feat(web): 重排首页运行任务层级

This commit is contained in:
tbphp committed 2026-07-29 02:06:43 +08:00
1 parent 5fcf0b3726
commit d0db2134df
10 files changed
+274 -101

No files matched your search

+77
View File
@@ -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: '<a :href="to"><slot /></a>',
},
},
},
})
}
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,
)
})
})
+17 -1
View File
@@ -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 ''
})
</script>
<template>
@@ -37,10 +44,14 @@ const serviceLabel = computed(() => {
{{ group.enabled ? t('home.enabled') : t('home.disabled') }}
</StatusBadge>
<span v-for="protocol in group.protocols" :key="protocol" class="meta-tag">{{
protocol
t(`common.protocols.${protocol}`)
}}</span>
</div>
<p v-if="serviceReason" data-test="group-service-reason" class="group-card__reason">
{{ serviceReason }}
</p>
<div class="group-card__facts">
<span>{{ t('home.models', { count: group.models.length }) }}</span>
<span>{{ t('home.keys', { count: group.key_count }) }}</span>
@@ -80,6 +91,7 @@ const serviceLabel = computed(() => {
.group-card h3 {
margin: 0;
font-size: 1.0625rem;
overflow-wrap: anywhere;
}
.group-card__header p {
margin: var(--space-1) 0 0;
@@ -97,6 +109,10 @@ const serviceLabel = computed(() => {
.group-card__facts {
color: var(--color-text-muted);
}
.group-card__reason {
margin: 0;
color: var(--color-text-muted);
}
.group-card__facts span + span::before {
content: '·';
margin-right: var(--space-2);
+19
View File
@@ -102,6 +102,25 @@ async function mountHome(api: FakeApi, origin?: string, queryClient?: QueryClien
}
describe('HomeView', () => {
it('keeps the semantic task order Operational Overview, Groups, then Connection Setup', async () => {
const api = new FakeApi()
api.when('/api/groups').resolve([groupFixture])
api.when('/api/health').resolve(healthFixture)
api.when('/api/access-keys/options').resolve([accessKeyFixture])
const wrapper = await mountHome(api)
const sections = wrapper.findAll(
'[data-test="home-operational-overview"], [data-test="home-groups"], [data-test="home-connection"]',
)
expect(sections.map((section) => section.attributes('data-test'))).toEqual([
'home-operational-overview',
'home-groups',
'home-connection',
])
expect(sections[0]?.find('[data-test="home-usage-requests"]').exists()).toBe(true)
})
it('keeps Groups visible when Health fails', async () => {
const api = new FakeApi()
api.when('/api/groups').resolve([groupFixture])
+122 -90
View File
@@ -65,102 +65,86 @@ const observedAt = computed(() => {
<div class="home-page">
<PageHeader :title="t('home.title')" :description="t('home.description')" />
<section class="home-overview" :aria-labelledby="'service-heading'">
<SurfaceCard class="service-card">
<div class="section-heading">
<div>
<p class="eyebrow">{{ t('home.service') }}</p>
<h2 id="service-heading">{{ t('home.service') }}</h2>
</div>
<StatusBadge v-if="healthQuery.isSuccess.value" tone="success">
{{ t('home.online') }}
</StatusBadge>
<StatusBadge
v-else-if="healthQuery.isError.value && healthQuery.data.value"
tone="warning"
>
{{ t('home.healthStale') }}
</StatusBadge>
<StatusBadge v-else-if="healthQuery.isError.value" tone="warning">
{{ healthErrorMessage }}
</StatusBadge>
</div>
<section
class="operational-overview"
data-test="home-operational-overview"
aria-labelledby="operational-overview-heading"
>
<header class="operational-overview__header">
<h2 id="operational-overview-heading">{{ t('home.operationalOverview') }}</h2>
<p>{{ t('home.operationalOverviewDescription') }}</p>
</header>
<div class="home-overview">
<SurfaceCard class="service-card">
<div class="section-heading">
<div>
<p class="eyebrow">{{ t('home.service') }}</p>
<h3 id="service-heading">{{ t('home.service') }}</h3>
</div>
<StatusBadge v-if="healthQuery.isSuccess.value" tone="success">
{{ t('home.online') }}
</StatusBadge>
<StatusBadge
v-else-if="healthQuery.isError.value && healthQuery.data.value"
tone="warning"
>
{{ t('home.healthStale') }}
</StatusBadge>
<StatusBadge v-else-if="healthQuery.isError.value" tone="warning">
{{ healthErrorMessage }}
</StatusBadge>
</div>
<QueryFeedback
v-if="healthQuery.isPending.value"
state="loading"
:message="t('auth.checking')"
/>
<QueryFeedback
v-else-if="healthQuery.isError.value && !healthQuery.data.value"
state="error"
:message="healthErrorMessage"
:retry-label="t('common.retry')"
@retry="healthQuery.refetch()"
/>
<template v-else-if="healthQuery.data.value">
<QueryFeedback
v-if="healthQuery.isError.value"
state="stale"
:message="t('home.healthStale')"
v-if="healthQuery.isPending.value"
state="loading"
:message="t('auth.checking')"
/>
<QueryFeedback
v-else-if="healthQuery.isError.value && !healthQuery.data.value"
state="error"
:message="healthErrorMessage"
:retry-label="t('common.retry')"
@retry="healthQuery.refetch()"
/>
<div class="health-meta">
<span>{{
t('home.revision', { revision: healthQuery.data.value.snapshot_revision })
}}</span>
<span>{{ t('home.observedAt', { time: observedAt }) }}</span>
</div>
<div class="health-counts">
<span>{{ t('home.keyTotal', { count: healthQuery.data.value.counts.total }) }}</span>
<span>{{
t('home.keyAvailable', { count: healthQuery.data.value.counts.available })
}}</span>
<span>{{
t('home.keyCooldown', { count: healthQuery.data.value.counts.cooldown })
}}</span>
<span>{{
t('home.keyBlacklisted', { count: healthQuery.data.value.counts.blacklisted })
}}</span>
<span>{{
t('home.keyDisabled', { count: healthQuery.data.value.counts.disabled })
}}</span>
</div>
</template>
</SurfaceCard>
<template v-else-if="healthQuery.data.value">
<QueryFeedback
v-if="healthQuery.isError.value"
state="stale"
:message="t('home.healthStale')"
:retry-label="t('common.retry')"
@retry="healthQuery.refetch()"
/>
<div class="health-meta">
<span>{{
t('home.revision', { revision: healthQuery.data.value.snapshot_revision })
}}</span>
<span>{{ t('home.observedAt', { time: observedAt }) }}</span>
</div>
<div class="health-counts">
<span>{{ t('home.keyTotal', { count: healthQuery.data.value.counts.total }) }}</span>
<span>{{
t('home.keyAvailable', { count: healthQuery.data.value.counts.available })
}}</span>
<span>{{
t('home.keyCooldown', { count: healthQuery.data.value.counts.cooldown })
}}</span>
<span>{{
t('home.keyBlacklisted', { count: healthQuery.data.value.counts.blacklisted })
}}</span>
<span>{{
t('home.keyDisabled', { count: healthQuery.data.value.counts.disabled })
}}</span>
</div>
</template>
</SurfaceCard>
<QueryFeedback
v-if="accessKeysQuery.isPending.value"
state="loading"
:message="t('auth.checking')"
/>
<QueryFeedback
v-else-if="accessKeysQuery.isError.value && !accessKeysQuery.data.value"
state="error"
:message="t('home.accessKeysError')"
:retry-label="t('common.retry')"
@retry="accessKeysQuery.refetch()"
/>
<div v-else class="connection-section">
<QueryFeedback
v-if="accessKeysQuery.isError.value"
state="stale"
:message="t('home.accessKeysStale')"
:retry-label="t('common.retry')"
@retry="accessKeysQuery.refetch()"
/>
<ConnectionCard
:keys="accessKeysQuery.data.value ?? []"
:model-ids="modelIds"
:origin="props.origin"
/>
<UsageSummaryCard heading-as="h3" />
</div>
</section>
<UsageSummaryCard />
<section class="groups-section" aria-labelledby="groups-heading">
<section class="groups-section" data-test="home-groups" aria-labelledby="groups-heading">
<header class="groups-section__header">
<div>
<h2 id="groups-heading">{{ t('home.groups') }}</h2>
@@ -208,6 +192,39 @@ const observedAt = computed(() => {
</div>
</template>
</section>
<section
class="connection-section"
data-test="home-connection"
:aria-label="t('home.connection')"
>
<QueryFeedback
v-if="accessKeysQuery.isPending.value"
state="loading"
:message="t('auth.checking')"
/>
<QueryFeedback
v-else-if="accessKeysQuery.isError.value && !accessKeysQuery.data.value"
state="error"
:message="t('home.accessKeysError')"
:retry-label="t('common.retry')"
@retry="accessKeysQuery.refetch()"
/>
<template v-else>
<QueryFeedback
v-if="accessKeysQuery.isError.value"
state="stale"
:message="t('home.accessKeysStale')"
:retry-label="t('common.retry')"
@retry="accessKeysQuery.refetch()"
/>
<ConnectionCard
:keys="accessKeysQuery.data.value ?? []"
:model-ids="modelIds"
:origin="props.origin"
/>
</template>
</section>
</div>
</template>
@@ -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;
@@ -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')
})
+16 -7
View File
@@ -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 {
<header class="usage-summary-card__header">
<div>
<p class="eyebrow">{{ t('home.usage.eyebrow') }}</p>
<h2 id="home-usage-heading">{{ t('home.usage.title') }}</h2>
<component :is="headingAs" id="home-usage-heading">{{ t('home.usage.title') }}</component>
<p>{{ t('home.usage.description') }}</p>
</div>
<RouterLink
@@ -137,21 +146,21 @@ function formatEstimatedCost(aggregate: UsageAggregateDto): string {
>
<StatusBadge
data-test="home-usage-quality-missing"
:tone="report.summary.usage_missing_count ? 'warning' : 'success'"
:tone="report.summary.usage_missing_count ? 'warning' : 'neutral'"
>
{{ t('home.usage.quality.missing') }}
{{ formatCount(report.summary.usage_missing_count) }}
</StatusBadge>
<StatusBadge
data-test="home-usage-quality-partial"
:tone="report.summary.partial_count ? 'warning' : 'success'"
:tone="report.summary.partial_count ? 'warning' : 'neutral'"
>
{{ t('home.usage.quality.partial') }}
{{ formatCount(report.summary.partial_count) }}
</StatusBadge>
<StatusBadge
data-test="home-usage-quality-unpriced"
:tone="report.summary.unpriced_request_count ? 'warning' : 'success'"
:tone="report.summary.unpriced_request_count ? 'warning' : 'neutral'"
>
{{ 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);
}
+2 -1
View File
@@ -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 {
+6
View File
@@ -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',
+7
View File
@@ -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: '詳細を見る',
+6
View File
@@ -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: '查看详情',