优化配置页面布局与请求状态并在独立拆页前建立检查点

This commit is contained in:
smail 2026-09-05 22:05:25 +08:00
parent c37759585c
commit 7f7b1291ce
4 changed files with 712 additions and 283 deletions

View File

@ -1,7 +1,8 @@
.config-settings {
display: grid;
gap: 24px;
padding-bottom: 28px;
gap: 20px;
padding-bottom: 16px;
--config-accent: var(--pm-primary-color);
}
.settings-header,
.panel-heading,
@ -23,11 +24,14 @@
flex-shrink: 0;
}
.settings-header {
flex-direction: column;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.settings-header .settings-desc {
margin: 0;
margin: 6px 0 0;
}
.access-tag {
flex: none;
}
.config-availability {
display: grid;
@ -39,55 +43,82 @@
}
.panel-heading h4,
.status-heading h4 {
margin-top: 5px;
font-size: 18px;
font-size: 16px;
line-height: 1.5;
}
.panel-heading h4 {
margin-top: 4px;
}
.heading-note {
margin: 5px 0 0;
color: var(--pm-text-secondary);
font-size: 12px;
line-height: 1.65;
}
.settings-desc {
max-width: 680px;
margin: 8px 0 0;
color: var(--pm-text-secondary);
font-size: 13px;
line-height: 1.65;
}
.section-eyebrow {
color: var(--pm-primary-color);
color: var(--config-accent);
font-size: 12px;
font-weight: 700;
letter-spacing: .08em;
}
.status-panel,
.editor-panel,
.rollback-panel,
.audit-panel {
padding: 20px;
.maintenance-panel {
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-lg);
border-radius: 16px;
background: var(--pm-bg-secondary);
}
.status-panel,
.editor-panel {
padding: 20px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 16px;
}
.status-item {
display: grid;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
min-width: 0;
padding: 14px;
border-radius: var(--pm-radius-sm);
padding: 12px;
border: 1px solid transparent;
border-radius: 10px;
background: var(--pm-bg-primary);
}
.status-item span {
.status-item .status-label {
color: var(--pm-text-secondary);
font-size: 13px;
font-size: 12px;
line-height: 1.5;
}
.status-item strong {
color: var(--pm-text-primary);
font-size: 14px;
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.status-wide {
grid-column: span 3;
.status-item .generation-value {
font-size: 18px;
line-height: 1.3;
font-variant-numeric: tabular-nums;
}
.status-warning {
border-color: var(--el-color-warning-light-5);
background: var(--el-color-warning-light-9);
}
.status-item :deep(.el-tag) {
max-width: 100%;
height: auto;
min-height: 24px;
white-space: normal;
overflow-wrap: anywhere;
}
.tag-list {
display: flex;
@ -109,54 +140,131 @@
grid-area: main;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 24px;
gap: 16px;
min-width: 0;
container: config-main / inline-size;
}
.config-main > section {
.config-main > * {
min-width: 0;
}
.panel-heading {
flex-wrap: wrap;
}
.selected-section-label {
color: var(--pm-primary-color);
font-size: 14px;
padding: 4px 8px;
border-radius: 6px;
background: var(--pm-bg-primary);
color: var(--pm-text-secondary);
font-size: 11px;
line-height: 1.5;
white-space: nowrap;
}
.editor-meta {
.change-workflow {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin: 18px 0;
padding: 0;
list-style: none;
}
.change-workflow li {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin: 18px 0 10px;
align-items: center;
justify-content: center;
gap: 7px;
padding: 8px 4px;
border-radius: 8px;
background: var(--pm-bg-primary);
color: var(--pm-text-secondary);
font-size: 13px;
font-size: 12px;
line-height: 1.5;
}
.step-number {
display: grid;
place-items: center;
flex: none;
width: 20px;
height: 20px;
border-radius: 50%;
color: var(--config-accent);
background: color-mix(in srgb, var(--config-accent) 10%, var(--pm-bg-secondary));
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: 300px !important;
padding: 14px;
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;
color: var(--pm-text-secondary);
font-size: 12px;
line-height: 1.6;
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-sm);
background: var(--pm-bg-primary);
}
.secret-editor {
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1fr) minmax(180px, 1fr);
align-items: center;
gap: 16px;
margin-top: 16px;
padding: 14px;
border: 1px dashed var(--pm-border-color);
border-radius: var(--pm-radius-sm);
border: 1px solid var(--pm-border-color);
border-radius: 10px;
background: var(--pm-bg-primary);
}
.secret-editor strong {
.secret-editor label {
color: var(--pm-text-primary);
font-size: 13px;
font-weight: 600;
}
.secret-editor p {
margin: 5px 0 0;
color: var(--pm-text-secondary);
font-size: 13px;
font-size: 12px;
line-height: 1.5;
}
.confirmation-check {
@ -165,6 +273,14 @@
height: auto;
align-items: flex-start;
}
.editor-footer {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--pm-border-color);
}
.editor-footer .confirmation-check {
margin-top: 0;
}
.confirmation-check :deep(.el-checkbox__label) {
white-space: normal;
overflow-wrap: anywhere;
@ -180,6 +296,86 @@
gap: 10px;
margin-top: 18px;
}
.action-row > .el-button,
.audit-actions > .el-button {
margin-left: 0;
}
.audit-actions {
margin-top: 0;
}
.maintenance-heading {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px 12px;
margin-top: 8px;
}
.maintenance-heading h4 {
margin: 0;
color: var(--pm-text-primary);
font-size: 14px;
}
.maintenance-heading p {
margin: 0;
color: var(--pm-text-secondary);
font-size: 12px;
}
.maintenance-summary {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-radius: 16px;
list-style: none;
cursor: pointer;
transition: background-color .18s ease;
}
.maintenance-summary::-webkit-details-marker {
display: none;
}
.maintenance-summary:hover {
background: var(--pm-bg-hover);
}
.maintenance-summary:focus-visible,
.backup-item:focus-visible {
outline: 2px solid var(--config-accent);
outline-offset: -3px;
}
.maintenance-copy {
display: grid;
flex: 1;
min-width: 0;
gap: 5px;
}
.maintenance-copy strong {
color: var(--pm-text-primary);
font-size: 14px;
}
.maintenance-copy > span {
color: var(--pm-text-secondary);
font-size: 12px;
line-height: 1.5;
}
.disclosure-arrow {
flex: none;
width: 7px;
height: 7px;
margin: 0 3px;
border-right: 1.5px solid var(--pm-text-secondary);
border-bottom: 1.5px solid var(--pm-text-secondary);
transform: rotate(45deg);
transition: transform .18s ease;
}
.maintenance-panel[open] > .maintenance-summary {
border-bottom: 1px solid var(--pm-border-color);
border-radius: 16px 16px 0 0;
}
.maintenance-panel[open] .disclosure-arrow {
transform: rotate(225deg);
}
.maintenance-body {
padding: 18px 20px 20px;
}
.backup-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
@ -220,12 +416,12 @@
background: color-mix(in srgb, var(--pm-primary-color) 8%, var(--pm-bg-primary));
}
.result-success {
color: #166534;
background: #f0fdf4;
color: var(--el-color-success-dark-2);
background: var(--el-color-success-light-9);
}
.result-error {
color: #991b1b;
background: #fef2f2;
color: var(--el-color-danger-dark-2);
background: var(--el-color-danger-light-9);
}
.result-box ul {
margin: 0;
@ -235,6 +431,9 @@
display: grid;
gap: 10px;
margin-top: 18px;
max-height: 360px;
overflow-y: auto;
overscroll-behavior: contain;
}
.audit-item {
display: flex;
@ -249,6 +448,7 @@
display: grid;
gap: 4px;
min-width: 0;
overflow-wrap: anywhere;
}
.audit-item strong {
color: var(--pm-text-primary);
@ -261,26 +461,47 @@
.audit-item time {
flex: none;
}
@container settings-content (max-width: 560px) {
@container config-main (max-width: 620px) {
.status-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.secret-editor {
grid-template-columns: minmax(0, 1fr);
}
}
@container config-main (max-width: 400px) {
.status-panel,
.editor-panel,
.rollback-panel,
.audit-panel {
.editor-panel {
padding: 16px;
}
.status-grid {
grid-template-columns: 1fr;
.maintenance-summary,
.maintenance-body {
padding: 14px;
}
.status-wide {
grid-column: auto;
.maintenance-summary {
flex-wrap: wrap;
}
.settings-header,
.panel-heading,
.status-heading,
.audit-item {
align-items: stretch;
flex-direction: column;
}
.change-workflow li {
flex-direction: column;
gap: 5px;
font-size: 11px;
}
.action-row .el-button,
.audit-actions .el-button {
flex: 1;
min-width: 110px;
}
}
@container settings-content (max-width: 560px) {
.settings-header {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
.config-workspace {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
@ -291,15 +512,11 @@
.selected-section-label {
white-space: normal;
}
.action-row .el-button,
.audit-actions .el-button {
flex: 1;
min-width: 120px;
}
}
.panel-desc {
margin: 14px 0 0;
margin: 0;
color: var(--pm-text-secondary);
font-size: 13px;
line-height: 1.6;
}
.migration-source {
@ -336,7 +553,7 @@
gap: 12px;
}
.migration-error {
color: #b42318;
color: var(--el-color-danger);
}
.restart-services {
margin-top: 18px;
@ -344,7 +561,7 @@
.restart-result {
margin-top: 16px;
}
@media (max-width: 640px) {
@container config-main (max-width: 400px) {
.migration-source {
grid-template-columns: 1fr;
}
@ -353,3 +570,15 @@
flex-direction: column;
}
}
@media (prefers-color-scheme: dark) {
.config-settings {
--config-accent: var(--pm-primary-light-3);
}
}
@media (prefers-reduced-motion: reduce) {
.editor-shell,
.maintenance-summary,
.disclosure-arrow {
transition: none;
}
}

View File

@ -4,11 +4,11 @@ import { http } from '@/utils/axios'
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
const sections = [
{ value: 'base', label: '基础配置' },
{ value: 'smtp', label: 'SMTP 配置' },
{ value: 'email', label: '邮箱策略' },
{ value: 'ssl', label: 'SSL 配置' },
{ value: 'plugins', label: '插件配置' }
{ value: 'base', label: '基础配置', description: '管理站点与数据库连接,敏感凭据单独填写。' },
{ value: 'smtp', label: 'SMTP 配置', description: '管理邮件发送与中继连接,密码留空时保持原值。' },
{ value: 'email', label: '邮箱策略', description: '管理邮件协议端口与账号恢复策略。' },
{ value: 'ssl', label: 'SSL 配置', description: '管理证书与加密连接,应用后请留意待重启提示。' },
{ value: 'plugins', label: '插件配置', description: '管理插件运行配置,敏感设置仅显示配置状态。' }
]
const serviceLabels = {
@ -45,6 +45,9 @@ export function usePage() {
degradedReasons: []
})
const selectedSection = ref('base')
const currentSection = computed(() => sections.find(item => item.value === selectedSection.value) || sections[0])
const sectionLoading = ref(false)
let sectionRequestId = 0
const editorText = ref('')
const sectionError = ref('')
const secretValue = ref('')
@ -119,29 +122,38 @@ export function usePage() {
}
const loadSection = async () => {
const requestId = ++sectionRequestId
const section = selectedSection.value
sectionLoading.value = true
sectionError.value = ''
editorText.value = ''
secretValue.value = ''
confirmed.value = false
validationResult.value = null
savedGeneration.value = 0
try {
const response = await http.get(`/api/v1/config/${selectedSection.value}`)
const response = await http.get(`/api/v1/config/${section}`)
if (requestId !== sectionRequestId) return
notifyResponseError(response, '配置分区读取失败')
const publicConfig = response.data || {}
editorText.value = JSON.stringify(publicConfig, null, 2)
if (selectedSection.value === 'base') {
if (section === 'base') {
const database = publicConfig.database || {}
Object.assign(databaseConfig, { type: database.type || '', name: database.name || '', localRef: database.localRef || '' })
}
} catch (error) {
if (requestId !== sectionRequestId) return
sectionError.value = error.message || '配置分区读取失败'
editorText.value = ''
} finally {
if (requestId === sectionRequestId) sectionLoading.value = false
}
}
const selectSection = value => {
if (selectedSection.value === value) return
if (selectedSection.value === value || working.value || !sections.some(item => item.value === value)) return
selectedSection.value = value
loadSection()
return loadSection()
}
const loadBackups = async () => {
@ -448,13 +460,13 @@ export function usePage() {
return {
loading, availabilityError, working, status, stateLabel, stateTagType, sectionOptions: sections,
selectedSection, selectSection, editorText, sectionError, sensitiveHint, secretField, secretValue,
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, loadAll, loadSection, validateConfig, saveConfig, applyConfig,
loadBackups, previewRollback, executeRollback, loadAudit, exportAudit, formatTime,
serviceLabel, sectionLabel, operationLabel, databaseConfig, databaseTypeLabel, canMigrate,
migrationTargetName, migrationPreview, migrationRecord, migrationStatusLabel, migrationStatusType,
migrationTargetName, migrationPreview, migrationToken, migrationRecord, migrationStatusLabel, migrationStatusType,
previewDatabaseMigration, loadMigrationStatus, executeDatabaseMigration, canRestart, restartPreview,
previewRestart, requestRestart
}

View File

@ -1,8 +1,13 @@
<template>
<div class="config-settings" v-loading="loading">
<div class="settings-header">
<h3>配置管理</h3>
<p class="settings-desc">按配置分区安全编辑、验证、应用和回滚,敏感凭据只显示状态,不会回显明文。</p>
<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
@ -32,32 +37,34 @@
<section class="status-panel">
<div class="status-heading">
<div>
<span class="section-eyebrow">运行状态</span>
<h4>配置快照</h4>
<p class="heading-note">当前运行状态与生效版本</p>
</div>
<el-button size="small" plain :loading="loading" @click="loadAll">刷新</el-button>
<el-button size="small" plain :loading="loading" :disabled="!!working || sectionLoading" @click="loadAll">
刷新状态
</el-button>
</div>
<div class="status-grid">
<div class="status-item">
<span>状态</span>
<el-tag :type="stateTagType">{{ stateLabel }}</el-tag>
<span class="status-label">运行状态</span>
<el-tag :type="stateTagType" effect="light">{{ stateLabel }}</el-tag>
</div>
<div class="status-item">
<span>当前生效代次</span>
<strong>{{ status.activeGeneration || '-' }}</strong>
<span class="status-label">当前生效代次</span>
<strong class="generation-value">{{ status.activeGeneration || '-' }}</strong>
</div>
<div class="status-item">
<span>最近校验时间</span>
<span class="status-label">最近校验时间</span>
<strong>{{ formatTime(status.lastValidAt) }}</strong>
</div>
<div class="status-item status-wide">
<span>待重启服务</span>
<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>
<strong v-else>无需重启</strong>
</div>
</div>
<el-alert
@ -71,213 +78,284 @@
/>
</section>
<section class="editor-panel">
<section class="editor-panel" :aria-busy="sectionLoading">
<div class="panel-heading">
<div>
<span class="section-eyebrow">分区配置</span>
<h4>编辑候选配置</h4>
<h4>{{ currentSection.label }}</h4>
<p class="heading-note">{{ currentSection.description }}</p>
</div>
<strong class="selected-section-label">{{ sectionOptions.find(item => item.value === selectedSection)?.label }}</strong>
<span class="selected-section-label">{{ canWrite ? '候选配置编辑' : '配置查看' }}</span>
</div>
<el-alert
v-if="sectionError"
:title="sectionError"
type="error"
:closable="false"
show-icon
class="config-alert"
/>
<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>
<div class="editor-meta">
<span>编辑代次:{{ status.activeGeneration || '-' }}</span>
<span>敏感字段:{{ sensitiveHint }}</span>
</div>
<el-input
v-model="editorText"
type="textarea"
:rows="16"
resize="vertical"
spellcheck="false"
class="config-editor"
:readonly="!canWrite"
aria-label="配置 JSON 编辑器"
/>
<div v-if="secretField" class="secret-editor">
<div>
<strong>{{ secretField.label }}</strong>
<p>{{ secretField.description }}</p>
</div>
<el-input
v-model="secretValue"
type="password"
show-password
clearable
:disabled="!canWrite"
:placeholder="secretField.placeholder"
autocomplete="new-password"
/>
</div>
<el-checkbox v-if="canWrite" v-model="confirmed" class="confirmation-check">
我已确认这是受控变更,并允许使用最近认证完成高风险配置操作
</el-checkbox>
<div class="action-row">
<el-button :loading="working === 'validate'" :disabled="!canWrite" @click="validateConfig">只验证</el-button>
<el-button type="primary" :loading="working === 'save'" :disabled="!canWrite" @click="saveConfig">保存候选配置</el-button>
<el-button type="success" plain :loading="working === 'apply'" :disabled="!canWrite || !savedGeneration" @click="applyConfig">
应用 {{ savedGeneration ? `代次 ${savedGeneration}` : '' }}
</el-button>
</div>
<div v-if="validationResult" class="result-box" :class="validationResult.valid ? 'result-success' : 'result-error'">
<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>
<section v-if="canMigrate" class="migration-panel">
<div class="panel-heading">
<div>
<span class="section-eyebrow">数据库迁移</span>
<h4>安全迁移数据库</h4>
</div>
<el-tag type="warning" effect="plain">仅允许修改数据库名称</el-tag>
</div>
<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-alert
v-if="sectionError"
:title="sectionError"
type="error"
:closable="false"
show-icon
class="config-alert"
/>
</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>
</section>
<section v-if="canRestart && status.pendingRestart?.length" class="restart-panel">
<div class="panel-heading">
<div>
<span class="section-eyebrow">受控重启</span>
<h4>应用待重启服务</h4>
</div>
<el-tag type="warning" effect="plain">固定目标:Smail</el-tag>
</div>
<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>
</section>
<section v-if="canRollback" class="rollback-panel">
<div class="panel-heading">
<div>
<span class="section-eyebrow">受控回滚</span>
<h4>备份与回滚</h4>
</div>
<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 }"
@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>
</section>
<section v-if="canAudit" class="audit-panel">
<div class="panel-heading">
<div>
<span class="section-eyebrow">审计记录</span>
<h4>配置操作审计</h4>
</div>
<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 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>
<time>{{ formatTime(item.createdAt) }}</time>
</div>
</div>
<el-empty v-else-if="!auditLoading" description="暂无配置审计记录" :image-size="72" />
</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>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { usePage } from './ConfigSettings.js'
import SettingsSubmenu from '../SettingsSubmenu/SettingsSubmenu.vue'
@ -290,6 +368,8 @@ const {
stateTagType,
sectionOptions,
selectedSection,
currentSection,
sectionLoading,
selectSection,
editorText,
sectionError,
@ -342,8 +422,6 @@ const {
sectionLabel,
operationLabel
} = usePage()
void computed
</script>
<style scoped src="./ConfigSettings.css"></style>

View File

@ -2,6 +2,7 @@ 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'
import { computed, reactive, ref } from './vue.mjs'
const source = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigSettings.js', import.meta.url), 'utf8')
@ -17,6 +18,13 @@ function fixture(get) {
return { page, messages }
}
function deferred() {
let resolve
let reject
const promise = new Promise((success, failure) => { resolve = success; reject = failure })
return { promise, resolve, reject }
}
test('旧版配置仅请求状态,不并发请求分区、备份和审计', async () => {
const requests = []
const { page, messages } = fixture(async path => {
@ -86,3 +94,105 @@ test('状态请求失败后可重试恢复,不保留不可用提示', async ()
await page.loadAll()
assert.equal(page.availabilityError.value, '')
})
test('快速切换分区时丢弃较慢的旧响应,并清除旧编辑内容与确认状态', async () => {
const base = deferred()
const email = deferred()
const { page } = fixture(path => path.endsWith('/base') ? base.promise : email.promise)
page.editorText.value = '{"old":true}'
page.confirmed.value = true
page.secretValue.value = 'test-only-value'
const baseRequest = page.loadSection()
assert.equal(page.editorText.value, '')
assert.equal(page.confirmed.value, false)
assert.equal(page.secretValue.value, '')
assert.equal(page.sectionLoading.value, true)
const emailRequest = page.selectSection('email')
email.resolve({ errorNo: 0, data: { policy: { smtpPort: 25 } } })
await emailRequest
assert.equal(page.currentSection.value.label, '邮箱策略')
assert.equal(page.sectionLoading.value, false)
base.resolve({ errorNo: 0, data: { database: { type: 'sqlite' } } })
await baseRequest
assert.deepEqual(JSON.parse(page.editorText.value), { policy: { smtpPort: 25 } })
assert.equal(page.databaseConfig.type, '')
})
test('过期请求失败不清除新分区的加载状态或覆盖错误信息', async () => {
const base = deferred()
const ssl = deferred()
const { page } = fixture(path => path.endsWith('/base') ? base.promise : ssl.promise)
const baseRequest = page.loadSection()
const sslRequest = page.selectSection('ssl')
base.reject(new Error('旧分区读取失败'))
await baseRequest
assert.equal(page.sectionLoading.value, true)
assert.equal(page.sectionError.value, '')
ssl.resolve({ errorNo: 0, data: { enabled: true } })
await sslRequest
assert.equal(page.sectionLoading.value, false)
assert.equal(page.sectionError.value, '')
assert.deepEqual(JSON.parse(page.editorText.value), { enabled: true })
})
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]/)
}
await page.selectSection('plugins')
assert.deepEqual(requests, ['/api/v1/config/plugins'])
assert.equal(page.currentSection.value.label, '插件配置')
})
test('迁移预览票据返回模板,迁移执行取消时不发送写入请求', async () => {
const requests = []
const page = createPage(computed, () => {}, reactive, ref,
{ success: () => {}, error: () => assert.fail('不应显示错误') },
{ confirm: async () => { throw 'cancel' } },
{ post: async (path, payload) => {
requests.push({ path, payload })
return { errorNo: 0, data: { migration: { id: 'test-migration', status: 'previewed' }, token: 'test-only-ticket' } }
} }, () => ({ hasCapability: () => true }))
page.status.activeGeneration = 3
page.migrationTargetName.value = ' test-next.db '
assert.equal(page.migrationToken.value, '')
await page.previewDatabaseMigration()
assert.equal(page.migrationToken.value, 'test-only-ticket')
assert.equal(page.migrationPreview.value.id, 'test-migration')
await page.executeDatabaseMigration()
assert.deepEqual(requests, [{
path: '/api/v1/database/migrations/preview',
payload: { targetName: 'test-next.db', expectedGeneration: 3 }
}])
})
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 css = readFileSync(new URL('ConfigSettings.css', root), 'utf8')
const { descriptor, errors } = parse(template)
assert.deepEqual(errors, [])
assert.doesNotThrow(() => compileScript(descriptor, { id: 'config-layout', inlineTemplate: true }))
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.match(css, /@container config-main \(max-width: 620px\)/)
assert.match(css, /\.maintenance-summary:focus-visible/)
assert.doesNotMatch(template, /&#x[0-9a-f]+;/i)
})