forked from EduCraft/curriculum-project-hub
feat: 更新容量策略页面的维度标签和逻辑分组
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
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 PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
@@ -9,14 +9,15 @@
|
|||||||
|
|
||||||
const slug = $derived(page.params.slug ?? '');
|
const slug = $derived(page.params.slug ?? '');
|
||||||
|
|
||||||
|
// Friendlier, user-facing labels. No spec jargon (墙钟 → 运行时长, etc.).
|
||||||
const DIMENSION_LABELS: Record<CapacityDimension, string> = {
|
const DIMENSION_LABELS: Record<CapacityDimension, string> = {
|
||||||
requestRate: 'HTTP 请求速率',
|
requestRate: '请求速率',
|
||||||
requestBodySize: '请求体大小',
|
requestBodySize: '请求体大小',
|
||||||
agentConcurrency: '智能体并发',
|
agentConcurrency: '并发数',
|
||||||
admissionQueueLength: '接纳队列长度',
|
admissionQueueLength: '队列长度',
|
||||||
admissionQueueWait: '接纳队列等待',
|
admissionQueueWait: '队列等待',
|
||||||
fileSize: '单文件大小',
|
fileSize: '单文件大小',
|
||||||
attachmentCount: '每消息附件数',
|
attachmentCount: '附件数',
|
||||||
archiveExpansion: '归档展开',
|
archiveExpansion: '归档展开',
|
||||||
projectStorage: '项目存储',
|
projectStorage: '项目存储',
|
||||||
organizationStorage: '组织存储',
|
organizationStorage: '组织存储',
|
||||||
@@ -25,18 +26,28 @@
|
|||||||
teamCount: '团队数',
|
teamCount: '团队数',
|
||||||
folderCount: '文件夹数',
|
folderCount: '文件夹数',
|
||||||
sessionCount: '会话数',
|
sessionCount: '会话数',
|
||||||
runWallTime: '单次运行墙钟',
|
runWallTime: '运行时长',
|
||||||
runTurns: '单次运行轮次',
|
runTurns: '对话轮次',
|
||||||
runToolCalls: '单次运行工具调用',
|
runToolCalls: '工具调用数',
|
||||||
toolWallTime: '工具墙钟',
|
toolWallTime: '工具执行时长',
|
||||||
runOutputSize: '运行输出大小',
|
runOutputSize: '输出大小',
|
||||||
processMemory: '进程内存',
|
processMemory: '内存',
|
||||||
processCpu: '进程 CPU',
|
processCpu: 'CPU',
|
||||||
processCount: '进程数',
|
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 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 loading = $state(true);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let error = $state<string | null>(null);
|
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() {
|
async function save() {
|
||||||
if (!view) return;
|
if (!view || hasErrors) return;
|
||||||
saving = true;
|
saving = true;
|
||||||
const limits: Partial<Record<CapacityDimension, number | null>> = {};
|
const limits: Partial<Record<CapacityDimension, number | null>> = {};
|
||||||
for (const row of view.dimensions) {
|
for (const row of view.dimensions) {
|
||||||
const raw = drafts[row.dimension];
|
const text = draftText(row.dimension);
|
||||||
if (raw === undefined || raw.trim() === '') {
|
limits[row.dimension] = text === '' ? null : Number.parseInt(text, 10);
|
||||||
limits[row.dimension] = null;
|
|
||||||
} else {
|
|
||||||
const n = Number.parseInt(raw.trim(), 10);
|
|
||||||
limits[row.dimension] = Number.isFinite(n) ? n : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
view = await api.setCapacityPolicy(slug, { limits });
|
view = await api.setCapacityPolicy(slug, { limits });
|
||||||
@@ -89,16 +132,19 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader title="容量策略" description="平台上限不可突破;组织可设更低限制(取两者较低值)。">
|
<PageHeader
|
||||||
|
title="容量策略"
|
||||||
|
description="平台上限不可突破。组织可在此设更低限制,未设置时按平台上限执行。"
|
||||||
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<button class="saas-btn-primary" disabled={saving || !view} onclick={save}>
|
<button class="saas-btn-primary" disabled={saving || !view || hasErrors} onclick={save}>
|
||||||
{saving ? '保存中…' : '保存'}
|
{saving ? '保存中…' : '保存'}
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<p class="saas-muted mb-4">
|
<p class="saas-muted mb-6">
|
||||||
未配置平台上限的维度暂不可设置组织限制(见 ADR-0022 / <code>LayeredLimit.Valid</code>)。
|
未配置平台上限的维度暂不可设置组织限制。输入框留空即沿用平台上限。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -107,52 +153,158 @@
|
|||||||
<ErrorBanner message={error} onretry={load} />
|
<ErrorBanner message={error} onretry={load} />
|
||||||
{:else if view}
|
{:else if view}
|
||||||
{#if view.dimensions.length === 0}
|
{#if view.dimensions.length === 0}
|
||||||
<EmptyState title="暂无容量维度" description="容量维度由 spec 定义。" />
|
<EmptyState title="暂无容量维度" description="容量维度由平台定义。" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="saas-card overflow-hidden">
|
{@const policy = view}
|
||||||
<table class="data-table">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<thead>
|
{#each GROUPS as group}
|
||||||
<tr>
|
{@const rows = group.dims
|
||||||
<th>维度</th>
|
.map((d) => policy.dimensions.find((r) => r.dimension === d))
|
||||||
<th>平台上限</th>
|
.filter((r): r is CapacityDimensionRow => r !== undefined)}
|
||||||
<th>组织限制</th>
|
{#if rows.length > 0}
|
||||||
<th>有效值</th>
|
<section class="saas-card">
|
||||||
</tr>
|
<header class="group-header">
|
||||||
</thead>
|
<h3 class="group-title">{group.title}</h3>
|
||||||
<tbody>
|
<span class="group-count">{rows.length} 项</span>
|
||||||
{#each view.dimensions as row}
|
</header>
|
||||||
<tr>
|
<div class="dim-list">
|
||||||
<td class="font-medium">{DIMENSION_LABELS[row.dimension] ?? row.dimension}</td>
|
{#each rows as row (row.dimension)}
|
||||||
<td class="tabular-nums">
|
{@const err = draftError(row)}
|
||||||
{#if row.platformCeiling === null}
|
{@const eff = liveEffective(row)}
|
||||||
<span class="text-surface-500">未配置</span>
|
{@const lowered = isLowered(row)}
|
||||||
{:else}
|
{@const disabled = row.platformCeiling === null}
|
||||||
{row.platformCeiling}
|
<div class="dim-row" class:dim-row-error={err !== null}>
|
||||||
{/if}
|
<div class="dim-label">{DIMENSION_LABELS[row.dimension] ?? row.dimension}</div>
|
||||||
</td>
|
<div class="dim-ceiling">
|
||||||
<td>
|
{#if disabled}
|
||||||
<input
|
<span class="text-surface-400">未配置</span>
|
||||||
class="saas-input w-32"
|
{:else}
|
||||||
type="number"
|
<span class="tabular-nums">平台 ≤ {row.platformCeiling}</span>
|
||||||
min="1"
|
{/if}
|
||||||
placeholder="(用平台上限)"
|
</div>
|
||||||
disabled={row.platformCeiling === null}
|
<input
|
||||||
bind:value={drafts[row.dimension]}
|
class="saas-input dim-input"
|
||||||
/>
|
type="number"
|
||||||
</td>
|
placeholder="用平台值"
|
||||||
<td class="tabular-nums text-surface-700">
|
disabled={disabled}
|
||||||
{row.platformCeiling === null
|
bind:value={drafts[row.dimension]}
|
||||||
? '—'
|
/>
|
||||||
: drafts[row.dimension] && drafts[row.dimension] !== ''
|
<div class="dim-effective">
|
||||||
? Math.min(row.platformCeiling, Number.parseInt(drafts[row.dimension]!, 10) || row.platformCeiling)
|
{#if eff === null}
|
||||||
: row.platformCeiling}
|
<span class="text-surface-400">—</span>
|
||||||
</td>
|
{:else if lowered}
|
||||||
</tr>
|
<span class="saas-badge saas-badge-primary tabular-nums">有效 {eff}</span>
|
||||||
{/each}
|
{:else}
|
||||||
</tbody>
|
<span class="saas-badge saas-badge-neutral tabular-nums">有效 {eff}</span>
|
||||||
</table>
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if err !== null}
|
||||||
|
<div class="dim-error">{err}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState title="容量数据不可用" description="无法加载容量策略。" />
|
<EmptyState title="容量数据不可用" description="无法加载容量策略。" />
|
||||||
{/if}
|
{/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>
|
||||||
|
|||||||
Reference in New Issue
Block a user