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 } }