build / build (push) Successful in 5m3s
- 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 共用口径
111 lines
11 KiB
Markdown
111 lines
11 KiB
Markdown
# 家校活动信息发布(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`) |
|