diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.css b/frontend/src/views/components/ConfigSettings/ConfigSettings.css index 42b4cba..e362307 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.css +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.css @@ -1,7 +1,8 @@ .config-settings { display: grid; - gap: 24px; - padding-bottom: 28px; + gap: 20px; + padding-bottom: 16px; + --config-accent: var(--pm-primary-color); } .settings-header, .panel-heading, @@ -23,11 +24,14 @@ flex-shrink: 0; } .settings-header { - flex-direction: column; - gap: 8px; + align-items: center; + flex-wrap: wrap; } .settings-header .settings-desc { - margin: 0; + margin: 6px 0 0; +} +.access-tag { + flex: none; } .config-availability { display: grid; @@ -39,55 +43,82 @@ } .panel-heading h4, .status-heading h4 { - margin-top: 5px; - font-size: 18px; + font-size: 16px; + line-height: 1.5; +} +.panel-heading h4 { + margin-top: 4px; +} +.heading-note { + margin: 5px 0 0; + color: var(--pm-text-secondary); + font-size: 12px; + line-height: 1.65; } .settings-desc { - max-width: 680px; - margin: 8px 0 0; color: var(--pm-text-secondary); + font-size: 13px; line-height: 1.65; } .section-eyebrow { - color: var(--pm-primary-color); + color: var(--config-accent); font-size: 12px; font-weight: 700; - letter-spacing: .08em; } .status-panel, .editor-panel, -.rollback-panel, -.audit-panel { - padding: 20px; +.maintenance-panel { border: 1px solid var(--pm-border-color); - border-radius: var(--pm-radius-lg); + border-radius: 16px; background: var(--pm-bg-secondary); } +.status-panel, +.editor-panel { + padding: 20px; +} .status-grid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; - margin-top: 18px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-top: 16px; } .status-item { - display: grid; + display: flex; + flex-direction: column; + align-items: flex-start; gap: 8px; min-width: 0; - padding: 14px; - border-radius: var(--pm-radius-sm); + padding: 12px; + border: 1px solid transparent; + border-radius: 10px; background: var(--pm-bg-primary); } -.status-item span { +.status-item .status-label { color: var(--pm-text-secondary); - font-size: 13px; + font-size: 12px; + line-height: 1.5; } .status-item strong { color: var(--pm-text-primary); - font-size: 14px; + font-size: 13px; + line-height: 1.6; overflow-wrap: anywhere; } -.status-wide { - grid-column: span 3; +.status-item .generation-value { + font-size: 18px; + line-height: 1.3; + font-variant-numeric: tabular-nums; +} +.status-warning { + border-color: var(--el-color-warning-light-5); + background: var(--el-color-warning-light-9); +} +.status-item :deep(.el-tag) { + max-width: 100%; + height: auto; + min-height: 24px; + white-space: normal; + overflow-wrap: anywhere; } .tag-list { display: flex; @@ -109,54 +140,131 @@ grid-area: main; display: grid; grid-template-columns: minmax(0, 1fr); - gap: 24px; + gap: 16px; min-width: 0; + container: config-main / inline-size; } -.config-main > section { +.config-main > * { min-width: 0; } .panel-heading { flex-wrap: wrap; } .selected-section-label { - color: var(--pm-primary-color); - font-size: 14px; + padding: 4px 8px; + border-radius: 6px; + background: var(--pm-bg-primary); + color: var(--pm-text-secondary); + font-size: 11px; + line-height: 1.5; white-space: nowrap; } -.editor-meta { +.change-workflow { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin: 18px 0; + padding: 0; + list-style: none; +} +.change-workflow li { display: flex; - flex-wrap: wrap; - gap: 8px 20px; - margin: 18px 0 10px; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 4px; + border-radius: 8px; + background: var(--pm-bg-primary); color: var(--pm-text-secondary); - font-size: 13px; + font-size: 12px; + line-height: 1.5; +} +.step-number { + display: grid; + place-items: center; + flex: none; + width: 20px; + height: 20px; + border-radius: 50%; + color: var(--config-accent); + background: color-mix(in srgb, var(--config-accent) 10%, var(--pm-bg-secondary)); + font-size: 11px; + font-weight: 700; +} +.editor-shell { + margin-top: 16px; + overflow: hidden; + border: 1px solid var(--pm-border-color); + border-radius: 10px; + transition: border-color .18s ease; +} +.editor-shell:focus-within { + border-color: var(--config-accent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--config-accent) 12%, transparent); +} +.editor-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--pm-border-color); + background: var(--pm-bg-primary); + color: var(--pm-text-secondary); + font-size: 11px; +} +.editor-toolbar strong { + color: var(--pm-text-primary); + font-size: 12px; + font-weight: 600; +} +.format-label { + margin-left: 6px; + color: var(--pm-text-secondary); + font-size: 10px; + font-weight: 400; } .config-editor :deep(.el-textarea__inner) { - min-height: 300px !important; - padding: 14px; + min-height: 240px !important; + padding: 14px 16px; color: var(--pm-text-primary); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12px; + line-height: 1.7; + tab-size: 2; + border: 0; + border-radius: 0; + background: var(--pm-bg-secondary); + box-shadow: none; +} +.editor-hint, +.action-hint { + margin: 8px 0 0; + color: var(--pm-text-secondary); + font-size: 12px; line-height: 1.6; - border: 1px solid var(--pm-border-color); - border-radius: var(--pm-radius-sm); - background: var(--pm-bg-primary); } .secret-editor { display: grid; - gap: 10px; + grid-template-columns: minmax(0, 1fr) minmax(180px, 1fr); + align-items: center; + gap: 16px; margin-top: 16px; padding: 14px; - border: 1px dashed var(--pm-border-color); - border-radius: var(--pm-radius-sm); + border: 1px solid var(--pm-border-color); + border-radius: 10px; background: var(--pm-bg-primary); } -.secret-editor strong { +.secret-editor label { color: var(--pm-text-primary); + font-size: 13px; + font-weight: 600; } .secret-editor p { margin: 5px 0 0; color: var(--pm-text-secondary); - font-size: 13px; + font-size: 12px; line-height: 1.5; } .confirmation-check { @@ -165,6 +273,14 @@ height: auto; align-items: flex-start; } +.editor-footer { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--pm-border-color); +} +.editor-footer .confirmation-check { + margin-top: 0; +} .confirmation-check :deep(.el-checkbox__label) { white-space: normal; overflow-wrap: anywhere; @@ -180,6 +296,86 @@ gap: 10px; margin-top: 18px; } +.action-row > .el-button, +.audit-actions > .el-button { + margin-left: 0; +} +.audit-actions { + margin-top: 0; +} +.maintenance-heading { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 6px 12px; + margin-top: 8px; +} +.maintenance-heading h4 { + margin: 0; + color: var(--pm-text-primary); + font-size: 14px; +} +.maintenance-heading p { + margin: 0; + color: var(--pm-text-secondary); + font-size: 12px; +} +.maintenance-summary { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-radius: 16px; + list-style: none; + cursor: pointer; + transition: background-color .18s ease; +} +.maintenance-summary::-webkit-details-marker { + display: none; +} +.maintenance-summary:hover { + background: var(--pm-bg-hover); +} +.maintenance-summary:focus-visible, +.backup-item:focus-visible { + outline: 2px solid var(--config-accent); + outline-offset: -3px; +} +.maintenance-copy { + display: grid; + flex: 1; + min-width: 0; + gap: 5px; +} +.maintenance-copy strong { + color: var(--pm-text-primary); + font-size: 14px; +} +.maintenance-copy > span { + color: var(--pm-text-secondary); + font-size: 12px; + line-height: 1.5; +} +.disclosure-arrow { + flex: none; + width: 7px; + height: 7px; + margin: 0 3px; + border-right: 1.5px solid var(--pm-text-secondary); + border-bottom: 1.5px solid var(--pm-text-secondary); + transform: rotate(45deg); + transition: transform .18s ease; +} +.maintenance-panel[open] > .maintenance-summary { + border-bottom: 1px solid var(--pm-border-color); + border-radius: 16px 16px 0 0; +} +.maintenance-panel[open] .disclosure-arrow { + transform: rotate(225deg); +} +.maintenance-body { + padding: 18px 20px 20px; +} .backup-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); @@ -220,12 +416,12 @@ background: color-mix(in srgb, var(--pm-primary-color) 8%, var(--pm-bg-primary)); } .result-success { - color: #166534; - background: #f0fdf4; + color: var(--el-color-success-dark-2); + background: var(--el-color-success-light-9); } .result-error { - color: #991b1b; - background: #fef2f2; + color: var(--el-color-danger-dark-2); + background: var(--el-color-danger-light-9); } .result-box ul { margin: 0; @@ -235,6 +431,9 @@ display: grid; gap: 10px; margin-top: 18px; + max-height: 360px; + overflow-y: auto; + overscroll-behavior: contain; } .audit-item { display: flex; @@ -249,6 +448,7 @@ display: grid; gap: 4px; min-width: 0; + overflow-wrap: anywhere; } .audit-item strong { color: var(--pm-text-primary); @@ -261,26 +461,47 @@ .audit-item time { flex: none; } -@container settings-content (max-width: 560px) { +@container config-main (max-width: 620px) { + .status-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .secret-editor { + grid-template-columns: minmax(0, 1fr); + } +} +@container config-main (max-width: 400px) { .status-panel, - .editor-panel, - .rollback-panel, - .audit-panel { + .editor-panel { padding: 16px; } - .status-grid { - grid-template-columns: 1fr; + .maintenance-summary, + .maintenance-body { + padding: 14px; } - .status-wide { - grid-column: auto; + .maintenance-summary { + flex-wrap: wrap; } - .settings-header, - .panel-heading, - .status-heading, .audit-item { align-items: stretch; flex-direction: column; } + .change-workflow li { + flex-direction: column; + gap: 5px; + font-size: 11px; + } + .action-row .el-button, + .audit-actions .el-button { + flex: 1; + min-width: 110px; + } +} +@container settings-content (max-width: 560px) { + .settings-header { + align-items: flex-start; + flex-direction: column; + gap: 10px; + } .config-workspace { grid-template-columns: minmax(0, 1fr); grid-template-areas: @@ -291,15 +512,11 @@ .selected-section-label { white-space: normal; } - .action-row .el-button, - .audit-actions .el-button { - flex: 1; - min-width: 120px; - } } .panel-desc { - margin: 14px 0 0; + margin: 0; color: var(--pm-text-secondary); + font-size: 13px; line-height: 1.6; } .migration-source { @@ -336,7 +553,7 @@ gap: 12px; } .migration-error { - color: #b42318; + color: var(--el-color-danger); } .restart-services { margin-top: 18px; @@ -344,7 +561,7 @@ .restart-result { margin-top: 16px; } -@media (max-width: 640px) { +@container config-main (max-width: 400px) { .migration-source { grid-template-columns: 1fr; } @@ -353,3 +570,15 @@ flex-direction: column; } } +@media (prefers-color-scheme: dark) { + .config-settings { + --config-accent: var(--pm-primary-light-3); + } +} +@media (prefers-reduced-motion: reduce) { + .editor-shell, + .maintenance-summary, + .disclosure-arrow { + transition: none; + } +} diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.js b/frontend/src/views/components/ConfigSettings/ConfigSettings.js index aab70c6..aa210ba 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.js +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.js @@ -4,11 +4,11 @@ import { http } from '@/utils/axios' import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' const sections = [ - { value: 'base', label: '基础配置' }, - { value: 'smtp', label: 'SMTP 配置' }, - { value: 'email', label: '邮箱策略' }, - { value: 'ssl', label: 'SSL 配置' }, - { value: 'plugins', label: '插件配置' } + { value: 'base', label: '基础配置', description: '管理站点与数据库连接,敏感凭据单独填写。' }, + { value: 'smtp', label: 'SMTP 配置', description: '管理邮件发送与中继连接,密码留空时保持原值。' }, + { value: 'email', label: '邮箱策略', description: '管理邮件协议端口与账号恢复策略。' }, + { value: 'ssl', label: 'SSL 配置', description: '管理证书与加密连接,应用后请留意待重启提示。' }, + { value: 'plugins', label: '插件配置', description: '管理插件运行配置,敏感设置仅显示配置状态。' } ] const serviceLabels = { @@ -45,6 +45,9 @@ export function usePage() { degradedReasons: [] }) const selectedSection = ref('base') + const currentSection = computed(() => sections.find(item => item.value === selectedSection.value) || sections[0]) + const sectionLoading = ref(false) + let sectionRequestId = 0 const editorText = ref('') const sectionError = ref('') const secretValue = ref('') @@ -119,29 +122,38 @@ export function usePage() { } const loadSection = async () => { + const requestId = ++sectionRequestId + const section = selectedSection.value + sectionLoading.value = true sectionError.value = '' + editorText.value = '' secretValue.value = '' + confirmed.value = false validationResult.value = null savedGeneration.value = 0 try { - const response = await http.get(`/api/v1/config/${selectedSection.value}`) + const response = await http.get(`/api/v1/config/${section}`) + if (requestId !== sectionRequestId) return notifyResponseError(response, '配置分区读取失败') const publicConfig = response.data || {} editorText.value = JSON.stringify(publicConfig, null, 2) - if (selectedSection.value === 'base') { + if (section === 'base') { const database = publicConfig.database || {} Object.assign(databaseConfig, { type: database.type || '', name: database.name || '', localRef: database.localRef || '' }) } } catch (error) { + if (requestId !== sectionRequestId) return sectionError.value = error.message || '配置分区读取失败' editorText.value = '' + } finally { + if (requestId === sectionRequestId) sectionLoading.value = false } } const selectSection = value => { - if (selectedSection.value === value) return + if (selectedSection.value === value || working.value || !sections.some(item => item.value === value)) return selectedSection.value = value - loadSection() + return loadSection() } const loadBackups = async () => { @@ -448,13 +460,13 @@ export function usePage() { return { loading, availabilityError, working, status, stateLabel, stateTagType, sectionOptions: sections, - selectedSection, selectSection, editorText, sectionError, sensitiveHint, secretField, secretValue, + selectedSection, currentSection, sectionLoading, selectSection, editorText, sectionError, sensitiveHint, secretField, secretValue, confirmed, savedGeneration, validationResult, canWrite, canRollback, canAudit, canExportAudit, backupsLoading, backupsError, backups, rollbackGeneration, rollbackConfirmed, rollbackPreview, auditLoading, auditError, auditItems, loadAll, loadSection, validateConfig, saveConfig, applyConfig, loadBackups, previewRollback, executeRollback, loadAudit, exportAudit, formatTime, serviceLabel, sectionLabel, operationLabel, databaseConfig, databaseTypeLabel, canMigrate, - migrationTargetName, migrationPreview, migrationRecord, migrationStatusLabel, migrationStatusType, + migrationTargetName, migrationPreview, migrationToken, migrationRecord, migrationStatusLabel, migrationStatusType, previewDatabaseMigration, loadMigrationStatus, executeDatabaseMigration, canRestart, restartPreview, previewRestart, requestRestart } diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.vue b/frontend/src/views/components/ConfigSettings/ConfigSettings.vue index c0abf37..1a5d056 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.vue +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.vue @@ -1,8 +1,13 @@ diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs index d2cef24..808a30a 100644 --- a/test/frontend/config-settings.test.mjs +++ b/test/frontend/config-settings.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { readFileSync } from 'node:fs' import { compileFunction } from 'node:vm' +import { createRequire } from 'node:module' import { computed, reactive, ref } from './vue.mjs' const source = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigSettings.js', import.meta.url), 'utf8') @@ -17,6 +18,13 @@ function fixture(get) { return { page, messages } } +function deferred() { + let resolve + let reject + const promise = new Promise((success, failure) => { resolve = success; reject = failure }) + return { promise, resolve, reject } +} + test('旧版配置仅请求状态,不并发请求分区、备份和审计', async () => { const requests = [] const { page, messages } = fixture(async path => { @@ -86,3 +94,105 @@ test('状态请求失败后可重试恢复,不保留不可用提示', async () await page.loadAll() assert.equal(page.availabilityError.value, '') }) + +test('快速切换分区时丢弃较慢的旧响应,并清除旧编辑内容与确认状态', async () => { + const base = deferred() + const email = deferred() + const { page } = fixture(path => path.endsWith('/base') ? base.promise : email.promise) + page.editorText.value = '{"old":true}' + page.confirmed.value = true + page.secretValue.value = 'test-only-value' + const baseRequest = page.loadSection() + assert.equal(page.editorText.value, '') + assert.equal(page.confirmed.value, false) + assert.equal(page.secretValue.value, '') + assert.equal(page.sectionLoading.value, true) + const emailRequest = page.selectSection('email') + email.resolve({ errorNo: 0, data: { policy: { smtpPort: 25 } } }) + await emailRequest + assert.equal(page.currentSection.value.label, '邮箱策略') + assert.equal(page.sectionLoading.value, false) + base.resolve({ errorNo: 0, data: { database: { type: 'sqlite' } } }) + await baseRequest + assert.deepEqual(JSON.parse(page.editorText.value), { policy: { smtpPort: 25 } }) + assert.equal(page.databaseConfig.type, '') +}) + +test('过期请求失败不清除新分区的加载状态或覆盖错误信息', async () => { + const base = deferred() + const ssl = deferred() + const { page } = fixture(path => path.endsWith('/base') ? base.promise : ssl.promise) + const baseRequest = page.loadSection() + const sslRequest = page.selectSection('ssl') + base.reject(new Error('旧分区读取失败')) + await baseRequest + assert.equal(page.sectionLoading.value, true) + assert.equal(page.sectionError.value, '') + ssl.resolve({ errorNo: 0, data: { enabled: true } }) + await sslRequest + assert.equal(page.sectionLoading.value, false) + assert.equal(page.sectionError.value, '') + assert.deepEqual(JSON.parse(page.editorText.value), { enabled: true }) +}) + +test('变更执行期间或分区值无效时不切换,所有分区均提供中文说明', async () => { + const requests = [] + const { page } = fixture(async path => { requests.push(path); return { errorNo: 0, data: {} } }) + page.working.value = 'save' + await page.selectSection('smtp') + assert.equal(page.selectedSection.value, 'base') + page.working.value = '' + await page.selectSection('invalid-section') + await page.selectSection('base') + assert.deepEqual(requests, []) + for (const section of page.sectionOptions) { + assert.match(section.description, /[\u4e00-\u9fff]/) + } + await page.selectSection('plugins') + assert.deepEqual(requests, ['/api/v1/config/plugins']) + assert.equal(page.currentSection.value.label, '插件配置') +}) + +test('迁移预览票据返回模板,迁移执行取消时不发送写入请求', async () => { + const requests = [] + const page = createPage(computed, () => {}, reactive, ref, + { success: () => {}, error: () => assert.fail('不应显示错误') }, + { confirm: async () => { throw 'cancel' } }, + { post: async (path, payload) => { + requests.push({ path, payload }) + return { errorNo: 0, data: { migration: { id: 'test-migration', status: 'previewed' }, token: 'test-only-ticket' } } + } }, () => ({ hasCapability: () => true })) + page.status.activeGeneration = 3 + page.migrationTargetName.value = ' test-next.db ' + assert.equal(page.migrationToken.value, '') + await page.previewDatabaseMigration() + assert.equal(page.migrationToken.value, 'test-only-ticket') + assert.equal(page.migrationPreview.value.id, 'test-migration') + await page.executeDatabaseMigration() + assert.deepEqual(requests, [{ + path: '/api/v1/database/migrations/preview', + payload: { targetName: 'test-next.db', expectedGeneration: 3 } + }]) +}) + +test('配置模板保留权限与确认绑定,维护操作使用原生可访问折叠面板', () => { + const require = createRequire(new URL('../../frontend/package.json', import.meta.url)) + const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc') + const root = new URL('../../frontend/src/views/components/ConfigSettings/', import.meta.url) + const template = readFileSync(new URL('ConfigSettings.vue', root), 'utf8') + const css = readFileSync(new URL('ConfigSettings.css', root), 'utf8') + const { descriptor, errors } = parse(template) + assert.deepEqual(errors, []) + 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.match(template, /
]+ open>/) + assert.match(template, /
/g) || []).length, 4) + assert.match(css, /@container config-main \(max-width: 620px\)/) + assert.match(css, /\.maintenance-summary:focus-visible/) + assert.doesNotMatch(template, /&#x[0-9a-f]+;/i) +})