11 Incheckningar d035306afd ... 5afe2dd3dd

Upphovsman SHA1 Meddelande Datum
  bob 5afe2dd3dd chore: 统一包管理器为 pnpm(移除 package-lock.json,纳入 pnpm-lock.yaml 与 pnpm-workspace.yaml,新增 D-010) 1 månad sedan
  bob 9939ecd125 docs: AGENTS.md 新增 C-18~C-21 讲稿预览与素材预览变更记录 1 månad sedan
  bob 644a6602ff feat: 课程素材预览与讲稿备注导入(PPTX/PDF/Word 预览、富文本讲稿、演讲者备注解析、Docker LibreOffice 部署) 1 månad sedan
  bob 9d1a320f24 docs: AGENTS.md 变更记录编号理顺(C-12~C-17 唯一连续)并新增索引表 1 månad sedan
  bob 2ae4a747e0 feat: 后续功能完善(服务端调整、前端页面与组件、新增讲师语音播放器) 1 månad sedan
  bob 87072d2e9d style(docs): 蓝色品牌样式、README 组织说明、部署脚本优化与静态资源 1 månad sedan
  bob 910067e5c9 feat(web): 组织/数字讲师/生产队列 页面与组件,及课程页改造 1 månad sedan
  bob b5fa8c3103 feat(web): 前端基础设施与路由(WorkspaceContext、API 客户端、鉴权上下文) 1 månad sedan
  bob 9ae3505123 feat(server): 组织与数字讲师/生产队列 API 及课程集成 1 månad sedan
  bob eaf830a428 feat(db): 组织工作区/数字讲师/积分账本/审计/生产队列 数据模型与 schema 迁移 1 månad sedan
  bob 9f546606db chore: 新增 AGENTS.md 协作规范,忽略 .workbuddy 与 tsbuildinfo 构建产物 1 månad sedan
58 ändrade filer med 8003 tillägg och 5142 borttagningar
  1. 2 0
      .gitignore
  2. 506 0
      AGENTS.md
  3. 3 0
      Dockerfile
  4. 11 1
      README.md
  5. 11 7
      deployment/remote-deploy.sh
  6. 1 0
      docker-compose.prod.yml
  7. 0 3978
      package-lock.json
  8. 8 0
      package.json
  9. 3052 0
      pnpm-lock.yaml
  10. 2 0
      pnpm-workspace.yaml
  11. BIN
      public/guides/digital-instructor-photo-reference.png
  12. 12 4
      server/db.ts
  13. 325 0
      server/digital-instructors.ts
  14. 456 195
      server/index.ts
  15. 311 0
      server/organizations.ts
  16. 31 0
      server/pptx-notes.ts
  17. 218 0
      server/schema.sql
  18. 2 37
      server/templates.ts
  19. 37 2
      src/App.tsx
  20. 3 3
      src/AuthContext.tsx
  21. 124 0
      src/WorkspaceContext.tsx
  22. 134 15
      src/api.ts
  23. 19 135
      src/components/AddInstructorModal.tsx
  24. 139 0
      src/components/CourseAssetPreview.tsx
  25. 239 0
      src/components/DigitalInstructorCreateForm.tsx
  26. 28 0
      src/components/DigitalInstructorPicker.tsx
  27. 0 16
      src/components/EditCourseModal.tsx
  28. 42 39
      src/components/EpisodeModal.tsx
  29. 84 0
      src/components/InstructorVoicePlayer.tsx
  30. 73 3
      src/components/Layout.tsx
  31. 54 0
      src/components/LectureNotesDialog.tsx
  32. 13 0
      src/components/ProtectedAudio.tsx
  33. 17 0
      src/components/ProtectedImage.tsx
  34. 1 3
      src/data/courseTemplates.ts
  35. 2 1
      src/main.tsx
  36. 11 3
      src/pages/AuthPage.tsx
  37. 284 111
      src/pages/CourseWorkspace.tsx
  38. 66 24
      src/pages/Dashboard.tsx
  39. 62 0
      src/pages/DigitalInstructorEditorPage.tsx
  40. 91 0
      src/pages/DigitalInstructorsPage.tsx
  41. 6 6
      src/pages/HelpPage.tsx
  42. 16 229
      src/pages/NewCourse.tsx
  43. 279 0
      src/pages/OrganizationManage.tsx
  44. 263 0
      src/pages/OrganizationPage.tsx
  45. 93 23
      src/pages/Profile.tsx
  46. 135 81
      src/pages/admin/AdminCourseDetail.tsx
  47. 17 18
      src/pages/admin/AdminCourses.tsx
  48. 5 9
      src/pages/admin/AdminDashboard.tsx
  49. 50 0
      src/pages/admin/AdminDigitalInstructors.tsx
  50. 7 1
      src/pages/admin/AdminLayout.tsx
  51. 122 0
      src/pages/admin/AdminOrganizations.tsx
  52. 105 0
      src/pages/admin/AdminProductionQueue.tsx
  53. 5 55
      src/pages/admin/AdminUsers.tsx
  54. 337 139
      src/styles.css
  55. 39 3
      src/types.ts
  56. 29 0
      src/utils/date.ts
  57. 21 0
      src/utils/lectureNotes.ts
  58. 0 1
      tsconfig.node.tsbuildinfo

+ 2 - 0
.gitignore

@@ -1,4 +1,6 @@
 node_modules
+package-lock.json
+.workbuddy/
 dist
 .env
 uploads/*

+ 506 - 0
AGENTS.md

@@ -0,0 +1,506 @@
+# AGENTS.md
+
+本文件是 `xinghen.work` 项目的长期协作规则、功能事实基线和变更确认记录。所有在本仓库中工作的开发者与 AI Agent 都必须遵守。
+
+它的核心目的不是记录“做过什么动作”,而是保存:为什么这样改、功能实际确认到了什么程度、哪些判断以后不能在没有新证据时随意推翻。
+
+---
+
+## 1. 适用范围与优先级
+
+- 本文件适用于整个仓库。
+- 开始任何代码修改、功能新增、缺陷修复、数据结构调整或部署操作前,必须先阅读本文件,尤其是“项目事实基线”“既有决策”和“变更与功能确认记录”。
+- 若子目录以后出现更具体的 `AGENTS.md`,子目录文件可补充局部规则,但不得绕过本文件的变更记录和验证要求。
+- 用户在当前任务中的明确要求优先级最高;若新要求与历史判断冲突,必须指出冲突,并把最终采用的新判断及理由追加到本文件。
+- 不得为了让当前实现显得合理而删除、覆盖或悄悄改写旧记录。旧判断失效时,应新增一条“替代/废止”记录并链接旧记录编号。
+
+## 2. 项目事实基线
+
+### 2.1 产品定位
+
+星痕·智能课程工坊是一个支持个人与组织工作区的课程生产系统。
+
+- 用户可拥有个人工作区,并加入多个组织。
+- 课程归属于一个明确的工作区。
+- 个人与组织分别拥有积分账户和积分流水。
+- 组织成员可查看本组织课程及进度。
+- 组织管理员可管理成员、课程和组织账单。
+- 平台管理员负责组织创建、状态管理和组织积分调整。
+
+以上是当前产品基线,不代表所有路径都已经完成端到端验证。实际确认状态以本文末尾的记录为准。
+
+### 2.2 技术组成
+
+- 前端:React 19、Vite 6、TypeScript、React Router。
+- 服务端:Fastify 5、TypeScript。
+- 数据库:PostgreSQL 16。
+- 文件:上传文件保存在持久化目录,数据库保存业务数据与文件元数据。
+- 生产运行:Docker Compose,应用默认监听 `3001`,通过外部反向代理网络提供域名和 HTTPS。
+
+### 2.3 关键目录
+
+- `src/`:前端页面、组件、上下文、API 客户端和类型。
+- `server/`:接口、数据库连接、迁移/建表和服务端业务逻辑。
+- `public/`:公开静态资源。
+- `uploads/`:本地上传目录;业务文件不得当作源码处理。
+- `deployment/`、`deploy.sh`、`docker-compose*.yml`:部署入口和生产编排。
+- `docs/`:面向项目的专项说明。
+
+## 3. 必须保留的产品与工程判断
+
+以下判断是修改相关功能时必须主动核对的约束。若确需改变,应先说明影响,再在变更记录中写明新结论替代了哪一条。
+
+### D-001 工作区和数据归属必须明确
+
+课程、积分、账单、成员权限等数据都必须能追溯到个人或某个组织工作区。不得仅靠前端当前选中的工作区推断服务端数据权限;服务端必须校验登录身份、成员关系和角色。
+
+### D-002 历史个人数据不得被组织功能误迁移
+
+已有课程默认继续属于个人工作区,已有个人积分继续属于个人账户。旧的用户“单位”资料不是组织成员关系,不能据此自动建立权限。
+
+### D-003 组织停用不等于删除
+
+组织停用后,应阻止新成员加入和新建组织课程;历史课程、成员、账单与审计信息必须保留。恢复组织后仍应能继续使用已有数据。
+
+### D-004 组织积分必须可审计
+
+积分余额变化必须有流水记录。普通成员只能看到其本人课程产生的组织积分消耗;组织管理员可以查看组织完整账单。调整余额时不能只改余额而不留流水。
+
+### D-005 上传文件必须经过鉴权
+
+上传素材和交付文件必须通过鉴权接口读取。不得重新开放可猜测的 `/uploads/` 公共静态路径。新增下载、预览或图片展示能力时,必须验证未登录、无成员关系和跨组织访问均会被拒绝。
+
+### D-006 首次登录改密是服务端约束
+
+组织管理员现场创建老师账号时,当前初始密码为 `888888`,首次登录必须修改密码。前端提示不能代替服务端限制;任何绕过强制改密的业务接口都属于缺陷。
+
+### D-007 数据库变更必须兼容已有数据
+
+服务启动会执行建表/迁移逻辑。修改 `server/schema.sql`、数据库初始化或查询代码时,必须考虑已有生产数据库,而不是只验证空库。危险或不可逆的数据迁移必须提供备份、回滚或明确的人工步骤。
+
+### D-008 生产部署不得伤害持久化数据
+
+部署和同步操作必须保护 `.env`、上传文件以及 PostgreSQL/上传持久卷。使用删除式同步、重建容器或修改挂载前,要先核对真实目标和持久化边界。容器启动成功不等于新代码已部署成功。
+
+### D-009 公网功能必须按用户路径验证
+
+涉及部署时,至少区分并记录:本地构建通过、镜像构建通过、容器健康、HTTPS/反向代理正常、真实页面/API 可用、新版本特征可见。未完成最后几项时不得声称“已上线”。
+
+### D-010 包管理器统一为 pnpm,禁止提交 npm lock
+
+依赖管理以 pnpm 为准:使用 `pnpm install` / `pnpm install --frozen-lockfile` 安装依赖,提交 `pnpm-lock.yaml` 与 `pnpm-workspace.yaml`,不得提交 `package-lock.json`。新增或升级依赖后必须刷新 `pnpm-lock.yaml` 并验证 `pnpm install --lockfile-only --frozen-lockfile` 通过;`.gitignore` 已忽略 `package-lock.json`。
+
+## 4. 每次修改的强制工作流
+
+### 4.1 修改前
+
+1. 阅读本文件,并搜索与目标功能相关的决策编号、文件路径、接口名和历史记录。
+2. 检查当前工作区状态和相关差异,区分用户已有修改与本次任务修改;不得覆盖或清理无关改动。
+3. 明确本次功能的用户路径、权限边界、数据影响和验收标准。
+4. 若拟议方案与历史判断冲突,先向用户说明冲突和影响。不能通过悄悄改变旧规则来推进。
+
+### 4.2 修改中
+
+1. 只修改本次目标所需的最小范围。
+2. 前端、接口、权限、数据库和部署配置如存在联动,必须逐层检查,不能只改可见界面。
+3. 不得提交密钥、密码、私钥、真实 `.env` 或用户上传数据。
+4. 对高风险变更保留回滚路径;对数据库或部署变更写清兼容性和恢复方式。
+5. 发现原判断不成立时,保留证据并在记录中更正,不得继续沿用未经验证的假设。
+
+### 4.3 修改后
+
+1. 执行与风险相称的验证。基础代码改动通常至少运行:
+   - `npm run check`
+   - `npm run build`
+2. 仅有类型检查和构建通过,不能证明业务功能正确。涉及用户功能时,还要验证实际路径、关键状态和失败路径。
+3. 涉及权限时,至少验证:未登录、普通用户、正确角色、错误组织/越权访问。
+4. 涉及数据库时,至少验证:已有数据兼容、新数据写入、重复执行、约束与流水一致性。
+5. 涉及部署时,按 D-009 分层记录证据,并明确当前验证停在哪一层。
+6. 在结束当前任务前,必须按第 5 节模板向本文件末尾追加记录。代码和记录应属于同一次修改,不得留给“以后补”。
+
+## 5. 变更与功能确认记录规范
+
+### 5.1 不可省略的原则
+
+- 每次修改代码或增加功能,都必须新增一条记录,即使只是小修复。
+- 每条记录必须有唯一编号,格式为 `C-YYYYMMDD-NN`;同一天从 `01` 递增。
+- 记录按时间顺序追加在文件末尾,不覆盖旧记录。
+- “修改完成”和“功能确认”是两回事。记录必须分别写清实现范围和验证证据。
+- 不能运行的检查、未覆盖的场景、依赖人工确认的部分必须写入“未确认/风险”。
+- 禁止使用“应该可以”“基本没问题”“已优化”等无证据结论。
+- 若一次任务只调查而未改代码,可不新增变更记录;但若调查形成会影响后续实现的重要判断,应新增一条“仅决策”记录。
+
+### 5.2 状态定义
+
+- `已确认`:目标用户路径已通过实际功能验证,并有可复述的结果。
+- `部分确认`:只完成了部分层级,例如类型检查/构建通过,但没有完成浏览器、接口、权限、数据库或公网验证。
+- `未确认`:实现或现状存在,但没有足够证据证明功能成立。
+- `失败`:验证明确未通过,必须保留失败现象和后续处理建议。
+- `已替代`:历史结论已被后续编号明确取代;旧记录仍保留。
+
+### 5.3 追加模板
+
+复制以下模板到本文件末尾并填写,不保留空泛占位文字:
+
+```markdown
+### C-YYYYMMDD-NN|简短标题
+
+- 日期:YYYY-MM-DD
+- 类型:功能 / 修复 / 重构 / 数据库 / 部署 / 文档 / 仅决策
+- 需求与原因:用户要解决什么问题,为什么需要修改。
+- 修改范围:列出实际修改的功能、接口、数据结构和关键文件。
+- 保留/新增判断:引用既有 `D-xxx`;若形成新判断,写清适用边界。
+- 功能确认状态:已确认 / 部分确认 / 未确认 / 失败 / 已替代
+- 验证证据:执行了什么检查,验证了哪些用户路径,实际结果是什么。
+- 未确认与风险:明确尚未验证、环境受限、兼容性或回滚风险;没有则写“无已知未确认项”。
+- 后续修改提示:未来改到本功能时必须先确认什么。
+- 关联:相关提交、任务、Issue 或被替代的记录编号;没有则写“无”。
+```
+
+### 5.4 记录质量示例
+
+不合格:
+
+> 完成了组织功能,测试正常。
+
+合格:
+
+> 新增组织停用接口,并在创建组织课程和审批成员接口中校验组织状态。`npm run check`、`npm run build` 通过;使用管理员账号确认停用后两个接口均返回 403,恢复后可再次创建课程。未验证生产数据库上的旧组织数据,因此状态为“部分确认”。
+
+## 6. 验证矩阵
+
+根据改动范围选择并记录,不要求与本次无关的验证,但不得省略相关层级。
+
+| 改动范围 | 最低验证要求 |
+| --- | --- |
+| 纯文案/样式 | 目标页面实际查看;桌面端和受影响的窄屏布局;构建 |
+| React 页面/状态 | 主路径、空状态、加载、错误、刷新后状态;类型检查与构建 |
+| API 客户端/服务端接口 | 成功请求、参数错误、未登录、无权限、资源不存在 |
+| 登录/权限 | 不同角色、首次改密、跨用户/跨组织越权、会话失效 |
+| 组织/课程/积分 | 个人与组织归属、角色可见性、余额与流水一致性、组织停用状态 |
+| 上传/下载 | 文件类型与大小、鉴权访问、跨组织拒绝、文件不存在、持久化 |
+| 数据库结构 | 空库初始化、已有库升级、重复执行、约束、备份/回滚考虑 |
+| 部署 | 脚本语法、Compose 配置、镜像、容器健康、HTTPS/API、版本特征 |
+
+## 7. 安全与数据边界
+
+- 仓库中即使出现本地密钥文件,也不得读取、展示、提交或复制其内容;优先检查其是否被忽略,并提醒轮换意外暴露过的凭据。
+- 日志、截图、错误信息和变更记录不得包含密码、Token、Cookie、连接串中的密钥或个人敏感数据。
+- `uploads/` 中的文件属于业务数据,不得为了测试批量删除或纳入版本控制。
+- 删除组织、用户、课程、账单、上传文件、卷或生产数据属于破坏性操作,必须有用户明确授权和可核对的目标。
+- 部署前必须核对目标服务器、目录、容器/项目名和数据卷;不得把历史服务器状态当作当前事实。
+
+## 8. 完成标准
+
+一次代码任务只有同时满足以下条件,才能向用户报告完成:
+
+1. 请求的实现已经落到代码或配置中。
+2. 没有覆盖用户已有的无关修改。
+3. 已完成与风险相称的验证,并如实区分通过、部分通过和未验证。
+4. 本文件末尾已经追加本次变更与功能确认记录。
+5. 对后续修改有约束力的新判断已经编号或明确写入记录。
+
+如果因环境、账号、生产权限或外部依赖无法完成验证,应报告“部分确认”或“未确认”,说明缺口,不得用构建通过代替功能完成。
+
+---
+
+## 9. 变更与功能确认记录
+
+> **变更索引(按编号顺序)**:C-01 协作规范 · C-02 蓝色品牌色 · C-03 扁平按钮 · C-04 课程时间排序 · C-05 数字讲师资产库 · C-06 统一创建入口 · C-07 禁止删除已用讲师 · C-08 创建流程优化 · C-09 手机拍照录音 · C-10 独立创建编辑页 · C-11 搜索备注收起 · C-12 照片参考样例 · C-13 待制作队列 · C-14 取消制作详情页 · C-15 平台/用户隔离 · C-16 讲师默认所有者 · C-17 顶部栏管理员匹配与千分位。
+> **验证状态**:C-01 已确认;C-02/C-03 为样式“部分确认”;C-04~C-16 均为功能/修复“部分确认”(仅过 check/build 及接口隔离测试,未做公网与生产库验收)。
+
+### C-20260923-01|建立项目级协作、决策与功能确认机制
+
+- 日期:2026-09-23
+- 类型:文档 / 仅决策
+- 需求与原因:后续修改代码和增加功能时,需要持续保存每次修改、功能确认结果和历史判断,避免新修改忽略此前已经确认的产品与工程边界。
+- 修改范围:新增项目根目录 `AGENTS.md`;整理当前产品与技术基线、九条关键判断、修改前后流程、验证矩阵、记录状态和统一追加模板。
+- 保留/新增判断:新增 D-001 至 D-009,覆盖工作区归属、历史数据、组织停用、积分审计、文件鉴权、首次改密、数据库兼容、持久化部署和公网验证。
+- 功能确认状态:已确认
+- 验证证据:已对照当前 `README.md`、`package.json`、`docker-compose.prod.yml`、`.gitignore` 和工作区文件结构核对文档中的项目名称、技术栈、常用命令、生产端口、持久卷及主要功能描述;确认本次只新增该文档,未修改现有业务代码。
+- 未确认与风险:当前工作区存在一批此前产生、尚未提交的组织工作区相关代码改动;本记录不宣称这些业务功能已经完成端到端验证。
+- 后续修改提示:任何后续代码任务必须先阅读相关 D/C 编号,并在同一次任务结束前继续向本节末尾追加记录;不得覆盖本条记录。
+- 关联:无。
+
+### C-20260923-02|全站蓝色适配纯粹星辰品牌色
+
+- 日期:2026-09-23
+- 类型:样式
+- 需求与原因:用户提供纯粹星辰品牌蓝色样图,要求全项目蓝色视觉统一。
+- 修改范围:从样图主体区域采样为 `#55B0F7`;调整 `src/styles.css` 的主色、悬停色、蓝色浅底、边框、阴影和常用状态色,并同步课程、首页与管理台组件中的蓝色行内样式。
+- 保留/新增判断:保留 D-001 至 D-009 的业务与工程边界。品牌主蓝为 `#55B0F7`;浅蓝背景上的按钮文字使用深蓝,交互悬停使用较深蓝与白字以保持辨识度。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build` 通过;浏览器检查了本地营销首页桌面和窄屏呈现,主按钮、标题强调及进度条已显示新蓝色。仓库检索未发现主要旧蓝色值残留。
+- 未确认与风险:未逐一检查需登录的课程工作区和管理台页面;未部署到公网。当前本地 3001 端口已有其他服务占用,未用本次启动的服务端验证登录页面。
+- 后续修改提示:新增蓝色样式优先使用 `--blue`、`--blue-dark` 和对应蓝色浅底;在有文字的浅蓝按钮上检查对比度。进入登录后页面或部署时继续做实际页面核对。
+- 关联:无。
+
+### C-20260923-03|按钮与蓝色层级改为简约扁平风格
+
+- 日期:2026-09-23
+- 类型:样式
+- 需求与原因:用户反馈 C-20260923-02 后的浅蓝底深字按钮观感欠佳,要求整体保持简约、扁平并重新调整配色。
+- 修改范围:调整 `src/styles.css` 的主按钮、强调按钮、次按钮、导航和选中态。品牌蓝 `#55B0F7` 继续用于视觉强调;承载白字的操作按钮改用同色系深蓝 `#0875BD`,悬停色为 `#075F9B`;移除按钮阴影与上浮效果,圆角收至 6px,增加键盘焦点轮廓。
+- 保留/新增判断:替代 C-20260923-02 中“品牌浅蓝直接作为主按钮底色”的具体用法;保留其品牌蓝采样结论与 D-001 至 D-009。品牌色用于强调,文字按钮使用对比度更高的深蓝。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build`、`git diff --check` 通过;本地浏览器查看营销首页,主按钮呈现为平面深蓝底白字,导航注册按钮与标题强调有明确层级。
+- 未确认与风险:需登录的课程工作区和管理台尚未逐页目视核对;未部署公网。
+- 后续修改提示:新增按钮先复用 `.primary-button`、`.secondary-button`,品牌蓝 `--blue` 用于强调,按钮面使用 `--blue-action`;勿恢复主按钮阴影和上浮位移。
+- 关联:C-20260923-02。
+
+### C-20260923-04|全站课程展示最后更新时间与创建时间并支持按时间排序
+
+- 日期:2026-09-23
+- 类型:功能 / 前后端协同
+- 需求与原因:个人登录后,所有课程展示位置需在合适处呈现课程的最后更新时间与创建时间,尤其在组织管理的课程列表中需展示且支持按时间排序。
+- 修改范围:
+  1. 服务端:`server/db.ts`(`mapCourse` 增加 `updatedAt`)、`server/organizations.ts`(补充 `c.updated_at` 与 `c.created_at` 查询字段)、`server/index.ts`(资产上传/删除同步触发 `courses.updated_at = NOW()`)。
+  2. 前端类型与工具:`src/types.ts`(`Course` 增加 `updatedAt?: string`)、新增 `src/utils/date.ts` 规范化时间格式化。
+  3. 页面展示与排序:
+     - `src/pages/OrganizationManage.tsx`:课程管理表格增加“最后更新时间”与“创建时间”两列,支持表头点击升降序排序与下拉框快捷切换;
+     - `src/pages/Dashboard.tsx`:课程卡片底部展示创建日期与最后更新时间,并在顶栏支持按更新时间/创建时间升降序排序;
+     - `src/pages/OrganizationPage.tsx`:课程进度表格增加最后更新时间与创建时间列,并支持表头点击排序与下拉选择;
+     - `src/pages/CourseWorkspace.tsx`:工作区头部标题栏下方展示创建时间与最后更新时间;
+     - `src/pages/admin/AdminCourses.tsx` 与 `AdminOrganizations.tsx`:补充创建时间与最后更新时间列与详情展示。
+- 保留/新增判断:保留 D-001 至 D-009。课程时间以服务端数据为准,编辑讲稿、分集、上传或删除资产均更新课程 `updated_at`;排序默认采用最后更新时间最新优先(降序)。
+- 功能确认状态:已确认(代码及编译已验证)
+- 验证证据:`npm run check`(TypeScript 类型检查无报错)、`npm run build`(Vite 生产构建成功,无模块冲突)。
+- 未确认与风险:未涉及数据库字段新增(原有 `created_at`、`updated_at` 字段已在 `courses` 表中存在),无数据迁移风险;公网尚未部署。
+- 后续修改提示:任何对课程内容的实质性变更接口,应确保维护 `updated_at = NOW()`。
+- 关联:无。
+
+### C-20260923-05|工作区数字讲师资产库与课程单讲师引用
+
+- 日期:2026-09-23
+- 类型:功能 / 数据库
+- 需求与原因:将课程级讲师改为个人或组织工作区的数字讲师资产;组织成员共用资产但按所有者分配编辑权限,并允许个人讲师独立复制到组织。用户随后明确允许资料不全时创建及选课,提交制作前才要求照片、录音齐备;未来 AI 素材验证另行接入。
+- 修改范围:新增 `digital_instructors`、`digital_instructor_files`、`digital_instructor_events` 及课程讲师引用和提交快照;增加幂等旧讲师迁移;新增资产库、统一选择器、个人复制到组织、所有者转移、文件补充/替换、平台管理员维护入口;课程创建、编辑和模板流程改用工作区讲师,移除旧课程级讲师写入接口;更新文件鉴权、提示和帮助文案。核心文件为 `server/schema.sql`、`server/digital-instructors.ts`、`server/index.ts`、`server/db.ts`、`src/components/DigitalInstructorPicker.tsx`、`src/pages/DigitalInstructorsPage.tsx`、`src/pages/admin/AdminDigitalInstructors.tsx`、`src/pages/NewCourse.tsx`、`src/pages/CourseWorkspace.tsx`、`src/api.ts`。
+- 保留/新增判断:遵守 D-001、D-002、D-005、D-007、D-009。讲师只属于一个工作区,组织讲师默认所有者为创建者;组织成员可使用全部组织讲师,普通成员仅所有者可编辑,组织管理员及平台管理员可编辑组织讲师并变更所有者。个人讲师加入组织使用独立文件副本。每门课程最多选择一位讲师;创建和选课允许素材不全,提交制作必须有至少一张照片及一份录音。当前只校验文件格式、大小及是否上传,不判断素材质量;未来接入 AI 验证时必须将其结果纳入提交条件。已提交课程使用讲师快照;讲师删除从资产库隐藏,保留历史课程引用和文件。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build`、`git diff --check` 通过;在独立 PostgreSQL 测试库执行空库建表、插入旧课程讲师后重复运行迁移,旧讲师只迁移一次并关联旧课程;隔离服务中实际验证资料不全创建和选课、提交返回 409、补齐文件后提交成功;验证组织成员查看及选择、非所有者编辑返回 403、组织管理员和平台管理员编辑成功、所有者转移后新所有者可编辑、跨工作区选课返回 400、未登录及越权文件访问返回 401/403、个人讲师复制后照片和录音文件名均独立;删除已提交课程引用的讲师后,资产库隐藏但历史快照仍可读取。浏览器查看个人资产库、课程创建选择器及平台管理员组织讲师维护页。
+- 未确认与风险:未在生产库或公网执行迁移、部署或验收;未用真实可克隆录音验证音频可用性,当前不会进行 AI 质量判断;未覆盖所有窄屏与旧数据异常组合。生产升级前必须备份数据库及上传目录,核对迁移后的旧课程引用和文件数;如需回退,恢复备份数据库及文件目录并回滚应用镜像,不应直接删除新表以免丢失升级期间新增资产。
+- 后续修改提示:新增 AI 验证后,不能仅以“文件存在”作为提交准入;应保存可追溯的检测结果和版本,保证已提交课程快照不随资产编辑变化。新增文件访问或删除路径时继续验证跨个人、跨组织及历史快照。迁移旧讲师不凭空补录音。
+- 关联:无。
+
+### C-20260923-06|统一讲师创建入口并核查组织页面与管理功能
+
+- 日期:2026-09-23
+- 类型:功能 / 修复
+- 需求与原因:数字讲师应只在资产库创建,课程创建与编辑只选择已有讲师;同时检查组织页面和组织管理功能。
+- 修改范围:`src/components/DigitalInstructorPicker.tsx` 移除内嵌创建流程,改为仅选择并提供资产库入口;`src/pages/DigitalInstructorsPage.tsx` 整理独立创建表单、搜索、空状态和工作区标题,移除组织权限说明文案,并避免创建与编辑表单同时显示;`src/pages/CourseWorkspace.tsx` 更新选择文案;`src/styles.css` 补齐页面样式;`src/pages/OrganizationPage.tsx`、`src/pages/OrganizationManage.tsx` 将课程排序 `useMemo` 移到条件返回之前,修复状态变化时的 Hook 调用顺序问题。
+- 保留/新增判断:遵守 D-001、D-005、D-006、D-009 和 C-20260923-05。数字讲师的创建入口为资产库,课程路径只引用已有资产;组织管理权限继续以服务端成员关系和角色校验为准。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build`、`git diff --check` 通过;本地浏览器检查数字讲师、组织页面及组织管理页面,组织课程、成员、积分、邀请和管理区块正常展示;独立 PostgreSQL 测试库实际验证未登录、成员、管理员和非成员的访问边界,申请及审批、邀请加入及停用、成员创建和改名、首次改密限制、积分调整与流水可见性、组织停用和恢复后的接口行为,断言均通过。测试库已删除。
+- 未确认与风险:未在公网或生产数据上验证;未执行真实组织的增删改操作,浏览器端交互仅核查已有页面与只读展示;窄屏布局和所有课程操作未逐项验证。
+- 后续修改提示:组织页面若增加条件渲染,保持 Hook 在所有返回之前调用;组织管理新增操作需复核角色和停用状态,并用独立数据验证失败路径。
+- 关联:C-20260923-04、C-20260923-05。
+
+### C-20260923-07|已用于课程的讲师禁止删除并支持复制
+
+- 日期:2026-09-23
+- 类型:功能 / 修复
+- 需求与原因:用户要求任何已有课程引用的数字讲师不可删除,并在讲师管理页面复制讲师。
+- 修改范围:`server/digital-instructors.ts` 删除时在事务中锁定讲师并检查全部课程引用,命中时返回 409;新增同工作区复制接口,复制讲师资料和独立上传文件,记录来源与事件,复制件默认归操作人所有;`src/api.ts` 增加复制客户端;`src/pages/DigitalInstructorsPage.tsx` 和 `src/pages/admin/AdminDigitalInstructors.tsx` 增加复制操作。
+- 保留/新增判断:遵守 D-001、D-005、D-007 和 C-20260923-05。只要课程仍引用讲师,无论课程状态,讲师都不能删除;复制件不继承课程关联,文件使用独立副本;停用组织不能复制新讲师。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build`、`git diff --check` 通过;独立 PostgreSQL 测试库验证复制后 ID、来源、所有者及照片文件路径不同,未登录复制返回 401,草稿和已完成课程引用均阻止删除,未使用的复制件可以删除;本地浏览器确认资产库中出现“复制讲师”按钮。测试库已删除。
+- 未确认与风险:未对生产数据、平台管理员页面的实际点击复制和组织停用场景做浏览器端验证;未部署公网。复制文件可能随讲师数量增加而占用更多存储空间。
+- 后续修改提示:如引入课程讲师引用的新状态或关系,删除准入仍需覆盖全部引用;如调整复制机制,保持文件独立和来源可追溯。
+- 关联:C-20260923-05、C-20260923-06。
+
+### C-20260923-08|讲师创建流程按照片与录音步骤优化并统一名称备注
+
+- 日期:2026-09-23
+- 类型:功能 / 数据库 / 样式
+- 需求与原因:用户提供素材拍摄与录音准备参考图,要求创建讲师更友好;讲师资料只保留名称和备注,同一组织不能重名。
+- 修改范围:新增 `src/components/DigitalInstructorCreateForm.tsx`,将创建流程分为讲师资料、照片、录音三个区块,增加拍摄/录音提示、格式大小校验、照片预览和录音试听;调整资产库和平台管理页的编辑字段为名称及备注;更新 `src/api.ts`、`src/types.ts` 和 `server/digital-instructors.ts` 的输入输出与复制逻辑;`server/schema.sql` 新增备注列并把旧简介一次性迁入备注,新增组织内名称去重触发器,保留历史重名数据;`src/styles.css` 补齐表单与窄屏导航样式。
+- 保留/新增判断:遵守 D-001、D-005、D-007 和 C-20260923-05。组织内有效讲师姓名按去首尾空格、忽略大小写比较,创建、改名和跨库复制冲突返回 409;同工作区复制自动生成不冲突的“副本”名称。个人库允许同名。名称必填、备注选填;照片和录音允许创建后补齐,课程提交制作前仍须两项齐备。当前提示仅供用户自查,不声称已通过素材质量验证。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build`、`git diff --check` 通过;独立 PostgreSQL 测试库验证组织同名创建/改名/个人复制入组织返回 409、组织内复制自动递增副本名、个人库不受组织重名限制、备注字段正确返回;重复执行建表脚本成功;模拟旧库缺少备注列后重跑迁移,旧简介保留到备注。浏览器查看桌面创建表单、输入已有名称的即时提示和禁用保存,390px 视口下无页面横向溢出。测试库已删除。
+- 未确认与风险:未在生产库或公网执行迁移与验证;浏览器未实际上传真实照片和录音并听审质量。历史组织同名记录保留,编辑其名称时须改为唯一名称;旧 `organization`、`introduction` 数据列暂留作兼容,不再出现在新讲师界面。
+- 后续修改提示:未来 AI 质量检验须保存可追溯结果,并在提交制作时加入准入判断;若改变讲师命名规则,需同步服务端触发器、复制命名及前端即时提示。生产升级前备份数据库与上传目录。
+- 关联:C-20260923-05、C-20260923-07。
+
+### C-20260923-09|手机拍照与设备直接录音
+
+- 日期:2026-09-23
+- 类型:功能 / 样式
+- 需求与原因:用户要求创建讲师时可追加多张照片,手机可以直接拍照,手机或电脑可以直接录音,不必预先准备文件。
+- 修改范围:`src/components/DigitalInstructorCreateForm.tsx` 的照片选择改为分批追加、去除重复选择、单张移除和最多 19 张限制;窄屏提供后置摄像头拍照入口;新增基于浏览器 `getUserMedia` 和 `MediaRecorder` 的录音、计时、结束、取消、试听与重录流程,并在离开表单时释放麦克风。`server/digital-instructors.ts` 接受浏览器录音常见的 WebM、Ogg MIME 类型;讲师编辑及平台管理页的文件选择同步允许这些格式;`src/styles.css` 调整桌面与手机上传布局。
+- 保留/新增判断:遵守 D-005 和 C-20260923-05、C-20260923-08。照片可多张,录音每位讲师当前仍为一份;录制与选择文件最终走相同的鉴权上传接口和提交前素材齐备判断。最多 19 张照片加一份录音对应当前服务端 20 文件限制。手机拍照调用系统相机,浏览器录音需要 HTTPS 或 localhost 及麦克风权限;未获得权限时可继续上传文件。当前只校验文件存在、格式和大小,不判断质量。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build`、`git diff --check` 通过;390px 浏览器视口下显示拍照入口,页面无横向溢出;通过浏览器文件选择器先选两张测试照片,再从拍照入口追加第三张,已选数量由 2 变为 3 且前两张仍在;独立 PostgreSQL 测试库验证 multipart 请求保存两张照片及一份 WebM 录音,讲师返回素材齐备。测试库已删除。
+- 未确认与风险:未在真实手机上触发系统相机,也未授权真实麦克风录音,因此设备兼容性、权限弹窗和录音质量尚未确认;浏览器录制格式因设备而异,后续声音克隆服务若只接受特定格式,需在接入时转换或限制格式;未部署公网。
+- 后续修改提示:若提高照片上限,需同步调整 multipart 文件数量限制和前端提示;接入声音克隆接口前确认 WebM、Ogg、M4A 的实际支持范围。
+- 关联:C-20260923-08。
+
+### C-20260923-10|数字讲师独立创建编辑与资产管理页
+
+- 日期:2026-09-23
+- 类型:功能 / 样式 / 接口
+- 需求与原因:用户确认数字讲师的创建和编辑均使用独立页面,并要求统一照片、录音表单以及列表中的复制、删除和课程使用状态。
+- 修改范围:新增独立创建及编辑路由和共用表单;编辑时可逐张保留、移除或添加照片,设置封面,更换或保留录音,组织管理员可调整所有者;重排个人、组织和平台管理资产列表,加入搜索、素材状态、课程使用情况、复制与删除确认;新增讲师详情、课程引用查询和一次提交多项编辑的接口,并为列表返回课程引用数量。核心文件为 `src/pages/DigitalInstructorEditorPage.tsx`、`src/components/DigitalInstructorCreateForm.tsx`、`src/pages/DigitalInstructorsPage.tsx`、`src/pages/admin/AdminDigitalInstructors.tsx`、`server/digital-instructors.ts`、`src/api.ts`、`src/types.ts`、`src/App.tsx` 和 `src/styles.css`。
+- 保留/新增判断:遵守 D-001、D-005、D-007,以及 C-20260923-05、C-20260923-08、C-20260923-09。创建和编辑共用三段资料流程;照片和录音仍可暂缺,提交课程制作前要求齐备;已被课程引用的讲师在界面禁止删除,服务端继续返回 409;普通组织成员只能编辑自己拥有的讲师,组织管理员和平台管理员可维护组织讲师。编辑表单的文件增删和资料变更一次保存,失败时不提交部分数据库修改。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build`、`git diff --check` 通过;独立 PostgreSQL 测试库及本地服务验证两张照片加录音创建、非所有者编辑返回 403、组织管理员一次编辑姓名备注及所有者并移除一张旧照片、增加封面照片和更换录音、课程引用数量变化、已使用讲师删除返回 409;平台管理员管理接口及引用查询通过。浏览器查看资产卡片、课程使用弹层、操作菜单、独立创建与编辑页,390px 视口无横向溢出且底部操作可见。测试服务已停止,测试库已删除。
+- 未确认与风险:未在真实手机上验证系统相机和麦克风;未在生产数据库或公网验证。本地浏览器未对真实组织资料执行保存、复制或删除,相关写入由隔离接口测试覆盖。
+- 后续修改提示:接入 AI 素材质量验证时,需同时更新共用表单提示与课程提交条件;若改变讲师删除策略,须同步课程引用查询、界面禁用状态和服务端 409 校验。生产升级前核对数据库与上传目录备份。
+- 关联:C-20260923-05、C-20260923-08、C-20260923-09。
+
+### C-20260923-11|数字讲师搜索、备注收起与照片完整预览
+
+- 日期:2026-09-23
+- 类型:功能 / 样式
+- 需求与原因:用户希望资产库默认隐藏搜索栏,创建讲师默认隐藏选填备注;编辑照片要显示完整画面,并可点击放大。
+- 修改范围:`src/pages/DigitalInstructorsPage.tsx` 将搜索和素材状态筛选折叠到搜索按钮,筛选生效时在按钮上提示并提供清除操作;`src/components/DigitalInstructorCreateForm.tsx` 将备注改为按需展开,已有备注在编辑时自动展开;已保存和待上传照片均支持点击查看大图及 Escape 关闭;`src/styles.css` 补充搜索面板、按钮、完整照片缩略图和全屏预览的桌面/手机样式。
+- 保留/新增判断:遵守 D-005 及 C-20260923-10。搜索收起时保留筛选条件,并在入口标识“已筛选”,避免列表变化无法解释;备注收起不清空已有输入;受保护照片放大仍通过鉴权获取,不公开文件路径。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build`、`git diff --check` 通过;浏览器确认资产库搜索默认收起、点击展开并聚焦输入框;编辑页照片点击后出现完整大图及关闭按钮;创建页备注默认收起,点击后出现文本框;390px 浏览器视口查看资产库搜索入口和创建页布局,无明显横向溢出。
+- 未确认与风险:未在真实手机上验证触摸查看照片;未部署公网。本次未改服务端接口或数据结构。
+- 后续修改提示:若增加照片处理或裁剪功能,缩略图和大图仍应保留查看原图的路径;筛选条件隐藏时需保持可见提示或恢复默认筛选。
+- 关联:C-20260923-10。
+
+### C-20260923-12|照片参考样例与录音倒计时朗读提示
+
+- 日期:2026-09-23
+- 类型:功能 / 样式
+- 需求与原因:用户希望上传照片时能查看合适与不合适的参考样例;直接录音时显示朗读稿,先倒数 3、2、1 再录制,并显示录音时间,满 10 秒后用绿色提示。
+- 修改范围:`src/components/DigitalInstructorCreateForm.tsx` 增加照片样例入口和弹层、固定朗读稿、获取麦克风后 3 秒倒数、实际开始录制后计时及 10 秒绿色时长提示,倒数期间可取消并释放麦克风;`src/styles.css` 增加样例、朗读稿与录音状态样式;`public/guides/digital-instructor-photo-reference.png` 保存用户提供的示例材料,弹层按适合和不适合两张画面展示,并可展开完整说明。
+- 保留/新增判断:遵守 D-005 和 C-20260923-09。倒数结束前不启动 `MediaRecorder`;10 秒绿色仅说明本次录音时长达到提示线,不代表声音质量或未来 AI 检验合格,也不新增服务端时长限制。用户仍可上传现有录音文件。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build`、`git diff --check` 通过;本地浏览器确认创建页出现样例入口和朗读稿,点击样例后显示照片对照;390px 视口查看两张对照照片可辨认、弹层可滚动,随后恢复默认视口。
+- 未确认与风险:未授权真实麦克风,故倒数、实际录音计时和 10 秒状态切换尚未做设备端验证;未部署公网。构建提示主 JS 包超过 500 kB,未影响构建通过,本次未作拆包调整。
+- 后续修改提示:接入正式声音质量或时长校验时,需在服务端校验音频真实时长和内容,不能依赖前端绿色提示;若替换参考图,检查手机端两张示例的裁切位置。
+- 关联:C-20260923-09、C-20260923-11。
+
+### C-20260923-13|平台管理员独立待制作课程队列
+
+- 日期:2026-09-23
+- 类型:功能 / 样式
+- 需求与原因:制作团队需要单独查看当前等待制作的课程及提交资料,无需在全量课程表中筛选。
+- 修改范围:新增 `src/pages/admin/AdminProductionQueue.tsx` 与 `/admin/production-queue` 路由;管理导航增加“待制作课程”,概览入口指向队列;队列逐课展示提交人、工作区、提交时间、分集与讲稿数量、素材文件下载、提交时的数字讲师快照照片和录音文件;`src/styles.css` 增加桌面与窄屏布局。
+- 保留/新增判断:遵守 D-001、D-005、D-009 和 C-20260923-05。队列仅查询 `WAITING_PRODUCTION` 状态;讲师资料以提交快照为准;文件继续通过鉴权接口下载,不公开静态文件路径;完整制作操作仍进入现有课程详情页。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build`、`git diff --check` 通过;核对现有管理员课程列表接口按状态过滤且由 `adminAuth` 保护,文件接口校验登录及管理员身份。未改服务端或数据库结构。
+- 未确认与风险:当前未使用真实管理员账号在浏览器中验证队列加载、搜索、素材下载与窄屏页面;未部署公网。既有工作区含其他未提交修改,本次未清理或覆盖。
+- 后续修改提示:若增加制作状态或团队分工,明确队列纳入条件和权限;讲师照片与录音需继续使用提交快照及受保护文件接口。
+- 关联:C-20260923-05、C-20260923-10。
+
+### C-20260923-14|取消平台制作详情页并合并制作操作
+
+- 日期:2026-09-23
+- 类型:功能 / 重构
+- 需求与原因:用户明确要求删除平台管理员现有制作详情页,避免与新待制作页面重复。
+- 修改范围:移除 `/admin/courses/:id` 平台路由及各管理页面对此路径的链接;待制作队列卡片加入完整讲稿、提交人资料,以及接单操作;同页增加“制作中”切换、交付文件上传与下载、实际积分调整和完成交付。全部课程页保留为只读总览。`AdminCourseDetail` 组件仍供组织管理员课程路径使用,不作为平台管理员页面。
+- 保留/新增判断:保留 D-001、D-004、D-005、D-009;平台制作操作集中于 `/admin/production-queue`,受原管理员接口鉴权约束。替代 C-20260923-13 中“完整制作操作仍进入现有课程详情页”的判断。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build`、`git diff --check` 通过;检索确认平台管理端没有残留指向 `/admin/courses/:id` 的链接,组织管理路径仍引用原组件。
+- 未确认与风险:未用真实平台管理员账号验证接单、上传、积分调整与交付;未部署公网。旧平台详情页中的更多辅助操作未全部迁入新队列,例如删除交付文件、退回待制作和修改已完成课程的交付说明。
+- 后续修改提示:若团队需要恢复辅助操作,应直接在制作队列卡片中补充并验证相应状态及权限,不要重新引入重复的详情页。
+- 关联:C-20260923-13。
+
+### C-20260923-15|平台管理员与普通用户身份彻底隔离
+
+- 日期:2026-09-23
+- 类型:功能 / 数据库 / 修复
+- 需求与原因:平台管理员账户必须与普通用户、组织成员和组织管理员分开保存,双方不能通过后台操作互相转换,避免权限和数据归属误操作。
+- 修改范围:新增 `platform_admins`、`platform_admin_sessions` 独立表;后台登录和后台鉴权改用独立管理员会话;普通登录只使用 `users` 与普通会话;启动迁移旧 `users.role='ADMIN'` 记录并保留管理员审计归属;数据库触发器禁止普通表出现非 `USER` 角色,也禁止管理员表与普通用户手机号重叠;移除用户管理中的升降级操作;管理员操作的审计、积分流水、组织审批和数字讲师事件增加管理员归属字段;平台管理员不能创建普通用户数字讲师资产。
+- 保留/新增判断:保留 D-001、D-004、D-006、D-007、D-009。新增判断:`users` 只保存普通用户,`platform_admins` 只保存平台管理员;平台管理员与普通用户没有角色转换接口、共享会话或共享手机号;旧管理员若拥有课程、组织成员关系或数字讲师业务数据,启动迁移会停止并要求人工处理,不自动删除或转移业务归属。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build`、`git diff --check` 通过;隔离 PostgreSQL 库验证普通注册、管理员注册手机号拦截、普通登录、管理员登录、交叉登录拒绝、普通会话访问 `/api/me`、管理员会话访问 `/api/me`、普通会话访问后台返回 401、管理员访问后台统计/课程/用户/组织/数字讲师接口成功、管理员退出后会话失效、角色转换接口返回 403;实际操作验证用户创建/重置密码、组织创建/更新、成员授权/移除、组织积分、申请审批、课程制作状态/积分/交付/结单、数字讲师查看/编辑/复制/删除;浏览器实际验证管理员登录后台、管理员退出、普通用户登录课程首页;迁移库验证旧管理员从 `users` 迁入 `platform_admins` 且普通用户表不再保留管理员记录;数据库触发器验证三种手机号/角色越界写入均被拒绝;生产模式服务启动成功。
+- 未确认与风险:未在生产数据库或公网部署;本地隔离测试使用临时数据库和测试账号,临时服务已停止,测试数据库待清理;历史管理员若拥有业务数据会按设计阻止自动迁移,需要备份后人工制定归属方案;构建仍有 Vite 大包体积提示,但不影响构建。
+- 后续修改提示:新增平台后台接口必须使用 `adminAuth`,普通用户接口不得接受管理员会话;审计、积分流水、审批和资产事件涉及操作人时应分别写入 `admin_actor_id` 或普通用户字段;不得恢复 `users.role='ADMIN'` 或升降级接口。生产升级前备份数据库和上传目录,并先核对旧管理员是否拥有业务数据。
+- 关联:C-20260923-05、C-20260923-10、C-20260923-14。
+
+### C-20260923-16|数字讲师创建默认所有者且编辑所有者权限收紧
+
+- 日期:2026-09-23
+- 类型:功能 / 样式 / 权限
+- 需求与原因:用户明确要求:数字讲师创建时默认所有者为创建者本人,无需填写;编辑时普通用户不可修改所有者,仅管理员编辑时可以修改。
+- 修改范围:`src/components/DigitalInstructorCreateForm.tsx` 确认创建时不展示所有者字段;编辑组织讲师时,非管理员显示只读禁用的所有者姓名并提示仅管理员可修改,管理员提供成员下拉框支持更换;保存时非管理员不传 `ownerId`;`src/pages/DigitalInstructorEditorPage.tsx` 补充系统管理员与组织管理员判断并传递 `canManageOwner`;`src/styles.css` 补充输入框禁用样式。服务端原有时序与权限(创建直接设为创建者自身、编辑非管理员改所有者报 403)保持生效。
+- 保留/新增判断:遵守 D-001、D-005、D-009 和 C-20260923-05。数字讲师创建时不提供所有者填写项,服务端强制设为 `uid(req)`;编辑时所有者修改权严格限定为组织管理员及平台管理员,普通所有者编辑时字段只读。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build`、`git diff --check` 全部通过。
+- 未确认与风险:未在生产环境或真实公网验证;既有未提交改动继续保留。
+- 后续修改提示:未来若扩展协作者或多人所有权,需在此表单和接口保持只读与管理员授权逻辑一致。
+- 关联:C-20260923-05、C-20260923-10。
+
+### C-20260923-17|顶部栏工作区管理员匹配与积分余额千分位展示
+
+- 日期:2026-09-23
+- 类型:UI / 体验 / 逻辑修复
+- 需求与原因:用户明确要求:右侧“管理员”字样要跟当前工作区严格匹配,只有当前选中的工作区角色为管理员才显示,避免切换到个人工作区或普通组织成员时误显;积分部分文案需写清楚“积分余额:xxx”,且数字格式保持统一每三位用逗号隔开。
+- 修改范围:
+  1. `src/components/Layout.tsx`:将 `isOrgManager` 从全局匹配任一组织角色(`organizations.some`)改为仅匹配当前工作区(`currentOrganization?.role === 'MANAGER'`);若处于个人工作区或非管理员组织,则不渲染管理员徽标;
+  2. `src/components/Layout.tsx`:积分徽章文案更新为“积分余额:xxx”,并使用 `.toLocaleString('zh-CN')` 统一千分位逗号展示;
+  3. `src/pages/OrganizationManage.tsx`:组织积分余额数字同样增加 `.toLocaleString('zh-CN')` 格式化,保持全站一致。
+- 保留/新增判断:遵守 D-001。前台“管理员”徽章必须与当前选中的工作区上下文保持一致,不能泄露或混淆其他工作区的管理权限。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build`、`git diff --check` 全部通过。
+- 未确认与风险:未在生产环境构建部署。
+- 关联:C-20260923-05。
+
+### C-20260923-18|口播稿富文本弹窗与课程附件多选追加
+
+- 日期:2026-09-23
+- 类型:功能 / 样式 / 接口
+- 需求与原因:长篇口播稿需要独立编辑空间与基础排版;分集素材和通用资产需要一次选择多份,也能多次添加待上传文件。
+- 修改范围:课程详情页改为口播稿摘要与“编辑”入口,新增富文本弹窗,支持加粗、斜体、列表和长篇滚动编辑;保存到原讲稿字段,旧纯文本按段落显示;服务端保存时清理富文本标签。分集附件和通用附件的多次文件选择改为追加,显示待上传清单与删除入口,每批限 20 份。平台制作队列和组织管理详情按安全富文本显示讲稿,复制讲稿时提取纯文字。
+- 保留/新增判断:遵守 D-001、D-005、D-009;讲稿沿用原字段,富文本使用显式格式标记区分旧纯文本,展示时清理 HTML;附件仍经原鉴权接口保存,不开放公开静态路径。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build`、`git diff --check` 通过;核对原上传接口支持 multipart 多文件、每次最多 20 份且单份最大 200 MB。
+- 未确认与风险:未在已登录浏览器实际执行富文本保存、刷新回显、多次选择文件及上传;未部署公网。构建有既存的大包提示。旧分集创建弹窗仍提供纯文本初稿入口,创建后可进入新弹窗排版。
+- 后续修改提示:若增加讲稿导出、搜索或下游 AI 处理,应先将富文本转换为纯文本或明确解析格式;新增讲稿展示处不得直接信任数据库中的 HTML。
+- 关联:C-20260923-04、C-20260923-17。
+
+### C-20260923-19|课程素材全屏预览与讲稿弹窗布局、统计
+
+- 日期:2026-09-23
+- 类型:功能 / 修复 / 样式 / 接口 / 部署配置
+- 需求与原因:用户要求 PPTX、PDF、Word、图片和视频上传后可预览,PDF 与 PPTX 可翻页;用户截图显示口播稿弹窗的工具栏和底部操作透出遮罩,并要求实时字数和口播时间估算。
+- 修改范围:`src/components/CourseAssetPreview.tsx` 新增全屏预览,图片按原比例展示、视频使用浏览器播放控件、PDF 逐页渲染,PPTX 与 Word 调用鉴权转换接口后逐页渲染;分集和通用素材列表增加预览及下载入口,文件选择器标明支持格式。`server/index.ts` 新增课程素材鉴权预览接口,LibreOffice 按需转 PDF 并在私有上传卷缓存,删除素材或课程时清理对应缓存;`Dockerfile` 安装 LibreOffice 和中文字体。`src/components/LectureNotesDialog.tsx` 与 `src/styles.css` 修复弹窗整体白色容器、滚动和窄屏布局,增加实时字数与按每分钟约 260 字计算的时长;`src/api.ts` 的组织课程预览请求携带当前工作区标识。
+- 保留/新增判断:遵守 D-001、D-005、D-008、D-009。预览先按素材所属课程和用户/组织角色鉴权;转换后的 PDF 保存在私有上传卷的 `.previews` 目录,不开放公共静态路径。时长仅为按字数估算,不代表成片实际时长。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build`、`git diff --check` 与本地 Docker 镜像构建通过;在隔离数据库、服务和上传目录中批量上传 PPTX 与图片,另上传 PDF、DOCX、MP4。PPTX 转换得到 6 页 PDF,浏览器实际从第 1 页翻到第 2 页;PDF 同样翻页,Word 显示转换后页面,图片全屏展示,MP4 显示播放控件;生产镜像内分别实际转换 PPTX 和 DOCX 并返回 PDF。未登录、其他用户、目标素材不存在分别返回 401、403、404;组织课程正确工作区访问返回 200,其他组织工作区返回 404,即使指定目标工作区但不属于该组织也返回 403。浏览器确认讲稿弹窗各区域在同一白色容器内,输入后字数与时间实时变化,取消后文字未保存。
+- 未确认与风险:未部署到公网或使用生产数据验收;未在真实手机上验证讲稿弹窗触控与所有浏览器的视频编码兼容性,也未压测超大文档。PDF.js 工作线程与 LibreOffice 会增加前端资源体积和生产镜像体积;构建仍有主包超过 500 kB 的提示。非浏览器支持的视频格式需下载原文件查看。
+- 后续修改提示:扩展预览类型时继续复用课程鉴权与工作区校验,不得直接公开缓存文件;生产部署前核对新镜像体积、上传持久卷、LibreOffice 可用性,并按 D-009 验证公网路径。
+- 关联:C-20260923-18。
+
+### C-20260923-20|修复本地 PPTX 预览调用并导入演讲者备注
+
+- 日期:2026-09-23
+- 类型:修复 / 功能
+- 需求与原因:用户上传的 PPTX 在课程详情提示预览生成失败,并希望上传时自动把演讲者备注提取为口播稿。
+- 修改范围:`server/index.ts` 的 LibreOffice 调用支持 `SOFFICE_PATH`,并在本机运行时提供现有依赖路径作为回退;新增 `server/pptx-notes.ts` 读取 PPTX 备注正文;上传单份 PPTX 时,对指定分集或仅有一个分集的课程,在口播稿为空时写入逐页备注。上传结果返回导入页数,课程详情显示导入提示。增加 `jszip`、`fast-xml-parser` 依赖。
+- 保留/新增判断:遵守 D-001、D-005、D-007、D-009。备注只填空白讲稿,不覆盖人工内容;多分集课程的通用素材不自动猜测目标分集。原始 PPTX 继续保存在原鉴权路径。
+- 功能确认状态:部分确认
+- 验证证据:用户文件的 ZIP 校验通过,6 页均有演讲者备注;本机 LibreOffice 转换成功并生成 259015 字节 PDF;现有本地服务进程的 PATH 未包含 soffice,定位到原预览失败原因。使用新解析函数读取该文件得到 6 段正文;`npm run check`、`npm run build`、`git diff --check` 通过。
+- 未确认与风险:未在已登录浏览器完成上传到分集、刷新回显和预览接口的端到端验证;未部署公网。该课程现有口播稿非空,按保留规则不自动回填。PPTX 备注顺序目前按 notesSlide 编号读取,重排过幻灯片但备注 XML 编号未重排的文件可能顺序不一致。
+- 后续修改提示:生产环境优先使用镜像内 soffice 或显式 `SOFFICE_PATH`;如需覆盖已有口播稿,应增加用户确认与预览差异。处理特殊 PPTX 时应按 presentation 关系映射真实页序。
+- 关联:C-20260923-19。
+
+### C-20260923-21|确保生产容器可转换 PPTX 并修复预览中文缺字
+
+- 日期:2026-09-23
+- 类型:修复 / 部署配置
+- 需求与原因:用户要求部署服务器后仍能调用 LibreOffice,并反馈当前预览中中文文字缺失,需增加原文件优先提示。
+- 修改范围:`Dockerfile` 在安装 LibreOffice 后检查 `/usr/bin/soffice` 可执行;`docker-compose.prod.yml` 显式设置 `SOFFICE_PATH`;`server/index.ts` 在 macOS 转换时提供系统中文字体目录与微软雅黑到冬青黑体的替代配置,预览缓存升级为 `v2` 以跳过已生成的缺字 PDF,删除素材时一并清理两版缓存;`src/components/CourseAssetPreview.tsx` 与 `src/styles.css` 加入预览差异提示。
+- 保留/新增判断:遵守 D-005、D-008、D-009。生产容器继续使用镜像内 LibreOffice 和 Noto CJK 字体;转换结果仍仅存私有上传卷,经鉴权接口读取。预览仅供查看,原文件为准。
+- 功能确认状态:部分确认
+- 验证证据:使用部署脚本相同的 `linux/amd64` 架构构建生产镜像成功;容器中 `/usr/bin/soffice --version` 正常,`fc-match 微软雅黑` 匹配 Noto Sans CJK;以用户 PPTX 在容器内转换成功,PDF 第一页正文完整可见且文字层含中文。本机原配置生成的 PDF 中文缺失;显式加载 macOS 字体目录与替代规则后,同文件转换的第一页中文完整可见。
+- 未确认与风险:尚未部署服务器,未验证公网实际课程接口及持久卷中的新缓存;其他罕见或嵌入字体不保证版式完全一致。生产部署后仍需按 D-009 验证真实预览路径。
+- 后续修改提示:升级 LibreOffice 或字体包时,用有中文及演讲者备注的真实 PPTX 检查渲染图和文字层;如更改缓存算法,更新缓存版本并维护删除清理。
+- 关联:C-20260923-19、C-20260923-20。
+
+### C-20260923-22|统一包管理器为 pnpm
+
+- 日期:2026-09-23
+- 类型:仅决策 / 文档
+- 需求与原因:工作区此前同时存在已跟踪的 `package-lock.json`(npm)与未跟踪的 `pnpm-lock.yaml` / `pnpm-workspace.yaml`,包管理器混用会导致依赖不一致与部署失败。用户确认统一使用 pnpm。
+- 修改范围:删除已跟踪的 `package-lock.json`;将 `pnpm-lock.yaml` 与 `pnpm-workspace.yaml` 纳入版本控制;`.gitignore` 新增忽略 `package-lock.json`;本文件新增 D-010 工程判断。
+- 保留/新增判断:新增 D-010。依赖以 pnpm 为准,禁止提交 npm lock;新增或升级依赖后须刷新并提交 `pnpm-lock.yaml`,并用 `pnpm install --lockfile-only --frozen-lockfile` 校验一致。
+- 功能确认状态:已确认(仅决策)
+- 验证证据:用 corepack 激活 pnpm 12.5.1,`pnpm install --lockfile-only --frozen-lockfile` 返回 exit 0,确认 `pnpm-lock.yaml` 与当前 `package.json` 完全一致;npm 阶段新增的四个依赖(jszip、fast-xml-parser、qrcode、@types/qrcode)均已存在于 lock 的 packages 与 importers 段。
+- 未确认与风险:本机此前未安装 pnpm,已通过 corepack 临时激活;后续开发/CI 需确保 pnpm 可用(建议 `corepack enable` 或全局安装 pnpm)。`node_modules` 当前仍为 npm 格式,首次 `pnpm install` 会将其转为 pnpm 符号链接结构,属本地可逆变更。
+- 后续修改提示:所有安装、升级依赖必须走 pnpm;提交前确认 `pnpm-lock.yaml` 已更新且 `package-lock.json` 不出现在 `git status`。
+- 关联:D-010。

+ 3 - 0
Dockerfile

@@ -8,6 +8,9 @@ RUN npm run build
 FROM node:22-alpine
 WORKDIR /app
 ENV NODE_ENV=production
+RUN apk add --no-cache libreoffice font-noto-cjk \
+    && test -x /usr/bin/soffice \
+    && /usr/bin/soffice --version
 COPY package*.json ./
 RUN npm install --omit=dev
 COPY --from=build /app/dist ./dist

+ 11 - 1
README.md

@@ -1,6 +1,6 @@
 # 星痕 · 智能课程工坊
 
-首版 MVP 包含课程创建、PPT 课件上传、素材确认和提交制作。课程提交后进入“等待制作”状态。
+系统支持个人与组织工作区。用户可加入多个组织,课程归属于一个工作区;个人与组织分别拥有积分账户和流水。组织成员可查看本组织课程列表与进度,组织管理员可在独立页面管理成员、课程与组织账单。
 
 ## 技术组成
 
@@ -19,6 +19,16 @@
 
 Server 启动时会自动创建所需的数据表。
 
+## 组织功能
+
+- 平台管理员在 `/admin/organizations` 创建组织、设置组织管理员和调整组织积分。
+- 平台管理员可停用并恢复组织。停用期间停止新成员加入和新建组织课程,历史课程、成员与账单保留。
+- 组织管理员在 `/organizations/<组织网址标识>/manage` 审批加入申请、创建老师账号、生成免审批注册链接、管理组织课程并查看完整账单。
+- 普通成员在工作台切换个人或组织工作区;组织页面只显示课程列表、进度和人员名单。成员只能查看自己课程产生的组织积分消耗。
+- 组织管理员现场创建老师时只需手机号和姓名;初始密码为 `888888`,首次登录须修改密码。
+- 上传素材与交付文件通过鉴权接口读取,原 `/uploads/` 公开文件路径不再提供文件。
+- 已有课程默认保留在个人工作区,已有个人积分保留并转换为个人流水。旧的用户“单位”资料不再参与组织关系。
+
 ## 公网部署
 
 推荐在带持久磁盘的 Linux 云服务器运行:

+ 11 - 7
deployment/remote-deploy.sh

@@ -61,11 +61,21 @@ install_nginx_block() {
   rm -f "$staged"
 }
 
+server_names="$PRIMARY_DOMAIN"
+if [[ "${ENABLE_FUTURE_DOMAIN:-0}" == 1 ]]; then
+  resolved=$(getent ahostsv4 "$FUTURE_DOMAIN" | awk 'NR==1 {print $1}')
+  if [[ "$resolved" != 47.93.193.127 ]]; then
+    echo "$FUTURE_DOMAIN 尚未解析到 47.93.193.127,拒绝启用。" >&2
+    exit 1
+  fi
+  server_names="$PRIMARY_DOMAIN $FUTURE_DOMAIN"
+fi
+
 http_block=$(mktemp)
 cat >"$http_block" <<EOF
     server {
         listen 80;
-        server_name $PRIMARY_DOMAIN;
+        server_name $server_names;
 
         location ^~ /.well-known/acme-challenge/ {
             root $ACME_ROOT;
@@ -91,14 +101,8 @@ certbot certonly --webroot -w "$ACME_ROOT" \
   --non-interactive --agree-tos --register-unsafely-without-email \
   --keep-until-expiring
 
-server_names="$PRIMARY_DOMAIN"
 cert_name="$PRIMARY_DOMAIN"
 if [[ "${ENABLE_FUTURE_DOMAIN:-0}" == 1 ]]; then
-  resolved=$(getent ahostsv4 "$FUTURE_DOMAIN" | awk 'NR==1 {print $1}')
-  if [[ "$resolved" != 47.93.193.127 ]]; then
-    echo "$FUTURE_DOMAIN 尚未解析到 47.93.193.127,拒绝启用。" >&2
-    exit 1
-  fi
   certbot certonly --webroot -w "$ACME_ROOT" \
     --cert-name "$PRIMARY_DOMAIN" -d "$PRIMARY_DOMAIN" -d "$FUTURE_DOMAIN" \
     --non-interactive --agree-tos --register-unsafely-without-email --expand

+ 1 - 0
docker-compose.prod.yml

@@ -30,6 +30,7 @@ services:
       DATABASE_URL: postgresql://xinghen:${POSTGRES_PASSWORD}@postgres:5432/xinghen
       UPLOAD_DIR: /app/uploads
       MAX_UPLOAD_MB: 100
+      SOFFICE_PATH: /usr/bin/soffice
       ADMIN_PHONE: ${ADMIN_PHONE}
       ADMIN_PASSWORD: ${ADMIN_PASSWORD}
     expose:

+ 0 - 3978
package-lock.json

@@ -1,3978 +0,0 @@
-{
-  "name": "xinghen-course-workshop",
-  "version": "0.1.0",
-  "lockfileVersion": 3,
-  "requires": true,
-  "packages": {
-    "": {
-      "name": "xinghen-course-workshop",
-      "version": "0.1.0",
-      "dependencies": {
-        "@fastify/cors": "^10.0.2",
-        "@fastify/multipart": "^9.0.3",
-        "@fastify/static": "^8.1.1",
-        "fastify": "^5.2.1",
-        "lucide-react": "^0.468.0",
-        "pg": "^8.13.1",
-        "react": "^19.0.0",
-        "react-dom": "^19.0.0",
-        "react-router-dom": "^7.1.1",
-        "tsx": "^4.19.2",
-        "zod": "^3.24.1"
-      },
-      "devDependencies": {
-        "@types/node": "^22.10.2",
-        "@types/pg": "^8.11.10",
-        "@types/react": "^19.0.2",
-        "@types/react-dom": "^19.0.2",
-        "@vitejs/plugin-react": "^4.3.4",
-        "concurrently": "^9.1.2",
-        "typescript": "~5.7.2",
-        "vite": "^6.0.5"
-      }
-    },
-    "node_modules/@babel/code-frame": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz",
-      "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-validator-identifier": "^7.29.7",
-        "js-tokens": "^4.0.0",
-        "picocolors": "^1.1.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/compat-data": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz",
-      "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/core": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz",
-      "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/code-frame": "^7.29.7",
-        "@babel/generator": "^7.29.7",
-        "@babel/helper-compilation-targets": "^7.29.7",
-        "@babel/helper-module-transforms": "^7.29.7",
-        "@babel/helpers": "^7.29.7",
-        "@babel/parser": "^7.29.7",
-        "@babel/template": "^7.29.7",
-        "@babel/traverse": "^7.29.7",
-        "@babel/types": "^7.29.7",
-        "@jridgewell/remapping": "^2.3.5",
-        "convert-source-map": "^2.0.0",
-        "debug": "^4.1.0",
-        "gensync": "^1.0.0-beta.2",
-        "json5": "^2.2.3",
-        "semver": "^6.3.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/babel"
-      }
-    },
-    "node_modules/@babel/generator": {
-      "version": "7.29.8",
-      "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.8.tgz",
-      "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/parser": "^7.29.8",
-        "@babel/types": "^7.29.8",
-        "@jridgewell/gen-mapping": "^0.3.12",
-        "@jridgewell/trace-mapping": "^0.3.28",
-        "jsesc": "^3.0.2"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-compilation-targets": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
-      "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/compat-data": "^7.29.7",
-        "@babel/helper-validator-option": "^7.29.7",
-        "browserslist": "^4.24.0",
-        "lru-cache": "^5.1.1",
-        "semver": "^6.3.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-globals": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
-      "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-module-imports": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
-      "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/traverse": "^7.29.7",
-        "@babel/types": "^7.29.7"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-module-transforms": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
-      "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-module-imports": "^7.29.7",
-        "@babel/helper-validator-identifier": "^7.29.7",
-        "@babel/traverse": "^7.29.7"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0"
-      }
-    },
-    "node_modules/@babel/helper-plugin-utils": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
-      "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-string-parser": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
-      "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-validator-identifier": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
-      "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-validator-option": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
-      "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helpers": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.7.tgz",
-      "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/template": "^7.29.7",
-        "@babel/types": "^7.29.7"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/parser": {
-      "version": "7.29.8",
-      "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz",
-      "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/types": "^7.29.8"
-      },
-      "bin": {
-        "parser": "bin/babel-parser.js"
-      },
-      "engines": {
-        "node": ">=6.0.0"
-      }
-    },
-    "node_modules/@babel/plugin-transform-react-jsx-self": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
-      "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.29.7"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/plugin-transform-react-jsx-source": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
-      "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-plugin-utils": "^7.29.7"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
-    "node_modules/@babel/template": {
-      "version": "7.29.7",
-      "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz",
-      "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/code-frame": "^7.29.7",
-        "@babel/parser": "^7.29.7",
-        "@babel/types": "^7.29.7"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/traverse": {
-      "version": "7.29.8",
-      "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.8.tgz",
-      "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/code-frame": "^7.29.7",
-        "@babel/generator": "^7.29.8",
-        "@babel/helper-globals": "^7.29.7",
-        "@babel/parser": "^7.29.8",
-        "@babel/template": "^7.29.7",
-        "@babel/types": "^7.29.8",
-        "debug": "^4.3.1"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/types": {
-      "version": "7.29.8",
-      "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz",
-      "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/helper-string-parser": "^7.29.7",
-        "@babel/helper-validator-identifier": "^7.29.7"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@esbuild/aix-ppc64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
-      "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
-      "cpu": [
-        "ppc64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "aix"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-arm": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
-      "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
-      "cpu": [
-        "arm"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-arm64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
-      "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-x64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
-      "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/darwin-arm64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
-      "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/darwin-x64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
-      "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
-      "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/freebsd-x64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
-      "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-arm": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
-      "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
-      "cpu": [
-        "arm"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-arm64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
-      "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-ia32": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
-      "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
-      "cpu": [
-        "ia32"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-loong64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
-      "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
-      "cpu": [
-        "loong64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-mips64el": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
-      "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
-      "cpu": [
-        "mips64el"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-ppc64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
-      "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
-      "cpu": [
-        "ppc64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-riscv64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
-      "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
-      "cpu": [
-        "riscv64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-s390x": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
-      "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
-      "cpu": [
-        "s390x"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-x64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
-      "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/netbsd-arm64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
-      "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/netbsd-x64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
-      "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-arm64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
-      "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-x64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
-      "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openharmony-arm64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
-      "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openharmony"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/sunos-x64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
-      "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "sunos"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-arm64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
-      "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-ia32": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
-      "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
-      "cpu": [
-        "ia32"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-x64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
-      "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@fastify/accept-negotiator": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmmirror.com/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz",
-      "integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT"
-    },
-    "node_modules/@fastify/ajv-compiler": {
-      "version": "4.0.6",
-      "resolved": "https://registry.npmmirror.com/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz",
-      "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "ajv": "^8.12.0",
-        "ajv-formats": "^3.0.1",
-        "fast-uri": "^4.0.0"
-      }
-    },
-    "node_modules/@fastify/busboy": {
-      "version": "3.2.1",
-      "resolved": "https://registry.npmmirror.com/@fastify/busboy/-/busboy-3.2.1.tgz",
-      "integrity": "sha512-tgK4O+57iz5ycYNGXE5ZWj1ES03lD2XnnBYWSbU/3wYZRMQzCUq7Ycds/RdyBZQeL5MU4fxBt6lzbIWf/Bickw==",
-      "license": "MIT"
-    },
-    "node_modules/@fastify/cors": {
-      "version": "10.1.0",
-      "resolved": "https://registry.npmmirror.com/@fastify/cors/-/cors-10.1.0.tgz",
-      "integrity": "sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "fastify-plugin": "^5.0.0",
-        "mnemonist": "0.40.0"
-      }
-    },
-    "node_modules/@fastify/deepmerge": {
-      "version": "3.2.1",
-      "resolved": "https://registry.npmmirror.com/@fastify/deepmerge/-/deepmerge-3.2.1.tgz",
-      "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT"
-    },
-    "node_modules/@fastify/error": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmmirror.com/@fastify/error/-/error-4.2.0.tgz",
-      "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT"
-    },
-    "node_modules/@fastify/fast-json-stringify-compiler": {
-      "version": "5.1.0",
-      "resolved": "https://registry.npmmirror.com/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz",
-      "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "fast-json-stringify": "^7.0.0"
-      }
-    },
-    "node_modules/@fastify/forwarded": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmmirror.com/@fastify/forwarded/-/forwarded-3.0.2.tgz",
-      "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT"
-    },
-    "node_modules/@fastify/merge-json-schemas": {
-      "version": "0.2.1",
-      "resolved": "https://registry.npmmirror.com/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
-      "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "dequal": "^2.0.3"
-      }
-    },
-    "node_modules/@fastify/multipart": {
-      "version": "9.4.0",
-      "resolved": "https://registry.npmmirror.com/@fastify/multipart/-/multipart-9.4.0.tgz",
-      "integrity": "sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "@fastify/busboy": "^3.0.0",
-        "@fastify/deepmerge": "^3.0.0",
-        "@fastify/error": "^4.0.0",
-        "fastify-plugin": "^5.0.0",
-        "secure-json-parse": "^4.0.0"
-      }
-    },
-    "node_modules/@fastify/proxy-addr": {
-      "version": "5.1.0",
-      "resolved": "https://registry.npmmirror.com/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
-      "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "@fastify/forwarded": "^3.0.0",
-        "ipaddr.js": "^2.1.0"
-      }
-    },
-    "node_modules/@fastify/send": {
-      "version": "4.1.1",
-      "resolved": "https://registry.npmmirror.com/@fastify/send/-/send-4.1.1.tgz",
-      "integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "@lukeed/ms": "^2.0.2",
-        "escape-html": "~1.0.3",
-        "fast-decode-uri-component": "^1.0.1",
-        "http-errors": "^2.0.0",
-        "mime": "^3"
-      }
-    },
-    "node_modules/@fastify/static": {
-      "version": "8.3.0",
-      "resolved": "https://registry.npmmirror.com/@fastify/static/-/static-8.3.0.tgz",
-      "integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "@fastify/accept-negotiator": "^2.0.0",
-        "@fastify/send": "^4.0.0",
-        "content-disposition": "^0.5.4",
-        "fastify-plugin": "^5.0.0",
-        "fastq": "^1.17.1",
-        "glob": "^11.0.0"
-      }
-    },
-    "node_modules/@isaacs/cliui": {
-      "version": "9.0.0",
-      "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-9.0.0.tgz",
-      "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
-      "license": "BlueOak-1.0.0",
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@jridgewell/gen-mapping": {
-      "version": "0.3.13",
-      "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
-      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jridgewell/sourcemap-codec": "^1.5.0",
-        "@jridgewell/trace-mapping": "^0.3.24"
-      }
-    },
-    "node_modules/@jridgewell/remapping": {
-      "version": "2.3.5",
-      "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
-      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jridgewell/gen-mapping": "^0.3.5",
-        "@jridgewell/trace-mapping": "^0.3.24"
-      }
-    },
-    "node_modules/@jridgewell/resolve-uri": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
-      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.0.0"
-      }
-    },
-    "node_modules/@jridgewell/sourcemap-codec": {
-      "version": "1.5.5",
-      "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
-      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@jridgewell/trace-mapping": {
-      "version": "0.3.31",
-      "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
-      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jridgewell/resolve-uri": "^3.1.0",
-        "@jridgewell/sourcemap-codec": "^1.4.14"
-      }
-    },
-    "node_modules/@lukeed/ms": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmmirror.com/@lukeed/ms/-/ms-2.0.2.tgz",
-      "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/@napi-rs/lzma-linux-x64-gnu": {
-      "version": "1.5.1",
-      "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
-      "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^22.20 || ^24.12 || >=25"
-      }
-    },
-    "node_modules/@pinojs/redact": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz",
-      "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
-      "license": "MIT"
-    },
-    "node_modules/@rolldown/pluginutils": {
-      "version": "1.0.0-beta.27",
-      "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
-      "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@rollup/rollup-android-arm-eabi": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
-      "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ]
-    },
-    "node_modules/@rollup/rollup-android-arm64": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
-      "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ]
-    },
-    "node_modules/@rollup/rollup-darwin-arm64": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
-      "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ]
-    },
-    "node_modules/@rollup/rollup-darwin-x64": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
-      "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ]
-    },
-    "node_modules/@rollup/rollup-freebsd-arm64": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
-      "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ]
-    },
-    "node_modules/@rollup/rollup-freebsd-x64": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
-      "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
-      "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
-      "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-arm64-gnu": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
-      "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-arm64-musl": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
-      "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-loong64-gnu": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
-      "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
-      "cpu": [
-        "loong64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-loong64-musl": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
-      "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
-      "cpu": [
-        "loong64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
-      "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-ppc64-musl": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
-      "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
-      "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
-      "cpu": [
-        "riscv64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-riscv64-musl": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
-      "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
-      "cpu": [
-        "riscv64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-s390x-gnu": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
-      "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
-      "cpu": [
-        "s390x"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-x64-gnu": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
-      "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-linux-x64-musl": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
-      "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ]
-    },
-    "node_modules/@rollup/rollup-openbsd-x64": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
-      "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openbsd"
-      ]
-    },
-    "node_modules/@rollup/rollup-openharmony-arm64": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
-      "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openharmony"
-      ]
-    },
-    "node_modules/@rollup/rollup-win32-arm64-msvc": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
-      "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ]
-    },
-    "node_modules/@rollup/rollup-win32-ia32-msvc": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
-      "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ]
-    },
-    "node_modules/@rollup/rollup-win32-x64-gnu": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
-      "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ]
-    },
-    "node_modules/@rollup/rollup-win32-x64-msvc": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
-      "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ]
-    },
-    "node_modules/@types/babel__core": {
-      "version": "7.20.5",
-      "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz",
-      "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/parser": "^7.20.7",
-        "@babel/types": "^7.20.7",
-        "@types/babel__generator": "*",
-        "@types/babel__template": "*",
-        "@types/babel__traverse": "*"
-      }
-    },
-    "node_modules/@types/babel__generator": {
-      "version": "7.27.0",
-      "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz",
-      "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/types": "^7.0.0"
-      }
-    },
-    "node_modules/@types/babel__template": {
-      "version": "7.4.4",
-      "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz",
-      "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/parser": "^7.1.0",
-        "@babel/types": "^7.0.0"
-      }
-    },
-    "node_modules/@types/babel__traverse": {
-      "version": "7.28.0",
-      "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
-      "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/types": "^7.28.2"
-      }
-    },
-    "node_modules/@types/estree": {
-      "version": "1.0.9",
-      "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
-      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/@types/node": {
-      "version": "22.20.1",
-      "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.1.tgz",
-      "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "undici-types": "~6.21.0"
-      }
-    },
-    "node_modules/@types/pg": {
-      "version": "8.23.1",
-      "resolved": "https://registry.npmmirror.com/@types/pg/-/pg-8.23.1.tgz",
-      "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/node": "*",
-        "pg-protocol": "*",
-        "pg-types": "^2.2.0"
-      }
-    },
-    "node_modules/@types/react": {
-      "version": "19.2.18",
-      "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.18.tgz",
-      "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "csstype": "^3.2.2"
-      }
-    },
-    "node_modules/@types/react-dom": {
-      "version": "19.2.4",
-      "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.4.tgz",
-      "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
-      "dev": true,
-      "license": "MIT",
-      "peerDependencies": {
-        "@types/react": "^19.2.0"
-      }
-    },
-    "node_modules/@vitejs/plugin-react": {
-      "version": "4.7.0",
-      "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
-      "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/core": "^7.28.0",
-        "@babel/plugin-transform-react-jsx-self": "^7.27.1",
-        "@babel/plugin-transform-react-jsx-source": "^7.27.1",
-        "@rolldown/pluginutils": "1.0.0-beta.27",
-        "@types/babel__core": "^7.20.5",
-        "react-refresh": "^0.17.0"
-      },
-      "engines": {
-        "node": "^14.18.0 || >=16.0.0"
-      },
-      "peerDependencies": {
-        "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
-      }
-    },
-    "node_modules/abstract-logging": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmmirror.com/abstract-logging/-/abstract-logging-2.0.1.tgz",
-      "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
-      "license": "MIT"
-    },
-    "node_modules/ajv": {
-      "version": "8.20.0",
-      "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz",
-      "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
-      "license": "MIT",
-      "dependencies": {
-        "fast-deep-equal": "^3.1.3",
-        "fast-uri": "^3.0.1",
-        "json-schema-traverse": "^1.0.0",
-        "require-from-string": "^2.0.2"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/epoberezkin"
-      }
-    },
-    "node_modules/ajv-formats": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz",
-      "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
-      "license": "MIT",
-      "dependencies": {
-        "ajv": "^8.0.0"
-      },
-      "peerDependencies": {
-        "ajv": "^8.0.0"
-      },
-      "peerDependenciesMeta": {
-        "ajv": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/ajv/node_modules/fast-uri": {
-      "version": "3.1.5",
-      "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.5.tgz",
-      "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/ansi-regex": {
-      "version": "5.0.1",
-      "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
-      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/ansi-styles": {
-      "version": "4.3.0",
-      "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
-      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "color-convert": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/atomic-sleep": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
-      "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=8.0.0"
-      }
-    },
-    "node_modules/avvio": {
-      "version": "9.3.0",
-      "resolved": "https://registry.npmmirror.com/avvio/-/avvio-9.3.0.tgz",
-      "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "@fastify/error": "^4.0.0",
-        "fastq": "^1.17.1"
-      }
-    },
-    "node_modules/balanced-match": {
-      "version": "4.0.4",
-      "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz",
-      "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
-      "license": "MIT",
-      "engines": {
-        "node": "18 || 20 || >=22"
-      }
-    },
-    "node_modules/baseline-browser-mapping": {
-      "version": "2.11.15",
-      "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz",
-      "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "bin": {
-        "baseline-browser-mapping": "dist/cli.cjs"
-      },
-      "engines": {
-        "node": ">=6.0.0"
-      }
-    },
-    "node_modules/brace-expansion": {
-      "version": "5.0.9",
-      "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.9.tgz",
-      "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
-      "license": "MIT",
-      "dependencies": {
-        "balanced-match": "^4.0.2"
-      },
-      "engines": {
-        "node": "20 || >=22"
-      }
-    },
-    "node_modules/browserslist": {
-      "version": "4.28.8",
-      "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.8.tgz",
-      "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/browserslist"
-        },
-        {
-          "type": "tidelift",
-          "url": "https://tidelift.com/funding/github/npm/browserslist"
-        },
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "baseline-browser-mapping": "^2.11.12",
-        "caniuse-lite": "^1.0.30001809",
-        "electron-to-chromium": "^1.5.402",
-        "node-releases": "^2.0.53",
-        "update-browserslist-db": "^1.3.0"
-      },
-      "bin": {
-        "browserslist": "cli.js"
-      },
-      "engines": {
-        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
-      }
-    },
-    "node_modules/caniuse-lite": {
-      "version": "1.0.30001809",
-      "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
-      "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/browserslist"
-        },
-        {
-          "type": "tidelift",
-          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
-        },
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
-        }
-      ],
-      "license": "CC-BY-4.0"
-    },
-    "node_modules/chalk": {
-      "version": "4.1.2",
-      "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
-      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ansi-styles": "^4.1.0",
-        "supports-color": "^7.1.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/chalk?sponsor=1"
-      }
-    },
-    "node_modules/chalk/node_modules/supports-color": {
-      "version": "7.2.0",
-      "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
-      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "has-flag": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/cliui": {
-      "version": "8.0.1",
-      "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz",
-      "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "string-width": "^4.2.0",
-        "strip-ansi": "^6.0.1",
-        "wrap-ansi": "^7.0.0"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/color-convert": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
-      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "color-name": "~1.1.4"
-      },
-      "engines": {
-        "node": ">=7.0.0"
-      }
-    },
-    "node_modules/color-name": {
-      "version": "1.1.4",
-      "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
-      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/concurrently": {
-      "version": "9.2.4",
-      "resolved": "https://registry.npmmirror.com/concurrently/-/concurrently-9.2.4.tgz",
-      "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "chalk": "4.1.2",
-        "rxjs": "7.8.2",
-        "shell-quote": "1.9.0",
-        "supports-color": "8.1.1",
-        "tree-kill": "1.2.2",
-        "yargs": "17.7.2"
-      },
-      "bin": {
-        "conc": "dist/bin/concurrently.js",
-        "concurrently": "dist/bin/concurrently.js"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
-      }
-    },
-    "node_modules/content-disposition": {
-      "version": "0.5.4",
-      "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz",
-      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
-      "license": "MIT",
-      "dependencies": {
-        "safe-buffer": "5.2.1"
-      },
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/convert-source-map": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz",
-      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/cookie": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz",
-      "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/express"
-      }
-    },
-    "node_modules/cross-spawn": {
-      "version": "7.0.6",
-      "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
-      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
-      "license": "MIT",
-      "dependencies": {
-        "path-key": "^3.1.0",
-        "shebang-command": "^2.0.0",
-        "which": "^2.0.1"
-      },
-      "engines": {
-        "node": ">= 8"
-      }
-    },
-    "node_modules/csstype": {
-      "version": "3.2.3",
-      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
-      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/debug": {
-      "version": "4.4.3",
-      "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
-      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ms": "^2.1.3"
-      },
-      "engines": {
-        "node": ">=6.0"
-      },
-      "peerDependenciesMeta": {
-        "supports-color": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/depd": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
-      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/dequal": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz",
-      "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/electron-to-chromium": {
-      "version": "1.5.411",
-      "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz",
-      "integrity": "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/emoji-regex": {
-      "version": "8.0.0",
-      "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
-      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/esbuild": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz",
-      "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
-      "hasInstallScript": true,
-      "license": "MIT",
-      "bin": {
-        "esbuild": "bin/esbuild"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.28.2",
-        "@esbuild/android-arm": "0.28.2",
-        "@esbuild/android-arm64": "0.28.2",
-        "@esbuild/android-x64": "0.28.2",
-        "@esbuild/darwin-arm64": "0.28.2",
-        "@esbuild/darwin-x64": "0.28.2",
-        "@esbuild/freebsd-arm64": "0.28.2",
-        "@esbuild/freebsd-x64": "0.28.2",
-        "@esbuild/linux-arm": "0.28.2",
-        "@esbuild/linux-arm64": "0.28.2",
-        "@esbuild/linux-ia32": "0.28.2",
-        "@esbuild/linux-loong64": "0.28.2",
-        "@esbuild/linux-mips64el": "0.28.2",
-        "@esbuild/linux-ppc64": "0.28.2",
-        "@esbuild/linux-riscv64": "0.28.2",
-        "@esbuild/linux-s390x": "0.28.2",
-        "@esbuild/linux-x64": "0.28.2",
-        "@esbuild/netbsd-arm64": "0.28.2",
-        "@esbuild/netbsd-x64": "0.28.2",
-        "@esbuild/openbsd-arm64": "0.28.2",
-        "@esbuild/openbsd-x64": "0.28.2",
-        "@esbuild/openharmony-arm64": "0.28.2",
-        "@esbuild/sunos-x64": "0.28.2",
-        "@esbuild/win32-arm64": "0.28.2",
-        "@esbuild/win32-ia32": "0.28.2",
-        "@esbuild/win32-x64": "0.28.2"
-      }
-    },
-    "node_modules/escalade": {
-      "version": "3.2.0",
-      "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
-      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/escape-html": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
-      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
-      "license": "MIT"
-    },
-    "node_modules/fast-decode-uri-component": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmmirror.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
-      "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
-      "license": "MIT"
-    },
-    "node_modules/fast-deep-equal": {
-      "version": "3.1.3",
-      "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
-      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
-      "license": "MIT"
-    },
-    "node_modules/fast-json-stringify": {
-      "version": "7.0.1",
-      "resolved": "https://registry.npmmirror.com/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz",
-      "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "@fastify/merge-json-schemas": "^0.2.0",
-        "ajv": "^8.12.0",
-        "ajv-formats": "^3.0.1",
-        "fast-uri": "^4.0.0",
-        "json-schema-ref-resolver": "^3.0.0",
-        "rfdc": "^1.2.0"
-      }
-    },
-    "node_modules/fast-querystring": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmmirror.com/fast-querystring/-/fast-querystring-1.1.2.tgz",
-      "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
-      "license": "MIT",
-      "dependencies": {
-        "fast-decode-uri-component": "^1.0.1"
-      }
-    },
-    "node_modules/fast-uri": {
-      "version": "4.1.2",
-      "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-4.1.2.tgz",
-      "integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/fastify": {
-      "version": "5.12.1",
-      "resolved": "https://registry.npmmirror.com/fastify/-/fastify-5.12.1.tgz",
-      "integrity": "sha512-FWi+tQvwxR/PeRX7Z2mhfEF5ozJ3jn9asiiclzKXNSzJRHAYcU924aIOKAdHFJ+YIKieh3cqr1IwCOvTr41B3Q==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "@fastify/ajv-compiler": "^4.0.5",
-        "@fastify/error": "^4.0.0",
-        "@fastify/fast-json-stringify-compiler": "^5.0.0",
-        "@fastify/proxy-addr": "^5.0.0",
-        "abstract-logging": "^2.0.1",
-        "avvio": "^9.0.0",
-        "fast-json-stringify": "^7.0.0",
-        "find-my-way": "^9.6.0",
-        "light-my-request": "^6.0.0",
-        "pino": "^9.14.0 || ^10.1.0",
-        "process-warning": "^5.1.0",
-        "rfdc": "^1.3.1",
-        "secure-json-parse": "^4.0.0",
-        "semver": "^7.6.0",
-        "toad-cache": "^3.7.0"
-      }
-    },
-    "node_modules/fastify-plugin": {
-      "version": "5.1.0",
-      "resolved": "https://registry.npmmirror.com/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
-      "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT"
-    },
-    "node_modules/fastify/node_modules/semver": {
-      "version": "7.8.5",
-      "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
-      "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
-      "license": "ISC",
-      "bin": {
-        "semver": "bin/semver.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/fastq": {
-      "version": "1.20.1",
-      "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz",
-      "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
-      "license": "ISC",
-      "dependencies": {
-        "reusify": "^1.0.4"
-      }
-    },
-    "node_modules/fdir": {
-      "version": "6.5.0",
-      "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
-      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=12.0.0"
-      },
-      "peerDependencies": {
-        "picomatch": "^3 || ^4"
-      },
-      "peerDependenciesMeta": {
-        "picomatch": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/find-my-way": {
-      "version": "9.8.0",
-      "resolved": "https://registry.npmmirror.com/find-my-way/-/find-my-way-9.8.0.tgz",
-      "integrity": "sha512-JtyUgATO7qxRp2zKhrmWof74Mqxc1ikbwpwMY97p8ipuTj2QtreA4gK2JNAF6SOqqHnYYkwMUvsgQVi2AJxIyw==",
-      "license": "MIT",
-      "dependencies": {
-        "fast-deep-equal": "^3.1.3",
-        "fast-querystring": "^1.0.0",
-        "safe-regex2": "^5.0.0"
-      },
-      "engines": {
-        "node": ">=20"
-      }
-    },
-    "node_modules/foreground-child": {
-      "version": "3.3.1",
-      "resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz",
-      "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
-      "license": "ISC",
-      "dependencies": {
-        "cross-spawn": "^7.0.6",
-        "signal-exit": "^4.0.1"
-      },
-      "engines": {
-        "node": ">=14"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/fsevents": {
-      "version": "2.3.3",
-      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
-      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
-      "hasInstallScript": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
-      }
-    },
-    "node_modules/gensync": {
-      "version": "1.0.0-beta.2",
-      "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
-      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/get-caller-file": {
-      "version": "2.0.5",
-      "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
-      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
-      "dev": true,
-      "license": "ISC",
-      "engines": {
-        "node": "6.* || 8.* || >= 10.*"
-      }
-    },
-    "node_modules/glob": {
-      "version": "11.1.0",
-      "resolved": "https://registry.npmmirror.com/glob/-/glob-11.1.0.tgz",
-      "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
-      "license": "BlueOak-1.0.0",
-      "dependencies": {
-        "foreground-child": "^3.3.1",
-        "jackspeak": "^4.1.1",
-        "minimatch": "^10.1.1",
-        "minipass": "^7.1.2",
-        "package-json-from-dist": "^1.0.0",
-        "path-scurry": "^2.0.0"
-      },
-      "bin": {
-        "glob": "dist/esm/bin.mjs"
-      },
-      "engines": {
-        "node": "20 || >=22"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/has-flag": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
-      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/http-errors": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
-      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
-      "license": "MIT",
-      "dependencies": {
-        "depd": "~2.0.0",
-        "inherits": "~2.0.4",
-        "setprototypeof": "~1.2.0",
-        "statuses": "~2.0.2",
-        "toidentifier": "~1.0.1"
-      },
-      "engines": {
-        "node": ">= 0.8"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/express"
-      }
-    },
-    "node_modules/inherits": {
-      "version": "2.0.4",
-      "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
-      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "license": "ISC"
-    },
-    "node_modules/ipaddr.js": {
-      "version": "2.5.0",
-      "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-2.5.0.tgz",
-      "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 10"
-      }
-    },
-    "node_modules/is-fullwidth-code-point": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
-      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/isexe": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
-      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
-      "license": "ISC"
-    },
-    "node_modules/jackspeak": {
-      "version": "4.2.3",
-      "resolved": "https://registry.npmmirror.com/jackspeak/-/jackspeak-4.2.3.tgz",
-      "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
-      "license": "BlueOak-1.0.0",
-      "dependencies": {
-        "@isaacs/cliui": "^9.0.0"
-      },
-      "engines": {
-        "node": "20 || >=22"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/js-tokens": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
-      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/jsesc": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz",
-      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
-      "dev": true,
-      "license": "MIT",
-      "bin": {
-        "jsesc": "bin/jsesc"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/json-schema-ref-resolver": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmmirror.com/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
-      "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "dequal": "^2.0.3"
-      }
-    },
-    "node_modules/json-schema-traverse": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
-      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
-      "license": "MIT"
-    },
-    "node_modules/json5": {
-      "version": "2.2.3",
-      "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz",
-      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
-      "dev": true,
-      "license": "MIT",
-      "bin": {
-        "json5": "lib/cli.js"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/light-my-request": {
-      "version": "6.6.0",
-      "resolved": "https://registry.npmmirror.com/light-my-request/-/light-my-request-6.6.0.tgz",
-      "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "cookie": "^1.0.1",
-        "process-warning": "^4.0.0",
-        "set-cookie-parser": "^2.6.0"
-      }
-    },
-    "node_modules/light-my-request/node_modules/process-warning": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-4.0.1.tgz",
-      "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT"
-    },
-    "node_modules/lru-cache": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
-      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "yallist": "^3.0.2"
-      }
-    },
-    "node_modules/lucide-react": {
-      "version": "0.468.0",
-      "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.468.0.tgz",
-      "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
-      "license": "ISC",
-      "peerDependencies": {
-        "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
-      }
-    },
-    "node_modules/mime": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmmirror.com/mime/-/mime-3.0.0.tgz",
-      "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
-      "license": "MIT",
-      "bin": {
-        "mime": "cli.js"
-      },
-      "engines": {
-        "node": ">=10.0.0"
-      }
-    },
-    "node_modules/minimatch": {
-      "version": "10.2.6",
-      "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.6.tgz",
-      "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
-      "license": "BlueOak-1.0.0",
-      "dependencies": {
-        "brace-expansion": "^5.0.8"
-      },
-      "engines": {
-        "node": "18 || 20 || >=22"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/minipass": {
-      "version": "7.1.3",
-      "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz",
-      "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
-      "license": "BlueOak-1.0.0",
-      "engines": {
-        "node": ">=16 || 14 >=14.17"
-      }
-    },
-    "node_modules/mnemonist": {
-      "version": "0.40.0",
-      "resolved": "https://registry.npmmirror.com/mnemonist/-/mnemonist-0.40.0.tgz",
-      "integrity": "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==",
-      "license": "MIT",
-      "dependencies": {
-        "obliterator": "^2.0.4"
-      }
-    },
-    "node_modules/ms": {
-      "version": "2.1.3",
-      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
-      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/nanoid": {
-      "version": "3.3.18",
-      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz",
-      "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
-        }
-      ],
-      "license": "MIT",
-      "bin": {
-        "nanoid": "bin/nanoid.cjs"
-      },
-      "engines": {
-        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
-      }
-    },
-    "node_modules/node-releases": {
-      "version": "2.0.53",
-      "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.53.tgz",
-      "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/obliterator": {
-      "version": "2.0.5",
-      "resolved": "https://registry.npmmirror.com/obliterator/-/obliterator-2.0.5.tgz",
-      "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
-      "license": "MIT"
-    },
-    "node_modules/on-exit-leak-free": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
-      "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=14.0.0"
-      }
-    },
-    "node_modules/package-json-from-dist": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
-      "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
-      "license": "BlueOak-1.0.0"
-    },
-    "node_modules/path-key": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
-      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/path-scurry": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.2.tgz",
-      "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
-      "license": "BlueOak-1.0.0",
-      "dependencies": {
-        "lru-cache": "^11.0.0",
-        "minipass": "^7.1.2"
-      },
-      "engines": {
-        "node": "18 || 20 || >=22"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/path-scurry/node_modules/lru-cache": {
-      "version": "11.5.2",
-      "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.2.tgz",
-      "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
-      "license": "BlueOak-1.0.0",
-      "engines": {
-        "node": "20 || >=22"
-      }
-    },
-    "node_modules/pg": {
-      "version": "8.23.0",
-      "resolved": "https://registry.npmmirror.com/pg/-/pg-8.23.0.tgz",
-      "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
-      "license": "MIT",
-      "dependencies": {
-        "pg-connection-string": "^2.14.0",
-        "pg-pool": "^3.14.0",
-        "pg-protocol": "^1.16.0",
-        "pg-types": "2.2.0",
-        "pgpass": "1.0.5"
-      },
-      "engines": {
-        "node": ">= 16.0.0"
-      },
-      "optionalDependencies": {
-        "pg-cloudflare": "^1.4.0"
-      },
-      "peerDependencies": {
-        "pg-native": ">=3.0.1"
-      },
-      "peerDependenciesMeta": {
-        "pg-native": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/pg-cloudflare": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmmirror.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
-      "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
-      "license": "MIT",
-      "optional": true
-    },
-    "node_modules/pg-connection-string": {
-      "version": "2.14.0",
-      "resolved": "https://registry.npmmirror.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
-      "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
-      "license": "MIT"
-    },
-    "node_modules/pg-int8": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmmirror.com/pg-int8/-/pg-int8-1.0.1.tgz",
-      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=4.0.0"
-      }
-    },
-    "node_modules/pg-pool": {
-      "version": "3.14.0",
-      "resolved": "https://registry.npmmirror.com/pg-pool/-/pg-pool-3.14.0.tgz",
-      "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
-      "license": "MIT",
-      "peerDependencies": {
-        "pg": ">=8.0"
-      }
-    },
-    "node_modules/pg-protocol": {
-      "version": "1.16.0",
-      "resolved": "https://registry.npmmirror.com/pg-protocol/-/pg-protocol-1.16.0.tgz",
-      "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
-      "license": "MIT"
-    },
-    "node_modules/pg-types": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmmirror.com/pg-types/-/pg-types-2.2.0.tgz",
-      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
-      "license": "MIT",
-      "dependencies": {
-        "pg-int8": "1.0.1",
-        "postgres-array": "~2.0.0",
-        "postgres-bytea": "~1.0.0",
-        "postgres-date": "~1.0.4",
-        "postgres-interval": "^1.1.0"
-      },
-      "engines": {
-        "node": ">=4"
-      }
-    },
-    "node_modules/pgpass": {
-      "version": "1.0.5",
-      "resolved": "https://registry.npmmirror.com/pgpass/-/pgpass-1.0.5.tgz",
-      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
-      "license": "MIT",
-      "dependencies": {
-        "split2": "^4.1.0"
-      }
-    },
-    "node_modules/picocolors": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
-      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/picomatch": {
-      "version": "4.0.5",
-      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz",
-      "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=12"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/jonschlinkert"
-      }
-    },
-    "node_modules/pino": {
-      "version": "10.3.1",
-      "resolved": "https://registry.npmmirror.com/pino/-/pino-10.3.1.tgz",
-      "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
-      "license": "MIT",
-      "dependencies": {
-        "@pinojs/redact": "^0.4.0",
-        "atomic-sleep": "^1.0.0",
-        "on-exit-leak-free": "^2.1.0",
-        "pino-abstract-transport": "^3.0.0",
-        "pino-std-serializers": "^7.0.0",
-        "process-warning": "^5.0.0",
-        "quick-format-unescaped": "^4.0.3",
-        "real-require": "^0.2.0",
-        "safe-stable-stringify": "^2.3.1",
-        "sonic-boom": "^4.0.1",
-        "thread-stream": "^4.0.0"
-      },
-      "bin": {
-        "pino": "bin.js"
-      }
-    },
-    "node_modules/pino-abstract-transport": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
-      "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
-      "license": "MIT",
-      "dependencies": {
-        "split2": "^4.0.0"
-      }
-    },
-    "node_modules/pino-std-serializers": {
-      "version": "7.1.0",
-      "resolved": "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
-      "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
-      "license": "MIT"
-    },
-    "node_modules/postcss": {
-      "version": "8.5.26",
-      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz",
-      "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/postcss/"
-        },
-        {
-          "type": "tidelift",
-          "url": "https://tidelift.com/funding/github/npm/postcss"
-        },
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "nanoid": "^3.3.17",
-        "picocolors": "^1.1.1",
-        "source-map-js": "^1.2.1"
-      },
-      "engines": {
-        "node": "^10 || ^12 || >=14"
-      }
-    },
-    "node_modules/postgres-array": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz",
-      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=4"
-      }
-    },
-    "node_modules/postgres-bytea": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmmirror.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
-      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/postgres-date": {
-      "version": "1.0.7",
-      "resolved": "https://registry.npmmirror.com/postgres-date/-/postgres-date-1.0.7.tgz",
-      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/postgres-interval": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmmirror.com/postgres-interval/-/postgres-interval-1.2.0.tgz",
-      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
-      "license": "MIT",
-      "dependencies": {
-        "xtend": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/process-warning": {
-      "version": "5.1.0",
-      "resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-5.1.0.tgz",
-      "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT"
-    },
-    "node_modules/quick-format-unescaped": {
-      "version": "4.0.4",
-      "resolved": "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
-      "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
-      "license": "MIT"
-    },
-    "node_modules/react": {
-      "version": "19.2.8",
-      "resolved": "https://registry.npmmirror.com/react/-/react-19.2.8.tgz",
-      "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/react-dom": {
-      "version": "19.2.8",
-      "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.8.tgz",
-      "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
-      "license": "MIT",
-      "dependencies": {
-        "scheduler": "^0.27.0"
-      },
-      "peerDependencies": {
-        "react": "^19.2.8"
-      }
-    },
-    "node_modules/react-refresh": {
-      "version": "0.17.0",
-      "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz",
-      "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/react-router": {
-      "version": "7.18.2",
-      "resolved": "https://registry.npmmirror.com/react-router/-/react-router-7.18.2.tgz",
-      "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
-      "license": "MIT",
-      "dependencies": {
-        "cookie": "^1.0.1",
-        "set-cookie-parser": "^2.6.0"
-      },
-      "engines": {
-        "node": ">=20.0.0"
-      },
-      "peerDependencies": {
-        "react": ">=18",
-        "react-dom": ">=18"
-      },
-      "peerDependenciesMeta": {
-        "react-dom": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/react-router-dom": {
-      "version": "7.18.2",
-      "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.18.2.tgz",
-      "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
-      "license": "MIT",
-      "dependencies": {
-        "react-router": "7.18.2"
-      },
-      "engines": {
-        "node": ">=20.0.0"
-      },
-      "peerDependencies": {
-        "react": ">=18",
-        "react-dom": ">=18"
-      }
-    },
-    "node_modules/real-require": {
-      "version": "0.2.0",
-      "resolved": "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz",
-      "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 12.13.0"
-      }
-    },
-    "node_modules/require-directory": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
-      "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/require-from-string": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
-      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/ret": {
-      "version": "0.5.0",
-      "resolved": "https://registry.npmmirror.com/ret/-/ret-0.5.0.tgz",
-      "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/reusify": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz",
-      "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
-      "license": "MIT",
-      "engines": {
-        "iojs": ">=1.0.0",
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/rfdc": {
-      "version": "1.4.1",
-      "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
-      "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
-      "license": "MIT"
-    },
-    "node_modules/rollup": {
-      "version": "4.62.4",
-      "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.4.tgz",
-      "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/estree": "1.0.9"
-      },
-      "bin": {
-        "rollup": "dist/bin/rollup"
-      },
-      "engines": {
-        "node": ">=18.0.0",
-        "npm": ">=8.0.0"
-      },
-      "optionalDependencies": {
-        "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
-        "@rollup/rollup-android-arm-eabi": "4.62.4",
-        "@rollup/rollup-android-arm64": "4.62.4",
-        "@rollup/rollup-darwin-arm64": "4.62.4",
-        "@rollup/rollup-darwin-x64": "4.62.4",
-        "@rollup/rollup-freebsd-arm64": "4.62.4",
-        "@rollup/rollup-freebsd-x64": "4.62.4",
-        "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
-        "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
-        "@rollup/rollup-linux-arm64-gnu": "4.62.4",
-        "@rollup/rollup-linux-arm64-musl": "4.62.4",
-        "@rollup/rollup-linux-loong64-gnu": "4.62.4",
-        "@rollup/rollup-linux-loong64-musl": "4.62.4",
-        "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
-        "@rollup/rollup-linux-ppc64-musl": "4.62.4",
-        "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
-        "@rollup/rollup-linux-riscv64-musl": "4.62.4",
-        "@rollup/rollup-linux-s390x-gnu": "4.62.4",
-        "@rollup/rollup-linux-x64-gnu": "4.62.4",
-        "@rollup/rollup-linux-x64-musl": "4.62.4",
-        "@rollup/rollup-openbsd-x64": "4.62.4",
-        "@rollup/rollup-openharmony-arm64": "4.62.4",
-        "@rollup/rollup-win32-arm64-msvc": "4.62.4",
-        "@rollup/rollup-win32-ia32-msvc": "4.62.4",
-        "@rollup/rollup-win32-x64-gnu": "4.62.4",
-        "@rollup/rollup-win32-x64-msvc": "4.62.4",
-        "fsevents": "~2.3.2"
-      }
-    },
-    "node_modules/rxjs": {
-      "version": "7.8.2",
-      "resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz",
-      "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "dependencies": {
-        "tslib": "^2.1.0"
-      }
-    },
-    "node_modules/safe-buffer": {
-      "version": "5.2.1",
-      "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
-      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/feross"
-        },
-        {
-          "type": "patreon",
-          "url": "https://www.patreon.com/feross"
-        },
-        {
-          "type": "consulting",
-          "url": "https://feross.org/support"
-        }
-      ],
-      "license": "MIT"
-    },
-    "node_modules/safe-regex2": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmmirror.com/safe-regex2/-/safe-regex2-5.1.1.tgz",
-      "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "ret": "~0.5.0"
-      },
-      "bin": {
-        "safe-regex2": "bin/safe-regex2.js"
-      }
-    },
-    "node_modules/safe-stable-stringify": {
-      "version": "2.5.0",
-      "resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
-      "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/scheduler": {
-      "version": "0.27.0",
-      "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
-      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
-      "license": "MIT"
-    },
-    "node_modules/secure-json-parse": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
-      "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/semver": {
-      "version": "6.3.1",
-      "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
-      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
-      "dev": true,
-      "license": "ISC",
-      "bin": {
-        "semver": "bin/semver.js"
-      }
-    },
-    "node_modules/set-cookie-parser": {
-      "version": "2.7.2",
-      "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
-      "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
-      "license": "MIT"
-    },
-    "node_modules/setprototypeof": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
-      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
-      "license": "ISC"
-    },
-    "node_modules/shebang-command": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
-      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
-      "license": "MIT",
-      "dependencies": {
-        "shebang-regex": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/shebang-regex": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
-      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/shell-quote": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.9.0.tgz",
-      "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/signal-exit": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz",
-      "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=14"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/sonic-boom": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz",
-      "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
-      "license": "MIT",
-      "dependencies": {
-        "atomic-sleep": "^1.0.0"
-      }
-    },
-    "node_modules/source-map-js": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
-      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/split2": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz",
-      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
-      "license": "ISC",
-      "engines": {
-        "node": ">= 10.x"
-      }
-    },
-    "node_modules/statuses": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
-      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/string-width": {
-      "version": "4.2.3",
-      "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
-      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "emoji-regex": "^8.0.0",
-        "is-fullwidth-code-point": "^3.0.0",
-        "strip-ansi": "^6.0.1"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/strip-ansi": {
-      "version": "6.0.1",
-      "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
-      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ansi-regex": "^5.0.1"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/supports-color": {
-      "version": "8.1.1",
-      "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz",
-      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "has-flag": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/supports-color?sponsor=1"
-      }
-    },
-    "node_modules/thread-stream": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.2.0.tgz",
-      "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
-      "license": "MIT",
-      "dependencies": {
-        "real-require": "^1.0.0"
-      },
-      "engines": {
-        "node": ">=20"
-      }
-    },
-    "node_modules/thread-stream/node_modules/real-require": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz",
-      "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
-      "license": "MIT"
-    },
-    "node_modules/tinyglobby": {
-      "version": "0.2.17",
-      "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
-      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "fdir": "^6.5.0",
-        "picomatch": "^4.0.4"
-      },
-      "engines": {
-        "node": ">=12.0.0"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/SuperchupuDev"
-      }
-    },
-    "node_modules/toad-cache": {
-      "version": "3.7.4",
-      "resolved": "https://registry.npmmirror.com/toad-cache/-/toad-cache-3.7.4.tgz",
-      "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=20"
-      }
-    },
-    "node_modules/toidentifier": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
-      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.6"
-      }
-    },
-    "node_modules/tree-kill": {
-      "version": "1.2.2",
-      "resolved": "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz",
-      "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
-      "dev": true,
-      "license": "MIT",
-      "bin": {
-        "tree-kill": "cli.js"
-      }
-    },
-    "node_modules/tslib": {
-      "version": "2.8.1",
-      "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
-      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
-      "dev": true,
-      "license": "0BSD"
-    },
-    "node_modules/tsx": {
-      "version": "4.23.12",
-      "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.23.12.tgz",
-      "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
-      "license": "MIT",
-      "dependencies": {
-        "esbuild": "~0.28.0"
-      },
-      "bin": {
-        "tsx": "dist/cli.mjs"
-      },
-      "engines": {
-        "node": ">=18.0.0"
-      },
-      "optionalDependencies": {
-        "fsevents": "~2.3.3"
-      }
-    },
-    "node_modules/typescript": {
-      "version": "5.7.3",
-      "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.7.3.tgz",
-      "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "bin": {
-        "tsc": "bin/tsc",
-        "tsserver": "bin/tsserver"
-      },
-      "engines": {
-        "node": ">=14.17"
-      }
-    },
-    "node_modules/undici-types": {
-      "version": "6.21.0",
-      "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
-      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/update-browserslist-db": {
-      "version": "1.3.1",
-      "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
-      "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/browserslist"
-        },
-        {
-          "type": "tidelift",
-          "url": "https://tidelift.com/funding/github/npm/browserslist"
-        },
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "escalade": "^3.2.0",
-        "picocolors": "^1.1.1"
-      },
-      "bin": {
-        "update-browserslist-db": "cli.js"
-      },
-      "peerDependencies": {
-        "browserslist": ">= 4.21.0"
-      }
-    },
-    "node_modules/vite": {
-      "version": "6.4.3",
-      "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz",
-      "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "esbuild": "^0.25.0",
-        "fdir": "^6.4.4",
-        "picomatch": "^4.0.2",
-        "postcss": "^8.5.3",
-        "rollup": "^4.34.9",
-        "tinyglobby": "^0.2.13"
-      },
-      "bin": {
-        "vite": "bin/vite.js"
-      },
-      "engines": {
-        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
-      },
-      "funding": {
-        "url": "https://github.com/vitejs/vite?sponsor=1"
-      },
-      "optionalDependencies": {
-        "fsevents": "~2.3.3"
-      },
-      "peerDependencies": {
-        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
-        "jiti": ">=1.21.0",
-        "less": "*",
-        "lightningcss": "^1.21.0",
-        "sass": "*",
-        "sass-embedded": "*",
-        "stylus": "*",
-        "sugarss": "*",
-        "terser": "^5.16.0",
-        "tsx": "^4.8.1",
-        "yaml": "^2.4.2"
-      },
-      "peerDependenciesMeta": {
-        "@types/node": {
-          "optional": true
-        },
-        "jiti": {
-          "optional": true
-        },
-        "less": {
-          "optional": true
-        },
-        "lightningcss": {
-          "optional": true
-        },
-        "sass": {
-          "optional": true
-        },
-        "sass-embedded": {
-          "optional": true
-        },
-        "stylus": {
-          "optional": true
-        },
-        "sugarss": {
-          "optional": true
-        },
-        "terser": {
-          "optional": true
-        },
-        "tsx": {
-          "optional": true
-        },
-        "yaml": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/aix-ppc64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
-      "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "aix"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/android-arm": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
-      "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/android-arm64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
-      "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/android-x64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
-      "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/darwin-arm64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
-      "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/darwin-x64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
-      "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
-      "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/freebsd-x64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
-      "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-arm": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
-      "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-arm64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
-      "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-ia32": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
-      "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-loong64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
-      "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
-      "cpu": [
-        "loong64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-mips64el": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
-      "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
-      "cpu": [
-        "mips64el"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-ppc64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
-      "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-riscv64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
-      "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
-      "cpu": [
-        "riscv64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-s390x": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
-      "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
-      "cpu": [
-        "s390x"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-x64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
-      "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
-      "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/netbsd-x64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
-      "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
-      "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/openbsd-x64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
-      "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/openharmony-arm64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
-      "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openharmony"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/sunos-x64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
-      "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "sunos"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/win32-arm64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
-      "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/win32-ia32": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
-      "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/win32-x64": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
-      "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/vite/node_modules/esbuild": {
-      "version": "0.25.12",
-      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
-      "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
-      "dev": true,
-      "hasInstallScript": true,
-      "license": "MIT",
-      "bin": {
-        "esbuild": "bin/esbuild"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.25.12",
-        "@esbuild/android-arm": "0.25.12",
-        "@esbuild/android-arm64": "0.25.12",
-        "@esbuild/android-x64": "0.25.12",
-        "@esbuild/darwin-arm64": "0.25.12",
-        "@esbuild/darwin-x64": "0.25.12",
-        "@esbuild/freebsd-arm64": "0.25.12",
-        "@esbuild/freebsd-x64": "0.25.12",
-        "@esbuild/linux-arm": "0.25.12",
-        "@esbuild/linux-arm64": "0.25.12",
-        "@esbuild/linux-ia32": "0.25.12",
-        "@esbuild/linux-loong64": "0.25.12",
-        "@esbuild/linux-mips64el": "0.25.12",
-        "@esbuild/linux-ppc64": "0.25.12",
-        "@esbuild/linux-riscv64": "0.25.12",
-        "@esbuild/linux-s390x": "0.25.12",
-        "@esbuild/linux-x64": "0.25.12",
-        "@esbuild/netbsd-arm64": "0.25.12",
-        "@esbuild/netbsd-x64": "0.25.12",
-        "@esbuild/openbsd-arm64": "0.25.12",
-        "@esbuild/openbsd-x64": "0.25.12",
-        "@esbuild/openharmony-arm64": "0.25.12",
-        "@esbuild/sunos-x64": "0.25.12",
-        "@esbuild/win32-arm64": "0.25.12",
-        "@esbuild/win32-ia32": "0.25.12",
-        "@esbuild/win32-x64": "0.25.12"
-      }
-    },
-    "node_modules/which": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
-      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
-      "license": "ISC",
-      "dependencies": {
-        "isexe": "^2.0.0"
-      },
-      "bin": {
-        "node-which": "bin/node-which"
-      },
-      "engines": {
-        "node": ">= 8"
-      }
-    },
-    "node_modules/wrap-ansi": {
-      "version": "7.0.0",
-      "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
-      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ansi-styles": "^4.0.0",
-        "string-width": "^4.1.0",
-        "strip-ansi": "^6.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
-      }
-    },
-    "node_modules/xtend": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz",
-      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.4"
-      }
-    },
-    "node_modules/y18n": {
-      "version": "5.0.8",
-      "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
-      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
-      "dev": true,
-      "license": "ISC",
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/yallist": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
-      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/yargs": {
-      "version": "17.7.2",
-      "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz",
-      "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "cliui": "^8.0.1",
-        "escalade": "^3.1.1",
-        "get-caller-file": "^2.0.5",
-        "require-directory": "^2.1.1",
-        "string-width": "^4.2.3",
-        "y18n": "^5.0.5",
-        "yargs-parser": "^21.1.1"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/yargs-parser": {
-      "version": "21.1.1",
-      "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz",
-      "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
-      "dev": true,
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/zod": {
-      "version": "3.25.76",
-      "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
-      "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
-      "license": "MIT",
-      "funding": {
-        "url": "https://github.com/sponsors/colinhacks"
-      }
-    }
-  }
-}

+ 8 - 0
package.json

@@ -14,12 +14,19 @@
     "@fastify/cors": "^10.0.2",
     "@fastify/multipart": "^9.0.3",
     "@fastify/static": "^8.1.1",
+    "@types/qrcode": "^1.5.6",
+    "dompurify": "^3.4.15",
+    "fast-xml-parser": "^5.11.1",
     "fastify": "^5.2.1",
+    "jszip": "^3.10.2",
     "lucide-react": "^0.468.0",
+    "pdfjs-dist": "^6.3.289",
     "pg": "^8.13.1",
+    "qrcode": "^1.5.4",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
     "react-router-dom": "^7.1.1",
+    "sanitize-html": "^2.17.7",
     "tsx": "^4.19.2",
     "zod": "^3.24.1"
   },
@@ -28,6 +35,7 @@
     "@types/pg": "^8.11.10",
     "@types/react": "^19.0.2",
     "@types/react-dom": "^19.0.2",
+    "@types/sanitize-html": "^2.16.1",
     "@vitejs/plugin-react": "^4.3.4",
     "concurrently": "^9.1.2",
     "typescript": "~5.7.2",

+ 3052 - 0
pnpm-lock.yaml

@@ -0,0 +1,3052 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+importers:
+
+  .:
+    dependencies:
+      '@fastify/cors':
+        specifier: ^10.0.2
+        version: 10.1.0
+      '@fastify/multipart':
+        specifier: ^9.0.3
+        version: 9.4.0
+      '@fastify/static':
+        specifier: ^8.1.1
+        version: 8.3.0
+      '@types/qrcode':
+        specifier: ^1.5.6
+        version: 1.5.6
+      dompurify:
+        specifier: ^3.4.15
+        version: 3.4.15
+      fast-xml-parser:
+        specifier: ^5.11.1
+        version: 5.11.1
+      fastify:
+        specifier: ^5.2.1
+        version: 5.12.4
+      jszip:
+        specifier: ^3.10.2
+        version: 3.10.2
+      lucide-react:
+        specifier: ^0.468.0
+        version: 0.468.0(react@19.3.0)
+      pdfjs-dist:
+        specifier: ^6.3.289
+        version: 6.3.289
+      pg:
+        specifier: ^8.13.1
+        version: 8.23.0
+      qrcode:
+        specifier: ^1.5.4
+        version: 1.5.4
+      react:
+        specifier: ^19.0.0
+        version: 19.3.0
+      react-dom:
+        specifier: ^19.0.0
+        version: 19.3.0(react@19.3.0)
+      react-router-dom:
+        specifier: ^7.1.1
+        version: 7.18.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+      sanitize-html:
+        specifier: ^2.17.7
+        version: 2.17.7
+      tsx:
+        specifier: ^4.19.2
+        version: 4.23.13
+      zod:
+        specifier: ^3.24.1
+        version: 3.25.76
+    devDependencies:
+      '@types/node':
+        specifier: ^22.10.2
+        version: 22.20.2
+      '@types/pg':
+        specifier: ^8.11.10
+        version: 8.23.1
+      '@types/react':
+        specifier: ^19.0.2
+        version: 19.3.0
+      '@types/react-dom':
+        specifier: ^19.0.2
+        version: 19.3.0(@types/react@19.3.0)
+      '@types/sanitize-html':
+        specifier: ^2.16.1
+        version: 2.16.1
+      '@vitejs/plugin-react':
+        specifier: ^4.3.4
+        version: 4.7.0(supports-color@8.1.1)(vite@6.4.3(@types/node@22.20.2)(tsx@4.23.13))
+      concurrently:
+        specifier: ^9.1.2
+        version: 9.2.4
+      typescript:
+        specifier: ~5.7.2
+        version: 5.7.3
+      vite:
+        specifier: ^6.0.5
+        version: 6.4.3(@types/node@22.20.2)(tsx@4.23.13)
+
+packages:
+
+  '@babel/code-frame@7.29.7':
+    resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/compat-data@7.29.7':
+    resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/core@7.29.7':
+    resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/generator@7.29.8':
+    resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-compilation-targets@7.29.7':
+    resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-globals@7.29.7':
+    resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-module-imports@7.29.7':
+    resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-module-transforms@7.29.7':
+    resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
+    engines: {node: '>=6.9.0'}
+    peerDependencies:
+      '@babel/core': ^7.0.0
+
+  '@babel/helper-plugin-utils@7.29.7':
+    resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-string-parser@7.29.7':
+    resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-validator-identifier@7.29.7':
+    resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-validator-option@7.29.7':
+    resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helpers@7.29.7':
+    resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/parser@7.29.8':
+    resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
+    engines: {node: '>=6.0.0'}
+    hasBin: true
+
+  '@babel/plugin-transform-react-jsx-self@7.29.7':
+    resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==}
+    engines: {node: '>=6.9.0'}
+    peerDependencies:
+      '@babel/core': ^7.0.0-0
+
+  '@babel/plugin-transform-react-jsx-source@7.29.7':
+    resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==}
+    engines: {node: '>=6.9.0'}
+    peerDependencies:
+      '@babel/core': ^7.0.0-0
+
+  '@babel/template@7.29.7':
+    resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/traverse@7.29.8':
+    resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/types@7.29.8':
+    resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
+    engines: {node: '>=6.9.0'}
+
+  '@esbuild/aix-ppc64@0.25.12':
+    resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [aix]
+
+  '@esbuild/aix-ppc64@0.28.2':
+    resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [aix]
+
+  '@esbuild/android-arm64@0.25.12':
+    resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [android]
+
+  '@esbuild/android-arm64@0.28.2':
+    resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [android]
+
+  '@esbuild/android-arm@0.25.12':
+    resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [android]
+
+  '@esbuild/android-arm@0.28.2':
+    resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [android]
+
+  '@esbuild/android-x64@0.25.12':
+    resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [android]
+
+  '@esbuild/android-x64@0.28.2':
+    resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [android]
+
+  '@esbuild/darwin-arm64@0.25.12':
+    resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@esbuild/darwin-arm64@0.28.2':
+    resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@esbuild/darwin-x64@0.25.12':
+    resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@esbuild/darwin-x64@0.28.2':
+    resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@esbuild/freebsd-arm64@0.25.12':
+    resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-arm64@0.28.2':
+    resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-x64@0.25.12':
+    resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-x64@0.28.2':
+    resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@esbuild/linux-arm64@0.25.12':
+    resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@esbuild/linux-arm64@0.28.2':
+    resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@esbuild/linux-arm@0.25.12':
+    resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [linux]
+
+  '@esbuild/linux-arm@0.28.2':
+    resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [linux]
+
+  '@esbuild/linux-ia32@0.25.12':
+    resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [linux]
+
+  '@esbuild/linux-ia32@0.28.2':
+    resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [linux]
+
+  '@esbuild/linux-loong64@0.25.12':
+    resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+    engines: {node: '>=18'}
+    cpu: [loong64]
+    os: [linux]
+
+  '@esbuild/linux-loong64@0.28.2':
+    resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
+    engines: {node: '>=18'}
+    cpu: [loong64]
+    os: [linux]
+
+  '@esbuild/linux-mips64el@0.25.12':
+    resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+    engines: {node: '>=18'}
+    cpu: [mips64el]
+    os: [linux]
+
+  '@esbuild/linux-mips64el@0.28.2':
+    resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
+    engines: {node: '>=18'}
+    cpu: [mips64el]
+    os: [linux]
+
+  '@esbuild/linux-ppc64@0.25.12':
+    resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@esbuild/linux-ppc64@0.28.2':
+    resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@esbuild/linux-riscv64@0.25.12':
+    resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+    engines: {node: '>=18'}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@esbuild/linux-riscv64@0.28.2':
+    resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
+    engines: {node: '>=18'}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@esbuild/linux-s390x@0.25.12':
+    resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+    engines: {node: '>=18'}
+    cpu: [s390x]
+    os: [linux]
+
+  '@esbuild/linux-s390x@0.28.2':
+    resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
+    engines: {node: '>=18'}
+    cpu: [s390x]
+    os: [linux]
+
+  '@esbuild/linux-x64@0.25.12':
+    resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [linux]
+
+  '@esbuild/linux-x64@0.28.2':
+    resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [linux]
+
+  '@esbuild/netbsd-arm64@0.25.12':
+    resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-arm64@0.28.2':
+    resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-x64@0.25.12':
+    resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-x64@0.28.2':
+    resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [netbsd]
+
+  '@esbuild/openbsd-arm64@0.25.12':
+    resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-arm64@0.28.2':
+    resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-x64@0.25.12':
+    resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-x64@0.28.2':
+    resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@esbuild/openharmony-arm64@0.25.12':
+    resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@esbuild/openharmony-arm64@0.28.2':
+    resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@esbuild/sunos-x64@0.25.12':
+    resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [sunos]
+
+  '@esbuild/sunos-x64@0.28.2':
+    resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [sunos]
+
+  '@esbuild/win32-arm64@0.25.12':
+    resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@esbuild/win32-arm64@0.28.2':
+    resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@esbuild/win32-ia32@0.25.12':
+    resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [win32]
+
+  '@esbuild/win32-ia32@0.28.2':
+    resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [win32]
+
+  '@esbuild/win32-x64@0.25.12':
+    resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [win32]
+
+  '@esbuild/win32-x64@0.28.2':
+    resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [win32]
+
+  '@fastify/accept-negotiator@2.1.0':
+    resolution: {integrity: sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==}
+
+  '@fastify/ajv-compiler@4.0.6':
+    resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
+
+  '@fastify/busboy@3.2.2':
+    resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==}
+
+  '@fastify/cors@10.1.0':
+    resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==}
+
+  '@fastify/deepmerge@3.2.1':
+    resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
+
+  '@fastify/error@4.2.0':
+    resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
+
+  '@fastify/fast-json-stringify-compiler@5.1.0':
+    resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==}
+
+  '@fastify/forwarded@3.0.2':
+    resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==}
+
+  '@fastify/merge-json-schemas@0.2.1':
+    resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
+
+  '@fastify/multipart@9.4.0':
+    resolution: {integrity: sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==}
+
+  '@fastify/proxy-addr@5.1.0':
+    resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
+
+  '@fastify/send@4.1.1':
+    resolution: {integrity: sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==}
+
+  '@fastify/static@8.3.0':
+    resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
+
+  '@isaacs/cliui@9.0.0':
+    resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
+    engines: {node: '>=18'}
+
+  '@jridgewell/gen-mapping@0.3.13':
+    resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+  '@jridgewell/remapping@2.3.5':
+    resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+  '@jridgewell/resolve-uri@3.1.2':
+    resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+    engines: {node: '>=6.0.0'}
+
+  '@jridgewell/sourcemap-codec@1.6.0':
+    resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
+
+  '@jridgewell/trace-mapping@0.3.31':
+    resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+  '@lukeed/ms@2.0.2':
+    resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
+    engines: {node: '>=8'}
+
+  '@napi-rs/canvas-android-arm64@1.0.9':
+    resolution: {integrity: sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [android]
+
+  '@napi-rs/canvas-darwin-arm64@1.0.9':
+    resolution: {integrity: sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@napi-rs/canvas-darwin-x64@1.0.9':
+    resolution: {integrity: sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@napi-rs/canvas-linux-arm-gnueabihf@1.0.9':
+    resolution: {integrity: sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==}
+    engines: {node: '>= 10'}
+    cpu: [arm]
+    os: [linux]
+
+  '@napi-rs/canvas-linux-arm64-gnu@1.0.9':
+    resolution: {integrity: sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@napi-rs/canvas-linux-arm64-musl@1.0.9':
+    resolution: {integrity: sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@napi-rs/canvas-linux-riscv64-gnu@1.0.9':
+    resolution: {integrity: sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==}
+    engines: {node: '>= 10'}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [glibc]
+
+  '@napi-rs/canvas-linux-x64-gnu@1.0.9':
+    resolution: {integrity: sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@napi-rs/canvas-linux-x64-musl@1.0.9':
+    resolution: {integrity: sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@napi-rs/canvas-win32-arm64-msvc@1.0.9':
+    resolution: {integrity: sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@napi-rs/canvas-win32-x64-msvc@1.0.9':
+    resolution: {integrity: sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [win32]
+
+  '@napi-rs/canvas@1.0.9':
+    resolution: {integrity: sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==}
+    engines: {node: '>= 10'}
+
+  '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+    resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
+    engines: {node: ^22.20 || ^24.12 || >=25}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@nodable/entities@3.0.0':
+    resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==}
+
+  '@pinojs/redact@0.4.0':
+    resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
+
+  '@rolldown/pluginutils@1.0.0-beta.27':
+    resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
+
+  '@rollup/rollup-android-arm-eabi@4.63.2':
+    resolution: {integrity: sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==}
+    cpu: [arm]
+    os: [android]
+
+  '@rollup/rollup-android-arm64@4.63.2':
+    resolution: {integrity: sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==}
+    cpu: [arm64]
+    os: [android]
+
+  '@rollup/rollup-darwin-arm64@4.63.2':
+    resolution: {integrity: sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@rollup/rollup-darwin-x64@4.63.2':
+    resolution: {integrity: sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@rollup/rollup-freebsd-arm64@4.63.2':
+    resolution: {integrity: sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@rollup/rollup-freebsd-x64@4.63.2':
+    resolution: {integrity: sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.63.2':
+    resolution: {integrity: sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==}
+    cpu: [arm]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm-musleabihf@4.63.2':
+    resolution: {integrity: sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==}
+    cpu: [arm]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-arm64-gnu@4.63.2':
+    resolution: {integrity: sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm64-musl@4.63.2':
+    resolution: {integrity: sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-loong64-gnu@4.63.2':
+    resolution: {integrity: sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==}
+    cpu: [loong64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-loong64-musl@4.63.2':
+    resolution: {integrity: sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==}
+    cpu: [loong64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-ppc64-gnu@4.63.2':
+    resolution: {integrity: sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-ppc64-musl@4.63.2':
+    resolution: {integrity: sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-riscv64-gnu@4.63.2':
+    resolution: {integrity: sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-riscv64-musl@4.63.2':
+    resolution: {integrity: sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-s390x-gnu@4.63.2':
+    resolution: {integrity: sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==}
+    cpu: [s390x]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-gnu@4.63.2':
+    resolution: {integrity: sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-musl@4.63.2':
+    resolution: {integrity: sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-openbsd-x64@4.63.2':
+    resolution: {integrity: sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@rollup/rollup-openharmony-arm64@4.63.2':
+    resolution: {integrity: sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@rollup/rollup-win32-arm64-msvc@4.63.2':
+    resolution: {integrity: sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==}
+    cpu: [arm64]
+    os: [win32]
+
+  '@rollup/rollup-win32-ia32-msvc@4.63.2':
+    resolution: {integrity: sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==}
+    cpu: [ia32]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-gnu@4.63.2':
+    resolution: {integrity: sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==}
+    cpu: [x64]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-msvc@4.63.2':
+    resolution: {integrity: sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==}
+    cpu: [x64]
+    os: [win32]
+
+  '@types/babel__core@7.20.5':
+    resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+  '@types/babel__generator@7.27.0':
+    resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+  '@types/babel__template@7.4.4':
+    resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+  '@types/babel__traverse@7.28.0':
+    resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
+  '@types/estree@1.0.9':
+    resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+  '@types/node@22.20.2':
+    resolution: {integrity: sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==}
+
+  '@types/pg@8.23.1':
+    resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
+
+  '@types/qrcode@1.5.6':
+    resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
+
+  '@types/react-dom@19.3.0':
+    resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
+    peerDependencies:
+      '@types/react': ^19.3.0
+
+  '@types/react@19.3.0':
+    resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
+
+  '@types/sanitize-html@2.16.1':
+    resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==}
+
+  '@types/trusted-types@2.0.7':
+    resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
+  '@vitejs/plugin-react@4.7.0':
+    resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
+    engines: {node: ^14.18.0 || >=16.0.0}
+    peerDependencies:
+      vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
+  abstract-logging@2.0.1:
+    resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
+
+  ajv-formats@3.0.1:
+    resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+    peerDependencies:
+      ajv: ^8.0.0
+    peerDependenciesMeta:
+      ajv:
+        optional: true
+
+  ajv@8.20.0:
+    resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
+
+  ansi-regex@5.0.1:
+    resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+    engines: {node: '>=8'}
+
+  ansi-styles@4.3.0:
+    resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+    engines: {node: '>=8'}
+
+  anynum@1.0.1:
+    resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==}
+
+  atomic-sleep@1.0.0:
+    resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
+    engines: {node: '>=8.0.0'}
+
+  avvio@9.3.0:
+    resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==}
+
+  balanced-match@4.0.4:
+    resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
+    engines: {node: 18 || 20 || >=22}
+
+  baseline-browser-mapping@2.11.22:
+    resolution: {integrity: sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==}
+    engines: {node: '>=6.0.0'}
+    hasBin: true
+
+  brace-expansion@5.0.9:
+    resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
+    engines: {node: 20 || >=22}
+
+  browserslist@4.28.9:
+    resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==}
+    engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+    hasBin: true
+
+  camelcase@5.3.1:
+    resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+    engines: {node: '>=6'}
+
+  caniuse-lite@1.0.30001810:
+    resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
+
+  chalk@4.1.2:
+    resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+    engines: {node: '>=10'}
+
+  cliui@6.0.0:
+    resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+
+  cliui@8.0.1:
+    resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
+    engines: {node: '>=12'}
+
+  color-convert@2.0.1:
+    resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+    engines: {node: '>=7.0.0'}
+
+  color-name@1.1.4:
+    resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+  concurrently@9.2.4:
+    resolution: {integrity: sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  content-disposition@0.5.4:
+    resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
+    engines: {node: '>= 0.6'}
+
+  convert-source-map@2.0.0:
+    resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+  cookie@1.1.1:
+    resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+    engines: {node: '>=18'}
+
+  core-util-is@1.0.3:
+    resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
+
+  cross-spawn@7.0.6:
+    resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+    engines: {node: '>= 8'}
+
+  csstype@3.2.3:
+    resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+  dayjs@1.11.23:
+    resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==}
+
+  debug@4.4.3:
+    resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+    engines: {node: '>=6.0'}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  decamelize@1.2.0:
+    resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+    engines: {node: '>=0.10.0'}
+
+  deepmerge@4.3.1:
+    resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
+    engines: {node: '>=0.10.0'}
+
+  depd@2.0.0:
+    resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+    engines: {node: '>= 0.8'}
+
+  dequal@2.0.3:
+    resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+    engines: {node: '>=6'}
+
+  dijkstrajs@1.0.3:
+    resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+
+  dom-serializer@2.0.0:
+    resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+
+  dom-serializer@3.1.1:
+    resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==}
+    engines: {node: '>=20.19.0'}
+
+  domelementtype@2.3.0:
+    resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+
+  domelementtype@3.0.0:
+    resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==}
+    engines: {node: '>=20.19.0'}
+
+  domhandler@5.0.3:
+    resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+    engines: {node: '>= 4'}
+
+  domhandler@6.0.1:
+    resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==}
+    engines: {node: '>=20.19.0'}
+
+  dompurify@3.4.15:
+    resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==}
+
+  domutils@3.2.2:
+    resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
+
+  domutils@4.0.2:
+    resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==}
+    engines: {node: '>=20.19.0'}
+
+  electron-to-chromium@1.5.427:
+    resolution: {integrity: sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==}
+
+  emoji-regex@8.0.0:
+    resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
+  entities@4.5.0:
+    resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+    engines: {node: '>=0.12'}
+
+  entities@7.0.1:
+    resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+    engines: {node: '>=0.12'}
+
+  entities@8.1.0:
+    resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==}
+    engines: {node: '>=20.19.0'}
+
+  esbuild@0.25.12:
+    resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  esbuild@0.28.2:
+    resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  escalade@3.2.0:
+    resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+    engines: {node: '>=6'}
+
+  escape-html@1.0.3:
+    resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
+  escape-string-regexp@4.0.0:
+    resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+    engines: {node: '>=10'}
+
+  fast-decode-uri-component@1.0.1:
+    resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
+
+  fast-deep-equal@3.1.3:
+    resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+  fast-json-stringify@7.0.1:
+    resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==}
+
+  fast-querystring@1.1.2:
+    resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
+
+  fast-uri@3.1.7:
+    resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==}
+
+  fast-uri@4.1.4:
+    resolution: {integrity: sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==}
+
+  fast-xml-builder@1.3.1:
+    resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==}
+
+  fast-xml-parser@5.11.1:
+    resolution: {integrity: sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==}
+    hasBin: true
+
+  fastify-plugin@5.1.0:
+    resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
+
+  fastify@5.12.4:
+    resolution: {integrity: sha512-zATD676fP6tBMsn+jQ7Q5/cO/Qo+149TXH0dUqUKel+1/hcZxwIbnflqtPa9EjlrvpY8eCZcL3M1UYrMc31Y7g==}
+
+  fastq@1.20.3:
+    resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==}
+
+  fdir@6.5.0:
+    resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+    engines: {node: '>=12.0.0'}
+    peerDependencies:
+      picomatch: ^3 || ^4
+    peerDependenciesMeta:
+      picomatch:
+        optional: true
+
+  find-my-way@9.9.0:
+    resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==}
+    engines: {node: '>=20'}
+
+  find-up@4.1.0:
+    resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+    engines: {node: '>=8'}
+
+  foreground-child@3.3.1:
+    resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
+    engines: {node: '>=14'}
+
+  fsevents@2.3.3:
+    resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
+  gensync@1.0.0-beta.2:
+    resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+    engines: {node: '>=6.9.0'}
+
+  get-caller-file@2.0.5:
+    resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+    engines: {node: 6.* || 8.* || >= 10.*}
+
+  glob@11.1.0:
+    resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
+    engines: {node: 20 || >=22}
+    hasBin: true
+
+  has-flag@4.0.0:
+    resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+    engines: {node: '>=8'}
+
+  htmlparser2@10.1.0:
+    resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
+
+  htmlparser2@12.0.0:
+    resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==}
+    engines: {node: '>=20.19.0'}
+
+  http-errors@2.0.1:
+    resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
+    engines: {node: '>= 0.8'}
+
+  immediate@3.0.6:
+    resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+
+  inherits@2.0.4:
+    resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+  ipaddr.js@2.5.0:
+    resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==}
+    engines: {node: '>= 10'}
+
+  is-fullwidth-code-point@3.0.0:
+    resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+    engines: {node: '>=8'}
+
+  is-plain-object@5.1.0:
+    resolution: {integrity: sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==}
+    engines: {node: '>=0.10.0'}
+
+  is-unsafe@2.0.2:
+    resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==}
+
+  isarray@1.0.0:
+    resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
+
+  isexe@2.0.0:
+    resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+  jackspeak@4.2.3:
+    resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==}
+    engines: {node: 20 || >=22}
+
+  js-tokens@4.0.0:
+    resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+  jsesc@3.1.0:
+    resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+    engines: {node: '>=6'}
+    hasBin: true
+
+  json-schema-ref-resolver@3.0.0:
+    resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
+
+  json-schema-traverse@1.0.0:
+    resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+
+  json5@2.2.3:
+    resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+    engines: {node: '>=6'}
+    hasBin: true
+
+  jszip@3.10.2:
+    resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==}
+
+  launder@1.7.1:
+    resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==}
+
+  lie@3.3.0:
+    resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+
+  light-my-request@6.6.0:
+    resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
+
+  locate-path@5.0.0:
+    resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+    engines: {node: '>=8'}
+
+  lru-cache@11.5.2:
+    resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
+    engines: {node: 20 || >=22}
+
+  lru-cache@5.1.1:
+    resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+  lucide-react@0.468.0:
+    resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==}
+    peerDependencies:
+      react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
+
+  mime@3.0.0:
+    resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
+    engines: {node: '>=10.0.0'}
+    hasBin: true
+
+  minimatch@10.2.6:
+    resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
+    engines: {node: 18 || 20 || >=22}
+
+  minipass@7.1.3:
+    resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
+    engines: {node: '>=16 || 14 >=14.17'}
+
+  mnemonist@0.40.0:
+    resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==}
+
+  ms@2.1.3:
+    resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+  nanoid@3.3.19:
+    resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
+    engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+    hasBin: true
+
+  node-releases@2.0.55:
+    resolution: {integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==}
+    engines: {node: '>=18'}
+
+  obliterator@2.0.5:
+    resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==}
+
+  on-exit-leak-free@2.1.2:
+    resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
+    engines: {node: '>=14.0.0'}
+
+  p-limit@2.3.0:
+    resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
+    engines: {node: '>=6'}
+
+  p-locate@4.1.0:
+    resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
+    engines: {node: '>=8'}
+
+  p-try@2.2.0:
+    resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
+    engines: {node: '>=6'}
+
+  package-json-from-dist@1.0.1:
+    resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
+
+  pako@1.0.11:
+    resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+
+  parse-srcset@1.0.2:
+    resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
+
+  path-exists@4.0.0:
+    resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+    engines: {node: '>=8'}
+
+  path-expression-matcher@1.6.2:
+    resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==}
+    engines: {node: '>=14.0.0'}
+
+  path-key@3.1.1:
+    resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+    engines: {node: '>=8'}
+
+  path-scurry@2.0.2:
+    resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
+    engines: {node: 18 || 20 || >=22}
+
+  pdfjs-dist@6.3.289:
+    resolution: {integrity: sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==}
+    engines: {node: '>=22.13.0 || >=24'}
+
+  pg-cloudflare@1.4.0:
+    resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
+
+  pg-connection-string@2.14.0:
+    resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==}
+
+  pg-int8@1.0.1:
+    resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
+    engines: {node: '>=4.0.0'}
+
+  pg-pool@3.14.0:
+    resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
+    peerDependencies:
+      pg: '>=8.0'
+
+  pg-protocol@1.16.0:
+    resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==}
+
+  pg-types@2.2.0:
+    resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
+    engines: {node: '>=4'}
+
+  pg@8.23.0:
+    resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==}
+    engines: {node: '>= 16.0.0'}
+    peerDependencies:
+      pg-native: '>=3.0.1'
+    peerDependenciesMeta:
+      pg-native:
+        optional: true
+
+  pgpass@1.0.5:
+    resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
+
+  picocolors@1.1.1:
+    resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+  picomatch@4.0.7:
+    resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
+    engines: {node: '>=12'}
+
+  pino-abstract-transport@3.0.0:
+    resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
+
+  pino-std-serializers@7.1.0:
+    resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
+
+  pino@10.3.1:
+    resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
+    hasBin: true
+
+  pngjs@5.0.0:
+    resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+    engines: {node: '>=10.13.0'}
+
+  postcss@8.5.28:
+    resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
+    engines: {node: ^10 || ^12 || >=14}
+
+  postgres-array@2.0.0:
+    resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
+    engines: {node: '>=4'}
+
+  postgres-bytea@1.0.1:
+    resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
+    engines: {node: '>=0.10.0'}
+
+  postgres-date@1.0.7:
+    resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
+    engines: {node: '>=0.10.0'}
+
+  postgres-interval@1.2.0:
+    resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
+    engines: {node: '>=0.10.0'}
+
+  process-nextick-args@2.0.1:
+    resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+
+  process-warning@4.0.1:
+    resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
+
+  process-warning@5.1.0:
+    resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
+
+  qrcode@1.5.4:
+    resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+    engines: {node: '>=10.13.0'}
+    hasBin: true
+
+  quick-format-unescaped@4.0.4:
+    resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
+
+  react-dom@19.3.0:
+    resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==}
+    peerDependencies:
+      react: ^19.3.0
+
+  react-refresh@0.17.0:
+    resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
+    engines: {node: '>=0.10.0'}
+
+  react-router-dom@7.18.3:
+    resolution: {integrity: sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: '>=18'
+      react-dom: '>=18'
+
+  react-router@7.18.3:
+    resolution: {integrity: sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: '>=18'
+      react-dom: '>=18'
+    peerDependenciesMeta:
+      react-dom:
+        optional: true
+
+  react@19.3.0:
+    resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
+    engines: {node: '>=0.10.0'}
+
+  readable-stream@2.3.8:
+    resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
+
+  real-require@0.2.0:
+    resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
+    engines: {node: '>= 12.13.0'}
+
+  real-require@1.0.0:
+    resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
+
+  require-directory@2.1.1:
+    resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+    engines: {node: '>=0.10.0'}
+
+  require-from-string@2.0.2:
+    resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+    engines: {node: '>=0.10.0'}
+
+  require-main-filename@2.0.0:
+    resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+
+  ret@0.5.0:
+    resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
+    engines: {node: '>=10'}
+
+  reusify@1.1.0:
+    resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
+    engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
+  rfdc@1.4.1:
+    resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
+
+  rollup@4.63.2:
+    resolution: {integrity: sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==}
+    engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+    hasBin: true
+
+  rxjs@7.8.2:
+    resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
+
+  safe-buffer@5.1.2:
+    resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
+
+  safe-buffer@5.2.1:
+    resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
+  safe-regex2@5.1.1:
+    resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==}
+    hasBin: true
+
+  safe-stable-stringify@2.5.0:
+    resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
+    engines: {node: '>=10'}
+
+  sanitize-html@2.17.7:
+    resolution: {integrity: sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==}
+    engines: {node: '>=22.12.0'}
+
+  scheduler@0.28.0:
+    resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
+
+  secure-json-parse@4.1.0:
+    resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
+
+  semver@6.3.1:
+    resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+    hasBin: true
+
+  semver@7.8.5:
+    resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+    engines: {node: '>=10'}
+    hasBin: true
+
+  set-blocking@2.0.0:
+    resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
+  set-cookie-parser@2.7.2:
+    resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
+  setimmediate@1.0.5:
+    resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
+
+  setprototypeof@1.2.0:
+    resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+
+  shebang-command@2.0.0:
+    resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+    engines: {node: '>=8'}
+
+  shebang-regex@3.0.0:
+    resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+    engines: {node: '>=8'}
+
+  shell-quote@1.9.0:
+    resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==}
+    engines: {node: '>= 0.4'}
+
+  signal-exit@4.1.0:
+    resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
+    engines: {node: '>=14'}
+
+  sonic-boom@4.2.1:
+    resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
+
+  source-map-js@1.2.1:
+    resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+    engines: {node: '>=0.10.0'}
+
+  split2@4.2.0:
+    resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
+    engines: {node: '>= 10.x'}
+
+  statuses@2.0.2:
+    resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+    engines: {node: '>= 0.8'}
+
+  string-width@4.2.3:
+    resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+    engines: {node: '>=8'}
+
+  string_decoder@1.1.1:
+    resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
+
+  strip-ansi@6.0.1:
+    resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+    engines: {node: '>=8'}
+
+  strnum@2.4.2:
+    resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==}
+
+  supports-color@7.2.0:
+    resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+    engines: {node: '>=8'}
+
+  supports-color@8.1.1:
+    resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
+    engines: {node: '>=10'}
+
+  thread-stream@4.2.0:
+    resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
+    engines: {node: '>=20'}
+
+  tinyglobby@0.2.17:
+    resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+    engines: {node: '>=12.0.0'}
+
+  toad-cache@3.7.4:
+    resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==}
+    engines: {node: '>=20'}
+
+  toidentifier@1.0.1:
+    resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+    engines: {node: '>=0.6'}
+
+  tree-kill@1.2.2:
+    resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
+    hasBin: true
+
+  tslib@2.8.1:
+    resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+  tsx@4.23.13:
+    resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
+    engines: {node: '>=18.0.0'}
+    hasBin: true
+
+  typescript@5.7.3:
+    resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==}
+    engines: {node: '>=14.17'}
+    hasBin: true
+
+  undici-types@6.21.0:
+    resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
+  update-browserslist-db@1.3.3:
+    resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==}
+    hasBin: true
+    peerDependencies:
+      browserslist: '>= 4.21.0'
+
+  util-deprecate@1.0.2:
+    resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+  vite@6.4.3:
+    resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==}
+    engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+    hasBin: true
+    peerDependencies:
+      '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+      jiti: '>=1.21.0'
+      less: '*'
+      lightningcss: ^1.21.0
+      sass: '*'
+      sass-embedded: '*'
+      stylus: '*'
+      sugarss: '*'
+      terser: ^5.16.0
+      tsx: ^4.8.1
+      yaml: ^2.4.2
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+      jiti:
+        optional: true
+      less:
+        optional: true
+      lightningcss:
+        optional: true
+      sass:
+        optional: true
+      sass-embedded:
+        optional: true
+      stylus:
+        optional: true
+      sugarss:
+        optional: true
+      terser:
+        optional: true
+      tsx:
+        optional: true
+      yaml:
+        optional: true
+
+  which-module@2.0.1:
+    resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+
+  which@2.0.2:
+    resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+    engines: {node: '>= 8'}
+    hasBin: true
+
+  wrap-ansi@6.2.0:
+    resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+    engines: {node: '>=8'}
+
+  wrap-ansi@7.0.0:
+    resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+    engines: {node: '>=10'}
+
+  xml-naming@0.3.0:
+    resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==}
+    engines: {node: '>=16.0.0'}
+
+  xtend@4.0.2:
+    resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
+    engines: {node: '>=0.4'}
+
+  y18n@4.0.3:
+    resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+
+  y18n@5.0.8:
+    resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
+    engines: {node: '>=10'}
+
+  yallist@3.1.1:
+    resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+  yargs-parser@18.1.3:
+    resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+    engines: {node: '>=6'}
+
+  yargs-parser@21.1.1:
+    resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
+    engines: {node: '>=12'}
+
+  yargs@15.4.1:
+    resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+    engines: {node: '>=8'}
+
+  yargs@17.7.2:
+    resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
+    engines: {node: '>=12'}
+
+  zod@3.25.76:
+    resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
+
+snapshots:
+
+  '@babel/code-frame@7.29.7':
+    dependencies:
+      '@babel/helper-validator-identifier': 7.29.7
+      js-tokens: 4.0.0
+      picocolors: 1.1.1
+
+  '@babel/compat-data@7.29.7': {}
+
+  '@babel/core@7.29.7(supports-color@8.1.1)':
+    dependencies:
+      '@babel/code-frame': 7.29.7
+      '@babel/generator': 7.29.8
+      '@babel/helper-compilation-targets': 7.29.7
+      '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
+      '@babel/helpers': 7.29.7
+      '@babel/parser': 7.29.8
+      '@babel/template': 7.29.7
+      '@babel/traverse': 7.29.8(supports-color@8.1.1)
+      '@babel/types': 7.29.8
+      '@jridgewell/remapping': 2.3.5
+      convert-source-map: 2.0.0
+      debug: 4.4.3(supports-color@8.1.1)
+      gensync: 1.0.0-beta.2
+      json5: 2.2.3
+      semver: 6.3.1
+    transitivePeerDependencies:
+      - supports-color
+
+  '@babel/generator@7.29.8':
+    dependencies:
+      '@babel/parser': 7.29.8
+      '@babel/types': 7.29.8
+      '@jridgewell/gen-mapping': 0.3.13
+      '@jridgewell/trace-mapping': 0.3.31
+      jsesc: 3.1.0
+
+  '@babel/helper-compilation-targets@7.29.7':
+    dependencies:
+      '@babel/compat-data': 7.29.7
+      '@babel/helper-validator-option': 7.29.7
+      browserslist: 4.28.9
+      lru-cache: 5.1.1
+      semver: 6.3.1
+
+  '@babel/helper-globals@7.29.7': {}
+
+  '@babel/helper-module-imports@7.29.7(supports-color@8.1.1)':
+    dependencies:
+      '@babel/traverse': 7.29.8(supports-color@8.1.1)
+      '@babel/types': 7.29.8
+    transitivePeerDependencies:
+      - supports-color
+
+  '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)':
+    dependencies:
+      '@babel/core': 7.29.7(supports-color@8.1.1)
+      '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1)
+      '@babel/helper-validator-identifier': 7.29.7
+      '@babel/traverse': 7.29.8(supports-color@8.1.1)
+    transitivePeerDependencies:
+      - supports-color
+
+  '@babel/helper-plugin-utils@7.29.7': {}
+
+  '@babel/helper-string-parser@7.29.7': {}
+
+  '@babel/helper-validator-identifier@7.29.7': {}
+
+  '@babel/helper-validator-option@7.29.7': {}
+
+  '@babel/helpers@7.29.7':
+    dependencies:
+      '@babel/template': 7.29.7
+      '@babel/types': 7.29.8
+
+  '@babel/parser@7.29.8':
+    dependencies:
+      '@babel/types': 7.29.8
+
+  '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))':
+    dependencies:
+      '@babel/core': 7.29.7(supports-color@8.1.1)
+      '@babel/helper-plugin-utils': 7.29.7
+
+  '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))':
+    dependencies:
+      '@babel/core': 7.29.7(supports-color@8.1.1)
+      '@babel/helper-plugin-utils': 7.29.7
+
+  '@babel/template@7.29.7':
+    dependencies:
+      '@babel/code-frame': 7.29.7
+      '@babel/parser': 7.29.8
+      '@babel/types': 7.29.8
+
+  '@babel/traverse@7.29.8(supports-color@8.1.1)':
+    dependencies:
+      '@babel/code-frame': 7.29.7
+      '@babel/generator': 7.29.8
+      '@babel/helper-globals': 7.29.7
+      '@babel/parser': 7.29.8
+      '@babel/template': 7.29.7
+      '@babel/types': 7.29.8
+      debug: 4.4.3(supports-color@8.1.1)
+    transitivePeerDependencies:
+      - supports-color
+
+  '@babel/types@7.29.8':
+    dependencies:
+      '@babel/helper-string-parser': 7.29.7
+      '@babel/helper-validator-identifier': 7.29.7
+
+  '@esbuild/aix-ppc64@0.25.12':
+    optional: true
+
+  '@esbuild/aix-ppc64@0.28.2':
+    optional: true
+
+  '@esbuild/android-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/android-arm64@0.28.2':
+    optional: true
+
+  '@esbuild/android-arm@0.25.12':
+    optional: true
+
+  '@esbuild/android-arm@0.28.2':
+    optional: true
+
+  '@esbuild/android-x64@0.25.12':
+    optional: true
+
+  '@esbuild/android-x64@0.28.2':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.28.2':
+    optional: true
+
+  '@esbuild/darwin-x64@0.25.12':
+    optional: true
+
+  '@esbuild/darwin-x64@0.28.2':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.28.2':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.25.12':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.28.2':
+    optional: true
+
+  '@esbuild/linux-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-arm64@0.28.2':
+    optional: true
+
+  '@esbuild/linux-arm@0.25.12':
+    optional: true
+
+  '@esbuild/linux-arm@0.28.2':
+    optional: true
+
+  '@esbuild/linux-ia32@0.25.12':
+    optional: true
+
+  '@esbuild/linux-ia32@0.28.2':
+    optional: true
+
+  '@esbuild/linux-loong64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-loong64@0.28.2':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.25.12':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.28.2':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.28.2':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.28.2':
+    optional: true
+
+  '@esbuild/linux-s390x@0.25.12':
+    optional: true
+
+  '@esbuild/linux-s390x@0.28.2':
+    optional: true
+
+  '@esbuild/linux-x64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-x64@0.28.2':
+    optional: true
+
+  '@esbuild/netbsd-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/netbsd-arm64@0.28.2':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.25.12':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.28.2':
+    optional: true
+
+  '@esbuild/openbsd-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/openbsd-arm64@0.28.2':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.25.12':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.28.2':
+    optional: true
+
+  '@esbuild/openharmony-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/openharmony-arm64@0.28.2':
+    optional: true
+
+  '@esbuild/sunos-x64@0.25.12':
+    optional: true
+
+  '@esbuild/sunos-x64@0.28.2':
+    optional: true
+
+  '@esbuild/win32-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/win32-arm64@0.28.2':
+    optional: true
+
+  '@esbuild/win32-ia32@0.25.12':
+    optional: true
+
+  '@esbuild/win32-ia32@0.28.2':
+    optional: true
+
+  '@esbuild/win32-x64@0.25.12':
+    optional: true
+
+  '@esbuild/win32-x64@0.28.2':
+    optional: true
+
+  '@fastify/accept-negotiator@2.1.0': {}
+
+  '@fastify/ajv-compiler@4.0.6':
+    dependencies:
+      ajv: 8.20.0
+      ajv-formats: 3.0.1(ajv@8.20.0)
+      fast-uri: 4.1.4
+
+  '@fastify/busboy@3.2.2': {}
+
+  '@fastify/cors@10.1.0':
+    dependencies:
+      fastify-plugin: 5.1.0
+      mnemonist: 0.40.0
+
+  '@fastify/deepmerge@3.2.1': {}
+
+  '@fastify/error@4.2.0': {}
+
+  '@fastify/fast-json-stringify-compiler@5.1.0':
+    dependencies:
+      fast-json-stringify: 7.0.1
+
+  '@fastify/forwarded@3.0.2': {}
+
+  '@fastify/merge-json-schemas@0.2.1':
+    dependencies:
+      dequal: 2.0.3
+
+  '@fastify/multipart@9.4.0':
+    dependencies:
+      '@fastify/busboy': 3.2.2
+      '@fastify/deepmerge': 3.2.1
+      '@fastify/error': 4.2.0
+      fastify-plugin: 5.1.0
+      secure-json-parse: 4.1.0
+
+  '@fastify/proxy-addr@5.1.0':
+    dependencies:
+      '@fastify/forwarded': 3.0.2
+      ipaddr.js: 2.5.0
+
+  '@fastify/send@4.1.1':
+    dependencies:
+      '@lukeed/ms': 2.0.2
+      escape-html: 1.0.3
+      fast-decode-uri-component: 1.0.1
+      http-errors: 2.0.1
+      mime: 3.0.0
+
+  '@fastify/static@8.3.0':
+    dependencies:
+      '@fastify/accept-negotiator': 2.1.0
+      '@fastify/send': 4.1.1
+      content-disposition: 0.5.4
+      fastify-plugin: 5.1.0
+      fastq: 1.20.3
+      glob: 11.1.0
+
+  '@isaacs/cliui@9.0.0': {}
+
+  '@jridgewell/gen-mapping@0.3.13':
+    dependencies:
+      '@jridgewell/sourcemap-codec': 1.6.0
+      '@jridgewell/trace-mapping': 0.3.31
+
+  '@jridgewell/remapping@2.3.5':
+    dependencies:
+      '@jridgewell/gen-mapping': 0.3.13
+      '@jridgewell/trace-mapping': 0.3.31
+
+  '@jridgewell/resolve-uri@3.1.2': {}
+
+  '@jridgewell/sourcemap-codec@1.6.0': {}
+
+  '@jridgewell/trace-mapping@0.3.31':
+    dependencies:
+      '@jridgewell/resolve-uri': 3.1.2
+      '@jridgewell/sourcemap-codec': 1.6.0
+
+  '@lukeed/ms@2.0.2': {}
+
+  '@napi-rs/canvas-android-arm64@1.0.9':
+    optional: true
+
+  '@napi-rs/canvas-darwin-arm64@1.0.9':
+    optional: true
+
+  '@napi-rs/canvas-darwin-x64@1.0.9':
+    optional: true
+
+  '@napi-rs/canvas-linux-arm-gnueabihf@1.0.9':
+    optional: true
+
+  '@napi-rs/canvas-linux-arm64-gnu@1.0.9':
+    optional: true
+
+  '@napi-rs/canvas-linux-arm64-musl@1.0.9':
+    optional: true
+
+  '@napi-rs/canvas-linux-riscv64-gnu@1.0.9':
+    optional: true
+
+  '@napi-rs/canvas-linux-x64-gnu@1.0.9':
+    optional: true
+
+  '@napi-rs/canvas-linux-x64-musl@1.0.9':
+    optional: true
+
+  '@napi-rs/canvas-win32-arm64-msvc@1.0.9':
+    optional: true
+
+  '@napi-rs/canvas-win32-x64-msvc@1.0.9':
+    optional: true
+
+  '@napi-rs/canvas@1.0.9':
+    optionalDependencies:
+      '@napi-rs/canvas-android-arm64': 1.0.9
+      '@napi-rs/canvas-darwin-arm64': 1.0.9
+      '@napi-rs/canvas-darwin-x64': 1.0.9
+      '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.9
+      '@napi-rs/canvas-linux-arm64-gnu': 1.0.9
+      '@napi-rs/canvas-linux-arm64-musl': 1.0.9
+      '@napi-rs/canvas-linux-riscv64-gnu': 1.0.9
+      '@napi-rs/canvas-linux-x64-gnu': 1.0.9
+      '@napi-rs/canvas-linux-x64-musl': 1.0.9
+      '@napi-rs/canvas-win32-arm64-msvc': 1.0.9
+      '@napi-rs/canvas-win32-x64-msvc': 1.0.9
+    optional: true
+
+  '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+    optional: true
+
+  '@nodable/entities@3.0.0': {}
+
+  '@pinojs/redact@0.4.0': {}
+
+  '@rolldown/pluginutils@1.0.0-beta.27': {}
+
+  '@rollup/rollup-android-arm-eabi@4.63.2':
+    optional: true
+
+  '@rollup/rollup-android-arm64@4.63.2':
+    optional: true
+
+  '@rollup/rollup-darwin-arm64@4.63.2':
+    optional: true
+
+  '@rollup/rollup-darwin-x64@4.63.2':
+    optional: true
+
+  '@rollup/rollup-freebsd-arm64@4.63.2':
+    optional: true
+
+  '@rollup/rollup-freebsd-x64@4.63.2':
+    optional: true
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.63.2':
+    optional: true
+
+  '@rollup/rollup-linux-arm-musleabihf@4.63.2':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-gnu@4.63.2':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-musl@4.63.2':
+    optional: true
+
+  '@rollup/rollup-linux-loong64-gnu@4.63.2':
+    optional: true
+
+  '@rollup/rollup-linux-loong64-musl@4.63.2':
+    optional: true
+
+  '@rollup/rollup-linux-ppc64-gnu@4.63.2':
+    optional: true
+
+  '@rollup/rollup-linux-ppc64-musl@4.63.2':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-gnu@4.63.2':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-musl@4.63.2':
+    optional: true
+
+  '@rollup/rollup-linux-s390x-gnu@4.63.2':
+    optional: true
+
+  '@rollup/rollup-linux-x64-gnu@4.63.2':
+    optional: true
+
+  '@rollup/rollup-linux-x64-musl@4.63.2':
+    optional: true
+
+  '@rollup/rollup-openbsd-x64@4.63.2':
+    optional: true
+
+  '@rollup/rollup-openharmony-arm64@4.63.2':
+    optional: true
+
+  '@rollup/rollup-win32-arm64-msvc@4.63.2':
+    optional: true
+
+  '@rollup/rollup-win32-ia32-msvc@4.63.2':
+    optional: true
+
+  '@rollup/rollup-win32-x64-gnu@4.63.2':
+    optional: true
+
+  '@rollup/rollup-win32-x64-msvc@4.63.2':
+    optional: true
+
+  '@types/babel__core@7.20.5':
+    dependencies:
+      '@babel/parser': 7.29.8
+      '@babel/types': 7.29.8
+      '@types/babel__generator': 7.27.0
+      '@types/babel__template': 7.4.4
+      '@types/babel__traverse': 7.28.0
+
+  '@types/babel__generator@7.27.0':
+    dependencies:
+      '@babel/types': 7.29.8
+
+  '@types/babel__template@7.4.4':
+    dependencies:
+      '@babel/parser': 7.29.8
+      '@babel/types': 7.29.8
+
+  '@types/babel__traverse@7.28.0':
+    dependencies:
+      '@babel/types': 7.29.8
+
+  '@types/estree@1.0.9': {}
+
+  '@types/node@22.20.2':
+    dependencies:
+      undici-types: 6.21.0
+
+  '@types/pg@8.23.1':
+    dependencies:
+      '@types/node': 22.20.2
+      pg-protocol: 1.16.0
+      pg-types: 2.2.0
+
+  '@types/qrcode@1.5.6':
+    dependencies:
+      '@types/node': 22.20.2
+
+  '@types/react-dom@19.3.0(@types/react@19.3.0)':
+    dependencies:
+      '@types/react': 19.3.0
+
+  '@types/react@19.3.0':
+    dependencies:
+      csstype: 3.2.3
+
+  '@types/sanitize-html@2.16.1':
+    dependencies:
+      htmlparser2: 10.1.0
+
+  '@types/trusted-types@2.0.7':
+    optional: true
+
+  '@vitejs/plugin-react@4.7.0(supports-color@8.1.1)(vite@6.4.3(@types/node@22.20.2)(tsx@4.23.13))':
+    dependencies:
+      '@babel/core': 7.29.7(supports-color@8.1.1)
+      '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))
+      '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))
+      '@rolldown/pluginutils': 1.0.0-beta.27
+      '@types/babel__core': 7.20.5
+      react-refresh: 0.17.0
+      vite: 6.4.3(@types/node@22.20.2)(tsx@4.23.13)
+    transitivePeerDependencies:
+      - supports-color
+
+  abstract-logging@2.0.1: {}
+
+  ajv-formats@3.0.1(ajv@8.20.0):
+    optionalDependencies:
+      ajv: 8.20.0
+
+  ajv@8.20.0:
+    dependencies:
+      fast-deep-equal: 3.1.3
+      fast-uri: 3.1.7
+      json-schema-traverse: 1.0.0
+      require-from-string: 2.0.2
+
+  ansi-regex@5.0.1: {}
+
+  ansi-styles@4.3.0:
+    dependencies:
+      color-convert: 2.0.1
+
+  anynum@1.0.1: {}
+
+  atomic-sleep@1.0.0: {}
+
+  avvio@9.3.0:
+    dependencies:
+      '@fastify/error': 4.2.0
+      fastq: 1.20.3
+
+  balanced-match@4.0.4: {}
+
+  baseline-browser-mapping@2.11.22: {}
+
+  brace-expansion@5.0.9:
+    dependencies:
+      balanced-match: 4.0.4
+
+  browserslist@4.28.9:
+    dependencies:
+      baseline-browser-mapping: 2.11.22
+      caniuse-lite: 1.0.30001810
+      electron-to-chromium: 1.5.427
+      node-releases: 2.0.55
+      update-browserslist-db: 1.3.3(browserslist@4.28.9)
+
+  camelcase@5.3.1: {}
+
+  caniuse-lite@1.0.30001810: {}
+
+  chalk@4.1.2:
+    dependencies:
+      ansi-styles: 4.3.0
+      supports-color: 7.2.0
+
+  cliui@6.0.0:
+    dependencies:
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      wrap-ansi: 6.2.0
+
+  cliui@8.0.1:
+    dependencies:
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      wrap-ansi: 7.0.0
+
+  color-convert@2.0.1:
+    dependencies:
+      color-name: 1.1.4
+
+  color-name@1.1.4: {}
+
+  concurrently@9.2.4:
+    dependencies:
+      chalk: 4.1.2
+      rxjs: 7.8.2
+      shell-quote: 1.9.0
+      supports-color: 8.1.1
+      tree-kill: 1.2.2
+      yargs: 17.7.2
+
+  content-disposition@0.5.4:
+    dependencies:
+      safe-buffer: 5.2.1
+
+  convert-source-map@2.0.0: {}
+
+  cookie@1.1.1: {}
+
+  core-util-is@1.0.3: {}
+
+  cross-spawn@7.0.6:
+    dependencies:
+      path-key: 3.1.1
+      shebang-command: 2.0.0
+      which: 2.0.2
+
+  csstype@3.2.3: {}
+
+  dayjs@1.11.23: {}
+
+  debug@4.4.3(supports-color@8.1.1):
+    dependencies:
+      ms: 2.1.3
+    optionalDependencies:
+      supports-color: 8.1.1
+
+  decamelize@1.2.0: {}
+
+  deepmerge@4.3.1: {}
+
+  depd@2.0.0: {}
+
+  dequal@2.0.3: {}
+
+  dijkstrajs@1.0.3: {}
+
+  dom-serializer@2.0.0:
+    dependencies:
+      domelementtype: 2.3.0
+      domhandler: 5.0.3
+      entities: 4.5.0
+
+  dom-serializer@3.1.1:
+    dependencies:
+      domelementtype: 3.0.0
+      domhandler: 6.0.1
+      entities: 8.1.0
+
+  domelementtype@2.3.0: {}
+
+  domelementtype@3.0.0: {}
+
+  domhandler@5.0.3:
+    dependencies:
+      domelementtype: 2.3.0
+
+  domhandler@6.0.1:
+    dependencies:
+      domelementtype: 3.0.0
+
+  dompurify@3.4.15:
+    optionalDependencies:
+      '@types/trusted-types': 2.0.7
+
+  domutils@3.2.2:
+    dependencies:
+      dom-serializer: 2.0.0
+      domelementtype: 2.3.0
+      domhandler: 5.0.3
+
+  domutils@4.0.2:
+    dependencies:
+      dom-serializer: 3.1.1
+      domelementtype: 3.0.0
+      domhandler: 6.0.1
+
+  electron-to-chromium@1.5.427: {}
+
+  emoji-regex@8.0.0: {}
+
+  entities@4.5.0: {}
+
+  entities@7.0.1: {}
+
+  entities@8.1.0: {}
+
+  esbuild@0.25.12:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.25.12
+      '@esbuild/android-arm': 0.25.12
+      '@esbuild/android-arm64': 0.25.12
+      '@esbuild/android-x64': 0.25.12
+      '@esbuild/darwin-arm64': 0.25.12
+      '@esbuild/darwin-x64': 0.25.12
+      '@esbuild/freebsd-arm64': 0.25.12
+      '@esbuild/freebsd-x64': 0.25.12
+      '@esbuild/linux-arm': 0.25.12
+      '@esbuild/linux-arm64': 0.25.12
+      '@esbuild/linux-ia32': 0.25.12
+      '@esbuild/linux-loong64': 0.25.12
+      '@esbuild/linux-mips64el': 0.25.12
+      '@esbuild/linux-ppc64': 0.25.12
+      '@esbuild/linux-riscv64': 0.25.12
+      '@esbuild/linux-s390x': 0.25.12
+      '@esbuild/linux-x64': 0.25.12
+      '@esbuild/netbsd-arm64': 0.25.12
+      '@esbuild/netbsd-x64': 0.25.12
+      '@esbuild/openbsd-arm64': 0.25.12
+      '@esbuild/openbsd-x64': 0.25.12
+      '@esbuild/openharmony-arm64': 0.25.12
+      '@esbuild/sunos-x64': 0.25.12
+      '@esbuild/win32-arm64': 0.25.12
+      '@esbuild/win32-ia32': 0.25.12
+      '@esbuild/win32-x64': 0.25.12
+
+  esbuild@0.28.2:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.28.2
+      '@esbuild/android-arm': 0.28.2
+      '@esbuild/android-arm64': 0.28.2
+      '@esbuild/android-x64': 0.28.2
+      '@esbuild/darwin-arm64': 0.28.2
+      '@esbuild/darwin-x64': 0.28.2
+      '@esbuild/freebsd-arm64': 0.28.2
+      '@esbuild/freebsd-x64': 0.28.2
+      '@esbuild/linux-arm': 0.28.2
+      '@esbuild/linux-arm64': 0.28.2
+      '@esbuild/linux-ia32': 0.28.2
+      '@esbuild/linux-loong64': 0.28.2
+      '@esbuild/linux-mips64el': 0.28.2
+      '@esbuild/linux-ppc64': 0.28.2
+      '@esbuild/linux-riscv64': 0.28.2
+      '@esbuild/linux-s390x': 0.28.2
+      '@esbuild/linux-x64': 0.28.2
+      '@esbuild/netbsd-arm64': 0.28.2
+      '@esbuild/netbsd-x64': 0.28.2
+      '@esbuild/openbsd-arm64': 0.28.2
+      '@esbuild/openbsd-x64': 0.28.2
+      '@esbuild/openharmony-arm64': 0.28.2
+      '@esbuild/sunos-x64': 0.28.2
+      '@esbuild/win32-arm64': 0.28.2
+      '@esbuild/win32-ia32': 0.28.2
+      '@esbuild/win32-x64': 0.28.2
+
+  escalade@3.2.0: {}
+
+  escape-html@1.0.3: {}
+
+  escape-string-regexp@4.0.0: {}
+
+  fast-decode-uri-component@1.0.1: {}
+
+  fast-deep-equal@3.1.3: {}
+
+  fast-json-stringify@7.0.1:
+    dependencies:
+      '@fastify/merge-json-schemas': 0.2.1
+      ajv: 8.20.0
+      ajv-formats: 3.0.1(ajv@8.20.0)
+      fast-uri: 4.1.4
+      json-schema-ref-resolver: 3.0.0
+      rfdc: 1.4.1
+
+  fast-querystring@1.1.2:
+    dependencies:
+      fast-decode-uri-component: 1.0.1
+
+  fast-uri@3.1.7: {}
+
+  fast-uri@4.1.4: {}
+
+  fast-xml-builder@1.3.1:
+    dependencies:
+      path-expression-matcher: 1.6.2
+      xml-naming: 0.3.0
+
+  fast-xml-parser@5.11.1:
+    dependencies:
+      '@nodable/entities': 3.0.0
+      fast-xml-builder: 1.3.1
+      is-unsafe: 2.0.2
+      path-expression-matcher: 1.6.2
+      strnum: 2.4.2
+      xml-naming: 0.3.0
+
+  fastify-plugin@5.1.0: {}
+
+  fastify@5.12.4:
+    dependencies:
+      '@fastify/ajv-compiler': 4.0.6
+      '@fastify/error': 4.2.0
+      '@fastify/fast-json-stringify-compiler': 5.1.0
+      '@fastify/proxy-addr': 5.1.0
+      abstract-logging: 2.0.1
+      avvio: 9.3.0
+      fast-json-stringify: 7.0.1
+      find-my-way: 9.9.0
+      light-my-request: 6.6.0
+      pino: 10.3.1
+      process-warning: 5.1.0
+      rfdc: 1.4.1
+      secure-json-parse: 4.1.0
+      semver: 7.8.5
+      toad-cache: 3.7.4
+
+  fastq@1.20.3:
+    dependencies:
+      reusify: 1.1.0
+
+  fdir@6.5.0(picomatch@4.0.7):
+    optionalDependencies:
+      picomatch: 4.0.7
+
+  find-my-way@9.9.0:
+    dependencies:
+      fast-deep-equal: 3.1.3
+      fast-querystring: 1.1.2
+      safe-regex2: 5.1.1
+
+  find-up@4.1.0:
+    dependencies:
+      locate-path: 5.0.0
+      path-exists: 4.0.0
+
+  foreground-child@3.3.1:
+    dependencies:
+      cross-spawn: 7.0.6
+      signal-exit: 4.1.0
+
+  fsevents@2.3.3:
+    optional: true
+
+  gensync@1.0.0-beta.2: {}
+
+  get-caller-file@2.0.5: {}
+
+  glob@11.1.0:
+    dependencies:
+      foreground-child: 3.3.1
+      jackspeak: 4.2.3
+      minimatch: 10.2.6
+      minipass: 7.1.3
+      package-json-from-dist: 1.0.1
+      path-scurry: 2.0.2
+
+  has-flag@4.0.0: {}
+
+  htmlparser2@10.1.0:
+    dependencies:
+      domelementtype: 2.3.0
+      domhandler: 5.0.3
+      domutils: 3.2.2
+      entities: 7.0.1
+
+  htmlparser2@12.0.0:
+    dependencies:
+      domelementtype: 3.0.0
+      domhandler: 6.0.1
+      domutils: 4.0.2
+      entities: 8.1.0
+
+  http-errors@2.0.1:
+    dependencies:
+      depd: 2.0.0
+      inherits: 2.0.4
+      setprototypeof: 1.2.0
+      statuses: 2.0.2
+      toidentifier: 1.0.1
+
+  immediate@3.0.6: {}
+
+  inherits@2.0.4: {}
+
+  ipaddr.js@2.5.0: {}
+
+  is-fullwidth-code-point@3.0.0: {}
+
+  is-plain-object@5.1.0: {}
+
+  is-unsafe@2.0.2: {}
+
+  isarray@1.0.0: {}
+
+  isexe@2.0.0: {}
+
+  jackspeak@4.2.3:
+    dependencies:
+      '@isaacs/cliui': 9.0.0
+
+  js-tokens@4.0.0: {}
+
+  jsesc@3.1.0: {}
+
+  json-schema-ref-resolver@3.0.0:
+    dependencies:
+      dequal: 2.0.3
+
+  json-schema-traverse@1.0.0: {}
+
+  json5@2.2.3: {}
+
+  jszip@3.10.2:
+    dependencies:
+      lie: 3.3.0
+      pako: 1.0.11
+      readable-stream: 2.3.8
+      setimmediate: 1.0.5
+
+  launder@1.7.1:
+    dependencies:
+      dayjs: 1.11.23
+
+  lie@3.3.0:
+    dependencies:
+      immediate: 3.0.6
+
+  light-my-request@6.6.0:
+    dependencies:
+      cookie: 1.1.1
+      process-warning: 4.0.1
+      set-cookie-parser: 2.7.2
+
+  locate-path@5.0.0:
+    dependencies:
+      p-locate: 4.1.0
+
+  lru-cache@11.5.2: {}
+
+  lru-cache@5.1.1:
+    dependencies:
+      yallist: 3.1.1
+
+  lucide-react@0.468.0(react@19.3.0):
+    dependencies:
+      react: 19.3.0
+
+  mime@3.0.0: {}
+
+  minimatch@10.2.6:
+    dependencies:
+      brace-expansion: 5.0.9
+
+  minipass@7.1.3: {}
+
+  mnemonist@0.40.0:
+    dependencies:
+      obliterator: 2.0.5
+
+  ms@2.1.3: {}
+
+  nanoid@3.3.19: {}
+
+  node-releases@2.0.55: {}
+
+  obliterator@2.0.5: {}
+
+  on-exit-leak-free@2.1.2: {}
+
+  p-limit@2.3.0:
+    dependencies:
+      p-try: 2.2.0
+
+  p-locate@4.1.0:
+    dependencies:
+      p-limit: 2.3.0
+
+  p-try@2.2.0: {}
+
+  package-json-from-dist@1.0.1: {}
+
+  pako@1.0.11: {}
+
+  parse-srcset@1.0.2: {}
+
+  path-exists@4.0.0: {}
+
+  path-expression-matcher@1.6.2: {}
+
+  path-key@3.1.1: {}
+
+  path-scurry@2.0.2:
+    dependencies:
+      lru-cache: 11.5.2
+      minipass: 7.1.3
+
+  pdfjs-dist@6.3.289:
+    optionalDependencies:
+      '@napi-rs/canvas': 1.0.9
+
+  pg-cloudflare@1.4.0:
+    optional: true
+
+  pg-connection-string@2.14.0: {}
+
+  pg-int8@1.0.1: {}
+
+  pg-pool@3.14.0(pg@8.23.0):
+    dependencies:
+      pg: 8.23.0
+
+  pg-protocol@1.16.0: {}
+
+  pg-types@2.2.0:
+    dependencies:
+      pg-int8: 1.0.1
+      postgres-array: 2.0.0
+      postgres-bytea: 1.0.1
+      postgres-date: 1.0.7
+      postgres-interval: 1.2.0
+
+  pg@8.23.0:
+    dependencies:
+      pg-connection-string: 2.14.0
+      pg-pool: 3.14.0(pg@8.23.0)
+      pg-protocol: 1.16.0
+      pg-types: 2.2.0
+      pgpass: 1.0.5
+    optionalDependencies:
+      pg-cloudflare: 1.4.0
+
+  pgpass@1.0.5:
+    dependencies:
+      split2: 4.2.0
+
+  picocolors@1.1.1: {}
+
+  picomatch@4.0.7: {}
+
+  pino-abstract-transport@3.0.0:
+    dependencies:
+      split2: 4.2.0
+
+  pino-std-serializers@7.1.0: {}
+
+  pino@10.3.1:
+    dependencies:
+      '@pinojs/redact': 0.4.0
+      atomic-sleep: 1.0.0
+      on-exit-leak-free: 2.1.2
+      pino-abstract-transport: 3.0.0
+      pino-std-serializers: 7.1.0
+      process-warning: 5.1.0
+      quick-format-unescaped: 4.0.4
+      real-require: 0.2.0
+      safe-stable-stringify: 2.5.0
+      sonic-boom: 4.2.1
+      thread-stream: 4.2.0
+
+  pngjs@5.0.0: {}
+
+  postcss@8.5.28:
+    dependencies:
+      nanoid: 3.3.19
+      picocolors: 1.1.1
+      source-map-js: 1.2.1
+
+  postgres-array@2.0.0: {}
+
+  postgres-bytea@1.0.1: {}
+
+  postgres-date@1.0.7: {}
+
+  postgres-interval@1.2.0:
+    dependencies:
+      xtend: 4.0.2
+
+  process-nextick-args@2.0.1: {}
+
+  process-warning@4.0.1: {}
+
+  process-warning@5.1.0: {}
+
+  qrcode@1.5.4:
+    dependencies:
+      dijkstrajs: 1.0.3
+      pngjs: 5.0.0
+      yargs: 15.4.1
+
+  quick-format-unescaped@4.0.4: {}
+
+  react-dom@19.3.0(react@19.3.0):
+    dependencies:
+      react: 19.3.0
+      scheduler: 0.28.0
+
+  react-refresh@0.17.0: {}
+
+  react-router-dom@7.18.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
+    dependencies:
+      react: 19.3.0
+      react-dom: 19.3.0(react@19.3.0)
+      react-router: 7.18.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+
+  react-router@7.18.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
+    dependencies:
+      cookie: 1.1.1
+      react: 19.3.0
+      set-cookie-parser: 2.7.2
+    optionalDependencies:
+      react-dom: 19.3.0(react@19.3.0)
+
+  react@19.3.0: {}
+
+  readable-stream@2.3.8:
+    dependencies:
+      core-util-is: 1.0.3
+      inherits: 2.0.4
+      isarray: 1.0.0
+      process-nextick-args: 2.0.1
+      safe-buffer: 5.1.2
+      string_decoder: 1.1.1
+      util-deprecate: 1.0.2
+
+  real-require@0.2.0: {}
+
+  real-require@1.0.0: {}
+
+  require-directory@2.1.1: {}
+
+  require-from-string@2.0.2: {}
+
+  require-main-filename@2.0.0: {}
+
+  ret@0.5.0: {}
+
+  reusify@1.1.0: {}
+
+  rfdc@1.4.1: {}
+
+  rollup@4.63.2:
+    dependencies:
+      '@types/estree': 1.0.9
+    optionalDependencies:
+      '@napi-rs/lzma-linux-x64-gnu': 1.5.1
+      '@rollup/rollup-android-arm-eabi': 4.63.2
+      '@rollup/rollup-android-arm64': 4.63.2
+      '@rollup/rollup-darwin-arm64': 4.63.2
+      '@rollup/rollup-darwin-x64': 4.63.2
+      '@rollup/rollup-freebsd-arm64': 4.63.2
+      '@rollup/rollup-freebsd-x64': 4.63.2
+      '@rollup/rollup-linux-arm-gnueabihf': 4.63.2
+      '@rollup/rollup-linux-arm-musleabihf': 4.63.2
+      '@rollup/rollup-linux-arm64-gnu': 4.63.2
+      '@rollup/rollup-linux-arm64-musl': 4.63.2
+      '@rollup/rollup-linux-loong64-gnu': 4.63.2
+      '@rollup/rollup-linux-loong64-musl': 4.63.2
+      '@rollup/rollup-linux-ppc64-gnu': 4.63.2
+      '@rollup/rollup-linux-ppc64-musl': 4.63.2
+      '@rollup/rollup-linux-riscv64-gnu': 4.63.2
+      '@rollup/rollup-linux-riscv64-musl': 4.63.2
+      '@rollup/rollup-linux-s390x-gnu': 4.63.2
+      '@rollup/rollup-linux-x64-gnu': 4.63.2
+      '@rollup/rollup-linux-x64-musl': 4.63.2
+      '@rollup/rollup-openbsd-x64': 4.63.2
+      '@rollup/rollup-openharmony-arm64': 4.63.2
+      '@rollup/rollup-win32-arm64-msvc': 4.63.2
+      '@rollup/rollup-win32-ia32-msvc': 4.63.2
+      '@rollup/rollup-win32-x64-gnu': 4.63.2
+      '@rollup/rollup-win32-x64-msvc': 4.63.2
+      fsevents: 2.3.3
+
+  rxjs@7.8.2:
+    dependencies:
+      tslib: 2.8.1
+
+  safe-buffer@5.1.2: {}
+
+  safe-buffer@5.2.1: {}
+
+  safe-regex2@5.1.1:
+    dependencies:
+      ret: 0.5.0
+
+  safe-stable-stringify@2.5.0: {}
+
+  sanitize-html@2.17.7:
+    dependencies:
+      deepmerge: 4.3.1
+      escape-string-regexp: 4.0.0
+      htmlparser2: 12.0.0
+      is-plain-object: 5.1.0
+      launder: 1.7.1
+      parse-srcset: 1.0.2
+      postcss: 8.5.28
+
+  scheduler@0.28.0: {}
+
+  secure-json-parse@4.1.0: {}
+
+  semver@6.3.1: {}
+
+  semver@7.8.5: {}
+
+  set-blocking@2.0.0: {}
+
+  set-cookie-parser@2.7.2: {}
+
+  setimmediate@1.0.5: {}
+
+  setprototypeof@1.2.0: {}
+
+  shebang-command@2.0.0:
+    dependencies:
+      shebang-regex: 3.0.0
+
+  shebang-regex@3.0.0: {}
+
+  shell-quote@1.9.0: {}
+
+  signal-exit@4.1.0: {}
+
+  sonic-boom@4.2.1:
+    dependencies:
+      atomic-sleep: 1.0.0
+
+  source-map-js@1.2.1: {}
+
+  split2@4.2.0: {}
+
+  statuses@2.0.2: {}
+
+  string-width@4.2.3:
+    dependencies:
+      emoji-regex: 8.0.0
+      is-fullwidth-code-point: 3.0.0
+      strip-ansi: 6.0.1
+
+  string_decoder@1.1.1:
+    dependencies:
+      safe-buffer: 5.1.2
+
+  strip-ansi@6.0.1:
+    dependencies:
+      ansi-regex: 5.0.1
+
+  strnum@2.4.2:
+    dependencies:
+      anynum: 1.0.1
+
+  supports-color@7.2.0:
+    dependencies:
+      has-flag: 4.0.0
+
+  supports-color@8.1.1:
+    dependencies:
+      has-flag: 4.0.0
+
+  thread-stream@4.2.0:
+    dependencies:
+      real-require: 1.0.0
+
+  tinyglobby@0.2.17:
+    dependencies:
+      fdir: 6.5.0(picomatch@4.0.7)
+      picomatch: 4.0.7
+
+  toad-cache@3.7.4: {}
+
+  toidentifier@1.0.1: {}
+
+  tree-kill@1.2.2: {}
+
+  tslib@2.8.1: {}
+
+  tsx@4.23.13:
+    dependencies:
+      esbuild: 0.28.2
+    optionalDependencies:
+      fsevents: 2.3.3
+
+  typescript@5.7.3: {}
+
+  undici-types@6.21.0: {}
+
+  update-browserslist-db@1.3.3(browserslist@4.28.9):
+    dependencies:
+      browserslist: 4.28.9
+      escalade: 3.2.0
+      picocolors: 1.1.1
+
+  util-deprecate@1.0.2: {}
+
+  vite@6.4.3(@types/node@22.20.2)(tsx@4.23.13):
+    dependencies:
+      esbuild: 0.25.12
+      fdir: 6.5.0(picomatch@4.0.7)
+      picomatch: 4.0.7
+      postcss: 8.5.28
+      rollup: 4.63.2
+      tinyglobby: 0.2.17
+    optionalDependencies:
+      '@types/node': 22.20.2
+      fsevents: 2.3.3
+      tsx: 4.23.13
+
+  which-module@2.0.1: {}
+
+  which@2.0.2:
+    dependencies:
+      isexe: 2.0.0
+
+  wrap-ansi@6.2.0:
+    dependencies:
+      ansi-styles: 4.3.0
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+
+  wrap-ansi@7.0.0:
+    dependencies:
+      ansi-styles: 4.3.0
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+
+  xml-naming@0.3.0: {}
+
+  xtend@4.0.2: {}
+
+  y18n@4.0.3: {}
+
+  y18n@5.0.8: {}
+
+  yallist@3.1.1: {}
+
+  yargs-parser@18.1.3:
+    dependencies:
+      camelcase: 5.3.1
+      decamelize: 1.2.0
+
+  yargs-parser@21.1.1: {}
+
+  yargs@15.4.1:
+    dependencies:
+      cliui: 6.0.0
+      decamelize: 1.2.0
+      find-up: 4.1.0
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      require-main-filename: 2.0.0
+      set-blocking: 2.0.0
+      string-width: 4.2.3
+      which-module: 2.0.1
+      y18n: 4.0.3
+      yargs-parser: 18.1.3
+
+  yargs@17.7.2:
+    dependencies:
+      cliui: 8.0.1
+      escalade: 3.2.0
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      string-width: 4.2.3
+      y18n: 5.0.8
+      yargs-parser: 21.1.1
+
+  zod@3.25.76: {}

+ 2 - 0
pnpm-workspace.yaml

@@ -0,0 +1,2 @@
+allowBuilds:
+  esbuild: false

BIN
public/guides/digital-instructor-photo-reference.png


+ 12 - 4
server/db.ts

@@ -9,11 +9,12 @@ export const mapUser = (r: any) => ({
   id: r.id,
   phone: r.phone,
   role: r.role || 'USER',
-  organization: r.organization || '',
+  organization: '',
   wechat: r.wechat || '',
   contactName: r.contact_name || '',
   bio: r.bio || '',
   pointsBalance: Number(r.points_balance) || 0,
+  mustChangePassword: Boolean(r.must_change_password),
   createdAt: r.created_at,
   updatedAt: r.updated_at
 })
@@ -34,8 +35,10 @@ export const mapInstructor = (r: any) => ({
   name: r.name,
   organization: r.organization || '',
   introduction: r.introduction || '',
+  note: r.note || '',
   imageUrl: r.image_urls?.[0] || (r.image_stored_name ? `/uploads/${r.image_stored_name}` : null),
-  imageUrls: r.image_urls?.length ? r.image_urls : (r.image_stored_name ? [`/uploads/${r.image_stored_name}`] : [])
+  imageUrls: r.image_urls?.length ? r.image_urls : (r.image_stored_name ? [`/uploads/${r.image_stored_name}`] : []),
+  audioUrls: r.audio_urls?.length ? r.audio_urls : (r.audio?.length ? r.audio.map((a: any) => a.url) : [])
 })
 
 export const mapDeliverable = (r: any) => ({
@@ -82,12 +85,14 @@ export const mapCourse = (
 
   return {
     id: r.id,
+    organizationId: r.organization_id || null,
+    organizationName: r.organization_name || null,
     userId: r.user_id,
     name: r.name,
-    category: r.category || '',
     description: r.description || '',
     status: r.status,
     productionNotes: r.production_notes || '',
+    assetNotes: r.asset_notes || '',
     estimatedMinutes: Number(r.estimated_minutes) || 1,
     estimatedPoints: Number(r.estimated_points) || 1000,
     actualPoints: r.actual_points == null ? null : Number(r.actual_points),
@@ -102,17 +107,20 @@ export const mapCourse = (
       createdAt: event.created_at
     })),
     createdAt: r.created_at,
+    updatedAt: r.updated_at,
     submittedAt: r.submitted_at || null,
     completedAt: r.completed_at || null,
     assets: mappedAssets,
     episodes: mappedEpisodes,
     instructors: instructors.map(mapInstructor),
+    digitalInstructorId: r.digital_instructor_id || null,
+    digitalInstructorSnapshot: r.digital_instructor_snapshot || null,
     deliverables: mappedDeliverables,
     creator: creatorUser ? mapUser(creatorUser) : (r.user_phone ? {
       id: r.user_id,
       phone: r.user_phone,
       role: r.user_role || 'USER',
-      organization: r.user_organization || '',
+      organization: '',
       wechat: r.user_wechat || '',
       contactName: r.user_contact_name || '',
       bio: r.user_bio || '',

+ 325 - 0
server/digital-instructors.ts

@@ -0,0 +1,325 @@
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
+import type pg from 'pg'
+import { randomUUID } from 'node:crypto'
+import { copyFile, unlink, writeFile } from 'node:fs/promises'
+import { extname, resolve } from 'node:path'
+import { z } from 'zod'
+
+type Dependencies = { pool: pg.Pool; auth: (req: FastifyRequest, reply: FastifyReply) => Promise<unknown>; uploadDir: string }
+const input = z.object({ name: z.string().trim().min(1).max(80), note: z.string().trim().max(1000).default('') })
+const uid = (req: FastifyRequest) => (req as any).user.id as string
+const selected = (req: FastifyRequest) => String(req.headers['x-workspace-id'] || 'personal')
+
+export function registerDigitalInstructors(app: FastifyInstance, { pool, auth, uploadDir }: Dependencies) {
+  async function membership(req: FastifyRequest, organizationId: string) {
+    return (await pool.query(`SELECT m.role,o.is_active FROM organization_members m JOIN organizations o ON o.id=m.organization_id WHERE m.organization_id=$1 AND m.user_id=$2`, [organizationId, uid(req)])).rows[0] as { role: string; is_active: boolean } | undefined
+  }
+  async function access(req: FastifyRequest, row: any) {
+    if ((req as any).user.role === 'ADMIN' && row.organization_id && selected(req) === row.organization_id) return 'MANAGER'
+    if (row.personal_user_id) return row.personal_user_id === uid(req) && selected(req) === 'personal' ? 'OWNER' : null
+    if (selected(req) !== row.organization_id) return null
+    const member = await membership(req, row.organization_id)
+    if (!member) return null
+    return member.role === 'MANAGER' ? 'MANAGER' : row.owner_id === uid(req) ? 'OWNER' : 'VIEWER'
+  }
+  async function get(id: string) { return (await pool.query('SELECT * FROM digital_instructors WHERE id=$1 AND deleted_at IS NULL', [id])).rows[0] }
+  async function files(id: string) { return (await pool.query('SELECT * FROM digital_instructor_files WHERE instructor_id=$1 AND is_active=TRUE ORDER BY kind,sort_order,created_at', [id])).rows }
+  async function dto(row: any) {
+    const fileRows = await files(row.id)
+    const usageCount = Number((await pool.query('SELECT COUNT(*)::int AS count FROM courses WHERE digital_instructor_id=$1', [row.id])).rows[0].count)
+    const creator = row.admin_creator_id
+      ? (await pool.query('SELECT contact_name FROM platform_admins WHERE id=$1', [row.admin_creator_id])).rows[0]
+      : (await pool.query('SELECT contact_name FROM users WHERE id=$1', [row.creator_id])).rows[0]
+    const owner = (await pool.query('SELECT contact_name FROM users WHERE id=$1', [row.owner_id])).rows[0]
+    return { id: row.id, name: row.name, note: row.note,
+      organizationId: row.organization_id, personalUserId: row.personal_user_id, ownerId: row.owner_id,
+      ownerName: owner?.contact_name || '未知用户', creatorId: row.admin_creator_id || row.creator_id, creatorName: creator?.contact_name || '未知用户',
+      createdAt: row.created_at, updatedAt: row.updated_at, sourceId: row.source_id, qualityStatus: row.quality_status, usageCount,
+      images: fileRows.filter(f => f.kind === 'IMAGE').map(f => ({ id: f.id, name: f.original_name, url: `/uploads/${f.stored_name}` })),
+      audio: fileRows.filter(f => f.kind === 'AUDIO').map(f => ({ id: f.id, name: f.original_name, url: `/uploads/${f.stored_name}` })),
+      ready: fileRows.some(f => f.kind === 'IMAGE') && fileRows.some(f => f.kind === 'AUDIO') }
+  }
+  async function event(id: string, actor: string, action: string, details: object = {}, client: pg.PoolClient | pg.Pool = pool) {
+    const admin = (await client.query('SELECT 1 FROM platform_admins WHERE id=$1', [actor])).rows[0]
+    await client.query('INSERT INTO digital_instructor_events(id,instructor_id,actor_id,admin_actor_id,action,details) VALUES($1,$2,$3,$4,$5,$6)',
+      [randomUUID(), id, admin ? null : actor, admin ? actor : null, action, JSON.stringify(details)])
+  }
+  app.get('/api/digital-instructors', { preHandler: auth }, async (req, reply) => {
+    const workspace = selected(req)
+    if (workspace !== 'personal' && (req as any).user.role !== 'ADMIN' && !await membership(req, workspace)) return reply.code(403).send({ message: '无权访问该组织资产库' })
+    const rows = workspace === 'personal'
+      ? (await pool.query('SELECT * FROM digital_instructors WHERE personal_user_id=$1 AND deleted_at IS NULL ORDER BY created_at DESC', [uid(req)])).rows
+      : (await pool.query('SELECT * FROM digital_instructors WHERE organization_id=$1 AND deleted_at IS NULL ORDER BY created_at DESC', [workspace])).rows
+    return { instructors: await Promise.all(rows.map(dto)) }
+  })
+  app.get('/api/digital-instructors/:id', { preHandler: auth }, async (req, reply) => {
+    const row = await get((req.params as any).id)
+    if (!row || !await access(req, row)) return reply.code(404).send({ message: '讲师不存在' })
+    return { instructor: await dto(row) }
+  })
+  app.get('/api/digital-instructors/:id/usage', { preHandler: auth }, async (req, reply) => {
+    const row = await get((req.params as any).id)
+    if (!row || !await access(req, row)) return reply.code(404).send({ message: '讲师不存在' })
+    const { rows } = await pool.query('SELECT id,name,status FROM courses WHERE digital_instructor_id=$1 ORDER BY created_at DESC', [row.id])
+    return { courses: rows }
+  })
+  app.post('/api/digital-instructors', { preHandler: auth }, async (req, reply) => {
+    if ((req as any).user.role === 'ADMIN') return reply.code(403).send({ message: '平台管理员不能创建普通用户资产' })
+    const workspace = selected(req)
+    if (workspace !== 'personal') {
+      const member = await membership(req, workspace)
+      if (!member || !member.is_active) return reply.code(403).send({ message: '无权在该组织创建讲师' })
+    }
+    const fields: Record<string, string> = {}
+    const pending: Array<{ kind: 'IMAGE' | 'AUDIO'; original: string; stored: string; size: number; mime: string }> = []
+    let committed = false
+    try {
+      for await (const part of req.parts()) {
+        if (part.type === 'field') { fields[part.fieldname] = String(part.value); continue }
+        const kind = part.fieldname === 'images' ? 'IMAGE' : part.fieldname === 'audio' ? 'AUDIO' : null
+        const mime = part.mimetype.toLowerCase()
+        if (!kind || !(kind === 'IMAGE' ? ['image/jpeg','image/png','image/webp'].includes(mime) : ['audio/wav','audio/x-wav','audio/mpeg','audio/mp4','audio/x-m4a','audio/webm','audio/ogg'].includes(mime))) throw new Error('照片或录音格式不支持')
+        if (kind === 'AUDIO' && pending.some(f => f.kind === 'AUDIO')) throw new Error('每位讲师只能上传一份录音')
+        const buffer = await part.toBuffer()
+        if (!buffer.length) throw new Error('上传文件不能为空')
+        if (buffer.length > (kind === 'IMAGE' ? 20 : 50) * 1024 * 1024) throw new Error(kind === 'IMAGE' ? '单张照片不能超过 20 MB' : '录音不能超过 50 MB')
+        const stored = `digital-instructor-${randomUUID()}${extname(part.filename).slice(0,10).toLowerCase()}`
+        await writeFile(resolve(uploadDir, stored), buffer)
+        pending.push({ kind, original: part.filename.slice(0,255), stored, size: buffer.length, mime })
+      }
+      const parsed = input.safeParse(fields)
+      if (!parsed.success) throw new Error('请填写讲师姓名')
+      const id = randomUUID()
+      const client = await pool.connect()
+      try {
+        await client.query('BEGIN')
+        await client.query(`INSERT INTO digital_instructors(id,organization_id,personal_user_id,owner_id,creator_id,name,note) VALUES($1,$2,$3,$4,$4,$5,$6)`, [id, workspace === 'personal' ? null : workspace, workspace === 'personal' ? uid(req) : null, uid(req), parsed.data.name, parsed.data.note])
+        const coverNewIndex = fields.coverNewIndex === undefined ? -1 : Number(fields.coverNewIndex)
+        for (const [i, f] of pending.entries()) await client.query(`INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [randomUUID(), id, f.kind, f.original, f.stored, f.size, f.mime, f.kind === 'IMAGE' && i === coverNewIndex ? -1 : i])
+        await event(id, uid(req), 'CREATED', {}, client)
+        await client.query('COMMIT')
+        committed = true
+      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+      return reply.code(201).send({ instructor: await dto(await get(id)) })
+    } catch (error: any) {
+      if (!committed) await Promise.all(pending.map(f => unlink(resolve(uploadDir,f.stored)).catch(() => {})))
+      if (error.code === '23505' && error.constraint === 'digital_instructors_org_name_unique') return reply.code(409).send({ message: '这个组织已有同名讲师,请换一个名字' })
+      return reply.code(400).send({ message: (error as Error).message || '创建失败' })
+    }
+  })
+  app.patch('/api/digital-instructors/:id', { preHandler: auth }, async (req, reply) => {
+    const row = await get((req.params as any).id)
+    const permission = row && await access(req, row)
+    if (!permission) return reply.code(404).send({ message: '讲师不存在' })
+    if (permission === 'VIEWER') return reply.code(403).send({ message: '只有所有者或组织管理员可以编辑' })
+    const parsed = input.safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: '讲师资料不完整' })
+    try {
+      await pool.query('UPDATE digital_instructors SET name=$1,note=$2,updated_at=NOW() WHERE id=$3', [parsed.data.name,parsed.data.note,row.id])
+    } catch (error: any) {
+      if (error.code === '23505' && error.constraint === 'digital_instructors_org_name_unique') return reply.code(409).send({ message: '这个组织已有同名讲师,请换一个名字' })
+      throw error
+    }
+    await event(row.id,uid(req),'UPDATED')
+    return { instructor: await dto(await get(row.id)) }
+  })
+  app.post('/api/digital-instructors/:id/manage', { preHandler: auth }, async (req, reply) => {
+    const row = await get((req.params as any).id)
+    const permission = row && await access(req, row)
+    if (!permission) return reply.code(404).send({ message: '讲师不存在' })
+    if (permission === 'VIEWER') return reply.code(403).send({ message: '只有所有者或组织管理员可以编辑' })
+    const fields: Record<string, string> = {}
+    const pending: Array<{ id: string; kind: 'IMAGE' | 'AUDIO'; original: string; stored: string; size: number; mime: string }> = []
+    let committed = false
+    try {
+      for await (const part of req.parts()) {
+        if (part.type === 'field') { fields[part.fieldname] = String(part.value); continue }
+        const kind = part.fieldname === 'images' ? 'IMAGE' : part.fieldname === 'audio' ? 'AUDIO' : null
+        const mime = part.mimetype.toLowerCase()
+        if (!kind || !(kind === 'IMAGE' ? ['image/jpeg','image/png','image/webp'].includes(mime) : ['audio/wav','audio/x-wav','audio/mpeg','audio/mp4','audio/x-m4a','audio/webm','audio/ogg'].includes(mime))) throw new Error('照片或录音格式不支持')
+        if (kind === 'AUDIO' && pending.some(f => f.kind === 'AUDIO')) throw new Error('每位讲师只能上传一份录音')
+        const buffer = await part.toBuffer()
+        if (!buffer.length || buffer.length > (kind === 'IMAGE' ? 20 : 50) * 1024 * 1024) throw new Error('文件为空或超过大小限制')
+        const stored = `digital-instructor-${randomUUID()}${extname(part.filename).slice(0,10).toLowerCase()}`
+        await writeFile(resolve(uploadDir,stored),buffer)
+        pending.push({ id: randomUUID(),kind,original:part.filename.slice(0,255),stored,size:buffer.length,mime })
+      }
+      const parsed = input.safeParse(fields)
+      if (!parsed.success) throw new Error('请填写正确的讲师名称和备注')
+      const removeIds = fields.removeImageIds ? JSON.parse(fields.removeImageIds) : []
+      if (!Array.isArray(removeIds) || removeIds.some((id: unknown) => typeof id !== 'string' || !z.string().uuid().safeParse(id).success)) throw new Error('照片移除参数不正确')
+      const ownerId = fields.ownerId || row.owner_id
+      if (ownerId !== row.owner_id && permission !== 'MANAGER') { const forbidden = new Error('只有组织管理员可以更换所有者') as Error & { statusCode?: number }; forbidden.statusCode = 403; throw forbidden }
+      if (ownerId !== row.owner_id && !(await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [row.organization_id,ownerId])).rows[0]) throw new Error('新所有者必须是本组织成员')
+      const client = await pool.connect()
+      try {
+        await client.query('BEGIN')
+        await client.query('SELECT id FROM digital_instructors WHERE id=$1 FOR UPDATE', [row.id])
+        const activeImages = (await client.query("SELECT id FROM digital_instructor_files WHERE instructor_id=$1 AND kind='IMAGE' AND is_active=TRUE ORDER BY sort_order,created_at", [row.id])).rows as { id: string }[]
+        if (removeIds.some((id: string) => !activeImages.some(image => image.id === id))) throw new Error('要移除的照片不存在')
+        const retained = activeImages.filter(image => !removeIds.includes(image.id)).map(image => image.id)
+        const newImages = pending.filter(file => file.kind === 'IMAGE')
+        if (retained.length + newImages.length > 19) throw new Error('最多保留 19 张照片')
+        const coverId = fields.coverImageId || (fields.coverNewIndex !== undefined ? newImages[Number(fields.coverNewIndex)]?.id : '')
+        if (coverId && ![...retained,...newImages.map(file => file.id)].includes(coverId)) throw new Error('封面照片不存在')
+        await client.query('UPDATE digital_instructors SET name=$1,note=$2,owner_id=$3,updated_at=NOW() WHERE id=$4', [parsed.data.name,parsed.data.note,ownerId,row.id])
+        if (removeIds.length) await client.query("UPDATE digital_instructor_files SET is_active=FALSE WHERE instructor_id=$1 AND id=ANY($2::uuid[]) AND kind='IMAGE'", [row.id,removeIds])
+        if (pending.some(file => file.kind === 'AUDIO')) await client.query("UPDATE digital_instructor_files SET is_active=FALSE WHERE instructor_id=$1 AND kind='AUDIO'", [row.id])
+        for (const [index,file] of pending.entries()) await client.query(`INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order)
+          VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [file.id,row.id,file.kind,file.original,file.stored,file.size,file.mime,index+activeImages.length])
+        const order = [...retained,...newImages.map(file => file.id)]
+        if (coverId) order.unshift(...order.splice(order.indexOf(coverId),1))
+        for (const [index,id] of order.entries()) await client.query('UPDATE digital_instructor_files SET sort_order=$1 WHERE id=$2', [index,id])
+        await event(row.id,uid(req),'MANAGED',{ removedImageIds: removeIds, addedImageCount: newImages.length, replacedAudio: pending.some(file => file.kind === 'AUDIO'), ownerChanged: ownerId !== row.owner_id },client)
+        await client.query('COMMIT')
+        committed = true
+      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+      return { instructor: await dto(await get(row.id)) }
+    } catch (error: any) {
+      if (!committed) await Promise.all(pending.map(file => unlink(resolve(uploadDir,file.stored)).catch(() => {})))
+      if (error.code === '23505' && error.constraint === 'digital_instructors_org_name_unique') return reply.code(409).send({ message: '这个组织已有同名讲师,请换一个名字' })
+      return reply.code(error.statusCode || 400).send({ message: error.message || '保存失败' })
+    }
+  })
+  app.patch('/api/digital-instructors/:id/owner', { preHandler: auth }, async (req, reply) => {
+    const row = await get((req.params as any).id)
+    if (!row || await access(req,row) !== 'MANAGER') return reply.code(403).send({ message: '只有组织管理员可以更换所有者' })
+    const parsed = z.object({ ownerId: z.string().uuid() }).safeParse(req.body)
+    if (!parsed.success || !(await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [row.organization_id,parsed.data.ownerId])).rows[0]) return reply.code(400).send({ message: '新所有者必须是本组织成员' })
+    await pool.query('UPDATE digital_instructors SET owner_id=$1,updated_at=NOW() WHERE id=$2', [parsed.data.ownerId,row.id])
+    await event(row.id,uid(req),'OWNER_CHANGED',{ from: row.owner_id, to: parsed.data.ownerId })
+    return { instructor: await dto(await get(row.id)) }
+  })
+  app.post('/api/digital-instructors/:id/copy-to-organization', { preHandler: auth }, async (req, reply) => {
+    if ((req as any).user.role === 'ADMIN') return reply.code(403).send({ message: '平台管理员没有个人资产' })
+    const row = await get((req.params as any).id)
+    if (!row || row.personal_user_id !== uid(req) || selected(req) !== 'personal') return reply.code(404).send({ message: '个人讲师不存在' })
+    const parsed = z.object({ organizationId: z.string().uuid() }).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: '请选择组织' })
+    const member = await membership(req,parsed.data.organizationId)
+    if (!member || !member.is_active) return reply.code(403).send({ message: '您不属于该组织或组织已停用' })
+    const originalFiles = await files(row.id)
+    const copies: Array<{ original: any; stored: string }> = []
+    let committed = false
+    try {
+      for (const f of originalFiles) {
+        const stored = `digital-instructor-${randomUUID()}${extname(f.stored_name)}`
+        await copyFile(resolve(uploadDir,f.stored_name),resolve(uploadDir,stored))
+        copies.push({ original: f, stored })
+      }
+      const id = randomUUID()
+      const client = await pool.connect()
+      try {
+        await client.query('BEGIN')
+        await client.query(`INSERT INTO digital_instructors(id,organization_id,owner_id,creator_id,source_id,name,note) VALUES($1,$2,$3,$3,$4,$5,$6)`, [id,parsed.data.organizationId,uid(req),row.id,row.name,row.note])
+        for (const [i, c] of copies.entries()) await client.query(`INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [randomUUID(),id,c.original.kind,c.original.original_name,c.stored,c.original.size,c.original.mime_type,i])
+        await event(id,uid(req),'COPIED_FROM_PERSONAL',{ sourceId: row.id },client)
+        await client.query('COMMIT')
+        committed = true
+      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+      return reply.code(201).send({ instructor: await dto(await get(id)) })
+    } catch (error: any) {
+      if (!committed) await Promise.all(copies.map(c => unlink(resolve(uploadDir,c.stored)).catch(() => {})))
+      if (error.code === '23505' && error.constraint === 'digital_instructors_org_name_unique') return reply.code(409).send({ message: '这个组织已有同名讲师,请先修改讲师姓名' })
+      return reply.code(500).send({ message: '复制失败,请重试' })
+    }
+  })
+  app.post('/api/digital-instructors/:id/duplicate', { preHandler: auth }, async (req, reply) => {
+    const row = await get((req.params as any).id)
+    if (!row || !await access(req, row)) return reply.code(404).send({ message: '讲师不存在' })
+    if (row.organization_id && !(await pool.query('SELECT is_active FROM organizations WHERE id=$1', [row.organization_id])).rows[0]?.is_active) {
+      return reply.code(409).send({ message: '组织已停用,不能复制讲师' })
+    }
+    const originalFiles = await files(row.id)
+    const copies: Array<{ original: any; stored: string }> = []
+    let committed = false
+    try {
+      for (const f of originalFiles) {
+        const stored = `digital-instructor-${randomUUID()}${extname(f.stored_name)}`
+        await copyFile(resolve(uploadDir, f.stored_name), resolve(uploadDir, stored))
+        copies.push({ original: f, stored })
+      }
+      const id = randomUUID()
+      const client = await pool.connect()
+      try {
+        await client.query('BEGIN')
+        const base = row.name.slice(0, 72)
+        let copyName = `${base}(副本)`
+        if (row.organization_id) {
+          await client.query('SELECT id FROM organizations WHERE id=$1 FOR UPDATE', [row.organization_id])
+          for (let number = 2; (await client.query(`SELECT 1 FROM digital_instructors WHERE organization_id=$1 AND deleted_at IS NULL AND lower(btrim(name))=lower(btrim($2)) LIMIT 1`, [row.organization_id,copyName])).rows[0]; number++) {
+            copyName = `${base}(副本${number})`
+          }
+        }
+        const admin = (req as any).user.role === 'ADMIN'
+        await client.query(`INSERT INTO digital_instructors(id,organization_id,personal_user_id,owner_id,creator_id,admin_creator_id,source_id,name,note)
+          VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [id,row.organization_id,row.personal_user_id ? uid(req) : null,admin ? row.owner_id : uid(req),admin ? null : uid(req),admin ? uid(req) : null,row.id,copyName,row.note])
+        for (const [i,c] of copies.entries()) await client.query(`INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order)
+          VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [randomUUID(),id,c.original.kind,c.original.original_name,c.stored,c.original.size,c.original.mime_type,i])
+        await event(id,uid(req),'DUPLICATED',{ sourceId: row.id },client)
+        await client.query('COMMIT')
+        committed = true
+      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+      return reply.code(201).send({ instructor: await dto(await get(id)) })
+    } catch {
+      if (!committed) await Promise.all(copies.map(c => unlink(resolve(uploadDir,c.stored)).catch(() => {})))
+      return reply.code(500).send({ message: '复制失败,请重试' })
+    }
+  })
+  app.post('/api/digital-instructors/:id/files', { preHandler: auth }, async (req, reply) => {
+    const row = await get((req.params as any).id)
+    const permission = row && await access(req,row)
+    if (!permission) return reply.code(404).send({ message: '讲师不存在' })
+    if (permission === 'VIEWER') return reply.code(403).send({ message: '只有所有者或组织管理员可以编辑' })
+    const pending: Array<{ kind: 'IMAGE' | 'AUDIO'; original: string; stored: string; size: number; mime: string }> = []
+    let committed = false
+    try {
+      for await (const part of req.parts()) {
+        if (part.type !== 'file') continue
+        const kind = part.fieldname === 'images' ? 'IMAGE' : part.fieldname === 'audio' ? 'AUDIO' : null
+        const mime = part.mimetype.toLowerCase()
+        if (!kind || !(kind === 'IMAGE' ? ['image/jpeg','image/png','image/webp'].includes(mime) : ['audio/wav','audio/x-wav','audio/mpeg','audio/mp4','audio/x-m4a','audio/webm','audio/ogg'].includes(mime))) throw new Error('照片或录音格式不支持')
+        if (kind === 'AUDIO' && pending.some(f => f.kind === 'AUDIO')) throw new Error('每位讲师只能上传一份录音')
+        const buffer = await part.toBuffer()
+        if (!buffer.length) throw new Error('文件不能为空')
+        if (buffer.length > (kind === 'IMAGE' ? 20 : 50) * 1024 * 1024) throw new Error(kind === 'IMAGE' ? '单张照片不能超过 20 MB' : '录音不能超过 50 MB')
+        const stored = `digital-instructor-${randomUUID()}${extname(part.filename).slice(0,10).toLowerCase()}`
+        await writeFile(resolve(uploadDir,stored),buffer)
+        pending.push({ kind,original:part.filename.slice(0,255),stored,size:buffer.length,mime })
+      }
+      if (!pending.length) throw new Error('请选择新文件')
+      const client = await pool.connect()
+      try {
+        await client.query('BEGIN')
+        for (const kind of new Set(pending.map(f => f.kind))) await client.query('UPDATE digital_instructor_files SET is_active=FALSE WHERE instructor_id=$1 AND kind=$2', [row.id,kind])
+        for (const [i,f] of pending.entries()) await client.query('INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order) VALUES($1,$2,$3,$4,$5,$6,$7,$8)', [randomUUID(),row.id,f.kind,f.original,f.stored,f.size,f.mime,i])
+        await client.query('UPDATE digital_instructors SET updated_at=NOW() WHERE id=$1', [row.id])
+        await event(row.id,uid(req),'FILES_REPLACED',{ kinds:[...new Set(pending.map(f => f.kind))] },client)
+        await client.query('COMMIT')
+        committed = true
+      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+      return { instructor: await dto(await get(row.id)) }
+    } catch (error) {
+      if (!committed) await Promise.all(pending.map(f => unlink(resolve(uploadDir,f.stored)).catch(() => {})))
+      return reply.code(400).send({ message:(error as Error).message || '上传失败' })
+    }
+  })
+  app.delete('/api/digital-instructors/:id', { preHandler: auth }, async (req, reply) => {
+    const row = await get((req.params as any).id)
+    const permission = row && await access(req,row)
+    if (!permission) return reply.code(404).send({ message: '讲师不存在' })
+    if (row.organization_id && permission !== 'MANAGER') return reply.code(403).send({ message: '只有组织管理员可以删除' })
+    const client = await pool.connect()
+    try {
+      await client.query('BEGIN')
+      await client.query('SELECT id FROM digital_instructors WHERE id=$1 FOR UPDATE', [row.id])
+      const used = (await client.query('SELECT 1 FROM courses WHERE digital_instructor_id=$1 LIMIT 1', [row.id])).rows[0]
+      if (used) { await client.query('ROLLBACK'); return reply.code(409).send({ message: '已有课程使用该讲师,请先为相关课程更换讲师' }) }
+      await event(row.id,uid(req),'DELETED',{ name: row.name },client)
+      await client.query('UPDATE digital_instructors SET deleted_at=NOW(),updated_at=NOW() WHERE id=$1', [row.id])
+      await client.query('COMMIT')
+    } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+    return { ok: true }
+  })
+  return { dto, get, files }
+}

+ 456 - 195
server/index.ts

@@ -3,18 +3,25 @@ import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'
 import cors from '@fastify/cors'
 import multipart from '@fastify/multipart'
 import staticPlugin from '@fastify/static'
-import { createWriteStream } from 'node:fs'
-import { mkdir, readFile, unlink } from 'node:fs/promises'
+import { createReadStream, createWriteStream, existsSync } from 'node:fs'
+import { copyFile, mkdir, mkdtemp, readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
 import { pipeline } from 'node:stream/promises'
-import { resolve, extname, dirname, join } from 'node:path'
+import { resolve, extname, dirname, join, basename } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { randomBytes, randomUUID, scrypt as scryptCb, timingSafeEqual, createHash } from 'node:crypto'
+import { homedir, tmpdir } from 'node:os'
+import { execFile } from 'node:child_process'
 import { promisify } from 'node:util'
 import { z } from 'zod'
+import sanitizeHtml from 'sanitize-html'
 import { mapCourse, mapDeliverable, mapInstructor, mapUser, pool } from './db.js'
 import { COURSE_TEMPLATES, populateCourseFromTemplate } from './templates.js'
+import { registerOrganizations } from './organizations.js'
+import { registerDigitalInstructors } from './digital-instructors.js'
+import { extractPptxSpeakerNotes } from './pptx-notes.js'
 
 const scrypt = promisify(scryptCb)
+const execFileAsync = promisify(execFile)
 const app = Fastify({ logger: true })
 const port = Number(process.env.PORT || 3001)
 const uploadDir = resolve(process.env.UPLOAD_DIR || './uploads')
@@ -25,39 +32,54 @@ await mkdir(uploadDir, { recursive: true })
 const here = dirname(fileURLToPath(import.meta.url))
 await pool.query(await readFile(join(here, 'schema.sql'), 'utf8'))
 
-// 检查是否存在管理员账号,若不存在则创建默认演示管理员
+// 旧版管理员从 users 迁出。若曾拥有业务数据,停止启动并要求人工处理,避免级联删除。
 async function initDefaultAdmin() {
+  const client = await pool.connect()
   try {
-    const { rows } = await pool.query("SELECT id FROM users WHERE role = 'ADMIN' LIMIT 1")
-    if (rows.length === 0) {
-      const defaultPhone = process.env.ADMIN_PHONE || '18888888888'
-      const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123456'
-      const existing = await pool.query('SELECT id FROM users WHERE phone = $1', [defaultPhone])
-      const salt = randomBytes(16).toString('hex')
-      const key = (await scrypt(defaultPassword, salt, 64)) as Buffer
-      const hash = 'scrypt$' + salt + '$' + key.toString('hex')
-
-      if (existing.rows.length > 0) {
-        await pool.query("UPDATE users SET role = 'ADMIN' WHERE id = $1", [existing.rows[0].id])
-        app.log.info(`Updated existing user ${defaultPhone} to ADMIN role`)
-      } else {
-        const id = randomUUID()
-        await pool.query(
-          "INSERT INTO users(id, phone, password_hash, role, contact_name, organization, bio) VALUES($1, $2, $3, 'ADMIN', $4, $5, $6)",
-          [id, defaultPhone, hash, '系统管理员', '星痕课程工坊管理中心', '默认初始化管理员账号']
-        )
-        app.log.info(`Initialized default admin account for ${defaultPhone}`)
-      }
+    await client.query('BEGIN')
+    const legacy = await client.query("SELECT * FROM users WHERE role = 'ADMIN' FOR UPDATE")
+    for (const row of legacy.rows) {
+      const owned = await client.query(`SELECT
+        EXISTS(SELECT 1 FROM courses WHERE user_id=$1) OR
+        EXISTS(SELECT 1 FROM organization_members WHERE user_id=$1) OR
+        EXISTS(SELECT 1 FROM digital_instructors WHERE owner_id=$1 OR creator_id=$1 OR personal_user_id=$1) AS has_business_data`, [row.id])
+      if (owned.rows[0].has_business_data) throw new Error(`Legacy admin ${row.id} owns business data; manual migration required`)
+      const auditIds = (await client.query('SELECT id FROM audit_events WHERE actor_id=$1', [row.id])).rows.map(r => r.id)
+      const ledgerIds = (await client.query("SELECT id FROM points_ledger WHERE actor_id=$1 AND NOT (owner_type='PERSONAL' AND owner_id=$1)", [row.id])).rows.map(r => r.id)
+      const applicationIds = (await client.query('SELECT id FROM organization_applications WHERE reviewed_by=$1', [row.id])).rows.map(r => r.id)
+      const instructorEventIds = (await client.query('SELECT id FROM digital_instructor_events WHERE actor_id=$1', [row.id])).rows.map(r => r.id)
+      // 外键先释放,再用新的管理员外键恢复历史操作人的身份。
+      await client.query('UPDATE audit_events SET actor_id=NULL WHERE actor_id=$1', [row.id])
+      await client.query('UPDATE points_ledger SET actor_id=NULL WHERE actor_id=$1', [row.id])
+      await client.query('UPDATE organization_applications SET reviewed_by=NULL WHERE reviewed_by=$1', [row.id])
+      await client.query('UPDATE digital_instructor_events SET actor_id=NULL WHERE actor_id=$1', [row.id])
+      await client.query("DELETE FROM points_ledger WHERE owner_type='PERSONAL' AND owner_id=$1", [row.id])
+      await client.query('DELETE FROM users WHERE id=$1', [row.id])
+      await client.query('INSERT INTO platform_admins(id,phone,password_hash,contact_name,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6)',
+        [row.id,row.phone,row.password_hash,row.contact_name,row.created_at,row.updated_at])
+      await client.query('UPDATE audit_events SET admin_actor_id=$1 WHERE id=ANY($2::uuid[])', [row.id,auditIds])
+      await client.query('UPDATE points_ledger SET admin_actor_id=$1 WHERE id=ANY($2::uuid[])', [row.id,ledgerIds])
+      await client.query('UPDATE organization_applications SET reviewed_by_admin=$1 WHERE id=ANY($2::uuid[])', [row.id,applicationIds])
+      await client.query('UPDATE digital_instructor_events SET admin_actor_id=$1 WHERE id=ANY($2::uuid[])', [row.id,instructorEventIds])
     }
-  } catch (err) {
-    app.log.error(err, 'Failed to initialize default admin account')
-  }
+    const count = await client.query('SELECT 1 FROM platform_admins LIMIT 1')
+    if (!count.rows[0]) {
+      const phone = process.env.ADMIN_PHONE
+      const password = process.env.ADMIN_PASSWORD
+      if (!phone || !password) throw new Error('ADMIN_PHONE and ADMIN_PASSWORD are required to create the first platform administrator')
+      const existing = await client.query('SELECT 1 FROM users WHERE phone=$1', [phone])
+      if (existing.rows[0]) throw new Error('ADMIN_PHONE already belongs to an ordinary user')
+      await client.query('INSERT INTO platform_admins(id,phone,password_hash,contact_name) VALUES($1,$2,$3,$4)',
+        [randomUUID(),phone,await hashPassword(password),'系统管理员'])
+    }
+    await client.query('COMMIT')
+  } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
 }
 await initDefaultAdmin()
 
 await app.register(cors, { origin: process.env.NODE_ENV === 'production' ? false : true })
 await app.register(multipart, { limits: { fileSize: maxBytes, files: 20, fields: 30 } })
-await app.register(staticPlugin, { root: uploadDir, prefix: '/uploads/', decorateReply: false })
+await app.register(staticPlugin, { root: uploadDir, serve: false })
 await app.register(staticPlugin, { root: sampleDir, prefix: '/sample/', decorateReply: false })
 
 const sha = (v: string) => createHash('sha256').update(v).digest('hex')
@@ -76,9 +98,9 @@ async function verifyPassword(p: string, stored: string) {
   return a.length === b.length && timingSafeEqual(a, b)
 }
 
-async function session(uid: string) {
+async function session(uid: string, admin = false) {
   const token = randomBytes(32).toString('hex')
-  await pool.query("INSERT INTO sessions(token_hash, user_id, expires_at) VALUES($1, $2, NOW() + INTERVAL '30 days')", [
+  await pool.query(admin ? "INSERT INTO platform_admin_sessions(token_hash, admin_id, expires_at) VALUES($1, $2, NOW() + INTERVAL '30 days')" : "INSERT INTO sessions(token_hash, user_id, expires_at) VALUES($1, $2, NOW() + INTERVAL '30 days')", [
     sha(token),
     uid
   ])
@@ -94,15 +116,25 @@ async function auth(req: FastifyRequest, reply: FastifyReply) {
   )
   if (!rows[0]) return reply.code(401).send({ message: '登录已过期,请重新登录' })
   ;(req as any).user = rows[0]
+  if (rows[0].must_change_password && !['/api/me', '/api/me/password', '/api/auth/logout'].includes(req.url.split('?')[0])) {
+    return reply.code(403).send({ message: '请先修改初始密码' })
+  }
 }
 
 async function adminAuth(req: FastifyRequest, reply: FastifyReply) {
-  await auth(req, reply)
-  if (reply.sent) return
-  const user = (req as any).user
-  if (!user || user.role !== 'ADMIN') {
-    return reply.code(403).send({ message: '权限不足:仅管理员可访问此接口' })
+  const token = req.headers.authorization?.replace(/^Bearer\s+/i, '')
+  if (!token) return reply.code(401).send({ message: '请先登录管理后台' })
+  const { rows } = await pool.query('SELECT a.* FROM platform_admin_sessions s JOIN platform_admins a ON a.id=s.admin_id WHERE s.token_hash=$1 AND s.expires_at>NOW()', [sha(token)])
+  if (!rows[0]) return reply.code(401).send({ message: '管理员登录已过期' })
+  ;(req as any).user = { ...rows[0], role: 'ADMIN' }
+}
+async function accountAuth(req: FastifyRequest, reply: FastifyReply) {
+  const token = req.headers.authorization?.replace(/^Bearer\s+/i, '')
+  if (token) {
+    const { rows } = await pool.query('SELECT a.* FROM platform_admin_sessions s JOIN platform_admins a ON a.id=s.admin_id WHERE s.token_hash=$1 AND s.expires_at>NOW()', [sha(token)])
+    if (rows[0]) { (req as any).user = { ...rows[0], role: 'ADMIN' }; return }
   }
+  return auth(req, reply)
 }
 
 const userId = (r: any) => r.user.id
@@ -114,6 +146,10 @@ const phonePassword = z.object({
 const fullCourse = async (row: any, includeCreator = false) => {
   const latest = await pool.query('SELECT * FROM courses WHERE id = $1', [row.id])
   row = { ...row, ...(latest.rows[0] || {}) }
+  if (row.organization_id) {
+    const organization = await pool.query('SELECT name FROM organizations WHERE id=$1', [row.organization_id])
+    row.organization_name = organization.rows[0]?.name || ''
+  }
   const [a, i, instructorImages, d, e, progress] = await Promise.all([
     pool.query('SELECT * FROM course_assets WHERE course_id = $1 ORDER BY created_at', [row.id]),
     pool.query('SELECT * FROM instructors WHERE course_id = $1 ORDER BY created_at', [row.id]),
@@ -141,12 +177,32 @@ const fullCourse = async (row: any, includeCreator = false) => {
     const u = await pool.query('SELECT * FROM users WHERE id = $1', [row.user_id])
     creatorUser = u.rows[0] || null
   }
-  const instructors = i.rows.map((instructor) => ({
+  let instructors = i.rows.map((instructor) => ({
     ...instructor,
     image_urls: instructorImages.rows
       .filter((image) => image.instructor_id === instructor.id)
       .map((image) => `/uploads/${image.stored_name}`)
   }))
+  if (row.digital_instructor_id) {
+    const digital = await pool.query('SELECT * FROM digital_instructors WHERE id=$1', [row.digital_instructor_id])
+    if (digital.rows[0]) {
+      const imageRows = await pool.query("SELECT stored_name FROM digital_instructor_files WHERE instructor_id=$1 AND kind='IMAGE' AND is_active=TRUE ORDER BY sort_order", [row.digital_instructor_id])
+      const audioRows = await pool.query("SELECT stored_name FROM digital_instructor_files WHERE instructor_id=$1 AND kind='AUDIO' AND is_active=TRUE ORDER BY sort_order", [row.digital_instructor_id])
+      instructors = [{
+        ...digital.rows[0],
+        image_urls: imageRows.rows.map(f => `/uploads/${f.stored_name}`),
+        audio_urls: audioRows.rows.map(f => `/uploads/${f.stored_name}`)
+      }]
+    }
+  }
+  if (row.status !== 'DRAFT' && row.digital_instructor_snapshot) {
+    const snapshot = row.digital_instructor_snapshot
+    instructors = [{
+      ...snapshot,
+      image_urls: (snapshot.images || []).map((image: any) => image.url),
+      audio_urls: (snapshot.audio || []).map((audio: any) => audio.url)
+    }]
+  }
   return mapCourse({ ...row, progress_events: progress.rows }, a.rows, instructors, d.rows, creatorUser, episodesRows)
 }
 
@@ -168,23 +224,211 @@ async function refreshCoursePoints(courseId: string) {
 }
 
 const ownedCourse = async (id: string, uid: string) =>
-  (await pool.query('SELECT * FROM courses WHERE id = $1 AND user_id = $2', [id, uid])).rows[0]
+  (await pool.query(`SELECT c.* FROM courses c WHERE c.id = $1 AND
+    ((c.organization_id IS NULL AND c.user_id=$2) OR (c.organization_id IS NOT NULL AND EXISTS (
+      SELECT 1 FROM organization_members m WHERE m.organization_id=c.organization_id AND m.user_id=$2
+      AND (c.user_id=$2 OR m.role='MANAGER'))))`, [id, uid])).rows[0]
+
+registerOrganizations(app, { pool, auth, adminAuth, hashPassword, mapUser })
+const digitalInstructors = registerDigitalInstructors(app, { pool, auth: accountAuth, uploadDir })
+
+async function courseSnapshot(id: string) {
+  const { rows: [course] } = await pool.query('SELECT * FROM courses WHERE id=$1', [id])
+  if (!course) return null
+  const [episodes, assets, instructors, deliverables] = await Promise.all([
+    pool.query('SELECT * FROM episodes WHERE course_id=$1 ORDER BY episode_number', [id]),
+    pool.query('SELECT id,episode_id,original_name,size,mime_type FROM course_assets WHERE course_id=$1 ORDER BY created_at', [id]),
+    pool.query('SELECT id,name,organization,introduction FROM instructors WHERE course_id=$1 ORDER BY created_at', [id]),
+    pool.query('SELECT id,episode_id,original_name,size,mime_type FROM course_deliverables WHERE course_id=$1 ORDER BY created_at', [id])
+  ])
+  return { course, episodes: episodes.rows, assets: assets.rows, instructors: instructors.rows, deliverables: deliverables.rows }
+}
+
+app.addHook('onRequest', async (req) => {
+  if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(req.method)) return
+  const match = req.url.match(/^\/api\/(?:admin\/)?courses\/([0-9a-f-]{36})(?:\/|\?|$)/i)
+  if (!match) return
+  ;(req as any).auditCourseId = match[1]
+  ;(req as any).auditBefore = await courseSnapshot(match[1])
+})
+
+app.addHook('onSend', async (req, reply, payload) => {
+  const id = (req as any).auditCourseId as string | undefined
+  const before = (req as any).auditBefore
+  if (!id || !before || reply.statusCode >= 400) return payload
+  const authorization = req.headers.authorization?.replace(/^Bearer\s+/i, '')
+  if (!authorization) return payload
+  const actor = await pool.query(`SELECT s.user_id FROM sessions s WHERE s.token_hash=$1 AND s.expires_at>NOW()`, [sha(authorization)])
+  const adminActor = actor.rows[0] ? null : (await pool.query(`SELECT s.admin_id FROM platform_admin_sessions s WHERE s.token_hash=$1 AND s.expires_at>NOW()`, [sha(authorization)])).rows[0]
+  if (!actor.rows[0] && !adminActor) return payload
+  const after = await courseSnapshot(id)
+  await pool.query(`INSERT INTO audit_events(id,organization_id,course_id,actor_id,admin_actor_id,subject_user_id,action,details)
+    VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [randomUUID(), before.course.organization_id, id, actor.rows[0]?.user_id || null,
+    adminActor?.admin_id || null, before.course.user_id, `${req.method} ${req.routeOptions.url}`, JSON.stringify({ before, after })])
+  return payload
+})
+
+app.addHook('preHandler', async (req, reply) => {
+  // 只读 GET 请求不在此处因工作区暂不匹配而强阻断,以便前端可感知课程真实工作区并自动校准对齐(后续业务层已由 ownedCourse 确保访问安全)
+  if (req.method === 'GET') return
+
+  const match = req.url.match(/^\/api\/courses\/([0-9a-f-]{36})(?:\/|\?|$)/i)
+  if (!match) return
+  const { rows } = await pool.query('SELECT organization_id FROM courses WHERE id=$1', [match[1]])
+  if (!rows[0]) return
+  const selected = req.headers['x-workspace-id']
+  const workspace = typeof selected === 'string' && selected !== 'personal' ? selected : null
+  if (rows[0].organization_id !== workspace) return reply.code(404).send({ message: '请切换到课程所属工作区' })
+})
+
+async function courseOperator(req: FastifyRequest, reply: FastifyReply) {
+  await accountAuth(req, reply)
+  if (reply.sent) return
+  if ((req as any).user.role === 'ADMIN') return
+  const id = (req.params as any).id
+  const { rows } = await pool.query(`SELECT 1 FROM courses c JOIN organization_members m ON m.organization_id=c.organization_id
+    WHERE c.id=$1 AND m.user_id=$2 AND m.role='MANAGER'`, [id, userId(req)])
+  if (!rows[0]) return reply.code(403).send({ message: '没有该课程的组织管理权限' })
+}
+
+app.get('/api/files/:stored', { preHandler: accountAuth }, async (req, reply) => {
+  const stored = (req.params as any).stored as string
+  const digitalFile = await pool.query(`SELECT f.stored_name,d.organization_id,d.personal_user_id FROM digital_instructor_files f JOIN digital_instructors d ON d.id=f.instructor_id WHERE f.stored_name=$1`, [stored])
+  if (digitalFile.rows[0]) {
+    const file = digitalFile.rows[0]
+    const permitted = file.personal_user_id === userId(req) || (file.organization_id && ((req as any).user.role === 'ADMIN' || (await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [file.organization_id,userId(req)])).rows[0]))
+    if (!permitted) return reply.code(403).send({ message: '无权访问该文件' })
+    return reply.sendFile(stored, uploadDir)
+  }
+  const { rows } = await pool.query(`SELECT c.user_id,c.organization_id FROM courses c WHERE
+    EXISTS(SELECT 1 FROM course_assets a WHERE a.course_id=c.id AND a.stored_name=$1)
+    OR EXISTS(SELECT 1 FROM course_deliverables d WHERE d.course_id=c.id AND d.stored_name=$1)
+    OR EXISTS(SELECT 1 FROM instructors i WHERE i.course_id=c.id AND i.image_stored_name=$1)
+    OR EXISTS(SELECT 1 FROM instructor_images ii JOIN instructors i ON i.id=ii.instructor_id WHERE i.course_id=c.id AND ii.stored_name=$1)
+    LIMIT 1`, [stored])
+  const course = rows[0]
+  if (!course) return reply.code(404).send({ message: '文件不存在' })
+  if ((req as any).user.role !== 'ADMIN') {
+    const member = await pool.query(`SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2 AND role='MANAGER'`,
+      [course.organization_id, userId(req)])
+    const ownerAllowed = course.user_id === userId(req) && (!course.organization_id ||
+      (await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [course.organization_id, userId(req)])).rows[0])
+    if (!member.rows[0] && !ownerAllowed) return reply.code(403).send({ message: '无权访问该文件' })
+  }
+  return reply.sendFile(stored, uploadDir)
+})
+
+const officePreviewExtensions = new Set(['.pptx', '.ppt', '.docx', '.doc'])
+const previewJobs = new Map<string, Promise<string>>()
+const previewCacheVersion = 'v2'
+const bundledSoffice = join(homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/bin/override/soffice')
+const sofficeCommand = process.env.SOFFICE_PATH || (existsSync(bundledSoffice) ? bundledSoffice : 'soffice')
+
+async function officePreview(stored: string) {
+  const previewDir = resolve(uploadDir, '.previews')
+  const destination = resolve(previewDir, `${stored}.${previewCacheVersion}.pdf`)
+  if (await stat(destination).then((s) => s.size > 0).catch(() => false)) return destination
+  const existing = previewJobs.get(stored)
+  if (existing) return existing
+  const job = (async () => {
+    await mkdir(previewDir, { recursive: true })
+    const workDir = await mkdtemp(join(tmpdir(), 'xinghen-preview-'))
+    const staging = resolve(previewDir, stored + '.' + randomUUID() + '.tmp')
+    try {
+      const input = join(workDir, basename(stored))
+      await copyFile(resolve(uploadDir, stored), input)
+      let conversionEnv = process.env
+      if (process.platform === 'darwin' && !process.env.FONTCONFIG_FILE) {
+        const fontConfig = join(workDir, 'fonts.conf')
+        await writeFile(fontConfig, `<?xml version="1.0"?>
+<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
+<fontconfig>
+  <dir>/System/Library/Fonts</dir><dir>/System/Library/Fonts/Supplemental</dir><dir>/Library/Fonts</dir>
+  <cachedir>${join(workDir, 'font-cache')}</cachedir>
+  <match target="pattern"><test name="family" compare="eq"><string>微软雅黑</string></test><edit name="family" mode="assign"><string>Hiragino Sans GB</string></edit></match>
+</fontconfig>`)
+        conversionEnv = { ...process.env, FONTCONFIG_FILE: fontConfig }
+      }
+      await execFileAsync(sofficeCommand, [
+        '-env:UserInstallation=' + new URL('profile/', `file://${workDir}/`).href,
+        '--headless', '--convert-to', 'pdf', '--outdir', workDir, input
+      ], { timeout: 120_000, maxBuffer: 1024 * 1024, env: conversionEnv })
+      const converted = join(workDir, basename(stored, extname(stored)) + '.pdf')
+      if (!(await stat(converted).then((s) => s.size > 0).catch(() => false))) throw new Error('文档转换没有生成预览文件')
+      await copyFile(converted, staging)
+      await rename(staging, destination)
+      return destination
+    } finally {
+      await unlink(staging).catch(() => {})
+      await rm(workDir, { recursive: true, force: true })
+    }
+  })()
+  previewJobs.set(stored, job)
+  try { return await job } finally { previewJobs.delete(stored) }
+}
+
+app.get('/api/courses/:id/assets/:assetId/preview', { preHandler: accountAuth }, async (req, reply) => {
+  const { id, assetId } = req.params as { id: string; assetId: string }
+  const { rows } = await pool.query(`SELECT a.stored_name, a.original_name, c.user_id, c.organization_id
+    FROM course_assets a JOIN courses c ON c.id=a.course_id WHERE a.id=$1 AND a.course_id=$2`, [assetId, id])
+  const asset = rows[0]
+  if (!asset) return reply.code(404).send({ message: '素材不存在' })
+  if ((req as any).user.role !== 'ADMIN') {
+    const membership = asset.organization_id ? await pool.query(
+      'SELECT role FROM organization_members WHERE organization_id=$1 AND user_id=$2',
+      [asset.organization_id, userId(req)]
+    ) : null
+    const member = membership?.rows[0]
+    const allowed = asset.organization_id
+      ? !!member && (member.role === 'MANAGER' || asset.user_id === userId(req))
+      : asset.user_id === userId(req)
+    if (!allowed) return reply.code(403).send({ message: '无权预览该素材' })
+  }
+  if (!officePreviewExtensions.has(extname(asset.original_name).toLowerCase())) {
+    return reply.code(415).send({ message: '该文件无需转换预览' })
+  }
+  try {
+    const path = await officePreview(asset.stored_name)
+    return reply.type('application/pdf').header('Cache-Control', 'private, no-store').send(createReadStream(path))
+  } catch (error) {
+    req.log.error({ err: error, assetId }, 'office preview failed')
+    return reply.code(503).send({ message: '预览生成失败,请下载原文件查看' })
+  }
+})
 
 app.get('/api/health', async () => ({ ok: true }))
 
 // ==================== 认证相关 ====================
 
 app.post('/api/auth/register', async (req, reply) => {
-  const p = phonePassword.safeParse(req.body)
+  const p = phonePassword.extend({
+    contactName: z.string().trim().min(1, '请填写姓名').max(80, '姓名不能超过 80 个字'),
+    inviteToken: z.string().optional()
+  }).safeParse(req.body)
   if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
+  if ((await pool.query('SELECT 1 FROM platform_admins WHERE phone=$1', [p.data.phone])).rows[0]) return reply.code(409).send({ message: '该手机号不可注册普通用户' })
+  let organizationId: string | null = null
+  if (p.data.inviteToken) {
+    const invite = await pool.query('SELECT id FROM organizations WHERE invite_token_hash=$1 AND is_active=TRUE', [sha(p.data.inviteToken)])
+    if (!invite.rows[0]) return reply.code(404).send({ message: '注册链接无效或已停用' })
+    organizationId = invite.rows[0].id
+  }
   try {
     const id = randomUUID()
     const hash = await hashPassword(p.data.password)
-    const { rows } = await pool.query(
-      'INSERT INTO users(id, phone, password_hash, role, points_balance) VALUES($1, $2, $3, $4, 10000) RETURNING *',
-      [id, p.data.phone, hash, 'USER']
-    )
-    return reply.code(201).send({ token: await session(id), user: mapUser(rows[0]) })
+    const client = await pool.connect()
+    let user: any
+    try {
+      await client.query('BEGIN')
+      const { rows } = await client.query(
+        'INSERT INTO users(id, phone, password_hash, role, contact_name, points_balance) VALUES($1, $2, $3, $4, $5, 10000) RETURNING *',
+        [id, p.data.phone, hash, 'USER', p.data.contactName]
+      )
+      user = rows[0]
+      if (organizationId) await client.query('INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2)', [organizationId, id])
+      await client.query('COMMIT')
+    } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+    return reply.code(201).send({ token: await session(id), user: mapUser(user) })
   } catch (e: any) {
     if (e.code === '23505') return reply.code(409).send({ message: '该手机号已注册' })
     throw e
@@ -204,40 +448,37 @@ app.post('/api/auth/login', async (req, reply) => {
 app.post('/api/admin/auth/login', async (req, reply) => {
   const p = phonePassword.safeParse(req.body)
   if (!p.success) return reply.code(400).send({ message: '手机号或密码格式不正确' })
-  const { rows } = await pool.query('SELECT * FROM users WHERE phone = $1', [p.data.phone])
+  const { rows } = await pool.query('SELECT * FROM platform_admins WHERE phone = $1', [p.data.phone])
   if (!rows[0] || !(await verifyPassword(p.data.password, rows[0].password_hash))) {
     return reply.code(401).send({ message: '管理员账号或密码错误' })
   }
-  if (rows[0].role !== 'ADMIN') {
-    return reply.code(403).send({ message: '该账号不是管理员,无权登录后台' })
-  }
-  return { token: await session(rows[0].id), user: mapUser(rows[0]) }
+  return { token: await session(rows[0].id, true), user: mapUser({ ...rows[0], role: 'ADMIN' }) }
 })
 
-app.post('/api/auth/logout', { preHandler: auth }, async (req) => {
+app.post('/api/auth/logout', { preHandler: accountAuth }, async (req) => {
   const token = req.headers.authorization!.replace(/^Bearer\s+/i, '')
   await pool.query('DELETE FROM sessions WHERE token_hash = $1', [sha(token)])
+  await pool.query('DELETE FROM platform_admin_sessions WHERE token_hash = $1', [sha(token)])
   return { ok: true }
 })
 
-app.get('/api/me', { preHandler: auth }, async (req) => ({ user: mapUser((req as any).user) }))
+app.get('/api/me', { preHandler: accountAuth }, async (req) => ({ user: mapUser((req as any).user) }))
 
 app.get('/api/me/points-transactions', { preHandler: auth }, async (req) => {
   const { rows } = await pool.query(
-    `SELECT id, name, COALESCE(actual_points, estimated_points) AS points, completed_at
-     FROM courses
-     WHERE user_id = $1 AND points_charged = TRUE
-     ORDER BY completed_at DESC NULLS LAST, updated_at DESC`,
+    `SELECT l.id,l.course_id,COALESCE(c.name,l.course_name_snapshot) AS course_name,l.amount,l.reason,l.created_at
+     FROM points_ledger l LEFT JOIN courses c ON c.id=l.course_id
+     WHERE l.owner_type='PERSONAL' AND l.owner_id=$1 ORDER BY l.created_at DESC`,
     [userId(req)]
   )
   return {
     balance: Number((req as any).user.points_balance) || 0,
     transactions: rows.map((row) => ({
-      id: `course-${row.id}`,
-      courseId: row.id,
-      courseName: row.name,
-      points: -Math.abs(Number(row.points) || 0),
-      createdAt: row.completed_at
+      id: row.id,
+      courseId: row.course_id,
+      courseName: row.course_name || row.reason,
+      points: Number(row.amount),
+      createdAt: row.created_at
     }))
   }
 })
@@ -245,7 +486,6 @@ app.get('/api/me/points-transactions', { preHandler: auth }, async (req) => {
 app.patch('/api/me', { preHandler: auth }, async (req, reply) => {
   const p = z
     .object({
-      organization: z.string().max(120),
       wechat: z.string().max(80),
       contactName: z.string().max(80),
       bio: z.string().max(500)
@@ -254,8 +494,8 @@ app.patch('/api/me', { preHandler: auth }, async (req, reply) => {
   if (!p.success) return reply.code(400).send({ message: '用户信息格式不正确' })
   const d = p.data
   const { rows } = await pool.query(
-    'UPDATE users SET organization = $1, wechat = $2, contact_name = $3, bio = $4, updated_at = NOW() WHERE id = $5 RETURNING *',
-    [d.organization, d.wechat, d.contactName, d.bio, userId(req)]
+    'UPDATE users SET wechat = $1, contact_name = $2, bio = $3, updated_at = NOW() WHERE id = $4 RETURNING *',
+    [d.wechat, d.contactName, d.bio, userId(req)]
   )
   return { user: mapUser(rows[0]) }
 })
@@ -271,7 +511,7 @@ app.post('/api/me/password', { preHandler: auth }, async (req, reply) => {
   if (!(await verifyPassword(p.data.currentPassword, (req as any).user.password_hash))) {
     return reply.code(400).send({ message: '当前密码不正确' })
   }
-  await pool.query('UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2', [
+  await pool.query('UPDATE users SET password_hash = $1, must_change_password = FALSE, updated_at = NOW() WHERE id = $2', [
     await hashPassword(p.data.newPassword),
     userId(req)
   ])
@@ -282,16 +522,33 @@ app.post('/api/me/password', { preHandler: auth }, async (req, reply) => {
 
 const courseInput = z.object({
   name: z.string().trim().min(1, '请输入课程名称').max(100),
-  category: z.string().max(50).default(''),
-  description: z.string().max(500).default('')
+  description: z.string().max(500).default(''),
+  assetNotes: z.string().max(5000).default(''),
+  digitalInstructorId: z.string().uuid().nullable().optional()
 })
 
+async function validDigitalInstructor(id: string, organizationId: string | null, personalUserId: string) {
+  const row = await digitalInstructors.get(id)
+  if (!row || row.organization_id !== organizationId || (organizationId === null && row.personal_user_id !== personalUserId)) return false
+  return true
+}
+async function readyDigitalInstructor(id: string) {
+  const files = await digitalInstructors.files(id)
+  return files.some(f => f.kind === 'IMAGE') && files.some(f => f.kind === 'AUDIO')
+}
+
 app.get('/api/course-templates', async () => ({ templates: COURSE_TEMPLATES }))
 
-app.get('/api/courses', { preHandler: auth }, async (req) => {
-  const { rows } = await pool.query('SELECT * FROM courses WHERE user_id = $1 ORDER BY created_at DESC', [
-    userId(req)
-  ])
+app.get('/api/courses', { preHandler: auth }, async (req, reply) => {
+  const workspace = req.headers['x-workspace-id'] as string | undefined
+  if (workspace && workspace !== 'personal') {
+    const member = await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [workspace, userId(req)])
+    if (!member.rows[0]) return reply.code(403).send({ message: '您不属于该组织' })
+  }
+  const organizationWorkspace = workspace && workspace !== 'personal'
+  const { rows } = organizationWorkspace
+    ? await pool.query('SELECT * FROM courses WHERE organization_id=$1 AND user_id=$2 ORDER BY created_at DESC', [workspace, userId(req)])
+    : await pool.query('SELECT * FROM courses WHERE organization_id IS NULL AND user_id=$1 ORDER BY created_at DESC', [userId(req)])
   return { courses: await Promise.all(rows.map((r) => fullCourse(r))) }
 })
 
@@ -300,9 +557,17 @@ app.post('/api/courses', { preHandler: auth }, async (req, reply) => {
   if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请完整填写课程信息' })
   const id = randomUUID()
   const d = p.data
+  const workspace = req.headers['x-workspace-id'] as string | undefined
+  const organizationId = workspace && workspace !== 'personal' ? workspace : null
+  if (organizationId) {
+    const member = await pool.query(`SELECT 1 FROM organization_members m JOIN organizations o ON o.id=m.organization_id
+      WHERE m.organization_id=$1 AND m.user_id=$2 AND o.is_active=TRUE`, [organizationId, userId(req)])
+    if (!member.rows[0]) return reply.code(403).send({ message: '您不属于该组织' })
+  }
+  if (d.digitalInstructorId && !await validDigitalInstructor(d.digitalInstructorId, organizationId, userId(req))) return reply.code(400).send({ message: '请选择本工作区的数字讲师' })
   const { rows } = await pool.query(
-    'INSERT INTO courses(id, user_id, name, category, description) VALUES($1, $2, $3, $4, $5) RETURNING *',
-    [id, userId(req), d.name, d.category, d.description]
+    'INSERT INTO courses(id, user_id, organization_id, name, description, digital_instructor_id) VALUES($1, $2, $3, $4, $5, $6) RETURNING *',
+    [id, userId(req), organizationId, d.name, d.description, d.digitalInstructorId || null]
   )
 
   // 课程创建后默认创建第 1 集内容,确保分集不为空
@@ -314,6 +579,8 @@ app.post('/api/courses', { preHandler: auth }, async (req, reply) => {
   )
 
   await addProgressEvent(id, 'USER', 'COURSE_CREATED', '课程已创建', '课程草稿创建成功。')
+  await pool.query(`INSERT INTO audit_events(id,organization_id,course_id,actor_id,subject_user_id,action,details)
+    VALUES($1,$2,$3,$4,$4,'COURSE_CREATED',$5)`, [randomUUID(), organizationId, id, userId(req), JSON.stringify({ name: d.name })])
 
   return reply.code(201).send({ course: await fullCourse(rows[0]) })
 })
@@ -326,16 +593,25 @@ app.post('/api/courses/template', { preHandler: auth }, async (req, reply) => {
 
   const template = COURSE_TEMPLATES.find((t) => t.id === p.data.templateId) || COURSE_TEMPLATES[0]
   const courseId = randomUUID()
+  const workspace = req.headers['x-workspace-id'] as string | undefined
+  const organizationId = workspace && workspace !== 'personal' ? workspace : null
+  if (organizationId) {
+    const member = await pool.query(`SELECT 1 FROM organization_members m JOIN organizations o ON o.id=m.organization_id
+      WHERE m.organization_id=$1 AND m.user_id=$2 AND o.is_active=TRUE`, [organizationId, userId(req)])
+    if (!member.rows[0]) return reply.code(403).send({ message: '您不属于该组织' })
+  }
 
   const { rows } = await pool.query(
-    'INSERT INTO courses(id, user_id, name, category, description) VALUES($1, $2, $3, $4, $5) RETURNING *',
-    [courseId, userId(req), template.name, template.category, template.description]
+    'INSERT INTO courses(id, user_id, organization_id, name, description) VALUES($1, $2, $3, $4, $5) RETURNING *',
+    [courseId, userId(req), organizationId, template.name, template.description]
   )
 
   await populateCourseFromTemplate(pool, courseId, template.id, uploadDir, sampleDir)
 
   await refreshCoursePoints(courseId)
   await addProgressEvent(courseId, 'USER', 'COURSE_CREATED', '从模板创建课程', '已按模板生成课程内容。')
+  await pool.query(`INSERT INTO audit_events(id,organization_id,course_id,actor_id,subject_user_id,action,details)
+    VALUES($1,$2,$3,$4,$4,'COURSE_CREATED_FROM_TEMPLATE',$5)`, [randomUUID(), organizationId, courseId, userId(req), JSON.stringify({ templateId: template.id })])
 
   return reply.code(201).send({ course: await fullCourse(rows[0]) })
 })
@@ -352,17 +628,31 @@ app.patch('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
   const row = await ownedCourse(id, userId(req))
   if (!row) return reply.code(404).send({ message: '课程不存在' })
 
-  const p = courseInput.pick({ name: true, category: true, description: true }).safeParse(req.body)
+  const p = z.object({
+    name: z.string().trim().min(1, '请输入课程名称').max(100).optional(),
+    description: z.string().max(500).optional(),
+    assetNotes: z.string().max(5000).optional(),
+    digitalInstructorId: z.string().uuid().nullable().optional()
+  }).safeParse(req.body)
+
   if (!p.success) {
     return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查课程信息' })
   }
 
+  if (p.data.digitalInstructorId !== undefined && row.status !== 'DRAFT') return reply.code(409).send({ message: '只有草稿课程可以更换讲师' })
+  if (p.data.digitalInstructorId && !await validDigitalInstructor(p.data.digitalInstructorId,row.organization_id,userId(req))) return reply.code(400).send({ message: '请选择本工作区的数字讲师' })
+
+  const newName = p.data.name !== undefined ? p.data.name : row.name
+  const newDesc = p.data.description !== undefined ? p.data.description : row.description
+  const newAssetNotes = p.data.assetNotes !== undefined ? p.data.assetNotes : (row.asset_notes || '')
+  const newDigitalInstructorId = p.data.digitalInstructorId !== undefined ? (p.data.digitalInstructorId || null) : row.digital_instructor_id
+
   const { rows } = await pool.query(
-    'UPDATE courses SET name = $1, category = $2, description = $3, updated_at = NOW() WHERE id = $4 AND user_id = $5 RETURNING *',
-    [p.data.name, p.data.category, p.data.description, id, userId(req)]
+    'UPDATE courses SET name = $1, description = $2, asset_notes = $3, digital_instructor_id = $4, updated_at = NOW() WHERE id = $5 RETURNING *',
+    [newName, newDesc, newAssetNotes, newDigitalInstructorId, id]
   )
   await refreshCoursePoints(id)
-  await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程基本信息已修改', '课程名称、分类或需求说明已更新。')
+  await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程基本信息已修改', '课程名称、需求说明或通用资产备注已更新。')
   return { course: await fullCourse(rows[0]) }
 })
 
@@ -375,8 +665,8 @@ app.post('/api/courses/:id/cancel-submission', { preHandler: auth }, async (req,
   }
 
   const { rows } = await pool.query(
-    "UPDATE courses SET status = 'DRAFT', submitted_at = NULL, updated_at = NOW() WHERE id = $1 AND user_id = $2 RETURNING *",
-    [id, userId(req)]
+    "UPDATE courses SET status = 'DRAFT', submitted_at = NULL, updated_at = NOW() WHERE id = $1 RETURNING *",
+    [id]
   )
   await addProgressEvent(id, 'USER', 'SUBMISSION_CANCELLED', '已撤回制作申请', '课程恢复为草稿,可继续修改后重新提交。')
   return { course: await fullCourse(rows[0]) }
@@ -388,14 +678,20 @@ app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
   if (!row) return reply.code(404).send({ message: '课程不存在' })
 
   // 已经等待制作、制作中或者制作完成的课程不能删除
-  if (row.status === 'WAITING_PRODUCTION' || row.status === 'IN_PRODUCTION' || row.status === 'COMPLETED') {
+  const manager = row.organization_id && (await pool.query(`SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2 AND role='MANAGER'`,
+    [row.organization_id, userId(req)])).rows[0]
+  if (!manager && (row.points_charged || row.status === 'WAITING_PRODUCTION' || row.status === 'IN_PRODUCTION' || row.status === 'COMPLETED')) {
     return reply.code(409).send({ message: '课程已进入制作排期或已制作完成,不可删除' })
   }
 
   // 1. 清理该课程关联的所有素材文件
   const assetsRes = await pool.query('SELECT stored_name FROM course_assets WHERE course_id = $1', [id])
   for (const ast of assetsRes.rows) {
-    if (ast.stored_name) await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
+    if (ast.stored_name) {
+      await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
+      await unlink(resolve(uploadDir, '.previews', ast.stored_name + '.pdf')).catch(() => {})
+      await unlink(resolve(uploadDir, '.previews', `${ast.stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
+    }
   }
 
   // 2. 清理讲师头像图片
@@ -409,7 +705,8 @@ app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
     if (ins.image_stored_name) instructorStoredNames.add(ins.image_stored_name)
   }
   for (const storedName of instructorStoredNames) {
-    await unlink(resolve(uploadDir, storedName as string)).catch(() => {})
+    const shared = await pool.query('SELECT 1 FROM digital_instructor_files WHERE stored_name=$1', [storedName])
+    if (!shared.rows[0]) await unlink(resolve(uploadDir, storedName as string)).catch(() => {})
   }
 
   // 3. 清理交付成品文件
@@ -424,7 +721,7 @@ app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
   }
 
   // 5. 从数据库中删除课程(级联删除 episodes, course_assets, instructors 等)
-  await pool.query('DELETE FROM courses WHERE id = $1 AND user_id = $2', [id, userId(req)])
+  await pool.query('DELETE FROM courses WHERE id = $1', [id])
 
   return { ok: true, message: '课程已成功删除' }
 })
@@ -473,7 +770,29 @@ app.post('/api/courses/:id/assets', { preHandler: auth }, async (req, reply) =>
     )
   }
 
-  return { course: await fullCourse(row) }
+  let notesImported = 0
+  const pptxFiles = fileList.filter(f => extname(f.filename).toLowerCase() === '.pptx')
+  if (pptxFiles.length === 1) {
+    const episodes = await pool.query('SELECT id,lecture_notes FROM episodes WHERE course_id=$1 ORDER BY episode_number,created_at', [id])
+    const target = episodeId ? episodes.rows.find(ep => ep.id === episodeId) : episodes.rows.length === 1 ? episodes.rows[0] : null
+    if (target && !target.lecture_notes.trim()) {
+      try {
+        const notes = (await extractPptxSpeakerNotes(resolve(uploadDir, pptxFiles[0].stored)))
+          .map((note, index) => note ? `第 ${index + 1} 页\n${note}` : '')
+          .filter(Boolean)
+        if (notes.length) {
+          const combined = notes.join('\n\n')
+          const updated = await pool.query("UPDATE episodes SET lecture_notes=$1,updated_at=NOW() WHERE id=$2 AND course_id=$3 AND TRIM(lecture_notes)='' RETURNING id", [combined, target.id, id])
+          if (updated.rows[0]) notesImported = notes.length
+        }
+      } catch (error) {
+        req.log.warn({ err: error }, 'pptx speaker notes extraction failed')
+      }
+    }
+  }
+
+  await pool.query('UPDATE courses SET updated_at = NOW() WHERE id = $1', [id])
+  return { course: await fullCourse(row), notesImported }
 })
 
 app.delete('/api/courses/:id/assets/:assetId', { preHandler: auth }, async (req, reply) => {
@@ -485,7 +804,12 @@ app.delete('/api/courses/:id/assets/:assetId', { preHandler: auth }, async (req,
     'DELETE FROM course_assets WHERE id = $1 AND course_id = $2 RETURNING stored_name',
     [assetId, id]
   )
-  if (rows[0]) await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
+  if (rows[0]) {
+    await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
+    await unlink(resolve(uploadDir, '.previews', rows[0].stored_name + '.pdf')).catch(() => {})
+    await unlink(resolve(uploadDir, '.previews', `${rows[0].stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
+  }
+  await pool.query('UPDATE courses SET updated_at = NOW() WHERE id = $1', [id])
   return { course: await fullCourse(row) }
 })
 
@@ -599,7 +923,14 @@ app.patch('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (r
   const newTitle = p.data.title !== undefined ? p.data.title : cur.title
   const newNumber = p.data.episodeNumber !== undefined ? p.data.episodeNumber : cur.episode_number
   const newSummary = p.data.summary !== undefined ? p.data.summary : cur.summary
-  const newNotes = p.data.lectureNotes !== undefined ? p.data.lectureNotes : cur.lecture_notes
+  const richPrefix = '<!--xinghen-richtext-->'
+  const submittedNotes = p.data.lectureNotes
+  const newNotes = submittedNotes === undefined ? cur.lecture_notes : submittedNotes.startsWith(richPrefix)
+    ? richPrefix + sanitizeHtml(submittedNotes.slice(richPrefix.length), {
+      allowedTags: ['p', 'div', 'br', 'strong', 'b', 'em', 'i', 'ul', 'ol', 'li'],
+      allowedAttributes: {}
+    })
+    : submittedNotes
 
   await pool.query(
     'UPDATE episodes SET title = $1, episode_number = $2, summary = $3, lecture_notes = $4, updated_at = NOW() WHERE id = $5 AND course_id = $6',
@@ -629,6 +960,8 @@ app.delete('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (
   const astRes = await pool.query('SELECT stored_name FROM course_assets WHERE episode_id = $1', [episodeId])
   for (const ast of astRes.rows) {
     await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
+    await unlink(resolve(uploadDir, '.previews', ast.stored_name + '.pdf')).catch(() => {})
+    await unlink(resolve(uploadDir, '.previews', `${ast.stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
   }
 
   await pool.query('DELETE FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
@@ -637,85 +970,21 @@ app.delete('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (
   return { course: await fullCourse(row) }
 })
 
-app.post('/api/courses/:id/instructors', { preHandler: auth }, async (req, reply) => {
+app.post('/api/courses/:id/submit', { preHandler: auth }, async (req, reply) => {
   const { id } = req.params as any
   const row = await ownedCourse(id, userId(req))
   if (!row) return reply.code(404).send({ message: '课程不存在' })
-  if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' })
-  const fields: any = {}
-  const images: any[] = []
-  for await (const part of req.parts()) {
-    if (part.type === 'file') {
-      if (!part.mimetype.startsWith('image/')) return reply.code(400).send({ message: '讲师图片必须是图片格式' })
-      const ext = extname(part.filename).slice(0, 12)
-      const stored = 'instructor-' + randomUUID() + ext
-      await pipeline(part.file, createWriteStream(resolve(uploadDir, stored)))
-      images.push({ original: part.filename, stored, mime: part.mimetype })
-    } else {
-      fields[part.fieldname] = part.value
-    }
-  }
-  const p = z
-    .object({
-      name: z.string().trim().min(1, '请填写讲师姓名').max(80),
-      organization: z.string().max(120).default(''),
-      introduction: z.string().max(1000).default('')
-    })
-    .safeParse(fields)
-  if (!p.success) {
-    await Promise.all(images.map((image) => unlink(resolve(uploadDir, image.stored)).catch(() => {})))
-    return reply.code(400).send({ message: '请填写讲师姓名' })
-  }
-  const d = p.data
-  const instructorId = randomUUID()
-  const image = images[0]
-  const { rows } = await pool.query(
-    'INSERT INTO instructors(id, course_id, name, organization, introduction, image_original_name, image_stored_name, image_mime_type) VALUES($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *',
-    [
-      instructorId,
-      id,
-      d.name,
-      d.organization,
-      d.introduction,
-      image?.original || null,
-      image?.stored || null,
-      image?.mime || null
-    ]
-  )
-  for (const [index, uploadedImage] of images.entries()) {
-    await pool.query(
-      'INSERT INTO instructor_images(id, instructor_id, original_name, stored_name, mime_type, sort_order) VALUES($1, $2, $3, $4, $5, $6)',
-      [randomUUID(), instructorId, uploadedImage.original, uploadedImage.stored, uploadedImage.mime, index]
-    )
-  }
-  return reply.code(201).send({ instructor: mapInstructor(rows[0]), course: await fullCourse(row) })
-})
-
-app.delete('/api/courses/:id/instructors/:instructorId', { preHandler: auth }, async (req, reply) => {
-  const { id, instructorId } = req.params as any
-  const row = await ownedCourse(id, userId(req))
-  if (!row) return reply.code(404).send({ message: '课程不存在' })
-  if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' })
-  const imageRows = await pool.query('SELECT stored_name FROM instructor_images WHERE instructor_id = $1', [instructorId])
-  const { rows } = await pool.query(
-    'DELETE FROM instructors WHERE id = $1 AND course_id = $2 RETURNING image_stored_name',
-    [instructorId, id]
-  )
-  const storedNames = new Set(imageRows.rows.map((image) => image.stored_name))
-  if (rows[0]?.image_stored_name) storedNames.add(rows[0].image_stored_name)
-  await Promise.all([...storedNames].map((storedName) => unlink(resolve(uploadDir, storedName as string)).catch(() => {})))
-  return { course: await fullCourse(row) }
-})
-
-app.post('/api/courses/:id/submit', { preHandler: auth }, async (req, reply) => {
-  const { id } = req.params as any
+  if (!row.digital_instructor_id || !await validDigitalInstructor(row.digital_instructor_id,row.organization_id,userId(req)) || !await readyDigitalInstructor(row.digital_instructor_id)) return reply.code(409).send({ message: '提交前请选择讲师,并补齐照片和录音' })
+  const snapshot = await digitalInstructors.dto(await digitalInstructors.get(row.digital_instructor_id))
   const estimate = await refreshCoursePoints(id)
-  const balance = Number((req as any).user.points_balance) || 0
+  const balance = row.organization_id
+    ? Number((await pool.query('SELECT points_balance FROM organizations WHERE id=$1', [row.organization_id])).rows[0]?.points_balance) || 0
+    : Number((req as any).user.points_balance) || 0
   const estimatedPoints = estimate.points
   if (balance < estimatedPoints) return reply.code(409).send({ message: `积分不足:本课程需要 ${estimatedPoints} 积分,当前余额 ${balance} 积分` })
   const { rows } = await pool.query(
-    "UPDATE courses SET status = 'WAITING_PRODUCTION', submitted_at = NOW(), updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status = 'DRAFT' RETURNING *",
-    [id, userId(req)]
+    "UPDATE courses SET status = 'WAITING_PRODUCTION', digital_instructor_snapshot=$2, submitted_at = NOW(), updated_at = NOW() WHERE id = $1 AND status = 'DRAFT' AND digital_instructor_id=$3 RETURNING *",
+    [id,JSON.stringify(snapshot),row.digital_instructor_id]
   )
   if (!rows[0]) return reply.code(409).send({ message: '课程已经提交或不存在' })
   await addProgressEvent(id, 'USER', 'SUBMITTED', '已提交制作', '课程已进入制作队列。')
@@ -763,7 +1032,7 @@ app.get('/api/admin/courses', { preHandler: adminAuth }, async (req) => {
   if (query.keyword && query.keyword.trim()) {
     params.push(`%${query.keyword.trim()}%`)
     const idx = params.length
-    conditions.push(`(c.name ILIKE $${idx} OR c.category ILIKE $${idx} OR u.phone ILIKE $${idx} OR u.contact_name ILIKE $${idx} OR u.organization ILIKE $${idx})`)
+    conditions.push(`(c.name ILIKE $${idx} OR u.phone ILIKE $${idx} OR u.contact_name ILIKE $${idx})`)
   }
 
   const whereClause = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''
@@ -772,7 +1041,6 @@ app.get('/api/admin/courses', { preHandler: adminAuth }, async (req) => {
       c.*,
       u.phone AS user_phone,
       u.role AS user_role,
-      u.organization AS user_organization,
       u.wechat AS user_wechat,
       u.contact_name AS user_contact_name,
       u.bio AS user_bio,
@@ -798,14 +1066,13 @@ app.get('/api/admin/courses', { preHandler: adminAuth }, async (req) => {
 })
 
 // 3. 获取特定课程完整详情(供制作工作台使用)
-app.get('/api/admin/courses/:id', { preHandler: adminAuth }, async (req, reply) => {
+app.get('/api/admin/courses/:id', { preHandler: courseOperator }, async (req, reply) => {
   const { id } = req.params as any
   const sql = `
     SELECT
       c.*,
       u.phone AS user_phone,
       u.role AS user_role,
-      u.organization AS user_organization,
       u.wechat AS user_wechat,
       u.contact_name AS user_contact_name,
       u.bio AS user_bio,
@@ -821,7 +1088,7 @@ app.get('/api/admin/courses/:id', { preHandler: adminAuth }, async (req, reply)
 })
 
 // 4. 修改课程状态(例如接单转为 IN_PRODUCTION 或其他流转)
-app.post('/api/admin/courses/:id/status', { preHandler: adminAuth }, async (req, reply) => {
+app.post('/api/admin/courses/:id/status', { preHandler: courseOperator }, async (req, reply) => {
   const { id } = req.params as any
   const p = z.object({
     status: z.enum(['DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED'])
@@ -839,7 +1106,7 @@ app.post('/api/admin/courses/:id/status', { preHandler: adminAuth }, async (req,
   return { course: await fullCourse(rows[0], true) }
 })
 
-app.patch('/api/admin/courses/:id/points', { preHandler: adminAuth }, async (req, reply) => {
+app.patch('/api/admin/courses/:id/points', { preHandler: courseOperator }, async (req, reply) => {
   const { id } = req.params as any
   const p = z.object({ points: z.number().int().min(1).max(100000), reason: z.string().max(500).default('根据实际制作需求调整') }).safeParse(req.body)
   if (!p.success) return reply.code(400).send({ message: '请输入有效的积分消耗(至少 1 积分)' })
@@ -853,7 +1120,7 @@ app.patch('/api/admin/courses/:id/points', { preHandler: adminAuth }, async (req
 })
 
 // 5. 管理员上传制作交付成品文件
-app.post('/api/admin/courses/:id/deliverables', { preHandler: adminAuth }, async (req, reply) => {
+app.post('/api/admin/courses/:id/deliverables', { preHandler: courseOperator }, async (req, reply) => {
   const { id } = req.params as any
   const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
   if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
@@ -902,7 +1169,7 @@ app.post('/api/admin/courses/:id/deliverables', { preHandler: adminAuth }, async
 })
 
 // 6. 删除已上传的交付成果文件
-app.delete('/api/admin/courses/:id/deliverables/:delivId', { preHandler: adminAuth }, async (req, reply) => {
+app.delete('/api/admin/courses/:id/deliverables/:delivId', { preHandler: courseOperator }, async (req, reply) => {
   const { id, delivId } = req.params as any
   const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
   if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
@@ -916,7 +1183,7 @@ app.delete('/api/admin/courses/:id/deliverables/:delivId', { preHandler: adminAu
 })
 
 // 7. 提交制作成果,结单交付(流转为 COMPLETED)
-app.post('/api/admin/courses/:id/complete', { preHandler: adminAuth }, async (req, reply) => {
+app.post('/api/admin/courses/:id/complete', { preHandler: courseOperator }, async (req, reply) => {
   const { id } = req.params as any
   const p = z.object({
     productionNotes: z.string().max(2000).default('')
@@ -931,8 +1198,14 @@ app.post('/api/admin/courses/:id/complete', { preHandler: adminAuth }, async (re
     if (!current.rows[0]) { await client.query('ROLLBACK'); return reply.code(404).send({ message: '课程不存在' }) }
     const cost = Number(current.rows[0].actual_points ?? current.rows[0].estimated_points) || 100
     if (!current.rows[0].points_charged) {
-      const charged = await client.query('UPDATE users SET points_balance = points_balance - $1, updated_at = NOW() WHERE id = $2 AND points_balance >= $1 RETURNING points_balance', [cost, current.rows[0].user_id])
-      if (!charged.rows[0]) { await client.query('ROLLBACK'); return reply.code(409).send({ message: `用户积分不足,完成制作需要扣除 ${cost} 积分` }) }
+      const organizationId = current.rows[0].organization_id
+      const charged = organizationId
+        ? await client.query('UPDATE organizations SET points_balance=points_balance-$1,updated_at=NOW() WHERE id=$2 AND points_balance >= $1 RETURNING points_balance', [cost, organizationId])
+        : await client.query('UPDATE users SET points_balance=points_balance-$1,updated_at=NOW() WHERE id=$2 AND points_balance >= $1 RETURNING points_balance', [cost, current.rows[0].user_id])
+      if (!charged.rows[0]) { await client.query('ROLLBACK'); return reply.code(409).send({ message: `工作区积分不足,完成制作需要扣除 ${cost} 积分` }) }
+      await client.query(`INSERT INTO points_ledger(id,owner_type,owner_id,course_id,course_name_snapshot,beneficiary_user_id,admin_actor_id,amount,reason)
+        VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [randomUUID(), organizationId ? 'ORGANIZATION' : 'PERSONAL', organizationId || current.rows[0].user_id,
+        id, current.rows[0].name, current.rows[0].user_id, (req as any).user.role === 'ADMIN' ? userId(req) : null, -cost, '课程完成扣费'])
     }
     const updated = await client.query("UPDATE courses SET status = 'COMPLETED', production_notes = $1, completed_at = NOW(), points_charged = TRUE, updated_at = NOW() WHERE id = $2 RETURNING *", [p.data.productionNotes, id])
     rows = updated.rows
@@ -950,12 +1223,13 @@ app.get('/api/admin/users', { preHandler: adminAuth }, async (req) => {
   let where = ''
   if (query.keyword && query.keyword.trim()) {
     params.push(`%${query.keyword.trim()}%`)
-    where = 'WHERE (u.phone ILIKE $1 OR u.contact_name ILIKE $1 OR u.organization ILIKE $1 OR u.wechat ILIKE $1)'
+    where = 'WHERE (u.phone ILIKE $1 OR u.contact_name ILIKE $1 OR u.wechat ILIKE $1)'
   }
 
   const sql = `
     SELECT
       u.*,
+      (SELECT string_agg(o.name, '、') FROM organization_members m JOIN organizations o ON o.id=m.organization_id WHERE m.user_id=u.id) AS organization_names,
       COUNT(c.id)::int AS courses_count,
       COUNT(c.id) FILTER (WHERE c.status = 'WAITING_PRODUCTION')::int AS waiting_courses_count,
       COUNT(c.id) FILTER (WHERE c.status = 'IN_PRODUCTION')::int AS in_production_courses_count,
@@ -969,6 +1243,7 @@ app.get('/api/admin/users', { preHandler: adminAuth }, async (req) => {
   const { rows } = await pool.query(sql, params)
   const users = rows.map((r) => ({
     ...mapUser(r),
+    organization: r.organization_names || '',
     coursesCount: r.courses_count || 0,
     waitingCoursesCount: r.waiting_courses_count || 0,
     inProductionCoursesCount: r.in_production_courses_count || 0,
@@ -982,18 +1257,18 @@ app.post('/api/admin/users', { preHandler: adminAuth }, async (req, reply) => {
   const p = z.object({
     phone: z.string().regex(/^1\d{10}$/, '请输入正确的手机号码'),
     password: z.string().min(6, '密码至少 6 位').max(72),
-    contactName: z.string().trim().max(50, '姓名不能超过 50 个字').optional().default(''),
-    organization: z.string().trim().max(100, '单位不能超过 100 个字').optional().default('')
+    contactName: z.string().trim().max(50, '姓名不能超过 50 个字').optional().default('')
   }).safeParse(req.body)
   if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
+  if ((await pool.query('SELECT 1 FROM platform_admins WHERE phone=$1', [p.data.phone])).rows[0]) return reply.code(409).send({ message: '该手机号属于平台管理员' })
 
   try {
     const id = randomUUID()
     const passwordHash = await hashPassword(p.data.password)
     const { rows } = await pool.query(
-      `INSERT INTO users(id, phone, password_hash, role, contact_name, organization, points_balance)
-       VALUES($1, $2, $3, 'USER', $4, $5, 10000) RETURNING *`,
-      [id, p.data.phone, passwordHash, p.data.contactName, p.data.organization]
+      `INSERT INTO users(id, phone, password_hash, role, contact_name, points_balance)
+       VALUES($1, $2, $3, 'USER', $4, 10000) RETURNING *`,
+      [id, p.data.phone, passwordHash, p.data.contactName]
     )
     return reply.code(201).send({ user: mapUser(rows[0]) })
   } catch (e: any) {
@@ -1032,34 +1307,20 @@ app.patch('/api/admin/users/:id/password', { preHandler: adminAuth }, async (req
   }
 })
 
-// 11. 修改用户角色(赋予或撤销管理员权限)
+// 平台管理员与普通用户永不互转。
 app.patch('/api/admin/users/:id/role', { preHandler: adminAuth }, async (req, reply) => {
-  const { id } = req.params as any
-  const currentAdmin = (req as any).user
-  const p = z.object({
-    role: z.enum(['USER', 'ADMIN'])
-  }).safeParse(req.body)
-  if (!p.success) return reply.code(400).send({ message: '角色类型不正确' })
-
-  if (id === currentAdmin.id && p.data.role !== 'ADMIN') {
-    return reply.code(400).send({ message: '不能撤销当前登录账号的管理员权限' })
-  }
-
-  const { rows } = await pool.query(
-    'UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
-    [p.data.role, id]
-  )
-  if (!rows[0]) return reply.code(404).send({ message: '用户不存在' })
-  return { user: mapUser(rows[0]) }
+  return reply.code(403).send({ message: '平台管理员与普通用户不能互相转化' })
 })
 
 // ==================== 静态托管与错误处理 ====================
 
 if (process.env.NODE_ENV === 'production') {
   const dist = resolve('./dist')
-  await app.register(staticPlugin, { root: dist, prefix: '/', wildcard: false })
+  await app.register(staticPlugin, { root: dist, prefix: '/', wildcard: false, decorateReply: false })
   app.setNotFoundHandler((req, reply) =>
-    req.url.startsWith('/api/') ? reply.code(404).send({ message: '接口不存在' }) : reply.sendFile('index.html')
+    req.url.startsWith('/api/') || req.url.startsWith('/uploads/')
+      ? reply.code(404).send({ message: '接口或文件不存在' })
+      : reply.sendFile('index.html', dist)
   )
 }
 

+ 311 - 0
server/organizations.ts

@@ -0,0 +1,311 @@
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
+import type pg from 'pg'
+import { randomBytes, randomUUID, createHash } from 'node:crypto'
+import { z } from 'zod'
+
+type Pool = pg.Pool
+type Deps = {
+  pool: Pool
+  auth: (req: FastifyRequest, reply: FastifyReply) => Promise<unknown>
+  adminAuth: (req: FastifyRequest, reply: FastifyReply) => Promise<unknown>
+  hashPassword: (password: string) => Promise<string>
+  mapUser: (row: any) => any
+}
+const sha = (value: string) => createHash('sha256').update(value).digest('hex')
+const uid = (req: FastifyRequest) => (req as any).user.id as string
+const params = (req: FastifyRequest) => req.params as Record<string, string>
+const json = (value: unknown) => JSON.stringify(value)
+
+export function registerOrganizations(app: FastifyInstance, { pool, auth, adminAuth, hashPassword, mapUser }: Deps) {
+  const membership = async (organizationId: string, userId: string) =>
+    (await pool.query('SELECT role FROM organization_members WHERE organization_id = $1 AND user_id = $2', [organizationId, userId])).rows[0] as { role: 'MEMBER' | 'MANAGER' } | undefined
+  const org = async (id: string) => (await pool.query('SELECT * FROM organizations WHERE id = $1', [id])).rows[0]
+  const manager = async (req: FastifyRequest, reply: FastifyReply, organizationId: string) => {
+    const member = await membership(organizationId, uid(req))
+    if (member?.role !== 'MANAGER') {
+      reply.code(403).send({ message: '没有该组织的管理权限' })
+      return false
+    }
+    return true
+  }
+  const event = (organizationId: string | null, actorId: string, action: string, details: unknown, subjectUserId: string | null = null) =>
+    pool.query(`INSERT INTO audit_events(id, organization_id, actor_id, admin_actor_id, subject_user_id, action, details)
+      VALUES($1,$2,$3,$4,$5,$6,$7)`, [randomUUID(), organizationId, ['ORGANIZATION_CREATED','ORGANIZATION_UPDATED','MEMBER_ROLE_SET','MEMBER_REMOVED','MEMBER_APPROVED','MEMBER_REJECTED','ORGANIZATION_POINTS_ADJUSTED'].includes(action) ? null : actorId,
+      ['ORGANIZATION_CREATED','ORGANIZATION_UPDATED','MEMBER_ROLE_SET','MEMBER_REMOVED','MEMBER_APPROVED','MEMBER_REJECTED','ORGANIZATION_POINTS_ADJUSTED'].includes(action) ? actorId : null,
+      subjectUserId, action, json(details)])
+
+  app.get('/api/me/organizations', { preHandler: auth }, async (req) => {
+    const { rows } = await pool.query(`SELECT o.id, o.slug, o.name, o.is_active, o.points_balance, m.role
+      FROM organization_members m JOIN organizations o ON o.id = m.organization_id
+      WHERE m.user_id = $1 ORDER BY o.name`, [uid(req)])
+    return { organizations: rows.map((r) => ({ id: r.id, slug: r.slug, name: r.name, isActive: r.is_active, pointsBalance: r.points_balance, role: r.role })) }
+  })
+
+  app.get('/api/organizations/:slug', { preHandler: auth }, async (req) => {
+    const { rows } = await pool.query('SELECT id, name, is_active FROM organizations WHERE slug = $1', [params(req).slug])
+    return { organization: rows[0] ? { id: rows[0].id, name: rows[0].name, isActive: rows[0].is_active } : null }
+  })
+
+  app.post('/api/organizations/:slug/apply', { preHandler: auth }, async (req, reply) => {
+    const { rows } = await pool.query('SELECT id FROM organizations WHERE slug = $1 AND is_active=TRUE', [params(req).slug])
+    if (!rows[0]) return reply.code(404).send({ message: '组织不存在' })
+    const organizationId = rows[0].id
+    if (await membership(organizationId, uid(req))) return reply.code(409).send({ message: '您已经是该组织成员' })
+    await pool.query(`INSERT INTO organization_applications(id, organization_id, user_id, status)
+      VALUES($1,$2,$3,'PENDING') ON CONFLICT (organization_id,user_id)
+      DO UPDATE SET status = 'PENDING', reviewed_by = NULL, reviewed_at = NULL, created_at = NOW()`,
+      [randomUUID(), organizationId, uid(req)])
+    await event(organizationId, uid(req), 'MEMBERSHIP_APPLIED', {})
+    return { ok: true }
+  })
+
+  app.post('/api/organizations/join/:token', { preHandler: auth }, async (req, reply) => {
+    const { rows } = await pool.query('SELECT id FROM organizations WHERE invite_token_hash = $1 AND is_active=TRUE', [sha(params(req).token)])
+    if (!rows[0]) return reply.code(404).send({ message: '注册链接无效或已停用' })
+    await pool.query(`INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2)
+      ON CONFLICT (organization_id,user_id) DO NOTHING`, [rows[0].id, uid(req)])
+    await pool.query(`UPDATE organization_applications SET status='APPROVED',reviewed_at=NOW()
+      WHERE organization_id=$1 AND user_id=$2 AND status='PENDING'`, [rows[0].id, uid(req)])
+    await event(rows[0].id, uid(req), 'INVITE_JOINED', {})
+    return { organizationId: rows[0].id }
+  })
+
+  app.get('/api/organizations/:id/content', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    const member = await membership(id, uid(req))
+    if (!member) return reply.code(403).send({ message: '请先加入组织' })
+    const organization = await org(id)
+    if (!organization) return reply.code(404).send({ message: '组织不存在' })
+    const [members, courses] = await Promise.all([
+      pool.query(`SELECT u.id, u.contact_name, m.role FROM organization_members m
+        JOIN users u ON u.id=m.user_id WHERE m.organization_id=$1 ORDER BY m.joined_at`, [id]),
+      pool.query(`SELECT c.id,c.user_id,c.name,c.status,c.created_at,c.updated_at,c.submitted_at,c.completed_at,
+        u.contact_name AS creator_name FROM courses c JOIN users u ON u.id=c.user_id
+        WHERE c.organization_id=$1 ORDER BY c.created_at DESC`, [id])
+    ])
+    return { organization: { id, slug: organization.slug, name: organization.name, pointsBalance: organization.points_balance, role: member.role }, members: members.rows, courses: courses.rows }
+  })
+
+  app.get('/api/organizations/:id/manage', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    if (!(await manager(req, reply, id))) return
+    const [members, applications, audit, ledger] = await Promise.all([
+      pool.query(`SELECT u.id,u.phone,u.contact_name,m.role,m.joined_at FROM organization_members m
+        JOIN users u ON u.id=m.user_id WHERE m.organization_id=$1 ORDER BY m.joined_at DESC`, [id]),
+      pool.query(`SELECT a.id,a.status,a.created_at,u.id AS user_id,u.phone,u.contact_name
+        FROM organization_applications a JOIN users u ON u.id=a.user_id
+        WHERE a.organization_id=$1 ORDER BY a.created_at DESC`, [id]),
+      pool.query(`SELECT a.*,u.contact_name AS actor_name FROM audit_events a LEFT JOIN users u ON u.id=a.actor_id
+        WHERE a.organization_id=$1 ORDER BY a.created_at DESC`, [id]),
+      pool.query(`SELECT l.*,u.contact_name AS beneficiary_name FROM points_ledger l
+        LEFT JOIN users u ON u.id=l.beneficiary_user_id
+        WHERE l.owner_type='ORGANIZATION' AND l.owner_id=$1 ORDER BY l.created_at DESC`, [id])
+    ])
+    const organization = await org(id)
+    return { members: members.rows, applications: applications.rows, audit: audit.rows, ledger: ledger.rows,
+      inviteActive: Boolean(organization?.invite_token_hash),
+      inviteToken: organization?.invite_token || null }
+  })
+
+  app.post('/api/organizations/:id/applications/:applicationId/review', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    if (!(await manager(req, reply, id))) return
+    const parsed = z.object({ approve: z.boolean() }).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: '审批参数不正确' })
+    const client = await pool.connect()
+    try {
+      await client.query('BEGIN')
+      const application = await client.query(`UPDATE organization_applications SET status=$1,reviewed_by=$2,reviewed_at=NOW()
+        WHERE id=$3 AND organization_id=$4 AND status='PENDING' RETURNING user_id`,
+        [parsed.data.approve ? 'APPROVED' : 'REJECTED', uid(req), params(req).applicationId, id])
+      if (!application.rows[0]) { await client.query('ROLLBACK'); return reply.code(404).send({ message: '待审批申请不存在' }) }
+      if (parsed.data.approve) await client.query(`INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2)
+        ON CONFLICT DO NOTHING`, [id, application.rows[0].user_id])
+      await client.query('COMMIT')
+      await event(id, uid(req), parsed.data.approve ? 'MEMBER_APPROVED' : 'MEMBER_REJECTED',
+        { applicationId: params(req).applicationId, before: 'PENDING', after: parsed.data.approve ? 'APPROVED' : 'REJECTED' }, application.rows[0].user_id)
+      return { ok: true }
+    } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+  })
+
+  app.post('/api/organizations/:id/users', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    if (!(await manager(req, reply, id))) return
+    if (!(await org(id))?.is_active) return reply.code(409).send({ message: '组织已停用,不能创建新成员' })
+    const parsed = z.object({ phone: z.string().regex(/^1\d{10}$/), contactName: z.string().trim().min(1).max(80) }).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: '请填写正确的手机号和姓名' })
+    const client = await pool.connect()
+    try {
+      await client.query('BEGIN')
+      const userId = randomUUID()
+      const created = await client.query(`INSERT INTO users(id,phone,password_hash,role,contact_name,points_balance,must_change_password)
+        VALUES($1,$2,$3,'USER',$4,10000,TRUE) RETURNING *`,
+        [userId, parsed.data.phone, await hashPassword('888888'), parsed.data.contactName])
+      await client.query('INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2)', [id, userId])
+      await client.query('COMMIT')
+      await event(id, uid(req), 'USER_CREATED', { phone: parsed.data.phone, contactName: parsed.data.contactName }, userId)
+      return reply.code(201).send({ user: mapUser(created.rows[0]), initialPassword: '888888' })
+    } catch (error: any) {
+      await client.query('ROLLBACK')
+      if (error.code === '23505') return reply.code(409).send({ message: '手机号已注册,请让用户通过组织链接加入' })
+      throw error
+    } finally { client.release() }
+  })
+
+  app.patch('/api/organizations/:id/members/:userId', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    if (!(await manager(req, reply, id))) return
+    const parsed = z.object({ contactName: z.string().trim().min(1, '请填写姓名').max(80, '姓名不能超过 80 个字') }).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: parsed.error.issues[0].message })
+    const member = await membership(id, params(req).userId)
+    if (!member) return reply.code(404).send({ message: '组织成员不存在' })
+    const { rows } = await pool.query('UPDATE users SET contact_name=$1,updated_at=NOW() WHERE id=$2 RETURNING *', [parsed.data.contactName, params(req).userId])
+    await event(id, uid(req), 'MEMBER_NAME_UPDATED', { contactName: parsed.data.contactName }, params(req).userId)
+    return { user: mapUser(rows[0]) }
+  })
+
+  app.post('/api/organizations/:id/invite', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    if (!(await manager(req, reply, id))) return
+    if (!(await org(id))?.is_active) return reply.code(409).send({ message: '组织已停用,不能生成注册链接' })
+    const token = randomBytes(24).toString('hex')
+    await pool.query('UPDATE organizations SET invite_token_hash=$1,invite_token=$2,updated_at=NOW() WHERE id=$3', [sha(token), token, id])
+    await event(id, uid(req), 'INVITE_ROTATED', {})
+    return { token }
+  })
+  app.delete('/api/organizations/:id/invite', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    if (!(await manager(req, reply, id))) return
+    await pool.query('UPDATE organizations SET invite_token_hash=NULL,invite_token=NULL,updated_at=NOW() WHERE id=$1', [id])
+    await event(id, uid(req), 'INVITE_DISABLED', {})
+    return { ok: true }
+  })
+
+  app.get('/api/organizations/:id/points', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    const member = await membership(id, uid(req))
+    if (!member) return reply.code(403).send({ message: '请先加入组织' })
+    const { rows: [organization] } = await pool.query('SELECT points_balance FROM organizations WHERE id=$1', [id])
+    const { rows } = await pool.query(`SELECT l.id,l.course_id,COALESCE(c.name,l.course_name_snapshot) AS course_name,l.amount,l.reason,l.created_at
+      FROM points_ledger l LEFT JOIN courses c ON c.id=l.course_id
+      WHERE l.owner_type='ORGANIZATION' AND l.owner_id=$1 ${member.role === 'MANAGER' ? '' : 'AND l.beneficiary_user_id=$2'}
+      ORDER BY l.created_at DESC`, member.role === 'MANAGER' ? [id] : [id, uid(req)])
+    return { balance: organization?.points_balance ?? 0, transactions: rows }
+  })
+
+  app.get('/api/admin/organizations', { preHandler: adminAuth }, async () => {
+    const { rows } = await pool.query(`SELECT o.*,COUNT(m.user_id)::int AS members_count FROM organizations o
+      LEFT JOIN organization_members m ON m.organization_id=o.id GROUP BY o.id ORDER BY o.created_at DESC`)
+    return { organizations: rows }
+  })
+  app.post('/api/admin/organizations', { preHandler: adminAuth }, async (req, reply) => {
+    const parsed = z.object({ name: z.string().trim().min(1).max(120), slug: z.string().regex(/^[a-z0-9-]{3,80}$/) }).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: '请填写名称和英文网址标识' })
+    try {
+      const { rows } = await pool.query('INSERT INTO organizations(id,name,slug) VALUES($1,$2,$3) RETURNING *',
+        [randomUUID(), parsed.data.name, parsed.data.slug])
+      await event(rows[0].id, uid(req), 'ORGANIZATION_CREATED', { name: rows[0].name })
+      return reply.code(201).send({ organization: rows[0] })
+    } catch (error: any) {
+      if (error.code === '23505') return reply.code(409).send({ message: '网址标识已存在' })
+      throw error
+    }
+  })
+  app.get('/api/admin/organizations/:id', { preHandler: adminAuth }, async (req, reply) => {
+    const id = params(req).id
+    const organization = await org(id)
+    if (!organization) return reply.code(404).send({ message: '组织不存在' })
+    const [members, applications, ledger, audit, courses] = await Promise.all([
+      pool.query(`SELECT u.id,u.phone,u.contact_name,m.role FROM organization_members m JOIN users u ON u.id=m.user_id
+        WHERE m.organization_id=$1 ORDER BY m.joined_at DESC`, [id]),
+      pool.query(`SELECT a.id,a.status,u.id AS user_id,u.phone,u.contact_name FROM organization_applications a
+        JOIN users u ON u.id=a.user_id WHERE a.organization_id=$1 ORDER BY a.created_at DESC`, [id]),
+      pool.query(`SELECT * FROM points_ledger WHERE owner_type='ORGANIZATION' AND owner_id=$1 ORDER BY created_at DESC`, [id]),
+      pool.query(`SELECT a.*,u.contact_name AS actor_name FROM audit_events a LEFT JOIN users u ON u.id=a.actor_id
+        WHERE a.organization_id=$1 ORDER BY a.created_at DESC`, [id]),
+      pool.query(`SELECT c.id,c.name,c.status,c.created_at,c.updated_at,u.contact_name AS creator_name FROM courses c JOIN users u ON u.id=c.user_id
+        WHERE c.organization_id=$1 ORDER BY c.created_at DESC`, [id])
+    ])
+    return { organization, members: members.rows, applications: applications.rows, ledger: ledger.rows, audit: audit.rows, courses: courses.rows }
+  })
+  app.patch('/api/admin/organizations/:id', { preHandler: adminAuth }, async (req, reply) => {
+    const parsed = z.object({
+      name: z.string().trim().min(1).max(120).optional(),
+      slug: z.string().trim().regex(/^[a-z0-9-]{3,80}$/, '网址标识需为3-80位小写字母、数字或连字符').optional(),
+      isActive: z.boolean().optional()
+    }).refine((v) => v.name !== undefined || v.slug !== undefined || v.isActive !== undefined).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: parsed.error.issues[0]?.message || '组织参数不正确' })
+    const old = await org(params(req).id)
+    try {
+      const { rows } = await pool.query(
+        'UPDATE organizations SET name=COALESCE($1,name),slug=COALESCE($2,slug),is_active=COALESCE($3,is_active),updated_at=NOW() WHERE id=$4 RETURNING *',
+        [parsed.data.name ?? null, parsed.data.slug ?? null, parsed.data.isActive ?? null, params(req).id]
+      )
+      if (!rows[0]) return reply.code(404).send({ message: '组织不存在' })
+      await event(rows[0].id, uid(req), 'ORGANIZATION_UPDATED', {
+        before: { name: old?.name, slug: old?.slug, isActive: old?.is_active },
+        after: { name: rows[0].name, slug: rows[0].slug, isActive: rows[0].is_active }
+      })
+      return { organization: rows[0] }
+    } catch (error: any) {
+      if (error.code === '23505') return reply.code(409).send({ message: '网址标识已存在' })
+      throw error
+    }
+  })
+  app.put('/api/admin/organizations/:id/members/:userId', { preHandler: adminAuth }, async (req, reply) => {
+    const parsed = z.object({ role: z.enum(['MEMBER', 'MANAGER']) }).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: '成员角色不正确' })
+    const id = params(req).id
+    const previous = await membership(id, params(req).userId)
+    const { rows } = await pool.query(`INSERT INTO organization_members(organization_id,user_id,role) VALUES($1,$2,$3)
+      ON CONFLICT (organization_id,user_id) DO UPDATE SET role=EXCLUDED.role RETURNING *`, [id, params(req).userId, parsed.data.role])
+    await pool.query(`UPDATE organization_applications SET status='APPROVED',reviewed_by_admin=$1,reviewed_at=NOW()
+      WHERE organization_id=$2 AND user_id=$3 AND status='PENDING'`, [uid(req), id, params(req).userId])
+    await event(id, uid(req), 'MEMBER_ROLE_SET', { before: previous?.role || null, after: parsed.data.role }, params(req).userId)
+    return { member: rows[0] }
+  })
+  app.delete('/api/admin/organizations/:id/members/:userId', { preHandler: adminAuth }, async (req) => {
+    const id = params(req).id
+    await pool.query('DELETE FROM organization_members WHERE organization_id=$1 AND user_id=$2', [id, params(req).userId])
+    await event(id, uid(req), 'MEMBER_REMOVED', {}, params(req).userId)
+    return { ok: true }
+  })
+  app.post('/api/admin/organizations/:id/applications/:applicationId/review', { preHandler: adminAuth }, async (req, reply) => {
+    const parsed = z.object({ approve: z.boolean() }).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: '审批参数不正确' })
+    const id = params(req).id
+    const client = await pool.connect()
+    try {
+      await client.query('BEGIN')
+      const { rows } = await client.query(`UPDATE organization_applications SET status=$1,reviewed_by_admin=$2,reviewed_at=NOW()
+        WHERE id=$3 AND organization_id=$4 AND status='PENDING' RETURNING user_id`,
+        [parsed.data.approve ? 'APPROVED' : 'REJECTED', uid(req), params(req).applicationId, id])
+      if (!rows[0]) { await client.query('ROLLBACK'); return reply.code(404).send({ message: '待审批申请不存在' }) }
+      if (parsed.data.approve) await client.query('INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2) ON CONFLICT DO NOTHING', [id, rows[0].user_id])
+      await client.query('COMMIT')
+      await event(id, uid(req), parsed.data.approve ? 'MEMBER_APPROVED' : 'MEMBER_REJECTED',
+        { applicationId: params(req).applicationId, before: 'PENDING', after: parsed.data.approve ? 'APPROVED' : 'REJECTED' }, rows[0].user_id)
+      return { ok: true }
+    } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+  })
+  app.post('/api/admin/organizations/:id/points', { preHandler: adminAuth }, async (req, reply) => {
+    const parsed = z.object({ amount: z.number().int().refine((n) => n !== 0), reason: z.string().trim().min(1).max(200) }).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: '请填写非零积分和操作原因' })
+    const id = params(req).id
+    const client = await pool.connect()
+    try {
+      await client.query('BEGIN')
+      const { rows } = await client.query(`UPDATE organizations SET points_balance=points_balance+$1,updated_at=NOW()
+        WHERE id=$2 AND points_balance+$1>=0 RETURNING points_balance`, [parsed.data.amount, id])
+      if (!rows[0]) { await client.query('ROLLBACK'); return reply.code(409).send({ message: '组织不存在或余额不足' }) }
+      await client.query(`INSERT INTO points_ledger(id,owner_type,owner_id,admin_actor_id,amount,reason)
+        VALUES($1,'ORGANIZATION',$2,$3,$4,$5)`, [randomUUID(), id, uid(req), parsed.data.amount, parsed.data.reason])
+      await client.query('COMMIT')
+      await event(id, uid(req), 'ORGANIZATION_POINTS_ADJUSTED', {
+        amount: parsed.data.amount, reason: parsed.data.reason,
+        before: rows[0].points_balance - parsed.data.amount, after: rows[0].points_balance
+      })
+      return { balance: rows[0].points_balance }
+    } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+  })
+}

+ 31 - 0
server/pptx-notes.ts

@@ -0,0 +1,31 @@
+import { readFile } from 'node:fs/promises'
+import JSZip from 'jszip'
+import { XMLParser } from 'fast-xml-parser'
+
+const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true })
+const list = <T>(value: T | T[] | undefined): T[] => value === undefined ? [] : Array.isArray(value) ? value : [value]
+
+function textNodes(value: unknown): string[] {
+  if (!value || typeof value !== 'object') return []
+  return Object.entries(value).flatMap(([key, child]) => key === 't'
+    ? [typeof child === 'string' ? child : String(child ?? '')]
+    : Array.isArray(child) ? child.flatMap(textNodes) : textNodes(child))
+}
+
+export async function extractPptxSpeakerNotes(path: string): Promise<string[]> {
+  const zip = await JSZip.loadAsync(await readFile(path), { checkCRC32: true })
+  const files = Object.keys(zip.files).filter(name => /^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(name))
+    .sort((a, b) => Number(a.match(/\d+(?=\.xml$)/)![0]) - Number(b.match(/\d+(?=\.xml$)/)![0]))
+  if (files.length > 500) throw new Error('PPTX 页数超出备注解析限制')
+  const notes: string[] = []
+  for (const name of files) {
+    const xml = await zip.file(name)!.async('string')
+    const shapes = list(parser.parse(xml)?.notes?.cSld?.spTree?.sp)
+    const body = shapes.find((shape: any) => shape?.nvSpPr?.nvPr?.ph?.['@_type'] === 'body') as any
+    const paragraphs = list(body?.txBody?.p)
+      .map((paragraph: unknown) => textNodes(paragraph).join('').trim())
+      .filter(Boolean)
+    notes.push(paragraphs.join('\n'))
+  }
+  return notes
+}

+ 218 - 0
server/schema.sql

@@ -14,8 +14,117 @@ CREATE TABLE IF NOT EXISTS users (
 
 ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'USER';
 ALTER TABLE users ADD COLUMN IF NOT EXISTS points_balance INT NOT NULL DEFAULT 10000;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE;
 ALTER TABLE users ALTER COLUMN points_balance SET DEFAULT 10000;
 
+CREATE TABLE IF NOT EXISTS platform_admins (
+  id UUID PRIMARY KEY,
+  phone VARCHAR(20) UNIQUE NOT NULL,
+  password_hash TEXT NOT NULL,
+  contact_name VARCHAR(80) NOT NULL DEFAULT '',
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE TABLE IF NOT EXISTS platform_admin_sessions (
+  token_hash CHAR(64) PRIMARY KEY,
+  admin_id UUID NOT NULL REFERENCES platform_admins(id) ON DELETE CASCADE,
+  expires_at TIMESTAMPTZ NOT NULL,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_platform_admin_sessions_admin ON platform_admin_sessions(admin_id);
+CREATE OR REPLACE FUNCTION reject_platform_admin_user() RETURNS trigger AS $$
+BEGIN
+  PERFORM pg_advisory_xact_lock(hashtextextended(NEW.phone, 0));
+  IF NEW.role <> 'USER' THEN RAISE EXCEPTION 'users table only accepts USER role'; END IF;
+  IF EXISTS (SELECT 1 FROM platform_admins WHERE phone = NEW.phone) THEN RAISE EXCEPTION 'phone belongs to platform administrator'; END IF;
+  RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+DROP TRIGGER IF EXISTS trg_reject_platform_admin_user ON users;
+CREATE TRIGGER trg_reject_platform_admin_user BEFORE INSERT OR UPDATE OF phone, role ON users
+FOR EACH ROW EXECUTE FUNCTION reject_platform_admin_user();
+CREATE OR REPLACE FUNCTION reject_existing_user_admin() RETURNS trigger AS $$
+BEGIN
+  PERFORM pg_advisory_xact_lock(hashtextextended(NEW.phone, 0));
+  IF EXISTS (SELECT 1 FROM users WHERE phone = NEW.phone) THEN RAISE EXCEPTION 'phone belongs to ordinary user'; END IF;
+  RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+DROP TRIGGER IF EXISTS trg_reject_existing_user_admin ON platform_admins;
+CREATE TRIGGER trg_reject_existing_user_admin BEFORE INSERT OR UPDATE OF phone ON platform_admins
+FOR EACH ROW EXECUTE FUNCTION reject_existing_user_admin();
+
+CREATE TABLE IF NOT EXISTS organizations (
+  id UUID PRIMARY KEY,
+  slug VARCHAR(80) UNIQUE NOT NULL,
+  name VARCHAR(120) NOT NULL,
+  is_active BOOLEAN NOT NULL DEFAULT TRUE,
+  points_balance INT NOT NULL DEFAULT 0 CHECK (points_balance >= 0),
+  invite_token_hash CHAR(64),
+  invite_token TEXT,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+ALTER TABLE organizations ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE;
+ALTER TABLE organizations ADD COLUMN IF NOT EXISTS invite_token TEXT;
+CREATE TABLE IF NOT EXISTS organization_members (
+  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  role VARCHAR(20) NOT NULL DEFAULT 'MEMBER' CHECK (role IN ('MEMBER', 'MANAGER')),
+  joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  PRIMARY KEY (organization_id, user_id)
+);
+CREATE INDEX IF NOT EXISTS idx_organization_members_user ON organization_members(user_id);
+CREATE TABLE IF NOT EXISTS organization_applications (
+  id UUID PRIMARY KEY,
+  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  status VARCHAR(20) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED')),
+  reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  reviewed_at TIMESTAMPTZ,
+  UNIQUE (organization_id, user_id)
+);
+ALTER TABLE organization_applications ADD COLUMN IF NOT EXISTS reviewed_by_admin UUID REFERENCES platform_admins(id) ON DELETE SET NULL;
+CREATE INDEX IF NOT EXISTS idx_organization_applications_org ON organization_applications(organization_id, status);
+CREATE TABLE IF NOT EXISTS audit_events (
+  id UUID PRIMARY KEY,
+  organization_id UUID REFERENCES organizations(id) ON DELETE SET NULL,
+  course_id UUID,
+  actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
+  subject_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+  action VARCHAR(100) NOT NULL,
+  details JSONB NOT NULL DEFAULT '{}'::jsonb,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+ALTER TABLE audit_events ADD COLUMN IF NOT EXISTS admin_actor_id UUID REFERENCES platform_admins(id) ON DELETE SET NULL;
+CREATE INDEX IF NOT EXISTS idx_audit_events_org ON audit_events(organization_id, created_at DESC);
+CREATE TABLE IF NOT EXISTS points_ledger (
+  id UUID PRIMARY KEY,
+  owner_type VARCHAR(20) NOT NULL CHECK (owner_type IN ('PERSONAL', 'ORGANIZATION')),
+  owner_id UUID NOT NULL,
+  course_id UUID,
+  course_name_snapshot VARCHAR(100),
+  beneficiary_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+  actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
+  amount INT NOT NULL,
+  reason VARCHAR(200) NOT NULL,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+ALTER TABLE points_ledger ADD COLUMN IF NOT EXISTS admin_actor_id UUID REFERENCES platform_admins(id) ON DELETE SET NULL;
+CREATE INDEX IF NOT EXISTS idx_points_ledger_owner ON points_ledger(owner_type, owner_id, created_at DESC);
+ALTER TABLE points_ledger ADD COLUMN IF NOT EXISTS course_name_snapshot VARCHAR(100);
+CREATE OR REPLACE FUNCTION initialize_personal_points_ledger() RETURNS trigger AS $$
+BEGIN
+  INSERT INTO points_ledger(id,owner_type,owner_id,beneficiary_user_id,amount,reason)
+  VALUES(gen_random_uuid(),'PERSONAL',NEW.id,NEW.id,NEW.points_balance,'账户初始积分');
+  RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+DROP TRIGGER IF EXISTS trg_personal_points_opening ON users;
+CREATE TRIGGER trg_personal_points_opening AFTER INSERT ON users
+FOR EACH ROW EXECUTE FUNCTION initialize_personal_points_ledger();
+
 CREATE TABLE IF NOT EXISTS sessions (
   token_hash CHAR(64) PRIMARY KEY,
   user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -32,6 +141,7 @@ CREATE TABLE IF NOT EXISTS courses (
   description VARCHAR(500) NOT NULL DEFAULT '',
   status VARCHAR(30) NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED')),
   production_notes TEXT NOT NULL DEFAULT '',
+  asset_notes TEXT NOT NULL DEFAULT '',
   estimated_minutes INT NOT NULL DEFAULT 1,
   estimated_points INT NOT NULL DEFAULT 1000,
   actual_points INT,
@@ -47,7 +157,10 @@ CREATE TABLE IF NOT EXISTS courses (
 );
 
 ALTER TABLE courses ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE;
+ALTER TABLE courses ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id) ON DELETE RESTRICT;
+CREATE INDEX IF NOT EXISTS idx_courses_organization_created ON courses(organization_id, created_at DESC);
 ALTER TABLE courses ADD COLUMN IF NOT EXISTS production_notes TEXT NOT NULL DEFAULT '';
+ALTER TABLE courses ADD COLUMN IF NOT EXISTS asset_notes TEXT NOT NULL DEFAULT '';
 ALTER TABLE courses ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ;
 ALTER TABLE courses ADD COLUMN IF NOT EXISTS estimated_minutes INT NOT NULL DEFAULT 1;
 ALTER TABLE courses ADD COLUMN IF NOT EXISTS estimated_points INT NOT NULL DEFAULT 1000;
@@ -117,6 +230,93 @@ CREATE TABLE IF NOT EXISTS instructor_images (
 );
 CREATE INDEX IF NOT EXISTS idx_instructor_images_instructor ON instructor_images(instructor_id, sort_order, created_at);
 
+-- 数字讲师属于一个工作区;旧课程讲师保留原表作历史资料。
+CREATE TABLE IF NOT EXISTS digital_instructors (
+  id UUID PRIMARY KEY,
+  organization_id UUID REFERENCES organizations(id) ON DELETE RESTRICT,
+  personal_user_id UUID REFERENCES users(id) ON DELETE RESTRICT,
+  owner_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
+  creator_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
+  source_id UUID REFERENCES digital_instructors(id) ON DELETE SET NULL,
+  legacy_instructor_id UUID UNIQUE REFERENCES instructors(id) ON DELETE SET NULL,
+  name VARCHAR(80) NOT NULL,
+  organization VARCHAR(120) NOT NULL DEFAULT '',
+  introduction VARCHAR(1000) NOT NULL DEFAULT '',
+  note VARCHAR(1000),
+  quality_status VARCHAR(30) NOT NULL DEFAULT 'NOT_CHECKED',
+  deleted_at TIMESTAMPTZ,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  CHECK ((organization_id IS NULL) <> (personal_user_id IS NULL))
+);
+ALTER TABLE digital_instructors ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
+ALTER TABLE digital_instructors ADD COLUMN IF NOT EXISTS note VARCHAR(1000);
+ALTER TABLE digital_instructors ADD COLUMN IF NOT EXISTS admin_creator_id UUID REFERENCES platform_admins(id) ON DELETE SET NULL;
+ALTER TABLE digital_instructors ALTER COLUMN creator_id DROP NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_digital_instructors_workspace ON digital_instructors(organization_id, personal_user_id, created_at DESC);
+CREATE TABLE IF NOT EXISTS digital_instructor_files (
+  id UUID PRIMARY KEY,
+  instructor_id UUID NOT NULL REFERENCES digital_instructors(id) ON DELETE CASCADE,
+  kind VARCHAR(10) NOT NULL CHECK (kind IN ('IMAGE', 'AUDIO')),
+  original_name VARCHAR(255) NOT NULL,
+  stored_name VARCHAR(255) NOT NULL UNIQUE,
+  size BIGINT NOT NULL DEFAULT 0,
+  mime_type VARCHAR(150) NOT NULL,
+  sort_order INT NOT NULL DEFAULT 0,
+  is_active BOOLEAN NOT NULL DEFAULT TRUE,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+ALTER TABLE digital_instructor_files ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE;
+CREATE INDEX IF NOT EXISTS idx_digital_instructor_files_parent ON digital_instructor_files(instructor_id, kind, sort_order);
+CREATE TABLE IF NOT EXISTS digital_instructor_events (
+  id UUID PRIMARY KEY,
+  instructor_id UUID,
+  actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
+  action VARCHAR(50) NOT NULL,
+  details JSONB NOT NULL DEFAULT '{}',
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+ALTER TABLE courses ADD COLUMN IF NOT EXISTS digital_instructor_id UUID REFERENCES digital_instructors(id) ON DELETE SET NULL;
+ALTER TABLE courses ADD COLUMN IF NOT EXISTS digital_instructor_snapshot JSONB;
+
+-- 幂等迁移:保留旧讲师及文件;缺录音的历史记录不能供新课程选择。
+INSERT INTO digital_instructors(id, organization_id, personal_user_id, owner_id, creator_id, legacy_instructor_id, name, organization, introduction, created_at)
+SELECT i.id, c.organization_id, CASE WHEN c.organization_id IS NULL THEN c.user_id ELSE NULL END,
+       c.user_id, c.user_id, i.id, i.name, i.organization, i.introduction, i.created_at
+FROM instructors i JOIN courses c ON c.id=i.course_id WHERE c.user_id IS NOT NULL
+ON CONFLICT DO NOTHING;
+INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order,created_at)
+SELECT ii.id,ii.instructor_id,'IMAGE',ii.original_name,ii.stored_name,0,ii.mime_type,ii.sort_order,ii.created_at
+FROM instructor_images ii JOIN digital_instructors di ON di.legacy_instructor_id=ii.instructor_id
+ON CONFLICT DO NOTHING;
+INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order,created_at)
+SELECT gen_random_uuid(),i.id,'IMAGE',COALESCE(i.image_original_name,'历史照片'),i.image_stored_name,0,
+       COALESCE(i.image_mime_type,'image/jpeg'),0,i.created_at
+FROM instructors i JOIN digital_instructors di ON di.legacy_instructor_id=i.id
+WHERE i.image_stored_name IS NOT NULL
+ON CONFLICT DO NOTHING;
+UPDATE courses c SET digital_instructor_id=(SELECT di.id FROM digital_instructors di JOIN instructors i ON i.id=di.legacy_instructor_id WHERE i.course_id=c.id ORDER BY i.created_at LIMIT 1)
+WHERE c.digital_instructor_id IS NULL AND EXISTS(SELECT 1 FROM instructors i WHERE i.course_id=c.id);
+UPDATE digital_instructors SET note=introduction WHERE note IS NULL;
+ALTER TABLE digital_instructors ALTER COLUMN note SET DEFAULT '';
+ALTER TABLE digital_instructors ALTER COLUMN note SET NOT NULL;
+
+-- 保留历史重名记录;新建、改名和复制必须在组织内唯一,行锁防止并发写入重名。
+CREATE OR REPLACE FUNCTION check_digital_instructor_name() RETURNS trigger AS $$
+BEGIN
+  IF NEW.organization_id IS NOT NULL AND NEW.deleted_at IS NULL THEN
+    PERFORM 1 FROM organizations WHERE id=NEW.organization_id FOR UPDATE;
+    IF EXISTS (SELECT 1 FROM digital_instructors d WHERE d.organization_id=NEW.organization_id
+      AND d.deleted_at IS NULL AND d.id<>NEW.id AND lower(btrim(d.name))=lower(btrim(NEW.name))) THEN
+      RAISE EXCEPTION 'digital instructor name already exists' USING ERRCODE='23505', CONSTRAINT='digital_instructors_org_name_unique';
+    END IF;
+  END IF;
+  RETURN NEW;
+END $$ LANGUAGE plpgsql;
+DROP TRIGGER IF EXISTS trg_digital_instructor_name ON digital_instructors;
+CREATE TRIGGER trg_digital_instructor_name BEFORE INSERT OR UPDATE OF name, organization_id, deleted_at
+ON digital_instructors FOR EACH ROW EXECUTE FUNCTION check_digital_instructor_name();
+
 CREATE TABLE IF NOT EXISTS course_deliverables (
   id UUID PRIMARY KEY,
   course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
@@ -146,3 +346,21 @@ CREATE INDEX IF NOT EXISTS idx_course_progress_events ON course_progress_events(
 -- 默认按每集 1000 积分计费,并同步刷新已有课程的默认值。
 UPDATE courses c
 SET estimated_points = GREATEST(1, (SELECT COUNT(*) FROM episodes e WHERE e.course_id = c.id)) * 1000;
+
+-- 旧账户的既有余额与已完成课程,折算为可追溯的个人流水。
+INSERT INTO points_ledger(id,owner_type,owner_id,beneficiary_user_id,amount,reason)
+SELECT gen_random_uuid(),'PERSONAL',u.id,u.id,
+  u.points_balance + COALESCE(SUM(COALESCE(c.actual_points,c.estimated_points)) FILTER (WHERE c.points_charged AND c.organization_id IS NULL),0)::int,
+  '历史账户期初余额'
+FROM users u LEFT JOIN courses c ON c.user_id=u.id
+WHERE NOT EXISTS (SELECT 1 FROM points_ledger l WHERE l.owner_type='PERSONAL' AND l.owner_id=u.id)
+GROUP BY u.id;
+INSERT INTO points_ledger(id,owner_type,owner_id,course_id,course_name_snapshot,beneficiary_user_id,amount,reason,created_at)
+SELECT gen_random_uuid(),'PERSONAL',c.user_id,c.id,c.name,c.user_id,
+  -COALESCE(c.actual_points,c.estimated_points),'历史课程完成扣费',COALESCE(c.completed_at,c.updated_at)
+FROM courses c WHERE c.points_charged AND c.organization_id IS NULL
+  AND NOT EXISTS (SELECT 1 FROM points_ledger l WHERE l.owner_type='PERSONAL' AND l.course_id=c.id);
+UPDATE points_ledger l SET course_name_snapshot=c.name FROM courses c
+WHERE l.course_id=c.id AND l.course_name_snapshot IS NULL;
+
+ALTER TABLE digital_instructor_events ADD COLUMN IF NOT EXISTS admin_actor_id UUID REFERENCES platform_admins(id) ON DELETE SET NULL;

+ 2 - 37
server/templates.ts

@@ -7,10 +7,9 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
   {
     id: 'party-building-standard',
     name: '《坚持人民至上根本立场》党课视频',
-    category: '党政专题',
     tags: ['精品党课', 'AI数字人', '1080P高清', 'PPT同步论证'],
     badge: '官方推荐示范',
-    summary: '严格依据党课制作任务书规范,包含审定讲稿、全屏课件切换逻辑、数字人讲师近景与全景运镜、精准语速停顿控制。',
+    summary: '严格依据党课制作任务书规范,包含审定讲稿、全屏课件切换逻辑、数字讲师近景与全景运镜、精准语速停顿控制。',
     description: `【制作目标】PPT配合讲稿,完成一支可用于网络党课学习的横版课程视频。
 【形式结构】(课程包装片头—片名)—讲师讲述—PPT论证—讲师回场—章节转场—继续讲述—总结—片尾。
 【运镜与节奏】全片约 30% 为讲师中近景/全景,约 70% 为全屏课件;单页 PPT 驻留 10—45 秒;讲师近景口型眼神对齐,现代演播室虚化背景。
@@ -104,7 +103,6 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
   {
     id: 'higher-edu-calculus',
     name: '《深入浅出微积分:极限与导数之美》公开课',
-    category: '高校公开课',
     tags: ['高校公开课', '数理基础', '公式板书', '生动启发'],
     badge: '高校示范',
     summary: '高校精品开放课程标准,聚焦概念启发与几何直观推导,支持讲师半身出镜与动态板书公式画中画同步。',
@@ -205,40 +203,7 @@ export async function populateCourseFromTemplate(
 ) {
   const template = COURSE_TEMPLATES.find((t) => t.id === templateId) || COURSE_TEMPLATES[0]
 
-  // 1. 填充讲师
-  for (const inst of template.instructors) {
-    let storedImageName: string | null = null
-    let origImageName: string | null = null
-    let mimeType: string | null = null
-
-    if (inst.sampleImageFile) {
-      try {
-        const sourcePath = resolve(sampleDir, inst.sampleImageFile)
-        storedImageName = `instructor-${randomUUID()}.jpg`
-        const destPath = resolve(uploadDir, storedImageName)
-        await copyFile(sourcePath, destPath)
-        origImageName = inst.sampleImageFile
-        mimeType = 'image/jpeg'
-      } catch (err) {
-        console.error('Failed to copy sample instructor image:', err)
-      }
-    }
-
-    await pool.query(
-      `INSERT INTO instructors(id, course_id, name, organization, introduction, image_original_name, image_stored_name, image_mime_type)
-       VALUES($1, $2, $3, $4, $5, $6, $7, $8)`,
-      [
-        randomUUID(),
-        courseId,
-        inst.name,
-        inst.organization,
-        inst.introduction,
-        origImageName,
-        storedImageName,
-        mimeType
-      ]
-    )
-  }
+  // 示例课程不自动生成缺少录音的数字讲师;用户在工作区选择已配置资产。
 
   // 2. 填充预设分集 (Episodes)
   const episodeMap = new Map<number, string>() // episodeNumber -> episodeId

+ 37 - 2
src/App.tsx

@@ -10,9 +10,15 @@ import { MarketingPage } from './pages/MarketingPage'
 import { AdminLayout } from './pages/admin/AdminLayout'
 import { AdminDashboard } from './pages/admin/AdminDashboard'
 import { AdminCourses } from './pages/admin/AdminCourses'
-import { AdminCourseDetail } from './pages/admin/AdminCourseDetail'
+import { AdminProductionQueue } from './pages/admin/AdminProductionQueue'
 import { AdminUsers } from './pages/admin/AdminUsers'
 import { AdminLoginPage } from './pages/admin/AdminLoginPage'
+import { AdminOrganizations, AdminOrganizationDetail } from './pages/admin/AdminOrganizations'
+import { OrganizationPage, InviteJoinPage } from './pages/OrganizationPage'
+import { OrganizationManage } from './pages/OrganizationManage'
+import { DigitalInstructorsPage } from './pages/DigitalInstructorsPage'
+import { AdminDigitalInstructors } from './pages/admin/AdminDigitalInstructors'
+import { DigitalInstructorEditorPage } from './pages/DigitalInstructorEditorPage'
 import { useAuth } from './AuthContext'
 
 export default function App() {
@@ -20,15 +26,41 @@ export default function App() {
 
   if (loading) return <div className="center-loading">正在加载星痕工坊…</div>
 
+  if (user?.mustChangePassword) return <Routes>
+    <Route element={<Layout />}><Route path="profile" element={<Profile />} /></Route>
+    <Route path="*" element={<Navigate to="/profile?tab=security" replace />} />
+  </Routes>
+
   return (
     <Routes>
       {/* ================= 管理员专属体系 (/admin/*) ================= */}
       <Route path="admin/login" element={<AdminLoginPage />} />
+      <Route path="join/:token" element={<InviteJoinPage />} />
+      {/* ================= 组织页面体系 ================= */}
+      {user ? (
+        <>
+          <Route element={<Layout />}>
+            <Route path="organizations/:slug" element={<OrganizationPage />} />
+          </Route>
+          <Route path="organizations/:slug/manage" element={<OrganizationManage />} />
+          <Route path="organizations/:slug/manage/courses/:id" element={<OrganizationManage />} />
+        </>
+      ) : (
+        <>
+          <Route path="organizations/:slug" element={<Navigate to="/" replace />} />
+          <Route path="organizations/:slug/manage" element={<Navigate to="/" replace />} />
+          <Route path="organizations/:slug/manage/courses/:id" element={<Navigate to="/" replace />} />
+        </>
+      )}
       <Route path="admin" element={<AdminLayout />}>
         <Route index element={<AdminDashboard />} />
         <Route path="courses" element={<AdminCourses />} />
-        <Route path="courses/:id" element={<AdminCourseDetail />} />
+        <Route path="production-queue" element={<AdminProductionQueue />} />
         <Route path="users" element={<AdminUsers />} />
+        <Route path="organizations" element={<AdminOrganizations />} />
+        <Route path="digital-instructors" element={<AdminDigitalInstructors />} />
+        <Route path="digital-instructors/:id/edit" element={<DigitalInstructorEditorPage admin />} />
+        <Route path="organizations/:id" element={<AdminOrganizationDetail />} />
         <Route path="*" element={<Navigate to="/admin" replace />} />
       </Route>
 
@@ -40,6 +72,9 @@ export default function App() {
         <Route element={<Layout />}>
           <Route index element={<Dashboard />} />
           <Route path="courses/new" element={<NewCourse />} />
+          <Route path="digital-instructors" element={<DigitalInstructorsPage />} />
+          <Route path="digital-instructors/new" element={<DigitalInstructorEditorPage />} />
+          <Route path="digital-instructors/:id/edit" element={<DigitalInstructorEditorPage />} />
           <Route path="courses/:id" element={<CourseWorkspace />} />
           <Route path="profile" element={<Profile />} />
           <Route path="help" element={<HelpPage />} />

+ 3 - 3
src/AuthContext.tsx

@@ -5,7 +5,7 @@ import type { User } from './types'
 type AuthContextType = {
   user: User | null
   loading: boolean
-  authenticate: (mode: 'login' | 'register', phone: string, password: string) => Promise<void>
+  authenticate: (mode: 'login' | 'register', phone: string, password: string, contactName?: string, inviteToken?: string) => Promise<void>
   adminLogin: (phone: string, password: string) => Promise<User>
   logout: () => Promise<void>
   setUser: (u: User) => void
@@ -32,8 +32,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
     return () => removeEventListener('auth-expired', load)
   }, [])
 
-  async function authenticate(mode: 'login' | 'register', phone: string, password: string) {
-    const r = await api[mode](phone, password)
+  async function authenticate(mode: 'login' | 'register', phone: string, password: string, contactName?: string, inviteToken?: string) {
+    const r = mode === 'register' ? await api.register(phone, password, contactName || '', inviteToken) : await api.login(phone, password)
     localStorage.setItem('xinghen_token', r.token)
     setUser(r.user)
   }

+ 124 - 0
src/WorkspaceContext.tsx

@@ -0,0 +1,124 @@
+import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
+import { api } from './api'
+import { useAuth } from './AuthContext'
+import type { Organization } from './types'
+
+type WorkspaceContextValue = {
+  organizations: Organization[]
+  workspaceId: string
+  currentOrganization: Organization | null
+  selectWorkspace: (id: string) => void
+  refreshOrganizations: () => Promise<void>
+}
+
+const WorkspaceContext = createContext<WorkspaceContextValue>(null!)
+
+const USER_WORKSPACE_KEY = (userId: string) => `xinghen_workspace_user_${userId}`
+
+export function WorkspaceProvider({ children }: { children: ReactNode }) {
+  const { user, loading } = useAuth()
+  const [organizations, setOrganizations] = useState<Organization[]>([])
+  const [workspaceId, setWorkspaceId] = useState<string>(() => {
+    return localStorage.getItem('xinghen_workspace') || 'personal'
+  })
+
+  // 当 user 变动时(登录/登出/切换用户):
+  // 1. 若仍处于身份验证 loading 中,切勿误判为登出或执行重置
+  // 2. 若登出或管理员,重置状态
+  // 3. 若普通用户登录,优先尝试同步预设其上次选择的工作区,避免闪烁
+  useEffect(() => {
+    if (loading) return
+
+    if (!user || user.role === 'ADMIN') {
+      setOrganizations([])
+      if (workspaceId !== 'personal') {
+        localStorage.setItem('xinghen_workspace', 'personal')
+        setWorkspaceId('personal')
+      }
+      return
+    }
+
+    const saved = localStorage.getItem(USER_WORKSPACE_KEY(user.id))
+    if (saved && saved !== workspaceId) {
+      localStorage.setItem('xinghen_workspace', saved)
+      setWorkspaceId(saved)
+    }
+
+    refreshOrganizations().catch(() => setOrganizations([]))
+  }, [user?.id, loading])
+
+  async function refreshOrganizations() {
+    if (loading) return
+    if (!user || user.role === 'ADMIN') {
+      setOrganizations([])
+      if (workspaceId !== 'personal') {
+        localStorage.setItem('xinghen_workspace', 'personal')
+        setWorkspaceId('personal')
+      }
+      return
+    }
+
+    const result = await api.organizations.mine()
+    const orgs = result.organizations || []
+    setOrganizations(orgs)
+
+    const saved = localStorage.getItem(USER_WORKSPACE_KEY(user.id))
+
+    let targetWorkspace = 'personal'
+    if (saved) {
+      // 用户之前已做过选择
+      if (saved === 'personal') {
+        targetWorkspace = 'personal'
+      } else if (orgs.some((org) => org.id === saved)) {
+        targetWorkspace = saved
+      } else {
+        // 用户上次选择的组织已不存在(可能被移除或解散),安全回退到首个可用组织或个人工作区
+        const firstActiveOrg = orgs.find((org) => org.isActive) || orgs[0]
+        targetWorkspace = firstActiveOrg ? firstActiveOrg.id : 'personal'
+        localStorage.setItem(USER_WORKSPACE_KEY(user.id), targetWorkspace)
+      }
+    } else {
+      // 用户首次登录或未曾显式选择:如果属于组织,默认进入该人的第一个组织;否则进入个人工作区
+      const firstActiveOrg = orgs.find((org) => org.isActive) || orgs[0]
+      targetWorkspace = firstActiveOrg ? firstActiveOrg.id : 'personal'
+      localStorage.setItem(USER_WORKSPACE_KEY(user.id), targetWorkspace)
+    }
+
+    localStorage.setItem('xinghen_workspace', targetWorkspace)
+    setWorkspaceId(targetWorkspace)
+  }
+
+  useEffect(() => {
+    const refresh = () => { refreshOrganizations().catch(() => {}) }
+    window.addEventListener('workspace-updated', refresh)
+    window.addEventListener('focus', refresh)
+    return () => {
+      window.removeEventListener('workspace-updated', refresh)
+      window.removeEventListener('focus', refresh)
+    }
+  }, [user?.id, workspaceId])
+
+  function selectWorkspace(id: string) {
+    if (user?.id) {
+      localStorage.setItem(USER_WORKSPACE_KEY(user.id), id)
+    }
+    localStorage.setItem('xinghen_workspace', id)
+    setWorkspaceId(id)
+  }
+
+  return (
+    <WorkspaceContext.Provider
+      value={{
+        organizations,
+        workspaceId,
+        currentOrganization: organizations.find((org) => org.id === workspaceId) || null,
+        selectWorkspace,
+        refreshOrganizations
+      }}
+    >
+      {children}
+    </WorkspaceContext.Provider>
+  )
+}
+
+export const useWorkspace = () => useContext(WorkspaceContext)

+ 134 - 15
src/api.ts

@@ -1,10 +1,11 @@
-import type { AdminStats, Course, CourseStatus, CourseTemplate, PointsTransaction, User } from './types'
+import type { AdminStats, Course, CourseStatus, CourseTemplate, DigitalInstructor, Organization, PointsTransaction, User } from './types'
 
 const token = () => localStorage.getItem('xinghen_token')
 
 async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
   const headers = new Headers(init.headers)
   if (token()) headers.set('Authorization', `Bearer ${token()}`)
+  if (!headers.has('X-Workspace-Id')) headers.set('X-Workspace-Id', localStorage.getItem('xinghen_workspace') || 'personal')
   const response = await fetch(`/api${path}`, { ...init, headers })
   if (!response.ok) {
     const body = await response.json().catch(() => ({ message: '请求失败,请稍后重试' }))
@@ -14,16 +15,116 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
     }
     throw new Error(body.message || '请求失败,请稍后重试')
   }
-  return response.json() as Promise<T>
+  const result = await response.json() as T
+  if (init.method && init.method !== 'GET') window.dispatchEvent(new Event('workspace-updated'))
+  return result
+}
+
+export async function downloadPrivateFile(url: string, filename: string) {
+  const response = await fetch(url.replace('/uploads/', '/api/files/'), {
+    headers: { Authorization: `Bearer ${token()}` }
+  })
+  if (!response.ok) throw new Error('文件下载失败')
+  const objectUrl = URL.createObjectURL(await response.blob())
+  const link = document.createElement('a')
+  link.href = objectUrl
+  link.download = filename
+  link.click()
+  setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
+}
+
+export async function privateImageUrl(url: string) {
+  const response = await fetch(url.replace('/uploads/', '/api/files/'), {
+    headers: { Authorization: `Bearer ${token()}` }
+  })
+  if (!response.ok) throw new Error('图片不可访问')
+  return URL.createObjectURL(await response.blob())
+}
+
+export async function fetchPrivateAsset(url: string) {
+  const response = await fetch(url.replace('/uploads/', '/api/files/'), {
+    headers: { Authorization: `Bearer ${token()}` }
+  })
+  if (!response.ok) throw new Error('素材读取失败,请确认登录状态和课程权限')
+  return response.blob()
+}
+
+export async function fetchOfficePreview(courseId: string, assetId: string) {
+  const response = await fetch(`/api/courses/${encodeURIComponent(courseId)}/assets/${encodeURIComponent(assetId)}/preview`, {
+    headers: {
+      Authorization: `Bearer ${token()}`,
+      'X-Workspace-Id': localStorage.getItem('xinghen_workspace') || 'personal'
+    }
+  })
+  if (!response.ok) {
+    const body = await response.json().catch(() => null)
+    throw new Error(body?.message || '预览生成失败')
+  }
+  return response.blob()
 }
 
 export const api = {
+  digitalInstructors: {
+    list: (workspaceId?: string) => request<{ instructors: DigitalInstructor[] }>('/digital-instructors', workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}),
+    get: (id: string, workspaceId?: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}`, workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}),
+    usage: (id: string, workspaceId?: string) => request<{ courses: { id: string; name: string; status: string }[] }>(`/digital-instructors/${id}/usage`, workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}),
+    create: (data: { name: string; note: string; images: File[]; audio?: File | null; coverNewIndex?: number }) => {
+      const form = new FormData()
+      form.append('name', data.name)
+      form.append('note', data.note)
+      if (data.coverNewIndex !== undefined) form.append('coverNewIndex', String(data.coverNewIndex))
+      data.images.forEach(file => form.append('images', file))
+      if (data.audio) form.append('audio', data.audio)
+      return request<{ instructor: DigitalInstructor }>('/digital-instructors', { method: 'POST', body: form })
+    },
+    update: (id: string, data: Pick<DigitalInstructor, 'name' | 'note'>, workspaceId?: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...(workspaceId ? { 'X-Workspace-Id': workspaceId } : {}) }, body: JSON.stringify(data) }),
+    manage: (id: string, data: { name: string; note: string; ownerId?: string; images: File[]; audio: File | null; removeImageIds: string[]; coverImageId?: string; coverNewIndex?: number }, workspaceId?: string) => {
+      const form = new FormData()
+      form.append('name', data.name); form.append('note', data.note)
+      if (data.ownerId) form.append('ownerId', data.ownerId)
+      form.append('removeImageIds', JSON.stringify(data.removeImageIds))
+      if (data.coverImageId) form.append('coverImageId', data.coverImageId)
+      if (data.coverNewIndex !== undefined) form.append('coverNewIndex', String(data.coverNewIndex))
+      data.images.forEach(file => form.append('images', file))
+      if (data.audio) form.append('audio', data.audio)
+      return request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}/manage`, { method: 'POST', body: form, ...(workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}) })
+    },
+    owner: (id: string, ownerId: string, workspaceId?: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}/owner`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...(workspaceId ? { 'X-Workspace-Id': workspaceId } : {}) }, body: JSON.stringify({ ownerId }) }),
+    copy: (id: string, organizationId: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}/copy-to-organization`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ organizationId }) }),
+    duplicate: (id: string, workspaceId?: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}/duplicate`, { method: 'POST', ...(workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}) }),
+    delete: (id: string, workspaceId?: string) => request<{ ok: true }>(`/digital-instructors/${id}`, { method: 'DELETE', ...(workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}) })
+  },
+  organizations: {
+    mine: () => request<{ organizations: Organization[] }>('/me/organizations'),
+    publicInfo: (slug: string) => request<{ organization: { id: string; name: string; isActive: boolean } | null }>(`/organizations/${slug}`),
+    content: (id: string) => request<any>(`/organizations/${id}/content`),
+    apply: (slug: string) => request<{ ok: boolean }>(`/organizations/${slug}/apply`, { method: 'POST' }),
+    join: (inviteToken: string) => request<{ organizationId: string }>(`/organizations/join/${inviteToken}`, { method: 'POST' }),
+    manage: (id: string) => request<any>(`/organizations/${id}/manage`),
+    review: (id: string, applicationId: string, approve: boolean) => request<{ ok: boolean }>(`/organizations/${id}/applications/${applicationId}/review`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ approve })
+    }),
+    createUser: (id: string, phone: string, contactName: string) => request<{ user: User; initialPassword: string }>(`/organizations/${id}/users`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone, contactName })
+    }),
+    updateMemberName: (id: string, userId: string, contactName: string) => request<{ user: User }>(`/organizations/${id}/members/${userId}`, {
+      method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contactName })
+    }),
+    rotateInvite: (id: string) => request<{ token: string }>(`/organizations/${id}/invite`, { method: 'POST' }),
+    disableInvite: (id: string) => request<{ ok: boolean }>(`/organizations/${id}/invite`, { method: 'DELETE' }),
+    points: async (id: string) => {
+      const result = await request<{ balance: number; transactions: any[] }>(`/organizations/${id}/points`)
+      return { balance: result.balance, transactions: result.transactions.map((row) => ({
+        id: row.id, courseId: row.course_id, courseName: row.course_name || row.reason, points: row.amount, createdAt: row.created_at
+      })) as PointsTransaction[] }
+    }
+  },
   // 用户鉴权与信息
-  register: (phone: string, password: string) =>
+  register: (phone: string, password: string, contactName: string, inviteToken?: string) =>
     request<{ token: string; user: User }>('/auth/register', {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ phone, password })
+      body: JSON.stringify({ phone, password, contactName, inviteToken })
     }),
   login: (phone: string, password: string) =>
     request<{ token: string; user: User }>('/auth/login', {
@@ -35,7 +136,7 @@ export const api = {
   me: () => request<{ user: User }>('/me'),
   getPointsTransactions: () =>
     request<{ balance: number; transactions: PointsTransaction[] }>('/me/points-transactions'),
-  updateMe: (body: Pick<User, 'organization' | 'wechat' | 'contactName' | 'bio'>) =>
+  updateMe: (body: Pick<User, 'wechat' | 'contactName' | 'bio'>) =>
     request<{ user: User }>('/me', {
       method: 'PATCH',
       headers: { 'Content-Type': 'application/json' },
@@ -59,14 +160,14 @@ export const api = {
 
   // 普通用户课程操作
   listCourses: () => request<{ courses: Course[] }>('/courses'),
-  createCourse: (body: Pick<Course, 'name' | 'category' | 'description'>) =>
+  createCourse: (body: Pick<Course, 'name' | 'description'> & { digitalInstructorId?: string | null }) =>
     request<{ course: Course }>('/courses', {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify(body)
     }),
   getCourse: (id: string) => request<{ course: Course }>(`/courses/${id}`),
-  updateCourse: (id: string, body: Pick<Course, 'name' | 'category' | 'description'>) =>
+  updateCourse: (id: string, body: Partial<Pick<Course, 'name' | 'description' | 'assetNotes'>> & { digitalInstructorId?: string | null }) =>
     request<{ course: Course }>(`/courses/${id}`, {
       method: 'PATCH',
       headers: { 'Content-Type': 'application/json' },
@@ -77,7 +178,7 @@ export const api = {
     const form = new FormData()
     files.forEach((f) => form.append('assets', f))
     if (episodeId) form.append('episodeId', episodeId)
-    return request<{ course: Course }>(`/courses/${id}/assets`, { method: 'POST', body: form })
+    return request<{ course: Course; notesImported: number }>(`/courses/${id}/assets`, { method: 'POST', body: form })
   },
   deleteAsset: (id: string, assetId: string) =>
     request<{ course: Course }>(`/courses/${id}/assets/${assetId}`, { method: 'DELETE' }),
@@ -133,6 +234,30 @@ export const api = {
 
   // 管理员后台专区 API
   admin: {
+    organizations: () => request<{ organizations: any[] }>('/admin/organizations'),
+    createOrganization: (name: string, slug: string) => request<any>('/admin/organizations', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, slug })
+    }),
+    organization: (id: string) => request<any>(`/admin/organizations/${id}`),
+    renameOrganization: (id: string, name: string) => request<any>(`/admin/organizations/${id}`, {
+      method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name })
+    }),
+    updateOrganizationSlug: (id: string, slug: string) => request<any>(`/admin/organizations/${id}`, {
+      method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug })
+    }),
+    setOrganizationActive: (id: string, isActive: boolean) => request<any>(`/admin/organizations/${id}`, {
+      method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isActive })
+    }),
+    setOrganizationMember: (id: string, userId: string, role: 'MEMBER' | 'MANAGER') => request<any>(`/admin/organizations/${id}/members/${userId}`, {
+      method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role })
+    }),
+    removeOrganizationMember: (id: string, userId: string) => request<any>(`/admin/organizations/${id}/members/${userId}`, { method: 'DELETE' }),
+    reviewOrganizationApplication: (id: string, applicationId: string, approve: boolean) => request<any>(`/admin/organizations/${id}/applications/${applicationId}/review`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ approve })
+    }),
+    adjustOrganizationPoints: (id: string, amount: number, reason: string) => request<any>(`/admin/organizations/${id}/points`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amount, reason })
+    }),
     login: (phone: string, password: string) =>
       request<{ token: string; user: User }>('/admin/auth/login', {
         method: 'POST',
@@ -178,7 +303,7 @@ export const api = {
       const qs = keyword ? `?keyword=${encodeURIComponent(keyword)}` : ''
       return request<{ users: User[] }>(`/admin/users${qs}`)
     },
-    createUser: (body: { phone: string; password: string; contactName?: string; organization?: string }) =>
+    createUser: (body: { phone: string; password: string; contactName?: string }) =>
       request<{ user: User }>('/admin/users', {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
@@ -189,12 +314,6 @@ export const api = {
         method: 'PATCH',
         headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify({ password })
-      }),
-    updateUserRole: (id: string, role: 'USER' | 'ADMIN') =>
-      request<{ user: User }>(`/admin/users/${id}/role`, {
-        method: 'PATCH',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ role })
       })
   }
 }

+ 19 - 135
src/components/AddInstructorModal.tsx

@@ -1,145 +1,29 @@
-import { FormEvent, useState, useEffect } from 'react'
-import { X, UserPlus, Sparkles } from 'lucide-react'
-import { InstructorImageField } from './InstructorImageField'
+import { useEffect, useState } from 'react'
 import { api } from '../api'
 import type { Course } from '../types'
+import { DigitalInstructorPicker } from './DigitalInstructorPicker'
 
-interface AddInstructorModalProps {
-  isOpen: boolean
-  onClose: () => void
-  courseId: string
-  onSuccess: (course: Course) => void
-}
-
-export function AddInstructorModal({
-  isOpen,
-  onClose,
-  courseId,
-  onSuccess
-}: AddInstructorModalProps) {
-  const [busy, setBusy] = useState(false)
+export function AddInstructorModal({ isOpen, onClose, courseId, onSuccess }: { isOpen: boolean; onClose: () => void; courseId: string; onSuccess: (course: Course) => void }) {
+  const [course, setCourse] = useState<Course | null>(null)
+  const [selected, setSelected] = useState<string | null>(null)
   const [error, setError] = useState('')
-  const [images, setImages] = useState<File[]>([])
-
+  const [busy, setBusy] = useState(false)
   useEffect(() => {
-    function handleKeyDown(e: KeyboardEvent) {
-      if (e.key === 'Escape' && isOpen && !busy) {
-        onClose()
-      }
-    }
-    window.addEventListener('keydown', handleKeyDown)
-    return () => window.removeEventListener('keydown', handleKeyDown)
-  }, [isOpen, busy, onClose])
-
+    if (isOpen) api.getCourse(courseId).then(r => { setCourse(r.course); setSelected(r.course.digitalInstructorId || null) }).catch(e => setError(e.message))
+  }, [isOpen, courseId])
   if (!isOpen) return null
-
-  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
-    e.preventDefault()
-    const form = e.currentTarget
-    const f = new FormData(form)
-
-    const name = String(f.get('name') || '').trim()
-    if (!name) {
-      setError('请填写讲师姓名')
-      return
-    }
-
+  async function save() {
+    if (!course || !selected) { setError('请选择一位数字讲师'); return }
     setBusy(true)
-    setError('')
     try {
-      const res = await api.addInstructor(courseId, {
-        name,
-        organization: String(f.get('organization') || '').trim(),
-        introduction: String(f.get('introduction') || '').trim(),
-        images
-      })
-      onSuccess(res.course)
-      onClose()
-    } catch (err) {
-      setError((err as Error).message || '保存讲师信息失败,请重试')
-    } finally {
-      setBusy(false)
-    }
+      const result = await api.updateCourse(courseId, { name: course.name, description: course.description, digitalInstructorId: selected })
+      onSuccess(result.course); onClose()
+    } catch (e) { setError((e as Error).message) } finally { setBusy(false) }
   }
-
-  return (
-    <div className="modal-backdrop" onClick={() => !busy && onClose()}>
-      <div
-        className="modal-dialog instructor-modal"
-        onClick={(e) => e.stopPropagation()}
-      >
-        <div className="modal-header">
-          <div>
-            <h3>添加讲师信息</h3>
-          </div>
-          <button
-            type="button"
-            className="close-modal-btn"
-            onClick={onClose}
-            disabled={busy}
-            title="关闭"
-          >
-            <X size={18} />
-          </button>
-        </div>
-
-        <form onSubmit={handleSubmit} className="modal-form-content">
-          <div className="modal-body instructor-modal-body">
-            {error && <div className="notice notice-error">{error}</div>}
-
-            <div className="form-group-row">
-              <label className="form-label required">
-                <span>讲师姓名</span>
-                <input
-                  name="name"
-                  required
-                  placeholder="如:张教授 / 李主讲"
-                  disabled={busy}
-                  autoFocus
-                />
-              </label>
-              <label className="form-label">
-                <span>所属单位 / 机构</span>
-                <input
-                  name="organization"
-                  placeholder="如:中共党史与理论研究中心"
-                  disabled={busy}
-                />
-              </label>
-            </div>
-
-            <label className="form-label">
-              <span>讲师介绍</span>
-              <textarea
-                name="introduction"
-                rows={3}
-                placeholder="简要描述讲师专业背景、主讲方向与授课风格"
-                disabled={busy}
-              />
-            </label>
-
-            <InstructorImageField disabled={busy} onFileChange={setImages} />
-          </div>
-
-          <div className="modal-footer">
-            <button
-              type="button"
-              className="secondary-button"
-              onClick={onClose}
-              disabled={busy}
-            >
-              取消
-            </button>
-            <button
-              type="submit"
-              className="primary-button"
-              disabled={busy}
-            >
-              {busy ? '正在保存…' : '保存讲师'}
-            </button>
-          </div>
-        </form>
-      </div>
-    </div>
-  )
+  return <div className="modal-backdrop" onClick={onClose}><div className="modal-dialog instructor-modal" onClick={e => e.stopPropagation()}>
+    <div className="modal-header"><h3>选择数字讲师</h3><button type="button" onClick={onClose}>关闭</button></div>
+    {error && <div className="notice notice-error">{error}</div>}
+    <div className="modal-body"><DigitalInstructorPicker value={selected} onChange={setSelected} /></div>
+    <div className="modal-footer"><button className="primary-button" type="button" disabled={busy || !selected} onClick={save}>{busy ? '保存中…' : '保存选择'}</button></div>
+  </div></div>
 }

+ 139 - 0
src/components/CourseAssetPreview.tsx

@@ -0,0 +1,139 @@
+import { useEffect, useRef, useState } from 'react'
+import { ChevronLeft, ChevronRight, Download, X } from 'lucide-react'
+import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'
+import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
+import { downloadPrivateFile, fetchOfficePreview, fetchPrivateAsset } from '../api'
+import type { CourseAsset } from '../types'
+
+const extension = (name: string) => name.split('.').pop()?.toLowerCase() || ''
+const documentTypes = new Set(['pdf', 'pptx', 'ppt', 'docx', 'doc'])
+const officeTypes = new Set(['pptx', 'ppt', 'docx', 'doc'])
+const imageTypes = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'])
+const videoTypes = new Set(['mp4', 'webm', 'mov', 'm4v'])
+
+export function canPreviewAsset(asset: CourseAsset) {
+  const type = extension(asset.originalName)
+  return documentTypes.has(type) || imageTypes.has(type) || videoTypes.has(type) ||
+    asset.mimeType.startsWith('image/') || asset.mimeType.startsWith('video/')
+}
+
+export function CourseAssetPreview({ asset, courseId, onClose }: {
+  asset: CourseAsset
+  courseId: string
+  onClose: () => void
+}) {
+  const type = extension(asset.originalName)
+  const isDocument = documentTypes.has(type)
+  const isImage = imageTypes.has(type) || asset.mimeType.startsWith('image/')
+  const [objectUrl, setObjectUrl] = useState('')
+  const [document, setDocument] = useState<PDFDocumentProxy | null>(null)
+  const [page, setPage] = useState(1)
+  const [loading, setLoading] = useState(true)
+  const [rendering, setRendering] = useState(false)
+  const [error, setError] = useState('')
+  const canvas = useRef<HTMLCanvasElement>(null)
+  const canvasWrap = useRef<HTMLDivElement>(null)
+
+  useEffect(() => {
+    let active = true
+    let task: ReturnType<typeof import('pdfjs-dist').getDocument> | null = null
+    setLoading(true)
+    setError('')
+    setPage(1)
+    setDocument(null)
+    setObjectUrl('')
+    ;(async () => {
+      try {
+        const blob = officeTypes.has(type)
+          ? await fetchOfficePreview(courseId, asset.id)
+          : await fetchPrivateAsset(asset.url)
+        if (!active) return
+        if (isDocument) {
+          const pdfjs = await import('pdfjs-dist')
+          pdfjs.GlobalWorkerOptions.workerSrc = workerUrl
+          task = pdfjs.getDocument({ data: new Uint8Array(await blob.arrayBuffer()) })
+          const pdf = await task.promise
+          if (active) setDocument(pdf)
+        } else {
+          const url = URL.createObjectURL(blob)
+          if (active) setObjectUrl(url)
+          else URL.revokeObjectURL(url)
+        }
+      } catch (cause) {
+        if (active) setError((cause as Error).message || '无法预览素材')
+      } finally {
+        if (active) setLoading(false)
+      }
+    })()
+    return () => {
+      active = false
+      if (task) void task.destroy()
+    }
+  }, [asset.id, asset.url, courseId, isDocument, type])
+
+  useEffect(() => {
+    if (!objectUrl) return
+    return () => URL.revokeObjectURL(objectUrl)
+  }, [objectUrl])
+
+  useEffect(() => {
+    if (!document || !canvas.current || !canvasWrap.current) return
+    let cancelled = false
+    let renderTask: RenderTask | null = null
+    setRendering(true)
+    ;(async () => {
+      try {
+        const pdfPage = await document.getPage(page)
+        if (cancelled || !canvas.current || !canvasWrap.current) return
+        const canvasElement = canvas.current
+        const context = canvasElement.getContext('2d')
+        if (!context) throw new Error('浏览器无法绘制预览')
+        const base = pdfPage.getViewport({ scale: 1 })
+        const fitWidth = Math.max(280, Math.min(canvasWrap.current.clientWidth - 32, 1300))
+        const viewport = pdfPage.getViewport({ scale: fitWidth / base.width })
+        const dpr = Math.min(window.devicePixelRatio || 1, 2)
+        canvasElement.width = Math.round(viewport.width * dpr)
+        canvasElement.height = Math.round(viewport.height * dpr)
+        canvasElement.style.width = `${Math.round(viewport.width)}px`
+        canvasElement.style.height = `${Math.round(viewport.height)}px`
+        renderTask = pdfPage.render({ canvas: canvasElement, canvasContext: context, viewport, transform: [dpr, 0, 0, dpr, 0, 0] })
+        await renderTask.promise
+      } catch (cause) {
+        if (!cancelled && !(cause instanceof Error && cause.name === 'RenderingCancelledException')) setError((cause as Error).message)
+      } finally {
+        if (!cancelled) setRendering(false)
+      }
+    })()
+    return () => { cancelled = true; renderTask?.cancel() }
+  }, [document, page])
+
+  useEffect(() => {
+    const keyDown = (event: KeyboardEvent) => {
+      if (event.key === 'Escape') onClose()
+      if (!document) return
+      if (event.key === 'ArrowRight') setPage((current) => Math.min(document.numPages, current + 1))
+      if (event.key === 'ArrowLeft') setPage((current) => Math.max(1, current - 1))
+    }
+    window.addEventListener('keydown', keyDown)
+    return () => window.removeEventListener('keydown', keyDown)
+  }, [document, onClose])
+
+  return <div className="asset-preview-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}>
+    <div className="asset-preview-dialog" role="dialog" aria-modal="true" aria-label={`预览 ${asset.originalName}`}>
+      <header className="asset-preview-header"><div><strong title={asset.originalName}>{asset.originalName}</strong><span>{isDocument ? officeTypes.has(type) ? '文档预览' : 'PDF 预览' : isImage ? '图片预览' : '视频预览'}</span>{officeTypes.has(type) && <span className="asset-preview-warning">预览可能存在字体或排版差异,请以下载的原文件为准。</span>}</div><button type="button" onClick={onClose} aria-label="关闭预览"><X size={22} /></button></header>
+      <div className="asset-preview-stage" ref={canvasWrap}>
+        {loading && <p className="asset-preview-message">正在加载预览{officeTypes.has(type) ? ',首次转换可能需要一些时间' : ''}…</p>}
+        {error && <div className="asset-preview-message" role="alert"><p>{error}</p><small>可以下载原文件查看。</small></div>}
+        {isDocument && <canvas ref={canvas} className="asset-preview-canvas" style={{ display: document && !error ? 'block' : 'none' }} />}
+        {!isDocument && objectUrl && (isImage
+          ? <img className="asset-preview-media" src={objectUrl} alt={asset.originalName} onError={() => setError('浏览器不支持预览此图片格式')} />
+          : <video className="asset-preview-media" src={objectUrl} controls autoPlay={false} onError={() => setError('浏览器不支持播放此视频格式')} />)}
+        {rendering && !loading && !error && <span className="asset-preview-rendering">正在渲染第 {page} 页…</span>}
+      </div>
+      <footer className="asset-preview-footer">
+        <div>{document && <><button type="button" disabled={page <= 1 || rendering} onClick={() => setPage(page - 1)} aria-label="上一页"><ChevronLeft size={18} /> 上一页</button><span>第 {page} / {document.numPages} 页</span><button type="button" disabled={page >= document.numPages || rendering} onClick={() => setPage(page + 1)} aria-label="下一页">下一页 <ChevronRight size={18} /></button></>}</div>
+        <button type="button" onClick={() => void downloadPrivateFile(asset.url, asset.originalName).catch((cause) => setError((cause as Error).message))}><Download size={17} /> 下载原文件</button>
+      </footer>
+    </div>
+  </div>
+}

+ 239 - 0
src/components/DigitalInstructorCreateForm.tsx

@@ -0,0 +1,239 @@
+import { useEffect, useRef, useState } from 'react'
+import { api } from '../api'
+import { ProtectedImage } from './ProtectedImage'
+import { ProtectedAudio } from './ProtectedAudio'
+import type { DigitalInstructor } from '../types'
+
+type Props = {
+  workspaceName: string
+  organizationNames: string[] | null
+  onCreated: () => void
+  onCancel: () => void
+  existing?: DigitalInstructor
+  workspaceId?: string
+  ownerOptions?: { id: string; contact_name: string }[]
+  canManageOwner?: boolean
+}
+
+const imageTypes = ['image/jpeg', 'image/png', 'image/webp']
+const audioTypes = ['audio/wav', 'audio/x-wav', 'audio/mpeg', 'audio/mp4', 'audio/x-m4a', 'audio/webm', 'audio/ogg']
+const maxImages = 19
+const readingScript = '大家好,欢迎来到今天的课程。接下来,我会用清晰、自然的语速介绍本节内容。学习时可以先了解整体思路,再结合具体例子逐步练习。遇到不明白的地方,请暂停思考,之后继续学习。希望这段讲解能帮助大家掌握新的知识。'
+
+export function DigitalInstructorCreateForm({ workspaceName, organizationNames, onCreated, onCancel, existing, workspaceId, ownerOptions, canManageOwner }: Props) {
+  const [name, setName] = useState(existing?.name || '')
+  const [note, setNote] = useState(existing?.note || '')
+  const [noteOpen, setNoteOpen] = useState(!!existing?.note)
+  const [images, setImages] = useState<File[]>([])
+  const [audio, setAudio] = useState<File | null>(null)
+  const [removedImageIds, setRemovedImageIds] = useState<string[]>([])
+  const [coverChoice, setCoverChoice] = useState(existing?.images[0] ? `existing:${existing.images[0].id}` : '')
+  const [ownerId, setOwnerId] = useState(existing?.ownerId || '')
+  const [imageUrls, setImageUrls] = useState<string[]>([])
+  const [audioUrl, setAudioUrl] = useState('')
+  const [preview, setPreview] = useState<{ src: string; alt: string; protected: boolean } | null>(null)
+  const [error, setError] = useState('')
+  const [busy, setBusy] = useState(false)
+  const [recordingStatus, setRecordingStatus] = useState<'idle' | 'requesting' | 'countdown' | 'recording' | 'processing'>('idle')
+  const [recordingSeconds, setRecordingSeconds] = useState(0)
+  const [countdown, setCountdown] = useState(3)
+  const [photoGuideOpen, setPhotoGuideOpen] = useState(false)
+  const recorderRef = useRef<MediaRecorder | null>(null)
+  const streamRef = useRef<MediaStream | null>(null)
+  const recordingStartedAtRef = useRef(0)
+  const discardRecordingRef = useRef(false)
+  const mountedRef = useRef(true)
+
+  useEffect(() => {
+    mountedRef.current = true
+    return () => {
+      mountedRef.current = false
+      discardRecordingRef.current = true
+      if (recorderRef.current?.state === 'recording') recorderRef.current.stop()
+      streamRef.current?.getTracks().forEach(track => track.stop())
+    }
+  }, [])
+  useEffect(() => {
+    if (recordingStatus !== 'recording') return
+    const timer = window.setInterval(() => setRecordingSeconds(Math.floor((Date.now() - recordingStartedAtRef.current) / 1000)), 250)
+    return () => window.clearInterval(timer)
+  }, [recordingStatus])
+  useEffect(() => {
+    if (recordingStatus !== 'countdown') return
+    if (countdown === 0) {
+      try {
+        if (recorderRef.current?.state === 'inactive') recorderRef.current.start(1000)
+        recordingStartedAtRef.current = Date.now()
+        setRecordingSeconds(0)
+        setRecordingStatus('recording')
+      } catch {
+        streamRef.current?.getTracks().forEach(track => track.stop())
+        streamRef.current = null
+        recorderRef.current = null
+        setRecordingStatus('idle')
+        setError('录音无法开始,请重试或上传录音文件。')
+      }
+      return
+    }
+    const timer = window.setTimeout(() => setCountdown(value => value - 1), 1000)
+    return () => window.clearTimeout(timer)
+  }, [recordingStatus, countdown])
+
+  useEffect(() => {
+    const urls = images.map(file => URL.createObjectURL(file))
+    setImageUrls(urls)
+    return () => urls.forEach(url => URL.revokeObjectURL(url))
+  }, [images])
+  useEffect(() => {
+    if (!audio) { setAudioUrl(''); return }
+    const url = URL.createObjectURL(audio)
+    setAudioUrl(url)
+    return () => URL.revokeObjectURL(url)
+  }, [audio])
+  useEffect(() => {
+    if (!preview && !photoGuideOpen) return
+    const close = (event: KeyboardEvent) => { if (event.key === 'Escape') { setPreview(null); setPhotoGuideOpen(false) } }
+    window.addEventListener('keydown', close)
+    return () => window.removeEventListener('keydown', close)
+  }, [preview, photoGuideOpen])
+
+  const duplicate = !!organizationNames?.some(otherName => otherName.trim().toLocaleLowerCase() === name.trim().toLocaleLowerCase())
+  const retainedImages = existing?.images.filter(image => !removedImageIds.includes(image.id)) || []
+
+  function chooseImages(files: FileList | null) {
+    if (!files?.length) return
+    const picked = Array.from(files)
+    if (picked.some(file => !imageTypes.includes(file.type) || file.size === 0 || file.size > 20 * 1024 * 1024)) {
+      setError('照片仅支持 JPG、PNG、WebP;每张不超过 20 MB,且不能为空。')
+      return
+    }
+    const next = [...images]
+    for (const file of picked) {
+      if (!next.some(existing => existing.name === file.name && existing.size === file.size && existing.lastModified === file.lastModified)) next.push(file)
+    }
+    if (next.length + retainedImages.length > maxImages) { setError(`最多保留 ${maxImages} 张照片。`); return }
+    setError('')
+    setImages(next)
+  }
+  function chooseAudio(file?: File) {
+    if (!file) return
+    if (!audioTypes.includes(file.type) || file.size === 0 || file.size > 50 * 1024 * 1024) {
+      setError('录音仅支持 WAV、MP3、M4A、WebM、Ogg;文件不超过 50 MB,且不能为空。')
+      return
+    }
+    setError('')
+    setAudio(file)
+  }
+  async function startRecording() {
+    if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
+      setError('当前浏览器无法直接录音,请使用上传录音文件。')
+      return
+    }
+    setError('')
+    setRecordingStatus('requesting')
+    try {
+      const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
+      if (!mountedRef.current) { stream.getTracks().forEach(track => track.stop()); return }
+      const mimeType = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus']
+        .find(type => MediaRecorder.isTypeSupported(type))
+      const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
+      const chunks: BlobPart[] = []
+      streamRef.current = stream
+      recorderRef.current = recorder
+      discardRecordingRef.current = false
+      recorder.ondataavailable = event => { if (event.data.size) chunks.push(event.data) }
+      recorder.onerror = () => {
+        discardRecordingRef.current = true
+        stream.getTracks().forEach(track => track.stop())
+        if (mountedRef.current) { setRecordingStatus('idle'); setError('录音中断,请重试或上传录音文件。') }
+      }
+      recorder.onstop = () => {
+        stream.getTracks().forEach(track => track.stop())
+        streamRef.current = null
+        recorderRef.current = null
+        if (!mountedRef.current) return
+        setRecordingStatus('idle')
+        if (discardRecordingRef.current) return
+        const type = recorder.mimeType.split(';')[0] || 'audio/webm'
+        const extension = type === 'audio/mp4' ? 'm4a' : type === 'audio/ogg' ? 'ogg' : 'webm'
+        const blob = new Blob(chunks, { type })
+        chooseAudio(new File([blob], `讲师录音-${Date.now()}.${extension}`, { type }))
+      }
+      setCountdown(3)
+      setRecordingStatus('countdown')
+    } catch {
+      streamRef.current?.getTracks().forEach(track => track.stop())
+      streamRef.current = null
+      if (mountedRef.current) { setRecordingStatus('idle'); setError('麦克风未启用或无法访问。请允许麦克风权限,或上传已有录音文件。') }
+    }
+  }
+  function stopRecording(discard = false) {
+    discardRecordingRef.current = discard
+    if (recordingStatus === 'countdown') {
+      streamRef.current?.getTracks().forEach(track => track.stop())
+      streamRef.current = null
+      recorderRef.current = null
+      setRecordingStatus('idle')
+      return
+    }
+    if (recorderRef.current?.state === 'recording') {
+      setRecordingStatus('processing')
+      recorderRef.current.stop()
+    }
+  }
+  async function save() {
+    if (!name.trim()) { setError('请先填写讲师姓名。'); return }
+    if (duplicate) { setError('这个组织已有同名讲师,请换一个名字。'); return }
+    if (recordingStatus !== 'idle') { setError('请先结束录音,再保存讲师。'); return }
+    setBusy(true); setError('')
+    try {
+      if (existing) {
+        await api.digitalInstructors.manage(existing.id, {
+          name: name.trim(), note: note.trim(), ownerId: canManageOwner ? (ownerId || undefined) : undefined, images, audio, removeImageIds: removedImageIds,
+          coverImageId: coverChoice.startsWith('existing:') ? coverChoice.slice(9) : undefined,
+          coverNewIndex: coverChoice.startsWith('new:') ? Number(coverChoice.slice(4)) : undefined
+        }, workspaceId)
+      } else await api.digitalInstructors.create({ name: name.trim(), note: note.trim(), images, audio, coverNewIndex: coverChoice.startsWith('new:') ? Number(coverChoice.slice(4)) : undefined })
+      onCreated()
+    } catch (cause) { setError((cause as Error).message) }
+    finally { setBusy(false) }
+  }
+
+  return <section className="digital-create-panel" aria-label={existing ? '编辑数字讲师' : '创建数字讲师'}>
+    <div className="digital-create-intro"><div><span className="digital-create-eyebrow">{existing ? '编辑讲师' : '新建讲师'} · {workspaceName}</span><h2>{existing ? '编辑数字讲师' : '创建数字讲师'}</h2><p>按顺序填写资料、准备照片和录音。素材可以稍后补齐,提交课程制作前需要两项齐备。</p></div></div>
+    {error && <div className="notice notice-error" role="alert">{error}</div>}
+    <div className="digital-create-steps">
+      <section className="digital-step"><div className="digital-step-title"><span>01</span><div><h3>讲师资料</h3><p>用一个容易辨认的名称管理这位讲师。</p></div></div>
+        <div className="digital-step-body"><label className="digital-field">讲师姓名 <strong>必填</strong><input autoFocus value={name} onChange={event => setName(event.target.value)} maxLength={80} placeholder="例如:张老师" aria-invalid={duplicate} /></label>
+          {organizationNames && <p className={duplicate ? 'digital-field-error' : 'digital-field-hint'}>{duplicate ? '这个组织已有同名讲师,请换一个名字。' : '同一组织中的讲师不能重名。'}</p>}
+          <button type="button" className="digital-note-toggle" aria-expanded={noteOpen} onClick={() => setNoteOpen(open => !open)}>{noteOpen ? '▾ 收起备注' : `+ 添加备注${note ? ' · 已填写' : '(选填)'}`}</button>
+          {noteOpen && <label className="digital-field">备注 <span>选填,仅用于内部记录</span><textarea value={note} onChange={event => setNote(event.target.value)} maxLength={1000} placeholder="例如:适合讲解哪类课程、后续需要补充什么素材" rows={3} /></label>}
+          {existing && (existing.organizationId || workspaceId !== 'personal') && (
+            canManageOwner && ownerOptions && ownerOptions.length > 0 ? (
+              <label className="digital-field">所有者<select value={ownerId} onChange={event => setOwnerId(event.target.value)}>{ownerOptions.map(member => <option key={member.id} value={member.id}>{member.contact_name || '未填写姓名'}</option>)}</select><span className="digital-field-hint">管理员可为该讲师分配新的所有者</span></label>
+            ) : (
+              <label className="digital-field">所有者<input type="text" value={existing.ownerName === '未知用户' ? '历史导入' : (existing.ownerName || '未知用户')} disabled readOnly /><span className="digital-field-hint">默认所有者为创建者本人,仅管理员可修改</span></label>
+            )
+          )}</div>
+      </section>
+      <section className="digital-step"><div className="digital-step-title"><span>02</span><div><h3>上传参考照片</h3><p>建议使用清晰的正面半身照,便于生成稳定的数字人形象。</p></div></div>
+        <div className="digital-step-body"><div className="digital-guidance"><strong>拍摄时留意</strong><ul><li>纯色、整洁的背景;正面均匀光线,避免逆光和强阴影。</li><li>肩部以上居中,面部无遮挡;建议手机后置摄像头、1080P 以上,关闭美颜。</li></ul><button type="button" className="digital-guide-button" onClick={() => setPhotoGuideOpen(true)}>查看照片参考样例 →</button></div>
+          <div className="digital-upload-actions"><label className="digital-upload-zone"><span className="digital-upload-icon">▧</span><strong>{images.length ? '继续添加照片' : '从相册或电脑选择'}</strong><small>可多次添加,最多 {maxImages} 张;JPG、PNG、WebP,每张不超过 20 MB</small><input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => { chooseImages(event.target.files); event.target.value = '' }} /></label>
+            <label className="digital-upload-zone digital-camera-zone"><span className="digital-upload-icon">◎</span><strong>用手机拍照</strong><small>拍完可以继续拍下一张</small><input type="file" accept="image/jpeg,image/png,image/webp" capture="environment" onChange={event => { chooseImages(event.target.files); event.target.value = '' }} /></label></div>
+          {(retainedImages.length > 0 || images.length > 0) && <div className="digital-upload-preview"><div className="digital-preview-heading"><strong>照片 {retainedImages.length + images.length} 张</strong><span>点击照片放大,选择一张作为卡片封面</span></div><div className="digital-photo-list">{retainedImages.map(image => <div key={image.id}><button type="button" className="digital-photo-open" onClick={() => setPreview({ src: image.url, alt: image.name, protected: true })} aria-label={`放大查看 ${image.name}`}><ProtectedImage src={image.url} alt={image.name} /></button><span>{image.name}</span><button type="button" onClick={() => setCoverChoice(`existing:${image.id}`)}>{coverChoice === `existing:${image.id}` ? '✓ 封面' : '设为封面'}</button><button type="button" onClick={() => { setRemovedImageIds(ids => [...ids,image.id]); if (coverChoice === `existing:${image.id}`) setCoverChoice('') }} aria-label={`移除照片 ${image.name}`}>移除</button></div>)}{images.map((file, index) => <div key={`${file.name}-${index}`}><button type="button" className="digital-photo-open" onClick={() => setPreview({ src: imageUrls[index], alt: file.name, protected: false })} aria-label={`放大查看 ${file.name}`}><img src={imageUrls[index]} alt={`新参考照片 ${index + 1}`} /></button><span>{file.name}</span><button type="button" onClick={() => setCoverChoice(`new:${index}`)}>{coverChoice === `new:${index}` ? '✓ 封面' : '设为封面'}</button><button type="button" onClick={() => { setImages(current => current.filter((_, photoIndex) => photoIndex !== index)); if (coverChoice === `new:${index}`) setCoverChoice(''); else if (coverChoice.startsWith('new:') && Number(coverChoice.slice(4)) > index) setCoverChoice(`new:${Number(coverChoice.slice(4)) - 1}`) }} aria-label={`移除照片 ${file.name}`}>移除</button></div>)}</div></div>}</div>
+      </section>
+      <section className="digital-step"><div className="digital-step-title"><span>03</span><div><h3>上传声音录音</h3><p>请上传讲师本人连续、自然的人声录音。</p></div></div>
+        <div className="digital-step-body"><div className="digital-guidance"><strong>录音时留意</strong><ul><li>在安静环境录制,关好门窗,避开空调、风扇和明显回声。</li><li>距麦克风约 15–20 厘米,不喷麦、不捂麦;照着下面的文字自然朗读。录满 10 秒会提示时长达标,更长的连续录音通常更有利于后续声音处理。</li></ul></div>
+          <div className="digital-reading-script"><div><strong>录音朗读稿</strong><span>开始录音后照着读,读完可继续自然讲述</span></div><p>{readingScript}</p></div>
+          <div className="digital-upload-actions"><label className="digital-upload-zone"><span className="digital-upload-icon">♫</span><strong>{audio ? '更换录音文件' : '上传录音文件'}</strong><small>WAV、MP3、M4A、WebM、Ogg · 不超过 50 MB</small><input type="file" accept="audio/wav,audio/x-wav,audio/mpeg,audio/mp4,audio/x-m4a,audio/webm,audio/ogg" onChange={event => { chooseAudio(event.target.files?.[0]); event.target.value = '' }} /></label>
+            <div className="digital-record-zone"><span className="digital-upload-icon">●</span><strong>直接录音</strong><small>使用手机或电脑麦克风</small>{recordingStatus === 'countdown' ? <div className="digital-record-actions"><span className="digital-record-countdown" role="status" aria-live="assertive">{countdown}</span><span>准备开始朗读…</span><button type="button" onClick={() => stopRecording(true)}>取消</button></div> : recordingStatus === 'recording' ? <div className="digital-record-actions"><span className={recordingSeconds >= 10 ? 'digital-record-time-ready' : ''} role="status">录制中 {Math.floor(recordingSeconds / 60).toString().padStart(2, '0')}:{(recordingSeconds % 60).toString().padStart(2, '0')} {recordingSeconds >= 10 ? '✓ 时长达标' : '· 满 10 秒变绿'}</span><button type="button" onClick={() => stopRecording()}>结束并使用</button><button type="button" onClick={() => stopRecording(true)}>取消</button></div> : <button type="button" onClick={startRecording} disabled={recordingStatus !== 'idle'}>{recordingStatus === 'requesting' ? '正在请求麦克风…' : recordingStatus === 'processing' ? '正在处理录音…' : audio ? '重新录音' : '开始录音'}</button>}</div></div>
+          {audio && <div className="digital-audio-preview"><div><strong>{audio.name} · 待保存</strong><button type="button" onClick={() => setAudio(null)}>移除</button></div>{audioUrl && <audio controls src={audioUrl} aria-label="试听已选录音" />}</div>}
+          {!audio && existing?.audio[0] && <div className="digital-audio-preview"><strong>当前录音:{existing.audio[0].name}</strong><ProtectedAudio src={existing.audio[0].url} /></div>}
+          </div>
+      </section>
+    </div>
+    <div className="digital-create-footer"><p>目前只检查文件格式和是否上传,不会自动判断照片或声音质量。{!(retainedImages.length || images.length) || !(audio || existing?.audio.length) ? '现在可以先保存;提交课程制作前须补齐照片与录音。' : '照片与录音已齐备,可在课程中选择。'}</p><div><button type="button" className="secondary-button" onClick={onCancel} disabled={busy}>取消</button><button type="button" className="primary-button" onClick={save} disabled={busy || duplicate || recordingStatus !== 'idle'}>{busy ? '保存中…' : existing ? '保存修改' : '保存数字讲师'}</button></div></div>
+    {photoGuideOpen && <div className="digital-photo-lightbox" role="presentation" onClick={() => setPhotoGuideOpen(false)}><div className="digital-photo-guide-dialog" role="dialog" aria-modal="true" aria-label="照片参考样例" onClick={event => event.stopPropagation()}><button type="button" className="digital-photo-lightbox-close" onClick={() => setPhotoGuideOpen(false)} aria-label="关闭照片参考样例">×</button><h3>照片参考样例</h3><p>上传前对照检查:正面、光线均匀、背景整洁,人物完整清晰。</p><div className="digital-guide-examples"><div><div className="digital-guide-example-image digital-guide-example-good" role="img" aria-label="合适的正面半身照示例" /><strong>✓ 适合作为参考</strong><span>正面拍摄,背景干净,光线均匀</span></div><div><div className="digital-guide-example-image digital-guide-example-bad" role="img" aria-label="逆光且背景杂乱的反例" /><strong>✕ 建议重新拍摄</strong><span>逆光、背对镜头,背景杂乱</span></div></div><details><summary>查看完整拍摄说明</summary><img src="/guides/digital-instructor-photo-reference.png" alt="照片与录音素材准备参考材料" /></details></div></div>}
+    {preview && <div className="digital-photo-lightbox" role="presentation" onClick={() => setPreview(null)}><div className="digital-photo-lightbox-content" role="dialog" aria-modal="true" aria-label={`查看照片:${preview.alt}`} onClick={event => event.stopPropagation()}><button type="button" className="digital-photo-lightbox-close" onClick={() => setPreview(null)} aria-label="关闭照片预览">×</button>{preview.protected ? <ProtectedImage src={preview.src} alt={preview.alt} /> : <img src={preview.src} alt={preview.alt} />}<p>{preview.alt}</p></div></div>}
+  </section>
+}

+ 28 - 0
src/components/DigitalInstructorPicker.tsx

@@ -0,0 +1,28 @@
+import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { api } from '../api'
+import { ProtectedImage } from './ProtectedImage'
+import type { DigitalInstructor } from '../types'
+
+export function DigitalInstructorPicker({ value, onChange }: { value: string | null; onChange: (id: string | null) => void }) {
+  const [items, setItems] = useState<DigitalInstructor[]>([])
+  const [error, setError] = useState('')
+  const [search, setSearch] = useState('')
+  useEffect(() => { api.digitalInstructors.list().then(r => setItems(r.instructors)).catch(e => setError(e.message)) }, [])
+  const matches = items.filter(item => `${item.name} ${item.creatorName}`.toLowerCase().includes(search.trim().toLowerCase()))
+  return <div className="digital-picker">
+    <div className="digital-picker-head"><strong>选择数字讲师</strong></div>
+    {error && <p className="form-error">{error}</p>}
+    {items.length > 0 && <input className="digital-search" aria-label="搜索数字讲师" placeholder="搜索讲师姓名或创建者" value={search} onChange={e => setSearch(e.target.value)} />}
+    <div className="digital-picker-options">
+      {matches.map(item => <button type="button" key={item.id} className={`digital-picker-item ${value === item.id ? 'selected' : ''}`} onClick={() => onChange(item.id)}>
+        {item.images[0] ? <ProtectedImage src={item.images[0].url} alt={item.name} /> : <span className="avatar">{item.name[0]}</span>}
+        <span><strong>{item.name}</strong><small>创建者 {item.creatorName} · {new Date(item.createdAt).toLocaleDateString('zh-CN')}</small></span>
+        <span>{value === item.id ? '已选中' : item.ready ? '选择' : '待补齐素材'}</span>
+      </button>)}
+      {!items.length && !error && <p>当前工作区还没有数字讲师。</p>}
+      {items.length > 0 && !matches.length && <p>没有找到符合条件的讲师。</p>}
+    </div>
+    {!items.length && <p className="digital-picker-help">请先到 <Link to="/digital-instructors">数字讲师资产库</Link> 创建,再返回课程选择。</p>}
+  </div>
+}

+ 0 - 16
src/components/EditCourseModal.tsx

@@ -12,7 +12,6 @@ interface EditCourseModalProps {
 
 export function EditCourseModal({ course, isOpen, onClose, onSuccess }: EditCourseModalProps) {
   const [name, setName] = useState(course.name)
-  const [category, setCategory] = useState(course.category)
   const [description, setDescription] = useState(course.description)
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState('')
@@ -20,7 +19,6 @@ export function EditCourseModal({ course, isOpen, onClose, onSuccess }: EditCour
   useEffect(() => {
     if (!isOpen) return
     setName(course.name)
-    setCategory(course.category)
     setDescription(course.description)
     setError('')
   }, [isOpen, course])
@@ -46,7 +44,6 @@ export function EditCourseModal({ course, isOpen, onClose, onSuccess }: EditCour
     try {
       const res = await api.updateCourse(course.id, {
         name: name.trim(),
-        category,
         description: description.trim()
       })
       onSuccess(res.course)
@@ -77,19 +74,6 @@ export function EditCourseModal({ course, isOpen, onClose, onSuccess }: EditCour
               <span>课程名称</span>
               <input autoFocus required maxLength={100} value={name} onChange={(e) => setName(e.target.value)} disabled={busy} />
             </label>
-            <label className="form-label">
-              <span>课程分类</span>
-              <select value={category} onChange={(e) => setCategory(e.target.value)} disabled={busy}>
-                {category && !['党政专题', '高校公开课', '企业培训', '职业教育', '其他'].includes(category) && (
-                  <option value={category}>{category}</option>
-                )}
-                <option value="党政专题">党政专题</option>
-                <option value="高校公开课">高校公开课</option>
-                <option value="企业培训">企业培训</option>
-                <option value="职业教育">职业教育</option>
-                <option value="其他">其他</option>
-              </select>
-            </label>
             <label className="form-label">
               <span className="edit-course-field-heading">
                 <span>课程简介与制作要求</span>

+ 42 - 39
src/components/EpisodeModal.tsx

@@ -41,8 +41,8 @@ export function EpisodeModal({
         setActiveTab('single')
         setTitle(episode.title)
         setEpisodeNumber(episode.episodeNumber)
-        setLectureNotes(episode.lectureNotes || '')
-        setShowOptionalFields(!!episode.lectureNotes?.trim())
+        setLectureNotes('')
+        setShowOptionalFields(false)
       } else {
         setActiveTab('single')
         setTitle(`第 ${nextNumber} 集:`)
@@ -94,8 +94,7 @@ export function EpisodeModal({
       if (episode) {
         res = await api.updateEpisode(courseId, episode.id, {
           title: title.trim(),
-          episodeNumber: Number(episodeNumber) || 1,
-          lectureNotes: lectureNotes.trim()
+          episodeNumber: Number(episodeNumber) || 1
         })
       } else {
         res = await api.createEpisode(courseId, {
@@ -215,43 +214,47 @@ export function EpisodeModal({
                 </label>
               </div>
 
-              {/* 选填信息折叠开关 */}
-              <div className="optional-fields-toggle">
-                <button
-                  type="button"
-                  className="toggle-optional-btn"
-                  onClick={() => setShowOptionalFields(!showOptionalFields)}
-                >
-                  {showOptionalFields ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
-                  <span>{showOptionalFields ? '收起口播讲稿' : '+ 填写分集口播讲稿(选填)'}</span>
-                </button>
-              </div>
+              {/* 选填信息折叠开关(仅在添加分集时显示;编辑分集时请直接在工作台卡片中编辑,避免重复) */}
+              {!episode && (
+                <>
+                  <div className="optional-fields-toggle">
+                    <button
+                      type="button"
+                      className="toggle-optional-btn"
+                      onClick={() => setShowOptionalFields(!showOptionalFields)}
+                    >
+                      {showOptionalFields ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
+                      <span>{showOptionalFields ? '收起口播讲稿' : '+ 填写分集口播讲稿(选填)'}</span>
+                    </button>
+                  </div>
 
-              {showOptionalFields && (
-                <div className="optional-fields-container">
-                  <label className="form-label">
-                    <div className="label-with-badge">
-                      <span>分集口播讲稿正文(选填)</span>
-                      {lectureNotes.trim().length > 0 && (
-                        <span className="lecture-stats-badge">
-                          <FileText size={12} /> {lectureNotes.trim().length} 字 · 预计口播约 {estMinutes} 分钟
-                        </span>
-                      )}
+                  {showOptionalFields && (
+                    <div className="optional-fields-container">
+                      <label className="form-label">
+                        <div className="label-with-badge">
+                          <span>分集口播讲稿正文(选填)</span>
+                          {lectureNotes.trim().length > 0 && (
+                            <span className="lecture-stats-badge">
+                              <FileText size={12} /> {lectureNotes.trim().length} 字 · 预计口播约 {estMinutes} 分钟
+                            </span>
+                          )}
+                        </div>
+                        <textarea
+                          rows={5}
+                          value={lectureNotes}
+                          onChange={(e) => setLectureNotes(e.target.value)}
+                          placeholder="选填。可在线粘贴讲稿,也可在创建分集后随时在工作台编辑或上传 Word/PDF 附件…"
+                          disabled={busy}
+                        />
+                        <small className="hint-text">
+                          {lectureNotes
+                            ? `已录入 ${lectureNotes.length} 字符`
+                            : '创建分集后可在工作台直接在线编辑讲稿或上传附件'}
+                        </small>
+                      </label>
                     </div>
-                    <textarea
-                      rows={5}
-                      value={lectureNotes}
-                      onChange={(e) => setLectureNotes(e.target.value)}
-                      placeholder="选填。可在线粘贴讲稿,也可在创建分集后随时在工作台编辑或上传 Word/PDF 附件…"
-                      disabled={busy}
-                    />
-                    <small className="hint-text">
-                      {lectureNotes
-                        ? `已录入 ${lectureNotes.length} 字符`
-                        : '创建分集后可在工作台直接在线编辑讲稿或上传附件'}
-                    </small>
-                  </label>
-                </div>
+                  )}
+                </>
               )}
             </div>
 

+ 84 - 0
src/components/InstructorVoicePlayer.tsx

@@ -0,0 +1,84 @@
+import { useEffect, useRef, useState } from 'react'
+import { Volume2, Loader2 } from 'lucide-react'
+import { privateImageUrl } from '../api'
+
+interface InstructorVoicePlayerProps {
+  src: string
+  title?: string
+}
+
+export function InstructorVoicePlayer({ src, title = '播放讲师语音' }: InstructorVoicePlayerProps) {
+  const [isPlaying, setIsPlaying] = useState(false)
+  const [loading, setLoading] = useState(false)
+  const audioRef = useRef<HTMLAudioElement | null>(null)
+  const blobUrlRef = useRef<string | null>(null)
+
+  useEffect(() => {
+    return () => {
+      if (audioRef.current) {
+        audioRef.current.pause()
+        audioRef.current = null
+      }
+      if (blobUrlRef.current) {
+        URL.revokeObjectURL(blobUrlRef.current)
+        blobUrlRef.current = null
+      }
+    }
+  }, [src])
+
+  const togglePlay = async (e: React.MouseEvent) => {
+    e.stopPropagation()
+    if (loading) return
+
+    if (isPlaying && audioRef.current) {
+      audioRef.current.pause()
+      setIsPlaying(false)
+      return
+    }
+
+    try {
+      if (!audioRef.current) {
+        setLoading(true)
+        const objectUrl = await privateImageUrl(src)
+        blobUrlRef.current = objectUrl
+        const audio = new Audio(objectUrl)
+        audioRef.current = audio
+
+        audio.onended = () => setIsPlaying(false)
+        audio.onpause = () => setIsPlaying(false)
+        audio.onerror = () => {
+          setIsPlaying(false)
+          setLoading(false)
+        }
+      }
+
+      await audioRef.current.play()
+      setIsPlaying(true)
+    } catch (err) {
+      console.error('音频播放失败', err)
+      setIsPlaying(false)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  return (
+    <button
+      type="button"
+      className={`instructor-voice-btn ${isPlaying ? 'playing' : ''}`}
+      onClick={togglePlay}
+      title={isPlaying ? '暂停播放语音' : title}
+      aria-label={isPlaying ? '暂停播放语音' : title}
+      disabled={loading}
+    >
+      {loading ? (
+        <Loader2 size={13} className="spin" />
+      ) : isPlaying ? (
+        <Volume2 size={13} className="voice-icon-active" />
+      ) : (
+        <Volume2 size={13} />
+      )}
+      <span className="voice-btn-text">{isPlaying ? '播放中' : '试听'}</span>
+    </button>
+  )
+}

+ 73 - 3
src/components/Layout.tsx

@@ -1,11 +1,43 @@
-import { CircleHelp, LogOut, UserRound } from 'lucide-react'
+import { ChevronDown, CircleHelp, LogOut, ShieldCheck, UserRound } from 'lucide-react'
+import { useEffect, useRef, useState } from 'react'
 import { NavLink, Outlet } from 'react-router-dom'
 import { Brand } from './Brand'
 import { Footer } from './Footer'
 import { useAuth } from '../AuthContext'
+import { useWorkspace } from '../WorkspaceContext'
+import { useNavigate } from 'react-router-dom'
 
 export function Layout() {
   const { user, logout } = useAuth()
+  const { organizations, workspaceId, selectWorkspace, currentOrganization } = useWorkspace()
+  const navigate = useNavigate()
+  const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false)
+  const workspaceMenuRef = useRef<HTMLDivElement>(null)
+  const personalWorkspaceName = '个人'
+  const currentWorkspaceName = currentOrganization?.name || personalWorkspaceName
+  const hasMultipleWorkspaces = organizations.length > 0
+
+  useEffect(() => {
+    if (!isWorkspaceMenuOpen) return
+
+    const closeWorkspaceMenu = (event: PointerEvent) => {
+      if (!workspaceMenuRef.current?.contains(event.target as Node)) setIsWorkspaceMenuOpen(false)
+    }
+
+    window.addEventListener('pointerdown', closeWorkspaceMenu)
+    return () => window.removeEventListener('pointerdown', closeWorkspaceMenu)
+  }, [isWorkspaceMenuOpen])
+
+  const switchWorkspace = (id: string) => {
+    selectWorkspace(id)
+    setIsWorkspaceMenuOpen(false)
+    navigate('/')
+  }
+
+  const isCurrentWorkspaceManager = Boolean(currentOrganization?.role === 'MANAGER')
+  const managerBadgeTitle = currentOrganization
+    ? `组织管理员 · ${currentOrganization.name}`
+    : '组织管理员'
 
   return (
     <div className="app-shell">
@@ -14,19 +46,57 @@ export function Layout() {
           <Brand />
         </NavLink>
         <nav>
+          <div className="workspace-switcher" ref={workspaceMenuRef}>
+            {hasMultipleWorkspaces ? (
+              <>
+                <button
+                  type="button"
+                  className="workspace-switcher-trigger"
+                  aria-haspopup="menu"
+                  aria-expanded={isWorkspaceMenuOpen}
+                  onClick={() => setIsWorkspaceMenuOpen((open) => !open)}
+                >
+                  <span>{currentWorkspaceName}</span>
+                  <ChevronDown size={16} aria-hidden="true" />
+                </button>
+                {isWorkspaceMenuOpen && (
+                  <div className="workspace-switcher-menu" role="menu" aria-label="切换工作区">
+                    <button type="button" role="menuitem" className={workspaceId === 'personal' ? 'active' : ''} onClick={() => switchWorkspace('personal')}>
+                      {personalWorkspaceName}
+                    </button>
+                    {organizations.map((organization) => (
+                      <button type="button" role="menuitem" className={workspaceId === organization.id ? 'active' : ''} onClick={() => switchWorkspace(organization.id)} key={organization.id}>
+                        {organization.name}{organization.isActive ? '' : '(已停用)'}
+                      </button>
+                    ))}
+                  </div>
+                )}
+              </>
+            ) : (
+              <span className="workspace-switcher-current">{currentWorkspaceName}</span>
+            )}
+          </div>
           <NavLink to="/" end>
-            我的课程
+            课程
           </NavLink>
+          <NavLink to="/digital-instructors">数字讲师</NavLink>
+          {currentOrganization && <NavLink to={`/organizations/${currentOrganization.slug}`}>机构主页</NavLink>}
         </nav>
         <div className="top-actions">
           {user?.role !== 'ADMIN' && (
             <NavLink to="/profile?tab=points" className="points-balance-badge" title="查看积分消费记录">
-              积分 {user?.pointsBalance ?? 0}
+              积分余额:{(currentOrganization ? currentOrganization.pointsBalance : (user?.pointsBalance ?? 0)).toLocaleString('zh-CN')}
             </NavLink>
           )}
           <NavLink to="/profile" className="user-link">
             <UserRound size={16} />
             <span>{user?.contactName || user?.phone}</span>
+            {isCurrentWorkspaceManager && (
+              <span className="topbar-role-badge" title={managerBadgeTitle}>
+                <ShieldCheck size={12} />
+                <span>管理员</span>
+              </span>
+            )}
           </NavLink>
           <NavLink to="/help" className="icon-button" title="使用帮助">
             <CircleHelp size={19} />

+ 54 - 0
src/components/LectureNotesDialog.tsx

@@ -0,0 +1,54 @@
+import { useEffect, useRef, useState } from 'react'
+import { Bold, Italic, List, ListOrdered, Save, X } from 'lucide-react'
+import DOMPurify from 'dompurify'
+import { notesHtml, notesText, RICH_NOTES_PREFIX } from '../utils/lectureNotes'
+
+const speakingLength = (text: string) => Array.from(text.replace(/\s/g, '')).length
+
+export function LectureNotesDialog({ title, initialValue, onClose, onSave, saving }: {
+  title: string
+  initialValue: string
+  onClose: () => void
+  onSave: (value: string) => Promise<void>
+  saving: boolean
+}) {
+  const editor = useRef<HTMLDivElement>(null)
+  const [length, setLength] = useState(() => speakingLength(notesText(initialValue)))
+  const [error, setError] = useState('')
+
+  useEffect(() => {
+    if (editor.current) editor.current.innerHTML = notesHtml(initialValue)
+    editor.current?.focus()
+  }, [initialValue])
+
+  const updateLength = () => setLength(speakingLength(editor.current?.textContent || ''))
+
+  const command = (name: string) => {
+    document.execCommand(name)
+    editor.current?.focus()
+    updateLength()
+  }
+
+  const save = async () => {
+    const html = DOMPurify.sanitize(editor.current?.innerHTML || '')
+    const text = editor.current?.textContent?.trim() || ''
+    setError('')
+    try { await onSave(text ? RICH_NOTES_PREFIX + html : '') }
+    catch (cause) { setError((cause as Error).message) }
+  }
+
+  return <div className="modal-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) onClose() }}>
+    <div className="modal-dialog lecture-dialog" role="dialog" aria-modal="true" aria-label={`编辑${title}口播稿`}>
+      <div className="modal-header"><div><h3>编辑口播稿</h3><small>{title}</small></div><button type="button" className="close-modal-btn" onClick={onClose} disabled={saving} aria-label="关闭"><X size={18} /></button></div>
+      <div className="lecture-toolbar" aria-label="文字格式">
+        <button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => command('bold')} title="加粗"><Bold size={17} /></button>
+        <button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => command('italic')} title="斜体"><Italic size={17} /></button>
+        <button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => command('insertUnorderedList')} title="项目列表"><List size={17} /></button>
+        <button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => command('insertOrderedList')} title="编号列表"><ListOrdered size={17} /></button>
+      </div>
+      <div ref={editor} className="lecture-editor" contentEditable suppressContentEditableWarning role="textbox" aria-multiline="true" aria-label="口播稿正文" data-placeholder="在这里撰写或粘贴口播稿…" onInput={updateLength} onPaste={(event) => { event.preventDefault(); document.execCommand('insertText', false, event.clipboardData.getData('text/plain')); requestAnimationFrame(updateLength) }} />
+      {error && <div className="notice notice-error" role="alert">{error}</div>}
+      <div className="lecture-dialog-footer"><div className="lecture-stats"><strong>{length.toLocaleString('zh-CN')} 字</strong><span>预计口播 {length ? (length / 260).toFixed(1) : '0.0'} 分钟</span><small>按每分钟约 260 字估算,实际时长以录制为准</small></div><div><button type="button" className="secondary-button" onClick={onClose} disabled={saving}>取消</button><button type="button" className="primary-button" onClick={save} disabled={saving}><Save size={15} /> {saving ? '保存中…' : '保存讲稿'}</button></div></div>
+    </div>
+  </div>
+}

+ 13 - 0
src/components/ProtectedAudio.tsx

@@ -0,0 +1,13 @@
+import { useEffect, useState } from 'react'
+import { privateImageUrl } from '../api'
+
+export function ProtectedAudio({ src }: { src: string }) {
+  const [url, setUrl] = useState('')
+  useEffect(() => {
+    let alive = true
+    let created = ''
+    privateImageUrl(src).then(value => { created = value; if (alive) setUrl(value); else URL.revokeObjectURL(value) }).catch(() => setUrl(''))
+    return () => { alive = false; if (created) URL.revokeObjectURL(created) }
+  }, [src])
+  return url ? <audio controls preload="none" src={url}>您的浏览器不支持音频播放</audio> : null
+}

+ 17 - 0
src/components/ProtectedImage.tsx

@@ -0,0 +1,17 @@
+import { useEffect, useState } from 'react'
+import { privateImageUrl } from '../api'
+
+export function ProtectedImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
+  const [objectUrl, setObjectUrl] = useState('')
+  useEffect(() => {
+    let alive = true
+    let createdUrl = ''
+    privateImageUrl(src).then((url) => {
+      createdUrl = url
+      if (alive) setObjectUrl(url)
+      else URL.revokeObjectURL(url)
+    }).catch(() => setObjectUrl(''))
+    return () => { alive = false; if (createdUrl) URL.revokeObjectURL(createdUrl) }
+  }, [src])
+  return objectUrl ? <img src={objectUrl} alt={alt} className={className} /> : null
+}

+ 1 - 3
src/data/courseTemplates.ts

@@ -6,10 +6,9 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
   {
     id: 'party-building-standard',
     name: '《坚持人民至上根本立场》党课视频',
-    category: '党政专题',
     tags: ['精品党课', 'AI数字人', '1080P高清', 'PPT同步论证'],
     badge: '官方推荐示范',
-    summary: '严格依据党课制作任务书规范,包含审定讲稿、全屏课件切换逻辑、数字人讲师近景与全景运镜、精准语速停顿控制。',
+    summary: '严格依据党课制作任务书规范,包含审定讲稿、全屏课件切换逻辑、数字讲师近景与全景运镜、精准语速停顿控制。',
     description: `【制作目标】PPT配合讲稿,完成一支可用于网络党课学习的横版课程视频。
 【形式结构】(课程包装片头—片名)—讲师讲述—PPT论证—讲师回场—章节转场—继续讲述—总结—片尾。
 【运镜与节奏】全片约 30% 为讲师中近景/全景,约 70% 为全屏课件;单页 PPT 驻留 10—45 秒;讲师近景口型眼神对齐,现代演播室虚化背景。
@@ -103,7 +102,6 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
   {
     id: 'higher-edu-calculus',
     name: '《深入浅出微积分:极限与导数之美》公开课',
-    category: '高校公开课',
     tags: ['高校公开课', '数理基础', '公式板书', '生动启发'],
     badge: '高校示范',
     summary: '高校精品开放课程标准,聚焦概念启发与几何直观推导,支持讲师半身出镜与动态板书公式画中画同步。',

+ 2 - 1
src/main.tsx

@@ -3,8 +3,9 @@ import ReactDOM from 'react-dom/client'
 import { BrowserRouter } from 'react-router-dom'
 import App from './App'
 import { AuthProvider } from './AuthContext'
+import { WorkspaceProvider } from './WorkspaceContext'
 import './styles.css'
 
 ReactDOM.createRoot(document.getElementById('root')!).render(
-  <React.StrictMode><BrowserRouter><AuthProvider><App /></AuthProvider></BrowserRouter></React.StrictMode>,
+  <React.StrictMode><BrowserRouter><AuthProvider><WorkspaceProvider><App /></WorkspaceProvider></AuthProvider></BrowserRouter></React.StrictMode>,
 )

+ 11 - 3
src/pages/AuthPage.tsx

@@ -1,5 +1,5 @@
 import { FormEvent, useState } from 'react'
-import { Navigate, useSearchParams } from 'react-router-dom'
+import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'
 import { ArrowRight, LockKeyhole, Phone } from 'lucide-react'
 import { Brand } from '../components/Brand'
 import { Footer } from '../components/Footer'
@@ -7,13 +7,14 @@ import { useAuth } from '../AuthContext'
 
 export function AuthPage() {
   const [searchParams] = useSearchParams()
+  const navigate = useNavigate()
   const { user, authenticate } = useAuth()
   const [mode, setMode] = useState<'login' | 'register'>(() => searchParams.get('mode') === 'register' ? 'register' : 'login')
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState('')
 
   if (user) {
-    return <Navigate to={user.role === 'ADMIN' ? '/admin' : '/'} replace />
+    return <Navigate to={user.role === 'ADMIN' ? '/admin' : searchParams.get('invite') ? `/join/${searchParams.get('invite')}` : searchParams.get('returnTo') || '/'} replace />
   }
 
   async function submit(e: FormEvent<HTMLFormElement>) {
@@ -29,7 +30,8 @@ export function AuthPage() {
     setBusy(true)
     setError('')
     try {
-      await authenticate(mode, String(f.get('phone')), password)
+      await authenticate(mode, String(f.get('phone')), password, mode === 'register' ? String(f.get('contactName')) : undefined, mode === 'register' ? searchParams.get('invite') || undefined : undefined)
+      if (mode === 'login' && searchParams.get('invite')) navigate(`/join/${searchParams.get('invite')}`)
     } catch (x) {
       setError(x instanceof Error ? x.message : '操作失败')
     } finally {
@@ -68,6 +70,12 @@ export function AuthPage() {
               />
             </div>
           </label>
+          {mode === 'register' && (
+            <label>
+              姓名
+              <input name="contactName" required maxLength={80} autoComplete="name" placeholder="请输入您的姓名" />
+            </label>
+          )}
           <label>
             密码
             <div className="input-with-icon">

+ 284 - 111
src/pages/CourseWorkspace.tsx

@@ -20,11 +20,16 @@ import {
   Trash2,
   Upload,
   UserPlus,
-  X
+  X,
+  ZoomIn
 } from 'lucide-react'
 import { FormEvent, useEffect, useRef, useState } from 'react'
 import { Link, useNavigate, useParams } from 'react-router-dom'
-import { api } from '../api'
+import { api, downloadPrivateFile } from '../api'
+import { useWorkspace } from '../WorkspaceContext'
+import { canPreviewAsset, CourseAssetPreview } from '../components/CourseAssetPreview'
+import { ProtectedImage } from '../components/ProtectedImage'
+import { InstructorVoicePlayer } from '../components/InstructorVoicePlayer'
 import { StatusBadge } from '../components/StatusBadge'
 import { AddInstructorModal } from '../components/AddInstructorModal'
 import { EpisodeModal } from '../components/EpisodeModal'
@@ -32,8 +37,13 @@ import { DeleteCourseModal } from '../components/DeleteCourseModal'
 import { ContactModal } from '../components/ContactModal'
 import { EditCourseModal } from '../components/EditCourseModal'
 import { CourseProductionProgress } from '../components/CourseProductionProgress'
+import { LectureNotesDialog } from '../components/LectureNotesDialog'
+import { notesHtml, notesText } from '../utils/lectureNotes'
 import contactQrCode from '../assets/contact-group-qrcode.jpg'
-import type { Course, Episode } from '../types'
+import { formatDateTime } from '../utils/date'
+import type { Course, CourseAsset, Episode } from '../types'
+
+const assetAccept = '.ppt,.pptx,.pdf,.doc,.docx,image/*,video/mp4,video/webm,video/quicktime,.m4v'
 
 const size = (n: number) =>
   n > 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + ' MB' : Math.ceil(n / 1024) + ' KB'
@@ -41,6 +51,7 @@ const size = (n: number) =>
 export function CourseWorkspace() {
   const { id = '' } = useParams()
   const navigate = useNavigate()
+  const { workspaceId, selectWorkspace } = useWorkspace()
   const globalInputRef = useRef<HTMLInputElement>(null)
 
   const [course, setCourse] = useState<Course | null>(null)
@@ -57,6 +68,17 @@ export function CourseWorkspace() {
   const [showEditCourseModal, setShowEditCourseModal] = useState(false)
   const [isEditingWaitingCourse, setIsEditingWaitingCourse] = useState(false)
   const [editingEpisode, setEditingEpisode] = useState<Episode | null>(null)
+  const [previewImage, setPreviewImage] = useState<{ url: string; name: string } | null>(null)
+  const [previewAsset, setPreviewAsset] = useState<CourseAsset | null>(null)
+
+  useEffect(() => {
+    if (!previewImage) return
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') setPreviewImage(null)
+    }
+    window.addEventListener('keydown', handleKeyDown)
+    return () => window.removeEventListener('keydown', handleKeyDown)
+  }, [previewImage])
 
   // 全局资产待上传
   const [globalFiles, setGlobalFiles] = useState<File[]>([])
@@ -64,27 +86,35 @@ export function CourseWorkspace() {
   // 分集展开折叠状态 Map (episodeId -> boolean)
   const [expandedMap, setExpandedMap] = useState<Record<string, boolean>>({})
 
-  // 分集讲稿内联草稿状态 (episodeId -> string)
-  const [notesDraftMap, setNotesDraftMap] = useState<Record<string, string>>({})
   const [savingNotesId, setSavingNotesId] = useState<string | null>(null)
+  const [editingNotesEpisode, setEditingNotesEpisode] = useState<Episode | null>(null)
 
   // 分集上传文件输入引用 Map (episodeId -> File[])
   const [epFilesMap, setEpFilesMap] = useState<Record<string, File[]>>({})
   const [uploadingEpId, setUploadingEpId] = useState<string | null>(null)
 
+  // 通用资产备注状态
+  const [isEditingAssetNotes, setIsEditingAssetNotes] = useState(false)
+  const [assetNotesDraft, setAssetNotesDraft] = useState('')
+  const [savingAssetNotes, setSavingAssetNotes] = useState(false)
+
   const loadCourse = () => {
+    setError('')
     api
       .getCourse(id)
       .then((r) => {
+        setError('')
         setCourse(r.course)
+        // 自动自愈:当课程所属工作区与当前工作区不一致时,自动对齐切换工作区
+        const courseWorkspaceId = r.course.organizationId || 'personal'
+        if (courseWorkspaceId !== (localStorage.getItem('xinghen_workspace') || 'personal')) {
+          selectWorkspace(courseWorkspaceId)
+        }
         const initialExpand: Record<string, boolean> = {}
-        const initialDrafts: Record<string, string> = {}
         r.course.episodes?.forEach((ep, idx) => {
           if (idx < 2) initialExpand[ep.id] = true
-          initialDrafts[ep.id] = ep.lectureNotes || ''
         })
         setExpandedMap((prev) => ({ ...initialExpand, ...prev }))
-        setNotesDraftMap((prev) => ({ ...initialDrafts, ...prev }))
       })
       .catch((e) => setError(e.message))
   }
@@ -101,13 +131,14 @@ export function CourseWorkspace() {
   // 上传全局通用资产
   async function handleUploadGlobalAssets() {
     if (!globalFiles.length) return
+    if (globalFiles.length > 20) { setError('每次最多上传 20 份文件,请分批上传'); return }
     setBusy(true)
     setError('')
     try {
       const res = await api.uploadAssets(id, globalFiles)
       setCourse(res.course)
       setGlobalFiles([])
-      notify('全局资产上传成功')
+      notify(res.notesImported ? `资产上传成功,已从 ${res.notesImported} 页演讲者备注填入口播稿` : '全局资产上传成功')
     } catch (e) {
       setError((e as Error).message)
     } finally {
@@ -115,17 +146,54 @@ export function CourseWorkspace() {
     }
   }
 
+  // 保存通用资产备注
+  async function handleSaveAssetNotes() {
+    if (!course) return
+    setSavingAssetNotes(true)
+    setError('')
+    try {
+      const res = await api.updateCourse(course.id, { assetNotes: assetNotesDraft.trim() })
+      setCourse(res.course)
+      setIsEditingAssetNotes(false)
+      notify('通用资产备注已保存')
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setSavingAssetNotes(false)
+    }
+  }
+
+  // 清空通用资产备注
+  async function handleDeleteAssetNotes() {
+    if (!course) return
+    if (!confirm('确定要清空通用资产备注吗?')) return
+    setSavingAssetNotes(true)
+    setError('')
+    try {
+      const res = await api.updateCourse(course.id, { assetNotes: '' })
+      setCourse(res.course)
+      setAssetNotesDraft('')
+      setIsEditingAssetNotes(false)
+      notify('通用资产备注已清空')
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setSavingAssetNotes(false)
+    }
+  }
+
   // 上传特定分集的专属资产
   async function handleUploadEpisodeAssets(episodeId: string) {
     const files = epFilesMap[episodeId] || []
     if (!files.length) return
+    if (files.length > 20) { setError('每次最多上传 20 份文件,请分批上传'); return }
     setUploadingEpId(episodeId)
     setError('')
     try {
       const res = await api.uploadAssets(id, files, episodeId)
       setCourse(res.course)
       setEpFilesMap((prev) => ({ ...prev, [episodeId]: [] }))
-      notify('分集素材上传成功')
+      notify(res.notesImported ? `素材上传成功,已从 ${res.notesImported} 页演讲者备注填入口播稿` : '分集素材上传成功')
     } catch (e) {
       setError((e as Error).message)
     } finally {
@@ -134,8 +202,7 @@ export function CourseWorkspace() {
   }
 
   // 保存分集讲稿正文
-  async function handleSaveLectureNotes(episode: Episode) {
-    const newNotes = notesDraftMap[episode.id] ?? episode.lectureNotes
+  async function handleSaveLectureNotes(episode: Episode, newNotes: string) {
     setSavingNotesId(episode.id)
     setError('')
     try {
@@ -144,9 +211,10 @@ export function CourseWorkspace() {
         lectureNotes: newNotes
       })
       setCourse(res.course)
+      setEditingNotesEpisode(null)
       notify(`《${episode.title}》口播讲稿已保存`)
     } catch (e) {
-      setError((e as Error).message)
+      throw e
     } finally {
       setSavingNotesId(null)
     }
@@ -258,9 +326,13 @@ export function CourseWorkspace() {
               <Edit3 size={14} /> 修改
             </button>
           </div>
-          <p>
-            {course.category || '综合分类'}
-          </p>
+          <div style={{ display: 'flex', alignItems: 'center', gap: '14px', flexWrap: 'wrap', color: '#64748b', fontSize: '0.85rem', marginTop: '6px' }}>
+            <span title={`创建时间:${formatDateTime(course.createdAt)}`}>创建时间:{formatDateTime(course.createdAt)}</span>
+            <span>•</span>
+            <span title={`最后更新时间:${formatDateTime(course.updatedAt || course.createdAt)}`} style={{ color: '#334155', fontWeight: 500 }}>
+              最后更新:{formatDateTime(course.updatedAt || course.createdAt)}
+            </span>
+          </div>
         </div>
       </div>
 
@@ -341,15 +413,12 @@ export function CourseWorkspace() {
                               <strong>{deliv.originalName}</strong>
                               <span>{size(deliv.size)}</span>
                             </div>
-                            <a
-                              href={deliv.url}
-                              download={deliv.originalName}
+                            <button
+                              onClick={() => downloadPrivateFile(deliv.url, deliv.originalName)}
                               className="primary-button small-button"
-                              target="_blank"
-                              rel="noreferrer"
                             >
                               <Download size={14} /> 下载
-                            </a>
+                            </button>
                           </div>
                         ))}
                       </div>
@@ -371,15 +440,12 @@ export function CourseWorkspace() {
                               <strong>{deliv.originalName}</strong>
                               <span>{size(deliv.size)}</span>
                             </div>
-                            <a
-                              href={deliv.url}
-                              download={deliv.originalName}
+                            <button
+                              onClick={() => downloadPrivateFile(deliv.url, deliv.originalName)}
                               className="primary-button small-button"
-                              target="_blank"
-                              rel="noreferrer"
                             >
                               <Download size={14} /> 下载
-                            </a>
+                            </button>
                           </div>
                         ))}
                       </div>
@@ -428,7 +494,7 @@ export function CourseWorkspace() {
           <div className="receipt">
             <span>
               <small>课程状态</small>
-              <strong style={{ color: '#245bd6' }}>制作中</strong>
+              <strong style={{ color: '#0875bd' }}>制作中</strong>
             </span>
             <span>
               <small>课程分集</small>
@@ -579,7 +645,6 @@ export function CourseWorkspace() {
             <section className="paper-form episodes-header-panel">
               <div className="panel-heading-row">
                 <div>
-                  <span className="step-number">02</span>
                   <h2>课程分集与素材管理</h2>
                   <p className="panel-intro">
                     课程默认已包含第 1 集,可按需添加更多分集(如 12 集微积分),为每一集在线录入口播讲稿,并上传该集的专属课件与素材文件。
@@ -610,14 +675,6 @@ export function CourseWorkspace() {
                   <span className="metric-num">{filledNotesCount} / {episodes.length}</span>
                   <small>已录入讲稿</small>
                 </div>
-                <div className="metric-item">
-                  <span className="metric-num">{course.assets.length}</span>
-                  <small>课件与素材</small>
-                </div>
-                <div className="metric-item">
-                  <span className="metric-num">{course.instructors.length}</span>
-                  <small>主讲讲师</small>
-                </div>
               </div>
 
               {hasEpisodes && (
@@ -660,8 +717,6 @@ export function CourseWorkspace() {
               {episodes.map((ep) => {
                 const isExpanded = !!expandedMap[ep.id]
                 const epAssets = course.assets?.filter((a) => a.episodeId === ep.id) || []
-                const draftNotes = notesDraftMap[ep.id] ?? (ep.lectureNotes || '')
-                const isNotesDirty = draftNotes !== (ep.lectureNotes || '')
                 const pendingFiles = epFilesMap[ep.id] || []
 
                 return (
@@ -679,7 +734,7 @@ export function CourseWorkspace() {
                       <div className="header-right" onClick={(e) => e.stopPropagation()}>
                         {ep.lectureNotes?.trim() ? (
                           <span className="tag-pill tag-pill-success" title="已录入讲稿">
-                            讲稿 ({ep.lectureNotes.length}字)
+                            讲稿 ({notesText(ep.lectureNotes).length}字)
                           </span>
                         ) : (
                           <span className="tag-pill tag-pill-dim">未添加讲稿</span>
@@ -714,70 +769,36 @@ export function CourseWorkspace() {
                       </div>
                     </div>
 
-                    {/* 展开后的分集详情:讲稿正文编辑 + 分集素材上传 */}
+                    {/* 展开后的分集详情:分集素材上传 + 讲稿正文编辑 */}
                     {isExpanded && (
                       <div className="episode-card-body">
                         <div className="episode-content-heading">
                           <div>
                             <strong>本集制作内容</strong>
-                            <span>口播稿与素材均为选填,可按实际制作方式添加</span>
                           </div>
                         </div>
 
                         <div className="ep-body-grid">
-                          {/* 口播稿(选填) */}
-                          <div className="ep-notes-box">
-                            <div className="box-title-row">
-                              <div className="title-left">
-                                <FileText size={15} />
-                                <strong>口播稿</strong>
-                                <span className="optional-label">选填</span>
-                              </div>
-                              {isNotesDirty && <span className="dirty-badge">未保存</span>}
-                            </div>
-                            <textarea
-                              rows={5}
-                              className="ep-notes-textarea"
-                              value={draftNotes}
-                              onChange={(e) => {
-                                const val = e.target.value
-                                setNotesDraftMap((prev) => ({ ...prev, [ep.id]: val }))
-                              }}
-                              placeholder="可直接输入或粘贴本集的口播稿、字幕文稿…"
-                            />
-                            <div className="notes-footer-row">
-                              <small>共 {draftNotes.length} 字</small>
-                              <button
-                                type="button"
-                                className="primary-button small-button save-notes-btn"
-                                disabled={savingNotesId === ep.id || !isNotesDirty}
-                                onClick={() => handleSaveLectureNotes(ep)}
-                              >
-                                <Save size={13} />
-                                {savingNotesId === ep.id ? '保存中…' : '保存讲稿'}
-                              </button>
-                            </div>
-                          </div>
-
-                          {/* 本集素材(选填) */}
+                          {/* 本集素材 */}
                           <div className="ep-assets-box">
                             <div className="box-title-row">
                               <div className="title-left">
                                 <FileSpreadsheet size={15} />
                                 <strong>上传素材</strong>
-                                <span className="optional-label">选填</span>
                                 {epAssets.length > 0 && <span className="asset-count-label">{epAssets.length} 份</span>}
                               </div>
                               <label className="ep-file-label compact-upload-button">
                                 <Upload size={14} />
-                                <span>选择文件</span>
+                                <span>添加文件(可多选)</span>
                                 <input
                                   type="file"
+                                  accept={assetAccept}
                                   multiple
                                   hidden
                                   onChange={(e) => {
                                     const selected = Array.from(e.target.files || [])
-                                    setEpFilesMap((prev) => ({ ...prev, [ep.id]: selected }))
+                                    setEpFilesMap((prev) => ({ ...prev, [ep.id]: [...(prev[ep.id] || []), ...selected] }))
+                                    e.target.value = ''
                                   }}
                                 />
                               </label>
@@ -792,6 +813,8 @@ export function CourseWorkspace() {
                                     <strong title={a.originalName}>{a.originalName}</strong>
                                     <span>{size(a.size)}</span>
                                   </div>
+                                  {canPreviewAsset(a) && <button type="button" className="asset-preview-action" onClick={() => setPreviewAsset(a)}>预览</button>}
+                                  <button type="button" className="icon-action-btn" title="下载素材" aria-label={`下载 ${a.originalName}`} onClick={() => void downloadPrivateFile(a.url, a.originalName).catch((cause) => setError((cause as Error).message))}><Download size={14} /></button>
                                   <button
                                     type="button"
                                     className="icon-action-btn delete-btn"
@@ -803,7 +826,7 @@ export function CourseWorkspace() {
                                 </div>
                               ))}
                               {!epAssets.length && (
-                                <p className="empty-sub-tip">如有需要,可上传本集使用的 PPT、Word、图片等制作素材。</p>
+                                <p className="empty-sub-tip">可上传 PPTX、PDF、Word、图片或视频,支持一次多选和分次添加。</p>
                               )}
                             </div>
 
@@ -841,6 +864,19 @@ export function CourseWorkspace() {
                               )}
                             </div>
                           </div>
+
+                          {/* 口播稿 */}
+                          <div className="ep-notes-box">
+                            <div className="box-title-row">
+                              <div className="title-left">
+                                <FileText size={15} />
+                                <strong>口播稿</strong>
+                              </div>
+                              <button type="button" className="secondary-button small-button" onClick={() => setEditingNotesEpisode(ep)}><Edit3 size={14} /> 编辑</button>
+                            </div>
+                            {ep.lectureNotes?.trim() ? <div className="lecture-preview" dangerouslySetInnerHTML={{ __html: notesHtml(ep.lectureNotes) }} /> : <p className="empty-sub-tip">尚未添加口播稿。点击“编辑”打开大幅编辑窗口。</p>}
+                            {ep.lectureNotes?.trim() && <small className="lecture-count">共 {notesText(ep.lectureNotes).length} 字</small>}
+                          </div>
                         </div>
                       </div>
                     )}
@@ -849,26 +885,118 @@ export function CourseWorkspace() {
               })}
             </section>
 
-            {/* 课程通用 / 全局素材专区 */}
+            {/* 通用资产专区 */}
             <section className="paper-form global-assets-panel">
               <div className="panel-heading-row">
                 <div>
-                  <h3>全套课程通用资产 ({globalAssets.length})</h3>
+                  <h3>通用资产 ({globalAssets.length})</h3>
                   <p className="panel-intro">
                     如全课程大纲、全套通用 PPT 模板、讲师全局参考音频等不区分分集的通用资料。
                   </p>
                 </div>
+                {!isEditingAssetNotes && !course.assetNotes?.trim() && canEditCourseContent && (
+                  <button
+                    type="button"
+                    className="secondary-button small-button add-asset-notes-btn"
+                    onClick={() => {
+                      setAssetNotesDraft('')
+                      setIsEditingAssetNotes(true)
+                    }}
+                  >
+                    <Plus size={14} /> 添加备注
+                  </button>
+                )}
               </div>
 
+              {/* 通用资产备注展示与编辑 */}
+              {(isEditingAssetNotes || !!course.assetNotes?.trim()) && (
+                <div className="asset-notes-card">
+                  <div className="asset-notes-header">
+                    <div className="notes-header-left">
+                      <FileText size={15} />
+                      <strong>通用备注</strong>
+                    </div>
+                    {canEditCourseContent && !isEditingAssetNotes && (
+                      <div className="notes-actions">
+                        <button
+                          type="button"
+                          className="icon-action-btn"
+                          title="修改备注"
+                          onClick={() => {
+                            setAssetNotesDraft(course.assetNotes || '')
+                            setIsEditingAssetNotes(true)
+                          }}
+                        >
+                          <Edit3 size={13} />
+                        </button>
+                        <button
+                          type="button"
+                          className="icon-action-btn delete-btn"
+                          title="清空备注"
+                          onClick={handleDeleteAssetNotes}
+                          disabled={savingAssetNotes}
+                        >
+                          <Trash2 size={13} />
+                        </button>
+                      </div>
+                    )}
+                  </div>
+
+                  {isEditingAssetNotes ? (
+                    <div className="asset-notes-edit-box">
+                      <textarea
+                        rows={4}
+                        className="asset-notes-textarea"
+                        value={assetNotesDraft}
+                        onChange={(e) => setAssetNotesDraft(e.target.value)}
+                        placeholder="输入通用制作备注、课件风格要求、全局格式规范等说明文字…"
+                        autoFocus
+                      />
+                      <div className="notes-footer-row">
+                        <small>共 {assetNotesDraft.length} 字</small>
+                        <div className="notes-btn-group">
+                          <button
+                            type="button"
+                            className="secondary-button small-button"
+                            onClick={() => {
+                              setIsEditingAssetNotes(false)
+                              setAssetNotesDraft(course.assetNotes || '')
+                            }}
+                            disabled={savingAssetNotes}
+                          >
+                            取消
+                          </button>
+                          <button
+                            type="button"
+                            className="primary-button small-button"
+                            onClick={handleSaveAssetNotes}
+                            disabled={savingAssetNotes}
+                          >
+                            <Save size={13} />
+                            {savingAssetNotes ? '保存中…' : '保存备注'}
+                          </button>
+                        </div>
+                      </div>
+                    </div>
+                  ) : (
+                    <div className="asset-notes-text">
+                      {course.assetNotes}
+                    </div>
+                  )}
+                </div>
+              )}
+
               <div className="asset-list">
                 {globalAssets.map((a) => (
                   <div className="asset-row" key={a.id}>
                     <FileText />
                     <div>
                       <strong>{a.originalName}</strong>
-                      <span>{size(a.size)} · 全局通用</span>
+                      <span>{size(a.size)} · 通用素材</span>
                     </div>
-                    <button title="删除" onClick={() => handleDeleteAsset(a.id)}>
+                    {canPreviewAsset(a) && <button type="button" className="asset-preview-action" onClick={() => setPreviewAsset(a)}>预览</button>}
+                    <button type="button" title="下载" aria-label={`下载 ${a.originalName}`} onClick={() => void downloadPrivateFile(a.url, a.originalName).catch((cause) => setError((cause as Error).message))}><Download size={16} /></button>
+                    <button type="button" title="删除" aria-label={`删除 ${a.originalName}`} onClick={() => handleDeleteAsset(a.id)}>
                       <Trash2 size={16} />
                     </button>
                   </div>
@@ -877,15 +1005,16 @@ export function CourseWorkspace() {
 
               <div className="drop-zone compact" onClick={() => globalInputRef.current?.click()}>
                 <FilePlus2 />
-                <h3>选择全局通用资产</h3>
-                <small>支持任意常见课件/文档文件,单个文件不超过 200 MB</small>
+                <h3>添加通用附件(可多选)</h3>
+                <small>支持 PPTX、PDF、Word、图片和视频;可分次添加,每次最多 20 份</small>
               </div>
               <input
                 hidden
                 multiple
                 ref={globalInputRef}
                 type="file"
-                onChange={(e) => setGlobalFiles(Array.from(e.target.files || []))}
+                accept={assetAccept}
+                onChange={(e) => { setGlobalFiles((prev) => [...prev, ...Array.from(e.target.files || [])]); e.target.value = '' }}
               />
 
               {globalFiles.length > 0 && (
@@ -918,7 +1047,7 @@ export function CourseWorkspace() {
           <aside className="instructor-panel workspace-sidebar">
             <div className="aside-title">
               <div>
-                <h3>讲师信息 ({course.instructors.length})</h3>
+                <h3>数字讲师</h3>
               </div>
               <button
                 type="button"
@@ -926,40 +1055,58 @@ export function CourseWorkspace() {
                 onClick={() => setShowAddInstructorModal(true)}
               >
                 <UserPlus size={16} />
-                添加
+                {course.digitalInstructorId ? '更换' : '选择'}
               </button>
             </div>
 
             <div className="instructor-list">
-              {course.instructors.map((t) => (
-                <div className="instructor-card" key={t.id}>
-                  {t.imageUrls?.length ? (
-                    <div className="instructor-photo-stack">
-                      {t.imageUrls.map((url, index) => <img src={url} alt={`${t.name} 出镜照 ${index + 1}`} key={url} />)}
+              {course.instructors.map((t) => {
+                const note = (t.note || t.introduction || '').trim()
+                const audioUrl = t.audioUrls?.[0] || course.digitalInstructorSnapshot?.audio?.[0]?.url
+                return (
+                  <div className="instructor-card" key={t.id}>
+                    {t.imageUrls?.length ? (
+                      <div className="instructor-photo-stack">
+                        {t.imageUrls.map((url, index) => (
+                          <button
+                            key={url}
+                            type="button"
+                            className="instructor-photo-thumb-btn"
+                            onClick={() => setPreviewImage({ url, name: `${t.name} 照片 ${index + 1}` })}
+                            title="点击放大查看照片"
+                            aria-label={`点击放大查看 ${t.name} 照片 ${index + 1}`}
+                          >
+                            <ProtectedImage src={url} alt={`${t.name} 出镜照 ${index + 1}`} />
+                            <span className="photo-zoom-hint">
+                              <ZoomIn size={12} />
+                            </span>
+                          </button>
+                        ))}
+                      </div>
+                    ) : (
+                      <span className="avatar">{t.name[0]}</span>
+                    )}
+                    <div className="instructor-card-info">
+                      <div className="instructor-name-row">
+                        <strong className="instructor-name">{t.name}</strong>
+                        {audioUrl && (
+                          <InstructorVoicePlayer src={audioUrl} title={`${t.name} 录音试听`} />
+                        )}
+                      </div>
+                      {note ? <p className="instructor-note">{note}</p> : null}
                     </div>
-                  ) : <span className="avatar">{t.name[0]}</span>}
-                  <div>
-                    <strong>{t.name}</strong>
-                    <small>{t.organization || '未填写单位'}</small>
-                    <p>{t.introduction || '暂无介绍'}</p>
                   </div>
-                  <button
-                    title="删除讲师"
-                    onClick={async () => setCourse((await api.deleteInstructor(id, t.id)).course)}
-                  >
-                    <Trash2 size={14} />
-                  </button>
-                </div>
-              ))}
+                )
+              })}
               {!course.instructors.length && (
                 <div className="empty-instructor-box">
-                  <p>还没有讲师信息,可按需添加</p>
+                  <p>尚未选择数字讲师</p>
                   <button
                     type="button"
                     className="add-instructor-trigger-btn"
                     onClick={() => setShowAddInstructorModal(true)}
                   >
-                    <UserPlus size={14} /> 点击添加讲师
+                    <UserPlus size={14} /> 选择讲师
                   </button>
                 </div>
               )}
@@ -1046,6 +1193,8 @@ export function CourseWorkspace() {
       />
 
       {/* 单集添加/编辑弹窗 */}
+      {previewAsset && <CourseAssetPreview key={previewAsset.id} asset={previewAsset} courseId={id} onClose={() => setPreviewAsset(null)} />}
+      {editingNotesEpisode && <LectureNotesDialog key={editingNotesEpisode.id} title={editingNotesEpisode.title} initialValue={course.episodes?.find((episode) => episode.id === editingNotesEpisode.id)?.lectureNotes || ''} onClose={() => setEditingNotesEpisode(null)} onSave={(value) => handleSaveLectureNotes(editingNotesEpisode, value)} saving={savingNotesId === editingNotesEpisode.id} />}
       <EpisodeModal
         isOpen={showEpisodeModal}
         onClose={() => {
@@ -1077,6 +1226,30 @@ export function CourseWorkspace() {
         <ContactModal onClose={() => setShowContactModal(false)} />
       )}
 
+      {/* 照片放大预览弹窗 */}
+      {previewImage && (
+        <div className="digital-photo-lightbox" role="presentation" onClick={() => setPreviewImage(null)}>
+          <div
+            className="digital-photo-lightbox-content"
+            role="dialog"
+            aria-modal="true"
+            aria-label={`查看照片:${previewImage.name}`}
+            onClick={(e) => e.stopPropagation()}
+          >
+            <button
+              type="button"
+              className="digital-photo-lightbox-close"
+              onClick={() => setPreviewImage(null)}
+              aria-label="关闭照片预览"
+            >
+              ×
+            </button>
+            <ProtectedImage src={previewImage.url} alt={previewImage.name} />
+            <p>{previewImage.name}</p>
+          </div>
+        </div>
+      )}
+
       {error && <div className="notice notice-error">{error}</div>}
     </div>
   )

+ 66 - 24
src/pages/Dashboard.tsx

@@ -1,14 +1,20 @@
 import { ArrowRight, BookOpen, CheckCircle2, Clock3, Loader2, Plus, Sparkles, Stars } from 'lucide-react'
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
 import { Link } from 'react-router-dom'
 import { api } from '../api'
 import { StatusBadge } from '../components/StatusBadge'
+import { formatDate, formatDateTime } from '../utils/date'
 import type { Course } from '../types'
+import { useWorkspace } from '../WorkspaceContext'
+
+type DashboardSortMode = 'updated_desc' | 'updated_asc' | 'created_desc' | 'created_asc'
 
 export function Dashboard() {
+  const { workspaceId, currentOrganization } = useWorkspace()
   const [courses, setCourses] = useState<Course[]>([])
   const [loading, setLoading] = useState(true)
   const [error, setError] = useState('')
+  const [sortMode, setSortMode] = useState<DashboardSortMode>('updated_desc')
 
   useEffect(() => {
     api
@@ -16,12 +22,25 @@ export function Dashboard() {
       .then((r) => setCourses(r.courses))
       .catch((e) => setError(e.message))
       .finally(() => setLoading(false))
-  }, [])
+  }, [workspaceId])
 
   const waitingCount = courses.filter((c) => c.status === 'WAITING_PRODUCTION').length
   const inProductionCount = courses.filter((c) => c.status === 'IN_PRODUCTION').length
   const completedCount = courses.filter((c) => c.status === 'COMPLETED').length
 
+  const sortedCourses = useMemo(() => {
+    return [...courses].sort((a, b) => {
+      const updatedA = new Date(a.updatedAt || a.createdAt).getTime() || 0
+      const updatedB = new Date(b.updatedAt || b.createdAt).getTime() || 0
+      const createdA = new Date(a.createdAt).getTime() || 0
+      const createdB = new Date(b.createdAt).getTime() || 0
+      if (sortMode === 'updated_desc') return updatedB - updatedA
+      if (sortMode === 'updated_asc') return updatedA - updatedB
+      if (sortMode === 'created_desc') return createdB - createdA
+      return createdA - createdB
+    })
+  }, [courses, sortMode])
+
   return (
     <div className="page dashboard">
       <section className="hero">
@@ -39,7 +58,7 @@ export function Dashboard() {
           </h1>
           <p>归集课程素材,建立制作任务。简单几步,让创意从这里启程。</p>
           <div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
-            <Link className="primary-button" to="/courses/new">
+            <Link className="primary-button" to="/courses/new" aria-disabled={currentOrganization?.isActive === false} onClick={(e) => { if (currentOrganization?.isActive === false) e.preventDefault() }}>
               <Plus size={18} /> 创建新课程
             </Link>
           </div>
@@ -51,28 +70,46 @@ export function Dashboard() {
       </section>
 
       <section className="content-section">
-        <div className="section-heading">
+        <div className="section-heading" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '12px' }}>
           <div>
-            <h2>我的课程</h2>
+            <h2>{currentOrganization ? `${currentOrganization.name} · 我的课程` : '个人课程'}</h2>
           </div>
-          <div className="metrics">
-            <span>
-              <BookOpen size={16} /> {courses.length} 门课程
-            </span>
-            {waitingCount > 0 && (
+          <div style={{ display: 'flex', alignItems: 'center', gap: '16px', flexWrap: 'wrap' }}>
+            <div className="metrics">
               <span>
-                <Clock3 size={16} /> {waitingCount} 等待制作
-              </span>
-            )}
-            {inProductionCount > 0 && (
-              <span style={{ color: '#2d62ce' }}>
-                <Loader2 size={16} className="spin" /> {inProductionCount} 制作中
-              </span>
-            )}
-            {completedCount > 0 && (
-              <span style={{ color: '#2e7d32' }}>
-                <CheckCircle2 size={16} /> {completedCount} 已完成
+                <BookOpen size={16} /> {courses.length} 门课程
               </span>
+              {waitingCount > 0 && (
+                <span>
+                  <Clock3 size={16} /> {waitingCount} 等待制作
+                </span>
+              )}
+              {inProductionCount > 0 && (
+                <span style={{ color: '#0875bd' }}>
+                  <Loader2 size={16} className="spin" /> {inProductionCount} 制作中
+                </span>
+              )}
+              {completedCount > 0 && (
+                <span style={{ color: '#2e7d32' }}>
+                  <CheckCircle2 size={16} /> {completedCount} 已完成
+                </span>
+              )}
+            </div>
+
+            {courses.length > 1 && (
+              <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
+                <span style={{ fontSize: '0.85rem', color: '#64748b' }}>排序:</span>
+                <select
+                  style={{ padding: '4px 8px', borderRadius: '6px', border: '1px solid #cbd5e1', fontSize: '0.85rem', background: '#fff' }}
+                  value={sortMode}
+                  onChange={(e) => setSortMode(e.target.value as DashboardSortMode)}
+                >
+                  <option value="updated_desc">最后更新(最新优先)</option>
+                  <option value="updated_asc">最后更新(最早优先)</option>
+                  <option value="created_desc">创建时间(最新优先)</option>
+                  <option value="created_asc">创建时间(最早优先)</option>
+                </select>
+              </div>
             )}
           </div>
         </div>
@@ -92,14 +129,14 @@ export function Dashboard() {
             <h3>还没有课程</h3>
             <p>创建第一门课程。</p>
             <div style={{ display: 'flex', gap: '10px', marginTop: '4px' }}>
-              <Link className="primary-button small-button" to="/courses/new">
+              <Link className="primary-button small-button" to="/courses/new" aria-disabled={currentOrganization?.isActive === false} onClick={(e) => { if (currentOrganization?.isActive === false) e.preventDefault() }}>
                 <Plus size={15} /> 创建课程或使用示例
               </Link>
             </div>
           </div>
         ) : (
           <div className="course-grid">
-            {courses.map((course, index) => (
+            {sortedCourses.map((course, index) => (
               <Link to={`/courses/${course.id}`} className="course-card" key={course.id}>
                 <div className={`course-number tone-${index % 3}`}>
                   {String(index + 1).padStart(2, '0')}
@@ -109,7 +146,6 @@ export function Dashboard() {
                   <h3>{course.name}</h3>
                   <p>{course.description || '一门正在准备中的课程。'}</p>
                   <div className="course-meta">
-                    <span>{course.category || '未分类'}</span>
                     <span>
                       {course.assets && course.assets.length > 0
                         ? `${course.assets.length} 份课件资产`
@@ -120,6 +156,12 @@ export function Dashboard() {
                         <Sparkles size={12} style={{ display: 'inline', verticalAlign: '-1px' }} /> 成果可查收
                       </span>
                     )}
+                    <span title={`创建时间:${formatDateTime(course.createdAt)}`}>
+                      创建于 {formatDate(course.createdAt)}
+                    </span>
+                    <span title={`最后更新时间:${formatDateTime(course.updatedAt || course.createdAt)}`} style={{ color: '#0f172a', fontWeight: 500 }}>
+                      更新于 {formatDateTime(course.updatedAt || course.createdAt)}
+                    </span>
                   </div>
                 </div>
                 <ArrowRight className="card-arrow" size={19} />

+ 62 - 0
src/pages/DigitalInstructorEditorPage.tsx

@@ -0,0 +1,62 @@
+import { useEffect, useState } from 'react'
+import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
+import { api } from '../api'
+import { useWorkspace } from '../WorkspaceContext'
+import { useAuth } from '../AuthContext'
+import { DigitalInstructorCreateForm } from '../components/DigitalInstructorCreateForm'
+import type { DigitalInstructor } from '../types'
+
+export function DigitalInstructorEditorPage({ admin = false }: { admin?: boolean }) {
+  const { id } = useParams()
+  const [searchParams] = useSearchParams()
+  const navigate = useNavigate()
+  const { workspaceId, currentOrganization } = useWorkspace()
+  const { user } = useAuth()
+  const adminOrganizationId = admin ? searchParams.get('organizationId') || '' : ''
+  const activeWorkspace = admin ? adminOrganizationId : workspaceId
+  const backTo = admin ? `/admin/digital-instructors?organizationId=${encodeURIComponent(adminOrganizationId)}` : '/digital-instructors'
+  const [item, setItem] = useState<DigitalInstructor | null>(null)
+  const [names, setNames] = useState<string[] | null>(null)
+  const [ownerOptions, setOwnerOptions] = useState<{ id: string; contact_name: string }[]>()
+  const [workspaceName, setWorkspaceName] = useState(currentOrganization?.name || '个人工作区')
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState('')
+
+  const isManager = Boolean(admin || user?.role === 'ADMIN' || currentOrganization?.role === 'MANAGER')
+
+  useEffect(() => {
+    let cancelled = false
+    setLoading(true); setError('')
+    if (admin && !adminOrganizationId) { setError('请先选择组织'); setLoading(false); return }
+    const orgIdToLoad = admin ? adminOrganizationId : (currentOrganization?.id || '')
+    const fetchManagement = !isManager || !orgIdToLoad ? Promise.resolve(null)
+      : (admin || user?.role === 'ADMIN')
+        ? api.admin.organization(orgIdToLoad).catch(() => currentOrganization?.role === 'MANAGER' ? api.organizations.manage(orgIdToLoad) : null)
+        : api.organizations.manage(orgIdToLoad)
+    Promise.all([
+      api.digitalInstructors.list(activeWorkspace),
+      id ? api.digitalInstructors.get(id, activeWorkspace) : Promise.resolve(null),
+      fetchManagement
+    ]).then(([list, detail, management]) => {
+      if (cancelled) return
+      const selected = detail?.instructor || null
+      if (selected && !admin && selected.ownerId !== user?.id && currentOrganization?.role !== 'MANAGER' && user?.role !== 'ADMIN') {
+        setError('您没有编辑这位数字讲师的权限'); setLoading(false); return
+      }
+      setItem(selected)
+      setNames(activeWorkspace === 'personal' ? null : list.instructors.filter(candidate => candidate.id !== selected?.id).map(candidate => candidate.name))
+      if (management && 'members' in management) setOwnerOptions(management.members || [])
+      else setOwnerOptions(undefined)
+      if (admin && management && 'organization' in management) setWorkspaceName(management.organization.name)
+      else setWorkspaceName(currentOrganization?.name || '个人工作区')
+      setLoading(false)
+    }).catch(cause => { if (!cancelled) { setError((cause as Error).message); setLoading(false) } })
+    return () => { cancelled = true }
+  }, [id,activeWorkspace,admin,adminOrganizationId,currentOrganization?.id,currentOrganization?.role,currentOrganization?.name,user?.id,user?.role,isManager])
+
+  return <div className="page digital-editor-page">
+    <Link className="digital-editor-back" to={backTo}>← 返回数字讲师</Link>
+    {loading ? <div className="center-loading">正在加载数字讲师…</div> : error ? <div className="notice notice-error">{error}</div> :
+      <DigitalInstructorCreateForm key={`${activeWorkspace}-${id || 'new'}`} existing={item || undefined} workspaceId={activeWorkspace} workspaceName={workspaceName} organizationNames={names} ownerOptions={ownerOptions} canManageOwner={isManager} onCancel={() => navigate(backTo)} onCreated={() => navigate(backTo, { state: { notice: id ? '数字讲师已更新' : '数字讲师已创建' } })} />}
+  </div>
+}

+ 91 - 0
src/pages/DigitalInstructorsPage.tsx

@@ -0,0 +1,91 @@
+import { useEffect, useRef, useState } from 'react'
+import { Link, useLocation } from 'react-router-dom'
+import { api } from '../api'
+import { useAuth } from '../AuthContext'
+import { useWorkspace } from '../WorkspaceContext'
+import { ProtectedImage } from '../components/ProtectedImage'
+import type { DigitalInstructor } from '../types'
+
+export function DigitalInstructorsPage() {
+  const { user } = useAuth()
+  const { workspaceId, currentOrganization, organizations } = useWorkspace()
+  const location = useLocation()
+  const [items, setItems] = useState<DigitalInstructor[]>([])
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState('')
+  const [notice, setNotice] = useState((location.state as { notice?: string } | null)?.notice || '')
+  const [search, setSearch] = useState('')
+  const [filter, setFilter] = useState<'all' | 'ready' | 'incomplete'>('all')
+  const [searchOpen, setSearchOpen] = useState(false)
+  const searchInputRef = useRef<HTMLInputElement>(null)
+  const [deleteTarget, setDeleteTarget] = useState<DigitalInstructor | null>(null)
+  const [usageId, setUsageId] = useState<string | null>(null)
+  const [usedCourses, setUsedCourses] = useState<{ id: string; name: string; status: string }[]>([])
+  const [targetOrg, setTargetOrg] = useState('')
+  const [busy, setBusy] = useState(false)
+
+  async function load() {
+    setLoading(true); setError('')
+    try { setItems((await api.digitalInstructors.list()).instructors) }
+    catch (cause) { setError((cause as Error).message) }
+    finally { setLoading(false) }
+  }
+  useEffect(() => { load(); setUsageId(null); setTargetOrg('') }, [workspaceId])
+  useEffect(() => { if (searchOpen) searchInputRef.current?.focus() }, [searchOpen])
+
+  async function duplicate(item: DigitalInstructor) {
+    setBusy(true); setError('')
+    try { const result = await api.digitalInstructors.duplicate(item.id); setNotice(`已复制为「${result.instructor.name}」`); await load() }
+    catch (cause) { setError((cause as Error).message) }
+    finally { setBusy(false) }
+  }
+  async function copyToOrganization(item: DigitalInstructor) {
+    if (!targetOrg) { setError('请先选择目标组织'); return }
+    setBusy(true); setError('')
+    try { await api.digitalInstructors.copy(item.id,targetOrg); setNotice('已复制到组织资产库'); setTargetOrg('') }
+    catch (cause) { setError((cause as Error).message) }
+    finally { setBusy(false) }
+  }
+  async function showUsage(item: DigitalInstructor) {
+    if (usageId === item.id) { setUsageId(null); return }
+    setError('')
+    try { setUsedCourses((await api.digitalInstructors.usage(item.id)).courses); setUsageId(item.id) }
+    catch (cause) { setError((cause as Error).message) }
+  }
+  async function remove() {
+    if (!deleteTarget) return
+    setBusy(true); setError('')
+    try { await api.digitalInstructors.delete(deleteTarget.id); setNotice(`已删除「${deleteTarget.name}」`); setDeleteTarget(null); await load() }
+    catch (cause) { setError((cause as Error).message); setDeleteTarget(null) }
+    finally { setBusy(false) }
+  }
+  const visibleItems = items.filter(item => {
+    if (filter === 'ready' && !item.ready || filter === 'incomplete' && item.ready) return false
+    return `${item.name} ${item.creatorName} ${item.ownerName} ${item.note}`.toLowerCase().includes(search.trim().toLowerCase())
+  })
+  return <div className="page digital-library-page">
+    <div className="digital-library-heading"><div><h1>数字讲师</h1><p>{currentOrganization?.name || '个人工作区'} · {items.length} 位讲师</p></div><div className="digital-library-heading-actions"><button type="button" className="secondary-button digital-search-toggle" aria-expanded={searchOpen} aria-controls="digital-instructor-search" onClick={() => setSearchOpen(open => !open)}>⌕ 搜索{search || filter !== 'all' ? ' · 已筛选' : ''}</button><Link className="primary-button" to="/digital-instructors/new">+ 创建数字讲师</Link></div></div>
+    {notice && <div className="notice notice-success">{notice}</div>}{error && <div className="notice notice-error" role="alert">{error}</div>}
+    {searchOpen && <div id="digital-instructor-search" className="digital-library-toolbar" role="search"><input ref={searchInputRef} aria-label="搜索数字讲师" placeholder="搜索姓名、备注、创建者或所有者" value={search} onChange={event => setSearch(event.target.value)} onKeyDown={event => { if (event.key === 'Escape') setSearchOpen(false) }} /><select aria-label="按素材状态筛选" value={filter} onChange={event => setFilter(event.target.value as typeof filter)}><option value="all">全部状态</option><option value="ready">素材齐备</option><option value="incomplete">待补齐</option></select>{(search || filter !== 'all') && <button type="button" className="digital-search-clear" onClick={() => { setSearch(''); setFilter('all'); searchInputRef.current?.focus() }}>清除筛选</button>}</div>}
+    {loading ? <div className="center-loading">正在加载数字讲师…</div> : !items.length ? <div className="digital-library-empty"><h2>还没有数字讲师</h2><p>创建后即可在课程中选择。</p><Link className="primary-button" to="/digital-instructors/new">创建第一位讲师</Link></div> : !visibleItems.length ? <div className="digital-library-empty">没有找到符合条件的讲师。</div> :
+      <div className="digital-library-grid">{visibleItems.map(item => {
+        const canEdit = item.ownerId === user?.id || currentOrganization?.role === 'MANAGER'
+        const canDelete = workspaceId === 'personal' || currentOrganization?.role === 'MANAGER'
+        return <article key={item.id} className="digital-library-card digital-asset-card">
+          <div className="digital-asset-photo">{item.images[0] ? <ProtectedImage src={item.images[0].url} alt={item.name} /> : <span>暂无照片</span>}</div>
+          <div className="digital-asset-body"><div className="digital-asset-title"><h2>{item.name}</h2><span className={item.ready ? 'digital-state-ready' : 'digital-state-incomplete'}>{item.ready ? '素材齐备' : '待补齐'}</span></div>
+            <p className="digital-asset-note">{item.note || '暂无备注'}</p>
+            <p className="digital-asset-meta">所有者:{item.ownerName === '未知用户' ? '历史导入' : item.ownerName} · 创建者:{item.creatorName === '未知用户' ? '历史导入' : item.creatorName}</p>
+            <p className="digital-asset-meta">创建于 {new Date(item.createdAt).toLocaleString('zh-CN')}</p>
+            {item.usageCount > 0 && <button type="button" className="digital-usage-link" onClick={() => showUsage(item)}>用于 {item.usageCount} 门课程 · 查看</button>}
+            {usageId === item.id && <div className="digital-usage-list"><strong>正在使用这位讲师的课程</strong>{usedCourses.map(course => <Link key={course.id} to={`/courses/${course.id}`}>{course.name}</Link>)}</div>}
+            <div className="digital-asset-actions">{canEdit && <Link className="secondary-button" to={`/digital-instructors/${item.id}/edit`}>编辑</Link>}
+              <details className="digital-more-menu"><summary className="secondary-button">更多操作</summary><div><button type="button" onClick={() => duplicate(item)} disabled={busy}>复制讲师</button>
+                {workspaceId === 'personal' && organizations.some(org => org.isActive) && <div className="digital-copy-org"><label>加入组织资产库<select value={targetOrg} onChange={event => setTargetOrg(event.target.value)}><option value="">选择组织</option>{organizations.filter(org => org.isActive).map(org => <option key={org.id} value={org.id}>{org.name}</option>)}</select></label><button type="button" onClick={() => copyToOrganization(item)} disabled={busy || !targetOrg}>复制到组织</button></div>}
+                {canDelete && <button type="button" className="digital-danger-action" disabled={busy || item.usageCount > 0} title={item.usageCount > 0 ? '已有课程使用,不能删除' : undefined} onClick={() => setDeleteTarget(item)}>{item.usageCount > 0 ? '已有课程使用,不能删除' : '删除讲师'}</button>}</div></details></div>
+          </div>
+        </article>
+      })}</div>}
+    {deleteTarget && <div className="digital-confirm-backdrop" role="presentation"><div className="digital-confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="digital-delete-title"><h2 id="digital-delete-title">删除数字讲师?</h2><p>确定删除「{deleteTarget.name}」吗?删除后将从资产库中隐藏。</p><div><button type="button" className="secondary-button" onClick={() => setDeleteTarget(null)} disabled={busy}>取消</button><button type="button" className="primary-button digital-danger-button" onClick={remove} disabled={busy}>{busy ? '删除中…' : '确认删除'}</button></div></div></div>}
+  </div>
+}

+ 6 - 6
src/pages/HelpPage.tsx

@@ -27,7 +27,7 @@ interface FAQItem {
 
 const FEATURE_LIST = [
   { icon: Layers, title: '结构化大纲编排', description: '支持按集管理课程知识点、逐字稿讲义与预估时长,告别混乱的文档传输与版本断层。' },
-  { icon: UserCheck, title: '讲师出镜形象配置', description: '支持为每门课程建立专属讲师库,录入站姿与坐姿立绘素材,保障课件出镜形象专业统一。' },
+  { icon: UserCheck, title: '数字讲师资产库', description: '个人和组织工作区分别管理数字讲师。课程可选择本工作区的一位讲师。' },
   { icon: UploadCloud, title: '全格式课件归集', description: 'PPT、PDF、教学讲义集中云端归档,自动匹配分集内容,让每一次制作都有迹可循。' },
   { icon: Video, title: '专业制作与成果交付', description: '一键提交进入流水线制作,在线实时跟踪制作状态,成片交付支持在线即时预览与下载。' },
 ]
@@ -35,7 +35,7 @@ const FEATURE_LIST = [
 const WORKFLOW_LIST = [
   { step: '01', title: '创建课程', description: '录入课程名称、分类和简介,或直接选择系统提供的官方范例模板一键快速生成。' },
   { step: '02', title: '编排大纲与台词', description: '添加分集,填写每集知识主题与口播讲义,设定课程节奏。' },
-  { step: '03', title: '上传讲师与课件', description: '上传讲师高清照片(坐姿/站姿)及配套 PPT 课件等制作素材。' },
+  { step: '03', title: '选择讲师与上传课件', description: '从工作区选择数字讲师,上传参考照片、录音和配套课件;提交制作前补齐讲师照片和录音。' },
   { step: '04', title: '提交制作与交付', description: '确认无误后提交,顾问团队排期制作,完成后在工作区一键获取成品。' },
 ]
 
@@ -65,7 +65,7 @@ const FAQ_LIST: FAQItem[] = [
         <ol style={{ margin: 0, paddingLeft: '20px', lineHeight: 1.8 }}>
           <li><strong>创建课程</strong>:填写课程主题、分类与简介,也可直接从官方模板一键生成;</li>
           <li><strong>编排分集大纲</strong>:添加各个分集的章节名称、预计时长与口播逐字稿;</li>
-          <li><strong>配置讲师与上传素材</strong>:上传讲师出镜形象照片(坐姿/站姿)及配套 PPT 课件;</li>
+          <li><strong>配置讲师与上传素材</strong>:从工作区资产库选择一位数字讲师,上传照片、录音及配套 PPT 课件;照片和录音可稍后补齐,但提交制作前必须齐备;</li>
           <li><strong>提交制作</strong>:确认材料无误后点击“提交制作”,制作团队将跟进合成并交付最终视频。</li>
         </ol>
       </div>
@@ -76,7 +76,7 @@ const FAQ_LIST: FAQItem[] = [
     category: 'getting-started',
     question: '新手可以如何快速体验?有预设模板吗?',
     answer:
-      '在创建课程页面,我们提供了多种精选的官方课程模板(如企业培训、通识科普、专业讲座等)。点击“使用示例”即可一键预填完整的分集大纲、讲师配置与配套素材,方便您快速熟悉全套工作流。',
+      '在创建课程页面,我们提供了多种精选的官方课程模板(如企业培训、通识科普、专业讲座等)。使用示例后,可在课程页选择工作区数字讲师;模板不会自动生成可用于制作的讲师。',
   },
   {
     id: 'ppt-requirements',
@@ -154,9 +154,9 @@ const FAQ_LIST: FAQItem[] = [
   {
     id: 'profile-and-security',
     category: 'account',
-    question: '如何修改我的联系人姓名、单位或登录密码?',
+    question: '如何修改我的联系人姓名或登录密码?',
     answer:
-      '点击顶部导航栏右上角的个人账号名称(或前往「个人中心 /profile」),即可修改您的联系人姓名、单位名称、微信号,或进行登录密码的修改。',
+      '点击顶部导航栏右上角的个人账号名称(或前往「个人中心 /profile」),即可修改联系人姓名、微信号或登录密码。所属组织由组织管理员或平台管理员管理。',
   },
 ]
 

+ 16 - 229
src/pages/NewCourse.tsx

@@ -1,43 +1,33 @@
 import {
   ArrowLeft,
   ArrowRight,
-  BookOpen,
-  Check,
   CheckCircle2,
-  FileCheck,
   Flame,
-  Info,
-  Layers,
   Sparkles,
-  Users,
-  Video,
-  Volume2,
-  Wand2,
-  X
+  Wand2
 } from 'lucide-react'
 import { FormEvent, useEffect, useState } from 'react'
 import { Link, useNavigate } from 'react-router-dom'
 import { api } from '../api'
 import { COURSE_TEMPLATES, type CourseTemplate } from '../data/courseTemplates'
-
-const DEFAULT_COURSE_DESCRIPTION = `【课程简介】本课程围绕核心主题展开,通过清晰的知识讲解与案例说明,帮助学习者理解并掌握课程重点。
-【制作要求】采用“讲师讲解 + PPT课件”的形式,画面简洁清晰,内容层次分明;讲解自然流畅,重点内容配合字幕或图示呈现;视频建议使用16:9横屏、1080P高清规格。`
+import { DigitalInstructorPicker } from '../components/DigitalInstructorPicker'
+import { useWorkspace } from '../WorkspaceContext'
 
 export function NewCourse() {
   const navigate = useNavigate()
+  const { workspaceId } = useWorkspace()
+  const [digitalInstructorId, setDigitalInstructorId] = useState<string | null>(null)
   const [templates, setTemplates] = useState<CourseTemplate[]>(COURSE_TEMPLATES)
   const [selectedTemplateId, setSelectedTemplateId] = useState<string>(COURSE_TEMPLATES[0].id)
   const [activeTab, setActiveTab] = useState<'form' | 'templates'>('form')
 
   // 表单状态
   const [name, setName] = useState('')
-  const [category, setCategory] = useState('党政专题')
-  const [description, setDescription] = useState(DEFAULT_COURSE_DESCRIPTION)
+  const [description, setDescription] = useState('')
 
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState('')
   const [filledNotice, setFilledNotice] = useState('')
-  const [detailModalOpen, setDetailModalOpen] = useState(false)
 
   const currentTemplate = templates.find((t) => t.id === selectedTemplateId) || templates[0]
 
@@ -57,7 +47,6 @@ export function NewCourse() {
   // 1. 一键填入表单
   function handleFillTemplate(tmpl: CourseTemplate = currentTemplate) {
     setName(tmpl.name)
-    setCategory(tmpl.category)
     setDescription(tmpl.description)
     setFilledNotice(`已填入示例《${tmpl.name}》内容,您可按需进行调整。`)
     setTimeout(() => setFilledNotice(''), 4500)
@@ -84,8 +73,8 @@ export function NewCourse() {
     try {
       const { course } = await api.createCourse({
         name: name.trim(),
-        category,
-        description: description.trim()
+        description: description.trim(),
+        digitalInstructorId
       })
       navigate(`/courses/${course.id}`)
     } catch (x) {
@@ -103,16 +92,12 @@ export function NewCourse() {
       <div className="form-layout new-course-layout">
         {/* 左侧说明与精选示例区域 */}
         <aside className="new-course-aside">
-          <span className="step-number">01</span>
-          <p className="eyebrow">
-            <Sparkles size={13} /> 创建课程
-          </p>
           <h1>
             先告诉我们,<br />
             这是一门什么课。
           </h1>
           <p className="aside-subtext">
-            填写基本信息后,可继续添加课程资产和讲师;也可以参考下方制作示例快速开始。
+            填写基本信息并选择工作区数字讲师;照片和录音可稍后补齐,提交制作前必须齐备。
           </p>
 
           {/* 示范模版精选卡片 */}
@@ -133,8 +118,7 @@ export function NewCourse() {
                   className={`template-pill ${tmpl.id === currentTemplate.id ? 'active' : ''}`}
                   onClick={() => setSelectedTemplateId(tmpl.id)}
                 >
-                  {tmpl.badge && <span className="pill-badge">{tmpl.badge}</span>}
-                  {tmpl.category}
+                  {tmpl.badge || tmpl.name}
                 </button>
               ))}
             </div>
@@ -145,32 +129,6 @@ export function NewCourse() {
                 <h3 className="sample-title">{currentTemplate.name}</h3>
                 <p className="sample-desc">{currentTemplate.summary}</p>
               </div>
-
-              {/* 关键规格参数列表 */}
-              <div className="specs-grid">
-                <div className="spec-item">
-                  <Video size={13} />
-                  <div>
-                    <small>分辨率与画面</small>
-                    <span>{currentTemplate.keySpecs.resolution}</span>
-                  </div>
-                </div>
-                <div className="spec-item">
-                  <Layers size={13} />
-                  <div>
-                    <small>讲师与课件比例</small>
-                    <span>{currentTemplate.keySpecs.presenterRatio}</span>
-                  </div>
-                </div>
-                <div className="spec-item">
-                  <Volume2 size={13} />
-                  <div>
-                    <small>语速与节奏控制</small>
-                    <span>{currentTemplate.keySpecs.speechSpeed}</span>
-                  </div>
-                </div>
-              </div>
-
               {/* 模版操作按钮组 */}
               <div className="template-actions">
                 <button
@@ -186,21 +144,11 @@ export function NewCourse() {
                   className="primary-button small-button action-btn highlight-sample-btn"
                   disabled={busy}
                   onClick={() => handleCreateFromTemplate(currentTemplate)}
-                  title="直接创建完整示范课程,并自动导入讲师与课件素材"
+                  title="创建示范课程并导入课件素材,讲师需在课程页选择"
                 >
                   <Sparkles size={14} /> 一键使用此示例
                 </button>
               </div>
-
-              <div className="template-card-footer">
-                <button
-                  type="button"
-                  className="detail-link-btn"
-                  onClick={() => setDetailModalOpen(true)}
-                >
-                  <Info size={13} /> 查看完整任务书制作规范与验收标准 &raquo;
-                </button>
-              </div>
             </div>
           </div>
         </aside>
@@ -217,54 +165,27 @@ export function NewCourse() {
           <form className="paper-form" onSubmit={submit}>
             <div className="form-title">
               <div className="title-left">
-                <span>COURSE PROFILE</span>
-                <h2>课程基本信息</h2>
+                <h2>视频课程基本信息</h2>
               </div>
             </div>
 
             <label>
-              <span>课程名称 <b>*</b></span>
+              <span>视频课程名称 <b>*</b></span>
               <input
                 name="name"
                 required
                 maxLength={100}
-                placeholder="例如:坚持人民至上根本立场党课视频"
+                placeholder="例如:高校数字化教学实战与微课制作规范"
                 value={name}
                 onChange={(e) => setName(e.target.value)}
               />
             </label>
 
-            <label>
-              <span>课程分类</span>
-              <select
-                name="category"
-                value={category}
-                onChange={(e) => setCategory(e.target.value)}
-              >
-                <option value="党政专题">党政专题</option>
-                <option value="高校公开课">高校公开课</option>
-                <option value="企业培训">企业培训</option>
-                <option value="职业教育">职业教育</option>
-                <option value="其他">其他</option>
-              </select>
-            </label>
-
-            <label>
-              <span>课程简介与制作要求</span>
-              <textarea
-                name="description"
-                rows={7}
-                maxLength={1000}
-                placeholder="填写课程概述、目标人群、运镜风格(如30%出镜/70%课件)、语速要求(如210-225字/分)或技术规格要求等..."
-                value={description}
-                onChange={(e) => setDescription(e.target.value)}
-              />
-            </label>
-
             {error && <p className="form-error">{error}</p>}
+            <DigitalInstructorPicker key={workspaceId} value={digitalInstructorId} onChange={setDigitalInstructorId} />
 
             <div className="form-footer">
-              <span>下一步:添加课程资产与讲师</span>
+              <span>下一步:添加课程资产并完善讲师素材</span>
               <button className="primary-button" disabled={busy || !name.trim()}>
                 {busy ? '正在创建…' : '继续'}
                 <ArrowRight size={17} />
@@ -273,140 +194,6 @@ export function NewCourse() {
           </form>
         </div>
       </div>
-
-      {/* 完整制作任务书与技术标准弹窗 */}
-      {detailModalOpen && (
-        <div className="modal-backdrop" onClick={() => setDetailModalOpen(false)}>
-          <div className="modal-dialog brief-modal" onClick={(e) => e.stopPropagation()}>
-            <div className="modal-header">
-              <div>
-                <span className="modal-badge">{currentTemplate.badge || '制作示范'}</span>
-                <h3>{currentTemplate.name} · 制作任务书</h3>
-              </div>
-              <button
-                type="button"
-                className="close-modal-btn"
-                onClick={() => setDetailModalOpen(false)}
-              >
-                <X size={18} />
-              </button>
-            </div>
-
-            <div className="modal-body brief-modal-content">
-              {/* 核心重点 */}
-              <div className="brief-section focus-card">
-                <h4>
-                  <Flame size={15} /> 核心重点要求
-                </h4>
-                <ul>
-                  {currentTemplate.taskBrief.focalPoints.map((pt, i) => (
-                    <li key={i}>{pt}</li>
-                  ))}
-                </ul>
-              </div>
-
-              {/* 项目目标 */}
-              <div className="brief-section">
-                <h4>
-                  <FileCheck size={15} /> 项目目标与规格
-                </h4>
-                <ul>
-                  {currentTemplate.taskBrief.goals.map((g, i) => (
-                    <li key={i}>{g}</li>
-                  ))}
-                </ul>
-              </div>
-
-              {/* 画面与运镜节奏 */}
-              <div className="brief-section">
-                <h4>
-                  <Video size={15} /> 画面运镜与语速控制
-                </h4>
-                <ul>
-                  {currentTemplate.taskBrief.cameraAndPacing.map((c, i) => (
-                    <li key={i}>{c}</li>
-                  ))}
-                </ul>
-              </div>
-
-              {/* 验收标准 */}
-              <div className="brief-section">
-                <h4>
-                  <CheckCircle2 size={15} /> 交付与验收标准
-                </h4>
-                <ul>
-                  {currentTemplate.taskBrief.acceptanceCriteria.map((a, i) => (
-                    <li key={i}>{a}</li>
-                  ))}
-                </ul>
-              </div>
-
-              {/* 预置素材与讲师概览 */}
-              <div className="brief-section">
-                <h4>
-                  <Users size={15} /> 示范配置一览
-                </h4>
-                <div className="brief-preset-grid">
-                  <div className="brief-subcard">
-                    <strong>示范讲师</strong>
-                    {currentTemplate.instructors.map((inst, i) => (
-                      <div key={i} className="inst-subrow">
-                        <span>{inst.name}</span>
-                        <small>{inst.organization}</small>
-                        <p>{inst.introduction}</p>
-                      </div>
-                    ))}
-                  </div>
-                  {currentTemplate.episodes && currentTemplate.episodes.length > 0 && (
-                    <div className="brief-subcard">
-                      <strong>示范课程分集 ({currentTemplate.episodes.length} 讲/集)</strong>
-                      {currentTemplate.episodes.map((ep, i) => (
-                        <div key={i} className="ep-subrow">
-                          <span>{ep.title}</span>
-                          <small>包含完整口播讲稿与课件</small>
-                        </div>
-                      ))}
-                    </div>
-                  )}
-                  <div className="brief-subcard">
-                    <strong>示范课件与讲稿</strong>
-                    {currentTemplate.assets.map((ast, i) => (
-                      <div key={i} className="asset-subrow">
-                        <span>{ast.originalName}</span>
-                        <small>{ast.sizeText} · {ast.description}</small>
-                      </div>
-                    ))}
-                  </div>
-                </div>
-              </div>
-            </div>
-
-            <div className="modal-footer">
-              <button
-                type="button"
-                className="secondary-button"
-                onClick={() => {
-                  handleFillTemplate(currentTemplate)
-                  setDetailModalOpen(false)
-                }}
-              >
-                <Wand2 size={15} /> 填入当前表单
-              </button>
-              <button
-                type="button"
-                className="primary-button highlight-sample-btn"
-                disabled={busy}
-                onClick={() => {
-                  setDetailModalOpen(false)
-                  handleCreateFromTemplate(currentTemplate)
-                }}
-              >
-                <Sparkles size={15} /> 一键使用此示例创建课程
-              </button>
-            </div>
-          </div>
-        </div>
-      )}
     </div>
   )
 }

+ 279 - 0
src/pages/OrganizationManage.tsx

@@ -0,0 +1,279 @@
+import { useEffect, useMemo, useState, type FormEvent } from 'react'
+import { Link, Navigate, useParams } from 'react-router-dom'
+import QRCode from 'qrcode'
+import { api } from '../api'
+import { useAuth } from '../AuthContext'
+import { useWorkspace } from '../WorkspaceContext'
+import { StatusBadge } from '../components/StatusBadge'
+import { AdminCourseDetail } from './admin/AdminCourseDetail'
+import { formatDateTime } from '../utils/date'
+import type { CourseStatus } from '../types'
+
+type CourseSortField = 'updated_at' | 'created_at'
+type SortOrder = 'desc' | 'asc'
+
+export function OrganizationManage() {
+  const { slug = '', id: courseId } = useParams()
+  const { user } = useAuth()
+  const { organizations, selectWorkspace } = useWorkspace()
+  const organization = organizations.find((item) => item.slug === slug)
+  const [content, setContent] = useState<any>(null)
+  const [manage, setManage] = useState<any>(null)
+  const [error, setError] = useState('')
+  const [message, setMessage] = useState('')
+  const [invite, setInvite] = useState('')
+  const [inviteQrCode, setInviteQrCode] = useState('')
+  const [inviteCopied, setInviteCopied] = useState(false)
+  const [createdLogin, setCreatedLogin] = useState<{ phone: string; password: string } | null>(null)
+  const [editingMemberId, setEditingMemberId] = useState<string | null>(null)
+  const [editingMemberName, setEditingMemberName] = useState('')
+  const [savingMemberName, setSavingMemberName] = useState(false)
+  const [showAudit, setShowAudit] = useState(false)
+  const [courseSortField, setCourseSortField] = useState<CourseSortField>('updated_at')
+  const [courseSortOrder, setCourseSortOrder] = useState<SortOrder>('desc')
+
+  async function load() {
+    if (!organization) return
+    try {
+      const [c, m] = await Promise.all([api.organizations.content(organization.id), api.organizations.manage(organization.id)])
+      setContent(c); setManage(m)
+    } catch (e) { setError((e as Error).message) }
+  }
+  useEffect(() => { load() }, [organization?.id])
+  const activeInvite = invite || (manage?.inviteToken ? `${window.location.origin}/join/${manage.inviteToken}` : '')
+  useEffect(() => {
+    if (!activeInvite) { setInviteQrCode(''); return }
+    QRCode.toDataURL(activeInvite, { width: 176, margin: 1, errorCorrectionLevel: 'M' })
+      .then(setInviteQrCode)
+      .catch(() => setInviteQrCode(''))
+  }, [activeInvite])
+  const sortedCourses = useMemo(() => {
+    if (!content?.courses) return []
+    return [...content.courses].sort((a: any, b: any) => {
+      const valA = new Date(courseSortField === 'updated_at' ? (a.updated_at || a.created_at) : a.created_at).getTime() || 0
+      const valB = new Date(courseSortField === 'updated_at' ? (b.updated_at || b.created_at) : b.created_at).getTime() || 0
+      return courseSortOrder === 'desc' ? valB - valA : valA - valB
+    })
+  }, [content?.courses, courseSortField, courseSortOrder])
+
+  if (!user) return <Navigate to="/" replace />
+  if (!organization || organization.role !== 'MANAGER') return <div className="page">没有组织管理权限</div>
+  if (courseId) return <div className="org-manage-shell"><header className="admin-topbar"><Link to={`/organizations/${slug}/manage`}>← {organization.name} · 组织管理</Link></header>
+    <AdminCourseDetail backTo={`/organizations/${slug}/manage`} /></div>
+
+  async function createUser(e: FormEvent<HTMLFormElement>) {
+    e.preventDefault()
+    const form = new FormData(e.currentTarget)
+    try {
+      const result = await api.organizations.createUser(organization!.id, String(form.get('phone')), String(form.get('contactName')))
+      setMessage(`已创建 ${result.user.contactName}:手机号 ${result.user.phone},初始密码 ${result.initialPassword}`)
+      setCreatedLogin({ phone: result.user.phone, password: result.initialPassword })
+      ;(e.currentTarget as HTMLFormElement).reset()
+      await load()
+    } catch (x) { setError((x as Error).message) }
+  }
+  async function review(id: string, approve: boolean) {
+    try { await api.organizations.review(organization!.id, id, approve); await load() }
+    catch (x) { setError((x as Error).message) }
+  }
+  async function rotateInvite() {
+    if (manage?.inviteActive && !confirm('生成新链接会立即停用旧链接,继续吗?')) return
+    try {
+      const result = await api.organizations.rotateInvite(organization!.id)
+      setInvite(`${window.location.origin}/join/${result.token}`)
+      await load()
+    } catch (x) { setError((x as Error).message) }
+  }
+  async function copyInvite() {
+    try {
+      await navigator.clipboard.writeText(activeInvite)
+      setInviteCopied(true)
+      window.setTimeout(() => setInviteCopied(false), 1800)
+    } catch { setError('复制失败,请手动复制链接') }
+  }
+  async function saveMemberName(memberId: string) {
+    const contactName = editingMemberName.trim()
+    if (!contactName) { setError('请填写姓名'); return }
+    setSavingMemberName(true)
+    setError('')
+    try {
+      await api.organizations.updateMemberName(organization!.id, memberId, contactName)
+      setEditingMemberId(null)
+      setMessage('姓名已更新')
+      await load()
+    } catch (x) { setError((x as Error).message) } finally { setSavingMemberName(false) }
+  }
+
+  function toggleCourseSort(field: CourseSortField) {
+    if (courseSortField === field) {
+      setCourseSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc'))
+    } else {
+      setCourseSortField(field)
+      setCourseSortOrder('desc')
+    }
+  }
+
+  return <div className="org-manage-shell">
+    <header className="admin-topbar org-manage-topbar">
+      <Link className="org-manage-back" to={`/organizations/${slug}`} aria-label="返回机构主页">← 返回机构主页</Link>
+    </header>
+    <main className="page admin-page">
+      <div className="admin-header-row"><div><h1>{organization.name} · 机构设置</h1><p>成员、课程与组织账单</p></div></div>
+    {error && <div className="notice notice-error">{error}</div>}
+    {message && <div className="notice notice-success">{message}</div>}
+    {createdLogin && <div className="org-created-login"><span>请将登录信息交给新老师,并提醒首次登录修改密码。</span>
+      <button className="secondary-button small-button" onClick={() => navigator.clipboard.writeText(`手机号:${createdLogin.phone}\n初始密码:${createdLogin.password}`)}>复制登录信息</button></div>}
+    {!content || !manage ? <div className="center-loading">正在加载组织数据…</div> : <>
+      {!organization.isActive && <div className="notice notice-error">该组织已停用:可查看和处理历史课程,暂不能新增成员或课程。</div>}
+      <div className="stats-grid"><div className="stat-card"><div className="stat-content"><small>组织成员</small><strong>{manage.members.length}</strong></div></div>
+        <div className="stat-card"><div className="stat-content"><small>组织课程</small><strong>{content.courses.length}</strong></div></div>
+        <div className="stat-card"><div className="stat-content"><small>组织积分余额</small><strong>{content.organization.pointsBalance.toLocaleString('zh-CN')}</strong></div></div></div>
+      <section className="admin-section-block"><h2>现场创建老师</h2><form onSubmit={createUser} className="org-inline-form">
+        <label>手机号<input name="phone" required pattern="1[0-9]{10}" inputMode="numeric" /></label>
+        <label>姓名<input name="contactName" required maxLength={80} /></label>
+        <button className="primary-button" disabled={!organization.isActive}>创建账号(初始密码 888888)</button>
+      </form></section>
+      <section className="admin-section-block"><h2>免审批注册链接</h2><p>持有链接的人注册或登录后,可直接加入本组织。</p>
+        <div className="org-invite-actions"><button className="secondary-button" onClick={rotateInvite} disabled={!organization.isActive}>{manage.inviteActive ? '重新生成并停用旧链接' : '生成注册链接'}</button>
+        {manage.inviteActive && <button className="secondary-button" onClick={async () => {
+          if (!confirm('停用后,已发出的注册链接将无法再加入组织。')) return
+          await api.organizations.disableInvite(organization.id); setInvite(''); await load()
+        }}>停用当前链接</button>}</div>
+        {activeInvite && <div className="org-invite-display">
+          <div className="org-invite-link"><label htmlFor="organization-invite-link">当前注册链接</label><div><input id="organization-invite-link" readOnly value={activeInvite} /><button className="secondary-button small-button" onClick={copyInvite}>{inviteCopied ? '已复制' : '复制链接'}</button></div></div>
+          {inviteQrCode && <div className="org-invite-qr"><img src={inviteQrCode} alt="注册链接二维码" /><span>扫码打开注册链接</span></div>}
+        </div>}
+        {manage.inviteActive && !activeInvite && <p className="org-invite-legacy-note">当前有效链接生成于此前版本,出于安全原因无法还原。请重新生成链接后,即可展示并复制二维码。</p>}
+      </section>
+      <section className="admin-section-block"><h2>待审批申请</h2>
+        {manage.applications.filter((a: any) => a.status === 'PENDING').length === 0 ? <p>暂无待审批申请</p> :
+          manage.applications.filter((a: any) => a.status === 'PENDING').map((a: any) => <div className="org-list-row" key={a.id}>
+            <span>{a.contact_name || a.phone} · {a.phone}</span><div><button className="primary-button small-button" onClick={() => review(a.id, true)}>同意</button>
+              <button className="secondary-button small-button" onClick={() => review(a.id, false)}>拒绝</button></div></div>)}
+      </section>
+      <section className="admin-section-block">
+        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginBottom: '12px' }}>
+          <h2 style={{ margin: 0 }}>课程管理</h2>
+          <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
+            <label style={{ fontSize: '0.85rem', color: '#64748b' }}>排序:</label>
+            <select
+              style={{ padding: '4px 8px', borderRadius: '6px', border: '1px solid #cbd5e1', fontSize: '0.85rem', background: '#fff' }}
+              value={`${courseSortField}-${courseSortOrder}`}
+              onChange={(e) => {
+                const [f, o] = e.target.value.split('-') as [CourseSortField, SortOrder]
+                setCourseSortField(f)
+                setCourseSortOrder(o)
+              }}
+            >
+              <option value="updated_at-desc">最后更新时间(最新优先)</option>
+              <option value="updated_at-asc">最后更新时间(最早优先)</option>
+              <option value="created_at-desc">创建时间(最新优先)</option>
+              <option value="created_at-asc">创建时间(最早优先)</option>
+            </select>
+          </div>
+        </div>
+        <div className="admin-table-wrapper">
+          <table className="admin-table">
+            <thead>
+              <tr>
+                <th>课程</th>
+                <th>老师</th>
+                <th>状态</th>
+                <th>
+                  <button
+                    type="button"
+                    style={{ background: 'none', border: 'none', padding: 0, font: 'inherit', fontWeight: 600, color: 'inherit', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
+                    onClick={() => toggleCourseSort('updated_at')}
+                    title="点击切换按最后更新时间排序"
+                  >
+                    最后更新时间 {courseSortField === 'updated_at' ? (courseSortOrder === 'desc' ? '↓' : '↑') : '↕'}
+                  </button>
+                </th>
+                <th>
+                  <button
+                    type="button"
+                    style={{ background: 'none', border: 'none', padding: 0, font: 'inherit', fontWeight: 600, color: 'inherit', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
+                    onClick={() => toggleCourseSort('created_at')}
+                    title="点击切换按创建时间排序"
+                  >
+                    创建时间 {courseSortField === 'created_at' ? (courseSortOrder === 'desc' ? '↓' : '↑') : '↕'}
+                  </button>
+                </th>
+                <th>操作</th>
+              </tr>
+            </thead>
+            <tbody>
+              {sortedCourses.length === 0 ? (
+                <tr>
+                  <td colSpan={6} style={{ textAlign: 'center', color: '#64748b', padding: '24px' }}>
+                    暂无组织课程
+                  </td>
+                </tr>
+              ) : (
+                sortedCourses.map((c: any) => (
+                  <tr key={c.id}>
+                    <td><strong>{c.name}</strong></td>
+                    <td>{c.creator_name || '未填写'}</td>
+                    <td><StatusBadge status={c.status as CourseStatus} /></td>
+                    <td><span style={{ fontSize: '0.85rem', color: '#334155' }}>{formatDateTime(c.updated_at || c.created_at)}</span></td>
+                    <td><span style={{ fontSize: '0.85rem', color: '#64748b' }}>{formatDateTime(c.created_at)}</span></td>
+                    <td>
+                      <div className="action-button-group">
+                        <Link className="secondary-button small-button" to={`/organizations/${slug}/manage/courses/${c.id}`}>制作与积分</Link>
+                        <Link className="secondary-button small-button" to={`/courses/${c.id}`} onClick={() => selectWorkspace(organization.id)}>代修改内容</Link>
+                        <button className="secondary-button small-button" onClick={async () => {
+                          if (!confirm(`删除课程「${c.name}」及其素材?已结算积分不会自动退回,账单和操作记录会保留。`)) return
+                          selectWorkspace(organization.id)
+                          try { await api.deleteCourse(c.id); await load() } catch (x) { setError((x as Error).message) }
+                        }}>删除</button>
+                      </div>
+                    </td>
+                  </tr>
+                ))
+              )}
+            </tbody>
+          </table>
+        </div>
+      </section>
+      <section className="admin-section-block"><h2>人员名单</h2><div className="admin-table-wrapper"><table className="admin-table"><thead><tr><th>姓名</th><th>手机号</th><th>组织身份</th><th>操作</th></tr></thead><tbody>
+        {manage.members.map((m: any) => <tr key={m.id}><td>{editingMemberId === m.id ? <input aria-label={`${m.phone} 的姓名`} value={editingMemberName} maxLength={80} autoFocus onChange={(e) => setEditingMemberName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') saveMemberName(m.id); if (e.key === 'Escape') setEditingMemberId(null) }} /> : (m.contact_name || '未填写')}</td><td>{m.phone}</td><td>{m.role === 'MANAGER' ? '组织管理员' : '成员'}</td><td>{editingMemberId === m.id ? <div className="action-button-group"><button className="primary-button small-button" disabled={savingMemberName} onClick={() => saveMemberName(m.id)}>{savingMemberName ? '保存中…' : '保存'}</button><button className="secondary-button small-button" disabled={savingMemberName} onClick={() => setEditingMemberId(null)}>取消</button></div> : <button className="secondary-button small-button" onClick={() => { setEditingMemberId(m.id); setEditingMemberName(m.contact_name || '') }}>修改姓名</button>}</td></tr>)}
+      </tbody></table></div></section>
+      <section className="admin-section-block"><h2>组织积分流水</h2><div className="admin-table-wrapper"><table className="admin-table"><thead><tr><th>时间</th><th>事项</th><th>老师</th><th>积分变化</th></tr></thead><tbody>
+        {manage.ledger.map((l: any) => <tr key={l.id}><td>{new Date(l.created_at).toLocaleString('zh-CN')}</td><td>{l.reason}</td><td>{l.beneficiary_name || '组织账户'}</td><td>{l.amount}</td></tr>)}
+      </tbody></table></div></section>
+      <section className="admin-section-block">
+        <div
+          style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', userSelect: 'none' }}
+          onClick={() => setShowAudit((prev) => !prev)}
+        >
+          <h2 style={{ margin: 0 }}>操作记录</h2>
+          <button
+            type="button"
+            className="secondary-button small-button"
+            onClick={(e) => {
+              e.stopPropagation()
+              setShowAudit((prev) => !prev)
+            }}
+          >
+            {showAudit ? '收起记录' : '展开记录'}
+          </button>
+        </div>
+        {showAudit && (
+          <div style={{ marginTop: '16px' }}>
+            {manage.audit?.length === 0 ? (
+              <p style={{ color: '#64748b', marginTop: '12px' }}>暂无操作记录</p>
+            ) : (
+              manage.audit.map((a: any) => (
+                <details className="org-audit-event" key={a.id}>
+                  <summary>{a.actor_name || '系统'} · {a.action} · {new Date(a.created_at).toLocaleString('zh-CN')}</summary>
+                  <pre>{JSON.stringify(a.details, null, 2)}</pre>
+                </details>
+              ))
+            )}
+          </div>
+        )}
+      </section>
+    </>}
+    </main>
+  </div>
+}

+ 263 - 0
src/pages/OrganizationPage.tsx

@@ -0,0 +1,263 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
+import { api } from '../api'
+import { useAuth } from '../AuthContext'
+import { useWorkspace } from '../WorkspaceContext'
+import { StatusBadge } from '../components/StatusBadge'
+import { formatDateTime } from '../utils/date'
+import type { CourseStatus } from '../types'
+
+export function OrganizationPage() {
+  const { slug = '' } = useParams()
+  const { user } = useAuth()
+  const { organizations } = useWorkspace()
+  const [content, setContent] = useState<any>(null)
+  const [orgInfo, setOrgInfo] = useState<{ id: string; name: string; isActive: boolean } | null>(null)
+  const [loading, setLoading] = useState(true)
+  const [applying, setApplying] = useState(false)
+  const [message, setMessage] = useState('')
+  const [error, setError] = useState('')
+  const [courseSortField, setCourseSortField] = useState<'updated_at' | 'created_at'>('updated_at')
+  const [courseSortOrder, setCourseSortOrder] = useState<'desc' | 'asc'>('desc')
+
+  useEffect(() => {
+    if (!user || !slug) return
+    let cancelled = false
+    setLoading(true)
+    setError('')
+    api.organizations.publicInfo(slug)
+      .then(async ({ organization }) => {
+        if (cancelled) return
+        if (!organization) {
+          setError('组织不存在')
+          setLoading(false)
+          return
+        }
+        setOrgInfo(organization)
+        const isMember = organizations.some((item) => item.id === organization.id)
+        if (isMember) {
+          try {
+            const data = await api.organizations.content(organization.id)
+            if (!cancelled) setContent(data)
+          } catch (e) {
+            if (!cancelled) setError((e as Error).message)
+          }
+        }
+        if (!cancelled) setLoading(false)
+      })
+      .catch((e) => {
+        if (!cancelled) {
+          setError(e.message)
+          setLoading(false)
+        }
+      })
+
+    return () => {
+      cancelled = true
+    }
+  }, [slug, user, organizations])
+
+  const sortedCourses = useMemo(() => {
+    if (!content?.courses) return []
+    return [...content.courses].sort((a: any, b: any) => {
+      const valA = new Date(courseSortField === 'updated_at' ? (a.updated_at || a.created_at) : a.created_at).getTime() || 0
+      const valB = new Date(courseSortField === 'updated_at' ? (b.updated_at || b.created_at) : b.created_at).getTime() || 0
+      return courseSortOrder === 'desc' ? valB - valA : valA - valB
+    })
+  }, [content?.courses, courseSortField, courseSortOrder])
+
+  if (!user) {
+    return <Navigate to="/" replace />
+  }
+
+  async function apply() {
+    if (!orgInfo) return
+    setApplying(true)
+    setError('')
+    try {
+      await api.organizations.apply(slug)
+      setMessage('申请已提交,等待组织管理员审批')
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setApplying(false)
+    }
+  }
+
+  if (loading) {
+    return (
+      <div className="page org-page">
+        <div className="center-loading">正在加载组织信息…</div>
+      </div>
+    )
+  }
+
+  if (error && !orgInfo) {
+    return (
+      <div className="page org-page">
+        <div className="notice notice-error">{error}</div>
+      </div>
+    )
+  }
+
+  // 已登录但不属于组织:仅展示组织名称和申请加入
+  if (!content) {
+    return (
+      <div className="page org-page org-guest-page">
+        <div className="org-apply-card">
+          <div className="org-apply-header">
+            <h1 className="org-title">{orgInfo?.name || '组织'}</h1>
+          </div>
+          {error && <div className="notice notice-error">{error}</div>}
+          {message && <div className="notice notice-success">{message}</div>}
+          <div className="org-apply-actions">
+            {orgInfo?.isActive ? (
+              <button
+                type="button"
+                className="primary-button"
+                onClick={apply}
+                disabled={applying || !!message}
+              >
+                {applying ? '正在提交申请…' : message ? '申请已提交' : '申请加入'}
+              </button>
+            ) : (
+              <p className="org-inactive-text">该组织已停用</p>
+            )}
+          </div>
+        </div>
+      </div>
+    )
+  }
+
+  function toggleCourseSort(field: 'updated_at' | 'created_at') {
+    if (courseSortField === field) {
+      setCourseSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc'))
+    } else {
+      setCourseSortField(field)
+      setCourseSortOrder('desc')
+    }
+  }
+
+  // 已属于组织:展示完整组织页面
+  return (
+    <div className="page org-page">
+      <div className="admin-header-row">
+        <div>
+          <h1>{content.organization.name}</h1>
+          <p>组织课程与成员</p>
+        </div>
+        <div className="action-button-group">
+          <Link className="secondary-button" to="/">
+            返回工作台
+          </Link>
+          {content.organization.role === 'MANAGER' && (
+            <Link className="primary-button" to={`/organizations/${slug}/manage`}>
+              机构设置
+            </Link>
+          )}
+        </div>
+      </div>
+
+      <div className="admin-section-block">
+        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginBottom: '12px' }}>
+          <h2 style={{ margin: 0 }}>课程进度</h2>
+          {content.courses.length > 1 && (
+            <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
+              <label style={{ fontSize: '0.85rem', color: '#64748b' }}>排序:</label>
+              <select
+                style={{ padding: '4px 8px', borderRadius: '6px', border: '1px solid #cbd5e1', fontSize: '0.85rem', background: '#fff' }}
+                value={`${courseSortField}-${courseSortOrder}`}
+                onChange={(e) => {
+                  const [f, o] = e.target.value.split('-') as ['updated_at' | 'created_at', 'desc' | 'asc']
+                  setCourseSortField(f)
+                  setCourseSortOrder(o)
+                }}
+              >
+                <option value="updated_at-desc">最后更新时间(最新优先)</option>
+                <option value="updated_at-asc">最后更新时间(最早优先)</option>
+                <option value="created_at-desc">创建时间(最新优先)</option>
+                <option value="created_at-asc">创建时间(最早优先)</option>
+              </select>
+            </div>
+          )}
+        </div>
+        {sortedCourses.length === 0 ? (
+          <p>暂无课程</p>
+        ) : (
+          <div className="admin-table-wrapper">
+            <table className="admin-table">
+              <thead>
+                <tr>
+                  <th>课程</th>
+                  <th>创建老师</th>
+                  <th>进度</th>
+                  <th>
+                    <button
+                      type="button"
+                      style={{ background: 'none', border: 'none', padding: 0, font: 'inherit', fontWeight: 600, color: 'inherit', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
+                      onClick={() => toggleCourseSort('updated_at')}
+                      title="点击切换按最后更新时间排序"
+                    >
+                      最后更新时间 {courseSortField === 'updated_at' ? (courseSortOrder === 'desc' ? '↓' : '↑') : '↕'}
+                    </button>
+                  </th>
+                  <th>
+                    <button
+                      type="button"
+                      style={{ background: 'none', border: 'none', padding: 0, font: 'inherit', fontWeight: 600, color: 'inherit', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
+                      onClick={() => toggleCourseSort('created_at')}
+                      title="点击切换按创建时间排序"
+                    >
+                      创建时间 {courseSortField === 'created_at' ? (courseSortOrder === 'desc' ? '↓' : '↑') : '↕'}
+                    </button>
+                  </th>
+                </tr>
+              </thead>
+              <tbody>
+                {sortedCourses.map((course: any) => (
+                  <tr key={course.id}>
+                    <td><strong>{course.name}</strong></td>
+                    <td>{course.creator_name || '未填写'}</td>
+                    <td>
+                      <StatusBadge status={course.status as CourseStatus} />
+                    </td>
+                    <td><span style={{ fontSize: '0.85rem', color: '#334155' }}>{formatDateTime(course.updated_at || course.created_at)}</span></td>
+                    <td><span style={{ fontSize: '0.85rem', color: '#64748b' }}>{formatDateTime(course.created_at)}</span></td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          </div>
+        )}
+      </div>
+
+      <div className="admin-section-block">
+        <h2>人员名单</h2>
+        <div className="org-member-grid">
+          {content.members.map((member: any) => (
+            <span key={member.id}>
+              {member.contact_name || '未填写姓名'}
+              {member.role === 'MANAGER' ? ' · 管理员' : ''}
+            </span>
+          ))}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export function InviteJoinPage() {
+  const { token = '' } = useParams()
+  const { user } = useAuth()
+  const { refreshOrganizations } = useWorkspace()
+  const navigate = useNavigate()
+  const [error, setError] = useState('')
+  useEffect(() => {
+    if (!user) return
+    api.organizations.join(token).then(async () => { await refreshOrganizations(); navigate('/') }).catch((e) => setError(e.message))
+  }, [token, user?.id])
+  if (!user) return <div className="page org-page"><h1>加入组织</h1><p>登录或注册后,将通过此链接直接加入组织。</p>
+    <Link className="primary-button" to={`/login?mode=register&invite=${encodeURIComponent(token)}`}>注册并加入</Link>
+    <Link className="secondary-button" to={`/login?invite=${encodeURIComponent(token)}`}>已有账号,登录加入</Link></div>
+  return <div className="page org-page">{error ? <div className="notice notice-error">{error}</div> : '正在加入组织…'}</div>
+}

+ 93 - 23
src/pages/Profile.tsx

@@ -1,43 +1,46 @@
 import { FormEvent, useEffect, useState } from 'react'
-import { ArrowLeft, ChevronRight, Coins, KeyRound, LoaderCircle, Save, UserRound } from 'lucide-react'
+import { ArrowLeft, Building2, ChevronRight, Coins, KeyRound, LoaderCircle, Save, UserRound } from 'lucide-react'
 import { Link, useSearchParams } from 'react-router-dom'
 import { api } from '../api'
 import { useAuth } from '../AuthContext'
 import type { PointsTransaction } from '../types'
+import { useWorkspace } from '../WorkspaceContext'
 
 type ProfileTab = 'profile' | 'security' | 'points'
 
 const tabs: Array<{ id: ProfileTab; label: string; description: string; icon: typeof UserRound }> = [
-  { id: 'profile', label: '用户信息', description: '联系人与单位资料', icon: UserRound },
+  { id: 'profile', label: '用户信息', description: '姓名与组织资料', icon: UserRound },
   { id: 'security', label: '账户安全', description: '修改登录密码', icon: KeyRound },
-  { id: 'points', label: '积分记录', description: '余额与消费明细', icon: Coins }
+  { id: 'points', label: '积分账单', description: '余额与积分流水', icon: Coins }
 ]
 
 export function Profile() {
   const { user, setUser } = useAuth()
+  const { organizations, currentOrganization } = useWorkspace()
   const [searchParams, setSearchParams] = useSearchParams()
   const requestedTab = searchParams.get('tab')
-  const activeTab: ProfileTab = tabs.some((tab) => tab.id === requestedTab)
+  const activeTab: ProfileTab = user?.mustChangePassword ? 'security' : tabs.some((tab) => tab.id === requestedTab)
     ? (requestedTab as ProfileTab)
     : 'profile'
   const [message, setMessage] = useState('')
   const [error, setError] = useState('')
   const [transactions, setTransactions] = useState<PointsTransaction[]>([])
   const [pointsLoading, setPointsLoading] = useState(false)
+  const [displayBalance, setDisplayBalance] = useState<number | null>(null)
 
   useEffect(() => {
     if (activeTab !== 'points') return
     setPointsLoading(true)
-    setError('')
-    api
-      .getPointsTransactions()
+    setError('');
+    (currentOrganization ? api.organizations.points(currentOrganization.id) : api.getPointsTransactions())
       .then((result) => {
         setTransactions(result.transactions)
-        if (user && result.balance !== user.pointsBalance) setUser({ ...user, pointsBalance: result.balance })
+        setDisplayBalance(result.balance)
+        if (!currentOrganization && user && result.balance !== user.pointsBalance) setUser({ ...user, pointsBalance: result.balance })
       })
       .catch((reason) => setError((reason as Error).message))
       .finally(() => setPointsLoading(false))
-  }, [activeTab])
+  }, [activeTab, currentOrganization?.id])
 
   if (!user) return null
 
@@ -54,7 +57,6 @@ export function Profile() {
     setMessage('')
     try {
       const result = await api.updateMe({
-        organization: String(form.get('organization')),
         wechat: String(form.get('wechat')),
         contactName: String(form.get('contactName')),
         bio: String(form.get('bio'))
@@ -75,6 +77,7 @@ export function Profile() {
     if (form.get('newPassword') !== form.get('confirm')) return setError('两次输入的新密码不一致')
     try {
       await api.changePassword(String(form.get('currentPassword')), String(form.get('newPassword')))
+      setUser({ ...user!, mustChangePassword: false })
       formElement.reset()
       setMessage('密码修改成功')
     } catch (reason) {
@@ -92,7 +95,7 @@ export function Profile() {
             <div><strong>{user.contactName || '我的账户'}</strong><span>{user.phone}</span></div>
           </div>
           <nav aria-label="个人中心菜单">
-            {tabs.map(({ id, label, description, icon: Icon }) => (
+            {tabs.filter((tab) => !user.mustChangePassword || tab.id === 'security').map(({ id, label, description, icon: Icon }) => (
               <button key={id} className={activeTab === id ? 'active' : ''} onClick={() => changeTab(id)}>
                 <Icon size={18} />
                 <span><strong>{label}</strong><small>{description}</small></span>
@@ -105,12 +108,58 @@ export function Profile() {
         <main className="profile-content">
           {activeTab === 'profile' && (
             <form className="paper-form profile-panel" onSubmit={save}>
-              <div className="form-title"><span>USER PROFILE</span><h2>用户信息</h2><p>管理您的基本资料和联系方式</p></div>
+              <div className="form-title"><h2>用户信息</h2><p>管理您的基本资料和联系方式</p></div>
               <label>注册手机号<input value={user.phone} disabled /></label>
-              <div className="two-columns">
-                <label>联系人<input name="contactName" defaultValue={user.contactName} placeholder="怎么称呼您" /></label>
-                <label>单位名称<input name="organization" defaultValue={user.organization} placeholder="请输入单位名称" /></label>
+
+              <div className="profile-field-item">
+                <span className="profile-field-label">隶属组织</span>
+                {organizations.length > 0 ? (
+                  <div className="profile-org-list">
+                    {organizations.map((org) => (
+                      <div key={org.id} className="profile-org-card">
+                        <div className="profile-org-main">
+                          <div className="profile-org-icon-box">
+                            <Building2 size={16} />
+                          </div>
+                          <div className="profile-org-meta">
+                            <span className="profile-org-name">{org.name}</span>
+                            <span className={`profile-org-badge ${org.role === 'MANAGER' ? 'badge-manager' : 'badge-member'}`}>
+                              {org.role === 'MANAGER' ? '组织管理员' : '组织成员'}
+                            </span>
+                            {!org.isActive && <span className="profile-org-badge badge-inactive">已停用</span>}
+                          </div>
+                        </div>
+                        {org.role === 'MANAGER' && (
+                          <Link to={`/org/${org.slug}/manage`} className="profile-org-action">
+                            组织管理
+                          </Link>
+                        )}
+                      </div>
+                    ))}
+                  </div>
+                ) : user.organization ? (
+                  <div className="profile-org-list">
+                    <div className="profile-org-card">
+                      <div className="profile-org-main">
+                        <div className="profile-org-icon-box">
+                          <Building2 size={16} />
+                        </div>
+                        <div className="profile-org-meta">
+                          <span className="profile-org-name">{user.organization}</span>
+                          <span className="profile-org-badge badge-member">成员</span>
+                        </div>
+                      </div>
+                    </div>
+                  </div>
+                ) : (
+                  <div className="profile-org-empty-card">
+                    <Building2 size={16} />
+                    <span>当前为个人账号,暂未加入任何企业或组织</span>
+                  </div>
+                )}
               </div>
+
+              <label>姓名<input name="contactName" defaultValue={user.contactName} placeholder="怎么称呼您" /></label>
               <label>联系方式(微信)<input name="wechat" defaultValue={user.wechat} placeholder="请输入微信号" /></label>
               <label>补充信息<textarea name="bio" rows={4} defaultValue={user.bio} placeholder="其他需要说明的信息" /></label>
               <button className="primary-button"><Save size={16} />保存信息</button>
@@ -119,7 +168,8 @@ export function Profile() {
 
           {activeTab === 'security' && (
             <form className="paper-form profile-panel profile-security" onSubmit={password}>
-              <div className="form-title"><span>SECURITY</span><h2>账户安全</h2><p>定期更新密码有助于保护您的账户</p></div>
+              <div className="form-title"><h2>账户安全</h2><p>定期更新密码有助于保护您的账户</p></div>
+              {user.mustChangePassword && <div className="notice notice-error">这是组织管理员创建的初始密码。请先设置自己的密码,再进入工作区。</div>}
               <label>当前密码<input name="currentPassword" type="password" required /></label>
               <label>新密码<input name="newPassword" type="password" minLength={6} required placeholder="至少 6 位" /></label>
               <label>确认新密码<input name="confirm" type="password" minLength={6} required /></label>
@@ -129,26 +179,46 @@ export function Profile() {
 
           {activeTab === 'points' && (
             <section className="paper-form profile-panel points-history-panel">
-              <div className="form-title"><span>POINTS</span><h2>积分记录</h2><p>查看当前积分余额与历史消费明细</p></div>
+              <div className="form-title points-form-title">
+                <div>
+                  <div className="points-heading-wrap">
+                    <h2>{currentOrganization ? `${currentOrganization.name} · 积分账单` : '个人积分账单'}</h2>
+                    {currentOrganization && (
+                      <span className="points-org-pill">
+                        <Building2 size={13} />
+                        {currentOrganization.role === 'MANAGER' ? '组织管理员' : '组织成员'}
+                      </span>
+                    )}
+                  </div>
+                </div>
+                <p>{currentOrganization?.role === 'MANAGER' ? '组织完整积分流水' : currentOrganization ? '组织余额与本人课程消耗' : '个人积分余额与流水'}</p>
+              </div>
               <div className="points-wallet">
                 <div className="points-wallet-icon"><Coins size={25} /></div>
-                <div><span>当前可用积分</span><strong>{user.pointsBalance.toLocaleString('zh-CN')}</strong><small>积分</small></div>
+                <div>
+                  <span>{currentOrganization ? `${currentOrganization.name} · 组织可用积分` : '个人可用积分'}</span>
+                  <strong>{(displayBalance ?? currentOrganization?.pointsBalance ?? user.pointsBalance).toLocaleString('zh-CN')}</strong>
+                  <small>积分</small>
+                </div>
+              </div>
+              <div className="points-history-heading">
+                <h3>{currentOrganization ? `${currentOrganization.name} · 积分流水` : '积分流水'}</h3>
+                <span>共 {transactions.length} 笔</span>
               </div>
-              <div className="points-history-heading"><h3>消费记录</h3><span>共 {transactions.length} 笔</span></div>
               {pointsLoading ? (
                 <div className="points-empty"><LoaderCircle className="spin" size={22} />正在加载消费记录…</div>
               ) : transactions.length ? (
                 <div className="points-transaction-list">
                   {transactions.map((transaction) => (
-                    <Link to={`/courses/${transaction.courseId}`} className="points-transaction" key={transaction.id}>
+                    <div className="points-transaction" key={transaction.id}>
                       <div className="transaction-icon"><Coins size={17} /></div>
-                      <div className="transaction-main"><strong>{transaction.courseName}</strong><span>课程制作完成扣费</span></div>
+                      <div className="transaction-main"><strong>{transaction.courseName}</strong><span>{transaction.points < 0 ? currentOrganization ? '组织账户支付课程费用' : '个人账户支付课程费用' : '积分入账'}</span></div>
                       <div className="transaction-value"><strong>{transaction.points.toLocaleString('zh-CN')}</strong><time>{new Date(transaction.createdAt).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</time></div>
-                    </Link>
+                    </div>
                   ))}
                 </div>
               ) : (
-                <div className="points-empty"><Coins size={24} /><strong>暂无积分消费记录</strong><span>课程制作完成并交付后,消费明细会显示在这里</span></div>
+                <div className="points-empty"><Coins size={24} /><strong>暂无积分流水</strong><span>课程制作完成或积分入账后,记录会显示在这里</span></div>
               )}
             </section>
           )}

+ 135 - 81
src/pages/admin/AdminCourseDetail.tsx

@@ -27,21 +27,35 @@ import {
   Trash2,
   Upload,
   User,
-  Users
+  Users,
+  ZoomIn
 } from 'lucide-react'
 import { FormEvent, useEffect, useRef, useState } from 'react'
 import { Link, useParams } from 'react-router-dom'
-import { api } from '../../api'
+import { api, downloadPrivateFile } from '../../api'
+import { ProtectedImage } from '../../components/ProtectedImage'
+import { InstructorVoicePlayer } from '../../components/InstructorVoicePlayer'
 import { StatusBadge } from '../../components/StatusBadge'
 import { CourseProductionProgress } from '../../components/CourseProductionProgress'
 import type { Course, CourseStatus } from '../../types'
+import { notesHtml, notesText } from '../../utils/lectureNotes'
 
 const size = (n: number) =>
   n > 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + ' MB' : Math.ceil(n / 1024) + ' KB'
 
-export function AdminCourseDetail() {
+export function AdminCourseDetail({ backTo = '/admin/courses' }: { backTo?: string }) {
   const { id = '' } = useParams()
   const [course, setCourse] = useState<Course | null>(null)
+  const [previewImage, setPreviewImage] = useState<{ url: string; name: string } | null>(null)
+
+  useEffect(() => {
+    if (!previewImage) return
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') setPreviewImage(null)
+    }
+    window.addEventListener('keydown', handleKeyDown)
+    return () => window.removeEventListener('keydown', handleKeyDown)
+  }, [previewImage])
   const [loading, setLoading] = useState(true)
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState('')
@@ -202,7 +216,7 @@ export function AdminCourseDetail() {
   return (
     <div className="page admin-page">
       <div className="admin-breadcrumb-bar">
-        <Link to="/admin/courses" className="back-link">
+        <Link to={backTo} className="back-link">
           <ArrowLeft size={16} /> 返回课程制作列表
         </Link>
         <span className="breadcrumb-id">ID: {course.id}</span>
@@ -217,10 +231,6 @@ export function AdminCourseDetail() {
               <StatusBadge status={course.status} />
             </div>
             <div className="admin-meta-tags">
-              <span className="meta-tag">
-                <Tag size={13} />
-                分类:<strong>{course.category || '未分类'}</strong>
-              </span>
               <span className="meta-tag">
                 <Layers size={13} />
                 规模:<strong>共 {episodes.length} 集</strong>
@@ -311,9 +321,9 @@ export function AdminCourseDetail() {
                 <Building2 size={18} />
               </div>
               <div className="field-content">
-                <span className="field-label">单位 / 企业机构</span>
-                <strong className="field-value-text" title={creator?.organization || '个人创作者'}>
-                  {creator?.organization || '个人创作者'}
+                <span className="field-label">课程所属工作区</span>
+                <strong className="field-value-text" title={course.organizationName || '个人工作区'}>
+                  {course.organizationName || '个人工作区'}
                 </strong>
               </div>
             </div>
@@ -421,7 +431,7 @@ export function AdminCourseDetail() {
             </div>
           </div>
           <p className="points-card-note">
-            制作完成后系统将自动从用户账户中扣除【实际消耗积分】。管理员可根据实际制作工作量及复杂度调整最终扣除额度。
+            制作完成后系统将自动从课程所属工作区账户中扣除【实际消耗积分】。管理员可根据实际制作工作量及复杂度调整最终扣除额度。
           </p>
         </div>
 
@@ -515,15 +525,12 @@ export function AdminCourseDetail() {
                                 <small>{size(deliv.size)} · 成品文件</small>
                               </div>
                               <div className="deliv-actions">
-                                <a
-                                  href={deliv.url}
-                                  download={deliv.originalName}
+                                <button
+                                  onClick={() => downloadPrivateFile(deliv.url, deliv.originalName)}
                                   className="secondary-button small-button"
-                                  target="_blank"
-                                  rel="noreferrer"
                                 >
                                   <Download size={13} /> 下载
-                                </a>
+                                </button>
                                 <button
                                   type="button"
                                   className="icon-button delete-button"
@@ -557,15 +564,12 @@ export function AdminCourseDetail() {
                               <small>{size(deliv.size)} · 全套通用交付物</small>
                             </div>
                             <div className="deliv-actions">
-                              <a
-                                href={deliv.url}
-                                download={deliv.originalName}
+                              <button
+                                onClick={() => downloadPrivateFile(deliv.url, deliv.originalName)}
                                 className="secondary-button small-button"
-                                target="_blank"
-                                rel="noreferrer"
                               >
                                 <Download size={13} /> 下载
-                              </a>
+                              </button>
                               <button
                                 type="button"
                                 className="icon-button delete-button"
@@ -738,7 +742,7 @@ export function AdminCourseDetail() {
                         </div>
                         <div className="header-right">
                           <span className="text-pill">
-                            {ep.lectureNotes ? `${ep.lectureNotes.length} 字讲稿` : '未录入讲稿'}
+                            {ep.lectureNotes ? `${notesText(ep.lectureNotes).length} 字讲稿` : '未录入讲稿'}
                           </span>
                           <span className="text-pill">{epAssets.length} 份课件</span>
                           {isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
@@ -760,7 +764,7 @@ export function AdminCourseDetail() {
                                   className={`copy-notes-btn ${noteCopied ? 'copied' : ''}`}
                                   onClick={(e) => {
                                     e.stopPropagation()
-                                    handleCopyNotes(ep.id, ep.lectureNotes)
+                                    handleCopyNotes(ep.id, notesText(ep.lectureNotes))
                                   }}
                                   title="复制讲稿全文"
                                 >
@@ -777,7 +781,7 @@ export function AdminCourseDetail() {
                               )}
                             </div>
                             {ep.lectureNotes?.trim() ? (
-                              <pre className="notes-pre-content">{ep.lectureNotes}</pre>
+                              <div className="notes-pre-content" dangerouslySetInnerHTML={{ __html: notesHtml(ep.lectureNotes) }} />
                             ) : (
                               <p className="empty-sub-tip">该集用户未录入纯文本讲稿,请查阅下方附件文件。</p>
                             )}
@@ -795,15 +799,12 @@ export function AdminCourseDetail() {
                                       <strong title={asset.originalName}>{asset.originalName}</strong>
                                       <small>{size(asset.size)}</small>
                                     </div>
-                                    <a
-                                      href={asset.url}
-                                      download={asset.originalName}
+                                    <button
+                                      onClick={() => downloadPrivateFile(asset.url, asset.originalName)}
                                       className="secondary-button small-button download-mini-btn"
-                                      target="_blank"
-                                      rel="noreferrer"
                                     >
                                       <Download size={12} /> 下载
-                                    </a>
+                                    </button>
                                   </div>
                                 ))}
                               </div>
@@ -817,34 +818,44 @@ export function AdminCourseDetail() {
               </div>
             ) : null}
 
-            {/* 全局通用原始素材 */}
-            {globalAssets.length > 0 && (
+            {/* 全局通用原始素材与通用资产备注 */}
+            {(globalAssets.length > 0 || !!course.assetNotes?.trim()) && (
               <div className="admin-global-assets-section">
-                <h4 className="sub-section-title">全套课程通用素材 ({globalAssets.length})</h4>
-                <div className="asset-download-grid">
-                  {globalAssets.map((asset) => (
-                    <div className="asset-download-card" key={asset.id}>
-                      <FileText size={24} className="tone-primary-icon" />
-                      <div className="asset-download-info">
-                        <strong title={asset.originalName}>{asset.originalName}</strong>
-                        <small>{size(asset.size)} · 全套通用素材</small>
-                      </div>
-                      <a
-                        href={asset.url}
-                        download={asset.originalName}
-                        className="primary-button small-button"
-                        target="_blank"
-                        rel="noreferrer"
-                      >
-                        <Download size={14} /> 下载素材
-                      </a>
+                <h4 className="sub-section-title">通用资产 ({globalAssets.length})</h4>
+                {course.assetNotes && (
+                  <div className="admin-asset-notes-banner" style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '8px', padding: '12px 14px', marginBottom: '14px' }}>
+                    <div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#1e293b', fontWeight: 600, fontSize: '13px', marginBottom: '6px' }}>
+                      <FileText size={15} />
+                      <span>通用备注</span>
                     </div>
-                  ))}
-                </div>
+                    <p style={{ margin: 0, color: '#334155', fontSize: '13px', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
+                      {course.assetNotes}
+                    </p>
+                  </div>
+                )}
+                {globalAssets.length > 0 && (
+                  <div className="asset-download-grid">
+                    {globalAssets.map((asset) => (
+                      <div className="asset-download-card" key={asset.id}>
+                        <FileText size={24} className="tone-primary-icon" />
+                        <div className="asset-download-info">
+                          <strong title={asset.originalName}>{asset.originalName}</strong>
+                          <small>{size(asset.size)} · 通用素材</small>
+                        </div>
+                        <button
+                          onClick={() => downloadPrivateFile(asset.url, asset.originalName)}
+                          className="primary-button small-button"
+                        >
+                          <Download size={14} /> 下载素材
+                        </button>
+                      </div>
+                    ))}
+                  </div>
+                )}
               </div>
             )}
 
-            {!episodes.length && !globalAssets.length && (
+            {!episodes.length && !globalAssets.length && !course.assetNotes && (
               <div className="empty-sub-tip">用户尚未录入分集或上传任何素材文件。</div>
             )}
           </section>
@@ -867,7 +878,7 @@ export function AdminCourseDetail() {
                   {creator?.contactName || '未填写联系人'}
                 </strong>
                 <span className="sidebar-user-org">
-                  {creator?.organization || '个人创作者'}
+                  {course.organizationName || '个人工作区'}
                 </span>
                 <span className="sidebar-user-role">
                   {creator?.role === 'ADMIN' ? '平台管理员' : '普通学员 / 创作者'}
@@ -896,8 +907,8 @@ export function AdminCourseDetail() {
                 <strong>{creator?.wechat || '未填写'}</strong>
               </div>
               <div className="contact-item">
-                <span className="contact-label">所属单位:</span>
-                <strong>{creator?.organization || '未填写'}</strong>
+                <span className="contact-label">课程工作区:</span>
+                <strong>{course.organizationName || '个人工作区'}</strong>
               </div>
               {creator?.bio && (
                 <div className="contact-item flex-col">
@@ -953,7 +964,7 @@ export function AdminCourseDetail() {
                     <div className="outline-info">
                       <strong>{ep.title}</strong>
                       <small>
-                        {ep.lectureNotes ? `${ep.lectureNotes.length} 字讲稿` : '无文本讲稿'} ·{' '}
+                        {ep.lectureNotes ? `${notesText(ep.lectureNotes).length} 字讲稿` : '无文本讲稿'} ·{' '}
                         {course.assets?.filter((a) => a.episodeId === ep.id).length || 0} 份附件
                       </small>
                     </div>
@@ -971,29 +982,48 @@ export function AdminCourseDetail() {
 
             {course.instructors && course.instructors.length > 0 ? (
               <div className="instructor-sidebar-list">
-                {course.instructors.map((inst) => (
-                  <div className="inst-card-detail" key={inst.id}>
-                    {inst.imageUrls?.length ? (
-                      <div className="admin-instructor-photos">
-                        {inst.imageUrls.map((url, index) => (
-                          <img
-                            src={url}
-                            alt={`${inst.name} 出镜照 ${index + 1}`}
-                            className="inst-photo"
-                            key={url}
-                          />
-                        ))}
+                {course.instructors.map((inst) => {
+                  const note = (inst.note || inst.introduction || '').trim()
+                  const audioUrl = inst.audioUrls?.[0] || course.digitalInstructorSnapshot?.audio?.[0]?.url
+                  return (
+                    <div className="inst-card-detail" key={inst.id}>
+                      {inst.imageUrls?.length ? (
+                        <div className="admin-instructor-photos">
+                          {inst.imageUrls.map((url, index) => (
+                            <button
+                              key={url}
+                              type="button"
+                              className="instructor-photo-thumb-btn"
+                              onClick={() => setPreviewImage({ url, name: `${inst.name} 照片 ${index + 1}` })}
+                              title="点击放大查看照片"
+                              aria-label={`点击放大查看 ${inst.name} 照片 ${index + 1}`}
+                            >
+                              <ProtectedImage
+                                src={url}
+                                alt={`${inst.name} 出镜照 ${index + 1}`}
+                                className="inst-photo"
+                              />
+                              <span className="photo-zoom-hint">
+                                <ZoomIn size={12} />
+                              </span>
+                            </button>
+                          ))}
+                        </div>
+                      ) : (
+                        <div className="avatar-placeholder">{inst.name[0]}</div>
+                      )}
+                      <div className="inst-info">
+                        <div className="instructor-name-row">
+                          <strong className="instructor-name">{inst.name}</strong>
+                          {audioUrl && (
+                            <InstructorVoicePlayer src={audioUrl} title={`${inst.name} 录音试听`} />
+                          )}
+                        </div>
+                        {note ? <p className="instructor-note">{note}</p> : null}
                       </div>
-                    ) : (
-                      <div className="avatar-placeholder">{inst.name[0]}</div>
-                    )}
-                    <div className="inst-info">
-                      <strong>{inst.name}</strong>
-                      <small>{inst.organization || '未提供单位'}</small>
-                      <p>{inst.introduction || '暂无详细介绍'}</p>
                     </div>
-                  </div>
-                ))}
+                  )
+                })}
               </div>
             ) : (
               <p className="empty-sub-tip">未提供讲师图文信息。</p>
@@ -1001,6 +1031,30 @@ export function AdminCourseDetail() {
           </section>
         </div>
       </div>
+
+      {/* 照片放大预览弹窗 */}
+      {previewImage && (
+        <div className="digital-photo-lightbox" role="presentation" onClick={() => setPreviewImage(null)}>
+          <div
+            className="digital-photo-lightbox-content"
+            role="dialog"
+            aria-modal="true"
+            aria-label={`查看照片:${previewImage.name}`}
+            onClick={(e) => e.stopPropagation()}
+          >
+            <button
+              type="button"
+              className="digital-photo-lightbox-close"
+              onClick={() => setPreviewImage(null)}
+              aria-label="关闭照片预览"
+            >
+              ×
+            </button>
+            <ProtectedImage src={previewImage.url} alt={previewImage.name} />
+            <p>{previewImage.name}</p>
+          </div>
+        </div>
+      )}
     </div>
   )
 }

+ 17 - 18
src/pages/admin/AdminCourses.tsx

@@ -1,5 +1,4 @@
 import {
-  ArrowRight,
   Building2,
   Clock3,
   FileText,
@@ -11,12 +10,11 @@ import {
   Search,
   User,
   Users,
-  Wrench
 } from 'lucide-react'
 import { useEffect, useState } from 'react'
-import { Link } from 'react-router-dom'
 import { api } from '../../api'
 import { StatusBadge } from '../../components/StatusBadge'
+import { formatDate, formatDateTime } from '../../utils/date'
 import type { Course, CourseStatus } from '../../types'
 
 type StatusFilter = 'ALL' | CourseStatus
@@ -50,8 +48,8 @@ export function AdminCourses() {
     <div className="page admin-page">
       <div className="admin-header-row">
         <div>
-          <h2>课程制作管理</h2>
-          <p className="admin-subtitle">管理全平台用户创建并提交的课程,跟踪制作排期与交付进度</p>
+          <h2>全部课程</h2>
+          <p className="admin-subtitle">查看全平台课程及其当前状态</p>
         </div>
         <button className="secondary-button" onClick={loadCourses} title="刷新列表">
           <RotateCw size={15} /> 刷新
@@ -101,7 +99,7 @@ export function AdminCourses() {
             <input
               value={keyword}
               onChange={(e) => setKeyword(e.target.value)}
-              placeholder="搜索课程名、用户手机、姓名或单位…"
+              placeholder="搜索课程名、用户手机或姓名…"
             />
           </div>
           <button type="submit" className="secondary-button">
@@ -125,12 +123,11 @@ export function AdminCourses() {
             <thead>
               <tr>
                 <th>课程名称与大纲</th>
-                <th>课程分类</th>
                 <th style={{ minWidth: 200 }}>提交制作用户信息</th>
                 <th>素材 / 讲师 / 交付</th>
                 <th>当前状态</th>
+                <th>创建 / 更新时间</th>
                 <th>提交 / 交付时间</th>
-                <th style={{ textAlign: 'right' }}>操作</th>
               </tr>
             </thead>
             <tbody>
@@ -149,9 +146,6 @@ export function AdminCourses() {
                       </div>
                     </div>
                   </td>
-                  <td>
-                    <span className="category-pill">{course.category || '未分类'}</span>
-                  </td>
                   <td>
                     {/* 醒目的提交用户信息单元格 */}
                     <div className="user-highlight-cell">
@@ -162,8 +156,8 @@ export function AdminCourses() {
                         <strong className="user-contact-name">
                           {course.creator?.contactName || '未填写联系人'}
                         </strong>
-                        <span className="user-org-text" title={course.creator?.organization || '个人创作者'}>
-                          <Building2 size={12} /> {course.creator?.organization || '个人创作者'}
+                        <span className="user-org-text" title={course.organizationName || '个人工作区'}>
+                          <Building2 size={12} /> {course.organizationName || '个人工作区'}
                         </span>
                         <span className="user-phone-text">
                           <Phone size={12} /> {course.creator?.phone || '无手机号'}
@@ -189,6 +183,16 @@ export function AdminCourses() {
                   <td>
                     <StatusBadge status={course.status} />
                   </td>
+                  <td>
+                    <div className="time-cell">
+                      <small title={`创建时间:${formatDateTime(course.createdAt)}`}>
+                        建: {formatDate(course.createdAt)}
+                      </small>
+                      <small title={`最后更新时间:${formatDateTime(course.updatedAt || course.createdAt)}`} style={{ color: '#0f172a', fontWeight: 500 }}>
+                        更: {formatDateTime(course.updatedAt || course.createdAt)}
+                      </small>
+                    </div>
+                  </td>
                   <td>
                     <div className="time-cell">
                       <small title="提交时间">
@@ -201,11 +205,6 @@ export function AdminCourses() {
                       )}
                     </div>
                   </td>
-                  <td style={{ textAlign: 'right' }}>
-                    <Link to={`/admin/courses/${course.id}`} className="primary-button small-button">
-                      <Wrench size={13} /> 制作管理
-                    </Link>
-                  </td>
                 </tr>
               ))}
             </tbody>

+ 5 - 9
src/pages/admin/AdminDashboard.tsx

@@ -46,7 +46,7 @@ export function AdminDashboard() {
           <p className="admin-subtitle">监控平台用户动态与课程制作全周期流转状态</p>
         </div>
         <div className="admin-header-actions">
-          <Link to="/admin/courses" className="primary-button">
+          <Link to="/admin/production-queue" className="primary-button">
             <Wrench size={16} />
             <span>进入制作工作台</span>
           </Link>
@@ -114,14 +114,14 @@ export function AdminDashboard() {
           <div>
             <h3>待制作与进行中课程</h3>
           </div>
-          <Link to="/admin/courses" className="text-link">
+          <Link to="/admin/production-queue" className="text-link">
             查看全部课程 <ArrowRight size={14} />
           </Link>
         </div>
 
         {pendingCourses.length === 0 ? (
           <div className="empty-state compact-empty">
-            <Sparkles size={32} style={{ color: '#245bd6', marginBottom: 8 }} />
+            <Sparkles size={32} style={{ color: '#0875bd', marginBottom: 8 }} />
             <h3>当前没有待处理的制作任务</h3>
             <p>当用户提交新课程后,系统会自动汇入此制作队列中。</p>
           </div>
@@ -131,7 +131,6 @@ export function AdminDashboard() {
               <thead>
                 <tr>
                   <th>课程名称</th>
-                  <th>课程分类</th>
                   <th>提交用户</th>
                   <th>素材与讲师</th>
                   <th>状态</th>
@@ -148,9 +147,6 @@ export function AdminDashboard() {
                         {course.description && <small>{course.description}</small>}
                       </div>
                     </td>
-                    <td>
-                      <span>{course.category || '未分类'}</span>
-                    </td>
                     <td>
                       <strong>{course.creator?.contactName || '未填姓名'}</strong>
                       <small className="sub-text">{course.creator?.phone}</small>
@@ -179,8 +175,8 @@ export function AdminDashboard() {
                       </small>
                     </td>
                     <td style={{ textAlign: 'right' }}>
-                      <Link to={`/admin/courses/${course.id}`} className="primary-button small-button">
-                        进入制作 <ArrowRight size={13} />
+                      <Link to="/admin/production-queue" className="secondary-button small-button">
+                        查看待制作队列 <ArrowRight size={13} />
                       </Link>
                     </td>
                   </tr>

+ 50 - 0
src/pages/admin/AdminDigitalInstructors.tsx

@@ -0,0 +1,50 @@
+import { useEffect, useState } from 'react'
+import { Link, useLocation, useSearchParams } from 'react-router-dom'
+import { api } from '../../api'
+import { ProtectedImage } from '../../components/ProtectedImage'
+import type { DigitalInstructor } from '../../types'
+
+type Org = { id: string; name: string }
+export function AdminDigitalInstructors() {
+  const [searchParams, setSearchParams] = useSearchParams()
+  const location = useLocation()
+  const orgId = searchParams.get('organizationId') || ''
+  const [orgs, setOrgs] = useState<Org[]>([])
+  const [items, setItems] = useState<DigitalInstructor[]>([])
+  const [error, setError] = useState('')
+  const [notice, setNotice] = useState((location.state as { notice?: string } | null)?.notice || '')
+  const [busy, setBusy] = useState(false)
+  const [deleteTarget, setDeleteTarget] = useState<DigitalInstructor | null>(null)
+  useEffect(() => { api.admin.organizations().then(result => setOrgs(result.organizations)).catch(cause => setError((cause as Error).message)) }, [])
+  async function load(id: string) {
+    if (!id) { setItems([]); return }
+    try { setItems((await api.digitalInstructors.list(id)).instructors) }
+    catch (cause) { setError((cause as Error).message) }
+  }
+  useEffect(() => { load(orgId) }, [orgId])
+  async function duplicate(item: DigitalInstructor) {
+    setBusy(true); setError('')
+    try { const result = await api.digitalInstructors.duplicate(item.id,orgId); setNotice(`已复制为「${result.instructor.name}」`); await load(orgId) }
+    catch (cause) { setError((cause as Error).message) }
+    finally { setBusy(false) }
+  }
+  async function remove() {
+    if (!deleteTarget) return
+    setBusy(true); setError('')
+    try { await api.digitalInstructors.delete(deleteTarget.id,orgId); setNotice('数字讲师已删除'); setDeleteTarget(null); await load(orgId) }
+    catch (cause) { setError((cause as Error).message); setDeleteTarget(null) }
+    finally { setBusy(false) }
+  }
+  return <div className="page digital-library-page"><div className="digital-library-heading"><div><h1>组织数字讲师</h1><p>查看并维护组织的数字讲师资产</p></div></div>
+    <div className="digital-library-toolbar"><select aria-label="选择组织" value={orgId} onChange={event => setSearchParams(event.target.value ? { organizationId: event.target.value } : {})}><option value="">选择组织</option>{orgs.map(org => <option key={org.id} value={org.id}>{org.name}</option>)}</select></div>
+    {error && <div className="notice notice-error" role="alert">{error}</div>}{notice && <div className="notice notice-success">{notice}</div>}
+    {orgId && !items.length && <div className="digital-library-empty">该组织还没有数字讲师。</div>}
+    <div className="digital-library-grid">{items.map(item => <article className="digital-library-card digital-asset-card" key={item.id}>
+      <div className="digital-asset-photo">{item.images[0] ? <ProtectedImage src={item.images[0].url} alt={item.name} /> : <span>暂无照片</span>}</div>
+      <div className="digital-asset-body"><div className="digital-asset-title"><h2>{item.name}</h2><span className={item.ready ? 'digital-state-ready' : 'digital-state-incomplete'}>{item.ready ? '素材齐备' : '待补齐'}</span></div><p className="digital-asset-note">{item.note || '暂无备注'}</p><p className="digital-asset-meta">所有者:{item.ownerName} · 创建者:{item.creatorName}</p><p className="digital-asset-meta">创建于 {new Date(item.createdAt).toLocaleString('zh-CN')}</p>{item.usageCount > 0 && <p className="digital-asset-meta">用于 {item.usageCount} 门课程,暂不能删除</p>}
+        <div className="digital-asset-actions"><Link className="secondary-button" to={`/admin/digital-instructors/${item.id}/edit?organizationId=${encodeURIComponent(orgId)}`}>编辑</Link><details className="digital-more-menu"><summary className="secondary-button">更多操作</summary><div><button type="button" onClick={() => duplicate(item)} disabled={busy}>复制讲师</button><button type="button" className="digital-danger-action" disabled={busy || item.usageCount > 0} onClick={() => setDeleteTarget(item)}>{item.usageCount > 0 ? '已有课程使用,不能删除' : '删除讲师'}</button></div></details></div>
+      </div>
+    </article>)}</div>
+    {deleteTarget && <div className="digital-confirm-backdrop" role="presentation"><div className="digital-confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="admin-digital-delete-title"><h2 id="admin-digital-delete-title">删除数字讲师?</h2><p>确定删除「{deleteTarget.name}」吗?</p><div><button type="button" className="secondary-button" onClick={() => setDeleteTarget(null)} disabled={busy}>取消</button><button type="button" className="primary-button digital-danger-button" onClick={remove} disabled={busy}>确认删除</button></div></div></div>}
+  </div>
+}

+ 7 - 1
src/pages/admin/AdminLayout.tsx

@@ -1,5 +1,6 @@
 import {
   BookOpen,
+  Clock3,
   LayoutDashboard,
   LogOut,
   ShieldCheck,
@@ -46,12 +47,17 @@ export function AdminLayout() {
           </NavLink>
           <NavLink to="/admin/courses" className={({ isActive }) => (isActive ? 'active' : '')}>
             <BookOpen size={16} />
-            <span>课程制作</span>
+            <span>全部课程</span>
+          </NavLink>
+          <NavLink to="/admin/production-queue" className={({ isActive }) => (isActive ? 'active' : '')}>
+            <Clock3 size={16} /><span>待制作课程</span>
           </NavLink>
           <NavLink to="/admin/users" className={({ isActive }) => (isActive ? 'active' : '')}>
             <Users size={16} />
             <span>用户管理</span>
           </NavLink>
+          <NavLink to="/admin/organizations" className={({ isActive }) => (isActive ? 'active' : '')}>组织管理</NavLink>
+          <NavLink to="/admin/digital-instructors" className={({ isActive }) => (isActive ? 'active' : '')}>数字讲师</NavLink>
         </nav>
 
         <div className="admin-top-actions">

+ 122 - 0
src/pages/admin/AdminOrganizations.tsx

@@ -0,0 +1,122 @@
+import { useEffect, useState, type FormEvent } from 'react'
+import { Link, useParams } from 'react-router-dom'
+import { api } from '../../api'
+import { formatDateTime } from '../../utils/date'
+
+export function AdminOrganizations() {
+  const [organizations, setOrganizations] = useState<any[]>([])
+  const [error, setError] = useState('')
+  const [message, setMessage] = useState('')
+  async function load() { try { setOrganizations((await api.admin.organizations()).organizations) } catch (e) { setError((e as Error).message) } }
+  useEffect(() => { load() }, [])
+  async function create(e: FormEvent<HTMLFormElement>) {
+    e.preventDefault(); const form = new FormData(e.currentTarget)
+    try { await api.admin.createOrganization(String(form.get('name')), String(form.get('slug'))); setMessage('组织已创建'); (e.currentTarget as HTMLFormElement).reset(); await load() }
+    catch (x) { setError((x as Error).message) }
+  }
+  return <div className="page admin-page"><div className="admin-header-row"><div><h2>组织管理</h2><p>创建组织并管理成员与积分</p></div></div>
+    {error && <div className="notice notice-error">{error}</div>}{message && <div className="notice notice-success">{message}</div>}
+    <section className="admin-section-block"><h3>创建组织</h3><form onSubmit={create} className="org-inline-form">
+      <label>组织名称<input name="name" required maxLength={120} placeholder="杭州市计算机学校" /></label>
+      <label>网址标识<input name="slug" required pattern="[a-z0-9-]{3,80}" placeholder="hangzhou-computer-school" /></label>
+      <button className="primary-button">创建组织</button></form></section>
+    <section className="admin-section-block"><h3>全部组织</h3><div className="admin-table-wrapper"><table className="admin-table"><thead><tr><th>名称</th><th>状态</th><th>成员</th><th>积分余额</th><th>操作</th></tr></thead><tbody>
+      {organizations.map((o) => <tr key={o.id}><td>{o.name}</td><td>{o.is_active ? '正常' : '已停用'}</td><td>{o.members_count}</td><td>{o.points_balance}</td><td><Link className="secondary-button small-button" to={`/admin/organizations/${o.id}`}>管理</Link></td></tr>)}
+    </tbody></table></div></section></div>
+}
+
+export function AdminOrganizationDetail() {
+  const { id = '' } = useParams()
+  const [data, setData] = useState<any>(null)
+  const [users, setUsers] = useState<any[]>([])
+  const [error, setError] = useState('')
+  const [message, setMessage] = useState('')
+  const [showAudit, setShowAudit] = useState(false)
+  async function load() { try { setData(await api.admin.organization(id)) } catch (x) { setError((x as Error).message) } }
+  useEffect(() => { load() }, [id])
+  async function setMember(e: FormEvent<HTMLFormElement>) {
+    e.preventDefault(); const f = new FormData(e.currentTarget)
+    const phone = String(f.get('phone'))
+    try {
+      const person = (await api.admin.listUsers(phone)).users.find((u) => u.phone === phone)
+      if (!person) return setError('未找到该手机号对应的用户')
+      await api.admin.setOrganizationMember(id, person.id, String(f.get('role')) as 'MEMBER' | 'MANAGER')
+      setMessage('成员关系已更新'); await load()
+    }
+    catch (x) { setError((x as Error).message) }
+  }
+  async function search(phone: string) { try { setUsers((await api.admin.listUsers(phone)).users) } catch (x) { setError((x as Error).message) } }
+  async function review(applicationId: string, approve: boolean) { try { await api.admin.reviewOrganizationApplication(id, applicationId, approve); await load() } catch (x) { setError((x as Error).message) } }
+  async function points(e: FormEvent<HTMLFormElement>) {
+    e.preventDefault(); const f = new FormData(e.currentTarget)
+    try { await api.admin.adjustOrganizationPoints(id, Number(f.get('amount')), String(f.get('reason'))); setMessage('积分已调整'); await load() }
+    catch (x) { setError((x as Error).message) }
+  }
+  async function rename(e: FormEvent<HTMLFormElement>) { e.preventDefault(); const f = new FormData(e.currentTarget)
+    try { await api.admin.renameOrganization(id, String(f.get('name'))); setMessage('组织名称已更新'); await load() } catch (x) { setError((x as Error).message) } }
+  async function updateSlug(e: FormEvent<HTMLFormElement>) { e.preventDefault(); const f = new FormData(e.currentTarget)
+    try { await api.admin.updateOrganizationSlug(id, String(f.get('slug'))); setMessage('公开页面访问后缀已更新'); await load() } catch (x) { setError((x as Error).message) } }
+  if (!data) return <div className="page admin-page">{error || '正在加载组织…'}</div>
+  return <div className="page admin-page"><Link className="back-link" to="/admin/organizations">← 全部组织</Link><h2>{data.organization.name} · {data.organization.is_active ? '正常' : '已停用'}</h2>
+    {error && <div className="notice notice-error">{error}</div>}{message && <div className="notice notice-success">{message}</div>}
+    <section className="admin-section-block"><h3>组织资料</h3>
+      <form className="org-inline-form" onSubmit={rename}><label>组织名称<input name="name" defaultValue={data.organization.name} required maxLength={120} /></label><button className="secondary-button">保存名称</button></form>
+      <form className="org-inline-form" onSubmit={updateSlug}><label>URL 访问后缀<input key={data.organization.slug} name="slug" defaultValue={data.organization.slug} required pattern="[a-z0-9-]{3,80}" title="3-80位小写字母、数字或连字符(-)" placeholder="pure-cosmos" /></label><button className="secondary-button">保存后缀</button></form>
+      <p>公开页面:<a href={`/organizations/${data.organization.slug}`} target="_blank" rel="noreferrer">{window.location.origin}/organizations/{data.organization.slug}</a></p>
+      <button className="secondary-button" onClick={async () => {
+        if (!confirm(data.organization.is_active ? '停用后,新用户不能加入且不能新建组织课程,已有记录保留。继续吗?' : '恢复该组织的新加入和课程创建?')) return
+        try { await api.admin.setOrganizationActive(id, !data.organization.is_active); await load() } catch (x) { setError((x as Error).message) }
+      }}>{data.organization.is_active ? '停用组织' : '恢复组织'}</button></section>
+    <section className="admin-section-block"><h3>组织积分账户 · {data.organization.points_balance} 积分</h3><form className="org-inline-form" onSubmit={points}>
+      <label>积分变化(扣减填负数)<input name="amount" type="number" required step="1" /></label><label>原因<input name="reason" required maxLength={200} /></label><button className="primary-button">调整积分</button></form>
+      {data.ledger.map((l: any) => <div className="org-list-row" key={l.id}><span>{l.reason} · {l.amount > 0 ? '+' : ''}{l.amount}</span><time>{new Date(l.created_at).toLocaleString('zh-CN')}</time></div>)}
+    </section>
+    <section className="admin-section-block"><h3>加入申请</h3>{data.applications.filter((a: any) => a.status === 'PENDING').map((a: any) => <div className="org-list-row" key={a.id}><span>{a.contact_name || a.phone} · {a.phone}</span><div><button className="primary-button small-button" onClick={() => review(a.id, true)}>同意</button><button className="secondary-button small-button" onClick={() => review(a.id, false)}>拒绝</button></div></div>)}</section>
+    <section className="admin-section-block"><h3>组织课程</h3>{data.courses.map((course: any) => <div className="org-list-row" key={course.id}>
+      <span style={{ display: 'inline-flex', flexDirection: 'column', gap: '2px' }}>
+        <strong>{course.name} · {course.creator_name || '未填姓名'} · {course.status}</strong>
+        <small style={{ color: '#64748b' }}>创建于 {formatDateTime(course.created_at)} · 更新于 {formatDateTime(course.updated_at || course.created_at)}</small>
+      </span>
+      <Link className="secondary-button small-button" to="/admin/courses">查看全部课程</Link></div>)}</section>
+    <section className="admin-section-block"><h3>成员与管理员</h3><form className="org-inline-form" onSubmit={setMember}>
+      <label>用户手机号<input name="phone" required pattern="1[0-9]{10}" onChange={(e) => e.target.value.length === 11 && search(e.target.value)} /></label>
+      <label>组织身份<select name="role"><option value="MEMBER">普通成员</option><option value="MANAGER">组织管理员</option></select></label><button className="primary-button">添加或更新</button></form>
+      {users.length > 0 && <p>匹配用户:{users.map((u) => `${u.contactName || '未填姓名'} ${u.phone}`).join('、')}</p>}
+      {data.members.map((m: any) => <div className="org-list-row" key={m.id}><span>{m.contact_name || '未填姓名'} · {m.phone} · {m.role === 'MANAGER' ? '组织管理员' : '成员'}</span><div>
+        <button className="secondary-button small-button" onClick={async () => { await api.admin.setOrganizationMember(id, m.id, m.role === 'MANAGER' ? 'MEMBER' : 'MANAGER'); await load() }}>{m.role === 'MANAGER' ? '撤销管理员' : '设为管理员'}</button>
+        <button className="secondary-button small-button" onClick={async () => { if (!confirm(`移除 ${m.contact_name || m.phone} 的组织成员身份?`)) return; await api.admin.removeOrganizationMember(id, m.id); await load() }}>移出组织</button></div></div>)}
+    </section>
+    <section className="admin-section-block">
+      <div
+        style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', userSelect: 'none' }}
+        onClick={() => setShowAudit((prev) => !prev)}
+      >
+        <h3 style={{ margin: 0 }}>组织操作日志</h3>
+        <button
+          type="button"
+          className="secondary-button small-button"
+          onClick={(e) => {
+            e.stopPropagation()
+            setShowAudit((prev) => !prev)
+          }}
+        >
+          {showAudit ? '收起日志' : '展开日志'}
+        </button>
+      </div>
+      {showAudit && (
+        <div style={{ marginTop: '16px' }}>
+          {data.audit.length === 0 ? (
+            <p style={{ color: '#64748b', marginTop: '12px' }}>暂无操作日志</p>
+          ) : (
+            data.audit.map((a: any) => (
+              <details className="org-audit-event" key={a.id}>
+                <summary>{a.actor_name || '系统'} · {a.action} · {new Date(a.created_at).toLocaleString('zh-CN')}</summary>
+                <pre>{JSON.stringify(a.details, null, 2)}</pre>
+              </details>
+            ))
+          )}
+        </div>
+      )}
+    </section>
+  </div>
+}

+ 105 - 0
src/pages/admin/AdminProductionQueue.tsx

@@ -0,0 +1,105 @@
+import { Clock3, Download, FileText, RefreshCw, Search, UserRound } from 'lucide-react'
+import { useEffect, useState } from 'react'
+import { api, downloadPrivateFile } from '../../api'
+import { ProtectedImage } from '../../components/ProtectedImage'
+import { formatDateTime } from '../../utils/date'
+import { notesHtml, notesText } from '../../utils/lectureNotes'
+import type { Course } from '../../types'
+
+export function AdminProductionQueue() {
+  const [courses, setCourses] = useState<Course[]>([])
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState('')
+  const [keyword, setKeyword] = useState('')
+  const [query, setQuery] = useState('')
+  const [status, setStatus] = useState<'WAITING_PRODUCTION' | 'IN_PRODUCTION'>('WAITING_PRODUCTION')
+  const [busyId, setBusyId] = useState('')
+  const [notice, setNotice] = useState('')
+  const [files, setFiles] = useState<Record<string, File[]>>({})
+  const [notes, setNotes] = useState<Record<string, string>>({})
+  const [points, setPoints] = useState<Record<string, string>>({})
+
+  useEffect(() => {
+    let active = true
+    setLoading(true)
+    setError('')
+    api.admin.listCourses({ status, keyword: query })
+      .then(({ courses }) => { if (active) setCourses(courses) })
+      .catch((err) => { if (active) setError(err.message) })
+      .finally(() => { if (active) setLoading(false) })
+    return () => { active = false }
+  }, [query, status])
+
+  const refresh = () => {
+    setLoading(true)
+    setError('')
+    api.admin.listCourses({ status, keyword: query })
+      .then(({ courses }) => setCourses(courses))
+      .catch((err) => setError(err.message))
+      .finally(() => setLoading(false))
+  }
+
+  const run = async (id: string, action: () => Promise<unknown>, message: string) => {
+    setBusyId(id)
+    setError('')
+    setNotice('')
+    try { await action(); setNotice(message); refresh() }
+    catch (err) { setError((err as Error).message) }
+    finally { setBusyId('') }
+  }
+
+  return <div className="page admin-page production-queue-page">
+    <div className="admin-header-row">
+      <div><h2>待制作课程</h2><p className="admin-subtitle">团队制作准备清单 · 查看已提交课程的资料、素材和数字讲师</p></div>
+      <button type="button" className="secondary-button" onClick={refresh}><RefreshCw size={15} /> 刷新队列</button>
+    </div>
+    <div className="production-queue-toolbar">
+      <div className="production-queue-tabs"><button type="button" className={status === 'WAITING_PRODUCTION' ? 'active' : ''} onClick={() => setStatus('WAITING_PRODUCTION')}>待制作</button><button type="button" className={status === 'IN_PRODUCTION' ? 'active' : ''} onClick={() => setStatus('IN_PRODUCTION')}>制作中</button><strong><Clock3 size={17} /> {loading ? '…' : courses.length} 门</strong></div>
+      <form onSubmit={(event) => { event.preventDefault(); setQuery(keyword.trim()) }}>
+        <Search size={16} /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索课程、分类或提交人" aria-label="搜索待制作课程" />
+        <button type="submit" className="secondary-button">搜索</button>
+      </form>
+    </div>
+    {error && <div className="notice notice-error" role="alert">{error}</div>}
+    {notice && <div className="notice notice-success" role="status">{notice}</div>}
+    {loading ? <div className="center-loading">正在读取待制作课程…</div> : !error && courses.length === 0 ?
+      <div className="empty-state"><Clock3 size={32} /><h3>{query ? '没有匹配的课程' : status === 'WAITING_PRODUCTION' ? '当前没有等待制作的课程' : '当前没有制作中的课程'}</h3><p>{query ? '可以调整搜索词或清空搜索。' : '课程状态变化后,这里的列表会随之更新。'}</p></div> :
+      <div className="production-queue-list">{courses.map((course) => {
+        const instructor = course.digitalInstructorSnapshot
+        const notesCount = course.episodes.filter((episode) => notesText(episode.lectureNotes || '').trim()).length
+        return <article className="production-queue-card" key={course.id}>
+          <div className="production-queue-card-head">
+            <div><span className="production-queue-index">等待制作</span><h3>{course.name}</h3><p>{course.description || '未填写课程简介'}</p></div>
+          </div>
+          <div className="production-queue-meta">
+            <span><UserRound size={14} /> {course.creator?.contactName || '未知提交人'} · {course.creator?.phone || '无联系电话'} · {course.organizationName || '个人工作区'}</span>
+            <span><Clock3 size={14} /> 提交于 {formatDateTime(course.submittedAt || course.createdAt)}</span>
+            <span><FileText size={14} /> {course.episodes.length} 集 · {notesCount} 份讲稿 · {course.assets.length} 份课程素材</span>
+          </div>
+          <div className="production-queue-grid">
+            <section><h4>课程资料与分集</h4>{course.episodes.length ? <ul>{course.episodes.map((episode) => <li key={episode.id}><strong>第 {episode.episodeNumber} 集 · {episode.title}</strong><span>{episode.summary || '未填写分集简介'} · {notesText(episode.lectureNotes || '').trim() ? `${notesText(episode.lectureNotes).length} 字讲稿` : '暂无讲稿'}</span></li>)}</ul> : <p>暂无分集资料</p>}</section>
+            <section><h4>上传素材({course.assets.length})</h4>{course.assets.length ? <ul>{course.assets.map((asset) => <li key={asset.id}><span>{asset.originalName}</span><button type="button" className="production-file-action" onClick={() => downloadPrivateFile(asset.url, asset.originalName)} aria-label={`下载 ${asset.originalName}`}><Download size={14} /> 下载</button></li>)}</ul> : <p>暂无上传素材</p>}</section>
+            <section><h4>数字讲师</h4>{instructor ? <div className="production-instructor"><div className="production-instructor-photos">{instructor.images?.map((photo) => <ProtectedImage key={photo.id} src={photo.url} alt={`${instructor.name} 照片`} />)}</div><strong>{instructor.name}</strong><p>{instructor.note || '无备注'}</p><small>照片 {instructor.images?.length || 0} 张 · 录音 {instructor.audio?.length || 0} 份</small>{instructor.audio?.map((audio) => <button key={audio.id} type="button" className="production-file-action" onClick={() => downloadPrivateFile(audio.url, audio.name)}><Download size={14} /> {audio.name}</button>)}</div> : <p>未保存数字讲师快照,请进入详情核对历史讲师资料。</p>}</section>
+          </div>
+          <details className="production-queue-more"><summary>查看完整制作资料</summary>
+            <div className="production-queue-more-content">
+              <div><h4>提交人资料</h4><p>姓名:{course.creator?.contactName || '未填写'}</p><p>电话:{course.creator?.phone || '未填写'}</p><p>微信:{course.creator?.wechat || '未填写'}</p></div>
+              <div><h4>分集讲稿</h4>{course.episodes.map((episode) => <section key={episode.id}><strong>第 {episode.episodeNumber} 集 · {episode.title}</strong>{episode.lectureNotes ? <div dangerouslySetInnerHTML={{ __html: notesHtml(episode.lectureNotes) }} /> : <p>未填写讲稿</p>}</section>)}</div>
+            </div>
+          </details>
+          <div className="production-queue-actions">
+            {status === 'WAITING_PRODUCTION' ? <button type="button" className="primary-button" disabled={busyId === course.id} onClick={() => run(course.id, () => api.admin.updateStatus(course.id, 'IN_PRODUCTION'), '已接单,课程进入制作中')}>接单并开始制作</button> : <>
+              <label>上传交付文件<input type="file" multiple onChange={(event) => setFiles((current) => ({ ...current, [course.id]: Array.from(event.target.files || []) }))} /></label>
+              <button type="button" className="secondary-button" disabled={busyId === course.id || !(files[course.id]?.length)} onClick={() => run(course.id, () => api.admin.uploadDeliverables(course.id, files[course.id]), '交付文件已上传')}>上传所选文件</button>
+              {!!course.deliverables?.length && <div className="production-deliverables">{course.deliverables.map((file) => <button key={file.id} type="button" className="production-file-action" onClick={() => downloadPrivateFile(file.url, file.originalName)}><Download size={14} /> {file.originalName}</button>)}</div>}
+              <label>实际积分<input type="number" min="1" max="100000" value={points[course.id] ?? String(course.actualPoints ?? course.estimatedPoints)} onChange={(event) => setPoints((current) => ({ ...current, [course.id]: event.target.value }))} /></label>
+              <button type="button" className="secondary-button" disabled={busyId === course.id || !Number.isInteger(Number(points[course.id] ?? course.actualPoints ?? course.estimatedPoints)) || Number(points[course.id] ?? course.actualPoints ?? course.estimatedPoints) < 1} onClick={() => run(course.id, () => api.admin.updatePoints(course.id, Number(points[course.id] ?? course.actualPoints ?? course.estimatedPoints), '制作团队调整实际消耗'), '实际积分已更新')}>保存积分</button>
+              <label>交付说明<textarea value={notes[course.id] || ''} onChange={(event) => setNotes((current) => ({ ...current, [course.id]: event.target.value }))} placeholder="填写交付说明" /></label>
+              <button type="button" className="primary-button" disabled={busyId === course.id || !course.deliverables?.length} onClick={() => { if (window.confirm(`确认完成并交付「${course.name}」?`)) run(course.id, () => api.admin.completeCourse(course.id, notes[course.id] || ''), '课程已完成并交付') }}>确认完成并交付</button>
+              <span>已上传 {course.deliverables?.length || 0} 份交付文件</span>
+            </>}
+          </div>
+        </article>
+      })}</div>}
+  </div>
+}

+ 5 - 55
src/pages/admin/AdminUsers.tsx

@@ -18,11 +18,9 @@ import {
 import { useEffect, useState } from 'react'
 import { Link } from 'react-router-dom'
 import { api } from '../../api'
-import { useAuth } from '../../AuthContext'
 import type { User } from '../../types'
 
 export function AdminUsers() {
-  const { user: currentUser } = useAuth()
   const [users, setUsers] = useState<User[]>([])
   const [loading, setLoading] = useState(true)
   const [keyword, setKeyword] = useState('')
@@ -67,29 +65,6 @@ export function AdminUsers() {
     loadUsers()
   }
 
-  const handleToggleRole = async (targetUser: User) => {
-    const nextRole = targetUser.role === 'ADMIN' ? 'USER' : 'ADMIN'
-    const confirmText =
-      nextRole === 'ADMIN'
-        ? `确定要将用户【${targetUser.contactName || targetUser.phone}】提升为管理员吗?`
-        : `确定要撤销用户【${targetUser.contactName || targetUser.phone}】的管理员权限吗?`
-
-    if (!confirm(confirmText)) return
-
-    setBusyId(targetUser.id)
-    setError('')
-    setSuccessMsg('')
-    try {
-      await api.admin.updateUserRole(targetUser.id, nextRole)
-      setSuccessMsg(`用户 ${targetUser.phone} 权限已更新为:${nextRole === 'ADMIN' ? '管理员' : '普通用户'}`)
-      loadUsers()
-    } catch (err: any) {
-      setError(err.message || '更新角色失败')
-    } finally {
-      setBusyId(null)
-    }
-  }
-
   const handleCreateUser = async (e: React.FormEvent<HTMLFormElement>) => {
     e.preventDefault()
     const form = new FormData(e.currentTarget)
@@ -102,8 +77,7 @@ export function AdminUsers() {
     try {
       await api.admin.createUser({
         ...login,
-        contactName: String(form.get('contactName') || '').trim(),
-        organization: String(form.get('organization') || '').trim()
+        contactName: String(form.get('contactName') || '').trim()
       })
       setCreatedLogin(login)
       setSuccessMsg(`用户 ${login.phone} 创建成功`)
@@ -182,7 +156,7 @@ export function AdminUsers() {
             <input
               value={keyword}
               onChange={(e) => setKeyword(e.target.value)}
-              placeholder="搜索手机号、姓名、单位或微信…"
+              placeholder="搜索手机号、姓名或微信…"
             />
           </div>
           <button type="submit" className="secondary-button">
@@ -209,7 +183,7 @@ export function AdminUsers() {
             <thead>
               <tr>
                 <th>用户姓名 / 手机号</th>
-                <th>所属单位 / 微信号</th>
+                <th>所属组织 / 微信号</th>
                 <th>用户角色</th>
                 <th>课程统计</th>
                 <th>注册时间</th>
@@ -227,20 +201,10 @@ export function AdminUsers() {
                     </div>
                   </td>
                   <td>
-                    <span>{u.organization || '未填写单位'}</span>
+                    <span>{u.organization || '未加入组织'}</span>
                     <small className="sub-text">微信号:{u.wechat || '未填写'}</small>
                   </td>
-                  <td>
-                    {u.role === 'ADMIN' ? (
-                      <span className="role-tag role-admin">
-                        <ShieldCheck size={13} /> 管理员
-                      </span>
-                    ) : (
-                      <span className="role-tag role-user">
-                        <UserCheck size={13} /> 普通创作者
-                      </span>
-                    )}
-                  </td>
+                  <td><span className="role-tag role-user"><UserCheck size={13} /> 普通用户</span></td>
                   <td>
                     <div className="badge-group">
                       <span className="badge-count" title="总课程数">
@@ -280,19 +244,6 @@ export function AdminUsers() {
                         {busyId === u.id ? '处理中…' : '重置密码'}
                       </button>
 
-                      {/* 权限切换按钮 */}
-                      {u.id !== currentUser?.id && (
-                        <button
-                          type="button"
-                          disabled={busyId === u.id}
-                          className={`small-button ${
-                            u.role === 'ADMIN' ? 'secondary-button' : 'primary-button'
-                          }`}
-                          onClick={() => handleToggleRole(u)}
-                        >
-                          {u.role === 'ADMIN' ? '降为普通' : '设为管理员'}
-                        </button>
-                      )}
                     </div>
                   </td>
                 </tr>
@@ -345,7 +296,6 @@ export function AdminUsers() {
                   </label>
                   <div className="create-user-grid">
                     <label className="create-user-field"><span>姓名(选填)</span><input name="contactName" maxLength={50} placeholder="用户姓名" /></label>
-                    <label className="create-user-field"><span>单位(选填)</span><input name="organization" maxLength={100} placeholder="所属单位" /></label>
                   </div>
                 </div>
                 <div className="modal-footer">

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 337 - 139
src/styles.css


+ 39 - 3
src/types.ts

@@ -15,11 +15,21 @@ export interface User {
   inProductionCoursesCount?: number
   completedCoursesCount?: number
   pointsBalance: number
+  mustChangePassword?: boolean
+}
+
+export interface Organization {
+  id: string
+  slug: string
+  name: string
+  pointsBalance: number
+  role: 'MEMBER' | 'MANAGER'
+  isActive: boolean
 }
 
 export interface PointsTransaction {
   id: string
-  courseId: string
+  courseId: string | null
   courseName: string
   points: number
   createdAt: string
@@ -51,8 +61,30 @@ export interface Instructor {
   name: string
   organization: string
   introduction: string
+  note?: string
   imageUrl: string | null
   imageUrls: string[]
+  audioUrls?: string[]
+}
+
+export interface DigitalInstructor {
+  id: string
+  name: string
+  note: string
+  organizationId: string | null
+  personalUserId: string | null
+  ownerId: string
+  ownerName: string
+  creatorId: string
+  creatorName: string
+  createdAt: string
+  updatedAt: string
+  sourceId: string | null
+  qualityStatus: string
+  ready: boolean
+  usageCount: number
+  images: { id: string; name: string; url: string }[]
+  audio: { id: string; name: string; url: string }[]
 }
 
 export interface CourseDeliverable {
@@ -82,11 +114,13 @@ export interface Episode {
 export interface Course {
   id: string
   userId?: string
+  organizationId?: string | null
+  organizationName?: string | null
   name: string
-  category: string
   description: string
   status: CourseStatus
   productionNotes?: string
+  assetNotes?: string
   estimatedMinutes: number
   estimatedPoints: number
   actualPoints: number | null
@@ -95,9 +129,12 @@ export interface Course {
   assets: CourseAsset[]
   episodes: Episode[]
   instructors: Instructor[]
+  digitalInstructorId?: string | null
+  digitalInstructorSnapshot?: DigitalInstructor | null
   deliverables?: CourseDeliverable[]
   creator?: User
   createdAt: string
+  updatedAt?: string
   submittedAt: string | null
   completedAt?: string | null
 }
@@ -136,7 +173,6 @@ export interface PresetEpisode {
 export interface CourseTemplate {
   id: string
   name: string
-  category: string
   tags: string[]
   badge?: string
   summary: string

+ 29 - 0
src/utils/date.ts

@@ -0,0 +1,29 @@
+export function formatDateTime(dateStr?: string | null): string {
+  if (!dateStr) return '-'
+  try {
+    const d = new Date(dateStr)
+    if (isNaN(d.getTime())) return '-'
+    const y = d.getFullYear()
+    const m = String(d.getMonth() + 1).padStart(2, '0')
+    const day = String(d.getDate()).padStart(2, '0')
+    const h = String(d.getHours()).padStart(2, '0')
+    const min = String(d.getMinutes()).padStart(2, '0')
+    return `${y}-${m}-${day} ${h}:${min}`
+  } catch {
+    return '-'
+  }
+}
+
+export function formatDate(dateStr?: string | null): string {
+  if (!dateStr) return '-'
+  try {
+    const d = new Date(dateStr)
+    if (isNaN(d.getTime())) return '-'
+    const y = d.getFullYear()
+    const m = String(d.getMonth() + 1).padStart(2, '0')
+    const day = String(d.getDate()).padStart(2, '0')
+    return `${y}-${m}-${day}`
+  } catch {
+    return '-'
+  }
+}

+ 21 - 0
src/utils/lectureNotes.ts

@@ -0,0 +1,21 @@
+import DOMPurify from 'dompurify'
+
+export const RICH_NOTES_PREFIX = '<!--xinghen-richtext-->'
+
+export function notesText(value: string) {
+  if (!value.startsWith(RICH_NOTES_PREFIX)) return value
+  const element = document.createElement('div')
+  element.innerHTML = DOMPurify.sanitize(value.slice(RICH_NOTES_PREFIX.length))
+  return element.textContent || ''
+}
+
+export function notesHtml(value: string) {
+  if (value.startsWith(RICH_NOTES_PREFIX)) return DOMPurify.sanitize(value.slice(RICH_NOTES_PREFIX.length))
+  const element = document.createElement('div')
+  value.split(/\n/).forEach((line) => {
+    const paragraph = document.createElement('p')
+    paragraph.textContent = line || ' '
+    element.appendChild(paragraph)
+  })
+  return element.innerHTML
+}

+ 0 - 1
tsconfig.node.tsbuildinfo

@@ -1 +0,0 @@
-{"root":["./vite.config.ts","./server/db.ts","./server/index.ts","./server/migrate.ts","./server/templates.ts"],"version":"5.7.3"}

Vissa filer visades inte eftersom för många filer har ändrats