This commit is contained in:
parent
7f7b1291ce
commit
be51853815
@ -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>
|
||||
@ -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>
|
||||
@ -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 }
|
||||
}
|
||||
@ -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>
|
||||
@ -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 }
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
60
test/frontend/config-navigation.test.mjs
Normal file
60
test/frontend/config-navigation.test.mjs
Normal 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')
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user