feat(web): 统一用量筛选草稿与应用状态

This commit is contained in:
tbphp committed 2026-07-28 22:42:32 +08:00
1 parent 255e434d0f
commit e4e9220b70
7 files changed
+177 -51

No files matched your search

+37 -11
View File
@@ -142,7 +142,7 @@ describe('UsageTab', () => {
)
})
it('updates range immediately while Group/model remain an explicit Apply/Reset draft', async () => {
it('keeps every filter as one draft and applies range, Group, and model atomically', async () => {
const api = new UsageApi([usageReport(), usageReport(), usageReport(), usageReport()])
const { router, wrapper } = await mountUsage(
api,
@@ -150,27 +150,44 @@ describe('UsageTab', () => {
)
await wrapper.get('[data-test="usage-range"]').setValue('30d')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(
'/monitor?tab=usage&range=30d&group_id=7&model=gpt-upstream',
)
await wrapper.get('[data-test="usage-group"]').setValue('')
await wrapper.get('[data-test="usage-model"]').setValue('claude-upstream')
expect(router.currentRoute.value.fullPath).toContain('model=gpt-upstream')
expect(router.currentRoute.value.fullPath).toBe(
'/monitor?tab=usage&range=24h&group_id=7&model=gpt-upstream',
)
expect(api.requests.filter(({ path }) => path.startsWith('/api/usage?'))).toHaveLength(1)
expect(wrapper.get('[data-test="usage-filter-dirty"]').text()).toContain('not applied')
const appliedBefore = wrapper.get('[data-test="usage-applied-filters"]').text()
expect(appliedBefore).toContain('Last 24 hours')
expect(appliedBefore).toContain('gpt-upstream')
expect(appliedBefore).not.toContain('claude-upstream')
await wrapper.get('[data-test="usage-filter-form"]').trigger('submit')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(
'/monitor?tab=usage&range=30d&model=claude-upstream',
)
expect(api.requests.filter(({ path }) => path.startsWith('/api/usage?'))).toHaveLength(2)
expect(wrapper.find('[data-test="usage-filter-dirty"]').exists()).toBe(false)
await wrapper.get('[data-test="usage-reset"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/monitor?tab=usage&range=30d')
expect(router.currentRoute.value.fullPath).toBe(
'/monitor?tab=usage&range=30d&model=claude-upstream',
)
expect(api.requests.filter(({ path }) => path.startsWith('/api/usage?'))).toHaveLength(2)
expect(wrapper.get<HTMLSelectElement>('[data-test="usage-range"]').element.value).toBe('24h')
expect(wrapper.get<HTMLInputElement>('[data-test="usage-model"]').element.value).toBe('')
expect(wrapper.get('[data-test="usage-applied-filters"]').text()).toContain('claude-upstream')
await wrapper.get('[data-test="usage-filter-form"]').trigger('submit')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/monitor?tab=usage&range=24h')
expect(api.requests.filter(({ path }) => path.startsWith('/api/usage?'))).toHaveLength(3)
})
it('preserves an unapplied Group/model draft when only range changes', async () => {
const api = new UsageApi([usageReport(), usageReport()])
it('preserves an unapplied complete draft on Refresh and separates observed/refreshed time', async () => {
const api = new UsageApi([usageReport(), usageReport({ observed_at: '2026-07-27T04:30:00Z' })])
const { router, wrapper } = await mountUsage(
api,
'/monitor?tab=usage&range=24h&group_id=7&model=gpt-upstream',
@@ -179,15 +196,24 @@ describe('UsageTab', () => {
await wrapper.get('[data-test="usage-group"]').setValue('')
await wrapper.get('[data-test="usage-model"]').setValue('draft-upstream')
await wrapper.get('[data-test="usage-range"]').setValue('30d')
await wrapper.get('[data-test="usage-refresh"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(
'/monitor?tab=usage&range=30d&group_id=7&model=gpt-upstream',
'/monitor?tab=usage&range=24h&group_id=7&model=gpt-upstream',
)
expect(api.requests.filter(({ path }) => path.startsWith('/api/usage?'))).toHaveLength(2)
expect(wrapper.get<HTMLSelectElement>('[data-test="usage-range"]').element.value).toBe('30d')
expect(wrapper.get<HTMLSelectElement>('[data-test="usage-group"]').element.value).toBe('')
expect(wrapper.get<HTMLInputElement>('[data-test="usage-model"]').element.value).toBe(
'draft-upstream',
)
expect(wrapper.get('[data-test="usage-observed-at"]').attributes('datetime')).toBe(
'2026-07-27T04:30:00Z',
)
expect(wrapper.get('[data-test="usage-refreshed-at"]').attributes('datetime')).not.toBe(
'2026-07-27T04:30:00Z',
)
})
it('degrades a failed Group option query to a usable positive-ID input', async () => {
+98 -21
View File
@@ -16,6 +16,7 @@ import { useApiClient } from '@/api/client-context'
import { listGroups } from '@/api/control/groups'
import { getUsageReport, type UsageAggregateDto, type UsageFilters } from '@/api/control/usage'
import { controlQueryKeys } from '@/app/query-keys'
import { useUnsavedChanges } from '@/app/unsaved-changes'
import AppButton from '@/components/ui/AppButton.vue'
import DataTable from '@/components/ui/DataTable.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
@@ -56,11 +57,30 @@ const usageQuery = useQuery({
})
const report = computed(() => usageQuery.data.value)
const hasData = computed(() => (report.value?.summary.request_count ?? 0) > 0)
watch([() => appliedFilters.value.group_id, () => appliedFilters.value.model], () => {
draft.value = createUsageFilterDraft(appliedFilters.value)
filterErrors.value = {}
const draftDirty = computed(() => {
const appliedDraft = createUsageFilterDraft(appliedFilters.value)
return (
draft.value.range !== appliedDraft.range ||
draft.value.group_id !== appliedDraft.group_id ||
draft.value.model !== appliedDraft.model
)
})
const lastRefreshedAt = computed(() =>
usageQuery.dataUpdatedAt.value > 0 ? new Date(usageQuery.dataUpdatedAt.value) : null,
)
const unsavedChanges = useUnsavedChanges(draftDirty)
watch(
[
() => appliedFilters.value.range,
() => appliedFilters.value.group_id,
() => appliedFilters.value.model,
],
() => {
draft.value = createUsageFilterDraft(appliedFilters.value)
filterErrors.value = {}
},
)
function formatCount(value: number): string {
return new Intl.NumberFormat(locale.value).format(value)
@@ -81,27 +101,21 @@ function groupLabel(groupID: number): string {
return t('monitor.usage.filters.deletedOrUnknown', { id: groupID })
}
function filterError(field: keyof UsageFilterDraft): string | undefined {
function filterError(field: keyof UsageFilterErrors): string | undefined {
const key = filterErrors.value[field]
return key ? t(key) : undefined
}
async function changeRange(event: Event): Promise<void> {
const range = (event.target as HTMLSelectElement).value === '30d' ? '30d' : '24h'
await navigate({ ...appliedFilters.value, range })
}
async function applyFilters(): Promise<void> {
const errors = validateUsageFilterDraft(draft.value)
filterErrors.value = errors
if (Object.keys(errors).length > 0) return
await navigate(applyUsageFilterDraft(appliedFilters.value.range, draft.value))
await unsavedChanges.runWithoutPrompt(() => navigate(applyUsageFilterDraft(draft.value)))
}
async function resetFilters(): Promise<void> {
function resetFilters(): void {
draft.value = resetUsageFilterDraft()
filterErrors.value = {}
await navigate({ range: appliedFilters.value.range })
}
async function navigate(filters: UsageFilters): Promise<void> {
@@ -119,12 +133,7 @@ async function navigate(filters: UsageFilters): Promise<void> {
>
<div class="usage-filter-grid">
<FormField id="usage-range" :label="t('monitor.usage.filters.range')">
<select
id="usage-range"
data-test="usage-range"
:value="appliedFilters.range"
@change="changeRange"
>
<select id="usage-range" v-model="draft.range" data-test="usage-range">
<option value="24h">{{ t('monitor.usage.filters.range24h') }}</option>
<option value="30d">{{ t('monitor.usage.filters.range30d') }}</option>
</select>
@@ -207,6 +216,7 @@ async function navigate(filters: UsageFilters): Promise<void> {
data-test="usage-refresh"
type="button"
variant="secondary"
:busy="usageQuery.isFetching.value"
@click="usageQuery.refetch()"
>
<RefreshCw :size="16" aria-hidden="true" />{{ t('monitor.usage.filters.refresh') }}
@@ -218,6 +228,30 @@ async function navigate(filters: UsageFilters): Promise<void> {
</div>
</form>
<section class="usage-applied" data-test="usage-applied-filters">
<strong>{{ t('monitor.usage.filters.applied') }}</strong>
<span class="usage-filter-chip">
{{
appliedFilters.range === '30d'
? t('monitor.usage.filters.range30d')
: t('monitor.usage.filters.range24h')
}}
</span>
<span class="usage-filter-chip">
{{
appliedFilters.group_id === undefined
? t('monitor.usage.filters.anyGroup')
: groupLabel(appliedFilters.group_id)
}}
</span>
<span class="usage-filter-chip">
{{ appliedFilters.model ?? t('monitor.usage.filters.anyModel') }}
</span>
</section>
<InlineFeedback v-if="draftDirty" data-test="usage-filter-dirty" tone="info">
{{ t('monitor.usage.filters.dirty') }}
</InlineFeedback>
<InlineFeedback
v-if="groupsQuery.isError.value"
data-test="usage-group-options-failed"
@@ -256,6 +290,21 @@ async function navigate(filters: UsageFilters): Promise<void> {
@retry="usageQuery.refetch()"
/>
<section class="usage-freshness" data-test="usage-freshness">
<p>
<strong>{{ t('monitor.usage.filters.observedAt') }}</strong>
<time data-test="usage-observed-at" :datetime="report.observed_at">
{{ report.observed_at }}
</time>
</p>
<p v-if="lastRefreshedAt">
<strong>{{ t('monitor.usage.filters.refreshedAt') }}</strong>
<time data-test="usage-refreshed-at" :datetime="lastRefreshedAt.toISOString()">
{{ lastRefreshedAt.toISOString() }}
</time>
</p>
</section>
<EmptyState
v-if="!hasData"
data-test="usage-empty"
@@ -606,7 +655,9 @@ async function navigate(filters: UsageFilters): Promise<void> {
}
.usage-filter-form,
.usage-scope {
.usage-scope,
.usage-applied,
.usage-freshness {
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
background: var(--color-surface);
@@ -639,7 +690,9 @@ async function navigate(filters: UsageFilters): Promise<void> {
.usage-filter-actions,
.usage-scope,
.usage-heading {
.usage-heading,
.usage-applied,
.usage-freshness {
display: flex;
min-width: 0;
align-items: center;
@@ -651,6 +704,30 @@ async function navigate(filters: UsageFilters): Promise<void> {
align-items: flex-start;
}
.usage-applied {
box-shadow: none;
}
.usage-filter-chip {
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface-secondary);
padding: var(--space-1) var(--space-2);
font-size: 0.8125rem;
}
.usage-freshness {
justify-content: space-between;
box-shadow: none;
}
.usage-freshness p {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 0;
}
.usage-scope > svg {
flex: 0 0 auto;
color: var(--color-primary);
+21 -12
View File
@@ -11,6 +11,7 @@ describe('usage filters', () => {
const applied = { range: '30d' as const, group_id: 7, model: 'provider/model:Exact' }
expect(createUsageFilterDraft(applied)).toEqual({
range: '30d',
group_id: '7',
model: 'provider/model:Exact',
})
@@ -21,28 +22,36 @@ describe('usage filters', () => {
expect(
parseAppliedUsageFilters({ tab: 'usage', range: '30d', group_id: '7', model: 'gpt-5' }),
).toEqual({ range: '30d', group_id: 7, model: 'gpt-5' })
expect(applyUsageFilterDraft('24h', { group_id: '8', model: 'provider/model:Exact' })).toEqual({
range: '24h',
group_id: 8,
model: 'provider/model:Exact',
})
expect(
applyUsageFilterDraft({
range: '24h',
group_id: '8',
model: 'provider/model:Exact',
}),
).toEqual({ range: '24h', group_id: 8, model: 'provider/model:Exact' })
})
it('resets draft-only filters without changing the selected range', () => {
expect(resetUsageFilterDraft()).toEqual({ group_id: '', model: '' })
expect(applyUsageFilterDraft('30d', resetUsageFilterDraft())).toEqual({ range: '30d' })
it('resets the complete draft to the canonical default without applying it', () => {
expect(resetUsageFilterDraft()).toEqual({ range: '24h', group_id: '', model: '' })
expect(applyUsageFilterDraft(resetUsageFilterDraft())).toEqual({ range: '24h' })
})
it('reports ASCII and Unicode control characters without retaining unsafe values in URL state', () => {
expect(validateUsageFilterDraft({ group_id: '0', model: ' leading-model' })).toEqual({
expect(
validateUsageFilterDraft({
range: '30d',
group_id: '0',
model: ' leading-model',
}),
).toEqual({
group_id: 'monitor.usage.errors.positiveId',
model: 'monitor.usage.errors.model',
})
expect(parseAppliedUsageFilters({ range: '24h', model: 'gpt-\u00855.6' })).toEqual({
range: '24h',
})
expect(validateUsageFilterDraft({ group_id: '', model: 'gpt-\u00855.6' })).toEqual({
model: 'monitor.usage.errors.model',
})
expect(
validateUsageFilterDraft({ range: '24h', group_id: '', model: 'gpt-\u00855.6' }),
).toEqual({ model: 'monitor.usage.errors.model' })
})
})
+6 -7
View File
@@ -1,13 +1,14 @@
import type { UsageFilters } from '@/api/control/usage'
export interface UsageFilterDraft {
range: UsageFilters['range']
group_id: string
model: string
}
export type UsageFilterErrors = Partial<Record<keyof UsageFilterDraft, string>>
export type UsageFilterErrors = Partial<Record<Exclude<keyof UsageFilterDraft, 'range'>, string>>
const emptyDraft = (): UsageFilterDraft => ({ group_id: '', model: '' })
const emptyDraft = (): UsageFilterDraft => ({ range: '24h', group_id: '', model: '' })
export function normalizeUsageRange(raw: unknown): UsageFilters['range'] {
return raw === '30d' ? '30d' : '24h'
@@ -43,6 +44,7 @@ export function parseAppliedUsageFilters(query: Record<string, unknown>): UsageF
export function createUsageFilterDraft(filters: UsageFilters): UsageFilterDraft {
return {
...emptyDraft(),
range: filters.range,
group_id: filters.group_id === undefined ? '' : String(filters.group_id),
model: filters.model ?? '',
}
@@ -52,11 +54,8 @@ export function resetUsageFilterDraft(): UsageFilterDraft {
return emptyDraft()
}
export function applyUsageFilterDraft(
range: UsageFilters['range'],
draft: UsageFilterDraft,
): UsageFilters {
const filters: UsageFilters = { range: normalizeUsageRange(range) }
export function applyUsageFilterDraft(draft: UsageFilterDraft): UsageFilters {
const filters: UsageFilters = { range: normalizeUsageRange(draft.range) }
const groupID = normalizeUsageGroupID(draft.group_id)
const model = normalizeUsageModel(draft.model)
if (groupID !== undefined) filters.group_id = groupID
+5
View File
@@ -114,6 +114,11 @@ export default {
refresh: 'Refresh',
apply: 'Apply',
reset: 'Reset',
applied: 'Applied filters',
anyModel: 'All upstream models',
dirty: 'Filter changes are not applied yet.',
observedAt: 'Backend observed',
refreshedAt: 'Last refreshed',
},
errors: {
positiveId: 'Enter a valid positive Group ID.',
+5
View File
@@ -116,6 +116,11 @@ export default {
refresh: '更新',
apply: '適用',
reset: 'リセット',
applied: '適用中のフィルター',
anyModel: 'すべてのアップストリームモデル',
dirty: 'フィルターの変更はまだ適用されていません。',
observedAt: 'バックエンド観測時刻',
refreshedAt: '最終更新時刻',
},
errors: {
positiveId: '有効な正の Group ID を入力してください。',
+5
View File
@@ -112,6 +112,11 @@ export default {
refresh: '刷新',
apply: '应用',
reset: '重置',
applied: '已应用筛选',
anyModel: '全部上游模型',
dirty: '筛选条件的更改尚未应用。',
observedAt: '后端观测时间',
refreshedAt: '最近刷新时间',
},
errors: {
positiveId: '请输入有效的正整数 Group ID。',