精简配置保存与应用流程
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-07 16:48:46 +08:00
parent 7aab26221b
commit f4c50226f8
8 changed files with 237 additions and 95 deletions

View File

@ -43,6 +43,19 @@
padding-top: 2px;
}
.action-row {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
}
.action-row :deep(.el-button) {
min-width: 112px;
margin-left: 0;
}
.overview-meta {
display: flex;
flex-wrap: wrap;
@ -128,6 +141,14 @@
padding-top: 0;
}
.action-row {
justify-content: stretch;
}
.action-row :deep(.el-button) {
flex: 1;
}
.overview-meta {
gap: 8px 16px;
}

View File

@ -78,7 +78,7 @@
<div v-if="canWrite" class="editor-footer">
<el-alert
v-if="pendingCandidate"
:title="`服务器存在待应用候选,代次 ${pendingCandidate.generation}。已锁定编辑;如需修改,请先取消候选。刷新页面不会丢失候选。`"
title="有一项配置尚未生效,可以继续应用或放弃更改。"
type="warning"
:closable="false"
show-icon
@ -90,30 +90,38 @@
:closable="false"
show-icon
/>
<el-checkbox v-model="confirmed" class="confirmation-check" :disabled="sectionLoading || !!working || !!sectionError">
我已确认这是受控变更,并允许使用最近认证完成高风险配置操作
</el-checkbox>
<div class="action-row">
<el-button :loading="working === 'validate'" :disabled="sectionLoading || !!working || !!sectionError || !!pendingCandidate" @click="validateConfig">
只验证
<el-button
v-if="!pendingCandidate"
type="primary"
:loading="working === 'save' || working === 'apply'"
:disabled="sectionLoading || !!working || !!sectionError || !hasChanges"
@click="saveAndApply"
>
保存并应用
</el-button>
<el-button type="primary" :loading="working === 'save'" :disabled="sectionLoading || !!working || !!sectionError || !!pendingCandidate" @click="saveConfig">
保存候选配置
<el-button
v-else
type="primary"
:loading="working === 'resume' || working === 'apply'"
:disabled="sectionLoading || !!working || !!sectionError"
@click="continuePending"
>
继续应用
</el-button>
<el-button v-if="pendingCandidate && !pendingCandidate.sameSession" :disabled="!!working || !confirmed" @click="managePending('resume')">
恢复候选到当前会话
</el-button>
<el-button v-if="pendingCandidate" type="warning" plain :disabled="!!working || pendingCandidate.committed" @click="managePending('cancel')">
取消候选配置
</el-button>
<el-button type="success" plain :loading="working === 'apply'" :disabled="sectionLoading || !!working || !savedGeneration" @click="applyConfig">
{{ savedGeneration ? `应用代次 ${savedGeneration}` : '应用配置' }}
<el-button
v-if="pendingCandidate"
type="danger"
plain
:disabled="!!working || pendingCandidate.committed"
@click="managePending('cancel')"
>
放弃更改
</el-button>
</div>
</div>
<div v-if="validationResult" class="result-box" :class="validationResult.valid ? 'result-success' : 'result-error'" role="status">
<strong>{{ validationResult.valid ? '验证通过' : '验证未通过' }}</strong>
<span v-if="validationResult.generation">候选代次:{{ validationResult.generation }}</span>
<div v-if="validationResult && !validationResult.valid" class="result-box result-error" role="status">
<strong>配置检查未通过</strong>
<ul v-if="validationResult.issues?.length">
<li v-for="issue in validationResult.issues" :key="`${issue.code}-${issue.message}`">
{{ issue.code }}:{{ issue.message }}
@ -145,19 +153,15 @@ const {
sectionError,
secretField,
secretValue,
confirmed,
savedGeneration,
pendingCandidate,
pendingScopeHint,
managePending,
validationResult,
hasChanges,
canWrite,
refreshPage,
validateConfig,
saveConfig,
applyConfig,
formatTime,
serviceLabel
saveAndApply,
continuePending
} = inject('smail-config-page')
</script>

View File

@ -243,27 +243,31 @@ export function usePage(pageKey = 'base') {
}
const managePending = async action => {
if (!canOperate() || !pendingCandidate.value) return
if (!canOperate() || !pendingCandidate.value) return false
working.value = action
try {
await ElMessageBox.confirm(
action === 'cancel' ? '取消后保留当前生效配置,候选修改不会应用。确认取消吗?' : '恢复后将候选配置绑定到当前会话,请确认已核对候选内容。',
action === 'cancel' ? '取消候选配置' : '恢复候选配置',
{ type: 'warning', confirmButtonText: '确认', cancelButtonText: '返回' }
)
if (action === 'cancel') {
await ElMessageBox.confirm(
'放弃后会保留当前生效配置,本次修改不会应用。',
'放弃本次更改',
{ type: 'warning', confirmButtonText: '放弃更改', cancelButtonText: '返回' }
)
}
const response = await http.post('/api/v1/config/pending', {
generation: pendingCandidate.value.generation,
candidateId: pendingCandidate.value.candidateId,
action,
confirmation: { confirmed: confirmed.value, method: 'recent_auth' }
confirmation: { confirmed: true, method: 'recent_auth' }
})
notifyResponseError(response, '候选配置操作失败')
await loadStatus()
await loadSection()
await loadPending()
ElMessage.success(action === 'cancel' ? '候选已取消,可以继续编辑' : '候选已恢复,请核对后应用')
if (action === 'cancel') ElMessage.success('本次更改已放弃')
return true
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message || '候选配置操作失败')
return false
} finally {
working.value = ''
}
@ -528,7 +532,7 @@ export function usePage(pageKey = 'base') {
expectedGeneration: Number(status.activeGeneration),
payload: parsePayload(),
secretChanges,
confirmation: { confirmed: confirmed.value, method: 'recent_auth' }
confirmation: { confirmed: true, method: 'recent_auth' }
}
}
@ -557,8 +561,8 @@ export function usePage(pageKey = 'base') {
}
}
const saveConfig = async () => {
if (!canEdit() || !checkForm()) return
const saveConfig = async ({ quiet = false } = {}) => {
if (!canEdit() || !checkForm()) return false
working.value = 'save'
try {
const request = buildRequest()
@ -570,20 +574,22 @@ export function usePage(pageKey = 'base') {
validationResult.value = { valid: true, generation: savedGeneration.value, issues: [] }
await loadStatus()
await loadPending()
ElMessage.success(`候选配置已保存,代次 ${savedGeneration.value} 等待应用`)
if (!quiet) ElMessage.success('配置已保存,等待应用')
return true
} catch (error) {
ElMessage.error(error.message || '配置保存失败')
return false
} finally {
working.value = ''
}
}
const applyConfig = async () => {
if (!canOperate() || !savedGeneration.value) return
const applyConfig = async ({ successMessage = '配置已应用' } = {}) => {
if (!canOperate() || !savedGeneration.value) return false
if (!savedInput || savedInput.text !== editorText.value || savedInput.secret !== secretValue.value) {
invalidateCandidate()
ElMessage.warning('填写内容已变更,请重新保存候选配置后再应用')
return
return false
}
working.value = 'apply'
try {
@ -595,14 +601,34 @@ export function usePage(pageKey = 'base') {
savedGeneration.value = 0
await Promise.all([loadStatus(), loadSection()])
await loadPending()
ElMessage.success('配置已应用')
ElMessage.success(successMessage)
return true
} catch (error) {
ElMessage.error(error.message || '配置应用失败')
return false
} finally {
working.value = ''
}
}
const saveAndApply = async () => {
if (pendingCandidate.value) {
await continuePending()
return
}
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()
}
const previewRollback = async () => {
if (!canRollback.value || working.value || !rollbackGeneration.value) return
working.value = 'preview'
@ -715,7 +741,7 @@ export function usePage(pageKey = 'base') {
sectionError, 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,
auditLoading, auditError, auditItems, loadAll, loadSection, validateConfig, saveConfig, applyConfig, saveAndApply, continuePending,
loadBackups, previewRollback, executeRollback, loadAudit, exportAudit, formatTime,
serviceLabel, sectionLabel, operationLabel, databaseConfig, databaseTypeLabel, canMigrate,
migrationTargetName, migrationPreview, migrationToken, migrationRecord, migrationStatusLabel, migrationStatusType,

View File

@ -56,17 +56,19 @@
margin-top: -4px;
}
.spam-confirmation {
margin-top: 20px;
}
.spam-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.spam-actions :deep(.el-button) {
min-width: 112px;
margin-left: 0;
}
.spam-result {
display: grid;
gap: 7px;
@ -101,4 +103,4 @@
grid-template-columns: 1fr;
gap: 0;
}
}
}

View File

@ -1,4 +1,4 @@
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { http } from '@/utils/axios'
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
@ -16,7 +16,6 @@ export function useSpamBlockSettings() {
const loading = ref(false)
const working = ref('')
const errorMessage = ref('')
const confirmed = ref(false)
const savedGeneration = ref(0)
const pendingCandidate = ref(null)
let savedInput = ''
@ -24,6 +23,7 @@ export function useSpamBlockSettings() {
const validationResult = ref(null)
const pluginsPayload = ref(null)
const settings = reactive({ ...defaultSettings })
const hasChanges = computed(() => JSON.stringify(settings) !== savedInput)
const canWrite = () => globalStatus.hasCapability('config.write_global')
@ -95,7 +95,7 @@ export function useSpamBlockSettings() {
expectedGeneration: activeGeneration.value,
payload: buildPayload(),
confirmation: {
confirmed: confirmed.value,
confirmed: true,
method: 'recent_auth'
}
})
@ -117,8 +117,8 @@ export function useSpamBlockSettings() {
}
}
const save = async () => {
if (!canWrite() || loading.value || working.value || errorMessage.value || pendingCandidate.value) return
const save = async ({ quiet = false } = {}) => {
if (!canWrite() || loading.value || working.value || errorMessage.value || pendingCandidate.value) return false
working.value = 'save'
try {
const response = await http.put('/api/v1/config/plugins', buildRequest())
@ -128,19 +128,21 @@ export function useSpamBlockSettings() {
savedInput = JSON.stringify(settings)
validationResult.value = { valid: true, generation: savedGeneration.value, issues: [] }
await load()
ElMessage.success(`垃圾邮件插件配置已保存,代次 ${savedGeneration.value} 等待应用`)
if (!quiet) ElMessage.success('垃圾邮件插件配置已保存,等待应用')
return true
} catch (error) {
ElMessage.error(error.message || '插件配置保存失败')
return false
} finally {
working.value = ''
}
}
const apply = async () => {
if (!canWrite() || loading.value || working.value || !savedGeneration.value) return
const apply = async ({ successMessage = '垃圾邮件插件配置已应用' } = {}) => {
if (!canWrite() || loading.value || working.value || !savedGeneration.value) return false
if (savedInput !== JSON.stringify(settings)) {
ElMessage.warning('填写内容与候选不一致,请刷新查看候选,或先取消候选再修改')
return
return false
}
working.value = 'apply'
try {
@ -151,49 +153,75 @@ export function useSpamBlockSettings() {
notifyResponseError(response, '插件配置应用失败')
savedGeneration.value = 0
await load()
ElMessage.success('垃圾邮件插件配置已应用')
ElMessage.success(successMessage)
return true
} catch (error) {
ElMessage.error(error.message || '垃圾邮件插件配置应用失败')
return false
} finally {
working.value = ''
}
}
const managePending = async action => {
if (!canWrite() || loading.value || working.value || !pendingCandidate.value) return
if (!canWrite() || loading.value || working.value || !pendingCandidate.value) return false
working.value = action
try {
await ElMessageBox.confirm(action === 'cancel' ? '取消候选后不会影响当前生效配置,确认继续吗?' : '确认将已保存候选恢复到当前会话吗?', '管理候选配置', { type: 'warning', confirmButtonText: '确认', cancelButtonText: '返回' })
if (action === 'cancel') {
await ElMessageBox.confirm('放弃后会保留当前生效配置,本次修改不会应用。', '放弃本次更改', { type: 'warning', confirmButtonText: '放弃更改', cancelButtonText: '返回' })
}
const response = await http.post('/api/v1/config/pending', {
generation: pendingCandidate.value.generation, action,
candidateId: pendingCandidate.value.candidateId,
confirmation: { confirmed: confirmed.value, method: 'recent_auth' }
confirmation: { confirmed: true, method: 'recent_auth' }
})
notifyResponseError(response, '候选配置操作失败')
await load()
if (action === 'cancel') ElMessage.success('本次更改已放弃')
return true
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message || '候选配置操作失败')
return false
} finally {
working.value = ''
}
}
const saveAndApply = async () => {
if (pendingCandidate.value) {
await continuePending()
return
}
const saved = await save({ quiet: true })
if (saved) await apply({ successMessage: '垃圾邮件插件配置已保存并应用' })
}
const continuePending = async () => {
if (!pendingCandidate.value) return
if (!pendingCandidate.value.sameSession) {
const resumed = await managePending('resume')
if (!resumed) return
}
await apply()
}
onMounted(load)
return {
loading,
working,
errorMessage,
confirmed,
savedGeneration,
pendingCandidate,
managePending,
validationResult,
settings,
hasChanges,
canWrite,
load,
validate,
save,
apply
apply,
saveAndApply,
continuePending
}
}

View File

@ -25,7 +25,7 @@
show-icon
/>
<el-alert v-if="pendingCandidate" title="存在待应用候选,编辑已锁定。刷新可恢复候选,需要修改时请先取消。" type="warning" :closable="false" show-icon />
<el-alert v-if="pendingCandidate" title="有一项配置尚未生效,可以继续应用或放弃更改。" type="warning" :closable="false" show-icon />
<el-form label-position="top" class="spam-form">
<el-form-item label="模型 API 接口地址">
<el-input
@ -60,21 +60,29 @@
</div>
<p class="spam-help">阈值越低,过滤越严格,也更容易产生误判;请根据模型返回结果逐步调整。</p>
<el-checkbox v-if="canWrite()" v-model="confirmed" class="spam-confirmation">
我已确认这是受控变更,并允许使用最近认证完成高风险配置操作
</el-checkbox>
<div class="spam-actions">
<el-button :loading="working === 'validate'" :disabled="!canWrite() || !!working || !!pendingCandidate || loading" @click="validate">只验证</el-button>
<el-button type="primary" :loading="working === 'save'" :disabled="!canWrite() || !!working || !!pendingCandidate || loading" @click="save">保存候选配置</el-button>
<el-button type="success" plain :loading="working === 'apply'" :disabled="!canWrite() || !savedGeneration || !!working || loading" @click="apply">
应用{{ savedGeneration ? `代次 ${savedGeneration}` : '' }}
<el-button
v-if="!pendingCandidate"
type="primary"
:loading="working === 'save' || working === 'apply'"
:disabled="!canWrite() || !!working || loading || !hasChanges"
@click="saveAndApply"
>
保存并应用
</el-button>
<el-button v-if="canWrite() && pendingCandidate && !pendingCandidate.sameSession" :disabled="!!working || !confirmed" @click="managePending('resume')">恢复候选到当前会话</el-button>
<el-button v-if="canWrite() && pendingCandidate" type="warning" plain :disabled="!!working || pendingCandidate.committed" @click="managePending('cancel')">取消候选配置</el-button>
<el-button
v-else
type="primary"
:loading="working === 'resume' || working === 'apply'"
:disabled="!canWrite() || !!working || loading"
@click="continuePending"
>
继续应用
</el-button>
<el-button v-if="canWrite() && pendingCandidate" type="danger" plain :disabled="!!working || pendingCandidate.committed" @click="managePending('cancel')">放弃更改</el-button>
</div>
<div v-if="validationResult" class="spam-result" :class="validationResult.valid ? 'is-success' : 'is-error'">
<strong>{{ validationResult.valid ? '验证通过' : '验证未通过' }}</strong>
<span v-if="validationResult.generation">候选代次:{{ validationResult.generation }}</span>
<div v-if="validationResult && !validationResult.valid" class="spam-result is-error">
<strong>配置检查未通过</strong>
<ul v-if="validationResult.issues?.length">
<li v-for="issue in validationResult.issues" :key="`${issue.code}-${issue.message}`">{{ issue.code }}:{{ issue.message }}</li>
</ul>
@ -90,17 +98,15 @@ const {
loading,
working,
errorMessage,
confirmed,
savedGeneration,
pendingCandidate,
managePending,
validationResult,
settings,
hasChanges,
canWrite,
load,
validate,
save,
apply
saveAndApply,
continuePending
} = useSpamBlockSettings()
</script>

View File

@ -390,7 +390,7 @@ test('独立页模板可编译,编辑器不再连接维护面板且权限绑
assert.doesNotThrow(() => compileScript(descriptor, { id: 'config-layout', inlineTemplate: true }))
assert.deepEqual(compileStyle({ source: css, id: 'data-v-config-layout', scoped: true }).errors, [])
assert.match(template, /v-if="canWrite" class="editor-footer"/)
assert.match(template, /v-model="confirmed"/)
assert.doesNotMatch(template, /v-model="confirmed"/)
assert.doesNotMatch(template, /maintenance-panel|migration-panel|rollback-panel|audit-panel/)
for (const [key, capability] of [['migration', 'canMigrate'], ['restart', 'canRestart'], ['backups', 'canRollback'], ['audit', 'canAudit']]) {
assert.ok(maintenance.includes(`pageKey === '${key}' && ${capability}`))
@ -407,20 +407,62 @@ test('独立页模板可编译,编辑器不再连接维护面板且权限绑
assert.doesNotMatch(template, /&#x[0-9a-f]+;/i)
})
test('配置编辑页移除步骤提示条和专用样式,保留实际操作及确认控件', () => {
test('配置编辑页将验证、候选保存和应用精简为单一操作', () => {
const root = new URL('../../frontend/src/views/components/ConfigSettings/', import.meta.url)
const template = readFileSync(new URL('ConfigEditor/ConfigEditor.vue', root), 'utf8')
const sharedCss = readFileSync(new URL('ConfigSettings.css', root), 'utf8')
const editorCss = readFileSync(new URL('ConfigEditor/ConfigEditor.css', root), 'utf8')
assert.doesNotMatch(template, /change-workflow|step-number|配置变更流程/)
assert.doesNotMatch(sharedCss + editorCss, /change-workflow|step-number/)
for (const action of ['validateConfig', 'saveConfig', 'applyConfig']) {
assert.match(template, new RegExp(`<el-button\\b[^>]*@click="${action}"[^>]*>`))
}
assert.match(template, /@click="saveAndApply"/)
assert.match(template, /保存并应用/)
assert.match(template, /@click="continuePending"/)
assert.match(template, /继续应用/)
assert.match(template, /v-if="canWrite" class="editor-footer"/)
assert.match(template, /<el-checkbox\b[^>]*v-model="confirmed"/)
assert.match(template, /@click="managePending\('resume'\)"/)
assert.match(template, /@click="managePending\('cancel'\)"/)
assert.match(template, /放弃更改/)
assert.doesNotMatch(template, /只验证|保存候选配置|我已确认这是受控变更|应用代次/)
})
test('保存并应用一次点击按候选协议连续完成保存和应用', async () => {
const requests = []
let pending = null
const active = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: { relayHost: '', relayPort: 0, security: '', from: '', fromDomain: '' },
imap: { tlsPort: 993 },
pop3: { port: 110, tlsPort: 995 },
policy: { maxMessageBytes: 10 << 20, spamFilterLevel: 2 }
}
const { page } = fixture(async path => ({
errorNo: 0,
data: path.endsWith('/status') ? { activeGeneration: 4 } : active
}), 'mail', {
http: {
get: async path => ({
errorNo: 0,
data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 4 } : active
}),
put: async (path, body) => {
requests.push(path)
pending = { generation: 5, candidateId: 'one-click-5', section: 'mail', sameSession: true, payload: body.payload }
return { errorNo: 0, data: pending }
},
post: async (path, body) => {
requests.push(path)
assert.equal(body.generation, 5)
assert.equal(body.candidateId, 'one-click-5')
pending = null
return { errorNo: 0, data: { generation: 5 } }
}
}
})
await page.loadAll()
page.updateField(['policy', 'maxMessageBytes'], 12 << 20)
await page.saveAndApply()
assert.deepEqual(requests, ['/api/v1/config/mail', '/api/v1/config/mail/apply'])
assert.equal(page.pendingCandidate.value, null)
assert.equal(page.savedGeneration.value, 0)
})
test('配置编辑区移除填写标题、说明与重复标签,保留主标题和必要提示', () => {
@ -434,7 +476,7 @@ test('配置编辑区移除填写标题、说明与重复标签,保留主标
assert.match(template, /v-if="sectionError"[\s\S]*?:title="sectionError"/)
assert.doesNotMatch(template, /config-sensitive-hint|sensitiveHint/)
assert.match(template, /v-if="secretField" class="secret-editor"/)
assert.match(template, /v-if="validationResult"[^>]*role="status"/)
assert.match(template, /v-if="validationResult && !validationResult\.valid"[^>]*role="status"/)
})
test('离开、刷新和正在执行的变更受保护,取消时保留编辑内容', async () => {

View File

@ -113,7 +113,21 @@ for (const action of ['edit', 'refresh']) {
test(`保存候选配置后 ${action} 不应让页面失去继续保存或应用的入口`, async () => {
const source = read('src/views/components/ConfigSettings/ConfigSettings.js')
.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage')
const initial = { generation: 1, policy: { smtpPort: 25, imapPort: 993, pop3Port: 995, maxMessageBytes: 0 } }
const initial = {
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
relay: {
relayHost: '',
relayPort: 0,
security: '',
from: '',
fromDomain: '',
verificationEnabled: true,
loginPolicy: 'new_device'
},
imap: { tlsPort: 993 },
pop3: { port: 110, tlsPort: 995 },
policy: { maxMessageBytes: 10 << 20, spamFilterLevel: 2, externalRecoveryEnabled: true }
}
let pending = null
let applied = false
const errors = []
@ -121,7 +135,7 @@ for (const action of ['edit', 'refresh']) {
get: async url => ({ errorNo: 0, data: url.endsWith('/pending') ? pending : url.endsWith('/status') ? { activeGeneration: 1, setupState: 'ready' } : initial }),
put: async (url, body) => {
if (pending) return { errorNo: 409, errorMsg: '配置代次冲突' }
pending = { generation: 2, candidateId: 'audit-candidate', sameSession: true, section: 'email', payload: body.payload }
pending = { generation: 2, candidateId: 'audit-candidate', sameSession: true, section: 'mail', payload: body.payload }
return { errorNo: 0, data: pending }
},
post: async (url, body) => {
@ -133,7 +147,7 @@ for (const action of ['edit', 'refresh']) {
return { errorNo: 0, data: { generation: 2 } }
}
}
const page = compileFunction(source + '\nreturn usePage("email")', [
const page = compileFunction(source + '\nreturn usePage("mail")', [
'computed', 'onMounted', 'onBeforeUnmount', 'reactive', 'ref', 'ElMessage', 'ElMessageBox',
'http', 'useGlobalStatusStore', 'readFormConfig', 'updateFormConfig', 'validateFormConfig'
])(
@ -148,8 +162,7 @@ for (const action of ['edit', 'refresh']) {
assert.deepEqual(errors, [])
if (action === 'edit') page.updateField(['policy', 'maxMessageBytes'], 16 << 20)
else await page.refreshPage()
await page.saveConfig()
await page.applyConfig()
await page.continuePending()
console.log(`action=${action} server_pending=${pending} saved_generation=${page.savedGeneration.value} apply_requested=${applied} errors=${JSON.stringify(errors)}`)
assert.equal(applied, true, '页面必须保留恢复或取消候选配置的可用路径,而不是保存冲突且应用不发请求')
})