87 Commits

Author SHA1 Message Date
e092ede8d8 Merge pull request 'fix: 修复 CI 时区测试失败 + 依赖安全升级 + 新增依赖检查 workflow' (#68) from fix/ci-deps-security into main
All checks were successful
Dependency Check / check (push) Successful in 48s
2026-08-10 02:13:41 +00:00
c6e1d94510 fix: 修复 CI 时区测试失败 + 依赖安全升级 + 新增依赖检查 workflow
All checks were successful
CI / check (pull_request) Successful in 4m11s
Dependency Check / check (pull_request) Successful in 2m30s
- fix(server): getCourseClock 固定 UTC+8(Asia/Shanghai),与 attendance-settlement 保持一致,
  修复 CI(UTC 容器)下 attendance.lesson-session.spec.ts 失败
- chore(deps): @ant-design/x 2.9.0 / @ant-design/x-markdown 2.9.0 / mermaid 11.16.1;
  直接依赖 dompurify 3.4.13(修复生产依赖 audit 漏洞,mermaid/x-markdown 复用同一份)
- ci: 新增 dependency-check workflow(生产依赖 moderate+ / 全局 critical 失败,outdated 报告)
2026-08-10 10:05:05 +08:00
8c437cd082 Merge pull request 'fix: 迁移读取根 .env + 部署脚本本地直跑 + 健康检查读 PORT' (#66) from fix/datasource-root-env into main 2026-08-10 01:45:19 +00:00
4c95de2e56 Merge pull request 'fix(admin): 修复首次进入系统后点击任意导航被拉回 dashboard' (#67) from fix/route-redirect-keepalive into main 2026-08-10 01:44:02 +00:00
ce9bde35eb fix(admin): 修复首次进入系统后点击任意导航被拉回 dashboard
Some checks failed
CI / check (pull_request) Failing after 2m14s
根因:RouteKeeper 保活缓存了首页 index 节点,DefaultRoute 中的声明式
<Navigate> 会随缓存节点在后续路由变化时反复触发 replace,导致所有导航
被强制拉回落地页(dashboard),刷新后因不再经过 / 而恢复正常。

修复:DefaultRoute 改为 effect 导航,仅在确实处于 / 且已算出落地页时
跳转一次;navigate 通过 ref 持有避免 effect 空转。新增集成测试覆盖
「首次进入 / → 跳转一次 → 后续导航不被劫持 → 回到 / 仍能重定向」。
2026-08-10 09:42:46 +08:00
9d74bd2d51 ci(deploy): deploy.sh 支持本地直跑模式 + workflow 健康检查读取 .env PORT
Some checks failed
CI / check (pull_request) Failing after 5m3s
- deploy.sh 默认本地直跑(./deploy.sh 或 ./deploy.sh local),不再需要 SSH;传 ssh_host 才走远程版
- workflow 健康检查从 .env 读取 PORT(默认 3000),适配 PORT=3002 等自定义端口
2026-08-09 22:04:21 +08:00
f4072ce93a fix(db): 迁移 CLI 默认读取仓库根目录 .env
- datasource.ts 优先加载根目录 .env,再回退 apps/server/.env
- 生产只需维护根目录一份 .env,迁移脚本不再报 root 空密码
2026-08-09 22:02:00 +08:00
29d987ab74 Merge pull request 'ci: 部署工作流改为本地 runner 直跑版(免 SSH/Secrets)' (#65) from ci/local-runner-deploy into main 2026-08-09 13:42:36 +00:00
f0321b5bdd ci(deploy): 部署工作流改为本地 runner 直跑版(免 SSH/Secrets)
Some checks failed
CI / check (pull_request) Failing after 3m9s
- 移除 SSH/rsync 步骤与 SSH 相关 Secrets 依赖,runs-on: self-hosted 本机直跑
- 步骤:git pull → npm ci → build → migration:run → pm2 startOrReload --update-env → 健康检查
- 新增可选输入 seed_roles:首次部署/新增权限码时强制播种 RBAC,跑完自动去掉
- .env 无需配置到 Gitea,仅放服务器部署目录
- 注释说明 runner 在 docker 容器时的挂载与宿主 pm2 要求
2026-08-09 21:41:48 +08:00
fb9927e712 Merge pull request 'fix: OCR 审查驱动的全库修复(安全/正确性/部署/前端)' (#64) from feat/ocr-review-fixes into main 2026-08-09 13:32:19 +00:00
db11a57e98 fix(security): classrooms 上传限制/路由顺序/LIKE 转义补充
Some checks failed
CI / check (pull_request) Failing after 3m26s
由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- classrooms.controller:report 路由移到 :id 之前、上传 10MB + 判空
- classrooms.service:搜索关键词 LIKE 通配符转义

Reviewed-by: OCR (open-codereview.ai)
2026-08-09 21:30:42 +08:00
1289aea7ce docs(ocr): 全库 OCR 审查与修复记录
由 OCR(open-codereview.ai,deepseek-v4-flash)审查产出:
- 审查汇总.md / high-severity-comments.md / all-comments.jsonl / 修复记录.md 等

Reviewed-by: OCR (open-codereview.ai)
2026-08-09 21:29:54 +08:00
a32c0a0731 fix(admin): 前端功能与安全修复(角色勾选/月视图新建/附件预览/考勤 late 等)
由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- Roles 多组勾选、Schedules 月视图新建、useDirtyGuard 稳定引用
- JinshujuMatchModal success:false、Attendance store 订阅、EditableCell Enter、DynamicChart 空数据、渲染期 ref
- AttachmentsTab 附件预览不再 window.open(防存储型 XSS)+ 请求乱序防护
- 考勤 late 计入出勤率与主状态

Reviewed-by: OCR (open-codereview.ai)
2026-08-09 21:29:54 +08:00
f50301148d fix(correctness): 并发/事务/实体/时区/状态一致性修复
由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- wallets 原子扣款防 double-spend;refund 条件更新幂等;findTransactions 分页
- financial/imports/occupancies/attendance 事务与 advisory lock;重复生成/提交幂等
- 矛盾校验器、日期区间、实体双映射/DECIMAL/nullable、时区统一(china-time)
- rbac-seed 防重激活、exam 权限恢复、状态一致性、路由顺序、N+1/IN 分块等性能项

Reviewed-by: OCR (open-codereview.ai)
2026-08-09 21:29:54 +08:00
99ea931409 fix(security): 认证/越权/注入/上传/凭据全链路加固
由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- JWT 生产必填、密码 8-72 字节、防枚举;全局 ValidationPipe
- classes/dashboard/schedules/students/attendance/archive/exams 越权与 IDOR 修复
- LIKE 通配符转义(14 处);上传 10MB 上限 + MIME 白名单 + 附件 XSS
- 集成配置 appSecret AES 加密 + 回填脚本;审计 best-effort;IP 来源防伪造

Reviewed-by: OCR (open-codereview.ai)
2026-08-09 21:29:22 +08:00
3bcad138a1 fix(ops): 加固部署/代理/迁移脚本,生产环境安全默认
由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- serve-proxy:API_TARGET 生效、SPA 404 语义、流式静态文件、hop-by-hop/超时/断连/穿越防护
- migrate.sh:密码不进 argv/不泄漏 pm2、原子锁防重入、DML-only 事务说明、就绪诊断
- deploy.sh/CI:保护 .env、总是 npm ci(迁移依赖 devDeps)、健康检查、并发锁
- ecosystem/oxlint/.env.example:优雅停机、React 版本对齐、TRUST_PROXY 说明

Reviewed-by: OCR (open-codereview.ai)
2026-08-09 21:29:22 +08:00
9565a0f23c feat(admin): 深化 TanStack Query 数据层
- 新增 queryKeys.ts 统一 QueryKey 工厂(覆盖全部模块,invalidate/refetch/prefetch 同源)
- 新增 queryClient.ts 全局配置:retry 1、staleTime 30s、gcTime 5min、refetchOnWindowFocus false
- 新增 useApiQuery:zod schema 校验 + 类型收敛 + select 转换,消除页面重复 validateResponse 样板
- useApiMutation 支持 onMutate/onSettled/context(乐观更新)
- 示范迁移:Organizations(useApiQuery + 乐观更新归档/恢复)、Students(useApiQuery + queryKeys + 打开抽屉前 prefetch 档案聚合)
- StudentProfileContent queryKey 统一为 queryKeys.archive.detail,prefetch 可命中缓存
2026-08-08 18:37:55 +08:00
4dd7b5a0b1 fix(server): 时间戳→业务日期/时间全部统一为中国时区(UTC+8)
此前仅'今天'默认值统一为中国日期,Date→字符串的转换仍是 UTC:
- sync 考勤同步窗口 lastSyncAt 时间戳按 UTC 取日期,凌晨会偏一天
- 考勤导出 punchTime/createdAt 显示 UTC 时间
- normalizeDateOnly 把 ISO datetime 按 UTC 归一化,业务日期少一天
- getCourseClock/getChinaDateParts 用 dayjs.utc(date).add(8h) 隐式转换

统一为 dayjs(date).utcOffset(8),纯日期字符串运算(shiftDate/addDays/
daysInMonth/nextMonth)保留 dayjs.utc;date-normalization 测试断言
同步更新为北京时间语义。
2026-08-08 18:01:35 +08:00
7905f1ee23 fix(server): 业务日期'今天'统一为中国日期,避免凌晨 UTC 偏一天
joinDate/leaveDate/checkInDate/sync 起始日等业务日期默认值原来用
dayjs().utc()(UTC 今天),在国内 00:00-07:59 会取到昨天。
统一改为 dayjs().utcOffset(8)(固定 UTC+8 中国日期),与考勤周边界
修复保持一致;pending-tasks/controller-base 的本地 dayjs() 也显式化。
2026-08-08 17:58:16 +08:00
782b43ef0a fix(attendance): 默认周范围改为中国日期周一起点
generateFromSchedules / getCalendar 原来用本地周一零点转 UTC 日期,
在上海时区会得到前一天的日期,导致默认范围变成周日~周日(8 天)。
改为 dayjs().utcOffset(8) 按中国日期计算,与 getTodayDateOnly 语义一致。

新增测试锁定:默认周 = 本周一~本周日,周日晚上仍归本周,显式传参原样透传。
2026-08-08 17:56:00 +08:00
8936453e84 refactor(server): 日期/月份格式化统一改用 dayjs
- 替换散落的 toISOString().slice(0,10) / split('T')[0] / replace('T',' ')(UTC 语义用 dayjs(...).utc() 保持完全一致)
- Asia/Shanghai 固定时区日期(Intl.DateTimeFormat en-CA)改用 dayjs().utcOffset(8)
- 月份边界 first/last、daysInMonth、nextMonth、shiftDate/addDays 等改 dayjs 简洁实现
- 涉及 attendance/classes/classrooms/room/dashboard/bills/expenses/occupancies/sync/ai-chat/rental 等 28 个文件
2026-08-08 17:52:32 +08:00
92ba2e779f refactor(server): 用已装库替换手写工具
- uuid v7 替换手写 RFC9562 实现(uuid@11.1.1 转为直接依赖)
- node:timers/promises setTimeout 替换两处手写 sleep(钉钉限流、AI 上游重试退避)
- dayjs 替换 4 处零散 Date 格式化(imports/expenses/occupancies 导入模板、档案报告日期)
2026-08-08 17:46:31 +08:00
8ddc3ea690 refactor(admin): LiteMermaid 用 usehooks-ts useIsMounted 替代手写 cancelled 标志 2026-08-08 17:27:14 +08:00
d324b2fb0a refactor(server): 日期工具统一迁入 dayjs(utc 插件),移除手写 Intl/padStart 重复实现 2026-08-08 17:27:14 +08:00
74d5b90faa refactor(admin): 用 usehooks-ts useEventListener/useTimeout 与 dayjs 替代手写监听、撤销计时与相对时间 2026-08-08 17:21:33 +08:00
dab28f85e8 refactor(admin): 用已安装的 file-saver/fast-deep-equal 替代手写下载与 JSON 比较 2026-08-08 17:11:10 +08:00
01cceea237 refactor(admin): 钉钉集成配置缓存迁入 zustand 页面级 store,移除模块级单例 2026-08-08 17:07:13 +08:00
9cc9ed09df refactor(admin): EditableCell 跨单元格协调状态迁入 zustand,替代模块级单例并补单测 2026-08-08 17:04:42 +08:00
260a7517d2 feat(admin): RouteDock 页签增强——pathname 唯一 key、20 上限 LRU、关闭其他/左/右/全部、登出清理、持久化校验收紧 2026-08-08 17:00:49 +08:00
d27f10eb84 chore: admin 构建稳定 vendor 分包,消除 chunk 告警;更新 .env.example 品牌文案与端口 2026-08-08 16:46:16 +08:00
00cdb322d2 fix: 修复 QA 巡检发现的 console 告警——useForm 未连接、RangePicker 空值禁用、dashboard 占用率类型 2026-08-08 16:36:58 +08:00
6c11fd7e16 refactor(server): 待办统计日期懒计算,aislop 豁免注释与文档纠错 2026-08-08 16:03:05 +08:00
e50f660811 chore: 移除 aislop 质量门禁工作流 2026-08-08 15:29:13 +08:00
bb8228bd0f docs: 同步 A2UI 契约与 AI 工作流文档至统一 artifact/向导流程 2026-08-08 15:06:03 +08:00
f03f22c605 docs: 更新项目结构与部署说明(apps/* 布局、monorepo 命令) 2026-08-08 15:06:03 +08:00
b73ac5e3e4 ci: 新增 aislop 质量门禁工作流 2026-08-08 15:06:03 +08:00
f160256865 chore(server): 移除 e2e 测试配置与 supertest 依赖 2026-08-08 15:05:52 +08:00
f96c1c26c3 fix(server): 考勤自动结算支持空班级直接完成,补充自动匹配测试 2026-08-08 15:05:42 +08:00
a0829f17ce feat(admin): 管理端 UI/UX 深度优化——错误/加载/空状态、表单快捷键、移动端适配与无障碍 2026-08-08 15:05:33 +08:00
57639a3869 refactor(admin): AI 对话收敛为统一 ui.artifact 协议,移除 legacy 事件处理 2026-08-08 15:05:20 +08:00
b37ae9471e refactor(server): A2UI 收敛——移除 legacy SSE 双发与 render_review 工具,提取公共校验 2026-08-08 15:05:10 +08:00
1e3d6ee20f refactor(server): 收敛类型边界工具,aislop AI Slop 10 → 1
- 新增 common/buffer.ts bufferToArrayBuffer:9 处
  'as unknown as ArrayBuffer' 收敛为精确切片(含 byteOffset),
  消除潜在 Buffer 池偏移隐患,类型断言集中到单一实现
- 新增 common/stringify.ts:4 处重复的 stringify 助手收敛为共享实现
- aislop: AI Slop 10→1(仅剩 1 处有理由的 stringify 薄包装,
  eslint no-base-to-string 绕过所需);Code Quality 剩余
  4 重复块(声明式 SQL 配置)+ 2 文件过大(既有规模)均保留
- 测试 142 套件/1065 用例通过
2026-08-08 09:48:03 +08:00
4b60e0c018 fix: code-review 审查问题修复 + A2UI 测试补齐
Standards 轴:
- 移除 uiArtifacts.ts 的 payloadOf 死代码残留
- AttendanceDevices 残留 any 类型化(补 ClassroomOption.status 字段)
- 批量考勤纠错区分业务失败(已结算/无权限)与系统错误,
  前端提示精确到两类数量
- Dashboard queryFn 六段重复校验块收敛为 safeValidate 助手

Spec 轴:
- 补齐阶段 3.4 A2UI 测试:图表空数据占位、ArtifactErrorBoundary
  降级隔离、useSubmissionState/useXCardSurface 单测(7 用例)
- 阶段 2.2 补两处引导:教师工作台区分「今日无课」与「未分配班级」、
  班级花名册空态带「添加学员」动作
- 契约文档修正 DynamicReview 状态管理描述(多提交点如实说明)

aislop 剩余 16 警告均为必要豁免(类型边界/声明式 SQL 配置/既有文件规模)
2026-08-08 09:43:48 +08:00
bcd2d1a559 refactor(admin): A2UI 双轨收敛,统一 artifact 协议
- mergeArtifactIntoMessage 不再派发 legacy 列表,只维护 uiArtifacts
- 渲染层从 uiArtifacts 派生 forms/reviews/charts,为空时回退 legacy
  (历史消息兼容,老 metadata 仅 a2uiForm/a2uiReview/a2uiChart)
- sseReducer 删除 ui.form/ui.review/ui.chart 旧事件分支,只消费
  ui.artifact(后端过渡期双发,旧事件将被忽略)
- types.ts legacy 字段标注 deprecated;契约文档同步现状
- 测试更新:旧事件忽略 + uiArtifacts 合并/恢复断言

aislop scan: 5 引擎 0 issues
2026-08-08 09:33:53 +08:00
a644a8de42 refactor(server): 清理全量 any 类型安全警告 (692 → 0)
- 全模块类型化:controller 的 req: any → AuthenticatedRequest/RequestUser,
  聚合查询 getRawMany 泛型标注、导入行/响应体定义具体 interface、
  catch (e: any) → unknown + 收窄、no-base-to-string 用 String() 显式转换
- 第三方无类型库边界(pdfkit/exceljs)文件级或单行 disable 并注明理由
- 顺带修复:get-business-context.tool 两个 require-await error、
  bills.controller 参数顺序隐患、main.ts compression 调用
- 运行时逻辑零改动;测试 142 套件 / 1065 用例全部通过
2026-08-08 09:28:23 +08:00
8b5fa98028 feat(admin): 管理端考勤明细批量纠错
- 原始考勤明细表支持行选择(跨页保留),选中后出现
  「标记正常/标记请假/标记缺勤」批量操作条
- 批量提交前确认,成功后按 updated/failed 数量反馈,
  失败记录(如已结算)提示原因;筛选条件变化自动清空选择
- 复用 PUT /attendance-records/batch-status 接口

aislop scan: 5 引擎 0 issues
2026-08-07 18:03:22 +08:00
1b4ba893fd feat: 空状态引导全面应用与考勤批量标记
admin:
- 17 个列表页空态统一为 QueryEmpty 引导:学生/账单/入住/费用/押金/
  教室/房间/班级/考试/排课/机构/考勤机/钱包/租赁/通知/角色等,
  有创建权限的页面附带主操作按钮,无权限时纯展示
- 教师端课堂点名新增「全部已打卡/全部未打卡」批量按钮:
  仅作用于状态不一致的记录,确认后调用批量接口,展示成功/失败数量

server:
- 新增 PUT /attendance-records/batch-status 批量改状态接口
  (ids ≤200,逐条权限校验与会话锁,部分失败返回 failedIds,
  审计日志记录批量结果;路由声明在 :id 之前避免被捕获)

aislop scan: 5 引擎 0 issues
2026-08-07 18:00:29 +08:00
c10476d203 feat(admin): 弹窗未保存内容保护与空状态引导扩展
- 新增 useDirtyGuard hook:关闭弹窗时若表单被修改,先确认再关闭
- 接入 17 个弹窗:账号/密码/档案、角色、教室、宿舍/床位/柜子、
  考勤机、机构、班级、教师、考试、排课、入住/退宿/换房、费用
  (子组件弹窗用 useEffect 在回填后快照,时序正确)
- 学生列表空状态带「添加学生」引导动作
- 排课创建成功提示「同步钉钉」,考试创建成功提示「去详情录成绩」

aislop scan: 5 引擎 0 issues
2026-08-07 17:56:13 +08:00
bcbf3c8b2e refactor(admin): 按 aislop 质量门禁修复两个代码质量警告
- RoleTour: buildSteps 拆分为各角色的声明式步骤常量,函数从 91 行降至组装逻辑
- ImportWizardModal: 抽取 uploadRun 共享 helper,消除 handleUpload/handleReupload
  之间重复的上传进度与 loading 状态处理

aislop scan --changes: 99/100 → 100/100 (5 引擎 0 issues)
2026-08-07 17:43:00 +08:00
ff3b6cdfde style(admin): 清理 AI slop 痕迹
- 移除教师端 hero 的英文眉标(TEACHING DAY),日期并入正文描述
- 删除不再使用的 attendance-eyebrow 样式
- Dashboard 的 ══ 装饰注释改为普通注释

kill-ai-slop 扫描: 18 处命中 → 7 处(剩余均为合理用法:
链接 hover 下划线、头像圆形角)
2026-08-07 17:41:01 +08:00
8dc72d6e1b fix(admin): 编辑消息/切换会话保护、行内编辑撤销与上传进度反馈
- 编辑旧消息会删除其后全部消息时,先弹确认告知影响范围
- 有待发送附件时切换会话,先确认再丢弃(避免静默删除已上传文件)
- EditableCell 保存成功后提供 6 秒内"撤销"入口(把旧值重新保存),
  重新编辑时自动清除
- 导入向导/重新上传显示上传进度条与百分比,不再"假死"
- AI 附件上传透传进度给附件列表
- 租赁合同上传显示按钮 loading 与上传百分比
2026-08-07 17:38:21 +08:00
00e2bc5acf fix(admin): 通知分页、登录过期提示、附件打开反馈与导出统一 loading
- 通知中心改为游标分页 + 加载更多,历史通知不再被 50 条上限截断;
  加载失败显示错误态与重试
- AI 流式请求 401 被登出时,登录页提示"登录已过期",不再无声踢出
- AI 附件/引用来源打开失败时给出明确错误提示,不再"点了没反应"
- 新增 useDownload hook:统一导出/下载的防重复、loading 与成功/失败反馈;
  接入学生/账单/房间/入住/费用五个页面的模板下载与导出按钮
- 学生页移除不检查响应状态的私有下载实现,统一走 downloadBlob
2026-08-07 17:33:58 +08:00
67435e46ca feat(admin): 用户体验体系化提升与高危缺陷修复
UX 缺陷修复:
- 校验失败不再卡死弹窗按钮(Users/Roles/Bills)
- 押金收取/批量收取/添加分期防重复提交;切换房型重置勾选
- AI 表单/批量确认不再出现"假成功"
- Dashboard 各数据模块独立加载,单接口失败不再整页清零
- 房间可视化加载失败显示错误态而非永久转圈
- 学生编辑表单回填前重置,避免字段残留污染
- 覆盖式导入增加二次确认;恢复默认考勤时段确认并同步表单
- 金数据匹配关闭前确认,同步中禁止误关

体验提升:
- 新增统一 QueryErrorState/QueryEmpty,20+ 页面加载失败显示错误态与重试
- 全局 ErrorBoundary + RouteKeeper 逐页兜底
- 新增 usePageVisible/useVisibleRefetch,保活页面切回自动刷新数据
- 新增首次登录角色引导 RoleTour 与业务闭环 NextStepHint 引导卡
- 重构 A2UI:useSubmissionState/useXCardSurface 收敛状态与命令生命周期,
  ArtifactErrorBoundary 渲染降级,图表空数据占位
- AI 助手欢迎语与建议话术按角色定制,会话列表空态引导
- 更新 a2ui-contract.md 契约文档说明实现现状
2026-08-07 17:23:23 +08:00
db0d972633 Merge pull request 'fix: 上传与路由校验加固、甘特图时间线修复' (#63) from fix/quick-wins-and-gantt into main 2026-08-07 08:38:45 +00:00
8d4ebcf9c0 fix(admin,server): 修复 Dashboard 甘特图时间线与数据校验
Some checks failed
CI / check (pull_request) Failing after 1m54s
2026-08-07 16:38:16 +08:00
5f9566e26d fix(server): 修正档案成绩趋势图 y 轴坐标字符串拼接 2026-08-07 16:38:09 +08:00
5b5ffb5b9e fix(server): 路由 id 使用 ParseIntPipe,限制分页与批量数组上限 2026-08-07 16:38:03 +08:00
899d2dde5b fix(admin,server): 上传改用 file.buffer 并移除手写 multipart 头 2026-08-07 16:37:55 +08:00
54b002455f chore(admin): 声明 react-syntax-highlighter 运行时依赖 2026-08-07 16:37:47 +08:00
b032890b4f style(admin): 清理考勤页与排课表的冗余装饰与标签 2026-08-07 09:50:52 +08:00
f560b046a4 Merge pull request 'fix(admin): 账号管理用户名过长溢出' (#62) from fix/users-username-overflow into main 2026-08-06 09:11:44 +00:00
549a3ff14b fix(admin): 账号管理用户名过长溢出
Some checks failed
CI / check (pull_request) Failing after 1m48s
2026-08-06 17:10:06 +08:00
7fdfdbc717 Merge pull request 'fix(admin): 钱包列表隐藏学号显示' (#61) from fix/wallet-hide-student-no into main 2026-08-06 09:07:50 +00:00
edb798b752 fix(admin): 钱包列表隐藏学号显示
Some checks failed
CI / check (pull_request) Failing after 3m30s
2026-08-06 17:06:24 +08:00
a436d9aa38 Merge branch 'feat/ai-a2ui-context' into main 2026-08-06 08:27:24 +00:00
bbeea440f9 fix(admin): dashboard 待办卡片点击导航去重与防抖
Some checks failed
CI / check (pull_request) Failing after 3m19s
- 同一目标路径不再重复 navigate,避免历史记录堆叠
- 400ms 冷却期内忽略重复/幽灵点击,防止来回跳转
- 新增 shouldNavigateTodoCard 守卫单元测试
2026-08-06 16:11:13 +08:00
9048816abc feat(ai): 移除 Excel 导入预检链路
- 删除 preflight_import 工具、预检卡、resolve 接口与 ui.import_preflight 事件
- 删除 imports.preflight 解析器与 PreflightReport 类型
- SYSTEM_PROMPT 改为上传 Excel 后直接确认列映射/策略并调用 start_import_wizard
- 前端同步移除预检类型/组件/测试,保留导入向导
2026-08-06 15:50:38 +08:00
14db28afc6 fix(ai): 修复 Nest DI 启动失败
- A2uiSubmissionsService/DataSource/BusinessContextService 等构造参数
  由 import type 改为值导入,确保 Nest 能解析依赖
- 验证:dev 模式后端正常启动,相关 243 个测试通过
2026-08-06 15:39:33 +08:00
c72ff2cb8a fix(ai): 清理 aislop 扫描告警
- 拆分 SSE reducer 到 sseReducer.ts,provider.ts 降至 220 行
- artifact 合并工具抽到 uiArtifacts.ts,去除双重类型断言
- 业务上下文注册表改用 field/relation/entity 构建器收敛重复
- aislop 分数 71 → 81(Healthy),AI Slop 0 告警
2026-08-06 15:35:12 +08:00
24e0ecbdaf feat(ai): 业务上下文感知与 A2UI 链路统一
- 新增代码内业务上下文元数据层(实体字典 + 三大闭环工作流)
- 新增 get_business_context / get_entity_schema / get_pending_tasks 运行时工具
- SYSTEM_PROMPT 与技能目录改为先查业务流程/待办再执行
- A2UI 增加 ai_a2ui_submissions 幂等表、表单过期、ui.artifact 事件
- 提交回灌携带 submissionId / fieldErrors / 下一步建议
- 前端 uiArtifacts 归一化与过期表单禁用
2026-08-06 15:23:23 +08:00
d9c541dacc fix(admin): 跨标签页权限写入不再整页刷新
storage 事件里 permissions 变更改为原地写入权限 store,
仅 gongxue-auth token 实际变化(登录/退出/切换账号)才 reload,
避免多标签页相互触发刷新风暴
2026-08-06 14:53:22 +08:00
ae88372ef8 fix(imports): 修复 AI 导入向导多工作表与表头误判
- AI resolve 生成向导时按阶段携带全部 sheetNames,不再只取第一张表
- ImportStageRequest 支持 sheets 数组并兼容旧 sheet;手动重传同步修复
- headerMatches 收窄为单向包含,避免宿舍号被原/新宿舍号反向匹配
- suggestStep 增加入住/换宿显式表头信号,修复入住表误判为换宿
- 预检与预览按工作表逐表解析列映射,兼容异构表头
- 修复预检卡生成向导成功后按钮未复位 loading 的问题
- 补充 mapping/预检/run/ai-chat 多工作表测试
2026-08-06 14:46:33 +08:00
259271f56c fix(admin): Alert message 弃用 API 全部迁移为 title 2026-08-06 12:20:24 +08:00
824c33a71c refactor(ai-chat): 拆分超大文件并通过 aislop 100 分门槛
- AiChatService 拆出抽象基类,职责不变
- submissions 按提交内容/评审确认/运行时/流程拆为 4 个模块并保持导出兼容
- 导入工具执行器抽取共享 runner,消除重复签名块
- 全仓 aislop 扫描 100/100,0 警告
2026-08-06 12:13:37 +08:00
7f09d5271e feat(imports): 导入向导兼容旧版工作簿并完善执行/预览
- 新增 xls 工作簿回退解析与测试
- 执行/预览/权限校验逻辑完善
2026-08-06 11:59:51 +08:00
7f3e30ba38 fix(archive): 档案聚合接口 null 字段归一化为空串
避免可选列缺省返回 null 导致前端响应校验报「字段格式异常」
2026-08-06 11:59:43 +08:00
d0ab8da01b feat(admin): AI 预检卡片交互与消息组件完善
- 预检卡支持列映射、导入策略确认与生成导入向导
- 消息组件/API 契约配套更新,含集成测试
2026-08-06 11:59:36 +08:00
6a18fd264d feat(ai-chat): AI 导入确认流程完善并清理代码质量
- 新增导入确认/映射解析辅助,支持预检后生成导入向导
- 抽取 parseAttachmentArgs/beginImportToolRun 等重复逻辑
- 双重类型断言改为运行时守卫,消除 aislop 告警
2026-08-06 11:59:28 +08:00
026a9f35d8 fix(admin): 迁移 antd v6 弃用 API 并清理控制台警告
- Card bordered -> variant;Modal maskClosable -> mask.closable
- InputNumber addonBefore/After -> prefix/suffix;Statistic valueStyle -> styles.content
- Alert message -> title;Steps items.description -> items.content
- 移除考勤页未挂载 Form 上的 setFieldsValue,消除 useForm 警告
2026-08-06 11:58:58 +08:00
2e7bb81ebd fix(admin): 修正接口 schema 与路由不匹配
- classroomSchema 允许 building/status 为 null,教室查看页恢复数据
- 排期 matrix 单元格为对象而非数组,教室排期页恢复渲染
- 押金批量收取改用 /deposits/eligible-students 路由
- 学生下拉 schema 按 /deposits/student-lookups 实际结构校验
- 水电费录入改走 /expenses/student-utility,批量恢复改为 PUT
2026-08-06 11:58:19 +08:00
67e14e357c fix(imports): 导入向导 sheets_json 扩容为 MEDIUMTEXT
start_import_wizard 存整表行数据,TEXT(64KB) 在 430 行文件上触发
Data too long for column 'sheets_json',扩容至 MEDIUMTEXT(16MB) 并补迁移
(实际表名 import_runs,原迁移误写 import_run 导致空跑)
2026-08-05 22:26:14 +08:00
6249fefc64 refactor(ai-chat): 移除 excel_analyze/office_analyze 工具与 OfficeKit 依赖
- 删除 excel_analyze/office_analyze 两个 A2UI 工具(schema、分发、执行器、测试)
- 附件分析改为依赖上传时自动提取的文本与 preflight_import 报告 errorSamples
- 移除 @officecli/officecli 依赖及 OfficeCliService(PPT 上传不再自动提取文本)
- 保留 AiExcelReaderService(附件文本提取与导入预检仍使用)
2026-08-05 22:26:05 +08:00
d1c933f032 refactor(server): 清理无用导入与类型转换
- 移除测试/控制器中未使用的导入与实例
- bills/classroom-rentals 改用 ESM 导入替代 require
- bills.service 去掉多余断言、backfill 显式转字符串、main.ts void bootstrap
2026-08-05 21:14:17 +08:00
1a1e90c72f fix(admin): 补全 React hooks 依赖并修复页面稳定性
- 各页面 useCallback/useMemo 依赖补全(modal/mutation/setter 等),避免闭包过期
- ECharts 使用 optionRef、MainLayout 缓存菜单转换函数、main.tsx 增加挂载点校验
- 学生同步结果改用 recordsCount 展示
2026-08-05 21:14:08 +08:00
ab4765cee5 feat: Excel 导入预检与动态问答,AI 聊天/文件解析体验修复
- imports: 新增 preflight_import 预检报告(判定/分阶段统计/阻断归因/问题/下一步/错误示例),导入任务 settings 落库(映射/校区/更新/重复/未匹配策略),预览应用策略,向导提交写操作日志
- ai-chat: 新增 excel_analyze(ExcelJS)工具,移除附件/上下文截断,start_import_wizard 支持确认参数,ui.import_preflight SSE,预览确认写操作日志
- admin: ImportPreflightCard 渲染与持久化,聊天抽屉布局/侧边栏修复,考勤页 CSS 引入,费用/学生页接口 schema 校验修复
2026-08-05 21:12:50 +08:00
b70f45fb04 Merge pull request '考勤请假同步、SSE 修复与学生报告优化' (#59) from codex/ai-a2ui-agent-tools into main 2026-08-05 10:58:31 +00:00
5f89335dbb Merge pull request 'feat: AI 对话 A2UI 表单/审查/图表、Agent 业务工具与前端状态迁移' (#58) from codex/ai-a2ui-agent-tools into main 2026-08-05 08:04:33 +00:00
425 changed files with 22423 additions and 8661 deletions

View File

@@ -33,3 +33,11 @@ AI_CONFIG_ENCRYPTION_KEY=
# 允许内网地址作为 OPENAI_COMPATIBLE 的 baseUrl仅内网部署使用 # 允许内网地址作为 OPENAI_COMPATIBLE 的 baseUrl仅内网部署使用
# AI_ALLOW_PRIVATE_BASE_URL=true # AI_ALLOW_PRIVATE_BASE_URL=true
# ---- 安全 ----
# 是否信任反向代理的 X-Forwarded-For / X-Real-IP仅当部署在可信代理/Nginx 后时才设 true
# 不设置时服务端只用 TCP socket 地址,防止伪造客户端 IP
# TRUST_PROXY=true
# 说明:第三方集成配置(钉钉/企微 appSecret的静态加密复用 AI_CONFIG_ENCRYPTION_KEY
# 存量明文可用 `cd apps/server && npm run encrypt:integration-secrets` 一次性加密。

View File

@@ -0,0 +1,44 @@
name: Dependency Check
# 依赖安全检查:生产依赖 moderate+ 或全局 critical 漏洞即失败;
# npm outdated 作为信息输出,不阻断。
# 注意npmmirror 未实现 audit 接口audit 必须显式指定官方 registry。
env:
NPM_CONFIG_REGISTRY: https://registry.npmmirror.com
NPM_CONFIG_AUDIT: "false"
NPM_CONFIG_FUND: "false"
NPM_CONFIG_REPLACE_REGISTRY_HOST: always
CI: "true"
on:
pull_request:
branches: [main]
schedule:
- cron: '30 2 * * 1' # 每周一 02:30 复查已发布的新漏洞
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
container: node:22.22.0-bookworm
timeout-minutes: 20
steps:
- name: Checkout
uses: https://gitee.com/mirrors_actions/checkout@v4
with:
github-server-url: https://git.gongxue100.com
fetch-depth: 1
- name: Install dependencies
run: npm ci
- name: Audit production dependencies (moderate+)
run: npm audit --registry=https://registry.npmjs.org --omit=dev --audit-level=moderate
- name: Audit all dependencies (critical only)
run: npm audit --registry=https://registry.npmjs.org --audit-level=critical
- name: Outdated report (informational)
run: npm outdated --registry=https://registry.npmjs.org || true

View File

@@ -1,80 +1,86 @@
# PM2 手动部署工作流 # PM2 本地 runner 手动部署工作流
# 手动触发 → 构建 → rsync → 安装依赖 → 数据库迁移 → PM2 重载 # 手动触发 → 服务器本地 git pull → 构建 → 依赖安装 → 数据库迁移 → PM2 重载
#
# 适用场景Gitea runner 与生产服务器同机(本机直跑,不需要 SSH/Secrets
# #
# 前置准备: # 前置准备:
# 1. 服务器 MySQL 已在运行 # 1. Gitea Actions 已开启,且已注册 self-hosted runnerruns-on 标签与下面一致)
# 2. 服务器已安装 PM2: npm i -g pm2 # 2. 服务器已 clone 仓库到部署目录(默认 /opt/gongxue可用仓库 Variable REMOTE_DIR 覆盖)
# 3. Gitea 仓库 Settings → Secrets 配置: # 3. 服务器已安装 Node 22 + PM2npm i -g pm2MySQL 已运行
# - SSH_PRIVATE_KEY : 部署用 SSH 私钥 # 4. runner 运行账户对部署目录有写权限、可执行 npm/pm2
# - SSH_HOST : 服务器地址 #
# - SSH_USER : SSH 用户名 # 关于 .env不需要配置在 Gitea 界面。.env 只放在服务器部署目录下,
# - SSH_PORT : SSH 端口(默认 22可选 # git pull 不会覆盖它PM2/Nest 启动时直接读取服务器上的 .env。
# - REMOTE_DIR : 服务器项目目录,如 /opt/gongxue #
# 关于“在 docker 里构建/迁移”:
# - 如果 runner 以“宿主机进程”方式运行(推荐,最省事):本 workflow 的
# npm ci / build / migration 直接在宿主机执行pm2 直接操作宿主 pm2。
# - 如果 runner 跑在 docker 容器里:需要把部署目录挂载进容器
# (如 -v /opt/gongxue:/opt/gongxue构建/迁移在容器内完成;
# PM2 重载则必须打到宿主机(挂载 docker.sock 后 docker exec 到宿主,
# 或在宿主机单独跑 pm2 服务端)——容器里的 pm2 管理不了宿主进程。
name: PM2 部署 name: PM2 本地部署
on: on:
workflow_dispatch: workflow_dispatch:
inputs:
seed_roles:
description: '强制播种 RBAC 权限码(首次部署或新增权限码时勾选,跑完会自动去掉)'
required: false
default: false
type: boolean
concurrency:
group: deploy-local
cancel-in-progress: false
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: self-hosted # 改为你注册 runner 时使用的 labelubuntu-latest
steps: steps:
- name: 检出代码 - name: 拉取最新代码
uses: actions/checkout@v4 run: |
cd ${{ vars.REMOTE_DIR || '/opt/gongxue' }}
- name: 安装 Node.js git fetch origin
uses: actions/setup-node@v4 git checkout main
with: git pull origin main
node-version: '22'
- name: 安装依赖 & 构建 - name: 安装依赖 & 构建
run: | run: |
cd ${{ vars.REMOTE_DIR || '/opt/gongxue' }}
npm ci npm ci
npm run build -w @gongxue/server npm run build -w @gongxue/server
npm run build -w @gongxue/admin npm run build -w @gongxue/admin
- name: 配置 SSH - name: 执行数据库迁移
run: | run: |
mkdir -p ~/.ssh cd ${{ vars.REMOTE_DIR || '/opt/gongxue' }}
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
cat >> ~/.ssh/config <<'EOF'
Host deploy-server
HostName ${{ secrets.SSH_HOST }}
User ${{ secrets.SSH_USER }}
Port ${{ secrets.SSH_PORT || '22' }}
IdentityFile ~/.ssh/deploy_key
StrictHostKeyChecking accept-new
EOF
- name: 同步代码到服务器
run: |
rsync -avz --delete \
--exclude='node_modules' \
--exclude='.git' \
--exclude='*.db' \
--exclude='.DS_Store' \
--exclude='logs/' \
--exclude='.turbo/' \
--exclude='.claude/' \
--exclude='.codegraph/' \
./ deploy-server:${{ secrets.REMOTE_DIR }}/
- name: 安装依赖 → 迁移 → PM2 重载
run: |
ssh deploy-server "
cd ${{ secrets.REMOTE_DIR }}
mkdir -p logs
if [ ! -d node_modules ]; then
echo '首次部署,安装生产依赖...'
npm ci --omit=dev
fi
echo '执行数据库迁移...'
npm run migration:run -w @gongxue/server npm run migration:run -w @gongxue/server
echo 'PM2 重载...'
- name: PM2 重载
run: |
cd ${{ vars.REMOTE_DIR || '/opt/gongxue' }}
if [ "${{ github.event.inputs.seed_roles }}" = "true" ]; then
echo '>>> 强制播种 RBAC 权限码(本次进程带 SEED_ROLES=true'
SEED_ROLES=true pm2 startOrReload ecosystem.config.cjs --only gongxue-backend --update-env
pm2 restart gongxue-backend --update-env # 去掉 SEED_ROLES 正常重启
else
pm2 startOrReload ecosystem.config.cjs --update-env pm2 startOrReload ecosystem.config.cjs --update-env
fi
pm2 save pm2 save
echo '=== PM2 状态 ==='
pm2 status pm2 status
"
- name: 健康检查
run: |
cd ${{ vars.REMOTE_DIR || '/opt/gongxue' }}
sleep 3
# 从 .env 读 PORT默认 3000避免硬编码端口与后端不一致
PORT="$(grep '^PORT=' .env 2>/dev/null | cut -d= -f2- | tr -d '"' || true)"
PORT="${PORT:-3000}"
code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/api/dashboard/stats" || true)"
echo "后端 HTTP 状态: ${code}401=正常,接口需登录)"
if [ "${code}" != "401" ] && [ "${code}" != "200" ]; then
echo "健康检查失败:后端未按预期响应" >&2
exit 1
fi

View File

@@ -16,6 +16,11 @@
| 账单导出 | Excel汇总+明细双Sheet、单条PDF账单 | | 账单导出 | Excel汇总+明细双Sheet、单条PDF账单 |
| 教室管理 | 教室信息维护、教室租赁记录 | | 教室管理 | 教室信息维护、教室租赁记录 |
| 押金管理 | 押金收取与退还 | | 押金管理 | 押金收取与退还 |
| 班级/排课 | 班级档案、分班、教室日程与排课 |
| 考勤管理 | 手工考勤、钉钉考勤同步、自动匹配 |
| 教室租赁 | 租赁订单、合同、租赁日程 |
| AI 助手 | 对话式查询、表单/导入向导/图表、业务待办引导 |
| 组织/校区 | 组织机构与数据范围 |
| 操作日志 | 所有涉及钱的操作自动审计留痕 | | 操作日志 | 所有涉及钱的操作自动审计留痕 |
| 账号管理 | 用户增删改查、角色区分、启用/禁用、重置密码 | | 账号管理 | 用户增删改查、角色区分、启用/禁用、重置密码 |
@@ -42,7 +47,7 @@
### 后端启动 ### 后端启动
```bash ```bash
cd backend cd apps/server
cp .env.example .env # 复制并修改环境配置 cp .env.example .env # 复制并修改环境配置
npm install npm install
npm run start:dev # 开发模式启动,默认端口 3000 npm run start:dev # 开发模式启动,默认端口 3000
@@ -51,46 +56,48 @@ npm run start:dev # 开发模式启动,默认端口 3000
### 前端启动 ### 前端启动
```bash ```bash
cd frontend cd apps/admin
npm install npm install
npm run dev # 开发模式启动,默认端口 5173 npm run dev # 开发模式启动,默认端口 5173
``` ```
### Docker 部署 ### 常用命令
```bash ```bash
docker-compose up -d # 一键启动 MySQL + 后端 + 前端 npm run typecheck # 全仓类型检查
npm run lint # 全仓 lint
npm run test # 全仓测试
npm run build # 全仓构建
``` ```
## 项目结构 ## 项目结构
``` ```
├── backend/ # 后端 NestJS 服务 ├── apps/server/ # 后端 NestJS 服务
│ ├── src/ │ ├── src/
│ │ ├── auth/ # 认证模块 (JWT) │ │ ├── ai-chat/ # AI 对话、表单/导入向导/图表
│ │ ├── attendance/ # 考勤与钉钉同步
│ │ ├── bills/ # 账单模块 │ │ ├── bills/ # 账单模块
│ │ ├── classrooms/ # 教室管理
│ │ ├── dashboard/ # 数据面板 │ │ ├── dashboard/ # 数据面板
│ │ ├── deposits/ # 押金管理
│ │ ├── entities/ # 数据实体 │ │ ├── entities/ # 数据实体
│ │ ├── expenses/ # 费用录入
│ │ ├── occupancies/# 入住管理 │ │ ├── occupancies/# 入住管理
│ │ ├── rbac/ # 角色权限
│ │ ├── rooms/ # 宿舍管理 │ │ ├── rooms/ # 宿舍管理
│ │ ── students/ # 学生管理 │ │ ── students/ # 学生管理
│ │ └── tenants/ # 租户管理
│ └── .env.example # 环境配置模板 │ └── .env.example # 环境配置模板
├── frontend/ # 前端 React 应用 ├── apps/admin/ # 前端 React 应用
│ └── src/ │ └── src/
│ ├── api/ # API 请求封装 │ ├── api/ # API 请求封装
│ ├── components/ # 通用组件
│ ├── layouts/ # 布局组件 │ ├── layouts/ # 布局组件
│ └── pages/ # 页面组件 │ └── pages/ # 页面组件
├── docker-compose.yml # Docker 编排配置 ├── packages/ # 共享配置
└── 技术文档.md # 详细技术文档 └── 技术文档.md # 详细技术文档
``` ```
## 环境配置 ## 环境配置
复制 `backend/.env.example``backend/.env`,按需修改: 复制 `apps/server/.env.example``apps/server/.env`,按需修改:
| 配置项 | 说明 | 默认值 | | 配置项 | 说明 | 默认值 |
|--------|------|--------| |--------|------|--------|

View File

@@ -14,9 +14,9 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.1.1", "@ant-design/icons": "^6.1.1",
"@ant-design/x": "^2.8.0", "@ant-design/x": "^2.9.0",
"@ant-design/x-card": "^2.9.0", "@ant-design/x-card": "^2.9.0",
"@ant-design/x-markdown": "^2.8.0", "@ant-design/x-markdown": "^2.9.0",
"@ant-design/x-sdk": "^2.8.0", "@ant-design/x-sdk": "^2.8.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -26,13 +26,15 @@
"antd": "^6.3.6", "antd": "^6.3.6",
"axios": "^1.15.1", "axios": "^1.15.1",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"dompurify": "^3.4.13",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"mermaid": "^11.16.0", "mermaid": "^11.16.1",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-router": "^8.3.0", "react-router": "^8.3.0",
"react-syntax-highlighter": "^16.1.1",
"use-immer": "^0.11.0", "use-immer": "^0.11.0",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"zod": "^4.4.3", "zod": "^4.4.3",

View File

@@ -7,6 +7,7 @@ import zhCN from 'antd/es/locale/zh_CN';
import MainLayout from './layouts/MainLayout'; import MainLayout from './layouts/MainLayout';
import PermissionRoute from './components/PermissionRoute'; import PermissionRoute from './components/PermissionRoute';
import DefaultRoute from './components/DefaultRoute'; import DefaultRoute from './components/DefaultRoute';
import ScrollToTop from './components/ScrollToTop';
import AppMessageBridge from './ui/AppMessageBridge'; import AppMessageBridge from './ui/AppMessageBridge';
import { useUserStore } from './store/user/userStore'; import { useUserStore } from './store/user/userStore';
@@ -74,6 +75,7 @@ const App: React.FC = () => {
<AntdApp> <AntdApp>
<AppMessageBridge /> <AppMessageBridge />
<BrowserRouter> <BrowserRouter>
<ScrollToTop />
<Suspense <Suspense
fallback={ fallback={
<div style={{ minHeight: '40vh', display: 'grid', placeItems: 'center' }}> <div style={{ minHeight: '40vh', display: 'grid', placeItems: 'center' }}>

View File

@@ -21,6 +21,8 @@ export async function createImportRun(
conversationId?: number; conversationId?: number;
stages?: ImportStageRequest[]; stages?: ImportStageRequest[];
mapping?: Record<string, Record<string, string>>; mapping?: Record<string, Record<string, string>>;
/** 上传进度回调0-100 */
onProgress?: (percent: number) => void;
}, },
): Promise<ImportRunDetail> { ): Promise<ImportRunDetail> {
const form = new FormData(); const form = new FormData();
@@ -32,7 +34,10 @@ export async function createImportRun(
form.append('mapping', JSON.stringify(options.mapping)); form.append('mapping', JSON.stringify(options.mapping));
} }
const res = await api.post<ApiEnvelope<ImportRunDetail>>('/imports/runs', form, { const res = await api.post<ApiEnvelope<ImportRunDetail>>('/imports/runs', form, {
headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: (event) => {
if (!options.onProgress || !event.total) return;
options.onProgress(Math.min(Math.round((event.loaded / event.total) * 100), 100));
},
}); });
return res.data; return res.data;
} }

View File

@@ -0,0 +1,20 @@
import { QueryClient } from '@tanstack/react-query';
/**
* 全局 QueryClient统一缓存/重试策略。
* - retry 1接口失败最多重试 1 次,避免瞬时错误直接白屏
* - staleTime 30s30 秒内重复请求走缓存
* - gcTime 5min不活跃缓存 5 分钟后回收
* - refetchOnWindowFocus false切回窗口不自动全量刷新
* 保活页面由 useVisibleRefetch 按需刷新,避免重复请求
*/
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: false,
},
},
});

View File

@@ -0,0 +1,145 @@
/**
* 统一 QueryKey 工厂。
*
* 每个模块一个命名空间key 由工厂函数生成:
* - 避免散落字符串字面量导致拼写不一致、缓存串扰
* - invalidate / refetch / prefetch 与 useQuery 使用同一来源,不会对不上
*
* 约定:
* - `all` 是该模块的「根 key」用于整体失效invalidateQueries 会匹配前缀)
* - 列表 key 按过滤条件展开;详情/子资源用固定段 + 参数
*/
export const queryKeys = {
students: {
all: ['students'] as const,
list: (filters: {
search?: string;
status?: string;
archived?: boolean;
organizationId?: number;
classId?: number;
teacherId?: number;
}) => ['students', filters] as const,
organizations: (canView: boolean) => ['students', 'organizations', canView] as const,
filterLookups: () => ['students', 'filter-lookups'] as const,
},
classes: {
all: ['classes'] as const,
list: (filters: { status?: string; type?: string; archived?: boolean }) =>
['classes', filters] as const,
detail: (id: number) => ['classes', 'detail', id] as const,
schedule: (id: number, dateRange: unknown) => ['classes', 'schedule', id, dateRange] as const,
attendanceSummary: (id: number, dateRange: unknown) =>
['classes', 'attendance-summary', id, dateRange] as const,
},
classSchedules: {
all: ['class-schedules'] as const,
list: (params: { startDate?: string; endDate?: string; classroomIds?: unknown[] }) =>
['class-schedules', params] as const,
},
classrooms: {
all: ['classrooms'] as const,
list: (archived: boolean) => ['classrooms', archived] as const,
},
classroomRentals: {
all: ['classroom-rentals'] as const,
list: (month?: string) => ['classroom-rentals', month] as const,
schedule: (year: number, month: number) =>
['classroom-rentals', 'schedule', year, month] as const,
meta: () => ['classroom-rentals', 'meta'] as const,
},
organizations: {
all: ['organizations'] as const,
list: () => ['organizations'] as const,
options: () => ['organizations', 'options'] as const,
},
rbac: {
all: ['rbac'] as const,
users: (archived: boolean) => ['rbac', 'users', archived] as const,
allUsers: () => ['rbac', 'users', 'all'] as const,
teachers: (params: { page: number; pageSize: number; search?: string }) =>
['rbac', 'teachers', params] as const,
teacherWorkspace: () => ['rbac', 'teacher-workspace'] as const,
roles: () => ['rbac', 'roles'] as const,
permissionTree: () => ['rbac', 'roles', 'permission-tree'] as const,
permissionsTree: () => ['rbac', 'permissions', 'tree'] as const,
},
expenses: {
all: ['expenses'] as const,
list: (archived: boolean) => ['expenses', archived ? 'archived' : 'active'] as const,
},
expenseTypes: {
map: () => ['expense-types', 'map'] as const,
},
expenseLookups: {
all: ['expense-lookups'] as const,
},
bills: {
all: ['bills'] as const,
list: (filters: { status?: string; expenseType?: string }) => ['bills', filters] as const,
},
wallets: {
all: ['wallets'] as const,
list: (params: { keyword?: string; debtOnly?: boolean; roomType?: string }) =>
['wallets', params] as const,
transactions: (studentId: number) => ['wallets', 'transactions', studentId] as const,
roomTypes: () => ['wallets', 'room-types'] as const,
},
deposits: {
all: ['deposits'] as const,
list: () => ['deposits'] as const,
eligible: (roomType?: string) => ['deposits', 'eligible', roomType] as const,
},
occupancies: {
all: ['occupancies'] as const,
list: (params: { viewMode?: string; dateRange?: unknown }) => ['occupancies', params] as const,
},
rooms: {
all: ['rooms'] as const,
overview: (archived: boolean) => ['rooms', 'overview', archived] as const,
visual: (params: { historical: boolean; asOf?: unknown }) =>
['rooms', 'visual', params] as const,
},
exams: {
all: ['exams'] as const,
detail: (id: number) => ['exams', 'detail', id] as const,
classes: () => ['exams', 'classes'] as const,
},
operationLogs: {
all: ['operation-logs'] as const,
list: (params: { page: number; pageSize: number; module?: string; dateRange?: unknown }) =>
['operation-logs', params] as const,
},
attendance: {
all: ['attendance'] as const,
workspace: () => ['attendance', 'workspace'] as const,
syncStatus: () => ['attendance', 'sync-status'] as const,
schedules: (classId: number, date: string) => ['attendance', 'schedules', classId, date] as const,
meta: {
periods: () => ['attendance', 'meta', 'periods'] as const,
classes: () => ['attendance', 'meta', 'classes'] as const,
alerts: () => ['attendance', 'meta', 'alerts'] as const,
},
},
attendanceDevices: {
all: ['attendance-devices'] as const,
},
dashboard: {
all: ['dashboard'] as const,
summary: (period: unknown) => ['dashboard', period] as const,
},
integration: {
config: () => ['integration', 'config'] as const,
},
sync: {
jinshujuRules: () => ['sync', 'jinshuju', 'rules'] as const,
},
archive: {
detail: (studentId: number) => ['archive', studentId] as const,
},
ai: {
config: () => ['ai', 'config'] as const,
},
} as const;
export type QueryKeys = typeof queryKeys;

View File

@@ -153,8 +153,8 @@ export const classroomSchema = z
.object({ .object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
building: z.string().optional(), building: z.string().nullable().optional(),
status: z.string().optional(), status: z.string().nullable().optional(),
}) })
.passthrough(); .passthrough();
@@ -165,7 +165,7 @@ export const studentSchema = z
.object({ .object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
studentNo: z.string().optional(), studentNo: z.string().nullable().optional(),
status: z.string(), status: z.string(),
}) })
.passthrough(); .passthrough();
@@ -180,7 +180,11 @@ export const depositSchema = z
export const depositsSchema = z.array(depositSchema); export const depositsSchema = z.array(depositSchema);
export const depositStudentLookupSchema = z export const depositStudentLookupSchema = z
.object({ studentId: z.number(), name: z.string().optional() }) .object({
id: z.number(),
name: z.string().nullable().optional(),
studentNo: z.string().nullable().optional(),
})
.passthrough(); .passthrough();
export const depositStudentLookupsSchema = z.array(depositStudentLookupSchema); export const depositStudentLookupsSchema = z.array(depositStudentLookupSchema);
@@ -217,6 +221,18 @@ export const expenseRecordSchema = z
export const expenseRecordsSchema = z.array(expenseRecordSchema); export const expenseRecordsSchema = z.array(expenseRecordSchema);
export const expenseStudentLookupSchema = z
.object({
id: z.number(),
name: z.string().nullable().optional(),
studentNo: z.string().nullable().optional(),
})
.passthrough();
export const expenseStudentLookupsSchema = z.array(expenseStudentLookupSchema);
export const expenseRoomsListSchema = z.array(z.record(z.string(), z.unknown()));
export const expenseLookupsSchema = z export const expenseLookupsSchema = z
.object({ .object({
rooms: z.array(z.record(z.string(), z.unknown())), rooms: z.array(z.record(z.string(), z.unknown())),

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { ganttRoomsSchema } from './dashboard';
describe('ganttRoomsSchema 接口校验', () => {
const validPayload = [
{
roomNumber: 'A101',
occupancies: [
{
studentName: '张三',
studentId: 3,
checkInDate: '2026-05-01',
checkOutDate: null,
billingStartDate: '2026-05-01',
billingEndDate: null,
},
],
},
];
it('接受合法的甘特图数据studentId 为数字)', () => {
expect(ganttRoomsSchema.safeParse(validPayload).success).toBe(true);
});
it('拒绝缺少 checkInDate 的入住记录', () => {
const payload = [
{
roomNumber: 'A101',
occupancies: [{ studentName: '张三', checkOutDate: null }],
},
];
expect(ganttRoomsSchema.safeParse(payload).success).toBe(false);
});
it('拒绝缺少 studentName 的入住记录', () => {
const payload = [
{
roomNumber: 'A101',
occupancies: [{ checkInDate: '2026-05-01', checkOutDate: null }],
},
];
expect(ganttRoomsSchema.safeParse(payload).success).toBe(false);
});
});

View File

@@ -4,7 +4,8 @@ export const classroomScheduleSchema = z
.object({ .object({
classrooms: z.array(z.record(z.string(), z.unknown())), classrooms: z.array(z.record(z.string(), z.unknown())),
organizations: z.array(z.record(z.string(), z.unknown())), organizations: z.array(z.record(z.string(), z.unknown())),
matrix: z.record(z.string(), z.record(z.string(), z.array(z.record(z.string(), z.unknown())))), // matrix: 教室 id → 日期 → 单条排课/租赁记录(对象,非数组)
matrix: z.record(z.string(), z.record(z.string(), z.record(z.string(), z.unknown()))),
summary: z.record(z.string(), z.record(z.string(), z.unknown())), summary: z.record(z.string(), z.record(z.string(), z.unknown())),
days: z.number().optional(), days: z.number().optional(),
}) })
@@ -58,8 +59,19 @@ export const classAttendanceRankingSchema = z
export const ganttRoomsSchema = z.array( export const ganttRoomsSchema = z.array(
z z
.object({ roomNumber: z.string(), occupancies: z.array(z.record(z.string(), z.unknown())) }) .object({
.passthrough(), roomNumber: z.string(),
occupancies: z.array(
z.object({
studentName: z.string(),
studentId: z.union([z.string(), z.number()]).optional(),
checkInDate: z.string(),
checkOutDate: z.string().nullable(),
billingStartDate: z.string().optional(),
billingEndDate: z.string().nullable().optional(),
}),
),
}),
); );
export const classroomOccupanciesSchema = z.array( export const classroomOccupanciesSchema = z.array(

View File

@@ -34,6 +34,18 @@ export const importStepDetailSchema = z
}) })
.passthrough(); .passthrough();
export const importRunSettingsSchema = z
.object({
mapping: z.record(z.string(), z.record(z.string(), z.string())).optional(),
organization: z.string().nullable().optional(),
updateExisting: z.boolean().optional(),
duplicatePolicy: z.enum(['error', 'skip']).optional(),
skipUnmatched: z.boolean().optional(),
})
.passthrough()
.nullable()
.optional();
export const importRunDetailSchema = z export const importRunDetailSchema = z
.object({ .object({
id: z.string(), id: z.string(),
@@ -44,6 +56,7 @@ export const importRunDetailSchema = z
createdAt: z.string(), createdAt: z.string(),
sheets: z.array(importSheetMetaSchema), sheets: z.array(importSheetMetaSchema),
steps: z.array(importStepDetailSchema), steps: z.array(importStepDetailSchema),
settings: importRunSettingsSchema,
}) })
.passthrough(); .passthrough();

View File

@@ -64,6 +64,14 @@ export const AiChatSidebar: React.FC<AiChatSidebarProps> = ({
} }
/> />
{loadingList && <Spin className="ai-chat-sidebar__loading" />} {loadingList && <Spin className="ai-chat-sidebar__loading" />}
{!loadingList && !selectionMode && conversationCount === 0 ? (
<div className="ai-chat-sidebar__empty">
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
</div>
) : null}
<div className="ai-chat-sidebar__footer"> <div className="ai-chat-sidebar__footer">
{selectionMode ? ( {selectionMode ? (
<> <>

View File

@@ -20,8 +20,11 @@ import {
} from 'antd'; } from 'antd';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { useUserStore } from '../../store/user/userStore';
import { usePermissionStore } from '../../store/permission/permissionStore';
import { aiChatApi, conversationStreamUrl } from './api'; import { aiChatApi, conversationStreamUrl } from './api';
import { GongxueAiChatProvider } from './provider'; import { GongxueAiChatProvider } from './provider';
import { welcomeDescription, workflowPromptExamples } from './welcomeCopy';
import { ImportWizardModal } from '../ImportWizard/ImportWizardModal'; import { ImportWizardModal } from '../ImportWizard/ImportWizardModal';
import type { AiSkill } from './types'; import type { AiSkill } from './types';
import { useAiChatMessageActions } from './useAiChatMessageActions'; import { useAiChatMessageActions } from './useAiChatMessageActions';
@@ -51,10 +54,14 @@ interface AiChatDrawerProps {
const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequestingChange }) => { const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequestingChange }) => {
const { modal } = App.useApp(); const { modal } = App.useApp();
const user = useUserStore((state) => state.user);
const permissions = usePermissionStore((state) => state.permissions);
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = !screens.sm; const isMobile = !screens.sm;
const [loadingList, setLoadingList] = useState(false); const [loadingList, setLoadingList] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(!isMobile); // 断点首帧可能尚未解析isMobile 误判为 true桌面端默认展开会话侧边栏
// 移动端通过 effectiveSidebarOpen 统一隐藏。
const [sidebarOpen, setSidebarOpen] = useState(true);
const effectiveSidebarOpen = isMobile ? false : sidebarOpen; const effectiveSidebarOpen = isMobile ? false : sidebarOpen;
const [skills, setSkills] = useState<AiSkill[]>([]); const [skills, setSkills] = useState<AiSkill[]>([]);
const [conversationStatus, setConversationStatus] = useState< const [conversationStatus, setConversationStatus] = useState<
@@ -182,11 +189,32 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
const switchConversation = useCallback( const switchConversation = useCallback(
(key: string) => { (key: string) => {
const doSwitch = () => {
discardPendingAttachments(); discardPendingAttachments();
if (isMobile) setSidebarOpen(false); if (isMobile) setSidebarOpen(false);
setActiveConversationKey(key); setActiveConversationKey(key);
};
// 有待发送的附件时先确认,避免静默删除已上传文件
if (uploadItems.length > 0) {
modal.confirm({
title: '切换会话将丢弃未发送的附件',
content: `当前有 ${uploadItems.length} 个已上传但未发送的附件,切换会话后将被删除,此操作不可恢复。`,
okText: '切换并丢弃',
okButtonProps: { danger: true },
cancelText: '留在当前会话',
onOk: doSwitch,
});
return;
}
doSwitch();
}, },
[discardPendingAttachments, isMobile, setActiveConversationKey], [
discardPendingAttachments,
isMobile,
modal,
setActiveConversationKey,
uploadItems.length,
],
); );
useEffect( useEffect(
@@ -228,7 +256,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
}, },
}); });
}, },
[setConversation], [setConversation, modal],
); );
/** 删除单个会话时中止请求并清理会话运行时状态 */ /** 删除单个会话时中止请求并清理会话运行时状态 */
@@ -275,6 +303,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
switchConversation, switchConversation,
removeConversation, removeConversation,
removeConversationEntry, removeConversationEntry,
modal,
], ],
); );
@@ -362,6 +391,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
selectedKeys, selectedKeys,
switchConversation, switchConversation,
setConversations, setConversations,
modal,
]); ]);
const conversationMenu = useCallback( const conversationMenu = useCallback(
@@ -520,12 +550,21 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
title="你好,我是恭学 AI 助手" title="你好,我是恭学 AI 助手"
description={ description={
lockedSkill?.description || lockedSkill?.description ||
'我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。' welcomeDescription(user?.roles ?? [], permissions)
} }
/> />
<Prompts <Prompts
title="你可以这样问" title="你可以这样问"
items={promptItems} items={[
...promptItems,
...workflowPromptExamples(user?.roles ?? [], permissions).map(
(item, index) => ({
key: `workflow-${index}`,
label: item.label,
description: item.description,
}),
),
]}
wrap wrap
onItemClick={({ data }) => submit(String(data.label || ''))} onItemClick={({ data }) => submit(String(data.label || ''))}
/> />

View File

@@ -13,9 +13,12 @@ import type { ThoughtChainItemType } from '@ant-design/x';
import XMarkdown, { type ComponentProps } from '@ant-design/x-markdown'; import XMarkdown, { type ComponentProps } from '@ant-design/x-markdown';
import { Alert, Button, Flex, Input, Space, Typography } from 'antd'; import { Alert, Button, Flex, Input, Space, Typography } from 'antd';
import { useUserStore } from '../../store/user/userStore'; import { useUserStore } from '../../store/user/userStore';
import { message } from '../../ui/app-message';
import { DynamicChart } from './DynamicChart'; import { DynamicChart } from './DynamicChart';
import { DynamicForm } from './DynamicForm'; import { DynamicForm } from './DynamicForm';
import { DynamicReview } from './DynamicReview'; import { DynamicReview } from './DynamicReview';
import { deriveCharts, deriveForms, deriveReviews } from './uiArtifacts';
import { ArtifactErrorBoundary } from './ArtifactErrorBoundary';
import { LiteCodeHighlighter } from './LiteCodeHighlighter'; import { LiteCodeHighlighter } from './LiteCodeHighlighter';
import { LiteMermaid } from './LiteMermaid'; import { LiteMermaid } from './LiteMermaid';
import type { import type {
@@ -41,7 +44,6 @@ const toolLabels: Record<string, string> = {
search_bills: '查询账单', search_bills: '查询账单',
get_dashboard_stats: '读取经营概览', get_dashboard_stats: '读取经营概览',
render_form: '生成表单', render_form: '生成表单',
render_review: '生成导入预览',
render_chart: '生成图表', render_chart: '生成图表',
start_import_wizard: '生成导入向导', start_import_wizard: '生成导入向导',
create_student: '创建学生', create_student: '创建学生',
@@ -52,6 +54,9 @@ const toolLabels: Record<string, string> = {
search_classrooms: '查询教室', search_classrooms: '查询教室',
search_classroom_rentals: '查询教室租用', search_classroom_rentals: '查询教室租用',
get_sync_status: '查询同步状态', get_sync_status: '查询同步状态',
get_business_context: '读取业务流程',
get_entity_schema: '读取实体字典',
get_pending_tasks: '查询业务待办',
}; };
const markdownComponents = { const markdownComponents = {
@@ -100,6 +105,24 @@ async function openSourceUrl(item: { url?: string }): Promise<void> {
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
} }
/** 打开附件,失败时给出明确提示(避免「点了没反应」) */
async function handleOpenAttachment(attachment: AiAttachment): Promise<void> {
try {
await openAttachment(attachment);
} catch (error: unknown) {
message.error(error instanceof Error ? error.message : '附件打开失败,请重试');
}
}
/** 打出来源链接,失败时给出明确提示 */
async function handleOpenSource(item: { url?: string }): Promise<void> {
try {
await openSourceUrl(item);
} catch (error: unknown) {
message.error(error instanceof Error ? error.message : '来源打开失败,请重试');
}
}
function ToolChain({ tools }: { tools: AiToolRun[] }) { function ToolChain({ tools }: { tools: AiToolRun[] }) {
const items = useMemo<ThoughtChainItemType[]>( const items = useMemo<ThoughtChainItemType[]>(
() => () =>
@@ -203,6 +226,11 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
const streaming = status === 'loading' || status === 'updating'; const streaming = status === 'loading' || status === 'updating';
const formSubmission = message.metadata?.a2uiSubmit; const formSubmission = message.metadata?.a2uiSubmit;
const reviewSubmission = message.metadata?.a2uiReviewSubmit; const reviewSubmission = message.metadata?.a2uiReviewSubmit;
// 统一 artifact 优先,历史消息(仅 legacy 字段)回退
const forms = deriveForms(message).length > 0 ? deriveForms(message) : (message.forms ?? []);
const reviews =
deriveReviews(message).length > 0 ? deriveReviews(message) : (message.reviews ?? []);
const charts = deriveCharts(message).length > 0 ? deriveCharts(message) : (message.charts ?? []);
const sourceMeta = message.metadata?.a2uiSources; const sourceMeta = message.metadata?.a2uiSources;
const sourceItems = Array.isArray(sourceMeta) const sourceItems = Array.isArray(sourceMeta)
? sourceMeta ? sourceMeta
@@ -224,7 +252,7 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
byte={attachment.size} byte={attachment.size}
size="small" size="small"
icon={attachmentIcon(attachment)} icon={attachmentIcon(attachment)}
onClick={() => void openAttachment(attachment)} onClick={() => void handleOpenAttachment(attachment)}
/> />
)); ));
@@ -348,20 +376,21 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
<Sources <Sources
items={sourceItems} items={sourceItems}
title="引用来源" title="引用来源"
onClick={(item) => void openSourceUrl(item as { url?: string })} onClick={(item) => void handleOpenSource(item as { url?: string })}
/> />
)} )}
{(message.forms ?? []).map((form) => ( {(forms ?? []).map((form) => (
<ArtifactErrorBoundary key={form.id} title="表单">
<DynamicForm <DynamicForm
key={form.id}
form={form} form={form}
disabled={streaming} disabled={streaming}
onSubmit={(values) => onSubmitForm?.(form, values)} onSubmit={(values) => onSubmitForm?.(form, values)}
/> />
</ArtifactErrorBoundary>
))} ))}
{(message.reviews ?? []).map((review: AiReviewSchema) => ( {(reviews ?? []).map((review: AiReviewSchema) => (
<ArtifactErrorBoundary key={review.id} title="导入预览">
<DynamicReview <DynamicReview
key={review.id}
review={review} review={review}
messageId={typeof message.id === 'number' ? message.id : undefined} messageId={typeof message.id === 'number' ? message.id : undefined}
disabled={streaming} disabled={streaming}
@@ -369,9 +398,12 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
onConfirmStep={onConfirmReviewStep} onConfirmStep={onConfirmReviewStep}
onConfirmGroup={onConfirmReviewGroup} onConfirmGroup={onConfirmReviewGroup}
/> />
</ArtifactErrorBoundary>
))} ))}
{(message.charts ?? []).map((chart: AiChartSchema) => ( {(charts ?? []).map((chart: AiChartSchema) => (
<DynamicChart key={chart.id} chart={chart} /> <ArtifactErrorBoundary key={chart.id} title="图表">
<DynamicChart chart={chart} />
</ArtifactErrorBoundary>
))} ))}
{message.error && <Alert type="error" showIcon title={message.error} />} {message.error && <Alert type="error" showIcon title={message.error} />}
{message.cancelled && <Typography.Text type="secondary"></Typography.Text>} {message.cancelled && <Typography.Text type="secondary"></Typography.Text>}

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { Alert } from 'antd';
interface ArtifactErrorBoundaryProps {
children: React.ReactNode;
/** 制品标题(用于错误提示文案) */
title?: string;
}
interface ArtifactErrorBoundaryState {
hasError: boolean;
}
/**
* A2UI 制品(表单/审查卡/图表)渲染错误兜底:单个制品渲染失败只降级为
* 错误占位卡片,不影响同气泡内其他消息与制品。
*/
export class ArtifactErrorBoundary extends React.Component<
ArtifactErrorBoundaryProps,
ArtifactErrorBoundaryState
> {
state: ArtifactErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): ArtifactErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error('[ArtifactErrorBoundary] 制品渲染异常:', error, info.componentStack);
}
render(): React.ReactNode {
if (this.state.hasError) {
return (
<Alert
type="warning"
showIcon
title={this.props.title ? `${this.props.title}渲染失败` : '此内容渲染失败'}
description="请让 AI 重新生成,或刷新后重试。"
/>
);
}
return this.props.children;
}
}
export default ArtifactErrorBoundary;

View File

@@ -1,10 +1,12 @@
import React, { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'; import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { saveAs } from 'file-saver';
import { XCard, registerCatalog, type XAgentCommand_v0_9 } from '@ant-design/x-card'; import { XCard, registerCatalog, type XAgentCommand_v0_9 } from '@ant-design/x-card';
import { Button, Spin, Tag, Tooltip, Typography } from 'antd'; import { Button, Spin, Tag, Tooltip, Typography } from 'antd';
import { DownloadOutlined } from '@ant-design/icons'; import { DownloadOutlined } from '@ant-design/icons';
import type { EChartsType } from 'echarts/core'; import type { EChartsType } from 'echarts/core';
import type { EChartsOption } from '../../components/ECharts'; import type { EChartsOption } from '../../components/ECharts';
import type { AiChartSchema } from './types'; import type { AiChartSchema } from './types';
import { useXCardSurface } from './useSubmissionState';
// echarts 体积较大,仅在真正渲染图表时加载,避免打开 AI 抽屉就拉取 // echarts 体积较大,仅在真正渲染图表时加载,避免打开 AI 抽屉就拉取
const ReactECharts = lazy(() => import('../../components/ECharts')); const ReactECharts = lazy(() => import('../../components/ECharts'));
@@ -199,9 +201,35 @@ interface ChartPreviewProps {
* renders an ECharts option built from it. * renders an ECharts option built from it.
*/ */
const ChartPreview: React.FC<ChartPreviewProps> = ({ chart }) => { const ChartPreview: React.FC<ChartPreviewProps> = ({ chart }) => {
const option = useMemo<EChartsOption>(() => (chart ? buildOption(chart) : {}), [chart]); // 空/无数据时先短路,避免 buildOption 在空数据集上执行
const hasData = !!chart && !!chart.rows && chart.rows.length > 0;
const option = useMemo<EChartsOption>(
() => (chart && hasData ? buildOption(chart) : {}),
[chart, hasData],
);
const [instance, setInstance] = useState<EChartsType | null>(null); const [instance, setInstance] = useState<EChartsType | null>(null);
if (!chart) return null; if (!chart) return null;
// 空数据集:渲染明确占位,而不是一张空白图
if (!hasData) {
return (
<div className="ai-chat-chart-card">
<div className="ai-chat-chart-card__header">
<Typography.Text strong>{chart.title}</Typography.Text>
<Tag color="blue">{CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType}</Tag>
</div>
<div
style={{
height: 120,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography.Text type="secondary"></Typography.Text>
</div>
</div>
);
}
const downloadImage = () => { const downloadImage = () => {
if (!instance) return; if (!instance) return;
@@ -210,12 +238,7 @@ const ChartPreview: React.FC<ChartPreviewProps> = ({ chart }) => {
pixelRatio: 2, pixelRatio: 2,
backgroundColor: '#fff', backgroundColor: '#fff',
}); });
const link = document.createElement('a'); saveAs(url, `${chart.title || '图表'}.png`);
link.href = url;
link.download = `${chart.title || '图表'}.png`;
document.body.appendChild(link);
link.click();
link.remove();
}; };
return ( return (
@@ -254,32 +277,24 @@ export interface DynamicChartProps {
* so history replays identically. * so history replays identically.
*/ */
export const DynamicChart: React.FC<DynamicChartProps> = ({ chart }) => { export const DynamicChart: React.FC<DynamicChartProps> = ({ chart }) => {
const commandsRef = useRef<XAgentCommand_v0_9[]>([]); const sid = surfaceId(chart.id);
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]); const { commands, pushCommands } = useXCardSurface(sid);
const idRef = useRef<string>('');
useEffect(() => { useEffect(() => {
const sid = surfaceId(chart.id); const cmds: XAgentCommand_v0_9[] = [
if (idRef.current !== sid) { {
commandsRef.current = [];
idRef.current = sid;
}
const cmds = commandsRef.current;
if (cmds.length === 0) {
cmds.push({
version: 'v0.9', version: 'v0.9',
createSurface: { surfaceId: sid, catalogId: CHART_CATALOG_ID }, createSurface: { surfaceId: sid, catalogId: CHART_CATALOG_ID },
}); },
} {
cmds.push({
version: 'v0.9', version: 'v0.9',
updateDataModel: { updateDataModel: {
surfaceId: sid, surfaceId: sid,
path: '/chart', path: '/chart',
value: chart, value: chart,
}, },
}); },
cmds.push({ {
version: 'v0.9', version: 'v0.9',
updateComponents: { updateComponents: {
surfaceId: sid, surfaceId: sid,
@@ -291,9 +306,10 @@ export const DynamicChart: React.FC<DynamicChartProps> = ({ chart }) => {
}, },
], ],
}, },
}); },
setCommands([...cmds]); ];
}, [chart]); pushCommands(cmds);
}, [chart, pushCommands, sid]);
return ( return (
<div className="ai-chat-chart"> <div className="ai-chat-chart">

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo } from 'react';
import { import {
XCard, XCard,
registerCatalog, registerCatalog,
@@ -18,6 +18,7 @@ import {
} from 'antd'; } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { AiFormField, AiFormSchema } from './types'; import type { AiFormField, AiFormSchema } from './types';
import { useSubmissionState, useXCardSurface } from './useSubmissionState';
const FORM_CATALOG_ID = 'gongxue-form-catalog'; const FORM_CATALOG_ID = 'gongxue-form-catalog';
@@ -84,6 +85,7 @@ const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) =
); );
if (!form) return null; if (!form) return null;
const finished = Boolean(form.submitted) || form.status === 'submitted'; const finished = Boolean(form.submitted) || form.status === 'submitted';
const expired = form.status === 'expired';
const handleFinish = (values: Record<string, unknown>) => { const handleFinish = (values: Record<string, unknown>) => {
onAction?.('form:submit', { values: normalizeValues(form.fields, values) }); onAction?.('form:submit', { values: normalizeValues(form.fields, values) });
@@ -97,8 +99,10 @@ const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) =
{form.description} {form.description}
</Typography.Text> </Typography.Text>
)} )}
{finished ? ( {expired ? (
<Alert type="success" showIcon message="已提交AI 正在处理…" /> <Alert type="warning" showIcon title="表单已失效" description="此表单已被新的请求替代,请让助手重新生成。" />
) : finished ? (
<Alert type="success" showIcon title="已提交AI 正在处理…" />
) : ( ) : (
<Form <Form
layout="vertical" layout="vertical"
@@ -176,35 +180,25 @@ export interface DynamicFormProps {
* success/failure/loading transitions are pushed as incremental commands. * success/failure/loading transitions are pushed as incremental commands.
*/ */
export const DynamicForm: React.FC<DynamicFormProps> = ({ form, disabled, onSubmit }) => { export const DynamicForm: React.FC<DynamicFormProps> = ({ form, disabled, onSubmit }) => {
const [submitting, setSubmitting] = useState(false); const sid = surfaceId(form.id);
const [submitted, setSubmitted] = useState(false); const { submitting, submitted, error, run } = useSubmissionState();
const [error, setError] = useState<string | null>(null); const { commands, pushCommands } = useXCardSurface(sid);
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
const idRef = useRef<string>('');
useEffect(() => { useEffect(() => {
const sid = surfaceId(form.id); const cmds: XAgentCommand_v0_9[] = [
if (idRef.current !== sid) { {
commandsRef.current = [];
idRef.current = sid;
}
const cmds = commandsRef.current;
if (cmds.length === 0) {
cmds.push({
version: 'v0.9', version: 'v0.9',
createSurface: { surfaceId: sid, catalogId: FORM_CATALOG_ID }, createSurface: { surfaceId: sid, catalogId: FORM_CATALOG_ID },
}); },
} {
cmds.push({
version: 'v0.9', version: 'v0.9',
updateDataModel: { updateDataModel: {
surfaceId: sid, surfaceId: sid,
path: '/form', path: '/form',
value: { ...form, submitting, submitted, error }, value: { ...form, submitting, submitted, error },
}, },
}); },
cmds.push({ {
version: 'v0.9', version: 'v0.9',
updateComponents: { updateComponents: {
surfaceId: sid, surfaceId: sid,
@@ -217,22 +211,15 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({ form, disabled, onSubm
}, },
], ],
}, },
}); },
setCommands([...cmds]); ];
}, [disabled, error, form, submitted, submitting]); pushCommands(cmds);
}, [disabled, error, form, pushCommands, sid, submitted, submitting]);
const handleSubmit = async (values: Record<string, unknown>) => { const handleSubmit = (values: Record<string, unknown>) => {
if (submitting) return; void run(async () => {
setSubmitting(true);
setError(null);
try {
await onSubmit(values); await onSubmit(values);
setSubmitted(true); });
} catch (reason) {
setError(reason instanceof Error ? reason.message : '提交失败,请稍后重试');
} finally {
setSubmitting(false);
}
}; };
const handleAction = (payload: ActionPayload) => { const handleAction = (payload: ActionPayload) => {

View File

@@ -17,6 +17,7 @@ import {
type TableProps, type TableProps,
} from 'antd'; } from 'antd';
import type { AiReviewRow, AiReviewSchema, AiReviewSection, AiReviewSectionType } from './types'; import type { AiReviewRow, AiReviewSchema, AiReviewSection, AiReviewSectionType } from './types';
import { useXCardSurface } from './useSubmissionState';
import { import {
GROUP_STATUS_LABELS, GROUP_STATUS_LABELS,
SECTION_ORDER, SECTION_ORDER,
@@ -183,7 +184,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
<Alert <Alert
type="warning" type="warning"
showIcon showIcon
message="此导入预览已被新的预览替代,已失效" title="此导入预览已被新的预览替代,已失效"
description="如需导入,请使用最新的预览卡。" description="如需导入,请使用最新的预览卡。"
/> />
)} )}
@@ -341,7 +342,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
/> />
)} )}
{activeStatus === 'failed' && ( {activeStatus === 'failed' && (
<Alert type="error" showIcon message="本步导入失败,可重试" /> <Alert type="error" showIcon title="本步导入失败,可重试" />
)} )}
{activeSection.resultSummary && activeStatus === 'submitted' && ( {activeSection.resultSummary && activeStatus === 'submitted' && (
<Typography.Text type="secondary" className="ai-chat-review-card__step-result"> <Typography.Text type="secondary" className="ai-chat-review-card__step-result">
@@ -377,7 +378,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
</Popconfirm> </Popconfirm>
)} )}
</Flex> </Flex>
{submitted && <Alert type="success" showIcon message="已确认导入,数据已入库" />} {submitted && <Alert type="success" showIcon title="已确认导入,数据已入库" />}
{review.error && ( {review.error && (
<Alert <Alert
type="error" type="error"
@@ -430,9 +431,8 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
activeTypeRef.current = activeType; activeTypeRef.current = activeType;
const [localReview, setLocalReview] = useState<AiReviewSchema>(review); const [localReview, setLocalReview] = useState<AiReviewSchema>(review);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const commandsRef = useRef<XAgentCommand_v0_9[]>([]); const sid = surfaceId(localReview.id);
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]); const { commands, pushCommands } = useXCardSurface(sid);
const idRef = useRef<string>('');
useEffect(() => { useEffect(() => {
setLocalReview(review); setLocalReview(review);
@@ -455,19 +455,12 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
}, [review]); }, [review]);
useEffect(() => { useEffect(() => {
const sid = surfaceId(localReview.id); const cmds: XAgentCommand_v0_9[] = [
if (idRef.current !== sid) { {
commandsRef.current = [];
idRef.current = sid;
}
const cmds = commandsRef.current;
if (cmds.length === 0) {
cmds.push({
version: 'v0.9', version: 'v0.9',
createSurface: { surfaceId: sid, catalogId: REVIEW_CATALOG_ID }, createSurface: { surfaceId: sid, catalogId: REVIEW_CATALOG_ID },
}); },
} {
cmds.push({
version: 'v0.9', version: 'v0.9',
updateDataModel: { updateDataModel: {
surfaceId: sid, surfaceId: sid,
@@ -482,8 +475,8 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
error, error,
}, },
}, },
}); },
cmds.push({ {
version: 'v0.9', version: 'v0.9',
updateComponents: { updateComponents: {
surfaceId: sid, surfaceId: sid,
@@ -496,14 +489,17 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
}, },
], ],
}, },
}); },
setCommands([...cmds]); ];
pushCommands(cmds);
}, [ }, [
activeKey, activeKey,
activeType, activeType,
disabled, disabled,
error, error,
localReview, localReview,
pushCommands,
sid,
submitting, submitting,
submittingGroup, submittingGroup,
submittingKey, submittingKey,

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useIsMounted } from 'usehooks-ts';
interface LiteMermaidProps { interface LiteMermaidProps {
children: string; children: string;
@@ -11,9 +12,9 @@ interface LiteMermaidProps {
export function LiteMermaid({ children }: LiteMermaidProps) { export function LiteMermaid({ children }: LiteMermaidProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isMounted = useIsMounted();
useEffect(() => { useEffect(() => {
let cancelled = false;
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
@@ -22,21 +23,17 @@ export function LiteMermaid({ children }: LiteMermaidProps) {
const mermaid = (await import('mermaid')).default; const mermaid = (await import('mermaid')).default;
mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' }); mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' });
const { svg } = await mermaid.render(`mermaid-${crypto.randomUUID()}`, children); const { svg } = await mermaid.render(`mermaid-${crypto.randomUUID()}`, children);
if (!cancelled) { if (isMounted()) {
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml'); const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
container.replaceChildren(doc.documentElement); container.replaceChildren(doc.documentElement);
setError(null); setError(null);
} }
} catch (e) { } catch (e) {
if (!cancelled) { if (isMounted()) {
setError(e instanceof Error ? e.message : '图表渲染失败'); setError(e instanceof Error ? e.message : '图表渲染失败');
} }
} }
})(); })();
return () => {
cancelled = true;
};
}, [children]); }, [children]);
if (error) { if (error) {

View File

@@ -30,13 +30,19 @@ export const aiChatApi = {
`${basePath}/${conversationId}/messages/${messageId}`, `${basePath}/${conversationId}/messages/${messageId}`,
) )
).data, ).data,
uploadAttachment: async (file: File): Promise<AiAttachment> => { uploadAttachment: async (
file: File,
onProgress?: (percent: number) => void,
): Promise<AiAttachment> => {
const form = new FormData(); const form = new FormData();
form.append('file', file); form.append('file', file);
return ( return (
await api.post<AiApiResponse<AiAttachment>>('/ai/chat/attachments', form, { await api.post<AiApiResponse<AiAttachment>>('/ai/chat/attachments', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120_000, timeout: 120_000,
onUploadProgress: (event) => {
if (!onProgress || !event.total) return;
onProgress(Math.min(Math.round((event.loaded / event.total) * 100), 100));
},
}) })
).data; ).data;
}, },

View File

@@ -4,6 +4,7 @@ import { Bubble } from '@ant-design/x';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { aiBubbleRoles, conversationStatusMeta } from './AiChatDrawer'; import { aiBubbleRoles, conversationStatusMeta } from './AiChatDrawer';
import { AiMessageContent } from './AiMessageContent'; import { AiMessageContent } from './AiMessageContent';
import { ArtifactErrorBoundary } from './ArtifactErrorBoundary';
import { DynamicChart } from './DynamicChart'; import { DynamicChart } from './DynamicChart';
import { DynamicForm } from './DynamicForm'; import { DynamicForm } from './DynamicForm';
import { DynamicReview } from './DynamicReview'; import { DynamicReview } from './DynamicReview';
@@ -102,6 +103,38 @@ describe('AI chat bubble rendering', () => {
expect(container.textContent).toContain('已提交'); expect(container.textContent).toContain('已提交');
}); });
it('renders an expired A2UI form as disabled without submit action', async () => {
let submitted = false;
const el = document.createElement('div');
container = el;
document.body.appendChild(el);
root = createRoot(el);
await act(async () => {
root?.render(
<DynamicForm
form={{
id: 'form-expired',
title: '已失效的表单',
status: 'expired',
fields: [{ name: 'name', label: '姓名', type: 'input', required: true }],
}}
onSubmit={() => {
submitted = true;
}}
/>,
);
});
expect(el.textContent).toContain('已失效');
expect(el.querySelector('button[type="submit"]')).toBeNull();
await act(async () => {
const buttons = Array.from(el.querySelectorAll('button'));
buttons.forEach((button) => button.click());
});
expect(submitted).toBe(false);
});
it('renders an A2UI review card and submits via the confirm button', async () => { it('renders an A2UI review card and submits via the confirm button', async () => {
let submittedId: string | null = null; let submittedId: string | null = null;
const review: AiReviewSchema = { const review: AiReviewSchema = {
@@ -472,4 +505,49 @@ describe('AI chat bubble rendering', () => {
expect(container.textContent).toContain(label); expect(container.textContent).toContain(label);
expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull(); expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull();
}); });
it('renders an empty-data placeholder instead of a blank chart', async () => {
const chart: AiChartSchema = {
id: 'chart-empty',
title: '空图表',
chartType: 'bar',
columns: [{ key: 'name', title: '名称' }],
rows: [],
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<DynamicChart chart={chart} />);
});
expect(container.textContent).toContain('空图表');
expect(container.textContent).toContain('暂无数据');
expect(container.querySelector('canvas')).toBeNull();
});
it('degrades a single failing artifact to an error card without crashing the bubble', async () => {
const Bomb: React.FC = () => {
throw new Error('boom');
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
// 用无错误边界的兄弟节点 + 错误边界内的炸弹组件验证隔离
await act(async () => {
root?.render(
<div>
<div className="neighbor"></div>
<ArtifactErrorBoundary title="表单">
<Bomb />
</ArtifactErrorBoundary>
</div>,
);
});
expect(container.querySelector('.neighbor')?.textContent).toContain('正常内容');
expect(container.textContent).toContain('表单渲染失败');
});
}); });

View File

@@ -78,6 +78,58 @@ describe('AI chat history mapper', () => {
expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-9', title: '新增学生' }); expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-9', title: '新增学生' });
}); });
it('restores uiArtifacts from message metadata (统一协议)', () => {
const mapped = mapHistoryMessage({
id: 8,
role: 'assistant',
content: '请处理',
reasoningContent: null,
status: 'completed',
errorCode: null,
createdAt: '2026-07-23T00:00:00.000Z',
metadata: {
uiArtifacts: [
{
id: 'form-10',
type: 'form',
status: 'submitted',
messageId: 8,
conversationId: 3,
payload: {
id: 'form-10',
title: '新增学生',
status: 'submitted',
fields: [],
},
},
{
id: 'review-10',
type: 'review',
status: 'expired',
messageId: 8,
conversationId: 3,
payload: {
id: 'review-10',
title: '旧预览',
status: 'expired',
sections: [],
},
},
],
},
});
expect(mapped.message.uiArtifacts).toHaveLength(2);
expect(mapped.message.uiArtifacts?.[0].payload).toMatchObject({
id: 'form-10',
status: 'submitted',
});
expect(mapped.message.uiArtifacts?.[1].payload).toMatchObject({
id: 'review-10',
status: 'expired',
});
});
it('restores a persisted A2UI review from message metadata', () => { it('restores a persisted A2UI review from message metadata', () => {
const mapped = mapHistoryMessage({ const mapped = mapHistoryMessage({
id: 6, id: 6,
@@ -139,4 +191,5 @@ describe('AI chat history mapper', () => {
expect(mapped.message.charts).toHaveLength(1); expect(mapped.message.charts).toHaveLength(1);
expect(mapped.message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'bar' }); expect(mapped.message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'bar' });
}); });
}); });

View File

@@ -3,11 +3,13 @@ import type {
AiChatMessage, AiChatMessage,
AiChatMessageStatus, AiChatMessageStatus,
AiChartSchema, AiChartSchema,
AiArtifactSchema,
AiFormSchema, AiFormSchema,
AiMessageRecord, AiMessageRecord,
AiReviewSchema, AiReviewSchema,
AiToolRun, AiToolRun,
} from './types'; } from './types';
import { mergeArtifactIntoMessage } from './uiArtifacts';
function mapStatus(record: AiMessageRecord): AiChatMessageStatus { function mapStatus(record: AiMessageRecord): AiChatMessageStatus {
if (record.status === 'pending') return 'loading'; if (record.status === 'pending') return 'loading';
@@ -50,11 +52,18 @@ function historyCharts(record: AiMessageRecord): AiChartSchema[] | undefined {
return [a2uiChart as AiChartSchema]; return [a2uiChart as AiChartSchema];
} }
function historyArtifacts(record: AiMessageRecord) {
const artifacts = record.metadata?.uiArtifacts;
if (!Array.isArray(artifacts)) return undefined;
return artifacts.filter(
(item): item is AiArtifactSchema =>
Boolean(item) && typeof item === 'object' && typeof (item as AiArtifactSchema).id === 'string',
);
}
export function mapHistoryMessage(record: AiMessageRecord): MessageInfo<AiChatMessage> { export function mapHistoryMessage(record: AiMessageRecord): MessageInfo<AiChatMessage> {
return { const artifacts = historyArtifacts(record);
id: record.id, const baseMessage = {
status: mapStatus(record),
message: {
id: record.id, id: record.id,
role: record.role, role: record.role,
content: record.content || '', content: record.content || '',
@@ -68,6 +77,15 @@ export function mapHistoryMessage(record: AiMessageRecord): MessageInfo<AiChatMe
metadata: record.metadata, metadata: record.metadata,
error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined, error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined,
cancelled: record.status === 'cancelled', cancelled: record.status === 'cancelled',
}, };
if (artifacts) {
for (const artifact of artifacts) {
mergeArtifactIntoMessage(baseMessage as AiChatMessage, artifact);
}
}
return {
id: record.id,
status: mapStatus(record),
message: baseMessage as AiChatMessage,
}; };
} }

View File

@@ -123,37 +123,42 @@ describe('AI chat SSE message reducer', () => {
expect(message.id).toBe(9); expect(message.id).toBe(9);
}); });
it('merges ui.form events into the assistant message by id', () => { it('merges ui.artifact events into uiArtifacts by id', () => {
const form = {
id: 'form-1',
title: '新增学生',
submitLabel: '提交创建',
fields: [
{ name: 'name', label: '姓名', type: 'input', required: true },
{ name: 'gender', label: '性别', type: 'select', options: [{ label: '男', value: 'male' }] },
],
};
let message = reduceAiSseMessage(undefined, { let message = reduceAiSseMessage(undefined, {
event: 'ui.form', event: 'ui.artifact',
data: JSON.stringify({ messageId: 8, form }),
});
message = reduceAiSseMessage(message, {
event: 'ui.form',
data: JSON.stringify({ messageId: 8, form: { ...form, id: 'form-1' } }),
});
message = reduceAiSseMessage(message, {
event: 'ui.form',
data: JSON.stringify({ data: JSON.stringify({
messageId: 8, messageId: 12,
form: { id: 'form-2', title: '入住确认', fields: [] }, artifact: {
id: 'form-1',
type: 'form',
status: 'pending',
messageId: 12,
conversationId: 3,
payload: { id: 'form-1', title: '新增学生', fields: [] },
},
}),
});
message = reduceAiSseMessage(message, {
event: 'ui.artifact',
data: JSON.stringify({
messageId: 12,
artifact: {
id: 'review-1',
type: 'review',
status: 'expired',
messageId: 12,
conversationId: 3,
payload: { id: 'review-1', title: '旧导入预览', status: 'expired', sections: [] },
},
}), }),
}); });
expect(message.forms).toHaveLength(2); expect(message.uiArtifacts).toHaveLength(2);
expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' }); expect(message.uiArtifacts?.[0].payload).toMatchObject({ id: 'form-1', title: '新增学生' });
expect(message.forms?.[1]).toMatchObject({ id: 'form-2' }); expect(message.uiArtifacts?.[1].payload).toMatchObject({ id: 'review-1', status: 'expired' });
}); });
it('restores a persisted form from message.completed metadata', () => { it('restores a persisted form from message.completed metadata', () => {
const message = reduceAiSseMessage(undefined, { const message = reduceAiSseMessage(undefined, {
event: 'message.completed', event: 'message.completed',
@@ -177,43 +182,6 @@ describe('AI chat SSE message reducer', () => {
expect(message.forms?.[0].id).toBe('form-9'); expect(message.forms?.[0].id).toBe('form-9');
}); });
it('merges ui.review events into the assistant message and updates by id', () => {
const review = {
id: 'review-1',
title: '开学导入',
summary: '来自报名 Excel',
status: 'pending',
sections: [
{
key: 'students',
type: 'students',
title: '学生',
kind: 'table',
columns: [
{ key: 'name', title: '姓名' },
{ key: 'phone', title: '手机号' },
],
rows: [{ name: '张三', phone: '13800138000' }],
issues: [],
},
],
};
let message = reduceAiSseMessage(undefined, {
event: 'ui.review',
data: JSON.stringify({ messageId: 8, review }),
});
message = reduceAiSseMessage(message, {
event: 'ui.review',
data: JSON.stringify({
messageId: 8,
review: { ...review, status: 'submitted', resultSummary: '{"students":{"created":1}}' },
}),
});
expect(message.reviews).toHaveLength(1);
expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'submitted' });
});
it('shows model retrying state and clears it when content starts', () => { it('shows model retrying state and clears it when content starts', () => {
let message = reduceAiSseMessage(undefined, { let message = reduceAiSseMessage(undefined, {
event: 'model.retrying', event: 'model.retrying',
@@ -265,37 +233,6 @@ describe('AI chat SSE message reducer', () => {
expect(message.reviews?.[0]).toMatchObject({ id: 'review-9', title: '批量导入' }); expect(message.reviews?.[0]).toMatchObject({ id: 'review-9', title: '批量导入' });
}); });
it('merges ui.chart events into the assistant message by id', () => {
const chart = {
id: 'chart-1',
title: '各班级人数',
chartType: 'bar',
columns: [
{ key: 'className', title: '班级' },
{ key: 'count', title: '人数' },
],
rows: [
{ className: '一班', count: 20 },
{ className: '二班', count: 15 },
],
};
let message = reduceAiSseMessage(undefined, {
event: 'ui.chart',
data: JSON.stringify({ messageId: 8, chart }),
});
message = reduceAiSseMessage(message, {
event: 'ui.chart',
data: JSON.stringify({
messageId: 8,
chart: { ...chart, id: 'chart-2', title: '女生人数' },
}),
});
expect(message.charts).toHaveLength(2);
expect(message.charts?.[0]).toMatchObject({ id: 'chart-1', chartType: 'bar' });
expect(message.charts?.[1]).toMatchObject({ id: 'chart-2' });
});
it('restores persisted charts from message.completed metadata', () => { it('restores persisted charts from message.completed metadata', () => {
const message = reduceAiSseMessage(undefined, { const message = reduceAiSseMessage(undefined, {
event: 'message.completed', event: 'message.completed',
@@ -423,15 +360,16 @@ describe('AI chat SSE message reducer', () => {
} }
}); });
it('routes submit-time ui.review to the original message instead of the streaming one', () => { it('routes ui.artifact targeting another message to the external handler', () => {
const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream'); const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream');
const onExternalReview = vi.fn(); const onExternalArtifact = vi.fn();
provider.onExternalReview = onExternalReview; provider.onExternalArtifact = onExternalArtifact;
const review = { const artifact = {
id: 'review-1', id: 'artifact-1',
title: '批量导入', type: 'form',
status: 'submitted', status: 'submitted',
sections: [], messageId: 12,
payload: { id: 'form-1', title: '批量导入', status: 'submitted' },
}; };
const origin = { const origin = {
id: 13, id: 13,
@@ -440,31 +378,37 @@ describe('AI chat SSE message reducer', () => {
reasoningContent: '', reasoningContent: '',
toolRuns: [], toolRuns: [],
attachments: [], attachments: [],
reviews: [], uiArtifacts: [],
}; };
const next = provider.transformMessage({ const next = provider.transformMessage({
originMessage: origin, originMessage: origin,
chunk: { event: 'ui.review', data: JSON.stringify({ messageId: 12, review }) }, chunk: { event: 'ui.artifact', data: JSON.stringify({ messageId: 12, artifact }) },
status: 'updating', status: 'updating',
chunks: [], chunks: [],
responseHeaders: {} as Headers, responseHeaders: {} as Headers,
}); });
expect(onExternalReview).toHaveBeenCalledWith(12, review); expect(onExternalArtifact).toHaveBeenCalledWith(12, artifact);
expect(next).toBe(origin); expect(next).toBe(origin);
expect(next.reviews ?? []).toHaveLength(0); expect(next.uiArtifacts ?? []).toHaveLength(0);
}); });
it('routes ui.review without an origin message to the external handler', () => { it('routes ui.artifact without an origin message to the external handler', () => {
const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream'); const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream');
const onExternalReview = vi.fn(); const onExternalArtifact = vi.fn();
provider.onExternalReview = onExternalReview; provider.onExternalArtifact = onExternalArtifact;
const next = provider.transformMessage({ const next = provider.transformMessage({
chunk: { chunk: {
event: 'ui.review', event: 'ui.artifact',
data: JSON.stringify({ data: JSON.stringify({
messageId: 12, messageId: 12,
review: { id: 'review-1', title: '批量导入', status: 'submitted', sections: [] }, artifact: {
id: 'artifact-1',
type: 'review',
status: 'submitted',
messageId: 12,
payload: { id: 'review-1', title: '批量导入', status: 'submitted', sections: [] },
},
}), }),
}, },
status: 'updating', status: 'updating',
@@ -472,11 +416,11 @@ describe('AI chat SSE message reducer', () => {
responseHeaders: {} as Headers, responseHeaders: {} as Headers,
}); });
expect(onExternalReview).toHaveBeenCalledWith( expect(onExternalArtifact).toHaveBeenCalledWith(
12, 12,
expect.objectContaining({ id: 'review-1' }), expect.objectContaining({ id: 'artifact-1' }),
); );
expect(next.reviews ?? []).toHaveLength(0); expect(next.uiArtifacts ?? []).toHaveLength(0);
}); });
it('tolerates non-JSON event data', () => { it('tolerates non-JSON event data', () => {

View File

@@ -6,231 +6,10 @@ import {
} from '@ant-design/x-sdk'; } from '@ant-design/x-sdk';
import { usePermissionStore } from '../../store/permission/permissionStore'; import { usePermissionStore } from '../../store/permission/permissionStore';
import { useUserStore } from '../../store/user/userStore'; import { useUserStore } from '../../store/user/userStore';
import type { import type { AiArtifactSchema, AiChatInput, AiChatMessage, AiReviewSchema, AiSseChunk } from './types';
AiAttachment, import { emptyAssistant, parseSsePayload, reduceAiSseMessage } from './sseReducer';
AiChatInput,
AiChatMessage,
AiChartSchema,
AiFormSchema,
AiModelRetryInfo,
AiReviewSchema,
AiSseChunk,
AiToolRun,
} from './types';
interface AiSsePayload { export { parseSsePayload, reduceAiSseMessage };
messageId?: number;
userMessageId?: number;
assistantMessageId?: number;
delta?: string;
content?: string;
reasoningContent?: string | null;
toolCallId?: string;
toolName?: string;
skillKey?: string | null;
status?: string;
summary?: string | null;
durationMs?: number | null;
attachment?: AiAttachment;
form?: AiFormSchema;
review?: AiReviewSchema;
chart?: AiChartSchema;
wizard?: unknown;
retry?: AiModelRetryInfo;
message?:
| string
| {
id?: number;
content?: string;
reasoningContent?: string | null;
status?: string;
toolRuns?: AiToolRun[];
attachments?: AiAttachment[];
replyToMessageId?: number | null;
metadata?: Record<string, unknown> | null;
};
error?: string;
}
function emptyAssistant(): AiChatMessage {
return {
role: 'assistant',
content: '',
reasoningContent: '',
toolRuns: [],
attachments: [],
forms: [],
};
}
function mergeForms(
current: AiFormSchema[] | undefined,
incoming: AiFormSchema | AiFormSchema[] | undefined,
): AiFormSchema[] {
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
if (!items.length) return current ?? [];
const next = [...(current ?? [])];
for (const item of items) {
if (item && typeof item === 'object' && !next.some((existing) => existing.id === item.id)) {
next.push(item);
}
}
return next;
}
function mergeById<T extends { id: string }>(
current: T[] | undefined,
incoming: T | T[] | undefined,
): T[] {
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
if (!items.length) return current ?? [];
const next = [...(current ?? [])];
for (const item of items) {
if (!item || typeof item !== 'object') continue;
const index = next.findIndex((existing) => existing.id === item.id);
if (index === -1) {
next.push(item);
} else {
next[index] = item;
}
}
return next;
}
export function parseSsePayload(chunk?: AiSseChunk): {
event: string;
payload: AiSsePayload;
} {
if (!chunk) return { event: '', payload: {} };
const event = chunk.event?.trim() || 'message';
if (!chunk.data || chunk.data === '[DONE]') return { event, payload: {} };
try {
const parsed: unknown = JSON.parse(chunk.data);
return {
event,
payload: parsed && typeof parsed === 'object' ? (parsed as AiSsePayload) : {},
};
} catch {
return { event, payload: { delta: chunk.data } };
}
}
function upsertToolRun(
toolRuns: AiToolRun[],
payload: AiSsePayload,
fallbackStatus: AiToolRun['status'],
): AiToolRun[] {
const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`;
const next: AiToolRun = {
toolCallId,
toolName: payload.toolName || '查询工具',
skillKey: payload.skillKey,
status: (payload.status as AiToolRun['status']) || fallbackStatus,
summary: payload.summary,
resultSummary: fallbackStatus === 'running' ? undefined : payload.summary,
argumentsSummary: fallbackStatus === 'running' ? payload.summary : undefined,
durationMs: payload.durationMs,
};
const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId);
if (index === -1) return [...toolRuns, next];
return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item));
}
function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRun[]): AiToolRun[] {
if (!toolRuns) return fallback;
return toolRuns.map((tool) => ({
...tool,
status: tool.status === 'error' ? 'failed' : tool.status,
summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary,
}));
}
function applyMessagePayload(
message: AiChatMessage,
nested: AiSsePayload['message'],
payload: AiSsePayload,
): void {
if (typeof nested !== 'object' || nested === null) return;
message.forms = mergeForms(
message.forms,
(nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
);
message.reviews = mergeById<AiReviewSchema>(
message.reviews,
(nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
);
message.charts = mergeById<AiChartSchema>(
message.charts,
(nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
);
message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;
message.metadata = nested.metadata ?? message.metadata;
}
export function reduceAiSseMessage(
originMessage: AiChatMessage | undefined,
chunk?: AiSseChunk,
): AiChatMessage {
const message = originMessage ? { ...originMessage } : emptyAssistant();
const { event, payload } = parseSsePayload(chunk);
if (event === 'message.created') {
const nested = typeof payload.message === 'object' ? payload.message : undefined;
message.id = nested?.id ?? payload.assistantMessageId ?? payload.messageId ?? message.id;
message.content = nested?.content ?? message.content;
message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments;
applyMessagePayload(message, nested, payload);
} else if (event === 'reasoning.delta') {
message.retrying = null;
message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';
} else if (event === 'content.delta') {
message.retrying = null;
message.content += payload.delta ?? payload.content ?? '';
} else if (event === 'model.retrying' && payload.retry) {
message.retrying = payload.retry;
} else if (event === 'ui.form' && payload.form) {
message.forms = mergeForms(message.forms, payload.form);
} else if (event === 'ui.review' && payload.review) {
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload.review);
} else if (event === 'ui.chart' && payload.chart) {
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
} else if (event === 'ui.import_wizard' && payload.wizard) {
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };
} else if (event === 'tool.started') {
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running');
} else if (event === 'tool.completed') {
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'success');
} else if (event === 'tool.failed') {
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'failed');
} else if (event === 'attachment.processed' && payload.attachment) {
if (!message.attachments.some((item) => item.id === payload.attachment?.id)) {
message.attachments = [...message.attachments, payload.attachment];
}
} else if (event === 'message.completed') {
const nested = typeof payload.message === 'object' ? payload.message : undefined;
message.id = nested?.id ?? payload.messageId ?? message.id;
message.content = nested?.content ?? payload.content ?? message.content;
message.reasoningContent =
nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments;
applyMessagePayload(message, nested, payload);
message.retrying = null;
} else if (event === 'message.cancelled') {
message.id = payload.messageId ?? message.id;
message.cancelled = true;
message.retrying = null;
} else if (event === 'error') {
message.retrying = null;
message.error =
(typeof payload.message === 'string' ? payload.message : undefined) ||
payload.error ||
'AI 回答生成失败';
}
return message;
}
export async function authenticatedFetch( export async function authenticatedFetch(
input: RequestInfo | URL, input: RequestInfo | URL,
@@ -301,6 +80,8 @@ export async function authenticatedFetch(
} }
const response = await fetch(requestInput, { ...requestInit, headers }); const response = await fetch(requestInput, { ...requestInit, headers });
if (response.status === 401) { if (response.status === 401) {
// 提示由登录页读取展示:直接弹 toast 会被跳转销毁
sessionStorage.setItem('login_expired_hint', '1');
useUserStore.getState().logout(); useUserStore.getState().logout();
usePermissionStore.getState().clearPermissions(); usePermissionStore.getState().clearPermissions();
window.location.href = '/login'; window.location.href = '/login';
@@ -315,6 +96,7 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
> { > {
/** Routes events that target another (already streamed) message. */ /** Routes events that target another (already streamed) message. */
onExternalReview?: (messageId: number, review: AiReviewSchema) => void; onExternalReview?: (messageId: number, review: AiReviewSchema) => void;
onExternalArtifact?: (messageId: number, artifact: AiArtifactSchema) => void;
constructor(url: string, onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void) { constructor(url: string, onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void) {
super({ super({
@@ -401,14 +183,12 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
transformMessage(info: TransformMessage<AiChatMessage, AiSseChunk>): AiChatMessage { transformMessage(info: TransformMessage<AiChatMessage, AiSseChunk>): AiChatMessage {
const { event, payload } = parseSsePayload(info.chunk); const { event, payload } = parseSsePayload(info.chunk);
if ( if (
event === 'ui.review' && event === 'ui.artifact' &&
payload.review && payload.artifact &&
typeof payload.messageId === 'number' && typeof payload.messageId === 'number' &&
info.originMessage?.id !== payload.messageId info.originMessage?.id !== payload.messageId
) { ) {
// The submitted review belongs to the original assistant message; this.onExternalArtifact?.(payload.messageId, payload.artifact);
// do not merge it into the message currently being streamed.
this.onExternalReview?.(payload.messageId, payload.review);
return info.originMessage ?? emptyAssistant(); return info.originMessage ?? emptyAssistant();
} }
return reduceAiSseMessage(info.originMessage, info.chunk); return reduceAiSseMessage(info.originMessage, info.chunk);

View File

@@ -0,0 +1,197 @@
import type {
AiArtifactSchema,
AiAttachment,
AiChartSchema,
AiChatMessage,
AiFormSchema,
AiModelRetryInfo,
AiReviewSchema,
AiSseChunk,
AiToolRun,
} from './types';
import { mergeArtifactIntoMessage, mergeById } from './uiArtifacts';
export interface AiSsePayload {
messageId?: number;
userMessageId?: number;
assistantMessageId?: number;
delta?: string;
content?: string;
reasoningContent?: string | null;
toolCallId?: string;
toolName?: string;
skillKey?: string | null;
status?: string;
summary?: string | null;
durationMs?: number | null;
attachment?: AiAttachment;
artifact?: AiArtifactSchema;
wizard?: unknown;
retry?: AiModelRetryInfo;
message?:
| string
| {
id?: number;
content?: string;
reasoningContent?: string | null;
status?: string;
toolRuns?: AiToolRun[];
attachments?: AiAttachment[];
replyToMessageId?: number | null;
metadata?: Record<string, unknown> | null;
};
error?: string;
}
export function emptyAssistant(): AiChatMessage {
return {
role: 'assistant',
content: '',
reasoningContent: '',
toolRuns: [],
attachments: [],
forms: [],
uiArtifacts: [],
};
}
export function parseSsePayload(chunk?: AiSseChunk): {
event: string;
payload: AiSsePayload;
} {
if (!chunk) return { event: '', payload: {} };
const event = chunk.event?.trim() || 'message';
if (!chunk.data || chunk.data === '[DONE]') return { event, payload: {} };
try {
const parsed: unknown = JSON.parse(chunk.data);
return {
event,
payload: parsed && typeof parsed === 'object' ? (parsed as AiSsePayload) : {},
};
} catch {
return { event, payload: { delta: chunk.data } };
}
}
function upsertToolRun(
toolRuns: AiToolRun[],
payload: AiSsePayload,
fallbackStatus: AiToolRun['status'],
): AiToolRun[] {
const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`;
const next: AiToolRun = {
toolCallId,
toolName: payload.toolName || '查询工具',
skillKey: payload.skillKey,
status: (payload.status as AiToolRun['status']) || fallbackStatus,
summary: payload.summary,
resultSummary: fallbackStatus === 'running' ? undefined : payload.summary,
argumentsSummary: fallbackStatus === 'running' ? payload.summary : undefined,
durationMs: payload.durationMs,
};
const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId);
if (index === -1) return [...toolRuns, next];
return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item));
}
function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRun[]): AiToolRun[] {
if (!toolRuns) return fallback;
return toolRuns.map((tool) => ({
...tool,
status: tool.status === 'error' ? 'failed' : tool.status,
summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary,
}));
}
function applyMessagePayload(
message: AiChatMessage,
nested: AiSsePayload['message'],
): void {
if (typeof nested !== 'object' || nested === null) return;
// 历史消息兼容:老数据只有 metadata.a2uiForm/a2uiReview/a2uiChart
// 恢复为 legacy 字段供渲染层在 uiArtifacts 为空时回退使用。
message.forms = mergeById<AiFormSchema>(
message.forms,
nested.metadata?.a2uiForm as AiFormSchema | undefined,
);
message.reviews = mergeById<AiReviewSchema>(
message.reviews,
nested.metadata?.a2uiReview as AiReviewSchema | undefined,
);
message.charts = mergeById<AiChartSchema>(
message.charts,
nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined,
);
const artifacts = nested.metadata?.uiArtifacts;
if (Array.isArray(artifacts)) {
for (const artifact of artifacts) {
if (artifact && typeof artifact === 'object' && typeof artifact.id === 'string') {
mergeArtifactIntoMessage(message, artifact as AiArtifactSchema);
}
}
}
message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;
message.metadata = nested.metadata ?? message.metadata;
}
export function reduceAiSseMessage(
originMessage: AiChatMessage | undefined,
chunk?: AiSseChunk,
): AiChatMessage {
const message = originMessage ? { ...originMessage } : emptyAssistant();
const { event, payload } = parseSsePayload(chunk);
if (event === 'message.created') {
const nested = typeof payload.message === 'object' ? payload.message : undefined;
message.id = nested?.id ?? payload.assistantMessageId ?? payload.messageId ?? message.id;
message.content = nested?.content ?? message.content;
message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments;
applyMessagePayload(message, nested);
} else if (event === 'reasoning.delta') {
message.retrying = null;
message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';
} else if (event === 'content.delta') {
message.retrying = null;
message.content += payload.delta ?? payload.content ?? '';
} else if (event === 'model.retrying' && payload.retry) {
message.retrying = payload.retry;
} else if (event === 'ui.artifact' && payload.artifact) {
// 统一 artifact 事件legacy 列表由渲染层从 uiArtifacts 派生。
mergeArtifactIntoMessage(message, payload.artifact);
} else if (event === 'ui.import_wizard' && payload.wizard) {
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };
} else if (event === 'tool.started') {
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running');
} else if (event === 'tool.completed') {
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'success');
} else if (event === 'tool.failed') {
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'failed');
} else if (event === 'attachment.processed' && payload.attachment) {
if (!message.attachments.some((item) => item.id === payload.attachment?.id)) {
message.attachments = [...message.attachments, payload.attachment];
}
} else if (event === 'message.completed') {
const nested = typeof payload.message === 'object' ? payload.message : undefined;
message.id = nested?.id ?? payload.messageId ?? message.id;
message.content = nested?.content ?? payload.content ?? message.content;
message.reasoningContent =
nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments;
applyMessagePayload(message, nested);
message.retrying = null;
} else if (event === 'message.cancelled') {
message.id = payload.messageId ?? message.id;
message.cancelled = true;
message.retrying = null;
} else if (event === 'error') {
message.retrying = null;
message.error =
(typeof payload.message === 'string' ? payload.message : undefined) ||
payload.error ||
'AI 回答生成失败';
}
return message;
}

View File

@@ -138,6 +138,16 @@
inset: 68px 0 auto; inset: 68px 0 auto;
} }
.ai-chat-sidebar__empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 24px 16px;
}
.ai-chat-sidebar__footer { .ai-chat-sidebar__footer {
flex: none; flex: none;
display: flex; display: flex;
@@ -165,6 +175,18 @@
min-height: 0; min-height: 0;
} }
.ai-chat-main .ai-chat-toolbar {
order: 0;
}
.ai-chat-main .ai-chat-messages {
order: 1;
}
.ai-chat-main .ai-chat-composer {
order: 2;
}
.ai-chat-toolbar { .ai-chat-toolbar {
display: flex; display: flex;
flex: 0 0 48px; flex: 0 0 48px;

View File

@@ -52,7 +52,7 @@ export interface AiFormSchema {
description?: string | null; description?: string | null;
submitLabel?: string; submitLabel?: string;
fields: AiFormField[]; fields: AiFormField[];
status?: 'pending' | 'submitted'; status?: 'pending' | 'submitted' | 'expired';
} }
export interface AiReviewColumn { export interface AiReviewColumn {
@@ -98,6 +98,26 @@ export interface AiChartSchema {
rows: AiReviewRow[]; rows: AiReviewRow[];
} }
export type AiArtifactType =
| 'form'
| 'review'
| 'chart'
| 'import_wizard';
export type AiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled';
export interface AiArtifactSchema<T = unknown> {
id: string;
type: AiArtifactType;
status: AiArtifactStatus;
messageId: number;
conversationId?: number;
payload: T;
createdAt?: string | null;
submittedAt?: string | null;
supersededBy?: string | null;
}
export interface AiImportWizard { export interface AiImportWizard {
runId: string; runId: string;
fileName: string; fileName: string;
@@ -151,9 +171,14 @@ export interface AiChatMessage {
reasoningContent: string; reasoningContent: string;
toolRuns: AiToolRun[]; toolRuns: AiToolRun[];
attachments: AiAttachment[]; attachments: AiAttachment[];
/** @deprecated 仅历史消息兼容读取metadata.a2uiForm新数据统一走 uiArtifacts */
forms?: AiFormSchema[]; forms?: AiFormSchema[];
/** @deprecated 仅历史消息兼容读取metadata.a2uiReview新数据统一走 uiArtifacts */
reviews?: AiReviewSchema[]; reviews?: AiReviewSchema[];
/** @deprecated 仅历史消息兼容读取metadata.a2uiChart新数据统一走 uiArtifacts */
charts?: AiChartSchema[]; charts?: AiChartSchema[];
/** 统一 A2UI 制品协议(唯一事实源) */
uiArtifacts?: AiArtifactSchema[];
replyToMessageId?: number | null; replyToMessageId?: number | null;
metadata?: Record<string, unknown> | null; metadata?: Record<string, unknown> | null;
retrying?: AiModelRetryInfo | null; retrying?: AiModelRetryInfo | null;

View File

@@ -0,0 +1,68 @@
import type {
AiArtifactSchema,
AiChartSchema,
AiChatMessage,
AiFormSchema,
AiReviewSchema,
} from './types';
export function mergeById<T extends { id: string }>(
current: T[] | undefined,
incoming: T | T[] | undefined,
): T[] {
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
if (!items.length) return current ?? [];
const next = [...(current ?? [])];
for (const item of items) {
if (!item || typeof item !== 'object') continue;
const index = next.findIndex((existing) => existing.id === item.id);
if (index === -1) {
next.push(item);
} else {
next[index] = item;
}
}
return next;
}
/**
* 将统一 artifact 归入 uiArtifacts。
*
* 注意:不再派发到 legacy 列表forms/reviews/charts——渲染层从
* uiArtifacts 派生legacy 字段仅保留给历史消息metadata 中只有
* a2uiForm/a2uiReview/a2uiChart 的老数据)作兼容读取。
*/
export function mergeArtifactIntoMessage(
message: AiChatMessage,
artifact: AiArtifactSchema,
): AiChatMessage {
message.uiArtifacts = mergeById<AiArtifactSchema>(message.uiArtifacts, artifact);
return message;
}
/**
* 从 uiArtifacts 派生 legacy 列表(渲染用)。
* 仅当 message 上没有显式 legacy 数据(历史消息)时,渲染层回退到 message.forms 等。
*/
export function deriveForms(message: AiChatMessage): AiFormSchema[] {
return (message.uiArtifacts ?? [])
.filter((artifact) => artifact.type === 'form')
.map((artifact) => artifact.payload)
.filter((payload): payload is AiFormSchema => Boolean(payload) && typeof payload === 'object');
}
export function deriveReviews(message: AiChatMessage): AiReviewSchema[] {
return (message.uiArtifacts ?? [])
.filter((artifact) => artifact.type === 'review')
.map((artifact) => artifact.payload)
.filter(
(payload): payload is AiReviewSchema => Boolean(payload) && typeof payload === 'object',
);
}
export function deriveCharts(message: AiChatMessage): AiChartSchema[] {
return (message.uiArtifacts ?? [])
.filter((artifact) => artifact.type === 'chart')
.map((artifact) => artifact.payload)
.filter((payload): payload is AiChartSchema => Boolean(payload) && typeof payload === 'object');
}

View File

@@ -10,6 +10,7 @@ import { aiChatApi } from './api';
import { AiMessageContent } from './AiMessageContent'; import { AiMessageContent } from './AiMessageContent';
import { mapHistoryMessage } from './message-mappers'; import { mapHistoryMessage } from './message-mappers';
import { GongxueAiChatProvider } from './provider'; import { GongxueAiChatProvider } from './provider';
import { mergeArtifactIntoMessage } from './uiArtifacts';
import { import {
emptyAssistant, emptyAssistant,
MessageHoverActions, MessageHoverActions,
@@ -110,6 +111,11 @@ export function useAiChatMessageActions({
}, },
})); }));
}; };
provider.onExternalArtifact = (messageId, artifact) => {
setMessage(messageId, (info) => ({
message: mergeArtifactIntoMessage(info.message, artifact),
}));
};
}, [provider, setMessage]); }, [provider, setMessage]);
requestingRef.current = isRequesting; requestingRef.current = isRequesting;
@@ -271,7 +277,7 @@ export function useAiChatMessageActions({
}, },
}); });
}, },
[activeId, isRequesting, refreshConversations, removeMessage], [activeId, isRequesting, refreshConversations, removeMessage, modal],
); );
const confirmEditMessage = useCallback( const confirmEditMessage = useCallback(
@@ -290,6 +296,10 @@ export function useAiChatMessageActions({
setEditingMessageId(null); setEditingMessageId(null);
if (content === messageInfo.message.content) return; if (content === messageInfo.message.content) return;
// 编辑旧消息会删除其后的全部消息并重新生成,需先告知用户
const index = messagesRef.current.findIndex((item) => item.id === messageInfo.id);
const followingCount = index >= 0 ? messagesRef.current.length - index - 1 : 0;
const doEdit = () => {
setMessage(messageInfo.id, (info) => ({ setMessage(messageInfo.id, (info) => ({
message: { message: {
...info.message, ...info.message,
@@ -297,7 +307,6 @@ export function useAiChatMessageActions({
metadata: { ...info.message.metadata, edited: true }, metadata: { ...info.message.metadata, edited: true },
}, },
})); }));
const index = messagesRef.current.findIndex((item) => item.id === messageInfo.id);
if (index >= 0) { if (index >= 0) {
for (const item of messagesRef.current.slice(index + 1)) removeMessage(item.id); for (const item of messagesRef.current.slice(index + 1)) removeMessage(item.id);
} }
@@ -309,11 +318,24 @@ export function useAiChatMessageActions({
reasoningEffort: deepThinking ? 'high' : null, reasoningEffort: deepThinking ? 'high' : null,
editMessageId: messageId, editMessageId: messageId,
}); });
};
if (followingCount > 0) {
modal.confirm({
title: '编辑消息将删除后续内容',
content: `编辑这条消息会删除其后的 ${followingCount} 条消息并重新生成回答,此操作不可恢复。`,
okText: '继续编辑',
cancelText: '取消',
onOk: doEdit,
});
return;
}
doEdit();
}, },
[ [
activeConversation?.lockedSkillKey, activeConversation?.lockedSkillKey,
activeId, activeId,
deepThinking, deepThinking,
modal,
removeMessage, removeMessage,
requestWithStatus, requestWithStatus,
setMessage, setMessage,
@@ -321,8 +343,9 @@ export function useAiChatMessageActions({
); );
const submitForm = useCallback( const submitForm = useCallback(
(form: AiFormSchema, values: Record<string, unknown>) => { async (form: AiFormSchema, values: Record<string, unknown>): Promise<void> => {
if (!activeId || isRequesting) return; if (!activeId) throw new Error('当前会话不可用,请稍后重试');
if (isRequesting) throw new Error('请等待当前 AI 回复完成后再提交表单');
requestWithStatus({ requestWithStatus({
message: '表单提交', message: '表单提交',
attachmentIds: [], attachmentIds: [],
@@ -336,8 +359,9 @@ export function useAiChatMessageActions({
); );
const submitReview = useCallback( const submitReview = useCallback(
(reviewId: string, reviewTitle?: string) => { async (reviewId: string, reviewTitle?: string): Promise<void> => {
if (!activeId || isRequesting) return; if (!activeId) throw new Error('当前会话不可用,请稍后重试');
if (isRequesting) throw new Error('请等待当前 AI 回复完成后再确认导入');
requestWithStatus({ requestWithStatus({
message: '确认批量导入', message: '确认批量导入',
attachmentIds: [], attachmentIds: [],
@@ -418,7 +442,9 @@ export function useAiChatMessageActions({
return; return;
} }
try { try {
const uploaded = await aiChatApi.uploadAttachment(file); const uploaded = await aiChatApi.uploadAttachment(file, (percent) => {
options.onProgress?.({ percent });
});
setAttachments((items) => [...items, uploaded]); setAttachments((items) => [...items, uploaded]);
options.onSuccess?.(uploaded, file); options.onSuccess?.(uploaded, file);
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,161 @@
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it } from 'vitest';
import { useSubmissionState, useXCardSurface } from './useSubmissionState';
// 项目未安装 @testing-library/react用 createRoot + harness 组件暴露 hook API
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
let api: ReturnType<typeof useSubmissionState> | null = null;
let surface: ReturnType<typeof useXCardSurface> | null = null;
let surfaceId = 'surface-test';
function Harness() {
api = useSubmissionState();
surface = useXCardSurface(surfaceId);
return null;
}
function renderHarness(): void {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(<Harness />);
});
}
afterEach(async () => {
if (root) await act(async () => root?.unmount());
container?.remove();
root = null;
container = null;
api = null;
surface = null;
surfaceId = 'surface-test';
});
describe('useSubmissionState', () => {
it('tracks submitting during the task and succeeds afterwards', async () => {
renderHarness();
let resolveTask: () => void = () => undefined;
const task = () =>
new Promise<void>((resolve) => {
resolveTask = resolve;
});
let promise: Promise<void> | undefined;
act(() => {
promise = api?.run(task);
});
expect(api?.submitting).toBe(true);
expect(api?.error).toBeNull();
await act(async () => {
resolveTask();
await promise;
});
expect(api?.submitting).toBe(false);
expect(api?.submitted).toBe(true);
expect(api?.error).toBeNull();
});
it('captures the error message and keeps submitted false on failure', async () => {
renderHarness();
const failing = () => {
throw new Error('接口 500');
};
await act(async () => {
await api?.run(failing);
});
expect(api?.submitting).toBe(false);
expect(api?.submitted).toBe(false);
expect(api?.error).toBe('接口 500');
});
it('normalizes non-Error rejections to a generic message', async () => {
renderHarness();
const failing = () => Promise.reject('raw string');
await act(async () => {
await api?.run(failing);
});
expect(api?.error).toBe('提交失败,请稍后重试');
});
it('ignores re-entrant calls while a task is in flight', async () => {
renderHarness();
let resolveTask: () => void = () => undefined;
const task = () =>
new Promise<void>((resolve) => {
resolveTask = resolve;
});
let secondRan = false;
act(() => {
void api?.run(task);
void api?.run(() => {
secondRan = true;
});
});
expect(secondRan).toBe(false);
await act(async () => {
resolveTask();
});
expect(api?.submitted).toBe(true);
});
it('reset clears submitted and error states', async () => {
renderHarness();
await act(async () => {
await api?.run(() => undefined);
});
expect(api?.submitted).toBe(true);
act(() => {
api?.reset();
});
expect(api?.submitted).toBe(false);
expect(api?.error).toBeNull();
});
});
describe('useXCardSurface', () => {
it('deduplicates createSurface commands for the same surface id', () => {
renderHarness();
act(() => {
surface?.pushCommands([
{ version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'catalog' } },
{ version: 'v0.9', updateDataModel: { surfaceId: 'surface-test', path: '/x', value: 1 } },
]);
surface?.pushCommands([
{ version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'catalog' } },
{ version: 'v0.9', updateDataModel: { surfaceId: 'surface-test', path: '/x', value: 2 } },
]);
});
const createCommands = surface?.commands.filter((command) => 'createSurface' in command);
expect(createCommands).toHaveLength(1);
expect(surface?.commands).toHaveLength(3);
});
it('resets the command stream when the surface id changes', () => {
renderHarness();
act(() => {
surface?.pushCommands([
{ version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'c' } },
]);
});
expect(surface?.commands).toHaveLength(1);
surfaceId = 'surface-other';
act(() => {
root?.render(<Harness />);
surface?.pushCommands([
{ version: 'v0.9', createSurface: { surfaceId: 'surface-other', catalogId: 'c' } },
]);
});
expect(surface?.commands).toHaveLength(1);
});
});

View File

@@ -0,0 +1,89 @@
import { useCallback, useRef, useState } from 'react';
import { useLayoutEffect } from 'react';
import type { XAgentCommand_v0_9 } from '@ant-design/x-card';
/**
* 提交状态管理:收敛 DynamicForm / DynamicReview 中重复的
* submitting / submitted / error 状态与「防重复提交 + 失败可重试」逻辑。
*
* 用法:
* const { submitting, submitted, error, run } = useSubmissionState();
* const handleSubmit = (values) => run(async () => { await onSubmit(values); });
*/
export function useSubmissionState() {
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const submittingRef = useRef(false);
const run = useCallback(async (task: () => Promise<void> | void) => {
if (submittingRef.current) return;
submittingRef.current = true;
setSubmitting(true);
setError(null);
try {
await task();
setSubmitted(true);
} catch (reason) {
setError(reason instanceof Error ? reason.message : '提交失败,请稍后重试');
} finally {
submittingRef.current = false;
setSubmitting(false);
}
}, []);
const reset = useCallback(() => {
setSubmitted(false);
setError(null);
}, []);
return { submitting, submitted, error, run, reset };
}
/**
* A2UI surface 的 XCard commands 增量更新生命周期:
* 每个 surface 只创建一次,后续通过 updateDataModel / updateComponents 增量更新。
*
* 用法:
* const { pushCommands } = useXCardSurface(surfaceId);
* useEffect(() => {
* pushCommands([
* { version: 'v0.9', createSurface: { surfaceId, catalogId } },
* { version: 'v0.9', updateDataModel: { surfaceId, path: '/x', value } },
* { version: 'v0.9', updateComponents: { surfaceId, components } },
* ]);
* }, [value]);
*/
export function useXCardSurface(surfaceId: string) {
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
const idRef = useRef<string>('');
const pushCommands = useCallback(
(cmds: XAgentCommand_v0_9[]) => {
if (cmds.length === 0) return;
// 同一 surface 的 createSurface 命令只允许出现一次,自动去重
const hasSurface = commandsRef.current.some(
(c) => 'createSurface' in c && c.createSurface.surfaceId === surfaceId,
);
const filtered = hasSurface
? cmds.filter((c) => !('createSurface' in c))
: cmds;
commandsRef.current = [...commandsRef.current, ...filtered];
setCommands([...commandsRef.current]);
},
[surfaceId],
);
const surfaceKey = surfaceId;
// 渲染期保持纯函数ref 变更放到 layout effect 里
useLayoutEffect(() => {
if (idRef.current !== surfaceKey) {
// 组件复用到新 surface 时,清空历史命令重新初始化
commandsRef.current = [];
idRef.current = surfaceKey;
}
}, [surfaceKey]);
return { commands, pushCommands };
}

View File

@@ -0,0 +1,69 @@
import { getRoleDomains } from '../../auth/menu-policy';
/**
* 按用户角色生成 AI 助手欢迎语,引导用户使用与其岗位匹配的业务闭环。
* 优先级:教师 > 住宿运营 > 教务 > 教室运营 > 系统/超管 > 兜底。
*/
export function welcomeDescription(roles: readonly string[], permissions: readonly string[]): string {
const domains = getRoleDomains(roles, permissions);
if (domains.has('teacher')) {
return '我可以帮你查询今日课程、拉取钉钉考勤、查看排课。课程开始后就能看到打卡结果。';
}
if (domains.has('accommodation')) {
return '我可以帮你完成「宿舍档案 → 学生 → 入住 → 费用 → 账单」的住宿计费闭环,先告诉我你手头有什么数据。';
}
if (domains.has('academic')) {
return '我可以帮你完成「学生档案 → 分班 → 排课 → 考勤」的教学闭环,支持 Excel 批量导入与预览确认。';
}
if (domains.has('classroom')) {
return '我可以帮你管理教室排期与租赁订单,查询占用情况,避免时间冲突。';
}
if (domains.has('system') || domains.has('super')) {
return '我是恭学 AI 助手。我可以查询经营数据、管理业务数据、生成批量导入预览——所有写操作都会先经你确认。';
}
return '我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。';
}
/**
* 与角色匹配的业务闭环引导示例(用于 Prompts 建议话术)。
* 返回空数组表示当前角色无匹配示例。
*/
export function workflowPromptExamples(
roles: readonly string[],
permissions: readonly string[],
): { label: string; description: string }[] {
const domains = getRoleDomains(roles, permissions);
const examples: { label: string; description: string }[] = [];
if (domains.has('academic')) {
examples.push(
{ label: '帮我从 Excel 导入学生并完成分班', description: '教学闭环' },
{ label: '查一下这周有哪些班级还没排课', description: '教学闭环' },
);
}
if (domains.has('accommodation')) {
examples.push(
{ label: '帮我从 Excel 导入学生并安排入住', description: '住宿计费闭环' },
{ label: '查一下本月还没生成账单的入住学生', description: '住宿计费闭环' },
);
}
if (domains.has('classroom')) {
examples.push(
{ label: '查一下这间教室本周的占用情况', description: '教室运营' },
);
}
if (domains.has('teacher')) {
examples.push(
{ label: '今天我有哪几节课?', description: '今日教学' },
);
}
if (domains.has('system') || domains.has('super')) {
examples.push(
{ label: '看一下本月的经营概览', description: '数据面板' },
{ label: '帮我梳理宿舍计费的完整流程', description: '业务流程' },
);
}
return examples;
}

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Button, Result } from 'antd';
interface AppErrorBoundaryProps {
children: React.ReactNode;
/** 自定义降级内容;不传则使用默认错误卡片 */
fallback?: React.ReactNode;
}
interface AppErrorBoundaryState {
hasError: boolean;
}
/**
* 全局渲染错误兜底:捕获子树内的渲染异常,展示可恢复的错误卡片,
* 避免单个页面/组件崩溃导致整个应用白屏。
*/
export class AppErrorBoundary extends React.Component<
AppErrorBoundaryProps,
AppErrorBoundaryState
> {
state: AppErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): AppErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error('[AppErrorBoundary] 渲染异常:', error, info.componentStack);
}
render(): React.ReactNode {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<Result
status="error"
title="页面出现异常"
subTitle="请刷新页面重试;若问题持续,请联系管理员。"
extra={
<Button type="primary" onClick={() => window.location.reload()}>
</Button>
}
/>
);
}
return this.props.children;
}
}
export default AppErrorBoundary;

View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
import { Button, Tooltip } from 'antd';
import { VerticalAlignTopOutlined } from '@ant-design/icons';
import { useEventCallback, useEventListener } from 'usehooks-ts';
/**
* 全局「回到顶部」浮动按钮:长列表滚动超过 400px 后出现。
* 尊重系统「减少动态效果」偏好,平滑滚动仅在未开启该偏好时使用。
*/
export const BackTop: React.FC<{ threshold?: number }> = ({ threshold = 400 }) => {
const [visible, setVisible] = useState(false);
const updateVisible = useEventCallback(() => setVisible(window.scrollY > threshold));
useEffect(() => {
updateVisible();
}, [threshold, updateVisible]);
useEventListener('scroll', updateVisible, undefined, { passive: true });
if (!visible) return null;
const scrollToTop = () => {
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
window.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' });
};
return (
<Tooltip title="回到顶部">
<Button
type="primary"
shape="circle"
icon={<VerticalAlignTopOutlined />}
aria-label="回到顶部"
onClick={scrollToTop}
style={{ position: 'fixed', right: 24, bottom: 48, zIndex: 1000 }}
/>
</Tooltip>
);
};
export default BackTop;

View File

@@ -0,0 +1,105 @@
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router';
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import DefaultRoute from './DefaultRoute';
import { RouteKeeper } from './RouteKeeper';
import { usePermissionStore } from '../store/permission/permissionStore';
import { useUserStore } from '../store/user/userStore';
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
beforeAll(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
beforeEach(() => {
// 清掉可能由其他测试文件遗留的持久化数据,保证会话状态可控
Object.keys(localStorage).forEach((key) => localStorage.removeItem(key));
useUserStore.setState({ token: null, user: null });
usePermissionStore.setState({ permissions: [], status: 'unknown' });
useUserStore.getState().setSession('test-token', {
id: 1,
username: 'admin',
roles: ['超级管理员'],
});
usePermissionStore.getState().writePermissions(['dashboard:view', 'student:view']);
});
afterEach(async () => {
if (root) await act(async () => root?.unmount());
container?.remove();
root = null;
container = null;
});
function PageDashboard() {
const navigate = useNavigate();
return (
<div>
<div data-testid="page-dashboard"></div>
<button data-testid="go-students" onClick={() => navigate('/students')}>
</button>
</div>
);
}
function PageStudents() {
const navigate = useNavigate();
return (
<div>
<div data-testid="page-students"></div>
<button data-testid="go-home" onClick={() => navigate('/')}>
</button>
</div>
);
}
function Harness() {
return (
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<RouteKeeper />}>
<Route index element={<DefaultRoute />} />
<Route path="dashboard" element={<PageDashboard />} />
<Route path="students" element={<PageStudents />} />
</Route>
</Routes>
</MemoryRouter>
);
}
async function renderHarness() {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<Harness />);
});
}
describe('DefaultRoute keep-alive regression', () => {
it('redirects to landing page once and does not hijack later navigations', async () => {
await renderHarness();
// 首次进入 '/' 应跳转到落地页 /dashboard且只跳一次
expect(document.querySelector('[data-testid="page-dashboard"]')).not.toBeNull();
// 再导航到 /students不应被保活的首页节点拉回 /dashboard
await act(async () => {
(document.querySelector('[data-testid="go-students"]') as HTMLButtonElement).click();
});
expect(document.querySelector('[data-testid="page-students"]')).not.toBeNull();
// 回到 '/' 时仍应再次跳转到落地页
await act(async () => {
(document.querySelector('[data-testid="go-home"]') as HTMLButtonElement).click();
});
expect(document.querySelector('[data-testid="page-dashboard"]')).not.toBeNull();
});
});

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React, { useEffect, useRef } from 'react';
import { Navigate } from 'react-router'; import { useLocation, useNavigate } from 'react-router';
import { Result, Spin } from 'antd'; import { Result, Spin } from 'antd';
import { usePermission } from '../hooks/usePermission'; import { usePermission } from '../hooks/usePermission';
import { findRoleAwareLandingPath } from '../auth/menu-policy'; import { findRoleAwareLandingPath } from '../auth/menu-policy';
@@ -8,14 +8,31 @@ import { useUserStore } from '../store/user/userStore';
const DefaultRoute: React.FC = () => { const DefaultRoute: React.FC = () => {
const { permissions, permissionsReady } = usePermission(); const { permissions, permissionsReady } = usePermission();
const roles = useUserStore((state) => state.user?.roles ?? []); const roles = useUserStore((state) => state.user?.roles ?? []);
const navigate = useNavigate();
const { pathname } = useLocation();
const firstPath = permissionsReady ? findRoleAwareLandingPath(roles, permissions) : null;
// 用 effect 导航替代声明式 <Navigate>RouteKeeper 会把首页index节点保活缓存
// 声明式 <Navigate> 在缓存节点随路由变化重渲染时会反复触发,导致首次进入系统后
// 点击任何按钮都被拉回 dashboard必须刷新页面才能恢复。这里仅在确实处于首页
// 且已计算出落点时跳转navigate 通过 ref 持有,避免其每次渲染变化导致 effect 空转。
const navigateRef = useRef(navigate);
navigateRef.current = navigate;
useEffect(() => {
if (pathname === '/' && firstPath) {
navigateRef.current(firstPath, { replace: true });
}
}, [pathname, firstPath]);
if (!permissionsReady) { if (!permissionsReady) {
return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />; return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
} }
const firstPath = findRoleAwareLandingPath(roles, permissions); if (!firstPath) {
if (firstPath) return <Navigate to={firstPath} replace />;
return ( return (
<Result status="403" title="暂无可访问功能" subTitle="请联系管理员为当前账号分配功能权限" /> <Result status="403" title="暂无可访问功能" subTitle="请联系管理员为当前账号分配功能权限" />
); );
}
return null;
}; };
export default DefaultRoute; export default DefaultRoute;

View File

@@ -50,13 +50,15 @@ interface EChartsProps {
const ECharts: React.FC<EChartsProps> = ({ option, style, className, onReady }) => { const ECharts: React.FC<EChartsProps> = ({ option, style, className, onReady }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const optionRef = useRef(option);
optionRef.current = option;
const onReadyRef = useRef(onReady); const onReadyRef = useRef(onReady);
onReadyRef.current = onReady; onReadyRef.current = onReady;
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const chart = echarts.init(containerRef.current); const chart = echarts.init(containerRef.current);
chart.setOption(option); chart.setOption(optionRef.current);
onReadyRef.current?.(chart); onReadyRef.current?.(chart);
const observer = new ResizeObserver(() => chart.resize()); const observer = new ResizeObserver(() => chart.resize());
observer.observe(containerRef.current); observer.observe(containerRef.current);

View File

@@ -3,6 +3,8 @@ import { DatePicker, Input, InputNumber, Select, Spin, Tooltip } from 'antd';
import dayjs, { type Dayjs } from 'dayjs'; import dayjs, { type Dayjs } from 'dayjs';
import equal from 'fast-deep-equal'; import equal from 'fast-deep-equal';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { useTimeout } from 'usehooks-ts';
import { useEditableCellStore } from '../../store/editableCell/editableCellStore';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import './style.css'; import './style.css';
import { getErrorMessage } from '../../utils/error'; import { getErrorMessage } from '../../utils/error';
@@ -39,9 +41,6 @@ export interface EditableCellProps<Value = unknown> {
onSave: (value: Value) => Promise<void>; onSave: (value: Value) => Promise<void>;
} }
let activeCell: { id: string; save: () => Promise<boolean> } | null = null;
let replayingOutsideAction = false;
export function normalizeEditableValue(value: unknown, editor: EditableCellEditor) { export function normalizeEditableValue(value: unknown, editor: EditableCellEditor) {
if (editor === 'date') return value ? dayjs(value as string) : null; if (editor === 'date') return value ? dayjs(value as string) : null;
if (editor === 'date-range') if (editor === 'date-range')
@@ -102,6 +101,10 @@ const EditableCell = <Value,>({
const [draft, setDraft] = useState<unknown>(() => const [draft, setDraft] = useState<unknown>(() =>
normalizeEditableValue(formatValue ? formatValue(value) : value, editor), normalizeEditableValue(formatValue ? formatValue(value) : value, editor),
); );
// 保存成功后短暂显示「撤销」入口:记录保存前的序列化旧值
const [undoMeta, setUndoMeta] = useState<{ serializedPrevious: unknown } | null>(null);
// 撤销入口 6 秒后自动消失useTimeout 在 undoMeta 置空/组件卸载时自动清理
useTimeout(() => setUndoMeta(null), undoMeta ? 6_000 : null);
const enabled = !disabled && (!permission || hasPermission(permission)); const enabled = !disabled && (!permission || hasPermission(permission));
const original = useMemo( const original = useMemo(
@@ -115,7 +118,7 @@ const EditableCell = <Value,>({
const cancel = useCallback(() => { const cancel = useCallback(() => {
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor)); setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
if (activeCell?.id === idRef.current) activeCell = null; useEditableCellStore.getState().clearIfActive(idRef.current);
setEditing(false); setEditing(false);
}, [editor, formatValue, value]); }, [editor, formatValue, value]);
@@ -128,15 +131,18 @@ const EditableCell = <Value,>({
return false; return false;
} }
if (editableValuesEqual(serialized, original)) { if (editableValuesEqual(serialized, original)) {
if (activeCell?.id === idRef.current) activeCell = null; useEditableCellStore.getState().clearIfActive(idRef.current);
setEditing(false); setEditing(false);
return true; return true;
} }
setSaving(true); setSaving(true);
const previousValue = original;
try { try {
await onSave(parseValue ? parseValue(serialized) : (serialized as Value)); await onSave(parseValue ? parseValue(serialized) : (serialized as Value));
if (activeCell?.id === idRef.current) activeCell = null; useEditableCellStore.getState().clearIfActive(idRef.current);
setEditing(false); setEditing(false);
// 提供 6 秒内的撤销入口把旧值再保存一次useTimeout 负责到时自动清除
setUndoMeta({ serializedPrevious: previousValue });
return true; return true;
} catch (error) { } catch (error) {
message.error(getErrorMessage(error, '保存失败')); message.error(getErrorMessage(error, '保存失败'));
@@ -152,16 +158,14 @@ const EditableCell = <Value,>({
useEffect(() => { useEffect(() => {
const cellId = idRef.current; const cellId = idRef.current;
if (editing && activeCell?.id === cellId) activeCell.save = save; if (editing) useEditableCellStore.getState().updateActiveSave(cellId, save);
return () => { return () => useEditableCellStore.getState().clearIfActive(cellId);
if (activeCell?.id === cellId) activeCell = null;
};
}, [editing, save]); }, [editing, save]);
useEffect(() => { useEffect(() => {
if (!editing) return; if (!editing) return;
const onPointerDown = (event: PointerEvent) => { const onPointerDown = (event: PointerEvent) => {
if (replayingOutsideAction) return; if (useEditableCellStore.getState().replayingOutsideAction) return;
if (rootRef.current?.contains(event.target as Node) || isEditorOverlay(event.target)) return; if (rootRef.current?.contains(event.target as Node) || isEditorOverlay(event.target)) return;
const actionTarget = const actionTarget =
event.target instanceof Element event.target instanceof Element
@@ -177,10 +181,10 @@ const EditableCell = <Value,>({
event.stopPropagation(); event.stopPropagation();
void save().then((saved) => { void save().then((saved) => {
if (!saved) return; if (!saved) return;
replayingOutsideAction = true; useEditableCellStore.getState().setReplayingOutsideAction(true);
actionTarget.click(); actionTarget.click();
queueMicrotask(() => { queueMicrotask(() => {
replayingOutsideAction = false; useEditableCellStore.getState().setReplayingOutsideAction(false);
}); });
}); });
}; };
@@ -192,15 +196,33 @@ const EditableCell = <Value,>({
const beginEdit = async () => { const beginEdit = async () => {
if (!enabled || saving) return; if (!enabled || saving) return;
const { activeCell } = useEditableCellStore.getState();
if (activeCell && activeCell.id !== idRef.current) { if (activeCell && activeCell.id !== idRef.current) {
const saved = await activeCell.save(); const saved = await activeCell.save();
if (!saved) return; if (!saved) return;
} }
activeCell = { id: idRef.current, save }; useEditableCellStore.getState().setActiveCell({ id: idRef.current, save });
// 重新进入编辑时清掉上一次的撤销入口
setUndoMeta(null);
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor)); setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
setEditing(true); setEditing(true);
}; };
const handleUndo = async () => {
if (!undoMeta) return;
setUndoMeta(null);
try {
await onSave(
parseValue
? parseValue(undoMeta.serializedPrevious)
: (undoMeta.serializedPrevious as Value),
);
message.success('已撤销修改');
} catch (error) {
message.error(getErrorMessage(error, '撤销失败'));
}
};
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => { const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'touch' || editing) return; if (event.pointerType !== 'touch' || editing) return;
touchStartRef.current = { touchStartRef.current = {
@@ -241,6 +263,16 @@ const EditableCell = <Value,>({
return; return;
} }
if (event.key === 'Enter' && editor !== 'textarea') { if (event.key === 'Enter' && editor !== 'textarea') {
// 这些编辑器会自己消费 Enter确认/提交选中值),不重复触发单元格保存
if (
editor === 'select' ||
editor === 'multi-select' ||
editor === 'tags' ||
editor === 'date' ||
editor === 'date-range'
) {
return;
}
event.preventDefault(); event.preventDefault();
await save(); await save();
return; return;
@@ -308,7 +340,23 @@ const EditableCell = <Value,>({
{editing ? ( {editing ? (
<Spin spinning={saving}>{control}</Spin> <Spin spinning={saving}>{control}</Spin>
) : ( ) : (
<Tooltip title={enabled ? '双击编辑,触屏双击编辑' : undefined}>{children}</Tooltip> <Tooltip title={enabled ? '双击编辑,触屏双击编辑' : undefined}>
<span className="editable-cell-display">
{children}
{undoMeta ? (
<button
type="button"
className="editable-cell-undo"
onClick={(event) => {
event.stopPropagation();
void handleUndo();
}}
>
</button>
) : null}
</span>
</Tooltip>
)} )}
</div> </div>
); );

View File

@@ -5,6 +5,29 @@
align-items: center; align-items: center;
} }
.editable-cell-display {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
width: 100%;
}
.editable-cell-undo {
flex: none;
border: none;
background: transparent;
color: #1677ff;
font-size: 12px;
padding: 0;
cursor: pointer;
white-space: nowrap;
}
.editable-cell-undo:hover {
text-decoration: underline;
}
.editable-cell--enabled { .editable-cell--enabled {
cursor: cell; cursor: cell;
touch-action: manipulation; touch-action: manipulation;

View File

@@ -14,6 +14,7 @@ import {
Descriptions, Descriptions,
Flex, Flex,
Modal, Modal,
Progress,
Select, Select,
Space, Space,
Spin, Spin,
@@ -34,11 +35,13 @@ import {
importErrorReportUrl, importErrorReportUrl,
previewImportStep, previewImportStep,
} from '../../api/imports'; } from '../../api/imports';
import { saveAs } from 'file-saver';
import { import {
STEP_FIELDS, STEP_FIELDS,
type ImportPreviewResult, type ImportPreviewResult,
type ImportReceipt, type ImportReceipt,
type ImportRunDetail, type ImportRunDetail,
type ImportStageRequest,
type ImportStepKey, type ImportStepKey,
} from './types'; } from './types';
@@ -99,12 +102,7 @@ async function downloadErrorReport(runId: string, stepKey?: ImportStepKey): Prom
}); });
if (!response.ok) throw new Error('错误报告下载失败'); if (!response.ok) throw new Error('错误报告下载失败');
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); saveAs(blob, `导入错误报告-${runId.slice(0, 8)}.csv`);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `导入错误报告-${runId.slice(0, 8)}.csv`;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
} }
export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
@@ -115,6 +113,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
const [run, setRun] = useState<ImportRunDetail | null>(null); const [run, setRun] = useState<ImportRunDetail | null>(null);
const [loadingRun, setLoadingRun] = useState(false); const [loadingRun, setLoadingRun] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadPercent, setUploadPercent] = useState(0);
const [activeStepKey, setActiveStepKey] = useState<ImportStepKey | null>(null); const [activeStepKey, setActiveStepKey] = useState<ImportStepKey | null>(null);
const [sheetSelection, setSheetSelection] = useState<Record<string, string[]>>({}); const [sheetSelection, setSheetSelection] = useState<Record<string, string[]>>({});
const [mappingDraft, setMappingDraft] = useState<Record<string, Record<string, string>>>({}); const [mappingDraft, setMappingDraft] = useState<Record<string, Record<string, string>>>({});
@@ -185,20 +184,41 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
return [...headers]; return [...headers];
}, [run, activeStepKey, sheetSelection]); }, [run, activeStepKey, sheetSelection]);
const handleUpload: UploadProps['customRequest'] = async (options) => { /** 创建导入任务并加载详情:统一处理上传进度与 loading 状态。成功返回 run 详情,失败返回 null */
const file = options.file as File; const uploadRun = async (
file: File,
options: {
source: 'ai' | 'manual';
conversationId?: number;
stages?: ImportStageRequest[];
mapping?: Record<string, Record<string, string>>;
},
errorMessage: string,
): Promise<ImportRunDetail | null> => {
setUploading(true); setUploading(true);
setUploadPercent(0);
try { try {
const detail = await createImportRun(file, { source: 'manual' }); const detail = await createImportRun(file, {
...options,
onProgress: (percent) => setUploadPercent(percent),
});
await loadRun(detail.id); await loadRun(detail.id);
message.success(`已识别 ${detail.sheets.length} 个工作表`); return detail;
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '文件上传失败'); message.error(error instanceof Error ? error.message : errorMessage);
return null;
} finally { } finally {
setUploading(false); setUploading(false);
setUploadPercent(0);
} }
}; };
const handleUpload: UploadProps['customRequest'] = async (options) => {
const file = options.file as File;
const detail = await uploadRun(file, { source: 'manual' }, '文件上传失败');
if (detail) message.success(`已识别 ${detail.sheets.length} 个工作表`);
};
const handlePreview = async () => { const handlePreview = async () => {
if (!run || !activeStepKey || !activeStep) return; if (!run || !activeStepKey || !activeStep) return;
const mapping = mappingDraft[activeStepKey] ?? {}; const mapping = mappingDraft[activeStepKey] ?? {};
@@ -255,20 +275,16 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
const handleReupload = async (file: File) => { const handleReupload = async (file: File) => {
if (!run || !activeStepKey) return; if (!run || !activeStepKey) return;
setUploading(true); const detail = await uploadRun(
try { file,
const detail = await createImportRun(file, { {
source: 'manual', source: 'manual',
stages: [{ stepKey: activeStepKey, sheet: sheetSelection[activeStepKey]?.[0] }], stages: [{ stepKey: activeStepKey, sheets: sheetSelection[activeStepKey] ?? [] }],
mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} }, mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} },
}); },
await loadRun(detail.id); '重新上传失败',
message.success('已重新上传,并保留原列映射'); );
} catch (error) { if (detail) message.success('已重新上传,并保留原列映射');
message.error(error instanceof Error ? error.message : '重新上传失败');
} finally {
setUploading(false);
}
}; };
const previewRows = useMemo(() => { const previewRows = useMemo(() => {
@@ -390,7 +406,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
<Alert <Alert
type="info" type="info"
showIcon showIcon
message="上传 Excel 后,系统会自动识别工作表并按业务依赖分阶段(学生/宿舍 → 入住/换宿)。每一阶段都需要先预览、再确认,确认后才会写入数据库。" title="上传 Excel 后,系统会自动识别工作表并按业务依赖分阶段(学生/宿舍 → 入住/换宿)。每一阶段都需要先预览、再确认,确认后才会写入数据库。"
/> />
<Upload.Dragger <Upload.Dragger
accept=".xlsx,.csv" accept=".xlsx,.csv"
@@ -405,6 +421,20 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
<p className="ant-upload-text"> .xlsx / .csv </p> <p className="ant-upload-text"> .xlsx / .csv </p>
<p className="ant-upload-hint"> 10MB.xls .xlsx</p> <p className="ant-upload-hint"> 10MB.xls .xlsx</p>
</Upload.Dragger> </Upload.Dragger>
{uploading ? (
<Flex vertical gap={4} style={{ marginTop: 8 }}>
<Progress
percent={uploadPercent}
size="small"
status={uploadPercent > 0 && uploadPercent < 100 ? 'active' : 'normal'}
/>
<Typography.Text type="secondary" style={{ fontSize: 12, textAlign: 'center' }}>
{uploadPercent > 0 && uploadPercent < 100
? `正在上传 ${uploadPercent}%...`
: '正在上传并解析文件...'}
</Typography.Text>
</Flex>
) : null}
</Space> </Space>
) : ( ) : (
<Space orientation="vertical" size={16} style={{ width: '100%' }}> <Space orientation="vertical" size={16} style={{ width: '100%' }}>
@@ -456,7 +486,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
</Flex> </Flex>
) : allCommitted ? ( ) : allCommitted ? (
<Space orientation="vertical" size={12} style={{ width: '100%' }}> <Space orientation="vertical" size={12} style={{ width: '100%' }}>
<Alert type="success" showIcon message="全部阶段已提交完成" /> <Alert type="success" showIcon title="全部阶段已提交完成" />
<Descriptions <Descriptions
bordered bordered
size="small" size="small"
@@ -607,7 +637,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
)} )}
</Space> </Space>
) : ( ) : (
<Alert type="warning" showIcon message="当前没有可处理的阶段" /> <Alert type="warning" showIcon title="当前没有可处理的阶段" />
)} )}
</Space> </Space>
)} )}

View File

@@ -36,11 +36,21 @@ export interface ImportRunDetail {
createdAt: string; createdAt: string;
sheets: ImportSheetMeta[]; sheets: ImportSheetMeta[];
steps: ImportStepDetail[]; steps: ImportStepDetail[];
settings?: {
mapping?: Partial<Record<ImportStepKey, Record<string, string>>>;
organization?: string | null;
updateExisting?: boolean;
duplicatePolicy?: 'error' | 'skip';
skipUnmatched?: boolean;
};
} }
export interface ImportStageRequest { export interface ImportStageRequest {
stepKey: ImportStepKey; stepKey: ImportStepKey;
/** 兼容旧调用:单个工作表名。 */
sheet?: string; sheet?: string;
/** 一个阶段可包含多张工作表;与 sheet 二选一sheets 优先)。 */
sheets?: string[];
headerRow?: number; headerRow?: number;
} }

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useImmer } from 'use-immer'; import { useImmer } from 'use-immer';
import { Button, Form, Input, Modal, Select, Spin, Steps, Typography } from 'antd'; import { App, Button, Form, Input, Modal, Select, Spin, Steps, Typography } from 'antd';
import { CloudUploadOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons'; import { CloudUploadOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
import api from '../api'; import api from '../api';
import { message } from '../ui/app-message'; import { message } from '../ui/app-message';
@@ -30,6 +30,7 @@ interface MatchModalProps {
} }
const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplied }) => { const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplied }) => {
const { modal } = App.useApp();
const { hasPermission, hasAllPermissions, permissionsReady } = usePermission(); const { hasPermission, hasAllPermissions, permissionsReady } = usePermission();
const canTriggerSync = hasPermission('sync:trigger'); const canTriggerSync = hasPermission('sync:trigger');
const canEnterModal = permissionsReady && hasAllPermissions('sync:read', 'sync:trigger'); const canEnterModal = permissionsReady && hasAllPermissions('sync:read', 'sync:trigger');
@@ -145,6 +146,10 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
message.success(res.log.message || `处理 ${res.log.recordsCount} 条记录`); message.success(res.log.message || `处理 ${res.log.recordsCount} 条记录`);
onApplied(); onApplied();
reset(); reset();
} else {
// 接口返回 success:false 时也要结束「处理中」并给出错误提示
message.error(res.log?.message || '处理失败,请检查后重试');
setStep('match');
} }
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { message?: string }; const err = e as { message?: string };
@@ -168,6 +173,22 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
}; };
const handleClose = () => { const handleClose = () => {
// 已进入匹配步骤且存在待处理数据时,关闭会丢失全部决策,需先确认
const hasPendingWork = step === 'match' && (entries.length > 0 || decisions.size > 0);
if (hasPendingWork) {
modal.confirm({
title: '放弃当前匹配?',
content: '已确认的匹配决策将全部丢失,且不会写入系统。',
okText: '放弃',
okButtonProps: { danger: true },
cancelText: '继续匹配',
onOk: () => {
reset();
onClose();
},
});
return;
}
reset(); reset();
onClose(); onClose();
}; };
@@ -329,7 +350,8 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
open={open && canEnterModal} open={open && canEnterModal}
onCancel={handleClose} onCancel={handleClose}
width={step === 'match' || step === 'applying' ? 900 : 640} width={step === 'match' || step === 'applying' ? 900 : 640}
maskClosable={false} mask={{ closable: false }}
closable={step !== 'applying'}
footer={ footer={
step === 'connection' step === 'connection'
? [ ? [
@@ -380,7 +402,12 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
{step === 'rule' ? renderRuleStep() : null} {step === 'rule' ? renderRuleStep() : null}
{step === 'match' ? renderMatchStep() : null} {step === 'match' ? renderMatchStep() : null}
{step === 'applying' ? ( {step === 'applying' ? (
<Spin description="正在同步..." style={{ display: 'block', margin: '48px auto' }} /> <>
<Spin description="正在同步,请勿关闭窗口..." style={{ display: 'block', margin: '48px auto' }} />
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>
</Typography.Text>
</>
) : null} ) : null}
</Modal> </Modal>
); );

View File

@@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { Button, Card, Flex, Typography } from 'antd';
import { CloseOutlined, RightOutlined, StepForwardOutlined } from '@ant-design/icons';
export interface NextStepHintProps {
/** 提示标题,如「下一步:分班」 */
title: string;
/** 补充说明 */
description?: string;
/** 主操作按钮(跳转到下一步) */
action?: { label: string; onClick: () => void };
/** 是否可关闭,默认 true */
closable?: boolean;
/** 关闭回调 */
onClose?: () => void;
}
/**
* 「下一步」引导卡片:在操作成功后或空状态下提示用户业务闭环的下一步,
* 让用户始终知道接下来该做什么。
*/
export const NextStepHint: React.FC<NextStepHintProps> = ({
title,
description,
action,
closable = true,
onClose,
}) => {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
return (
<Card
size="small"
className="next-step-hint"
style={{ marginBottom: 16, borderColor: '#b7d4ff', background: '#f0f7ff' }}
styles={{ body: { padding: '10px 16px' } }}
>
<Flex align="center" justify="space-between" gap={8} wrap>
<Flex align="center" gap={8} wrap>
<StepForwardOutlined style={{ color: '#1677ff' }} />
<Typography.Text strong>{title}</Typography.Text>
{description ? <Typography.Text type="secondary">{description}</Typography.Text> : null}
</Flex>
<Flex gap={4} align="center">
{action ? (
<Button type="primary" size="small" onClick={action.onClick}>
{action.label} <RightOutlined />
</Button>
) : null}
{closable ? (
<Button
type="text"
size="small"
icon={<CloseOutlined />}
aria-label="关闭提示"
onClick={() => {
setDismissed(true);
onClose?.();
}}
/>
) : null}
</Flex>
</Flex>
</Card>
);
};
export default NextStepHint;

View File

@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useInterval } from 'usehooks-ts'; import { useInterval } from 'usehooks-ts';
import api from '../api'; import api from '../api';
import { message } from '../ui/app-message';
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display'; import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
import { useUserStore } from '../store/user/userStore'; import { useUserStore } from '../store/user/userStore';
@@ -33,8 +34,9 @@ const NotificationBell: React.FC = () => {
try { try {
const data = await api.get<NotificationItem[]>('/notifications?limit=20'); const data = await api.get<NotificationItem[]>('/notifications?limit=20');
setNotifications(data); setNotifications(data);
} catch { } catch (error) {
/* ignore */ console.error('全部已读失败', error);
message.error('全部已读失败,请重试');
} }
}; };
@@ -118,7 +120,7 @@ const NotificationBell: React.FC = () => {
}} }}
> >
<Typography.Text strong></Typography.Text> <Typography.Text strong></Typography.Text>
<Button type="link" size="small" onClick={handleMarkAll}> <Button type="link" size="small" disabled={unreadCount === 0} onClick={handleMarkAll}>
</Button> </Button>
</div> </div>
@@ -199,7 +201,12 @@ const NotificationBell: React.FC = () => {
placement="bottomRight" placement="bottomRight"
> >
<Badge count={unreadCount} size="small" offset={[-2, 2]}> <Badge count={unreadCount} size="small" offset={[-2, 2]}>
<BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} /> <Button
type="text"
shape="circle"
icon={<BellOutlined />}
aria-label="通知中心"
/>
</Badge> </Badge>
</Popover> </Popover>
); );

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { Button, Empty } from 'antd';
export interface QueryEmptyAction {
label: string;
onClick: () => void;
type?: 'primary' | 'default';
icon?: React.ReactNode;
}
export interface QueryEmptyProps {
/** 空状态描述,默认「暂无数据」 */
description?: string;
/** 主操作按钮(如「添加学生」「导入 Excel」 */
action?: QueryEmptyAction;
/** 自定义空态插图 */
image?: React.ReactNode;
}
/**
* 统一空状态:数据确实为空时渲染本组件,并附带主操作按钮引导用户开始。
*/
export const QueryEmpty: React.FC<QueryEmptyProps> = ({ description = '暂无数据', action, image }) => {
return (
<Empty image={image} description={description}>
{action ? (
<Button type={action.type ?? 'primary'} icon={action.icon} onClick={action.onClick}>
{action.label}
</Button>
) : null}
</Empty>
);
};
export default QueryEmpty;

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { Button, Result, Typography } from 'antd';
export interface QueryErrorStateProps {
/** 错误标题,默认「数据加载失败」 */
title?: string;
/** 错误描述,默认「请检查网络后重试」 */
description?: string;
/** 点击重试回调;不传则不显示重试按钮 */
onRetry?: () => void;
/** 紧凑模式:用于表格内部、弹窗等空间受限场景 */
compact?: boolean;
}
/**
* 统一查询错误态:任何数据加载失败都应渲染本组件(而非伪装成空状态),
* 并提供重试入口,让用户明确知道「加载失败」而非「没有数据」。
*/
export const QueryErrorState: React.FC<QueryErrorStateProps> = ({
title = '数据加载失败',
description = '请检查网络后重试。',
onRetry,
compact = false,
}) => {
if (compact) {
return (
<div style={{ padding: '32px 16px', textAlign: 'center' }}>
<Typography.Text type="secondary">{title}</Typography.Text>
{description ? (
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{description}
</Typography.Text>
</div>
) : null}
{onRetry ? (
<div style={{ marginTop: 12 }}>
<Button size="small" onClick={onRetry}>
</Button>
</div>
) : null}
</div>
);
}
return (
<Result
status="warning"
title={title}
subTitle={description}
extra={onRetry ? <Button type="primary" onClick={onRetry}></Button> : undefined}
/>
);
};
export default QueryErrorState;

View File

@@ -0,0 +1,4 @@
export { QueryErrorState } from './QueryErrorState';
export type { QueryErrorStateProps } from './QueryErrorState';
export { QueryEmpty } from './QueryEmpty';
export type { QueryEmptyProps, QueryEmptyAction } from './QueryEmpty';

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { Button, Tooltip } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
interface RefreshButtonProps {
onRefresh: () => void;
loading?: boolean;
}
/** 列表工具栏刷新入口:手动重新拉取当前数据,带加载反馈。 */
export const RefreshButton: React.FC<RefreshButtonProps> = ({ onRefresh, loading }) => (
<Tooltip title="刷新">
<Button icon={<ReloadOutlined />} loading={loading} onClick={onRefresh} aria-label="刷新" />
</Tooltip>
);
export default RefreshButton;

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import type { DockTab } from '../../store/app/appTypes';
import { MAX_DOCK_TABS, upsertDockTab } from './dockTabs';
const tab = (key: string, label = key): DockTab => ({ key, label });
describe('upsertDockTab', () => {
it('追加新页签', () => {
expect(upsertDockTab([], '/students', '学生管理')).toEqual([tab('/students', '学生管理')]);
});
it('标题未变时保持原引用,避免无谓渲染', () => {
const tabs = [tab('/students', '学生管理')];
expect(upsertDockTab(tabs, '/students', '学生管理')).toBe(tabs);
});
it('菜单标题变化时更新页签标题', () => {
const tabs = [tab('/students', '学生管理')];
expect(upsertDockTab(tabs, '/students', '学生档案')).toEqual([tab('/students', '学生档案')]);
});
it('超过上限时保留当前页签 + 最新页签LRU 淘汰最旧)', () => {
const tabs = Array.from({ length: MAX_DOCK_TABS }, (_, i) => tab(`/p${i + 1}`));
const next = upsertDockTab(tabs, '/new', '新页');
expect(next).toHaveLength(MAX_DOCK_TABS);
expect(next[0]).toEqual(tab('/new', '新页'));
expect(next[next.length - 1]).toEqual(tab(`/p${MAX_DOCK_TABS}`));
expect(next.some((t) => t.key === '/p1')).toBe(false);
});
it('恢复持久化的超量页签时压缩到上限,并保留当前页', () => {
const tabs = Array.from({ length: 25 }, (_, i) => tab(`/p${i + 1}`));
tabs.push(tab('/wallets', '学生余额'));
const next = upsertDockTab(tabs, '/wallets', '学生余额');
expect(next).toHaveLength(MAX_DOCK_TABS);
expect(next[0]).toEqual(tab('/wallets', '学生余额'));
expect(next.some((t) => t.key === '/p1')).toBe(false);
expect(next.some((t) => t.key === '/p6')).toBe(false);
expect(next.some((t) => t.key === '/p7')).toBe(true);
});
});

View File

@@ -0,0 +1,31 @@
import type { DockTab } from '../../store/app/appTypes';
/** 页签数量上限超出后淘汰最旧的非当前页签LRU 式),避免无限堆积。 */
export const MAX_DOCK_TABS = 20;
function clampToLimit(list: readonly DockTab[], activeKey: string): DockTab[] {
// 未超限时保留原引用,避免触发无谓的 tab 列表重渲染
if (list.length <= MAX_DOCK_TABS) return list as DockTab[];
// 恢复/迁移或新增后超出上限:保留当前页签 + 最新的其余页签LRU 式淘汰)
const active = list.find((tab) => tab.key === activeKey);
const rest = list.filter((tab) => tab.key !== activeKey);
const keptRest = rest.slice(rest.length - (MAX_DOCK_TABS - 1));
return active ? [active, ...keptRest] : keptRest.slice(-MAX_DOCK_TABS);
}
/**
* 路由页签合并:按 pathname 建 tab重复时更新标题并始终把列表压回上限。
*/
export function upsertDockTab(
tabs: readonly DockTab[],
activeKey: string,
label: string,
): DockTab[] {
const existing = tabs.find((tab) => tab.key === activeKey);
if (existing) {
const updated =
existing.label === label ? tabs : tabs.map((tab) => (tab.key === activeKey ? { ...tab, label } : tab));
return clampToLimit(updated, activeKey);
}
return clampToLimit([...tabs, { key: activeKey, label }], activeKey);
}

View File

@@ -14,10 +14,12 @@ import {
useSortable, useSortable,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { Tabs, type TabsProps } from 'antd'; import { Button, Dropdown, Tabs, type TabsProps } from 'antd';
import { DownOutlined } from '@ant-design/icons';
import type { Location } from 'react-router'; import type { Location } from 'react-router';
import type { AppMenuItem } from '../../auth/menu-policy'; import type { AppMenuItem } from '../../auth/menu-policy';
import { useAppStore } from '../../store'; import { useAppStore } from '../../store';
import { upsertDockTab } from './dockTabs';
interface RouteDockProps { interface RouteDockProps {
location: Location; location: Location;
@@ -72,20 +74,16 @@ const DraggableTabNode: React.FC<Readonly<DraggableTabNodeProps>> = ({ ...props
}; };
const RouteDock: React.FC<RouteDockProps> = ({ location, menuItems, onNavigate, draggable }) => { const RouteDock: React.FC<RouteDockProps> = ({ location, menuItems, onNavigate, draggable }) => {
const activeKey = `${location.pathname}${location.search}`; // 与 RouteKeeper 缓存 key 保持一致:只按 pathname 建 tab避免 query 变化产生重复页签。
const activeKey = location.pathname;
const tabs = useAppStore((state) => state.routeDockTabs); const tabs = useAppStore((state) => state.routeDockTabs);
const setRouteDockTabs = useAppStore((state) => state.setRouteDockTabs); const setRouteDockTabs = useAppStore((state) => state.setRouteDockTabs);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
useEffect(() => { useEffect(() => {
if (location.pathname === '/') return; if (location.pathname === '/') return;
setRouteDockTabs((currentTabs) => {
const label = getRouteLabel(menuItems, location.pathname); const label = getRouteLabel(menuItems, location.pathname);
const existing = currentTabs.find((tab) => tab.key === activeKey); setRouteDockTabs((currentTabs) => upsertDockTab(currentTabs, activeKey, label));
if (!existing) return [...currentTabs, { key: activeKey, label }];
if (existing.label === label) return currentTabs;
return currentTabs.map((tab) => (tab.key === activeKey ? { ...tab, label } : tab));
});
}, [activeKey, location.pathname, menuItems, setRouteDockTabs]); }, [activeKey, location.pathname, menuItems, setRouteDockTabs]);
const tabItems = useMemo<NonNullable<TabsProps['items']>>( const tabItems = useMemo<NonNullable<TabsProps['items']>>(
@@ -109,6 +107,27 @@ const RouteDock: React.FC<RouteDockProps> = ({ location, menuItems, onNavigate,
} }
}; };
const closeOthers = () => {
setRouteDockTabs(tabs.filter((tab) => tab.key === activeKey));
};
const closeLeft = () => {
const index = tabs.findIndex((tab) => tab.key === activeKey);
if (index <= 0) return;
setRouteDockTabs(tabs.filter((_, i) => i >= index));
};
const closeRight = () => {
const index = tabs.findIndex((tab) => tab.key === activeKey);
if (index < 0 || index === tabs.length - 1) return;
setRouteDockTabs(tabs.filter((_, i) => i <= index));
};
const closeAll = () => {
setRouteDockTabs([]);
onNavigate('/dashboard');
};
const handleDragEnd = ({ active, over }: DragEndEvent) => { const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return; if (!over || active.id === over.id) return;
setRouteDockTabs((currentTabs) => { setRouteDockTabs((currentTabs) => {
@@ -166,6 +185,32 @@ const RouteDock: React.FC<RouteDockProps> = ({ location, menuItems, onNavigate,
if (action === 'remove') closeTab(String(targetKey)); if (action === 'remove') closeTab(String(targetKey));
}} }}
renderTabBar={renderTabBar} renderTabBar={renderTabBar}
tabBarExtraContent={
tabs.length > 1 ? (
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'close-others', label: '关闭其他' },
{ key: 'close-left', label: '关闭左侧' },
{ key: 'close-right', label: '关闭右侧' },
{ type: 'divider' },
{ key: 'close-all', label: '关闭全部' },
],
onClick: ({ key }) => {
if (key === 'close-others') closeOthers();
else if (key === 'close-left') closeLeft();
else if (key === 'close-right') closeRight();
else if (key === 'close-all') closeAll();
},
}}
>
<Button type="text" size="small" icon={<DownOutlined />} aria-label="更多页签操作">
</Button>
</Dropdown>
) : undefined
}
/> />
</nav> </nav>
); );

View File

@@ -1,11 +1,16 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { useLocation, useOutlet } from 'react-router'; import { useLocation, useOutlet } from 'react-router';
import AppErrorBoundary from './AppErrorBoundary';
import { ActivePageContext } from './routeKeeperContext';
const MAX_CACHED_PAGES = 30; const MAX_CACHED_PAGES = 30;
/** /**
* 路由保活:切换页面时保留已访问页面的组件实例(输入、滚动、弹窗状态不丢失)。 * 路由保活:切换页面时保留已访问页面的组件实例(输入、滚动、弹窗状态不丢失)。
* 隐藏页面仍挂载在 DOM 中,仅通过 display:none 隐藏。 * 隐藏页面仍挂载在 DOM 中,仅通过 display:none 隐藏。
*
* - 每个缓存页外层包裹 AppErrorBoundary单页渲染异常不影响其他缓存页。
* - 通过 ActivePageContext 向页面暴露「当前激活页路径」,供 usePageVisible 使用。
*/ */
export const RouteKeeper: React.FC = () => { export const RouteKeeper: React.FC = () => {
const location = useLocation(); const location = useLocation();
@@ -26,17 +31,17 @@ export const RouteKeeper: React.FC = () => {
} }
return ( return (
<> <ActivePageContext.Provider value={pageKey}>
{Array.from(cacheRef.current.entries()).map(([key, node]) => ( {Array.from(cacheRef.current.entries()).map(([key, node]) => (
<div <div
key={key} key={key}
className="route-keeper-page" className="route-keeper-page"
style={{ display: key === pageKey ? undefined : 'none' }} style={{ display: key === pageKey ? undefined : 'none' }}
> >
{node} <AppErrorBoundary>{node}</AppErrorBoundary>
</div> </div>
))} ))}
</> </ActivePageContext.Provider>
); );
}; };

View File

@@ -0,0 +1,16 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router';
/**
* SPA 路由切换后把滚动位置复位到顶部。
* 只在 pathname 变化时触发,避免干扰弹窗/抽屉等局部滚动。
*/
export const ScrollToTop: React.FC = () => {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
};
export default ScrollToTop;

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useRef, useState } from 'react';
import { App, Button, Popconfirm, Space, Table, Upload } from 'antd'; import { App, Button, Modal, Popconfirm, Space, Table, Upload } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { EyeOutlined, InboxOutlined, UploadOutlined } from '@ant-design/icons'; import { EyeOutlined, InboxOutlined, UploadOutlined } from '@ant-design/icons';
import api from '../../api'; import api from '../../api';
@@ -10,6 +10,20 @@ import { getErrorMessage } from '../../utils/error';
import { ATTACHMENT_CATEGORY_OPTIONS, formatFileSize } from './shared'; import { ATTACHMENT_CATEGORY_OPTIONS, formatFileSize } from './shared';
import type { AttachmentRecord, TabProps } from './shared'; import type { AttachmentRecord, TabProps } from './shared';
type AttachmentPreview = {
url: string;
name: string;
kind: 'image' | 'pdf';
};
/** 根据文件扩展名决定安全展示方式:图片/PDF 内联预览,其余一律下载 */
function getAttachmentKind(fileName: string, mimeType?: string): 'image' | 'pdf' | 'download' {
const ext = fileName.split('.').pop()?.toLowerCase() ?? '';
if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'svg'].includes(ext)) return 'image';
if (ext === 'pdf' || mimeType === 'application/pdf') return 'pdf';
return 'download';
}
export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({
data, data,
studentId, studentId,
@@ -18,6 +32,9 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
const { hasPermission } = usePermission(); const { hasPermission } = usePermission();
const canPurgeArchive = hasPermission('archive:purge'); const canPurgeArchive = hasPermission('archive:purge');
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [preview, setPreview] = useState<AttachmentPreview | null>(null);
// 预览请求序号:快速点不同行「查看」时,慢的旧响应回来直接丢弃,避免覆盖新预览
const previewSeqRef = useRef(0);
const deleteAttachmentMutation = useApiMutation( const deleteAttachmentMutation = useApiMutation(
async (attachmentId: number) => api.delete(`/archive/attachments/${attachmentId}`), async (attachmentId: number) => api.delete(`/archive/attachments/${attachmentId}`),
@@ -29,12 +46,43 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
); );
const uploadAttachmentMutation = useApiMutation( const uploadAttachmentMutation = useApiMutation(
async (formData: FormData) => async (formData: FormData) =>
api.post(`/archive/${studentId}/attachments`, formData, { api.post(`/archive/${studentId}/attachments`, formData),
headers: { 'Content-Type': 'multipart/form-data' },
}),
{ invalidate: [['archive', studentId]] }, { invalidate: [['archive', studentId]] },
); );
const closePreview = () => {
previewSeqRef.current += 1; // 关闭后仍在途的旧响应也不再落地
if (preview?.url) URL.revokeObjectURL(preview.url);
setPreview(null);
};
const openAttachment = async (record: AttachmentRecord) => {
const seq = ++previewSeqRef.current;
try {
const blob = await api.get<Blob>(`/archive/${studentId}/attachments/${record.id}`, {
responseType: 'blob',
});
if (seq !== previewSeqRef.current) return; // 已有更新的查看请求,丢弃本次慢响应
const kind = getAttachmentKind(record.fileName, record.mimeType);
const url = URL.createObjectURL(blob);
if (kind === 'download') {
// 非内联类型通过 download 属性触发下载,避免以页面同源打开可执行内容
const a = document.createElement('a');
a.href = url;
a.download = record.fileName || 'attachment';
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
} else {
if (preview?.url) URL.revokeObjectURL(preview.url);
setPreview({ url, name: record.fileName || 'attachment', kind });
}
} catch (e: unknown) {
message.error(getErrorMessage(e, '查看失败'));
}
};
const handleDelete = async (attachmentId: number) => { const handleDelete = async (attachmentId: number) => {
try { try {
await deleteAttachmentMutation.mutateAsync(attachmentId); await deleteAttachmentMutation.mutateAsync(attachmentId);
@@ -74,22 +122,7 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
title: '操作', title: '操作',
render: (_: unknown, record: AttachmentRecord) => ( render: (_: unknown, record: AttachmentRecord) => (
<Space> <Space>
<Button <Button size="small" icon={<EyeOutlined />} onClick={() => openAttachment(record)}>
size="small"
icon={<EyeOutlined />}
onClick={async () => {
try {
const blob = await api.get<Blob>(`/archive/${studentId}/attachments/${record.id}`, {
responseType: 'blob',
});
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (e: unknown) {
message.error(getErrorMessage(e, '查看失败'));
}
}}
>
</Button> </Button>
{hasPermission('student:edit') && record.status !== 'archived' ? ( {hasPermission('student:edit') && record.status !== 'archived' ? (
@@ -139,7 +172,7 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
</Button> </Button>
</Upload> </Upload>
) : null} ) : null}
<Table<AttachmentRecord> <Table<AttachmentRecord> scroll={{ x: 'max-content' }}
columns={columns} columns={columns}
dataSource={data} dataSource={data}
rowKey="id" rowKey="id"
@@ -151,6 +184,25 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
}} }}
style={{ marginTop: 16 }} style={{ marginTop: 16 }}
/> />
<Modal
title={preview?.name}
open={!!preview}
footer={null}
onCancel={closePreview}
width={preview?.kind === 'pdf' ? 900 : undefined}
destroyOnHidden
>
{preview?.kind === 'image' ? (
<img src={preview.url} alt={preview.name} style={{ width: '100%' }} />
) : preview?.kind === 'pdf' ? (
<iframe
src={preview.url}
title={preview.name}
sandbox=""
style={{ width: '100%', height: '70vh', border: 'none' }}
/>
) : null}
</Modal>
</div> </div>
); );
}; };

View File

@@ -205,7 +205,7 @@ export const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> =
> >
</PermissionButton> </PermissionButton>
<Table<EnrollmentRecord> <Table<EnrollmentRecord> scroll={{ x: 'max-content' }}
columns={columns} columns={columns}
dataSource={data} dataSource={data}
rowKey="id" rowKey="id"

View File

@@ -195,7 +195,7 @@ export const ExamScoresTab: React.FC<
> >
</PermissionButton> </PermissionButton>
<Table<ExamScoreRecord> <Table<ExamScoreRecord> scroll={{ x: 'max-content' }}
columns={columns} columns={columns}
dataSource={data} dataSource={data}
rowKey="id" rowKey="id"

View File

@@ -162,7 +162,7 @@ export const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({
> >
</PermissionButton> </PermissionButton>
<Table<LearningRecord> <Table<LearningRecord> scroll={{ x: 'max-content' }}
columns={columns} columns={columns}
dataSource={data} dataSource={data}
rowKey="id" rowKey="id"

View File

@@ -28,10 +28,11 @@ import { message } from '../../ui/app-message';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation'; import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate'; import { validateResponse } from '../../utils/validate';
import { queryKeys } from '../../api/queryKeys';
import { organizationOptionsSchema, studentProfileAggregateSchema } from '../../api/schemas'; import { organizationOptionsSchema, studentProfileAggregateSchema } from '../../api/schemas';
import EditableCell from '../EditableCell'; import EditableCell from '../EditableCell';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { getErrorMessage } from '../../utils/error'; import { QueryErrorState } from '../QueryState';
import { ADMISSION_STATUS_MAP, ATTENDANCE_STATUS_MAP, SESSION_LABELS, getOptionLabel } from './shared'; import { ADMISSION_STATUS_MAP, ATTENDANCE_STATUS_MAP, SESSION_LABELS, getOptionLabel } from './shared';
import type { AttendanceRecordItem, ProfileData, ResultData, StudentInfo, StudentProfileAggregate, StudentProfileContentProps } from './shared'; import type { AttendanceRecordItem, ProfileData, ResultData, StudentInfo, StudentProfileAggregate, StudentProfileContentProps } from './shared';
@@ -187,7 +188,7 @@ const InlineArchiveSummary: React.FC<{
); );
return ( return (
<Descriptions bordered column={3} size="small" style={{ marginBottom: 24 }}> <Descriptions bordered column={{ xs: 1, sm: 2, lg: 3 }} size="small" style={{ marginBottom: 24 }}>
<Descriptions.Item label="手机号"> <Descriptions.Item label="手机号">
<EditableCell <EditableCell
value={student.phone} value={student.phone}
@@ -478,25 +479,21 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
data: aggregateData, data: aggregateData,
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch, refetch,
} = useQuery<StudentProfileAggregate | null>({ } = useQuery<StudentProfileAggregate | null>({
queryKey: ['archive', studentId], queryKey: queryKeys.archive.detail(studentId),
queryFn: async () => { queryFn: async () => {
try {
return validateResponse<StudentProfileAggregate>( return validateResponse<StudentProfileAggregate>(
studentProfileAggregateSchema, studentProfileAggregateSchema,
await api.get<StudentProfileAggregate>(`/archive/${studentId}`), await api.get<StudentProfileAggregate>(`/archive/${studentId}`),
); );
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败'));
return null;
}
}, },
}); });
const { data: organizations = [] } = useQuery< const { data: organizations = [] } = useQuery<
Array<{ id: number; name: string; isHost?: boolean }> Array<{ id: number; name: string; isHost?: boolean }>
>({ >({
queryKey: ['organizations', 'options'], queryKey: queryKeys.organizations.options(),
enabled: canLoadOrganizations, enabled: canLoadOrganizations,
queryFn: async () => { queryFn: async () => {
try { try {
@@ -582,6 +579,15 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
</div> </div>
); );
} }
if (isError) {
return (
<QueryErrorState
title="档案数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
);
}
return null; return null;
} }

View File

@@ -76,6 +76,7 @@ export interface AttachmentRecord {
category: string; category: string;
fileName: string; fileName: string;
fileSize: number; fileSize: number;
mimeType?: string;
} }
export interface AttendanceRecordItem { export interface AttendanceRecordItem {

View File

@@ -0,0 +1,10 @@
import { createContext, useContext } from 'react';
/**
* 当前激活的页面路径(由 RouteKeeper 提供)。
* RouteKeeper 用 display:none 保活已访问页面,页面组件本身不会重新挂载,
* 因此需要该上下文让每个缓存页感知「自己是否处于激活状态」。
*/
export const ActivePageContext = createContext<string>('');
export const useActivePage = (): string => useContext(ActivePageContext);

View File

@@ -0,0 +1,125 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router';
import { RefreshButton } from './RefreshButton';
import { BackTop } from './BackTop';
import { ScrollToTop } from './ScrollToTop';
import { useSubmitShortcut } from '../hooks/useSubmitShortcut';
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
const mount = (node: React.ReactNode) => {
const host = document.createElement('div');
container = host;
document.body.appendChild(host);
root = createRoot(host);
act(() => root?.render(node));
};
afterEach(async () => {
if (root) {
await act(async () => root?.unmount());
}
container?.remove();
root = null;
container = null;
vi.restoreAllMocks();
});
describe('UX 组件与交互', () => {
it('RefreshButton 点击触发 onRefreshloading 时展示加载态', async () => {
const onRefresh = vi.fn();
mount(<RefreshButton onRefresh={onRefresh} />);
const button = container?.querySelector('button');
if (!button) throw new Error('button not rendered');
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
await flush();
expect(onRefresh).toHaveBeenCalledTimes(1);
mount(<RefreshButton onRefresh={onRefresh} loading />);
expect(container?.querySelector('.ant-btn-loading')).toBeTruthy();
});
it('useSubmitShortcut 未激活时不响应 Cmd+Enter', async () => {
const onSubmit = vi.fn();
const Harness = () => {
useSubmitShortcut(false, onSubmit);
return <button type="button">ok</button>;
};
mount(<Harness />);
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', metaKey: true, bubbles: true }),
);
await flush();
expect(onSubmit).not.toHaveBeenCalled();
});
it('useSubmitShortcut 激活时响应 Cmd/Ctrl+Enter 且阻止默认行为', async () => {
const onSubmit = vi.fn();
const Harness = () => {
useSubmitShortcut(true, onSubmit);
return <button type="button">ok</button>;
};
mount(<Harness />);
const metaEvent = new KeyboardEvent('keydown', {
key: 'Enter',
metaKey: true,
bubbles: true,
cancelable: true,
});
window.dispatchEvent(metaEvent);
expect(metaEvent.defaultPrevented).toBe(true);
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true }),
);
await flush();
expect(onSubmit).toHaveBeenCalledTimes(2);
});
it('BackTop 超过阈值后出现,点击回到顶部', async () => {
const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
mount(<BackTop threshold={-1} />);
await flush();
const button = container?.querySelector('button');
expect(button).toBeTruthy();
if (button) {
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
}
await flush();
expect(scrollSpy).toHaveBeenCalledWith(expect.objectContaining({ top: 0 }));
});
it('ScrollToTop 在路由切换时把滚动位置复位到顶部', async () => {
const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
const Nav = () => {
const navigate = useNavigate();
return (
<button type="button" onClick={() => navigate('/other')}>
go
</button>
);
};
mount(
<MemoryRouter initialEntries={['/']}>
<ScrollToTop />
<Routes>
<Route path="/" element={<Nav />} />
<Route path="/other" element={<div>other</div>} />
</Routes>
</MemoryRouter>,
);
scrollSpy.mockClear();
const button = container?.querySelector('button');
if (!button) throw new Error('button not rendered');
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
await flush();
expect(scrollSpy).toHaveBeenCalledWith(0, 0);
});
});

View File

@@ -2,38 +2,62 @@ import { useMutation, useQueryClient, type QueryKey } from '@tanstack/react-quer
import { message } from '../ui/app-message'; import { message } from '../ui/app-message';
import { getErrorMessage } from '../utils/error'; import { getErrorMessage } from '../utils/error';
interface UseApiMutationOptions<TData, TVars> { interface UseApiMutationOptions<TData, TVars, TContext> {
/** 成功后自动失效的查询 key触发列表/详情刷新) */ /** 成功后自动失效的查询 key触发列表/详情刷新) */
invalidate?: QueryKey[]; invalidate?: QueryKey[];
/** 乐观更新mutate 前同步改缓存,返回回滚上下文(失败时传给 onError */
onMutate?: (vars: TVars) => Promise<TContext | undefined> | TContext | undefined;
/** 成功后回调(例如关闭弹窗) */ /** 成功后回调(例如关闭弹窗) */
onSuccess?: (data: TData, vars: TVars) => void; onSuccess?: (data: TData, vars: TVars, context?: TContext) => void;
/** 失败回调;默认统一用 getErrorMessage 弹错误提示 */ /** 失败回调;提供时由调用方负责(含乐观更新回滚),否则默认用 getErrorMessage 弹错误提示 */
onError?: (error: unknown) => void; onError?: (error: unknown, vars: TVars, context?: TContext) => void;
/** 结束后回调(无论成败) */
onSettled?: (data: TData | undefined, error: unknown, vars: TVars, context?: TContext) => void;
} }
/** /**
* useMutation 的轻量封装:统一错误提示 + 成功后 invalidateQueries * useMutation 的轻量封装:统一错误提示 + 成功后 invalidateQueries
* 消除手写 `await api.xxx(); await fetchData();` 样板。 * 消除手写 `await api.xxx(); await fetchData();` 样板。
*
* 乐观更新示例:
* ```ts
* const mutation = useApiMutation(fn, {
* onMutate: async (vars) => {
* await queryClient.cancelQueries({ queryKey });
* const previous = queryClient.getQueryData(queryKey);
* queryClient.setQueryData(queryKey, updater);
* return previous; // 回滚上下文
* },
* onError: (_e, _v, previous) => queryClient.setQueryData(queryKey, previous),
* });
* ```
*/ */
export function useApiMutation<TData = unknown, TVars = void>( export function useApiMutation<TData = unknown, TVars = void, TContext = unknown>(
mutationFn: (vars: TVars) => Promise<TData>, mutationFn: (vars: TVars) => Promise<TData>,
options: UseApiMutationOptions<TData, TVars> = {}, options: UseApiMutationOptions<TData, TVars, TContext> = {},
) { ) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<TData, Error, TVars>({ return useMutation<TData, Error, TVars, TContext>({
mutationFn, mutationFn,
onSuccess: (data, vars) => { // 包装 onMutate允许调用方返回 undefined无回滚上下文
// React Query 的 onMutate 类型要求返回 TContext
onMutate: async (vars) => {
const context = await options.onMutate?.(vars);
return context as TContext;
},
onSuccess: (data, vars, context) => {
for (const key of options.invalidate ?? []) { for (const key of options.invalidate ?? []) {
void queryClient.invalidateQueries({ queryKey: key }); void queryClient.invalidateQueries({ queryKey: key });
} }
options.onSuccess?.(data, vars); options.onSuccess?.(data, vars, context);
}, },
onError: (error) => { onError: (error, vars, context) => {
if (options.onError) { if (options.onError) {
options.onError(error); options.onError(error, vars, context);
} else { } else {
message.error(getErrorMessage(error)); message.error(getErrorMessage(error));
} }
}, },
onSettled: options.onSettled,
}); });
} }

View File

@@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query';
import type { QueryKey } from '@tanstack/react-query';
import type { z } from 'zod';
import { validateResponse } from '../utils/validate';
interface UseApiQueryOptions<T, TSelected = T> {
queryKey: QueryKey;
queryFn: () => Promise<unknown>;
/** zod schema响应校验失败会抛出带字段路径的错误由统一错误处理展示 */
schema: z.ZodType<unknown>;
enabled?: boolean;
staleTime?: number;
/** 可选的数据转换React Query select例如列表原始行 → UI 模型 */
select?: (data: T) => TSelected;
}
/**
* useQuery 的类型安全封装queryFn 返回 unknown
* 由 zod schema 校验并收敛为 T消除各页面重复的
* `validateResponse(schema, await api.get(...))` 样板。
*/
export function useApiQuery<T, TSelected = T>(options: UseApiQueryOptions<T, TSelected>) {
const { queryKey, queryFn, schema, enabled, staleTime, select } = options;
return useQuery<T, Error, TSelected>({
queryKey,
enabled,
staleTime,
queryFn: async () => validateResponse<T>(schema, await queryFn()),
select,
});
}

View File

@@ -0,0 +1,53 @@
import { useCallback, useMemo, useRef } from 'react';
import { App } from 'antd';
import type { FormInstance } from 'antd';
import equal from 'fast-deep-equal';
/**
* 弹窗「未保存内容」保护:关闭弹窗时若表单值已被修改,先确认再关闭,
* 避免用户误关丢失已填内容。
*
* 用法:
* const { confirmClose, snapshot } = useDirtyGuard(form);
* // 打开弹窗(或编辑回填后)时调用一次 snapshot() 记录初始值
* const openEdit = () => { form.setFieldsValue(record); snapshot(); setOpen(true); };
* // Modal 的 onCancel 改用确认关闭
* <Modal onCancel={() => confirmClose(() => setOpen(false))} ...>
*/
export function useDirtyGuard(form: FormInstance) {
const { modal } = App.useApp();
const pristineRef = useRef<unknown>(null);
/** 记录当前表单值为「未修改」基准;打开弹窗/回填后调用 */
const snapshot = useCallback(() => {
pristineRef.current = form.getFieldsValue();
}, [form]);
/** 表单是否有未保存修改(与 snapshot 时对比) */
const isDirty = useCallback(() => {
return !equal(form.getFieldsValue(), pristineRef.current);
}, [form]);
const confirmClose = useCallback(
(close: () => void) => {
if (!isDirty()) {
close();
return;
}
modal.confirm({
title: '放弃未保存的修改?',
content: '当前表单有未保存的内容,关闭后修改将丢失。',
okText: '放弃修改',
okButtonProps: { danger: true },
cancelText: '继续编辑',
onOk: close,
});
},
[isDirty, modal],
);
// 用 useMemo 稳定返回对象引用,避免消费方 useEffect 依赖每次渲染都变化
return useMemo(() => ({ confirmClose, snapshot, isDirty }), [confirmClose, snapshot, isDirty]);
}
export default useDirtyGuard;

View File

@@ -0,0 +1,46 @@
import { useCallback, useRef, useState } from 'react';
import { downloadBlob } from '../utils/download';
import { message } from '../ui/app-message';
export interface DownloadOptions {
/** 成功提示文案;默认「下载成功」 */
successMsg?: string;
/** 失败提示文案;默认使用接口返回的错误信息 */
errorMsg?: string;
}
/**
* 统一下载/导出状态:防重复点击 + 成功/失败反馈。
*
* 用法:
* const { downloading, run } = useDownload();
* <Button loading={downloading} onClick={() => run('/students/export', '名单.xlsx')}>
*/
export function useDownload() {
const [downloading, setDownloading] = useState(false);
const busyRef = useRef(false);
const run = useCallback(
async (endpoint: string, filename: string, options?: DownloadOptions) => {
if (busyRef.current) return;
busyRef.current = true;
setDownloading(true);
try {
await downloadBlob(endpoint, filename);
message.success(options?.successMsg ?? '下载成功');
} catch (error: unknown) {
message.error(
options?.errorMsg ?? (error instanceof Error ? error.message : '下载失败,请重试'),
);
} finally {
busyRef.current = false;
setDownloading(false);
}
},
[],
);
return { downloading, run };
}
export default useDownload;

View File

@@ -0,0 +1,37 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router';
import { useQueryClient, type QueryKey } from '@tanstack/react-query';
import { useActivePage } from '../components/routeKeeperContext';
/**
* 当前页面是否处于激活(可见)状态。
* RouteKeeper 保活页面始终挂载,只有激活页可见;配合
* `useVisibleRefetch` 可在切回页面时刷新数据。
*/
export function usePageVisible(): boolean {
const activePage = useActivePage();
const location = useLocation();
return activePage === location.pathname;
}
/**
* 页面重新变为可见时刷新指定 queryKey 的数据。
* 解决 RouteKeeper 保活导致的「切回列表页看不到新增/删除数据」问题。
*
* 用法:`useVisibleRefetch(['students']);`
*
* 注意queryKey 通过 ref 持有effect 只依赖 visible
* 避免调用方每次渲染传入新数组字面量导致频繁重复请求。
*/
export function useVisibleRefetch(queryKey: QueryKey | undefined): void {
const visible = usePageVisible();
const queryClient = useQueryClient();
const keyRef = useRef(queryKey);
keyRef.current = queryKey;
useEffect(() => {
if (visible && keyRef.current) {
void queryClient.refetchQueries({ queryKey: keyRef.current });
}
}, [visible, queryClient]);
}

View File

@@ -0,0 +1,19 @@
import { useEffect } from 'react';
/**
* 弹窗/表单内按 Cmd/Ctrl+Enter 触发表单提交。
* 仅在 active弹窗打开且非保存中时监听避免误触。
*/
export function useSubmitShortcut(active: boolean, onSubmit: () => void): void {
useEffect(() => {
if (!active) return;
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
onSubmit();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [active, onSubmit]);
}

View File

@@ -62,6 +62,6 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool
}, },
}); });
}, },
[studentId, module], [studentId, module, modal],
); );
} }

View File

@@ -514,3 +514,68 @@ canvas {
padding-inline: 0; padding-inline: 0;
} }
} }
/* ─── 全局无障碍与质感增强 ─── */
html {
scroll-behavior: smooth;
}
::selection {
background: rgba(0, 122, 255, 0.18);
}
/* 键盘导航焦点可见(鼠标点击不显示,符合 WCAG 2.4.7 */
:focus-visible {
outline: 2px solid #007aff;
outline-offset: 2px;
}
/* 细滚动条macOS/Chromium降低大面积滚动条对视觉的干扰 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.16);
border: 2px solid transparent;
border-radius: 8px;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.28);
}
/* 尊重系统「减少动态效果」偏好 */
/* 窄屏下压缩路由标签尺寸,避免横向裁切 */
@media (max-width: 575px) {
.route-dock {
padding: 6px 8px;
}
.route-dock .ant-tabs-tab {
min-width: 88px;
max-width: 160px;
padding: 0 8px 0 10px !important;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -36,9 +36,11 @@ import api from '../api';
import { useAppStore } from '../store/app/appStore'; import { useAppStore } from '../store/app/appStore';
import { usePermissionStore } from '../store/permission/permissionStore'; import { usePermissionStore } from '../store/permission/permissionStore';
import { useUserStore } from '../store/user/userStore'; import { useUserStore } from '../store/user/userStore';
import { AUTH_STORAGE_NAME, PERMISSION_STORAGE_NAME } from '../store/middleware/persist';
import NotificationBell from '../components/NotificationBell'; import NotificationBell from '../components/NotificationBell';
import RouteDock from '../components/RouteDock'; import RouteDock from '../components/RouteDock';
import RouteKeeper from '../components/RouteKeeper'; import RouteKeeper from '../components/RouteKeeper';
import BackTop from '../components/BackTop';
import { buildMenu, type AppMenuItem } from '../auth/menu-policy'; import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer')); const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer'));
@@ -125,9 +127,42 @@ const MainLayout: React.FC = () => {
}; };
const handleStorage = (event: StorageEvent) => { const handleStorage = (event: StorageEvent) => {
if (event.key !== 'token' && event.key !== 'permissions') return; if (event.key === PERMISSION_STORAGE_NAME) {
usePermissionStore.getState().beginPermissionVerification(); // 其他标签页的权限更新:原地应用,避免整页刷新造成刷新风暴。
try {
if (event.newValue === null) {
usePermissionStore.getState().clearPermissions();
return;
}
const parsed = JSON.parse(event.newValue) as {
state?: { permissions?: string[] };
};
const permissions = parsed?.state?.permissions;
if (Array.isArray(permissions)) {
usePermissionStore.getState().writePermissions(permissions);
}
} catch {
// 忽略无法解析的跨标签页权限写入
}
return;
}
if (event.key === AUTH_STORAGE_NAME) {
// 仅当登录态token确实变化时才整页刷新登录、退出或切换账号。
const currentToken = useUserStore.getState().token;
let otherToken: string | null = null;
if (event.newValue) {
try {
const parsed = JSON.parse(event.newValue) as {
state?: { token?: string | null };
};
otherToken = parsed?.state?.token ?? null;
} catch {
otherToken = null;
}
}
if (otherToken === currentToken) return;
window.location.reload(); window.location.reload();
}
}; };
const handleOnline = () => verifyPermissions(); const handleOnline = () => verifyPermissions();
const handleVisibilityChange = () => { const handleVisibilityChange = () => {
@@ -161,6 +196,7 @@ const MainLayout: React.FC = () => {
const handleLogout = useCallback(() => { const handleLogout = useCallback(() => {
logoutUser(); logoutUser();
usePermissionStore.getState().clearPermissions(); usePermissionStore.getState().clearPermissions();
useAppStore.getState().setRouteDockTabs([]);
navigate('/login'); navigate('/login');
}, [logoutUser, navigate]); }, [logoutUser, navigate]);
@@ -169,10 +205,11 @@ const MainLayout: React.FC = () => {
navigate(key); navigate(key);
if (usesDrawer) setDrawerOpen(false); if (usesDrawer) setDrawerOpen(false);
}, },
[navigate, usesDrawer], [navigate, usesDrawer, setDrawerOpen],
); );
const findSelectedKeys = (items: AppMenuItem[], pathname: string): string[] => { const findSelectedKeys = useCallback(
(items: AppMenuItem[], pathname: string): string[] => {
for (const item of items) { for (const item of items) {
if (item.key === pathname) return [item.key]; if (item.key === pathname) return [item.key];
if (item.children) { if (item.children) {
@@ -181,7 +218,9 @@ const MainLayout: React.FC = () => {
} }
} }
return [pathname]; return [pathname];
}; },
[],
);
const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => { const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => {
for (const item of items) { for (const item of items) {
@@ -201,7 +240,7 @@ const MainLayout: React.FC = () => {
const selectedKeys = useMemo( const selectedKeys = useMemo(
() => findSelectedKeys(menuItems, location.pathname), () => findSelectedKeys(menuItems, location.pathname),
[menuItems, location.pathname], [menuItems, location.pathname, findSelectedKeys],
); );
// 路径变化时同步展开的菜单(不干扰用户手动展开/收起) // 路径变化时同步展开的菜单(不干扰用户手动展开/收起)
useEffect(() => { useEffect(() => {
@@ -210,20 +249,20 @@ const MainLayout: React.FC = () => {
const routeOpenKeys = findOpenKeys(menuItems, location.pathname); const routeOpenKeys = findOpenKeys(menuItems, location.pathname);
setOpenKeys((currentKeys) => [...new Set([...currentKeys, ...routeOpenKeys])]); setOpenKeys((currentKeys) => [...new Set([...currentKeys, ...routeOpenKeys])]);
} }
}, [location.pathname, menuItems]); }, [location.pathname, menuItems, setOpenKeys]);
const handleOpenChange = useCallback((keys: string[]) => { const handleOpenChange = useCallback((keys: string[]) => {
setOpenKeys(keys); setOpenKeys(keys);
}, []); }, [setOpenKeys]);
const transformToMenuItems = (items: AppMenuItem[]): any[] => { const transformToMenuItems = useCallback((items: AppMenuItem[]): any[] => {
return items.map((item) => ({ return items.map((item) => ({
key: item.key, key: item.key,
icon: item.icon ? iconMap[item.icon] : undefined, icon: item.icon ? iconMap[item.icon] : undefined,
label: item.label, label: item.label,
children: item.children ? transformToMenuItems(item.children) : undefined, children: item.children ? transformToMenuItems(item.children) : undefined,
})); }));
}; }, []);
const menuContent = useMemo( const menuContent = useMemo(
() => ( () => (
<Menu <Menu
@@ -237,7 +276,7 @@ const MainLayout: React.FC = () => {
style={{ border: 'none' }} style={{ border: 'none' }}
/> />
), ),
[selectedKeys, openKeys, menuItems, handleMenuClick], [selectedKeys, openKeys, menuItems, handleMenuClick, handleOpenChange, transformToMenuItems],
); );
return ( return (
@@ -394,6 +433,7 @@ const MainLayout: React.FC = () => {
/> />
</React.Suspense> </React.Suspense>
)} )}
<BackTop />
</Layout> </Layout>
); );
}; };

View File

@@ -1,8 +1,10 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './api/queryClient';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import App from './App'; import App from './App';
import AppErrorBoundary from './components/AppErrorBoundary';
import './index.css'; import './index.css';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';
@@ -28,20 +30,16 @@ dayjs.extend(updateLocale);
// 必须在所有插件加载后设置 locale // 必须在所有插件加载后设置 locale
dayjs.locale('zh-cn'); dayjs.locale('zh-cn');
const queryClient = new QueryClient({ const rootElement = document.getElementById('root');
defaultOptions: { if (!rootElement) throw new Error('未找到 #root 挂载点');
queries: {
retry: 1,
staleTime: 30_000,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(rootElement).render(
<React.StrictMode> <React.StrictMode>
<AppErrorBoundary>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<App /> <App />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />} {import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider> </QueryClientProvider>
</AppErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -355,7 +355,7 @@ export const SaveTestStep: React.FC<{
<Card title={<span className={styles.cardTitle}></span>} extra={<CheckCircleOutlined />}> <Card title={<span className={styles.cardTitle}></span>} extra={<CheckCircleOutlined />}>
<Alert <Alert
type="info" type="info"
message="配置预览" title="配置预览"
description={ description={
<Descriptions column={1} size="small" style={{ marginTop: 8 }}> <Descriptions column={1} size="small" style={{ marginTop: 8 }}>
<Descriptions.Item label="服务商"> <Descriptions.Item label="服务商">

View File

@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation'; import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate'; import { validateResponse } from '../../utils/validate';
import { aiConfigEnvelopeSchema } from '../../api/schemas'; import { aiConfigEnvelopeSchema } from '../../api/schemas';
import { App, Alert, Button, Form, Space, Spin, Steps, Tag } from 'antd'; import {App, Alert, Button, Form, Space, Steps, Tag, Skeleton} from 'antd';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
@@ -48,10 +48,10 @@ const DEFAULT_FORM_VALUES: FormValues = {
}; };
const STEP_ITEMS = [ const STEP_ITEMS = [
{ title: '服务商', description: '选择 AI 服务商' }, { title: '服务商', content: '选择 AI 服务商' },
{ title: '密钥', description: '配置 API 密钥' }, { title: '密钥', content: '配置 API 密钥' },
{ title: '模型', description: '获取并选择模型' }, { title: '模型', content: '获取并选择模型' },
{ title: '完成', description: '保存并测试连接' }, { title: '完成', content: '保存并测试连接' },
]; ];
const AiConfigPage: React.FC = () => { const AiConfigPage: React.FC = () => {
@@ -276,7 +276,7 @@ const AiConfigPage: React.FC = () => {
} finally { } finally {
setTesting(false); setTesting(false);
} }
}, [formValues, form, currentProvider]); }, [formValues, form, currentProvider, refreshConfig]);
// ── Clear key ── // ── Clear key ──
@@ -299,7 +299,7 @@ const AiConfigPage: React.FC = () => {
} }
}, },
}); });
}, [config, modal]); }, [config, modal, clearKeyMutation]);
// ── Step navigation ── // ── Step navigation ──
@@ -332,8 +332,8 @@ const AiConfigPage: React.FC = () => {
if (loading) { if (loading) {
return ( return (
<div className={styles.container} style={{ textAlign: 'center', paddingTop: 80 }}> <div className={styles.container} style={{ paddingTop: 24 }}>
<Spin size="large" /> <Skeleton active paragraph={{ rows: 10 }} />
</div> </div>
); );
} }

View File

@@ -157,7 +157,7 @@ export const ADMIN_METRIC_META = [
]; ];
function pickPrimaryStatus(records: AttendanceRecordItem[]) { function pickPrimaryStatus(records: AttendanceRecordItem[]) {
const priority = ['absent', 'leave', 'present']; const priority = ['absent', 'leave', 'late', 'present'];
return ( return (
priority.find((item) => priority.find((item) =>
records.some((record) => displayAttendanceStatus(record.status) === item), records.some((record) => displayAttendanceStatus(record.status) === item),
@@ -189,7 +189,10 @@ export function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminS
} }
return Array.from(map.values()).map((item) => { return Array.from(map.values()).map((item) => {
const checked = item.records.filter((record) => record.status === 'present').length; // late 与 present 一样视为已出勤(与 attendance-workspace 一致)
const checked = item.records.filter(
(record) => record.status === 'present' || record.status === 'late',
).length;
return { return {
...item, ...item,
primaryStatus: pickPrimaryStatus(item.records), primaryStatus: pickPrimaryStatus(item.records),

View File

@@ -196,7 +196,6 @@ export const AttendanceAdminHeader: React.FC<{
<section className="student-class-overview" aria-label="班级考勤汇总"> <section className="student-class-overview" aria-label="班级考勤汇总">
<div className="student-class-identity"> <div className="student-class-identity">
<div className="student-class-heading"> <div className="student-class-heading">
<span className="student-overview-kicker"></span>
<h2>{selectedClass}</h2> <h2>{selectedClass}</h2>
<p> <p>
{dateLabel} · {visibleStudentCount} / {total} {dateLabel} · {visibleStudentCount} / {total}

View File

@@ -27,7 +27,8 @@ export const PeriodConfigModal: React.FC<{
onOk: () => void; onOk: () => void;
onCancel: () => void; onCancel: () => void;
onReset: () => void; onReset: () => void;
}> = ({ open, form, onOk, onCancel, onReset }) => { confirmLoading?: boolean;
}> = ({ open, form, onOk, onCancel, onReset, confirmLoading }) => {
return ( return (
<Modal <Modal
open={open} open={open}
@@ -37,6 +38,7 @@ export const PeriodConfigModal: React.FC<{
className="attendance-period-modal" className="attendance-period-modal"
onOk={onOk} onOk={onOk}
onCancel={onCancel} onCancel={onCancel}
confirmLoading={confirmLoading}
footer={(_, { OkBtn, CancelBtn }) => ( footer={(_, { OkBtn, CancelBtn }) => (
<> <>
<Button icon={<UndoOutlined />} onClick={onReset}> <Button icon={<UndoOutlined />} onClick={onReset}>
@@ -54,7 +56,7 @@ export const PeriodConfigModal: React.FC<{
description="默认07:30-08:30 早自习09:00-12:00 早课14:00-17:00 晚课18:30-21:00 晚自习。" description="默认07:30-08:30 早自习09:00-12:00 早课14:00-17:00 晚课18:30-21:00 晚自习。"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
/> />
<Form form={form} layout="vertical"> <Form form={form} layout="vertical" scrollToFirstError>
<Form.List name="periods"> <Form.List name="periods">
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
<div className="attendance-period-editor"> <div className="attendance-period-editor">

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Card, Empty, Input, Spin, Table } from 'antd'; import { Button, Card, Empty, Input, Spin, Table } from 'antd';
import { ExportOutlined } from '@ant-design/icons'; import { ExportOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { import {
@@ -27,6 +27,11 @@ export const AttendanceAdminWorkspace: React.FC<{
pageSize: number; pageSize: number;
total: number; total: number;
onPageChange: (page: number, pageSize: number) => void; onPageChange: (page: number, pageSize: number) => void;
/** 明细表批量纠错 */
selectedRecordKeys: number[];
onSelectRecords: (keys: number[]) => void;
onBatchCorrect: (status: string) => void;
batchCorrecting: boolean;
}> = ({ }> = ({
metricFilter, metricFilter,
studentSearch, studentSearch,
@@ -44,6 +49,10 @@ export const AttendanceAdminWorkspace: React.FC<{
pageSize, pageSize,
total, total,
onPageChange, onPageChange,
selectedRecordKeys,
onSelectRecords,
onBatchCorrect,
batchCorrecting,
}) => { }) => {
return ( return (
<> <>
@@ -134,13 +143,52 @@ export const AttendanceAdminWorkspace: React.FC<{
</Spin> </Spin>
</section> </section>
<Card className="student-record-card" bordered={false} title="原始考勤明细"> <Card className="student-record-card" variant="borderless" title="原始考勤明细">
{selectedRecordKeys.length > 0 ? (
<div
style={{
marginBottom: 8,
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
}}
>
<span> {selectedRecordKeys.length} </span>
<Button
size="small"
type="primary"
loading={batchCorrecting}
onClick={() => onBatchCorrect('present')}
>
</Button>
<Button size="small" loading={batchCorrecting} onClick={() => onBatchCorrect('leave')}>
</Button>
<Button
size="small"
danger
loading={batchCorrecting}
onClick={() => onBatchCorrect('absent')}
>
</Button>
<Button size="small" type="text" onClick={() => onSelectRecords([])}>
</Button>
</div>
) : null}
<Table<AttendanceRecordItem> <Table<AttendanceRecordItem>
rowKey="id" rowKey="id"
columns={columns} columns={columns}
dataSource={records} dataSource={records}
loading={loading} loading={loading}
scroll={{ x: 'max-content' }} scroll={{ x: 'max-content' }}
rowSelection={{
selectedRowKeys: selectedRecordKeys,
onChange: (keys) => onSelectRecords(keys as number[]),
}}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,

View File

@@ -1,8 +1,9 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Avatar, Button, Drawer, Empty, Input, Progress, Select, Table, Tag } from 'antd'; import { Alert, App, Avatar, Button, Drawer, Empty, Input, Progress, Select, Table, Tag } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { QueryErrorState } from '../../components/QueryState';
import { import {
filterLessonAttendanceRecords, filterLessonAttendanceRecords,
getPunchDisplayInfo, getPunchDisplayInfo,
@@ -78,44 +79,109 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
className, className,
onClose, onClose,
}) => { }) => {
const { modal } = App.useApp();
const { hasAnyPermission } = usePermission(); const { hasAnyPermission } = usePermission();
const canEditAttendance = hasAnyPermission('attendance:edit', 'attendance:self-edit'); const canEditAttendance = hasAnyPermission('attendance:edit', 'attendance:self-edit');
const [loadedSchedule, setLoadedSchedule] = useState<LessonAttendanceSchedule | null>(null); const [loadedSchedule, setLoadedSchedule] = useState<LessonAttendanceSchedule | null>(null);
const [session, setSession] = useState<LessonAttendanceSession | null>(null); const [session, setSession] = useState<LessonAttendanceSession | null>(null);
const [records, setRecords] = useState<LessonAttendanceRecord[]>([]); const [records, setRecords] = useState<LessonAttendanceRecord[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [filter, setFilter] = useState<LessonAttendanceFilter>('all'); const [filter, setFilter] = useState<LessonAttendanceFilter>('all');
const [batchUpdating, setBatchUpdating] = useState<'present' | 'absent' | null>(null);
// 组件以 key 重挂载(关闭/切换课节),卸载后 in-flight 请求不再更新状态或弹提示
const cancelledRef = useRef(false);
useEffect(() => { /** 一键全部已打卡/全部未打卡(仅对状态不一致的记录) */
const handleBatchMark = async (status: 'present' | 'absent') => {
if (batchUpdating || records.length === 0) return;
const targetIds = records
.filter((record) =>
status === 'present'
? record.status !== 'present' && record.status !== 'late'
: record.status !== 'absent',
)
.map((record) => record.id);
if (targetIds.length === 0) {
message.success(status === 'present' ? '所有学生都已打卡' : '所有学生都未打卡');
return;
}
modal.confirm({
title: status === 'present' ? `${targetIds.length} 名学生标记为已打卡?` : `${targetIds.length} 名学生标记为未打卡?`,
content:
'此操作会立即写入考勤记录;已结算(课程截止后)的记录无法修改。',
okText: '确认',
cancelText: '取消',
onOk: async () => {
setBatchUpdating(status);
try {
const res = await api.put<{
updated: number;
failed: number;
failedIds: number[];
systemFailed: number;
}>('/attendance-records/batch-status', { ids: targetIds, status });
if (cancelledRef.current) return;
const failedSet = new Set(res.failedIds);
setRecords((items) =>
items.map((record) =>
targetIds.includes(record.id) && !failedSet.has(record.id)
? { ...record, status }
: record,
),
);
message.success(`已更新 ${res.updated} 条记录`);
if (res.failed > 0) {
const bizFailed = res.failed - (res.systemFailed ?? 0);
const parts: string[] = [];
if (bizFailed > 0) parts.push(`${bizFailed} 条可能已结算`);
if (res.systemFailed > 0) parts.push(`${res.systemFailed} 条系统错误`);
message.warning(`${res.failed} 条更新失败:${parts.join('')}`);
}
} catch (error: unknown) {
if (cancelledRef.current) return;
message.error(getErrorMessage(error, '批量更新失败'));
} finally {
if (!cancelledRef.current) setBatchUpdating(null);
}
},
});
};
const loadLesson = useCallback(async () => {
if (!schedule) return; if (!schedule) return;
let cancelled = false;
setLoadedSchedule(schedule);
setLoading(true); setLoading(true);
setError(null);
const date = dayjs().format('YYYY-MM-DD'); const date = dayjs().format('YYYY-MM-DD');
void api try {
.post<LessonAttendanceResponse>(`/attendance-lessons/schedules/${schedule.id}/pull`, { const data = await api.post<LessonAttendanceResponse>(
date, `/attendance-lessons/schedules/${schedule.id}/pull`,
}) { date },
.then((data) => { );
if (cancelled) return; if (cancelledRef.current) return;
setLoadedSchedule(data.schedule); setLoadedSchedule(data.schedule);
setSession(data.session); setSession(data.session);
setRecords(data.records); setRecords(data.records);
message.success('钉钉打卡已更新;课程截止后系统将自动结算'); message.success('钉钉打卡已更新;课程截止后系统将自动结算');
}) } catch (error: unknown) {
.catch((error: unknown) => { if (cancelledRef.current) return;
if (cancelled) return; setError(getErrorMessage(error, '加载本节课考勤失败'));
message.error(getErrorMessage(error, '加载本节课考勤失败')); } finally {
}) if (!cancelledRef.current) setLoading(false);
.finally(() => { }
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [schedule]); }, [schedule]);
useEffect(() => {
if (!schedule) return;
cancelledRef.current = false;
setLoadedSchedule(schedule);
void loadLesson();
return () => {
cancelledRef.current = true;
};
}, [schedule, loadLesson]);
const updateRecord = useCallback(async (record: LessonAttendanceRecord, status: string) => { const updateRecord = useCallback(async (record: LessonAttendanceRecord, status: string) => {
const previous = record.status; const previous = record.status;
setRecords((items) => setRecords((items) =>
@@ -148,7 +214,6 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
destroyOnHidden destroyOnHidden
> >
<div className="lesson-record-header"> <div className="lesson-record-header">
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
<h2>{displayedSchedule?.subject || '课程考勤'}</h2> <h2>{displayedSchedule?.subject || '课程考勤'}</h2>
<p> <p>
{className} · {displayedSchedule?.startTime}{displayedSchedule?.endTime} ·{' '} {className} · {displayedSchedule?.startTime}{displayedSchedule?.endTime} ·{' '}
@@ -185,8 +250,35 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
<span className="lesson-record-filter-count"> <span className="lesson-record-filter-count">
{filteredRecords.length} / {records.length} {filteredRecords.length} / {records.length}
</span> </span>
{canEditAttendance && records.length > 0 ? (
<>
<Button
size="small"
type="primary"
loading={batchUpdating === 'present'}
onClick={() => void handleBatchMark('present')}
>
</Button>
<Button
size="small"
danger
loading={batchUpdating === 'absent'}
onClick={() => void handleBatchMark('absent')}
>
</Button>
</>
) : null}
</div> </div>
<Table<LessonAttendanceRecord> {error ? (
<QueryErrorState
title="本节课考勤加载失败"
description={error}
onRetry={() => void loadLesson()}
/>
) : (
<Table<LessonAttendanceRecord> scroll={{ x: 'max-content' }}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
dataSource={filteredRecords} dataSource={filteredRecords}
@@ -266,6 +358,7 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
}, },
]} ]}
/> />
)}
</Drawer> </Drawer>
); );
}; };

View File

@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation'; import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate'; import { validateResponse } from '../../utils/validate';
import { getErrorMessage } from '../../utils/error';
import { import {
attendanceAlertsSchema, attendanceAlertsSchema,
attendanceClassOptionsSchema, attendanceClassOptionsSchema,
@@ -11,13 +12,14 @@ import {
attendanceSummarySchema, attendanceSummarySchema,
dingTalkSyncStatusSchema, dingTalkSyncStatusSchema,
} from '../../api/schemas'; } from '../../api/schemas';
import { Form, Grid } from 'antd'; import { App, Form, Grid } from 'antd';
import dayjs, { type Dayjs } from 'dayjs'; import dayjs, { type Dayjs } from 'dayjs';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { useUserStore } from '../../store/user/userStore'; import { useUserStore } from '../../store/user/userStore';
import type { AttendanceSummary } from './attendance-workspace'; import type { AttendanceSummary } from './attendance-workspace';
import { getErrorMessage } from '../../utils/error'; import { QueryErrorState } from '../../components/QueryState';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
import { AttendanceAdminHeader } from './AttendanceAdminHeader'; import { AttendanceAdminHeader } from './AttendanceAdminHeader';
import { buildAttendanceAdminColumns } from './AttendanceAdminColumns'; import { buildAttendanceAdminColumns } from './AttendanceAdminColumns';
import { PeriodConfigModal, StudentDetailDrawer } from './AttendanceAdminModals'; import { PeriodConfigModal, StudentDetailDrawer } from './AttendanceAdminModals';
@@ -37,8 +39,10 @@ import {
type DingTalkSyncStatus, type DingTalkSyncStatus,
type HistoryScheduleOption, type HistoryScheduleOption,
} from './AttendanceAdmin.helpers'; } from './AttendanceAdmin.helpers';
import { saveAs } from 'file-saver';
export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const { modal } = App.useApp();
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = !screens.sm; const isMobile = !screens.sm;
const [periodModalOpen, setPeriodModalOpen] = useState(false); const [periodModalOpen, setPeriodModalOpen] = useState(false);
@@ -55,7 +59,11 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
const [studentSearch, setStudentSearch] = useState(''); const [studentSearch, setStudentSearch] = useState('');
const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null); const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null);
const [correctingRecordId, setCorrectingRecordId] = useState<number | null>(null); const [correctingRecordId, setCorrectingRecordId] = useState<number | null>(null);
// 明细表批量纠错:选中的记录 ID + 执行中状态
const [selectedRecordIds, setSelectedRecordIds] = useState<number[]>([]);
const [batchCorrecting, setBatchCorrecting] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
useVisibleRefetch(['attendance', 'records']);
const recordQueryKey = [ const recordQueryKey = [
'attendance', 'attendance',
'records', 'records',
@@ -68,44 +76,34 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
scheduleId, scheduleId,
] as const; ] as const;
// 筛选条件变化时清空批量选择,避免把上一批条件的记录带到新条件下提交
useEffect(() => {
setSelectedRecordIds([]);
}, [classId, attendanceDate, status, session, scheduleId]);
const { data: classOptions = [] } = useQuery<ClassOption[]>({ const { data: classOptions = [] } = useQuery<ClassOption[]>({
queryKey: ['attendance', 'meta', 'classes'], queryKey: ['attendance', 'meta', 'classes'],
queryFn: async () => { queryFn: async () =>
try { validateResponse<ClassOption[]>(
return validateResponse<ClassOption[]>(
attendanceClassOptionsSchema, attendanceClassOptionsSchema,
await api.get<ClassOption[]>('/attendance-records/classes'), await api.get<ClassOption[]>('/attendance-records/classes'),
); ),
} catch {
return [];
}
},
}); });
const { data: alerts = [] } = useQuery<AlertItem[]>({ const { data: alerts = [] } = useQuery<AlertItem[]>({
queryKey: ['attendance', 'meta', 'alerts'], queryKey: ['attendance', 'meta', 'alerts'],
queryFn: async () => { queryFn: async () =>
try { validateResponse<AlertItem[]>(
return validateResponse<AlertItem[]>(
attendanceAlertsSchema, attendanceAlertsSchema,
await api.get<AlertItem[]>('/attendance-records/alerts'), await api.get<AlertItem[]>('/attendance-records/alerts'),
); ),
} catch {
return [];
}
},
}); });
const { data: periods = DEFAULT_ATTENDANCE_PERIODS } = useQuery<AttendancePeriodConfigItem[]>({ const { data: periods = DEFAULT_ATTENDANCE_PERIODS } = useQuery<AttendancePeriodConfigItem[]>({
queryKey: ['attendance', 'meta', 'periods'], queryKey: ['attendance', 'meta', 'periods'],
queryFn: async () => { queryFn: async () =>
try { validateResponse<AttendancePeriodConfigItem[]>(
return validateResponse<AttendancePeriodConfigItem[]>(
attendancePeriodsSchema, attendancePeriodsSchema,
await api.get<AttendancePeriodConfigItem[]>('/attendance-period-configs'), await api.get<AttendancePeriodConfigItem[]>('/attendance-period-configs'),
); ),
} catch {
return DEFAULT_ATTENDANCE_PERIODS;
}
},
}); });
const { const {
data: scheduleOptions = [], data: scheduleOptions = [],
@@ -114,17 +112,13 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
queryKey: ['attendance', 'schedules', classId, attendanceDate], queryKey: ['attendance', 'schedules', classId, attendanceDate],
enabled: !!classId && !!attendanceDate, enabled: !!classId && !!attendanceDate,
queryFn: async () => { queryFn: async () => {
try { if (!attendanceDate) return [];
return validateResponse<HistoryScheduleOption[]>( return validateResponse<HistoryScheduleOption[]>(
attendanceScheduleOptionsSchema, attendanceScheduleOptionsSchema,
await api.get<HistoryScheduleOption[]>('/attendance-records/schedules', { await api.get<HistoryScheduleOption[]>('/attendance-records/schedules', {
params: { classId, date: attendanceDate!.format('YYYY-MM-DD') }, params: { classId, date: attendanceDate.format('YYYY-MM-DD') },
}), }),
); );
} catch (error: unknown) {
message.error(getErrorMessage(error, '加载班级科目失败'));
return [];
}
}, },
}); });
const scheduleOptionsLoading = scheduleOptionsFetching; const scheduleOptionsLoading = scheduleOptionsFetching;
@@ -133,10 +127,6 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
? scheduleId ? scheduleId
: undefined; : undefined;
useEffect(() => {
periodForm.setFieldsValue({ periods });
}, [periods, periodForm]);
const enabledPeriods = useMemo( const enabledPeriods = useMemo(
() => periods.filter((period) => period.enabled).sort((a, b) => a.sortOrder - b.sortOrder), () => periods.filter((period) => period.enabled).sort((a, b) => a.sortOrder - b.sortOrder),
[periods], [periods],
@@ -190,13 +180,24 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
} }
}; };
const resetPeriodConfig = async () => { const resetPeriodConfig = () => {
modal.confirm({
title: '恢复默认考勤时段?',
content: '当前自定义的考勤时段配置将被系统默认值覆盖,此操作不可撤销。',
okText: '恢复默认',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try { try {
await resetPeriodConfigMutation.mutateAsync(); const data = await resetPeriodConfigMutation.mutateAsync();
// 同步回填表单,避免界面仍显示旧配置、用户再点保存把旧值写回
periodForm.setFieldsValue({ periods: data });
message.success('已恢复默认考勤时段'); message.success('已恢复默认考勤时段');
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} }
},
});
}; };
const buildParams = useCallback( const buildParams = useCallback(
@@ -214,27 +215,23 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
if (effectiveScheduleId) params.scheduleId = effectiveScheduleId; if (effectiveScheduleId) params.scheduleId = effectiveScheduleId;
return params; return params;
}, },
[page, pageSize, classId, attendanceDate, status, session, scheduleId], [page, pageSize, classId, attendanceDate, status, session, effectiveScheduleId],
); );
const { data: syncStatus = null, refetch: refetchSyncStatus } = useQuery<DingTalkSyncStatus | null>({ const { data: syncStatus = null, refetch: refetchSyncStatus } = useQuery<DingTalkSyncStatus | null>({
queryKey: ['attendance', 'sync-status'], queryKey: ['attendance', 'sync-status'],
queryFn: async () => { queryFn: async () =>
try { validateResponse<DingTalkSyncStatus>(
return validateResponse<DingTalkSyncStatus>(
dingTalkSyncStatusSchema, dingTalkSyncStatusSchema,
await api.get<DingTalkSyncStatus>('/attendance-records/dingtalk-sync-status'), await api.get<DingTalkSyncStatus>('/attendance-records/dingtalk-sync-status'),
); ),
} catch {
return null;
}
},
}); });
const loadSyncStatus = useCallback(() => refetchSyncStatus(), [refetchSyncStatus]); const loadSyncStatus = useCallback(() => refetchSyncStatus(), [refetchSyncStatus]);
const { const {
data: recordQuery = { records: [], total: 0, summary: EMPTY_SUMMARY }, data: recordQuery = { records: [], total: 0, summary: EMPTY_SUMMARY },
isFetching: recordsFetching, isFetching: recordsFetching,
isError: recordsError,
refetch: refetchRecords, refetch: refetchRecords,
} = useQuery<{ } = useQuery<{
records: AttendanceRecordItem[]; records: AttendanceRecordItem[];
@@ -243,7 +240,6 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
}>({ }>({
queryKey: recordQueryKey, queryKey: recordQueryKey,
queryFn: async () => { queryFn: async () => {
try {
const [recordData, summaryData] = await Promise.all([ const [recordData, summaryData] = await Promise.all([
api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', { api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', {
params: buildParams(true), params: buildParams(true),
@@ -265,10 +261,6 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
total: validatedRecords.total, total: validatedRecords.total,
summary: { ...EMPTY_SUMMARY, ...validatedSummary }, summary: { ...EMPTY_SUMMARY, ...validatedSummary },
}; };
} catch (error: unknown) {
message.error(getErrorMessage(error, '加载学生考勤失败'));
return { records: [], total: 0, summary: EMPTY_SUMMARY };
}
}, },
}); });
const records = recordQuery.records; const records = recordQuery.records;
@@ -334,7 +326,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
} finally { } finally {
setRefreshingDingTalk(false); setRefreshingDingTalk(false);
} }
}, [attendanceDate, classId, loadRecords, loadSyncStatus, session]); }, [attendanceDate, classId, loadRecords, loadSyncStatus, session, refreshDingTalkMutation]);
const resetFilters = () => { const resetFilters = () => {
setClassId(undefined); setClassId(undefined);
@@ -358,14 +350,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
if (!response.ok) throw new Error('导出失败'); if (!response.ok) throw new Error('导出失败');
return response.blob(); return response.blob();
}) })
.then((blob) => { .then((blob) => saveAs(blob, `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`))
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`;
anchor.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败')); .catch(() => message.error('导出失败'));
}, [buildParams]); }, [buildParams]);
@@ -418,6 +403,44 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
} }
}; };
/** 批量纠错:对选中的明细记录统一标记状态 */
const batchCorrectStatus = async (nextStatus: string) => {
if (selectedRecordIds.length === 0) return;
const statusLabel =
nextStatus === 'present' ? '正常' : nextStatus === 'leave' ? '请假' : '缺勤';
modal.confirm({
title: `将选中的 ${selectedRecordIds.length} 条记录标记为「${statusLabel}」?`,
content: '此操作会立即写入考勤记录;已结算的记录无法修改。',
okText: '确认',
cancelText: '取消',
onOk: async () => {
setBatchCorrecting(true);
try {
const res = await api.put<{
updated: number;
failed: number;
failedIds: number[];
systemFailed: number;
}>('/attendance-records/batch-status', { ids: selectedRecordIds, status: nextStatus });
setSelectedRecordIds([]);
message.success(`已更新 ${res.updated} 条记录`);
if (res.failed > 0) {
const bizFailed = res.failed - (res.systemFailed ?? 0);
const parts: string[] = [];
if (bizFailed > 0) parts.push(`${bizFailed} 条可能已结算`);
if (res.systemFailed > 0) parts.push(`${res.systemFailed} 条系统错误`);
message.warning(`${res.failed} 条更新失败:${parts.join('')}`);
}
void refetchRecords();
} catch (e: unknown) {
message.error(getErrorMessage(e, '批量更新失败'));
} finally {
setBatchCorrecting(false);
}
},
});
};
const saveAdminRecordCell = async ( const saveAdminRecordCell = async (
record: AttendanceRecordItem, record: AttendanceRecordItem,
field: 'status' | 'remark', field: 'status' | 'remark',
@@ -535,6 +558,13 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
alerts={alerts} alerts={alerts}
/> />
{recordsError ? (
<QueryErrorState
title="学生考勤数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetchRecords()}
/>
) : (
<AttendanceAdminWorkspace <AttendanceAdminWorkspace
metricFilter={metricFilter} metricFilter={metricFilter}
studentSearch={studentSearch} studentSearch={studentSearch}
@@ -555,7 +585,12 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
setPage(nextPage); setPage(nextPage);
setPageSize(nextPageSize); setPageSize(nextPageSize);
}} }}
selectedRecordKeys={selectedRecordIds}
onSelectRecords={setSelectedRecordIds}
onBatchCorrect={batchCorrectStatus}
batchCorrecting={batchCorrecting}
/> />
)}
<StudentDetailDrawer <StudentDetailDrawer
student={selectedStudent} student={selectedStudent}
@@ -574,6 +609,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
onOk={savePeriodConfig} onOk={savePeriodConfig}
onCancel={() => setPeriodModalOpen(false)} onCancel={() => setPeriodModalOpen(false)}
onReset={() => void resetPeriodConfig()} onReset={() => void resetPeriodConfig()}
confirmLoading={savePeriodConfigMutation.isPending}
/> />
</div> </div>
); );

View File

@@ -59,13 +59,6 @@
font-size: 15px; font-size: 15px;
} }
.attendance-eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 1.7px;
opacity: 0.72;
}
.teacher-topbar { .teacher-topbar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -761,7 +754,6 @@
padding: 0 24px; padding: 0 24px;
border-bottom: 1px solid var(--student-line); border-bottom: 1px solid var(--student-line);
background: rgb(255 255 255 / 96%); background: rgb(255 255 255 / 96%);
backdrop-filter: blur(10px);
} }
.student-center-title { .student-center-title {
@@ -911,21 +903,7 @@
min-height: 176px; min-height: 176px;
overflow: hidden; overflow: hidden;
padding: 20px; padding: 20px;
background: background: var(--student-surface);
radial-gradient(circle at 100% 0%, rgb(21 122 101 / 10%), transparent 34%),
linear-gradient(135deg, #ffffff 0%, #f8fcfa 100%);
}
.student-class-identity::after {
content: '';
position: absolute;
right: -36px;
bottom: -44px;
width: 118px;
height: 118px;
border: 18px solid rgb(21 122 101 / 7%);
border-radius: 999px;
pointer-events: none;
} }
.student-class-heading { .student-class-heading {
@@ -933,19 +911,6 @@
z-index: 1; z-index: 1;
} }
.student-overview-kicker {
display: inline-flex;
align-items: center;
min-height: 24px;
margin-bottom: 8px;
padding: 0 9px;
border-radius: 999px;
background: rgb(21 122 101 / 10%);
color: var(--student-primary);
font-size: 12px;
font-weight: 700;
}
.student-class-identity h2 { .student-class-identity h2 {
margin: 0 0 6px; margin: 0 0 6px;
color: #111c18; color: #111c18;

View File

@@ -1,18 +1,16 @@
import React, { useMemo } from 'react'; import React from 'react';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { useUserStore } from '../../store/user/userStore'; import { useUserStore } from '../../store/user/userStore';
import { getAttendanceExperience } from './attendance-workspace'; import { getAttendanceExperience } from './attendance-workspace';
import { TeacherAttendanceWorkspace } from './teacher'; import { TeacherAttendanceWorkspace } from './teacher';
import { AdminAttendanceArchive } from './admin'; import { AdminAttendanceArchive } from './admin';
import './attendance.css';
function readCurrentRoles(): string[] {
const roles = useUserStore.getState().user?.roles;
return Array.isArray(roles) ? roles : [];
}
const AttendancePage: React.FC = () => { const AttendancePage: React.FC = () => {
const { permissions, hasPermission } = usePermission(); const { permissions, hasPermission } = usePermission();
const roles = useMemo(readCurrentRoles, []); // 订阅 store角色变化时重新计算体验不再只在首渲染读一次
const rolesRef = useUserStore((s) => s.user?.roles);
const roles = Array.isArray(rolesRef) ? rolesRef : [];
const experience = getAttendanceExperience(permissions, roles); const experience = getAttendanceExperience(permissions, roles);
if (experience === 'teacher') { if (experience === 'teacher') {

View File

@@ -11,8 +11,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message'; import { QueryErrorState } from '../../components/QueryState';
import { getErrorMessage } from '../../utils/error';
import { import {
canPullAttendance, canPullAttendance,
getSchedulePhase, getSchedulePhase,
@@ -41,21 +40,18 @@ export const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ c
const { const {
data: workspace, data: workspace,
isLoading,
isFetching, isFetching,
isPending,
isError,
refetch, refetch,
} = useQuery<TeacherWorkspaceData | null>({ } = useQuery<TeacherWorkspaceData | null>({
queryKey: ['attendance', 'workspace'], queryKey: ['attendance', 'workspace'],
queryFn: async () => { queryFn: async () => {
try {
return await api.get<TeacherWorkspaceData>('/rbac/teacher-workspace'); return await api.get<TeacherWorkspaceData>('/rbac/teacher-workspace');
} catch (error: unknown) {
message.error(getErrorMessage(error, '加载今日课程失败'));
return null;
}
}, },
}); });
const loading = isLoading || isFetching; // isPending 覆盖自动重试的退避窗口,避免「加载失败/重试中」短暂闪现为空态
const loading = isPending || isFetching;
const loadWorkspace = useCallback(() => refetch(), [refetch]); const loadWorkspace = useCallback(() => refetch(), [refetch]);
const classNameById = useMemo( const classNameById = useMemo(
@@ -80,11 +76,10 @@ export const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ c
<div className="attendance-page teacher-attendance"> <div className="attendance-page teacher-attendance">
<section className="attendance-hero attendance-hero--teacher"> <section className="attendance-hero attendance-hero--teacher">
<div> <div>
<span className="attendance-eyebrow">
TEACHING DAY · {dayjs().format('MM月DD日 dddd')}
</span>
<h1></h1> <h1></h1>
<p></p> <p>
{dayjs().format('MM月DD日 dddd')} ·
</p>
</div> </div>
<Button icon={<ReloadOutlined />} onClick={() => void loadWorkspace()}> <Button icon={<ReloadOutlined />} onClick={() => void loadWorkspace()}>
@@ -124,7 +119,15 @@ export const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ c
</div> </div>
<Spin spinning={loading}> <Spin spinning={loading}>
{schedules.length === 0 ? ( {isError ? (
<Card className="attendance-empty-card">
<QueryErrorState
title="课程数据加载失败"
description="请检查网络后点击重试;若持续失败请联系管理员。"
onRetry={() => void loadWorkspace()}
/>
</Card>
) : schedules.length === 0 ? (
<Card className="attendance-empty-card"> <Card className="attendance-empty-card">
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}

View File

@@ -3,18 +3,22 @@ import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../hooks/useApiMutation'; import { useApiMutation } from '../hooks/useApiMutation';
import { validateResponse } from '../utils/validate'; import { validateResponse } from '../utils/validate';
import { attendanceDevicesSchema, classroomOptionsSchema } from '../api/schemas'; import { attendanceDevicesSchema, classroomOptionsSchema } from '../api/schemas';
import { Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd'; import { Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import api from '../api'; import api from '../api';
import PermissionButton from '../components/PermissionButton'; import PermissionButton from '../components/PermissionButton';
import EditableCell from '../components/EditableCell'; import EditableCell from '../components/EditableCell';
import { QueryErrorState, QueryEmpty } from '../components/QueryState';
import { message } from '../ui/app-message'; import { message } from '../ui/app-message';
import { useDirtyGuard } from '../hooks/useDirtyGuard';
import { usePermission } from '../hooks/usePermission';
interface ClassroomOption { interface ClassroomOption {
id: number; id: number;
name: string; name: string;
building?: string | null; building?: string | null;
status?: string;
} }
interface AttendanceDeviceRow { interface AttendanceDeviceRow {
@@ -34,20 +38,23 @@ const statusMeta = {
} as const; } as const;
const AttendanceDevicesPage: React.FC = () => { const AttendanceDevicesPage: React.FC = () => {
const { hasPermission } = usePermission();
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<AttendanceDeviceRow | null>(null); const [editing, setEditing] = useState<AttendanceDeviceRow | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [form] = Form.useForm(); const [form] = Form.useForm();
const formGuard = useDirtyGuard(form);
const { const {
data: fetchResult = { devices: [], classrooms: [] }, data: fetchResult = { devices: [], classrooms: [] },
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<{ devices: AttendanceDeviceRow[]; classrooms: ClassroomOption[] }>({ } = useQuery<{ devices: AttendanceDeviceRow[]; classrooms: ClassroomOption[] }>({
queryKey: ['attendance-devices'], queryKey: ['attendance-devices'],
queryFn: async () => { queryFn: async () => {
try {
const [devices, classroomList] = await Promise.all([ const [devices, classroomList] = await Promise.all([
api.get<AttendanceDeviceRow[]>('/attendance-devices'), api.get<AttendanceDeviceRow[]>('/attendance-devices'),
api.get<ClassroomOption[]>('/classrooms'), api.get<ClassroomOption[]>('/classrooms'),
@@ -57,12 +64,8 @@ const AttendanceDevicesPage: React.FC = () => {
classrooms: validateResponse<ClassroomOption[]>( classrooms: validateResponse<ClassroomOption[]>(
classroomOptionsSchema, classroomOptionsSchema,
classroomList, classroomList,
).filter((item: any) => item.status !== 'archived'), ).filter((item: ClassroomOption) => item.status !== 'archived'),
}; };
} catch (error: any) {
message.error(error?.message || '加载考勤机绑定失败');
return { devices: [], classrooms: [] };
}
}, },
}); });
const data = fetchResult.devices; const data = fetchResult.devices;
@@ -109,6 +112,7 @@ const AttendanceDevicesPage: React.FC = () => {
setEditing(null); setEditing(null);
form.resetFields(); form.resetFields();
form.setFieldsValue({ status: 'active' }); form.setFieldsValue({ status: 'active' });
formGuard.snapshot();
setModalOpen(true); setModalOpen(true);
}; };
@@ -122,6 +126,7 @@ const AttendanceDevicesPage: React.FC = () => {
location: record.location, location: record.location,
notes: record.notes, notes: record.notes,
}); });
formGuard.snapshot();
setModalOpen(true); setModalOpen(true);
}; };
@@ -307,22 +312,43 @@ const AttendanceDevicesPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</div> </div>
<Table<AttendanceDeviceRow> {isError ? (
<QueryErrorState
title="考勤机数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : (
<Table<AttendanceDeviceRow> scroll={{ x: 'max-content' }}
rowKey="id" rowKey="id"
columns={columns} columns={columns}
dataSource={filteredData} dataSource={filteredData}
loading={loading} loading={loading}
locale={{ emptyText: <Empty description="暂无考勤机绑定" /> }} locale={{
emptyText: (
<QueryEmpty
description="暂无考勤机绑定"
action={
hasPermission('classroom:edit')
? { label: '添加考勤机', icon: <PlusOutlined />, onClick: openCreate }
: undefined
}
/>
),
}}
pagination={{ defaultPageSize: 20, showSizeChanger: true }} pagination={{ defaultPageSize: 20, showSizeChanger: true }}
/> />
)}
<Modal <Modal
title={editing ? '编辑考勤机绑定' : '添加考勤机绑定'} title={editing ? '编辑考勤机绑定' : '添加考勤机绑定'}
open={modalOpen} open={modalOpen}
onOk={handleSave} onOk={handleSave}
onCancel={() => { onCancel={() =>
formGuard.confirmClose(() => {
setModalOpen(false); setModalOpen(false);
setEditing(null); setEditing(null);
}} })
}
confirmLoading={saving} confirmLoading={saving}
okText="保存" okText="保存"
> >

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import { import {
App, App,
Table, Table,
@@ -13,7 +13,6 @@ import {
Input, Input,
Select, Select,
Spin, Spin,
Empty,
} from 'antd'; } from 'antd';
import { import {
FileTextOutlined, FileTextOutlined,
@@ -24,8 +23,12 @@ import {
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { downloadBlob } from '../../utils/download'; import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
import { NextStepHint } from '../../components/NextStepHint';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
import { useDownload } from '../../hooks/useDownload';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { RefreshButton } from '../../components/RefreshButton';
import { buildBillPrintHtml, type BillPrintData } from './bill-print'; import { buildBillPrintHtml, type BillPrintData } from './bill-print';
import { newOperationId } from '../../utils/operation-id'; import { newOperationId } from '../../utils/operation-id';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
@@ -64,26 +67,27 @@ const BillsPage: React.FC = () => {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [batchLoading, setBatchLoading] = useState(false); const [batchLoading, setBatchLoading] = useState(false);
// 生成账单成功后的「下一步」引导提示
const [billGeneratedHint, setBillGeneratedHint] = useState(false);
const { const {
data: bills = [], data: bills = [],
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery({ } = useQuery({
queryKey: ['bills', filterStatus, filterExpenseType], queryKey: ['bills', filterStatus, filterExpenseType],
queryFn: async () => { queryFn: async () => {
try {
const params: Record<string, string | undefined> = {}; const params: Record<string, string | undefined> = {};
if (filterStatus) params.status = filterStatus; if (filterStatus) params.status = filterStatus;
if (filterExpenseType) params.expenseType = filterExpenseType; if (filterExpenseType) params.expenseType = filterExpenseType;
return validateResponse<unknown[]>(billsSchema, await api.get('/bills', { params })); return validateResponse<unknown[]>(billsSchema, await api.get('/bills', { params }));
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
return [];
}
}, },
}); });
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新账单列表
useVisibleRefetch(['bills']);
const generateMutation = useApiMutation( const generateMutation = useApiMutation(
async (payload: { operationId: string; billingMonth: string }) => async (payload: { operationId: string; billingMonth: string }) =>
@@ -122,8 +126,8 @@ const BillsPage: React.FC = () => {
}, [bills, searchText, filterStatus]); }, [bills, searchText, filterStatus]);
const handleGenerate = async () => { const handleGenerate = async () => {
setSaving(true);
const values = await generateForm.validateFields(); const values = await generateForm.validateFields();
setSaving(true);
try { try {
const res: any = await generateMutation.mutateAsync({ const res: any = await generateMutation.mutateAsync({
operationId: newOperationId(), operationId: newOperationId(),
@@ -132,6 +136,7 @@ const BillsPage: React.FC = () => {
message.success(res.message || '生成成功'); message.success(res.message || '生成成功');
setGenerateModal(false); setGenerateModal(false);
generateForm.resetFields(); generateForm.resetFields();
setBillGeneratedHint(true);
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} finally { } finally {
@@ -139,7 +144,12 @@ const BillsPage: React.FC = () => {
} }
}; };
const showDetail = async (id: number) => { const openGenerateModal = () => {
generateForm.resetFields();
setGenerateModal(true);
};
const showDetail = useCallback(async (id: number) => {
setDetailLoading(true); setDetailLoading(true);
try { try {
const res = await api.get(`/bills/${id}`); const res = await api.get(`/bills/${id}`);
@@ -149,9 +159,10 @@ const BillsPage: React.FC = () => {
} finally { } finally {
setDetailLoading(false); setDetailLoading(false);
} }
}; }, []);
const handleCancel = async (id: number) => { const handleCancel = useCallback(
(id: number) => {
let reason = ''; let reason = '';
modal.confirm({ modal.confirm({
title: '取消账单并退回已扣余额', title: '取消账单并退回已扣余额',
@@ -175,18 +186,24 @@ const BillsPage: React.FC = () => {
message.success('账单已取消,已扣余额已冲正退回'); message.success('账单已取消,已扣余额已冲正退回');
}, },
}); });
}; },
[modal, cancelMutation],
);
const handleArchive = async (id: number) => { const handleArchive = useCallback(
async (id: number) => {
try { try {
await archiveMutation.mutateAsync(id); await archiveMutation.mutateAsync(id);
message.success('账单已归档'); message.success('账单已归档');
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} }
}; },
[archiveMutation],
);
const handlePurge = (id: number, studentName: string, period: string) => { const handlePurge = useCallback(
(id: number, studentName: string, period: string) => {
modal.confirm({ modal.confirm({
title: `永久删除账单(${studentName} ${period}`, title: `永久删除账单(${studentName} ${period}`,
content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?', content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?',
@@ -202,7 +219,9 @@ const BillsPage: React.FC = () => {
} }
}, },
}); });
}; },
[modal, purgeMutation],
);
const batchArchive = async () => { const batchArchive = async () => {
if (selectedRows.length === 0) return message.warning('请先选择账单'); if (selectedRows.length === 0) return message.warning('请先选择账单');
@@ -219,14 +238,16 @@ const BillsPage: React.FC = () => {
} }
}; };
const { downloading: exportExcelDownloading, run: runExportExcel } = useDownload();
const handleExportExcel = () => { const handleExportExcel = () => {
downloadBlob('/bills/export/excel', `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`).then( void runExportExcel(`/bills/export/excel`, `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`, {
() => message.success('Excel 导出成功'), successMsg: 'Excel 导出成功',
() => message.error('导出失败'), errorMsg: '导出失败',
); });
}; };
const handleExportPdf = async (billId: number) => { const handleExportPdf = useCallback(async (billId: number) => {
const printWindow = window.open('', '_blank'); const printWindow = window.open('', '_blank');
if (!printWindow) { if (!printWindow) {
message.error('浏览器阻止了打印窗口,请允许弹出窗口后重试'); message.error('浏览器阻止了打印窗口,请允许弹出窗口后重试');
@@ -245,7 +266,7 @@ const BillsPage: React.FC = () => {
printWindow.close(); printWindow.close();
message.error(error?.message || '账单加载失败'); message.error(error?.message || '账单加载失败');
} }
}; }, []);
const columns = useMemo( const columns = useMemo(
() => [ () => [
@@ -314,6 +335,7 @@ const BillsPage: React.FC = () => {
}, },
{ {
title: '操作', title: '操作',
fixed: 'right' as const,
width: 320, width: 320,
render: (_: any, record: any) => ( render: (_: any, record: any) => (
<Space> <Space>
@@ -441,26 +463,47 @@ const BillsPage: React.FC = () => {
</Popconfirm> </Popconfirm>
</Space> </Space>
<Space wrap className="responsive-toolbar__group"> <Space wrap className="responsive-toolbar__group">
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
<PermissionButton <PermissionButton
permission="bill:generate" permission="bill:generate"
type="primary" type="primary"
icon={<FileTextOutlined />} icon={<FileTextOutlined />}
onClick={() => { onClick={openGenerateModal}
generateForm.resetFields();
setGenerateModal(true);
}}
> >
</PermissionButton> </PermissionButton>
<PermissionButton <PermissionButton
permission="bill:export-excel" permission="bill:export-excel"
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
loading={exportExcelDownloading}
onClick={handleExportExcel} onClick={handleExportExcel}
> >
Excel Excel
</PermissionButton> </PermissionButton>
</Space> </Space>
</div> </div>
{billGeneratedHint && (
<NextStepHint
title="账单已生成"
description="请核对账单明细,确认后标记已付,完成「住宿→计费」闭环。"
action={{
label: '筛选待确认账单',
onClick: () => {
// 账单生成后即为 unpaid待支付状态
setFilterStatus('unpaid');
setBillGeneratedHint(false);
},
}}
onClose={() => setBillGeneratedHint(false)}
/>
)}
{isError ? (
<QueryErrorState
title="账单数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : (
<Table <Table
scroll={{ x: 1400 }} scroll={{ x: 1400 }}
columns={columns} columns={columns}
@@ -468,12 +511,24 @@ const BillsPage: React.FC = () => {
rowKey="id" rowKey="id"
loading={loading} loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }} pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
locale={{ emptyText: <Empty description="暂无数据" /> }} locale={{
emptyText: (
<QueryEmpty
description="暂无账单"
action={
hasPermission('bill:generate')
? { label: '生成账单', onClick: openGenerateModal }
: undefined
}
/>
),
}}
rowSelection={{ rowSelection={{
selectedRowKeys: selectedRows, selectedRowKeys: selectedRows,
onChange: (keys) => setSelectedRows(keys as number[]), onChange: (keys) => setSelectedRows(keys as number[]),
}} }}
/> />
)}
<Modal <Modal
title="生成账单" title="生成账单"
@@ -512,7 +567,7 @@ const BillsPage: React.FC = () => {
> >
{detailModal && ( {detailModal && (
<Spin spinning={detailLoading}> <Spin spinning={detailLoading}>
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}> <Descriptions bordered size="small" column={{ xs: 1, sm: 2 }} style={{ marginBottom: 16 }}>
<Descriptions.Item label="学生">{detailModal.student?.name}</Descriptions.Item> <Descriptions.Item label="学生">{detailModal.student?.name}</Descriptions.Item>
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
<Tag color={statusMap[detailModal.status]?.color}> <Tag color={statusMap[detailModal.status]?.color}>
@@ -537,7 +592,7 @@ const BillsPage: React.FC = () => {
</strong> </strong>
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
<Descriptions bordered size="small" column={3} style={{ marginBottom: 16 }}> <Descriptions bordered size="small" column={{ xs: 1, sm: 2, lg: 3 }} style={{ marginBottom: 16 }}>
<Descriptions.Item label="已扣余额"> <Descriptions.Item label="已扣余额">
¥{Number(detailModal.paidAmount || 0).toFixed(2)} ¥{Number(detailModal.paidAmount || 0).toFixed(2)}
</Descriptions.Item> </Descriptions.Item>

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import { import {
Button, Button,
Card, Card,
@@ -22,8 +22,11 @@ import { DownloadOutlined, PlusOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useUserStore } from '../../store/user/userStore'; import { useUserStore } from '../../store/user/userStore';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { QueryEmpty } from '../../components/QueryState';
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate'; import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
import { saveAs } from 'file-saver';
export interface ClassStudent { export interface ClassStudent {
id: number; id: number;
@@ -204,7 +207,7 @@ export const ClassInfoTab: React.FC<{
</Form> </Form>
) : ( ) : (
<div> <div>
<Descriptions column={3} bordered size="small"> <Descriptions column={{ xs: 1, sm: 2, lg: 3 }} bordered size="small">
<Descriptions.Item label="班型">{TYPE_MAP[detail.classType]}</Descriptions.Item> <Descriptions.Item label="班型">{TYPE_MAP[detail.classType]}</Descriptions.Item>
<Descriptions.Item label="开班日期"> <Descriptions.Item label="开班日期">
{detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'} {detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'}
@@ -246,6 +249,7 @@ export const ClassStudentsTab: React.FC<{
onClose: () => void; onClose: () => void;
onRemove: (studentId: number) => void; onRemove: (studentId: number) => void;
onSelect: (ids: number[]) => void; onSelect: (ids: number[]) => void;
adding?: boolean;
}> = ({ }> = ({
id, id,
detail, detail,
@@ -258,7 +262,9 @@ export const ClassStudentsTab: React.FC<{
onClose, onClose,
onRemove, onRemove,
onSelect, onSelect,
adding,
}) => { }) => {
const [exporting, setExporting] = useState(false);
const studentColumns: ColumnsType<ClassStudent> = [ const studentColumns: ColumnsType<ClassStudent> = [
{ title: '姓名', dataIndex: 'studentName' }, { title: '姓名', dataIndex: 'studentName' },
{ title: '学号', dataIndex: 'studentNo' }, { title: '学号', dataIndex: 'studentNo' },
@@ -297,25 +303,24 @@ export const ClassStudentsTab: React.FC<{
<PermissionButton <PermissionButton
permission="class:view" permission="class:view"
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
onClick={() => { loading={exporting}
onClick={async () => {
setExporting(true);
try {
const token = useUserStore.getState().token; const token = useUserStore.getState().token;
fetch(`/api/classes/${id}/roster/export`, { const res = await fetch(`/api/classes/${id}/roster/export`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) });
.then((res) => {
if (!res.ok) throw new Error('导出失败'); if (!res.ok) throw new Error('导出失败');
return res.blob(); const blob = await res.blob();
}) saveAs(blob, `班级花名册-${detail?.name || id}.xlsx`);
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `班级花名册-${detail?.name || id}.xlsx`;
a.click();
URL.revokeObjectURL(url);
message.success('花名册导出成功'); message.success('花名册导出成功');
}) } catch (error) {
.catch(() => message.error('花名册导出失败')); console.error('花名册导出失败', error);
message.error('花名册导出失败');
} finally {
setExporting(false);
}
}} }}
> >
@@ -324,13 +329,26 @@ export const ClassStudentsTab: React.FC<{
columns={studentColumns} columns={studentColumns}
dataSource={students} dataSource={students}
rowKey="id" rowKey="id"
locale={{
emptyText: (
<QueryEmpty
description="班级还没有学员"
action={{
label: '添加学员',
type: 'primary',
icon: <PlusOutlined />,
onClick: onOpen,
}}
/>
),
}}
pagination={{ pagination={{
defaultPageSize: 20, defaultPageSize: 20,
showSizeChanger: true, showSizeChanger: true,
pageSizeOptions: [20, 50, 100], pageSizeOptions: [20, 50, 100],
}} }}
/> />
<Modal title="添加学员" open={modalOpen} onOk={onAdd} onCancel={onClose}> <Modal title="添加学员" open={modalOpen} onOk={onAdd} onCancel={onClose} confirmLoading={adding}>
<Select <Select
mode="multiple" mode="multiple"
style={{ width: '100%' }} style={{ width: '100%' }}
@@ -365,6 +383,7 @@ export const ClassTeachersTab: React.FC<{
onSubjectChange: (subject: string) => void; onSubjectChange: (subject: string) => void;
onUserChange: (userId?: number) => void; onUserChange: (userId?: number) => void;
getTeacherName: (teacher: ClassTeacher) => string; getTeacherName: (teacher: ClassTeacher) => string;
adding?: boolean;
}> = ({ }> = ({
teachers, teachers,
allUsers, allUsers,
@@ -380,7 +399,9 @@ export const ClassTeachersTab: React.FC<{
onSubjectChange, onSubjectChange,
onUserChange, onUserChange,
getTeacherName, getTeacherName,
adding,
}) => { }) => {
useSubmitShortcut(modalOpen, onAdd);
const teacherColumns: ColumnsType<ClassTeacher> = [ const teacherColumns: ColumnsType<ClassTeacher> = [
{ title: '姓名', render: (_: unknown, teacher) => getTeacherName(teacher) }, { title: '姓名', render: (_: unknown, teacher) => getTeacherName(teacher) },
{ {
@@ -425,7 +446,7 @@ export const ClassTeachersTab: React.FC<{
pageSizeOptions: [20, 50, 100], pageSizeOptions: [20, 50, 100],
}} }}
/> />
<Modal title="添加教师" open={modalOpen} onOk={onAdd} onCancel={onClose}> <Modal title="添加教师" open={modalOpen} onOk={onAdd} onCancel={onClose} confirmLoading={adding}>
<Space orientation="vertical" style={{ width: '100%' }}> <Space orientation="vertical" style={{ width: '100%' }}>
<Select <Select
style={{ width: '100%' }} style={{ width: '100%' }}
@@ -531,22 +552,22 @@ export const ClassAttendanceTab: React.FC<{
{attendanceSummary && ( {attendanceSummary && (
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={12} md={6}> <Col xs={12} md={6}>
<Card bordered={false}> <Card variant="borderless">
<Statistic title="总记录" value={attendanceSummary.total} /> <Statistic title="总记录" value={attendanceSummary.total} />
</Card> </Card>
</Col> </Col>
<Col xs={12} md={6}> <Col xs={12} md={6}>
<Card bordered={false}> <Card variant="borderless">
<Statistic title="出勤率" value={attendanceSummary.presentRate} suffix="%" /> <Statistic title="出勤率" value={attendanceSummary.presentRate} suffix="%" />
</Card> </Card>
</Col> </Col>
<Col xs={12} md={6}> <Col xs={12} md={6}>
<Card bordered={false}> <Card variant="borderless">
<Statistic title="缺勤率" value={attendanceSummary.absentRate} suffix="%" /> <Statistic title="缺勤率" value={attendanceSummary.absentRate} suffix="%" />
</Card> </Card>
</Col> </Col>
<Col xs={12} md={6}> <Col xs={12} md={6}>
<Card bordered={false}> <Card variant="borderless">
<Statistic title="迟到率" value={attendanceSummary.lateRate} suffix="%" /> <Statistic title="迟到率" value={attendanceSummary.lateRate} suffix="%" />
</Card> </Card>
</Col> </Col>

View File

@@ -1,6 +1,6 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router'; import { useParams, useNavigate } from 'react-router';
import { Button, Card, Form, Space, Tabs, Tag } from 'antd'; import {Button, Card, Form, Space, Tabs, Tag, Skeleton} from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
@@ -8,6 +8,7 @@ import { message } from '../../ui/app-message';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import type { TeacherCandidateUser } from './teacher-candidate'; import type { TeacherCandidateUser } from './teacher-candidate';
import { getErrorMessage } from '../../utils/error'; import { getErrorMessage } from '../../utils/error';
import { QueryErrorState } from '../../components/QueryState';
import { import {
ClassAttendanceTab, ClassAttendanceTab,
ClassInfoTab, ClassInfoTab,
@@ -32,6 +33,8 @@ const ClassDetailPage: React.FC = () => {
const [studentModalOpen, setStudentModalOpen] = useState(false); const [studentModalOpen, setStudentModalOpen] = useState(false);
const [allStudents, setAllStudents] = useState<StudentItem[]>([]); const [allStudents, setAllStudents] = useState<StudentItem[]>([]);
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]); const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
const [addingStudents, setAddingStudents] = useState(false);
const [addingTeacher, setAddingTeacher] = useState(false);
// Teacher modal state // Teacher modal state
const [teacherModalOpen, setTeacherModalOpen] = useState(false); const [teacherModalOpen, setTeacherModalOpen] = useState(false);
@@ -51,6 +54,7 @@ const ClassDetailPage: React.FC = () => {
data: detailResult = { detail: null, students: [], teachers: [] }, data: detailResult = { detail: null, students: [], teachers: [] },
isLoading: detailLoading, isLoading: detailLoading,
isFetching: detailFetching, isFetching: detailFetching,
isError: detailError,
refetch: refetchDetail, refetch: refetchDetail,
} = useQuery<{ } = useQuery<{
detail: ClassDetail | null; detail: ClassDetail | null;
@@ -59,13 +63,8 @@ const ClassDetailPage: React.FC = () => {
}>({ }>({
queryKey: ['classes', 'detail', id], queryKey: ['classes', 'detail', id],
queryFn: async () => { queryFn: async () => {
try {
const res = (await api.get(`/classes/${id}`)) as ClassDetail; const res = (await api.get(`/classes/${id}`)) as ClassDetail;
return { detail: res, students: res.students || [], teachers: res.teachers || [] }; return { detail: res, students: res.students || [], teachers: res.teachers || [] };
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败'));
return { detail: null, students: [], teachers: [] };
}
}, },
}); });
const detail = detailResult.detail; const detail = detailResult.detail;
@@ -74,39 +73,41 @@ const ClassDetailPage: React.FC = () => {
const loading = detailLoading || detailFetching; const loading = detailLoading || detailFetching;
const fetchDetail = useCallback(() => refetchDetail(), [refetchDetail]); const fetchDetail = useCallback(() => refetchDetail(), [refetchDetail]);
const { data: allUsers = [], refetch: refetchUsers } = useQuery<TeacherCandidateUser[]>({ const {
data: allUsers = [],
isError: allUsersError,
refetch: refetchUsers,
} = useQuery<TeacherCandidateUser[]>({
queryKey: ['rbac', 'users', 'all'], queryKey: ['rbac', 'users', 'all'],
queryFn: async () => { queryFn: async () => {
try {
return (await api.get('/rbac/users')) as TeacherCandidateUser[]; return (await api.get('/rbac/users')) as TeacherCandidateUser[];
} catch {
return [];
}
}, },
}); });
const fetchUsers = useCallback(() => refetchUsers(), [refetchUsers]); const fetchUsers = useCallback(() => refetchUsers(), [refetchUsers]);
const { data: schedules = [] } = useQuery<ClassScheduleItem[]>({ const {
data: schedules = [],
isError: schedulesError,
refetch: refetchSchedules,
} = useQuery<ClassScheduleItem[]>({
queryKey: ['classes', 'schedule', id, scheduleDateRange], queryKey: ['classes', 'schedule', id, scheduleDateRange],
queryFn: async () => { queryFn: async () => {
if (!id) return []; if (!id) return [];
try {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD'); if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD');
if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD'); if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD');
return (await api.get<ClassScheduleItem[]>(`/classes/${id}/schedule`, { params })) || []; return (await api.get<ClassScheduleItem[]>(`/classes/${id}/schedule`, { params })) || [];
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载课表失败'));
return [];
}
}, },
}); });
const { data: attendanceSummary = null } = useQuery<AttendanceSummary | null>({ const {
data: attendanceSummary = null,
isError: attendanceSummaryError,
refetch: refetchAttendanceSummary,
} = useQuery<AttendanceSummary | null>({
queryKey: ['classes', 'attendance-summary', id, attendanceDateRange], queryKey: ['classes', 'attendance-summary', id, attendanceDateRange],
queryFn: async () => { queryFn: async () => {
if (!id) return null; if (!id) return null;
try {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (attendanceDateRange?.[0]) if (attendanceDateRange?.[0])
params.startDate = attendanceDateRange[0].format('YYYY-MM-DD'); params.startDate = attendanceDateRange[0].format('YYYY-MM-DD');
@@ -116,10 +117,6 @@ const ClassDetailPage: React.FC = () => {
params, params,
})) || null })) || null
); );
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载出勤汇总失败'));
return null;
}
}, },
}); });
@@ -156,6 +153,7 @@ const ClassDetailPage: React.FC = () => {
const handleAddStudents = async () => { const handleAddStudents = async () => {
if (!selectedStudentIds.length) return; if (!selectedStudentIds.length) return;
setAddingStudents(true);
try { try {
await api.post(`/classes/${id}/students`, { studentIds: selectedStudentIds }); await api.post(`/classes/${id}/students`, { studentIds: selectedStudentIds });
setStudentModalOpen(false); setStudentModalOpen(false);
@@ -164,11 +162,14 @@ const ClassDetailPage: React.FC = () => {
message.success('已添加'); message.success('已添加');
} catch (e: unknown) { } catch (e: unknown) {
message.error(getErrorMessage(e, '添加失败')); message.error(getErrorMessage(e, '添加失败'));
} finally {
setAddingStudents(false);
} }
}; };
const handleAddTeacher = async () => { const handleAddTeacher = async () => {
if (!teacherUserId) return; if (!teacherUserId) return;
setAddingTeacher(true);
try { try {
await api.post(`/classes/${id}/teachers`, { await api.post(`/classes/${id}/teachers`, {
userId: teacherUserId, userId: teacherUserId,
@@ -180,6 +181,8 @@ const ClassDetailPage: React.FC = () => {
message.success('已添加'); message.success('已添加');
} catch (e: unknown) { } catch (e: unknown) {
message.error(getErrorMessage(e, '添加失败')); message.error(getErrorMessage(e, '添加失败'));
} finally {
setAddingTeacher(false);
} }
}; };
@@ -221,7 +224,25 @@ const ClassDetailPage: React.FC = () => {
const getTeacherName = (teacher: ClassTeacher) => const getTeacherName = (teacher: ClassTeacher) =>
allUsers.find((user) => user.id === teacher.userId)?.name?.trim() || '-'; allUsers.find((user) => user.id === teacher.userId)?.name?.trim() || '-';
if (!detail) return null; if (!detail) {
if (loading) {
return (
<div style={{ padding: 24 }}>
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
if (detailError) {
return (
<QueryErrorState
title="班级详情加载失败"
description="请检查网络后重试。"
onRetry={() => void refetchDetail()}
/>
);
}
return null;
}
return ( return (
<Card <Card
@@ -283,13 +304,20 @@ const ClassDetailPage: React.FC = () => {
onClose={() => setStudentModalOpen(false)} onClose={() => setStudentModalOpen(false)}
onRemove={handleRemoveStudent} onRemove={handleRemoveStudent}
onSelect={setSelectedStudentIds} onSelect={setSelectedStudentIds}
adding={addingStudents}
/> />
), ),
}, },
{ {
key: 'teachers', key: 'teachers',
label: `教师 (${teachers.length})`, label: `教师 (${teachers.length})`,
children: ( children: allUsersError ? (
<QueryErrorState
title="可添加教师加载失败"
description="请检查网络后重试。"
onRetry={() => void fetchUsers()}
/>
) : (
<ClassTeachersTab <ClassTeachersTab
teachers={teachers} teachers={teachers}
allUsers={allUsers} allUsers={allUsers}
@@ -305,13 +333,20 @@ const ClassDetailPage: React.FC = () => {
onSubjectChange={setTeacherSubject} onSubjectChange={setTeacherSubject}
onUserChange={setTeacherUserId} onUserChange={setTeacherUserId}
getTeacherName={getTeacherName} getTeacherName={getTeacherName}
adding={addingTeacher}
/> />
), ),
}, },
{ {
key: 'schedule', key: 'schedule',
label: '课表', label: '课表',
children: ( children: schedulesError ? (
<QueryErrorState
title="课表加载失败"
description="请检查网络后重试。"
onRetry={() => void refetchSchedules()}
/>
) : (
<ClassScheduleTab <ClassScheduleTab
schedules={schedules} schedules={schedules}
scheduleDateRange={scheduleDateRange} scheduleDateRange={scheduleDateRange}
@@ -322,7 +357,13 @@ const ClassDetailPage: React.FC = () => {
{ {
key: 'attendance-summary', key: 'attendance-summary',
label: '出勤汇总', label: '出勤汇总',
children: ( children: attendanceSummaryError ? (
<QueryErrorState
title="出勤汇总加载失败"
description="请检查网络后重试。"
onRetry={() => void refetchAttendanceSummary()}
/>
) : (
<ClassAttendanceTab <ClassAttendanceTab
attendanceSummary={attendanceSummary} attendanceSummary={attendanceSummary}
attendanceDateRange={attendanceDateRange} attendanceDateRange={attendanceDateRange}

View File

@@ -19,7 +19,6 @@ import {
Popconfirm, Popconfirm,
Card, Card,
Switch, Switch,
Empty,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons'; import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons';
@@ -30,6 +29,9 @@ import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell'; import EditableCell from '../../components/EditableCell';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
interface ClassItem { interface ClassItem {
id: number; id: number;
@@ -86,44 +88,19 @@ const ClassesPage: React.FC = () => {
const [filterStatus, setFilterStatus] = useState<string>(); const [filterStatus, setFilterStatus] = useState<string>();
const [filterType, setFilterType] = useState<string>(); const [filterType, setFilterType] = useState<string>();
const [form] = Form.useForm<ClassFormValues>(); const [form] = Form.useForm<ClassFormValues>();
const classFormGuard = useDirtyGuard(form);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const handleArchive = async (id: number, archive: boolean) => {
try {
await archiveMutation.mutateAsync({ id, archive });
message.success(archive ? '已归档' : '已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handlePurge = (record: ClassItem) => {
modal.confirm({
title: `永久删除班级「${record.name}」?`,
content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const { const {
data = [], data = [],
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<ClassItem[]>({ } = useQuery<ClassItem[]>({
queryKey: ['classes', filterStatus, filterType, showArchived], queryKey: ['classes', filterStatus, filterType, showArchived],
queryFn: async () => { queryFn: async () => {
try {
const params: Record<string, string | boolean | undefined> = {}; const params: Record<string, string | boolean | undefined> = {};
if (filterStatus) params.status = filterStatus; if (filterStatus) params.status = filterStatus;
if (filterType) params.classType = filterType; if (filterType) params.classType = filterType;
@@ -132,13 +109,11 @@ const ClassesPage: React.FC = () => {
classesSchema, classesSchema,
await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>), await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>),
); );
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
return [];
}
}, },
}); });
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据
useVisibleRefetch(['classes']);
const saveMutation = useApiMutation( const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) => async (payload: Record<string, unknown>) =>
@@ -160,6 +135,39 @@ const ClassesPage: React.FC = () => {
{ invalidate: [['classes']] }, { invalidate: [['classes']] },
); );
const handleArchive = useCallback(
async (id: number, archive: boolean) => {
try {
await archiveMutation.mutateAsync({ id, archive });
message.success(archive ? '已归档' : '已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[archiveMutation],
);
const handlePurge = useCallback(
(record: ClassItem) => {
modal.confirm({
title: `永久删除班级「${record.name}」?`,
content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
},
[modal, purgeMutation],
);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!searchText) return data; if (!searchText) return data;
const q = searchText.toLowerCase(); const q = searchText.toLowerCase();
@@ -171,10 +179,12 @@ const ClassesPage: React.FC = () => {
const handleCreate = () => { const handleCreate = () => {
setEditing(null); setEditing(null);
form.resetFields(); form.resetFields();
classFormGuard.snapshot();
setModalOpen(true); setModalOpen(true);
}; };
const handleEdit = (record: ClassItem) => { const handleEdit = useCallback(
(record: ClassItem) => {
setEditing(record); setEditing(record);
form.setFieldsValue({ form.setFieldsValue({
...record, ...record,
@@ -182,8 +192,11 @@ const ClassesPage: React.FC = () => {
startDate: record.startDate ? dayjs(record.startDate) : undefined, startDate: record.startDate ? dayjs(record.startDate) : undefined,
endDate: record.endDate ? dayjs(record.endDate) : undefined, endDate: record.endDate ? dayjs(record.endDate) : undefined,
}); });
classFormGuard.snapshot();
setModalOpen(true); setModalOpen(true);
}; },
[form, classFormGuard],
);
const handleSubmit = async () => { const handleSubmit = async () => {
setSaving(true); setSaving(true);
@@ -369,7 +382,7 @@ const ClassesPage: React.FC = () => {
), ),
}, },
], ],
[saveCell, canPurgeClass, handlePurge], [saveCell, canPurgeClass, handlePurge, navigate, handleEdit, handleArchive],
); );
return ( return (
@@ -421,12 +434,30 @@ const ClassesPage: React.FC = () => {
/> />
</span> </span>
</Space> </Space>
{isError ? (
<QueryErrorState
title="班级数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : (
<Table<ClassItem> <Table<ClassItem>
columns={columns} columns={columns}
dataSource={filtered} dataSource={filtered}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }} locale={{
emptyText: (
<QueryEmpty
description="暂无班级数据"
action={
hasPermission('class:create')
? { label: '创建班级', icon: <PlusOutlined />, onClick: handleCreate }
: undefined
}
/>
),
}}
pagination={{ pagination={{
defaultPageSize: 20, defaultPageSize: 20,
showSizeChanger: true, showSizeChanger: true,
@@ -434,12 +465,13 @@ const ClassesPage: React.FC = () => {
}} }}
scroll={{ x: 1100 }} scroll={{ x: 1100 }}
/> />
)}
<Modal <Modal
title={editing ? '编辑班级' : '创建班级'} title={editing ? '编辑班级' : '创建班级'}
open={modalOpen} open={modalOpen}
onOk={handleSubmit} onOk={handleSubmit}
onCancel={() => setModalOpen(false)} onCancel={() => classFormGuard.confirmClose(() => setModalOpen(false))}
confirmLoading={saving} confirmLoading={saving}
width={600} width={600}
> >

View File

@@ -1,7 +1,6 @@
import React from 'react'; import React, { useState } from 'react';
import { import {
Button, Button,
Empty,
Popconfirm, Popconfirm,
Space, Space,
Table, Table,
@@ -19,6 +18,7 @@ import dayjs from 'dayjs';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell'; import EditableCell from '../../components/EditableCell';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { QueryEmpty } from '../../components/QueryState';
const RENTAL_FIELDS = { const RENTAL_FIELDS = {
classroomId: 'classroomId', classroomId: 'classroomId',
@@ -43,7 +43,11 @@ export interface RentalTableProps {
onPurge: (id: number, name: string) => void; onPurge: (id: number, name: string) => void;
onDownloadContract: (id: number, filename?: string) => void; onDownloadContract: (id: number, filename?: string) => void;
onDeleteContract: (id: number) => void; onDeleteContract: (id: number) => void;
onUploadContract: (id: number, formData: FormData) => Promise<unknown>; onUploadContract: (
id: number,
formData: FormData,
onProgress?: (percent: number) => void,
) => Promise<unknown>;
} }
export const RentalTable: React.FC<RentalTableProps> = ({ export const RentalTable: React.FC<RentalTableProps> = ({
@@ -62,6 +66,9 @@ export const RentalTable: React.FC<RentalTableProps> = ({
onDeleteContract, onDeleteContract,
onUploadContract, onUploadContract,
}) => { }) => {
const [uploadingContractId, setUploadingContractId] = useState<number | null>(null);
const [contractPercent, setContractPercent] = useState(0);
const EditableRentalCell = <R extends { id: number; effectiveStatus?: string }>({ const EditableRentalCell = <R extends { id: number; effectiveStatus?: string }>({
value, value,
field, field,
@@ -250,17 +257,28 @@ export const RentalTable: React.FC<RentalTableProps> = ({
} }
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
setUploadingContractId(r.id);
setContractPercent(0);
try { try {
await onUploadContract(r.id, formData); await onUploadContract(r.id, formData, (percent) => setContractPercent(percent));
message.success('合同已上传'); message.success('合同已上传');
onSuccess?.({}); onSuccess?.({});
} catch (e) { } catch (e) {
onError?.(e as Error); onError?.(e as Error);
} finally {
setUploadingContractId(null);
setContractPercent(0);
} }
}} }}
> >
<Button size="small" icon={<UploadOutlined />}> <Button
PDF size="small"
icon={<UploadOutlined />}
loading={uploadingContractId === r.id}
>
{uploadingContractId === r.id && contractPercent > 0 && contractPercent < 100
? `上传中 ${contractPercent}%`
: '上传PDF'}
</Button> </Button>
</Upload> </Upload>
) : ( ) : (
@@ -327,7 +345,7 @@ export const RentalTable: React.FC<RentalTableProps> = ({
dataSource={data} dataSource={data}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }} locale={{ emptyText: <QueryEmpty description="暂无租赁订单,点击右上角「新增租赁」创建第一笔订单" /> }}
pagination={{ pagination={{
defaultPageSize: 15, defaultPageSize: 15,
showSizeChanger: true, showSizeChanger: true,

View File

@@ -15,8 +15,11 @@ import dayjs, { Dayjs } from 'dayjs';
import api from '../../api'; import api from '../../api';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { QueryErrorState } from '../../components/QueryState';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation'; import { useApiMutation } from '../../hooks/useApiMutation';
import { getErrorMessage } from '../../utils/error'; import { getErrorMessage } from '../../utils/error';
@@ -42,6 +45,7 @@ const ClassroomRentalsPage: React.FC = () => {
const [filterStatus, setFilterStatus] = useState<string | undefined>(); const [filterStatus, setFilterStatus] = useState<string | undefined>();
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useSubmitShortcut(modalOpen && !saving, () => handleSave());
const [unavailableDates, setUnavailableDates] = useImmer<Set<string>>(new Set()); const [unavailableDates, setUnavailableDates] = useImmer<Set<string>>(new Set());
const loadedUnavailableMonths = useRef<Set<string>>(new Set()); const loadedUnavailableMonths = useRef<Set<string>>(new Set());
const unavailableRequestVersion = useRef(0); const unavailableRequestVersion = useRef(0);
@@ -52,10 +56,11 @@ const ClassroomRentalsPage: React.FC = () => {
data = [], data = [],
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<any[]>({ } = useQuery<any[]>({
queryKey: ['classroom-rentals', filterMonth], queryKey: ['classroom-rentals', filterMonth],
queryFn: async () => { queryFn: async () => {
try {
const params: any = {}; const params: any = {};
if (filterMonth) params.month = filterMonth.format('YYYY-MM'); if (filterMonth) params.month = filterMonth.format('YYYY-MM');
params.includeEnded = true; params.includeEnded = true;
@@ -63,10 +68,6 @@ const ClassroomRentalsPage: React.FC = () => {
rentalsSchema, rentalsSchema,
await api.get('/classroom-rentals', { params }), await api.get('/classroom-rentals', { params }),
); );
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
return [];
}
}, },
}); });
const { const {
@@ -93,6 +94,8 @@ const ClassroomRentalsPage: React.FC = () => {
const classrooms = meta.classrooms; const classrooms = meta.classrooms;
const organizations = meta.organizations; const organizations = meta.organizations;
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据
useVisibleRefetch(['classroom-rentals']);
const saveMutation = useApiMutation( const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) => async (payload: Record<string, unknown>) =>
@@ -139,9 +142,20 @@ const ClassroomRentalsPage: React.FC = () => {
{ invalidate: [['classroom-rentals']] }, { invalidate: [['classroom-rentals']] },
); );
const uploadContractMutation = useApiMutation( const uploadContractMutation = useApiMutation(
async ({ id, formData }: { id: number; formData: FormData }) => async ({
id,
formData,
onProgress,
}: {
id: number;
formData: FormData;
onProgress?: (percent: number) => void;
}) =>
api.post(`/classroom-rentals/${id}/contract`, formData, { api.post(`/classroom-rentals/${id}/contract`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: (event) => {
if (!onProgress || !event.total) return;
onProgress(Math.min(Math.round((event.loaded / event.total) * 100), 100));
},
}), }),
{ invalidate: [['classroom-rentals']] }, { invalidate: [['classroom-rentals']] },
); );
@@ -198,7 +212,7 @@ const ClassroomRentalsPage: React.FC = () => {
} }
} }
}, },
[], [setUnavailableDates],
); );
const handleClassroomChange = (classroomId: number) => { const handleClassroomChange = (classroomId: number) => {
@@ -320,8 +334,12 @@ const ClassroomRentalsPage: React.FC = () => {
} }
}; };
const handleUploadContract = async (id: number, formData: FormData) => { const handleUploadContract = async (
return uploadContractMutation.mutateAsync({ id, formData }); id: number,
formData: FormData,
onProgress?: (percent: number) => void,
) => {
return uploadContractMutation.mutateAsync({ id, formData, onProgress });
}; };
const openEdit = (record: any) => { const openEdit = (record: any) => {
@@ -401,6 +419,13 @@ const ClassroomRentalsPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</div> </div>
{isError ? (
<QueryErrorState
title="租赁订单加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : (
<RentalTable <RentalTable
data={filteredData} data={filteredData}
loading={loading} loading={loading}
@@ -417,6 +442,7 @@ const ClassroomRentalsPage: React.FC = () => {
onDeleteContract={handleDeleteContract} onDeleteContract={handleDeleteContract}
onUploadContract={handleUploadContract} onUploadContract={handleUploadContract}
/> />
)}
<Modal <Modal
title={editing ? '编辑租赁' : '新增租赁'} title={editing ? '编辑租赁' : '新增租赁'}
open={modalOpen} open={modalOpen}
@@ -430,7 +456,7 @@ const ClassroomRentalsPage: React.FC = () => {
okText="保存" okText="保存"
width={600} width={600}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical" scrollToFirstError>
<Form.Item name="classroomId" label="教室" rules={[{ required: true }]}> <Form.Item name="classroomId" label="教室" rules={[{ required: true }]}>
<Select <Select
showSearch showSearch
@@ -481,6 +507,7 @@ const ClassroomRentalsPage: React.FC = () => {
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']} placeholder={['开始日期', '结束日期']}
format="YYYY-MM-DD" format="YYYY-MM-DD"
allowEmpty={[true, true]}
disabled={!selectedClassroomId} disabled={!selectedClassroomId}
disabledDate={(date) => unavailableDatesLoading || isDateUnavailable(date)} disabledDate={(date) => unavailableDatesLoading || isDateUnavailable(date)}
onPanelChange={(dates) => dates.forEach((date) => date && handleCalendarChange(date))} onPanelChange={(dates) => dates.forEach((date) => date && handleCalendarChange(date))}

View File

@@ -13,15 +13,16 @@ import {
Button, Button,
Modal, Modal,
Spin, Spin,
Empty,
Tooltip, Tooltip,
} from 'antd'; } from 'antd';
import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons'; import { CalendarOutlined, FileTextOutlined, ReadOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import api from '../../api'; import api from '../../api';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { getErrorMessage } from '../../utils/error'; import { getErrorMessage } from '../../utils/error';
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
interface ScheduleData { interface ScheduleData {
year: number; year: number;
@@ -40,23 +41,19 @@ const ClassroomSchedulePage: React.FC = () => {
const [month, setMonth] = useState<Dayjs>(dayjs()); const [month, setMonth] = useState<Dayjs>(dayjs());
const [detailModal, setDetailModal] = useState<any>(null); const [detailModal, setDetailModal] = useState<any>(null);
const { data, isLoading, isFetching } = useQuery<ScheduleData | null>({ const { data, isLoading, isFetching, isError, refetch } = useQuery<ScheduleData | null>({
queryKey: ['classroom-rentals', 'schedule', month.year(), month.month()], queryKey: ['classroom-rentals', 'schedule', month.year(), month.month()],
queryFn: async () => { queryFn: async () =>
try { validateResponse<ScheduleData | null>(
return validateResponse<ScheduleData | null>(
classroomScheduleSchema, classroomScheduleSchema,
await api.get('/classroom-rentals/schedule', { await api.get('/classroom-rentals/schedule', {
params: { year: month.year(), month: month.month() + 1 }, params: { year: month.year(), month: month.month() + 1 },
}), }),
); ),
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
return null;
}
},
}); });
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新排期数据
useVisibleRefetch(['classroom-rentals', 'schedule']);
// 按楼栋+楼层分组教室 // 按楼栋+楼层分组教室
const groups = useMemo(() => { const groups = useMemo(() => {
@@ -65,7 +62,7 @@ const ClassroomSchedulePage: React.FC = () => {
for (const c of data.classrooms) { for (const c of data.classrooms) {
const key = `${c.building || '其他'}${c.floor ? ` · ${c.floor}` : ''}`; const key = `${c.building || '其他'}${c.floor ? ` · ${c.floor}` : ''}`;
if (!map.has(key)) map.set(key, []); if (!map.has(key)) map.set(key, []);
map.get(key)!.push(c); map.get(key)?.push(c);
} }
return Array.from(map.entries()).map(([name, classrooms]) => ({ name, classrooms })); return Array.from(map.entries()).map(([name, classrooms]) => ({ name, classrooms }));
}, [data]); }, [data]);
@@ -135,6 +132,14 @@ const ClassroomSchedulePage: React.FC = () => {
</Space> </Space>
</div> </div>
{isError ? (
<QueryErrorState
title="教室排期加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : (
<>
{/* 统计卡片 */} {/* 统计卡片 */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}> <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
@@ -191,7 +196,7 @@ const ClassroomSchedulePage: React.FC = () => {
<Spin spinning={loading}> <Spin spinning={loading}>
{!data || data.classrooms.length === 0 ? ( {!data || data.classrooms.length === 0 ? (
<Empty description="暂无教室数据" /> <QueryEmpty description="暂无教室数据,可在「教室管理」中添加教室后查看排期" />
) : ( ) : (
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
{groups.map((group) => ( {groups.map((group) => (
@@ -314,7 +319,13 @@ const ClassroomSchedulePage: React.FC = () => {
} }
> >
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}> <span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
{isInternal ? '📖' : cell.hasContract ? '📄' : ''} {isInternal ? (
<ReadOutlined style={{ fontSize: 12 }} />
) : cell.hasContract ? (
<FileTextOutlined style={{ fontSize: 12 }} />
) : (
''
)}
</span> </span>
</Tooltip> </Tooltip>
)} )}
@@ -331,6 +342,8 @@ const ClassroomSchedulePage: React.FC = () => {
</div> </div>
)} )}
</Spin> </Spin>
</>
)}
<Modal <Modal
title="租赁详情" title="租赁详情"

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation'; import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate'; import { validateResponse } from '../../utils/validate';
@@ -17,7 +17,6 @@ import {
Popconfirm, Popconfirm,
Upload, Upload,
Tooltip, Tooltip,
Empty,
} from 'antd'; } from 'antd';
import { import {
PlusOutlined, PlusOutlined,
@@ -29,9 +28,15 @@ import {
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell'; import EditableCell from '../../components/EditableCell';
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { RefreshButton } from '../../components/RefreshButton';
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
import { usePermission } from '../../hooks/usePermission'; import { usePermission } from '../../hooks/usePermission';
import { useUserStore } from '../../store/user/userStore'; import { useUserStore } from '../../store/user/userStore';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
import { saveAs } from 'file-saver';
const statusMap: Record<string, { text: string; color: string }> = { const statusMap: Record<string, { text: string; color: string }> = {
available: { text: '可用', color: 'green' }, available: { text: '可用', color: 'green' },
@@ -61,30 +66,30 @@ const ClassroomsPage: React.FC = () => {
const [editing, setEditing] = useState<any>(null); const [editing, setEditing] = useState<any>(null);
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const formGuard = useDirtyGuard(form);
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined); const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useSubmitShortcut(modalOpen && !saving, () => handleSave());
const { const {
data = [], data = [],
isLoading, isLoading,
isFetching, isFetching,
isError,
refetch,
} = useQuery<any[]>({ } = useQuery<any[]>({
queryKey: ['classrooms', showArchived], queryKey: ['classrooms', showArchived],
queryFn: async () => { queryFn: async () =>
try { validateResponse<any[]>(
return validateResponse<any[]>(
classroomsSchema, classroomsSchema,
await api.get('/classrooms', { params: { includeArchived: showArchived } }), await api.get('/classrooms', { params: { includeArchived: showArchived } }),
); ),
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
return [];
}
},
}); });
const loading = isLoading || isFetching; const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据
useVisibleRefetch(['classrooms']);
const saveMutation = useApiMutation( const saveMutation = useApiMutation(
async (values: Record<string, unknown>) => async (values: Record<string, unknown>) =>
@@ -110,9 +115,7 @@ const ClassroomsPage: React.FC = () => {
); );
const importMutation = useApiMutation( const importMutation = useApiMutation(
async (formData: FormData) => async (formData: FormData) =>
api.post('/classrooms/import', formData, { api.post('/classrooms/import', formData),
headers: { 'Content-Type': 'multipart/form-data' },
}),
{ invalidate: [['classrooms']] }, { invalidate: [['classrooms']] },
); );
@@ -147,37 +150,55 @@ const ClassroomsPage: React.FC = () => {
} }
}; };
const saveCell = async (record: any, field: string, value: unknown) => { const openCreateModal = () => {
setEditing(null);
form.resetFields();
formGuard.snapshot();
setModalOpen(true);
};
const saveCell = useCallback(
async (record: any, field: string, value: unknown) => {
try { try {
await saveCellMutation.mutateAsync({ record, field, value }); await saveCellMutation.mutateAsync({ record, field, value });
message.success('已保存'); message.success('已保存');
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} }
}; },
[saveCellMutation],
);
const handleArchive = async (id: number) => { const handleArchive = useCallback(
async (id: number) => {
try { try {
await archiveMutation.mutateAsync(id); await archiveMutation.mutateAsync(id);
message.success('已归档'); message.success('已归档');
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} }
}; },
[archiveMutation],
);
const handleRestore = async (id: number) => { const handleRestore = useCallback(
async (id: number) => {
try { try {
await restoreMutation.mutateAsync(id); await restoreMutation.mutateAsync(id);
message.success('已恢复'); message.success('已恢复');
} catch { } catch {
// 错误提示由 useApiMutation 统一处理 // 错误提示由 useApiMutation 统一处理
} }
}; },
[restoreMutation],
);
const handlePurge = (id: number, name: string) => { const handlePurge = useCallback(
(id: number, name: string) => {
modal.confirm({ modal.confirm({
title: `永久删除教室「${name}」?`, title: `永久删除教室「${name}」?`,
content: '删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?', content:
'删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?',
okText: '永久删除', okText: '永久删除',
okButtonProps: { danger: true }, okButtonProps: { danger: true },
cancelText: '取消', cancelText: '取消',
@@ -190,7 +211,9 @@ const ClassroomsPage: React.FC = () => {
} }
}, },
}); });
}; },
[modal, purgeMutation],
);
const handleDownloadTemplate = () => { const handleDownloadTemplate = () => {
const baseURL = import.meta.env.PROD const baseURL = import.meta.env.PROD
@@ -199,14 +222,7 @@ const ClassroomsPage: React.FC = () => {
const token = useUserStore.getState().token; const token = useUserStore.getState().token;
fetch(`${baseURL}/classrooms/template`, { headers: { Authorization: `Bearer ${token}` } }) fetch(`${baseURL}/classrooms/template`, { headers: { Authorization: `Bearer ${token}` } })
.then((res) => res.blob()) .then((res) => res.blob())
.then((blob) => { .then((blob) => saveAs(blob, '教室导入模板.xlsx'))
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '教室导入模板.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('下载失败')); .catch(() => message.error('下载失败'));
}; };
@@ -338,6 +354,7 @@ const ClassroomsPage: React.FC = () => {
{ {
title: '操作', title: '操作',
fixed: 'right' as const,
width: 180, width: 180,
render: (_: any, record: any) => ( render: (_: any, record: any) => (
<Space> <Space>
@@ -372,6 +389,7 @@ const ClassroomsPage: React.FC = () => {
onClick={() => { onClick={() => {
setEditing(record); setEditing(record);
form.setFieldsValue(record); form.setFieldsValue(record);
formGuard.snapshot();
setModalOpen(true); setModalOpen(true);
}} }}
> >
@@ -397,7 +415,7 @@ const ClassroomsPage: React.FC = () => {
), ),
}, },
], ],
[handlePurge, hasPermission], [handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form, formGuard],
); );
return ( return (
@@ -443,15 +461,12 @@ const ClassroomsPage: React.FC = () => {
</Button> </Button>
</Space> </Space>
<Space wrap> <Space wrap>
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
<PermissionButton <PermissionButton
permission="classroom:create" permission="classroom:create"
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => { onClick={openCreateModal}
setEditing(null);
form.resetFields();
setModalOpen(true);
}}
> >
</PermissionButton> </PermissionButton>
@@ -465,12 +480,8 @@ const ClassroomsPage: React.FC = () => {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
.then((r) => r.blob()) .then((r) => r.blob())
.then((b) => { .then((b) => saveAs(b, '教室使用报表.xlsx'))
const a = document.createElement('a'); .catch(() => message.error('导出失败'));
a.href = URL.createObjectURL(b);
a.download = '教室使用报表.xlsx';
a.click();
});
}} }}
> >
@@ -503,13 +514,31 @@ const ClassroomsPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</Space> </Space>
</div> </div>
{isError ? (
<QueryErrorState
title="教室列表加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : (
<Table <Table
scroll={{ x: 1100 }} scroll={{ x: 1100 }}
columns={columns} columns={columns}
dataSource={filteredData} dataSource={filteredData}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }} locale={{
emptyText: (
<QueryEmpty
description="暂无数据"
action={
hasPermission('classroom:create')
? { label: '添加教室', onClick: openCreateModal }
: undefined
}
/>
),
}}
pagination={{ pagination={{
defaultPageSize: 20, defaultPageSize: 20,
showSizeChanger: true, showSizeChanger: true,
@@ -517,18 +546,21 @@ const ClassroomsPage: React.FC = () => {
showTotal: (total) => `${total}`, showTotal: (total) => `${total}`,
}} }}
/> />
)}
<Modal <Modal
title={editing ? '编辑教室' : '添加教室'} title={editing ? '编辑教室' : '添加教室'}
open={modalOpen} open={modalOpen}
onOk={handleSave} onOk={handleSave}
onCancel={() => { onCancel={() =>
formGuard.confirmClose(() => {
setModalOpen(false); setModalOpen(false);
setEditing(null); setEditing(null);
}} })
}
confirmLoading={saving} confirmLoading={saving}
okText="保存" okText="保存"
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical" scrollToFirstError>
<Form.Item name="name" label="教室名" rules={[{ required: true }]}> <Form.Item name="name" label="教室名" rules={[{ required: true }]}>
<Input placeholder="如A201 / B301" /> <Input placeholder="如A201 / B301" />
</Form.Item> </Form.Item>

View File

@@ -55,7 +55,7 @@ export interface ExpenseByTypeRow {
} }
export interface GanttOccupancy { export interface GanttOccupancy {
studentName: string; studentName: string;
studentId?: string; studentId?: string | number;
checkInDate: string; checkInDate: string;
checkOutDate: string | null; checkOutDate: string | null;
billingStartDate?: string; billingStartDate?: string;

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { buildGanttOption } from './DashboardCharts';
import type { GanttOccupancy } from './Dashboard.types';
const ganttRoom = (occupancies: GanttOccupancy[]) => [
{ roomNumber: 'A101', occupancies },
];
describe('buildGanttOption 甘特图时间线', () => {
it('未退宿的入住条在查看过去月份时截断到 periodEnd而不是画到今天', () => {
const option = buildGanttOption(
ganttRoom([
{
studentName: '张三',
checkInDate: '2026-05-01',
checkOutDate: null,
},
]),
{ periodEnd: '2026-06-30', today: '2026-08-07' },
);
const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>;
expect(series[0].data[0].value[2]).toBe('2026-06-30');
expect(series[0].data[0].value[3]).toBe(true);
});
it('未退宿的入住条在查看当前月时截断到今天', () => {
const option = buildGanttOption(
ganttRoom([
{
studentName: '张三',
checkInDate: '2026-07-01',
checkOutDate: null,
},
]),
{ periodEnd: '2026-08-31', today: '2026-08-07' },
);
const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>;
expect(series[0].data[0].value[2]).toBe('2026-08-07');
});
it('已退宿的入住条保留真实退宿日期', () => {
const option = buildGanttOption(
ganttRoom([
{
studentName: '李四',
checkInDate: '2026-05-01',
checkOutDate: '2026-06-15',
},
]),
{ periodEnd: '2026-06-30', today: '2026-08-07' },
);
const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>;
expect(series[0].data[0].value[2]).toBe('2026-06-15');
expect(series[0].data[0].value[3]).toBe(false);
});
});

View File

@@ -1,4 +1,5 @@
import type { EChartsOption } from '../../components/ECharts'; import type { EChartsOption } from '../../components/ECharts';
import dayjs from 'dayjs';
import { import {
attendanceLabelMap, attendanceLabelMap,
COLORS, COLORS,
@@ -169,16 +170,6 @@ export function buildClassroomHeatmapOption(
data: classroomOccupancy.map((r) => r.name), data: classroomOccupancy.map((r) => r.name),
inverse: true, inverse: true,
}, },
visualMap: {
min: 0,
max: 1,
orient: 'horizontal',
left: 'center',
bottom: 0,
inRange: {
color: ['#e6f4ff', '#91caff', '#40a9ff', '#0050b3', '#002c8c'],
},
},
series: [ series: [
{ {
type: 'bar', type: 'bar',
@@ -189,7 +180,7 @@ export function buildClassroomHeatmapOption(
rentalCount: r.rentalCount, rentalCount: r.rentalCount,
occupancy: r.occupancy, occupancy: r.occupancy,
})), })),
itemStyle: { borderRadius: [0, 4, 4, 0] }, itemStyle: { color: '#1677ff', borderRadius: [0, 4, 4, 0] },
label: { label: {
show: true, show: true,
position: 'right', position: 'right',
@@ -201,7 +192,13 @@ export function buildClassroomHeatmapOption(
}; };
} }
export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption { export function buildGanttOption(
ganttData: GanttRoom[],
options?: { periodEnd?: string; today?: string },
): EChartsOption {
const today = options?.today ?? dayjs().format('YYYY-MM-DD');
const periodEnd = options?.periodEnd;
return { return {
tooltip: { tooltip: {
formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) => formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
@@ -246,15 +243,18 @@ export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption {
}, },
encode: { x: [1, 2], y: 0 }, encode: { x: [1, 2], y: 0 },
data: ganttData.flatMap((r) => data: ganttData.flatMap((r) =>
(r.occupancies || []).map((o) => ({ (r.occupancies || []).map((o) => {
const activeEnd = periodEnd && periodEnd < today ? periodEnd : today;
return {
name: o.studentName, name: o.studentName,
value: [ value: [
r.roomNumber, r.roomNumber,
o.checkInDate, o.checkInDate,
o.checkOutDate || new Date().toISOString().slice(0, 10), o.checkOutDate || activeEnd,
!o.checkOutDate, !o.checkOutDate,
] as [string, string, string, boolean], ] as [string, string, string, boolean],
})), };
}),
), ),
}, },
], ],

View File

@@ -49,22 +49,25 @@ export const ClassroomHeatmapCard: React.FC<{
minHeight={isMobile ? 340 : 440} minHeight={isMobile ? 340 : 440}
style={{ marginBottom: 24 }} style={{ marginBottom: 24 }}
> >
{data.length > 0 ? ( {data.some((r) => Number(r.occupancy) > 0) ? (
<ReactECharts <ReactECharts
option={buildClassroomHeatmapOption(data)} option={buildClassroomHeatmapOption(data)}
style={{ width: '100%', height: isMobile ? 300 : 400 }} style={{ width: '100%', height: isMobile ? 300 : 400 }}
/> />
) : ( ) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div> <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
</div>
)} )}
</LazySection> </LazySection>
); );
}; };
export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({ export const GanttCard: React.FC<{
data, data: GanttRoom[];
isMobile, isMobile: boolean;
}) => { periodEnd?: string;
}> = ({ data, isMobile, periodEnd }) => {
const vp = useInViewport('200px'); const vp = useInViewport('200px');
return ( return (
<LazySection <LazySection
@@ -74,7 +77,7 @@ export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({
> >
{data.length > 0 ? ( {data.length > 0 ? (
<ReactECharts <ReactECharts
option={buildGanttOption(data)} option={buildGanttOption(data, { periodEnd })}
style={{ width: '100%', height: isMobile ? 300 : 450 }} style={{ width: '100%', height: isMobile ? 300 : 450 }}
/> />
) : ( ) : (

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { shouldNavigateTodoCard } from './DashboardTodoCards';
describe('DashboardTodoCards 点击导航守卫', () => {
it('目标路径与当前路径不同且冷却已过时允许导航', () => {
expect(shouldNavigateTodoCard('/dashboard', '/attendance', 1000, 1600)).toBe(true);
});
it('目标路径与当前路径相同时不允许导航', () => {
expect(shouldNavigateTodoCard('/attendance', '/attendance', 1000, 1600)).toBe(false);
});
it('冷却期内忽略重复点击,防止产生多条历史记录', () => {
expect(shouldNavigateTodoCard('/dashboard', '/attendance', 1000, 1300)).toBe(false);
});
});

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useRef } from 'react';
import { Card, Col, Row } from 'antd'; import { Card, Col, Row } from 'antd';
import { import {
ArrowRightOutlined, ArrowRightOutlined,
@@ -6,9 +6,22 @@ import {
DollarOutlined, DollarOutlined,
ExclamationCircleOutlined, ExclamationCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router'; import { useLocation, useNavigate } from 'react-router';
import { MARGIN_BOTTOM_16_STYLE, TODO_CARD_DANGER, TODO_CARD_DRAFT, TODO_CARD_OK, TODO_CARD_WARN } from './Dashboard.types'; import { MARGIN_BOTTOM_16_STYLE, TODO_CARD_DANGER, TODO_CARD_DRAFT, TODO_CARD_OK, TODO_CARD_WARN } from './Dashboard.types';
/** 冷却期内忽略重复点击,避免快速/幽灵点击压入多条历史记录。 */
const TODO_CLICK_LOCK_MS = 400;
export function shouldNavigateTodoCard(
currentPath: string,
targetPath: string,
lastNavAt: number,
now: number,
): boolean {
if (currentPath === targetPath) return false;
return now - lastNavAt >= TODO_CLICK_LOCK_MS;
}
export const DashboardTodoCards: React.FC<{ export const DashboardTodoCards: React.FC<{
absentCount: number; absentCount: number;
draftCount: number; draftCount: number;
@@ -16,6 +29,14 @@ export const DashboardTodoCards: React.FC<{
pendingDeposits: number; pendingDeposits: number;
}> = ({ absentCount, draftCount, draftTotal, pendingDeposits }) => { }> = ({ absentCount, draftCount, draftTotal, pendingDeposits }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const lastNavAtRef = useRef(0);
const go = (target: string) => {
const now = Date.now();
if (!shouldNavigateTodoCard(location.pathname, target, lastNavAtRef.current, now)) return;
lastNavAtRef.current = now;
navigate(target);
};
return ( return (
<Card title="待办与异常" style={MARGIN_BOTTOM_16_STYLE}> <Card title="待办与异常" style={MARGIN_BOTTOM_16_STYLE}>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
@@ -23,7 +44,7 @@ export const DashboardTodoCards: React.FC<{
<Card <Card
style={absentCount > 0 ? TODO_CARD_WARN : TODO_CARD_OK} style={absentCount > 0 ? TODO_CARD_WARN : TODO_CARD_OK}
styles={{ body: { padding: 16 } }} styles={{ body: { padding: 16 } }}
onClick={() => navigate('/attendance')} onClick={() => go('/attendance')}
> >
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<ExclamationCircleOutlined <ExclamationCircleOutlined
@@ -55,7 +76,7 @@ export const DashboardTodoCards: React.FC<{
<Card <Card
style={draftCount > 0 ? TODO_CARD_DRAFT : TODO_CARD_OK} style={draftCount > 0 ? TODO_CARD_DRAFT : TODO_CARD_OK}
styles={{ body: { padding: 16 } }} styles={{ body: { padding: 16 } }}
onClick={() => navigate('/bills')} onClick={() => go('/bills')}
> >
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<DollarOutlined <DollarOutlined
@@ -87,7 +108,7 @@ export const DashboardTodoCards: React.FC<{
<Card <Card
style={pendingDeposits > 0 ? TODO_CARD_DANGER : TODO_CARD_OK} style={pendingDeposits > 0 ? TODO_CARD_DANGER : TODO_CARD_OK}
styles={{ body: { padding: 16 } }} styles={{ body: { padding: 16 } }}
onClick={() => navigate('/deposits')} onClick={() => go('/deposits')}
> >
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<BankOutlined <BankOutlined

Some files were not shown because too many files have changed in this diff Show More