Smail/frontend/src/views/components/ConfigSettings/ConfigSettings.js
smail be51853815
Some checks are pending
Security Checks / security (push) Waiting to run
拆分配置独立页面并在标题状态合并前建立检查点
2026-09-05 22:31:55 +08:00

513 lines
21 KiB
JavaScript

import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { http } from '@/utils/axios'
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
const sections = [
{ value: 'base', label: '基础配置', description: '管理站点与数据库连接,敏感凭据单独填写。' },
{ value: 'smtp', label: 'SMTP 配置', description: '管理邮件发送与中继连接,密码留空时保持原值。' },
{ value: 'email', label: '邮箱策略', description: '管理邮件协议端口与账号恢复策略。' },
{ value: 'ssl', label: 'SSL 配置', description: '管理证书与加密连接,应用后请留意待重启提示。' },
{ value: 'plugins', label: '插件配置', description: '管理插件运行配置,敏感设置仅显示配置状态。' }
]
const serviceLabels = {
smtp: 'SMTP 服务',
email: '邮箱服务',
ssl: 'SSL 服务',
plugins: '插件服务'
}
const sectionLabels = Object.fromEntries(sections.map(item => [item.value, item.label]))
const operationLabels = {
validate: '验证配置',
update: '保存配置',
apply: '应用配置',
rollback_preview: '预览回滚',
rollback_ticket: '签发回滚票据',
rollback: '执行回滚'
}
export function usePage(pageKey = 'base') {
const isConfigPage = sections.some(item => item.value === pageKey)
const globalStatus = useGlobalStatusStore()
const loading = ref(false)
const availabilityError = ref('')
const working = ref('')
const backupsLoading = ref(false)
const auditLoading = ref(false)
const backupsError = ref('')
const auditError = ref('')
const status = reactive({
setupState: '',
activeGeneration: 0,
lastValidAt: '',
pendingRestart: [],
degradedReasons: []
})
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)
const savedGeneration = ref(0)
const validationResult = ref(null)
const backups = ref([])
const rollbackGeneration = ref(0)
const rollbackConfirmed = ref(false)
const rollbackPreview = ref(null)
const auditItems = ref([])
const databaseConfig = reactive({ type: '', name: '', localRef: '' })
const migrationTargetName = ref('')
const migrationPreview = ref(null)
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'))
const canAudit = computed(() => globalStatus.hasCapability('audit.read'))
const canExportAudit = computed(() => globalStatus.hasCapability('audit.export'))
const canMigrate = computed(() => globalStatus.hasCapability('database.migrate'))
const canRestart = computed(() => globalStatus.hasCapability('service.restart'))
const stateLabel = computed(() => ({ new: '未初始化', bootstrap: '初始化中', ready: '就绪', degraded: '降级' }[status.setupState] || status.setupState || '未知'))
const stateTagType = computed(() => ({ ready: 'success', degraded: 'warning', bootstrap: 'info', new: 'danger' }[status.setupState] || 'info'))
const secretField = computed(() => {
if (selectedSection.value === 'base') {
return { label: '数据库密码', description: '只在本次请求内传输,保存后仅保留密文状态。留空表示保持原值。', placeholder: '输入新数据库密码(可选)' }
}
if (selectedSection.value === 'smtp') {
return { label: 'SMTP Relay 密码', description: '只在本次请求内传输,保存后仅保留密文状态。留空表示保持原值。', placeholder: '输入新的 Relay 密码(可选)' }
}
return null
})
const sensitiveHint = computed(() => secretField.value ? '密码字段已脱敏,修改请使用下方输入框' : '当前分区不包含可编辑密文')
const notifyResponseError = (response, fallback) => {
if (!response || response.errorNo !== 0) {
throw new Error(response?.errorMsg || fallback)
}
}
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'
const serviceLabel = value => serviceLabels[value] || value
const sectionLabel = value => sectionLabels[value] || value || '-'
const operationLabel = value => operationLabels[value] || value || '-'
const migrationStatusLabels = {
previewed: '已生成预览',
created: '已创建',
copying: '复制中',
verifying: '校验中',
switching: '切换中',
succeeded: '已完成',
failed: '失败'
}
const migrationStatusLabel = value => migrationStatusLabels[value] || value || '未知'
const migrationStatusType = value => ({
previewed: 'info',
created: 'info',
copying: 'warning',
verifying: 'warning',
switching: 'warning',
succeeded: 'success',
failed: 'danger'
}[value] || 'info')
const databaseTypeLabel = value => ({ mysql: 'MySQL', postgresql: 'PostgreSQL', sqlite: 'SQLite' }[value] || value || '-')
const loadStatus = async () => {
const response = await http.get('/api/v1/config/status')
notifyResponseError(response, '配置状态读取失败')
Object.assign(status, response.data || {})
}
const loadSection = async () => {
const requestId = ++sectionRequestId
const section = selectedSection.value
sectionLoading.value = true
sectionError.value = ''
editorText.value = ''
loadedText.value = ''
secretValue.value = ''
confirmed.value = false
validationResult.value = null
savedGeneration.value = 0
try {
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)
loadedText.value = editorText.value
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 loadBackups = async () => {
if (!canRollback.value) return
backupsLoading.value = true
backupsError.value = ''
try {
const response = await http.get('/api/v1/config/backups')
notifyResponseError(response, '配置备份读取失败')
backups.value = Array.isArray(response.data) ? response.data : []
} catch (error) {
backups.value = []
rollbackGeneration.value = 0
rollbackPreview.value = null
rollbackConfirmed.value = false
backupsError.value = error.message || '配置备份读取失败'
} finally {
backupsLoading.value = false
}
}
const loadAudit = async () => {
if (!canAudit.value) return
auditLoading.value = true
auditError.value = ''
try {
const response = await http.get('/api/v1/audits/config?limit=50')
notifyResponseError(response, '配置审计读取失败')
auditItems.value = Array.isArray(response.data) ? response.data : []
} catch (error) {
auditItems.value = []
auditError.value = error.message || '配置审计读取失败'
} finally {
auditLoading.value = false
}
}
const previewDatabaseMigration = async () => {
if (!canMigrate.value || working.value || sectionError.value || !migrationTargetName.value.trim()) return
working.value = 'migration-preview'
migrationPreview.value = null
migrationRecord.value = null
migrationToken.value = ''
try {
const response = await http.post('/api/v1/database/migrations/preview', {
targetName: migrationTargetName.value.trim(),
expectedGeneration: Number(status.activeGeneration)
})
notifyResponseError(response, '数据库迁移预览失败')
migrationPreview.value = response.data?.migration || null
migrationRecord.value = migrationPreview.value
migrationToken.value = response.data?.token || ''
ElMessage.success('数据库迁移预览已生成,请确认目标名称后执行')
} catch (error) {
ElMessage.error(error.message || '数据库迁移预览失败')
} finally {
working.value = ''
}
}
const loadMigrationStatus = async () => {
if (!migrationRecord.value?.id) return
working.value = 'migration-status'
try {
const response = await http.get(`/api/v1/database/migrations/${migrationRecord.value.id}`)
notifyResponseError(response, '数据库迁移状态读取失败')
migrationRecord.value = response.data || migrationRecord.value
} catch (error) {
ElMessage.error(error.message || '数据库迁移状态读取失败')
} finally {
working.value = ''
}
}
const executeDatabaseMigration = async () => {
if (!canMigrate.value || working.value || !migrationPreview.value?.id || !migrationToken.value) return
working.value = 'migration-confirm'
try {
await ElMessageBox.confirm(
'迁移将复制数据库内容并切换配置,旧数据库会保留。确认继续吗?',
'确认执行数据库迁移',
{ type: 'warning', confirmButtonText: '继续迁移', cancelButtonText: '取消' }
)
working.value = 'migration-execute'
const response = await http.post('/api/v1/database/migrations', {
id: migrationPreview.value.id,
token: migrationToken.value
})
notifyResponseError(response, '数据库迁移执行失败')
migrationRecord.value = response.data || migrationPreview.value
migrationToken.value = ''
migrationPreview.value = null
await Promise.all([loadStatus(), loadSection()])
if (migrationRecord.value.status === 'succeeded') {
ElMessage.success('数据库迁移已完成,旧数据库仍然保留')
} else {
ElMessage.warning(`数据库迁移${migrationStatusLabel(migrationRecord.value.status)}`)
}
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message || '数据库迁移执行失败')
} finally {
working.value = ''
}
}
const previewRestart = async () => {
if (!canRestart.value || working.value) return
working.value = 'restart-preview'
try {
const response = await http.post('/api/v1/system/restart/preview', {})
notifyResponseError(response, '重启预览读取失败')
restartPreview.value = response.data || null
} catch (error) {
ElMessage.error(error.message || '重启预览读取失败')
} finally {
working.value = ''
}
}
const requestRestart = async () => {
if (!canRestart.value || working.value || !restartPreview.value?.triggerAvailable) return
working.value = 'restart-confirm'
try {
await ElMessageBox.confirm(
'服务将按固定服务管理器重启,当前页面可能会短暂断开。确认继续吗?',
'确认重启 Smail',
{ type: 'warning', confirmButtonText: '确认重启', cancelButtonText: '取消' }
)
working.value = 'restart'
const response = await http.post('/api/v1/system/restart', {})
notifyResponseError(response, '重启请求提交失败')
ElMessage.success('重启请求已提交,请等待服务恢复')
restartPreview.value = null
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message || '重启请求提交失败')
} finally {
working.value = ''
}
}
const loadAll = async () => {
loading.value = true
availabilityError.value = ''
try {
await loadStatus()
if (disposed) return
if (status.available === false) {
availabilityError.value = status.unavailableReason || '在线配置管理尚未启用'
return
}
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 {
loading.value = false
}
}
const parsePayload = () => {
try {
const payload = JSON.parse(editorText.value)
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('配置必须是 JSON 对象')
}
if (selectedSection.value === 'base') {
delete payload.databasePasswordStatus
}
if (selectedSection.value === 'smtp') {
delete payload.relayPasswordStatus
}
if (selectedSection.value === 'plugins' && payload.plugins && typeof payload.plugins === 'object') {
Object.values(payload.plugins).forEach(plugin => {
if (plugin && typeof plugin === 'object') {
delete plugin.settingsConfigured
}
})
}
return payload
} catch (error) {
throw new Error(error.message || 'JSON 格式错误')
}
}
const buildRequest = () => {
const secretChanges = {}
const value = secretValue.value.trim()
if (value && selectedSection.value === 'base') {
secretChanges['database.password'] = { operation: 'replace', value: { plaintext: value } }
}
if (value && selectedSection.value === 'smtp') {
secretChanges['relay.password'] = { operation: 'replace', value: { plaintext: value } }
}
return {
expectedGeneration: Number(status.activeGeneration),
payload: parsePayload(),
secretChanges,
confirmation: { confirmed: confirmed.value, method: 'recent_auth' }
}
}
const validateConfig = async () => {
if (!canWrite.value || working.value || sectionLoading.value || sectionError.value) return
working.value = 'validate'
try {
const request = buildRequest()
const response = await http.post(`/api/v1/config/${selectedSection.value}/validate`, request)
notifyResponseError(response, '配置验证失败')
validationResult.value = response.data || null
ElMessage[validationResult.value?.valid ? 'success' : 'warning'](validationResult.value?.valid ? '配置验证通过' : '配置验证未通过')
} catch (error) {
ElMessage.error(error.message || '配置验证失败')
} finally {
working.value = ''
}
}
const saveConfig = async () => {
if (!canWrite.value || working.value || sectionLoading.value || sectionError.value) return
working.value = 'save'
try {
const request = buildRequest()
const response = await http.put(`/api/v1/config/${selectedSection.value}`, request)
notifyResponseError(response, '配置保存失败')
savedGeneration.value = response.data?.generation || 0
validationResult.value = { valid: true, generation: savedGeneration.value, issues: [] }
await loadStatus()
ElMessage.success(`候选配置已保存,代次 ${savedGeneration.value} 等待应用`)
} catch (error) {
ElMessage.error(error.message || '配置保存失败')
} finally {
working.value = ''
}
}
const applyConfig = async () => {
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 })
notifyResponseError(response, '配置应用失败')
savedGeneration.value = 0
await Promise.all([loadStatus(), loadSection()])
ElMessage.success('配置已应用')
} catch (error) {
ElMessage.error(error.message || '配置应用失败')
} finally {
working.value = ''
}
}
const previewRollback = async () => {
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, '回滚预览失败')
rollbackPreview.value = response.data || null
ElMessage.success('回滚预览已生成,请确认变更分区')
} catch (error) {
ElMessage.error(error.message || '回滚预览失败')
} finally {
working.value = ''
}
}
const executeRollback = async () => {
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'
const ticketResponse = await http.post('/api/v1/config/rollback/ticket', {
targetGeneration: rollbackPreview.value.targetGeneration,
confirmation: { confirmed: true, method: 'recent_auth' }
})
notifyResponseError(ticketResponse, '回滚票据签发失败')
const response = await http.post('/api/v1/config/rollback', { ticket: ticketResponse.data?.ticket })
notifyResponseError(response, '配置回滚失败')
rollbackPreview.value = null
rollbackConfirmed.value = false
await Promise.all([loadStatus(), loadBackups()])
ElMessage.success('配置已回滚')
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message || '配置回滚失败')
} finally {
working.value = ''
}
}
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' })
const blob = response.data instanceof Blob ? response.data : new Blob([response.data], { type: format === 'csv' ? 'text/csv' : 'application/json' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `smail-config-audit.${format}`
anchor.click()
URL.revokeObjectURL(url)
ElMessage.success('审计记录已导出')
} catch (error) {
ElMessage.error(error.message || '审计导出失败')
} finally {
working.value = ''
}
}
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, 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, migrationToken, migrationRecord, migrationStatusLabel, migrationStatusType,
previewDatabaseMigration, loadMigrationStatus, executeDatabaseMigration, canRestart, restartPreview,
previewRestart, requestRestart, beforeLeave, refreshPage, hasChanges
}
}