修复配置保存提示闪烁并保留真实错误信息
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-08 16:26:11 +08:00
parent b3132edfb7
commit dc188b1ed8
3 changed files with 242 additions and 44 deletions

View File

@ -13,7 +13,7 @@
size="small"
plain
:loading="loading"
:disabled="!!working || sectionLoading || loading"
:disabled="busy || sectionLoading || loading"
@click="refreshPage"
>
刷新状态
@ -48,13 +48,21 @@
show-icon
class="config-alert"
/>
<el-alert
v-if="saveError"
:title="saveError"
type="error"
:closable="false"
show-icon
class="config-alert"
/>
<ConfigForm
v-if="!sectionLoading && !sectionError"
:section="selectedSection"
:config="formConfig"
:errors="formErrors"
:disabled="!canWrite || !!working || !!pendingCandidate"
:disabled="!canWrite || busy || !!pendingCandidate"
@change="updateField"
/>
<div v-if="secretField" class="secret-editor">
@ -68,7 +76,7 @@
type="password"
show-password
clearable
:disabled="!canWrite || sectionLoading || !!working || !!sectionError || !!pendingCandidate"
:disabled="!canWrite || sectionLoading || busy || !!sectionError || !!pendingCandidate"
:placeholder="secretField.placeholder"
autocomplete="new-password"
@update:model-value="updateSecret"
@ -77,14 +85,14 @@
<div v-if="canWrite" class="editor-footer">
<el-alert
v-if="pendingCandidate"
v-if="showPendingRecovery"
title="上次保存尚未完成,可以继续保存或放弃本次更改。"
type="warning"
:closable="false"
show-icon
/>
<el-alert
v-if="pendingScopeHint"
v-if="showPendingRecovery && pendingScopeHint"
:title="pendingScopeHint"
type="warning"
:closable="false"
@ -92,28 +100,18 @@
/>
<div class="action-row">
<el-button
v-if="!pendingCandidate"
type="primary"
:loading="working === 'save' || working === 'apply'"
:disabled="sectionLoading || !!working || !!sectionError || !hasChanges"
:loading="busy"
:disabled="sectionLoading || busy || !!sectionError || (!pendingCandidate && !hasChanges)"
@click="saveAndApply"
>
保存配置
{{ continuingSave ? '继续保存' : '保存配置' }}
</el-button>
<el-button
v-else
type="primary"
:loading="working === 'resume' || working === 'apply'"
:disabled="sectionLoading || !!working || !!sectionError"
@click="continuePending"
>
继续保存
</el-button>
<el-button
v-if="pendingCandidate"
v-if="showPendingRecovery"
type="danger"
plain
:disabled="!!working || pendingCandidate.committed"
:disabled="busy || pendingCandidate.committed"
@click="managePending('cancel')"
>
放弃更改
@ -141,7 +139,9 @@ import ConfigForm from '../ConfigForm/ConfigForm.vue'
const {
availabilityError,
loading,
working,
busy,
showPendingRecovery,
continuingSave,
status,
selectedSection,
currentSection,
@ -151,6 +151,7 @@ const {
updateField,
updateSecret,
sectionError,
saveError,
secretField,
secretValue,
pendingCandidate,
@ -160,8 +161,7 @@ const {
hasChanges,
canWrite,
refreshPage,
saveAndApply,
continuePending
saveAndApply
} = inject('smail-config-page')
</script>

View File

@ -41,6 +41,9 @@ export function usePage(pageKey = 'base') {
const loading = ref(false)
const availabilityError = ref('')
const working = ref('')
// 一次保存跨越暂存、应用和回读;中间候选不是需要用户恢复的失败记录。
const saveFlow = ref('')
const busy = computed(() => !!working.value || !!saveFlow.value)
const backupsLoading = ref(false)
const auditLoading = ref(false)
const backupsError = ref('')
@ -67,10 +70,13 @@ export function usePage(pageKey = 'base') {
const formErrors = ref({})
const loadedText = ref('')
const sectionError = ref('')
const saveError = ref('')
const secretValue = ref('')
const confirmed = ref(false)
const savedGeneration = ref(0)
const pendingCandidate = ref(null)
const showPendingRecovery = computed(() => !!pendingCandidate.value && !saveFlow.value)
const continuingSave = computed(() => saveFlow.value === 'resume' || showPendingRecovery.value)
const pendingScopeHint = computed(() => {
const pending = pendingCandidate.value
if (pending?.section === 'plugins' && apiSection() === 'plugins') {
@ -178,16 +184,18 @@ export function usePage(pageKey = 'base') {
const canOperate = () => !disposed && canWrite.value && !loading.value && !working.value && !sectionLoading.value && !sectionError.value && !availabilityError.value
const canEdit = () => canOperate() && !pendingCandidate.value && !savedGeneration.value
const updateField = (path, value) => {
if (!canEdit()) return
if (saveFlow.value || !canEdit()) return
const updated = updateFormConfig(editorText.value, selectedSection.value, path, value)
if (updated === editorText.value) return
editorText.value = updated
saveError.value = ''
if (Object.keys(formErrors.value).length) formErrors.value = validateFormConfig(selectedSection.value, formConfig.value)
invalidateCandidate()
}
const updateSecret = value => {
if (!canEdit() || !secretField.value || secretValue.value === value) return
if (saveFlow.value || !canEdit() || !secretField.value || secretValue.value === value) return
secretValue.value = value
saveError.value = ''
invalidateCandidate()
}
@ -246,6 +254,7 @@ export function usePage(pageKey = 'base') {
const managePending = async action => {
if (!canOperate() || !pendingCandidate.value) return false
saveError.value = ''
working.value = action
try {
if (action === 'cancel') {
@ -268,7 +277,10 @@ export function usePage(pageKey = 'base') {
if (action === 'cancel') ElMessage.success('本次更改已放弃')
return true
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message || '未完成的配置更改处理失败')
if (error !== 'cancel' && error !== 'close') {
saveError.value = error.message || '未完成的配置更改处理失败'
ElMessage.error(saveError.value)
}
return false
} finally {
working.value = ''
@ -280,6 +292,7 @@ export function usePage(pageKey = 'base') {
const section = apiSection()
sectionLoading.value = true
sectionError.value = ''
saveError.value = ''
formErrors.value = {}
editorText.value = ''
loadedText.value = ''
@ -571,6 +584,7 @@ export function usePage(pageKey = 'base') {
const saveConfig = async ({ quiet = false } = {}) => {
if (!canEdit() || !checkForm()) return false
saveError.value = ''
working.value = 'save'
try {
const request = buildRequest()
@ -588,7 +602,8 @@ export function usePage(pageKey = 'base') {
return true
} catch (error) {
if (isRecentAuthenticationCancelled(error)) return false
ElMessage.error(error.message || '配置保存失败')
saveError.value = error.message || '配置保存失败'
ElMessage.error(saveError.value)
return false
} finally {
working.value = ''
@ -597,6 +612,7 @@ export function usePage(pageKey = 'base') {
const applyConfig = async ({ successMessage = '配置已保存' } = {}) => {
if (!canOperate() || !savedGeneration.value) return false
saveError.value = ''
if (!savedInput || savedInput.text !== editorText.value || savedInput.secret !== secretValue.value) {
invalidateCandidate()
ElMessage.warning('填写内容已变更,请重新保存配置')
@ -638,7 +654,8 @@ export function usePage(pageKey = 'base') {
ElMessage.warning(sectionError.value)
return false
}
ElMessage.error(error.message || '配置保存失败')
saveError.value = error.message || '配置保存失败'
ElMessage.error(saveError.value)
return false
} finally {
working.value = ''
@ -646,21 +663,23 @@ export function usePage(pageKey = 'base') {
}
const saveAndApply = async () => {
if (pendingCandidate.value) {
await continuePending()
return
if (saveFlow.value || !canOperate()) return false
saveFlow.value = pendingCandidate.value ? 'resume' : 'save'
try {
if (pendingCandidate.value) {
if (!pendingCandidate.value.sameSession && !await managePending('resume')) return false
return await applyConfig()
}
const saved = await saveConfig({ quiet: true })
return saved ? await applyConfig({ successMessage: '配置已保存' }) : false
} finally {
saveFlow.value = ''
}
const saved = await saveConfig({ quiet: true })
if (saved) await applyConfig({ successMessage: '配置已保存' })
}
const continuePending = async () => {
if (!pendingCandidate.value) return
if (!pendingCandidate.value.sameSession) {
const resumed = await managePending('resume')
if (!resumed) return
}
await applyConfig()
if (!pendingCandidate.value) return false
return saveAndApply()
}
const previewRollback = async () => {
@ -735,7 +754,7 @@ export function usePage(pageKey = 'base') {
}
const beforeLeave = async () => {
if (working.value) {
if (busy.value) {
ElMessage.warning('当前操作尚未完成,请稍后再切换页面')
return false
}
@ -746,7 +765,7 @@ export function usePage(pageKey = 'base') {
'确认离开当前页',
{ type: 'warning', confirmButtonText: '离开页面', cancelButtonText: '继续编辑' }
)
return !working.value
return !busy.value
} catch {
return false
}
@ -770,9 +789,10 @@ export function usePage(pageKey = 'base') {
return {
pendingScopeHint,
saveFlow, busy, showPendingRecovery, continuingSave,
loading, availabilityError, working, status, stateLabel, stateTagType, sectionOptions: sections,
selectedSection, currentSection, sectionLoading, editorText, formConfig, formErrors, updateField, updateSecret,
sectionError, sensitiveHint, secretField, secretValue,
sectionError, saveError, sensitiveHint, secretField, secretValue,
confirmed, savedGeneration, pendingCandidate, managePending, validationResult, canWrite, canRollback, canAudit, canExportAudit,
backupsLoading, backupsError, backups, rollbackGeneration, rollbackConfirmed, rollbackPreview,
auditLoading, auditError, auditItems, loadAll, loadSection, validateConfig, saveConfig, applyConfig, saveAndApply, continuePending,

View File

@ -444,7 +444,10 @@ test('配置编辑页将验证、候选保存和应用精简为单一保存操
assert.doesNotMatch(sharedCss + editorCss, /change-workflow|step-number/)
assert.match(template, /@click="saveAndApply"/)
assert.match(template, /保存配置/)
assert.match(template, /@click="continuePending"/)
assert.match(template, /continuingSave \? '继续保存' : '保存配置'/)
assert.match(template, /v-if="showPendingRecovery"/)
assert.match(template, /v-if="showPendingRecovery && pendingScopeHint"/)
assert.match(template, /v-if="saveError"[\s\S]*?:title="saveError"[\s\S]*?type="error"/)
assert.match(template, /继续保存/)
assert.match(template, /v-if="canWrite" class="editor-footer"/)
assert.match(template, /@click="managePending\('cancel'\)"/)
@ -498,6 +501,181 @@ test('保存并应用一次点击按候选协议连续完成保存和应用', as
assert.equal(page.formConfig.value.listener.port, 2525)
})
test('一键保存的所有异步阶段保持忙碌,不闪现恢复提示或允许重复操作', { timeout: 5000 }, async t => {
const stages = Array.from({ length: 7 }, () => ({ entered: deferred(), release: deferred() }))
t.after(() => stages.forEach(stage => stage.release.resolve()))
let requestIndex = 0
let testing = false
let pending = null
let active = {
meta: { generation: 1 },
tls: { management: 'custom', mode: 'managed_upload', defaultDirectory: 'config/ssl', customDirectory: 'config/ssl', certificateRef: 'config/ssl/public.crt', privateKeyRef: 'config/ssl/private.key' }
}
const pause = async () => {
if (!testing) return
const stage = stages[requestIndex++]
assert.ok(stage, '不应产生重复请求')
stage.entered.resolve()
await stage.release.promise
}
const { page, successes } = fixture(() => {}, 'ssl', { http: {
get: async path => {
await pause()
return { errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: active.meta.generation } : active }
},
put: async (path, body) => {
await pause()
pending = { generation: 2, candidateId: 'continuous-save', section: 'ssl', sameSession: true, payload: body.payload }
return { errorNo: 0, data: pending }
},
post: async () => {
await pause()
active = { ...pending.payload, meta: { generation: 2 } }
pending = null
return { errorNo: 0, data: { generation: 2 } }
}
} })
await page.loadAll()
page.updateField(['tls', 'certificateRef'], 'D:/certs/public.crt')
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const { watch } = require('vue')
const transitions = []
const stop = watch(() => [page.working.value, page.saveFlow.value, page.pendingCandidate.value], () => {
transitions.push({ working: page.working.value, flow: page.saveFlow.value, pending: !!page.pendingCandidate.value, busy: page.busy.value, recovery: page.showPendingRecovery.value })
}, { flush: 'sync' })
t.after(stop)
testing = true
const saving = page.saveAndApply()
for (const stage of stages) {
await Promise.race([stage.entered.promise, saving.then(() => { throw new Error('保存流程提前结束') })])
assert.equal(page.busy.value, true)
assert.equal(page.saveFlow.value, 'save')
assert.equal(page.showPendingRecovery.value, false)
assert.equal(page.continuingSave.value, false)
const input = page.editorText.value
page.updateField(['tls', 'certificateRef'], 'D:/blocked.crt')
assert.equal(page.editorText.value, input)
assert.equal(await page.saveAndApply(), false)
assert.equal(await page.continuePending(), false)
assert.equal(await page.beforeLeave(), false)
stage.release.resolve()
}
assert.equal(await saving, true)
assert.equal(requestIndex, 7)
assert.equal(page.busy.value, false)
assert.equal(page.saveFlow.value, '')
assert.equal(page.showPendingRecovery.value, false)
assert.deepEqual(successes, ['配置已保存'])
const gaps = transitions.filter(state => state.pending && !state.working && state.flow)
assert.ok(gaps.length > 0, '必须检查暂存与应用之间的同步状态切换')
assert.ok(gaps.every(state => state.busy && !state.recovery))
})
for (const failure of ['save', 'apply', 'apply-auth-cancel', 'readback']) {
test(`一键保存 ${failure} 后解除忙碌,真实失败仍提示且未应用的候选可以恢复`, async () => {
let fail = true
let applied = false
let pending = null
let writes = 0
const config = {
meta: { generation: 1 },
tls: { management: 'custom', mode: 'managed_upload', defaultDirectory: 'config/ssl', customDirectory: 'config/ssl', certificateRef: 'config/ssl/public.crt', privateKeyRef: 'config/ssl/private.key' }
}
const { page, messages, successes } = fixture(() => {}, 'ssl', { http: {
get: async path => {
if (fail && applied && failure === 'readback') throw new Error('模拟回读失败')
return { errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: applied ? 2 : 1 } : { ...config, meta: { generation: applied ? 2 : 1 } } }
},
put: async (path, body) => {
writes++
if (fail && failure === 'save') throw new Error('模拟保存失败')
pending = { generation: 2, candidateId: 'failed-save', section: 'ssl', sameSession: true, payload: body.payload }
return { errorNo: 0, data: pending }
},
post: async () => {
writes++
if (fail && failure === 'apply') throw new Error('模拟应用失败')
if (fail && failure === 'apply-auth-cancel') throw Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
applied = true
pending = null
return { errorNo: 0, data: { generation: 2 } }
}
} })
await page.loadAll()
page.updateField(['tls', 'certificateRef'], 'D:/certs/public.crt')
assert.equal(await page.saveAndApply(), false)
assert.equal(page.busy.value, false)
assert.equal(page.saveFlow.value, '')
assert.deepEqual(successes, [])
const recoverable = ['apply', 'apply-auth-cancel'].includes(failure)
assert.equal(page.showPendingRecovery.value, recoverable)
assert.equal(page.continuingSave.value, recoverable)
if (failure === 'apply-auth-cancel') {
assert.deepEqual(messages, [])
assert.equal(page.saveError.value, '')
} else {
assert.ok(messages.length > 0, '真实失败不能被隐藏')
assert.equal(page.saveError.value || page.sectionError.value, messages.at(-1), '错误必须保留在页面中')
}
if (failure === 'save') {
page.updateField(['tls', 'certificateRef'], 'D:/certs/fixed/public.crt')
assert.equal(page.saveError.value, '')
}
if (recoverable) {
fail = false
const writesBefore = writes
const continuing = page.continuePending()
assert.equal(page.saveFlow.value, 'resume')
assert.equal(page.continuingSave.value, true)
assert.equal(page.showPendingRecovery.value, false)
assert.equal(page.saveError.value, '')
assert.equal(await continuing, true)
assert.equal(writes, writesBefore + 1, '只应用已有候选,不重复保存')
assert.equal(page.showPendingRecovery.value, false)
assert.deepEqual(successes, ['配置已保存'])
}
})
}
for (const rejected of [false, true]) {
test(`恢复其他会话候选期间保持按钮稳定,恢复失败保留入口:失败=${rejected}`, async () => {
let active = {
meta: { generation: 1 },
tls: { management: 'custom', mode: 'managed_upload', defaultDirectory: 'config/ssl', customDirectory: 'config/ssl', certificateRef: 'config/ssl/public.crt', privateKeyRef: 'config/ssl/private.key' }
}
let pending = { generation: 2, section: 'ssl', sameSession: false, candidateId: 'another-session', payload: active }
const requests = []
const { page, messages } = fixture(() => {}, 'ssl', { http: {
get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: active.meta.generation } : active }),
post: async (path, body) => {
assert.equal(page.saveFlow.value, 'resume')
assert.equal(page.busy.value, true)
assert.equal(page.continuingSave.value, true)
assert.equal(page.showPendingRecovery.value, false)
requests.push(path)
if (path.endsWith('/pending')) {
assert.equal(body.action, 'resume')
if (rejected) throw new Error('模拟候选恢复失败')
pending = { ...pending, sameSession: true }
} else {
assert.equal(body.candidateId, 'another-session')
active = { ...active, meta: { generation: 2 } }
pending = null
}
return { errorNo: 0, data: { generation: 2 } }
}
} })
await page.loadAll()
assert.equal(page.showPendingRecovery.value, true)
assert.equal(await page.continuePending(), !rejected)
assert.equal(page.busy.value, false)
assert.equal(page.showPendingRecovery.value, rejected)
assert.equal(page.saveError.value, rejected ? '模拟候选恢复失败' : '')
assert.deepEqual(requests, rejected ? ['/api/v1/config/pending'] : ['/api/v1/config/pending', '/api/v1/config/ssl/apply'])
assert.deepEqual(messages, rejected ? ['模拟候选恢复失败'] : [])
})
}
test('最近认证过期时在当前页确认密码并自动重试保存和应用', async () => {
const requests = []
let pending = null
@ -631,7 +809,7 @@ test('标题合并配置状态,保留刷新保护和降级提示,不再渲
assert.doesNotMatch(header, /stateLabel|运行状态|state-tag/)
assert.match(header, /v-for="issue in status.degradedReasons \|\| \[\]"/)
assert.match(header, /v-if="!loading && !availabilityError"/)
assert.match(header, /:disabled="!!working \|\| sectionLoading \|\| loading"/)
assert.match(header, /:disabled="busy \|\| sectionLoading \|\| loading"/)
assert.match(header, /@click="refreshPage"/)
assert.doesNotMatch(header, /<dl class="overview-meta"/)
assert.doesNotMatch(header, /access-tag/)