From a4e2cbbe1db302bcf92bfb0e97e0ae696ad5377a Mon Sep 17 00:00:00 2001 From: tbphp Date: Sat, 1 Aug 2026 12:33:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(groups):=20=E5=AE=9E=E7=8E=B0=E5=88=86?= =?UTF-8?q?=E7=BB=84=E5=AF=86=E9=92=A5=E8=AF=A6=E6=83=85=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../task-6-report.md | 32 + web/src/features/groups/GroupDetailView.vue | 138 ++-- web/src/features/groups/GroupHeader.vue | 96 ++- web/src/features/groups/GroupTabs.vue | 26 +- web/src/features/groups/group-route.ts | 70 +- .../features/groups/keys/GroupKeyBatchBar.vue | 58 ++ .../groups/keys/GroupKeyMobileCard.vue | 146 ++++ web/src/features/groups/keys/GroupKeyRow.vue | 205 +++++ web/src/features/groups/keys/GroupKeysTab.vue | 709 ++++++++++-------- web/src/i18n/locales/en-US/group.ts | 60 +- web/src/i18n/locales/ja-JP/group.ts | 60 +- web/src/i18n/locales/zh-CN/group.ts | 60 +- 12 files changed, 1215 insertions(+), 445 deletions(-) create mode 100644 .superpowers/sdd/2026-08-01-group-detail-ledger/task-6-report.md create mode 100644 web/src/features/groups/keys/GroupKeyBatchBar.vue create mode 100644 web/src/features/groups/keys/GroupKeyMobileCard.vue create mode 100644 web/src/features/groups/keys/GroupKeyRow.vue 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)), + ), +) 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@