diff --git a/.superpowers/sdd/2026-08-01-group-detail-ledger/task-6-report.md b/.superpowers/sdd/2026-08-01-group-detail-ledger/task-6-report.md new file mode 100644 index 00000000..3dd8fb23 --- /dev/null +++ b/.superpowers/sdd/2026-08-01-group-detail-ledger/task-6-report.md @@ -0,0 +1,32 @@ +# Task 6 report + +## References + +- Task brief: `.superpowers/sdd/2026-08-01-group-detail-ledger/task-6-brief.md` +- Approved visual reference: `tmp/group-detail-ledger-preview.html` +- Notion: GPT-Load 2.0 M3 visual specification and interaction design +- Existing semantic tokens, LedgerSheet, StatusSummaryFilter, AppDialog, and Task 5 key cache helpers + +## Changes + +- Rebuilt the Group detail header from the summary contract with Group return navigation, service status, ID, copyable upstream URL, canonical protocols, and the unified import action. +- Added canonical Group detail query parsing for `tab`, `key_status`, `page`, `page_size`, and `q`; search is debounced for 250 ms and conditions reset the page and selection. +- Replaced the legacy key list with a Task 5 collection-backed Ledger keys tab: five-state summary, filtered/paginated desktop table, mobile cards, expandable failure/recovery details, per-key operations, and batch operations. +- Kept models and settings behind the explicit legacy compile bridge for Task 7; the header and keys caller use the summary/collection contracts. +- Added complete zh-CN, en-US, and ja-JP copy for this surface. + +## Checks + +- `pnpm --dir web format` — passed +- `pnpm --dir web type-check` — passed +- `pnpm --dir web build` — passed +- `git --no-pager diff --check` — passed + +## Commit + +- `feat(groups): 实现分组密钥详情页面` (local commit) + +## Concerns + +- Per task constraints, no frontend unit, browser, E2E, or Go tests were run. +- The production build updates ignored embedded assets only; no generated assets are staged. diff --git a/web/src/features/groups/GroupDetailView.vue b/web/src/features/groups/GroupDetailView.vue index 5623d932..305abb09 100644 --- a/web/src/features/groups/GroupDetailView.vue +++ b/web/src/features/groups/GroupDetailView.vue @@ -6,8 +6,10 @@ import { useRoute } from 'vue-router' import { useApiClient } from '@/api/client-context' import { lazySurface } from '@/app/async-surface' -import { groupDetailQueryOptions } from '@/app/resources/groups' -import { homeLocation } from '@/app/route-locations' +import { groupDetailQueryOptions, groupSummaryQueryOptions } from '@/app/resources/groups' +import { groupsLocation } from '@/app/route-locations' +import LedgerSheet from '@/components/layout/LedgerSheet.vue' +import PageFrame from '@/components/layout/PageFrame.vue' import QueryFeedback from '@/components/ui/QueryFeedback.vue' import GroupHeader from './GroupHeader.vue' @@ -17,65 +19,86 @@ import { normalizeGroupTab, parsePositiveId } from './group-route' const GroupKeysTab = lazySurface(() => import('./keys/GroupKeysTab.vue')) const GroupModelsTab = lazySurface(() => import('./models/GroupModelsTab.vue')) const GroupSettingsTab = lazySurface(() => import('./settings/GroupSettingsTab.vue')) - const route = useRoute() const client = useApiClient() const { t } = useI18n() const groupId = computed(() => parsePositiveId(route.params.id)) const activeTab = computed(() => normalizeGroupTab(route.query.tab)) -const detailQuery = useQuery(groupDetailQueryOptions(client, groupId)) +const summaryQuery = useQuery(groupSummaryQueryOptions(client, groupId)) +const legacyDetailQuery = useQuery( + groupDetailQueryOptions( + client, + computed(() => (activeTab.value === 'keys' ? undefined : groupId.value)), + ), +) - - - {{ t('group.invalidTitle') }} - {{ t('group.invalidDescription') }} - - {{ t('shell.backHome') }} - - - - - - + + + + {{ t('group.invalidTitle') }} + {{ t('group.invalidDescription') }} + {{ + t('group.backToGroups') + }} + + + - + + + + + + + + + + + - - - - - - - - + + diff --git a/web/src/features/groups/GroupHeader.vue b/web/src/features/groups/GroupHeader.vue index 930dda04..b91cf67f 100644 --- a/web/src/features/groups/GroupHeader.vue +++ b/web/src/features/groups/GroupHeader.vue @@ -2,33 +2,27 @@ import { ArrowLeft, Upload } from '@lucide/vue' import { useI18n } from 'vue-i18n' -import type { GroupDetailDto } from '@/app/resources/groups' -import { homeLocation, importLocation } from '@/app/route-locations' +import type { GroupSummaryDto } from '@/app/resources/groups' +import { groupsLocation, importLocation } from '@/app/route-locations' +import CopyChip from '@/components/ui/CopyChip.vue' import StatusBadge from '@/components/ui/StatusBadge.vue' -import { normalizeUpstreamHost } from './upstream-host' - -defineProps<{ group: GroupDetailDto }>() +defineProps<{ group: GroupSummaryDto }>() const { t } = useI18n() - - {{ t('shell.backHome') }} + + {{ t('group.backToGroups') }} - - - {{ group.name }} - {{ normalizeUpstreamHost(group.upstream_url) }} - - - - {{ t(`common.protocols.${protocol}`) }} - - - {{ group.enabled ? t('group.enabled') : t('group.disabled') }} + + + {{ group.name }} + + {{ t(`groups.collection.status.${group.service_status}`) }} + #{{ group.id }} {{ t('group.importKeys') }} + + + + {{ + protocol + }} + + @@ -44,54 +51,65 @@ const { t } = useI18n() .group-header { display: grid; gap: var(--space-3); + border-bottom: 1px solid var(--color-border-control); + padding-bottom: var(--space-5); } .group-header__back { display: inline-flex; width: fit-content; - min-height: 44px; + min-height: var(--touch-target); align-items: center; gap: var(--space-1); color: var(--color-text-muted); font-weight: 650; } -.group-header__main { +.group-header__topline { display: flex; min-width: 0; align-items: center; + justify-content: space-between; gap: var(--space-4); } -.group-header__identity { - min-width: 0; -} -.group-header h1 { - max-width: none; - font-size: clamp(1.5rem, 3vw, 2rem); -} -.group-header__identity p { - margin: var(--space-1) 0 0; - color: var(--color-text-muted); - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - overflow-wrap: anywhere; -} -.group-header__meta { +.group-header__title { display: flex; + min-width: 0; flex-wrap: wrap; align-items: center; gap: var(--space-2); } +.group-header h1 { + max-width: none; + margin: 0; + font-size: clamp(1.65rem, 3vw, 2.2rem); +} +.group-header__id { + color: var(--color-text-faint); + font-family: var(--font-mono); + font-size: var(--text-sm); +} .group-header__import { flex: 0 0 auto; gap: var(--space-2); - margin-left: auto; } -@media (max-width: 720px) { - .group-header__main { - align-items: flex-start; +.group-header__details { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); +} +.group-header__protocols { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); +} +@media (max-width: 620px) { + .group-header__topline { + align-items: stretch; flex-direction: column; } .group-header__import { width: 100%; - margin-left: 0; } } diff --git a/web/src/features/groups/GroupTabs.vue b/web/src/features/groups/GroupTabs.vue index 265a3fa0..03ad7402 100644 --- a/web/src/features/groups/GroupTabs.vue +++ b/web/src/features/groups/GroupTabs.vue @@ -8,13 +8,14 @@ import AppTabs, { type AppTabItem } from '@/components/ui/AppTabs.vue' import { normalizeGroupQuery, normalizeGroupTab, type GroupTab } from './group-route' +const props = defineProps<{ keyCount: number; modelCount: number }>() const route = useRoute() const router = useRouter() -const { t } = useI18n() +const { n, t } = useI18n() const activeTab = computed(() => normalizeGroupTab(route.query.tab)) const items = computed(() => [ - { value: 'keys', label: t('group.tabs.keys') }, - { value: 'models', label: t('group.tabs.models') }, + { value: 'keys', label: t('group.tabs.keys', { count: n(props.keyCount) }) }, + { value: 'models', label: t('group.tabs.models', { count: n(props.modelCount) }) }, { value: 'settings', label: t('group.tabs.settings') }, ]) @@ -22,11 +23,11 @@ watch( () => route.query, (query) => { const canonical = normalizeGroupQuery(query) - const canonicalKeys = Object.keys(canonical) - const isCanonical = - Object.keys(query).length === canonicalKeys.length && - canonicalKeys.every((key) => query[key] === canonical[key]) - if (!isCanonical) { + const keys = Object.keys(canonical) + if ( + Object.keys(query).length !== keys.length || + keys.some((key) => query[key] !== canonical[key]) + ) { void router.replace(groupDetailLocation(String(route.params.id), canonical)) } }, @@ -36,9 +37,7 @@ watch( function selectTab(value: string): void { const tab = normalizeGroupTab(value) if (tab === activeTab.value) return - void router.push( - groupDetailLocation(String(route.params.id), normalizeGroupQuery({ ...route.query, tab })), - ) + void router.push(groupDetailLocation(String(route.params.id), { tab })) } @@ -48,7 +47,6 @@ function selectTab(value: string): void { :label="t('group.tabs.label')" :items="items" @update:model-value="selectTab($event as GroupTab)" - > - - + > diff --git a/web/src/features/groups/group-route.ts b/web/src/features/groups/group-route.ts index 138269f5..b66ee6d8 100644 --- a/web/src/features/groups/group-route.ts +++ b/web/src/features/groups/group-route.ts @@ -1,10 +1,26 @@ -import type { LocationQueryRaw } from 'vue-router' +import type { LocationQuery, LocationQueryRaw } from 'vue-router' + +import type { GroupKeyCollectionFilters, GroupKeyStatus } from '@/api/control/types' export type GroupTab = 'keys' | 'models' | 'settings' -export type GroupKeyState = 'problem' + +const keyStatuses = new Set(['available', 'cooldown', 'blacklisted', 'disabled']) +const keyPageSizes = new Set([20, 50, 100]) +const maxSearchCodePoints = 200 + +function scalar(value: LocationQuery[string]): string | undefined { + return typeof value === 'string' ? value : undefined +} + +function positiveInteger(value: LocationQuery[string]): number | undefined { + const candidate = scalar(value) + if (candidate === undefined || !/^[1-9]\d*$/u.test(candidate)) return undefined + const parsed = Number(candidate) + return Number.isSafeInteger(parsed) ? parsed : undefined +} export function parsePositiveId(raw: unknown): number | undefined { - if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return undefined + if (typeof raw !== 'string' || !/^\d+$/u.test(raw)) return undefined const value = Number(raw) return Number.isSafeInteger(value) && value > 0 ? value : undefined } @@ -13,10 +29,46 @@ export function normalizeGroupTab(raw: unknown): GroupTab { return raw === 'models' || raw === 'settings' || raw === 'keys' ? raw : 'keys' } -export function normalizeGroupQuery(query: Record): LocationQueryRaw { - const tab = normalizeGroupTab(query.tab) - if (tab === 'keys' && query.key_state === 'problem') { - return { tab, key_state: 'problem' } - } - return { tab } +export function normalizeGroupKeySearch(value: string | undefined): string | undefined { + const trimmed = value?.trim() + if (!trimmed) return undefined + return Array.from(trimmed).length <= maxSearchCodePoints ? trimmed : undefined +} + +export function constrainGroupKeySearch(value: string | undefined): string | undefined { + const trimmed = value?.trim() + if (!trimmed) return undefined + return Array.from(trimmed).slice(0, maxSearchCodePoints).join('') +} + +export function parseGroupKeyRouteQuery(query: LocationQuery): GroupKeyCollectionFilters { + const status = scalar(query.key_status) + const pageSize = positiveInteger(query.page_size) + const filters: GroupKeyCollectionFilters = { + page: positiveInteger(query.page) ?? 1, + page_size: keyPageSizes.has(pageSize as GroupKeyCollectionFilters['page_size']) + ? (pageSize as GroupKeyCollectionFilters['page_size']) + : 20, + } + const q = normalizeGroupKeySearch(scalar(query.q)) + if (q !== undefined) filters.q = q + if (status !== undefined && keyStatuses.has(status as GroupKeyStatus)) { + filters.status = status as GroupKeyStatus + } + return filters +} + +export function serializeGroupKeyRouteQuery(filters: GroupKeyCollectionFilters): LocationQueryRaw { + const query: LocationQueryRaw = { tab: 'keys' } + const q = normalizeGroupKeySearch(filters.q) + if (q !== undefined) query.q = q + if (filters.status !== undefined) query.key_status = filters.status + if (filters.page !== 1) query.page = String(filters.page) + if (filters.page_size !== 20) query.page_size = String(filters.page_size) + return query +} + +export function normalizeGroupQuery(query: LocationQuery): LocationQueryRaw { + const tab = normalizeGroupTab(query.tab) + return tab === 'keys' ? serializeGroupKeyRouteQuery(parseGroupKeyRouteQuery(query)) : { tab } } diff --git a/web/src/features/groups/keys/GroupKeyBatchBar.vue b/web/src/features/groups/keys/GroupKeyBatchBar.vue new file mode 100644 index 00000000..8ebfcb9f --- /dev/null +++ b/web/src/features/groups/keys/GroupKeyBatchBar.vue @@ -0,0 +1,58 @@ + + + + + {{ t('group.keys.batch.selected', { count: n(selectedCount) }) }} + + {{ t('group.keys.batch.enable') }}{{ t('group.keys.batch.disable') }}{{ t('group.keys.batch.delete') }}{{ t('group.keys.batch.clear') }} + + + + + diff --git a/web/src/features/groups/keys/GroupKeyMobileCard.vue b/web/src/features/groups/keys/GroupKeyMobileCard.vue new file mode 100644 index 00000000..617f64b8 --- /dev/null +++ b/web/src/features/groups/keys/GroupKeyMobileCard.vue @@ -0,0 +1,146 @@ + + + + + + {{ item.mask }}{{ + t(`group.keys.effective.${item.effective_status}`) + }} + + + + {{ t('group.keys.columns.weight') }} + + {{ weight }}{{ weight }} + + {{ t('group.keys.columns.recent') }} + {{ recent }} + {{ t('group.keys.columns.failure') }} + + {{ + item.recent_failure_count === 0 + ? t('group.keys.none') + : `${item.last_failure_category}${item.last_status_code === null ? '' : ` · ${item.last_status_code}`}` + }} + + {{ t('group.keys.columns.recovery') }} + {{ recovery }} + + {{ + t('group.keys.weightFor', { mask: item.mask }) + }} + {{ t('group.keys.auto') }} + + {{ value }} + {{ + item.configured_status === 'active' ? t('group.keys.disable') : t('group.keys.enable') + }} + + + + diff --git a/web/src/features/groups/keys/GroupKeyRow.vue b/web/src/features/groups/keys/GroupKeyRow.vue new file mode 100644 index 00000000..31406a53 --- /dev/null +++ b/web/src/features/groups/keys/GroupKeyRow.vue @@ -0,0 +1,205 @@ + + + + + + + + {{ item.mask }} + + {{ + t(`group.keys.effective.${item.effective_status}`) + }} + + + {{ + t('group.keys.none') + }} + {{ weightLabel }} + + {{ recentLabel }} + + + {{ + t('group.keys.weightFor', { mask: item.mask }) + }} + + {{ t('group.keys.auto') }} + + {{ weight }} + + + {{ + item.configured_status === 'active' ? t('group.keys.disable') : t('group.keys.enable') + }} + + + + + + + + + + {{ t('group.keys.detailsFailure') }} + + {{ + item.recent_failure_count === 0 + ? t('group.keys.none') + : `${item.last_failure_category}${item.last_status_code === null ? '' : ` · ${item.last_status_code}`}` + }} + + + + {{ t('group.keys.detailsRecovery') }} + {{ recoveryLabel }} + + + {{ t('group.keys.detailsConsecutive') }} + {{ n(item.consecutive_failure_count) }} + + + + + + + diff --git a/web/src/features/groups/keys/GroupKeysTab.vue b/web/src/features/groups/keys/GroupKeysTab.vue index de630da3..fb312d73 100644 --- a/web/src/features/groups/keys/GroupKeysTab.vue +++ b/web/src/features/groups/keys/GroupKeysTab.vue @@ -1,191 +1,251 @@ @@ -196,20 +256,19 @@ const effectiveLabels = computed(() => ({ {{ t('group.keys.description') }} - - + ({ :retry-label="t('common.retry')" @retry="keysQuery.refetch()" /> - {{ actionError }} - - {{ t('group.keys.problemFilter') }} - - {{ t('group.keys.clearProblemFilter') }} - - + {{ feedback }} + + + {{ t('group.keys.filters.search') }} + - - - - {{ t('group.keys.columns.key') }} - {{ t('group.keys.columns.configured') }} - {{ t('group.keys.columns.effective') }} - {{ t('group.keys.columns.manualWeight') }} - {{ t('group.keys.columns.autoWeight') }} - {{ t('group.keys.columns.cooldown') }} - {{ t('group.keys.columns.failures') }} - {{ t('group.keys.columns.actions') }} - - - - - {{ key.mask }} - - - {{ statusLabels[key.status] }} - - - - - {{ effectiveLabels[key.effective_status] }} - - - - - {{ t('group.keys.auto') }} - 0 - - {{ weight }} - - - - {{ key.weight_auto }} - {{ formatCooldown(key.cooldown_until_ms) }} - {{ key.failure_count }} - - - - {{ key.status === 'active' ? t('group.keys.disable') : t('group.keys.enable') }} - - - {{ t('group.keys.saveWeight') }} - - - - - {{ t('group.keys.delete') }} - - - - - {{ t('group.keys.cancel') }} - - - {{ t('group.keys.confirmDelete') }} - - - - - - - - + v-else-if="collection.pagination.total_items === 0" + :title="t('group.keys.emptyFilterTitle')" + :description="t('group.keys.emptyFilterDescription')" + >{{ t('group.keys.filters.reset') }} + + + + + + + + {{ t('group.keys.columns.key') }} + {{ t('group.keys.columns.status') }} + {{ t('group.keys.columns.weight') }} + {{ t('group.keys.columns.recent') }} + {{ t('group.keys.columns.actions') }} + + + + + + + + + + + + + {{ + t('group.keys.cancel') + }}{{ + t('group.keys.confirmDelete') + }} + diff --git a/web/src/i18n/locales/en-US/group.ts b/web/src/i18n/locales/en-US/group.ts index 5afdf03c..1c527161 100644 --- a/web/src/i18n/locales/en-US/group.ts +++ b/web/src/i18n/locales/en-US/group.ts @@ -66,7 +66,12 @@ export default { stale: 'Group details may be stale because the background refresh failed.', enabled: 'Enabled', disabled: 'Disabled', - importKeys: 'Import keys', + backToGroups: 'Back to Groups', + importKeys: 'Import upstream keys', + copyUpstreamUrl: 'Copy upstream address {url}', + copySuccess: 'Copied', + copyFailure: 'Copy failed', + protocolsLabel: 'Protocols', modelEditor: { title: 'Models and aliases', description: @@ -178,13 +183,21 @@ export default { }, tabs: { label: 'Group detail sections', - keys: 'Keys', - models: 'Models and aliases', + keys: 'Keys {count}', + models: 'Models and aliases {count}', settings: 'Settings', }, keys: { title: 'Upstream keys', - description: 'Configured state and effective runtime state are shown separately.', + description: 'Review status, scheduling weight, and recent results', + summary: { region: 'Key status overview', current: 'Current keys' }, + filters: { + region: 'Filter keys', + search: 'Search', + placeholder: 'Mask', + clear: 'Clear search', + reset: 'Reset filters', + }, loading: 'Loading upstream keys…', loadFailed: 'Unable to load upstream keys.', stale: 'Upstream key data may be stale because the background refresh failed.', @@ -192,6 +205,8 @@ export default { deleteFailed: 'Unable to delete the upstream key.', emptyTitle: 'No upstream keys', emptyDescription: 'Import keys into this Group to begin routing requests.', + emptyFilterTitle: 'No keys match these filters', + emptyFilterDescription: 'Adjust the search or status filter and try again', problemFilter: 'Showing only problem keys that are cooling down or blacklisted.', clearProblemFilter: 'Clear filter', problemEmptyTitle: 'No problem keys right now', @@ -199,7 +214,28 @@ export default { caption: 'Upstream keys for this Group', auto: 'Auto', none: '—', + notScheduled: '—', + notScheduledHelp: 'This key is not participating in scheduling', + selectKey: 'Select key {mask}', + selectVisible: 'Select all keys on this page', + cardLabel: 'Upstream key {mask}', weightFor: 'Manual weight for {mask}', + weight: { auto: 'Auto · {weight}', manual: 'Manual · {weight}' }, + recent: '{success} success · {failure} failure', + recentSuccessOnly: '{success} success', + details: 'Recent failure and recovery', + detailsFailure: 'Latest failure', + detailsRecovery: 'Recovery', + detailsConsecutive: 'Consecutive failures', + restore: 'Restore now', + restoreFailed: 'Unable to restore the upstream key now', + recovery: { + none: 'No recovery needed', + cooldown: 'Recovers when cooldown ends', + probe: 'Waiting for probe recovery', + manual: 'Needs manual recovery', + at: 'Recovers automatically at {time}', + }, enable: 'Enable', disable: 'Disable', saveWeight: 'Save weight', @@ -211,6 +247,11 @@ export default { deleteDescription: 'Delete {mask} from this Group. This action cannot be undone.', columns: { key: 'Key', + status: 'Status', + weight: 'Weight', + recent: 'Last 5 minutes', + failure: 'Latest failure', + recovery: 'Recovery', configured: 'Configured status', effective: 'Effective status', manualWeight: 'Manual weight', @@ -220,6 +261,7 @@ export default { actions: 'Actions', }, status: { + all: 'All', active: 'Active', disabled: 'Disabled', }, @@ -229,6 +271,16 @@ export default { blacklisted: 'Blacklisted', disabled: 'Disabled', }, + batch: { + selected: '{count} selected', + enable: 'Enable selected', + disable: 'Disable selected', + delete: 'Delete selected', + clear: 'Clear selection', + failed: 'Unable to complete the batch operation', + deleteTitle: 'Delete selected keys?', + deleteDescription: 'This permanently deletes {count} upstream keys', + }, }, }, } as const diff --git a/web/src/i18n/locales/ja-JP/group.ts b/web/src/i18n/locales/ja-JP/group.ts index ec9a44d4..6cbb1010 100644 --- a/web/src/i18n/locales/ja-JP/group.ts +++ b/web/src/i18n/locales/ja-JP/group.ts @@ -67,7 +67,12 @@ export default { stale: 'バックグラウンド更新に失敗したため、Group 詳細が古い可能性があります。', enabled: '有効', disabled: '無効', - importKeys: 'キーをインポート', + backToGroups: 'グループ一覧に戻る', + importKeys: 'アップストリームキーをインポート', + copyUpstreamUrl: 'アップストリームアドレス {url} をコピー', + copySuccess: 'コピーしました', + copyFailure: 'コピーできませんでした', + protocolsLabel: 'プロトコル', modelEditor: { title: 'モデルとエイリアス', description: @@ -179,13 +184,21 @@ export default { }, tabs: { label: 'Group 詳細セクション', - keys: 'キー', - models: 'モデルとエイリアス', + keys: 'キー {count}', + models: 'モデルとエイリアス {count}', settings: '設定', }, keys: { title: 'アップストリームキー', - description: '設定状態と有効なランタイム状態を分けて表示します。', + description: '状態、スケジュールウェイト、最近の結果を確認します', + summary: { region: 'キーステータス概要', current: '現在のキー' }, + filters: { + region: 'キーを絞り込む', + search: '検索', + placeholder: 'マスク', + clear: '検索をクリア', + reset: '条件をリセット', + }, loading: 'アップストリームキーを読み込み中…', loadFailed: 'アップストリームキーを読み込めません。', stale: 'バックグラウンド更新に失敗したため、キーデータが古い可能性があります。', @@ -193,6 +206,8 @@ export default { deleteFailed: 'アップストリームキーを削除できません。', emptyTitle: 'アップストリームキーがありません', emptyDescription: 'この Group にキーをインポートすると、リクエストをルーティングできます。', + emptyFilterTitle: '条件に一致するキーがありません', + emptyFilterDescription: '検索またはステータス条件を変更して再試行してください', problemFilter: 'クールダウン中またはブラックリスト登録済みの問題キーのみを表示しています。', clearProblemFilter: 'フィルターを解除', problemEmptyTitle: '現在、問題のあるキーはありません', @@ -201,7 +216,28 @@ export default { caption: 'この Group のアップストリームキー', auto: '自動', none: '—', + notScheduled: '—', + notScheduledHelp: 'このキーは現在スケジューリングに参加していません', + selectKey: 'キー {mask} を選択', + selectVisible: 'このページのすべてのキーを選択', + cardLabel: 'アップストリームキー {mask}', weightFor: '{mask} の手動ウェイト', + weight: { auto: '自動 · {weight}', manual: '手動 · {weight}' }, + recent: '成功 {success} · 失敗 {failure}', + recentSuccessOnly: '成功 {success}', + details: '最近の失敗と回復', + detailsFailure: '最近の失敗', + detailsRecovery: '回復', + detailsConsecutive: '連続失敗', + restore: 'すぐに回復', + restoreFailed: 'アップストリームキーをすぐに回復できません', + recovery: { + none: '回復は不要', + cooldown: 'クールダウン終了時に回復', + probe: 'プローブによる回復待ち', + manual: '手動回復が必要', + at: '{time} に自動回復', + }, enable: '有効化', disable: '無効化', saveWeight: 'ウェイトを保存', @@ -213,6 +249,11 @@ export default { deleteDescription: '{mask} をこの Group から削除します。この操作は元に戻せません。', columns: { key: 'キー', + status: 'ステータス', + weight: 'ウェイト', + recent: '直近 5 分', + failure: '最近の失敗', + recovery: '回復', configured: '設定状態', effective: '有効状態', manualWeight: '手動ウェイト', @@ -222,6 +263,7 @@ export default { actions: '操作', }, status: { + all: 'すべて', active: '有効', disabled: '無効', }, @@ -231,6 +273,16 @@ export default { blacklisted: 'ブラックリスト', disabled: '無効', }, + batch: { + selected: '{count} 件を選択', + enable: '選択を有効化', + disable: '選択を無効化', + delete: '選択を削除', + clear: '選択を解除', + failed: '一括操作を完了できません', + deleteTitle: '選択したキーを削除しますか?', + deleteDescription: '{count} 個のアップストリームキーを完全に削除します', + }, }, }, } as const diff --git a/web/src/i18n/locales/zh-CN/group.ts b/web/src/i18n/locales/zh-CN/group.ts index 6343f908..85526326 100644 --- a/web/src/i18n/locales/zh-CN/group.ts +++ b/web/src/i18n/locales/zh-CN/group.ts @@ -66,7 +66,12 @@ export default { stale: 'Group 详情可能已过期;后台刷新失败。', enabled: '已启用', disabled: '已停用', - importKeys: '导入密钥', + backToGroups: '返回分组列表', + importKeys: '导入上游密钥', + copyUpstreamUrl: '复制上游地址 {url}', + copySuccess: '已复制', + copyFailure: '复制失败', + protocolsLabel: '协议', modelEditor: { title: '模型与别名', description: '发现只生成候选项。保存前请检查别名与完整的已选模型列表。', @@ -171,13 +176,21 @@ export default { }, tabs: { label: 'Group 详情分区', - keys: '密钥', - models: '模型与别名', + keys: '密钥 {count}', + models: '模型与别名 {count}', settings: '设置', }, keys: { title: '上游密钥', - description: '配置状态与有效运行状态分开显示。', + description: '查看状态、调度权重和最近结果', + summary: { region: '密钥状态概览', current: '当前密钥' }, + filters: { + region: '筛选密钥', + search: '搜索', + placeholder: '掩码', + clear: '清空搜索', + reset: '重置条件', + }, loading: '正在加载上游密钥…', loadFailed: '无法加载上游密钥。', stale: '上游密钥数据可能已过期;后台刷新失败。', @@ -185,6 +198,8 @@ export default { deleteFailed: '无法删除上游密钥。', emptyTitle: '尚无上游密钥', emptyDescription: '向此 Group 导入密钥后即可开始路由请求。', + emptyFilterTitle: '没有符合条件的密钥', + emptyFilterDescription: '调整搜索或状态筛选后再试', problemFilter: '当前仅显示冷却或已拉黑的问题密钥。', clearProblemFilter: '清除筛选', problemEmptyTitle: '当前没有问题密钥', @@ -192,7 +207,28 @@ export default { caption: '此 Group 的上游密钥', auto: '自动', none: '—', + notScheduled: '—', + notScheduledHelp: '此密钥当前不参与调度', + selectKey: '选择密钥 {mask}', + selectVisible: '选择当前页全部密钥', + cardLabel: '上游密钥 {mask}', weightFor: '{mask} 的手动权重', + weight: { auto: '自动 · {weight}', manual: '手动 · {weight}' }, + recent: '成功 {success} · 失败 {failure}', + recentSuccessOnly: '成功 {success}', + details: '最近失败与恢复', + detailsFailure: '最近失败', + detailsRecovery: '恢复方式', + detailsConsecutive: '连续失败', + restore: '立即恢复', + restoreFailed: '无法立即恢复上游密钥', + recovery: { + none: '无需恢复', + cooldown: '冷却结束后自动恢复', + probe: '等待探测恢复', + manual: '需手动恢复', + at: '将于 {time} 自动恢复', + }, enable: '启用', disable: '停用', saveWeight: '保存权重', @@ -204,6 +240,11 @@ export default { deleteDescription: '从此 Group 删除 {mask}。此操作无法撤销。', columns: { key: '密钥', + status: '状态', + weight: '权重', + recent: '近 5 分钟', + failure: '最近失败', + recovery: '恢复', configured: '配置状态', effective: '有效状态', manualWeight: '手动权重', @@ -213,6 +254,7 @@ export default { actions: '操作', }, status: { + all: '全部', active: '已启用', disabled: '已停用', }, @@ -222,6 +264,16 @@ export default { blacklisted: '黑名单', disabled: '停用', }, + batch: { + selected: '已选择 {count} 项', + enable: '批量启用', + disable: '批量停用', + delete: '批量删除', + clear: '取消选择', + failed: '无法完成批量操作', + deleteTitle: '删除已选密钥?', + deleteDescription: '这会永久删除 {count} 个上游密钥', + }, }, }, } as const
{{ t('group.invalidDescription') }}
{{ normalizeUpstreamHost(group.upstream_url) }}
{{ t('group.keys.description') }}
{{ actionError }}
{{ t('group.keys.problemFilter') }}
{{ feedback }}