From 8c9c9b1568c7d12afebae8fa0ea7b979514aca7f Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 8 Sep 2026 00:05:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=82=AE=E4=BB=B6=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E9=A1=B5=E9=9D=A2=E5=B9=B6=E8=BF=81=E7=A7=BB=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConfigEditor/ConfigEditor.css | 30 ++++++++++++++ .../ConfigSettings/ConfigForm/ConfigForm.css | 21 ++++++++++ .../ConfigSettings/ConfigForm/ConfigForm.js | 7 +++- .../ConfigSettings/ConfigNavigation.js | 2 +- .../ConfigSettings/ConfigSettings.js | 13 +++--- .../MailPolicySettings/MailPolicySettings.css | 41 +++++++++++++++++++ .../MailPolicySettings/MailPolicySettings.js | 1 + .../MailPolicySettings/MailPolicySettings.vue | 19 +++++++++ .../components/RuleSettings/RuleSettings.css | 39 ++++++++++++++++-- .../components/RuleSettings/RuleSettings.js | 8 +++- .../components/RuleSettings/RuleSettings.vue | 22 ++++++++-- test/frontend/config-form.test.mjs | 19 ++++++--- test/frontend/config-settings.test.mjs | 36 ++++++++-------- 13 files changed, 220 insertions(+), 38 deletions(-) create mode 100644 frontend/src/views/components/RuleSettings/MailPolicySettings/MailPolicySettings.css create mode 100644 frontend/src/views/components/RuleSettings/MailPolicySettings/MailPolicySettings.js create mode 100644 frontend/src/views/components/RuleSettings/MailPolicySettings/MailPolicySettings.vue diff --git a/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.css b/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.css index 89dd067c..05be4e3e 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.css +++ b/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.css @@ -158,3 +158,33 @@ border-left: 0; } } + +/* 半宽桌面抽屉内保持邮件服务标题与操作同行,避免无意义的纵向占位。 */ +@media (min-width: 769px) and (max-height: 800px) { + .config-page[data-config-page="mail"] .config-overview { + padding-bottom: 6px; + } + + .config-page[data-config-page="mail"] .overview-heading { + align-items: center; + flex-wrap: nowrap; + gap: 8px; + } + + .config-page[data-config-page="mail"] .overview-copy { + flex-basis: auto; + } + + .config-page[data-config-page="mail"] .overview-actions { + justify-content: flex-end; + padding-top: 0; + } + + .config-page[data-config-page="mail"] .editor-panel { + padding: 8px; + } + + .config-page[data-config-page="mail"] .editor-footer .action-row { + margin-top: 0; + } +} diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.css b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.css index 175c1374..ebb3ef4e 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.css +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.css @@ -80,11 +80,32 @@ .config-form[data-form-section="base"] .form-grid, .config-form[data-form-section="mail"] .form-grid, + .config-form[data-form-section="mail_policy"] .form-grid, .form-group[data-group="verification"] .form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +/* 邮件服务页在桌面抽屉中采用紧凑双列,移除策略后可在常见高度内完整展示。 */ +@container config-main (min-width: 360px) { + .config-form[data-form-section="mail"] { + gap: 8px; + } + + .config-form[data-form-section="mail"] .form-grid { + gap: 8px 12px; + } + + .config-form[data-form-section="mail"] .form-group + .form-group { + margin-top: 8px; + padding-top: 10px; + } + + .config-form[data-form-section="mail"] .form-group-heading { + margin-bottom: 6px; + } +} + @media (min-width: 769px) and (max-height: 800px) { .config-form, .form-grid { diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index 85a1d0c1..ad01f900 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -140,7 +140,10 @@ const mailGroups = [ field('relay.password', '中继密码', 'password', { placeholder: '留空保持原值' }), field('relay.from', '发件邮箱', 'text', { placeholder: 'noreply@example.com' }), field('relay.fromDomain', '发件域名', 'text', { placeholder: 'example.com' }) - ]), + ]) +] + +const mailPolicyGroups = [ group('policy', '邮件策略', '', [ field('policy.maxMessageBytes', '最大邮件大小(字节)', 'number', { min: 0, max: Number.MAX_SAFE_INTEGER, integer: true, hint: '0 使用后端默认限制;1 MiB = 1048576 字节。' }), field('policy.spamFilterLevel', '垃圾邮件过滤级别', 'select', { fallback: 0, options: options([[0, '不过滤'], [1, 'SPF 与 DKIM 均失败时过滤'], [2, 'SPF 失败时过滤'], [3, 'DKIM 失败时过滤']]) }) @@ -205,7 +208,7 @@ const pluginDefinitions = { export function getFieldGroups(section, config) { const pluginId = Object.hasOwn(pluginDefinitions, section) ? section : '' if (section !== 'plugins' && !pluginId) { - return ({ base: baseGroups, mail: mailGroups, security_policy: securityPolicyGroups, ssl: sslGroups }[section] || []) + return ({ base: baseGroups, mail: mailGroups, mail_policy: mailPolicyGroups, security_policy: securityPolicyGroups, ssl: sslGroups }[section] || []) .map(item => ({ ...item, fields: item.fields diff --git a/frontend/src/views/components/ConfigSettings/ConfigNavigation.js b/frontend/src/views/components/ConfigSettings/ConfigNavigation.js index b25fe5d0..1f3e6dc5 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigNavigation.js +++ b/frontend/src/views/components/ConfigSettings/ConfigNavigation.js @@ -14,7 +14,7 @@ export function useNavigation() { const switching = ref(false) const pages = [ { value: 'base', label: '基础配置', component: BaseConfig }, - { value: 'mail', label: '邮件服务与策略', component: EmailConfig }, + { value: 'mail', label: '邮件服务', component: EmailConfig }, { value: 'ssl', label: 'SSL 配置', component: SslConfig }, { value: 'plugins', label: '插件配置', children: [ { value: 'spam_block', label: '垃圾邮件拦截', component: PluginsConfig }, diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.js b/frontend/src/views/components/ConfigSettings/ConfigSettings.js index 1c631746..0751fa59 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.js +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.js @@ -7,7 +7,8 @@ import { readFormConfig, updateFormConfig, validateFormConfig } from './ConfigFo const sections = [ { value: 'base', label: '基础配置' }, - { value: 'mail', label: '邮件服务与策略' }, + { value: 'mail', label: '邮件服务' }, + { value: 'mail_policy', label: '邮件策略' }, { value: 'security_policy', label: '安全策略' }, { value: 'ssl', label: 'SSL 配置' }, { value: 'plugins', label: '插件配置' }, @@ -56,7 +57,7 @@ export function usePage(pageKey = 'base') { // 独立页面复用原配置分区的原子保存与候选保护,不丢弃其他页面的字段。 const pluginPages = ['spam_block', 'telegram_push', 'wechat_push'] const apiSection = () => pluginPages.includes(selectedSection.value) ? 'plugins' - : selectedSection.value === 'security_policy' ? 'mail' : selectedSection.value + : ['mail_policy', 'security_policy'].includes(selectedSection.value) ? 'mail' : selectedSection.value const currentSection = computed(() => sections.find(item => item.value === selectedSection.value) || sections[0]) const sectionLoading = ref(false) let sectionRequestId = 0 @@ -91,17 +92,17 @@ export function usePage(pageKey = 'base') { ['listener', 'SMTP 监听'], ['imap', 'IMAP 监听'], ['pop3', 'POP3 监听'], - ['relay', 'SMTP 中继与登录验证'], - ['policy', '邮件与账号策略'] + ['relay', 'SMTP 中继'], + ['policy', '邮件策略'] ].filter(([key]) => { const before = active[key] || {} const after = candidate[key] || {} return Object.keys({ ...before, ...after }).some(field => JSON.stringify(before[field]) !== JSON.stringify(after[field])) }).map(([, label]) => label) const scope = pending.payload && loadedText.value - ? (changed.length ? `上次未完成的保存涉及:${changed.join('、')}。` : '上次未完成保存的邮件服务与安全策略未变化。') + ? (changed.length ? `上次未完成的保存涉及:${changed.join('、')}。` : '上次未完成保存的邮件配置未变化。') : '上次未完成保存的内容尚未完整读取,请刷新后核对。' - return `${scope}邮件服务页和安全策略页共用一组运行配置;完成保存时全部变更一起生效,监听端口变更仍需重启后端。` + return `${scope}邮件服务、邮件策略和安全策略共用一组运行配置;完成保存时全部变更一起生效,监听端口变更仍需重启后端。` }) let savedInput = null const validationResult = ref(null) diff --git a/frontend/src/views/components/RuleSettings/MailPolicySettings/MailPolicySettings.css b/frontend/src/views/components/RuleSettings/MailPolicySettings/MailPolicySettings.css new file mode 100644 index 00000000..79ea5eae --- /dev/null +++ b/frontend/src/views/components/RuleSettings/MailPolicySettings/MailPolicySettings.css @@ -0,0 +1,41 @@ +.mail-policy-settings { + padding: 12px 14px; + border: 1px solid var(--pm-border-color); + border-radius: 12px; + background: var(--pm-bg-secondary); +} + +.mail-policy-settings :deep(.config-page), +.mail-policy-settings :deep(.config-page-body) { + gap: 8px; +} + +.mail-policy-settings :deep(.config-overview) { + padding-bottom: 8px; +} + +.mail-policy-settings :deep(.overview-title h3) { + font-size: 17px; +} + +.mail-policy-settings :deep(.editor-panel) { + padding: 0; + border: 0; + border-radius: 0; + background: transparent; +} + +.mail-policy-settings :deep(.form-group-heading) { + display: none; +} + +.mail-policy-settings :deep(.editor-footer) { + margin-top: 8px; + padding-top: 8px; +} + +@container settings-content (max-width: 560px) { + .mail-policy-settings { + padding: 12px; + } +} diff --git a/frontend/src/views/components/RuleSettings/MailPolicySettings/MailPolicySettings.js b/frontend/src/views/components/RuleSettings/MailPolicySettings/MailPolicySettings.js new file mode 100644 index 00000000..78006232 --- /dev/null +++ b/frontend/src/views/components/RuleSettings/MailPolicySettings/MailPolicySettings.js @@ -0,0 +1 @@ +export const mailPolicySection = 'mail_policy' diff --git a/frontend/src/views/components/RuleSettings/MailPolicySettings/MailPolicySettings.vue b/frontend/src/views/components/RuleSettings/MailPolicySettings/MailPolicySettings.vue new file mode 100644 index 00000000..2ed4fafb --- /dev/null +++ b/frontend/src/views/components/RuleSettings/MailPolicySettings/MailPolicySettings.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/frontend/src/views/components/RuleSettings/RuleSettings.css b/frontend/src/views/components/RuleSettings/RuleSettings.css index 57685f91..a133b665 100644 --- a/frontend/src/views/components/RuleSettings/RuleSettings.css +++ b/frontend/src/views/components/RuleSettings/RuleSettings.css @@ -2,15 +2,33 @@ padding: 0; } +.rule-settings-card { + display: grid; + gap: 14px; +} + +.personal-rules { + min-width: 0; +} + +.rule-mail-policy + .personal-rules { + padding-top: 14px; + border-top: 1px solid var(--pm-border-color); +} + .settings-header { - margin-bottom: 24px; + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 6px 12px; + margin-bottom: 12px; } .settings-header h3 { font-size: 18px; font-weight: 600; color: var(--pm-text-primary); - margin: 0 0 8px 0; + margin: 0; } .settings-desc { @@ -23,7 +41,7 @@ border: 1px solid var(--pm-border-color); border-radius: var(--pm-radius-sm); overflow: hidden; - margin-bottom: 24px; + margin-bottom: 12px; } .modern-table :deep(th.el-table__cell) { @@ -119,3 +137,18 @@ margin: 24px 0; border-color: var(--pm-border-color); } + +@media (min-width: 769px) and (max-height: 800px) { + .rule-settings-card { + gap: 10px; + } + + .rule-mail-policy + .personal-rules { + padding-top: 10px; + } + + .settings-header, + .table-container { + margin-bottom: 8px; + } +} diff --git a/frontend/src/views/components/RuleSettings/RuleSettings.js b/frontend/src/views/components/RuleSettings/RuleSettings.js index 6b78ca89..9ca1f3ad 100644 --- a/frontend/src/views/components/RuleSettings/RuleSettings.js +++ b/frontend/src/views/components/RuleSettings/RuleSettings.js @@ -1,4 +1,4 @@ -import {reactive, ref} from 'vue'; +import {computed, reactive, ref} from 'vue'; import lang from '../../../i18n/i18n'; @@ -7,8 +7,11 @@ import {Delete, Edit, InfoFilled, Plus} from '@element-plus/icons-vue' import {http} from "@/utils/axios"; import {ElNotification} from "element-plus"; +import {useGlobalStatusStore} from '@/stores/useGlobalStatusStore' export function usePage() { + const globalStatus = useGlobalStatusStore() + const canReadGlobalPolicy = computed(() => globalStatus.hasCapability('config.read')) const data = ref([]) const dialogVisible = ref(false) @@ -154,7 +157,8 @@ export function usePage() { removeRuleLine, addRule, submitRule, - ruleTypeChange + ruleTypeChange, + canReadGlobalPolicy } } diff --git a/frontend/src/views/components/RuleSettings/RuleSettings.vue b/frontend/src/views/components/RuleSettings/RuleSettings.vue index 139f78b3..70461b12 100644 --- a/frontend/src/views/components/RuleSettings/RuleSettings.vue +++ b/frontend/src/views/components/RuleSettings/RuleSettings.vue @@ -1,7 +1,14 @@ diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index c478599a..11fffd7d 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -39,7 +39,7 @@ test('配置与安全策略页面使用分组表单,控件和样式能编译 telegram_push: { settings: null }, wechat_push: { settings: null } } } - for (const section of ['base', 'mail', 'security_policy', 'ssl', 'plugins', 'spam_block', 'telegram_push', 'wechat_push']) { + for (const section of ['base', 'mail', 'mail_policy', 'security_policy', 'ssl', 'plugins', 'spam_block', 'telegram_push', 'wechat_push']) { const groups = getFieldGroups(section, config) assert.ok(groups.length) const keys = groups.flatMap(group => group.fields.map(field => field.path.join('.'))) @@ -49,21 +49,30 @@ test('配置与安全策略页面使用分组表单,控件和样式能编译 } }) -test('邮件服务页面使用统一加密方式管理监听端口,中继和策略字段保持在同一配置分区', () => { - const config = { listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, relay: { relayPort: 465 }, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 } } +test('邮件服务页面只管理监听和中继,邮件策略使用独立表单并保持同一配置分区', () => { + const config = { + listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, + relay: { relayPort: 465 }, + imap: { tlsPort: 993 }, + pop3: { port: 110, tlsPort: 995 }, + policy: { maxMessageBytes: 10485760, spamFilterLevel: 1, smtpPort: 25, imapPort: 143, pop3Port: 110 } + } const text = JSON.stringify(config) const groups = getFieldGroups('mail', config) + const policyGroups = getFieldGroups('mail_policy', config) const listener = groups.find(group => group.id === 'listener') const relay = groups.find(group => group.id === 'relay') const keys = groups.flatMap(group => group.fields.map(input => input.path.join('.'))) + const policyKeys = policyGroups.flatMap(group => group.fields.map(input => input.path.join('.'))) assert.deepEqual(keys.filter(key => /^(listener|imap|pop3)\./.test(key)), [ 'listener.security', 'listener.port', 'listener.starttlsPort', 'listener.tlsPort', 'imap.tlsPort', 'pop3.port', 'pop3.tlsPort' ]) assert.ok(keys.includes('relay.relayHost')) assert.ok(keys.includes('relay.password')) - assert.ok(keys.includes('policy.maxMessageBytes')) - assert.ok(keys.includes('policy.spamFilterLevel')) + assert.ok(!keys.includes('policy.maxMessageBytes')) + assert.ok(!keys.includes('policy.spamFilterLevel')) + assert.deepEqual(policyKeys, ['policy.maxMessageBytes', 'policy.spamFilterLevel']) assert.ok(!keys.includes('policy.smtpPort')) assert.ok(!keys.includes('policy.imapPort')) assert.ok(!keys.includes('policy.pop3Port')) diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs index 3664303b..a3389473 100644 --- a/test/frontend/config-settings.test.mjs +++ b/test/frontend/config-settings.test.mjs @@ -222,7 +222,7 @@ test('独立页互不覆盖,页面卸载时清除密码并丢弃较慢响应', const emailRequest = emailPage.loadSection() email.resolve({ errorNo: 0, data: { policy: { smtpPort: 25 } } }) await emailRequest - assert.equal(emailPage.currentSection.value.label, '邮件服务与策略') + assert.equal(emailPage.currentSection.value.label, '邮件服务') assert.equal(emailPage.sectionLoading.value, false) base.resolve({ errorNo: 0, data: { database: { type: 'sqlite' } } }) await baseRequest @@ -249,9 +249,9 @@ test('同一页重复加载时,过期失败不覆盖最新请求', async () => assert.deepEqual(JSON.parse(page.editorText.value), { enabled: true }) }) -test('配置页按需加载,邮件页和安全策略页共用 mail 分区', async () => { +test('配置页按需加载,邮件服务、邮件策略和安全策略页共用 mail 分区', async () => { const requests = [] - for (const key of ['base', 'mail', 'security_policy', 'ssl', 'plugins']) { + for (const key of ['base', 'mail', 'mail_policy', 'security_policy', 'ssl', 'plugins']) { const { page } = fixture(async path => { requests.push(path); return { errorNo: 0, data: {} } }, key) await page.loadAll() assert.equal(page.selectedSection.value, key) @@ -260,11 +260,11 @@ test('配置页按需加载,邮件页和安全策略页共用 mail 分区', as assert.equal(page.confirmed.value, false) assert.equal(page.savedGeneration.value, 0) } - assert.deepEqual(requests, ['base', 'mail', 'security_policy', 'ssl', 'plugins'] - .flatMap(key => ['/api/v1/config/status', `/api/v1/config/${key === 'security_policy' ? 'mail' : key}`])) + assert.deepEqual(requests, ['base', 'mail', 'mail_policy', 'security_policy', 'ssl', 'plugins'] + .flatMap(key => ['/api/v1/config/status', `/api/v1/config/${['mail_policy', 'security_policy'].includes(key) ? 'mail' : key}`])) }) -for (const key of ['mail', 'security_policy']) { +for (const key of ['mail', 'mail_policy', 'security_policy']) { test(`${key} 页面验证、保存、候选恢复及应用均使用 mail 分区且保留未展示字段`, async () => { const requests = [] const payloads = [] @@ -295,21 +295,25 @@ for (const key of ['mail', 'security_policy']) { } } }) await page.loadAll() - const path = key === 'mail' ? ['imap', 'tlsPort'] : ['relay', 'loginPolicy'] - page.updateField(path, key === 'mail' ? 1993 : 'always') + const edit = { + mail: { path: ['imap', 'tlsPort'], value: 1993, rejected: 8888 }, + mail_policy: { path: ['policy', 'maxMessageBytes'], value: 5678, rejected: 8765 }, + security_policy: { path: ['relay', 'loginPolicy'], value: 'always', rejected: 'off' } + }[key] + page.updateField(edit.path, edit.value) await page.validateConfig() await page.saveConfig() assert.equal(page.savedGeneration.value, 2) assert.equal(payloads[0].expectedGeneration, 1) assert.deepEqual(payloads[0].payload.listener, active.listener) assert.deepEqual(payloads[0].payload.pop3, active.pop3) - if (key === 'security_policy') assert.equal(payloads[0].secretChanges['relay.password'], undefined) + if (key !== 'mail') assert.equal(payloads[0].secretChanges['relay.password'], undefined) if (key === 'security_policy') { await page.refreshPage() assert.equal(page.savedGeneration.value, 2) } - page.updateField(path, key === 'mail' ? 8888 : 'off') - assert.notEqual(key === 'mail' ? page.formConfig.value.imap.tlsPort : page.formConfig.value.relay.loginPolicy, key === 'mail' ? 8888 : 'off') + page.updateField(edit.path, edit.rejected) + assert.notEqual(edit.path.reduce((value, part) => value?.[part], page.formConfig.value), edit.rejected) await page.applyConfig() assert.equal(page.status.activeGeneration, 2) assert.equal(page.savedGeneration.value, 0) @@ -844,21 +848,21 @@ test('保存后更改密码或填写内容不能应用旧候选', async () => { assert.deepEqual(requests, []) }) -test('邮件页与安全策略页共享候选并明确提示改动范围,忽略代次与字段顺序', async () => { +test('邮件服务、邮件策略和安全策略页共享候选并明确提示改动范围,忽略代次与字段顺序', async () => { const active = { meta: { generation: 1 }, listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, relay: { verificationEnabled: true, loginPolicy: 'new_device' }, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: { maxMessageBytes: 1234, externalRecoveryEnabled: true } } - for (const key of ['mail', 'security_policy']) { + for (const key of ['mail', 'mail_policy', 'security_policy']) { const { page } = fixture(async () => ({ errorNo: 0, data: active }), key) await page.loadSection() page.pendingCandidate.value = { section: 'mail', payload: { ...active, meta: { generation: 2 }, pop3: { tlsPort: 995, port: 110 } } } - assert.match(page.pendingScopeHint.value, /邮件服务与安全策略未变化/) + assert.match(page.pendingScopeHint.value, /邮件配置未变化/) page.pendingCandidate.value.payload = { ...active, pop3: { port: 1110, tlsPort: 1995 }, policy: { maxMessageBytes: 5678 } } - assert.match(page.pendingScopeHint.value, /上次未完成的保存涉及:POP3 监听、邮件与账号策略。/) - assert.match(page.pendingScopeHint.value, /邮件服务页和安全策略页共用一组运行配置/) + assert.match(page.pendingScopeHint.value, /上次未完成的保存涉及:POP3 监听、邮件策略。/) + assert.match(page.pendingScopeHint.value, /邮件服务、邮件策略和安全策略共用一组运行配置/) page.pendingCandidate.value = { section: 'smtp' } assert.equal(page.pendingScopeHint.value, '') page.pendingCandidate.value = { section: 'mail' }