Files
lijing a9ba1281c2
build / build (push) Successful in 5m3s
fix: 修复退出跳错域、二级页高亮与落地页权限口径
- host.config:新增④按站推导 ssoHost(base.* → sso.*),logout 加空值保护,修线上退出落到 base 域

- layout:记 lastMenuPage,二级页高亮跟随来源节点并自动展开所在分组(expandToActive)

- App.vue:权限口径=入口页(菜单节点)∪落地页(meta.parentId),落地页一律放行只谈高亮;initShell 首行 await router.isReady() 修深链接竞态

- routes:/signups 补 meta.parentId,修「报名情况不让点」(落地页漏声明会被当入口拦)

- menuRoute:抽 flattenMenuNodes / firstMenuPath 共用口径
2026-09-21 10:48:13 +08:00

111 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 家校活动信息发布(family-activity)
武校家校信息发布模块 —— **活动信息发布** 子模块。用于学校(德育处 / 武术训练部 / 教务处 / 校医务室等)向家长发布活动通知,
并回收家长的**阅读已读**与**报名回执**。
> 以 `D:\项目管理\CourseHomework`(课程作业)为模板工程改造:保留其 Vue3 + Vite + view-ui-plus + alova + vite-plugin-mock
> 技术栈、布局壳层、权限菜单契约、表格/分页/选人/富文本/附件等基础设施,业务域整体替换为「家校活动」。
## 快速开始
```bash
# 纯前端演示(内置 mock 数据,无需后端)
npm run dev:mock
# 联调本地后端
npm run dev
# 构建(产物直出 dist/)
npm run build
```
mock 模式访问:<http://localhost:8078/>
## 功能范围
### 发布端(教师 / 教练 / 处室)
| 菜单 | 路由 | 说明 |
| --- | --- | --- |
| 我发布的 | `/list` | 自己发布的活动列表,按流程状态分标签(全部/报名中/已截止/已结束/草稿) |
| 全部活动 | `/activitylist` | 管理端全量列表(同一组件,去掉 `scope=user`) |
| 发布活动 | `/list/new` | 新建 / 编辑 / 复用再发;支持存草稿与定时发布 |
| 活动详情 | `/list/detail`、`/activitylist/detail` | `type=0` 管理视角(阅读进度 + 回执进度 + 编辑/打印/复用);`type=1` 查看视角 |
| 报名情况 | `/signups` | 家长回执名单:按班级分组的汇总卡 + 回执状态筛选 + 回执详情 + 代为确认/取消 + 催报名 + 导出 CSV |
| 活动统计 | `/stats` | 总量卡、活动分类分布、月度发布趋势、按发布人统计、我发布活动的回执与名额 |
| 应用设置 | `/setting` | 主办部门(字典,可拖拽排序/启停)+ 自定义组(联系组与成员) |
### 家长端(家长 / 学生,演示用)
| 菜单 | 路由 | 说明 |
| --- | --- | --- |
| 活动广场 | `/par/activities` | 左列表右详情,按「全部 / 待回执 / 已报名 / 已结束」筛选,阅读即标读 |
| 我的报名 | `/par/mine` | 同组件,默认落「已报名」标签 |
| 发布端(教师) | `/list` | 从家长端切回发布端的入口(mock 演示便利项) |
## 业务模型要点
- **发布对象到班级一级**:`receiveList = "ALL"` 或班级 id 逗号串;应收人数、已读人数按班级学生数累加(家长即挂在学生档案上的联系人)。
- **回执方式 `receiptMode`**:
- `none` 仅通知 —— 只统计已读,不产生报名记录(如放假通知、安全演练告知);
- `signup` 需报名 —— 家长填报参加人数(可含随行家长),可设名额上限与随行开关;
- `confirm` 需确认 —— 家长端**打开活动详情即自动确认**,无需任何点击(如家长会、考核反馈)。
- **流程状态 `actStatus`**:`draft` 草稿 → `signup` 报名中 → `closed` 已截止 → `finished` 已结束。
- **回执状态 `status`**:`none` 未回执 / `signed` 已报名 / `confirmed` 已确认 / `canceled` 已取消(请假)。
- **名额**:`quota` 为 0 表示不限;按家长填报的参加人数累计占用,满额后拒绝新报名。
## 目录结构
```
src/
├── api/module/activity.ts # 活动域接口封装(get/postJSON/postForm 三件套,统一 {code,msg,data} 解包)
├── assets/
│ ├── config/menu.ts # 左侧菜单(对接后端 GetUserMod 前的兜底)
│ ├── mock/activityapi.ts # 本地 mock:种子数据 + 全部接口实现
│ └── styles/ # 平台主题 / 布局 / iconfont(沿用模板)
├── components/ # 布局壳层、表格、选人、上传、富文本附件等(沿用模板)
├── router/routes.ts # 扁平短路由 + modId 契约(菜单 ↔ 路由)
├── types/activity.ts # 活动域 TS 契约(对齐后端 VO/DTO)
└── views/activity/ # 业务页面(列表 / 发布 / 详情 / 报名 / 统计 / 设置 / 家长端)
```
## mock 演示数据
`src/assets/mock/activityapi.ts` 内置一套武校家校场景数据:
- 学校:少林文武学校;班级:小学部(三/四年级)、初中部(初一)、武术部(套路表演班、散打竞技班),共 7 个班 259 名学生;
- 8 条活动:金秋武术套路比赛、秋季家长会、国庆离校告知、体质健康测试、中秋文艺汇演、新生基本功考核、嵩山研学(草稿)、消防疏散演练;
- 每条活动按班级花名册生成完整回执记录(含家长姓名、手机号、参加人数、备注),并派生回执率与名额占用;
- 当前登录身份:德育处 **王建山**(发布端);家长端演示学生:三年级(1)班 第 1 位学生。
## 与模板工程的差异
| 方面 | 模板(课程作业) | 本工程(家校活动) |
| --- | --- | --- |
| 域模型 | 作业 / 提交 / 批改 | 活动 / 回执报名 / 名额 |
| 回执闭环 | 学生提交作业 → 教师批改 | 家长阅读 + 报名回执 → 学校核验/催办/导出 |
| 菜单 modId | 1/2/3/4/21/25/26/31/32 | 2/3/4/21/25/31/32(+22/23/24 深层子页) |
| 家长端前缀 | `/stu/*`(学生端) | `/par/*`(家长端) |
| 端口 | 8077 | 8078 |
| 已下线 | 审批流、批改中心、学生端提交 | —— |
## 交互实现要点(易踩坑,改动时别再踩回去)
下列几处是 view-ui-plus 的 API 契约(不是业务代码)导致的坑,已在工程内固化做法。
排查同类问题时**先读 `node_modules/view-ui-plus/src/components/<组件>/*.vue`,再怀疑业务代码**。
| 现象 | 根因 | 现行做法 |
| --- | --- | --- |
| 必填项「选完/填完值,红字还挂着」 | `FormItem.getFilteredRule(trigger)` 只保留 `!rule.trigger \|\| rule.trigger 命中` 的规则。控件只发它自己那种事件(Select/DatePicker/RadioGroup/Number 发 `change`,Input 发 `change`+`blur`):规则写了 `blur` 就漏掉 `change`,写了 `change` 就漏掉 `blur`,校验根本不执行,上一次的红字原地不动 | 规则**一律不写 `trigger`**(`NewActivity`、`ParentActivities`、`ActivitySetting`、`DictCardManager` 均已去掉)。数字字段必须显式 `type: "number"` |
| 同上,但值来自**程序化赋值** | 勾「全部班级」把数组填满、编辑/复用回显、弹窗回填都不产生控件事件,trigger 修法也救不了 | `Utils.clearFilledValidateState(formRef, props)` 按「值已填但仍标 error」兜底清状态;发布页 watch 字段值 + `nextTick` 调用 |
| 点左边栏「右边不刷新,点两次才刷」/ 家长端点菜单没反应 | `/list` ↔ `/activitylist`、`/par/activities` ↔ `/par/mine` 各自共用同一组件,Vue 复用实例 → `setup()`/`onMounted` 不重跑;点当前项又走 `router.go(0)`(整页 reload、白屏一下) | `App.vue` 给 `<router-view>` 加 `:key="viewKey"`(`route.fullPath + tick`)并提供 `refreshView`;`layout.vue` 的 `linkMenu` 点当前页时调 `refreshView` 而非 `router.go(0)` |
| 从一级节点点进二级页(发布活动 / 活动详情),**左边栏失去高亮** | 二级页(`/list/new`、`/list/detail`…)不在菜单树里,原代码只按 `meta.parentId` 静态兜底,与「你从哪个节点点进来的」无关 | `layout.vue` 用 `lastMenuPage` 记住**上一次命中的菜单节点**:二级页优先拿它当父节点(从「全部活动」进发布活动就选「全部活动」),无来源(冷启动/刷新/直接粘 URL)才退回 `meta.parentId`;末位兜底改成**保持**现有高亮而不再清空。另:分组默认收起时自动展开选中项所在分组(`expandToActive`),否则「选中了但看不见」 |
| 二级页被当成「无权页面」弹回第一个节点 | 曾把二级页纳入菜单白名单(要求父模块也得在 `GetUserMod` 里) | **只有一级节点(菜单节点)参与权限判断**:`App.vue` 的 `allowedPaths` = 菜单节点 ∪ 全部 `meta.parentId` 二级页。二级页是「点进去的落地页」,只谈高亮、不谈权限,一律放行 |
| **点「报名情况」按钮"不让点"**(被弹回第一个节点 + 一条无权提示) | 「落地页」是靠 `meta.parentId` 声明的,**不是**靠"在不在菜单里"。`/signups` 既能从左侧菜单进、又能从活动列表行操作进,却漏声明了 `parentId` → 被归入「一级入口」判权限;而真实环境账号的 `GetUserMod` 里常常没配「报名情况」节点 → 点按钮直接被守卫拦掉(按钮本身没有 `disabled`,极易误判成交互/样式问题) | 给 `/signups` 补 `meta.parentId: 2`。**规则:凡是页面内 `router.push` 得到的目标路由,都必须在 `routes.ts` 声明 `meta.parentId`**,否则会被当入口拦。自查:`grep -rn "router\.push\|router\.replace" src --include=*.vue \| grep -v router/index` 穷举全部跳转目标(本工程只有 `/list/new`、`/list/detail`、`/activitylist/detail`、`/signups` 四个)。补声明不影响高亮:`layout.vue` 会先命中「目标本身就是菜单节点」分支 |
| 教师账号能直接打开 `/par/mine` 等家长端页面 | 曾为解决上一条把权限放宽成「按端放行」(`/par` 前缀整端放行)—— 而教师菜单里有「家长端预览」指向 `/par/activities`,于是 `/par` 整端被放行 | **权限边界落在「入口页 vs 落地页」,不是「哪一端」**:跨端访问(目标页是入口页、且菜单里没有)照旧 `replace` 到第一个可用节点 |
| 冷启动 / 刷新任何非首节点路由都被弹回 `/#/list`(深链接、书签、刷新详情页全废) | `main.ts` 是同步 `app.mount()`,`onMounted` 里的初始化有机会**早于** router 首次导航完成,此时 `currentRoute` 还停在 `START_LOCATION`(`path === "/"`),于是每个深链接都被当成「访问应用入口」而重定向 | `initShell()` 首行 `await router.isReady()`;入口地址交给路由表自身的 `/ → /list` 重定向 |
| Tabs 高亮不跟随变量/路由 | `Tabs` 的 props 里**没有 `value`**,只有 `modelValue`。`:value` 会被当成普通 HTML 属性 fallthrough 到根元素,高亮永远退回第一个 tab(点击有反应是因为组件内部自己改 `activeKey`,所以只表现为「点进来高亮不对」) | 统一 `v-model` / `:model-value` |
| 表格压住分页 | 表格高度用 `Utils.useTableHeight({...})` 常量估算,漏算 Tabs 栏与容器内边距(约 78px),而 `.page-panel` 是 absolute 吸底 | 列表页改用**真实 DOM 实测**:`分页顶边 − 表格顶边 − 12 − (列宽合计超出容器 ? 15 : 0)`;`onMounted` + `resize` 重算 |
| 表头挤成两行 | 为了「收进一屏」合并列 / 把列宽压太窄 | 让表格**横向滚动**(列宽给足),操作列 `fixed: "right"`;序号列宽 ≥ 76px(单元格左右各 18px padding,「序号」两字约需 36px)。横向滚动条样式在 `App.vue` 里覆写 `.ivu-table-overflowX::-webkit-scrollbar`(8px 圆角) |
| 附件名挤在「选择文件」按钮右边 | `Upload` 根节点内部是 `.ivu-upload-select` + `ul.ivu-upload-list` 两个兄弟节点;根节点一旦是 `flex/inline-flex`,`ul` 作为 flex item 会被排到按钮同排 | `.ivu-upload` 保持 `display: block`(关键),只让 `.ivu-upload-select` 内部 `inline-flex` 对齐;`.ivu-upload-list` 另起一行、每项独占一行(见 `components/AttachmentUpload.vue`) |