feat(web): 统一工作流焦点与播报

This commit is contained in:
tbphp committed 2026-07-28 22:21:29 +08:00
1 parent e96ab545df
commit 536ef78161
14 files changed
+163 -28

No files matched your search

@@ -46,7 +46,7 @@ describe('AccessKeyDeleteDialog', () => {
method: 'DELETE',
signal: expect.any(AbortSignal),
})
expect(wrapper.emitted('deleted')).toEqual([[]])
expect(wrapper.emitted('deleted')).toEqual([['client']])
expect(invalidate).not.toHaveBeenCalled()
expect(client.getMutationCache().getAll()).toHaveLength(0)
wrapper.unmount()
@@ -108,7 +108,7 @@ describe('AccessKeyDeleteDialog', () => {
resolveDelete()
await flushPromises()
expect(wrapper.emitted('deleted')).toEqual([[]])
expect(wrapper.emitted('deleted')).toEqual([['client']])
wrapper.unmount()
})
})
@@ -12,7 +12,7 @@ import AppDialog from '@/components/ui/AppDialog.vue'
import InlineFeedback from '@/components/ui/InlineFeedback.vue'
const props = defineProps<{ accessKey: AccessKeyDto; total: number }>()
const emit = defineEmits<{ deleted: [] }>()
const emit = defineEmits<{ deleted: [name: string] }>()
const client = useApiClient()
const { t } = useI18n()
const open = ref(false)
@@ -39,7 +39,7 @@ async function confirmDelete(): Promise<void> {
try {
await deleteAccessKey(client, props.accessKey.id, activeController.signal)
open.value = false
emit('deleted')
emit('deleted', props.accessKey.name)
} catch (error: unknown) {
if (!(error instanceof RequestCancelledError)) failed.value = true
} finally {
@@ -15,7 +15,10 @@ import AccessKeyDeleteDialog from './AccessKeyDeleteDialog.vue'
import { useEphemeralSecret } from './use-ephemeral-secret'
const props = defineProps<{ accessKeys: AccessKeyDto[]; groups: GroupSummary[] }>()
const emit = defineEmits<{ edit: [accessKey: AccessKeyDto, trigger: HTMLElement]; deleted: [] }>()
const emit = defineEmits<{
edit: [accessKey: AccessKeyDto, trigger: HTMLElement]
deleted: [name: string]
}>()
const client = useApiClient()
const { locale, t } = useI18n()
const secret = useEphemeralSecret()
@@ -179,7 +182,7 @@ function rpmSummary(rpm: number): string {
<AccessKeyDeleteDialog
:access-key="accessKey"
:total="accessKeys.length"
@deleted="emit('deleted')"
@deleted="emit('deleted', $event)"
/>
</div>
</td>
@@ -183,6 +183,9 @@ describe('AccessKeysView', () => {
expect(document.activeElement).toBe(
wrapper.get('button[data-test="access-key-create"]').element,
)
const announcement = wrapper.get('[data-test="access-key-delete-announcement"]')
expect(announcement.attributes('aria-live')).toBe('polite')
expect(announcement.text()).toContain('Deleted AccessKey “client”.')
wrapper.unmount()
})
@@ -292,7 +295,7 @@ describe('AccessKeysView', () => {
).element
const focus = vi.spyOn(createButton, 'focus')
wrapper.getComponent(AccessKeyTable).vm.$emit('deleted')
wrapper.getComponent(AccessKeyTable).vm.$emit('deleted', 'client')
wrapper.unmount()
await nextTick()
await flushPromises()
@@ -27,6 +27,7 @@ const drawerOpen = ref(false)
const selected = ref<AccessKeyDto | null>(null)
const createOperation = ref<PendingAccessKeyCreateOperation | null>(null)
const viewRoot = ref<HTMLElement | null>(null)
const deletionAnnouncement = ref('')
let restoreFocus: HTMLElement | null = null
let mounted = true
@@ -88,7 +89,9 @@ async function setDrawerOpen(open: boolean): Promise<void> {
}
}
async function focusCreateAfterDelete(): Promise<void> {
async function focusCreateAfterDelete(name: string): Promise<void> {
deletionAnnouncement.value = ''
await nextTick()
await Promise.all(
accessKeyMutationInvalidations.delete.map((queryKey) =>
queryClient.invalidateQueries({ queryKey, exact: true }),
@@ -96,6 +99,7 @@ async function focusCreateAfterDelete(): Promise<void> {
)
await nextTick()
if (!mounted) return
deletionAnnouncement.value = t('accessKeys.delete.deletedAnnouncement', { name })
const target = viewRoot.value?.querySelector('button[data-test="access-key-create"]')
if (target instanceof HTMLButtonElement && target.isConnected) target.focus()
}
@@ -143,6 +147,15 @@ async function focusCreateAfterDelete(): Promise<void> {
</AppButton>
</section>
<p
class="sr-only"
data-test="access-key-delete-announcement"
aria-live="polite"
aria-atomic="true"
>
{{ deletionAnnouncement }}
</p>
<QueryFeedback
v-if="accessKeysQuery.isPending.value"
state="loading"
@@ -54,6 +54,7 @@ async function mountExisting(
path: string,
request: ApiClient['request'],
initialDraft?: ExistingGroupImportDraft,
attachTo?: Element,
) {
const client = queryClient()
const importRecovery = recovery()
@@ -65,13 +66,43 @@ async function mountExisting(
locale: 'en-US',
recovery: importRecovery,
operationOwner,
mounting: { props: { initialDraft } },
mounting: { props: { initialDraft }, attachTo },
})
await flushPromises()
return { ...mounted, importRecovery, operationOwner, queryClient: client }
}
describe('ExistingGroupImport', () => {
it('focuses the review heading and then the confirmed result heading', async () => {
const request = vi.fn(async (path: string) => {
if (path === '/api/groups') return groups
if (path === '/api/groups/7/keys/import') {
return { group_id: 7, keys_added: 1, keys_duplicated: 0 }
}
throw new Error(`unexpected request: ${path}`)
}) as ApiClient['request']
const { wrapper } = await mountExisting(
'/import?mode=existing&group_id=7',
request,
undefined,
document.body,
)
await wrapper.get('[data-test="keys"]').setValue('raw-key')
await wrapper.get('[data-test="existing-review"]').trigger('click')
await flushPromises()
expect(document.activeElement).toBe(
wrapper.get('[data-test="existing-review-heading"]').element,
)
await wrapper.get('[data-test="existing-submit"]').trigger('click')
await flushPromises()
expect(document.activeElement).toBe(
wrapper.get('[data-test="existing-result-heading"]').element,
)
wrapper.unmount()
})
it('preselects a positive route Group and imports exact raw keys without discovery or Group mutation', async () => {
const requestMock = vi.fn(async (path: string, _options?: ApiRequestOptions) => {
void _options
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { Check, ChevronLeft } from 'lucide-vue-next'
import { computed, onBeforeUnmount, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
@@ -32,6 +32,8 @@ const reviewing = ref(false)
const completed = ref(false)
const errorKey = ref('')
const result = ref<GroupKeyImportResult | null>(null)
const reviewHeading = ref<HTMLHeadingElement | null>(null)
const resultHeading = ref<HTMLHeadingElement | null>(null)
const importOperationOwner = useImportOperationOwner()
const operation = importOperationOwner.importKeys
if (operation.outcome.value?.kind === 'confirmed') operation.reset()
@@ -101,10 +103,12 @@ function selectGroup(event: Event): void {
void router.push({ name: 'import', query })
}
function showReview(): void {
async function showReview(): Promise<void> {
if (!canReview.value) return
errorKey.value = ''
reviewing.value = true
await nextTick()
reviewHeading.value?.focus()
}
function returnToEdit(): void {
@@ -160,6 +164,8 @@ async function executeImportOperation(): Promise<void> {
completed.value = true
keys.value = ''
recovery.clear()
await nextTick()
resultHeading.value?.focus()
return
}
if (!componentActive) return
@@ -241,7 +247,14 @@ onBeforeUnmount(() => {
<template v-else>
<section class="review" :aria-labelledby="'existing-review-title'">
<h3 id="existing-review-title">{{ t('import.existing.reviewTitle') }}</h3>
<h3
id="existing-review-title"
ref="reviewHeading"
data-test="existing-review-heading"
tabindex="-1"
>
{{ t('import.existing.reviewTitle') }}
</h3>
<p>{{ t('import.existing.reviewDescription') }}</p>
<dl>
<div>
@@ -273,7 +286,9 @@ onBeforeUnmount(() => {
<section v-else data-test="existing-result" class="result" role="status" aria-live="polite">
<Check :size="22" aria-hidden="true" />
<div>
<h3>{{ t('import.existing.successTitle') }}</h3>
<h3 ref="resultHeading" data-test="existing-result-heading" tabindex="-1">
{{ t('import.existing.successTitle') }}
</h3>
<p>
{{
t('import.existing.successSummary', {
+31 -1
View File
@@ -30,7 +30,11 @@ function recovery(): ImportRecoveryService {
}
}
async function mountImport(request: ApiClient['request'], initialDraft?: ImportDraft) {
async function mountImport(
request: ApiClient['request'],
initialDraft?: ImportDraft,
attachTo?: Element,
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
@@ -41,6 +45,7 @@ async function mountImport(request: ApiClient['request'], initialDraft?: ImportD
const operationOwner = createImportOperationOwner()
const wrapper = mount(NewGroupImport, {
props: { initialDraft },
attachTo,
global: {
plugins: [
router,
@@ -73,6 +78,31 @@ async function discoverAndReview(wrapper: ReturnType<typeof mount>) {
}
describe('NewGroupImport', () => {
it('focuses the new heading after every explicit step transition', async () => {
const request = vi.fn().mockResolvedValue({ models: ['gpt-4o'] }) as ApiClient['request']
const { wrapper } = await mountImport(request, undefined, document.body)
await enterConnection(wrapper)
await wrapper.get('[data-test="discover"]').trigger('click')
await flushPromises()
expect(document.activeElement).toBe(wrapper.get('[data-test="import-step-2-heading"]').element)
await wrapper.get('[data-test="review"]').trigger('click')
await flushPromises()
expect(document.activeElement).toBe(wrapper.get('[data-test="import-step-3-heading"]').element)
const reviewBack = wrapper.findAll('button').find((button) => button.text() === 'Back')
await reviewBack?.trigger('click')
await flushPromises()
expect(document.activeElement).toBe(wrapper.get('[data-test="import-step-2-heading"]').element)
const modelsBack = wrapper.findAll('button').find((button) => button.text() === 'Back')
await modelsBack?.trigger('click')
await flushPromises()
expect(document.activeElement).toBe(wrapper.get('[data-test="import-step-1-heading"]').element)
wrapper.unmount()
})
it('disables conflict Edit while append is pending', async () => {
const request = vi
.fn()
+28 -11
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useQueryClient } from '@tanstack/vue-query'
import { Check, ChevronLeft, ChevronRight, Search, TriangleAlert } from 'lucide-vue-next'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
@@ -71,6 +71,9 @@ const manualMode = ref(
const errorKey = ref('')
const conflict = ref<UpstreamUrlConflictData | null>(null)
const completed = ref(false)
const step1Heading = ref<HTMLHeadingElement | null>(null)
const step2Heading = ref<HTMLHeadingElement | null>(null)
const step3Heading = ref<HTMLHeadingElement | null>(null)
const importOperationOwner = useImportOperationOwner()
const createOperation = importOperationOwner.createGroup
const appendOperation = importOperationOwner.importKeys
@@ -181,6 +184,14 @@ function invalidateDiscovery(): void {
if (draft.step > 1) draft.step = 1
}
async function moveToStep(step: ImportDraft['step']): Promise<void> {
draft.step = step
await nextTick()
const heading =
step === 1 ? step1Heading.value : step === 2 ? step2Heading.value : step3Heading.value
heading?.focus()
}
watch(
[
() => draft.upstream_url,
@@ -225,12 +236,12 @@ async function runDiscovery(): Promise<void> {
discoveryReady.value = true
discoveryFailed.value = false
manualMode.value = true
draft.step = 2
await moveToStep(2)
} catch (error: unknown) {
if (discoveryController !== controller || error instanceof RequestCancelledError) return
discoveryFailed.value = true
manualMode.value = false
draft.step = 2
await moveToStep(2)
errorKey.value = 'import.discoveryFailed'
} finally {
finishAction(controller)
@@ -349,11 +360,11 @@ async function retryOperation(): Promise<void> {
if (createOperation.operation.value) await submitCreate()
}
function returnToEdit(): void {
async function returnToEdit(): Promise<void> {
createOperation.reset()
appendOperation.reset()
conflict.value = null
draft.step = 1
await moveToStep(1)
}
onBeforeUnmount(() => {
@@ -405,7 +416,9 @@ onBeforeUnmount(() => {
<SurfaceCard v-if="draft.step === 1" class="import-card">
<header>
<h2>{{ t('import.connection.title') }}</h2>
<h2 ref="step1Heading" data-test="import-step-1-heading" tabindex="-1">
{{ t('import.connection.title') }}
</h2>
<p>{{ t('import.connection.description') }}</p>
</header>
<div class="connection-grid">
@@ -465,7 +478,9 @@ onBeforeUnmount(() => {
<SurfaceCard v-else-if="draft.step === 2" class="import-card">
<header>
<h2>{{ t('import.models.title') }}</h2>
<h2 ref="step2Heading" data-test="import-step-2-heading" tabindex="-1">
{{ t('import.models.title') }}
</h2>
<p>{{ t('import.models.stepDescription') }}</p>
</header>
<InlineFeedback v-if="errorKey" tone="warning">{{ t(errorKey) }}</InlineFeedback>
@@ -480,9 +495,9 @@ onBeforeUnmount(() => {
</button>
<ModelDraftEditor v-if="manualMode" v-model="draft.models" />
<footer class="card-actions split">
<AppButton variant="secondary" @click="draft.step = 1"
<AppButton variant="secondary" @click="moveToStep(1)"
><ChevronLeft :size="16" aria-hidden="true" />{{ t('import.back') }}</AppButton
><AppButton data-test="review" :disabled="!canReview" @click="draft.step = 3"
><AppButton data-test="review" :disabled="!canReview" @click="moveToStep(3)"
>{{ t('import.review') }}<ChevronRight :size="16" aria-hidden="true"
/></AppButton>
</footer>
@@ -490,7 +505,9 @@ onBeforeUnmount(() => {
<SurfaceCard v-else class="import-card">
<header>
<h2>{{ t('import.reviewTitle') }}</h2>
<h2 ref="step3Heading" data-test="import-step-3-heading" tabindex="-1">
{{ t('import.reviewTitle') }}
</h2>
<p>{{ t('import.reviewDescription') }}</p>
</header>
<dl class="review-list">
@@ -553,7 +570,7 @@ onBeforeUnmount(() => {
<AppButton
variant="secondary"
:disabled="pending || operationNoticeKey !== ''"
@click="draft.step = 2"
@click="moveToStep(2)"
><ChevronLeft :size="16" aria-hidden="true" />{{ t('import.back') }}</AppButton
><AppButton
data-test="create"
@@ -197,6 +197,8 @@ describe('InspectorTab', () => {
'/monitor?tab=inspector&protocol=anthropic&external_model=claude-exact&access_key_id=13&raw_access_key=sk-gl-route-canary',
)
const submit = wrapper.get<HTMLButtonElement>('[data-test="inspector-submit"]').element
submit.focus()
await wrapper.get('[data-test="inspector-form"]').trigger('submit')
await flushPromises()
@@ -224,6 +226,8 @@ describe('InspectorTab', () => {
expect(wrapper.get('[data-test="inspector-access-key-status"]').classes()).toContain(
'status-badge--neutral',
)
expect(wrapper.get('[data-test="inspector-result"]').attributes('aria-live')).toBe('polite')
expect(document.activeElement).toBe(submit)
})
it('requires reselection when a deep-linked AccessKey no longer exists', async () => {
@@ -598,6 +602,9 @@ describe('InspectorTab', () => {
'complete current-runtime explanation',
)
expect(wrapper.find('[data-test="inspector-request-error"]').exists()).toBe(false)
expect(document.activeElement).toBe(
wrapper.get('[data-test="inspector-result-summary"]').element,
)
})
it('maps all 14 reason codes and uses a safe fallback for an unknown code', async () => {
+16 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
@@ -56,6 +56,7 @@ const failed = ref(false)
const resultStale = ref(false)
const submitted = ref<RouteInspectRequest>()
const observation = ref<RouteInspectResponseDto>()
const resultSummary = ref<HTMLHeadingElement | null>(null)
let owner = 0
let controller: AbortController | undefined
@@ -217,6 +218,10 @@ async function inspect(): Promise<void> {
submitted.value = request
observation.value = result
resultStale.value = false
await nextTick()
if (currentOwner === owner && !currentController.signal.aborted && !result.routable) {
resultSummary.value?.focus()
}
}
} catch (error: unknown) {
if (
@@ -393,7 +398,13 @@ onBeforeUnmount(() => {
@retry="inspect"
/>
<SurfaceCard v-if="observation" class="inspector-result" data-test="inspector-result">
<SurfaceCard
v-if="observation"
class="inspector-result"
data-test="inspector-result"
aria-live="polite"
aria-atomic="true"
>
<p
v-if="inputChanged"
class="inspector-input-changed"
@@ -413,7 +424,9 @@ onBeforeUnmount(() => {
<header class="inspector-heading inspector-result__heading">
<div>
<h2>{{ t('monitor.inspector.result.title') }}</h2>
<h2 ref="resultSummary" data-test="inspector-result-summary" tabindex="-1">
{{ t('monitor.inspector.result.title') }}
</h2>
<p>{{ t('monitor.inspector.boundary') }}</p>
</div>
<StatusBadge
@@ -91,6 +91,7 @@ export default {
'This is the last AccessKey. Deleting it leaves data-plane clients without an issued credential.',
confirm: 'Delete AccessKey',
failed: 'Unable to delete the AccessKey.',
deletedAnnouncement: 'Deleted AccessKey “{name}”.',
},
},
} as const
@@ -92,6 +92,7 @@ export default {
'これは最後の AccessKey です。削除すると、データプレーンクライアントに発行済み資格情報がなくなります。',
confirm: 'AccessKey を削除',
failed: 'AccessKey を削除できません。',
deletedAnnouncement: 'AccessKey「{name}」を削除しました。',
},
},
} as const
@@ -82,6 +82,7 @@ export default {
lastWarning: '这是最后一个 AccessKey。删除后,数据面客户端将没有已签发凭据。',
confirm: '删除 AccessKey',
failed: '无法删除 AccessKey。',
deletedAnnouncement: '已删除 AccessKey“{name}”。',
},
},
} as const