feat(groups): 实现分组密钥详情页面
This commit is contained in:
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.
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user