将配置编辑改为可视化表单并建立三种数据库迁移改造检查点
Some checks are pending
Security Checks / security (push) Waiting to run
Some checks are pending
Security Checks / security (push) Waiting to run
This commit is contained in:
parent
fd67cca2d4
commit
d92dfdacff
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
115
test/frontend/config-form.test.mjs
Normal file
115
test/frontend/config-form.test.mjs
Normal 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)
|
||||
})
|
||||
@ -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, [])
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user