Browse Source

设计审查 P0 三处硬伤修复:字体 token 落地 / 金额千分位+等宽数字 / 顶栏机构名

P0-3 全局字体:variables.scss 新增 --app-font(Noto Sans SC 优先),App.vue 改引 token 并去 Helvetica 优先,element-plus-theme.scss 补 --el-font-family 覆盖 EP 默认栈
P0-2 金额:formatMoney 入参扩 number|string,fee 页流水/工单/仪表盘/余额分布接千分位(仪表盘 el-statistic 换自绘 stat-card),ListPage 表格 tabular-nums,activity 活动金额接 formatMoney
P0-1 顶栏:scope.ts 加 institutionName 展示字段+setInstitutionName,layout watch institutionId 就绪后 listInstitutions 反查回填(seq 防竞态),未解析回退 #id
Q 11 hours ago
parent
commit
1c5b4e3bf1

+ 3 - 2
src/App.vue

@@ -31,7 +31,8 @@ body {
 }
 #app {
   height: 100%;
-  font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
-  color: var(--app-text-primary);
+  /* 品牌字体栈走 token(design-tokens.md §2:Noto Sans SC 优先,禁 Helvetica 优先) */
+  font-family: var(--app-font);
+  color: var(--app-color-text);
 }
 </style>

+ 4 - 0
src/components/ListPage.vue

@@ -145,4 +145,8 @@ const emit = defineEmits<{
 .list-page__empty {
   color: var(--app-text-secondary);
 }
+/* 表格数字列等宽对齐(design-tokens.md §2:金额/统计 tabular-nums,防错位) */
+.list-page :deep(.el-table .cell) {
+  font-variant-numeric: tabular-nums;
+}
 </style>

+ 28 - 1
src/store/scope.ts

@@ -18,6 +18,12 @@ export interface ScopeState {
   institutionId: number;
   /** 校区 id(作用域第二级;0 = 机构级,查全机构数据) */
   campusId: number;
+  /**
+   * 机构名称(**展示用**,非作用域判定依据;可空 = 尚未解析)。
+   * 作用域判定只认 institutionId/campusId;名称由 layout 壳按 institutionId 反查机构列表后回填,
+   * 仅供顶栏/页头展示,缺失时调用方回退显示 institutionId。
+   */
+  institutionName?: string;
 }
 
 import type { ComputedRef, Ref } from 'vue';
@@ -30,10 +36,14 @@ import type { ComputedRef, Ref } from 'vue';
 export interface ScopeStore {
   /** 当前作用域(未设置时 institutionId=0 表示"未就绪") */
   current: Ref<ScopeState>;
+  /** 当前机构名称(展示用响应式视图;'' = 未解析,调用方回退显示 id) */
+  institutionName: Ref<string>;
   /** 作用域是否就绪(institutionId > 0;守卫链第四步检查) */
   ready: ComputedRef<boolean>;
   /** 设置作用域(作用域选择器提交 / 登录后按 token 下发初始化) */
   set: (sc: ScopeState) => void;
+  /** 回填当前机构名称(展示用;layout 反查机构列表后调用。只改名称,不动 id 作用域) */
+  setInstitutionName: (name: string) => void;
   /** 转为请求 query 片段(useListPage 自动并入;campus=0 时省略 campusId 参数) */
   toQuery: () => { institutionId: number; campusId?: number };
   /** 清空(切换机构前 / 登出时调用) */
@@ -42,6 +52,8 @@ export interface ScopeStore {
 
 export const useScope = defineStore('scope', (): ScopeStore => {
   const current = ref<ScopeState>({ institutionId: 0, campusId: 0 });
+  /** 当前机构名称(展示用,非作用域判定依据;'' = 未解析,顶栏回退显示 id) */
+  const institutionName = ref('');
 
   /** 就绪判定:机构 id 已设置(campus 可为 0 = 机构级) */
   const ready = computed(() => current.value.institutionId > 0);
@@ -80,10 +92,25 @@ export const useScope = defineStore('scope', (): ScopeStore => {
     return q;
   }
 
+  /**
+   * 回填当前机构名称(展示用;layout 壳按 institutionId 反查机构列表后调用)。
+   *
+   * 入参:
+   *   - name string:机构名称;空串视为"未解析"(顶栏回退显示 institutionId)。
+   *
+   * 出参:无。同步写入 current.institutionName(保持 single source of truth),并置 institutionName 视图。
+   * 异常场景:无(纯展示字段,不触碰作用域判定)。
+   */
+  function setInstitutionName(name: string): void {
+    current.value.institutionName = name || undefined;
+    institutionName.value = name || '';
+  }
+
   /** 清空作用域(登出 / 切换机构前调用;页面随后被守卫拦截) */
   function reset(): void {
     current.value = { institutionId: 0, campusId: 0 };
+    institutionName.value = '';
   }
 
-  return { current, ready, set, toQuery, reset };
+  return { current, institutionName, ready, set, toQuery, setInstitutionName, reset };
 });

+ 3 - 0
src/styles/element-plus-theme.scss

@@ -39,6 +39,9 @@
   --el-border-color-lighter: #e3e7ea;
   --el-border-color-hover: #cbd2d8;
 
+  /* 字体:EP 组件字体栈指回品牌 token(design-tokens.md §2;EP 默认 Helvetica 优先,须覆盖) */
+  --el-font-family: var(--app-font);
+
   /* 文字(对齐品牌石板灰蓝系) */
   --el-text-color-primary: #2a3242;
   --el-text-color-regular: #2a3242;

+ 3 - 0
src/styles/variables.scss

@@ -33,6 +33,9 @@
   --app-font-md: 14px;
   --app-font-lg: 16px;
 
+  /* 字体栈(design-tokens.md §2:系统字体栈,Noto Sans SC 优先,不引外部字体文件) */
+  --app-font: 'Noto Sans SC', 'Source Han Sans SC', 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, 'Segoe UI', sans-serif;
+
   /* 圆角/阴影 */
   --app-radius-sm: 4px;
   --app-radius-md: 6px;

+ 6 - 5
src/utils/format.ts

@@ -15,17 +15,18 @@
  * 金额格式化(千分位 + 两位小数)。
  *
  * 入参:
- *   - value number:原始金额(元);NaN/负无穷时按 0 处理(后端金额不应出现,防御性兜底)。
+ *   - value number | string:原始金额(元);后端金额多为字符串(如 "1234.56"),此处统一解析。
+ *     NaN/负无穷/空串时按 0 处理(后端金额不应出现,防御性兜底)。
  *
  * 出参:
  *   - string:如 "1,234.50";整数值不带小数点后的多余 0("1,235.00" 仍保留两位,财务口径统一两位)。
  *
  * 异常场景:
- *   - value 非有限数:返回 "0.00"(调用方应排查上游数据,不抛错)。
+ *   - value 解析后非有限数:返回 "0.00"(调用方应排查上游数据,不抛错)。
  */
-export function formatMoney(value: number): string {
-  const n = Number.isFinite(value) ? value : 0;
-  return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+export function formatMoney(value: number | string): string {
+  const n = Number(value);
+  return Number.isFinite(n) ? n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '0.00';
 }
 
 /**

+ 2 - 2
src/views/academic/activity/index.vue

@@ -23,7 +23,7 @@ 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 { formatDateTime } from '@/utils/format';
+import { formatDateTime, formatMoney } from '@/utils/format';
 
 const scope = useScope();
 
@@ -190,7 +190,7 @@ const signStatusText: Record<number, string> = { 0: '已取消', 1: '报名', 2:
         </el-select>
         <el-button type="primary" :disabled="!selectedActivityId" @click="openSign">新增报名</el-button>
         <span v-if="selectedActivity" class="activity-page__sign-hint">
-          报名 {{ selectedActivity.signed }}/{{ selectedActivity.total }} · 活动金额 {{ selectedActivity.money }} · 状态 {{ statusText[selectedActivity.status] }}
+          报名 {{ selectedActivity.signed }}/{{ selectedActivity.total }} · 活动金额 ¥{{ formatMoney(selectedActivity.money) }} · 状态 {{ statusText[selectedActivity.status] }}
         </span>
       </div>
 

+ 33 - 10
src/views/academic/fee/index.vue

@@ -28,8 +28,7 @@ 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 { moneyPlain } from '@/utils/format';
-import { formatDateTime } from '@/utils/format';
+import { moneyPlain, formatMoney, formatDateTime } from '@/utils/format';
 
 const scope = useScope();
 
@@ -97,8 +96,8 @@ const typeText: Record<number, string> = { 100: '充值', 101: '消费', 106: '
 const logColumns: ListColumn<ExpenseLogDTO>[] = [
   { prop: 'id', label: '流水号', minWidth: 90 },
   { prop: 'type', label: '类型', minWidth: 90, formatter: (r) => typeText[r.type] ?? String(r.type) },
-  { prop: 'money', label: '金额', minWidth: 110, formatter: (r) => `${r.money}` },
-  { prop: 'balanceAfter', label: '余额(后)', minWidth: 110 },
+  { prop: 'money', label: '金额', minWidth: 110, formatter: (r) => formatMoney(r.money) },
+  { prop: 'balanceAfter', label: '余额(后)', minWidth: 110, formatter: (r) => formatMoney(r.balanceAfter) },
   { prop: 'remark', label: '备注', minWidth: 140 },
   { prop: 'createTime', label: '时间', minWidth: 150, formatter: (r) => formatDateTime(r.createTime) },
 ];
@@ -233,7 +232,7 @@ const reqStatusText: Record<number, string> = { 0: '待审', 1: '已通过', '-1
 const reqColumns: ListColumn<BalanceRequestDTO>[] = [
   { prop: 'bizNo', label: '单号', minWidth: 140 },
   { prop: 'type', label: '类型', minWidth: 110, formatter: (r) => reqTypeText[r.type] ?? String(r.type) },
-  { prop: 'money', label: '金额', minWidth: 100 },
+  { prop: 'money', label: '金额', minWidth: 100, formatter: (r) => formatMoney(r.money) },
   { prop: 'approved', label: '状态', minWidth: 90, formatter: (r) => reqStatusText[r.approved] ?? String(r.approved) },
   { prop: 'remark', label: '备注', minWidth: 120 },
   { prop: 'requestTime', label: '申请时间', minWidth: 140, formatter: (r) => formatDateTime(r.requestTime) },
@@ -301,16 +300,16 @@ const ops: FeeOp[] = [
 
     <!-- ② 仪表盘 -->
     <el-row v-if="dash" :gutter="12" class="fee-page__dash">
-      <el-col :span="4"><el-card shadow="never"><el-statistic :value="parseFloat(dash.balance)" :precision="2" prefix="¥" title="账户余额" /></el-card></el-col>
-      <el-col :span="4"><el-card shadow="never"><el-statistic :value="parseFloat(dash.totalIncome)" :precision="2" prefix="¥" title="累计充值" /></el-card></el-col>
-      <el-col :span="4"><el-card shadow="never"><el-statistic :value="parseFloat(dash.totalExpenditure)" :precision="2" prefix="¥" title="累计支出" /></el-card></el-col>
-      <el-col :span="4"><el-card shadow="never"><el-statistic :value="parseFloat(dash.specialExpenses)" :precision="2" prefix="¥" title="特殊费用" /></el-card></el-col>
+      <el-col :span="4"><div class="stat-card"><div class="stat-card__label">账户余额</div><div class="stat-card__value">¥ {{ formatMoney(dash.balance) }}</div></div></el-col>
+      <el-col :span="4"><div class="stat-card"><div class="stat-card__label">累计充值</div><div class="stat-card__value">¥ {{ formatMoney(dash.totalIncome) }}</div></div></el-col>
+      <el-col :span="4"><div class="stat-card"><div class="stat-card__label">累计支出</div><div class="stat-card__value">¥ {{ formatMoney(dash.totalExpenditure) }}</div></div></el-col>
+      <el-col :span="4"><div class="stat-card"><div class="stat-card__label">特殊费用</div><div class="stat-card__value">¥ {{ formatMoney(dash.specialExpenses) }}</div></div></el-col>
       <el-col :span="8">
         <el-card shadow="never">
           <template #header>校区余额分布</template>
           <div v-for="d in dash.balanceDistribution" :key="d.campusId" class="fee-page__dist">
             <span>{{ campusName(d.campusId) }}</span>
-            <span>¥ {{ d.balance }}</span>
+            <span class="fee-page__dist-num">¥ {{ formatMoney(d.balance) }}</span>
           </div>
         </el-card>
       </el-col>
@@ -422,6 +421,30 @@ const ops: FeeOp[] = [
   padding: 4px 0;
   font-size: var(--app-font-md);
 }
+.fee-page__dist-num {
+  font-variant-numeric: tabular-nums;
+  color: var(--app-color-text);
+}
+/* 统计卡(标签小字在上、金额左对齐,等宽数字——design-tokens.md §3 对齐口径) */
+.stat-card {
+  background: var(--app-color-card);
+  border: 1px solid var(--app-color-border);
+  border-radius: var(--app-radius-md);
+  padding: 14px 16px;
+  height: 100%;
+  box-sizing: border-box;
+}
+.stat-card__label {
+  font-size: var(--app-font-sm);
+  color: var(--app-color-text-2);
+  margin-bottom: 6px;
+}
+.stat-card__value {
+  font-size: 20px;
+  font-weight: 600;
+  color: var(--app-color-text);
+  font-variant-numeric: tabular-nums;
+}
 .fee-page__tabs {
   margin-top: 4px;
 }

+ 32 - 1
src/views/layout/index.vue

@@ -18,6 +18,7 @@
  */
 import { useLogin } from '@/store/login';
 import { useScope } from '@/store/scope';
+import { listInstitutions } from '@/api/org/institution';
 import SideMenu from '@/components/side-menu.vue';
 
 const login = useLogin();
@@ -25,6 +26,36 @@ const scope = useScope();
 const route = useRoute();
 const router = useRouter();
 
+/**
+ * 解析当前机构名称(展示用)。
+ *
+ * 作用域 store 只存 institutionId/campusId(数字),顶栏需要显示机构名。
+ * 在作用域就绪(institutionId>0)时按 institutionId 反查机构列表,取匹配项的名称回填
+ * scope.institutionName;解析失败/未匹配则保持空串(顶栏回退显示 id,不影响功能)。
+ *
+ * 竞态防护:切换机构时旧请求若晚返回会覆盖新机构名——用 seq 令牌,仅最新一次请求可写。
+ * 本逻辑纯展示,不触碰作用域判定与守卫链。
+ */
+let nameSeq = 0;
+watch(
+  () => scope.current.institutionId,
+  async (id) => {
+    if (id <= 0) return; // 未就绪:不请求
+    const seq = ++nameSeq;
+    try {
+      const r = await listInstitutions({ institutionId: id, page: 1, pageSize: 200 });
+      if (seq !== nameSeq) return; // 已有更新的作用域切换:丢弃过期结果
+      if (r.code === 200 && r.data?.list) {
+        const hit = r.data.list.find((x) => x.institutionId === id);
+        scope.setInstitutionName(hit?.institutionName ?? '');
+      }
+    } catch {
+      /* 网络/作用域越界:保持空名(顶栏回退显示 id;不阻断主流程) */
+    }
+  },
+  { immediate: true },
+);
+
 /** 退出二次确认(敏感操作,README §3.3-9)→ 登出 → 跳登录页 */
 async function handleLogout(): Promise<void> {
   const ElMessageBox = (globalThis.ElementPlus ?? {}).ElMessageBox;
@@ -56,7 +87,7 @@ async function handleLogout(): Promise<void> {
       <!-- 顶栏:机构作用域 + 当前用户 + 退出 -->
       <el-header class="app-layout__header">
         <span class="app-layout__scope" v-if="scope.ready">
-          机构:{{ scope.current.institutionId }}
+          机构:{{ scope.institutionName || `#${scope.current.institutionId}` }}
           <el-button link type="primary" @click="router.push('/org/scope')" class="app-layout__switch">
             切换
           </el-button>