拆分配置独立页面并在标题状态合并前建立检查点
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-05 22:31:55 +08:00
parent 7f7b1291ce
commit be51853815
16 changed files with 853 additions and 478 deletions

View File

@ -0,0 +1,13 @@
<template>
<ConfigEditor />
</template>
<script setup>
import { provide } from 'vue'
import { usePage } from '../ConfigSettings.js'
import ConfigEditor from '../ConfigEditor/ConfigEditor.vue'
const page = usePage('base')
provide('smail-config-page', page)
defineExpose({ beforeLeave: page.beforeLeave })
</script>

View File

@ -0,0 +1,196 @@
<template>
<div class="config-page" :data-config-page="selectedSection" v-loading="loading">
<div class="settings-header">
<div>
<h3>{{ currentSection.label }}</h3>
<p class="settings-desc">{{ currentSection.description }}</p>
</div>
<el-tag class="access-tag" :type="canWrite ? 'info' : 'warning'" effect="plain">
{{ canWrite ? '敏感凭据已脱敏' : '只读模式' }}
</el-tag>
</div>
<el-alert
v-if="!canWrite"
title="当前账号仅能查看配置,保存、应用和回滚操作已隐藏。"
type="info"
:closable="false"
show-icon
class="config-alert"
/>
<div v-if="availabilityError" class="config-availability" role="status">
<el-alert :title="availabilityError" type="warning" :closable="false" show-icon />
<el-button :loading="loading" @click="refreshPage">重新检查</el-button>
</div>
<div v-else-if="!loading" class="config-page-body">
<section class="status-panel">
<div class="status-heading">
<div>
<h4>配置快照</h4>
<p class="heading-note">当前运行状态与生效版本</p>
</div>
<el-button size="small" plain :loading="loading" :disabled="!!working || sectionLoading" @click="refreshPage">
刷新状态
</el-button>
</div>
<div class="status-grid">
<div class="status-item">
<span class="status-label">运行状态</span>
<el-tag :type="stateTagType" effect="light">{{ stateLabel }}</el-tag>
</div>
<div class="status-item">
<span class="status-label">当前生效代次</span>
<strong class="generation-value">{{ status.activeGeneration || '-' }}</strong>
</div>
<div class="status-item">
<span class="status-label">最近校验时间</span>
<strong>{{ formatTime(status.lastValidAt) }}</strong>
</div>
<div class="status-item" :class="{ 'status-warning': status.pendingRestart?.length }">
<span class="status-label">待重启服务</span>
<div v-if="status.pendingRestart?.length" class="tag-list">
<el-tag v-for="service in status.pendingRestart" :key="service" type="warning" effect="plain">
{{ serviceLabel(service) }}
</el-tag>
</div>
<strong v-else>无需重启</strong>
</div>
</div>
<el-alert
v-for="issue in status.degradedReasons || []"
:key="`${issue.code}-${issue.message}`"
:title="`${issue.code}:${issue.message}`"
type="warning"
:closable="false"
show-icon
class="degraded-alert"
/>
</section>
<section class="editor-panel" :aria-busy="sectionLoading">
<div class="panel-heading">
<div>
<h4>{{ canWrite ? '编辑配置' : '查看配置' }}</h4>
<p class="heading-note">{{ canWrite ? '仅处理当前页面的配置,保存后再应用生效。' : '当前页面的脱敏配置内容。' }}</p>
</div>
<span class="selected-section-label">{{ currentSection.label }}</span>
</div>
<ol v-if="canWrite" class="change-workflow" aria-label="配置变更流程">
<li><span class="step-number">1</span><span>验证配置</span></li>
<li><span class="step-number">2</span><span>保存候选</span></li>
<li><span class="step-number">3</span><span>应用生效</span></li>
</ol>
<el-alert
v-if="sectionError"
:title="sectionError"
type="error"
:closable="false"
show-icon
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>
<p id="config-sensitive-hint" class="editor-hint">{{ sensitiveHint }}。</p>
<div v-if="secretField" class="secret-editor">
<div>
<label for="config-secret-input">{{ secretField.label }}</label>
<p>{{ secretField.description }}</p>
</div>
<el-input
id="config-secret-input"
v-model="secretValue"
type="password"
show-password
clearable
:disabled="!canWrite || sectionLoading || !!working || !!sectionError"
:placeholder="secretField.placeholder"
autocomplete="new-password"
/>
</div>
<div v-if="canWrite" class="editor-footer">
<el-checkbox v-model="confirmed" class="confirmation-check" :disabled="sectionLoading || !!working || !!sectionError">
我已确认这是受控变更,并允许使用最近认证完成高风险配置操作
</el-checkbox>
<div class="action-row">
<el-button :loading="working === 'validate'" :disabled="sectionLoading || !!working || !!sectionError" @click="validateConfig">
只验证
</el-button>
<el-button type="primary" :loading="working === 'save'" :disabled="sectionLoading || !!working || !!sectionError" @click="saveConfig">
保存候选配置
</el-button>
<el-button type="success" plain :loading="working === 'apply'" :disabled="sectionLoading || !!working || !savedGeneration" @click="applyConfig">
{{ savedGeneration ? `应用代次 ${savedGeneration}` : '应用配置' }}
</el-button>
</div>
<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>
<span v-if="validationResult.generation">候选代次:{{ validationResult.generation }}</span>
<ul v-if="validationResult.issues?.length">
<li v-for="issue in validationResult.issues" :key="`${issue.code}-${issue.message}`">
{{ issue.code }}:{{ issue.message }}
</li>
</ul>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue'
const {
availabilityError,
loading,
working,
status,
stateLabel,
stateTagType,
selectedSection,
currentSection,
sectionLoading,
editorText,
sectionError,
sensitiveHint,
secretField,
secretValue,
confirmed,
savedGeneration,
validationResult,
canWrite,
refreshPage,
validateConfig,
saveConfig,
applyConfig,
formatTime,
serviceLabel
} = inject('smail-config-page')
</script>
<style scoped src="../ConfigSettings.css"></style>

View File

@ -0,0 +1,12 @@
import { usePage } from '../ConfigSettings.js'
export function useMaintenance(pageKey) {
const page = usePage(pageKey)
const selectBackup = generation => {
if (page.working.value) return
page.rollbackGeneration.value = generation
page.rollbackPreview.value = null
page.rollbackConfirmed.value = false
}
return { ...page, selectBackup }
}

View File

@ -0,0 +1,174 @@
<template>
<div class="config-page" :data-config-page="pageKey" v-loading="loading">
<div class="settings-header">
<div>
<h3>{{ pageTitle }}</h3>
<p class="settings-desc">维护工具独立运行,不会修改其他页面的编辑状态。</p>
</div>
<el-button plain :disabled="!!working" @click="refreshPage">刷新本页</el-button>
</div>
<el-alert v-if="availabilityError" :title="availabilityError" type="warning" :closable="false" show-icon />
<template v-else-if="!loading">
<section v-if="pageKey === 'migration' && canMigrate" class="maintenance-panel migration-panel">
<div class="maintenance-body">
<el-alert v-if="sectionError" :title="sectionError" type="error" :closable="false" show-icon />
<p class="panel-desc">系统先生成一次性预览票据,再复制并校验数据。主机、端口、用户名和密码保持不变,旧数据库不会自动删除。</p>
<div class="migration-source">
<div>
<span>当前数据库类型</span>
<strong>{{ databaseTypeLabel(databaseConfig.type) }}</strong>
</div>
<div>
<span>当前数据库</span>
<strong>{{ databaseConfig.type === 'sqlite' ? databaseConfig.localRef || '-' : databaseConfig.name || '-' }}</strong>
</div>
</div>
<el-form label-position="top" class="migration-form">
<el-form-item :label="databaseConfig.type === 'sqlite' ? '目标数据库文件引用' : '目标数据库名称'">
<el-input
v-model="migrationTargetName"
:placeholder="databaseConfig.type === 'sqlite' ? '例如 smail-next.db' : '例如 smail_next'"
:disabled="!!working || !!sectionError"
maxlength="128"
clearable
/>
</el-form-item>
</el-form>
<div class="action-row">
<el-button :loading="working === 'migration-preview'" :disabled="!!working || !!sectionError || !migrationTargetName.trim()" @click="previewDatabaseMigration">
生成迁移预览
</el-button>
<el-button type="primary" :loading="working === 'migration-execute'" :disabled="!!working || !migrationPreview || !migrationToken" @click="executeDatabaseMigration">
执行迁移
</el-button>
<el-button v-if="migrationRecord?.id" :disabled="!!working" :loading="working === 'migration-status'" @click="loadMigrationStatus">
刷新迁移状态
</el-button>
</div>
<div v-if="migrationRecord" class="preview-box migration-result">
<div class="migration-result-heading">
<strong>迁移状态</strong>
<el-tag :type="migrationStatusType(migrationRecord.status)">{{ migrationStatusLabel(migrationRecord.status) }}</el-tag>
</div>
<span>迁移编号:{{ migrationRecord.id }}</span>
<span>源数据库:{{ migrationRecord.source?.name || migrationRecord.source?.localRef || '-' }}</span>
<span>目标数据库:{{ migrationRecord.target?.name || migrationRecord.target?.localRef || '-' }}</span>
<span v-if="migrationRecord.errorCode" class="migration-error">错误代码:{{ migrationRecord.errorCode }}</span>
</div>
</div>
</section>
<section v-else-if="pageKey === 'restart' && canRestart" class="maintenance-panel restart-panel">
<div class="maintenance-body">
<p class="panel-desc">配置变更需要重启时,只能通过固定的 Smail 服务管理器提交请求,不接受外部命令、服务名或参数。</p>
<el-empty v-if="!status.pendingRestart?.length" description="当前没有待重启服务" :image-size="88" />
<template v-else>
<div class="tag-list restart-services">
<el-tag v-for="service in status.pendingRestart" :key="service" type="warning">{{ serviceLabel(service) }}</el-tag>
</div>
<div class="action-row">
<el-button :disabled="!!working" :loading="working === 'restart-preview'" @click="previewRestart">查看重启预览</el-button>
<el-button type="danger" :loading="working === 'restart'" :disabled="!!working || !restartPreview?.triggerAvailable" @click="requestRestart">
提交重启请求
</el-button>
</div>
<div v-if="restartPreview" class="preview-box restart-result">
<strong>重启预览</strong>
<span>目标:{{ restartPreview.target }} · 操作:{{ restartPreview.action }}</span>
<span>服务管理器:{{ restartPreview.serviceManager }}</span>
<span>{{ restartPreview.triggerAvailable ? '重启通道可用' : '重启通道暂不可用,请先检查服务部署' }}</span>
</div>
</template>
</div>
</section>
<section v-else-if="pageKey === 'backups' && canRollback" class="maintenance-panel rollback-panel">
<div class="maintenance-body">
<div class="panel-heading">
<p class="heading-note">选择备份并预览回滚范围,确认后再执行。</p>
<el-button size="small" :disabled="!!working" :loading="backupsLoading" @click="loadBackups">刷新备份</el-button>
</div>
<el-alert v-if="backupsError" :title="backupsError" type="warning" :closable="false" show-icon />
<el-empty v-else-if="!backups.length && !backupsLoading" description="暂无可用配置备份" :image-size="72" />
<div v-else class="backup-list">
<button
v-for="backup in backups"
:key="backup.generation"
type="button"
class="backup-item"
:disabled="!!working || backupsLoading"
:class="{ selected: rollbackGeneration === backup.generation }"
:aria-pressed="rollbackGeneration === backup.generation"
@click="selectBackup(backup.generation)"
>
<span>代次 {{ backup.generation }}</span>
<small>{{ formatTime(backup.createdAt) }}</small>
</button>
</div>
<el-checkbox v-model="rollbackConfirmed" :disabled="!!working || !rollbackPreview" class="confirmation-check">
我已阅读回滚预览,并确认执行一次性回滚
</el-checkbox>
<div class="action-row">
<el-button :loading="working === 'preview'" :disabled="!!working || !rollbackGeneration" @click="previewRollback">预览回滚</el-button>
<el-button type="danger" plain :loading="working === 'rollback'" :disabled="!!working || !rollbackPreview || !rollbackConfirmed" @click="executeRollback">执行回滚</el-button>
</div>
<div v-if="rollbackPreview" class="preview-box">
<strong>回滚预览</strong>
<span>当前代次 {{ rollbackPreview.currentGeneration }} → 目标代次 {{ rollbackPreview.targetGeneration }}</span>
<span>候选代次:{{ rollbackPreview.candidateGeneration }}</span>
<span>变更分区:{{ rollbackPreview.changedSections?.map(sectionLabel).join('、') || '无' }}</span>
</div>
</div>
</section>
<section v-else-if="pageKey === 'audit' && canAudit" class="maintenance-panel audit-panel">
<div class="maintenance-body">
<div class="panel-heading">
<p class="heading-note">展示最近 50 条,导出最多 500 条记录。</p>
<div class="audit-actions">
<el-button size="small" :disabled="!!working" :loading="auditLoading" @click="loadAudit">查询</el-button>
<el-button v-if="canExportAudit" size="small" type="primary" plain :disabled="!!working" :loading="working === 'export'" @click="exportAudit('json')">导出 JSON</el-button>
<el-button v-if="canExportAudit" size="small" plain :disabled="!!working" :loading="working === 'export-csv'" @click="exportAudit('csv')">导出 CSV</el-button>
</div>
</div>
<el-alert v-if="auditError" :title="auditError" type="warning" :closable="false" show-icon />
<div v-else-if="auditItems.length" class="audit-list">
<div v-for="item in auditItems" :key="item.id || `${item.createdAt}-${item.operation}`" class="audit-item">
<div>
<strong>{{ operationLabel(item.operation) }}</strong>
<span>{{ sectionLabel(item.section) }} · {{ item.actorId || '系统' }}</span>
</div>
<time>{{ formatTime(item.createdAt) }}</time>
</div>
</div>
<el-empty v-else-if="!auditLoading" description="暂无配置审计记录" :image-size="72" />
</div>
</section>
<el-empty v-else description="当前账号无权访问此页面" />
</template>
</div>
</template>
<script setup>
import { useMaintenance } from './ConfigMaintenance.js'
const props = defineProps({
pageKey: { type: String, required: true },
pageTitle: { type: String, required: true }
})
const {
loading, availabilityError, working, status, refreshPage, beforeLeave, sectionError,
canMigrate, databaseConfig, databaseTypeLabel, migrationTargetName, migrationPreview,
migrationToken, migrationRecord, migrationStatusType, migrationStatusLabel,
previewDatabaseMigration, executeDatabaseMigration, loadMigrationStatus,
canRestart, serviceLabel, restartPreview, previewRestart, requestRestart,
canRollback, backupsLoading, backupsError, backups, rollbackGeneration, rollbackConfirmed,
rollbackPreview, loadBackups, selectBackup, previewRollback, executeRollback,
canAudit, canExportAudit, auditLoading, auditError, auditItems, loadAudit, exportAudit,
formatTime, sectionLabel, operationLabel
} = useMaintenance(props.pageKey)
defineExpose({ beforeLeave })
</script>
<style scoped src="../ConfigSettings.css"></style>

View File

@ -0,0 +1,54 @@
import { computed, nextTick, ref } from 'vue'
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
import BaseConfig from './BaseConfig/BaseConfig.vue'
import SmtpConfig from './SmtpConfig/SmtpConfig.vue'
import EmailConfig from './EmailConfig/EmailConfig.vue'
import SslConfig from './SslConfig/SslConfig.vue'
import PluginsConfig from './PluginsConfig/PluginsConfig.vue'
import ConfigMaintenance from './ConfigMaintenance/ConfigMaintenance.vue'
export function useNavigation() {
const store = useGlobalStatusStore()
const selectedSection = ref('base')
const currentPage = ref(null)
const pageHost = ref(null)
const switching = ref(false)
const pages = [
{ value: 'base', label: '基础配置', component: BaseConfig },
{ value: 'smtp', label: 'SMTP 配置', component: SmtpConfig },
{ value: 'email', label: '邮箱策略', component: EmailConfig },
{ value: 'ssl', label: 'SSL 配置', component: SslConfig },
{ value: 'plugins', label: '插件配置', component: PluginsConfig }
]
const tools = [
{ value: 'migration', label: '数据库迁移', capability: 'database.migrate' },
{ value: 'restart', label: '服务重启', capability: 'service.restart' },
{ value: 'backups', label: '备份与回滚', capability: 'config.rollback' },
{ value: 'audit', label: '配置审计', capability: 'audit.read' }
]
const sectionOptions = computed(() => store.hasCapability('config.read') ? pages : [])
const maintenanceOptions = computed(() => store.hasCapability('config.read')
? tools.filter(item => store.hasCapability(item.capability))
.map(item => ({ ...item, component: ConfigMaintenance }))
: [])
const activePage = computed(() => [...sectionOptions.value, ...maintenanceOptions.value]
.find(item => item.value === selectedSection.value))
const beforeLeave = () => currentPage.value?.beforeLeave?.() ?? true
const selectSection = async value => {
const visible = () => [...sectionOptions.value, ...maintenanceOptions.value].some(item => item.value === value)
if (switching.value || value === selectedSection.value || !visible()) return
switching.value = true
try {
if (!await beforeLeave() || !visible()) return
selectedSection.value = value
await nextTick()
pageHost.value?.closest('.el-tabs__content')?.scrollTo({ top: 0 })
pageHost.value?.focus({ preventScroll: true })
} finally {
switching.value = false
}
}
return { sectionOptions, maintenanceOptions, selectedSection, activePage, currentPage, pageHost, selectSection, beforeLeave }
}

View File

@ -4,6 +4,24 @@
padding-bottom: 16px;
--config-accent: var(--pm-primary-color);
}
.config-page,
.config-page-body {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
min-width: 0;
}
.config-navigation {
grid-area: submenu;
position: sticky;
top: 0;
display: grid;
gap: 20px;
}
.config-navigation > .settings-submenu {
grid-area: auto;
position: static;
}
.settings-header,
.panel-heading,
.status-heading {
@ -147,6 +165,14 @@
.config-main > * {
min-width: 0;
}
.config-main:focus {
outline: none;
}
.config-main:focus-visible {
outline: 2px solid color-mix(in srgb, var(--config-accent) 35%, transparent);
outline-offset: -2px;
border-radius: 12px;
}
.panel-heading {
flex-wrap: wrap;
}
@ -512,6 +538,10 @@
.selected-section-label {
white-space: normal;
}
.config-navigation {
gap: 12px;
position: static;
}
}
.panel-desc {
margin: 0;

View File

@ -1,4 +1,4 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { http } from '@/utils/axios'
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
@ -28,7 +28,8 @@ const operationLabels = {
rollback: '执行回滚'
}
export function usePage() {
export function usePage(pageKey = 'base') {
const isConfigPage = sections.some(item => item.value === pageKey)
const globalStatus = useGlobalStatusStore()
const loading = ref(false)
const availabilityError = ref('')
@ -44,11 +45,13 @@ export function usePage() {
pendingRestart: [],
degradedReasons: []
})
const selectedSection = ref('base')
const selectedSection = ref(isConfigPage ? pageKey : 'base')
const currentSection = computed(() => sections.find(item => item.value === selectedSection.value) || sections[0])
const sectionLoading = ref(false)
let sectionRequestId = 0
let disposed = false
const editorText = ref('')
const loadedText = ref('')
const sectionError = ref('')
const secretValue = ref('')
const confirmed = ref(false)
@ -65,6 +68,7 @@ export function usePage() {
const migrationToken = ref('')
const migrationRecord = ref(null)
const restartPreview = ref(null)
const hasChanges = computed(() => editorText.value !== loadedText.value || !!secretValue.value || !!savedGeneration.value)
const canWrite = computed(() => globalStatus.hasCapability('config.write_global'))
const canRollback = computed(() => globalStatus.hasCapability('config.rollback'))
@ -127,6 +131,7 @@ export function usePage() {
sectionLoading.value = true
sectionError.value = ''
editorText.value = ''
loadedText.value = ''
secretValue.value = ''
confirmed.value = false
validationResult.value = null
@ -137,6 +142,7 @@ export function usePage() {
notifyResponseError(response, '配置分区读取失败')
const publicConfig = response.data || {}
editorText.value = JSON.stringify(publicConfig, null, 2)
loadedText.value = editorText.value
if (section === 'base') {
const database = publicConfig.database || {}
Object.assign(databaseConfig, { type: database.type || '', name: database.name || '', localRef: database.localRef || '' })
@ -150,12 +156,6 @@ export function usePage() {
}
}
const selectSection = value => {
if (selectedSection.value === value || working.value || !sections.some(item => item.value === value)) return
selectedSection.value = value
return loadSection()
}
const loadBackups = async () => {
if (!canRollback.value) return
backupsLoading.value = true
@ -192,7 +192,7 @@ export function usePage() {
}
const previewDatabaseMigration = async () => {
if (!canMigrate.value || !migrationTargetName.value.trim()) return
if (!canMigrate.value || working.value || sectionError.value || !migrationTargetName.value.trim()) return
working.value = 'migration-preview'
migrationPreview.value = null
migrationRecord.value = null
@ -229,7 +229,8 @@ export function usePage() {
}
const executeDatabaseMigration = async () => {
if (!migrationPreview.value?.id || !migrationToken.value) return
if (!canMigrate.value || working.value || !migrationPreview.value?.id || !migrationToken.value) return
working.value = 'migration-confirm'
try {
await ElMessageBox.confirm(
'迁移将复制数据库内容并切换配置,旧数据库会保留。确认继续吗?',
@ -259,7 +260,7 @@ export function usePage() {
}
const previewRestart = async () => {
if (!canRestart.value) return
if (!canRestart.value || working.value) return
working.value = 'restart-preview'
try {
const response = await http.post('/api/v1/system/restart/preview', {})
@ -273,7 +274,8 @@ export function usePage() {
}
const requestRestart = async () => {
if (!canRestart.value) return
if (!canRestart.value || working.value || !restartPreview.value?.triggerAvailable) return
working.value = 'restart-confirm'
try {
await ElMessageBox.confirm(
'服务将按固定服务管理器重启,当前页面可能会短暂断开。确认继续吗?',
@ -296,11 +298,14 @@ export function usePage() {
availabilityError.value = ''
try {
await loadStatus()
if (disposed) return
if (status.available === false) {
availabilityError.value = status.unavailableReason || '在线配置管理尚未启用'
return
}
await Promise.all([loadSection(), loadBackups(), loadAudit()])
if (isConfigPage || (pageKey === 'migration' && canMigrate.value)) await loadSection()
if (pageKey === 'backups') await loadBackups()
if (pageKey === 'audit') await loadAudit()
} catch (error) {
availabilityError.value = error.message || '配置管理数据读取失败'
} finally {
@ -351,6 +356,7 @@ export function usePage() {
}
const validateConfig = async () => {
if (!canWrite.value || working.value || sectionLoading.value || sectionError.value) return
working.value = 'validate'
try {
const request = buildRequest()
@ -366,6 +372,7 @@ export function usePage() {
}
const saveConfig = async () => {
if (!canWrite.value || working.value || sectionLoading.value || sectionError.value) return
working.value = 'save'
try {
const request = buildRequest()
@ -383,7 +390,7 @@ export function usePage() {
}
const applyConfig = async () => {
if (!savedGeneration.value) return
if (!canWrite.value || working.value || !savedGeneration.value) return
working.value = 'apply'
try {
const response = await http.post(`/api/v1/config/${selectedSection.value}/apply`, { generation: savedGeneration.value })
@ -399,9 +406,10 @@ export function usePage() {
}
const previewRollback = async () => {
if (!rollbackGeneration.value) return
if (!canRollback.value || working.value || !rollbackGeneration.value) return
working.value = 'preview'
rollbackPreview.value = null
rollbackConfirmed.value = false
try {
const response = await http.post('/api/v1/config/rollback/preview', { targetGeneration: rollbackGeneration.value })
notifyResponseError(response, '回滚预览失败')
@ -415,7 +423,8 @@ export function usePage() {
}
const executeRollback = async () => {
if (!rollbackPreview.value || !rollbackConfirmed.value) return
if (!canRollback.value || working.value || !rollbackPreview.value || !rollbackConfirmed.value) return
working.value = 'rollback-confirm'
try {
await ElMessageBox.confirm('回滚会替换当前候选配置并可能需要重启服务,确定继续吗?', '确认执行回滚', { type: 'warning', confirmButtonText: '继续回滚', cancelButtonText: '取消' })
working.value = 'rollback'
@ -428,7 +437,7 @@ export function usePage() {
notifyResponseError(response, '配置回滚失败')
rollbackPreview.value = null
rollbackConfirmed.value = false
await Promise.all([loadStatus(), loadSection(), loadBackups()])
await Promise.all([loadStatus(), loadBackups()])
ElMessage.success('配置已回滚')
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message || '配置回滚失败')
@ -438,6 +447,7 @@ export function usePage() {
}
const exportAudit = async format => {
if (!canExportAudit.value || working.value) return
working.value = format === 'csv' ? 'export-csv' : 'export'
try {
const response = await http.post('/api/v1/audits/config', { format, limit: 500 }, { responseType: 'blob' })
@ -456,11 +466,40 @@ export function usePage() {
}
}
const beforeLeave = async () => {
if (working.value) {
ElMessage.warning('当前操作尚未完成,请稍后再切换页面')
return false
}
if (!hasChanges.value && !migrationToken.value && !rollbackPreview.value) return true
try {
await ElMessageBox.confirm(
'当前页有编辑内容、待应用候选配置或操作预览。离开会清除本页状态,但不会撤销已保存到服务器的候选配置。确认离开吗?',
'确认离开当前页',
{ type: 'warning', confirmButtonText: '离开页面', cancelButtonText: '继续编辑' }
)
return !working.value
} catch {
return false
}
}
const refreshPage = async () => {
if (await beforeLeave()) await loadAll()
}
onMounted(loadAll)
onBeforeUnmount(() => {
disposed = true
sectionRequestId++
secretValue.value = ''
confirmed.value = false
migrationToken.value = ''
})
return {
loading, availabilityError, working, status, stateLabel, stateTagType, sectionOptions: sections,
selectedSection, currentSection, sectionLoading, selectSection, editorText, sectionError, sensitiveHint, secretField, secretValue,
selectedSection, currentSection, sectionLoading, editorText, sectionError, sensitiveHint, secretField, secretValue,
confirmed, savedGeneration, validationResult, canWrite, canRollback, canAudit, canExportAudit,
backupsLoading, backupsError, backups, rollbackGeneration, rollbackConfirmed, rollbackPreview,
auditLoading, auditError, auditItems, loadAll, loadSection, validateConfig, saveConfig, applyConfig,
@ -468,6 +507,6 @@ export function usePage() {
serviceLabel, sectionLabel, operationLabel, databaseConfig, databaseTypeLabel, canMigrate,
migrationTargetName, migrationPreview, migrationToken, migrationRecord, migrationStatusLabel, migrationStatusType,
previewDatabaseMigration, loadMigrationStatus, executeDatabaseMigration, canRestart, restartPreview,
previewRestart, requestRestart
previewRestart, requestRestart, beforeLeave, refreshPage, hasChanges
}
}

View File

@ -1,427 +1,47 @@
<template>
<div class="config-settings" v-loading="loading">
<div class="settings-header">
<div>
<h3>配置管理</h3>
<p class="settings-desc">集中管理服务配置。先验证,再保存与应用,所有变更均可追溯。</p>
</div>
<el-tag class="access-tag" :type="canWrite ? 'info' : 'warning'" effect="plain">
{{ canWrite ? '敏感凭据已脱敏' : '只读模式' }}
</el-tag>
</div>
<el-alert
v-if="!canWrite"
title="当前账号仅能查看配置,保存、应用和回滚操作已隐藏。"
type="info"
:closable="false"
show-icon
class="config-alert"
/>
<div v-if="availabilityError" class="config-availability" role="status">
<el-alert :title="availabilityError" type="warning" :closable="false" show-icon />
<el-button :loading="loading" @click="loadAll">重新检查</el-button>
</div>
<div v-else-if="!loading" class="config-workspace">
<SettingsSubmenu
title="配置管理"
caption="配置分区"
:items="sectionOptions"
:model-value="selectedSection"
@select="selectSection"
/>
<div class="config-main">
<section class="status-panel">
<div class="status-heading">
<div>
<h4>配置快照</h4>
<p class="heading-note">当前运行状态与生效版本</p>
</div>
<el-button size="small" plain :loading="loading" :disabled="!!working || sectionLoading" @click="loadAll">
刷新状态
</el-button>
</div>
<div class="status-grid">
<div class="status-item">
<span class="status-label">运行状态</span>
<el-tag :type="stateTagType" effect="light">{{ stateLabel }}</el-tag>
</div>
<div class="status-item">
<span class="status-label">当前生效代次</span>
<strong class="generation-value">{{ status.activeGeneration || '-' }}</strong>
</div>
<div class="status-item">
<span class="status-label">最近校验时间</span>
<strong>{{ formatTime(status.lastValidAt) }}</strong>
</div>
<div class="status-item" :class="{ 'status-warning': status.pendingRestart?.length }">
<span class="status-label">待重启服务</span>
<div v-if="status.pendingRestart?.length" class="tag-list">
<el-tag v-for="service in status.pendingRestart" :key="service" type="warning" effect="plain">
{{ serviceLabel(service) }}
</el-tag>
</div>
<strong v-else>无需重启</strong>
</div>
</div>
<el-alert
v-for="issue in status.degradedReasons || []"
:key="`${issue.code}-${issue.message}`"
:title="`${issue.code}:${issue.message}`"
type="warning"
:closable="false"
show-icon
class="degraded-alert"
/>
</section>
<section class="editor-panel" :aria-busy="sectionLoading">
<div class="panel-heading">
<div>
<span class="section-eyebrow">分区配置</span>
<h4>{{ currentSection.label }}</h4>
<p class="heading-note">{{ currentSection.description }}</p>
</div>
<span class="selected-section-label">{{ canWrite ? '候选配置编辑' : '配置查看' }}</span>
</div>
<ol v-if="canWrite" class="change-workflow" aria-label="配置变更流程">
<li><span class="step-number">1</span><span>验证配置</span></li>
<li><span class="step-number">2</span><span>保存候选</span></li>
<li><span class="step-number">3</span><span>应用生效</span></li>
</ol>
<el-alert
v-if="sectionError"
:title="sectionError"
type="error"
:closable="false"
show-icon
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>
<p id="config-sensitive-hint" class="editor-hint">{{ sensitiveHint }}。</p>
<div v-if="secretField" class="secret-editor">
<div>
<label for="config-secret-input">{{ secretField.label }}</label>
<p>{{ secretField.description }}</p>
</div>
<el-input
id="config-secret-input"
v-model="secretValue"
type="password"
show-password
clearable
:disabled="!canWrite || sectionLoading || !!working || !!sectionError"
:placeholder="secretField.placeholder"
autocomplete="new-password"
/>
</div>
<div v-if="canWrite" class="editor-footer">
<el-checkbox v-model="confirmed" class="confirmation-check" :disabled="sectionLoading || !!working || !!sectionError">
我已确认这是受控变更,并允许使用最近认证完成高风险配置操作
</el-checkbox>
<div class="action-row">
<el-button :loading="working === 'validate'" :disabled="sectionLoading || !!working || !!sectionError" @click="validateConfig">
只验证
</el-button>
<el-button type="primary" :loading="working === 'save'" :disabled="sectionLoading || !!working || !!sectionError" @click="saveConfig">
保存候选配置
</el-button>
<el-button type="success" plain :loading="working === 'apply'" :disabled="sectionLoading || !!working || !savedGeneration" @click="applyConfig">
{{ savedGeneration ? `应用代次 ${savedGeneration}` : '应用配置' }}
</el-button>
</div>
<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>
<span v-if="validationResult.generation">候选代次:{{ validationResult.generation }}</span>
<ul v-if="validationResult.issues?.length">
<li v-for="issue in validationResult.issues" :key="`${issue.code}-${issue.message}`">
{{ issue.code }}:{{ issue.message }}
</li>
</ul>
</div>
</section>
<div v-if="(canMigrate && selectedSection === 'base') || (canRestart && status.pendingRestart?.length) || canRollback || canAudit" class="maintenance-heading">
<h4>维护与记录</h4>
<p>按需展开,不影响上方配置编辑</p>
</div>
<details v-if="canMigrate && selectedSection === 'base'" class="maintenance-panel migration-panel">
<summary class="maintenance-summary">
<span class="maintenance-copy">
<strong>安全迁移数据库</strong>
<span>复制并校验数据,保留旧数据库</span>
</span>
<el-tag type="warning" effect="plain" size="small">高风险操作</el-tag>
<span class="disclosure-arrow" aria-hidden="true"></span>
</summary>
<div class="maintenance-body">
<p class="panel-desc">系统会先生成一次性预览票据,再复制并校验数据。主机、端口、用户名和密码保持不变,旧数据库不会自动删除。</p>
<div class="migration-source">
<div>
<span>当前数据库类型</span>
<strong>{{ databaseTypeLabel(databaseConfig.type) }}</strong>
</div>
<div>
<span>当前数据库</span>
<strong>{{ databaseConfig.type === 'sqlite' ? databaseConfig.localRef || '-' : databaseConfig.name || '-' }}</strong>
</div>
</div>
<el-form label-position="top" class="migration-form">
<el-form-item :label="databaseConfig.type === 'sqlite' ? '目标数据库文件引用' : '目标数据库名称'">
<el-input
v-model="migrationTargetName"
:placeholder="databaseConfig.type === 'sqlite' ? '例如 smail-next.db' : '例如 smail_next'"
maxlength="128"
clearable
/>
</el-form-item>
</el-form>
<div class="action-row">
<el-button :loading="working === 'migration-preview'" @click="previewDatabaseMigration">
生成迁移预览
</el-button>
<el-button type="primary" :loading="working === 'migration-execute'" :disabled="!migrationPreview || !migrationToken" @click="executeDatabaseMigration">
执行迁移
</el-button>
<el-button v-if="migrationRecord?.id" plain :loading="working === 'migration-status'" @click="loadMigrationStatus">
刷新迁移状态
</el-button>
</div>
<div v-if="migrationRecord" class="preview-box migration-result">
<div class="migration-result-heading">
<strong>迁移状态</strong>
<el-tag :type="migrationStatusType(migrationRecord.status)" effect="plain">
{{ migrationStatusLabel(migrationRecord.status) }}
</el-tag>
</div>
<span>迁移编号:{{ migrationRecord.id }}</span>
<span>源数据库:{{ migrationRecord.source?.name || migrationRecord.source?.localRef || '-' }}</span>
<span>目标数据库:{{ migrationRecord.target?.name || migrationRecord.target?.localRef || '-' }}</span>
<span v-if="migrationRecord.errorCode" class="migration-error">错误代码:{{ migrationRecord.errorCode }}</span>
</div>
</div>
</details>
<details v-if="canRestart && status.pendingRestart?.length" class="maintenance-panel restart-panel" open>
<summary class="maintenance-summary">
<span class="maintenance-copy">
<strong>应用待重启服务</strong>
<span>通过固定的 Smail 服务管理器受控重启</span>
</span>
<el-tag type="warning" effect="plain" size="small">待重启</el-tag>
<span class="disclosure-arrow" aria-hidden="true"></span>
</summary>
<div class="maintenance-body">
<p class="panel-desc">配置变更需要重启时,只能通过固定的 Smail 服务管理器提交请求,不接受外部命令、服务名或参数。</p>
<div class="tag-list restart-services">
<el-tag v-for="service in status.pendingRestart" :key="service" type="warning">
{{ serviceLabel(service) }}
</el-tag>
</div>
<div class="action-row">
<el-button :loading="working === 'restart-preview'" @click="previewRestart">查看重启预览</el-button>
<el-button type="danger" :loading="working === 'restart'" :disabled="!restartPreview?.triggerAvailable" @click="requestRestart">
提交重启请求
</el-button>
</div>
<div v-if="restartPreview" class="preview-box restart-result">
<strong>重启预览</strong>
<span>目标:{{ restartPreview.target }} · 操作:{{ restartPreview.action }}</span>
<span>服务管理器:{{ restartPreview.serviceManager }}</span>
<span>{{ restartPreview.triggerAvailable ? '重启通道可用' : '重启通道暂不可用,请先检查服务部署' }}</span>
</div>
</div>
</details>
<details v-if="canRollback" class="maintenance-panel rollback-panel">
<summary class="maintenance-summary">
<span class="maintenance-copy">
<strong>备份与回滚</strong>
<span>先预览变更,再确认一次性回滚</span>
</span>
<el-tag :type="backupsError ? 'warning' : 'info'" effect="plain" size="small">
{{ backupsError ? '读取失败' : `${backups.length} 份备份` }}
</el-tag>
<span class="disclosure-arrow" aria-hidden="true"></span>
</summary>
<div class="maintenance-body">
<div class="panel-heading">
<p class="heading-note">选择备份并预览回滚范围。</p>
<el-button size="small" plain :loading="backupsLoading" @click="loadBackups">刷新备份</el-button>
</div>
<el-alert v-if="backupsError" :title="backupsError" type="warning" :closable="false" show-icon />
<el-empty v-else-if="!backups.length && !backupsLoading" description="暂无可用配置备份" :image-size="72" />
<div v-else class="backup-list">
<button
v-for="backup in backups"
:key="backup.generation"
type="button"
class="backup-item"
:class="{ selected: rollbackGeneration === backup.generation }"
:aria-pressed="rollbackGeneration === backup.generation"
@click="rollbackGeneration = backup.generation"
>
<span>代次 {{ backup.generation }}</span>
<small>{{ formatTime(backup.createdAt) }}</small>
</button>
</div>
<el-checkbox v-model="rollbackConfirmed" class="confirmation-check">
我已阅读回滚预览,并确认执行一次性回滚
</el-checkbox>
<div class="action-row">
<el-button :loading="working === 'preview'" :disabled="!rollbackGeneration" @click="previewRollback">
预览回滚
</el-button>
<el-button type="danger" plain :loading="working === 'rollback'" :disabled="!rollbackPreview || !rollbackConfirmed" @click="executeRollback">
执行回滚
</el-button>
</div>
<div v-if="rollbackPreview" class="preview-box">
<strong>回滚预览</strong>
<span>当前代次 {{ rollbackPreview.currentGeneration }} → 目标代次 {{ rollbackPreview.targetGeneration }}</span>
<span>候选代次:{{ rollbackPreview.candidateGeneration }}</span>
<span>变更分区:{{ rollbackPreview.changedSections?.map(sectionLabel).join('、') || '无' }}</span>
</div>
</div>
</details>
<details v-if="canAudit" class="maintenance-panel audit-panel">
<summary class="maintenance-summary">
<span class="maintenance-copy">
<strong>配置操作审计</strong>
<span>查询变更记录,按需导出 JSON 或 CSV</span>
</span>
<el-tag :type="auditError ? 'warning' : 'info'" effect="plain" size="small">
{{ auditError ? '读取失败' : `最近 ${auditItems.length} 条` }}
</el-tag>
<span class="disclosure-arrow" aria-hidden="true"></span>
</summary>
<div class="maintenance-body">
<div class="panel-heading">
<p class="heading-note">展示最近 50 条,导出最多 500 条记录。</p>
<div class="audit-actions">
<el-button size="small" plain :loading="auditLoading" @click="loadAudit">查询</el-button>
<el-button v-if="canExportAudit" size="small" type="primary" plain :loading="working === 'export'" @click="exportAudit('json')">
导出 JSON
</el-button>
<el-button v-if="canExportAudit" size="small" plain :loading="working === 'export-csv'" @click="exportAudit('csv')">
导出 CSV
</el-button>
</div>
</div>
<el-alert v-if="auditError" :title="auditError" type="warning" :closable="false" show-icon />
<div v-else-if="auditItems.length" class="audit-list">
<div v-for="item in auditItems" :key="item.id || `${item.createdAt}-${item.operation}`" class="audit-item">
<div>
<strong>{{ operationLabel(item.operation) }}</strong>
<span>{{ sectionLabel(item.section) }} · {{ item.actorId || '系统' }}</span>
</div>
<time>{{ formatTime(item.createdAt) }}</time>
</div>
</div>
<el-empty v-else-if="!auditLoading" description="暂无配置审计记录" :image-size="72" />
</div>
</details>
<div class="config-settings">
<div class="config-workspace">
<aside class="config-navigation">
<SettingsSubmenu
title="配置管理"
caption="配置页面"
:items="sectionOptions"
:model-value="selectedSection"
@select="selectSection"
/>
<SettingsSubmenu
v-if="maintenanceOptions.length"
title="维护与记录"
caption="独立工具"
:items="maintenanceOptions"
:model-value="selectedSection"
@select="selectSection"
/>
</aside>
<div ref="pageHost" class="config-main" :aria-label="activePage?.label" tabindex="-1">
<component
:is="activePage.component"
v-if="activePage"
:key="selectedSection"
ref="currentPage"
:page-key="selectedSection"
:page-title="activePage.label"
/>
</div>
</div>
</div>
</template>
<script setup>
import { usePage } from './ConfigSettings.js'
import SettingsSubmenu from '../SettingsSubmenu/SettingsSubmenu.vue'
import { useNavigation } from './ConfigNavigation.js'
const {
availabilityError,
loading,
working,
status,
stateLabel,
stateTagType,
sectionOptions,
selectedSection,
currentSection,
sectionLoading,
selectSection,
editorText,
sectionError,
sensitiveHint,
secretField,
secretValue,
confirmed,
savedGeneration,
validationResult,
canWrite,
canRollback,
canAudit,
canExportAudit,
backupsLoading,
backupsError,
backups,
rollbackGeneration,
rollbackConfirmed,
rollbackPreview,
auditLoading,
auditError,
auditItems,
databaseConfig,
migrationTargetName,
migrationPreview,
migrationToken,
migrationRecord,
migrationStatusLabel,
migrationStatusType,
databaseTypeLabel,
canMigrate,
canRestart,
restartPreview,
loadAll,
validateConfig,
saveConfig,
applyConfig,
loadBackups,
previewDatabaseMigration,
loadMigrationStatus,
executeDatabaseMigration,
previewRestart,
requestRestart,
previewRollback,
executeRollback,
loadAudit,
exportAudit,
formatTime,
serviceLabel,
sectionLabel,
operationLabel
} = usePage()
sectionOptions, maintenanceOptions, selectedSection, activePage,
currentPage, pageHost, selectSection, beforeLeave
} = useNavigation()
defineExpose({ beforeLeave })
</script>
<style scoped src="./ConfigSettings.css"></style>

View File

@ -0,0 +1,13 @@
<template>
<ConfigEditor />
</template>
<script setup>
import { provide } from 'vue'
import { usePage } from '../ConfigSettings.js'
import ConfigEditor from '../ConfigEditor/ConfigEditor.vue'
const page = usePage('email')
provide('smail-config-page', page)
defineExpose({ beforeLeave: page.beforeLeave })
</script>

View File

@ -0,0 +1,13 @@
<template>
<ConfigEditor />
</template>
<script setup>
import { provide } from 'vue'
import { usePage } from '../ConfigSettings.js'
import ConfigEditor from '../ConfigEditor/ConfigEditor.vue'
const page = usePage('plugins')
provide('smail-config-page', page)
defineExpose({ beforeLeave: page.beforeLeave })
</script>

View File

@ -0,0 +1,13 @@
<template>
<ConfigEditor />
</template>
<script setup>
import { provide } from 'vue'
import { usePage } from '../ConfigSettings.js'
import ConfigEditor from '../ConfigEditor/ConfigEditor.vue'
const page = usePage('smtp')
provide('smail-config-page', page)
defineExpose({ beforeLeave: page.beforeLeave })
</script>

View File

@ -0,0 +1,13 @@
<template>
<ConfigEditor />
</template>
<script setup>
import { provide } from 'vue'
import { usePage } from '../ConfigSettings.js'
import ConfigEditor from '../ConfigEditor/ConfigEditor.vue'
const page = usePage('ssl')
provide('smail-config-page', page)
defineExpose({ beforeLeave: page.beforeLeave })
</script>

View File

@ -53,6 +53,24 @@ export function usePage() {
].filter(item => globalStatus.hasCapability(item.capability)))
const activeSettings = computed(() => settingsMenu.value.find(item => item.key === globalStatus.settingsActiveTab))
const settingsPages = new Map()
const setSettingsPage = (key, instance) => {
if (instance) settingsPages.set(key, instance)
else settingsPages.delete(key)
}
let checkingLeave = false
const beforeSettingsLeave = async () => {
if (checkingLeave) return false
checkingLeave = true
try {
return await (settingsPages.get(globalStatus.settingsActiveTab)?.beforeLeave?.() ?? true)
} finally {
checkingLeave = false
}
}
const closeSettings = async done => {
if (await beforeSettingsLeave()) done()
}
return {
EpMenu,
@ -74,7 +92,10 @@ export function usePage() {
isMobile,
handleResize,
settingsMenu,
activeSettings
activeSettings,
setSettingsPage,
beforeSettingsLeave,
closeSettings
}
}

View File

@ -25,6 +25,7 @@
class="settings-drawer"
:with-header="true"
direction="rtl"
:before-close="closeSettings"
>
<template #header="{ titleId }">
<div class="settings-drawer-heading">
@ -39,6 +40,7 @@
v-model="globalStatus.settingsActiveTab"
:tab-position="isMobile ? 'top' : 'right'"
class="settings-tabs"
:before-leave="beforeSettingsLeave"
>
<el-tab-pane
v-for="item in settingsMenu"
@ -61,6 +63,7 @@
<component
v-if="globalStatus.settingsDrawerVisible && globalStatus.settingsActiveTab === item.key"
:is="item.component"
:ref="instance => setSettingsPage(item.key, instance)"
/>
</el-tab-pane>
</el-tabs>
@ -79,7 +82,10 @@ const {
isMobile,
lang,
settingsMenu,
activeSettings
activeSettings,
closeSettings,
beforeSettingsLeave,
setSettingsPage
} = usePage()
</script>

View File

@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { compileFunction } from 'node:vm'
import { computed, reactive, ref } from './vue.mjs'
const source = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigNavigation.js', import.meta.url), 'utf8')
const components = ['BaseConfig', 'SmtpConfig', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance']
const createNavigation = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function useNavigation', 'function useNavigation') + '\nreturn useNavigation()',
['computed', 'nextTick', 'ref', 'useGlobalStatusStore', ...components]
)
function fixture(capabilities = ['config.read']) {
const allowed = reactive(new Set(capabilities))
const page = createNavigation(computed, async () => {}, ref,
() => ({ hasCapability: key => allowed.has(key) }), ...components.map(name => ({ name })))
return { page, allowed }
}
test('五个菜单对应五个不同页面组件,无权限的维护入口不可打开', async () => {
const { page, allowed } = fixture()
assert.equal(new Set(page.sectionOptions.value.map(item => item.component)).size, 5)
assert.equal(page.maintenanceOptions.value.length, 0)
await page.selectSection('audit')
await page.selectSection('invalid')
assert.equal(page.selectedSection.value, 'base')
allowed.add('audit.read')
assert.deepEqual(page.maintenanceOptions.value.map(item => item.value), ['audit'])
await page.selectSection('audit')
assert.equal(page.activePage.value.component.name, 'ConfigMaintenance')
allowed.delete('config.read')
assert.equal(page.activePage.value, undefined)
})
test('切页等待离开确认,取消和快速重复点击不会更改页面', async () => {
const { page } = fixture()
let resolve
let prompts = 0
page.currentPage.value = { beforeLeave: () => { prompts++; return new Promise(done => { resolve = done }) } }
const first = page.selectSection('smtp')
await page.selectSection('ssl')
assert.equal(prompts, 1)
assert.equal(page.selectedSection.value, 'base')
resolve(false)
await first
assert.equal(page.selectedSection.value, 'base')
const second = page.selectSection('email')
resolve(true)
await second
assert.equal(page.selectedSection.value, 'email')
assert.equal(page.activePage.value.component.name, 'EmailConfig')
})
test('退出确认期间收回权限,不得进入原先可见的维护页', async () => {
const { page, allowed } = fixture(['config.read', 'config.rollback'])
page.currentPage.value = { beforeLeave: async () => { allowed.delete('config.rollback'); return true } }
await page.selectSection('backups')
assert.equal(page.selectedSection.value, 'base')
})

View File

@ -6,16 +6,20 @@ import { createRequire } from 'node:module'
import { computed, reactive, ref } from './vue.mjs'
const source = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigSettings.js', import.meta.url), 'utf8')
const createPage = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage()',
['computed', 'onMounted', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore']
const createPageWithKey = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage(pageKey)',
['computed', 'onMounted', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore', 'pageKey', 'onBeforeUnmount']
)
const createPage = (...args) => createPageWithKey(...args, 'base', () => {})
function fixture(get) {
function fixture(get, pageKey = 'base', options = {}) {
const messages = []
const page = createPage(computed, () => {}, reactive, ref,
{ error: message => messages.push(message) }, {}, { get }, () => ({ hasCapability: () => true }))
return { page, messages }
let dispose = () => {}
const page = createPageWithKey(computed, () => {}, reactive, ref,
{ error: message => messages.push(message), warning: message => messages.push(message), success: () => {} },
{ confirm: options.confirm || (async () => {}) }, { get, ...options.http },
() => ({ hasCapability: options.hasCapability || (() => true) }), pageKey, callback => { dispose = callback })
return { page, messages, dispose: () => dispose() }
}
function deferred() {
@ -38,17 +42,26 @@ test('旧版配置仅请求状态,不并发请求分区、备份和审计', as
assert.equal(page.loading.value, false)
})
test('备份和审计失败只显示局部错误,不阻挡配置编辑区域', async () => {
const { page, messages } = fixture(async path => {
test('配置页不加载备份或审计,维护页失败只显示本页错误', async () => {
const requests = []
const get = async path => {
requests.push(path)
if (path.endsWith('/status')) return { errorNo: 0, data: { available: true } }
if (path.endsWith('/base')) return { errorNo: 0, data: {} }
throw new Error('配置服务暂时不可用')
})
}
const { page, messages } = fixture(get)
await page.loadAll()
assert.equal(page.availabilityError.value, '')
assert.equal(page.editorText.value, '{}')
assert.equal(page.backupsError.value, '配置服务暂时不可用')
assert.equal(page.auditError.value, '配置服务暂时不可用')
assert.deepEqual(requests, ['/api/v1/config/status', '/api/v1/config/base'])
const backup = fixture(get, 'backups').page
const audit = fixture(get, 'audit').page
await Promise.all([backup.loadAll(), audit.loadAll()])
assert.equal(backup.backupsError.value, '配置服务暂时不可用')
assert.equal(audit.auditError.value, '配置服务暂时不可用')
assert.equal(backup.editorText.value, '')
assert.equal(audit.editorText.value, '')
assert.deepEqual(messages, [])
await page.loadBackups()
assert.deepEqual(messages, [])
@ -95,10 +108,10 @@ test('状态请求失败后可重试恢复,不保留不可用提示', async ()
assert.equal(page.availabilityError.value, '')
})
test('快速切换分区时丢弃较慢的旧响应,并清除旧编辑内容与确认状态', async () => {
test('独立页互不覆盖,旧页卸载时清除密码并丢弃较慢响应', async () => {
const base = deferred()
const email = deferred()
const { page } = fixture(path => path.endsWith('/base') ? base.promise : email.promise)
const { page, dispose } = fixture(() => base.promise)
page.editorText.value = '{"old":true}'
page.confirmed.value = true
page.secretValue.value = 'test-only-value'
@ -107,23 +120,29 @@ test('快速切换分区时丢弃较慢的旧响应,并清除旧编辑内容
assert.equal(page.confirmed.value, false)
assert.equal(page.secretValue.value, '')
assert.equal(page.sectionLoading.value, true)
const emailRequest = page.selectSection('email')
page.secretValue.value = 'test-only-value'
dispose()
assert.equal(page.secretValue.value, '')
const emailPage = fixture(() => email.promise, 'email').page
const emailRequest = emailPage.loadSection()
email.resolve({ errorNo: 0, data: { policy: { smtpPort: 25 } } })
await emailRequest
assert.equal(page.currentSection.value.label, '邮箱策略')
assert.equal(page.sectionLoading.value, false)
assert.equal(emailPage.currentSection.value.label, '邮箱策略')
assert.equal(emailPage.sectionLoading.value, false)
base.resolve({ errorNo: 0, data: { database: { type: 'sqlite' } } })
await baseRequest
assert.deepEqual(JSON.parse(page.editorText.value), { policy: { smtpPort: 25 } })
assert.deepEqual(JSON.parse(emailPage.editorText.value), { policy: { smtpPort: 25 } })
assert.equal(page.editorText.value, '')
assert.equal(page.databaseConfig.type, '')
})
test('过期请求失败不清除新分区的加载状态或覆盖错误信息', async () => {
test('同一页重复加载时,过期失败不覆盖最新请求', async () => {
const base = deferred()
const ssl = deferred()
const { page } = fixture(path => path.endsWith('/base') ? base.promise : ssl.promise)
let count = 0
const { page } = fixture(() => ++count === 1 ? base.promise : ssl.promise, 'ssl')
const baseRequest = page.loadSection()
const sslRequest = page.selectSection('ssl')
const sslRequest = page.loadSection()
base.reject(new Error('旧分区读取失败'))
await baseRequest
assert.equal(page.sectionLoading.value, true)
@ -135,22 +154,19 @@ test('过期请求失败不清除新分区的加载状态或覆盖错误信息',
assert.deepEqual(JSON.parse(page.editorText.value), { enabled: true })
})
test('变更执行期间或分区值无效时不切换,所有分区均提供中文说明', async () => {
test('五个配置页固定分区并按需加载,所有分区均提供中文说明', async () => {
const requests = []
const { page } = fixture(async path => { requests.push(path); return { errorNo: 0, data: {} } })
page.working.value = 'save'
await page.selectSection('smtp')
assert.equal(page.selectedSection.value, 'base')
page.working.value = ''
await page.selectSection('invalid-section')
await page.selectSection('base')
assert.deepEqual(requests, [])
for (const section of page.sectionOptions) {
assert.match(section.description, /[\u4e00-\u9fff]/)
for (const key of ['base', 'smtp', 'email', 'ssl', 'plugins']) {
const { page } = fixture(async path => { requests.push(path); return { errorNo: 0, data: {} } }, key)
await page.loadAll()
assert.equal(page.selectedSection.value, key)
assert.match(page.currentSection.value.description, /[\u4e00-\u9fff]/)
assert.equal(page.secretValue.value, '')
assert.equal(page.confirmed.value, false)
assert.equal(page.savedGeneration.value, 0)
}
await page.selectSection('plugins')
assert.deepEqual(requests, ['/api/v1/config/plugins'])
assert.equal(page.currentSection.value.label, '插件配置')
assert.deepEqual(requests, ['base', 'smtp', 'email', 'ssl', 'plugins']
.flatMap(key => ['/api/v1/config/status', `/api/v1/config/${key}`]))
})
test('迁移预览票据返回模板,迁移执行取消时不发送写入请求', async () => {
@ -175,11 +191,12 @@ test('迁移预览票据返回模板,迁移执行取消时不发送写入请
}])
})
test('配置模板保留权限与确认绑定,维护操作使用原生可访问折叠面板', () => {
test('独立页模板可编译,编辑器不再连接维护面板且权限绑定保留', () => {
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc')
const root = new URL('../../frontend/src/views/components/ConfigSettings/', import.meta.url)
const template = readFileSync(new URL('ConfigSettings.vue', root), 'utf8')
const template = readFileSync(new URL('ConfigEditor/ConfigEditor.vue', root), 'utf8')
const maintenance = readFileSync(new URL('ConfigMaintenance/ConfigMaintenance.vue', root), 'utf8')
const css = readFileSync(new URL('ConfigSettings.css', root), 'utf8')
const { descriptor, errors } = parse(template)
assert.deepEqual(errors, [])
@ -187,12 +204,93 @@ test('配置模板保留权限与确认绑定,维护操作使用原生可访
assert.deepEqual(compileStyle({ source: css, id: 'data-v-config-layout', scoped: true }).errors, [])
assert.match(template, /v-if="canWrite" class="editor-footer"/)
assert.match(template, /v-model="confirmed"/)
assert.match(template, /<details v-if="canMigrate && selectedSection === 'base'"/)
assert.match(template, /<details v-if="canRestart && status.pendingRestart\?\.length"[^>]+ open>/)
assert.match(template, /<details v-if="canRollback"/)
assert.match(template, /<details v-if="canAudit"/)
assert.equal((template.match(/<summary class="maintenance-summary">/g) || []).length, 4)
assert.doesNotMatch(template, /maintenance-panel|migration-panel|rollback-panel|audit-panel/)
for (const [key, capability] of [['migration', 'canMigrate'], ['restart', 'canRestart'], ['backups', 'canRollback'], ['audit', 'canAudit']]) {
assert.ok(maintenance.includes(`pageKey === '${key}' && ${capability}`))
}
assert.match(maintenance, /v-if="canExportAudit"/)
for (const name of ['BaseConfig', 'SmtpConfig', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance']) {
const page = readFileSync(new URL(`${name}/${name}.vue`, root), 'utf8')
const { descriptor, errors } = parse(page)
assert.deepEqual(errors, [])
assert.doesNotThrow(() => compileScript(descriptor, { id: name, inlineTemplate: true }))
}
assert.match(css, /@container config-main \(max-width: 620px\)/)
assert.match(css, /\.maintenance-summary:focus-visible/)
assert.doesNotMatch(template, /&#x[0-9a-f]+;/i)
})
test('离开、刷新和正在执行的变更受保护,取消时保留编辑内容', async () => {
let cancel = true
let prompts = 0
const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'smtp', {
confirm: async () => { prompts++; if (cancel) throw 'cancel' }
})
await page.loadAll()
assert.equal(await page.beforeLeave(), true)
assert.equal(prompts, 0)
page.editorText.value = '{"test":true}'
assert.equal(await page.beforeLeave(), false)
await page.refreshPage()
assert.equal(page.editorText.value, '{"test":true}')
cancel = false
assert.equal(await page.beforeLeave(), true)
page.working.value = 'save'
assert.equal(await page.beforeLeave(), false)
page.working.value = ''
await page.refreshPage()
assert.equal(page.hasChanges.value, false)
page.savedGeneration.value = 3
cancel = true
assert.equal(await page.beforeLeave(), false)
})
test('卸载发生在状态请求期间,不再发送后续分区请求', async () => {
const pending = deferred()
const requests = []
const { page, dispose } = fixture(path => { requests.push(path); return pending.promise })
const loading = page.loadAll()
dispose()
pending.resolve({ errorNo: 0, data: { available: true } })
await loading
assert.deepEqual(requests, ['/api/v1/config/status'])
})
test('只读账号不得发送配置写入请求或读取无权限维护数据', async () => {
const requests = []
const { page } = fixture(async path => { requests.push(path); return { errorNo: 0, data: {} } }, 'audit', {
hasCapability: capability => capability === 'config.read'
})
await page.loadAll()
await page.validateConfig()
await page.saveConfig()
page.savedGeneration.value = 3
await page.applyConfig()
assert.deepEqual(requests, ['/api/v1/config/status'])
})
test('独立页保持验证、保存、应用端点和代次协议,敏感值不串页', async () => {
const requests = []
const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: 8 } : { relayPasswordStatus: 'configured' } }), 'smtp', {
http: {
post: async (path, body) => { requests.push({ path, body }); return { errorNo: 0, data: { valid: true } } },
put: async (path, body) => { requests.push({ path, body }); return { errorNo: 0, data: { generation: 9 } } }
}
})
await page.loadAll()
page.secretValue.value = 'test-only-value'
page.confirmed.value = true
await page.validateConfig()
await page.saveConfig()
assert.equal(page.savedGeneration.value, 9)
await page.applyConfig()
assert.deepEqual(requests.map(item => item.path), ['/api/v1/config/smtp/validate', '/api/v1/config/smtp', '/api/v1/config/smtp/apply'])
assert.equal(requests[0].body.expectedGeneration, 8)
assert.deepEqual(requests[0].body.payload, {})
assert.deepEqual(requests[0].body.secretChanges, { 'relay.password': { operation: 'replace', value: { plaintext: 'test-only-value' } } })
assert.deepEqual(requests[0].body.confirmation, { confirmed: true, method: 'recent_auth' })
assert.deepEqual(requests[2].body, { generation: 9 })
assert.equal(page.secretValue.value, '')
assert.equal(page.confirmed.value, false)
assert.equal(page.savedGeneration.value, 0)
})