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 { isRecentAuthenticationCancelled, requestWithRecentAuthentication } from '@/utils/recentAuthentication' import { readFormConfig, updateFormConfig, validateFormConfig } from './ConfigForm/ConfigForm.js' const sections = [ { value: 'base', label: '基础配置' }, { value: 'mail', label: '邮件服务' }, { value: 'mail_policy', label: '邮件策略' }, { value: 'security_policy', label: '安全策略' }, { value: 'ssl', label: 'SSL 配置' }, { value: 'plugins', label: '插件配置' }, { value: 'spam_block', label: '垃圾邮件拦截' }, { value: 'telegram_push', label: 'Telegram 推送' }, { value: 'wechat_push', label: '微信推送' } ] 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' : ['mail_policy', 'security_policy'].includes(selectedSection.value) ? 'mail' : 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 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 !== 'mail' || apiSection() !== 'mail') return '' const active = readFormConfig(loadedText.value) const candidate = pending.payload || {} const changed = [ ['listener', 'SMTP 监听'], ['imap', 'IMAP 监听'], ['pop3', 'POP3 监听'], ['relay', 'SMTP 中继'], ['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}邮件服务、邮件策略和安全策略共用一组运行配置;完成保存时全部变更一起生效,监听端口变更仍需重启后端。` }) 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: '输入新数据库密码(可选)' } } 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 false working.value = action try { if (action === 'cancel') { await ElMessageBox.confirm( '放弃后会保留当前生效配置,本次修改不会应用。', '放弃本次更改', { type: 'warning', confirmButtonText: '放弃更改', cancelButtonText: '返回' } ) } const response = await http.post('/api/v1/config/pending', { generation: pendingCandidate.value.generation, candidateId: pendingCandidate.value.candidateId, action, confirmation: { confirmed: true, method: 'recent_auth' } }) notifyResponseError(response, '未完成的配置更改处理失败') await loadStatus() await loadSection() await loadPending() if (action === 'cancel') ElMessage.success('本次更改已放弃') return true } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message || '未完成的配置更改处理失败') return false } finally { working.value = '' } } const loadSection = async ({ minimumGeneration = 0 } = {}) => { const requestId = ++sectionRequestId const section = apiSection() sectionLoading.value = true sectionError.value = '' formErrors.value = {} editorText.value = '' loadedText.value = '' 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 || disposed) return false notifyResponseError(response, '配置分区读取失败') const publicConfig = response.data || {} const generation = Number(section === 'base' ? publicConfig.generation : publicConfig.meta?.generation) if (minimumGeneration && (!Number.isSafeInteger(generation) || generation < minimumGeneration)) { throw new Error('读取到的配置版本落后于本次保存,请刷新状态核对') } 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 || '' }) } return true } catch (error) { if (requestId !== sectionRequestId || disposed) return false sectionError.value = error.message || '配置分区读取失败' editorText.value = '' return false } 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 (apiSection() === 'mail') { delete payload.relayPasswordStatus if (payload.relay && 'password' in payload.relay) { delete payload.relay.password if (Object.keys(payload.relay).length === 0) { delete payload.relay } } } 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 } } } const relayPassword = formConfig.value?.relay?.password if (relayPassword && selectedSection.value === 'mail') { secretChanges['relay.password'] = { operation: 'replace', value: { plaintext: relayPassword } } } return { expectedGeneration: Number(status.activeGeneration), payload: parsePayload(), secretChanges, confirmation: { confirmed: true, 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 ({ quiet = false } = {}) => { if (!canEdit() || !checkForm()) return false working.value = 'save' try { const request = buildRequest() const response = await requestWithRecentAuthentication( () => 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() if (!quiet) ElMessage.success('配置已暂存,正在完成保存') return true } catch (error) { if (isRecentAuthenticationCancelled(error)) return false ElMessage.error(error.message || '配置保存失败') return false } finally { working.value = '' } } const applyConfig = async ({ successMessage = '配置已保存' } = {}) => { if (!canOperate() || !savedGeneration.value) return false if (!savedInput || savedInput.text !== editorText.value || savedInput.secret !== secretValue.value) { invalidateCandidate() ElMessage.warning('填写内容已变更,请重新保存配置') return false } working.value = 'apply' const generation = savedGeneration.value let applied = false try { const response = await requestWithRecentAuthentication(() => http.post( `/api/v1/config/${apiSection()}/apply`, { generation: savedGeneration.value, candidateId: pendingCandidate.value?.candidateId } )) notifyResponseError(response, '配置保存失败') applied = true // 服务端已提交的候选不能再次应用;回读失败时只允许刷新,不误报保存失败或展示旧配置。 pendingCandidate.value = null invalidateCandidate() await loadStatus() if (disposed) return false if (!Number.isSafeInteger(Number(status.activeGeneration)) || Number(status.activeGeneration) < generation) { throw new Error('配置状态尚未更新') } if (!await loadSection({ minimumGeneration: generation })) throw new Error('最新配置读取失败') await loadPending() if (disposed) return false ElMessage.success(successMessage) return true } catch (error) { if (disposed) return false if (isRecentAuthenticationCancelled(error)) return false if (applied) { sectionError.value = '配置已应用,但最新配置读取失败,请刷新状态核对' editorText.value = '' loadedText.value = '' ElMessage.warning(sectionError.value) return false } ElMessage.error(error.message || '配置保存失败') return false } finally { working.value = '' } } const saveAndApply = async () => { if (pendingCandidate.value) { await continuePending() return } const saved = await saveConfig({ quiet: true }) if (saved) await applyConfig({ successMessage: '配置已保存' }) } const continuePending = async () => { if (!pendingCandidate.value) return if (!pendingCandidate.value.sameSession) { const resumed = await managePending('resume') if (!resumed) return } await applyConfig() } 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 { 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, saveAndApply, continuePending, 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 } }