Browse Source

设计审查 P1 四项收口:ep-message 收口 / 硬编码色清理 / 空态引导 / 限宽+面包屑

P1-4 抽 utils/ep-message.ts(epMessage/epConfirm/epPrompt 单点 UMD 读取),6 个 academic 页删重复定义,76 处调用收口;epConfirm 加可选 title(fee 敏感操作保'敏感操作确认')
P1-5 清 12 处 var(--token,#hex) 冗余兜底;凭空色 #8a8f9c×3 纠正为 --app-color-text-2
P1-6 ListPage 加 emptyText,默认按 total 区分越界空页/筛选无结果并给引导
P1-7 layout 内容区 max-width 1440 居中;顶栏加 el-breadcrumb(menuTree 反查当前路由祖先链)
Q 10 hours ago
parent
commit
50a9d43bc4

+ 17 - 2
src/components/ListPage.vue

@@ -50,13 +50,16 @@ interface Props {
   rowKey?: string;
   /** 每页条数候选(缺省 10/20/50/100) */
   pageSizes?: number[];
+  /** 空态自定义文案(缺省按 total 自动区分:有总数→越界空页 / 无总数→筛选无结果) */
+  emptyText?: string;
 }
 
-withDefaults(defineProps<Props>(), {
+const props = withDefaults(defineProps<Props>(), {
   loading: false,
   error: '',
   rowKey: 'id',
   pageSizes: () => [10, 20, 50, 100],
+  emptyText: '',
 });
 
 const emit = defineEmits<{
@@ -69,6 +72,18 @@ const emit = defineEmits<{
   /** 行点击(类型 = T) */
   (e: 'row-click', row: T): void;
 }>();
+
+/**
+ * 空态文案(设计原则 §5:空态须指引下一步,禁裸"暂无数据")。
+ * 优先级:页面显式传 emptyText → 越界空页(total>0 但当前页空)→ 真无数据(total=0,引导放宽筛选)。
+ * 入参:无(读 props)。出参:string。
+ */
+const emptyLabel = computed(() => {
+  if (props.emptyText) return props.emptyText;
+  return props.total > 0
+    ? `当前页无数据(共 ${props.total} 条,试试调整页码或筛选)`
+    : '没有符合条件的记录,试试放宽筛选';
+});
 </script>
 
 <template>
@@ -104,7 +119,7 @@ const emit = defineEmits<{
         </template>
       </el-table-column>
       <template #empty>
-        <span class="list-page__empty">暂无数据</span>
+        <span class="list-page__empty">{{ emptyLabel }}</span>
       </template>
     </el-table>
 

+ 81 - 0
src/utils/ep-message.ts

@@ -0,0 +1,81 @@
+/**
+ * ep-message.ts —— 命令式 Element Plus 收口(UMD 全局单点读取;README §2 utils:零 Vue 依赖、零 store 依赖)。
+ *
+ * 背景(2026-11-10 设计审查 P1-4):本项目框架层全走 UMD 全局(index.html 自托管,禁 import 框架运行时),
+ *   命令式 EP 组件(ElMessage / ElMessageBox)不在组件树内,必须经 `globalThis.ElementPlus` 读取。
+ *   原先 6 个 academic 页面各自重复定义 ElMessage_/ElConfirm(UMD 取法各写一遍),收口于此单点。
+ *
+ * 纪律(与业务页 http.ts 同款 UMD 读法一致):
+ *   - 读取 globalThis.ElementPlus,缺失时**降级不抛错**(ElMessage→console 兜底;ElMessageBox→resolve,避免阻断主流程);
+ *   - epConfirm 取消/确认统一 reject('cancel'),调用方 try/catch 区分"用户取消"与"执行";
+ *   - 本文件只提供"命令式反馈",不持有业务状态、不读 store。
+ */
+
+/** 命令式 EP 全局容器(index.html 注入的 UMD 全局 ElementPlus;缺失 = 框架未加载) */
+function ep(): { ElMessage?: unknown; ElMessageBox?: unknown } | undefined {
+  return (globalThis as { ElementPlus?: { ElMessage?: unknown; ElMessageBox?: unknown } }).ElementPlus;
+}
+
+/**
+ * 消息提示(ElMessage)。
+ *
+ * 入参:
+ *   - type 'success' | 'warning' | 'error':语义色(对应 EP success/warning/error);
+ *   - text string:提示文案(后端 message 直显或前端主动语态文案)。
+ *
+ * 出参:void。
+ * 异常场景:EP 全局缺失(框架未加载)→ console.warn 兜底,不抛错(开发期定位 vendor 未加载)。
+ */
+export function epMessage(type: 'success' | 'warning' | 'error', text: string): void {
+  const M = ep()?.ElMessage as ((o: { type: string; message: string }) => void) | undefined;
+  if (M) M({ type, message: text });
+  else console.warn(`[${type}]`, text);
+}
+
+/**
+ * 二次确认对话框(ElMessageBox.confirm;敏感/重操作用,README §3.3-9)。
+ *
+ * 入参:
+ *   - text string:确认文案(主动语态,说明后果,如"确认对该账户做坏账处理?");
+ *   - title string(可选,默认 '操作确认'):对话框标题(fee 页敏感操作用 '敏感操作确认')。
+ *
+ * 出参:
+ *   - Promise<void>:确认则 resolve;取消则 reject(new Error('cancel'))——调用方 catch 即中止执行。
+ * 异常场景:EP 全局缺失 → resolve(不阻断;与降级口径一致)。
+ */
+export function epConfirm(text: string, title = '操作确认'): Promise<void> {
+  const B = ep()?.ElMessageBox as {
+    confirm: (t: string, ti: string, o: Record<string, unknown>) => Promise<unknown>;
+  } | undefined;
+  if (!B) return Promise.resolve();
+  return B
+    .confirm(text, title, { confirmButtonText: '执行', cancelButtonText: '取消', type: 'warning' })
+    .then(() => undefined)
+    .catch(() => Promise.reject(new Error('cancel')));
+}
+
+/**
+ * 文本输入对话框(ElMessageBox.prompt;必填理由类操作用,如工单驳回理由)。
+ *
+ * 入参:
+ *   - text string:输入提示(如"驳回必须填写理由(对账/申诉依据)")。
+ *
+ * 出参:
+ *   - Promise<string>:确认回输入值(trim 由调用方判定空);取消 reject(new Error('cancel'))。
+ * 异常场景:EP 全局缺失 → resolve('')(调用方按空理由拦截,不阻断)。
+ */
+export function epPrompt(text: string): Promise<string> {
+  const B = ep()?.ElMessageBox as {
+    prompt: (t: string, ti: string, o: Record<string, unknown>) => Promise<{ value: string }>;
+  } | undefined;
+  if (!B) return Promise.resolve('');
+  return B
+    .prompt(text, '请输入理由', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      inputType: 'textarea',
+      inputPlaceholder: '必填',
+    })
+    .then((r) => r.value)
+    .catch(() => Promise.reject(new Error('cancel')));
+}

+ 11 - 22
src/views/academic/activity/index.vue

@@ -24,22 +24,11 @@ import type { ListColumn } from '@/components/list-column';
 import { useListPage } from '@/composables/use-list-page';
 import { useScope } from '@/store/scope';
 import { formatDateTime, formatMoney } from '@/utils/format';
+import { epMessage, epConfirm } from '@/utils/ep-message';
 
 const scope = useScope();
 
-/** 命令式 EP(UMD 全局,http.ts 同款读法) */
-function ElMessage_(type: 'success' | 'warning' | 'error', text: string): void {
-  const M = (globalThis.ElementPlus ?? {}).ElMessage;
-  if (M) M({ type, message: text });
-  else console.warn(`[${type}]`, text);
-}
-function ElConfirm(text: string): Promise<void> {
-  const B = (globalThis.ElementPlus ?? {}).ElMessageBox;
-  if (!B) return Promise.resolve();
-  return B.confirm(text, '操作确认', { confirmButtonText: '执行', cancelButtonText: '取消', type: 'warning' })
-    .then(() => undefined)
-    .catch(() => Promise.reject(new Error('cancel')));
-}
+/* 命令式 EP(epMessage/epConfirm)已收口到 @/utils/ep-message(UMD 全局单点读取) */
 
 /* ================= 学生/推荐人选项 ================= */
 const studentOpts = ref<MemberDTO[]>([]);
@@ -114,7 +103,7 @@ function openSign(): void {
 async function doSign(): Promise<void> {
   if (!selectedActivity.value) return;
   if (!signForm.userId) {
-    ElMessage_('warning', '请选择报名学生');
+    epMessage('warning', '请选择报名学生');
     return;
   }
   signBusy.value = true;
@@ -124,16 +113,16 @@ async function doSign(): Promise<void> {
     if (selectedActivity.value.type === 4 && signForm.recommendUserId) body.recommendUserId = signForm.recommendUserId;
     const r = await signActivity(selectedActivity.value.id, body);
     if (r.code === 200) {
-      ElMessage_('success', '报名成功');
+      epMessage('success', '报名成功');
       signVisible.value = false;
       void loadSigneds();
       void fetchList(); // 刷新活动列表 signed 计数
     } else {
       // 3105 已报名 / 3106 老带新限一次:读 code 直显 message(业务分支,非成功)
-      ElMessage_('warning', r.message || `报名失败(code ${r.code})`);
+      epMessage('warning', r.message || `报名失败(code ${r.code})`);
     }
   } catch {
-    ElMessage_('error', '网络连接异常');
+    epMessage('error', '网络连接异常');
   } finally {
     signBusy.value = false;
   }
@@ -142,17 +131,17 @@ async function doSign(): Promise<void> {
 async function doCancel(s: ActivitySignDTO): Promise<void> {
   if (!selectedActivity.value) return;
   try {
-    await ElConfirm(`确认取消「${studentName(s.userId)}」的报名?取消后该报名金额将归零。`);
+    await epConfirm(`确认取消「${studentName(s.userId)}」的报名?取消后该报名金额将归零。`);
   } catch {
     return;
   }
   const r = await cancelSign(selectedActivity.value.id, { userId: s.userId });
   if (r.code === 200) {
-    ElMessage_('success', '已取消报名');
+    epMessage('success', '已取消报名');
     void loadSigneds();
     void fetchList();
   } else {
-    ElMessage_('warning', r.message || `取消失败(code ${r.code})`);
+    epMessage('warning', r.message || `取消失败(code ${r.code})`);
   }
 }
 
@@ -250,7 +239,7 @@ const signStatusText: Record<number, string> = { 0: '已取消', 1: '报名', 2:
 .activity-page__sign {
   margin-top: 16px;
   padding-top: 12px;
-  border-top: 1px solid var(--app-color-border, #e3e7ea);
+  border-top: 1px solid var(--app-color-border);
 }
 .activity-page__sign-head {
   display: flex;
@@ -263,6 +252,6 @@ const signStatusText: Record<number, string> = { 0: '已取消', 1: '报名', 2:
 }
 .activity-page__sign-hint {
   font-size: var(--app-font-sm);
-  color: var(--app-color-text-2, #5d6672);
+  color: var(--app-color-text-2);
 }
 </style>

+ 19 - 30
src/views/academic/divide/index.vue

@@ -23,22 +23,11 @@ import type { ListColumn } from '@/components/list-column';
 import { useListPage } from '@/composables/use-list-page';
 import { useScope } from '@/store/scope';
 import { formatMoney, formatDateTime } from '@/utils/format';
+import { epMessage, epConfirm } from '@/utils/ep-message';
 
 const scope = useScope();
 
-/** 命令式 EP(UMD 全局,http.ts 同款读法) */
-function ElMessage_(type: 'success' | 'warning' | 'error', text: string): void {
-  const M = (globalThis.ElementPlus ?? {}).ElMessage;
-  if (M) M({ type, message: text });
-  else console.warn(`[${type}]`, text);
-}
-function ElConfirm(text: string): Promise<void> {
-  const B = (globalThis.ElementPlus ?? {}).ElMessageBox;
-  if (!B) return Promise.resolve();
-  return B.confirm(text, '操作确认', { confirmButtonText: '执行', cancelButtonText: '取消', type: 'warning' })
-    .then(() => undefined)
-    .catch(() => Promise.reject(new Error('cancel')));
-}
+/* 命令式 EP(epMessage/epConfirm)已收口到 @/utils/ep-message(UMD 全局单点读取) */
 
 /* ================= 成员选项(学生,机构级全量——分账树/流水跨校区) ================= */
 const studentOpts = ref<MemberDTO[]>([]);
@@ -101,15 +90,15 @@ async function doSetQuota(): Promise<void> {
   try {
     const r = await setDivideQuota(node.userId, { quota: quotaForm.quota });
     if (r.code === 200) {
-      ElMessage_('success', '额度已更新');
+      epMessage('success', '额度已更新');
       quotaVisible.value = false;
       void loadTree();
     } else {
       // 3111 子额度>父额度 / 2002 格式错:读 code 直显
-      ElMessage_('warning', r.message || `设置失败(code ${r.code})`);
+      epMessage('warning', r.message || `设置失败(code ${r.code})`);
     }
   } catch {
-    ElMessage_('error', '网络连接异常');
+    epMessage('error', '网络连接异常');
   } finally {
     quotaBusy.value = false;
   }
@@ -129,7 +118,7 @@ async function doAddMember(): Promise<void> {
   const node = memberTarget.value;
   if (!node) return;
   if (!memberForm.subUserId) {
-    ElMessage_('warning', '请选择下线成员');
+    epMessage('warning', '请选择下线成员');
     return;
   }
   memberBusy.value = true;
@@ -137,16 +126,16 @@ async function doAddMember(): Promise<void> {
     const r = await addDivideMember({ parentUserId: node.userId, subUserId: memberForm.subUserId });
     if (r.code === 200) {
       // 契约 §2.3:新节点默认 quota=0.00,不设置额度则该链不分成且不报错 → 引导设置
-      ElMessage_('success', '下线已添加(默认额度 0.00,请尽快设置额度,否则该链不产生分成)');
+      epMessage('success', '下线已添加(默认额度 0.00,请尽快设置额度,否则该链不产生分成)');
       memberVisible.value = false;
       await loadTree();
       void loadLogs();
     } else {
       // 3111 层级超限 / 3140 已在树中 / 3001 不存在:读 code 直显
-      ElMessage_('warning', r.message || `添加失败(code ${r.code})`);
+      epMessage('warning', r.message || `添加失败(code ${r.code})`);
     }
   } catch {
-    ElMessage_('error', '网络连接异常');
+    epMessage('error', '网络连接异常');
   } finally {
     memberBusy.value = false;
   }
@@ -196,21 +185,21 @@ function openLog(): void {
 }
 async function doCreateLog(): Promise<void> {
   if (!logForm.findUserId || !logForm.studentId || !logForm.orderNo) {
-    ElMessage_('warning', '请填全分账人/学生/订单号');
+    epMessage('warning', '请填全分账人/学生/订单号');
     return;
   }
   logBusy.value = true;
   try {
     const r = await createDivideLog({ findUserId: logForm.findUserId, studentId: logForm.studentId, orderNo: logForm.orderNo, remark: logForm.remark || undefined });
     if (r.code === 200) {
-      ElMessage_('success', '流水已记录');
+      epMessage('success', '流水已记录');
       logVisible.value = false;
       void loadLogs();
     } else {
-      ElMessage_('warning', r.message || `记录失败(code ${r.code})`);
+      epMessage('warning', r.message || `记录失败(code ${r.code})`);
     }
   } catch {
-    ElMessage_('error', '网络连接异常');
+    epMessage('error', '网络连接异常');
   } finally {
     logBusy.value = false;
   }
@@ -218,16 +207,16 @@ async function doCreateLog(): Promise<void> {
 /** 删分账流水(端点 42) */
 async function doDeleteLog(l: DivideLogDTO): Promise<void> {
   try {
-    await ElConfirm(`确认删除订单 ${l.orderNo} 的分账流水?`);
+    await epConfirm(`确认删除订单 ${l.orderNo} 的分账流水?`);
   } catch {
     return;
   }
   const r = await deleteDivideLog({ studentId: l.studentId, orderNo: l.orderNo });
   if (r.code === 200) {
-    ElMessage_('success', '已删除');
+    epMessage('success', '已删除');
     void loadLogs();
   } else {
-    ElMessage_('warning', r.message || `删除失败(code ${r.code})`);
+    epMessage('warning', r.message || `删除失败(code ${r.code})`);
   }
 }
 </script>
@@ -355,12 +344,12 @@ async function doDeleteLog(l: DivideLogDTO): Promise<void> {
 }
 .divide-page__hint {
   font-size: var(--app-font-md);
-  color: var(--app-text-secondary, #8a8f9c);
+  color: var(--app-color-text-2);
 }
 .divide-page__logs {
   margin-top: 16px;
   padding-top: 12px;
-  border-top: 1px solid var(--app-color-border, #e3e7ea);
+  border-top: 1px solid var(--app-color-border);
 }
 .divide-page__log-actions {
   margin-top: 12px;
@@ -375,7 +364,7 @@ async function doDeleteLog(l: DivideLogDTO): Promise<void> {
 }
 .divide-page__log-no {
   font-size: var(--app-font-md);
-  color: var(--app-color-text-2, #5d6672);
+  color: var(--app-color-text-2);
   min-width: 160px;
 }
 </style>

+ 7 - 11
src/views/academic/expenditure/index.vue

@@ -21,15 +21,11 @@ import type { ListColumn } from '@/components/list-column';
 import { useListPage } from '@/composables/use-list-page';
 import { useScope } from '@/store/scope';
 import { formatDateTime } from '@/utils/format';
+import { epMessage } from '@/utils/ep-message';
 
 const scope = useScope();
 
-/** 命令式 EP(UMD 全局,http.ts 同款读法) */
-function ElMessage_(type: 'success' | 'warning' | 'error', text: string): void {
-  const M = (globalThis.ElementPlus ?? {}).ElMessage;
-  if (M) M({ type, message: text });
-  else console.warn(`[${type}]`, text);
-}
+/* 命令式 EP(epMessage)已收口到 @/utils/ep-message(UMD 全局单点读取) */
 
 /* ================= 选项数据(校区;记账人恒取当前登录账号,不再需要成员下拉) ================= */
 const campusOpts = ref<CampusDTO[]>([]);
@@ -89,11 +85,11 @@ function openAdd(): void {
 }
 async function doAdd(): Promise<void> {
   if (!addForm.expenditureId) {
-    ElMessage_('warning', '请选择收支类型');
+    epMessage('warning', '请选择收支类型');
     return;
   }
   if (!addForm.money) {
-    ElMessage_('warning', '请填写金额');
+    epMessage('warning', '请填写金额');
     return;
   }
   addBusy.value = true;
@@ -105,14 +101,14 @@ async function doAdd(): Promise<void> {
       institutionId: scope.current.institutionId,
     });
     if (r.code === 200) {
-      ElMessage_('success', `新增成功(${r.data?.bizNo ?? ''})`);
+      epMessage('success', `新增成功(${r.data?.bizNo ?? ''})`);
       addVisible.value = false;
       void fetchList();
     } else {
-      ElMessage_('warning', r.message || `新增失败(code ${r.code})`);
+      epMessage('warning', r.message || `新增失败(code ${r.code})`);
     }
   } catch {
-    ElMessage_('error', '网络连接异常');
+    epMessage('error', '网络连接异常');
   } finally {
     addBusy.value = false;
   }

+ 14 - 29
src/views/academic/fee/index.vue

@@ -29,26 +29,11 @@ import type { ListColumn } from '@/components/list-column';
 import { useListPage } from '@/composables/use-list-page';
 import { useScope } from '@/store/scope';
 import { moneyPlain, formatMoney, formatDateTime } from '@/utils/format';
+import { epMessage, epConfirm, epPrompt } from '@/utils/ep-message';
 
 const scope = useScope();
 
-/** 命令式 EP(UMD 全局,http.ts 同款读法) */
-function ElMessage_(type: 'success' | 'warning' | 'error', text: string): void {
-  const M = (globalThis.ElementPlus ?? {}).ElMessage;
-  if (M) M({ type, message: text });
-  else console.warn(`[${type}]`, text);
-}
-function ElConfirm(text: string): Promise<void> {
-  const B = (globalThis.ElementPlus ?? {}).ElMessageBox;
-  if (!B) return Promise.resolve();
-  return B.confirm(text, '敏感操作确认', { confirmButtonText: '执行', cancelButtonText: '取消', type: 'warning' }).then(() => undefined).catch(() => Promise.reject(new Error('cancel')));
-}
-/** 文本输入对话框(驳回理由必填用;UMD 全局 ElMessageBox.prompt)。 */
-function ElPrompt(text: string): Promise<string> {
-  const B = (globalThis.ElementPlus ?? {}).ElMessageBox;
-  if (!B) return Promise.resolve('');
-  return (B as { prompt: (t: string, title: string, o: Record<string, unknown>) => Promise<{ value: string }> }).prompt(text, '请输入理由', { confirmButtonText: '确定', cancelButtonText: '取消', inputType: 'textarea', inputPlaceholder: '必填' }).then((r) => r.value).catch(() => Promise.reject(new Error('cancel')));
-}
+/* 命令式 EP(epMessage/epConfirm/epPrompt)已收口到 @/utils/ep-message(UMD 全局单点读取) */
 
 /* ================= 作用户 + 校区 ================= */
 const memberOpts = ref<MemberDTO[]>([]);
@@ -133,12 +118,12 @@ async function guardBalance(userId: string, campusId: number, moneyYuan: number)
   if (moneyYuan <= 0) return true;
   const r = await fetchAccount(userId, { institutionId: scope.current.institutionId, campusId });
   if (r.code !== 200 || !r.data) {
-    ElMessage_('error', '无法获取账户余额,操作已取消');
+    epMessage('error', '无法获取账户余额,操作已取消');
     return false;
   }
   const balance = Number(r.data.campusBalance) || 0;
   if (balance < moneyYuan) {
-    ElMessage_('warning', `校区余额不足:当前 ¥${r.data.campusBalance},需 ¥${moneyYuan.toFixed(2)}`);
+    epMessage('warning', `校区余额不足:当前 ¥${r.data.campusBalance},需 ¥${moneyYuan.toFixed(2)}`);
     return false;
   }
   return true;
@@ -150,14 +135,14 @@ async function runOp(fn: () => Promise<{ code: number; message: string }>): Prom
   try {
     const r = await fn();
     if (r.code === 200) {
-      ElMessage_('success', '操作成功');
+      epMessage('success', '操作成功');
       void fetchLogs();
       void loadDashboard();
     } else {
-      ElMessage_('warning', r.message || `操作失败(code ${r.code})`);
+      epMessage('warning', r.message || `操作失败(code ${r.code})`);
     }
   } catch {
-    ElMessage_('error', '网络连接异常');
+    epMessage('error', '网络连接异常');
   } finally {
     busy.value = false;
   }
@@ -178,7 +163,7 @@ function doRefund(): void {
 }
 async function doCustomRefund(): Promise<void> {
   try {
-    await ElConfirm('自定义退费将按可退额结算,不足部分计入坏账,确认执行?');
+    await epConfirm('自定义退费将按可退额结算,不足部分计入坏账,确认执行?', '敏感操作确认');
   } catch {
     return;
   }
@@ -186,7 +171,7 @@ async function doCustomRefund(): Promise<void> {
 }
 async function doBadDebt(): Promise<void> {
   try {
-    await ElConfirm('确认对该账户做坏账处理?');
+    await epConfirm('确认对该账户做坏账处理?');
   } catch {
     return;
   }
@@ -194,7 +179,7 @@ async function doBadDebt(): Promise<void> {
 }
 async function doClear(): Promise<void> {
   try {
-    await ElConfirm('余额清零为敏感操作,校区余额将一次性转特殊费用,确认执行?');
+    await epConfirm('余额清零为敏感操作,校区余额将一次性转特殊费用,确认执行?');
   } catch {
     return;
   }
@@ -209,7 +194,7 @@ function doSpend(): void {
  */
 async function doTransfer(): Promise<void> {
   if (transferForm.fromCampus === 0 || transferForm.toCampus === 0 || transferForm.fromCampus === transferForm.toCampus) {
-    ElMessage_('warning', '请选择两个不同的校区');
+    epMessage('warning', '请选择两个不同的校区');
     return;
   }
   const ok = await guardBalance(selectedUserId.value, transferForm.fromCampus, transferForm.money ?? 0);
@@ -252,13 +237,13 @@ async function doApprove(r: BalanceRequestDTO): Promise<void> {
 async function doReject(r: BalanceRequestDTO): Promise<void> {
   let msg = '';
   try {
-    msg = await ElPrompt('驳回必须填写理由(对账/申诉依据)');
+    msg = await epPrompt('驳回必须填写理由(对账/申诉依据)');
   } catch {
     return;
   }
   const text = (msg ?? '').trim();
   if (!text) {
-    ElMessage_('warning', '驳回理由不能为空');
+    epMessage('warning', '驳回理由不能为空');
     return;
   }
   void runOp(() => rejectRequest(r.id, { ...scope.toQuery(), campusId: selectedCampusId.value || undefined }, { responseMsg: text }).then((x) => ({ code: x.code, message: x.message })));
@@ -481,7 +466,7 @@ const ops: FeeOp[] = [
 }
 .fee-page__req-no {
   font-size: var(--app-font-md);
-  color: var(--app-text-secondary, #8a8f9c);
+  color: var(--app-color-text-2);
   min-width: 140px;
 }
 </style>

+ 10 - 14
src/views/academic/payroll/index.vue

@@ -28,15 +28,11 @@ import ListPage from '@/components/ListPage.vue';
 import type { ListColumn } from '@/components/list-column';
 import { useListPage } from '@/composables/use-list-page';
 import { useScope } from '@/store/scope';
+import { epMessage } from '@/utils/ep-message';
 
 const scope = useScope();
 
-/** 命令式 EP(UMD 全局,http.ts 同款读法) */
-function ElMessage_(type: 'success' | 'warning' | 'error', text: string): void {
-  const M = (globalThis.ElementPlus ?? {}).ElMessage;
-  if (M) M({ type, message: text });
-  else console.warn(`[${type}]`, text);
-}
+/* 命令式 EP(epMessage)已收口到 @/utils/ep-message(UMD 全局单点读取) */
 
 /* ================= 选项数据(校区/老师) ================= */
 const campusOpts = ref<CampusDTO[]>([]);
@@ -180,16 +176,16 @@ function removeDetail(i: number): void {
 /** 提交:方案级字段 + 完整 details → 整体替换(PUT 同事务先删后插)。 */
 async function savePlan(): Promise<void> {
   if (!planForm.schemeName) {
-    ElMessage_('warning', '请填写方案名');
+    epMessage('warning', '请填写方案名');
     return;
   }
   if (detailRows.value.length === 0) {
-    ElMessage_('warning', '至少需要 1 条明细');
+    epMessage('warning', '至少需要 1 条明细');
     return;
   }
   for (const d of detailRows.value) {
     if (d.teacherExpMin > d.teacherExpMax) {
-      ElMessage_('warning', '明细教龄下限不能大于上限');
+      epMessage('warning', '明细教龄下限不能大于上限');
       return;
     }
   }
@@ -222,14 +218,14 @@ async function savePlan(): Promise<void> {
     };
     const r = editingId.value === null ? await createPlan(body) : await updatePlan(editingId.value, body);
     if (r.code === 200) {
-      ElMessage_('success', '保存成功');
+      epMessage('success', '保存成功');
       editVisible.value = false;
       void fetchList();
     } else {
-      ElMessage_('warning', r.message || `保存失败(code ${r.code})`);
+      epMessage('warning', r.message || `保存失败(code ${r.code})`);
     }
   } catch {
-    ElMessage_('error', '网络连接异常');
+    epMessage('error', '网络连接异常');
   } finally {
     editBusy.value = false;
   }
@@ -252,7 +248,7 @@ function openQuote(): void {
 }
 async function doQuote(): Promise<void> {
   if (!quoteForm.teacherId) {
-    ElMessage_('warning', '请选择老师');
+    epMessage('warning', '请选择老师');
     return;
   }
   quoteBusy.value = true;
@@ -534,7 +530,7 @@ async function doQuote(): Promise<void> {
   align-items: center;
   margin-bottom: 8px;
   font-size: var(--app-font-md);
-  color: var(--app-text-secondary, #8a8f9c);
+  color: var(--app-color-text-2);
 }
 .payroll-page__quote-err,
 .payroll-page__quote-hint {

+ 38 - 49
src/views/academic/schedule/index.vue

@@ -48,22 +48,11 @@ import type { ListColumn } from '@/components/list-column';
 import { useListPage } from '@/composables/use-list-page';
 import { useScope } from '@/store/scope';
 import { formatDateTime, formatMoney, moneyPlain } from '@/utils/format';
+import { epMessage, epConfirm } from '@/utils/ep-message';
 
 const scope = useScope();
 
-/** 命令式 EP(UMD 全局,http.ts 同款读法) */
-function ElMessage_(type: 'success' | 'warning' | 'error', text: string): void {
-  const M = (globalThis.ElementPlus ?? {}).ElMessage;
-  if (M) M({ type, message: text });
-  else console.warn(`[${type}]`, text);
-}
-function ElConfirm(text: string): Promise<void> {
-  const B = (globalThis.ElementPlus ?? {}).ElMessageBox;
-  if (!B) return Promise.resolve();
-  return B.confirm(text, '操作确认', { confirmButtonText: '执行', cancelButtonText: '取消', type: 'warning' })
-    .then(() => undefined)
-    .catch(() => Promise.reject(new Error('cancel')));
-}
+/* 命令式 EP(epMessage/epConfirm)已收口到 @/utils/ep-message(UMD 全局单点读取) */
 
 /* ================= 选项数据(老师/学生/校区;机构级全量) ================= */
 const staffOpts = ref<MemberDTO[]>([]);
@@ -140,9 +129,9 @@ async function loadClasses(): Promise<void> {
   try {
     const r = await listClasses(selectedCourseId.value, { ...scope.toQuery(), pageSize: 200 });
     if (r.code === 200) classRows.value = r.data?.list ?? [];
-    else ElMessage_('error', r.message || '排班规则加载失败');
+    else epMessage('error', r.message || '排班规则加载失败');
   } catch {
-    ElMessage_('error', '网络连接异常');
+    epMessage('error', '网络连接异常');
   } finally {
     classLoading.value = false;
   }
@@ -199,11 +188,11 @@ function toggleWeek(v: number): void {
 async function doCreateClass(): Promise<void> {
   if (!selectedCourseId.value) return;
   if (!classForm.teacherId || !weeksToString() || !classForm.start || !classForm.end) {
-    ElMessage_('warning', '请填全老师/周/上课起止时间');
+    epMessage('warning', '请填全老师/周/上课起止时间');
     return;
   }
   if (Number(classForm.end) < Number(classForm.start)) {
-    ElMessage_('warning', '下课时间需晚于上课时间');
+    epMessage('warning', '下课时间需晚于上课时间');
     return;
   }
   classBusy.value = true;
@@ -217,15 +206,15 @@ async function doCreateClass(): Promise<void> {
       campusId: classForm.campusId,
     });
     if (r.code === 200) {
-      ElMessage_('success', '排班规则已创建');
+      epMessage('success', '排班规则已创建');
       classVisible.value = false;
       void loadClasses();
     } else {
       // 3122 排班冲突 / 2001 参数缺失:读 code 直显
-      ElMessage_('warning', r.message || `创建失败(code ${r.code})`);
+      epMessage('warning', r.message || `创建失败(code ${r.code})`);
     }
   } catch {
-    ElMessage_('error', '网络连接异常');
+    epMessage('error', '网络连接异常');
   } finally {
     classBusy.value = false;
   }
@@ -276,16 +265,16 @@ async function doGenerate(): Promise<void> {
   try {
     const r = await generateRecords(selectedCourseId.value, { days: genDays.value });
     if (r.code === 200 && r.data) {
-      if (r.data.changed) ElMessage_('success', `课表已更新:当前 ${r.data.generated} 节未开始课表(本次有新增)`);
-      else ElMessage_('warning', `无新增(幂等命中):当前 ${r.data.generated} 节未开始课表,请求 ${r.data.days} 天已覆盖`);
+      if (r.data.changed) epMessage('success', `课表已更新:当前 ${r.data.generated} 节未开始课表(本次有新增)`);
+      else epMessage('warning', `无新增(幂等命中):当前 ${r.data.generated} 节未开始课表,请求 ${r.data.days} 天已覆盖`);
       genVisible.value = false;
       recPage.value = 1;
       void fetchRecords();
     } else {
-      ElMessage_('warning', r.message || `生成失败(code ${r.code})`);
+      epMessage('warning', r.message || `生成失败(code ${r.code})`);
     }
   } catch {
-    ElMessage_('error', '网络连接异常');
+    epMessage('error', '网络连接异常');
   } finally {
     genBusy.value = false;
   }
@@ -298,15 +287,15 @@ async function doStart(rec: CourseRecordDTO): Promise<void> {
   try {
     const r = await startRecord(rec.recordId);
     if (r.code === 200) {
-      ElMessage_('success', '已开始上课');
+      epMessage('success', '已开始上课');
       void fetchRecords();
     } else {
       // 3121 状态非法:读 code 直显 + 刷新课表(状态可能已被他人变更)
-      ElMessage_('warning', r.message || `开始失败(code ${r.code})`);
+      epMessage('warning', r.message || `开始失败(code ${r.code})`);
       void fetchRecords();
     }
   } catch {
-    ElMessage_('error', '网络连接异常');
+    epMessage('error', '网络连接异常');
   } finally {
     startBusy.value = false;
   }
@@ -317,7 +306,7 @@ const finishBusy = ref(false);
 async function doFinish(rec: CourseRecordDTO): Promise<void> {
   if (finishBusy.value) return;
   try {
-    await ElConfirm(
+    await epConfirm(
       `确认结束课表 ${rec.recordId}(${formatDateTime(rec.startTime)})?\n本操作为单事务重操作:将同时产生 扣课时 + 余额变动 + 分账明细 + 课酬记录,不可撤销。`,
     );
   } catch {
@@ -327,10 +316,10 @@ async function doFinish(rec: CourseRecordDTO): Promise<void> {
   try {
     const r = await finishRecord(rec.recordId);
     if (r.code === 200 && r.data) {
-      ElMessage_('success', `下课结算完成:消耗 ${r.data.totalSpend},分账 ${r.data.divide.length} 条,课酬 ${r.data.payroll.reward}`);
+      epMessage('success', `下课结算完成:消耗 ${r.data.totalSpend},分账 ${r.data.divide.length} 条,课酬 ${r.data.payroll.reward}`);
     } else {
       // 3121 状态非法 / 其它:读 code 直显
-      ElMessage_('warning', r.message || `结束失败(code ${r.code})`);
+      epMessage('warning', r.message || `结束失败(code ${r.code})`);
     }
     // 契约明令:失败/成功后均刷新整页(课表 + 报课学生 + 欠费),不局部推断
     recPage.value = 1;
@@ -338,7 +327,7 @@ async function doFinish(rec: CourseRecordDTO): Promise<void> {
     void loadStudents();
     void fetchArrears();
   } catch {
-    ElMessage_('error', '网络连接异常');
+    epMessage('error', '网络连接异常');
     recPage.value = 1;
     void fetchRecords();
   } finally {
@@ -375,7 +364,7 @@ async function openAttendance(rec: CourseRecordDTO): Promise<void> {
   // 初值 = 端点 53 课程级报课学生(risk-1 免后端方案:52 提交时后端按该课实际学生匹配,不在课内的 userId 不生效)
   const r = await listCourseStudents(selectedCourseId.value, { ...scope.toQuery(), pageSize: 200 });
   if (r.code !== 200) {
-    ElMessage_('error', r.message || '学生初值加载失败');
+    epMessage('error', r.message || '学生初值加载失败');
     return;
   }
   attRows.value = (r.data?.list ?? []).map((s) => ({
@@ -405,16 +394,16 @@ async function doAttendance(): Promise<void> {
   try {
     const r = await checkAttendance(rec.recordId, { list });
     if (r.code === 200 && r.data) {
-      ElMessage_('success', `打卡完成:${r.data.checked} 人`);
+      epMessage('success', `打卡完成:${r.data.checked} 人`);
       attVisible.value = false;
       void fetchRecords();
     } else {
       // 3121 已结束不可再打卡:读 code 直显 + 刷新课表
-      ElMessage_('warning', r.message || `打卡失败(code ${r.code})`);
+      epMessage('warning', r.message || `打卡失败(code ${r.code})`);
       void fetchRecords();
     }
   } catch {
-    ElMessage_('error', '网络连接异常');
+    epMessage('error', '网络连接异常');
   } finally {
     attBusy.value = false;
   }
@@ -432,9 +421,9 @@ async function loadStudents(): Promise<void> {
   try {
     const r = await listCourseStudents(selectedCourseId.value, { ...scope.toQuery(), pageSize: 200 });
     if (r.code === 200) stuRows.value = r.data?.list ?? [];
-    else ElMessage_('error', r.message || '报课学生加载失败');
+    else epMessage('error', r.message || '报课学生加载失败');
   } catch {
-    ElMessage_('error', '网络连接异常');
+    epMessage('error', '网络连接异常');
   } finally {
     stuLoading.value = false;
   }
@@ -475,7 +464,7 @@ const recommenderOpts = computed(() => studentOpts.value.filter((m) => m.userId
 async function doEnroll(): Promise<void> {
   if (!selectedCourseId.value) return;
   if (!enrollForm.userId || !enrollForm.pay || !enrollForm.lessonCost || enrollForm.number <= 0) {
-    ElMessage_('warning', '请填全学生/课程费用/单节课时费用/课次');
+    epMessage('warning', '请填全学生/课程费用/单节课时费用/课次');
     return;
   }
   enrollBusy.value = true;
@@ -493,16 +482,16 @@ async function doEnroll(): Promise<void> {
       ...(enrollForm.remark ? { remark: enrollForm.remark } : {}),
     });
     if (r.code === 200) {
-      ElMessage_('success', '报名成功(仅记应收/实收快照,不写资金流水、不动余额;真实收款走财务域)');
+      epMessage('success', '报名成功(仅记应收/实收快照,不写资金流水、不动余额;真实收款走财务域)');
       enrollVisible.value = false;
       void loadStudents();
       void fetchRecords(); // 学生数变化
     } else {
       // 3143 重复报名(专用码)/ 2001 参数缺失:读 code 直显
-      ElMessage_('warning', r.message || `报名失败(code ${r.code})`);
+      epMessage('warning', r.message || `报名失败(code ${r.code})`);
     }
   } catch {
-    ElMessage_('error', '网络连接异常');
+    epMessage('error', '网络连接异常');
   } finally {
     enrollBusy.value = false;
   }
@@ -541,7 +530,7 @@ function openCounteract(row: ArrearsDTO): void {
 async function doCounteract(): Promise<void> {
   const t = actTarget.value;
   if (!t || !actMoney.value || actMoney.value <= 0) {
-    ElMessage_('warning', '请输入有效核销金额(>0)');
+    epMessage('warning', '请输入有效核销金额(>0)');
     return;
   }
   actBusy.value = true;
@@ -553,15 +542,15 @@ async function doCounteract(): Promise<void> {
       money: moneyPlain(actMoney.value),
     });
     if (r.code === 200 && r.data) {
-      ElMessage_('success', `核销完成:${studentName(t.studentId)} 核销 ${r.data.amount}`);
+      epMessage('success', `核销完成:${studentName(t.studentId)} 核销 ${r.data.amount}`);
       actVisible.value = false;
       arrPage.value = 1;
       void fetchArrears();
     } else {
-      ElMessage_('warning', r.message || `核销失败(code ${r.code})`);
+      epMessage('warning', r.message || `核销失败(code ${r.code})`);
     }
   } catch {
-    ElMessage_('error', '网络连接异常');
+    epMessage('error', '网络连接异常');
   } finally {
     actBusy.value = false;
   }
@@ -922,7 +911,7 @@ async function doCounteract(): Promise<void> {
 .schedule-page__section {
   margin-top: 16px;
   padding-top: 12px;
-  border-top: 1px solid var(--app-color-border, #e3e7ea);
+  border-top: 1px solid var(--app-color-border);
 }
 .schedule-page__head {
   display: flex;
@@ -948,7 +937,7 @@ async function doCounteract(): Promise<void> {
 }
 .schedule-page__hint {
   font-size: var(--app-font-sm);
-  color: var(--app-color-text-2, #5d6672);
+  color: var(--app-color-text-2);
 }
 .schedule-page__rec-actions {
   margin-top: 12px;
@@ -969,12 +958,12 @@ async function doCounteract(): Promise<void> {
 }
 .schedule-page__rec-label {
   font-size: var(--app-font-md);
-  color: var(--app-color-text-2, #5d6672);
+  color: var(--app-color-text-2);
   min-width: 280px;
 }
 .schedule-page__rec-done {
   font-size: var(--app-font-sm);
-  color: var(--app-color-text-2, #5d6672);
+  color: var(--app-color-text-2);
 }
 .att-loading {
   margin-top: 12px;

+ 1 - 1
src/views/common/placeholder.vue

@@ -41,6 +41,6 @@ defineProps<{
   font-size: 20px;
   font-weight: 600;
   color: #fff;
-  background: var(--app-brand-navy, #1e2a54);
+  background: var(--app-brand-navy);
 }
 </style>

+ 55 - 9
src/views/layout/index.vue

@@ -19,6 +19,7 @@
 import { useLogin } from '@/store/login';
 import { useScope } from '@/store/scope';
 import { listInstitutions } from '@/api/org/institution';
+import type { MenuNode } from '@/types/api';
 import SideMenu from '@/components/side-menu.vue';
 
 const login = useLogin();
@@ -26,6 +27,27 @@ const scope = useScope();
 const route = useRoute();
 const router = useRouter();
 
+/**
+ * 顶栏面包屑(设计源 §3:顶栏 = 作用域 pill + 面包屑 + 用户)。
+ * 从 menuTree 反查当前 route.path 的祖先 label 链(域分组 → 页),渲染为 el-breadcrumb。
+ * 入参:无(读 login.menuTree + route.path)。出参:{ label }[];路径不在菜单中(占位页等)→ [](顶栏不显示面包屑,优雅降级)。
+ */
+const breadcrumb = computed<{ label: string }[]>(() => {
+  const target = route.path;
+  if (!target) return [];
+  const walk = (nodes: MenuNode[], trail: string[]): string[] | null => {
+    for (const n of nodes) {
+      if (n.path === target) return [...trail, n.label];
+      if (n.children && n.children.length) {
+        const hit = walk(n.children, [...trail, n.label]);
+        if (hit) return hit;
+      }
+    }
+    return null;
+  };
+  return (walk(login.menuTree, []) ?? []).map((label) => ({ label }));
+});
+
 /**
  * 解析当前机构名称(展示用)。
  *
@@ -84,23 +106,33 @@ async function handleLogout(): Promise<void> {
     </el-aside>
 
     <el-container class="app-layout__body">
-      <!-- 顶栏:机构作用域 + 当前用户 + 退出 -->
+      <!-- 顶栏:左组(机构作用域 pill + 面包屑) + 右组(用户 + 退出) -->
       <el-header class="app-layout__header">
-        <span class="app-layout__scope" v-if="scope.ready">
-          机构:{{ scope.institutionName || `#${scope.current.institutionId}` }}
-          <el-button link type="primary" @click="router.push('/org/scope')" class="app-layout__switch">
-            切换
-          </el-button>
-        </span>
+        <div class="app-layout__header-left">
+          <span class="app-layout__scope" v-if="scope.ready">
+            机构:{{ scope.institutionName || `#${scope.current.institutionId}` }}
+            <el-button link type="primary" @click="router.push('/org/scope')" class="app-layout__switch">
+              切换
+            </el-button>
+          </span>
+          <!-- 面包屑:域分组 → 当前页(menuTree 反查;占位页不在菜单则不显示) -->
+          <el-breadcrumb v-if="breadcrumb.length" separator="/" class="app-layout__crumb">
+            <el-breadcrumb-item v-for="(item, i) in breadcrumb" :key="i">
+              {{ item.label }}
+            </el-breadcrumb-item>
+          </el-breadcrumb>
+        </div>
         <span class="app-layout__user">
           <span>{{ login.realName }}</span>
           <el-button link type="primary" @click="handleLogout">退出</el-button>
         </span>
       </el-header>
 
-      <!-- 内容区:嵌套路由出口(业务域页面渲染于此) -->
+      <!-- 内容区:嵌套路由出口(业务域页面渲染于此);宽屏限宽 1440 居中(token §3 max-width 1200-1440) -->
       <el-main class="app-layout__main">
-        <router-view />
+        <div class="app-layout__content">
+          <router-view />
+        </div>
       </el-main>
     </el-container>
   </el-container>
@@ -164,14 +196,28 @@ async function handleLogout(): Promise<void> {
   background: var(--app-bg-card);
   border-bottom: 1px solid var(--app-bg-page);
 }
+.app-layout__header-left {
+  display: flex;
+  align-items: center;
+  gap: var(--app-spacing-md);
+  min-width: 0;
+}
 .app-layout__scope,
 .app-layout__user {
   font-size: var(--app-font-md);
   color: var(--app-text-primary);
 }
+.app-layout__crumb {
+  font-size: var(--app-font-md);
+}
+/* 内容区限宽 1440 居中(token §3 宽屏布局升级:超宽屏时表格不拉满整屏) */
 .app-layout__main {
   background: var(--app-bg-page);
   padding: var(--app-spacing-md);
   overflow-y: auto;
 }
+.app-layout__content {
+  max-width: 1440px;
+  margin: 0 auto;
+}
 </style>