优化配置页面布局与请求状态并在独立拆页前建立检查点
This commit is contained in:
parent
c37759585c
commit
7f7b1291ce
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user