diff --git a/frontend/src/composables/useFormGuard.ts b/frontend/src/composables/useFormGuard.ts new file mode 100644 index 00000000..7a2e0060 --- /dev/null +++ b/frontend/src/composables/useFormGuard.ts @@ -0,0 +1,81 @@ +import { watch, isRef, onBeforeUnmount, type Ref } from 'vue' +import { useRouter } from 'vue-router' +import { ElMessageBox } from 'element-plus' + +/** + * 统一"未保存离开守卫"(CMA表单保存交互规范 P0) + * + * 用法(页面 setup 中): + * const isDirty = ref(false) + * const { markDirty, markClean } = useFormGuard(isDirty) + * 表单数据变化时 markDirty();保存成功后 markClean() + * + * 行为: + * 1. 路由切换前检查:从 meta.editable 页面离开且 isDirty 时弹确认框 + * 2. 浏览器关闭/刷新前检查:beforeunload 原生提示 + * 组件卸载时自动注销守卫与事件监听,避免全局钩子累积。 + */ +export function useFormGuard(isDirty: Ref) { + const router = useRouter() + + // 路由切换前检查(组件卸载时自动注销) + const removeGuard = router.beforeEach((to, from, next) => { + // 同页 query/hash 变化不算离开(如筛选条件更新URL),不触发守卫 + if (to.path === from.path) { + next() + return + } + if (isDirty.value && from.meta.editable) { + ElMessageBox.confirm('有未保存的修改,确定离开吗?', '提示', { + confirmButtonText: '离开', + cancelButtonText: '继续编辑', + type: 'warning', + }).then(() => { + isDirty.value = false + next() + }).catch(() => { + next(false) + }) + } else { + next() + } + }) + + // 浏览器关闭/刷新前检查 + const onBeforeUnload = (e: BeforeUnloadEvent) => { + if (isDirty.value) { + e.preventDefault() + e.returnValue = '' + } + } + window.addEventListener('beforeunload', onBeforeUnload) + + onBeforeUnmount(() => { + removeGuard() + window.removeEventListener('beforeunload', onBeforeUnload) + }) + + return { + markDirty: () => { isDirty.value = true }, + markClean: () => { isDirty.value = false }, + } +} + +/** + * 弹窗表单脏追踪:弹窗打开时记录表单快照,内容变化后触发 onDirty,关闭时重置。 + * 适用于"弹窗编辑"类页面(KPIList/OrgManage/UserManage/ExpenseManage/TaxCompliance/CashPlan/AlertList 等)。 + * form 可为 ref 或 reactive 对象。 + * + * 用法: + * trackDialogForm(showForm, form, markDirty) + */ +export function trackDialogForm(open: Ref, form: any, onDirty: () => void) { + let snap = '' + const get = () => JSON.stringify(isRef(form) ? form.value : form) + watch(open, (v) => { if (!v) snap = '' }) + watch(get, (v) => { + if (!open.value) return + if (!snap) snap = v + else if (v !== snap) onDirty() + }) +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 3572cbb9..36cf8a71 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -6,46 +6,46 @@ const routes = [ { path: '/', component: () => import('@/layouts/MainLayout.vue'), redirect: '/my-dashboard', children: [ { path: 'dashboard', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '经营看板', roles: ['ceo', 'finance', 'business', 'it'] } }, - { path: 'kpis', name: 'KPIs', component: () => import('@/views/KPIList.vue'), meta: { title: 'KPI字典', roles: ['ceo', 'finance', 'business', 'it'] } }, - { path: 'kpis/:id', name: 'KPIDetail', component: () => import('@/views/KPIDetail.vue'), meta: { title: 'KPI详情', roles: ['ceo', 'finance', 'business', 'it'] } }, - { path: 'maps', name: 'Maps', component: () => import('@/views/MapList.vue'), meta: { title: '战略地图', roles: ['ceo', 'finance'] } }, + { path: 'kpis', name: 'KPIs', component: () => import('@/views/KPIList.vue'), meta: { title: 'KPI字典', roles: ['ceo', 'finance', 'business', 'it'], editable: true } }, + { path: 'kpis/:id', name: 'KPIDetail', component: () => import('@/views/KPIDetail.vue'), meta: { title: 'KPI详情', roles: ['ceo', 'finance', 'business', 'it'], editable: true } }, + { path: 'maps', name: 'Maps', component: () => import('@/views/MapList.vue'), meta: { title: '战略地图', roles: ['ceo', 'finance'], editable: true } }, { path: 'customer', name: 'CustomerDimension', component: () => import('@/views/CustomerDashboard.vue'), meta: { title: '客户维度', roles: ['ceo', 'finance', 'business', 'it'] } }, { path: 'learning-dashboard', name: 'LearningDashboard', component: () => import('@/views/LearningDashboard.vue'), meta: { title: '学习成长看板', roles: ['ceo', 'finance', 'it'] } }, { path: 'maps-review', name: 'MapReviewList', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'] } }, - { path: 'maps/canvas/:id', name: 'MapCanvas', component: () => import('@/views/MapCanvas.vue'), meta: { title: '战略地图画布', roles: ['ceo', 'finance'] } }, + { path: 'maps/canvas/:id', name: 'MapCanvas', component: () => import('@/views/MapCanvas.vue'), meta: { title: '战略地图画布', roles: ['ceo', 'finance'], editable: true } }, { path: 'maps/review/:id', name: 'MapReview', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'] } }, { path: 'my-dashboard', name: 'MyDashboard', component: () => import('@/views/MyDashboard.vue'), meta: { title: '我的工作台', roles: ['ceo', 'finance', 'business', 'it'] } }, - { path: 'knowledge', name: 'CMAKnowledge', component: () => import('@/views/CMAKnowledge.vue'), meta: { title: 'CMA知识库', roles: ['ceo', 'finance', 'business', 'it'] } }, + { path: 'knowledge', name: 'CMAKnowledge', component: () => import('@/views/CMAKnowledge.vue'), meta: { title: 'CMA知识库', roles: ['ceo', 'finance', 'business', 'it'], editable: true } }, { path: 'guide', name: 'NewUserGuide', component: () => import('@/views/NewUserGuide.vue'), meta: { title: '新手引导', roles: ['ceo', 'finance', 'business', 'it'] } }, - { path: 'alerts', name: 'Alerts', component: () => import('@/views/AlertList.vue'), meta: { title: '预警中心', roles: ['ceo', 'finance', 'business', 'it'] } }, + { path: 'alerts', name: 'Alerts', component: () => import('@/views/AlertList.vue'), meta: { title: '预警中心', roles: ['ceo', 'finance', 'business', 'it'], editable: true } }, { path: 'data', name: 'Data', component: () => import('@/views/DataManage.vue'), meta: { title: '数据管理', roles: ['ceo', 'finance', 'it'] } }, - { path: 'users', name: 'Users', component: () => import('@/views/UserManage.vue'), meta: { title: '用户管理', roles: ['ceo', 'it'] } }, - { path: 'notifications', name: 'Notifications', component: () => import('@/views/NotificationManage.vue'), meta: { title: '通知配置', roles: ['ceo', 'it'] } }, - { path: 'permissions', name: 'RolePermissions', component: () => import('@/views/RolePermissions.vue'), meta: { title: '系统设置', roles: ['ceo', 'it'] } }, - { path: 'budget', name: 'BudgetManagement', component: () => import('@/views/BudgetManagement.vue'), meta: { title: '预算管理', roles: ['ceo', 'finance', 'it'] } }, - { path: 'org', name: 'OrgManage', component: () => import('@/views/OrgManage.vue'), meta: { title: '组织管理', roles: ['ceo', 'it'] } }, + { path: 'users', name: 'Users', component: () => import('@/views/UserManage.vue'), meta: { title: '用户管理', roles: ['ceo', 'it'], editable: true } }, + { path: 'notifications', name: 'Notifications', component: () => import('@/views/NotificationManage.vue'), meta: { title: '通知配置', roles: ['ceo', 'it'], editable: true } }, + { path: 'permissions', name: 'RolePermissions', component: () => import('@/views/RolePermissions.vue'), meta: { title: '系统设置', roles: ['ceo', 'it'], editable: true } }, + { path: 'budget', name: 'BudgetManagement', component: () => import('@/views/BudgetManagement.vue'), meta: { title: '预算管理', roles: ['ceo', 'finance', 'it'], editable: true } }, + { path: 'org', name: 'OrgManage', component: () => import('@/views/OrgManage.vue'), meta: { title: '组织管理', roles: ['ceo', 'it'], editable: true } }, { path: 'deviations', name: 'DeviationDashboard', component: () => import('@/views/DeviationDashboard.vue'), meta: { title: '差异分析', roles: ['ceo', 'finance', 'business', 'it'] } }, { path: 'cost', name: 'CostDashboard', component: () => import('@/views/CostDashboard.vue'), meta: { title: '成本分析', roles: ['ceo', 'finance', 'it'] } }, { path: 'predict', name: 'PredictDashboard', component: () => import('@/views/PredictDashboard.vue'), meta: { title: '预测模拟', roles: ['ceo', 'finance', 'it'] } }, { path: 'predict/accuracy', name: 'PredictAccuracy', component: () => import('@/views/PredictAccuracy.vue'), meta: { title: '预测准确率', roles: ['ceo', 'finance', 'it'] } }, { path: 'real-options', name: 'RealOptions', component: () => import('@/views/RealOptions.vue'), meta: { title: '实物期权计算器', roles: ['ceo', 'finance'] } }, - { path: 'action-plans', name: 'ActionPlans', component: () => import('@/views/ActionPlanLibrary.vue'), meta: { title: '改善行动', roles: ['ceo', 'finance', 'business', 'it'] } }, + { path: 'action-plans', name: 'ActionPlans', component: () => import('@/views/ActionPlanLibrary.vue'), meta: { title: '改善行动', roles: ['ceo', 'finance', 'business', 'it'], editable: true } }, { path: 'reports', name: 'ReportCenter', component: () => import('@/views/ReportCenter.vue'), meta: { title: '管理报表', roles: ['ceo', 'finance', 'business'] } }, - { path: 'alignment', name: 'KPIAlignment', component: () => import('@/views/KPIAlignment.vue'), meta: { title: '战略执行看板', roles: ['ceo', 'finance', 'business', 'it'] } }, + { path: 'alignment', name: 'KPIAlignment', component: () => import('@/views/KPIAlignment.vue'), meta: { title: '战略执行看板', roles: ['ceo', 'finance', 'business', 'it'], editable: true } }, { path: 'dupont-analysis', name: 'DupontAnalysis', component: () => import('@/views/DupontAnalysis.vue'), meta: { title: '杜邦分析', roles: ['ceo', 'finance', 'it'] } }, { path: 'data-quality', name: 'DataQuality', component: () => import('@/views/DataQuality.vue'), meta: { title: '数据质量', roles: ['ceo', 'finance', 'it'] } }, { path: 'bi-reports', name: 'BiReports', component: () => import('@/views/BIReportCenter.vue'), meta: { title: 'BI报表', roles: ['ceo', 'finance', 'business'] } }, - { path: 'okr-templates', name: 'OKRTemplates', component: () => import('@/views/OKRTemplates.vue'), meta: { title: 'OKR模板库', roles: ['ceo', 'finance', 'it'] } }, + { path: 'okr-templates', name: 'OKRTemplates', component: () => import('@/views/OKRTemplates.vue'), meta: { title: 'OKR模板库', roles: ['ceo', 'finance', 'it'], editable: true } }, { path: 'okr/:id', name: 'OkrDetail', component: () => import('@/views/OkrDetail.vue'), meta: { title: 'OKR详情', roles: ['ceo', 'finance', 'it'] } }, { path: 'subjects', name: 'SubjectManage', component: () => import('@/views/SubjectManage.vue'), meta: { title: '科目打标', roles: ['ceo', 'finance', 'business', 'it'] } }, { path: 'mpm-calculator', name: 'MpmCalculator', component: () => import('@/views/MpmCalculator.vue'), meta: { title: 'MPM计算器', roles: ['ceo', 'finance', 'business'] } }, { path: 'bot-kpis', name: 'BotKpis', component: () => import('@/views/BotKpiDashboard.vue'), meta: { title: 'Bot KPI看板', roles: ['ceo', 'finance', 'it'] } }, { path: 'analysis-confidence', name: 'AnalysisConfidence', component: () => import('@/views/AnalysisConfidence.vue'), meta: { title: '分析置信度', roles: ['ceo', 'finance', 'it'] } }, - { path: 'expenses', name: 'ExpenseManage', component: () => import('@/views/ExpenseManage.vue'), meta: { title: '费用审核', roles: ['ceo', 'finance', 'business', 'it'] } }, - { path: 'cash-plan', name: 'CashPlan', component: () => import('@/views/CashPlan.vue'), meta: { title: '收付款计划', roles: ['ceo', 'finance', 'business', 'it'] } }, + { path: 'expenses', name: 'ExpenseManage', component: () => import('@/views/ExpenseManage.vue'), meta: { title: '费用审核', roles: ['ceo', 'finance', 'business', 'it'], editable: true } }, + { path: 'cash-plan', name: 'CashPlan', component: () => import('@/views/CashPlan.vue'), meta: { title: '收付款计划', roles: ['ceo', 'finance', 'business', 'it'], editable: true } }, { path: 'receivables', name: 'Receivables', component: () => import('@/views/Receivables.vue'), meta: { title: '应收款催收', roles: ['ceo', 'finance', 'business'] } }, { path: 'growth-quality', name: 'GrowthQuality', component: () => import('@/views/GrowthQuality.vue'), meta: { title: '增长质量诊断', roles: ['ceo', 'finance', 'business', 'it'] } }, - { path: 'tax-compliance', name: 'TaxCompliance', component: () => import('@/views/TaxCompliance.vue'), meta: { title: '税务合规', roles: ['ceo', 'finance', 'business', 'it'] } }, + { path: 'tax-compliance', name: 'TaxCompliance', component: () => import('@/views/TaxCompliance.vue'), meta: { title: '税务合规', roles: ['ceo', 'finance', 'business', 'it'], editable: true } }, ] }, ] diff --git a/frontend/src/views/ExpenseManage.vue b/frontend/src/views/ExpenseManage.vue index 2e8b8666..7298884c 100644 --- a/frontend/src/views/ExpenseManage.vue +++ b/frontend/src/views/ExpenseManage.vue @@ -329,6 +329,7 @@ import { ref, reactive, computed, onMounted, nextTick } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { expenseApi } from '../api/index' import MyDialog from '../components/MyDialog.vue' +import { useFormGuard, trackDialogForm } from '../composables/useFormGuard' const EXPENSE_TYPES: Record = { entertainment: '招待费', @@ -399,6 +400,13 @@ const reviewTitle = computed(() => { return m[review.mode] }) +// ── 未保存离开守卫 ── +const isDirty = ref(false) +const { markDirty, markClean } = useFormGuard(isDirty) +trackDialogForm(ruleDialogVisible, ruleForm, markDirty) +trackDialogForm(submitVisible, submitForm, markDirty) +trackDialogForm(reviewVisible, review, markDirty) + // ── 图表 ── const typeChartRef = ref(null) @@ -626,6 +634,7 @@ async function saveRule() { } ruleDialogVisible.value = false ElMessage.success('规则已保存') + markClean() loadRules() } catch (e: any) { ElMessage.error('保存失败: ' + (e?.response?.data?.detail || e.message)) diff --git a/frontend/src/views/KPIDetail.vue b/frontend/src/views/KPIDetail.vue index 83ee8df2..41bacbfb 100644 --- a/frontend/src/views/KPIDetail.vue +++ b/frontend/src/views/KPIDetail.vue @@ -268,13 +268,14 @@