feat(groups): 实现分组密钥详情页面

This commit is contained in:
tbphp committed 2026-08-01 12:34:12 +08:00
1 parent 86d6e87134
commit a4e2cbbe1d
12 files changed
+1215 -445

No files matched your search

@@ -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.
+75 -63
View File
@@ -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)),
),
)
</script>
<template>
<div class="group-detail-page">
<div v-if="groupId === undefined" class="group-detail-invalid" role="alert">
<h1>{{ t('group.invalidTitle') }}</h1>
<p>{{ t('group.invalidDescription') }}</p>
<RouterLink class="button-link" :to="homeLocation()">
{{ t('shell.backHome') }}
</RouterLink>
</div>
<template v-else>
<QueryFeedback
v-if="detailQuery.isPending.value"
state="loading"
:message="t('group.loading')"
/>
<QueryFeedback
v-else-if="detailQuery.isError.value && !detailQuery.data.value"
state="error"
:message="t('group.loadFailed')"
:retry-label="t('common.retry')"
@retry="detailQuery.refetch()"
/>
<template v-else-if="detailQuery.data.value">
<PageFrame aria-labelledby="group-detail-title">
<LedgerSheet class="group-detail-page">
<div v-if="groupId === undefined" class="group-detail-invalid" role="alert">
<h1 id="group-detail-title">{{ t('group.invalidTitle') }}</h1>
<p>{{ t('group.invalidDescription') }}</p>
<RouterLink class="button-link" :to="groupsLocation()">{{
t('group.backToGroups')
}}</RouterLink>
</div>
<template v-else>
<QueryFeedback
v-if="detailQuery.isError.value"
state="stale"
:message="t('group.stale')"
v-if="summaryQuery.isPending.value"
state="loading"
:message="t('group.loading')"
/>
<QueryFeedback
v-else-if="summaryQuery.isError.value && !summaryQuery.data.value"
state="error"
:message="t('group.loadFailed')"
:retry-label="t('common.retry')"
@retry="detailQuery.refetch()"
@retry="summaryQuery.refetch()"
/>
<GroupHeader :group="detailQuery.data.value" />
<template v-else-if="summaryQuery.data.value">
<QueryFeedback
v-if="summaryQuery.isError.value"
state="stale"
:message="t('group.stale')"
:retry-label="t('common.retry')"
@retry="summaryQuery.refetch()"
/>
<GroupHeader :group="summaryQuery.data.value" />
<GroupTabs
:key-count="summaryQuery.data.value.key_count"
:model-count="summaryQuery.data.value.model_count"
>
<GroupKeysTab v-if="activeTab === 'keys'" :key="groupId" :group-id="groupId" />
<QueryFeedback
v-else-if="legacyDetailQuery.isPending.value"
state="loading"
:message="t('group.loading')"
/>
<QueryFeedback
v-else-if="legacyDetailQuery.isError.value && !legacyDetailQuery.data.value"
state="error"
:message="t('group.loadFailed')"
:retry-label="t('common.retry')"
@retry="legacyDetailQuery.refetch()"
/>
<GroupModelsTab
v-else-if="activeTab === 'models' && legacyDetailQuery.data.value"
:key="groupId"
:group-id="groupId"
:group="legacyDetailQuery.data.value"
/>
<GroupSettingsTab
v-else-if="activeTab === 'settings' && legacyDetailQuery.data.value"
:key="groupId"
:group-id="groupId"
:group="legacyDetailQuery.data.value"
/>
</GroupTabs>
</template>
</template>
<GroupTabs>
<GroupKeysTab v-if="activeTab === 'keys'" :key="groupId" :group-id="groupId" />
<GroupModelsTab
v-else-if="activeTab === 'models' && detailQuery.data.value"
:key="groupId"
:group-id="groupId"
:group="detailQuery.data.value"
/>
<GroupSettingsTab
v-else-if="activeTab === 'settings' && detailQuery.data.value"
:key="groupId"
:group-id="groupId"
:group="detailQuery.data.value"
/>
</GroupTabs>
</template>
</div>
</LedgerSheet>
</PageFrame>
</template>
<style scoped>
@@ -87,23 +110,12 @@ const detailQuery = useQuery(groupDetailQueryOptions(client, groupId))
display: grid;
max-width: 640px;
gap: var(--space-3);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-card);
background: var(--color-surface);
box-shadow: var(--shadow-card);
padding: var(--space-6);
}
.group-detail-invalid h1 {
max-width: none;
margin: 0;
font-size: 1.25rem;
}
.group-detail-invalid h1,
.group-detail-invalid p {
margin: 0;
}
.group-detail-invalid p {
color: var(--color-text-muted);
}
.group-detail-invalid .button-link {
width: fit-content;
margin-top: var(--space-2);
}
</style>
+57 -39
View File
@@ -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()
</script>
<template>
<header class="group-header">
<RouterLink class="group-header__back" :to="homeLocation()">
<ArrowLeft :size="16" aria-hidden="true" />{{ t('shell.backHome') }}
<RouterLink class="group-header__back" :to="groupsLocation()">
<ArrowLeft :size="16" aria-hidden="true" />{{ t('group.backToGroups') }}
</RouterLink>
<div class="group-header__main">
<div class="group-header__identity">
<h1>{{ group.name }}</h1>
<p>{{ normalizeUpstreamHost(group.upstream_url) }}</p>
</div>
<div class="group-header__meta">
<span v-for="protocol in group.protocols" :key="protocol" class="meta-tag">
{{ t(`common.protocols.${protocol}`) }}
</span>
<StatusBadge :tone="group.enabled ? 'success' : 'neutral'">
{{ group.enabled ? t('group.enabled') : t('group.disabled') }}
<div class="group-header__topline">
<div class="group-header__title">
<h1 id="group-detail-title">{{ group.name }}</h1>
<StatusBadge :status="group.service_status">
{{ t(`groups.collection.status.${group.service_status}`) }}
</StatusBadge>
<span class="group-header__id">#{{ group.id }}</span>
</div>
<RouterLink
class="button-link group-header__import"
@@ -37,6 +31,19 @@ const { t } = useI18n()
<Upload :size="16" aria-hidden="true" />{{ t('group.importKeys') }}
</RouterLink>
</div>
<div class="group-header__details">
<CopyChip
:value="group.upstream_url"
:label="t('group.copyUpstreamUrl', { url: group.upstream_url })"
:success-label="t('group.copySuccess')"
:failure-label="t('group.copyFailure')"
/>
<div class="group-header__protocols" :aria-label="t('group.protocolsLabel')">
<span v-for="protocol in group.protocols" :key="protocol" class="meta-tag">{{
protocol
}}</span>
</div>
</div>
</header>
</template>
@@ -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;
}
}
</style>
+12 -14
View File
@@ -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<AppTabItem[]>(() => [
{ 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 }))
}
</script>
@@ -48,7 +47,6 @@ function selectTab(value: string): void {
:label="t('group.tabs.label')"
:items="items"
@update:model-value="selectTab($event as GroupTab)"
>
<slot />
</AppTabs>
><slot
/></AppTabs>
</template>
+61 -9
View File
@@ -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<GroupKeyStatus>(['available', 'cooldown', 'blacklisted', 'disabled'])
const keyPageSizes = new Set<GroupKeyCollectionFilters['page_size']>([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<string, unknown>): 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 }
}
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { Ban, Check, Trash2, X } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import AppButton from '@/components/ui/AppButton.vue'
defineProps<{ selectedCount: number; pending?: boolean }>()
const emit = defineEmits<{ enable: []; disable: []; remove: []; clear: [] }>()
const { n, t } = useI18n()
</script>
<template>
<div class="group-key-batch" role="status">
<strong>{{ t('group.keys.batch.selected', { count: n(selectedCount) }) }}</strong>
<div class="group-key-batch__actions">
<AppButton variant="secondary" size="compact" :busy="pending" @click="emit('enable')"
><Check :size="15" aria-hidden="true" />{{ t('group.keys.batch.enable') }}</AppButton
><AppButton variant="secondary" size="compact" :busy="pending" @click="emit('disable')"
><Ban :size="15" aria-hidden="true" />{{ t('group.keys.batch.disable') }}</AppButton
><AppButton variant="danger" size="compact" :busy="pending" @click="emit('remove')"
><Trash2 :size="15" aria-hidden="true" />{{ t('group.keys.batch.delete') }}</AppButton
><AppButton variant="ghost" size="compact" :disabled="pending" @click="emit('clear')"
><X :size="15" aria-hidden="true" />{{ t('group.keys.batch.clear') }}</AppButton
>
</div>
</div>
</template>
<style scoped>
.group-key-batch {
position: sticky;
z-index: 1;
bottom: var(--space-3);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
border: 1px solid var(--color-border-control);
border-radius: var(--radius-card);
background: var(--color-surface-raised);
box-shadow: var(--shadow-card);
padding: var(--space-3);
}
.group-key-batch__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
@media (max-width: 680px) {
.group-key-batch {
align-items: stretch;
flex-direction: column;
}
.group-key-batch__actions > * {
flex: 1 1 calc(50% - var(--space-2));
}
}
</style>
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { RotateCcw, Trash2 } from '@lucide/vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { GroupKeyItemDto } from '@/api/control/types'
import AppButton from '@/components/ui/AppButton.vue'
import AppTooltip from '@/components/ui/AppTooltip.vue'
import IconButton from '@/components/ui/IconButton.vue'
import MobileRecordCard from '@/components/ui/MobileRecordCard.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { formatLocalInstant } from '@/lib/format'
const props = defineProps<{ item: GroupKeyItemDto; selected: boolean; busy: boolean }>()
const emit = defineEmits<{
'update:selected': [selected: boolean]
weight: [payload: { item: GroupKeyItemDto; value: string }]
toggle: [item: GroupKeyItemDto]
restore: [item: GroupKeyItemDto]
remove: [item: GroupKeyItemDto]
}>()
const { locale, n, t } = useI18n()
const weights = Array.from({ length: 100 }, (_, index) => index + 1)
const problem = computed(
() => props.item.effective_status === 'cooldown' || props.item.effective_status === 'blacklisted',
)
const weightValue = computed(() =>
props.item.weight_mode === 'manual' && props.item.weight !== null
? String(props.item.weight)
: 'auto',
)
const weight = computed(() =>
props.item.weight === null
? t('group.keys.none')
: t(`group.keys.weight.${props.item.weight_mode}`, { weight: n(props.item.weight) }),
)
const recent = computed(() =>
props.item.recent_failure_count === 0
? t('group.keys.recentSuccessOnly', { success: n(props.item.recent_success_count) })
: t('group.keys.recent', {
success: n(props.item.recent_success_count),
failure: n(props.item.recent_failure_count),
}),
)
const recovery = computed(() =>
props.item.recovery.at_ms === null
? t(`group.keys.recovery.${props.item.recovery.mode}`)
: t('group.keys.recovery.at', {
time: formatLocalInstant(props.item.recovery.at_ms, locale.value),
}),
)
</script>
<template>
<MobileRecordCard :label="t('group.keys.cardLabel', { mask: item.mask })">
<template #header
><div>
<strong class="group-key-card__mask">{{ item.mask }}</strong
><StatusBadge :status="item.effective_status">{{
t(`group.keys.effective.${item.effective_status}`)
}}</StatusBadge>
</div>
<input
type="checkbox"
:checked="selected"
:disabled="busy"
:aria-label="t('group.keys.selectKey', { mask: item.mask })"
@change="emit('update:selected', ($event.target as HTMLInputElement).checked)"
/></template>
<dl>
<dt>{{ t('group.keys.columns.weight') }}</dt>
<dd>
<AppTooltip v-if="item.weight === null" :content="t('group.keys.notScheduledHelp')"
><span tabindex="0">{{ weight }}</span></AppTooltip
><span v-else>{{ weight }}</span>
</dd>
<dt>{{ t('group.keys.columns.recent') }}</dt>
<dd>{{ recent }}</dd>
<dt>{{ t('group.keys.columns.failure') }}</dt>
<dd>
{{
item.recent_failure_count === 0
? t('group.keys.none')
: `${item.last_failure_category}${item.last_status_code === null ? '' : ` · ${item.last_status_code}`}`
}}
</dd>
<dt>{{ t('group.keys.columns.recovery') }}</dt>
<dd>{{ recovery }}</dd>
</dl>
<template #actions
><label class="sr-only" :for="`key-mobile-weight-${item.id}`">{{
t('group.keys.weightFor', { mask: item.mask })
}}</label
><select
:id="`key-mobile-weight-${item.id}`"
:value="weightValue"
:disabled="busy || item.configured_status === 'disabled'"
@change="emit('weight', { item, value: ($event.target as HTMLSelectElement).value })"
>
<option value="auto">{{ t('group.keys.auto') }}</option>
<option v-for="value in weights" :key="value" :value="String(value)">
{{ value }}
</option></select
><AppButton
variant="secondary"
size="compact"
:disabled="busy"
@click="emit('toggle', item)"
>{{
item.configured_status === 'active' ? t('group.keys.disable') : t('group.keys.enable')
}}</AppButton
><IconButton
v-if="problem"
variant="ghost"
size="compact"
:label="t('group.keys.restore')"
:disabled="busy"
@click="emit('restore', item)"
><RotateCcw :size="16" aria-hidden="true" /></IconButton
><IconButton
variant="ghost"
size="compact"
:label="t('group.keys.delete')"
:disabled="busy"
@click="emit('remove', item)"
><Trash2 :size="16" aria-hidden="true" /></IconButton
></template>
</MobileRecordCard>
</template>
<style scoped>
.group-key-card__mask {
display: block;
margin-bottom: var(--space-2);
font-family: var(--font-mono);
}
.mobile-record-card :deep(select) {
min-height: var(--touch-target);
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface);
color: var(--color-text);
padding-inline: var(--space-2);
font: inherit;
}
</style>
@@ -0,0 +1,205 @@
<script setup lang="ts">
import { RotateCcw, Trash2 } from '@lucide/vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { GroupKeyItemDto } from '@/api/control/types'
import AppButton from '@/components/ui/AppButton.vue'
import AppTooltip from '@/components/ui/AppTooltip.vue'
import DisclosurePanel from '@/components/ui/DisclosurePanel.vue'
import IconButton from '@/components/ui/IconButton.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { formatLocalInstant } from '@/lib/format'
const props = defineProps<{ item: GroupKeyItemDto; selected: boolean; busy: boolean }>()
const emit = defineEmits<{
'update:selected': [selected: boolean]
weight: [payload: { item: GroupKeyItemDto; value: string }]
toggle: [item: GroupKeyItemDto]
restore: [item: GroupKeyItemDto]
remove: [item: GroupKeyItemDto]
}>()
const { locale, n, t } = useI18n()
const weights = Array.from({ length: 100 }, (_, index) => index + 1)
const isProblem = computed(
() => props.item.effective_status === 'cooldown' || props.item.effective_status === 'blacklisted',
)
const weightValue = computed(() =>
props.item.weight_mode === 'manual' && props.item.weight !== null
? String(props.item.weight)
: 'auto',
)
const weightLabel = computed(() =>
props.item.weight === null
? t('group.keys.notScheduled')
: t(`group.keys.weight.${props.item.weight_mode}`, { weight: n(props.item.weight) }),
)
const recentLabel = computed(() =>
props.item.recent_failure_count === 0
? t('group.keys.recentSuccessOnly', { success: n(props.item.recent_success_count) })
: t('group.keys.recent', {
success: n(props.item.recent_success_count),
failure: n(props.item.recent_failure_count),
}),
)
const recoveryLabel = computed(() => {
if (props.item.recovery.mode === 'none') return t('group.keys.recovery.none')
if (props.item.recovery.at_ms !== null)
return t('group.keys.recovery.at', {
time: formatLocalInstant(props.item.recovery.at_ms, locale.value),
})
return t(`group.keys.recovery.${props.item.recovery.mode}`)
})
function updateWeight(event: Event): void {
emit('weight', { item: props.item, value: (event.target as HTMLSelectElement).value })
}
</script>
<template>
<tr>
<td>
<input
type="checkbox"
:checked="selected"
:disabled="busy"
:aria-label="t('group.keys.selectKey', { mask: item.mask })"
@change="emit('update:selected', ($event.target as HTMLInputElement).checked)"
/>
</td>
<td class="group-key-row__mask">{{ item.mask }}</td>
<td>
<StatusBadge :status="item.effective_status">{{
t(`group.keys.effective.${item.effective_status}`)
}}</StatusBadge>
</td>
<td>
<AppTooltip v-if="item.weight === null" :content="t('group.keys.notScheduledHelp')"
><span class="group-key-row__weight group-key-row__weight--none" tabindex="0">{{
t('group.keys.none')
}}</span></AppTooltip
>
<span v-else class="group-key-row__weight">{{ weightLabel }}</span>
</td>
<td class="group-key-row__recent">{{ recentLabel }}</td>
<td>
<div class="group-key-row__actions">
<label class="sr-only" :for="`key-weight-${item.id}`">{{
t('group.keys.weightFor', { mask: item.mask })
}}</label>
<select
:id="`key-weight-${item.id}`"
:value="weightValue"
:disabled="busy || item.configured_status === 'disabled'"
@change="updateWeight"
>
<option value="auto">{{ t('group.keys.auto') }}</option>
<option v-for="weight in weights" :key="weight" :value="String(weight)">
{{ weight }}
</option>
</select>
<AppButton variant="ghost" size="compact" :disabled="busy" @click="emit('toggle', item)">{{
item.configured_status === 'active' ? t('group.keys.disable') : t('group.keys.enable')
}}</AppButton>
<IconButton
v-if="isProblem"
variant="ghost"
size="compact"
:label="t('group.keys.restore')"
:disabled="busy"
@click="emit('restore', item)"
><RotateCcw :size="15" aria-hidden="true"
/></IconButton>
<IconButton
variant="ghost"
size="compact"
:label="t('group.keys.delete')"
:disabled="busy"
@click="emit('remove', item)"
><Trash2 :size="15" aria-hidden="true"
/></IconButton>
</div>
</td>
</tr>
<tr class="group-key-row__detail">
<td colspan="6">
<DisclosurePanel :summary="t('group.keys.details')"
><dl>
<div>
<dt>{{ t('group.keys.detailsFailure') }}</dt>
<dd>
{{
item.recent_failure_count === 0
? t('group.keys.none')
: `${item.last_failure_category}${item.last_status_code === null ? '' : ` · ${item.last_status_code}`}`
}}
</dd>
</div>
<div>
<dt>{{ t('group.keys.detailsRecovery') }}</dt>
<dd>{{ recoveryLabel }}</dd>
</div>
<div>
<dt>{{ t('group.keys.detailsConsecutive') }}</dt>
<dd>{{ n(item.consecutive_failure_count) }}</dd>
</div>
</dl></DisclosurePanel
>
</td>
</tr>
</template>
<style scoped>
.group-key-row__mask,
.group-key-row__recent,
.group-key-row__weight {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.group-key-row__weight--none {
color: var(--color-text-faint);
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.group-key-row__actions {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
}
.group-key-row__actions select {
min-height: var(--control-compact);
max-width: 104px;
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface);
color: var(--color-text);
padding-inline: var(--space-1);
font: inherit;
}
.group-key-row__detail td {
padding-top: 0;
}
.group-key-row__detail dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
margin: 0;
}
.group-key-row__detail dl div {
min-width: 0;
}
.group-key-row__detail dt {
color: var(--color-text-faint);
font-size: var(--text-sm);
}
.group-key-row__detail dd {
margin: var(--space-1) 0 0;
overflow-wrap: anywhere;
}
@media (max-width: 1023px) {
.group-key-row__detail dl {
grid-template-columns: 1fr;
}
}
</style>
+401 -308
View File
@@ -1,191 +1,251 @@
<script setup lang="ts">
import { Search, X } from '@lucide/vue'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useApiClient } from '@/api/client-context'
import type {
GroupKeyCollectionFilters,
GroupKeyItemDto,
GroupKeyStatus,
} from '@/api/control/types'
import {
batchGroupKeys,
cacheGroupKeyBatch,
cacheGroupKeyItem,
deleteGroupKey,
upstreamKeyListQueryOptions,
groupKeyCollectionQueryOptions,
invalidateGroupKeyCollections,
restoreGroupKey,
updateGroupKey,
type UpstreamKeyDto,
type UpstreamKeyEffectiveStatus,
} from '@/app/resources/upstream-keys'
import { applyInvalidationPlan, mutationInvalidationPlans } from '@/app/resources/invalidation'
import { groupDetailLocation } from '@/app/route-locations'
import AppButton from '@/components/ui/AppButton.vue'
import AppDialog from '@/components/ui/AppDialog.vue'
import DataTable from '@/components/ui/DataTable.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PaginationBar from '@/components/ui/PaginationBar.vue'
import QueryFeedback from '@/components/ui/QueryFeedback.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { formatLocalInstant } from '@/lib/format'
import StatusSummaryFilter, {
type StatusSummaryFilterValue,
} from '@/components/ui/StatusSummaryFilter.vue'
import { buildUpstreamKeyPatch } from './key-patch'
import GroupKeyBatchBar from './GroupKeyBatchBar.vue'
import GroupKeyMobileCard from './GroupKeyMobileCard.vue'
import GroupKeyRow from './GroupKeyRow.vue'
import {
constrainGroupKeySearch,
parseGroupKeyRouteQuery,
serializeGroupKeyRouteQuery,
} from '../group-route'
const props = defineProps<{ groupId: number }>()
const client = useApiClient()
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
const { locale, t } = useI18n()
const weights = Array.from({ length: 100 }, (_, index) => index + 1)
const weightDrafts = reactive(new Map<number, string>())
const pendingIds = ref(new Set<number>())
const deleteKeyId = ref<number | null>(null)
const actionError = ref('')
const controllers = new Set<AbortController>()
const keysQuery = useQuery(upstreamKeyListQueryOptions(client, () => props.groupId))
const problemFilterActive = computed(() => route.query.key_state === 'problem')
const visibleKeys = computed(() => {
const keys = keysQuery.data.value ?? []
if (!problemFilterActive.value) return keys
return keys.filter(
(key) => key.effective_status === 'cooldown' || key.effective_status === 'blacklisted',
)
})
const { n, t } = useI18n()
const filters = computed(() => parseGroupKeyRouteQuery(route.query))
const keysQuery = useQuery(groupKeyCollectionQueryOptions(client, () => props.groupId, filters))
const searchDraft = ref(filters.value.q ?? '')
const selectedIds = ref(new Set<number>())
const pendingOperations = ref(new Set<string>())
const feedback = ref('')
const deleteTarget = ref<{ ids: number[]; mask?: string } | undefined>()
let searchTimer: ReturnType<typeof setTimeout> | undefined
watch(
() => keysQuery.data.value,
(keys) => {
for (const key of keys ?? []) {
if (!pendingIds.value.has(key.id)) {
weightDrafts.set(key.id, key.weight_manual === null ? 'auto' : String(key.weight_manual))
}
}
},
{ immediate: true },
const collection = computed(() => keysQuery.data.value)
const selectedCount = computed(() => selectedIds.value.size)
const allVisibleSelected = computed(() => {
const items = collection.value?.items ?? []
return items.length > 0 && items.every(({ id }) => selectedIds.value.has(id))
})
const batchBusy = computed(() =>
[...pendingOperations.value].some((key) => key.startsWith('batch:')),
)
onBeforeUnmount(() => {
for (const controller of controllers) controller.abort()
controllers.clear()
})
watch(
() => route.query,
(query) => {
if (searchTimer !== undefined) {
clearTimeout(searchTimer)
searchTimer = undefined
}
const next = parseGroupKeyRouteQuery(query)
searchDraft.value = next.q ?? ''
const canonical = serializeGroupKeyRouteQuery(next)
const keys = Object.keys(canonical)
if (
Object.keys(query).length !== keys.length ||
keys.some((key) => query[key] !== canonical[key])
) {
void router.replace(groupDetailLocation(props.groupId, canonical))
}
},
{ deep: true, immediate: true },
)
function pending(keyId: number): boolean {
return pendingIds.value.has(keyId)
watch(
() => [filters.value.status, filters.value.q, filters.value.page, filters.value.page_size],
() => {
selectedIds.value = new Set()
},
)
watch(
() => ({
totalPages: collection.value?.pagination.total_pages,
page: filters.value.page,
placeholder: keysQuery.isPlaceholderData.value,
}),
({ totalPages, page, placeholder }) => {
if (!placeholder && totalPages !== undefined && totalPages > 0 && page > totalPages)
updateRoute({ ...filters.value, page: totalPages }, true)
},
)
function updateRoute(next: GroupKeyCollectionFilters, replace = false): void {
const location = groupDetailLocation(props.groupId, serializeGroupKeyRouteQuery(next))
void (replace ? router.replace(location) : router.push(location))
}
function setPending(keyId: number, value: boolean): void {
const next = new Set(pendingIds.value)
if (value) next.add(keyId)
else next.delete(keyId)
pendingIds.value = next
function setFilter(
patch: Partial<Pick<GroupKeyCollectionFilters, 'q' | 'status' | 'page_size'>>,
): void {
updateRoute({ ...filters.value, ...patch, page: 1 })
}
function selectedWeight(key: UpstreamKeyDto): number | null {
const value =
weightDrafts.get(key.id) ?? (key.weight_manual === null ? 'auto' : String(key.weight_manual))
return value === 'auto' ? null : Number(value)
function scheduleSearch(): void {
if (searchTimer !== undefined) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
searchTimer = undefined
setFilter({ q: constrainGroupKeySearch(searchDraft.value) })
}, 250)
}
function hasWeightPatch(key: UpstreamKeyDto): boolean {
return (
Object.keys(
buildUpstreamKeyPatch(
{ status: key.status, weight_manual: key.weight_manual },
{ status: key.status, weight_manual: selectedWeight(key) },
),
).length > 0
)
function clearSearch(): void {
if (searchTimer !== undefined) clearTimeout(searchTimer)
searchTimer = undefined
searchDraft.value = ''
setFilter({ q: undefined })
}
function statusTone(
status: UpstreamKeyEffectiveStatus,
): 'success' | 'warning' | 'danger' | 'neutral' {
if (status === 'available') return 'success'
if (status === 'cooldown') return 'warning'
if (status === 'blacklisted') return 'danger'
return 'neutral'
function setStatus(value: StatusSummaryFilterValue | undefined): void {
setFilter({ status: value as GroupKeyStatus | undefined })
}
function formatCooldown(value: number | null): string {
return value === null ? t('group.keys.none') : formatLocalInstant(value, locale.value)
function setPage(page: number): void {
updateRoute({ ...filters.value, page })
}
function setPageSize(pageSize: 20 | 50 | 100): void {
setFilter({ page_size: pageSize })
}
function setSelected(id: number, checked: boolean): void {
const next = new Set(selectedIds.value)
checked ? next.add(id) : next.delete(id)
selectedIds.value = next
}
function setAllVisible(checked: boolean): void {
const next = new Set(selectedIds.value)
for (const { id } of collection.value?.items ?? []) checked ? next.add(id) : next.delete(id)
selectedIds.value = next
}
function operation(id: number, action: string): string {
return `${id}:${action}`
}
function pending(id: number): boolean {
return [...pendingOperations.value].some((value) => value.startsWith(`${id}:`))
}
function setPending(id: number | 'batch', action: string, value: boolean): void {
const next = new Set(pendingOperations.value)
const key = id === 'batch' ? `batch:${action}` : operation(id, action)
value ? next.add(key) : next.delete(key)
pendingOperations.value = next
}
async function refetchCurrent(): Promise<void> {
await keysQuery.refetch()
}
function clearProblemFilter(): void {
void router.push(groupDetailLocation(String(route.params.id), { tab: 'keys' }))
}
async function runUpdate(
key: UpstreamKeyDto,
patch: ReturnType<typeof buildUpstreamKeyPatch>,
async function mutateItem(
item: GroupKeyItemDto,
action: 'weight' | 'toggle' | 'restore',
value?: string,
): Promise<void> {
if (Object.keys(patch).length === 0 || pending(key.id)) return
actionError.value = ''
setPending(key.id, true)
const controller = new AbortController()
controllers.add(controller)
if (pending(item.id)) return
feedback.value = ''
setPending(item.id, action, true)
try {
await updateGroupKey(client, props.groupId, key.id, patch, controller.signal)
await applyInvalidationPlan(
queryClient,
mutationInvalidationPlans.upstreamKey.update(props.groupId),
)
const result =
action === 'restore'
? await restoreGroupKey(client, props.groupId, item.id)
: await updateGroupKey(
client,
props.groupId,
item.id,
action === 'weight'
? { weight_manual: value === 'auto' ? null : Number(value) }
: { status: item.configured_status === 'active' ? 'disabled' : 'active' },
)
await cacheGroupKeyItem(queryClient, props.groupId, result)
await refetchCurrent()
} catch {
actionError.value = t('group.keys.updateFailed')
feedback.value = t(
action === 'restore' ? 'group.keys.restoreFailed' : 'group.keys.updateFailed',
)
} finally {
controllers.delete(controller)
setPending(key.id, false)
setPending(item.id, action, false)
}
}
function saveWeight(key: UpstreamKeyDto): void {
const patch = buildUpstreamKeyPatch(
{ status: key.status, weight_manual: key.weight_manual },
{ status: key.status, weight_manual: selectedWeight(key) },
)
void runUpdate(key, patch)
async function confirmDelete(): Promise<void> {
const target = deleteTarget.value
if (!target || batchBusy.value) return
feedback.value = ''
if (target.ids.length === 1) {
const id = target.ids[0]
setPending(id, 'delete', true)
try {
await deleteGroupKey(client, props.groupId, id)
await invalidateGroupKeyCollections(queryClient, props.groupId)
deleteTarget.value = undefined
selectedIds.value.delete(id)
selectedIds.value = new Set(selectedIds.value)
await refetchCurrent()
} catch {
feedback.value = t('group.keys.deleteFailed')
} finally {
setPending(id, 'delete', false)
}
return
}
await runBatch('delete', target.ids)
deleteTarget.value = undefined
}
function toggleStatus(key: UpstreamKeyDto): void {
const patch = buildUpstreamKeyPatch(
{ status: key.status, weight_manual: key.weight_manual },
{
status: key.status === 'active' ? 'disabled' : 'active',
weight_manual: key.weight_manual,
},
)
void runUpdate(key, patch)
}
function setDeleteDialog(open: boolean, keyId: number): void {
if (pending(keyId)) return
deleteKeyId.value = open ? keyId : null
}
async function confirmDelete(key: UpstreamKeyDto): Promise<void> {
if (pending(key.id)) return
actionError.value = ''
setPending(key.id, true)
const controller = new AbortController()
controllers.add(controller)
async function runBatch(
action: 'enable' | 'disable' | 'delete',
ids = [...selectedIds.value],
): Promise<void> {
if (ids.length === 0 || batchBusy.value) return
feedback.value = ''
setPending('batch', action, true)
try {
await deleteGroupKey(client, props.groupId, key.id, controller.signal)
await applyInvalidationPlan(
queryClient,
mutationInvalidationPlans.upstreamKey.delete(props.groupId),
)
deleteKeyId.value = null
const result = await batchGroupKeys(client, props.groupId, { action, key_ids: ids })
await cacheGroupKeyBatch(queryClient, props.groupId, action, result)
selectedIds.value = new Set()
await refetchCurrent()
} catch {
actionError.value = t('group.keys.deleteFailed')
feedback.value = t('group.keys.batch.failed')
} finally {
controllers.delete(controller)
setPending(key.id, false)
setPending('batch', action, false)
}
}
const statusLabels = computed(() => ({
active: t('group.keys.status.active'),
disabled: t('group.keys.status.disabled'),
}))
const effectiveLabels = computed(() => ({
available: t('group.keys.effective.available'),
cooldown: t('group.keys.effective.cooldown'),
blacklisted: t('group.keys.effective.blacklisted'),
disabled: t('group.keys.effective.disabled'),
}))
onBeforeUnmount(() => {
if (searchTimer !== undefined) clearTimeout(searchTimer)
})
</script>
<template>
@@ -196,20 +256,19 @@ const effectiveLabels = computed(() => ({
<p>{{ t('group.keys.description') }}</p>
</div>
</header>
<QueryFeedback
v-if="keysQuery.isPending.value"
state="loading"
:message="t('group.keys.loading')"
/>
<QueryFeedback
v-else-if="keysQuery.isError.value && !keysQuery.data.value"
v-else-if="keysQuery.isError.value && !collection"
state="error"
:message="t('group.keys.loadFailed')"
:retry-label="t('common.retry')"
@retry="keysQuery.refetch()"
/>
<template v-else>
<template v-else-if="collection">
<QueryFeedback
v-if="keysQuery.isError.value"
state="stale"
@@ -217,201 +276,235 @@ const effectiveLabels = computed(() => ({
:retry-label="t('common.retry')"
@retry="keysQuery.refetch()"
/>
<p v-if="actionError" class="group-keys__action-error" role="alert">{{ actionError }}</p>
<div v-if="problemFilterActive" class="group-keys__filter" role="status">
<p>{{ t('group.keys.problemFilter') }}</p>
<AppButton variant="ghost" size="sm" @click="clearProblemFilter">
{{ t('group.keys.clearProblemFilter') }}
</AppButton>
</div>
<p v-if="feedback" class="group-keys__feedback" role="alert">{{ feedback }}</p>
<StatusSummaryFilter
v-if="collection.summary.total > 0"
:summary="collection.summary"
:model-value="filters.status"
:labels="{
region: t('group.keys.summary.region'),
current: t('group.keys.summary.current'),
all: t('group.keys.status.all'),
available: t('group.keys.effective.available'),
unavailable: '',
cooldown: t('group.keys.effective.cooldown'),
blacklisted: t('group.keys.effective.blacklisted'),
disabled: t('group.keys.effective.disabled'),
}"
@update:model-value="setStatus"
/>
<form
v-if="collection.summary.total > 0"
class="group-keys__filters"
role="search"
:aria-label="t('group.keys.filters.region')"
@submit.prevent
>
<label
><span>{{ t('group.keys.filters.search') }}</span
><span class="group-keys__search"
><Search :size="15" aria-hidden="true" /><input
v-model="searchDraft"
type="search"
:placeholder="t('group.keys.filters.placeholder')"
@input="scheduleSearch" /><IconButton
v-if="searchDraft"
size="xs"
variant="ghost"
:label="t('group.keys.filters.clear')"
@click="clearSearch"
><X :size="14" aria-hidden="true" /></IconButton></span
></label>
</form>
<EmptyState
v-if="keysQuery.data.value?.length === 0"
v-if="collection.summary.total === 0"
:title="t('group.keys.emptyTitle')"
:description="t('group.keys.emptyDescription')"
/>
<EmptyState
v-else-if="visibleKeys.length === 0"
:title="t('group.keys.problemEmptyTitle')"
:description="t('group.keys.problemEmptyDescription')"
/>
<DataTable v-else :caption="t('group.keys.caption')">
<thead>
<tr>
<th scope="col">{{ t('group.keys.columns.key') }}</th>
<th scope="col">{{ t('group.keys.columns.configured') }}</th>
<th scope="col">{{ t('group.keys.columns.effective') }}</th>
<th scope="col">{{ t('group.keys.columns.manualWeight') }}</th>
<th scope="col">{{ t('group.keys.columns.autoWeight') }}</th>
<th scope="col">{{ t('group.keys.columns.cooldown') }}</th>
<th scope="col">{{ t('group.keys.columns.failures') }}</th>
<th scope="col">{{ t('group.keys.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="key in visibleKeys" :key="key.id">
<td class="group-keys__mask">{{ key.mask }}</td>
<td>
<StatusBadge :tone="key.status === 'active' ? 'success' : 'neutral'">
{{ statusLabels[key.status] }}
</StatusBadge>
</td>
<td>
<StatusBadge :tone="statusTone(key.effective_status)">
{{ effectiveLabels[key.effective_status] }}
</StatusBadge>
</td>
<td>
<select
:aria-label="t('group.keys.weightFor', { mask: key.mask })"
:value="weightDrafts.get(key.id)"
:disabled="pending(key.id)"
@change="weightDrafts.set(key.id, ($event.target as HTMLSelectElement).value)"
>
<option value="auto">{{ t('group.keys.auto') }}</option>
<option v-if="key.weight_manual === 0" value="0" disabled>0</option>
<option v-for="weight in weights" :key="weight" :value="String(weight)">
{{ weight }}
</option>
</select>
</td>
<td class="group-keys__number">{{ key.weight_auto }}</td>
<td class="group-keys__number">{{ formatCooldown(key.cooldown_until_ms) }}</td>
<td class="group-keys__number">{{ key.failure_count }}</td>
<td>
<div class="group-keys__actions">
<AppButton variant="ghost" :busy="pending(key.id)" @click="toggleStatus(key)">
{{ key.status === 'active' ? t('group.keys.disable') : t('group.keys.enable') }}
</AppButton>
<AppButton
variant="secondary"
:disabled="!hasWeightPatch(key)"
:busy="pending(key.id)"
@click="saveWeight(key)"
>
{{ t('group.keys.saveWeight') }}
</AppButton>
<AppDialog
:open="deleteKeyId === key.id"
:title="t('group.keys.deleteTitle')"
:description="t('group.keys.deleteDescription', { mask: key.mask })"
:close-label="t('group.keys.closeDialog')"
:dismissible="!pending(key.id)"
@update:open="setDeleteDialog($event, key.id)"
>
<template #trigger>
<AppButton
class="group-keys__delete"
variant="ghost"
:aria-disabled="pending(key.id) ? 'true' : undefined"
@click="!pending(key.id) && (deleteKeyId = key.id)"
>
{{ t('group.keys.delete') }}
</AppButton>
</template>
<div class="group-keys__dialog-actions">
<AppButton
variant="secondary"
:disabled="pending(key.id)"
@click="setDeleteDialog(false, key.id)"
>
{{ t('group.keys.cancel') }}
</AppButton>
<AppButton
class="group-keys__confirm-delete"
variant="secondary"
:busy="pending(key.id)"
:disabled="pending(key.id)"
@click="confirmDelete(key)"
>
{{ t('group.keys.confirmDelete') }}
</AppButton>
</div>
</AppDialog>
</div>
</td>
</tr>
</tbody>
</DataTable>
v-else-if="collection.pagination.total_items === 0"
:title="t('group.keys.emptyFilterTitle')"
:description="t('group.keys.emptyFilterDescription')"
><template #actions
><AppButton
variant="secondary"
size="compact"
@click="updateRoute({ page: 1, page_size: filters.page_size })"
>{{ t('group.keys.filters.reset') }}</AppButton
></template
></EmptyState
>
<template v-else>
<div class="group-keys__desktop">
<DataTable :caption="t('group.keys.caption')" dense
><thead>
<tr>
<th scope="col">
<input
type="checkbox"
:checked="allVisibleSelected"
:aria-label="t('group.keys.selectVisible')"
@change="setAllVisible(($event.target as HTMLInputElement).checked)"
/>
</th>
<th scope="col">{{ t('group.keys.columns.key') }}</th>
<th scope="col">{{ t('group.keys.columns.status') }}</th>
<th scope="col">{{ t('group.keys.columns.weight') }}</th>
<th scope="col">{{ t('group.keys.columns.recent') }}</th>
<th scope="col">{{ t('group.keys.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<GroupKeyRow
v-for="item in collection.items"
:key="item.id"
:item="item"
:selected="selectedIds.has(item.id)"
:busy="pending(item.id)"
@update:selected="setSelected(item.id, $event)"
@weight="mutateItem($event.item, 'weight', $event.value)"
@toggle="mutateItem($event, 'toggle')"
@restore="mutateItem($event, 'restore')"
@remove="deleteTarget = { ids: [$event.id], mask: $event.mask }"
/></tbody
></DataTable>
</div>
<div class="group-keys__mobile">
<GroupKeyMobileCard
v-for="item in collection.items"
:key="item.id"
:item="item"
:selected="selectedIds.has(item.id)"
:busy="pending(item.id)"
@update:selected="setSelected(item.id, $event)"
@weight="mutateItem($event.item, 'weight', $event.value)"
@toggle="mutateItem($event, 'toggle')"
@restore="mutateItem($event, 'restore')"
@remove="deleteTarget = { ids: [$event.id], mask: $event.mask }"
/>
</div>
<PaginationBar
:page="collection.pagination.page"
:page-size="collection.pagination.page_size"
:total-items="collection.pagination.total_items"
:total-pages="collection.pagination.total_pages"
show-page-size
@previous="setPage(filters.page - 1)"
@next="setPage(filters.page + 1)"
@update:page-size="setPageSize"
/>
<GroupKeyBatchBar
v-if="selectedCount > 0"
:selected-count="selectedCount"
:pending="batchBusy"
@enable="runBatch('enable')"
@disable="runBatch('disable')"
@remove="deleteTarget = { ids: [...selectedIds] }"
@clear="selectedIds = new Set()"
/>
</template>
</template>
<AppDialog
:open="deleteTarget !== undefined"
:title="
deleteTarget?.ids.length === 1
? t('group.keys.deleteTitle')
: t('group.keys.batch.deleteTitle')
"
:description="
deleteTarget?.ids.length === 1
? t('group.keys.deleteDescription', { mask: deleteTarget.mask })
: t('group.keys.batch.deleteDescription', { count: n(deleteTarget?.ids.length ?? 0) })
"
:close-label="t('group.keys.closeDialog')"
:dismissible="!batchBusy"
@update:open="!$event && (deleteTarget = undefined)"
><div class="group-keys__dialog-actions">
<AppButton variant="secondary" :disabled="batchBusy" @click="deleteTarget = undefined">{{
t('group.keys.cancel')
}}</AppButton
><AppButton variant="danger" :busy="batchBusy" @click="confirmDelete">{{
t('group.keys.confirmDelete')
}}</AppButton>
</div></AppDialog
>
</section>
</template>
<style scoped>
.group-keys {
display: grid;
min-width: 0;
gap: var(--space-4);
padding-top: var(--space-4);
}
.group-keys__header h2 {
.group-keys__header h2,
.group-keys__header p {
margin: 0;
font-size: 1.125rem;
}
.group-keys__header p {
margin: var(--space-1) 0 0;
margin-top: var(--space-1);
color: var(--color-text-muted);
}
.group-keys__filter {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
border: 1px solid var(--color-warning);
border-radius: var(--radius-control);
background: var(--color-warning-bg);
padding: var(--space-2) var(--space-3);
}
.group-keys__filter p {
margin: 0;
color: var(--color-text-muted);
}
.group-keys__mask,
.group-keys__number {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.group-keys select {
min-width: 92px;
min-height: 44px;
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface-sunken);
color: var(--color-text);
padding: 7px 9px;
font: inherit;
cursor: pointer;
}
.group-keys__actions,
.group-keys__dialog-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.group-keys__actions :deep(.app-button) {
padding-inline: var(--space-2);
font-size: 0.8125rem;
}
.group-keys__delete :deep(button),
.group-keys__delete,
.group-keys__confirm-delete {
color: var(--color-danger);
}
.group-keys__confirm-delete {
border-color: var(--color-danger);
}
.group-keys__dialog-actions {
justify-content: flex-end;
}
.group-keys__action-error {
.group-keys__feedback {
margin: 0;
border: 1px solid var(--color-feedback-danger-border);
border-radius: var(--radius-control);
background: var(--color-danger-bg);
color: var(--color-danger);
color: var(--color-text);
padding: var(--space-3);
}
@media (max-width: 640px) {
.group-keys select {
font-size: 16px;
.group-keys__filters {
display: grid;
max-width: 420px;
}
.group-keys__filters label {
display: grid;
gap: var(--space-1);
color: var(--color-text-faint);
font-size: var(--text-sm);
}
.group-keys__search {
position: relative;
display: block;
}
.group-keys__search > svg {
position: absolute;
top: 50%;
left: 10px;
transform: translateY(-50%);
color: var(--color-text-faint);
pointer-events: none;
}
.group-keys__search input {
width: 100%;
min-height: var(--control-md);
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface);
color: var(--color-text);
padding: 0 40px 0 32px;
font: inherit;
}
.group-keys__search :deep(.icon-button) {
position: absolute;
top: 2px;
right: 3px;
}
.group-keys__mobile {
display: none;
gap: var(--space-3);
}
.group-keys__dialog-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
@media (max-width: 767px) {
.group-keys__desktop {
display: none;
}
.group-keys__mobile {
display: grid;
}
}
</style>
+56 -4
View File
@@ -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
+56 -4
View File
@@ -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
+56 -4
View File
@@ -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