优化邮件服务页面并迁移邮件策略

This commit is contained in:
smail 2026-09-08 00:05:32 +08:00
parent c39a86a750
commit 8c9c9b1568
13 changed files with 220 additions and 38 deletions

View File

@ -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;
}
}

View File

@ -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 {

View File

@ -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

View File

@ -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 },

View File

@ -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)

View File

@ -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;
}
}

View File

@ -0,0 +1 @@
export const mailPolicySection = 'mail_policy'

View File

@ -0,0 +1,19 @@
<template>
<section class="mail-policy-settings" aria-label="邮件策略">
<ConfigEditor />
</section>
</template>
<script setup>
import { provide } from 'vue'
import { usePage } from '../../ConfigSettings/ConfigSettings.js'
import ConfigEditor from '../../ConfigSettings/ConfigEditor/ConfigEditor.vue'
import { mailPolicySection } from './MailPolicySettings.js'
const page = usePage(mailPolicySection)
provide('smail-config-page', page)
defineExpose({ beforeLeave: page.beforeLeave })
</script>
<style scoped src="./MailPolicySettings.css"></style>

View File

@ -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;
}
}

View File

@ -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
}
}

View File

@ -1,7 +1,14 @@
<template>
<div class="settings-card">
<div class="settings-card rule-settings-card">
<MailPolicySettings
v-if="canReadGlobalPolicy"
ref="mailPolicyPage"
class="rule-mail-policy"
/>
<section class="personal-rules" aria-labelledby="personal-rules-title">
<div class="settings-header">
<h3>{{ lang.auto_rules }}</h3>
<h3 id="personal-rules-title">{{ lang.auto_rules }}</h3>
<p class="settings-desc">{{ lang.auto_rules_desc }}</p>
</div>
@ -38,6 +45,7 @@
<el-icon><Plus /></el-icon> {{ lang.new_rule }}
</el-button>
</div>
</section>
<el-dialog v-model="dialogVisible" :title="addRuleForm.id > 0 ? '编辑规则' : lang.new_rule" width="700px" class="premium-dialog">
<div class="dialog-content">
@ -110,9 +118,11 @@
</template>
<script setup>
import { ref } from 'vue'
import { usePage, Plus } from './RuleSettings.js'
import { usePagination } from '../ConfigSettings/ConfigPagination/ConfigPagination.js'
import ConfigPagination from '../ConfigSettings/ConfigPagination/ConfigPagination.vue'
import MailPolicySettings from './MailPolicySettings/MailPolicySettings.vue'
const {
lang,
@ -136,9 +146,15 @@ const {
DELETE,
FORWARD,
reflushGroupInfos,
groupData
groupData,
canReadGlobalPolicy
} = usePage()
const { currentPage, pageCount, pageItems } = usePagination(data, 6)
const mailPolicyPage = ref(null)
const beforeLeave = () => mailPolicyPage.value?.beforeLeave?.() ?? true
defineExpose({ beforeLeave })
</script>
<style scoped src="./RuleSettings.css"></style>

View File

@ -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'))

View File

@ -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' }