fix(web): 允许在列表页核对编辑结果

This commit is contained in:
tbphp committed 2026-07-29 01:24:19 +08:00
1 parent 36660a2696
commit 3744cd5a4e
8 files changed
+200 -27

No files matched your search

@@ -797,9 +797,7 @@ describe('AccessKeyDrawer', () => {
const check = element<HTMLButtonElement>('[data-test="access-key-save"]')
expect(check.textContent).toContain('Check result')
expect(element<HTMLButtonElement>('.app-drawer__close').disabled).toBe(true)
requestDrawerClose('close')
expect(wrapper.emitted('update:open') ?? []).not.toContainEqual([false])
expect(element<HTMLButtonElement>('.app-drawer__close').disabled).toBe(false)
check.click()
await flushPromises()
@@ -828,7 +826,7 @@ describe('AccessKeyDrawer', () => {
expect(element<HTMLButtonElement>('[data-test="access-key-save"]').textContent).toContain(
'Check result',
)
expect(element<HTMLButtonElement>('.app-drawer__close').disabled).toBe(true)
expect(element<HTMLButtonElement>('.app-drawer__close').disabled).toBe(false)
}
wrapper.unmount()
},
@@ -11,7 +11,6 @@ import {
revealAccessKey,
updateAccessKey,
type CreateAccessKeyRequest,
type UpdateAccessKeyRequest,
} from '@/api/control/access-keys'
import type { AccessKeyDto, AccessProtocol, GroupSummary } from '@/api/control/types'
import { RequestCancelledError } from '@/api/errors'
@@ -26,6 +25,7 @@ import SecretValue from '@/components/ui/SecretValue.vue'
import { accessKeyProtocolOptions, buildAccessKeyModelOptions } from './access-key-options'
import type { PendingAccessKeyCreateOperation } from './access-key-create-operation'
import type { PendingAccessKeyEditOperation } from './access-key-edit-operation'
import {
materializeAccessKeyFilters,
validateAccessKeyScope,
@@ -45,11 +45,6 @@ import {
} from './access-key-patch'
import { useEphemeralSecret } from './use-ephemeral-secret'
interface PendingAccessKeyEditReconciliation {
base: AccessKeyDto
patch: UpdateAccessKeyRequest
}
const props = withDefaults(
defineProps<{
open: boolean
@@ -57,12 +52,14 @@ const props = withDefaults(
groups: GroupSummary[]
groupCatalogState?: GroupCatalogState
createOperation?: PendingAccessKeyCreateOperation | null
editOperation?: PendingAccessKeyEditOperation | null
}>(),
{ createOperation: null, groupCatalogState: 'ready' },
{ createOperation: null, editOperation: null, groupCatalogState: 'ready' },
)
const emit = defineEmits<{
'update:open': [open: boolean]
'update:createOperation': [operation: PendingAccessKeyCreateOperation | null]
'update:editOperation': [operation: PendingAccessKeyEditOperation | null]
}>()
const client = useApiClient()
const queryClient = useQueryClient()
@@ -74,10 +71,11 @@ const result = ref<AccessKeyDto | null>(null)
const operationID = ref('')
const createPayload = ref<CreateAccessKeyRequest | null>(null)
const createOperationRetained = ref(false)
const editOperationRetained = ref(false)
const pending = ref(false)
const failed = ref(false)
const mutationState = ref<'idle' | 'indeterminate' | 'reconciling'>('idle')
const editReconciliation = ref<PendingAccessKeyEditReconciliation | null>(null)
const editReconciliation = ref<PendingAccessKeyEditOperation | null>(null)
const editNotApplied = ref(false)
const revealPending = ref(false)
const revealFailed = ref(false)
@@ -97,7 +95,7 @@ const createOperationActive = computed(
const formLocked = computed(
() => pending.value || createOperationActive.value || editReconciliation.value !== null,
)
const closeBlocked = computed(() => pending.value || editReconciliation.value !== null)
const closeBlocked = computed(() => pending.value)
const resultSecret = computed(() =>
result.value ? ephemeralSecret.read(`access-key:${result.value.id}`) : null,
)
@@ -110,7 +108,8 @@ const unsavedDirty = computed(
() =>
dirty.value &&
!createCompleted.value &&
!(createOperationActive.value && createOperationRetained.value),
!(createOperationActive.value && createOperationRetained.value) &&
!(editReconciliation.value && editOperationRetained.value),
)
const groupCatalog = computed(() => ({
state: props.groupCatalogState,
@@ -195,6 +194,7 @@ function clearLocalState(): void {
operationID.value = ''
createPayload.value = null
createOperationRetained.value = false
editOperationRetained.value = false
pending.value = false
revealPending.value = false
revealFailed.value = false
@@ -206,21 +206,34 @@ function clearLocalState(): void {
}
async function resetForOpen(): Promise<void> {
const carriedOperation = props.accessKey ? null : props.createOperation
base.value = props.accessKey
draft.value = carriedOperation
? createAccessKeyDraftFromCreateInput(carriedOperation.payload)
: createAccessKeyDraft(props.accessKey)
const carriedCreateOperation = props.accessKey ? null : props.createOperation
const carriedEditOperation =
props.accessKey && props.editOperation?.base.id === props.accessKey.id
? props.editOperation
: null
base.value = carriedEditOperation?.base ?? props.accessKey
draft.value = carriedCreateOperation
? createAccessKeyDraftFromCreateInput(carriedCreateOperation.payload)
: carriedEditOperation
? createAccessKeyDraft({
...carriedEditOperation.base,
...carriedEditOperation.patch,
filters: carriedEditOperation.patch.filters ?? carriedEditOperation.base.filters,
})
: createAccessKeyDraft(props.accessKey)
result.value = null
operationID.value = props.accessKey
? ''
: (carriedOperation?.idempotencyKey ?? crypto.randomUUID())
createPayload.value = carriedOperation ? cloneCreatePayload(carriedOperation.payload) : null
createOperationRetained.value = carriedOperation !== null
: (carriedCreateOperation?.idempotencyKey ?? crypto.randomUUID())
createPayload.value = carriedCreateOperation
? cloneCreatePayload(carriedCreateOperation.payload)
: null
createOperationRetained.value = carriedCreateOperation !== null
editOperationRetained.value = carriedEditOperation !== null
ephemeralSecret.clear()
failed.value = false
mutationState.value = carriedOperation?.state ?? 'idle'
editReconciliation.value = null
mutationState.value = carriedCreateOperation?.state ?? carriedEditOperation?.state ?? 'idle'
editReconciliation.value = carriedEditOperation
editNotApplied.value = false
modelInput.value = ''
await nextTick()
@@ -371,6 +384,8 @@ async function save(): Promise<void> {
draft.value = createAccessKeyDraft(saved)
result.value = null
editReconciliation.value = null
editOperationRetained.value = false
emit('update:editOperation', null)
} else {
const saved = await createAccessKey(
client,
@@ -441,10 +456,14 @@ async function save(): Promise<void> {
updateBody &&
(outcome.kind === 'indeterminate' || outcome.kind === 'reconciling')
) {
editReconciliation.value = {
const operation: PendingAccessKeyEditOperation = {
base: currentBase,
patch: updateBody,
state: outcome.kind,
}
editReconciliation.value = operation
editOperationRetained.value = true
emit('update:editOperation', operation)
}
mutationState.value =
outcome.kind === 'reconciling'
@@ -479,6 +498,8 @@ async function reconcileEdit(): Promise<void> {
const latest = accessKeys.find((accessKey) => accessKey.id === attempt.base.id)
if (!latest) {
editReconciliation.value = null
editOperationRetained.value = false
emit('update:editOperation', null)
mutationState.value = 'idle'
failed.value = true
return
@@ -487,6 +508,8 @@ async function reconcileEdit(): Promise<void> {
base.value = latest
draft.value = createAccessKeyDraft(latest)
editReconciliation.value = null
editOperationRetained.value = false
emit('update:editOperation', null)
mutationState.value = 'idle'
return
}
@@ -496,19 +519,33 @@ async function reconcileEdit(): Promise<void> {
) {
base.value = latest
editReconciliation.value = null
editOperationRetained.value = false
emit('update:editOperation', null)
mutationState.value = 'idle'
failed.value = true
editNotApplied.value = true
return
}
mutationState.value = 'indeterminate'
const operation: PendingAccessKeyEditOperation = {
...attempt,
state: 'indeterminate',
}
editReconciliation.value = operation
emit('update:editOperation', operation)
mutationState.value = operation.state
} catch (error: unknown) {
if (
controller === activeController &&
editReconciliation.value === attempt &&
!(error instanceof RequestCancelledError)
) {
mutationState.value = 'indeterminate'
const operation: PendingAccessKeyEditOperation = {
...attempt,
state: 'indeterminate',
}
editReconciliation.value = operation
emit('update:editOperation', operation)
mutationState.value = operation.state
}
} finally {
if (controller === activeController) {
@@ -283,6 +283,77 @@ describe('AccessKeysView', () => {
wrapper.unmount()
})
it('keeps an unknown edit operation at page scope after the drawer closes', async () => {
const applied: AccessKeyDto = { ...keys[0], name: 'renamed-client' }
const secondary: AccessKeyDto = {
...keys[0],
id: 10,
name: 'secondary-client',
masked_key: 'sk-gl-••••••••beef',
}
let listCalls = 0
const requestMock = vi.fn(async (path: string, options?: ApiRequestOptions) => {
if (path === '/api/groups' && options?.method === 'GET') return groups
if (path === '/api/access-keys' && options?.method === 'GET') {
listCalls += 1
return listCalls === 1 ? [keys[0], secondary] : [applied, secondary]
}
if (path === '/api/access-keys/9' && options?.method === 'PUT') throw new NetworkError()
throw new Error(`unexpected ${path}`)
})
const confirm = vi.fn(() => false)
vi.stubGlobal('confirm', confirm)
const { wrapper } = await mountView(requestMock as ApiClient['request'])
await wrapper.get('[data-test="access-key-edit-9"]').trigger('click')
await flushPromises()
const name = document.querySelector<HTMLInputElement>('[data-test="access-key-name"]')
if (!name) throw new Error('missing edit name')
name.value = 'renamed-client'
name.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
documentButton('[data-test="access-key-save"]').click()
await flushPromises()
const close = documentButton('.app-drawer__close')
expect(close.disabled).toBe(false)
close.click()
await flushPromises()
expect(confirm).not.toHaveBeenCalled()
expect(document.querySelector('[role="dialog"]')).toBeNull()
expect(wrapper.get('[data-test="access-key-edit-operation-notice"]').text()).toContain(
'renamed-client',
)
expect(wrapper.get('[data-test="access-key-edit-operation-notice"]').text()).toContain(
'unknown outcome',
)
await wrapper.get('[data-test="access-key-edit-10"]').trigger('click')
await flushPromises()
expect(document.querySelector<HTMLInputElement>('[data-test="access-key-name"]')?.value).toBe(
'renamed-client',
)
documentButton('.app-drawer__close').click()
await flushPromises()
await wrapper.get('[data-test="access-key-edit-operation-check"]').trigger('click')
await flushPromises()
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
documentButton('[data-test="access-key-save"]').click()
await flushPromises()
expect(
requestMock.mock.calls.filter(
([path, options]) => path === '/api/access-keys/9' && options?.method === 'PUT',
),
).toHaveLength(1)
expect(listCalls).toBe(2)
expect(wrapper.find('[data-test="access-key-edit-operation-notice"]').exists()).toBe(false)
wrapper.unmount()
vi.unstubAllGlobals()
})
it('does not focus detached content when deleted is followed by immediate unmount', async () => {
const request = vi.fn(async (path: string, options?: ApiRequestOptions) => {
if (path === '/api/access-keys' && options?.method === 'GET') return keys
@@ -19,6 +19,7 @@ import QueryFeedback from '@/components/ui/QueryFeedback.vue'
import AccessKeyDrawer from './AccessKeyDrawer.vue'
import AccessKeyTable from './AccessKeyTable.vue'
import type { PendingAccessKeyCreateOperation } from './access-key-create-operation'
import type { PendingAccessKeyEditOperation } from './access-key-edit-operation'
const client = useApiClient()
const queryClient = useQueryClient()
@@ -26,6 +27,7 @@ const { t } = useI18n()
const drawerOpen = ref(false)
const selected = ref<AccessKeyDto | null>(null)
const createOperation = ref<PendingAccessKeyCreateOperation | null>(null)
const editOperation = ref<PendingAccessKeyEditOperation | null>(null)
const viewRoot = ref<HTMLElement | null>(null)
const deletionAnnouncement = ref('')
let restoreFocus: HTMLElement | null = null
@@ -54,6 +56,14 @@ const operationNoticeKey = computed(() =>
? 'accessKeys.operation.reconciling'
: 'accessKeys.operation.indeterminate',
)
const editOperationNoticeKey = computed(() =>
editOperation.value?.state === 'reconciling'
? 'accessKeys.operation.editReconciling'
: 'accessKeys.operation.editIndeterminate',
)
const editOperationName = computed(
() => editOperation.value?.patch.name ?? editOperation.value?.base.name ?? '',
)
function createKey(): void {
selected.value = null
@@ -62,6 +72,10 @@ function createKey(): void {
}
function editKey(accessKey: AccessKeyDto, trigger: HTMLElement): void {
if (editOperation.value && editOperation.value.base.id !== accessKey.id) {
checkEditOperation()
return
}
selected.value = accessKey
restoreFocus = trigger
drawerOpen.value = true
@@ -71,6 +85,10 @@ function setCreateOperation(operation: PendingAccessKeyCreateOperation | null):
createOperation.value = operation
}
function setEditOperation(operation: PendingAccessKeyEditOperation | null): void {
editOperation.value = operation
}
function checkCreateOperation(): void {
if (!createOperation.value) return
selected.value = null
@@ -78,6 +96,16 @@ function checkCreateOperation(): void {
drawerOpen.value = true
}
function checkEditOperation(): void {
const operation = editOperation.value
if (!operation) return
selected.value =
accessKeysQuery.data.value?.find((accessKey) => accessKey.id === operation.base.id) ??
operation.base
restoreFocus = null
drawerOpen.value = true
}
async function setDrawerOpen(open: boolean): Promise<void> {
drawerOpen.value = open
if (!open) {
@@ -119,7 +147,9 @@ async function focusCreateAfterDelete(name: string): Promise<void> {
:groups="groupsQuery.data.value ?? []"
:group-catalog-state="groupCatalogState"
:create-operation="createOperation"
:edit-operation="selected?.id === editOperation?.base.id ? editOperation : null"
@update:create-operation="setCreateOperation"
@update:edit-operation="setEditOperation"
@update:open="setDrawerOpen"
>
<template #trigger>
@@ -147,6 +177,24 @@ async function focusCreateAfterDelete(name: string): Promise<void> {
</AppButton>
</section>
<section
v-if="editOperation"
class="access-keys__operation"
data-test="access-key-edit-operation-notice"
aria-live="polite"
>
<InlineFeedback tone="warning">{{
t(editOperationNoticeKey, { name: editOperationName })
}}</InlineFeedback>
<AppButton
data-test="access-key-edit-operation-check"
variant="secondary"
@click="checkEditOperation"
>
{{ t('accessKeys.operation.checkResult') }}
</AppButton>
</section>
<p
class="sr-only"
data-test="access-key-delete-announcement"
@@ -0,0 +1,8 @@
import type { UpdateAccessKeyRequest } from '@/api/control/access-keys'
import type { AccessKeyDto } from '@/api/control/types'
export interface PendingAccessKeyEditOperation {
base: AccessKeyDto
patch: UpdateAccessKeyRequest
state: 'indeterminate' | 'reconciling'
}
@@ -24,6 +24,10 @@ export default {
indeterminate:
'The AccessKey create outcome is unknown. Reconcile it before starting another create.',
reconciling: 'The AccessKey was committed and runtime recovery is still in progress.',
editIndeterminate:
'The update to AccessKey “{name}” has an unknown outcome. Check current metadata before editing another AccessKey.',
editReconciling:
'The update to AccessKey “{name}” was committed and still needs metadata reconciliation.',
checkResult: 'Check result',
},
status: {
@@ -24,6 +24,10 @@ export default {
indeterminate:
'AccessKey の作成結果は不明です。新しい作成を始める前に、この操作の結果を確認してください。',
reconciling: 'AccessKey は確定しましたが、ランタイムの復旧処理が続いています。',
editIndeterminate:
'AccessKey「{name}」の更新結果は不明です。別の AccessKey を編集する前に現在のメタデータを確認してください。',
editReconciling:
'AccessKey「{name}」の更新は確定しましたが、現在のメタデータを確認する必要があります。',
checkResult: '結果を確認',
},
status: {
@@ -23,6 +23,9 @@ export default {
operation: {
indeterminate: 'AccessKey 创建结果未知;开始新的创建前,请先核对本次操作。',
reconciling: 'AccessKey 已提交,运行时仍在恢复。',
editIndeterminate:
'AccessKey“{name}”的更新结果未知;编辑其他 AccessKey 前请先核对当前元数据。',
editReconciling: 'AccessKey“{name}”的更新已提交,仍需核对当前元数据。',
checkResult: '核对结果',
},
status: {