将配置编辑改为可视化表单并建立三种数据库迁移改造检查点
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-05 23:20:06 +08:00
parent fd67cca2d4
commit d92dfdacff
11 changed files with 885 additions and 83 deletions

View File

@ -77,8 +77,8 @@
<section class="editor-panel" :aria-busy="sectionLoading">
<div class="panel-heading">
<div>
<h4>{{ canWrite ? '编辑配置' : '查看配置' }}</h4>
<p class="heading-note">{{ canWrite ? '仅处理当前页面的配置,保存后再应用生效。' : '当前页面的脱敏配置内容。' }}</p>
<h4>{{ canWrite ? '填写配置' : '查看配置' }}</h4>
<p class="heading-note">{{ canWrite ? '按分组填写内容,验证并保存后再应用生效。' : '当前页面的脱敏配置内容。' }}</p>
</div>
<span class="selected-section-label">{{ currentSection.label }}</span>
</div>
@ -98,24 +98,14 @@
class="config-alert"
/>
<div class="editor-shell" v-loading="sectionLoading">
<div class="editor-toolbar">
<strong>配置内容 <span class="format-label">JSON</span></strong>
<span>编辑代次 {{ status.activeGeneration || '-' }}</span>
</div>
<el-input
v-model="editorText"
type="textarea"
:rows="12"
resize="vertical"
spellcheck="false"
wrap="off"
class="config-editor"
:readonly="!canWrite || sectionLoading || !!working || !!sectionError"
aria-label="配置 JSON 编辑器"
aria-describedby="config-sensitive-hint"
/>
</div>
<ConfigForm
v-if="!sectionLoading && !sectionError"
:section="selectedSection"
:config="formConfig"
:errors="formErrors"
:disabled="!canWrite || !!working"
@change="updateField"
/>
<p id="config-sensitive-hint" class="editor-hint">{{ sensitiveHint }}。</p>
<div v-if="secretField" class="secret-editor">
@ -125,13 +115,14 @@
</div>
<el-input
id="config-secret-input"
v-model="secretValue"
:model-value="secretValue"
type="password"
show-password
clearable
:disabled="!canWrite || sectionLoading || !!working || !!sectionError"
:placeholder="secretField.placeholder"
autocomplete="new-password"
@update:model-value="updateSecret"
/>
</div>
@ -150,7 +141,7 @@
{{ savedGeneration ? `应用代次 ${savedGeneration}` : '应用配置' }}
</el-button>
</div>
<p class="action-hint">保存不会立即生效,确认候选配置后再应用。</p>
<p class="action-hint">保存不会立即生效;保存后若继续修改内容,需要重新保存才能应用。</p>
</div>
<div v-if="validationResult" class="result-box" :class="validationResult.valid ? 'result-success' : 'result-error'" role="status">
<strong>{{ validationResult.valid ? '验证通过' : '验证未通过' }}</strong>
@ -169,6 +160,7 @@
<script setup>
import { inject } from 'vue'
import ConfigForm from '../ConfigForm/ConfigForm.vue'
const {
availabilityError,
@ -180,7 +172,10 @@ const {
selectedSection,
currentSection,
sectionLoading,
editorText,
formConfig,
formErrors,
updateField,
updateSecret,
sectionError,
sensitiveHint,
secretField,

View File

@ -0,0 +1,94 @@
.config-field {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
min-width: 0;
}
.field-wide {
grid-column: 1 / -1;
}
.field-label {
display: flex;
align-items: center;
gap: 6px;
color: var(--pm-text-primary);
font-size: 13px;
font-weight: 600;
line-height: 1.6;
}
.field-required,
.field-error {
color: var(--el-color-danger);
}
.field-locked {
color: var(--pm-text-secondary);
font-size: 11px;
font-weight: 400;
}
.config-field > .el-input-number,
.config-field > .el-select {
width: 100%;
}
.config-field :deep(.el-input-number .el-input__inner) {
text-align: left;
}
.config-field > .el-switch {
align-self: flex-start;
max-width: 100%;
}
.field-list {
display: grid;
gap: 8px;
}
.field-list-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.network-row {
display: grid;
grid-template-columns: 100px minmax(0, 1fr) 90px auto;
gap: 8px;
}
.network-row > * {
min-width: 0;
width: 100%;
}
.field-add {
justify-self: start;
margin-left: 0;
}
.field-hint,
.field-error {
margin: 0;
font-size: 12px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.field-hint {
color: var(--pm-text-secondary);
}
@container config-main (max-width: 620px) {
.network-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
padding: 10px;
border: 1px solid var(--pm-border-color);
border-radius: 8px;
}
}

View File

@ -0,0 +1,28 @@
import { computed } from 'vue'
import { fieldKey } from '../ConfigForm/ConfigForm.js'
export { fieldKey }
export function useField(props, emit) {
const id = computed(() => `config-field-${fieldKey(props.field).replaceAll('.', '-')}`)
const descriptionId = computed(() => [
props.field.hint && `${id.value}-hint`,
props.error && `${id.value}-error`
].filter(Boolean).join(' ') || undefined)
const entries = computed(() => Array.isArray(props.value) ? props.value : [])
const selectOptions = computed(() => {
const values = props.field.options || []
const current = props.value ?? props.field.fallback ?? ''
return values.some(item => item.value === current)
? values
: [...values, { value: current, label: `当前值:${current === '' ? '未设置' : current}` }]
})
const update = value => {
if (!props.disabled && !props.field.locked) emit('change', props.field.path, value)
}
const updateEntry = (index, value) => update(entries.value.map((entry, current) => current === index ? value : entry))
const updateTarget = (index, key, value) => updateEntry(index, { ...entries.value[index], [key]: value ?? null })
const removeEntry = index => update(entries.value.filter((_, current) => current !== index))
return { id, descriptionId, entries, selectOptions, update, updateEntry, updateTarget, removeEntry }
}

View File

@ -0,0 +1,133 @@
<template>
<div class="config-field" :class="{ 'field-wide': ['list', 'network'].includes(field.type) }" :data-field="fieldKey(field)">
<label :id="`${id}-label`" :for="id" class="field-label">
{{ field.label }}
<span v-if="field.required" class="field-required" aria-hidden="true">*</span>
<span v-if="field.locked" class="field-locked">系统维护</span>
</label>
<el-select
v-if="field.type === 'select'"
:id="id"
:model-value="value ?? field.fallback ?? ''"
:empty-values="[null, undefined]"
:disabled="disabled || field.locked"
:aria-describedby="descriptionId"
:aria-invalid="!!error"
placeholder="请选择"
@update:model-value="update"
>
<el-option v-for="option in selectOptions" :key="String(option.value)" :label="option.label" :value="option.value" />
</el-select>
<el-switch
v-else-if="field.type === 'switch'"
:id="id"
:model-value="!!value"
:disabled="disabled || field.locked"
:aria-label="field.label"
:aria-describedby="descriptionId"
:aria-invalid="!!error"
active-text="已开启"
inactive-text="已关闭"
@update:model-value="update"
/>
<el-input-number
v-else-if="field.type === 'number'"
:id="id"
:model-value="value === undefined ? 0 : value"
:min="field.min"
:max="field.max"
:step="field.step || 1"
:precision="field.integer ? 0 : undefined"
:disabled="disabled || field.locked"
:aria-label="field.label"
:aria-describedby="descriptionId"
:aria-invalid="!!error"
controls-position="right"
@update:model-value="update"
/>
<div v-else-if="field.type === 'list'" class="field-list" role="group" :aria-labelledby="`${id}-label`">
<div v-for="(entry, index) in entries" :key="index" class="field-list-row">
<el-input
:id="index === 0 ? id : `${id}-${index}`"
:model-value="entry"
:disabled="disabled || field.locked"
:placeholder="field.placeholder || '填写内容'"
:aria-label="`${field.label},第 ${index + 1} 项`"
:aria-describedby="descriptionId"
:aria-invalid="!!error"
@update:model-value="updateEntry(index, $event)"
/>
<el-button :disabled="disabled || field.locked" :aria-label="`移除${field.label}第 ${index + 1} 项`" @click="removeEntry(index)">移除</el-button>
</div>
<el-button class="field-add" :disabled="disabled || field.locked" plain @click="update([...entries, ''])">添加一项</el-button>
</div>
<div v-else-if="field.type === 'network'" class="field-list" role="group" :aria-labelledby="`${id}-label`">
<div v-for="(entry, index) in entries" :key="index" class="network-row">
<el-select
:id="index === 0 ? id : `${id}-${index}`"
:model-value="entry.scheme"
:disabled="disabled || field.locked"
:aria-label="`网络目标 ${index + 1} 协议`"
:aria-describedby="descriptionId"
:aria-invalid="!!error"
placeholder="协议"
@update:model-value="updateTarget(index, 'scheme', $event)"
>
<el-option label="HTTPS" value="https" />
<el-option label="WSS" value="wss" />
</el-select>
<el-input
:model-value="entry.host"
:disabled="disabled || field.locked"
placeholder="api.example.com"
:aria-label="`网络目标 ${index + 1} 域名`"
:aria-describedby="descriptionId"
:aria-invalid="!!error"
@update:model-value="updateTarget(index, 'host', $event)"
/>
<el-input-number
:model-value="entry.port"
:disabled="disabled || field.locked"
:min="1"
:max="65535"
:precision="0"
:controls="false"
:aria-label="`网络目标 ${index + 1} 端口`"
:aria-describedby="descriptionId"
:aria-invalid="!!error"
@update:model-value="updateTarget(index, 'port', $event)"
/>
<el-button :disabled="disabled || field.locked" :aria-label="`移除网络目标 ${index + 1}`" @click="removeEntry(index)">移除</el-button>
</div>
<el-button class="field-add" :disabled="disabled || field.locked" plain @click="update([...entries, { scheme: 'https', host: '', port: 443 }])">添加网络目标</el-button>
</div>
<el-input
v-else
:id="id"
:model-value="value ?? ''"
:disabled="disabled || field.locked"
:placeholder="field.placeholder || '填写内容'"
:aria-describedby="descriptionId"
:aria-invalid="!!error"
autocomplete="off"
@update:model-value="update"
/>
<p v-if="field.hint" :id="`${id}-hint`" class="field-hint">{{ field.hint }}</p>
<p v-if="error" :id="`${id}-error`" class="field-error" role="alert">{{ error }}</p>
</div>
</template>
<script setup>
import { fieldKey, useField } from './ConfigField.js'
const props = defineProps({
field: { type: Object, required: true },
value: { type: [String, Number, Boolean, Array, Object], default: undefined },
disabled: Boolean,
error: { type: String, default: '' }
})
const emit = defineEmits(['change'])
const { id, descriptionId, entries, selectOptions, update, updateEntry, updateTarget, removeEntry } = useField(props, emit)
</script>
<style scoped src="./ConfigField.css"></style>

View File

@ -0,0 +1,58 @@
.config-form {
display: grid;
gap: 24px;
margin-top: 22px;
min-width: 0;
}
.form-group {
min-width: 0;
}
.form-group + .form-group {
padding-top: 24px;
border-top: 1px solid var(--pm-border-color);
}
.form-group-heading {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.form-group-heading h5 {
margin: 0;
color: var(--pm-text-primary);
font-size: 15px;
line-height: 1.5;
}
.credential-status {
color: var(--pm-text-secondary);
font-size: 12px;
}
.form-group-description,
.form-empty {
margin: 6px 0 18px;
color: var(--pm-text-secondary);
font-size: 12px;
line-height: 1.7;
overflow-wrap: anywhere;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 20px;
}
@container config-main (max-width: 620px) {
.form-grid {
grid-template-columns: minmax(0, 1fr);
gap: 18px;
}
}

View File

@ -0,0 +1,227 @@
const field = (path, label, type = 'text', extra = {}) => ({ path: path.split('.'), label, type, ...extra })
const options = entries => entries.map(([value, label]) => ({ value, label }))
const loginOptions = options([
['', '使用默认策略'], ['off', '关闭'], ['new_device', '新设备验证'], ['always', '每次登录验证']
])
const port = (path, label, extra = {}) => field(path, label, 'number', { min: 0, max: 65535, integer: true, ...extra })
const group = (id, title, description, fields) => ({ id, title, description, fields })
export function readFormConfig(text) {
try {
const value = JSON.parse(text)
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
} catch {
return {}
}
}
export function getValue(config, path) {
return path.reduce((value, key) => value?.[key], config)
}
export function fieldKey(item) {
return item.path.join('.')
}
const networkDatabase = config => config.database?.type !== 'sqlite'
const uploadedCertificate = config => ['managed_upload', 'custom'].includes(config.tls?.mode)
const normalizedDomain = value => value.toLowerCase().replace(/\.$/, '')
const validDomain = value => {
if (typeof value !== 'string' || value !== value.trim()) return false
const domain = normalizedDomain(value)
return domain.length > 0 && domain.length <= 253 && !/^\d+(\.\d+){3}$/.test(domain) &&
domain.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
}
const baseGroups = [
group('site', '站点与域名', '填写域名即可,无需添加协议前缀或路径。', [
field('domains.primary', '主邮件域名', 'text', { required: true, placeholder: 'example.com' }),
field('domains.web', '网页访问域名', 'text', { required: true, placeholder: 'mail.example.com' }),
field('domains.accepted', '接收邮件的域名', 'list', { required: true, hint: '需包含主邮件域名,每个域名单独填写。', placeholder: 'example.com' }),
field('domains.accessHosts', '额外允许的访问主机', 'list', { hint: '按需填写域名或 IP 地址,不填写则保持默认访问范围。', placeholder: 'mail.example.com' }),
field('logLevel', '日志级别', 'select', { options: options([['', '默认级别'], ['debug', '调试'], ['info', '信息'], ['warn', '警告'], ['error', '错误']]) })
]),
group('database', '数据库连接', '数据库类型保持不变;修改数据库名称或文件请使用右侧“数据库迁移”,避免切换到空数据库。', [
field('database.type', '数据库类型', 'select', { locked: true, options: options([['sqlite', 'SQLite(本地文件)'], ['mysql', 'MySQL'], ['postgresql', 'PostgreSQL']]) }),
field('database.localRef', '数据库文件', 'text', { locked: true, show: config => !networkDatabase(config) }),
field('database.name', '数据库名称', 'text', { locked: true, show: networkDatabase }),
field('database.host', '数据库主机', 'text', { required: true, show: networkDatabase, placeholder: '127.0.0.1' }),
port('database.port', '数据库端口', { min: 1, show: networkDatabase }),
field('database.username', '数据库用户名', 'text', { required: true, show: networkDatabase })
]),
group('http', '网页访问服务', '端口和加密方式变更可能需要重启,并影响当前访问地址。', [
port('http.port', 'HTTP 端口', { hint: '填写 0 使用后端默认值。' }),
field('http.tlsMode', '加密连接方式', 'select', { options: options([['', '使用默认方式'], ['direct', '由 Smail 提供 HTTPS'], ['proxy', '由反向代理提供 HTTPS']]) }),
field('http.httpsEnabled', '启用内置 HTTPS', 'switch', { show: config => config.http?.tlsMode !== 'proxy' }),
port('http.httpsPort', 'HTTPS 端口', { show: config => config.http?.tlsMode !== 'proxy', hint: '启用内置 HTTPS 时需填写有效端口;关闭时 0 表示不使用。' }),
field('http.trustedProxyCidrs', '可信代理网段', 'list', { placeholder: '127.0.0.1/32', hint: '仅填写实际代理的 CIDR 网段,不要无条件信任所有来源。' })
]),
group('security', '全局安全选项', '只调整功能开关与站点信息,密码算法和加密密钥由系统维护。', [
field('security.externalEmailEnabled', '启用外部邮箱功能', 'switch'),
field('security.webAuthnEnabled', '启用通行密钥', 'switch'),
field('security.webAuthnRpId', '通行密钥站点域名', 'text', { show: config => config.security?.webAuthnEnabled, placeholder: 'mail.example.com' }),
field('security.webAuthnRpDisplayName', '通行密钥显示名称', 'text', { show: config => config.security?.webAuthnEnabled, placeholder: 'Smail' }),
field('security.webAuthnRpOrigins', '通行密钥允许的来源', 'list', { show: config => config.security?.webAuthnEnabled, placeholder: 'https://mail.example.com' })
])
]
const smtpGroups = [
group('relay', 'SMTP 中继连接', '用于发送验证及通知邮件。未使用中继时可保留空值。', [
field('relay.relayHost', 'SMTP 服务器', 'text', { placeholder: 'smtp.example.com' }),
port('relay.relayPort', 'SMTP 端口', { hint: '常见端口:STARTTLS 使用 587,隐式 TLS 使用 465。' }),
field('relay.security', '连接加密', 'select', { options: options([['', '使用默认方式'], ['starttls', 'STARTTLS'], ['implicit_tls', '隐式 TLS'], ['tls', 'TLS(兼容模式)'], ['plain', '不加密(仅限本机)']]) }),
field('relay.username', '认证用户名', 'text', { placeholder: '需要认证时填写' }),
field('relay.from', '发件邮箱', 'text', { placeholder: 'noreply@example.com' }),
field('relay.fromDomain', '发件域名', 'text', { placeholder: 'example.com' })
]),
group('verification', '验证邮件策略', '启用前请确认服务器、端口、发件邮箱和认证信息完整。', [
field('relay.verificationEnabled', '启用验证邮件发送', 'switch'),
field('relay.loginPolicy', '中继登录验证策略', 'select', { options: loginOptions })
])
]
const emailGroups = [
group('ports', '邮件服务端口', '各服务端口不能重复,最终可用性由后端统一校验。', [
port('policy.smtpPort', 'SMTP 收信端口', { min: 1, required: true }),
port('policy.imapPort', 'IMAP 端口', { min: 1, required: true }),
port('policy.pop3Port', 'POP3 端口', { min: 1, required: true })
]),
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 失败时过滤']]) }),
field('policy.loginVerificationPolicy', '账号登录验证策略', 'select', { options: loginOptions }),
field('policy.externalRecoveryEnabled', '允许外部邮箱恢复账号', 'switch')
])
]
const sslGroups = [
group('certificate', '证书与存储', '这里只填写已导入受管存储的相对引用,不会上传文件或绕过证书校验。', [
field('tls.mode', '证书来源', 'select', { options: options([['managed_default', '系统管理证书'], ['managed_upload', '已导入的自定义证书'], ['default', '默认目录(兼容模式)'], ['custom', '自定义目录(兼容模式)']]) }),
field('tls.defaultDirectory', '默认证书目录', 'text', { required: true, placeholder: 'config/ssl' }),
field('tls.customDirectory', '自定义证书目录', 'text', { required: true, show: uploadedCertificate, placeholder: 'config/ssl/example.com' }),
field('tls.certificateRef', '证书文件引用', 'text', { placeholder: 'config/ssl/example.com/fullchain.pem', hint: '必须位于当前证书目录内,与私钥成对填写。' }),
field('tls.privateKeyRef', '私钥文件引用', 'text', { placeholder: 'config/ssl/example.com/privkey.pem', hint: '填写文件引用,不是私钥内容。' })
])
]
const pluginDefinitions = {
spam_block: { label: '垃圾邮件拦截', fields: [
field('url', '检测服务地址', 'text', { placeholder: 'https://filter.example.com' }),
field('timeout', '请求超时(毫秒)', 'number', { min: 0, max: 2147483647, integer: true, step: 100, hint: '1000 毫秒 = 1 秒;0 使用插件默认值 3000 毫秒。' }),
field('threshold', '拦截阈值(百分比)', 'number', { min: 0, max: Number.MAX_SAFE_INTEGER, step: 0.1, hint: '例如 20 表示 20%;0 使用插件默认值 20%。' })
] },
telegram_push: { label: 'Telegram 推送', fields: [
field('proxy', '代理地址', 'text', { hint: '可选,不使用代理时留空。' }),
field('timeout', '请求超时(秒)', 'number', { min: 0, max: 120, integer: true, hint: '0 使用插件默认值,最大 120 秒。' }),
field('webDomain', '网页域名', 'text'),
field('debug', '启用调试日志', 'switch'),
field('httpsEnabled', '链接使用 HTTPS', 'switch')
] },
wechat_push: { label: '微信推送', fields: [
field('appId', '应用编号(App ID)'),
field('templateId', '消息模板编号'),
field('userId', '接收用户编号'),
field('webDomain', '网页域名')
] }
}
export function getFieldGroups(section, config) {
if (section !== 'plugins') {
return ({ base: baseGroups, smtp: smtpGroups, email: emailGroups, ssl: sslGroups }[section] || [])
.map(item => ({ ...item, fields: item.fields.filter(input => !input.show || input.show(config)) }))
}
return Object.entries(config.plugins || {}).map(([id, plugin]) => {
const definition = pluginDefinitions[id]
const prefix = ['plugins', id]
const input = (name, label, type, extra = {}) => ({ path: [...prefix, name], label, type, ...extra })
const settingsEditable = plugin.settings == null || (typeof plugin.settings === 'object' && !Array.isArray(plugin.settings))
return {
id,
title: definition?.label || `插件:${id}`,
description: `插件标识:${id}。程序与权限由安装目录维护;凭据请在专用插件设置中管理。`,
fields: [
input('enabled', '启用插件', 'switch', { locked: !definition }),
input('executable', '运行程序', 'text', { locked: true }),
{ path: [...prefix, 'permissions', 'network'], label: '允许访问的网络目标', type: 'network', hint: '仅允许 HTTPS 或 WSS;填写域名和端口,不接受 IP 地址。', locked: !definition },
...(definition && settingsEditable ? definition.fields.map(item => ({ ...item, path: [...prefix, 'settings', ...item.path] })) : [])
],
note: !settingsEditable ? '当前插件使用非标准设置格式,已保留原值;请在专用插件页面调整。' : '',
secretConfigured: !!plugin.secretStatus?.configured
}
})
}
// 只更新明确可编辑的字段,完整保留代次、内部引用以及未来版本的未展示字段。
export function updateFormConfig(text, section, path, value) {
const config = readFormConfig(text)
if (!Array.isArray(path) || path.some(key => ['__proto__', 'prototype', 'constructor'].includes(key))) return text
const key = path.join('.')
const input = getFieldGroups(section, config).flatMap(item => item.fields).find(item => fieldKey(item) === key)
if (!input || input.locked) return text
let parent = config
for (const part of path.slice(0, -1)) {
if (!parent[part] || typeof parent[part] !== 'object') parent[part] = {}
parent = parent[part]
}
parent[path.at(-1)] = value ?? (input.type === 'number' ? null : '')
if (key === 'http.tlsMode' && value === 'proxy') {
config.http.httpsEnabled = false
config.http.httpsPort = 0
}
if (key === 'tls.mode' && ['managed_default', 'default'].includes(value)) config.tls.customDirectory = ''
return JSON.stringify(config, null, 2)
}
export function validateFormConfig(section, config) {
const errors = {}
for (const item of getFieldGroups(section, config).flatMap(item => item.fields)) {
if (item.locked) continue
const value = getValue(config, item.path)
const key = fieldKey(item)
if (item.required && (value == null || (typeof value === 'string' && !value.trim()) || (Array.isArray(value) && !value.length))) errors[key] = `请填写${item.label}`
if (item.type === 'number' && value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || (item.integer && !Number.isSafeInteger(value)) || value < item.min || value > item.max)) {
errors[key] = `请填写 ${item.min}~${item.max} 范围内的${item.integer ? '整数' : '数值'}`
}
if (item.type === 'list' && value != null && (!Array.isArray(value) || value.some(entry => typeof entry !== 'string' || !entry.trim()) || new Set(value).size !== value.length)) {
errors[key] = '每项都需填写,且不能重复;不需要的项目请移除'
}
if (item.type === 'network' && value != null) {
const valid = Array.isArray(value) && value.every(target => target && ['https', 'wss'].includes(target.scheme) && validDomain(target.host) && Number.isInteger(target.port) && target.port > 0 && target.port <= 65535)
if (!valid || new Set(value.map(target => `${target.scheme}://${normalizedDomain(target.host)}:${target.port}`)).size !== value.length) errors[key] = '请填写不重复的有效协议、域名和端口'
}
}
if (section === 'base') {
const domains = config.domains || {}
for (const name of ['primary', 'web']) {
if (!validDomain(domains[name])) errors[`domains.${name}`] = '请填写有效域名,不包含协议、路径或空格'
}
if (Array.isArray(domains.accepted) && domains.accepted.every(validDomain)) {
const accepted = domains.accepted.map(normalizedDomain)
if (new Set(accepted).size !== accepted.length) errors['domains.accepted'] = '接收域名不能重复,大小写和末尾点视为同一域名'
if (validDomain(domains.primary) && !accepted.includes(normalizedDomain(domains.primary))) errors['domains.accepted'] = '接收域名列表必须包含主邮件域名'
} else {
errors['domains.accepted'] = '请逐项填写有效的接收邮件域名'
}
if (config.http?.httpsEnabled && !config.http.httpsPort) errors['http.httpsPort'] = '启用内置 HTTPS 时请填写 1~65535 的端口'
}
if (section === 'email') {
const ports = ['smtpPort', 'imapPort', 'pop3Port']
for (const name of ports) {
const value = config.policy?.[name]
if (value > 0 && ports.some(other => other !== name && config.policy?.[other] === value)) errors[`policy.${name}`] = '邮件服务端口不能重复'
}
}
if (section === 'ssl') {
const tls = config.tls || {}
const directory = uploadedCertificate(config) ? tls.customDirectory : tls.defaultDirectory
const controlled = value => typeof value === 'string' && !!value && !/^(\/|[a-z]:)/i.test(value) && !/[\\:]/.test(value) && value.split('/').every(part => part && part !== '.' && part !== '..')
for (const name of ['defaultDirectory', ...(uploadedCertificate(config) ? ['customDirectory'] : [])]) {
if (!controlled(tls[name])) errors[`tls.${name}`] = '请填写受管存储中的相对目录,不允许绝对路径或上级目录'
}
for (const name of ['certificateRef', 'privateKeyRef']) {
if (tls[name] && (!controlled(tls[name]) || !tls[name].startsWith(`${directory}/`))) errors[`tls.${name}`] = '文件引用必须位于当前证书目录内'
}
if (!!tls.certificateRef !== !!tls.privateKeyRef) errors['tls.privateKeyRef'] = '证书与私钥引用必须成对填写'
}
return errors
}

View File

@ -0,0 +1,41 @@
<template>
<div class="config-form" :data-form-section="section">
<p v-if="!groups.length" class="form-empty">当前没有可配置的插件。安装插件后可在这里管理启用状态与运行设置。</p>
<section v-for="group in groups" :key="group.id" class="form-group" :aria-labelledby="`form-group-${group.id}`">
<div class="form-group-heading">
<h5 :id="`form-group-${group.id}`">{{ group.title }}</h5>
<span v-if="section === 'plugins'" class="credential-status">{{ group.secretConfigured ? '凭据已配置' : '未配置凭据' }}</span>
</div>
<p class="form-group-description">{{ group.description }}</p>
<p v-if="group.note" class="form-group-description">{{ group.note }}</p>
<div class="form-grid">
<ConfigField
v-for="field in group.fields"
:key="fieldKey(field)"
:field="field"
:value="getValue(config, field.path)"
:error="errors[fieldKey(field)]"
:disabled="disabled"
@change="(path, value) => emit('change', path, value)"
/>
</div>
</section>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { fieldKey, getFieldGroups, getValue } from './ConfigForm.js'
import ConfigField from '../ConfigField/ConfigField.vue'
const props = defineProps({
section: { type: String, required: true },
config: { type: Object, required: true },
errors: { type: Object, default: () => ({}) },
disabled: Boolean
})
const emit = defineEmits(['change'])
const groups = computed(() => getFieldGroups(props.section, props.config))
</script>
<style scoped src="./ConfigForm.css"></style>

View File

@ -168,53 +168,6 @@
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: 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;
@ -553,7 +506,6 @@
}
}
@media (prefers-reduced-motion: reduce) {
.editor-shell,
.maintenance-summary,
.disclosure-arrow {
transition: none;

View File

@ -2,6 +2,7 @@ import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { http } from '@/utils/axios'
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
import { readFormConfig, updateFormConfig, validateFormConfig } from './ConfigForm/ConfigForm.js'
const sections = [
{ value: 'base', label: '基础配置', description: '管理站点与数据库连接,敏感凭据单独填写。' },
@ -51,11 +52,14 @@ export function usePage(pageKey = 'base') {
let sectionRequestId = 0
let disposed = false
const editorText = ref('')
const formConfig = computed(() => readFormConfig(editorText.value))
const formErrors = ref({})
const loadedText = ref('')
const sectionError = ref('')
const secretValue = ref('')
const confirmed = ref(false)
const savedGeneration = ref(0)
let savedInput = null
const validationResult = ref(null)
const backups = ref([])
const rollbackGeneration = ref(0)
@ -79,7 +83,7 @@ export function usePage(pageKey = 'base') {
const stateLabel = computed(() => ({ new: '未初始化', bootstrap: '初始化中', ready: '就绪', degraded: '降级' }[status.setupState] || status.setupState || '未知'))
const stateTagType = computed(() => ({ ready: 'success', degraded: 'warning', bootstrap: 'info', new: 'danger' }[status.setupState] || 'info'))
const secretField = computed(() => {
if (selectedSection.value === 'base') {
if (selectedSection.value === 'base' && ['mysql', 'postgresql'].includes(formConfig.value.database?.type)) {
return { label: '数据库密码', description: '只在本次请求内传输,保存后仅保留密文状态。留空表示保持原值。', placeholder: '输入新数据库密码(可选)' }
}
if (selectedSection.value === 'smtp') {
@ -87,7 +91,28 @@ export function usePage(pageKey = 'base') {
}
return null
})
const sensitiveHint = computed(() => secretField.value ? '密码字段已脱敏,修改请使用下方输入框' : '当前分区不包含可编辑密文')
const sensitiveHint = computed(() => secretField.value ? '密码不会回填,修改请使用下方独立输入框,留空保持原值' : '系统内部字段自动保留,无需手工填写')
const invalidateCandidate = () => {
savedGeneration.value = 0
savedInput = null
validationResult.value = null
confirmed.value = false
}
const canEdit = () => !disposed && canWrite.value && !loading.value && !working.value && !sectionLoading.value && !sectionError.value
const updateField = (path, value) => {
if (!canEdit()) return
const updated = updateFormConfig(editorText.value, selectedSection.value, path, value)
if (updated === editorText.value) return
editorText.value = updated
if (Object.keys(formErrors.value).length) formErrors.value = validateFormConfig(selectedSection.value, formConfig.value)
invalidateCandidate()
}
const updateSecret = value => {
if (!canEdit() || !secretField.value || secretValue.value === value) return
secretValue.value = value
invalidateCandidate()
}
const notifyResponseError = (response, fallback) => {
if (!response || response.errorNo !== 0) {
@ -130,12 +155,14 @@ export function usePage(pageKey = 'base') {
const section = selectedSection.value
sectionLoading.value = true
sectionError.value = ''
formErrors.value = {}
editorText.value = ''
loadedText.value = ''
secretValue.value = ''
confirmed.value = false
validationResult.value = null
savedGeneration.value = 0
savedInput = null
try {
const response = await http.get(`/api/v1/config/${section}`)
if (requestId !== sectionRequestId) return
@ -317,7 +344,7 @@ export function usePage(pageKey = 'base') {
try {
const payload = JSON.parse(editorText.value)
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('配置必须是 JSON 对象')
throw new Error('配置内容不可用,请刷新页面后重试')
}
if (selectedSection.value === 'base') {
delete payload.databasePasswordStatus
@ -329,19 +356,20 @@ export function usePage(pageKey = 'base') {
Object.values(payload.plugins).forEach(plugin => {
if (plugin && typeof plugin === 'object') {
delete plugin.settingsConfigured
delete plugin.secretStatus
}
})
}
return payload
} catch (error) {
throw new Error(error.message || 'JSON 格式错误')
throw new Error('配置内容不可用,请刷新页面后重试')
}
}
const buildRequest = () => {
const secretChanges = {}
const value = secretValue.value.trim()
if (value && selectedSection.value === 'base') {
const value = secretValue.value
if (value && selectedSection.value === 'base' && secretField.value) {
secretChanges['database.password'] = { operation: 'replace', value: { plaintext: value } }
}
if (value && selectedSection.value === 'smtp') {
@ -355,8 +383,17 @@ export function usePage(pageKey = 'base') {
}
}
const checkForm = () => {
formErrors.value = validateFormConfig(selectedSection.value, formConfig.value)
const issues = Object.entries(formErrors.value).map(([code, message]) => ({ code, message }))
if (!issues.length) return true
validationResult.value = { valid: false, issues }
ElMessage.warning('请先检查表单中标红的字段')
return false
}
const validateConfig = async () => {
if (!canWrite.value || working.value || sectionLoading.value || sectionError.value) return
if (!canEdit() || !checkForm()) return
working.value = 'validate'
try {
const request = buildRequest()
@ -372,13 +409,14 @@ export function usePage(pageKey = 'base') {
}
const saveConfig = async () => {
if (!canWrite.value || working.value || sectionLoading.value || sectionError.value) return
if (!canEdit() || !checkForm()) return
working.value = 'save'
try {
const request = buildRequest()
const response = await http.put(`/api/v1/config/${selectedSection.value}`, request)
notifyResponseError(response, '配置保存失败')
savedGeneration.value = response.data?.generation || 0
savedInput = { text: editorText.value, secret: secretValue.value }
validationResult.value = { valid: true, generation: savedGeneration.value, issues: [] }
await loadStatus()
ElMessage.success(`候选配置已保存,代次 ${savedGeneration.value} 等待应用`)
@ -390,7 +428,12 @@ export function usePage(pageKey = 'base') {
}
const applyConfig = async () => {
if (!canWrite.value || working.value || !savedGeneration.value) return
if (!canEdit() || !savedGeneration.value) return
if (!savedInput || savedInput.text !== editorText.value || savedInput.secret !== secretValue.value) {
invalidateCandidate()
ElMessage.warning('填写内容已变更,请重新保存候选配置后再应用')
return
}
working.value = 'apply'
try {
const response = await http.post(`/api/v1/config/${selectedSection.value}/apply`, { generation: savedGeneration.value })
@ -493,13 +536,15 @@ export function usePage(pageKey = 'base') {
disposed = true
sectionRequestId++
secretValue.value = ''
savedInput = null
confirmed.value = false
migrationToken.value = ''
})
return {
loading, availabilityError, working, status, stateLabel, stateTagType, sectionOptions: sections,
selectedSection, currentSection, sectionLoading, editorText, sectionError, sensitiveHint, secretField, secretValue,
selectedSection, currentSection, sectionLoading, editorText, formConfig, formErrors, updateField, updateSecret,
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,

View File

@ -0,0 +1,115 @@
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'
const root = new URL('../../frontend/src/views/components/ConfigSettings/', import.meta.url)
const source = readFileSync(new URL('ConfigForm/ConfigForm.js', root), 'utf8')
const { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig } = compileFunction(
source.replaceAll('export function ', 'function ') + '\nreturn { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig }'
)()
test('五个分区使用分组表单,控件和样式能编译且不再提供 JSON 编辑器', () => {
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc')
for (const name of ['ConfigForm', 'ConfigField', 'ConfigEditor']) {
const template = readFileSync(new URL(`${name}/${name}.vue`, root), 'utf8')
const css = readFileSync(new URL(`${name}/${name}.css`, root), 'utf8')
const { descriptor, errors } = parse(template)
assert.deepEqual(errors, [])
assert.doesNotThrow(() => compileScript(descriptor, { id: name, inlineTemplate: true }))
assert.deepEqual(compileStyle({ source: css, id: `data-v-${name}`, scoped: true }).errors, [])
assert.doesNotMatch(template, /&#x[0-9a-f]+;|type="textarea"|配置 JSON 编辑器/i)
}
for (const section of ['base', 'smtp', 'email', 'ssl', 'plugins']) {
const groups = getFieldGroups(section, { plugins: { spam_block: { settings: null } } })
assert.ok(groups.length)
const keys = groups.flatMap(group => group.fields.map(field => field.path.join('.')))
assert.equal(keys.length, new Set(keys).size)
assert.ok(groups.every(group => /[\u4e00-\u9fff]/.test(group.title)))
assert.ok(!keys.some(key => /generation|password|encryptionKey|capabilities/.test(key)))
}
})
test('单字段更新完整保留内部数据,锁定字段和污染路径无法写入', () => {
const config = {
generation: 8, schemaVersion: 2, database: { type: 'sqlite', localRef: 'test.db', password: null },
domains: { primary: 'example.com', accepted: ['example.com'] },
security: { passwordVersion: 'argon2id', encryptionKeyRef: 'system-managed' },
future: { nested: [true, 1, null] }
}
const text = JSON.stringify(config, null, 2)
const updated = readFormConfig(updateFormConfig(text, 'base', ['domains', 'primary'], 'mail.example.com'))
assert.deepEqual(updated, { ...config, domains: { ...config.domains, primary: 'mail.example.com' } })
for (const path of [['database', 'type'], ['database', 'localRef'], ['generation'], ['security', 'encryptionKeyRef'], ['__proto__', 'polluted'], ['constructor', 'prototype', 'polluted'], ['relay', 'relayHost']]) {
assert.equal(updateFormConfig(text, 'base', path, 'changed'), text)
}
assert.equal({}.polluted, undefined)
})
test('开关、数字、空数字和列表保持真实类型,代理 HTTPS 正确联动', () => {
let text = JSON.stringify({ http: { tlsMode: 'direct', httpsEnabled: true, httpsPort: 443 } })
text = updateFormConfig(text, 'base', ['http', 'tlsMode'], 'proxy')
assert.deepEqual(readFormConfig(text).http, { tlsMode: 'proxy', httpsEnabled: false, httpsPort: 0 })
assert.equal(updateFormConfig(text, 'base', ['http', 'httpsEnabled'], true), text)
text = updateFormConfig(text, 'base', ['security', 'externalEmailEnabled'], true)
assert.equal(readFormConfig(text).security.externalEmailEnabled, true)
text = updateFormConfig(text, 'base', ['http', 'port'], 8080)
assert.equal(readFormConfig(text).http.port, 8080)
text = updateFormConfig(text, 'base', ['http', 'port'], undefined)
assert.equal(readFormConfig(text).http.port, null)
assert.ok(validateFormConfig('base', readFormConfig(text))['http.port'])
for (const value of ['25', 0, -1, 65536, 1.5, null]) {
assert.ok(validateFormConfig('email', { policy: { smtpPort: value, imapPort: 143, pop3Port: 110 } })['policy.smtpPort'])
}
assert.ok(validateFormConfig('email', { policy: { smtpPort: 143, imapPort: 143, pop3Port: 110 } })['policy.imapPort'])
assert.deepEqual(validateFormConfig('email', { policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 } }), {})
})
test('域名校验兼容大小写和末尾点,拒绝重复项、空白和路径', () => {
const valid = { database: { type: 'sqlite' }, domains: { primary: 'Example.com.', web: 'mail.example.com', accepted: ['example.com'] } }
assert.deepEqual(validateFormConfig('base', valid), {})
for (const accepted of [[], ['example.com', 'Example.com.'], [''], [' https://example.com'], ['other.example.com'], 'example.com', [null]]) {
assert.ok(validateFormConfig('base', { ...valid, domains: { ...valid.domains, accepted } })['domains.accepted'])
}
for (const primary of ['https://example.com', 'example.com/path', 'bad..com', '-bad.com', '127.0.0.1', '例子.com']) {
assert.ok(validateFormConfig('base', { ...valid, domains: { ...valid.domains, primary } })['domains.primary'])
}
})
test('插件网络列表拒绝无效和重复目标,空设置可编辑且非标准设置不覆盖', () => {
const make = network => ({ plugins: { telegram_push: { permissions: { network }, settings: null } } })
const valid = { scheme: 'https', host: 'api.example.com', port: 443 }
assert.deepEqual(validateFormConfig('plugins', make([valid])), {})
for (const network of [{}, [null], [{ ...valid, host: '127.0.0.1' }], [{ ...valid, host: 'bad..com' }], [{ ...valid, scheme: 'http' }], [{ ...valid, port: null }], [valid, { ...valid, host: 'API.EXAMPLE.COM.' }]]) {
assert.ok(validateFormConfig('plugins', make(network))['plugins.telegram_push.permissions.network'])
}
const text = JSON.stringify(make([]))
const updated = readFormConfig(updateFormConfig(text, 'plugins', ['plugins', 'telegram_push', 'settings', 'timeout'], 30))
assert.equal(updated.plugins.telegram_push.settings.timeout, 30)
const legacy = JSON.stringify({ plugins: { telegram_push: { settings: ['preserve'] } } })
assert.equal(updateFormConfig(legacy, 'plugins', ['plugins', 'telegram_push', 'settings', 'timeout'], 30), legacy)
})
test('不同插件超时字段使用各自实际单位,不把毫秒当作秒', () => {
const groups = getFieldGroups('plugins', { plugins: { spam_block: { settings: { timeout: 10000 } }, telegram_push: { settings: { timeout: 30 } } } })
const spamTimeout = groups.find(group => group.id === 'spam_block').fields.find(field => field.path.at(-1) === 'timeout')
const telegramTimeout = groups.find(group => group.id === 'telegram_push').fields.find(field => field.path.at(-1) === 'timeout')
assert.equal(spamTimeout.label, '请求超时(毫秒)')
assert.equal(telegramTimeout.label, '请求超时(秒)')
assert.deepEqual(validateFormConfig('plugins', { plugins: { spam_block: { settings: { timeout: 10000 } } } }), {})
})
test('证书路径始终受管且引用成对,切回默认来源清除自定义目录', () => {
const tls = { mode: 'managed_upload', defaultDirectory: 'config/ssl', customDirectory: 'config/ssl/imported', certificateRef: 'config/ssl/imported/cert.pem', privateKeyRef: 'config/ssl/imported/key.pem' }
assert.deepEqual(validateFormConfig('ssl', { tls }), {})
for (const customDirectory of ['C:/certs', '/etc/ssl', '../certs', 'config/../certs', 'config\\ssl', 'config//ssl']) {
assert.ok(validateFormConfig('ssl', { tls: { ...tls, customDirectory } })['tls.customDirectory'])
}
assert.ok(validateFormConfig('ssl', { tls: { ...tls, privateKeyRef: '' } })['tls.privateKeyRef'])
assert.ok(validateFormConfig('ssl', { tls: { ...tls, certificateRef: 'config/ssl/outside.pem' } })['tls.certificateRef'])
const updated = readFormConfig(updateFormConfig(JSON.stringify({ tls }), 'ssl', ['tls', 'mode'], 'managed_default'))
assert.equal(updated.tls.customDirectory, '')
assert.equal(updated.tls.privateKeyRef, tls.privateKeyRef)
})

View File

@ -5,11 +5,14 @@ import { compileFunction } from 'node:vm'
import { createRequire } from 'node:module'
import { computed, reactive, ref } from './vue.mjs'
const formSource = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js', import.meta.url), 'utf8')
const formHelpers = compileFunction(formSource.replaceAll('export function ', 'function ') + '\nreturn { readFormConfig, updateFormConfig, validateFormConfig }')()
const source = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigSettings.js', import.meta.url), 'utf8')
const createPageWithKey = compileFunction(
const compilePage = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage(pageKey)',
['computed', 'onMounted', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore', 'pageKey', 'onBeforeUnmount']
['computed', 'onMounted', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore', 'pageKey', 'onBeforeUnmount', 'readFormConfig', 'updateFormConfig', 'validateFormConfig']
)
const createPageWithKey = (...args) => compilePage(...args, formHelpers.readFormConfig, formHelpers.updateFormConfig, formHelpers.validateFormConfig)
const createPage = (...args) => createPageWithKey(...args, 'base', () => {})
function fixture(get, pageKey = 'base', options = {}) {
@ -323,3 +326,114 @@ test('独立页保持验证、保存、应用端点和代次协议,敏感值
assert.equal(page.confirmed.value, false)
assert.equal(page.savedGeneration.value, 0)
})
test('可视化编辑按权限和工作状态保护,编辑会清除过期确认及候选代次', async () => {
const { page, dispose } = fixture(async () => ({ errorNo: 0, data: { relay: { relayHost: 'smtp.example.com' } } }), 'smtp')
await page.loadAll()
const original = page.editorText.value
for (const state of ['loading', 'sectionLoading', 'working', 'sectionError']) {
page[state].value = true
page.updateField(['relay', 'relayHost'], 'blocked.example.com')
page.updateSecret('blocked-value')
assert.equal(page.editorText.value, original)
assert.equal(page.secretValue.value, '')
page[state].value = false
}
const readonly = fixture(async () => ({ errorNo: 0, data: {} }), 'smtp', { hasCapability: () => false }).page
readonly.updateField(['relay', 'relayHost'], 'blocked.example.com')
assert.equal(readonly.editorText.value, '')
page.savedGeneration.value = 8
page.confirmed.value = true
page.validationResult.value = { valid: true }
page.updateField(['relay', 'relayHost'], 'new.example.com')
assert.equal(page.formConfig.value.relay.relayHost, 'new.example.com')
assert.equal(page.savedGeneration.value, 0)
assert.equal(page.confirmed.value, false)
assert.equal(page.validationResult.value, null)
dispose()
page.updateField(['relay', 'relayHost'], 'blocked.example.com')
assert.equal(page.formConfig.value.relay.relayHost, 'new.example.com')
})
test('前端无效字段阻止网络请求,修正字段后同步清除错误', async () => {
const requests = []
const { page } = fixture(async () => ({ errorNo: 0, data: { policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 } } }), 'email', {
http: { post: async path => { requests.push(path); return { errorNo: 0, data: { valid: true } } } }
})
await page.loadAll()
page.updateField(['policy', 'smtpPort'], null)
await page.validateConfig()
await page.saveConfig()
assert.deepEqual(requests, [])
assert.ok(page.formErrors.value['policy.smtpPort'])
page.updateField(['policy', 'smtpPort'], 2525)
assert.deepEqual(page.formErrors.value, {})
await page.validateConfig()
assert.deepEqual(requests, ['/api/v1/config/email/validate'])
})
test('插件读取状态字段不传回严格解析接口,未展示字段和权限仍保留', async () => {
let request
const plugin = { enabled: true, executable: 'pmail_telegram_push.exe', permissions: { capabilities: ['network', 'userSettings', 'settingsHtml'], network: [] }, settings: { timeout: 30, future: 'preserved' }, secretStatus: { configured: true }, settingsConfigured: true }
const { page } = fixture(async () => ({ errorNo: 0, data: { meta: { generation: 7 }, plugins: { telegram_push: plugin } } }), 'plugins', {
http: { put: async (path, body) => { request = body; return { errorNo: 0, data: { generation: 8 } } } }
})
await page.loadSection()
page.updateField(['plugins', 'telegram_push', 'settings', 'timeout'], 40)
await page.saveConfig()
const actual = request.payload.plugins.telegram_push
assert.equal(actual.secretStatus, undefined)
assert.equal(actual.settingsConfigured, undefined)
assert.deepEqual(actual.permissions, plugin.permissions)
assert.deepEqual(actual.settings, { timeout: 40, future: 'preserved' })
assert.equal(request.payload.meta.generation, 7)
assert.deepEqual(request.secretChanges, {})
assert.equal(page.formConfig.value.plugins.telegram_push.secretStatus.configured, true)
})
test('SQLite 不显示或发送密码,网络数据库和中继密码留空保留且不裁剪空格', async () => {
for (const type of ['sqlite', 'mysql', 'postgresql']) {
let request
const { page } = fixture(async () => ({ errorNo: 0, data: {
database: { type, host: 'localhost', port: 3306, username: 'test' },
domains: { primary: 'example.com', web: 'mail.example.com', accepted: ['example.com'] }
} }), 'base', {
http: { post: async (path, body) => { request = body; return { errorNo: 0, data: { valid: true } } } }
})
await page.loadSection()
assert.equal(!!page.secretField.value, type !== 'sqlite')
await page.validateConfig()
assert.deepEqual(request.secretChanges, {})
page.secretValue.value = ' test-only-value '
await page.validateConfig()
assert.deepEqual(request.secretChanges, type === 'sqlite' ? {} : { 'database.password': { operation: 'replace', value: { plaintext: ' test-only-value ' } } })
}
let request
const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'smtp', {
http: { post: async (path, body) => { request = body; return { errorNo: 0, data: { valid: true } } } }
})
await page.loadSection()
page.updateSecret(' smtp-test-only ')
await page.validateConfig()
assert.equal(request.secretChanges['relay.password'].value.plaintext, ' smtp-test-only ')
})
test('保存后更改密码或填写内容不能应用旧候选', async () => {
const requests = []
const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'smtp', {
http: {
put: async () => ({ errorNo: 0, data: { generation: 8 } }),
post: async path => { requests.push(path); return { errorNo: 0 } }
}
})
await page.loadSection()
await page.saveConfig()
page.updateSecret('test-only-value')
assert.equal(page.savedGeneration.value, 0)
await page.applyConfig()
await page.saveConfig()
page.editorText.value = '{"relay":{"relayHost":"new.example.com"}}'
await page.applyConfig()
assert.equal(page.savedGeneration.value, 0)
assert.deepEqual(requests, [])
})