From d0db2134df9e9a75ba4e2cecdaf6306dabda4fe7 Mon Sep 17 00:00:00 2001 From: tbphp Date: Wed, 29 Jul 2026 02:06:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E9=87=8D=E6=8E=92=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E8=BF=90=E8=A1=8C=E4=BB=BB=E5=8A=A1=E5=B1=82=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/features/home/GroupCard.test.ts | 77 +++++++ web/src/features/home/GroupCard.vue | 18 +- web/src/features/home/HomeView.test.ts | 19 ++ web/src/features/home/HomeView.vue | 212 ++++++++++-------- .../features/home/UsageSummaryCard.test.ts | 4 +- web/src/features/home/UsageSummaryCard.vue | 23 +- web/src/features/home/home-model.ts | 3 +- web/src/i18n/locales/en-US/core.ts | 6 + web/src/i18n/locales/ja-JP/core.ts | 7 + web/src/i18n/locales/zh-CN/core.ts | 6 + 10 files changed, 274 insertions(+), 101 deletions(-) create mode 100644 web/src/features/home/GroupCard.test.ts diff --git a/web/src/features/home/GroupCard.test.ts b/web/src/features/home/GroupCard.test.ts new file mode 100644 index 00000000..d991314c --- /dev/null +++ b/web/src/features/home/GroupCard.test.ts @@ -0,0 +1,77 @@ +import { mount } from '@vue/test-utils' + +import type { GroupSummary, HealthGroupDto } from '@/api/control/types' +import { createTestAppI18n } from '@/test/i18n' + +import GroupCard from './GroupCard.vue' + +const longName = `Primary-${'segment-'.repeat(20)}Group` +const group: GroupSummary = { + id: 9, + name: longName, + upstream_url: `https://${'long-host-'.repeat(12)}example.com/v1`, + protocols: ['openai', 'anthropic'], + models: [], + enabled: true, + key_count: 2, +} +const health: HealthGroupDto = { + id: 9, + name: longName, + enabled: true, + counts: { + total: 2, + available: 0, + cooldown: 2, + blacklisted: 0, + disabled: 0, + }, +} + +function mountCard(groupValue = group, healthValue: HealthGroupDto | undefined = health) { + const appI18n = createTestAppI18n(undefined, 'en-US') + return mount(GroupCard, { + props: { + group: groupValue, + health: healthValue, + }, + global: { + plugins: [appI18n.plugin], + stubs: { + RouterLink: { + props: ['to'], + template: '', + }, + }, + }, + }) +} + +describe('GroupCard', () => { + it('preserves long identity text and translates protocol labels', () => { + const wrapper = mountCard() + + expect(wrapper.get('h3').text()).toBe(longName) + expect(wrapper.text()).toContain('long-host-') + expect(wrapper.text()).toContain('OpenAI') + expect(wrapper.text()).toContain('Anthropic') + expect(wrapper.get('[href="/groups/9?tab=keys"]').text()).toContain('View details') + expect(wrapper.get('[href="/import?mode=existing&group_id=9"]').text()).toContain('Import keys') + }) + + it('explains no-model and no-available-key serviceability separately', async () => { + const wrapper = mountCard() + expect(wrapper.get('[data-test="group-service-reason"]').text()).toContain('no models') + + await wrapper.setProps({ + group: { + ...group, + models: [{ id: 'gpt-real', alias: '' }], + }, + }) + + expect(wrapper.get('[data-test="group-service-reason"]').text()).toMatch( + /no upstream key is currently available/i, + ) + }) +}) diff --git a/web/src/features/home/GroupCard.vue b/web/src/features/home/GroupCard.vue index 85657dad..07076660 100644 --- a/web/src/features/home/GroupCard.vue +++ b/web/src/features/home/GroupCard.vue @@ -20,6 +20,13 @@ const serviceLabel = computed(() => { if (serviceable.value === undefined) return t('home.unknown') return serviceable.value ? t('home.serviceable') : t('home.unavailable') }) +const serviceReason = computed(() => { + if (!props.group.enabled) return t('home.groupDisabledReason') + if (props.group.models.length === 0) return t('home.noModelsReason') + if (!props.health) return t('home.healthUnknownReason') + if (props.health.counts.available === 0) return t('home.noAvailableKeysReason') + return '' +}) + +
+ + + +
@@ -216,14 +233,29 @@ const observedAt = computed(() => { display: grid; gap: var(--space-8); } +.operational-overview, +.connection-section { + display: grid; + gap: var(--space-4); +} +.operational-overview__header h2, +.operational-overview__header p { + margin: 0; +} +.operational-overview__header h2 { + font-size: 1.25rem; +} +.operational-overview__header p { + margin-top: var(--space-1); + color: var(--color-text-muted); +} .home-overview { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 0.9fr); gap: var(--space-5); align-items: start; } -.service-card, -.connection-section { +.service-card { display: grid; gap: var(--space-5); } @@ -236,7 +268,7 @@ const observedAt = computed(() => { justify-content: space-between; gap: var(--space-3); } -.section-heading h2, +.section-heading h3, .groups-section h2 { margin: 0; font-size: 1.125rem; diff --git a/web/src/features/home/UsageSummaryCard.test.ts b/web/src/features/home/UsageSummaryCard.test.ts index 3ae95ec8..c983b9b2 100644 --- a/web/src/features/home/UsageSummaryCard.test.ts +++ b/web/src/features/home/UsageSummaryCard.test.ts @@ -137,7 +137,7 @@ describe('UsageSummaryCard', () => { ) }) - it('always renders three known zero quality counts when persisted usage has no quality gaps', async () => { + it('keeps known zero quality counts neutral when persisted usage has no quality gaps', async () => { const report = usageReport({ summary: { ...aggregate, @@ -163,7 +163,7 @@ describe('UsageSummaryCard', () => { for (const quality of ['missing', 'partial', 'unpriced']) { const status = wrapper.get(`[data-test="home-usage-quality-${quality}"]`) expect(status.text()).toContain('0') - expect(status.classes()).toContain('status-badge--success') + expect(status.classes()).toContain('status-badge--neutral') } expect(wrapper.get('[data-test="home-usage-tokens"]').text()).toContain('0') }) diff --git a/web/src/features/home/UsageSummaryCard.vue b/web/src/features/home/UsageSummaryCard.vue index c16a93d4..013480d9 100644 --- a/web/src/features/home/UsageSummaryCard.vue +++ b/web/src/features/home/UsageSummaryCard.vue @@ -14,6 +14,15 @@ import StatusBadge from '@/components/ui/StatusBadge.vue' import SurfaceCard from '@/components/ui/SurfaceCard.vue' import { formatEstimatedUSD } from '@/features/usage/estimated-cost' +withDefaults( + defineProps<{ + headingAs?: 'h2' | 'h3' + }>(), + { + headingAs: 'h2', + }, +) + const filters = { range: '24h' } as const const client = useApiClient() const { locale, t } = useI18n() @@ -71,7 +80,7 @@ function formatEstimatedCost(aggregate: UsageAggregateDto): string {

{{ t('home.usage.eyebrow') }}

-

{{ t('home.usage.title') }}

+ {{ t('home.usage.title') }}

{{ t('home.usage.description') }}

{{ t('home.usage.quality.missing') }} {{ formatCount(report.summary.usage_missing_count) }} {{ t('home.usage.quality.partial') }} {{ formatCount(report.summary.partial_count) }} {{ t('home.usage.quality.unpriced') }} {{ formatCount(report.summary.unpriced_request_count) }} @@ -191,14 +200,14 @@ function formatEstimatedCost(aggregate: UsageAggregateDto): string { justify-content: space-between; gap: var(--space-4); } -.usage-summary-card__header h2, +.usage-summary-card__header :is(h2, h3), .usage-summary-card__header p { margin: 0; } -.usage-summary-card__header h2 { +.usage-summary-card__header :is(h2, h3) { font-size: 1.125rem; } -.usage-summary-card__header h2 + p { +.usage-summary-card__header :is(h2, h3) + p { margin-top: var(--space-1); color: var(--color-text-muted); } diff --git a/web/src/features/home/home-model.ts b/web/src/features/home/home-model.ts index 32f3cbbd..9dc186e4 100644 --- a/web/src/features/home/home-model.ts +++ b/web/src/features/home/home-model.ts @@ -69,8 +69,9 @@ export function buildConnectionSnippet(input: ConnectionSnippetInput): Connectio } export function isGroupServiceable(group: GroupSummary, counts?: KeyCounts): boolean | undefined { + if (!group.enabled || group.models.length === 0) return false if (!counts) return undefined - return group.enabled && group.models.length > 0 && counts.available > 0 + return counts.available > 0 } export function selectInitialAccessKey(keys: AccessKeyOptionDto[]): number | undefined { diff --git a/web/src/i18n/locales/en-US/core.ts b/web/src/i18n/locales/en-US/core.ts index 197057e9..4789b13d 100644 --- a/web/src/i18n/locales/en-US/core.ts +++ b/web/src/i18n/locales/en-US/core.ts @@ -76,6 +76,8 @@ export default { title: 'Operational overview', description: 'Review real runtime health, Group serviceability, and client connection settings.', + operationalOverview: 'Operational Overview', + operationalOverviewDescription: 'Runtime health and persisted usage from the last 24 hours.', service: 'Management service', online: 'Online', healthUnavailable: 'The service responded, but health is currently unavailable.', @@ -103,6 +105,10 @@ export default { serviceable: 'Serviceable', unavailable: 'Unavailable', unknown: 'Status unknown', + groupDisabledReason: 'This Group is disabled.', + noModelsReason: 'This Group has no models configured.', + healthUnknownReason: 'Current upstream-key availability is unknown.', + noAvailableKeysReason: 'No upstream key is currently available.', models: '{count} models', keys: '{count} keys', details: 'View details', diff --git a/web/src/i18n/locales/ja-JP/core.ts b/web/src/i18n/locales/ja-JP/core.ts index 48db1e1c..7049c196 100644 --- a/web/src/i18n/locales/ja-JP/core.ts +++ b/web/src/i18n/locales/ja-JP/core.ts @@ -76,6 +76,9 @@ export default { home: { title: '運用概要', description: '実際のランタイム状態、Group のサービス可否、クライアント接続設定を確認します。', + operationalOverview: '運用概要', + operationalOverviewDescription: + 'ランタイムの状態と過去 24 時間に永続化された使用量をまとめます。', service: '管理サービス', online: 'オンライン', healthUnavailable: 'サービスは応答しましたが、ヘルス状態を取得できません。', @@ -102,6 +105,10 @@ export default { serviceable: 'サービス可能', unavailable: 'サービス不可', unknown: '状態不明', + groupDisabledReason: 'この Group は無効です。', + noModelsReason: 'この Group にはモデルが設定されていません。', + healthUnknownReason: '現在のアップストリームキーの可用性は不明です。', + noAvailableKeysReason: '現在利用可能なアップストリームキーがありません。', models: '{count} モデル', keys: '{count} キー', details: '詳細を見る', diff --git a/web/src/i18n/locales/zh-CN/core.ts b/web/src/i18n/locales/zh-CN/core.ts index 61c7208e..65f2a814 100644 --- a/web/src/i18n/locales/zh-CN/core.ts +++ b/web/src/i18n/locales/zh-CN/core.ts @@ -75,6 +75,8 @@ export default { home: { title: '运行概览', description: '查看真实运行健康状态、Group 服务能力和客户端连接配置。', + operationalOverview: '运行概览', + operationalOverviewDescription: '汇总运行健康状态与最近 24 小时已持久化用量。', service: '管理服务', online: '在线', healthUnavailable: '服务已响应,但健康状态暂不可用。', @@ -100,6 +102,10 @@ export default { serviceable: '可服务', unavailable: '不可服务', unknown: '状态未知', + groupDisabledReason: '该 Group 已停用。', + noModelsReason: '该 Group 尚未配置模型。', + healthUnknownReason: '当前上游密钥可用性未知。', + noAvailableKeysReason: '当前没有可用的上游密钥。', models: '{count} 个模型', keys: '{count} 个密钥', details: '查看详情',