From f4c50226f88ec82270070fd8928a6c29dfa4daf2 Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 7 Sep 2026 16:48:46 +0800 Subject: [PATCH] =?UTF-8?q?=E7=B2=BE=E7=AE=80=E9=85=8D=E7=BD=AE=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E4=B8=8E=E5=BA=94=E7=94=A8=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConfigEditor/ConfigEditor.css | 21 +++++++ .../ConfigEditor/ConfigEditor.vue | 56 +++++++++-------- .../ConfigSettings/ConfigSettings.js | 60 +++++++++++++------ .../SpamBlockSettings/SpamBlockSettings.css | 12 ++-- .../SpamBlockSettings/SpamBlockSettings.js | 60 ++++++++++++++----- .../SpamBlockSettings/SpamBlockSettings.vue | 42 +++++++------ test/frontend/config-settings.test.mjs | 58 +++++++++++++++--- test/review/frontend-findings.test.mjs | 23 +++++-- 8 files changed, 237 insertions(+), 95 deletions(-) diff --git a/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.css b/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.css index 8550401..89dd067 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.css +++ b/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.css @@ -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; } diff --git a/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue b/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue index 3163441..2a92deb 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue +++ b/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue @@ -78,7 +78,7 @@ -
- {{ validationResult.valid ? '验证通过' : '验证未通过' }} - 候选代次:{{ validationResult.generation }} +
+ 配置检查未通过
  • {{ 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') diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.js b/frontend/src/views/components/ConfigSettings/ConfigSettings.js index 981c062..e8da632 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.js +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.js @@ -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, diff --git a/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.css b/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.css index 0507a76..a59e4cb 100644 --- a/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.css +++ b/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.css @@ -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; } -} \ No newline at end of file +} diff --git a/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.js b/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.js index d27f68b..a68d111 100644 --- a/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.js +++ b/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.js @@ -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 } } diff --git a/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.vue b/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.vue index 19c2a95..6fb608e 100644 --- a/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.vue +++ b/frontend/src/views/components/PluginSettings/SpamBlockSettings/SpamBlockSettings.vue @@ -25,7 +25,7 @@ show-icon /> - +

    阈值越低,过滤越严格,也更容易产生误判;请根据模型返回结果逐步调整。

    - - 我已确认这是受控变更,并允许使用最近认证完成高风险配置操作 -
    - 只验证 - 保存候选配置 - - 应用{{ savedGeneration ? `代次 ${savedGeneration}` : '' }} + + 保存并应用 - 恢复候选到当前会话 - 取消候选配置 + + 继续应用 + + 放弃更改
    -
    - {{ validationResult.valid ? '验证通过' : '验证未通过' }} - 候选代次:{{ validationResult.generation }} +
    + 配置检查未通过
    • {{ issue.code }}:{{ issue.message }}
    @@ -90,17 +98,15 @@ const { loading, working, errorMessage, - confirmed, - savedGeneration, pendingCandidate, managePending, validationResult, settings, + hasChanges, canWrite, load, - validate, - save, - apply + saveAndApply, + continuePending } = useSpamBlockSettings() diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs index e815c3f..6ef5225 100644 --- a/test/frontend/config-settings.test.mjs +++ b/test/frontend/config-settings.test.mjs @@ -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(`]*@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, /]*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 () => { diff --git a/test/review/frontend-findings.test.mjs b/test/review/frontend-findings.test.mjs index c48984e..e3cf471 100644 --- a/test/review/frontend-findings.test.mjs +++ b/test/review/frontend-findings.test.mjs @@ -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, '页面必须保留恢复或取消候选配置的可用路径,而不是保存冲突且应用不发请求') })