把 5 个模块「新建/发布」页的字段与提交体逐个核对后,补齐此前缺失或形态不符的部分:
- 部门名镜像:公告/宿舍/活动三处在模块页是「字典 id + 反查名称」一起提交。
新增 mirrorLabelOf 机制 —— 该字段不渲染控件、不进模型 schema,提交时按源字段
选中项的显示名填充(deptName)
- 公告审批流:新增 flow 控件(widget=flow)+ 教师组织树字典 notice.teaTree,
提交 flow:[{name,vote,approverTeaIds}];publishType 值域修正为 direct/approve;
存草稿改用模块自己的独立接口 notice.saveDraft(POST /api/notice/SaveDraft)
- 活动分类:category 提交的是**分类名称**(模块页 :value="String(c.v)"),
optionsKeys 由 ("k","v") 改为 ("v","v")
- 请假附件:fileIds 在 HomeLeave 是 number[](JSON 数组),去掉逗号串拼接;
连续请假按 continuousLeaveNum 逐天展开成 continuousLeaveTime[](服务端/浏览器同源)
- 作业发布(整块新增):homework.publish / schedule / published / drafts /
teachingSubjects / classTree。模块没有一步发布的接口,用 ToolDef.then 声明两步提交
(Create 落草稿 → Publish | Schedule);附件是对象数组
attachments:[{fileId,fileUrl,fileType,fileName,sort}],上传走 file 域
/coursehomework/api/file/Upload
- 执行器:新增 followUp 两步提交(浏览器侧同源)、数组参数给标量时包成单元素数组、
首步响应 data 取资源 id(对象形态取 .id,避免拼出 /homeworks/{"id":1}/publish)
- 新增 ModuleFormParityTest(13 项)逐条锁住上述契约
回归:mvn clean package → Tests run: 103, Failures: 0, Errors: 0, BUILD SUCCESS
真机:headless Chrome 走「表单 → 浏览器代执行 → 模块接口」,50 项断言全绿
This commit is contained in:
@@ -0,0 +1,454 @@
|
|||||||
|
# 校园智能助手 · 五模块能力盘点与工具补全建议
|
||||||
|
|
||||||
|
> 盘点日期:2026-10-12 范围:AgentService 已对接的 5 个业务模块(请假 / 校园公告 / 宿舍通知 / 家校活动 / 课程作业)
|
||||||
|
> 方法:逐模块通读前端 `src/api/module/*.ts` + `src/router/routes.ts` + 权限守卫,与 AgentService 现有 `tool/defs/*.java` 做差集。
|
||||||
|
> 目标:① 说清每个模块的**功能与权限**怎么划;② 指出**哪些写操作可以做成表单**;③ 挖出**模块有、助手还没接**的能力。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 结论速览
|
||||||
|
|
||||||
|
| 模块 | 已接工具 | 建议新增 | 一句话结论 |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| `homeleave` 请假 | 12 | **+6** | 最完整的一个,缺的是「删除 / 类型字典维护 / 按班查列表」 |
|
||||||
|
| `campusnotice` 校园公告 | 9 | **+14** | 发得出,但**收不回**——撤回/置顶/催读/阅读情况全缺 |
|
||||||
|
| `dormnotice` 宿舍通知 | 9 | **+18** | 家长侧齐了,**管理侧几乎空白**;模块还自带一整套「床位上床台账」完全没接 |
|
||||||
|
| `familyactivity` 家校活动 | 7 | **+13** | 家长报名齐了,**学校侧(发活动 / 确认报名)全缺** |
|
||||||
|
| `coursehomework` 课程作业 | 4 | **+20** | 缺口最大:**只接了学生端,教师端一整套能力 0 接入** |
|
||||||
|
| 合计 | **41** | **+71** | — |
|
||||||
|
| (`profession` 教师专业发展,教师专属,附带参考) | 9 | +12 | 只缺「编辑/删除记录、评论点赞、统计报表」 |
|
||||||
|
|
||||||
|
**一句话总纲**:四个「发布型」模块(公告 / 宿舍 / 活动 / 作业)其实是**同一套骨架**——
|
||||||
|
`我发布的 → 全部 → 详情 → 新建/编辑 → 回执或提交名单 → 统计 → 应用设置`,
|
||||||
|
再各自加一个 `/par/*` 家长端或 `/stu/*` 学生端。
|
||||||
|
助手目前普遍只做了**「读列表 + 我提交」**这一层,
|
||||||
|
而骨架里价值最高的**「发布 / 撤回 / 盯回执 / 批阅 / 催办」**基本没接。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 先说权限:能力边界的四层判定
|
||||||
|
|
||||||
|
### 1.1 助手侧(AgentService)
|
||||||
|
|
||||||
|
| 层 | 组件 | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `RoleResolver` | 只读平台 `BoaUser`,**只认「当前身份」`currentRole`**(不做并集)→ 教师 / 学生 / 家长 / 管理员 / 访客 |
|
||||||
|
| 2 | `ToolRegistry` | 只把**本身份可见**的工具写进模型 schema —— 模型"看不见"就不会选错 |
|
||||||
|
| 3 | `ToolExecutor` | 调用前再校验一次,越权直接拒绝并打 `[越权拦截]` |
|
||||||
|
| 4 | 浏览器代执行(A 通道) | 写操作由**前端带用户登录态**执行,服务端不代持身份 |
|
||||||
|
|
||||||
|
`RolePolicy` 现状(一张集中表):
|
||||||
|
|
||||||
|
- `STAFF_ONLY`(教师+管理员):请假登记/审批/统计/批量通过、公告已发/发文部门/发布/审批、宿舍楼栋房间、活动班级树
|
||||||
|
- `STUDENT_ONLY`:作业四件套、请假申请、销假
|
||||||
|
- `STUDENT_OR_PARENT`:活动报名 / 取消报名
|
||||||
|
- `SHARED`:撤销请假
|
||||||
|
- **未列入任何名单 = 所有已识别角色都可用**
|
||||||
|
|
||||||
|
> ★★ **最大风险点**:新增工具若忘了登记,**默认是"人人可用"**。
|
||||||
|
> 例如把 `homework.review`(教师打分)漏登记,学生就会在目录里看到"给作业打分"。
|
||||||
|
> 本报告第 5 节给出建议分桶表,实施时必须同步改 `RolePolicy`。
|
||||||
|
|
||||||
|
### 1.2 模块侧(5 个前端怎么分权限)
|
||||||
|
|
||||||
|
结论:**五个模块的前端都几乎不做基于 `roleId` 的业务判定**,统一靠三件事:
|
||||||
|
|
||||||
|
1. **平台菜单授权 `GET /api/base/GetUserMod`** —— 决定"能不能进这个入口页"(`modId` 是稳定契约);
|
||||||
|
2. **URL 前缀** —— `/par/*` = 家长端、`/stu/*` = 学生端,其余 = 教职工端(`roleOfPath()`);
|
||||||
|
3. **后端数据范围** —— 同一接口按身份收敛(学生看自己 / 教师看任教班 / 管理员看全校)。
|
||||||
|
|
||||||
|
| 模块 | 端分离方式 | 入口 `modId` 契约 |
|
||||||
|
|---|---|---|
|
||||||
|
| homeleave | 纯菜单(无 URL 前缀) | 2 申请 / 3 我的 / 4 审批 / 5 登记 / 6 统计 / 7 设置 |
|
||||||
|
| campusnotice | 纯菜单 | 1 我的公告 / 2 已发管理 / 3 全部公告 / 4 统计 / 5 审批中心 / 21 设置 |
|
||||||
|
| dormnotice | `/par/*` | 2 我发的 / 3 全部 / 25 回执情况 / 4 统计 / 21 设置 ‖ **31 通知箱 / 32 我的回执** |
|
||||||
|
| familyactivity | `/par/*` | 2 我发的 / 3 全部 / 25 报名情况 / 4 统计 / 21 设置 ‖ **31 活动广场 / 32 我的报名** |
|
||||||
|
| coursehomework | `/stu/*` | 教师端无 `/stu` 前缀(列表/详情/提交情况/批改中心/统计);学生端 今日作业 / 待订正 / 详情 |
|
||||||
|
|
||||||
|
> 注意:`/list/new`、`/list/detail`、`/signups`、`/grade` 这类**二级落地页一律不判权限**(只要路由声明了 `meta.parentId`)。
|
||||||
|
> 所以"有权限发公告"≠"有权限进审批中心",两者是不同 `modId`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 逐模块盘点
|
||||||
|
|
||||||
|
图例:**角色** = 建议归属(学/教/管 = 学生/教师/管理员,家 = 家长);**类型** = 只读 / 单参写 / 表单写。
|
||||||
|
`⭐` = 高频高价值,建议优先。
|
||||||
|
|
||||||
|
### 2.1 `homeleave` 请假 —— 已接 12,建议 +6
|
||||||
|
|
||||||
|
**已接**:`leave.types` `myClasses` `classStudents` `myLeaves` `detail` `stats`
|
||||||
|
`submit`(学生申请) `register`(教师登记) `withdraw` `reportBack` `audit` `batchPass`
|
||||||
|
|
||||||
|
**模块能力全景**(`/api/leave/*` + `/api/dict/*` + `/api/leavestats/*`):
|
||||||
|
|
||||||
|
| 页面 | 角色 | 能力 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/apply` 请假申请 | 学生 | 选班级/类型/时间/事由/附件 → 提交 |
|
||||||
|
| `/my` 我的请假 | 学生 | 列表+筛选、详情、撤销、申请销假 |
|
||||||
|
| `/audit` 请假审批 | 教师/管理员 | 列表、批量通过、本班一键通过、通过/拒绝、撤销审批、销假审批、删除 |
|
||||||
|
| `/register` 请假登记 | 教师/管理员 | 选班级→学生→类型→时间 → 免审登记 |
|
||||||
|
| `/stats` 请假统计 | 教师/管理员 | 时间/班级/类型筛选 + 分布 |
|
||||||
|
| `/setting` 应用设置 | 管理员 | **请假类型字典**增删改启停排序 |
|
||||||
|
|
||||||
|
**建议新增:**
|
||||||
|
|
||||||
|
| 建议工具 | 接口 | 角色 | 类型 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `leave.delete` ⭐ | `POST /api/leave/Delete?id=` | 教/管 | 单参写 | 只允许删「已撤销/已销假/已拒绝」;补全审批页的删除 |
|
||||||
|
| `leave.typeList` | `GET /api/dict/LeaveTypeList` | 管 | 只读 | 含**停用**项的管理列表(现有 `leave.types` 只有启用项) |
|
||||||
|
| `leave.typeAdd` | `POST /api/dict/LeaveTypeAdd?name=` | 管 | 表单写 | "加一个『竞赛假』" |
|
||||||
|
| `leave.typeUpdate` | `POST /api/dict/LeaveTypeUpdate?serid=&name=&enable=` | 管 | 表单写 | 改名 / 启停 |
|
||||||
|
| `leave.typeDelete` | `POST /api/dict/LeaveTypeDelete?serid=` | 管 | 单参写 | 被引用时后端会拒绝(要有友好提示) |
|
||||||
|
| `leave.typeSort` | `POST /api/dict/LeaveTypeSort?ids=` | 管 | 单参写 | 调整下拉顺序 |
|
||||||
|
| (扩展)`leave.myLeaves` | 同上,**加** `classId/stuId/type` 参数 | 教/管 | 只读 | 现在教师问"三班今天有谁请假"没法筛 |
|
||||||
|
|
||||||
|
> 说明:`leave.stats` / `audit` / `batchPass` 已有,是五个模块里做得最完整的一个。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 `campusnotice` 校园公告 —— 已接 9,建议 +14
|
||||||
|
|
||||||
|
**已接**:`notice.received` `readTabCount` `sent` `detail` `deptDics` `publish` `approvalList` `approve` `reject`
|
||||||
|
|
||||||
|
**模块能力全景**:
|
||||||
|
|
||||||
|
| 页面 | 角色 | 能力 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/` 我的公告 | 全员 | 收件箱、未读/已读筛选、一键已读、上/下篇、打印、附件 |
|
||||||
|
| `/list` 已发管理 | 发布者 | 撤回 / 置顶 / **催读** / 转发 / 编辑重提 / 删除 / **阅读回执** |
|
||||||
|
| `/list/new` 写公告 | 发布者 | 富文本+附件+置顶+短信+**阅读回执**+定时发送;直接发布 / 存草稿 / 提交审批 |
|
||||||
|
| `/drafts` 草稿箱 | 发布者 | 继续编辑、提交审批、删除 |
|
||||||
|
| `/noticelist` 全部公告 | 管理员 | 全量查询 + 删除 |
|
||||||
|
| `/approval` 审批中心 | 审批人 | 待审/已通过/已驳回/全部 + 通过/驳回 + 审批轨迹 |
|
||||||
|
| `/setting` 应用设置 | 管理员 | **发文部门字典** + **自定义联系组** |
|
||||||
|
|
||||||
|
**建议新增:**
|
||||||
|
|
||||||
|
| 建议工具 | 接口 | 角色 | 类型 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `notice.revoke` ⭐ | `POST /api/notice/UserPull?serid=` | 教/管 | 单参写 | "发错了,撤回" |
|
||||||
|
| `notice.setTopmost` ⭐ | `POST /api/notice/SetTopmost?serid=&topmost=` | 教/管 | 单参写 | 置顶 / 取消 |
|
||||||
|
| `notice.expedite` ⭐ | `POST /api/notice/Expedite?serid=` | 教/管 | 单参写 | **催读**,管理刚需 |
|
||||||
|
| `notice.readDetail` ⭐ | `GET /api/notice/ReadDetail?serid=` | 教/管 | 只读 | **谁读了 / 谁没读**(教师维度分组) |
|
||||||
|
| `notice.readReceipt` | `GET /api/notice/ReadReceipt?serid=` | 教/管 | 只读 | 年级→班级→学生 + 教师组的回执快照 |
|
||||||
|
| `notice.readAll` | `POST /api/notice/InitializeRed` | 全员 | 单参写 | 一键全部已读("我还有多少没看"→顺手清) |
|
||||||
|
| `notice.delete` | `POST /api/notice/Delete` | 教/管 | 单参写 | body 是 id **数组**,支持批量 |
|
||||||
|
| `notice.drafts` | `GET /api/notice/DraftList` | 教/管 | 只读 | 草稿箱 |
|
||||||
|
| `notice.saveDraft` | `POST /api/notice/SaveDraft` | 教/管 | 表单写 | 先存着 |
|
||||||
|
| `notice.edit` | `POST /api/notice/Edit` | 教/管 | 表单写 | 编辑(`flow=[]` 清流程) |
|
||||||
|
| `notice.submitApproval` ⭐ | `POST /api/notice/Submit` | 教/管 | 表单写 | 提交审批,`steps[].approverTeaIds` 来自**教师树** |
|
||||||
|
| `notice.appendReceivers` | `POST /api/notice/ReceiveUsers?serid=&users=` | 教/管 | 单参写 | 追加接收人(补发) |
|
||||||
|
| `notice.stats` | `GET /api/notice/Statis` | 管 | 只读 | 区间统计(页面 `/stats` 目前还是占位页,但接口是活的) |
|
||||||
|
| `notice.deptManage` | `/api/dict/{ManageList,Add,Update,Delete,Sort}` | 管 | 写 | 发文部门维护 |
|
||||||
|
|
||||||
|
> 老师最常问的两句是「**这条通知谁还没看**」和「**催一下没看的**」,对应 `readDetail` + `expedite`,建议 P0。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 `dormnotice` 宿舍通知 —— 已接 9,建议 +18(缺口第二大)
|
||||||
|
|
||||||
|
**已接**:`dorm.myNotices` `myNotice` `sendList` `submitReceipt` `cancelReceipt` `buildings` `rooms` `roomStudents` `dormTree`
|
||||||
|
|
||||||
|
**模块能力全景**(三条业务线):
|
||||||
|
|
||||||
|
| 页面 | 角色 | 能力 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/par/notices` `/par/mine` | 家长 | 通知箱、阅读、提交/修改/取消**回执** |
|
||||||
|
| `/list` `/noticelist` `/list/new` | 宿管/教师 | **发布通知**(含回执方式/回执单据/接收宿舍/短信/定时)、撤回、置顶、催办、编辑 |
|
||||||
|
| `/receipts` 回执情况 | 宿管/管理员 | 回执名单(按楼栋·房间)、**代为确认 / 取消 / 退回**、催办、导出 CSV |
|
||||||
|
| `/setting` 宿舍设置 | 管理员 | 通知类型字典、发布部门字典、**楼栋/房间台账 + 住宿生床位安排**、自定义联系组 |
|
||||||
|
|
||||||
|
**建议新增(A. 通知管理侧):**
|
||||||
|
|
||||||
|
| 建议工具 | 接口 | 角色 | 类型 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `dorm.publish` ⭐ | `POST /api/notice/Create` | 教/管 | 表单写 | **宿管发通知**。字段:标题/类型/部门/紧急度/生效时间/回执截止/**回执方式**(none/confirm/fill)/**回执单据**(stay/visit/adjust/general)/**接收宿舍**/附件 |
|
||||||
|
| `dorm.edit` | `POST /api/notice/Edit` | 教/管 | 表单写 | 编辑(含 `sourceId`) |
|
||||||
|
| `dorm.revoke` | `POST /api/notice/UserPull?serid=` | 教/管 | 单参写 | 撤回 |
|
||||||
|
| `dorm.setTopmost` | `POST /api/notice/SetTopmost?serid=&topmost=` | 教/管 | 单参写 | 置顶 |
|
||||||
|
| `dorm.delete` | `POST /api/notice/Delete` | 教/管 | 单参写 | 批量删除(级联删回执) |
|
||||||
|
| `dorm.receipts` ⭐ | `GET /api/notice/Receipts?serid=&status=` | 教/管 | 只读 | **回执名单 + 汇总**(应收/已回/已确认/已填报/未回执/回执率) |
|
||||||
|
| `dorm.receiptDetail` | `GET /api/notice/ReceiptDetail?receiptId=` | 教/管 | 只读 | 单条回执详情 |
|
||||||
|
| `dorm.reviewReceipt` ⭐ | `POST /api/notice/Review` | 教/管 | 单参写 | `{receiptId, action: confirm/cancel/reset}` —— 代确认 / 取消 / 退回 |
|
||||||
|
| `dorm.remindReceipt` ⭐ | `POST /api/notice/Remind?serid=` | 教/管 | 单参写 | 催未回执家长 |
|
||||||
|
| `dorm.remindStudent` | `POST /api/notice/RemindStudent?serid=&studentId=` | 教/管 | 单参写 | 定向催某个学生 |
|
||||||
|
| `dorm.markRead` | `POST /api/notice/MarkRead?serid=&studentId=` | 教/管 | 单参写 | 代标记已读(线下已通知的情况) |
|
||||||
|
| `dorm.exportReceipts` | `GET /api/notice/ExportReceipts?serid=` | 教/管 | 只读 | 导出 CSV(返回文本,可回传前端下载) |
|
||||||
|
| `dorm.stats` | `GET /api/notice/Statis` | 教/管 | 只读 | 通知统计 |
|
||||||
|
| `dorm.formTemplates` | `GET /api/notice/FormTemplates` | 教/管 | 只读 | 回执单据模板(**动态字段**:key/label/type/required/options) |
|
||||||
|
| `dorm.noticeTypes` | `GET /api/dict/List?type=noticeType` | 教/管 | 只读 | 通知类型下拉 |
|
||||||
|
|
||||||
|
**建议新增(B. 床位台账 —— 模块自带的"没想到的能力"):**
|
||||||
|
|
||||||
|
| 建议工具 | 接口 | 角色 | 类型 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `dorm.pendingStudents` ⭐ | `GET /api/dorm/PendingStudents?keyword=&gender=` | 教/管 | 只读 | **待安排床位学生池** |
|
||||||
|
| `dorm.assignStudent` ⭐ | `POST /api/dorm/AssignStudent?roomId=&studentIds=` | 教/管 | 表单写 | **安排入住**(房间 + 学生多选) |
|
||||||
|
| `dorm.removeStudent` | `POST /api/dorm/RemoveStudent?roomId=&studentId=` | 教/管 | 单参写 | 退宿 |
|
||||||
|
| `dorm.transferStudent` | `POST /api/dorm/TransferStudent` | 教/管 | 表单写 | 调整宿舍(回显是否原宿舍长 + 床位号) |
|
||||||
|
| `dorm.setMonitor` | `POST /api/dorm/SetMonitor?roomId=&studentId=` | 教/管 | 单参写 | 设/取消宿舍长(取消传 0) |
|
||||||
|
| `dorm.addBuilding` | `POST /api/dorm/AddBuilding` | 管 | 表单写 | 新增楼栋(可**批量铺房间**:层数×每层房间数×床位数) |
|
||||||
|
| `dorm.editBuilding` / `dorm.delBuilding` | 同名接口 | 管 | 写 | 改 / 删(删除需先清空房间) |
|
||||||
|
| `dorm.addRoom` / `editRoom` / `delRoom` | 同名接口 | 管 | 写 | 房间增删改 |
|
||||||
|
| `dorm.buildingTree` | 组合 `BuildingList` → `RoomList(buildingId)` | 教/管 | 只读 | 楼栋→楼层→房间三级(前端按 `floor` 分组) |
|
||||||
|
|
||||||
|
> 这三条联动链是模块里最有价值、也最"没人想到助手能做"的部分:
|
||||||
|
> **楼栋 → 房间 → 住宿生**、**待安排池 → 安排入住**、**回执名单 → 代确认/催办**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 `familyactivity` 家校活动 —— 已接 7,建议 +13
|
||||||
|
|
||||||
|
**已接**:`activity.plaza` `list` `detail` `mySignup` `signup` `cancelSignup` `classTree`
|
||||||
|
|
||||||
|
**模块能力全景**:闭环 = 发布(草稿/定时/复用)→ 触达(班级定向/短信/置顶)→ 已读追踪 → **报名回执**(仅通知 / 需报名 / 需确认)→ 学校确认 / 催办 / 导出 → 统计 → 字典与联系组。
|
||||||
|
|
||||||
|
**建议新增:**
|
||||||
|
|
||||||
|
| 建议工具 | 接口 | 角色 | 类型 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `activity.publish` ⭐ | `POST /api/activity/Create` | 教/管 | 表单写 | **发活动**。字段:标题/分类/主办部门/**接收班级**/时间地点/回执方式/附件/短信/定时 |
|
||||||
|
| `activity.edit` | `POST /api/activity/Edit` | 教/管 | 表单写 | 编辑 |
|
||||||
|
| `activity.revoke` | `POST /api/activity/UserPull?serid=` | 教/管 | 单参写 | 撤回(家长端不可见) |
|
||||||
|
| `activity.setTopmost` | `POST /api/activity/SetTopmost` | 教/管 | 单参写 | 置顶 |
|
||||||
|
| `activity.delete` | `POST /api/activity/Delete` | 教/管 | 单参写 | 批量删除 |
|
||||||
|
| `activity.signups` ⭐ | `GET /api/activity/Signups?serid=&signupStatus=` | 教/管 | 只读 | **报名名单**(按班级分组 + 汇总) |
|
||||||
|
| `activity.signupDetail` | `GET /api/activity/SignupDetail?signupId=` | 教/管 | 只读 | 单条回执详情 |
|
||||||
|
| `activity.reviewSignup` ⭐ | `POST /api/activity/Review` | 教/管 | 单参写 | `{signupId, action: confirm/cancel/waitlist, remark}` —— 学校确认 / 取消请假 / 待定 |
|
||||||
|
| `activity.remind` ⭐ | `POST /api/activity/Remind?serid=` | 教/管 | 单参写 | 催报名 |
|
||||||
|
| `activity.exportSignups` | `GET /api/activity/ExportSignups?serid=` | 教/管 | 只读 | 导出 CSV 文本(带 BOM) |
|
||||||
|
| `activity.readDetail` | `GET /api/activity/ReadDetail?serid=` | 教/管 | 只读 | 已读/未读(年级→班级) |
|
||||||
|
| `activity.stats` | `GET /api/activity/Statis` | 教/管 | 只读 | 活动统计 |
|
||||||
|
| `activity.categories` | `GET /api/dict/List?type=2` | 全员 | 只读 | 活动分类下拉(发布时用) |
|
||||||
|
| `activity.receiveClasses` | `POST /api/activity/ReceiveClasses?serid=&classes=` | 教/管 | 单参写 | 追加接收班级(补发) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.5 `coursehomework` 课程作业 —— 已接 4,建议 +20(**缺口最大**)
|
||||||
|
|
||||||
|
**已接(全是学生端)**:`homework.myList` `detail` `result` `submit`
|
||||||
|
|
||||||
|
> ★ 这个模块是**端分离**的:`/stu/*` = 学生,其余 = 教师。
|
||||||
|
> 助手目前只接了 `/api/student/homeworks*` 四件事,**`/api/teacher/homeworks/*` 一整套 0 接入**。
|
||||||
|
> 而教师的日常恰恰全在这一侧:布置、催交、批阅、打分、打回。
|
||||||
|
|
||||||
|
**建议新增(A. 教师端 V2,`/api/teacher/homeworks`):**
|
||||||
|
|
||||||
|
| 建议工具 | 接口 | 角色 | 类型 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `homework.create` ⭐ | `POST /api/teacher/homeworks` | 教师 | 表单写 | **布置作业**(先落草稿)。字段:标题/学科/**接收班级**/截止时间/提交方式/逾期可补交/截止提醒/附件 |
|
||||||
|
| `homework.editDraft` | `PUT /api/teacher/homeworks/{id}` | 教师 | 表单写 | 仅草稿可改 |
|
||||||
|
| `homework.deleteDraft` | `DELETE /api/teacher/homeworks/{id}` | 教师 | 单参写 | 仅草稿可删 |
|
||||||
|
| `homework.publishNow` ⭐ | `POST .../{id}/publish` | 教师 | 单参写 | 草稿 → 进行中(触发学生通知) |
|
||||||
|
| `homework.schedule` | `POST .../{id}/schedule` | 教师 | 单参写 | 定时发布 / 取消定时 |
|
||||||
|
| `homework.drafts` | `GET /api/teacher/homeworks/drafts` | 教师 | 只读 | 草稿箱 |
|
||||||
|
| `homework.published` | `GET /api/teacher/homeworks/published` | 教师 | 只读 | 已发布列表(可按学科筛) |
|
||||||
|
| `homework.remind` ⭐ | `POST .../{id}/remind` | 教师 | 单参写 | **催交**:`{type: NOT_READ\|NOT_SUBMITTED, studentIds?}` |
|
||||||
|
| `homework.extend` | `POST .../{id}/extend` | 教师 | 单参写 | 延长截止(新截止须晚于当前) |
|
||||||
|
| `homework.copy` | `POST .../{id}/copy` | 教师 | 单参写 | 复制为新草稿 |
|
||||||
|
| `homework.archive` | `POST .../{id}/archive` | 教师 | 单参写 | 归档(只读,不再提醒) |
|
||||||
|
| `homework.teacherDetail` | `GET .../{id}` | 教师 | 只读 | 作业详情 + 汇总统计 + 分班进度 |
|
||||||
|
| `homework.teacherStats` ⭐ | `GET .../{id}/stats` | 教师 | 只读 | "这份作业交了多少" |
|
||||||
|
| `homework.students` ⭐ | `GET .../{id}/students` | 教师 | 只读 | **学生矩阵**:谁读了/谁交了/谁没交/谁待批 |
|
||||||
|
| `homework.review` ⭐ | `POST .../{id}/submissions/{studentId}/review` | 教师 | 表单写 | **批阅打分 + 评语**(score/comment 至少一项) |
|
||||||
|
| `homework.returnSubmission` ⭐ | `POST .../{id}/submissions/{studentId}/return` | 教师 | 单参写 | **打回订正**(comment 必填) |
|
||||||
|
| `homework.versions` | `GET .../{id}/submissions/{studentId}/versions` | 教师 | 只读 | 历史提交版本 |
|
||||||
|
| `homework.export` | `GET .../{id}/export` | 教师 | 只读 | 导出学生矩阵 Excel(直链) |
|
||||||
|
| `homework.subjects` | `GET /api/teacher/homeworks/subjects/teaching` | 教师 | 只读 | 学科下拉 |
|
||||||
|
| `homework.classList` | `GET /api/teacher/homeworks/classes?subject=` | 教师 | 只读 | **年级→班级树,依赖所选学科** |
|
||||||
|
|
||||||
|
**建议新增(B. 学生端补全 + 通知中心):**
|
||||||
|
|
||||||
|
| 建议工具 | 接口 | 角色 | 类型 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `homework.withdrawSubmission` | `PUT .../submissions/{submissionId}` `action=withdraw` | 学生 | 单参写 | 撤回提交(截止前) |
|
||||||
|
| `homework.resubmit` | `PUT ...` `action=resubmit` | 学生 | 单参写 | **打回后重交**(与 `result` 配对) |
|
||||||
|
| `homework.read` | `POST /api/student/homeworks/{id}/read` | 学生 | 单参写 | 阅读上报(幂等) |
|
||||||
|
| `homework.notifications` | `GET /api/notifications` | 学生/教师 | 只读 | **通知中心**(接口已封装但前端无页面,是"有接口无 UI"的隐藏能力) |
|
||||||
|
| `homework.readAllNotifications` | `POST /api/notifications/read-all` | 同上 | 单参写 | 全部已读 |
|
||||||
|
|
||||||
|
> 另外 V1 冻结管理端还有 `GET /api/homework/SendList`(全部人作业)、`POST /api/homework/Delete`、`GET /api/homework/Statis`,
|
||||||
|
> 供"管理/上级教师"口径使用,可选接。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.6 `profession` 教师专业发展(第 6 个模块,教师专属,已接 9,建议 +12)
|
||||||
|
|
||||||
|
**已接**:`homeRecords` `list` `moduleRecords` `myRecords` `detail` `moduleTree` `myPoints` `pointsRank` `publish`
|
||||||
|
|
||||||
|
| 建议工具 | 接口 | 类型 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `profession.editRecord` | `POST /api/profession/Edit` | 表单写 | 编辑自己的记录 |
|
||||||
|
| `profession.deleteRecord` | `GET /api/profession/Delete?ids=` | 单参写 | 删除 |
|
||||||
|
| `profession.comments` | `GET /api/profession/DiscussList?sid=` | 只读 | 评论列表 |
|
||||||
|
| `profession.comment` | `POST /api/profession/DiscussCreate` | 单参写 | 发表评论 |
|
||||||
|
| `profession.deleteComment` | `GET /api/profession/DiscussDel?id=` | 单参写 | 删评论 |
|
||||||
|
| `profession.like` | `POST /api/profession/LikeCreate?recordId=` | 单参写 | 点赞 / 取消(toggle) |
|
||||||
|
| `profession.timeline` | `GET /api/profession/MyTimeline?userid=` | 只读 | 个人时间轴 |
|
||||||
|
| `profession.spaceOverview` | `GET /api/profession/SpaceOverview` | 只读 | 个人空间聚合(积分/记录/获赞/被访问) |
|
||||||
|
| `profession.pointsSource` | `GET /api/profession/PointsSourceList` | 只读 | **积分来源明细**("我这 20 分怎么来的") |
|
||||||
|
| `profession.grades` / `subjects` | `GetGradeList` / `GetSubList` | 只读 | 年级段 / 学科字典(发布记录时用) |
|
||||||
|
| `profession.stats` | `GET /api/stats/{TeacherStat,ModuleStat,PointsStat}` | 只读 | 校级统计(管理员) |
|
||||||
|
| `profession.modules` | `GET /api/moudule/MouduleList` | 只读 | 栏目列表(含 `readonly` 只读标记) |
|
||||||
|
|
||||||
|
> 注:**该模块没有审批流**(记录发布即生效,`isCheck` 是栏目启停不是记录审核),别按"提交审核"设计。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 跨模块共性缺口 —— "各模块都没想到的能力"
|
||||||
|
|
||||||
|
这 7 条是**重复出现在多个模块**、做一次就能复用多次的能力:
|
||||||
|
|
||||||
|
| # | 共性能力 | 出现在 | 助手现状 | 价值 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | **撤回 / 置顶 / 催办三件套** | 公告、宿舍、活动、作业 | 全缺 | ★★★ 发错了能收回、重要的能置顶、没看的能催 —— 都是**单参数 POST**,成本极低 |
|
||||||
|
| 2 | **回执 / 提交名单闭环** | 公告、宿舍、活动、作业 | 只做了"我提交",没做"**谁没提交**" | ★★★ 教师最高频诉求 |
|
||||||
|
| 3 | **统计数据** | 公告、宿舍、活动、作业都有 `Statis` | 只接了 `leave.stats` | ★★ 一句话出数字 |
|
||||||
|
| 4 | **字典维护**(在"应用设置"里) | 公告(发文部门)、宿舍(通知类型)、活动(主办部门/分类)、请假(类型) | 只做了"读下拉",没做"改" | ★★ 管理员可用自然语言维护 |
|
||||||
|
| 5 | **附件上传/预览/下载** | 5 模块统一 `/api/file/{Upload,Preview,Down}` | 写操作暂不支持附件 | ★ 侧重点:写时提示"需附件请到页面补传",只读加 `Preview/Down` 直链 |
|
||||||
|
| 6 | **级联下拉链** | 作业、宿舍、活动、公告 | **基建已有**(`optionCall` + `valueKey/labelKey` + `dependsOn`) | ★★ 直接复用,见第 4 节 |
|
||||||
|
| 7 | **审批人选择** | 公告(多步审批流 `steps`) | 缺 | ★ `getTeaTree` 教师树 + 多选 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 落地设计:复用现有基建,**零新增管线**
|
||||||
|
|
||||||
|
这一节是"**怎么做**",全部基于已经跑通的机制:
|
||||||
|
|
||||||
|
### 4.1 写操作 —— 自动走浏览器代执行
|
||||||
|
|
||||||
|
服务端**无法**代持用户登录态调模块 `/api/*`(下游 CAS 过滤器只认浏览器容器会话,必回 `code=302`)。
|
||||||
|
现有 A 通道(commit `ab87a4b`)已在跑:服务端挂起下发 `browserTool.calls` → 前端用**用户自己的登录态** fetch → 回传结果续跑。
|
||||||
|
**所以上面所有"写"工具,只需要在 `ToolDef` 里声明 `.write(landingPath)`,不需要任何新的传输管线。**
|
||||||
|
|
||||||
|
### 4.2 表单下拉 —— 用现成的 `optionCall` 机制
|
||||||
|
|
||||||
|
后端只下发"取数说明"(含 `{{占位符}}`),前端带登录态取,再按后端给的 `valueKey/labelKey` 映射:
|
||||||
|
|
||||||
|
```java
|
||||||
|
ToolParam.required("stuId", "integer", "学生 id")
|
||||||
|
.label("学生")
|
||||||
|
.optionsFrom("leave.classStudents").optionsKeys("stuId", "stuName")
|
||||||
|
.dependsOn("classId"); // ← 选完班级才解锁
|
||||||
|
```
|
||||||
|
|
||||||
|
**需要新写的级联链清单**(照这个模式加):
|
||||||
|
|
||||||
|
| 模块 | 联动链 | 依赖关系 |
|
||||||
|
|---|---|---|
|
||||||
|
| 课程作业 | 学科 → 年级班级树 | `homework.classList` `dependsOn("subject")` |
|
||||||
|
| 课程作业 | 已发布作业 → 班级 → 学生 | `stats.byClass` → `students?classId` |
|
||||||
|
| 宿舍通知 | **楼栋 → 房间 → 住宿生** | `RoomList` `dependsOn("buildingId")`;`RoomStudents` `dependsOn("roomId")` |
|
||||||
|
| 宿舍通知 | 楼栋 → 房间(接收宿舍多选) | 同上,前端按 `floor` 分组 |
|
||||||
|
| 家校活动 | 接收班级(年级→班级) | `GetClassTree` 分组下拉,选"全部班级"→ `receiveList=ALL` |
|
||||||
|
| 校园公告 | 发文部门 / 接收范围 / 审批人 | `dict/List?type=1` / `getStuTree` / `getTeaTree` |
|
||||||
|
| 请假 | 班级 → 学生(**已完成**) | 参考实现 |
|
||||||
|
|
||||||
|
### 4.3 裸意图直达 —— 可扩展
|
||||||
|
|
||||||
|
已有 `IntentShortcut`(去标点 + 剥前后缀正好等于标准说法 + ≤12 字 → 跳过 LLM 直接弹表单)。
|
||||||
|
可照葫芦画瓢加:
|
||||||
|
|
||||||
|
| 说法 | 直达工具 |
|
||||||
|
|---|---|
|
||||||
|
| 「我要交作业」「看今天作业」 | `homework.myList` / 学生提交表单 |
|
||||||
|
| 「布置作业」 | `homework.create` |
|
||||||
|
| 「我要报名」 | `activity.signup`(先弹活动选择) |
|
||||||
|
| 「查宿舍」 | `dorm.myNotices` |
|
||||||
|
| 「发通知」 | 视模块 → `notice.publish` / `dorm.publish` / `activity.publish`(**有歧义,需按目录可见性消歧**) |
|
||||||
|
|
||||||
|
> ⚠️ 「发通知」「我要报名」这类**跨模块同义**的说法不要做裸意图直达,
|
||||||
|
> 交给模型按 `catalog` 里可见的工具自己选,否则会猜错模块。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 角色分桶建议(实施时必须同步改 `RolePolicy`)
|
||||||
|
|
||||||
|
| 工具族 | 建议桶 |
|
||||||
|
|---|---|
|
||||||
|
| 写:发布/编辑/撤回/置顶/催办/删除/审批/统计/字典维护/床位台账/批阅打分/打回/催交 | `STAFF_ONLY` |
|
||||||
|
| 写:请假申请、交作业、阅读上报、撤回提交、重交 | `STUDENT_ONLY` |
|
||||||
|
| 写:活动报名 / 取消报名 | `STUDENT_OR_PARENT` |
|
||||||
|
| 写:一键全部已读、宿舍回执提交/取消 | 不限(所有人) |
|
||||||
|
| 只读:各类列表/详情/统计/下拉 | 按模块判定(**教师专属模块**如 `profession.*` 用前缀规则) |
|
||||||
|
|
||||||
|
当前 `profession.*` 已用模块前缀规则(`STAFF_MODULE_PREFIXES`)整模块归教职工,新增模块可沿用。
|
||||||
|
|
||||||
|
**几条容易踩的:**
|
||||||
|
|
||||||
|
- `homework.*` 现在被整桶放进 `STUDENT_ONLY`。**新增教师端工具必须改名字或用显式 `.allow(...)`**,否则会被误判成学生工具。
|
||||||
|
建议改成:`homework.myList/detail/result/submit/read/...`(学生)保留在 `STUDENT_ONLY`,
|
||||||
|
而 `homework.create/publish/review/...` 显式登记 `STAFF_ONLY`。
|
||||||
|
- 「回执/名单查询」(`dorm.receipts`、`activity.signups`、`notice.readDetail`)**必须** `STAFF_ONLY` —— 那是全班/全校数据。
|
||||||
|
- 「一键已读」「提交自己的回执」这类**只动自己的**,不要限制角色。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 建议实施顺序(4 批,风险递增)
|
||||||
|
|
||||||
|
| 批次 | 内容 | 风险 | 为什么先做 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **批 1** | 只读补全:统计(4 个模块)、阅读情况、回执/报名名单、草稿箱、`homework.students` 学生矩阵、下拉字典 | 极低 | 纯 GET,不改变任何数据;立刻让"谁没交/谁没看"能问 |
|
||||||
|
| **批 2** | 单参数写:撤回 / 置顶 / 催读 / 催办 / 催报名 / 删除 / 一键已读 / 代确认回执 / 打回 / 归档 / 发布草稿 | 低 | 单字段 POST,表单只需"确认";覆盖最高频的"发错了/催一下" |
|
||||||
|
| **批 3** | 表单写:布置作业、发活动、发宿舍通知、公告草稿+编辑+提交审批、请假类型字典维护 | 中 | 正文级表单,需要完整字段 + 级联下拉 |
|
||||||
|
| **批 4** | 复杂交互:批阅打分(分数+评语+历史版本)、床位安排(楼栋→房间→学生)、多步审批人选人、导出报表 | 中高 | 多步 + 多级联动,建议单独设计交互 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 A:接口路径速查
|
||||||
|
|
||||||
|
```
|
||||||
|
# 请假 homeleave
|
||||||
|
GET /api/leave/List|Details|MyClasses|ClassStudents /api/leavestats/Stats
|
||||||
|
POST /api/leave/Save|Audit|BatchPass|ReportBack /api/leave/Withdraw|Delete (form)
|
||||||
|
/api/dict/LeaveTypeOptions|List|Add|Update|Delete|Sort
|
||||||
|
|
||||||
|
# 校园公告 campusnotice
|
||||||
|
GET /api/notice/List|SendList|Detail|GetDetail|GetNextPrev|DraftList
|
||||||
|
/api/notice/ReadDetail|ReadReceipt|ApprovalList|ApprovalCount|Statis
|
||||||
|
POST /api/notice/Create|Edit|SaveDraft|Submit|Delete|Approve|Reject (JSON)
|
||||||
|
/api/notice/UserPull|SetTopmost|Expedite|InitializeRed|ReceiveUsers (form)
|
||||||
|
/api/dict/{List,ManageList,Add,Update,Delete,Sort} /api/contacts/*
|
||||||
|
|
||||||
|
# 宿舍通知 dormnotice
|
||||||
|
GET /api/notice/MyNotices|MyNotice|List|SendList|Detail|ReadDetail
|
||||||
|
/api/notice/Receipts|ReceiptDetail|ExportReceipts|FormTemplates|Statis
|
||||||
|
/api/dorm/BuildingList|RoomList|RoomStudents|PendingStudents /api/base/GetDormTree
|
||||||
|
POST /api/notice/SubmitReceipt|CancelReceipt|Create|Edit|Delete|Review (JSON)
|
||||||
|
/api/notice/UserPull|SetTopmost|Expedite|Remind|RemindStudent|MarkRead (form)
|
||||||
|
/api/dorm/AddBuilding|EditBuilding|DelBuilding|AddRoom|EditRoom|DelRoom
|
||||||
|
/api/dorm/AssignStudent|RemoveStudent|TransferStudent|SetMonitor
|
||||||
|
|
||||||
|
# 家校活动 familyactivity
|
||||||
|
GET /api/activity/MyActivities|List|SendList|Detail|GetDetail|ReadDetail
|
||||||
|
/api/activity/Signups|SignupDetail|ExportSignups|Statis|MySignup
|
||||||
|
POST /api/activity/Signup|CancelSignup|Create|Edit|Delete|Review (JSON)
|
||||||
|
/api/activity/UserPull|SetTopmost|Remind|ReceiveClasses|InitializeRed|Expedite (form)
|
||||||
|
/api/dict/List?type=1|2 /api/base/GetClassTree
|
||||||
|
|
||||||
|
# 课程作业 coursehomework
|
||||||
|
GET /api/student/homeworks?tab= /api/student/homeworks/{id}|/{id}/result
|
||||||
|
GET /api/teacher/homeworks/drafts|published|classes?subject=|subjects/teaching|assigned
|
||||||
|
/api/teacher/homeworks/{id}|/{id}/stats|/{id}/students|/{id}/export
|
||||||
|
/api/teacher/homeworks/{id}/submissions/{studentId}/versions
|
||||||
|
GET /api/homework/SendList|Detail|ReadDetail|Statis (V1 管理端)
|
||||||
|
POST /api/student/homeworks/{id}/read|/{id}/submissions
|
||||||
|
PUT /api/student/homeworks/{id}/submissions/{submissionId} (withdraw|resubmit)
|
||||||
|
POST /api/teacher/homeworks | /{id}/publish|schedule|remind|extend|copy|archive
|
||||||
|
/{id}/submissions/{studentId}/review|return
|
||||||
|
PUT /api/teacher/homeworks/{id} DELETE /api/teacher/homeworks/{id}
|
||||||
|
GET /api/notifications POST /api/notifications/{id}/read|read-all
|
||||||
|
|
||||||
|
# 专业发展 profession
|
||||||
|
GET /api/profession/HomeModuleRecords|ProfessionList|ModuleRecordList|MyModuleRecordList
|
||||||
|
/api/profession/ProfessionDetail|MyTimeline|MyPoints|PointsSourceList|PointsRank
|
||||||
|
/api/profession/SpaceOverview|DiscussList|VisitList|GetGradeList|GetSubList
|
||||||
|
POST /api/profession/Create|Edit|DiscussCreate|DiscussEdit|VisitCreate|LikeCreate
|
||||||
|
GET /api/profession/Delete|DiscussDel /api/moudule/* /api/points/* /api/stats/*
|
||||||
|
|
||||||
|
# 全模块通用
|
||||||
|
GET /api/base/GetUserInfo|GetUserMod|GetSchoolAll|GetClassTree|getStuTree|getTeaTree
|
||||||
|
POST /api/base/SetRole|FileUpload|Logout
|
||||||
|
GET /api/file/Preview|Down|List POST /api/file/Upload|UploadImage
|
||||||
|
```
|
||||||
@@ -33,8 +33,8 @@ public final class RolePolicy {
|
|||||||
private static final Set<String> STAFF_ONLY = new HashSet<>(Arrays.asList(
|
private static final Set<String> STAFF_ONLY = new HashSet<>(Arrays.asList(
|
||||||
// 请假:登记(教师给学生请假,type=1 免审)、审批与统计
|
// 请假:登记(教师给学生请假,type=1 免审)、审批与统计
|
||||||
"leave.register", "leave.classStudents", "leave.stats", "leave.audit", "leave.batchPass",
|
"leave.register", "leave.classStudents", "leave.stats", "leave.audit", "leave.batchPass",
|
||||||
// 公告:发布与审批
|
// 公告:发布与审批(另含存草稿与审批人选人的教师树)
|
||||||
"notice.sent", "notice.deptDics", "notice.publish",
|
"notice.sent", "notice.deptDics", "notice.publish", "notice.saveDraft", "notice.teaTree",
|
||||||
"notice.approvalList", "notice.approve", "notice.reject",
|
"notice.approvalList", "notice.approve", "notice.reject",
|
||||||
// 宿舍通知:发布 / 编辑 / 阅读情况 / 回执处理 / 催办 / 统计
|
// 宿舍通知:发布 / 编辑 / 阅读情况 / 回执处理 / 催办 / 统计
|
||||||
"dorm.publish", "dorm.editNotice", "dorm.sendList",
|
"dorm.publish", "dorm.editNotice", "dorm.sendList",
|
||||||
@@ -52,7 +52,10 @@ public final class RolePolicy {
|
|||||||
"activity.readDetail", "activity.signups", "activity.signupDetail", "activity.review",
|
"activity.readDetail", "activity.signups", "activity.signupDetail", "activity.review",
|
||||||
"activity.remind", "activity.expedite", "activity.pull", "activity.top", "activity.statistics",
|
"activity.remind", "activity.expedite", "activity.pull", "activity.top", "activity.statistics",
|
||||||
// 家校活动:发布表单的字典与选班级
|
// 家校活动:发布表单的字典与选班级
|
||||||
"activity.deptDic", "activity.categoryDic", "activity.classTree"
|
"activity.deptDic", "activity.categoryDic", "activity.classTree",
|
||||||
|
// 课程作业:教师端发布(发布/定时/我的作业/草稿)与发布表单的学科、班级树
|
||||||
|
"homework.publish", "homework.schedule", "homework.published", "homework.drafts",
|
||||||
|
"homework.teachingSubjects", "homework.classTree"
|
||||||
));
|
));
|
||||||
|
|
||||||
/** 学生专属 */
|
/** 学生专属 */
|
||||||
|
|||||||
@@ -52,5 +52,27 @@ public class BrowserToolRequest {
|
|||||||
private String body;
|
private String body;
|
||||||
/** POST 时的 Content-Type */
|
/** POST 时的 Content-Type */
|
||||||
private String contentType;
|
private String contentType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两步提交的第二步(可选):首步成功后,用首步响应的 {@code data} 替换
|
||||||
|
* {@link FollowUp#pathAndQuery} 里的 {@code {data}} 再发一次。
|
||||||
|
*
|
||||||
|
* <p>只用于「模块没有一步到位的接口」的场景(如作业:先落草稿 → 再发布/定时)。
|
||||||
|
* 第二步的地址由服务端在准备阶段生成(参数已替换完毕),前端不参与拼接。
|
||||||
|
*/
|
||||||
|
private FollowUp followUp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首步成功后的续跑请求(路径里的 {@code {data}} 用首步响应的 data 替换) */
|
||||||
|
@Data
|
||||||
|
public static class FollowUp {
|
||||||
|
/** GET / POST_JSON / POST_FORM */
|
||||||
|
private String method;
|
||||||
|
/** 路径模板,可含 {data}(首步响应的 data,如新作业 id) */
|
||||||
|
private String pathAndQuery;
|
||||||
|
/** 请求体(可含 {{参数名}} 占位,已在准备阶段替换) */
|
||||||
|
private String body;
|
||||||
|
/** Content-Type */
|
||||||
|
private String contentType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -663,9 +663,12 @@ public class AgentOrchestrator {
|
|||||||
}
|
}
|
||||||
for (ToolParam p : shown) {
|
for (ToolParam p : shown) {
|
||||||
FormField f = p.toFormField();
|
FormField f = p.toFormField();
|
||||||
// 附件控件:补上传地址(模块的附件上传只认浏览器 CAS 会话,必须前端带登录态 POST)
|
// 附件控件:补上传地址(模块的附件上传只认浏览器 CAS 会话,必须前端带登录态 POST)。
|
||||||
|
// 缺省按 「/<模块>/api/base/FileUpload」,个别模块不同(作业是 /api/file/Upload)时由参数覆盖。
|
||||||
if ("file".equals(f.getWidget())) {
|
if ("file".equals(f.getWidget())) {
|
||||||
f.setUploadPath("/" + def.getModule() + "/api/base/FileUpload");
|
f.setUploadPath(StringUtils.isNotBlank(p.getUploadPathOverride())
|
||||||
|
? p.getUploadPathOverride()
|
||||||
|
: "/" + def.getModule() + "/api/base/FileUpload");
|
||||||
}
|
}
|
||||||
// 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。
|
// 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。
|
||||||
//
|
//
|
||||||
@@ -677,8 +680,10 @@ public class AgentOrchestrator {
|
|||||||
BrowserToolRequest.BrowserCall optionCall = buildOptionCall(p, caller);
|
BrowserToolRequest.BrowserCall optionCall = buildOptionCall(p, caller);
|
||||||
if (optionCall != null) {
|
if (optionCall != null) {
|
||||||
f.setOptionCall(optionCall);
|
f.setOptionCall(optionCall);
|
||||||
// 多选字段保持 multiselect(接收班级/宿舍/审批人),其余走单选下拉
|
// ★ 多值控件必须保留原样:multiselect(接收班级/宿舍)、scopes(公告推送范围)、
|
||||||
if (!"multiselect".equals(f.getWidget())) {
|
// flow(公告审批流)都是「选项从字典代取 + 用户多选」,降级成单选下拉会直接丢掉多选能力。
|
||||||
|
String w = f.getWidget();
|
||||||
|
if (!"multiselect".equals(w) && !"scopes".equals(w) && !"flow".equals(w)) {
|
||||||
f.setWidget("select");
|
f.setWidget("select");
|
||||||
}
|
}
|
||||||
// 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖
|
// 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖
|
||||||
@@ -688,7 +693,10 @@ public class AgentOrchestrator {
|
|||||||
List<Map<String, String>> opts = dictOptions(p.getOptionsFrom(), caller, dictCache);
|
List<Map<String, String>> opts = dictOptions(p.getOptionsFrom(), caller, dictCache);
|
||||||
if (!opts.isEmpty()) {
|
if (!opts.isEmpty()) {
|
||||||
f.setOptions(opts);
|
f.setOptions(opts);
|
||||||
|
String w2 = f.getWidget();
|
||||||
|
if (!"multiselect".equals(w2) && !"scopes".equals(w2) && !"flow".equals(w2)) {
|
||||||
f.setWidget("select");
|
f.setWidget("select");
|
||||||
|
}
|
||||||
f.setOptionsFallback(false);
|
f.setOptionsFallback(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,35 @@ public class FormField {
|
|||||||
*/
|
*/
|
||||||
private Integer scopeType;
|
private Integer scopeType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「镜像字段」:本字段不由用户填写({@code hidden}),提交时自动填入同一表单中
|
||||||
|
* {@code mirrorOf} 指定字段**选中项的显示名**。
|
||||||
|
*
|
||||||
|
* <p>★ 用途:公告/宿舍/活动三个模块页在提交时会用字典把部门 id 反查成名称一并发出
|
||||||
|
* ({@code deptId} + {@code deptName},后端字典缺失时回退该名称)。助手只有下拉选中的 id,
|
||||||
|
* 但前端手里就有选项的 label —— 由它顺手镜像过去,提交体才和模块页一模一样。
|
||||||
|
*/
|
||||||
|
private String mirrorOf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「连续请假」展开说明:{@code {count: 天数字段, start: 开始时间字段, end: 结束时间字段}}。
|
||||||
|
*
|
||||||
|
* <p>作用在请假工具上:当本字段({@code continuousLeave})取 1 时,前端按
|
||||||
|
* {@code continuousLeaveNum} 天把 {@code startDate}/{@code endDate} 复制成逐天时间段,
|
||||||
|
* 生成模块要的 {@code continuousLeaveTime}({@code [{startDate,endDate}]})——
|
||||||
|
* 与模块页 {@code LeaveTimeSection.regenerate()} 的口径一致。
|
||||||
|
*/
|
||||||
|
private Map<String, String> dailyExpand;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code widget=file} 且为 true 时,提交的不是 fileId 数组,而是模块要的**附件对象数组**。
|
||||||
|
*
|
||||||
|
* <p>作业模块(coursehomework V2)的 {@code attachments} 形如
|
||||||
|
* {@code [{fileId,fileUrl,fileType,fileName,sort}]},与其余模块的 {@code fileIds="1,2"} 不同,
|
||||||
|
* 故由前端按上传结果组装成对象。
|
||||||
|
*/
|
||||||
|
private boolean fileAsObject;
|
||||||
|
|
||||||
private boolean required;
|
private boolean required;
|
||||||
|
|
||||||
/** 说明(来自工具定义,写清格式与含义) */
|
/** 说明(来自工具定义,写清格式与含义) */
|
||||||
|
|||||||
@@ -100,6 +100,34 @@ public class ToolDef {
|
|||||||
*/
|
*/
|
||||||
private boolean formAll;
|
private boolean formAll;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「两步提交」的第二步路径模板(可选)。首步成功后,用首步返回的 {@code data} 续跑。
|
||||||
|
*
|
||||||
|
* <p>★ 用途:作业模块(coursehomework V2)没有"一步发布"的接口 —— 模块页自己也是
|
||||||
|
* {@code Create(落草稿) → Publish/Schedule}。为了让用户在助手里填一次表单就发出去,
|
||||||
|
* 把第二步声明在这里:路径里的 {@code {data}} 用首步返回的作业 id 替换,
|
||||||
|
* {@link #followUpBody} 里的 {@code {{参数名}}} 在准备阶段就用表单实参替换好。
|
||||||
|
*/
|
||||||
|
private String followUpPath;
|
||||||
|
|
||||||
|
/** 第二步的请求体模板(JSON 字符串,可含 {@code {{参数名}}} 占位),为空表示无请求体 */
|
||||||
|
private String followUpBody;
|
||||||
|
|
||||||
|
/** 第二步的请求方式,缺省 POST */
|
||||||
|
private String followUpMethod = "POST";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 声明两步提交的第二步:首步成功后用它接着发一次请求。
|
||||||
|
*
|
||||||
|
* @param pathTemplate 路径模板,可含 {@code {data}}(首步响应的 data,如新作业 id)
|
||||||
|
* @param bodyTemplate 请求体模板,可含 {@code {{参数名}}};不需要请求体传 null
|
||||||
|
*/
|
||||||
|
public ToolDef then(String pathTemplate, String bodyTemplate) {
|
||||||
|
this.followUpPath = pathTemplate;
|
||||||
|
this.followUpBody = bodyTemplate;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public ToolDef() {
|
public ToolDef() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +250,10 @@ public class ToolDef {
|
|||||||
JSONObject props = new JSONObject();
|
JSONObject props = new JSONObject();
|
||||||
JSONArray required = new JSONArray();
|
JSONArray required = new JSONArray();
|
||||||
for (ToolParam p : params) {
|
for (ToolParam p : params) {
|
||||||
|
// 「镜像字段」(如 deptName)由前端按所选部门的显示名自动带上,不让模型填
|
||||||
|
if (p.getMirrorLabelOf() != null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
props.put(p.getName(), p.toSchema());
|
props.put(p.getName(), p.toSchema());
|
||||||
if (p.isRequired()) {
|
if (p.isRequired()) {
|
||||||
required.add(p.getName());
|
required.add(p.getName());
|
||||||
|
|||||||
@@ -143,7 +143,12 @@ public class ToolExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
long cost = System.currentTimeMillis() - start;
|
long cost = System.currentTimeMillis() - start;
|
||||||
return parseResponse(def, resp, cost);
|
ToolResult first = parseResponse(def, resp, cost);
|
||||||
|
// ★ 两步提交(如作业「先落草稿 → 再发布/定时」):首步成功才续跑,首步失败原样返回
|
||||||
|
if (StringUtils.isNotBlank(def.getFollowUpPath()) && first.isSuccess()) {
|
||||||
|
return runFollowUpOnServer(def, resp, merged, base, headers, start);
|
||||||
|
}
|
||||||
|
return first;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
long cost = System.currentTimeMillis() - start;
|
long cost = System.currentTimeMillis() - start;
|
||||||
@@ -225,9 +230,116 @@ public class ToolExecutor {
|
|||||||
throw new IllegalArgumentException("不支持的请求方式:" + method);
|
throw new IllegalArgumentException("不支持的请求方式:" + method);
|
||||||
}
|
}
|
||||||
call.setMethod(method);
|
call.setMethod(method);
|
||||||
|
call.setFollowUp(buildFollowUp(def, merged, base));
|
||||||
return call;
|
return call;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== 两步提交(先落草稿 → 再发布/定时) ==================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装「首步成功后的续跑请求」。
|
||||||
|
*
|
||||||
|
* <p>模块里有些动作没有一步到位的接口(作业发布就是 {@code Create → Publish|Schedule})。
|
||||||
|
* 声明 {@link ToolDef#then} 后,准备阶段就把第二步的地址与请求体拼好,
|
||||||
|
* 只留 {@code {data}}(首步返回的资源 id)给执行期替换 —— 前端与服务端同一套逻辑。
|
||||||
|
*
|
||||||
|
* @param merged 已合并固定参数的实参表(用于替换请求体里的 {@code {{参数名}}})
|
||||||
|
* @param base 模块根地址(用于拼出带模块前缀的相对路径)
|
||||||
|
*/
|
||||||
|
private com.yunhai.agent.model.BrowserToolRequest.FollowUp buildFollowUp(
|
||||||
|
ToolDef def, Map<String, Object> merged, String base) {
|
||||||
|
if (StringUtils.isBlank(def.getFollowUpPath())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
com.yunhai.agent.model.BrowserToolRequest.FollowUp fu =
|
||||||
|
new com.yunhai.agent.model.BrowserToolRequest.FollowUp();
|
||||||
|
String m = StringUtils.isBlank(def.getFollowUpMethod()) ? "POST" : def.getFollowUpMethod().toUpperCase();
|
||||||
|
fu.setMethod(m);
|
||||||
|
fu.setPathAndQuery(stripOrigin(followUpFullPath(def, base)));
|
||||||
|
if (StringUtils.isNotBlank(def.getFollowUpBody())) {
|
||||||
|
fu.setBody(fillPlaceholders(def.getFollowUpBody(), merged));
|
||||||
|
}
|
||||||
|
if ("POST_JSON".equals(m)) {
|
||||||
|
fu.setContentType("application/json;charset=UTF-8");
|
||||||
|
} else if ("POST_FORM".equals(m)) {
|
||||||
|
fu.setContentType("application/x-www-form-urlencoded;charset=UTF-8");
|
||||||
|
}
|
||||||
|
return fu;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第二步的完整地址(模块根 + 模板),供服务端直调与相对路径裁剪共用 */
|
||||||
|
private String followUpFullPath(ToolDef def, String base) {
|
||||||
|
return trimEnd(base) + def.getFollowUpPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 {@code {{参数名}}} 替换成实参值(用于第二步请求体) */
|
||||||
|
private String fillPlaceholders(String template, Map<String, Object> merged) {
|
||||||
|
String out = template;
|
||||||
|
if (merged != null) {
|
||||||
|
for (Map.Entry<String, Object> e : merged.entrySet()) {
|
||||||
|
String v = e.getValue() == null ? "" : String.valueOf(e.getValue());
|
||||||
|
out = out.replace("{{" + e.getKey() + "}}", v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首步响应里的业务数据(用于 {@code {data}}),取不到返回 null */
|
||||||
|
private String extractDataNode(String body) {
|
||||||
|
if (StringUtils.isBlank(body)) return null;
|
||||||
|
try {
|
||||||
|
JSONObject o = JSON.parseObject(body);
|
||||||
|
Object d = o == null ? null : o.get("data");
|
||||||
|
if (d == null) return null;
|
||||||
|
// 常见两种形态:直接回 id(作业创建接口 data=123),或回一个对象(data={id:123,...})。
|
||||||
|
// 对象形态必须取出 id 再填进路径,直接 JSON 序列化会拼出 /homeworks/{"id":123}/publish。
|
||||||
|
if (d instanceof JSONObject) {
|
||||||
|
Object id = ((JSONObject) d).get("id");
|
||||||
|
if (id == null) return null;
|
||||||
|
String s = String.valueOf(id);
|
||||||
|
return StringUtils.isBlank(s) || "null".equals(s) ? null : s;
|
||||||
|
}
|
||||||
|
String s = String.valueOf(d);
|
||||||
|
return StringUtils.isBlank(s) || "null".equals(s) ? null : s;
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端侧的两步提交:首步成功后用返回的 id 续跑第二步,返回第二步的结果
|
||||||
|
* (首步拿到 id 却发不出去时,如实报错而不谎报成功)。
|
||||||
|
*/
|
||||||
|
private ToolResult runFollowUpOnServer(ToolDef def, ResponseEntity<String> firstResp,
|
||||||
|
Map<String, Object> merged, String base,
|
||||||
|
HttpHeaders headers, long start) {
|
||||||
|
String dataId = extractDataNode(firstResp.getBody());
|
||||||
|
if (StringUtils.isBlank(dataId)) {
|
||||||
|
return ToolResult.fail(def, "第一步已提交,但没拿回记录 id,无法继续「" + def.getTitle() + "」的第二步",
|
||||||
|
System.currentTimeMillis() - start);
|
||||||
|
}
|
||||||
|
com.yunhai.agent.model.BrowserToolRequest.FollowUp fu = buildFollowUp(def, merged, base);
|
||||||
|
String url = followUpFullPath(def, base).replace("{data}", dataId);
|
||||||
|
try {
|
||||||
|
HttpHeaders h = new HttpHeaders();
|
||||||
|
h.putAll(headers);
|
||||||
|
HttpEntity<String> entity;
|
||||||
|
if (fu.getBody() != null) {
|
||||||
|
h.setContentType(MediaType.parseMediaType(fu.getContentType()));
|
||||||
|
entity = new HttpEntity<>(fu.getBody(), h);
|
||||||
|
} else {
|
||||||
|
entity = new HttpEntity<>(h);
|
||||||
|
}
|
||||||
|
ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.valueOf(fu.getMethod()),
|
||||||
|
entity, String.class);
|
||||||
|
return parseResponse(def, resp, System.currentTimeMillis() - start);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("工具 {} 第二步失败 url={} err={}", def.getName(), url, e.toString());
|
||||||
|
return ToolResult.fail(def, "第一步已提交,但第二步失败:" + e.getMessage(),
|
||||||
|
System.currentTimeMillis() - start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析浏览器回传的模块响应 —— 解析规则与服务端直调 {@link #execute} 完全一致
|
* 解析浏览器回传的模块响应 —— 解析规则与服务端直调 {@link #execute} 完全一致
|
||||||
* ({@code {code,msg,data}} 包装、{@code code=302} 识别、非包装响应原样返回)。
|
* ({@code {code,msg,data}} 包装、{@code code=302} 识别、非包装响应原样返回)。
|
||||||
@@ -486,9 +598,82 @@ public class ToolExecutor {
|
|||||||
}
|
}
|
||||||
out.put(e.getKey(), v);
|
out.put(e.getKey(), v);
|
||||||
}
|
}
|
||||||
|
// 数组参数却给了标量(模型偶尔写 "image" 而不是 ["image"])→ 包成单元素数组,
|
||||||
|
// 免得后端 @RequestBody List 反序列化直接 400
|
||||||
|
for (ToolParam p2 : def.getParams()) {
|
||||||
|
Object v = out.get(p2.getName());
|
||||||
|
if (v == null) continue;
|
||||||
|
if ("array".equalsIgnoreCase(StringUtils.trimToEmpty(p2.getType()))
|
||||||
|
&& !(v instanceof List) && !(v instanceof JSONArray)) {
|
||||||
|
out.put(p2.getName(), new ArrayList<>(java.util.Collections.singletonList(String.valueOf(v))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.putAll(expandDailyArgs(def, out));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「连续请假」派生字段:{@code continuousLeave=1} 时,按 {@code continuousLeaveNum} 天
|
||||||
|
* 把 {@code startDate}/{@code endDate} 复制成逐天时间段,产出模块要的
|
||||||
|
* {@code continuousLeaveTime:[{startDate,endDate}]}。
|
||||||
|
*
|
||||||
|
* <p>与模块页 {@code LeaveTimeSection.regenerate()} 口径一致(每天一段,时分沿用基准)。
|
||||||
|
* 放在执行器里做,浏览器代执行与服务端直调共用同一份逻辑。
|
||||||
|
*/
|
||||||
|
private Map<String, Object> expandDailyArgs(ToolDef def, Map<String, Object> args) {
|
||||||
|
Map<String, Object> extra = new LinkedHashMap<>();
|
||||||
|
for (ToolParam p : def.getParams()) {
|
||||||
|
Map<String, String> spec = p.getDailyExpandSpec();
|
||||||
|
if (spec == null || spec.isEmpty()) continue;
|
||||||
|
Object flag = args.get(p.getName());
|
||||||
|
if (flag == null || "0".equals(String.valueOf(flag).trim())
|
||||||
|
|| "false".equalsIgnoreCase(String.valueOf(flag).trim())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String countField = spec.get("count");
|
||||||
|
String startField = spec.get("start");
|
||||||
|
String endField = spec.get("end");
|
||||||
|
String s = args.get(startField) == null ? null : String.valueOf(args.get(startField));
|
||||||
|
String e = args.get(endField) == null ? null : String.valueOf(args.get(endField));
|
||||||
|
if (StringUtils.isBlank(s) || StringUtils.isBlank(e)) continue;
|
||||||
|
int days = 1;
|
||||||
|
try {
|
||||||
|
days = Integer.parseInt(String.valueOf(args.get(countField)).trim());
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
/* 天数缺失/非法 → 按 1 天处理 */
|
||||||
|
}
|
||||||
|
days = Math.max(1, Math.min(30, days));
|
||||||
|
List<Map<String, String>> rows = new ArrayList<>();
|
||||||
|
for (int i = 0; i < days; i++) {
|
||||||
|
Map<String, String> row = new LinkedHashMap<>();
|
||||||
|
row.put("startDate", shiftDays(s, i));
|
||||||
|
row.put("endDate", shiftDays(e, i));
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
extra.put("continuousLeaveTime", rows);
|
||||||
|
// 首段的起止时间对齐第一天(与模块页 payload() 的 startDate/endDate 口径一致)
|
||||||
|
if (!rows.isEmpty()) {
|
||||||
|
extra.put("startDate", rows.get(0).get("startDate"));
|
||||||
|
extra.put("endDate", rows.get(0).get("endDate"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extra;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 {@code yyyy-MM-dd HH:mm:ss} 往后推 n 天(解析失败原样返回) */
|
||||||
|
private String shiftDays(String dt, int n) {
|
||||||
|
if (n == 0 || StringUtils.isBlank(dt)) return dt;
|
||||||
|
try {
|
||||||
|
java.text.SimpleDateFormat f = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||||
|
java.util.Calendar c = java.util.Calendar.getInstance();
|
||||||
|
c.setTime(f.parse(dt));
|
||||||
|
c.add(java.util.Calendar.DAY_OF_MONTH, n);
|
||||||
|
return f.format(c.getTime());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 把数组/集合统一成字符串列表(供 join 与「全部」哨兵判断用) */
|
/** 把数组/集合统一成字符串列表(供 join 与「全部」哨兵判断用) */
|
||||||
private List<String> toStringList(Object v) {
|
private List<String> toStringList(Object v) {
|
||||||
List<String> out = new ArrayList<>();
|
List<String> out = new ArrayList<>();
|
||||||
|
|||||||
@@ -108,6 +108,18 @@ public class ToolParam {
|
|||||||
*/
|
*/
|
||||||
private Integer scopeType;
|
private Integer scopeType;
|
||||||
|
|
||||||
|
/** 「镜像字段」指向的源字段名(隐藏字段,提交时填入源字段选中项的显示名,如 deptId → deptName) */
|
||||||
|
private String mirrorLabelOf;
|
||||||
|
|
||||||
|
/** 「连续请假」展开说明:{@code {count,start,end}} 三个字段名(见 {@link #expandDaily}) */
|
||||||
|
private Map<String, String> dailyExpandSpec;
|
||||||
|
|
||||||
|
/** {@code widget=file} 时提交附件对象数组而非 fileId 数组(作业模块 attachments) */
|
||||||
|
private boolean fileObject;
|
||||||
|
|
||||||
|
/** 附件上传地址覆盖(缺省由编排层按 {@code /<模块>/api/base/FileUpload} 兜底) */
|
||||||
|
private String uploadPathOverride;
|
||||||
|
|
||||||
/** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */
|
/** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */
|
||||||
private boolean hidden;
|
private boolean hidden;
|
||||||
|
|
||||||
@@ -265,6 +277,58 @@ public class ToolParam {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 声明为「审批流」控件(校园公告的 {@code flow})。
|
||||||
|
*
|
||||||
|
* <p>表单会渲染成:是否走审批 + 多步(每步可选或签/会签)+ 每步多选审批人
|
||||||
|
* (候选人来自 {@link #optionsFrom} 指向的教师树工具);提交时前端拼成
|
||||||
|
* {@code [{name:"第1步",vote:"any"|"all",approverTeaIds:[...]}]}。
|
||||||
|
*/
|
||||||
|
public ToolParam flow() {
|
||||||
|
this.widget = "flow";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 声明本字段为**隐藏的镜像字段**:用户不填,提交时自动带上源字段选中项的显示名。
|
||||||
|
*
|
||||||
|
* <p>用于 {@code deptName} 这类「模块页会随包提交、但界面上没有对应输入框」的字段:
|
||||||
|
* 三个模块页都是「选部门 → 用字典把 id 反查成名称 → 一起提交」。
|
||||||
|
*/
|
||||||
|
public ToolParam mirrorLabelOf(String sourceField) {
|
||||||
|
this.mirrorLabelOf = sourceField;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连续请假展开:本字段取 1 时,前端按 {@code countField} 的天数把
|
||||||
|
* {@code startField}/{@code endField} 复制成逐天时间段,生成 {@code continuousLeaveTime}。
|
||||||
|
*
|
||||||
|
* @param countField 连续天数字段名(如 continuousLeaveNum)
|
||||||
|
* @param startField 基准开始时间字段名(如 startDate)
|
||||||
|
* @param endField 基准结束时间字段名(如 endDate)
|
||||||
|
*/
|
||||||
|
public ToolParam expandDaily(String countField, String startField, String endField) {
|
||||||
|
this.dailyExpandSpec = new LinkedHashMap<>();
|
||||||
|
this.dailyExpandSpec.put("count", countField);
|
||||||
|
this.dailyExpandSpec.put("start", startField);
|
||||||
|
this.dailyExpandSpec.put("end", endField);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 附件控件提交**对象数组**(作业模块 {@code attachments:[{fileId,fileUrl,fileType,fileName,sort}]}) */
|
||||||
|
public ToolParam asAttachments() {
|
||||||
|
this.widget = "file";
|
||||||
|
this.fileObject = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 覆盖附件上传地址(缺省由编排层按 {@code /<模块>/api/base/FileUpload} 兜底) */
|
||||||
|
public ToolParam upload(String path) {
|
||||||
|
this.uploadPathOverride = path;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/** 表单里显示的名字 */
|
/** 表单里显示的名字 */
|
||||||
public String labelOf() {
|
public String labelOf() {
|
||||||
return StringUtils.isNotBlank(label) ? label : name;
|
return StringUtils.isNotBlank(label) ? label : name;
|
||||||
@@ -355,6 +419,9 @@ public class ToolParam {
|
|||||||
f.setAllToken(allToken);
|
f.setAllToken(allToken);
|
||||||
f.setAllTokenLabel(allTokenLabel);
|
f.setAllTokenLabel(allTokenLabel);
|
||||||
f.setScopeType(scopeType);
|
f.setScopeType(scopeType);
|
||||||
|
f.setMirrorOf(mirrorLabelOf);
|
||||||
|
f.setDailyExpand(dailyExpandSpec);
|
||||||
|
f.setFileAsObject(fileObject);
|
||||||
// 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户
|
// 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户
|
||||||
f.setOptionsFallback(isUsingFallbackOptions());
|
f.setOptionsFallback(isUsingFallbackOptions());
|
||||||
return f;
|
return f;
|
||||||
|
|||||||
@@ -112,12 +112,16 @@ public final class ActivityTools {
|
|||||||
.label("活动标题").eg("秋季亲子运动会"),
|
.label("活动标题").eg("秋季亲子运动会"),
|
||||||
ToolParam.required("content", "string", "活动说明(纯文本,对应模块页的富文本编辑器)")
|
ToolParam.required("content", "string", "活动说明(纯文本,对应模块页的富文本编辑器)")
|
||||||
.label("活动说明").as("textarea").eg("为增进家校互动,学校拟举办秋季亲子运动会……"),
|
.label("活动说明").as("textarea").eg("为增进家校互动,学校拟举办秋季亲子运动会……"),
|
||||||
ToolParam.required("category", "string", "活动分类,来自 activity.categoryDic 的 k 字段")
|
ToolParam.required("category", "string", "活动分类:**分类名称**(模块页存的是名称,不是 id)")
|
||||||
.label("活动分类")
|
.label("活动分类")
|
||||||
.optionsFrom("activity.categoryDic").optionsKeys("k", "v"),
|
.optionsFrom("activity.categoryDic").optionsKeys("v", "v"),
|
||||||
ToolParam.required("deptId", "string", "主办部门 id,来自 activity.deptDic 的 k 字段")
|
ToolParam.required("deptId", "string", "主办部门 id,来自 activity.deptDic 的 k 字段")
|
||||||
.label("主办部门")
|
.label("主办部门")
|
||||||
.optionsFrom("activity.deptDic").optionsKeys("k", "v"),
|
.optionsFrom("activity.deptDic").optionsKeys("k", "v"),
|
||||||
|
// 模块页会用字典把部门 id 反查成名称一起提交,这里由前端按所选下拉项的名称镜像过去
|
||||||
|
ToolParam.optional("deptName", "string", "主办部门名称(由所选部门自动带出,无需填写)")
|
||||||
|
.label("主办部门名称")
|
||||||
|
.mirrorLabelOf("deptId"),
|
||||||
ToolParam.required("venue", "string", "活动地点")
|
ToolParam.required("venue", "string", "活动地点")
|
||||||
.label("活动地点").eg("学校操场"),
|
.label("活动地点").eg("学校操场"),
|
||||||
ToolParam.required("startTime", "string", "活动开始时间 yyyy-MM-dd HH:mm:ss")
|
ToolParam.required("startTime", "string", "活动开始时间 yyyy-MM-dd HH:mm:ss")
|
||||||
|
|||||||
@@ -63,48 +63,38 @@ public final class CampusNoticeTools {
|
|||||||
|
|
||||||
list.add(ToolDef.of("notice.publish", MODULE, "发布校园公告",
|
list.add(ToolDef.of("notice.publish", MODULE, "发布校园公告",
|
||||||
"发布一条校园公告。字段与校园公告模块的「新建公告」页**完全一致**:"
|
"发布一条校园公告。字段与校园公告模块的「新建公告」页**完全一致**:"
|
||||||
+ "标题、重要程度、来源部门、接收班级、置顶、短信提醒、学员阅读回执、附件、正文、定时发送、发布方式。"
|
+ "标题、重要程度、来源部门、接收班级、置顶、短信提醒、学员阅读回执、附件、正文、审批流程、定时发送、发布方式。"
|
||||||
+ "调用前必须确认:标题 title、正文 content、发文部门 deptId;"
|
+ "调用前必须确认:标题 title、正文 content、发文部门 deptId;"
|
||||||
+ "缺失就直接调用本工具,系统会弹出**完整表单**让用户填写,不要用文字反复追问;"
|
+ "缺失就直接调用本工具,系统会弹出**完整表单**让用户填写,不要用文字反复追问;"
|
||||||
+ "发文部门必须来自字典,不要编造。"
|
+ "发文部门必须来自字典,不要编造。"
|
||||||
+ "接收班级 pushScopes 默认「全部班级」;要定向到具体班级时由表单多选。"
|
+ "接收班级 pushScopes 默认「全部班级」;要定向到具体班级时由表单多选。"
|
||||||
+ "发布方式 publishType:direct 直接发布(默认)/ draft 存为草稿。"
|
+ "发布方式 publishType:direct 直接发布(默认)/ approve 提交审批(此时须同时给出 flow 审批流程)。"
|
||||||
|
+ "只存草稿请改用「存为公告草稿」工具(模块页的存草稿走的是另一个接口)。"
|
||||||
+ "timingSendTime 非空 = 定时发送。",
|
+ "timingSendTime 非空 = 定时发送。",
|
||||||
"POST_JSON", "/api/notice/Create")
|
"POST_JSON", "/api/notice/Create")
|
||||||
.p(ToolParam.required("title", "string", "公告标题(200 字以内)")
|
.p(noticePublishParams())
|
||||||
.label("公告标题").eg("关于国庆假期安排的通知"),
|
|
||||||
ToolParam.required("content", "string", "公告正文(纯文本,对应模块页的富文本编辑器)")
|
|
||||||
.label("公告正文").as("textarea").eg("各位老师、同学:经学校研究决定……"),
|
|
||||||
ToolParam.optional("type", "integer", "重要程度:1 普通 / 2 重要")
|
|
||||||
.label("重要程度").dft("1")
|
|
||||||
.options(new String[]{"1", "2"}, new String[]{"普通", "重要"}),
|
|
||||||
ToolParam.required("deptId", "string", "发文部门 id,来自 notice.deptDics 的 k 字段")
|
|
||||||
.label("发文部门")
|
|
||||||
.optionsFrom("notice.deptDics").optionsKeys("k", "v"),
|
|
||||||
ToolParam.optional("pushScopes", "array", "接收班级:留空或含全部 = 全部班级;也可多选具体班级")
|
|
||||||
.label("接收班级")
|
|
||||||
.optionsFrom("notice.stuTree").optionsKeys("id", "name")
|
|
||||||
.scopes(4).allOption("ALL", "全部班级"),
|
|
||||||
ToolParam.optional("topmost", "integer", "是否置顶")
|
|
||||||
.label("置顶").dft("0")
|
|
||||||
.options(new String[]{"0", "1"}, new String[]{"否", "是"}),
|
|
||||||
ToolParam.optional("smsFlag", "integer", "是否同时发短信提醒")
|
|
||||||
.label("短信提醒").dft("0")
|
|
||||||
.options(new String[]{"0", "1"}, new String[]{"否", "是"}),
|
|
||||||
ToolParam.optional("receiptFlag", "integer", "是否需要学员阅读回执(要回执时收件人需点确认)")
|
|
||||||
.label("学员阅读回执").dft("0")
|
|
||||||
.options(new String[]{"0", "1"}, new String[]{"否", "是"}),
|
|
||||||
ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)")
|
|
||||||
.label("添加附件").as("file").multiple(true).join(","),
|
|
||||||
ToolParam.optional("timingSendTime", "string", "定时发送时间 yyyy-MM-dd HH:mm:ss(留空=立即发布)")
|
|
||||||
.label("定时发送").eg("2026-10-13 08:00:00"),
|
|
||||||
ToolParam.optional("publishType", "string", "发布方式:direct 直接发布 / draft 存为草稿")
|
|
||||||
.label("发布方式").dft("direct")
|
|
||||||
.options(new String[]{"direct", "draft"}, new String[]{"直接发布", "存为草稿"}))
|
|
||||||
.fullForm()
|
.fullForm()
|
||||||
.write("/list/new")
|
.write("/list/new")
|
||||||
.modId(null));
|
.modId(null));
|
||||||
|
|
||||||
|
list.add(ToolDef.of("notice.saveDraft", MODULE, "存为公告草稿",
|
||||||
|
"把一条校园公告存为草稿(不发布,可在公告模块的草稿箱继续编辑)。"
|
||||||
|
+ "字段与「新建公告」页完全一致;只想先打个底稿时用本工具。",
|
||||||
|
"POST_JSON", "/api/notice/SaveDraft")
|
||||||
|
.p(noticePublishParams(false))
|
||||||
|
.fullForm()
|
||||||
|
.write("/drafts")
|
||||||
|
.modId(null));
|
||||||
|
|
||||||
|
list.add(ToolDef.of("notice.teaTree", MODULE, "组织树(教师)",
|
||||||
|
"查询本校教师组织树(学部 / 年级 / 教研组 / 教师),用于配置公告审批人。"
|
||||||
|
+ "仅在用户明确要「按组织范围选审批人」时使用;"
|
||||||
|
+ "发布公告时**不要**先调它 —— 表单的「审批流程」选人会自动代取该树。",
|
||||||
|
"GET", "/api/base/getTeaTree")
|
||||||
|
.fix("toUser", true)
|
||||||
|
.readAt("/list/new")
|
||||||
|
.modId(null));
|
||||||
|
|
||||||
list.add(ToolDef.of("notice.stuTree", MODULE, "组织树(学员)",
|
list.add(ToolDef.of("notice.stuTree", MODULE, "组织树(学员)",
|
||||||
"查询本校组织树(学部 / 年级 / 班级),用于发布公告时选择接收班级。"
|
"查询本校组织树(学部 / 年级 / 班级),用于发布公告时选择接收班级。"
|
||||||
+ "仅在用户明确要「按组织范围选人」时使用;"
|
+ "仅在用户明确要「按组织范围选人」时使用;"
|
||||||
@@ -144,4 +134,66 @@ public final class CampusNoticeTools {
|
|||||||
|
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「新建公告」页的完整字段(发布与存草稿共用)。
|
||||||
|
*
|
||||||
|
* <p>★ 与模块页 {@code NewNotice.vue#buildBody} 逐项对齐:{@code title/content/type/deptId/
|
||||||
|
* deptName/pushScopes/topmost/receiptFlag/fileIds/smsFlag/timingSendTime/flow/publishType}。
|
||||||
|
*
|
||||||
|
* @param withPublishType 是否带「发布方式」—— 存草稿走的是 {@code SaveDraft} 接口,
|
||||||
|
* 模块页在这一步并不传 {@code publishType}
|
||||||
|
*/
|
||||||
|
private static ToolParam[] noticePublishParams(boolean withPublishType) {
|
||||||
|
List<ToolParam> ps = new ArrayList<>();
|
||||||
|
ps.add(ToolParam.required("title", "string", "公告标题(200 字以内)")
|
||||||
|
.label("公告标题").eg("关于国庆假期安排的通知"));
|
||||||
|
ps.add(ToolParam.required("content", "string", "公告正文(纯文本,对应模块页的富文本编辑器)")
|
||||||
|
.label("公告正文").as("textarea").eg("各位老师、同学:经学校研究决定……"));
|
||||||
|
ps.add(ToolParam.optional("type", "integer", "重要程度:1 普通 / 2 重要")
|
||||||
|
.label("重要程度").dft("1")
|
||||||
|
.options(new String[]{"1", "2"}, new String[]{"普通", "重要"}));
|
||||||
|
ps.add(ToolParam.required("deptId", "string", "发文部门 id,来自 notice.deptDics 的 k 字段")
|
||||||
|
.label("发文部门")
|
||||||
|
.optionsFrom("notice.deptDics").optionsKeys("k", "v"));
|
||||||
|
// 模块页会用字典把部门 id 反查成名称一起提交,这里由前端按所选下拉项的名称镜像过去
|
||||||
|
ps.add(ToolParam.optional("deptName", "string", "发文部门名称(由所选部门自动带出,无需填写)")
|
||||||
|
.label("发文部门名称")
|
||||||
|
.mirrorLabelOf("deptId"));
|
||||||
|
ps.add(ToolParam.optional("pushScopes", "array", "接收班级:留空或含全部 = 全部班级;也可多选具体班级")
|
||||||
|
.label("接收班级")
|
||||||
|
.optionsFrom("notice.stuTree").optionsKeys("id", "name")
|
||||||
|
.scopes(4).allOption("ALL", "全部班级"));
|
||||||
|
ps.add(ToolParam.optional("topmost", "integer", "是否置顶")
|
||||||
|
.label("置顶").dft("0")
|
||||||
|
.options(new String[]{"0", "1"}, new String[]{"否", "是"}));
|
||||||
|
ps.add(ToolParam.optional("smsFlag", "integer", "是否同时发短信提醒")
|
||||||
|
.label("短信提醒").dft("0")
|
||||||
|
.options(new String[]{"0", "1"}, new String[]{"否", "是"}));
|
||||||
|
ps.add(ToolParam.optional("receiptFlag", "integer", "是否需要学员阅读回执(要回执时收件人需点确认)")
|
||||||
|
.label("学员阅读回执").dft("0")
|
||||||
|
.options(new String[]{"0", "1"}, new String[]{"否", "是"}));
|
||||||
|
ps.add(ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)")
|
||||||
|
.label("添加附件").as("file").multiple(true).join(","));
|
||||||
|
ps.add(ToolParam.optional("timingSendTime", "string", "定时发送时间 yyyy-MM-dd HH:mm:ss(留空=立即发布)")
|
||||||
|
.label("定时发送").eg("2026-10-13 08:00:00"));
|
||||||
|
// 审批流:模块页的 ApprovalFlowEditor(可多步,每步或签/会签,审批人来自教师树)
|
||||||
|
ps.add(ToolParam.optional("flow", "array",
|
||||||
|
"审批流程:走审批时填写。提交时按「走审批流程 + 多步 + 每步审批人与表决方式」组装成 "
|
||||||
|
+ "[{name,vote,approverTeaIds}];不填 = 不走审批(直接发布)")
|
||||||
|
.label("审批流程")
|
||||||
|
.flow()
|
||||||
|
// 教师树叶子形如 {id,name,...},其中 id 即 teaId(与模块页选人弹窗口径一致)
|
||||||
|
.optionsFrom("notice.teaTree").optionsKeys("id", "name"));
|
||||||
|
if (withPublishType) {
|
||||||
|
ps.add(ToolParam.optional("publishType", "string", "发布方式:direct 直接发布 / approve 提交审批")
|
||||||
|
.label("发布方式").dft("direct")
|
||||||
|
.options(new String[]{"direct", "approve"}, new String[]{"直接发布", "提交审批"}));
|
||||||
|
}
|
||||||
|
return ps.toArray(new ToolParam[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ToolParam[] noticePublishParams() {
|
||||||
|
return noticePublishParams(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ public final class DormNoticeTools {
|
|||||||
ToolParam.required("deptId", "string", "发文部门 id,来自 dorm.deptDic 的 k 字段")
|
ToolParam.required("deptId", "string", "发文部门 id,来自 dorm.deptDic 的 k 字段")
|
||||||
.label("发文部门")
|
.label("发文部门")
|
||||||
.optionsFrom("dorm.deptDic").optionsKeys("k", "v"),
|
.optionsFrom("dorm.deptDic").optionsKeys("k", "v"),
|
||||||
|
// 模块页会用字典把部门 id 反查成名称一起提交,这里由前端按所选下拉项的名称镜像过去
|
||||||
|
ToolParam.optional("deptName", "string", "发文部门名称(由所选部门自动带出,无需填写)")
|
||||||
|
.label("发文部门名称")
|
||||||
|
.mirrorLabelOf("deptId"),
|
||||||
ToolParam.optional("level", "string", "紧急程度:normal 普通 / important 重要 / urgent 紧急")
|
ToolParam.optional("level", "string", "紧急程度:normal 普通 / important 重要 / urgent 紧急")
|
||||||
.label("紧急程度").dft("normal")
|
.label("紧急程度").dft("normal")
|
||||||
.options(new String[]{"normal", "important", "urgent"},
|
.options(new String[]{"normal", "important", "urgent"},
|
||||||
|
|||||||
@@ -7,20 +7,42 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 课程作业模块工具(学生端 V2)—— 后端 CourseHomework,前缀 /api/student/homeworks。
|
* 课程作业模块工具(后端 CourseHomework)。
|
||||||
*
|
*
|
||||||
* <p>学生端接口路径带资源 id({@code /{id}/...}),执行器支持 {@code {name}} 路径占位。
|
* <p>两侧接口并列:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>学生端 V2</b> —— 前缀 {@code /api/student/homeworks},路径带资源 id
|
||||||
|
* (执行器支持 {@code {name}} 路径占位);</li>
|
||||||
|
* <li><b>教师端 V2</b> —— 前缀 {@code /api/teacher/homeworks},发布页在
|
||||||
|
* {@code NewHomework.vue}。★ 模块没有「一步发布」的接口,模块页自己也是
|
||||||
|
* {@code Create(落草稿) → Publish | Schedule},故这里用 {@link ToolDef#then}
|
||||||
|
* 声明第二步,让用户在助手里填一次表单就发出去。</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public final class HomeworkTools {
|
public final class HomeworkTools {
|
||||||
|
|
||||||
public static final String MODULE = "coursehomework";
|
public static final String MODULE = "coursehomework";
|
||||||
|
|
||||||
|
private static final String TEACHER = "/api/teacher/homeworks";
|
||||||
|
|
||||||
|
/** 附件上传地址(作业模块走 file 域,与其余模块的 base/FileUpload 不同) */
|
||||||
|
private static final String UPLOAD_PATH = "/coursehomework/api/file/Upload";
|
||||||
|
|
||||||
private HomeworkTools() {
|
private HomeworkTools() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<ToolDef> defs() {
|
public static List<ToolDef> defs() {
|
||||||
List<ToolDef> list = new ArrayList<>();
|
List<ToolDef> list = new ArrayList<>();
|
||||||
|
|
||||||
|
addStudentSide(list);
|
||||||
|
addTeacherPublishSide(list);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 学生端 ==================== */
|
||||||
|
|
||||||
|
private static void addStudentSide(List<ToolDef> list) {
|
||||||
|
|
||||||
list.add(ToolDef.of("homework.myList", MODULE, "我的作业列表",
|
list.add(ToolDef.of("homework.myList", MODULE, "我的作业列表",
|
||||||
"查询我的作业:待做 / 已完成 / 已截止 / 全部。用户问“今天有什么作业”“还有几项没交”时用它。",
|
"查询我的作业:待做 / 已完成 / 已截止 / 全部。用户问“今天有什么作业”“还有几项没交”时用它。",
|
||||||
"GET", "/api/student/homeworks")
|
"GET", "/api/student/homeworks")
|
||||||
@@ -51,7 +73,112 @@ public final class HomeworkTools {
|
|||||||
ToolParam.optional("text", "string", "作答文本内容")
|
ToolParam.optional("text", "string", "作答文本内容")
|
||||||
.label("作答内容").as("textarea").eg("我的解题思路是……"))
|
.label("作答内容").as("textarea").eg("我的解题思路是……"))
|
||||||
.write("/stu/today"));
|
.write("/stu/today"));
|
||||||
|
}
|
||||||
|
|
||||||
return list;
|
/* ==================== 教师端:发布作业(模块页 页 1) ==================== */
|
||||||
|
|
||||||
|
private static void addTeacherPublishSide(List<ToolDef> list) {
|
||||||
|
|
||||||
|
list.add(ToolDef.of("homework.teachingSubjects", MODULE, "我的任教学科",
|
||||||
|
"查询当前教师的任教学科(发布作业时选「学科」用)。"
|
||||||
|
+ "仅在用户明确问「我教哪些学科」时使用;"
|
||||||
|
+ "发布作业时**不要**先调它 —— 表单的「学科」下拉会自动带出可选值。",
|
||||||
|
"GET", TEACHER + "/subjects/teaching")
|
||||||
|
.readAt("/new"));
|
||||||
|
|
||||||
|
list.add(ToolDef.of("homework.classTree", MODULE, "任教班级树",
|
||||||
|
"年级 → 班级 树(叶子是班级,仅含本人任教班级;发布作业时选接收班级用)。"
|
||||||
|
+ "仅在用户明确要「按年级选班」时使用;"
|
||||||
|
+ "发布作业时**不要**先调它 —— 表单的「接收班级」多选会自动代取该树。",
|
||||||
|
"GET", TEACHER + "/classes")
|
||||||
|
.readAt("/new"));
|
||||||
|
|
||||||
|
// ---------- 立即发布(Create 落草稿 → Publish) ----------
|
||||||
|
list.add(ToolDef.of("homework.publish", MODULE, "发布作业",
|
||||||
|
"教师发布一条作业(学生端随即可见)。字段与课程作业模块的「新建作业」页**完全一致**:"
|
||||||
|
+ "标题、学科、接收班级、截止时间、提交方式、逾期提交、截止提醒、附件、作业内容。"
|
||||||
|
+ "调用前必须确认:标题 title、学科 subject、接收班级 classIds;"
|
||||||
|
+ "缺失就直接调用本工具,系统会弹出**完整表单**让用户填写,不要用文字反复追问;"
|
||||||
|
+ "学科与班级必须来自本人任教范围,不要编造。"
|
||||||
|
+ "提交方式 submitType 可多选(拍照/上传附件/文字作答),勾「仅查阅」则学生无需提交;"
|
||||||
|
+ "「仅查阅」与其余三种互斥。"
|
||||||
|
+ "需要定时发布请改用「定时发布作业」。",
|
||||||
|
"POST_JSON", TEACHER)
|
||||||
|
.p(homeworkPublishParams(false))
|
||||||
|
// ★ 模块没有一步发布的接口:先 Create 落草稿,再用返回的作业 id 发布
|
||||||
|
.then(TEACHER + "/{data}/publish", null)
|
||||||
|
.fullForm()
|
||||||
|
.write("/new"));
|
||||||
|
|
||||||
|
// ---------- 定时发布(Create 落草稿 → Schedule) ----------
|
||||||
|
list.add(ToolDef.of("homework.schedule", MODULE, "定时发布作业",
|
||||||
|
"教师定时发布一条作业(到点自动发布)。字段与「新建作业」页完全一致,"
|
||||||
|
+ "额外需要定时发布时间 scheduledAt(必须晚于当前时间)。",
|
||||||
|
"POST_JSON", TEACHER)
|
||||||
|
.p(homeworkPublishParams(true))
|
||||||
|
.then(TEACHER + "/{data}/schedule", "{\"scheduledAt\":\"{{scheduledAt}}\"}")
|
||||||
|
.fullForm()
|
||||||
|
.write("/new"));
|
||||||
|
|
||||||
|
list.add(ToolDef.of("homework.published", MODULE, "我发布的作业",
|
||||||
|
"分页查询本人已发布的作业(含进度)。用户问「我发过哪些作业」「那条作业交得怎么样」时用它。",
|
||||||
|
"GET", TEACHER + "/published")
|
||||||
|
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
|
||||||
|
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
|
||||||
|
ToolParam.optional("keyword", "string", "作业标题关键词"),
|
||||||
|
ToolParam.optional("subject", "string", "按学科筛选"))
|
||||||
|
.readAt("/list"));
|
||||||
|
|
||||||
|
list.add(ToolDef.of("homework.drafts", MODULE, "我的作业草稿",
|
||||||
|
"分页查询本人的作业草稿(尚未发布的)。",
|
||||||
|
"GET", TEACHER + "/drafts")
|
||||||
|
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
|
||||||
|
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
|
||||||
|
ToolParam.optional("keyword", "string", "作业标题关键词"))
|
||||||
|
.readAt("/drafts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「新建作业」页的完整字段({@code HomeworkV2Request})。
|
||||||
|
*
|
||||||
|
* @param withSchedule 是否带「定时发布时间」—— 只有定时发布工具需要
|
||||||
|
*/
|
||||||
|
private static ToolParam[] homeworkPublishParams(boolean withSchedule) {
|
||||||
|
List<ToolParam> ps = new ArrayList<>();
|
||||||
|
ps.add(ToolParam.required("title", "string", "作业标题(50 字以内)")
|
||||||
|
.label("作业标题").eg("第三章课后练习"));
|
||||||
|
ps.add(ToolParam.required("subject", "string", "学科:来自本人任教学科(subId)")
|
||||||
|
.label("学科")
|
||||||
|
.optionsFrom("homework.teachingSubjects").optionsKeys("subId", "subName"));
|
||||||
|
ps.add(ToolParam.required("classIds", "array", "接收班级:班级 id 列表(仅本人任教班级,可多选)")
|
||||||
|
.label("接收班级")
|
||||||
|
.optionsFrom("homework.classTree").optionsKeys("id", "name"));
|
||||||
|
ps.add(ToolParam.optional("description", "string", "作业内容(纯文本,对应模块页的富文本编辑器)")
|
||||||
|
.label("作业内容").as("textarea").eg("完成课本 P32 第 1~5 题,写出解题过程。"));
|
||||||
|
ps.add(ToolParam.optional("deadlineAt", "string", "截止时间 yyyy-MM-dd HH:mm:ss(留空=不设截止)")
|
||||||
|
.label("截止时间").eg("2026-10-20 22:00:00"));
|
||||||
|
ps.add(ToolParam.optional("submitType", "array",
|
||||||
|
"提交方式(可多选):image 拍照/图片 / attach 上传附件 / text 文字作答 / none 仅查阅(不必提交)。"
|
||||||
|
+ "勾「none」表示学生只预览、教师无需批改,与其余三种互斥")
|
||||||
|
.label("提交方式")
|
||||||
|
.dft("image")
|
||||||
|
.options(new String[]{"image", "attach", "text", "none"},
|
||||||
|
new String[]{"拍照/图片", "上传附件", "文字作答", "仅查阅(不必提交)"}));
|
||||||
|
ps.add(ToolParam.optional("allowLate", "integer", "是否允许逾期提交(截止后仍可补交)")
|
||||||
|
.label("逾期提交").dft("0")
|
||||||
|
.options(new String[]{"0", "1"}, new String[]{"不允许", "允许"}));
|
||||||
|
ps.add(ToolParam.optional("remind1d", "integer", "是否在截止前 1 天提醒未交学生")
|
||||||
|
.label("截止前 1 天提醒").dft("0")
|
||||||
|
.options(new String[]{"0", "1"}, new String[]{"否", "是"}));
|
||||||
|
ps.add(ToolParam.optional("remind1h", "integer", "是否在截止前 1 小时提醒未交学生")
|
||||||
|
.label("截止前 1 小时提醒").dft("0")
|
||||||
|
.options(new String[]{"0", "1"}, new String[]{"否", "是"}));
|
||||||
|
ps.add(ToolParam.optional("attachments", "array", "作业附件(由表单的附件上传得到)")
|
||||||
|
.label("添加附件").asAttachments().multiple(true).upload(UPLOAD_PATH));
|
||||||
|
if (withSchedule) {
|
||||||
|
ps.add(ToolParam.required("scheduledAt", "string", "定时发布时间 yyyy-MM-dd HH:mm:ss(须晚于当前时间)")
|
||||||
|
.label("定时发布时间").eg("2026-10-20 08:00:00"));
|
||||||
|
}
|
||||||
|
return ps.toArray(new ToolParam[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,11 +130,15 @@ public final class LeaveTools {
|
|||||||
.optionsFrom("leave.myClasses").optionsKeys("classId", "className"),
|
.optionsFrom("leave.myClasses").optionsKeys("classId", "className"),
|
||||||
ToolParam.optional("continuousLeave", "integer", "是否连续请假(同一时段连续多天)")
|
ToolParam.optional("continuousLeave", "integer", "是否连续请假(同一时段连续多天)")
|
||||||
.label("连续请假").dft("0")
|
.label("连续请假").dft("0")
|
||||||
.options(new String[]{"0", "1"}, new String[]{"否", "是"}),
|
.options(new String[]{"0", "1"}, new String[]{"否", "是"})
|
||||||
|
// 选「是」时按下面的连续天数把起止时间复制成逐天时间段,
|
||||||
|
// 生成模块要的 continuousLeaveTime(与模块页 LeaveTimeSection 一致)
|
||||||
|
.expandDaily("continuousLeaveNum", "startDate", "endDate"),
|
||||||
ToolParam.optional("continuousLeaveNum", "integer", "连续天数(continuousLeave=1 时有效,1~30)")
|
ToolParam.optional("continuousLeaveNum", "integer", "连续天数(continuousLeave=1 时有效,1~30)")
|
||||||
.label("连续天数").dft("1"),
|
.label("连续天数").dft("1"),
|
||||||
ToolParam.optional("fileIds", "array", "佐证材料附件 id 列表(由表单的附件上传得到)")
|
// ★ 模块传的是 number[](JSON 数组),不是逗号串,故不声明 join
|
||||||
.label("佐证材料").as("file").multiple(false).join(","))
|
ToolParam.optional("fileIds", "array", "佐证材料附件 id 列表(由表单的附件上传得到,可多份)")
|
||||||
|
.label("佐证材料").as("file").multiple(true))
|
||||||
.fix("type", 2)
|
.fix("type", 2)
|
||||||
.fullForm()
|
.fullForm()
|
||||||
.write("/my")
|
.write("/my")
|
||||||
@@ -166,11 +170,13 @@ public final class LeaveTools {
|
|||||||
.label("请假事由").as("textarea").eg("家中有事,需请假一天"),
|
.label("请假事由").as("textarea").eg("家中有事,需请假一天"),
|
||||||
ToolParam.optional("continuousLeave", "integer", "是否连续请假(同一时段连续多天)")
|
ToolParam.optional("continuousLeave", "integer", "是否连续请假(同一时段连续多天)")
|
||||||
.label("连续请假").dft("0")
|
.label("连续请假").dft("0")
|
||||||
.options(new String[]{"0", "1"}, new String[]{"否", "是"}),
|
.options(new String[]{"0", "1"}, new String[]{"否", "是"})
|
||||||
|
.expandDaily("continuousLeaveNum", "startDate", "endDate"),
|
||||||
ToolParam.optional("continuousLeaveNum", "integer", "连续天数(continuousLeave=1 时有效,1~30)")
|
ToolParam.optional("continuousLeaveNum", "integer", "连续天数(continuousLeave=1 时有效,1~30)")
|
||||||
.label("连续天数").dft("1"),
|
.label("连续天数").dft("1"),
|
||||||
ToolParam.optional("fileIds", "array", "佐证材料附件 id 列表(由表单的附件上传得到)")
|
// ★ 模块传的是 number[](JSON 数组),不是逗号串,故不声明 join
|
||||||
.label("佐证材料").as("file").multiple(false).join(","))
|
ToolParam.optional("fileIds", "array", "佐证材料附件 id 列表(由表单的附件上传得到,可多份)")
|
||||||
|
.label("佐证材料").as("file").multiple(true))
|
||||||
.fix("type", 1)
|
.fix("type", 1)
|
||||||
.fullForm()
|
.fullForm()
|
||||||
.write("/register")
|
.write("/register")
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package com.yunhai.agent;
|
||||||
|
|
||||||
|
import com.yunhai.agent.config.AgentSettings;
|
||||||
|
import com.yunhai.agent.model.BrowserToolRequest;
|
||||||
|
import com.yunhai.agent.tool.FormField;
|
||||||
|
import com.yunhai.agent.tool.ToolDef;
|
||||||
|
import com.yunhai.agent.tool.ToolExecutor;
|
||||||
|
import com.yunhai.agent.tool.ToolParam;
|
||||||
|
import com.yunhai.agent.tool.ToolRegistry;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「五个模块的发布页字段与助手表单完全对齐」的契约断言。
|
||||||
|
*
|
||||||
|
* <p>逐条钉住本轮从模块源码里抠出来的差异 —— 这些都是**会真的提交出问题**的地方,
|
||||||
|
* 不是风格问题:
|
||||||
|
* <ol>
|
||||||
|
* <li>公告 {@code publishType} 合法值是 {@code direct / approve}(存草稿走 {@code SaveDraft} 接口);</li>
|
||||||
|
* <li>活动 {@code category} 存的是分类**名称**(模块页写的是 {@code :value="String(c.v)"});</li>
|
||||||
|
* <li>请假 {@code fileIds} 是 {@code number[]},**不能**拼成逗号串;</li>
|
||||||
|
* <li>公告/宿舍/活动三处的 {@code deptName} 要按所选部门镜像过去;</li>
|
||||||
|
* <li>作业「发布」是两步(先落草稿 → 再发布/定时),附件是对象数组;</li>
|
||||||
|
* <li>公告审批流 {@code flow} 与教师树 {@code notice.teaTree}。</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
class ModuleFormParityTest {
|
||||||
|
|
||||||
|
private ToolRegistry registry;
|
||||||
|
private AgentSettings settings;
|
||||||
|
private ToolExecutor executor;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
settings = new AgentSettings();
|
||||||
|
settings.getModules().put("campusnotice", "http://base.yunhaixingtong.com/campusnotice");
|
||||||
|
settings.getModules().put("dormnotice", "http://base.yunhaixingtong.com/dormnotice");
|
||||||
|
settings.getModules().put("familyactivity", "http://base.yunhaixingtong.com/familyactivity");
|
||||||
|
settings.getModules().put("homeleave", "http://base.yunhaixingtong.com/homeleave");
|
||||||
|
settings.getModules().put("coursehomework", "http://base.yunhaixingtong.com/coursehomework");
|
||||||
|
registry = new ToolRegistry(settings);
|
||||||
|
registry.init();
|
||||||
|
executor = new ToolExecutor(settings, new RestTemplate());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== ① 公告:发布方式值域 / 存草稿独立接口 ==================== */
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noticePublishTypeOnlyAllowsDirectOrApprove() {
|
||||||
|
ToolParam publishType = param(registry.get("notice.publish"), "publishType");
|
||||||
|
assertNotNull(publishType, "公告发布要有「发布方式」");
|
||||||
|
assertEquals(Arrays.asList("direct", "approve"), publishType.getEnumValues(),
|
||||||
|
"模块页 publishType 只有 direct / approve —— 草稿走的是 SaveDraft 接口,不是 Create 的一个取值");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noticeSaveDraftIsItsOwnEndpointWithoutPublishType() {
|
||||||
|
ToolDef draft = registry.get("notice.saveDraft");
|
||||||
|
assertNotNull(draft, "公告要有独立的「存为草稿」工具");
|
||||||
|
assertEquals("/api/notice/SaveDraft", draft.getPath(), "存草稿走模块自己的 SaveDraft 接口");
|
||||||
|
assertFalse(draft.isReadOnly(), "存草稿是写操作");
|
||||||
|
assertTrue(draft.isFormAll(), "存草稿表单同样渲染全部字段");
|
||||||
|
assertNull(param(draft, "publishType"), "SaveDraft 不传 publishType(模块页这一步也不传)");
|
||||||
|
// 其余字段与发布工具保持一致
|
||||||
|
for (String name : Arrays.asList("title", "content", "deptId", "deptName", "pushScopes",
|
||||||
|
"topmost", "smsFlag", "receiptFlag", "fileIds", "timingSendTime", "flow")) {
|
||||||
|
assertNotNull(param(draft, name), "存草稿表单缺字段:" + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noticePublishCarriesApprovalFlowAndTeacherTree() {
|
||||||
|
ToolDef def = registry.get("notice.publish");
|
||||||
|
ToolParam flow = param(def, "flow");
|
||||||
|
assertNotNull(flow, "公告要有审批流字段(模块页的 ApprovalFlowEditor)");
|
||||||
|
assertEquals("flow", flow.resolveWidget(), "审批流要用专用控件");
|
||||||
|
assertEquals("notice.teaTree", flow.getOptionsFrom(), "审批人候选来自教师树");
|
||||||
|
assertEquals("id", flow.getOptionValueKey());
|
||||||
|
assertEquals("name", flow.getOptionLabelKey());
|
||||||
|
|
||||||
|
ToolDef teaTree = registry.get("notice.teaTree");
|
||||||
|
assertNotNull(teaTree, "要有教师树字典工具");
|
||||||
|
assertEquals("/api/base/getTeaTree", teaTree.getPath());
|
||||||
|
assertEquals(Boolean.TRUE, teaTree.getFixed().get("toUser"), "审批人取的是「用户态」教师叶子");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== ② 三个模块:部门名称镜像 ==================== */
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deptNameIsMirroredFromDeptIdEverywhere() {
|
||||||
|
for (String tool : Arrays.asList("notice.publish", "dorm.publish", "activity.publish")) {
|
||||||
|
ToolParam deptName = param(registry.get(tool), "deptName");
|
||||||
|
assertNotNull(deptName, tool + " 要带上部门名称字段");
|
||||||
|
assertEquals("deptId", deptName.getMirrorLabelOf(),
|
||||||
|
tool + " 的 deptName 应由所选部门镜像得到(模块页就是这么做的)");
|
||||||
|
// 镜像字段不能让模型去编 —— 它必须从模型的工具 schema 里排除
|
||||||
|
String schema = registry.get(tool).toOpenAiTool().toString();
|
||||||
|
assertFalse(schema.contains("\"deptName\""), tool + " 的 deptName 不该出现在给模型的 schema 里");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== ③ 活动:分类存名称 ==================== */
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activityCategoryStoresNameNotId() {
|
||||||
|
ToolParam category = param(registry.get("activity.publish"), "category");
|
||||||
|
assertNotNull(category);
|
||||||
|
assertEquals("v", category.getOptionValueKey(),
|
||||||
|
"模块页 NewActivity.vue 写的是 :value=\"String(c.v)\" —— 存的是分类名称,不是字典 k");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== ④ 请假:fileIds 是数组 + 连续请假展开 ==================== */
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void leaveFileIdsStaysArrayAndSupportsMultiple() {
|
||||||
|
for (String tool : Arrays.asList("leave.submit", "leave.register")) {
|
||||||
|
ToolParam fileIds = param(registry.get(tool), "fileIds");
|
||||||
|
assertNotNull(fileIds, tool + " 要有佐证材料");
|
||||||
|
assertEquals("file", fileIds.resolveWidget(), tool + " 的佐证材料应是上传控件");
|
||||||
|
assertNull(fileIds.getJoinSeparator(),
|
||||||
|
tool + " 的 fileIds 在模块里是 number[](JSON 数组),不能拼成逗号串");
|
||||||
|
assertTrue(fileIds.getMultiple(), tool + " 的佐证材料可多份");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void leaveContinuousLeaveDeclaresDailyExpand() {
|
||||||
|
for (String tool : Arrays.asList("leave.submit", "leave.register")) {
|
||||||
|
ToolParam flag = param(registry.get(tool), "continuousLeave");
|
||||||
|
assertNotNull(flag, tool + " 要有连续请假开关");
|
||||||
|
Map<String, String> spec = flag.getDailyExpandSpec();
|
||||||
|
assertNotNull(spec, tool + " 的连续请假要声明逐天展开,否则生成不出 continuousLeaveTime");
|
||||||
|
assertEquals("continuousLeaveNum", spec.get("count"));
|
||||||
|
assertEquals("startDate", spec.get("start"));
|
||||||
|
assertEquals("endDate", spec.get("end"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void continuousLeaveExpandsIntoPerDayRanges() {
|
||||||
|
ToolDef def = registry.get("leave.submit");
|
||||||
|
Map<String, Object> req = new LinkedHashMap<>();
|
||||||
|
req.put("leaveType", 1);
|
||||||
|
req.put("remarks", "发烧就医");
|
||||||
|
req.put("startDate", "2026-10-13 08:00:00");
|
||||||
|
req.put("endDate", "2026-10-13 12:00:00");
|
||||||
|
req.put("continuousLeave", 1);
|
||||||
|
req.put("continuousLeaveNum", 3);
|
||||||
|
|
||||||
|
BrowserToolRequest.BrowserCall call = executor.prepareBrowserCall(def, req);
|
||||||
|
String body = call.getBody();
|
||||||
|
assertTrue(body.contains("continuousLeaveTime"), "要派生出 continuousLeaveTime:" + body);
|
||||||
|
assertTrue(body.contains("2026-10-13 08:00:00"), body);
|
||||||
|
assertTrue(body.contains("2026-10-14 08:00:00"), "第 2 天要顺延一天:" + body);
|
||||||
|
assertTrue(body.contains("2026-10-15 12:00:00"), "第 3 天要顺延两天:" + body);
|
||||||
|
// 非连续请假时不产生该字段
|
||||||
|
req.put("continuousLeave", 0);
|
||||||
|
assertFalse(executor.prepareBrowserCall(def, req).getBody().contains("continuousLeaveTime"),
|
||||||
|
"没勾连续请假就不该派生出连续时间段");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== ⑤ 作业:教师端发布(两步 + 对象数组附件) ==================== */
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void homeworkPublishToolContract() {
|
||||||
|
ToolDef def = registry.get("homework.publish");
|
||||||
|
assertNotNull(def, "作业模块要有教师端「发布作业」工具");
|
||||||
|
assertFalse(def.isReadOnly(), "发布作业是写操作");
|
||||||
|
assertTrue(def.isFormAll(), "发布表单渲染全部字段,与「新建作业」页一致");
|
||||||
|
assertEquals("/api/teacher/homeworks", def.getPath());
|
||||||
|
assertEquals("/api/teacher/homeworks/{data}/publish", def.getFollowUpPath(),
|
||||||
|
"模块没有一步发布的接口:要先 Create 落草稿,再用返回的 id 发布");
|
||||||
|
|
||||||
|
for (String name : Arrays.asList("title", "subject", "classIds", "deadlineAt",
|
||||||
|
"submitType", "allowLate", "remind1d", "remind1h", "attachments", "description")) {
|
||||||
|
assertNotNull(param(def, name), "发布作业缺字段:" + name);
|
||||||
|
}
|
||||||
|
assertTrue(param(def, "title").isRequired());
|
||||||
|
assertTrue(param(def, "classIds").isRequired());
|
||||||
|
assertEquals("array", param(def, "classIds").getType(), "接收班级是数组(模块是 classIds: string[])");
|
||||||
|
assertEquals("homework.classTree", param(def, "classIds").getOptionsFrom());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void homeworkScheduleToolDeclaresSecondStep() {
|
||||||
|
ToolDef def = registry.get("homework.schedule");
|
||||||
|
assertNotNull(def, "要有定时发布作业");
|
||||||
|
assertEquals("/api/teacher/homeworks", def.getPath());
|
||||||
|
assertEquals("/api/teacher/homeworks/{data}/schedule", def.getFollowUpPath());
|
||||||
|
assertTrue(def.getFollowUpBody().contains("scheduledAt"), "第二步要把定时时间带过去");
|
||||||
|
assertTrue(param(def, "scheduledAt").isRequired(), "定时发布必须给出定时时间");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void homeworkAttachmentsAreObjectsUploadedToFileContext() {
|
||||||
|
ToolParam att = param(registry.get("homework.publish"), "attachments");
|
||||||
|
assertNotNull(att);
|
||||||
|
assertEquals("file", att.resolveWidget());
|
||||||
|
assertTrue(att.isFileObject(), "作业的 attachments 是 [{fileId,fileUrl,fileType,fileName,sort}] 对象数组");
|
||||||
|
assertEquals("/coursehomework/api/file/Upload", att.getUploadPathOverride(),
|
||||||
|
"作业模块的附件上传走 file 域,与其余模块的 base/FileUpload 不同");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void homeworkPublishFollowUpKeepsTheDraftId() {
|
||||||
|
ToolDef def = registry.get("homework.publish");
|
||||||
|
Map<String, Object> req = new LinkedHashMap<>();
|
||||||
|
req.put("title", "第三章课后练习");
|
||||||
|
req.put("subject", "数学");
|
||||||
|
req.put("classIds", Arrays.asList("3", "4"));
|
||||||
|
BrowserToolRequest.BrowserCall call = executor.prepareBrowserCall(def, req);
|
||||||
|
assertNotNull(call.getFollowUp(), "发布要挂上第二步");
|
||||||
|
assertEquals("/coursehomework/api/teacher/homeworks/{data}/publish",
|
||||||
|
call.getFollowUp().getPathAndQuery(), "第二步地址要带模块前缀,并留 {data} 给首步的 id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== ⑥ 附件控件渲染 ==================== */
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scalarValueIsWrappedForArrayParams() {
|
||||||
|
ToolDef def = registry.get("homework.publish");
|
||||||
|
Map<String, Object> req = new LinkedHashMap<>();
|
||||||
|
req.put("title", "标题");
|
||||||
|
req.put("subject", "语文");
|
||||||
|
req.put("classIds", "3");
|
||||||
|
req.put("submitType", "image");
|
||||||
|
String body = executor.prepareBrowserCall(def, req).getBody();
|
||||||
|
assertTrue(body.contains("\"classIds\":[\"3\"]"), "标量要包成数组:" + body);
|
||||||
|
assertTrue(body.contains("\"submitType\":[\"image\"]"), "标量要包成数组:" + body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 辅助 ==================== */
|
||||||
|
|
||||||
|
private ToolParam param(ToolDef def, String name) {
|
||||||
|
if (def == null || def.getParams() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (ToolParam p : def.getParams()) {
|
||||||
|
if (name.equals(p.getName())) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供将来表单字段断言使用(保持与 FormField 的耦合可见) */
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
private FormField field(ToolDef def, String name) {
|
||||||
|
ToolParam p = param(def, name);
|
||||||
|
return p == null ? null : p.toFormField();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
private List<String> paramNames(ToolDef def) {
|
||||||
|
List<String> names = new java.util.ArrayList<>();
|
||||||
|
for (ToolParam p : def.getParams()) {
|
||||||
|
names.add(p.getName());
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user