Smail/frontend/src/views/components/ConfigSettings/ConfigSettings.js
smail 20a6eea3b3
Some checks are pending
Security Checks / security (push) Waiting to run
补齐邮件协议隔离回归并优化客户端设置
2026-09-06 22:28:20 +08:00

752 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { http } from '@/utils/axios'
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
import { readFormConfig, updateFormConfig, validateFormConfig } from './ConfigForm/ConfigForm.js'
const sections = [
{ value: 'base', label: '基础配置', description: '管理站点与数据库连接,敏感凭据单独填写。' },
{ value: 'smtp', label: 'SMTP 配置', description: '管理本机监听与发信中继;监听端口变更需应用配置并重启后端。' },
{ value: 'imap', label: 'IMAP 配置', description: '管理 IMAP 隐式 TLS 监听;端口变更需应用配置并重启后端。' },
{ value: 'pop3', label: 'POP3 配置', description: '管理 POP3 的 STLS 与隐式 TLS 监听;端口变更需应用配置并重启后端。' },
{ value: 'email', label: '邮箱策略', description: '自动汇总三个协议的配置端口,管理邮件与账号恢复策略。' },
{ value: 'ssl', label: 'SSL 配置', description: '管理证书与加密连接,应用后请留意待重启提示。' },
{ value: 'plugins', label: '插件配置', description: '管理插件运行配置,敏感设置仅显示配置状态。' },
{ value: 'spam_block', label: '垃圾邮件拦截', description: '配置垃圾邮件检测服务、超时和拦截阈值,仅编辑当前插件。' },
{ value: 'telegram_push', label: 'Telegram 推送', description: '配置 Telegram 推送的运行参数与网络权限,个人绑定在扩展功能中管理。' },
{ value: 'wechat_push', label: '微信推送', description: '配置微信推送的应用、模板与接收用户,敏感凭据不回填。' }
]
const serviceLabels = {
database: '数据库服务',
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: '',
tlsMode: '',
activeGeneration: 0,
lastValidAt: '',
pendingRestart: [],
degradedReasons: []
})
const selectedSection = ref(isConfigPage ? pageKey : 'base')
// 独立页面复用原配置分区的原子保存与候选保护,不丢弃其他页面的字段。
const pluginPages = ['spam_block', 'telegram_push', 'wechat_push']
const apiSection = () => pluginPages.includes(selectedSection.value) ? 'plugins'
: ['imap', 'pop3'].includes(selectedSection.value) ? 'email' : selectedSection.value
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 formConfig = computed(() => readFormConfig(editorText.value))
const formErrors = ref({})
const loadedText = ref('')
const smtpListener = ref(null)
const mailPortSummary = computed(() => {
if (selectedSection.value !== 'email' || !loadedText.value || !smtpListener.value) return null
const email = readFormConfig(loadedText.value)
return [
{ name: 'SMTP', ports: [
{ label: '收信 / STARTTLS', value: smtpListener.value.port },
{ label: '提交 / STARTTLS', value: smtpListener.value.starttlsPort },
{ label: '隐式 TLS', value: smtpListener.value.tlsPort }
] },
{ name: 'IMAP', ports: [{ label: '隐式 TLS', value: email.imap?.tlsPort }] },
{ name: 'POP3', ports: [
{ label: 'STLS', value: email.pop3?.port },
{ label: '隐式 TLS', value: email.pop3?.tlsPort }
] }
]
})
const sectionError = ref('')
const secretValue = ref('')
const confirmed = ref(false)
const savedGeneration = ref(0)
const pendingCandidate = ref(null)
const pendingScopeHint = computed(() => {
const pending = pendingCandidate.value
if (pending?.section === 'plugins' && apiSection() === 'plugins') {
const active = readFormConfig(loadedText.value).plugins || {}
const candidate = pending.payload?.plugins || {}
const changed = Object.keys({ ...active, ...candidate }).filter(id =>
['enabled', 'executable', 'permissions', 'settings'].some(key =>
JSON.stringify(active[id]?.[key]) !== JSON.stringify(candidate[id]?.[key]))
).map(id => sections.find(item => item.value === id)?.label || id)
const scope = pending.payload && loadedText.value
? (changed.length ? `本次候选涉及:${changed.join('、')}。` : '本次候选的插件运行配置未变化。')
: '候选内容尚未完整读取,请刷新后核对。'
return `${scope}三个插件共用一个配置分区,请在对应三级菜单核对;应用时整个候选一起生效,其他插件的原有配置会保留。`
}
if (pending?.section !== 'email' || apiSection() !== 'email') return ''
const active = readFormConfig(loadedText.value)
const candidate = pending.payload || {}
const changed = [
['imap', 'IMAP 配置'],
['pop3', 'POP3 配置'],
['policy', '邮箱策略']
].filter(([key]) => {
const before = active[key] || {}
const after = candidate[key] || {}
return Object.keys({ ...before, ...after }).some(field => JSON.stringify(before[field]) !== JSON.stringify(after[field]))
}).map(([, label]) => label)
const scope = pending.payload && loadedText.value
? (changed.length ? `本次候选涉及:${changed.join('、')}。` : '本次候选的协议端口和邮箱策略未变化。')
: '候选内容尚未完整读取,请刷新后核对。'
return `${scope}IMAP、POP3 和邮箱策略共用一个配置分区,请逐页核对;应用时会一起生效,监听端口仍需重启后端。`
})
let savedInput = null
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 migrationTarget = reactive({ type: 'sqlite', host: '127.0.0.1', port: 3306, username: '', password: '' })
const migrationError = ref('')
let migrationInput = ''
const migrationPreview = ref(null)
const migrationToken = ref('')
const migrationRecord = ref(null)
const invalidateMigration = () => {
migrationPreview.value = null
migrationToken.value = ''
migrationInput = ''
migrationError.value = ''
}
const changeMigrationType = type => {
migrationTarget.type = type
migrationTarget.port = type === 'postgresql' ? 5432 : 3306
migrationTarget.password = ''
migrationTargetName.value = ''
invalidateMigration()
}
const migrationPayload = () => migrationTarget.type === 'sqlite'
? { type: 'sqlite', localRef: migrationTargetName.value.trim() }
: {
type: migrationTarget.type,
host: migrationTarget.host.trim(),
port: Number(migrationTarget.port),
username: migrationTarget.username.trim(),
name: migrationTargetName.value.trim(),
password: { plaintext: migrationTarget.password }
}
const migrationReady = computed(() => {
if (!migrationTargetName.value.trim() || status.pendingRestart?.length) return false
if (migrationTarget.type === 'sqlite') return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(migrationTargetName.value.trim())
return ['mysql', 'postgresql'].includes(migrationTarget.type) &&
/^[A-Za-z][A-Za-z0-9_-]{0,62}$/.test(migrationTargetName.value.trim()) &&
!!migrationTarget.host.trim() && !!migrationTarget.username.trim() && !!migrationTarget.password &&
Number.isInteger(Number(migrationTarget.port)) && Number(migrationTarget.port) > 0 && Number(migrationTarget.port) <= 65535
})
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' && ['mysql', 'postgresql'].includes(formConfig.value.database?.type)) {
return { label: '数据库密码', description: '只在本次请求内传输,保存后仅保留密文状态。留空表示保持原值。', placeholder: '输入新数据库密码(可选)' }
}
if (selectedSection.value === 'smtp') {
return { label: 'SMTP Relay 密码', description: '只在本次请求内传输,保存后仅保留密文状态。留空表示保持原值。', placeholder: '输入新的 Relay 密码(可选)' }
}
return null
})
const sensitiveHint = computed(() => secretField.value ? '密码不会回填,修改请使用下方独立输入框,留空保持原值' : '系统内部字段自动保留,无需手工填写')
const invalidateCandidate = () => {
savedGeneration.value = 0
savedInput = null
validationResult.value = null
confirmed.value = false
}
const canOperate = () => !disposed && canWrite.value && !loading.value && !working.value && !sectionLoading.value && !sectionError.value && !availabilityError.value
const canEdit = () => canOperate() && !pendingCandidate.value && !savedGeneration.value
const updateField = (path, value) => {
if (!canEdit()) return
const updated = updateFormConfig(editorText.value, selectedSection.value, path, value)
if (updated === editorText.value) return
editorText.value = updated
if (Object.keys(formErrors.value).length) formErrors.value = validateFormConfig(selectedSection.value, formConfig.value)
invalidateCandidate()
}
const updateSecret = value => {
if (!canEdit() || !secretField.value || secretValue.value === value) return
secretValue.value = value
invalidateCandidate()
}
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 loadPending = async () => {
const response = await http.get('/api/v1/config/pending')
if (disposed) return
notifyResponseError(response, '候选配置读取失败')
pendingCandidate.value = response.data || null
savedGeneration.value = 0
savedInput = null
const pending = pendingCandidate.value
const section = pending?.section === 'root' ? 'base' : pending?.section
if (pending && section === apiSection()) {
editorText.value = JSON.stringify(pending.payload, null, 2)
secretValue.value = ''
savedInput = { text: editorText.value, secret: '' }
savedGeneration.value = pending.sameSession ? Number(pending.generation) : 0
}
}
const managePending = async action => {
if (!canOperate() || !pendingCandidate.value) return
working.value = action
try {
await ElMessageBox.confirm(
action === 'cancel' ? '取消后保留当前生效配置,候选修改不会应用。确认取消吗?' : '恢复后将候选配置绑定到当前会话,请确认已核对候选内容。',
action === 'cancel' ? '取消候选配置' : '恢复候选配置',
{ type: 'warning', confirmButtonText: '确认', cancelButtonText: '返回' }
)
const response = await http.post('/api/v1/config/pending', {
generation: pendingCandidate.value.generation,
candidateId: pendingCandidate.value.candidateId,
action,
confirmation: { confirmed: confirmed.value, method: 'recent_auth' }
})
notifyResponseError(response, '候选配置操作失败')
await loadStatus()
await loadSection()
await loadPending()
ElMessage.success(action === 'cancel' ? '候选已取消,可以继续编辑' : '候选已恢复,请核对后应用')
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message || '候选配置操作失败')
} finally {
working.value = ''
}
}
const loadSection = async () => {
const requestId = ++sectionRequestId
const section = apiSection()
sectionLoading.value = true
sectionError.value = ''
formErrors.value = {}
editorText.value = ''
loadedText.value = ''
smtpListener.value = null
secretValue.value = ''
confirmed.value = false
validationResult.value = null
savedGeneration.value = 0
savedInput = null
try {
const response = await http.get(`/api/v1/config/${section}`)
if (requestId !== sectionRequestId) return
notifyResponseError(response, '配置分区读取失败')
const publicConfig = response.data || {}
if (selectedSection.value === 'email') {
const smtpResponse = await http.get('/api/v1/config/smtp')
if (requestId !== sectionRequestId) return
notifyResponseError(smtpResponse, 'SMTP 监听配置读取失败')
// 不拼接不同代次的配置,也不在读取失败时显示旧端口。
if (smtpResponse.data?.meta?.generation !== publicConfig.meta?.generation) {
throw new Error('配置代次已变化,请刷新后查看最新端口')
}
smtpListener.value = smtpResponse.data?.listener || null
}
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 || !migrationReady.value) return
working.value = 'migration-preview'
migrationPreview.value = null
migrationRecord.value = null
migrationToken.value = ''
migrationError.value = ''
try {
const target = migrationPayload()
const response = await http.post('/api/v1/database/migrations/preview', {
target,
expectedGeneration: Number(status.activeGeneration)
})
if (disposed) return
notifyResponseError(response, '数据库迁移预览失败')
migrationPreview.value = response.data?.migration || null
migrationRecord.value = migrationPreview.value
migrationToken.value = response.data?.token || ''
migrationInput = JSON.stringify(target)
ElMessage.success('目标连接验证通过,请核对数据库类型和连接后执行')
} catch (error) {
migrationError.value = error.message || '数据库迁移预览失败'
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
if (migrationInput !== JSON.stringify(migrationPayload())) {
invalidateMigration()
migrationError.value = '目标连接已修改,请重新生成迁移预览'
return
}
working.value = 'migration-confirm'
try {
await ElMessageBox.confirm(
`将迁移到 ${databaseTypeLabel(migrationPreview.value.target?.type)}:${migrationPreview.value.target?.name || migrationPreview.value.target?.localRef || migrationTargetName.value.trim()}。迁移期间暂停写入,成功后必须立即重启后端。旧数据库及失败目标均保留。确认继续吗?`,
'确认执行数据库迁移',
{ type: 'warning', confirmButtonText: '继续迁移', cancelButtonText: '取消' }
)
working.value = 'migration-execute'
const response = await http.post('/api/v1/database/migrations', {
id: migrationPreview.value.id,
token: migrationToken.value
}, { timeout: 11 * 60 * 1000 })
notifyResponseError(response, '数据库迁移执行失败')
migrationRecord.value = response.data || migrationPreview.value
migrationToken.value = ''
migrationPreview.value = null
migrationTarget.password = ''
migrationInput = ''
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') {
migrationToken.value = ''
migrationPreview.value = null
migrationTarget.password = ''
migrationInput = ''
migrationError.value = error.message || '数据库迁移执行失败'
await loadMigrationStatus()
ElMessage.error(migrationError.value)
}
} 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 () => {
invalidateMigration()
migrationTarget.password = ''
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 (isConfigPage) await loadPending()
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('配置内容不可用,请刷新页面后重试')
}
if (selectedSection.value === 'base') {
delete payload.databasePasswordStatus
}
if (selectedSection.value === 'smtp') {
delete payload.relayPasswordStatus
}
if (apiSection() === 'plugins' && payload.plugins && typeof payload.plugins === 'object') {
Object.values(payload.plugins).forEach(plugin => {
if (plugin && typeof plugin === 'object') {
delete plugin.settingsConfigured
delete plugin.secretStatus
}
})
}
return payload
} catch (error) {
throw new Error('配置内容不可用,请刷新页面后重试')
}
}
const buildRequest = () => {
const secretChanges = {}
const value = secretValue.value
if (value && selectedSection.value === 'base' && secretField.value) {
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 checkForm = () => {
formErrors.value = validateFormConfig(selectedSection.value, formConfig.value)
const issues = Object.entries(formErrors.value).map(([code, message]) => ({ code, message }))
if (!issues.length) return true
validationResult.value = { valid: false, issues }
ElMessage.warning('请先检查表单中标红的字段')
return false
}
const validateConfig = async () => {
if (!canEdit() || !checkForm()) return
working.value = 'validate'
try {
const request = buildRequest()
const response = await http.post(`/api/v1/config/${apiSection()}/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 (!canEdit() || !checkForm()) return
working.value = 'save'
try {
const request = buildRequest()
const response = await http.put(`/api/v1/config/${apiSection()}`, request)
notifyResponseError(response, '配置保存失败')
savedGeneration.value = response.data?.generation || 0
pendingCandidate.value = { ...response.data, section: apiSection(), sameSession: true }
savedInput = { text: editorText.value, secret: secretValue.value }
validationResult.value = { valid: true, generation: savedGeneration.value, issues: [] }
await loadStatus()
await loadPending()
ElMessage.success(`候选配置已保存,代次 ${savedGeneration.value} 等待应用`)
} catch (error) {
ElMessage.error(error.message || '配置保存失败')
} finally {
working.value = ''
}
}
const applyConfig = async () => {
if (!canOperate() || !savedGeneration.value) return
if (!savedInput || savedInput.text !== editorText.value || savedInput.secret !== secretValue.value) {
invalidateCandidate()
ElMessage.warning('填写内容已变更,请重新保存候选配置后再应用')
return
}
working.value = 'apply'
try {
const response = await http.post(`/api/v1/config/${apiSection()}/apply`, {
generation: savedGeneration.value,
candidateId: pendingCandidate.value?.candidateId
})
notifyResponseError(response, '配置应用失败')
savedGeneration.value = 0
await Promise.all([loadStatus(), loadSection()])
await loadPending()
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' })
if (!(response instanceof Blob)) {
throw new Error(response?.errorMsg || '审计导出响应格式不正确')
}
// Blob 请求也可能返回 JSON 业务错误,不能把错误响应当作审计文件下载。
if (response.type.includes('json') || format === 'json') {
const payload = JSON.parse(await response.text())
if (payload && !Array.isArray(payload) && Object.hasOwn(payload, 'errorNo') && payload.errorNo !== 0) {
throw new Error(payload.errorMsg || '审计导出失败')
}
}
const blob = response
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 = ''
savedInput = null
confirmed.value = false
migrationToken.value = ''
migrationTarget.password = ''
migrationInput = ''
})
return {
mailPortSummary,
pendingScopeHint,
loading, availabilityError, working, status, stateLabel, stateTagType, sectionOptions: sections,
selectedSection, currentSection, sectionLoading, editorText, formConfig, formErrors, updateField, updateSecret,
sectionError, sensitiveHint, secretField, secretValue,
confirmed, savedGeneration, pendingCandidate, managePending, 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,
migrationTarget, migrationReady, migrationError, invalidateMigration, changeMigrationType,
previewDatabaseMigration, loadMigrationStatus, executeDatabaseMigration, canRestart, restartPreview,
previewRestart, requestRestart, beforeLeave, refreshPage, hasChanges
}
}