feat: 更新容量策略页面的维度标签和逻辑分组

This commit is contained in:
2026-07-14 22:54:37 +08:00
parent 080efa70c5
commit 153d74d033
@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type CapacityDimension, type CapacityPolicyView } from '$lib/api';
import { api, type CapacityDimension, type CapacityDimensionRow, type CapacityPolicyView } from '$lib/api';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
@@ -9,14 +9,15 @@
const slug = $derived(page.params.slug ?? '');
// Friendlier, user-facing labels. No spec jargon (墙钟 → 运行时长, etc.).
const DIMENSION_LABELS: Record<CapacityDimension, string> = {
requestRate: 'HTTP 请求速率',
requestRate: '请求速率',
requestBodySize: '请求体大小',
agentConcurrency: '智能体并发',
admissionQueueLength: '接纳队列长度',
admissionQueueWait: '接纳队列等待',
agentConcurrency: '并发',
admissionQueueLength: '队列长度',
admissionQueueWait: '队列等待',
fileSize: '单文件大小',
attachmentCount: '每消息附件数',
attachmentCount: '附件数',
archiveExpansion: '归档展开',
projectStorage: '项目存储',
organizationStorage: '组织存储',
@@ -25,18 +26,28 @@
teamCount: '团队数',
folderCount: '文件夹数',
sessionCount: '会话数',
runWallTime: '单次运行墙钟',
runTurns: '单次运行轮次',
runToolCalls: '单次运行工具调用',
toolWallTime: '工具墙钟',
runOutputSize: '运行输出大小',
processMemory: '进程内存',
processCpu: '进程 CPU',
runWallTime: '运行时长',
runTurns: '对话轮次',
runToolCalls: '工具调用',
toolWallTime: '工具执行时长',
runOutputSize: '输出大小',
processMemory: '内存',
processCpu: 'CPU',
processCount: '进程数',
};
// Logical groupings for higher information density.
const GROUPS: { title: string; dims: CapacityDimension[] }[] = [
{ title: 'HTTP 接入', dims: ['requestRate', 'requestBodySize'] },
{ title: '智能体运行', dims: ['agentConcurrency', 'runWallTime', 'runTurns', 'runToolCalls', 'toolWallTime', 'runOutputSize'] },
{ title: '接纳队列', dims: ['admissionQueueLength', 'admissionQueueWait'] },
{ title: '附件与存储', dims: ['fileSize', 'attachmentCount', 'archiveExpansion', 'projectStorage', 'organizationStorage'] },
{ title: '组织配额', dims: ['memberCount', 'projectCount', 'teamCount', 'folderCount', 'sessionCount'] },
{ title: '进程资源', dims: ['processMemory', 'processCpu', 'processCount'] },
];
let view = $state<CapacityPolicyView | null>(null);
let drafts = $state<Partial<Record<CapacityDimension, string>>>({});
let drafts = $state<Partial<Record<CapacityDimension, string | number>>>({});
let loading = $state(true);
let saving = $state(false);
let error = $state<string | null>(null);
@@ -57,18 +68,50 @@
}
}
function draftText(dim: CapacityDimension): string {
const raw = drafts[dim];
if (raw === undefined || raw === null) return '';
return String(raw).trim();
}
// Inline validation: surfaced as the user edits, not on submit. Returns null
// when the draft is empty (means "use platform ceiling") or valid.
function draftError(row: CapacityDimensionRow): string | null {
const text = draftText(row.dimension);
if (text === '') return null;
const n = Number.parseInt(text, 10);
if (!Number.isFinite(n) || n < 1) return '需为正整数';
if (row.platformCeiling !== null && n > row.platformCeiling) return `不得超过 ${row.platformCeiling}`;
return null;
}
// Live effective value: min(platform ceiling, draft). Reflects the draft
// before save so the user sees the outcome as they type.
function liveEffective(row: CapacityDimensionRow): number | null {
if (row.platformCeiling === null) return null;
const text = draftText(row.dimension);
if (text === '') return row.platformCeiling;
const n = Number.parseInt(text, 10);
if (!Number.isFinite(n) || n < 1) return row.platformCeiling;
return Math.min(row.platformCeiling, n);
}
function isLowered(row: CapacityDimensionRow): boolean {
const eff = liveEffective(row);
return eff !== null && eff < row.platformCeiling!;
}
const hasErrors = $derived(
view?.dimensions.some((row) => draftError(row) !== null) ?? false,
);
async function save() {
if (!view) return;
if (!view || hasErrors) return;
saving = true;
const limits: Partial<Record<CapacityDimension, number | null>> = {};
for (const row of view.dimensions) {
const raw = drafts[row.dimension];
if (raw === undefined || raw.trim() === '') {
limits[row.dimension] = null;
} else {
const n = Number.parseInt(raw.trim(), 10);
limits[row.dimension] = Number.isFinite(n) ? n : null;
}
const text = draftText(row.dimension);
limits[row.dimension] = text === '' ? null : Number.parseInt(text, 10);
}
try {
view = await api.setCapacityPolicy(slug, { limits });
@@ -89,16 +132,19 @@
});
</script>
<PageHeader title="容量策略" description="平台上限不可突破;组织可设更低限制(取两者较低值)。">
<PageHeader
title="容量策略"
description="平台上限不可突破。组织可在此设更低限制,未设置时按平台上限执行。"
>
{#snippet actions()}
<button class="saas-btn-primary" disabled={saving || !view} onclick={save}>
<button class="saas-btn-primary" disabled={saving || !view || hasErrors} onclick={save}>
{saving ? '保存中…' : '保存'}
</button>
{/snippet}
</PageHeader>
<p class="saas-muted mb-4">
未配置平台上限的维度暂不可设置组织限制(见 ADR-0022 / <code>LayeredLimit.Valid</code>
<p class="saas-muted mb-6">
未配置平台上限的维度暂不可设置组织限制。输入框留空即沿用平台上限
</p>
{#if loading}
@@ -107,52 +153,158 @@
<ErrorBanner message={error} onretry={load} />
{:else if view}
{#if view.dimensions.length === 0}
<EmptyState title="暂无容量维度" description="容量维度由 spec 定义。" />
<EmptyState title="暂无容量维度" description="容量维度由平台定义。" />
{:else}
<div class="saas-card overflow-hidden">
<table class="data-table">
<thead>
<tr>
<th>维度</th>
<th>平台上限</th>
<th>组织限制</th>
<th>有效值</th>
</tr>
</thead>
<tbody>
{#each view.dimensions as row}
<tr>
<td class="font-medium">{DIMENSION_LABELS[row.dimension] ?? row.dimension}</td>
<td class="tabular-nums">
{#if row.platformCeiling === null}
<span class="text-surface-500">未配置</span>
{@const policy = view}
<div class="grid gap-4 md:grid-cols-2">
{#each GROUPS as group}
{@const rows = group.dims
.map((d) => policy.dimensions.find((r) => r.dimension === d))
.filter((r): r is CapacityDimensionRow => r !== undefined)}
{#if rows.length > 0}
<section class="saas-card">
<header class="group-header">
<h3 class="group-title">{group.title}</h3>
<span class="group-count">{rows.length}</span>
</header>
<div class="dim-list">
{#each rows as row (row.dimension)}
{@const err = draftError(row)}
{@const eff = liveEffective(row)}
{@const lowered = isLowered(row)}
{@const disabled = row.platformCeiling === null}
<div class="dim-row" class:dim-row-error={err !== null}>
<div class="dim-label">{DIMENSION_LABELS[row.dimension] ?? row.dimension}</div>
<div class="dim-ceiling">
{#if disabled}
<span class="text-surface-400">未配置</span>
{:else}
{row.platformCeiling}
<span class="tabular-nums">平台 ≤ {row.platformCeiling}</span>
{/if}
</td>
<td>
</div>
<input
class="saas-input w-32"
class="saas-input dim-input"
type="number"
min="1"
placeholder="(用平台上限)"
disabled={row.platformCeiling === null}
placeholder="用平台值"
disabled={disabled}
bind:value={drafts[row.dimension]}
/>
</td>
<td class="tabular-nums text-surface-700">
{row.platformCeiling === null
? '—'
: drafts[row.dimension] && drafts[row.dimension] !== ''
? Math.min(row.platformCeiling, Number.parseInt(drafts[row.dimension]!, 10) || row.platformCeiling)
: row.platformCeiling}
</td>
</tr>
<div class="dim-effective">
{#if eff === null}
<span class="text-surface-400"></span>
{:else if lowered}
<span class="saas-badge saas-badge-primary tabular-nums">有效 {eff}</span>
{:else}
<span class="saas-badge saas-badge-neutral tabular-nums">有效 {eff}</span>
{/if}
</div>
{#if err !== null}
<div class="dim-error">{err}</div>
{/if}
</div>
{/each}
</div>
</section>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
{:else}
<EmptyState title="容量数据不可用" description="无法加载容量策略。" />
{/if}
<style>
.group-header {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-surface-200);
background: var(--color-surface-100);
}
.group-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-surface-800);
letter-spacing: 0.02em;
}
.group-count {
font-size: 0.6875rem;
color: var(--color-surface-500);
}
.dim-list {
display: flex;
flex-direction: column;
}
.dim-row {
display: grid;
grid-template-columns: minmax(5rem, 1fr) auto minmax(7rem, 8.5rem) 6.5rem;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
border-bottom: 1px solid var(--color-surface-100);
}
.dim-row:last-child {
border-bottom: none;
}
.dim-row-error {
background: color-mix(in oklab, var(--color-error-100) 40%, transparent);
}
.dim-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-surface-900);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dim-ceiling {
font-size: 0.6875rem;
color: var(--color-surface-500);
white-space: nowrap;
}
.dim-input {
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
text-align: right;
}
.dim-effective {
display: flex;
justify-content: flex-end;
}
.dim-effective > .saas-badge {
width: 100%;
justify-content: center;
}
.dim-error {
grid-column: 1 / -1;
font-size: 0.6875rem;
color: var(--color-error-700);
padding-bottom: 0.2rem;
}
@media (max-width: 480px) {
.dim-row {
grid-template-columns: 1fr 5rem;
grid-template-rows: auto auto auto;
row-gap: 0.25rem;
}
.dim-label {
grid-column: 1;
}
.dim-ceiling {
grid-column: 2;
text-align: right;
}
.dim-input {
grid-column: 1 / -1;
}
.dim-effective {
grid-column: 1 / -1;
justify-content: flex-start;
}
.dim-effective > .saas-badge {
width: auto;
justify-content: flex-start;
}
}
</style>