commit 76f266645db4b67d976c6e43702f57d9a41a7b8c Author: daiyongkang Date: Wed Sep 2 10:04:03 2026 +0000 Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM) 含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor diff --git a/.cursor/rules/office-process.mdc b/.cursor/rules/office-process.mdc new file mode 100644 index 0000000..d33bc22 --- /dev/null +++ b/.cursor/rules/office-process.mdc @@ -0,0 +1,30 @@ +--- +description: 个人办公申请必须走统一流程,禁止再做孤立页面和三五个文字链筛选 +alwaysApply: true +--- + +# 办公申请怎么长 + +个人办公是「人发起、人跟踪、落地到台账」。不要再按用户点到哪一页就只改那一页。 + +## 列表筛选 + +- 用 `ApplyBucketBar`(Segmented + `?bucket=`),有选中态。 +- 标准桶:全部 / 待审批 / 已通过 / 已驳回。领域附加桶(外带未还、占用中、待入库)可以加,不能替代标准桶。 +- 禁止再在页头放三个无选中态的 `Link`(「全部」「待审批」「外带未还」这种)。 + +## 菜单 + +- 「我的申请」是跟踪入口,人事/用章/办公申请是发起入口,待我审批是审批入口。不要做成三套互不相通的列表。 +- 申请人页只看自己的单;台账(用章登记、资产管理、采购申请)在业务模块。 + +## 审批通过必须写下游 + +| 申请 | 落到 | +|---|---| +| 资产购置 | 采购单 + 待入库台账 + 付款待办 | +| 资产领用 / 用车 | 占用记录(挂台账 id),交回不改保管人 | +| 出差 | 差旅报销草稿 | +| 用章外带 | 归还待办 | + +保管人是台账责任人,占用是谁正在用。占用时不要把保管人改成持有人。 diff --git a/.cursor/rules/windows-packaging.mdc b/.cursor/rules/windows-packaging.mdc new file mode 100644 index 0000000..c05c7eb --- /dev/null +++ b/.cursor/rules/windows-packaging.mdc @@ -0,0 +1,11 @@ +--- +description: Windows 客户端打包机与 SSH 端口 +alwaysApply: true +--- + +# Windows 端打包 + +- 打包机:`10.1.1.202`,SSH **端口 22022**(不是 22),用户 `administrator`。 +- 产出必须是可安装的 **`.exe`**(Inno Setup 安装包),不要只丢 zip。 +- 安装包上传到生产:`https://oa.fysxkj.com/fengying-oa-setup.exe`,并写入 `apps/api/data/app-release.json` 的 `windowsUrl`。 +- 安卓仍在本机 `/opt/apps/native` 打 APK。 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..34a2c44 --- /dev/null +++ b/.env.example @@ -0,0 +1,44 @@ +NODE_ENV=development +APP_PORT=3000 +APP_NAME=enterprise-ops +API_PREFIX=api/v1 +CORS_ORIGINS=http://localhost:5173 + +DATABASE_URL=postgresql://ops:ops_dev_only@localhost:5432/enterprise_ops +REDIS_URL=redis://localhost:6379 +MINIO_ENDPOINT=localhost +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=opsadmin +MINIO_SECRET_KEY=opsadmin_dev_only +MINIO_BUCKET=enterprise-ops + +JWT_ACCESS_SECRET=change-me-access-secret-dev-only +JWT_REFRESH_SECRET=change-me-refresh-secret-dev-only +JWT_ACCESS_EXPIRES=15m +JWT_REFRESH_EXPIRES=7d + +ALIYUN_SMS_ACCESS_KEY_ID= +ALIYUN_SMS_ACCESS_KEY_SECRET= +ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com +ALIYUN_SMS_REGION=cn-qingdao +ALIYUN_SMS_DEFAULT_SIGN=江苏风影随行科技 + +VIVO_PUSH_APP_ID= +VIVO_PUSH_APP_KEY= +VIVO_PUSH_APP_SECRET= +OPPO_PUSH_APP_ID= +OPPO_PUSH_APP_KEY= +OPPO_PUSH_APP_SECRET= +OPPO_PUSH_MASTER_SECRET= +MEIZU_PUSH_APP_ID= +MEIZU_PUSH_APP_KEY= +MEIZU_PUSH_APP_SECRET= + +POSTGRES_USER=ops +POSTGRES_PASSWORD=ops_dev_only +POSTGRES_DB=enterprise_ops +POSTGRES_PORT=5432 +REDIS_PORT=6379 +MINIO_API_PORT=9000 +MINIO_CONSOLE_PORT=9001 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2fe668 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +node_modules +dist +build +coverage +.env +*.log +.DS_Store +apps/api/generated +apps/web/dist +.idea +.vscode +apps/api/uploads +import +apps/api/.aliyun-sms.env* +apps/api/.application-sms.yml* + +# 本地 SDK / 工具链(不进仓库) +flutter/ +android-sdk/ +.pnpm-store/ +backup/ +node_modules/ + +# 安装包与归档 +*.apk +*.exe +*.deb +*.dmg +*.msi +*.aab +*.ipa +*.tar.gz +*.zip +*.mp4 + +# Flutter 构建缓存 +.dart_tool/ +apps/native/build/ +apps/native/dist/ +apps/native/.flutter-plugins +apps/native/.flutter-plugins-dependencies + +# Android 签名(勿上传) +**/key.properties +**/*.jks +**/*.keystore + +# 临时打包 +fengying-oa-code-*.tar.gz diff --git a/DELIVERY.md b/DELIVERY.md new file mode 100644 index 0000000..8a031e2 --- /dev/null +++ b/DELIVERY.md @@ -0,0 +1,661 @@ +# 风影智慧办公 · 全系统转交文档 + +更新日期:2026-09-01 +当前手机包:**3.1.8+318**(`com.fengying.oa.mobile`) +本次生产修复与当前真实状态:**先读 `/opt/HANDOFF.md`,再读本文件第 0、2、3、14 节和 `.cursor/rules/`。** + +本文件覆盖 **Web + API + 手机 + IM + 库表 + 密钥 + 发版**。不是只写手机端。 +**含生产密钥,不要推到公开 git。** + +--- + +## 0. 你接手后立刻要知道的事 + +1. 这是一家约四十人的工程/软件公司内部 OA,从旧 Java OA(生产 MySQL 库 `oa`)迁到本仓库。 +2. 用户同时用 **网页** `https://oa.fysxkj.com` 和 **安卓包**。IM 是自研 TCP,不是环信。 +3. 办公模块有固定产品规则(第 3 节)。乱改列表筛选或审批不写下游,会被用户打回。 +4. 2026-09-01 的 API 数据修复、旧终止投标恢复兼容和项目类型修复已经发到生产;精确结果与备份位置见 `/opt/HANDOFF.md`。后续不要依据本文件旧版本描述判断是否已部署。 +5. 回复用户用**简体中文**。不要主动 `git commit` / `push`,除非用户说。 +6. 改菜单后必须跑 `apps/api/prisma/sync-menus.ts`,侧栏才更新。 +7. 手机登录/刷新必须带 `clientKind: 'mobile'`。 + +--- + +## 1. 产品与架构 + +一套 API,三端消费: + +| 端 | 路径 | 技术 | 入口 | +|---|---|---|---| +| API | `apps/api` | NestJS 11 + Prisma 6 + PostgreSQL | `/api/v1`,Swagger `/api/v1/docs` | +| Web | `apps/web` | React 18 + Vite 5 + Ant Design 5 + React Query + Zustand | 路由 `apps/web/src/App.tsx` | +| 手机 | `apps/native` | Flutter,包名 **必须** `com.fengying.oa.mobile` | 底栏:消息 / 工作台 / 通讯录 / 我 | +| IM | `apps/im-server/server.js` | Node TCP 8901 + 本机 HTTP 8902 | MobileIMSDK 协议 | + +生产: + +- 站点:`https://oa.fysxkj.com` +- REST:`https://oa.fysxkj.com/api/v1` +- IM TCP:`47.96.23.244:8901` +- APK:`https://oa.fysxkj.com/fengying-oa.apk` +- 远端代码:`/data/fengying-oa/` +- systemd:`fengying-api.service`、`fengying-im.service`(模板在 `infra/fengying-cloud/`) +- 生产 API 端口 **3010**(nginx 反代);本机 nest 常在 **3000** + +本机构建机 hostname `fykj`,公网不是 `47.96.23.244`。 + +租户字段 `tenantId` 目前固定 `'1'`,没有做多租户产品化。 + +--- + +## 2. 仓库地图 + +``` +/opt +├── DELIVERY.md ← 本文件 +├── .cursor/rules/office-process.mdc +├── apps/api Nest 后端 +│ ├── prisma/schema.prisma 库表 +│ ├── prisma/menus.ts 侧栏菜单源 +│ ├── prisma/seed.ts 演示数据 +│ ├── prisma/sync-menus.ts 菜单写入 Permission +│ ├── src/*.controller.ts 业务入口 +│ ├── data/app-release.json 手机 OTA +│ └── .env 本机密钥 +├── apps/web/src +│ ├── App.tsx 全部路由 +│ ├── pages/ 业务页 +│ ├── store/auth.ts 登录态 +│ ├── api/client.ts axios + sessionStorage +│ └── labels.ts 枚举中文 +├── apps/native Flutter +├── apps/im-server/server.js +└── infra/fengying-cloud nginx / systemd / compose 模板 +``` + +本机常用: + +```bash +cd /opt/apps/api && npx nest start # http://localhost:3000/api/v1 +cd /opt/apps/web && pnpm dev # http://localhost:5173 +cd /opt/apps/im-server && node server.js # TCP 8901 +cd /opt/apps/native && flutter build apk --release && bash scripts/publish-apk.sh +``` + +改 Prisma:`cd /opt/apps/api && npx prisma migrate dev`。 +改菜单:改 `prisma/menus.ts` 后 `npx ts-node --transpile-only prisma/sync-menus.ts`。 + +--- + +## 3. 硬约束(违反就是返工) + +### 3.1 个人办公怎么长 + +规则文件:`.cursor/rules/office-process.mdc` + +- 列表筛选用 **`ApplyBucketBar`**(Segmented + `?bucket=`),有选中态。 +- 标准桶:全部 / 待审批 / 已通过 / 已驳回。领域附加桶可以加,不能替代标准桶。 +- 禁止页头三个无选中态的 `Link`(「全部」「待审批」「外带未还」这种)。 +- 「我的申请」=`/office/flow` 是**跟踪**;人事/用章/办公申请是**发起**;「待我审批」是**审批**。不要做成三套互不相通的列表。 +- 申请人页只看自己的单;台账在业务模块(用章登记、资产管理、采购申请)。 + +审批通过必须写下游: + +| 申请 | 落到 | +|---|---| +| 资产购置 `ASSET_BUY` | 采购单 + 待入库台账 + 付款待办 | +| 资产领用 / 用车 | 占用记录(挂台账 id),**交回不改保管人** | +| 出差(人事) | 差旅报销草稿 | +| 用章外带 | 归还待办 | + +保管人 = 台账责任人。占用 = 谁正在用。占用时不要把保管人改成持有人。 + +实现:`apps/api/src/office/office-apply.ts`。 + +### 3.2 登录会话 + +`apps/api/src/auth/auth.service.ts` + Web `apps/web/src/api/client.ts` + +- Web 和手机**可以同时在线**。 +- 同一账号不能开两个 Web(后登挤掉先登)。JWT 含 `sid`。 +- 手机 `clientKind: 'mobile'`:refresh **不轮换**,access 7 天,refresh 3650 天,除非退出一直在线。 +- Web token 用 **sessionStorage** + BroadcastChannel(同浏览器多标签共享)。关浏览器再开要重登。 +- 桌面端只能扫码,**不能账密**。`clientKind: 'desktop'` 账密会被拒绝。页 `/login/desktop`;手机「我 → 确认电脑登录」;Deep Link `fengyingoa://qr`。 +- 登出不带 refresh 时只撤 web/desktop,不踢手机。 + +### 3.3 安卓 + +- `applicationId`:`com.fengying.oa.mobile`。`namespace` 仍是 `com.fysxkj.fengying_oa`,不要改混。 +- 当前 release 用 **debug 签名**。华为控制台正式 SHA256 对不上 debug,HMS token 可能拿不到。 +- **不要**加 `com.huawei.agconnect` Gradle 插件(AGP 8.6 会炸)。HMS 依赖 + `agconnect-services.json` + Manifest `appid` 留着即可。 + +### 3.4 数据范围 + +`AuthUser.dataScope`:`ALL` / `DEPT` / `SELF`。列表过滤在 `apps/api/src/common/data-scope.ts`。改列表不要只改前端。 + +--- + +## 4. 角色、权限、菜单 + +菜单源:`apps/api/prisma/menus.ts`,写入 `Permission`(`permType=MENU`)。 +`admin` / `owner` 前端 `canSee` 直接放行全部。其他人比 `permissions` 前缀。 + +种子角色(`prisma/seed.ts`,生产还会有导入岗位): + +| code | 名称 | dataScope | 大致权限 | +|---|---|---|---| +| admin | 系统管理员 | ALL | * | +| owner | 企业负责人 | ALL | * | +| biz_director | 商务总监 | DEPT | office/party/seal/bid/contract/finance/report | +| biz_staff | 商务专员 | SELF | office/party/seal/bid/contract | +| tech_director | 技术总监 | DEPT | office/bid/project/report | +| rd_director / 3d_director / video_director / material_director | 各技术条线总监 | DEPT | 见 `role-groups.ts` | +| finance | 财务 | ALL | office/finance/contract/report | +| hr | 人事 | ALL | office/hr + 人事设置 | +| employee / pm / 各 *\_staff | 员工/项目经理 | SELF | office + 工时等 | +| seal / asset / cashier … | 导入岗位 | 视脚本 | `fix-oa-posts.ts` | + +总监集合:`DIRECTOR_ROLES`。可发短信:`SMS_SEND_ROLES`。可派工作:`WORK_ASSIGN_ROLES`。 +`bid-workflow.ts` 仍保留旧的 `SPECIAL_COORDINATORS` 常量用于兼容,但当前特殊筛选/特殊制标的办理人加载逻辑是全体在职人员,禁止再改回写死两人。 + +首次登录非 admin:改密 + 完善档案(`/office/onboard`)。`mustChangePassword` / `profileCompletedAt`。 + +Web 侧栏:`AppLayout` 用 `/auth/me` 带回的 `menus`。 +发公告:`hr` 与各总监(`NOTICE_PUBLISH_ROLES` in `App.tsx`)。 + +--- + +## 5. Web 功能全表(按侧栏) + +路由全部在 `apps/web/src/App.tsx`。没有单独的移动端 Web。 + +### 5.1 工作台 `/workbench` + +`OverviewPage`:待办/待审批数字、天气问候、快捷入口。API:`GET /office/overview`、`GET /office/weather`。 + +### 5.2 个人办公 + +| 菜单 | 路径 | 页面 | API 要点 | 说明 | +|---|---|---|---|---| +| 待办事项 | `/office/todos` | OfficeTodosPage | `GET /office/todos` `PATCH /office/todos/:id` | 待办可标已办 | +| 待我审批 | `/office/approvals` | OfficeApprovalsPage | `GET /office/approvals` `POST .../decide` | 统一审批入口 | +| 我的申请 | `/office/flow` | MyFlowPage | `GET /office/flow` | 只跟踪自己的单 | +| 我的日程 | `/office/calendar` | CalendarPage | `GET /office/calendar` | 含证照/印章逾期、开标等 | +| 工作汇报 | `/office/reports` | WorkReportPage | `/office/reports*` | 可从工时草稿带入 | +| 我的费用 | `/office/expenses` | MyExpensesPage + Expense/Loan 详情 | `/office/my-expenses` `/expenses` `/loans` | 员工发起报销/借款 | +| 人事申请 | `/office/hr-apply` | HrApplyPage / HrLeaveDetailPage | `/leave-requests` | 请假加班出差外出离职晋升 | +| 用章申请 | `/office/seal-apply` | SealRequestListPage mode=apply | `/seal-requests` | 发起;台账在证照用章 | +| 办公申请 | `/office/apply` | OfficeApplyPage | `/office/applies` | 购置/领用/用车 | +| 公司公告 | `/office/notices` | NoticePage / NoticeDetailPage | `/office/notices` | 员工阅读 | +| 发送短信 | `/office/sms` | OfficeSmsPage | `/sms/*` | 总监/人事/admin | +| 工作安排 | `/office/assign` | WorkAssignPage | `/office/work-assignments` | 总监派任务 | +| 消息 | `/office/im` | ImPage | `/im/*` | Web IM + 通话弹层 | +| 员工通讯 | `/office/directory` | DirectoryPage | `/staff` `/directors` | 点人可开聊 | + +出差通过后应落差旅报销草稿(人事申请下游)。 + +### 5.3 往来单位 + +| 菜单 | 路径 | 页面 | API | +|---|---|---|---| +| 招标单位 | `/party/tenders` | TenderPartyPage | `/parties` | +| 参与主体 | `/party/entities` | LegalEntityPage | `/legal-entities` | +| 联系名录 | `/party/contacts` | ContactDirectoryPage | `/contacts` | + +本机已洗过 5 家主体:随行科技(默认,历史投标多挂这里)、风影软件、风影工程、山东鲁兵、隆创。脚本 `prisma/wash-legal-entities.ts`。OA dump 几乎没有「软件/工程」历史投标,不会自动拆单。 + +### 5.4 证照用章 + +| 菜单 | 路径 | 页面 | API | +|---|---|---|---| +| 资质台账 | `/seal/qualifications` | QualificationListPage | `/qualifications` | +| 证照借用 | `/seal/borrows` | BorrowListPage | `/credential-borrows` | +| 用章登记 | `/seal/requests` | SealRequestListPage mode=register | `/seal-requests` | +| 印章台账 | `/seal/registry` | SealRegistryPage | `/seals` | + +印章状态:`IN_STOCK` 在库,`OUT` 外带(Web 台账有特判)。外带审批通过写归还待办。 + +### 5.5 投标管理(核心) + +状态机:`apps/api/src/bidding/bid-workflow.ts` +列表桶:`BidListPage` + `?bucket=` + +``` +普通 FLOW: + 项目筛选 TECH_INITIAL → FETCH → TECH_FINAL + → 标书制作 DRAFTING → REVIEW1 → REVIEW2 → PRINT + → 投标执行 LIST → PENDING + → 开标结果 WON / LOST / FAILED + 随时可 TERMINATED(权限内) + +特殊 SPECIAL:特殊筛选 / 特殊制标的办理人从**全公司在职人员**中选择,不再写死刘运泽、陈铖。 +``` + +| 菜单 | 路径 | bucket | +|---|---|---| +| 项目筛选 | `/bid/screening` | screening(可新建) | +| 特殊筛选 | `/bid/special-screening` | 特殊 | +| 标书制作 | `/bid/drafting` | drafting | +| 特殊制标 | `/bid/special-drafting` | drafting+special | +| 投标执行 | `/bid/execution` | execution | +| 开标结果 | `/bid/result` | result | +| 终止投标 | `/bid/terminated` | terminated | + +详情 `/bid/cases/:id`:`BidDetailPage`。新建:`BidCreatePage`。 +中标:`ensureWonLedger` 立刻落 **合同草稿 + 项目台账**(幂等)。 +未中/终止:可「编辑」登记对方中标单位/金额 → 报表「落标对照」。 +API:`/bid-cases`,`PATCH /bid-cases/:id/competitor`。 + +### 5.6 合同管理 + +中标自动生成 `kind=TENDER` 草稿。直签/其他在本页新建,不互相重复。 + +| 菜单 | 路径 | 说明 | +|---|---|---| +| 投标合同 | `/contract/tender` | 中标来的 | +| 直签合同 | `/contract/direct` | 不经投标 | +| 其他合同 | `/contract/incremental` | 其余 | +| 人事合同 | `/contract/hr` | `LaborContractPage` `/labor-contracts` | + +详情 `/contract/:id`。API `/contracts`。 + +### 5.7 资产管理 + +数字资产 `/asset/digital`、固定资产 `/asset/fixed`:`AssetRegistryPage`,`/registered-assets`。 +状态含 `IN_STOCK` / `PENDING_IN` / 占用。入库:`POST /registered-assets/:id/inbound`。 +领用占用走办公申请,不在本页把保管人改掉。 + +### 5.8 项目管理 + +中标自动写入台账。直签可在台账补登。 + +| 菜单 | 路径 | API | +|---|---|---| +| 项目台账 | `/project/list` | `/projects` | +| 任务分解 | `/project/tasks` | `/project-tasks` | +| 工时填报 | `/project/timesheets` | `/timesheets` | +| 资源看板 | `/project/resources` | OpsPages ResourceBoardPage | +| 项目验收 | `/project/acceptance` | 同一 ProjectListPage mode=acceptance | +| 采购申请 | `/project/purchases` | PurchasePage(购置审批下游) | +| 器材占用 | `/project/assets` | AssetPage 占用记录 | + +详情 `/project/:id`。预算/报销:商务差旅进商务,技术部门差旅/工时/采购进技术(页面 description 已写)。 + +### 5.9 财务收支 + +员工在「我的费用」发起;财务在本模块确认、付款、开票。草稿财务看不到(`canSeeSubmittedFinance`)。 + +| 菜单 | 路径 | API | +|---|---|---| +| 费用报销 | `/finance/expenses` | `/expenses` | +| 借支记录 | `/finance/loans` | `/loans` | +| 投标/履约/质保金 | `/finance/bonds/*` | `/bonds` type=TENDER/PERFORMANCE/WARRANTY | +| 开票收款 | `/finance/invoices` | `/invoices` | +| 付款执行 | `/finance/payments` | `/payments` | + +报销必须挂费用对象:投标 / 正式项目 / 部门 / 行政,规则 `assertCostObject`。中标前挂投标,中标后挂项目,不要同时挂。 + +### 5.10 组织人事 + +| 菜单 | 路径 | 说明 | +|---|---|---| +| 在职员工 | `/hr/employees` | 档案、证件、银行卡 | +| 组织管理 | `/hr/org` | 部门树 | +| 入转调离 | `/hr/lifecycle` | EmploymentEvent | +| 假勤结果 | `/hr/attendance` | 月导入 + 打卡 | +| 薪酬核算 | `/hr/payroll` | 核算批次 | +| 绩效发放 | `/hr/performance` | | +| 发布公告 | `/hr/notices` | 人事发 | +| 规章制度 | `/hr/policies` | 同类 Notice,kind 不同 | + +### 5.11 经营报表 + +| 菜单 | 路径 | API | +|---|---|---| +| 投标转化 | `/report/conversion` | `/reports/bid-conversion` | +| 落标对照 | `/report/lost` | `/reports/competitor-wins` | +| 项目利润 | `/report/profit` | `/reports/profit` | +| 部门人效 | `/report/efficiency` | `/reports/efficiency` | +| 资金占用 | `/report/cash` | `/reports/cash` | + +页面在 `OpsPages.tsx`。 + +### 5.12 系统设置 + +| 菜单 | 路径 | 说明 | +|---|---|---| +| 员工账号 | `/system/acl` | 用户/角色 | +| 人事参数 | `/system/hr-params` | | +| 考勤设置 | `/system/attendance-rules` | | +| 数据字典 | `/system/dicts` | | +| 权限设置 | `/system/perms` | 菜单授权 | +| 编号规则 | `/system/numbers` | `nextBizNo` | +| 短信服务 | `/system/sms` | 签名/模板/绑定事件 | +| 消息通道 | `/system/messages` | 占位页,文案可能过时(IM 已做) | +| 手机端升级 | `/system/app-release` | 写 `app-release.json` | +| 操作日志 | `/system/logs` | | + +公开:`GET /system/app-release`(手机 OTA)。 + +--- + +## 6. 核心流程(改业务先对这条链) + +### 投标 → 合同/项目 + +筛选 → 取标 → 终审 → 制标 → 一审二审 → 印封 → 执行 → 开标。 +**WON** → 合同草稿 + 项目台账。售前报销仍挂投标;正式花费挂项目。 + +### 报销 / 借款 + +员工草稿 → 提交 PENDING → 财务/总监审批 APPROVED → 付款执行。借款还有 OPEN / OFFSET 冲账。 + +### 办公申请 + +发起 PENDING → 审批(admin/owner/hr/biz_director)→ 按 kind 写采购/占用/待入库。 + +### 用章 + +申请 → 审批 → 外带则归还待办;台账 status 在库/外带。 + +### 人事假勤 + +`LeaveRequest.kind`:LEAVE / OVERTIME / TRIP / OUT / RESIGN / PROMOTE。出差通过 → 报销草稿。 + +统一待办 `TodoTask`、统一审批 `ApprovalTask`(`bizType` 区分投标/合同/报销/用章/人事/办公)。 +待我审批一页消化,不要为每个 biz 再做孤立审批页。 + +--- + +## 7. API 地图 + +前缀均 `/api/v1`。鉴权 JWT,除标注 Public。 + +| 模块 | 文件 | 主要路径 | +|---|---|---| +| 登录 | `auth/` | `/auth/login` `/auth/refresh` `/auth/me` `/auth/logout` `/auth/profile` `/auth/password` `/auth/qr/*` | +| 办公 | `office/` | `/office/*` 待办审批日程公告申请汇报工作安排天气地理 | +| 投标 | `bidding/` | `/bid-cases` | +| 合同 | `contract/` | `/contracts` | +| 项目 | `project/` | `/projects` `/project-tasks` `/timesheets` | +| 用章 | `seal/` | `/seal-requests` `/seals` `/qualifications` `/credential-borrows` | +| 财务 | `finance/` | `/expenses` `/loans` `/bonds` `/invoices` `/payments` | +| 人事 | `hr/` | `/employees` `/leave-requests` `/payroll` `/attendance` `/labor-contracts` `/org` | +| 主数据 | `master/` | `/parties` `/legal-entities` `/contacts` `/registered-assets` | +| 系统 | `system/` | `/system/menus` `/system/acl` `/system/dicts` `/system/numbers` `/system/app-release` `/system/logs` | +| 报表 | `report/` | `/reports/*` | +| 文件 | `files/` | `POST /files` `GET /files/:id` | +| IM | `im/` | `/im/conversations` `/im/messages` `PATCH /im/messages/:id` `/im/calls*` `/im/read` | +| 推送 | `push/` | 设备 token 注册与下发 | +| 短信 | `sms/` | 签名模板发送 OTP | +| 通讯录 | `business/` | `/staff` `/directors` | +| 健康 | `health/` | `/health` | +| 协议 | `legal/` | 用户协议页 | + +响应外包一层 `{ data }`,Web 用 `unwrapList` / `body.data`。手机 `oa_client.dart` 同样解包。 +错误:`HttpErrorFilter`。请求号:`X-Request-Id`。 + +地理:`/office/geo/*` 走腾讯位置服务,`TENCENT_MAP_KEY`。 + +--- + +## 8. 数据模型(Prisma) + +完整定义:`apps/api/prisma/schema.prisma`。 + +**账号**:User、RefreshToken(`clientKind`)、AuthQrTicket、Role、Permission、UserRole、PushDevice +**组织人事**:Department、Employee、LaborContract、EmploymentEvent、AttendanceMonth、AttendancePunch、LeaveRequest、HrSetting、AttendanceRule、PayrollRun、PayrollItem +**主数据**:LegalEntity、BusinessParty、PartyContact、DictItem、NumberRule、Qualification、SealItem、RegisteredAsset +**投标**:BidCase(含 `winnerName/Amount/Note`)、BidReviewer、BidAssignee、BidDocVersion、BidActionLog +**合同项目**:Contract、ContractReviewer、PaymentMilestone、Project、ProjectBudget、ProjectTask、Timesheet、ProjectAcceptance、ProjectAcceptMaterial、ProjectChange +**财务**:ExpenseClaim、ExpenseLine、LoanRecord、BondRecord、InvoiceRecord、PaymentRecord、PurchaseRequest +**办公**:TodoTask、ApprovalTask、WorkReport、WorkAssignment、OfficeApply、Notice、AssetOccupancy、CredentialBorrow、SealRequest +**IM**:ImConversation、ImConversationMember、ImMessage(`fingerprint` 唯一)、ImReadCursor、ImMessageReceipt、ImCall、ImCallMember、ImCallSignal +**其它**:FileAsset、SmsSendLog、SmsEventBind、SmsOtp、OperationLog + +编号:`NumberRule` + `src/system/numbering.ts`。注意:**项目筛选、特殊筛选的招标编号和合同编号必须手填,系统不得另生成隐藏编号**;报销等内部单据仍可使用系统编号。 + +--- + +## 9. 手机端 + +Flutter。版本:`apps/native/lib/app_config.dart` + `pubspec.yaml` + `test/widget_test.dart` + `apps/api/data/app-release.json`,四者一起改。 + +默认连生产 API / IM。底栏四页:`messages_page`、`workbench_page`、`directory_page`、`mine_page`。 + +工作台宫格来自登录菜单。`open_module.dart` / `module_list_page.dart` 把菜单码映射到 REST 列表(投标→`/bid-cases` 等)。系统设置、发短信、工作台里的「消息」入口故意不打开(消息在底栏)。详情 `record_detail_page.dart` + `biz_route.dart` 反查详情 API。中文:`labels.dart`。 + +聊天/通话的关键文件:聊天 `chat_page.dart`,来电 `home_shell.dart` + `call_page.dart`。当前版本为 3.1.8+318;音视频、截屏提醒、撤回再编辑、会话已读后保留等需求必须按 `/opt/HANDOFF.md` 的“未完成/需真机回归”继续验证,不能仅凭旧交接宣称已修。 + +打包: + +```bash +cd /opt/apps/native && flutter build apk --release && bash scripts/publish-apk.sh +``` + +产物:`/opt/apps/native/dist/fengying-oa.apk`。本机 root 跑 Flutter 会警告,仍能编过。下一版必须在 **3.1.8+318** 基础上递增。 + +OTA:比较 `build`。后台「手机端升级」。 + +--- + +## 10. IM + +协议:4 字节大端长度 + UTF-8 JSON Protocal。登录用 OA accessToken 调 `GET /auth/me`。 + +**新 `server.js`**:`Map>`,同一账号多端/重连 **不再 kick**。旧版会踢旧连接,客户端若把 `_want=false` 会永久断线。 +生产必须换新 `server.js` 长连接才稳。新 APK 即使对旧 IM 也不会再「被踢后永不再连」。 + +HTTP 消息仍落 Postgres。TCP 负责推送。通话信令走 `/im/calls/:id/signal`(HTTP 轮询),WebRTC + 国内 STUN。 + +`PATCH /im/messages/:id`:2 分钟内改自己的文本。 + +--- + +## 11. 文件、短信、推送、地图 + +- 文件:`FilesService` 存 MinIO(本机 docker)或本地 `uploads/`。下载带鉴权。手机 `AuthImage` / `fileBytes` 有内存缓存。 +- 短信:阿里云 Dysmsapi。系统设置申请签名/模板,事件绑定在 `SmsEventBind`。投标节点会 `emitSms`。 +- 推送:vivo / OPPO / 魅族 / 华为。设备 `PushDevice`。华为 App ID `118301849`。 +- 地图:腾讯。选点 `location_pick_page.dart` + `tencent-lbs`;POI 用真实距离,不要写死「100m内」。 + +--- + +## 12. 前端约定 + +**Web** + +- 新列表:Ant Design Table + 中文 `zh()` / `StatusTag`,不要直接渲染枚举码。 +- 申请类列表:`ApplyBucketBar` + URL `bucket`。 +- 抽屉表单:`FormDrawer.tsx`。附件:`FileAttachments.tsx`。 +- 枚举中文:`apps/web/src/labels.ts`,与手机 `labels.dart` 对齐。 +- token 只放 sessionStorage。 + +**手机** + +- 企业微信风:`theme.dart`、`widgets/wecom.dart`。 +- 英文字段:补 `fieldZh` / `zhMap`,不要在单个页写死。`OUT` 人事=外出、印章=外带,有冲突时按字段上下文。 + +--- + +## 13. 脚本与历史数据 + +`apps/api/prisma/` 里常见脚本(跑前看文件头,生产库只读): + +| 脚本 | 用途 | +|---|---| +| seed.ts | 空库演示 | +| sync-menus.ts | 菜单/权限 | +| import-hr.ts / import-prod.ts | 从旧 OA 导人事/业务 | +| wash-legal-entities.ts | 参与主体清洗 | +| wash-oa-ledger.ts / wash-oa-flow-finance.ts | 台账/财务清洗 | +| sync-oa-attachments.ts | 附件 | +| grant-admin-equivalents.ts / ensure-prod-admin.ts | 管理员 | +| fix-oa-posts.ts / fix-expense-applicants.ts | 岗位与报销申请人 | + +旧 OA 在生产 MySQL `oa`(只读扒数,用户禁止改生产旧库、禁止整用户过来)。dump 曾放 `/opt/import/prod/`。 + +--- + +## 14. 密钥与账号(Codex 直接用,不必再问) + +### 生产 SSH + +``` +主机:47.96.23.244 +用户:root +密码:VYTnNdfFfrX7xTP +代码目录:/data/fengying-oa/ +``` + +```bash +export SSHPASS='VYTnNdfFfrX7xTP' +RSH='ssh -o StrictHostKeyChecking=no' +DEST=root@47.96.23.244 +sshpass -e ssh -o StrictHostKeyChecking=no "$DEST" 'hostname; systemctl is-active fengying-api fengying-im' +``` + +发版: + +```bash +export SSHPASS='VYTnNdfFfrX7xTP' +RSH='ssh -o StrictHostKeyChecking=no' +DEST=root@47.96.23.244 +# 先 nest build、确认 APK 已 publish +sshpass -e rsync -az -e "$RSH" /opt/apps/api/dist/ $DEST:/data/fengying-oa/apps/api/dist/ +sshpass -e rsync -az -e "$RSH" /opt/apps/api/data/app-release.json $DEST:/data/fengying-oa/apps/api/data/app-release.json +sshpass -e rsync -az -e "$RSH" /opt/apps/web/dist/ $DEST:/data/fengying-oa/apps/web/dist/ # 若本机有最新 web 构建 +sshpass -e rsync -az -e "$RSH" /opt/apps/im-server/server.js $DEST:/data/fengying-oa/apps/im-server/server.js +sshpass -e rsync -az -e "$RSH" /opt/apps/native/dist/fengying-oa.apk $DEST:/data/fengying-oa/apps/web/dist/fengying-oa.apk +sshpass -e ssh -o StrictHostKeyChecking=no "$DEST" 'systemctl restart fengying-api fengying-im; sleep 2; systemctl is-active fengying-api fengying-im' +``` + +生产 API `.env` 在远端 `/data/fengying-oa/apps/api/.env`。上去先 `cat`,**不要用本机 development 的 JWT/Postgres 覆盖生产**。发 IM/API 后确认 Prisma 已有 `RefreshToken.clientKind`、`AuthQrTicket`、`BidCase.winner*`。 + +### 旧 OA MySQL(只读) + +``` +用户:root +密码:BtH9ywG8gFTXG9w! +库:oa +``` + +### 本机 API `apps/api/.env` + +``` +NODE_ENV=development +APP_PORT=3000 +DATABASE_URL=postgresql://ops:ops_dev_only@localhost:5432/enterprise_ops +REDIS_URL=redis://localhost:6379 +MINIO_ACCESS_KEY=opsadmin +MINIO_SECRET_KEY=opsadmin_dev_only +JWT_ACCESS_SECRET=change-me-access-secret-dev-only +JWT_REFRESH_SECRET=change-me-refresh-secret-dev-only +POSTGRES_USER=ops +POSTGRES_PASSWORD=ops_dev_only +POSTGRES_DB=enterprise_ops + +ALIYUN_SMS_ACCESS_KEY_ID=LTAI5t9DdPycJp7sabQyEWRc +ALIYUN_SMS_ACCESS_KEY_SECRET=RPFVqKDUpEvxo3IsqlxLB5qIywfheM +ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com +ALIYUN_SMS_REGION=cn-hangzhou +ALIYUN_SMS_DEFAULT_SIGN=江苏风影随行科技 +ALIYUN_RAM_LOGIN=yongkangdai10@1801267455383083.onaliyun.com + +TENCENT_MAP_KEY=L6NBZ-47KLI-OL7G2-UMROZ-XPI3T-SLFC7 + +IM_TCP_PORT=8901 +IM_HTTP_PORT=8902 +IM_INTERNAL_TOKEN=fengying-im-internal + +VIVO_PUSH_APP_ID=106114371 +VIVO_PUSH_APP_KEY=b521c805229a00e85a53d4b535a0cafe +VIVO_PUSH_APP_SECRET=2e1536ed-ed2f-4cd6-88d1-67aa9a93eeba + +OPPO_PUSH_APP_ID=37438377 +OPPO_PUSH_APP_KEY=e23832b52fbb46e18de6eef5affda2e9 +OPPO_PUSH_APP_SECRET=e9cf0bf35052484ebcaf15d56576468b +OPPO_PUSH_MASTER_SECRET=e9cf0bf35052484ebcaf15d56576468b + +MEIZU_PUSH_APP_ID=157643 +MEIZU_PUSH_APP_KEY=4263a8d18de442b6a17203866ce1546d +MEIZU_PUSH_APP_SECRET=2a0bafc2d47a470f8b2aad3f182d2377 + +HUAWEI_PUSH_APP_ID=118301849 +HUAWEI_PUSH_CLIENT_ID=118301849 +HUAWEI_PUSH_CLIENT_SECRET=85c01c5c853d571e2d83fd8629efb21e9b2cad0bdfadd6f32c2c5b42e38d7888 +``` + +本机 IM `.env` 写的是 `OA_API_URL=http://127.0.0.1:3010/api/v1`,本机 nest 常在 3000,连本机 IM 请改 3000。生产 systemd:`OA_API_URL=http://127.0.0.1:3010/api/v1`。 + +### 华为 AGC + +- 包名 `com.fengying.oa.mobile`,App ID `118301849` +- 服务端 Client Secret:`85c01c5c853d571e2d83fd8629efb21e9b2cad0bdfadd6f32c2c5b42e38d7888` +- JSON:`apps/native/android/app/agconnect-services.json` +- JSON `client.client_id`:`1993069421077225280` +- JSON `client.client_secret`:`93585E101B911EFF0F99F215050CD3E4297F67CB004E0D8E070AAB1EEDD7E725` +- JSON `client.api_key`:`DgEDAFLpMeUxEsIEiKams+kYWThvAg0ioMuL1rW9BEP4i71BuChEPD7Udg1AbDKbg+ljwD+pBACxktLqHwmUlSxOCwsLHiRAKQDBFw==` +- `cp_id`:`10086000847744494`;`product_id`:`101653523864520415` + +### OPPO Maven + +``` +https://maven.columbus.heytapmobi.com/repository/releases/ +username: nexus +password: c0b08da17e3ec36c3870fed674a0bcb36abc2e23 +``` + +### 腾讯地图 / vivo Manifest + +- `TENCENT_MAP_KEY=L6NBZ-47KLI-OL7G2-UMROZ-XPI3T-SLFC7` +- Manifest vivo:`api_key=b521c805229a00e85a53d4b535a0cafe`,`app_id=106114371` + +### 本机演示账号 + +`admin` / `Admin@123456`。种子其他人常见 `Demo@123456` 或导入后 `123456`。不要拿去撞生产。 + +--- + +## 15. 发生产清单 + +1. `npx nest build`,同步 `apps/api/dist/` +2. 若改了 Web:`pnpm --filter web build`,同步 `apps/web/dist/` +3. 同步 `server.js`、APK、`app-release.json` +4. `systemctl restart fengying-api fengying-im` +5. 后台确认手机端升级为当前最新 build(现为 318) +6. 真机覆盖安装当前最新版(现为 3.1.8) + +**APK 保留规则:**云主机只保留当前 OTA 指向的最新安装包(对外统一为 `apps/web/dist/fengying-oa.apk`)。每次发版先精确列出 `/data/fengying-oa` 下的 `.apk` / `.aab`,确认新包可下载后删除其他旧包,不要在 `apps/native/dist` 等目录留历史副本。 + +API/IM/APK 是否为同一版必须分别核对,不能只看网页或 OTA JSON。 + +--- + +## 16. 已知坑 / 未完成 + +1. 生产 API 已同步 2026-09-01 的数据修复代码;Web、IM、APK 后续每次仍要分别核对版本和构建时间。 +2. 华为正式推送:缺正式签名 + AGConnect 插件未进构建。 +3. `OUT` 中文:人事外出 vs 印章外带。 +4. 系统设置「消息通道」文案还写「本期不做原生 IM」,已过时。 +5. Web IM 与手机 IM 都可用,但通话依赖双方打开页 + STUN,失败别先改来电弹层。 +6. 手机工作台对报表/部分系统页映射为 `null`,点了可能没页。 +7. 打开 docx 依赖手机已装 WPS/Office。 +8. 历史投标主体不会自动拆到软件/工程公司。 +9. `apps/native/README.md`、部分 api README 版本号过时,以 `app_config.dart` 和本文件为准。 +10. 薪酬/绩效/资源看板有页面,深度以现网数据为准,缺的是补流程不是再铺空壳。 +11. 问题视频:`/opt/问题视频/`。来电测试对象常为「戴永康」。 + +--- + +## 17. 建议接着做的顺序 + +1. 用第 14 节发生产(API + Web + IM + APK + OTA),不要再问密码。 +2. 真机覆盖 2.3.2:来电能关、消息上屏、图片/地图能点、印章「在库」。 +3. 网页重新登录:确认扫码登电脑、Web 互踢、侧栏「落标对照」。 +4. 再改业务:遵守第 3 节办公规则;改投标走 `bid-workflow.ts`,不要只改一个列表页。 +5. 英文残留:补 `labels.ts` / `labels.dart`。 +6. 华为推送真机(正式签名)。 +7. 用户新需求再开功能,不要平行重构目录。 + +改完菜单同步 `sync-menus.ts`。改完枚举两边 labels 一起改。改完版本四个文件一起改。 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..efb4f40 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +up: + docker compose up -d + +migrate: + cd apps/api && pnpm exec prisma migrate dev --name init + +seed: + cd apps/api && pnpm prisma:seed + +api: + cd apps/api && pnpm start:dev + +web: + cd apps/web && pnpm dev diff --git a/README.md b/README.md new file mode 100644 index 0000000..7830992 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# 企业综合运营管理平台 + +内部使用。先上 Web,手机和桌面端以后再说。 + +## 启动 + +```bash +cp .env.example .env +docker compose up -d +cd apps/api && pnpm exec prisma migrate dev --name init && pnpm prisma:seed && pnpm start:dev +# 另一个终端 +cd apps/web && pnpm dev +``` + +打开 http://localhost:5173 ,账号 `admin` / `Admin@123456`。 + +Swagger:http://localhost:3000/api/v1/docs + +## 模块 + +个人办公、往来单位、证照用章、投标管理、合同管理、项目管理、财务收支、组织人事、经营报表、系统设置。 + +投标筛选可以新建。其余页面已接通列表接口,数据会随使用增加。 diff --git a/WINDOWS_BUILD_3.1.4.md b/WINDOWS_BUILD_3.1.4.md new file mode 100644 index 0000000..e89b911 --- /dev/null +++ b/WINDOWS_BUILD_3.1.4.md @@ -0,0 +1,13 @@ +# 风影办公 Windows 3.1.7 构建 + +在 Windows PowerShell 中解压源码后执行: + +```powershell +flutter config --enable-windows-desktop +flutter pub get +flutter build windows --release +``` + +产物目录:`build\windows\x64\runner\Release\`。发布时请连同该目录内 DLL 和 `data` 目录一起分发,不能只复制 exe。 + +本版本的 Android 与 Windows 共用 Flutter 业务代码,包含通话状态同步和工作台业务卡片转发功能。 diff --git a/WINDOWS_BUILD_3.1.7.md b/WINDOWS_BUILD_3.1.7.md new file mode 100644 index 0000000..2d9254b --- /dev/null +++ b/WINDOWS_BUILD_3.1.7.md @@ -0,0 +1,11 @@ +# 风影办公 Windows 3.1.7 构建 + +在 Windows PowerShell 中解压源码后执行: + +```powershell +flutter config --enable-windows-desktop +flutter pub get +flutter build windows --release +``` + +产物目录:`build\\windows\\x64\\runner\\Release\\`。发布时请连同该目录内 DLL 和 `data` 目录一起分发,不能只复制 exe。 diff --git a/agconnect-services.json b/agconnect-services.json new file mode 100644 index 0000000..d57482c --- /dev/null +++ b/agconnect-services.json @@ -0,0 +1 @@ +{"agcgw":{"url":"connect-drcn.dbankcloud.cn","backurl":"connect-drcn.hispace.hicloud.com","websocketurl":"connect-ws-drcn.hispace.dbankcloud.cn","websocketbackurl":"connect-ws-drcn.hispace.dbankcloud.com"},"agcgw_all":{"SG":"connect-dra.dbankcloud.cn","SG_back":"connect-dra.hispace.hicloud.com","CN":"connect-drcn.dbankcloud.cn","CN_back":"connect-drcn.hispace.hicloud.com","RU":"connect-drru.hispace.dbankcloud.ru","RU_back":"connect-drru.hispace.dbankcloud.cn","DE":"connect-dre.dbankcloud.cn","DE_back":"connect-dre.hispace.hicloud.com"},"websocketgw_all":{"SG":"connect-ws-dra.hispace.dbankcloud.cn","SG_back":"connect-ws-dra.hispace.dbankcloud.com","CN":"connect-ws-drcn.hispace.dbankcloud.cn","CN_back":"connect-ws-drcn.hispace.dbankcloud.com","RU":"connect-ws-drru.hispace.dbankcloud.ru","RU_back":"connect-ws-drru.hispace.dbankcloud.cn","DE":"connect-ws-dre.hispace.dbankcloud.cn","DE_back":"connect-ws-dre.hispace.dbankcloud.com"},"client":{"cp_id":"10086000847744494","product_id":"101653523864520415","client_id":"1993069421077225280","client_secret":"93585E101B911EFF0F99F215050CD3E4297F67CB004E0D8E070AAB1EEDD7E725","project_id":"101653523864520415","app_id":"118301849","api_key":"DgEDAFLpMeUxEsIEiKams+kYWThvAg0ioMuL1rW9BEP4i71BuChEPD7Udg1AbDKbg+ljwD+pBACxktLqHwmUlSxOCwsLHiRAKQDBFw==","package_name":"com.fengying.oa.mobile"},"oauth_client":{"client_id":"118301849","client_type":1},"app_info":{"app_id":"118301849","package_name":"com.fengying.oa.mobile"},"service":{"analytics":{"collector_url":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_cn":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_de":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn","collector_url_ru":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com","collector_url_sg":"datacollector-dra.dt.hicloud.com,datacollector-dra.dt.dbankcloud.cn","resource_id":"p1","channel_id":""},"ml":{"mlservice_url":"ml-api-drcn.ai.dbankcloud.com,ml-api-drcn.ai.dbankcloud.cn"},"cloudstorage":{"storage_url":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg":"https://ops-dra.agcstorage.link","storage_url_de":"https://ops-dre.agcstorage.link","storage_url_cn":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru_back":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg_back":"https://agc-storage-dra.cloud.huawei.asia","storage_url_de_back":"https://agc-storage-dre.cloud.huawei.eu","storage_url_cn_back":"https://agc-storage-drcn.cloud.huawei.com.cn"},"search":{"url":"https://search-drcn.cloud.huawei.com"},"edukit":{"edu_url":"edukit.cloud.huawei.com.cn","dh_url":"edukit.cloud.huawei.com.cn"}},"region":"CN","configuration_version":"3.0","appInfos":[{"package_name":"com.fengying.oa.mobile","client":{"app_id":"118301849"},"oauth_client":{"client_id":"118301849","client_type":1},"app_info":{"app_id":"118301849","package_name":"com.fengying.oa.mobile"}}]} \ No newline at end of file diff --git a/apps/api/.prettierrc b/apps/api/.prettierrc new file mode 100644 index 0000000..a20502b --- /dev/null +++ b/apps/api/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..d375c6c --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,47 @@ +# 企业综合运营管理平台 + +内部运营系统。当前先交付 **Web 端**,手机端和桌面端后续用同一套 API 再做。 + +## 技术栈 + +- 后端:NestJS 11 + Prisma 6 + PostgreSQL 16 +- 前端:React 18 + Vite 5 + Ant Design 5 +- 依赖:Redis、MinIO(本地 Docker) + +## 目录 + +``` +apps/api 后端 +apps/web Web +infra/ 预留脚本 +docs/ 架构说明 +``` + +## 启动 + +```bash +cp .env.example .env +docker compose up -d +pnpm --filter api exec prisma migrate dev --name init +pnpm --filter api prisma:seed +pnpm --filter api start:dev +pnpm --filter web dev +``` + +- Web:http://localhost:5173 +- API:http://localhost:3000/api/v1 +- Swagger:http://localhost:3000/api/v1/docs + +演示账号:`admin` / `Admin@123456` +其他人:`owner` `bizdir` `bizstaff` `dirsoft` `dir3d` `dirfilm` `dirmaterial` `finance` `hr` `employee`,密码均为 `Demo@123456` + +## 测试 + +```bash +pnpm --filter api test +pnpm --filter api test:e2e +``` + +## 范围 + +本期把十个一级模块和二级菜单全部铺到 Web 上,登录、权限、投标筛选可写。合同、工时、薪酬等以台账和空数据为主,后续按模块补流程。不含外包、线索、仓库,也不做原生 IM。 diff --git a/apps/api/data/app-release.json b/apps/api/data/app-release.json new file mode 100644 index 0000000..8950118 --- /dev/null +++ b/apps/api/data/app-release.json @@ -0,0 +1,12 @@ +{ + "platform": "android", + "version": "3.1.10", + "build": 324, + "url": "https://oa.fysxkj.com/fengying-oa.apk", + "windowsUrl": "https://oa.fysxkj.com/fengying-oa-setup.exe", + "linuxUrl": "https://oa.fysxkj.com/fengying-oa-linux.tar.gz", + "linuxDebUrl": "https://oa.fysxkj.com/fengying-oa_amd64.deb", + "changelog": "桌面端功能补全:语音播放/录制/转写、位置/转发/编辑、工作台模块路由、Linux deb 安装包(Ubuntu/Debian/Deepin25)、OTA 修复。", + "force": false, + "publishedAt": "2026-09-02T07:15:00.000Z" +} diff --git a/apps/api/eslint.config.mjs b/apps/api/eslint.config.mjs new file mode 100644 index 0000000..4e9f827 --- /dev/null +++ b/apps/api/eslint.config.mjs @@ -0,0 +1,35 @@ +// @ts-check +import eslint from '@eslint/js'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['eslint.config.mjs'], + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + eslintPluginPrettierRecommended, + { + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + }, + sourceType: 'commonjs', + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-unsafe-argument': 'warn', + "prettier/prettier": ["error", { endOfLine: "auto" }], + }, + }, +); diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..c659c77 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,110 @@ +{ + "name": "api", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:seed": "ts-node --transpile-only prisma/seed.ts" + }, + "dependencies": { + "@alicloud/dysmsapi20170525": "^4.6.0", + "@alicloud/openapi-client": "^0.4.15", + "@alicloud/openapi-core": "^1.0.8", + "@alicloud/tea-util": "^1.4.11", + "@darabonba/typescript": "^1.0.5", + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.0", + "@nestjs/mapped-types": "^2.1.0", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.2.1", + "@nestjs/terminus": "^11.1.1", + "@nestjs/throttler": "^6.5.0", + "@prisma/client": "^6.19.0", + "argon2": "^0.45.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "helmet": "^8.3.0", + "ioredis": "^6.0.0", + "livekit-server-sdk": "^2.18.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "zod": "^3.24.2" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/node": "^24.0.0", + "@types/passport": "^1.0.17", + "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^7.0.0", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^17.0.0", + "jest": "^30.0.0", + "prettier": "^3.4.2", + "prisma": "6.19.0", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "argon2", + "@prisma/client", + "prisma", + "@prisma/engines" + ] + }, + "prisma": { + "seed": "ts-node --transpile-only prisma/seed.ts" + } +} diff --git a/apps/api/pnpm-lock.yaml b/apps/api/pnpm-lock.yaml new file mode 100644 index 0000000..4efa1c0 --- /dev/null +++ b/apps/api/pnpm-lock.yaml @@ -0,0 +1,7234 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@nestjs/common': + specifier: ^11.0.1 + version: 11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.2 + version: 4.0.2(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.0.1 + version: 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': + specifier: ^11.0.0 + version: 11.0.0(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/mapped-types': + specifier: ^2.1.0 + version: 2.1.0(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + '@nestjs/passport': + specifier: ^11.0.5 + version: 11.0.5(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/platform-express': + specifier: ^11.0.1 + version: 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3) + '@nestjs/swagger': + specifier: ^11.2.1 + version: 11.2.1(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + '@nestjs/terminus': + specifier: ^11.1.1 + version: 11.1.1(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)(@prisma/client@6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/throttler': + specifier: ^6.5.0 + version: 6.5.0(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)(reflect-metadata@0.2.2) + '@prisma/client': + specifier: ^6.19.0 + version: 6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3) + argon2: + specifier: ^0.45.1 + version: 0.45.1 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.1 + version: 0.14.1 + helmet: + specifier: ^8.3.0 + version: 8.3.0 + ioredis: + specifier: ^6.0.0 + version: 6.0.0 + passport: + specifier: ^0.7.0 + version: 0.7.0 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + zod: + specifier: ^3.24.2 + version: 3.24.2 + devDependencies: + '@eslint/eslintrc': + specifier: ^3.2.0 + version: 3.3.6 + '@eslint/js': + specifier: ^9.18.0 + version: 9.39.5 + '@nestjs/cli': + specifier: ^11.0.0 + version: 11.0.24(@types/node@24.13.3)(prettier@3.9.6) + '@nestjs/schematics': + specifier: ^11.0.0 + version: 11.1.0(chokidar@4.0.3)(prettier@3.9.6)(typescript@5.9.3) + '@nestjs/testing': + specifier: ^11.0.1 + version: 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)(@nestjs/platform-express@11.2.3) + '@types/express': + specifier: ^5.0.0 + version: 5.0.6 + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + '@types/passport': + specifier: ^1.0.17 + version: 1.0.17 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 + '@types/supertest': + specifier: ^7.0.0 + version: 7.2.1 + eslint: + specifier: ^9.18.0 + version: 9.39.5(jiti@2.7.0) + eslint-config-prettier: + specifier: ^10.0.1 + version: 10.1.8(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-prettier: + specifier: ^5.2.2 + version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.6) + globals: + specifier: ^17.0.0 + version: 17.11.0 + jest: + specifier: ^30.0.0 + version: 30.5.0(@types/node@24.13.3)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)) + prettier: + specifier: ^3.4.2 + version: 3.9.6 + prisma: + specifier: 6.19.0 + version: 6.19.0(typescript@5.9.3) + source-map-support: + specifier: ^0.5.21 + version: 0.5.21 + supertest: + specifier: ^7.0.0 + version: 7.2.2 + ts-jest: + specifier: ^29.2.5 + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.0)(@jest/types@30.5.0)(babel-jest@30.5.0(@babel/core@7.29.7))(jest-util@30.5.0)(jest@30.5.0(@types/node@24.13.3)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)))(typescript@5.9.3) + ts-loader: + specifier: ^9.5.2 + version: 9.6.2(typescript@5.9.3)(webpack@5.106.2) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@24.13.3)(typescript@5.9.3) + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.20.0 + version: 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + +packages: + + '@angular-devkit/core@19.2.24': + resolution: {integrity: sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/core@19.2.27': + resolution: {integrity: sha512-3amNzoCVSKd7ah6l6lBQL4onwwJvqvam7FMoQBILrxtW5LB5ezh8gMSPuA4zJjKjoRzf9uoWdlzqv/84I52xZA==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/schematics-cli@19.2.27': + resolution: {integrity: sha512-wHYH6SVXVykhLzovUHtYor3Nl4SpIiITi7r9DQDaKYUD4hpRBx25W6N9eGuakT9Vd5tV/x6wmvQFWQZQwFB7eA==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + hasBin: true + + '@angular-devkit/schematics@19.2.24': + resolution: {integrity: sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@angular-devkit/schematics@19.2.27': + resolution: {integrity: sha512-/PZmyAlb2NGWPikRRuiWLdfHQd8Wrx6lX4HqvTcaDhlU43M3T0ud4PH2T3QDp7BzHYY92xtD8iPxX2asg67G1A==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@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-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + 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'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.3.2': + resolution: {integrity: sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@ioredis/commands@2.0.0': + resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/console@30.5.0': + resolution: {integrity: sha512-BI1DpOedrJqbrYVi9yNhDWGjqphR/+gsM4STmg2+VaeXm7851hvpBDyKOZGMXATTrGEnFuEseQvLCtrrRmG0GQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/core@30.5.0': + resolution: {integrity: sha512-DLjRME+NY//j+UDTSfWnjoP0srrdR3DrJRy7yFZktGIwzpN2iVy2vMo0jziZ5c2Ij7bOwlJRXKVWtxZusazOJg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/diff-sequences@30.5.0': + resolution: {integrity: sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@30.5.0': + resolution: {integrity: sha512-HUaqexIauIh69IQ4NTuPDEUCB8g8T4TOPSIzQOS18mwI/KEHKQk1j013K2o6ra031szZE2t5jGmVx3xbzdjgKA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@30.5.0': + resolution: {integrity: sha512-5j0ztPxSy3McUJihjkDdCyCfjvT2hxykFTWsgEBZKB8qsw9ALdCiGTpTRH5gnf/d+qI4SflYUJ0dWNbzjQCWbA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect@30.5.0': + resolution: {integrity: sha512-jEmgmgJEobJ3zEhDOGp1VAJ6JkoVelpS8uZ1ae1Ul/5lP78UKJKmmU0lciJwd6JdnqOXHaaS/QCKwbf1dHI9MA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/fake-timers@30.5.0': + resolution: {integrity: sha512-sg8xIbYwe5GdB/vT3/0qrDIpO7Ov9mazHi++M95uynmDKEZ70G1r169AWct73H07VrTZhrz1SJEfLtjYv8tE3A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/get-type@30.5.0': + resolution: {integrity: sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@30.5.0': + resolution: {integrity: sha512-h7eJx534czwL8lQMYB0hwLT4/HquO8EX/RtYL7RNUHyUyWWVciYjoudN4Ns5JmNvn2/jh0Vm9UstZjEzJJ5EsQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.5.0': + resolution: {integrity: sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/reporters@30.5.0': + resolution: {integrity: sha512-FEAuusWm+PUOn9ydjaHhpOpyPmS6IbGE04HaKTuUI4zd8eqYZiXiNLoCsdCog/l2XnNj53E9pFy64UltcvfJKg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@30.5.0': + resolution: {integrity: sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/snapshot-utils@30.5.0': + resolution: {integrity: sha512-iWQtIsi2dRsO2oWzVceOeynuRJiYTW8gsDVp5wFQ02ipHluQsNgBpasWSHiawVxukIlsGLdCtCSbIEK7fPtpvQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@30.5.0': + resolution: {integrity: sha512-xWpTJP9D0bDFGbPGT8XuWSwwha/iHADyyKzUnMx4UbdgnHugxrDaQFO4RZ8x4ZsFzRP6pNii8uvlgKCDxCIuDg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-result@30.5.0': + resolution: {integrity: sha512-9IlPqUzUMkVDmoDqSSrVVLroVotgN3hTUPWPwq24XWXhh1Zpg915RXZ5pgRJ/7j4YE/kng5nqjSn4p3S68SZJA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-sequencer@30.5.0': + resolution: {integrity: sha512-TXlvSDIVv482b83hD8A8WtxDJEmGF47f60W6jRXOQL0Isfs3hKtk4rZIm9R/jLzAibg8+c56y+Q00BQs8R7Xcg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/transform@30.5.0': + resolution: {integrity: sha512-n1cYhoByyULEIXi64wbT4Lq91qeT1E6bwpM//sprFXhw955qaiHTdAmy1c1rNFGB6fCf1J+nxDUSf3RGwgZP5A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/types@30.5.0': + resolution: {integrity: sha512-s1N+79S4Yp9ZgklCauZXi+YPJdCdtStNYQT32stuD6EeQaIBGHoUfyj2P0YWy8RmuQfaJboO+ulxEvEheR/POQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@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/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@microsoft/tsdoc@0.15.1': + resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@nestjs/cli@11.0.24': + resolution: {integrity: sha512-aIHxQLSYtXShifA3zwWIeznEsZnNa3Iz2QRykFj+sl9IcbERBHr5nH87FRgywM+He3NxoF5WazHfR8FsmVeWxw==} + engines: {node: '>= 20.11'} + hasBin: true + peerDependencies: + '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0 + '@swc/core': ^1.3.62 + peerDependenciesMeta: + '@swc/cli': + optional: true + '@swc/core': + optional: true + + '@nestjs/common@11.2.3': + resolution: {integrity: sha512-obdauJXHfthhepbV+LpGe88OeBlR/Kw9lwjLo0Utzc//agoLXYb9DUGhPQWtm81IpBWMv+19eiwcve9MsBZwXA==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/config@4.0.2': + resolution: {integrity: sha512-McMW6EXtpc8+CwTUwFdg6h7dYcBUpH5iUILCclAsa+MbCEvC9ZKu4dCHRlJqALuhjLw97pbQu62l4+wRwGeZqA==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + + '@nestjs/core@11.2.3': + resolution: {integrity: sha512-vkA9/Ja0Z3hvqXErSa+HaxrfF+cNXthNFi8VPNEKVli4rMd009yExAl0gLmko/Kf8peDXr72u1RN+j9Da2ukHg==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/jwt@11.0.0': + resolution: {integrity: sha512-v7YRsW3Xi8HNTsO+jeHSEEqelX37TVWgwt+BcxtkG/OfXJEOs6GZdbdza200d6KqId1pJQZ6UPj1F0M6E+mxaA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + + '@nestjs/mapped-types@2.1.0': + resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/passport@11.0.5': + resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + passport: ^0.5.0 || ^0.6.0 || ^0.7.0 + + '@nestjs/platform-express@11.2.3': + resolution: {integrity: sha512-YFQvRXT2de1qNL9LJPUBQ31+RsfI4cJ+sbpU9ENM/hDCgoHSEhm7oxUuGGKmhTZBNZEYm8mDYdfoTFmAH1LIJg==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + + '@nestjs/schematics@11.1.0': + resolution: {integrity: sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==} + peerDependencies: + prettier: ^3.0.0 + typescript: '>=4.8.2' + peerDependenciesMeta: + prettier: + optional: true + + '@nestjs/swagger@11.2.1': + resolution: {integrity: sha512-1MS7xf0pzc1mofG53xrrtrurnziafPUHkqzRm4YUVPA/egeiMaSerQBD/feiAeQ2BnX0WiLsTX4HQFO0icvOjQ==} + peerDependencies: + '@fastify/static': ^8.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/terminus@11.1.1': + resolution: {integrity: sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==} + peerDependencies: + '@grpc/grpc-js': '*' + '@grpc/proto-loader': '*' + '@mikro-orm/core': '*' + '@mikro-orm/nestjs': '*' + '@nestjs/axios': ^2.0.0 || ^3.0.0 || ^4.0.0 + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/microservices': ^10.0.0 || ^11.0.0 + '@nestjs/mongoose': ^11.0.0 + '@nestjs/sequelize': ^10.0.0 || ^11.0.0 + '@nestjs/typeorm': ^10.0.0 || ^11.0.0 + '@prisma/client': '*' + mongoose: '*' + reflect-metadata: 0.1.x || 0.2.x + rxjs: 7.x + sequelize: '*' + typeorm: '*' + peerDependenciesMeta: + '@grpc/grpc-js': + optional: true + '@grpc/proto-loader': + optional: true + '@mikro-orm/core': + optional: true + '@mikro-orm/nestjs': + optional: true + '@nestjs/axios': + optional: true + '@nestjs/microservices': + optional: true + '@nestjs/mongoose': + optional: true + '@nestjs/sequelize': + optional: true + '@nestjs/typeorm': + optional: true + '@prisma/client': + optional: true + mongoose: + optional: true + sequelize: + optional: true + typeorm: + optional: true + + '@nestjs/testing@11.2.3': + resolution: {integrity: sha512-7ANDWlkm8Xw4CYIhCNZhtBzANsQUKqjteA2yx/6sjqGyWhekeBKz8wgCJykm0vo+ltrg6U34dZlm2NgiRcNHPQ==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + + '@nestjs/throttler@6.5.0': + resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} + engines: {node: '>= 10.0.0'} + + '@phc/format@1.0.0': + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@pkgr/core@0.3.6': + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} + + '@prisma/client@6.19.0': + resolution: {integrity: sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@6.19.0': + resolution: {integrity: sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==} + + '@prisma/debug@6.19.0': + resolution: {integrity: sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==} + + '@prisma/engines-version@6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773': + resolution: {integrity: sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==} + + '@prisma/engines@6.19.0': + resolution: {integrity: sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==} + + '@prisma/fetch-engine@6.19.0': + resolution: {integrity: sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==} + + '@prisma/get-platform@6.19.0': + resolution: {integrity: sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==} + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@sinclair/typebox@0.34.52': + resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@15.4.0': + resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@tsconfig/node10@1.0.13': + resolution: {integrity: sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@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/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/express-serve-static-core@5.1.3': + resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@30.0.0': + resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/jsonwebtoken@9.0.7': + resolution: {integrity: sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/passport-jwt@4.0.1': + resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + + '@types/passport-strategy@0.2.38': + resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} + + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/superagent@8.1.11': + resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} + + '@types/supertest@7.2.1': + resolution: {integrity: sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==} + + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@typescript-eslint/eslint-plugin@8.68.0': + resolution: {integrity: sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.68.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.68.0': + resolution: {integrity: sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.68.0': + resolution: {integrity: sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.68.0': + resolution: {integrity: sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.68.0': + resolution: {integrity: sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.68.0': + resolution: {integrity: sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.68.0': + resolution: {integrity: sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.68.0': + resolution: {integrity: sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.68.0': + resolution: {integrity: sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.68.0': + resolution: {integrity: sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.12.2': + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.12.2': + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} + cpu: [x64] + os: [win32] + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-import-phases@1.0.4: + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} + peerDependencies: + acorn: ^8.14.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + argon2@0.45.1: + resolution: {integrity: sha512-skm+/WCjkGqCQxF7FG1LuZXM5yvbFjgbfiCGsud2oLgaDhh6b6dbH0b1EkghbM+xx4Bj8Ape+KKgixoIlWZicQ==} + engines: {node: '>=16.17.0'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + babel-jest@30.5.0: + resolution: {integrity: sha512-PrhPHlKC+MsLnuNzgIH/y1dkz1f6cSfKWaQeaG8WxLMuG44dYWQ8E9uRrsBbAGCU/3+BEFYPN4d6G3Zc5Y+waA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-0 + + babel-plugin-istanbul@8.0.0: + resolution: {integrity: sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==} + engines: {node: '>=18'} + + babel-plugin-jest-hoist@30.5.0: + resolution: {integrity: sha512-gtGo1B+u14jrZQv6TdSWIWkTqclboo7Qn+dFAGUOIuXLFuJKAdg3U5MIWzZnW4vfUb4dX9skmAMiby86e/SF4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@30.5.0: + resolution: {integrity: sha512-ZGPn5ClP4lBDpuOK8W1yQIOy359HmbnZv3suucAlIe+SEE9yDsxV4S2PjSbH2Vc97U+WmzYD7vw3kr8NsQ/i6w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-beta.1 || ^8.0.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} + engines: {node: '>=6.0.0'} + hasBin: true + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + 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'} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + + check-disk-space@3.4.0: + resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==} + engines: {node: '>=16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + + cjs-module-lexer@2.2.1: + resolution: {integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==} + + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.14.1: + resolution: {integrity: sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==} + + cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + 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==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + comment-json@5.0.0: + resolution: {integrity: sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==} + engines: {node: '>= 6'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + diff@4.0.4: + resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} + engines: {node: '>=0.3.1'} + + dotenv-expand@12.0.1: + resolution: {integrity: sha512-LaKRbou8gt0RNID/9RoI+J2rvXsBRPMV7p+ElHlPhcSARbCPDYcYG2s1TIzAfWv4YSgyY5taidWzzs31lNV3yQ==} + engines: {node: '>=12'} + + dotenv@16.4.7: + resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + effect@3.18.4: + resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + + electron-to-chromium@1.5.416: + resolution: {integrity: sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + 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@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-prettier@5.5.6: + resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit-x@0.2.2: + resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} + engines: {node: '>= 0.8.0'} + + expect@30.5.0: + resolution: {integrity: sha512-8fiMWcEjPU7B9nErC4FtFcCzf2tC6I75Qf7m8wzBAWC2taZmcno3yAFEjIQL34SwoGZNgPf63UDiJLyh4SMPaw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fork-ts-checker-webpack-plugin@9.1.0: + resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} + engines: {node: '>=14.21.3'} + peerDependencies: + typescript: '>3.6.0' + webpack: ^5.11.0 + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-monkey@1.1.0: + resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + 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.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@17.11.0: + resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + helmet@8.3.0: + resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} + engines: {node: '>=18.0.0'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ioredis@6.0.0: + resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==} + engines: {node: '>=20.0.0'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jest-changed-files@30.5.0: + resolution: {integrity: sha512-dq1x8JiEnHkJDxxOrF6UJDivBRAQMwAa5tzr+VX3um0SfyMFseUPQUbe51wLwggfoa5h/EmOtSpdJxxkzSlNRQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-circus@30.5.0: + resolution: {integrity: sha512-T3v7uM4wwCu+RQicjsAWCgoL3CiyuX3THSKwv2uMb9N2bMUgb+HfwDWEUma85vOGbvQNQ/YfoCXF1OCZot0ZEw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-cli@30.5.0: + resolution: {integrity: sha512-QHZMiy32x2K+NzJ1AuuoCAVc1Y5co0VXib3R7kD9MWcWFflzsD1eJN0wR+mkNlM+ts7C+Bjz0oOoFE5IiHylCg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@30.5.0: + resolution: {integrity: sha512-gYQl2FqYgiVpyuB7DutBIbJRWaq5VcHdzOXJJ51HNa8J5JrsZehIlzNFW0/9s5uvvcbzM5/LTUizYSbsFErv+w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@types/node': '*' + esbuild-register: '>=3.4.0' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + + jest-diff@30.5.0: + resolution: {integrity: sha512-QjCfDMwdPFvLxTQmS4/Dswx3PUCiqmSXVLGljMC3SU7YG1qHVoR6b86IH/O2G9k9OMyKXz2vS2Q60VnAozNDwA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@30.5.0: + resolution: {integrity: sha512-NwDqcxtoZi33RhuW+zJS/RVA3rmheQ8BnwpYZuc/Eruaz6seQb7+aoCeDZu/3X7W2XmD8DbSo9Pn72DbKsBFYw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-each@30.5.0: + resolution: {integrity: sha512-NiMFNhRygJEFqYNt8pnkxppUF6CR486GEpt9rSU6lPBf7KeccaOL0zbjxG8fgJgD//6e7zYdssnlt6j+1kI31A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-environment-node@30.5.0: + resolution: {integrity: sha512-bTc79ywKLz0ogbT3JIYuEhgWV4Ffd/cJe06co4v8CyRtlmju8x5gokMlGFR8ARWhmk5SnbDRxmf50XWnlZVhDg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-haste-map@30.5.0: + resolution: {integrity: sha512-0FStogBslBVOEqTOJr4oXMtFitmrWp9WscG6Gbns88i0YAuMXijCT2G5VMfg/HCR4QAnL+OF2C2ednag+HlDuA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-leak-detector@30.5.0: + resolution: {integrity: sha512-Mq11ceAkNR250Iv45RoOwuG9fb4kYbJ02qoyL7A0nCI8FV5+aG/THmcEUx5uR2dNSa4KOtz+xBKrJ8cZPhPpuQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-matcher-utils@30.5.0: + resolution: {integrity: sha512-EfaYMC9f9ds7fahB/LYFTgd1Z2RS9Vpm2e46gazij0onkpoQG7Daq+MLm8/gQVqWwRVjL/RNDggbFx9MsrJEmQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@30.5.0: + resolution: {integrity: sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-mock@30.5.0: + resolution: {integrity: sha512-bP5MHZpkYrV7xpV+yvhl36DPcXoEmTR57Un5EACcdVpMY7mpkDefCBq+V4mhcjE/3rwUajT6OTrcJTN7EwN1BA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-regex-util@30.5.0: + resolution: {integrity: sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve-dependencies@30.5.0: + resolution: {integrity: sha512-TnSBAp3wGOnqXBmLT3OXtJkugw6Vj2KU0IPde2EJmbGns/QMoNlkauJ7jZ8KYaxONSpx0o4pUvi+u1Q/LSk3Pg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve@30.5.0: + resolution: {integrity: sha512-NFvQWJ4G7e2kN5712iG+12Xr325NRFGAIhovrwLfgq5Oqd4bFHITw4CzcFkZGp1GTCqemC4v1l8/yZzedKZkjw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runner@30.5.0: + resolution: {integrity: sha512-Q6Yt+1LvXvEstvru6sQLT7OQYC77VNl6dK0KEvBkeOHgThmXDqNp3Ox9TglDWFHU85pemqTNdKwxfnmO3vgrdA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runtime@30.5.0: + resolution: {integrity: sha512-VTRz0sRIw2EISeHigx1O+CMuwoG4+RKJjF8dp8okzFaDNQEcv5Mw0h5QW8VJXgb2CRF4gZIjSEBb2jdzXPagFQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-snapshot@30.5.0: + resolution: {integrity: sha512-pZWETdcqmKve9MDTE/AX6RaeAbRhzKhhAXGozAW8Pg2FfOSdUrQ/7D+VdwE3fLQsqjpITXJVQvYVZoMEzrUl0Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-util@30.5.0: + resolution: {integrity: sha512-lzU4aGUWaS+2X/B0CmgheDasfnsVlRfZh/rNQxB9b9s8cSYUq5BcqdQA95ld+KqJXBUVVt1sqnMQ2T3OxIalmg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-validate@30.5.0: + resolution: {integrity: sha512-N/hsPYKgBSzBeVZ2RHCs3yvBbTZNPX7Be8q33zhyo/yeFneBL1swzly31LYU4LJ3zJM9e8TxSM8IYLo2SDJZYQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-watcher@30.5.0: + resolution: {integrity: sha512-ujjnEoL4Uu+Swu3WRwYenWMB9JMEiD2T3OypnveMJ64S/1r/5G8eC4OQ1wEOgYWQqMi+mT+buzccflCHr/XJ9Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jest-worker@30.5.0: + resolution: {integrity: sha512-7kFk/607EoynNHLJa20daivkElM+c9PrCLduYy6AlMkYrXbh5TmVtW1BLXE05Y8baAFsJHMoC3xs2QRRTotwLw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest@30.5.0: + resolution: {integrity: sha512-HeFeOUEKh5gjnp1rjuSCse8Dhj0Y3KA8lsZ3azr4Wnq1nCxwMBu35MDc9mp8iblZxmeAz6wV4P241tyUB7J2Ew==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.15.2: + resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@3.2.3: + resolution: {integrity: sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + libphonenumber-js@1.13.12: + resolution: {integrity: sha512-uLVeV1c9OTk6qkdqnj+mpMD+ZdnZ0szVyWu58HwMmpwkHA1gCEkyjd3veZQXDnuw9KEwSRjcc9B1pS9XKIN1fA==} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + + loader-runner@4.3.2: + resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} + engines: {node: '>=6.11.5'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + 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==} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + memfs@3.5.3: + resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} + engines: {node: '>= 4.0.0'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multer@2.2.0: + resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + engines: {node: '>= 10.16.0'} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} + engines: {node: ^18 || ^20 || >= 21} + + node-emoji@1.11.0: + resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + nypm@0.6.9: + resolution: {integrity: sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==} + engines: {node: '>=18'} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + 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==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + passport-jwt@4.0.1: + resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + + passport-strategy@1.0.0: + resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} + engines: {node: '>= 0.4.0'} + + passport@0.7.0: + resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + engines: {node: '>= 0.4.0'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pause@0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} + engines: {node: '>=6.0.0'} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@30.5.0: + resolution: {integrity: sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + prisma@6.19.0: + resolution: {integrity: sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.8: + resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + 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'} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + + 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 + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + 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'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + 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'} + + swagger-ui-dist@5.29.4: + resolution: {integrity: sha512-gJFDz/gyLOCQtWwAgqs6Rk78z9ONnqTnlW11gimG9nLap8drKa3AJBKpzIQMIjl5PD2Ix+Tn+mc/tfoT2tgsng==} + + symbol-observable@4.0.0: + resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} + engines: {node: '>=0.10'} + + synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} + engines: {node: ^14.18.0 || >=16.0.0} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + terser-webpack-plugin@5.6.1: + resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@minify-html/node': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@minify-html/node': + optional: true + '@swc/core': + optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true + uglify-js: + optional: true + + terser@5.51.2: + resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==} + engines: {node: '>=10'} + hasBin: true + + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-jest@29.4.12: + resolution: {integrity: sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <7' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + + ts-loader@9.6.2: + resolution: {integrity: sha512-R4iuczmtgxvtuI556s+hTZ6/7Ee03VCAk/l/M8LY1OAsUgB7YydsCxkgq9D9pKRaD7GJqUi2u8fp9zZP/ufjKA==} + engines: {node: '>=12.0.0'} + peerDependencies: + loader-utils: '*' + typescript: '*' + webpack: ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + loader-utils: + optional: true + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + tsconfig-paths-webpack-plugin@4.2.0: + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} + engines: {node: '>=10.13.0'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript-eslint@8.68.0: + resolution: {integrity: sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + watchpack@2.5.2: + resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} + engines: {node: '>=10.13.0'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + webpack-node-externals@3.0.0: + resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} + engines: {node: '>=6'} + + webpack-sources@3.5.1: + resolution: {integrity: sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==} + engines: {node: '>=10.13.0'} + + webpack@5.106.2: + resolution: {integrity: sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + 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'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + 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@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + zod@3.24.2: + resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + +snapshots: + + '@angular-devkit/core@19.2.24(chokidar@4.0.3)': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + jsonc-parser: 3.3.1 + picomatch: 4.0.4 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/core@19.2.27(chokidar@4.0.3)': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + jsonc-parser: 3.3.1 + picomatch: 4.0.4 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/schematics-cli@19.2.27(@types/node@24.13.3)(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.27(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.27(chokidar@4.0.3) + '@inquirer/prompts': 7.3.2(@types/node@24.13.3) + ansi-colors: 4.1.3 + symbol-observable: 4.0.0 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - '@types/node' + - chokidar + + '@angular-devkit/schematics@19.2.24(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.24(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/schematics@19.2.27(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.27(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@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': + 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) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + 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.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + 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-syntax-async-generators@7.8.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@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': + 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 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@0.2.3': {} + + '@borewit/text-codec@0.2.2': {} + + '@colors/colors@1.5.0': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@epic-web/invariant@1.0.0': {} + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))': + dependencies: + eslint: 9.39.5(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.2 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@24.13.3)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.13.3) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/confirm@5.1.21(@types/node@24.13.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/type': 3.0.10(@types/node@24.13.3) + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/core@10.3.2(@types/node@24.13.3)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.13.3) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/editor@4.2.23(@types/node@24.13.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/external-editor': 1.0.3(@types/node@24.13.3) + '@inquirer/type': 3.0.10(@types/node@24.13.3) + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/expand@4.0.23(@types/node@24.13.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/type': 3.0.10(@types/node@24.13.3) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/external-editor@1.0.3(@types/node@24.13.3)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@24.13.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/type': 3.0.10(@types/node@24.13.3) + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/number@3.0.23(@types/node@24.13.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/type': 3.0.10(@types/node@24.13.3) + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/password@4.0.23(@types/node@24.13.3)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/type': 3.0.10(@types/node@24.13.3) + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/prompts@7.10.1(@types/node@24.13.3)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.13.3) + '@inquirer/confirm': 5.1.21(@types/node@24.13.3) + '@inquirer/editor': 4.2.23(@types/node@24.13.3) + '@inquirer/expand': 4.0.23(@types/node@24.13.3) + '@inquirer/input': 4.3.1(@types/node@24.13.3) + '@inquirer/number': 3.0.23(@types/node@24.13.3) + '@inquirer/password': 4.0.23(@types/node@24.13.3) + '@inquirer/rawlist': 4.1.11(@types/node@24.13.3) + '@inquirer/search': 3.2.2(@types/node@24.13.3) + '@inquirer/select': 4.4.2(@types/node@24.13.3) + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/prompts@7.3.2(@types/node@24.13.3)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.13.3) + '@inquirer/confirm': 5.1.21(@types/node@24.13.3) + '@inquirer/editor': 4.2.23(@types/node@24.13.3) + '@inquirer/expand': 4.0.23(@types/node@24.13.3) + '@inquirer/input': 4.3.1(@types/node@24.13.3) + '@inquirer/number': 3.0.23(@types/node@24.13.3) + '@inquirer/password': 4.0.23(@types/node@24.13.3) + '@inquirer/rawlist': 4.1.11(@types/node@24.13.3) + '@inquirer/search': 3.2.2(@types/node@24.13.3) + '@inquirer/select': 4.4.2(@types/node@24.13.3) + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/rawlist@4.1.11(@types/node@24.13.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/type': 3.0.10(@types/node@24.13.3) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/search@3.2.2(@types/node@24.13.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.13.3) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/select@4.4.2(@types/node@24.13.3)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.13.3) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/type@3.0.10(@types/node@24.13.3)': + optionalDependencies: + '@types/node': 24.13.3 + + '@ioredis/commands@2.0.0': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.15.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jest/console@30.5.0': + dependencies: + '@jest/types': 30.5.0 + '@types/node': 24.13.3 + chalk: 4.1.2 + jest-message-util: 30.5.0 + jest-util: 30.5.0 + slash: 3.0.0 + + '@jest/core@30.5.0(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3))': + dependencies: + '@jest/console': 30.5.0 + '@jest/pattern': 30.5.0 + '@jest/reporters': 30.5.0 + '@jest/test-result': 30.5.0 + '@jest/transform': 30.5.0 + '@jest/types': 30.5.0 + '@types/node': 24.13.3 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.4.0 + exit-x: 0.2.2 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-changed-files: 30.5.0 + jest-config: 30.5.0(@types/node@24.13.3)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)) + jest-haste-map: 30.5.0 + jest-message-util: 30.5.0 + jest-regex-util: 30.5.0 + jest-resolve: 30.5.0 + jest-resolve-dependencies: 30.5.0 + jest-runner: 30.5.0 + jest-runtime: 30.5.0 + jest-snapshot: 30.5.0 + jest-util: 30.5.0 + jest-validate: 30.5.0 + jest-watcher: 30.5.0 + pretty-format: 30.5.0 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + '@jest/diff-sequences@30.5.0': {} + + '@jest/environment@30.5.0': + dependencies: + '@jest/fake-timers': 30.5.0 + '@jest/types': 30.5.0 + '@types/node': 24.13.3 + jest-mock: 30.5.0 + + '@jest/expect-utils@30.5.0': + dependencies: + '@jest/get-type': 30.5.0 + + '@jest/expect@30.5.0': + dependencies: + expect: 30.5.0 + jest-snapshot: 30.5.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@30.5.0': + dependencies: + '@jest/types': 30.5.0 + '@sinonjs/fake-timers': 15.4.0 + '@types/node': 24.13.3 + jest-message-util: 30.5.0 + jest-mock: 30.5.0 + jest-util: 30.5.0 + + '@jest/get-type@30.5.0': {} + + '@jest/globals@30.5.0': + dependencies: + '@jest/environment': 30.5.0 + '@jest/expect': 30.5.0 + '@jest/types': 30.5.0 + jest-mock: 30.5.0 + transitivePeerDependencies: + - supports-color + + '@jest/pattern@30.5.0': + dependencies: + '@types/node': 24.13.3 + jest-regex-util: 30.5.0 + + '@jest/reporters@30.5.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 30.5.0 + '@jest/test-result': 30.5.0 + '@jest/transform': 30.5.0 + '@jest/types': 30.5.0 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 24.13.3 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit-x: 0.2.2 + glob: 13.0.6 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + jest-message-util: 30.5.0 + jest-util: 30.5.0 + jest-worker: 30.5.0 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@30.5.0': + dependencies: + '@sinclair/typebox': 0.34.52 + + '@jest/snapshot-utils@30.5.0': + dependencies: + '@jest/types': 30.5.0 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 + + '@jest/source-map@30.5.0': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + convert-source-map: 2.0.0 + graceful-fs: 4.2.11 + + '@jest/test-result@30.5.0': + dependencies: + '@jest/console': 30.5.0 + '@jest/types': 30.5.0 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@30.5.0': + dependencies: + '@jest/test-result': 30.5.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.5.0 + slash: 3.0.0 + + '@jest/transform@30.5.0': + dependencies: + '@babel/core': 7.29.7 + '@jest/types': 30.5.0 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 8.0.0 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.5.0 + jest-regex-util: 30.5.0 + jest-util: 30.5.0 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + + '@jest/types@30.5.0': + dependencies: + '@jest/pattern': 30.5.0 + '@jest/schemas': 30.5.0 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 24.13.3 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@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/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@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 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@lukeed/csprng@1.1.0': {} + + '@microsoft/tsdoc@0.15.1': {} + + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@nestjs/cli@11.0.24(@types/node@24.13.3)(prettier@3.9.6)': + dependencies: + '@angular-devkit/core': 19.2.27(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.27(chokidar@4.0.3) + '@angular-devkit/schematics-cli': 19.2.27(@types/node@24.13.3)(chokidar@4.0.3) + '@inquirer/prompts': 7.10.1(@types/node@24.13.3) + '@nestjs/schematics': 11.1.0(chokidar@4.0.3)(prettier@3.9.6)(typescript@5.9.3) + ansis: 4.2.0 + chokidar: 4.0.3 + cli-table3: 0.6.5 + commander: 4.1.1 + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.2) + glob: 13.0.6 + node-emoji: 1.11.0 + ora: 5.4.1 + tsconfig-paths: 4.2.0 + tsconfig-paths-webpack-plugin: 4.2.0 + typescript: 5.9.3 + webpack: 5.106.2 + webpack-node-externals: 3.0.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/html' + - '@types/node' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - prettier + - uglify-js + - webpack-cli + + '@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.4 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.1 + transitivePeerDependencies: + - supports-color + + '@nestjs/config@4.0.2(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 16.4.7 + dotenv-expand: 12.0.1 + lodash: 4.17.21 + rxjs: 7.8.2 + + '@nestjs/core@11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3) + + '@nestjs/jwt@11.0.0(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.7 + jsonwebtoken: 9.0.2 + + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.1 + + '@nestjs/passport@11.0.5(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + + '@nestjs/platform-express@11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)': + dependencies: + '@nestjs/common': 11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cors: 2.8.6 + express: 5.2.1 + multer: 2.2.0 + path-to-regexp: 8.4.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@nestjs/schematics@11.1.0(chokidar@4.0.3)(prettier@3.9.6)(typescript@5.9.3)': + dependencies: + '@angular-devkit/core': 19.2.24(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3) + comment-json: 5.0.0 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.9.3 + optionalDependencies: + prettier: 3.9.6 + transitivePeerDependencies: + - chokidar + + '@nestjs/swagger@11.2.1(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.15.1 + '@nestjs/common': 11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + js-yaml: 4.1.0 + lodash: 4.17.21 + path-to-regexp: 8.3.0 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.29.4 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.1 + + '@nestjs/terminus@11.1.1(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)(@prisma/client@6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + boxen: 5.1.2 + check-disk-space: 3.4.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + optionalDependencies: + '@prisma/client': 6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3) + + '@nestjs/testing@11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)(@nestjs/platform-express@11.2.3)': + dependencies: + '@nestjs/common': 11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3) + + '@nestjs/throttler@6.5.0(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + + '@noble/hashes@1.8.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@parcel/watcher-android-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-x64@2.6.0': + optional: true + + '@parcel/watcher-freebsd-x64@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-musl@2.6.0': + optional: true + + '@parcel/watcher-win32-arm64@2.6.0': + optional: true + + '@parcel/watcher-win32-x64@2.6.0': + optional: true + + '@parcel/watcher@2.6.0': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.7 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 + + '@phc/format@1.0.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.3.6': {} + + '@prisma/client@6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)': + optionalDependencies: + prisma: 6.19.0(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@6.19.0': + dependencies: + c12: 3.1.0 + deepmerge-ts: 7.1.5 + effect: 3.18.4 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.19.0': {} + + '@prisma/engines-version@6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773': {} + + '@prisma/engines@6.19.0': + dependencies: + '@prisma/debug': 6.19.0 + '@prisma/engines-version': 6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773 + '@prisma/fetch-engine': 6.19.0 + '@prisma/get-platform': 6.19.0 + + '@prisma/fetch-engine@6.19.0': + dependencies: + '@prisma/debug': 6.19.0 + '@prisma/engines-version': 6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773 + '@prisma/get-platform': 6.19.0 + + '@prisma/get-platform@6.19.0': + dependencies: + '@prisma/debug': 6.19.0 + + '@scarf/scarf@1.4.0': {} + + '@sinclair/typebox@0.34.52': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@15.4.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@standard-schema/spec@1.1.0': {} + + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@tsconfig/node10@1.0.13': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + 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/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.13.3 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.13.3 + + '@types/cookiejar@2.1.5': {} + + '@types/eslint-scope@3.7.7': + dependencies: + '@types/eslint': 9.6.1 + '@types/estree': 1.0.9 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + + '@types/estree@1.0.9': {} + + '@types/express-serve-static-core@5.1.3': + dependencies: + '@types/node': 24.13.3 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.3 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@30.0.0': + dependencies: + expect: 30.5.0 + pretty-format: 30.5.0 + + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.13.3 + + '@types/jsonwebtoken@9.0.7': + dependencies: + '@types/node': 24.13.3 + + '@types/methods@1.1.4': {} + + '@types/ms@2.1.0': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/passport-jwt@4.0.1': + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/passport-strategy': 0.2.38 + + '@types/passport-strategy@0.2.38': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 5.0.6 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 24.13.3 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.13.3 + + '@types/stack-utils@2.0.3': {} + + '@types/superagent@8.1.11': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 24.13.3 + form-data: 4.0.6 + + '@types/supertest@7.2.1': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.11 + + '@types/validator@13.15.10': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/type-utils': 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.68.0 + eslint: 9.39.5(jiti@2.7.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.68.0 + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.68.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@5.9.3) + '@typescript-eslint/types': 8.68.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.68.0': + dependencies: + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 + + '@typescript-eslint/tsconfig-utils@8.68.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.68.0': {} + + '@typescript-eslint/typescript-estree@8.68.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.68.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@5.9.3) + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.68.0': + dependencies: + '@typescript-eslint/types': 8.68.0 + eslint-visitor-keys: 5.0.1 + + '@ungap/structured-clone@1.4.0': {} + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + optional: true + + '@unrs/resolver-binding-android-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + optional: true + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + acorn-import-phases@1.0.4(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv-keywords@3.5.2(ajv@6.15.0): + dependencies: + ajv: 6.15.0 + + ajv-keywords@5.1.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + fast-deep-equal: 3.1.3 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-regex@6.3.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + ansis@4.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + append-field@1.0.0: {} + + arg@4.1.3: {} + + argon2@0.45.1: + dependencies: + '@phc/format': 1.0.0 + cross-env: 10.1.0 + node-addon-api: 8.9.2 + node-gyp-build: 4.8.4 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-timsort@1.0.3: {} + + asap@2.0.6: {} + + asynckit@0.4.0: {} + + babel-jest@30.5.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@jest/transform': 30.5.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 8.0.0 + babel-preset-jest: 30.5.0(@babel/core@7.29.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@8.0.0: + dependencies: + '@babel/helper-plugin-utils': 7.29.7 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 6.0.3 + test-exclude: 7.0.2 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@30.5.0: + dependencies: + '@types/babel__core': 7.20.5 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) + + babel-preset-jest@30.5.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jest-hoist: 30.5.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.11.20: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + boxen@5.1.2: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.416 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.8) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + c12@3.1.0: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 16.6.1 + exsolve: 1.1.1 + giget: 2.0.0 + jiti: 2.7.0 + ohash: 2.0.12 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.1 + rc9: 2.1.2 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001810: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + char-regex@1.0.2: {} + + chardet@2.2.0: {} + + check-disk-space@3.4.0: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chrome-trace-event@1.0.4: {} + + ci-info@4.4.0: {} + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.2: {} + + cjs-module-lexer@2.2.1: {} + + class-transformer@0.5.1: {} + + class-validator@0.14.1: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.13.12 + validator: 13.15.35 + + cli-boxes@2.2.1: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + cluster-key-slot@1.1.1: {} + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@2.20.3: {} + + commander@4.1.1: {} + + comment-json@5.0.0: + dependencies: + array-timsort: 1.0.3 + esprima: 4.0.1 + + component-emitter@1.3.1: {} + + concat-map@0.0.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + confbox@0.2.4: {} + + consola@3.4.2: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig@8.3.6(typescript@5.9.3): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.3.2 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.9.3 + + create-require@1.1.1: {} + + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dedent@1.7.2: {} + + deep-is@0.1.4: {} + + deepmerge-ts@7.1.5: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + defu@6.1.7: {} + + delayed-stream@1.0.0: {} + + denque@2.1.0: {} + + depd@2.0.0: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + detect-newline@3.1.0: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + diff@4.0.4: {} + + dotenv-expand@12.0.1: + dependencies: + dotenv: 16.4.7 + + dotenv@16.4.7: {} + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + effect@3.18.4: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + electron-to-chromium@1.5.416: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + empathic@2.0.0: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.2: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)): + dependencies: + eslint: 9.39.5(jiti@2.7.0) + + eslint-plugin-prettier@5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.6): + dependencies: + eslint: 9.39.5(jiti@2.7.0) + prettier: 3.9.6 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.13 + optionalDependencies: + '@types/eslint': 9.6.1 + eslint-config-prettier: 10.1.8(eslint@9.39.5(jiti@2.7.0)) + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + events@3.3.0: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit-x@0.2.2: {} + + expect@30.5.0: + dependencies: + '@jest/expect-utils': 30.5.0 + '@jest/get-type': 30.5.0 + jest-matcher-utils: 30.5.0 + jest-message-util: 30.5.0 + jest-mock: 30.5.0 + jest-util: 30.5.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + exsolve@1.1.1: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.6: {} + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.2): + dependencies: + '@babel/code-frame': 7.29.7 + chalk: 4.1.2 + chokidar: 4.0.3 + cosmiconfig: 8.3.6(typescript@5.9.3) + deepmerge: 4.3.1 + fs-extra: 10.1.0 + memfs: 3.5.3 + minimatch: 3.1.5 + node-abort-controller: 3.1.1 + schema-utils: 3.3.0 + semver: 7.8.5 + tapable: 2.3.3 + typescript: 5.9.3 + webpack: 5.106.2 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-monkey@1.1.0: {} + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@6.0.1: {} + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.7 + node-fetch-native: 1.6.7 + nypm: 0.6.9 + pathe: 2.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + globals@14.0.0: {} + + globals@17.11.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + helmet@8.3.0: {} + + html-escaper@2.0.2: {} + + 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 + + human-signals@2.1.0: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + ioredis@6.0.0: + dependencies: + '@ioredis/commands': 2.0.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-promise@4.0.0: {} + + is-stream@2.0.1: {} + + is-unicode-supported@0.1.0: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.8 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterare@1.2.1: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-changed-files@30.5.0: + dependencies: + execa: 5.1.1 + jest-util: 30.5.0 + p-limit: 3.1.0 + + jest-circus@30.5.0: + dependencies: + '@jest/environment': 30.5.0 + '@jest/expect': 30.5.0 + '@jest/test-result': 30.5.0 + '@jest/types': 30.5.0 + '@types/node': 24.13.3 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 30.5.0 + jest-matcher-utils: 30.5.0 + jest-message-util: 30.5.0 + jest-runtime: 30.5.0 + jest-snapshot: 30.5.0 + jest-util: 30.5.0 + p-limit: 3.1.0 + pretty-format: 30.5.0 + pure-rand: 7.0.1 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@30.5.0(@types/node@24.13.3)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.5.0(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)) + '@jest/test-result': 30.5.0 + '@jest/types': 30.5.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.5.0(@types/node@24.13.3)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)) + jest-util: 30.5.0 + jest-validate: 30.5.0 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jest-config@30.5.0(@types/node@24.13.3)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.29.7 + '@jest/get-type': 30.5.0 + '@jest/pattern': 30.5.0 + '@jest/test-sequencer': 30.5.0 + '@jest/types': 30.5.0 + babel-jest: 30.5.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 13.0.6 + graceful-fs: 4.2.11 + jest-circus: 30.5.0 + jest-docblock: 30.5.0 + jest-environment-node: 30.5.0 + jest-regex-util: 30.5.0 + jest-resolve: 30.5.0 + jest-runner: 30.5.0 + jest-util: 30.5.0 + jest-validate: 30.5.0 + parse-json: 5.2.0 + pretty-format: 30.5.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 24.13.3 + ts-node: 10.9.2(@types/node@24.13.3)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@30.5.0: + dependencies: + '@jest/diff-sequences': 30.5.0 + '@jest/get-type': 30.5.0 + chalk: 4.1.2 + pretty-format: 30.5.0 + + jest-docblock@30.5.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@30.5.0: + dependencies: + '@jest/get-type': 30.5.0 + '@jest/types': 30.5.0 + chalk: 4.1.2 + jest-util: 30.5.0 + pretty-format: 30.5.0 + + jest-environment-node@30.5.0: + dependencies: + '@jest/environment': 30.5.0 + '@jest/fake-timers': 30.5.0 + '@jest/types': 30.5.0 + '@types/node': 24.13.3 + jest-mock: 30.5.0 + jest-util: 30.5.0 + jest-validate: 30.5.0 + + jest-haste-map@30.5.0: + dependencies: + '@jest/types': 30.5.0 + '@parcel/watcher': 2.6.0 + '@types/node': 24.13.3 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + fdir: 6.5.0(picomatch@4.0.7) + graceful-fs: 4.2.11 + jest-regex-util: 30.5.0 + jest-util: 30.5.0 + jest-worker: 30.5.0 + picomatch: 4.0.7 + + jest-leak-detector@30.5.0: + dependencies: + '@jest/get-type': 30.5.0 + pretty-format: 30.5.0 + + jest-matcher-utils@30.5.0: + dependencies: + '@jest/get-type': 30.5.0 + chalk: 4.1.2 + jest-diff: 30.5.0 + pretty-format: 30.5.0 + + jest-message-util@30.5.0: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 30.5.0 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-util: 30.5.0 + picomatch: 4.0.7 + pretty-format: 30.5.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@30.5.0: + dependencies: + '@jest/expect-utils': 30.5.0 + '@jest/types': 30.5.0 + '@types/node': 24.13.3 + jest-util: 30.5.0 + + jest-regex-util@30.5.0: {} + + jest-resolve-dependencies@30.5.0: + dependencies: + jest-regex-util: 30.5.0 + jest-snapshot: 30.5.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@30.5.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 30.5.0 + jest-util: 30.5.0 + jest-validate: 30.5.0 + slash: 3.0.0 + unrs-resolver: 1.12.2 + + jest-runner@30.5.0: + dependencies: + '@jest/console': 30.5.0 + '@jest/environment': 30.5.0 + '@jest/source-map': 30.5.0 + '@jest/test-result': 30.5.0 + '@jest/transform': 30.5.0 + '@jest/types': 30.5.0 + '@types/node': 24.13.3 + chalk: 4.1.2 + emittery: 0.13.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-docblock: 30.5.0 + jest-environment-node: 30.5.0 + jest-haste-map: 30.5.0 + jest-leak-detector: 30.5.0 + jest-message-util: 30.5.0 + jest-resolve: 30.5.0 + jest-runtime: 30.5.0 + jest-util: 30.5.0 + jest-watcher: 30.5.0 + jest-worker: 30.5.0 + p-limit: 3.1.0 + transitivePeerDependencies: + - supports-color + + jest-runtime@30.5.0: + dependencies: + '@jest/environment': 30.5.0 + '@jest/fake-timers': 30.5.0 + '@jest/globals': 30.5.0 + '@jest/source-map': 30.5.0 + '@jest/test-result': 30.5.0 + '@jest/transform': 30.5.0 + '@jest/types': 30.5.0 + '@types/node': 24.13.3 + chalk: 4.1.2 + cjs-module-lexer: 2.2.1 + collect-v8-coverage: 1.0.3 + es-module-lexer: 2.3.2 + glob: 13.0.6 + graceful-fs: 4.2.11 + jest-haste-map: 30.5.0 + jest-message-util: 30.5.0 + jest-mock: 30.5.0 + jest-regex-util: 30.5.0 + jest-resolve: 30.5.0 + jest-snapshot: 30.5.0 + jest-util: 30.5.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@30.5.0: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.8 + '@jest/expect-utils': 30.5.0 + '@jest/get-type': 30.5.0 + '@jest/snapshot-utils': 30.5.0 + '@jest/transform': 30.5.0 + '@jest/types': 30.5.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + chalk: 4.1.2 + expect: 30.5.0 + graceful-fs: 4.2.11 + jest-diff: 30.5.0 + jest-matcher-utils: 30.5.0 + jest-message-util: 30.5.0 + jest-util: 30.5.0 + pretty-format: 30.5.0 + semver: 7.8.5 + synckit: 0.11.13 + transitivePeerDependencies: + - supports-color + + jest-util@30.5.0: + dependencies: + '@jest/types': 30.5.0 + '@types/node': 24.13.3 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.7 + + jest-validate@30.5.0: + dependencies: + '@jest/get-type': 30.5.0 + '@jest/types': 30.5.0 + camelcase: 6.3.0 + chalk: 4.1.2 + leven: 3.1.0 + pretty-format: 30.5.0 + + jest-watcher@30.5.0: + dependencies: + '@jest/test-result': 30.5.0 + '@jest/types': 30.5.0 + '@types/node': 24.13.3 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 30.5.0 + string-length: 4.0.2 + + jest-worker@27.5.1: + dependencies: + '@types/node': 24.13.3 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest-worker@30.5.0: + dependencies: + '@types/node': 24.13.3 + '@ungap/structured-clone': 1.4.0 + jest-util: 30.5.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@30.5.0(@types/node@24.13.3)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.5.0(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)) + '@jest/types': 30.5.0 + import-local: 3.2.0 + jest-cli: 30.5.0(@types/node@24.13.3)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + js-yaml@3.15.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonc-parser@3.3.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.3 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.3: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + libphonenumber-js@1.13.12: {} + + lines-and-columns@1.2.4: {} + + load-esm@1.0.3: {} + + loader-runner@4.3.2: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.memoize@4.1.2: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash@4.17.21: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + lru-cache@10.4.3: {} + + lru-cache@11.5.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + make-error@1.3.6: {} + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + media-typer@1.1.1: {} + + memfs@3.5.3: + dependencies: + fs-monkey: 1.1.0 + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@2.6.0: {} + + mimic-fn@2.1.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + ms@2.1.3: {} + + multer@2.2.0: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + + mute-stream@2.0.0: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + neo-async@2.6.2: {} + + node-abort-controller@3.1.1: {} + + node-addon-api@7.1.1: {} + + node-addon-api@8.9.2: {} + + node-emoji@1.11.0: + dependencies: + lodash: 4.18.1 + + node-fetch-native@1.6.7: {} + + node-gyp-build@4.8.4: {} + + node-int64@0.4.0: {} + + node-releases@2.0.54: {} + + normalize-path@3.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + nypm@0.6.9: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.3.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + ohash@2.0.12: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parseurl@1.3.3: {} + + passport-jwt@4.0.1: + dependencies: + jsonwebtoken: 9.0.3 + passport-strategy: 1.0.0 + + passport-strategy@1.0.0: {} + + passport@0.7.0: + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + path-to-regexp@8.3.0: {} + + path-to-regexp@8.4.2: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + pause@0.0.1: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + picomatch@4.0.7: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + + pluralize@8.0.0: {} + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + + prettier@3.9.6: {} + + pretty-format@30.5.0: + dependencies: + '@jest/react-is-18': react-is@18.3.1 + '@jest/react-is-19': react-is@19.2.8 + '@jest/schemas': 30.5.0 + ansi-styles: 5.2.0 + + prisma@6.19.0(typescript@5.9.3): + dependencies: + '@prisma/config': 6.19.0 + '@prisma/engines': 6.19.0 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + pure-rand@7.0.1: {} + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + rc9@2.1.2: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + react-is@18.3.1: {} + + react-is@19.2.8: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + + redis-errors@1.2.0: {} + + reflect-metadata@0.2.2: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + schema-utils@3.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.15.0 + ajv-keywords: 3.5.2(ajv@6.15.0) + + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-keywords: 5.1.0(ajv@8.20.0) + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.4: {} + + source-map@0.7.6: {} + + sprintf-js@1.0.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + standard-as-callback@2.1.0: {} + + statuses@2.0.2: {} + + streamsearch@1.1.0: {} + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + + strip-bom@3.0.0: {} + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@3.1.1: {} + + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.3 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + swagger-ui-dist@5.29.4: + dependencies: + '@scarf/scarf': 1.4.0 + + symbol-observable@4.0.0: {} + + synckit@0.11.13: + dependencies: + '@pkgr/core': 0.3.6 + + tapable@2.3.3: {} + + terser-webpack-plugin@5.6.1(webpack@5.106.2): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.51.2 + webpack: 5.106.2 + + terser@5.51.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.18.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.6 + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.0)(@jest/types@30.5.0)(babel-jest@30.5.0(@babel/core@7.29.7))(jest-util@30.5.0)(jest@30.5.0(@types/node@24.13.3)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.9 + jest: 30.5.0(@types/node@24.13.3)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.8.5 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.7 + '@jest/transform': 30.5.0 + '@jest/types': 30.5.0 + babel-jest: 30.5.0(@babel/core@7.29.7) + jest-util: 30.5.0 + + ts-loader@9.6.2(typescript@5.9.3)(webpack@5.106.2): + dependencies: + chalk: 4.1.2 + picomatch: 4.0.7 + source-map: 0.7.6 + typescript: 5.9.3 + webpack: 5.106.2 + + ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.13 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 24.13.3 + acorn: 8.18.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + tsconfig-paths-webpack-plugin@4.2.0: + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.24.5 + tapable: 2.3.3 + tsconfig-paths: 4.2.0 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.20.2: {} + + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typedarray@0.0.6: {} + + typescript-eslint@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.68.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.68.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + uglify-js@3.19.3: + optional: true + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + + undici-types@7.18.2: {} + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + unrs-resolver@1.12.2: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + + update-browserslist-db@1.3.2(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + v8-compile-cache-lib@3.0.1: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + validator@13.15.35: {} + + vary@1.1.2: {} + + watchpack@2.5.2: + dependencies: + graceful-fs: 4.2.11 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + webpack-node-externals@3.0.0: {} + + webpack-sources@3.5.1: {} + + webpack@5.106.2: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.18.0 + acorn-import-phases: 1.0.4(acorn@8.18.0) + browserslist: 4.28.8 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.24.5 + es-module-lexer: 2.3.2 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + loader-runner: 4.3.2 + mime-db: 1.54.0 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.3 + terser-webpack-plugin: 5.6.1(webpack@5.106.2) + watchpack: 2.5.2 + webpack-sources: 3.5.1 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + + word-wrap@1.2.5: {} + + wordwrap@1.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 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + 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 + + yn@3.1.1: {} + + yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} + + zod@3.24.2: {} diff --git a/apps/api/prisma/backfill-contract-buckets.ts b/apps/api/prisma/backfill-contract-buckets.ts new file mode 100644 index 0000000..13bfc04 --- /dev/null +++ b/apps/api/prisma/backfill-contract-buckets.ts @@ -0,0 +1,121 @@ +import { PrismaClient } from '@prisma/client'; +import * as fs from 'fs'; +import { parseOaJson } from './oa-json'; + +const prisma = new PrismaClient(); + +function str(v: unknown) { + if (v == null) return ''; + return String(v).trim(); +} + +function compactName(name: string) { + return name.replace(/[\s()()【】\[\]《》·,,。.\-_/\\]/g, '').toLowerCase(); +} + +function namesOverlap(a: string, b: string) { + const na = compactName(a); + const nb = compactName(b); + if (!na || !nb) return false; + if (na === nb) return true; + const shorter = na.length <= nb.length ? na : nb; + const longer = na.length <= nb.length ? nb : na; + return shorter.length >= 8 && longer.includes(shorter); +} + +async function linkContracts() { + const bids = await prisma.bidCase.findMany({ + select: { id: true, bidNo: true, externalNo: true, name: true }, + }); + const contracts = await prisma.contract.findMany({ + where: { bidCaseId: null }, + select: { id: true, name: true, contractNo: true }, + }); + let linked = 0; + for (const c of contracts) { + const no = c.contractNo || ''; + const tb = no.match(/TB-\d+-\d+/); + const hits = bids.filter((b) => { + if (tb && (b.bidNo === tb[0] || b.bidNo.includes(tb[0]) || (b.externalNo && b.externalNo.includes(tb[0])))) { + return true; + } + if (no && (b.bidNo && no.includes(b.bidNo))) return true; + return namesOverlap(c.name, b.name); + }); + const unique = [...new Map(hits.map((h) => [h.id, h])).values()]; + if (unique.length !== 1) continue; + await prisma.contract.update({ where: { id: c.id }, data: { bidCaseId: unique[0].id } }); + linked += 1; + } + return { scanned: contracts.length, linked }; +} + +async function backfillSealApplicants() { + const dumps = ['/opt/import/prod/oa_fresh.json', '/opt/import/prod/oa_core.json', '/opt/import/prod/oa_clean.json']; + const dump = dumps.find((p) => fs.existsSync(p)); + if (!dump) return { updated: 0, skipped: 'no dump' }; + + const raw = parseOaJson>(dump); + const hrPath = '/opt/import/prod/oa_hr.json'; + const hr = fs.existsSync(hrPath) ? parseOaJson<{ users?: Record[] }>(hrPath) : { users: [] }; + + const users = await prisma.user.findMany(); + const byUsername = new Map(users.map((u) => [u.username, u])); + const byName = new Map(users.map((u) => [u.displayName, u])); + const admin = users.find((u) => u.displayName === '系统管理员'); + const oaUser = new Map((hr.users || []).map((u) => [String(u.id), str(u.userName || u.username)])); + const userFromOaId = (id: unknown) => { + const uname = oaUser.get(str(id)); + return uname ? byUsername.get(uname) : undefined; + }; + + const applies = (raw.oa_seal_apply || raw.oa_seal || []) as Record[]; + const rows = await prisma.sealRequest.findMany({ + include: { applicant: { select: { displayName: true } } }, + }); + let updated = 0; + for (const r of rows) { + if (r.applicant?.displayName && r.applicant.displayName !== '系统管理员') continue; + const reason = r.reason || ''; + const created = r.createdAt ? r.createdAt.getTime() : 0; + const hit = applies.find((a) => { + const content = str(a.content || a.apply_subject || a.reason); + if (content && reason && (content === reason || reason.includes(content) || content.includes(reason))) return true; + const t = str(a.create_time); + if (t && created) { + const d = new Date(t.includes('T') ? t : t.replace(' ', 'T')); + return Math.abs(d.getTime() - created) < 2000 && str(a.seal_type || '') === r.sealType; + } + return false; + }); + if (!hit) continue; + const who = + byName.get(str(hit.applicant_name) === '张靖妍' ? '张婧妍' : str(hit.applicant_name)) || + userFromOaId(hit.applicant_id) || + userFromOaId(hit.create_by) || + userFromOaId(hit.creator_id) || + userFromOaId(hit.apply_user); + if (!who || who.id === admin?.id) continue; + await prisma.sealRequest.update({ where: { id: r.id }, data: { applicantId: who.id } }); + updated += 1; + } + return { updated, scanned: rows.length }; +} + +async function main() { + const contracts = await linkContracts(); + const seals = await backfillSealApplicants(); + const [tender, incremental] = await Promise.all([ + prisma.contract.count({ where: { bidCaseId: { not: null } } }), + prisma.contract.count({ where: { bidCaseId: null } }), + ]); + console.log(JSON.stringify({ contracts, seals, tender, incremental }, null, 2)); +} + +main() + .then(async () => prisma.$disconnect()) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/prisma/backfill-project-ledger.ts b/apps/api/prisma/backfill-project-ledger.ts new file mode 100644 index 0000000..f1b3b81 --- /dev/null +++ b/apps/api/prisma/backfill-project-ledger.ts @@ -0,0 +1,127 @@ +import { Prisma, PrismaClient } from '@prisma/client'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { normalizeProjectType } from '../src/project/project-workflow'; + +const prisma = new PrismaClient(); + +function str(v: unknown) { + return v == null ? '' : String(v).trim(); +} + +function num(v: unknown) { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +function compact(name: string) { + return String(name || '') + .replace(/[()()\[\]【】\s\-—_.,,、]/g, '') + .replace(/招标公告|采购项目|技术服务合同|合同/g, ''); +} + +function loadCore(): { project_list?: Record[]; reimbursement?: Record[] } { + const candidates = [ + join('/opt/import/prod/oa_core.json'), + join(process.cwd(), '../../import/prod/oa_core.json'), + join(process.cwd(), 'import/prod/oa_core.json'), + ]; + for (const p of candidates) { + if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf8')); + } + return {}; +} + +async function main() { + const core = loadCore(); + const oldProjects = core.project_list || []; + const reimburses = core.reimbursement || []; + + const projects = await prisma.project.findMany({ + include: { contract: { include: { bidCase: { select: { projectType: true, name: true } } } }, expenses: { select: { id: true, projectId: true } } }, + }); + const expenses = await prisma.expenseClaim.findMany({ + where: { tenantId: '1' }, + select: { id: true, projectId: true, purpose: true, remark: true }, + }); + + let typed = 0; + let amounted = 0; + let linked = 0; + + for (const p of projects) { + const old = oldProjects.find((o) => { + const code = str(o.project_code || o.system_number); + const name = str(o.project_name); + return (code && (code === p.projectNo || p.projectNo.endsWith(code))) || + (name && (name === p.name || compact(name) === compact(p.name))); + }); + const nextType = + normalizeProjectType(p.projectType) || + normalizeProjectType(p.contract?.bidCase?.projectType) || + normalizeProjectType(str(old?.project_category || old?.project_type)); + const nextAmount = + num(p.amount) || + num(p.contract?.amount) || + num(old?.project_budget); + const data: Prisma.ProjectUpdateInput = {}; + if (nextType && nextType !== p.projectType) { + data.projectType = nextType; + typed += 1; + } + if (nextAmount > 0 && num(p.amount) <= 0) { + data.amount = nextAmount; + amounted += 1; + } + if (Object.keys(data).length) { + await prisma.project.update({ where: { id: p.id }, data }); + } + } + + const refreshed = await prisma.project.findMany({ select: { id: true, name: true, projectNo: true } }); + for (const e of expenses) { + if (e.projectId) continue; + const hit = refreshed.find((p) => { + const blob = `${e.purpose || ''} ${e.remark || ''}`; + return blob.includes(p.projectNo) || blob.includes(p.name) || (p.name.length >= 8 && blob.includes(p.name.slice(0, 12))); + }); + const old = reimburses.find((r) => str(r.project_name) && hit && compact(str(r.project_name)) === compact(hit.name)); + const byName = refreshed.find((p) => { + const r = reimburses.find((x) => str(x.id) && String(e.remark || '').includes(String(x.id))); + return r ? compact(str(r.project_name)) === compact(p.name) : false; + }); + const target = hit || byName; + if (!target) continue; + await prisma.expenseClaim.update({ where: { id: e.id }, data: { projectId: target.id, costType: 'PROJECT' } }); + linked += 1; + } + + // old reimbursements already imported with project names + for (const r of reimburses) { + const name = str(r.project_name); + if (!name) continue; + const project = refreshed.find((p) => p.name === name || compact(p.name) === compact(name)); + if (!project) continue; + const updated = await prisma.expenseClaim.updateMany({ + where: { + tenantId: '1', + projectId: null, + OR: [{ purpose: { contains: name.slice(0, 16) } }, { remark: { contains: name.slice(0, 16) } }], + }, + data: { projectId: project.id, costType: 'PROJECT' }, + }); + linked += updated.count; + } + + console.log(`project ledger backfill: typed=${typed} amounted=${amounted} expensesLinked=${linked}`); +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/prisma/backfill-report-cc.ts b/apps/api/prisma/backfill-report-cc.ts new file mode 100644 index 0000000..9edd77e --- /dev/null +++ b/apps/api/prisma/backfill-report-cc.ts @@ -0,0 +1,79 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const KEYS = ['ccDaily', 'ccWeekly', 'ccMonthly'] as const; + +function rolesOf(emp: { users: { roles: { role: { code: string } }[] }[] }) { + return [...new Set(emp.users.flatMap((u) => u.roles.map((r) => r.role.code)))]; +} + +function isDirector(emp: { title?: string | null; jobRole?: string | null; users: { roles: { role: { code: string } }[] }[] }) { + if (/总监/.test(`${emp.title || ''}${emp.jobRole || ''}`)) return true; + return rolesOf(emp).some((r) => r.endsWith('_director') || r === 'biz_director' || r === 'owner'); +} + +function parseIds(raw?: string | null) { + if (!raw) return []; + return [...new Set(raw.split(/[,,、\s]+/).filter((s) => UUID_RE.test(s)))]; +} + +async function main() { + const depts = await prisma.department.findMany({ where: { tenantId: '1' }, select: { id: true, parentId: true } }); + const byId = new Map(depts.map((d) => [d.id, d])); + const people = await prisma.employee.findMany({ + where: { tenantId: '1' }, + include: { users: { select: { roles: { select: { role: { select: { code: true } } } } } } }, + }); + const byDept = new Map(); + for (const e of people) { + const list = byDept.get(e.departmentId) || []; + list.push(e); + byDept.set(e.departmentId, list); + } + + function scopeDeptIds(start: string, selfId: string) { + const scope: string[] = []; + const seen = new Set(); + let cur: string | null | undefined = start; + while (cur && !seen.has(cur)) { + seen.add(cur); + scope.push(cur); + const hasDir = (byDept.get(cur) || []).some((e) => e.id !== selfId && isDirector(e)); + if (hasDir) break; + cur = byId.get(cur)?.parentId; + } + return new Set(scope); + } + + let n = 0; + for (const emp of people) { + const scope = scopeDeptIds(emp.departmentId, emp.id); + const dirs = people.filter((d) => scope.has(d.departmentId) && d.id !== emp.id && d.id !== emp.managerId && isDirector(d)); + const dirIds = dirs.map((d) => d.id); + const allowed = new Set(dirIds); + const data: Partial> = {}; + for (const key of KEYS) { + const cur = parseIds(emp[key]); + let next = cur.filter((id) => allowed.has(id)); + if (!next.length && dirIds.length && cur.some((id) => id === emp.managerId || !allowed.has(id))) { + next = dirIds; + } + const joined = next.length ? next.join(',') : null; + if (joined !== (emp[key] || null)) data[key] = joined; + } + if (!Object.keys(data).length) continue; + await prisma.employee.update({ where: { id: emp.id }, data }); + n += 1; + console.log(`${emp.name}: ${JSON.stringify(data)}`); + } + console.log(`updated ${n} / ${people.length} employee cc fields`); +} + +main() + .then(async () => prisma.$disconnect()) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/prisma/backfill-tech-travel.ts b/apps/api/prisma/backfill-tech-travel.ts new file mode 100644 index 0000000..f6612a1 --- /dev/null +++ b/apps/api/prisma/backfill-tech-travel.ts @@ -0,0 +1,36 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + const techPeople = await prisma.employee.findMany({ + where: { department: { deptType: 'TECH' } }, + select: { name: true, department: { select: { deptType: true, name: true } } }, + }); + const names = techPeople.map((e) => e.name).filter((n) => n && n.length >= 2); + const rows = await prisma.expenseClaim.findMany({ + where: { tenantId: '1', costType: 'PROJECT', budgetCategory: 'TRAVEL' }, + include: { + applicant: { select: { employee: { select: { name: true, department: { select: { deptType: true } } } } } }, + lines: { select: { remark: true } }, + }, + }); + let n = 0; + for (const row of rows) { + const dept = row.applicant.employee?.department?.deptType || ''; + const text = [row.purpose, row.remark, ...row.lines.map((l) => l.remark)].filter(Boolean).join(' '); + const forTech = names.some((name) => text.includes(name)); + if (dept !== 'TECH' && !forTech) continue; + await prisma.expenseClaim.update({ where: { id: row.id }, data: { budgetCategory: 'TECH' } }); + n += 1; + } + console.log(`reclassified ${n} / ${rows.length} travel expenses into tech budget; tech staff ${names.length}`); +} + +main() + .then(async () => prisma.$disconnect()) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/prisma/backfill-won-ledger.ts b/apps/api/prisma/backfill-won-ledger.ts new file mode 100644 index 0000000..09a9fca --- /dev/null +++ b/apps/api/prisma/backfill-won-ledger.ts @@ -0,0 +1,88 @@ +import { PrismaClient } from '@prisma/client'; +import { ensureWonLedger } from '../src/contract/won-ledger'; +import { BIZ_TYPE } from '../src/bidding/bid-workflow'; + +const prisma = new PrismaClient(); + +async function main() { + const owner = await prisma.user.findFirst({ + where: { tenantId: '1', status: 'ACTIVE', roles: { some: { role: { code: { in: ['owner', 'admin'] } } } } }, + select: { id: true }, + }); + const won = await prisma.bidCase.findMany({ + where: { tenantId: '1', status: 'WON' }, + include: { party: true, legalEntity: true, expenses: { select: { amount: true } } }, + }); + let newContracts = 0; + let newProjects = 0; + let todos = 0; + for (const bid of won) { + const actorId = bid.createdById || owner?.id; + if (!actorId) throw new Error('没有可用的合同创建人'); + const beforeC = await prisma.contract.count({ + where: { bidCaseId: bid.id, status: { in: ['DRAFT', 'PENDING', 'APPROVED'] } }, + }); + const beforeP = await prisma.project.count({ + where: { contract: { bidCaseId: bid.id } }, + }); + const result = await prisma.$transaction((tx) => ensureWonLedger(tx, bid, actorId)); + if ( + (await prisma.contract.count({ + where: { bidCaseId: bid.id, status: { in: ['DRAFT', 'PENDING', 'APPROVED'] } }, + })) > beforeC + ) { + newContracts += 1; + } + if ((await prisma.project.count({ where: { contract: { bidCaseId: bid.id } } })) > beforeP) { + newProjects += 1; + } + + await prisma.todoTask.updateMany({ + where: { + status: 'OPEN', + bizType: BIZ_TYPE.CONVERT_CONTRACT, + bizId: bid.id, + }, + data: { bizId: result.contract.id, title: `完善合同草稿:${bid.name}` }, + }); + const openTodo = await prisma.todoTask.findFirst({ + where: { + status: 'OPEN', + bizType: BIZ_TYPE.CONVERT_CONTRACT, + bizId: result.contract.id, + }, + }); + if (!openTodo && result.contract.status === 'DRAFT') { + const assignees = await prisma.user.findMany({ + where: { + tenantId: '1', + status: 'ACTIVE', + roles: { some: { role: { code: { in: ['biz_director', 'biz_staff', 'owner'] } } } }, + }, + }); + if (assignees.length) { + await prisma.todoTask.createMany({ + data: assignees.map((u) => ({ + tenantId: '1', + title: `完善合同草稿:${bid.name}`, + bizType: BIZ_TYPE.CONVERT_CONTRACT, + bizId: result.contract.id, + assigneeId: u.id, + })), + }); + todos += assignees.length; + } + } + } + console.log( + `won bids ${won.length}; new contracts ${newContracts}; new projects ${newProjects}; todos ${todos}`, + ); +} + +main() + .then(async () => prisma.$disconnect()) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/prisma/ensure-prod-admin.ts b/apps/api/prisma/ensure-prod-admin.ts new file mode 100644 index 0000000..98c9ae1 --- /dev/null +++ b/apps/api/prisma/ensure-prod-admin.ts @@ -0,0 +1,65 @@ +import { PrismaClient } from '@prisma/client'; +import * as argon2 from 'argon2'; + +const prisma = new PrismaClient(); + +async function main() { + const username = '18115604840'; + const password = '123456'; + const role = await prisma.role.findFirst({ where: { code: 'admin' } }); + if (!role) throw new Error('缺少 admin 角色,请先执行 prisma seed'); + const root = + (await prisma.department.findFirst({ where: { code: 'ROOT' } })) || + (await prisma.department.findFirst({ orderBy: { sortNo: 'asc' } })); + if (!root) throw new Error('缺少部门'); + let emp = await prisma.employee.findFirst({ where: { mobile: username } }); + if (!emp) { + emp = await prisma.employee.create({ + data: { + name: '系统管理员', + employeeNo: 'E-ADMIN', + departmentId: root.id, + title: '管理员', + restSchedule: 'DOUBLE', + employmentStatus: 'REGULAR', + mobile: username, + hiredAt: new Date(), + }, + }); + } else if (!emp.mobile) { + emp = await prisma.employee.update({ where: { id: emp.id }, data: { mobile: username } }); + } + const hash = await argon2.hash(password); + let user = await prisma.user.findFirst({ where: { username } }); + if (!user) { + user = await prisma.user.create({ + data: { + username, + passwordHash: hash, + displayName: emp.name, + employeeId: emp.id, + mustChangePassword: false, + status: 'ACTIVE', + }, + }); + } else { + user = await prisma.user.update({ + where: { id: user.id }, + data: { employeeId: emp.id, status: 'ACTIVE' }, + }); + } + await prisma.userRole.upsert({ + where: { userId_roleId: { userId: user.id, roleId: role.id } }, + create: { userId: user.id, roleId: role.id }, + update: {}, + }); + console.log('prod admin ok', username); +} + +main() + .then(() => prisma.$disconnect()) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/prisma/fix-approval-fields.ts b/apps/api/prisma/fix-approval-fields.ts new file mode 100644 index 0000000..2a52644 --- /dev/null +++ b/apps/api/prisma/fix-approval-fields.ts @@ -0,0 +1,141 @@ +import { PrismaClient } from '@prisma/client'; +import { readFileSync } from 'fs'; + +const prisma = new PrismaClient(); + +type OaExpense = { + name: string; + amount: number; + project: string; + date: string; + purpose: string; + remark: string | null; + expenseCategory: string; +}; + +type OaLoan = { name: string; amount: number; purpose: string; project: string }; + +const NAME_ALIAS: Record = { + 张靖妍: '张婧妍', + 张婧妍: '张婧妍', +}; + +function normName(v?: string | null) { + const s = (v || '').trim(); + return NAME_ALIAS[s] || s; +} + +function amtEq(a: unknown, b: unknown) { + return Math.abs(Number(a) - Number(b)) < 0.021; +} + +function sameProject(oa: string, locals: string[]) { + if (!oa) return locals.length === 0; + return locals.some((p) => p === oa || p.includes(oa) || oa.includes(p)); +} + +async function main() { + const oaExpenses = JSON.parse(readFileSync('/opt/apps/api/prisma/_oa_reimb_map.json', 'utf8')) as OaExpense[]; + const oaLoans = JSON.parse(readFileSync('/opt/apps/api/prisma/_oa_loan_map.json', 'utf8')) as OaLoan[]; + + const expenses = await prisma.expenseClaim.findMany({ + include: { + applicant: { select: { displayName: true, employee: { select: { name: true } } } }, + project: { select: { name: true } }, + bidCase: { select: { name: true } }, + }, + orderBy: { claimNo: 'asc' }, + }); + + const used = new Set(); + let patchedExpenses = 0; + const unmatchedExpenses: string[] = []; + + for (const row of expenses) { + const names = [normName(row.applicant?.displayName), normName(row.applicant?.employee?.name)].filter(Boolean); + const locals = [row.project?.name, row.bidCase?.name].filter((v): v is string => Boolean(v)); + const date = String(row.submittedAt || '').slice(0, 10); + const idxs = oaExpenses + .map((o, i) => ({ o, i })) + .filter(({ o, i }) => !used.has(i) && amtEq(o.amount, row.amount) && names.includes(normName(o.name))); + const byProj = idxs.filter(({ o }) => sameProject(o.project, locals)); + const byDate = idxs.filter(({ o }) => o.date === date); + const hit = (byProj.length === 1 ? byProj[0] : null) || (byDate.length === 1 ? byDate[0] : null) || (idxs.length === 1 ? idxs[0] : byProj[0] || byDate[0] || null); + if (!hit) { + unmatchedExpenses.push(`${row.claimNo} ${names[0] || '?'} ${row.amount}`); + continue; + } + used.add(hit.i); + await prisma.expenseClaim.update({ + where: { id: row.id }, + data: { + purpose: hit.o.purpose || null, + remark: hit.o.remark, + expenseCategory: hit.o.expenseCategory, + }, + }); + if (hit.o.purpose) { + await prisma.approvalTask.updateMany({ + where: { bizId: row.id, bizType: 'EXPENSE_APPROVAL' }, + data: { title: hit.o.purpose }, + }); + } + patchedExpenses += 1; + } + + const loans = await prisma.loanRecord.findMany({ + include: { + applicant: { select: { displayName: true, employee: { select: { name: true } } } }, + }, + orderBy: { loanNo: 'asc' }, + }); + const usedLoan = new Set(); + let patchedLoans = 0; + const unmatchedLoans: string[] = []; + for (const row of loans) { + const names = [normName(row.applicant?.displayName), normName(row.applicant?.employee?.name)].filter(Boolean); + const hit = oaLoans + .map((o, i) => ({ o, i })) + .find(({ o, i }) => !usedLoan.has(i) && amtEq(o.amount, row.amount) && names.includes(normName(o.name)) && (!row.purpose || row.purpose === o.purpose || o.purpose.includes(row.purpose) || row.purpose.includes(o.purpose))); + const fallback = oaLoans + .map((o, i) => ({ o, i })) + .find(({ o, i }) => !usedLoan.has(i) && amtEq(o.amount, row.amount) && names.includes(normName(o.name))); + const pick = hit || fallback; + if (!pick) { + unmatchedLoans.push(`${row.loanNo} ${names[0] || '?'}`); + await prisma.loanRecord.update({ where: { id: row.id }, data: { remark: null } }); + if (row.purpose) { + await prisma.approvalTask.updateMany({ + where: { bizId: row.id, bizType: 'LOAN_APPROVAL' }, + data: { title: row.purpose }, + }); + } + continue; + } + usedLoan.add(pick.i); + await prisma.loanRecord.update({ + where: { id: row.id }, + data: { purpose: pick.o.purpose, remark: null }, + }); + await prisma.approvalTask.updateMany({ + where: { bizId: row.id, bizType: 'LOAN_APPROVAL' }, + data: { title: pick.o.purpose }, + }); + patchedLoans += 1; + } + + console.log( + JSON.stringify( + { patchedExpenses, unmatchedExpenses, patchedLoans, unmatchedLoans }, + null, + 2, + ), + ); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/prisma/fix-bid-from-oa.ts b/apps/api/prisma/fix-bid-from-oa.ts new file mode 100644 index 0000000..d35d16f --- /dev/null +++ b/apps/api/prisma/fix-bid-from-oa.ts @@ -0,0 +1,125 @@ +import { PrismaClient } from '@prisma/client'; +import * as fs from 'fs'; + +const prisma = new PrismaClient(); +const DUMP = '/opt/import/prod/oa_clean.json'; + +type Row = Record; + +function str(v: unknown) { + if (v == null) return ''; + return String(v).trim(); +} + +function when(v: unknown) { + const s = str(v); + if (!s || s.startsWith('0000')) return null; + const d = new Date(s.includes('T') ? s : s.replace(' ', 'T')); + return Number.isNaN(d.getTime()) ? null : d; +} + +function entityKey(raw: unknown) { + const s = str(raw); + if (!s) return ''; + if (s.includes('鲁兵')) return '山东鲁兵'; + if (s.includes('隆创')) return '隆创'; + if (s.includes('风影')) return '江苏风影随行科技有限公司'; + return s; +} + +const ENTITIES: { key: string; name: string; shortName: string }[] = [ + { key: '江苏风影随行科技有限公司', name: '江苏风影随行科技有限公司', shortName: '江苏风影' }, + { key: '山东鲁兵', name: '山东鲁兵', shortName: '山东鲁兵' }, + { key: '隆创', name: '隆创', shortName: '隆创' }, +]; + +async function main() { + const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record; + const tenders = raw.oa_tender || []; + const entityIds = new Map(); + for (const e of ENTITIES) { + const existing = await prisma.legalEntity.findFirst({ + where: { tenantId: '1', OR: [{ name: e.name }, { shortName: e.shortName }] }, + }); + if (existing) { + await prisma.legalEntity.update({ where: { id: existing.id }, data: { name: e.name, shortName: e.shortName } }); + entityIds.set(e.key, existing.id); + } else { + const created = await prisma.legalEntity.create({ + data: { name: e.name, shortName: e.shortName, isDefault: e.shortName === '江苏风影' }, + }); + entityIds.set(e.key, created.id); + } + } + await prisma.legalEntity.updateMany({ + where: { name: { not: '江苏风影随行科技有限公司' } }, + data: { isDefault: false }, + }); + await prisma.legalEntity.updateMany({ + where: { name: '江苏风影随行科技有限公司' }, + data: { isDefault: true }, + }); + + const users = await prisma.user.findMany(); + const userByName = new Map(users.map((u) => [u.displayName, u])); + const bids = await prisma.bidCase.findMany(); + const usedNos = new Set(); + const matched: { rowId: string; t: Row }[] = []; + for (const t of tenders) { + const name = str(t.project_name); + const sys = str(t.system_number); + const official = str(t.project_number); + const row = bids.find((b) => b.name === name) || bids.find((b) => b.bidNo === sys || b.externalNo === official); + if (!row) continue; + matched.push({ rowId: row.id, t }); + } + for (const m of matched) { + await prisma.bidCase.update({ + where: { id: m.rowId }, + data: { bidNo: `TMP-${m.rowId.replace(/-/g, '').slice(0, 20)}` }, + }); + } + + for (const { rowId, t } of matched) { + const row = bids.find((b) => b.id === rowId)!; + const official = str(t.project_number); + const key = entityKey(t.participating_unit); + let bidNo = official || row.externalNo || row.bidNo; + if (usedNos.has(bidNo)) bidNo = `${bidNo}-${row.id.slice(0, 4)}`; + usedNos.add(bidNo); + const preparer = str(t.bid_prepare_person); + const creator = userByName.get(preparer === '张靖妍' ? '张婧妍' : preparer); + await prisma.bidCase.update({ + where: { id: row.id }, + data: { + bidNo, + externalNo: official || row.externalNo, + legalEntityId: key ? entityIds.get(key) || null : null, + openAt: when(t.bid_open_date) || row.openAt, + applyEndAt: when(t.file_apply_deadline) || row.applyEndAt, + applyStartAt: when(t.create_time) || row.applyStartAt, + createdById: creator?.id || row.createdById, + createdAt: when(t.create_time) || row.createdAt, + }, + }); + } + const patched = matched.length; + + const loan = await prisma.loanRecord.findFirst({ where: { purpose: { contains: '重庆投标' } } }); + const chongqing = await prisma.bidCase.findFirst({ where: { name: { contains: '门诊特病' } } }); + if (loan && chongqing) { + await prisma.loanRecord.update({ + where: { id: loan.id }, + data: { bidCaseId: chongqing.id, costType: 'BID' }, + }); + } + + console.log(JSON.stringify({ patchedBids: patched, entities: ENTITIES.map((e) => e.shortName), linkedChongqingLoan: Boolean(loan && chongqing) })); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/prisma/fix-expense-applicants.ts b/apps/api/prisma/fix-expense-applicants.ts new file mode 100644 index 0000000..263b38b --- /dev/null +++ b/apps/api/prisma/fix-expense-applicants.ts @@ -0,0 +1,125 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function ensureUser(name: string, username: string, deptName: string, employeeNo: string, passwordHash: string) { + const existing = await prisma.user.findFirst({ where: { OR: [{ username }, { displayName: name }] } }); + if (existing) return existing.id; + const dept = + (await prisma.department.findFirst({ where: { name: deptName } })) || + (await prisma.department.findFirst({ where: { name: { contains: '研发' } } })) || + (await prisma.department.findFirst({ where: { parentId: { not: null } } })); + if (!dept) throw new Error(`没有部门可挂 ${name}`); + const empRole = await prisma.role.findFirst({ where: { code: 'employee' } }); + if (!empRole) throw new Error('没有 employee 角色'); + let emp = await prisma.employee.findFirst({ where: { OR: [{ name }, { employeeNo }] } }); + if (!emp) { + emp = await prisma.employee.create({ + data: { + name, + employeeNo, + departmentId: dept.id, + title: '员工', + employmentStatus: 'LEFT', + remark: `OA 报销有此人(原部门 ${deptName}),人事花名册未导入,补建后挂单据,账号停用。`, + }, + }); + } + const user = await prisma.user.create({ + data: { + username, + displayName: name, + passwordHash, + status: 'DISABLED', + employeeId: emp.id, + }, + }); + await prisma.userRole.create({ data: { userId: user.id, roleId: empRole.id } }); + console.log('created leftover', name, user.id, 'dept', dept.name); + return user.id; +} + +async function main() { + const admin = await prisma.user.findFirst({ where: { username: 'admin' } }); + if (!admin) throw new Error('no admin'); + await ensureUser('李乐菡', 'lilehan', '项目一部', 'E0115', admin.passwordHash); + + const users = await prisma.user.findMany(); + const byName = new Map(users.map((u) => [u.displayName, u.id])); + const ALIAS: Record = { 张靖妍: '张婧妍' }; + for (const [from, to] of Object.entries(ALIAS)) { + const uid = byName.get(to); + if (uid) byName.set(from, uid); + } + const expenses = await prisma.expenseClaim.findMany({ select: { id: true, remark: true, applicantId: true, status: true } }); + let n = 0; + for (const e of expenses) { + const name = (e.remark || '').split(' / ')[0]?.trim(); + if (!name || name === '系统管理员') continue; + const uid = byName.get(name); + if (!uid || uid === e.applicantId) continue; + await prisma.expenseClaim.update({ where: { id: e.id }, data: { applicantId: uid } }); + n += 1; + } + const loans = await prisma.loanRecord.findMany({ select: { id: true, remark: true, applicantId: true } }); + let ln = 0; + for (const e of loans) { + const name = (e.remark || '').split(' / ')[0]?.trim(); + if (!name || name === '系统管理员') continue; + const uid = byName.get(name); + if (!uid || uid === e.applicantId) continue; + await prisma.loanRecord.update({ where: { id: e.id }, data: { applicantId: uid } }); + ln += 1; + } + + const approvers = await prisma.user.findMany({ + where: { status: 'ACTIVE', roles: { some: { role: { code: { in: ['admin', 'finance', 'owner'] } } } } }, + }); + const pendingExp = await prisma.expenseClaim.findMany({ where: { status: 'PENDING' } }); + const pendingLoan = await prisma.loanRecord.findMany({ where: { status: 'PENDING' } }); + let tasks = 0; + for (const row of pendingExp) { + for (const u of approvers) { + const hit = await prisma.approvalTask.findFirst({ + where: { bizType: 'EXPENSE_APPROVAL', bizId: row.id, assigneeId: u.id, status: 'PENDING' }, + }); + if (hit) continue; + await prisma.approvalTask.create({ + data: { + title: `报销确认:${row.claimNo}`, + bizType: 'EXPENSE_APPROVAL', + bizId: row.id, + assigneeId: u.id, + }, + }); + tasks += 1; + } + } + for (const row of pendingLoan) { + for (const u of approvers) { + const hit = await prisma.approvalTask.findFirst({ + where: { bizType: 'LOAN_APPROVAL', bizId: row.id, assigneeId: u.id, status: 'PENDING' }, + }); + if (hit) continue; + await prisma.approvalTask.create({ + data: { + title: `借款确认:${row.loanNo}`, + bizType: 'LOAN_APPROVAL', + bizId: row.id, + assigneeId: u.id, + }, + }); + tasks += 1; + } + } + + const left = await prisma.expenseClaim.count({ where: { applicantId: admin.id } }); + console.log(JSON.stringify({ rematchedExpenses: n, rematchedLoans: ln, approvalTasks: tasks, adminStillHolds: left })); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/prisma/fix-expense-lines.ts b/apps/api/prisma/fix-expense-lines.ts new file mode 100644 index 0000000..45bf397 --- /dev/null +++ b/apps/api/prisma/fix-expense-lines.ts @@ -0,0 +1,107 @@ +import { Prisma, PrismaClient } from '@prisma/client'; +import { mapExpenseLineKind, parseOaJson } from './oa-json'; + +const prisma = new PrismaClient(); +const FLOW = '/opt/import/prod/oa_flow_finance.json'; + +type Row = Record; + +function str(v: unknown) { + if (v == null) return ''; + return String(v).trim(); +} + +function num(v: unknown) { + if (v == null || v === '') return 0; + const n = Number(String(v).replace(/,/g, '')); + return Number.isFinite(n) ? n : 0; +} + +function when(v: unknown) { + const s = str(v); + if (!s || s.startsWith('0000')) return null; + const d = new Date(s.includes('T') ? s : s.replace(' ', 'T')); + return Number.isNaN(d.getTime()) ? null : d; +} + +function clip(v: unknown, n = 200) { + const s = str(v); + return s.length > n ? s.slice(0, n) : s; +} + +async function main() { + const flow = parseOaJson>(FLOW); + const itemsByReimb = new Map(); + for (const item of flow.reimbursement_item || []) { + const key = str(item.reimbursement_id); + if (!key) continue; + const list = itemsByReimb.get(key) || []; + list.push(item); + itemsByReimb.set(key, list); + } + + let claims = 0; + let lines = 0; + let skipped = 0; + for (const r of flow.reimbursement || []) { + const claimNo = str(r.reimburse_no); + if (!claimNo) continue; + const row = await prisma.expenseClaim.findFirst({ where: { tenantId: '1', claimNo } }); + if (!row) { + skipped += 1; + continue; + } + const incoming = itemsByReimb.get(str(r.id)) || []; + await prisma.$transaction(async (tx) => { + await tx.expenseLine.deleteMany({ where: { claimId: row.id } }); + for (const [i, line] of incoming.entries()) { + await tx.expenseLine.create({ + data: { + claimId: row.id, + kind: mapExpenseLineKind(str(line.item_type), str(line.remark)), + amount: new Prisma.Decimal(num(line.amount)), + invoiceNo: clip(line.invoice_no, 80) || undefined, + occurAt: when(line.expense_date) || undefined, + remark: clip(line.remark || line.item_type, 200) || undefined, + sortNo: num(line.sort_order) || i, + }, + }); + lines += 1; + } + }); + claims += 1; + } + + const sample = await prisma.expenseClaim.findFirst({ + where: { claimNo: 'BX-20260828-002' }, + include: { lines: { orderBy: { sortNo: 'asc' } } }, + }); + const sum = sample?.lines.reduce((s, l) => s + Number(l.amount), 0); + console.log( + JSON.stringify( + { + claims, + lines, + skipped, + sample: sample + ? { + claimNo: sample.claimNo, + header: Number(sample.amount), + lineCount: sample.lines.length, + lineSum: sum, + kinds: sample.lines.map((l) => `${l.kind}:${l.amount}:${l.remark}`), + } + : null, + }, + null, + 2, + ), + ); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/prisma/fix-oa-posts.ts b/apps/api/prisma/fix-oa-posts.ts new file mode 100644 index 0000000..5280f73 --- /dev/null +++ b/apps/api/prisma/fix-oa-posts.ts @@ -0,0 +1,141 @@ +import { PrismaClient } from '@prisma/client'; +import * as fs from 'fs'; + +const prisma = new PrismaClient(); +const DUMP = '/opt/import/prod/oa_hr.json'; + +const ROLES: { code: string; name: string; dataScope: string }[] = [ + { code: 'rd_director', name: '研发总监', dataScope: 'DEPT' }, + { code: '3d_director', name: '三维总监', dataScope: 'DEPT' }, + { code: 'video_director', name: '摄制总监', dataScope: 'DEPT' }, + { code: 'material_director', name: '物资总监', dataScope: 'DEPT' }, + { code: 'pm', name: '项目经理', dataScope: 'DEPT' }, + { code: 'rd_staff', name: '研发技术岗', dataScope: 'SELF' }, + { code: '3d_staff', name: '三维技术岗', dataScope: 'SELF' }, + { code: 'video_staff', name: '摄制技术岗', dataScope: 'SELF' }, + { code: 'material_staff', name: '物资技术岗', dataScope: 'SELF' }, + { code: 'tech', name: '普通岗位', dataScope: 'SELF' }, +]; + +const POST_ROLE: Record = { + sysadmin: 'admin', + boss: 'owner', + business_director: 'biz_director', + business_staff: 'biz_staff', + rd_director: 'rd_director', + '3d_director': '3d_director', + video_director: 'video_director', + material_director: 'material_director', + pm: 'pm', + tech: 'tech', + rd_staff: 'rd_staff', + '3d_staff': '3d_staff', + video_staff: 'video_staff', + material_staff: 'material_staff', +}; + +function pickRole(userName: string, postCodes: string[]) { + if (userName === 'xibei') return 'employee'; + if (userName === 'administrator' || userName === 'admin') return 'admin'; + const rank: Record = { + employee: 0, + tech: 1, + rd_staff: 1, + '3d_staff': 1, + video_staff: 1, + material_staff: 1, + pm: 2, + biz_staff: 3, + rd_director: 4, + '3d_director': 4, + video_director: 4, + material_director: 4, + tech_director: 4, + biz_director: 5, + owner: 6, + admin: 7, + }; + let best: string | null = null; + let bestRank = -1; + for (const c of postCodes) { + const role = POST_ROLE[c]; + if (!role) continue; + const r = rank[role] ?? 1; + if (r > bestRank) { + best = role; + bestRank = r; + } + } + return best || 'employee'; +} + +async function main() { + for (const r of ROLES) { + const existing = await prisma.role.findFirst({ where: { code: r.code } }); + if (existing) { + await prisma.role.update({ where: { id: existing.id }, data: { name: r.name, dataScope: r.dataScope } }); + } else { + await prisma.role.create({ data: { name: r.name, code: r.code, dataScope: r.dataScope } }); + } + } + await prisma.role.updateMany({ where: { code: 'biz_staff' }, data: { name: '商务专员' } }); + await prisma.role.updateMany({ where: { code: 'biz_director' }, data: { name: '商务总监' } }); + await prisma.role.updateMany({ where: { code: 'owner' }, data: { name: '企业负责人' } }); + + const dump = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as { + posts: { id: number; code: string; name: string }[]; + users: { id: number; userName: string; nickName: string }[]; + userPosts: { postId: number; userId: number }[]; + }; + const postById = new Map(dump.posts.map((p) => [p.id, p])); + const postsByUser = new Map(); + for (const up of dump.userPosts) { + const post = postById.get(up.postId); + if (!post) continue; + const list = postsByUser.get(up.userId) || []; + list.push(post); + postsByUser.set(up.userId, list); + } + + const roles = await prisma.role.findMany(); + const roleByCode = new Map(roles.map((r) => [r.code, r.id])); + let n = 0; + for (const u of dump.users) { + if (u.userName === 'administrator') continue; + const user = await prisma.user.findFirst({ where: { username: u.userName } }); + if (!user) continue; + const posts = postsByUser.get(u.id) || []; + const roleCode = pickRole(u.userName, posts.map((p) => p.code)); + const roleId = roleByCode.get(roleCode); + if (!roleId) continue; + await prisma.userRole.deleteMany({ where: { userId: user.id } }); + await prisma.userRole.create({ data: { userId: user.id, roleId } }); + const title = posts.find((p) => POST_ROLE[p.code] === roleCode)?.name || posts[0]?.name; + if (title && user.employeeId) { + await prisma.employee.update({ where: { id: user.employeeId }, data: { title } }); + } + n += 1; + } + + const delQual = await prisma.qualification.deleteMany({ where: { code: 'QUAL_P6_OLD' } }); + + const cancelled = await prisma.approvalTask.updateMany({ + where: { bizType: 'EXPENSE_APPROVAL', status: 'PENDING' }, + data: { status: 'CANCELLED' }, + }); + + console.log( + JSON.stringify({ + reassignedUsers: n, + deletedTestQuals: delQual.count, + cancelledExpenseApprovals: cancelled.count, + }), + ); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/prisma/grant-admin-equivalents.ts b/apps/api/prisma/grant-admin-equivalents.ts new file mode 100644 index 0000000..8f4e75b --- /dev/null +++ b/apps/api/prisma/grant-admin-equivalents.ts @@ -0,0 +1,40 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + const admin = await prisma.role.findFirst({ where: { code: 'admin' } }); + if (!admin) throw new Error('缺少 admin 角色'); + await prisma.role.updateMany({ where: { code: 'rd_director' }, data: { dataScope: 'ALL' } }); + + const users = await prisma.user.findMany({ + where: { + status: 'ACTIVE', + OR: [ + { displayName: { contains: '潘兴辉' } }, + { employee: { name: { contains: '潘兴辉' } } }, + { roles: { some: { role: { code: 'rd_director' } } } }, + ], + }, + select: { id: true, displayName: true, username: true }, + }); + if (!users.length) throw new Error('没有找到潘兴辉或研发总监账号'); + + const result = await prisma.userRole.createMany({ + data: users.map((u) => ({ userId: u.id, roleId: admin.id })), + skipDuplicates: true, + }); + console.log( + `granted admin to: ${users.map((u) => u.displayName).join('、')} (new links ${result.count})`, + ); +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/prisma/grant-sun-chunqing.ts b/apps/api/prisma/grant-sun-chunqing.ts new file mode 100644 index 0000000..13ac52a --- /dev/null +++ b/apps/api/prisma/grant-sun-chunqing.ts @@ -0,0 +1,41 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + const user = await prisma.user.findFirst({ + where: { + status: 'ACTIVE', + OR: [{ displayName: { contains: '孙春庆' } }, { employee: { name: { contains: '孙春庆' } } }], + }, + include: { roles: true }, + }); + if (!user) throw new Error('没有找到孙春庆'); + + const role = + (await prisma.role.findFirst({ where: { code: 'bid_screener' } })) || + (await prisma.role.create({ + data: { name: '项目筛选发起', code: 'bid_screener', dataScope: 'SELF' }, + })); + + const perms = await prisma.permission.findMany({ + where: { code: { in: ['office', 'office:overview', 'bid', 'bid:screening'] } }, + }); + await prisma.rolePermission.createMany({ + data: perms.map((p) => ({ roleId: role.id, permissionId: p.id })), + skipDuplicates: true, + }); + await prisma.userRole.createMany({ + data: [{ userId: user.id, roleId: role.id }], + skipDuplicates: true, + }); + console.log(`granted bid screening to ${user.displayName} (${user.username})`); +} + +main() + .then(async () => prisma.$disconnect()) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/prisma/import-hr.ts b/apps/api/prisma/import-hr.ts new file mode 100644 index 0000000..5e63d20 --- /dev/null +++ b/apps/api/prisma/import-hr.ts @@ -0,0 +1,587 @@ +// @ts-nocheck +import { PrismaClient } from '@prisma/client'; +import * as argon2 from 'argon2'; +import * as fs from 'fs'; +import * as path from 'path'; + +const prisma = new PrismaClient(); +const DUMP = path.resolve('/opt/import/prod/oa_hr.json'); +const DEFAULT_PASSWORD = '123456'; + +const DEMO_USERS = [ + 'owner', + 'bizdir', + 'bizstaff', + 'dirsoft', + 'dir3d', + 'dirfilm', + 'dirmaterial', + 'finance', + 'hr', + 'employee', + 'softpm', + 'batchdemo1', +]; + +const DEPT_META: Record = { + 1: { deptType: 'ADMIN' }, + 2: { deptType: 'ADMIN' }, + 4: { deptType: 'BUSINESS' }, + 7: { deptType: 'ADMIN' }, + 8: { deptType: 'ADMIN' }, + 9: { deptType: 'TECH', categoryCode: 'CAT_SOFTWARE' }, + 10: { deptType: 'TECH', categoryCode: 'CAT_SOFTWARE' }, + 19: { deptType: 'TECH', categoryCode: 'CAT_FILM' }, + 23: { deptType: 'TECH', categoryCode: 'CAT_3D' }, + 210: { deptType: 'TECH', categoryCode: 'CAT_SOFTWARE' }, + 211: { deptType: 'TECH', categoryCode: 'CAT_3D' }, + 212: { deptType: 'TECH', categoryCode: 'CAT_FILM' }, + 213: { deptType: 'TECH', categoryCode: 'CAT_MATERIAL' }, + 214: { deptType: 'TECH', categoryCode: 'CAT_SOFTWARE' }, +}; + +const OLD_DEPT_TO_OA: Record = { + ROOT: 'OA_1', + BIZ: 'OA_4', + TECH_SOFTWARE: 'OA_9', + TECH_3D: 'OA_211', + TECH_FILM: 'OA_212', + TECH_MATERIAL: 'OA_213', + FIN: 'OA_2', + HR: 'OA_2', +}; + +const DEPT_MANAGER_OA: Record = { + 1: 101, + 2: 101, + 4: 107, + 7: 101, + 8: 101, + 9: 131, + 10: 131, + 19: 102, + 23: 105, + 210: 131, + 211: 105, + 212: 102, + 213: 104, + 214: 131, +}; + +const POST_ROLE: Record = { + sysadmin: 'admin', + boss: 'owner', + business_director: 'biz_director', + business_staff: 'biz_staff', + rd_director: 'rd_director', + '3d_director': '3d_director', + video_director: 'video_director', + material_director: 'material_director', + pm: 'pm', + tech: 'tech', + rd_staff: 'rd_staff', + '3d_staff': '3d_staff', + video_staff: 'video_staff', + material_staff: 'material_staff', +}; + +const ROLE_RANK: Record = { + employee: 0, + tech: 1, + rd_staff: 1, + '3d_staff': 1, + video_staff: 1, + material_staff: 1, + pm: 2, + biz_staff: 3, + tech_director: 4, + rd_director: 4, + '3d_director': 4, + video_director: 4, + material_director: 4, + biz_director: 5, + owner: 6, + admin: 7, +}; + +type Dump = { + depts: { id: number; name: string; parentId: number; orderNum: number; delFlag: string }[]; + posts: { id: number; code: string; name: string }[]; + users: { + id: number; + sex: string; + email: string; + phone: string; + deptId: number; + remark: string | null; + status: string; + nickName: string; + userName: string; + entryDate: string | null; + leaveDate: string | null; + employmentStatus: string; + }[]; + userPosts: { postId: number; userId: number }[]; +}; + +function empNo(oaId: number) { + return `E${String(oaId).padStart(4, '0')}`; +} + +function gender(sex: string) { + if (sex === '0') return 'MALE'; + if (sex === '1') return 'FEMALE'; + return null; +} + +function empStatus(code: string) { + if (code === '1') return 'INTERN'; + if (code === '2') return 'PROBATION'; + if (code === '4') return 'LEFT'; + return 'REGULAR'; +} + +function when(v: string | null) { + if (!v || v.startsWith('0000')) return null; + const d = new Date(v.includes('T') ? v : `${v}T00:00:00`); + return Number.isNaN(d.getTime()) ? null : d; +} + +function addMonths(d: Date, n: number) { + const x = new Date(d); + x.setMonth(x.getMonth() + n); + return x; +} + +function pickRole(userName: string, postCodes: string[]) { + if (userName === 'xibei') return 'employee'; + let best: string | null = null; + let bestRank = -1; + for (const c of postCodes) { + const role = POST_ROLE[c]; + if (!role) continue; + const r = ROLE_RANK[role] ?? 1; + if (r > bestRank) { + best = role; + bestRank = r; + } + } + return best || 'employee'; +} + +async function remumber( + rows: T[], + prefix: string, + update: (id: string, no: string) => Promise, +) { + for (const r of rows) await update(r.id, `TMP-${r.id.replace(/-/g, '').slice(0, 20)}`); + const groups = new Map(); + const sorted = [...rows].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id)); + for (const r of sorted) { + const y = r.createdAt.getFullYear(); + const list = groups.get(y) || []; + list.push(r); + groups.set(y, list); + } + for (const [year, list] of [...groups.entries()].sort((a, b) => a[0] - b[0])) { + let i = 1; + for (const r of list) { + await update(r.id, `${prefix}-${year}-${String(i).padStart(5, '0')}`); + i += 1; + } + } +} + +async function remapDeptFks(codeToId: Map) { + const old = await prisma.department.findMany({ where: { tenantId: '1' } }); + const oldByCode = new Map(old.map((d) => [d.code, d.id])); + const mapping = new Map(); + for (const d of old) { + const targetCode = OLD_DEPT_TO_OA[d.code] || (d.code.startsWith('OA_') ? d.code : 'OA_2'); + const nid = codeToId.get(targetCode); + if (nid && nid !== d.id) mapping.set(d.id, nid); + } + for (const [from, to] of mapping) { + await prisma.projectTask.updateMany({ where: { departmentId: from }, data: { departmentId: to } }); + await prisma.employmentEvent.updateMany({ where: { fromDeptId: from }, data: { fromDeptId: to } }); + await prisma.employmentEvent.updateMany({ where: { toDeptId: from }, data: { toDeptId: to } }); + } + return { oldByCode, mapping }; +} + +async function reassignDemoUsers(demoIds: string[], adminId: string) { + if (!demoIds.length) return; + await prisma.bidReviewer.deleteMany({ where: { userId: { in: demoIds } } }); + await prisma.bidAssignee.deleteMany({ where: { userId: { in: demoIds } } }); + await prisma.contractReviewer.deleteMany({ where: { userId: { in: demoIds } } }); + await prisma.todoTask.deleteMany({ where: { assigneeId: { in: demoIds } } }); + await prisma.approvalTask.deleteMany({ where: { assigneeId: { in: demoIds } } }); + await prisma.workReport.deleteMany({ where: { OR: [{ authorId: { in: demoIds } }, { toUserId: { in: demoIds } }, { ccUserId: { in: demoIds } }] } }); + await prisma.refreshToken.deleteMany({ where: { userId: { in: demoIds } } }); + await prisma.userRole.deleteMany({ where: { userId: { in: demoIds } } }); + + const patch = async (model: string, field: string) => { + const delegate = (prisma as unknown as Record Promise }>)[model]; + if (!delegate?.updateMany) return; + try { + await delegate.updateMany({ + where: { [field]: { in: demoIds } }, + data: { [field]: adminId }, + }); + } catch (e) { + console.warn(`skip ${model}.${field}`, (e as Error).message); + } + }; + + await patch('bidCase', 'createdById'); + await patch('bidActionLog', 'actorId'); + await patch('bidDocVersion', 'submittedById'); + await patch('contract', 'createdById'); + await patch('contractActionLog', 'actorId'); + await patch('expenseClaim', 'applicantId'); + await patch('expenseClaim', 'decidedById'); + await patch('loanRecord', 'applicantId'); + await patch('loanRecord', 'decidedById'); + await patch('sealRequest', 'applicantId'); + await patch('sealRequest', 'decidedById'); + await patch('timesheet', 'userId'); + await patch('projectTask', 'assigneeId'); + await patch('projectAcceptance', 'actorId'); + await patch('employmentEvent', 'actorId'); + await patch('attendanceMonth', 'importedById'); + await patch('payrollRun', 'createdById'); + await patch('purchaseRequest', 'applicantId'); + await patch('purchaseRequest', 'decidedById'); + await patch('paymentRecord', 'createdById'); + await patch('projectChange', 'actorId'); + await patch('assetOccupancy', 'holderId'); + await patch('invoiceRecord', 'createdById'); + await patch('operationLog', 'userId'); + await patch('credentialBorrow', 'borrowerId'); + + await prisma.user.deleteMany({ where: { id: { in: demoIds } } }); +} + +async function main() { + const dump = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Dump; + const hash = await argon2.hash(DEFAULT_PASSWORD); + const posts = new Map(dump.posts.map((p) => [p.id, p])); + const postsByUser = new Map(); + const postNamesByUser = new Map(); + for (const up of dump.userPosts) { + const p = posts.get(up.postId); + if (!p) continue; + postsByUser.set(up.userId, [...(postsByUser.get(up.userId) || []), p.code]); + postNamesByUser.set(up.userId, [...(postNamesByUser.get(up.userId) || []), p.name]); + } + + await prisma.role.updateMany({ where: { code: { in: ['admin', 'owner', 'finance', 'hr'] } }, data: { dataScope: 'ALL' } }); + await prisma.role.updateMany({ where: { code: { in: ['biz_director', 'tech_director'] } }, data: { dataScope: 'DEPT' } }); + await prisma.role.updateMany({ where: { code: { in: ['biz_staff', 'employee'] } }, data: { dataScope: 'SELF' } }); + + const numberRules = [ + { code: 'BID', name: '投标编号', prefix: 'BID', padLength: 5 }, + { code: 'HT', name: '合同编号', prefix: 'HT', padLength: 5 }, + { code: 'PRJ', name: '项目编号', prefix: 'PRJ', padLength: 5 }, + { code: 'BX', name: '报销编号', prefix: 'BX', padLength: 5 }, + { code: 'JK', name: '借款编号', prefix: 'JK', padLength: 5 }, + { code: 'BZ', name: '保证金编号', prefix: 'BZ', padLength: 5 }, + ]; + for (const r of numberRules) { + await prisma.numberRule.upsert({ + where: { tenantId_code: { tenantId: '1', code: r.code } }, + update: { prefix: r.prefix, padLength: r.padLength, name: r.name }, + create: { ...r, tenantId: '1' }, + }); + } + + const roles = await prisma.role.findMany(); + const roleByCode = new Map(roles.map((r) => [r.code, r.id])); + + const codeToId = new Map(); + const sortedDepts = [...dump.depts].sort((a, b) => a.parentId - b.parentId || a.orderNum - b.orderNum); + for (const d of sortedDepts) { + const code = `OA_${d.id}`; + const meta = DEPT_META[d.id] || { deptType: 'ADMIN' }; + const parentCode = d.parentId ? `OA_${d.parentId}` : null; + const parentId = parentCode ? codeToId.get(parentCode) || null : null; + const existing = await prisma.department.findFirst({ where: { tenantId: '1', code } }); + const row = existing + ? await prisma.department.update({ + where: { id: existing.id }, + data: { + name: d.name, + deptType: meta.deptType, + categoryCode: meta.categoryCode || null, + sortNo: d.orderNum, + parentId, + }, + }) + : await prisma.department.create({ + data: { + name: d.name, + code, + deptType: meta.deptType, + categoryCode: meta.categoryCode || null, + sortNo: d.orderNum, + parentId, + }, + }); + codeToId.set(code, row.id); + } + + await remapDeptFks(codeToId); + + const officeId = codeToId.get('OA_2')!; + const empByOa = new Map(); + const empByName = new Map(); + + const adminUser = await prisma.user.findFirst({ where: { username: 'admin' } }); + if (!adminUser) throw new Error('本地必须先有 admin 账号'); + + const adminEmpId = + adminUser.employeeId || + ( + await prisma.employee.create({ + data: { + name: '系统管理员', + employeeNo: 'E0000', + departmentId: officeId, + title: '系统管理员', + employmentStatus: 'REGULAR', + }, + }) + ).id; + + const adminOa = dump.users.find((u) => u.userName === 'administrator'); + await prisma.employee.update({ + where: { id: adminEmpId }, + data: { + name: adminOa?.nickName || '系统管理员', + employeeNo: 'E0000', + departmentId: officeId, + title: '系统管理员', + employmentStatus: 'REGULAR', + hiredAt: when(adminOa?.entryDate || '2026-06-04'), + gender: gender(adminOa?.sex || '2'), + email: adminOa?.email || null, + mobile: adminOa?.phone || null, + remark: adminOa?.remark || '超级管理员', + managerId: null, + }, + }); + empByOa.set(1, adminEmpId); + empByName.set('系统管理员', adminEmpId); + + for (const u of dump.users) { + if (u.userName === 'administrator') continue; + const deptId = codeToId.get(`OA_${u.deptId}`) || officeId; + const status = empStatus(u.employmentStatus); + const hiredAt = when(u.entryDate); + const titles = postNamesByUser.get(u.id) || []; + const title = titles[0] || u.remark || '员工'; + const no = empNo(u.id); + const data = { + name: u.nickName, + departmentId: deptId, + title, + restSchedule: DEPT_META[u.deptId]?.categoryCode === 'CAT_SOFTWARE' ? 'DOUBLE' : 'SINGLE', + employmentStatus: status, + hiredAt, + internStartAt: status === 'INTERN' ? hiredAt : null, + probationEndAt: status === 'PROBATION' && hiredAt ? addMonths(hiredAt, 3) : null, + leftAt: when(u.leaveDate), + gender: gender(u.sex), + email: u.email || null, + mobile: u.phone || null, + remark: u.remark, + }; + const existing = await prisma.employee.findFirst({ where: { tenantId: '1', employeeNo: no } }); + const row = existing + ? await prisma.employee.update({ where: { id: existing.id }, data }) + : await prisma.employee.create({ data: { ...data, employeeNo: no } }); + empByOa.set(u.id, row.id); + empByName.set(u.nickName, row.id); + } + + for (const u of dump.users) { + if (u.userName === 'administrator') continue; + const mgrOa = DEPT_MANAGER_OA[u.deptId]; + if (!mgrOa || mgrOa === u.id) continue; + const managerId = empByOa.get(mgrOa); + const id = empByOa.get(u.id); + if (managerId && id) await prisma.employee.update({ where: { id }, data: { managerId } }); + } + + await prisma.user.update({ + where: { id: adminUser.id }, + data: { + passwordHash: hash, + displayName: '系统管理员', + employeeId: adminEmpId, + status: 'ACTIVE', + }, + }); + + for (const u of dump.users) { + if (u.userName === 'administrator') continue; + const employeeId = empByOa.get(u.id)!; + const roleCode = pickRole(u.userName, postsByUser.get(u.id) || []); + const roleId = roleByCode.get(roleCode); + if (!roleId) throw new Error(`缺少角色 ${roleCode}`); + const disabled = u.status === '1' || u.employmentStatus === '4'; + const existing = await prisma.user.findFirst({ where: { tenantId: '1', username: u.userName } }); + const row = existing + ? await prisma.user.update({ + where: { id: existing.id }, + data: { + passwordHash: hash, + displayName: u.nickName, + employeeId, + status: disabled ? 'DISABLED' : 'ACTIVE', + }, + }) + : await prisma.user.create({ + data: { + username: u.userName, + passwordHash: hash, + displayName: u.nickName, + employeeId, + status: disabled ? 'DISABLED' : 'ACTIVE', + }, + }); + await prisma.userRole.deleteMany({ where: { userId: row.id } }); + await prisma.userRole.create({ data: { userId: row.id, roleId } }); + } + + const users = await prisma.user.findMany({ include: { employee: true } }); + const userByName = new Map(users.map((u) => [u.displayName, u.id])); + userByName.set('系统管理员', adminUser.id); + + const rematchByText = async ( + model: 'expenseClaim' | 'loanRecord' | 'sealRequest', + field: 'remark' | 'reason', + ) => { + const rows = await prisma[model].findMany({ select: { id: true, [field]: true } as never }); + let n = 0; + for (const r of rows as { id: string; remark?: string; reason?: string }[]) { + const name = ((r.remark || r.reason || '') as string).split(' / ')[0]?.trim(); + if (!name) continue; + const uid = userByName.get(name); + if (!uid) continue; + await prisma[model].update({ where: { id: r.id }, data: { applicantId: uid } }); + n += 1; + } + console.log(`matched ${model}`, n); + }; + await rematchByText('expenseClaim', 'remark'); + await rematchByText('loanRecord', 'remark'); + await rematchByText('sealRequest', 'reason'); + + const keepUsernames = new Set(['admin', ...dump.users.filter((u) => u.userName !== 'administrator').map((u) => u.userName)]); + const demo = await prisma.user.findMany({ + where: { OR: [{ username: { in: DEMO_USERS } }, { username: { notIn: [...keepUsernames] } }] }, + }); + const demoIds = demo.filter((u) => u.username !== 'admin').map((u) => u.id); + console.log( + 'delete demo users', + demo.filter((u) => u.username !== 'admin').map((u) => u.username), + ); + await reassignDemoUsers(demoIds, adminUser.id); + + const keepEmp = new Set([adminEmpId, ...empByOa.values()]); + const leftoverEmp = await prisma.employee.findMany({ where: { id: { notIn: [...keepEmp] } } }); + const leftoverIds = leftoverEmp.map((e) => e.id); + if (leftoverIds.length) { + await prisma.payrollItem.deleteMany({ where: { employeeId: { in: leftoverIds } } }); + await prisma.attendanceMonth.deleteMany({ where: { employeeId: { in: leftoverIds } } }); + await prisma.employmentEvent.deleteMany({ where: { employeeId: { in: leftoverIds } } }); + await prisma.user.updateMany({ where: { employeeId: { in: leftoverIds } }, data: { employeeId: null } }); + await prisma.employee.updateMany({ where: { managerId: { in: leftoverIds } }, data: { managerId: null } }); + await prisma.employee.deleteMany({ where: { id: { in: leftoverIds } } }); + } + console.log( + 'deleted demo employees', + leftoverEmp.map((e) => e.name), + ); + + const keepDept = new Set(codeToId.values()); + const leftoverDept = await prisma.department.findMany({ where: { id: { notIn: [...keepDept] } } }); + if (leftoverDept.length) { + const leftoverDeptIds = leftoverDept.map((d) => d.id); + await prisma.employee.updateMany({ + where: { departmentId: { in: leftoverDeptIds } }, + data: { departmentId: officeId }, + }); + await prisma.projectTask.updateMany({ + where: { departmentId: { in: leftoverDeptIds } }, + data: { departmentId: officeId }, + }); + await prisma.department.updateMany({ where: { id: { in: leftoverDeptIds } }, data: { parentId: null } }); + await prisma.department.deleteMany({ where: { id: { in: leftoverDeptIds } } }); + } + console.log( + 'deleted demo depts', + leftoverDept.map((d) => `${d.code}:${d.name}`), + ); + + const demoBid = await prisma.bidCase.findMany({ where: { name: '某市展厅数字化升级' } }); + for (const b of demoBid) { + const used = + (await prisma.expenseClaim.count({ where: { bidCaseId: b.id } })) + + (await prisma.contract.count({ where: { bidCaseId: b.id } })); + if (used) continue; + await prisma.bidReviewer.deleteMany({ where: { bidCaseId: b.id } }); + await prisma.bidAssignee.deleteMany({ where: { bidCaseId: b.id } }); + await prisma.bidActionLog.deleteMany({ where: { bidCaseId: b.id } }); + await prisma.bidDocVersion.deleteMany({ where: { bidCaseId: b.id } }); + await prisma.bondRecord.deleteMany({ where: { bidCaseId: b.id } }); + await prisma.sealRequest.deleteMany({ where: { bidCaseId: b.id } }); + await prisma.bidCase.delete({ where: { id: b.id } }); + console.log('deleted demo bid', b.bidNo); + } + + await remumber( + await prisma.bidCase.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }), + 'BID', + (id, bidNo) => prisma.bidCase.update({ where: { id }, data: { bidNo } }), + ); + await remumber( + await prisma.contract.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }), + 'HT', + (id, contractNo) => prisma.contract.update({ where: { id }, data: { contractNo } }), + ); + await remumber( + await prisma.project.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }), + 'PRJ', + (id, projectNo) => prisma.project.update({ where: { id }, data: { projectNo } }), + ); + await remumber( + await prisma.expenseClaim.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }), + 'BX', + (id, claimNo) => prisma.expenseClaim.update({ where: { id }, data: { claimNo } }), + ); + await remumber( + await prisma.loanRecord.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }), + 'JK', + (id, loanNo) => prisma.loanRecord.update({ where: { id }, data: { loanNo } }), + ); + await remumber( + await prisma.bondRecord.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }), + 'BZ', + (id, bondNo) => prisma.bondRecord.update({ where: { id }, data: { bondNo } }), + ); + + const empCount = await prisma.employee.count(); + const userCount = await prisma.user.count(); + const deptCount = await prisma.department.count(); + const sample = await prisma.expenseClaim.findMany({ take: 3, include: { applicant: true }, orderBy: { createdAt: 'desc' } }); + console.log({ empCount, userCount, deptCount, sampleNos: sample.map((s) => `${s.claimNo}:${s.applicant.displayName}`) }); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/prisma/import-notices-from-oa.ts b/apps/api/prisma/import-notices-from-oa.ts new file mode 100644 index 0000000..aea2fe3 --- /dev/null +++ b/apps/api/prisma/import-notices-from-oa.ts @@ -0,0 +1,306 @@ +import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { createGunzip } from 'zlib'; +import { basename, join } from 'path'; +import { createHash, randomUUID } from 'crypto'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); +const DUMP = process.env.OA_DUMP || '/opt/backup/cloud-20260830/oa.sql.gz'; +const UPLOAD_ROOT = process.env.OA_UPLOAD || '/opt/backup/cloud-20260830/oa-uploadPath'; +const SKIP_TITLE = /已更新至|发现新版本|推荐更新|桌面端|移动端|^App /; + +function stableId(key: string) { + const hex = createHash('sha1').update(key).digest('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +function parseMysqlTuples(valuesSql: string): unknown[][] { + const rows: unknown[][] = []; + let i = 0; + const s = valuesSql; + while (i < s.length) { + while (i < s.length && ' \n\r\t,'.includes(s[i])) i += 1; + if (i >= s.length) break; + if (s[i] !== '(') throw new Error(`expected ( at ${i}`); + i += 1; + const row: unknown[] = []; + while (true) { + while (i < s.length && ' \n\r\t'.includes(s[i])) i += 1; + if (s.startsWith('_binary ', i)) i += 8; + if (s.startsWith('NULL', i) && (i + 4 === s.length || ',) \n\r\t'.includes(s[i + 4]))) { + row.push(null); + i += 4; + } else if (s[i] === "'" || s[i] === '"') { + const q = s[i]; + i += 1; + let out = ''; + while (i < s.length) { + const ch = s[i]; + if (ch === '\\') { + const n = s[i + 1]; + out += n === 'n' ? '\n' : n === 'r' ? '\r' : n === 't' ? '\t' : n === '0' ? '\0' : n; + i += 2; + continue; + } + if (ch === q) { + if (s[i + 1] === q) { + out += q; + i += 2; + continue; + } + i += 1; + break; + } + out += ch; + i += 1; + } + row.push(out); + } else { + const start = i; + while (i < s.length && s[i] !== ',' && s[i] !== ')') i += 1; + const raw = s.slice(start, i).trim(); + row.push(raw === '' ? null : /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw); + } + while (i < s.length && ' \n\r\t'.includes(s[i])) i += 1; + if (s[i] === ',') { + i += 1; + continue; + } + if (s[i] === ')') { + i += 1; + rows.push(row); + break; + } + throw new Error(`bad token at ${i}: ${s.slice(i, i + 20)}`); + } + } + return rows; +} + +async function readDump(): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + createReadStream(DUMP) + .pipe(createGunzip()) + .on('data', (c: Buffer) => chunks.push(c)) + .on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + .on('error', reject); + }); +} + +function insertsFor(dump: string, table: string) { + const rows: unknown[][] = []; + const needle = `INSERT INTO \`${table}\` VALUES `; + let from = 0; + while (true) { + const start = dump.indexOf(needle, from); + if (start < 0) break; + let i = start + needle.length; + const s = dump; + while (i < s.length) { + while (i < s.length && ' \n\r\t'.includes(s[i])) i += 1; + if (s[i] === ';') { + i += 1; + break; + } + if (s[i] === ',') { + i += 1; + continue; + } + if (s[i] !== '(') break; + const sliceStart = i; + let depth = 0; + let inStr = false; + let q = ''; + while (i < s.length) { + const ch = s[i]; + if (inStr) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === q) { + inStr = false; + } + i += 1; + continue; + } + if (ch === "'" || ch === '"') { + inStr = true; + q = ch; + i += 1; + continue; + } + if (ch === '(') depth += 1; + if (ch === ')') { + depth -= 1; + if (depth === 0) { + i += 1; + break; + } + } + i += 1; + } + rows.push(...parseMysqlTuples(s.slice(sliceStart, i))); + } + from = i; + } + return rows; +} + +function mimeOf(name: string) { + const lower = name.toLowerCase(); + if (lower.endsWith('.pdf')) return 'application/pdf'; + if (lower.endsWith('.docx')) return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + if (lower.endsWith('.doc')) return 'application/msword'; + if (lower.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + if (lower.endsWith('.xls')) return 'application/vnd.ms-excel'; + if (lower.endsWith('.png')) return 'image/png'; + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'; + return 'application/octet-stream'; +} + +function resolveUpload(rel: string) { + const cleaned = rel.replace(/^\/profile\//, '').replace(/^\//, ''); + const candidates = [join(UPLOAD_ROOT, cleaned), join(UPLOAD_ROOT, 'upload', cleaned.replace(/^upload\//, ''))]; + return candidates.find((p) => existsSync(p)); +} + +async function attachFile(noticeId: string, rel: string, authorId: string) { + if (!rel?.trim()) return; + const src = resolveUpload(rel); + if (!src) { + console.warn('missing attachment', rel); + return; + } + const fileName = basename(src); + const buf = readFileSync(src); + const id = stableId(`oa-file:${rel}`); + const ext = (fileName.split('.').pop() || 'bin').replace(/[^a-zA-Z0-9]/g, '').slice(0, 8); + const storageKey = `NOTICE/${noticeId}/${id}.${ext || 'bin'}`; + const full = join(process.cwd(), 'uploads', storageKey); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, buf); + await prisma.fileAsset.upsert({ + where: { id }, + update: { fileName, size: buf.length, mimeType: mimeOf(fileName), storageKey, bizId: noticeId }, + create: { + id, + tenantId: '1', + bizType: 'NOTICE', + bizId: noticeId, + fileName, + mimeType: mimeOf(fileName), + size: buf.length, + storageKey, + uploadedById: authorId, + }, + }); +} + +async function upsertNotice(opts: { + key: string; + title: string; + content: string; + kind: 'ANNOUNCEMENT' | 'POLICY'; + createdAt?: Date | null; + authorId: string; + attachment?: string | null; +}) { + const title = opts.title.trim(); + if (!title) return; + const id = stableId(opts.key); + const content = opts.content || ''; + await prisma.notice.upsert({ + where: { id }, + update: { title, content, kind: opts.kind, status: 'PUBLISHED' }, + create: { + id, + tenantId: '1', + title, + content, + kind: opts.kind, + status: 'PUBLISHED', + createdById: opts.authorId, + createdAt: opts.createdAt || undefined, + }, + }); + if (opts.attachment) await attachFile(id, opts.attachment, opts.authorId); +} + +function asDate(v: unknown) { + if (!v) return null; + const d = new Date(String(v)); + return Number.isNaN(d.getTime()) ? null : d; +} + +async function main() { + const admin = await prisma.user.findFirst({ where: { username: '18115604840' } }); + if (!admin) throw new Error('缺少管理员账号'); + await prisma.notice.deleteMany({ where: { title: '未命名', status: 'DRAFT' } }); + + const dump = await readDump(); + let n = 0; + + for (const row of insertsFor(dump, 'oa_announcement')) { + const title = String(row[1] || ''); + const status = String(row[4] || ''); + if (status && status !== '正常') continue; + await upsertNotice({ + key: `oa-announcement:${row[0]}`, + title, + content: String(row[15] || ''), + kind: 'ANNOUNCEMENT', + createdAt: asDate(row[17]) || asDate(row[11]), + authorId: admin.id, + attachment: row[14] ? String(row[14]) : null, + }); + n += 1; + } + + for (const row of insertsFor(dump, 'oa_regulation')) { + const title = String(row[1] || ''); + const status = String(row[16] || ''); + if (status && status !== '正常') continue; + await upsertNotice({ + key: `oa-regulation:${row[0]}`, + title, + content: String(row[19] || ''), + kind: 'POLICY', + createdAt: asDate(row[15]) || asDate(row[21]), + authorId: admin.id, + attachment: row[18] ? String(row[18]) : null, + }); + n += 1; + } + + for (const row of insertsFor(dump, 'sys_notice')) { + const type = String(row[2] || ''); + const title = String(row[1] || ''); + const status = String(row[4] || '0'); + if (type !== '2') continue; + if (status === '1') continue; + if (SKIP_TITLE.test(title)) continue; + await upsertNotice({ + key: `sys-notice:${row[0]}`, + title, + content: String(row[3] || ''), + kind: 'ANNOUNCEMENT', + createdAt: asDate(row[7]), + authorId: admin.id, + }); + n += 1; + } + + const total = await prisma.notice.count(); + const files = await prisma.fileAsset.count({ where: { bizType: 'NOTICE' } }); + console.log(`imported ${n} source rows; notices=${total} files=${files}`); +} + +main() + .then(async () => prisma.$disconnect()) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/prisma/import-prod-complete.ts b/apps/api/prisma/import-prod-complete.ts new file mode 100644 index 0000000..6a9ac9f --- /dev/null +++ b/apps/api/prisma/import-prod-complete.ts @@ -0,0 +1,371 @@ +// @ts-nocheck +import { PrismaClient, Prisma } from '@prisma/client'; +import { createHash, randomUUID } from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; + +const prisma = new PrismaClient(); +const DUMP = '/opt/import/prod/oa_core.json'; +const HR = '/opt/import/prod/oa_hr.json'; +const OA_FILES = '/opt/import/prod/oa-files'; +const UPLOAD = path.join(process.cwd(), 'uploads'); + +type Row = Record; + +const MIME: Record = { + pdf: 'application/pdf', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + zip: 'application/zip', + rar: 'application/x-rar-compressed', +}; + +const NAME_ALIAS: Record = { + 张靖妍: '张婧妍', +}; + +function str(v: unknown) { + if (v == null) return ''; + return String(v).trim(); +} + +function num(v: unknown) { + if (v == null || v === '') return 0; + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +function when(v: unknown) { + const s = str(v); + if (!s || s.startsWith('0000')) return null; + const d = new Date(s.includes('T') ? s : s.replace(' ', 'T')); + return Number.isNaN(d.getTime()) ? null : d; +} + +function clip(v: unknown, n = 2000) { + const s = str(v); + return s.length > n ? s.slice(0, n) : s || undefined; +} + +function mapProjectType(v: unknown, projectName?: unknown) { + const name = str(projectName); + if (name.includes('三维')) return '三维'; + if (/(视频.{0,12}(制作|摄制|拍摄)|摄制|拍摄|摄影|宣传片|专题片|纪录片|微电影|影视制作)/.test(name)) return '摄制'; + const s = str(v); + if (s.includes('三维')) return '三维'; + if (s.includes('摄制') || s.includes('视频')) return '摄制'; + if (s.includes('物资')) return '物资'; + if (s.includes('集成')) return '集成'; + return '软件'; +} + +function mapQual(raw: unknown) { + const s = str(raw); + if (!s) return '无需资质'; + try { + const arr = JSON.parse(s) as { item?: string }[]; + const item = str(arr?.[0]?.item); + if (!item || item === '无') return '无需资质'; + if (item.includes('军')) return '军二'; + if (item.includes('涉')) return '涉乙'; + return item.slice(0, 20); + } catch { + if (s.includes('军')) return '军二'; + if (s.includes('涉')) return '涉乙'; + return '其他'; + } +} + +function profilePaths(v: unknown) { + const out: string[] = []; + if (!v) return out; + for (const chunk of str(v).split(/[,;]/)) { + const piece = chunk.trim(); + const first = piece.split('|')[0].trim(); + const idx = first.indexOf('/profile/upload/'); + const p = idx >= 0 ? first.slice(idx) : first; + if (p.startsWith('/profile/upload/')) out.push(p); + } + return out; +} + +function relOf(p: string) { + return p.replace(/^\/profile\/upload\//, ''); +} + +function displayName(p: string) { + const pipe = str(p).split('|')[1]; + if (pipe && !pipe.includes('/')) return path.basename(pipe); + return path.basename(relOf(p.split('|')[0])); +} + +function mimeOf(name: string) { + const ext = name.split('.').pop()?.toLowerCase() || 'bin'; + return MIME[ext] || 'application/octet-stream'; +} + +function prettyName(name: string) { + try { + return decodeURIComponent(name); + } catch { + return name; + } +} + +async function attach( + bizType: string, + bizId: string, + urls: unknown, + uploadedById: string, + seen: Set, +) { + let n = 0; + let firstNas: string | undefined; + for (const raw of profilePaths(urls)) { + const rel = relOf(raw); + const src = path.join(OA_FILES, rel); + if (!fs.existsSync(src)) continue; + const buf = fs.readFileSync(src); + const hash = createHash('sha1').update(buf).digest('hex').slice(0, 16); + const key = `${bizId}:${hash}`; + if (seen.has(key)) continue; + seen.add(key); + const id = randomUUID(); + const ext = (path.extname(rel).replace('.', '') || 'bin').replace(/[^a-zA-Z0-9]/g, '').slice(0, 8); + const storageKey = `OA/${bizType}/${bizId}/${id}.${ext || 'bin'}`; + const dest = path.join(UPLOAD, storageKey); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + const name = prettyName(displayName(raw) || path.basename(rel)); + await prisma.fileAsset.create({ + data: { + id, + tenantId: '1', + bizType, + bizId, + fileName: name.slice(0, 180), + mimeType: mimeOf(name), + size: buf.length, + storageKey, + uploadedById, + }, + }); + n += 1; + firstNas ||= raw; + } + return { n, firstNas }; +} + +async function main() { + const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record; + const hr = JSON.parse(fs.readFileSync(HR, 'utf8')) as { users: { id: number; userName: string; nickName: string }[] }; + const admin = await prisma.user.findFirst({ where: { username: 'admin' } }); + if (!admin) throw new Error('没有 admin'); + fs.mkdirSync(UPLOAD, { recursive: true }); + + const users = await prisma.user.findMany(); + const byUsername = new Map(users.map((u) => [u.username, u])); + const byName = new Map(users.map((u) => [u.displayName, u])); + for (const [a, b] of Object.entries(NAME_ALIAS)) { + const u = byName.get(b); + if (u) byName.set(a, u); + } + const oaUser = new Map(hr.users.map((u) => [String(u.id), u.userName])); + const userFromOaId = (id: unknown) => { + const uname = oaUser.get(str(id)); + return uname ? byUsername.get(uname) : undefined; + }; + const userFromName = (name: unknown) => { + const n = NAME_ALIAS[str(name)] || str(name); + return byName.get(n); + }; + + await prisma.fileAsset.deleteMany({ where: { storageKey: { startsWith: 'OA/' } } }); + const seen = new Set(); + const stats = { bids: 0, bidFiles: 0, contracts: 0, contractFiles: 0, expenses: 0, expenseFiles: 0, projects: 0, purchases: 0 }; + + const bids = await prisma.bidCase.findMany(); + const bidByName = new Map(bids.map((b) => [b.name, b])); + const tenders = (raw.oa_tender || []).filter((t) => /^TB-/.test(str(t.system_number)) && str(t.project_name)); + + for (const t of tenders) { + const local = bidByName.get(str(t.project_name)); + if (!local) { + console.warn('no local bid', t.project_name); + continue; + } + const creator = userFromOaId(t.create_by) || userFromName(t.bid_prepare_person) || admin; + const drafter = userFromName(t.bid_prepare_person); + const bidder = userFromOaId(t.bid_delivery_person_id) || userFromName(t.bid_delivery_person); + const announce = await attach('BID_ANNOUNCE', local.id, t.announcement_file, creator.id, seen); + const extra = await attach('BID_FILE', local.id, t.attachment, creator.id, seen); + const formal = await attach('BID_FORMAL', local.id, t.formal_bid_file, creator.id, seen); + const proof = await attach('BID_PROOF', local.id, t.submit_proof_file, creator.id, seen); + const nas = announce.firstNas || extra.firstNas || formal.firstNas || proof.firstNas; + await prisma.bidCase.update({ + where: { id: local.id }, + data: { + externalNo: clip(t.project_number, 120) || local.externalNo, + projectType: mapProjectType(t.project_type, t.project_name), + source: clip(t.project_source, 50) || local.source, + qualificationNeed: mapQual(t.bid_doc_requirement), + tenderMethod: clip(t.tender_method, 50) || local.tenderMethod, + applyStartAt: when(t.create_time) || local.applyStartAt, + applyEndAt: when(t.file_apply_deadline) || local.applyEndAt, + applyMethod: announce.firstNas || extra.firstNas ? '网上申领' : '其他', + openAt: when(t.bid_open_date) || local.openAt, + openMethod: str(t.tender_address) ? '线下开标' : '其他', + openPlace: clip(t.tender_address, 200) || local.openPlace, + projectNature: clip(t.project_nature, 50) || local.projectNature, + securityLevel: str(t.security_level) || local.securityLevel, + contactName: clip(t.bid_contact_person, 64) || local.contactName, + contactPhone: clip(t.bid_contact_phone, 32) || local.contactPhone, + bondMethod: clip(t.deposit_pay_method, 20) || local.bondMethod, + bondStatus: clip(t.deposit_status, 20) || local.bondStatus, + bondPaidAt: when(t.deposit_pay_date) || local.bondPaidAt, + delivererName: clip(t.bid_delivery_person, 64) || local.delivererName, + priceCap: t.price_limit == null || t.price_limit === '' ? local.priceCap : String(t.price_limit), + quoteAmount: t.bid_quotation == null || t.bid_quotation === '' ? local.quoteAmount : String(t.bid_quotation), + summary: clip(t.project_content || t.project_intro, 4000) || local.summary, + nasPath: nas || local.nasPath, + remark: clip(t.remark, 1000) || local.remark, + createdById: creator.id, + }, + }); + if (drafter) { + await prisma.bidAssignee.upsert({ + where: { bidCaseId_userId_role: { bidCaseId: local.id, userId: drafter.id, role: 'DRAFTER' } }, + update: {}, + create: { bidCaseId: local.id, userId: drafter.id, role: 'DRAFTER' }, + }); + } + if (bidder) { + await prisma.bidAssignee.upsert({ + where: { bidCaseId_userId_role: { bidCaseId: local.id, userId: bidder.id, role: 'BIDDER' } }, + update: {}, + create: { bidCaseId: local.id, userId: bidder.id, role: 'BIDDER' }, + }); + } + if (formal.firstNas) { + const exists = await prisma.bidDocVersion.findFirst({ where: { bidCaseId: local.id, versionNo: 1 } }); + if (!exists) { + await prisma.bidDocVersion.create({ + data: { + bidCaseId: local.id, + versionNo: 1, + nasPath: formal.firstNas, + note: 'OA 正式标书', + submittedById: (drafter || creator).id, + }, + }); + } else { + await prisma.bidDocVersion.update({ + where: { id: exists.id }, + data: { nasPath: formal.firstNas }, + }); + } + } + stats.bids += 1; + stats.bidFiles += announce.n + extra.n + formal.n + proof.n; + } + + const contracts = await prisma.contract.findMany(); + const contractByName = new Map(contracts.map((c) => [c.name, c])); + for (const c of raw.oa_contract || []) { + const local = contractByName.get(str(c.contract_name)); + if (!local) { + console.warn('no local contract', c.contract_name); + continue; + } + const creator = userFromOaId(c.create_by) || userFromOaId(c.creator_id) || admin; + const original = await attach('CONTRACT', local.id, c.contract_original_url, creator.id, seen); + const accept = await attach('CONTRACT_ACCEPT', local.id, c.acceptance_url, creator.id, seen); + const award = await attach('CONTRACT_AWARD', local.id, c.award_notice_url, creator.id, seen); + await prisma.contract.update({ + where: { id: local.id }, + data: { + nasPath: original.firstNas || accept.firstNas || award.firstNas || local.nasPath, + signedAt: when(c.sign_date) || local.signedAt, + createdById: creator.id, + partyA: local.partyA || str(c.party_a_name) || undefined, + partyB: local.partyB || str(c.party_b_name) || undefined, + remark: + local.remark || + [str(c.payment_method) && `付款:${c.payment_method}`, str(c.remark)] + .filter(Boolean) + .join(' / ') || + undefined, + }, + }); + stats.contracts += 1; + stats.contractFiles += original.n + accept.n + award.n; + } + + const expenses = await prisma.expenseClaim.findMany(); + for (const r of raw.reimbursement || []) { + const amount = num(r.total_amount); + const name = str(r.employee_name); + const subject = str(r.subject); + let hits = expenses.filter((e) => Math.abs(Number(e.amount) - amount) < 0.009 && (e.remark || '').includes(name)); + if (subject) { + const tighter = hits.filter((e) => (e.remark || '').includes(subject)); + if (tighter.length) hits = tighter; + } + const stamp = when(r.created_time); + if (stamp && hits.length > 1) { + const day = stamp.toISOString().slice(0, 10); + const byDay = hits.filter((e) => (e.submittedAt || e.createdAt).toISOString().slice(0, 10) === day); + if (byDay.length) hits = byDay; + } + const local = hits[0]; + if (!local) continue; + const who = userFromName(name) || users.find((u) => u.id === local.applicantId) || admin; + const items = (raw.reimbursement_item || []).filter((i) => String(i.reimbursement_id) === String(r.id)); + let n = 0; + for (const item of items) { + n += (await attach('EXPENSE', local.id, item.url, who.id, seen)).n; + } + n += (await attach('EXPENSE', local.id, r.attachment_url, who.id, seen)).n; + if (n) { + stats.expenses += 1; + stats.expenseFiles += n; + } + } + + const projects = await prisma.project.findMany(); + const projectByName = new Map(projects.map((p) => [p.name, p])); + for (const p of raw.project_list || []) { + const local = projectByName.get(str(p.project_name)); + if (!local) continue; + const n = await attach('PROJECT', local.id, p.bzj_attachment, admin.id, seen); + stats.projects += n.n ? 1 : 0; + } + + const purchases = await prisma.purchaseRequest.findMany(); + for (const r of raw.oa_purchasing || []) { + const titleBits = [str(r.contract_no), str(r.contract_name)].filter(Boolean); + const local = purchases.find((p) => titleBits.some((b) => p.title.includes(b))); + if (!local) continue; + const who = userFromOaId(r.create_by) || userFromOaId(r.creator_id) || admin; + const a = await attach('PURCHASE', local.id, r.contract_original_url, who.id, seen); + const b = await attach('PURCHASE_ACCEPT', local.id, r.acceptance_url, who.id, seen); + if (a.n + b.n) stats.purchases += 1; + } + + const fileCount = await prisma.fileAsset.count(); + console.log(JSON.stringify({ ...stats, fileAssetTotal: fileCount }, null, 2)); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/prisma/import-prod.ts b/apps/api/prisma/import-prod.ts new file mode 100644 index 0000000..1ed55c3 --- /dev/null +++ b/apps/api/prisma/import-prod.ts @@ -0,0 +1,475 @@ +import { PrismaClient, Prisma } from '@prisma/client'; +import * as fs from 'fs'; +import * as path from 'path'; + +const prisma = new PrismaClient(); +const DUMP = path.resolve('/opt/import/prod/oa_clean.json'); + +type Row = Record; + +function str(v: unknown) { + if (v == null) return ''; + return String(v).trim(); +} + +function num(v: unknown) { + if (v == null || v === '') return 0; + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +function when(v: unknown) { + const s = str(v); + if (!s || s.startsWith('0000')) return null; + const d = new Date(s.includes('T') ? s : s.replace(' ', 'T')); + return Number.isNaN(d.getTime()) ? null : d; +} + +function clip(v: unknown, n = 200) { + const s = str(v); + return s.length > n ? s.slice(0, n) : s || undefined; +} + +function realPartyName(raw: unknown) { + let name = str(raw).replace(/\s+/g, ' '); + if (!name) return ''; + const alias: Record = { + '73047': '中国人民解放军73047部队保障部', + '中国人民解放军 32272 部队': '中国人民解放军32272部队', + }; + name = alias[name] || name; + if (/^未指定/.test(name)) return ''; + if (/^\d+$/.test(name)) return ''; + if (name.startsWith('/') || name.endsWith('.pdf')) return ''; + if (name.length < 2) return ''; + return name; +} + +function mapProjectType(v: unknown, projectName?: unknown) { + const name = str(projectName); + if (name.includes('三维')) return '三维'; + if (/(视频.{0,12}(制作|摄制|拍摄)|摄制|拍摄|摄影|宣传片|专题片|纪录片|微电影|影视制作)/.test(name)) return '摄制'; + const s = str(v); + if (s.includes('三维')) return '三维'; + if (s.includes('摄制') || s.includes('视频')) return '摄制'; + if (s.includes('物资')) return '物资'; + if (s.includes('集成')) return '集成'; + return '软件'; +} + +function mapBidStatus(row: Row) { + const win = str(row.winning_status); + const st = str(row.project_status); + if (st === '中标' || (st === '' && win.includes('中标'))) return 'WON'; + if (st === '未中' || (st === '' && win.includes('未中'))) return 'LOST'; + if (st === '流标') return 'FAILED'; + if (st === '弃标' || win.includes('弃标')) return 'TERMINATED'; + return 'PENDING'; +} + +function mapContractStatus(v: unknown) { + const s = str(v); + if (s === '已通过') return 'APPROVED'; + if (s === '审批中') return 'PENDING'; + return 'DRAFT'; +} + +function mapProjectStatus(v: unknown) { + const s = str(v); + if (s === '验收通过') return 'ACCEPTED'; + if (s === '已完成') return 'DONE'; + if (s === '待验收') return 'INTERNAL_ACCEPTED'; + if (s === '未开始') return 'NOT_STARTED'; + return 'ACTIVE'; +} + +function mapExpenseStatus(v: unknown) { + const s = str(v); + if (s === '2') return 'APPROVED'; + if (s === '3') return 'REJECTED'; + if (s === '1') return 'PENDING'; + return 'DRAFT'; +} + +function mapLoanStatus(row: Row) { + const st = str(row.approval_status); + const offset = num(row.offset_amount); + if (st === '2') { + if (str(row.offset_status) === '2' || offset > 0) return offset >= num(row.amount) ? 'OFFSET' : 'OPEN'; + return 'OPEN'; + } + if (st === '1') return 'PENDING'; + if (st === '3') return 'REJECTED'; + return 'DRAFT'; +} + +function mapSealStatus(v: unknown) { + const s = str(v); + if (s === '2') return 'APPROVED'; + if (s === '3') return 'REJECTED'; + return 'PENDING'; +} + +function mapItemCategory(itemType: string) { + if (itemType.includes('交通') || itemType.includes('差旅')) return 'travel'; + if (itemType.includes('餐')) return 'meal'; + if (itemType.includes('住宿')) return 'stay'; + if (itemType.includes('办公')) return 'office'; + if (itemType.includes('招待')) return 'entertain'; + return 'other'; +} + +function isBidExpense(itemType: string) { + return itemType.includes('投标') || itemType.includes('制标'); +} + +async function purgeBusiness() { + await prisma.smsSendLog.deleteMany(); + await prisma.operationLog.deleteMany(); + await prisma.paymentRecord.deleteMany(); + await prisma.projectChange.deleteMany(); + await prisma.todoTask.deleteMany(); + await prisma.approvalTask.deleteMany(); + await prisma.workReport.deleteMany(); + await prisma.fileAsset.deleteMany(); + await prisma.expenseClaim.deleteMany(); + await prisma.loanRecord.deleteMany(); + await prisma.bondRecord.deleteMany(); + await prisma.invoiceRecord.deleteMany(); + await prisma.timesheet.deleteMany(); + await prisma.projectAcceptance.deleteMany(); + await prisma.projectBudget.deleteMany(); + await prisma.projectTask.deleteMany(); + await prisma.purchaseRequest.deleteMany(); + await prisma.assetOccupancy.deleteMany(); + await prisma.contractReviewer.deleteMany(); + await prisma.contractActionLog.deleteMany(); + await prisma.paymentMilestone.deleteMany(); + await prisma.sealRequest.deleteMany(); + await prisma.credentialBorrow.deleteMany(); + await prisma.project.deleteMany(); + await prisma.contract.deleteMany(); + await prisma.bidDocVersion.deleteMany(); + await prisma.bidAssignee.deleteMany(); + await prisma.bidActionLog.deleteMany(); + await prisma.bidReviewer.deleteMany(); + await prisma.bidCase.deleteMany(); + await prisma.partyContact.deleteMany(); + await prisma.businessParty.deleteMany(); +} + +async function main() { + const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record; + const admin = await prisma.user.findFirst({ where: { username: 'admin' } }); + if (!admin) throw new Error('本地没有 admin'); + + await purgeBusiness(); + + await prisma.legalEntity.updateMany({ data: { isDefault: false } }); + let entity = await prisma.legalEntity.findFirst({ + where: { tenantId: '1', name: '江苏风影随行科技有限公司' }, + }); + if (!entity) { + entity = await prisma.legalEntity.create({ + data: { name: '江苏风影随行科技有限公司', shortName: '江苏风影', isDefault: true }, + }); + } else { + entity = await prisma.legalEntity.update({ where: { id: entity.id }, data: { isDefault: true } }); + } + + const tenders = (raw.oa_tender || []).filter((r) => /^TB-\d/.test(str(r.system_number)) && str(r.project_name)); + const partyByName = new Map(); + const partyNames = new Set(); + for (const t of tenders) { + const n = realPartyName(t.tender_dan_wei); + if (n) partyNames.add(n); + } + for (const c of raw.oa_contract || []) { + const n = realPartyName(c.party_a_name); + if (n && n !== '江苏风影随行科技有限公司') partyNames.add(n); + } + for (const name of partyNames) { + const row = await prisma.businessParty.create({ + data: { name, remark: 'OA 导入', level: 'B' }, + }); + partyByName.set(name, row.id); + } + for (const t of tenders) { + const n = realPartyName(t.tender_dan_wei); + const person = str(t.bid_contact_person); + const phone = str(t.bid_contact_phone); + if (!n || (!person && !phone)) continue; + const partyId = partyByName.get(n); + if (!partyId) continue; + const exists = await prisma.partyContact.findFirst({ where: { partyId, name: person || '招标联系人' } }); + if (!exists) { + await prisma.partyContact.create({ + data: { partyId, name: person || '招标联系人', phone: phone || undefined }, + }); + } + } + + const bidByProd = new Map(); + const bidByNo = new Map(); + const bidByName = new Map(); + for (const t of tenders) { + const bidNo = str(t.system_number); + const partyName = realPartyName(t.tender_dan_wei); + const resultBits = [ + t.winning_unit ? `中标单位:${t.winning_unit}` : '', + num(t.winning_amount) ? `中标金额:${num(t.winning_amount)}` : '', + str(t.opening_remark), + str(t.bid_summary), + ].filter(Boolean); + const row = await prisma.bidCase.create({ + data: { + bidNo, + name: clip(t.project_name, 200)!, + externalNo: clip(t.project_number, 120) || undefined, + projectType: mapProjectType(t.project_type, t.project_name), + source: clip(t.project_source, 50) || undefined, + legalEntityId: entity.id, + partyId: partyName ? partyByName.get(partyName) : undefined, + tenderMethod: clip(t.tender_method, 50) || undefined, + applyEndAt: when(t.file_apply_deadline) || undefined, + openAt: when(t.bid_open_date) || undefined, + openPlace: clip(t.tender_address, 200) || undefined, + securityLevel: str(t.security_level) || 'INTERNAL', + projectNature: clip(t.project_nature, 50) || undefined, + contactName: clip(t.bid_contact_person, 64) || undefined, + contactPhone: clip(t.bid_contact_phone, 32) || undefined, + bondMethod: clip(t.deposit_pay_method, 20) || undefined, + bondStatus: clip(t.deposit_status, 20) || undefined, + bondPaidAt: when(t.deposit_pay_date) || undefined, + delivererName: clip(t.bid_delivery_person, 64) || undefined, + priceCap: t.price_limit == null ? undefined : String(t.price_limit), + quoteAmount: t.bid_quotation == null ? undefined : String(t.bid_quotation), + summary: clip(t.project_intro, 2000) || undefined, + remark: clip(t.remark, 1000) || undefined, + resultNote: resultBits.join(';') || undefined, + openedResultAt: when(t.winning_date) || when(t.bid_open_date) || undefined, + status: mapBidStatus(t), + createdById: admin.id, + createdAt: when(t.create_time) || undefined, + }, + }); + bidByProd.set(String(t.id), row.id); + bidByNo.set(bidNo, row.id); + bidByName.set(str(t.project_name), row.id); + const deposit = num(t.bid_deposit); + if (deposit > 0) { + const paid = Boolean(when(t.deposit_pay_date)); + const returned = Boolean(when(t.deposit_refund_date)); + await prisma.bondRecord.create({ + data: { + bondNo: `BZJ-${bidNo}`, + bidCaseId: row.id, + bondType: 'TENDER', + amount: new Prisma.Decimal(deposit), + paidAt: when(t.deposit_pay_date) || undefined, + returnedAt: when(t.deposit_refund_date) || undefined, + status: returned ? 'RETURNED' : paid ? 'PAID' : 'UNPAID', + remark: 'OA 投标保证金', + }, + }); + } + } + + const contractByProd = new Map(); + const contractByNo = new Map(); + const contractByName = new Map(); + for (const c of raw.oa_contract || []) { + const contractNo = str(c.contract_no); + if (!contractNo) continue; + const tb = contractNo.match(/TB-\d+-\d+/); + const bidCaseId = tb ? bidByNo.get(tb[0]) : undefined; + if (str(c.approval_status) === 'pending_submit' && !c.sign_date && !num(c.amount)) { + continue; + } + const partyA = realPartyName(c.party_a_name); + const partyB = str(c.party_b_name); + if (partyA && !partyByName.has(partyA) && partyA !== '江苏风影随行科技有限公司') { + const p = await prisma.businessParty.create({ data: { name: partyA, remark: 'OA 合同甲方', level: 'B' } }); + partyByName.set(partyA, p.id); + } + const remark = [ + str(c.payment_method) ? `付款:${c.payment_method}` : '', + str(c.contract_lb) ? `类别:${c.contract_lb}` : '', + str(c.remark), + ] + .filter(Boolean) + .join(' / '); + const row = await prisma.contract.create({ + data: { + contractNo, + name: clip(c.contract_name, 200)!, + bidCaseId, + legalEntityId: entity.id, + amount: new Prisma.Decimal(num(c.amount)), + status: mapContractStatus(c.approval_status), + partyA: partyA || undefined, + partyB: partyB || undefined, + remark: remark || undefined, + signedAt: when(c.sign_date) || undefined, + createdById: admin.id, + createdAt: when(c.create_time) || undefined, + }, + }); + contractByProd.set(String(c.id), row.id); + contractByNo.set(contractNo, row.id); + contractByName.set(str(c.contract_name), row.id); + } + + const projectByProd = new Map(); + const projectByName = new Map(); + for (const p of raw.project_list || []) { + const projectNo = str(p.system_number) || str(p.project_code); + if (!projectNo || !str(p.project_name)) continue; + const contractId = + (p.contract_id != null ? contractByProd.get(String(p.contract_id)) : undefined) || + (str(p.contract_name) ? contractByName.get(str(p.contract_name)) : undefined); + const row = await prisma.project.create({ + data: { + projectNo, + name: clip(p.project_name, 200)!, + contractId, + status: mapProjectStatus(p.project_status), + createdAt: when(p.create_time) || undefined, + }, + }); + projectByProd.set(String(p.project_id), row.id); + projectByName.set(str(p.project_name), row.id); + const budget = num(p.project_budget); + if (budget > 0) { + await prisma.projectBudget.create({ + data: { projectId: row.id, category: 'PURCHASE', amount: new Prisma.Decimal(budget) }, + }); + } + const bzj = num(p.project_bzj); + if (bzj > 0) { + await prisma.bondRecord.create({ + data: { + bondNo: `BZJ-${projectNo}`, + projectId: row.id, + contractId, + bondType: 'WARRANTY', + amount: new Prisma.Decimal(bzj), + dueBackAt: when(p.bzj_date) || undefined, + status: 'UNPAID', + remark: str(p.bzj_type) ? `OA 项目质保金 ${p.bzj_type}` : 'OA 项目质保金', + }, + }); + } + } + + const itemsByReimb = new Map(); + for (const item of raw.reimbursement_item || []) { + const key = String(item.reimbursement_id); + if (!itemsByReimb.has(key)) itemsByReimb.set(key, []); + itemsByReimb.get(key)!.push(item); + } + + for (const r of raw.reimbursement || []) { + const claimNo = str(r.reimburse_no); + if (!claimNo) continue; + const lines = itemsByReimb.get(String(r.id)) || []; + const main = [...lines].sort((a, b) => num(b.amount) - num(a.amount))[0]; + const itemType = str(main?.item_type); + const cat = mapItemCategory(itemType); + const projectId = r.project_id != null ? projectByProd.get(String(r.project_id)) : projectByName.get(str(r.project_name)); + const bidCaseId = isBidExpense(itemType) + ? bidByName.get(str(r.project_name)) + : !projectId + ? bidByName.get(str(r.project_name)) + : undefined; + const costType = isBidExpense(itemType) || bidCaseId ? 'BID' : projectId ? 'PROJECT' : 'DEPT'; + await prisma.expenseClaim.create({ + data: { + claimNo, + applicantId: admin.id, + costType, + budgetCategory: costType === 'PROJECT' ? (cat === 'office' ? 'PURCHASE' : 'TRAVEL') : undefined, + projectId: costType === 'PROJECT' ? projectId : undefined, + bidCaseId: costType === 'BID' ? bidCaseId : undefined, + expenseCategory: cat, + purpose: clip(r.subject || r.project_name, 200) || undefined, + amount: new Prisma.Decimal(num(r.total_amount)), + status: mapExpenseStatus(r.approval_status), + remark: clip(r.remark, 1000) || undefined, + submittedAt: when(r.created_time) || undefined, + decidedAt: str(r.approval_status) === '2' ? when(r.created_time) || undefined : undefined, + decidedById: str(r.approval_status) === '2' ? admin.id : undefined, + }, + }); + } + + for (const r of raw.borrow_advance || []) { + const loanNo = str(r.advance_no); + if (!loanNo) continue; + const projectId = r.project_id != null ? projectByProd.get(String(r.project_id)) : projectByName.get(str(r.project_name)); + await prisma.loanRecord.create({ + data: { + loanNo, + applicantId: admin.id, + costType: projectId ? 'PROJECT' : 'DEPT', + projectId, + amount: new Prisma.Decimal(num(r.amount)), + offsetAmount: new Prisma.Decimal(num(r.offset_amount)), + purpose: clip(r.reason || r.subject, 200) || 'OA 借款', + status: mapLoanStatus(r), + remark: undefined, + submittedAt: when(r.apply_time) || undefined, + }, + }); + } + + for (const r of raw.oa_seal_apply || []) { + const reason = clip(r.content || r.apply_subject, 500) || '用章'; + const bidHit = tenders.find((t) => str(t.project_name) && reason.includes(str(t.project_name))); + await prisma.sealRequest.create({ + data: { + sealType: clip(r.seal_type, 40) || '公章', + reason, + bidCaseId: bidHit ? bidByProd.get(String(bidHit.id)) : undefined, + applicantId: admin.id, + takeOut: str(r.is_borrow) === '是', + returnAt: when(r.borrow_end) || undefined, + status: mapSealStatus(r.approval_status), + createdAt: when(r.create_time) || undefined, + }, + }); + } + + for (const r of raw.oa_purchasing || []) { + const title = [str(r.contract_no), str(r.contract_name)].filter(Boolean).join(' '); + if (!title) continue; + await prisma.purchaseRequest.create({ + data: { + title: clip(title, 200)!, + amount: new Prisma.Decimal(num(r.amount)), + status: str(r.approval_status) === '已通过' ? 'APPROVED' : str(r.approval_status) === '审批中' ? 'PENDING' : 'DRAFT', + remark: [r.party_a_name, r.payment_method, r.sign_date, r.remark].map(str).filter(Boolean).join(' / ') || undefined, + applicantId: admin.id, + }, + }); + } + + const counts = { + parties: partyByName.size, + bids: tenders.length, + contracts: (raw.oa_contract || []).length, + projects: (raw.project_list || []).length, + expenses: (raw.reimbursement || []).length, + loans: (raw.borrow_advance || []).length, + seals: (raw.oa_seal_apply || []).length, + purchases: (raw.oa_purchasing || []).length, + }; + console.log(JSON.stringify(counts, null, 2)); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/prisma/import-salary-from-oa.ts b/apps/api/prisma/import-salary-from-oa.ts new file mode 100644 index 0000000..08a455b --- /dev/null +++ b/apps/api/prisma/import-salary-from-oa.ts @@ -0,0 +1,239 @@ +import { createReadStream } from 'fs'; +import { createGunzip } from 'zlib'; +import { PrismaClient } from '@prisma/client'; +import { parseOaJson } from './oa-json'; + +const prisma = new PrismaClient(); +const DUMP = process.env.OA_DUMP || '/opt/backup/cloud-20260830/oa.sql.gz'; +const HR = '/opt/import/prod/oa_hr.json'; + +function n(v: unknown) { + if (v == null || v === '') return 0; + const x = Number(v); + return Number.isFinite(x) ? x : 0; +} + +function parseMysqlTuples(s: string): unknown[][] { + const rows: unknown[][] = []; + let i = 0; + while (i < s.length) { + while (i < s.length && ' \n\r\t,'.includes(s[i])) i += 1; + if (i >= s.length) break; + if (s[i] !== '(') throw new Error(`expected ( at ${i}`); + i += 1; + const row: unknown[] = []; + while (true) { + while (i < s.length && ' \n\r\t'.includes(s[i])) i += 1; + if (s.startsWith('NULL', i) && (i + 4 === s.length || ',) \n\r\t'.includes(s[i + 4]))) { + row.push(null); + i += 4; + } else if (s[i] === "'" || s[i] === '"') { + const q = s[i]; + i += 1; + let out = ''; + while (i < s.length) { + const ch = s[i]; + if (ch === '\\') { + out += s[i + 1]; + i += 2; + continue; + } + if (ch === q) { + if (s[i + 1] === q) { + out += q; + i += 2; + continue; + } + i += 1; + break; + } + out += ch; + i += 1; + } + row.push(out); + } else { + const start = i; + while (i < s.length && s[i] !== ',' && s[i] !== ')') i += 1; + const raw = s.slice(start, i).trim(); + row.push(raw === '' ? null : /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw); + } + while (i < s.length && ' \n\r\t'.includes(s[i])) i += 1; + if (s[i] === ',') { + i += 1; + continue; + } + if (s[i] === ')') { + i += 1; + rows.push(row); + break; + } + throw new Error(`bad token at ${i}`); + } + } + return rows; +} + +function insertsFor(dump: string, table: string) { + const rows: unknown[][] = []; + const needle = `INSERT INTO \`${table}\` VALUES `; + let from = 0; + while (true) { + const start = dump.indexOf(needle, from); + if (start < 0) break; + let i = start + needle.length; + while (i < dump.length) { + while (i < dump.length && ' \n\r\t'.includes(dump[i])) i += 1; + if (dump[i] === ';') { + i += 1; + break; + } + if (dump[i] === ',') { + i += 1; + continue; + } + if (dump[i] !== '(') break; + const sliceStart = i; + let depth = 0; + let inStr = false; + let q = ''; + while (i < dump.length) { + const ch = dump[i]; + if (inStr) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === q) inStr = false; + i += 1; + continue; + } + if (ch === "'" || ch === '"') { + inStr = true; + q = ch; + i += 1; + continue; + } + if (ch === '(') depth += 1; + if (ch === ')') { + depth -= 1; + if (depth === 0) { + i += 1; + break; + } + } + i += 1; + } + rows.push(...parseMysqlTuples(dump.slice(sliceStart, i))); + } + from = i; + } + return rows; +} + +async function readDump() { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + createReadStream(DUMP) + .pipe(createGunzip()) + .on('data', (c: Buffer) => chunks.push(c)) + .on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + .on('error', reject); + }); +} + +async function main() { + const dump = await readDump(); + const hr = parseOaJson(HR) as { users?: { id: number; nickName?: string; phone?: string }[] }; + const employees = await prisma.employee.findMany({ select: { id: true, name: true, mobile: true } }); + const byName = new Map(employees.map((e) => [e.name, e])); + const byMobile = new Map(employees.filter((e) => e.mobile).map((e) => [e.mobile as string, e])); + const oaUserToEmp = new Map(); + for (const u of hr.users || []) { + const hit = (u.phone && byMobile.get(String(u.phone))) || (u.nickName && byName.get(u.nickName)); + if (hit) oaUserToEmp.set(Number(u.id), hit.id); + } + + const months = insertsFor(dump, 'oa_salary_month'); + const slips = insertsFor(dump, 'oa_salary_slip'); + const admin = await prisma.user.findFirst({ where: { username: '18115604840' } }); + + await prisma.payrollItem.deleteMany(); + await prisma.payrollRun.deleteMany(); + + const runByYm = new Map(); + for (const row of months) { + const ym = String(row[1] || ''); + if (!/^\d{4}-\d{2}$/.test(ym)) continue; + const confirmed = Number(row[2]) === 1; + const run = await prisma.payrollRun.create({ + data: { + tenantId: '1', + yearMonth: ym, + status: confirmed ? 'CONFIRMED' : 'DRAFT', + createdById: admin?.id, + confirmedAt: confirmed ? (row[7] ? new Date(String(row[7])) : new Date()) : null, + }, + }); + runByYm.set(ym, run.id); + } + + let ok = 0; + let skip = 0; + for (const row of slips) { + const ym = String(row[2] || ''); + const oaUserId = Number(row[3]); + const name = String(row[4] || ''); + let empId = oaUserToEmp.get(oaUserId); + if (!empId && name) empId = byName.get(name)?.id; + if (!empId || !runByYm.has(ym)) { + skip += 1; + continue; + } + await prisma.payrollItem.upsert({ + where: { runId_employeeId: { runId: runByYm.get(ym) as string, employeeId: empId } }, + update: { + basePay: n(row[9]) + n(row[10]), + performancePay: n(row[18] || row[11]), + leaveDeduct: n(row[22]), + sickDeduct: n(row[23]), + socialPersonal: n(row[27]), + tax: n(row[28]), + grossPay: n(row[16]), + netPay: n(row[30]), + scheduledDays: n(row[31]), + prorationFactor: n(row[29]) || 1, + }, + create: { + runId: runByYm.get(ym) as string, + employeeId: empId, + basePay: n(row[9]) + n(row[10]), + performancePay: n(row[18] || row[11]), + leaveDeduct: n(row[22]), + sickDeduct: n(row[23]), + socialPersonal: n(row[27]), + tax: n(row[28]), + grossPay: n(row[16]), + netPay: n(row[30]), + scheduledDays: n(row[31]), + prorationFactor: n(row[29]) || 1, + }, + }); + ok += 1; + } + console.log( + JSON.stringify({ + months: runByYm.size, + slips: slips.length, + imported: ok, + skipped: skip, + }), + ); +} + +main() + .then(async () => prisma.$disconnect()) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/prisma/menus.ts b/apps/api/prisma/menus.ts new file mode 100644 index 0000000..3c55d34 --- /dev/null +++ b/apps/api/prisma/menus.ts @@ -0,0 +1,201 @@ +export type MenuNode = { + name: string; + code: string; + path: string; + icon?: string; + children?: MenuNode[]; +}; + +/** 人事业务在「组织人事」;组织/班次/参数进「系统设置 · 人事设置」。 */ +export const MENUS: MenuNode[] = [ + { + name: '工作台', + code: 'office:overview', + path: '/workbench', + icon: 'HomeOutlined', + }, + { + name: '个人办公', + code: 'office', + path: '/office/todos', + icon: 'AppstoreOutlined', + children: [ + { name: '待办事项', code: 'office:todos', path: '/office/todos' }, + { name: '待我审批', code: 'office:approvals', path: '/office/approvals' }, + { name: '我的申请', code: 'office:flow', path: '/office/flow' }, + { name: '我的日程', code: 'office:calendar', path: '/office/calendar' }, + { name: '工作汇报', code: 'office:reports', path: '/office/reports' }, + { name: '我的费用', code: 'office:expenses', path: '/office/expenses' }, + { name: '人事申请', code: 'office:hr-apply', path: '/office/hr-apply' }, + { name: '用章申请', code: 'office:seal-apply', path: '/office/seal-apply' }, + { name: '办公申请', code: 'office:apply', path: '/office/apply' }, + { name: '公司公告', code: 'office:notices', path: '/office/notices' }, + { name: '发送短信', code: 'office:sms', path: '/office/sms' }, + { name: '工作安排', code: 'office:assign', path: '/office/assign' }, + { name: '消息', code: 'office:im', path: '/office/im' }, + { name: '员工通讯', code: 'office:directory', path: '/office/directory' }, + ], + }, + { + name: '往来单位', + code: 'party', + path: '/party/tenders', + icon: 'ClusterOutlined', + children: [ + { name: '招标单位', code: 'party:tenders', path: '/party/tenders' }, + { name: '参与主体', code: 'party:entities', path: '/party/entities' }, + { name: '联系名录', code: 'party:contacts', path: '/party/contacts' }, + ], + }, + { + name: '证照用章', + code: 'seal', + path: '/seal/qualifications', + icon: 'SafetyCertificateOutlined', + children: [ + { name: '资质台账', code: 'seal:qualifications', path: '/seal/qualifications' }, + { name: '证照借用', code: 'seal:borrows', path: '/seal/borrows' }, + { name: '用章登记', code: 'seal:requests', path: '/seal/requests' }, + { name: '印章台账', code: 'seal:registry', path: '/seal/registry' }, + ], + }, + { + name: '投标管理', + code: 'bid', + path: '/bid/screening', + icon: 'AuditOutlined', + children: [ + { name: '项目筛选', code: 'bid:screening', path: '/bid/screening' }, + { name: '特殊筛选', code: 'bid:special-screening', path: '/bid/special-screening' }, + { name: '标书制作', code: 'bid:drafting', path: '/bid/drafting' }, + { name: '特殊制标', code: 'bid:special-drafting', path: '/bid/special-drafting' }, + { name: '投标执行', code: 'bid:execution', path: '/bid/execution' }, + { name: '开标结果', code: 'bid:result', path: '/bid/result' }, + { name: '终止投标', code: 'bid:terminated', path: '/bid/terminated' }, + ], + }, + { + name: '合同管理', + code: 'contract', + path: '/contract/tender', + icon: 'FileProtectOutlined', + children: [ + { name: '投标合同', code: 'contract:approval', path: '/contract/tender' }, + { name: '直签合同', code: 'contract:direct', path: '/contract/direct' }, + { name: '其他合同', code: 'contract:list', path: '/contract/incremental' }, + { name: '人事合同', code: 'contract:hr', path: '/contract/hr' }, + ], + }, + { + name: '资产管理', + code: 'asset', + path: '/asset/digital', + icon: 'DatabaseOutlined', + children: [ + { name: '数字资产', code: 'asset:digital', path: '/asset/digital' }, + { name: '固定资产', code: 'asset:fixed', path: '/asset/fixed' }, + ], + }, + { + name: '项目管理', + code: 'project', + path: '/project/list', + icon: 'ProjectOutlined', + children: [ + { name: '项目台账', code: 'project:list', path: '/project/list' }, + { name: '任务分解', code: 'project:tasks', path: '/project/tasks' }, + { name: '工时填报', code: 'project:timesheets', path: '/project/timesheets' }, + { name: '资源看板', code: 'project:resources', path: '/project/resources' }, + { name: '项目验收', code: 'project:acceptance', path: '/project/acceptance' }, + { name: '采购申请', code: 'project:purchases', path: '/project/purchases' }, + { name: '器材占用', code: 'project:assets', path: '/project/assets' }, + ], + }, + { + name: '财务收支', + code: 'finance', + path: '/finance/expenses', + icon: 'AccountBookOutlined', + children: [ + { name: '费用报销', code: 'finance:expenses', path: '/finance/expenses' }, + { name: '借支记录', code: 'finance:loans', path: '/finance/loans' }, + { name: '资产购置台账', code: 'finance:asset-purchases', path: '/finance/asset-purchases' }, + { name: '投标保证金', code: 'finance:tender-bonds', path: '/finance/bonds/tender' }, + { name: '履约保证金', code: 'finance:perf-bonds', path: '/finance/bonds/performance' }, + { name: '项目质保金', code: 'finance:warranty-bonds', path: '/finance/bonds/warranty' }, + { name: '开票收款', code: 'finance:invoices', path: '/finance/invoices' }, + { name: '付款执行', code: 'finance:payments', path: '/finance/payments' }, + ], + }, + { + name: '组织人事', + code: 'hr', + path: '/hr/employees', + icon: 'TeamOutlined', + children: [ + { name: '在职员工', code: 'hr:employees', path: '/hr/employees' }, + { name: '组织管理', code: 'hr:org', path: '/hr/org' }, + { name: '入转调离', code: 'hr:lifecycle', path: '/hr/lifecycle' }, + { name: '假勤结果', code: 'hr:attendance', path: '/hr/attendance' }, + { name: '薪酬核算', code: 'hr:payroll', path: '/hr/payroll' }, + { name: '绩效发放', code: 'hr:performance', path: '/hr/performance' }, + { name: '发布公告', code: 'hr:notices', path: '/hr/notices' }, + { name: '规章制度', code: 'hr:policies', path: '/hr/policies' }, + ], + }, + { + name: '经营报表', + code: 'report', + path: '/report/conversion', + icon: 'BarChartOutlined', + children: [ + { name: '投标转化', code: 'report:conversion', path: '/report/conversion' }, + { name: '落标对照', code: 'report:lost', path: '/report/lost' }, + { name: '项目利润', code: 'report:profit', path: '/report/profit' }, + { name: '部门人效', code: 'report:efficiency', path: '/report/efficiency' }, + { name: '资金占用', code: 'report:cash', path: '/report/cash' }, + ], + }, + { + name: '系统设置', + code: 'system', + path: '/system/acl', + icon: 'SettingOutlined', + children: [ + { + name: '人事设置', + code: 'system:hr', + path: '/system/hr-params', + children: [ + { name: '员工账号', code: 'system:acl', path: '/system/acl' }, + { name: '人事参数', code: 'system:hr-params', path: '/system/hr-params' }, + { name: '考勤设置', code: 'system:attendance-rules', path: '/system/attendance-rules' }, + ], + }, + { name: '数据字典', code: 'system:dicts', path: '/system/dicts' }, + { name: '权限设置', code: 'system:perms', path: '/system/perms' }, + { name: '编号规则', code: 'system:numbers', path: '/system/numbers' }, + { name: '短信服务', code: 'system:sms', path: '/system/sms' }, + { name: '消息通道', code: 'system:messages', path: '/system/messages' }, + { name: '手机端升级', code: 'system:app-release', path: '/system/app-release' }, + { name: '操作日志', code: 'system:logs', path: '/system/logs' }, + ], + }, +]; + +export function walkMenus(nodes: MenuNode[], visit: (node: MenuNode, parent: MenuNode | null, sortNo: number) => void, parent: MenuNode | null = null) { + nodes.forEach((node, i) => { + visit(node, parent, parent ? i + 1 : (i + 1) * 10); + if (node.children?.length) walkMenus(node.children, visit, node); + }); +} + +export function isHrSettingCode(code: string) { + return ( + code === 'system:acl' || + code === 'system:hr' || + code.startsWith('system:hr') || + code === 'system:org' || + code === 'system:attendance-rules' + ); +} diff --git a/apps/api/prisma/migrations/20260829065602_init/migration.sql b/apps/api/prisma/migrations/20260829065602_init/migration.sql new file mode 100644 index 0000000..628c35d --- /dev/null +++ b/apps/api/prisma/migrations/20260829065602_init/migration.sql @@ -0,0 +1,567 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "username" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', + "employeeId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RefreshToken" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Department" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "parentId" TEXT, + "name" TEXT NOT NULL, + "code" TEXT NOT NULL, + "deptType" TEXT NOT NULL, + "categoryCode" TEXT, + "sortNo" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "Department_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Employee" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "name" TEXT NOT NULL, + "employeeNo" TEXT NOT NULL, + "departmentId" TEXT NOT NULL, + "title" TEXT, + "restSchedule" TEXT NOT NULL DEFAULT 'SINGLE', + "employmentStatus" TEXT NOT NULL DEFAULT 'REGULAR', + "hiredAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Employee_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Role" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "name" TEXT NOT NULL, + "code" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + + CONSTRAINT "Role_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Permission" ( + "id" TEXT NOT NULL, + "parentId" TEXT, + "name" TEXT NOT NULL, + "code" TEXT NOT NULL, + "permType" TEXT NOT NULL, + "path" TEXT, + "icon" TEXT, + "sortNo" INTEGER NOT NULL DEFAULT 0, + "visible" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "Permission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RolePermission" ( + "roleId" TEXT NOT NULL, + "permissionId" TEXT NOT NULL, + + CONSTRAINT "RolePermission_pkey" PRIMARY KEY ("roleId","permissionId") +); + +-- CreateTable +CREATE TABLE "UserRole" ( + "userId" TEXT NOT NULL, + "roleId" TEXT NOT NULL, + + CONSTRAINT "UserRole_pkey" PRIMARY KEY ("userId","roleId") +); + +-- CreateTable +CREATE TABLE "DictItem" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "dictType" TEXT NOT NULL, + "label" TEXT NOT NULL, + "value" TEXT NOT NULL, + "sortNo" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "DictItem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LegalEntity" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "name" TEXT NOT NULL, + "shortName" TEXT NOT NULL, + "creditNo" TEXT, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LegalEntity_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BusinessParty" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "name" TEXT NOT NULL, + "creditNo" TEXT, + "level" TEXT NOT NULL DEFAULT 'B', + "riskFlag" BOOLEAN NOT NULL DEFAULT false, + "remark" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BusinessParty_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PartyContact" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "partyId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "phone" TEXT, + "title" TEXT, + + CONSTRAINT "PartyContact_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Qualification" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "name" TEXT NOT NULL, + "code" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3), + "keeper" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Qualification_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SealRequest" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "sealType" TEXT NOT NULL, + "reason" TEXT NOT NULL, + "bidCaseId" TEXT, + "takeOut" BOOLEAN NOT NULL DEFAULT false, + "returnAt" TIMESTAMP(3), + "status" TEXT NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SealRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CredentialBorrow" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "itemName" TEXT NOT NULL, + "borrower" TEXT NOT NULL, + "dueAt" TIMESTAMP(3), + "returnedAt" TIMESTAMP(3), + "status" TEXT NOT NULL DEFAULT 'BORROWED', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CredentialBorrow_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BidCase" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "bidNo" TEXT NOT NULL, + "name" TEXT NOT NULL, + "externalNo" TEXT, + "projectType" TEXT NOT NULL, + "sourceType" TEXT NOT NULL DEFAULT 'FLOW', + "source" TEXT, + "qualificationNeed" TEXT, + "legalEntityId" TEXT, + "tenderMethod" TEXT, + "applyStartAt" TIMESTAMP(3), + "applyEndAt" TIMESTAMP(3), + "applyMethod" TEXT, + "openAt" TIMESTAMP(3), + "openMethod" TEXT, + "openPlace" TEXT, + "securityLevel" TEXT NOT NULL DEFAULT 'INTERNAL', + "projectNature" TEXT, + "partyId" TEXT, + "contactName" TEXT, + "contactPhone" TEXT, + "bondMethod" TEXT, + "bondStatus" TEXT, + "bondPaidAt" TIMESTAMP(3), + "delivererName" TEXT, + "priceCap" TEXT, + "quoteAmount" TEXT, + "summary" TEXT, + "remark" TEXT, + "status" TEXT NOT NULL DEFAULT 'TECH_INITIAL', + "nasPath" TEXT, + "createdById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "BidCase_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BidReviewer" ( + "id" TEXT NOT NULL, + "bidCaseId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "stage" TEXT NOT NULL, + "result" TEXT NOT NULL DEFAULT 'PENDING', + "comment" TEXT, + + CONSTRAINT "BidReviewer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TodoTask" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "title" TEXT NOT NULL, + "bizType" TEXT NOT NULL, + "bizId" TEXT, + "assigneeId" TEXT NOT NULL, + "dueAt" TIMESTAMP(3), + "status" TEXT NOT NULL DEFAULT 'OPEN', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TodoTask_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ApprovalTask" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "title" TEXT NOT NULL, + "bizType" TEXT NOT NULL, + "bizId" TEXT, + "assigneeId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ApprovalTask_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WorkReport" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "authorId" TEXT NOT NULL, + "period" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "WorkReport_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Contract" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "contractNo" TEXT NOT NULL, + "name" TEXT NOT NULL, + "bidCaseId" TEXT, + "amount" DECIMAL(18,2) NOT NULL DEFAULT 0, + "status" TEXT NOT NULL DEFAULT 'DRAFT', + "signedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Contract_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PaymentMilestone" ( + "id" TEXT NOT NULL, + "contractId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "ratio" DECIMAL(5,2) NOT NULL, + "dueAt" TIMESTAMP(3), + "trigger" TEXT, + + CONSTRAINT "PaymentMilestone_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "projectNo" TEXT NOT NULL, + "name" TEXT NOT NULL, + "contractId" TEXT, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProjectTask" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "department" TEXT, + "dueAt" TIMESTAMP(3), + "status" TEXT NOT NULL DEFAULT 'OPEN', + + CONSTRAINT "ProjectTask_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Timesheet" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "projectId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "workDate" TIMESTAMP(3) NOT NULL, + "hours" DECIMAL(6,2) NOT NULL, + "note" TEXT, + + CONSTRAINT "Timesheet_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ExpenseClaim" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "claimNo" TEXT NOT NULL, + "applicantId" TEXT NOT NULL, + "costType" TEXT NOT NULL, + "bidCaseId" TEXT, + "projectId" TEXT, + "amount" DECIMAL(18,2) NOT NULL DEFAULT 0, + "status" TEXT NOT NULL DEFAULT 'DRAFT', + "remark" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ExpenseClaim_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LoanRecord" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "applicantId" TEXT NOT NULL, + "amount" DECIMAL(18,2) NOT NULL, + "purpose" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'OPEN', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LoanRecord_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BondRecord" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "bidCaseId" TEXT NOT NULL, + "bondType" TEXT NOT NULL, + "amount" DECIMAL(18,2) NOT NULL, + "paidAt" TIMESTAMP(3), + "dueBackAt" TIMESTAMP(3), + "returnedAt" TIMESTAMP(3), + "status" TEXT NOT NULL DEFAULT 'UNPAID', + + CONSTRAINT "BondRecord_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "InvoiceRecord" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "invoiceNo" TEXT, + "amount" DECIMAL(18,2) NOT NULL, + "status" TEXT NOT NULL DEFAULT 'TO_ISSUE', + "issuedAt" TIMESTAMP(3), + "receivedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "InvoiceRecord_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PurchaseRequest" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "title" TEXT NOT NULL, + "projectId" TEXT, + "amount" DECIMAL(18,2) NOT NULL DEFAULT 0, + "status" TEXT NOT NULL DEFAULT 'DRAFT', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PurchaseRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AssetOccupancy" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "assetName" TEXT NOT NULL, + "holder" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'IN_USE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AssetOccupancy_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OperationLog" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "userId" TEXT, + "action" TEXT NOT NULL, + "path" TEXT NOT NULL, + "requestId" TEXT, + "ip" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OperationLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_tenantId_username_key" ON "User"("tenantId", "username"); + +-- CreateIndex +CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Department_tenantId_code_key" ON "Department"("tenantId", "code"); + +-- CreateIndex +CREATE UNIQUE INDEX "Employee_tenantId_employeeNo_key" ON "Employee"("tenantId", "employeeNo"); + +-- CreateIndex +CREATE UNIQUE INDEX "Role_tenantId_code_key" ON "Role"("tenantId", "code"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_code_key" ON "Permission"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "DictItem_tenantId_dictType_value_key" ON "DictItem"("tenantId", "dictType", "value"); + +-- CreateIndex +CREATE UNIQUE INDEX "LegalEntity_tenantId_name_key" ON "LegalEntity"("tenantId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "BusinessParty_tenantId_name_key" ON "BusinessParty"("tenantId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "BidCase_tenantId_bidNo_key" ON "BidCase"("tenantId", "bidNo"); + +-- CreateIndex +CREATE INDEX "BidReviewer_bidCaseId_idx" ON "BidReviewer"("bidCaseId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Contract_tenantId_contractNo_key" ON "Contract"("tenantId", "contractNo"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_tenantId_projectNo_key" ON "Project"("tenantId", "projectNo"); + +-- CreateIndex +CREATE UNIQUE INDEX "ExpenseClaim_tenantId_claimNo_key" ON "ExpenseClaim"("tenantId", "claimNo"); + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Department" ADD CONSTRAINT "Department_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Employee" ADD CONSTRAINT "Employee_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Permission"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "Permission"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PartyContact" ADD CONSTRAINT "PartyContact_partyId_fkey" FOREIGN KEY ("partyId") REFERENCES "BusinessParty"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BidCase" ADD CONSTRAINT "BidCase_legalEntityId_fkey" FOREIGN KEY ("legalEntityId") REFERENCES "LegalEntity"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BidCase" ADD CONSTRAINT "BidCase_partyId_fkey" FOREIGN KEY ("partyId") REFERENCES "BusinessParty"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BidCase" ADD CONSTRAINT "BidCase_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BidReviewer" ADD CONSTRAINT "BidReviewer_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TodoTask" ADD CONSTRAINT "TodoTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApprovalTask" ADD CONSTRAINT "ApprovalTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Contract" ADD CONSTRAINT "Contract_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PaymentMilestone" ADD CONSTRAINT "PaymentMilestone_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectTask" ADD CONSTRAINT "ProjectTask_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Timesheet" ADD CONSTRAINT "Timesheet_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ExpenseClaim" ADD CONSTRAINT "ExpenseClaim_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ExpenseClaim" ADD CONSTRAINT "ExpenseClaim_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BondRecord" ADD CONSTRAINT "BondRecord_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260829071500_bid_workflow_phase1/migration.sql b/apps/api/prisma/migrations/20260829071500_bid_workflow_phase1/migration.sql new file mode 100644 index 0000000..eeaa36e --- /dev/null +++ b/apps/api/prisma/migrations/20260829071500_bid_workflow_phase1/migration.sql @@ -0,0 +1,34 @@ +-- AlterTable +ALTER TABLE "BidCase" ADD COLUMN "fetchedAt" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "BidReviewer" ADD COLUMN "decidedAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "BidActionLog" ( + "id" TEXT NOT NULL, + "bidCaseId" TEXT NOT NULL, + "actorId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "fromStatus" TEXT, + "toStatus" TEXT, + "comment" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BidActionLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "BidReviewer_bidCaseId_userId_stage_key" ON "BidReviewer"("bidCaseId", "userId", "stage"); + +-- CreateIndex +CREATE INDEX "BidActionLog_bidCaseId_idx" ON "BidActionLog"("bidCaseId"); + +-- AddForeignKey +ALTER TABLE "BidReviewer" ADD CONSTRAINT "BidReviewer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BidActionLog" ADD CONSTRAINT "BidActionLog_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BidActionLog" ADD CONSTRAINT "BidActionLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260829073000_bid_draft_to_open/migration.sql b/apps/api/prisma/migrations/20260829073000_bid_draft_to_open/migration.sql new file mode 100644 index 0000000..673fdb3 --- /dev/null +++ b/apps/api/prisma/migrations/20260829073000_bid_draft_to_open/migration.sql @@ -0,0 +1,50 @@ +-- AlterTable +ALTER TABLE "BidCase" ADD COLUMN "reviewCycle" INTEGER NOT NULL DEFAULT 1; +ALTER TABLE "BidCase" ADD COLUMN "printCost" DECIMAL(18,2); +ALTER TABLE "BidCase" ADD COLUMN "printNote" TEXT; +ALTER TABLE "BidCase" ADD COLUMN "printedAt" TIMESTAMP(3); +ALTER TABLE "BidCase" ADD COLUMN "costDisposition" TEXT; +ALTER TABLE "BidCase" ADD COLUMN "resultNote" TEXT; +ALTER TABLE "BidCase" ADD COLUMN "openedResultAt" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "BidReviewer" ADD COLUMN "cycle" INTEGER NOT NULL DEFAULT 1; + +DROP INDEX IF EXISTS "BidReviewer_bidCaseId_userId_stage_key"; +CREATE UNIQUE INDEX "BidReviewer_bidCaseId_userId_stage_cycle_key" ON "BidReviewer"("bidCaseId", "userId", "stage", "cycle"); + +-- CreateTable +CREATE TABLE "BidAssignee" ( + "id" TEXT NOT NULL, + "bidCaseId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BidAssignee_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "BidAssignee_bidCaseId_userId_role_key" ON "BidAssignee"("bidCaseId", "userId", "role"); +CREATE INDEX "BidAssignee_bidCaseId_idx" ON "BidAssignee"("bidCaseId"); + +ALTER TABLE "BidAssignee" ADD CONSTRAINT "BidAssignee_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "BidAssignee" ADD CONSTRAINT "BidAssignee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "BidDocVersion" ( + "id" TEXT NOT NULL, + "bidCaseId" TEXT NOT NULL, + "versionNo" INTEGER NOT NULL, + "nasPath" TEXT NOT NULL, + "note" TEXT, + "submittedById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BidDocVersion_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "BidDocVersion_bidCaseId_versionNo_key" ON "BidDocVersion"("bidCaseId", "versionNo"); +CREATE INDEX "BidDocVersion_bidCaseId_idx" ON "BidDocVersion"("bidCaseId"); + +ALTER TABLE "BidDocVersion" ADD CONSTRAINT "BidDocVersion_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "BidDocVersion" ADD CONSTRAINT "BidDocVersion_submittedById_fkey" FOREIGN KEY ("submittedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260829080000_contract_and_project/migration.sql b/apps/api/prisma/migrations/20260829080000_contract_and_project/migration.sql new file mode 100644 index 0000000..05e9821 --- /dev/null +++ b/apps/api/prisma/migrations/20260829080000_contract_and_project/migration.sql @@ -0,0 +1,56 @@ +-- AlterTable +ALTER TABLE "Contract" ADD COLUMN "legalEntityId" TEXT; +ALTER TABLE "Contract" ADD COLUMN "nasPath" TEXT; +ALTER TABLE "Contract" ADD COLUMN "remark" TEXT; +ALTER TABLE "Contract" ADD COLUMN "submittedAt" TIMESTAMP(3); +ALTER TABLE "Contract" ADD COLUMN "approvedAt" TIMESTAMP(3); +ALTER TABLE "Contract" ADD COLUMN "createdById" TEXT; + +ALTER TABLE "Contract" ADD CONSTRAINT "Contract_legalEntityId_fkey" FOREIGN KEY ("legalEntityId") REFERENCES "LegalEntity"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Contract" ADD CONSTRAINT "Contract_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AlterTable +ALTER TABLE "SealRequest" ADD COLUMN "contractId" TEXT; +ALTER TABLE "SealRequest" ADD COLUMN "applicantId" TEXT; + +ALTER TABLE "SealRequest" ADD CONSTRAINT "SealRequest_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "presalesCost" DECIMAL(18,2) NOT NULL DEFAULT 0; + +-- CreateTable +CREATE TABLE "ContractReviewer" ( + "id" TEXT NOT NULL, + "contractId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "result" TEXT NOT NULL DEFAULT 'PENDING', + "comment" TEXT, + "decidedAt" TIMESTAMP(3), + + CONSTRAINT "ContractReviewer_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ContractReviewer_contractId_userId_key" ON "ContractReviewer"("contractId", "userId"); +CREATE INDEX "ContractReviewer_contractId_idx" ON "ContractReviewer"("contractId"); + +ALTER TABLE "ContractReviewer" ADD CONSTRAINT "ContractReviewer_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ContractReviewer" ADD CONSTRAINT "ContractReviewer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "ContractActionLog" ( + "id" TEXT NOT NULL, + "contractId" TEXT NOT NULL, + "actorId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "fromStatus" TEXT, + "toStatus" TEXT, + "comment" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ContractActionLog_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "ContractActionLog_contractId_idx" ON "ContractActionLog"("contractId"); + +ALTER TABLE "ContractActionLog" ADD CONSTRAINT "ContractActionLog_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ContractActionLog" ADD CONSTRAINT "ContractActionLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260829090000_finance_funds/migration.sql b/apps/api/prisma/migrations/20260829090000_finance_funds/migration.sql new file mode 100644 index 0000000..e6482c9 --- /dev/null +++ b/apps/api/prisma/migrations/20260829090000_finance_funds/migration.sql @@ -0,0 +1,67 @@ +-- ExpenseClaim +ALTER TABLE "ExpenseClaim" ADD COLUMN "loanId" TEXT; +ALTER TABLE "ExpenseClaim" ADD COLUMN "submittedAt" TIMESTAMP(3); +ALTER TABLE "ExpenseClaim" ADD COLUMN "decidedAt" TIMESTAMP(3); +ALTER TABLE "ExpenseClaim" ADD COLUMN "decidedById" TEXT; +ALTER TABLE "ExpenseClaim" ADD COLUMN "rejectComment" TEXT; + +ALTER TABLE "ExpenseClaim" ADD CONSTRAINT "ExpenseClaim_applicantId_fkey" FOREIGN KEY ("applicantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "ExpenseClaim" ADD CONSTRAINT "ExpenseClaim_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "ExpenseClaim" ADD CONSTRAINT "ExpenseClaim_loanId_fkey" FOREIGN KEY ("loanId") REFERENCES "LoanRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE; +CREATE INDEX "ExpenseClaim_applicantId_idx" ON "ExpenseClaim"("applicantId"); +CREATE INDEX "ExpenseClaim_loanId_idx" ON "ExpenseClaim"("loanId"); + +-- LoanRecord +ALTER TABLE "LoanRecord" ADD COLUMN "loanNo" TEXT; +ALTER TABLE "LoanRecord" ADD COLUMN "costType" TEXT; +ALTER TABLE "LoanRecord" ADD COLUMN "bidCaseId" TEXT; +ALTER TABLE "LoanRecord" ADD COLUMN "projectId" TEXT; +ALTER TABLE "LoanRecord" ADD COLUMN "offsetAmount" DECIMAL(18,2) NOT NULL DEFAULT 0; +ALTER TABLE "LoanRecord" ADD COLUMN "remark" TEXT; +ALTER TABLE "LoanRecord" ADD COLUMN "submittedAt" TIMESTAMP(3); +ALTER TABLE "LoanRecord" ADD COLUMN "decidedAt" TIMESTAMP(3); +ALTER TABLE "LoanRecord" ADD COLUMN "decidedById" TEXT; + +UPDATE "LoanRecord" SET "loanNo" = 'JK-LEGACY-' || substr("id", 1, 8) WHERE "loanNo" IS NULL; +UPDATE "LoanRecord" SET "costType" = 'ADMIN' WHERE "costType" IS NULL; + +ALTER TABLE "LoanRecord" ALTER COLUMN "loanNo" SET NOT NULL; +ALTER TABLE "LoanRecord" ALTER COLUMN "costType" SET NOT NULL; +ALTER TABLE "LoanRecord" ALTER COLUMN "status" SET DEFAULT 'DRAFT'; + +CREATE UNIQUE INDEX "LoanRecord_tenantId_loanNo_key" ON "LoanRecord"("tenantId", "loanNo"); +CREATE INDEX "LoanRecord_applicantId_idx" ON "LoanRecord"("applicantId"); +ALTER TABLE "LoanRecord" ADD CONSTRAINT "LoanRecord_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "LoanRecord" ADD CONSTRAINT "LoanRecord_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "LoanRecord" ADD CONSTRAINT "LoanRecord_applicantId_fkey" FOREIGN KEY ("applicantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "LoanRecord" ADD CONSTRAINT "LoanRecord_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- BondRecord +ALTER TABLE "BondRecord" ADD COLUMN "bondNo" TEXT; +ALTER TABLE "BondRecord" ADD COLUMN "projectId" TEXT; +ALTER TABLE "BondRecord" ADD COLUMN "contractId" TEXT; +ALTER TABLE "BondRecord" ADD COLUMN "remark" TEXT; +ALTER TABLE "BondRecord" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +UPDATE "BondRecord" SET "bondNo" = 'BZ-LEGACY-' || substr("id", 1, 8) WHERE "bondNo" IS NULL; +ALTER TABLE "BondRecord" ALTER COLUMN "bondNo" SET NOT NULL; +ALTER TABLE "BondRecord" ALTER COLUMN "bidCaseId" DROP NOT NULL; + +ALTER TABLE "BondRecord" DROP CONSTRAINT "BondRecord_bidCaseId_fkey"; +ALTER TABLE "BondRecord" ADD CONSTRAINT "BondRecord_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "BondRecord" ADD CONSTRAINT "BondRecord_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "BondRecord" ADD CONSTRAINT "BondRecord_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE SET NULL ON UPDATE CASCADE; +CREATE UNIQUE INDEX "BondRecord_tenantId_bondNo_key" ON "BondRecord"("tenantId", "bondNo"); + +-- InvoiceRecord +DELETE FROM "InvoiceRecord"; +ALTER TABLE "InvoiceRecord" ADD COLUMN "contractId" TEXT NOT NULL; +ALTER TABLE "InvoiceRecord" ADD COLUMN "milestoneId" TEXT NOT NULL; +ALTER TABLE "InvoiceRecord" ADD COLUMN "dueAt" TIMESTAMP(3); +ALTER TABLE "InvoiceRecord" ADD COLUMN "remark" TEXT; +ALTER TABLE "InvoiceRecord" ADD COLUMN "createdById" TEXT; + +CREATE UNIQUE INDEX "InvoiceRecord_milestoneId_key" ON "InvoiceRecord"("milestoneId"); +ALTER TABLE "InvoiceRecord" ADD CONSTRAINT "InvoiceRecord_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "InvoiceRecord" ADD CONSTRAINT "InvoiceRecord_milestoneId_fkey" FOREIGN KEY ("milestoneId") REFERENCES "PaymentMilestone"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "InvoiceRecord" ADD CONSTRAINT "InvoiceRecord_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260829100000_project_execution/migration.sql b/apps/api/prisma/migrations/20260829100000_project_execution/migration.sql new file mode 100644 index 0000000..234080d --- /dev/null +++ b/apps/api/prisma/migrations/20260829100000_project_execution/migration.sql @@ -0,0 +1,57 @@ +-- Project hour rate and acceptance timestamps +ALTER TABLE "Project" ADD COLUMN "hourRate" DECIMAL(18,2) NOT NULL DEFAULT 200; +ALTER TABLE "Project" ADD COLUMN "internalAcceptedAt" TIMESTAMP(3); +ALTER TABLE "Project" ADD COLUMN "customerAcceptedAt" TIMESTAMP(3); + +-- ProjectTask WBS +ALTER TABLE "ProjectTask" ADD COLUMN "departmentId" TEXT; +ALTER TABLE "ProjectTask" ADD COLUMN "assigneeId" TEXT; +ALTER TABLE "ProjectTask" ADD COLUMN "sortNo" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "ProjectTask" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +CREATE INDEX "ProjectTask_projectId_idx" ON "ProjectTask"("projectId"); +ALTER TABLE "ProjectTask" ADD CONSTRAINT "ProjectTask_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "ProjectTask" ADD CONSTRAINT "ProjectTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- Timesheet +ALTER TABLE "Timesheet" ADD COLUMN "taskId" TEXT; +ALTER TABLE "Timesheet" ADD COLUMN "costAmount" DECIMAL(18,2) NOT NULL DEFAULT 0; +ALTER TABLE "Timesheet" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +CREATE INDEX "Timesheet_projectId_idx" ON "Timesheet"("projectId"); +CREATE INDEX "Timesheet_userId_idx" ON "Timesheet"("userId"); +ALTER TABLE "Timesheet" ADD CONSTRAINT "Timesheet_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "ProjectTask"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Timesheet" ADD CONSTRAINT "Timesheet_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Expense budget category (travel / purchase) +ALTER TABLE "ExpenseClaim" ADD COLUMN "budgetCategory" TEXT; + +-- ProjectBudget +CREATE TABLE "ProjectBudget" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "category" TEXT NOT NULL, + "amount" DECIMAL(18,2) NOT NULL, + + CONSTRAINT "ProjectBudget_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ProjectBudget_projectId_category_key" ON "ProjectBudget"("projectId", "category"); +ALTER TABLE "ProjectBudget" ADD CONSTRAINT "ProjectBudget_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- ProjectAcceptance +CREATE TABLE "ProjectAcceptance" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "result" TEXT NOT NULL, + "comment" TEXT, + "actorId" TEXT NOT NULL, + "decidedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ProjectAcceptance_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "ProjectAcceptance_projectId_idx" ON "ProjectAcceptance"("projectId"); +ALTER TABLE "ProjectAcceptance" ADD CONSTRAINT "ProjectAcceptance_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ProjectAcceptance" ADD CONSTRAINT "ProjectAcceptance_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260829110000_seal_office/migration.sql b/apps/api/prisma/migrations/20260829110000_seal_office/migration.sql new file mode 100644 index 0000000..d884b9d --- /dev/null +++ b/apps/api/prisma/migrations/20260829110000_seal_office/migration.sql @@ -0,0 +1,19 @@ +-- SealRequest: actual return and decision +ALTER TABLE "SealRequest" ADD COLUMN "returnedAt" TIMESTAMP(3); +ALTER TABLE "SealRequest" ADD COLUMN "decidedAt" TIMESTAMP(3); +ALTER TABLE "SealRequest" ADD COLUMN "decidedById" TEXT; +ALTER TABLE "SealRequest" ADD COLUMN "rejectComment" TEXT; + +ALTER TABLE "SealRequest" ADD CONSTRAINT "SealRequest_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "SealRequest" ADD CONSTRAINT "SealRequest_applicantId_fkey" FOREIGN KEY ("applicantId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "SealRequest" ADD CONSTRAINT "SealRequest_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- CredentialBorrow: link to person, qualification, bid +ALTER TABLE "CredentialBorrow" ADD COLUMN "borrowerId" TEXT; +ALTER TABLE "CredentialBorrow" ADD COLUMN "qualificationId" TEXT; +ALTER TABLE "CredentialBorrow" ADD COLUMN "bidCaseId" TEXT; +ALTER TABLE "CredentialBorrow" ADD COLUMN "remark" TEXT; + +ALTER TABLE "CredentialBorrow" ADD CONSTRAINT "CredentialBorrow_borrowerId_fkey" FOREIGN KEY ("borrowerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "CredentialBorrow" ADD CONSTRAINT "CredentialBorrow_qualificationId_fkey" FOREIGN KEY ("qualificationId") REFERENCES "Qualification"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "CredentialBorrow" ADD CONSTRAINT "CredentialBorrow_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260829120000_hr_org_payroll/migration.sql b/apps/api/prisma/migrations/20260829120000_hr_org_payroll/migration.sql new file mode 100644 index 0000000..074e35e --- /dev/null +++ b/apps/api/prisma/migrations/20260829120000_hr_org_payroll/migration.sql @@ -0,0 +1,134 @@ +-- Employee: salary, reporting line, lifecycle dates, compensatory leave +ALTER TABLE "Employee" ADD COLUMN "internStartAt" TIMESTAMP(3); +ALTER TABLE "Employee" ADD COLUMN "probationEndAt" TIMESTAMP(3); +ALTER TABLE "Employee" ADD COLUMN "leftAt" TIMESTAMP(3); +ALTER TABLE "Employee" ADD COLUMN "managerId" TEXT; +ALTER TABLE "Employee" ADD COLUMN "baseSalary" DECIMAL(18,2) NOT NULL DEFAULT 0; +ALTER TABLE "Employee" ADD COLUMN "perfBase" DECIMAL(18,2) NOT NULL DEFAULT 0; +ALTER TABLE "Employee" ADD COLUMN "socialBase" DECIMAL(18,2) NOT NULL DEFAULT 0; +ALTER TABLE "Employee" ADD COLUMN "compLeaveHours" DECIMAL(10,2) NOT NULL DEFAULT 0; + +CREATE INDEX "Employee_departmentId_idx" ON "Employee"("departmentId"); +CREATE INDEX "Employee_managerId_idx" ON "Employee"("managerId"); + +ALTER TABLE "Employee" ADD CONSTRAINT "Employee_managerId_fkey" FOREIGN KEY ("managerId") REFERENCES "Employee"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +UPDATE "Employee" SET "baseSalary" = 8000, "perfBase" = 0, "socialBase" = 8000 WHERE "employeeNo" = 'E000'; +UPDATE "Employee" SET "baseSalary" = 25000, "perfBase" = 10000, "socialBase" = 20000 WHERE "employeeNo" = 'E001'; +UPDATE "Employee" SET "baseSalary" = 18000, "perfBase" = 8000, "socialBase" = 18000 WHERE "employeeNo" = 'E010'; +UPDATE "Employee" SET "baseSalary" = 9000, "perfBase" = 2500, "socialBase" = 9000 WHERE "employeeNo" = 'E011'; +UPDATE "Employee" SET "baseSalary" = 15000, "perfBase" = 5000, "socialBase" = 15000 WHERE "employeeNo" IN ('E020','E021','E022','E023'); +UPDATE "Employee" SET "baseSalary" = 10000, "perfBase" = 2000, "socialBase" = 10000 WHERE "employeeNo" = 'E030'; +UPDATE "Employee" SET "baseSalary" = 9000, "perfBase" = 2000, "socialBase" = 9000 WHERE "employeeNo" = 'E040'; +UPDATE "Employee" SET "baseSalary" = 10000, "perfBase" = 3000, "socialBase" = 10000 WHERE "employeeNo" = 'E100'; + +UPDATE "Employee" AS e +SET "managerId" = d.id +FROM "Employee" AS d +WHERE e."employeeNo" = 'E100' AND d."employeeNo" = 'E023'; + +-- CreateTable +CREATE TABLE "EmploymentEvent" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "employeeId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "fromStatus" TEXT, + "toStatus" TEXT, + "fromDeptId" TEXT, + "toDeptId" TEXT, + "effectiveAt" TIMESTAMP(3) NOT NULL, + "comment" TEXT, + "actorId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "EmploymentEvent_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "EmploymentEvent_employeeId_idx" ON "EmploymentEvent"("employeeId"); +CREATE INDEX "EmploymentEvent_effectiveAt_idx" ON "EmploymentEvent"("effectiveAt"); + +ALTER TABLE "EmploymentEvent" ADD CONSTRAINT "EmploymentEvent_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "EmploymentEvent" ADD CONSTRAINT "EmploymentEvent_fromDeptId_fkey" FOREIGN KEY ("fromDeptId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "EmploymentEvent" ADD CONSTRAINT "EmploymentEvent_toDeptId_fkey" FOREIGN KEY ("toDeptId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "EmploymentEvent" ADD CONSTRAINT "EmploymentEvent_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "AttendanceMonth" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "employeeId" TEXT NOT NULL, + "yearMonth" TEXT NOT NULL, + "workDays" DECIMAL(6,2) NOT NULL DEFAULT 0, + "leaveDays" DECIMAL(6,2) NOT NULL DEFAULT 0, + "sickDays" DECIMAL(6,2) NOT NULL DEFAULT 0, + "overtimeHours" DECIMAL(6,2) NOT NULL DEFAULT 0, + "compLeaveDays" DECIMAL(6,2) NOT NULL DEFAULT 0, + "remark" TEXT, + "importedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "importedById" TEXT, + + CONSTRAINT "AttendanceMonth_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AttendanceMonth_employeeId_yearMonth_key" ON "AttendanceMonth"("employeeId", "yearMonth"); +CREATE INDEX "AttendanceMonth_yearMonth_idx" ON "AttendanceMonth"("yearMonth"); + +ALTER TABLE "AttendanceMonth" ADD CONSTRAINT "AttendanceMonth_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "AttendanceMonth" ADD CONSTRAINT "AttendanceMonth_importedById_fkey" FOREIGN KEY ("importedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "PayrollRun" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "yearMonth" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'DRAFT', + "createdById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "confirmedAt" TIMESTAMP(3), + + CONSTRAINT "PayrollRun_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "PayrollRun_tenantId_yearMonth_key" ON "PayrollRun"("tenantId", "yearMonth"); + +ALTER TABLE "PayrollRun" ADD CONSTRAINT "PayrollRun_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "PayrollItem" ( + "id" TEXT NOT NULL, + "runId" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "prorationFactor" DECIMAL(8,6) NOT NULL DEFAULT 1, + "scheduledDays" DECIMAL(6,2) NOT NULL DEFAULT 0, + "dailyRate" DECIMAL(18,2) NOT NULL DEFAULT 0, + "basePay" DECIMAL(18,2) NOT NULL DEFAULT 0, + "performancePay" DECIMAL(18,2) NOT NULL DEFAULT 0, + "performanceRatio" DECIMAL(5,2) NOT NULL DEFAULT 100, + "leaveDeduct" DECIMAL(18,2) NOT NULL DEFAULT 0, + "sickDeduct" DECIMAL(18,2) NOT NULL DEFAULT 0, + "socialPersonal" DECIMAL(18,2) NOT NULL DEFAULT 0, + "housingFund" DECIMAL(18,2) NOT NULL DEFAULT 0, + "tax" DECIMAL(18,2) NOT NULL DEFAULT 0, + "grossPay" DECIMAL(18,2) NOT NULL DEFAULT 0, + "netPay" DECIMAL(18,2) NOT NULL DEFAULT 0, + "overtimeHours" DECIMAL(6,2) NOT NULL DEFAULT 0, + "compLeaveUsedDays" DECIMAL(6,2) NOT NULL DEFAULT 0, + "compLeaveHours" DECIMAL(10,2) NOT NULL DEFAULT 0, + + CONSTRAINT "PayrollItem_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "PayrollItem_runId_employeeId_key" ON "PayrollItem"("runId", "employeeId"); + +ALTER TABLE "PayrollItem" ADD CONSTRAINT "PayrollItem_runId_fkey" FOREIGN KEY ("runId") REFERENCES "PayrollRun"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "PayrollItem" ADD CONSTRAINT "PayrollItem_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Directors only see 绩效发放 under 组织人事, not the full HR module +INSERT INTO "RolePermission" ("roleId", "permissionId") +SELECT r.id, p.id +FROM "Role" r +CROSS JOIN "Permission" p +WHERE r.code IN ('tech_director', 'biz_director') + AND p.code = 'hr:performance' +ON CONFLICT DO NOTHING; diff --git a/apps/api/prisma/migrations/20260829130000_reports_and_ops/migration.sql b/apps/api/prisma/migrations/20260829130000_reports_and_ops/migration.sql new file mode 100644 index 0000000..36cb533 --- /dev/null +++ b/apps/api/prisma/migrations/20260829130000_reports_and_ops/migration.sql @@ -0,0 +1,98 @@ +ALTER TABLE "Role" ADD COLUMN "dataScope" TEXT NOT NULL DEFAULT 'ALL'; + +ALTER TABLE "WorkReport" ADD COLUMN "periodType" TEXT NOT NULL DEFAULT 'DAY'; +ALTER TABLE "WorkReport" ADD COLUMN "periodStart" TIMESTAMP(3); +ALTER TABLE "WorkReport" ADD COLUMN "hours" DECIMAL(8,2) NOT NULL DEFAULT 0; + +DELETE FROM "WorkReport" WHERE "authorId" IS NOT NULL AND "authorId" NOT IN (SELECT "id" FROM "User"); +ALTER TABLE "WorkReport" ADD CONSTRAINT "WorkReport_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +CREATE INDEX "WorkReport_authorId_idx" ON "WorkReport"("authorId"); + +ALTER TABLE "PurchaseRequest" ADD COLUMN "remark" TEXT; +ALTER TABLE "PurchaseRequest" ADD COLUMN "applicantId" TEXT; +ALTER TABLE "PurchaseRequest" ADD COLUMN "decidedById" TEXT; +ALTER TABLE "PurchaseRequest" ADD COLUMN "decidedAt" TIMESTAMP(3); +UPDATE "PurchaseRequest" SET "projectId" = NULL WHERE "projectId" IS NOT NULL AND "projectId" NOT IN (SELECT "id" FROM "Project"); +ALTER TABLE "PurchaseRequest" ADD CONSTRAINT "PurchaseRequest_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "PurchaseRequest" ADD CONSTRAINT "PurchaseRequest_applicantId_fkey" FOREIGN KEY ("applicantId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "PurchaseRequest" ADD CONSTRAINT "PurchaseRequest_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "AssetOccupancy" ADD COLUMN "holderId" TEXT; +ALTER TABLE "AssetOccupancy" ADD COLUMN "projectId" TEXT; +ALTER TABLE "AssetOccupancy" ADD COLUMN "dueAt" TIMESTAMP(3); +ALTER TABLE "AssetOccupancy" ADD COLUMN "returnedAt" TIMESTAMP(3); +UPDATE "AssetOccupancy" SET "projectId" = NULL WHERE "projectId" IS NOT NULL AND "projectId" NOT IN (SELECT "id" FROM "Project"); +ALTER TABLE "AssetOccupancy" ADD CONSTRAINT "AssetOccupancy_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "AssetOccupancy" ADD CONSTRAINT "AssetOccupancy_holderId_fkey" FOREIGN KEY ("holderId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "OperationLog" ADD COLUMN "username" TEXT; +ALTER TABLE "OperationLog" ADD COLUMN "detail" TEXT; +ALTER TABLE "OperationLog" ADD COLUMN "success" BOOLEAN NOT NULL DEFAULT true; +UPDATE "OperationLog" SET "userId" = NULL WHERE "userId" IS NOT NULL AND "userId" NOT IN (SELECT "id" FROM "User"); +ALTER TABLE "OperationLog" ADD CONSTRAINT "OperationLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +CREATE INDEX "OperationLog_createdAt_idx" ON "OperationLog"("createdAt"); +CREATE INDEX "OperationLog_action_idx" ON "OperationLog"("action"); + +CREATE TABLE "NumberRule" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "prefix" TEXT NOT NULL, + "padLength" INTEGER NOT NULL DEFAULT 5, + CONSTRAINT "NumberRule_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "NumberRule_tenantId_code_key" ON "NumberRule"("tenantId", "code"); + +INSERT INTO "NumberRule" ("id", "code", "name", "prefix", "padLength") VALUES + ('nr-bid', 'BID', '投标编号', 'BID', 5), + ('nr-ht', 'HT', '合同编号', 'HT', 5), + ('nr-prj', 'PRJ', '项目编号', 'PRJ', 5); + +CREATE TABLE "SealItem" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "name" TEXT NOT NULL, + "keeper" TEXT NOT NULL DEFAULT '行政', + "status" TEXT NOT NULL DEFAULT 'IN_STOCK', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "SealItem_pkey" PRIMARY KEY ("id") +); + +INSERT INTO "SealItem" ("id", "name", "keeper", "status") VALUES + ('seal-official', '公章', '行政', 'IN_STOCK'), + ('seal-legal', '法人章', '行政', 'IN_STOCK'); + +CREATE TABLE "ProjectChange" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "projectId" TEXT NOT NULL, + "summary" TEXT NOT NULL, + "amountDelta" DECIMAL(18,2) NOT NULL DEFAULT 0, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "comment" TEXT, + "actorId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "decidedAt" TIMESTAMP(3), + CONSTRAINT "ProjectChange_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "ProjectChange_projectId_idx" ON "ProjectChange"("projectId"); +ALTER TABLE "ProjectChange" ADD CONSTRAINT "ProjectChange_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ProjectChange" ADD CONSTRAINT "ProjectChange_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE TABLE "PaymentRecord" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "payNo" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "refId" TEXT, + "amount" DECIMAL(18,2) NOT NULL, + "status" TEXT NOT NULL DEFAULT 'TODO', + "remark" TEXT, + "paidAt" TIMESTAMP(3), + "createdById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PaymentRecord_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "PaymentRecord_tenantId_payNo_key" ON "PaymentRecord"("tenantId", "payNo"); +ALTER TABLE "PaymentRecord" ADD CONSTRAINT "PaymentRecord_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260829140000_work_report_flow/migration.sql b/apps/api/prisma/migrations/20260829140000_work_report_flow/migration.sql new file mode 100644 index 0000000..54acbb0 --- /dev/null +++ b/apps/api/prisma/migrations/20260829140000_work_report_flow/migration.sql @@ -0,0 +1,13 @@ +ALTER TABLE "WorkReport" ADD COLUMN "toUserId" TEXT; +ALTER TABLE "WorkReport" ADD COLUMN "ccUserId" TEXT; +ALTER TABLE "WorkReport" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'DRAFT'; +ALTER TABLE "WorkReport" ADD COLUMN "submittedAt" TIMESTAMP(3); +ALTER TABLE "WorkReport" ALTER COLUMN "content" SET DEFAULT ''; + +UPDATE "WorkReport" SET "toUserId" = NULL WHERE "toUserId" IS NOT NULL AND "toUserId" NOT IN (SELECT "id" FROM "User"); +UPDATE "WorkReport" SET "ccUserId" = NULL WHERE "ccUserId" IS NOT NULL AND "ccUserId" NOT IN (SELECT "id" FROM "User"); + +ALTER TABLE "WorkReport" ADD CONSTRAINT "WorkReport_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "WorkReport" ADD CONSTRAINT "WorkReport_ccUserId_fkey" FOREIGN KEY ("ccUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +CREATE INDEX "WorkReport_toUserId_idx" ON "WorkReport"("toUserId"); +CREATE INDEX "WorkReport_ccUserId_idx" ON "WorkReport"("ccUserId"); diff --git a/apps/api/prisma/migrations/20260829150000_party_expense_forms/migration.sql b/apps/api/prisma/migrations/20260829150000_party_expense_forms/migration.sql new file mode 100644 index 0000000..650d3fc --- /dev/null +++ b/apps/api/prisma/migrations/20260829150000_party_expense_forms/migration.sql @@ -0,0 +1,29 @@ +ALTER TABLE "ExpenseClaim" ADD COLUMN IF NOT EXISTS "expenseCategory" TEXT; + +CREATE TABLE IF NOT EXISTS "FileAsset" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "bizType" TEXT NOT NULL, + "bizId" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "storageKey" TEXT NOT NULL, + "uploadedById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FileAsset_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "FileAsset_bizType_bizId_idx" ON "FileAsset"("bizType", "bizId"); +CREATE INDEX IF NOT EXISTS "FileAsset_uploadedById_idx" ON "FileAsset"("uploadedById"); + +INSERT INTO "DictItem" ("id", "tenantId", "dictType", "label", "value", "sortNo") +VALUES + ('e1c10000-0000-4000-8000-000000000001', '1', 'expenseCategory', '交通', '交通', 1), + ('e1c10000-0000-4000-8000-000000000002', '1', 'expenseCategory', '餐饮', '餐饮', 2), + ('e1c10000-0000-4000-8000-000000000003', '1', 'expenseCategory', '住宿', '住宿', 3), + ('e1c10000-0000-4000-8000-000000000004', '1', 'expenseCategory', '办公', '办公', 4), + ('e1c10000-0000-4000-8000-000000000005', '1', 'expenseCategory', '招待', '招待', 5), + ('e1c10000-0000-4000-8000-000000000006', '1', 'expenseCategory', '其他', '其他', 6) +ON CONFLICT ("tenantId", "dictType", "value") DO NOTHING; diff --git a/apps/api/prisma/migrations/20260829170000_sms_send_log/migration.sql b/apps/api/prisma/migrations/20260829170000_sms_send_log/migration.sql new file mode 100644 index 0000000..f6930c9 --- /dev/null +++ b/apps/api/prisma/migrations/20260829170000_sms_send_log/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "SmsSendLog" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "phones" TEXT NOT NULL, + "signName" TEXT NOT NULL, + "templateCode" TEXT NOT NULL, + "templateParam" TEXT, + "bizId" TEXT, + "code" TEXT NOT NULL, + "message" TEXT, + "createdById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SmsSendLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "SmsSendLog_createdAt_idx" ON "SmsSendLog"("createdAt"); diff --git a/apps/api/prisma/migrations/20260829180000_hr_archive_settings/migration.sql b/apps/api/prisma/migrations/20260829180000_hr_archive_settings/migration.sql new file mode 100644 index 0000000..82a9e0b --- /dev/null +++ b/apps/api/prisma/migrations/20260829180000_hr_archive_settings/migration.sql @@ -0,0 +1,67 @@ +-- Employee archive fields aligned with personnel files +ALTER TABLE "Employee" ADD COLUMN "gender" TEXT; +ALTER TABLE "Employee" ADD COLUMN "email" TEXT; +ALTER TABLE "Employee" ADD COLUMN "mobile" TEXT; +ALTER TABLE "Employee" ADD COLUMN "idNo" TEXT; +ALTER TABLE "Employee" ADD COLUMN "education" TEXT; +ALTER TABLE "Employee" ADD COLUMN "bankName" TEXT; +ALTER TABLE "Employee" ADD COLUMN "bankAccount" TEXT; +ALTER TABLE "Employee" ADD COLUMN "remark" TEXT; + +CREATE TABLE "HrSetting" ( + "id" TEXT NOT NULL DEFAULT 'default', + "tenantId" TEXT NOT NULL DEFAULT '1', + "idMask" BOOLEAN NOT NULL DEFAULT true, + "autoAccount" BOOLEAN NOT NULL DEFAULT false, + "probationMonths" INTEGER NOT NULL DEFAULT 3, + "defaultRest" TEXT NOT NULL DEFAULT 'SINGLE', + "workStart" TEXT NOT NULL DEFAULT '09:00', + "workEnd" TEXT NOT NULL DEFAULT '18:00', + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "HrSetting_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "HrSetting_tenantId_key" ON "HrSetting"("tenantId"); + +INSERT INTO "HrSetting" ("id", "tenantId", "idMask", "autoAccount", "probationMonths", "defaultRest", "workStart", "workEnd", "updatedAt") +VALUES ('default', '1', true, false, 3, 'SINGLE', '09:00', '18:00', CURRENT_TIMESTAMP); + +CREATE TABLE "AttendanceRule" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "name" TEXT NOT NULL, + "restSchedule" TEXT NOT NULL, + "workStart" TEXT NOT NULL, + "workEnd" TEXT NOT NULL, + "lateGraceMinutes" INTEGER NOT NULL DEFAULT 0, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "sortNo" INTEGER NOT NULL DEFAULT 0, + "remark" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AttendanceRule_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AttendanceRule_tenantId_name_key" ON "AttendanceRule"("tenantId", "name"); + +INSERT INTO "AttendanceRule" ("id", "tenantId", "name", "restSchedule", "workStart", "workEnd", "lateGraceMinutes", "enabled", "sortNo", "remark", "createdAt", "updatedAt") +VALUES + ('att-rule-single-000000000001', '1', '单休班次', 'SINGLE', '09:00', '18:00', 0, true, 1, '每周休一天,薪酬按 26.09 天核算', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('att-rule-double-000000000001', '1', '双休班次', 'DOUBLE', '09:00', '18:00', 0, true, 2, '每周休两天,薪酬按 21.75 天核算', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +INSERT INTO "DictItem" ("id", "tenantId", "dictType", "label", "value", "sortNo") +SELECT d."id", '1', d."dictType", d."label", d."value", d."sortNo" +FROM (VALUES + ('dict-gender-male-00000000001', 'gender', '男', 'MALE', 1), + ('dict-gender-female-000000001', 'gender', '女', 'FEMALE', 2), + ('dict-edu-high-0000000000001', 'education', '高中及以下', 'HIGH_SCHOOL', 1), + ('dict-edu-college-00000000001', 'education', '专科', 'COLLEGE', 2), + ('dict-edu-bachelor-0000000001', 'education', '本科', 'BACHELOR', 3), + ('dict-edu-master-000000000001', 'education', '硕士', 'MASTER', 4), + ('dict-edu-doctor-000000000001', 'education', '博士', 'DOCTOR', 5) +) AS d("id", "dictType", "label", "value", "sortNo") +WHERE NOT EXISTS ( + SELECT 1 FROM "DictItem" x WHERE x."tenantId" = '1' AND x."dictType" = d."dictType" AND x."value" = d."value" +); diff --git a/apps/api/prisma/migrations/20260830140000_contract_parties/migration.sql b/apps/api/prisma/migrations/20260830140000_contract_parties/migration.sql new file mode 100644 index 0000000..2b2c5d8 --- /dev/null +++ b/apps/api/prisma/migrations/20260830140000_contract_parties/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Contract" ADD COLUMN "partyA" TEXT; +ALTER TABLE "Contract" ADD COLUMN "partyB" TEXT; diff --git a/apps/api/prisma/migrations/20260830150000_leave_and_punch/migration.sql b/apps/api/prisma/migrations/20260830150000_leave_and_punch/migration.sql new file mode 100644 index 0000000..3043e27 --- /dev/null +++ b/apps/api/prisma/migrations/20260830150000_leave_and_punch/migration.sql @@ -0,0 +1,48 @@ +-- CreateTable +CREATE TABLE "AttendancePunch" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "employeeId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "punchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "remark" TEXT, + + CONSTRAINT "AttendancePunch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LeaveRequest" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "employeeId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "startAt" TIMESTAMP(3) NOT NULL, + "endAt" TIMESTAMP(3) NOT NULL, + "days" DECIMAL(6,2) NOT NULL, + "reason" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "comment" TEXT, + "decidedById" TEXT, + "decidedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LeaveRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AttendancePunch_employeeId_punchedAt_idx" ON "AttendancePunch"("employeeId", "punchedAt"); + +-- CreateIndex +CREATE INDEX "LeaveRequest_employeeId_idx" ON "LeaveRequest"("employeeId"); + +-- CreateIndex +CREATE INDEX "LeaveRequest_status_idx" ON "LeaveRequest"("status"); + +-- AddForeignKey +ALTER TABLE "AttendancePunch" ADD CONSTRAINT "AttendancePunch_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LeaveRequest" ADD CONSTRAINT "LeaveRequest_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LeaveRequest" ADD CONSTRAINT "LeaveRequest_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260830170000_labor_contracts/migration.sql b/apps/api/prisma/migrations/20260830170000_labor_contracts/migration.sql new file mode 100644 index 0000000..455e4f0 --- /dev/null +++ b/apps/api/prisma/migrations/20260830170000_labor_contracts/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "LaborContract" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "employeeId" TEXT NOT NULL, + "contractNo" TEXT, + "startAt" TIMESTAMP(3), + "endAt" TIMESTAMP(3), + "remark" TEXT, + "createdById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LaborContract_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "LaborContract_employeeId_idx" ON "LaborContract"("employeeId"); + +-- CreateIndex +CREATE INDEX "LaborContract_tenantId_createdAt_idx" ON "LaborContract"("tenantId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "LaborContract" ADD CONSTRAINT "LaborContract_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LaborContract" ADD CONSTRAINT "LaborContract_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260830180000_onboard_assets_assign/migration.sql b/apps/api/prisma/migrations/20260830180000_onboard_assets_assign/migration.sql new file mode 100644 index 0000000..4d6e41c --- /dev/null +++ b/apps/api/prisma/migrations/20260830180000_onboard_assets_assign/migration.sql @@ -0,0 +1,70 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "mustChangePassword" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "profileCompletedAt" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "Contract" ADD COLUMN IF NOT EXISTS "kind" TEXT NOT NULL DEFAULT 'OTHER'; +UPDATE "Contract" SET "kind" = 'TENDER' WHERE "bidCaseId" IS NOT NULL; + +-- AlterTable +ALTER TABLE "BidCase" ADD COLUMN IF NOT EXISTS "needSurvey" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "SealItem" ADD COLUMN IF NOT EXISTS "sealType" TEXT NOT NULL DEFAULT '公章'; +ALTER TABLE "SealItem" ADD COLUMN IF NOT EXISTS "legalEntityId" TEXT; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "WorkAssignment" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "title" TEXT NOT NULL, + "content" TEXT NOT NULL DEFAULT '', + "assigneeId" TEXT NOT NULL, + "dueAt" TIMESTAMP(3), + "status" TEXT NOT NULL DEFAULT 'OPEN', + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WorkAssignment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "RegisteredAsset" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "kind" TEXT NOT NULL, + "name" TEXT NOT NULL, + "code" TEXT, + "spec" TEXT, + "keeper" TEXT, + "location" TEXT, + "amount" DECIMAL(18,2), + "remark" TEXT, + "legalEntityId" TEXT, + "createdById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RegisteredAsset_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "WorkAssignment_assigneeId_idx" ON "WorkAssignment"("assigneeId"); +CREATE INDEX IF NOT EXISTS "WorkAssignment_createdById_idx" ON "WorkAssignment"("createdById"); +CREATE INDEX IF NOT EXISTS "RegisteredAsset_kind_idx" ON "RegisteredAsset"("kind"); +CREATE INDEX IF NOT EXISTS "RegisteredAsset_legalEntityId_idx" ON "RegisteredAsset"("legalEntityId"); + +ALTER TABLE "WorkAssignment" DROP CONSTRAINT IF EXISTS "WorkAssignment_assigneeId_fkey"; +ALTER TABLE "WorkAssignment" ADD CONSTRAINT "WorkAssignment_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "WorkAssignment" DROP CONSTRAINT IF EXISTS "WorkAssignment_createdById_fkey"; +ALTER TABLE "WorkAssignment" ADD CONSTRAINT "WorkAssignment_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "RegisteredAsset" DROP CONSTRAINT IF EXISTS "RegisteredAsset_legalEntityId_fkey"; +ALTER TABLE "RegisteredAsset" ADD CONSTRAINT "RegisteredAsset_legalEntityId_fkey" FOREIGN KEY ("legalEntityId") REFERENCES "LegalEntity"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "RegisteredAsset" DROP CONSTRAINT IF EXISTS "RegisteredAsset_createdById_fkey"; +ALTER TABLE "RegisteredAsset" ADD CONSTRAINT "RegisteredAsset_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "SealItem" DROP CONSTRAINT IF EXISTS "SealItem_legalEntityId_fkey"; +ALTER TABLE "SealItem" ADD CONSTRAINT "SealItem_legalEntityId_fkey" FOREIGN KEY ("legalEntityId") REFERENCES "LegalEntity"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- 系统管理员不强制改密/补档 +UPDATE "User" u SET "mustChangePassword" = false, "profileCompletedAt" = CURRENT_TIMESTAMP +WHERE EXISTS (SELECT 1 FROM "UserRole" ur JOIN "Role" r ON r.id = ur."roleId" WHERE ur."userId" = u.id AND r.code = 'admin'); diff --git a/apps/api/prisma/migrations/20260830190000_project_party_budget/migration.sql b/apps/api/prisma/migrations/20260830190000_project_party_budget/migration.sql new file mode 100644 index 0000000..333867d --- /dev/null +++ b/apps/api/prisma/migrations/20260830190000_project_party_budget/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "partyName" TEXT; diff --git a/apps/api/prisma/migrations/20260830200000_project_accept_materials/migration.sql b/apps/api/prisma/migrations/20260830200000_project_accept_materials/migration.sql new file mode 100644 index 0000000..09ef78c --- /dev/null +++ b/apps/api/prisma/migrations/20260830200000_project_accept_materials/migration.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS "ProjectAcceptMaterial" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "remark" TEXT, + "required" BOOLEAN NOT NULL DEFAULT true, + "sortNo" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ProjectAcceptMaterial_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "ProjectAcceptMaterial_projectId_idx" ON "ProjectAcceptMaterial"("projectId"); + +ALTER TABLE "ProjectAcceptMaterial" DROP CONSTRAINT IF EXISTS "ProjectAcceptMaterial_projectId_fkey"; +ALTER TABLE "ProjectAcceptMaterial" ADD CONSTRAINT "ProjectAcceptMaterial_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260830210000_office_apply_asset_link/migration.sql b/apps/api/prisma/migrations/20260830210000_office_apply_asset_link/migration.sql new file mode 100644 index 0000000..2840ba9 --- /dev/null +++ b/apps/api/prisma/migrations/20260830210000_office_apply_asset_link/migration.sql @@ -0,0 +1,51 @@ +-- AlterTable +ALTER TABLE "AssetOccupancy" ADD COLUMN IF NOT EXISTS "registeredAssetId" TEXT; + +CREATE INDEX IF NOT EXISTS "AssetOccupancy_registeredAssetId_idx" ON "AssetOccupancy"("registeredAssetId"); + +ALTER TABLE "AssetOccupancy" DROP CONSTRAINT IF EXISTS "AssetOccupancy_registeredAssetId_fkey"; +ALTER TABLE "AssetOccupancy" ADD CONSTRAINT "AssetOccupancy_registeredAssetId_fkey" FOREIGN KEY ("registeredAssetId") REFERENCES "RegisteredAsset"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "OfficeApply" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "kind" TEXT NOT NULL, + "title" TEXT NOT NULL, + "reason" TEXT, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "assetKind" TEXT, + "registeredAssetId" TEXT, + "amount" DECIMAL(18,2), + "destination" TEXT, + "startAt" TIMESTAMP(3), + "endAt" TIMESTAMP(3), + "projectId" TEXT, + "purchaseId" TEXT, + "occupancyId" TEXT, + "applicantId" TEXT NOT NULL, + "decidedById" TEXT, + "decidedAt" TIMESTAMP(3), + "remark" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OfficeApply_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "OfficeApply_applicantId_idx" ON "OfficeApply"("applicantId"); +CREATE INDEX IF NOT EXISTS "OfficeApply_status_idx" ON "OfficeApply"("status"); +CREATE INDEX IF NOT EXISTS "OfficeApply_kind_idx" ON "OfficeApply"("kind"); + +ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_applicantId_fkey"; +ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_applicantId_fkey" FOREIGN KEY ("applicantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_decidedById_fkey"; +ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_registeredAssetId_fkey"; +ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_registeredAssetId_fkey" FOREIGN KEY ("registeredAssetId") REFERENCES "RegisteredAsset"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_projectId_fkey"; +ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_purchaseId_fkey"; +ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_purchaseId_fkey" FOREIGN KEY ("purchaseId") REFERENCES "PurchaseRequest"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_occupancyId_fkey"; +ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_occupancyId_fkey" FOREIGN KEY ("occupancyId") REFERENCES "AssetOccupancy"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260830220000_office_flow_inbound_notices/migration.sql b/apps/api/prisma/migrations/20260830220000_office_flow_inbound_notices/migration.sql new file mode 100644 index 0000000..6ada1d0 --- /dev/null +++ b/apps/api/prisma/migrations/20260830220000_office_flow_inbound_notices/migration.sql @@ -0,0 +1,24 @@ +ALTER TABLE "RegisteredAsset" ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'ACTIVE'; +ALTER TABLE "RegisteredAsset" ADD COLUMN IF NOT EXISTS "usage" TEXT NOT NULL DEFAULT 'GENERAL'; + +ALTER TABLE "OfficeApply" ADD COLUMN IF NOT EXISTS "inboundAssetId" TEXT; +ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_inboundAssetId_fkey"; +ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_inboundAssetId_fkey" FOREIGN KEY ("inboundAssetId") REFERENCES "RegisteredAsset"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE TABLE IF NOT EXISTS "Notice" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PUBLISHED', + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Notice_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "Notice_status_idx" ON "Notice"("status"); +CREATE INDEX IF NOT EXISTS "Notice_createdAt_idx" ON "Notice"("createdAt"); + +ALTER TABLE "Notice" DROP CONSTRAINT IF EXISTS "Notice_createdById_fkey"; +ALTER TABLE "Notice" ADD CONSTRAINT "Notice_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260830230000_sms_events_notice_kind/migration.sql b/apps/api/prisma/migrations/20260830230000_sms_events_notice_kind/migration.sql new file mode 100644 index 0000000..ceff0a2 --- /dev/null +++ b/apps/api/prisma/migrations/20260830230000_sms_events_notice_kind/migration.sql @@ -0,0 +1,29 @@ +ALTER TABLE "Notice" ADD COLUMN IF NOT EXISTS "kind" TEXT NOT NULL DEFAULT 'ANNOUNCEMENT'; +CREATE INDEX IF NOT EXISTS "Notice_kind_idx" ON "Notice"("kind"); + +ALTER TABLE "SmsSendLog" ADD COLUMN IF NOT EXISTS "eventKey" TEXT; +CREATE INDEX IF NOT EXISTS "SmsSendLog_eventKey_idx" ON "SmsSendLog"("eventKey"); + +CREATE TABLE IF NOT EXISTS "SmsEventBind" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL DEFAULT '1', + "eventKey" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "templateCode" TEXT NOT NULL DEFAULT '', + "signName" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "SmsEventBind_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX IF NOT EXISTS "SmsEventBind_eventKey_key" ON "SmsEventBind"("eventKey"); + +CREATE TABLE IF NOT EXISTS "SmsOtp" ( + "id" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "codeHash" TEXT NOT NULL, + "purpose" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "SmsOtp_pkey" PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "SmsOtp_phone_purpose_idx" ON "SmsOtp"("phone", "purpose"); diff --git a/apps/api/prisma/migrations/20260831093000_project_type_amount/migration.sql b/apps/api/prisma/migrations/20260831093000_project_type_amount/migration.sql new file mode 100644 index 0000000..0677272 --- /dev/null +++ b/apps/api/prisma/migrations/20260831093000_project_type_amount/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "projectType" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "amount" DECIMAL(18,2) NOT NULL DEFAULT 0; diff --git a/apps/api/prisma/migrations/20260831095000_notice_audience/migration.sql b/apps/api/prisma/migrations/20260831095000_notice_audience/migration.sql new file mode 100644 index 0000000..37de3a2 --- /dev/null +++ b/apps/api/prisma/migrations/20260831095000_notice_audience/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "Notice" ADD COLUMN IF NOT EXISTS "audience" TEXT NOT NULL DEFAULT 'ALL'; +ALTER TABLE "Notice" ADD COLUMN IF NOT EXISTS "departmentIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; +ALTER TABLE "Notice" ADD COLUMN IF NOT EXISTS "userIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; diff --git a/apps/api/prisma/migrations/20260831102000_role_acl_custom/migration.sql b/apps/api/prisma/migrations/20260831102000_role_acl_custom/migration.sql new file mode 100644 index 0000000..b3367db --- /dev/null +++ b/apps/api/prisma/migrations/20260831102000_role_acl_custom/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Role" ADD COLUMN IF NOT EXISTS "aclCustom" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/api/prisma/migrations/20260831140000_push_device/migration.sql b/apps/api/prisma/migrations/20260831140000_push_device/migration.sql new file mode 100644 index 0000000..3edb3ad --- /dev/null +++ b/apps/api/prisma/migrations/20260831140000_push_device/migration.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS "PushDevice" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "vendor" TEXT NOT NULL, + "regId" TEXT NOT NULL, + "brand" TEXT, + "model" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "PushDevice_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "PushDevice_vendor_regId_key" ON "PushDevice"("vendor", "regId"); +CREATE INDEX IF NOT EXISTS "PushDevice_userId_vendor_idx" ON "PushDevice"("userId", "vendor"); + +ALTER TABLE "PushDevice" ADD CONSTRAINT "PushDevice_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260901090000_im_message_lifecycle/migration.sql b/apps/api/prisma/migrations/20260901090000_im_message_lifecycle/migration.sql new file mode 100644 index 0000000..2afef7b --- /dev/null +++ b/apps/api/prisma/migrations/20260901090000_im_message_lifecycle/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "ImMessage" ADD COLUMN IF NOT EXISTS "recalledAt" TIMESTAMP(3); +ALTER TABLE "ImMessage" ADD COLUMN IF NOT EXISTS "editedAt" TIMESTAMP(3); +CREATE INDEX IF NOT EXISTS "ImMessage_recalledAt_idx" ON "ImMessage"("recalledAt"); diff --git a/apps/api/prisma/migrations/20260901150000_bid_manual_number/migration.sql b/apps/api/prisma/migrations/20260901150000_bid_manual_number/migration.sql new file mode 100644 index 0000000..b82efd5 --- /dev/null +++ b/apps/api/prisma/migrations/20260901150000_bid_manual_number/migration.sql @@ -0,0 +1,3 @@ +-- 项目筛选编号是人工填写的公告编号,不是系统单号;允许重复公告/分包再次筛选。 +DROP INDEX IF EXISTS "BidCase_tenantId_bidNo_key"; +CREATE INDEX IF NOT EXISTS "BidCase_tenantId_bidNo_idx" ON "BidCase"("tenantId", "bidNo"); diff --git a/apps/api/prisma/migrations/20260902100000_im_message_seq/migration.sql b/apps/api/prisma/migrations/20260902100000_im_message_seq/migration.sql new file mode 100644 index 0000000..a7f3077 --- /dev/null +++ b/apps/api/prisma/migrations/20260902100000_im_message_seq/migration.sql @@ -0,0 +1,26 @@ +-- Phase 1.1: 会话级单调递增 seq,用于消息排序、去重与断线补拉 +ALTER TABLE "ImConversation" ADD COLUMN IF NOT EXISTS "nextSeq" INTEGER NOT NULL DEFAULT 1; + +ALTER TABLE "ImMessage" ADD COLUMN IF NOT EXISTS "seq" INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE "ImReadCursor" ADD COLUMN IF NOT EXISTS "lastReadSeq" INTEGER NOT NULL DEFAULT 0; + +-- 按 createdAt 回填历史 seq +WITH ranked AS ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY "conversationId" ORDER BY "createdAt", id) AS rn + FROM "ImMessage" +) +UPDATE "ImMessage" m SET seq = ranked.rn FROM ranked WHERE m.id = ranked.id; + +UPDATE "ImConversation" c +SET "nextSeq" = COALESCE(( + SELECT MAX(seq) + 1 FROM "ImMessage" WHERE "conversationId" = c.id +), 1); + +UPDATE "ImReadCursor" rc +SET "lastReadSeq" = COALESCE(( + SELECT MAX(seq) FROM "ImMessage" m + WHERE m."conversationId" = rc."conversationId" AND m."createdAt" <= rc."lastReadAt" +), 0); + +CREATE INDEX IF NOT EXISTS "ImMessage_conversationId_seq_idx" ON "ImMessage"("conversationId", "seq"); diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/apps/api/prisma/oa-json.ts b/apps/api/prisma/oa-json.ts new file mode 100644 index 0000000..337dbcc --- /dev/null +++ b/apps/api/prisma/oa-json.ts @@ -0,0 +1,23 @@ +import * as fs from 'fs'; + +/** OA 雪花 ID 超过 Number.MAX_SAFE_INTEGER,JSON.parse 会丢精度导致报销明细串单。 */ +export function parseOaJson>(file: string): T { + const text = fs.readFileSync(file, 'utf8'); + const safe = text.replace( + /"(id|reimbursement_id|project_id|employee_id|payer_id|created_by|updated_by|department_id|cc_user_id|offset_advance_id|current_approver_id|user_id|post_id|dept_id|apply_id|tender_id|contract_id)"\s*:\s*(-?\d+)/g, + '"$1":"$2"', + ); + return JSON.parse(safe) as T; +} + +export function mapExpenseLineKind(itemType: string, remark = '') { + const t = `${itemType}${remark}`; + if (t.includes('机票') || t.includes('航班')) return 'AIR'; + if (t.includes('高铁')) return 'HSR'; + if (t.includes('火车') || t.includes('卧铺')) return 'TRAIN'; + if (t.includes('出租') || t.includes('打车') || t.includes('交通') || t.includes('高速')) return 'TAXI'; + if (t.includes('住宿') || t.includes('酒店')) return 'HOTEL'; + if (t.includes('餐')) return 'MEAL'; + if (t.includes('办公') || t.includes('胶装')) return 'OFFICE'; + return 'OTHER'; +} diff --git a/apps/api/prisma/production-clean-orphan-contract-files-20260901.sql b/apps/api/prisma/production-clean-orphan-contract-files-20260901.sql new file mode 100644 index 0000000..8c77f1b --- /dev/null +++ b/apps/api/prisma/production-clean-orphan-contract-files-20260901.sql @@ -0,0 +1,38 @@ +\set ON_ERROR_STOP on + +BEGIN; + +DO $$ +DECLARE + orphan_count integer; + mirrored_count integer; +BEGIN + SELECT count(*) INTO orphan_count + FROM "FileAsset" file + WHERE file."bizType" LIKE 'CONTRACT%' + AND NOT EXISTS (SELECT 1 FROM "Contract" contract WHERE contract.id = file."bizId"); + + SELECT count(*) INTO mirrored_count + FROM "FileAsset" orphan + WHERE orphan."bizType" LIKE 'CONTRACT%' + AND NOT EXISTS (SELECT 1 FROM "Contract" contract WHERE contract.id = orphan."bizId") + AND EXISTS ( + SELECT 1 + FROM "FileAsset" current_file + JOIN "Contract" current_contract ON current_contract.id = current_file."bizId" + WHERE current_file."bizType" = orphan."bizType" + AND current_file."fileName" = orphan."fileName" + AND current_file.size = orphan.size + ); + + IF orphan_count <> 29 OR mirrored_count <> orphan_count THEN + RAISE EXCEPTION 'Unexpected orphan contract files: orphan %, mirrored %', + orphan_count, mirrored_count; + END IF; +END $$; + +DELETE FROM "FileAsset" file +WHERE file."bizType" LIKE 'CONTRACT%' + AND NOT EXISTS (SELECT 1 FROM "Contract" contract WHERE contract.id = file."bizId"); + +COMMIT; diff --git a/apps/api/prisma/production-deduplicate-20260901.sql b/apps/api/prisma/production-deduplicate-20260901.sql new file mode 100644 index 0000000..3ae37e6 --- /dev/null +++ b/apps/api/prisma/production-deduplicate-20260901.sql @@ -0,0 +1,111 @@ +\set ON_ERROR_STOP on + +BEGIN; + +CREATE TEMP TABLE expense_dedup_map ( + old_id text PRIMARY KEY, + new_id text NOT NULL UNIQUE +) ON COMMIT DROP; + +INSERT INTO expense_dedup_map (old_id, new_id) +SELECT old_claim.id, formal_claim.id +FROM "ExpenseClaim" old_claim +JOIN "ExpenseClaim" formal_claim + ON formal_claim."claimNo" ~ '^BX-[0-9]{8}-[0-9]{3}$' + AND formal_claim."applicantId" = old_claim."applicantId" + AND formal_claim.amount = old_claim.amount + AND formal_claim."projectId" IS NOT DISTINCT FROM old_claim."projectId" + AND formal_claim."bidCaseId" IS NOT DISTINCT FROM old_claim."bidCaseId" +WHERE old_claim."createdAt" >= TIMESTAMP '2026-08-29 11:00:30' + AND old_claim."createdAt" < TIMESTAMP '2026-08-29 11:00:31'; + +INSERT INTO expense_dedup_map (old_id, new_id) +SELECT old_claim.id, formal_claim.id +FROM "ExpenseClaim" old_claim +JOIN "ExpenseClaim" formal_claim + ON formal_claim."claimNo" = CASE old_claim."claimNo" + WHEN 'BX-2026-00043' THEN 'BX-20260617-004' + WHEN 'BX-2026-00044' THEN 'BX-20260617-005' + END +WHERE old_claim."claimNo" IN ('BX-2026-00043', 'BX-2026-00044'); + +DO $$ +BEGIN + IF (SELECT count(*) FROM expense_dedup_map) <> 83 THEN + RAISE EXCEPTION 'Expense dedup mapping is incomplete: expected 83, got %', + (SELECT count(*) FROM expense_dedup_map); + END IF; +END $$; + +-- FileAsset is intentionally reassigned before duplicate claims are removed. +UPDATE "FileAsset" file +SET "bizId" = mapping.new_id +FROM expense_dedup_map mapping +WHERE file."bizType" = 'EXPENSE' + AND file."bizId" = mapping.old_id; + +DELETE FROM "ApprovalTask" task +USING expense_dedup_map mapping +WHERE task."bizId" = mapping.old_id; + +DELETE FROM "TodoTask" task +USING expense_dedup_map mapping +WHERE task."bizId" = mapping.old_id; + +DELETE FROM "ExpenseClaim" claim +USING expense_dedup_map mapping +WHERE claim.id = mapping.old_id; + +CREATE TEMP TABLE loan_dedup_map ( + old_id text PRIMARY KEY, + new_id text NOT NULL UNIQUE, + new_status text NOT NULL +) ON COMMIT DROP; + +INSERT INTO loan_dedup_map (old_id, new_id, new_status) +SELECT old_loan.id, formal_loan.id, formal_loan.status +FROM "LoanRecord" old_loan +JOIN "LoanRecord" formal_loan + ON formal_loan."loanNo" ~ '^JZ-[0-9]{8}-[0-9]{3}$' + AND formal_loan."applicantId" = old_loan."applicantId" + AND formal_loan.amount = old_loan.amount + AND formal_loan."projectId" IS NOT DISTINCT FROM old_loan."projectId" + AND formal_loan.purpose = old_loan.purpose +WHERE old_loan."loanNo" ~ '^JK-2026-[0-9]{5}$' + AND old_loan."createdAt" >= TIMESTAMP '2026-08-29 11:00:30' + AND old_loan."createdAt" < TIMESTAMP '2026-08-29 11:00:31'; + +DO $$ +BEGIN + IF (SELECT count(*) FROM loan_dedup_map) <> 9 THEN + RAISE EXCEPTION 'Loan dedup mapping is incomplete: expected 9, got %', + (SELECT count(*) FROM loan_dedup_map); + END IF; +END $$; + +-- Keep approval tasks only when the surviving formal loan is still pending. +UPDATE "ApprovalTask" task +SET "bizId" = mapping.new_id +FROM loan_dedup_map mapping +WHERE task."bizId" = mapping.old_id + AND mapping.new_status = 'PENDING'; + +DELETE FROM "ApprovalTask" task +USING loan_dedup_map mapping +WHERE task."bizId" = mapping.old_id; + +DELETE FROM "TodoTask" task +USING loan_dedup_map mapping +WHERE task."bizId" = mapping.old_id; + +UPDATE "FileAsset" file +SET "bizId" = mapping.new_id +FROM loan_dedup_map mapping +WHERE file."bizType" = 'LOAN' + AND file."bizId" = mapping.old_id; + +DELETE FROM "LoanRecord" loan +USING loan_dedup_map mapping +WHERE loan.id = mapping.old_id; + +COMMIT; diff --git a/apps/api/prisma/production-restore-missing-expense-20260901.sql b/apps/api/prisma/production-restore-missing-expense-20260901.sql new file mode 100644 index 0000000..51ca3ac --- /dev/null +++ b/apps/api/prisma/production-restore-missing-expense-20260901.sql @@ -0,0 +1,61 @@ +\set ON_ERROR_STOP on + +BEGIN; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "ExpenseClaim" + WHERE "tenantId" = '1' AND "claimNo" = 'BX-20260809-002' + ) THEN + RAISE EXCEPTION 'Expense BX-20260809-002 already exists'; + END IF; +END $$; + +WITH restored AS ( + INSERT INTO "ExpenseClaim" ( + id, "tenantId", "claimNo", "applicantId", "costType", + "budgetCategory", "projectId", "expenseCategory", purpose, + amount, status, "submittedAt", "createdAt" + ) + SELECT + gen_random_uuid()::text, + '1', + 'BX-20260809-002', + applicant.id, + 'PROJECT', + 'TRAVEL', + project.id, + '办公', + '日常办公(江苏风影)', + 689.08, + 'DRAFT', + TIMESTAMP '2026-08-09 21:39:30', + TIMESTAMP '2026-08-09 21:39:30' + FROM "User" applicant + CROSS JOIN "Project" project + WHERE applicant."displayName" = '戴永康' + AND project."projectNo" = 'XM-20260609-002' + RETURNING id +) +INSERT INTO "ExpenseLine" ( + id, "tenantId", "claimId", kind, amount, remark, "sortNo", "createdAt" +) +SELECT gen_random_uuid()::text, '1', restored.id, line.kind, line.amount, + line.remark, line.sort_no, TIMESTAMP '2026-08-09 21:39:30' +FROM restored +CROSS JOIN ( + VALUES + ('OFFICE', 122.65::numeric, 'OA平台短信通知费用', 1), + ('OFFICE', 566.43::numeric, 'OA云服务器及流量费用', 2) +) AS line(kind, amount, remark, sort_no); + +DO $$ +BEGIN + IF (SELECT count(*) FROM "ExpenseClaim" WHERE "claimNo" = 'BX-20260809-002') <> 1 + OR (SELECT count(*) FROM "ExpenseLine" line JOIN "ExpenseClaim" claim ON claim.id = line."claimId" WHERE claim."claimNo" = 'BX-20260809-002') <> 2 THEN + RAISE EXCEPTION 'Expense BX-20260809-002 restore verification failed'; + END IF; +END $$; + +COMMIT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..1ff7a38 --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -0,0 +1,1421 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(uuid()) + tenantId String @default("1") + username String + passwordHash String + displayName String + status String @default("ACTIVE") + employeeId String? + mustChangePassword Boolean @default(true) + profileCompletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + employee Employee? @relation(fields: [employeeId], references: [id]) + roles UserRole[] + refreshTokens RefreshToken[] + todos TodoTask[] @relation("TodoAssignee") + approvals ApprovalTask[] @relation("ApprovalAssignee") + bidCreated BidCase[] @relation("BidCreator") + bidReviews BidReviewer[] + bidActions BidActionLog[] + bidAssignments BidAssignee[] + bidDocVersions BidDocVersion[] + contractsCreated Contract[] @relation("ContractCreator") + contractReviews ContractReviewer[] + contractActions ContractActionLog[] + expensesCreated ExpenseClaim[] @relation("ExpenseApplicant") + expenseDecided ExpenseClaim[] @relation("ExpenseDecider") + expensesPaid ExpenseClaim[] @relation("ExpensePayer") + loansCreated LoanRecord[] @relation("LoanApplicant") + loanDecided LoanRecord[] @relation("LoanDecider") + invoicesCreated InvoiceRecord[] @relation("InvoiceCreator") + timesheets Timesheet[] + assignedTasks ProjectTask[] @relation("TaskAssignee") + projectAcceptances ProjectAcceptance[] + sealApplied SealRequest[] @relation("SealApplicant") + sealDecided SealRequest[] @relation("SealDecider") + credentialBorrows CredentialBorrow[] + employmentEvents EmploymentEvent[] @relation("EventActor") + attendanceImports AttendanceMonth[] @relation("AttendanceImporter") + leaveDecided LeaveRequest[] @relation("LeaveDecider") + payrollRuns PayrollRun[] @relation("PayrollCreator") + workReports WorkReport[] + workReportsTo WorkReport[] @relation("ReportTo") + workReportsCc WorkReport[] @relation("ReportCc") + operationLogs OperationLog[] + purchases PurchaseRequest[] @relation("PurchaseApplicant") + purchaseDecided PurchaseRequest[] @relation("PurchaseDecider") + paymentsCreated PaymentRecord[] @relation("PaymentCreator") + paymentsReceived PaymentRecord[] @relation("PaymentPayee") + projectChanges ProjectChange[] @relation("ChangeActor") + assetsHeld AssetOccupancy[] @relation("AssetHolder") + laborContractsCreated LaborContract[] @relation("LaborContractCreator") + workAssigned WorkAssignment[] @relation("WorkAssigner") + workReceived WorkAssignment[] @relation("WorkAssignee") + assetsRegistered RegisteredAsset[] @relation("AssetRegistrar") + officeApplies OfficeApply[] @relation("OfficeApplicant") + officeApplyDecided OfficeApply[] @relation("OfficeDecider") + noticesCreated Notice[] @relation("NoticeAuthor") + pushDevices PushDevice[] + + @@unique([tenantId, username]) +} + +model PushDevice { + id String @id @default(uuid()) + userId String + vendor String + regId String + brand String? + model String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([vendor, regId]) + @@index([userId, vendor]) +} + +model RefreshToken { + id String @id @default(uuid()) + userId String + tokenHash String + clientKind String @default("web") + expiresAt DateTime + revokedAt DateTime? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, clientKind]) +} + +model AuthQrTicket { + id String @id @default(uuid()) + ticket String @unique + status String @default("pending") + userId String? + confirmCodeHash String? + confirmCodeIssuedAt DateTime? + confirmCodeAttempts Int @default(0) + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + @@index([status, expiresAt]) +} + +model Department { + id String @id @default(uuid()) + tenantId String @default("1") + parentId String? + name String + code String + deptType String + categoryCode String? + sortNo Int @default(0) + + parent Department? @relation("DeptTree", fields: [parentId], references: [id]) + children Department[] @relation("DeptTree") + employees Employee[] + projectTasks ProjectTask[] + eventsFrom EmploymentEvent[] @relation("EventFromDept") + eventsTo EmploymentEvent[] @relation("EventToDept") + + @@unique([tenantId, code]) +} + +model Employee { + id String @id @default(uuid()) + tenantId String @default("1") + name String + employeeNo String + departmentId String + title String? + restSchedule String @default("SINGLE") + employmentStatus String @default("REGULAR") + hiredAt DateTime? + internStartAt DateTime? + probationEndAt DateTime? + leftAt DateTime? + managerId String? + managerIds String? + gender String? + email String? + mobile String? + idNo String? + idType String? + education String? + birthday DateTime? + ethnicity String? + maritalStatus String? + hukouAddress String? + liveAddress String? + emergencyName String? + emergencyPhone String? + idIssuer String? + idValidFrom DateTime? + idValidTo DateTime? + personCategory String? + ccTo String? + shortMobile String? + workMobile String? + school String? + major String? + graduatedAt DateTime? + professionalTitle String? + certificates String? + companyName String? + jobRole String? + jobLevel String? + attendGroup String? + probationMonths Int? + contractStartAt DateTime? + contractEndAt DateTime? + recruitSource String? + landline String? + landlineExt String? + groupShortNo String? + socialNo String? + housingFundNo String? + familyNote String? + jobType String? + ccDaily String? + ccWeekly String? + ccMonthly String? + bankName String? + bankAccount String? + remark String? + baseSalary Decimal @default(0) @db.Decimal(18, 2) + postSalary Decimal @default(0) @db.Decimal(18, 2) + attendanceBonus Decimal @default(0) @db.Decimal(18, 2) + phoneSubsidy Decimal @default(0) @db.Decimal(18, 2) + transportSubsidy Decimal @default(0) @db.Decimal(18, 2) + mealSubsidy Decimal @default(0) @db.Decimal(18, 2) + probationSalary Decimal @default(0) @db.Decimal(18, 2) + perfBase Decimal @default(0) @db.Decimal(18, 2) + socialBase Decimal @default(0) @db.Decimal(18, 2) + compLeaveHours Decimal @default(0) @db.Decimal(10, 2) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + department Department @relation(fields: [departmentId], references: [id]) + manager Employee? @relation("EmpManager", fields: [managerId], references: [id]) + reports Employee[] @relation("EmpManager") + users User[] + events EmploymentEvent[] + attendance AttendanceMonth[] + punches AttendancePunch[] + leaves LeaveRequest[] + payrollItems PayrollItem[] + laborContracts LaborContract[] + + @@unique([tenantId, employeeNo]) + @@index([departmentId]) + @@index([managerId]) +} + +model LaborContract { + id String @id @default(uuid()) + tenantId String @default("1") + employeeId String + contractNo String? + startAt DateTime? + endAt DateTime? + remark String? + createdById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + createdBy User? @relation("LaborContractCreator", fields: [createdById], references: [id]) + + @@index([employeeId]) + @@index([tenantId, createdAt]) +} + +model EmploymentEvent { + id String @id @default(uuid()) + tenantId String @default("1") + employeeId String + kind String + fromStatus String? + toStatus String? + fromDeptId String? + toDeptId String? + effectiveAt DateTime + comment String? + actorId String? + createdAt DateTime @default(now()) + + employee Employee @relation(fields: [employeeId], references: [id]) + fromDept Department? @relation("EventFromDept", fields: [fromDeptId], references: [id]) + toDept Department? @relation("EventToDept", fields: [toDeptId], references: [id]) + actor User? @relation("EventActor", fields: [actorId], references: [id]) + + @@index([employeeId]) + @@index([effectiveAt]) +} + +model AttendanceMonth { + id String @id @default(uuid()) + tenantId String @default("1") + employeeId String + yearMonth String + workDays Decimal @default(0) @db.Decimal(6, 2) + leaveDays Decimal @default(0) @db.Decimal(6, 2) + sickDays Decimal @default(0) @db.Decimal(6, 2) + overtimeHours Decimal @default(0) @db.Decimal(6, 2) + compLeaveDays Decimal @default(0) @db.Decimal(6, 2) + remark String? + importedAt DateTime @default(now()) + importedById String? + + employee Employee @relation(fields: [employeeId], references: [id]) + importedBy User? @relation("AttendanceImporter", fields: [importedById], references: [id]) + + @@unique([employeeId, yearMonth]) + @@index([yearMonth]) +} + +model AttendancePunch { + id String @id @default(uuid()) + tenantId String @default("1") + employeeId String + kind String + punchedAt DateTime @default(now()) + remark String? + lat Decimal? @db.Decimal(10, 7) + lng Decimal? @db.Decimal(10, 7) + address String? + accuracy Float? + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + + @@index([employeeId, punchedAt]) +} + +model LeaveRequest { + id String @id @default(uuid()) + tenantId String @default("1") + employeeId String + kind String + startAt DateTime + endAt DateTime + days Decimal @db.Decimal(6, 2) + reason String + toDeptId String? + targetTitle String? + status String @default("PENDING") + comment String? + decidedById String? + decidedAt DateTime? + createdAt DateTime @default(now()) + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + decidedBy User? @relation("LeaveDecider", fields: [decidedById], references: [id]) + + @@index([employeeId]) + @@index([status]) +} + +model HrSetting { + id String @id @default("default") + tenantId String @default("1") @unique + idMask Boolean @default(true) + autoAccount Boolean @default(false) + probationMonths Int @default(3) + defaultRest String @default("SINGLE") + workStart String @default("09:00") + workEnd String @default("18:00") + updatedAt DateTime @updatedAt +} + +model AttendanceRule { + id String @id @default(uuid()) + tenantId String @default("1") + name String + restSchedule String + workStart String + workEnd String + lateGraceMinutes Int @default(0) + punchPlace String? + punchAddress String? + punchLat Decimal? @db.Decimal(10, 7) + punchLng Decimal? @db.Decimal(10, 7) + punchRadiusMeters Int @default(300) + enabled Boolean @default(true) + sortNo Int @default(0) + remark String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([tenantId, name]) +} + +model PayrollRun { + id String @id @default(uuid()) + tenantId String @default("1") + yearMonth String + status String @default("DRAFT") + createdById String? + createdAt DateTime @default(now()) + confirmedAt DateTime? + + createdBy User? @relation("PayrollCreator", fields: [createdById], references: [id]) + items PayrollItem[] + + @@unique([tenantId, yearMonth]) +} + +model PayrollItem { + id String @id @default(uuid()) + runId String + employeeId String + prorationFactor Decimal @default(1) @db.Decimal(8, 6) + scheduledDays Decimal @default(0) @db.Decimal(6, 2) + dailyRate Decimal @default(0) @db.Decimal(18, 2) + basePay Decimal @default(0) @db.Decimal(18, 2) + performancePay Decimal @default(0) @db.Decimal(18, 2) + performanceRatio Decimal @default(100) @db.Decimal(5, 2) + leaveDeduct Decimal @default(0) @db.Decimal(18, 2) + sickDeduct Decimal @default(0) @db.Decimal(18, 2) + socialPersonal Decimal @default(0) @db.Decimal(18, 2) + housingFund Decimal @default(0) @db.Decimal(18, 2) + tax Decimal @default(0) @db.Decimal(18, 2) + grossPay Decimal @default(0) @db.Decimal(18, 2) + netPay Decimal @default(0) @db.Decimal(18, 2) + overtimeHours Decimal @default(0) @db.Decimal(6, 2) + compLeaveUsedDays Decimal @default(0) @db.Decimal(6, 2) + compLeaveHours Decimal @default(0) @db.Decimal(10, 2) + + run PayrollRun @relation(fields: [runId], references: [id], onDelete: Cascade) + employee Employee @relation(fields: [employeeId], references: [id]) + + @@unique([runId, employeeId]) +} + +model Role { + id String @id @default(uuid()) + tenantId String @default("1") + name String + code String + description String @default("") + dataScope String @default("ALL") + aclCustom Boolean @default(false) + + permissions RolePermission[] + users UserRole[] + + @@unique([tenantId, code]) +} + +model Permission { + id String @id @default(uuid()) + parentId String? + name String + code String @unique + permType String + path String? + icon String? + sortNo Int @default(0) + visible Boolean @default(true) + + parent Permission? @relation("PermTree", fields: [parentId], references: [id]) + children Permission[] @relation("PermTree") + roles RolePermission[] +} + +model RolePermission { + roleId String + permissionId String + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade) + + @@id([roleId, permissionId]) +} + +model UserRole { + userId String + roleId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + + @@id([userId, roleId]) +} + +model DictItem { + id String @id @default(uuid()) + tenantId String @default("1") + dictType String + label String + value String + sortNo Int @default(0) + + @@unique([tenantId, dictType, value]) +} + +model NumberRule { + id String @id @default(uuid()) + tenantId String @default("1") + code String + name String + prefix String + padLength Int @default(5) + + @@unique([tenantId, code]) +} + +model SealItem { + id String @id @default(uuid()) + tenantId String @default("1") + name String + sealType String @default("公章") + keeper String @default("行政") + legalEntityId String? + status String @default("IN_STOCK") + createdAt DateTime @default(now()) + legalEntity LegalEntity? @relation(fields: [legalEntityId], references: [id]) +} + +model LegalEntity { + id String @id @default(uuid()) + tenantId String @default("1") + name String + shortName String + creditNo String? + isDefault Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + bidCases BidCase[] + contracts Contract[] + sealItems SealItem[] + assets RegisteredAsset[] + + @@unique([tenantId, name]) +} + +model BusinessParty { + id String @id @default(uuid()) + tenantId String @default("1") + name String + creditNo String? + level String @default("B") + riskFlag Boolean @default(false) + remark String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + contacts PartyContact[] + bidCases BidCase[] + + @@unique([tenantId, name]) +} + +model PartyContact { + id String @id @default(uuid()) + tenantId String @default("1") + partyId String + name String + phone String? + title String? + party BusinessParty @relation(fields: [partyId], references: [id], onDelete: Cascade) +} + +model Qualification { + id String @id @default(uuid()) + tenantId String @default("1") + name String + code String + expiresAt DateTime? + keeper String? + createdAt DateTime @default(now()) + borrows CredentialBorrow[] +} + +model SealRequest { + id String @id @default(uuid()) + tenantId String @default("1") + sealType String + reason String + bidCaseId String? + contractId String? + applicantId String? + takeOut Boolean @default(false) + returnAt DateTime? + returnedAt DateTime? + decidedAt DateTime? + decidedById String? + rejectComment String? + status String @default("PENDING") + createdAt DateTime @default(now()) + contract Contract? @relation(fields: [contractId], references: [id]) + bidCase BidCase? @relation(fields: [bidCaseId], references: [id]) + applicant User? @relation("SealApplicant", fields: [applicantId], references: [id]) + decidedBy User? @relation("SealDecider", fields: [decidedById], references: [id]) +} + +model CredentialBorrow { + id String @id @default(uuid()) + tenantId String @default("1") + itemName String + borrower String + borrowerId String? + qualificationId String? + bidCaseId String? + dueAt DateTime? + returnedAt DateTime? + remark String? + status String @default("BORROWED") + createdAt DateTime @default(now()) + borrowerUser User? @relation(fields: [borrowerId], references: [id]) + qualification Qualification? @relation(fields: [qualificationId], references: [id]) + bidCase BidCase? @relation(fields: [bidCaseId], references: [id]) +} + +model BidCase { + id String @id @default(uuid()) + tenantId String @default("1") + bidNo String + name String + externalNo String? + projectType String + sourceType String @default("FLOW") + source String? + qualificationNeed String? + legalEntityId String? + tenderMethod String? + applyStartAt DateTime? + applyEndAt DateTime? + applyMethod String? + openAt DateTime? + openMethod String? + openPlace String? + securityLevel String @default("INTERNAL") + projectNature String? + partyId String? + contactName String? + contactPhone String? + bondMethod String? + bondStatus String? + bondPaidAt DateTime? + delivererName String? + priceCap String? + quoteAmount String? + summary String? + remark String? + status String @default("TECH_INITIAL") + nasPath String? + fetchedAt DateTime? + reviewCycle Int @default(1) + printCost Decimal? @db.Decimal(18, 2) + printNote String? + printedAt DateTime? + costDisposition String? + resultNote String? + winnerName String? + winnerAmount Decimal? @db.Decimal(18, 2) + winnerNote String? + openedResultAt DateTime? + needSurvey Boolean @default(false) + createdById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + legalEntity LegalEntity? @relation(fields: [legalEntityId], references: [id]) + party BusinessParty? @relation(fields: [partyId], references: [id]) + createdBy User? @relation("BidCreator", fields: [createdById], references: [id]) + reviewers BidReviewer[] + actionLogs BidActionLog[] + assignees BidAssignee[] + versions BidDocVersion[] + expenses ExpenseClaim[] + loans LoanRecord[] + contracts Contract[] + bonds BondRecord[] + sealRequests SealRequest[] + credentialBorrows CredentialBorrow[] + + @@index([tenantId, bidNo]) +} + +model BidReviewer { + id String @id @default(uuid()) + bidCaseId String + userId String + stage String + cycle Int @default(1) + result String @default("PENDING") + comment String? + decidedAt DateTime? + bidCase BidCase @relation(fields: [bidCaseId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) + + @@unique([bidCaseId, userId, stage, cycle]) + @@index([bidCaseId]) +} + +model BidAssignee { + id String @id @default(uuid()) + bidCaseId String + userId String + role String + createdAt DateTime @default(now()) + bidCase BidCase @relation(fields: [bidCaseId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) + + @@unique([bidCaseId, userId, role]) + @@index([bidCaseId]) +} + +model BidDocVersion { + id String @id @default(uuid()) + bidCaseId String + versionNo Int + nasPath String + note String? + submittedById String + createdAt DateTime @default(now()) + bidCase BidCase @relation(fields: [bidCaseId], references: [id], onDelete: Cascade) + submittedBy User @relation(fields: [submittedById], references: [id]) + + @@unique([bidCaseId, versionNo]) + @@index([bidCaseId]) +} + +model BidActionLog { + id String @id @default(uuid()) + bidCaseId String + actorId String + action String + fromStatus String? + toStatus String? + comment String? + createdAt DateTime @default(now()) + bidCase BidCase @relation(fields: [bidCaseId], references: [id], onDelete: Cascade) + actor User @relation(fields: [actorId], references: [id]) + + @@index([bidCaseId]) +} + +model TodoTask { + id String @id @default(uuid()) + tenantId String @default("1") + title String + bizType String + bizId String? + assigneeId String + dueAt DateTime? + status String @default("OPEN") + createdAt DateTime @default(now()) + assignee User @relation("TodoAssignee", fields: [assigneeId], references: [id]) +} + +model ApprovalTask { + id String @id @default(uuid()) + tenantId String @default("1") + title String + bizType String + bizId String? + assigneeId String + status String @default("PENDING") + createdAt DateTime @default(now()) + assignee User @relation("ApprovalAssignee", fields: [assigneeId], references: [id]) +} + +model WorkReport { + id String @id @default(uuid()) + tenantId String @default("1") + authorId String + toUserId String? + ccUserId String? + period String + periodType String @default("DAY") + periodStart DateTime? + hours Decimal @default(0) @db.Decimal(8, 2) + content String @default("") + status String @default("DRAFT") + submittedAt DateTime? + createdAt DateTime @default(now()) + author User @relation(fields: [authorId], references: [id]) + toUser User? @relation("ReportTo", fields: [toUserId], references: [id]) + ccUser User? @relation("ReportCc", fields: [ccUserId], references: [id]) + + @@index([authorId]) + @@index([toUserId]) + @@index([ccUserId]) +} + +model Contract { + id String @id @default(uuid()) + tenantId String @default("1") + contractNo String? + name String + kind String @default("OTHER") + bidCaseId String? + legalEntityId String? + amount Decimal @default(0) @db.Decimal(18, 2) + status String @default("DRAFT") + partyA String? + partyB String? + nasPath String? + remark String? + signedAt DateTime? + submittedAt DateTime? + approvedAt DateTime? + createdById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + bidCase BidCase? @relation(fields: [bidCaseId], references: [id]) + legalEntity LegalEntity? @relation(fields: [legalEntityId], references: [id]) + createdBy User? @relation("ContractCreator", fields: [createdById], references: [id]) + projects Project[] + milestones PaymentMilestone[] + reviewers ContractReviewer[] + actionLogs ContractActionLog[] + sealRequests SealRequest[] + invoices InvoiceRecord[] + bonds BondRecord[] + + @@unique([tenantId, contractNo]) +} + +model ContractReviewer { + id String @id @default(uuid()) + contractId String + userId String + result String @default("PENDING") + comment String? + decidedAt DateTime? + contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) + + @@unique([contractId, userId]) + @@index([contractId]) +} + +model ContractActionLog { + id String @id @default(uuid()) + contractId String + actorId String + action String + fromStatus String? + toStatus String? + comment String? + createdAt DateTime @default(now()) + contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade) + actor User @relation(fields: [actorId], references: [id]) + + @@index([contractId]) +} + +model PaymentMilestone { + id String @id @default(uuid()) + contractId String + name String + ratio Decimal @db.Decimal(5, 2) + dueAt DateTime? + trigger String? + contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade) + invoice InvoiceRecord? +} + +model Project { + id String @id @default(uuid()) + tenantId String @default("1") + projectNo String + name String + partyName String? + contractId String? + projectType String @default("") + amount Decimal @default(0) @db.Decimal(18, 2) + status String @default("ACTIVE") + presalesCost Decimal @default(0) @db.Decimal(18, 2) + hourRate Decimal @default(200) @db.Decimal(18, 2) + internalAcceptedAt DateTime? + customerAcceptedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + contract Contract? @relation(fields: [contractId], references: [id]) + tasks ProjectTask[] + timesheets Timesheet[] + expenses ExpenseClaim[] + loans LoanRecord[] + bonds BondRecord[] + budgets ProjectBudget[] + acceptances ProjectAcceptance[] + acceptMaterials ProjectAcceptMaterial[] + purchases PurchaseRequest[] + changes ProjectChange[] + assets AssetOccupancy[] + officeApplies OfficeApply[] + + @@unique([tenantId, projectNo]) +} + +model ProjectBudget { + id String @id @default(uuid()) + projectId String + category String + amount Decimal @db.Decimal(18, 2) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, category]) +} + +model ProjectAcceptance { + id String @id @default(uuid()) + projectId String + kind String + result String + comment String? + actorId String + decidedAt DateTime @default(now()) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + actor User @relation(fields: [actorId], references: [id]) + + @@index([projectId]) +} + +model ProjectAcceptMaterial { + id String @id @default(uuid()) + projectId String + name String + remark String? + required Boolean @default(true) + sortNo Int @default(0) + createdAt DateTime @default(now()) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId]) +} + +model ProjectTask { + id String @id @default(uuid()) + projectId String + name String + department String? + departmentId String? + assigneeId String? + sortNo Int @default(0) + dueAt DateTime? + status String @default("OPEN") + createdAt DateTime @default(now()) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + dept Department? @relation(fields: [departmentId], references: [id]) + assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id]) + timesheets Timesheet[] + + @@index([projectId]) +} + +model Timesheet { + id String @id @default(uuid()) + tenantId String @default("1") + projectId String + taskId String? + userId String + workDate DateTime + hours Decimal @db.Decimal(6, 2) + costAmount Decimal @default(0) @db.Decimal(18, 2) + note String? + createdAt DateTime @default(now()) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + task ProjectTask? @relation(fields: [taskId], references: [id], onDelete: SetNull) + user User @relation(fields: [userId], references: [id]) + + @@index([projectId]) + @@index([userId]) +} + +model ExpenseClaim { + id String @id @default(uuid()) + tenantId String @default("1") + claimNo String + applicantId String + costType String + budgetCategory String? + bidCaseId String? + projectId String? + loanId String? + expenseCategory String? + purpose String? + amount Decimal @default(0) @db.Decimal(18, 2) + status String @default("DRAFT") + remark String? + submittedAt DateTime? + decidedAt DateTime? + decidedById String? + rejectComment String? + prepaid Boolean @default(false) + payerId String? + createdAt DateTime @default(now()) + bidCase BidCase? @relation(fields: [bidCaseId], references: [id]) + project Project? @relation(fields: [projectId], references: [id]) + loan LoanRecord? @relation(fields: [loanId], references: [id]) + applicant User @relation("ExpenseApplicant", fields: [applicantId], references: [id]) + decidedBy User? @relation("ExpenseDecider", fields: [decidedById], references: [id]) + payer User? @relation("ExpensePayer", fields: [payerId], references: [id]) + lines ExpenseLine[] + + @@unique([tenantId, claimNo]) + @@index([applicantId]) + @@index([loanId]) + @@index([payerId]) +} + +model ExpenseLine { + id String @id @default(uuid()) + tenantId String @default("1") + claimId String + kind String + amount Decimal @db.Decimal(18, 2) + invoiceNo String? + occurAt DateTime? + remark String? + sortNo Int @default(0) + createdAt DateTime @default(now()) + claim ExpenseClaim @relation(fields: [claimId], references: [id], onDelete: Cascade) + + @@index([claimId]) +} + +model LoanRecord { + id String @id @default(uuid()) + tenantId String @default("1") + loanNo String + applicantId String + costType String + bidCaseId String? + projectId String? + amount Decimal @db.Decimal(18, 2) + offsetAmount Decimal @default(0) @db.Decimal(18, 2) + purpose String + status String @default("DRAFT") + remark String? + submittedAt DateTime? + decidedAt DateTime? + decidedById String? + createdAt DateTime @default(now()) + bidCase BidCase? @relation(fields: [bidCaseId], references: [id]) + project Project? @relation(fields: [projectId], references: [id]) + applicant User @relation("LoanApplicant", fields: [applicantId], references: [id]) + decidedBy User? @relation("LoanDecider", fields: [decidedById], references: [id]) + expenses ExpenseClaim[] + + @@unique([tenantId, loanNo]) + @@index([applicantId]) +} + +model BondRecord { + id String @id @default(uuid()) + tenantId String @default("1") + bondNo String + bidCaseId String? + projectId String? + contractId String? + bondType String + amount Decimal @db.Decimal(18, 2) + paidAt DateTime? + dueBackAt DateTime? + returnedAt DateTime? + status String @default("UNPAID") + remark String? + createdAt DateTime @default(now()) + bidCase BidCase? @relation(fields: [bidCaseId], references: [id], onDelete: SetNull) + project Project? @relation(fields: [projectId], references: [id]) + contract Contract? @relation(fields: [contractId], references: [id]) + + @@unique([tenantId, bondNo]) +} + +model InvoiceRecord { + id String @id @default(uuid()) + tenantId String @default("1") + invoiceNo String? + contractId String + milestoneId String @unique + amount Decimal @db.Decimal(18, 2) + status String @default("TO_ISSUE") + dueAt DateTime? + issuedAt DateTime? + receivedAt DateTime? + remark String? + createdById String? + createdAt DateTime @default(now()) + contract Contract @relation(fields: [contractId], references: [id]) + milestone PaymentMilestone @relation(fields: [milestoneId], references: [id]) + createdBy User? @relation("InvoiceCreator", fields: [createdById], references: [id]) +} + +model FileAsset { + id String @id @default(uuid()) + tenantId String @default("1") + bizType String + bizId String + fileName String + mimeType String + size Int + storageKey String + uploadedById String + createdAt DateTime @default(now()) + + @@index([bizType, bizId]) + @@index([uploadedById]) +} + +model PurchaseRequest { + id String @id @default(uuid()) + tenantId String @default("1") + title String + projectId String? + amount Decimal @default(0) @db.Decimal(18, 2) + status String @default("DRAFT") + remark String? + applicantId String? + decidedById String? + decidedAt DateTime? + createdAt DateTime @default(now()) + project Project? @relation(fields: [projectId], references: [id]) + applicant User? @relation("PurchaseApplicant", fields: [applicantId], references: [id]) + decidedBy User? @relation("PurchaseDecider", fields: [decidedById], references: [id]) + officeApplies OfficeApply[] +} + +model AssetOccupancy { + id String @id @default(uuid()) + tenantId String @default("1") + assetName String + holder String + holderId String? + projectId String? + registeredAssetId String? + status String @default("IN_USE") + dueAt DateTime? + returnedAt DateTime? + createdAt DateTime @default(now()) + project Project? @relation(fields: [projectId], references: [id]) + holderUser User? @relation("AssetHolder", fields: [holderId], references: [id]) + registeredAsset RegisteredAsset? @relation(fields: [registeredAssetId], references: [id]) + officeApplies OfficeApply[] @relation("ApplyOccupancy") + + @@index([registeredAssetId]) +} + +model ProjectChange { + id String @id @default(uuid()) + tenantId String @default("1") + projectId String + summary String + amountDelta Decimal @default(0) @db.Decimal(18, 2) + status String @default("PENDING") + comment String? + actorId String? + createdAt DateTime @default(now()) + decidedAt DateTime? + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + actor User? @relation("ChangeActor", fields: [actorId], references: [id]) + + @@index([projectId]) +} + +model PaymentRecord { + id String @id @default(uuid()) + tenantId String @default("1") + payNo String + kind String + refId String? + amount Decimal @db.Decimal(18, 2) + status String @default("TODO") + remark String? + paidAt DateTime? + createdById String? + payeeUserId String? + payeeName String? + payeeKind String? + bankName String? + bankAccount String? + payChannel String @default("MANUAL") + bankStatus String @default("NONE") + bankTxnNo String? + bankPayload String? + createdAt DateTime @default(now()) + createdBy User? @relation("PaymentCreator", fields: [createdById], references: [id]) + payeeUser User? @relation("PaymentPayee", fields: [payeeUserId], references: [id]) + + @@unique([tenantId, payNo]) + @@index([refId]) + @@index([payeeUserId]) +} + +model OperationLog { + id String @id @default(uuid()) + tenantId String @default("1") + userId String? + username String? + action String + path String + detail String? + success Boolean @default(true) + requestId String? + ip String? + createdAt DateTime @default(now()) + user User? @relation(fields: [userId], references: [id]) + + @@index([createdAt]) + @@index([action]) +} + +model ImConversation { + id String @id @default(uuid()) + tenantId String @default("1") + type String @default("direct") + name String @default("") + ownerId String? + userAId String? + userBId String? + lastText String @default("") + lastAt DateTime @default(now()) + nextSeq Int @default(1) + createdAt DateTime @default(now()) + + @@unique([userAId, userBId]) + @@index([lastAt]) + @@index([type, lastAt]) +} + +model ImConversationMember { + id String @id @default(uuid()) + conversationId String + userId String + role String @default("member") + joinedAt DateTime @default(now()) + leftAt DateTime? + + @@unique([conversationId, userId]) + @@index([userId]) +} + +model ImMessage { + id String @id @default(uuid()) + tenantId String @default("1") + conversationId String + seq Int @default(0) + fromUserId String + toUserId String @default("") + kind String @default("chat") + contentType String @default("text") + body String + meta String @default("") + fingerprint String + createdAt DateTime @default(now()) + + @@unique([fingerprint]) + @@index([conversationId, createdAt]) + @@index([conversationId, seq]) + @@index([toUserId, createdAt]) +} + +model ImReadCursor { + id String @id @default(uuid()) + userId String + conversationId String + lastReadAt DateTime @default(now()) + lastReadSeq Int @default(0) + + @@unique([userId, conversationId]) +} + +model ImMessageReceipt { + id String @id @default(uuid()) + messageId String + userId String + readAt DateTime @default(now()) + + @@unique([messageId, userId]) + @@index([messageId]) +} + +model ImCall { + id String @id @default(uuid()) + tenantId String @default("1") + conversationId String? + kind String + status String @default("ringing") + initiatorId String + title String @default("") + createdAt DateTime @default(now()) + endedAt DateTime? + + @@index([status, createdAt]) +} + +model ImCallMember { + id String @id @default(uuid()) + callId String + userId String + status String @default("invited") + joinedAt DateTime? + leftAt DateTime? + + @@unique([callId, userId]) +} + +model ImCallSignal { + id String @id @default(uuid()) + callId String + fromUserId String + toUserId String + kind String + payload String + createdAt DateTime @default(now()) + + @@index([callId, createdAt]) + @@index([toUserId, createdAt]) +} + +model SmsSendLog { + id String @id @default(uuid()) + tenantId String @default("1") + phones String + signName String + templateCode String + templateParam String? + eventKey String? + bizId String? + code String + message String? + createdById String? + createdAt DateTime @default(now()) + + @@index([createdAt]) + @@index([eventKey]) +} + +model SmsEventBind { + id String @id @default(uuid()) + tenantId String @default("1") + eventKey String @unique + enabled Boolean @default(true) + templateCode String @default("") + signName String? + updatedAt DateTime @updatedAt +} + +model SmsOtp { + id String @id @default(uuid()) + phone String + codeHash String + purpose String + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + @@index([phone, purpose]) +} + +model WorkAssignment { + id String @id @default(uuid()) + tenantId String @default("1") + title String + content String @default("") + assigneeId String + dueAt DateTime? + status String @default("OPEN") + createdById String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + assignee User @relation("WorkAssignee", fields: [assigneeId], references: [id]) + createdBy User @relation("WorkAssigner", fields: [createdById], references: [id]) + + @@index([assigneeId]) + @@index([createdById]) +} + +model RegisteredAsset { + id String @id @default(uuid()) + tenantId String @default("1") + kind String + name String + code String? + spec String? + keeper String? + location String? + amount Decimal? @db.Decimal(18, 2) + remark String? + status String @default("ACTIVE") + usage String @default("GENERAL") + legalEntityId String? + createdById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + legalEntity LegalEntity? @relation(fields: [legalEntityId], references: [id]) + createdBy User? @relation("AssetRegistrar", fields: [createdById], references: [id]) + occupancies AssetOccupancy[] + officeApplies OfficeApply[] + inboundFrom OfficeApply[] @relation("ApplyInbound") + + @@index([kind]) + @@index([legalEntityId]) +} + +model OfficeApply { + id String @id @default(uuid()) + tenantId String @default("1") + kind String + title String + reason String? + status String @default("PENDING") + assetKind String? + registeredAssetId String? + amount Decimal? @db.Decimal(18, 2) + destination String? + startAt DateTime? + endAt DateTime? + projectId String? + purchaseId String? + occupancyId String? + inboundAssetId String? + applicantId String + decidedById String? + decidedAt DateTime? + remark String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + applicant User @relation("OfficeApplicant", fields: [applicantId], references: [id]) + decidedBy User? @relation("OfficeDecider", fields: [decidedById], references: [id]) + registeredAsset RegisteredAsset? @relation(fields: [registeredAssetId], references: [id]) + project Project? @relation(fields: [projectId], references: [id]) + purchase PurchaseRequest? @relation(fields: [purchaseId], references: [id]) + occupancy AssetOccupancy? @relation("ApplyOccupancy", fields: [occupancyId], references: [id]) + inboundAsset RegisteredAsset? @relation("ApplyInbound", fields: [inboundAssetId], references: [id]) + + @@index([applicantId]) + @@index([status]) + @@index([kind]) +} + +model Notice { + id String @id @default(uuid()) + tenantId String @default("1") + title String + content String + status String @default("PUBLISHED") + kind String @default("ANNOUNCEMENT") + audience String @default("ALL") + departmentIds String[] @default([]) + userIds String[] @default([]) + createdById String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdBy User @relation("NoticeAuthor", fields: [createdById], references: [id]) + + @@index([status]) + @@index([kind]) + @@index([createdAt]) +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts new file mode 100644 index 0000000..830e2ac --- /dev/null +++ b/apps/api/prisma/seed.ts @@ -0,0 +1,383 @@ +import { PrismaClient } from '@prisma/client'; +import * as argon2 from 'argon2'; +import { MENUS, type MenuNode } from './menus'; + +const prisma = new PrismaClient(); + +async function main() { + const passwordHash = await argon2.hash('Demo@123456'); + const adminHash = await argon2.hash('Admin@123456'); + + await prisma.$transaction(async (tx) => { + await tx.operationLog.deleteMany(); + await tx.paymentRecord.deleteMany(); + await tx.projectChange.deleteMany(); + await tx.sealItem.deleteMany(); + await tx.numberRule.deleteMany(); + await tx.todoTask.deleteMany(); + await tx.approvalTask.deleteMany(); + await tx.workReport.deleteMany(); + await tx.payrollItem.deleteMany(); + await tx.payrollRun.deleteMany(); + await tx.attendanceMonth.deleteMany(); + await tx.employmentEvent.deleteMany(); + await tx.expenseClaim.deleteMany(); + await tx.loanRecord.deleteMany(); + await tx.bondRecord.deleteMany(); + await tx.invoiceRecord.deleteMany(); + await tx.timesheet.deleteMany(); + await tx.projectAcceptance.deleteMany(); + await tx.projectBudget.deleteMany(); + await tx.projectTask.deleteMany(); + await tx.purchaseRequest.deleteMany(); + await tx.assetOccupancy.deleteMany(); + await tx.contractReviewer.deleteMany(); + await tx.contractActionLog.deleteMany(); + await tx.paymentMilestone.deleteMany(); + await tx.project.deleteMany(); + await tx.sealRequest.deleteMany(); + await tx.contract.deleteMany(); + await tx.bidDocVersion.deleteMany(); + await tx.bidAssignee.deleteMany(); + await tx.bidActionLog.deleteMany(); + await tx.bidReviewer.deleteMany(); + await tx.credentialBorrow.deleteMany(); + await tx.bidCase.deleteMany(); + await tx.partyContact.deleteMany(); + await tx.qualification.deleteMany(); + await tx.userRole.deleteMany(); + await tx.rolePermission.deleteMany(); + await tx.refreshToken.deleteMany(); + await tx.user.deleteMany(); + await tx.employee.deleteMany(); + await tx.department.deleteMany(); + await tx.permission.deleteMany(); + await tx.role.deleteMany(); + await tx.dictItem.deleteMany(); + await tx.attendanceRule.deleteMany(); + await tx.hrSetting.deleteMany(); + await tx.legalEntity.deleteMany(); + await tx.businessParty.deleteMany(); + + const root = await tx.department.create({ + data: { + name: '演示科技有限公司', + code: 'ROOT', + deptType: 'ADMIN', + sortNo: 0, + }, + }); + const depts = await Promise.all( + [ + ['商务部', 'BIZ', 'BUSINESS', null, 10], + ['三维部门', 'TECH_3D', 'TECH', 'CAT_3D', 20], + ['摄制部门', 'TECH_FILM', 'TECH', 'CAT_FILM', 30], + ['物资部门', 'TECH_MATERIAL', 'TECH', 'CAT_MATERIAL', 40], + ['软件研发部门', 'TECH_SOFTWARE', 'TECH', 'CAT_SOFTWARE', 50], + ['财务部', 'FIN', 'FINANCE', null, 60], + ['人事行政', 'HR', 'HR', null, 70], + ].map(([name, code, deptType, categoryCode, sortNo]) => + tx.department.create({ + data: { + parentId: root.id, + name: name as string, + code: code as string, + deptType: deptType as string, + categoryCode: categoryCode as string | null, + sortNo: sortNo as number, + }, + }), + ), + ); + const byCode = Object.fromEntries(depts.map((d) => [d.code, d])); + + const permIds: Record = {}; + const writeMenus = async (nodes: MenuNode[], parentId: string | null, root = true) => { + for (let i = 0; i < nodes.length; i++) { + const menu = nodes[i]; + const row = await tx.permission.create({ + data: { + parentId, + name: menu.name, + code: menu.code, + permType: 'MENU', + path: menu.path, + icon: menu.icon, + sortNo: root ? (i + 1) * 10 : i + 1, + }, + }); + permIds[menu.code] = row.id; + if (menu.children?.length) await writeMenus(menu.children, row.id, false); + } + }; + await writeMenus(MENUS, null); + + const roles = { + admin: await tx.role.create({ data: { name: '系统管理员', code: 'admin', dataScope: 'ALL' } }), + owner: await tx.role.create({ data: { name: '企业负责人', code: 'owner', dataScope: 'ALL' } }), + biz_director: await tx.role.create({ data: { name: '商务总监', code: 'biz_director', dataScope: 'DEPT' } }), + biz_staff: await tx.role.create({ data: { name: '商务专员', code: 'biz_staff', dataScope: 'SELF' } }), + tech_director: await tx.role.create({ data: { name: '技术总监', code: 'tech_director', dataScope: 'DEPT' } }), + finance: await tx.role.create({ data: { name: '财务人员', code: 'finance', dataScope: 'ALL' } }), + hr: await tx.role.create({ data: { name: '人事', code: 'hr', dataScope: 'ALL' } }), + employee: await tx.role.create({ data: { name: '员工', code: 'employee', dataScope: 'SELF' } }), + }; + + const allPerms = await tx.permission.findMany(); + const grant = async (roleId: string, codes: string[]) => { + const selected = + codes[0] === '*' + ? allPerms + : allPerms.filter((p) => codes.some((c) => p.code === c || p.code.startsWith(c))); + await tx.rolePermission.createMany({ + data: selected.map((p) => ({ roleId, permissionId: p.id })), + skipDuplicates: true, + }); + }; + await grant(roles.admin.id, ['*']); + await grant(roles.owner.id, ['*']); + await grant(roles.biz_director.id, ['office', 'party', 'seal', 'bid', 'contract', 'finance', 'report', 'hr:performance']); + await grant(roles.biz_staff.id, ['office', 'party', 'seal', 'bid', 'contract']); + await grant(roles.tech_director.id, ['office', 'bid', 'project', 'report', 'hr:performance']); + await grant(roles.finance.id, ['office', 'finance', 'contract', 'report', 'bid:result']); + await grant(roles.hr.id, ['office', 'hr', 'system:hr', 'system:org', 'system:attendance-rules']); + await grant(roles.employee.id, ['office', 'project:timesheets']); + + const mkEmp = ( + name: string, + no: string, + deptCode: string, + title: string, + rest: string, + baseSalary: number, + perfBase: number, + socialBase: number, + ) => + tx.employee.create({ + data: { + name, + employeeNo: no, + departmentId: byCode[deptCode].id, + title, + restSchedule: rest, + employmentStatus: 'REGULAR', + hiredAt: new Date('2022-01-01'), + baseSalary, + perfBase, + socialBase, + }, + }); + + const emps = { + admin: await mkEmp('系统管理员', 'E000', 'HR', '管理员', 'DOUBLE', 8000, 0, 8000), + owner: await mkEmp('陈总', 'E001', 'BIZ', '企业负责人', 'SINGLE', 25000, 10000, 20000), + bizdir: await mkEmp('王商务', 'E010', 'BIZ', '商务总监', 'SINGLE', 18000, 8000, 18000), + bizstaff: await mkEmp('李专员', 'E011', 'BIZ', '商务专员', 'SINGLE', 9000, 2500, 9000), + dir3d: await mkEmp('赵三维', 'E020', 'TECH_3D', '三维总监', 'SINGLE', 15000, 5000, 15000), + dirfilm: await mkEmp('孙摄制', 'E021', 'TECH_FILM', '摄制总监', 'SINGLE', 15000, 5000, 15000), + dirmaterial: await mkEmp('周物资', 'E022', 'TECH_MATERIAL', '物资总监', 'SINGLE', 15000, 5000, 15000), + dirsoft: await mkEmp('吴软件', 'E023', 'TECH_SOFTWARE', '软件总监', 'DOUBLE', 15000, 5000, 15000), + finance: await mkEmp('郑财务', 'E030', 'FIN', '会计', 'SINGLE', 10000, 2000, 10000), + hr: await mkEmp('冯人事', 'E040', 'HR', '人事专员', 'SINGLE', 9000, 2000, 9000), + employee: await mkEmp('钱员工', 'E100', 'TECH_SOFTWARE', '工程师', 'DOUBLE', 10000, 3000, 10000), + }; + + const softpm = await mkEmp('软件项目经理', 'E101', 'TECH_SOFTWARE', '项目经理', 'DOUBLE', 12000, 4000, 12000); + await tx.employee.update({ + where: { id: emps.employee.id }, + data: { managerId: softpm.id }, + }); + await tx.employee.update({ + where: { id: softpm.id }, + data: { managerId: emps.dirsoft.id }, + }); + + const mkUser = async ( + username: string, + hash: string, + emp: { id: string; name: string }, + roleId: string, + ) => { + const u = await tx.user.create({ + data: { + username, + passwordHash: hash, + displayName: emp.name, + employeeId: emp.id, + }, + }); + await tx.userRole.create({ data: { userId: u.id, roleId } }); + return u; + }; + + const admin = await mkUser('admin', adminHash, emps.admin, roles.admin.id); + await mkUser('owner', passwordHash, emps.owner, roles.owner.id); + await mkUser('bizdir', passwordHash, emps.bizdir, roles.biz_director.id); + await mkUser('bizstaff', passwordHash, emps.bizstaff, roles.biz_staff.id); + const dirsoft = await mkUser('dirsoft', passwordHash, emps.dirsoft, roles.tech_director.id); + const dir3d = await mkUser('dir3d', passwordHash, emps.dir3d, roles.tech_director.id); + await mkUser('dirfilm', passwordHash, emps.dirfilm, roles.tech_director.id); + await mkUser('dirmaterial', passwordHash, emps.dirmaterial, roles.tech_director.id); + await mkUser('finance', passwordHash, emps.finance, roles.finance.id); + await mkUser('hr', passwordHash, emps.hr, roles.hr.id); + await mkUser('employee', passwordHash, emps.employee, roles.employee.id); + await mkUser('softpm', passwordHash, softpm, roles.employee.id); + + const demoEntity = await tx.legalEntity.create({ + data: { name: '演示科技有限公司', shortName: '演示科技', creditNo: '91110000MA0000001X', isDefault: true }, + }); + const party = await tx.businessParty.create({ + data: { name: '某市城投集团', creditNo: '91110000MA9999999X', level: 'A' }, + }); + await tx.partyContact.create({ + data: { partyId: party.id, name: '招标办张工', phone: '13800001111', title: '项目联系人' }, + }); + await tx.qualification.createMany({ + data: [ + { + name: '电子与智能化工程专业承包', + code: 'QUAL_INTEL', + keeper: '行政', + expiresAt: new Date(Date.now() + 400 * 86400000), + }, + { + name: '涉密信息系统集成乙级', + code: 'SECRET_B', + keeper: '行政', + expiresAt: new Date(Date.now() + 20 * 86400000), + }, + { + name: '建筑装修装饰工程专业承包', + code: 'QUAL_DECOR', + keeper: '行政', + expiresAt: new Date(Date.now() - 10 * 86400000), + }, + ], + }); + + const dicts: [string, string, string, number][] = [ + ['projectType', '软件', '软件', 1], + ['projectType', '三维', '三维', 2], + ['projectType', '摄制', '摄制', 3], + ['projectType', '物资', '物资', 4], + ['projectType', '集成', '集成', 5], + ['tenderMethod', '公开招标', '公开招标', 1], + ['tenderMethod', '邀请招标', '邀请招标', 2], + ['tenderMethod', '询价', '询价', 3], + ['tenderMethod', '竞谈【综合评分】', '竞谈【综合评分】', 4], + ['tenderMethod', '竞谈【最低价中】', '竞谈【最低价中】', 5], + ['tenderMethod', '单一来源', '单一来源', 6], + ['tenderMethod', '其他', '其他', 7], + ['qualificationNeed', '无需资质', '无需资质', 1], + ['qualificationNeed', '军二', '军二', 2], + ['qualificationNeed', '涉乙', '涉乙', 3], + ['qualificationNeed', '其他', '其他', 4], + ['expenseCategory', '交通', '交通', 1], + ['expenseCategory', '餐饮', '餐饮', 2], + ['expenseCategory', '住宿', '住宿', 3], + ['expenseCategory', '办公', '办公', 4], + ['expenseCategory', '招待', '招待', 5], + ['expenseCategory', '其他', '其他', 6], + ['gender', '男', 'MALE', 1], + ['gender', '女', 'FEMALE', 2], + ['education', '高中及以下', 'HIGH_SCHOOL', 1], + ['education', '专科', 'COLLEGE', 2], + ['education', '本科', 'BACHELOR', 3], + ['education', '硕士', 'MASTER', 4], + ['education', '博士', 'DOCTOR', 5], + ]; + await tx.dictItem.createMany({ + data: dicts.map(([dictType, label, value, sortNo]) => ({ dictType, label, value, sortNo })), + }); + await tx.numberRule.createMany({ + data: [ + { code: 'BID', name: '投标编号', prefix: 'BID', padLength: 5 }, + { code: 'HT', name: '合同编号', prefix: 'HT', padLength: 5 }, + { code: 'PRJ', name: '项目编号', prefix: 'PRJ', padLength: 5 }, + { code: 'BX', name: '报销编号', prefix: 'BX', padLength: 5 }, + { code: 'JK', name: '借款编号', prefix: 'JK', padLength: 5 }, + { code: 'BZ', name: '保证金编号', prefix: 'BZ', padLength: 5 }, + ], + }); + await tx.hrSetting.create({ + data: { id: 'default', tenantId: '1', idMask: true, autoAccount: false, probationMonths: 3, defaultRest: 'SINGLE', workStart: '09:00', workEnd: '18:00' }, + }); + await tx.attendanceRule.createMany({ + data: [ + { name: '单休班次', restSchedule: 'SINGLE', workStart: '09:00', workEnd: '18:00', sortNo: 1, remark: '每周休一天,薪酬按 26.09 天核算' }, + { name: '双休班次', restSchedule: 'DOUBLE', workStart: '09:00', workEnd: '18:00', sortNo: 2, remark: '每周休两天,薪酬按 21.75 天核算' }, + ], + }); + const sealTypes = ['公章', '合同章', '法人章', '法人签名章', '项目负责人章', '其他章']; + await tx.sealItem.createMany({ + data: sealTypes.map((sealType) => ({ + name: `${demoEntity.shortName}${sealType}`, + sealType, + keeper: '行政', + legalEntityId: demoEntity.id, + status: 'IN_STOCK', + })), + }); + + const bid = await tx.bidCase.create({ + data: { + bidNo: 'BID-2026-00001', + name: '某市展厅数字化升级', + externalNo: 'ZFCG-2026-088', + projectType: '集成', + source: '招标公告', + qualificationNeed: '涉乙', + tenderMethod: '公开招标', + applyMethod: '网上申领', + openMethod: '线下开标', + openPlace: '市公共资源交易中心', + openAt: new Date(Date.now() + 7 * 86400000), + partyId: party.id, + status: 'TECH_INITIAL', + createdById: admin.id, + reviewers: { + create: [ + { userId: dirsoft.id, stage: 'TECH_INITIAL' }, + { userId: dir3d.id, stage: 'TECH_INITIAL' }, + ], + }, + }, + }); + await tx.bidActionLog.create({ + data: { + bidCaseId: bid.id, + actorId: admin.id, + action: 'CREATE', + toStatus: 'TECH_INITIAL', + comment: `发起筛选 ${bid.bidNo}`, + }, + }); + await tx.approvalTask.createMany({ + data: [ + { + title: `技术初审会签:${bid.name}`, + bizType: 'BID_TECH_INITIAL', + bizId: bid.id, + assigneeId: dirsoft.id, + }, + { + title: `技术初审会签:${bid.name}`, + bizType: 'BID_TECH_INITIAL', + bizId: bid.id, + assigneeId: dir3d.id, + }, + ], + }); + }); +} + +main() + .then(async () => { + await prisma.$disconnect(); + console.log('seed ok'); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/prisma/sync-menus.ts b/apps/api/prisma/sync-menus.ts new file mode 100644 index 0000000..5e6ab97 --- /dev/null +++ b/apps/api/prisma/sync-menus.ts @@ -0,0 +1,165 @@ +import { PrismaClient } from '@prisma/client'; +import { DIRECTOR_ROLES, WORK_ASSIGN_ROLES } from '../src/common/role-groups'; +import { MENUS, isHrSettingCode, type MenuNode } from './menus'; + +const prisma = new PrismaClient(); + +async function upsertNode(node: MenuNode, parentId: string | null, sortNo: number) { + const existing = await prisma.permission.findUnique({ where: { code: node.code } }); + const data = { + parentId, + name: node.name, + permType: 'MENU', + path: node.path, + icon: node.icon ?? null, + sortNo, + visible: true, + }; + const row = existing + ? await prisma.permission.update({ where: { id: existing.id }, data }) + : await prisma.permission.create({ data: { ...data, code: node.code } }); + return row; +} + +const ACTION_DEFS = [ + { name: '查看', suffix: 'view' }, + { name: '新增', suffix: 'create' }, + { name: '编辑', suffix: 'edit' }, + { name: '删除', suffix: 'delete' }, + { name: '审批', suffix: 'approve' }, +]; + +async function upsertActions(parentCode: string, parentId: string) { + const ids: string[] = []; + for (let i = 0; i < ACTION_DEFS.length; i++) { + const a = ACTION_DEFS[i]; + const code = `${parentCode}:${a.suffix}`; + const existing = await prisma.permission.findUnique({ where: { code } }); + const data = { + parentId, + name: a.name, + permType: 'ACTION', + path: null as string | null, + icon: null as string | null, + sortNo: i + 1, + visible: true, + }; + const row = existing + ? await prisma.permission.update({ where: { id: existing.id }, data }) + : await prisma.permission.create({ data: { ...data, code } }); + ids.push(row.id); + } + return ids; +} + +async function walk(nodes: MenuNode[], parentId: string | null, root = true) { + const ids: string[] = []; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const row = await upsertNode(node, parentId, root ? (i + 1) * 10 : i + 1); + ids.push(row.id); + if (node.children?.length) ids.push(...(await walk(node.children, row.id, false))); + else ids.push(...(await upsertActions(node.code, row.id))); + } + return ids; +} + +async function main() { + const kept = await walk(MENUS, null); + const stale = await prisma.permission.findMany({ + where: { + permType: 'MENU', + OR: [ + { id: { notIn: kept }, code: { in: ['system:org'] } }, + { code: { in: ['office:loan-approve', 'finance:expense-approve', 'finance:loan-approve', 'finance:bonds', 'contract:milestones', 'office:attendance', 'office:policies', 'project:budgets', 'project:changes'] } }, + ], + }, + }); + if (stale.length) { + await prisma.permission.updateMany({ + where: { id: { in: stale.map((s) => s.id) } }, + data: { visible: false }, + }); + } + + const all = await prisma.permission.findMany({ where: { visible: true } }); + const grantByCodes = (codes: string[]) => { + if (codes[0] === '*') return all; + return all.filter((p) => + codes.some((c) => { + if (c.endsWith(':')) return p.code === c.slice(0, -1) || p.code.startsWith(c); + return p.code === c || p.code.startsWith(`${c}:`); + }), + ); + }; + const STAFF_OFFICE = [ + 'office:overview', + 'office', + 'office:todos', + 'office:approvals', + 'office:calendar', + 'office:reports', + 'office:expenses', + 'office:flow', + 'office:hr-apply', + 'office:seal-apply', + 'office:apply', + 'office:notices', + 'office:directory', + 'office:im', + ]; + const ROLE_GRANT: Record = { + admin: ['*'], + owner: ['*'], + biz_director: ['office:', 'party:', 'seal:', 'bid:', 'contract:', 'finance:', 'report:', 'hr:', 'asset:', 'system:hr', 'system:org', 'system:attendance-rules'], + biz_staff: ['office:', 'party:', 'seal:', 'bid:', 'contract:', 'finance:', 'asset:', 'hr:employees', 'hr:payroll', 'hr:performance', 'hr:attendance', 'hr:lifecycle'], + tech_director: ['office:', 'bid:', 'project:', 'report:', 'hr:performance', 'hr:notices'], + rd_director: ['*'], + '3d_director': ['office:', 'bid:', 'project:', 'report:', 'hr:performance', 'hr:notices'], + video_director: ['office:', 'bid:', 'project:', 'report:', 'hr:performance', 'hr:notices'], + material_director: ['office:', 'bid:', 'project:', 'report:', 'hr:performance', 'hr:notices'], + finance: ['office:', 'finance:', 'contract:', 'report:', 'bid:result', 'asset:'], + hr: ['office:', 'hr:', 'contract:hr', 'system:hr', 'system:org', 'system:attendance-rules', 'system:acl'], + employee: [...STAFF_OFFICE, 'project:timesheets'], + bid_screener: ['office', 'office:overview', 'bid', 'bid:screening'], + pm: [...STAFF_OFFICE, 'office:assign', 'project:'], + rd_staff: [...STAFF_OFFICE, 'project:timesheets'], + '3d_staff': [...STAFF_OFFICE, 'project:timesheets'], + video_staff: [...STAFF_OFFICE, 'project:timesheets'], + material_staff: [...STAFF_OFFICE, 'project:timesheets'], + tech: [...STAFF_OFFICE, 'project:timesheets'], + }; + const roles = await prisma.role.findMany(); + for (const role of roles) { + if (role.aclCustom) continue; + const codes = ROLE_GRANT[role.code]; + if (!codes) continue; + const wanted = grantByCodes(codes); + const extra = role.code === 'hr' ? all.filter((p) => isHrSettingCode(p.code)) : []; + const smsRoles = ['admin', 'owner', 'hr', ...DIRECTOR_ROLES]; + const merged = [...new Map([...wanted, ...extra].map((p) => [p.id, p])).values()].filter((p) => { + if (p.code === 'office:assign') return WORK_ASSIGN_ROLES.includes(role.code); + if (p.code === 'office:sms') return smsRoles.includes(role.code); + return true; + }); + const wantedIds = merged.map((p) => p.id); + await prisma.rolePermission.deleteMany({ + where: { roleId: role.id, permissionId: { notIn: wantedIds.length ? wantedIds : [''] } }, + }); + await prisma.rolePermission.createMany({ + data: merged.map((p) => ({ roleId: role.id, permissionId: p.id })), + skipDuplicates: true, + }); + } + console.log(`menus synced: ${kept.length} visible, hid ${stale.length}`); +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/prisma/sync-oa-attachments.ts b/apps/api/prisma/sync-oa-attachments.ts new file mode 100644 index 0000000..36cfc1e --- /dev/null +++ b/apps/api/prisma/sync-oa-attachments.ts @@ -0,0 +1,329 @@ +import { PrismaClient } from '@prisma/client'; +import { createHash, randomUUID } from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { parseOaJson } from './oa-json'; + +const prisma = new PrismaClient(); +const CORE = '/opt/import/prod/oa_core.json'; +const FLOW = '/opt/import/prod/oa_flow_finance.json'; +const BID_FILES = '/opt/import/prod/oa_bid_files.json'; +const HR = '/opt/import/prod/oa_hr.json'; +const OA_FILES = '/opt/import/prod/oa-files'; +const UPLOAD = path.join(process.cwd(), 'uploads'); + +type Row = Record; + +const MIME: Record = { + pdf: 'application/pdf', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + zip: 'application/zip', + rar: 'application/x-rar-compressed', +}; + +const NAME_ALIAS: Record = { 张靖妍: '张婧妍' }; +const SKIP_DOC_ROLE = new Set(['draft', 'final', 'review1']); + +function str(v: unknown) { + if (v == null) return ''; + return String(v).trim(); +} + +function officialNo(raw: unknown) { + const n = str(raw).replace(/[))]+$/, '').trim(); + if (!n || n === '无' || n === '-' || n === '—') return ''; + return n; +} + +function num(v: unknown) { + if (v == null || v === '') return 0; + const n = Number(String(v).replace(/,/g, '')); + return Number.isFinite(n) ? n : 0; +} + +function profilePaths(v: unknown) { + const out: string[] = []; + if (!v) return out; + for (const chunk of str(v).split(/[,;]/)) { + const piece = chunk.trim(); + const first = piece.split('|')[0].trim(); + const idx = first.indexOf('/profile/upload/'); + const p = idx >= 0 ? first.slice(idx) : first; + if (p.startsWith('/profile/upload/')) out.push(p); + } + return out; +} + +function relOf(p: string) { + return p.replace(/^\/profile\/upload\//, ''); +} + +function displayName(p: string) { + const pipe = str(p).split('|')[1]; + if (pipe && !pipe.includes('/')) return path.basename(pipe); + return path.basename(relOf(p.split('|')[0])); +} + +function mimeOf(name: string) { + const ext = name.split('.').pop()?.toLowerCase() || 'bin'; + return MIME[ext] || 'application/octet-stream'; +} + +function prettyName(name: string) { + try { + return decodeURIComponent(name); + } catch { + return name; + } +} + +async function attach( + bizType: string, + bizId: string, + urls: unknown, + uploadedById: string, + seen: Set, +) { + let n = 0; + let missing = 0; + for (const raw of profilePaths(urls)) { + const rel = relOf(raw); + const src = path.join(OA_FILES, rel); + if (!fs.existsSync(src)) { + console.warn(`[missing-source] ${bizType} ${rel}`); + missing += 1; + continue; + } + const buf = fs.readFileSync(src); + const hash = createHash('sha1').update(buf).digest('hex').slice(0, 16); + const key = `${bizId}:${hash}`; + if (seen.has(key)) continue; + seen.add(key); + const id = randomUUID(); + const ext = (path.extname(rel).replace('.', '') || 'bin').replace(/[^a-zA-Z0-9]/g, '').slice(0, 8); + const storageKey = `OA/${bizType}/${bizId}/${id}.${ext || 'bin'}`; + const dest = path.join(UPLOAD, storageKey); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + const name = prettyName(displayName(raw) || path.basename(rel)); + await prisma.fileAsset.create({ + data: { + id, + tenantId: '1', + bizType, + bizId, + fileName: name.slice(0, 180), + mimeType: mimeOf(name), + size: buf.length, + storageKey, + uploadedById, + }, + }); + n += 1; + } + return { n, missing }; +} + +async function main() { + const core = parseOaJson>(CORE); + const flow = parseOaJson>(FLOW); + const docFiles = JSON.parse(fs.readFileSync(BID_FILES, 'utf8')) as Row[]; + const hr = JSON.parse(fs.readFileSync(HR, 'utf8')) as { users: { id: number; userName: string; nickName: string }[] }; + const admin = await prisma.user.findFirst({ + where: { OR: [{ username: 'admin' }, { displayName: '系统管理员' }] }, + orderBy: { createdAt: 'asc' }, + }); + if (!admin) throw new Error('没有可用于迁移附件的系统管理员账号'); + fs.mkdirSync(UPLOAD, { recursive: true }); + + const users = await prisma.user.findMany(); + const byUsername = new Map(users.map((u) => [u.username, u])); + const byName = new Map(users.map((u) => [u.displayName, u])); + for (const [a, b] of Object.entries(NAME_ALIAS)) { + const u = byName.get(b); + if (u) byName.set(a, u); + } + const oaUser = new Map(hr.users.map((u) => [String(u.id), u.userName])); + const userFromOaId = (id: unknown) => { + const uname = oaUser.get(str(id)); + return uname ? byUsername.get(uname) : undefined; + }; + const userFromName = (name: unknown) => { + const n = NAME_ALIAS[str(name)] || str(name); + return byName.get(n); + }; + + const existing = await prisma.fileAsset.findMany({ select: { bizId: true, storageKey: true, size: true, fileName: true } }); + const seen = new Set(); + for (const f of existing) { + const src = path.join(process.cwd(), 'uploads', f.storageKey); + if (!fs.existsSync(src)) continue; + const hash = createHash('sha1').update(fs.readFileSync(src)).digest('hex').slice(0, 16); + seen.add(`${f.bizId}:${hash}`); + } + + const bids = await prisma.bidCase.findMany(); + const findBids = (nos: string[], name?: string) => { + const wanted = new Set(nos.filter(Boolean)); + const hits = bids.filter( + (b) => wanted.has(b.bidNo) || (b.externalNo && wanted.has(b.externalNo)), + ); + if (hits.length) return hits; + if (name) return bids.filter((b) => b.name === name); + return []; + }; + + const stats = { + bidAnnounce: 0, + bidFile: 0, + bidProof: 0, + contracts: 0, + expenses: 0, + projects: 0, + purchases: 0, + missing: 0, + unmatchedBids: 0, + }; + + for (const t of core.oa_tender || []) { + const localHits = findBids( + [officialNo(t.project_number), str(t.system_number)], + str(t.project_name), + ); + if (!localHits.length) continue; + const who = userFromOaId(t.create_by) || userFromName(t.bid_prepare_person) || admin; + for (const local of localHits) { + stats.bidAnnounce += (await attach('BID_ANNOUNCE', local.id, t.announcement_file, who.id, seen)).n; + const extra = await attach('BID_FILE', local.id, t.attachment, who.id, seen); + stats.bidFile += extra.n; + stats.missing += extra.missing; + stats.bidProof += (await attach('BID_PROOF', local.id, t.submit_proof_file, who.id, seen)).n; + } + } + + for (const p of flow.project_preinvest_apply || []) { + const nos = [str(p.apply_no), officialNo(p.project_number)]; + const localHits = findBids(nos, str(p.project_name)); + if (!localHits.length) { + if (profilePaths(p.attachment).length || profilePaths(p.business_attachment).length) stats.unmatchedBids += 1; + continue; + } + const who = userFromName(p.applicant_name) || userFromName(p.business_handler_name) || admin; + for (const local of localHits) { + const a = await attach('BID_ANNOUNCE', local.id, p.attachment, who.id, seen); + const b = await attach('BID_FILE', local.id, p.business_attachment, who.id, seen); + const c = await attach('BID_PROOF', local.id, p.material_decision_proof, who.id, seen); + const d = await attach('BID_FILE', local.id, p.business_prep_attachment, who.id, seen); + stats.bidAnnounce += a.n; + stats.bidFile += b.n + d.n; + stats.bidProof += c.n; + stats.missing += a.missing + b.missing + c.missing + d.missing; + } + } + + const taskById = new Map((flow.bid_document_task || []).map((t) => [String(t.id), t])); + for (const f of docFiles) { + if (SKIP_DOC_ROLE.has(str(f.file_role))) continue; + const task = taskById.get(str(f.task_id)); + const nos = task + ? [str(task.task_no), officialNo(task.project_number), str(task.preinvest_apply_no)] + : []; + const localHits = findBids(nos, str(task?.project_name)); + if (!localHits.length) { + stats.unmatchedBids += 1; + continue; + } + const who = userFromName(f.uploaded_by_name) || userFromName(task?.dispatcher_name) || admin; + const role = str(f.file_role); + const from = str(f.source_preinvest_field); + const bizType = + role === 'submit_proof' || from === 'material_decision_proof' + ? 'BID_PROOF' + : from === 'attachment' + ? 'BID_ANNOUNCE' + : 'BID_FILE'; + for (const local of localHits) { + const r = await attach(bizType, local.id, f.file_url, who.id, seen); + if (bizType === 'BID_ANNOUNCE') stats.bidAnnounce += r.n; + else if (bizType === 'BID_PROOF') stats.bidProof += r.n; + else stats.bidFile += r.n; + stats.missing += r.missing; + } + } + + const contracts = await prisma.contract.findMany(); + const contractByName = new Map(contracts.map((c) => [c.name, c])); + for (const c of core.oa_contract || []) { + const local = contractByName.get(str(c.contract_name)); + if (!local) continue; + const who = userFromOaId(c.create_by) || userFromOaId(c.creator_id) || admin; + stats.contracts += (await attach('CONTRACT', local.id, c.contract_original_url, who.id, seen)).n; + stats.contracts += (await attach('CONTRACT_ACCEPT', local.id, c.acceptance_url, who.id, seen)).n; + stats.contracts += (await attach('CONTRACT_AWARD', local.id, c.award_notice_url, who.id, seen)).n; + } + + const expenses = await prisma.expenseClaim.findMany(); + for (const r of flow.reimbursement || core.reimbursement || []) { + const amount = num(r.total_amount); + const name = str(r.employee_name); + const claimNo = str(r.reimburse_no); + const oaId = str(r.id); + if (!oaId) continue; + let hits = claimNo ? expenses.filter((e) => e.claimNo === claimNo) : []; + if (!hits.length) { + hits = expenses.filter((e) => Math.abs(Number(e.amount) - amount) < 0.009 && name && (e.remark || '').includes(name)); + } + const local = hits[0]; + if (!local) continue; + const who = userFromName(name) || users.find((u) => u.id === local.applicantId) || admin; + const items = (flow.reimbursement_item || core.reimbursement_item || []).filter( + (i) => str(i.reimbursement_id) && str(i.reimbursement_id) === oaId, + ); + for (const item of items) { + const x = await attach('EXPENSE', local.id, item.url, who.id, seen); + stats.expenses += x.n; + stats.missing += x.missing; + } + const x = await attach('EXPENSE', local.id, r.attachment_url, who.id, seen); + stats.expenses += x.n; + stats.missing += x.missing; + } + + const projects = await prisma.project.findMany(); + const projectByName = new Map(projects.map((p) => [p.name, p])); + for (const p of core.project_list || []) { + const local = + projects.find((x) => x.projectNo === str(p.system_number)) || projectByName.get(str(p.project_name)); + if (!local) continue; + stats.projects += (await attach('PROJECT', local.id, p.bzj_attachment, admin.id, seen)).n; + } + + const purchases = await prisma.purchaseRequest.findMany(); + for (const r of core.oa_purchasing || []) { + const titleBits = [str(r.contract_no), str(r.contract_name)].filter(Boolean); + const local = purchases.find((p) => titleBits.some((b) => p.title.includes(b))); + if (!local) continue; + const who = userFromOaId(r.create_by) || userFromOaId(r.creator_id) || admin; + stats.purchases += (await attach('PURCHASE', local.id, r.contract_original_url, who.id, seen)).n; + stats.purchases += (await attach('PURCHASE_ACCEPT', local.id, r.acceptance_url, who.id, seen)).n; + } + + const fileCount = await prisma.fileAsset.count(); + const byType = await prisma.fileAsset.groupBy({ by: ['bizType'], _count: true }); + console.log(JSON.stringify({ added: stats, fileAssetTotal: fileCount, byType }, null, 2)); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/prisma/sync-tender-methods.ts b/apps/api/prisma/sync-tender-methods.ts new file mode 100644 index 0000000..6fbf14e --- /dev/null +++ b/apps/api/prisma/sync-tender-methods.ts @@ -0,0 +1,35 @@ +import { PrismaClient } from '@prisma/client'; + +const ITEMS = [ + ['公开招标', 1], + ['邀请招标', 2], + ['询价', 3], + ['竞谈【综合评分】', 4], + ['竞谈【最低价中】', 5], + ['单一来源', 6], + ['其他', 7], +] as const; + +const prisma = new PrismaClient(); + +async function main() { + await prisma.dictItem.deleteMany({ where: { tenantId: '1', dictType: 'tenderMethod' } }); + await prisma.dictItem.createMany({ + data: ITEMS.map(([value, sortNo]) => ({ + tenantId: '1', + dictType: 'tenderMethod', + label: value, + value, + sortNo, + })), + }); + console.log('tenderMethod dict synced', ITEMS.length); +} + +main() + .then(() => prisma.$disconnect()) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/prisma/wash-bid-buckets.ts b/apps/api/prisma/wash-bid-buckets.ts new file mode 100644 index 0000000..9030f21 --- /dev/null +++ b/apps/api/prisma/wash-bid-buckets.ts @@ -0,0 +1,131 @@ +import { PrismaClient } from '@prisma/client'; +import * as fs from 'fs'; + +const prisma = new PrismaClient(); +const DUMP = '/opt/import/prod/oa_core.json'; + +type Row = Record; + +function str(v: unknown) { + if (v == null) return ''; + return String(v).trim(); +} + +function officialNo(raw: unknown) { + const n = str(raw).replace(/[))]+$/, '').trim(); + if (!n || n === '无' || n === '-' || n === '—') return ''; + return n; +} + +function mapQual(raw: unknown) { + const s = str(raw); + if (!s) return ''; + try { + const arr = JSON.parse(s) as { item?: string }[]; + const item = str(arr?.[0]?.item); + if (!item || item === '无') return '无需资质'; + if (item.includes('军')) return '军二'; + if (item.includes('涉')) return '涉乙'; + return item.slice(0, 20); + } catch { + if (s.includes('军')) return '军二'; + if (s.includes('涉')) return '涉乙'; + return ''; + } +} + +function mapStatus(row: Row) { + const st = str(row.project_status); + const win = str(row.winning_status); + if (st === '中标' || (st === '' && win.includes('中标'))) return 'WON'; + if (st === '未中' || (st === '' && win.includes('未中'))) return 'LOST'; + if (st === '流标') return 'FAILED'; + if (st === '弃标' || win.includes('弃标')) return 'TERMINATED'; + return ''; +} + +function isRealTender(row: Row) { + return /^TB-\d/.test(str(row.system_number)) && Boolean(str(row.project_name)); +} + +async function main() { + const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record; + const tenders = (raw.oa_tender || []).filter(isRealTender); + const bids = await prisma.bidCase.findMany(); + const users = await prisma.user.findMany({ select: { id: true, displayName: true } }); + const userByName = new Map(users.map((u) => [u.displayName, u.id])); + userByName.set('张靖妍', userByName.get('张婧妍') || ''); + + const testNos = ['SP-TEST-1788068192', 'SP-FIX-1788068273']; + const tests = bids.filter((b) => testNos.includes(b.bidNo) || testNos.includes(b.externalNo || '')); + const testIds = tests.map((b) => b.id); + if (testIds.length) { + await prisma.todoTask.deleteMany({ where: { bizId: { in: testIds } } }); + await prisma.approvalTask.deleteMany({ where: { bizId: { in: testIds } } }); + await prisma.bidCase.deleteMany({ where: { id: { in: testIds } } }); + } + + const used = new Set(); + const remaining = await prisma.bidCase.findMany(); + let patched = 0; + const moves: { name: string; from: string; to: string; no: string }[] = []; + + for (const t of tenders) { + const name = str(t.project_name); + const sys = str(t.system_number); + const official = officialNo(t.project_number); + const row = + remaining.find((b) => b.name === name) || + remaining.find((b) => b.bidNo === sys || b.externalNo === official || b.externalNo === str(t.project_number)); + if (!row) continue; + + const status = mapStatus(t) || row.status; + const creatorName = str(t.bid_prepare_person) === '张靖妍' ? '张婧妍' : str(t.bid_prepare_person); + let bidNo = official || sys; + if (used.has(bidNo)) bidNo = `${official || sys}-${sys.slice(-3)}`; + used.add(bidNo); + + if (row.status !== status) { + moves.push({ name: name.slice(0, 24), from: row.status, to: status, no: official || sys }); + } + + const qual = mapQual(t.bid_doc_requirement); + await prisma.bidCase.update({ + where: { id: row.id }, + data: { + bidNo: `TMP-${row.id.replace(/-/g, '').slice(0, 18)}`, + }, + }); + await prisma.bidCase.update({ + where: { id: row.id }, + data: { + bidNo, + externalNo: official || null, + status, + sourceType: 'FLOW', + qualificationNeed: qual || row.qualificationNeed, + tenderMethod: str(t.tender_method) || row.tenderMethod, + applyMethod: row.applyMethod || (str(t.announcement_file) ? '网上申领' : row.applyMethod), + delivererName: str(t.bid_delivery_person) || row.delivererName, + createdById: userByName.get(creatorName) || row.createdById, + }, + }); + patched += 1; + } + + const after = await prisma.bidCase.groupBy({ by: ['status'], _count: true }); + console.log( + JSON.stringify( + { deletedTests: testIds.length, patched, moves, counts: after }, + null, + 2, + ), + ); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/prisma/wash-legal-entities.ts b/apps/api/prisma/wash-legal-entities.ts new file mode 100644 index 0000000..40ffbbf --- /dev/null +++ b/apps/api/prisma/wash-legal-entities.ts @@ -0,0 +1,142 @@ +import { PrismaClient } from '@prisma/client'; +import * as fs from 'fs'; + +const prisma = new PrismaClient(); +const DUMP = '/opt/import/prod/oa_clean.json'; + +type Row = Record; + +function str(v: unknown) { + if (v == null) return ''; + return String(v).trim(); +} + +const CANON = [ + { name: '江苏风影随行科技有限公司', shortName: '随行科技', aliases: ['江苏风影随行科技有限公司', '江苏风影随行', '随行科技'] }, + { name: '江苏风影软件有限公司', shortName: '风影软件', aliases: ['江苏风影软件有限公司', '风影软件'] }, + { name: '江苏风影工程科技有限公司', shortName: '风影工程', aliases: ['江苏风影工程科技有限公司', '风影工程'] }, + { name: '山东鲁兵', shortName: '山东鲁兵', aliases: ['山东鲁兵', '鲁兵'] }, + { name: '隆创', shortName: '隆创', aliases: ['隆创'] }, +] as const; + +function canonOf(raw: string) { + const s = str(raw); + if (!s) return null; + if (/软件/.test(s) && /风影/.test(s)) return CANON[1]; + if (/工程/.test(s) && /风影/.test(s)) return CANON[2]; + if (/随行/.test(s) || s === '江苏风影' || (s.includes('风影') && !/软件|工程/.test(s))) return CANON[0]; + if (s.includes('鲁兵')) return CANON[3]; + if (s.includes('隆创')) return CANON[4]; + for (const c of CANON) { + if (c.aliases.some((a) => s === a || s.includes(a))) return c; + } + return null; +} + +async function main() { + const ids = new Map(); + for (const c of CANON) { + let row = + (await prisma.legalEntity.findFirst({ where: { tenantId: '1', name: c.name } })) || + (await prisma.legalEntity.findFirst({ + where: { tenantId: '1', OR: c.aliases.map((a) => ({ name: a })) }, + })); + if (row) { + row = await prisma.legalEntity.update({ + where: { id: row.id }, + data: { name: c.name, shortName: c.shortName, isDefault: c.name === '江苏风影随行科技有限公司' }, + }); + } else { + row = await prisma.legalEntity.create({ + data: { + name: c.name, + shortName: c.shortName, + isDefault: c.name === '江苏风影随行科技有限公司', + }, + }); + } + ids.set(c.name, row.id); + } + + const all = await prisma.legalEntity.findMany({ where: { tenantId: '1' } }); + const keep = new Set(ids.values()); + let merged = 0; + for (const extra of all) { + if (keep.has(extra.id)) continue; + const hit = canonOf(extra.name) || canonOf(extra.shortName); + const target = hit ? ids.get(hit.name) : ids.get('江苏风影随行科技有限公司'); + if (!target || target === extra.id) continue; + const n = await prisma.bidCase.updateMany({ where: { legalEntityId: extra.id }, data: { legalEntityId: target } }); + await prisma.contract.updateMany({ where: { legalEntityId: extra.id }, data: { legalEntityId: target } }).catch(() => null); + await prisma.sealItem.updateMany({ where: { legalEntityId: extra.id }, data: { legalEntityId: target } }).catch(() => null); + await prisma.registeredAsset.updateMany({ where: { legalEntityId: extra.id }, data: { legalEntityId: target } }).catch(() => null); + await prisma.legalEntity.delete({ where: { id: extra.id } }).catch(() => null); + merged += 1; + console.log('merged', extra.name, '->', hit?.name, 'bids', n.count); + } + + await prisma.legalEntity.updateMany({ + where: { name: { not: '江苏风影随行科技有限公司' } }, + data: { isDefault: false }, + }); + await prisma.legalEntity.updateMany({ + where: { name: '江苏风影随行科技有限公司' }, + data: { isDefault: true }, + }); + + let remapped = 0; + if (fs.existsSync(DUMP)) { + const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record; + const tenders = raw.oa_tender || []; + const bids = await prisma.bidCase.findMany({ select: { id: true, name: true, bidNo: true, externalNo: true } }); + for (const t of tenders) { + const name = str(t.project_name); + const sys = str(t.system_number); + const official = str(t.project_number); + const row = + bids.find((b) => b.name === name) || + bids.find((b) => b.bidNo === sys || b.externalNo === official || b.bidNo === official); + const hit = canonOf(str(t.participating_unit)); + const legalEntityId = hit ? ids.get(hit.name) : undefined; + const winner = str(t.winning_unit); + const amtRaw = str(t.winning_amount); + const status = str(t.winning_status); + if (!row) continue; + const data: { legalEntityId?: string; winnerName?: string; winnerAmount?: number } = {}; + if (legalEntityId) data.legalEntityId = legalEntityId; + if (winner && status && !/中标/.test(status)) { + data.winnerName = winner; + if (amtRaw && !Number.isNaN(Number(amtRaw))) data.winnerAmount = Number(amtRaw); + } + if (!Object.keys(data).length) continue; + await prisma.bidCase.update({ where: { id: row.id }, data }); + remapped += 1; + } + } + + const leftover = await prisma.legalEntity.findMany({ where: { tenantId: '1' }, orderBy: { name: 'asc' } }); + const grouped = await prisma.bidCase.groupBy({ by: ['legalEntityId'], _count: true }); + console.log( + JSON.stringify( + { + entities: leftover.map((e) => ({ name: e.name, shortName: e.shortName, isDefault: e.isDefault })), + bids: grouped.map((g) => ({ + id: g.legalEntityId, + count: g._count, + name: leftover.find((e) => e.id === g.legalEntityId)?.name || '(空)', + })), + merged, + remapped, + }, + null, + 2, + ), + ); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/prisma/wash-oa-flow-finance.ts b/apps/api/prisma/wash-oa-flow-finance.ts new file mode 100644 index 0000000..053cd07 --- /dev/null +++ b/apps/api/prisma/wash-oa-flow-finance.ts @@ -0,0 +1,466 @@ +import { Prisma, PrismaClient } from '@prisma/client'; +import { mapExpenseLineKind, parseOaJson } from './oa-json'; + +const prisma = new PrismaClient(); +const FLOW = '/opt/import/prod/oa_flow_finance.json'; +const FRESH = '/opt/import/prod/oa_fresh.json'; + +type Row = Record; + +function str(v: unknown) { + if (v == null) return ''; + return String(v).trim(); +} + +function num(v: unknown) { + if (v == null || v === '') return 0; + const n = Number(String(v).replace(/,/g, '')); + return Number.isFinite(n) ? n : 0; +} + +function when(v: unknown) { + const s = str(v); + if (!s || s.startsWith('0000')) return null; + const d = new Date(s.includes('T') ? s : s.replace(' ', 'T')); + return Number.isNaN(d.getTime()) ? null : d; +} + +function clip(v: unknown, n = 200) { + const s = str(v); + return s.length > n ? s.slice(0, n) : s; +} + +function compactName(name: string) { + return str(name) + .replace(/[()()\[\]【】\s\-—_.,,、]/g, '') + .replace(/招标公告|采购项目|技术服务合同|合同/g, ''); +} + +function namesOverlap(a: string, b: string) { + const na = compactName(a); + const nb = compactName(b); + if (!na || !nb) return false; + if (na === nb) return true; + const shorter = na.length <= nb.length ? na : nb; + const longer = na.length <= nb.length ? nb : na; + return shorter.length >= 10 && longer.includes(shorter); +} + +function mapProjectType(v: unknown, projectName?: unknown) { + const name = str(projectName); + if (name.includes('三维')) return '三维'; + if (/(视频.{0,12}(制作|摄制|拍摄)|摄制|拍摄|摄影|宣传片|专题片|纪录片|微电影|影视制作)/.test(name)) return '摄制'; + const s = str(v); + if (s.includes('三维')) return '三维'; + if (s.includes('摄制') || s.includes('视频')) return '摄制'; + if (s.includes('物资') && s.includes('软件')) return '集成'; + if (s.includes('物资')) return '物资'; + if (s.includes('集成')) return '集成'; + if (s === '软件') return '软件'; + return '软件'; +} + +function mapDocStatus(stage: number, approval: number) { + if (stage === 8 || approval === 3) return 'TERMINATED'; + if (stage === 1 || stage === 2 || stage === 3 || stage === 4 || stage === 5 || stage === 6) return 'DRAFTING'; + if (stage === 7 && approval === 2) return 'PENDING'; + return 'DRAFTING'; +} + +function mapPreinvestStatus(stage: number, approval: number) { + if (stage === 6 || approval === 3) return 'TERMINATED'; + if (stage === 3) return 'TECH_FINAL'; + if (stage === 1) return 'TECH_INITIAL'; + if (stage === 5 && approval === 2) return ''; + return 'FETCH'; +} + +function mapExpenseStatus(v: unknown) { + const s = str(v); + if (s === '2') return 'APPROVED'; + if (s === '3') return 'REJECTED'; + if (s === '4') return 'DRAFT'; + return 'PENDING'; +} + +function mapLoanStatus(row: Row) { + const st = str(row.approval_status); + const offset = num(row.offset_amount); + if (st === '2') return offset >= num(row.amount) && offset > 0 ? 'OFFSET' : 'OPEN'; + if (st === '1') return 'PENDING'; + if (st === '3') return 'REJECTED'; + return 'DRAFT'; +} + +function mapItemKind(itemType: string, remark = '') { + return mapExpenseLineKind(itemType, remark); +} + +function mapItemCategory(itemType: string) { + if (itemType.includes('交通') || itemType.includes('差旅') || itemType.includes('机票') || itemType.includes('高铁')) return '交通'; + if (itemType.includes('餐')) return '餐饮'; + if (itemType.includes('住宿')) return '住宿'; + if (itemType.includes('办公')) return '办公'; + if (itemType.includes('招待')) return '招待'; + return '其他'; +} + +function isBidExpense(itemType: string) { + return itemType.includes('投标') || itemType.includes('制标') || itemType.includes('取标') || itemType.includes('印'); +} + +function officialNo(raw: unknown) { + const n = str(raw).replace(/[))]+$/, '').trim(); + if (!n || n === '无' || n === '-' || n === '—') return ''; + return n; +} + +function inferTenderMethod(name: string, stored?: string) { + const s = str(stored); + if (s) return s; + const n = name || ''; + if (n.includes('询价')) return '询价'; + if (n.includes('邀请招标')) return '邀请招标'; + if (n.includes('单一来源')) return '单一来源'; + if (n.includes('竞谈最低') || n.includes('竞争性谈判(最低') || n.includes('竞争性谈判(最低')) return '竞谈最低价'; + if (n.includes('竞谈综合') || n.includes('综合评审')) return '竞谈综合评审'; + if (n.includes('竞争性谈判') || n.includes('竞谈')) return '竞争性谈判'; + if (n.includes('竞争性磋商') || n.includes('磋商')) return '竞争性磋商'; + if (n.includes('公开招标')) return '公开招标'; + return ''; +} + +async function main() { + const flow = parseOaJson>(FLOW); + const fresh = parseOaJson>(FRESH); + const admin = await prisma.user.findFirst({ where: { username: 'admin' } }); + if (!admin) throw new Error('本地没有 admin'); + const entity = + (await prisma.legalEntity.findFirst({ where: { tenantId: '1', isDefault: true } })) || + (await prisma.legalEntity.findFirst({ where: { tenantId: '1' } })); + if (!entity) throw new Error('本地没有主体'); + + const users = await prisma.user.findMany({ select: { id: true, displayName: true, username: true } }); + const userByName = new Map(users.map((u) => [u.displayName, u.id])); + userByName.set('张靖妍', userByName.get('张婧妍') || ''); + + const entities = await prisma.legalEntity.findMany(); + const entityByName = new Map(entities.map((e) => [e.name, e.id])); + for (const e of entities) if (e.shortName) entityByName.set(e.shortName, e.id); + + const projects = await prisma.project.findMany({ select: { id: true, name: true, projectNo: true } }); + const projectByName = new Map(projects.map((p) => [p.name, p.id])); + const projectByOaId = new Map(); + for (const p of fresh.project_list || []) { + const local = projects.find((x) => x.projectNo === str(p.system_number) || namesOverlap(x.name, str(p.project_name))); + if (local && p.project_id != null) projectByOaId.set(String(p.project_id), local.id); + } + + const tenders = (fresh.oa_tender || []).filter((t) => /^TB-\d/.test(str(t.system_number))); + const tenderById = new Map(tenders.map((t) => [String(t.id), t])); + + const allPreinvests = flow.project_preinvest_apply || []; + const preByTask = new Map(); + const preByTender = new Map(); + const preByName = new Map(); + for (const p of [...allPreinvests].sort((a, b) => num(a.deleted) - num(b.deleted))) { + if (p.bid_document_task_id != null) preByTask.set(String(p.bid_document_task_id), p); + if (p.tender_id != null) preByTender.set(String(p.tender_id), p); + if (str(p.project_name) && !preByName.has(str(p.project_name))) preByName.set(str(p.project_name), p); + } + + const bids = await prisma.bidCase.findMany(); + const usedNos = new Set(bids.map((b) => b.bidNo)); + + function findBid(_name: string, nos: string[]) { + const wanted = new Set(nos.filter(Boolean)); + if (!wanted.size) return undefined; + return bids.find((b) => wanted.has(b.bidNo) || (b.externalNo && wanted.has(b.externalNo))); + } + + let applyPatched = 0; + for (const t of tenders) { + const pre = preByTender.get(String(t.id)) || preByName.get(str(t.project_name)); + const start = when(pre?.file_apply_time_start) || when(t.bid_prepare_date) || when(t.create_time); + const end = when(pre?.file_apply_time_end) || when(t.file_apply_deadline); + const row = findBid(str(t.project_name), [officialNo(t.project_number), str(t.system_number)]); + if (!row || (!start && !end)) continue; + await prisma.bidCase.update({ + where: { id: row.id }, + data: { + applyStartAt: start || row.applyStartAt, + applyEndAt: end || row.applyEndAt, + applyMethod: clip(pre?.apply_method, 50) || row.applyMethod, + openMethod: clip(pre?.bid_open_method, 50) || row.openMethod, + }, + }); + applyPatched += 1; + } + + async function ensureBid(opts: { + bidNo: string; + name: string; + official?: string; + status: string; + openAt?: Date | null; + openPlace?: string; + applyStart?: Date | null; + applyEnd?: Date | null; + applyMethod?: string; + openMethod?: string; + projectType?: string; + source?: string; + qualification?: string; + entityName?: string; + createdAt?: Date | null; + createdByName?: string; + tenderMethod?: string; + }) { + if (!opts.name || /^test/i.test(opts.name)) return 'skip'; + const existing = findBid(opts.name, [opts.bidNo, opts.official || '']); + if (existing) { + const result = ['WON', 'LOST', 'FAILED'].includes(existing.status); + const sameNo = existing.bidNo === opts.bidNo; + if (opts.status === 'TERMINATED' && !sameNo && !result && existing.status !== 'TERMINATED') { + // 同名在途单不因回收站记录被误杀,下面另建终止单 + } else { + const nextStatus = result ? existing.status : opts.status === 'TERMINATED' || existing.status === 'TERMINATED' ? 'TERMINATED' : opts.status; + await prisma.bidCase.update({ + where: { id: existing.id }, + data: { + applyStartAt: opts.applyStart || existing.applyStartAt, + applyEndAt: opts.applyEnd || existing.applyEndAt, + applyMethod: opts.applyMethod || existing.applyMethod, + openMethod: opts.openMethod || existing.openMethod, + openAt: opts.openAt || existing.openAt, + openPlace: opts.openPlace || existing.openPlace, + projectType: mapProjectType(opts.projectType, opts.name) || existing.projectType, + tenderMethod: inferTenderMethod(opts.name, opts.tenderMethod) || existing.tenderMethod, + status: nextStatus, + }, + }); + return 'updated'; + } + } + let bidNo = opts.bidNo; + if (!bidNo || usedNos.has(bidNo)) bidNo = opts.official || opts.bidNo; + if (usedNos.has(bidNo)) bidNo = `${opts.bidNo}-${Date.now().toString().slice(-4)}`; + usedNos.add(bidNo); + const created = await prisma.bidCase.create({ + data: { + bidNo, + name: clip(opts.name, 200), + externalNo: opts.official || undefined, + projectType: mapProjectType(opts.projectType, opts.name), + sourceType: 'FLOW', + source: clip(opts.source, 50) || undefined, + qualificationNeed: clip(opts.qualification, 40) || undefined, + legalEntityId: (opts.entityName && entityByName.get(opts.entityName)) || entity.id, + applyStartAt: opts.applyStart || undefined, + applyEndAt: opts.applyEnd || undefined, + applyMethod: clip(opts.applyMethod, 50) || undefined, + openAt: opts.openAt || undefined, + openMethod: clip(opts.openMethod, 50) || undefined, + openPlace: clip(opts.openPlace, 200) || undefined, + tenderMethod: inferTenderMethod(opts.name, opts.tenderMethod) || undefined, + status: opts.status, + createdById: userByName.get(opts.createdByName || '') || admin.id, + createdAt: opts.createdAt || undefined, + }, + }); + bids.push(created); + return 'created'; + } + + const created = { drafting: 0, screening: 0, terminated: 0, other: 0, updated: 0, skipped: 0 }; + const tasks = (flow.bid_document_task || []).filter((t) => str(t.project_name)); + for (const t of tasks) { + const pre = preByTask.get(String(t.id)) || preByName.get(str(t.project_name)); + const tender = t.tender_id != null ? tenderById.get(String(t.tender_id)) : undefined; + const deleted = num(t.is_deleted) === 1; + const status = deleted + ? 'TERMINATED' + : tender + ? undefined + : mapDocStatus(num(t.workflow_stage), num(t.approval_status)); + if (!status && tender) { + const r = await ensureBid({ + bidNo: str(t.task_no), + name: str(t.project_name), + official: officialNo(t.project_number) || officialNo(tender.project_number), + status: 'PENDING', + openAt: when(t.bid_open_time) || when(tender.bid_open_date), + openPlace: clip(t.bid_open_location, 200), + applyStart: when(pre?.file_apply_time_start) || when(tender.bid_prepare_date), + applyEnd: when(pre?.file_apply_time_end) || when(t.file_apply_time_end) || when(tender.file_apply_deadline), + applyMethod: str(pre?.apply_method), + openMethod: str(pre?.bid_open_method), + projectType: str(pre?.screening_category || t.screening_category || t.project_type || tender.project_type), + source: str(t.project_source || pre?.project_source), + qualification: str(pre?.qualification_require), + entityName: str(pre?.bid_entity), + createdAt: when(t.created_time), + createdByName: str(t.dispatcher_name), + tenderMethod: str(t.bid_method || tender.tender_method), + }); + if (r === 'updated') created.updated += 1; + else if (r === 'skip') created.skipped += 1; + continue; + } + const next = status || 'DRAFTING'; + const r = await ensureBid({ + bidNo: str(t.task_no), + name: str(t.project_name), + official: officialNo(t.project_number), + status: next, + openAt: when(t.bid_open_time), + openPlace: clip(t.bid_open_location, 200), + applyStart: when(pre?.file_apply_time_start), + applyEnd: when(pre?.file_apply_time_end) || when(t.file_apply_time_end), + applyMethod: str(pre?.apply_method), + openMethod: str(pre?.bid_open_method), + projectType: str(pre?.screening_category || t.screening_category || t.project_type || pre?.project_type), + source: str(t.project_source || pre?.project_source), + qualification: str(pre?.qualification_require), + entityName: str(pre?.bid_entity), + createdAt: when(t.created_time), + createdByName: str(t.dispatcher_name), + tenderMethod: str(t.bid_method), + }); + if (r === 'created') { + if (next === 'DRAFTING') created.drafting += 1; + else if (next === 'TERMINATED') created.terminated += 1; + else created.other += 1; + } else if (r === 'updated') created.updated += 1; + else created.skipped += 1; + } + + for (const p of allPreinvests) { + if (!str(p.project_name) || !str(p.apply_no)) continue; + const deleted = num(p.deleted) === 1; + const status = deleted ? 'TERMINATED' : mapPreinvestStatus(num(p.workflow_stage), num(p.approval_status)); + if (!status) continue; + const r = await ensureBid({ + bidNo: str(p.apply_no), + name: str(p.project_name), + official: officialNo(p.project_number), + status, + openAt: when(p.bid_open_time), + openPlace: clip(p.bid_open_location, 200), + applyStart: when(p.file_apply_time_start), + applyEnd: when(p.file_apply_time_end), + applyMethod: str(p.apply_method), + openMethod: str(p.bid_open_method), + projectType: str(p.screening_category || p.project_type), + source: str(p.project_source || p.entry_source), + qualification: str(p.qualification_require), + entityName: str(p.bid_entity), + createdAt: when(p.created_time), + createdByName: str(p.applicant_name), + tenderMethod: str(p.bid_method), + }); + if (r === 'created') { + if (status === 'TERMINATED') created.terminated += 1; + else created.screening += 1; + } else if (r === 'updated') created.updated += 1; + else created.skipped += 1; + } + + const itemsByReimb = new Map(); + for (const item of flow.reimbursement_item || []) { + const key = String(item.reimbursement_id); + if (!itemsByReimb.has(key)) itemsByReimb.set(key, []); + itemsByReimb.get(key)!.push(item); + } + + let expensesUpserted = 0; + for (const r of flow.reimbursement || []) { + const claimNo = str(r.reimburse_no); + if (!claimNo) continue; + const lines = itemsByReimb.get(String(r.id)) || []; + const main = [...lines].sort((a, b) => num(b.amount) - num(a.amount))[0]; + const itemType = str(main?.item_type); + const applicantId = userByName.get(str(r.employee_name)) || admin.id; + const payerId = userByName.get(str(r.payer_name)) || undefined; + const projectId = r.project_id != null ? projectByOaId.get(String(r.project_id)) : projectByName.get(str(r.project_name)); + const bidHit = findBid(str(r.project_name), []); + const bidCaseId = isBidExpense(itemType) || (!projectId && bidHit) ? bidHit?.id : undefined; + const costType = bidCaseId ? 'BID' : projectId ? 'PROJECT' : 'DEPT'; + const data = { + applicantId, + costType, + budgetCategory: costType === 'PROJECT' ? 'TRAVEL' : undefined, + projectId: costType === 'PROJECT' ? projectId : undefined, + bidCaseId: costType === 'BID' ? bidCaseId : undefined, + expenseCategory: mapItemCategory(itemType), + purpose: clip(r.subject || r.project_name, 200) || undefined, + amount: new Prisma.Decimal(num(r.total_amount)), + status: mapExpenseStatus(r.approval_status), + remark: clip(r.remark, 1000) || undefined, + submittedAt: when(r.created_time) || undefined, + decidedAt: str(r.approval_status) === '2' ? when(r.updated_time) || when(r.created_time) : undefined, + decidedById: str(r.approval_status) === '2' ? admin.id : undefined, + prepaid: Boolean(payerId && payerId !== applicantId), + payerId: payerId && payerId !== applicantId ? payerId : undefined, + }; + const existed = await prisma.expenseClaim.findFirst({ where: { tenantId: '1', claimNo } }); + const row = existed + ? await prisma.expenseClaim.update({ where: { id: existed.id }, data }) + : await prisma.expenseClaim.create({ data: { claimNo, ...data } }); + await prisma.expenseLine.deleteMany({ where: { claimId: row.id } }); + for (const [i, line] of lines.entries()) { + await prisma.expenseLine.create({ + data: { + claimId: row.id, + kind: mapItemKind(str(line.item_type), str(line.remark)), + amount: new Prisma.Decimal(num(line.amount)), + invoiceNo: clip(line.invoice_no, 80) || undefined, + occurAt: when(line.expense_date) || undefined, + remark: clip(line.remark || line.item_type, 200) || undefined, + sortNo: num(line.sort_order) || i, + }, + }); + } + expensesUpserted += 1; + } + + let loansUpserted = 0; + for (const r of flow.borrow_advance || []) { + const loanNo = str(r.advance_no); + if (!loanNo) continue; + const applicantId = userByName.get(str(r.employee_name)) || admin.id; + const projectId = r.project_id != null ? projectByOaId.get(String(r.project_id)) : projectByName.get(str(r.project_name)); + const data = { + applicantId, + costType: projectId ? 'PROJECT' : 'DEPT', + projectId, + amount: new Prisma.Decimal(num(r.amount)), + offsetAmount: new Prisma.Decimal(num(r.offset_amount)), + purpose: clip(r.reason || r.subject, 200) || 'OA 借款', + status: mapLoanStatus(r), + submittedAt: when(r.apply_time) || when(r.created_time) || undefined, + }; + const existed = await prisma.loanRecord.findFirst({ where: { tenantId: '1', loanNo } }); + if (existed) await prisma.loanRecord.update({ where: { id: existed.id }, data }); + else await prisma.loanRecord.create({ data: { loanNo, ...data } }); + loansUpserted += 1; + } + + const bidCounts = await prisma.bidCase.groupBy({ by: ['status'], _count: true }); + const expCounts = await prisma.expenseClaim.groupBy({ by: ['status'], _count: true }); + const loanCounts = await prisma.loanRecord.groupBy({ by: ['status'], _count: true }); + console.log( + JSON.stringify( + { applyPatched, created, expensesUpserted, loansUpserted, bidCounts, expCounts, loanCounts }, + null, + 2, + ), + ); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/prisma/wash-oa-ledger.ts b/apps/api/prisma/wash-oa-ledger.ts new file mode 100644 index 0000000..192dd07 --- /dev/null +++ b/apps/api/prisma/wash-oa-ledger.ts @@ -0,0 +1,563 @@ +import { Prisma, PrismaClient } from '@prisma/client'; +import * as fs from 'fs'; + +const prisma = new PrismaClient(); +const DUMP = '/opt/import/prod/oa_fresh.json'; + +type Row = Record; + +function str(v: unknown) { + if (v == null) return ''; + return String(v).trim(); +} + +function num(v: unknown) { + if (v == null || v === '') return 0; + const n = Number(String(v).replace(/,/g, '')); + return Number.isFinite(n) ? n : 0; +} + +function when(v: unknown) { + const s = str(v); + if (!s || s.startsWith('0000')) return null; + const d = new Date(s.includes('T') ? s : s.replace(' ', 'T')); + return Number.isNaN(d.getTime()) ? null : d; +} + +function clip(v: unknown, n = 200) { + const s = str(v); + return s.length > n ? s.slice(0, n) : s; +} + +function realPartyName(raw: unknown) { + let name = str(raw).replace(/\s+/g, ' '); + if (!name) return ''; + const alias: Record = { + '73047': '中国人民解放军73047部队保障部', + '中国人民解放军 32272 部队': '中国人民解放军32272部队', + }; + name = alias[name] || name; + if (/^未指定/.test(name)) return ''; + if (/^\d+$/.test(name)) return ''; + if (name.startsWith('/') || name.endsWith('.pdf')) return ''; + if (name.length < 2) return ''; + return name; +} + +function compactName(name: string) { + return str(name) + .replace(/[()()\[\]【】\s\-—_.,,、]/g, '') + .replace(/招标公告|采购项目|技术服务合同|合同/g, '') + .replace(/检查院/g, '检察院'); +} + +function namesOverlap(a: string, b: string) { + const na = compactName(a); + const nb = compactName(b); + if (!na || !nb) return false; + if (na === nb) return true; + const ka = groupKey(a); + const kb = groupKey(b); + if (ka && ka === kb && ka.length >= 3 && ka !== na && ka !== nb) return true; + const shorter = na.length <= nb.length ? na : nb; + const longer = na.length <= nb.length ? nb : na; + return shorter.length >= 8 && longer.includes(shorter); +} + +function groupKey(name: string) { + const anchors = [ + '信息化教材管理服务平台', + '教务管理系统国产化', + '中船建模', + '课程录播资源管理平台', + '九江市国动办', + '北京顺义', + '天长市', + '国防科技大学试验训练', + '银川低空', + '无锡联勤', + '南京军代', + '日常办公(江苏', + '日常办公(南京', + '数字人', + '沉浸式体验区', + '某演练', + '某单位陈列室', + 'OA网络教学', + '某部软件系统定制', + '院感及互联互通', + '财经管控', + '智能控制系统', + '某部门户网站', + ]; + const hit = anchors.find((a) => name.includes(a)); + return hit || compactName(name) || name; +} + +function mapBidStatus(row: Row) { + const st = str(row.project_status); + const win = str(row.winning_status); + if (st === '中标' || (st === '' && win.includes('中标'))) return 'WON'; + if (st === '未中' || (st === '' && win.includes('未中'))) return 'LOST'; + if (st === '流标') return 'FAILED'; + if (st === '弃标' || win.includes('弃标')) return 'TERMINATED'; + return ''; +} + +function mapContractStatus(v: unknown) { + const s = str(v); + if (s === '已通过') return 'APPROVED'; + if (s === '审批中') return 'PENDING'; + return 'DRAFT'; +} + +function mapProjectStatus(v: unknown) { + const s = str(v); + if (s === '验收通过') return 'ACCEPTED'; + if (s === '已完成') return 'DONE'; + if (s === '待验收') return 'INTERNAL_ACCEPTED'; + if (s === '未开始') return 'NOT_STARTED'; + return 'ACTIVE'; +} + +const STATUS_RANK: Record = { + 验收通过: 5, + 已完成: 4, + 待验收: 3, + 进行中: 2, + 未开始: 1, +}; + +function officialNo(raw: unknown) { + const n = str(raw).replace(/[))]+$/, '').trim(); + if (!n || n === '无' || n === '-' || n === '—') return ''; + return n; +} + +function mapProjectType(v: unknown, projectName?: unknown) { + const name = str(projectName); + if (name.includes('三维')) return '三维'; + if (/(视频.{0,12}(制作|摄制|拍摄)|摄制|拍摄|摄影|宣传片|专题片|纪录片|微电影|影视制作)/.test(name)) return '摄制'; + const s = str(v); + if (s.includes('三维')) return '三维'; + if (s.includes('摄制') || s.includes('视频')) return '摄制'; + if (s.includes('物资')) return '物资'; + if (s.includes('集成')) return '集成'; + return '软件'; +} + +function isRealTender(row: Row) { + return /^TB-\d/.test(str(row.system_number)) && Boolean(str(row.project_name)) && !str(row.project_name).startsWith('/profile/'); +} + +function isGenericContractName(name: string) { + return compactName(name) === '采购' || str(name) === '采购合同'; +} + +async function ensureParty(name: string, partyByName: Map, remark: string) { + const existing = partyByName.get(name); + if (existing) return existing; + const row = await prisma.businessParty.create({ data: { name, remark, level: 'B' } }); + partyByName.set(name, row.id); + return row.id; +} + +type MergedProject = { + systemNumber: string; + name: string; + status: string; + oaStatus: string; + contractProdId: string; + contractName: string; + budget: number; + createdAt: Date | null; +}; + +function mergeProjects(rows: Row[]): MergedProject[] { + const groups = new Map(); + for (const p of rows) { + const name = str(p.project_name); + if (!name || !str(p.system_number)) continue; + const key = groupKey(name); + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(p); + } + const out: MergedProject[] = []; + for (const items of groups.values()) { + const withContract = items.find((p) => p.contract_id != null && str(p.contract_id)); + const progressed = [...items].sort((a, b) => (STATUS_RANK[str(b.project_status)] || 0) - (STATUS_RANK[str(a.project_status)] || 0))[0]; + const pick = withContract || progressed; + out.push({ + systemNumber: str(pick.system_number), + name: str(progressed.project_name), + status: mapProjectStatus(progressed.project_status), + oaStatus: str(progressed.project_status), + contractProdId: withContract ? str(withContract.contract_id) : '', + contractName: str((withContract || pick).contract_name), + budget: Math.max(...items.map((p) => num(p.project_budget))), + createdAt: when(pick.create_time), + }); + } + return out; +} + +async function main() { + const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record; + const admin = await prisma.user.findFirst({ where: { username: 'admin' } }); + if (!admin) throw new Error('本地没有 admin'); + const users = await prisma.user.findMany({ select: { id: true, displayName: true, username: true } }); + const userByName = new Map(users.map((u) => [u.displayName, u.id])); + const xia = userByName.get('夏雨晴'); + const entity = + (await prisma.legalEntity.findFirst({ where: { tenantId: '1', isDefault: true } })) || + (await prisma.legalEntity.findFirst({ where: { tenantId: '1' } })); + if (!entity) throw new Error('本地没有主体'); + + const stale = await prisma.contract.findMany({ + where: { contractNo: { startsWith: 'HT-2026-00' } }, + include: { + _count: { + select: { + projects: true, + invoices: true, + bonds: true, + sealRequests: true, + milestones: true, + reviewers: true, + actionLogs: true, + }, + }, + }, + }); + const staleIds = stale.filter((c) => Object.values(c._count).every((n) => n === 0)).map((c) => c.id); + if (staleIds.length) await prisma.contract.deleteMany({ where: { id: { in: staleIds } } }); + + const tenders = (raw.oa_tender || []).filter(isRealTender); + const contracts = raw.oa_contract || []; + const mergedProjects = mergeProjects(raw.project_list || []); + + const parties = await prisma.businessParty.findMany(); + const partyByName = new Map(parties.map((p) => [p.name, p.id])); + + for (const t of tenders) { + const n = realPartyName(t.tender_dan_wei); + if (n) await ensureParty(n, partyByName, 'OA 招标单位'); + } + for (const c of contracts) { + const n = realPartyName(c.party_a_name); + if (n && n !== '江苏风影随行科技有限公司') await ensureParty(n, partyByName, 'OA 合同甲方'); + } + + const bids = await prisma.bidCase.findMany(); + const bidByNo = new Map(bids.map((b) => [b.bidNo, b])); + let bidsUpdated = 0; + let bidsCreated = 0; + const usedNos = new Set(bids.map((b) => b.bidNo)); + + function findExistingBid(t: Row) { + const sys = str(t.system_number); + const official = officialNo(t.project_number); + const suffix = sys.split('-').pop() || ''; + const name = str(t.project_name); + const all = [...bidByNo.values()]; + return ( + bidByNo.get(sys) || + all.find((b) => b.externalNo === sys) || + all.find((b) => b.bidNo === `${official}-${suffix}` || (official && b.bidNo.endsWith(`-${suffix}`) && b.name === name)) || + (official && all.filter((b) => b.bidNo === official || b.externalNo === official).length === 1 + ? all.find((b) => b.bidNo === official || b.externalNo === official) + : undefined) + ); + } + + const testNos = ['SP-TEST-1788068192', 'SP-FIX-1788068273']; + const tests = bids.filter((b) => testNos.includes(b.bidNo) || testNos.includes(b.externalNo || '')); + if (tests.length) { + const ids = tests.map((b) => b.id); + await prisma.todoTask.deleteMany({ where: { bizId: { in: ids } } }); + await prisma.approvalTask.deleteMany({ where: { bizId: { in: ids } } }); + await prisma.bidCase.deleteMany({ where: { id: { in: ids } } }); + } + + for (const t of tenders) { + const sys = str(t.system_number); + const official = officialNo(t.project_number); + const name = clip(t.project_name, 200); + const partyName = realPartyName(t.tender_dan_wei); + const status = mapBidStatus(t) || 'PENDING'; + const existing = findExistingBid(t); + const resultBits = [ + t.winning_unit ? `中标单位:${t.winning_unit}` : '', + num(t.winning_amount) ? `中标金额:${num(t.winning_amount)}` : '', + str(t.opening_remark), + str(t.bid_summary), + ].filter(Boolean); + + if (existing) { + let bidNo = official || sys; + if (usedNos.has(bidNo) && bidNo !== existing.bidNo) bidNo = `${official || sys}-${sys.slice(-3)}`; + usedNos.delete(existing.bidNo); + usedNos.add(bidNo); + await prisma.bidCase.update({ + where: { id: existing.id }, + data: { + bidNo, + name, + externalNo: official || null, + status, + sourceType: 'FLOW', + partyId: partyName ? partyByName.get(partyName) : existing.partyId, + tenderMethod: clip(t.tender_method, 50) || existing.tenderMethod, + openPlace: clip(t.tender_address, 200) || existing.openPlace, + priceCap: t.price_limit == null ? existing.priceCap : String(t.price_limit), + quoteAmount: t.bid_quotation == null ? existing.quoteAmount : String(t.bid_quotation), + resultNote: resultBits.join(';') || existing.resultNote, + openedResultAt: when(t.winning_date) || when(t.bid_open_date) || existing.openedResultAt, + }, + }); + existing.bidNo = bidNo; + existing.name = name; + existing.status = status; + existing.externalNo = official || existing.externalNo; + bidByNo.set(bidNo, existing); + bidByNo.set(sys, existing); + bidsUpdated += 1; + } else { + let bidNo = official || sys; + if (usedNos.has(bidNo)) bidNo = `${sys}`; + if (usedNos.has(bidNo)) bidNo = `${sys}-${Date.now().toString().slice(-4)}`; + usedNos.add(bidNo); + const row = await prisma.bidCase.create({ + data: { + bidNo, + name, + externalNo: official || undefined, + projectType: mapProjectType(t.project_type, t.project_name), + legalEntityId: entity.id, + partyId: partyName ? partyByName.get(partyName) : undefined, + tenderMethod: clip(t.tender_method, 50) || undefined, + applyEndAt: when(t.file_apply_deadline) || undefined, + openAt: when(t.bid_open_date) || undefined, + openPlace: clip(t.tender_address, 200) || undefined, + securityLevel: str(t.security_level) || 'INTERNAL', + projectNature: clip(t.project_nature, 50) || undefined, + contactName: clip(t.bid_contact_person, 64) || undefined, + contactPhone: clip(t.bid_contact_phone, 32) || undefined, + priceCap: t.price_limit == null ? undefined : String(t.price_limit), + quoteAmount: t.bid_quotation == null ? undefined : String(t.bid_quotation), + resultNote: resultBits.join(';') || undefined, + openedResultAt: when(t.winning_date) || when(t.bid_open_date) || undefined, + status, + createdById: admin.id, + createdAt: when(t.create_time) || undefined, + }, + }); + bidByNo.set(bidNo, row); + bidByNo.set(sys, row); + bidsCreated += 1; + } + } + + const localContracts = await prisma.contract.findMany(); + const contractByNo = new Map(localContracts.map((c) => [c.contractNo, c])); + const contractByProd = new Map(); + let contractsUpdated = 0; + let contractsCreated = 0; + + for (const c of contracts) { + const contractNo = str(c.contract_no); + if (!contractNo) continue; + const tb = contractNo.match(/TB-\d+-\d+/); + const bid = + (tb && (bidByNo.get(tb[0]) || [...bidByNo.values()].find((b) => b.bidNo === tb[0] || b.bidNo.startsWith(tb[0])))) || + [...bidByNo.values()].find((b) => namesOverlap(b.name, str(c.contract_name))); + if (str(c.approval_status) === 'pending_submit' && !c.sign_date && !num(c.amount)) { + continue; + } + const partyA = realPartyName(c.party_a_name); + const partyB = str(c.party_b_name); + const remark = [ + str(c.payment_method) ? `付款:${c.payment_method}` : '', + str(c.contract_lb) ? `类别:${c.contract_lb}` : '', + str(c.remark), + ] + .filter(Boolean) + .join(' / '); + const handlerId = Number(c.create_by) === 107 ? xia : bid?.createdById || xia || admin.id; + const data = { + name: clip(c.contract_name, 200), + bidCaseId: bid?.id, + legalEntityId: entity.id, + amount: new Prisma.Decimal(num(c.amount)), + status: mapContractStatus(c.approval_status), + partyA: partyA || undefined, + partyB: partyB || undefined, + remark: remark || undefined, + signedAt: when(c.sign_date) || undefined, + createdById: handlerId, + }; + const existing = + contractByNo.get(contractNo) || + localContracts.find( + (row) => row.name === clip(c.contract_name, 200) && /^HT-2026-00\d+$/.test(row.contractNo), + ); + if (existing) { + const nextNo = contractByNo.has(contractNo) && existing.contractNo !== contractNo ? existing.contractNo : contractNo; + await prisma.contract.update({ + where: { id: existing.id }, + data: { ...data, contractNo: nextNo }, + }); + existing.contractNo = nextNo; + contractByNo.set(nextNo, existing); + contractByProd.set(String(c.id), existing.id); + contractsUpdated += 1; + } else { + const row = await prisma.contract.create({ + data: { + contractNo, + createdAt: when(c.create_time) || undefined, + ...data, + }, + }); + contractByNo.set(contractNo, row); + contractByProd.set(String(c.id), row.id); + contractsCreated += 1; + } + } + + const localProjects = await prisma.project.findMany({ include: { budgets: true } }); + const usedProjectNos = new Set(localProjects.map((p) => p.projectNo)); + let projectsUpdated = 0; + let projectsCreated = 0; + const projectMoves: { name: string; from: string; to: string; no: string }[] = []; + + function findLocalProject(p: MergedProject) { + return ( + localProjects.find((row) => row.projectNo === p.systemNumber) || + localProjects.find((row) => namesOverlap(row.name, p.name) || groupKey(row.name) === groupKey(p.name)) + ); + } + + function findContractId(p: MergedProject) { + if (p.contractProdId && contractByProd.has(p.contractProdId)) return contractByProd.get(p.contractProdId); + if (p.contractName) { + const exact = [...contractByNo.values()].find((c) => c.name === p.contractName); + if (exact) return exact.id; + } + const fuzzy = [...contractByNo.values()].find( + (c) => !isGenericContractName(c.name) && namesOverlap(c.name, p.name), + ); + return fuzzy?.id; + } + + for (const p of mergedProjects) { + const existing = findLocalProject(p); + const contractId = findContractId(p); + if (existing) { + let projectNo = p.systemNumber; + if (usedProjectNos.has(projectNo) && projectNo !== existing.projectNo) { + projectNo = existing.projectNo; + } + usedProjectNos.delete(existing.projectNo); + usedProjectNos.add(projectNo); + if (existing.status !== p.status || existing.projectNo !== projectNo) { + projectMoves.push({ name: p.name.slice(0, 24), from: `${existing.projectNo}/${existing.status}`, to: `${projectNo}/${p.status}`, no: p.oaStatus }); + } + await prisma.project.update({ + where: { id: existing.id }, + data: { + projectNo, + name: clip(p.name, 200), + contractId, + status: p.status, + }, + }); + if (p.budget > 0) { + await prisma.projectBudget.upsert({ + where: { projectId_category: { projectId: existing.id, category: 'PURCHASE' } }, + update: { amount: new Prisma.Decimal(p.budget) }, + create: { projectId: existing.id, category: 'PURCHASE', amount: new Prisma.Decimal(p.budget) }, + }); + } + existing.projectNo = projectNo; + existing.name = p.name; + existing.status = p.status; + projectsUpdated += 1; + } else { + if (!contractId && /招标公告/.test(p.name) && p.status === 'NOT_STARTED') { + continue; + } + let projectNo = p.systemNumber; + if (usedProjectNos.has(projectNo)) projectNo = `${p.systemNumber}-2`; + usedProjectNos.add(projectNo); + const row = await prisma.project.create({ + data: { + projectNo, + name: clip(p.name, 200), + contractId, + status: p.status, + createdAt: p.createdAt || undefined, + }, + }); + if (p.budget > 0) { + await prisma.projectBudget.create({ + data: { projectId: row.id, category: 'PURCHASE', amount: new Prisma.Decimal(p.budget) }, + }); + } + localProjects.push({ ...row, budgets: [] }); + projectsCreated += 1; + } + } + + const caijing = await prisma.bidCase.findMany({ + where: { name: { contains: '财经管控' } }, + include: { _count: { select: { contracts: true, expenses: true, bonds: true, assignees: true, versions: true } } }, + }); + if (caijing.length) { + const won = caijing.find((b) => /196/.test(b.bidNo) || b._count.contracts > 0); + const extras = caijing.filter((b) => b.id !== won?.id); + const keepFailed = extras.find((b) => Object.values(b._count).some((n) => n > 0)) || extras[0]; + if (keepFailed) { + await prisma.bidCase.update({ + where: { id: keepFailed.id }, + data: { status: 'FAILED', bidNo: keepFailed.bidNo.includes('182') ? keepFailed.bidNo : '2026-JLDJAE-W3006-182' }, + }); + } + if (won && won.status !== 'WON') { + await prisma.bidCase.update({ where: { id: won.id }, data: { status: 'WON' } }); + } + const drop = extras.filter((b) => b.id !== keepFailed?.id && Object.values(b._count).every((n) => n === 0)).map((b) => b.id); + if (drop.length) await prisma.bidCase.deleteMany({ where: { id: { in: drop } } }); + } + + const bidCounts = await prisma.bidCase.groupBy({ by: ['status'], _count: true }); + const projectCounts = await prisma.project.groupBy({ by: ['status'], _count: true }); + const linked = await prisma.project.count({ where: { contractId: { not: null } } }); + console.log( + JSON.stringify( + { + tenders: tenders.length, + bidsUpdated, + bidsCreated, + contractsUpdated, + contractsCreated, + projectsMerged: mergedProjects.length, + projectsUpdated, + projectsCreated, + projectMoves, + bidCounts, + projectCounts, + projectsWithContract: linked, + }, + null, + 2, + ), + ); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/prisma/wash-terminated-winners.ts b/apps/api/prisma/wash-terminated-winners.ts new file mode 100644 index 0000000..f6863e3 --- /dev/null +++ b/apps/api/prisma/wash-terminated-winners.ts @@ -0,0 +1,186 @@ +import { Prisma, PrismaClient } from '@prisma/client'; +import * as fs from 'fs'; + +const prisma = new PrismaClient(); +const DUMP = '/opt/import/prod/oa_flow_finance.json'; +const APPLY = process.argv.includes('--apply'); + +type Row = Record; +type Bid = { + id: string; + bidNo: string; + name: string; + externalNo: string | null; + status: string; + winnerName: string | null; + winnerAmount: Prisma.Decimal | null; + winnerNote: string | null; +}; + +function str(v: unknown) { + if (v == null) return ''; + return String(v).trim(); +} + +function num(v: unknown) { + if (v == null || v === '') return null; + const n = Number(String(v).replace(/,/g, '')); + if (!Number.isFinite(n) || n === 0) return null; + return n; +} + +function compactName(name: string) { + return str(name) + .replace(/\s+/g, '') + .replace(/[((].*?[))]/g, '') + .replace(/招标公告|采购公告|询价公告|竞争性谈判公告|谈判公告|比价公告|需求公示|意向公开/g, ''); +} + +function isOwnWinner(name: string) { + return /风影|随行科技|山东鲁兵/.test(name) || name === '隆创'; +} + +function officialNo(raw: unknown) { + const n = str(raw).replace(/[))]+$/, '').trim(); + if (!n || n === '无' || n === '-' || n === '—') return ''; + return n; +} + +function pickUnused(cands: Bid[], used: Set) { + const open = cands.filter((b) => !used.has(b.id)); + if (!open.length) return null; + const yt = open.find((b) => b.bidNo.startsWith('YT-')); + return yt || open[0]; +} + +async function main() { + if (!fs.existsSync(DUMP)) { + throw new Error(`找不到 OA 备份 ${DUMP}`); + } + const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record; + const pres = raw.project_preinvest_apply || []; + const sources = pres.filter((p) => { + const deleted = str(p.deleted) === '1'; + const winner = str(p.winning_unit); + const cat = str(p.screening_category) || str(p.project_type); + return deleted && winner && cat.includes('软件'); + }); + + const bids = await prisma.bidCase.findMany({ + where: { tenantId: '1', status: 'TERMINATED' }, + select: { + id: true, + bidNo: true, + name: true, + externalNo: true, + status: true, + winnerName: true, + winnerAmount: true, + winnerNote: true, + }, + }); + + const byNo = new Map(); + const byName = new Map(); + const byCompact = new Map(); + const add = (map: Map, key: string, row: Bid) => { + if (!key) return; + const list = map.get(key) || []; + list.push(row); + map.set(key, list); + }; + for (const b of bids) { + add(byNo, b.bidNo, b); + add(byNo, str(b.externalNo), b); + add(byName, b.name, b); + add(byCompact, compactName(b.name), b); + } + + const used = new Set(); + const updated: { bidNo: string; name: string; winner: string; amount: number | null; via: string }[] = []; + const skippedSame: string[] = []; + const conflicts: { bidNo: string; have: string; want: string }[] = []; + const unmatched: { applyNo: string; name: string; winner: string }[] = []; + const ownSkipped: string[] = []; + + for (const p of sources) { + const applyNo = str(p.apply_no); + const name = str(p.project_name); + const official = officialNo(p.project_number); + const winner = str(p.winning_unit); + const amount = num(p.winning_amount); + if (isOwnWinner(winner)) { + ownSkipped.push(`${applyNo} ${winner}`); + continue; + } + + const hit = + pickUnused(byNo.get(applyNo) || [], used) || + pickUnused(byNo.get(official) || [], used) || + pickUnused(byName.get(name) || [], used) || + pickUnused(byCompact.get(compactName(name)) || [], used); + + if (!hit) { + unmatched.push({ applyNo, name, winner }); + continue; + } + used.add(hit.id); + + const haveName = str(hit.winnerName); + const haveAmt = hit.winnerAmount != null ? Number(hit.winnerAmount) : null; + if (haveName) { + const sameName = haveName === winner; + const sameAmt = amount == null || haveAmt === amount; + if (sameName && sameAmt) skippedSame.push(hit.bidNo); + else conflicts.push({ bidNo: hit.bidNo, have: `${haveName}/${haveAmt}`, want: `${winner}/${amount}` }); + continue; + } + + const note = str(hit.winnerNote) || 'OA 软件已删除迁入'; + if (APPLY) { + await prisma.bidCase.update({ + where: { id: hit.id }, + data: { + winnerName: winner, + winnerAmount: amount == null ? undefined : new Prisma.Decimal(amount), + winnerNote: note, + }, + }); + } + updated.push({ + bidNo: hit.bidNo, + name: hit.name, + winner, + amount, + via: hit.bidNo === applyNo ? 'bidNo' : str(hit.externalNo) === applyNo || hit.bidNo === official ? 'official' : 'name', + }); + } + + console.log( + JSON.stringify( + { + apply: APPLY, + dumpSources: sources.length, + terminated: bids.length, + filled: updated.length, + skippedSame: skippedSame.length, + conflicts: conflicts.length, + unmatched: unmatched.length, + ownSkipped: ownSkipped.length, + filledSample: updated.slice(0, 8), + conflictRows: conflicts, + unmatchedRows: unmatched.slice(0, 20), + tinyAmounts: updated.filter((u) => u.amount != null && u.amount < 100), + }, + null, + 2, + ), + ); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..7087371 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,93 @@ +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; +import { loadEnv } from './config/env'; +import { PrismaModule } from './prisma/prisma.module'; +import { AuthModule } from './auth/auth.module'; +import { SmsModule } from './sms/sms.module'; +import { ImModule } from './im/im.module'; +import { PushModule } from './push/push.module'; +import { HealthController } from './health/health.controller'; +import { ReportController } from './report/report.controller'; +import { ReportService } from './report/report.service'; +import { SystemController } from './system/system.controller'; +import { SystemService } from './system/system.service'; +import { OfficeController } from './office/office.controller'; +import { MasterController } from './master/master.controller'; +import { BiddingController } from './bidding/bidding.controller'; +import { BiddingService } from './bidding/bidding.service'; +import { ContractController } from './contract/contract.controller'; +import { ContractService } from './contract/contract.service'; +import { ProjectController } from './project/project.controller'; +import { ProjectService } from './project/project.service'; +import { SealController } from './seal/seal.controller'; +import { SealService } from './seal/seal.service'; +import { FinanceController } from './finance/finance.controller'; +import { FinanceService } from './finance/finance.service'; +import { BusinessController } from './business/business.controller'; +import { HrController } from './hr/hr.controller'; +import { HrService } from './hr/hr.service'; +import { MasterService } from './master/master.service'; +import { FilesController } from './files/files.controller'; +import { FilesModule } from './files/files.module'; +import { JwtAuthGuard, PermissionsGuard } from './common/guards/auth.guards'; +import { ResponseInterceptor } from './common/interceptors/response.interceptor'; +import { HttpErrorFilter } from './common/filters/http-exception.filter'; +import { RequestIdMiddleware } from './common/middleware/request-id.middleware'; +import { LegalController } from './legal/legal.controller'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: ['.env', '../../.env'], + validate: (raw) => loadEnv(raw), + }), + ThrottlerModule.forRoot({ + throttlers: [{ ttl: 60000, limit: 120 }], + }), + PrismaModule, + AuthModule, + SmsModule, + PushModule, + ImModule, + FilesModule, + ], + controllers: [ + HealthController, + SystemController, + OfficeController, + MasterController, + BiddingController, + ContractController, + ProjectController, + SealController, + FinanceController, + BusinessController, + HrController, + ReportController, + LegalController, + ], + providers: [ + BiddingService, + ContractService, + ProjectService, + SealService, + FinanceService, + HrService, + ReportService, + SystemService, + MasterService, + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: PermissionsGuard }, + { provide: APP_GUARD, useClass: ThrottlerGuard }, + { provide: APP_INTERCEPTOR, useClass: ResponseInterceptor }, + { provide: APP_FILTER, useClass: HttpErrorFilter }, + ], +}) +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(RequestIdMiddleware).forRoutes('*'); + } +} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..55ba6af --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,199 @@ +import { BadRequestException, Body, Controller, Get, Patch, Post, Query, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { Equals, IsOptional, IsString, MinLength } from 'class-validator'; +import type { Request } from 'express'; +import { AuthService } from './auth.service'; +import { Public } from '../common/decorators/auth.decorators'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { PrismaService } from '../prisma/prisma.service'; +import { SmsService } from '../sms/sms.service'; +import { LOG_ACTION, writeOpLog } from '../system/op-log'; +import { REQUEST_ID_HEADER } from '../common/middleware/request-id.middleware'; + +class LoginDto { + @IsString() + username!: string; + + @IsString() + @MinLength(6) + password!: string; + + @IsOptional() + @IsString() + smsCode?: string; + + @Transform(({ value }) => value === true || value === 'true' || value === 1 || value === '1') + @Equals(true, { message: '请先阅读并同意用户协议和隐私政策' }) + agreeTerms!: boolean; + + @IsOptional() + @IsString() + clientKind?: string; +} + +class RefreshDto { + @IsString() + refreshToken!: string; + + @IsOptional() + @IsString() + clientKind?: string; +} + +class ProfileDto { + @IsOptional() + @IsString() + displayName?: string; +} + +class PasswordDto { + @IsString() + oldPassword!: string; + @IsString() + @MinLength(6, { message: '新密码至少 6 位' }) + newPassword!: string; +} + +class OnboardProfileDto { + @IsOptional() @IsString() name?: string; + @IsOptional() @IsString() gender?: string; + @IsOptional() @IsString() email?: string; + @IsOptional() @IsString() mobile?: string; + @IsOptional() @IsString() birthday?: string; + @IsOptional() @IsString() ethnicity?: string; + @IsOptional() @IsString() maritalStatus?: string; + @IsOptional() @IsString() hukouAddress?: string; + @IsOptional() @IsString() liveAddress?: string; + @IsOptional() @IsString() emergencyName?: string; + @IsOptional() @IsString() emergencyPhone?: string; + @IsOptional() @IsString() education?: string; + @IsOptional() @IsString() school?: string; + @IsOptional() @IsString() major?: string; + @IsOptional() @IsString() graduatedAt?: string; + @IsOptional() @IsString() bankName?: string; + @IsOptional() @IsString() bankAccount?: string; + @IsOptional() @IsString() idType?: string; + @IsOptional() @IsString() idNo?: string; + @IsOptional() @IsString() idIssuer?: string; + @IsOptional() @IsString() idValidFrom?: string; + @IsOptional() @IsString() idValidTo?: string; +} + +@ApiTags('auth') +@Controller('auth') +export class AuthController { + constructor( + private readonly auth: AuthService, + private readonly prisma: PrismaService, + private readonly sms: SmsService, + ) {} + + @Public() + @Get('login-options') + loginOptions() { + return this.sms.loginOptions(); + } + + @Public() + @Post('sms-code') + smsCode(@Body() body: { username?: string }) { + return this.sms.sendLoginCode(body.username || ''); + } + + @Public() + @Post('login') + @ApiOperation({ summary: '登录' }) + async login(@Body() dto: LoginDto, @Req() req: Request) { + const requestId = (req.headers[REQUEST_ID_HEADER] as string) || ''; + try { + if (!dto.agreeTerms) { + throw new BadRequestException('请先阅读并同意用户协议和隐私政策'); + } + const result = await this.auth.login(dto.username, dto.password, dto.smsCode, dto.clientKind); + await writeOpLog(this.prisma, { + userId: result.user.id, + username: result.user.username, + action: LOG_ACTION.LOGIN, + path: '/auth/login', + detail: '登录成功', + requestId, + ip: req.ip, + }); + return result; + } catch (e) { + await writeOpLog(this.prisma, { + username: dto.username, + action: LOG_ACTION.LOGIN_FAIL, + path: '/auth/login', + detail: '用户名或密码错误', + success: false, + requestId, + ip: req.ip, + }); + throw e; + } + } + + @Public() + @Post('refresh') + @ApiOperation({ summary: '刷新令牌' }) + refresh(@Body() dto: RefreshDto) { + return this.auth.refresh(dto.refreshToken, dto.clientKind); + } + + @Post('logout') + @ApiBearerAuth() + logout(@CurrentUser() user: AuthUser, @Body() dto: Partial) { + return this.auth.logout(user.id, dto.refreshToken); + } + + @Public() + @Post('qr/start') + qrStart() { + return this.auth.startQrLogin(); + } + + @Public() + @Get('qr/status') + qrStatus(@Query('ticket') ticket?: string) { + return this.auth.qrStatus(ticket || ''); + } + + @Public() + @Post('qr/complete') + qrComplete(@Body() body: { ticket?: string; code?: string }) { + return this.auth.completeQrLogin(body.ticket || '', body.code || ''); + } + + @Post('qr/confirm') + @ApiBearerAuth() + qrConfirm(@CurrentUser() user: AuthUser, @Body() body: { ticket?: string }) { + return this.auth.confirmQrLogin(user.id, body.ticket || ''); + } + + @Get('me') + @ApiBearerAuth() + me(@CurrentUser() user: AuthUser) { + return this.auth.me(user.id); + } + + @Patch('profile') + @ApiBearerAuth() + updateProfile(@CurrentUser() user: AuthUser, @Body() dto: ProfileDto) { + return this.auth.updateProfile(user.id, dto); + } + + @Patch('password') + @ApiBearerAuth() + updatePassword(@CurrentUser() user: AuthUser, @Body() dto: PasswordDto) { + return this.auth.updatePassword(user.id, dto); + } + + @Patch('onboard-profile') + @ApiBearerAuth() + onboardProfile(@CurrentUser() user: AuthUser, @Body() dto: OnboardProfileDto) { + return this.auth.onboardProfile(user.id, dto); + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..cc56e5e --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtStrategy } from './jwt.strategy'; + +@Module({ + imports: [PassportModule, JwtModule.register({})], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..11ac34a --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -0,0 +1,532 @@ +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import * as argon2 from 'argon2'; +import { createHash, randomInt, randomUUID } from 'crypto'; +import { PrismaService } from '../prisma/prisma.service'; +import { Prisma } from '@prisma/client'; +import { SmsService } from '../sms/sms.service'; +import { elevateToAdmin } from '../common/role-groups'; + +function sha256(value: string) { + return createHash('sha256').update(value).digest('hex'); +} + +function needsOnboardGate(input: { + isAdmin: boolean; + mustChangePassword?: boolean | null; + profileCompletedAt?: Date | string | null; + employeeId?: string | null; + idPhotoFileId?: string | null; + idFrontFileId?: string | null; + idBackFileId?: string | null; + employee?: { idNo?: string | null; idType?: string | null } | null; +}) { + if (input.isAdmin) return false; + if (input.mustChangePassword) return true; + if (!input.profileCompletedAt) return true; + if (!input.employeeId) return false; + if (!input.idPhotoFileId || !input.idFrontFileId || !input.idBackFileId) return true; + if (input.employee && (!input.employee.idNo || !input.employee.idType)) return true; + return false; +} + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly jwt: JwtService, + private readonly config: ConfigService, + private readonly sms: SmsService, + ) {} + + async login(username: string, password: string, smsCode?: string, clientKind = 'web') { + const account = username.trim(); + const kind = normalizeClientKind(clientKind); + if (kind === 'desktop') { + throw new BadRequestException('电脑端请用手机扫码登录'); + } + const user = await this.prisma.user.findFirst({ + where: { + tenantId: '1', + OR: [{ username: account }, { employee: { is: { mobile: account } } }], + }, + include: { + roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } }, + employee: { include: { department: true } }, + }, + }); + if (!user || user.status !== 'ACTIVE') { + throw new UnauthorizedException('手机号或密码错误'); + } + const ok = await argon2.verify(user.passwordHash, password); + if (!ok) { + throw new UnauthorizedException('用户名或密码错误'); + } + await this.sms.assertLoginCode(account, smsCode); + return this.issueTokens(user, kind); + } + + async refresh(refreshToken: string, clientKindHint?: string) { + if (!refreshToken) throw new BadRequestException('缺少刷新令牌'); + let payload: { sub: string; typ: string; ck?: string }; + try { + payload = await this.jwt.verifyAsync(refreshToken, { + secret: this.config.get('JWT_REFRESH_SECRET'), + }); + } catch { + throw new UnauthorizedException('刷新令牌无效'); + } + if (payload.typ !== 'refresh') { + throw new UnauthorizedException('刷新令牌无效'); + } + const hash = sha256(refreshToken); + const stored = await this.prisma.refreshToken.findFirst({ + where: { userId: payload.sub, tokenHash: hash, revokedAt: null }, + }); + if (!stored || stored.expiresAt < new Date()) { + throw new UnauthorizedException('刷新令牌已失效'); + } + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + include: { + roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } }, + employee: { include: { department: true } }, + }, + }); + if (!user) throw new UnauthorizedException('用户不存在'); + const kind = normalizeClientKind(clientKindHint || stored.clientKind || payload.ck); + if (stored.clientKind !== kind) { + await this.prisma.refreshToken.update({ where: { id: stored.id }, data: { clientKind: kind } }); + } + if (kind === 'mobile') { + return this.issueTokens(user, kind, { keepRefresh: stored }); + } + await this.prisma.refreshToken.update({ + where: { id: stored.id }, + data: { revokedAt: new Date() }, + }); + return this.issueTokens(user, kind); + } + + async logout(userId: string, refreshToken?: string) { + if (refreshToken) { + await this.prisma.refreshToken.updateMany({ + where: { userId, tokenHash: sha256(refreshToken), revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } else { + await this.prisma.refreshToken.updateMany({ + where: { userId, clientKind: { in: ['web', 'desktop'] }, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + return { ok: true }; + } + + async updateProfile(userId: string, dto: { displayName?: string }) { + const name = dto.displayName?.trim(); + if (!name) throw new BadRequestException('请填写姓名'); + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { displayName: name }, + include: { employee: true }, + }); + if (user.employeeId) { + await this.prisma.employee.update({ + where: { id: user.employeeId }, + data: { name }, + }); + } + return this.me(userId); + } + + async updatePassword(userId: string, dto: { oldPassword: string; newPassword: string }) { + if (!dto.newPassword || dto.newPassword.length < 6) { + throw new BadRequestException('新密码至少 6 位'); + } + const user = await this.prisma.user.findFirst({ where: { id: userId, tenantId: '1' } }); + if (!user) throw new UnauthorizedException(); + const ok = await argon2.verify(user.passwordHash, dto.oldPassword || ''); + if (!ok) throw new BadRequestException('当前密码不正确'); + await this.prisma.user.update({ + where: { id: userId }, + data: { passwordHash: await argon2.hash(dto.newPassword), mustChangePassword: false }, + }); + return { ok: true }; + } + + async onboardProfile( + userId: string, + dto: { + name?: string; + gender?: string; + email?: string; + mobile?: string; + birthday?: string; + ethnicity?: string; + maritalStatus?: string; + hukouAddress?: string; + liveAddress?: string; + emergencyName?: string; + emergencyPhone?: string; + education?: string; + school?: string; + major?: string; + graduatedAt?: string; + bankName?: string; + bankAccount?: string; + idType?: string; + idNo?: string; + idIssuer?: string; + idValidFrom?: string; + idValidTo?: string; + }, + ) { + const user = await this.prisma.user.findFirst({ + where: { id: userId, tenantId: '1' }, + include: { roles: { include: { role: true } }, employee: true }, + }); + if (!user) throw new UnauthorizedException(); + const isAdmin = user.roles.some((r) => r.role.code === 'admin'); + if (isAdmin) return this.me(userId); + if (user.employeeId && user.employee) { + const name = dto.name?.trim() || user.employee.name; + if (!name) throw new BadRequestException('请填写姓名'); + if (!dto.mobile?.trim() && !user.employee.mobile) throw new BadRequestException('请填写手机号码'); + if (!dto.idType?.trim() && !user.employee.idType) throw new BadRequestException('请选择证件类型'); + if (!dto.idNo?.trim() && !user.employee.idNo) throw new BadRequestException('请填写证件号码'); + if (!dto.idIssuer?.trim() && !user.employee.idIssuer) throw new BadRequestException('请填写发证机关'); + if (!dto.idValidFrom && !user.employee.idValidFrom) throw new BadRequestException('请选择证件有效期起'); + if (!dto.idValidTo && !user.employee.idValidTo) throw new BadRequestException('请选择证件有效期止'); + if (!dto.education?.trim() && !user.employee.education) throw new BadRequestException('请填写学历'); + if (!dto.bankName?.trim() && !user.employee.bankName) throw new BadRequestException('请填写南京银行卡开户行'); + if (!dto.bankAccount?.trim() && !user.employee.bankAccount) throw new BadRequestException('请填写南京银行卡号'); + const scans = await this.prisma.fileAsset.findMany({ + where: { + tenantId: '1', + OR: [ + { bizType: 'USER_ID_PHOTO', bizId: userId }, + { bizType: { in: ['EMPLOYEE_ID_PHOTO', 'EMPLOYEE_ID_FRONT', 'EMPLOYEE_ID_BACK'] }, bizId: user.employeeId }, + ], + }, + }); + const hasPhoto = scans.some((f) => f.bizType === 'USER_ID_PHOTO' || f.bizType === 'EMPLOYEE_ID_PHOTO'); + const hasFront = scans.some((f) => f.bizType === 'EMPLOYEE_ID_FRONT'); + const hasBack = scans.some((f) => f.bizType === 'EMPLOYEE_ID_BACK'); + if (!hasPhoto) throw new BadRequestException('请上传证件照'); + if (!hasFront) throw new BadRequestException('请上传身份证正面'); + if (!hasBack) throw new BadRequestException('请上传身份证反面'); + await this.prisma.employee.update({ + where: { id: user.employeeId }, + data: { + name, + gender: dto.gender?.trim() || user.employee.gender, + email: dto.email?.trim() || user.employee.email, + mobile: dto.mobile?.trim() || user.employee.mobile, + birthday: dto.birthday ? new Date(dto.birthday) : user.employee.birthday, + ethnicity: dto.ethnicity?.trim() || user.employee.ethnicity, + maritalStatus: dto.maritalStatus?.trim() || user.employee.maritalStatus, + hukouAddress: dto.hukouAddress?.trim() || user.employee.hukouAddress, + liveAddress: dto.liveAddress?.trim() || user.employee.liveAddress, + emergencyName: dto.emergencyName?.trim() || user.employee.emergencyName, + emergencyPhone: dto.emergencyPhone?.trim() || user.employee.emergencyPhone, + idType: dto.idType?.trim() || user.employee.idType, + idNo: dto.idNo?.trim() || user.employee.idNo, + idIssuer: dto.idIssuer?.trim() || user.employee.idIssuer, + idValidFrom: dto.idValidFrom ? new Date(dto.idValidFrom) : user.employee.idValidFrom, + idValidTo: dto.idValidTo ? new Date(dto.idValidTo) : user.employee.idValidTo, + education: dto.education?.trim() || user.employee.education, + school: dto.school?.trim() || user.employee.school, + major: dto.major?.trim() || user.employee.major, + graduatedAt: dto.graduatedAt ? new Date(dto.graduatedAt) : user.employee.graduatedAt, + bankName: dto.bankName?.trim() || user.employee.bankName, + bankAccount: dto.bankAccount?.trim() || user.employee.bankAccount, + }, + }); + if (name !== user.displayName) { + await this.prisma.user.update({ where: { id: userId }, data: { displayName: name } }); + } + } + await this.prisma.user.update({ + where: { id: userId }, + data: { profileCompletedAt: new Date() }, + }); + return this.me(userId); + } + + async me(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { + roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } }, + employee: { include: { department: true } }, + }, + }); + if (!user) throw new UnauthorizedException(); + const roles = elevateToAdmin(user.roles.map((r) => r.role.code)); + const permissions = [ + ...new Set(user.roles.flatMap((r) => r.role.permissions.map((p) => p.permission.code))), + ]; + const photos = await this.prisma.fileAsset.findMany({ + where: { + tenantId: '1', + OR: [ + { bizId: user.id, bizType: { in: ['USER_AVATAR', 'USER_ID_PHOTO'] } }, + user.employeeId + ? { bizId: user.employeeId, bizType: { in: ['EMPLOYEE_ID_PHOTO', 'EMPLOYEE_ID_FRONT', 'EMPLOYEE_ID_BACK'] } } + : undefined, + ].filter(Boolean) as Prisma.FileAssetWhereInput[], + }, + orderBy: { createdAt: 'desc' }, + }); + const isAdmin = roles.includes('admin'); + const idPhotoFileId = + photos.find((f) => f.bizType === 'USER_ID_PHOTO' || f.bizType === 'EMPLOYEE_ID_PHOTO')?.id || null; + const idFrontFileId = photos.find((f) => f.bizType === 'EMPLOYEE_ID_FRONT')?.id || null; + const idBackFileId = photos.find((f) => f.bizType === 'EMPLOYEE_ID_BACK')?.id || null; + return { + id: user.id, + username: user.username, + displayName: user.displayName, + avatarFileId: photos.find((f) => f.bizType === 'USER_AVATAR')?.id || null, + idPhotoFileId, + idFrontFileId, + idBackFileId, + roles, + permissions, + employeeId: user.employeeId, + mustChangePassword: isAdmin ? false : user.mustChangePassword, + profileCompletedAt: user.profileCompletedAt, + needsOnboard: needsOnboardGate({ + isAdmin, + mustChangePassword: user.mustChangePassword, + profileCompletedAt: user.profileCompletedAt, + employeeId: user.employeeId, + idPhotoFileId, + idFrontFileId, + idBackFileId, + employee: user.employee, + }), + dataScope: isAdmin || roles.includes('owner') + ? 'ALL' + : user.roles.reduce( + (best, r) => { + const rank = { SELF: 0, DEPT: 1, ALL: 2 } as Record; + return (rank[r.role.dataScope] || 0) > (rank[best] || 0) ? r.role.dataScope : best; + }, + 'SELF', + ), + employee: user.employee, + }; + } + + private async issueTokens( + user: { + id: string; + username: string; + displayName: string; + employeeId?: string | null; + mustChangePassword?: boolean; + profileCompletedAt?: Date | null; + roles: { role: { code: string; permissions: { permission: { code: string; permType: string; path: string | null; name: string; icon: string | null; parentId: string | null; sortNo: number; visible: boolean; id: string } }[] } }[]; + employee: unknown; + }, + clientKind: ClientKind, + opts?: { keepRefresh?: { id: string; tokenHash: string; expiresAt: Date } }, + ) { + if (clientKind === 'web' || clientKind === 'desktop') { + await this.prisma.refreshToken.updateMany({ + where: { userId: user.id, clientKind, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + const accessTtl = clientKind === 'mobile' ? '7d' : '12h'; + const refreshTtl = clientKind === 'mobile' ? '3650d' : '2d'; + const sessionId = opts?.keepRefresh?.id || randomUUID(); + const accessToken = await this.jwt.signAsync( + { sub: user.id, username: user.username, typ: 'access', sid: sessionId, ck: clientKind }, + { + secret: this.config.get('JWT_ACCESS_SECRET'), + expiresIn: accessTtl, + }, + ); + let refreshToken = ''; + if (opts?.keepRefresh) { + refreshToken = ''; + } else { + refreshToken = await this.jwt.signAsync( + { sub: user.id, typ: 'refresh', jti: randomUUID(), ck: clientKind }, + { + secret: this.config.get('JWT_REFRESH_SECRET'), + expiresIn: refreshTtl, + }, + ); + const days = clientKind === 'mobile' ? 3650 : 2; + await this.prisma.refreshToken.create({ + data: { + id: sessionId, + userId: user.id, + tokenHash: sha256(refreshToken), + clientKind, + expiresAt: new Date(Date.now() + days * 24 * 3600 * 1000), + }, + }); + } + const permissions = [ + ...new Set( + user.roles.flatMap((r) => r.role.permissions.map((p) => p.permission)), + ), + ]; + const photos = await this.prisma.fileAsset.findMany({ + where: { + tenantId: '1', + OR: [ + { bizId: user.id, bizType: { in: ['USER_AVATAR', 'USER_ID_PHOTO'] } }, + user.employeeId + ? { bizId: user.employeeId, bizType: { in: ['EMPLOYEE_ID_PHOTO', 'EMPLOYEE_ID_FRONT', 'EMPLOYEE_ID_BACK'] } } + : undefined, + ].filter(Boolean) as Prisma.FileAssetWhereInput[], + }, + orderBy: { createdAt: 'desc' }, + }); + const roles = elevateToAdmin(user.roles.map((r) => r.role.code)); + const isAdmin = roles.includes('admin'); + const idPhotoFileId = + photos.find((f) => f.bizType === 'USER_ID_PHOTO' || f.bizType === 'EMPLOYEE_ID_PHOTO')?.id || null; + const idFrontFileId = photos.find((f) => f.bizType === 'EMPLOYEE_ID_FRONT')?.id || null; + const idBackFileId = photos.find((f) => f.bizType === 'EMPLOYEE_ID_BACK')?.id || null; + return { + accessToken, + refreshToken: refreshToken || undefined, + clientKind, + user: { + id: user.id, + username: user.username, + displayName: user.displayName, + avatarFileId: photos.find((f) => f.bizType === 'USER_AVATAR')?.id || null, + idPhotoFileId, + idFrontFileId, + idBackFileId, + roles, + employeeId: user.employeeId, + mustChangePassword: isAdmin ? false : Boolean(user.mustChangePassword), + profileCompletedAt: user.profileCompletedAt, + needsOnboard: needsOnboardGate({ + isAdmin, + mustChangePassword: user.mustChangePassword, + profileCompletedAt: user.profileCompletedAt, + employeeId: user.employeeId, + idPhotoFileId, + idFrontFileId, + idBackFileId, + employee: user.employee as { idNo?: string | null; idType?: string | null } | null, + }), + employee: user.employee, + permissions: permissions.map((p) => p.code), + }, + }; + } + + async startQrLogin() { + const ticket = randomUUID().replace(/-/g, '').slice(0, 16); + const expiresAt = new Date(Date.now() + 2 * 60 * 1000); + await this.prisma.authQrTicket.create({ + data: { ticket, expiresAt }, + }); + return { + ticket, + expiresAt, + payload: `fengyingoa://qr?ticket=${ticket}`, + }; + } + + async qrStatus(ticket: string) { + const row = await this.prisma.authQrTicket.findFirst({ where: { ticket: ticket.trim() } }); + if (!row) throw new UnauthorizedException('二维码无效'); + if (row.consumedAt || row.expiresAt < new Date()) { + if (row.status === 'pending') { + await this.prisma.authQrTicket.update({ where: { id: row.id }, data: { status: 'expired' } }); + } + return { status: row.consumedAt ? 'consumed' : 'expired' }; + } + if (row.status !== 'confirmed' || !row.userId) { + return { status: row.status }; + } + // 扫码只证明是本人的手机;还需把手机屏幕上的一次性六码填到电脑, + // 才签发桌面端令牌,避免旁人代扫后直接登录电脑。 + return { status: 'code_required', expiresAt: row.expiresAt }; + } + + async completeQrLogin(ticket: string, code: string) { + const rawTicket = ticket.trim(); + const rawCode = code.trim(); + if (!/^\d{6}$/.test(rawCode)) throw new BadRequestException('请输入手机显示的六位登录码'); + const row = await this.prisma.authQrTicket.findFirst({ where: { ticket: rawTicket } }); + if (!row || row.expiresAt < new Date()) { + throw new BadRequestException('二维码已过期,请刷新后重新扫码'); + } + if (row.status !== 'confirmed' || !row.userId || !row.confirmCodeHash) { + throw new BadRequestException('请先在手机端扫码确认'); + } + if (row.confirmCodeAttempts >= 5) { + await this.prisma.authQrTicket.update({ + where: { id: row.id }, + data: { status: 'expired' }, + }); + throw new BadRequestException('登录码错误次数过多,请刷新二维码'); + } + if (sha256(`${rawTicket}:${rawCode}`) !== row.confirmCodeHash) { + await this.prisma.authQrTicket.update({ + where: { id: row.id }, + data: { confirmCodeAttempts: { increment: 1 } }, + }); + throw new BadRequestException('六位登录码不正确'); + } + const user = await this.prisma.user.findUnique({ + where: { id: row.userId }, + include: { + roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } }, + employee: { include: { department: true } }, + }, + }); + if (!user || user.status !== 'ACTIVE') throw new UnauthorizedException('账号不可用'); + await this.prisma.authQrTicket.update({ + where: { id: row.id }, + data: { status: 'consumed', consumedAt: new Date() }, + }); + const tokens = await this.issueTokens(user, 'desktop'); + return { status: 'ok', ...tokens }; + } + + async confirmQrLogin(userId: string, ticket: string) { + const row = await this.prisma.authQrTicket.findFirst({ where: { ticket: ticket.trim() } }); + if (!row || row.expiresAt < new Date()) throw new BadRequestException('二维码已过期,请在电脑上刷新'); + if (row.status !== 'pending') throw new BadRequestException('这个码已经用过了'); + const code = String(randomInt(0, 1_000_000)).padStart(6, '0'); + await this.prisma.authQrTicket.update({ + where: { id: row.id }, + data: { + status: 'confirmed', + userId, + confirmCodeHash: sha256(`${row.ticket}:${code}`), + confirmCodeIssuedAt: new Date(), + confirmCodeAttempts: 0, + }, + }); + return { ok: true, code, expiresAt: row.expiresAt }; + } +} + +type ClientKind = 'web' | 'mobile' | 'desktop'; + +function normalizeClientKind(raw?: string | null): ClientKind { + const v = (raw || 'web').toLowerCase(); + if (v === 'mobile' || v === 'android' || v === 'ios' || v === 'app') return 'mobile'; + if (v === 'desktop' || v === 'windows' || v === 'pc') return 'desktop'; + return 'web'; +} diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts new file mode 100644 index 0000000..b726232 --- /dev/null +++ b/apps/api/src/auth/jwt.strategy.ts @@ -0,0 +1,71 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuthUser } from '../common/decorators/current-user.decorator'; +import { mergeDataScope } from '../common/data-scope'; +import { elevateToAdmin } from '../common/role-groups'; + +export interface JwtPayload { + sub: string; + username: string; + typ: 'access'; + sid?: string; + ck?: string; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + config: ConfigService, + private readonly prisma: PrismaService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: config.get('JWT_ACCESS_SECRET')!, + }); + } + + async validate(payload: JwtPayload): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + include: { + roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } }, + employee: true, + }, + }); + if (!user || user.status !== 'ACTIVE') { + throw new UnauthorizedException('登录已失效'); + } + if (payload.sid) { + const session = await this.prisma.refreshToken.findFirst({ + where: { id: payload.sid, userId: user.id, revokedAt: null }, + }); + if (!session || session.expiresAt < new Date()) { + throw new UnauthorizedException('登录已失效'); + } + } + const roles = elevateToAdmin(user.roles.map((r: { role: { code: string } }) => r.role.code)); + const permissions = [ + ...new Set( + user.roles.flatMap((r: { role: { permissions: { permission: { code: string } }[] } }) => + r.role.permissions.map((p) => p.permission.code), + ), + ), + ]; + const dataScope = roles.includes('admin') + ? 'ALL' + : mergeDataScope(user.roles.map((r: { role: { dataScope: string } }) => r.role.dataScope)); + return { + id: user.id, + username: user.username, + displayName: user.displayName, + roles, + permissions, + employeeId: user.employeeId, + departmentId: user.employee?.departmentId ?? null, + dataScope, + }; + } +} diff --git a/apps/api/src/bidding/bid-workflow.spec.ts b/apps/api/src/bidding/bid-workflow.spec.ts new file mode 100644 index 0000000..8c5a2f6 --- /dev/null +++ b/apps/api/src/bidding/bid-workflow.spec.ts @@ -0,0 +1,161 @@ +import { + ASSIGN_ROLE, + canAccessBidModule, + canAssignFetcher, + canCreateBid, + canCreateSpecialBid, + canFetchBid, + canFetchThisBid, + canManageDraft, + canManageThisDraft, + canSeeAllBids, + canSeeTerminated, + canTerminateBid, + canTerminateFrom, + currentHandlerNames, + isCommercialStage, + isSpecialBid, + isSpecialCoordinatorUser, + stageForStatus, + previousBidStatus, + inferLegacyRestoreStatus, +} from './bid-workflow'; + +describe('bid-workflow', () => { + it('maps status to review stage', () => { + expect(stageForStatus('TECH_INITIAL')).toBe('TECH_INITIAL'); + expect(stageForStatus('TECH_FINAL')).toBe('TECH_FINAL'); + expect(stageForStatus('REVIEW1')).toBe('REVIEW1'); + expect(stageForStatus('REVIEW2')).toBe('REVIEW2'); + expect(stageForStatus('FETCH')).toBeNull(); + expect(stageForStatus('DRAFTING')).toBeNull(); + }); + + it('treats commercial reject as bounce, not terminate', () => { + expect(isCommercialStage('REVIEW1')).toBe(true); + expect(isCommercialStage('REVIEW2')).toBe(true); + expect(isCommercialStage('TECH_INITIAL')).toBe(false); + }); + + it('lets every director see all bids', () => { + expect(canSeeAllBids(['rd_director'])).toBe(true); + expect(canSeeAllBids(['3d_director'])).toBe(true); + expect(canSeeAllBids(['video_director'])).toBe(true); + expect(canSeeAllBids(['material_director'])).toBe(true); + expect(canSeeAllBids(['biz_director'])).toBe(true); + expect(canSeeAllBids(['tech_director'])).toBe(true); + expect(canSeeAllBids(['owner'])).toBe(true); + expect(canSeeAllBids(['admin'])).toBe(true); + expect(canSeeAllBids(['biz_staff'])).toBe(true); + expect(canSeeAllBids(['employee'])).toBe(false); + expect(canSeeAllBids(['pm'])).toBe(false); + }); + + it('hides terminated bids from ordinary employees', () => { + expect(canSeeTerminated(['employee'], ['office'])).toBe(false); + expect(canSeeTerminated(['hr'], ['office', 'hr'])).toBe(false); + expect(canSeeTerminated(['finance'], ['bid:result'])).toBe(false); + expect(canSeeTerminated(['biz_staff'], ['bid:screening'])).toBe(false); + expect(canSeeTerminated(['biz_director'], ['bid:terminated'])).toBe(true); + expect(canSeeTerminated(['tech_director'], ['bid'])).toBe(true); + expect(canSeeTerminated(['owner'], [])).toBe(true); + expect(canSeeTerminated(['admin'], [])).toBe(true); + }); + + it('restricts create / fetch / terminate by role', () => { + expect(canCreateBid(['owner'])).toBe(true); + expect(canCreateBid(['tech_director'])).toBe(false); + expect(canCreateBid(['employee'], ['bid:screening'])).toBe(true); + expect(canFetchBid(['biz_staff'])).toBe(true); + expect(canFetchBid(['tech_director'])).toBe(false); + expect(canTerminateBid(['biz_director'])).toBe(true); + expect(canTerminateBid(['biz_staff'])).toBe(false); + expect(canManageDraft(['biz_director'])).toBe(true); + expect(canManageDraft(['biz_staff'])).toBe(false); + }); + + it('keeps special screening on named coordinators', () => { + expect(isSpecialBid('SPECIAL')).toBe(true); + expect(isSpecialBid('FLOW')).toBe(false); + expect(canCreateSpecialBid(['owner'])).toBe(true); + expect(canCreateSpecialBid(['biz_director'])).toBe(false); + expect(isSpecialCoordinatorUser({ username: 'liuyunze', displayName: '刘运泽' })).toBe(true); + expect(isSpecialCoordinatorUser({ username: 'chencheng', displayName: '陈铖' })).toBe(true); + expect(isSpecialCoordinatorUser({ username: 'other', displayName: '其他人' })).toBe(false); + const coordinators = [{ userId: 'liu', role: ASSIGN_ROLE.COORDINATOR }]; + const fetchers = [{ userId: 'staff', role: ASSIGN_ROLE.FETCHER }]; + expect(canAssignFetcher(['3d_director'], 'SPECIAL', coordinators, 'liu', 'FETCH')).toBe(true); + expect(canAssignFetcher(['3d_director'], 'SPECIAL', coordinators, 'other', 'FETCH')).toBe(false); + expect(canAssignFetcher(['biz_director'], 'FLOW', [], 'x', 'FETCH')).toBe(true); + expect(canAssignFetcher(['biz_staff'], 'FLOW', [], 'x', 'FETCH')).toBe(false); + expect(canFetchThisBid(['employee'], 'SPECIAL', fetchers, 'staff')).toBe(true); + expect(canFetchThisBid(['biz_staff'], 'SPECIAL', fetchers, 'other')).toBe(false); + expect(canFetchThisBid(['biz_staff'], 'FLOW', [], 'other')).toBe(false); + expect(canFetchThisBid(['employee'], 'FLOW', fetchers, 'staff')).toBe(true); + expect(canManageThisDraft(['3d_director'], 'SPECIAL', coordinators, 'liu')).toBe(true); + expect(canManageThisDraft(['biz_director'], 'SPECIAL', coordinators, 'biz')).toBe(false); + expect(canManageThisDraft(['biz_director'], 'FLOW', [], 'biz')).toBe(true); + }); + + it('allows terminate until execution, not after open', () => { + expect(canTerminateFrom('TECH_INITIAL')).toBe(true); + expect(canTerminateFrom('DRAFTING')).toBe(true); + expect(canTerminateFrom('LIST')).toBe(true); + expect(canTerminateFrom('PENDING')).toBe(false); + expect(canTerminateFrom('WON')).toBe(false); + }); + + it('shows the person who should act on the current step', () => { + expect( + currentHandlerNames({ + status: 'TECH_INITIAL', + reviewCycle: 1, + createdBy: { displayName: '潘兴辉' }, + reviewers: [ + { stage: 'TECH_INITIAL', result: 'PENDING', cycle: 1, user: { displayName: '戴永康' } }, + ], + }), + ).toBe('戴永康'); + expect( + currentHandlerNames({ + status: 'FETCH', + assignees: [{ role: 'FETCHER', user: { displayName: '何烨' } }], + createdBy: { displayName: '潘兴辉' }, + }), + ).toBe('何烨'); + expect( + currentHandlerNames({ + status: 'DRAFTING', + assignees: [{ role: 'DRAFTER', user: { displayName: '张婧妍' } }], + }), + ).toBe('张婧妍'); + expect(currentHandlerNames({ status: 'PRINT', createdBy: { displayName: '潘兴辉' } })).toBe('商务部'); + expect(currentHandlerNames({ status: 'FETCH', createdBy: { displayName: '潘兴辉' } })).toBe('待派取标人'); + }); + + it('rolls back one process step, except special fetch', () => { + expect(previousBidStatus('FETCH', 'FLOW')).toBe('TECH_INITIAL'); + expect(previousBidStatus('FETCH', 'SPECIAL')).toBeNull(); + expect(previousBidStatus('TECH_FINAL')).toBe('FETCH'); + expect(previousBidStatus('DRAFTING')).toBe('TECH_FINAL'); + expect(previousBidStatus('REVIEW1')).toBe('DRAFTING'); + expect(previousBidStatus('REVIEW2')).toBe('REVIEW1'); + expect(previousBidStatus('PRINT')).toBe('REVIEW2'); + expect(previousBidStatus('LIST')).toBe('PRINT'); + expect(previousBidStatus('PENDING')).toBe('LIST'); + expect(previousBidStatus('TECH_INITIAL')).toBeNull(); + expect(previousBidStatus('WON')).toBeNull(); + }); + + it('infers the restore step for legacy terminated records without an action log', () => { + expect(inferLegacyRestoreStatus([], [])).toBe('DRAFTING'); + expect( + inferLegacyRestoreStatus( + [{ stage: 'TECH_INITIAL', result: 'APPROVED' }], + [{ role: 'FETCHER' }, { role: 'DRAFTER' }], + ), + ).toBe('DRAFTING'); + expect(inferLegacyRestoreStatus([], [{ role: 'BIDDER' }])).toBe('PENDING'); + expect(inferLegacyRestoreStatus([{ stage: 'REVIEW2', result: 'PENDING' }], [])).toBe('REVIEW2'); + }); +}); diff --git a/apps/api/src/bidding/bid-workflow.ts b/apps/api/src/bidding/bid-workflow.ts new file mode 100644 index 0000000..63a95dd --- /dev/null +++ b/apps/api/src/bidding/bid-workflow.ts @@ -0,0 +1,336 @@ +import { DIRECTOR_ROLES, hasAnyRole, isTechLead } from '../common/role-groups'; + +export const BID_STATUS = { + TECH_INITIAL: 'TECH_INITIAL', + FETCH: 'FETCH', + TECH_FINAL: 'TECH_FINAL', + DRAFTING: 'DRAFTING', + REVIEW1: 'REVIEW1', + REVIEW2: 'REVIEW2', + PRINT: 'PRINT', + LIST: 'LIST', + PENDING: 'PENDING', + WON: 'WON', + LOST: 'LOST', + FAILED: 'FAILED', + TERMINATED: 'TERMINATED', +} as const; + +export type BidStatus = (typeof BID_STATUS)[keyof typeof BID_STATUS]; + +export const STATUS_BUCKET: Record = { + screening: [BID_STATUS.TECH_INITIAL, BID_STATUS.FETCH, BID_STATUS.TECH_FINAL], + drafting: [BID_STATUS.DRAFTING, BID_STATUS.REVIEW1, BID_STATUS.REVIEW2, BID_STATUS.PRINT], + execution: [BID_STATUS.LIST, BID_STATUS.PENDING], + result: [BID_STATUS.WON, BID_STATUS.LOST, BID_STATUS.FAILED], + terminated: [BID_STATUS.TERMINATED], +}; + +export const REVIEW_STAGE = { + TECH_INITIAL: 'TECH_INITIAL', + TECH_FINAL: 'TECH_FINAL', + REVIEW1: 'REVIEW1', + REVIEW2: 'REVIEW2', +} as const; + +export type ReviewStage = (typeof REVIEW_STAGE)[keyof typeof REVIEW_STAGE]; + +export const BID_SOURCE = { + FLOW: 'FLOW', + SPECIAL: 'SPECIAL', +} as const; + +export const ASSIGN_ROLE = { + DRAFTER: 'DRAFTER', + REVIEW1: 'REVIEW1', + BIDDER: 'BIDDER', + FETCHER: 'FETCHER', + COORDINATOR: 'COORDINATOR', +} as const; + +/** 特殊筛选只发给这两人,由他们派取标人 / 制标人。 */ +export const SPECIAL_COORDINATORS = [ + { username: 'liuyunze', name: '刘运泽' }, + { username: 'chencheng', name: '陈铖' }, +] as const; + +export const BIZ_TYPE = { + TECH_INITIAL: 'BID_TECH_INITIAL', + TECH_FINAL: 'BID_TECH_FINAL', + FETCH: 'BID_FETCH', + ASSIGN_FETCH: 'BID_ASSIGN_FETCH', + ASSIGN_DRAFT: 'BID_ASSIGN_DRAFT', + DRAFT: 'BID_DRAFT', + REVIEW1: 'BID_REVIEW1', + REVIEW2: 'BID_REVIEW2', + PRINT: 'BID_PRINT', + COMPLETE: 'BID_COMPLETE', + OPEN: 'BID_OPEN', + CONVERT_CONTRACT: 'CONTRACT_DRAFT', +} as const; + +export const OPEN_RESULT = { + WON: 'WON', + LOST: 'LOST', + FAILED: 'FAILED', +} as const; + +export const COST_DISPOSITION = { + DEPT: 'DEPT', + MARKETING: 'MARKETING', +} as const; + +export function stageForStatus(status: string): ReviewStage | null { + if (status === BID_STATUS.TECH_INITIAL) return REVIEW_STAGE.TECH_INITIAL; + if (status === BID_STATUS.TECH_FINAL) return REVIEW_STAGE.TECH_FINAL; + if (status === BID_STATUS.REVIEW1) return REVIEW_STAGE.REVIEW1; + if (status === BID_STATUS.REVIEW2) return REVIEW_STAGE.REVIEW2; + return null; +} + +/** + * Old OA imports did not always contain the TERMINATED action log. Infer the + * furthest assigned workflow step so those records can still be restored. + */ +export function inferLegacyRestoreStatus( + reviewers: { stage: string; result?: string | null }[], + assignees: { role: string }[], +): BidStatus { + const candidates: BidStatus[] = []; + for (const reviewer of reviewers) { + if (reviewer.result && reviewer.result !== 'PENDING') continue; + if (reviewer.stage === REVIEW_STAGE.TECH_INITIAL) candidates.push(BID_STATUS.TECH_INITIAL); + if (reviewer.stage === REVIEW_STAGE.TECH_FINAL) candidates.push(BID_STATUS.TECH_FINAL); + if (reviewer.stage === REVIEW_STAGE.REVIEW1) candidates.push(BID_STATUS.REVIEW1); + if (reviewer.stage === REVIEW_STAGE.REVIEW2) candidates.push(BID_STATUS.REVIEW2); + } + for (const assignee of assignees) { + if (assignee.role === ASSIGN_ROLE.FETCHER) candidates.push(BID_STATUS.FETCH); + if (assignee.role === ASSIGN_ROLE.DRAFTER) candidates.push(BID_STATUS.DRAFTING); + if (assignee.role === ASSIGN_ROLE.REVIEW1) candidates.push(BID_STATUS.REVIEW1); + if (assignee.role === ASSIGN_ROLE.BIDDER) candidates.push(BID_STATUS.PENDING); + } + const order: BidStatus[] = [ + BID_STATUS.TECH_INITIAL, + BID_STATUS.FETCH, + BID_STATUS.TECH_FINAL, + BID_STATUS.DRAFTING, + BID_STATUS.REVIEW1, + BID_STATUS.REVIEW2, + BID_STATUS.PRINT, + BID_STATUS.LIST, + BID_STATUS.PENDING, + ]; + return candidates.sort((a, b) => order.indexOf(b) - order.indexOf(a))[0] ?? BID_STATUS.DRAFTING; +} + +export function isCommercialStage(stage: string) { + return stage === REVIEW_STAGE.REVIEW1 || stage === REVIEW_STAGE.REVIEW2; +} + +export function approvalBizType(stage: string) { + if (stage === REVIEW_STAGE.TECH_FINAL) return BIZ_TYPE.TECH_FINAL; + if (stage === REVIEW_STAGE.REVIEW1) return BIZ_TYPE.REVIEW1; + if (stage === REVIEW_STAGE.REVIEW2) return BIZ_TYPE.REVIEW2; + return BIZ_TYPE.TECH_INITIAL; +} + +export function canAccessBidModule(roles: string[], permissions: string[]) { + if (roles.includes('admin')) return true; + return permissions.some((p) => p === 'bid' || p.startsWith('bid:')); +} + +/** 管理员、负责人、商务部(总监/专员)、全体技术总监可看全部投标。 */ +export function canSeeAllBids(roles: string[]) { + return hasAnyRole(roles, ['admin', 'owner', 'biz_staff', ...DIRECTOR_ROLES]); +} + +export function canSeeTerminated(roles: string[], permissions: string[]) { + if (roles.includes('admin') || roles.includes('owner') || roles.includes('biz_director')) { + return true; + } + if (isTechLead(roles)) return true; + return permissions.includes('bid:terminated'); +} + +export function canCreateBid(roles: string[], permissions: string[] = []) { + if (['admin', 'owner', 'biz_director', 'biz_staff'].some((r) => roles.includes(r))) return true; + return permissions.includes('bid:screening'); +} + +export function canCreateSpecialBid(roles: string[]) { + return ['admin', 'owner'].some((r) => roles.includes(r)); +} + +export function isSpecialBid(sourceType?: string | null) { + return sourceType === BID_SOURCE.SPECIAL; +} + +export function isSpecialCoordinatorUser(user: { username?: string | null; displayName?: string | null }) { + const username = (user.username || '').trim().toLowerCase(); + const name = (user.displayName || '').trim(); + return SPECIAL_COORDINATORS.some( + (c) => c.username === username || c.name === name, + ); +} + +export function hasAssigneeRole( + assignees: { userId: string; role: string }[], + userId: string, + role: string, +) { + return assignees.some((a) => a.userId === userId && a.role === role); +} + +export function canFetchBid(roles: string[]) { + return ['admin', 'owner', 'biz_director', 'biz_staff'].some((r) => roles.includes(r)); +} + +export function canFetchThisBid( + roles: string[], + sourceType: string | null | undefined, + assignees: { userId: string; role: string }[], + userId: string, +) { + // 普通/特殊筛选都必须由被派的取标人办理。过去普通筛选把整个商务部 + // 当成取标人,既会群发短信,也会让任意人越权上传或完成取标。 + return hasAssigneeRole(assignees, userId, ASSIGN_ROLE.FETCHER) || roles.includes('admin'); +} + +export function canManageDraft(roles: string[]) { + return ['admin', 'owner', 'biz_director'].some((r) => roles.includes(r)); +} + +export function canAssignFetcher( + roles: string[], + sourceType: string | null | undefined, + assignees: { userId: string; role: string }[], + userId: string, + status: string, +) { + if (status !== BID_STATUS.FETCH) return false; + if (isSpecialBid(sourceType)) { + return roles.includes('admin') || hasAssigneeRole(assignees, userId, ASSIGN_ROLE.COORDINATOR); + } + return ['admin', 'owner', 'biz_director'].some((role) => roles.includes(role)); +} + +export function canManageThisDraft( + roles: string[], + sourceType: string | null | undefined, + assignees: { userId: string; role: string }[], + userId: string, +) { + if (isSpecialBid(sourceType)) { + return roles.includes('admin') || hasAssigneeRole(assignees, userId, ASSIGN_ROLE.COORDINATOR); + } + return canManageDraft(roles); +} + +export function canBizOperate(roles: string[]) { + return ['admin', 'owner', 'biz_director', 'biz_staff'].some((r) => roles.includes(r)); +} + +export function canTerminateBid(roles: string[]) { + return ['admin', 'owner', 'biz_director'].some((r) => roles.includes(r)); +} + +const TERMINABLE_STATUSES: string[] = [ + BID_STATUS.TECH_INITIAL, + BID_STATUS.FETCH, + BID_STATUS.TECH_FINAL, + BID_STATUS.DRAFTING, + BID_STATUS.REVIEW1, + BID_STATUS.REVIEW2, + BID_STATUS.PRINT, + BID_STATUS.LIST, +]; + +export function canTerminateFrom(status: string) { + return TERMINABLE_STATUSES.includes(status); +} + +const PREVIOUS_STATUS: Partial> = { + [BID_STATUS.FETCH]: BID_STATUS.TECH_INITIAL, + [BID_STATUS.TECH_FINAL]: BID_STATUS.FETCH, + [BID_STATUS.DRAFTING]: BID_STATUS.TECH_FINAL, + [BID_STATUS.REVIEW1]: BID_STATUS.DRAFTING, + [BID_STATUS.REVIEW2]: BID_STATUS.REVIEW1, + [BID_STATUS.PRINT]: BID_STATUS.REVIEW2, + [BID_STATUS.LIST]: BID_STATUS.PRINT, + [BID_STATUS.PENDING]: BID_STATUS.LIST, +}; + +/** 当前步骤办错了,退回上一步重做。特殊筛选没有技术初审,取标不能再往回退。 */ +export function previousBidStatus(status: string, sourceType?: string | null): BidStatus | null { + if (status === BID_STATUS.FETCH && isSpecialBid(sourceType)) return null; + return PREVIOUS_STATUS[status as BidStatus] || null; +} + +export function canRollbackBid(roles: string[]) { + return ['admin', 'owner', 'biz_director'].some((r) => roles.includes(r)); +} + +type Named = { displayName?: string | null } | null | undefined; + +function uniqNames(names: (string | null | undefined)[]) { + return [...new Set(names.map((n) => (n || '').trim()).filter(Boolean))]; +} + +/** 列表「经办」:当前这一步该谁办,就显示谁。 */ +export function currentHandlerNames(bid: { + status: string; + sourceType?: string | null; + reviewCycle?: number; + createdBy?: Named; + assignees?: { role: string; user?: Named }[]; + reviewers?: { stage: string; result: string; cycle: number; user?: Named }[]; +}) { + const cycle = bid.reviewCycle ?? 1; + const byRole = (role: string) => + uniqNames((bid.assignees || []).filter((a) => a.role === role).map((a) => a.user?.displayName)); + const pending = (stage: string) => { + const rows = bid.reviewers || []; + const atCycle = rows.filter((r) => r.stage === stage && r.result === 'PENDING' && r.cycle === cycle); + const anyPending = rows.filter((r) => r.stage === stage && r.result === 'PENDING'); + const pick = atCycle.length ? atCycle : anyPending; + return uniqNames(pick.map((r) => r.user?.displayName)); + }; + const show = (names: string[], fallback = '') => names.join('、') || fallback; + + switch (bid.status) { + case BID_STATUS.TECH_INITIAL: + return show(pending(REVIEW_STAGE.TECH_INITIAL), bid.createdBy?.displayName || '—'); + case BID_STATUS.FETCH: { + const fetchers = byRole(ASSIGN_ROLE.FETCHER); + if (fetchers.length) return show(fetchers); + if (isSpecialBid(bid.sourceType)) return show(byRole(ASSIGN_ROLE.COORDINATOR), '待派取标人'); + return '待派取标人'; + } + case BID_STATUS.TECH_FINAL: + return show(pending(REVIEW_STAGE.TECH_FINAL), '待终审'); + case BID_STATUS.DRAFTING: { + const drafters = byRole(ASSIGN_ROLE.DRAFTER); + if (drafters.length) return show(drafters); + if (isSpecialBid(bid.sourceType)) return show(byRole(ASSIGN_ROLE.COORDINATOR), '待派制标人'); + return '待派制标人'; + } + case BID_STATUS.REVIEW1: + return show([...pending(REVIEW_STAGE.REVIEW1), ...byRole(ASSIGN_ROLE.REVIEW1)], '商务一审'); + case BID_STATUS.REVIEW2: + return show(pending(REVIEW_STAGE.REVIEW2), '商务二审'); + case BID_STATUS.PRINT: + return '商务部'; + case BID_STATUS.LIST: + return show(byRole(ASSIGN_ROLE.BIDDER), '待派投标人'); + case BID_STATUS.PENDING: + case BID_STATUS.WON: + case BID_STATUS.LOST: + case BID_STATUS.FAILED: + return show(byRole(ASSIGN_ROLE.BIDDER), bid.createdBy?.displayName || '—'); + case BID_STATUS.TERMINATED: + return bid.createdBy?.displayName || '—'; + default: + return bid.createdBy?.displayName || '—'; + } +} diff --git a/apps/api/src/bidding/bidding.controller.ts b/apps/api/src/bidding/bidding.controller.ts new file mode 100644 index 0000000..cca8ee0 --- /dev/null +++ b/apps/api/src/bidding/bidding.controller.ts @@ -0,0 +1,389 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + ArrayNotEmpty, + IsArray, + IsBoolean, + IsDefined, + IsIn, + MinLength, + IsOptional, + IsString, +} from 'class-validator'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { PageQueryDto } from '../common/dto/page-query.dto'; +import { BiddingService } from './bidding.service'; + +class CreateBidDto { + @IsString() + name!: string; + @IsString() + externalNo!: string; + @IsString() + projectType!: string; + @IsString() + source!: string; + @IsString() + qualificationNeed!: string; + @IsString() + legalEntityId!: string; + @IsOptional() + @IsString() + partyId?: string; + @IsOptional() + @IsString() + tenderMethod?: string; + @IsOptional() + applyStartAt?: string; + @IsOptional() + applyEndAt?: string; + @IsOptional() + @IsString() + applyMethod?: string; + @IsOptional() + openAt?: string; + @IsOptional() + @IsString() + openMethod?: string; + @IsOptional() + @IsString() + openPlace?: string; + @IsOptional() + @IsString() + remark?: string; + @IsOptional() + @IsString() + summary?: string; + @IsOptional() + @IsString() + nasPath?: string; + @IsOptional() + @IsString() + bondMethod?: string; + @IsOptional() + @IsString() + quoteAmount?: string; + @IsOptional() + @IsString() + priceCap?: string; + @IsArray() + @ArrayNotEmpty({ message: '请选择项目初审人或终审总监' }) + @IsString({ each: true }) + reviewerIds!: string[]; + @IsOptional() + @IsIn(['FLOW', 'SPECIAL']) + sourceType?: string; + @IsOptional() + @IsArray() + @IsString({ each: true }) + coordinatorIds?: string[]; + @IsOptional() + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + needSurvey?: boolean; +} + +class ReviewDto { + @IsIn(['APPROVED', 'REJECTED']) + result!: 'APPROVED' | 'REJECTED'; + @IsOptional() + @IsString() + comment?: string; +} + +class FetchDto { + @IsOptional() + @IsString() + nasPath?: string; + @IsOptional() + @IsString() + remark?: string; + @IsOptional() + openAt?: string; + @IsOptional() + @IsString() + openMethod?: string; + @IsOptional() + @IsString() + openPlace?: string; + @IsOptional() + @IsString() + applyMethod?: string; +} + +class TerminateDto { + @IsString() + @MinLength(1, { message: '终止请填写原因' }) + comment!: string; +} + +class RollbackDto { + @IsString() + @MinLength(1, { message: '退回请填写原因' }) + comment!: string; +} + +class AssignFetcherDto { + @IsString() + fetcherId!: string; +} + +class AssignDrafterDto { + @IsString() + drafterId!: string; + @IsOptional() + @IsString() + review1Id?: string; +} + +class SubmitDraftDto { + @IsString() + @MinLength(1, { message: '请填写标书 NAS 路径' }) + nasPath!: string; + @IsOptional() + @IsString() + note?: string; +} + +class PrintDto { + @IsOptional() + amount?: number; + @IsOptional() + @IsString() + note?: string; + @IsOptional() + @IsString() + nasPath?: string; +} + +class CompleteDto { + @IsOptional() + @IsString() + bondMethod?: string; + @IsOptional() + @IsString() + bondStatus?: string; + @IsOptional() + bondAmount?: number; + @IsOptional() + @IsString() + delivererName?: string; + @IsOptional() + @IsString() + priceCap?: string; + @IsOptional() + @IsString() + quoteAmount?: string; + @IsOptional() + @IsString() + securityLevel?: string; + @IsOptional() + @IsString() + contactName?: string; + @IsOptional() + @IsString() + contactPhone?: string; + @IsArray() + @ArrayNotEmpty({ message: '请派投标人' }) + @IsString({ each: true }) + bidderIds!: string[]; + @IsOptional() + openAt?: string; + @IsOptional() + @IsString() + openPlace?: string; + @IsOptional() + @IsString() + openMethod?: string; +} + +class OpenResultDto { + @IsIn(['WON', 'LOST', 'FAILED']) + result!: 'WON' | 'LOST' | 'FAILED'; + @IsOptional() + @IsString() + comment?: string; + @IsOptional() + @IsIn(['DEPT', 'MARKETING']) + costDisposition?: 'DEPT' | 'MARKETING'; + @IsOptional() + @IsString() + winnerName?: string; + @IsOptional() + winnerAmount?: number | string; +} + +class CompetitorDto { + @IsString() + @MinLength(1, { message: '请填写中标单位' }) + winnerName!: string; + @IsDefined({ message: '请填写中标金额' }) + winnerAmount!: number | string; + @IsOptional() + @IsString() + winnerNote?: string; +} + +class UpdateLegalEntityDto { + @IsString() + legalEntityId!: string; +} + +@ApiTags('bidding') +@ApiBearerAuth() +@Controller('bid-cases') +export class BiddingController { + constructor(private readonly bidding: BiddingService) {} + + @Get() + list( + @CurrentUser() user: AuthUser, + @Query() q: PageQueryDto, + @Query('bucket') bucket?: string, + @Query('sourceType') sourceType?: string, + @Query('projectType') projectType?: string, + @Query('status') status?: string, + @Query('legalEntityId') legalEntityId?: string, + ) { + return this.bidding.list(user, q.page, q.pageSize, q.keyword, bucket, sourceType, projectType, status, legalEntityId); + } + + @Get('special-coordinators') + specialCoordinators(@CurrentUser() user: AuthUser) { + return this.bidding.specialCoordinators(user); + } + + @Get(':id') + get(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) { + return this.bidding.get(id, user); + } + + @Post() + create(@Body() dto: CreateBidDto, @CurrentUser() user: AuthUser) { + return this.bidding.create(dto, user); + } + + @Patch(':id/survey') + setSurvey( + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { needSurvey?: boolean }, + @CurrentUser() user: AuthUser, + ) { + return this.bidding.setNeedSurvey(id, user, Boolean(body.needSurvey)); + } + + @Post(':id/reviews') + review( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ReviewDto, + @CurrentUser() user: AuthUser, + ) { + return this.bidding.review(id, user, dto); + } + + @Post(':id/fetch') + fetch( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: FetchDto, + @CurrentUser() user: AuthUser, + ) { + return this.bidding.fetch(id, user, dto); + } + + @Post(':id/terminate') + terminate( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: TerminateDto, + @CurrentUser() user: AuthUser, + ) { + return this.bidding.terminate(id, user, dto.comment); + } + + @Post(':id/rollback') + rollback( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RollbackDto, + @CurrentUser() user: AuthUser, + ) { + return this.bidding.rollback(id, user, dto.comment); + } + + @Post(':id/restore') + restore(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) { + return this.bidding.restoreTerminated(id, user); + } + + @Post(':id/assign-fetcher') + assignFetcher( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignFetcherDto, + @CurrentUser() user: AuthUser, + ) { + return this.bidding.assignFetcher(id, user, dto); + } + + @Post(':id/assign-drafter') + assignDrafter( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignDrafterDto, + @CurrentUser() user: AuthUser, + ) { + return this.bidding.assignDrafter(id, user, dto); + } + + @Post(':id/submit-draft') + submitDraft( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SubmitDraftDto, + @CurrentUser() user: AuthUser, + ) { + return this.bidding.submitDraft(id, user, dto); + } + + @Post(':id/print') + print( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: PrintDto, + @CurrentUser() user: AuthUser, + ) { + return this.bidding.print(id, user, dto); + } + + @Post(':id/complete') + complete( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CompleteDto, + @CurrentUser() user: AuthUser, + ) { + return this.bidding.completeExecution(id, user, dto); + } + + @Post(':id/open-result') + openResult( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: OpenResultDto, + @CurrentUser() user: AuthUser, + ) { + return this.bidding.openResult(id, user, dto); + } + + @Patch(':id/competitor') + competitor( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CompetitorDto, + @CurrentUser() user: AuthUser, + ) { + return this.bidding.recordCompetitor(id, user, dto); + } + + @Patch(':id/legal-entity') + updateLegalEntity( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateLegalEntityDto, + @CurrentUser() user: AuthUser, + ) { + return this.bidding.updateLegalEntity(id, user, dto.legalEntityId); + } +} diff --git a/apps/api/src/bidding/bidding.service.ts b/apps/api/src/bidding/bidding.service.ts new file mode 100644 index 0000000..57392d0 --- /dev/null +++ b/apps/api/src/bidding/bidding.service.ts @@ -0,0 +1,1810 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { emitSms } from '../sms/sms-bus'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { assertCanSeeOwner, ownerScope, withAnd } from '../common/data-scope'; +import { canSeeSubmittedFinance, EXPENSE_STATUS } from '../finance/finance-workflow'; +import { nextBondNo, nextExpenseClaimNo } from '../common/doc-no'; +import { decodeUploadFileName } from '../files/files.service'; +import { ensureWonLedger } from '../contract/won-ledger'; +import { findBizDirectors } from '../common/org-chain'; +import { + ASSIGN_ROLE, + BID_SOURCE, + BID_STATUS, + BIZ_TYPE, + COST_DISPOSITION, + OPEN_RESULT, + REVIEW_STAGE, + STATUS_BUCKET, + approvalBizType, + canAccessBidModule, + canAssignFetcher, + canBizOperate, + canCreateBid, + canCreateSpecialBid, + canFetchThisBid, + canManageThisDraft, + canSeeAllBids, + canSeeTerminated, + canTerminateBid, + canTerminateFrom, + currentHandlerNames, + isCommercialStage, + isSpecialBid, + inferLegacyRestoreStatus, + previousBidStatus, + canRollbackBid, + stageForStatus, +} from './bid-workflow'; + +const USER_SELECT = { id: true, displayName: true, username: true }; + +function fmtSmsTime(d?: Date | null) { + if (!d) return ''; + const x = new Date(d); + const p = (n: number) => String(n).padStart(2, '0'); + return `${x.getFullYear()}-${p(x.getMonth() + 1)}-${p(x.getDate())} ${p(x.getHours())}:${p(x.getMinutes())}`; +} + +function bidSmsParams( + bid: { + bidNo?: string | null; + name?: string | null; + openAt?: Date | null; + openPlace?: string | null; + contactName?: string | null; + contactPhone?: string | null; + }, + extra: Record = {}, +) { + return { + apply_no: bid.bidNo || '', + project_name: bid.name || '', + date: fmtSmsTime(bid.openAt), + location: bid.openPlace || '', + contact_person: bid.contactName || '', + contact_phone: bid.contactPhone || '', + content: bid.name || '', + ...extra, + }; +} + +const DETAIL_INCLUDE = { + party: true, + legalEntity: true, + createdBy: { select: USER_SELECT }, + actionLogs: { + include: { actor: { select: USER_SELECT } }, + orderBy: { createdAt: 'asc' as const }, + }, + assignees: { + include: { user: { select: USER_SELECT } }, + orderBy: { createdAt: 'asc' as const }, + }, + versions: { + include: { submittedBy: { select: USER_SELECT } }, + orderBy: { versionNo: 'desc' as const }, + }, + bonds: true, + expenses: true, + contracts: { + select: { + id: true, + contractNo: true, + name: true, + status: true, + amount: true, + projects: { select: { id: true, projectNo: true, name: true, status: true } }, + }, + orderBy: { createdAt: 'desc' as const }, + }, +}; + +type Tx = Prisma.TransactionClient; + +export type CreateBidInput = { + name: string; + externalNo: string; + projectType: string; + source?: string; + qualificationNeed?: string; + legalEntityId?: string; + partyId?: string; + tenderMethod?: string; + applyStartAt?: string; + applyEndAt?: string; + applyMethod?: string; + openAt?: string; + openMethod?: string; + openPlace?: string; + remark?: string; + summary?: string; + nasPath?: string; + bondMethod?: string; + quoteAmount?: string; + priceCap?: string; + reviewerIds: string[]; + sourceType?: string; + coordinatorIds?: string[]; + needSurvey?: boolean; +}; + +export type ReviewInput = { + result: 'APPROVED' | 'REJECTED'; + comment?: string; +}; + +export type FetchInput = { + nasPath?: string; + remark?: string; + openAt?: string; + openMethod?: string; + openPlace?: string; + applyMethod?: string; +}; + +export type AssignDrafterInput = { + drafterId: string; + review1Id?: string; +}; + +export type SubmitDraftInput = { + nasPath: string; + note?: string; +}; + +export type PrintInput = { + amount?: number; + note?: string; + nasPath?: string; +}; + +export type CompleteInput = { + bondMethod?: string; + bondStatus?: string; + bondAmount?: number; + delivererName?: string; + priceCap?: string; + quoteAmount?: string; + securityLevel?: string; + contactName?: string; + contactPhone?: string; + bidderIds: string[]; + openAt?: string; + openPlace?: string; + openMethod?: string; +}; + +export type OpenResultInput = { + result: 'WON' | 'LOST' | 'FAILED'; + comment?: string; + costDisposition?: 'DEPT' | 'MARKETING'; + winnerName?: string; + winnerAmount?: number | string; +}; + +@Injectable() +export class BiddingService { + constructor(private readonly prisma: PrismaService) {} + + assertModuleAccess(user: AuthUser) { + if (!canAccessBidModule(user.roles, user.permissions)) { + throw new ForbiddenException('无权访问投标管理'); + } + } + + async list( + user: AuthUser, + page: number, + pageSize: number, + keyword?: string, + bucket?: string, + sourceType?: string, + projectType?: string, + status?: string, + legalEntityId?: string, + ) { + this.assertModuleAccess(user); + const seeTerminated = canSeeTerminated(user.roles, user.permissions); + if (bucket === 'terminated' && !seeTerminated) { + throw new ForbiddenException('无权查看终止投标'); + } + const bucketStatuses = bucket ? STATUS_BUCKET[bucket] : undefined; + if (bucket && !bucketStatuses) { + throw new BadRequestException('未知的列表分桶'); + } + + const where: Prisma.BidCaseWhereInput = { tenantId: '1' }; + if (legalEntityId) where.legalEntityId = legalEntityId; + if (status) { + if (bucketStatuses && !bucketStatuses.includes(status)) { + throw new BadRequestException('该状态不在当前列表'); + } + where.status = status; + } else if (bucket === 'terminated') { + where.status = BID_STATUS.TERMINATED; + } else if (bucketStatuses) { + where.status = { in: bucketStatuses }; + } else if (!seeTerminated) { + where.status = { not: BID_STATUS.TERMINATED }; + } + if (sourceType === BID_SOURCE.SPECIAL || sourceType === BID_SOURCE.FLOW) { + where.sourceType = sourceType; + } else if (bucket !== 'execution' && bucket !== 'result' && bucket !== 'terminated') { + where.sourceType = { not: BID_SOURCE.SPECIAL }; + } + if (projectType) where.projectType = projectType; + if (keyword) { + where.AND = [ + { + OR: [ + { name: { contains: keyword } }, + { bidNo: { contains: keyword } }, + { externalNo: { contains: keyword } }, + ], + }, + ]; + } + const scoped = canSeeAllBids(user.roles) ? {} : await ownerScope(this.prisma, user, 'createdById'); + const visibility = Object.keys(scoped).length + ? { + OR: [ + scoped, + { assignees: { some: { userId: user.id } } }, + { reviewers: { some: { userId: user.id } } }, + ], + } + : {}; + const filtered = withAnd(where, visibility as Record); + + const [items, total] = await this.prisma.$transaction([ + this.prisma.bidCase.findMany({ + where: filtered, + include: { + party: true, + legalEntity: true, + createdBy: { select: USER_SELECT }, + assignees: { include: { user: { select: USER_SELECT } } }, + reviewers: { include: { user: { select: USER_SELECT } } }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.bidCase.count({ where: filtered }), + ]); + return { + items: items.map((r) => ({ ...r, handlerName: currentHandlerNames(r) })), + page, + pageSize, + total, + }; + } + + async get(id: string, user: AuthUser) { + const bid = await this.prisma.bidCase.findFirst({ + where: { id, tenantId: '1' }, + include: { + ...DETAIL_INCLUDE, + reviewers: { + include: { user: { select: USER_SELECT } }, + orderBy: [{ cycle: 'asc' }, { stage: 'asc' }, { decidedAt: 'asc' }], + }, + }, + }); + if (!bid) throw new NotFoundException('投标事项不存在'); + const assigned = bid.assignees.some((a) => a.userId === user.id); + const reviewing = bid.reviewers.some((r) => r.userId === user.id); + const involved = assigned || reviewing; + if (!canAccessBidModule(user.roles, user.permissions) && !involved) { + throw new ForbiddenException('无权访问投标管理'); + } + if (!involved && !canSeeAllBids(user.roles)) await assertCanSeeOwner(this.prisma, user, bid.createdById); + if ( + bid.status === BID_STATUS.TERMINATED && + !canSeeTerminated(user.roles, user.permissions) && + !assigned + ) { + throw new ForbiddenException('无权查看终止投标'); + } + const files = ( + await this.prisma.fileAsset.findMany({ + where: { tenantId: '1', bizId: id, bizType: { startsWith: 'BID' } }, + orderBy: { createdAt: 'asc' }, + }) + ).map((f) => ({ ...f, fileName: decodeUploadFileName(f.fileName) })); + const expenses = (bid.expenses || []).filter((e) => { + if (e.applicantId === user.id) return true; + if (e.status === EXPENSE_STATUS.DRAFT) return false; + if (e.status === EXPENSE_STATUS.APPROVED) return true; + return canSeeSubmittedFinance(user.roles, user.permissions); + }); + return { ...bid, expenses, files, myActions: this.myActions(bid, user) }; + } + + async setNeedSurvey(id: string, user: AuthUser, needSurvey: boolean) { + this.assertModuleAccess(user); + const row = await this.prisma.bidCase.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('投标事项不存在'); + return this.prisma.bidCase.update({ + where: { id }, + data: { needSurvey: Boolean(needSurvey) }, + }); + } + + async specialCoordinators(user: AuthUser) { + this.assertModuleAccess(user); + return this.loadSpecialCoordinators(this.prisma); + } + + async create(dto: CreateBidInput, user: AuthUser) { + this.assertModuleAccess(user); + const special = isSpecialBid(dto.sourceType); + if (special) { + if (!canCreateSpecialBid(user.roles)) { + throw new ForbiddenException('特殊筛选只能由企业负责人发起'); + } + } else if (!canCreateBid(user.roles, user.permissions)) { + throw new ForbiddenException('无权发起项目筛选'); + } + const reviewerIds = [...new Set(dto.reviewerIds.filter(Boolean))]; + if (reviewerIds.length === 0) { + throw new BadRequestException(special ? '请选择终审总监' : '请选择项目初审人'); + } + const reviewers = await this.prisma.user.findMany({ + where: { id: { in: reviewerIds }, tenantId: '1', status: 'ACTIVE' }, + }); + if (reviewers.length !== reviewerIds.length) { + throw new BadRequestException(special ? '终审总监无效' : '初审人无效'); + } + const allowed = await this.loadSpecialCoordinators(this.prisma); + const coordinatorIds = special + ? [...new Set((dto.coordinatorIds || []).filter(Boolean))] + : []; + if (special) { + if (!coordinatorIds.length) { + throw new BadRequestException('请选择特殊筛选办理人'); + } + const allowedIds = new Set(allowed.map((u) => u.id)); + if (coordinatorIds.some((id) => !allowedIds.has(id))) { + throw new BadRequestException('特殊筛选办理人无效'); + } + } + + const bid = await this.prisma.$transaction(async (tx) => { + const tenderNo = (dto.externalNo || '').trim(); + if (!tenderNo) throw new BadRequestException('请填写招标编号(公告上的项目编号)'); + // 项目筛选只使用人工填写的公告编号。相同公告可能二次发布、 + // 分包或重复筛选,不能把它当成系统唯一编号拦截。 + const created = await tx.bidCase.create({ + data: { + tenantId: '1', + bidNo: tenderNo, + name: dto.name, + externalNo: tenderNo, + projectType: dto.projectType, + sourceType: special ? BID_SOURCE.SPECIAL : BID_SOURCE.FLOW, + source: dto.source, + qualificationNeed: dto.qualificationNeed, + legalEntityId: dto.legalEntityId, + partyId: dto.partyId || undefined, + tenderMethod: dto.tenderMethod, + applyStartAt: dto.applyStartAt ? new Date(dto.applyStartAt) : undefined, + applyEndAt: dto.applyEndAt ? new Date(dto.applyEndAt) : undefined, + applyMethod: dto.applyMethod, + openAt: dto.openAt ? new Date(dto.openAt) : undefined, + openMethod: dto.openMethod, + openPlace: dto.openPlace, + remark: dto.remark, + summary: dto.summary, + nasPath: dto.nasPath, + bondMethod: dto.bondMethod, + quoteAmount: dto.quoteAmount, + priceCap: dto.priceCap, + needSurvey: Boolean(dto.needSurvey), + status: special ? BID_STATUS.FETCH : BID_STATUS.TECH_INITIAL, + createdById: user.id, + reviewers: { + create: reviewerIds.map((userId) => ({ + userId, + stage: REVIEW_STAGE.TECH_INITIAL, + })), + }, + assignees: special + ? { + create: coordinatorIds.map((userId) => ({ + userId, + role: ASSIGN_ROLE.COORDINATOR, + })), + } + : undefined, + }, + }); + if (special) { + await this.createTodos( + tx, + created.id, + `特殊筛选派取标:${created.name}`, + BIZ_TYPE.ASSIGN_FETCH, + coordinatorIds.map((id) => ({ id })), + created.applyEndAt, + ); + await this.log( + tx, + created.id, + user.id, + 'CREATE_SPECIAL', + undefined, + BID_STATUS.FETCH, + `发起特殊筛选 ${created.externalNo || created.bidNo},发给 ${allowed + .filter((u) => coordinatorIds.includes(u.id)) + .map((u) => u.displayName) + .join('、')}`, + ); + } else { + await tx.approvalTask.createMany({ + data: reviewerIds.map((assigneeId) => ({ + tenantId: '1', + title: `技术初审会签:${created.name}`, + bizType: BIZ_TYPE.TECH_INITIAL, + bizId: created.id, + assigneeId, + })), + }); + emitSms('BID_TECH_INITIAL', { + userIds: reviewerIds, + params: bidSmsParams(created), + }); + await this.log(tx, created.id, user.id, 'CREATE', undefined, BID_STATUS.TECH_INITIAL, `发起筛选 ${created.externalNo || created.bidNo}`); + } + return created; + }); + return this.get(bid.id, user); + } + + async review(bidId: string, user: AuthUser, dto: ReviewInput) { + this.assertModuleAccess(user); + if (dto.result === 'REJECTED' && !dto.comment?.trim()) { + throw new BadRequestException('驳回请填写意见'); + } + await this.prisma.$transaction(async (tx) => { + const bid = await tx.bidCase.findFirst({ + where: { id: bidId, tenantId: '1' }, + include: { reviewers: true, assignees: true }, + }); + if (!bid) throw new NotFoundException('投标事项不存在'); + const stage = stageForStatus(bid.status); + if (!stage) { + throw new BadRequestException('当前状态不可会签'); + } + const row = bid.reviewers.find( + (r) => + r.userId === user.id && + r.stage === stage && + r.result === 'PENDING' && + r.cycle === bid.reviewCycle, + ); + if (!row) { + throw new ForbiddenException('没有待办的会签'); + } + await tx.bidReviewer.update({ + where: { id: row.id }, + data: { + result: dto.result, + comment: dto.comment?.trim() || null, + decidedAt: new Date(), + }, + }); + await tx.approvalTask.updateMany({ + where: { + bizId: bid.id, + assigneeId: user.id, + status: 'PENDING', + bizType: approvalBizType(stage), + }, + data: { status: dto.result === 'APPROVED' ? 'APPROVED' : 'REJECTED' }, + }); + + if (dto.result === 'REJECTED') { + if (isCommercialStage(stage)) { + await this.bounceToDrafting(tx, bid, user, dto.comment!.trim(), stage); + return; + } + await this.closeAndTerminate( + tx, + bid, + user, + dto.comment!.trim(), + stage === REVIEW_STAGE.TECH_FINAL ? 'FINAL_REJECT' : 'INITIAL_REJECT', + ); + return; + } + + await this.log( + tx, + bid.id, + user.id, + this.approveAction(stage), + bid.status, + undefined, + dto.comment?.trim() || '同意', + ); + + const pending = await tx.bidReviewer.count({ + where: { + bidCaseId: bid.id, + stage, + cycle: bid.reviewCycle, + result: 'PENDING', + }, + }); + if (pending > 0) return; + + if (stage === REVIEW_STAGE.TECH_INITIAL) { + await tx.bidCase.update({ + where: { id: bid.id }, + data: { status: BID_STATUS.FETCH }, + }); + await this.log(tx, bid.id, user.id, 'INITIAL_PASSED', BID_STATUS.TECH_INITIAL, BID_STATUS.FETCH, '初审会签全部通过,进入取标'); + await this.createTodos( + tx, + bid.id, + `派取标人:${bid.name}`, + BIZ_TYPE.ASSIGN_FETCH, + await this.bizDeptDirectors(tx), + bid.applyEndAt, + ); + return; + } + + if (stage === REVIEW_STAGE.TECH_FINAL) { + await tx.bidCase.update({ + where: { id: bid.id }, + data: { status: BID_STATUS.DRAFTING }, + }); + await this.log(tx, bid.id, user.id, 'FINAL_PASSED', BID_STATUS.TECH_FINAL, BID_STATUS.DRAFTING, '技术终审通过,进入制标'); + const draftOwners = isSpecialBid(bid.sourceType) + ? bid.assignees + .filter((a) => a.role === ASSIGN_ROLE.COORDINATOR) + .map((a) => ({ id: a.userId })) + : [...(await this.bizDeptDirectors(tx)), ...(await this.usersByRoles(tx, ['owner']))]; + await this.createTodos( + tx, + bid.id, + isSpecialBid(bid.sourceType) ? `特殊制标派制标人:${bid.name}` : `派制标:${bid.name}`, + BIZ_TYPE.ASSIGN_DRAFT, + draftOwners, + ); + // 终审通过后的短信由 createTodos 仅发给本步骤指定的派制标人; + // 不再额外抄送固定人员,避免无关短信。 + return; + } + + if (stage === REVIEW_STAGE.REVIEW1) { + const directors = await this.bizDeptDirectors(tx); + const review2Ids = directors.length + ? directors.map((u) => u.id) + : (await this.usersByRoles(tx, ['owner'])).map((u) => u.id); + if (!review2Ids.length) { + throw new BadRequestException('没有可办理二审的商务总监'); + } + await tx.bidCase.update({ + where: { id: bid.id }, + data: { status: BID_STATUS.REVIEW2 }, + }); + await tx.bidReviewer.createMany({ + data: review2Ids.map((userId) => ({ + bidCaseId: bid.id, + userId, + stage: REVIEW_STAGE.REVIEW2, + cycle: bid.reviewCycle, + })), + }); + await tx.approvalTask.createMany({ + data: review2Ids.map((assigneeId) => ({ + tenantId: '1', + title: `商务二审:${bid.name}`, + bizType: BIZ_TYPE.REVIEW2, + bizId: bid.id, + assigneeId, + })), + }); + emitSms('BID_REVIEW', { userIds: review2Ids, params: bidSmsParams(bid) }); + await this.log(tx, bid.id, user.id, 'REVIEW1_PASSED', BID_STATUS.REVIEW1, BID_STATUS.REVIEW2, '一审通过,进入商务总监二审'); + return; + } + + await tx.bidCase.update({ + where: { id: bid.id }, + data: { status: BID_STATUS.PRINT }, + }); + await this.log(tx, bid.id, user.id, 'REVIEW2_PASSED', BID_STATUS.REVIEW2, BID_STATUS.PRINT, '二审通过,进入印封'); + await this.createTodos( + tx, + bid.id, + `印封:${bid.name}`, + BIZ_TYPE.PRINT, + [{ id: user.id }], + ); + }); + return this.get(bidId, user); + } + + async fetch(bidId: string, user: AuthUser, dto: FetchInput) { + await this.prisma.$transaction(async (tx) => { + const bid = await tx.bidCase.findFirst({ + where: { id: bidId, tenantId: '1' }, + include: { reviewers: true, assignees: true }, + }); + if (!bid) throw new NotFoundException('投标事项不存在'); + if (bid.status !== BID_STATUS.FETCH) { + throw new BadRequestException('当前不是取标状态'); + } + if (!canFetchThisBid(user.roles, bid.sourceType, bid.assignees, user.id)) { + throw new ForbiddenException('只有被指定的取标人可以办理取标'); + } + if (!bid.assignees.some((a) => a.role === ASSIGN_ROLE.FETCHER)) { + throw new BadRequestException('请先由负责人指定取标人'); + } + await tx.bidCase.update({ + where: { id: bid.id }, + data: { + status: BID_STATUS.TECH_FINAL, + remark: dto.remark?.trim() || bid.remark, + applyMethod: dto.applyMethod || bid.applyMethod, + openAt: dto.openAt ? new Date(dto.openAt) : bid.openAt, + openMethod: dto.openMethod || bid.openMethod, + openPlace: dto.openPlace || bid.openPlace, + fetchedAt: new Date(), + }, + }); + await tx.todoTask.updateMany({ + where: { bizId: bid.id, bizType: { in: [BIZ_TYPE.FETCH, BIZ_TYPE.ASSIGN_FETCH] }, status: 'OPEN' }, + data: { status: 'DONE' }, + }); + const initialReviewers = [ + ...new Set( + bid.reviewers + .filter((r) => r.stage === REVIEW_STAGE.TECH_INITIAL) + .map((r) => r.userId), + ), + ]; + for (const userId of initialReviewers) { + await tx.bidReviewer.create({ + data: { + bidCaseId: bid.id, + userId, + stage: REVIEW_STAGE.TECH_FINAL, + }, + }); + } + if (initialReviewers.length) { + await tx.approvalTask.createMany({ + data: initialReviewers.map((assigneeId) => ({ + tenantId: '1', + title: `技术终审:${bid.name}`, + bizType: BIZ_TYPE.TECH_FINAL, + bizId: bid.id, + assigneeId, + })), + }); + emitSms('BID_TECH_FINAL', { + userIds: initialReviewers, + params: bidSmsParams(bid), + }); + } + await this.log( + tx, + bid.id, + user.id, + 'FETCH_DONE', + BID_STATUS.FETCH, + BID_STATUS.TECH_FINAL, + '取标完成,进入技术终审', + ); + }); + return this.get(bidId, user); + } + + async assignFetcher(bidId: string, user: AuthUser, dto: { fetcherId: string }) { + await this.prisma.$transaction(async (tx) => { + const bid = await tx.bidCase.findFirst({ + where: { id: bidId, tenantId: '1' }, + include: { assignees: true }, + }); + if (!bid) throw new NotFoundException('投标事项不存在'); + if (!canAssignFetcher(user.roles, bid.sourceType, bid.assignees, user.id, bid.status)) { + throw new ForbiddenException( + isSpecialBid(bid.sourceType) ? '只有被指定的特殊流程办理人可以指定取标人' : '只有商务总监可以指定取标人', + ); + } + const fetcher = await tx.user.findFirst({ + where: { id: dto.fetcherId, tenantId: '1', status: 'ACTIVE' }, + }); + if (!fetcher) throw new BadRequestException('取标人无效'); + await tx.bidAssignee.deleteMany({ + where: { bidCaseId: bid.id, role: ASSIGN_ROLE.FETCHER }, + }); + await tx.bidAssignee.create({ + data: { bidCaseId: bid.id, userId: fetcher.id, role: ASSIGN_ROLE.FETCHER }, + }); + await tx.todoTask.updateMany({ + where: { bizId: bid.id, bizType: { in: [BIZ_TYPE.ASSIGN_FETCH, BIZ_TYPE.FETCH] }, status: 'OPEN' }, + data: { status: 'DONE' }, + }); + await tx.todoTask.create({ + data: { + tenantId: '1', + title: `${isSpecialBid(bid.sourceType) ? '特殊筛选' : ''}取标:${bid.name}`, + bizType: BIZ_TYPE.FETCH, + bizId: bid.id, + assigneeId: fetcher.id, + }, + }); + emitSms('BID_FETCH', { userIds: [fetcher.id], params: bidSmsParams(bid) }); + await this.log( + tx, + bid.id, + user.id, + 'ASSIGN_FETCHER', + bid.status, + bid.status, + `派取标人 ${fetcher.displayName}`, + ); + }); + return this.get(bidId, user); + } + + async assignDrafter(bidId: string, user: AuthUser, dto: AssignDrafterInput) { + this.assertModuleAccess(user); + await this.prisma.$transaction(async (tx) => { + const bid = await tx.bidCase.findFirst({ + where: { id: bidId, tenantId: '1' }, + include: { assignees: true }, + }); + if (!bid) throw new NotFoundException('投标事项不存在'); + if (bid.status !== BID_STATUS.DRAFTING) { + throw new BadRequestException('当前不是制标状态'); + } + if (!canManageThisDraft(user.roles, bid.sourceType, bid.assignees, user.id)) { + throw new ForbiddenException( + isSpecialBid(bid.sourceType) ? '只有被指定的特殊流程办理人可以派特殊制标人' : '无权派制标人', + ); + } + const drafter = await tx.user.findFirst({ + where: { id: dto.drafterId, tenantId: '1', status: 'ACTIVE' }, + }); + if (!drafter) throw new BadRequestException('制标人无效'); + if (dto.review1Id) { + const r1 = await tx.user.findFirst({ + where: { id: dto.review1Id, tenantId: '1', status: 'ACTIVE' }, + }); + if (!r1) throw new BadRequestException('一审人无效'); + } + await tx.bidAssignee.deleteMany({ + where: { bidCaseId: bid.id, role: { in: [ASSIGN_ROLE.DRAFTER, ASSIGN_ROLE.REVIEW1] } }, + }); + await tx.bidAssignee.create({ + data: { bidCaseId: bid.id, userId: drafter.id, role: ASSIGN_ROLE.DRAFTER }, + }); + if (dto.review1Id) { + await tx.bidAssignee.create({ + data: { bidCaseId: bid.id, userId: dto.review1Id, role: ASSIGN_ROLE.REVIEW1 }, + }); + } + await tx.todoTask.updateMany({ + where: { bizId: bid.id, bizType: BIZ_TYPE.ASSIGN_DRAFT, status: 'OPEN' }, + data: { status: 'DONE' }, + }); + await tx.todoTask.updateMany({ + where: { bizId: bid.id, bizType: BIZ_TYPE.DRAFT, status: 'OPEN' }, + data: { status: 'CANCELLED' }, + }); + await tx.todoTask.create({ + data: { + tenantId: '1', + title: `制标:${bid.name}`, + bizType: BIZ_TYPE.DRAFT, + bizId: bid.id, + assigneeId: drafter.id, + }, + }); + emitSms('BID_DRAFT', { userIds: [drafter.id], params: bidSmsParams(bid) }); + await this.log( + tx, + bid.id, + user.id, + 'ASSIGN_DRAFTER', + bid.status, + bid.status, + `派制标人 ${drafter.displayName}`, + ); + }); + return this.get(bidId, user); + } + + async submitDraft(bidId: string, user: AuthUser, dto: SubmitDraftInput) { + if (!dto.nasPath?.trim()) { + throw new BadRequestException('请填写标书 NAS 路径'); + } + await this.prisma.$transaction(async (tx) => { + const bid = await tx.bidCase.findFirst({ + where: { id: bidId, tenantId: '1' }, + include: { assignees: true }, + }); + if (!bid) throw new NotFoundException('投标事项不存在'); + if (bid.status !== BID_STATUS.DRAFTING) { + throw new BadRequestException('当前不是制标状态'); + } + const isDrafter = bid.assignees.some( + (a) => a.role === ASSIGN_ROLE.DRAFTER && a.userId === user.id, + ); + const canManage = canManageThisDraft(user.roles, bid.sourceType, bid.assignees, user.id); + if (!isDrafter && !canManage && (isSpecialBid(bid.sourceType) || !canBizOperate(user.roles))) { + throw new ForbiddenException('无权提交标书'); + } + const latest = await tx.bidDocVersion.aggregate({ + where: { bidCaseId: bid.id }, + _max: { versionNo: true }, + }); + const versionNo = (latest._max.versionNo ?? 0) + 1; + await tx.bidDocVersion.create({ + data: { + bidCaseId: bid.id, + versionNo, + nasPath: dto.nasPath.trim(), + note: dto.note?.trim() || null, + submittedById: user.id, + }, + }); + const review1Ids = await this.review1Ids(tx, bid); + if (!review1Ids.length) { + throw new BadRequestException('请先指定一审人,或确保有商务专员'); + } + await tx.bidCase.update({ + where: { id: bid.id }, + data: { status: BID_STATUS.REVIEW1, nasPath: dto.nasPath.trim() }, + }); + await tx.bidReviewer.createMany({ + data: review1Ids.map((userId) => ({ + bidCaseId: bid.id, + userId, + stage: REVIEW_STAGE.REVIEW1, + cycle: bid.reviewCycle, + })), + }); + await tx.approvalTask.createMany({ + data: review1Ids.map((assigneeId) => ({ + tenantId: '1', + title: `商务一审:${bid.name}(第${versionNo}版)`, + bizType: BIZ_TYPE.REVIEW1, + bizId: bid.id, + assigneeId, + })), + }); + emitSms('BID_REVIEW', { userIds: review1Ids, params: bidSmsParams(bid) }); + // 一审待办短信仅发送给实际的一审人,不再抄送固定人员。 + await tx.todoTask.updateMany({ + where: { bizId: bid.id, bizType: BIZ_TYPE.DRAFT, status: 'OPEN' }, + data: { status: 'DONE' }, + }); + await this.log( + tx, + bid.id, + user.id, + 'SUBMIT_DRAFT', + BID_STATUS.DRAFTING, + BID_STATUS.REVIEW1, + `提交第${versionNo}版 ${dto.nasPath.trim()}`, + ); + }); + return this.get(bidId, user); + } + + async print(bidId: string, user: AuthUser, dto: PrintInput) { + this.assertModuleAccess(user); + if (!canBizOperate(user.roles)) { + throw new ForbiddenException('无权办理印封'); + } + await this.prisma.$transaction(async (tx) => { + const bid = await this.requireBid(tx, bidId); + if (bid.status !== BID_STATUS.PRINT) { + throw new BadRequestException('当前不是印封状态'); + } + const amount = dto.amount ?? 0; + if (amount < 0) throw new BadRequestException('印封费用不能为负'); + if (amount > 0) { + await tx.expenseClaim.create({ + data: { + tenantId: '1', + claimNo: await nextExpenseClaimNo(tx), + applicantId: user.id, + costType: 'BID', + bidCaseId: bid.id, + amount, + status: 'PENDING', + remark: dto.note?.trim() || '印封费用', + }, + }); + } + await tx.bidCase.update({ + where: { id: bid.id }, + data: { + status: BID_STATUS.LIST, + printCost: amount, + printNote: dto.note?.trim() || null, + printedAt: new Date(), + nasPath: dto.nasPath?.trim() || bid.nasPath, + }, + }); + await tx.todoTask.updateMany({ + where: { bizId: bid.id, bizType: BIZ_TYPE.PRINT, status: 'OPEN' }, + data: { status: 'DONE' }, + }); + await this.createTodos( + tx, + bid.id, + `补全投标:${bid.name}`, + BIZ_TYPE.COMPLETE, + [{ id: user.id }], + ); + await this.log( + tx, + bid.id, + user.id, + 'PRINT_DONE', + BID_STATUS.PRINT, + BID_STATUS.LIST, + amount > 0 ? `印封费用 ${amount}` : '印封完成,进入投标执行', + ); + }); + return this.get(bidId, user); + } + + async completeExecution(bidId: string, user: AuthUser, dto: CompleteInput) { + this.assertModuleAccess(user); + if (!canBizOperate(user.roles)) { + throw new ForbiddenException('无权补全投标执行'); + } + const bidderIds = [...new Set((dto.bidderIds ?? []).filter(Boolean))]; + if (!bidderIds.length) { + throw new BadRequestException('请派投标人'); + } + await this.prisma.$transaction(async (tx) => { + const bid = await this.requireBid(tx, bidId); + if (bid.status !== BID_STATUS.LIST) { + throw new BadRequestException('当前不是投标执行状态'); + } + const bidders = await tx.user.findMany({ + where: { id: { in: bidderIds }, tenantId: '1', status: 'ACTIVE' }, + }); + if (bidders.length !== bidderIds.length) { + throw new BadRequestException('投标人无效'); + } + await tx.bidCase.update({ + where: { id: bid.id }, + data: { + status: BID_STATUS.PENDING, + bondMethod: dto.bondMethod || bid.bondMethod, + bondStatus: dto.bondStatus || bid.bondStatus, + delivererName: dto.delivererName || bid.delivererName, + priceCap: dto.priceCap || bid.priceCap, + quoteAmount: dto.quoteAmount || bid.quoteAmount, + securityLevel: dto.securityLevel || bid.securityLevel, + contactName: dto.contactName || bid.contactName, + contactPhone: dto.contactPhone || bid.contactPhone, + openAt: dto.openAt ? new Date(dto.openAt) : bid.openAt, + openPlace: dto.openPlace || bid.openPlace, + openMethod: dto.openMethod || bid.openMethod, + }, + }); + if (dto.bondAmount && dto.bondAmount > 0) { + await tx.bondRecord.create({ + data: { + tenantId: '1', + bondNo: await nextBondNo(tx), + bidCaseId: bid.id, + bondType: 'TENDER', + amount: dto.bondAmount, + status: dto.bondStatus === '已缴纳' ? 'PAID' : 'UNPAID', + paidAt: dto.bondStatus === '已缴纳' ? new Date() : null, + }, + }); + } + await tx.bidAssignee.deleteMany({ + where: { bidCaseId: bid.id, role: ASSIGN_ROLE.BIDDER }, + }); + await tx.bidAssignee.createMany({ + data: bidderIds.map((userId) => ({ + bidCaseId: bid.id, + userId, + role: ASSIGN_ROLE.BIDDER, + })), + }); + await tx.todoTask.updateMany({ + where: { bizId: bid.id, bizType: BIZ_TYPE.COMPLETE, status: 'OPEN' }, + data: { status: 'DONE' }, + }); + const openAt = dto.openAt ? new Date(dto.openAt) : bid.openAt; + await this.createTodos( + tx, + bid.id, + `开标反馈:${bid.name}`, + BIZ_TYPE.OPEN, + bidders, + openAt, + ); + await this.log( + tx, + bid.id, + user.id, + 'EXECUTION_DONE', + BID_STATUS.LIST, + BID_STATUS.PENDING, + `派投标人 ${bidders.map((b) => b.displayName).join('、')}`, + ); + }); + return this.get(bidId, user); + } + + async openResult(bidId: string, user: AuthUser, dto: OpenResultInput) { + if (!['WON', 'LOST', 'FAILED'].includes(dto.result)) { + throw new BadRequestException('开标结果无效'); + } + if (dto.result !== OPEN_RESULT.WON && !dto.costDisposition) { + throw new BadRequestException('未中标请选择售前费用归属:部门或营销'); + } + if ( + dto.costDisposition && + dto.costDisposition !== COST_DISPOSITION.DEPT && + dto.costDisposition !== COST_DISPOSITION.MARKETING + ) { + throw new BadRequestException('费用归属无效'); + } + await this.prisma.$transaction(async (tx) => { + const bid = await tx.bidCase.findFirst({ + where: { id: bidId, tenantId: '1' }, + include: { assignees: true }, + }); + if (!bid) throw new NotFoundException('投标事项不存在'); + if (bid.status !== BID_STATUS.PENDING) { + throw new BadRequestException('当前不是待开标状态'); + } + const isBidder = bid.assignees.some( + (a) => a.role === ASSIGN_ROLE.BIDDER && a.userId === user.id, + ); + if (!isBidder) { + throw new ForbiddenException('请由被派的投标人在个人办公待办里登记开标结果'); + } + const disposition = + dto.result === OPEN_RESULT.WON ? null : dto.costDisposition || COST_DISPOSITION.DEPT; + await tx.bidCase.update({ + where: { id: bid.id }, + data: { + status: dto.result, + resultNote: dto.comment?.trim() || null, + costDisposition: disposition, + openedResultAt: new Date(), + winnerName: dto.result === 'WON' ? null : dto.winnerName?.trim() || null, + winnerAmount: dto.result === 'WON' ? null : parseMoney(dto.winnerAmount), + }, + }); + if (disposition) { + await tx.expenseClaim.updateMany({ + where: { bidCaseId: bid.id }, + data: { costType: disposition }, + }); + } + await tx.todoTask.updateMany({ + where: { bizId: bid.id, bizType: BIZ_TYPE.OPEN, status: 'OPEN' }, + data: { status: 'DONE' }, + }); + const label = + dto.result === 'WON' ? '中标' : dto.result === 'LOST' ? '未中标' : '流标/废标'; + const extra = + disposition === 'DEPT' ? '售前费用归部门' : disposition === 'MARKETING' ? '售前费用归营销' : ''; + await this.log( + tx, + bid.id, + user.id, + 'OPEN_RESULT', + BID_STATUS.PENDING, + dto.result, + [label, extra, dto.comment?.trim()].filter(Boolean).join(';'), + ); + emitSms('BID_DONE', { + userIds: bid.createdById ? [bid.createdById] : [], + params: bidSmsParams(bid, { content: `${bid.name}已登记开标结果:${label}` }), + }); + if (dto.result === OPEN_RESULT.WON) { + const full = await tx.bidCase.findFirst({ + where: { id: bid.id }, + include: { party: true, legalEntity: true, expenses: { select: { amount: true } } }, + }); + if (!full) throw new NotFoundException('投标事项不存在'); + const spawned = await ensureWonLedger(tx, full, user.id); + await this.createTodos( + tx, + spawned.contract.id, + `完善合同草稿:${bid.name}`, + BIZ_TYPE.CONVERT_CONTRACT, + await this.bizDeptDirectors(tx), + ); + } + }); + return this.get(bidId, user); + } + + async terminate(bidId: string, user: AuthUser, comment?: string) { + this.assertModuleAccess(user); + if (!canTerminateBid(user.roles)) { + throw new ForbiddenException('无权终止投标'); + } + if (!comment?.trim()) { + throw new BadRequestException('终止请填写原因'); + } + await this.prisma.$transaction(async (tx) => { + const bid = await this.requireBid(tx, bidId); + if (!canTerminateFrom(bid.status)) { + throw new BadRequestException('当前状态不能终止'); + } + await this.closeAndTerminate(tx, bid, user, comment.trim(), 'MANUAL_TERMINATE'); + }); + if (!canSeeTerminated(user.roles, user.permissions)) { + return { id: bidId, status: BID_STATUS.TERMINATED }; + } + return this.get(bidId, user); + } + + async decideApproval(approvalId: string, user: AuthUser, dto: ReviewInput) { + const task = await this.prisma.approvalTask.findFirst({ + where: { id: approvalId, tenantId: '1' }, + }); + if (!task) throw new NotFoundException('审批任务不存在'); + if (task.assigneeId !== user.id) { + throw new ForbiddenException('这不是你的审批'); + } + if (task.status !== 'PENDING') { + throw new BadRequestException('该审批已办理'); + } + const allowed = [ + BIZ_TYPE.TECH_INITIAL, + BIZ_TYPE.TECH_FINAL, + BIZ_TYPE.REVIEW1, + BIZ_TYPE.REVIEW2, + ]; + if (!task.bizId || !allowed.includes(task.bizType as (typeof allowed)[number])) { + throw new BadRequestException('暂不支持该类型审批'); + } + return this.review(task.bizId, user, dto); + } + + private async bounceToDrafting( + tx: Tx, + bid: { id: string; status: string; name: string; reviewCycle: number }, + user: AuthUser, + comment: string, + stage: string, + ) { + const nextCycle = bid.reviewCycle + 1; + await tx.bidCase.update({ + where: { id: bid.id }, + data: { status: BID_STATUS.DRAFTING, reviewCycle: nextCycle }, + }); + await tx.approvalTask.updateMany({ + where: { bizId: bid.id, status: 'PENDING' }, + data: { status: 'CANCELLED' }, + }); + await tx.bidReviewer.updateMany({ + where: { bidCaseId: bid.id, result: 'PENDING' }, + data: { result: 'CANCELLED' }, + }); + const drafters = await tx.bidAssignee.findMany({ + where: { bidCaseId: bid.id, role: ASSIGN_ROLE.DRAFTER }, + }); + if (drafters.length) { + await tx.todoTask.createMany({ + data: drafters.map((d) => ({ + tenantId: '1', + title: `改标书:${bid.name}`, + bizType: BIZ_TYPE.DRAFT, + bizId: bid.id, + assigneeId: d.userId, + })), + }); + emitSms('BID_DRAFT', { + userIds: drafters.map((d) => d.userId), + params: bidSmsParams(bid), + }); + } + await this.log( + tx, + bid.id, + user.id, + stage === REVIEW_STAGE.REVIEW2 ? 'REVIEW2_REJECT' : 'REVIEW1_REJECT', + bid.status, + BID_STATUS.DRAFTING, + comment, + ); + } + + private async closeAndTerminate( + tx: Tx, + bid: { + id: string; + status: string; + bidNo?: string; + name?: string; + createdById?: string | null; + }, + user: AuthUser, + comment: string, + action: string, + ) { + await tx.bidCase.update({ + where: { id: bid.id }, + data: { status: BID_STATUS.TERMINATED }, + }); + await tx.approvalTask.updateMany({ + where: { bizId: bid.id, status: 'PENDING' }, + data: { status: 'CANCELLED' }, + }); + await tx.todoTask.updateMany({ + where: { bizId: bid.id, status: 'OPEN' }, + data: { status: 'CANCELLED' }, + }); + await tx.bidReviewer.updateMany({ + where: { bidCaseId: bid.id, result: 'PENDING' }, + data: { result: 'CANCELLED' }, + }); + await this.log(tx, bid.id, user.id, action, bid.status, BID_STATUS.TERMINATED, comment); + emitSms('BID_TERMINATED', { + userIds: await this.panXinghuiIds(tx), + params: bidSmsParams(bid, { content: `${bid.name}项目终止,终止原因:${comment}` }), + }); + } + + private async requireBid(tx: Tx, id: string) { + const bid = await tx.bidCase.findFirst({ where: { id, tenantId: '1' } }); + if (!bid) throw new NotFoundException('投标事项不存在'); + return bid; + } + + private async usersByRoles(tx: Tx, roles: string[]) { + return tx.user.findMany({ + where: { + tenantId: '1', + status: 'ACTIVE', + roles: { some: { role: { code: { in: roles } } } }, + }, + }); + } + + private async bizDeptDirectors(tx: Tx) { + return findBizDirectors(tx); + } + + /** 投标终止按业务规则知会潘兴辉;其他环节不做固定抄送。 */ + private async panXinghuiIds(tx: Tx) { + const users = await tx.user.findMany({ + where: { + tenantId: '1', + status: 'ACTIVE', + OR: [{ username: 'panxinghui' }, { displayName: '潘兴辉' }], + }, + select: { id: true }, + }); + return users.map((u) => u.id); + } + + private async createTodos( + tx: Tx, + bidId: string, + title: string, + bizType: string, + users: { id: string }[], + dueAt?: Date | null, + ) { + const seen = new Set(); + const assignees = users.filter((u) => u.id && !seen.has(u.id) && seen.add(u.id)); + if (!assignees.length) return; + await tx.todoTask.createMany({ + data: assignees.map((u) => ({ + tenantId: '1', + title, + bizType, + bizId: bidId, + assigneeId: u.id, + dueAt: dueAt ?? undefined, + })), + }); + const smsKey = + bizType === BIZ_TYPE.TECH_INITIAL + ? 'BID_TECH_INITIAL' + : bizType === BIZ_TYPE.FETCH + ? 'BID_FETCH' + : bizType === BIZ_TYPE.ASSIGN_FETCH + ? 'BID_ASSIGN_FETCH' + : bizType === BIZ_TYPE.TECH_FINAL + ? 'BID_TECH_FINAL' + : bizType === BIZ_TYPE.DRAFT + ? 'BID_DRAFT' + : bizType === BIZ_TYPE.ASSIGN_DRAFT + ? 'BID_ASSIGN_DRAFT' + : bizType === BIZ_TYPE.PRINT + ? 'BID_PRINT' + : bizType === BIZ_TYPE.COMPLETE + ? 'BID_EXECUTION' + : bizType === BIZ_TYPE.OPEN + ? 'BID_ASSIGN' + : bizType === BIZ_TYPE.REVIEW1 || bizType === BIZ_TYPE.REVIEW2 + ? 'BID_REVIEW' + : null; + if (smsKey) { + const bid = await tx.bidCase.findFirst({ + where: { id: bidId }, + select: { + bidNo: true, + name: true, + openAt: true, + openPlace: true, + contactName: true, + contactPhone: true, + }, + }); + emitSms(smsKey, { + userIds: assignees.map((u) => u.id), + params: bidSmsParams(bid || {}, { content: title }), + }); + } + } + + private async review1Ids( + tx: Tx, + bid: { id: string; assignees: { role: string; userId: string }[] }, + ) { + const assigned = bid.assignees + .filter((a) => a.role === ASSIGN_ROLE.REVIEW1) + .map((a) => a.userId); + if (assigned.length) return [...new Set(assigned)]; + const staff = await this.bizDeptDirectors(tx); + return staff.map((u) => u.id); + } + + private async log( + tx: Tx, + bidCaseId: string, + actorId: string, + action: string, + fromStatus?: string, + toStatus?: string, + comment?: string, + ) { + await tx.bidActionLog.create({ + data: { bidCaseId, actorId, action, fromStatus, toStatus, comment }, + }); + } + + private approveAction(stage: string) { + if (stage === REVIEW_STAGE.TECH_FINAL) return 'FINAL_APPROVE'; + if (stage === REVIEW_STAGE.REVIEW1) return 'REVIEW1_APPROVE'; + if (stage === REVIEW_STAGE.REVIEW2) return 'REVIEW2_APPROVE'; + return 'INITIAL_APPROVE'; + } + + private async loadSpecialCoordinators(db: PrismaService | Tx) { + // 特殊筛选/特殊制标由发起人从全体在职人员中指定办理人,不再写死两位协调人。 + return db.user.findMany({ + where: { + tenantId: '1', + status: 'ACTIVE', + }, + select: { id: true, displayName: true, username: true, employee: { select: { department: { select: { name: true } } } } }, + orderBy: { displayName: 'asc' }, + }); + } + + private myActions( + bid: { + createdById?: string | null; + status: string; + sourceType?: string | null; + reviewCycle: number; + reviewers: { userId: string; stage: string; result: string; cycle: number }[]; + assignees: { userId: string; role: string }[]; + contracts?: { status: string }[]; + }, + user: AuthUser, + ) { + const stage = stageForStatus(bid.status); + const canReview = Boolean( + stage && + bid.reviewers.some( + (r) => + r.userId === user.id && + r.stage === stage && + r.result === 'PENDING' && + r.cycle === bid.reviewCycle, + ), + ); + const isDrafter = bid.assignees.some( + (a) => a.role === ASSIGN_ROLE.DRAFTER && a.userId === user.id, + ); + const isBidder = bid.assignees.some( + (a) => a.role === ASSIGN_ROLE.BIDDER && a.userId === user.id, + ); + const manageDraft = canManageThisDraft(user.roles, bid.sourceType, bid.assignees, user.id); + const hasActiveContract = (bid.contracts ?? []).some((c) => + ['DRAFT', 'PENDING', 'APPROVED'].includes(c.status), + ); + return { + canReview, + reviewStage: canReview ? stage : null, + reviewKind: canReview && stage && isCommercialStage(stage) ? 'commercial' : 'tech', + canAssignFetcher: canAssignFetcher( + user.roles, + bid.sourceType, + bid.assignees, + user.id, + bid.status, + ), + canFetch: + bid.status === BID_STATUS.FETCH && + canFetchThisBid(user.roles, bid.sourceType, bid.assignees, user.id) && + (!isSpecialBid(bid.sourceType) || + bid.assignees.some((a) => a.role === ASSIGN_ROLE.FETCHER)), + canUploadFetchFile: + bid.status === BID_STATUS.FETCH && + (user.roles.includes('admin') || + bid.assignees.some((a) => a.role === ASSIGN_ROLE.FETCHER && a.userId === user.id)), + canAssignDrafter: bid.status === BID_STATUS.DRAFTING && manageDraft, + canSubmitDraft: bid.status === BID_STATUS.DRAFTING && (isDrafter || manageDraft || (!isSpecialBid(bid.sourceType) && canBizOperate(user.roles))), + canPrint: bid.status === BID_STATUS.PRINT && canBizOperate(user.roles), + canCompleteExecution: bid.status === BID_STATUS.LIST && canBizOperate(user.roles), + canOpenResult: bid.status === BID_STATUS.PENDING && isBidder, + canEditCompetitor: + ['TERMINATED', 'LOST', 'FAILED'].includes(bid.status) && + (canSeeTerminated(user.roles, user.permissions) || canBizOperate(user.roles) || bid.assignees.some((a) => a.userId === user.id)), + canEditLegalEntity: + user.username.trim().toLowerCase() === 'panxinghui' || + user.displayName.trim() === '潘兴辉' || + user.roles.includes('biz_director') || + user.roles.includes('rd_director') || + user.roles.includes('admin') || + user.roles.includes('owner'), + canConvertToContract: + bid.status === BID_STATUS.WON && canBizOperate(user.roles) && !hasActiveContract, + canTerminate: canTerminateFrom(bid.status) && canTerminateBid(user.roles), + canRestore: bid.status === BID_STATUS.TERMINATED && canTerminateBid(user.roles), + canRollback: Boolean(previousBidStatus(bid.status, bid.sourceType)) && ( + canRollbackBid(user.roles) || + canReview || + (bid.status === BID_STATUS.FETCH && + canFetchThisBid(user.roles, bid.sourceType, bid.assignees, user.id)) || + (bid.status === BID_STATUS.DRAFTING && (isDrafter || manageDraft)) || + (bid.status === BID_STATUS.PRINT && canBizOperate(user.roles)) || + (bid.status === BID_STATUS.LIST && canBizOperate(user.roles)) || + (bid.status === BID_STATUS.PENDING && isBidder) + ), + rollbackTarget: previousBidStatus(bid.status, bid.sourceType), + }; + } + + async recordCompetitor( + bidId: string, + user: AuthUser, + dto: { winnerName: string; winnerAmount: number | string; winnerNote?: string }, + ) { + this.assertModuleAccess(user); + const bid = await this.prisma.bidCase.findFirst({ + where: { id: bidId, tenantId: '1' }, + include: { assignees: true }, + }); + if (!bid) throw new NotFoundException('投标事项不存在'); + if (!['TERMINATED', 'LOST', 'FAILED'].includes(bid.status)) { + throw new BadRequestException('只有未中、流标或已终止的事项才能登记对方中标信息'); + } + const assigned = bid.assignees.some((a) => a.userId === user.id); + if (!assigned && !canSeeTerminated(user.roles, user.permissions) && !canBizOperate(user.roles)) { + throw new ForbiddenException('无权登记该项目的中标信息'); + } + const winnerName = dto.winnerName.trim(); + const winnerAmount = parseMoney(dto.winnerAmount); + if (!winnerName) throw new BadRequestException('请填写中标单位'); + if (winnerAmount == null) throw new BadRequestException('请填写中标金额'); + await this.prisma.$transaction(async (tx) => { + await tx.bidCase.update({ + where: { id: bid.id }, + data: { + winnerName, + winnerAmount, + winnerNote: dto.winnerNote?.trim() || null, + }, + }); + await this.log( + tx, + bid.id, + user.id, + 'UPDATE_COMPETITOR_WIN', + bid.status, + bid.status, + `登记中标单位:${winnerName},中标金额:${winnerAmount.toString()}`, + ); + }); + return this.get(bidId, user); + } + + async updateLegalEntity(bidId: string, user: AuthUser, legalEntityId: string) { + this.assertModuleAccess(user); + const allowed = + user.username.trim().toLowerCase() === 'panxinghui' || + user.displayName.trim() === '潘兴辉' || + user.roles.includes('biz_director') || + user.roles.includes('rd_director') || + user.roles.includes('admin') || + user.roles.includes('owner'); + if (!allowed) throw new ForbiddenException('只有潘兴辉、商务总监或研发总监可以修改参与主体'); + + const [bid, entity] = await Promise.all([ + this.prisma.bidCase.findFirst({ where: { id: bidId, tenantId: '1' } }), + this.prisma.legalEntity.findFirst({ where: { id: legalEntityId, tenantId: '1' } }), + ]); + if (!bid) throw new NotFoundException('投标事项不存在'); + if (!entity) throw new BadRequestException('参与主体不存在'); + await this.prisma.$transaction(async (tx) => { + await tx.bidCase.update({ where: { id: bid.id }, data: { legalEntityId: entity.id } }); + await this.log(tx, bid.id, user.id, 'UPDATE_LEGAL_ENTITY', bid.status, bid.status, `修改参与主体为 ${entity.name}`); + }); + return this.get(bidId, user); + } + + async rollback(bidId: string, user: AuthUser, comment: string) { + this.assertModuleAccess(user); + const note = comment.trim(); + if (!note) throw new BadRequestException('退回请填写原因'); + await this.prisma.$transaction(async (tx) => { + const bid = await tx.bidCase.findFirst({ + where: { id: bidId, tenantId: '1' }, + include: { reviewers: true, assignees: true }, + }); + if (!bid) throw new NotFoundException('投标事项不存在'); + const target = previousBidStatus(bid.status, bid.sourceType); + if (!target) throw new BadRequestException('当前步骤不能再退回'); + const stage = stageForStatus(bid.status); + const isReviewer = Boolean( + stage && + bid.reviewers.some( + (r) => + r.userId === user.id && + r.stage === stage && + r.result === 'PENDING' && + r.cycle === bid.reviewCycle, + ), + ); + const isDrafter = bid.assignees.some((a) => a.role === ASSIGN_ROLE.DRAFTER && a.userId === user.id); + const manageDraft = canManageThisDraft(user.roles, bid.sourceType, bid.assignees, user.id); + const allowed = + canRollbackBid(user.roles) || + isReviewer || + (bid.status === BID_STATUS.FETCH && + canFetchThisBid(user.roles, bid.sourceType, bid.assignees, user.id)) || + (bid.status === BID_STATUS.DRAFTING && (isDrafter || manageDraft)) || + (bid.status === BID_STATUS.PRINT && canBizOperate(user.roles)) || + (bid.status === BID_STATUS.LIST && canBizOperate(user.roles)) || + (bid.status === BID_STATUS.PENDING && + bid.assignees.some((a) => a.role === ASSIGN_ROLE.BIDDER && a.userId === user.id)); + if (!allowed) throw new ForbiddenException('无权退回上一步'); + + const from = bid.status; + const nextCycle = bid.reviewCycle + 1; + await tx.bidCase.update({ + where: { id: bid.id }, + data: { status: target, reviewCycle: nextCycle }, + }); + await tx.approvalTask.updateMany({ + where: { bizId: bid.id, status: 'PENDING' }, + data: { status: 'CANCELLED' }, + }); + await tx.todoTask.updateMany({ + where: { bizId: bid.id, status: 'OPEN' }, + data: { status: 'CANCELLED' }, + }); + await tx.bidReviewer.updateMany({ + where: { bidCaseId: bid.id, result: 'PENDING' }, + data: { result: 'CANCELLED' }, + }); + await this.restoreStep(tx, { ...bid, reviewCycle: nextCycle }, target); + await this.log(tx, bid.id, user.id, 'ROLLBACK', from, target, note); + }); + return this.get(bidId, user); + } + + async restoreTerminated(bidId: string, user: AuthUser) { + this.assertModuleAccess(user); + if (!canTerminateBid(user.roles)) throw new ForbiddenException('无权恢复终止投标'); + await this.prisma.$transaction(async (tx) => { + const bid = await tx.bidCase.findFirst({ + where: { id: bidId, tenantId: '1' }, + include: { + reviewers: true, + assignees: true, + actionLogs: { where: { toStatus: BID_STATUS.TERMINATED }, orderBy: { createdAt: 'desc' }, take: 1 }, + }, + }); + if (!bid) throw new NotFoundException('投标事项不存在'); + if (bid.status !== BID_STATUS.TERMINATED) throw new BadRequestException('当前事项未终止'); + const loggedTarget = bid.actionLogs[0]?.fromStatus; + const restorable = new Set([ + BID_STATUS.TECH_INITIAL, + BID_STATUS.FETCH, + BID_STATUS.TECH_FINAL, + BID_STATUS.DRAFTING, + BID_STATUS.REVIEW1, + BID_STATUS.REVIEW2, + BID_STATUS.PRINT, + BID_STATUS.LIST, + BID_STATUS.PENDING, + ]); + const target = loggedTarget && restorable.has(loggedTarget) + ? loggedTarget + : inferLegacyRestoreStatus(bid.reviewers, bid.assignees); + const nextCycle = bid.reviewCycle + 1; + await tx.bidCase.update({ where: { id: bid.id }, data: { status: target, reviewCycle: nextCycle } }); + await tx.approvalTask.updateMany({ + where: { bizId: bid.id, status: 'PENDING' }, + data: { status: 'CANCELLED' }, + }); + await tx.todoTask.updateMany({ + where: { bizId: bid.id, status: 'OPEN' }, + data: { status: 'CANCELLED' }, + }); + await tx.bidReviewer.updateMany({ + where: { bidCaseId: bid.id, result: 'PENDING' }, + data: { result: 'CANCELLED' }, + }); + await this.restoreStep(tx, { ...bid, reviewCycle: nextCycle }, target); + await this.log( + tx, + bid.id, + user.id, + 'RESTORE_TERMINATED', + BID_STATUS.TERMINATED, + target, + loggedTarget ? '恢复到终止前步骤' : '旧数据缺少终止日志,按现有办理记录恢复', + ); + }); + return this.get(bidId, user); + } + + private async restoreStep( + tx: Tx, + bid: { + id: string; + name: string; + sourceType?: string | null; + applyEndAt?: Date | null; + reviewCycle: number; + assignees: { userId: string; role: string }[]; + reviewers: { userId: string; stage: string }[]; + }, + target: string, + ) { + const reopen = async (stage: string, title: string, bizType: string) => { + const ids = [...new Set(bid.reviewers.filter((r) => r.stage === stage).map((r) => r.userId))]; + if (!ids.length) return; + await tx.bidReviewer.createMany({ + data: ids.map((userId) => ({ + bidCaseId: bid.id, + userId, + stage, + cycle: bid.reviewCycle, + })), + }); + await tx.approvalTask.createMany({ + data: ids.map((assigneeId) => ({ + tenantId: '1', + title: `${title}:${bid.name}`, + bizType, + bizId: bid.id, + assigneeId, + })), + }); + emitSms('BID_REVIEW', { userIds: ids, params: bidSmsParams(bid) }); + }; + + if (target === BID_STATUS.TECH_INITIAL) { + await reopen(REVIEW_STAGE.TECH_INITIAL, '技术初审(退回重审)', BIZ_TYPE.TECH_INITIAL); + return; + } + if (target === BID_STATUS.FETCH) { + const fetchers = bid.assignees.filter((a) => a.role === ASSIGN_ROLE.FETCHER).map((a) => ({ id: a.userId })); + if (fetchers.length) { + await this.createTodos(tx, bid.id, `取标(退回重做):${bid.name}`, BIZ_TYPE.FETCH, fetchers, bid.applyEndAt); + } else if (isSpecialBid(bid.sourceType)) { + const owners = bid.assignees + .filter((a) => a.role === ASSIGN_ROLE.COORDINATOR) + .map((a) => ({ id: a.userId })); + await this.createTodos( + tx, + bid.id, + `特殊筛选派取标(退回重做):${bid.name}`, + BIZ_TYPE.ASSIGN_FETCH, + owners, + bid.applyEndAt, + ); + } else { + await this.createTodos( + tx, + bid.id, + `派取标人(退回重做):${bid.name}`, + BIZ_TYPE.ASSIGN_FETCH, + await this.bizDeptDirectors(tx), + bid.applyEndAt, + ); + } + return; + } + if (target === BID_STATUS.TECH_FINAL) { + await reopen(REVIEW_STAGE.TECH_FINAL, '技术终审(退回重审)', BIZ_TYPE.TECH_FINAL); + return; + } + if (target === BID_STATUS.DRAFTING) { + const drafters = bid.assignees.filter((a) => a.role === ASSIGN_ROLE.DRAFTER).map((a) => ({ id: a.userId })); + if (drafters.length) { + await this.createTodos(tx, bid.id, `改标书(退回重做):${bid.name}`, BIZ_TYPE.DRAFT, drafters); + } else { + const owners = isSpecialBid(bid.sourceType) + ? bid.assignees.filter((a) => a.role === ASSIGN_ROLE.COORDINATOR).map((a) => ({ id: a.userId })) + : [...(await this.bizDeptDirectors(tx)), ...(await this.usersByRoles(tx, ['owner']))]; + await this.createTodos(tx, bid.id, `派制标(退回重做):${bid.name}`, BIZ_TYPE.ASSIGN_DRAFT, owners); + } + return; + } + if (target === BID_STATUS.REVIEW1) { + await reopen(REVIEW_STAGE.REVIEW1, '商务一审(退回重审)', BIZ_TYPE.REVIEW1); + return; + } + if (target === BID_STATUS.REVIEW2) { + await reopen(REVIEW_STAGE.REVIEW2, '商务二审(退回重审)', BIZ_TYPE.REVIEW2); + return; + } + if (target === BID_STATUS.PRINT) { + await this.createTodos( + tx, + bid.id, + `印封(退回重做):${bid.name}`, + BIZ_TYPE.PRINT, + await this.bizDeptDirectors(tx), + ); + return; + } + if (target === BID_STATUS.LIST) { + await this.createTodos( + tx, + bid.id, + `投标执行(退回重做):${bid.name}`, + BIZ_TYPE.COMPLETE, + await this.bizDeptDirectors(tx), + ); + return; + } + if (target === BID_STATUS.PENDING) { + const bidders = bid.assignees.filter((a) => a.role === ASSIGN_ROLE.BIDDER).map((a) => ({ id: a.userId })); + await this.createTodos(tx, bid.id, `开标反馈(恢复):${bid.name}`, BIZ_TYPE.OPEN, bidders); + } + } +} + +function parseMoney(v?: number | string | null) { + if (v == null || v === '') return null; + const n = Number(v); + if (!Number.isFinite(n) || n < 0) return null; + return n; +} diff --git a/apps/api/src/business/business.controller.ts b/apps/api/src/business/business.controller.ts new file mode 100644 index 0000000..f6d15d0 --- /dev/null +++ b/apps/api/src/business/business.controller.ts @@ -0,0 +1,62 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { TECH_LEAD_ROLES } from '../common/role-groups'; +import { PrismaService } from '../prisma/prisma.service'; + +@ApiTags('business') +@ApiBearerAuth() +@Controller() +export class BusinessController { + constructor(private readonly prisma: PrismaService) {} + + @Get('directors') + async directors() { + const users = await this.prisma.user.findMany({ + where: { + tenantId: '1', + roles: { some: { role: { code: { in: ['admin', 'tech_director', ...TECH_LEAD_ROLES] } } } }, + }, + include: { employee: { include: { department: true } }, roles: { include: { role: true } } }, + }); + return users.map((u) => ({ + id: u.id, + name: u.displayName, + department: u.employee?.department?.name, + roles: u.roles.map((r) => r.role.code), + })); + } + + @Get('staff') + async staff() { + const users = await this.prisma.user.findMany({ + where: { tenantId: '1', status: 'ACTIVE' }, + include: { employee: { include: { department: true } } }, + orderBy: { displayName: 'asc' }, + }); + const avatars = await this.prisma.fileAsset.findMany({ + where: { tenantId: '1', bizType: 'USER_AVATAR', bizId: { in: users.map((u) => u.id) } }, + select: { id: true, bizId: true }, + orderBy: { createdAt: 'desc' }, + }); + const avatarByUser = new Map(); + for (const a of avatars) { + if (!avatarByUser.has(a.bizId)) avatarByUser.set(a.bizId, a.id); + } + return users.map((u) => { + const acc = u.employee?.bankAccount?.replace(/\s/g, '') || ''; + return { + id: u.id, + name: u.displayName, + username: u.username, + department: u.employee?.department?.name, + title: u.employee?.title, + mobile: u.employee?.mobile || null, + email: u.employee?.email || null, + avatarFileId: avatarByUser.get(u.id) || null, + bankReady: Boolean(acc), + bankName: u.employee?.bankName || null, + bankAccountMasked: acc ? `${'*'.repeat(Math.max(4, acc.length - 4))}${acc.slice(-4)}` : null, + }; + }); + } +} diff --git a/apps/api/src/common/data-scope.ts b/apps/api/src/common/data-scope.ts new file mode 100644 index 0000000..52da161 --- /dev/null +++ b/apps/api/src/common/data-scope.ts @@ -0,0 +1,105 @@ +import { ForbiddenException } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; +import type { AuthUser } from './decorators/current-user.decorator'; +import { PrismaService } from '../prisma/prisma.service'; + +export type DataScope = 'ALL' | 'DEPT' | 'SELF'; + +const RANK: Record = { SELF: 0, DEPT: 1, ALL: 2 }; + +export function mergeDataScope(scopes: string[]): DataScope { + let best: DataScope = 'SELF'; + for (const s of scopes) { + const v = s as DataScope; + if (RANK[v] > RANK[best]) best = v; + } + return best; +} + +export function effectiveScope(user: AuthUser): DataScope { + if (user.roles.includes('admin') || user.roles.includes('owner')) return 'ALL'; + return user.dataScope || 'SELF'; +} + +export async function descendantDeptIds(prisma: PrismaService, rootId?: string | null) { + if (!rootId) return [] as string[]; + const all = await prisma.department.findMany({ + where: { tenantId: '1' }, + select: { id: true, parentId: true }, + }); + const ids = new Set([rootId]); + let grew = true; + while (grew) { + grew = false; + for (const d of all) { + if (d.parentId && ids.has(d.parentId) && !ids.has(d.id)) { + ids.add(d.id); + grew = true; + } + } + } + return [...ids]; +} + +function empInScope(user: AuthUser, deptIds: string[]): Prisma.EmployeeWhereInput { + return { + OR: [ + { departmentId: { in: deptIds } }, + ...(user.employeeId + ? [{ managerId: user.employeeId }, { managerIds: { contains: user.employeeId } }] + : []), + ], + }; +} + +/** 本人 / 本部门及下级 / 全公司 */ +export async function ownerScope( + prisma: PrismaService, + user: AuthUser, + owner: 'createdById' | 'applicantId', +): Promise> { + const scope = effectiveScope(user); + if (scope === 'ALL') return {}; + if (scope === 'SELF' || !user.departmentId) return { [owner]: user.id }; + const deptIds = await descendantDeptIds(prisma, user.departmentId); + const rel = owner === 'createdById' ? 'createdBy' : 'applicant'; + return { + OR: [{ [owner]: user.id }, { [rel]: { employee: empInScope(user, deptIds) } }], + }; +} + +export function withAnd(where: T, extra: Record): T { + if (!extra || !Object.keys(extra).length) return where; + const prev = where.AND; + const list = Array.isArray(prev) ? [...prev] : prev ? [prev] : []; + list.push(extra); + where.AND = list; + return where; +} + +export async function canSeeOwner(prisma: PrismaService, user: AuthUser, ownerId?: string | null) { + if (!ownerId) return true; + const scope = effectiveScope(user); + if (scope === 'ALL') return true; + if (ownerId === user.id) return true; + if (scope !== 'DEPT' || !user.departmentId) return false; + const owner = await prisma.user.findUnique({ + where: { id: ownerId }, + include: { employee: true }, + }); + if (!owner?.employee) return false; + const deptIds = await descendantDeptIds(prisma, user.departmentId); + if (owner.employee.departmentId && deptIds.includes(owner.employee.departmentId)) return true; + const extra = String(owner.employee.managerIds || '') + .split(/[,,、\s]+/) + .filter(Boolean); + return Boolean( + user.employeeId && + (owner.employee.managerId === user.employeeId || extra.includes(user.employeeId)), + ); +} + +export async function assertCanSeeOwner(prisma: PrismaService, user: AuthUser, ownerId?: string | null) { + if (await canSeeOwner(prisma, user, ownerId)) return; + throw new ForbiddenException('超出数据范围,无权查看'); +} diff --git a/apps/api/src/common/decorators/auth.decorators.ts b/apps/api/src/common/decorators/auth.decorators.ts new file mode 100644 index 0000000..c5eed81 --- /dev/null +++ b/apps/api/src/common/decorators/auth.decorators.ts @@ -0,0 +1,8 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); + +export const PERMISSIONS_KEY = 'permissions'; +export const RequirePermissions = (...codes: string[]) => + SetMetadata(PERMISSIONS_KEY, codes); diff --git a/apps/api/src/common/decorators/current-user.decorator.ts b/apps/api/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..3649000 --- /dev/null +++ b/apps/api/src/common/decorators/current-user.decorator.ts @@ -0,0 +1,18 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export interface AuthUser { + id: string; + username: string; + displayName: string; + permissions: string[]; + roles: string[]; + employeeId?: string | null; + departmentId?: string | null; + dataScope: 'ALL' | 'DEPT' | 'SELF'; +} + +export const CurrentUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): AuthUser => { + return ctx.switchToHttp().getRequest().user as AuthUser; + }, +); diff --git a/apps/api/src/common/doc-no.spec.ts b/apps/api/src/common/doc-no.spec.ts new file mode 100644 index 0000000..0b5b5f5 --- /dev/null +++ b/apps/api/src/common/doc-no.spec.ts @@ -0,0 +1,15 @@ +import { nextSerial } from './doc-no'; + +describe('nextSerial', () => { + it('starts at 00001 when empty', () => { + expect(nextSerial([], 'BX-2026-')).toBe('BX-2026-00001'); + }); + + it('uses max+1 so a hole in the middle does not collide', () => { + expect(nextSerial(['BX-2026-00001', 'BX-2026-00005'], 'BX-2026-')).toBe('BX-2026-00006'); + }); + + it('ignores old date-style numbers that share the year digits', () => { + expect(nextSerial(['BX-20260617-005', 'BX-2026-00002'], 'BX-2026-')).toBe('BX-2026-00003'); + }); +}); diff --git a/apps/api/src/common/doc-no.ts b/apps/api/src/common/doc-no.ts new file mode 100644 index 0000000..266a4d5 --- /dev/null +++ b/apps/api/src/common/doc-no.ts @@ -0,0 +1,84 @@ +import { Prisma } from '@prisma/client'; + +const DOC_NO_LOCK: Record = { BX: 91001, JK: 91002, BZ: 91003 }; + +export function nextSerial(existing: string[], prefix: string, pad = 5) { + let max = 0; + for (const no of existing) { + if (!no.startsWith(prefix)) continue; + const tail = no.slice(prefix.length); + if (/^\d+$/.test(tail)) { + const n = Number(tail); + if (Number.isFinite(n)) max = Math.max(max, n); + } + } + return `${prefix}${String(max + 1).padStart(pad, '0')}`; +} + +export function isUniqueConflict(e: unknown) { + return e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002'; +} + +export async function withUniqueRetry(fn: () => Promise, times = 8): Promise { + let last: unknown; + for (let i = 0; i < times; i += 1) { + try { + return await fn(); + } catch (e) { + last = e; + if (!isUniqueConflict(e)) throw e; + } + } + throw last instanceof Error ? last : new Error('单号冲突,请重试'); +} + +type LockTx = { $executeRaw: Prisma.TransactionClient['$executeRaw'] }; + +export async function lockDocNo(tx: LockTx, prefix: 'BX' | 'JK' | 'BZ') { + const key = DOC_NO_LOCK[prefix]; + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${key}::bigint)`; +} + +type ClaimDb = { + expenseClaim: { + findMany: (args: { + where: { tenantId: string; claimNo: { startsWith: string } }; + select: { claimNo: true }; + }) => Promise<{ claimNo: string }[]>; + }; +}; + +export async function nextExpenseClaimNo(tx: LockTx & ClaimDb, year = new Date().getFullYear()) { + await lockDocNo(tx, 'BX'); + const prefix = `BX-${year}-`; + const rows = await tx.expenseClaim.findMany({ + where: { tenantId: '1', claimNo: { startsWith: prefix } }, + select: { claimNo: true }, + }); + return nextSerial( + rows.map((r) => r.claimNo), + prefix, + ); +} + +type BondDb = { + bondRecord: { + findMany: (args: { + where: { tenantId: string; bondNo: { startsWith: string } }; + select: { bondNo: true }; + }) => Promise<{ bondNo: string }[]>; + }; +}; + +export async function nextBondNo(tx: LockTx & BondDb, year = new Date().getFullYear()) { + await lockDocNo(tx, 'BZ'); + const prefix = `BZ-${year}-`; + const rows = await tx.bondRecord.findMany({ + where: { tenantId: '1', bondNo: { startsWith: prefix } }, + select: { bondNo: true }, + }); + return nextSerial( + rows.map((r) => r.bondNo), + prefix, + ); +} diff --git a/apps/api/src/common/dto/page-query.dto.ts b/apps/api/src/common/dto/page-query.dto.ts new file mode 100644 index 0000000..2d43828 --- /dev/null +++ b/apps/api/src/common/dto/page-query.dto.ts @@ -0,0 +1,24 @@ +import { IsInt, IsOptional, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class PageQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize = 10; + + @IsOptional() + keyword?: string; +} + +export function pageMeta(page: number, pageSize: number, total: number) { + return { page, pageSize, total }; +} diff --git a/apps/api/src/common/filters/http-exception.filter.ts b/apps/api/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..6f66440 --- /dev/null +++ b/apps/api/src/common/filters/http-exception.filter.ts @@ -0,0 +1,84 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Injectable, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { Prisma } from '@prisma/client'; +import { REQUEST_ID_HEADER } from '../middleware/request-id.middleware'; +import { PrismaService } from '../../prisma/prisma.service'; +import { LOG_ACTION, writeOpLog } from '../../system/op-log'; +import type { AuthUser } from '../decorators/current-user.decorator'; + +function publicErrorMessage(exception: unknown) { + if (exception instanceof Prisma.PrismaClientKnownRequestError) { + if (exception.code === 'P2002') return '单号已被占用,请再试一次'; + return '保存失败,请稍后重试'; + } + if (exception instanceof Error) { + if (/Invalid `prisma\.|Unique constraint failed/i.test(exception.message)) { + return '单号已被占用,请再试一次'; + } + return exception.message; + } + return '服务器内部错误'; +} + +@Catch() +@Injectable() +export class HttpErrorFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpErrorFilter.name); + + constructor(private readonly prisma: PrismaService) {} + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const res = ctx.getResponse(); + const req = ctx.getRequest(); + const requestId = (req.headers[REQUEST_ID_HEADER] as string) || ''; + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = '服务器内部错误'; + if (exception instanceof HttpException) { + status = exception.getStatus(); + const payload = exception.getResponse(); + if (typeof payload === 'string') { + message = payload; + } else if (typeof payload === 'object' && payload && 'message' in payload) { + const raw = (payload as { message: string | string[] }).message; + message = Array.isArray(raw) ? raw.join('; ') : raw; + } + } else { + message = publicErrorMessage(exception); + } + + this.logger.error( + `${req.method} ${req.url} ${status} ${message}`, + exception instanceof Error ? exception.stack : undefined, + ); + + if (status === HttpStatus.FORBIDDEN) { + void writeOpLog(this.prisma, { + userId: req.user?.id, + username: req.user?.username, + action: LOG_ACTION.FORBIDDEN, + path: `${req.method} ${req.originalUrl || req.url}`, + detail: message, + success: false, + requestId, + ip: req.ip, + }); + } + + res.status(status).json({ + code: status, + message, + data: null, + requestId, + }); + } +} diff --git a/apps/api/src/common/guards/auth.guards.ts b/apps/api/src/common/guards/auth.guards.ts new file mode 100644 index 0000000..798180a --- /dev/null +++ b/apps/api/src/common/guards/auth.guards.ts @@ -0,0 +1,37 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { IS_PUBLIC_KEY } from '../decorators/auth.decorators'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private readonly reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + return super.canActivate(context); + } +} + +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const required = this.reflector.getAllAndOverride('permissions', [ + context.getHandler(), + context.getClass(), + ]); + if (!required?.length) return true; + const user = context.switchToHttp().getRequest().user; + if (!user?.permissions) return false; + if (user.roles?.includes('admin')) return true; + return required.every((code) => user.permissions.includes(code)); + } +} diff --git a/apps/api/src/common/interceptors/response.interceptor.ts b/apps/api/src/common/interceptors/response.interceptor.ts new file mode 100644 index 0000000..3ab8093 --- /dev/null +++ b/apps/api/src/common/interceptors/response.interceptor.ts @@ -0,0 +1,40 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, + StreamableFile, +} from '@nestjs/common'; +import { Observable, map } from 'rxjs'; +import { REQUEST_ID_HEADER } from '../middleware/request-id.middleware'; + +export interface ApiResponse { + code: number; + message: string; + data: T; + requestId: string; +} + +@Injectable() +export class ResponseInterceptor + implements NestInterceptor | StreamableFile> +{ + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable | StreamableFile> { + const req = context.switchToHttp().getRequest(); + const requestId = (req.headers[REQUEST_ID_HEADER] as string) || ''; + return next.handle().pipe( + map((data) => { + if (data instanceof StreamableFile) return data; + return { + code: 0, + message: 'ok', + data: (data ?? null) as T, + requestId, + }; + }), + ); + } +} diff --git a/apps/api/src/common/middleware/request-id.middleware.ts b/apps/api/src/common/middleware/request-id.middleware.ts new file mode 100644 index 0000000..19afc8b --- /dev/null +++ b/apps/api/src/common/middleware/request-id.middleware.ts @@ -0,0 +1,15 @@ +import { randomUUID } from 'crypto'; +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { NextFunction, Request, Response } from 'express'; + +export const REQUEST_ID_HEADER = 'x-request-id'; + +@Injectable() +export class RequestIdMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction) { + const id = (req.headers[REQUEST_ID_HEADER] as string) || randomUUID(); + req.headers[REQUEST_ID_HEADER] = id; + res.setHeader(REQUEST_ID_HEADER, id); + next(); + } +} diff --git a/apps/api/src/common/org-chain.spec.ts b/apps/api/src/common/org-chain.spec.ts new file mode 100644 index 0000000..72c74a8 --- /dev/null +++ b/apps/api/src/common/org-chain.spec.ts @@ -0,0 +1,42 @@ +import { assembleApprovalChain } from './org-chain'; + +const staff = { id: 'staff', displayName: '员工甲', username: 'a' }; +const pm = { id: 'pm', displayName: '项目经理', username: 'pm' }; +const dir = { id: 'dir', displayName: '研发总监', username: 'rd' }; +const pan = { id: 'pan', displayName: '潘兴辉', username: 'pan' }; +const biz = { id: 'biz', displayName: '王迪', username: 'wangdi' }; + +describe('assembleApprovalChain', () => { + it('staff goes PM → dept director → 潘兴辉 → 商务总监', () => { + expect( + assembleApprovalChain({ + applicantId: staff.id, + manager: pm, + director: dir, + pan, + bizDirector: biz, + }).map((s) => `${s.role}:${s.user.displayName}`), + ).toEqual(['项目经理:项目经理', '部门总监:研发总监', '潘兴辉:潘兴辉', '商务总监:王迪']); + }); + + it('skips the applicant and duplicate people', () => { + expect( + assembleApprovalChain({ + applicantId: pm.id, + manager: pm, + director: dir, + pan, + bizDirector: biz, + }).map((s) => s.role), + ).toEqual(['部门总监', '潘兴辉', '商务总监']); + expect( + assembleApprovalChain({ + applicantId: staff.id, + manager: pm, + director: biz, + pan, + bizDirector: biz, + }).map((s) => s.role), + ).toEqual(['项目经理', '部门总监', '潘兴辉']); + }); +}); diff --git a/apps/api/src/common/org-chain.ts b/apps/api/src/common/org-chain.ts new file mode 100644 index 0000000..11ecadb --- /dev/null +++ b/apps/api/src/common/org-chain.ts @@ -0,0 +1,209 @@ +import type { Prisma } from '@prisma/client'; +import { DIRECTOR_ROLES, TECH_LEAD_ROLES } from './role-groups'; +import { PrismaService } from '../prisma/prisma.service'; + +export const PERSON_SELECT = { id: true, displayName: true, username: true } as const; +export type OrgPerson = { id: string; displayName: string; username: string }; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function parsePeopleIds(raw?: string | null) { + if (!raw) return [] as string[]; + return [ + ...new Set( + raw + .split(/[,,、\s]+/) + .map((s) => s.trim()) + .filter((s) => UUID_RE.test(s)), + ), + ]; +} + +type OrgDb = PrismaService | Prisma.TransactionClient; + +const PAN_NAME = '潘兴辉'; + +export function assembleApprovalChain(input: { + applicantId: string; + manager: OrgPerson | null; + director: OrgPerson | null; + pan: OrgPerson | null; + bizDirector: OrgPerson | null; +}): { role: string; user: OrgPerson }[] { + const steps: { role: string; user: OrgPerson }[] = []; + const push = (role: string, user: OrgPerson | null | undefined) => { + if (!user) return; + if (user.id === input.applicantId) return; + if (steps.some((s) => s.user.id === user.id)) return; + steps.push({ role, user }); + }; + push('项目经理', input.manager); + push('部门总监', input.director); + push('潘兴辉', input.pan); + push('商务总监', input.bizDirector); + return steps; +} + +export async function ancestorDeptIds(db: OrgDb, startId?: string | null) { + if (!startId) return [] as string[]; + const all = await db.department.findMany({ + where: { tenantId: '1' }, + select: { id: true, parentId: true }, + }); + const ids: string[] = []; + const seen = new Set(); + let cur: string | null | undefined = startId; + while (cur && !seen.has(cur)) { + seen.add(cur); + ids.push(cur); + cur = all.find((d) => d.id === cur)?.parentId; + } + return ids; +} + +export async function findDeptDirector(db: OrgDb, departmentId?: string | null): Promise { + const chain = await ancestorDeptIds(db, departmentId); + for (const deptId of chain) { + const tech = await db.user.findFirst({ + where: { + tenantId: '1', + status: 'ACTIVE', + employee: { departmentId: deptId }, + roles: { some: { role: { code: { in: [...TECH_LEAD_ROLES] } } } }, + }, + select: PERSON_SELECT, + }); + if (tech) return tech; + const any = await db.user.findFirst({ + where: { + tenantId: '1', + status: 'ACTIVE', + employee: { departmentId: deptId }, + roles: { some: { role: { code: { in: [...DIRECTOR_ROLES] } } } }, + }, + select: PERSON_SELECT, + }); + if (any) return any; + } + return null; +} + +export async function findNamedPerson(db: OrgDb, name: string): Promise { + const users = await db.user.findMany({ + where: { + tenantId: '1', + status: 'ACTIVE', + OR: [{ displayName: { contains: name } }, { employee: { name: { contains: name } } }], + }, + select: { ...PERSON_SELECT, employee: { select: { name: true } } }, + }); + const exact = users.find((u) => u.displayName === name || u.employee?.name === name); + const hit = exact || users[0]; + return hit ? { id: hit.id, displayName: hit.displayName, username: hit.username } : null; +} + +/** 投标/报销里的「商务总监」指商务部负责人,不含其他部门误挂 biz_director 的同事。 */ +export async function findBizDirectors(db: OrgDb): Promise { + const inBizDept = await db.user.findMany({ + where: { + tenantId: '1', + status: 'ACTIVE', + roles: { some: { role: { code: 'biz_director' } } }, + employee: { + department: { + OR: [{ name: { contains: '商务' } }, { code: { contains: 'BIZ' } }, { code: { startsWith: 'OA_4' } }], + }, + }, + }, + select: PERSON_SELECT, + orderBy: { displayName: 'asc' }, + }); + if (inBizDept.length) return inBizDept; + return db.user.findMany({ + where: { + tenantId: '1', + status: 'ACTIVE', + roles: { some: { role: { code: 'biz_director' } } }, + }, + select: PERSON_SELECT, + orderBy: { displayName: 'asc' }, + }); +} + +export async function findRolePerson(db: OrgDb, roleCode: string, exceptId?: string | null): Promise { + if (roleCode === 'biz_director') { + const users = await findBizDirectors(db); + return users.find((u) => u.id !== exceptId) || users[0] || null; + } + const users = await db.user.findMany({ + where: { + tenantId: '1', + status: 'ACTIVE', + roles: { some: { role: { code: roleCode } } }, + }, + select: PERSON_SELECT, + }); + return users.find((u) => u.id !== exceptId) || users[0] || null; +} + +function managerUserOf(emp: { + manager?: { users: Array<{ id: string; displayName: string; username: string }> } | null; +} | null): OrgPerson | null { + const u = emp?.manager?.users[0]; + return u ? { id: u.id, displayName: u.displayName, username: u.username } : null; +} + +export async function resolveExpenseApproverChain(db: OrgDb, applicantId: string) { + const applicant = await db.user.findFirst({ + where: { id: applicantId }, + include: { + employee: { + include: { + manager: { include: { users: { where: { status: 'ACTIVE' }, take: 1 } } }, + }, + }, + }, + }); + const [director, pan, bizDirector] = await Promise.all([ + findDeptDirector(db, applicant?.employee?.departmentId), + findNamedPerson(db, PAN_NAME), + findRolePerson(db, 'biz_director'), + ]); + return assembleApprovalChain({ + applicantId, + manager: managerUserOf(applicant?.employee || null), + director, + pan, + bizDirector, + }); +} + +export async function resolveReportPeople(db: OrgDb, userId: string) { + const me = await db.user.findFirst({ + where: { id: userId }, + include: { + employee: { + include: { + manager: { include: { users: { where: { status: 'ACTIVE' }, take: 1 } } }, + reports: true, + }, + }, + }, + }); + const director = await findDeptDirector(db, me?.employee?.departmentId); + const extraIds = parsePeopleIds((me?.employee as { managerIds?: string | null } | undefined)?.managerIds).filter( + (id) => id !== me?.employee?.managerId, + ); + const extraManagers = extraIds.length + ? await db.user.findMany({ + where: { tenantId: '1', status: 'ACTIVE', employeeId: { in: extraIds } }, + select: PERSON_SELECT, + }) + : []; + return { + me, + manager: managerUserOf(me?.employee || null), + extraManagers, + director, + }; +} diff --git a/apps/api/src/common/role-groups.ts b/apps/api/src/common/role-groups.ts new file mode 100644 index 0000000..3aabc7d --- /dev/null +++ b/apps/api/src/common/role-groups.ts @@ -0,0 +1,60 @@ +export const TECH_LEAD_ROLES = [ + 'tech_director', + 'rd_director', + '3d_director', + 'video_director', + 'material_director', +]; + +/** 商务总监 + 各技术条线总监 */ +export const DIRECTOR_ROLES = ['biz_director', ...TECH_LEAD_ROLES]; + +/** 手工发短信:管理员、人事、各总监。 */ +export const SMS_SEND_ROLES = ['admin', 'owner', 'hr', ...DIRECTOR_ROLES]; + +export function canSendSms(roles: string[], permissions: string[] = []) { + if (hasAnyRole(roles, SMS_SEND_ROLES)) return true; + return permissions.includes('office:sms') || permissions.includes('system:sms'); +} + +export const TECH_STAFF_ROLES = ['employee', 'pm', 'rd_staff', '3d_staff', 'video_staff', 'material_staff', 'tech']; + +export const APPROVER_ROLES = [ + 'admin', + 'owner', + 'biz_director', + 'biz_staff', + 'hr', + 'finance', + ...TECH_LEAD_ROLES, +]; + +export function isStaffRole(roles: string[]) { + return roles.length > 0 && roles.every((r) => TECH_STAFF_ROLES.includes(r)); +} + +export function canApproveOthers(roles: string[], permissions: string[]) { + if (hasAnyRole(roles, APPROVER_ROLES)) return true; + return permissions.includes('office:approvals'); +} + +export function hasAnyRole(roles: string[], codes: readonly string[]) { + return codes.some((c) => roles.includes(c)); +} + +export function isTechLead(roles: string[]) { + return hasAnyRole(roles, TECH_LEAD_ROLES); +} + +/** 研发总监与系统管理员同等。登录后补上 admin,后端原有管理员判断都会生效。 */ +export function elevateToAdmin(roles: string[]) { + if (roles.includes('rd_director') && !roles.includes('admin')) return [...roles, 'admin']; + return roles; +} + +/** 总监、项目经理、公司负责人可给下属分配工作。 */ +export const WORK_ASSIGN_ROLES = ['admin', 'owner', 'biz_director', 'pm', ...TECH_LEAD_ROLES]; + +export function canAssignWork(roles: string[]) { + return hasAnyRole(roles, WORK_ASSIGN_ROLES); +} diff --git a/apps/api/src/common/tencent-lbs.ts b/apps/api/src/common/tencent-lbs.ts new file mode 100644 index 0000000..bd19dc3 --- /dev/null +++ b/apps/api/src/common/tencent-lbs.ts @@ -0,0 +1,288 @@ +function key() { + return process.env.TENCENT_MAP_KEY || ''; +} + +const REFERER = 'https://oa.fysxkj.com/'; + +async function tencentGet(path: string, params: Record) { + const k = key(); + if (!k) return null; + const q = new URLSearchParams({ key: k, output: 'json', ...params }); + const res = await fetch(`https://apis.map.qq.com${path}?${q.toString()}`, { + headers: { Referer: REFERER }, + }); + if (!res.ok) return null; + return (await res.json()) as Record; +} + +export async function locateByIp(ip?: string) { + const json = await tencentGet('/ws/location/v1/ip', ip ? { ip } : {}); + const result = json?.result as + | { location?: { lat?: number; lng?: number }; ad_info?: { city?: string; province?: string; district?: string } } + | undefined; + if (json?.status !== 0 || !result?.location) return null; + return { + lat: Number(result.location.lat), + lng: Number(result.location.lng), + city: String(result.ad_info?.city || result.ad_info?.province || '').replace(/市$/, ''), + district: String(result.ad_info?.district || ''), + }; +} + +export function tencentMapKey() { + return key(); +} + +export type PlaceHit = { + lat: number; + lng: number; + title: string; + address: string; + city?: string; + distance?: number; +}; + +function asPoi(p: { + title?: string; + address?: string; + location?: { lat?: number; lng?: number }; + _distance?: number | string; + ad_info?: { city?: string }; +}): PlaceHit | null { + const lat = Number(p.location?.lat); + const lng = Number(p.location?.lng); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + const dist = Number(p._distance); + return { + lat, + lng, + title: String(p.title || p.address || '位置'), + address: String(p.address || ''), + city: String(p.ad_info?.city || '').replace(/市$/, ''), + distance: Number.isFinite(dist) ? dist : undefined, + }; +} + +export async function reverseGeocode(lat: number, lng: number) { + const json = await tencentGet('/ws/geocoder/v1/', { + location: `${lat},${lng}`, + get_poi: '1', + poi_options: 'address_format=short;radius=1000;page_size=20;policy=2', + }); + const result = json?.result as + | { + address?: string; + formatted_addresses?: { recommend?: string; rough?: string }; + address_component?: { city?: string; province?: string; district?: string }; + pois?: Array<{ + title?: string; + address?: string; + location?: { lat?: number; lng?: number }; + _distance?: number | string; + }>; + } + | undefined; + if (json?.status !== 0 || !result) return null; + const address = String(result.address || ''); + const title = String(result.formatted_addresses?.recommend || result.formatted_addresses?.rough || address); + return { + address, + title: title || address, + city: String(result.address_component?.city || result.address_component?.province || '').replace(/市$/, ''), + district: String(result.address_component?.district || ''), + lat, + lng, + pois: (result.pois || []).map(asPoi).filter((p): p is PlaceHit => !!p), + }; +} + +export async function suggestPlaces(keyword: string, lat?: number, lng?: number) { + const q = keyword.trim(); + if (!q) return [] as PlaceHit[]; + const params: Record = { keyword: q, page_size: '20' }; + if (Number.isFinite(Number(lat)) && Number.isFinite(Number(lng))) params.location = `${lat},${lng}`; + const json = await tencentGet('/ws/place/v1/suggestion/', params); + const rows = (json?.data as Array[0]>) || []; + return rows.map(asPoi).filter((p): p is PlaceHit => !!p); +} + +const CITIES = [ + '合肥', + '南京', + '北京', + '上海', + '杭州', + '苏州', + '无锡', + '宁波', + '武汉', + '成都', + '西安', + '广州', + '深圳', + '青岛', + '济南', + '郑州', + '长沙', + '南昌', + '福州', + '厦门', + '天津', + '重庆', + '沈阳', + '大连', + '哈尔滨', + '长春', + '昆明', + '贵阳', + '南宁', + '海口', + '兰州', + '银川', + '西宁', + '太原', + '石家庄', + '呼和浩特', + '乌鲁木齐', +]; + +function cityHint(text: string) { + return CITIES.find((c) => text.includes(c)) || ''; +} + +function poiScore(query: string, title: string, addr: string) { + const hay = `${title}${addr}`; + if (title === query) return 1000; + if (title.includes(query) || query.includes(title)) return 800; + let s = 0; + for (const part of query.match(/[\u4e00-\u9fff]{2,}/g) || []) { + if (hay.includes(part)) s += part.length * 10; + } + return s; +} + +type GeoHit = { + lat: number; + lng: number; + title: string; + address: string; + city: string; +}; + +function asHit(lat: number, lng: number, title: string, address: string, city: string): GeoHit { + return { lat, lng, title, address, city }; +} + +export async function geocodeAddress(address: string) { + const q = address.trim(); + if (!q) return null; + const geo = await tencentGet('/ws/geocoder/v1/', { address: q }); + const geoResult = geo?.result as + | { location?: { lat?: number; lng?: number }; title?: string; address?: string } + | undefined; + if (geo?.status === 0 && geoResult?.location) { + const extra = await reverseGeocode(Number(geoResult.location.lat), Number(geoResult.location.lng)); + return asHit( + Number(geoResult.location.lat), + Number(geoResult.location.lng), + String(geoResult.title || q), + extra?.address || String(geoResult.address || q), + extra?.city || '', + ); + } + + const city = cityHint(q); + const [sug, search] = await Promise.all([ + tencentGet('/ws/place/v1/suggestion/', city ? { keyword: q, region: city } : { keyword: q }), + tencentGet('/ws/place/v1/search/', { + keyword: q, + boundary: city ? `region(${city},0)` : 'region(全国,0)', + page_size: '10', + }), + ]); + type Poi = { title?: string; address?: string; location?: { lat?: number; lng?: number }; ad_info?: { city?: string } }; + const pois = [...((sug?.data as Poi[]) || []), ...((search?.data as Poi[]) || [])].filter( + (p) => p?.location && Number.isFinite(Number(p.location.lat)), + ); + if (!pois.length) return null; + const ranked = [...pois].sort( + (a, b) => + poiScore(q, String(b.title || ''), String(b.address || '')) - + poiScore(q, String(a.title || ''), String(a.address || '')), + ); + const best = ranked[0]; + const extra = await reverseGeocode(Number(best.location!.lat), Number(best.location!.lng)); + return asHit( + Number(best.location!.lat), + Number(best.location!.lng), + String(best.title || q), + extra?.address || String(best.address || q), + extra?.city || String(best.ad_info?.city || city).replace(/市$/, ''), + ); +} + +type WeatherNow = { + city: string; + weather: string; + temperature?: number; + humidity?: number; + wind?: string; + advice: string; +}; + +function adviceFrom(info: string, temp?: number) { + const tips: string[] = []; + if (/雨|雪|雷/.test(info)) tips.push('注意下雨,出门带伞'); + if (/晴/.test(info) || (typeof temp === 'number' && temp >= 30)) tips.push('注意紫外线,做好防晒'); + if (typeof temp === 'number' && temp >= 35) tips.push('注意防暑降温'); + if (typeof temp === 'number' && temp <= 5) tips.push('注意保暖'); + if (/雾|霾|沙/.test(info)) tips.push('注意出行能见度'); + if (/风/.test(info) && !/微风/.test(info)) tips.push('注意大风'); + if (!tips.length) tips.push('注意劳逸结合'); + return [...new Set(tips)].slice(0, 2).join(','); +} + +export async function weatherByLocation(lat: number, lng: number, cityHint?: string): Promise { + const json = await tencentGet('/ws/weather/v1/', { + location: `${lat},${lng}`, + type: 'now', + added_fields: 'alarm', + }); + if (json?.status !== 0) return null; + const result = json.result as { realtime?: Array> } | undefined; + const row = result?.realtime?.[0]; + if (!row) return null; + const infos = (row.infos || {}) as Record; + const info = String(infos.weather || '—'); + const temperature = Number(infos.temperature); + const humidity = Number(infos.humidity); + const windDir = String(infos.wind_direction || ''); + const windPower = String(infos.wind_power_v2 || infos.wind_power || ''); + const city = String(row.city || cityHint || '').replace(/市$/, ''); + return { + city: city || '当地', + weather: info, + temperature: Number.isFinite(temperature) ? temperature : undefined, + humidity: Number.isFinite(humidity) ? humidity : undefined, + wind: [windDir, windPower].filter(Boolean).join(' ') || undefined, + advice: adviceFrom(info, Number.isFinite(temperature) ? temperature : undefined), + }; +} + +const GREETINGS = [ + (n: string) => `尊敬的${n}您好,美好的一天开始了,祝您工作愉快。`, + (n: string) => `尊敬的${n},新的一天已就绪,愿今天经手的每一件事都顺利落地。`, + (n: string) => `${n},早上好。保持节奏,今天也会很出色。`, + (n: string) => `尊敬的${n}您好,愿今天思路清晰、协作顺畅。`, + (n: string) => `${n},新的一天值得认真对待,也值得对自己温柔一点。`, + (n: string) => `尊敬的${n},把难事先拆小步,今天就会轻松很多。`, + (n: string) => `${n}您好,专注当下这一件事,成果会自己跟上。`, +]; + +export function dailyGreeting(name: string, seed: string) { + const day = new Date().toISOString().slice(0, 10); + let h = 0; + const s = `${day}:${seed}`; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; + return GREETINGS[h % GREETINGS.length](name || '同事'); +} diff --git a/apps/api/src/config/env.spec.ts b/apps/api/src/config/env.spec.ts new file mode 100644 index 0000000..b4657d1 --- /dev/null +++ b/apps/api/src/config/env.spec.ts @@ -0,0 +1,22 @@ +import { loadEnv } from './env'; + +describe('loadEnv', () => { + const base = { + DATABASE_URL: 'postgresql://ops:ops@localhost:5432/enterprise_ops', + REDIS_URL: 'redis://localhost:6379', + MINIO_ACCESS_KEY: 'opsadmin', + MINIO_SECRET_KEY: 'opsadmin_dev_only', + JWT_ACCESS_SECRET: 'change-me-access-secret-dev-only', + JWT_REFRESH_SECRET: 'change-me-refresh-secret-dev-only', + }; + + it('accepts valid development env', () => { + const env = loadEnv({ ...base, NODE_ENV: 'development' }); + expect(env.APP_PORT).toBe(3000); + expect(env.API_PREFIX).toBe('api/v1'); + }); + + it('rejects missing database url', () => { + expect(() => loadEnv({ ...base, DATABASE_URL: '' })).toThrow(/环境变量校验失败/); + }); +}); diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts new file mode 100644 index 0000000..fe4a8b6 --- /dev/null +++ b/apps/api/src/config/env.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; + +export const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + APP_PORT: z.coerce.number().default(3000), + APP_NAME: z.string().default('enterprise-ops'), + API_PREFIX: z.string().default('api/v1'), + CORS_ORIGINS: z.string().default('http://localhost:5173'), + DATABASE_URL: z.string().min(1), + REDIS_URL: z.string().min(1), + MINIO_ENDPOINT: z.string().default('localhost'), + MINIO_PORT: z.coerce.number().default(9000), + MINIO_USE_SSL: z.coerce.boolean().default(false), + MINIO_ACCESS_KEY: z.string().min(1), + MINIO_SECRET_KEY: z.string().min(1), + MINIO_BUCKET: z.string().default('enterprise-ops'), + JWT_ACCESS_SECRET: z.string().min(16), + JWT_REFRESH_SECRET: z.string().min(16), + JWT_ACCESS_EXPIRES: z.string().default('15m'), + JWT_REFRESH_EXPIRES: z.string().default('7d'), + ALIYUN_SMS_ACCESS_KEY_ID: z.string().optional(), + ALIYUN_SMS_ACCESS_KEY_SECRET: z.string().optional(), + ALIYUN_SMS_ENDPOINT: z.string().optional(), + ALIYUN_SMS_REGION: z.string().optional(), + ALIYUN_SMS_DEFAULT_SIGN: z.string().optional(), + ALIYUN_RAM_LOGIN: z.string().optional(), + ALIYUN_RAM_SECURITY_PHONE: z.string().optional(), + ALIYUN_RAM_SECURITY_EMAIL: z.string().optional(), + TENCENT_MAP_KEY: z.string().optional(), + IM_TCP_PORT: z.coerce.number().optional(), + IM_HTTP_PORT: z.coerce.number().optional(), + IM_INTERNAL_TOKEN: z.string().optional(), + VIVO_PUSH_APP_ID: z.string().optional(), + VIVO_PUSH_APP_KEY: z.string().optional(), + VIVO_PUSH_APP_SECRET: z.string().optional(), + OPPO_PUSH_APP_ID: z.string().optional(), + OPPO_PUSH_APP_KEY: z.string().optional(), + OPPO_PUSH_APP_SECRET: z.string().optional(), + OPPO_PUSH_MASTER_SECRET: z.string().optional(), + MEIZU_PUSH_APP_ID: z.string().optional(), + MEIZU_PUSH_APP_KEY: z.string().optional(), + MEIZU_PUSH_APP_SECRET: z.string().optional(), + HUAWEI_PUSH_APP_ID: z.string().optional(), + HUAWEI_PUSH_CLIENT_ID: z.string().optional(), + HUAWEI_PUSH_CLIENT_SECRET: z.string().optional(), + HUAWEI_PUSH_PROJECT_ID: z.string().optional(), +}); + +export type Env = z.infer; + +export function loadEnv(raw: NodeJS.ProcessEnv = process.env): Env { + const parsed = envSchema.safeParse(raw); + if (!parsed.success) { + const details = parsed.error.issues + .map((i) => `${i.path.join('.')}: ${i.message}`) + .join('; '); + throw new Error(`环境变量校验失败: ${details}`); + } + return parsed.data; +} diff --git a/apps/api/src/contract/contract-workflow.spec.ts b/apps/api/src/contract/contract-workflow.spec.ts new file mode 100644 index 0000000..a4817ca --- /dev/null +++ b/apps/api/src/contract/contract-workflow.spec.ts @@ -0,0 +1,26 @@ +import { + canAccessContract, + canApproveContract, + canEditContract, + milestoneSumOk, +} from './contract-workflow'; + +describe('contract-workflow', () => { + it('allows biz roles to draft, and owner/director to approve', () => { + expect(canEditContract(['biz_staff'])).toBe(true); + expect(canEditContract(['tech_director'])).toBe(false); + expect(canApproveContract(['owner'])).toBe(true); + expect(canApproveContract(['biz_staff'])).toBe(false); + }); + + it('requires milestone ratios to sum to 100', () => { + expect(milestoneSumOk([60, 40])).toBe(true); + expect(milestoneSumOk([30, 30, 40])).toBe(true); + expect(milestoneSumOk([50, 40])).toBe(false); + }); + + it('lets finance see contracts via permission', () => { + expect(canAccessContract(['finance'], ['contract:list'])).toBe(true); + expect(canAccessContract(['employee'], ['office'])).toBe(false); + }); +}); diff --git a/apps/api/src/contract/contract-workflow.ts b/apps/api/src/contract/contract-workflow.ts new file mode 100644 index 0000000..35e1c75 --- /dev/null +++ b/apps/api/src/contract/contract-workflow.ts @@ -0,0 +1,29 @@ +export const CONTRACT_STATUS = { + DRAFT: 'DRAFT', + PENDING: 'PENDING', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED', +} as const; + +export const CONTRACT_BIZ = { + APPROVAL: 'CONTRACT_APPROVAL', + DRAFT: 'CONTRACT_DRAFT', +} as const; + +export function canAccessContract(roles: string[], permissions: string[]) { + if (roles.includes('admin')) return true; + return permissions.some((p) => p === 'contract' || p.startsWith('contract:') || p === 'bid' || p.startsWith('bid:')); +} + +export function canEditContract(roles: string[]) { + return ['admin', 'owner', 'biz_director', 'biz_staff'].some((r) => roles.includes(r)); +} + +export function canApproveContract(roles: string[]) { + return ['admin', 'owner', 'biz_director'].some((r) => roles.includes(r)); +} + +export function milestoneSumOk(ratios: number[]) { + const sum = ratios.reduce((a, b) => a + b, 0); + return Math.abs(sum - 100) < 0.01; +} diff --git a/apps/api/src/contract/contract.controller.ts b/apps/api/src/contract/contract.controller.ts new file mode 100644 index 0000000..cb5ed1c --- /dev/null +++ b/apps/api/src/contract/contract.controller.ts @@ -0,0 +1,102 @@ +import { Body, Controller, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser, type AuthUser } from '../common/decorators/current-user.decorator'; +import { ContractService } from './contract.service'; + +@ApiTags('contract') +@ApiBearerAuth() +@Controller() +export class ContractController { + constructor(private readonly contracts: ContractService) {} + + @Get('contracts') + list( + @CurrentUser() user: AuthUser, + @Query('page') page = '1', + @Query('pageSize') pageSize = '20', + @Query('keyword') keyword?: string, + @Query('bucket') bucket?: string, + ) { + return this.contracts.list(user, Number(page) || 1, Number(pageSize) || 20, keyword, bucket); + } + + @Get('contracts/:id') + get(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.contracts.get(id, user); + } + + @Post('contracts') + create( + @CurrentUser() user: AuthUser, + @Body() + body: { + bidCaseId?: string; + legalEntityId?: string; + amount: number; + name?: string; + partyA?: string; + partyB?: string; + remark?: string; + contractNo?: string; + kind?: string; + }, + ) { + if (body.bidCaseId && !body.kind) return this.contracts.createFromBid(body, user); + return this.contracts.createIncremental(body, user); + } + + @Patch('contracts/:id') + update( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() + body: { + name?: string; + amount?: number; + partyA?: string; + partyB?: string; + remark?: string; + nasPath?: string; + contractNo?: string; + }, + ) { + return this.contracts.update(id, user, body); + } + + @Patch('contracts/:id/kind') + moveKind(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() body: { kind?: string }) { + return this.contracts.moveKind(id, user, body.kind || 'OTHER'); + } + + @Put('contracts/:id/milestones') + milestones( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { items: Array<{ name: string; ratio: number; dueAt?: string; trigger?: string }> }, + ) { + return this.contracts.replaceMilestones(id, user, body.items || []); + } + + @Post('contracts/:id/seal') + seal( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { sealType: string; reason: string; takeOut?: boolean; returnAt?: string }, + ) { + return this.contracts.createSeal(id, user, body); + } + + @Post('contracts/:id/submit') + submit(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.contracts.submit(id, user); + } + + @Post('contracts/:id/reviews') + review( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { result: 'APPROVED' | 'REJECTED'; comment?: string }, + ) { + return this.contracts.review(id, user, body); + } +} diff --git a/apps/api/src/contract/contract.service.ts b/apps/api/src/contract/contract.service.ts new file mode 100644 index 0000000..1267cf5 --- /dev/null +++ b/apps/api/src/contract/contract.service.ts @@ -0,0 +1,666 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { BIZ_TYPE } from '../bidding/bid-workflow'; +import { SealService } from '../seal/seal.service'; +import { CONTRACT_BIZ, CONTRACT_STATUS, canAccessContract, canEditContract, milestoneSumOk } from './contract-workflow'; +import { nextBizNo } from '../system/numbering'; +import { assertCanSeeOwner, ownerScope, withAnd } from '../common/data-scope'; +import { BUDGET_CATEGORY, PROJECT_STATUS, splitEven } from '../project/project-workflow'; + +const USER_SELECT = { id: true, displayName: true, username: true }; + +const DETAIL_INCLUDE = { + bidCase: { select: { id: true, bidNo: true, name: true, status: true, quoteAmount: true, party: { select: { name: true } } } }, + legalEntity: true, + createdBy: { select: USER_SELECT }, + milestones: { include: { invoice: true }, orderBy: { dueAt: 'asc' as const } }, + reviewers: { + include: { user: { select: USER_SELECT } }, + orderBy: { decidedAt: 'asc' as const }, + }, + actionLogs: { + include: { actor: { select: USER_SELECT } }, + orderBy: { createdAt: 'asc' as const }, + }, + sealRequests: { orderBy: { createdAt: 'desc' as const } }, + invoices: true, + projects: true, +}; + +type Tx = Prisma.TransactionClient; + +export type CreateContractInput = { + bidCaseId?: string; + legalEntityId?: string; + amount: number; + name?: string; + partyA?: string; + partyB?: string; + remark?: string; + contractNo?: string; + kind?: string; +}; + +export type UpdateContractInput = { + name?: string; + amount?: number; + partyA?: string; + partyB?: string; + remark?: string; + nasPath?: string; + contractNo?: string; +}; + +function officialContractNo(raw?: string | null) { + const n = (raw || '').trim(); + if (!n || n === '待填' || n === '-' || n === '—') return ''; + return n; +} + +export type MilestoneInput = { + name: string; + ratio: number; + dueAt?: string; + trigger?: string; +}; + +export type ReviewInput = { + result: 'APPROVED' | 'REJECTED'; + comment?: string; +}; + +export type SealInput = { + sealType: string; + reason: string; + takeOut?: boolean; + returnAt?: string; +}; + +@Injectable() +export class ContractService { + constructor( + private readonly prisma: PrismaService, + private readonly seals: SealService, + ) {} + + assertAccess(user: AuthUser) { + if (!canAccessContract(user.roles, user.permissions)) { + throw new ForbiddenException('无权访问合同管理'); + } + } + + async list(user: AuthUser, page: number, pageSize: number, keyword?: string, bucket?: string) { + this.assertAccess(user); + const where: Prisma.ContractWhereInput = { tenantId: '1' }; + if (bucket === 'tender' || bucket === 'bid' || bucket === 'approval') { + where.OR = [{ kind: 'TENDER' }, { bidCaseId: { not: null } }]; + } + if (bucket === 'direct') { + where.kind = 'DIRECT'; + } + if (bucket === 'incremental' || bucket === 'other') { + where.kind = 'OTHER'; + where.bidCaseId = null; + } + if (keyword) { + where.AND = [ + { + OR: [ + { name: { contains: keyword } }, + { contractNo: { contains: keyword } }, + { partyA: { contains: keyword } }, + { partyB: { contains: keyword } }, + ], + }, + ]; + } + const scoped = withAnd(where, (await ownerScope(this.prisma, user, 'createdById')) as Record); + const [items, total] = await this.prisma.$transaction([ + this.prisma.contract.findMany({ + where: scoped, + include: { + bidCase: { + select: { + id: true, + bidNo: true, + externalNo: true, + name: true, + party: { select: { name: true } }, + createdBy: { select: USER_SELECT }, + }, + }, + legalEntity: true, + createdBy: { select: USER_SELECT }, + projects: { select: { id: true, projectNo: true, status: true } }, + milestones: true, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.contract.count({ where: scoped }), + ]); + return { items, page, pageSize, total }; + } + + async get(id: string, user: AuthUser) { + this.assertAccess(user); + const row = await this.prisma.contract.findFirst({ + where: { id, tenantId: '1' }, + include: DETAIL_INCLUDE, + }); + if (!row) throw new NotFoundException('合同不存在'); + await assertCanSeeOwner(this.prisma, user, row.createdById); + const files = await this.prisma.fileAsset.findMany({ + where: { tenantId: '1', bizId: id, bizType: { startsWith: 'CONTRACT' } }, + orderBy: { createdAt: 'asc' }, + }); + return { ...row, files, myActions: this.myActions(row, user) }; + } + + async createIncremental(dto: CreateContractInput, user: AuthUser) { + this.assertAccess(user); + if (!canEditContract(user.roles)) { + throw new ForbiddenException('无权起草合同'); + } + const name = dto.name?.trim(); + if (!name) throw new BadRequestException('请填写合同名称'); + if (!dto.amount || dto.amount <= 0) throw new BadRequestException('请填写合同金额'); + const kind = dto.kind === 'TENDER' || dto.kind === 'DIRECT' ? dto.kind : 'OTHER'; + const contract = await this.prisma.$transaction(async (tx) => { + const entity = dto.legalEntityId + ? await tx.legalEntity.findFirst({ where: { id: dto.legalEntityId, tenantId: '1' } }) + : (await tx.legalEntity.findFirst({ where: { tenantId: '1', isDefault: true } })) || + (await tx.legalEntity.findFirst({ where: { tenantId: '1' } })); + if (dto.legalEntityId && !entity) throw new NotFoundException('我方主体不存在'); + if (kind === 'TENDER' && dto.bidCaseId) { + const bid = await tx.bidCase.findFirst({ where: { id: dto.bidCaseId, tenantId: '1' } }); + if (!bid) throw new NotFoundException('投标事项不存在'); + const existing = await tx.contract.findFirst({ + where: { + bidCaseId: bid.id, + status: { in: [CONTRACT_STATUS.DRAFT, CONTRACT_STATUS.PENDING, CONTRACT_STATUS.APPROVED] }, + }, + }); + if (existing) { + throw new BadRequestException('该中标事项已自动生成合同草稿,请到合同管理完善后提交审批'); + } + } + const contractNo = officialContractNo(dto.contractNo) || null; + if (contractNo) { + const clash = await tx.contract.findFirst({ where: { tenantId: '1', contractNo } }); + if (clash) throw new BadRequestException('该合同编号已存在'); + } + const created = await tx.contract.create({ + data: { + tenantId: '1', + contractNo, + name, + kind, + bidCaseId: kind === 'TENDER' ? dto.bidCaseId || undefined : undefined, + legalEntityId: entity?.id, + amount: dto.amount, + partyA: dto.partyA?.trim() || undefined, + partyB: dto.partyB?.trim() || entity?.name || undefined, + remark: dto.remark, + status: CONTRACT_STATUS.DRAFT, + createdById: user.id, + }, + }); + const label = kind === 'TENDER' ? '投标合同' : kind === 'DIRECT' ? '直签合同' : '其他合同'; + await this.log(tx, created.id, user.id, 'CREATE', undefined, CONTRACT_STATUS.DRAFT, `新建${label}`); + return created; + }); + return this.get(contract.id, user); + } + + async createFromBid(dto: CreateContractInput, user: AuthUser) { + this.assertAccess(user); + if (!canEditContract(user.roles)) { + throw new ForbiddenException('无权起草合同'); + } + if (!dto.bidCaseId) throw new BadRequestException('请选择投标事项'); + if (!dto.amount || dto.amount <= 0) { + throw new BadRequestException('请填写合同金额'); + } + const contract = await this.prisma.$transaction(async (tx) => { + const bid = await tx.bidCase.findFirst({ + where: { id: dto.bidCaseId, tenantId: '1' }, + include: { party: true, legalEntity: true }, + }); + if (!bid) throw new NotFoundException('投标事项不存在'); + if (bid.status !== 'WON') { + throw new BadRequestException('只有中标事项可以转合同'); + } + const existing = await tx.contract.findFirst({ + where: { + bidCaseId: bid.id, + status: { in: [CONTRACT_STATUS.DRAFT, CONTRACT_STATUS.PENDING, CONTRACT_STATUS.APPROVED] }, + }, + }); + if (existing) { + throw new BadRequestException('该中标事项已自动生成合同草稿,请到合同管理完善后提交审批'); + } + const contractNo = officialContractNo(dto.contractNo) || null; + if (contractNo) { + const clash = await tx.contract.findFirst({ where: { tenantId: '1', contractNo } }); + if (clash) throw new BadRequestException('该合同编号已存在'); + } + const created = await tx.contract.create({ + data: { + tenantId: '1', + contractNo, + name: dto.name?.trim() || bid.name, + bidCaseId: bid.id, + legalEntityId: bid.legalEntityId, + amount: dto.amount, + partyA: dto.partyA?.trim() || bid.party?.name || undefined, + partyB: dto.partyB?.trim() || bid.legalEntity?.name || undefined, + remark: dto.remark, + kind: 'TENDER', + status: CONTRACT_STATUS.DRAFT, + createdById: user.id, + }, + }); + await tx.todoTask.updateMany({ + where: { + status: 'OPEN', + bizType: BIZ_TYPE.CONVERT_CONTRACT, + bizId: { in: [bid.id, created.id] }, + }, + data: { status: 'DONE' }, + }); + await this.log(tx, created.id, user.id, 'CREATE', undefined, CONTRACT_STATUS.DRAFT, `由 ${bid.bidNo} 转合同`); + return created; + }); + return this.get(contract.id, user); + } + + async update(id: string, user: AuthUser, dto: UpdateContractInput) { + this.assertAccess(user); + if (!canEditContract(user.roles)) throw new ForbiddenException('无权修改合同'); + const row = await this.require(id); + if (row.status !== CONTRACT_STATUS.DRAFT && row.status !== CONTRACT_STATUS.REJECTED) { + throw new BadRequestException('当前状态不能修改'); + } + const contractNo = dto.contractNo === undefined ? undefined : officialContractNo(dto.contractNo) || null; + if (contractNo) { + const clash = await this.prisma.contract.findFirst({ + where: { tenantId: '1', contractNo, id: { not: id } }, + }); + if (clash) throw new BadRequestException('该合同编号已存在'); + } + await this.prisma.contract.update({ + where: { id }, + data: { + name: dto.name?.trim() || undefined, + amount: dto.amount, + partyA: dto.partyA?.trim() || undefined, + partyB: dto.partyB?.trim() || undefined, + remark: dto.remark, + nasPath: dto.nasPath, + contractNo, + status: CONTRACT_STATUS.DRAFT, + }, + }); + await this.prisma.contractActionLog.create({ + data: { + contractId: id, + actorId: user.id, + action: 'UPDATE', + fromStatus: row.status, + toStatus: CONTRACT_STATUS.DRAFT, + }, + }); + return this.get(id, user); + } + + async replaceMilestones(id: string, user: AuthUser, items: MilestoneInput[]) { + this.assertAccess(user); + if (!canEditContract(user.roles)) throw new ForbiddenException('无权修改收款条款'); + const row = await this.require(id); + if (row.status !== CONTRACT_STATUS.DRAFT && row.status !== CONTRACT_STATUS.REJECTED) { + throw new BadRequestException('当前状态不能改收款条款'); + } + if (!items?.length) throw new BadRequestException('请至少填写一条收款条款'); + const ratios = items.map((i) => Number(i.ratio)); + if (ratios.some((n) => !Number.isFinite(n) || n <= 0)) { + throw new BadRequestException('比例必须大于 0'); + } + if (!milestoneSumOk(ratios)) { + throw new BadRequestException('收款条款比例合计必须为 100%'); + } + await this.prisma.$transaction(async (tx) => { + await tx.paymentMilestone.deleteMany({ where: { contractId: id } }); + await tx.paymentMilestone.createMany({ + data: items.map((i) => ({ + contractId: id, + name: i.name, + ratio: i.ratio, + dueAt: i.dueAt ? new Date(i.dueAt) : null, + trigger: i.trigger || null, + })), + }); + await tx.contract.update({ + where: { id }, + data: { status: CONTRACT_STATUS.DRAFT }, + }); + await this.log(tx, id, user.id, 'MILESTONES', row.status, CONTRACT_STATUS.DRAFT, `更新 ${items.length} 条收款条款`); + }); + return this.get(id, user); + } + + async createSeal(id: string, user: AuthUser, dto: SealInput) { + this.assertAccess(user); + if (!canEditContract(user.roles)) throw new ForbiddenException('无权申请用章'); + if (!dto.sealType?.trim() || !dto.reason?.trim()) { + throw new BadRequestException('请填写印章类型和事由'); + } + const row = await this.require(id); + if (row.status === CONTRACT_STATUS.APPROVED) { + throw new BadRequestException('合同已通过,用章请走证照用章模块补登记'); + } + await this.prisma.sealRequest.create({ + data: { + tenantId: '1', + sealType: dto.sealType.trim(), + reason: dto.reason.trim(), + contractId: row.id, + bidCaseId: row.bidCaseId, + applicantId: user.id, + takeOut: Boolean(dto.takeOut), + returnAt: dto.returnAt ? new Date(dto.returnAt) : null, + status: 'PENDING', + }, + }); + await this.prisma.contractActionLog.create({ + data: { + contractId: id, + actorId: user.id, + action: 'SEAL_REQUEST', + comment: `${dto.sealType}:${dto.reason}`, + }, + }); + return this.get(id, user); + } + + async submit(id: string, user: AuthUser) { + this.assertAccess(user); + if (!canEditContract(user.roles)) throw new ForbiddenException('无权提交合同审批'); + await this.prisma.$transaction(async (tx) => { + const row = await tx.contract.findFirst({ + where: { id, tenantId: '1' }, + include: { milestones: true, sealRequests: true, reviewers: true }, + }); + if (!row) throw new NotFoundException('合同不存在'); + if (row.status !== CONTRACT_STATUS.DRAFT && row.status !== CONTRACT_STATUS.REJECTED) { + throw new BadRequestException('当前状态不能提交审批'); + } + if (Number(row.amount) <= 0) throw new BadRequestException('请填写合同金额'); + if (!officialContractNo(row.contractNo)) { + throw new BadRequestException('请先填写正式合同编号,不要用系统自动号'); + } + const ratios = row.milestones.map((m) => Number(m.ratio)); + if (!ratios.length || !milestoneSumOk(ratios)) { + throw new BadRequestException('请先保存合计为 100% 的收款条款'); + } + if (!row.sealRequests.length) { + throw new BadRequestException('提交审批前请先申请用章,必须关联本合同'); + } + const approvers = await tx.user.findMany({ + where: { + tenantId: '1', + status: 'ACTIVE', + roles: { some: { role: { code: { in: ['owner', 'biz_director'] } } } }, + }, + }); + const ids = [...new Set(approvers.map((u) => u.id))]; + if (!ids.length) throw new BadRequestException('没有可审批的企业负责人或商务总监'); + await tx.contract.update({ + where: { id }, + data: { status: CONTRACT_STATUS.PENDING, submittedAt: new Date() }, + }); + await tx.contractReviewer.deleteMany({ where: { contractId: id } }); + await tx.contractReviewer.createMany({ + data: ids.map((userId) => ({ contractId: id, userId })), + }); + await tx.approvalTask.createMany({ + data: ids.map((assigneeId) => ({ + tenantId: '1', + title: `合同审批:${row.name}`, + bizType: CONTRACT_BIZ.APPROVAL, + bizId: id, + assigneeId, + })), + }); + await tx.todoTask.updateMany({ + where: { + status: 'OPEN', + bizType: BIZ_TYPE.CONVERT_CONTRACT, + bizId: { in: [id, row.bidCaseId].filter(Boolean) as string[] }, + }, + data: { status: 'DONE' }, + }); + await this.log(tx, id, user.id, 'SUBMIT', row.status, CONTRACT_STATUS.PENDING, '提交合同审批(含用章)'); + }); + return this.get(id, user); + } + + async review(id: string, user: AuthUser, dto: ReviewInput) { + this.assertAccess(user); + if (dto.result === 'REJECTED' && !dto.comment?.trim()) { + throw new BadRequestException('驳回请填写意见'); + } + await this.prisma.$transaction(async (tx) => { + const row = await tx.contract.findFirst({ + where: { id, tenantId: '1' }, + include: { reviewers: true, bidCase: { include: { expenses: true, party: { select: { name: true } } } }, sealRequests: true }, + }); + if (!row) throw new NotFoundException('合同不存在'); + if (row.status !== CONTRACT_STATUS.PENDING) { + throw new BadRequestException('当前不是待审批状态'); + } + const mine = row.reviewers.find((r) => r.userId === user.id && r.result === 'PENDING'); + if (!mine) throw new ForbiddenException('没有待办的合同审批'); + await tx.contractReviewer.update({ + where: { id: mine.id }, + data: { + result: dto.result, + comment: dto.comment?.trim() || null, + decidedAt: new Date(), + }, + }); + await tx.approvalTask.updateMany({ + where: { + bizId: id, + assigneeId: user.id, + status: 'PENDING', + bizType: CONTRACT_BIZ.APPROVAL, + }, + data: { status: dto.result === 'APPROVED' ? 'APPROVED' : 'REJECTED' }, + }); + + if (dto.result === 'REJECTED') { + await tx.contract.update({ + where: { id }, + data: { status: CONTRACT_STATUS.DRAFT }, + }); + await tx.approvalTask.updateMany({ + where: { bizId: id, status: 'PENDING' }, + data: { status: 'CANCELLED' }, + }); + await tx.contractReviewer.updateMany({ + where: { contractId: id, result: 'PENDING' }, + data: { result: 'CANCELLED' }, + }); + await this.log(tx, id, user.id, 'REJECT', CONTRACT_STATUS.PENDING, CONTRACT_STATUS.DRAFT, dto.comment!.trim()); + return; + } + + await this.log(tx, id, user.id, 'APPROVE', CONTRACT_STATUS.PENDING, undefined, dto.comment?.trim() || '同意'); + const pending = await tx.contractReviewer.count({ + where: { contractId: id, result: 'PENDING' }, + }); + if (pending > 0) return; + + await this.seals.applyContractApproved(tx, id); + const presales = (row.bidCase?.expenses || []).reduce( + (s, e) => s + Number(e.amount), + 0, + ); + const existingProject = await tx.project.findFirst({ where: { contractId: row.id } }); + const contractAmount = Number(row.amount || 0); + let project = existingProject; + if (existingProject) { + const patch: Prisma.ProjectUpdateInput = { + presalesCost: presales, + partyName: row.partyA || row.bidCase?.party?.name || existingProject.partyName, + }; + if (contractAmount > 0) patch.amount = contractAmount; + if (existingProject.status === PROJECT_STATUS.NOT_STARTED) { + patch.status = PROJECT_STATUS.ACTIVE; + } + project = await tx.project.update({ where: { id: existingProject.id }, data: patch }); + const budgetCount = await tx.projectBudget.count({ where: { projectId: existingProject.id } }); + if (contractAmount > 0 && budgetCount === 0) { + const split = splitEven(contractAmount); + await tx.projectBudget.createMany({ + data: [ + { projectId: existingProject.id, category: BUDGET_CATEGORY.BUSINESS, amount: split.business }, + { projectId: existingProject.id, category: BUDGET_CATEGORY.TECH, amount: split.tech }, + ], + }); + } + } else { + const projectNo = await nextBizNo(tx, 'PRJ', (start) => + tx.project.count({ where: { projectNo: { startsWith: start } } }), + ); + project = await tx.project.create({ + data: { + tenantId: '1', + projectNo, + name: row.name, + contractId: row.id, + partyName: row.partyA || row.bidCase?.party?.name || null, + status: PROJECT_STATUS.ACTIVE, + presalesCost: presales, + }, + }); + if (contractAmount > 0) { + const split = splitEven(contractAmount); + await tx.projectBudget.createMany({ + data: [ + { projectId: project.id, category: BUDGET_CATEGORY.BUSINESS, amount: split.business }, + { projectId: project.id, category: BUDGET_CATEGORY.TECH, amount: split.tech }, + ], + }); + } + } + if (row.bidCaseId) { + await tx.expenseClaim.updateMany({ + where: { bidCaseId: row.bidCaseId }, + data: { projectId: project.id }, + }); + } + await tx.contract.update({ + where: { id }, + data: { + status: CONTRACT_STATUS.APPROVED, + approvedAt: new Date(), + signedAt: new Date(), + }, + }); + await this.log( + tx, + id, + user.id, + 'APPROVED', + CONTRACT_STATUS.PENDING, + CONTRACT_STATUS.APPROVED, + existingProject + ? `合同通过,项目 ${project.projectNo} 已在中标时立项,继承售前成本 ${presales}` + : `立项 ${project.projectNo},继承售前成本 ${presales}`, + ); + }); + return this.get(id, user); + } + + async decideApproval(approvalId: string, user: AuthUser, dto: ReviewInput) { + const task = await this.prisma.approvalTask.findFirst({ + where: { id: approvalId, tenantId: '1' }, + }); + if (!task) throw new NotFoundException('审批任务不存在'); + if (task.assigneeId !== user.id) throw new ForbiddenException('这不是你的审批'); + if (task.status !== 'PENDING') throw new BadRequestException('该审批已办理'); + if (task.bizType !== CONTRACT_BIZ.APPROVAL || !task.bizId) { + throw new BadRequestException('不是合同审批'); + } + return this.review(task.bizId, user, dto); + } + + async moveKind(id: string, user: AuthUser, kind: string) { + this.assertAccess(user); + if (!canEditContract(user.roles)) throw new ForbiddenException('无权调整合同分类'); + const normalized = String(kind || '').trim().toUpperCase(); + if (!['TENDER', 'DIRECT', 'OTHER'].includes(normalized)) { + throw new BadRequestException('合同分类只能是投标合同、直签合同或其他合同'); + } + const row = await this.require(id); + if (row.status !== CONTRACT_STATUS.DRAFT && row.status !== CONTRACT_STATUS.REJECTED) { + throw new BadRequestException('仅草稿或已驳回合同可以调整分类'); + } + const updated = await this.prisma.contract.update({ where: { id }, data: { kind: normalized } }); + return this.get(updated.id, user); + } + + private async require(id: string) { + const row = await this.prisma.contract.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('合同不存在'); + return row; + } + + private async log( + tx: Tx, + contractId: string, + actorId: string, + action: string, + fromStatus?: string, + toStatus?: string, + comment?: string, + ) { + await tx.contractActionLog.create({ + data: { contractId, actorId, action, fromStatus, toStatus, comment }, + }); + } + + private myActions( + row: { + status: string; + reviewers: { userId: string; result: string }[]; + milestones: unknown[]; + sealRequests: unknown[]; + projects: unknown[]; + }, + user: AuthUser, + ) { + const editable = + (row.status === CONTRACT_STATUS.DRAFT || row.status === CONTRACT_STATUS.REJECTED) && + canEditContract(user.roles); + return { + canEdit: editable, + canSubmit: editable, + canRequestSeal: editable, + canReview: Boolean( + row.status === CONTRACT_STATUS.PENDING && + row.reviewers.some((r) => r.userId === user.id && r.result === 'PENDING'), + ), + }; + } +} diff --git a/apps/api/src/contract/won-ledger.ts b/apps/api/src/contract/won-ledger.ts new file mode 100644 index 0000000..b47d5c5 --- /dev/null +++ b/apps/api/src/contract/won-ledger.ts @@ -0,0 +1,106 @@ +import type { Prisma } from '@prisma/client'; +import { nextBizNo } from '../system/numbering'; +import { + BUDGET_CATEGORY, + normalizeProjectType, + PROJECT_STATUS, + splitEven, +} from '../project/project-workflow'; +import { CONTRACT_STATUS } from './contract-workflow'; + +type Tx = Prisma.TransactionClient; + +export type WonBidSeed = { + id: string; + bidNo: string; + name: string; + quoteAmount?: string | null; + projectType?: string | null; + legalEntityId?: string | null; + createdById?: string | null; + party?: { name: string } | null; + legalEntity?: { name: string } | null; + expenses?: { amount: unknown }[]; +}; + +/** 中标后立刻落合同草稿 + 项目台账,便于报销挂项目。幂等。 */ +export async function ensureWonLedger(tx: Tx, bid: WonBidSeed, actorId: string) { + const amount = Number(bid.quoteAmount) || 0; + const presales = (bid.expenses || []).reduce((s, e) => s + Number(e.amount || 0), 0); + const partyA = bid.party?.name || undefined; + const partyB = bid.legalEntity?.name || undefined; + + let contract = await tx.contract.findFirst({ + where: { + bidCaseId: bid.id, + status: { in: [CONTRACT_STATUS.DRAFT, CONTRACT_STATUS.PENDING, CONTRACT_STATUS.APPROVED] }, + }, + }); + if (!contract) { + contract = await tx.contract.create({ + data: { + tenantId: '1', + name: bid.name, + kind: 'TENDER', + bidCaseId: bid.id, + legalEntityId: bid.legalEntityId || undefined, + amount, + partyA, + partyB, + remark: `由中标事项 ${bid.bidNo} 自动生成草稿`, + status: CONTRACT_STATUS.DRAFT, + createdById: bid.createdById || actorId, + }, + }); + await tx.contractActionLog.create({ + data: { + contractId: contract.id, + actorId, + action: 'CREATE', + toStatus: CONTRACT_STATUS.DRAFT, + comment: `中标自动生成合同草稿 ${bid.bidNo}`, + }, + }); + } + + let project = await tx.project.findFirst({ where: { contractId: contract.id } }); + if (!project) { + const projectNo = await nextBizNo(tx, 'PRJ', (start) => + tx.project.count({ where: { projectNo: { startsWith: start } } }), + ); + project = await tx.project.create({ + data: { + tenantId: '1', + projectNo, + name: bid.name, + contractId: contract.id, + partyName: partyA || null, + projectType: normalizeProjectType(bid.projectType) || '', + amount, + status: PROJECT_STATUS.NOT_STARTED, + presalesCost: presales, + }, + }); + if (amount > 0) { + const split = splitEven(amount); + await tx.projectBudget.createMany({ + data: [ + { projectId: project.id, category: BUDGET_CATEGORY.BUSINESS, amount: split.business }, + { projectId: project.id, category: BUDGET_CATEGORY.TECH, amount: split.tech }, + ], + }); + } + } else if (presales > Number(project.presalesCost || 0)) { + project = await tx.project.update({ + where: { id: project.id }, + data: { presalesCost: presales }, + }); + } + + await tx.expenseClaim.updateMany({ + where: { bidCaseId: bid.id, projectId: null }, + data: { projectId: project.id }, + }); + + return { contract, project }; +} diff --git a/apps/api/src/files/files.controller.ts b/apps/api/src/files/files.controller.ts new file mode 100644 index 0000000..83a1fc3 --- /dev/null +++ b/apps/api/src/files/files.controller.ts @@ -0,0 +1,58 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Post, + StreamableFile, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger'; +import { join } from 'path'; +import { CurrentUser, type AuthUser } from '../common/decorators/current-user.decorator'; +import { decodeUploadFileName, FilesService } from './files.service'; + +@ApiTags('files') +@ApiBearerAuth() +@Controller('files') +export class FilesController { + constructor(private readonly files: FilesService) { + this.files.root(); + } + + @Post() + @ApiConsumes('multipart/form-data') + @UseInterceptors( + FileInterceptor('file', { + dest: join(process.cwd(), 'uploads', 'tmp'), + limits: { fileSize: 20 * 1024 * 1024 }, + }), + ) + upload( + @CurrentUser() user: AuthUser, + @UploadedFile() file: { originalname: string; mimetype: string; size: number; buffer?: Buffer; path?: string }, + @Body() body: { bizType?: string; bizId?: string }, + ) { + return this.files.create(user, { bizType: body.bizType || '', bizId: body.bizId || '' }, file); + } + + @Get(':id') + async download(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) { + const { row, stream } = await this.files.download(id, user); + const fileName = decodeUploadFileName(row.fileName); + return new StreamableFile(stream, { + type: row.mimeType || 'application/octet-stream', + disposition: `inline; filename*=UTF-8''${encodeURIComponent(fileName)}`, + length: row.size || undefined, + }); + } + + @Delete(':id') + remove(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) { + return this.files.remove(id, user); + } +} diff --git a/apps/api/src/files/files.mime.spec.ts b/apps/api/src/files/files.mime.spec.ts new file mode 100644 index 0000000..59a5786 --- /dev/null +++ b/apps/api/src/files/files.mime.spec.ts @@ -0,0 +1,19 @@ +import { isAllowedChatImage, mimeFromName, sniffUploadMime } from './files.mime'; + +describe('files.mime', () => { + it('sniffs jpeg/png/gif even when declared as octet-stream', () => { + expect(sniffUploadMime(Buffer.from([0xff, 0xd8, 0xff, 0xe0]), 'application/octet-stream', 'a.bin')).toBe( + 'image/jpeg', + ); + expect(sniffUploadMime(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 'application/octet-stream')).toBe( + 'image/png', + ); + expect(sniffUploadMime(Buffer.from('GIF89a'), '', 'x.gif')).toBe('image/gif'); + }); + + it('maps image/jpg and filename screenshot.jpg', () => { + expect(sniffUploadMime(Buffer.from('xxxx'), 'image/jpg', 'shot.jpg')).toBe('image/jpeg'); + expect(mimeFromName('screenshot_com.tencent.wechat.jpg')).toBe('image/jpeg'); + expect(isAllowedChatImage('image/jpg')).toBe(true); + }); +}); diff --git a/apps/api/src/files/files.mime.ts b/apps/api/src/files/files.mime.ts new file mode 100644 index 0000000..44271f7 --- /dev/null +++ b/apps/api/src/files/files.mime.ts @@ -0,0 +1,77 @@ +const IMAGE_OK = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'image/bmp', + 'image/heic', + 'image/heif', +]); + +const ALIAS: Record = { + 'image/jpg': 'image/jpeg', + 'image/pjpeg': 'image/jpeg', + 'image/x-png': 'image/png', + 'image/x-icon': 'image/png', +}; + +export function normalizeMime(raw?: string | null) { + const m = (raw || '').split(';')[0].trim().toLowerCase(); + if (!m) return ''; + return ALIAS[m] || m; +} + +export function mimeFromName(name?: string | null) { + const n = (name || '').toLowerCase(); + if (/\.jpe?g$/.test(n)) return 'image/jpeg'; + if (n.endsWith('.png')) return 'image/png'; + if (n.endsWith('.gif')) return 'image/gif'; + if (n.endsWith('.webp')) return 'image/webp'; + if (n.endsWith('.bmp')) return 'image/bmp'; + if (n.endsWith('.heic') || n.endsWith('.heif')) return 'image/heic'; + if (n.endsWith('.pdf')) return 'application/pdf'; + if (n.endsWith('.doc')) return 'application/msword'; + if (n.endsWith('.docx')) return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + if (n.endsWith('.xls')) return 'application/vnd.ms-excel'; + if (n.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + if (n.endsWith('.m4a') || n.endsWith('.mp4')) return 'audio/mp4'; + if (n.endsWith('.aac')) return 'audio/aac'; + if (n.endsWith('.mp3')) return 'audio/mpeg'; + if (n.endsWith('.amr')) return 'audio/amr'; + if (n.endsWith('.ogg') || n.endsWith('.opus')) return 'audio/ogg'; + return ''; +} + +export function sniffUploadMime(buf: Buffer, declared?: string | null, name?: string | null) { + if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'; + if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'image/png'; + if (buf.length >= 6 && buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return 'image/gif'; + if ( + buf.length >= 12 && + buf[0] === 0x52 && + buf[1] === 0x49 && + buf[2] === 0x46 && + buf[3] === 0x46 && + buf[8] === 0x57 && + buf[9] === 0x45 && + buf[10] === 0x42 && + buf[11] === 0x50 + ) { + return 'image/webp'; + } + if (buf.length >= 4 && buf[0] === 0x25 && buf[1] === 0x50 && buf[2] === 0x44 && buf[3] === 0x46) return 'application/pdf'; + if (buf.length >= 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) { + const d = normalizeMime(declared); + if (d.startsWith('video/')) return d; + return 'audio/mp4'; + } + const fromName = mimeFromName(name); + if (fromName) return fromName; + const d = normalizeMime(declared); + if (d && d !== 'application/octet-stream') return d; + return d || 'application/octet-stream'; +} + +export function isAllowedChatImage(mime: string) { + return IMAGE_OK.has(normalizeMime(mime)); +} diff --git a/apps/api/src/files/files.module.ts b/apps/api/src/files/files.module.ts new file mode 100644 index 0000000..7adb8e3 --- /dev/null +++ b/apps/api/src/files/files.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { FilesController } from './files.controller'; +import { FilesService } from './files.service'; + +@Module({ + controllers: [FilesController], + providers: [FilesService], + exports: [FilesService], +}) +export class FilesModule {} diff --git a/apps/api/src/files/files.service.ts b/apps/api/src/files/files.service.ts new file mode 100644 index 0000000..31e4494 --- /dev/null +++ b/apps/api/src/files/files.service.ts @@ -0,0 +1,433 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { createReadStream, existsSync, mkdirSync, openSync, readFileSync, readSync, closeSync, unlinkSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; +import { PrismaService } from '../prisma/prisma.service'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { ASSIGN_ROLE, BID_STATUS, canAccessBidModule } from '../bidding/bid-workflow'; +import { canAccessContract } from '../contract/contract-workflow'; +import { canAccessProject } from '../project/project-workflow'; +import { canApproveFinance, canFileFinance } from '../finance/finance-workflow'; +import { canManageHr } from '../hr/hr-workflow'; +import { canManageNotice, canSeeNotice } from '../office/office-apply'; +import { isAllowedChatImage, sniffUploadMime } from './files.mime'; + +export const FILE_BIZ = { + EXPENSE: 'EXPENSE', + EXPENSE_LINE: 'EXPENSE_LINE', + LOAN: 'LOAN', + BID_ANNOUNCE: 'BID_ANNOUNCE', + BID_FILE: 'BID_FILE', + BID_FORMAL: 'BID_FORMAL', + BID_PROOF: 'BID_PROOF', + CONTRACT: 'CONTRACT', + CONTRACT_ACCEPT: 'CONTRACT_ACCEPT', + CONTRACT_AWARD: 'CONTRACT_AWARD', + PROJECT: 'PROJECT', + PROJECT_ACCEPT: 'PROJECT_ACCEPT', + PURCHASE: 'PURCHASE', + PURCHASE_ACCEPT: 'PURCHASE_ACCEPT', + USER_AVATAR: 'USER_AVATAR', + IM_IMAGE: 'IM_IMAGE', + IM_VOICE: 'IM_VOICE', + IM_STICKER: 'IM_STICKER', + IM_FILE: 'IM_FILE', + USER_ID_PHOTO: 'USER_ID_PHOTO', + EMPLOYEE_ID_PHOTO: 'EMPLOYEE_ID_PHOTO', + EMPLOYEE_ID_FRONT: 'EMPLOYEE_ID_FRONT', + EMPLOYEE_ID_BACK: 'EMPLOYEE_ID_BACK', + EMPLOYEE_CERT: 'EMPLOYEE_CERT', + EMPLOYEE_LABOR: 'EMPLOYEE_LABOR', + NOTICE: 'NOTICE', +} as const; + +const ATTACHABLE = new Set([ + FILE_BIZ.EXPENSE, + FILE_BIZ.EXPENSE_LINE, + FILE_BIZ.LOAN, + FILE_BIZ.BID_ANNOUNCE, + FILE_BIZ.BID_FILE, + FILE_BIZ.BID_FORMAL, + FILE_BIZ.BID_PROOF, + FILE_BIZ.CONTRACT, + FILE_BIZ.CONTRACT_ACCEPT, + FILE_BIZ.CONTRACT_AWARD, + FILE_BIZ.PROJECT, + FILE_BIZ.PROJECT_ACCEPT, + FILE_BIZ.PURCHASE, + FILE_BIZ.PURCHASE_ACCEPT, + FILE_BIZ.USER_AVATAR, + FILE_BIZ.IM_IMAGE, + FILE_BIZ.IM_VOICE, + FILE_BIZ.IM_STICKER, + FILE_BIZ.IM_FILE, + FILE_BIZ.USER_ID_PHOTO, + FILE_BIZ.EMPLOYEE_ID_PHOTO, + FILE_BIZ.EMPLOYEE_ID_FRONT, + FILE_BIZ.EMPLOYEE_ID_BACK, + FILE_BIZ.EMPLOYEE_CERT, + FILE_BIZ.EMPLOYEE_LABOR, + FILE_BIZ.NOTICE, +]); + +const ALLOWED = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'audio/mp4', + 'audio/aac', + 'audio/mpeg', + 'audio/amr', + 'audio/3gpp', + 'audio/ogg', + 'video/mp4', + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +]); + +const VOICE_OK = new Set(['audio/mp4', 'audio/aac', 'audio/mpeg', 'audio/amr', 'audio/3gpp', 'audio/ogg', 'video/mp4', 'application/octet-stream']); +const MAX = 8 * 1024 * 1024; + +function sniffStoredMime(full: string, declared?: string | null, name?: string | null) { + const buf = Buffer.alloc(16); + try { + const fd = openSync(full, 'r'); + readSync(fd, buf, 0, 16, 0); + closeSync(fd); + } catch { + return declared || 'application/octet-stream'; + } + return sniffUploadMime(buf, declared, name); +} + +/** multer 把 Content-Disposition 文件名按 latin1 解,中文会变成乱码。 */ +export function decodeUploadFileName(name?: string) { + if (!name) return '附件'; + if (/[\u4e00-\u9fff]/.test(name)) return name; + try { + const decoded = Buffer.from(name, 'latin1').toString('utf8'); + if (/[\u4e00-\u9fff]/.test(decoded) && !decoded.includes('\uFFFD')) return decoded; + } catch { + /* keep original */ + } + return name; +} + +@Injectable() +export class FilesService { + constructor(private readonly prisma: PrismaService) {} + + root() { + const dir = join(process.cwd(), 'uploads'); + mkdirSync(dir, { recursive: true }); + mkdirSync(join(dir, 'tmp'), { recursive: true }); + return dir; + } + + async list(bizType: string, bizId: string) { + const rows = await this.prisma.fileAsset.findMany({ + where: { tenantId: '1', bizType, bizId }, + orderBy: { createdAt: 'asc' }, + }); + return rows.map((r) => ({ ...r, fileName: decodeUploadFileName(r.fileName) })); + } + + async create( + user: AuthUser, + dto: { bizType: string; bizId: string }, + file?: { originalname: string; mimetype: string; size: number; buffer?: Buffer; path?: string }, + ) { + if (!file) throw new BadRequestException('请选择文件'); + const buffer = file.buffer?.length + ? file.buffer + : file.path && existsSync(file.path) + ? readFileSync(file.path) + : undefined; + if (!buffer?.length) throw new BadRequestException('请选择文件'); + const bizType = (dto.bizType || '').toUpperCase(); + if (file.size > (bizType === FILE_BIZ.IM_FILE ? 20 * 1024 * 1024 : MAX)) { + throw new BadRequestException(bizType === FILE_BIZ.IM_FILE ? '文件不能超过 20MB' : '文件不能超过 8MB'); + } + const mime = sniffUploadMime(buffer, file.mimetype, file.originalname); + const voice = bizType === FILE_BIZ.IM_VOICE; + const chatFile = bizType === FILE_BIZ.IM_FILE; + const chatImage = bizType === FILE_BIZ.IM_IMAGE || bizType === FILE_BIZ.IM_STICKER; + if (voice) { + if (mime && !VOICE_OK.has(mime) && !mime.startsWith('audio/')) { + throw new BadRequestException('语音只接受音频文件'); + } + } else if (chatFile) { + /* 聊天文件放宽类型 */ + } else if (chatImage) { + if (!isAllowedChatImage(mime)) { + throw new BadRequestException('只接受图片'); + } + } else if (mime && !ALLOWED.has(mime) && !isAllowedChatImage(mime)) { + throw new BadRequestException('只接受图片、语音、PDF 或 Office 文件'); + } + if (!ATTACHABLE.has(bizType)) { + throw new BadRequestException('该类型不能上传附件'); + } + await this.assertCanAttach(user, bizType, dto.bizId); + const fileName = decodeUploadFileName(file.originalname); + const id = randomUUID(); + const ext = (fileName.split('.').pop() || 'bin').replace(/[^a-zA-Z0-9]/g, '').slice(0, 8); + const storageKey = `${bizType}/${dto.bizId}/${id}.${ext || 'bin'}`; + const full = join(this.root(), storageKey); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, buffer); + if (file.path && existsSync(file.path)) unlinkSync(file.path); + return this.prisma.fileAsset.create({ + data: { + id, + tenantId: '1', + bizType, + bizId: dto.bizId, + fileName, + mimeType: mime || file.mimetype || 'application/octet-stream', + size: file.size, + storageKey, + uploadedById: user.id, + }, + }); + } + + async download(id: string, user: AuthUser) { + const row = await this.prisma.fileAsset.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('附件不存在'); + await this.assertCanSee(user, row.bizType, row.bizId); + const full = join(this.root(), row.storageKey); + if (!existsSync(full)) throw new NotFoundException('文件已丢失'); + const mimeType = sniffStoredMime(full, row.mimeType, row.fileName); + return { row: { ...row, mimeType }, stream: createReadStream(full) }; + } + + async remove(id: string, user: AuthUser) { + const row = await this.prisma.fileAsset.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('附件不存在'); + await this.assertCanAttach(user, row.bizType, row.bizId); + const full = join(this.root(), row.storageKey); + if (existsSync(full)) unlinkSync(full); + await this.prisma.fileAsset.delete({ where: { id } }); + return { ok: true }; + } + + private async assertCanSee(user: AuthUser, bizType: string, bizId: string) { + if (canApproveFinance(user.roles) || user.roles.includes('admin') || user.roles.includes('owner')) return; + if (bizType === FILE_BIZ.EXPENSE || bizType === FILE_BIZ.EXPENSE_LINE) { + const row = + bizType === FILE_BIZ.EXPENSE + ? await this.prisma.expenseClaim.findFirst({ where: { id: bizId, tenantId: '1' } }) + : ( + await this.prisma.expenseLine.findFirst({ + where: { id: bizId }, + include: { claim: true }, + }) + )?.claim; + if (row && row.applicantId === user.id) return; + } + if (bizType === FILE_BIZ.LOAN) { + const row = await this.prisma.loanRecord.findFirst({ where: { id: bizId, tenantId: '1' } }); + if (row && row.applicantId === user.id) return; + } + if ( + bizType === FILE_BIZ.USER_AVATAR || + bizType === FILE_BIZ.IM_IMAGE || + bizType === FILE_BIZ.IM_VOICE || + bizType === FILE_BIZ.IM_STICKER || + bizType === FILE_BIZ.IM_FILE + ) + return; + if (bizType.startsWith('USER') && (bizId === user.id || user.roles.includes('admin') || user.roles.includes('hr'))) + return; + if ( + (bizType === FILE_BIZ.EMPLOYEE_ID_PHOTO || + bizType === FILE_BIZ.EMPLOYEE_ID_FRONT || + bizType === FILE_BIZ.EMPLOYEE_ID_BACK || + bizType === FILE_BIZ.EMPLOYEE_CERT) && + (user.roles.includes('admin') || user.roles.includes('hr') || user.employeeId === bizId) + ) + return; + if (bizType === FILE_BIZ.EMPLOYEE_LABOR) { + if (canManageHr(user.roles, user.permissions) || canAccessContract(user.roles, user.permissions)) return; + const row = await this.prisma.laborContract.findFirst({ + where: { id: bizId, tenantId: '1' }, + include: { employee: { select: { id: true } } }, + }); + if (row && user.employeeId && row.employeeId === user.employeeId) return; + } + if (bizType === FILE_BIZ.BID_FILE) { + const bid = await this.prisma.bidCase.findFirst({ + where: { id: bizId, tenantId: '1' }, + include: { assignees: true }, + }); + if ( + bid && + (canAccessBidModule(user.roles, user.permissions) || + bid.assignees.some((a) => a.role === ASSIGN_ROLE.FETCHER && a.userId === user.id)) + ) { + return; + } + } + if (bizType.startsWith('BID') && canAccessBidModule(user.roles, user.permissions)) return; + if (bizType.startsWith('CONTRACT') && canAccessContract(user.roles, user.permissions)) return; + if ((bizType.startsWith('PROJECT') || bizType.startsWith('PURCHASE')) && canAccessProject(user.roles, user.permissions)) + return; + if (bizType === FILE_BIZ.NOTICE) { + const row = await this.prisma.notice.findFirst({ where: { id: bizId, tenantId: '1' } }); + if (row && (await canSeeNotice(this.prisma, user, row))) return; + } + throw new ForbiddenException('无权查看该附件'); + } + + private async assertCanAttach(user: AuthUser, bizType: string, bizId: string) { + if (bizType.startsWith('USER')) { + if (bizId !== user.id && !user.roles.includes('admin') && !user.roles.includes('hr')) { + throw new ForbiddenException('只能上传自己的头像或证件照'); + } + const row = await this.prisma.user.findFirst({ where: { id: bizId, tenantId: '1' } }); + if (!row) throw new NotFoundException('账号不存在'); + await this.prisma.fileAsset.deleteMany({ + where: { tenantId: '1', bizType, bizId }, + }); + return; + } + if ( + bizType === FILE_BIZ.EMPLOYEE_ID_PHOTO || + bizType === FILE_BIZ.EMPLOYEE_ID_FRONT || + bizType === FILE_BIZ.EMPLOYEE_ID_BACK || + bizType === FILE_BIZ.EMPLOYEE_CERT + ) { + const self = user.employeeId === bizId; + if (!self && !user.roles.includes('admin') && !user.roles.includes('hr')) { + throw new ForbiddenException('只有本人或人事可以补档案附件'); + } + const row = await this.prisma.employee.findFirst({ where: { id: bizId, tenantId: '1' } }); + if (!row) throw new NotFoundException('员工不存在'); + if ( + bizType === FILE_BIZ.EMPLOYEE_ID_PHOTO || + bizType === FILE_BIZ.EMPLOYEE_ID_FRONT || + bizType === FILE_BIZ.EMPLOYEE_ID_BACK + ) { + await this.prisma.fileAsset.deleteMany({ + where: { tenantId: '1', bizType, bizId }, + }); + } + return; + } + if (bizType === FILE_BIZ.EMPLOYEE_LABOR) { + if (!canManageHr(user.roles, user.permissions) && !canAccessContract(user.roles, user.permissions)) { + throw new ForbiddenException('无权上传劳动合同'); + } + const row = await this.prisma.laborContract.findFirst({ where: { id: bizId, tenantId: '1' } }); + if (!row) throw new NotFoundException('人事合同不存在'); + return; + } + if (bizType.startsWith('BID')) { + const bid = await this.prisma.bidCase.findFirst({ + where: { id: bizId, tenantId: '1' }, + include: { assignees: true }, + }); + if (!bid) throw new NotFoundException('投标事项不存在'); + if (bizType === FILE_BIZ.BID_ANNOUNCE) { + if (bid.createdById !== user.id && !user.roles.includes('admin')) { + throw new ForbiddenException('只有该投标的发起人可以上传招标公告附件'); + } + return; + } + if (bizType === FILE_BIZ.BID_FILE) { + const assignedFetcher = bid.assignees.some( + (a) => a.role === ASSIGN_ROLE.FETCHER && a.userId === user.id, + ); + const canHandleFetch = + bid.status === BID_STATUS.FETCH && + (assignedFetcher || user.roles.includes('admin')); + if (!canHandleFetch) { + throw new ForbiddenException('只有当前指定的取标办理人可以上传或删除取标附件'); + } + return; + } + if (!canAccessBidModule(user.roles, user.permissions)) { + throw new ForbiddenException('无权上传投标附件'); + } + const assigned = bid.assignees.some((a) => a.userId === user.id); + if (!assigned && bid.createdById !== user.id && !user.roles.includes('admin')) { + throw new ForbiddenException('只有该投标的指定办理人可以上传附件'); + } + return; + } + if (bizType === FILE_BIZ.PROJECT_ACCEPT) { + if (!canAccessProject(user.roles, user.permissions)) { + throw new ForbiddenException('无权上传验收材料'); + } + const row = await this.prisma.projectAcceptMaterial.findFirst({ + where: { id: bizId }, + include: { project: { select: { status: true } } }, + }); + if (!row) throw new NotFoundException('验收材料不存在'); + if (row.project.status === 'ACCEPTED' || row.project.status === 'DONE') { + throw new BadRequestException('项目已验收,不能再改材料'); + } + await this.prisma.fileAsset.deleteMany({ + where: { tenantId: '1', bizType, bizId }, + }); + return; + } + if (bizType.startsWith('PROJECT') || bizType.startsWith('PURCHASE') || bizType.startsWith('CONTRACT')) { + if (!canAccessProject(user.roles, user.permissions) && !canAccessContract(user.roles, user.permissions)) { + throw new ForbiddenException('无权上传该附件'); + } + return; + } + if (bizType === FILE_BIZ.IM_IMAGE || bizType === FILE_BIZ.IM_VOICE || bizType === FILE_BIZ.IM_STICKER || bizType === FILE_BIZ.IM_FILE) { + return; + } + if (bizType === FILE_BIZ.NOTICE) { + if (!canManageNotice(user.roles, user.permissions)) { + throw new ForbiddenException('无权给通知上传附件'); + } + const row = await this.prisma.notice.findFirst({ where: { id: bizId, tenantId: '1' } }); + if (!row) throw new NotFoundException('通知不存在'); + return; + } + if (!canFileFinance(user.roles, user.permissions) && !canApproveFinance(user.roles)) { + throw new ForbiddenException('无权上传附件'); + } + if (bizType === FILE_BIZ.EXPENSE || bizType === FILE_BIZ.EXPENSE_LINE) { + const row = + bizType === FILE_BIZ.EXPENSE + ? await this.prisma.expenseClaim.findFirst({ where: { id: bizId, tenantId: '1' } }) + : ( + await this.prisma.expenseLine.findFirst({ + where: { id: bizId }, + include: { claim: true }, + }) + )?.claim; + if (!row) throw new NotFoundException('报销单不存在'); + if (row.applicantId !== user.id && !canApproveFinance(user.roles)) { + throw new ForbiddenException('只能给自己的报销上传发票'); + } + if (row.status !== 'DRAFT' && row.status !== 'REJECTED' && row.status !== 'PENDING' && !canApproveFinance(user.roles)) { + throw new BadRequestException('当前状态不能改附件'); + } + return; + } + const row = await this.prisma.loanRecord.findFirst({ where: { id: bizId, tenantId: '1' } }); + if (!row) throw new NotFoundException('借款单不存在'); + if (row.applicantId !== user.id && !canApproveFinance(user.roles)) { + throw new ForbiddenException('只能给自己的借款上传附件'); + } + if (row.status !== 'DRAFT' && row.status !== 'REJECTED' && !canApproveFinance(user.roles)) { + throw new BadRequestException('当前状态不能改附件'); + } + } +} diff --git a/apps/api/src/finance/bank-pay.ts b/apps/api/src/finance/bank-pay.ts new file mode 100644 index 0000000..e5f8fb0 --- /dev/null +++ b/apps/api/src/finance/bank-pay.ts @@ -0,0 +1,49 @@ +import { BadRequestException } from '@nestjs/common'; + +/** 打款通道。BANK_API 是银企直联预留口,未对接具体银行。 */ +export const BANK_PAY_CHANNELS = [ + { code: 'MANUAL', name: '线下网银 / 柜面', enabled: true, hint: '财务在网银打完后点「已打款」。' }, + { code: 'BANK_API', name: '银企直联', enabled: false, hint: '接口已预留,尚未对接银行主机。' }, +] as const; + +export type BankPayInstruction = { + paymentId: string; + payNo: string; + amount: number; + purpose: string; + payeeName: string; + bankName: string; + bankAccount: string; +}; + +export type BankPayResult = { + ok: boolean; + channel: string; + bankTxnNo?: string; + message: string; +}; + +export function maskBankAccount(acc?: string | null) { + const s = String(acc || '').replace(/\s/g, ''); + if (!s) return null; + if (s.length <= 4) return s; + return `${'*'.repeat(Math.max(4, s.length - 4))}${s.slice(-4)}`; +} + +export function assertBankInstruction(ins: BankPayInstruction) { + if (!ins.payeeName?.trim()) throw new BadRequestException('缺少收款人'); + if (!ins.bankAccount?.trim()) throw new BadRequestException('收款人没有银行账号,无法打款'); + if (!(ins.amount > 0)) throw new BadRequestException('打款金额无效'); +} + +/** 银企直联入口。开通后在此对接银行,不要把账号写进业务页面。 */ +export async function dispatchBankPay(channel: string, ins: BankPayInstruction): Promise { + assertBankInstruction(ins); + if (channel === 'MANUAL') { + return { ok: true, channel: 'MANUAL', message: '请财务在网银打完后点已打款' }; + } + if (channel === 'BANK_API') { + throw new BadRequestException('银企直联尚未开通,当前请线下打到收款人账户后点「已打款」'); + } + throw new BadRequestException('未知打款通道'); +} diff --git a/apps/api/src/finance/finance-workflow.spec.ts b/apps/api/src/finance/finance-workflow.spec.ts new file mode 100644 index 0000000..e862784 --- /dev/null +++ b/apps/api/src/finance/finance-workflow.spec.ts @@ -0,0 +1,39 @@ +import { + assertCostObject, + bondBucket, + canAccessFinanceDesk, + canApproveFinance, + canFileFinance, + FILE_COST_TYPES, + loanRemaining, +} from './finance-workflow'; + +describe('finance-workflow', () => { + it('requires a cost object of bid, project, dept or admin', () => { + expect(assertCostObject('BID')).toMatch(/投标/); + expect(assertCostObject('BID', 'bid-1')).toBeNull(); + expect(assertCostObject('PROJECT', undefined, 'p1')).toBeNull(); + expect(assertCostObject('DEPT')).toBeNull(); + expect(assertCostObject('ADMIN')).toBeNull(); + expect(assertCostObject('MARKETING')).toMatch(/必须是/); + expect(assertCostObject('BID', 'bid-1', 'p1')).toMatch(/只挂投标/); + }); + + it('lets office users file and finance approve', () => { + expect(canFileFinance(['employee'], ['office:todos'])).toBe(true); + expect(canApproveFinance(['finance'])).toBe(true); + expect(canApproveFinance(['biz_director'])).toBe(true); + expect(canApproveFinance(['biz_staff'])).toBe(false); + expect(canAccessFinanceDesk(['finance'], ['finance:expenses'])).toBe(true); + expect(canAccessFinanceDesk(['employee'], ['office'])).toBe(false); + }); + + it('computes loan remaining and bond buckets', () => { + expect(loanRemaining(1000, 400)).toBe(600); + expect(FILE_COST_TYPES).toContain('BID'); + expect(bondBucket({ status: 'UNPAID' })).toBe('unpaid'); + expect(bondBucket({ status: 'PAID', dueBackAt: new Date(Date.now() - 86400000) })).toBe('overdue'); + expect(bondBucket({ status: 'PAID', dueBackAt: new Date(Date.now() + 86400000) })).toBe('due'); + expect(bondBucket({ status: 'RETURNED' })).toBe('returned'); + }); +}); diff --git a/apps/api/src/finance/finance-workflow.ts b/apps/api/src/finance/finance-workflow.ts new file mode 100644 index 0000000..ccb89e4 --- /dev/null +++ b/apps/api/src/finance/finance-workflow.ts @@ -0,0 +1,94 @@ +export const COST_TYPE = { + BID: 'BID', + PROJECT: 'PROJECT', + DEPT: 'DEPT', + ADMIN: 'ADMIN', + MARKETING: 'MARKETING', +} as const; + +export const FILE_COST_TYPES = [COST_TYPE.BID, COST_TYPE.PROJECT, COST_TYPE.DEPT, COST_TYPE.ADMIN] as const; + +export const EXPENSE_STATUS = { + DRAFT: 'DRAFT', + PENDING: 'PENDING', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED', +} as const; + +export const LOAN_STATUS = { + DRAFT: 'DRAFT', + PENDING: 'PENDING', + OPEN: 'OPEN', + OFFSET: 'OFFSET', + REJECTED: 'REJECTED', +} as const; + +export const BOND_TYPE = { + TENDER: 'TENDER', + PERFORMANCE: 'PERFORMANCE', + WARRANTY: 'WARRANTY', +} as const; + +export const BOND_STATUS = { + UNPAID: 'UNPAID', + PAID: 'PAID', + RETURNED: 'RETURNED', +} as const; + +export const INVOICE_STATUS = { + TO_ISSUE: 'TO_ISSUE', + ISSUED: 'ISSUED', + RECEIVED: 'RECEIVED', + OVERDUE: 'OVERDUE', +} as const; + +export const FINANCE_BIZ = { + EXPENSE: 'EXPENSE_APPROVAL', + LOAN: 'LOAN_APPROVAL', + OFFSET: 'LOAN_OFFSET', +} as const; + +export function canFileFinance(roles: string[], permissions: string[]) { + if (roles.includes('admin')) return true; + return permissions.some((p) => p === 'office' || p.startsWith('office:')); +} + +export function canApproveFinance(roles: string[]) { + return ['admin', 'owner', 'finance', 'biz_director'].some((r) => roles.includes(r)); +} + +export function canAccessFinanceDesk(roles: string[], permissions: string[]) { + if (roles.includes('admin')) return true; + return permissions.some((p) => p === 'finance' || p.startsWith('finance:')); +} + +/** 上一级:财务确认岗。草稿不到这里;未通过不到再上一级(台账/报表/付款)。 */ +export function canSeeSubmittedFinance(roles: string[], permissions: string[]) { + return canApproveFinance(roles) || canAccessFinanceDesk(roles, permissions); +} + +export function assertCostObject(costType: string, bidCaseId?: string, projectId?: string) { + if (!(FILE_COST_TYPES as readonly string[]).includes(costType)) { + return '报销归属必须是投标、项目、部门或行政之一'; + } + if (costType === COST_TYPE.BID && !bidCaseId) return '挂投标费用请选择投标事项'; + if (costType === COST_TYPE.PROJECT && !projectId) return '挂项目费用请选择正式项目'; + if (costType === COST_TYPE.BID && projectId) return '中标前费用请只挂投标编号'; + if (costType === COST_TYPE.PROJECT && bidCaseId) return '正式项目费用请只挂项目,不要同时挂投标'; + if ((costType === COST_TYPE.DEPT || costType === COST_TYPE.ADMIN) && (bidCaseId || projectId)) { + return '部门或行政费用不要挂投标或项目'; + } + return null; +} + +export function loanRemaining(amount: number, offsetAmount: number) { + return Math.round((amount - offsetAmount) * 100) / 100; +} + +export function bondBucket(row: { status: string; dueBackAt?: Date | string | null; returnedAt?: Date | string | null }) { + if (row.status === BOND_STATUS.UNPAID) return 'unpaid'; + if (row.status === BOND_STATUS.RETURNED || row.returnedAt) return 'returned'; + if (row.dueBackAt && new Date(row.dueBackAt).getTime() < Date.now()) return 'overdue'; + if (row.dueBackAt) return 'due'; + return 'paid'; +} diff --git a/apps/api/src/finance/finance.controller.ts b/apps/api/src/finance/finance.controller.ts new file mode 100644 index 0000000..b33eba1 --- /dev/null +++ b/apps/api/src/finance/finance.controller.ts @@ -0,0 +1,308 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser, type AuthUser } from '../common/decorators/current-user.decorator'; +import { FinanceService } from './finance.service'; + +@ApiTags('finance') +@ApiBearerAuth() +@Controller() +export class FinanceController { + constructor(private readonly finance: FinanceService) {} + + @Get('cost-targets') + costTargets(@CurrentUser() user: AuthUser) { + return this.finance.costTargets(user); + } + + @Get('asset-purchases') + assetPurchases(@CurrentUser() user: AuthUser) { + return this.finance.assetPurchaseLedger(user); + } + + @Get('expenses') + expenses( + @CurrentUser() user: AuthUser, + @Query('page') page = '1', + @Query('pageSize') pageSize = '10', + @Query('bucket') bucket?: string, + @Query('keyword') keyword?: string, + ) { + return this.finance.listExpenses(user, Number(page) || 1, Number(pageSize) || 10, bucket, keyword); + } + + @Get('expenses/:id') + expense(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.finance.getExpense(id, user); + } + + @Post('expenses') + createExpense( + @CurrentUser() user: AuthUser, + @Body() + body: { + costType: string; + amount: number; + bidCaseId?: string; + projectId?: string; + loanId?: string; + remark?: string; + budgetCategory?: string; + expenseCategory?: string; + purpose?: string; + prepaid?: boolean; + payerId?: string; + lines?: { + id?: string; + kind: string; + amount: number; + invoiceNo?: string; + occurAt?: string; + remark?: string; + }[]; + }, + ) { + return this.finance.createExpense(user, body); + } + + @Patch('expenses/:id') + updateExpense( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() + body: { + costType: string; + amount: number; + bidCaseId?: string; + projectId?: string; + loanId?: string; + remark?: string; + budgetCategory?: string; + expenseCategory?: string; + purpose?: string; + prepaid?: boolean; + payerId?: string; + lines?: { + id?: string; + kind: string; + amount: number; + invoiceNo?: string; + occurAt?: string; + remark?: string; + }[]; + }, + ) { + return this.finance.updateExpense(id, user, body); + } + + @Post('expenses/:id/link-loan') + linkExpenseLoan( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { loanId: string }, + ) { + return this.finance.linkExpenseLoan(id, user, body.loanId); + } + + @Delete('expenses/:id') + deleteExpense(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.finance.deleteExpense(id, user); + } + + @Post('expenses/:id/submit') + submitExpense(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.finance.submitExpense(id, user); + } + + @Post('expenses/:id/reviews') + reviewExpense( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { result: 'APPROVED' | 'REJECTED'; comment?: string }, + ) { + return this.finance.reviewExpense(id, user, body); + } + + @Get('loans') + loans( + @CurrentUser() user: AuthUser, + @Query('page') page = '1', + @Query('pageSize') pageSize = '10', + @Query('bucket') bucket?: string, + @Query('keyword') keyword?: string, + ) { + return this.finance.listLoans(user, Number(page) || 1, Number(pageSize) || 10, bucket, keyword); + } + + @Get('loans/:id') + loan(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.finance.getLoan(id, user); + } + + @Post('loans') + createLoan( + @CurrentUser() user: AuthUser, + @Body() + body: { + costType: string; + amount: number; + purpose: string; + bidCaseId?: string; + projectId?: string; + remark?: string; + }, + ) { + return this.finance.createLoan(user, body); + } + + @Patch('loans/:id') + updateLoan( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() + body: { + costType: string; + amount: number; + purpose: string; + bidCaseId?: string; + projectId?: string; + remark?: string; + }, + ) { + return this.finance.updateLoan(id, user, body); + } + + @Delete('loans/:id') + deleteLoan(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.finance.deleteLoan(id, user); + } + + @Post('loans/:id/submit') + submitLoan(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.finance.submitLoan(id, user); + } + + @Post('loans/:id/reviews') + reviewLoan( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { result: 'APPROVED' | 'REJECTED'; comment?: string }, + ) { + return this.finance.reviewLoan(id, user, body); + } + + @Get('bonds') + bonds( + @CurrentUser() user: AuthUser, + @Query('page') page = '1', + @Query('pageSize') pageSize = '20', + @Query('bucket') bucket?: string, + @Query('bondType') bondType?: string, + ) { + return this.finance.listBonds(user, Number(page) || 1, Number(pageSize) || 20, bucket, bondType); + } + + @Post('bonds') + createBond( + @CurrentUser() user: AuthUser, + @Body() + body: { + bondType: string; + amount: number; + bidCaseId?: string; + projectId?: string; + contractId?: string; + dueBackAt?: string; + remark?: string; + }, + ) { + return this.finance.createBond(user, body); + } + + @Post('bonds/:id/pay') + payBond( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { dueBackAt?: string }, + ) { + return this.finance.payBond(id, user, body); + } + + @Post('bonds/:id/return') + returnBond(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.finance.returnBond(id, user); + } + + @Delete('bonds/:id') + deleteBond(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.finance.deleteBond(id, user); + } + + @Get('invoices') + invoices( + @CurrentUser() user: AuthUser, + @Query('page') page = '1', + @Query('pageSize') pageSize = '20', + @Query('bucket') bucket?: string, + ) { + return this.finance.listInvoices(user, Number(page) || 1, Number(pageSize) || 20, bucket); + } + + @Post('invoices') + createInvoice( + @CurrentUser() user: AuthUser, + @Body() body: { contractId: string; milestoneId: string; amount?: number; dueAt?: string; remark?: string }, + ) { + return this.finance.createInvoice(user, body); + } + + @Post('invoices/:id/issue') + issueInvoice( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { invoiceNo: string }, + ) { + return this.finance.issueInvoice(id, user, body); + } + + @Post('invoices/:id/receive') + receiveInvoice(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.finance.receiveInvoice(id, user); + } + + @Get('payments') + payments(@CurrentUser() user: AuthUser) { + return this.finance.listPayments(user); + } + + @Get('payments/bank-channels') + bankChannels(@CurrentUser() user: AuthUser) { + return this.finance.listBankChannels(user); + } + + @Post('payments/bank-webhook') + bankWebhook() { + return this.finance.receiveBankWebhook(); + } + + @Post('payments') + createPayment( + @CurrentUser() user: AuthUser, + @Body() body: { kind: string; refId?: string; amount?: number; remark?: string }, + ) { + return this.finance.createPayment(user, body); + } + + @Post('payments/:id/dispatch') + dispatchPayment( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { channel?: string }, + ) { + return this.finance.dispatchPayment(id, user, body.channel || 'BANK_API'); + } + + @Post('payments/:id/pay') + pay(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.finance.pay(id, user); + } +} diff --git a/apps/api/src/finance/finance.service.ts b/apps/api/src/finance/finance.service.ts new file mode 100644 index 0000000..a38d16b --- /dev/null +++ b/apps/api/src/finance/finance.service.ts @@ -0,0 +1,1461 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { BANK_PAY_CHANNELS, dispatchBankPay, maskBankAccount } from './bank-pay'; +import { emitSms } from '../sms/sms-bus'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { + BOND_STATUS, + BOND_TYPE, + COST_TYPE, + EXPENSE_STATUS, + FINANCE_BIZ, + INVOICE_STATUS, + LOAN_STATUS, + assertCostObject, + bondBucket, + canAccessFinanceDesk, + canApproveFinance, + canFileFinance, + canSeeSubmittedFinance, + loanRemaining, +} from './finance-workflow'; +import { ProjectService } from '../project/project.service'; +import { assertExpenseBudgetCategory, encodeExpenseBudget } from '../project/project-workflow'; +import { assertCanSeeOwner, ownerScope, withAnd } from '../common/data-scope'; +import { resolveExpenseApproverChain } from '../common/org-chain'; +import { lockDocNo, nextSerial, withUniqueRetry } from '../common/doc-no'; + +const USER_SELECT = { id: true, displayName: true, username: true }; +const PAYEE_EMPLOYEE = { select: { name: true, bankName: true, bankAccount: true, department: { select: { name: true } } } }; +const BID_SELECT = { id: true, bidNo: true, externalNo: true, name: true, status: true, party: { select: { name: true } } }; +const PROJECT_SELECT = { id: true, projectNo: true, name: true, status: true }; + +const EXPENSE_INCLUDE = { + applicant: { select: { id: true, displayName: true, username: true, employee: PAYEE_EMPLOYEE } }, + decidedBy: { select: USER_SELECT }, + payer: { select: { id: true, displayName: true, username: true, employee: PAYEE_EMPLOYEE } }, + bidCase: { select: BID_SELECT }, + project: { select: PROJECT_SELECT }, + loan: true, + lines: { orderBy: { sortNo: 'asc' as const } }, +}; + +const LINE_KINDS = ['AIR', 'HSR', 'TRAIN', 'TAXI', 'HOTEL', 'MEAL', 'OFFICE', 'OTHER'] as const; + +const LOAN_INCLUDE = { + applicant: { select: { id: true, displayName: true, username: true, employee: { select: { name: true, department: { select: { name: true } } } } } }, + decidedBy: { select: USER_SELECT }, + bidCase: { select: BID_SELECT }, + project: { select: PROJECT_SELECT }, + expenses: true, +}; + +type Tx = Prisma.TransactionClient; + +export type CostInput = { + costType: string; + amount: number; + bidCaseId?: string; + projectId?: string; + remark?: string; +}; + +export type ExpenseLineInput = { + id?: string; + kind: string; + amount: number; + invoiceNo?: string; + occurAt?: string; + remark?: string; +}; + +const LINE_TO_CATEGORY: Record = { + AIR: '交通', + HSR: '交通', + TRAIN: '交通', + TAXI: '交通', + HOTEL: '住宿', + MEAL: '餐饮', + OFFICE: '办公', + OTHER: '其他', +}; + +export type ExpenseInput = CostInput & { + loanId?: string; + budgetCategory?: string; + expenseCategory?: string; + purpose?: string; + prepaid?: boolean; + payerId?: string; + lines?: ExpenseLineInput[]; +}; +export type LoanInput = CostInput & { purpose: string }; +export type ReviewInput = { result: 'APPROVED' | 'REJECTED'; comment?: string }; + +@Injectable() +export class FinanceService { + constructor( + private readonly prisma: PrismaService, + private readonly projects: ProjectService, + ) {} + + assertFile(user: AuthUser) { + if (!canFileFinance(user.roles, user.permissions)) { + throw new ForbiddenException('无权填报费用'); + } + } + + assertDesk(user: AuthUser) { + if (!canAccessFinanceDesk(user.roles, user.permissions)) { + throw new ForbiddenException('无权访问财务收支'); + } + } + + async costTargets(user: AuthUser) { + this.assertFile(user); + const canBid = + user.roles.includes('admin') || + user.permissions.some((p) => p === 'bid' || p.startsWith('bid:')); + const [bids, projects] = await Promise.all([ + this.prisma.bidCase.findMany({ + where: canBid + ? { tenantId: '1' } + : { tenantId: '1', assignees: { some: { userId: user.id } } }, + select: BID_SELECT, + orderBy: { createdAt: 'desc' }, + take: 80, + }), + this.prisma.project.findMany({ + where: { tenantId: '1' }, + select: PROJECT_SELECT, + orderBy: { createdAt: 'desc' }, + take: 200, + }), + ]); + return { bids, projects }; + } + + async listExpenses(user: AuthUser, page: number, pageSize: number, bucket?: string, keyword?: string) { + this.assertFile(user); + const where: Prisma.ExpenseClaimWhereInput = { tenantId: '1' }; + if (bucket === 'pending') where.status = EXPENSE_STATUS.PENDING; + if (bucket === 'approved') where.status = EXPENSE_STATUS.APPROVED; + if (bucket === 'rejected') where.status = EXPENSE_STATUS.REJECTED; + if (bucket === 'mine') where.applicantId = user.id; + else { + withAnd(where, (await ownerScope(this.prisma, user, 'applicantId')) as Record); + withAnd(where, this.othersSeeSubmitted(user)); + } + if (keyword) { + withAnd(where, { + OR: [ + { claimNo: { contains: keyword } }, + { purpose: { contains: keyword } }, + { remark: { contains: keyword } }, + { applicant: { displayName: { contains: keyword } } }, + ], + }); + } + const [items, total] = await this.prisma.$transaction([ + this.prisma.expenseClaim.findMany({ + where, + include: EXPENSE_INCLUDE, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.expenseClaim.count({ where }), + ]); + const reveal = canAccessFinanceDesk(user.roles, user.permissions); + return { items: items.map((i) => this.presentExpensePeople(i, reveal)), page, pageSize, total }; + } + + async getExpense(id: string, user: AuthUser) { + this.assertFile(user); + const row = await this.prisma.expenseClaim.findFirst({ + where: { id, tenantId: '1' }, + include: EXPENSE_INCLUDE, + }); + if (!row) throw new NotFoundException('报销单不存在'); + await this.assertVisibleTo(user, row, FINANCE_BIZ.EXPENSE); + const myTask = await this.prisma.approvalTask.findFirst({ + where: { bizType: FINANCE_BIZ.EXPENSE, bizId: id, assigneeId: user.id }, + }); + if (!myTask) await assertCanSeeOwner(this.prisma, user, row.applicantId); + const files = await this.prisma.fileAsset.findMany({ + where: { tenantId: '1', bizType: 'EXPENSE', bizId: id }, + orderBy: { createdAt: 'asc' }, + }); + const lineFiles = row.lines.length + ? await this.prisma.fileAsset.findMany({ + where: { tenantId: '1', bizType: 'EXPENSE_LINE', bizId: { in: row.lines.map((l) => l.id) } }, + orderBy: { createdAt: 'asc' }, + }) + : []; + const payment = await this.prisma.paymentRecord.findFirst({ + where: { kind: 'EXPENSE', refId: id }, + orderBy: { createdAt: 'desc' }, + }); + const approvalChain = await this.expenseChainStatus(id, row.applicantId); + const reveal = canAccessFinanceDesk(user.roles, user.permissions); + const presented = this.presentExpensePeople(row, reveal); + return { + ...presented, + lines: presented.lines.map((l) => ({ + ...l, + files: lineFiles.filter((f) => f.bizId === l.id), + })), + files, + payment, + payout: this.expensePayout(row, reveal), + approvalChain, + myActions: this.expenseActions( + row, + user, + Boolean(myTask && myTask.status === 'PENDING'), + approvalChain.some((step) => step.status === 'APPROVED'), + ), + }; + } + + async createExpense(user: AuthUser, dto: ExpenseInput) { + this.assertFile(user); + this.deriveExpenseHeader(dto); + await this.resolveExpenseBudget(user, dto); + const amount = this.normalizeExpenseAmount(dto); + await this.assertPayer(dto, user.id); + await this.validateCost(dto, true); + if (amount > 0) await this.assertProjectBudget(dto); + const created = await withUniqueRetry(() => + this.prisma.$transaction(async (tx) => { + await this.assertLoanForExpense(tx, user, dto); + const claimNo = await this.nextNo(tx, 'BX'); + return tx.expenseClaim.create({ + data: { + tenantId: '1', + claimNo, + applicantId: user.id, + costType: dto.costType, + budgetCategory: dto.costType === COST_TYPE.PROJECT ? dto.budgetCategory : null, + bidCaseId: dto.costType === COST_TYPE.BID ? dto.bidCaseId : null, + projectId: dto.costType === COST_TYPE.PROJECT ? dto.projectId : null, + loanId: dto.prepaid ? null : dto.loanId || null, + expenseCategory: dto.expenseCategory?.trim() || null, + purpose: dto.purpose?.trim() || null, + amount, + remark: dto.remark, + prepaid: Boolean(dto.prepaid), + payerId: dto.prepaid ? dto.payerId || null : null, + status: EXPENSE_STATUS.DRAFT, + lines: { create: this.lineRows(dto.lines) }, + }, + }); + }), + ); + return this.getExpense(created.id, user); + } + + async updateExpense(id: string, user: AuthUser, dto: ExpenseInput) { + const row = await this.requireOwnExpense(id, user); + if (row.status !== EXPENSE_STATUS.DRAFT && row.status !== EXPENSE_STATUS.REJECTED) { + throw new BadRequestException('当前状态不能修改'); + } + this.deriveExpenseHeader(dto); + await this.resolveExpenseBudget(user, dto); + const amount = this.normalizeExpenseAmount(dto); + await this.assertPayer(dto, user.id); + await this.validateCost(dto, true); + if (amount > 0) await this.assertProjectBudget(dto, id); + await this.prisma.$transaction(async (tx) => { + await this.assertLoanForExpense(tx, user, dto, id); + await this.syncLines(tx, id, dto.lines); + await tx.expenseClaim.update({ + where: { id }, + data: { + costType: dto.costType, + budgetCategory: dto.costType === COST_TYPE.PROJECT ? dto.budgetCategory : null, + bidCaseId: dto.costType === COST_TYPE.BID ? dto.bidCaseId : null, + projectId: dto.costType === COST_TYPE.PROJECT ? dto.projectId : null, + loanId: dto.prepaid ? null : dto.loanId || null, + expenseCategory: dto.expenseCategory?.trim() || null, + purpose: dto.purpose?.trim() || null, + amount, + remark: dto.remark, + prepaid: Boolean(dto.prepaid), + payerId: dto.prepaid ? dto.payerId || null : null, + status: EXPENSE_STATUS.DRAFT, + rejectComment: null, + }, + }); + }); + return this.getExpense(id, user); + } + + async linkExpenseLoan(id: string, user: AuthUser, loanId: string) { + this.assertFile(user); + if (!loanId) throw new BadRequestException('请选择要冲销的借款'); + await this.prisma.$transaction(async (tx) => { + const row = await tx.expenseClaim.findFirst({ + where: { id, tenantId: '1' }, + include: { lines: true }, + }); + if (!row) throw new NotFoundException('报销单不存在'); + if (row.applicantId !== user.id) throw new ForbiddenException('只能关联自己的报销单'); + if (!( [EXPENSE_STATUS.DRAFT, EXPENSE_STATUS.REJECTED, EXPENSE_STATUS.PENDING] as string[]).includes(row.status)) { + throw new BadRequestException('当前状态不能关联冲账'); + } + if (row.status === EXPENSE_STATUS.PENDING) { + const passed = await tx.approvalTask.count({ + where: { bizType: FINANCE_BIZ.EXPENSE, bizId: id, status: 'APPROVED' }, + }); + if (passed) throw new BadRequestException('审批已开始,不能再变更冲账借款'); + } + const loan = await tx.loanRecord.findFirst({ + where: { id: loanId, tenantId: '1', applicantId: user.id, status: LOAN_STATUS.OPEN }, + }); + if (!loan) throw new BadRequestException('请选择本人已放款、未结清的借款'); + const dto: ExpenseInput = { + costType: loan.costType, + bidCaseId: loan.bidCaseId || undefined, + projectId: loan.projectId || undefined, + budgetCategory: loan.costType === COST_TYPE.PROJECT ? row.budgetCategory || undefined : undefined, + amount: Number(row.amount), + loanId: loan.id, + prepaid: false, + }; + await this.validateCost(dto, true); + if (dto.costType === COST_TYPE.PROJECT) await this.assertProjectBudget(dto, id); + await this.assertLoanForExpense(tx, user, dto, id); + await tx.expenseClaim.update({ + where: { id }, + data: { + costType: loan.costType, + bidCaseId: loan.costType === COST_TYPE.BID ? loan.bidCaseId : null, + projectId: loan.costType === COST_TYPE.PROJECT ? loan.projectId : null, + budgetCategory: loan.costType === COST_TYPE.PROJECT ? row.budgetCategory : null, + loanId: loan.id, + prepaid: false, + payerId: null, + }, + }); + }); + return this.getExpense(id, user); + } + + async submitExpense(id: string, user: AuthUser) { + await this.prisma.$transaction(async (tx) => { + const row = await tx.expenseClaim.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('报销单不存在'); + if (row.applicantId !== user.id) throw new ForbiddenException('只能提交自己的报销'); + if (row.status !== EXPENSE_STATUS.DRAFT && row.status !== EXPENSE_STATUS.REJECTED) { + throw new BadRequestException('当前状态不能提交'); + } + const lines = await tx.expenseLine.findMany({ where: { claimId: id }, orderBy: { sortNo: 'asc' } }); + if (!lines.length) throw new BadRequestException('至少填一张发票'); + if (lines.some((l) => Number(l.amount) <= 0)) throw new BadRequestException('请填写每张发票的金额'); + const costErr = assertCostObject(row.costType, row.bidCaseId || undefined, row.projectId || undefined); + if (costErr) throw new BadRequestException(costErr); + if (!Number(row.amount) || Number(row.amount) <= 0) throw new BadRequestException('请填写发票金额'); + await this.assertProjectBudget( + { + costType: row.costType, + amount: Number(row.amount), + bidCaseId: row.bidCaseId || undefined, + projectId: row.projectId || undefined, + budgetCategory: row.budgetCategory || undefined, + }, + id, + ); + await this.assertLoanForExpense( + tx, + user, + { + costType: row.costType, + amount: Number(row.amount), + bidCaseId: row.bidCaseId || undefined, + projectId: row.projectId || undefined, + loanId: row.loanId || undefined, + prepaid: row.prepaid, + }, + id, + ); + const chain = await resolveExpenseApproverChain(tx, user.id); + if (!chain.length) { + throw new BadRequestException('无法解析报销审批链,请先在档案里维护项目经理和部门总监'); + } + const first = chain[0]; + await tx.expenseClaim.update({ + where: { id }, + data: { status: EXPENSE_STATUS.PENDING, submittedAt: new Date() }, + }); + await tx.approvalTask.create({ + data: { + tenantId: '1', + title: `报销待审(${first.role}):${(row.purpose || '').trim() || row.claimNo}`, + bizType: FINANCE_BIZ.EXPENSE, + bizId: id, + assigneeId: first.user.id, + }, + }); + emitSms('EXPENSE', { + userIds: [first.user.id], + params: { content: `报销待确认:${row.claimNo}`, apply_no: row.claimNo }, + }); + if (row.loanId) { + await tx.todoTask.updateMany({ + where: { bizId: row.loanId, bizType: FINANCE_BIZ.OFFSET, assigneeId: user.id, status: 'OPEN' }, + data: { status: 'DONE' }, + }); + } + }); + return this.getExpense(id, user); + } + + async reviewExpense(id: string, user: AuthUser, dto: ReviewInput) { + if (dto.result === 'REJECTED' && !dto.comment?.trim()) { + throw new BadRequestException('打回请填写意见'); + } + await this.prisma.$transaction(async (tx) => { + const row = await tx.expenseClaim.findFirst({ + where: { id, tenantId: '1' }, + include: { loan: true }, + }); + if (!row) throw new NotFoundException('报销单不存在'); + if (row.status !== EXPENSE_STATUS.PENDING) { + throw new BadRequestException('当前不是待确认状态'); + } + const myTask = await tx.approvalTask.findFirst({ + where: { bizType: FINANCE_BIZ.EXPENSE, bizId: id, assigneeId: user.id, status: 'PENDING' }, + }); + if (!myTask) throw new ForbiddenException('这不是你当前要审的报销'); + if (dto.result === 'REJECTED') { + await tx.expenseClaim.update({ + where: { id }, + data: { + status: EXPENSE_STATUS.REJECTED, + decidedAt: new Date(), + decidedById: user.id, + rejectComment: dto.comment!.trim(), + }, + }); + await this.closeApprovals(tx, FINANCE_BIZ.EXPENSE, id, 'REJECTED', user.id); + await tx.todoTask.create({ + data: { + tenantId: '1', + title: (row.purpose || '').trim() || `修改报销 ${row.claimNo}`, + bizType: FINANCE_BIZ.EXPENSE, + bizId: id, + assigneeId: row.applicantId, + }, + }); + return; + } + await tx.approvalTask.update({ + where: { id: myTask.id }, + data: { status: 'APPROVED' }, + }); + const chain = await resolveExpenseApproverChain(tx, row.applicantId); + const done = await tx.approvalTask.findMany({ + where: { bizType: FINANCE_BIZ.EXPENSE, bizId: id, status: 'APPROVED' }, + select: { assigneeId: true }, + }); + const doneIds = new Set(done.map((t) => t.assigneeId)); + const next = chain.find((s) => !doneIds.has(s.user.id)); + if (next) { + await tx.approvalTask.create({ + data: { + tenantId: '1', + title: `报销待审(${next.role}):${(row.purpose || '').trim() || row.claimNo}`, + bizType: FINANCE_BIZ.EXPENSE, + bizId: id, + assigneeId: next.user.id, + }, + }); + emitSms('EXPENSE', { + userIds: [next.user.id], + params: { content: `报销待确认:${row.claimNo}`, apply_no: row.claimNo }, + }); + return; + } + if (row.loanId && !row.prepaid) { + await this.applyOffset(tx, row.loanId, Number(row.amount), id); + } + await tx.expenseClaim.update({ + where: { id }, + data: { + status: EXPENSE_STATUS.APPROVED, + decidedAt: new Date(), + decidedById: user.id, + rejectComment: null, + }, + }); + await this.closeApprovals(tx, FINANCE_BIZ.EXPENSE, id, 'APPROVED', user.id); + if (row.prepaid || !row.loanId) { + await this.enqueueExpensePayment(tx, row, user.id); + } + }); + return this.getExpense(id, user); + } + + async listLoans(user: AuthUser, page: number, pageSize: number, bucket?: string, keyword?: string) { + this.assertFile(user); + const where: Prisma.LoanRecordWhereInput = { tenantId: '1' }; + if (bucket === 'pending') where.status = LOAN_STATUS.PENDING; + if (bucket === 'open') where.status = LOAN_STATUS.OPEN; + if (bucket === 'mine') where.applicantId = user.id; + else { + withAnd(where, (await ownerScope(this.prisma, user, 'applicantId')) as Record); + withAnd(where, this.othersSeeSubmitted(user, 'loan')); + } + if (keyword) { + withAnd(where, { + OR: [{ loanNo: { contains: keyword } }, { purpose: { contains: keyword } }, { remark: { contains: keyword } }], + }); + } + const [items, total] = await this.prisma.$transaction([ + this.prisma.loanRecord.findMany({ + where, + include: LOAN_INCLUDE, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.loanRecord.count({ where }), + ]); + return { + items: items.map((r) => ({ + ...r, + remaining: loanRemaining(Number(r.amount), Number(r.offsetAmount)), + })), + page, + pageSize, + total, + }; + } + + async getLoan(id: string, user: AuthUser) { + this.assertFile(user); + const row = await this.prisma.loanRecord.findFirst({ + where: { id, tenantId: '1' }, + include: LOAN_INCLUDE, + }); + if (!row) throw new NotFoundException('借款单不存在'); + await this.assertVisibleTo(user, row, FINANCE_BIZ.LOAN); + await assertCanSeeOwner(this.prisma, user, row.applicantId); + const files = await this.prisma.fileAsset.findMany({ + where: { tenantId: '1', bizType: 'LOAN', bizId: id }, + orderBy: { createdAt: 'asc' }, + }); + return { + ...row, + files, + remaining: loanRemaining(Number(row.amount), Number(row.offsetAmount)), + myActions: this.loanActions(row, user), + }; + } + + async deleteExpense(id: string, user: AuthUser) { + const row = await this.requireOwnExpense(id, user); + if (row.status !== EXPENSE_STATUS.DRAFT && row.status !== EXPENSE_STATUS.REJECTED) { + throw new BadRequestException('已提交的报销不能删除'); + } + const lines = await this.prisma.expenseLine.findMany({ where: { claimId: id }, select: { id: true } }); + await this.prisma.fileAsset.deleteMany({ + where: { + OR: [{ bizType: 'EXPENSE', bizId: id }, { bizType: 'EXPENSE_LINE', bizId: { in: lines.map((l) => l.id) } }], + }, + }); + await this.prisma.expenseClaim.delete({ where: { id } }); + return { ok: true }; + } + + async deleteLoan(id: string, user: AuthUser) { + const row = await this.requireOwnLoan(id, user); + if (row.status !== LOAN_STATUS.DRAFT && row.status !== LOAN_STATUS.REJECTED) { + throw new BadRequestException('已提交的借款不能删除'); + } + await this.prisma.fileAsset.deleteMany({ where: { bizType: 'LOAN', bizId: id } }); + await this.prisma.loanRecord.delete({ where: { id } }); + return { ok: true }; + } + + async createLoan(user: AuthUser, dto: LoanInput) { + this.assertFile(user); + if (!dto.purpose?.trim()) throw new BadRequestException('请填写借款用途'); + await this.validateCost(dto); + const created = await withUniqueRetry(() => + this.prisma.$transaction(async (tx) => { + const loanNo = await this.nextNo(tx, 'JK'); + return tx.loanRecord.create({ + data: { + tenantId: '1', + loanNo, + applicantId: user.id, + costType: dto.costType, + bidCaseId: dto.costType === COST_TYPE.BID ? dto.bidCaseId : null, + projectId: dto.costType === COST_TYPE.PROJECT ? dto.projectId : null, + amount: dto.amount, + purpose: dto.purpose.trim(), + remark: dto.remark, + status: LOAN_STATUS.DRAFT, + }, + }); + }), + ); + return this.getLoan(created.id, user); + } + + async updateLoan(id: string, user: AuthUser, dto: LoanInput) { + const row = await this.requireOwnLoan(id, user); + if (row.status !== LOAN_STATUS.DRAFT && row.status !== LOAN_STATUS.REJECTED) { + throw new BadRequestException('当前状态不能修改'); + } + if (!dto.purpose?.trim()) throw new BadRequestException('请填写借款用途'); + await this.validateCost(dto); + await this.prisma.loanRecord.update({ + where: { id }, + data: { + costType: dto.costType, + bidCaseId: dto.costType === COST_TYPE.BID ? dto.bidCaseId : null, + projectId: dto.costType === COST_TYPE.PROJECT ? dto.projectId : null, + amount: dto.amount, + purpose: dto.purpose.trim(), + remark: dto.remark, + status: LOAN_STATUS.DRAFT, + }, + }); + return this.getLoan(id, user); + } + + async submitLoan(id: string, user: AuthUser) { + await this.prisma.$transaction(async (tx) => { + const row = await tx.loanRecord.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('借款单不存在'); + if (row.applicantId !== user.id) throw new ForbiddenException('只能提交自己的借款'); + if (row.status !== LOAN_STATUS.DRAFT && row.status !== LOAN_STATUS.REJECTED) { + throw new BadRequestException('当前状态不能提交'); + } + if (Number(row.amount) <= 0) throw new BadRequestException('请填写借款金额'); + const approvers = await this.financeApprovers(tx); + if (!approvers.length) throw new BadRequestException('没有可确认的财务人员'); + await tx.loanRecord.update({ + where: { id }, + data: { status: LOAN_STATUS.PENDING, submittedAt: new Date() }, + }); + await tx.approvalTask.createMany({ + data: approvers.map((u) => ({ + tenantId: '1', + title: (row.purpose || '').trim() || `借支 ${row.loanNo}`, + bizType: FINANCE_BIZ.LOAN, + bizId: id, + assigneeId: u.id, + })), + }); + emitSms('EXPENSE', { + userIds: approvers.map((u) => u.id), + params: { content: `借款待确认:${row.loanNo}`, apply_no: row.loanNo }, + }); + }); + return this.getLoan(id, user); + } + + async reviewLoan(id: string, user: AuthUser, dto: ReviewInput) { + if (!canApproveFinance(user.roles)) throw new ForbiddenException('无权确认借款'); + if (dto.result === 'REJECTED' && !dto.comment?.trim()) { + throw new BadRequestException('打回请填写意见'); + } + await this.prisma.$transaction(async (tx) => { + const row = await tx.loanRecord.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('借款单不存在'); + if (row.status !== LOAN_STATUS.PENDING) throw new BadRequestException('当前不是待确认状态'); + if (dto.result === 'REJECTED') { + await tx.loanRecord.update({ + where: { id }, + data: { status: LOAN_STATUS.REJECTED, decidedAt: new Date(), decidedById: user.id, remark: dto.comment!.trim() }, + }); + await this.closeApprovals(tx, FINANCE_BIZ.LOAN, id, 'REJECTED', user.id); + return; + } + await tx.loanRecord.update({ + where: { id }, + data: { status: LOAN_STATUS.OPEN, decidedAt: new Date(), decidedById: user.id }, + }); + await this.closeApprovals(tx, FINANCE_BIZ.LOAN, id, 'APPROVED', user.id); + await tx.todoTask.create({ + data: { + tenantId: '1', + title: `报销冲账:${row.loanNo}`, + bizType: FINANCE_BIZ.OFFSET, + bizId: id, + assigneeId: row.applicantId, + }, + }); + }); + return this.getLoan(id, user); + } + + async listBonds(user: AuthUser, page: number, pageSize: number, bucket?: string, bondType?: string) { + this.assertDesk(user); + const type = bondType && Object.values(BOND_TYPE).includes(bondType as (typeof BOND_TYPE)[keyof typeof BOND_TYPE]) + ? bondType + : undefined; + const rows = await this.prisma.bondRecord.findMany({ + where: type ? { tenantId: '1', bondType: type } : { tenantId: '1' }, + include: { + bidCase: { select: BID_SELECT }, + project: { select: PROJECT_SELECT }, + contract: { select: { id: true, contractNo: true, name: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + const mapped = rows.map((r) => ({ ...r, bucket: bondBucket(r) })); + const filtered = bucket ? mapped.filter((r) => r.bucket === bucket) : mapped; + const start = (page - 1) * pageSize; + return { items: filtered.slice(start, start + pageSize), page, pageSize, total: filtered.length }; + } + + async createBond( + user: AuthUser, + dto: { + bondType: string; + amount: number; + bidCaseId?: string; + projectId?: string; + contractId?: string; + dueBackAt?: string; + remark?: string; + }, + ) { + this.assertDesk(user); + if (!Object.values(BOND_TYPE).includes(dto.bondType as (typeof BOND_TYPE)[keyof typeof BOND_TYPE])) { + throw new BadRequestException('保证金类型须为投标、履约或质保'); + } + if (!dto.amount || dto.amount <= 0) throw new BadRequestException('请填写保证金金额'); + if (dto.bondType === BOND_TYPE.TENDER && !dto.bidCaseId) { + throw new BadRequestException('投标保证金必须挂投标'); + } + if (dto.bondType === BOND_TYPE.PERFORMANCE && !dto.bidCaseId && !dto.projectId && !dto.contractId) { + throw new BadRequestException('履约保证金必须挂投标或项目'); + } + if (dto.bondType === BOND_TYPE.WARRANTY && !dto.projectId) { + throw new BadRequestException('项目质保金必须挂项目'); + } + return withUniqueRetry(() => + this.prisma.$transaction(async (tx) => { + const bondNo = await this.nextNo(tx, 'BZ'); + return tx.bondRecord.create({ + data: { + tenantId: '1', + bondNo, + bondType: dto.bondType, + amount: dto.amount, + bidCaseId: dto.bidCaseId || null, + projectId: dto.projectId || null, + contractId: dto.contractId || null, + dueBackAt: dto.dueBackAt ? new Date(dto.dueBackAt) : null, + remark: dto.remark, + status: BOND_STATUS.UNPAID, + }, + }); + }), + ); + } + + async payBond(id: string, user: AuthUser, dto: { dueBackAt?: string }) { + this.assertDesk(user); + const row = await this.prisma.bondRecord.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('保证金记录不存在'); + if (row.status !== BOND_STATUS.UNPAID) throw new BadRequestException('当前不是未缴纳状态'); + return this.prisma.bondRecord.update({ + where: { id }, + data: { + status: BOND_STATUS.PAID, + paidAt: new Date(), + dueBackAt: dto.dueBackAt ? new Date(dto.dueBackAt) : row.dueBackAt, + }, + }); + } + + async returnBond(id: string, user: AuthUser) { + this.assertDesk(user); + const row = await this.prisma.bondRecord.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('保证金记录不存在'); + if (row.status !== BOND_STATUS.PAID) throw new BadRequestException('未缴纳或已退回'); + return this.prisma.bondRecord.update({ + where: { id }, + data: { status: BOND_STATUS.RETURNED, returnedAt: new Date() }, + }); + } + + async deleteBond(id: string, user: AuthUser) { + this.assertDesk(user); + const row = await this.prisma.bondRecord.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('保证金记录不存在'); + await this.prisma.bondRecord.delete({ where: { id } }); + return { ok: true }; + } + + async listInvoices(user: AuthUser, page: number, pageSize: number, bucket?: string) { + this.assertDesk(user); + await this.markOverdueInvoices(); + const where: Prisma.InvoiceRecordWhereInput = { tenantId: '1' }; + if (bucket === 'to_issue') where.status = INVOICE_STATUS.TO_ISSUE; + if (bucket === 'issued') where.status = { in: [INVOICE_STATUS.ISSUED, INVOICE_STATUS.OVERDUE] }; + if (bucket === 'received') where.status = INVOICE_STATUS.RECEIVED; + if (bucket === 'overdue') where.status = INVOICE_STATUS.OVERDUE; + const [items, total] = await this.prisma.$transaction([ + this.prisma.invoiceRecord.findMany({ + where, + include: { + contract: { select: { id: true, contractNo: true, name: true, amount: true } }, + milestone: true, + createdBy: { select: USER_SELECT }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.invoiceRecord.count({ where }), + ]); + return { items, page, pageSize, total }; + } + + async createInvoice( + user: AuthUser, + dto: { contractId: string; milestoneId: string; amount?: number; dueAt?: string; remark?: string }, + ) { + this.assertDesk(user); + const contract = await this.prisma.contract.findFirst({ + where: { id: dto.contractId, tenantId: '1' }, + include: { milestones: true }, + }); + if (!contract) throw new NotFoundException('合同不存在'); + if (contract.status !== 'APPROVED') throw new BadRequestException('只有审批通过的合同可以开票'); + const mile = contract.milestones.find((m) => m.id === dto.milestoneId); + if (!mile) throw new BadRequestException('收款条款不属于本合同'); + const exists = await this.prisma.invoiceRecord.findFirst({ where: { milestoneId: mile.id } }); + if (exists) throw new BadRequestException('该收款条款已经开过票'); + const amount = + dto.amount && dto.amount > 0 + ? dto.amount + : Math.round(Number(contract.amount) * Number(mile.ratio)) / 100; + if (amount <= 0) throw new BadRequestException('开票金额无效'); + return this.prisma.invoiceRecord.create({ + data: { + tenantId: '1', + contractId: contract.id, + milestoneId: mile.id, + amount, + dueAt: dto.dueAt ? new Date(dto.dueAt) : mile.dueAt, + remark: dto.remark, + createdById: user.id, + status: INVOICE_STATUS.TO_ISSUE, + }, + include: { contract: true, milestone: true }, + }); + } + + async issueInvoice(id: string, user: AuthUser, dto: { invoiceNo: string }) { + this.assertDesk(user); + if (!dto.invoiceNo?.trim()) throw new BadRequestException('请填写发票号'); + const row = await this.prisma.invoiceRecord.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('开票记录不存在'); + if (row.status !== INVOICE_STATUS.TO_ISSUE) throw new BadRequestException('当前不是待开状态'); + return this.prisma.invoiceRecord.update({ + where: { id }, + data: { status: INVOICE_STATUS.ISSUED, invoiceNo: dto.invoiceNo.trim(), issuedAt: new Date() }, + }); + } + + async receiveInvoice(id: string, user: AuthUser) { + this.assertDesk(user); + const row = await this.prisma.invoiceRecord.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('开票记录不存在'); + if (row.status !== INVOICE_STATUS.ISSUED && row.status !== INVOICE_STATUS.OVERDUE) { + throw new BadRequestException('当前不能登记回款'); + } + return this.prisma.invoiceRecord.update({ + where: { id }, + data: { status: INVOICE_STATUS.RECEIVED, receivedAt: new Date() }, + }); + } + + async decideApproval(approvalId: string, user: AuthUser, dto: ReviewInput) { + const task = await this.prisma.approvalTask.findFirst({ where: { id: approvalId, tenantId: '1' } }); + if (!task) throw new NotFoundException('审批任务不存在'); + if (task.assigneeId !== user.id) throw new ForbiddenException('这不是你的审批'); + if (task.status !== 'PENDING') throw new BadRequestException('该审批已办理'); + if (!task.bizId) throw new BadRequestException('审批缺少业务单'); + if (task.bizType === FINANCE_BIZ.EXPENSE) return this.reviewExpense(task.bizId, user, dto); + if (task.bizType === FINANCE_BIZ.LOAN) return this.reviewLoan(task.bizId, user, dto); + throw new BadRequestException('不是费用或借款审批'); + } + + async myWorkbench(user: AuthUser) { + this.assertFile(user); + const [expenses, loans] = await Promise.all([ + this.prisma.expenseClaim.findMany({ + // 旧版本曾留下没有任何发票明细的空报销草稿;现行规则不可能提交此类单据, + // 不在个人费用中展示,以免和实际报销重复。 + where: { tenantId: '1', applicantId: user.id, lines: { some: {} } }, + include: { bidCase: { select: BID_SELECT }, project: { select: PROJECT_SELECT }, loan: true }, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.loanRecord.findMany({ + where: { tenantId: '1', applicantId: user.id }, + include: { bidCase: { select: BID_SELECT }, project: { select: PROJECT_SELECT } }, + orderBy: { createdAt: 'desc' }, + }), + ]); + return { + expenses, + loans: loans.map((r) => ({ + ...r, + remaining: loanRemaining(Number(r.amount), Number(r.offsetAmount)), + })), + }; + } + + async assetPurchaseLedger(user: AuthUser) { + this.assertDesk(user); + return this.prisma.officeApply.findMany({ + where: { tenantId: '1', kind: 'ASSET_BUY' }, + include: { + applicant: { select: { id: true, displayName: true, username: true } }, + decidedBy: { select: { id: true, displayName: true, username: true } }, + project: { select: { id: true, projectNo: true, name: true } }, + purchase: { select: { id: true, title: true, amount: true, status: true } }, + inboundAsset: { select: { id: true, code: true, name: true, status: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + private normalizeExpenseAmount(dto: ExpenseInput) { + this.validateLines(dto.lines); + if (dto.lines?.length) { + dto.amount = Math.round(dto.lines.reduce((s, l) => s + Number(l.amount || 0), 0) * 100) / 100; + } + return dto.amount || 0; + } + + private validateLines(lines?: ExpenseLineInput[]) { + if (!lines?.length) return; + lines.forEach((line, i) => { + if (!LINE_KINDS.includes(line.kind as (typeof LINE_KINDS)[number])) { + throw new BadRequestException(`第 ${i + 1} 张发票类别无效`); + } + }); + } + + private lineRows(lines?: ExpenseLineInput[]) { + return (lines || []).map((l, i) => ({ + tenantId: '1', + kind: l.kind, + amount: l.amount, + invoiceNo: l.invoiceNo?.trim() || null, + occurAt: l.occurAt ? new Date(l.occurAt) : null, + remark: l.remark?.trim() || null, + sortNo: i, + })); + } + + private deriveExpenseHeader(dto: ExpenseInput) { + const first = dto.lines?.[0]; + if (!dto.expenseCategory?.trim() && first) { + dto.expenseCategory = LINE_TO_CATEGORY[first.kind] || '其他'; + } + if (!dto.purpose?.trim() && dto.lines?.length) { + dto.purpose = + dto.lines + .map((l) => l.remark?.trim()) + .filter(Boolean) + .join(';') || undefined; + } + } + + private async resolveExpenseBudget(user: AuthUser, dto: ExpenseInput) { + if (dto.costType !== COST_TYPE.PROJECT) { + dto.budgetCategory = dto.budgetCategory || undefined; + return; + } + if (dto.budgetCategory === 'PURCHASE') return; + if (dto.budgetCategory === 'TECH') return; + if (dto.budgetCategory && dto.budgetCategory !== 'TRAVEL' && dto.budgetCategory !== 'BUSINESS') return; + const emp = await this.prisma.user.findFirst({ + where: { id: user.id }, + select: { employee: { select: { department: { select: { deptType: true } } } } }, + }); + const deptType = emp?.employee?.department?.deptType || ''; + dto.budgetCategory = encodeExpenseBudget( + dto.budgetCategory === 'BUSINESS' ? 'TRAVEL' : dto.budgetCategory || 'TRAVEL', + deptType === 'TECH' ? 'TECH' : 'BUSINESS', + ); + } + + private async syncLines(tx: Tx, claimId: string, lines?: ExpenseLineInput[]) { + const incoming = lines || []; + const old = await tx.expenseLine.findMany({ where: { claimId } }); + const keep = new Set(incoming.map((l) => l.id).filter(Boolean) as string[]); + const removed = old.filter((o) => !keep.has(o.id)); + if (removed.length) { + await tx.fileAsset.deleteMany({ + where: { bizType: 'EXPENSE_LINE', bizId: { in: removed.map((r) => r.id) } }, + }); + await tx.expenseLine.deleteMany({ where: { id: { in: removed.map((r) => r.id) } } }); + } + for (const [i, l] of incoming.entries()) { + const data = { + kind: l.kind, + amount: l.amount, + invoiceNo: l.invoiceNo?.trim() || null, + occurAt: l.occurAt ? new Date(l.occurAt) : null, + remark: l.remark?.trim() || null, + sortNo: i, + }; + if (l.id && old.some((o) => o.id === l.id)) { + await tx.expenseLine.update({ where: { id: l.id }, data }); + } else { + await tx.expenseLine.create({ data: { ...data, tenantId: '1', claimId } }); + } + } + } + + private async assertPayer(dto: ExpenseInput, applicantId: string) { + if (!dto.prepaid) return; + if (!dto.payerId) throw new BadRequestException('请选择代付人'); + const payer = await this.prisma.user.findFirst({ + where: { id: dto.payerId, tenantId: '1', status: 'ACTIVE' }, + include: { employee: true }, + }); + if (!payer) throw new BadRequestException('代付人不存在'); + if (payer.id === applicantId) throw new BadRequestException('代付人不能是申请人自己'); + if (!payer.employee?.bankAccount?.trim()) { + throw new BadRequestException('代付人档案没有银行账号,请先在人事补卡号,否则无法打款'); + } + } + + private async validateCost(dto: CostInput, draft = false) { + if (!draft) { + if (!dto.amount || dto.amount <= 0) throw new BadRequestException('请填写金额'); + const err = assertCostObject(dto.costType, dto.bidCaseId, dto.projectId); + if (err) throw new BadRequestException(err); + } + if (dto.costType === COST_TYPE.BID && dto.bidCaseId) { + const bid = await this.prisma.bidCase.findFirst({ where: { id: dto.bidCaseId, tenantId: '1' } }); + if (!bid) throw new NotFoundException('投标事项不存在'); + } + if (dto.costType === COST_TYPE.PROJECT && dto.projectId) { + const project = await this.prisma.project.findFirst({ where: { id: dto.projectId, tenantId: '1' } }); + if (!project) throw new NotFoundException('项目不存在'); + } + } + + private async assertProjectBudget(dto: ExpenseInput, excludeExpenseId?: string) { + if (dto.costType !== COST_TYPE.PROJECT || !dto.projectId) return; + const err = assertExpenseBudgetCategory(dto.costType, dto.budgetCategory); + if (err) throw new BadRequestException(err); + await this.projects.assertBudgetRoom(dto.projectId, dto.budgetCategory!, dto.amount, { + excludeExpenseId, + }); + } + + private async assertLoanForExpense(tx: Tx, user: AuthUser, dto: ExpenseInput, excludeId?: string) { + if (dto.prepaid) return; + if (!dto.amount || dto.amount <= 0) return; + const open = await tx.loanRecord.findMany({ + where: { + tenantId: '1', + applicantId: user.id, + costType: dto.costType, + bidCaseId: dto.costType === COST_TYPE.BID ? dto.bidCaseId || null : null, + projectId: dto.costType === COST_TYPE.PROJECT ? dto.projectId || null : null, + status: { in: [LOAN_STATUS.DRAFT, LOAN_STATUS.PENDING, LOAN_STATUS.OPEN] }, + }, + include: { expenses: true }, + }); + if (!open.length) { + if (dto.loanId) throw new BadRequestException('借款单无效'); + return; + } + if (!dto.loanId) { + throw new BadRequestException('该成本对象有在途或未还借款,请先冲账,避免重复报销'); + } + const loan = open.find((l) => l.id === dto.loanId); + if (!loan) throw new BadRequestException('请选择同一成本对象上的借款冲账'); + if (loan.status !== LOAN_STATUS.OPEN) { + throw new BadRequestException('借款通过后才能冲账'); + } + const held = loan.expenses + .filter((e) => e.id !== excludeId && (e.status === EXPENSE_STATUS.PENDING || e.status === EXPENSE_STATUS.APPROVED)) + .reduce((s, e) => s + Number(e.amount), 0); + const remain = loanRemaining(Number(loan.amount), held); + if (dto.amount - remain > 0.001) { + throw new BadRequestException(`冲账金额不能超过未还 ${remain}`); + } + } + + private async applyOffset(tx: Tx, loanId: string, amount: number, expenseId: string) { + const loan = await tx.loanRecord.findFirst({ + where: { id: loanId }, + include: { expenses: true }, + }); + if (!loan || loan.status !== LOAN_STATUS.OPEN) { + throw new BadRequestException('借款不是未还状态,不能冲账'); + } + const held = loan.expenses + .filter((e) => e.id !== expenseId && (e.status === EXPENSE_STATUS.PENDING || e.status === EXPENSE_STATUS.APPROVED)) + .reduce((s, e) => s + Number(e.amount), 0); + const remain = loanRemaining(Number(loan.amount), held); + if (amount - remain > 0.001) throw new BadRequestException('冲账金额超过未还借款'); + const nextOffset = Number(loan.offsetAmount) + amount; + await tx.loanRecord.update({ + where: { id: loanId }, + data: { + offsetAmount: nextOffset, + status: nextOffset + 0.001 >= Number(loan.amount) ? LOAN_STATUS.OFFSET : LOAN_STATUS.OPEN, + }, + }); + } + + private othersSeeSubmitted(user: AuthUser, kind: 'expense' | 'loan' = 'expense') { + const desk = canSeeSubmittedFinance(user.roles, user.permissions); + if (desk) { + return { status: { not: EXPENSE_STATUS.DRAFT } }; + } + const passed = kind === 'loan' ? { in: [LOAN_STATUS.OPEN, LOAN_STATUS.OFFSET] } : EXPENSE_STATUS.APPROVED; + return { OR: [{ applicantId: user.id }, { status: passed }] }; + } + + private async assertVisibleTo( + user: AuthUser, + row: { id: string; status: string; applicantId: string }, + bizType?: string, + ) { + if (row.applicantId === user.id) return; + if (row.status === EXPENSE_STATUS.DRAFT || row.status === LOAN_STATUS.DRAFT) { + throw new ForbiddenException('草稿只在填写人自己的账号里,提交后才会到确认岗'); + } + const desk = canSeeSubmittedFinance(user.roles, user.permissions); + if ( + (row.status === EXPENSE_STATUS.PENDING || + row.status === EXPENSE_STATUS.REJECTED || + row.status === LOAN_STATUS.PENDING || + row.status === LOAN_STATUS.REJECTED) && + !desk + ) { + if (bizType) { + const task = await this.prisma.approvalTask.findFirst({ + where: { bizType, bizId: row.id, assigneeId: user.id }, + }); + if (task) return; + } + throw new ForbiddenException('尚未通过确认,不能传到下一岗'); + } + } + + private async expenseChainStatus(bizId: string, applicantId: string) { + const chain = await resolveExpenseApproverChain(this.prisma, applicantId); + const tasks = await this.prisma.approvalTask.findMany({ + where: { bizType: FINANCE_BIZ.EXPENSE, bizId }, + orderBy: { createdAt: 'asc' }, + }); + return chain.map((s) => { + const t = tasks.find((x) => x.assigneeId === s.user.id); + let status = 'WAITING'; + if (t?.status === 'APPROVED') status = 'APPROVED'; + else if (t?.status === 'PENDING') status = 'PENDING'; + else if (t?.status === 'REJECTED') status = 'REJECTED'; + return { role: s.role, name: s.user.displayName, userId: s.user.id, status }; + }); + } + + private async financeApprovers(tx: Tx) { + const users = await tx.user.findMany({ + where: { + tenantId: '1', + status: 'ACTIVE', + roles: { some: { role: { code: { in: ['admin', 'finance', 'owner'] } } } }, + }, + }); + return [...new Map(users.map((u) => [u.id, u])).values()]; + } + + private async closeApprovals(tx: Tx, bizType: string, bizId: string, result: 'APPROVED' | 'REJECTED', actorId: string) { + await tx.approvalTask.updateMany({ + where: { bizType, bizId, assigneeId: actorId, status: 'PENDING' }, + data: { status: result }, + }); + await tx.approvalTask.updateMany({ + where: { bizType, bizId, status: 'PENDING' }, + data: { status: 'CANCELLED' }, + }); + } + + private async nextNo(tx: Tx, prefix: 'BX' | 'JK' | 'BZ') { + await lockDocNo(tx, prefix); + const year = new Date().getFullYear(); + const start = `${prefix}-${year}-`; + const existing = + prefix === 'BX' + ? (await tx.expenseClaim.findMany({ where: { tenantId: '1', claimNo: { startsWith: start } }, select: { claimNo: true } })).map( + (r) => r.claimNo, + ) + : prefix === 'JK' + ? (await tx.loanRecord.findMany({ where: { tenantId: '1', loanNo: { startsWith: start } }, select: { loanNo: true } })).map( + (r) => r.loanNo, + ) + : (await tx.bondRecord.findMany({ where: { tenantId: '1', bondNo: { startsWith: start } }, select: { bondNo: true } })).map( + (r) => r.bondNo, + ); + return nextSerial(existing, start); + } + + private async markOverdueInvoices() { + await this.prisma.invoiceRecord.updateMany({ + where: { status: INVOICE_STATUS.ISSUED, dueAt: { lt: new Date() } }, + data: { status: INVOICE_STATUS.OVERDUE }, + }); + } + + private async requireOwnExpense(id: string, user: AuthUser) { + this.assertFile(user); + const row = await this.prisma.expenseClaim.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('报销单不存在'); + if (row.applicantId !== user.id) throw new ForbiddenException('只能改自己的报销'); + return row; + } + + private async requireOwnLoan(id: string, user: AuthUser) { + this.assertFile(user); + const row = await this.prisma.loanRecord.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('借款单不存在'); + if (row.applicantId !== user.id) throw new ForbiddenException('只能改自己的借款'); + return row; + } + + private expenseActions( + row: { status: string; applicantId: string; prepaid?: boolean }, + user: AuthUser, + canReview = false, + approvalStarted = false, + ) { + const own = row.applicantId === user.id; + const draft = row.status === EXPENSE_STATUS.DRAFT || row.status === EXPENSE_STATUS.REJECTED; + const canAmendBeforeApproval = ([EXPENSE_STATUS.DRAFT, EXPENSE_STATUS.REJECTED, EXPENSE_STATUS.PENDING] as string[]).includes(row.status); + return { + canEdit: own && draft, + canSubmit: own && draft, + canDelete: own && draft, + canReview: canReview && row.status === EXPENSE_STATUS.PENDING, + canAttachFiles: own && canAmendBeforeApproval, + canLinkLoan: own && !row.prepaid && !approvalStarted && canAmendBeforeApproval, + }; + } + + private loanActions(row: { status: string; applicantId: string }, user: AuthUser) { + const own = row.applicantId === user.id; + const draft = row.status === LOAN_STATUS.DRAFT || row.status === LOAN_STATUS.REJECTED; + return { + canEdit: own && draft, + canSubmit: own && draft, + canDelete: own && draft, + canReview: canApproveFinance(user.roles) && row.status === LOAN_STATUS.PENDING, + canOffset: own && row.status === LOAN_STATUS.OPEN, + }; + } + + async listPayments(user: AuthUser) { + this.assertDesk(user); + const items = await this.prisma.paymentRecord.findMany({ + include: { createdBy: { select: { id: true, displayName: true } } }, + orderBy: { createdAt: 'desc' }, + }); + return { items: items.map((i) => ({ ...i, amount: Number(i.amount) })), channels: BANK_PAY_CHANNELS }; + } + + async listBankChannels(user: AuthUser) { + this.assertDesk(user); + return { items: BANK_PAY_CHANNELS }; + } + + async createPayment( + user: AuthUser, + dto: { kind: string; refId?: string; amount?: number; remark?: string }, + ) { + this.assertDesk(user); + if (!canApproveFinance(user.roles)) throw new ForbiddenException('无权登记付款'); + let amount = Number(dto.amount || 0); + let remark = dto.remark?.trim() || ''; + let payee: Awaited> | null = null; + if (dto.kind === 'EXPENSE' && dto.refId) { + const exp = await this.prisma.expenseClaim.findFirst({ where: { id: dto.refId } }); + if (!exp) throw new BadRequestException('报销单不存在'); + if (exp.status !== EXPENSE_STATUS.APPROVED) throw new BadRequestException('只能对已通过的报销登记付款'); + amount = amount || Number(exp.amount); + remark = remark || `报销 ${exp.claimNo}`; + payee = await this.resolvePayee(exp.prepaid ? exp.payerId : exp.applicantId, exp.prepaid ? 'PAYER' : 'APPLICANT'); + } else if (dto.kind === 'BOND' && dto.refId) { + const bond = await this.prisma.bondRecord.findFirst({ where: { id: dto.refId } }); + if (!bond) throw new BadRequestException('保证金不存在'); + amount = amount || Number(bond.amount); + remark = remark || `保证金 ${bond.bondNo}`; + } else if (dto.kind === 'PURCHASE' && dto.refId) { + const purchase = await this.prisma.purchaseRequest.findFirst({ where: { id: dto.refId } }); + if (!purchase) throw new BadRequestException('采购申请不存在'); + if (purchase.status !== 'APPROVED') throw new BadRequestException('只能对已通过的采购付款'); + amount = amount || Number(purchase.amount); + remark = remark || `采购 ${purchase.title}`; + } + if (!(amount > 0)) throw new BadRequestException('请填写付款金额'); + const payNo = await this.nextPayNo(this.prisma); + return this.prisma.paymentRecord.create({ + data: { + tenantId: '1', + payNo, + kind: dto.kind, + refId: dto.refId || null, + amount, + remark: remark || null, + createdById: user.id, + status: 'TODO', + payeeUserId: payee?.payeeUserId || null, + payeeName: payee?.payeeName || null, + payeeKind: payee?.payeeKind || null, + bankName: payee?.bankName || null, + bankAccount: payee?.bankAccount || null, + }, + }); + } + + async pay(id: string, user: AuthUser) { + this.assertDesk(user); + if (!canApproveFinance(user.roles)) throw new ForbiddenException('无权打款'); + const row = await this.prisma.paymentRecord.findFirst({ where: { id } }); + if (!row) throw new NotFoundException('付款单不存在'); + if (row.status === 'PAID') throw new BadRequestException('已经打过款'); + if (row.kind === 'EXPENSE' && !row.bankAccount?.trim()) { + throw new BadRequestException('收款人没有银行账号,补齐人事卡号后再打款'); + } + return this.prisma.paymentRecord.update({ + where: { id }, + data: { status: 'PAID', paidAt: new Date(), payChannel: 'MANUAL', bankStatus: 'SUCCEEDED' }, + }); + } + + async dispatchPayment(id: string, user: AuthUser, channel = 'BANK_API') { + this.assertDesk(user); + if (!canApproveFinance(user.roles)) throw new ForbiddenException('无权打款'); + const row = await this.prisma.paymentRecord.findFirst({ where: { id } }); + if (!row) throw new NotFoundException('付款单不存在'); + if (row.status === 'PAID') throw new BadRequestException('已经打过款'); + const result = await dispatchBankPay(channel, { + paymentId: row.id, + payNo: row.payNo, + amount: Number(row.amount), + purpose: row.remark || row.payNo, + payeeName: row.payeeName || '', + bankName: row.bankName || '', + bankAccount: row.bankAccount || '', + }); + return this.prisma.paymentRecord.update({ + where: { id }, + data: { + payChannel: result.channel, + bankStatus: result.ok ? 'SENT' : 'FAILED', + bankTxnNo: result.bankTxnNo || null, + bankPayload: JSON.stringify(result), + }, + }); + } + + receiveBankWebhook() { + throw new BadRequestException('银企直联尚未开通,回调口已预留'); + } + + private presentExpensePeople(row: T, reveal: boolean): T { + const hideEmp = (emp: { bankAccount?: string | null } | null | undefined) => + emp ? { ...emp, bankAccount: reveal ? emp.bankAccount : maskBankAccount(emp.bankAccount) } : emp; + const hideUser = (u: { employee?: { bankAccount?: string | null } | null } | null | undefined) => + u ? { ...u, employee: hideEmp(u.employee) } : u; + return { ...row, applicant: hideUser(row.applicant as never), payer: hideUser(row.payer as never) }; + } + + private expensePayout( + row: { + prepaid: boolean; + loanId?: string | null; + applicant?: { displayName: string; employee?: { name?: string | null; bankName?: string | null; bankAccount?: string | null } | null }; + payer?: { displayName: string; employee?: { name?: string | null; bankName?: string | null; bankAccount?: string | null } | null } | null; + }, + fullAccount: boolean, + ) { + if (!row.prepaid && row.loanId) { + return { kind: 'OFFSET' as const, name: null, bankName: null, bankAccount: null }; + } + const person = row.prepaid ? row.payer : row.applicant; + const bankAccount = person?.employee?.bankAccount || null; + return { + kind: row.prepaid ? ('PAYER' as const) : ('APPLICANT' as const), + name: person?.employee?.name || person?.displayName || null, + bankName: person?.employee?.bankName || null, + bankAccount: fullAccount ? bankAccount : maskBankAccount(bankAccount), + }; + } + + private async resolvePayee(userId: string | null | undefined, kind: 'APPLICANT' | 'PAYER') { + if (!userId) return null; + const user = await this.prisma.user.findFirst({ + where: { id: userId, tenantId: '1' }, + include: { employee: { select: { name: true, bankName: true, bankAccount: true } } }, + }); + if (!user) return null; + return { + payeeUserId: user.id, + payeeKind: kind, + payeeName: user.employee?.name || user.displayName, + bankName: user.employee?.bankName || null, + bankAccount: user.employee?.bankAccount?.replace(/\s/g, '') || null, + }; + } + + private async enqueueExpensePayment( + tx: Tx, + row: { id: string; claimNo: string; amount: unknown; prepaid: boolean; payerId: string | null; applicantId: string }, + actorId: string, + ) { + const existed = await tx.paymentRecord.findFirst({ where: { kind: 'EXPENSE', refId: row.id } }); + if (existed) return; + const payee = await this.resolvePayee(row.prepaid ? row.payerId : row.applicantId, row.prepaid ? 'PAYER' : 'APPLICANT'); + const payNo = await this.nextPayNo(tx); + await tx.paymentRecord.create({ + data: { + tenantId: '1', + payNo, + kind: 'EXPENSE', + refId: row.id, + amount: row.amount as number, + remark: `报销 ${row.claimNo}${row.prepaid ? ' · 代付' : ''}`, + createdById: actorId, + status: 'TODO', + payeeUserId: payee?.payeeUserId || null, + payeeName: payee?.payeeName || null, + payeeKind: payee?.payeeKind || null, + bankName: payee?.bankName || null, + bankAccount: payee?.bankAccount || null, + }, + }); + } + + private async nextPayNo(db: { paymentRecord: { count: (args: { where: { payNo: { startsWith: string } } }) => Promise } }) { + const year = new Date().getFullYear(); + const count = await db.paymentRecord.count({ where: { payNo: { startsWith: `PAY-${year}-` } } }); + return `PAY-${year}-${String(count + 1).padStart(5, '0')}`; + } +} diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts new file mode 100644 index 0000000..c85fc71 --- /dev/null +++ b/apps/api/src/health/health.controller.ts @@ -0,0 +1,58 @@ +import { Controller, Get } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ApiTags } from '@nestjs/swagger'; +import Redis from 'ioredis'; +import { PrismaService } from '../prisma/prisma.service'; +import { SkipThrottle } from '@nestjs/throttler'; +import { Public } from '../common/decorators/auth.decorators'; + +@ApiTags('health') +@SkipThrottle() +@Controller('health') +export class HealthController { + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + ) {} + + @Public() + @Get('live') + live() { + return { status: 'ok' }; + } + + @Public() + @Get('ready') + async ready() { + const checks: Record = {}; + try { + await this.prisma.$queryRaw`SELECT 1`; + checks.postgres = 'up'; + } catch { + checks.postgres = 'down'; + } + const redis = new Redis(this.config.get('REDIS_URL')!, { + maxRetriesPerRequest: 1, + lazyConnect: true, + }); + try { + await redis.connect(); + const pong = await redis.ping(); + checks.redis = pong === 'PONG' ? 'up' : 'down'; + } catch { + checks.redis = 'down'; + } finally { + redis.disconnect(); + } + try { + const host = this.config.get('MINIO_ENDPOINT'); + const port = this.config.get('MINIO_PORT'); + const res = await fetch(`http://${host}:${port}/minio/health/live`); + checks.objectStorage = res.ok ? 'up' : 'down'; + } catch { + checks.objectStorage = 'down'; + } + const ready = Object.values(checks).every((v) => v === 'up'); + return { status: ready ? 'ok' : 'degraded', checks }; + } +} diff --git a/apps/api/src/hr/hr-workflow.spec.ts b/apps/api/src/hr/hr-workflow.spec.ts new file mode 100644 index 0000000..003a4ed --- /dev/null +++ b/apps/api/src/hr/hr-workflow.spec.ts @@ -0,0 +1,109 @@ +import { + calendarDays, + canAdjustPerformance, + canConfirmPayroll, + canManageHr, + canSeeCompanyPayroll, + canViewPayroll, + computePayslip, + housingFund, + incomeTax, + nextStatusForKind, + overtimeToCompHours, + prorationFactor, + scheduledDays, + socialPersonal, + statusPayFactor, + statusPerfFactor, +} from './hr-workflow'; + +describe('hr-workflow', () => { + it('lets hr maintain roster and directors adjust performance', () => { + expect(canManageHr(['hr'], ['hr:org'])).toBe(true); + expect(canManageHr(['biz_director'], [])).toBe(true); + expect(canManageHr(['employee'], ['office'])).toBe(false); + expect(canManageHr(['tech_director'], ['hr:performance'])).toBe(false); + expect(canAdjustPerformance(['tech_director'])).toBe(true); + expect(canAdjustPerformance(['employee'])).toBe(false); + expect(canViewPayroll(['finance'], [])).toBe(true); + expect(canViewPayroll(['biz_staff'], [])).toBe(true); + expect(canSeeCompanyPayroll(['biz_staff'])).toBe(true); + expect(canConfirmPayroll(['biz_director'])).toBe(true); + expect(canConfirmPayroll(['tech_director'])).toBe(false); + expect(canViewPayroll(['employee'], ['office'])).toBe(false); + }); + + it('prorates mid-month hire and uses 单休 / 双休 scheduled days', () => { + expect(calendarDays('2026-08')).toBe(31); + expect(calendarDays('2026-02')).toBe(28); + expect(prorationFactor({ yearMonth: '2026-08', hiredAt: '2026-08-16' })).toBeCloseTo(16 / 31); + expect(prorationFactor({ yearMonth: '2026-08', hiredAt: '2026-07-01' })).toBe(1); + expect(prorationFactor({ yearMonth: '2026-08', leftAt: '2026-08-10' })).toBeCloseTo(10 / 31); + expect(scheduledDays('DOUBLE')).toBe(21.75); + expect(scheduledDays('SINGLE')).toBe(25.75); + const bonusGone = computePayslip({ + baseSalary: 8000, + attendanceBonus: 500, + perfBase: 2000, + socialBase: 8000, + restSchedule: 'DOUBLE', + employmentStatus: 'REGULAR', + yearMonth: '2026-08', + leaveDays: 1, + performanceRatio: 120, + }); + expect(bonusGone.performanceRatio).toBe(120); + expect(bonusGone.leaveDeduct).toBeGreaterThan(0); + expect(statusPayFactor('INTERN')).toBe(0.8); + expect(statusPerfFactor('INTERN')).toBe(0); + expect(statusPerfFactor('PROBATION')).toBe(0.5); + expect(nextStatusForKind('REGULARIZE', 'PROBATION')).toBe('REGULAR'); + expect(nextStatusForKind('LEAVE', 'REGULAR')).toBe('LEFT'); + }); + + it('converts overtime to 调休, deducts leave, withholds social tax and never housing fund', () => { + expect(overtimeToCompHours(8)).toBe(8); + expect(housingFund(10000)).toBe(0); + expect(socialPersonal(10000, 1)).toBe(1050); + expect(incomeTax(1000)).toBe(30); + expect(incomeTax(0)).toBe(0); + + const slip = computePayslip({ + baseSalary: 10000, + perfBase: 3000, + socialBase: 10000, + restSchedule: 'SINGLE', + employmentStatus: 'REGULAR', + hiredAt: '2026-08-16', + yearMonth: '2026-08', + leaveDays: 1, + overtimeHours: 8, + performanceRatio: 100, + prevCompLeaveHours: 0, + }); + expect(slip.housingFund).toBe(0); + expect(slip.compLeaveHours).toBe(8); + expect(slip.leaveDeduct).toBeGreaterThan(0); + expect(slip.socialPersonal).toBeGreaterThan(0); + expect(slip.tax).toBeGreaterThan(0); + expect(slip.grossPay).toBeGreaterThan(slip.netPay); + expect(slip.performancePay).toBeGreaterThan(0); + + const cut = computePayslip({ + baseSalary: 10000, + perfBase: 3000, + socialBase: 10000, + restSchedule: 'SINGLE', + employmentStatus: 'REGULAR', + hiredAt: '2026-08-16', + yearMonth: '2026-08', + leaveDays: 1, + overtimeHours: 8, + performanceRatio: 80, + prevCompLeaveHours: 0, + }); + expect(cut.performancePay).toBeLessThan(slip.performancePay); + expect(cut.grossPay).toBeLessThan(slip.grossPay); + expect(cut.housingFund).toBe(0); + }); +}); diff --git a/apps/api/src/hr/hr-workflow.ts b/apps/api/src/hr/hr-workflow.ts new file mode 100644 index 0000000..f82f493 --- /dev/null +++ b/apps/api/src/hr/hr-workflow.ts @@ -0,0 +1,429 @@ +import { isTechLead } from '../common/role-groups'; + +export const REST = { + SINGLE: 'SINGLE', + DOUBLE: 'DOUBLE', +} as const; + +export const EMP_STATUS = { + INTERN: 'INTERN', + PROBATION: 'PROBATION', + REGULAR: 'REGULAR', + LEFT: 'LEFT', +} as const; + +export const HR_BIZ = { + LEAVE: 'HR_LEAVE', +} as const; + +export const LEAVE_KIND = { + STATUTORY: 'STATUTORY', + ANNUAL: 'ANNUAL', + SICK: 'SICK', + WORK_INJURY: 'WORK_INJURY', + MATERNITY: 'MATERNITY', + PRENATAL: 'PRENATAL', + BREASTFEEDING: 'BREASTFEEDING', + PATERNITY: 'PATERNITY', + PARENTAL: 'PARENTAL', + ONLY_CHILD_CARE: 'ONLY_CHILD_CARE', + MARRIAGE: 'MARRIAGE', + BEREAVEMENT: 'BEREAVEMENT', + PERSONAL: 'PERSONAL', + COMP: 'COMP', +} as const; + +export const HR_APPLY_KIND = { + BUSINESS: 'BUSINESS', + OUT: 'OUT', + OVERTIME: 'OVERTIME', + MAKEUP: 'MAKEUP', + REGULARIZE: 'REGULARIZE', + PROMOTE: 'PROMOTE', + TRANSFER: 'TRANSFER', + RESIGN: 'RESIGN', +} as const; + +export const HR_APPLY_LABEL: Record = { + STATUTORY: '法定节假日', + ANNUAL: '带薪年休假', + SICK: '病假', + WORK_INJURY: '工伤假', + MATERNITY: '产假', + PRENATAL: '产前检查假', + BREASTFEEDING: '哺乳假', + PATERNITY: '陪产假', + PARENTAL: '育儿假', + ONLY_CHILD_CARE: '独生子女护理假', + MARRIAGE: '婚假', + BEREAVEMENT: '丧假', + PERSONAL: '事假', + COMP: '调休', + BUSINESS: '出差', + OUT: '外出', + OVERTIME: '加班', + MAKEUP: '补卡', + REGULARIZE: '转正', + PROMOTE: '晋升', + TRANSFER: '转岗', + RESIGN: '离职', +}; + +export function isLeaveKind(kind: string) { + return (Object.values(LEAVE_KIND) as string[]).includes(kind); +} + +export function leavePayBucket(kind: string): 'unpaid' | 'sick' | 'comp' | 'paid' | null { + if (kind === LEAVE_KIND.PERSONAL) return 'unpaid'; + if (kind === LEAVE_KIND.SICK) return 'sick'; + if (kind === LEAVE_KIND.COMP) return 'comp'; + if (isLeaveKind(kind)) return 'paid'; + return null; +} + +export function isHrApplyKind(kind: string) { + return isLeaveKind(kind) || (Object.values(HR_APPLY_KIND) as string[]).includes(kind); +} + +export const LEAVE_STATUS = { + PENDING: 'PENDING', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED', +} as const; + +export const PUNCH_KIND = { + IN: 'IN', + OUT: 'OUT', +} as const; + +export const LIFE_KIND = { + HIRE: 'HIRE', + INTERN: 'INTERN', + PROBATION: 'PROBATION', + REGULARIZE: 'REGULARIZE', + TRANSFER: 'TRANSFER', + PROMOTE: 'PROMOTE', + LEAVE: 'LEAVE', +} as const; + +export const PAYROLL_STATUS = { + DRAFT: 'DRAFT', + CONFIRMED: 'CONFIRMED', +} as const; + +export const DEPT_TYPES = ['ADMIN', 'BUSINESS', 'TECH', 'FINANCE', 'HR'] as const; + +export const SCHEDULED_DAYS = { + DOUBLE: 21.75, + SINGLE: 25.75, +} as const; + +export const SOCIAL_RATE = 0.105; +export const HOUSING_FUND_RATE = 0; +export const TAX_THRESHOLD = 5000; +export const HOURS_PER_DAY = 8; +export const SICK_PAY_RATIO = 0.6; +export const INTERN_PAY_FACTOR = 0.8; +export const PROBATION_PERF_FACTOR = 0.5; + +const TAX_BRACKETS = [ + { max: 3000, rate: 0.03, deduct: 0 }, + { max: 12000, rate: 0.1, deduct: 210 }, + { max: 25000, rate: 0.2, deduct: 1410 }, + { max: 35000, rate: 0.25, deduct: 2660 }, + { max: 55000, rate: 0.3, deduct: 4410 }, + { max: 80000, rate: 0.35, deduct: 7160 }, + { max: Infinity, rate: 0.45, deduct: 15160 }, +]; + +export function roundMoney(n: number) { + return Math.round((n + Number.EPSILON) * 100) / 100; +} + +export function canAccessHr(roles: string[], permissions: string[]) { + if (roles.includes('admin')) return true; + return permissions.some((p) => p === 'hr' || p.startsWith('hr:')); +} + +export function canManageHr(roles: string[], permissions: string[]) { + if (['admin', 'owner', 'hr', 'biz_director'].some((r) => roles.includes(r))) return true; + return canAccessHr(roles, permissions) && (roles.includes('hr') || roles.includes('biz_director') || roles.includes('biz_staff')); +} + +export function canAdjustPerformance(roles: string[]) { + return ['admin', 'owner', 'hr', 'biz_director'].some((r) => roles.includes(r)) || isTechLead(roles); +} + +/** 商务部兼人事/财务,看全公司工资条;总监只看本部门。 */ +export function canSeeCompanyPayroll(roles: string[]) { + return ['admin', 'owner', 'hr', 'finance', 'biz_director', 'biz_staff'].some((r) => roles.includes(r)); +} + +export function canConfirmPayroll(roles: string[]) { + return ['admin', 'owner', 'hr', 'biz_director'].some((r) => roles.includes(r)); +} + +export function canViewPayroll(roles: string[], permissions: string[]) { + if (canAdjustPerformance(roles) || canSeeCompanyPayroll(roles)) return true; + return canAccessHr(roles, permissions); +} + +export function assertYearMonth(ym?: string) { + if (!ym || !/^\d{4}-(0[1-9]|1[0-2])$/.test(ym)) { + throw new Error('请填写年月,格式 YYYY-MM'); + } + return ym; +} + +export function parseYearMonth(ym: string) { + const value = assertYearMonth(ym); + const [year, month] = value.split('-').map(Number); + return { year, month }; +} + +export function calendarDays(yearMonth: string) { + const { year, month } = parseYearMonth(yearMonth); + return new Date(year, month, 0).getDate(); +} + +export function currentYearMonth(now = new Date()) { + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; +} + +function asDate(value?: Date | string | null) { + if (!value) return null; + const d = value instanceof Date ? value : new Date(value); + if (Number.isNaN(d.getTime())) return null; + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} + +export function prorationFactor(opts: { + hiredAt?: Date | string | null; + leftAt?: Date | string | null; + yearMonth: string; +}) { + const days = calendarDays(opts.yearMonth); + const { year, month } = parseYearMonth(opts.yearMonth); + const monthStart = new Date(year, month - 1, 1); + const monthEnd = new Date(year, month - 1, days); + const hired = asDate(opts.hiredAt); + const left = asDate(opts.leftAt); + let from = monthStart; + let to = monthEnd; + if (hired) { + if (hired > monthEnd) return 0; + if (hired > monthStart) from = hired; + } + if (left) { + if (left < monthStart) return 0; + if (left < monthEnd) to = left; + } + if (to < from) return 0; + const onPayroll = Math.round((to.getTime() - from.getTime()) / 86400000) + 1; + return Math.min(1, Math.max(0, onPayroll / days)); +} + +export function scheduledDays(rest?: string) { + return rest === REST.DOUBLE ? SCHEDULED_DAYS.DOUBLE : SCHEDULED_DAYS.SINGLE; +} + +export function statusPayFactor(status?: string) { + if (status === EMP_STATUS.INTERN) return INTERN_PAY_FACTOR; + return 1; +} + +export function statusPerfFactor(status?: string) { + if (status === EMP_STATUS.INTERN) return 0; + if (status === EMP_STATUS.PROBATION) return PROBATION_PERF_FACTOR; + return 1; +} + +export function dailyRate(amount: number, rest?: string) { + return roundMoney(amount / scheduledDays(rest)); +} + +export function wagePackage(input: { + baseSalary?: number; + postSalary?: number; + phoneSubsidy?: number; + transportSubsidy?: number; + mealSubsidy?: number; +}) { + return roundMoney( + (input.baseSalary || 0) + + (input.postSalary || 0) + + (input.phoneSubsidy || 0) + + (input.transportSubsidy || 0) + + (input.mealSubsidy || 0), + ); +} + +export function internalHourRate(input: { + baseSalary?: number; + postSalary?: number; + phoneSubsidy?: number; + transportSubsidy?: number; + mealSubsidy?: number; + perfBase?: number; + restSchedule?: string; +}) { + const days = scheduledDays(input.restSchedule); + return roundMoney((wagePackage(input) + (input.perfBase || 0)) / days / HOURS_PER_DAY); +} + +export function socialPersonal(socialBase: number, factor = 1) { + return roundMoney(Math.max(0, socialBase) * SOCIAL_RATE * factor); +} + +export function housingFund(_socialBase?: number) { + return 0 * HOUSING_FUND_RATE; +} + +export function incomeTax(taxable: number) { + if (taxable <= 0) return 0; + const bracket = TAX_BRACKETS.find((b) => taxable <= b.max) ?? TAX_BRACKETS[TAX_BRACKETS.length - 1]; + return roundMoney(taxable * bracket.rate - bracket.deduct); +} + +export function overtimeToCompHours(overtimeHours: number) { + return roundMoney(Math.max(0, overtimeHours)); +} + +export function nextStatusForKind(kind: string, current: string) { + if (kind === LIFE_KIND.INTERN) return EMP_STATUS.INTERN; + if (kind === LIFE_KIND.PROBATION) return EMP_STATUS.PROBATION; + if (kind === LIFE_KIND.REGULARIZE) return EMP_STATUS.REGULAR; + if (kind === LIFE_KIND.LEAVE) return EMP_STATUS.LEFT; + if (kind === LIFE_KIND.HIRE) return current || EMP_STATUS.PROBATION; + if (kind === LIFE_KIND.TRANSFER || kind === LIFE_KIND.PROMOTE) return current; + return null; +} + +export type PayslipInput = { + baseSalary: number; + postSalary?: number; + attendanceBonus?: number; + phoneSubsidy?: number; + transportSubsidy?: number; + mealSubsidy?: number; + probationSalary?: number; + perfBase: number; + socialBase: number; + restSchedule: string; + employmentStatus: string; + hiredAt?: Date | string | null; + leftAt?: Date | string | null; + regularizeAt?: Date | string | null; + yearMonth: string; + leaveDays?: number; + sickDays?: number; + overtimeHours?: number; + compLeaveDays?: number; + performanceRatio?: number; + prevCompLeaveHours?: number; +}; + +function packageForStatus(input: PayslipInput, status: string) { + const regular = wagePackage(input); + if (status === EMP_STATUS.INTERN || status === EMP_STATUS.PROBATION) { + if (input.probationSalary && input.probationSalary > 0) return input.probationSalary; + return roundMoney(regular * statusPayFactor(status)); + } + return regular; +} + +export function computePayslip(input: PayslipInput) { + const factor = prorationFactor(input); + const rest = input.restSchedule === REST.DOUBLE ? REST.DOUBLE : REST.SINGLE; + const divisor = scheduledDays(rest); + const leaveDays = Math.max(0, Number(input.leaveDays ?? 0)); + const sickDays = Math.max(0, Number(input.sickDays ?? 0)); + const hasLeave = leaveDays + sickDays > 0; + const overtimeHours = overtimeToCompHours(Number(input.overtimeHours ?? 0)); + const requestedComp = Math.max(0, Number(input.compLeaveDays ?? 0)); + const prev = Math.max(0, Number(input.prevCompLeaveHours ?? 0)); + const maxCompDays = (prev + overtimeHours) / HOURS_PER_DAY; + const usedCompDays = roundMoney(Math.min(requestedComp, maxCompDays)); + const usedHours = roundMoney(usedCompDays * HOURS_PER_DAY); + const nextBalance = roundMoney(prev + overtimeHours - usedHours); + const ratioPct = Math.min(200, Math.max(0, Number(input.performanceRatio ?? 100))); + const attendancePay = hasLeave ? 0 : roundMoney((input.attendanceBonus || 0) * factor); + + const { year, month } = parseYearMonth(input.yearMonth); + const monthStart = new Date(year, month - 1, 1); + const monthEnd = new Date(year, month - 1, calendarDays(input.yearMonth)); + const regularize = asDate(input.regularizeAt); + const inThisMonth = + regularize && regularize >= monthStart && regularize <= monthEnd && regularize > monthStart; + + const perfOf = (status: string) => + roundMoney(input.perfBase * statusPerfFactor(status) * (ratioPct / 100)); + + let basePay: number; + let performancePay: number; + let daily: number; + let leaveDeduct: number; + let sickDeduct: number; + + if (inThisMonth && regularize) { + const internEnd = new Date(regularize); + internEnd.setDate(internEnd.getDate() - 1); + const internDays = Math.max(0, Math.round((internEnd.getTime() - monthStart.getTime()) / 86400000) + 1); + const regularDays = Math.max(0, Math.round((monthEnd.getTime() - regularize.getTime()) / 86400000) + 1); + const total = internDays + regularDays || 1; + const internLeave = roundMoney(leaveDays * (internDays / total)); + const regularLeave = roundMoney(leaveDays - internLeave); + const internSick = roundMoney(sickDays * (internDays / total)); + const regularSick = roundMoney(sickDays - internSick); + const internPkg = packageForStatus(input, EMP_STATUS.PROBATION); + const regularPkg = packageForStatus(input, EMP_STATUS.REGULAR); + const internDaily = dailyRate(internPkg + perfOf(EMP_STATUS.PROBATION), rest); + const regularDaily = dailyRate(regularPkg + perfOf(EMP_STATUS.REGULAR), rest); + daily = roundMoney((internDaily * internDays + regularDaily * regularDays) / total); + leaveDeduct = roundMoney(internDaily * internLeave + regularDaily * regularLeave); + sickDeduct = roundMoney( + internDaily * internSick * (1 - SICK_PAY_RATIO) + regularDaily * regularSick * (1 - SICK_PAY_RATIO), + ); + const internAttend = Math.max(0, internDays - internLeave - internSick); + const regularAttend = Math.max(0, regularDays - regularLeave - regularSick); + performancePay = roundMoney( + perfOf(EMP_STATUS.PROBATION) * (internAttend / divisor) + perfOf(EMP_STATUS.REGULAR) * (regularAttend / divisor), + ); + basePay = roundMoney( + internPkg * (internAttend / divisor) + regularPkg * (regularAttend / divisor) + attendancePay, + ); + } else { + const pkg = packageForStatus(input, input.employmentStatus); + const perf = perfOf(input.employmentStatus); + daily = dailyRate(pkg + perf, rest); + leaveDeduct = roundMoney(daily * leaveDays); + sickDeduct = roundMoney(daily * sickDays * (1 - SICK_PAY_RATIO)); + basePay = roundMoney(Math.max(0, pkg * factor - leaveDeduct - sickDeduct) + attendancePay); + performancePay = roundMoney(perf * factor); + } + + const grossPay = roundMoney(basePay + performancePay); + const social = socialPersonal(input.socialBase, factor); + const housing = housingFund(input.socialBase); + const taxable = roundMoney(Math.max(0, grossPay - social - housing - TAX_THRESHOLD)); + const tax = incomeTax(taxable); + const netPay = roundMoney(grossPay - social - housing - tax); + return { + prorationFactor: factor, + scheduledDays: divisor, + dailyRate: daily, + basePay, + leaveDeduct, + sickDeduct, + performancePay, + performanceRatio: roundMoney(ratioPct), + grossPay, + socialPersonal: social, + housingFund: housing, + tax, + netPay, + overtimeHours, + compLeaveUsedDays: usedCompDays, + compLeaveHours: nextBalance, + }; +} diff --git a/apps/api/src/hr/hr.controller.ts b/apps/api/src/hr/hr.controller.ts new file mode 100644 index 0000000..57450f9 --- /dev/null +++ b/apps/api/src/hr/hr.controller.ts @@ -0,0 +1,461 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser, type AuthUser } from '../common/decorators/current-user.decorator'; +import { HrService } from './hr.service'; + +@ApiTags('hr') +@ApiBearerAuth() +@Controller() +export class HrController { + constructor(private readonly hr: HrService) {} + + @Get('departments') + departments() { + return this.hr.listDepartments(); + } + + @Post('departments') + createDept( + @CurrentUser() user: AuthUser, + @Body() + body: { name: string; code: string; deptType: string; parentId?: string; categoryCode?: string; sortNo?: number }, + ) { + return this.hr.createDepartment(user, body); + } + + @Patch('departments/:id') + updateDept( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() + body: { name?: string; code?: string; deptType?: string; parentId?: string | null; categoryCode?: string; sortNo?: number }, + ) { + return this.hr.updateDepartment(id, user, body); + } + + @Delete('departments/:id') + deleteDept(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.hr.deleteDepartment(id, user); + } + + @Get('hr-settings') + hrSettings() { + return this.hr.getSettings(); + } + + @Patch('hr-settings') + updateHrSettings( + @CurrentUser() user: AuthUser, + @Body() + body: { + idMask?: boolean; + autoAccount?: boolean; + probationMonths?: number; + defaultRest?: string; + workStart?: string; + workEnd?: string; + }, + ) { + return this.hr.updateSettings(user, body); + } + + @Get('attendance-rules') + attendanceRules() { + return this.hr.listAttendanceRules(); + } + + @Post('attendance-rules') + createAttendanceRule( + @CurrentUser() user: AuthUser, + @Body() + body: { + name: string; + restSchedule?: string; + workStart: string; + workEnd: string; + lateGraceMinutes?: number; + punchPlace?: string | null; + punchAddress?: string | null; + punchLat?: number | null; + punchLng?: number | null; + punchRadiusMeters?: number; + enabled?: boolean; + sortNo?: number; + remark?: string | null; + }, + ) { + return this.hr.saveAttendanceRule(user, undefined, body); + } + + @Patch('attendance-rules/:id') + updateAttendanceRule( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() + body: { + name: string; + restSchedule?: string; + workStart: string; + workEnd: string; + lateGraceMinutes?: number; + punchPlace?: string | null; + punchAddress?: string | null; + punchLat?: number | null; + punchLng?: number | null; + punchRadiusMeters?: number; + enabled?: boolean; + sortNo?: number; + remark?: string | null; + }, + ) { + return this.hr.saveAttendanceRule(user, id, body); + } + + @Delete('attendance-rules/:id') + deleteAttendanceRule(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.hr.deleteAttendanceRule(id, user); + } + + @Get('employees') + employees(@CurrentUser() user: AuthUser) { + return this.hr.listEmployees(user); + } + + @Get('employees/:id') + employee(@Param('id') id: string) { + return this.hr.getEmployee(id); + } + + @Delete('employees/:id') + deleteEmp(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.hr.deleteEmployee(id, user); + } + + @Post('employees') + createEmp( + @CurrentUser() user: AuthUser, + @Body() + body: { + name: string; + employeeNo?: string; + departmentId: string; + title?: string; + restSchedule?: string; + employmentStatus?: string; + hiredAt?: string; + internStartAt?: string; + probationEndAt?: string; + managerId?: string | string[] | null; + gender?: string; + email?: string; + mobile?: string; + idNo?: string; + idType?: string; + education?: string; + birthday?: string; + ethnicity?: string; + maritalStatus?: string; + hukouAddress?: string; + liveAddress?: string; + emergencyName?: string; + emergencyPhone?: string; + idIssuer?: string; + idValidFrom?: string; + idValidTo?: string; + personCategory?: string; + ccTo?: string; + shortMobile?: string; + workMobile?: string; + school?: string; + major?: string; + graduatedAt?: string; + professionalTitle?: string; + certificates?: string; + companyName?: string; + jobRole?: string; + jobLevel?: string; + attendGroup?: string; + probationMonths?: number; + contractStartAt?: string; + contractEndAt?: string; + recruitSource?: string; + landline?: string; + landlineExt?: string; + groupShortNo?: string; + socialNo?: string; + housingFundNo?: string; + familyNote?: string; + jobType?: string; + ccDaily?: string; + ccWeekly?: string; + ccMonthly?: string; + bankName?: string; + bankAccount?: string; + remark?: string; + baseSalary?: number; + postSalary?: number; + attendanceBonus?: number; + phoneSubsidy?: number; + transportSubsidy?: number; + mealSubsidy?: number; + probationSalary?: number; + perfBase?: number; + socialBase?: number; + }, + ) { + return this.hr.createEmployee(user, body); + } + + @Patch('employees/:id') + updateEmp( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() + body: { + name?: string; + departmentId?: string; + title?: string; + restSchedule?: string; + managerId?: string | string[] | null; + baseSalary?: number; + postSalary?: number; + attendanceBonus?: number; + phoneSubsidy?: number; + transportSubsidy?: number; + mealSubsidy?: number; + probationSalary?: number; + perfBase?: number; + socialBase?: number; + internStartAt?: string | null; + probationEndAt?: string | null; + gender?: string | null; + email?: string | null; + mobile?: string | null; + idNo?: string | null; + idType?: string | null; + education?: string | null; + birthday?: string | null; + ethnicity?: string | null; + maritalStatus?: string | null; + hukouAddress?: string | null; + liveAddress?: string | null; + emergencyName?: string | null; + emergencyPhone?: string | null; + idIssuer?: string | null; + idValidFrom?: string | null; + idValidTo?: string | null; + personCategory?: string | null; + ccTo?: string | null; + shortMobile?: string | null; + workMobile?: string | null; + school?: string | null; + major?: string | null; + graduatedAt?: string | null; + professionalTitle?: string | null; + certificates?: string | null; + companyName?: string | null; + jobRole?: string | null; + jobLevel?: string | null; + attendGroup?: string | null; + probationMonths?: number | null; + contractStartAt?: string | null; + contractEndAt?: string | null; + recruitSource?: string | null; + landline?: string | null; + landlineExt?: string | null; + groupShortNo?: string | null; + socialNo?: string | null; + housingFundNo?: string | null; + familyNote?: string | null; + jobType?: string | null; + ccDaily?: string | null; + ccWeekly?: string | null; + ccMonthly?: string | null; + bankName?: string | null; + bankAccount?: string | null; + remark?: string | null; + }, + ) { + return this.hr.updateEmployee(id, user, body); + } + + @Get('employment-events') + events(@CurrentUser() user: AuthUser, @Query('employeeId') employeeId?: string) { + return this.hr.listEvents(user, employeeId); + } + + @Post('employment-events') + createEvent( + @CurrentUser() user: AuthUser, + @Body() body: { employeeId: string; kind: string; effectiveAt?: string; toDepartmentId?: string; comment?: string }, + ) { + return this.hr.createEvent(user, body); + } + + @Get('attendance') + attendance(@CurrentUser() user: AuthUser, @Query('yearMonth') yearMonth?: string) { + return this.hr.listAttendance(user, yearMonth); + } + + @Post('attendance') + upsertAttendance( + @CurrentUser() user: AuthUser, + @Body() + body: { + employeeId: string; + yearMonth: string; + workDays?: number; + leaveDays?: number; + sickDays?: number; + overtimeHours?: number; + compLeaveDays?: number; + remark?: string; + }, + ) { + return this.hr.upsertAttendance(user, body); + } + + @Post('attendance/import') + importAttendance( + @CurrentUser() user: AuthUser, + @Body() + body: { + yearMonth: string; + items: { + employeeId?: string; + employeeNo?: string; + workDays?: number; + leaveDays?: number; + sickDays?: number; + overtimeHours?: number; + compLeaveDays?: number; + remark?: string; + }[]; + }, + ) { + return this.hr.importAttendance(user, body); + } + + @Post('attendance/punch') + punch(@CurrentUser() user: AuthUser, @Body() body: { kind?: string }) { + return this.hr.punch(user, body.kind || 'IN'); + } + + @Get('attendance/punches/mine') + myPunches(@CurrentUser() user: AuthUser, @Query('date') date?: string) { + return this.hr.myPunches(user, date); + } + + @Get('attendance/punches') + punches(@CurrentUser() user: AuthUser, @Query('yearMonth') yearMonth?: string) { + return this.hr.listPunches(user, yearMonth); + } + + @Get('attendance/comp-balance') + compBalance(@CurrentUser() user: AuthUser) { + return this.hr.compBalance(user); + } + + @Get('leave-requests') + leaves(@CurrentUser() user: AuthUser, @Query('bucket') bucket?: string) { + return this.hr.listLeaves(user, bucket); + } + + @Get('leave-requests/:id') + leaveOne(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.hr.getLeave(id, user); + } + + @Post('leave-requests') + createLeave( + @CurrentUser() user: AuthUser, + @Body() + body: { + kind: string; + startAt?: string; + endAt?: string; + days?: number; + reason: string; + toDeptId?: string; + targetTitle?: string; + punchKind?: string; + }, + ) { + return this.hr.createLeave(user, body); + } + + @Post('leave-requests/:id/review') + reviewLeave( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { result: 'APPROVED' | 'REJECTED'; comment?: string }, + ) { + return this.hr.reviewLeave(id, user, body); + } + + @Delete('leave-requests/:id') + deleteLeave(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.hr.deleteLeave(id, user); + } + + @Get('payroll') + payroll(@CurrentUser() user: AuthUser, @Query('yearMonth') yearMonth?: string) { + return this.hr.getPayroll(user, yearMonth); + } + + @Post('payroll/run') + runPayroll(@CurrentUser() user: AuthUser, @Body() body: { yearMonth?: string }) { + return this.hr.runPayroll(user, body); + } + + @Post('payroll/:id/confirm') + confirm(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.hr.confirmPayroll(id, user); + } + + @Post('payroll/:id/unconfirm') + unconfirm(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.hr.unconfirmPayroll(id, user); + } + + @Patch('payroll-items/:id') + adjust( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { performanceRatio: number }, + ) { + return this.hr.adjustPerformance(id, user, body); + } + + @Get('labor-contracts') + laborContracts(@CurrentUser() user: AuthUser, @Query('keyword') keyword?: string) { + return this.hr.listLaborContracts(user, keyword); + } + + @Get('labor-contracts/:id') + laborContract(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.hr.getLaborContract(id, user); + } + + @Post('labor-contracts') + createLabor( + @CurrentUser() user: AuthUser, + @Body() body: { employeeId: string; contractNo?: string; startAt?: string; endAt?: string; remark?: string }, + ) { + return this.hr.createLaborContract(user, body); + } + + @Patch('labor-contracts/:id') + updateLabor( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { contractNo?: string; startAt?: string | null; endAt?: string | null; remark?: string }, + ) { + return this.hr.updateLaborContract(id, user, body); + } + + @Delete('labor-contracts/:id') + deleteLabor(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.hr.deleteLaborContract(id, user); + } +} diff --git a/apps/api/src/hr/hr.service.ts b/apps/api/src/hr/hr.service.ts new file mode 100644 index 0000000..8b73377 --- /dev/null +++ b/apps/api/src/hr/hr.service.ts @@ -0,0 +1,1997 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { descendantDeptIds, effectiveScope } from '../common/data-scope'; +import { ancestorDeptIds } from '../common/org-chain'; +import { nextExpenseClaimNo } from '../common/doc-no'; +import { emitSms } from '../sms/sms-bus'; +import { canAccessContract } from '../contract/contract-workflow'; +import { + DEPT_TYPES, + EMP_STATUS, + HR_APPLY_KIND, + HR_APPLY_LABEL, + HR_BIZ, + isHrApplyKind, + isLeaveKind, + leavePayBucket, + LEAVE_KIND, + LEAVE_STATUS, + LIFE_KIND, + PAYROLL_STATUS, + PUNCH_KIND, + REST, + assertYearMonth, + canAdjustPerformance, + canConfirmPayroll, + canSeeCompanyPayroll, + canManageHr, + canViewPayroll, + computePayslip, + currentYearMonth, + HOURS_PER_DAY, + nextStatusForKind, + prorationFactor, +} from './hr-workflow'; + +const EMP_INCLUDE = { + department: true, + manager: { select: { id: true, name: true, employeeNo: true } }, + users: { + select: { + id: true, + username: true, + status: true, + roles: { select: { role: { select: { code: true } } } }, + }, + }, +} satisfies Prisma.EmployeeInclude; + +type Tx = Prisma.TransactionClient; + +type ArchiveFields = { + gender?: string | null; + email?: string | null; + mobile?: string | null; + idNo?: string | null; + idType?: string | null; + education?: string | null; + birthday?: string | null; + ethnicity?: string | null; + maritalStatus?: string | null; + hukouAddress?: string | null; + liveAddress?: string | null; + emergencyName?: string | null; + emergencyPhone?: string | null; + idIssuer?: string | null; + idValidFrom?: string | null; + idValidTo?: string | null; + personCategory?: string | null; + ccTo?: string | null; + shortMobile?: string | null; + workMobile?: string | null; + school?: string | null; + major?: string | null; + graduatedAt?: string | null; + professionalTitle?: string | null; + certificates?: string | null; + companyName?: string | null; + jobRole?: string | null; + jobLevel?: string | null; + attendGroup?: string | null; + probationMonths?: number | null; + contractStartAt?: string | null; + contractEndAt?: string | null; + recruitSource?: string | null; + landline?: string | null; + landlineExt?: string | null; + groupShortNo?: string | null; + socialNo?: string | null; + housingFundNo?: string | null; + familyNote?: string | null; + jobType?: string | null; + ccDaily?: string | string[] | null; + ccWeekly?: string | string[] | null; + ccMonthly?: string | string[] | null; + bankName?: string | null; + bankAccount?: string | null; + remark?: string | null; +}; + +const ARCHIVE_TEXT = [ + 'ethnicity', + 'maritalStatus', + 'hukouAddress', + 'liveAddress', + 'emergencyName', + 'emergencyPhone', + 'idIssuer', + 'idType', + 'personCategory', + 'ccTo', + 'shortMobile', + 'workMobile', + 'school', + 'major', + 'professionalTitle', + 'certificates', + 'companyName', + 'jobRole', + 'jobLevel', + 'attendGroup', + 'recruitSource', + 'landline', + 'landlineExt', + 'groupShortNo', + 'socialNo', + 'housingFundNo', + 'familyNote', + 'jobType', + 'bankName', + 'bankAccount', + 'remark', + 'email', + 'mobile', + 'idNo', +] as const; + +const ARCHIVE_DATE = ['birthday', 'idValidFrom', 'idValidTo', 'graduatedAt', 'contractStartAt', 'contractEndAt'] as const; + +function archiveData(dto: ArchiveFields, mode: 'create' | 'update') { + const out: Record = {}; + for (const key of ARCHIVE_TEXT) { + if (mode === 'create') out[key] = cleanText(dto[key]) ?? null; + else if (dto[key] !== undefined) out[key] = cleanText(dto[key]); + } + for (const key of ARCHIVE_DATE) { + if (mode === 'create') out[key] = parseDate(dto[key]); + else if (dto[key] !== undefined) out[key] = parseDate(dto[key]); + } + if (mode === 'create') out.probationMonths = dto.probationMonths ?? null; + else if (dto.probationMonths !== undefined) out.probationMonths = dto.probationMonths; + for (const key of ['ccDaily', 'ccWeekly', 'ccMonthly'] as const) { + if (mode === 'create') out[key] = joinPeopleIds(dto[key]) ?? null; + else if (dto[key] !== undefined) out[key] = joinPeopleIds(dto[key]); + } + return out; +} + +const GENDERS = ['MALE', 'FEMALE'] as const; +const EDUCATIONS = ['HIGH_SCHOOL', 'COLLEGE', 'BACHELOR', 'MASTER', 'DOCTOR'] as const; +const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; + +type EmpDto = { + name: string; + employeeNo?: string; + departmentId: string; + title?: string; + restSchedule?: string; + employmentStatus?: string; + hiredAt?: string; + internStartAt?: string; + probationEndAt?: string; + managerId?: string | string[] | null; + baseSalary?: number; + postSalary?: number; + attendanceBonus?: number; + phoneSubsidy?: number; + transportSubsidy?: number; + mealSubsidy?: number; + probationSalary?: number; + perfBase?: number; + socialBase?: number; +} & ArchiveFields; + +function n(v: Prisma.Decimal | number | string | null | undefined) { + return Number(v ?? 0); +} + +function parseDate(value?: string | Date | null) { + if (!value) return null; + const d = value instanceof Date ? value : new Date(value); + if (Number.isNaN(d.getTime())) throw new BadRequestException('日期格式不正确'); + return d; +} + +function maskValue(value?: string | null) { + if (!value) return value ?? null; + if (value.length <= 4) return '****'; + return `${value.slice(0, 2)}****${value.slice(-2)}`; +} + +function addMonths(d: Date, months: number) { + const next = new Date(d); + next.setMonth(next.getMonth() + months); + return next; +} + +function cleanText(v?: string | null) { + if (v === undefined) return undefined; + if (v === null) return null; + const t = v.trim(); + return t || null; +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const LEAD_TITLE = /经理|总监|主管|负责人/; +const LEAD_ROLES = new Set(['owner', 'pm', 'admin', 'biz_director']); + +function joinPeopleIds(v: unknown) { + if (v === undefined) return undefined; + if (v === null || v === '') return null; + const parts = Array.isArray(v) ? v : String(v).split(/[,,、\s]+/); + const ids = [...new Set(parts.map((s) => String(s).trim()).filter((s) => UUID_RE.test(s)))]; + return ids.length ? ids.join(',') : null; +} + +function peopleIdList(v: unknown): string[] { + const joined = joinPeopleIds(v); + return joined ? joined.split(',') : []; +} + +function managerIdList(emp: { managerId?: string | null; managerIds?: unknown }): string[] { + const extra = peopleIdList(emp.managerIds); + if (extra.length) return extra; + return emp.managerId ? [emp.managerId] : []; +} + +function splitManagers(v: unknown): { primary: string | null; joined: string | null; all: string[] } { + const all = peopleIdList(v); + return { primary: all[0] || null, joined: all.length ? all.join(',') : null, all }; +} + +function reportsTo(emp: { managerId?: string | null; managerIds?: unknown }, employeeId?: string | null) { + return Boolean(employeeId && managerIdList(emp).includes(employeeId)); +} + +function accountRolesOf(users?: { roles?: { role: { code: string } }[] }[]) { + return [...new Set((users || []).flatMap((u) => (u.roles || []).map((r) => r.role.code)))]; +} + +function isDeptLead(emp: { + title?: string | null; + jobRole?: string | null; + professionalTitle?: string | null; + users?: { roles?: { role: { code: string } }[] }[]; +}) { + const text = `${emp.title || ''}${emp.jobRole || ''}${emp.professionalTitle || ''}`; + if (LEAD_TITLE.test(text)) return true; + return accountRolesOf(emp.users).some((r) => LEAD_ROLES.has(r) || r.endsWith('_director')); +} + +function isDirectorLead(emp: { + title?: string | null; + jobRole?: string | null; + users?: { roles?: { role: { code: string } }[] }[]; +}) { + if (/总监/.test(`${emp.title || ''}${emp.jobRole || ''}`)) return true; + return accountRolesOf(emp.users).some((r) => r.endsWith('_director') || r === 'biz_director' || r === 'owner'); +} + +function assertGender(v?: string | null) { + if (v === undefined || v === null || v === '') return null; + if (!GENDERS.includes(v as (typeof GENDERS)[number])) throw new BadRequestException('性别须是男或女'); + return v; +} + +function assertEducation(v?: string | null) { + if (v === undefined || v === null || v === '') return null; + if (!EDUCATIONS.includes(v as (typeof EDUCATIONS)[number])) throw new BadRequestException('学历选项无效'); + return v; +} + +function assertClock(v: string | undefined, label: string) { + if (!v) throw new BadRequestException(`请填写${label}`); + if (!TIME_RE.test(v)) throw new BadRequestException(`${label}须是 HH:mm`); + return v; +} + +@Injectable() +export class HrService { + constructor(private readonly prisma: PrismaService) {} + + assertManage(user: AuthUser) { + if (!canManageHr(user.roles, user.permissions)) { + throw new ForbiddenException('无权维护组织人事'); + } + } + + assertViewPayroll(user: AuthUser) { + if (!canViewPayroll(user.roles, user.permissions)) { + throw new ForbiddenException('无权查看薪酬'); + } + } + + listDepartments() { + return this.prisma.department.findMany({ + where: { tenantId: '1' }, + include: { parent: true, _count: { select: { employees: true, children: true } } }, + orderBy: { sortNo: 'asc' }, + }); + } + + async createDepartment( + user: AuthUser, + dto: { name: string; code: string; deptType: string; parentId?: string; categoryCode?: string; sortNo?: number }, + ) { + this.assertManage(user); + if (!dto.name?.trim() || !dto.code?.trim()) throw new BadRequestException('请填写部门名称和编码'); + if (!DEPT_TYPES.includes(dto.deptType as (typeof DEPT_TYPES)[number])) { + throw new BadRequestException('部门类型须是 ADMIN / BUSINESS / TECH / FINANCE / HR'); + } + if (dto.parentId) { + const parent = await this.prisma.department.findFirst({ where: { id: dto.parentId, tenantId: '1' } }); + if (!parent) throw new BadRequestException('上级部门不存在'); + } + try { + return await this.prisma.department.create({ + data: { + tenantId: '1', + name: dto.name.trim(), + code: dto.code.trim().toUpperCase(), + deptType: dto.deptType, + parentId: dto.parentId || null, + categoryCode: dto.categoryCode?.trim() || null, + sortNo: dto.sortNo ?? 99, + }, + include: { parent: true, _count: { select: { employees: true, children: true } } }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new BadRequestException('部门编码已存在'); + } + throw e; + } + } + + async updateDepartment( + id: string, + user: AuthUser, + dto: { name?: string; code?: string; deptType?: string; parentId?: string | null; categoryCode?: string; sortNo?: number }, + ) { + this.assertManage(user); + const row = await this.prisma.department.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('部门不存在'); + if (dto.deptType && !DEPT_TYPES.includes(dto.deptType as (typeof DEPT_TYPES)[number])) { + throw new BadRequestException('部门类型须是 ADMIN / BUSINESS / TECH / FINANCE / HR'); + } + if (dto.parentId === id) throw new BadRequestException('不能把部门设为自己的上级'); + try { + return await this.prisma.department.update({ + where: { id }, + data: { + name: dto.name?.trim() || undefined, + code: dto.code ? dto.code.trim().toUpperCase() : undefined, + deptType: dto.deptType || undefined, + parentId: dto.parentId === undefined ? undefined : dto.parentId || null, + categoryCode: dto.categoryCode === undefined ? undefined : dto.categoryCode?.trim() || null, + sortNo: dto.sortNo, + }, + include: { parent: true, _count: { select: { employees: true, children: true } } }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new BadRequestException('部门编码已存在'); + } + throw e; + } + } + + async deleteDepartment(id: string, user: AuthUser) { + this.assertManage(user); + const row = await this.prisma.department.findFirst({ + where: { id, tenantId: '1' }, + include: { _count: { select: { employees: true, children: true } } }, + }); + if (!row) throw new NotFoundException('部门不存在'); + if (row._count.children) throw new BadRequestException('请先删完下级部门'); + if (row._count.employees) throw new BadRequestException('部门下还有员工,请先办理调岗或离职'); + await this.prisma.department.delete({ where: { id } }); + return { ok: true }; + } + + async getSettings() { + const row = await this.prisma.hrSetting.findFirst({ where: { tenantId: '1' } }); + if (row) return row; + return this.prisma.hrSetting.create({ + data: { tenantId: '1', idMask: true, autoAccount: false, probationMonths: 3, defaultRest: REST.SINGLE, workStart: '09:00', workEnd: '18:00' }, + }); + } + + async updateSettings( + user: AuthUser, + dto: { + idMask?: boolean; + autoAccount?: boolean; + probationMonths?: number; + defaultRest?: string; + workStart?: string; + workEnd?: string; + }, + ) { + this.assertManage(user); + const current = await this.getSettings(); + if (dto.probationMonths !== undefined && (dto.probationMonths < 0 || dto.probationMonths > 12)) { + throw new BadRequestException('试用期须在 0 到 12 个月'); + } + const rest = + dto.defaultRest === undefined ? undefined : dto.defaultRest === REST.DOUBLE ? REST.DOUBLE : REST.SINGLE; + return this.prisma.hrSetting.update({ + where: { id: current.id }, + data: { + idMask: dto.idMask, + autoAccount: dto.autoAccount === undefined ? false : Boolean(dto.autoAccount), + probationMonths: dto.probationMonths, + defaultRest: rest, + workStart: dto.workStart ? assertClock(dto.workStart, '上班时间') : undefined, + workEnd: dto.workEnd ? assertClock(dto.workEnd, '下班时间') : undefined, + }, + }); + } + + listAttendanceRules() { + return this.prisma.attendanceRule.findMany({ where: { tenantId: '1' }, orderBy: { sortNo: 'asc' } }); + } + + async saveAttendanceRule( + user: AuthUser, + id: string | undefined, + dto: { + name: string; + restSchedule?: string; + workStart: string; + workEnd: string; + lateGraceMinutes?: number; + punchPlace?: string | null; + punchAddress?: string | null; + punchLat?: number | null; + punchLng?: number | null; + punchRadiusMeters?: number; + enabled?: boolean; + sortNo?: number; + remark?: string | null; + }, + ) { + this.assertManage(user); + if (!dto.name?.trim()) throw new BadRequestException('请填写班次名称'); + const rest = dto.restSchedule === REST.DOUBLE ? REST.DOUBLE : REST.SINGLE; + const data = { + name: dto.name.trim(), + restSchedule: rest, + workStart: assertClock(dto.workStart, '上班时间'), + workEnd: assertClock(dto.workEnd, '下班时间'), + lateGraceMinutes: dto.lateGraceMinutes ?? 0, + punchPlace: cleanText(dto.punchPlace) ?? null, + punchAddress: cleanText(dto.punchAddress) ?? null, + punchLat: dto.punchLat == null || Number.isNaN(Number(dto.punchLat)) ? null : Number(dto.punchLat), + punchLng: dto.punchLng == null || Number.isNaN(Number(dto.punchLng)) ? null : Number(dto.punchLng), + punchRadiusMeters: dto.punchRadiusMeters ?? 300, + enabled: dto.enabled ?? true, + sortNo: dto.sortNo ?? 99, + remark: cleanText(dto.remark) ?? null, + }; + try { + if (id) { + const row = await this.prisma.attendanceRule.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('班次不存在'); + return this.prisma.attendanceRule.update({ where: { id }, data }); + } + return this.prisma.attendanceRule.create({ data: { tenantId: '1', ...data } }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new BadRequestException('班次名称已存在'); + } + throw e; + } + } + + async deleteAttendanceRule(id: string, user: AuthUser) { + this.assertManage(user); + const row = await this.prisma.attendanceRule.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('班次不存在'); + await this.prisma.attendanceRule.delete({ where: { id } }); + return { ok: true }; + } + + async listEmployees(user: AuthUser) { + const where: Prisma.EmployeeWhereInput = { tenantId: '1' }; + const scope = effectiveScope(user); + if (scope === 'SELF') where.id = user.employeeId || '__none__'; + if (scope === 'DEPT') { + const deptIds = await descendantDeptIds(this.prisma, user.departmentId); + where.OR = [ + { departmentId: { in: deptIds } }, + ...(user.employeeId + ? [{ managerId: user.employeeId }, { managerIds: { contains: user.employeeId } }] + : []), + ]; + } + const [rows, settings] = await Promise.all([ + this.prisma.employee.findMany({ + where, + include: EMP_INCLUDE, + orderBy: { employeeNo: 'asc' }, + }), + this.getSettings(), + ]); + const items = rows.map((r) => this.serializeEmployee(r, settings.idMask)); + return { items: await this.attachPhotos(await this.attachManagerNames(items)), total: rows.length }; + } + + async getEmployee(id: string) { + const row = await this.prisma.employee.findFirst({ where: { id, tenantId: '1' }, include: EMP_INCLUDE }); + if (!row) throw new NotFoundException('员工不存在'); + const [item] = await this.attachPhotos(await this.attachManagerNames([this.serializeEmployee(row, false)])); + return item; + } + + async deleteEmployee(id: string, user: AuthUser) { + this.assertManage(user); + const row = await this.assertEmployee(id); + const [payroll, att, punches, leaves] = await Promise.all([ + this.prisma.payrollItem.count({ where: { employeeId: id } }), + this.prisma.attendanceMonth.count({ where: { employeeId: id } }), + this.prisma.attendancePunch.count({ where: { employeeId: id } }), + this.prisma.leaveRequest.count({ where: { employeeId: id, status: LEAVE_STATUS.APPROVED } }), + ]); + if (payroll || att || punches || leaves) { + throw new BadRequestException('该员工已有薪酬或假勤记录,请走入转调离,不能直接删除'); + } + await this.prisma.$transaction(async (tx) => { + await tx.user.updateMany({ where: { employeeId: id }, data: { employeeId: null } }); + await tx.employmentEvent.deleteMany({ where: { employeeId: id } }); + await tx.leaveRequest.deleteMany({ where: { employeeId: id } }); + await tx.fileAsset.deleteMany({ + where: { tenantId: '1', bizType: 'EMPLOYEE_ID_PHOTO', bizId: id }, + }); + await tx.employee.delete({ where: { id } }); + }); + return { ok: true, name: row.name }; + } + + private async nextEmployeeNo() { + const prefix = `E-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-`; + const last = await this.prisma.employee.findFirst({ + where: { tenantId: '1', employeeNo: { startsWith: prefix } }, + orderBy: { employeeNo: 'desc' }, + select: { employeeNo: true }, + }); + const seq = last ? Number(last.employeeNo.slice(prefix.length)) + 1 : 1; + return `${prefix}${String(Number.isFinite(seq) && seq > 0 ? seq : 1).padStart(3, '0')}`; + } + + async createEmployee( + user: AuthUser, + dto: EmpDto, + ) { + this.assertManage(user); + if (!dto.name?.trim()) throw new BadRequestException('请填写姓名'); + await this.assertDept(dto.departmentId); + const mgrs = splitManagers(dto.managerId); + for (const mid of mgrs.all) await this.assertDeptLead(mid, dto.departmentId, undefined); + await this.assertCcLeads(dto, dto.departmentId, undefined, mgrs.all); + const settings = await this.getSettings(); + const employeeNo = dto.employeeNo?.trim() + ? dto.employeeNo.trim().toUpperCase() + : await this.nextEmployeeNo(); + const rest = dto.restSchedule === REST.DOUBLE || dto.restSchedule === REST.SINGLE ? dto.restSchedule : settings.defaultRest; + const status = Object.values(EMP_STATUS).includes(dto.employmentStatus as (typeof EMP_STATUS)[keyof typeof EMP_STATUS]) + ? dto.employmentStatus! + : EMP_STATUS.PROBATION; + const hiredAt = parseDate(dto.hiredAt) || new Date(); + const probationEndAt = + parseDate(dto.probationEndAt) || + (status === EMP_STATUS.PROBATION ? addMonths(hiredAt, settings.probationMonths) : null); + try { + const created = await this.prisma.$transaction(async (tx) => { + const row = await tx.employee.create({ + data: { + tenantId: '1', + name: dto.name.trim(), + employeeNo, + departmentId: dto.departmentId, + title: dto.title?.trim() || null, + restSchedule: rest, + employmentStatus: status, + hiredAt, + internStartAt: status === EMP_STATUS.INTERN ? parseDate(dto.internStartAt) || hiredAt : parseDate(dto.internStartAt), + probationEndAt, + managerId: mgrs.primary, + managerIds: mgrs.joined, + gender: assertGender(dto.gender), + education: assertEducation(dto.education), + ...archiveData(dto, 'create'), + baseSalary: dto.baseSalary ?? 0, + postSalary: dto.postSalary ?? 0, + attendanceBonus: dto.attendanceBonus ?? 0, + phoneSubsidy: dto.phoneSubsidy ?? 0, + transportSubsidy: dto.transportSubsidy ?? 0, + mealSubsidy: dto.mealSubsidy ?? 0, + probationSalary: dto.probationSalary ?? 0, + perfBase: dto.perfBase ?? 0, + socialBase: dto.socialBase ?? dto.baseSalary ?? 0, + } as Prisma.EmployeeUncheckedCreateInput, + include: EMP_INCLUDE, + }); + await tx.employmentEvent.create({ + data: { + tenantId: '1', + employeeId: row.id, + kind: LIFE_KIND.HIRE, + toStatus: status, + toDeptId: row.departmentId, + effectiveAt: hiredAt, + comment: '入职', + actorId: user.id, + }, + }); + return row; + }); + return (await this.attachManagerNames([this.serializeEmployee(created)]))[0]; + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new BadRequestException('工号已存在'); + } + throw e; + } + } + + async updateEmployee( + id: string, + user: AuthUser, + dto: { + name?: string; + departmentId?: string; + title?: string; + restSchedule?: string; + managerId?: string | string[] | null; + baseSalary?: number; + postSalary?: number; + attendanceBonus?: number; + phoneSubsidy?: number; + transportSubsidy?: number; + mealSubsidy?: number; + probationSalary?: number; + perfBase?: number; + socialBase?: number; + internStartAt?: string | null; + probationEndAt?: string | null; + } & ArchiveFields, + ) { + this.assertManage(user); + const row = await this.assertEmployee(id); + if (row.employmentStatus === EMP_STATUS.LEFT) throw new BadRequestException('已离职员工请走入转调离,不能直接改档案'); + const deptId = dto.departmentId || row.departmentId; + const mgrs = dto.managerId === undefined ? null : splitManagers(dto.managerId); + if (mgrs) { + for (const mid of mgrs.all) await this.assertDeptLead(mid, deptId, id); + } + await this.assertCcLeads(dto, deptId, id, mgrs ? mgrs.all : managerIdList(row)); + if (dto.departmentId && dto.departmentId !== row.departmentId) { + await this.assertDept(dto.departmentId); + } + const rest = + dto.restSchedule === undefined ? undefined : dto.restSchedule === REST.DOUBLE ? REST.DOUBLE : REST.SINGLE; + const updated = await this.prisma.employee.update({ + where: { id }, + data: { + name: dto.name?.trim() || undefined, + departmentId: dto.departmentId && dto.departmentId !== row.departmentId ? dto.departmentId : undefined, + title: dto.title === undefined ? undefined : dto.title?.trim() || null, + restSchedule: rest, + managerId: mgrs ? mgrs.primary : undefined, + managerIds: mgrs ? mgrs.joined : undefined, + baseSalary: dto.baseSalary, + postSalary: dto.postSalary, + attendanceBonus: dto.attendanceBonus, + phoneSubsidy: dto.phoneSubsidy, + transportSubsidy: dto.transportSubsidy, + mealSubsidy: dto.mealSubsidy, + probationSalary: dto.probationSalary, + perfBase: dto.perfBase, + socialBase: dto.socialBase, + internStartAt: dto.internStartAt === undefined ? undefined : parseDate(dto.internStartAt), + probationEndAt: dto.probationEndAt === undefined ? undefined : parseDate(dto.probationEndAt), + gender: dto.gender === undefined ? undefined : assertGender(dto.gender), + education: dto.education === undefined ? undefined : assertEducation(dto.education), + ...archiveData(dto, 'update'), + } as Prisma.EmployeeUncheckedUpdateInput, + include: EMP_INCLUDE, + }); + return (await this.attachManagerNames([this.serializeEmployee(updated, false)]))[0]; + } + + async listEvents(user: AuthUser, employeeId?: string) { + this.assertManage(user); + const rows = await this.prisma.employmentEvent.findMany({ + where: { tenantId: '1', ...(employeeId ? { employeeId } : {}) }, + include: { + employee: { include: { department: true } }, + fromDept: true, + toDept: true, + actor: { select: { id: true, displayName: true } }, + }, + orderBy: { createdAt: 'desc' }, + take: 200, + }); + return { items: rows, total: rows.length }; + } + + async createEvent( + user: AuthUser, + dto: { employeeId: string; kind: string; effectiveAt?: string; toDepartmentId?: string; comment?: string }, + ) { + this.assertManage(user); + const kind = dto.kind?.toUpperCase(); + if (!Object.values(LIFE_KIND).includes(kind as (typeof LIFE_KIND)[keyof typeof LIFE_KIND])) { + throw new BadRequestException('请选择实习、试用、转正、调岗、晋升或离职'); + } + const emp = await this.assertEmployee(dto.employeeId); + if (emp.employmentStatus === EMP_STATUS.LEFT && kind !== LIFE_KIND.HIRE) { + throw new BadRequestException('该员工已离职'); + } + const nextStatus = nextStatusForKind(kind, emp.employmentStatus); + if (!nextStatus) throw new BadRequestException('不支持的异动类型'); + const effectiveAt = parseDate(dto.effectiveAt) || new Date(); + let toDeptId = emp.departmentId; + if (kind === LIFE_KIND.TRANSFER) { + if (!dto.toDepartmentId) throw new BadRequestException('调岗请选择转入部门'); + await this.assertDept(dto.toDepartmentId); + toDeptId = dto.toDepartmentId; + } + if (kind === LIFE_KIND.LEAVE && effectiveAt < (emp.hiredAt || effectiveAt)) { + throw new BadRequestException('离职日不能早于入职日'); + } + const updated = await this.prisma.$transaction(async (tx) => { + await tx.employmentEvent.create({ + data: { + tenantId: '1', + employeeId: emp.id, + kind, + fromStatus: emp.employmentStatus, + toStatus: nextStatus, + fromDeptId: emp.departmentId, + toDeptId, + effectiveAt, + comment: dto.comment?.trim() || null, + actorId: user.id, + }, + }); + return tx.employee.update({ + where: { id: emp.id }, + data: { + employmentStatus: nextStatus, + departmentId: toDeptId, + internStartAt: kind === LIFE_KIND.INTERN ? effectiveAt : undefined, + probationEndAt: kind === LIFE_KIND.PROBATION ? emp.probationEndAt : undefined, + leftAt: kind === LIFE_KIND.LEAVE ? effectiveAt : undefined, + }, + include: EMP_INCLUDE, + }); + }); + return this.serializeEmployee(updated); + } + + async listAttendance(user: AuthUser, yearMonth?: string) { + this.assertManage(user); + const ym = yearMonth ? assertYearMonthSafe(yearMonth) : currentYearMonth(); + const rows = await this.prisma.attendanceMonth.findMany({ + where: { tenantId: '1', yearMonth: ym }, + include: { employee: { include: { department: true } }, importedBy: { select: { displayName: true } } }, + orderBy: { importedAt: 'desc' }, + }); + return { + yearMonth: ym, + items: rows.map((r) => ({ + ...r, + workDays: n(r.workDays), + leaveDays: n(r.leaveDays), + sickDays: n(r.sickDays), + overtimeHours: n(r.overtimeHours), + compLeaveDays: n(r.compLeaveDays), + compLeaveHours: n(r.employee.compLeaveHours), + })), + total: rows.length, + }; + } + + async upsertAttendance( + user: AuthUser, + dto: { + employeeId: string; + yearMonth: string; + workDays?: number; + leaveDays?: number; + sickDays?: number; + overtimeHours?: number; + compLeaveDays?: number; + remark?: string; + }, + ) { + this.assertManage(user); + const ym = assertYearMonthSafe(dto.yearMonth); + const emp = await this.assertEmployee(dto.employeeId); + return this.prisma.$transaction((tx) => this.writeAttendance(tx, user.id, emp, ym, dto)); + } + + async importAttendance( + user: AuthUser, + dto: { + yearMonth: string; + items: { + employeeId?: string; + employeeNo?: string; + workDays?: number; + leaveDays?: number; + sickDays?: number; + overtimeHours?: number; + compLeaveDays?: number; + remark?: string; + }[]; + }, + ) { + this.assertManage(user); + const ym = assertYearMonthSafe(dto.yearMonth); + if (!dto.items?.length) throw new BadRequestException('请提供假勤明细'); + const written = await this.prisma.$transaction(async (tx) => { + const out = []; + for (const line of dto.items) { + let emp = line.employeeId + ? await tx.employee.findFirst({ where: { id: line.employeeId, tenantId: '1' } }) + : line.employeeNo + ? await tx.employee.findFirst({ + where: { tenantId: '1', employeeNo: line.employeeNo.trim().toUpperCase() }, + }) + : null; + if (!emp) throw new BadRequestException(`找不到员工 ${line.employeeNo || line.employeeId || ''}`); + out.push(await this.writeAttendance(tx, user.id, emp, ym, line)); + } + return out; + }); + return { yearMonth: ym, items: written, total: written.length }; + } + + async punch(user: AuthUser, kind: string) { + const employeeId = user.employeeId; + if (!employeeId) throw new BadRequestException('当前账号未关联员工档案,不能打卡'); + const k = kind === PUNCH_KIND.OUT ? PUNCH_KIND.OUT : PUNCH_KIND.IN; + const start = new Date(); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 1); + const today = await this.prisma.attendancePunch.findMany({ + where: { employeeId, punchedAt: { gte: start, lt: end } }, + orderBy: { punchedAt: 'asc' }, + }); + if (k === PUNCH_KIND.IN && today.some((p) => p.kind === PUNCH_KIND.IN)) { + throw new BadRequestException('今天已经打过上班卡'); + } + if (k === PUNCH_KIND.OUT && !today.some((p) => p.kind === PUNCH_KIND.IN)) { + throw new BadRequestException('请先打上班卡'); + } + if (k === PUNCH_KIND.OUT && today.some((p) => p.kind === PUNCH_KIND.OUT)) { + throw new BadRequestException('今天已经打过下班卡'); + } + await this.prisma.attendancePunch.create({ data: { employeeId, kind: k } }); + return this.myPunches(user); + } + + async myPunches(user: AuthUser, date?: string) { + const employeeId = user.employeeId; + if (!employeeId) return { employeeId: null, items: [], clockIn: null, clockOut: null }; + const day = date ? new Date(`${date}T00:00:00`) : new Date(); + day.setHours(0, 0, 0, 0); + const next = new Date(day); + next.setDate(next.getDate() + 1); + const items = await this.prisma.attendancePunch.findMany({ + where: { employeeId, punchedAt: { gte: day, lt: next } }, + orderBy: { punchedAt: 'asc' }, + }); + return { + employeeId, + items, + clockIn: items.find((p) => p.kind === PUNCH_KIND.IN)?.punchedAt || null, + clockOut: [...items].reverse().find((p) => p.kind === PUNCH_KIND.OUT)?.punchedAt || null, + }; + } + + async listPunches(user: AuthUser, yearMonth?: string) { + this.assertManage(user); + const ym = yearMonth ? assertYearMonthSafe(yearMonth) : currentYearMonth(); + const start = new Date(`${ym}-01T00:00:00`); + const end = new Date(start); + end.setMonth(end.getMonth() + 1); + const items = await this.prisma.attendancePunch.findMany({ + where: { punchedAt: { gte: start, lt: end } }, + include: { employee: { select: { id: true, name: true, employeeNo: true, department: { select: { name: true } } } } }, + orderBy: { punchedAt: 'desc' }, + }); + return { yearMonth: ym, items }; + } + + async compBalance(user: AuthUser, employeeId?: string) { + const id = employeeId && canManageHr(user.roles, user.permissions) ? employeeId : user.employeeId; + if (!id) { + return { + hours: 0, + days: 0, + usableHours: 0, + usableDays: 0, + pendingCompDays: 0, + pendingOvertimeHours: 0, + employmentStatus: null, + probationEndAt: null, + canRegularize: false, + }; + } + const emp = await this.assertEmployee(id); + const pending = await this.prisma.leaveRequest.findMany({ + where: { employeeId: id, status: LEAVE_STATUS.PENDING, kind: { in: [LEAVE_KIND.COMP, HR_APPLY_KIND.OVERTIME] } }, + select: { kind: true, days: true }, + }); + const pendingCompDays = pending.filter((p) => p.kind === LEAVE_KIND.COMP).reduce((s, p) => s + n(p.days), 0); + const pendingOvertimeHours = pending + .filter((p) => p.kind === HR_APPLY_KIND.OVERTIME) + .reduce((s, p) => s + n(p.days), 0); + const hours = n(emp.compLeaveHours); + const usableHours = Math.max(0, Math.round((hours - pendingCompDays * HOURS_PER_DAY) * 100) / 100); + return { + hours, + days: Math.round((hours / HOURS_PER_DAY) * 100) / 100, + usableHours, + usableDays: Math.round((usableHours / HOURS_PER_DAY) * 100) / 100, + pendingCompDays, + pendingOvertimeHours, + employmentStatus: emp.employmentStatus, + probationEndAt: emp.probationEndAt, + canRegularize: emp.employmentStatus === EMP_STATUS.PROBATION, + }; + } + + async createLeave( + user: AuthUser, + dto: { + kind: string; + startAt?: string; + endAt?: string; + days?: number; + reason: string; + toDeptId?: string; + targetTitle?: string; + punchKind?: string; + }, + ) { + const employeeId = user.employeeId; + if (!employeeId) throw new BadRequestException('当前账号未关联员工档案,不能提交人事申请'); + if (!isHrApplyKind(dto.kind)) throw new BadRequestException('请选择有效的申请类型'); + const kind = dto.kind; + const startAt = new Date(dto.startAt || Date.now()); + const endAt = new Date(dto.endAt || dto.startAt || Date.now()); + if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime()) || endAt < startAt) { + throw new BadRequestException('请填写正确的日期'); + } + if (!dto.reason?.trim()) throw new BadRequestException('请填写事由'); + if (kind === HR_APPLY_KIND.TRANSFER) { + if (!dto.toDeptId) throw new BadRequestException('转岗请选择转入部门'); + await this.assertDept(dto.toDeptId); + } + if (kind === HR_APPLY_KIND.PROMOTE && !dto.targetTitle?.trim()) { + throw new BadRequestException('晋升请填写拟任职务'); + } + if (kind === HR_APPLY_KIND.MAKEUP && !['IN', 'OUT'].includes(dto.punchKind || '')) { + throw new BadRequestException('补卡请选择上班或下班'); + } + if (kind === HR_APPLY_KIND.OVERTIME && !(dto.days && dto.days > 0)) { + throw new BadRequestException('加班请填写小时数,通过后转入调休'); + } + const needsSpan = isLeaveKind(kind) || kind === HR_APPLY_KIND.BUSINESS || kind === HR_APPLY_KIND.OUT; + const days = + kind === HR_APPLY_KIND.OVERTIME + ? dto.days! + : needsSpan + ? dto.days && dto.days > 0 + ? dto.days + : Math.max(0.5, Math.round(((endAt.getTime() - startAt.getTime()) / 86400000) * 2) / 2) + : 0; + const emp = await this.assertEmployee(employeeId); + if (kind === HR_APPLY_KIND.REGULARIZE) { + if (emp.employmentStatus !== EMP_STATUS.PROBATION) { + throw new BadRequestException('仅试用期员工可申请转正'); + } + const pending = await this.prisma.leaveRequest.findFirst({ + where: { employeeId, kind: HR_APPLY_KIND.REGULARIZE, status: LEAVE_STATUS.PENDING }, + }); + if (pending) throw new BadRequestException('已有待批转正申请'); + } + if (kind === LEAVE_KIND.COMP) { + const bal = await this.compBalance(user); + if (days * HOURS_PER_DAY - bal.usableHours > 0.001) { + throw new BadRequestException( + `调休余额不足:可用 ${bal.usableHours} 小时(${bal.usableDays} 天),本次 ${days} 天`, + ); + } + } + const created = await this.prisma.leaveRequest.create({ + data: { + employeeId, + kind, + startAt, + endAt, + days: new Prisma.Decimal(days), + reason: dto.reason.trim(), + toDeptId: kind === HR_APPLY_KIND.TRANSFER ? dto.toDeptId : null, + targetTitle: + kind === HR_APPLY_KIND.PROMOTE + ? dto.targetTitle?.trim() || null + : kind === HR_APPLY_KIND.MAKEUP + ? dto.punchKind || null + : null, + status: LEAVE_STATUS.PENDING, + }, + }); + const reviewers = await this.leaveReviewers(emp, kind); + if (reviewers.length) { + await this.prisma.approvalTask.createMany({ + data: reviewers.map((id) => ({ + title: `人事申请:${emp.name} ${HR_APPLY_LABEL[kind] || kind}`, + bizType: HR_BIZ.LEAVE, + bizId: created.id, + assigneeId: id, + })), + skipDuplicates: true, + }); + emitSms('HR_LEAVE', { + userIds: reviewers, + params: { content: `人事:${emp.name}${HR_APPLY_LABEL[kind] || kind}` }, + bizType: HR_BIZ.LEAVE, + bizId: created.id, + }); + } + return this.getLeave(created.id, user); + } + + async deleteLeave(id: string, user: AuthUser) { + const row = await this.prisma.leaveRequest.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('申请不存在'); + const mine = Boolean(user.employeeId && row.employeeId === user.employeeId); + if (!mine && !canManageHr(user.roles, user.permissions)) { + throw new ForbiddenException('只能删除自己的人事申请'); + } + if (row.status !== LEAVE_STATUS.PENDING) { + throw new BadRequestException('已审批的申请不能删除'); + } + await this.prisma.$transaction(async (tx) => { + await tx.approvalTask.updateMany({ + where: { bizId: id, bizType: HR_BIZ.LEAVE, status: 'PENDING' }, + data: { status: 'CANCELLED' }, + }); + await tx.leaveRequest.delete({ where: { id } }); + }); + return { ok: true }; + } + + async listLeaves(user: AuthUser, bucket?: string) { + const where: Prisma.LeaveRequestWhereInput = { tenantId: '1' }; + if (!canManageHr(user.roles, user.permissions) || bucket === 'mine') { + if (!user.employeeId) return { items: [], balance: await this.compBalance(user) }; + where.employeeId = user.employeeId; + } + if (bucket === 'pending') where.status = LEAVE_STATUS.PENDING; + else if (bucket === 'approved') where.status = LEAVE_STATUS.APPROVED; + else if (bucket === 'rejected') where.status = LEAVE_STATUS.REJECTED; + const items = await this.prisma.leaveRequest.findMany({ + where, + include: { + employee: { select: { id: true, name: true, employeeNo: true, department: { select: { name: true } } } }, + decidedBy: { select: { id: true, displayName: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + const balance = user.employeeId ? await this.compBalance(user) : null; + return { items, balance }; + } + + async reviewLeave(id: string, user: AuthUser, dto: { result: 'APPROVED' | 'REJECTED'; comment?: string }) { + const assigned = await this.prisma.approvalTask.findFirst({ + where: { bizType: HR_BIZ.LEAVE, bizId: id, assigneeId: user.id }, + }); + if (!canManageHr(user.roles, user.permissions) && !assigned) { + throw new ForbiddenException('无权审批该人事申请'); + } + const row = await this.prisma.leaveRequest.findFirst({ + where: { id, tenantId: '1' }, + include: { employee: true }, + }); + if (!row) throw new NotFoundException('申请不存在'); + if (row.status !== LEAVE_STATUS.PENDING) throw new BadRequestException('该申请已处理'); + const status = dto.result === 'APPROVED' ? LEAVE_STATUS.APPROVED : LEAVE_STATUS.REJECTED; + await this.prisma.$transaction(async (tx) => { + await tx.leaveRequest.update({ + where: { id }, + data: { status, comment: dto.comment?.trim() || null, decidedById: user.id, decidedAt: new Date() }, + }); + await tx.approvalTask.updateMany({ + where: { bizType: HR_BIZ.LEAVE, bizId: id, status: 'PENDING' }, + data: { status: dto.result }, + }); + if (status !== LEAVE_STATUS.APPROVED) return; + const ym = row.startAt.toISOString().slice(0, 7); + const month = await tx.attendanceMonth.findUnique({ + where: { employeeId_yearMonth: { employeeId: row.employeeId, yearMonth: ym } }, + }); + if (row.kind === HR_APPLY_KIND.OVERTIME) { + await this.writeAttendance(tx, user.id, row.employee, ym, { + overtimeHours: n(month?.overtimeHours) + Number(row.days), + remark: '加班申请转入调休', + }); + } else if (row.kind === LEAVE_KIND.COMP) { + await this.writeAttendance(tx, user.id, row.employee, ym, { + compLeaveDays: n(month?.compLeaveDays) + Number(row.days), + remark: '调休申请扣减余额', + }); + } else if (leavePayBucket(row.kind) === 'unpaid' || leavePayBucket(row.kind) === 'sick') { + const addLeave = leavePayBucket(row.kind) === 'unpaid' ? Number(row.days) : 0; + const addSick = leavePayBucket(row.kind) === 'sick' ? Number(row.days) : 0; + if (month) { + await tx.attendanceMonth.update({ + where: { id: month.id }, + data: { + leaveDays: { increment: addLeave }, + sickDays: { increment: addSick }, + }, + }); + } else { + await tx.attendanceMonth.create({ + data: { + employeeId: row.employeeId, + yearMonth: ym, + leaveDays: addLeave, + sickDays: addSick, + importedById: user.id, + remark: '请假审批自动计入', + }, + }); + } + } + if (row.kind === HR_APPLY_KIND.MAKEUP) { + await tx.attendancePunch.create({ + data: { + employeeId: row.employeeId, + kind: row.targetTitle === PUNCH_KIND.OUT ? PUNCH_KIND.OUT : PUNCH_KIND.IN, + punchedAt: row.startAt, + remark: `补卡:${row.reason}`, + }, + }); + } + if ( + row.kind === HR_APPLY_KIND.TRANSFER || + row.kind === HR_APPLY_KIND.RESIGN || + row.kind === HR_APPLY_KIND.PROMOTE || + row.kind === HR_APPLY_KIND.REGULARIZE + ) { + const lifeKind = + row.kind === HR_APPLY_KIND.TRANSFER + ? LIFE_KIND.TRANSFER + : row.kind === HR_APPLY_KIND.RESIGN + ? LIFE_KIND.LEAVE + : row.kind === HR_APPLY_KIND.REGULARIZE + ? LIFE_KIND.REGULARIZE + : LIFE_KIND.PROMOTE; + const nextStatus = nextStatusForKind(lifeKind, row.employee.employmentStatus); + const toDeptId = row.kind === HR_APPLY_KIND.TRANSFER ? row.toDeptId || row.employee.departmentId : row.employee.departmentId; + await tx.employmentEvent.create({ + data: { + tenantId: '1', + employeeId: row.employeeId, + kind: lifeKind, + fromStatus: row.employee.employmentStatus, + toStatus: nextStatus, + fromDeptId: row.employee.departmentId, + toDeptId, + effectiveAt: row.startAt, + comment: row.reason, + actorId: user.id, + }, + }); + await tx.employee.update({ + where: { id: row.employeeId }, + data: { + employmentStatus: nextStatus || row.employee.employmentStatus, + departmentId: toDeptId, + jobRole: row.kind === HR_APPLY_KIND.PROMOTE ? row.targetTitle : undefined, + leftAt: row.kind === HR_APPLY_KIND.RESIGN ? row.startAt : undefined, + probationEndAt: row.kind === HR_APPLY_KIND.REGULARIZE ? row.startAt : undefined, + }, + }); + } + if (row.kind === HR_APPLY_KIND.BUSINESS) { + const applicant = await tx.user.findFirst({ + where: { employeeId: row.employeeId, tenantId: '1' }, + }); + if (applicant) { + const claim = await tx.expenseClaim.create({ + data: { + tenantId: '1', + claimNo: await nextExpenseClaimNo(tx), + applicantId: applicant.id, + costType: 'ADMIN', + purpose: `出差报销:${row.reason || ''}`.trim(), + amount: 0, + remark: `人事出差申请通过后自动生成草稿,请补金额和票据后提交`, + status: 'DRAFT', + }, + }); + await tx.todoTask.create({ + data: { + tenantId: '1', + title: '填写出差报销', + bizType: 'TRAVEL_EXPENSE', + bizId: claim.id, + assigneeId: applicant.id, + }, + }); + } + } + }); + return this.getLeave(id, user); + } + + async decideLeaveApproval(taskId: string, user: AuthUser, dto: { result: 'APPROVED' | 'REJECTED'; comment?: string }) { + const task = await this.prisma.approvalTask.findFirst({ where: { id: taskId, assigneeId: user.id } }); + if (!task?.bizId) throw new NotFoundException('审批不存在'); + return this.reviewLeave(task.bizId, user, dto); + } + + async getLeave(id: string, user: AuthUser) { + const row = await this.prisma.leaveRequest.findFirst({ + where: { id, tenantId: '1' }, + include: { + employee: { select: { id: true, name: true, employeeNo: true, department: { select: { name: true } } } }, + decidedBy: { select: { id: true, displayName: true } }, + }, + }); + if (!row) throw new NotFoundException('请假单不存在'); + const assigned = await this.prisma.approvalTask.findFirst({ + where: { bizType: HR_BIZ.LEAVE, bizId: id, assigneeId: user.id }, + }); + if (!canManageHr(user.roles, user.permissions) && row.employeeId !== user.employeeId && !assigned) { + throw new ForbiddenException('无权查看该请假'); + } + return { ...row, days: Number(row.days) }; + } + + private async deptDirectorUserIds(departmentId: string, selfEmpId?: string) { + const chain = await ancestorDeptIds(this.prisma, departmentId); + if (!chain.length) return [] as string[]; + const inChain = await this.prisma.employee.findMany({ + where: { tenantId: '1', departmentId: { in: chain } }, + include: EMP_INCLUDE, + }); + const allowed: string[] = []; + for (const deptId of chain) { + allowed.push(deptId); + if (inChain.some((e) => e.departmentId === deptId && e.id !== selfEmpId && isDirectorLead(e))) break; + } + const allow = new Set(allowed); + const empIds = inChain + .filter((e) => allow.has(e.departmentId) && e.id !== selfEmpId && isDirectorLead(e)) + .map((e) => e.id); + if (!empIds.length) return []; + const users = await this.prisma.user.findMany({ + where: { employeeId: { in: empIds }, status: 'ACTIVE' }, + select: { id: true }, + }); + return users.map((u) => u.id); + } + + private async leaveReviewers( + emp: { id: string; departmentId: string; managerId?: string | null; managerIds?: unknown }, + kind?: string, + ) { + const ids = new Set(); + const mgrEmpIds = managerIdList(emp); + if (mgrEmpIds.length) { + const mgrs = await this.prisma.user.findMany({ + where: { employeeId: { in: mgrEmpIds }, status: 'ACTIVE' }, + select: { id: true }, + }); + for (const mgr of mgrs) ids.add(mgr.id); + } + for (const id of await this.deptDirectorUserIds(emp.departmentId, emp.id)) ids.add(id); + const needHr = ['REGULARIZE', 'PROMOTE', 'TRANSFER', 'RESIGN'].includes(kind || ''); + if (needHr || !ids.size) { + const hrs = await this.prisma.user.findMany({ + where: { status: 'ACTIVE', roles: { some: { role: { code: { in: needHr ? ['hr', 'owner'] : ['hr'] } } } } }, + select: { id: true }, + }); + for (const hr of hrs) ids.add(hr.id); + } + return [...ids]; + } + + async getPayroll(user: AuthUser, yearMonth?: string) { + this.assertViewPayroll(user); + const ym = yearMonth ? assertYearMonthSafe(yearMonth) : currentYearMonth(); + const run = await this.prisma.payrollRun.findFirst({ + where: { tenantId: '1', yearMonth: ym }, + include: { + items: { + include: { employee: { include: { department: true } } }, + orderBy: { employee: { employeeNo: 'asc' } }, + }, + }, + }); + if (!run) return { yearMonth: ym, run: null, items: [] }; + const items = this.filterPayrollItems(user, run.items).map((i) => this.serializeItem(i)); + return { yearMonth: ym, run: { id: run.id, status: run.status, confirmedAt: run.confirmedAt, createdAt: run.createdAt }, items }; + } + + async runPayroll(user: AuthUser, dto: { yearMonth?: string }) { + this.assertManage(user); + const ym = dto.yearMonth ? assertYearMonthSafe(dto.yearMonth) : currentYearMonth(); + return this.prisma.$transaction(async (tx) => { + let run = await tx.payrollRun.findFirst({ where: { tenantId: '1', yearMonth: ym } }); + if (run?.status === PAYROLL_STATUS.CONFIRMED) { + throw new BadRequestException('本月薪酬已确认,不能重算'); + } + if (!run) { + run = await tx.payrollRun.create({ + data: { tenantId: '1', yearMonth: ym, status: PAYROLL_STATUS.DRAFT, createdById: user.id }, + }); + } + const existing = await tx.payrollItem.findMany({ where: { runId: run.id } }); + const ratioMap = new Map(existing.map((i) => [i.employeeId, n(i.performanceRatio)])); + await tx.payrollItem.deleteMany({ where: { runId: run.id } }); + const depts = await tx.department.findMany({ where: { tenantId: '1' }, select: { id: true, name: true, parentId: true } }); + const byId = new Map(depts.map((d) => [d.id, d])); + const skipPayroll = (departmentId: string) => { + const seen = new Set(); + let cur = byId.get(departmentId); + while (cur && !seen.has(cur.id)) { + seen.add(cur.id); + if (cur.name === '外协部' || cur.name === '保障部') return true; + cur = cur.parentId ? byId.get(cur.parentId) : undefined; + } + return false; + }; + const employees = await tx.employee.findMany({ where: { tenantId: '1' } }); + const attendance = await tx.attendanceMonth.findMany({ where: { tenantId: '1', yearMonth: ym } }); + const attMap = new Map(attendance.map((a) => [a.employeeId, a])); + const regs = await tx.employmentEvent.findMany({ + where: { tenantId: '1', kind: LIFE_KIND.REGULARIZE }, + orderBy: { effectiveAt: 'desc' }, + }); + const regMap = new Map(); + for (const e of regs) { + if (!regMap.has(e.employeeId)) regMap.set(e.employeeId, e.effectiveAt); + } + for (const emp of employees) { + if (skipPayroll(emp.departmentId)) continue; + const factor = prorationFactor({ hiredAt: emp.hiredAt, leftAt: emp.leftAt, yearMonth: ym }); + if (factor <= 0) continue; + const att = attMap.get(emp.id); + const overtime = n(att?.overtimeHours); + const usedComp = n(att?.compLeaveDays); + const prevBalance = n(emp.compLeaveHours) - overtime + usedComp * HOURS_PER_DAY; + const slip = computePayslip({ + baseSalary: n(emp.baseSalary), + postSalary: n(emp.postSalary), + attendanceBonus: n(emp.attendanceBonus), + phoneSubsidy: n(emp.phoneSubsidy), + transportSubsidy: n(emp.transportSubsidy), + mealSubsidy: n(emp.mealSubsidy), + probationSalary: n(emp.probationSalary), + perfBase: n(emp.perfBase), + socialBase: n(emp.socialBase), + restSchedule: emp.restSchedule, + employmentStatus: emp.employmentStatus, + hiredAt: emp.hiredAt, + leftAt: emp.leftAt, + regularizeAt: regMap.get(emp.id) || null, + yearMonth: ym, + leaveDays: n(att?.leaveDays), + sickDays: n(att?.sickDays), + overtimeHours: overtime, + compLeaveDays: usedComp, + performanceRatio: ratioMap.get(emp.id) ?? 100, + prevCompLeaveHours: Math.max(0, prevBalance), + }); + await tx.payrollItem.create({ + data: { + runId: run.id, + employeeId: emp.id, + prorationFactor: slip.prorationFactor, + scheduledDays: slip.scheduledDays, + dailyRate: slip.dailyRate, + basePay: slip.basePay, + performancePay: slip.performancePay, + performanceRatio: slip.performanceRatio, + leaveDeduct: slip.leaveDeduct, + sickDeduct: slip.sickDeduct, + socialPersonal: slip.socialPersonal, + housingFund: slip.housingFund, + tax: slip.tax, + grossPay: slip.grossPay, + netPay: slip.netPay, + overtimeHours: slip.overtimeHours, + compLeaveUsedDays: slip.compLeaveUsedDays, + compLeaveHours: slip.compLeaveHours, + }, + }); + } + const full = await tx.payrollRun.findUniqueOrThrow({ + where: { id: run.id }, + include: { items: { include: { employee: { include: { department: true } } }, orderBy: { employee: { employeeNo: 'asc' } } } }, + }); + return { + yearMonth: ym, + run: { id: full.id, status: full.status, confirmedAt: full.confirmedAt, createdAt: full.createdAt }, + items: full.items.map((i) => this.serializeItem(i)), + }; + }); + } + + async confirmPayroll(id: string, user: AuthUser) { + if (!canConfirmPayroll(user.roles)) throw new ForbiddenException('无权确认薪酬'); + const run = await this.prisma.payrollRun.findFirst({ where: { id, tenantId: '1' } }); + if (!run) throw new NotFoundException('核算批次不存在'); + if (run.status === PAYROLL_STATUS.CONFIRMED) throw new BadRequestException('本月已确认'); + const updated = await this.prisma.payrollRun.update({ + where: { id }, + data: { status: PAYROLL_STATUS.CONFIRMED, confirmedAt: new Date() }, + }); + return { id: updated.id, yearMonth: updated.yearMonth, status: updated.status, confirmedAt: updated.confirmedAt }; + } + + async unconfirmPayroll(id: string, user: AuthUser) { + if (!canConfirmPayroll(user.roles)) throw new ForbiddenException('无权取消确认'); + const run = await this.prisma.payrollRun.findFirst({ where: { id, tenantId: '1' } }); + if (!run) throw new NotFoundException('核算批次不存在'); + if (run.status !== PAYROLL_STATUS.CONFIRMED) throw new BadRequestException('本月尚未确认'); + const updated = await this.prisma.payrollRun.update({ + where: { id }, + data: { status: PAYROLL_STATUS.DRAFT, confirmedAt: null }, + }); + return { id: updated.id, yearMonth: updated.yearMonth, status: updated.status, confirmedAt: updated.confirmedAt }; + } + + async adjustPerformance(id: string, user: AuthUser, dto: { performanceRatio: number }) { + if (!canAdjustPerformance(user.roles)) throw new ForbiddenException('无权调整绩效比例'); + const ratio = Number(dto.performanceRatio); + if (!Number.isFinite(ratio) || ratio < 0 || ratio > 200) throw new BadRequestException('发放比例须在 0 到 200 之间'); + const item = await this.prisma.payrollItem.findFirst({ + where: { id }, + include: { run: true, employee: { include: { department: true } } }, + }); + if (!item) throw new NotFoundException('工资条不存在'); + this.assertCanAdjustEmployee(user, item.employee); + if (item.run.status === PAYROLL_STATUS.CONFIRMED) { + await this.prisma.payrollRun.update({ + where: { id: item.runId }, + data: { status: PAYROLL_STATUS.DRAFT, confirmedAt: null }, + }); + } + const att = await this.prisma.attendanceMonth.findFirst({ + where: { employeeId: item.employeeId, yearMonth: item.run.yearMonth }, + }); + const emp = item.employee; + const overtime = n(att?.overtimeHours); + const usedComp = n(att?.compLeaveDays); + const prevBalance = n(emp.compLeaveHours) - overtime + usedComp * HOURS_PER_DAY; + const reg = await this.prisma.employmentEvent.findFirst({ + where: { employeeId: emp.id, kind: LIFE_KIND.REGULARIZE }, + orderBy: { effectiveAt: 'desc' }, + }); + const slip = computePayslip({ + baseSalary: n(emp.baseSalary), + postSalary: n(emp.postSalary), + attendanceBonus: n(emp.attendanceBonus), + phoneSubsidy: n(emp.phoneSubsidy), + transportSubsidy: n(emp.transportSubsidy), + mealSubsidy: n(emp.mealSubsidy), + probationSalary: n(emp.probationSalary), + perfBase: n(emp.perfBase), + socialBase: n(emp.socialBase), + restSchedule: emp.restSchedule, + employmentStatus: emp.employmentStatus, + hiredAt: emp.hiredAt, + leftAt: emp.leftAt, + regularizeAt: reg?.effectiveAt || null, + yearMonth: item.run.yearMonth, + leaveDays: n(att?.leaveDays), + sickDays: n(att?.sickDays), + overtimeHours: overtime, + compLeaveDays: usedComp, + performanceRatio: ratio, + prevCompLeaveHours: Math.max(0, prevBalance), + }); + const updated = await this.prisma.payrollItem.update({ + where: { id }, + data: { + performanceRatio: slip.performanceRatio, + performancePay: slip.performancePay, + grossPay: slip.grossPay, + tax: slip.tax, + netPay: slip.netPay, + socialPersonal: slip.socialPersonal, + housingFund: slip.housingFund, + basePay: slip.basePay, + }, + include: { employee: { include: { department: true } } }, + }); + return this.serializeItem(updated); + } + + private assertLabor(user: AuthUser) { + if (canManageHr(user.roles, user.permissions) || canAccessContract(user.roles, user.permissions)) return; + throw new ForbiddenException('无权访问人事合同'); + } + + private laborInclude() { + return { + employee: { include: { department: true } }, + createdBy: { select: { id: true, displayName: true, username: true } }, + } as const; + } + + async listLaborContracts(user: AuthUser, keyword?: string) { + this.assertLabor(user); + const where: Prisma.LaborContractWhereInput = { tenantId: '1' }; + if (keyword?.trim()) { + where.OR = [ + { contractNo: { contains: keyword.trim() } }, + { remark: { contains: keyword.trim() } }, + { employee: { name: { contains: keyword.trim() } } }, + { employee: { employeeNo: { contains: keyword.trim() } } }, + ]; + } + const rows = await this.prisma.laborContract.findMany({ + where, + include: this.laborInclude(), + orderBy: { createdAt: 'desc' }, + }); + const files = await this.prisma.fileAsset.findMany({ + where: { tenantId: '1', bizType: 'EMPLOYEE_LABOR', bizId: { in: rows.map((r) => r.id) } }, + orderBy: { createdAt: 'asc' }, + }); + const byId = new Map(); + for (const f of files) { + const list = byId.get(f.bizId) || []; + list.push(f); + byId.set(f.bizId, list); + } + const employees = await this.prisma.employee.findMany({ + where: { tenantId: '1' }, + include: { department: true }, + orderBy: { employeeNo: 'asc' }, + }); + return { + items: rows.map((r) => ({ ...r, files: byId.get(r.id) || [] })), + employees: employees.map((e) => ({ + id: e.id, + name: e.name, + employeeNo: e.employeeNo, + department: e.department?.name, + contractStartAt: e.contractStartAt, + contractEndAt: e.contractEndAt, + })), + total: rows.length, + }; + } + + async getLaborContract(id: string, user: AuthUser) { + this.assertLabor(user); + const row = await this.prisma.laborContract.findFirst({ + where: { id, tenantId: '1' }, + include: this.laborInclude(), + }); + if (!row) throw new NotFoundException('人事合同不存在'); + const files = await this.prisma.fileAsset.findMany({ + where: { tenantId: '1', bizType: 'EMPLOYEE_LABOR', bizId: id }, + orderBy: { createdAt: 'asc' }, + }); + return { ...row, files }; + } + + async createLaborContract( + user: AuthUser, + dto: { employeeId: string; contractNo?: string; startAt?: string; endAt?: string; remark?: string }, + ) { + this.assertLabor(user); + if (!dto.employeeId) throw new BadRequestException('请选择员工'); + const emp = await this.assertEmployee(dto.employeeId); + const startAt = dto.startAt ? new Date(dto.startAt) : emp.contractStartAt; + const endAt = dto.endAt ? new Date(dto.endAt) : emp.contractEndAt; + const created = await this.prisma.laborContract.create({ + data: { + tenantId: '1', + employeeId: emp.id, + contractNo: dto.contractNo?.trim() || null, + startAt, + endAt, + remark: dto.remark?.trim() || null, + createdById: user.id, + }, + }); + if (startAt || endAt) { + await this.prisma.employee.update({ + where: { id: emp.id }, + data: { + contractStartAt: startAt ?? emp.contractStartAt, + contractEndAt: endAt ?? emp.contractEndAt, + }, + }); + } + return this.getLaborContract(created.id, user); + } + + async updateLaborContract( + id: string, + user: AuthUser, + dto: { contractNo?: string; startAt?: string | null; endAt?: string | null; remark?: string }, + ) { + this.assertLabor(user); + const row = await this.prisma.laborContract.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('人事合同不存在'); + const startAt = dto.startAt === undefined ? row.startAt : dto.startAt ? new Date(dto.startAt) : null; + const endAt = dto.endAt === undefined ? row.endAt : dto.endAt ? new Date(dto.endAt) : null; + await this.prisma.laborContract.update({ + where: { id }, + data: { + contractNo: dto.contractNo === undefined ? row.contractNo : dto.contractNo.trim() || null, + startAt, + endAt, + remark: dto.remark === undefined ? row.remark : dto.remark.trim() || null, + }, + }); + await this.prisma.employee.update({ + where: { id: row.employeeId }, + data: { contractStartAt: startAt, contractEndAt: endAt }, + }); + return this.getLaborContract(id, user); + } + + async deleteLaborContract(id: string, user: AuthUser) { + this.assertLabor(user); + const row = await this.prisma.laborContract.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('人事合同不存在'); + await this.prisma.fileAsset.deleteMany({ where: { tenantId: '1', bizType: 'EMPLOYEE_LABOR', bizId: id } }); + await this.prisma.laborContract.delete({ where: { id } }); + return { ok: true }; + } + + private async writeAttendance( + tx: Tx, + userId: string, + emp: { id: string; compLeaveHours: Prisma.Decimal }, + ym: string, + dto: { + workDays?: number; + leaveDays?: number; + sickDays?: number; + overtimeHours?: number; + compLeaveDays?: number; + remark?: string; + }, + ) { + const existing = await tx.attendanceMonth.findUnique({ + where: { employeeId_yearMonth: { employeeId: emp.id, yearMonth: ym } }, + }); + const next = { + workDays: numOr(dto.workDays, existing ? n(existing.workDays) : 0), + leaveDays: numOr(dto.leaveDays, existing ? n(existing.leaveDays) : 0), + sickDays: numOr(dto.sickDays, existing ? n(existing.sickDays) : 0), + overtimeHours: numOr(dto.overtimeHours, existing ? n(existing.overtimeHours) : 0), + compLeaveDays: numOr(dto.compLeaveDays, existing ? n(existing.compLeaveDays) : 0), + }; + const oldOt = existing ? n(existing.overtimeHours) : 0; + const oldComp = existing ? n(existing.compLeaveDays) : 0; + const current = n(emp.compLeaveHours); + const reversed = current - oldOt + oldComp * HOURS_PER_DAY; + const nextBalance = reversed + next.overtimeHours - next.compLeaveDays * HOURS_PER_DAY; + if (nextBalance < -0.001) throw new BadRequestException('调休余额不足,请少用调休或先导入加班小时'); + const row = existing + ? await tx.attendanceMonth.update({ + where: { id: existing.id }, + data: { ...next, remark: dto.remark?.trim() || existing.remark, importedById: userId, importedAt: new Date() }, + }) + : await tx.attendanceMonth.create({ + data: { + tenantId: '1', + employeeId: emp.id, + yearMonth: ym, + ...next, + remark: dto.remark?.trim() || null, + importedById: userId, + }, + }); + const updatedEmp = await tx.employee.update({ + where: { id: emp.id }, + data: { compLeaveHours: Math.max(0, nextBalance) }, + }); + return { + ...row, + workDays: n(row.workDays), + leaveDays: n(row.leaveDays), + sickDays: n(row.sickDays), + overtimeHours: n(row.overtimeHours), + compLeaveDays: n(row.compLeaveDays), + compLeaveHours: n(updatedEmp.compLeaveHours), + }; + } + + private filterPayrollItems( + user: AuthUser, + items: T[], + ) { + if (canSeeCompanyPayroll(user.roles)) return items; + if (!user.departmentId && !user.employeeId) return items; + return items.filter( + (i) => i.employee.departmentId === user.departmentId || reportsTo(i.employee, user.employeeId), + ); + } + + private assertCanAdjustEmployee(user: AuthUser, emp: { departmentId: string; managerId: string | null; managerIds?: string | null }) { + if (canManageHr(user.roles, user.permissions) || user.roles.includes('biz_director')) return; + const sameDept = user.departmentId && emp.departmentId === user.departmentId; + const isReport = reportsTo(emp, user.employeeId); + if (!sameDept && !isReport) throw new ForbiddenException('只能调整本部门或下属的绩效比例'); + } + + private async assertDept(id: string) { + const row = await this.prisma.department.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new BadRequestException('部门不存在'); + return row; + } + + private async assertEmployee(id: string) { + const row = await this.prisma.employee.findFirst({ where: { id, tenantId: '1' }, include: EMP_INCLUDE }); + if (!row) throw new NotFoundException('员工不存在'); + return row; + } + + private async assertDeptLead(employeeId: string, departmentId: string, selfId?: string) { + if (selfId && employeeId === selfId) throw new BadRequestException('不能把汇报对象设为自己'); + const emp = await this.prisma.employee.findFirst({ + where: { id: employeeId, tenantId: '1' }, + include: EMP_INCLUDE, + }); + if (!emp) throw new BadRequestException('所选人员不存在'); + if (emp.departmentId !== departmentId) { + throw new BadRequestException('只能选择本部门的项目经理或总监'); + } + if (!isDeptLead(emp)) { + throw new BadRequestException('请选择本部门的项目经理或总监'); + } + return emp; + } + + private async assertCcLeads( + dto: { ccDaily?: unknown; ccWeekly?: unknown; ccMonthly?: unknown }, + departmentId: string, + selfId?: string, + managerIds?: string | string[] | null, + ) { + const ids = [ + ...new Set( + (['ccDaily', 'ccWeekly', 'ccMonthly'] as const) + .flatMap((k) => { + const joined = joinPeopleIds(dto[k]); + return joined ? joined.split(',') : []; + }), + ), + ]; + if (!ids.length) return; + const mgrSet = new Set(peopleIdList(managerIds)); + const chain = await ancestorDeptIds(this.prisma, departmentId); + const inChain = await this.prisma.employee.findMany({ + where: { tenantId: '1', departmentId: { in: chain } }, + include: EMP_INCLUDE, + }); + const allowedDeptIds: string[] = []; + for (const deptId of chain) { + allowedDeptIds.push(deptId); + if (inChain.some((e) => e.departmentId === deptId && e.id !== selfId && isDirectorLead(e))) break; + } + const allowed = new Set(allowedDeptIds); + for (const id of ids) { + if (selfId && id === selfId) throw new BadRequestException('抄送人不能选自己'); + if (mgrSet.has(id)) { + throw new BadRequestException('抄送人不能和汇报对象相同,普通员工汇报给项目经理、抄送给总监'); + } + const emp = inChain.find((e) => e.id === id) || (await this.prisma.employee.findFirst({ + where: { id, tenantId: '1' }, + include: EMP_INCLUDE, + })); + if (!emp) throw new BadRequestException('所选抄送人不存在'); + if (!allowed.has(emp.departmentId) || !isDirectorLead(emp)) { + throw new BadRequestException('抄送人只能选本部门或上级部门的总监,不能选项目经理'); + } + } + } + + private async attachManagerNames( + items: T[], + ): Promise<(T & { managerIds: string[]; managerName?: string })[]> { + const allIds = new Set(); + for (const i of items) { + for (const id of managerIdList(i)) allIds.add(id); + } + if (!allIds.size) { + return items.map((i) => ({ ...i, managerIds: managerIdList(i), managerName: i.managerName || undefined })); + } + const people = await this.prisma.employee.findMany({ + where: { id: { in: [...allIds] } }, + select: { id: true, name: true }, + }); + const names = new Map(people.map((p) => [p.id, p.name])); + return items.map((i) => { + const ordered = managerIdList(i); + return { + ...i, + managerIds: ordered, + managerName: ordered.map((id) => names.get(id)).filter(Boolean).join('、') || i.managerName || undefined, + }; + }); + } + + private async attachPhotos( + items: Array & { id: string }>, + ) { + const userIds = items.flatMap((i) => (i.accounts || []).map((a) => a.id)); + const empIds = items.map((i) => String(i.id)); + if (!userIds.length && !empIds.length) return items; + const files = await this.prisma.fileAsset.findMany({ + where: { + tenantId: '1', + OR: [ + userIds.length ? { bizType: 'USER_ID_PHOTO', bizId: { in: userIds } } : undefined, + empIds.length + ? { bizType: { in: ['EMPLOYEE_ID_PHOTO', 'EMPLOYEE_ID_FRONT', 'EMPLOYEE_ID_BACK', 'EMPLOYEE_CERT'] }, bizId: { in: empIds } } + : undefined, + ].filter(Boolean) as Prisma.FileAssetWhereInput[], + }, + orderBy: { createdAt: 'desc' }, + }); + const latest = new Map(); + const certs = new Map(); + for (const f of files) { + const key = `${f.bizType}:${f.bizId}`; + if (!latest.has(key)) latest.set(key, f.id); + if (f.bizType === 'EMPLOYEE_CERT') { + const list = certs.get(f.bizId) || []; + list.push({ id: f.id, fileName: f.fileName, size: f.size, mimeType: f.mimeType, bizType: f.bizType }); + certs.set(f.bizId, list); + } + } + return items.map((i) => { + const fromUser = (i.accounts || []).map((a) => latest.get(`USER_ID_PHOTO:${a.id}`)).find(Boolean); + return { + ...i, + userId: i.accounts?.[0]?.id || null, + idPhotoFileId: fromUser || latest.get(`EMPLOYEE_ID_PHOTO:${String(i.id)}`) || null, + idFrontFileId: latest.get(`EMPLOYEE_ID_FRONT:${String(i.id)}`) || null, + idBackFileId: latest.get(`EMPLOYEE_ID_BACK:${String(i.id)}`) || null, + certFiles: certs.get(String(i.id)) || [], + }; + }); + } + + private serializeEmployee( + row: { + baseSalary: Prisma.Decimal; + postSalary?: Prisma.Decimal; + attendanceBonus?: Prisma.Decimal; + phoneSubsidy?: Prisma.Decimal; + transportSubsidy?: Prisma.Decimal; + mealSubsidy?: Prisma.Decimal; + probationSalary?: Prisma.Decimal; + perfBase: Prisma.Decimal; + socialBase: Prisma.Decimal; + compLeaveHours: Prisma.Decimal; + department?: { name: string } | null; + manager?: { name: string } | null; + users?: { id: string; username: string; status: string; roles?: { role: { code: string } }[] }[]; + mobile?: string | null; + idNo?: string | null; + bankAccount?: string | null; + } & Record, + maskSensitive = false, + ) { + const users = row.users || []; + return { + ...row, + id: String((row as unknown as { id: string }).id), + baseSalary: n(row.baseSalary), + postSalary: n(row.postSalary), + attendanceBonus: n(row.attendanceBonus), + phoneSubsidy: n(row.phoneSubsidy), + transportSubsidy: n(row.transportSubsidy), + mealSubsidy: n(row.mealSubsidy), + probationSalary: n(row.probationSalary), + perfBase: n(row.perfBase), + socialBase: n(row.socialBase), + compLeaveHours: n(row.compLeaveHours), + departmentName: row.department?.name, + managerIds: managerIdList(row as { managerId?: string | null; managerIds?: unknown }), + managerName: row.manager?.name, + accountUsername: users[0]?.username || null, + accountRoles: accountRolesOf(users), + accounts: users, + mobile: row.mobile, + idNo: maskSensitive ? maskValue(row.idNo) : row.idNo, + bankAccount: maskSensitive ? maskValue(row.bankAccount) : row.bankAccount, + }; + } + + private serializeItem(row: { + employee: { name: string; employeeNo: string; restSchedule: string; employmentStatus: string; department?: { name: string } | null }; + prorationFactor: Prisma.Decimal; + scheduledDays: Prisma.Decimal; + dailyRate: Prisma.Decimal; + basePay: Prisma.Decimal; + performancePay: Prisma.Decimal; + performanceRatio: Prisma.Decimal; + leaveDeduct: Prisma.Decimal; + sickDeduct: Prisma.Decimal; + socialPersonal: Prisma.Decimal; + housingFund: Prisma.Decimal; + tax: Prisma.Decimal; + grossPay: Prisma.Decimal; + netPay: Prisma.Decimal; + overtimeHours: Prisma.Decimal; + compLeaveUsedDays: Prisma.Decimal; + compLeaveHours: Prisma.Decimal; + id: string; + employeeId: string; + }) { + return { + id: row.id, + employeeId: row.employeeId, + name: row.employee.name, + employeeNo: row.employee.employeeNo, + departmentName: row.employee.department?.name, + restSchedule: row.employee.restSchedule, + employmentStatus: row.employee.employmentStatus, + prorationFactor: n(row.prorationFactor), + scheduledDays: n(row.scheduledDays), + dailyRate: n(row.dailyRate), + basePay: n(row.basePay), + performancePay: n(row.performancePay), + performanceRatio: n(row.performanceRatio), + leaveDeduct: n(row.leaveDeduct), + sickDeduct: n(row.sickDeduct), + socialPersonal: n(row.socialPersonal), + housingFund: n(row.housingFund), + tax: n(row.tax), + grossPay: n(row.grossPay), + netPay: n(row.netPay), + overtimeHours: n(row.overtimeHours), + compLeaveUsedDays: n(row.compLeaveUsedDays), + compLeaveHours: n(row.compLeaveHours), + }; + } +} + +function assertYearMonthSafe(ym: string) { + try { + return assertYearMonth(ym); + } catch (e) { + throw new BadRequestException((e as Error).message); + } +} + +function kindLabel(kind: string) { + return HR_APPLY_LABEL[kind] || kind; +} + +function numOr(value: number | undefined, fallback: number) { + if (value === undefined || value === null || Number.isNaN(Number(value))) return fallback; + return Math.max(0, Number(value)); +} diff --git a/apps/api/src/im/im-bus.ts b/apps/api/src/im/im-bus.ts new file mode 100644 index 0000000..e62d39a --- /dev/null +++ b/apps/api/src/im/im-bus.ts @@ -0,0 +1,22 @@ +export type ImPushPayload = { + userIds: string[]; + kind: 'chat' | 'todo' | 'approval'; + title: string; + body?: string; + bizType?: string; + bizId?: string; + fromUserId?: string; +}; + +type ImHandler = (payload: ImPushPayload) => Promise; + +let handler: ImHandler | null = null; + +export function registerImHandler(fn: ImHandler) { + handler = fn; +} + +export function emitIm(payload: ImPushPayload) { + if (!handler || !payload.userIds?.length) return; + void handler(payload).catch(() => undefined); +} diff --git a/apps/api/src/im/im.controller.ts b/apps/api/src/im/im.controller.ts new file mode 100644 index 0000000..9d30ff9 --- /dev/null +++ b/apps/api/src/im/im.controller.ts @@ -0,0 +1,202 @@ +import { Body, Controller, Get, Headers, Param, Patch, Post, Query, UnauthorizedException } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Public } from '../common/decorators/auth.decorators'; +import { CurrentUser, type AuthUser } from '../common/decorators/current-user.decorator'; +import { SkipThrottle } from '@nestjs/throttler'; +import { ImService } from './im.service'; + +@ApiTags('im') +@ApiBearerAuth() +@SkipThrottle() +@Controller('im') +export class ImController { + constructor(private readonly im: ImService) {} + + @Get('status') + status() { + return this.im.status(); + } + + @Get('conversations') + conversations(@CurrentUser() user: AuthUser) { + return this.im.conversations(user); + } + + @Get('messages') + messages( + @CurrentUser() user: AuthUser, + @Query('peerId') peerId?: string, + @Query('conversationId') conversationId?: string, + @Query('sinceSeq') sinceSeq?: string, + ) { + const seq = sinceSeq ? Number(sinceSeq) : undefined; + return this.im.messages(user, peerId, conversationId, seq && seq > 0 ? seq : undefined); + } + + @Post('messages') + send( + @CurrentUser() user: AuthUser, + @Body() + body: { + peerId?: string; + conversationId?: string; + body?: string; + fingerprint?: string; + contentType?: string; + meta?: unknown; + kind?: string; + }, + ) { + return this.im.sendChat(user, body); + } + + @Patch('messages/:id') + edit( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { body?: string }, + ) { + return this.im.editMessage(user, id, body.body || ''); + } + + @Post('messages/:id/recall') + recall(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.im.recallMessage(user, id); + } + + @Post('groups') + createGroup( + @CurrentUser() user: AuthUser, + @Body() body: { name?: string; memberIds?: string[] }, + ) { + return this.im.createGroup(user, body.name || '', body.memberIds || []); + } + + @Get('groups/:id/members') + groupMembers(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.im.groupMembers(user, id); + } + + @Post('groups/:id/members') + addMembers( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { memberIds?: string[] }, + ) { + return this.im.addMembers(user, id, body.memberIds || []); + } + + @Post('events/screenshot') + screenshot( + @CurrentUser() user: AuthUser, + @Body() body: { conversationId?: string; peerId?: string }, + ) { + return this.im.screenshot(user, body.conversationId, body.peerId); + } + + @Post('read') + markRead( + @CurrentUser() user: AuthUser, + @Body() body: { conversationId?: string; peerId?: string }, + ) { + return this.im.markRead(user, body.conversationId, body.peerId); + } + + @Post('voice/transcribe') + transcribeVoice(@CurrentUser() user: AuthUser, @Body() body: { fileId?: string }) { + return this.im.transcribeVoice(user, body.fileId || ''); + } + + @Get('messages/:id/receipts') + receipts(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.im.receipts(user, id); + } + + @Get('calls/incoming') + incoming(@CurrentUser() user: AuthUser) { + return this.im.incomingCalls(user); + } + + @Post('calls') + createCall( + @CurrentUser() user: AuthUser, + @Body() body: { conversationId?: string; peerId?: string; kind?: string; title?: string }, + ) { + return this.im.createCall(user, body); + } + + @Get('calls/:id') + callStatus(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.im.callStatus(user, id); + } + + @Post('calls/:id/livekit-token') + livekitToken(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.im.livekitToken(user, id); + } + + @Post('calls/:id/signal') + signal( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { toUserId?: string; kind?: string; payload?: unknown }, + ) { + return this.im.postSignal(user, id, body); + } + + @Post('calls/:id/:action') + callAction( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Param('action') action: 'join' | 'reject' | 'end', + ) { + return this.im.callAction(user, id, action); + } + + @Get('calls/:id/signals') + signals( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Query('after') after?: string, + ) { + return this.im.pullSignals(user, id, after); + } + + @Public() + @Get('internal/members/:id') + internalMembers(@Headers('x-im-token') token: string, @Param('id') id: string) { + const expected = process.env.IM_INTERNAL_TOKEN || ''; + if (!expected || token !== expected) throw new UnauthorizedException('IM 内部令牌无效'); + return this.im.internalMembers(id); + } + + @Public() + @Post('internal/ingest') + ingest( + @Headers('x-im-token') token: string, + @Body() + body: { + fromUserId?: string; + toUserId?: string; + conversationId?: string; + kind?: string; + contentType?: string; + body?: string; + meta?: unknown; + fingerprint?: string; + }, + ) { + const expected = process.env.IM_INTERNAL_TOKEN || ''; + if (!expected || token !== expected) throw new UnauthorizedException('IM 内部令牌无效'); + return this.im.ingest({ + fromUserId: body.fromUserId || '', + toUserId: body.toUserId || '', + conversationId: body.conversationId, + kind: body.kind, + contentType: body.contentType, + body: body.body || '', + meta: body.meta, + fingerprint: body.fingerprint || '', + }); + } +} diff --git a/apps/api/src/im/im.module.ts b/apps/api/src/im/im.module.ts new file mode 100644 index 0000000..7b0c4c0 --- /dev/null +++ b/apps/api/src/im/im.module.ts @@ -0,0 +1,15 @@ +import { Global, Module } from '@nestjs/common'; +import { ImController } from './im.controller'; +import { ImService } from './im.service'; +import { PushModule } from '../push/push.module'; +import { VoiceAsrService } from './voice-asr.service'; +import { FilesModule } from '../files/files.module'; + +@Global() +@Module({ + imports: [PushModule, FilesModule], + controllers: [ImController], + providers: [ImService, VoiceAsrService], + exports: [ImService], +}) +export class ImModule {} diff --git a/apps/api/src/im/im.service.ts b/apps/api/src/im/im.service.ts new file mode 100644 index 0000000..3a0768c --- /dev/null +++ b/apps/api/src/im/im.service.ts @@ -0,0 +1,998 @@ +import { BadRequestException, ConflictException, ForbiddenException, Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { AccessToken } from 'livekit-server-sdk'; +import { PrismaService } from '../prisma/prisma.service'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { emitIm, registerImHandler, type ImPushPayload } from './im-bus'; +import { PushService } from '../push/push.service'; +import { FilesService } from '../files/files.service'; +import { VoiceAsrService } from './voice-asr.service'; + +function pair(a: string, b: string) { + return a < b ? [a, b] : [b, a]; +} + +function preview(kind: string, contentType: string, body: string) { + if (contentType === 'location') return '[位置]'; + if (contentType === 'call') return '[通话]'; + if (contentType === 'image') return '[图片]'; + if (contentType === 'sticker') return '[表情]'; + if (contentType === 'voice') return '[语音]'; + if (contentType === 'file') return '[文件]'; + if (kind === 'screenshot') return '[截屏通知]'; + return body.slice(0, 80); +} + +async function avatarByUserIds(prisma: PrismaService, userIds: string[]) { + const ids = [...new Set(userIds.filter(Boolean))]; + if (!ids.length) return new Map(); + const rows = await prisma.fileAsset.findMany({ + where: { tenantId: '1', bizType: 'USER_AVATAR', bizId: { in: ids } }, + select: { id: true, bizId: true }, + orderBy: { createdAt: 'desc' }, + }); + const map = new Map(); + for (const r of rows) { + if (!map.has(r.bizId)) map.set(r.bizId, r.id); + } + return map; +} + +@Injectable() +export class ImService implements OnModuleInit { + private readonly log = new Logger(ImService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly push: PushService, + private readonly files: FilesService, + private readonly asr: VoiceAsrService, + ) {} + + onModuleInit() { + registerImHandler((payload) => this.pushEvent(payload)); + } + + async status() { + let online = 0; + try { + const port = Number(process.env.IM_HTTP_PORT || 8902); + const res = await fetch(`http://127.0.0.1:${port}/health`); + if (res.ok) { + const json = (await res.json()) as { online?: number }; + online = Number(json.online || 0); + } + } catch { + /* IM 未启动 */ + } + return { + id: 'channel', + inboxEnabled: '已开启', + mobileImSdk: '已启用', + tcpPort: Number(process.env.IM_TCP_PORT || 8901), + httpPort: Number(process.env.IM_HTTP_PORT || 8902), + online, + stun: 'stun:stun.qq.com:3478', + iceServers: [ + { urls: 'stun:stun.qq.com:3478' }, + { urls: 'stun:stun.miwifi.com:3478' }, + { urls: 'stun:stun.l.google.com:19302' }, + ], + note: '私聊/群聊、已读、定位、音视频信令。客户端先登录 OA,再用 accessToken 连 IM。', + }; + } + + async conversations(user: AuthUser) { + await this.backfillDirectMembers(); + const memberOf = await this.prisma.imConversationMember.findMany({ + where: { userId: user.id, leftAt: null }, + select: { conversationId: true }, + }); + const ids = [...new Set(memberOf.map((m) => m.conversationId))]; + const legacy = await this.prisma.imConversation.findMany({ + where: { + tenantId: '1', + type: 'direct', + OR: [{ userAId: user.id }, { userBId: user.id }], + }, + select: { id: true }, + }); + const allIds = [...new Set([...ids, ...legacy.map((r) => r.id)])]; + if (!allIds.length) return []; + const rows = await this.prisma.imConversation.findMany({ + where: { id: { in: allIds } }, + orderBy: { lastAt: 'desc' }, + take: 200, + }); + const members = await this.prisma.imConversationMember.findMany({ + where: { conversationId: { in: rows.map((r) => r.id) }, leftAt: null }, + }); + const byConv = new Map(); + for (const m of members) { + const list = byConv.get(m.conversationId) || []; + list.push(m.userId); + byConv.set(m.conversationId, list); + } + const peerIds = [ + ...new Set( + rows.flatMap((r) => { + if (r.type === 'group') return []; + const idsIn = byConv.get(r.id) || [r.userAId, r.userBId].filter(Boolean) as string[]; + return idsIn.filter((id) => id && id !== user.id); + }), + ), + ]; + const people = await this.prisma.user.findMany({ + where: { id: { in: [...peerIds, ...rows.map((r) => r.ownerId).filter(Boolean) as string[]] } }, + select: { id: true, displayName: true, username: true }, + }); + const names = new Map(people.map((p) => [p.id, p.displayName || p.username])); + const avatars = await avatarByUserIds(this.prisma, [...peerIds, user.id]); + const cursors = await this.prisma.imReadCursor.findMany({ + where: { userId: user.id, conversationId: { in: rows.map((r) => r.id) } }, + }); + const lastRead = new Map(cursors.map((c) => [c.conversationId, c.lastReadAt])); + const lastReadSeq = new Map(cursors.map((c) => [c.conversationId, c.lastReadSeq])); + const unreadCounts = await Promise.all( + rows.map((r) => + this.prisma.imMessage.count({ + where: { + conversationId: r.id, + fromUserId: { not: user.id }, + seq: { gt: lastReadSeq.get(r.id) || 0 }, + }, + }), + ), + ); + return rows.map((r, i) => { + const memberIds = byConv.get(r.id) || []; + const peerId = + r.type === 'group' + ? '' + : memberIds.find((id) => id !== user.id) || + (r.userAId === user.id ? r.userBId : r.userAId) || + ''; + return { + id: r.id, + type: r.type, + name: r.type === 'group' ? r.name || '群聊' : peerId === '0' ? '系统通知' : names.get(peerId) || '同事', + peerId, + peerName: r.type === 'group' ? r.name || '群聊' : peerId === '0' ? '系统通知' : names.get(peerId) || '同事', + ownerId: r.ownerId, + memberCount: memberIds.length, + lastText: r.lastText, + lastAt: r.lastAt, + unread: unreadCounts[i], + avatarFileId: r.type === 'group' ? null : avatars.get(peerId) || null, + }; + }); + } + + async messages(user: AuthUser, peerId?: string, conversationId?: string, sinceSeq?: number) { + const conv = conversationId + ? await this.requireMember(conversationId, user.id) + : await this.ensureDirect(user.id, peerId || ''); + const gapFill = sinceSeq != null && sinceSeq > 0; + const items = await this.prisma.imMessage.findMany({ + where: { + conversationId: conv.id, + ...(gapFill ? { seq: { gt: sinceSeq } } : {}), + }, + orderBy: { seq: gapFill ? 'asc' : 'desc' }, + take: gapFill ? 500 : 300, + }); + if (!gapFill) items.reverse(); + if (!gapFill) { + await this.prisma.imReadCursor.upsert({ + where: { userId_conversationId: { userId: user.id, conversationId: conv.id } }, + create: { userId: user.id, conversationId: conv.id, lastReadAt: new Date() }, + update: { lastReadAt: new Date() }, + }); + const latestSeq = items.length ? items[items.length - 1].seq : 0; + if (latestSeq > 0) { + await this.prisma.imReadCursor.update({ + where: { userId_conversationId: { userId: user.id, conversationId: conv.id } }, + data: { lastReadSeq: latestSeq }, + }); + } + } + const ids = items.map((m) => m.id); + const created = !gapFill && ids.length + ? await this.prisma.imMessageReceipt.createMany({ + data: ids + .filter((id) => items.find((m) => m.id === id)?.fromUserId !== user.id) + .map((messageId) => ({ messageId, userId: user.id })), + skipDuplicates: true, + }) + : { count: 0 }; + if (created.count > 0) { + const senderIds = [ + ...new Set(items.filter((m) => m.fromUserId && m.fromUserId !== user.id).map((m) => m.fromUserId)), + ]; + await this.fanout(senderIds, { + kind: 'receipt', + conversationId: conv.id, + fromUserId: user.id, + }); + } + const receipts = ids.length + ? await this.prisma.imMessageReceipt.findMany({ where: { messageId: { in: ids } } }) + : []; + const byMsg = new Map(); + for (const r of receipts) { + const list = byMsg.get(r.messageId) || []; + list.push(r.userId); + byMsg.set(r.messageId, list); + } + const memberCount = await this.prisma.imConversationMember.count({ + where: { conversationId: conv.id, leftAt: null }, + }); + const fromIds = [...new Set(items.map((m) => m.fromUserId).filter(Boolean))]; + const senders = fromIds.length + ? await this.prisma.user.findMany({ + where: { id: { in: fromIds } }, + select: { id: true, displayName: true, username: true }, + }) + : []; + const senderNames = new Map(senders.map((p) => [p.id, p.displayName || p.username])); + const avatars = await avatarByUserIds(this.prisma, fromIds); + return { + conversationId: conv.id, + type: conv.type, + name: conv.name, + memberCount, + items: items.map((m) => { + let meta: Record = {}; + try { meta = JSON.parse(m.meta || '{}') as Record; } catch { /* empty */ } + return { + ...m, + recalledAt: meta.recalledAt || null, + fromName: senderNames.get(m.fromUserId) || '', + fromAvatarFileId: avatars.get(m.fromUserId) || null, + readBy: byMsg.get(m.id) || [], + readCount: (byMsg.get(m.id) || []).length, + }; + }), + }; + } + + async markRead(user: AuthUser, conversationId?: string, peerId?: string) { + const conv = conversationId + ? await this.requireMember(conversationId, user.id) + : await this.ensureDirect(user.id, peerId || ''); + const latest = await this.prisma.imMessage.findFirst({ + where: { conversationId: conv.id }, + orderBy: { seq: 'desc' }, + select: { seq: true }, + }); + const lastSeq = latest?.seq || 0; + const others = await this.prisma.imMessage.findMany({ + where: { conversationId: conv.id, NOT: { fromUserId: user.id } }, + select: { id: true, fromUserId: true }, + orderBy: { seq: 'desc' }, + take: 300, + }); + await this.prisma.imReadCursor.upsert({ + where: { userId_conversationId: { userId: user.id, conversationId: conv.id } }, + create: { userId: user.id, conversationId: conv.id, lastReadAt: new Date(), lastReadSeq: lastSeq }, + update: { lastReadAt: new Date(), lastReadSeq: lastSeq }, + }); + await this.forwardTcp(user.id, { + kind: 'readSync', + conversationId: conv.id, + lastReadSeq: lastSeq, + userId: user.id, + }); + if (!others.length) return { count: 0 }; + const created = await this.prisma.imMessageReceipt.createMany({ + data: others.map((m) => ({ messageId: m.id, userId: user.id })), + skipDuplicates: true, + }); + if (created.count > 0) { + await this.fanout( + [...new Set(others.map((m) => m.fromUserId).filter(Boolean))], + { kind: 'receipt', conversationId: conv.id, fromUserId: user.id }, + ); + } + return { count: created.count }; + } + + async transcribeVoice(user: AuthUser, fileId: string) { + if (!fileId) throw new BadRequestException('fileId 无效'); + const { row, stream } = await this.files.download(fileId, user); + if (row.bizType !== 'IM_VOICE') throw new BadRequestException('不是语音附件'); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const text = await this.asr.transcribe(Buffer.concat(chunks)); + return { text }; + } + + async sendChat( + user: AuthUser, + dto: { + peerId?: string; + conversationId?: string; + body?: string; + fingerprint?: string; + contentType?: string; + meta?: unknown; + kind?: string; + }, + ) { + const contentType = dto.contentType || 'text'; + const text = (dto.body || '').trim(); + const media = new Set(['image', 'sticker', 'voice', 'location', 'call', 'file', 'business']); + if (contentType === 'text' && !text) throw new BadRequestException('请填写消息'); + if (!media.has(contentType) && contentType !== 'text' && contentType !== 'system' && !text) { + throw new BadRequestException('请填写消息'); + } + const conv = dto.conversationId + ? await this.requireMember(dto.conversationId, user.id) + : await this.ensureDirect(user.id, dto.peerId || ''); + if (contentType === 'business') { + const meta = dto.meta && typeof dto.meta === 'object' ? dto.meta as Record : {}; + const path = String(meta.fetchPath || ''); + if (!path.startsWith('/') || path.includes('..') || path.includes('?')) { + throw new BadRequestException('业务卡片地址无效'); + } + } + const members = await this.memberIds(conv.id); + const peerId = members.find((id) => id !== user.id) || dto.peerId || ''; + const saved = await this.ingest({ + fromUserId: user.id, + toUserId: conv.type === 'group' ? '' : peerId, + conversationId: conv.id, + kind: dto.kind || 'chat', + contentType, + body: text || preview(dto.kind || 'chat', contentType, ''), + meta: dto.meta, + fingerprint: dto.fingerprint || randomUUID(), + }); + const payload = { + kind: dto.kind || 'chat', + contentType, + text: saved.body, + meta: dto.meta || null, + fromUserId: user.id, + fromName: user.displayName, + conversationId: conv.id, + fingerprint: saved.fingerprint, + seq: saved.seq, + }; + await this.fanout(members.filter((id) => id !== user.id), payload); + return saved; + } + + async editMessage(user: AuthUser, id: string, body: string) { + const text = body.trim(); + if (!text) throw new BadRequestException('请填写消息'); + const row = await this.prisma.imMessage.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new BadRequestException('消息不存在'); + if (row.fromUserId !== user.id) throw new ForbiddenException('只能改自己发的消息'); + let recalled = false; + try { + recalled = Boolean(JSON.parse(row.meta || '{}').recalledAt); + } catch { + recalled = false; + } + if (row.contentType !== 'text' && !recalled) throw new BadRequestException('这类消息不能改文字'); + if (!recalled && Date.now() - row.createdAt.getTime() > 2 * 60 * 1000) { + throw new BadRequestException('超过两分钟不能再改'); + } + const next = await this.prisma.imMessage.update({ + where: { id }, + data: { body: text, contentType: 'text', meta: recalled ? '' : row.meta }, + }); + await this.prisma.imConversation.update({ + where: { id: row.conversationId }, + data: { lastText: preview(next.kind, next.contentType, text), lastAt: new Date() }, + }); + return next; + } + + async recallMessage(user: AuthUser, id: string) { + const row = await this.prisma.imMessage.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new BadRequestException('消息不存在'); + await this.requireMember(row.conversationId, user.id); + if (row.fromUserId !== user.id) throw new ForbiddenException('只能撤回自己发的消息'); + if (row.contentType !== 'text') throw new BadRequestException('仅文字消息支持撤回'); + if (Date.now() - row.createdAt.getTime() > 2 * 60 * 1000) { + throw new BadRequestException('超过两分钟不能撤回'); + } + const meta = { recalledAt: new Date().toISOString(), originalBody: row.body }; + const next = await this.prisma.imMessage.update({ + where: { id }, + data: { body: '你撤回了一条消息', contentType: 'system', meta: JSON.stringify(meta) }, + }); + await this.prisma.imConversation.update({ + where: { id: row.conversationId }, + data: { lastText: '你撤回了一条消息', lastAt: new Date() }, + }); + const members = await this.memberIds(row.conversationId); + await this.fanout(members.filter((memberId) => memberId !== user.id), { + kind: 'chat', + contentType: 'system', + text: `${user.displayName} 撤回了一条消息`, + conversationId: row.conversationId, + fromUserId: user.id, + messageId: row.id, + action: 'recall', + }); + return next; + } + + async createGroup(user: AuthUser, name: string, memberIds: string[]) { + const title = name.trim(); + if (!title) throw new BadRequestException('请填写群名称'); + const uniq = [...new Set([user.id, ...memberIds.filter(Boolean)])]; + if (uniq.length < 3) throw new BadRequestException('群聊至少需要 3 人'); + const conv = await this.prisma.imConversation.create({ + data: { + type: 'group', + name: title, + ownerId: user.id, + lastText: `${user.displayName} 创建了群聊`, + lastAt: new Date(), + }, + }); + await this.prisma.imConversationMember.createMany({ + data: uniq.map((userId) => ({ + conversationId: conv.id, + userId, + role: userId === user.id ? 'owner' : 'member', + })), + }); + await this.ingest({ + fromUserId: user.id, + toUserId: '', + conversationId: conv.id, + kind: 'system', + contentType: 'system', + body: `${user.displayName} 创建了群聊「${title}」`, + fingerprint: randomUUID(), + }); + await this.fanout( + uniq.filter((id) => id !== user.id), + { + kind: 'system', + text: `${user.displayName} 邀请你加入群聊「${title}」`, + conversationId: conv.id, + fromUserId: user.id, + }, + ); + return conv; + } + + async groupMembers(user: AuthUser, conversationId: string) { + await this.requireMember(conversationId, user.id); + const rows = await this.prisma.imConversationMember.findMany({ + where: { conversationId, leftAt: null }, + }); + const people = await this.prisma.user.findMany({ + where: { id: { in: rows.map((r) => r.userId) } }, + select: { id: true, displayName: true, username: true }, + }); + const names = new Map(people.map((p) => [p.id, p.displayName || p.username])); + const avatars = await avatarByUserIds(this.prisma, rows.map((r) => r.userId)); + return rows.map((r) => ({ + ...r, + id: r.userId, + name: names.get(r.userId) || '同事', + avatarFileId: avatars.get(r.userId) || null, + })); + } + + async addMembers(user: AuthUser, conversationId: string, memberIds: string[]) { + const conv = await this.requireMember(conversationId, user.id); + if (conv.type !== 'group') throw new BadRequestException('只有群聊可以拉人'); + const uniq = [...new Set(memberIds.filter(Boolean))]; + await this.prisma.imConversationMember.createMany({ + data: uniq.map((userId) => ({ conversationId, userId, role: 'member' })), + skipDuplicates: true, + }); + return this.groupMembers(user, conversationId); + } + + async screenshot(user: AuthUser, conversationId?: string, peerId?: string) { + const conv = conversationId + ? await this.requireMember(conversationId, user.id) + : await this.ensureDirect(user.id, peerId || ''); + const members = await this.memberIds(conv.id); + const body = `${user.displayName} 截屏了`; + await this.ingest({ + fromUserId: user.id, + toUserId: '', + conversationId: conv.id, + kind: 'screenshot', + contentType: 'system', + body, + fingerprint: randomUUID(), + }); + await this.fanout( + members.filter((id) => id !== user.id), + { kind: 'screenshot', text: body, conversationId: conv.id, fromUserId: user.id }, + ); + return { ok: true }; + } + + async receipts(user: AuthUser, messageId: string) { + const msg = await this.prisma.imMessage.findFirst({ where: { id: messageId } }); + if (!msg) throw new NotFoundLike(); + await this.requireMember(msg.conversationId, user.id); + const rows = await this.prisma.imMessageReceipt.findMany({ where: { messageId } }); + const people = await this.prisma.user.findMany({ + where: { id: { in: rows.map((r) => r.userId) } }, + select: { id: true, displayName: true }, + }); + return people.map((p) => ({ userId: p.id, name: p.displayName })); + } + + async createCall(user: AuthUser, dto: { conversationId?: string; peerId?: string; kind?: string; title?: string }) { + await this.expireRingingCalls(); + const kind = dto.kind === 'audio' ? 'audio' : dto.kind === 'meeting' ? 'meeting' : 'video'; + const conv = dto.conversationId + ? await this.requireMember(dto.conversationId, user.id) + : dto.peerId + ? await this.ensureDirect(user.id, dto.peerId) + : null; + const memberIds = conv ? await this.memberIds(conv.id) : [user.id, dto.peerId || ''].filter(Boolean); + const others = memberIds.filter((id) => id !== user.id); + if (!others.length) throw new BadRequestException('请选择通话对象'); + const call = await this.prisma.$transaction(async (tx) => { + const lock = (tx as unknown as { $queryRawUnsafe: (sql: string, ...args: unknown[]) => Promise }).$queryRawUnsafe; + for (const id of [...new Set(memberIds)].sort()) { + await lock.call(tx, 'SELECT pg_advisory_xact_lock(hashtext($1))', `fy-im-call:${id}`); + } + const activeCalls = await tx.imCall.findMany({ + where: { status: { in: ['ringing', 'active'] } }, + select: { id: true }, + }); + const busy = activeCalls.length + ? await tx.imCallMember.findFirst({ + where: { + callId: { in: activeCalls.map((item) => item.id) }, + userId: { in: memberIds }, + status: { in: ['invited', 'joined'] }, + }, + }) + : null; + if (busy) { + if (busy.userId === user.id) throw new ConflictException('您正在通话中'); + throw new ConflictException('对方正在通话中'); + } + const created = await tx.imCall.create({ + data: { + conversationId: conv?.id, + kind, + status: 'ringing', + initiatorId: user.id, + title: dto.title?.trim() || (kind === 'meeting' ? '会议' : kind === 'audio' ? '语音通话' : '视频通话'), + }, + }); + await tx.imCallMember.createMany({ + data: memberIds.map((userId) => ({ + callId: created.id, + userId, + status: userId === user.id ? 'joined' : 'invited', + joinedAt: userId === user.id ? new Date() : null, + })), + }); + return created; + }); + if (conv) { + await this.ingest({ + fromUserId: user.id, + toUserId: conv.type === 'group' ? '' : others[0] || '', + conversationId: conv.id, + kind: 'chat', + contentType: 'call', + body: `${user.displayName} 发起了${call.title}`, + meta: { callId: call.id, callKind: kind }, + fingerprint: randomUUID(), + }); + } + await this.fanout(others, { + kind: 'call', + contentType: 'call', + text: `${user.displayName} 发起了${call.title}`, + conversationId: conv?.id, + callId: call.id, + callKind: kind, + fromUserId: user.id, + fromName: user.displayName, + memberIds, + }); + return { ...call, memberIds }; + } + + async incomingCalls(user: AuthUser) { + const staleAt = new Date(Date.now() - 45 * 1000); + await this.expireRingingCalls(); + const rows = await this.prisma.imCallMember.findMany({ + where: { userId: user.id, status: 'invited' }, + }); + const calls = await this.prisma.imCall.findMany({ + where: { + id: { in: rows.map((r) => r.callId) }, + status: 'ringing', + createdAt: { gte: staleAt }, + }, + orderBy: { createdAt: 'desc' }, + take: 5, + }); + const people = await this.prisma.user.findMany({ + where: { id: { in: [...new Set(calls.map((c) => c.initiatorId))] } }, + select: { id: true, displayName: true }, + }); + const names = new Map(people.map((p) => [p.id, p.displayName])); + return Promise.all( + calls.map(async (c) => ({ + ...c, + title: c.title || names.get(c.initiatorId) || '来电', + fromName: names.get(c.initiatorId) || '', + memberIds: await this.memberIdsOfCall(c.id), + })), + ); + } + + async callAction(user: AuthUser, callId: string, action: 'join' | 'reject' | 'end') { + const call = await this.prisma.imCall.findFirst({ where: { id: callId } }); + if (!call) throw new NotFoundLike(); + const member = await this.prisma.imCallMember.findFirst({ where: { callId, userId: user.id } }); + if (!member) throw new ForbiddenException('不是通话成员'); + if (action === 'join') { + await this.prisma.imCallMember.update({ + where: { id: member.id }, + data: { status: 'joined', joinedAt: new Date() }, + }); + const joined = await this.prisma.imCallMember.count({ where: { callId, status: 'joined' } }); + if (call.status === 'ringing' && joined >= 2) { + await this.prisma.imCall.update({ where: { id: callId }, data: { status: 'active' } }); + } + } else if (action === 'reject') { + await this.prisma.imCallMember.update({ + where: { id: member.id }, + data: { status: 'rejected', leftAt: new Date() }, + }); + const still = await this.prisma.imCallMember.count({ + where: { callId, userId: { not: user.id }, status: { in: ['invited', 'joined'] } }, + }); + if (still <= 1) { + await this.prisma.imCall.update({ + where: { id: callId }, + data: { status: 'ended', endedAt: new Date() }, + }); + } + const others = await this.memberIdsOfCall(callId); + await this.fanout(others.filter((id) => id !== user.id), { + kind: 'call', callId, action: 'reject', text: '对方拒绝接听', fromUserId: user.id, + }); + } else { + await this.prisma.imCall.update({ + where: { id: callId }, + data: { status: 'ended', endedAt: new Date() }, + }); + await this.prisma.imCallMember.updateMany({ + where: { callId, status: { in: ['invited', 'joined'] } }, + data: { status: 'left', leftAt: new Date() }, + }); + const others = await this.memberIdsOfCall(callId); + await this.fanout( + others.filter((id) => id !== user.id), + { kind: 'call', callId, action: 'end', text: '通话已结束', fromUserId: user.id }, + ); + } + return this.prisma.imCall.findFirst({ where: { id: callId } }); + } + + async callStatus(user: AuthUser, callId: string) { + await this.expireRingingCalls(); + const call = await this.prisma.imCall.findFirst({ where: { id: callId } }); + if (!call) throw new NotFoundLike(); + const members = await this.prisma.imCallMember.findMany({ where: { callId } }); + const mine = members.find((m) => m.userId === user.id); + if (!mine) throw new ForbiddenException('不是通话成员'); + const others = members.filter((m) => m.userId !== user.id); + const initiator = await this.prisma.user.findUnique({ where: { id: call.initiatorId }, select: { displayName: true } }); + return { + ...call, + memberIds: members.map((m) => m.userId), + memberStatus: mine.status, + peerJoined: others.some((m) => m.status === 'joined'), + peerRejected: others.some((m) => m.status === 'rejected'), + joinedCount: members.filter((m) => m.status === 'joined').length, + fromName: initiator?.displayName || '', + }; + } + + async livekitToken(user: AuthUser, callId: string) { + const state = await this.callStatus(user, callId); + if (state.status === 'ended' || state.memberStatus === 'rejected' || state.memberStatus === 'left') { + throw new BadRequestException('通话已结束'); + } + const apiKey = process.env.LIVEKIT_API_KEY || ''; + const apiSecret = process.env.LIVEKIT_API_SECRET || ''; + const url = process.env.LIVEKIT_URL || ''; + if (!apiKey || !apiSecret || !url) throw new BadRequestException('音视频服务未配置'); + const token = new AccessToken(apiKey, apiSecret, { + identity: `user-${user.id}`, + name: user.displayName, + ttl: '1h', + }); + token.addGrant({ roomJoin: true, room: `fy-call-${callId}`, canPublish: true, canSubscribe: true }); + return { url, token: await token.toJwt(), room: `fy-call-${callId}` }; + } + + async postSignal( + user: AuthUser, + callId: string, + dto: { toUserId?: string; kind?: string; payload?: unknown }, + ) { + const member = await this.prisma.imCallMember.findFirst({ where: { callId, userId: user.id } }); + if (!member) throw new ForbiddenException('不是通话成员'); + if (!dto.toUserId || !dto.kind) throw new BadRequestException('缺少信令对象'); + const row = await this.prisma.imCallSignal.create({ + data: { + callId, + fromUserId: user.id, + toUserId: dto.toUserId, + kind: dto.kind, + payload: typeof dto.payload === 'string' ? dto.payload : JSON.stringify(dto.payload ?? {}), + }, + }); + await this.forwardTcp(dto.toUserId, { + kind: 'signal', + callId, + signalKind: dto.kind, + fromUserId: user.id, + payload: dto.payload, + }); + return row; + } + + async pullSignals(user: AuthUser, callId: string, after?: string) { + const member = await this.prisma.imCallMember.findFirst({ where: { callId, userId: user.id } }); + if (!member) throw new ForbiddenException('不是通话成员'); + return this.prisma.imCallSignal.findMany({ + where: { + callId, + toUserId: user.id, + ...(after ? { createdAt: { gt: new Date(after) } } : {}), + }, + orderBy: { createdAt: 'asc' }, + take: 100, + }); + } + + async ingest(dto: { + fromUserId: string; + toUserId: string; + conversationId?: string; + kind?: string; + contentType?: string; + body: string; + meta?: unknown; + fingerprint: string; + }) { + if (!dto.fromUserId || !dto.fingerprint) throw new BadRequestException('缺少会话字段'); + const existing = await this.prisma.imMessage.findUnique({ where: { fingerprint: dto.fingerprint } }); + if (existing) return existing; + const conv = dto.conversationId + ? await this.prisma.imConversation.findFirst({ where: { id: dto.conversationId } }) + : await this.ensureDirect(dto.fromUserId, dto.toUserId); + if (!conv) throw new BadRequestException('会话不存在'); + const meta = dto.meta == null ? '' : typeof dto.meta === 'string' ? dto.meta : JSON.stringify(dto.meta); + const seq = await this.allocateSeq(conv.id); + const row = await this.prisma.imMessage.create({ + data: { + conversationId: conv.id, + seq, + fromUserId: dto.fromUserId, + toUserId: dto.toUserId || '', + kind: dto.kind || 'chat', + contentType: dto.contentType || 'text', + body: dto.body, + meta, + fingerprint: dto.fingerprint, + }, + }); + await this.prisma.imConversation.update({ + where: { id: conv.id }, + data: { + lastText: preview(row.kind, row.contentType, dto.body), + lastAt: new Date(), + }, + }); + return row; + } + + async pushEvent(payload: ImPushPayload) { + const text = payload.body || payload.title; + for (const toUserId of payload.userIds) { + if (!toUserId || toUserId === payload.fromUserId) continue; + const fp = payload.bizType && payload.bizId + ? `evt:${payload.bizType}:${payload.bizId}:${payload.kind || 'todo'}` + : randomUUID(); + const saved = await this.ingest({ + fromUserId: payload.fromUserId || '0', + toUserId, + kind: payload.kind, + body: text, + fingerprint: fp, + }); + await this.forwardTcp(toUserId, { + kind: payload.kind, + text, + title: payload.title, + bizType: payload.bizType, + bizId: payload.bizId, + fromUserId: payload.fromUserId || '0', + fingerprint: fp, + seq: saved.seq, + }); + void this.push.notifyUsers([toUserId], { + title: payload.title || '风影办公', + content: text || '你有一条新消息', + kind: payload.kind, + category: payload.kind === 'chat' ? 'IM' : 'TODO', + bizType: payload.bizType, + bizId: payload.bizId, + }); + } + } + + async internalMembers(conversationId: string) { + return { userIds: await this.memberIds(conversationId) }; + } + + private async ensureDirect(a: string, b: string) { + if (!a || !b) throw new BadRequestException('请选择同事'); + const [userAId, userBId] = pair(a, b); + const conv = await this.prisma.imConversation.upsert({ + where: { userAId_userBId: { userAId, userBId } }, + create: { type: 'direct', userAId, userBId }, + update: {}, + }); + await this.prisma.imConversationMember.createMany({ + data: [ + { conversationId: conv.id, userId: a, role: 'member' }, + { conversationId: conv.id, userId: b, role: 'member' }, + ], + skipDuplicates: true, + }); + return conv; + } + + private async requireMember(conversationId: string, userId: string) { + const conv = await this.prisma.imConversation.findFirst({ where: { id: conversationId } }); + if (!conv) throw new BadRequestException('会话不存在'); + const hit = await this.prisma.imConversationMember.findFirst({ + where: { conversationId, userId, leftAt: null }, + }); + const legacy = conv.type === 'direct' && (conv.userAId === userId || conv.userBId === userId); + if (!hit && !legacy) throw new ForbiddenException('不是会话成员'); + if (!hit && legacy) { + await this.prisma.imConversationMember.createMany({ + data: [ + { conversationId, userId: conv.userAId || userId, role: 'member' }, + { conversationId, userId: conv.userBId || userId, role: 'member' }, + ].filter((r) => r.userId), + skipDuplicates: true, + }); + } + return conv; + } + + private async memberIds(conversationId: string) { + const rows = await this.prisma.imConversationMember.findMany({ + where: { conversationId, leftAt: null }, + select: { userId: true }, + }); + if (rows.length) return rows.map((r) => r.userId); + const conv = await this.prisma.imConversation.findFirst({ where: { id: conversationId } }); + return [conv?.userAId, conv?.userBId].filter(Boolean) as string[]; + } + + private async memberIdsOfCall(callId: string) { + const rows = await this.prisma.imCallMember.findMany({ where: { callId }, select: { userId: true } }); + return rows.map((r) => r.userId); + } + + private async expireRingingCalls() { + const staleAt = new Date(Date.now() - 45 * 1000); + const stale = await this.prisma.imCall.findMany({ + where: { status: 'ringing', createdAt: { lt: staleAt } }, + select: { id: true }, + }); + if (!stale.length) return; + await this.prisma.imCall.updateMany({ + where: { id: { in: stale.map((row) => row.id) }, status: 'ringing' }, + data: { status: 'ended', endedAt: new Date() }, + }); + await this.prisma.imCallMember.updateMany({ + where: { callId: { in: stale.map((row) => row.id) }, status: { in: ['invited', 'joined'] } }, + data: { status: 'left', leftAt: new Date() }, + }); + for (const row of stale) { + const members = await this.memberIdsOfCall(row.id); + await this.fanout(members, { kind: 'call', callId: row.id, action: 'timeout', text: '无人接听' }); + } + } + + private async fanout(userIds: string[], payload: Record) { + await Promise.all(userIds.map((id) => this.forwardTcp(id, payload))); + const kind = String(payload.kind || ''); + const title = String(payload.fromName || payload.title || '风影办公'); + const content = String(payload.text || payload.body || '你有一条新消息'); + void this.push.notifyUsers(userIds, { + title, + content, + conversationId: String(payload.conversationId || ''), + kind, + category: kind === 'todo' || kind === 'approval' ? 'TODO' : 'IM', + bizType: String(payload.bizType || ''), + bizId: String(payload.bizId || ''), + callId: String(payload.callId || ''), + callKind: String(payload.callKind || ''), + fromName: String(payload.fromName || ''), + action: String(payload.action || ''), + }); + } + + private async forwardTcp(toUserId: string, payload: Record) { + const port = Number(process.env.IM_HTTP_PORT || 8902); + const token = process.env.IM_INTERNAL_TOKEN || ''; + try { + const res = await fetch(`http://127.0.0.1:${port}/push`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-im-token': token }, + body: JSON.stringify({ + toUserId, + dataContent: JSON.stringify(payload), + seq: payload.seq, + }), + }); + if (!res.ok) this.log.warn(`im push ${res.status}`); + } catch (e) { + this.log.warn(`im push: ${(e as Error).message}`); + } + } + + /** 会话内单调递增 seq,服务端唯一分配。 */ + private async allocateSeq(conversationId: string) { + const conv = await this.prisma.imConversation.update({ + where: { id: conversationId }, + data: { nextSeq: { increment: 1 } }, + select: { nextSeq: true }, + }); + return conv.nextSeq - 1; + } + + private async backfillDirectMembers() { + const orphans = await this.prisma.imConversation.findMany({ + where: { type: 'direct', userAId: { not: null }, userBId: { not: null } }, + take: 50, + orderBy: { lastAt: 'desc' }, + }); + for (const c of orphans) { + if (!c.userAId || !c.userBId) continue; + await this.prisma.imConversationMember.createMany({ + data: [ + { conversationId: c.id, userId: c.userAId, role: 'member' }, + { conversationId: c.id, userId: c.userBId, role: 'member' }, + ], + skipDuplicates: true, + }); + } + } +} + +class NotFoundLike extends BadRequestException { + constructor() { + super('记录不存在'); + } +} + +export { emitIm }; diff --git a/apps/api/src/im/voice-asr.service.ts b/apps/api/src/im/voice-asr.service.ts new file mode 100644 index 0000000..d3b4663 --- /dev/null +++ b/apps/api/src/im/voice-asr.service.ts @@ -0,0 +1,178 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { createHmac, randomUUID } from 'crypto'; +import { execFile } from 'child_process'; +import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +function percentEncode(value: string): string { + return encodeURIComponent(value).replace(/\+/g, '%20').replace(/\*/g, '%2A').replace(/%7E/g, '~'); +} + +function signRpc(params: Record, secret: string): string { + const keys = Object.keys(params).sort(); + const canonical = keys.map((k) => `${percentEncode(k)}=${percentEncode(params[k]!)}`).join('&'); + const stringToSign = `GET&${percentEncode('/')}&${percentEncode(canonical)}`; + return createHmac('sha1', `${secret}&`).update(stringToSign).digest('base64'); +} + +type TokenCache = { token: string; expireAt: number }; +let nlsToken: TokenCache | null = null; + +@Injectable() +export class VoiceAsrService { + private readonly log = new Logger(VoiceAsrService.name); + + async transcribe(buffer: Buffer): Promise { + if (!buffer.length) throw new BadRequestException('语音为空'); + const wav = await this.toWav16k(buffer); + try { + const dash = await this.dashScope(wav); + if (dash) return dash; + } catch (e) { + this.log.warn(`DashScope ASR failed: ${e}`); + } + try { + const ali = await this.aliyunFlash(wav); + if (ali) return ali; + } catch (e) { + this.log.warn(`Aliyun NLS failed: ${e}`); + } + try { + const whisper = await this.whisperCli(wav); + if (whisper) return whisper; + } catch (e) { + this.log.warn(`whisper cli failed: ${e}`); + } + throw new BadRequestException('语音识别服务未配置或识别失败,请联系管理员配置 DASHSCOPE_API_KEY 或 ALIYUN_NLS_APPKEY'); + } + + private async toWav16k(input: Buffer): Promise { + const dir = mkdtempSync(join(tmpdir(), 'fy-asr-')); + const inPath = join(dir, 'in.bin'); + const outPath = join(dir, 'out.wav'); + writeFileSync(inPath, input); + try { + await execFileAsync('ffmpeg', ['-y', '-i', inPath, '-ac', '1', '-ar', '16000', '-f', 'wav', outPath], { + timeout: 60000, + }); + return readFileSync(outPath); + } finally { + for (const p of [inPath, outPath]) { + try { + unlinkSync(p); + } catch { + /* ignore */ + } + } + } + } + + private async dashScope(wav: Buffer): Promise { + const key = process.env.DASHSCOPE_API_KEY || ''; + if (!key) return null; + const res = await fetch('https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription', { + method: 'POST', + headers: { + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'paraformer-v2', + input: { audio: wav.toString('base64'), format: 'wav', sample_rate: 16000 }, + }), + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`DashScope ${res.status}: ${err.slice(0, 200)}`); + } + const json = (await res.json()) as { output?: { text?: string; sentence?: { text?: string }[] } }; + const text = json.output?.text?.trim() || json.output?.sentence?.map((s) => s.text || '').join('').trim() || ''; + return text || null; + } + + private async aliyunFlash(wav: Buffer): Promise { + const appkey = process.env.ALIYUN_NLS_APPKEY || ''; + if (!appkey) return null; + const token = await this.nlsToken(); + const url = + `https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/FlashRecognizer` + + `?appkey=${encodeURIComponent(appkey)}&token=${encodeURIComponent(token)}&format=wav&sample_rate=16000`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: new Uint8Array(wav), + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`NLS ${res.status}: ${err.slice(0, 200)}`); + } + const json = (await res.json()) as { + flash_result?: { sentences?: { text?: string }[] }; + result?: string; + message?: string; + }; + if (json.message && !json.flash_result && !json.result) { + throw new Error(json.message); + } + const text = + json.flash_result?.sentences?.map((s) => s.text || '').join('').trim() || `${json.result || ''}`.trim(); + return text || null; + } + + private async nlsToken(): Promise { + const now = Date.now(); + if (nlsToken && nlsToken.expireAt > now + 60_000) return nlsToken.token; + const id = process.env.ALIYUN_SMS_ACCESS_KEY_ID || process.env.ALIYUN_ACCESS_KEY_ID || ''; + const secret = process.env.ALIYUN_SMS_ACCESS_KEY_SECRET || process.env.ALIYUN_ACCESS_KEY_SECRET || ''; + if (!id || !secret) throw new Error('缺少阿里云 AccessKey'); + const params: Record = { + Action: 'CreateToken', + Version: '2019-02-28', + Format: 'JSON', + RegionId: 'cn-shanghai', + AccessKeyId: id, + SignatureMethod: 'HMAC-SHA1', + SignatureVersion: '1.0', + SignatureNonce: randomUUID(), + Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), + }; + params.Signature = signRpc(params, secret); + const qs = Object.keys(params) + .sort() + .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k]!)}`) + .join('&'); + const res = await fetch(`https://nls-meta.cn-shanghai.aliyuncs.com/?${qs}`); + if (!res.ok) throw new Error(`CreateToken HTTP ${res.status}`); + const json = (await res.json()) as { Token?: { Id?: string; ExpireTime?: number }; Message?: string }; + const token = json.Token?.Id || ''; + if (!token) throw new Error(json.Message || 'CreateToken 失败'); + const expireAt = (json.Token?.ExpireTime || 0) * 1000; + nlsToken = { token, expireAt: expireAt || now + 3600_000 }; + return token; + } + + private async whisperCli(wav: Buffer): Promise { + const dir = mkdtempSync(join(tmpdir(), 'fy-whisper-')); + const wavPath = join(dir, 'in.wav'); + writeFileSync(wavPath, wav); + try { + const { stdout } = await execFileAsync('whisper', [wavPath, '--language', 'Chinese', '--output_format', 'txt'], { + timeout: 120000, + }); + const text = `${stdout || ''}`.trim(); + return text || null; + } catch { + return null; + } finally { + try { + unlinkSync(wavPath); + } catch { + /* ignore */ + } + } + } +} diff --git a/apps/api/src/legal/legal.controller.ts b/apps/api/src/legal/legal.controller.ts new file mode 100644 index 0000000..d790e37 --- /dev/null +++ b/apps/api/src/legal/legal.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, NotFoundException, Param } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; +import { Public } from '../common/decorators/auth.decorators'; +import { LEGAL_DOCS, isLegalKind } from './legal.docs'; + +@ApiTags('legal') +@SkipThrottle() +@Controller('legal') +export class LegalController { + @Public() + @Get() + index() { + return Object.values(LEGAL_DOCS).map((d) => ({ + kind: d.kind, + title: d.title, + updatedAt: d.updatedAt, + })); + } + + @Public() + @Get(':kind') + doc(@Param('kind') kind: string) { + if (!isLegalKind(kind)) throw new NotFoundException('文档不存在'); + return LEGAL_DOCS[kind]; + } +} diff --git a/apps/api/src/legal/legal.docs.ts b/apps/api/src/legal/legal.docs.ts new file mode 100644 index 0000000..44f7db6 --- /dev/null +++ b/apps/api/src/legal/legal.docs.ts @@ -0,0 +1,127 @@ +export type LegalKind = 'user-agreement' | 'privacy'; + +export type LegalDoc = { + kind: LegalKind; + title: string; + updatedAt: string; + operator: string; + product: string; + paragraphs: { heading?: string; body: string }[]; +}; + +const OPERATOR = '江苏风影随行科技有限公司'; +const PRODUCT = '风影智慧办公'; +const UPDATED = '2026-08-31'; + +const USER_AGREEMENT: LegalDoc = { + kind: 'user-agreement', + title: '用户协议', + updatedAt: UPDATED, + operator: OPERATOR, + product: PRODUCT, + paragraphs: [ + { + heading: '一、协议的订立', + body: `欢迎使用${PRODUCT}(含网页端、Android 客户端及其他经授权的接入端,以下统称「本平台」)。本协议由您与${OPERATOR}(以下简称「我们」)订立。您点击同意、勾选同意或实际登录使用,即表示您已阅读、理解并同意受本协议约束。未勾选同意前,任何一端均不得登录。`, + }, + { + heading: '二、服务内容', + body: '本平台为企业内部办公系统,向经授权的员工提供即时通讯、审批、人事、用章、资产、采购、差旅报销及相关台账等服务。账号由单位开通或确认,仅限本人履行职务使用,不得出借、转让或交由他人代为登录。', + }, + { + heading: '三、账号与安全', + body: '您应妥善保管手机号、密码、短信验证码及设备。因您保管不善导致的账号盗用、信息泄露,由您和所属单位按内部规定承担责任。发现异常登录应立即修改密码并通知管理员。我们有权在发现安全风险时限制、冻结或注销账号。', + }, + { + heading: '四、使用规范', + body: '您不得利用本平台从事违法违规、侵害他人权益或危害信息系统安全的行为,包括但不限于传播违法信息、绕过权限、扫描攻击、爬取他人数据、伪造审批或篡改台账。工作信息、审批意见、聊天记录等应按单位制度使用,不得擅自对外披露。', + }, + { + heading: '五、通知与推送', + body: '为及时送达聊天消息、审批待办等,客户端可能申请通知权限、忽略电池优化,并引导您按机型打开自启动/后台运行。您可在系统设置中关闭,但可能无法及时收到消息。相机、麦克风、位置等权限仅在您使用拍照、语音、定位相关功能时申请。', + }, + { + heading: '六、知识产权', + body: `本平台的软件、界面、文档及相关知识产权归${OPERATOR}或合法权利人所有。您仅获得按职务需要使用的有限、不可转让许可,不得反向工程、破解或把本平台提供给无关第三方。您在平台产生的工作内容,权属按劳动合同及单位制度执行。`, + }, + { + heading: '七、服务变更与中止', + body: '我们可能因系统维护、安全事件、政策要求或与贵单位的服务安排调整功能或中止服务。重大变更将通过公告、站内信或管理员通知。因不可抗力、基础运营商故障、第三方推送通道或您未按机型完成保活设置导致的延迟或中断,我们将在合理范围内协助排查。', + }, + { + heading: '八、责任限制', + body: '本平台按「现状」向内部用户提供。在法律允许范围内,我们对间接损失、预期利益、数据未能及时推送造成的延误不承担责任,但因我们故意或重大过失造成的除外。与职务相关的审批、合同、财务责任仍由相应业务制度约束。', + }, + { + heading: '九、协议变更与法律适用', + body: '我们可能更新本协议,更新后将在登录页公示并重新取得勾选同意。本协议适用中华人民共和国法律。争议由我们住所地有管辖权的人民法院管辖。', + }, + { + heading: '十、联系我们', + body: `如对本协议有疑问,请通过本平台管理员或单位信息化接口人联系${OPERATOR}。`, + }, + ], +}; + +const PRIVACY: LegalDoc = { + kind: 'privacy', + title: '隐私政策', + updatedAt: UPDATED, + operator: OPERATOR, + product: PRODUCT, + paragraphs: [ + { + heading: '一、引言', + body: `${OPERATOR}(以下简称「我们」)作为${PRODUCT}的运营者,重视员工及用户的个人信息保护。本政策说明我们处理个人信息的目的、方式、范围和您的权利。本平台为企业内部办公系统,处理活动同时受贵单位用工管理与数据安全制度约束。未阅读并勾选同意本政策前,任何一端均不得登录。`, + }, + { + heading: '二、我们收集的信息', + body: '1. 账号信息:手机号、姓名、工号、部门、岗位、角色权限,用于登录鉴权和权限控制。2. 身份与人事信息:按入职/档案要求可能包括证件影像、紧急联系人、银行卡等,仅用于人事与薪酬流程。3. 办公业务信息:审批、用章、资产占用、差旅报销、即时通讯内容等,用于完成您发起或处理的业务。4. 设备与推送信息:设备品牌型号、推送登记标识(如各厂商 pushId/regId)、应用版本,用于消息送达。5. 日志信息:登录时间、IP、操作记录,用于安全审计。6. 您主动上传的照片、语音、文件。', + }, + { + heading: '三、权限调用说明', + body: '通知:用于聊天、审批待办等提醒。电池优化/自启动/后台运行:用于厂商系统下保持消息通道,不用于采集额外个人信息。相机:仅在拍照、扫码或上传影像时使用。麦克风:仅在按住说话等语音功能时使用。位置:仅在考勤打卡、外勤等明确需要定位的功能中使用。存储/相册:仅在您选择图片或文件时读取您指定的内容。上述权限均可在系统设置中关闭,关闭后对应功能可能不可用。', + }, + { + heading: '四、使用目的', + body: '我们在实现办公协同、身份认证、审批流转、消息通知、安全审计、故障排查和版本升级所必需的范围内处理个人信息,不会将您的个人信息用于与职务无关的营销推送。', + }, + { + heading: '五、委托处理、共享与公开', + body: '为实现推送,我们可能将推送标识及通知标题/摘要发送至 vivo、OPPO/一加、魅族/Flyme 等系统推送通道,传输内容以完成消息展示为限。对象存储、短信等基础设施服务商仅在提供服务所必需的范围内处理数据。我们不会出售个人信息。因法律法规、监管或安全需要,可能依法提供必要信息。', + }, + { + heading: '六、存储与跨境', + body: '个人信息存储在中华人民共和国境内。保留期限为实现目的所必需的最短时间,或按会计、劳动、档案等法定期限及单位制度执行。逾期将删除或匿名化,法律另有规定的除外。', + }, + { + heading: '七、安全保护', + body: '我们采取访问控制、传输加密、操作日志、最小权限等措施。因您将账号交由他人使用,或在越狱/Root、非正式渠道安装的设备上使用而导致的风险,请您谨慎评估。', + }, + { + heading: '八、您的权利', + body: '您有权查阅、更正与职务相关的个人资料,并可通过「我」或管理员申请注销账号。账号注销后,除依法需保留的人事、财务、审计记录外,我们将停止提供服务并删除或匿名化相关信息。部分职务数据可能因单位档案要求继续由单位保管。', + }, + { + heading: '九、未成年人', + body: '本平台面向单位在职人员。如发现未成年人在未获监护人同意的情况下使用,请联系管理员停止服务。', + }, + { + heading: '十、政策更新', + body: '本政策更新后将在登录页公示。若处理目的、方式或种类发生重大变化,我们将再次取得您的同意。继续勾选并登录视为知悉更新后的政策。', + }, + { + heading: '十一、联系我们', + body: `个人信息保护相关事项,请通过本平台管理员联系${OPERATOR}。`, + }, + ], +}; + +export const LEGAL_DOCS: Record = { + 'user-agreement': USER_AGREEMENT, + privacy: PRIVACY, +}; + +export function isLegalKind(v: string): v is LegalKind { + return v === 'user-agreement' || v === 'privacy'; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..957e456 --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,51 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import helmet from 'helmet'; +import { AppModule } from './app.module'; +import { loadEnv } from './config/env'; + +function applyEnvFile(file: string) { + try { + for (const line of readFileSync(file, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const i = trimmed.indexOf('='); + if (i <= 0) continue; + process.env[trimmed.slice(0, i)] = trimmed.slice(i + 1); + } + } catch { + /* 文件不存在就跳过 */ + } +} +applyEnvFile(resolve(__dirname, '../.env')); + +async function bootstrap() { + const env = loadEnv(); + const app = await NestFactory.create(AppModule, { bufferLogs: true }); + app.setGlobalPrefix(env.API_PREFIX); + app.use(helmet()); + app.enableCors({ + origin: env.CORS_ORIGINS.split(',').map((s) => s.trim()), + credentials: true, + }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: false, + }), + ); + const swagger = new DocumentBuilder() + .setTitle('企业综合运营管理平台') + .setDescription('Web 先行 API') + .setVersion('0.1.0') + .addBearerAuth() + .build(); + SwaggerModule.setup(`${env.API_PREFIX}/docs`, app, SwaggerModule.createDocument(app, swagger)); + await app.listen(env.APP_PORT); + console.log(`API ready at http://localhost:${env.APP_PORT}/${env.API_PREFIX}`); +} +bootstrap(); diff --git a/apps/api/src/master/master.controller.ts b/apps/api/src/master/master.controller.ts new file mode 100644 index 0000000..bbfc9d8 --- /dev/null +++ b/apps/api/src/master/master.controller.ts @@ -0,0 +1,192 @@ +import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsArray, IsBoolean, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { PageQueryDto } from '../common/dto/page-query.dto'; +import { MasterService } from './master.service'; +import { CurrentUser, type AuthUser } from '../common/decorators/current-user.decorator'; + +class ContactBody { + @IsOptional() + @IsString() + id?: string; + @IsString() + name!: string; + @IsOptional() + @IsString() + phone?: string; + @IsOptional() + @IsString() + title?: string; +} + +class PartyDto { + @IsString() + name!: string; + @IsOptional() + @IsString() + creditNo?: string; + @IsOptional() + @IsString() + level?: string; + @IsOptional() + @IsBoolean() + riskFlag?: boolean; + @IsOptional() + @IsString() + remark?: string; + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ContactBody) + contacts?: ContactBody[]; +} + +class EntityDto { + @IsString() + name!: string; + @IsString() + shortName!: string; + @IsOptional() + @IsString() + creditNo?: string; + @IsOptional() + @IsBoolean() + isDefault?: boolean; +} + +class ContactDto extends ContactBody { + @IsString() + partyId!: string; +} + +@ApiTags('master') +@ApiBearerAuth() +@Controller() +export class MasterController { + constructor(private readonly master: MasterService) {} + + @Get('parties') + parties(@Query() q: PageQueryDto) { + return this.master.listParties(q.page, q.pageSize, q.keyword); + } + + @Post('parties') + createParty(@Body() dto: PartyDto) { + return this.master.createParty(dto); + } + + @Get('parties/:id') + party(@Param('id', ParseUUIDPipe) id: string) { + return this.master.getParty(id); + } + + @Patch('parties/:id') + updateParty(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PartyDto) { + return this.master.updateParty(id, dto); + } + + @Delete('parties/:id') + deleteParty(@Param('id', ParseUUIDPipe) id: string) { + return this.master.deleteParty(id); + } + + @Get('legal-entities') + legalEntities() { + return this.master.listEntities(); + } + + @Post('legal-entities') + createEntity(@Body() dto: EntityDto) { + return this.master.createEntity(dto); + } + + @Get('legal-entities/:id') + entity(@Param('id', ParseUUIDPipe) id: string) { + return this.master.getEntity(id); + } + + @Patch('legal-entities/:id') + updateEntity(@Param('id', ParseUUIDPipe) id: string, @Body() dto: EntityDto) { + return this.master.updateEntity(id, dto); + } + + @Delete('legal-entities/:id') + deleteEntity(@Param('id', ParseUUIDPipe) id: string) { + return this.master.deleteEntity(id); + } + + @Get('contacts') + contacts(@Query('keyword') keyword?: string) { + return this.master.listContacts(keyword); + } + + @Post('contacts') + createContact(@Body() dto: ContactDto) { + return this.master.createContact(dto); + } + + @Patch('contacts/:id') + updateContact(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ContactDto) { + return this.master.updateContact(id, dto); + } + + @Delete('contacts/:id') + deleteContact(@Param('id', ParseUUIDPipe) id: string) { + return this.master.deleteContact(id); + } + + @Get('registered-assets') + assets(@Query('kind') kind?: string, @Query('usage') usage?: string) { + return this.master.listAssets(kind, usage); + } + + @Post('registered-assets') + createAsset( + @CurrentUser() user: AuthUser, + @Body() + body: { + kind: string; + name: string; + code?: string; + spec?: string; + keeper?: string; + location?: string; + amount?: number; + remark?: string; + legalEntityId?: string; + usage?: string; + }, + ) { + return this.master.createAsset(user.id, body); + } + + @Patch('registered-assets/:id') + updateAsset( + @Param('id', ParseUUIDPipe) id: string, + @Body() + body: { + name?: string; + code?: string; + spec?: string; + keeper?: string; + location?: string; + amount?: number | null; + remark?: string; + legalEntityId?: string | null; + usage?: string; + }, + ) { + return this.master.updateAsset(id, body); + } + + @Delete('registered-assets/:id') + deleteAsset(@Param('id', ParseUUIDPipe) id: string) { + return this.master.deleteAsset(id); + } + + @Post('registered-assets/:id/inbound') + inboundAsset(@Param('id', ParseUUIDPipe) id: string) { + return this.master.confirmInbound(id); + } +} diff --git a/apps/api/src/master/master.service.ts b/apps/api/src/master/master.service.ts new file mode 100644 index 0000000..3ea48f0 --- /dev/null +++ b/apps/api/src/master/master.service.ts @@ -0,0 +1,380 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +export type ContactInput = { id?: string; name: string; phone?: string; title?: string }; +export type PartyInput = { + name: string; + creditNo?: string; + level?: string; + riskFlag?: boolean; + remark?: string; + contacts?: ContactInput[]; +}; +export type EntityInput = { + name: string; + shortName: string; + creditNo?: string; + isDefault?: boolean; +}; + +const PARTY_INCLUDE = { contacts: { orderBy: { name: 'asc' as const } } }; + +@Injectable() +export class MasterService { + constructor(private readonly prisma: PrismaService) {} + + listParties(page: number, pageSize: number, keyword?: string) { + const where = { + tenantId: '1', + ...(keyword ? { name: { contains: keyword } } : {}), + }; + return this.prisma.$transaction(async (tx) => { + const [items, total] = await Promise.all([ + tx.businessParty.findMany({ + where, + include: PARTY_INCLUDE, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + tx.businessParty.count({ where }), + ]); + return { items, page, pageSize, total }; + }); + } + + async getParty(id: string) { + const row = await this.prisma.businessParty.findFirst({ + where: { id, tenantId: '1' }, + include: PARTY_INCLUDE, + }); + if (!row) throw new NotFoundException('单位不存在'); + return row; + } + + async createParty(dto: PartyInput) { + const name = dto.name?.trim(); + if (!name) throw new BadRequestException('请填写单位名称'); + return this.prisma.businessParty.create({ + data: { + tenantId: '1', + name, + creditNo: dto.creditNo?.trim() || null, + level: dto.level?.trim() || 'B', + riskFlag: Boolean(dto.riskFlag), + remark: dto.remark?.trim() || null, + contacts: { + create: (dto.contacts || []) + .filter((c) => c.name?.trim()) + .map((c) => ({ + tenantId: '1', + name: c.name.trim(), + phone: c.phone?.trim() || null, + title: c.title?.trim() || null, + })), + }, + }, + include: PARTY_INCLUDE, + }); + } + + async updateParty(id: string, dto: PartyInput) { + await this.getParty(id); + const name = dto.name?.trim(); + if (!name) throw new BadRequestException('请填写单位名称'); + const incoming = dto.contacts?.filter((c) => c.name?.trim()); + return this.prisma.$transaction(async (tx) => { + if (incoming) { + const keepIds = incoming.map((c) => c.id).filter(Boolean) as string[]; + await tx.partyContact.deleteMany({ + where: { partyId: id, ...(keepIds.length ? { id: { notIn: keepIds } } : {}) }, + }); + for (const c of incoming) { + const data = { + name: c.name.trim(), + phone: c.phone?.trim() || null, + title: c.title?.trim() || null, + }; + if (c.id) { + await tx.partyContact.updateMany({ + where: { id: c.id, partyId: id }, + data, + }); + } else { + await tx.partyContact.create({ data: { tenantId: '1', partyId: id, ...data } }); + } + } + } + return tx.businessParty.update({ + where: { id }, + data: { + name, + creditNo: dto.creditNo?.trim() || null, + level: dto.level?.trim() || 'B', + riskFlag: dto.riskFlag === undefined ? undefined : Boolean(dto.riskFlag), + remark: dto.remark?.trim() || null, + }, + include: PARTY_INCLUDE, + }); + }); + } + + async deleteParty(id: string) { + const row = await this.getParty(id); + const used = await this.prisma.bidCase.count({ where: { partyId: id } }); + if (used) throw new BadRequestException('已有投标事项使用该单位,不能删除'); + await this.prisma.businessParty.delete({ where: { id: row.id } }); + return { ok: true }; + } + + listEntities() { + return this.prisma.legalEntity.findMany({ + where: { tenantId: '1' }, + orderBy: { createdAt: 'asc' }, + }); + } + + async getEntity(id: string) { + const row = await this.prisma.legalEntity.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('主体不存在'); + return row; + } + + async createEntity(dto: EntityInput) { + const name = dto.name?.trim(); + const shortName = dto.shortName?.trim(); + if (!name || !shortName) throw new BadRequestException('请填写主体全称和简称'); + if (dto.isDefault) { + await this.prisma.legalEntity.updateMany({ where: { tenantId: '1' }, data: { isDefault: false } }); + } + return this.prisma.legalEntity.create({ + data: { + tenantId: '1', + name, + shortName, + creditNo: dto.creditNo?.trim() || null, + isDefault: Boolean(dto.isDefault), + }, + }); + } + + async updateEntity(id: string, dto: EntityInput) { + await this.getEntity(id); + const name = dto.name?.trim(); + const shortName = dto.shortName?.trim(); + if (!name || !shortName) throw new BadRequestException('请填写主体全称和简称'); + if (dto.isDefault) { + await this.prisma.legalEntity.updateMany({ + where: { tenantId: '1', NOT: { id } }, + data: { isDefault: false }, + }); + } + return this.prisma.legalEntity.update({ + where: { id }, + data: { + name, + shortName, + creditNo: dto.creditNo?.trim() || null, + isDefault: dto.isDefault === undefined ? undefined : Boolean(dto.isDefault), + }, + }); + } + + async deleteEntity(id: string) { + const row = await this.getEntity(id); + const [bids, contracts] = await Promise.all([ + this.prisma.bidCase.count({ where: { legalEntityId: id } }), + this.prisma.contract.count({ where: { legalEntityId: id } }), + ]); + if (bids || contracts) throw new BadRequestException('已有投标或合同使用该主体,不能删除'); + await this.prisma.legalEntity.delete({ where: { id: row.id } }); + return { ok: true }; + } + + listContacts(keyword?: string) { + return this.prisma.partyContact.findMany({ + where: { + tenantId: '1', + ...(keyword + ? { + OR: [ + { name: { contains: keyword } }, + { phone: { contains: keyword } }, + { party: { name: { contains: keyword } } }, + ], + } + : {}), + }, + include: { party: { select: { id: true, name: true, creditNo: true } } }, + orderBy: { name: 'asc' }, + }); + } + + async createContact(dto: ContactInput & { partyId: string }) { + if (!dto.partyId) throw new BadRequestException('请选择所属单位'); + if (!dto.name?.trim()) throw new BadRequestException('请填写联系人姓名'); + await this.getParty(dto.partyId); + return this.prisma.partyContact.create({ + data: { + tenantId: '1', + partyId: dto.partyId, + name: dto.name.trim(), + phone: dto.phone?.trim() || null, + title: dto.title?.trim() || null, + }, + include: { party: { select: { id: true, name: true, creditNo: true } } }, + }); + } + + async updateContact(id: string, dto: ContactInput & { partyId: string }) { + const row = await this.prisma.partyContact.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('联系人不存在'); + if (!dto.partyId) throw new BadRequestException('请选择所属单位'); + if (!dto.name?.trim()) throw new BadRequestException('请填写联系人姓名'); + await this.getParty(dto.partyId); + return this.prisma.partyContact.update({ + where: { id }, + data: { + partyId: dto.partyId, + name: dto.name.trim(), + phone: dto.phone?.trim() || null, + title: dto.title?.trim() || null, + }, + include: { party: { select: { id: true, name: true, creditNo: true } } }, + }); + } + + async deleteContact(id: string) { + const row = await this.prisma.partyContact.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('联系人不存在'); + await this.prisma.partyContact.delete({ where: { id } }); + return { ok: true }; + } + + async listAssets(kind?: string, usage?: string) { + const where = { tenantId: '1', ...(kind ? { kind } : {}), ...(usage ? { usage } : {}) }; + const items = await this.prisma.registeredAsset.findMany({ + where, + include: { + legalEntity: { select: { id: true, name: true, shortName: true } }, + createdBy: { select: { id: true, displayName: true } }, + occupancies: { + where: { status: 'IN_USE' }, + select: { id: true, holder: true, dueAt: true, status: true }, + take: 5, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + return { items, total: items.length }; + } + + async createAsset( + userId: string, + dto: { + kind: string; + name: string; + code?: string; + spec?: string; + keeper?: string; + location?: string; + amount?: number; + remark?: string; + legalEntityId?: string; + usage?: string; + }, + ) { + if (dto.kind !== 'DIGITAL' && dto.kind !== 'FIXED') throw new BadRequestException('请选择资产类型'); + if (!dto.name?.trim()) throw new BadRequestException('请填写资产名称'); + if (dto.legalEntityId) { + const entity = await this.prisma.legalEntity.findFirst({ where: { id: dto.legalEntityId, tenantId: '1' } }); + if (!entity) throw new NotFoundException('参与主体不存在'); + } + return this.prisma.registeredAsset.create({ + data: { + tenantId: '1', + kind: dto.kind, + name: dto.name.trim(), + code: dto.code?.trim() || null, + spec: dto.spec?.trim() || null, + keeper: dto.keeper?.trim() || null, + location: dto.location?.trim() || null, + amount: dto.amount ?? null, + remark: dto.remark?.trim() || null, + legalEntityId: dto.legalEntityId || null, + createdById: userId, + usage: dto.kind === 'FIXED' && dto.usage === 'VEHICLE' ? 'VEHICLE' : 'GENERAL', + status: 'ACTIVE', + }, + include: { + legalEntity: { select: { id: true, name: true, shortName: true } }, + createdBy: { select: { id: true, displayName: true } }, + }, + }); + } + + async updateAsset( + id: string, + dto: { + name?: string; + code?: string; + spec?: string; + keeper?: string; + location?: string; + amount?: number | null; + remark?: string; + legalEntityId?: string | null; + usage?: string; + }, + ) { + const row = await this.prisma.registeredAsset.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('资产不存在'); + if (dto.legalEntityId) { + const entity = await this.prisma.legalEntity.findFirst({ where: { id: dto.legalEntityId, tenantId: '1' } }); + if (!entity) throw new NotFoundException('参与主体不存在'); + } + return this.prisma.registeredAsset.update({ + where: { id }, + data: { + name: dto.name?.trim() || undefined, + code: dto.code === undefined ? undefined : dto.code?.trim() || null, + spec: dto.spec === undefined ? undefined : dto.spec?.trim() || null, + keeper: dto.keeper === undefined ? undefined : dto.keeper?.trim() || null, + location: dto.location === undefined ? undefined : dto.location?.trim() || null, + amount: dto.amount === undefined ? undefined : dto.amount, + remark: dto.remark === undefined ? undefined : dto.remark?.trim() || null, + legalEntityId: dto.legalEntityId === undefined ? undefined : dto.legalEntityId || null, + usage: dto.usage === undefined ? undefined : dto.usage === 'VEHICLE' ? 'VEHICLE' : 'GENERAL', + }, + include: { + legalEntity: { select: { id: true, name: true, shortName: true } }, + createdBy: { select: { id: true, displayName: true } }, + }, + }); + } + + async deleteAsset(id: string) { + const row = await this.prisma.registeredAsset.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('资产不存在'); + await this.prisma.registeredAsset.delete({ where: { id } }); + return { ok: true }; + } + + async confirmInbound(id: string) { + const row = await this.prisma.registeredAsset.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('资产不存在'); + if (row.status !== 'PENDING_IN') throw new BadRequestException('该资产不是待入库状态'); + return this.prisma.registeredAsset.update({ + where: { id }, + data: { status: 'ACTIVE' }, + include: { + legalEntity: { select: { id: true, name: true, shortName: true } }, + createdBy: { select: { id: true, displayName: true } }, + }, + }); + } +} diff --git a/apps/api/src/office/office-apply.spec.ts b/apps/api/src/office/office-apply.spec.ts new file mode 100644 index 0000000..8b79471 --- /dev/null +++ b/apps/api/src/office/office-apply.spec.ts @@ -0,0 +1,16 @@ +import { canApproveOfficeApply, OFFICE_APPLY_KIND } from './office-apply'; + +describe('office apply', () => { + it('lets owner hr and admin approve, not staff', () => { + expect(canApproveOfficeApply(['owner'])).toBe(true); + expect(canApproveOfficeApply(['hr'])).toBe(true); + expect(canApproveOfficeApply(['admin'])).toBe(true); + expect(canApproveOfficeApply(['biz_director'])).toBe(true); + expect(canApproveOfficeApply(['employee'])).toBe(false); + expect(canApproveOfficeApply(['pm'])).toBe(false); + }); + + it('keeps the three office apply kinds', () => { + expect(Object.values(OFFICE_APPLY_KIND)).toEqual(['ASSET_BUY', 'ASSET_USE', 'VEHICLE']); + }); +}); diff --git a/apps/api/src/office/office-apply.ts b/apps/api/src/office/office-apply.ts new file mode 100644 index 0000000..d78af33 --- /dev/null +++ b/apps/api/src/office/office-apply.ts @@ -0,0 +1,765 @@ +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { existsSync, unlinkSync } from 'fs'; +import { join } from 'path'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { PrismaService } from '../prisma/prisma.service'; +import { ASSET_STATUS, PURCHASE_STATUS } from '../system/ops-workflow'; +import { sealBucket } from '../seal/seal-workflow'; +import { emitSms } from '../sms/sms-bus'; +import { DIRECTOR_ROLES } from '../common/role-groups'; + +export const OFFICE_APPLY_KIND = { + ASSET_BUY: 'ASSET_BUY', + ASSET_USE: 'ASSET_USE', + VEHICLE: 'VEHICLE', +} as const; + +export const OFFICE_APPLY_STATUS = { + PENDING: 'PENDING', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED', +} as const; + +export const OFFICE_BIZ = { + APPLY: 'OFFICE_APPLY', +} as const; + +export const OFFICE_APPLY_APPROVER_ROLES = ['admin', 'owner', 'hr', 'biz_director'] as const; + +const APPLY_INCLUDE = { + applicant: { + select: { + id: true, + displayName: true, + username: true, + employee: { select: { name: true, department: { select: { name: true } } } }, + }, + }, + decidedBy: { select: { id: true, displayName: true, username: true } }, + registeredAsset: { select: { id: true, kind: true, name: true, code: true, status: true, usage: true } }, + project: { select: { id: true, projectNo: true, name: true } }, + purchase: { select: { id: true, title: true, status: true, amount: true } }, + occupancy: { select: { id: true, status: true, assetName: true } }, + inboundAsset: { select: { id: true, name: true, status: true, kind: true } }, +} satisfies Prisma.OfficeApplyInclude; + +const VEHICLE_NAME = /车|车辆|轿车|商务车|越野|皮卡|面包车/; + +export function canApproveOfficeApply(roles: string[]) { + return OFFICE_APPLY_APPROVER_ROLES.some((r) => roles.includes(r)); +} + +function kindLabel(kind: string) { + if (kind === OFFICE_APPLY_KIND.ASSET_BUY) return '资产购置'; + if (kind === OFFICE_APPLY_KIND.ASSET_USE) return '资产领用'; + if (kind === OFFICE_APPLY_KIND.VEHICLE) return '用车'; + return kind; +} + +function assetKindLabel(kind?: string | null) { + if (kind === 'DIGITAL') return '数字资产'; + if (kind === 'FIXED') return '固定资产'; + return '资产'; +} + +function isOverdue(d?: Date | null) { + return Boolean(d && d.getTime() < Date.now()); +} + +export async function listOfficeApplies(prisma: PrismaService, user: AuthUser, bucket?: string, scope?: string) { + const where: Prisma.OfficeApplyWhereInput = { tenantId: '1' }; + const mineOnly = !canApproveOfficeApply(user.roles) || bucket === 'mine' || scope === 'mine'; + if (mineOnly) { + where.applicantId = user.id; + } + if (bucket === 'pending') where.status = OFFICE_APPLY_STATUS.PENDING; + else if (bucket === 'approved') where.status = OFFICE_APPLY_STATUS.APPROVED; + else if (bucket === 'rejected') where.status = OFFICE_APPLY_STATUS.REJECTED; + const items = await prisma.officeApply.findMany({ + where, + include: APPLY_INCLUDE, + orderBy: { createdAt: 'desc' }, + }); + const filtered = + bucket === 'occupying' + ? items.filter((i) => i.occupancy?.status === ASSET_STATUS.IN_USE) + : items; + return { items: filtered }; +} + +export async function createOfficeApply( + prisma: PrismaService, + user: AuthUser, + dto: { + kind?: string; + title?: string; + reason?: string; + assetKind?: string; + registeredAssetId?: string; + amount?: number; + destination?: string; + startAt?: string; + endAt?: string; + projectId?: string; + }, +) { + const kind = dto.kind?.trim(); + if (!kind || !Object.values(OFFICE_APPLY_KIND).includes(kind as (typeof OFFICE_APPLY_KIND)[keyof typeof OFFICE_APPLY_KIND])) { + throw new BadRequestException('请选择申请类型:资产购置、资产领用或用车'); + } + let assetKind = dto.assetKind?.trim() || null; + if (kind === OFFICE_APPLY_KIND.VEHICLE) assetKind = 'FIXED'; + if (kind !== OFFICE_APPLY_KIND.VEHICLE && assetKind !== 'DIGITAL' && assetKind !== 'FIXED') { + throw new BadRequestException('请先选择数字资产或固定资产'); + } + + let registeredAsset: { id: string; name: string; kind: string; code: string | null; status: string } | null = null; + if (dto.registeredAssetId) { + registeredAsset = await prisma.registeredAsset.findFirst({ + where: { id: dto.registeredAssetId, tenantId: '1' }, + select: { id: true, name: true, kind: true, code: true, status: true }, + }); + if (!registeredAsset) throw new NotFoundException('台账中没有该资产'); + if (registeredAsset.status === 'PENDING_IN') throw new BadRequestException('该资产尚未入库,不能领用或用车'); + if (assetKind && registeredAsset.kind !== assetKind) { + throw new BadRequestException('所选资产与类型不符'); + } + } + if (kind === OFFICE_APPLY_KIND.ASSET_USE && !registeredAsset) { + throw new BadRequestException('领用请从资产台账选择具体资产'); + } + if (registeredAsset && (kind === OFFICE_APPLY_KIND.ASSET_USE || kind === OFFICE_APPLY_KIND.VEHICLE)) { + const busy = await prisma.assetOccupancy.findFirst({ + where: { registeredAssetId: registeredAsset.id, status: ASSET_STATUS.IN_USE }, + }); + if (busy) throw new BadRequestException(`「${registeredAsset.name}」已被 ${busy.holder} 占用,请先交回`); + } + if (kind === OFFICE_APPLY_KIND.ASSET_BUY && !(Number(dto.amount) > 0)) { + throw new BadRequestException('购置请填写金额'); + } + if (kind === OFFICE_APPLY_KIND.VEHICLE) { + if (!dto.destination?.trim()) throw new BadRequestException('请填写用车目的地'); + if (!dto.startAt || !dto.endAt) throw new BadRequestException('请填写用车起止时间'); + } + if (dto.projectId) { + const project = await prisma.project.findFirst({ where: { id: dto.projectId, tenantId: '1' } }); + if (!project) throw new NotFoundException('项目不存在'); + } + + const title = + dto.title?.trim() || + (kind === OFFICE_APPLY_KIND.ASSET_BUY + ? `购置${assetKindLabel(assetKind)}` + : kind === OFFICE_APPLY_KIND.ASSET_USE + ? `领用 ${registeredAsset?.name}` + : `用车 ${dto.destination?.trim()}`); + + const row = await prisma.$transaction(async (tx) => { + const created = await tx.officeApply.create({ + data: { + tenantId: '1', + kind, + title, + reason: dto.reason?.trim() || null, + assetKind, + registeredAssetId: registeredAsset?.id || null, + amount: kind === OFFICE_APPLY_KIND.ASSET_BUY ? dto.amount : null, + destination: dto.destination?.trim() || null, + startAt: dto.startAt ? new Date(dto.startAt) : null, + endAt: dto.endAt ? new Date(dto.endAt) : null, + projectId: dto.projectId || null, + applicantId: user.id, + status: OFFICE_APPLY_STATUS.PENDING, + }, + }); + await openApprovals(tx, created.id, `${kindLabel(kind)}:${title}`); + return tx.officeApply.findFirstOrThrow({ where: { id: created.id }, include: APPLY_INCLUDE }); + }); + return row; +} + +export async function decideOfficeApply( + prisma: PrismaService, + approvalId: string | null, + applyId: string | null, + user: AuthUser, + dto: { result: 'APPROVED' | 'REJECTED'; comment?: string }, +) { + if (!canApproveOfficeApply(user.roles)) throw new ForbiddenException('无权审批办公申请'); + let id = applyId; + if (approvalId) { + const task = await prisma.approvalTask.findFirst({ where: { id: approvalId, tenantId: '1' } }); + if (!task || task.bizType !== OFFICE_BIZ.APPLY || !task.bizId) { + throw new NotFoundException('审批任务不存在'); + } + id = task.bizId; + } + if (!id) throw new BadRequestException('缺少申请'); + const row = await prisma.officeApply.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('办公申请不存在'); + if (row.status !== OFFICE_APPLY_STATUS.PENDING) throw new BadRequestException('该申请已办理'); + + return prisma.$transaction(async (tx) => { + let purchaseId: string | null = row.purchaseId; + let occupancyId: string | null = row.occupancyId; + let inboundAssetId: string | null = row.inboundAssetId; + if (dto.result === 'APPROVED') { + if (row.kind === OFFICE_APPLY_KIND.ASSET_BUY) { + const purchase = await tx.purchaseRequest.create({ + data: { + tenantId: '1', + title: row.title, + amount: row.amount ?? 0, + projectId: row.projectId, + remark: `办公购置 ${row.id}`, + applicantId: row.applicantId, + decidedById: user.id, + decidedAt: new Date(), + status: PURCHASE_STATUS.APPROVED, + }, + }); + purchaseId = purchase.id; + const inbound = await tx.registeredAsset.create({ + data: { + tenantId: '1', + kind: row.assetKind === 'DIGITAL' ? 'DIGITAL' : 'FIXED', + name: row.title, + amount: row.amount, + remark: `购置申请待入库。${row.reason || ''}`.trim(), + status: 'PENDING_IN', + usage: VEHICLE_NAME.test(row.title) ? 'VEHICLE' : 'GENERAL', + createdById: row.applicantId, + }, + }); + inboundAssetId = inbound.id; + if (Number(row.amount) > 0) { + const year = new Date().getFullYear(); + const count = await tx.paymentRecord.count({ where: { payNo: { startsWith: `PAY-${year}-` } } }); + await tx.paymentRecord.create({ + data: { + tenantId: '1', + payNo: `PAY-${year}-${String(count + 1).padStart(5, '0')}`, + kind: 'PURCHASE', + refId: purchase.id, + amount: row.amount ?? 0, + remark: `采购 ${row.title}`, + createdById: user.id, + status: 'TODO', + }, + }); + } + } + if (row.kind === OFFICE_APPLY_KIND.ASSET_USE || (row.kind === OFFICE_APPLY_KIND.VEHICLE && row.registeredAssetId)) { + if (!row.registeredAssetId) throw new BadRequestException('领用缺少台账资产'); + occupancyId = await occupyRegistered(tx, { + registeredAssetId: row.registeredAssetId, + holderId: row.applicantId, + projectId: row.projectId, + dueAt: row.endAt || row.startAt, + }); + await tx.todoTask.create({ + data: { + tenantId: '1', + title: `交回资产:${row.title}`, + bizType: OFFICE_BIZ.APPLY, + bizId: row.id, + assigneeId: row.applicantId, + dueAt: row.endAt, + }, + }); + } + } + await tx.officeApply.update({ + where: { id: row.id }, + data: { + status: dto.result === 'APPROVED' ? OFFICE_APPLY_STATUS.APPROVED : OFFICE_APPLY_STATUS.REJECTED, + remark: dto.comment?.trim() || row.remark, + decidedById: user.id, + decidedAt: new Date(), + purchaseId, + occupancyId, + inboundAssetId, + }, + }); + await closeApprovals(tx, row.id, dto.result, user.id); + return tx.officeApply.findFirstOrThrow({ where: { id: row.id }, include: APPLY_INCLUDE }); + }); +} + +export async function deleteOfficeApply(prisma: PrismaService, id: string, user: AuthUser) { + const row = await prisma.officeApply.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('办公申请不存在'); + if (row.applicantId !== user.id && !user.roles.includes('admin')) { + throw new ForbiddenException('只能删除自己的申请'); + } + if (row.status !== OFFICE_APPLY_STATUS.PENDING && !user.roles.includes('admin')) { + throw new BadRequestException('已办理的申请不能删除'); + } + await prisma.$transaction(async (tx) => { + await tx.approvalTask.updateMany({ + where: { bizType: OFFICE_BIZ.APPLY, bizId: id, status: 'PENDING' }, + data: { status: 'CANCELLED' }, + }); + await tx.officeApply.delete({ where: { id } }); + }); + return { ok: true }; +} + +export async function listOfficeCalendar(prisma: PrismaService, user: AuthUser) { + const where: Prisma.OfficeApplyWhereInput = { + tenantId: '1', + kind: OFFICE_APPLY_KIND.VEHICLE, + status: OFFICE_APPLY_STATUS.APPROVED, + startAt: { not: null }, + }; + if (!canApproveOfficeApply(user.roles)) where.applicantId = user.id; + const items = await prisma.officeApply.findMany({ + where, + include: { registeredAsset: { select: { name: true, code: true } } }, + orderBy: { startAt: 'asc' }, + }); + return items.map((r) => ({ + id: `vehicle-${r.id}`, + kind: 'VEHICLE', + title: `用车 ${r.destination || r.title}`, + start: r.startAt, + place: r.registeredAsset?.name || r.destination, + ref: r.registeredAsset?.code || null, + overdue: isOverdue(r.endAt), + path: '/office/apply', + })); +} + +async function occupyRegistered( + tx: Prisma.TransactionClient, + dto: { registeredAssetId: string; holderId: string; projectId?: string | null; dueAt?: Date | null }, +) { + const asset = await tx.registeredAsset.findFirst({ where: { id: dto.registeredAssetId, tenantId: '1' } }); + if (!asset) throw new NotFoundException('台账中没有该资产'); + if (asset.status === 'PENDING_IN') throw new BadRequestException('该资产尚未入库'); + const busy = await tx.assetOccupancy.findFirst({ + where: { registeredAssetId: asset.id, status: ASSET_STATUS.IN_USE }, + }); + if (busy) throw new BadRequestException(`「${asset.name}」已被 ${busy.holder} 占用,请先交回`); + const holder = await tx.user.findFirst({ where: { id: dto.holderId } }); + const occupancy = await tx.assetOccupancy.create({ + data: { + tenantId: '1', + assetName: asset.name, + holder: holder?.displayName || '未登记', + holderId: dto.holderId, + projectId: dto.projectId || null, + registeredAssetId: asset.id, + dueAt: dto.dueAt || null, + status: ASSET_STATUS.IN_USE, + }, + }); + return occupancy.id; +} + +async function openApprovals(tx: Prisma.TransactionClient, applyId: string, title: string) { + const approvers = await tx.user.findMany({ + where: { + tenantId: '1', + status: 'ACTIVE', + roles: { some: { role: { code: { in: [...OFFICE_APPLY_APPROVER_ROLES] } } } }, + }, + }); + const ids = [...new Set(approvers.map((u) => u.id))]; + if (!ids.length) throw new BadRequestException('没有可审批办公申请的负责人'); + await tx.approvalTask.createMany({ + data: ids.map((assigneeId) => ({ + tenantId: '1', + title, + bizType: OFFICE_BIZ.APPLY, + bizId: applyId, + assigneeId, + })), + }); + emitSms('OFFICE_APPROVAL', { + userIds: ids, + params: { content: `待审批:${title}`, project_name: title, apply_no: 'OA' }, + }); +} + +async function closeApprovals(tx: Prisma.TransactionClient, bizId: string, result: string, actorId: string) { + await tx.approvalTask.updateMany({ + where: { bizType: OFFICE_BIZ.APPLY, bizId, assigneeId: actorId, status: 'PENDING' }, + data: { status: result }, + }); + await tx.approvalTask.updateMany({ + where: { bizType: OFFICE_BIZ.APPLY, bizId, status: 'PENDING' }, + data: { status: 'CANCELLED' }, + }); +} + +export function isVehicleNamed(name: string, spec?: string | null) { + return VEHICLE_NAME.test(`${name}${spec || ''}`); +} + +export async function listMyFlow(prisma: PrismaService, user: AuthUser, bucket?: string, kind?: string) { + const [applies, seals, leaves, expenses, loans] = await Promise.all([ + prisma.officeApply.findMany({ + where: { tenantId: '1', applicantId: user.id }, + include: { occupancy: { select: { status: true } } }, + orderBy: { createdAt: 'desc' }, + }), + prisma.sealRequest.findMany({ + where: { tenantId: '1', applicantId: user.id }, + orderBy: { createdAt: 'desc' }, + }), + user.employeeId + ? prisma.leaveRequest.findMany({ + where: { tenantId: '1', employeeId: user.employeeId }, + orderBy: { createdAt: 'desc' }, + }) + : Promise.resolve([]), + prisma.expenseClaim.findMany({ + where: { tenantId: '1', applicantId: user.id }, + orderBy: { createdAt: 'desc' }, + take: 50, + }), + prisma.loanRecord.findMany({ + where: { tenantId: '1', applicantId: user.id }, + orderBy: { createdAt: 'desc' }, + take: 50, + }), + ]); + const items = [ + ...applies.map((r) => ({ + id: r.id, + source: 'OFFICE', + kind: r.kind, + title: r.title, + status: r.status, + bucket: + r.occupancy?.status === ASSET_STATUS.IN_USE + ? 'occupying' + : r.status === 'PENDING' + ? 'pending' + : r.status === 'REJECTED' + ? 'rejected' + : 'approved', + createdAt: r.createdAt, + path: '/office/apply', + })), + ...seals.map((r) => ({ + id: r.id, + source: 'SEAL', + kind: 'SEAL', + title: `用章 ${r.sealType}:${r.reason}`, + status: r.status, + bucket: sealBucket(r), + createdAt: r.createdAt, + path: '/office/seal-apply', + })), + ...leaves.map((r) => ({ + id: r.id, + source: 'HR', + kind: r.kind, + title: r.reason || r.kind, + status: r.status, + bucket: r.status === 'PENDING' ? 'pending' : r.status === 'REJECTED' ? 'rejected' : 'approved', + createdAt: r.createdAt, + path: '/office/hr-apply', + })), + ...expenses.map((r) => ({ + id: r.id, + source: 'EXPENSE', + kind: 'EXPENSE', + title: r.purpose || r.claimNo, + status: r.status, + bucket: r.status === 'REJECTED' ? 'rejected' : r.status === 'APPROVED' ? 'approved' : 'pending', + createdAt: r.createdAt, + path: `/office/expenses/${r.id}`, + })), + ...loans.map((r) => ({ + id: r.id, + source: 'EXPENSE', + kind: 'LOAN', + title: r.purpose || r.loanNo, + status: r.status, + bucket: r.status === 'REJECTED' ? 'rejected' : r.status === 'OPEN' || r.status === 'OFFSET' ? 'approved' : 'pending', + createdAt: r.createdAt, + path: `/office/loans/${r.id}`, + })), + ].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + return { + items: items.filter((i) => { + if (kind && i.source !== kind) return false; + if (!bucket) return true; + return i.bucket === bucket; + }), + }; +} + +export function canManageNotice(roles: string[], permissions: string[] = []) { + if (['admin', 'owner', 'hr', ...DIRECTOR_ROLES].some((r) => roles.includes(r))) return true; + return permissions.some((p) => p === 'hr' || p === 'hr:notices' || p === 'hr:policies'); +} + +function stripNoticeText(html: string) { + return html + .replace(/<[^>]*>/g, ' ') + .replace(/ /gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +async function attachNoticeFiles(prisma: PrismaService, items: T[]) { + if (!items.length) return items.map((row) => ({ ...row, files: [], excerpt: '' })); + const files = await prisma.fileAsset.findMany({ + where: { tenantId: '1', bizType: 'NOTICE', bizId: { in: items.map((i) => i.id) } }, + orderBy: { createdAt: 'asc' }, + select: { id: true, fileName: true, size: true, mimeType: true, bizType: true, bizId: true }, + }); + const byId = new Map(); + for (const f of files) { + const list = byId.get(f.bizId) || []; + list.push(f); + byId.set(f.bizId, list); + } + return items.map((row) => ({ + ...row, + files: byId.get(row.id) || [], + excerpt: stripNoticeText(row.content || '').slice(0, 80), + })); +} + +export async function listNotices(prisma: PrismaService, publishedOnly = true, kind?: string, user?: AuthUser) { + const kindFilter = kind === 'POLICY' || kind === 'ANNOUNCEMENT' ? kind : undefined; + const items = await prisma.notice.findMany({ + where: { + tenantId: '1', + ...(publishedOnly ? { status: 'PUBLISHED' } : {}), + ...(kindFilter ? { kind: kindFilter } : {}), + }, + include: { createdBy: { select: { displayName: true } } }, + orderBy: { createdAt: 'desc' }, + take: 80, + }); + const visible = publishedOnly && user ? await filterNoticesForUser(prisma, items, user) : items; + return { items: await attachNoticeFiles(prisma, visible) }; +} + +export async function getNotice(prisma: PrismaService, user: AuthUser, id: string) { + const row = await prisma.notice.findFirst({ + where: { id, tenantId: '1' }, + include: { createdBy: { select: { displayName: true } } }, + }); + if (!row) throw new NotFoundException('通知不存在'); + const manage = canManageNotice(user.roles, user.permissions); + if (row.status !== 'PUBLISHED' && !manage) { + throw new NotFoundException('通知不存在'); + } + if (row.status === 'PUBLISHED' && !manage) { + const ok = (await filterNoticesForUser(prisma, [row], user)).length > 0; + if (!ok) throw new NotFoundException('通知不存在'); + } + const [withFiles] = await attachNoticeFiles(prisma, [row]); + return withFiles; +} + +export async function canSeeNotice( + prisma: PrismaService, + user: AuthUser, + row: { status: string; createdById: string; audience?: string | null; departmentIds?: string[]; userIds?: string[] }, +) { + if (canManageNotice(user.roles, user.permissions)) return true; + if (row.status !== 'PUBLISHED') return row.createdById === user.id; + return (await filterNoticesForUser(prisma, [row], user)).length > 0; +} + +type NoticeAudienceDto = { + title?: string; + content?: string; + kind?: string; + status?: string; + audience?: string; + departmentIds?: string[]; + userIds?: string[]; +}; + +function normalizeAudience(dto: NoticeAudienceDto, fallback?: { audience: string; departmentIds: string[]; userIds: string[] }) { + const audience = dto.audience || fallback?.audience || 'ALL'; + const departmentIds = audience === 'DEPT' ? [...new Set((dto.departmentIds || fallback?.departmentIds || []).filter(Boolean))] : []; + const userIds = audience === 'USER' ? [...new Set((dto.userIds || fallback?.userIds || []).filter(Boolean))] : []; + if (!['ALL', 'DEPT', 'USER'].includes(audience)) { + throw new BadRequestException('接收范围只能是全公司、按部门或按人员'); + } + return { audience, departmentIds, userIds }; +} + +async function noticeSmsUserIds( + prisma: PrismaService, + audience: string, + departmentIds: string[], + userIds: string[], +) { + if (audience === 'USER') return userIds; + if (audience === 'DEPT') { + const depts = await expandDeptIds(prisma, departmentIds); + const users = await prisma.user.findMany({ + where: { tenantId: '1', status: 'ACTIVE', employee: { departmentId: { in: depts } } }, + select: { id: true, employee: { select: { employmentStatus: true } } }, + }); + return users.filter((u) => u.employee?.employmentStatus !== 'LEFT').map((u) => u.id); + } + return []; +} + +async function expandDeptIds(prisma: PrismaService, ids: string[]) { + if (!ids.length) return []; + const all = await prisma.department.findMany({ + where: { tenantId: '1' }, + select: { id: true, parentId: true }, + }); + const children = new Map(); + for (const d of all) { + if (!d.parentId) continue; + const list = children.get(d.parentId) || []; + list.push(d.id); + children.set(d.parentId, list); + } + const out = new Set(ids); + const walk = (id: string) => { + for (const c of children.get(id) || []) { + if (!out.has(c)) { + out.add(c); + walk(c); + } + } + }; + ids.forEach(walk); + return [...out]; +} + +async function userDeptChain(prisma: PrismaService, departmentId?: string | null) { + if (!departmentId) return []; + const all = await prisma.department.findMany({ + where: { tenantId: '1' }, + select: { id: true, parentId: true }, + }); + const parentOf = new Map(all.map((d) => [d.id, d.parentId])); + const chain: string[] = []; + let cur: string | null | undefined = departmentId; + const seen = new Set(); + while (cur && !seen.has(cur)) { + chain.push(cur); + seen.add(cur); + cur = parentOf.get(cur); + } + return chain; +} + +async function filterNoticesForUser( + prisma: PrismaService, + items: T[], + user: AuthUser, +) { + const chain = await userDeptChain(prisma, user.departmentId); + return items.filter((row) => { + const audience = row.audience || 'ALL'; + if (audience === 'ALL') return true; + if (audience === 'USER') return (row.userIds || []).includes(user.id); + if (audience === 'DEPT') return (row.departmentIds || []).some((id) => chain.includes(id)); + return true; + }); +} + +export async function createNotice(prisma: PrismaService, user: AuthUser, dto: NoticeAudienceDto) { + if (!canManageNotice(user.roles, user.permissions)) throw new ForbiddenException('无权发布公告'); + const kind = dto.kind === 'POLICY' ? 'POLICY' : 'ANNOUNCEMENT'; + const publish = dto.status === 'PUBLISHED'; + const title = (dto.title || '').trim() || (publish ? '' : '未命名'); + const content = dto.content ?? ''; + if (publish && (!title || title === '未命名' || !stripNoticeText(content))) { + throw new BadRequestException('请填写标题和正文'); + } + const audience = normalizeAudience(dto); + if (publish && audience.audience === 'DEPT' && !audience.departmentIds.length) { + throw new BadRequestException('请选择要发送的部门'); + } + if (publish && audience.audience === 'USER' && !audience.userIds.length) { + throw new BadRequestException('请选择要发送的人员'); + } + const row = await prisma.notice.create({ + data: { + tenantId: '1', + title: title || '未命名', + content, + kind, + status: publish ? 'PUBLISHED' : 'DRAFT', + createdById: user.id, + ...audience, + }, + include: { createdBy: { select: { displayName: true } } }, + }); + if (publish) { + const smsUserIds = await noticeSmsUserIds(prisma, audience.audience, audience.departmentIds, audience.userIds); + emitSms('NOTICE', { + userIds: smsUserIds.length ? smsUserIds : undefined, + params: { content: `新${kind === 'POLICY' ? '制度' : '公告'}:${row.title}` }, + }); + } + const [withFiles] = await attachNoticeFiles(prisma, [row]); + return withFiles; +} + +export async function updateNotice(prisma: PrismaService, user: AuthUser, id: string, dto: NoticeAudienceDto) { + if (!canManageNotice(user.roles, user.permissions)) throw new ForbiddenException('无权编辑公告'); + const row = await prisma.notice.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('通知不存在'); + const publish = dto.status === 'PUBLISHED'; + const title = dto.title !== undefined ? dto.title.trim() : row.title; + const content = dto.content !== undefined ? dto.content : row.content; + if (publish && (!title || title === '未命名' || !stripNoticeText(content))) { + throw new BadRequestException('请填写标题和正文后再发布'); + } + const audience = normalizeAudience(dto, { + audience: row.audience, + departmentIds: row.departmentIds, + userIds: row.userIds, + }); + if (publish && audience.audience === 'DEPT' && !audience.departmentIds.length) { + throw new BadRequestException('请选择要发送的部门'); + } + if (publish && audience.audience === 'USER' && !audience.userIds.length) { + throw new BadRequestException('请选择要发送的人员'); + } + const next = await prisma.notice.update({ + where: { id }, + data: { + title: title || '未命名', + content, + ...audience, + ...(dto.status === 'DRAFT' || publish ? { status: publish ? 'PUBLISHED' : 'DRAFT' } : {}), + }, + include: { createdBy: { select: { displayName: true } } }, + }); + if (publish && row.status !== 'PUBLISHED') { + const smsUserIds = await noticeSmsUserIds(prisma, audience.audience, audience.departmentIds, audience.userIds); + emitSms('NOTICE', { + userIds: smsUserIds.length ? smsUserIds : undefined, + params: { content: `新${next.kind === 'POLICY' ? '制度' : '公告'}:${next.title}` }, + }); + } + const [withFiles] = await attachNoticeFiles(prisma, [next]); + return withFiles; +} + +export async function deleteNotice(prisma: PrismaService, user: AuthUser, id: string) { + if (!canManageNotice(user.roles, user.permissions)) throw new ForbiddenException('无权删除公告'); + const row = await prisma.notice.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('公告不存在'); + const files = await prisma.fileAsset.findMany({ where: { tenantId: '1', bizType: 'NOTICE', bizId: id } }); + const root = join(process.cwd(), 'uploads'); + for (const f of files) { + const full = join(root, f.storageKey); + if (existsSync(full)) unlinkSync(full); + } + await prisma.fileAsset.deleteMany({ where: { tenantId: '1', bizType: 'NOTICE', bizId: id } }); + await prisma.notice.delete({ where: { id } }); + return { ok: true }; +} diff --git a/apps/api/src/office/office.controller.ts b/apps/api/src/office/office.controller.ts new file mode 100644 index 0000000..7b80cb6 --- /dev/null +++ b/apps/api/src/office/office.controller.ts @@ -0,0 +1,740 @@ +import { Body, Controller, ForbiddenException, Get, Param, ParseUUIDPipe, Patch, Post, Query, BadRequestException, NotFoundException, Delete, Req, StreamableFile } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString } from 'class-validator'; +import { PrismaService } from '../prisma/prisma.service'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { BiddingService } from '../bidding/bidding.service'; +import { ContractService } from '../contract/contract.service'; +import { FinanceService } from '../finance/finance.service'; +import { HrService } from '../hr/hr.service'; +import { SealService } from '../seal/seal.service'; +import { CONTRACT_BIZ } from '../contract/contract-workflow'; +import { FINANCE_BIZ } from '../finance/finance-workflow'; +import { HR_BIZ } from '../hr/hr-workflow'; +import { SEAL_BIZ } from '../seal/seal-workflow'; +import { canAccessBidModule } from '../bidding/bid-workflow'; +import { canAccessSeal } from '../seal/seal-workflow'; +import { canAssignWork } from '../common/role-groups'; +import { LEAVE_STATUS } from '../hr/hr-workflow'; +import { emitSms } from '../sms/sms-bus'; +import { emitIm } from '../im/im-bus'; +import { dailyGreeting, geocodeAddress, locateByIp, reverseGeocode, suggestPlaces, tencentMapKey, weatherByLocation } from '../common/tencent-lbs'; +import { LOG_ACTION, writeOpLog } from '../system/op-log'; +import { + getReport, + hoursDraft, + listReports, + resolveReportRoute, + saveReport, + submitReport, +} from './work-report'; +import { + OFFICE_BIZ, + canApproveOfficeApply, + canManageNotice, + createNotice, + createOfficeApply, + decideOfficeApply, + deleteNotice, + deleteOfficeApply, + getNotice, + listMyFlow, + listNotices, + listOfficeApplies, + listOfficeCalendar, + updateNotice, +} from './office-apply'; + +class DecideDto { + @IsIn(['APPROVED', 'REJECTED']) + result!: 'APPROVED' | 'REJECTED'; + @IsOptional() + @IsString() + comment?: string; +} + +@ApiTags('office') +@ApiBearerAuth() +@Controller('office') +export class OfficeController { + constructor( + private readonly prisma: PrismaService, + private readonly bidding: BiddingService, + private readonly contracts: ContractService, + private readonly finance: FinanceService, + private readonly hr: HrService, + private readonly seals: SealService, + ) {} + + @Get('overview') + async overview(@CurrentUser() user: AuthUser) { + const canBid = canAccessBidModule(user.roles, user.permissions); + const canSeal = canAccessSeal(user.roles, user.permissions); + const [todos, approvals, bids, expenses, loans, leaves, overdueTodos, extras, officePending] = await Promise.all([ + this.prisma.todoTask.count({ + where: { assigneeId: user.id, status: 'OPEN' }, + }), + this.prisma.approvalTask.count({ + where: { assigneeId: user.id, status: 'PENDING' }, + }), + canBid + ? this.prisma.bidCase.count({ + where: { tenantId: '1', status: { notIn: ['TERMINATED', 'LOST', 'FAILED'] } }, + }) + : Promise.resolve(0), + this.prisma.expenseClaim.count({ + where: { applicantId: user.id, status: { in: ['DRAFT', 'PENDING', 'REJECTED'] } }, + }), + this.prisma.loanRecord.count({ + where: { applicantId: user.id, status: { in: ['DRAFT', 'PENDING', 'OPEN'] } }, + }), + user.employeeId + ? this.prisma.leaveRequest.count({ + where: { employeeId: user.employeeId, status: LEAVE_STATUS.PENDING }, + }) + : Promise.resolve(0), + this.prisma.todoTask.count({ + where: { assigneeId: user.id, status: 'OPEN', dueAt: { lt: new Date() } }, + }), + this.seals.overviewExtras(user), + this.prisma.officeApply.count({ + where: { applicantId: user.id, status: 'PENDING' }, + }), + ]); + return { + pendingTodos: todos, + pendingApprovals: approvals, + myPending: expenses + loans + leaves + officePending, + canBid, + canSeal, + canApprove: true, + activeBids: canBid ? bids : 0, + myExpenseDrafts: expenses + loans, + overdueTodos, + expiringQualifications: canSeal ? extras.expiringQualifications : 0, + overdueBorrows: canSeal ? extras.overdueBorrows : 0, + overdueSeals: canSeal ? extras.overdueSeals : 0, + upcomingOpens: canBid ? extras.upcomingOpens : 0, + warnings: { + qualifications: canSeal ? extras.warnings.qualifications : [], + borrows: canSeal ? extras.warnings.borrows : [], + seals: canSeal ? extras.warnings.seals : [], + opens: canBid ? extras.warnings.opens : [], + }, + }; + } + + @Get('todos') + async todos(@CurrentUser() user: AuthUser) { + const items = await this.prisma.todoTask.findMany({ + where: { assigneeId: user.id }, + orderBy: { createdAt: 'desc' }, + }); + return this.withBiz(items); + } + + @Get('approvals') + async approvals(@CurrentUser() user: AuthUser) { + const items = await this.prisma.approvalTask.findMany({ + where: { assigneeId: user.id }, + orderBy: { createdAt: 'desc' }, + }); + return this.withBiz(items); + } + + @Post('approvals/:id/decide') + async decide( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: DecideDto, + @CurrentUser() user: AuthUser, + ) { + const task = await this.prisma.approvalTask.findFirst({ + where: { id, tenantId: '1' }, + }); + let result: unknown; + if (task?.bizType === CONTRACT_BIZ.APPROVAL) { + result = await this.contracts.decideApproval(id, user, dto); + } else if (task?.bizType === FINANCE_BIZ.EXPENSE || task?.bizType === FINANCE_BIZ.LOAN) { + result = await this.finance.decideApproval(id, user, dto); + } else if (task?.bizType === SEAL_BIZ.APPROVAL) { + result = await this.seals.decideApproval(id, user, dto); + } else if (task?.bizType === HR_BIZ.LEAVE) { + result = await this.hr.decideLeaveApproval(id, user, dto); + } else if (task?.bizType === OFFICE_BIZ.APPLY) { + result = await decideOfficeApply(this.prisma, id, null, user, dto); + } else { + result = await this.bidding.decideApproval(id, user, dto); + } + await writeOpLog(this.prisma, { + userId: user.id, + username: user.username, + action: LOG_ACTION.APPROVAL, + path: `/office/approvals/${id}/decide`, + detail: `${dto.result} ${task?.title || id}`, + }); + return result; + } + + @Get('calendar') + async calendar(@CurrentUser() user: AuthUser) { + const items = await this.seals.calendar(user); + const extra = await listOfficeCalendar(this.prisma, user); + return [...items, ...extra].sort( + (a, b) => new Date(a.start || 0).getTime() - new Date(b.start || 0).getTime(), + ); + } + + @Get('weather') + async weather( + @CurrentUser() user: AuthUser, + @Query('lat') lat?: string, + @Query('lng') lng?: string, + @Req() req?: { ip?: string; headers?: Record }, + ) { + const greeting = dailyGreeting(user.displayName, user.id); + let latN = Number(lat); + let lngN = Number(lng); + let city = ''; + if (!Number.isFinite(latN) || !Number.isFinite(lngN)) { + const fwd = req?.headers?.['x-forwarded-for']; + const ip = String(Array.isArray(fwd) ? fwd[0] : fwd || req?.ip || '') + .split(',')[0] + .trim(); + const located = await locateByIp(ip.startsWith('127.') || ip === '::1' ? undefined : ip); + if (located) { + latN = located.lat; + lngN = located.lng; + city = located.city; + } + } else { + const rev = await reverseGeocode(latN, lngN); + city = rev?.city || ''; + } + const weather = Number.isFinite(latN) && Number.isFinite(lngN) ? await weatherByLocation(latN, lngN, city) : null; + return { + greeting, + city: weather?.city || city || '', + lat: Number.isFinite(latN) ? latN : undefined, + lng: Number.isFinite(lngN) ? lngN : undefined, + weather: weather?.weather || '', + temperature: weather?.temperature, + wind: weather?.wind, + advice: weather?.advice || '', + summary: weather + ? `当前${weather.city}市天气${weather.weather}${weather.temperature != null ? ` ${weather.temperature}℃` : ''},${weather.advice}` + : '', + }; + } + + @Post('geo/geocode') + async geocode(@Body() body: { address?: string }) { + const address = body.address?.trim(); + if (!address) throw new BadRequestException('请填写地址'); + const hit = await geocodeAddress(address); + if (!hit) throw new BadRequestException('腾讯定位没有解析到这个地址,请写得更完整一些'); + return hit; + } + + @Get('geo/map-config') + mapConfig() { + const key = process.env.TENCENT_MAP_KEY || ''; + if (!key) throw new BadRequestException('还没配置地图密钥'); + return { key }; + } + + @Post('geo/reverse') + async reverse(@Body() body: { lat?: number; lng?: number }) { + const lat = Number(body.lat); + const lng = Number(body.lng); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) throw new BadRequestException('请传入坐标'); + const hit = await reverseGeocode(lat, lng); + if (!hit) throw new BadRequestException('没有反查出这个点的地址'); + return { + lat, + lng, + address: hit.address, + title: hit.title || hit.address, + city: hit.city, + pois: hit.pois || [], + }; + } + + @Get('geo/suggest') + async suggest( + @Query('keyword') keyword?: string, + @Query('lat') lat?: string, + @Query('lng') lng?: string, + ) { + const q = (keyword || '').trim(); + if (!q) return []; + return suggestPlaces(q, Number(lat), Number(lng)); + } + + @Get('geo/static-map') + async staticMap(@Query('lat') lat?: string, @Query('lng') lng?: string) { + const la = Number(lat); + const ln = Number(lng); + if (!Number.isFinite(la) || !Number.isFinite(ln)) throw new BadRequestException('请传入坐标'); + const key = tencentMapKey(); + if (!key) throw new BadRequestException('还没配置地图密钥'); + const url = `https://apis.map.qq.com/ws/staticmap/v2/?size=600*280¢er=${la},${ln}&zoom=16&markers=size:large|color:0x07C160|${la},${ln}&key=${encodeURIComponent(key)}`; + const img = await fetch(url, { headers: { Referer: 'https://oa.fysxkj.com/' } }); + if (!img.ok) throw new BadRequestException('地图瓦片加载失败'); + const buf = Buffer.from(await img.arrayBuffer()); + return new StreamableFile(buf, { type: img.headers.get('content-type') || 'image/png' }); + } + + @Get('flow') + flow( + @CurrentUser() user: AuthUser, + @Query('bucket') bucket?: string, + @Query('kind') kind?: string, + ) { + return listMyFlow(this.prisma, user, bucket, kind); + } + + @Get('notices') + notices( + @CurrentUser() user: AuthUser, + @Query('kind') kind?: string, + @Query('scope') scope?: string, + ) { + const manage = scope === 'manage' && canManageNotice(user.roles, user.permissions); + return listNotices(this.prisma, !manage, kind, user); + } + + @Get('notices/:id') + noticeOne(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) { + return getNotice(this.prisma, user, id); + } + + @Post('notices') + createNoticePost( + @CurrentUser() user: AuthUser, + @Body() body: { title?: string; content?: string; kind?: string; status?: string; audience?: string; departmentIds?: string[]; userIds?: string[] }, + ) { + return createNotice(this.prisma, user, body); + } + + @Patch('notices/:id') + patchNotice( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { title?: string; content?: string; status?: string; audience?: string; departmentIds?: string[]; userIds?: string[] }, + ) { + return updateNotice(this.prisma, user, id, body); + } + + @Delete('notices/:id') + removeNotice(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) { + return deleteNotice(this.prisma, user, id); + } + + @Get('applies') + applies( + @CurrentUser() user: AuthUser, + @Query('bucket') bucket?: string, + @Query('scope') scope?: string, + ) { + return listOfficeApplies(this.prisma, user, bucket, scope); + } + + @Post('applies') + createApply( + @CurrentUser() user: AuthUser, + @Body() + body: { + kind?: string; + title?: string; + reason?: string; + assetKind?: string; + registeredAssetId?: string; + amount?: number; + destination?: string; + startAt?: string; + endAt?: string; + projectId?: string; + }, + ) { + return createOfficeApply(this.prisma, user, body); + } + + @Get('applies/:id') + async applyOne(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) { + const row = await this.prisma.officeApply.findFirst({ + where: { id, tenantId: '1' }, + include: { + applicant: { select: { id: true, displayName: true, username: true, employee: { select: { name: true, department: { select: { name: true } } } } } }, + decidedBy: { select: { id: true, displayName: true, username: true } }, + registeredAsset: { select: { id: true, kind: true, name: true, code: true, status: true } }, + project: { select: { id: true, projectNo: true, name: true } }, + purchase: { select: { id: true, title: true, status: true, amount: true } }, + occupancy: { select: { id: true, status: true, assetName: true } }, + inboundAsset: { select: { id: true, name: true, status: true, kind: true } }, + }, + }); + if (!row) throw new NotFoundException('申请不存在'); + if (row.applicantId !== user.id && !canApproveOfficeApply(user.roles)) { + throw new ForbiddenException('无权查看该申请'); + } + return row; + } + + @Patch('todos/:id') + async patchTodo( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { status?: string }, + ) { + const row = await this.prisma.todoTask.findFirst({ where: { id, assigneeId: user.id } }); + if (!row) throw new NotFoundException('待办不存在'); + const status = body.status === 'DONE' ? 'DONE' : body.status === 'OPEN' ? 'OPEN' : row.status; + return this.prisma.todoTask.update({ where: { id }, data: { status } }); + } + + @Post('applies/:id/decide') + decideApply( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: DecideDto, + ) { + if (!canApproveOfficeApply(user.roles)) throw new ForbiddenException('无权审批办公申请'); + return decideOfficeApply(this.prisma, null, id, user, dto); + } + + @Delete('applies/:id') + removeApply(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) { + return deleteOfficeApply(this.prisma, id, user); + } + + @Get('reports/route') + reportRoute(@CurrentUser() user: AuthUser, @Query('periodType') periodType?: string) { + return resolveReportRoute(this.prisma, user, periodType); + } + + @Get('reports/hours-draft') + hoursDraftGet( + @CurrentUser() user: AuthUser, + @Query('periodType') periodType?: string, + @Query('date') date?: string, + ) { + return hoursDraft(this.prisma, user, periodType || 'DAY', date); + } + + @Get('reports') + reports(@CurrentUser() user: AuthUser) { + return listReports(this.prisma, user); + } + + @Get('reports/:id') + report(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) { + return getReport(this.prisma, user, id); + } + + @Post('reports') + createReport( + @CurrentUser() user: AuthUser, + @Body() body: { periodType?: string; date?: string; content?: string; submit?: boolean }, + ) { + return saveReport(this.prisma, user, body); + } + + @Patch('reports/:id') + updateReport( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { periodType?: string; date?: string; content?: string; submit?: boolean }, + ) { + return saveReport(this.prisma, user, body, id); + } + + @Post('reports/:id/submit') + submit(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) { + return submitReport(this.prisma, user, id); + } + + @Get('my-expenses') + myExpenses(@CurrentUser() user: AuthUser) { + return this.finance.myWorkbench(user); + } + + @Get('work-assignments') + async workAssignments(@CurrentUser() user: AuthUser) { + const manage = canAssignWork(user.roles); + const items = await this.prisma.workAssignment.findMany({ + where: manage + ? { tenantId: '1', OR: [{ createdById: user.id }, { assigneeId: user.id }] } + : { tenantId: '1', assigneeId: user.id }, + include: { + assignee: { select: { id: true, displayName: true, username: true } }, + createdBy: { select: { id: true, displayName: true, username: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + return { items }; + } + + @Post('work-assignments') + async createWork( + @CurrentUser() user: AuthUser, + @Body() body: { title?: string; content?: string; assigneeId?: string; dueAt?: string }, + ) { + if (!canAssignWork(user.roles)) throw new ForbiddenException('无权分配工作'); + const title = body.title?.trim(); + if (!title) throw new BadRequestException('请填写工作标题'); + if (!body.assigneeId) throw new BadRequestException('请选择执行人'); + const assignee = await this.prisma.user.findFirst({ + where: { id: body.assigneeId, tenantId: '1', status: 'ACTIVE' }, + }); + if (!assignee) throw new NotFoundException('执行人不存在'); + return this.prisma.$transaction(async (tx) => { + const row = await tx.workAssignment.create({ + data: { + tenantId: '1', + title, + content: body.content?.trim() || '', + assigneeId: assignee.id, + dueAt: body.dueAt ? new Date(body.dueAt) : null, + createdById: user.id, + }, + include: { + assignee: { select: { id: true, displayName: true, username: true } }, + createdBy: { select: { id: true, displayName: true, username: true } }, + }, + }); + await tx.todoTask.create({ + data: { + tenantId: '1', + title: `工作安排:${title}`, + bizType: 'WORK_ASSIGN', + bizId: row.id, + assigneeId: assignee.id, + dueAt: row.dueAt, + }, + }); + emitSms('WORK_ASSIGN', { + userIds: [assignee.id], + params: { + assigner: user.displayName, + task_name: title, + task_code: `WA${row.id.replace(/-/g, '').slice(0, 10)}`, + }, + actorId: user.id, + }); + emitIm({ + userIds: [assignee.id], + kind: 'todo', + title: `工作安排:${title}`, + body: `${user.displayName} 安排了工作:${title}`, + bizType: 'WORK_ASSIGN', + bizId: row.id, + fromUserId: user.id, + }); + return row; + }); + } + + @Patch('work-assignments/:id') + async updateWork( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + @Body() body: { status?: string; title?: string; content?: string }, + ) { + const row = await this.prisma.workAssignment.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('工作安排不存在'); + const canEdit = row.createdById === user.id && canAssignWork(user.roles); + const canDone = row.assigneeId === user.id; + if (!canEdit && !canDone) throw new ForbiddenException('无权修改该工作安排'); + const nextStatus = body.status === 'DONE' || body.status === 'OPEN' ? body.status : undefined; + if (!canEdit && nextStatus !== 'DONE') throw new ForbiddenException('执行人只能标记完成'); + const updated = await this.prisma.workAssignment.update({ + where: { id }, + data: canEdit + ? { + title: body.title?.trim() || undefined, + content: body.content === undefined ? undefined : body.content.trim(), + status: nextStatus, + } + : { status: 'DONE' }, + }); + if (updated.status === 'DONE') { + await this.prisma.todoTask.updateMany({ + where: { bizId: id, bizType: 'WORK_ASSIGN', status: 'OPEN' }, + data: { status: 'DONE' }, + }); + } + return updated; + } + + @Delete('work-assignments/:id') + async deleteWork(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) { + const row = await this.prisma.workAssignment.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('工作安排不存在'); + if (row.createdById !== user.id && !user.roles.includes('admin')) { + throw new ForbiddenException('只能删除自己分配的工作'); + } + await this.prisma.$transaction(async (tx) => { + await tx.todoTask.updateMany({ + where: { bizId: id, bizType: 'WORK_ASSIGN', status: 'OPEN' }, + data: { status: 'CANCELLED' }, + }); + await tx.workAssignment.delete({ where: { id } }); + }); + return { ok: true }; + } + + private async withBiz(items: T[]) { + const ids = [...new Set(items.map((i) => i.bizId).filter((id): id is string => Boolean(id)))]; + if (!ids.length) { + return items.map((i) => ({ + ...i, + bidCase: null, + contract: null, + expense: null, + loan: null, + project: null, + sealRequest: null, + borrow: null, + officeApply: null, + leaveRequest: null, + })); + } + const person = { + select: { + id: true, + displayName: true, + username: true, + employee: { select: { name: true, department: { select: { name: true } } } }, + }, + }; + const [bids, contracts, expenses, loans, projects, sealRequests, borrows, officeApplies, leaveRequests] = await Promise.all([ + this.prisma.bidCase.findMany({ + where: { id: { in: ids } }, + select: { + id: true, + bidNo: true, + externalNo: true, + name: true, + status: true, + remark: true, + summary: true, + quoteAmount: true, + createdBy: person, + party: { select: { name: true } }, + }, + }), + this.prisma.contract.findMany({ + where: { id: { in: ids } }, + select: { + id: true, + contractNo: true, + name: true, + status: true, + amount: true, + remark: true, + createdBy: person, + }, + }), + this.prisma.expenseClaim.findMany({ + where: { id: { in: ids } }, + select: { + id: true, + claimNo: true, + status: true, + amount: true, + purpose: true, + remark: true, + expenseCategory: true, + costType: true, + applicant: person, + bidCase: { select: { id: true, bidNo: true, externalNo: true, name: true } }, + project: { select: { id: true, projectNo: true, name: true } }, + }, + }), + this.prisma.loanRecord.findMany({ + where: { id: { in: ids } }, + select: { + id: true, + loanNo: true, + status: true, + amount: true, + purpose: true, + remark: true, + costType: true, + applicant: person, + bidCase: { select: { id: true, bidNo: true, externalNo: true, name: true } }, + project: { select: { id: true, projectNo: true, name: true } }, + }, + }), + this.prisma.project.findMany({ + where: { id: { in: ids } }, + select: { id: true, projectNo: true, name: true, status: true }, + }), + this.prisma.sealRequest.findMany({ + where: { id: { in: ids } }, + select: { id: true, sealType: true, status: true, takeOut: true, reason: true, applicant: person }, + }), + this.prisma.credentialBorrow.findMany({ + where: { id: { in: ids } }, + select: { id: true, itemName: true, status: true, borrower: true }, + }), + this.prisma.officeApply.findMany({ + where: { id: { in: ids } }, + select: { + id: true, + kind: true, + title: true, + status: true, + amount: true, + reason: true, + destination: true, + assetKind: true, + applicant: person, + registeredAsset: { select: { name: true, code: true, kind: true } }, + }, + }), + this.prisma.leaveRequest.findMany({ + where: { id: { in: ids } }, + select: { + id: true, + kind: true, + status: true, + days: true, + reason: true, + startAt: true, + endAt: true, + comment: true, + employee: { + select: { id: true, name: true, employeeNo: true, department: { select: { name: true } } }, + }, + }, + }), + ]); + const bidMap = new Map(bids.map((b) => [b.id, b])); + const contractMap = new Map(contracts.map((c) => [c.id, c])); + const expenseMap = new Map(expenses.map((e) => [e.id, e])); + const loanMap = new Map(loans.map((l) => [l.id, l])); + const projectMap = new Map(projects.map((p) => [p.id, p])); + const sealMap = new Map(sealRequests.map((s) => [s.id, s])); + const borrowMap = new Map(borrows.map((b) => [b.id, b])); + const applyMap = new Map(officeApplies.map((a) => [a.id, a])); + const leaveMap = new Map(leaveRequests.map((l) => [l.id, l])); + return items.map((i) => ({ + ...i, + bidCase: i.bizId ? bidMap.get(i.bizId) ?? null : null, + contract: i.bizId ? contractMap.get(i.bizId) ?? null : null, + expense: i.bizId ? expenseMap.get(i.bizId) ?? null : null, + loan: i.bizId ? loanMap.get(i.bizId) ?? null : null, + project: i.bizId ? projectMap.get(i.bizId) ?? null : null, + sealRequest: i.bizId ? sealMap.get(i.bizId) ?? null : null, + borrow: i.bizId ? borrowMap.get(i.bizId) ?? null : null, + officeApply: i.bizId ? applyMap.get(i.bizId) ?? null : null, + leaveRequest: i.bizId + ? leaveMap.has(i.bizId) + ? { ...leaveMap.get(i.bizId)!, days: Number(leaveMap.get(i.bizId)!.days) } + : null + : null, + })); + } +} diff --git a/apps/api/src/office/work-report.ts b/apps/api/src/office/work-report.ts new file mode 100644 index 0000000..4cf61f0 --- /dev/null +++ b/apps/api/src/office/work-report.ts @@ -0,0 +1,325 @@ +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { DIRECTOR_ROLES } from '../common/role-groups'; +import { descendantDeptIds } from '../common/data-scope'; +import { PERSON_SELECT, parsePeopleIds, resolveReportPeople } from '../common/org-chain'; +import { canManageSystem, periodRange } from '../system/ops-workflow'; +import { emitIm } from '../im/im-bus'; + +export const REPORT_STATUS = { + DRAFT: 'DRAFT', + SENT: 'SENT', +} as const; + +export const REPORT_BIZ = { + NOTICE: 'WORK_REPORT', +} as const; + +export const PERIOD_LABEL: Record = { + DAY: '日报', + WEEK: '周报', + MONTH: '月报', +}; + +const REPORT_VIEW_ONLY = ['admin', 'owner', ...DIRECTOR_ROLES]; + +/** 总监、负责人只看下属汇报,不写日报。 */ +export function canWriteWorkReport(roles: string[]) { + return !REPORT_VIEW_ONLY.some((r) => roles.includes(r)); +} + +export function reportFlowKind(input: { + authorRoles: string[]; + hasSubordinates: boolean; + title: string; +}): 'DIRECTOR_TO_OWNER' | 'PM_TO_DIRECTOR' | 'STAFF_TO_PM' { + if (REPORT_VIEW_ONLY.some((r) => input.authorRoles.includes(r))) return 'DIRECTOR_TO_OWNER'; + if (input.hasSubordinates || /经理|主管/.test(input.title || '')) return 'PM_TO_DIRECTOR'; + return 'STAFF_TO_PM'; +} + +export function parseReportDate(value?: string) { + if (!value) return new Date(); + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value); + if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + return new Date(value); +} + +type Person = { id: string; displayName: string; username: string }; + +const CC_FIELD: Record = { + DAY: 'ccDaily', + WEEK: 'ccWeekly', + MONTH: 'ccMonthly', +}; + +function uniquePeople(...lists: Array) { + const seen = new Set(); + const out: Person[] = []; + for (const item of lists) { + const people = Array.isArray(item) ? item : item ? [item] : []; + for (const p of people) { + if (!p || seen.has(p.id)) continue; + seen.add(p.id); + out.push(p); + } + } + return out; +} + +export async function resolveReportRoute(prisma: PrismaService, user: AuthUser, periodType?: string) { + const { me, manager: managerUser, extraManagers, director } = await resolveReportPeople(prisma, user.id); + const owner = await prisma.user.findFirst({ + where: { tenantId: '1', username: 'owner', status: 'ACTIVE' }, + select: PERSON_SELECT, + }); + const kind = reportFlowKind({ + authorRoles: user.roles, + hasSubordinates: (me?.employee?.reports.length || 0) > 0, + title: me?.employee?.title || '', + }); + const ccKey = CC_FIELD[(periodType || 'DAY').toUpperCase()] || 'ccDaily'; + const ccEmpIds = parsePeopleIds(me?.employee?.[ccKey]); + const archiveCc = ccEmpIds.length + ? ( + await prisma.user.findMany({ + where: { tenantId: '1', status: 'ACTIVE', employeeId: { in: ccEmpIds } }, + select: PERSON_SELECT, + }) + ).filter((p) => p.id !== user.id) + : []; + const extraMgrPeople = extraManagers.filter((p) => p.id !== user.id); + + const canWrite = canWriteWorkReport(user.roles); + if (kind === 'DIRECTOR_TO_OWNER') { + return { + kind, + hint: '总监只查看下属的日报、周报、月报,不用自己写。', + to: null, + cc: null, + ccPeople: [] as Person[], + canWrite, + }; + } + if (kind === 'PM_TO_DIRECTOR') { + const to = pick(director, extraMgrPeople[0], owner); + const extra = uniquePeople( + extraMgrPeople.filter((p) => p.id !== to?.id), + archiveCc.filter((p) => p.id !== to?.id), + ); + return { + kind, + hint: '项目经理 → 部门总监', + to, + cc: extra[0] || null, + ccPeople: extra, + canWrite, + }; + } + const to = pick(managerUser, extraMgrPeople[0], director, owner); + const directorCc = + director && to && director.id !== to.id && director.id !== user.id ? [director] : []; + const ccPeople = uniquePeople( + managerUser && managerUser.id !== to?.id ? managerUser : null, + extraMgrPeople.filter((p) => p.id !== to?.id), + directorCc, + archiveCc.filter((p) => p.id !== to?.id && p.id !== director?.id), + ); + return { + kind, + hint: '普通员工 → 项目经理 → 部门总监', + to, + cc: ccPeople[0] || null, + ccPeople, + canWrite, + }; +} + +export async function hoursDraft(prisma: PrismaService, user: AuthUser, periodType: string, date?: string) { + const type = (periodType || 'DAY').toUpperCase(); + if (!['DAY', 'WEEK', 'MONTH'].includes(type)) throw new BadRequestException('周期须是日/周/月'); + const { start, end, period } = periodRange(type, parseReportDate(date)); + const sheets = await prisma.timesheet.findMany({ + where: { userId: user.id, workDate: { gte: start, lt: end } }, + include: { project: { select: { projectNo: true, name: true } }, task: { select: { name: true } } }, + }); + const tasks = await prisma.projectTask.findMany({ + where: { assigneeId: user.id, status: 'OPEN' }, + include: { project: { select: { projectNo: true } } }, + }); + const hours = sheets.reduce((s, r) => s + Number(r.hours), 0); + const lines = [ + `${PERIOD_LABEL[type] || type} ${period},本人工时 ${hours} 小时。`, + ...sheets.map((s) => + `- ${s.workDate.toISOString().slice(0, 10)} ${s.project.projectNo} ${s.task?.name || ''} ${Number(s.hours)}h ${s.note || ''}`.trim(), + ), + tasks.length + ? `未完成任务:${tasks.map((t) => `${t.project.projectNo} ${t.name}`).join(';')}` + : '当前没有未完成任务。', + ]; + return { period, periodType: type, hours, content: lines.join('\n') }; +} + +const INCLUDE = { + author: { select: PERSON_SELECT }, + toUser: { select: PERSON_SELECT }, + ccUser: { select: PERSON_SELECT }, +}; + +export function serializeReport(row: T) { + return { ...row, hours: Number(row.hours) }; +} + +export async function listReports(prisma: PrismaService, user: AuthUser) { + let where: { tenantId: string; OR?: object[] }; + if (canManageSystem(user.roles)) { + where = { tenantId: '1' }; + } else if (!canWriteWorkReport(user.roles)) { + const deptIds = await descendantDeptIds(prisma, user.departmentId); + where = { + tenantId: '1', + OR: [ + { toUserId: user.id }, + { ccUserId: user.id }, + ...(deptIds.length ? [{ author: { employee: { departmentId: { in: deptIds } } } }] : []), + ], + }; + } else { + where = { + tenantId: '1', + OR: [{ authorId: user.id }, { toUserId: user.id }, { ccUserId: user.id }], + }; + } + const items = await prisma.workReport.findMany({ + where, + include: INCLUDE, + orderBy: { createdAt: 'desc' }, + take: 80, + }); + return { items: items.map(serializeReport) }; +} + +export async function getReport(prisma: PrismaService, user: AuthUser, id: string) { + const row = await prisma.workReport.findFirst({ where: { id, tenantId: '1' }, include: INCLUDE }); + if (!row) throw new NotFoundException('汇报不存在'); + await assertCanSee(prisma, user, row); + return serializeReport(row); +} + +export async function saveReport( + prisma: PrismaService, + user: AuthUser, + dto: { periodType?: string; date?: string; content?: string; submit?: boolean }, + id?: string, +) { + if (!canWriteWorkReport(user.roles)) { + throw new ForbiddenException('总监只查看汇报,不写日报'); + } + const periodType = (dto.periodType || 'DAY').toUpperCase(); + if (!['DAY', 'WEEK', 'MONTH'].includes(periodType)) throw new BadRequestException('周期须是日/周/月'); + const content = (dto.content || '').trim(); + if (dto.submit && !content) throw new BadRequestException('请先写汇报内容再提交'); + const when = parseReportDate(dto.date); + const { start, period } = periodRange(periodType, when); + const draft = await hoursDraft(prisma, user, periodType, dto.date); + const route = await resolveReportRoute(prisma, user, periodType); + + if (id) { + const row = await prisma.workReport.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('汇报不存在'); + if (row.authorId !== user.id) throw new ForbiddenException('只能改自己的汇报'); + if (row.status !== REPORT_STATUS.DRAFT) throw new BadRequestException('已提交的汇报不能再改'); + } + + const data = { + period, + periodType, + periodStart: start, + hours: draft.hours, + content, + status: dto.submit ? REPORT_STATUS.SENT : REPORT_STATUS.DRAFT, + toUserId: dto.submit ? route.to?.id || null : null, + ccUserId: dto.submit ? route.cc?.id || null : null, + submittedAt: dto.submit ? new Date() : null, + }; + + const saved = id + ? await prisma.workReport.update({ where: { id }, data, include: INCLUDE }) + : await prisma.workReport.create({ + data: { tenantId: '1', authorId: user.id, ...data }, + include: INCLUDE, + }); + + if (dto.submit) await notifyRecipients(prisma, saved, route.ccPeople?.map((p) => p.id) || []); + return serializeReport(saved); +} + +export async function submitReport(prisma: PrismaService, user: AuthUser, id: string) { + const row = await prisma.workReport.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('汇报不存在'); + if (row.authorId !== user.id) throw new ForbiddenException('只能提交自己的汇报'); + if (row.status === REPORT_STATUS.SENT) throw new BadRequestException('已经提交过了'); + if (!row.content.trim()) throw new BadRequestException('请先写汇报内容再提交'); + return saveReport(prisma, user, { + periodType: row.periodType, + date: row.periodStart ? row.periodStart.toISOString().slice(0, 10) : undefined, + content: row.content, + submit: true, + }, id); +} + +async function assertCanSee( + prisma: PrismaService, + user: AuthUser, + row: { authorId: string; toUserId: string | null; ccUserId: string | null }, +) { + if (canManageSystem(user.roles)) return; + if (row.authorId === user.id || row.toUserId === user.id || row.ccUserId === user.id) return; + if (!canWriteWorkReport(user.roles) && user.departmentId) { + const author = await prisma.user.findUnique({ + where: { id: row.authorId }, + include: { employee: { select: { departmentId: true } } }, + }); + const deptIds = await descendantDeptIds(prisma, user.departmentId); + if (author?.employee?.departmentId && deptIds.includes(author.employee.departmentId)) return; + } + throw new ForbiddenException('无权查看这份汇报'); +} + +function pick(...people: Array) { + return people.find((p) => p) || null; +} + +async function notifyRecipients( + prisma: PrismaService, + row: { + id: string; + period: string; + periodType: string; + author: { displayName: string }; + toUserId: string | null; + ccUserId: string | null; + }, + extraCcIds: string[] = [], +) { + const kind = PERIOD_LABEL[row.periodType] || '汇报'; + const ccIds = [...new Set([row.ccUserId, ...extraCcIds].filter((id): id is string => Boolean(id)))]; + const targets = [ + row.toUserId ? { id: row.toUserId, title: `${row.author.displayName} 的${kind}(${row.period})` } : null, + ...ccIds + .filter((id) => id !== row.toUserId) + .map((id) => ({ id, title: `抄送:${row.author.displayName} 的${kind}(${row.period})` })), + ].filter((x): x is { id: string; title: string } => Boolean(x)); + if (!targets.length) return; + // 汇报是系统通知,不是需要执行/关闭的待办事项。 + // 接收人可在「工作汇报」查看,下发一条系统消息作为提醒。 + emitIm({ + userIds: targets.map((t) => t.id), + kind: 'todo', + title: '工作汇报', + body: `${row.author.displayName}提交了${kind}(${row.period})`, + bizType: REPORT_BIZ.NOTICE, + bizId: row.id, + }); +} diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/apps/api/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts new file mode 100644 index 0000000..abcaa81 --- /dev/null +++ b/apps/api/src/prisma/prisma.service.ts @@ -0,0 +1,56 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit() { + await this.$connect(); + this.hookTaskPush(); + } + + private hookTaskPush() { + const todoCreate = this.todoTask.create.bind(this.todoTask); + this.todoTask.create = (async (args: Parameters[0]) => { + const row = await todoCreate(args); + const { emitIm } = await import('../im/im-bus'); + if (row?.assigneeId && row.title) { + emitIm({ + userIds: [row.assigneeId], + kind: 'todo', + title: row.title, + body: row.title, + bizType: row.bizType, + bizId: row.bizId || undefined, + }); + } + return row; + }) as unknown as typeof todoCreate; + const approvalCreate = this.approvalTask.create.bind(this.approvalTask); + this.approvalTask.create = (async (args: Parameters[0]) => { + const row = await approvalCreate(args); + const { emitIm } = await import('../im/im-bus'); + if (row?.assigneeId && row.title) { + emitIm({ + userIds: [row.assigneeId], + kind: 'approval', + title: row.title, + body: `待审批:${row.title}`, + bizType: row.bizType, + bizId: row.bizId || undefined, + }); + } + return row; + }) as unknown as typeof approvalCreate; + } + + async onModuleDestroy() { + await this.$disconnect(); + } + + async transaction(fn: (tx: PrismaClient) => Promise): Promise { + return this.$transaction(fn); + } +} diff --git a/apps/api/src/project/project-workflow.spec.ts b/apps/api/src/project/project-workflow.spec.ts new file mode 100644 index 0000000..6d111da --- /dev/null +++ b/apps/api/src/project/project-workflow.spec.ts @@ -0,0 +1,73 @@ +import { + assertExpenseBudgetCategory, + budgetWouldExceed, + canAcceptCustomer, + canAcceptInternal, + canAccessProject, + canCreateProject, + canFillTimesheet, + canManageWbs, + canSetBudget, + encodeExpenseBudget, + envelopeOfExpense, + laborCost, + splitEven, +} from './project-workflow'; + +describe('project-workflow', () => { + it('lets tech directors manage WBS and employees fill timesheets', () => { + expect(canManageWbs(['tech_director'])).toBe(true); + expect(canManageWbs(['employee'])).toBe(false); + expect(canFillTimesheet(['employee'], ['project:timesheets'])).toBe(true); + expect(canFillTimesheet(['hr'], ['hr:org'])).toBe(false); + expect(canSetBudget(['finance'])).toBe(true); + expect(canSetBudget(['biz_staff'])).toBe(true); + expect(canSetBudget(['biz_director'])).toBe(true); + }); + + it('lets contract and finance roles open the project ledger', () => { + expect(canAccessProject(['biz_director'], ['contract:list'])).toBe(true); + expect(canAccessProject(['finance'], ['finance:expenses'])).toBe(true); + expect(canAccessProject(['employee'], ['project:timesheets'])).toBe(false); + expect(canAccessProject(['hr'], ['hr:org'])).toBe(false); + }); + + it('splits internal and customer acceptance', () => { + expect(canAcceptInternal(['tech_director'])).toBe(true); + expect(canAcceptInternal(['biz_director'])).toBe(false); + expect(canAcceptCustomer(['biz_director'])).toBe(true); + expect(canAcceptCustomer(['employee'])).toBe(false); + }); + + it('lets pm and biz director create a ledger project', () => { + expect(canCreateProject(['pm'])).toBe(true); + expect(canCreateProject(['biz_director'])).toBe(true); + expect(canCreateProject(['employee'])).toBe(false); + }); + + it('splits contract amount evenly between business and tech', () => { + expect(splitEven(100)).toEqual({ business: 50, tech: 50 }); + expect(splitEven(100.01)).toEqual({ business: 50.01, tech: 50 }); + expect(splitEven(0)).toEqual({ business: 0, tech: 0 }); + }); + + it('computes labor cost and budget overflow', () => { + expect(laborCost(2, 200)).toBe(400); + expect(budgetWouldExceed(400, 800, 1000)).toBe(true); + expect(budgetWouldExceed(400, 200, 1000)).toBe(false); + expect(budgetWouldExceed(400, 200, null)).toBe(false); + }); + + it('requires travel or purchase on project expenses', () => { + expect(assertExpenseBudgetCategory('PROJECT')).toMatch(/差旅或采购/); + expect(assertExpenseBudgetCategory('PROJECT', 'LABOR')).toMatch(/差旅或采购/); + expect(assertExpenseBudgetCategory('PROJECT', 'TRAVEL')).toBeNull(); + expect(assertExpenseBudgetCategory('PROJECT', 'TECH')).toBeNull(); + expect(assertExpenseBudgetCategory('BID', undefined)).toBeNull(); + expect(envelopeOfExpense('TRAVEL')).toBe('BUSINESS'); + expect(envelopeOfExpense('TECH')).toBe('TECH'); + expect(encodeExpenseBudget('TRAVEL', 'TECH')).toBe('TECH'); + expect(encodeExpenseBudget('TRAVEL', 'BUSINESS')).toBe('TRAVEL'); + expect(encodeExpenseBudget('PURCHASE', 'BUSINESS')).toBe('PURCHASE'); + }); +}); diff --git a/apps/api/src/project/project-workflow.ts b/apps/api/src/project/project-workflow.ts new file mode 100644 index 0000000..f1786d9 --- /dev/null +++ b/apps/api/src/project/project-workflow.ts @@ -0,0 +1,190 @@ +import { isTechLead } from '../common/role-groups'; + +export const PROJECT_STATUS = { + NOT_STARTED: 'NOT_STARTED', + ACTIVE: 'ACTIVE', + INTERNAL_ACCEPTED: 'INTERNAL_ACCEPTED', + ACCEPTED: 'ACCEPTED', + DONE: 'DONE', +} as const; + +export const PROJECT_LEDGER_STATUSES = [PROJECT_STATUS.NOT_STARTED, PROJECT_STATUS.ACTIVE] as const; +export const PROJECT_ACCEPTANCE_STATUSES = [ + PROJECT_STATUS.INTERNAL_ACCEPTED, + PROJECT_STATUS.ACCEPTED, + PROJECT_STATUS.DONE, +] as const; + +export function isProjectClosed(status: string) { + return status === PROJECT_STATUS.ACCEPTED || status === PROJECT_STATUS.DONE; +} + +export function partyFromContractRemark(remark?: string | null) { + const m = String(remark || '').match(/甲方:([^/]+)/); + return m?.[1]?.trim() || ''; +} + +export function compactProjectName(name: string) { + return String(name || '') + .replace(/[()()\[\]【】\s\-—_.,,、]/g, '') + .replace(/招标公告|采购项目|技术服务合同|合同/g, '') + .replace(/检查院/g, '检察院'); +} + +export function namesOverlap(a: string, b: string) { + const na = compactProjectName(a); + const nb = compactProjectName(b); + if (!na || !nb) return false; + if (na === nb) return true; + const shorter = na.length <= nb.length ? na : nb; + const longer = na.length <= nb.length ? nb : na; + return shorter.length >= 8 && longer.includes(shorter); +} + +export const BUDGET_CATEGORY = { + LABOR: 'LABOR', + TRAVEL: 'TRAVEL', + PURCHASE: 'PURCHASE', + BUSINESS: 'BUSINESS', + TECH: 'TECH', +} as const; + +export const EXPENSE_BUDGET_CATEGORIES = [BUDGET_CATEGORY.TRAVEL, BUDGET_CATEGORY.PURCHASE, BUDGET_CATEGORY.TECH] as const; +export const LINE_BUDGET_CATEGORIES = [ + BUDGET_CATEGORY.LABOR, + BUDGET_CATEGORY.TRAVEL, + BUDGET_CATEGORY.PURCHASE, +] as const; +export const ENVELOPE_BUDGET_CATEGORIES = [BUDGET_CATEGORY.BUSINESS, BUDGET_CATEGORY.TECH] as const; + +/** 合同额(或项目总额)按商务、技术各一半。分到分后余数给技术。 */ +export function splitEven(amount: number): { business: number; tech: number } { + const n = roundMoney(Math.max(0, Number(amount) || 0)); + const business = roundMoney(n / 2); + return { business, tech: roundMoney(n - business) }; +} + +export function envelopeLimit( + side: 'BUSINESS' | 'TECH', + budgets: { category: string; amount: unknown }[], + contractAmount?: number | null, +): number | null { + const saved = budgets.find((b) => b.category === side); + if (saved) return Number(saved.amount); + if (contractAmount && contractAmount > 0) { + const split = splitEven(contractAmount); + return side === 'BUSINESS' ? split.business : split.tech; + } + return null; +} + +export const ACCEPT_KIND = { + INTERNAL: 'INTERNAL', + CUSTOMER: 'CUSTOMER', +} as const; + +export const TASK_STATUS = { + OPEN: 'OPEN', + DONE: 'DONE', +} as const; + +export const PROJECT_BIZ = { + TIMESHEET: 'PROJECT_TIMESHEET', + ACCEPT: 'PROJECT_ACCEPT', +} as const; + +export const DEFAULT_ACCEPT_MATERIALS = [ + '验收报告', + '交付清单 / 竣工资料', + '培训记录', + '客户签章验收单', + '质保承诺', +]; + +export function canAccessProject(roles: string[], permissions: string[]) { + if (roles.includes('admin')) return true; + if (permissions.some((p) => p === 'contract' || p.startsWith('contract:') || p === 'finance' || p.startsWith('finance:'))) { + return true; + } + return permissions.some((p) => p === 'project' || (p.startsWith('project:') && p !== 'project:timesheets')); +} + +export function canFillTimesheet(roles: string[], permissions: string[]) { + if (roles.includes('admin') || roles.includes('owner') || isTechLead(roles)) return true; + return permissions.some((p) => p === 'project' || p === 'project:timesheets' || p.startsWith('project:')); +} + +export function canManageWbs(roles: string[]) { + return ['admin', 'owner'].some((r) => roles.includes(r)) || isTechLead(roles); +} + +export const PROJECT_TYPES = ['软件', '三维', '摄制', '物资', '集成'] as const; + +export function normalizeProjectType(v?: string | null) { + const s = String(v || '').trim(); + if (!s) return ''; + if (s.includes('三维') || s.includes('动画')) return '三维'; + if (s.includes('摄制') || s.includes('视频') || s.includes('摄影')) return '摄制'; + if (s.includes('物资')) return '物资'; + if (s.includes('集成')) return '集成'; + if (s.includes('软件') || s.includes('开发') || s.includes('信息化')) return '软件'; + if ((PROJECT_TYPES as readonly string[]).includes(s)) return s; + return ''; +} + +/** 台账手动立项:直签、补登历史项目。 */ +export function canCreateProject(roles: string[]) { + return ['admin', 'owner', 'pm', 'biz_director', 'biz_staff'].some((r) => roles.includes(r)) || isTechLead(roles); +} + +export function canSetBudget(roles: string[]) { + return ['admin', 'owner', 'finance', 'biz_director', 'biz_staff'].some((r) => roles.includes(r)) || isTechLead(roles); +} + +export function canAcceptInternal(roles: string[]) { + return ['admin', 'owner'].some((r) => roles.includes(r)) || isTechLead(roles); +} + +export function canAcceptCustomer(roles: string[]) { + return ['admin', 'owner', 'biz_director'].some((r) => roles.includes(r)) || isTechLead(roles); +} + +export function roundMoney(n: number) { + return Math.round(n * 100) / 100; +} + +export function laborCost(hours: number, hourRate: number) { + return roundMoney(hours * hourRate); +} + +export function budgetWouldExceed(used: number, add: number, limit?: number | null) { + if (limit === undefined || limit === null) return false; + return roundMoney(used + add) - limit > 0.001; +} + +export function envelopeOfExpense(category?: string | null): 'BUSINESS' | 'TECH' | null { + if (category === BUDGET_CATEGORY.TRAVEL) return 'BUSINESS'; + if ( + category === BUDGET_CATEGORY.PURCHASE || + category === BUDGET_CATEGORY.LABOR || + category === BUDGET_CATEGORY.TECH + ) { + return 'TECH'; + } + return null; +} + +export function encodeExpenseBudget(kind?: string | null, envelope?: string | null) { + if (kind === BUDGET_CATEGORY.PURCHASE) return BUDGET_CATEGORY.PURCHASE; + if (kind === BUDGET_CATEGORY.TECH || envelope === BUDGET_CATEGORY.TECH) return BUDGET_CATEGORY.TECH; + if (kind === BUDGET_CATEGORY.TRAVEL || envelope === BUDGET_CATEGORY.BUSINESS) return BUDGET_CATEGORY.TRAVEL; + return kind || ''; +} + +export function assertExpenseBudgetCategory(costType: string, budgetCategory?: string) { + if (costType !== 'PROJECT') return null; + if (!(EXPENSE_BUDGET_CATEGORIES as readonly string[]).includes(budgetCategory || '')) { + return '挂项目报销请选择差旅或采购,并标明商务或技术预算'; + } + return null; +} diff --git a/apps/api/src/project/project.controller.ts b/apps/api/src/project/project.controller.ts new file mode 100644 index 0000000..c5120cc --- /dev/null +++ b/apps/api/src/project/project.controller.ts @@ -0,0 +1,273 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser, type AuthUser } from '../common/decorators/current-user.decorator'; +import { ProjectService } from './project.service'; + +@ApiTags('project') +@ApiBearerAuth() +@Controller() +export class ProjectController { + constructor(private readonly projects: ProjectService) {} + + @Get('projects') + list( + @CurrentUser() user: AuthUser, + @Query('page') page = '1', + @Query('pageSize') pageSize = '20', + @Query('keyword') keyword?: string, + @Query('bucket') bucket?: string, + @Query('projectType') projectType?: string, + @Query('status') status?: string, + ) { + return this.projects.list( + user, + Number(page) || 1, + Number(pageSize) || 20, + keyword, + bucket, + projectType, + status, + ); + } + + @Post('projects') + create( + @CurrentUser() user: AuthUser, + @Body() + body: { + name: string; + contractId?: string; + partyName?: string; + amount?: number; + status?: string; + projectType?: string; + }, + ) { + return this.projects.create(user, body); + } + + @Get('project-tasks') + tasks(@CurrentUser() user: AuthUser) { + return this.projects.listTasks(user); + } + + @Get('timesheets') + timesheets(@CurrentUser() user: AuthUser, @Query('projectId') projectId?: string) { + return this.projects.listTimesheets(user, projectId); + } + + @Post('timesheets') + createTimesheet( + @CurrentUser() user: AuthUser, + @Body() body: { projectId: string; taskId?: string; workDate: string; hours: number; note?: string }, + ) { + return this.projects.createTimesheet(user, body); + } + + @Delete('timesheets/:id') + deleteTimesheet(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.projects.deleteTimesheet(id, user); + } + + @Get('projects/:id') + get(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.projects.get(id, user); + } + + @Patch('projects/:id') + update( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { hourRate?: number; amount?: number; projectType?: string; name?: string }, + ) { + return this.projects.update(id, user, body); + } + + @Put('projects/:id/budgets') + budgets( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { items: Array<{ category: string; amount: number }> }, + ) { + return this.projects.replaceBudgets(id, user, body.items || []); + } + + @Post('projects/:id/budgets/sync') + syncBudgets( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { amount?: number }, + ) { + return this.projects.syncSplitBudgets(id, user, body); + } + + @Post('projects/:id/tasks') + createTask( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() + body: { name: string; departmentId?: string; assigneeId?: string; dueAt?: string; sortNo?: number }, + ) { + return this.projects.createTask(id, user, body); + } + + @Patch('projects/:id/tasks/:taskId') + updateTask( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Param('taskId') taskId: string, + @Body() + body: { + name?: string; + departmentId?: string; + assigneeId?: string; + dueAt?: string; + sortNo?: number; + status?: string; + }, + ) { + return this.projects.updateTask(id, taskId, user, { name: body.name || '', ...body }); + } + + @Post('projects/:id/tasks/:taskId/done') + completeTask(@CurrentUser() user: AuthUser, @Param('id') id: string, @Param('taskId') taskId: string) { + return this.projects.completeTask(id, taskId, user); + } + + @Delete('projects/:id/tasks/:taskId') + deleteTask(@CurrentUser() user: AuthUser, @Param('id') id: string, @Param('taskId') taskId: string) { + return this.projects.deleteTask(id, taskId, user); + } + + @Post('projects/:id/accept') + accept( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { kind: 'INTERNAL' | 'CUSTOMER'; result: 'APPROVED' | 'REJECTED'; comment?: string }, + ) { + return this.projects.accept(id, user, body); + } + + @Post('projects/:id/accept-materials') + addMaterial( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { name: string; remark?: string; required?: boolean }, + ) { + return this.projects.addAcceptMaterial(id, user, body); + } + + @Post('projects/:id/accept-materials/seed') + seedMaterials(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.projects.seedAcceptMaterials(id, user); + } + + @Patch('projects/:id/accept-materials/:materialId') + updateMaterial( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Param('materialId') materialId: string, + @Body() body: { name?: string; remark?: string | null; required?: boolean; sortNo?: number }, + ) { + return this.projects.updateAcceptMaterial(id, materialId, user, body); + } + + @Delete('projects/:id/accept-materials/:materialId') + deleteMaterial( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Param('materialId') materialId: string, + ) { + return this.projects.deleteAcceptMaterial(id, materialId, user); + } + + @Get('project-changes') + changes(@CurrentUser() user: AuthUser) { + return this.projects.listChanges(user); + } + + @Post('project-changes') + createChange( + @CurrentUser() user: AuthUser, + @Body() body: { projectId: string; summary: string; amountDelta?: number }, + ) { + return this.projects.createChange(user, body); + } + + @Post('project-changes/:id/review') + reviewChange( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { result: 'APPROVED' | 'REJECTED'; comment?: string }, + ) { + return this.projects.reviewChange(id, user, body); + } + + @Delete('project-changes/:id') + deleteChange(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.projects.deleteChange(id, user); + } + + @Get('purchase-requests') + purchases(@CurrentUser() user: AuthUser) { + return this.projects.listPurchases(user); + } + + @Post('purchase-requests') + createPurchase( + @CurrentUser() user: AuthUser, + @Body() body: { title: string; amount: number; projectId?: string; remark?: string }, + ) { + return this.projects.createPurchase(user, body); + } + + @Post('purchase-requests/:id/decide') + decidePurchase( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { result: 'APPROVED' | 'REJECTED'; comment?: string }, + ) { + return this.projects.decidePurchase(id, user, body); + } + + @Delete('purchase-requests/:id') + deletePurchase(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.projects.deletePurchase(id, user); + } + + @Get('assets') + assets(@CurrentUser() user: AuthUser) { + return this.projects.listAssets(user); + } + + @Post('assets') + occupy( + @CurrentUser() user: AuthUser, + @Body() + body: { + registeredAssetId?: string; + assetName?: string; + holderId?: string; + holder?: string; + projectId?: string; + dueAt?: string; + }, + ) { + return this.projects.occupyAsset(user, body); + } + + @Post('assets/:id/return') + returnAsset(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.projects.returnAsset(id, user); + } + + @Delete('assets/:id') + deleteOccupancy(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.projects.deleteOccupancy(id, user); + } + + @Get('resource-board') + board(@CurrentUser() user: AuthUser) { + return this.projects.resourceBoard(user); + } +} diff --git a/apps/api/src/project/project.service.ts b/apps/api/src/project/project.service.ts new file mode 100644 index 0000000..4d097d7 --- /dev/null +++ b/apps/api/src/project/project.service.ts @@ -0,0 +1,1300 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { EXPENSE_STATUS, INVOICE_STATUS } from '../finance/finance-workflow'; +import { + ACCEPT_KIND, + BUDGET_CATEGORY, + DEFAULT_ACCEPT_MATERIALS, + PROJECT_BIZ, + PROJECT_ACCEPTANCE_STATUSES, + PROJECT_LEDGER_STATUSES, + PROJECT_STATUS, + TASK_STATUS, + budgetWouldExceed, + canAcceptCustomer, + canAcceptInternal, + canAccessProject, + canCreateProject, + canFillTimesheet, + canManageWbs, + canSetBudget, + envelopeLimit, + envelopeOfExpense, + isProjectClosed, + laborCost, + namesOverlap, + normalizeProjectType, + partyFromContractRemark, + roundMoney, + splitEven, +} from './project-workflow'; +import { canAccessBidModule } from '../bidding/bid-workflow'; +import { internalHourRate } from '../hr/hr-workflow'; +import { + ASSET_STATUS, + CHANGE_STATUS, + PURCHASE_STATUS, + canApprovePurchase, + canChangeProject, + canFilePurchase, + resourceLoad, +} from '../system/ops-workflow'; +import { nextBizNo } from '../system/numbering'; + +const USER_SELECT = { id: true, displayName: true, username: true }; +const DEPT_SELECT = { id: true, name: true, code: true, deptType: true }; + +type Tx = Prisma.TransactionClient; + +export type BudgetItemInput = { category: string; amount: number }; +export type TaskInput = { + name: string; + departmentId?: string; + assigneeId?: string; + dueAt?: string; + sortNo?: number; +}; +export type TimesheetInput = { + projectId: string; + taskId?: string; + workDate: string; + hours: number; + note?: string; +}; +export type AcceptInput = { + kind: 'INTERNAL' | 'CUSTOMER'; + result: 'APPROVED' | 'REJECTED'; + comment?: string; +}; + +const HOLD_EXPENSE = [EXPENSE_STATUS.PENDING, EXPENSE_STATUS.APPROVED]; + +@Injectable() +export class ProjectService { + constructor(private readonly prisma: PrismaService) {} + + assertAccess(user: AuthUser) { + if (!canAccessProject(user.roles, user.permissions)) { + throw new ForbiddenException('无权访问项目管理'); + } + } + + assertTimesheet(user: AuthUser) { + if (!canFillTimesheet(user.roles, user.permissions)) { + throw new ForbiddenException('无权填报工时'); + } + } + + async list( + user: AuthUser, + page: number, + pageSize: number, + keyword?: string, + bucket?: string, + projectType?: string, + status?: string, + ) { + this.assertAccess(user); + const where: Prisma.ProjectWhereInput = { tenantId: '1' }; + if (keyword) { + where.OR = [ + { projectNo: { contains: keyword } }, + { name: { contains: keyword } }, + { partyName: { contains: keyword } }, + ]; + } + if (projectType) where.projectType = projectType; + if (status) where.status = status; + if (bucket === 'active') where.status = PROJECT_STATUS.ACTIVE; + if (bucket === 'internal') where.status = PROJECT_STATUS.INTERNAL_ACCEPTED; + if (bucket === 'accepted') where.status = PROJECT_STATUS.ACCEPTED; + if (bucket === 'pending') where.status = { notIn: [PROJECT_STATUS.ACCEPTED, PROJECT_STATUS.DONE] }; + if (bucket === 'ledger') where.status = { in: [...PROJECT_LEDGER_STATUSES] }; + if (bucket === 'acceptance') { + where.AND = [ + ...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []), + { + OR: [{ status: { in: [...PROJECT_ACCEPTANCE_STATUSES] } }, { acceptMaterials: { some: {} } }], + }, + ]; + } + const seeBid = canAccessBidModule(user.roles, user.permissions); + const [rows, total] = await this.prisma.$transaction([ + this.prisma.project.findMany({ + where, + include: { + contract: { + include: { + bidCase: { select: { id: true, bidNo: true, projectType: true, party: { select: { name: true } } } }, + }, + }, + budgets: true, + _count: { select: { acceptMaterials: true, expenses: true } }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.project.count({ where }), + ]); + const bids = seeBid + ? await this.prisma.bidCase.findMany({ + where: { tenantId: '1' }, + select: { name: true, party: { select: { name: true } } }, + }) + : []; + const usage = await this.usageMap(rows.map((r) => r.id)); + return { + items: rows.map((r) => ({ + ...r, + usage: usage.get(r.id) || this.emptyUsage(Number(r.hourRate), r.budgets), + projectType: r.projectType || r.contract?.bidCase?.projectType || '', + amount: Number(r.amount) || Number(r.contract?.amount || 0), + budgetTotal: r.budgets.reduce((sum, b) => sum + Number(b.amount), 0), + acceptMaterialCount: r._count.acceptMaterials, + expenseCount: r._count.expenses, + contract: seeBid + ? r.contract + : r.contract + ? { ...r.contract, bidCase: null } + : r.contract, + partyName: seeBid + ? r.partyName || + r.contract?.partyA || + r.contract?.bidCase?.party?.name || + partyFromContractRemark(r.contract?.remark) || + bids.find((b) => namesOverlap(b.name, r.name))?.party?.name || + null + : r.partyName || r.contract?.partyA || partyFromContractRemark(r.contract?.remark) || null, + })), + page, + pageSize, + total, + }; + } + + async get(id: string, user: AuthUser) { + this.assertAccess(user); + const row = await this.prisma.project.findFirst({ + where: { id, tenantId: '1' }, + include: { + contract: { + include: { + bidCase: { select: { id: true, bidNo: true, name: true, projectType: true } }, + legalEntity: true, + milestones: { include: { invoice: true }, orderBy: { dueAt: 'asc' } }, + }, + }, + tasks: { + include: { dept: { select: DEPT_SELECT }, assignee: { select: USER_SELECT } }, + orderBy: [{ sortNo: 'asc' }, { createdAt: 'asc' }], + }, + timesheets: { + include: { user: { select: USER_SELECT }, task: { select: { id: true, name: true } } }, + orderBy: { workDate: 'desc' }, + take: 200, + }, + budgets: true, + acceptances: { + include: { actor: { select: USER_SELECT } }, + orderBy: { decidedAt: 'asc' }, + }, + expenses: { + where: { + OR: [{ applicantId: user.id }, { status: { not: EXPENSE_STATUS.DRAFT } }], + }, + include: { applicant: { select: USER_SELECT } }, + orderBy: { createdAt: 'desc' }, + take: 200, + }, + purchases: { + include: { applicant: { select: USER_SELECT } }, + orderBy: { createdAt: 'desc' }, + take: 100, + }, + }, + }); + if (!row) throw new NotFoundException('项目不存在'); + if (!canAccessBidModule(user.roles, user.permissions) && row.contract) { + (row.contract as { bidCase?: unknown }).bidCase = null; + } + const usage = (await this.usageMap([row.id])).get(row.id) || this.emptyUsage(Number(row.hourRate), row.budgets); + const files = await this.prisma.fileAsset.findMany({ + where: { tenantId: '1', bizId: id, bizType: { startsWith: 'PROJECT' } }, + orderBy: { createdAt: 'asc' }, + }); + const materials = await this.withMaterialFiles(id); + return { + ...row, + projectType: row.projectType || row.contract?.bidCase?.projectType || '', + amount: Number(row.amount) || Number(row.contract?.amount || 0), + usage, + files: files.filter((f) => f.bizType !== PROJECT_BIZ.ACCEPT), + acceptMaterials: materials, + acceptMaterialsReady: this.materialsReady(materials), + myActions: this.myActions(row, user), + }; + } + + async create( + user: AuthUser, + dto: { + name: string; + contractId?: string; + partyName?: string; + amount?: number; + status?: string; + projectType?: string; + }, + ) { + this.assertAccess(user); + if (!canCreateProject(user.roles)) throw new ForbiddenException('无权新建项目台账'); + if (!dto.name?.trim()) throw new BadRequestException('请填写项目名称'); + const status = + dto.status === PROJECT_STATUS.ACTIVE ? PROJECT_STATUS.ACTIVE : PROJECT_STATUS.NOT_STARTED; + let contract: { id: string; amount: Prisma.Decimal; partyA: string | null; name: string } | null = null; + if (dto.contractId) { + const found = await this.prisma.contract.findFirst({ + where: { id: dto.contractId, tenantId: '1' }, + include: { projects: { select: { id: true } }, bidCase: { select: { projectType: true } } }, + }); + if (!found) throw new NotFoundException('合同不存在'); + if (found.projects.length) throw new BadRequestException('该合同已经立过项'); + contract = found; + } + const amount = dto.amount && dto.amount > 0 ? dto.amount : Number(contract?.amount || 0); + const created = await this.prisma.$transaction(async (tx) => { + const projectNo = await nextBizNo(tx, 'PRJ', (start) => + tx.project.count({ where: { projectNo: { startsWith: start } } }), + ); + const project = await tx.project.create({ + data: { + tenantId: '1', + projectNo, + name: dto.name.trim(), + contractId: contract?.id || null, + partyName: dto.partyName?.trim() || contract?.partyA || null, + projectType: + normalizeProjectType(dto.projectType) || + normalizeProjectType((contract as { bidCase?: { projectType?: string } } | null)?.bidCase?.projectType) || + '', + amount, + status, + }, + }); + if (amount > 0) { + const split = splitEven(amount); + await tx.projectBudget.createMany({ + data: [ + { projectId: project.id, category: BUDGET_CATEGORY.BUSINESS, amount: split.business }, + { projectId: project.id, category: BUDGET_CATEGORY.TECH, amount: split.tech }, + ], + }); + } + return project; + }); + return this.get(created.id, user); + } + + async update( + id: string, + user: AuthUser, + dto: { hourRate?: number; amount?: number; projectType?: string; name?: string }, + ) { + this.assertAccess(user); + if (!canSetBudget(user.roles) && !canCreateProject(user.roles)) { + throw new ForbiddenException('无权调整项目台账'); + } + const row = await this.require(id); + this.assertOpen(row.status); + const data: Prisma.ProjectUpdateInput = {}; + if (dto.hourRate !== undefined) { + if (dto.hourRate <= 0) throw new BadRequestException('请填写大于 0 的人工单价'); + data.hourRate = dto.hourRate; + } + if (dto.projectType !== undefined) data.projectType = normalizeProjectType(dto.projectType); + if (dto.name?.trim()) data.name = dto.name.trim(); + if (dto.amount !== undefined) { + if (dto.amount < 0) throw new BadRequestException('项目总额不能为负'); + data.amount = dto.amount; + } + if (!Object.keys(data).length) throw new BadRequestException('没有要保存的内容'); + await this.prisma.project.update({ where: { id }, data }); + if (dto.amount !== undefined && dto.amount > 0) { + await this.syncSplitBudgets(id, user, { amount: dto.amount }); + } + return this.get(id, user); + } + + async replaceBudgets(id: string, user: AuthUser, items: BudgetItemInput[]) { + this.assertAccess(user); + if (!canSetBudget(user.roles)) throw new ForbiddenException('无权设置项目预算'); + const row = await this.require(id); + this.assertOpen(row.status); + const allowed = new Set(Object.values(BUDGET_CATEGORY)); + const seen = new Set(); + const cleaned: BudgetItemInput[] = []; + for (const item of items || []) { + if (!allowed.has(item.category as (typeof BUDGET_CATEGORY)[keyof typeof BUDGET_CATEGORY])) { + throw new BadRequestException('预算类别必须是商务、技术、人工、差旅或采购'); + } + if (seen.has(item.category)) throw new BadRequestException('同一预算类别不要重复'); + if (item.amount === undefined || item.amount < 0) throw new BadRequestException('预算金额不能为负'); + seen.add(item.category); + cleaned.push({ category: item.category, amount: roundMoney(item.amount) }); + } + await this.prisma.$transaction(async (tx) => { + await tx.projectBudget.deleteMany({ where: { projectId: id } }); + if (cleaned.length) { + await tx.projectBudget.createMany({ + data: cleaned.map((c) => ({ projectId: id, category: c.category, amount: c.amount })), + }); + } + }); + return this.get(id, user); + } + + async syncSplitBudgets(id: string, user: AuthUser, dto?: { amount?: number }) { + this.assertAccess(user); + if (!canSetBudget(user.roles) && !canCreateProject(user.roles)) { + throw new ForbiddenException('无权同步项目预算'); + } + const row = await this.prisma.project.findFirst({ + where: { id, tenantId: '1' }, + include: { contract: { select: { amount: true } } }, + }); + if (!row) throw new NotFoundException('项目不存在'); + this.assertOpen(row.status); + const amount = + dto?.amount && dto.amount > 0 + ? dto.amount + : Number(row.amount || 0) || Number(row.contract?.amount || 0); + if (amount <= 0) { + throw new BadRequestException('请填写项目总额,或先关联带金额的合同'); + } + const split = splitEven(amount); + await this.prisma.$transaction(async (tx) => { + await tx.project.update({ where: { id }, data: { amount } }); + await tx.projectBudget.deleteMany({ + where: { projectId: id, category: { in: [BUDGET_CATEGORY.BUSINESS, BUDGET_CATEGORY.TECH] } }, + }); + await tx.projectBudget.createMany({ + data: [ + { projectId: id, category: BUDGET_CATEGORY.BUSINESS, amount: split.business }, + { projectId: id, category: BUDGET_CATEGORY.TECH, amount: split.tech }, + ], + }); + }); + return this.get(id, user); + } + + async listTasks(user: AuthUser) { + this.assertAccess(user); + return this.prisma.projectTask.findMany({ + include: { + project: { select: { id: true, projectNo: true, name: true, status: true } }, + dept: { select: DEPT_SELECT }, + assignee: { select: USER_SELECT }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async createTask(projectId: string, user: AuthUser, dto: TaskInput) { + this.assertAccess(user); + if (!canManageWbs(user.roles)) throw new ForbiddenException('无权分解任务'); + const project = await this.require(projectId); + this.assertOpen(project.status); + if (!dto.name?.trim()) throw new BadRequestException('请填写任务名称'); + const dept = dto.departmentId ? await this.requireDept(dto.departmentId) : null; + if (dto.assigneeId) await this.requireUser(dto.assigneeId); + const created = await this.prisma.$transaction(async (tx) => { + const task = await tx.projectTask.create({ + data: { + projectId, + name: dto.name.trim(), + departmentId: dept?.id || null, + department: dept?.name || null, + assigneeId: dto.assigneeId || null, + dueAt: dto.dueAt ? new Date(dto.dueAt) : null, + sortNo: dto.sortNo ?? 0, + status: TASK_STATUS.OPEN, + }, + }); + if (dto.assigneeId) { + await tx.todoTask.create({ + data: { + tenantId: '1', + title: `填工时:${project.projectNo} ${task.name}`, + bizType: PROJECT_BIZ.TIMESHEET, + bizId: project.id, + assigneeId: dto.assigneeId, + dueAt: task.dueAt, + }, + }); + } + return task; + }); + return created; + } + + async updateTask(projectId: string, taskId: string, user: AuthUser, dto: TaskInput & { status?: string }) { + this.assertAccess(user); + if (!canManageWbs(user.roles)) throw new ForbiddenException('无权分解任务'); + const project = await this.require(projectId); + this.assertOpen(project.status); + const task = await this.requireTask(projectId, taskId); + const dept = dto.departmentId ? await this.requireDept(dto.departmentId) : null; + if (dto.assigneeId) await this.requireUser(dto.assigneeId); + if (dto.status && dto.status !== TASK_STATUS.OPEN && dto.status !== TASK_STATUS.DONE) { + throw new BadRequestException('任务状态无效'); + } + await this.prisma.$transaction(async (tx) => { + await tx.projectTask.update({ + where: { id: taskId }, + data: { + name: dto.name?.trim() || task.name, + departmentId: dto.departmentId === undefined ? task.departmentId : dept?.id || null, + department: dto.departmentId === undefined ? task.department : dept?.name || null, + assigneeId: dto.assigneeId === undefined ? task.assigneeId : dto.assigneeId || null, + dueAt: dto.dueAt === undefined ? task.dueAt : dto.dueAt ? new Date(dto.dueAt) : null, + sortNo: dto.sortNo ?? task.sortNo, + status: dto.status || task.status, + }, + }); + if (dto.assigneeId && dto.assigneeId !== task.assigneeId) { + await tx.todoTask.create({ + data: { + tenantId: '1', + title: `填工时:${project.projectNo} ${dto.name?.trim() || task.name}`, + bizType: PROJECT_BIZ.TIMESHEET, + bizId: project.id, + assigneeId: dto.assigneeId, + dueAt: dto.dueAt ? new Date(dto.dueAt) : task.dueAt, + }, + }); + } + }); + return this.get(projectId, user); + } + + async completeTask(projectId: string, taskId: string, user: AuthUser) { + return this.updateTask(projectId, taskId, user, { name: '', status: TASK_STATUS.DONE }); + } + + async deleteTask(projectId: string, taskId: string, user: AuthUser) { + this.assertAccess(user); + if (!canManageWbs(user.roles)) throw new ForbiddenException('无权分解任务'); + await this.require(projectId); + await this.requireTask(projectId, taskId); + const used = await this.prisma.timesheet.count({ where: { taskId } }); + if (used) throw new BadRequestException('已有工时的任务不能删除'); + await this.prisma.projectTask.delete({ where: { id: taskId } }); + return this.get(projectId, user); + } + + async listTimesheets(user: AuthUser, projectId?: string) { + this.assertTimesheet(user); + const where: Prisma.TimesheetWhereInput = { tenantId: '1' }; + if (projectId) where.projectId = projectId; + if (!canManageWbs(user.roles) && !canSetBudget(user.roles)) where.userId = user.id; + return this.prisma.timesheet.findMany({ + where, + include: { + project: { select: { id: true, projectNo: true, name: true, status: true } }, + task: { select: { id: true, name: true, department: true } }, + user: { select: USER_SELECT }, + }, + orderBy: { workDate: 'desc' }, + }); + } + + async createTimesheet(user: AuthUser, dto: TimesheetInput) { + this.assertTimesheet(user); + if (!dto.hours || dto.hours <= 0 || dto.hours > 24) { + throw new BadRequestException('工时请填写 0 到 24 之间'); + } + if (!dto.workDate) throw new BadRequestException('请选择工作日期'); + const project = await this.require(dto.projectId); + this.assertOpen(project.status); + if (dto.taskId) await this.requireTask(dto.projectId, dto.taskId); + const emp = await this.prisma.employee.findFirst({ + where: { users: { some: { id: user.id } } }, + }); + if (!emp) throw new BadRequestException('当前账号未关联员工档案,工时成本按薪酬自动折算'); + const hourRate = internalHourRate({ + baseSalary: Number(emp.baseSalary), + postSalary: Number(emp.postSalary), + phoneSubsidy: Number(emp.phoneSubsidy), + transportSubsidy: Number(emp.transportSubsidy), + mealSubsidy: Number(emp.mealSubsidy), + perfBase: Number(emp.perfBase), + restSchedule: emp.restSchedule, + }); + const addCost = laborCost(dto.hours, hourRate); + await this.assertBudgetRoom(dto.projectId, BUDGET_CATEGORY.LABOR, addCost); + const created = await this.prisma.timesheet.create({ + data: { + tenantId: '1', + projectId: dto.projectId, + taskId: dto.taskId || null, + userId: user.id, + workDate: new Date(dto.workDate), + hours: dto.hours, + costAmount: addCost, + note: dto.note, + }, + }); + await this.prisma.todoTask.updateMany({ + where: { bizId: dto.projectId, bizType: PROJECT_BIZ.TIMESHEET, assigneeId: user.id, status: 'OPEN' }, + data: { status: 'DONE' }, + }); + return created; + } + + async deleteTimesheet(id: string, user: AuthUser) { + this.assertTimesheet(user); + const row = await this.prisma.timesheet.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('工时不存在'); + if (row.userId !== user.id && !canManageWbs(user.roles)) { + throw new ForbiddenException('只能删除自己的工时'); + } + const project = await this.require(row.projectId); + this.assertOpen(project.status); + await this.prisma.timesheet.delete({ where: { id } }); + return { ok: true }; + } + + async accept(id: string, user: AuthUser, dto: AcceptInput) { + this.assertAccess(user); + if (dto.result === 'REJECTED' && !dto.comment?.trim()) { + throw new BadRequestException('不通过请填写意见'); + } + const invoices: { id: string; status: string; amount: Prisma.Decimal }[] = []; + await this.prisma.$transaction(async (tx) => { + const row = await tx.project.findFirst({ + where: { id, tenantId: '1' }, + include: { contract: { include: { milestones: { include: { invoice: true } } } } }, + }); + if (!row) throw new NotFoundException('项目不存在'); + if (dto.kind === ACCEPT_KIND.INTERNAL) { + if (!canAcceptInternal(user.roles)) throw new ForbiddenException('无权做内部验收'); + if (row.status !== PROJECT_STATUS.ACTIVE) throw new BadRequestException('当前不能做内部验收'); + await tx.projectAcceptance.create({ + data: { + projectId: id, + kind: ACCEPT_KIND.INTERNAL, + result: dto.result, + comment: dto.comment?.trim() || null, + actorId: user.id, + }, + }); + if (dto.result === 'APPROVED') { + await tx.project.update({ + where: { id }, + data: { status: PROJECT_STATUS.INTERNAL_ACCEPTED, internalAcceptedAt: new Date() }, + }); + const owners = await this.usersByRoles(tx, ['owner', 'biz_director']); + if (owners.length) { + await tx.todoTask.createMany({ + data: owners.map((u) => ({ + tenantId: '1', + title: `客户验收:${row.projectNo}`, + bizType: PROJECT_BIZ.ACCEPT, + bizId: id, + assigneeId: u.id, + })), + }); + } + } + return; + } + if (dto.kind !== ACCEPT_KIND.CUSTOMER) throw new BadRequestException('验收类型无效'); + if (!canAcceptCustomer(user.roles)) throw new ForbiddenException('无权做客户验收'); + if (row.status !== PROJECT_STATUS.INTERNAL_ACCEPTED) { + throw new BadRequestException('请先完成内部验收'); + } + if (dto.result === 'APPROVED') { + await this.assertMaterialsReady(id, tx); + } + await tx.projectAcceptance.create({ + data: { + projectId: id, + kind: ACCEPT_KIND.CUSTOMER, + result: dto.result, + comment: dto.comment?.trim() || null, + actorId: user.id, + }, + }); + if (dto.result !== 'APPROVED') return; + await tx.project.update({ + where: { id }, + data: { status: PROJECT_STATUS.ACCEPTED, customerAcceptedAt: new Date() }, + }); + await tx.todoTask.updateMany({ + where: { bizId: id, bizType: PROJECT_BIZ.ACCEPT, status: 'OPEN' }, + data: { status: 'DONE' }, + }); + const contract = row.contract; + if (!contract) return; + for (const mile of contract.milestones) { + if (mile.invoice) continue; + const amount = roundMoney((Number(contract.amount) * Number(mile.ratio)) / 100); + if (amount <= 0) continue; + const created = await tx.invoiceRecord.create({ + data: { + tenantId: '1', + contractId: contract.id, + milestoneId: mile.id, + amount, + dueAt: mile.dueAt, + createdById: user.id, + status: INVOICE_STATUS.TO_ISSUE, + remark: `项目验收 ${row.projectNo}`, + }, + }); + invoices.push(created); + } + }); + const detail = await this.get(id, user); + return { ...detail, invoicesCreated: invoices.length }; + } + + async assertBudgetRoom( + projectId: string, + category: string, + addAmount: number, + opts?: { excludeExpenseId?: string; excludeTimesheetId?: string; tx?: Tx }, + ) { + const db = opts?.tx || this.prisma; + const project = await db.project.findFirst({ + where: { id: projectId, tenantId: '1' }, + include: { budgets: true, contract: { select: { amount: true } } }, + }); + if (!project) throw new NotFoundException('项目不存在'); + const usage = await this.usageFor(projectId, Number(project.hourRate), db, opts); + const lineUsed = + category === BUDGET_CATEGORY.LABOR + ? usage.labor + : category === BUDGET_CATEGORY.TRAVEL + ? usage.travel + : category === BUDGET_CATEGORY.TECH + ? usage.techTravel + : category === BUDGET_CATEGORY.PURCHASE + ? usage.purchase + : null; + const lineRow = project.budgets.find((b) => b.category === category); + if (lineRow && lineUsed !== null && budgetWouldExceed(lineUsed, addAmount, Number(lineRow.amount))) { + const label = + category === BUDGET_CATEGORY.LABOR + ? '人工' + : category === BUDGET_CATEGORY.TRAVEL + ? '商务差旅' + : category === BUDGET_CATEGORY.TECH + ? '技术差旅' + : '采购'; + throw new BadRequestException( + `${label}预算红线 ${Number(lineRow.amount)},已占用 ${lineUsed},本单 ${addAmount} 将超支`, + ); + } + const contractAmount = Number(project.contract?.amount || 0); + const envelopeSide = envelopeOfExpense(category); + if (envelopeSide) { + const cap = envelopeLimit(envelopeSide, project.budgets, contractAmount); + const envelopeUsed = + envelopeSide === BUDGET_CATEGORY.BUSINESS ? usage.businessUsed : usage.techUsed; + const envLabel = envelopeSide === BUDGET_CATEGORY.BUSINESS ? '商务' : '技术'; + if (budgetWouldExceed(envelopeUsed, addAmount, cap)) { + throw new BadRequestException( + `${envLabel}预算 ${cap},已占用 ${envelopeUsed},本单 ${addAmount} 将超支`, + ); + } + } + } + + private async usageMap(ids: string[]) { + const map = new Map>(); + if (!ids.length) return map; + const projects = await this.prisma.project.findMany({ + where: { id: { in: ids } }, + include: { budgets: true, contract: { select: { amount: true } } }, + }); + for (const p of projects) { + map.set( + p.id, + await this.usageFor(p.id, Number(p.hourRate), this.prisma, undefined, { + presalesCost: Number(p.presalesCost), + contractAmount: Number(p.contract?.amount || 0), + budgets: p.budgets, + }), + ); + } + return map; + } + + private async usageFor( + projectId: string, + hourRate: number, + db: PrismaService | Tx, + opts?: { excludeExpenseId?: string; excludeTimesheetId?: string }, + extras?: { + presalesCost?: number; + contractAmount?: number; + budgets?: { category: string; amount: Prisma.Decimal }[]; + }, + ) { + const [sheets, expenses, budgets, purchases, project] = await Promise.all([ + db.timesheet.findMany({ + where: { + projectId, + ...(opts?.excludeTimesheetId ? { id: { not: opts.excludeTimesheetId } } : {}), + }, + select: { hours: true, costAmount: true }, + }), + db.expenseClaim.findMany({ + where: { + projectId, + status: { in: HOLD_EXPENSE }, + ...(opts?.excludeExpenseId ? { id: { not: opts.excludeExpenseId } } : {}), + }, + select: { amount: true, budgetCategory: true }, + }), + extras?.budgets + ? Promise.resolve(extras.budgets) + : db.projectBudget.findMany({ where: { projectId } }), + db.purchaseRequest.findMany({ + where: { projectId, status: { in: ['PENDING', 'APPROVED'] } }, + select: { amount: true }, + }), + extras?.presalesCost !== undefined && extras?.contractAmount !== undefined + ? Promise.resolve(null) + : db.project.findFirst({ + where: { id: projectId }, + select: { presalesCost: true, amount: true, contract: { select: { amount: true } } }, + }), + ]); + const hours = sheets.reduce((s, r) => s + Number(r.hours), 0); + const labor = roundMoney(sheets.reduce((s, r) => s + Number(r.costAmount), 0)); + const travel = expenses + .filter((e) => e.budgetCategory === BUDGET_CATEGORY.TRAVEL) + .reduce((s, e) => s + Number(e.amount), 0); + const techTravel = expenses + .filter((e) => e.budgetCategory === BUDGET_CATEGORY.TECH) + .reduce((s, e) => s + Number(e.amount), 0); + const purchase = + expenses + .filter((e) => e.budgetCategory === BUDGET_CATEGORY.PURCHASE) + .reduce((s, e) => s + Number(e.amount), 0) + + purchases.reduce((s, p) => s + Number(p.amount), 0); + const presalesCost = extras?.presalesCost ?? Number(project?.presalesCost || 0); + const fromProject = Number((project as { amount?: unknown } | null)?.amount || 0); + const fromContract = Number(project?.contract?.amount || 0); + const contractAmount = extras?.contractAmount ?? (fromProject || fromContract); + const suggested = contractAmount > 0 ? splitEven(contractAmount) : { business: 0, tech: 0 }; + const businessUsed = roundMoney(presalesCost + travel); + const techUsed = roundMoney(labor + purchase + techTravel); + return { + hours: roundMoney(hours), + labor, + travel: roundMoney(travel), + techTravel: roundMoney(techTravel), + purchase: roundMoney(purchase), + presalesCost: roundMoney(presalesCost), + businessUsed, + techUsed, + contractAmount: roundMoney(contractAmount), + suggested, + hourRate, + limits: { + labor: this.limitOf(budgets, BUDGET_CATEGORY.LABOR), + travel: this.limitOf(budgets, BUDGET_CATEGORY.TRAVEL), + purchase: this.limitOf(budgets, BUDGET_CATEGORY.PURCHASE), + business: envelopeLimit(BUDGET_CATEGORY.BUSINESS, budgets, contractAmount), + tech: envelopeLimit(BUDGET_CATEGORY.TECH, budgets, contractAmount), + }, + }; + } + + private emptyUsage( + hourRate: number, + budgets: { category: string; amount: Prisma.Decimal }[], + extras?: { presalesCost?: number; contractAmount?: number }, + ) { + const contractAmount = extras?.contractAmount ?? 0; + const presalesCost = extras?.presalesCost ?? 0; + const suggested = contractAmount > 0 ? splitEven(contractAmount) : { business: 0, tech: 0 }; + return { + hours: 0, + labor: 0, + travel: 0, + techTravel: 0, + purchase: 0, + presalesCost: roundMoney(presalesCost), + businessUsed: roundMoney(presalesCost), + techUsed: 0, + contractAmount: roundMoney(contractAmount), + suggested, + hourRate, + limits: { + labor: this.limitOf(budgets, BUDGET_CATEGORY.LABOR), + travel: this.limitOf(budgets, BUDGET_CATEGORY.TRAVEL), + purchase: this.limitOf(budgets, BUDGET_CATEGORY.PURCHASE), + business: envelopeLimit(BUDGET_CATEGORY.BUSINESS, budgets, contractAmount), + tech: envelopeLimit(BUDGET_CATEGORY.TECH, budgets, contractAmount), + }, + }; + } + + private limitOf(budgets: { category: string; amount: Prisma.Decimal }[], category: string) { + const row = budgets.find((b) => b.category === category); + return row ? Number(row.amount) : null; + } + + private myActions( + row: { status: string }, + user: AuthUser, + ) { + const open = !isProjectClosed(row.status); + return { + canSetBudget: open && canSetBudget(user.roles), + canManageWbs: open && canManageWbs(user.roles), + canFillTimesheet: open && canFillTimesheet(user.roles, user.permissions), + canAcceptInternal: row.status === PROJECT_STATUS.ACTIVE && canAcceptInternal(user.roles), + canAcceptCustomer: row.status === PROJECT_STATUS.INTERNAL_ACCEPTED && canAcceptCustomer(user.roles), + canEditMaterials: open && (canAcceptCustomer(user.roles) || canAcceptInternal(user.roles) || canManageWbs(user.roles) || canSetBudget(user.roles)), + }; + } + + private assertOpen(status: string) { + if (isProjectClosed(status)) { + throw new BadRequestException(status === PROJECT_STATUS.DONE ? '项目已完成,不能再改执行数据' : '项目已验收,不能再改执行数据'); + } + } + + private async require(id: string) { + const row = await this.prisma.project.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('项目不存在'); + return row; + } + + async addAcceptMaterial(projectId: string, user: AuthUser, dto: { name: string; remark?: string; required?: boolean }) { + this.assertAccess(user); + const project = await this.require(projectId); + this.assertOpen(project.status); + this.assertCanEditMaterials(user); + if (!dto.name?.trim()) throw new BadRequestException('请填写材料名称'); + const max = await this.prisma.projectAcceptMaterial.aggregate({ + where: { projectId }, + _max: { sortNo: true }, + }); + await this.prisma.projectAcceptMaterial.create({ + data: { + projectId, + name: dto.name.trim(), + remark: dto.remark?.trim() || null, + required: dto.required !== false, + sortNo: (max._max.sortNo ?? 0) + 1, + }, + }); + return this.get(projectId, user); + } + + async seedAcceptMaterials(projectId: string, user: AuthUser) { + this.assertAccess(user); + const project = await this.require(projectId); + this.assertOpen(project.status); + this.assertCanEditMaterials(user); + const existing = await this.prisma.projectAcceptMaterial.count({ where: { projectId } }); + if (existing) throw new BadRequestException('已经有材料清单,不必再带入'); + await this.prisma.projectAcceptMaterial.createMany({ + data: DEFAULT_ACCEPT_MATERIALS.map((name, i) => ({ + projectId, + name, + required: true, + sortNo: i + 1, + })), + }); + return this.get(projectId, user); + } + + async updateAcceptMaterial( + projectId: string, + materialId: string, + user: AuthUser, + dto: { name?: string; remark?: string | null; required?: boolean; sortNo?: number }, + ) { + this.assertAccess(user); + const project = await this.require(projectId); + this.assertOpen(project.status); + this.assertCanEditMaterials(user); + const row = await this.prisma.projectAcceptMaterial.findFirst({ where: { id: materialId, projectId } }); + if (!row) throw new NotFoundException('验收材料不存在'); + await this.prisma.projectAcceptMaterial.update({ + where: { id: materialId }, + data: { + name: dto.name?.trim() || undefined, + remark: dto.remark === undefined ? undefined : dto.remark?.trim() || null, + required: dto.required, + sortNo: dto.sortNo, + }, + }); + return this.get(projectId, user); + } + + async deleteAcceptMaterial(projectId: string, materialId: string, user: AuthUser) { + this.assertAccess(user); + const project = await this.require(projectId); + this.assertOpen(project.status); + this.assertCanEditMaterials(user); + const row = await this.prisma.projectAcceptMaterial.findFirst({ where: { id: materialId, projectId } }); + if (!row) throw new NotFoundException('验收材料不存在'); + await this.prisma.$transaction(async (tx) => { + await tx.fileAsset.deleteMany({ where: { bizType: PROJECT_BIZ.ACCEPT, bizId: materialId } }); + await tx.projectAcceptMaterial.delete({ where: { id: materialId } }); + }); + return this.get(projectId, user); + } + + private assertCanEditMaterials(user: AuthUser) { + if ( + canAcceptCustomer(user.roles) || + canAcceptInternal(user.roles) || + canManageWbs(user.roles) || + canSetBudget(user.roles) + ) { + return; + } + throw new ForbiddenException('无权维护验收材料'); + } + + private async withMaterialFiles(projectId: string) { + const rows = await this.prisma.projectAcceptMaterial.findMany({ + where: { projectId }, + orderBy: [{ sortNo: 'asc' }, { createdAt: 'asc' }], + }); + if (!rows.length) return []; + const files = await this.prisma.fileAsset.findMany({ + where: { tenantId: '1', bizType: PROJECT_BIZ.ACCEPT, bizId: { in: rows.map((r) => r.id) } }, + orderBy: { createdAt: 'asc' }, + }); + return rows.map((r) => ({ + ...r, + files: files.filter((f) => f.bizId === r.id), + uploaded: files.some((f) => f.bizId === r.id), + })); + } + + private materialsReady(materials: { required: boolean; uploaded: boolean }[]) { + const required = materials.filter((m) => m.required); + if (!required.length) return false; + return required.every((m) => m.uploaded); + } + + private async assertMaterialsReady(projectId: string, db: PrismaService | Tx) { + const rows = await db.projectAcceptMaterial.findMany({ where: { projectId, required: true } }); + if (!rows.length) { + throw new BadRequestException('请先在验收材料表里写好需要哪些材料,并逐份上传'); + } + const files = await db.fileAsset.findMany({ + where: { bizType: PROJECT_BIZ.ACCEPT, bizId: { in: rows.map((r) => r.id) } }, + select: { bizId: true }, + }); + const have = new Set(files.map((f) => f.bizId)); + const missing = rows.filter((r) => !have.has(r.id)).map((r) => r.name); + if (missing.length) { + throw new BadRequestException(`还有未上传的验收材料:${missing.join('、')}`); + } + } + + private async requireTask(projectId: string, taskId: string) { + const row = await this.prisma.projectTask.findFirst({ where: { id: taskId, projectId } }); + if (!row) throw new NotFoundException('任务不存在'); + return row; + } + + private async requireDept(id: string) { + const row = await this.prisma.department.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('部门不存在'); + return row; + } + + private async requireUser(id: string) { + const row = await this.prisma.user.findFirst({ where: { id, tenantId: '1', status: 'ACTIVE' } }); + if (!row) throw new NotFoundException('人员不存在'); + return row; + } + + private async usersByRoles(tx: Tx, roles: string[]) { + return tx.user.findMany({ + where: { + tenantId: '1', + status: 'ACTIVE', + roles: { some: { role: { code: { in: roles } } } }, + }, + }); + } + + async listChanges(user: AuthUser) { + this.assertAccess(user); + const items = await this.prisma.projectChange.findMany({ + include: { project: { select: { id: true, projectNo: true, name: true } }, actor: { select: USER_SELECT } }, + orderBy: { createdAt: 'desc' }, + }); + return { items }; + } + + async createChange(user: AuthUser, dto: { projectId: string; summary: string; amountDelta?: number }) { + this.assertAccess(user); + if (!canChangeProject(user.roles)) throw new ForbiddenException('无权登记项目变更'); + if (!dto.summary?.trim()) throw new BadRequestException('请填写变更说明'); + const project = await this.require(dto.projectId); + return this.prisma.projectChange.create({ + data: { + tenantId: '1', + projectId: project.id, + summary: dto.summary.trim(), + amountDelta: dto.amountDelta ?? 0, + actorId: user.id, + }, + include: { project: { select: { id: true, projectNo: true, name: true } } }, + }); + } + + async reviewChange(id: string, user: AuthUser, dto: { result: 'APPROVED' | 'REJECTED'; comment?: string }) { + this.assertAccess(user); + if (!canChangeProject(user.roles) && !canApprovePurchase(user.roles)) { + throw new ForbiddenException('无权审批项目变更'); + } + const row = await this.prisma.projectChange.findFirst({ where: { id }, include: { project: true } }); + if (!row) throw new NotFoundException('变更不存在'); + if (row.status !== CHANGE_STATUS.PENDING) throw new BadRequestException('该变更已办理'); + const status = dto.result === 'APPROVED' ? CHANGE_STATUS.APPROVED : CHANGE_STATUS.REJECTED; + if (status === CHANGE_STATUS.APPROVED && Number(row.amountDelta) !== 0 && row.project.contractId) { + const contract = await this.prisma.contract.findFirst({ where: { id: row.project.contractId } }); + if (contract) { + await this.prisma.contract.update({ + where: { id: contract.id }, + data: { amount: Number(contract.amount) + Number(row.amountDelta) }, + }); + } + } + return this.prisma.projectChange.update({ + where: { id }, + data: { status, comment: dto.comment?.trim() || null, decidedAt: new Date(), actorId: user.id }, + include: { project: { select: { id: true, projectNo: true, name: true } } }, + }); + } + + async deleteChange(id: string, user: AuthUser) { + this.assertAccess(user); + const row = await this.prisma.projectChange.findFirst({ where: { id } }); + if (!row) throw new NotFoundException('变更不存在'); + if (row.status === CHANGE_STATUS.APPROVED && !user.roles.includes('admin')) { + throw new BadRequestException('已通过的变更不能删除'); + } + await this.prisma.projectChange.delete({ where: { id } }); + return { ok: true }; + } + + async listPurchases(user: AuthUser) { + this.assertAccess(user); + const items = await this.prisma.purchaseRequest.findMany({ + include: { + project: { select: { id: true, projectNo: true, name: true } }, + applicant: { select: USER_SELECT }, + }, + orderBy: { createdAt: 'desc' }, + }); + return { items: items.map((i) => ({ ...i, amount: Number(i.amount) })) }; + } + + async createPurchase(user: AuthUser, dto: { title: string; amount: number; projectId?: string; remark?: string }) { + this.assertAccess(user); + if (!canFilePurchase(user.roles)) throw new ForbiddenException('无权提交采购申请'); + if (!dto.title?.trim() || !(dto.amount > 0)) throw new BadRequestException('请填写采购内容和金额'); + if (dto.projectId) { + await this.require(dto.projectId); + await this.assertBudgetRoom(dto.projectId, BUDGET_CATEGORY.PURCHASE, dto.amount); + } + return this.prisma.purchaseRequest.create({ + data: { + tenantId: '1', + title: dto.title.trim(), + amount: dto.amount, + projectId: dto.projectId || null, + remark: dto.remark?.trim() || null, + applicantId: user.id, + status: PURCHASE_STATUS.PENDING, + }, + include: { project: { select: { id: true, projectNo: true, name: true } } }, + }); + } + + async decidePurchase(id: string, user: AuthUser, dto: { result: 'APPROVED' | 'REJECTED'; comment?: string }) { + this.assertAccess(user); + if (!canApprovePurchase(user.roles)) throw new ForbiddenException('无权审批采购'); + const row = await this.prisma.purchaseRequest.findFirst({ where: { id } }); + if (!row) throw new NotFoundException('采购申请不存在'); + if (row.status !== PURCHASE_STATUS.PENDING) throw new BadRequestException('该申请已办理'); + if (dto.result === 'APPROVED' && row.projectId) { + await this.assertBudgetRoom(row.projectId, BUDGET_CATEGORY.PURCHASE, Number(row.amount)); + } + return this.prisma.$transaction(async (tx) => { + const updated = await tx.purchaseRequest.update({ + where: { id }, + data: { + status: dto.result === 'APPROVED' ? PURCHASE_STATUS.APPROVED : PURCHASE_STATUS.REJECTED, + remark: dto.comment?.trim() || row.remark, + decidedById: user.id, + decidedAt: new Date(), + }, + include: { project: { select: { id: true, projectNo: true, name: true } } }, + }); + if (dto.result === 'APPROVED' && Number(row.amount) > 0) { + const year = new Date().getFullYear(); + const count = await tx.paymentRecord.count({ where: { payNo: { startsWith: `PAY-${year}-` } } }); + await tx.paymentRecord.create({ + data: { + tenantId: '1', + payNo: `PAY-${year}-${String(count + 1).padStart(5, '0')}`, + kind: 'PURCHASE', + refId: row.id, + amount: row.amount, + remark: `采购 ${row.title}`, + createdById: user.id, + status: 'TODO', + }, + }); + } + return updated; + }); + } + + async deletePurchase(id: string, user: AuthUser) { + this.assertAccess(user); + const row = await this.prisma.purchaseRequest.findFirst({ where: { id } }); + if (!row) throw new NotFoundException('采购申请不存在'); + if (row.status === PURCHASE_STATUS.APPROVED && !user.roles.includes('admin')) { + throw new BadRequestException('已通过的采购不能删除'); + } + await this.prisma.purchaseRequest.delete({ where: { id } }); + return { ok: true }; + } + + async listAssets(user: AuthUser) { + this.assertAccess(user); + const items = await this.prisma.assetOccupancy.findMany({ + include: { + project: { select: { id: true, projectNo: true, name: true } }, + holderUser: { select: USER_SELECT }, + registeredAsset: { select: { id: true, kind: true, name: true, code: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + return { items }; + } + + async occupyAsset( + user: AuthUser, + dto: { + registeredAssetId?: string; + assetName?: string; + holderId?: string; + holder?: string; + projectId?: string; + dueAt?: string; + }, + ) { + this.assertAccess(user); + if (!dto.registeredAssetId) throw new BadRequestException('请先选择数字资产或固定资产,再从台账里选具体器材'); + const asset = await this.prisma.registeredAsset.findFirst({ + where: { id: dto.registeredAssetId, tenantId: '1' }, + }); + if (!asset) throw new NotFoundException('台账中没有该资产,请先到资产管理登记'); + if (asset.status === 'PENDING_IN') throw new BadRequestException('该资产尚未入库,不能占用'); + const busy = await this.prisma.assetOccupancy.findFirst({ + where: { registeredAssetId: asset.id, status: ASSET_STATUS.IN_USE }, + }); + if (busy) throw new BadRequestException(`「${asset.name}」已被 ${busy.holder} 占用,请先交回`); + let holder = dto.holder?.trim() || user.displayName; + if (dto.holderId) { + const u = await this.requireUser(dto.holderId); + holder = u.displayName; + } + if (dto.projectId) await this.require(dto.projectId); + const row = await this.prisma.assetOccupancy.create({ + data: { + tenantId: '1', + assetName: asset.name, + holder, + holderId: dto.holderId || user.id, + projectId: dto.projectId || null, + registeredAssetId: asset.id, + dueAt: dto.dueAt ? new Date(dto.dueAt) : null, + status: ASSET_STATUS.IN_USE, + }, + include: { + project: { select: { id: true, projectNo: true, name: true } }, + registeredAsset: { select: { id: true, kind: true, name: true, code: true } }, + }, + }); + return row; + } + + async returnAsset(id: string, user: AuthUser) { + this.assertAccess(user); + const row = await this.prisma.assetOccupancy.findFirst({ where: { id } }); + if (!row) throw new NotFoundException('占用记录不存在'); + return this.prisma.assetOccupancy.update({ + where: { id }, + data: { status: ASSET_STATUS.RETURNED, returnedAt: new Date() }, + }); + } + + async deleteOccupancy(id: string, user: AuthUser) { + this.assertAccess(user); + const row = await this.prisma.assetOccupancy.findFirst({ where: { id } }); + if (!row) throw new NotFoundException('占用记录不存在'); + await this.prisma.assetOccupancy.delete({ where: { id } }); + return { ok: true }; + } + + async resourceBoard(user: AuthUser) { + this.assertAccess(user); + const start = new Date(new Date().getFullYear(), new Date().getMonth(), 1); + const employees = await this.prisma.employee.findMany({ + where: { tenantId: '1', employmentStatus: { not: 'LEFT' } }, + include: { department: true, users: true }, + orderBy: { employeeNo: 'asc' }, + }); + const tasks = await this.prisma.projectTask.findMany({ + where: { status: 'OPEN', assigneeId: { not: null } }, + include: { project: { select: { projectNo: true, name: true } } }, + }); + const sheets = await this.prisma.timesheet.findMany({ where: { workDate: { gte: start } } }); + const items = employees.map((e) => { + const userIds = e.users.map((u) => u.id); + const mine = tasks.filter((t) => t.assigneeId && userIds.includes(t.assigneeId)); + const monthHours = sheets.filter((s) => userIds.includes(s.userId)).reduce((s, r) => s + Number(r.hours), 0); + const load = resourceLoad(mine.length, monthHours); + return { + id: e.id, + name: e.name, + employeeNo: e.employeeNo, + departmentName: e.department.name, + openTasks: mine.length, + monthHours: roundMoney(monthHours), + load, + projects: [...new Set(mine.map((t) => t.project.projectNo))].join('、') || '—', + }; + }); + return { items }; + } +} diff --git a/apps/api/src/push/push.controller.ts b/apps/api/src/push/push.controller.ts new file mode 100644 index 0000000..1780697 --- /dev/null +++ b/apps/api/src/push/push.controller.ts @@ -0,0 +1,27 @@ +import { Body, Controller, Delete, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser, type AuthUser } from '../common/decorators/current-user.decorator'; +import { PushService } from './push.service'; + +@ApiTags('push') +@ApiBearerAuth() +@Controller('push') +export class PushController { + constructor(private readonly push: PushService) {} + + @Post('devices') + register( + @CurrentUser() user: AuthUser, + @Body() body: { vendor?: string; regId?: string; brand?: string; model?: string }, + ) { + return this.push.register(user.id, body); + } + + @Delete('devices') + unregister( + @CurrentUser() user: AuthUser, + @Body() body: { vendor?: string; regId?: string }, + ) { + return this.push.unregister(user.id, body.vendor, body.regId); + } +} diff --git a/apps/api/src/push/push.module.ts b/apps/api/src/push/push.module.ts new file mode 100644 index 0000000..9e80bc8 --- /dev/null +++ b/apps/api/src/push/push.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from '@nestjs/common'; +import { PushController } from './push.controller'; +import { PushService } from './push.service'; + +@Global() +@Module({ + controllers: [PushController], + providers: [PushService], + exports: [PushService], +}) +export class PushModule {} diff --git a/apps/api/src/push/push.service.ts b/apps/api/src/push/push.service.ts new file mode 100644 index 0000000..c4f6ba1 --- /dev/null +++ b/apps/api/src/push/push.service.ts @@ -0,0 +1,439 @@ +import { createHash, randomUUID } from 'crypto'; +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +export type VendorPush = { + title: string; + content: string; + conversationId?: string; + kind?: string; + category?: 'IM' | 'TODO' | 'ACCOUNT'; + bizType?: string; + bizId?: string; + callId?: string; + callKind?: string; + fromName?: string; + action?: string; +}; + +const SKIP_KINDS = new Set(['receipt', 'typing', 'screenshot']); + +@Injectable() +export class PushService { + private readonly log = new Logger(PushService.name); + private vivoToken = ''; + private vivoTokenAt = 0; + private oppoToken = ''; + private oppoTokenAt = 0; + + constructor(private readonly prisma: PrismaService) {} + + async register( + userId: string, + dto: { vendor?: string; regId?: string; brand?: string; model?: string }, + ) { + const vendor = (dto.vendor || 'vivo').trim().toLowerCase(); + const regId = (dto.regId || '').trim(); + if (!regId || !vendor) return { ok: false }; + await this.prisma.pushDevice.upsert({ + where: { vendor_regId: { vendor, regId } }, + create: { + userId, + vendor, + regId, + brand: dto.brand?.slice(0, 64) || null, + model: dto.model?.slice(0, 64) || null, + }, + update: { + userId, + brand: dto.brand?.slice(0, 64) || undefined, + model: dto.model?.slice(0, 64) || undefined, + }, + }); + return { ok: true, vendor }; + } + + async unregister(userId: string, vendor?: string, regId?: string) { + await this.prisma.pushDevice.deleteMany({ + where: { + userId, + ...(vendor ? { vendor } : {}), + ...(regId ? { regId } : {}), + }, + }); + return { ok: true }; + } + + async notifyUsers(userIds: string[], msg: VendorPush) { + const kind = msg.kind || ''; + if (SKIP_KINDS.has(kind)) return; + const ids = [...new Set(userIds.filter(Boolean))]; + if (!ids.length) return; + const devices = await this.prisma.pushDevice.findMany({ + where: { userId: { in: ids } }, + }); + await Promise.all( + devices.map((d) => { + if (d.vendor === 'vivo') return this.sendVivo(d.regId, msg); + if (d.vendor === 'oppo') return this.sendOppo(d.regId, msg); + if (d.vendor === 'meizu') return this.sendMeizu(d.regId, msg); + if (d.vendor === 'huawei' || d.vendor === 'honor') return this.sendHuawei(d.regId, msg); + return Promise.resolve(); + }), + ); + } + + private async sendVivo(regId: string, msg: VendorPush) { + const appId = Number(process.env.VIVO_PUSH_APP_ID || ''); + const appKey = process.env.VIVO_PUSH_APP_KEY || ''; + const appSecret = process.env.VIVO_PUSH_APP_SECRET || ''; + if (!appId || !appKey || !appSecret) return; + const token = await this.vivoAuth(appId, appKey, appSecret); + if (!token) return; + const title = clip(msg.title || '风影办公', 20); + const content = clip(msg.content || '你有一条新消息', 50); + const category = msg.category || (msg.kind === 'todo' || msg.kind === 'approval' ? 'TODO' : 'IM'); + const body: Record = { + regId, + notifyType: 4, + title, + content, + timeToLive: 86400, + skipType: 1, + networkType: -1, + classification: 1, + category, + requestId: randomUUID().replace(/-/g, '').slice(0, 32), + clientCustomMap: { + conversationId: msg.conversationId || '', + kind: msg.kind || 'chat', + bizType: msg.bizType || '', + bizId: msg.bizId || '', + callId: msg.callId || '', + callKind: msg.callKind || '', + fromName: msg.fromName || '', + action: msg.action || '', + }, + }; + try { + const res = await fetch('https://api-push.vivo.com.cn/message/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json', authToken: token }, + body: JSON.stringify(body), + }); + const json = (await res.json()) as { result?: number; desc?: string }; + if (json.result === 10302) { + await this.prisma.pushDevice.deleteMany({ where: { vendor: 'vivo', regId } }); + this.log.warn(`vivo regId 失效已清理`); + return; + } + if (json.result && json.result !== 0) { + this.log.warn(`vivo send ${json.result} ${json.desc || ''}`); + } + } catch (e) { + this.log.warn(`vivo send: ${(e as Error).message}`); + } + } + + private async vivoAuth(appId: number, appKey: string, appSecret: string) { + if (this.vivoToken && Date.now() - this.vivoTokenAt < 20 * 60 * 60 * 1000) return this.vivoToken; + const timestamp = Date.now(); + const sign = createHash('md5') + .update(`${appId}${appKey}${timestamp}${appSecret}`) + .digest('hex'); + try { + const res = await fetch('https://api-push.vivo.com.cn/message/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ appId, appKey, timestamp, sign }), + }); + const json = (await res.json()) as { result?: number; authToken?: string; desc?: string }; + if (json.result !== 0 || !json.authToken) { + this.log.warn(`vivo auth ${json.result} ${json.desc || ''}`); + return ''; + } + this.vivoToken = json.authToken; + this.vivoTokenAt = Date.now(); + return this.vivoToken; + } catch (e) { + this.log.warn(`vivo auth: ${(e as Error).message}`); + return ''; + } + } + + private async sendOppo(regId: string, msg: VendorPush) { + const appKey = process.env.OPPO_PUSH_APP_KEY || ''; + const master = process.env.OPPO_PUSH_MASTER_SECRET || process.env.OPPO_PUSH_APP_SECRET || ''; + if (!appKey || !master) return; + const token = await this.oppoAuth(appKey, master); + if (!token) return; + const title = clip(msg.title || '风影办公', 32); + const content = clip(msg.content || '你有一条新消息', 100); + const category = msg.category || (msg.kind === 'todo' || msg.kind === 'approval' ? 'TODO' : 'IM'); + const channelId = msg.kind === 'call' ? 'fy_call_v2' : category === 'IM' ? 'fy_im_custom' : 'fy_todo'; + const message = { + target_type: 2, + target_value: regId, + notification: { + app_message_id: randomUUID().replace(/-/g, '').slice(0, 32), + title, + content, + click_action_type: 0, + channel_id: channelId, + off_line: true, + off_line_ttl: 86400, + category, + notify_level: category === 'IM' ? 2 : 1, + action_parameters: JSON.stringify({ + conversationId: msg.conversationId || '', + kind: msg.kind || 'chat', + bizType: msg.bizType || '', + bizId: msg.bizId || '', + callId: msg.callId || '', + callKind: msg.callKind || '', + fromName: msg.fromName || '', + action: msg.action || '', + }), + }, + }; + try { + const form = new URLSearchParams({ + auth_token: token, + message: JSON.stringify(message), + }); + const res = await fetch('https://api.push.oppomobile.com/server/v1/message/notification/unicast', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body: form.toString(), + }); + const json = (await res.json()) as { code?: number; message?: string }; + if (json.code === 33 || json.code === 10000) { + await this.prisma.pushDevice.deleteMany({ where: { vendor: 'oppo', regId } }); + this.log.warn(`oppo regId 失效已清理`); + return; + } + if (json.code && json.code !== 0) { + this.log.warn(`oppo send ${json.code} ${json.message || ''}`); + } + } catch (e) { + this.log.warn(`oppo send: ${(e as Error).message}`); + } + } + + private async oppoAuth(appKey: string, masterSecret: string) { + if (this.oppoToken && Date.now() - this.oppoTokenAt < 20 * 60 * 60 * 1000) return this.oppoToken; + const timestamp = String(Date.now()); + const sign = createHash('sha256').update(`${appKey}${timestamp}${masterSecret}`).digest('hex'); + try { + const form = new URLSearchParams({ app_key: appKey, timestamp, sign }); + const res = await fetch('https://api.push.oppomobile.com/server/v1/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body: form.toString(), + }); + const json = (await res.json()) as { + code?: number; + message?: string; + data?: { auth_token?: string }; + }; + if (json.code !== 0 || !json.data?.auth_token) { + this.log.warn(`oppo auth ${json.code} ${json.message || ''}`); + return ''; + } + this.oppoToken = json.data.auth_token; + this.oppoTokenAt = Date.now(); + return this.oppoToken; + } catch (e) { + this.log.warn(`oppo auth: ${(e as Error).message}`); + return ''; + } + } + + private async sendMeizu(pushId: string, msg: VendorPush) { + const appId = process.env.MEIZU_PUSH_APP_ID || ''; + const appSecret = process.env.MEIZU_PUSH_APP_SECRET || ''; + if (!appId || !appSecret) return; + const title = clip(msg.title || '风影办公', 16); + const content = clip(msg.content || '你有一条新消息', 50); + const messageJson = JSON.stringify({ + noticeBarInfo: { + noticeMsgType: 1, + noticeBarType: 0, + title, + content, + }, + clickTypeInfo: { + clickType: 0, + parameters: { + conversationId: msg.conversationId || '', + kind: msg.kind || 'chat', + bizType: msg.bizType || '', + bizId: msg.bizId || '', + callId: msg.callId || '', + callKind: msg.callKind || '', + fromName: msg.fromName || '', + action: msg.action || '', + }, + }, + pushTimeInfo: { offLine: 1, validTime: 24 }, + advanceInfo: { + suspend: 1, + clearNoticeBar: 1, + notificationType: { vibrate: 1, lights: 1, sound: 1 }, + }, + }); + const params: Record = { appId, pushIds: pushId, messageJson }; + params.sign = meizuSign(params, appSecret); + try { + const res = await fetch('https://server-api-push.meizu.com/garcia/api/server/push/varnished/pushByPushId', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body: new URLSearchParams(params).toString(), + }); + const json = (await res.json()) as { code?: string | number; message?: string; msg?: string }; + const code = String(json.code ?? ''); + if (code === '200' || code === '0') return; + if (code === '512' || code === '513' || /invalid|不存在|失效/i.test(json.message || json.msg || '')) { + await this.prisma.pushDevice.deleteMany({ where: { vendor: 'meizu', regId: pushId } }); + this.log.warn(`meizu pushId 失效已清理`); + return; + } + this.log.warn(`meizu send ${code} ${json.message || json.msg || ''}`); + } catch (e) { + this.log.warn(`meizu send: ${(e as Error).message}`); + } + } + + private huaweiToken = ''; + private huaweiTokenAt = 0; + + private async sendHuawei(token: string, msg: VendorPush) { + const appId = process.env.HUAWEI_PUSH_APP_ID || '118825899'; + const clientId = process.env.HUAWEI_PUSH_CLIENT_ID || appId; + const secret = process.env.HUAWEI_PUSH_CLIENT_SECRET || ''; + if (!secret) return; + const auth = await this.huaweiAuth(clientId, secret); + if (!auth) return; + const data = { + conversationId: msg.conversationId || '', + kind: msg.kind || '', + bizType: msg.bizType || '', + bizId: msg.bizId || '', + callId: msg.callId || '', + callKind: msg.callKind || '', + fromName: msg.fromName || '', + action: msg.action || '', + }; + const qs = new URLSearchParams(data).toString() ? `?${new URLSearchParams(data).toString()}` : ''; + const intent = `intent://chat${qs}#Intent;scheme=fengyingoa;launchFlags=0x04000000;end`; + // 安卓 / 能装 APK 的鸿蒙:HMS Push v1 + await this.huaweiPost( + // Push Kit REST v1 使用 AGC 项目设置中的 OAuth client_id 作为路径参数, + // 不是 App ID;两者相同的项目也统一走 clientId,避免凭据与应用错配。 + `https://push-api.cloud.huawei.com/v1/${clientId}/messages:send`, + auth, + { + message: { + android: { + notification: { + title: clip(msg.title, 40), + body: clip(msg.content, 100), + click_action: { type: 1, intent }, + channel_id: msg.kind === 'call' ? 'fy_call_v2' : msg.category === 'TODO' ? 'fy_todo' : 'fy_im_custom', + importance: msg.category === 'TODO' ? 'NORMAL' : 'HIGH', + default_sound: false, + use_default_vibrate: true, + }, + category: msg.category === 'TODO' ? 'WORK' : 'IM', + urgency: msg.category === 'TODO' ? 'NORMAL' : 'HIGH', + }, + token: [token], + data: JSON.stringify(data), + }, + }, + token, + ); + } + + private async huaweiPost( + url: string, + auth: string, + body: unknown, + token: string, + extraHeaders: Record = {}, + ) { + try { + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth}`, + 'Content-Type': 'application/json', + ...extraHeaders, + }, + body: JSON.stringify(body), + }); + const json = (await res.json()) as { code?: string; msg?: string }; + if (json.code === '80300007' || json.code === '80100003') { + await this.prisma.pushDevice.deleteMany({ + where: { vendor: { in: ['huawei', 'honor'] }, regId: token }, + }); + this.log.warn('huawei token 失效已清理'); + return true; + } + if (json.code === '80000000') return true; + this.log.warn(`huawei send ${url.includes('/v3/') ? 'v3' : 'v1'} ${json.code} ${json.msg || ''}`); + return false; + } catch (e) { + this.log.warn(`huawei send: ${(e as Error).message}`); + return false; + } + } + + private async huaweiAuth(clientId: string, clientSecret: string) { + if (this.huaweiToken && Date.now() - this.huaweiTokenAt < 50 * 60 * 1000) return this.huaweiToken; + try { + const res = await fetch('https://oauth-login.cloud.huawei.com/oauth2/v3/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + }).toString(), + }); + const json = (await res.json()) as { access_token?: string; error?: string }; + if (!json.access_token) { + this.log.warn(`huawei auth ${json.error || res.status}`); + return ''; + } + this.huaweiToken = json.access_token; + this.huaweiTokenAt = Date.now(); + return this.huaweiToken; + } catch (e) { + this.log.warn(`huawei auth: ${(e as Error).message}`); + return ''; + } + } +} + +function clip(text: string, maxHans: number) { + const raw = (text || '').replace(/\s+/g, ' ').trim(); + let units = 0; + let out = ''; + for (const ch of raw) { + units += /[\u4e00-\u9fff]/.test(ch) ? 1 : 0.5; + if (units > maxHans) break; + out += ch; + } + return out || '通知'; +} + +function meizuSign(params: Record, appSecret: string) { + const raw = + Object.keys(params) + .filter((k) => k !== 'sign' && params[k] !== '') + .sort() + .map((k) => `${k}=${params[k]}`) + .join('') + appSecret; + return createHash('md5').update(raw).digest('hex'); +} diff --git a/apps/api/src/report/report.controller.ts b/apps/api/src/report/report.controller.ts new file mode 100644 index 0000000..05f5df9 --- /dev/null +++ b/apps/api/src/report/report.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser, type AuthUser } from '../common/decorators/current-user.decorator'; +import { ReportService } from './report.service'; + +@ApiTags('reports') +@ApiBearerAuth() +@Controller('reports') +export class ReportController { + constructor(private readonly reports: ReportService) {} + + @Get('bid-conversion') + bidConversion(@CurrentUser() user: AuthUser) { + return this.reports.bidConversion(user); + } + + @Get('project-profit') + projectProfit(@CurrentUser() user: AuthUser) { + return this.reports.projectProfit(user); + } + + @Get('dept-efficiency') + deptEfficiency(@CurrentUser() user: AuthUser) { + return this.reports.deptEfficiency(user); + } + + @Get('cash-lock') + cashLock(@CurrentUser() user: AuthUser) { + return this.reports.cashLock(user); + } + + @Get('competitor-wins') + competitorWins(@CurrentUser() user: AuthUser) { + return this.reports.competitorWins(user); + } +} diff --git a/apps/api/src/report/report.service.ts b/apps/api/src/report/report.service.ts new file mode 100644 index 0000000..5925a8d --- /dev/null +++ b/apps/api/src/report/report.service.ts @@ -0,0 +1,170 @@ +import { ForbiddenException, Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { BID_STATUS } from '../bidding/bid-workflow'; +import { EXPENSE_STATUS, BOND_STATUS, INVOICE_STATUS } from '../finance/finance-workflow'; +import { canAccessReport, FUNNEL_STAGES, PAY_STATUS, PURCHASE_STATUS } from '../system/ops-workflow'; + +@Injectable() +export class ReportService { + constructor(private readonly prisma: PrismaService) {} + + assert(user: AuthUser) { + if (!canAccessReport(user.roles, user.permissions)) { + throw new ForbiddenException('无权查看经营报表'); + } + } + + async bidConversion(user: AuthUser) { + this.assert(user); + const [bids, expenses] = await Promise.all([ + this.prisma.bidCase.findMany({ + where: { tenantId: '1' }, + select: { id: true, status: true, printCost: true }, + }), + this.prisma.expenseClaim.findMany({ + where: { + tenantId: '1', + costType: 'BID', + status: EXPENSE_STATUS.APPROVED, + bidCaseId: { not: null }, + }, + select: { bidCaseId: true, amount: true }, + }), + ]); + const spend = new Map(); + for (const e of expenses) { + if (!e.bidCaseId) continue; + spend.set(e.bidCaseId, (spend.get(e.bidCaseId) || 0) + Number(e.amount)); + } + for (const b of bids) { + spend.set(b.id, (spend.get(b.id) || 0) + Number(b.printCost || 0)); + } + return FUNNEL_STAGES.map((stage) => { + const rows = bids.filter((b) => (stage.statuses as readonly string[]).includes(b.status)); + return { + id: stage.id, + status: stage.label, + count: rows.length, + presalesSpend: round(rows.reduce((s, b) => s + (spend.get(b.id) || 0), 0)), + }; + }); + } + + async projectProfit(user: AuthUser) { + this.assert(user); + const projects = await this.prisma.project.findMany({ + include: { contract: true, expenses: true, timesheets: true, purchases: true }, + orderBy: { createdAt: 'desc' }, + }); + return projects.map((p) => { + const revenue = Number(p.contract?.amount ?? 0); + const expenseCost = p.expenses + .filter((e) => e.status === EXPENSE_STATUS.APPROVED) + .reduce((s, e) => s + Number(e.amount), 0); + const laborCost = p.timesheets.reduce((s, t) => s + Number(t.costAmount), 0); + const purchaseCost = p.purchases + .filter((x) => x.status === PURCHASE_STATUS.APPROVED || x.status === PURCHASE_STATUS.PENDING) + .reduce((s, x) => s + Number(x.amount), 0); + const cost = round(expenseCost + laborCost + purchaseCost); + return { + id: p.id, + name: p.name, + projectNo: p.projectNo, + revenue, + laborCost: round(laborCost), + expenseCost: round(expenseCost), + purchaseCost: round(purchaseCost), + presalesCost: Number(p.presalesCost), + cost, + profit: round(revenue - cost), + }; + }); + } + + async deptEfficiency(user: AuthUser) { + this.assert(user); + const start = new Date(new Date().getFullYear(), new Date().getMonth(), 1); + const depts = await this.prisma.department.findMany({ + where: { deptType: 'TECH' }, + include: { employees: true, projectTasks: true }, + orderBy: { sortNo: 'asc' }, + }); + const sheets = await this.prisma.timesheet.findMany({ + where: { workDate: { gte: start } }, + include: { task: true }, + }); + return depts.map((d) => { + const monthHours = sheets + .filter((s) => s.task?.departmentId === d.id) + .reduce((s, r) => s + Number(r.hours), 0); + const openTasks = d.projectTasks.filter((t) => t.status === 'OPEN').length; + const headcount = d.employees.filter((e) => e.employmentStatus !== 'LEFT').length; + return { + id: d.id, + name: d.name, + headcount, + openTasks, + monthHours: round(monthHours), + hoursPerHead: headcount ? round(monthHours / headcount) : 0, + }; + }); + } + + async competitorWins(user: AuthUser) { + this.assert(user); + const rows = await this.prisma.bidCase.findMany({ + where: { + tenantId: '1', + status: { in: [BID_STATUS.TERMINATED, BID_STATUS.LOST, BID_STATUS.FAILED] }, + }, + include: { + legalEntity: { select: { name: true, shortName: true } }, + party: { select: { name: true } }, + }, + orderBy: [{ winnerAmount: { sort: 'desc', nulls: 'last' } }, { updatedAt: 'desc' }], + }); + return rows.map((b) => ({ + id: b.id, + bidNo: b.bidNo, + externalNo: b.externalNo, + name: b.name, + status: b.status, + projectType: b.projectType, + partyName: b.party?.name || '', + legalEntity: b.legalEntity?.name || b.legalEntity?.shortName || '', + quoteAmount: b.quoteAmount, + winnerName: b.winnerName || '', + winnerAmount: b.winnerAmount != null ? Number(b.winnerAmount) : null, + winnerNote: b.winnerNote || '', + openedResultAt: b.openedResultAt, + createdAt: b.createdAt, + updatedAt: b.updatedAt, + })); + } + + async cashLock(user: AuthUser) { + this.assert(user); + const [bonds, invoices, payments] = await Promise.all([ + this.prisma.bondRecord.findMany({ where: { status: BOND_STATUS.PAID } }), + this.prisma.invoiceRecord.findMany({ + where: { status: { in: [INVOICE_STATUS.ISSUED, INVOICE_STATUS.OVERDUE] } }, + }), + this.prisma.paymentRecord.findMany({ where: { status: PAY_STATUS.TODO } }), + ]); + const won = await this.prisma.bidCase.count({ where: { status: BID_STATUS.WON } }); + return [ + { + id: 'cash', + unpaidBonds: round(bonds.reduce((s, b) => s + Number(b.amount), 0)), + outstandingInvoices: round(invoices.reduce((s, i) => s + Number(i.amount), 0)), + pendingPayments: round(payments.reduce((s, p) => s + Number(p.amount), 0)), + wonBids: won, + }, + ]; + } +} + +function round(n: number) { + return Math.round(n * 100) / 100; +} diff --git a/apps/api/src/seal/seal-workflow.spec.ts b/apps/api/src/seal/seal-workflow.spec.ts new file mode 100644 index 0000000..5ab840d --- /dev/null +++ b/apps/api/src/seal/seal-workflow.spec.ts @@ -0,0 +1,41 @@ +import { + borrowBucket, + canAccessSeal, + canApplySeal, + canApproveSeal, + isOverdue, + qualBucket, + sealBucket, + sealStatusAfterApprove, +} from './seal-workflow'; + +describe('seal-workflow', () => { + it('lets biz roles apply and owner/director approve', () => { + expect(canApplySeal(['biz_staff'], ['seal:requests'])).toBe(true); + expect(canApproveSeal(['owner'])).toBe(true); + expect(canApproveSeal(['biz_staff'])).toBe(false); + expect(canAccessSeal(['hr'], ['hr:org'])).toBe(false); + expect(canApplySeal(['employee'], ['office:seal-apply'])).toBe(true); + expect(canAccessSeal(['biz_director'], ['seal:qualifications'])).toBe(true); + }); + + it('warns qualifications within 30 days or already expired', () => { + expect(qualBucket(null)).toBe('valid'); + expect(qualBucket(new Date(Date.now() + 40 * 86400000))).toBe('valid'); + expect(qualBucket(new Date(Date.now() + 10 * 86400000))).toBe('expiring'); + expect(qualBucket(new Date(Date.now() - 86400000))).toBe('expired'); + }); + + it('flags overdue borrows and take-out seals', () => { + expect(borrowBucket({ status: 'RETURNED' })).toBe('returned'); + expect(borrowBucket({ status: 'BORROWED', dueAt: new Date(Date.now() - 86400000) })).toBe('overdue'); + expect(borrowBucket({ status: 'BORROWED', dueAt: new Date(Date.now() + 86400000) })).toBe('due'); + expect(sealBucket({ status: 'PENDING', takeOut: true })).toBe('pending'); + expect(sealBucket({ status: 'OUT', takeOut: true, returnAt: new Date(Date.now() - 86400000) })).toBe( + 'overdue', + ); + expect(sealStatusAfterApprove(true)).toBe('OUT'); + expect(sealStatusAfterApprove(false)).toBe('APPROVED'); + expect(isOverdue(new Date(Date.now() - 1000))).toBe(true); + }); +}); diff --git a/apps/api/src/seal/seal-workflow.ts b/apps/api/src/seal/seal-workflow.ts new file mode 100644 index 0000000..8e5d59c --- /dev/null +++ b/apps/api/src/seal/seal-workflow.ts @@ -0,0 +1,82 @@ +export const QUAL_WARN_DAYS = 30; + +export const SEAL_STATUS = { + PENDING: 'PENDING', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED', + OUT: 'OUT', + RETURNED: 'RETURNED', +} as const; + +export const BORROW_STATUS = { + BORROWED: 'BORROWED', + RETURNED: 'RETURNED', +} as const; + +export const SEAL_BIZ = { + APPROVAL: 'SEAL_APPROVAL', + RETURN: 'SEAL_RETURN', + CREDENTIAL: 'CREDENTIAL_RETURN', +} as const; + +export function canAccessSeal(roles: string[], permissions: string[]) { + if (roles.includes('admin')) return true; + return permissions.some((p) => p === 'seal' || p.startsWith('seal:')); +} + +export const SEAL_TYPES = ['公章', '合同章', '法人章', '法人签名章', '项目负责人章', '其他章'] as const; + +export function canApplySeal(roles: string[], permissions: string[]) { + if (['admin', 'owner', 'biz_director', 'biz_staff'].some((r) => roles.includes(r))) return true; + if (permissions.includes('office:seal-apply')) return true; + return canAccessSeal(roles, permissions); +} + +export function canApproveSeal(roles: string[]) { + return ['admin', 'owner', 'biz_director'].some((r) => roles.includes(r)); +} + +export function qualBucket(expiresAt?: Date | string | null) { + if (!expiresAt) return 'valid'; + const t = new Date(expiresAt).getTime(); + const now = Date.now(); + if (t < now) return 'expired'; + if (t <= now + QUAL_WARN_DAYS * 86400000) return 'expiring'; + return 'valid'; +} + +export function borrowBucket(row: { + status: string; + dueAt?: Date | string | null; + returnedAt?: Date | string | null; +}) { + if (row.status === BORROW_STATUS.RETURNED || row.returnedAt) return 'returned'; + if (row.dueAt && new Date(row.dueAt).getTime() < Date.now()) return 'overdue'; + if (row.dueAt) return 'due'; + return 'borrowed'; +} + +export function sealBucket(row: { + status: string; + takeOut: boolean; + returnAt?: Date | string | null; + returnedAt?: Date | string | null; +}) { + if (row.status === SEAL_STATUS.PENDING) return 'pending'; + if (row.status === SEAL_STATUS.REJECTED) return 'rejected'; + if (row.status === SEAL_STATUS.RETURNED || row.returnedAt) return 'returned'; + if (row.status === SEAL_STATUS.OUT || (row.takeOut && row.status === SEAL_STATUS.APPROVED && !row.returnedAt)) { + if (row.returnAt && new Date(row.returnAt).getTime() < Date.now()) return 'overdue'; + return 'out'; + } + if (row.status === SEAL_STATUS.APPROVED) return 'approved'; + return row.status.toLowerCase(); +} + +export function sealStatusAfterApprove(takeOut: boolean) { + return takeOut ? SEAL_STATUS.OUT : SEAL_STATUS.APPROVED; +} + +export function isOverdue(dueAt?: Date | string | null) { + return Boolean(dueAt && new Date(dueAt).getTime() < Date.now()); +} diff --git a/apps/api/src/seal/seal.controller.ts b/apps/api/src/seal/seal.controller.ts new file mode 100644 index 0000000..7e3dc8a --- /dev/null +++ b/apps/api/src/seal/seal.controller.ts @@ -0,0 +1,152 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser, type AuthUser } from '../common/decorators/current-user.decorator'; +import { SealService } from './seal.service'; + +@ApiTags('seal') +@ApiBearerAuth() +@Controller() +export class SealController { + constructor(private readonly seals: SealService) {} + + @Get('qualifications') + quals(@CurrentUser() user: AuthUser, @Query('bucket') bucket?: string) { + return this.seals.listQualifications(user, bucket); + } + + @Post('qualifications') + createQual( + @CurrentUser() user: AuthUser, + @Body() body: { name: string; code: string; expiresAt?: string; keeper?: string }, + ) { + return this.seals.createQualification(user, body); + } + + @Patch('qualifications/:id') + updateQual( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { name?: string; code?: string; expiresAt?: string | null; keeper?: string }, + ) { + return this.seals.updateQualification(id, user, body); + } + + @Delete('qualifications/:id') + deleteQual(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.seals.deleteQualification(id, user); + } + + @Get('credential-borrows') + borrows(@CurrentUser() user: AuthUser, @Query('bucket') bucket?: string) { + return this.seals.listBorrows(user, bucket); + } + + @Post('credential-borrows') + createBorrow( + @CurrentUser() user: AuthUser, + @Body() + body: { + itemName: string; + borrower?: string; + borrowerId?: string; + qualificationId?: string; + bidCaseId?: string; + dueAt?: string; + remark?: string; + }, + ) { + return this.seals.createBorrow(user, body); + } + + @Post('credential-borrows/:id/return') + returnBorrow(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.seals.returnBorrow(id, user); + } + + @Delete('credential-borrows/:id') + deleteBorrow(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.seals.deleteBorrow(id, user); + } + + @Get('seal-requests/:id') + requestOne(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.seals.getRequest(id, user); + } + + @Get('seal-requests') + requests( + @CurrentUser() user: AuthUser, + @Query('bucket') bucket?: string, + @Query('scope') scope?: string, + ) { + return this.seals.listSeals(user, bucket, scope); + } + + @Post('seal-requests') + createRequest( + @CurrentUser() user: AuthUser, + @Body() + body: { + sealType: string; + reason: string; + bidCaseId?: string; + contractId?: string; + applicantId?: string; + approverId?: string; + takeOut?: boolean; + returnAt?: string; + }, + ) { + return this.seals.createSealRequest(user, body); + } + + @Post('seal-requests/:id/reviews') + review( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { result: 'APPROVED' | 'REJECTED'; comment?: string }, + ) { + return this.seals.reviewSeal(id, user, body); + } + + @Post('seal-requests/:id/return') + returnSeal(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.seals.returnSeal(id, user); + } + + @Delete('seal-requests/:id') + deleteRequest(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.seals.deleteSealRequest(id, user); + } + + @Get('seals') + registry( + @CurrentUser() user: AuthUser, + @Query('legalEntityId') legalEntityId?: string, + @Query('sealType') sealType?: string, + ) { + return this.seals.listRegistry(user, legalEntityId, sealType); + } + + @Post('seals') + createSeal( + @CurrentUser() user: AuthUser, + @Body() body: { name: string; keeper?: string; sealType?: string; legalEntityId?: string }, + ) { + return this.seals.createRegistryItem(user, body); + } + + @Patch('seals/:id') + updateSeal( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { name?: string; keeper?: string; status?: string; sealType?: string; legalEntityId?: string | null }, + ) { + return this.seals.updateRegistryItem(id, user, body); + } + + @Delete('seals/:id') + deleteSeal(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.seals.deleteRegistryItem(id, user); + } +} diff --git a/apps/api/src/seal/seal.service.ts b/apps/api/src/seal/seal.service.ts new file mode 100644 index 0000000..bb7acf6 --- /dev/null +++ b/apps/api/src/seal/seal.service.ts @@ -0,0 +1,759 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { CONTRACT_STATUS } from '../contract/contract-workflow'; +import { emitSms } from '../sms/sms-bus'; +import { ownerScope, withAnd } from '../common/data-scope'; +import { canAccessBidModule } from '../bidding/bid-workflow'; +import { + BORROW_STATUS, + SEAL_BIZ, + SEAL_STATUS, + SEAL_TYPES, + borrowBucket, + canAccessSeal, + canApplySeal, + canApproveSeal, + isOverdue, + qualBucket, + sealBucket, + sealStatusAfterApprove, +} from './seal-workflow'; + +const USER_SELECT = { id: true, displayName: true, username: true }; +const BID_SELECT = { id: true, bidNo: true, name: true, status: true }; +const CONTRACT_SELECT = { id: true, contractNo: true, name: true, status: true }; +const QUAL_SELECT = { id: true, name: true, code: true, expiresAt: true }; + +type Tx = Prisma.TransactionClient; +export type ReviewInput = { result: 'APPROVED' | 'REJECTED'; comment?: string }; + +@Injectable() +export class SealService { + constructor(private readonly prisma: PrismaService) {} + + assertAccess(user: AuthUser) { + if (!canAccessSeal(user.roles, user.permissions)) { + throw new ForbiddenException('无权访问证照用章'); + } + } + + async listQualifications(user: AuthUser, bucket?: string) { + this.assertAccess(user); + const rows = await this.prisma.qualification.findMany({ + where: { tenantId: '1' }, + orderBy: { name: 'asc' }, + }); + const items = rows.map((r) => ({ ...r, bucket: qualBucket(r.expiresAt) })); + const filtered = bucket ? items.filter((i) => i.bucket === bucket) : items; + return { items: filtered, total: filtered.length }; + } + + async createQualification( + user: AuthUser, + dto: { name: string; code: string; expiresAt?: string; keeper?: string }, + ) { + this.assertAccess(user); + if (!dto.name?.trim() || !dto.code?.trim()) throw new BadRequestException('请填写资质名称和编码'); + const created = await this.prisma.qualification.create({ + data: { + tenantId: '1', + name: dto.name.trim(), + code: dto.code.trim(), + keeper: dto.keeper?.trim() || null, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, + }, + }); + return { ...created, bucket: qualBucket(created.expiresAt) }; + } + + async updateQualification( + id: string, + user: AuthUser, + dto: { name?: string; code?: string; expiresAt?: string | null; keeper?: string }, + ) { + this.assertAccess(user); + const row = await this.prisma.qualification.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('资质不存在'); + const updated = await this.prisma.qualification.update({ + where: { id }, + data: { + name: dto.name?.trim() || row.name, + code: dto.code?.trim() || row.code, + keeper: dto.keeper === undefined ? row.keeper : dto.keeper?.trim() || null, + expiresAt: dto.expiresAt === undefined ? row.expiresAt : dto.expiresAt ? new Date(dto.expiresAt) : null, + }, + }); + return { ...updated, bucket: qualBucket(updated.expiresAt) }; + } + + async deleteQualification(id: string, user: AuthUser) { + this.assertAccess(user); + const row = await this.prisma.qualification.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('资质不存在'); + await this.prisma.$transaction(async (tx) => { + await tx.credentialBorrow.updateMany({ where: { qualificationId: id }, data: { qualificationId: null } }); + await tx.qualification.delete({ where: { id } }); + }); + return { ok: true }; + } + + async listBorrows(user: AuthUser, bucket?: string) { + this.assertAccess(user); + const rows = await this.prisma.credentialBorrow.findMany({ + where: { tenantId: '1' }, + include: { + borrowerUser: { select: USER_SELECT }, + qualification: { select: QUAL_SELECT }, + bidCase: { select: BID_SELECT }, + }, + orderBy: { createdAt: 'desc' }, + }); + const items = rows.map((r) => ({ ...r, bucket: borrowBucket(r) })); + const filtered = bucket ? items.filter((i) => i.bucket === bucket) : items; + return { items: filtered, total: filtered.length }; + } + + async createBorrow( + user: AuthUser, + dto: { + itemName: string; + borrower?: string; + borrowerId?: string; + qualificationId?: string; + bidCaseId?: string; + dueAt?: string; + remark?: string; + }, + ) { + this.assertAccess(user); + if (!canApplySeal(user.roles, user.permissions)) throw new ForbiddenException('无权登记外借'); + if (!dto.itemName?.trim()) throw new BadRequestException('请填写证照或物品名称'); + let borrowerName = dto.borrower?.trim() || ''; + if (dto.borrowerId) { + const u = await this.prisma.user.findFirst({ where: { id: dto.borrowerId, tenantId: '1' } }); + if (!u) throw new NotFoundException('借用人不存在'); + borrowerName = u.displayName; + } + if (!borrowerName) throw new BadRequestException('请选择或填写借用人'); + if (dto.qualificationId) { + const q = await this.prisma.qualification.findFirst({ + where: { id: dto.qualificationId, tenantId: '1' }, + }); + if (!q) throw new NotFoundException('资质不存在'); + } + if (dto.bidCaseId) { + const bid = await this.prisma.bidCase.findFirst({ where: { id: dto.bidCaseId, tenantId: '1' } }); + if (!bid) throw new NotFoundException('投标事项不存在'); + } + const created = await this.prisma.$transaction(async (tx) => { + const row = await tx.credentialBorrow.create({ + data: { + tenantId: '1', + itemName: dto.itemName.trim(), + borrower: borrowerName, + borrowerId: dto.borrowerId || null, + qualificationId: dto.qualificationId || null, + bidCaseId: dto.bidCaseId || null, + dueAt: dto.dueAt ? new Date(dto.dueAt) : null, + remark: dto.remark, + status: BORROW_STATUS.BORROWED, + }, + }); + const assigneeId = dto.borrowerId || user.id; + await tx.todoTask.create({ + data: { + tenantId: '1', + title: `归还证照:${row.itemName}`, + bizType: SEAL_BIZ.CREDENTIAL, + bizId: row.id, + assigneeId, + dueAt: row.dueAt, + }, + }); + return row; + }); + return this.getBorrow(created.id); + } + + async returnBorrow(id: string, user: AuthUser) { + this.assertAccess(user); + const row = await this.prisma.credentialBorrow.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('借用记录不存在'); + if (row.status === BORROW_STATUS.RETURNED) throw new BadRequestException('已经归还'); + await this.prisma.$transaction(async (tx) => { + await tx.credentialBorrow.update({ + where: { id }, + data: { status: BORROW_STATUS.RETURNED, returnedAt: new Date() }, + }); + await tx.todoTask.updateMany({ + where: { bizId: id, bizType: SEAL_BIZ.CREDENTIAL, status: 'OPEN' }, + data: { status: 'DONE' }, + }); + }); + return this.getBorrow(id); + } + + async deleteBorrow(id: string, user: AuthUser) { + this.assertAccess(user); + const row = await this.prisma.credentialBorrow.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('借用记录不存在'); + await this.prisma.$transaction(async (tx) => { + await tx.todoTask.updateMany({ + where: { bizId: id, bizType: SEAL_BIZ.CREDENTIAL, status: 'OPEN' }, + data: { status: 'CANCELLED' }, + }); + await tx.credentialBorrow.delete({ where: { id } }); + }); + return { ok: true }; + } + + async listSeals(user: AuthUser, bucket?: string, scope?: string) { + const canModule = canAccessSeal(user.roles, user.permissions); + if (!canModule && !canApplySeal(user.roles, user.permissions)) { + throw new ForbiddenException('无权访问用章申请'); + } + const where: Prisma.SealRequestWhereInput = { tenantId: '1' }; + if (scope === 'mine' || (!canModule && !this.canSeeAllCompanySeals(user))) { + where.applicantId = user.id; + } else if (!this.canSeeAllCompanySeals(user)) { + withAnd(where, (await ownerScope(this.prisma, user, 'applicantId')) as Record); + } + const rows = await this.prisma.sealRequest.findMany({ + where, + include: { + applicant: { select: USER_SELECT }, + decidedBy: { select: USER_SELECT }, + bidCase: { select: BID_SELECT }, + contract: { select: CONTRACT_SELECT }, + }, + orderBy: { createdAt: 'desc' }, + }); + const items = rows.map((r) => ({ + ...r, + bucket: sealBucket(r), + myActions: { + canReview: canApproveSeal(user.roles) && r.status === SEAL_STATUS.PENDING, + canReturn: (r.status === SEAL_STATUS.OUT || sealBucket(r) === 'overdue') && !r.returnedAt, + }, + })); + const filtered = bucket ? items.filter((i) => i.bucket === bucket) : items; + return { items: filtered, total: filtered.length }; + } + + async createSealRequest( + user: AuthUser, + dto: { + sealType: string; + reason: string; + bidCaseId?: string; + contractId?: string; + applicantId?: string; + approverId?: string; + takeOut?: boolean; + returnAt?: string; + }, + ) { + if (!canApplySeal(user.roles, user.permissions)) throw new ForbiddenException('无权申请用章'); + if (!dto.sealType?.trim() || !dto.reason?.trim()) { + throw new BadRequestException('请填写印章类型和事由'); + } + if (dto.takeOut && !dto.returnAt) { + throw new BadRequestException('外带请填写预计归还日期'); + } + let contract: { id: string; status: string; bidCaseId: string | null } | null = null; + if (dto.contractId) { + contract = await this.prisma.contract.findFirst({ + where: { id: dto.contractId, tenantId: '1' }, + select: { id: true, status: true, bidCaseId: true }, + }); + if (!contract) throw new NotFoundException('合同不存在'); + } + if (dto.bidCaseId) { + const bid = await this.prisma.bidCase.findFirst({ where: { id: dto.bidCaseId, tenantId: '1' } }); + if (!bid) throw new NotFoundException('投标事项不存在'); + } + // 申请人始终绑定当前登录账号,避免前端篡改代申请人造成数据和短信错配。 + const applicantId = user.id; + const skipApproval = + Boolean(contract) && + (contract!.status === CONTRACT_STATUS.DRAFT || contract!.status === CONTRACT_STATUS.REJECTED); + let approverId: string | undefined; + if (!skipApproval) { + if (!dto.approverId?.trim()) throw new BadRequestException('请选择审批人'); + const approver = await this.prisma.user.findFirst({ + where: { id: dto.approverId.trim(), tenantId: '1', status: 'ACTIVE' }, + include: { roles: { include: { role: true } } }, + }); + if (!approver || !canApproveSeal(approver.roles.map((r) => r.role.code))) { + throw new BadRequestException('所选账号不是有效的用章审批人'); + } + approverId = approver.id; + } + const created = await this.prisma.$transaction(async (tx) => { + const row = await tx.sealRequest.create({ + data: { + tenantId: '1', + sealType: dto.sealType.trim(), + reason: dto.reason.trim(), + bidCaseId: dto.bidCaseId || contract?.bidCaseId || null, + contractId: dto.contractId || null, + applicantId, + takeOut: Boolean(dto.takeOut), + returnAt: dto.returnAt ? new Date(dto.returnAt) : null, + status: SEAL_STATUS.PENDING, + }, + }); + if (!skipApproval) { + await this.openApprovals(tx, row.id, `${dto.sealType}:${dto.reason}`, approverId!); + } + return row; + }); + return this.getSeal(created.id, user); + } + + async deleteSealRequest(id: string, user: AuthUser) { + const canModule = canAccessSeal(user.roles, user.permissions); + if (!canModule && !canApplySeal(user.roles, user.permissions)) { + throw new ForbiddenException('无权删除用章记录'); + } + const row = await this.prisma.sealRequest.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('用章申请不存在'); + if (!canModule && row.applicantId !== user.id) { + throw new ForbiddenException('只能删除自己的用章申请'); + } + await this.prisma.$transaction(async (tx) => { + await tx.approvalTask.updateMany({ + where: { bizId: id, bizType: SEAL_BIZ.APPROVAL, status: 'PENDING' }, + data: { status: 'CANCELLED' }, + }); + await tx.todoTask.updateMany({ + where: { bizId: id, bizType: { in: [SEAL_BIZ.APPROVAL, SEAL_BIZ.RETURN] }, status: 'OPEN' }, + data: { status: 'CANCELLED' }, + }); + await tx.sealRequest.delete({ where: { id } }); + }); + return { ok: true }; + } + + async reviewSeal(id: string, user: AuthUser, dto: ReviewInput) { + if (!canApproveSeal(user.roles)) throw new ForbiddenException('无权审批用章'); + if (dto.result === 'REJECTED' && !dto.comment?.trim()) { + throw new BadRequestException('驳回请填写意见'); + } + await this.prisma.$transaction(async (tx) => { + const row = await tx.sealRequest.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('用章申请不存在'); + if (row.status !== SEAL_STATUS.PENDING) throw new BadRequestException('当前不是待审批状态'); + if (dto.result === 'REJECTED') { + await tx.sealRequest.update({ + where: { id }, + data: { + status: SEAL_STATUS.REJECTED, + decidedAt: new Date(), + decidedById: user.id, + rejectComment: dto.comment!.trim(), + }, + }); + await this.closeApprovals(tx, id, 'REJECTED', user.id); + return; + } + const next = sealStatusAfterApprove(row.takeOut); + await tx.sealRequest.update({ + where: { id }, + data: { status: next, decidedAt: new Date(), decidedById: user.id }, + }); + await this.closeApprovals(tx, id, 'APPROVED', user.id); + if (row.takeOut) { + await this.createReturnTodo(tx, row); + } + }); + return this.getSeal(id, user); + } + + async returnSeal(id: string, user: AuthUser) { + this.assertAccess(user); + const row = await this.prisma.sealRequest.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('用章申请不存在'); + const bucket = sealBucket(row); + if (bucket !== 'out' && bucket !== 'overdue') { + throw new BadRequestException('当前不是外带未还状态'); + } + await this.prisma.$transaction(async (tx) => { + await tx.sealRequest.update({ + where: { id }, + data: { status: SEAL_STATUS.RETURNED, returnedAt: new Date() }, + }); + await tx.todoTask.updateMany({ + where: { bizId: id, bizType: SEAL_BIZ.RETURN, status: 'OPEN' }, + data: { status: 'DONE' }, + }); + }); + return this.getSeal(id, user); + } + + async listRegistry(user: AuthUser, legalEntityId?: string, sealType?: string) { + this.assertAccess(user); + const items = await this.prisma.sealItem.findMany({ + where: { + tenantId: '1', + ...(legalEntityId ? { legalEntityId } : {}), + ...(sealType ? { sealType } : {}), + }, + include: { legalEntity: { select: { id: true, name: true, shortName: true } } }, + orderBy: [{ legalEntity: { shortName: 'asc' } }, { sealType: 'asc' }, { name: 'asc' }], + }); + return { + items: items.map((i) => ({ + ...i, + statusLabel: i.status === 'OUT' ? '外带' : '在库', + })), + }; + } + + async createRegistryItem( + user: AuthUser, + dto: { name: string; keeper?: string; sealType?: string; legalEntityId?: string }, + ) { + this.assertAccess(user); + if (!dto.name?.trim()) throw new BadRequestException('请填写印章名称'); + const sealType = dto.sealType?.trim() || '公章'; + if (!SEAL_TYPES.includes(sealType as (typeof SEAL_TYPES)[number])) { + throw new BadRequestException('请选择正确的印章类型'); + } + if (dto.legalEntityId) { + const entity = await this.prisma.legalEntity.findFirst({ where: { id: dto.legalEntityId, tenantId: '1' } }); + if (!entity) throw new NotFoundException('参与主体不存在'); + } + return this.prisma.sealItem.create({ + data: { + tenantId: '1', + name: dto.name.trim(), + sealType, + keeper: dto.keeper?.trim() || '行政', + legalEntityId: dto.legalEntityId || null, + }, + }); + } + + async updateRegistryItem( + id: string, + user: AuthUser, + dto: { name?: string; keeper?: string; status?: string; sealType?: string; legalEntityId?: string | null }, + ) { + this.assertAccess(user); + const row = await this.prisma.sealItem.findFirst({ where: { id } }); + if (!row) throw new NotFoundException('印章不存在'); + if (dto.sealType && !SEAL_TYPES.includes(dto.sealType as (typeof SEAL_TYPES)[number])) { + throw new BadRequestException('请选择正确的印章类型'); + } + if (dto.legalEntityId) { + const entity = await this.prisma.legalEntity.findFirst({ where: { id: dto.legalEntityId, tenantId: '1' } }); + if (!entity) throw new NotFoundException('参与主体不存在'); + } + return this.prisma.sealItem.update({ + where: { id }, + data: { + name: dto.name?.trim() || undefined, + keeper: dto.keeper?.trim() || undefined, + sealType: dto.sealType?.trim() || undefined, + legalEntityId: dto.legalEntityId === undefined ? undefined : dto.legalEntityId || null, + status: dto.status === 'OUT' || dto.status === 'IN_STOCK' ? dto.status : undefined, + }, + }); + } + + async deleteRegistryItem(id: string, user: AuthUser) { + this.assertAccess(user); + const row = await this.prisma.sealItem.findFirst({ where: { id } }); + if (!row) throw new NotFoundException('印章不存在'); + await this.prisma.sealItem.delete({ where: { id } }); + return { ok: true }; + } + + async decideApproval(approvalId: string, user: AuthUser, dto: ReviewInput) { + const task = await this.prisma.approvalTask.findFirst({ where: { id: approvalId, tenantId: '1' } }); + if (!task) throw new NotFoundException('审批任务不存在'); + if (task.assigneeId !== user.id) throw new ForbiddenException('这不是你的审批'); + if (task.status !== 'PENDING') throw new BadRequestException('该审批已办理'); + if (task.bizType !== SEAL_BIZ.APPROVAL || !task.bizId) throw new BadRequestException('不是用章审批'); + return this.reviewSeal(task.bizId, user, dto); + } + + async applyContractApproved(tx: Tx, contractId: string) { + const seals = await tx.sealRequest.findMany({ + where: { contractId, status: SEAL_STATUS.PENDING }, + }); + for (const row of seals) { + const next = sealStatusAfterApprove(row.takeOut); + await tx.sealRequest.update({ + where: { id: row.id }, + data: { status: next, decidedAt: new Date() }, + }); + if (row.takeOut) await this.createReturnTodo(tx, row); + } + } + + async overviewExtras(user?: AuthUser) { + const canBid = !user || canAccessBidModule(user.roles, user.permissions); + const canSeal = !user || canAccessSeal(user.roles, user.permissions); + const [quals, borrows, seals, opens] = await Promise.all([ + canSeal ? this.prisma.qualification.findMany({ where: { tenantId: '1' } }) : Promise.resolve([]), + canSeal + ? this.prisma.credentialBorrow.findMany({ + where: { tenantId: '1', status: BORROW_STATUS.BORROWED }, + }) + : Promise.resolve([]), + canSeal + ? this.prisma.sealRequest.findMany({ + where: { tenantId: '1', status: { in: [SEAL_STATUS.OUT, SEAL_STATUS.APPROVED] } }, + }) + : Promise.resolve([]), + canBid + ? this.prisma.bidCase.findMany({ + where: { + tenantId: '1', + openAt: { not: null, gte: new Date(), lte: new Date(Date.now() + 14 * 86400000) }, + status: { notIn: ['TERMINATED', 'WON', 'LOST', 'FAILED'] }, + }, + select: { + id: true, + bidNo: true, + externalNo: true, + name: true, + openAt: true, + openPlace: true, + assignees: { + where: { role: 'BIDDER' }, + select: { user: { select: { displayName: true, employee: { select: { name: true } } } } }, + }, + }, + orderBy: { openAt: 'asc' }, + take: 20, + }) + : Promise.resolve([]), + ]); + const qualWarn = quals + .map((q) => ({ ...q, bucket: qualBucket(q.expiresAt) })) + .filter((q) => q.bucket === 'expired' || q.bucket === 'expiring'); + const borrowOverdue = borrows.filter((b) => borrowBucket(b) === 'overdue'); + const sealOverdue = seals.filter((s) => sealBucket(s) === 'overdue'); + return { + expiringQualifications: qualWarn.length, + overdueBorrows: borrowOverdue.length, + overdueSeals: sealOverdue.length, + upcomingOpens: opens.length, + warnings: { + qualifications: qualWarn.slice(0, 8), + borrows: borrowOverdue.slice(0, 8), + seals: sealOverdue.slice(0, 8), + opens: opens.map((o) => ({ + id: o.id, + bidNo: o.bidNo, + externalNo: o.externalNo, + name: o.name, + openAt: o.openAt, + openPlace: o.openPlace, + bidders: o.assignees + .map((a) => a.user.employee?.name || a.user.displayName) + .filter(Boolean) + .join('、'), + })), + }, + }; + } + + async calendar(user: AuthUser) { + const [bids, tasks, todos, borrows, seals] = await Promise.all([ + this.prisma.bidCase.findMany({ + where: { + tenantId: '1', + openAt: { not: null }, + status: { notIn: ['TERMINATED', 'WON', 'LOST', 'FAILED'] }, + assignees: { some: { userId: user.id } }, + }, + select: { id: true, name: true, bidNo: true, externalNo: true, openAt: true, openPlace: true }, + }), + this.prisma.projectTask.findMany({ + where: { assigneeId: user.id, dueAt: { not: null }, status: { not: 'DONE' } }, + include: { project: { select: { id: true, projectNo: true, name: true } } }, + }), + this.prisma.todoTask.findMany({ + where: { assigneeId: user.id, status: 'OPEN', dueAt: { not: null } }, + }), + canAccessSeal(user.roles, user.permissions) + ? this.prisma.credentialBorrow.findMany({ + where: { tenantId: '1', status: BORROW_STATUS.BORROWED, dueAt: { not: null } }, + }) + : Promise.resolve([]), + canAccessSeal(user.roles, user.permissions) + ? this.prisma.sealRequest.findMany({ + where: { + tenantId: '1', + status: { in: [SEAL_STATUS.OUT, SEAL_STATUS.APPROVED] }, + takeOut: true, + returnedAt: null, + returnAt: { not: null }, + }, + }) + : Promise.resolve([]), + ]); + const items = [ + ...bids.map((b) => ({ + id: `bid-${b.id}`, + kind: 'BID_OPEN', + title: `开标 ${b.name}`, + start: b.openAt, + place: b.openPlace, + ref: b.bidNo, + overdue: isOverdue(b.openAt), + path: `/bid/cases/${b.id}`, + })), + ...tasks.map((t) => ({ + id: `task-${t.id}`, + kind: 'TASK', + title: `任务截止 ${t.name}`, + start: t.dueAt, + place: t.project.projectNo, + ref: t.project.projectNo, + overdue: isOverdue(t.dueAt), + path: `/project/${t.project.id}`, + })), + ...todos.map((t) => ({ + id: `todo-${t.id}`, + kind: 'TODO', + title: t.title, + start: t.dueAt, + place: null as string | null, + ref: null as string | null, + overdue: isOverdue(t.dueAt), + path: null as string | null, + })), + ...borrows.map((b) => ({ + id: `borrow-${b.id}`, + kind: 'BORROW', + title: `归还证照 ${b.itemName}`, + start: b.dueAt, + place: b.borrower, + ref: b.itemName, + overdue: borrowBucket(b) === 'overdue', + path: '/seal/borrows', + })), + ...seals + .filter((s) => sealBucket(s) === 'out' || sealBucket(s) === 'overdue') + .map((s) => ({ + id: `seal-${s.id}`, + kind: 'SEAL', + title: `归还印章 ${s.sealType}`, + start: s.returnAt, + place: s.reason, + ref: s.sealType, + overdue: sealBucket(s) === 'overdue', + path: '/seal/requests', + })), + ]; + items.sort((a, b) => new Date(a.start || 0).getTime() - new Date(b.start || 0).getTime()); + return items; + } + + private async getBorrow(id: string) { + const row = await this.prisma.credentialBorrow.findFirst({ + where: { id }, + include: { + borrowerUser: { select: USER_SELECT }, + qualification: { select: QUAL_SELECT }, + bidCase: { select: BID_SELECT }, + }, + }); + if (!row) throw new NotFoundException('借用记录不存在'); + return { ...row, bucket: borrowBucket(row) }; + } + + async getRequest(id: string, user: AuthUser) { + return this.getSeal(id, user); + } + + private async getSeal(id: string, user: AuthUser) { + const row = await this.prisma.sealRequest.findFirst({ + where: { id }, + include: { + applicant: { select: USER_SELECT }, + decidedBy: { select: USER_SELECT }, + bidCase: { select: BID_SELECT }, + contract: { select: CONTRACT_SELECT }, + }, + }); + if (!row) throw new NotFoundException('用章申请不存在'); + const bucket = sealBucket(row); + return { + ...row, + bucket, + myActions: { + canReview: canApproveSeal(user.roles) && row.status === SEAL_STATUS.PENDING, + canReturn: (bucket === 'out' || bucket === 'overdue') && !row.returnedAt, + }, + }; + } + + private async openApprovals(tx: Tx, sealId: string, reason: string, approverId: string) { + const approver = await tx.user.findFirst({ + where: { id: approverId, tenantId: '1', status: 'ACTIVE' }, + include: { roles: { include: { role: true } } }, + }); + if (!approver || !canApproveSeal(approver.roles.map((r) => r.role.code))) { + throw new BadRequestException('没有可审批用章的负责人'); + } + const ids = [approver.id]; + await tx.approvalTask.createMany({ + data: ids.map((assigneeId) => ({ + tenantId: '1', + title: `用章审批:${reason}`, + bizType: SEAL_BIZ.APPROVAL, + bizId: sealId, + assigneeId, + })), + }); + emitSms('SEAL_REQUEST', { userIds: ids, params: { content: `用章待审批:${reason}` } }); + } + + /** 潘兴辉、戴永康是用章台账的全公司查看账号;其他账号继续遵循数据范围。 */ + private canSeeAllCompanySeals(user: AuthUser) { + const name = String(user.displayName || '').trim(); + const account = String(user.username || '').trim().toLowerCase(); + return user.roles.includes('admin') || name === '潘兴辉' || name === '戴永康' || account === 'panxinghui'; + } + + private async closeApprovals(tx: Tx, bizId: string, result: string, actorId: string) { + await tx.approvalTask.updateMany({ + where: { bizType: SEAL_BIZ.APPROVAL, bizId, assigneeId: actorId, status: 'PENDING' }, + data: { status: result }, + }); + await tx.approvalTask.updateMany({ + where: { bizType: SEAL_BIZ.APPROVAL, bizId, status: 'PENDING' }, + data: { status: 'CANCELLED' }, + }); + } + + private async createReturnTodo( + tx: Tx, + row: { id: string; sealType: string; applicantId: string | null; returnAt: Date | null }, + ) { + if (!row.applicantId) return; + await tx.todoTask.create({ + data: { + tenantId: '1', + title: `归还印章:${row.sealType}`, + bizType: SEAL_BIZ.RETURN, + bizId: row.id, + assigneeId: row.applicantId, + dueAt: row.returnAt, + }, + }); + } +} diff --git a/apps/api/src/sms/sms-bus.ts b/apps/api/src/sms/sms-bus.ts new file mode 100644 index 0000000..4507fa4 --- /dev/null +++ b/apps/api/src/sms/sms-bus.ts @@ -0,0 +1,64 @@ +import { emitIm } from '../im/im-bus'; + +type SmsPayload = { + userIds?: string[]; + phones?: string[]; + params?: Record; + actorId?: string; + bizType?: string; + bizId?: string; +}; + +type SmsHandler = (eventKey: string, payload: SmsPayload) => Promise; + +let handler: SmsHandler | null = null; + +const IM_KIND: Record = { + BID_TECH_INITIAL: 'approval', + BID_REVIEW: 'approval', + BID_TECH_FINAL: 'approval', + OFFICE_APPROVAL: 'approval', + HR_LEAVE: 'approval', + EXPENSE: 'approval', + SEAL_REQUEST: 'approval', + BID_FETCH: 'todo', + BID_ASSIGN_FETCH: 'todo', + BID_DRAFT: 'todo', + BID_ASSIGN_DRAFT: 'todo', + BID_PRINT: 'todo', + BID_EXECUTION: 'todo', + BID_FINAL_PASSED: 'todo', + BID_DRAFT_DONE: 'todo', + BID_DONE: 'todo', + BID_REJECT: 'todo', + BID_TERMINATED: 'todo', + WORK_REPORT: 'todo', + NOTICE: 'todo', +}; + +export function registerSmsHandler(fn: SmsHandler) { + handler = fn; +} + +/** 业务步骤触发短信。未配置或步骤关闭时静默跳过,不打断主流程。 */ +export function emitSms(eventKey: string, payload: SmsPayload) { + if (handler) { + void handler(eventKey, payload).catch(() => undefined); + } + const kind = IM_KIND[eventKey]; + if (!kind || !payload.userIds?.length) return; + const title = + payload.params?.content || + payload.params?.task_name || + payload.params?.project_name || + eventKey; + emitIm({ + userIds: payload.userIds, + kind, + title, + body: title, + fromUserId: payload.actorId, + bizType: payload.bizType || eventKey, + bizId: payload.bizId, + }); +} diff --git a/apps/api/src/sms/sms-events.ts b/apps/api/src/sms/sms-events.ts new file mode 100644 index 0000000..206e603 --- /dev/null +++ b/apps/api/src/sms/sms-events.ts @@ -0,0 +1,238 @@ +export type SmsEventGroup = '登录' | '投标' | '个人办公' | '信息发布'; + +export type SmsEventSpec = { + key: string; + group: SmsEventGroup; + name: string; + when: string; + to: string; + defaultTemplate: string; + defaultEnabled: boolean; + /** 业务侧会传入的字段;真正发出去的 TemplateParam 以阿里云模板变量为准。 */ + sample: Record; +}; + +/** 步骤 → 短信。在「短信服务 · 步骤关联」里开关和换模板。模板变量必须和阿里云审核通过的内容一致。 */ +export const SMS_EVENTS: SmsEventSpec[] = [ + { + key: 'LOGIN_CODE', + group: '登录', + name: '登录验证码', + when: '登录页点获取验证码', + to: '登录账号绑定的手机', + defaultTemplate: 'SMS_510935060', + defaultEnabled: true, + sample: { code: '123456' }, + }, + { + key: 'BID_TECH_INITIAL', + group: '投标', + name: '技术初审待办', + when: '新建项目筛选,发给初审会签人', + to: '初审人', + defaultTemplate: 'SMS_508595151', + defaultEnabled: true, + sample: { name: '张三', apply_no: 'BID-00001', project_name: '某某项目' }, + }, + { + key: 'BID_FETCH', + group: '投标', + name: '取标待办', + when: '初审通过或派取标人', + to: '取标人', + defaultTemplate: 'SMS_508380165', + defaultEnabled: true, + sample: { name: '张三', apply_no: 'BID-00001', project_name: '某某项目' }, + }, + { + key: 'BID_ASSIGN_FETCH', + group: '投标', + name: '派取标人待办', + when: '初审通过,通知商务总监指定取标人', + to: '商务总监或特殊筛选协调人', + defaultTemplate: 'SMS_508485178', + defaultEnabled: true, + sample: { name: '张三', apply_no: 'BID-00001', project_name: '某某项目' }, + }, + { + key: 'BID_TECH_FINAL', + group: '投标', + name: '技术终审待办', + when: '取标完成,进入终审', + to: '终审人', + defaultTemplate: 'SMS_508400144', + defaultEnabled: true, + sample: { name: '张三', apply_no: 'BID-00001', project_name: '某某项目' }, + }, + { + key: 'BID_DRAFT', + group: '投标', + name: '制标待办', + when: '终审通过或派制标人', + to: '制标人', + defaultTemplate: 'SMS_508520168', + defaultEnabled: true, + sample: { name: '张三', apply_no: 'BID-00001', project_name: '某某项目' }, + }, + { + key: 'BID_ASSIGN_DRAFT', + group: '投标', + name: '派制标人待办', + when: '技术终审通过,通知负责人指定制标人', + to: '商务总监或特殊筛选协调人', + defaultTemplate: 'SMS_508485178', + defaultEnabled: true, + sample: { name: '张三', apply_no: 'BID-00001', project_name: '某某项目' }, + }, + { + key: 'BID_REVIEW', + group: '投标', + name: '商务审待办', + when: '提交标书进入一审,或一审通过进入二审', + to: '商务审批人', + defaultTemplate: 'SMS_508485178', + defaultEnabled: true, + sample: { name: '张三', apply_no: 'BID-00001', project_name: '某某项目' }, + }, + { + key: 'BID_ASSIGN', + group: '投标', + name: '分配投标人', + when: '印封后派出投标人', + to: '投标人', + defaultTemplate: 'SMS_511275271', + defaultEnabled: true, + sample: { + name: '谢晓晨', + date: '2026-08-13 09:00', + location: '市公共资源交易中心', + project_name: '某某智慧校园', + contact_person: '张三', + contact_phone: '13800138000', + }, + }, + { + key: 'BID_DONE', + group: '投标', + name: '开标结果通知', + when: '开标登记结果', + to: '申请人', + defaultTemplate: 'SMS_512165104', + defaultEnabled: true, + sample: { name: '张三', apply_no: 'BID-00001', project_name: '某某项目' }, + }, + { + key: 'BID_REJECT', + group: '投标', + name: '投标驳回', + when: '初审/终审/商务审驳回', + to: '申请人', + defaultTemplate: 'SMS_508695169', + defaultEnabled: true, + sample: { name: '张三', apply_no: 'BID-00001', project_name: '某某项目', reason: '资料不全' }, + }, + { + key: 'BID_TERMINATED', + group: '投标', + name: '投标终止通知', + when: '项目筛选任一环节终止投标', + to: '潘兴辉', + defaultTemplate: 'SMS_512165104', + defaultEnabled: true, + sample: { name: '潘兴辉', content: '某某项目终止,终止原因:资料不全' }, + }, + { + key: 'BID_PRINT', + group: '投标', + name: '印封待办', + when: '商务二审通过,进入印封', + to: '印封办理人', + defaultTemplate: 'SMS_508485178', + defaultEnabled: true, + sample: { name: '张三', apply_no: 'BID-00001', project_name: '某某项目' }, + }, + { + key: 'BID_EXECUTION', + group: '投标', + name: '投标执行待办', + when: '印封完成,待分配投标人', + to: '投标执行办理人', + defaultTemplate: 'SMS_508485178', + defaultEnabled: true, + sample: { name: '张三', apply_no: 'BID-00001', project_name: '某某项目' }, + }, + { + key: 'OFFICE_APPROVAL', + group: '个人办公', + name: '待我审批', + when: '提交人事/用章/办公申请、报销', + to: '审批人', + defaultTemplate: 'SMS_512165104', + defaultEnabled: true, + sample: { name: '张三', content: '请明日上午9点参加项目例会' }, + }, + { + key: 'WORK_ASSIGN', + group: '个人办公', + name: '工作安排', + when: '负责人给下属派工作', + to: '执行人', + defaultTemplate: 'SMS_508875184', + defaultEnabled: true, + sample: { name: '李四', assigner: '王经理', task_name: '提交周报', task_code: 'WA0001' }, + }, + { + key: 'WORK_REPORT', + group: '个人办公', + name: '工作汇报', + when: '提交日报/周报/月报', + to: '汇报对象和抄送人', + defaultTemplate: 'SMS_512165104', + defaultEnabled: true, + sample: { name: '王经理', content: '张三提交了本周周报' }, + }, + { + key: 'HR_LEAVE', + group: '个人办公', + name: '人事申请', + when: '提交请假、出差、加班等', + to: '审批人', + defaultTemplate: 'SMS_512165104', + defaultEnabled: true, + sample: { content: '有一条人事申请待审批' }, + }, + { + key: 'SEAL_REQUEST', + group: '个人办公', + name: '用章申请', + when: '提交用章', + to: '审批人', + defaultTemplate: 'SMS_512165104', + defaultEnabled: true, + sample: { content: '有一条用章申请待审批' }, + }, + { + key: 'EXPENSE', + group: '个人办公', + name: '费用确认', + when: '提交报销或借款', + to: '财务确认人', + defaultTemplate: 'SMS_512165104', + defaultEnabled: true, + sample: { content: '有一条报销待确认' }, + }, + { + key: 'NOTICE', + group: '信息发布', + name: '公告/制度发布', + when: '发布通知公告或规章制度', + to: '全体在职有手机号的同事', + defaultTemplate: 'SMS_512165104', + defaultEnabled: false, + sample: { content: '新公告:春节放假安排' }, + }, +]; + +export function smsEventOf(key: string) { + return SMS_EVENTS.find((e) => e.key === key); +} diff --git a/apps/api/src/sms/sms-fill.spec.ts b/apps/api/src/sms/sms-fill.spec.ts new file mode 100644 index 0000000..f0b1cf5 --- /dev/null +++ b/apps/api/src/sms/sms-fill.spec.ts @@ -0,0 +1,37 @@ +import { clipByAttr, fillTemplateParams, parseTemplateVars } from './sms-fill'; + +describe('sms-fill', () => { + it('clips others to 35 chars', () => { + expect(clipByAttr('甲'.repeat(40), 'others')).toHaveLength(35); + }); + + it('fills 初审待办 vars from bid payload and recipient name', () => { + const vars = parseTemplateVars( + '{"name":"user_nick","apply_no":"other_number2","project_name":"others"}', + '尊敬的${name}您好,项目筛选待您初审,单号${apply_no},项目名称${project_name}', + ); + const out = fillTemplateParams( + vars, + { apply_no: 'BID-2026-00001', project_name: '某市展厅数字化升级' }, + '吴软件', + ); + expect(out).toEqual({ + name: '吴软件', + apply_no: 'BID-2026-00001', + project_name: '某市展厅数字化升级', + }); + }); + + it('maps content onto 办公工作通知 ${content}', () => { + const vars = parseTemplateVars('{"content":"others"}', '办公通知:${content}'); + const out = fillTemplateParams(vars, { content: '请明日上午9点参加项目例会' }, '张三'); + expect(out).toEqual({ content: '请明日上午9点参加项目例会' }); + }); + + it('maps content onto templates that only have ${others}', () => { + const vars = parseTemplateVars('{"name":"user_nick","others":"others"}', '${name}您好,${others}'); + const out = fillTemplateParams(vars, { content: '请尽快办理技术初审' }, '戴永康'); + expect(out.name).toBe('戴永康'); + expect(out.others).toBe('请尽快办理技术初审'); + }); +}); diff --git a/apps/api/src/sms/sms-fill.ts b/apps/api/src/sms/sms-fill.ts new file mode 100644 index 0000000..ae3f89e --- /dev/null +++ b/apps/api/src/sms/sms-fill.ts @@ -0,0 +1,89 @@ +/** 按阿里云 TemplateRule / variableAttribute 截断,避免变量超长被拒。 */ +export const ATTR_LIMIT: Record = { + numberCaptcha: 6, + user_nick: 20, + name: 12, + others: 35, + other_number2: 35, + time: 24, + address: 50, + phone: 11, +}; + +const FALLBACK: Record = { + content: ['content', 'project_name', 'task_name', 'others'], + project_name: ['project_name', 'content', 'task_name', 'others'], + others: ['others', 'content', 'project_name', 'task_name'], + other_number2: ['other_number2', 'content', 'others'], + apply_no: ['apply_no', 'task_code'], + task_name: ['task_name', 'content', 'project_name'], + task_code: ['task_code', 'apply_no'], + reason: ['reason'], + assigner: ['assigner'], + date: ['date'], + location: ['location'], + contact_person: ['contact_person'], + contact_phone: ['contact_phone', 'phone'], + code: ['code'], + name: ['name'], +}; + +export function clipByAttr(value: string, attr: string) { + const s = String(value || '') + .replace(/\s+/g, ' ') + .trim(); + const n = ATTR_LIMIT[attr] || 35; + return s.length <= n ? s : s.slice(0, n); +} + +export function parseTemplateVars(variableAttribute?: string, templateContent?: string) { + const vars: Record = {}; + if (variableAttribute?.trim()) { + try { + const parsed = JSON.parse(variableAttribute) as Record; + for (const [k, v] of Object.entries(parsed)) { + if (k && v) vars[k] = v; + } + } catch { + /* 忽略坏 JSON,下面用正文里的 ${var} 兜底 */ + } + } + for (const m of String(templateContent || '').matchAll(/\$\{([a-zA-Z0-9_]+)\}/g)) { + if (!vars[m[1]]) vars[m[1]] = m[1] === 'code' ? 'numberCaptcha' : 'others'; + } + return vars; +} + +/** 只填模板里声明的变量;${name} 用收件人姓名。 */ +export function fillTemplateParams( + vars: Record, + payload: Record = {}, + recipientName?: string, +) { + if (!Object.keys(vars).length) { + const out = { ...payload }; + if (recipientName && !out.name) out.name = clipByAttr(recipientName, 'user_nick'); + return out; + } + const out: Record = {}; + for (const [key, attr] of Object.entries(vars)) { + let v = ''; + if (key === 'name') { + v = payload.name || recipientName || '同事'; + } else { + for (const src of FALLBACK[key] || [key]) { + if (payload[src]?.trim()) { + v = payload[src]; + break; + } + } + } + if (!v) v = attr === 'numberCaptcha' ? payload.code || '' : '无'; + out[key] = clipByAttr(v, attr) || (attr === 'numberCaptcha' ? v : '无'); + } + return out; +} + +export function extractVarNames(templateContent: string) { + return [...new Set([...String(templateContent || '').matchAll(/\$\{([a-zA-Z0-9_]+)\}/g)].map((m) => m[1]))]; +} diff --git a/apps/api/src/sms/sms.controller.ts b/apps/api/src/sms/sms.controller.ts new file mode 100644 index 0000000..5c3a0ba --- /dev/null +++ b/apps/api/src/sms/sms.controller.ts @@ -0,0 +1,168 @@ +import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { Public } from '../common/decorators/auth.decorators'; +import { SystemService } from '../system/system.service'; +import { SmsService } from './sms.service'; + +@ApiTags('sms') +@ApiBearerAuth() +@Controller('sms') +export class SmsController { + constructor( + private readonly sms: SmsService, + private readonly system: SystemService, + ) {} + + @Get('login-options') + @Public() + loginOptions() { + return this.sms.loginOptions(); + } + + @Get('events') + events(@CurrentUser() user: AuthUser) { + this.system.assertManage(user); + return this.sms.listEvents(); + } + + @Post('events') + saveEvent( + @CurrentUser() user: AuthUser, + @Body() body: { eventKey: string; enabled?: boolean; templateCode?: string; signName?: string }, + ) { + this.system.assertManage(user); + return this.sms.saveEvent(body); + } + + @Get('config') + config(@CurrentUser() user: AuthUser) { + this.system.assertManage(user); + return this.sms.config(); + } + + @Get('office-options') + officeOptions(@CurrentUser() user: AuthUser) { + return this.sms.officeOptions(user); + } + + @Get('signs') + signs(@CurrentUser() user: AuthUser, @Query('pageIndex') pageIndex?: string, @Query('pageSize') pageSize?: string) { + this.system.assertManage(user); + return this.sms.querySigns(Number(pageIndex) || 1, Number(pageSize) || 50); + } + + @Get('signs/detail') + signDetail(@CurrentUser() user: AuthUser, @Query('signName') signName: string) { + this.system.assertManage(user); + return this.sms.getSign(signName); + } + + @Post('signs') + createSign( + @CurrentUser() user: AuthUser, + @Body() + body: { + signName: string; + signSource: number; + qualificationId?: string; + remark?: string; + signType?: number; + }, + ) { + this.system.assertManage(user); + return this.sms.createSign(body); + } + + @Get('templates') + templates(@CurrentUser() user: AuthUser, @Query('pageIndex') pageIndex?: string, @Query('pageSize') pageSize?: string) { + this.system.assertManage(user); + return this.sms.queryTemplates(Number(pageIndex) || 1, Number(pageSize) || 50); + } + + @Get('templates/detail') + templateDetail(@CurrentUser() user: AuthUser, @Query('templateCode') templateCode: string) { + this.system.assertManage(user); + return this.sms.getTemplate(templateCode); + } + + @Post('templates') + createTemplate( + @CurrentUser() user: AuthUser, + @Body() + body: { + templateName: string; + templateContent: string; + templateType: number; + relatedSignName?: string; + remark?: string; + templateRule?: string | Record; + applySceneContent?: string; + }, + ) { + this.system.assertManage(user); + return this.sms.createTemplate(body); + } + + @Post('templates/update') + updateTemplate( + @CurrentUser() user: AuthUser, + @Body() + body: { + templateCode: string; + templateName: string; + templateContent: string; + templateType: number; + relatedSignName?: string; + remark?: string; + templateRule?: string | Record; + applySceneContent?: string; + }, + ) { + this.system.assertManage(user); + return this.sms.updateTemplate(body); + } + + @Post('send') + send( + @CurrentUser() user: AuthUser, + @Body() + body: { + phoneNumbers?: string; + extraPhones?: string; + departmentIds?: string[]; + userIds?: string[]; + signName?: string; + templateCode?: string; + templateParam?: string | Record; + content?: string; + }, + ) { + return this.sms.send(user, body); + } + + @Post('send-batch') + sendBatch( + @CurrentUser() user: AuthUser, + @Body() + body: { + templateCode: string; + items: { phone: string; signName?: string; templateParam?: Record }[]; + }, + ) { + return this.sms.sendBatch(user, body); + } + + @Get('qualifications') + qualifications(@CurrentUser() user: AuthUser) { + this.system.assertManage(user); + return this.sms.qualifications(); + } + + @Get('logs') + logs(@CurrentUser() user: AuthUser) { + this.system.assertManage(user); + return this.sms.logs(); + } +} diff --git a/apps/api/src/sms/sms.module.ts b/apps/api/src/sms/sms.module.ts new file mode 100644 index 0000000..b0fb0db --- /dev/null +++ b/apps/api/src/sms/sms.module.ts @@ -0,0 +1,12 @@ +import { Global, Module } from '@nestjs/common'; +import { SmsController } from './sms.controller'; +import { SmsService } from './sms.service'; +import { SystemService } from '../system/system.service'; + +@Global() +@Module({ + controllers: [SmsController], + providers: [SmsService, SystemService], + exports: [SmsService], +}) +export class SmsModule {} diff --git a/apps/api/src/sms/sms.service.ts b/apps/api/src/sms/sms.service.ts new file mode 100644 index 0000000..b72c555 --- /dev/null +++ b/apps/api/src/sms/sms.service.ts @@ -0,0 +1,806 @@ +import { BadRequestException, ForbiddenException, Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import Dysmsapi20170525, * as $Dysmsapi from '@alicloud/dysmsapi20170525'; +import { $OpenApiUtil } from '@alicloud/openapi-core'; +import * as $dara from '@darabonba/typescript'; +import { createHash, randomInt } from 'crypto'; +import { PrismaService } from '../prisma/prisma.service'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { canSendSms } from '../common/role-groups'; +import { SystemService } from '../system/system.service'; +import { registerSmsHandler } from './sms-bus'; +import { SMS_EVENTS, smsEventOf } from './sms-events'; +import { fillTemplateParams, parseTemplateVars, ATTR_LIMIT } from './sms-fill'; + +const PRESET_TEMPLATES = [ + { code: 'SMS_512165104', name: '办公工作通知' }, + { code: 'SMS_510935060', name: '登录验证码' }, + { code: 'SMS_511100061', name: '重置密码' }, + { code: 'SMS_511275271', name: '日程通知' }, + { code: 'SMS_508595151', name: '初审待办' }, + { code: 'SMS_508380165', name: '取标待办' }, + { code: 'SMS_508400144', name: '终审待办' }, + { code: 'SMS_508520168', name: '制标待办' }, + { code: 'SMS_508485178', name: '环节催办' }, + { code: 'SMS_508735170', name: '终审通过通知' }, + { code: 'SMS_508625169', name: '制标完成通知' }, + { code: 'SMS_508695169', name: '驳回通知' }, + { code: 'SMS_508875184', name: '任务通知' }, +]; + +const DEAD_TEMPLATE = 'SMS_508545096'; +const OFFICE_TEMPLATE = 'SMS_512165104'; +// Only login and bidding workflow notices are system-triggered. All office +// notices (announcements, reports, reimbursements, HR, seals, assignments) +// must be deliberately sent from the manual "发送工作通知短信" page. +const AUTOMATED_EVENT_KEYS = new Set([ + 'LOGIN_CODE', + 'BID_TECH_INITIAL', 'BID_FETCH', 'BID_ASSIGN_FETCH', 'BID_TECH_FINAL', + 'BID_DRAFT', 'BID_ASSIGN_DRAFT', + 'BID_REVIEW', 'BID_ASSIGN', 'BID_DONE', 'BID_REJECT', 'BID_TERMINATED', + 'BID_PRINT', 'BID_EXECUTION', +]); +const TEMPLATE_ALIAS: Record = { + SMS_508545096: OFFICE_TEMPLATE, + SMS_512185104: OFFICE_TEMPLATE, +}; + +function normalizeTemplate(code?: string) { + const raw = (code || OFFICE_TEMPLATE).trim(); + return TEMPLATE_ALIAS[raw] || raw || OFFICE_TEMPLATE; +} + +type AliyunErr = { data?: { Message?: string; Code?: string }; message?: string }; + +function maskAccessKey(id: string) { + if (id.length <= 10) return '********'; + return `${id.slice(0, 6)}********${id.slice(-4)}`; +} + +@Injectable() +export class SmsService implements OnModuleInit { + private readonly log = new Logger(SmsService.name); + private cached?: Dysmsapi20170525; + private cachedKey?: string; + private tplCache = new Map; content: string }>(); + + constructor( + private readonly prisma: PrismaService, + private readonly system: SystemService, + ) {} + + async onModuleInit() { + registerSmsHandler((eventKey, payload) => this.notifyEvent(eventKey, payload)); + try { + await this.prisma.smsEventBind.updateMany({ + where: { templateCode: { in: Object.keys(TEMPLATE_ALIAS) } }, + data: { templateCode: OFFICE_TEMPLATE }, + }); + await this.prisma.smsEventBind.updateMany({ + where: { eventKey: { notIn: [...AUTOMATED_EVENT_KEYS] } }, + data: { enabled: false }, + }); + } catch (e) { + this.log.warn(`sms bind migrate: ${(e as Error).message}`); + } + } + + configured() { + return Boolean(process.env.ALIYUN_SMS_ACCESS_KEY_ID && process.env.ALIYUN_SMS_ACCESS_KEY_SECRET); + } + + config() { + const accessKeyId = process.env.ALIYUN_SMS_ACCESS_KEY_ID || ''; + const configured = Boolean(accessKeyId && process.env.ALIYUN_SMS_ACCESS_KEY_SECRET); + return { + configured, + region: process.env.ALIYUN_SMS_REGION || 'cn-hangzhou', + endpoint: process.env.ALIYUN_SMS_ENDPOINT || 'dysmsapi.aliyuncs.com', + defaultSign: process.env.ALIYUN_SMS_DEFAULT_SIGN || '江苏风影随行科技', + ramLogin: process.env.ALIYUN_RAM_LOGIN || '', + accessKeyIdMasked: accessKeyId ? maskAccessKey(accessKeyId) : '', + secretConfigured: Boolean(process.env.ALIYUN_SMS_ACCESS_KEY_SECRET), + securityPhone: process.env.ALIYUN_RAM_SECURITY_PHONE || '', + securityEmail: process.env.ALIYUN_RAM_SECURITY_EMAIL || '', + presets: PRESET_TEMPLATES, + note: configured + ? '已接入阿里云短信(Dysmsapi)。申请签名须先有审核通过的资质;发送须用已通过的签名和模板。控制台登录密码不写入本系统,发信用 AccessKey。' + : '未配置 AccessKey。把 ALIYUN_SMS_ACCESS_KEY_ID / SECRET 写进环境变量后即可调用。', + }; + } + + private client() { + const accessKeyId = process.env.ALIYUN_SMS_ACCESS_KEY_ID; + const accessKeySecret = process.env.ALIYUN_SMS_ACCESS_KEY_SECRET; + if (!accessKeyId || !accessKeySecret) { + throw new BadRequestException('未配置阿里云短信 AccessKey,无法调用'); + } + const cacheKey = `${accessKeyId}:${accessKeySecret}`; + if (!this.cached || this.cachedKey !== cacheKey) { + const cfg = new $OpenApiUtil.Config({ accessKeyId, accessKeySecret }); + cfg.endpoint = process.env.ALIYUN_SMS_ENDPOINT || 'dysmsapi.aliyuncs.com'; + this.cached = new Dysmsapi20170525(cfg); + this.cachedKey = cacheKey; + } + return this.cached; + } + + private wrap(e: unknown): never { + const err = e as AliyunErr; + const msg = err.data?.Message || err.message || '阿里云短信调用失败'; + const code = err.data?.Code; + this.log.warn(`aliyun sms: ${code || ''} ${msg}`); + throw new BadRequestException(code ? `${code}: ${msg}` : msg); + } + + private body(resp: { body?: T } | undefined): T { + if (!resp?.body) throw new BadRequestException('阿里云未返回结果'); + return resp.body; + } + + async querySigns(pageIndex = 1, pageSize = 50) { + try { + const req = new $Dysmsapi.QuerySmsSignListRequest({ pageIndex, pageSize }); + const resp = await this.client().querySmsSignListWithOptions(req, new $dara.RuntimeOptions({})); + return this.body(resp); + } catch (e) { + this.wrap(e); + } + } + + async getSign(signName: string) { + if (!signName?.trim()) throw new BadRequestException('请填写签名名称'); + try { + const req = new $Dysmsapi.GetSmsSignRequest({ signName: signName.trim() }); + const resp = await this.client().getSmsSignWithOptions(req, new $dara.RuntimeOptions({})); + return this.body(resp); + } catch (e) { + this.wrap(e); + } + } + + async createSign(dto: { + signName: string; + signSource: number; + qualificationId?: string; + remark?: string; + signType?: number; + }) { + if (!dto.signName?.trim()) throw new BadRequestException('请填写签名名称'); + if (dto.signSource === undefined || dto.signSource === null) { + throw new BadRequestException('请选择签名来源'); + } + try { + const req = new $Dysmsapi.CreateSmsSignRequest({ + signName: dto.signName.trim(), + signSource: Number(dto.signSource), + qualificationId: dto.qualificationId ? Number(dto.qualificationId) : undefined, + remark: dto.remark?.trim() || undefined, + signType: dto.signType === undefined ? 1 : Number(dto.signType), + }); + const resp = await this.client().createSmsSignWithOptions(req, new $dara.RuntimeOptions({})); + return this.body(resp); + } catch (e) { + this.wrap(e); + } + } + + async queryTemplates(pageIndex = 1, pageSize = 50) { + try { + const req = new $Dysmsapi.QuerySmsTemplateListRequest({ pageIndex, pageSize }); + const resp = await this.client().querySmsTemplateListWithOptions(req, new $dara.RuntimeOptions({})); + return this.body(resp); + } catch (e) { + this.wrap(e); + } + } + + async getTemplate(templateCode: string) { + if (!templateCode?.trim()) throw new BadRequestException('请填写模板 Code'); + try { + const req = new $Dysmsapi.GetSmsTemplateRequest({ templateCode: templateCode.trim() }); + const resp = await this.client().getSmsTemplateWithOptions(req, new $dara.RuntimeOptions({})); + return this.body(resp); + } catch (e) { + this.wrap(e); + } + } + + async createTemplate(dto: { + templateName: string; + templateContent: string; + templateType: number; + relatedSignName?: string; + remark?: string; + templateRule?: string | Record; + applySceneContent?: string; + }) { + if (!dto.templateName?.trim() || !dto.templateContent?.trim()) { + throw new BadRequestException('请填写模板名称和内容'); + } + if (dto.templateType === undefined || dto.templateType === null) { + throw new BadRequestException('请选择模板类型'); + } + const content = dto.templateContent.trim(); + const vars = [...content.matchAll(/\$\{([a-zA-Z0-9_]+)\}/g)].map((m) => m[1]); + let templateRule = + typeof dto.templateRule === 'string' + ? dto.templateRule.trim() + : dto.templateRule + ? JSON.stringify(dto.templateRule) + : ''; + if (vars.length && !templateRule) { + throw new BadRequestException('模板含变量时必须填写 TemplateRule(变量属性),例如 {"content":"others"}'); + } + if (templateRule) { + try { + JSON.parse(templateRule); + } catch { + throw new BadRequestException('TemplateRule 必须是 JSON,例如 {"content":"others"}'); + } + } + try { + const req = new $Dysmsapi.CreateSmsTemplateRequest({ + templateName: dto.templateName.trim(), + templateContent: content, + templateType: Number(dto.templateType), + relatedSignName: dto.relatedSignName?.trim() || this.config().defaultSign, + remark: dto.remark?.trim() || undefined, + templateRule: templateRule || undefined, + applySceneContent: dto.applySceneContent?.trim() || 'https://oa.fysxkj.com', + }); + const resp = await this.client().createSmsTemplateWithOptions(req, new $dara.RuntimeOptions({})); + return this.body(resp); + } catch (e) { + this.wrap(e); + } + } + + async updateTemplate(dto: { + templateCode: string; + templateName: string; + templateContent: string; + templateType: number; + relatedSignName?: string; + remark?: string; + templateRule?: string | Record; + applySceneContent?: string; + }) { + if (!dto.templateCode?.trim()) throw new BadRequestException('请填写模板 Code'); + if (!dto.templateName?.trim() || !dto.templateContent?.trim()) { + throw new BadRequestException('请填写模板名称和内容'); + } + const templateRule = + typeof dto.templateRule === 'string' + ? dto.templateRule.trim() + : dto.templateRule + ? JSON.stringify(dto.templateRule) + : undefined; + try { + const req = new $Dysmsapi.UpdateSmsTemplateRequest({ + templateCode: dto.templateCode.trim(), + templateName: dto.templateName.trim(), + templateContent: dto.templateContent.trim(), + templateType: Number(dto.templateType), + relatedSignName: dto.relatedSignName?.trim() || this.config().defaultSign, + remark: dto.remark?.trim() || undefined, + templateRule, + applySceneContent: dto.applySceneContent?.trim() || 'https://oa.fysxkj.com', + }); + const resp = await this.client().updateSmsTemplateWithOptions(req, new $dara.RuntimeOptions({})); + this.tplCache.delete(dto.templateCode.trim()); + return this.body(resp); + } catch (e) { + this.wrap(e); + } + } + + async officeOptions(user: AuthUser) { + if (!canSendSms(user.roles, user.permissions)) throw new ForbiddenException('无权发送短信'); + const cfg = this.config(); + let templateContent = '办公通知:${content}'; + let contentLimit = ATTR_LIMIT.others || 35; + try { + const meta = await this.templateMeta(OFFICE_TEMPLATE); + if (meta.content) templateContent = meta.content; + const limits = Object.entries(meta.vars) + .filter(([key, attr]) => key !== 'name' && attr !== 'user_nick') + .map(([, attr]) => ATTR_LIMIT[attr] || 35); + if (limits.length) contentLimit = Math.min(...limits); + } catch { + /* 通道未配时仍给预览文案 */ + } + const previewHint = `【${cfg.defaultSign}】${templateContent + .replace(/\$\{name\}/g, '同事') + .replace(/\$\{content\}/g, '……') + .replace(/\$\{others\}/g, '……') + .replace(/\$\{project_name\}/g, '……') + .replace(/\$\{[^}]+\}/g, '……')}`; + return { + configured: cfg.configured, + defaultSign: cfg.defaultSign, + presets: PRESET_TEMPLATES.filter((p) => p.code !== DEAD_TEMPLATE), + officeTemplate: OFFICE_TEMPLATE, + templateContent, + contentLimit, + previewHint, + note: cfg.configured + ? '一次只发一种:部门群发、点名同事,或系统外手机号。对方收到的是运营商模板,你只填中间那句通知。' + : cfg.note, + }; + } + + async send( + user: AuthUser, + dto: { + phoneNumbers?: string; + extraPhones?: string; + departmentIds?: string[]; + userIds?: string[]; + signName?: string; + templateCode?: string; + templateParam?: string | Record; + content?: string; + }, + ) { + if (!canSendSms(user.roles, user.permissions)) throw new ForbiddenException('无权发送短信'); + const people = await this.collectRecipients(dto); + if (!people.length) throw new BadRequestException('请选择部门、人员,或填写手机号'); + const templateCode = normalizeTemplate(dto.templateCode || OFFICE_TEMPLATE); + const signName = dto.signName?.trim() || this.config().defaultSign; + const rawParam = + typeof dto.templateParam === 'string' + ? (() => { + try { + return JSON.parse(dto.templateParam) as Record; + } catch { + return {} as Record; + } + })() + : dto.templateParam || {}; + const payload: Record = { ...rawParam }; + if (dto.content?.trim()) { + payload.content = dto.content.trim(); + if (!payload.others) payload.others = dto.content.trim(); + if (!payload.project_name) payload.project_name = dto.content.trim(); + } + const meta = await this.templateMeta(templateCode); + const items = people.map((p) => ({ + phone: p.phone, + signName, + templateParam: fillTemplateParams(meta.vars, payload, p.name), + })); + try { + const body = (await this.dispatchSms(templateCode, items)) as { + bizId?: string; + code?: string; + Code?: string; + message?: string; + Message?: string; + }; + const code = body.code || body.Code || 'OK'; + const message = body.message || body.Message || ''; + await this.prisma.smsSendLog.create({ + data: { + phones: items.map((i) => i.phone).join(','), + signName, + templateCode, + templateParam: JSON.stringify(items.map((i) => i.templateParam)), + eventKey: 'MANUAL', + bizId: body.bizId, + code, + message, + createdById: user.id, + }, + }); + if (code !== 'OK') { + throw new BadRequestException(`${code}: ${message || '请检查模板内容和模板参数是否匹配'}`); + } + return { ...body, sent: items.length }; + } catch (e) { + if (e instanceof BadRequestException) throw e; + this.wrap(e); + } + } + + async sendBatch( + user: AuthUser, + dto: { + templateCode: string; + items: { phone: string; signName?: string; templateParam?: Record }[]; + }, + ) { + if (!canSendSms(user.roles, user.permissions)) throw new ForbiddenException('无权发送短信'); + const items = (dto.items || []).filter((i) => i.phone?.trim()); + if (!items.length) throw new BadRequestException('请至少填写一个手机号'); + const defaultSign = this.config().defaultSign; + try { + const req = new $Dysmsapi.SendBatchSmsRequest({ + phoneNumberJson: JSON.stringify(items.map((i) => i.phone.trim())), + signNameJson: JSON.stringify(items.map((i) => i.signName?.trim() || defaultSign)), + templateCode: dto.templateCode.trim(), + templateParamJson: JSON.stringify(items.map((i) => i.templateParam || {})), + }); + const resp = await this.client().sendBatchSmsWithOptions(req, new $dara.RuntimeOptions({})); + const body = this.body(resp); + await this.prisma.smsSendLog.create({ + data: { + phones: items.map((i) => i.phone).join(','), + signName: items.map((i) => i.signName || defaultSign).join(','), + templateCode: dto.templateCode.trim(), + templateParam: JSON.stringify(items.map((i) => i.templateParam || {})), + bizId: (body as { bizId?: string }).bizId, + code: (body as { code?: string }).code || 'OK', + message: (body as { message?: string }).message, + createdById: user.id, + }, + }); + return body; + } catch (e) { + this.wrap(e); + } + } + + async qualifications() { + try { + const req = new $Dysmsapi.QuerySmsQualificationRecordRequest({ pageNo: 1, pageSize: 50, state: 'PASS' }); + const resp = await this.client().querySmsQualificationRecordWithOptions(req, new $dara.RuntimeOptions({})); + return this.body(resp); + } catch (e) { + this.wrap(e); + } + } + + async logs() { + return this.prisma.smsSendLog.findMany({ + orderBy: { createdAt: 'desc' }, + take: 100, + }); + } + + async loginOptions() { + const bind = await this.prisma.smsEventBind.findUnique({ where: { eventKey: 'LOGIN_CODE' } }); + const enabled = bind?.enabled ?? true; + return { smsLogin: this.configured() && enabled }; + } + + async listEvents() { + const binds = await this.prisma.smsEventBind.findMany(); + const map = new Map(binds.map((b) => [b.eventKey, b])); + return { + configured: this.configured(), + defaultSign: this.config().defaultSign, + presets: PRESET_TEMPLATES, + items: SMS_EVENTS.map((spec) => { + const bind = map.get(spec.key); + return { + ...spec, + automatic: AUTOMATED_EVENT_KEYS.has(spec.key), + enabled: AUTOMATED_EVENT_KEYS.has(spec.key) && (bind?.enabled ?? spec.defaultEnabled), + templateCode: bind?.templateCode || spec.defaultTemplate, + signName: bind?.signName || this.config().defaultSign, + vars: Object.keys(spec.sample).join('、'), + }; + }), + }; + } + + async saveEvent(dto: { eventKey: string; enabled?: boolean; templateCode?: string; signName?: string }) { + const spec = smsEventOf(dto.eventKey); + if (!spec) throw new BadRequestException('未知短信步骤'); + const automatic = AUTOMATED_EVENT_KEYS.has(spec.key); + return this.prisma.smsEventBind.upsert({ + where: { eventKey: spec.key }, + create: { + eventKey: spec.key, + enabled: automatic && (dto.enabled ?? spec.defaultEnabled), + templateCode: normalizeTemplate(dto.templateCode?.trim() || spec.defaultTemplate), + signName: dto.signName?.trim() || null, + }, + update: { + enabled: automatic ? dto.enabled : false, + templateCode: dto.templateCode === undefined ? undefined : normalizeTemplate(dto.templateCode.trim()), + signName: dto.signName === undefined ? undefined : dto.signName.trim() || null, + }, + }); + } + + async notifyEvent( + eventKey: string, + payload: { userIds?: string[]; phones?: string[]; params?: Record; actorId?: string }, + ) { + if (!this.configured() || !AUTOMATED_EVENT_KEYS.has(eventKey)) return; + const spec = smsEventOf(eventKey); + if (!spec) return; + const bind = await this.prisma.smsEventBind.findUnique({ where: { eventKey } }); + const enabled = bind?.enabled ?? spec.defaultEnabled; + if (!enabled) return; + const templateCode = normalizeTemplate(bind?.templateCode || spec.defaultTemplate); + const signName = bind?.signName?.trim() || this.config().defaultSign; + let people = await this.recipientsOf(payload.userIds, payload.phones); + if (eventKey === 'NOTICE' && !people.length) { + people = await this.allStaffRecipients(); + } + if (!people.length) return; + const meta = await this.templateMeta(templateCode); + const items = people.map((p) => ({ + phone: p.phone, + signName, + templateParam: fillTemplateParams(meta.vars, payload.params || spec.sample, p.name), + })); + try { + const body = await this.dispatchSms(templateCode, items); + await this.prisma.smsSendLog.create({ + data: { + phones: items.map((i) => i.phone).join(','), + signName, + templateCode, + templateParam: JSON.stringify(items.map((i) => i.templateParam)), + eventKey, + bizId: (body as { bizId?: string }).bizId, + code: (body as { code?: string }).code || 'OK', + message: (body as { message?: string }).message, + createdById: payload.actorId || null, + }, + }); + } catch (e) { + const err = e as { data?: { Message?: string }; message?: string }; + this.log.warn(`sms ${eventKey}: ${err.data?.Message || err.message}`); + await this.prisma.smsSendLog.create({ + data: { + phones: items.map((i) => i.phone).join(','), + signName, + templateCode, + templateParam: JSON.stringify(items.map((i) => i.templateParam)), + eventKey, + code: 'FAIL', + message: err.data?.Message || err.message || '发送失败', + createdById: payload.actorId || null, + }, + }); + if (eventKey === 'LOGIN_CODE') { + throw new BadRequestException(err.data?.Message || err.message || '验证码发送失败'); + } + } + } + + async sendLoginCode(account: string) { + const raw = account.trim(); + if (!raw) throw new BadRequestException('请先填写手机号'); + if (!this.configured()) return { ok: true }; + const opt = await this.loginOptions(); + if (!opt.smsLogin) throw new BadRequestException('未开启登录短信验证码'); + const phone = await this.resolveLoginPhone(raw); + if (!phone) throw new BadRequestException('该账号未绑定手机,无法发送验证码'); + const recent = await this.prisma.smsOtp.findFirst({ + where: { phone, purpose: 'LOGIN', createdAt: { gt: new Date(Date.now() - 60_000) } }, + orderBy: { createdAt: 'desc' }, + }); + if (recent) throw new BadRequestException('验证码已发送,请稍后再试'); + const code = String(randomInt(100000, 999999)); + await this.prisma.smsOtp.create({ + data: { + phone, + codeHash: createHash('sha256').update(code).digest('hex'), + purpose: 'LOGIN', + expiresAt: new Date(Date.now() + 5 * 60_000), + }, + }); + await this.notifyEvent('LOGIN_CODE', { phones: [phone], params: { code } }); + return { ok: true }; + } + + async assertLoginCode(account: string, code?: string) { + const options = await this.loginOptions(); + if (!options.smsLogin) return; + const phone = await this.resolveLoginPhone(account); + if (!phone) throw new BadRequestException('该账号未绑定手机,无法短信登录'); + if (!code?.trim()) throw new BadRequestException('请填写短信验证码'); + const hash = createHash('sha256').update(code.trim()).digest('hex'); + const row = await this.prisma.smsOtp.findFirst({ + where: { phone, purpose: 'LOGIN', usedAt: null, expiresAt: { gt: new Date() } }, + orderBy: { createdAt: 'desc' }, + }); + if (!row || row.codeHash !== hash) throw new BadRequestException('验证码不正确或已过期'); + await this.prisma.smsOtp.update({ where: { id: row.id }, data: { usedAt: new Date() } }); + } + + private async resolveLoginPhone(account: string) { + const raw = account.trim(); + if (!raw) return null; + const user = await this.prisma.user.findFirst({ + where: { tenantId: '1', OR: [{ username: raw }, { employee: { is: { mobile: raw } } }] }, + include: { employee: { select: { mobile: true } } }, + }); + if (!user || user.status !== 'ACTIVE') return null; + const mobile = user.employee?.mobile?.trim() || (/^1\d{10}$/.test(user.username) ? user.username : ''); + return /^1\d{10}$/.test(mobile) ? mobile : null; + } + + private async templateMeta(templateCode: string) { + const hit = this.tplCache.get(templateCode); + if (hit && Date.now() - hit.at < 10 * 60_000) return hit; + try { + const body = (await this.getTemplate(templateCode)) as { + variableAttribute?: string; + VariableAttribute?: string; + templateContent?: string; + TemplateContent?: string; + templateRule?: string; + TemplateRule?: string; + }; + const templateContent = body.templateContent || body.TemplateContent || ''; + const variableAttribute = + body.variableAttribute || body.VariableAttribute || body.templateRule || body.TemplateRule; + const vars = parseTemplateVars(variableAttribute, templateContent); + const row = { at: Date.now(), vars, content: templateContent }; + this.tplCache.set(templateCode, row); + return row; + } catch (e) { + const err = e as { message?: string }; + this.log.warn(`sms template ${templateCode}: ${err.message}`); + const spec = SMS_EVENTS.find((s) => s.defaultTemplate === templateCode); + const vars = spec ? parseTemplateVars(undefined, Object.keys(spec.sample).map((k) => `\${${k}}`).join('')) : {}; + const row = { at: Date.now(), vars, content: '' }; + this.tplCache.set(templateCode, row); + return row; + } + } + + private async dispatchSms( + templateCode: string, + items: { phone: string; signName: string; templateParam: Record }[], + ) { + const same = + items.length === 1 || + items.every((i) => JSON.stringify(i.templateParam) === JSON.stringify(items[0].templateParam) && i.signName === items[0].signName); + if (same) { + const req = new $Dysmsapi.SendSmsRequest({ + phoneNumbers: items.map((i) => i.phone).join(','), + signName: items[0].signName, + templateCode, + templateParam: JSON.stringify(items[0].templateParam), + }); + const resp = await this.client().sendSmsWithOptions(req, new $dara.RuntimeOptions({})); + return this.body(resp); + } + let last: unknown; + for (let i = 0; i < items.length; i += 100) { + const chunk = items.slice(i, i + 100); + const req = new $Dysmsapi.SendBatchSmsRequest({ + phoneNumberJson: JSON.stringify(chunk.map((c) => c.phone)), + signNameJson: JSON.stringify(chunk.map((c) => c.signName)), + templateCode, + templateParamJson: JSON.stringify(chunk.map((c) => c.templateParam)), + }); + const resp = await this.client().sendBatchSmsWithOptions(req, new $dara.RuntimeOptions({})); + last = this.body(resp); + } + return last; + } + + private phoneOf(user: { username: string; employee?: { mobile?: string | null } | null }) { + const m = user.employee?.mobile?.trim() || ''; + if (/^1\d{10}$/.test(m)) return m; + return /^1\d{10}$/.test(user.username) ? user.username : ''; + } + + private parsePhones(raw?: string) { + return (raw || '') + .split(/[,,||;\s]+/) + .map((s) => s.replace(/\D/g, '')) + .filter((s) => /^1\d{10}$/.test(s)); + } + + private async expandDeptIds(ids: string[]) { + if (!ids.length) return []; + const all = await this.prisma.department.findMany({ + where: { tenantId: '1' }, + select: { id: true, parentId: true }, + }); + const children = new Map(); + for (const d of all) { + if (!d.parentId) continue; + const list = children.get(d.parentId) || []; + list.push(d.id); + children.set(d.parentId, list); + } + const out = new Set(ids); + const walk = (id: string) => { + for (const c of children.get(id) || []) { + if (!out.has(c)) { + out.add(c); + walk(c); + } + } + }; + ids.forEach(walk); + return [...out]; + } + + private async collectPhones(dto: { + phoneNumbers?: string; + extraPhones?: string; + departmentIds?: string[]; + userIds?: string[]; + }) { + return (await this.collectRecipients(dto)).map((p) => p.phone); + } + + private async collectRecipients(dto: { + phoneNumbers?: string; + extraPhones?: string; + departmentIds?: string[]; + userIds?: string[]; + }) { + const rows: { phone: string; name: string }[] = []; + const seen = new Set(); + const add = (phone: string, name?: string) => { + if (!/^1\d{10}$/.test(phone) || seen.has(phone)) return; + seen.add(phone); + rows.push({ phone, name: name?.trim() || '同事' }); + }; + for (const p of this.parsePhones(dto.phoneNumbers)) add(p); + for (const p of this.parsePhones(dto.extraPhones)) add(p); + const userIds = [...(dto.userIds || [])]; + if (dto.departmentIds?.length) { + const deptIds = await this.expandDeptIds(dto.departmentIds); + const users = await this.prisma.user.findMany({ + where: { tenantId: '1', status: 'ACTIVE', employee: { departmentId: { in: deptIds } } }, + include: { employee: { select: { mobile: true, name: true, employmentStatus: true } } }, + }); + for (const u of users) { + if (u.employee?.employmentStatus === 'LEFT') continue; + add(this.phoneOf(u), u.displayName || u.employee?.name); + } + } + if (userIds.length) { + const users = await this.prisma.user.findMany({ + where: { id: { in: userIds } }, + include: { employee: { select: { mobile: true, name: true } } }, + }); + for (const u of users) add(this.phoneOf(u), u.displayName || u.employee?.name); + } + return rows; + } + + private async recipientsOf(userIds?: string[], phones?: string[]) { + const rows: { phone: string; name: string }[] = []; + const seen = new Set(); + if (userIds?.length) { + const users = await this.prisma.user.findMany({ + where: { id: { in: userIds } }, + include: { employee: { select: { mobile: true, name: true } } }, + }); + for (const u of users) { + const phone = this.phoneOf(u); + if (!phone || seen.has(phone)) continue; + seen.add(phone); + rows.push({ phone, name: u.displayName || u.employee?.name || '同事' }); + } + } + for (const raw of phones || []) { + const phone = raw.replace(/\s/g, ''); + if (!/^1\d{10}$/.test(phone) || seen.has(phone)) continue; + seen.add(phone); + rows.push({ phone, name: '同事' }); + } + return rows; + } + + private async allStaffRecipients() { + const users = await this.prisma.user.findMany({ + where: { tenantId: '1', status: 'ACTIVE' }, + include: { employee: { select: { mobile: true, name: true, employmentStatus: true } } }, + }); + const rows: { phone: string; name: string }[] = []; + const seen = new Set(); + for (const u of users) { + if (u.employee?.employmentStatus === 'LEFT') continue; + const phone = this.phoneOf(u); + if (!phone || seen.has(phone)) continue; + seen.add(phone); + rows.push({ phone, name: u.displayName || u.employee?.name || '同事' }); + } + return rows; + } +} diff --git a/apps/api/src/system/numbering.ts b/apps/api/src/system/numbering.ts new file mode 100644 index 0000000..1d8de5d --- /dev/null +++ b/apps/api/src/system/numbering.ts @@ -0,0 +1,31 @@ +import { Prisma } from '@prisma/client'; + +type Db = { + numberRule: { + findFirst: (args: { where: { tenantId: string; code: string } }) => Promise<{ prefix: string; padLength: number } | null>; + }; +}; + +export async function nextBizNo( + db: Db, + code: 'BID' | 'HT' | 'PRJ' | 'PAY', + countStartingWith: (start: string) => Promise, +) { + const rule = await db.numberRule.findFirst({ where: { tenantId: '1', code } }); + const prefix = rule?.prefix || code; + const pad = rule?.padLength || 5; + const year = new Date().getFullYear(); + const start = `${prefix}-${year}-`; + const count = await countStartingWith(start); + return `${start}${String(count + 1).padStart(pad, '0')}`; +} + +export function exampleNo(prefix: string, padLength = 5, year = new Date().getFullYear()) { + return `${prefix}-${year}-${String(1).padStart(padLength, '0')}`; +} + +export function patternOf(prefix: string, padLength = 5) { + return `${prefix}-YYYY-${'#'.repeat(padLength)}`; +} + +export type Tx = Prisma.TransactionClient; diff --git a/apps/api/src/system/op-log.ts b/apps/api/src/system/op-log.ts new file mode 100644 index 0000000..485f00b --- /dev/null +++ b/apps/api/src/system/op-log.ts @@ -0,0 +1,41 @@ +import type { PrismaService } from '../prisma/prisma.service'; + +export const LOG_ACTION = { + LOGIN: 'LOGIN', + LOGIN_FAIL: 'LOGIN_FAIL', + APPROVAL: 'APPROVAL', + FORBIDDEN: 'FORBIDDEN', + ACL: 'ACL', +} as const; + +export async function writeOpLog( + prisma: PrismaService, + entry: { + userId?: string | null; + username?: string | null; + action: string; + path: string; + detail?: string | null; + success?: boolean; + requestId?: string | null; + ip?: string | null; + }, +) { + try { + await prisma.operationLog.create({ + data: { + tenantId: '1', + userId: entry.userId || null, + username: entry.username || null, + action: entry.action, + path: entry.path.slice(0, 240), + detail: entry.detail?.slice(0, 500) || null, + success: entry.success !== false, + requestId: entry.requestId || null, + ip: entry.ip || null, + }, + }); + } catch { + /* logging must not break the request */ + } +} diff --git a/apps/api/src/system/ops-workflow.spec.ts b/apps/api/src/system/ops-workflow.spec.ts new file mode 100644 index 0000000..4dfbc1f --- /dev/null +++ b/apps/api/src/system/ops-workflow.spec.ts @@ -0,0 +1,59 @@ +import { + canAccessReport, + canApprovePurchase, + canChangeProject, + canFilePurchase, + canManageSystem, + canPay, + FUNNEL_STAGES, + periodRange, + resourceLoad, +} from './ops-workflow'; +import { reportFlowKind, canWriteWorkReport } from '../office/work-report'; + +describe('ops-workflow', () => { + it('lets directors and finance see reports, only owner/admin manage ACL', () => { + expect(canAccessReport(['tech_director'], [])).toBe(true); + expect(canAccessReport(['employee'], ['office'])).toBe(false); + expect(canAccessReport(['hr'], ['hr'])).toBe(false); + expect(canManageSystem(['admin'])).toBe(true); + expect(canManageSystem(['hr'])).toBe(false); + expect(canPay(['finance'])).toBe(true); + expect(canFilePurchase(['tech_director'])).toBe(true); + expect(canApprovePurchase(['tech_director'])).toBe(false); + expect(canChangeProject(['biz_director'])).toBe(true); + }); + + it('builds the screening-to-award funnel and resource load buckets', () => { + expect(FUNNEL_STAGES.map((s) => s.id)).toEqual([ + 'screening', + 'drafting', + 'execution', + 'won', + 'lost', + 'terminated', + ]); + expect(resourceLoad(0, 0)).toBe('idle'); + expect(resourceLoad(1, 20)).toBe('full'); + expect(resourceLoad(4, 10)).toBe('over'); + const day = periodRange('DAY', new Date('2026-08-29')); + expect(day.period).toBe('2026-08-29'); + }); + + it('routes staff reports to PM with director CC, and PM reports to director', () => { + expect(reportFlowKind({ authorRoles: ['employee'], hasSubordinates: false, title: '工程师' })).toBe( + 'STAFF_TO_PM', + ); + expect(reportFlowKind({ authorRoles: ['employee'], hasSubordinates: true, title: '项目经理' })).toBe( + 'PM_TO_DIRECTOR', + ); + expect(reportFlowKind({ authorRoles: ['tech_director'], hasSubordinates: true, title: '软件总监' })).toBe( + 'DIRECTOR_TO_OWNER', + ); + expect(canWriteWorkReport(['rd_director'])).toBe(false); + expect(canWriteWorkReport(['biz_director'])).toBe(false); + expect(canWriteWorkReport(['owner'])).toBe(false); + expect(canWriteWorkReport(['employee'])).toBe(true); + expect(canWriteWorkReport(['pm'])).toBe(true); + }); +}); diff --git a/apps/api/src/system/ops-workflow.ts b/apps/api/src/system/ops-workflow.ts new file mode 100644 index 0000000..039ca46 --- /dev/null +++ b/apps/api/src/system/ops-workflow.ts @@ -0,0 +1,109 @@ +import { BID_STATUS, STATUS_BUCKET } from '../bidding/bid-workflow'; +import { isTechLead } from '../common/role-groups'; + +export const DATA_SCOPE = { + ALL: 'ALL', + DEPT: 'DEPT', + SELF: 'SELF', +} as const; + +export const PAY_KIND = { + EXPENSE: 'EXPENSE', + BOND: 'BOND', + PURCHASE: 'PURCHASE', +} as const; + +export const PAY_STATUS = { + TODO: 'TODO', + PAID: 'PAID', +} as const; + +export const PURCHASE_STATUS = { + DRAFT: 'DRAFT', + PENDING: 'PENDING', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED', +} as const; + +export const CHANGE_STATUS = { + PENDING: 'PENDING', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED', +} as const; + +export const ASSET_STATUS = { + IN_USE: 'IN_USE', + RETURNED: 'RETURNED', +} as const; + +export const SEAL_STOCK = { + IN_STOCK: 'IN_STOCK', + OUT: 'OUT', +} as const; + +export const FUNNEL_STAGES = [ + { id: 'screening', label: '筛选', statuses: STATUS_BUCKET.screening }, + { id: 'drafting', label: '制标', statuses: STATUS_BUCKET.drafting }, + { id: 'execution', label: '投标执行', statuses: STATUS_BUCKET.execution }, + { id: 'won', label: '中标', statuses: [BID_STATUS.WON] }, + { id: 'lost', label: '未中 / 流废', statuses: [BID_STATUS.LOST, BID_STATUS.FAILED] }, + { id: 'terminated', label: '终止', statuses: [BID_STATUS.TERMINATED] }, +] as const; + +export function canAccessReport(roles: string[], permissions: string[]) { + if (['admin', 'owner', 'finance', 'biz_director'].some((r) => roles.includes(r)) || isTechLead(roles)) { + return true; + } + return permissions.some((p) => p === 'report' || p.startsWith('report:')); +} + +export function canManageSystem(roles: string[]) { + return ['admin', 'owner'].some((r) => roles.includes(r)); +} + +export function canPay(roles: string[]) { + return ['admin', 'owner', 'finance'].some((r) => roles.includes(r)); +} + +export function canFilePurchase(roles: string[]) { + return ['admin', 'owner', 'biz_director'].some((r) => roles.includes(r)) || isTechLead(roles); +} + +export function canApprovePurchase(roles: string[]) { + return ['admin', 'owner', 'finance'].some((r) => roles.includes(r)); +} + +export function canChangeProject(roles: string[]) { + return ['admin', 'owner', 'biz_director'].some((r) => roles.includes(r)) || isTechLead(roles); +} + +export function resourceLoad(openTasks: number, monthHours: number) { + if (openTasks >= 3 || monthHours >= 80) return 'over'; + if (openTasks >= 1 || monthHours >= 20) return 'full'; + return 'idle'; +} + +function ymd(d: Date) { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +export function periodRange(periodType: string, date = new Date()) { + const d = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + if (periodType === 'WEEK') { + const day = d.getDay() || 7; + const start = new Date(d); + start.setDate(d.getDate() - day + 1); + const end = new Date(start); + end.setDate(start.getDate() + 7); + return { start, end, period: `${ymd(start)} 周` }; + } + if (periodType === 'MONTH') { + const start = new Date(d.getFullYear(), d.getMonth(), 1); + const end = new Date(d.getFullYear(), d.getMonth() + 1, 1); + return { start, end, period: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` }; + } + const start = d; + const end = new Date(d); + end.setDate(d.getDate() + 1); + return { start, end, period: ymd(start) }; +} diff --git a/apps/api/src/system/system.controller.ts b/apps/api/src/system/system.controller.ts new file mode 100644 index 0000000..3516051 --- /dev/null +++ b/apps/api/src/system/system.controller.ts @@ -0,0 +1,242 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { PrismaService } from '../prisma/prisma.service'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { Public } from '../common/decorators/auth.decorators'; +import { PageQueryDto } from '../common/dto/page-query.dto'; +import { SystemService } from './system.service'; + +@ApiTags('system') +@ApiBearerAuth() +@Controller('system') +export class SystemController { + constructor( + private readonly prisma: PrismaService, + private readonly system: SystemService, + ) {} + + @Public() + @Get('app-release') + appRelease() { + return this.system.getAppRelease(); + } + + @Patch('app-release') + updateAppRelease( + @CurrentUser() user: AuthUser, + @Body() + body: { version?: string; build?: number; url?: string; changelog?: string; force?: boolean; windowsUrl?: string }, + ) { + return this.system.updateAppRelease(user, body); + } + + @Get('menus') + async menus(@CurrentUser() user: AuthUser) { + const all = await this.prisma.permission.findMany({ + where: { permType: 'MENU', visible: true }, + orderBy: { sortNo: 'asc' }, + }); + const allowed = user.roles.includes('admin') + ? all + : all.filter((p) => user.permissions.includes(p.code)); + const visible = includeAncestors(all, allowed); + return buildTree(visible); + } + + @Get('dicts') + dicts(@Query('type') type?: string) { + return this.prisma.dictItem.findMany({ + where: { tenantId: '1', ...(type ? { dictType: type } : {}) }, + orderBy: [{ dictType: 'asc' }, { sortNo: 'asc' }], + }); + } + + @Post('dicts') + createDict( + @CurrentUser() user: AuthUser, + @Body() body: { dictType: string; label: string; value: string; sortNo?: number }, + ) { + return this.system.createDict(user, body); + } + + @Patch('dicts/:id') + updateDict( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { dictType?: string; label?: string; value?: string; sortNo?: number }, + ) { + return this.system.updateDict(id, user, body); + } + + @Delete('dicts/:id') + deleteDict(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.system.deleteDict(id, user); + } + + @Get('logs') + logs(@CurrentUser() user: AuthUser, @Query() q: PageQueryDto, @Query('action') action?: string) { + this.system.assertManage(user); + return this.system.listLogs(q.page, q.pageSize, action); + } + + @Get('number-rules') + numberRules() { + return this.system.listNumberRules(); + } + + @Patch('number-rules/:id') + updateRule( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { prefix?: string; padLength?: number; name?: string }, + ) { + return this.system.updateNumberRule(id, user, body); + } + + @Get('users') + users(@CurrentUser() user: AuthUser) { + this.system.assertManage(user); + return this.system.listUsers(); + } + + @Post('users') + createUser( + @CurrentUser() user: AuthUser, + @Body() body: { username: string; password: string; displayName: string; roleIds: string[]; employeeId?: string }, + ) { + return this.system.createUser(user, body); + } + + @Post('users/batch') + createUsersBatch( + @CurrentUser() user: AuthUser, + @Body() + body: { + items?: { username: string; displayName?: string; password: string; roleIds?: string[]; roleCodes?: string[] }[]; + text?: string; + defaultPassword?: string; + defaultRoleCodes?: string[]; + }, + ) { + const items = + body.items?.length + ? body.items + : parseUserLines(body.text || '', body.defaultPassword, body.defaultRoleCodes); + return this.system.createUsersBatch(user, { items }); + } + + @Patch('users/:id') + updateUser( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { displayName?: string; status?: string; roleIds?: string[]; password?: string; employeeId?: string | null }, + ) { + return this.system.updateUser(id, user, body); + } + + @Get('roles') + roles(@CurrentUser() user: AuthUser) { + this.system.assertManage(user); + return this.system.listRoles(); + } + + @Patch('roles/:id') + updateRole( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { name?: string; description?: string; dataScope?: string; permissionCodes?: string[] }, + ) { + return this.system.updateRole(id, user, body); + } + + @Get('permissions') + permissions(@CurrentUser() user: AuthUser) { + this.system.assertManage(user); + return this.system.listPermissions(); + } + + @Get('message-channel') + async messageChannel() { + const port = Number(process.env.IM_TCP_PORT || 8901); + let online = 0; + try { + const httpPort = Number(process.env.IM_HTTP_PORT || 8902); + const res = await fetch(`http://127.0.0.1:${httpPort}/health`); + if (res.ok) { + const json = (await res.json()) as { online?: number }; + online = Number(json.online || 0); + } + } catch { + /* IM 未启动 */ + } + return [ + { + id: 'channel', + inboxEnabled: '已开启', + mobileImSdk: '已启用', + online, + note: `MobileIMSDK TCP ${port}:同事私聊和待办/审批推送已接通${online ? `,当前在线 ${online} 人` : ''}。`, + }, + ]; + } +} + +function parseUserLines(text: string, defaultPassword?: string, defaultRoleCodes?: string[]) { + return text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + .map((line) => { + const parts = line.split(/[,,\t]+/).map((s) => s.trim()); + const [username, displayName, password, roleCodes] = parts; + return { + username, + displayName: displayName || username, + password: password || defaultPassword || '', + roleCodes: roleCodes + ? roleCodes.split(/[|/;]+/).map((s) => s.trim()).filter(Boolean) + : defaultRoleCodes || [], + }; + }); +} + +function includeAncestors(all: T[], allowed: T[]) { + const byId = new Map(all.map((p) => [p.id, p])); + const visible = new Map(allowed.map((p) => [p.id, p])); + for (const item of allowed) { + let parentId = item.parentId; + while (parentId && !visible.has(parentId)) { + const parent = byId.get(parentId); + if (!parent) break; + visible.set(parent.id, parent); + parentId = parent.parentId; + } + } + return all.filter((p) => visible.has(p.id)); +} + +function buildTree( + items: { + id: string; + parentId: string | null; + name: string; + code: string; + path: string | null; + icon: string | null; + sortNo: number; + }[], +) { + const map = new Map>(); + items.forEach((i) => map.set(i.id, { ...i, children: [] as Record[] })); + const roots: Record[] = []; + items.forEach((i) => { + const node = map.get(i.id)!; + if (i.parentId && map.has(i.parentId)) { + (map.get(i.parentId)!.children as Record[]).push(node); + } else { + roots.push(node); + } + }); + return roots; +} diff --git a/apps/api/src/system/system.service.ts b/apps/api/src/system/system.service.ts new file mode 100644 index 0000000..b3df55a --- /dev/null +++ b/apps/api/src/system/system.service.ts @@ -0,0 +1,366 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import * as argon2 from 'argon2'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { PrismaService } from '../prisma/prisma.service'; +import type { AuthUser } from '../common/decorators/current-user.decorator'; +import { pageMeta } from '../common/dto/page-query.dto'; +import { DATA_SCOPE, canManageSystem } from './ops-workflow'; +import { exampleNo, patternOf } from './numbering'; +import { LOG_ACTION, writeOpLog } from './op-log'; + +@Injectable() +export class SystemService { + constructor(private readonly prisma: PrismaService) {} + + assertManage(user: AuthUser) { + if (!canManageSystem(user.roles)) throw new ForbiddenException('无权维护系统设置'); + } + + async listUsers() { + const rows = await this.prisma.user.findMany({ + where: { tenantId: '1' }, + include: { + employee: { include: { department: true } }, + roles: { include: { role: true } }, + }, + orderBy: { username: 'asc' }, + }); + return { + items: rows.map((u) => ({ + id: u.id, + username: u.username, + displayName: u.displayName, + status: u.status, + employeeId: u.employeeId, + employeeName: u.employee?.name, + departmentName: u.employee?.department?.name, + title: u.employee?.title || null, + roles: u.roles.map((r) => ({ id: r.role.id, code: r.role.code, name: r.role.name, dataScope: r.role.dataScope })), + })), + }; + } + + async updateUser( + id: string, + user: AuthUser, + dto: { displayName?: string; status?: string; roleIds?: string[]; password?: string; employeeId?: string | null }, + ) { + this.assertManage(user); + const row = await this.prisma.user.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('账号不存在'); + if (row.username === 'admin' && dto.status && dto.status !== 'ACTIVE') { + throw new BadRequestException('不能停用内置管理员'); + } + if (dto.roleIds) { + const roles = await this.prisma.role.findMany({ where: { id: { in: dto.roleIds } } }); + if (roles.length !== dto.roleIds.length) throw new BadRequestException('角色无效'); + await this.prisma.userRole.deleteMany({ where: { userId: id } }); + await this.prisma.userRole.createMany({ + data: dto.roleIds.map((roleId) => ({ userId: id, roleId })), + }); + } + const updated = await this.prisma.user.update({ + where: { id }, + data: { + displayName: dto.displayName?.trim() || undefined, + status: dto.status === 'DISABLED' ? 'DISABLED' : dto.status === 'ACTIVE' ? 'ACTIVE' : undefined, + employeeId: dto.employeeId === undefined ? undefined : dto.employeeId, + passwordHash: dto.password ? await argon2.hash(dto.password) : undefined, + }, + }); + await writeOpLog(this.prisma, { + userId: user.id, + username: user.username, + action: LOG_ACTION.ACL, + path: `/system/users/${id}`, + detail: `维护账号 ${updated.username}`, + }); + return { id: updated.id, username: updated.username, status: updated.status }; + } + + async createUser( + user: AuthUser, + dto: { username: string; password: string; displayName: string; roleIds: string[]; employeeId?: string }, + ) { + this.assertManage(user); + const phone = dto.username?.trim(); + if (!phone || !dto.password || dto.password.length < 6) { + throw new BadRequestException('请填写登录手机号和不少于 6 位的密码'); + } + if (!/^1\d{10}$/.test(phone) && phone !== 'admin') { + throw new BadRequestException('登录账号请填 11 位手机号'); + } + const exists = await this.prisma.user.findFirst({ where: { tenantId: '1', username: phone } }); + if (exists) throw new BadRequestException('该手机号已开通账号'); + const created = await this.prisma.user.create({ + data: { + username: phone, + passwordHash: await argon2.hash(dto.password), + displayName: dto.displayName?.trim() || phone, + employeeId: dto.employeeId || null, + roles: dto.roleIds?.length + ? { create: dto.roleIds.map((roleId) => ({ roleId })) } + : undefined, + }, + }); + await writeOpLog(this.prisma, { + userId: user.id, + username: user.username, + action: LOG_ACTION.ACL, + path: '/system/users', + detail: `新建账号 ${created.username}`, + }); + return { id: created.id, username: created.username, displayName: created.displayName, status: created.status }; + } + + async createUsersBatch( + user: AuthUser, + dto: { + items: { username: string; displayName?: string; password: string; roleIds?: string[]; roleCodes?: string[] }[]; + }, + ) { + this.assertManage(user); + const items = dto.items || []; + if (!items.length) throw new BadRequestException('请至少填写一行账号'); + if (items.length > 200) throw new BadRequestException('一次最多 200 个账号'); + const roles = await this.prisma.role.findMany(); + const byCode = new Map(roles.map((r) => [r.code, r])); + const byId = new Map(roles.map((r) => [r.id, r])); + const created: { username: string; displayName: string }[] = []; + const failed: { username: string; reason: string }[] = []; + for (const row of items) { + const username = row.username?.trim(); + try { + if (!username || !row.password || row.password.length < 6) { + throw new Error('用户名或密码不足 6 位'); + } + const roleIds = [ + ...(row.roleIds || []).filter((id) => byId.has(id)), + ...(row.roleCodes || []) + .map((c) => byCode.get(c.trim())?.id) + .filter((id): id is string => Boolean(id)), + ]; + const unique = [...new Set(roleIds)]; + const one = await this.createUser(user, { + username, + password: row.password, + displayName: row.displayName || username, + roleIds: unique, + }); + created.push({ username: one.username, displayName: one.displayName }); + } catch (e) { + failed.push({ username: username || '', reason: e instanceof Error ? e.message : '创建失败' }); + } + } + return { created, failed, createdCount: created.length, failedCount: failed.length }; + } + + async listRoles() { + const rows = await this.prisma.role.findMany({ + include: { permissions: { include: { permission: true } }, users: true }, + orderBy: { code: 'asc' }, + }); + return { + items: rows.map((r) => ({ + id: r.id, + name: r.name, + code: r.code, + description: r.description, + dataScope: r.dataScope, + userCount: r.users.length, + permissionCodes: r.permissions.map((p) => p.permission.code), + aclCustom: r.aclCustom, + })), + }; + } + + async updateRole( + id: string, + user: AuthUser, + dto: { name?: string; description?: string; dataScope?: string; permissionCodes?: string[] }, + ) { + this.assertManage(user); + const row = await this.prisma.role.findFirst({ where: { id } }); + if (!row) throw new NotFoundException('角色不存在'); + if (row.code === 'admin' && dto.permissionCodes) { + throw new BadRequestException('内置管理员角色请保持全部权限'); + } + if (dto.dataScope && !Object.values(DATA_SCOPE).includes(dto.dataScope as (typeof DATA_SCOPE)[keyof typeof DATA_SCOPE])) { + throw new BadRequestException('数据范围须是 ALL / DEPT / SELF'); + } + if (dto.permissionCodes) { + const perms = await this.prisma.permission.findMany({ + where: { code: { in: dto.permissionCodes }, permType: { in: ['MENU', 'ACTION'] } }, + }); + await this.prisma.rolePermission.deleteMany({ where: { roleId: id } }); + await this.prisma.rolePermission.createMany({ + data: perms.map((p) => ({ roleId: id, permissionId: p.id })), + }); + } + const updated = await this.prisma.role.update({ + where: { id }, + data: { + name: dto.name?.trim() || undefined, + description: dto.description, + dataScope: dto.dataScope, + aclCustom: dto.permissionCodes ? true : undefined, + }, + }); + await writeOpLog(this.prisma, { + userId: user.id, + username: user.username, + action: LOG_ACTION.ACL, + path: `/system/roles/${id}`, + detail: `维护角色 ${updated.code} 数据范围 ${updated.dataScope}`, + }); + return updated; + } + + async listPermissions() { + return this.prisma.permission.findMany({ + where: { permType: { in: ['MENU', 'ACTION'] }, visible: true }, + orderBy: { sortNo: 'asc' }, + }); + } + + async listNumberRules() { + const rows = await this.prisma.numberRule.findMany({ orderBy: { code: 'asc' } }); + return rows.map((r) => ({ + ...r, + pattern: patternOf(r.prefix, r.padLength), + example: exampleNo(r.prefix, r.padLength), + })); + } + + async updateNumberRule(id: string, user: AuthUser, dto: { prefix?: string; padLength?: number; name?: string }) { + this.assertManage(user); + const row = await this.prisma.numberRule.findFirst({ where: { id } }); + if (!row) throw new NotFoundException('编号规则不存在'); + if (dto.prefix && !/^[A-Z]{2,6}$/.test(dto.prefix)) throw new BadRequestException('前缀须是 2 到 6 位大写字母'); + if (dto.padLength && (dto.padLength < 3 || dto.padLength > 8)) throw new BadRequestException('流水位数须在 3 到 8'); + const updated = await this.prisma.numberRule.update({ + where: { id }, + data: { + prefix: dto.prefix || undefined, + padLength: dto.padLength, + name: dto.name?.trim() || undefined, + }, + }); + await writeOpLog(this.prisma, { + userId: user.id, + username: user.username, + action: LOG_ACTION.ACL, + path: `/system/number-rules/${id}`, + detail: `编号规则 ${updated.code} → ${updated.prefix}`, + }); + return { ...updated, pattern: patternOf(updated.prefix, updated.padLength), example: exampleNo(updated.prefix, updated.padLength) }; + } + + async listLogs(page: number, pageSize: number, action?: string) { + const where = { tenantId: '1', ...(action ? { action } : {}) }; + const [items, total] = await this.prisma.$transaction([ + this.prisma.operationLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.operationLog.count({ where }), + ]); + return { items, ...pageMeta(page, pageSize, total) }; + } + + async createDict(user: AuthUser, dto: { dictType: string; label: string; value: string; sortNo?: number }) { + this.assertManage(user); + if (!dto.dictType?.trim() || !dto.label?.trim() || !dto.value?.trim()) { + throw new BadRequestException('请填写字典类型、名称和值'); + } + return this.prisma.dictItem.create({ + data: { + tenantId: '1', + dictType: dto.dictType.trim(), + label: dto.label.trim(), + value: dto.value.trim(), + sortNo: dto.sortNo === undefined || dto.sortNo === null ? 99 : Number(dto.sortNo), + }, + }); + } + + async updateDict( + id: string, + user: AuthUser, + dto: { dictType?: string; label?: string; value?: string; sortNo?: number }, + ) { + this.assertManage(user); + const row = await this.prisma.dictItem.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('字典项不存在'); + return this.prisma.dictItem.update({ + where: { id }, + data: { + dictType: dto.dictType?.trim() || row.dictType, + label: dto.label?.trim() || row.label, + value: dto.value?.trim() || row.value, + sortNo: dto.sortNo === undefined || dto.sortNo === null ? row.sortNo : Number(dto.sortNo), + }, + }); + } + + async deleteDict(id: string, user: AuthUser) { + this.assertManage(user); + const row = await this.prisma.dictItem.findFirst({ where: { id, tenantId: '1' } }); + if (!row) throw new NotFoundException('字典项不存在'); + await this.prisma.dictItem.delete({ where: { id } }); + return { ok: true }; + } + + private appReleasePath() { + return process.env.APP_RELEASE_FILE || join(process.cwd(), 'data/app-release.json'); + } + + defaultAppRelease() { + return { + platform: 'android', + version: process.env.APP_ANDROID_VERSION || '1.1.0', + build: Number(process.env.APP_ANDROID_BUILD || 2), + url: process.env.APP_ANDROID_URL || 'https://oa.fysxkj.com/fengying-oa.apk', + windowsUrl: process.env.APP_WINDOWS_URL || '', + linuxUrl: process.env.APP_LINUX_URL || '', + linuxDebUrl: process.env.APP_LINUX_DEB_URL || '', + changelog: process.env.APP_ANDROID_CHANGELOG || '按微信/企业微信重做手机端:消息、工作台、通讯录、我;支持应用内升级。', + force: process.env.APP_ANDROID_FORCE === '1', + publishedAt: new Date().toISOString(), + }; + } + + getAppRelease() { + const file = this.appReleasePath(); + if (!existsSync(file)) return this.defaultAppRelease(); + try { + return { ...this.defaultAppRelease(), ...JSON.parse(readFileSync(file, 'utf8')) }; + } catch { + return this.defaultAppRelease(); + } + } + + updateAppRelease( + user: AuthUser, + dto: { version?: string; build?: number; url?: string; changelog?: string; force?: boolean; windowsUrl?: string; linuxUrl?: string; linuxDebUrl?: string }, + ) { + this.assertManage(user); + const next = { + ...this.getAppRelease(), + ...Object.fromEntries(Object.entries(dto).filter(([, v]) => v !== undefined)), + publishedAt: new Date().toISOString(), + }; + const file = this.appReleasePath(); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(next, null, 2)); + return next; + } +} diff --git a/apps/api/test/app.e2e-spec.ts b/apps/api/test/app.e2e-spec.ts new file mode 100644 index 0000000..ecf4ab8 --- /dev/null +++ b/apps/api/test/app.e2e-spec.ts @@ -0,0 +1,1450 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('API e2e', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + app = moduleRef.createNestApplication(); + app.setGlobalPrefix('api/v1'); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('liveness', async () => { + const res = await request(app.getHttpServer()).get('/api/v1/health/live'); + expect(res.status).toBe(200); + expect(res.body.code).toBe(0); + expect(res.body.data.status).toBe('ok'); + }); + + it('readiness', async () => { + const res = await request(app.getHttpServer()).get('/api/v1/health/ready'); + expect(res.status).toBe(200); + expect(res.body.data.checks.postgres).toBe('up'); + }); + + it('login and menu', async () => { + const login = await request(app.getHttpServer()) + .post('/api/v1/auth/login') + .send({ username: 'admin', password: 'Admin@123456', agreeTerms: true }); + expect(login.status).toBe(201); + expect(login.body.data.accessToken).toBeDefined(); + const token = login.body.data.accessToken; + const menus = await request(app.getHttpServer()) + .get('/api/v1/system/menus') + .set('Authorization', `Bearer ${token}`); + expect(menus.status).toBe(200); + expect(menus.body.data.length).toBeGreaterThan(5); + }); + + it('rejects bad password', async () => { + const res = await request(app.getHttpServer()) + .post('/api/v1/auth/login') + .send({ username: 'admin', password: 'wrong-password', agreeTerms: true }); + expect(res.status).toBe(401); + expect(res.body.code).toBe(401); + }); + + it('rejects login without agreeing to terms', async () => { + const res = await request(app.getHttpServer()) + .post('/api/v1/auth/login') + .send({ username: 'admin', password: 'Admin@123456' }); + expect(res.status).toBe(400); + expect(String(res.body.message || '')).toContain('用户协议'); + }); + + it('serves user agreement and privacy policy', async () => { + const agreement = await request(app.getHttpServer()).get('/api/v1/legal/user-agreement'); + expect(agreement.status).toBe(200); + expect(agreement.body.data.title).toBe('用户协议'); + expect(agreement.body.data.paragraphs.length).toBeGreaterThan(3); + const privacy = await request(app.getHttpServer()).get('/api/v1/legal/privacy'); + expect(privacy.status).toBe(200); + expect(privacy.body.data.title).toBe('隐私政策'); + }); + + async function login(username: string, password: string) { + const res = await request(app.getHttpServer()) + .post('/api/v1/auth/login') + .send({ username, password, agreeTerms: true }); + expect(res.status).toBe(201); + return res.body.data.accessToken as string; + } + + it('runs screening to final review, and hides terminated from employees', async () => { + const owner = await login('owner', 'Demo@123456'); + const directors = await request(app.getHttpServer()) + .get('/api/v1/directors') + .set('Authorization', `Bearer ${owner}`); + expect(directors.status).toBe(200); + const dirsoft = (directors.body.data as { id: string; name: string }[]).find( + (d) => d.name === '吴软件', + ); + const dir3d = (directors.body.data as { id: string; name: string }[]).find( + (d) => d.name === '赵三维', + ); + expect(dirsoft && dir3d).toBeTruthy(); + + const created = await request(app.getHttpServer()) + .post('/api/v1/bid-cases') + .set('Authorization', `Bearer ${owner}`) + .send({ + name: '阶段1主链演示', + externalNo: 'E2E-P1-001', + projectType: '集成', + source: '招标公告', + qualificationNeed: '涉乙', + tenderMethod: '公开招标', + applyMethod: '网上申领', + openMethod: '线下开标', + openPlace: '交易中心', + reviewerIds: [dirsoft!.id, dir3d!.id], + }); + expect(created.status).toBe(201); + const bidId = created.body.data.id as string; + expect(created.body.data.status).toBe('TECH_INITIAL'); + + const softToken = await login('dirsoft', 'Demo@123456'); + const first = await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${softToken}`) + .send({ result: 'APPROVED', comment: '软件可做' }); + expect(first.status).toBe(201); + expect(first.body.data.status).toBe('TECH_INITIAL'); + + const d3Token = await login('dir3d', 'Demo@123456'); + const second = await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${d3Token}`) + .send({ result: 'APPROVED', comment: '三维可做' }); + expect(second.status).toBe(201); + expect(second.body.data.status).toBe('FETCH'); + + const biz = await login('bizstaff', 'Demo@123456'); + const fetched = await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/fetch`) + .set('Authorization', `Bearer ${biz}`) + .send({ nasPath: '\\\\nas\\bids\\E2E-P1-001', openPlace: '交易中心三楼' }); + expect(fetched.status).toBe(201); + expect(fetched.body.data.status).toBe('TECH_FINAL'); + + const final1 = await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${softToken}`) + .send({ result: 'APPROVED' }); + expect(final1.body.data.status).toBe('TECH_FINAL'); + const final2 = await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${d3Token}`) + .send({ result: 'APPROVED' }); + expect(final2.status).toBe(201); + expect(final2.body.data.status).toBe('DRAFTING'); + + const drafting = await request(app.getHttpServer()) + .get('/api/v1/bid-cases?bucket=drafting') + .set('Authorization', `Bearer ${owner}`); + expect(drafting.body.data.items.some((i: { id: string }) => i.id === bidId)).toBe(true); + + const rejectCreate = await request(app.getHttpServer()) + .post('/api/v1/bid-cases') + .set('Authorization', `Bearer ${owner}`) + .send({ + name: '阶段1驳回演示', + externalNo: 'E2E-P1-002', + projectType: '软件', + source: '招标公告', + qualificationNeed: '无需资质', + tenderMethod: '公开招标', + reviewerIds: [dirsoft!.id], + }); + const rejectId = rejectCreate.body.data.id as string; + const rejected = await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${rejectId}/reviews`) + .set('Authorization', `Bearer ${softToken}`) + .send({ result: 'REJECTED', comment: '工期不够' }); + expect(rejected.body.data.status).toBe('TERMINATED'); + + const employee = await login('employee', 'Demo@123456'); + const hidden = await request(app.getHttpServer()) + .get('/api/v1/bid-cases?bucket=terminated') + .set('Authorization', `Bearer ${employee}`); + expect(hidden.status).toBe(403); + + const visible = await request(app.getHttpServer()) + .get('/api/v1/bid-cases?bucket=terminated') + .set('Authorization', `Bearer ${owner}`); + expect(visible.status).toBe(200); + expect(visible.body.data.items.some((i: { id: string }) => i.id === rejectId)).toBe(true); + }, 60000); + + it('runs drafting to open result, and bounces rejected drafts back', async () => { + const owner = await login('owner', 'Demo@123456'); + const bizdir = await login('bizdir', 'Demo@123456'); + const biz = await login('bizstaff', 'Demo@123456'); + const directors = await request(app.getHttpServer()) + .get('/api/v1/directors') + .set('Authorization', `Bearer ${owner}`); + const dirsoft = (directors.body.data as { id: string; name: string }[]).find( + (d) => d.name === '吴软件', + ); + expect(dirsoft).toBeTruthy(); + const staff = await request(app.getHttpServer()) + .get('/api/v1/staff') + .set('Authorization', `Bearer ${owner}`); + const bizUser = (staff.body.data as { id: string; username: string }[]).find( + (s) => s.username === 'bizstaff', + ); + const employee = (staff.body.data as { id: string; username: string }[]).find( + (s) => s.username === 'employee', + ); + expect(bizUser && employee).toBeTruthy(); + + const created = await request(app.getHttpServer()) + .post('/api/v1/bid-cases') + .set('Authorization', `Bearer ${owner}`) + .send({ + name: '阶段2主链演示', + externalNo: 'E2E-P2-001', + projectType: '软件', + source: '招标公告', + qualificationNeed: '无需资质', + tenderMethod: '公开招标', + reviewerIds: [dirsoft!.id], + }); + const bidId = created.body.data.id as string; + const softToken = await login('dirsoft', 'Demo@123456'); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${softToken}`) + .send({ result: 'APPROVED' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/fetch`) + .set('Authorization', `Bearer ${biz}`) + .send({ nasPath: '\\\\nas\\p2' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${softToken}`) + .send({ result: 'APPROVED' }); + + const assigned = await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/assign-drafter`) + .set('Authorization', `Bearer ${bizdir}`) + .send({ drafterId: bizUser!.id, review1Id: bizUser!.id }); + expect(assigned.body.data.status).toBe('DRAFTING'); + + const submitted = await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/submit-draft`) + .set('Authorization', `Bearer ${biz}`) + .send({ nasPath: '\\\\nas\\p2\\v1', note: '初稿' }); + expect(submitted.body.data.status).toBe('REVIEW1'); + + const bounced = await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${biz}`) + .send({ result: 'REJECTED', comment: '封面格式不对' }); + expect(bounced.body.data.status).toBe('DRAFTING'); + + const resubmit = await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/submit-draft`) + .set('Authorization', `Bearer ${biz}`) + .send({ nasPath: '\\\\nas\\p2\\v2', note: '已改封面' }); + expect(resubmit.body.data.status).toBe('REVIEW1'); + expect(resubmit.body.data.versions).toHaveLength(2); + + const r1 = await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${biz}`) + .send({ result: 'APPROVED' }); + expect(r1.body.data.status).toBe('REVIEW2'); + + const r2 = await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${bizdir}`) + .send({ result: 'APPROVED' }); + expect(r2.body.data.status).toBe('PRINT'); + + const printed = await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/print`) + .set('Authorization', `Bearer ${biz}`) + .send({ amount: 800, note: '印标店' }); + expect(printed.body.data.status).toBe('LIST'); + expect(printed.body.data.expenses.length).toBeGreaterThan(0); + + const completed = await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/complete`) + .set('Authorization', `Bearer ${biz}`) + .send({ + bidderIds: [employee!.id], + bondMethod: '电汇', + bondAmount: 50000, + quoteAmount: '1280000', + }); + expect(completed.body.data.status).toBe('PENDING'); + + const empToken = await login('employee', 'Demo@123456'); + const opened = await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/open-result`) + .set('Authorization', `Bearer ${empToken}`) + .send({ result: 'LOST', costDisposition: 'MARKETING', comment: '第二名' }); + expect(opened.status).toBe(201); + expect(opened.body.data.status).toBe('LOST'); + expect(opened.body.data.costDisposition).toBe('MARKETING'); + + const resultList = await request(app.getHttpServer()) + .get('/api/v1/bid-cases?bucket=result') + .set('Authorization', `Bearer ${owner}`); + expect(resultList.body.data.items.some((i: { id: string }) => i.id === bidId)).toBe(true); + }, 60000); + + it('converts a won bid to contract, rejects then approves into a project', async () => { + const owner = await login('owner', 'Demo@123456'); + const bizdir = await login('bizdir', 'Demo@123456'); + const biz = await login('bizstaff', 'Demo@123456'); + const empToken = await login('employee', 'Demo@123456'); + const directors = await request(app.getHttpServer()) + .get('/api/v1/directors') + .set('Authorization', `Bearer ${owner}`); + const dirsoft = (directors.body.data as { id: string; name: string }[]).find( + (d) => d.name === '吴软件', + ); + const staff = await request(app.getHttpServer()) + .get('/api/v1/staff') + .set('Authorization', `Bearer ${owner}`); + const bizUser = (staff.body.data as { id: string; username: string }[]).find( + (s) => s.username === 'bizstaff', + ); + const employee = (staff.body.data as { id: string; username: string }[]).find( + (s) => s.username === 'employee', + ); + expect(dirsoft && bizUser && employee).toBeTruthy(); + + const created = await request(app.getHttpServer()) + .post('/api/v1/bid-cases') + .set('Authorization', `Bearer ${owner}`) + .send({ + name: '阶段3主链演示', + externalNo: 'E2E-P3-001', + projectType: '软件', + source: '招标公告', + qualificationNeed: '无需资质', + tenderMethod: '公开招标', + reviewerIds: [dirsoft!.id], + }); + const bidId = created.body.data.id as string; + const softToken = await login('dirsoft', 'Demo@123456'); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${softToken}`) + .send({ result: 'APPROVED' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/fetch`) + .set('Authorization', `Bearer ${biz}`) + .send({ nasPath: '\\\\nas\\p3' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${softToken}`) + .send({ result: 'APPROVED' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/assign-drafter`) + .set('Authorization', `Bearer ${bizdir}`) + .send({ drafterId: bizUser!.id, review1Id: bizUser!.id }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/submit-draft`) + .set('Authorization', `Bearer ${biz}`) + .send({ nasPath: '\\\\nas\\p3\\v1' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${biz}`) + .send({ result: 'APPROVED' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${bizdir}`) + .send({ result: 'APPROVED' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/print`) + .set('Authorization', `Bearer ${biz}`) + .send({ amount: 800, note: '印标店' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/complete`) + .set('Authorization', `Bearer ${biz}`) + .send({ bidderIds: [employee!.id], bondMethod: '电汇', quoteAmount: '1280000' }); + + const opened = await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/open-result`) + .set('Authorization', `Bearer ${empToken}`) + .send({ result: 'WON', comment: '第一名' }); + expect(opened.status).toBe(201); + expect(opened.body.data.status).toBe('WON'); + expect(opened.body.data.contracts).toHaveLength(1); + expect(opened.body.data.contracts[0].status).toBe('DRAFT'); + expect(opened.body.data.contracts[0].projects).toHaveLength(1); + const contractId = opened.body.data.contracts[0].id as string; + + const asBiz = await request(app.getHttpServer()) + .get(`/api/v1/bid-cases/${bidId}`) + .set('Authorization', `Bearer ${biz}`); + expect(asBiz.body.data.myActions.canConvertToContract).toBe(false); + + const forbidden = await request(app.getHttpServer()) + .post('/api/v1/contracts') + .set('Authorization', `Bearer ${empToken}`) + .send({ bidCaseId: bidId, amount: 1280000 }); + expect(forbidden.status).toBe(403); + + const drafted = await request(app.getHttpServer()) + .post('/api/v1/contracts') + .set('Authorization', `Bearer ${biz}`) + .send({ bidCaseId: bidId, amount: 1280000 }); + expect(drafted.status).toBe(400); + + await request(app.getHttpServer()) + .patch(`/api/v1/contracts/${contractId}`) + .set('Authorization', `Bearer ${biz}`) + .send({ contractNo: `HT-E2E-${bidId.slice(0, 8)}` }); + + const noTerms = await request(app.getHttpServer()) + .post(`/api/v1/contracts/${contractId}/submit`) + .set('Authorization', `Bearer ${biz}`); + expect(noTerms.status).toBe(400); + + const miles = await request(app.getHttpServer()) + .put(`/api/v1/contracts/${contractId}/milestones`) + .set('Authorization', `Bearer ${biz}`) + .send({ + items: [ + { name: '预付款', ratio: 30 }, + { name: '验收款', ratio: 70 }, + ], + }); + expect(miles.status).toBe(200); + + const sealed = await request(app.getHttpServer()) + .post(`/api/v1/contracts/${contractId}/seal`) + .set('Authorization', `Bearer ${biz}`) + .send({ sealType: '合同章', reason: '签订中标合同' }); + expect(sealed.status).toBe(201); + + const submitted = await request(app.getHttpServer()) + .post(`/api/v1/contracts/${contractId}/submit`) + .set('Authorization', `Bearer ${biz}`); + expect(submitted.status).toBe(201); + expect(submitted.body.data.status).toBe('PENDING'); + + const rejected = await request(app.getHttpServer()) + .post(`/api/v1/contracts/${contractId}/reviews`) + .set('Authorization', `Bearer ${owner}`) + .send({ result: 'REJECTED', comment: '收款节点要改' }); + expect(rejected.status).toBe(201); + expect(rejected.body.data.status).toBe('DRAFT'); + expect(rejected.body.data.projects).toHaveLength(1); + + await request(app.getHttpServer()) + .post(`/api/v1/contracts/${contractId}/submit`) + .set('Authorization', `Bearer ${biz}`); + + const ownerOk = await request(app.getHttpServer()) + .post(`/api/v1/contracts/${contractId}/reviews`) + .set('Authorization', `Bearer ${owner}`) + .send({ result: 'APPROVED' }); + expect(ownerOk.body.data.status).toBe('PENDING'); + expect(ownerOk.body.data.projects).toHaveLength(1); + + const dirOk = await request(app.getHttpServer()) + .post(`/api/v1/contracts/${contractId}/reviews`) + .set('Authorization', `Bearer ${bizdir}`) + .send({ result: 'APPROVED' }); + expect(dirOk.status).toBe(201); + expect(dirOk.body.data.status).toBe('APPROVED'); + expect(dirOk.body.data.projects).toHaveLength(1); + expect(dirOk.body.data.projects[0].projectNo).toMatch(/^PRJ-\d{4}-\d{5}$/); + expect(Number(dirOk.body.data.projects[0].presalesCost)).toBe(800); + expect(dirOk.body.data.sealRequests[0].status).toBe('APPROVED'); + + const bid = await request(app.getHttpServer()) + .get(`/api/v1/bid-cases/${bidId}`) + .set('Authorization', `Bearer ${owner}`); + expect(bid.body.data.expenses.every((e: { projectId: string | null }) => e.projectId)).toBe(true); + + const projects = await request(app.getHttpServer()) + .get('/api/v1/projects') + .set('Authorization', `Bearer ${owner}`); + expect(projects.body.data.items.some((p: { contractId: string }) => p.contractId === contractId)).toBe( + true, + ); + }, 60000); + + it('requires cost object, offsets loans, and flags overdue bonds', async () => { + const owner = await login('owner', 'Demo@123456'); + const biz = await login('bizstaff', 'Demo@123456'); + const finance = await login('finance', 'Demo@123456'); + const emp = await login('employee', 'Demo@123456'); + const directors = await request(app.getHttpServer()) + .get('/api/v1/directors') + .set('Authorization', `Bearer ${owner}`); + const dirsoft = (directors.body.data as { id: string; name: string }[]).find((d) => d.name === '吴软件'); + const created = await request(app.getHttpServer()) + .post('/api/v1/bid-cases') + .set('Authorization', `Bearer ${owner}`) + .send({ + name: '阶段4差旅借款', + externalNo: 'E2E-P4-001', + projectType: '软件', + source: '招标公告', + qualificationNeed: '无需资质', + tenderMethod: '公开招标', + reviewerIds: [dirsoft!.id], + }); + const bidId = created.body.data.id as string; + + const badCost = await request(app.getHttpServer()) + .post('/api/v1/expenses') + .set('Authorization', `Bearer ${biz}`) + .send({ amount: 800, costType: 'OTHER' }); + expect(badCost.status).toBe(400); + + const noBid = await request(app.getHttpServer()) + .post('/api/v1/expenses') + .set('Authorization', `Bearer ${biz}`) + .send({ amount: 800, costType: 'BID' }); + expect(noBid.status).toBe(400); + + const loan = await request(app.getHttpServer()) + .post('/api/v1/loans') + .set('Authorization', `Bearer ${biz}`) + .send({ costType: 'BID', bidCaseId: bidId, amount: 2000, purpose: '取标差旅' }); + expect(loan.status).toBe(201); + const loanId = loan.body.data.id as string; + await request(app.getHttpServer()) + .post(`/api/v1/loans/${loanId}/submit`) + .set('Authorization', `Bearer ${biz}`); + const loanOk = await request(app.getHttpServer()) + .post(`/api/v1/loans/${loanId}/reviews`) + .set('Authorization', `Bearer ${finance}`) + .send({ result: 'APPROVED' }); + expect(loanOk.body.data.status).toBe('OPEN'); + + const dup = await request(app.getHttpServer()) + .post('/api/v1/expenses') + .set('Authorization', `Bearer ${biz}`) + .send({ costType: 'BID', bidCaseId: bidId, amount: 800 }); + expect(dup.status).toBe(400); + + const exp = await request(app.getHttpServer()) + .post('/api/v1/expenses') + .set('Authorization', `Bearer ${biz}`) + .send({ costType: 'BID', bidCaseId: bidId, amount: 800, loanId }); + expect(exp.status).toBe(201); + const expId = exp.body.data.id as string; + await request(app.getHttpServer()) + .post(`/api/v1/expenses/${expId}/submit`) + .set('Authorization', `Bearer ${biz}`); + const bounced = await request(app.getHttpServer()) + .post(`/api/v1/expenses/${expId}/reviews`) + .set('Authorization', `Bearer ${finance}`) + .send({ result: 'REJECTED', comment: '发票不齐' }); + expect(bounced.body.data.status).toBe('REJECTED'); + const stillOpen = await request(app.getHttpServer()) + .get(`/api/v1/loans/${loanId}`) + .set('Authorization', `Bearer ${biz}`); + expect(stillOpen.body.data.status).toBe('OPEN'); + + const categorized = await request(app.getHttpServer()) + .patch(`/api/v1/expenses/${expId}`) + .set('Authorization', `Bearer ${biz}`) + .send({ costType: 'BID', bidCaseId: bidId, amount: 800, loanId, expenseCategory: '交通' }); + expect(categorized.status).toBe(200); + expect(categorized.body.data.expenseCategory).toBe('交通'); + + const uploaded = await request(app.getHttpServer()) + .post('/api/v1/files') + .set('Authorization', `Bearer ${biz}`) + .attach('file', Buffer.from('%PDF-1.4 invoice'), { + filename: 'fapiao.pdf', + contentType: 'application/pdf', + }) + .field('bizType', 'EXPENSE') + .field('bizId', expId); + expect(uploaded.status).toBe(201); + expect(uploaded.body.data.fileName).toBe('fapiao.pdf'); + + const withFile = await request(app.getHttpServer()) + .get(`/api/v1/expenses/${expId}`) + .set('Authorization', `Bearer ${biz}`); + expect(withFile.body.data.files).toHaveLength(1); + + await request(app.getHttpServer()) + .post(`/api/v1/expenses/${expId}/submit`) + .set('Authorization', `Bearer ${biz}`); + const expOk = await request(app.getHttpServer()) + .post(`/api/v1/expenses/${expId}/reviews`) + .set('Authorization', `Bearer ${finance}`) + .send({ result: 'APPROVED' }); + expect(expOk.body.data.status).toBe('APPROVED'); + const offset = await request(app.getHttpServer()) + .get(`/api/v1/loans/${loanId}`) + .set('Authorization', `Bearer ${biz}`); + expect(offset.body.data.status).toBe('OPEN'); + expect(Number(offset.body.data.remaining)).toBe(1200); + + const rest = await request(app.getHttpServer()) + .post('/api/v1/expenses') + .set('Authorization', `Bearer ${biz}`) + .send({ costType: 'BID', bidCaseId: bidId, amount: 1200, loanId }); + const restId = rest.body.data.id as string; + await request(app.getHttpServer()) + .post(`/api/v1/expenses/${restId}/submit`) + .set('Authorization', `Bearer ${biz}`); + await request(app.getHttpServer()) + .post(`/api/v1/expenses/${restId}/reviews`) + .set('Authorization', `Bearer ${finance}`) + .send({ result: 'APPROVED' }); + const done = await request(app.getHttpServer()) + .get(`/api/v1/loans/${loanId}`) + .set('Authorization', `Bearer ${biz}`); + expect(done.body.data.status).toBe('OFFSET'); + expect(Number(done.body.data.remaining)).toBe(0); + + const noBond = await request(app.getHttpServer()) + .post('/api/v1/bonds') + .set('Authorization', `Bearer ${finance}`) + .send({ bondType: 'TENDER', amount: 50000 }); + expect(noBond.status).toBe(400); + + const empBond = await request(app.getHttpServer()) + .get('/api/v1/bonds') + .set('Authorization', `Bearer ${emp}`); + expect(empBond.status).toBe(403); + + const bond = await request(app.getHttpServer()) + .post('/api/v1/bonds') + .set('Authorization', `Bearer ${finance}`) + .send({ bondType: 'TENDER', amount: 50000, bidCaseId: bidId }); + expect(bond.status).toBe(201); + const yesterday = new Date(Date.now() - 86400000).toISOString(); + await request(app.getHttpServer()) + .post(`/api/v1/bonds/${bond.body.data.id}/pay`) + .set('Authorization', `Bearer ${finance}`) + .send({ dueBackAt: yesterday }); + const overdue = await request(app.getHttpServer()) + .get('/api/v1/bonds?bucket=overdue') + .set('Authorization', `Bearer ${finance}`); + expect(overdue.body.data.items.some((b: { id: string }) => b.id === bond.body.data.id)).toBe(true); + }, 60000); + + it('invoices a contract milestone then records receipt', async () => { + const owner = await login('owner', 'Demo@123456'); + const finance = await login('finance', 'Demo@123456'); + const list = await request(app.getHttpServer()) + .get('/api/v1/contracts') + .set('Authorization', `Bearer ${owner}`); + const contract = (list.body.data.items as { + id: string; + status: string; + milestones: { id: string }[]; + }[]).find((c) => c.status === 'APPROVED' && c.milestones?.length); + expect(contract).toBeTruthy(); + const mileId = contract!.milestones[0].id; + const created = await request(app.getHttpServer()) + .post('/api/v1/invoices') + .set('Authorization', `Bearer ${finance}`) + .send({ contractId: contract!.id, milestoneId: mileId }); + expect(created.status).toBe(201); + expect(created.body.data.status).toBe('TO_ISSUE'); + expect(Number(created.body.data.amount)).toBeGreaterThan(0); + const dup = await request(app.getHttpServer()) + .post('/api/v1/invoices') + .set('Authorization', `Bearer ${finance}`) + .send({ contractId: contract!.id, milestoneId: mileId }); + expect(dup.status).toBe(400); + const issued = await request(app.getHttpServer()) + .post(`/api/v1/invoices/${created.body.data.id}/issue`) + .set('Authorization', `Bearer ${finance}`) + .send({ invoiceNo: 'INV-P4-001' }); + expect(issued.body.data.status).toBe('ISSUED'); + const received = await request(app.getHttpServer()) + .post(`/api/v1/invoices/${created.body.data.id}/receive`) + .set('Authorization', `Bearer ${finance}`); + expect(received.body.data.status).toBe('RECEIVED'); + }, 30000); + + it('breaks down WBS, fills timesheets, blocks over-budget expenses, and invoices on acceptance', async () => { + const owner = await login('owner', 'Demo@123456'); + const bizdir = await login('bizdir', 'Demo@123456'); + const biz = await login('bizstaff', 'Demo@123456'); + const empToken = await login('employee', 'Demo@123456'); + const hr = await login('hr', 'Demo@123456'); + const directors = await request(app.getHttpServer()) + .get('/api/v1/directors') + .set('Authorization', `Bearer ${owner}`); + const dirsoft = (directors.body.data as { id: string; name: string }[]).find((d) => d.name === '吴软件'); + const staff = await request(app.getHttpServer()) + .get('/api/v1/staff') + .set('Authorization', `Bearer ${owner}`); + const bizUser = (staff.body.data as { id: string; username: string }[]).find((s) => s.username === 'bizstaff'); + const employee = (staff.body.data as { id: string; username: string }[]).find((s) => s.username === 'employee'); + expect(dirsoft && bizUser && employee).toBeTruthy(); + + const created = await request(app.getHttpServer()) + .post('/api/v1/bid-cases') + .set('Authorization', `Bearer ${owner}`) + .send({ + name: '阶段5主链演示', + externalNo: 'E2E-P5-001', + projectType: '软件', + source: '招标公告', + qualificationNeed: '无需资质', + tenderMethod: '公开招标', + reviewerIds: [dirsoft!.id], + }); + const bidId = created.body.data.id as string; + const softToken = await login('dirsoft', 'Demo@123456'); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${softToken}`) + .send({ result: 'APPROVED' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/fetch`) + .set('Authorization', `Bearer ${biz}`) + .send({ nasPath: '\\\\nas\\p5' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${softToken}`) + .send({ result: 'APPROVED' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/assign-drafter`) + .set('Authorization', `Bearer ${bizdir}`) + .send({ drafterId: bizUser!.id, review1Id: bizUser!.id }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/submit-draft`) + .set('Authorization', `Bearer ${biz}`) + .send({ nasPath: '\\\\nas\\p5\\v1' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${biz}`) + .send({ result: 'APPROVED' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/reviews`) + .set('Authorization', `Bearer ${bizdir}`) + .send({ result: 'APPROVED' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/print`) + .set('Authorization', `Bearer ${biz}`) + .send({ amount: 600, note: '印标' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/complete`) + .set('Authorization', `Bearer ${biz}`) + .send({ bidderIds: [employee!.id], bondMethod: '电汇', quoteAmount: '880000' }); + await request(app.getHttpServer()) + .post(`/api/v1/bid-cases/${bidId}/open-result`) + .set('Authorization', `Bearer ${empToken}`) + .send({ result: 'WON' }); + const opened = await request(app.getHttpServer()) + .get(`/api/v1/bid-cases/${bidId}`) + .set('Authorization', `Bearer ${biz}`); + const contractId = opened.body.data.contracts[0].id as string; + await request(app.getHttpServer()) + .patch(`/api/v1/contracts/${contractId}`) + .set('Authorization', `Bearer ${biz}`) + .send({ contractNo: `HT-E2E5-${bidId.slice(0, 8)}` }); + await request(app.getHttpServer()) + .put(`/api/v1/contracts/${contractId}/milestones`) + .set('Authorization', `Bearer ${biz}`) + .send({ + items: [ + { name: '预付款', ratio: 30 }, + { name: '验收款', ratio: 70 }, + ], + }); + await request(app.getHttpServer()) + .post(`/api/v1/contracts/${contractId}/seal`) + .set('Authorization', `Bearer ${biz}`) + .send({ sealType: '合同章', reason: '阶段5合同' }); + await request(app.getHttpServer()) + .post(`/api/v1/contracts/${contractId}/submit`) + .set('Authorization', `Bearer ${biz}`); + await request(app.getHttpServer()) + .post(`/api/v1/contracts/${contractId}/reviews`) + .set('Authorization', `Bearer ${owner}`) + .send({ result: 'APPROVED' }); + const approved = await request(app.getHttpServer()) + .post(`/api/v1/contracts/${contractId}/reviews`) + .set('Authorization', `Bearer ${bizdir}`) + .send({ result: 'APPROVED' }); + expect(approved.body.data.projects).toHaveLength(1); + const projectId = approved.body.data.projects[0].id as string; + + const hidden = await request(app.getHttpServer()) + .get('/api/v1/projects') + .set('Authorization', `Bearer ${hr}`); + expect(hidden.status).toBe(403); + + const depts = await request(app.getHttpServer()) + .get('/api/v1/departments') + .set('Authorization', `Bearer ${softToken}`); + const software = (depts.body.data as { id: string; code: string }[]).find((d) => d.code === 'TECH_SOFTWARE'); + const d3 = (depts.body.data as { id: string; code: string }[]).find((d) => d.code === 'TECH_3D'); + expect(software && d3).toBeTruthy(); + + const t1 = await request(app.getHttpServer()) + .post(`/api/v1/projects/${projectId}/tasks`) + .set('Authorization', `Bearer ${softToken}`) + .send({ name: '软件开发', departmentId: software!.id, assigneeId: employee!.id }); + expect(t1.status).toBe(201); + const t2 = await request(app.getHttpServer()) + .post(`/api/v1/projects/${projectId}/tasks`) + .set('Authorization', `Bearer ${softToken}`) + .send({ name: '三维制作', departmentId: d3!.id }); + expect(t2.status).toBe(201); + + const tasks = await request(app.getHttpServer()) + .get('/api/v1/project-tasks') + .set('Authorization', `Bearer ${owner}`); + expect(tasks.body.data.filter((t: { projectId: string }) => t.projectId === projectId)).toHaveLength(2); + + const budgets = await request(app.getHttpServer()) + .put(`/api/v1/projects/${projectId}/budgets`) + .set('Authorization', `Bearer ${owner}`) + .send({ + items: [ + { category: 'LABOR', amount: 1000 }, + { category: 'TRAVEL', amount: 500 }, + { category: 'PURCHASE', amount: 8000 }, + ], + }); + expect(budgets.status).toBe(200); + expect(Number(budgets.body.data.hourRate)).toBe(200); + + const sheet = await request(app.getHttpServer()) + .post('/api/v1/timesheets') + .set('Authorization', `Bearer ${empToken}`) + .send({ + projectId, + taskId: t1.body.data.id, + workDate: '2026-08-29', + hours: 2, + note: '联调', + }); + expect(sheet.status).toBe(201); + expect(Number(sheet.body.data.costAmount)).toBe(400); + + const overLabor = await request(app.getHttpServer()) + .post('/api/v1/timesheets') + .set('Authorization', `Bearer ${empToken}`) + .send({ projectId, workDate: '2026-08-30', hours: 4 }); + expect(overLabor.status).toBe(400); + + const noCat = await request(app.getHttpServer()) + .post('/api/v1/expenses') + .set('Authorization', `Bearer ${biz}`) + .send({ costType: 'PROJECT', projectId, amount: 100 }); + expect(noCat.status).toBe(400); + + const travel = await request(app.getHttpServer()) + .post('/api/v1/expenses') + .set('Authorization', `Bearer ${biz}`) + .send({ costType: 'PROJECT', projectId, amount: 400, budgetCategory: 'TRAVEL', remark: '现场' }); + expect(travel.status).toBe(201); + + const overTravel = await request(app.getHttpServer()) + .post('/api/v1/expenses') + .set('Authorization', `Bearer ${biz}`) + .send({ costType: 'PROJECT', projectId, amount: 200, budgetCategory: 'TRAVEL' }); + expect(overTravel.status).toBe(400); + + const tooSoon = await request(app.getHttpServer()) + .post(`/api/v1/projects/${projectId}/accept`) + .set('Authorization', `Bearer ${owner}`) + .send({ kind: 'CUSTOMER', result: 'APPROVED' }); + expect(tooSoon.status).toBe(400); + + const internal = await request(app.getHttpServer()) + .post(`/api/v1/projects/${projectId}/accept`) + .set('Authorization', `Bearer ${softToken}`) + .send({ kind: 'INTERNAL', result: 'APPROVED' }); + expect(internal.status).toBe(201); + expect(internal.body.data.status).toBe('INTERNAL_ACCEPTED'); + + const customer = await request(app.getHttpServer()) + .post(`/api/v1/projects/${projectId}/accept`) + .set('Authorization', `Bearer ${owner}`) + .send({ kind: 'CUSTOMER', result: 'APPROVED' }); + expect(customer.status).toBe(201); + expect(customer.body.data.status).toBe('ACCEPTED'); + expect(customer.body.data.invoicesCreated).toBe(2); + expect( + customer.body.data.contract.milestones.every((m: { invoice: { status: string } | null }) => m.invoice?.status), + ).toBe(true); + + const empProjects = await request(app.getHttpServer()) + .get('/api/v1/projects') + .set('Authorization', `Bearer ${empToken}`); + expect(empProjects.status).toBe(200); + expect(empProjects.body.data.items.some((p: { id: string }) => p.id === projectId)).toBe(true); + }, 60000); + + it('warns expiring qualifications, overdue borrows and take-out seals, and lists calendar dates', async () => { + const owner = await login('owner', 'Demo@123456'); + const biz = await login('bizstaff', 'Demo@123456'); + const emp = await login('employee', 'Demo@123456'); + const hr = await login('hr', 'Demo@123456'); + + const forbidden = await request(app.getHttpServer()) + .get('/api/v1/qualifications') + .set('Authorization', `Bearer ${hr}`); + expect(forbidden.status).toBe(403); + + const yesterday = new Date(Date.now() - 86400000).toISOString(); + const expired = await request(app.getHttpServer()) + .post('/api/v1/qualifications') + .set('Authorization', `Bearer ${owner}`) + .send({ name: '阶段6过期资质', code: 'QUAL_P6_OLD', expiresAt: yesterday, keeper: '行政' }); + expect(expired.status).toBe(201); + expect(expired.body.data.bucket).toBe('expired'); + + const expiringList = await request(app.getHttpServer()) + .get('/api/v1/qualifications?bucket=expired') + .set('Authorization', `Bearer ${owner}`); + expect(expiringList.body.data.items.some((q: { id: string }) => q.id === expired.body.data.id)).toBe(true); + + const staff = await request(app.getHttpServer()) + .get('/api/v1/staff') + .set('Authorization', `Bearer ${owner}`); + const employee = (staff.body.data as { id: string; username: string }[]).find((s) => s.username === 'employee'); + expect(employee).toBeTruthy(); + + const borrow = await request(app.getHttpServer()) + .post('/api/v1/credential-borrows') + .set('Authorization', `Bearer ${biz}`) + .send({ + itemName: '涉乙证书原件', + borrowerId: employee!.id, + dueAt: yesterday, + }); + expect(borrow.status).toBe(201); + expect(borrow.body.data.bucket).toBe('overdue'); + + const overdueBorrows = await request(app.getHttpServer()) + .get('/api/v1/credential-borrows?bucket=overdue') + .set('Authorization', `Bearer ${owner}`); + expect(overdueBorrows.body.data.items.some((b: { id: string }) => b.id === borrow.body.data.id)).toBe(true); + + const directors = await request(app.getHttpServer()) + .get('/api/v1/directors') + .set('Authorization', `Bearer ${owner}`); + const dirsoft = (directors.body.data as { id: string; name: string }[]).find((d) => d.name === '吴软件'); + const bid = await request(app.getHttpServer()) + .post('/api/v1/bid-cases') + .set('Authorization', `Bearer ${owner}`) + .send({ + name: '阶段6开标日程', + externalNo: 'E2E-P6-001', + projectType: '软件', + source: '招标公告', + qualificationNeed: '无需资质', + tenderMethod: '公开招标', + openAt: new Date(Date.now() + 3 * 86400000).toISOString(), + openPlace: '交易中心', + reviewerIds: [dirsoft!.id], + }); + expect(bid.status).toBe(201); + const bidId = bid.body.data.id as string; + + const noDue = await request(app.getHttpServer()) + .post('/api/v1/seal-requests') + .set('Authorization', `Bearer ${biz}`) + .send({ sealType: '公章', reason: '开标带章', bidCaseId: bidId, takeOut: true }); + expect(noDue.status).toBe(400); + + const seal = await request(app.getHttpServer()) + .post('/api/v1/seal-requests') + .set('Authorization', `Bearer ${biz}`) + .send({ + sealType: '公章', + reason: '开标带章', + bidCaseId: bidId, + takeOut: true, + returnAt: yesterday, + }); + expect(seal.status).toBe(201); + expect(seal.body.data.status).toBe('PENDING'); + const sealId = seal.body.data.id as string; + + const approved = await request(app.getHttpServer()) + .post(`/api/v1/seal-requests/${sealId}/reviews`) + .set('Authorization', `Bearer ${owner}`) + .send({ result: 'APPROVED' }); + expect(approved.status).toBe(201); + expect(approved.body.data.status).toBe('OUT'); + expect(approved.body.data.bucket).toBe('overdue'); + + const overview = await request(app.getHttpServer()) + .get('/api/v1/office/overview') + .set('Authorization', `Bearer ${owner}`); + expect(overview.body.data.expiringQualifications).toBeGreaterThan(0); + expect(overview.body.data.overdueBorrows).toBeGreaterThan(0); + expect(overview.body.data.overdueSeals).toBeGreaterThan(0); + expect(overview.body.data.upcomingOpens).toBeGreaterThan(0); + + const calendar = await request(app.getHttpServer()) + .get('/api/v1/office/calendar') + .set('Authorization', `Bearer ${owner}`); + expect(calendar.body.data.some((i: { kind: string }) => i.kind === 'BORROW' && i.overdue)).toBe(true); + expect(calendar.body.data.some((i: { kind: string }) => i.kind === 'SEAL' && i.overdue)).toBe(true); + expect(calendar.body.data.some((i: { kind: string }) => i.kind === 'BID_OPEN')).toBe(false); + + const returnedBorrow = await request(app.getHttpServer()) + .post(`/api/v1/credential-borrows/${borrow.body.data.id}/return`) + .set('Authorization', `Bearer ${biz}`); + expect(returnedBorrow.body.data.bucket).toBe('returned'); + + const returnedSeal = await request(app.getHttpServer()) + .post(`/api/v1/seal-requests/${sealId}/return`) + .set('Authorization', `Bearer ${biz}`); + expect(returnedSeal.body.data.status).toBe('RETURNED'); + + const empCal = await request(app.getHttpServer()) + .get('/api/v1/office/calendar') + .set('Authorization', `Bearer ${emp}`); + expect(empCal.status).toBe(200); + expect(empCal.body.data.some((i: { kind: string }) => i.kind === 'BID_OPEN')).toBe(false); + }, 30000); + + it('maintains hire-transfer-leave, imports attendance, payroll without housing fund, and lets directors change performance ratio', async () => { + const hr = await login('hr', 'Demo@123456'); + const empToken = await login('employee', 'Demo@123456'); + const soft = await login('dirsoft', 'Demo@123456'); + const film = await login('dirfilm', 'Demo@123456'); + const suffix = String(Date.now()).slice(-8); + const payDate = new Date(2026, 8, 1); + payDate.setMonth(payDate.getMonth() + (Number(suffix) % 30)); + const payMonth = `${payDate.getFullYear()}-${String(payDate.getMonth() + 1).padStart(2, '0')}`; + + const forbiddenDept = await request(app.getHttpServer()) + .post('/api/v1/departments') + .set('Authorization', `Bearer ${empToken}`) + .send({ name: '不可建', code: `NOPE_${suffix}`, deptType: 'TECH' }); + expect(forbiddenDept.status).toBe(403); + + const forbiddenPay = await request(app.getHttpServer()) + .get('/api/v1/payroll?yearMonth=2026-07') + .set('Authorization', `Bearer ${empToken}`); + expect(forbiddenPay.status).toBe(403); + + const depts = await request(app.getHttpServer()) + .get('/api/v1/departments') + .set('Authorization', `Bearer ${hr}`); + expect(Array.isArray(depts.body.data)).toBe(true); + const software = (depts.body.data as { id: string; code: string }[]).find((d) => d.code === 'TECH_SOFTWARE'); + const filmDept = (depts.body.data as { id: string; code: string }[]).find((d) => d.code === 'TECH_FILM'); + expect(software && filmDept).toBeTruthy(); + + const createdDept = await request(app.getHttpServer()) + .post('/api/v1/departments') + .set('Authorization', `Bearer ${hr}`) + .send({ + name: '阶段7演示组', + code: `TECH_P7_${suffix}`, + deptType: 'TECH', + parentId: software!.id, + categoryCode: 'CAT_SOFTWARE', + }); + expect(createdDept.status).toBe(201); + expect(createdDept.body.data.code).toBe(`TECH_P7_${suffix}`); + + const roster = await request(app.getHttpServer()) + .get('/api/v1/employees') + .set('Authorization', `Bearer ${hr}`); + const dirEmp = (roster.body.data.items as { id: string; employeeNo: string }[]).find((e) => e.employeeNo === 'E023'); + expect(dirEmp).toBeTruthy(); + + const hired = await request(app.getHttpServer()) + .post('/api/v1/employees') + .set('Authorization', `Bearer ${hr}`) + .send({ + name: '阶段7实习生', + employeeNo: `E7${suffix}`, + departmentId: software!.id, + title: '实习工程师', + restSchedule: 'SINGLE', + employmentStatus: 'INTERN', + hiredAt: '2026-07-16', + managerId: dirEmp!.id, + baseSalary: 10000, + perfBase: 3000, + socialBase: 10000, + }); + expect(hired.status).toBe(201); + expect(hired.body.data.employmentStatus).toBe('INTERN'); + const employeeId = hired.body.data.id as string; + + const intern = await request(app.getHttpServer()) + .post('/api/v1/employment-events') + .set('Authorization', `Bearer ${hr}`) + .send({ employeeId, kind: 'PROBATION', effectiveAt: '2026-07-20', comment: '转试用' }); + expect(intern.status).toBe(201); + expect(intern.body.data.employmentStatus).toBe('PROBATION'); + + const regular = await request(app.getHttpServer()) + .post('/api/v1/employment-events') + .set('Authorization', `Bearer ${hr}`) + .send({ employeeId, kind: 'REGULARIZE', effectiveAt: '2026-07-25', comment: '转正' }); + expect(regular.status).toBe(201); + expect(regular.body.data.employmentStatus).toBe('REGULAR'); + + const att = await request(app.getHttpServer()) + .post('/api/v1/attendance') + .set('Authorization', `Bearer ${hr}`) + .send({ + employeeId, + yearMonth: payMonth, + workDays: 12, + leaveDays: 1, + overtimeHours: 8, + remark: '加班转调休', + }); + expect(att.status).toBe(201); + expect(att.body.data.overtimeHours).toBe(8); + expect(att.body.data.compLeaveHours).toBe(8); + + const run = await request(app.getHttpServer()) + .post('/api/v1/payroll/run') + .set('Authorization', `Bearer ${hr}`) + .send({ yearMonth: payMonth }); + expect(run.status).toBe(201); + const item = ( + run.body.data.items as { + id: string; + employeeId: string; + housingFund: number; + tax: number; + socialPersonal: number; + leaveDeduct: number; + grossPay: number; + performancePay: number; + overtimeHours: number; + netPay: number; + }[] + ).find((i) => i.employeeId === employeeId); + expect(item).toBeTruthy(); + expect(item!.housingFund).toBe(0); + expect(item!.socialPersonal).toBeGreaterThan(0); + expect(item!.tax).toBeGreaterThan(0); + expect(item!.leaveDeduct).toBeGreaterThan(0); + expect(item!.overtimeHours).toBe(8); + expect(item!.grossPay).toBeGreaterThan(item!.netPay); + const beforePerf = item!.performancePay; + const beforeGross = item!.grossPay; + + const otherDept = await request(app.getHttpServer()) + .patch(`/api/v1/payroll-items/${item!.id}`) + .set('Authorization', `Bearer ${film}`) + .send({ performanceRatio: 50 }); + expect(otherDept.status).toBe(403); + + const adjusted = await request(app.getHttpServer()) + .patch(`/api/v1/payroll-items/${item!.id}`) + .set('Authorization', `Bearer ${soft}`) + .send({ performanceRatio: 80 }); + expect(adjusted.status).toBe(200); + expect(adjusted.body.data.performanceRatio).toBe(80); + expect(adjusted.body.data.performancePay).toBeLessThan(beforePerf); + expect(adjusted.body.data.grossPay).toBeLessThan(beforeGross); + expect(adjusted.body.data.housingFund).toBe(0); + + const transferred = await request(app.getHttpServer()) + .post('/api/v1/employment-events') + .set('Authorization', `Bearer ${hr}`) + .send({ + employeeId, + kind: 'TRANSFER', + toDepartmentId: filmDept!.id, + effectiveAt: '2026-07-28', + comment: '支援摄制', + }); + expect(transferred.status).toBe(201); + expect(transferred.body.data.departmentId).toBe(filmDept!.id); + + const left = await request(app.getHttpServer()) + .post('/api/v1/employment-events') + .set('Authorization', `Bearer ${hr}`) + .send({ employeeId, kind: 'LEAVE', effectiveAt: '2026-07-31', comment: '交接完成' }); + expect(left.status).toBe(201); + expect(left.body.data.employmentStatus).toBe('LEFT'); + + const confirmed = await request(app.getHttpServer()) + .post(`/api/v1/payroll/${run.body.data.run.id}/confirm`) + .set('Authorization', `Bearer ${hr}`); + expect(confirmed.status).toBe(201); + expect(confirmed.body.data.status).toBe('CONFIRMED'); + + const reopened = await request(app.getHttpServer()) + .patch(`/api/v1/payroll-items/${item!.id}`) + .set('Authorization', `Bearer ${soft}`) + .send({ performanceRatio: 60 }); + expect(reopened.status).toBe(200); + expect(reopened.body.data.performanceRatio).toBe(60); + const after = await request(app.getHttpServer()) + .get('/api/v1/payroll?yearMonth=2026-07') + .set('Authorization', `Bearer ${hr}`); + expect(after.status).toBe(200); + expect(after.body.data.run.status).toBe('DRAFT'); + }, 30000); + + it('shows funnel and spend, writes logs, and fills remaining ledgers', async () => { + const fail = await request(app.getHttpServer()) + .post('/api/v1/auth/login') + .send({ username: 'admin', password: 'wrong-password', agreeTerms: true }); + expect(fail.status).toBe(401); + + const employee = await login('employee', 'Demo@123456'); + const denied = await request(app.getHttpServer()) + .get('/api/v1/reports/bid-conversion') + .set('Authorization', `Bearer ${employee}`); + expect(denied.status).toBe(403); + + const report = await request(app.getHttpServer()) + .post('/api/v1/office/reports') + .set('Authorization', `Bearer ${employee}`) + .send({ periodType: 'DAY', date: '2026-08-29', content: '完成接口联调,明天对方案。', submit: true }); + expect(report.status).toBe(201); + expect(report.body.data.periodType).toBe('DAY'); + expect(report.body.data.content).toContain('接口联调'); + expect(report.body.data.status).toBe('SENT'); + expect(report.body.data.toUser.displayName).toBe('软件项目经理'); + expect(report.body.data.ccUser.displayName).toBe('吴软件'); + + const viewed = await request(app.getHttpServer()) + .get(`/api/v1/office/reports/${report.body.data.id}`) + .set('Authorization', `Bearer ${employee}`); + expect(viewed.status).toBe(200); + expect(viewed.body.data.content).toContain('明天对方案'); + + const owner = await login('owner', 'Demo@123456'); + const funnel = await request(app.getHttpServer()) + .get('/api/v1/reports/bid-conversion') + .set('Authorization', `Bearer ${owner}`); + expect(funnel.status).toBe(200); + expect(funnel.body.data[0].status).toBe('筛选'); + expect(funnel.body.data.some((r: { id: string; presalesSpend: number }) => r.id === 'won')).toBe(true); + expect(typeof funnel.body.data[0].presalesSpend).toBe('number'); + + const profit = await request(app.getHttpServer()) + .get('/api/v1/reports/project-profit') + .set('Authorization', `Bearer ${owner}`); + expect(profit.status).toBe(200); + + const depts = await request(app.getHttpServer()) + .get('/api/v1/departments') + .set('Authorization', `Bearer ${owner}`); + expect(Array.isArray(depts.body.data)).toBe(true); + + const admin = await login('admin', 'Admin@123456'); + const partyName = `E2E招标人-${Date.now()}`; + const party = await request(app.getHttpServer()) + .post('/api/v1/parties') + .set('Authorization', `Bearer ${admin}`) + .send({ + name: partyName, + creditNo: '91320000MA00E2E01X', + level: 'A', + contacts: [{ name: '李工', phone: '13900001111', title: '招标办' }], + }); + expect(party.status).toBe(201); + expect(party.body.data.creditNo).toBe('91320000MA00E2E01X'); + expect(party.body.data.contacts[0].name).toBe('李工'); + const partyId = party.body.data.id as string; + const partyPatched = await request(app.getHttpServer()) + .patch(`/api/v1/parties/${partyId}`) + .set('Authorization', `Bearer ${admin}`) + .send({ name: partyName, creditNo: '91320000MA00E2E02X', level: 'B', contacts: party.body.data.contacts }); + expect(partyPatched.body.data.creditNo).toBe('91320000MA00E2E02X'); + const partyView = await request(app.getHttpServer()) + .get(`/api/v1/parties/${partyId}`) + .set('Authorization', `Bearer ${admin}`); + expect(partyView.status).toBe(200); + expect(partyView.body.data.contacts).toHaveLength(1); + + const logs = await request(app.getHttpServer()) + .get('/api/v1/system/logs?pageSize=100') + .set('Authorization', `Bearer ${admin}`); + expect(logs.status).toBe(200); + const actions = (logs.body.data.items as { action: string }[]).map((i) => i.action); + expect(actions).toEqual(expect.arrayContaining(['LOGIN', 'LOGIN_FAIL', 'FORBIDDEN'])); + + const roles = await request(app.getHttpServer()) + .get('/api/v1/system/roles') + .set('Authorization', `Bearer ${admin}`); + const employeeRole = (roles.body.data.items as { id: string; code: string }[]).find((r) => r.code === 'employee'); + expect(employeeRole).toBeTruthy(); + const scoped = await request(app.getHttpServer()) + .patch(`/api/v1/system/roles/${employeeRole!.id}`) + .set('Authorization', `Bearer ${admin}`) + .send({ dataScope: 'DEPT' }); + expect(scoped.status).toBe(200); + expect(scoped.body.data.dataScope).toBe('DEPT'); + + const rules = await request(app.getHttpServer()) + .get('/api/v1/system/number-rules') + .set('Authorization', `Bearer ${admin}`); + expect(rules.status).toBe(200); + expect((rules.body.data as { code: string }[]).map((r) => r.code).sort()).toEqual(['BID', 'HT', 'PRJ']); + + const board = await request(app.getHttpServer()) + .get('/api/v1/resource-board') + .set('Authorization', `Bearer ${owner}`); + expect(board.status).toBe(200); + expect(board.body.data.items.length).toBeGreaterThan(0); + expect(board.body.data.items[0].load).toMatch(/idle|full|over/); + + const seal = await request(app.getHttpServer()) + .post('/api/v1/seals') + .set('Authorization', `Bearer ${admin}`) + .send({ name: '项目章', keeper: '行政' }); + expect(seal.status).toBe(201); + expect(seal.body.data.name).toBe('项目章'); + + const projects = await request(app.getHttpServer()) + .get('/api/v1/projects?pageSize=50') + .set('Authorization', `Bearer ${owner}`); + const projectId = (projects.body.data.items as { id: string }[])[0]?.id; + expect(projectId).toBeTruthy(); + + const purchase = await request(app.getHttpServer()) + .post('/api/v1/purchase-requests') + .set('Authorization', `Bearer ${owner}`) + .send({ title: '渲染节点硬盘', amount: 80, projectId }); + expect(purchase.status).toBe(201); + expect(purchase.body.data.status).toBe('PENDING'); + + const change = await request(app.getHttpServer()) + .post('/api/v1/project-changes') + .set('Authorization', `Bearer ${owner}`) + .send({ projectId, summary: '增加交底次数', amountDelta: 0 }); + expect(change.status).toBe(201); + expect(change.body.data.status).toBe('PENDING'); + + const camera = await request(app.getHttpServer()) + .post('/api/v1/registered-assets') + .set('Authorization', `Bearer ${owner}`) + .send({ kind: 'FIXED', name: `摄像机 ${Date.now()}`, code: `CAM-${Date.now()}` }); + expect(camera.status).toBe(201); + const asset = await request(app.getHttpServer()) + .post('/api/v1/assets') + .set('Authorization', `Bearer ${owner}`) + .send({ registeredAssetId: camera.body.data.id, projectId }); + expect(asset.status).toBe(201); + expect(asset.body.data.status).toBe('IN_USE'); + expect(asset.body.data.registeredAssetId || asset.body.data.registeredAsset?.id).toBe(camera.body.data.id); + + const finance = await login('finance', 'Demo@123456'); + const payment = await request(app.getHttpServer()) + .post('/api/v1/payments') + .set('Authorization', `Bearer ${finance}`) + .send({ kind: 'EXPENSE', amount: 88, remark: '阶段8演示打款' }); + expect(payment.status).toBe(201); + expect(payment.body.data.status).toBe('TODO'); + const paid = await request(app.getHttpServer()) + .post(`/api/v1/payments/${payment.body.data.id}/pay`) + .set('Authorization', `Bearer ${finance}`); + expect(paid.status).toBe(201); + expect(paid.body.data.status).toBe('PAID'); + }, 60000); + + it('occupies registry assets and office applies land on purchase occupancy calendar', async () => { + const owner = await login('owner', 'Demo@123456'); + const employee = await login('employee', 'Demo@123456'); + const stamp = Date.now(); + + const nameless = await request(app.getHttpServer()) + .post('/api/v1/assets') + .set('Authorization', `Bearer ${owner}`) + .send({ assetName: '手填器材' }); + expect(nameless.status).toBe(400); + + const laptop = await request(app.getHttpServer()) + .post('/api/v1/registered-assets') + .set('Authorization', `Bearer ${owner}`) + .send({ kind: 'DIGITAL', name: `渲染账号 ${stamp}`, code: `DIG-${stamp}` }); + expect(laptop.status).toBe(201); + + const useApply = await request(app.getHttpServer()) + .post('/api/v1/office/applies') + .set('Authorization', `Bearer ${employee}`) + .send({ + kind: 'ASSET_USE', + assetKind: 'DIGITAL', + registeredAssetId: laptop.body.data.id, + reason: '项目渲染', + endAt: '2026-09-15', + }); + expect(useApply.status).toBe(201); + expect(useApply.body.data.status).toBe('PENDING'); + + const useOk = await request(app.getHttpServer()) + .post(`/api/v1/office/applies/${useApply.body.data.id}/decide`) + .set('Authorization', `Bearer ${owner}`) + .send({ result: 'APPROVED' }); + expect(useOk.status).toBe(201); + expect(useOk.body.data.status).toBe('APPROVED'); + expect(useOk.body.data.occupancy?.status).toBe('IN_USE'); + + const buy = await request(app.getHttpServer()) + .post('/api/v1/office/applies') + .set('Authorization', `Bearer ${employee}`) + .send({ + kind: 'ASSET_BUY', + assetKind: 'FIXED', + title: '摄影灯套装', + amount: 1280, + reason: '补充灯光', + }); + expect(buy.status).toBe(201); + const buyOk = await request(app.getHttpServer()) + .post(`/api/v1/office/applies/${buy.body.data.id}/decide`) + .set('Authorization', `Bearer ${owner}`) + .send({ result: 'APPROVED' }); + expect(buyOk.status).toBe(201); + expect(buyOk.body.data.purchase?.status).toBe('APPROVED'); + + const car = await request(app.getHttpServer()) + .post('/api/v1/registered-assets') + .set('Authorization', `Bearer ${owner}`) + .send({ kind: 'FIXED', name: `商务车 ${stamp}`, code: `CAR-${stamp}` }); + expect(car.status).toBe(201); + const vehicle = await request(app.getHttpServer()) + .post('/api/v1/office/applies') + .set('Authorization', `Bearer ${employee}`) + .send({ + kind: 'VEHICLE', + registeredAssetId: car.body.data.id, + destination: '南京政务中心', + startAt: '2026-09-01', + endAt: '2026-09-02', + reason: '送标', + }); + expect(vehicle.status).toBe(201); + const vehicleOk = await request(app.getHttpServer()) + .post(`/api/v1/office/applies/${vehicle.body.data.id}/decide`) + .set('Authorization', `Bearer ${owner}`) + .send({ result: 'APPROVED' }); + expect(vehicleOk.status).toBe(201); + expect(vehicleOk.body.data.occupancy?.status).toBe('IN_USE'); + + const cal = await request(app.getHttpServer()) + .get('/api/v1/office/calendar') + .set('Authorization', `Bearer ${employee}`); + expect(cal.status).toBe(200); + const kinds = (cal.body.data as { kind: string; title: string }[]).map((i) => i.kind); + expect(kinds).toEqual(expect.arrayContaining(['VEHICLE'])); + }, 30000); +}); diff --git a/apps/api/test/jest-e2e.json b/apps/api/test/jest-e2e.json new file mode 100644 index 0000000..e9d912f --- /dev/null +++ b/apps/api/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 0000000..2c8f61f --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..4c9da5d --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": true, + "isolatedModules": true, + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2023", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "noFallthroughCasesInSwitch": true + } +} diff --git a/apps/harmony/.gitignore b/apps/harmony/.gitignore new file mode 100644 index 0000000..bc632d9 --- /dev/null +++ b/apps/harmony/.gitignore @@ -0,0 +1,10 @@ +/node_modules +/.hvigor +**/build +**/.preview +**/.cxx +local.properties +oh_modules +.idea +*.hap +*.app diff --git a/apps/harmony/AppScope/app.json5 b/apps/harmony/AppScope/app.json5 new file mode 100644 index 0000000..66a44d6 --- /dev/null +++ b/apps/harmony/AppScope/app.json5 @@ -0,0 +1,10 @@ +{ + "app": { + "bundleName": "com.fengying.oa.mobile", + "vendor": "fengying", + "versionCode": 301000, + "versionName": "3.0.1", + "icon": "$media:app_icon", + "label": "$string:app_name" + } +} diff --git a/apps/harmony/AppScope/resources/base/element/string.json b/apps/harmony/AppScope/resources/base/element/string.json new file mode 100644 index 0000000..e9faa3b --- /dev/null +++ b/apps/harmony/AppScope/resources/base/element/string.json @@ -0,0 +1,5 @@ +{ + "string": [ + { "name": "app_name", "value": "风影智慧办公" } + ] +} diff --git a/apps/harmony/AppScope/resources/base/media/app_icon.png b/apps/harmony/AppScope/resources/base/media/app_icon.png new file mode 100644 index 0000000..cea1702 Binary files /dev/null and b/apps/harmony/AppScope/resources/base/media/app_icon.png differ diff --git a/apps/harmony/build-profile.json5 b/apps/harmony/build-profile.json5 new file mode 100644 index 0000000..aa0497e --- /dev/null +++ b/apps/harmony/build-profile.json5 @@ -0,0 +1,49 @@ +{ + "app": { + "signingConfigs": [ + { + "name": "release", + "type": "HarmonyOS", + "material": { + "certpath": "/opt/huawei/harmony/fengying.cer", + "storePassword": "FyHarmony#2026oa", + "keyAlias": "fengying", + "keyPassword": "FyHarmony#2026oa", + "profile": "/opt/huawei/harmony/fengyingRelease.p7b", + "signAlg": "SHA256withECDSA", + "storeFile": "/opt/huawei/harmony/fengying.p12" + } + } + ], + "products": [ + { + "name": "default", + "compatibleSdkVersion": "5.0.5(17)", + "targetSdkVersion": "6.1.0(23)", + "compileSdkVersion": "6.1.0(23)", + "runtimeOS": "HarmonyOS", + "buildOption": { + "strictMode": { + "useNormalizedOHMUrl": true + } + } + } + ], + "buildModeSet": [ + { "name": "debug" }, + { "name": "release" } + ] + }, + "modules": [ + { + "name": "entry", + "srcPath": "./entry", + "targets": [ + { + "name": "default", + "applyToProducts": ["default"] + } + ] + } + ] +} diff --git a/apps/harmony/entry/build-profile.json5 b/apps/harmony/entry/build-profile.json5 new file mode 100644 index 0000000..f61bbfe --- /dev/null +++ b/apps/harmony/entry/build-profile.json5 @@ -0,0 +1,26 @@ +{ + "apiType": "stageMode", + "buildOption": { + "resOptions": { + "copyCodeResource": { + "enable": false + } + } + }, + "buildOptionSet": [ + { + "name": "release", + "arkOptions": { + "obfuscation": { + "ruleOptions": { + "enable": false, + "files": [] + } + } + } + } + ], + "targets": [ + { "name": "default" } + ] +} diff --git a/apps/harmony/entry/hvigorfile.ts b/apps/harmony/entry/hvigorfile.ts new file mode 100644 index 0000000..567099d --- /dev/null +++ b/apps/harmony/entry/hvigorfile.ts @@ -0,0 +1,6 @@ +import { hapTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: hapTasks, + plugins: [] +}; diff --git a/apps/harmony/entry/oh-package.json5 b/apps/harmony/entry/oh-package.json5 new file mode 100644 index 0000000..15cdcd8 --- /dev/null +++ b/apps/harmony/entry/oh-package.json5 @@ -0,0 +1,9 @@ +{ + "name": "entry", + "version": "1.0.0", + "description": "风影智慧办公", + "main": "", + "author": "", + "license": "Proprietary", + "dependencies": {} +} diff --git a/apps/harmony/entry/src/main/ets/entryability/EntryAbility.ets b/apps/harmony/entry/src/main/ets/entryability/EntryAbility.ets new file mode 100644 index 0000000..41804c8 --- /dev/null +++ b/apps/harmony/entry/src/main/ets/entryability/EntryAbility.ets @@ -0,0 +1,57 @@ +import { AbilityConstant, UIAbility, Want, abilityAccessCtrl, Permissions, common } from '@kit.AbilityKit'; +import { window } from '@kit.ArkUI'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { notificationManager } from '@kit.NotificationKit'; +import { pushService } from '@kit.PushKit'; + +const TAG = 'FengyingOA'; +const PERMS: Permissions[] = [ + 'ohos.permission.CAMERA', + 'ohos.permission.MICROPHONE', + 'ohos.permission.APPROXIMATELY_LOCATION', + 'ohos.permission.LOCATION', + 'ohos.permission.READ_IMAGEVIDEO', + 'ohos.permission.WRITE_IMAGEVIDEO', +]; + +export default class EntryAbility extends UIAbility { + onCreate(want: Want, _launchParam: AbilityConstant.LaunchParam): void { + AppStorage.setOrCreate('pushToken', ''); + AppStorage.setOrCreate('launchUri', want?.uri ?? ''); + notificationManager.requestEnableNotification(this.context).catch((err: BusinessError) => { + hilog.warn(0x0000, TAG, 'notify perm %{public}s', JSON.stringify(err)); + }); + pushService.getToken().then((token: string) => { + if (token) { + AppStorage.setOrCreate('pushToken', token); + hilog.info(0x0000, TAG, 'push token ok'); + } + }).catch((err: BusinessError) => { + hilog.error(0x0000, TAG, 'getToken %{public}d %{public}s', err.code, err.message); + }); + } + + onNewWant(want: Want): void { + if (want?.uri) { + AppStorage.setOrCreate('launchUri', want.uri); + } + } + + onWindowStageCreate(windowStage: window.WindowStage): void { + windowStage.loadContent('pages/Index').then(() => { + this.askUserPerms(); + }).catch((err: BusinessError) => { + hilog.error(0x0000, TAG, 'loadContent %{public}s', JSON.stringify(err)); + }); + } + + private askUserPerms(): void { + const ctx = this.context as common.UIAbilityContext; + abilityAccessCtrl.createAtManager() + .requestPermissionsFromUser(ctx, PERMS) + .catch((err: BusinessError) => { + hilog.warn(0x0000, TAG, 'user perm %{public}s', JSON.stringify(err)); + }); + } +} diff --git a/apps/harmony/entry/src/main/ets/pages/Index.ets b/apps/harmony/entry/src/main/ets/pages/Index.ets new file mode 100644 index 0000000..2165550 --- /dev/null +++ b/apps/harmony/entry/src/main/ets/pages/Index.ets @@ -0,0 +1,65 @@ +import { webview } from '@kit.ArkWeb'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +const OA_URL = 'https://oa.fysxkj.com'; +const TAG = 'FengyingOA'; + +@Entry +@Component +struct Index { + controller: webview.WebviewController = new webview.WebviewController(); + @StorageLink('pushToken') pushToken: string = ''; + @StorageLink('launchUri') launchUri: string = ''; + + build() { + Column() { + Web({ src: OA_URL, controller: this.controller }) + .width('100%') + .layoutWeight(1) + .javaScriptAccess(true) + .domStorageAccess(true) + .fileAccess(true) + .geolocationAccess(true) + .mediaPlayGestureAccess(true) + .onGeolocationShow((event) => { + event.geolocation.invoke(event.origin, true, false); + }) + .onPermissionRequest((event) => { + event.request.grant(event.request.getAccessibleResource()); + }) + .onPageEnd(() => { + this.injectNative(); + }) + } + .width('100%') + .height('100%') + } + + private injectNative(): void { + const token = this.pushToken.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + const deep = this.launchUri.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + const js = ` + (function(){ + window.__fyPushToken='${token}'; + window.__fyPushVendor='huawei'; + window.__fyDeepLink='${deep}'; + function once(){ + var t=sessionStorage.getItem('accessToken'); + if(!t || !window.__fyPushToken) return; + if(window.__fyPushSent===t+window.__fyPushToken) return; + window.__fyPushSent=t+window.__fyPushToken; + fetch('/api/v1/push/devices',{ + method:'POST', + headers:{'Content-Type':'application/json','Authorization':'Bearer '+t}, + body:JSON.stringify({vendor:'huawei',regId:window.__fyPushToken,brand:'HarmonyOS',model:'phone'}) + }).catch(function(){}); + } + once(); + setInterval(once, 4000); + })(); + `; + this.controller.runJavaScript(js).catch(() => { + hilog.warn(0x0000, TAG, 'inject js fail'); + }); + } +} diff --git a/apps/harmony/entry/src/main/ets/pushability/PushMessageAbility.ets b/apps/harmony/entry/src/main/ets/pushability/PushMessageAbility.ets new file mode 100644 index 0000000..4cce4f0 --- /dev/null +++ b/apps/harmony/entry/src/main/ets/pushability/PushMessageAbility.ets @@ -0,0 +1,13 @@ +import { PushExtensionAbility, pushCommon } from '@kit.PushKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +const TAG = 'FengyingOA'; + +export default class PushMessageAbility extends PushExtensionAbility { + onReceiveMessage(message: pushCommon.PushPayload): void { + hilog.info(0x0000, TAG, 'push data %{public}s', JSON.stringify(message)); + } + + onDestroy(): void { + } +} diff --git a/apps/harmony/entry/src/main/module.json5 b/apps/harmony/entry/src/main/module.json5 new file mode 100644 index 0000000..44df847 --- /dev/null +++ b/apps/harmony/entry/src/main/module.json5 @@ -0,0 +1,87 @@ +{ + "module": { + "name": "entry", + "type": "entry", + "description": "$string:module_desc", + "mainElement": "EntryAbility", + "deviceTypes": ["phone", "tablet"], + "deliveryWithInstall": true, + "installationFree": false, + "pages": "$profile:main_pages", + "abilities": [ + { + "name": "EntryAbility", + "srcEntry": "./ets/entryability/EntryAbility.ets", + "description": "$string:EntryAbility_desc", + "icon": "$media:app_icon", + "label": "$string:EntryAbility_label", + "startWindowIcon": "$media:app_icon", + "startWindowBackground": "$color:start_window_background", + "exported": true, + "skills": [ + { + "entities": ["entity.system.home"], + "actions": ["ohos.want.action.home"] + }, + { + "entities": ["entity.system.browsable"], + "actions": ["ohos.want.action.viewData"], + "uris": [ + { "scheme": "fengyingoa", "host": "chat" }, + { "scheme": "fengyingoa", "host": "qr" } + ] + } + ] + } + ], + "extensionAbilities": [ + { + "name": "PushMessageAbility", + "srcEntry": "./ets/pushability/PushMessageAbility.ets", + "type": "push", + "exported": false, + "description": "华为推送" + } + ], + "requestPermissions": [ + { "name": "ohos.permission.INTERNET" }, + { "name": "ohos.permission.GET_NETWORK_INFO" }, + { + "name": "ohos.permission.CAMERA", + "reason": "$string:perm_camera", + "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } + }, + { + "name": "ohos.permission.MICROPHONE", + "reason": "$string:perm_mic", + "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } + }, + { + "name": "ohos.permission.APPROXIMATELY_LOCATION", + "reason": "$string:perm_location", + "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } + }, + { + "name": "ohos.permission.LOCATION", + "reason": "$string:perm_location", + "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } + }, + { + "name": "ohos.permission.READ_IMAGEVIDEO", + "reason": "$string:perm_media", + "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } + }, + { + "name": "ohos.permission.WRITE_IMAGEVIDEO", + "reason": "$string:perm_media", + "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } + } + ], + "metadata": [ + { + "name": "client_id", + "value": "6917615149085387132" + } + ] + } +} diff --git a/apps/harmony/entry/src/main/resources/base/element/color.json b/apps/harmony/entry/src/main/resources/base/element/color.json new file mode 100644 index 0000000..9b173cf --- /dev/null +++ b/apps/harmony/entry/src/main/resources/base/element/color.json @@ -0,0 +1,5 @@ +{ + "color": [ + { "name": "start_window_background", "value": "#5B4FE3" } + ] +} diff --git a/apps/harmony/entry/src/main/resources/base/element/string.json b/apps/harmony/entry/src/main/resources/base/element/string.json new file mode 100644 index 0000000..f56bd75 --- /dev/null +++ b/apps/harmony/entry/src/main/resources/base/element/string.json @@ -0,0 +1,11 @@ +{ + "string": [ + { "name": "module_desc", "value": "风影智慧办公鸿蒙客户端" }, + { "name": "EntryAbility_desc", "value": "风影智慧办公" }, + { "name": "EntryAbility_label", "value": "风影智慧办公" }, + { "name": "perm_camera", "value": "用于扫码登录、拍照上传附件" }, + { "name": "perm_mic", "value": "用于即时通讯语音消息" }, + { "name": "perm_location", "value": "用于出差打卡、地图选点" }, + { "name": "perm_media", "value": "用于选择和保存图片、文件" } + ] +} diff --git a/apps/harmony/entry/src/main/resources/base/media/app_icon.png b/apps/harmony/entry/src/main/resources/base/media/app_icon.png new file mode 100644 index 0000000..cea1702 Binary files /dev/null and b/apps/harmony/entry/src/main/resources/base/media/app_icon.png differ diff --git a/apps/harmony/entry/src/main/resources/base/profile/main_pages.json b/apps/harmony/entry/src/main/resources/base/profile/main_pages.json new file mode 100644 index 0000000..1898d94 --- /dev/null +++ b/apps/harmony/entry/src/main/resources/base/profile/main_pages.json @@ -0,0 +1,5 @@ +{ + "src": [ + "pages/Index" + ] +} diff --git a/apps/harmony/entry/src/main/resources/rawfile/agconnect-services.json b/apps/harmony/entry/src/main/resources/rawfile/agconnect-services.json new file mode 100644 index 0000000..021ab01 --- /dev/null +++ b/apps/harmony/entry/src/main/resources/rawfile/agconnect-services.json @@ -0,0 +1 @@ +{"agcgw":{"url":"connect-drcn.dbankcloud.cn","backurl":"connect-drcn.hispace.hicloud.com","websocketurl":"connect-ws-drcn.hispace.dbankcloud.cn","websocketbackurl":"connect-ws-drcn.hispace.dbankcloud.com"},"agcgw_all":{"SG":"connect-dra.dbankcloud.cn","SG_back":"connect-dra.hispace.hicloud.com","CN":"connect-drcn.dbankcloud.cn","CN_back":"connect-drcn.hispace.hicloud.com","RU":"connect-drru.hispace.dbankcloud.ru","RU_back":"connect-drru.hispace.dbankcloud.cn","DE":"connect-dre.dbankcloud.cn","DE_back":"connect-dre.hispace.hicloud.com"},"websocketgw_all":{"SG":"connect-ws-dra.hispace.dbankcloud.cn","SG_back":"connect-ws-dra.hispace.dbankcloud.com","CN":"connect-ws-drcn.hispace.dbankcloud.cn","CN_back":"connect-ws-drcn.hispace.dbankcloud.com","RU":"connect-ws-drru.hispace.dbankcloud.ru","RU_back":"connect-ws-drru.hispace.dbankcloud.cn","DE":"connect-ws-dre.hispace.dbankcloud.cn","DE_back":"connect-ws-dre.hispace.dbankcloud.com"},"client":{"cp_id":"10086000847744494","product_id":"101653523864520415","client_id":"1993069421077225280","client_secret":"[!00A9C94D03C4BF55EA6519B1CF01153AB697CC8211E399E829DA73A990F958613D6F5F01F1B8BA8CF74DB5933C003EAE4D1A1657300CA65BA1E683ADFBB26E6CCA45A3F7BB34D44507209E4BB2E5AFFE8E8638749171D9720E7B982E71D04C90A8]","project_id":"101653523864520415","app_id":"6917615149085387132","api_key":"[!004452080D1B4651C537B84F0961D936D6438A360469CDDFD0B606B67BA71B6DF1DCF265C6FB898FAB736110DFAB338E35D3F7B943D49CBA4C0680A8682E29F298CDC17AF4456F592F0AD5EA63DAD95EADE168E41FF71F2048E358A9B469E06D2B391F558B01802AD2558D763ABD03D93126D342B2B02B54B9D21A726B3D07CC5D]","package_name":"com.fengying.oa.mobile"},"oauth_client":{"client_id":"6917615149085387132","client_type":30},"app_info":{"app_id":"6917615149085387132","package_name":"com.fengying.oa.mobile"},"code":{"code1":"3349BBB4FB01961D41511945F371A779","code2":"0A2C1415D785F047B1C3D121AD7C7529","code3":"3C860679A443A35E092B4378B7E3466A","code4":"3DEE6F9A95E1D2512A921DA41B96ED82"},"service":{"analytics":{"collector_url":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_cn":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_de":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn","collector_url_ru":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com","collector_url_sg":"datacollector-dra.dt.hicloud.com,datacollector-dra.dt.dbankcloud.cn","resource_id":"p1","channel_id":""},"ml":{"mlservice_url":"ml-api-drcn.ai.dbankcloud.com,ml-api-drcn.ai.dbankcloud.cn"},"cloudstorage":{"storage_url":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg":"https://ops-dra.agcstorage.link","storage_url_de":"https://ops-dre.agcstorage.link","storage_url_cn":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru_back":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg_back":"https://agc-storage-dra.cloud.huawei.asia","storage_url_de_back":"https://agc-storage-dre.cloud.huawei.eu","storage_url_cn_back":"https://agc-storage-drcn.cloud.huawei.com.cn"},"search":{"url":"https://search-drcn.cloud.huawei.com"},"edukit":{"edu_url":"edukit.cloud.huawei.com.cn","dh_url":"edukit.cloud.huawei.com.cn"}},"region":"CN","configuration_version":"3.0","appInfos":[{"package_name":"com.fengying.oa.mobile","client":{"client_secret":"[!007C7452FF690E0A79490D8BB5131A3AE11AAABC74685A7C5AF4CF72FB41C6987E3868AAFE41004FF5A80995FFB4D783723634ACA37F5248ABA50110BB687D1DBCDD1769F5896B688D788B32C26F7F960A0C10B8DC5D7B4BBAF06DAD603B6F3AC8]","app_id":"6917615149085387132","api_key":"[!006FD5F294EDA4E1636B94706055D7E9A80BF64D9DC898C6D5C929ABE8C626ABEEEDFA41EDFB04779BD60FF776C00D1ABC9108A9E98BE5A55ADD5F39DE84149EF1AAF494E9075A51594DA05CD2BB5816E35C8969AE95368AFB1166C3308A3542084BACE7F883CB6D020E7130ACB5AB2122B34AE2648D8DC0F5F4BFC054482A002E]"},"code":{"code1":"E371F4D9A118D7ADA53F1A5BEC3E15C6004674FE3E029E6435264AEBA83A26B1","code2":"2C669AB03EF94DE3EBB1976F8A7478A3E512B9D7D5DD5E26B3758AF83B52E0B5","code3":"FFA3797D46B5F30F4F741B79793C388352F77CB05796E76DC91325CD36DAE505","code4":"44302FD51983592DB6B3EB4FA6E74FB9"}}]} \ No newline at end of file diff --git a/apps/harmony/hvigor/hvigor-config.json5 b/apps/harmony/hvigor/hvigor-config.json5 new file mode 100644 index 0000000..428c18a --- /dev/null +++ b/apps/harmony/hvigor/hvigor-config.json5 @@ -0,0 +1,7 @@ +{ + "modelVersion": "6.0.1", + "dependencies": {}, + "execution": { + "daemon": false + } +} diff --git a/apps/harmony/hvigorfile.ts b/apps/harmony/hvigorfile.ts new file mode 100644 index 0000000..9ed4942 --- /dev/null +++ b/apps/harmony/hvigorfile.ts @@ -0,0 +1,6 @@ +import { appTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: appTasks, + plugins: [] +}; diff --git a/apps/harmony/oh-package-lock.json5 b/apps/harmony/oh-package-lock.json5 new file mode 100644 index 0000000..96541d5 --- /dev/null +++ b/apps/harmony/oh-package-lock.json5 @@ -0,0 +1,20 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "@ohos/hypium@1.0.21": "@ohos/hypium@1.0.21" + }, + "packages": { + "@ohos/hypium@1.0.21": { + "name": "@ohos/hypium", + "version": "1.0.21", + "integrity": "sha512-iyKGMXxE+9PpCkqEwu0VykN/7hNpb+QOeIuHwkmZnxOpI+dFZt6yhPB7k89EgV1MiSK/ieV/hMjr5Z2mWwRfMQ==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.21.har", + "registryType": "ohpm" + } + } +} \ No newline at end of file diff --git a/apps/harmony/oh-package.json5 b/apps/harmony/oh-package.json5 new file mode 100644 index 0000000..14544b6 --- /dev/null +++ b/apps/harmony/oh-package.json5 @@ -0,0 +1,8 @@ +{ + "modelVersion": "6.0.1", + "description": "风影智慧办公 鸿蒙版", + "dependencies": {}, + "devDependencies": { + "@ohos/hypium": "1.0.21" + } +} diff --git a/apps/im-server/README.md b/apps/im-server/README.md new file mode 100644 index 0000000..fbde75b --- /dev/null +++ b/apps/im-server/README.md @@ -0,0 +1,10 @@ +# MobileIMSDK TCP 服务端 + +官方协议:4 字节大端 body 长度 + UTF-8 JSON `Protocal`。 + +- TCP `8901`:客户端登录 / 心跳 / 私聊 / QoS +- HTTP `127.0.0.1:8902`:`/health` `/online` `/push`(仅本机,OA 推待办/审批) + +登录:`loginUserId` = OA 员工 id,`loginToken` = OA `accessToken`,服务端调 `GET /auth/me` 校验。 + +环境变量见同目录 `.env`,生产由 `fengying-im.service` 读取 API 的 `.env`。 diff --git a/apps/im-server/package.json b/apps/im-server/package.json new file mode 100644 index 0000000..db58035 --- /dev/null +++ b/apps/im-server/package.json @@ -0,0 +1,13 @@ +{ + "name": "fengying-im-server", + "private": true, + "version": "1.0.0", + "description": "MobileIMSDK TCP server for 风影智慧办公", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=18" + } +} diff --git a/apps/im-server/server.js b/apps/im-server/server.js new file mode 100644 index 0000000..2177e9e --- /dev/null +++ b/apps/im-server/server.js @@ -0,0 +1,324 @@ +#!/usr/bin/env node +/** + * MobileIMSDK TCP 服务端(官方协议): + * 4 字节大端长度 + UTF-8 JSON Protocal。 + * 登录 token 走 OA GET /auth/me。 + * 同一账号允许多端同时在线(手机 + 网页),重连不再把本机踢掉。 + */ +const net = require('net'); +const http = require('http'); +const crypto = require('crypto'); + +const TCP_PORT = Number(process.env.IM_TCP_PORT || 8901); +const HTTP_PORT = Number(process.env.IM_HTTP_PORT || 8902); +const OA_API = (process.env.OA_API_URL || 'http://127.0.0.1:3010/api/v1').replace(/\/$/, ''); +const INTERNAL = process.env.IM_INTERNAL_TOKEN || ''; +const MAX_BODY = 6 * 1024; + +const C = { + LOGIN: 0, + KEEP_ALIVE: 1, + COMMON_DATA: 2, + LOGOUT: 3, + RECIVED: 4, + ECHO: 5, +}; +const S = { + LOGIN: 50, + KEEP_ALIVE: 51, + ERROR: 52, + ECHO: 53, + KICKOUT: 54, +}; + +/** @type {Map>} */ +const online = new Map(); +/** @type {WeakMap} */ +const sessions = new WeakMap(); + +function frame(obj) { + const body = Buffer.from(JSON.stringify(obj), 'utf8'); + const header = Buffer.alloc(4); + header.writeUInt32BE(body.length, 0); + return Buffer.concat([header, body]); +} + +function send(socket, obj) { + if (!socket || socket.destroyed) return false; + try { + socket.write(frame(obj)); + return true; + } catch { + return false; + } +} + +function addOnline(userId, socket) { + let set = online.get(userId); + if (!set) { + set = new Set(); + online.set(userId, set); + } + set.add(socket); +} + +function removeSocket(userId, socket) { + const set = online.get(userId); + if (!set) return; + set.delete(socket); + if (set.size === 0) online.delete(userId); +} + +function sendToUser(userId, obj) { + const set = online.get(userId); + if (!set) return false; + let ok = false; + for (const s of set) ok = send(s, obj) || ok; + return ok; +} + +function protocal(type, dataContent, from, to, extra = {}) { + return { + bridge: false, + type, + dataContent, + from: from || '0', + to: to || '0', + fp: extra.fp || null, + QoS: Boolean(extra.QoS), + typeu: extra.typeu ?? -1, + sm: extra.sm ?? -1, + }; +} + +async function verifyLogin(userId, token) { + if (!userId || !token) return false; + try { + const res = await fetch(`${OA_API}/auth/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return false; + const json = await res.json(); + const me = json.data || json; + return me && me.id === userId && me.status !== 'LEFT'; + } catch (e) { + console.warn('verify login', e.message); + return false; + } +} + +async function ingest(fromUserId, toUserId, body, fingerprint, extra = {}) { + if (!INTERNAL) return; + try { + await fetch(`${OA_API}/im/internal/ingest`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-im-token': INTERNAL }, + body: JSON.stringify({ + fromUserId, + toUserId, + kind: extra.kind || 'chat', + body, + fingerprint, + conversationId: extra.conversationId, + contentType: extra.contentType, + meta: extra.meta, + }), + }); + } catch (e) { + console.warn('ingest', e.message); + } +} + +async function membersOf(conversationId) { + if (!INTERNAL || !conversationId) return []; + try { + const res = await fetch(`${OA_API}/im/internal/members/${conversationId}`, { + headers: { 'x-im-token': INTERNAL }, + }); + if (!res.ok) return []; + const json = await res.json(); + const data = json.data || json; + return data.userIds || []; + } catch { + return []; + } +} + +async function handle(socket, p) { + const type = Number(p.type); + if (type === C.LOGIN) { + let info = {}; + try { + info = JSON.parse(p.dataContent || '{}'); + } catch { + info = {}; + } + const userId = info.loginUserId || p.from; + const ok = await verifyLogin(userId, info.loginToken); + if (!ok) { + send(socket, protocal(S.LOGIN, JSON.stringify({ code: 1, firstLoginTime: -1 }), '0', '-1')); + socket.end(); + return; + } + const firstLoginTime = Number(info.firstLoginTime) > 0 ? Number(info.firstLoginTime) : Date.now(); + const sess = sessions.get(socket) || { buf: Buffer.alloc(0) }; + sess.userId = userId; + sessions.set(socket, sess); + addOnline(userId, socket); + send(socket, protocal(S.LOGIN, JSON.stringify({ code: 0, firstLoginTime }), '0', userId)); + console.log(`login ok ${userId} sockets=${online.get(userId)?.size || 0} users=${online.size}`); + return; + } + + const sess = sessions.get(socket); + if (!sess?.userId) { + socket.end(); + return; + } + const userId = sess.userId; + + if (type === C.KEEP_ALIVE) { + send(socket, protocal(S.KEEP_ALIVE, '{}', '0', userId)); + return; + } + if (type === C.LOGOUT) { + removeSocket(userId, socket); + socket.end(); + return; + } + if (type === C.ECHO) { + send(socket, protocal(S.ECHO, p.dataContent || '', '0', userId)); + return; + } + if (type === C.RECIVED) { + return; + } + if (type === C.COMMON_DATA) { + const fp = p.fp || crypto.randomUUID(); + let parsed = {}; + try { + parsed = JSON.parse(p.dataContent || '{}'); + } catch { + parsed = {}; + } + const convId = parsed.conversationId || ''; + const typeu = Number(p.typeu ?? -1); + const payload = protocal(C.COMMON_DATA, p.dataContent, userId, p.to, { + QoS: p.QoS, + fp, + typeu: p.typeu, + sm: Date.now(), + }); + let targets = []; + if (typeu === 2 && convId) { + targets = (await membersOf(convId)).filter((id) => id !== userId); + } else if (p.to) { + targets = [p.to]; + } + for (const to of targets) { + sendToUser(to, { ...payload, to }); + } + if (p.QoS) { + send(socket, protocal(C.RECIVED, fp, p.to || '0', userId)); + } + if (typeu === 3 || parsed.kind === 'signal') return; + const text = String(parsed.text || parsed.title || p.dataContent || ''); + await ingest(userId, p.to, text, fp, { + kind: parsed.kind || 'chat', + conversationId: convId, + contentType: parsed.contentType, + meta: parsed.meta, + }); + } +} + +const tcp = net.createServer((socket) => { + sessions.set(socket, { buf: Buffer.alloc(0) }); + socket.on('data', (chunk) => { + const sess = sessions.get(socket); + if (!sess) return; + sess.buf = Buffer.concat([sess.buf, chunk]); + while (sess.buf.length >= 4) { + const len = sess.buf.readUInt32BE(0); + if (len <= 0 || len > MAX_BODY) { + socket.end(); + return; + } + if (sess.buf.length < 4 + len) break; + const body = sess.buf.subarray(4, 4 + len).toString('utf8'); + sess.buf = sess.buf.subarray(4 + len); + try { + void handle(socket, JSON.parse(body)); + } catch (e) { + console.warn('bad packet', e.message); + } + } + }); + socket.on('close', () => { + const sess = sessions.get(socket); + if (sess?.userId) removeSocket(sess.userId, socket); + }); + socket.on('error', () => {}); + socket.setTimeout(90000, () => socket.end()); +}); + +const httpServer = http.createServer((req, res) => { + const url = new URL(req.url || '/', 'http://127.0.0.1'); + if (req.method === 'GET' && url.pathname === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, online: online.size, tcp: TCP_PORT })); + return; + } + if (req.method === 'GET' && url.pathname === '/online') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ count: online.size, users: [...online.keys()] })); + return; + } + if (req.method === 'POST' && url.pathname === '/push') { + const token = req.headers['x-im-token']; + if (!INTERNAL || token !== INTERNAL) { + res.writeHead(401); + res.end('forbidden'); + return; + } + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + try { + const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + const to = body.toUserId; + let parsed = {}; + try { + parsed = JSON.parse(body.dataContent || '{}'); + } catch { + parsed = {}; + } + const seq = Number(parsed.seq ?? body.seq ?? 0); + const delivered = sendToUser( + to, + protocal(C.COMMON_DATA, body.dataContent || '', '0', to, { + QoS: true, + fp: parsed.fingerprint || crypto.randomUUID(), + typeu: 1, + sm: seq > 0 ? seq : Date.now(), + }), + ); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, delivered })); + } catch (e) { + res.writeHead(400); + res.end(e.message); + } + }); + return; + } + res.writeHead(404); + res.end(); +}); + +tcp.listen(TCP_PORT, '0.0.0.0', () => { + console.log(`MobileIMSDK TCP ${TCP_PORT}`); +}); +httpServer.listen(HTTP_PORT, '127.0.0.1', () => { + console.log(`IM HTTP push ${HTTP_PORT}`); +}); diff --git a/apps/native/.gitignore b/apps/native/.gitignore new file mode 100644 index 0000000..81f47c7 --- /dev/null +++ b/apps/native/.gitignore @@ -0,0 +1,46 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/dist/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/apps/native/.metadata b/apps/native/.metadata new file mode 100644 index 0000000..ec911c6 --- /dev/null +++ b/apps/native/.metadata @@ -0,0 +1,36 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d8a9f9a52e5af486f80d932e838ee93861ffd863" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + - platform: android + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + - platform: linux + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + - platform: windows + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/native/README.md b/apps/native/README.md new file mode 100644 index 0000000..2ee05b9 --- /dev/null +++ b/apps/native/README.md @@ -0,0 +1,55 @@ +# 风影办公 · 原生客户端 + +一套 Flutter 代码,Android APK + Windows exe。界面按微信 / 企业微信手机端重做,不嵌 OA 网页。 + +底栏:消息 · 工作台 · 通讯录 · 我。 + +- 消息:会话列表、未读红点、全部/未读/单聊/群聊筛选,待办和待审批钉在列表顶部 +- 工作台:应用宫格 + 待处理数字 + 新版本横幅 +- 通讯录:组织架构、按姓氏排序、点人发消息 +- 我:检查更新(OTA)、连接设置、退出 + +默认连接: + +- REST:`https://oa.fysxkj.com/api/v1` +- IM:`47.96.23.244:8901`(MobileIMSDK TCP) + +当前版本:`1.1.0`(build `2`)。 + +## Android + +```bash +flutter pub get +flutter build apk --release +``` + +产物:`build/app/outputs/flutter-apk/app-release.apk` + +发布到服务器后,用户打开 App 会自动检查升级;也可在「我 → 检查更新」手动检查。 + +```bash +# 把 APK 放到下载地址,并登记版本 +mkdir -p /data/oa-releases +cp build/app/outputs/flutter-apk/app-release.apk /data/oa-releases/fengying-oa.apk +# 管理员登录后: +# PATCH /api/v1/system/app-release +# { "version": "1.1.0", "build": 2, "url": "https://oa.fysxkj.com/fengying-oa.apk", "changelog": "..." } +``` + +公开接口 `GET /api/v1/system/app-release` 返回最新包信息。客户端比较 `build`,更大则提示下载并安装。 + +## Windows + +在 Windows 安装 Flutter 与 Visual Studio(Desktop development with C++)后: + +```bat +build-windows.bat +``` + +exe 在 `build\windows\x64\runner\Release\fengying_oa.exe`。 + +本 Linux 构建机不能交叉编译 Windows 桌面包。 + +## 登录前 init + +Android `MainActivity` 通过 `com.fysxkj.oa/im` 暴露 `init` / `release`,通过 `com.fysxkj.oa/ota` 安装下载好的 APK。 diff --git a/apps/native/WINDOWS_BUILD.md b/apps/native/WINDOWS_BUILD.md new file mode 100644 index 0000000..5adaa9e --- /dev/null +++ b/apps/native/WINDOWS_BUILD.md @@ -0,0 +1,9 @@ +# Windows 桌面端打包 + +打包机 `10.1.1.202`,SSH **端口 22022**,用户 `administrator`。 + +1. 安装工具链(只需一次):`installer\bootstrap-windows.ps1` +2. 在 `C:\fengying-oa-native` 执行 `.\build-windows.ps1` + +产物必须是可安装的 **`build\fengying-oa-setup.exe`**(Inno Setup),不要只发 zip。 +上传到 `https://oa.fysxkj.com/fengying-oa-setup.exe`,并写入 `apps/api/data/app-release.json` 的 `windowsUrl`。 diff --git a/apps/native/analysis_options.yaml b/apps/native/analysis_options.yaml new file mode 100644 index 0000000..c16dc22 --- /dev/null +++ b/apps/native/analysis_options.yaml @@ -0,0 +1,34 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - windows/** + - linux/** +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/apps/native/android/.gitignore b/apps/native/android/.gitignore new file mode 100644 index 0000000..55afd91 --- /dev/null +++ b/apps/native/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/apps/native/android/app/agconnect-services.json b/apps/native/android/app/agconnect-services.json new file mode 100644 index 0000000..4b57ff6 --- /dev/null +++ b/apps/native/android/app/agconnect-services.json @@ -0,0 +1 @@ +{"agcgw_all":{"SG":"connect-dra.dbankcloud.cn","SG_back":"connect-dra.hispace.hicloud.com","CN":"connect-drcn.dbankcloud.cn","CN_back":"connect-drcn.hispace.dbankcloud.cn","RU":"connect-drru.hispace.dbankcloud.ru","RU_back":"connect-drru.hispace.dbankcloud.cn","DE":"connect-dre.dbankcloud.cn","DE_back":"connect-dre.hispace.hicloud.com"},"websocketgw_all":{"SG":"connect-ws-dra.hispace.dbankcloud.cn","SG_back":"connect-ws-dra.hispace.dbankcloud.com","CN":"connect-ws-drcn.dbankcloud.cn","CN_back":"connect-ws-drcn.hispace.dbankcloud.com","RU":"connect-ws-drru.hispace.dbankcloud.ru","RU_back":"connect-ws-drru.hispace.dbankcloud.cn","DE":"connect-dre.hispace.hicloud.com","DE_back":"connect-dre.hispace.hicloud.com"},"client":{"cp_id":"10086000847744494","product_id":"101653523864901451","client_id":"2029248924690199744","client_secret":"2540EB569E83BF5271BC689A63FB0540832E4849A9608BB814D463EFA6754FA4","project_id":"101653523864901451","app_id":"118825899","api_key":"DgEDALOHkJa4k9Q93H74q1jSF7G3GWcDUQIqrJBqwJFC3Bcyrg+TEK1K5FdhJHh++SBQQ/QxRTdQXOO+XOq/8r5qdZ6rGi7ISmKJlw==","package_name":"com.fengying.oa.mobile"},"oauth_client":{"client_id":"118825899","client_type":1},"app_info":{"app_id":"118825899","package_name":"com.fengying.oa.mobile"},"configuration_version":"3.0","appInfos":[{"package_name":"com.fengying.oa.mobile","client":{"app_id":"118825899"},"oauth_client":{"client_id":"118825899","client_type":1},"app_info":{"app_id":"118825899","package_name":"com.fengying.oa.mobile"}}]} diff --git a/apps/native/android/app/build.gradle b/apps/native/android/app/build.gradle new file mode 100644 index 0000000..766b43c --- /dev/null +++ b/apps/native/android/app/build.gradle @@ -0,0 +1,68 @@ +plugins { + id "com.android.application" + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" +} + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +android { + namespace = "com.fysxkj.fengying_oa" + compileSdk = flutter.compileSdkVersion + // LiveKit/WebRTC and the current Android plugins require NDK 27+. + // 28.2 is installed in the build environment and is backwards compatible. + ndkVersion = "28.2.13676358" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + } + + defaultConfig { + applicationId = "com.fengying.oa.mobile" + minSdkVersion = 23 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + signingConfigs { + release { + keyAlias = keystoreProperties["keyAlias"] + keyPassword = keystoreProperties["keyPassword"] + storeFile = keystoreProperties["storeFile"] ? file(keystoreProperties["storeFile"]) : null + storePassword = keystoreProperties["storePassword"] + } + } + + buildTypes { + release { + signingConfig = signingConfigs.release + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + } + } +} + +flutter { + source = "../.." +} + +dependencies { + implementation "vivo:vpush:4.1.5.0" + implementation "com.heytap.msp:push:3.0.0" + implementation files("libs/push-internal-5.0.5.aar") + implementation "com.google.code.gson:gson:2.10.1" + implementation "com.huawei.agconnect:agconnect-core:1.9.0.300" + implementation "com.huawei.hms:push:6.13.0.300" + implementation "com.alphacephei:vosk-android:0.3.75@aar" + implementation "net.java.dev.jna:jna:5.13.0@aar" +} diff --git a/apps/native/android/app/libs/push-internal-5.0.5.aar b/apps/native/android/app/libs/push-internal-5.0.5.aar new file mode 100644 index 0000000..9cbf7bb Binary files /dev/null and b/apps/native/android/app/libs/push-internal-5.0.5.aar differ diff --git a/apps/native/android/app/proguard-rules.pro b/apps/native/android/app/proguard-rules.pro new file mode 100644 index 0000000..943f889 --- /dev/null +++ b/apps/native/android/app/proguard-rules.pro @@ -0,0 +1,19 @@ +-dontwarn com.vivo.push.** +-keep class com.vivo.push.** { *; } +-keep class com.vivo.vms.** { *; } +-dontwarn com.heytap.** +-keep class com.heytap.** { *; } +-dontwarn com.meizu.** +-keep class com.meizu.** { *; } +-keep class com.fysxkj.fengying_oa.VivoPushReceiver { *; } +-keep class com.fysxkj.fengying_oa.OppoPushCallback { *; } +-keep class com.fysxkj.fengying_oa.OppoDataMessageService { *; } +-keep class com.fysxkj.fengying_oa.OppoCompatPushService { *; } +-keep class com.fysxkj.fengying_oa.MeizuPushReceiver { *; } +-keep class com.fysxkj.fengying_oa.HuaweiPushService { *; } +-dontwarn com.huawei.** +-keep class com.huawei.** { *; } +-keep class com.huawei.hianalytics.** { *; } +-keep class com.huawei.updatesdk.** { *; } +-keep class org.webrtc.** { *; } +-dontwarn org.webrtc.** diff --git a/apps/native/android/app/src/debug/AndroidManifest.xml b/apps/native/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/native/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/native/android/app/src/main/AndroidManifest.xml b/apps/native/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e40447e --- /dev/null +++ b/apps/native/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/native/android/app/src/main/assets/agconnect-services.json b/apps/native/android/app/src/main/assets/agconnect-services.json new file mode 100644 index 0000000..4b57ff6 --- /dev/null +++ b/apps/native/android/app/src/main/assets/agconnect-services.json @@ -0,0 +1 @@ +{"agcgw_all":{"SG":"connect-dra.dbankcloud.cn","SG_back":"connect-dra.hispace.hicloud.com","CN":"connect-drcn.dbankcloud.cn","CN_back":"connect-drcn.hispace.dbankcloud.cn","RU":"connect-drru.hispace.dbankcloud.ru","RU_back":"connect-drru.hispace.dbankcloud.cn","DE":"connect-dre.dbankcloud.cn","DE_back":"connect-dre.hispace.hicloud.com"},"websocketgw_all":{"SG":"connect-ws-dra.hispace.dbankcloud.cn","SG_back":"connect-ws-dra.hispace.dbankcloud.com","CN":"connect-ws-drcn.dbankcloud.cn","CN_back":"connect-ws-drcn.hispace.dbankcloud.com","RU":"connect-ws-drru.hispace.dbankcloud.ru","RU_back":"connect-ws-drru.hispace.dbankcloud.cn","DE":"connect-dre.hispace.hicloud.com","DE_back":"connect-dre.hispace.hicloud.com"},"client":{"cp_id":"10086000847744494","product_id":"101653523864901451","client_id":"2029248924690199744","client_secret":"2540EB569E83BF5271BC689A63FB0540832E4849A9608BB814D463EFA6754FA4","project_id":"101653523864901451","app_id":"118825899","api_key":"DgEDALOHkJa4k9Q93H74q1jSF7G3GWcDUQIqrJBqwJFC3Bcyrg+TEK1K5FdhJHh++SBQQ/QxRTdQXOO+XOq/8r5qdZ6rGi7ISmKJlw==","package_name":"com.fengying.oa.mobile"},"oauth_client":{"client_id":"118825899","client_type":1},"app_info":{"app_id":"118825899","package_name":"com.fengying.oa.mobile"},"configuration_version":"3.0","appInfos":[{"package_name":"com.fengying.oa.mobile","client":{"app_id":"118825899"},"oauth_client":{"client_id":"118825899","client_type":1},"app_info":{"app_id":"118825899","package_name":"com.fengying.oa.mobile"}}]} diff --git a/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/Coord.kt b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/Coord.kt new file mode 100644 index 0000000..c0b83f1 --- /dev/null +++ b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/Coord.kt @@ -0,0 +1,43 @@ +package com.fysxkj.fengying_oa + +import kotlin.math.PI +import kotlin.math.sin +import kotlin.math.sqrt + +/** 国内地图(腾讯/高德)用 GCJ-02。卫星 GPS 是 WGS-84,不转换会偏三四百米。 */ +object Coord { + private const val A = 6378245.0 + private const val EE = 0.00669342162296594323 + + fun outOfChina(lat: Double, lng: Double): Boolean { + return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271 + } + + fun wgs84ToGcj02(lat: Double, lng: Double): Pair { + if (outOfChina(lat, lng)) return lat to lng + var dLat = transformLat(lng - 105.0, lat - 35.0) + var dLng = transformLng(lng - 105.0, lat - 35.0) + val radLat = lat / 180.0 * PI + var magic = 1 - EE * sin(radLat) * sin(radLat) + val sqrtMagic = sqrt(magic) + dLat = dLat * 180.0 / ((A * (1 - EE)) / (magic * sqrtMagic) * PI) + dLng = dLng * 180.0 / (A / sqrtMagic * Math.cos(radLat) * PI) + return (lat + dLat) to (lng + dLng) + } + + private fun transformLat(x: Double, y: Double): Double { + var ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * sqrt(Math.abs(x)) + ret += (20.0 * sin(6.0 * x * PI) + 20.0 * sin(2.0 * x * PI)) * 2.0 / 3.0 + ret += (20.0 * sin(y * PI) + 40.0 * sin(y / 3.0 * PI)) * 2.0 / 3.0 + ret += (160.0 * sin(y / 12.0 * PI) + 320 * sin(y * PI / 30.0)) * 2.0 / 3.0 + return ret + } + + private fun transformLng(x: Double, y: Double): Double { + var ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * sqrt(Math.abs(x)) + ret += (20.0 * sin(6.0 * x * PI) + 20.0 * sin(2.0 * x * PI)) * 2.0 / 3.0 + ret += (20.0 * sin(x * PI) + 40.0 * sin(x / 3.0 * PI)) * 2.0 / 3.0 + ret += (150.0 * sin(x / 12.0 * PI) + 300.0 * sin(x / 30.0 * PI)) * 2.0 / 3.0 + return ret + } +} diff --git a/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/FengyingApp.kt b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/FengyingApp.kt new file mode 100644 index 0000000..01afdcd --- /dev/null +++ b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/FengyingApp.kt @@ -0,0 +1,104 @@ +package com.fysxkj.fengying_oa + +import android.app.Application +import android.util.Log +import com.heytap.msp.push.HeytapPushManager +import com.vivo.push.IPushActionListener +import com.vivo.push.PushClient +import com.vivo.push.PushConfig +import com.vivo.push.listener.IPushQueryActionListener +import com.vivo.push.util.VivoPushException + +class FengyingApp : Application() { + override fun onCreate() { + super.onCreate() + OemAdapt.ensureChannels(this) + OppoPushCallback.appCtx = applicationContext + initVivo() + initOppo() + initMeizu() + initHuawei() + } + + private fun initVivo() { + try { + val config = PushConfig.Builder().agreePrivacyStatement(true).build() + PushClient.getInstance(this).initialize(config) + PushClient.getInstance(applicationContext).turnOnPush( + object : IPushActionListener { + override fun onStateChanged(state: Int) { + if (state != 0) { + Log.w(TAG, "vivo turnOnPush $state") + return + } + PushClient.getInstance(applicationContext).getRegId( + object : IPushQueryActionListener { + override fun onSuccess(regid: String?) { + if (!regid.isNullOrBlank()) PushStore.saveReg(this@FengyingApp, regid, "vivo") + } + + override fun onFail(errorCode: Int?) { + Log.w(TAG, "vivo getRegId $errorCode") + } + }, + ) + } + }, + ) + } catch (e: VivoPushException) { + Log.e(TAG, "vivo initialize", e) + } catch (e: Throwable) { + Log.e(TAG, "vivo initialize", e) + } + } + + private fun initOppo() { + try { + HeytapPushManager.init(this, false) + if (!HeytapPushManager.isSupportPush(this)) return + HeytapPushManager.register(this, OPPO_APP_KEY, OPPO_APP_SECRET, OppoPushCallback()) + } catch (e: Throwable) { + Log.e(TAG, "oppo initialize", e) + } + } + + private fun initMeizu() { + if (PushStore.detectVendor() != "meizu") return + try { + com.meizu.cloud.pushsdk.PushManager.register(this, MEIZU_APP_ID, MEIZU_APP_KEY) + } catch (e: Throwable) { + Log.e(TAG, "meizu initialize", e) + } + } + + private fun initHuawei() { + if (!PushStore.isHuaweiFamily()) return + Thread { + try { + runCatching { + if (com.huawei.agconnect.AGConnectInstance.getInstance() == null) { + com.huawei.agconnect.AGConnectInstance.initialize(this) + } + } + val token = com.huawei.hms.aaid.HmsInstanceId.getInstance(this).getToken(HUAWEI_APP_ID, "HCM") + if (!token.isNullOrBlank()) { + PushStore.saveReg(this, token, "huawei") + Log.i(TAG, "huawei token ok") + } else { + Log.w(TAG, "huawei token empty") + } + } catch (e: Throwable) { + Log.e(TAG, "huawei initialize", e) + } + }.start() + } + + companion object { + private const val TAG = "FengyingPush" + private const val OPPO_APP_KEY = "e23832b52fbb46e18de6eef5affda2e9" + private const val OPPO_APP_SECRET = "e9cf0bf35052484ebcaf15d56576468b" + private const val MEIZU_APP_ID = "157643" + private const val MEIZU_APP_KEY = "4263a8d18de442b6a17203866ce1546d" + private const val HUAWEI_APP_ID = "118825899" + } +} diff --git a/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/HuaweiPushService.kt b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/HuaweiPushService.kt new file mode 100644 index 0000000..5bd8e16 --- /dev/null +++ b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/HuaweiPushService.kt @@ -0,0 +1,32 @@ +package com.fysxkj.fengying_oa + +import android.os.Bundle +import android.util.Log +import com.huawei.hms.push.HmsMessageService +import com.huawei.hms.push.RemoteMessage + +class HuaweiPushService : HmsMessageService() { + override fun onNewToken(token: String?) { + saveToken(token) + } + + override fun onNewToken(token: String?, bundle: Bundle?) { + saveToken(token) + } + + override fun onMessageReceived(message: RemoteMessage?) { + val data = message?.dataOfMap ?: return + if (data.isEmpty()) return + Log.i(TAG, "huawei data ${data.keys}") + } + + private fun saveToken(token: String?) { + if (token.isNullOrBlank()) return + PushStore.saveReg(this, token, "huawei") + Log.i(TAG, "huawei token ok") + } + + companion object { + private const val TAG = "FengyingPush" + } +} diff --git a/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/MainActivity.kt b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/MainActivity.kt new file mode 100644 index 0000000..4567855 --- /dev/null +++ b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/MainActivity.kt @@ -0,0 +1,926 @@ +package com.fysxkj.fengying_oa + +import android.Manifest +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.ClipData +import android.content.ContentValues +import android.content.Intent +import android.content.pm.PackageManager +import android.database.ContentObserver +import android.location.LocationManager +import android.media.MediaPlayer +import android.media.AudioAttributes +import android.media.MediaRecorder +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import android.provider.OpenableColumns +import android.provider.Settings +import androidx.core.app.NotificationCompat +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel +import java.io.File +import java.io.FileOutputStream + +class MainActivity : FlutterActivity() { + private val imChannel = "com.fysxkj.oa/im" + private val otaChannel = "com.fysxkj.oa/ota" + private val deviceEvents = "com.fysxkj.oa/device" + private val pushEvents = "com.fysxkj.oa/push" + private var initialized = false + private var screenshotSink: EventChannel.EventSink? = null + private var pushSink: EventChannel.EventSink? = null + private var pendingPush: Map? = null + private var watching = false + private var lastShotAt = 0L + private val mainHandler = Handler(Looper.getMainLooper()) + private var mediaObserver: ContentObserver? = null + private var captureCallback: (() -> Unit)? = null + + private var recorder: MediaRecorder? = null + private var recordFile: File? = null + private var player: MediaPlayer? = null + private var callTone: MediaPlayer? = null + private var pickResult: MethodChannel.Result? = null + private var locResult: MethodChannel.Result? = null + private var voiceStartResult: MethodChannel.Result? = null + private var locListener: android.location.LocationListener? = null + private var locTimeout: Runnable? = null + private var callMediaResult: MethodChannel.Result? = null + private var bestGps: android.location.Location? = null + private var bestNet: android.location.Location? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + prepareIncomingCallWindow(intent) + stashPush(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + prepareIncomingCallWindow(intent) + stashPush(intent) + emitPush(intent) + } + + override fun onPostResume() { + super.onPostResume() + preferMaxRefresh() + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, imChannel) + .setMethodCallHandler { call, result -> + when (call.method) { + "init" -> { + initialized = true + result.success( + mapOf( + "ok" to true, + "initialized" to true, + "engine" to "dart-tcp", + ), + ) + } + "release" -> { + initialized = false + result.success(mapOf("ok" to true)) + } + "engine" -> result.success(if (initialized) "dart-tcp" else "idle") + else -> result.notImplemented() + } + } + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, otaChannel) + .setMethodCallHandler { call, result -> + when (call.method) { + "installApk" -> { + val path = call.argument("path") + if (path.isNullOrBlank()) { + result.error("bad_path", "缺少安装包路径", null) + return@setMethodCallHandler + } + try { + installApk(path) + result.success(true) + } catch (e: Exception) { + result.error("install_failed", e.message, null) + } + } + "preferMaxRefresh" -> { + preferMaxRefresh() + result.success(true) + } + "startScreenshotWatch" -> { + startScreenshotWatch() + result.success(true) + } + "stopScreenshotWatch" -> { + stopScreenshotWatch() + result.success(true) + } + "startVoice" -> startVoice(result) + "stopVoice" -> stopVoice(result, cancel = false) + "cancelVoice" -> stopVoice(result, cancel = true) + "playVoice" -> { + val path = call.argument("path") + if (path.isNullOrBlank()) { + result.error("bad_path", "缺少音频路径", null) + return@setMethodCallHandler + } + playVoice(path, result) + } + "stopPlay" -> { + stopPlay() + result.success(true) + } + "startCallTone" -> { + startCallTone(call.argument("incoming") == true) + result.success(true) + } + "stopCallTone" -> { + stopCallTone() + result.success(true) + } + "cancelCallNotification" -> { + cancelCallNotification() + result.success(true) + } + "playMessageSent" -> { + playBundledSound(R.raw.fengying_send) + result.success(true) + } + "showOnlineNotification" -> { + val title = call.argument("title") ?: "风影办公" + val content = call.argument("content") ?: "您有一条新消息" + @Suppress("UNCHECKED_CAST") + val extras = (call.argument>("extras") ?: emptyMap()).mapValues { "${it.value}" } + showOnlineNotification(title, content, extras) + result.success(true) + } + "speechToText" -> { + val path = call.argument("path") + if (path.isNullOrBlank()) { + result.success(null) + return@setMethodCallHandler + } + Thread { + val text = + try { + VoiceAsr.recognize(applicationContext, path) + } catch (_: Exception) { + null + } + mainHandler.post { result.success(text) } + }.start() + } + "pickMedia" -> pickContent(result, "image/*", arrayOf("image/gif", "image/png", "image/jpeg", "image/webp")) + "pickFile" -> pickContent(result, "*/*", null) + "getLocation" -> getLocation(result) + "openFile" -> { + val path = call.argument("path") + val mime = call.argument("mime") + if (path.isNullOrBlank()) { + result.error("bad_path", "缺少文件路径", null) + return@setMethodCallHandler + } + try { + openFile(path, mime) + result.success(true) + } catch (e: Exception) { + result.error("open_failed", e.message, null) + } + } + "saveImage" -> { + val path = call.argument("path") + if (path.isNullOrBlank()) { + result.error("bad_path", "缺少图片路径", null) + return@setMethodCallHandler + } + try { + saveImage(path) + result.success(true) + } catch (e: Exception) { + result.error("save_failed", e.message, null) + } + } + "openUrl" -> { + val url = call.argument("url") + if (url.isNullOrBlank()) { + result.error("bad_url", "缺少链接", null) + return@setMethodCallHandler + } + try { + openUrl(url) + result.success(true) + } catch (e: Exception) { + result.error("open_failed", e.message, null) + } + } + "getPushToken" -> result.success(PushStore.token(this)) + "consumeLaunchPush" -> { + val hit = pendingPush + pendingPush = null + result.success(hit) + } + "requestPushPermission", "requestFirstLoginPermissions" -> { + requestNotifyPermission() + result.success(true) + } + "requestCallMedia" -> requestCallMedia(result) + "oemStatus" -> { + val map = OemAdapt.status().toMutableMap() + map["batteryIgnored"] = OemAdapt.batteryIgnored(this) + map["prompted"] = PushStore.oemPrompted(this) + result.success(map) + } + "openOemKeepAlive" -> { + OemAdapt.openKeepAlive(this) + result.success(true) + } + "requestBattery" -> { + OemAdapt.requestBattery(this) + result.success(true) + } + "markOemPrompted" -> { + PushStore.markOemPrompted(this) + result.success(true) + } + else -> result.notImplemented() + } + } + EventChannel(flutterEngine.dartExecutor.binaryMessenger, deviceEvents) + .setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + screenshotSink = events + } + + override fun onCancel(arguments: Any?) { + screenshotSink = null + } + }, + ) + EventChannel(flutterEngine.dartExecutor.binaryMessenger, pushEvents) + .setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + pushSink = events + pendingPush?.let { events?.success(it) } + } + + override fun onCancel(arguments: Any?) { + pushSink = null + } + }, + ) + preferMaxRefresh() + } + + private fun requestNotifyPermission() { + if (Build.VERSION.SDK_INT < 33) return + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + ) { + return + } + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQ_NOTIFY) + } + + private fun requestCallMedia(result: MethodChannel.Result) { + val need = mutableListOf() + if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + need.add(Manifest.permission.RECORD_AUDIO) + } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + need.add(Manifest.permission.CAMERA) + } + if (need.isEmpty()) { + result.success(true) + return + } + callMediaResult = result + ActivityCompat.requestPermissions(this, need.toTypedArray(), REQ_CALL) + } + + private fun stashPush(intent: Intent?) { + val extras = PushStore.extrasOf(intent) + if (extras.isNotEmpty()) pendingPush = extras + } + + private fun emitPush(intent: Intent?) { + val extras = PushStore.extrasOf(intent) + if (extras.isEmpty()) return + mainHandler.post { pushSink?.success(extras) } + } + + private fun preferMaxRefresh() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return + try { + val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay + val modes = display?.supportedModes ?: return + val best = modes.maxByOrNull { it.refreshRate } ?: return + val params = window.attributes + if (params.preferredDisplayModeId != best.modeId) { + params.preferredDisplayModeId = best.modeId + window.attributes = params + } + } catch (_: Exception) { + } + } + + private fun emitScreenshot() { + val now = System.currentTimeMillis() + if (now - lastShotAt < 1500) return + lastShotAt = now + mainHandler.post { screenshotSink?.success("screenshot") } + } + + private fun startScreenshotWatch() { + if (watching) return + watching = true + requestMediaRead() + if (Build.VERSION.SDK_INT >= 34) { + val cb = android.app.Activity.ScreenCaptureCallback { emitScreenshot() } + captureCallback = { unregisterScreenCaptureCallback(cb) } + registerScreenCaptureCallback(mainExecutor, cb) + } + val observer = + object : ContentObserver(mainHandler) { + override fun onChange(selfChange: Boolean, uri: Uri?) { + super.onChange(selfChange, uri) + mainHandler.post { inspectScreenshot(uri) } + } + } + mediaObserver = observer + try { + contentResolver.registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, observer) + } catch (_: Exception) { + } + } + + private fun requestMediaRead() { + val perm = + if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES + else Manifest.permission.READ_EXTERNAL_STORAGE + if (ContextCompat.checkSelfPermission(this, perm) == PackageManager.PERMISSION_GRANTED) return + ActivityCompat.requestPermissions(this, arrayOf(perm), REQ_MEDIA) + } + + private fun inspectScreenshot(uri: Uri?) { + val uriText = uri?.toString()?.lowercase() ?: "" + if (uriText.contains("screenshot") || uriText.contains("screen_shot") || uriText.contains("截屏")) { + emitScreenshot() + return + } + try { + val projection = + arrayOf( + MediaStore.Images.Media.DISPLAY_NAME, + MediaStore.Images.Media.RELATIVE_PATH, + MediaStore.Images.Media.DATA, + MediaStore.Images.Media.DATE_ADDED, + ) + val target = uri ?: MediaStore.Images.Media.EXTERNAL_CONTENT_URI + contentResolver.query(target, projection, null, null, "${MediaStore.Images.Media.DATE_ADDED} DESC")?.use { c -> + if (!c.moveToFirst()) return + val added = if (c.columnCount > 3) c.getLong(3) else 0L + if (added > 0 && System.currentTimeMillis() / 1000 - added > 8) return + val blob = "${c.getString(0) ?: ""} ${c.getString(1) ?: ""} ${if (c.columnCount > 2) c.getString(2) ?: "" else ""}".lowercase() + if ( + blob.contains("screenshot") || + blob.contains("screen_shot") || + blob.contains("截屏") || + blob.contains("截圖") || + blob.contains("screen capture") + ) { + emitScreenshot() + } + } + } catch (_: Exception) { + } + } + + private fun stopScreenshotWatch() { + watching = false + if (Build.VERSION.SDK_INT >= 34) { + try { + captureCallback?.invoke() + } catch (_: Exception) { + } + captureCallback = null + } + mediaObserver?.let { + try { + contentResolver.unregisterContentObserver(it) + } catch (_: Exception) { + } + } + mediaObserver = null + } + + /** Keep a call visible over the lock screen when the user enters through an OEM push. */ + private fun prepareIncomingCallWindow(intent: Intent?) { + val extras = PushStore.extrasOf(intent) + if (extras["kind"] != "call" || extras["action"] in setOf("end", "reject", "timeout")) return + try { + setShowWhenLocked(true) + setTurnScreenOn(true) + window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } catch (_: Exception) { + } + } + + private fun startCallTone(incoming: Boolean) { + if (callTone?.isPlaying == true) return + stopCallTone() + try { + val res = if (incoming) R.raw.fengying_incoming else R.raw.fengying_dial + callTone = MediaPlayer.create(this, res)?.apply { + isLooping = true + setAudioAttributes( + AudioAttributes.Builder().setUsage( + if (incoming) AudioAttributes.USAGE_NOTIFICATION_RINGTONE else AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING, + ).setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build(), + ) + start() + } + } catch (_: Exception) { + stopCallTone() + } + } + + private fun stopCallTone() { + try { callTone?.stop() } catch (_: Exception) {} + try { callTone?.release() } catch (_: Exception) {} + callTone = null + } + + private fun playBundledSound(res: Int) { + try { + MediaPlayer.create(this, res)?.apply { + setOnCompletionListener { it.release() } + start() + } + } catch (_: Exception) { + } + } + + private fun notificationIntent(extras: Map, requestCode: Int): PendingIntent { + val i = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + extras.forEach { (k, v) -> putExtra(k, v) } + data = Uri.parse("fengyingoa://chat/open?$requestCode") + } + return PendingIntent.getActivity( + this, + requestCode, + i, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun showOnlineNotification(title: String, content: String, extras: Map) { + val isCall = extras["kind"] == "call" && extras["action"] !in setOf("end", "reject", "timeout") + val id = if (isCall) CALL_NOTICE_ID else (System.currentTimeMillis() % 100000).toInt() + val intent = notificationIntent(extras, id) + val b = NotificationCompat.Builder(this, if (isCall) PushStore.CHANNEL_CALL else PushStore.CHANNEL_IM) + .setSmallIcon(android.R.drawable.sym_action_chat) + .setContentTitle(title) + .setContentText(content) + .setAutoCancel(!isCall) + .setContentIntent(intent) + .setPriority(if (isCall) NotificationCompat.PRIORITY_MAX else NotificationCompat.PRIORITY_HIGH) + .setCategory(if (isCall) NotificationCompat.CATEGORY_CALL else NotificationCompat.CATEGORY_MESSAGE) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + if (isCall) { + b.setOngoing(true).setFullScreenIntent(intent, true) + } + val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + nm.notify(id, b.build()) + } + + private fun cancelCallNotification() { + val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + nm.cancel(CALL_NOTICE_ID) + } + + private fun hasMic(): Boolean { + return ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + } + + private fun startVoice(result: MethodChannel.Result) { + if (!hasMic()) { + voiceStartResult = result + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.RECORD_AUDIO), REQ_MIC) + return + } + beginRecord(result) + } + + private fun beginRecord(result: MethodChannel.Result) { + try { + stopPlay() + recorder?.release() + val file = File(cacheDir, "voice_${System.currentTimeMillis()}.m4a") + recordFile = file + val rec = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(this) + } else { + @Suppress("DEPRECATION") + MediaRecorder() + } + rec.setAudioSource(MediaRecorder.AudioSource.MIC) + rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + rec.setAudioSamplingRate(16000) + rec.setAudioEncodingBitRate(64000) + rec.setAudioChannels(1) + rec.setOutputFile(file.absolutePath) + rec.prepare() + rec.start() + recorder = rec + result.success(true) + } catch (e: Exception) { + recorder?.release() + recorder = null + recordFile?.delete() + recordFile = null + result.error("record_failed", e.message ?: "无法开始录音", null) + } + } + + private fun stopVoice(result: MethodChannel.Result, cancel: Boolean) { + val file = recordFile + try { + recorder?.stop() + } catch (_: Exception) { + } + try { + recorder?.release() + } catch (_: Exception) { + } + recorder = null + recordFile = null + if (cancel || file == null || !file.exists() || file.length() < 200) { + file?.delete() + result.success(null) + return + } + result.success(mapOf("path" to file.absolutePath, "name" to file.name)) + } + + private fun playVoice(path: String, result: MethodChannel.Result) { + try { + stopPlay() + val p = MediaPlayer() + p.setDataSource(path) + p.setOnCompletionListener { + stopPlay() + } + p.prepare() + p.start() + player = p + result.success(true) + } catch (e: Exception) { + stopPlay() + result.error("play_failed", e.message ?: "无法播放", null) + } + } + + private fun stopPlay() { + try { + player?.stop() + } catch (_: Exception) { + } + try { + player?.release() + } catch (_: Exception) { + } + player = null + } + + private fun pickContent(result: MethodChannel.Result, type: String, extras: Array?) { + pickResult = result + val intent = + Intent(Intent.ACTION_GET_CONTENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + this.type = type + if (extras != null) putExtra(Intent.EXTRA_MIME_TYPES, extras) + } + startActivityForResult(Intent.createChooser(intent, "选择文件"), REQ_PICK) + } + + private fun hasLoc(): Boolean { + return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + } + + private fun getLocation(result: MethodChannel.Result) { + if (!hasLoc()) { + locResult = result + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), + REQ_LOC, + ) + return + } + emitLocation(result) + } + + @Suppress("MissingPermission") + private fun emitLocation(result: MethodChannel.Result) { + try { + val lm = getSystemService(LOCATION_SERVICE) as LocationManager + stopLocUpdates(lm) + locResult = result + bestGps = null + bestNet = null + val listener = + android.location.LocationListener { loc -> + if (loc.provider == LocationManager.GPS_PROVIDER) { + bestGps = loc + if (loc.accuracy <= 50f) finishLoc(lm, loc) + } else { + val cur = bestNet + if (cur == null || loc.accuracy < cur.accuracy) bestNet = loc + } + } + locListener = listener + if (lm.isProviderEnabled(LocationManager.GPS_PROVIDER)) { + lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0L, 0f, listener, Looper.getMainLooper()) + } + if (lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + lm.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0L, 0f, listener, Looper.getMainLooper()) + } + val timeout = + Runnable { + val gps = bestGps + val net = bestNet + val last = bestLastLocation(lm) + val chosen = + when { + gps != null -> gps + net != null -> net + last != null -> last + else -> null + } + if (chosen != null) finishLoc(lm, chosen) + else { + stopLocUpdates(lm) + val cb = locResult + locResult = null + cb?.error("no_location", "没有拿到定位,请到开阔处打开系统定位后再试", null) + } + } + locTimeout = timeout + mainHandler.postDelayed(timeout, 16000) + } catch (e: Exception) { + result.error("no_location", e.message ?: "定位失败", null) + } + } + + @Suppress("MissingPermission") + private fun bestLastLocation(lm: LocationManager): android.location.Location? { + val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER, LocationManager.PASSIVE_PROVIDER) + var best: android.location.Location? = null + for (p in providers) { + val loc = + try { + lm.getLastKnownLocation(p) + } catch (_: Exception) { + null + } ?: continue + if (best == null) { + best = loc + continue + } + val newer = loc.time - best.time > 15_000 + val moreAccurate = loc.accuracy + 8f < best.accuracy + if (newer || moreAccurate) best = loc + } + return best + } + + @Suppress("MissingPermission") + private fun finishLoc(lm: LocationManager, loc: android.location.Location) { + stopLocUpdates(lm) + val cb = locResult + locResult = null + cb?.success(locMap(loc)) + } + + @Suppress("MissingPermission") + private fun stopLocUpdates(lm: LocationManager) { + locTimeout?.let { mainHandler.removeCallbacks(it) } + locTimeout = null + locListener?.let { + try { + lm.removeUpdates(it) + } catch (_: Exception) { + } + } + locListener = null + } + + private fun locMap(loc: android.location.Location): Map { + var lat = loc.latitude + var lng = loc.longitude + val p = loc.provider ?: "" + if (p != LocationManager.NETWORK_PROVIDER) { + val gcj = Coord.wgs84ToGcj02(lat, lng) + lat = gcj.first + lng = gcj.second + } + return mapOf( + "lat" to lat, + "lng" to lng, + "accuracy" to loc.accuracy.toDouble(), + ) + } + + private fun openFile(path: String, mime: String?) { + val file = File(path) + if (!file.exists()) throw IllegalStateException("文件不存在") + val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file) + val mimeType = if (mime.isNullOrBlank()) "*/*" else mime + val view = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, mimeType) + clipData = ClipData.newRawUri("", uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + val matches = packageManager.queryIntentActivities(view, PackageManager.MATCH_DEFAULT_ONLY) + for (ri in matches) { + grantUriPermission(ri.activityInfo.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + if (matches.isNotEmpty()) { + startActivity(Intent.createChooser(view, "打开文件").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + return + } + val send = + Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + setType(mimeType) + clipData = ClipData.newRawUri("", uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + val sendMatches = packageManager.queryIntentActivities(send, PackageManager.MATCH_DEFAULT_ONLY) + for (ri in sendMatches) { + grantUriPermission(ri.activityInfo.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + if (sendMatches.isEmpty()) { + throw IllegalStateException("手机上没有能打开此文件的应用,请先安装 WPS 或 Office") + } + startActivity(Intent.createChooser(send, "打开文件").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + private fun saveImage(path: String) { + val file = File(path) + if (!file.exists()) throw IllegalStateException("图片不存在") + val values = + ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "fy_${System.currentTimeMillis()}.jpg") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + } + val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + ?: throw IllegalStateException("无法写入相册") + contentResolver.openOutputStream(uri)?.use { out -> + file.inputStream().use { it.copyTo(out) } + } ?: throw IllegalStateException("无法写入相册") + } + + private fun openUrl(url: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode != REQ_PICK) return + val cb = pickResult + pickResult = null + if (cb == null) return + if (resultCode != RESULT_OK || data?.data == null) { + cb.success(null) + return + } + try { + cb.success(copyUri(data.data!!)) + } catch (e: Exception) { + cb.error("pick_failed", e.message, null) + } + } + + private fun copyUri(uri: Uri): Map { + var name = "sticker.gif" + contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c -> + if (c.moveToFirst()) { + val i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (i >= 0) name = c.getString(i) ?: name + } + } + val mime = contentResolver.getType(uri) ?: "" + val safe = name.replace(Regex("[^a-zA-Z0-9._-]"), "_") + val out = File(cacheDir, "pick_${System.currentTimeMillis()}_$safe") + contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(out).use { input.copyTo(it) } + } ?: throw IllegalStateException("无法读取所选文件") + return mapOf("path" to out.absolutePath, "name" to name, "mime" to mime) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQ_MIC) { + val cb = voiceStartResult + voiceStartResult = null + if (cb == null) return + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + beginRecord(cb) + } else { + cb.error("need_permission", "请允许麦克风权限后再按住说话", null) + } + return + } + if (requestCode == REQ_CALL) { + val cb = callMediaResult + callMediaResult = null + if (cb == null) return + cb.success(grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }) + return + } + if (requestCode == REQ_LOC) { + val cb = locResult + locResult = null + if (cb == null) return + if (grantResults.any { it == PackageManager.PERMISSION_GRANTED }) { + emitLocation(cb) + } else { + cb.error("need_permission", "请允许位置权限后再发送位置", null) + } + } + } + + override fun onDestroy() { + stopScreenshotWatch() + try { + recorder?.release() + } catch (_: Exception) { + } + stopPlay() + stopCallTone() + super.onDestroy() + } + + private fun installApk(path: String) { + val file = File(path) + if (!file.exists()) throw IllegalStateException("安装包不存在") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !packageManager.canRequestPackageInstalls() + ) { + startActivity( + Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:$packageName"), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file) + val intent = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + } + + companion object { + private const val REQ_MIC = 91 + private const val REQ_PICK = 88 + private const val REQ_LOC = 93 + private const val REQ_NOTIFY = 94 + private const val REQ_CALL = 95 + private const val REQ_MEDIA = 96 + private const val CALL_NOTICE_ID = 73101 + } +} diff --git a/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/MeizuPushReceiver.kt b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/MeizuPushReceiver.kt new file mode 100644 index 0000000..fb5e15b --- /dev/null +++ b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/MeizuPushReceiver.kt @@ -0,0 +1,71 @@ +package com.fysxkj.fengying_oa + +import android.content.Context +import android.content.Intent +import android.util.Log +import com.meizu.cloud.pushsdk.MzPushMessageReceiver +import com.meizu.cloud.pushsdk.handler.MzPushMessage +import com.meizu.cloud.pushsdk.platform.message.PushSwitchStatus +import com.meizu.cloud.pushsdk.platform.message.RegisterStatus +import com.meizu.cloud.pushsdk.platform.message.SubAliasStatus +import com.meizu.cloud.pushsdk.platform.message.SubTagsStatus +import com.meizu.cloud.pushsdk.platform.message.UnRegisterStatus +import org.json.JSONObject + +class MeizuPushReceiver : MzPushMessageReceiver() { + override fun onRegisterStatus(context: Context?, registerStatus: RegisterStatus?) { + val pushId = registerStatus?.pushId + if (context == null || pushId.isNullOrBlank()) { + Log.w(TAG, "meizu register ${registerStatus?.code} ${registerStatus?.message}") + return + } + PushStore.saveReg(context, pushId, "meizu") + Log.i(TAG, "meizu register ok") + } + + override fun onUnRegisterStatus(context: Context?, unRegisterStatus: UnRegisterStatus?) {} + + override fun onPushStatus(context: Context?, pushSwitchStatus: PushSwitchStatus?) {} + + override fun onSubTagsStatus(context: Context?, subTagsStatus: SubTagsStatus?) {} + + override fun onSubAliasStatus(context: Context?, subAliasStatus: SubAliasStatus?) {} + + override fun onMessage(context: Context?, message: String?, platformExtra: String?) {} + + override fun onNotificationClicked(context: Context?, mzPushMessage: MzPushMessage?) { + val ctx = context ?: return + val intent = + Intent(ctx, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtras(parseExtras(mzPushMessage?.selfDefineContentString)) + } + runCatching { ctx.startActivity(intent) } + .onFailure { Log.w(TAG, "meizu click", it) } + } + + override fun onNotificationArrived(context: Context?, mzPushMessage: MzPushMessage?) {} + + override fun onNotificationDeleted(context: Context?, mzPushMessage: MzPushMessage?) {} + + companion object { + private const val TAG = "FengyingPush" + + private fun parseExtras(raw: String?): android.os.Bundle { + val extras = android.os.Bundle() + if (raw.isNullOrBlank()) return extras + runCatching { + val obj = JSONObject(raw) + val src = if (obj.has("parameters")) obj.getJSONObject("parameters") else obj + for (key in listOf( + "conversationId", "kind", "fromName", "peerId", "title", "bizType", "bizId", + "callId", "callKind", "action", "fromUserId", + )) { + val v = src.optString(key) + if (v.isNotBlank()) extras.putString(key, v) + } + } + return extras + } + } +} diff --git a/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/OemAdapt.kt b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/OemAdapt.kt new file mode 100644 index 0000000..913871a --- /dev/null +++ b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/OemAdapt.kt @@ -0,0 +1,183 @@ +package com.fysxkj.fengying_oa + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.media.AudioAttributes +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import android.util.Log + +object OemAdapt { + private const val TAG = "FengyingOem" + + fun ensureChannels(ctx: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val im = + NotificationChannel(PushStore.CHANNEL_IM, "聊天消息", NotificationManager.IMPORTANCE_HIGH).apply { + description = "同事发来的即时消息和通话" + enableVibration(true) + enableLights(true) + setShowBadge(true) + val sound = Uri.parse("android.resource://${ctx.packageName}/raw/fengying_notification") + setSound(sound, AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION).build()) + } + val call = + NotificationChannel(PushStore.CHANNEL_CALL, "音视频来电", NotificationManager.IMPORTANCE_HIGH).apply { + description = "语音和视频来电提醒" + enableVibration(true) + enableLights(true) + setShowBadge(true) + val sound = Uri.parse("android.resource://${ctx.packageName}/raw/fengying_incoming") + setSound(sound, AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE).build()) + lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC + } + val todo = + NotificationChannel(PushStore.CHANNEL_TODO, "待办与审批", NotificationManager.IMPORTANCE_DEFAULT).apply { + description = "审批、待办和办公提醒" + setShowBadge(true) + } + nm.createNotificationChannel(im) + nm.createNotificationChannel(call) + nm.createNotificationChannel(todo) + } + + fun batteryIgnored(ctx: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true + val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager + return pm.isIgnoringBatteryOptimizations(ctx.packageName) + } + + fun requestBattery(ctx: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return + if (batteryIgnored(ctx)) return + try { + val intent = + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${ctx.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + ctx.startActivity(intent) + } catch (e: Exception) { + Log.w(TAG, "battery intent", e) + openAppDetails(ctx) + } + } + + fun status(): Map { + val vendor = PushStore.detectVendor() + val (title, hint) = + when (vendor) { + "oppo" -> + "后台运行与通知" to "请允许自启动、允许后台运行,并把电池优化设为不优化,否则 ColorOS 会杀掉办公消息。" + "vivo" -> + "后台运行与通知" to "请在 i 管家 / 权限管理里允许自启动、允许后台高耗电,并关闭省电限制,OriginOS 才会及时弹出消息。" + "meizu" -> + "后台运行与通知" to "请在手机管家里允许自启动、允许后台运行,并关闭电池优化,Flyme 才会及时弹出消息。" + "huawei" -> + "后台运行与通知" to "请允许自启动、允许关联启动,并把电池优化设为不允许,否则鸿蒙/EMUI 会杀掉办公消息。" + else -> + "后台运行与通知" to "请允许通知,并把本应用加入电池优化白名单,以便及时收到消息。" + } + return mapOf( + "vendor" to vendor, + "title" to title, + "hint" to hint, + ) + } + + fun openKeepAlive(ctx: Context) { + val vendor = PushStore.detectVendor() + val intents = + when (vendor) { + "oppo" -> oppoIntents(ctx) + "vivo" -> vivoIntents(ctx) + "meizu" -> meizuIntents(ctx) + "huawei" -> huaweiIntents() + else -> emptyList() + } + for (intent in intents) { + if (launch(ctx, intent)) return + } + openAppDetails(ctx) + } + + private fun oppoIntents(ctx: Context): List { + val pkg = ctx.packageName + return listOf( + component("com.oplus.safecenter", "com.oplus.safecenter.startupapp.view.StartupAppListActivity"), + component("com.coloros.safecenter", "com.coloros.safecenter.startupapp.StartupAppListActivity"), + component("com.coloros.safecenter", "com.coloros.safecenter.permission.startup.StartupAppListActivity"), + component("com.oppo.safe", "com.oppo.safe.permission.startup.StartupAppListActivity"), + component("com.coloros.oppoguardelf", "com.coloros.powermanager.fuelgaue.PowerConsumptionActivity"), + Intent().setClassName("com.coloros.safecenter", "com.coloros.privacypermissionsentry.PermissionTopActivity"), + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS), + ).onEach { it.putExtra("packageName", pkg) } + } + + private fun vivoIntents(ctx: Context): List { + val pkg = ctx.packageName + return listOf( + component("com.vivo.permissionmanager", "com.vivo.permissionmanager.activity.BgStartUpManagerActivity"), + component("com.iqoo.secure", "com.iqoo.secure.ui.phoneoptimize.BgStartUpManager"), + component("com.iqoo.secure", "com.iqoo.secure.ui.phoneoptimize.AddWhiteListActivity"), + component("com.vivo.abe", "com.vivo.applicationbehaviorengine.ui.ExcessivePowerManagerActivity"), + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS), + ).onEach { it.putExtra("packageName", pkg) } + } + + private fun huaweiIntents(): List { + return listOf( + component("com.huawei.systemmanager", "com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity"), + component("com.huawei.systemmanager", "com.huawei.systemmanager.appcontrol.activity.StartupAppControlActivity"), + component("com.huawei.systemmanager", "com.huawei.systemmanager.optimize.process.ProtectActivity"), + component("com.huawei.systemmanager", "com.huawei.systemmanager.power.ui.HwPowerManagerActivity"), + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS), + ) + } + + private fun meizuIntents(ctx: Context): List { + val pkg = ctx.packageName + return listOf( + Intent("com.meizu.safe.security.SHOW_APPSEC").apply { + setClassName("com.meizu.safe", "com.meizu.safe.security.AppSecActivity") + putExtra("packageName", pkg) + }, + component("com.meizu.safe", "com.meizu.safe.permission.PermissionMainActivity"), + component("com.meizu.safe", "com.meizu.safe.powerui.PowerAppPermissionActivity"), + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS), + ) + } + + private fun component(pkg: String, cls: String) = + Intent().setComponent(ComponentName(pkg, cls)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + private fun openAppDetails(ctx: Context) { + try { + ctx.startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.parse("package:${ctx.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } catch (e: Exception) { + Log.w(TAG, "details", e) + } + } + + private fun launch(ctx: Context, intent: Intent): Boolean { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return try { + if (intent.resolveActivity(ctx.packageManager) == null) return false + ctx.startActivity(intent) + true + } catch (_: Exception) { + false + } + } +} diff --git a/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/OppoCompatPushService.kt b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/OppoCompatPushService.kt new file mode 100644 index 0000000..9f48497 --- /dev/null +++ b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/OppoCompatPushService.kt @@ -0,0 +1,11 @@ +package com.fysxkj.fengying_oa + +import android.content.Context +import com.heytap.msp.push.mode.DataMessage +import com.heytap.msp.push.service.CompatibleDataMessageCallbackService + +class OppoCompatPushService : CompatibleDataMessageCallbackService() { + override fun processMessage(context: Context?, message: DataMessage?) { + super.processMessage(context, message) + } +} diff --git a/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/OppoDataMessageService.kt b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/OppoDataMessageService.kt new file mode 100644 index 0000000..084a988 --- /dev/null +++ b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/OppoDataMessageService.kt @@ -0,0 +1,11 @@ +package com.fysxkj.fengying_oa + +import android.content.Context +import com.heytap.msp.push.mode.DataMessage +import com.heytap.msp.push.service.DataMessageCallbackService + +class OppoDataMessageService : DataMessageCallbackService() { + override fun processMessage(context: Context?, message: DataMessage?) { + super.processMessage(context, message) + } +} diff --git a/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/OppoPushCallback.kt b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/OppoPushCallback.kt new file mode 100644 index 0000000..0573dcf --- /dev/null +++ b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/OppoPushCallback.kt @@ -0,0 +1,34 @@ +package com.fysxkj.fengying_oa + +import android.content.Context +import android.util.Log +import com.heytap.msp.push.callback.ICallBackResultService + +class OppoPushCallback : ICallBackResultService { + override fun onRegister(responseCode: Int, registerID: String?) { + if (responseCode == 0 && !registerID.isNullOrBlank()) { + val ctx = appCtx ?: return + PushStore.saveReg(ctx, registerID, "oppo") + Log.i(TAG, "oppo register ok") + } else { + Log.w(TAG, "oppo register $responseCode") + } + } + + override fun onUnRegister(responseCode: Int) {} + + override fun onSetPushTime(responseCode: Int, pushTime: String?) {} + + override fun onGetPushStatus(responseCode: Int, status: Int) {} + + override fun onGetNotificationStatus(responseCode: Int, status: Int) {} + + override fun onError(errorCode: Int, message: String?) { + Log.w(TAG, "oppo error $errorCode $message") + } + + companion object { + private const val TAG = "FengyingPush" + @Volatile var appCtx: Context? = null + } +} diff --git a/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/PushStore.kt b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/PushStore.kt new file mode 100644 index 0000000..18a2bce --- /dev/null +++ b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/PushStore.kt @@ -0,0 +1,89 @@ +package com.fysxkj.fengying_oa + +import android.content.Context +import android.content.Intent +import android.os.Build + +object PushStore { + const val PREFS = "fy_push" + const val KEY_REG = "regId" + const val KEY_VENDOR = "vendor" + // New id makes the bundled custom sound take effect even on phones that + // already created the legacy fy_im notification channel. + const val CHANNEL_IM = "fy_im_custom" + const val CHANNEL_CALL = "fy_call_v2" + const val CHANNEL_TODO = "fy_todo" + const val KEY_OEM_PROMPT = "oem_prompted" + + fun saveReg(ctx: Context, id: String, vendor: String = detectVendor()) { + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putString(KEY_REG, id) + .putString(KEY_VENDOR, vendor) + .apply() + } + + fun token(ctx: Context): Map { + val p = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val reg = p.getString(KEY_REG, "") ?: "" + val vendor = p.getString(KEY_VENDOR, detectVendor()) ?: detectVendor() + return mapOf( + "vendor" to vendor, + "regId" to reg, + "brand" to Build.BRAND, + "model" to Build.MODEL, + ) + } + + fun detectVendor(): String { + val m = "${Build.MANUFACTURER} ${Build.BRAND} ${Build.FINGERPRINT}".lowercase() + return when { + m.contains("vivo") || m.contains("iqoo") -> "vivo" + m.contains("oppo") || m.contains("realme") || m.contains("oneplus") || m.contains("oplus") -> "oppo" + m.contains("meizu") -> "meizu" + isHuaweiFamily(m) -> "huawei" + m.contains("xiaomi") || m.contains("redmi") || m.contains("blackshark") -> "xiaomi" + else -> "android" + } + } + + fun isHuaweiFamily(raw: String = "${Build.MANUFACTURER} ${Build.BRAND} ${Build.FINGERPRINT}".lowercase()): Boolean { + return listOf("huawei", "honor", "harmony", "hmos", "ohos", "harmonyos").any { raw.contains(it) } + } + + fun extrasOf(intent: Intent?): Map { + val out = linkedMapOf() + val data = intent?.data + if (data != null && data.scheme == "fengyingoa") { + when (data.host) { + "qr" -> { + val ticket = data.getQueryParameter("ticket") + if (!ticket.isNullOrBlank()) { + out["kind"] = "qr-login" + out["ticket"] = ticket + } + } + "chat" -> { + val cid = data.getQueryParameter("conversationId") + if (!cid.isNullOrBlank()) out["conversationId"] = cid + } + } + } + val b = intent?.extras ?: return out + for (key in listOf( + "conversationId", "kind", "fromName", "peerId", "title", "bizType", "bizId", "ticket", + "callId", "callKind", "action", "fromUserId", + )) { + val v = runCatching { b.getString(key) }.getOrNull() + if (!v.isNullOrBlank() && !out.containsKey(key)) out[key] = v + } + return out + } + + fun oemPrompted(ctx: Context) = + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_OEM_PROMPT, false) + + fun markOemPrompted(ctx: Context) { + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_OEM_PROMPT, true).apply() + } +} diff --git a/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/VivoPushReceiver.kt b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/VivoPushReceiver.kt new file mode 100644 index 0000000..1a3d1d2 --- /dev/null +++ b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/VivoPushReceiver.kt @@ -0,0 +1,11 @@ +package com.fysxkj.fengying_oa + +import android.content.Context +import com.vivo.push.sdk.OpenClientPushMessageReceiver + +class VivoPushReceiver : OpenClientPushMessageReceiver() { + override fun onReceiveRegId(context: Context?, regId: String?) { + if (context == null || regId.isNullOrBlank()) return + PushStore.saveReg(context, regId, "vivo") + } +} diff --git a/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/VoiceAsr.kt b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/VoiceAsr.kt new file mode 100644 index 0000000..de23b3d --- /dev/null +++ b/apps/native/android/app/src/main/kotlin/com/fysxkj/fengying_oa/VoiceAsr.kt @@ -0,0 +1,222 @@ +package com.fysxkj.fengying_oa + +import android.content.Context +import android.media.MediaCodec +import android.media.MediaExtractor +import android.media.MediaFormat +import org.json.JSONObject +import org.vosk.LibVosk +import org.vosk.LogLevel +import org.vosk.Model +import org.vosk.Recognizer +import java.io.BufferedInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.zip.ZipInputStream + +/** 手机端离线语音转文字(Vosk,首次使用自动下载中文小模型到应用目录)。 */ +object VoiceAsr { + private const val MODEL_DIR = "vosk-model-small-cn-0.22" + private const val MODEL_URL = + "https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip" + + @Volatile + private var model: Model? = null + + @Synchronized + fun recognize(context: Context, audioPath: String): String? { + LibVosk.setLogLevel(LogLevel.WARN) + val m = model ?: loadModel(context.applicationContext).also { model = it } + val pcm = decodeToPcm16Mono16k(audioPath) ?: return null + if (pcm.isEmpty()) return null + val recognizer = Recognizer(m, 16000f) + try { + recognizer.acceptWaveForm(pcm, pcm.size) + val json = JSONObject(recognizer.finalResult) + val text = json.optString("text", "").trim() + return text.ifEmpty { null } + } finally { + recognizer.close() + } + } + + private fun loadModel(context: Context): Model { + val dir = File(context.filesDir, MODEL_DIR) + if (!File(dir, "am/final.mdl").exists()) { + downloadAndUnzip(dir) + } + return Model(dir.absolutePath) + } + + private fun downloadAndUnzip(targetDir: File) { + if (targetDir.exists()) { + targetDir.deleteRecursively() + } + targetDir.mkdirs() + val zipFile = File(targetDir.parentFile, "$MODEL_DIR.zip") + val conn = (URL(MODEL_URL).openConnection() as HttpURLConnection).apply { + connectTimeout = 30_000 + readTimeout = 120_000 + requestMethod = "GET" + } + conn.inputStream.use { input -> + FileOutputStream(zipFile).use { output -> input.copyTo(output) } + } + conn.disconnect() + unzip(zipFile, targetDir.parentFile!!) + zipFile.delete() + val nested = File(targetDir.parentFile, MODEL_DIR) + if (!nested.exists()) { + targetDir.parentFile?.listFiles()?.firstOrNull { + it.isDirectory && File(it, "am/final.mdl").exists() + }?.renameTo(nested) + } + } + + private fun unzip(zipFile: File, destDir: File) { + ZipInputStream(BufferedInputStream(zipFile.inputStream())).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + val outFile = File(destDir, entry.name) + if (entry.isDirectory) { + outFile.mkdirs() + } else { + outFile.parentFile?.mkdirs() + FileOutputStream(outFile).use { fos -> zis.copyTo(fos) } + } + zis.closeEntry() + entry = zis.nextEntry + } + } + } + + private fun decodeToPcm16Mono16k(path: String): ByteArray? { + val extractor = MediaExtractor() + try { + extractor.setDataSource(path) + var track = -1 + var format: MediaFormat? = null + for (i in 0 until extractor.trackCount) { + val f = extractor.getTrackFormat(i) + val mime = f.getString(MediaFormat.KEY_MIME) ?: continue + if (mime.startsWith("audio/")) { + track = i + format = f + break + } + } + if (track < 0 || format == null) return null + extractor.selectTrack(track) + val mime = format.getString(MediaFormat.KEY_MIME) ?: return null + val codec = MediaCodec.createDecoderByType(mime) + codec.configure(format, null, null, 0) + codec.start() + val pcmOut = ByteArrayOutputStream() + var sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE) + var channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) + val bufferInfo = MediaCodec.BufferInfo() + var inputDone = false + while (true) { + if (!inputDone) { + val inIndex = codec.dequeueInputBuffer(10_000) + if (inIndex >= 0) { + val buf = codec.getInputBuffer(inIndex) ?: continue + val size = extractor.readSampleData(buf, 0) + if (size < 0) { + codec.queueInputBuffer( + inIndex, + 0, + 0, + 0, + MediaCodec.BUFFER_FLAG_END_OF_STREAM, + ) + inputDone = true + } else { + codec.queueInputBuffer(inIndex, 0, size, extractor.sampleTime, 0) + extractor.advance() + } + } + } + val outIndex = codec.dequeueOutputBuffer(bufferInfo, 10_000) + if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + val outFormat = codec.outputFormat + sampleRate = outFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) + channels = outFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT) + } else if (outIndex >= 0) { + val buf = codec.getOutputBuffer(outIndex) ?: continue + if (bufferInfo.size > 0) { + val chunk = ByteArray(bufferInfo.size) + buf.position(bufferInfo.offset) + buf.limit(bufferInfo.offset + bufferInfo.size) + buf.get(chunk) + pcmOut.write(chunk) + } + codec.releaseOutputBuffer(outIndex, false) + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break + } + } + codec.stop() + codec.release() + var samples = pcmOut.toByteArray() + if (samples.isEmpty()) return null + var shorts = bytesToShorts(samples) + if (channels > 1) { + shorts = downmix(shorts, channels) + } + if (sampleRate != 16000) { + shorts = resample(shorts, sampleRate, 16000) + } + return shortsToBytes(shorts) + } catch (_: Exception) { + return null + } finally { + try { + extractor.release() + } catch (_: Exception) { + } + } + } + + private fun bytesToShorts(bytes: ByteArray): ShortArray { + val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + val count = bytes.size / 2 + val out = ShortArray(count) + for (i in 0 until count) out[i] = bb.short + return out + } + + private fun shortsToBytes(samples: ShortArray): ByteArray { + val bb = ByteBuffer.allocate(samples.size * 2).order(ByteOrder.LITTLE_ENDIAN) + for (s in samples) bb.putShort(s) + return bb.array() + } + + private fun downmix(samples: ShortArray, channels: Int): ShortArray { + if (channels <= 1) return samples + val frames = samples.size / channels + val out = ShortArray(frames) + for (i in 0 until frames) { + var sum = 0 + for (c in 0 until channels) sum += samples[i * channels + c] + out[i] = (sum / channels).toShort() + } + return out + } + + private fun resample(samples: ShortArray, fromRate: Int, toRate: Int): ShortArray { + if (fromRate == toRate) return samples + val ratio = fromRate.toDouble() / toRate.toDouble() + val outLen = (samples.size / ratio).toInt().coerceAtLeast(1) + val out = ShortArray(outLen) + for (i in 0 until outLen) { + val src = (i * ratio).toInt().coerceIn(0, samples.size - 1) + out[i] = samples[src] + } + return out + } +} diff --git a/apps/native/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/native/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/apps/native/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/native/android/app/src/main/res/drawable/launch_background.xml b/apps/native/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/apps/native/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/native/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/native/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..3defb0b Binary files /dev/null and b/apps/native/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/native/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/native/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..eb9c081 Binary files /dev/null and b/apps/native/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/native/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/native/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09858ad Binary files /dev/null and b/apps/native/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..4e01456 Binary files /dev/null and b/apps/native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..51b26ec Binary files /dev/null and b/apps/native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/native/android/app/src/main/res/raw/fengying_dial.mp3 b/apps/native/android/app/src/main/res/raw/fengying_dial.mp3 new file mode 100644 index 0000000..f25ae61 Binary files /dev/null and b/apps/native/android/app/src/main/res/raw/fengying_dial.mp3 differ diff --git a/apps/native/android/app/src/main/res/raw/fengying_incoming.mp3 b/apps/native/android/app/src/main/res/raw/fengying_incoming.mp3 new file mode 100644 index 0000000..7d509f0 Binary files /dev/null and b/apps/native/android/app/src/main/res/raw/fengying_incoming.mp3 differ diff --git a/apps/native/android/app/src/main/res/raw/fengying_notification.wav b/apps/native/android/app/src/main/res/raw/fengying_notification.wav new file mode 100644 index 0000000..705db49 Binary files /dev/null and b/apps/native/android/app/src/main/res/raw/fengying_notification.wav differ diff --git a/apps/native/android/app/src/main/res/raw/fengying_send.wav b/apps/native/android/app/src/main/res/raw/fengying_send.wav new file mode 100644 index 0000000..24a69f5 Binary files /dev/null and b/apps/native/android/app/src/main/res/raw/fengying_send.wav differ diff --git a/apps/native/android/app/src/main/res/values-night-v35/styles.xml b/apps/native/android/app/src/main/res/values-night-v35/styles.xml new file mode 100644 index 0000000..425dfb0 --- /dev/null +++ b/apps/native/android/app/src/main/res/values-night-v35/styles.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/apps/native/android/app/src/main/res/values-night/styles.xml b/apps/native/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/apps/native/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/native/android/app/src/main/res/values-v35/styles.xml b/apps/native/android/app/src/main/res/values-v35/styles.xml new file mode 100644 index 0000000..0f7a45e --- /dev/null +++ b/apps/native/android/app/src/main/res/values-v35/styles.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/apps/native/android/app/src/main/res/values/styles.xml b/apps/native/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/apps/native/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/native/android/app/src/main/res/xml/file_paths.xml b/apps/native/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..4a1b1a5 --- /dev/null +++ b/apps/native/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/native/android/app/src/profile/AndroidManifest.xml b/apps/native/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/native/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/native/android/build.gradle b/apps/native/android/build.gradle new file mode 100644 index 0000000..d71850a --- /dev/null +++ b/apps/native/android/build.gradle @@ -0,0 +1,27 @@ +allprojects { + repositories { + google() + mavenCentral() + maven { url "https://developer.huawei.com/repo/" } + maven { url "https://repos.vivo.com.cn/maven/repository/external-lib/" } + maven { + url "https://maven.columbus.heytapmobi.com/repository/releases/" + credentials { + username "nexus" + password "c0b08da17e3ec36c3870fed674a0bcb36abc2e23" + } + } + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/apps/native/android/gradle.properties b/apps/native/android/gradle.properties new file mode 100644 index 0000000..f43af91 --- /dev/null +++ b/apps/native/android/gradle.properties @@ -0,0 +1,8 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true +apmsInstrumentationEnabled=false +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/apps/native/android/gradle/wrapper/gradle-wrapper.properties b/apps/native/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..efdcc4a --- /dev/null +++ b/apps/native/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip diff --git a/apps/native/android/settings.gradle b/apps/native/android/settings.gradle new file mode 100644 index 0000000..f4cdfab --- /dev/null +++ b/apps/native/android/settings.gradle @@ -0,0 +1,34 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + maven { url "https://developer.huawei.com/repo/" } + maven { url "https://repos.vivo.com.cn/maven/repository/external-lib/" } + maven { + url "https://maven.columbus.heytapmobi.com/repository/releases/" + credentials { + username "nexus" + password "c0b08da17e3ec36c3870fed674a0bcb36abc2e23" + } + } + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.9.1" apply false + id "org.jetbrains.kotlin.android" version "1.9.24" apply false +} + +include ":app" diff --git a/apps/native/assets/brand/fengying-logo-512.png b/apps/native/assets/brand/fengying-logo-512.png new file mode 100644 index 0000000..0e9f647 Binary files /dev/null and b/apps/native/assets/brand/fengying-logo-512.png differ diff --git a/apps/native/assets/sounds/ring.wav b/apps/native/assets/sounds/ring.wav new file mode 100644 index 0000000..dd619a0 Binary files /dev/null and b/apps/native/assets/sounds/ring.wav differ diff --git a/apps/native/build-linux.sh b/apps/native/build-linux.sh new file mode 100755 index 0000000..493f6de --- /dev/null +++ b/apps/native/build-linux.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Linux 发布:deb(推荐)+ tar.gz(备用) +set -euo pipefail +cd "$(dirname "$0")" +bash packaging/build-deb.sh "${1:-3.1.10}" "${2:-324}" diff --git a/apps/native/build-windows.bat b/apps/native/build-windows.bat new file mode 100644 index 0000000..92426ca --- /dev/null +++ b/apps/native/build-windows.bat @@ -0,0 +1,8 @@ +@echo off +setlocal +cd /d %~dp0 +flutter pub get +flutter build windows --release +echo. +echo exe: build\windows\x64\runner\Release\fengying_oa.exe +endlocal diff --git a/apps/native/build-windows.ps1 b/apps/native/build-windows.ps1 new file mode 100644 index 0000000..5180a24 --- /dev/null +++ b/apps/native/build-windows.ps1 @@ -0,0 +1,49 @@ +$ErrorActionPreference = 'Stop' +Set-Location $PSScriptRoot + +$env:PUB_HOSTED_URL = 'https://pub.dev' +$env:FLUTTER_STORAGE_BASE_URL = 'https://storage.googleapis.com' + +$flutter = $null +foreach ($c in @( + 'D:\flutter\bin\flutter.bat', + 'C:\flutter\bin\flutter.bat', + (Join-Path $env:LOCALAPPDATA 'flutter\bin\flutter.bat') +)) { + if (Test-Path $c) { $flutter = $c; break } +} +if (-not $flutter) { + $cmd = Get-Command flutter -ErrorAction SilentlyContinue + if ($cmd) { $flutter = $cmd.Source } +} +if (-not $flutter) { + throw '未找到 Flutter。请先运行 installer\bootstrap-windows.ps1' +} + +& $flutter pub get +if ($LASTEXITCODE -ne 0) { throw "flutter pub get failed: $LASTEXITCODE" } +& $flutter build windows --release --build-name=3.1.10 --build-number=324 +if ($LASTEXITCODE -ne 0) { throw "flutter build windows failed: $LASTEXITCODE" } + +$release = Join-Path $PSScriptRoot 'build\windows\x64\runner\Release' +$exe = Join-Path $release 'fengying_oa.exe' +if (-not (Test-Path $exe)) { throw "missing $exe" } + +$iscc = @( + "${env:ProgramFiles}\Inno Setup 7\ISCC.exe", + "${env:ProgramFiles(x86)}\Inno Setup 7\ISCC.exe", + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "${env:ProgramFiles}\Inno Setup 6\ISCC.exe", + 'C:\Program Files\Inno Setup 7\ISCC.exe', + 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe' +) | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $iscc) { throw '未找到 Inno Setup(ISCC.exe)。请先运行 installer\bootstrap-windows.ps1' } + +$iss = Join-Path $PSScriptRoot 'installer\fengying-oa.iss' +& $iscc $iss +if ($LASTEXITCODE -ne 0) { throw "ISCC failed: $LASTEXITCODE" } + +$setup = Join-Path $PSScriptRoot 'build\fengying-oa-setup.exe' +if (-not (Test-Path $setup)) { throw "missing $setup" } +Write-Host "Windows setup: $setup" +Write-Host "Windows exe: $exe" diff --git a/apps/native/installer/bootstrap-windows.ps1 b/apps/native/installer/bootstrap-windows.ps1 new file mode 100644 index 0000000..a163726 --- /dev/null +++ b/apps/native/installer/bootstrap-windows.ps1 @@ -0,0 +1,118 @@ +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$env:PUB_HOSTED_URL = 'https://pub.flutter-io.cn' +$env:FLUTTER_STORAGE_BASE_URL = 'https://storage.flutter-io.cn' + +function Get-FreeGb([string]$letter) { + $d = Get-PSDrive -Name $letter -ErrorAction SilentlyContinue + if (-not $d) { return 0 } + return [math]::Round($d.Free / 1GB, 1) +} + +$sdkRoot = if ((Get-FreeGb 'D') -gt 8) { 'D:\' } else { 'C:\' } +$flutterDir = Join-Path $sdkRoot 'flutter' +$work = Join-Path $sdkRoot 'oa-build-cache' +New-Item -ItemType Directory -Force -Path $work | Out-Null + +function Test-Flutter { + $bat = Join-Path $flutterDir 'bin\flutter.bat' + return (Test-Path $bat) +} + +function Ensure-Flutter { + if (Test-Flutter) { + Write-Host "Flutter already at $flutterDir" + return + } + $zip = Join-Path $work 'flutter_windows_3.27.4-stable.zip' + if (-not (Test-Path $zip) -or ((Get-Item $zip).Length -lt 100MB)) { + $urls = @( + 'https://storage.flutter-io.cn/flutter_infra_release/releases/stable/windows/flutter_windows_3.27.4-stable.zip', + 'https://fast-mirror.isrc.ac.cn/flutter/flutter_infra/releases/stable/windows/flutter_windows_3.27.4-stable.zip' + ) + $ok = $false + foreach ($u in $urls) { + Write-Host "Downloading Flutter from $u" + try { + Invoke-WebRequest -Uri $u -OutFile $zip -UseBasicParsing + $ok = $true + break + } catch { + Write-Host "download failed: $_" + } + } + if (-not $ok) { throw 'Flutter SDK 下载失败' } + } + Write-Host "Extracting Flutter to $flutterDir" + if (Test-Path $flutterDir) { Remove-Item $flutterDir -Recurse -Force } + Expand-Archive -Path $zip -DestinationPath $sdkRoot -Force + if (-not (Test-Flutter)) { throw "unzip 后仍没有 $flutterDir\bin\flutter.bat" } +} + +function Ensure-VsBuildTools { + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $ok = $false + if (Test-Path $vswhere) { + $path = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if ($path) { $ok = $true } + } + if ($ok) { + Write-Host "VS C++ tools already installed" + return + } + $boot = Join-Path $work 'vs_BuildTools.exe' + if (-not (Test-Path $boot)) { + Write-Host 'Downloading VS 2022 Build Tools bootstrapper' + $urls = @( + 'https://aka.ms/vs/17/release/vs_BuildTools.exe' + ) + $got = $false + foreach ($u in $urls) { + try { + Invoke-WebRequest -Uri $u -OutFile $boot -UseBasicParsing + $got = $true + break + } catch { + Write-Host "bootstrapper download failed: $_" + } + } + if (-not $got) { throw 'VS Build Tools 引导程序下载失败' } + } + $installPath = Join-Path $sdkRoot 'BuildTools' + Write-Host "Installing VS Build Tools to $installPath (this takes a while)" + $p = Start-Process -FilePath $boot -ArgumentList @( + '--quiet', '--wait', '--norestart', '--nocache', + '--installPath', $installPath, + '--add', 'Microsoft.VisualStudio.Workload.VCTools', + '--includeRecommended', + '--add', 'Microsoft.VisualStudio.Component.Windows11SDK.22621' + ) -Wait -PassThru + if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { + throw "VS Build Tools install failed: $($p.ExitCode)" + } +} + +function Ensure-Inno { + $iscc = @( + "${env:ProgramFiles}\Inno Setup 7\ISCC.exe", + "${env:ProgramFiles(x86)}\Inno Setup 7\ISCC.exe", + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "${env:ProgramFiles}\Inno Setup 6\ISCC.exe" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if ($iscc) { + Write-Host "Inno Setup already at $iscc" + return + } + $setup = Join-Path $work 'innosetup.exe' + Write-Host 'Downloading Inno Setup' + Invoke-WebRequest -Uri 'https://jrsoftware.org/download.php/is.exe' -OutFile $setup -UseBasicParsing + $p = Start-Process -FilePath $setup -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' -Wait -PassThru + if ($p.ExitCode -ne 0) { throw "Inno Setup install failed: $($p.ExitCode)" } +} + +Ensure-Flutter +Ensure-VsBuildTools +Ensure-Inno +Write-Host 'toolchain ready' +Write-Host "flutter=$flutterDir\bin\flutter.bat" diff --git a/apps/native/installer/fengying-oa.iss b/apps/native/installer/fengying-oa.iss new file mode 100644 index 0000000..12f3516 --- /dev/null +++ b/apps/native/installer/fengying-oa.iss @@ -0,0 +1,43 @@ +#define MyAppName "风影办公" +#define MyAppVersion "3.1.8" +#define MyAppPublisher "江苏风影随行科技有限公司" +#define MyAppExeName "fengying_oa.exe" +#define MyAppURL "https://oa.fysxkj.com" + +[Setup] +AppId={{8E3F2A11-6C4B-4D91-9E27-FENGINGOA3180} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +DefaultDirName={autopf}\FengyingOA +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +OutputDir=..\build +OutputBaseFilename=fengying-oa-setup +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +UninstallDisplayIcon={app}\{#MyAppExeName} +SetupIconFile=..\windows\runner\resources\app_icon.ico + +[Languages] +Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl" + +[Tasks] +Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加选项:"; Flags: unchecked + +[Files] +Source: "..\build\windows\x64\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "启动 {#MyAppName}"; Flags: nowait postinstall skipifsilent diff --git a/apps/native/lib/api/oa_client.dart b/apps/native/lib/api/oa_client.dart new file mode 100644 index 0000000..4f64ad7 --- /dev/null +++ b/apps/native/lib/api/oa_client.dart @@ -0,0 +1,358 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; +import 'package:uuid/uuid.dart'; + +import '../app_config.dart'; +import '../session/session.dart'; + +class ApiException implements Exception { + ApiException(this.message); + final String message; + @override + String toString() => message; +} + +class OaClient { + OaClient(this.session); + final SessionStore session; + bool _refreshing = false; + + Uri _uri(String path, [Map? query]) { + final base = session.apiBase.replaceAll(RegExp(r'/$'), ''); + final p = path.startsWith('/') ? path : '/$path'; + return Uri.parse('$base$p').replace(queryParameters: query); + } + + Map _headers({bool auth = true}) { + final h = { + 'Content-Type': 'application/json', + 'X-Request-Id': const Uuid().v4(), + }; + if (auth && session.accessToken.isNotEmpty) { + h['Authorization'] = 'Bearer ${session.accessToken}'; + } + return h; + } + + Future get(String path, {Map? query}) => + _send('GET', path, query: query); + + Future post(String path, [Map? body]) => + _send('POST', path, body: body); + + Future patch(String path, [Map? body]) => + _send('PATCH', path, body: body); + + final _fileCache = {}; + + Uint8List? peekFile(String id) => _fileCache[id]; + + Future fileBytes(String id) async { + if (id.isEmpty) return null; + final hit = _fileCache[id]; + if (hit != null) return hit; + final uri = _uri('/files/$id'); + final headers = { + 'X-Request-Id': const Uuid().v4(), + if (session.accessToken.isNotEmpty) 'Authorization': 'Bearer ${session.accessToken}', + }; + try { + var res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30)); + if (res.statusCode == 401 && session.refreshToken.isNotEmpty) { + final ok = await _refresh(); + if (ok) { + headers['Authorization'] = 'Bearer ${session.accessToken}'; + res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30)); + } + } + if (res.statusCode >= 400 || res.bodyBytes.isEmpty) return null; + if (_fileCache.length > 80) _fileCache.remove(_fileCache.keys.first); + _fileCache[id] = res.bodyBytes; + return res.bodyBytes; + } catch (_) { + return null; + } + } + + Future getBytes(String path, {Map? query}) async { + final uri = _uri(path, query); + final headers = { + 'X-Request-Id': const Uuid().v4(), + if (session.accessToken.isNotEmpty) 'Authorization': 'Bearer ${session.accessToken}', + }; + try { + var res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 20)); + if (res.statusCode == 401 && session.refreshToken.isNotEmpty) { + final ok = await _refresh(); + if (ok) { + headers['Authorization'] = 'Bearer ${session.accessToken}'; + res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 20)); + } + } + if (res.statusCode >= 400 || res.bodyBytes.isEmpty) return null; + return res.bodyBytes; + } catch (_) { + return null; + } + } + + Future> uploadFile({ + required String filePath, + required String filename, + required String bizType, + required String bizId, + String? mime, + }) async { + final uri = _uri('/files'); + final type = _mediaType(filename, mime); + Future send() async { + final req = http.MultipartRequest('POST', uri); + req.headers['Authorization'] = 'Bearer ${session.accessToken}'; + req.headers['X-Request-Id'] = const Uuid().v4(); + req.fields['bizType'] = bizType; + req.fields['bizId'] = bizId; + req.files.add(await http.MultipartFile.fromPath('file', filePath, filename: filename, contentType: type)); + return http.Response.fromStream(await req.send().timeout(const Duration(seconds: 60))); + } + + var res = await send(); + if (res.statusCode == 401 && session.refreshToken.isNotEmpty) { + final ok = await _refresh(); + if (ok) res = await send(); + } + if ((res.statusCode == 502 || res.statusCode == 503) && session.accessToken.isNotEmpty) { + await Future.delayed(const Duration(milliseconds: 700)); + res = await send(); + } + dynamic json; + try { + json = res.body.isEmpty ? null : jsonDecode(res.body); + } catch (_) { + throw ApiException(res.statusCode >= 400 ? '上传失败 ${res.statusCode}' : '响应无法解析'); + } + if (json is Map && json['code'] != null && json['code'] != 0) { + throw ApiException('${json['message'] ?? '上传失败'}'); + } + if (res.statusCode >= 400) { + final msg = json is Map ? (json['message'] ?? json['error'] ?? res.statusCode) : res.statusCode; + throw ApiException('$msg'); + } + final data = json is Map && json.containsKey('data') ? json['data'] : json; + return data is Map ? Map.from(data) : {'id': '$data'}; + } + + Future _send( + String method, + String path, { + Map? query, + Map? body, + bool retry = true, + }) async { + final uri = _uri(path, query); + final headers = _headers( + auth: !path.startsWith('/auth/login') && + !path.startsWith('/auth/sms-code') && + path != '/auth/login-options' && + path != '/system/app-release' && + !path.startsWith('/legal'), + ); + http.Response res; + try { + if (method == 'GET') { + res = await http.get(uri, headers: headers).timeout(const Duration(seconds: 30)); + } else if (method == 'PATCH') { + res = await http.patch(uri, headers: headers, body: jsonEncode(body ?? {})).timeout(const Duration(seconds: 30)); + } else { + res = await http.post(uri, headers: headers, body: jsonEncode(body ?? {})).timeout(const Duration(seconds: 30)); + } + } catch (e) { + throw ApiException('网络不可用:$e'); + } + if ((res.statusCode == 502 || res.statusCode == 503) && retry) { + await Future.delayed(const Duration(milliseconds: 700)); + return _send(method, path, query: query, body: body, retry: false); + } + if (res.statusCode == 401 && retry && session.refreshToken.isNotEmpty && !path.contains('/auth/refresh')) { + final ok = await _refresh(); + if (ok) return _send(method, path, query: query, body: body, retry: false); + await session.clear(); + throw ApiException('登录已过期,请重新登录'); + } + dynamic json; + try { + json = res.body.isEmpty ? null : jsonDecode(res.body); + } catch (_) { + throw ApiException(res.statusCode >= 400 ? '请求失败 ${res.statusCode}' : '响应无法解析'); + } + if (json is Map && json['code'] != null && json['code'] != 0) { + throw ApiException('${json['message'] ?? '请求失败'}'); + } + if (res.statusCode >= 400) { + final msg = json is Map ? (json['message'] ?? json['error'] ?? res.statusCode) : res.statusCode; + throw ApiException('$msg'); + } + if (json is Map && json.containsKey('data')) return json['data']; + return json; + } + + Future _refresh() async { + if (_refreshing) return false; + _refreshing = true; + try { + final uri = _uri('/auth/refresh'); + final res = await http.post( + uri, + headers: _headers(auth: false), + body: jsonEncode({ + 'refreshToken': session.refreshToken, + 'clientKind': 'mobile', + }), + ); + if (res.statusCode >= 400) return false; + final json = jsonDecode(res.body); + final data = json is Map && json['data'] is Map ? json['data'] as Map : json; + final access = '${data['accessToken'] ?? ''}'; + final refresh = '${data['refreshToken'] ?? ''}'; + if (access.isEmpty) return false; + await session.setTokens(access, refresh.isEmpty ? session.refreshToken : refresh); + return true; + } catch (_) { + return false; + } finally { + _refreshing = false; + } + } + + MediaType _mediaType(String filename, String? mime) { + var raw = (mime ?? '').split(';').first.trim().toLowerCase(); + if (raw == 'image/jpg' || raw == 'image/pjpeg') raw = 'image/jpeg'; + if (raw.isEmpty || raw == 'application/octet-stream') { + final n = filename.toLowerCase(); + if (n.endsWith('.jpg') || n.endsWith('.jpeg')) { + raw = 'image/jpeg'; + } else if (n.endsWith('.png')) { + raw = 'image/png'; + } else if (n.endsWith('.gif')) { + raw = 'image/gif'; + } else if (n.endsWith('.webp')) { + raw = 'image/webp'; + } else if (n.endsWith('.m4a') || n.endsWith('.mp4')) { + raw = 'audio/mp4'; + } else if (n.endsWith('.pdf')) { + raw = 'application/pdf'; + } else { + raw = 'application/octet-stream'; + } + } + try { + return MediaType.parse(raw); + } catch (_) { + return MediaType('application', 'octet-stream'); + } + } +} + +List> asMaps(dynamic data) { + final list = extractList(data); + return list.whereType().map((e) => Map.from(e)).toList(); +} + +List extractList(dynamic data) { + if (data is List) return data; + if (data is Map) { + for (final k in ['items', 'list', 'rows', 'records', 'data']) { + if (data[k] is List) return data[k] as List; + } + } + return const []; +} + +String scalarText(dynamic v) { + if (v == null) return ''; + if (v is Map) { + return '${v['displayName'] ?? v['name'] ?? v['title'] ?? v['departmentName'] ?? ''}'.trim(); + } + if (v is List) { + return v.map(scalarText).where((e) => e.isNotEmpty).join('、'); + } + final s = '$v'.trim(); + if (s.startsWith('{') && (s.contains('id:') || s.contains('tenantId'))) return ''; + return s; +} + +String pickTitle(Map row) { + for (final k in [ + 'title', + 'name', + 'displayName', + 'projectName', + 'bidNo', + 'contractNo', + 'claimNo', + 'loanNo', + 'projectNo', + 'purpose', + 'reason', + 'label', + ]) { + final s = scalarText(row[k]); + if (s.isNotEmpty) return s; + } + return row['id']?.toString() ?? '未命名'; +} + +String pickSub(Map row) { + for (final k in [ + 'departmentName', + 'department', + 'status', + 'title', + 'bidNo', + 'contractNo', + 'claimNo', + 'kind', + 'mobile', + 'lastText', + 'employmentStatus', + ]) { + final s = scalarText(row[k]); + if (s.isNotEmpty) return s; + } + return ''; +} + +String fmtTime(dynamic v) { + if (v == null) return ''; + final s = '$v'; + final t = DateTime.tryParse(s); + if (t != null) { + final local = t.toLocal(); + final date = '${local.year.toString().padLeft(4, '0')}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')}'; + final time = '${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}:${local.second.toString().padLeft(2, '0')}'; + return '$date $time'; + } + if (s.length >= 16) return s.substring(0, 16).replaceFirst('T', ' '); + return s; +} + +String shortTime(dynamic v) { + if (v == null) return ''; + final t = DateTime.tryParse('$v'); + if (t == null) return fmtTime(v); + final now = DateTime.now(); + final local = t.toLocal(); + final today = DateTime(now.year, now.month, now.day); + final day = DateTime(local.year, local.month, local.day); + final hh = local.hour.toString().padLeft(2, '0'); + final mm = local.minute.toString().padLeft(2, '0'); + if (day == today) return '$hh:$mm'; + if (day == today.subtract(const Duration(days: 1))) return '昨天'; + const week = ['一', '二', '三', '四', '五', '六', '日']; + if (today.difference(day).inDays < 7) return '周${week[local.weekday - 1]}'; + return '${local.month}/${local.day}'; +} + +const defaultApiHint = AppConfig.defaultApiBase; diff --git a/apps/native/lib/app_config.dart b/apps/native/lib/app_config.dart new file mode 100644 index 0000000..c5f9cde --- /dev/null +++ b/apps/native/lib/app_config.dart @@ -0,0 +1,13 @@ +class AppConfig { + static const brand = '风影智慧办公综合平台'; + static const shortName = '风影办公'; + static const company = '江苏风影随行科技有限公司'; + static const icp = '苏ICP备2026017371号-2'; + static const icpUrl = 'https://beian.miit.gov.cn/'; + static const phone = '13142801138'; + static const defaultApiBase = 'https://oa.fysxkj.com/api/v1'; + static const defaultImHost = '47.96.23.244'; + static const defaultImPort = 8901; + static const version = '3.1.10'; + static const build = 324; +} diff --git a/apps/native/lib/app_runtime.dart b/apps/native/lib/app_runtime.dart new file mode 100644 index 0000000..918a3c2 --- /dev/null +++ b/apps/native/lib/app_runtime.dart @@ -0,0 +1,19 @@ +import 'package:package_info_plus/package_info_plus.dart'; + +import 'app_config.dart'; + +/// 运行时从安装包读取的真实版本,避免 OTA 误判。 +class AppRuntime { + AppRuntime._(); + + static String version = AppConfig.version; + static int build = AppConfig.build; + + static Future load() async { + try { + final info = await PackageInfo.fromPlatform(); + version = info.version.isNotEmpty ? info.version : AppConfig.version; + build = int.tryParse(info.buildNumber) ?? AppConfig.build; + } catch (_) {} + } +} diff --git a/apps/native/lib/desktop/desk_platform.dart b/apps/native/lib/desktop/desk_platform.dart new file mode 100644 index 0000000..56582e6 --- /dev/null +++ b/apps/native/lib/desktop/desk_platform.dart @@ -0,0 +1,4 @@ +import 'dart:io'; + +/// Windows / Linux 桌面端,与安卓 UI 完全分离。 +bool get isDesktopPlatform => Platform.isWindows || Platform.isLinux; diff --git a/apps/native/lib/desktop/desk_shell.dart b/apps/native/lib/desktop/desk_shell.dart new file mode 100644 index 0000000..3faf534 --- /dev/null +++ b/apps/native/lib/desktop/desk_shell.dart @@ -0,0 +1,369 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../device/push_bridge.dart'; +import '../nav/notice_route.dart'; +import '../ota/updater.dart'; +import '../pages/call_page.dart'; +import '../session/session.dart'; +import '../im/im_lifecycle.dart'; +import 'desk_platform.dart'; +import 'desk_theme.dart'; +import 'pages/desk_chat_page.dart'; +import 'pages/desk_contacts_page.dart'; +import 'pages/desk_conv_list_page.dart'; +import 'pages/desk_profile_page.dart'; +import 'pages/desk_system_notice_page.dart'; +import 'pages/desk_workbench_page.dart'; +import 'widgets/desk_widgets.dart'; + +/// 桌面端主壳:仅引用 lib/desktop/*,不加载任何移动端页面组件。 +class DeskShell extends StatefulWidget { + const DeskShell({super.key, required this.session, required this.api}); + + final SessionStore session; + final OaClient api; + + @override + State createState() => _DeskShellState(); +} + +class _DeskShellState extends State { + int _nav = 0; + int _msgBadge = 0; + Timer? _badgeDebounce; + Timer? _callPoll; + String _ringingId = ''; + bool _checkingCall = false; + final _declinedCalls = {}; + ImLifecycle? _imLife; + + String? _chatConvId; + String? _chatPeerId; + String _chatPeerName = ''; + bool _chatGroup = false; + String _chatAvatar = ''; + + final List<_DeskTab> _tabs = [_DeskTab.home()]; + int _activeTab = 0; + bool _otaOnce = false; + + @override + void initState() { + super.initState(); + _imLife = ImLifecycle(widget.session)..start(); + widget.session.im.onLoggedIn = () => _refreshBadge(); + widget.session.im.addListener(_onIm); + _refreshBadge(); + PushBridge.listen(_openFromPush); + _callPoll = Timer.periodic(const Duration(seconds: 8), (_) => _checkIncoming()); + WidgetsBinding.instance.addPostFrameCallback((_) => _checkOta()); + } + + Future _checkOta() async { + if (_otaOnce) return; + _otaOnce = true; + final rel = await OtaUpdater(widget.api).fetch(); + if (rel != null && rel.newer && mounted) { + await OtaUpdater(widget.api).prompt(context, rel); + } + } + + @override + void dispose() { + _badgeDebounce?.cancel(); + _callPoll?.cancel(); + widget.session.im.onLoggedIn = null; + _imLife?.stop(); + widget.session.im.removeListener(_onIm); + PushBridge.stop(); + super.dispose(); + } + + void _onIm() { + final inbox = widget.session.im.inbox; + if (inbox.isNotEmpty && inbox.first.kind == 'readSync') { + _refreshBadge(); + return; + } + _badgeDebounce?.cancel(); + _badgeDebounce = Timer(const Duration(milliseconds: 1200), _refreshBadge); + final hit = widget.session.im.inbox.where((e) => e.kind == 'call').toList(); + if (hit.isNotEmpty) _checkIncoming(); + } + + Future _refreshBadge() async { + var n = 0; + try { + final data = await widget.api.get('/im/conversations'); + for (final r in asMaps(data)) { + n += (r['unread'] as num?)?.toInt() ?? 0; + } + } catch (_) {} + if (mounted && n != _msgBadge) setState(() => _msgBadge = n); + } + + Future _checkIncoming() async { + if (!mounted || _ringingId.isNotEmpty || _checkingCall) return; + _checkingCall = true; + try { + final rows = asMaps(await widget.api.get('/im/calls/incoming')); + final me = widget.session.userId; + Map? ring; + for (final r in rows) { + if ('${r['initiatorId']}' != me && '${r['status']}' == 'ringing') { + ring = r; + break; + } + } + if (ring == null) return; + final id = '${ring['id'] ?? ''}'; + if (id.isEmpty || id == _ringingId || _declinedCalls.contains(id)) return; + final created = DateTime.tryParse('${ring['createdAt'] ?? ''}'); + if (created != null && DateTime.now().difference(created).inSeconds > 45) { + _declinedCalls.add(id); + return; + } + await _showIncoming(ring); + } catch (_) { + } finally { + _checkingCall = false; + } + } + + Future _showIncoming(Map call, {String fallbackName = '同事'}) async { + final id = '${call['id'] ?? ''}'; + if (!mounted || id.isEmpty || _ringingId.isNotEmpty || _declinedCalls.contains(id)) return; + _ringingId = id; + await openCallPage( + context, + session: widget.session, + api: widget.api, + call: call, + peerName: '${call['fromName'] ?? call['title'] ?? fallbackName}', + incoming: true, + ); + _declinedCalls.add(id); + if (_declinedCalls.length > 40) _declinedCalls.remove(_declinedCalls.first); + if (mounted) _ringingId = ''; + } + + String _openedPush = ''; + + void _openFromPush(Map extras) { + if (extras['kind'] == 'qr-login') { + // 桌面端已登录,扫码登录是手机扫电脑二维码,不应在桌面打开扫码页 + return; + } + if (extras['kind'] == 'call') { + unawaited(_openIncomingCallFromPush(extras)); + return; + } + final cid = extras['conversationId'] ?? ''; + final kind = extras['kind'] ?? ''; + final key = '$cid|$kind|${extras['callId'] ?? extras['bizId'] ?? ''}'; + if (key == _openedPush) return; + _openedPush = key; + if (kind == 'todo' || kind == 'approval' || ((extras['bizType'] ?? '').isNotEmpty && kind != 'chat')) { + openNoticeTarget( + context, + widget.api, + session: widget.session, + kind: kind, + bizType: extras['bizType'] ?? '', + bizId: extras['bizId'] ?? '', + title: extras['title'] ?? '系统通知', + ); + return; + } + if (cid.isEmpty) { + setState(() => _nav = 0); + return; + } + _pickChat({ + 'id': cid, + 'peerId': extras['peerId'] ?? '', + 'name': extras['fromName'] ?? extras['title'] ?? '聊天', + 'type': extras['isGroup'] == '1' ? 'group' : 'direct', + 'avatarFileId': extras['avatarFileId'] ?? '', + }); + } + + Future _openIncomingCallFromPush(Map extras) async { + final callId = (extras['callId'] ?? '').isNotEmpty ? extras['callId']! : (extras['bizId'] ?? ''); + try { + Map? call; + if (callId.isNotEmpty) { + final raw = await widget.api.get('/im/calls/$callId'); + if (raw is Map) { + final row = Map.from(raw); + if ('${row['status'] ?? ''}' == 'ringing' && '${row['memberStatus'] ?? ''}' == 'invited') call = row; + } + } + if (call != null && mounted) { + call['fromName'] ??= extras['fromName']; + await _showIncoming(call, fallbackName: extras['fromName'] ?? '同事'); + return; + } + } catch (_) {} + final cid = extras['conversationId'] ?? ''; + if (mounted && cid.isNotEmpty) { + _pickChat({ + 'id': cid, + 'peerId': extras['peerId'] ?? '', + 'name': extras['fromName'] ?? extras['title'] ?? '聊天', + }); + } + } + + void _pickChat(Map row) { + setState(() { + _nav = 0; + _chatConvId = '${row['id'] ?? row['conversationId'] ?? ''}'; + _chatPeerId = '${row['peerId'] ?? row['id'] ?? ''}'; + _chatPeerName = '${row['name'] ?? row['peerName'] ?? '同事'}'; + _chatGroup = row['type'] == 'group'; + _chatAvatar = '${row['avatarFileId'] ?? ''}'; + }); + } + + void _openTab(String key, String title, IconData icon, Color color, Widget page) { + final idx = _tabs.indexWhere((t) => t.key == key); + setState(() { + _nav = 1; + if (idx >= 0) { + _activeTab = idx; + } else { + _tabs.add(_DeskTab(key: key, title: title, icon: icon, color: color, page: page)); + _activeTab = _tabs.length - 1; + } + }); + } + + void _closeTab(int i) { + if (i <= 0 || i >= _tabs.length) return; + setState(() { + _tabs.removeAt(i); + if (_activeTab >= _tabs.length) _activeTab = _tabs.length - 1; + }); + } + + Widget _messagesPane() { + return Row( + children: [ + SizedBox( + width: kDeskConvWidth, + child: DeskConvListPage( + session: widget.session, + api: widget.api, + selectedId: _chatConvId, + onSelect: _pickChat, + ), + ), + const VerticalDivider(width: 1, color: kDeskLine), + Expanded( + child: _chatConvId == null || _chatConvId!.isEmpty + ? const DeskEmptyChat() + : _chatPeerId == '0' + ? DeskSystemNoticePage(session: widget.session, api: widget.api, conversationId: _chatConvId!) + : DeskChatPage( + session: widget.session, + api: widget.api, + conversationId: _chatConvId!, + peerId: _chatPeerId ?? '', + peerName: _chatPeerName, + isGroup: _chatGroup, + peerAvatarFileId: _chatAvatar, + ), + ), + ], + ); + } + + Widget _workbenchPane() { + return Column( + children: [ + DeskTabStrip( + tabs: [for (final t in _tabs) (t.key, t.title, t.icon, t.color)], + active: _activeTab, + onSelect: (i) => setState(() => _activeTab = i), + onClose: _closeTab, + ), + Expanded( + child: IndexedStack( + index: _activeTab, + children: [ + for (final t in _tabs) + t.key == 'home' + ? DeskWorkbenchPage(session: widget.session, api: widget.api, onOpenTab: _openTab) + : t.page, + ], + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final navItems = <(IconData, IconData, String, int)>[ + (Icons.chat_bubble_outline, Icons.chat_bubble, '消息', _msgBadge), + (Icons.apps_outlined, Icons.apps, '工作台', 0), + (Icons.account_tree_outlined, Icons.account_tree, '通讯录', 0), + (Icons.person_outline, Icons.person, '我', 0), + ]; + + Widget body; + switch (_nav) { + case 0: + body = _messagesPane(); + case 1: + body = _workbenchPane(); + case 2: + body = DeskContactsPage(session: widget.session, api: widget.api, onMessage: (r) { + _pickChat({ + 'id': '${r['conversationId'] ?? ''}', + 'peerId': '${r['id'] ?? r['peerId'] ?? ''}', + 'name': '${r['name'] ?? '同事'}', + 'avatarFileId': '${r['avatarFileId'] ?? ''}', + 'type': 'direct', + }); + setState(() => _nav = 0); + }); + default: + body = DeskProfilePage(session: widget.session, api: widget.api); + } + + return Scaffold( + backgroundColor: kDeskBg, + body: Row( + children: [ + DeskNavRail( + session: widget.session, + api: widget.api, + index: _nav, + onChanged: (i) => setState(() => _nav = i), + items: navItems, + ), + const VerticalDivider(width: 1, color: kDeskLine), + Expanded(child: body), + ], + ), + ); + } +} + +class _DeskTab { + _DeskTab({required this.key, required this.title, required this.icon, required this.color, required this.page}); + factory _DeskTab.home() => _DeskTab(key: 'home', title: '工作台', icon: Icons.apps, color: kDeskBind, page: const SizedBox.shrink()); + + final String key; + final String title; + final IconData icon; + final Color color; + final Widget page; +} + +bool get isDesktopShell => isDesktopPlatform; diff --git a/apps/native/lib/desktop/desk_theme.dart b/apps/native/lib/desktop/desk_theme.dart new file mode 100644 index 0000000..c0c25fe --- /dev/null +++ b/apps/native/lib/desktop/desk_theme.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +/// 企业微信桌面端配色与尺寸,不依赖移动端 theme。 +const kDeskBg = Color(0xFFEDEDED); +const kDeskRail = Color(0xFFE7E7E7); +const kDeskPane = Colors.white; +const kDeskListBg = Color(0xFFF7F7F7); +const kDeskActive = Color(0xFFDCEBFF); +const kDeskLine = Color(0xFFE3E3E3); +const kDeskInk = Color(0xFF191919); +const kDeskMute = Color(0xFF8E8E8E); +const kDeskBind = Color(0xFF267EF0); +const kDeskGreen = Color(0xFF07C160); +const kDeskBubbleMine = Color(0xFF95EC69); + +const kDeskRailWidth = 68.0; +const kDeskConvWidth = 280.0; + +ThemeData buildDesktopTheme() { + return ThemeData( + useMaterial3: false, + scaffoldBackgroundColor: kDeskBg, + fontFamily: 'sans-serif', + colorScheme: ColorScheme.fromSeed(seedColor: kDeskBind, brightness: Brightness.light), + dividerColor: kDeskLine, + textTheme: const TextTheme( + bodyMedium: TextStyle(fontSize: 14, color: kDeskInk, height: 1.4), + titleMedium: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kDeskInk), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: const Color(0xFFF3F3F3), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: kDeskBind, width: 1), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + hintStyle: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 14), + ), + dataTableTheme: const DataTableThemeData( + headingTextStyle: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: kDeskMute), + dataTextStyle: TextStyle(fontSize: 14, color: kDeskInk), + ), + ); +} diff --git a/apps/native/lib/desktop/pages/desk_attendance_page.dart b/apps/native/lib/desktop/pages/desk_attendance_page.dart new file mode 100644 index 0000000..8597ae2 --- /dev/null +++ b/apps/native/lib/desktop/pages/desk_attendance_page.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; + +import '../../api/oa_client.dart'; +import '../../device/device_bridge.dart'; +import '../desk_theme.dart'; +import '../widgets/desk_widgets.dart'; + +class DeskAttendancePage extends StatefulWidget { + const DeskAttendancePage({super.key, required this.api}); + final OaClient api; + + @override + State createState() => _DeskAttendancePageState(); +} + +class _DeskAttendancePageState extends State { + Map _status = {}; + Map? _location; + bool _loading = true; + bool _sending = false; + String _mode = 'OFFICE'; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() => _loading = true); + try { + _location = await DeviceBridge.getLocation(); + final q = { + if (_location != null) 'lat': '${_location!['lat']}', + if (_location != null) 'lng': '${_location!['lng']}', + 'mode': _mode, + }; + final data = Map.from(await widget.api.get('/attendance/punch-status', query: q) as Map); + if (mounted) setState(() { + _status = data; + _loading = false; + }); + if (mounted && _location == null && _mode == 'OFFICE') { + deskToast(context, '未获取到定位,请允许位置权限后点刷新', error: true); + } + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + Future _punch(String kind) async { + if (_mode == 'OFFICE' && _status['tripActive'] == true) return; + if (_mode == 'FIELD' && _status['fieldApproved'] != true) return; + setState(() => _sending = true); + try { + await widget.api.post('/attendance/punch', {'kind': kind, 'mode': _mode, if (_location != null) ..._location!}); + if (mounted) deskToast(context, '打卡成功'); + await _load(); + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + String _time(dynamic value) { + if (value == null || '$value' == 'null' || '$value'.isEmpty) return '未打卡'; + final d = DateTime.tryParse('$value')?.toLocal(); + return d == null ? '$value' : '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + final inKey = _mode == 'FIELD' ? 'fieldIn' : 'clockIn'; + final outKey = _mode == 'FIELD' ? 'fieldOut' : 'clockOut'; + final inAt = _time(_status[inKey]); + final outAt = _time(_status[outKey]); + final hasIn = inAt != '未打卡'; + final hasOut = outAt != '未打卡'; + final trip = _status['tripActive'] == true; + final fieldOk = _status['fieldApproved'] == true; + final canPunch = _mode == 'FIELD' ? fieldOk : !trip && _status['inRange'] != false; + + return ColoredBox( + color: kDeskBg, + child: Column( + children: [ + DeskPaneHeader( + title: '考勤打卡', + actions: [IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20))], + ), + if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind), + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + SegmentedButton( + segments: const [ + ButtonSegment(value: 'OFFICE', label: Text('上下班打卡'), icon: Icon(Icons.access_time)), + ButtonSegment(value: 'FIELD', label: Text('外勤打卡'), icon: Icon(Icons.location_on_outlined)), + ], + selected: {_mode}, + onSelectionChanged: (v) async { + setState(() => _mode = v.first); + await _load(); + }, + ), + const SizedBox(height: 20), + Material( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Text(_today(), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700)), + const SizedBox(height: 12), + _banner(canPunch, trip, fieldOk), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _punchBtn( + enabled: canPunch && !hasIn && !_sending, + label: _mode == 'FIELD' ? '外勤上班' : '上班打卡', + onTap: () => _punch('IN'), + ), + const SizedBox(width: 16), + _punchBtn( + enabled: canPunch && hasIn && !hasOut && !_sending, + label: '下班打卡', + color: kDeskGreen, + onTap: () => _punch('OUT'), + ), + ], + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _timeBox('上班', inAt, _status['late'] == true), + _timeBox('下班', outAt, _status['early'] == true), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 12), + Material( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + child: ListTile( + leading: Icon(Icons.place, color: _status['inRange'] == true ? kDeskGreen : const Color(0xFFFA5151)), + title: Text('${_status['rangeText'] ?? '正在获取考勤范围'}'), + subtitle: Text(_status['distanceMeters'] == null ? '请开启定位后刷新' : '距离考勤点约 ${_status['distanceMeters']} 米'), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } + + String _today() { + final d = DateTime.now(); + return '${d.year}年${d.month}月${d.day}日'; + } + + Widget _banner(bool canPunch, bool trip, bool fieldOk) { + final text = _mode == 'FIELD' + ? (fieldOk ? '外出申请已通过,可进行外勤打卡' : '需先申请并审批通过外出') + : (trip ? '出差期间无需打卡' : (canPunch ? '当前可进行上下班打卡' : '未进入考勤范围')); + final color = trip || fieldOk || canPunch ? kDeskGreen : const Color(0xFFFA9D3B); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: color.withValues(alpha: .1), borderRadius: BorderRadius.circular(8)), + child: Text(text, textAlign: TextAlign.center, style: TextStyle(color: color, fontWeight: FontWeight.w600, fontSize: 13)), + ); + } + + Widget _punchBtn({required bool enabled, required String label, required VoidCallback onTap, Color? color}) { + final c = color ?? kDeskBind; + return SizedBox( + width: 140, + height: 140, + child: Material( + color: enabled ? c : const Color(0xFFE8E8E8), + shape: const CircleBorder(), + child: InkWell( + onTap: enabled ? onTap : null, + customBorder: const CircleBorder(), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: TextStyle(color: enabled ? Colors.white : kDeskMute, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text(_nowTime(), style: TextStyle(color: enabled ? Colors.white : kDeskMute, fontSize: 18, fontWeight: FontWeight.w700)), + ], + ), + ), + ), + ), + ); + } + + Widget _timeBox(String label, String value, bool flag) { + return Column( + children: [ + Text(label, style: const TextStyle(color: kDeskMute, fontSize: 12)), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + if (flag) const Padding(padding: EdgeInsets.only(left: 4), child: Icon(Icons.error, color: Color(0xFFFA9D3B), size: 16)), + ], + ), + ], + ); + } + + String _nowTime() { + final d = DateTime.now(); + return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; + } +} diff --git a/apps/native/lib/desktop/pages/desk_chat_detail_page.dart b/apps/native/lib/desktop/pages/desk_chat_detail_page.dart new file mode 100644 index 0000000..a0c0f4c --- /dev/null +++ b/apps/native/lib/desktop/pages/desk_chat_detail_page.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; + +import '../../api/oa_client.dart'; +import '../../im/chat_prefs.dart'; +import '../../session/session.dart'; +import '../desk_theme.dart'; +import '../widgets/desk_widgets.dart'; +import 'desk_pick_people_page.dart'; + +class DeskChatDetailPage extends StatefulWidget { + const DeskChatDetailPage({ + super.key, + required this.session, + required this.api, + required this.conversationId, + required this.peerId, + required this.peerName, + required this.isGroup, + this.peerAvatarFileId, + this.onCall, + }); + + final SessionStore session; + final OaClient api; + final String conversationId; + final String peerId; + final String peerName; + final bool isGroup; + final String? peerAvatarFileId; + final void Function(String kind)? onCall; + + @override + State createState() => _DeskChatDetailPageState(); +} + +class _DeskChatDetailPageState extends State { + List> _members = []; + bool _mute = false; + bool _pin = false; + + @override + void initState() { + super.initState(); + _mute = ChatPrefs.muted(widget.conversationId); + _pin = ChatPrefs.pinned(widget.conversationId); + _loadMembers(); + } + + Future _loadMembers() async { + if (widget.isGroup && widget.conversationId.isNotEmpty) { + try { + final data = await widget.api.get('/im/groups/${widget.conversationId}/members'); + if (!mounted) return; + setState(() => _members = asMaps(data)); + return; + } catch (_) {} + } + setState(() { + _members = [ + {'id': widget.peerId, 'name': widget.peerName, 'avatarFileId': widget.peerAvatarFileId}, + {'id': widget.session.userId, 'name': widget.session.displayName, 'avatarFileId': widget.session.user['avatarFileId']}, + ]; + }); + } + + Future _addMembers() async { + final exclude = {widget.session.userId, ..._members.map((e) => '${e['id'] ?? e['userId'] ?? ''}')}; + final picked = await Navigator.of(context).push>>( + MaterialPageRoute(builder: (_) => DeskPickPeoplePage(api: widget.api, title: '选择同事', exclude: exclude)), + ); + if (picked == null || picked.isEmpty) return; + try { + if (widget.isGroup && widget.conversationId.isNotEmpty) { + await widget.api.post('/im/groups/${widget.conversationId}/members', { + 'memberIds': picked.map((e) => '${e['id']}').toList(), + }); + await _loadMembers(); + } else { + await widget.api.post('/im/groups', { + 'name': '${widget.peerName}的群聊', + 'memberIds': [widget.peerId, ...picked.map((e) => '${e['id']}')], + }); + if (mounted) { + deskToast(context, '已创建群聊'); + Navigator.pop(context); + } + } + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } + } + + Future _clear() async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('删除聊天记录'), + content: const Text('将从本机清空当前会话的聊天记录,对方不受影响。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')), + ], + ), + ); + if (ok != true) return; + await ChatPrefs.clearHistory(widget.conversationId); + if (mounted) { + deskToast(context, '已删除本机聊天记录'); + Navigator.pop(context, 'cleared'); + } + } + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: kDeskBg, + child: Column( + children: [ + DeskPaneHeader( + title: '聊天详情', + leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)), + ), + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + Material( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Wrap( + spacing: 16, + runSpacing: 12, + children: [ + for (final m in _members) + SizedBox( + width: 56, + child: Column( + children: [ + DeskAvatar( + label: '${m['name'] ?? m['displayName'] ?? ''}', + fileId: '${m['avatarFileId'] ?? ''}', + api: widget.api, + size: 44, + ), + const SizedBox(height: 4), + Text('${m['name'] ?? m['displayName'] ?? ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)), + ], + ), + ), + InkWell( + onTap: _addMembers, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), border: Border.all(color: kDeskLine)), + child: const Icon(Icons.add, color: kDeskMute), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + _section([ + _switchRow('消息免打扰', _mute, (v) async { + await ChatPrefs.setMuted(widget.conversationId, v); + setState(() => _mute = v); + }), + _switchRow('置顶聊天', _pin, (v) async { + await ChatPrefs.setPinned(widget.conversationId, v); + setState(() => _pin = v); + }), + ]), + const SizedBox(height: 12), + _section([ + _actionRow('语音通话', () => widget.onCall?.call('voice')), + _actionRow('视频通话', () => widget.onCall?.call(widget.isGroup ? 'meeting' : 'video')), + _actionRow('删除聊天记录', _clear, danger: true), + ]), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _section(List children) { + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + clipBehavior: Clip.antiAlias, + child: Column(children: children), + ); + } + + Widget _switchRow(String title, bool value, ValueChanged onChanged) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Expanded(child: Text(title, style: const TextStyle(fontSize: 14))), + Switch(value: value, activeTrackColor: kDeskGreen, onChanged: onChanged), + ], + ), + ); + } + + Widget _actionRow(String title, VoidCallback onTap, {bool danger = false}) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Expanded(child: Text(title, style: TextStyle(fontSize: 14, color: danger ? const Color(0xFFFA5151) : kDeskInk))), + if (!danger) const Icon(Icons.chevron_right, size: 18, color: kDeskMute), + ], + ), + ), + ), + ); + } +} diff --git a/apps/native/lib/desktop/pages/desk_chat_page.dart b/apps/native/lib/desktop/pages/desk_chat_page.dart new file mode 100644 index 0000000..1560c84 --- /dev/null +++ b/apps/native/lib/desktop/pages/desk_chat_page.dart @@ -0,0 +1,1011 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; + +import '../../api/oa_client.dart'; +import '../../device/device_bridge.dart'; +import '../../im/chat_local_store.dart'; +import '../../im/chat_prefs.dart'; +import '../../im/im_seq_store.dart'; +import '../../im/sticker_store.dart'; +import '../../pages/call_page.dart'; +import '../../pages/location_pick_page.dart'; +import 'desk_chat_detail_page.dart'; +import 'desk_pick_people_page.dart'; +import 'desk_record_detail_page.dart'; +import '../../session/session.dart'; +import '../../widgets/auth_image.dart'; +import '../../widgets/location_view_page.dart'; +import '../../widgets/tencent_map_thumb.dart'; +import '../../widgets/voice_message.dart'; +import '../desk_theme.dart'; +import '../widgets/desk_widgets.dart'; + +const _emojis = ['😀', '😁', '😂', '🤣', '😊', '😎', '😍', '🥰', '🙂', '🤔', '😐', '😏', '👍', '👏', '🙏', '❤️', '🎉', '🔥', '✅', '❌']; + +class DeskChatPage extends StatefulWidget { + const DeskChatPage({ + super.key, + required this.session, + required this.api, + required this.conversationId, + required this.peerId, + required this.peerName, + this.isGroup = false, + this.peerAvatarFileId = '', + }); + + final SessionStore session; + final OaClient api; + final String conversationId; + final String peerId; + final String peerName; + final bool isGroup; + final String peerAvatarFileId; + + @override + State createState() => _DeskChatPageState(); +} + +class _DeskChatPageState extends State { + final _input = TextEditingController(); + final _scroll = ScrollController(); + final _focus = FocusNode(); + List> _items = []; + String _convId = ''; + bool _sending = false; + bool _emoji = false; + int _emojiTab = 0; + bool _voice = false; + bool _recording = false; + bool _cancelVoice = false; + bool _pressed = false; + bool _firstLoad = true; + String? _playingId; + final Set _voiceTextLoading = {}; + Timer? _playTimer; + Timer? _receiptPoll; + Timer? _imDebounce; + DateTime? _voiceDown; + Future? _voiceStart; + int _loadSeq = 0; + + @override + void initState() { + super.initState(); + _convId = widget.conversationId; + widget.session.im.addListener(_onIm); + DeviceBridge.watchScreenshot(_onScreenshot); + ChatPrefs.ensure().then((_) => StickerStore.ensure()); + _load(jump: true); + _ackRead(); + _receiptPoll = Timer.periodic(const Duration(seconds: 10), (_) { + if (mounted) { + _ackRead(); + _load(); + } + }); + } + + @override + void dispose() { + _receiptPoll?.cancel(); + _imDebounce?.cancel(); + _playTimer?.cancel(); + DeviceBridge.stopPlay(); + DeviceBridge.unwatchScreenshot(_onScreenshot); + widget.session.im.removeListener(_onIm); + _input.dispose(); + _scroll.dispose(); + _focus.dispose(); + super.dispose(); + } + + void _onIm() { + _ackRead(); + _imDebounce?.cancel(); + _imDebounce = Timer(const Duration(milliseconds: 400), () { + if (mounted) _load(); + }); + } + + Map get _query { + if (_convId.isNotEmpty) return {'conversationId': _convId}; + return {'peerId': widget.peerId}; + } + + Map _metaOf(Map r) { + final m = r['meta']; + if (m is Map) return Map.from(m); + if (m is String && m.isNotEmpty) { + try { + final d = jsonDecode(m); + if (d is Map) return Map.from(d); + } catch (_) {} + } + return {}; + } + + Future _ackRead() async { + if (_convId.isEmpty && widget.peerId.isEmpty) return; + try { + await widget.api.post('/im/read', { + if (_convId.isNotEmpty) 'conversationId': _convId, + if (widget.peerId.isNotEmpty) 'peerId': widget.peerId, + }); + } catch (_) {} + } + + Future _load({bool jump = false}) async { + final seq = ++_loadSeq; + try { + await ImSeqStore.ensure(); + final query = Map.from(_query); + if (_convId.isNotEmpty) { + final since = await ImSeqStore.lastSeq(_convId); + if (since > 0) query['sinceSeq'] = '$since'; + } + final data = await widget.api.get('/im/messages', query: query); + final items = data is Map ? asMaps(data['items'] ?? data) : asMaps(data); + if (data is Map && data['conversationId'] != null) _convId = '${data['conversationId']}'; + final local = await ChatLocalStore.load(_convId); + final byKey = >{}; + for (final e in [...local, ...items]) { + final fp = '${e['fingerprint'] ?? ''}'; + final s = (e['seq'] as num?)?.toInt() ?? 0; + final key = fp.isNotEmpty ? 'fp:$fp' : (s > 0 ? 'seq:$s' : 'id:${e['id'] ?? ''}'); + if (key != '|' && key != 'id:') byKey[key] = e; + } + final merged = byKey.values.toList() + ..sort((a, b) { + final sa = (a['seq'] as num?)?.toInt() ?? 0; + final sb = (b['seq'] as num?)?.toInt() ?? 0; + if (sa > 0 && sb > 0 && sa != sb) return sa.compareTo(sb); + return '${a['createdAt'] ?? ''}'.compareTo('${b['createdAt'] ?? ''}'); + }); + final cut = ChatPrefs.clearedAt(_convId); + final shown = cut == null + ? merged + : merged.where((e) { + final t = DateTime.tryParse('${e['createdAt'] ?? ''}'); + return t == null || t.isAfter(cut); + }).toList(); + if (!mounted || seq != _loadSeq) return; + final pending = _items.where((e) => e['_pending'] == true).toList(); + final fps = {for (final e in shown) '${e['fingerprint'] ?? ''}'}; + final keep = pending.where((e) => !fps.contains('${e['fingerprint'] ?? ''}')).toList(); + setState(() => _items = [...shown, ...keep]); + await ChatLocalStore.save(_convId, merged); + if (_convId.isNotEmpty) await ImSeqStore.applyItems(_convId, merged); + if (jump || _firstLoad) { + _firstLoad = false; + await Future.delayed(const Duration(milliseconds: 50)); + _jumpBottom(); + } + } catch (_) {} + } + + void _jumpBottom() { + if (_scroll.hasClients) _scroll.jumpTo(_scroll.position.maxScrollExtent); + } + + Future _post(Map body) async { + final fp = '${body['fingerprint'] ?? const Uuid().v4()}'; + final local = { + 'id': 'local-$fp', + 'fromUserId': widget.session.userId, + 'fromName': widget.session.displayName, + 'body': body['body'], + 'contentType': body['contentType'] ?? 'text', + 'kind': 'chat', + 'fingerprint': fp, + 'meta': body['meta'], + 'createdAt': DateTime.now().toIso8601String(), + '_pending': true, + }; + if (mounted) { + setState(() => _items = [..._items, local]); + WidgetsBinding.instance.addPostFrameCallback((_) => _jumpBottom()); + } + try { + await widget.api.post('/im/messages', { + if (widget.peerId.isNotEmpty) 'peerId': widget.peerId, + if (_convId.isNotEmpty) 'conversationId': _convId, + 'fingerprint': fp, + ...body, + }); + widget.session.im.bump(); + await _load(jump: true); + } catch (e) { + if (mounted) { + setState(() => _items = _items.where((e) => e['id'] != local['id']).toList()); + deskToast(context, '$e', error: true); + } + } + } + + Future _send() async { + final text = _input.text.trim(); + if (text.isEmpty || _sending) return; + _input.clear(); + setState(() => _emoji = false); + await _post({'body': text}); + } + + Future _onScreenshot() async { + try { + if (_convId.isEmpty && widget.peerId.isEmpty) return; + await widget.api.post('/im/events/screenshot', { + if (_convId.isNotEmpty) 'conversationId': _convId, + if (widget.peerId.isNotEmpty) 'peerId': widget.peerId, + }); + await _load(); + } catch (_) {} + } + + Future> _upload(String path, String name, String bizType, {String? mime}) async { + final bizId = _convId.isNotEmpty ? _convId : widget.session.userId; + return widget.api.uploadFile(filePath: path, filename: name, bizType: bizType, bizId: bizId, mime: mime); + } + + Future _pickImage() async { + try { + final picked = await DeviceBridge.pickMedia(); + if (picked == null || (picked['path'] ?? '').isEmpty) return; + setState(() => _sending = true); + final file = await _upload(picked['path']!, picked['name'] ?? 'image.jpg', 'IM_IMAGE', mime: picked['mime']); + await _post({ + 'body': '[图片]', + 'contentType': 'image', + 'meta': {'fileId': file['id'], 'fileName': file['fileName'] ?? picked['name']}, + }); + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + Future _pickFile() async { + try { + final picked = await DeviceBridge.pickFile(); + if (picked == null || (picked['path'] ?? '').isEmpty) return; + setState(() => _sending = true); + final name = picked['name'] ?? '文件'; + final file = await _upload(picked['path']!, name, 'IM_FILE', mime: picked['mime']); + await _post({ + 'body': '[文件] $name', + 'contentType': 'file', + 'meta': {'fileId': file['id'], 'fileName': file['fileName'] ?? name, 'size': file['size']}, + }); + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + Future _sendSticker(String fileId) async { + await _post({ + 'body': '[表情]', + 'contentType': 'sticker', + 'meta': {'fileId': fileId}, + }); + if (mounted) setState(() => _emoji = false); + } + + Future _addCustomSticker() async { + try { + final picked = await DeviceBridge.pickMedia(); + if (picked == null || (picked['path'] ?? '').isEmpty) return; + setState(() => _sending = true); + final file = await _upload(picked['path']!, picked['name'] ?? 'sticker.gif', 'IM_STICKER', mime: picked['mime']); + final id = '${file['id']}'; + await StickerStore.add(id); + await _sendSticker(id); + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + Future _saveChatImage(String fileId) async { + try { + final bytes = await widget.api.fileBytes(fileId); + if (bytes == null || bytes.isEmpty) throw Exception('图片无法下载'); + final dir = await getTemporaryDirectory(); + final f = File('${dir.path}/img_$fileId.jpg'); + await f.writeAsBytes(bytes, flush: true); + await DeviceBridge.saveImage(f.path); + if (mounted) deskToast(context, '已保存'); + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } + } + + Future _call(String kind) async { + try { + final data = await widget.api.post('/im/calls', { + if (_convId.isNotEmpty) 'conversationId': _convId, + if (widget.peerId.isNotEmpty) 'peerId': widget.peerId, + 'kind': kind, + }); + if (!mounted || data is! Map) return; + openCallPage(context, session: widget.session, api: widget.api, call: Map.from(data), peerName: widget.peerName); + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } + } + + Future _location() async { + final picked = await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => LocationPickPage(api: widget.api), + )); + if (picked == null) return; + await _post({ + 'body': picked.title, + 'contentType': 'location', + 'meta': picked.toMeta(), + }); + } + + Future _forwardMessage(Map r) async { + final picked = await Navigator.of(context).push>>(MaterialPageRoute( + builder: (_) => DeskPickPeoplePage(api: widget.api, title: '转发给', multiple: true, exclude: {widget.session.userId}), + )); + if (picked == null || picked.isEmpty) return; + final meta = _metaOf(r); + try { + for (final p in picked) { + await widget.api.post('/im/messages', { + 'peerId': '${p['id']}', + 'body': r['body'] ?? '', + 'contentType': r['contentType'] ?? 'text', + if (meta.isNotEmpty) 'meta': meta, + }); + } + if (mounted) deskToast(context, '已转发给 ${picked.length} 人'); + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } + } + + Future _editMessage(Map r) async { + final id = '${r['id'] ?? ''}'; + if (id.isEmpty || id.startsWith('local-')) return; + final c = TextEditingController(text: '${r['body'] ?? ''}'); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(r['recalledAt'] != null ? '重新编辑' : '编辑消息'), + content: TextField(controller: c, maxLines: 4, autofocus: true), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('保存')), + ], + ), + ); + if (ok != true) return; + try { + await widget.api.patch('/im/messages/$id', {'body': c.text.trim()}); + await _load(); + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } + } + + void _openLocation(Map meta, String fallback) { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => LocationViewPage( + api: widget.api, + title: '${meta['title'] ?? fallback}', + address: '${meta['address'] ?? ''}', + lat: (meta['lat'] as num?)?.toDouble(), + lng: (meta['lng'] as num?)?.toDouble(), + ), + )); + } + + Future _recall(Map r) async { + final id = '${r['id'] ?? ''}'; + if (id.isEmpty || id.startsWith('local-')) return; + try { + await widget.api.post('/im/messages/$id/recall', {}); + await _load(); + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } + } + + Future _playVoice(Map r) async { + final meta = _metaOf(r); + final fileId = '${meta['fileId'] ?? ''}'; + final sec = (meta['seconds'] as num?)?.toInt() ?? 1; + if (fileId.isEmpty) return; + if (_playingId == fileId) { + await DeviceBridge.stopPlay(); + _playTimer?.cancel(); + setState(() => _playingId = null); + return; + } + try { + final bytes = await widget.api.fileBytes(fileId); + if (bytes == null || bytes.isEmpty) throw Exception('语音无法下载'); + final dir = await getTemporaryDirectory(); + final f = File('${dir.path}/voice_$fileId.m4a'); + await f.writeAsBytes(bytes, flush: true); + await DeviceBridge.playVoice(f.path); + _playTimer?.cancel(); + setState(() => _playingId = fileId); + _playTimer = Timer(Duration(seconds: sec.clamp(1, 60) + 1), () { + if (mounted) setState(() => _playingId = null); + }); + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } + } + + Future _voiceToText(Map row, String fileId) async { + final key = _voiceKey(row, fileId); + final cached = ChatPrefs.voiceText(key); + if (cached != null && cached.isNotEmpty) { + setState(() {}); + return; + } + if (_voiceTextLoading.contains(key)) return; + setState(() => _voiceTextLoading.add(key)); + try { + final bytes = await widget.api.fileBytes(fileId); + if (bytes == null || bytes.isEmpty) throw Exception('语音无法下载'); + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/asr_$fileId.m4a'); + await file.writeAsBytes(bytes, flush: true); + final text = await DeviceBridge.speechToText(audioPath: file.path); + if (!mounted) return; + if (text == null || text.trim().isEmpty) { + deskToast(context, '未识别到文字,请在手机端使用', error: true); + return; + } + await ChatPrefs.setVoiceText(key, text.trim()); + if (mounted) setState(() {}); + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } finally { + if (mounted) setState(() => _voiceTextLoading.remove(key)); + } + } + + String _voiceKey(Map row, String fileId) { + final id = '${row['id'] ?? ''}'; + return id.isNotEmpty ? id : fileId; + } + + Future _beginVoice() async { + setState(() { + _recording = true; + _cancelVoice = false; + }); + _voiceDown = DateTime.now(); + _voiceStart = DeviceBridge.startVoice(); + try { + await _voiceStart; + if (!_pressed) { + await DeviceBridge.cancelVoice(); + if (mounted) setState(() => _recording = false); + } + } catch (e) { + if (mounted) { + setState(() => _recording = false); + deskToast(context, '$e', error: true); + } + } + } + + Future _endVoice() async { + if (!_pressed && !_recording) return; + final cancel = _cancelVoice; + final start = _voiceDown; + _pressed = false; + _voiceDown = null; + if (mounted) { + setState(() { + _recording = false; + _cancelVoice = false; + }); + } + try { + await _voiceStart; + } catch (_) { + await DeviceBridge.cancelVoice(); + return; + } + if (cancel || start == null) { + await DeviceBridge.cancelVoice(); + return; + } + final ms = DateTime.now().difference(start).inMilliseconds; + if (ms < 700) { + await DeviceBridge.cancelVoice(); + if (mounted) deskToast(context, '说话时间太短'); + return; + } + try { + final file = await DeviceBridge.stopVoice(); + final path = '${file?['path'] ?? ''}'; + if (path.isEmpty) { + if (mounted) deskToast(context, '说话时间太短'); + return; + } + setState(() => _sending = true); + final sec = (ms / 1000).round().clamp(1, 60); + final up = await _upload(path, file?['name']?.toString() ?? 'voice.m4a', 'IM_VOICE', mime: 'audio/mp4'); + await _post({ + 'body': '[语音] $sec″', + 'contentType': 'voice', + 'meta': {'fileId': up['id'], 'seconds': sec, 'fileName': up['fileName']}, + }); + } catch (e) { + await DeviceBridge.cancelVoice(); + if (mounted) deskToast(context, '$e', error: true); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + Future _bubbleMenu(Map r, Offset pos) async { + final mine = '${r['fromUserId']}' == widget.session.userId; + final ctype = '${r['contentType'] ?? 'text'}'; + final meta = _metaOf(r); + final fileId = '${meta['fileId'] ?? ''}'; + final created = DateTime.tryParse('${r['createdAt'] ?? ''}'); + final canRecall = mine && ctype == 'text' && r['recalledAt'] == null && created != null && DateTime.now().difference(created).inMinutes < 2; + final canEdit = mine && ctype == 'text' && (r['recalledAt'] != null || (created != null && DateTime.now().difference(created).inMinutes < 2)); + final action = await showMenu( + context: context, + position: RelativeRect.fromLTRB(pos.dx, pos.dy, pos.dx + 1, pos.dy + 1), + items: [ + if (ctype == 'text') const PopupMenuItem(value: 'copy', child: Text('复制')), + if (fileId.isNotEmpty && (ctype == 'image' || ctype == 'sticker')) const PopupMenuItem(value: 'view', child: Text('查看')), + if (fileId.isNotEmpty && (ctype == 'image' || ctype == 'sticker')) const PopupMenuItem(value: 'save', child: Text('保存图片')), + if (ctype == 'sticker' && fileId.isNotEmpty) + PopupMenuItem(value: 'sticker', child: Text(StickerStore.has(fileId) ? '已在表情' : '添加到表情')), + if (ctype == 'voice' && fileId.isNotEmpty) const PopupMenuItem(value: 'voiceText', child: Text('语音转文字')), + const PopupMenuItem(value: 'forward', child: Text('转发')), + if (canEdit) PopupMenuItem(value: 'edit', child: Text(r['recalledAt'] != null ? '重新编辑' : '编辑')), + if (canRecall) const PopupMenuItem(value: 'recall', child: Text('撤回')), + ], + ); + if (!mounted || action == null) return; + if (action == 'copy') { + await Clipboard.setData(ClipboardData(text: '${r['body'] ?? ''}')); + deskToast(context, '已复制'); + } else if (action == 'view') { + _previewImage(fileId); + } else if (action == 'save') { + await _saveChatImage(fileId); + } else if (action == 'sticker') { + await StickerStore.add(fileId); + if (mounted) deskToast(context, '已添加到表情'); + } else if (action == 'voiceText') { + await _voiceToText(r, fileId); + } else if (action == 'forward') { + await _forwardMessage(r); + } else if (action == 'edit') { + await _editMessage(r); + } else if (action == 'recall') { + await _recall(r); + } + } + + void _previewImage(String fileId) { + showDialog( + context: context, + builder: (_) => Dialog( + backgroundColor: Colors.black, + insetPadding: const EdgeInsets.all(24), + child: InteractiveViewer(child: AuthImage(fileId: fileId, api: widget.api, fit: BoxFit.contain)), + ), + ); + } + + Future _openBusiness(Map meta) async { + final path = '${meta['fetchPath'] ?? ''}'; + if (!path.startsWith('/') || path.contains('..')) return; + try { + final raw = await widget.api.get(path); + if (!mounted) return; + final row = raw is Map ? Map.from(raw) : {}; + row['_fetch'] = path; + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => DeskRecordDetailPage(api: widget.api, seed: row, title: '${meta['title'] ?? '详情'}'), + )); + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } + } + + Future _openFile(String fileId, String name, String mime) async { + try { + final bytes = await widget.api.fileBytes(fileId); + if (bytes == null) throw Exception('无法下载'); + final dir = await getTemporaryDirectory(); + final f = File('${dir.path}/${name.replaceAll(RegExp(r'[/\\]'), '_')}'); + await f.writeAsBytes(bytes, flush: true); + await DeviceBridge.openFile(f.path, mime: mime); + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } + } + + void _openDetail() { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => DeskChatDetailPage( + session: widget.session, + api: widget.api, + conversationId: _convId, + peerId: widget.peerId, + peerName: widget.peerName, + isGroup: widget.isGroup, + peerAvatarFileId: widget.peerAvatarFileId, + onCall: _call, + ), + )); + } + + @override + Widget build(BuildContext context) { + final me = widget.session.userId; + final myAvatar = '${widget.session.user['avatarFileId'] ?? ''}'; + return ColoredBox( + color: kDeskBg, + child: Column( + children: [ + DeskPaneHeader( + title: widget.peerName, + actions: [ + IconButton(onPressed: () => _call('voice'), icon: const Icon(Icons.call_outlined, size: 20), tooltip: '语音通话'), + IconButton(onPressed: () => _call('video'), icon: const Icon(Icons.videocam_outlined, size: 20), tooltip: '视频通话'), + IconButton(onPressed: _openDetail, icon: const Icon(Icons.more_horiz, size: 20)), + ], + ), + Expanded( + child: ListView.builder( + controller: _scroll, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + itemCount: _items.length, + itemBuilder: (_, i) => _bubble(_items[i], i, me, myAvatar), + ), + ), + _composer(), + if (_emoji) _emojiPanel(), + ], + ), + ); + } + + Widget _composer() { + return ColoredBox( + color: kDeskPane, + child: Column( + children: [ + const Divider(height: 1, color: kDeskLine), + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Row( + children: [ + _toolBtn( + _voice ? Icons.keyboard_outlined : Icons.mic_none_outlined, + () => setState(() { + _voice = !_voice; + _emoji = false; + }), + tooltip: _voice ? '键盘' : '语音', + ), + _toolBtn(Icons.emoji_emotions_outlined, () => setState(() { + _emoji = !_emoji; + _voice = false; + })), + _toolBtn(Icons.content_cut_outlined, _onScreenshot, tooltip: '截屏通知'), + _toolBtn(Icons.image_outlined, _pickImage), + _toolBtn(Icons.folder_open_outlined, _pickFile), + _toolBtn(Icons.place_outlined, _location, tooltip: '位置'), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: _voice + ? Listener( + onPointerDown: (_) { + _pressed = true; + _beginVoice(); + }, + onPointerUp: (_) => _endVoice(), + onPointerCancel: (_) => _endVoice(), + child: Container( + height: 80, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _recording ? const Color(0xFFE0E0E0) : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: kDeskLine), + ), + child: Text( + _recording ? '松开 发送' : '按住 说话', + style: TextStyle(fontSize: 14, color: _recording ? kDeskGreen : kDeskInk), + ), + ), + ) + : Container( + constraints: const BoxConstraints(minHeight: 80, maxHeight: 160), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: TextField( + controller: _input, + focusNode: _focus, + maxLines: 6, + minLines: 3, + style: const TextStyle(fontSize: 14), + decoration: const InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + hintText: '输入消息', + isDense: true, + contentPadding: EdgeInsets.zero, + ), + onSubmitted: (_) => _send(), + ), + ), + ), + const SizedBox(width: 10), + if (!_voice) + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: kDeskGreen, + minimumSize: const Size(72, 36), + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + onPressed: _sending ? null : _send, + child: const Text('发送(S)', style: TextStyle(fontSize: 13)), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _toolBtn(IconData icon, VoidCallback onTap, {String? tooltip}) { + return IconButton( + onPressed: onTap, + icon: Icon(icon, size: 20, color: const Color(0xFF5C5C5C)), + tooltip: tooltip, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(6), + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ); + } + + Widget _emojiPanel() { + return ColoredBox( + color: kDeskPane, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 180, + child: _emojiTab == 0 + ? GridView.count( + crossAxisCount: 10, + padding: const EdgeInsets.all(8), + children: [ + for (final e in _emojis) + InkWell( + onTap: () { + _input.text += e; + _input.selection = TextSelection.collapsed(offset: _input.text.length); + }, + child: Center(child: Text(e, style: const TextStyle(fontSize: 22))), + ), + ], + ) + : GridView.count( + crossAxisCount: 6, + padding: const EdgeInsets.all(8), + children: [ + InkWell( + onTap: _addCustomSticker, + child: Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration(border: Border.all(color: kDeskLine), borderRadius: BorderRadius.circular(6)), + child: const Icon(Icons.add, color: kDeskMute), + ), + ), + for (final id in StickerStore.ids) + GestureDetector( + onTap: () => _sendSticker(id), + child: Padding( + padding: const EdgeInsets.all(4), + child: AuthImage(fileId: id, api: widget.api, width: 48, height: 48, fit: BoxFit.contain), + ), + ), + ], + ), + ), + Row( + children: [ + _emojiTabBtn(0, '表情'), + _emojiTabBtn(1, '自定义'), + ], + ), + ], + ), + ); + } + + Widget _emojiTabBtn(int i, String label) { + final on = _emojiTab == i; + return InkWell( + onTap: () => setState(() => _emojiTab = i), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: on ? kDeskBg : Colors.transparent, + border: on ? const Border(top: BorderSide(color: kDeskLine)) : null, + ), + child: Text(label, style: TextStyle(fontSize: 13, color: on ? kDeskInk : kDeskMute)), + ), + ); + } + + Widget _bubble(Map r, int i, String me, String myAvatar) { + final mine = '${r['fromUserId']}' == me; + final ctype = '${r['contentType'] ?? 'text'}'; + var body = '${r['body'] ?? ''}'; + final showTime = i == 0 || shortTime(r['createdAt']) != shortTime(_items[i - 1]['createdAt']); + + if (r['recalledAt'] != null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Center(child: Text(mine ? '你撤回了一条消息' : '对方撤回了一条消息', style: const TextStyle(fontSize: 12, color: kDeskMute))), + ); + } + + final meta = _metaOf(r); + final fileId = '${meta['fileId'] ?? ''}'; + final avatarId = mine ? myAvatar : '${r['fromAvatarFileId'] ?? widget.peerAvatarFileId}'; + final sticker = ctype == 'sticker' && fileId.isNotEmpty; + final image = fileId.isNotEmpty && ctype == 'image'; + final fileMsg = ctype == 'file'; + final business = ctype == 'business'; + final voice = ctype == 'voice'; + final loc = ctype == 'location'; + final sec = (meta['seconds'] as num?)?.toInt() ?? 1; + final locLat = (meta['lat'] as num?)?.toDouble(); + final locLng = (meta['lng'] as num?)?.toDouble(); + + return Column( + children: [ + if (showTime) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text(shortTime(r['createdAt']), style: const TextStyle(fontSize: 11, color: kDeskMute)), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: mine ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + if (!mine) ...[ + DeskAvatar(label: '${r['fromName'] ?? widget.peerName}', fileId: avatarId, api: widget.api, size: 36), + const SizedBox(width: 8), + ], + Flexible( + child: voice + ? GestureDetector( + onSecondaryTapDown: (d) => _bubbleMenu(r, d.globalPosition), + child: VoiceMessageBlock( + mine: mine, + seconds: sec, + playing: _playingId == fileId, + transcript: ChatPrefs.voiceText(_voiceKey(r, fileId)), + loading: + _voiceTextLoading.contains(_voiceKey(r, fileId)), + onTap: () => _playVoice(r), + bubbleColor: kDeskBubbleMine, + iconColor: kDeskInk, + bottomMargin: 10, + ), + ) + : GestureDetector( + onSecondaryTapDown: (d) => _bubbleMenu(r, d.globalPosition), + onTap: business + ? () => _openBusiness(meta) + : loc + ? () => _openLocation(meta, body) + : (image || sticker) && fileId.isNotEmpty + ? () => _previewImage(fileId) + : (fileMsg && fileId.isNotEmpty) + ? () => _openFile(fileId, '${meta['fileName'] ?? body}', '${meta['mime'] ?? ''}') + : null, + child: Container( + margin: const EdgeInsets.only(bottom: 10), + padding: sticker || image ? const EdgeInsets.all(4) : const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + constraints: BoxConstraints(maxWidth: sticker ? 140 : (image ? 320 : (voice ? 200 : (loc ? 240 : 480)))), + decoration: BoxDecoration( + color: sticker || image ? Colors.transparent : (mine ? kDeskBubbleMine : Colors.white), + borderRadius: BorderRadius.circular(6), + boxShadow: sticker || image || mine + ? null + : [BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 2, offset: const Offset(0, 1))], + ), + child: sticker + ? AuthImage(fileId: fileId, api: widget.api, width: 120, height: 120, fit: BoxFit.contain) + : image + ? ClipRRect( + borderRadius: BorderRadius.circular(4), + child: AuthImage(fileId: fileId, api: widget.api, width: 240, height: 180, fit: BoxFit.cover), + ) + : business + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${meta['title'] ?? '业务卡片'}', style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text(body, style: const TextStyle(fontSize: 13, color: kDeskMute)), + ], + ) + : loc + ? SizedBox( + width: 220, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(4, 2, 4, 0), + child: Text( + meta['title']?.toString().isNotEmpty == true ? '${meta['title']}' : body, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + ), + if ('${meta['address'] ?? ''}'.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(4, 2, 4, 4), + child: Text('${meta['address']}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: kDeskMute)), + ), + if (locLat != null && locLng != null) + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: TencentMapThumb(api: widget.api, lat: locLat, lng: locLng, width: 212, height: 100), + ), + ], + ), + ) + : Text(body, style: const TextStyle(fontSize: 14, height: 1.45)), + ), + ), + ), + if (mine) const SizedBox(width: 8), + if (mine) DeskAvatar(label: widget.session.displayName, fileId: myAvatar, api: widget.api, size: 36), + ], + ), + ], + ); + } +} diff --git a/apps/native/lib/desktop/pages/desk_contacts_page.dart b/apps/native/lib/desktop/pages/desk_contacts_page.dart new file mode 100644 index 0000000..8f1fb24 --- /dev/null +++ b/apps/native/lib/desktop/pages/desk_contacts_page.dart @@ -0,0 +1,279 @@ +import 'package:flutter/material.dart'; + +import '../../api/oa_client.dart'; +import '../../im/pinyin.dart'; +import 'desk_org_browse_page.dart'; +import '../../session/session.dart'; +import '../desk_theme.dart'; +import '../widgets/desk_widgets.dart'; + +class DeskContactsPage extends StatefulWidget { + const DeskContactsPage({super.key, required this.session, required this.api, this.onMessage}); + + final SessionStore session; + final OaClient api; + final void Function(Map person)? onMessage; + + @override + State createState() => _DeskContactsPageState(); +} + +class _DeskContactsPageState extends State { + List> _items = []; + Map> _presence = {}; + String _q = ''; + bool _loading = true; + String? _selectedId; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final data = await widget.api.get('/staff'); + Map> presence = {}; + try { + final ids = asMaps(data).map((e) => '${e['id']}').where((e) => e.isNotEmpty).join(','); + final raw = await widget.api.get('/im/presence', query: {'userIds': ids}); + if (raw is Map) { + presence = raw.map((k, v) => MapEntry('$k', (v is List ? v : const []).map((x) => '$x').toList())); + } + } catch (_) {} + if (!mounted) return; + setState(() { + _items = asMaps(data); + _presence = presence; + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + List> get _shown { + if (_q.isEmpty) return _items; + return _items.where((e) => '${e['name']}${e['mobile']}${e['department']}'.contains(_q)).toList(); + } + + Map>> get _groups { + final g = >>{}; + for (final r in _shown) { + final letter = letterOf('${r['name'] ?? ''}'); + g.putIfAbsent(letter, () => []).add(r); + } + for (final k in g.keys) { + g[k]!.sort((a, b) => '${a['name']}'.compareTo('${b['name']}')); + } + return g; + } + + Future _message(Map r) async { + if (widget.onMessage != null) { + widget.onMessage!(r); + return; + } + String conversationId = ''; + try { + final raw = await widget.api.post('/im/conversations/direct', {'peerId': '${r['id']}'}); + if (raw is Map) conversationId = '${raw['conversationId'] ?? raw['id'] ?? ''}'; + } catch (_) {} + if (!mounted) return; + widget.onMessage?.call({ + ...r, + 'conversationId': conversationId, + 'peerId': '${r['id']}', + 'name': '${r['name'] ?? '同事'}', + 'type': 'direct', + }); + } + + @override + Widget build(BuildContext context) { + final groups = _groups; + final letters = groups.keys.toList()..sort(); + final depts = _items.map((e) => '${e['department'] ?? ''}').where((e) => e.isNotEmpty).toSet(); + final selected = _selectedId == null ? null : _items.cast?>().firstWhere( + (e) => '${e?['id']}' == _selectedId, + orElse: () => null, + ); + + return ColoredBox( + color: kDeskBg, + child: Row( + children: [ + SizedBox( + width: 300, + child: ColoredBox( + color: kDeskListBg, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: DeskSearchField(hint: '搜索同事', onChanged: (v) => setState(() => _q = v.trim())), + ), + if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind), + Expanded( + child: ListView( + children: [ + if (_q.isEmpty) ...[ + _entry(Icons.account_tree, '组织架构', '${depts.length} 个部门', () { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => DeskOrgBrowsePage( + session: widget.session, + api: widget.api, + staff: _items, + onMessage: widget.onMessage, + ), + )); + }), + const Divider(height: 1, color: kDeskLine), + ], + for (final letter in letters) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), + child: Text(letter, style: const TextStyle(fontSize: 12, color: kDeskMute, fontWeight: FontWeight.w600)), + ), + for (final r in groups[letter]!) + _personTile(r), + ], + ], + ), + ), + ], + ), + ), + ), + const VerticalDivider(width: 1, color: kDeskLine), + Expanded( + child: selected == null + ? const Center(child: Text('选择联系人查看详情', style: TextStyle(color: kDeskMute))) + : _detail(selected), + ), + ], + ), + ); + } + + Widget _entry(IconData icon, String title, String sub, VoidCallback onTap) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: Row( + children: [ + DeskAvatar(label: title, api: widget.api, size: 40, color: kDeskBind, icon: icon), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + Text(sub, style: const TextStyle(fontSize: 12, color: kDeskMute)), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _personTile(Map r) { + final id = '${r['id']}'; + final online = (_presence[id] ?? const []).contains('online'); + return Material( + color: _selectedId == id ? kDeskActive : Colors.transparent, + child: InkWell( + onTap: () => setState(() => _selectedId = id), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: Row( + children: [ + DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 36), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${r['name']}', style: const TextStyle(fontSize: 14)), + Text( + [r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '), + style: const TextStyle(fontSize: 12, color: kDeskMute), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: online ? kDeskGreen : const Color(0xFFD0D0D0), + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _detail(Map r) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 80), + const SizedBox(height: 16), + Text('${r['name']}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)), + const SizedBox(height: 6), + Text( + [r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '), + style: const TextStyle(color: kDeskMute), + ), + const SizedBox(height: 24), + _infoRow('手机', '${r['mobile'] ?? '未填写'}'), + _infoRow('邮箱', '${r['email'] ?? '未填写'}'), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + style: FilledButton.styleFrom(backgroundColor: kDeskBind, minimumSize: const Size.fromHeight(40)), + onPressed: () => _message(r), + child: const Text('发消息'), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _infoRow(String k, String v) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all(color: kDeskLine)), + child: Row( + children: [ + SizedBox(width: 56, child: Text(k, style: const TextStyle(color: kDeskMute, fontSize: 13))), + Expanded(child: Text(v, style: const TextStyle(fontSize: 14))), + ], + ), + ); + } +} diff --git a/apps/native/lib/desktop/pages/desk_conv_list_page.dart b/apps/native/lib/desktop/pages/desk_conv_list_page.dart new file mode 100644 index 0000000..a466b83 --- /dev/null +++ b/apps/native/lib/desktop/pages/desk_conv_list_page.dart @@ -0,0 +1,256 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../api/oa_client.dart'; +import '../../im/chat_prefs.dart'; +import 'desk_pick_people_page.dart'; +import '../../session/session.dart'; +import '../desk_theme.dart'; +import '../widgets/desk_widgets.dart'; + +class DeskConvListPage extends StatefulWidget { + const DeskConvListPage({ + super.key, + required this.session, + required this.api, + this.selectedId, + required this.onSelect, + }); + + final SessionStore session; + final OaClient api; + final String? selectedId; + final void Function(Map row) onSelect; + + @override + State createState() => _DeskConvListPageState(); +} + +class _DeskConvListPageState extends State { + List> _items = []; + bool _loading = true; + String _filter = 'all'; + String _q = ''; + Timer? _debounce; + + @override + void initState() { + super.initState(); + widget.session.im.addListener(_onIm); + ChatPrefs.ensure().then((_) { + if (mounted) setState(() {}); + }); + _load(); + } + + @override + void dispose() { + _debounce?.cancel(); + widget.session.im.removeListener(_onIm); + super.dispose(); + } + + void _onIm() { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 300), () { + if (mounted) _load(); + }); + } + + Future _load() async { + try { + final data = await widget.api.get('/im/conversations'); + if (!mounted) return; + setState(() { + _items = asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + List> get _shown { + var list = _items.where((e) => !ChatPrefs.hidden('${e['id'] ?? ''}')).toList(); + if (_filter == 'unread') list = list.where((e) => ((e['unread'] as num?)?.toInt() ?? 0) > 0).toList(); + if (_filter == 'dm') list = list.where((e) => e['type'] != 'group').toList(); + if (_filter == 'group') list = list.where((e) => e['type'] == 'group').toList(); + if (_q.isNotEmpty) { + list = list.where((e) => '${e['name']}${e['peerName']}${e['lastText']}'.contains(_q)).toList(); + } + list.sort((a, b) { + final ap = ChatPrefs.pinned('${a['id'] ?? ''}'); + final bp = ChatPrefs.pinned('${b['id'] ?? ''}'); + if (ap == bp) return 0; + return ap ? -1 : 1; + }); + return list; + } + + Future _menu(Map r, Offset pos) async { + final id = '${r['id'] ?? ''}'; + if (id.isEmpty) return; + final muted = ChatPrefs.muted(id); + final pinned = ChatPrefs.pinned(id); + final unread = (r['unread'] as num?)?.toInt() ?? 0; + final fake = ChatPrefs.fakeUnread(id); + final selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB(pos.dx, pos.dy, pos.dx + 1, pos.dy + 1), + items: [ + if (unread == 0 && fake == 0) const PopupMenuItem(value: 'unread', child: Text('标为未读')), + PopupMenuItem(value: 'mute', child: Text(muted ? '取消免打扰' : '消息免打扰')), + PopupMenuItem(value: 'pin', child: Text(pinned ? '取消置顶' : '置顶')), + const PopupMenuItem(value: 'hide', child: Text('不显示')), + const PopupMenuDivider(), + const PopupMenuItem(value: 'clear', child: Text('清空聊天记录')), + const PopupMenuItem(value: 'delete', child: Text('删除')), + ], + ); + if (!mounted || selected == null) return; + switch (selected) { + case 'unread': + await ChatPrefs.setFakeUnread(id, 1); + case 'mute': + await ChatPrefs.setMuted(id, !muted); + case 'pin': + await ChatPrefs.setPinned(id, !pinned); + case 'hide': + case 'delete': + await ChatPrefs.setHidden(id, true); + case 'clear': + await ChatPrefs.clearHistory(id); + } + if (mounted) { + setState(() {}); + await _load(); + } + } + + Future _newChat({required bool group}) async { + final picked = await Navigator.of(context).push>>( + MaterialPageRoute( + builder: (_) => DeskPickPeoplePage( + api: widget.api, + title: group ? '选择联系人' : '发起单聊', + multiple: group, + exclude: {widget.session.userId}, + ), + ), + ); + if (picked == null || picked.isEmpty) return; + if (!group) { + final p = picked.first; + widget.onSelect({ + 'id': '', + 'peerId': '${p['id']}', + 'name': '${p['name'] ?? '同事'}', + 'avatarFileId': '${p['avatarFileId'] ?? ''}', + 'type': 'direct', + }); + return; + } + final name = TextEditingController(); + if (!mounted) return; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('群名称'), + content: TextField(controller: name, decoration: const InputDecoration(hintText: '例如:项目组')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('创建')), + ], + ), + ); + if (ok == true) { + await widget.api.post('/im/groups', { + 'name': name.text.trim().isEmpty ? '群聊' : name.text.trim(), + 'memberIds': picked.map((e) => '${e['id']}').toList(), + }); + await _load(); + } + } + + void _plusMenu(BuildContext ctx) { + final box = ctx.findRenderObject() as RenderBox?; + final pos = box?.localToGlobal(Offset.zero) ?? Offset.zero; + showMenu( + context: context, + position: RelativeRect.fromLTRB(pos.dx, pos.dy + 32, pos.dx + 200, pos.dy), + items: [ + PopupMenuItem(onTap: () => Future.microtask(() => _newChat(group: false)), child: const Text('发起单聊')), + PopupMenuItem(onTap: () => Future.microtask(() => _newChat(group: true)), child: const Text('发起群聊')), + ], + ); + } + + @override + Widget build(BuildContext context) { + final shown = _shown; + return ColoredBox( + color: kDeskListBg, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: Row( + children: [ + Expanded(child: DeskSearchField(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim()))), + const SizedBox(width: 8), + Material( + color: const Color(0xFFEFEFEF), + borderRadius: BorderRadius.circular(6), + child: InkWell( + onTap: () { + final rb = context.findRenderObject() as RenderBox?; + if (rb != null) _plusMenu(context); + }, + borderRadius: BorderRadius.circular(6), + child: const SizedBox(width: 32, height: 32, child: Icon(Icons.add, size: 18, color: kDeskInk)), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: DeskFilterChips( + value: _filter, + onChanged: (v) => setState(() => _filter = v), + items: const [('all', '全部'), ('unread', '未读'), ('dm', '单聊'), ('group', '群聊')], + ), + ), + if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind), + Expanded( + child: shown.isEmpty + ? const Center(child: Text('暂无会话', style: TextStyle(color: kDeskMute))) + : ListView.builder( + itemCount: shown.length, + itemBuilder: (_, i) { + final r = shown[i]; + final id = '${r['id'] ?? ''}'; + final fake = ChatPrefs.fakeUnread(id); + final unread = fake > 0 ? fake : ((r['unread'] as num?)?.toInt() ?? 0); + return DeskConvRow( + name: '${r['name'] ?? r['peerName'] ?? '会话'}', + preview: '${r['lastText'] ?? ''}', + time: shortTime(r['lastAt'] ?? r['updatedAt']), + unread: unread, + selected: widget.selectedId == id, + muted: ChatPrefs.muted(id), + pinned: ChatPrefs.pinned(id), + avatarId: '${r['avatarFileId'] ?? ''}', + api: widget.api, + onTap: () => widget.onSelect(r), + onSecondaryTap: (d) => _menu(r, d.globalPosition), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/desktop/pages/desk_hr_apply_page.dart b/apps/native/lib/desktop/pages/desk_hr_apply_page.dart new file mode 100644 index 0000000..e7c59dd --- /dev/null +++ b/apps/native/lib/desktop/pages/desk_hr_apply_page.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; + +import '../../api/oa_client.dart'; +import '../../device/device_bridge.dart'; +import '../../labels.dart'; +import '../desk_theme.dart'; +import '../widgets/desk_data_table.dart'; +import '../widgets/desk_widgets.dart'; +import 'desk_record_detail_page.dart'; + +const _kinds = [ + ('PERSONAL', '请假'), + ('OVERTIME', '加班'), + ('BUSINESS', '出差'), + ('OUT', '外出'), + ('REGULARIZE', '转正'), + ('RESIGN', '离职'), +]; + +class DeskHrApplyPage extends StatefulWidget { + const DeskHrApplyPage({super.key, required this.api}); + final OaClient api; + + @override + State createState() => _DeskHrApplyPageState(); +} + +class _DeskHrApplyPageState extends State { + List> _items = []; + bool _loading = true; + String _bucket = ''; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() => _loading = true); + try { + final q = _bucket.isEmpty ? null : {'bucket': _bucket}; + final data = await widget.api.get('/leave-requests', query: q); + if (!mounted) return; + setState(() { + _items = asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + Future _create() async { + var kind = 'LEAVE'; + final reason = TextEditingController(); + final days = TextEditingController(text: '1'); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('发起人事申请'), + content: SizedBox( + width: 420, + child: StatefulBuilder( + builder: (ctx, setSt) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final k in _kinds) + FilterChip( + label: Text(k.$2), + selected: kind == k.$1, + onSelected: (_) => setSt(() => kind = k.$1), + ), + ], + ), + const SizedBox(height: 12), + TextField(controller: days, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '天数')), + const SizedBox(height: 8), + TextField(controller: reason, maxLines: 3, decoration: const InputDecoration(labelText: '事由')), + ], + ), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('提交')), + ], + ), + ); + if (ok != true) return; + try { + final created = Map.from(await widget.api.post('/leave-requests', { + 'kind': kind, + 'reason': reason.text.trim(), + 'days': num.tryParse(days.text) ?? 1, + }) as Map); + final id = '${created['id'] ?? ''}'; + if (id.isNotEmpty && mounted) { + final add = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('添加申请材料'), + content: const Text('可以上传病假证明、行程单等附件。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('暂不添加')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('选择附件')), + ], + ), + ); + if (add == true) { + final picked = await DeviceBridge.pickFile(); + if (picked != null && picked['path'] != null) { + await widget.api.uploadFile( + filePath: picked['path']!, + filename: picked['name'] ?? '申请材料', + bizType: 'HR_LEAVE', + bizId: id, + mime: picked['mime'], + ); + } + } + } + if (mounted) { + deskToast(context, '已提交,等待审批'); + _load(); + } + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } + } + + @override + Widget build(BuildContext context) { + return DeskListScaffold( + title: '人事申请', + loading: _loading, + actions: [ + FilledButton.icon( + style: FilledButton.styleFrom(backgroundColor: kDeskBind), + onPressed: _create, + icon: const Icon(Icons.add, size: 18), + label: const Text('发起申请'), + ), + ], + filters: DeskFilterChips( + value: _bucket, + onChanged: (v) { + setState(() => _bucket = v); + _load(); + }, + items: const [('', '全部'), ('pending', '待审批'), ('approved', '已通过'), ('rejected', '已驳回')], + ), + body: DeskDataTable( + columns: const ['类型', '事由', '状态', '时间'], + rows: [ + for (final r in _items) + [zh(r['kind']), '${r['reason'] ?? ''}', zh(r['status']), fmtTime(r['createdAt'])], + ], + emptyHint: '还没有人事申请', + onRowTap: (i) => deskOpenRecord(context, widget.api, {..._items[i], 'source': 'HR'}), + ), + ); + } +} diff --git a/apps/native/lib/desktop/pages/desk_legal_page.dart b/apps/native/lib/desktop/pages/desk_legal_page.dart new file mode 100644 index 0000000..dd86a91 --- /dev/null +++ b/apps/native/lib/desktop/pages/desk_legal_page.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +import '../../api/oa_client.dart'; +import '../desk_theme.dart'; +import '../widgets/desk_widgets.dart'; + +class DeskLegalPage extends StatefulWidget { + const DeskLegalPage({super.key, required this.api, required this.kind}); + final OaClient api; + final String kind; + + @override + State createState() => _DeskLegalPageState(); +} + +class _DeskLegalPageState extends State { + String _title = ''; + List> _sections = []; + String _error = ''; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final raw = await widget.api.get('/legal/${widget.kind}'); + if (!mounted) return; + if (raw is Map) { + final data = Map.from(raw); + final paras = asMaps(data['paragraphs']); + setState(() { + _title = '${data['title'] ?? ''}'; + _sections = paras.isNotEmpty + ? paras.map((e) => {'title': '${e['heading'] ?? e['title'] ?? ''}', 'body': '${e['body'] ?? ''}'}).toList() + : [{'title': '', 'body': '${data['body'] ?? data['content'] ?? ''}'}]; + _loading = false; + }); + } + } catch (e) { + if (mounted) setState(() { + _error = '$e'; + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: kDeskBg, + child: Column( + children: [ + DeskPaneHeader( + title: _title.isEmpty ? (widget.kind == 'privacy' ? '隐私政策' : '用户协议') : _title, + leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)), + ), + if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind), + Expanded( + child: _error.isNotEmpty + ? Center(child: Text(_error, style: const TextStyle(color: kDeskMute))) + : ListView( + padding: const EdgeInsets.all(24), + children: [ + for (final s in _sections) ...[ + Text(s['title'] ?? '', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Text(s['body'] ?? '', style: const TextStyle(fontSize: 14, height: 1.6, color: kDeskInk)), + const SizedBox(height: 20), + ], + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/desktop/pages/desk_office_list_page.dart b/apps/native/lib/desktop/pages/desk_office_list_page.dart new file mode 100644 index 0000000..05e40b1 --- /dev/null +++ b/apps/native/lib/desktop/pages/desk_office_list_page.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; + +import '../../api/oa_client.dart'; +import '../../labels.dart'; +import 'desk_record_detail_page.dart'; +import '../widgets/desk_data_table.dart'; +import '../widgets/desk_widgets.dart'; + +/// 桌面端办公列表(待办、审批、申请等),表格布局。 +class DeskOfficeListPage extends StatefulWidget { + const DeskOfficeListPage({ + super.key, + required this.api, + required this.title, + required this.path, + this.query, + this.buckets = const [], + this.defaultBucket = '', + this.columns = const ['标题', '类型', '时间', '状态'], + this.rowBuilder, + }); + + final OaClient api; + final String title; + final String path; + final Map? query; + final List<(String, String)> buckets; + final String defaultBucket; + final List columns; + final List Function(Map row)? rowBuilder; + + @override + State createState() => _DeskOfficeListPageState(); +} + +class _DeskOfficeListPageState extends State { + List> _items = []; + String _bucket = ''; + bool _loading = true; + String _q = ''; + + @override + void initState() { + super.initState(); + _bucket = widget.defaultBucket; + _load(); + } + + Future _load() async { + setState(() => _loading = true); + try { + final q = {...?widget.query}; + if (_bucket.isNotEmpty) q['bucket'] = _bucket; + if (_bucket.isNotEmpty && widget.buckets.any((b) => b.$1 == _bucket && b.$1 == 'PENDING')) { + q.remove('bucket'); + } + final data = await widget.api.get(widget.path, query: q.isEmpty ? null : q); + if (!mounted) return; + setState(() { + _items = asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + List> get _shown { + var list = _bucket.isEmpty || widget.buckets.isEmpty + ? _items + : _items.where((e) { + if (widget.path.contains('approvals')) return '${e['status']}' == _bucket; + if (widget.path.contains('todos')) return '${e['status']}' == _bucket; + return true; + }).toList(); + if (_q.isNotEmpty) { + list = list.where((e) => '${e['title']}${e['bizType']}'.contains(_q)).toList(); + } + return list; + } + + List _row(Map r) { + if (widget.rowBuilder != null) return widget.rowBuilder!(r); + return [ + '${r['title'] ?? ''}', + zh(r['bizType'] ?? r['type'] ?? ''), + fmtTime(r['dueAt'] ?? r['createdAt'] ?? ''), + zhStatus(r['status']), + ]; + } + + String zhStatus(dynamic s) { + final v = '$s'; + const m = {'PENDING': '待处理', 'OPEN': '待办', 'DONE': '已完成', 'APPROVED': '已通过', 'REJECTED': '已驳回'}; + return m[v] ?? v; + } + + @override + Widget build(BuildContext context) { + final shown = _shown; + return DeskListScaffold( + title: widget.title, + loading: _loading, + actions: [ + IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20)), + ], + filters: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DeskSearchField(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())), + if (widget.buckets.isNotEmpty) ...[ + const SizedBox(height: 8), + DeskFilterChips( + value: _bucket, + onChanged: (v) { + setState(() => _bucket = v); + _load(); + }, + items: widget.buckets, + ), + ], + ], + ), + body: DeskDataTable( + columns: widget.columns, + rows: [for (final r in shown) _row(r)], + emptyHint: '暂无${widget.title}', + onRowTap: (i) => deskOpenRecord(context, widget.api, shown[i]), + ), + ); + } +} diff --git a/apps/native/lib/desktop/pages/desk_org_browse_page.dart b/apps/native/lib/desktop/pages/desk_org_browse_page.dart new file mode 100644 index 0000000..67e094c --- /dev/null +++ b/apps/native/lib/desktop/pages/desk_org_browse_page.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; + +import '../../api/oa_client.dart'; +import '../../session/session.dart'; +import '../desk_theme.dart'; +import '../widgets/desk_widgets.dart'; + +class DeskOrgBrowsePage extends StatefulWidget { + const DeskOrgBrowsePage({ + super.key, + required this.session, + required this.api, + required this.staff, + this.parentId, + this.title = '组织架构', + this.deptName, + this.onMessage, + }); + + final SessionStore session; + final OaClient api; + final List> staff; + final String? parentId; + final String title; + final String? deptName; + final void Function(Map person)? onMessage; + + @override + State createState() => _DeskOrgBrowsePageState(); +} + +class _DeskOrgBrowsePageState extends State { + List> _depts = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final data = await widget.api.get('/departments'); + if (!mounted) return; + setState(() { + _depts = asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + List> get _children { + if (_depts.isEmpty) return const []; + return _depts.where((e) { + final pid = '${e['parentId'] ?? ''}'; + if (widget.parentId == null || widget.parentId!.isEmpty) return pid.isEmpty || pid == 'null'; + return pid == widget.parentId; + }).toList(); + } + + List> get _people { + final name = widget.deptName; + if (name == null || name.isEmpty) return const []; + final me = widget.session.userId; + return widget.staff.where((e) => '${e['department']}' == name && '${e['id']}' != me).toList(); + } + + int _countOf(Map d) { + final c = d['_count']; + if (c is Map && c['employees'] != null) return (c['employees'] as num).toInt(); + return widget.staff.where((e) => '${e['department']}' == '${d['name']}').length; + } + + void _openDept(Map d) { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => DeskOrgBrowsePage( + session: widget.session, + api: widget.api, + staff: widget.staff, + parentId: '${d['id']}', + title: '${d['name']}', + deptName: '${d['name']}', + onMessage: widget.onMessage, + ), + )); + } + + Future _message(Map r) async { + if (widget.onMessage != null) { + widget.onMessage!(r); + return; + } + String conversationId = ''; + try { + final raw = await widget.api.post('/im/conversations/direct', {'peerId': '${r['id']}'}); + if (raw is Map) conversationId = '${raw['conversationId'] ?? raw['id'] ?? ''}'; + } catch (_) {} + if (mounted) Navigator.pop(context, {...r, 'conversationId': conversationId, 'peerId': '${r['id']}', 'name': '${r['name']}', 'type': 'direct'}); + } + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: kDeskBg, + child: Column( + children: [ + DeskPaneHeader( + title: widget.title, + leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)), + ), + if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind), + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + for (final d in _children) + Material( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: () => _openDept(d), + borderRadius: BorderRadius.circular(8), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + child: Row( + children: [ + DeskAvatar(label: '${d['name']}', api: widget.api, size: 40, color: kDeskBind, icon: Icons.folder_outlined), + const SizedBox(width: 12), + Expanded(child: Text('${d['name']}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500))), + Text('${_countOf(d)} 人', style: const TextStyle(fontSize: 12, color: kDeskMute)), + const Icon(Icons.chevron_right, size: 18, color: kDeskMute), + ], + ), + ), + ), + ), + if (_people.isNotEmpty) ...[ + const Padding( + padding: EdgeInsets.fromLTRB(4, 12, 4, 8), + child: Text('部门成员', style: TextStyle(fontSize: 12, color: kDeskMute, fontWeight: FontWeight.w600)), + ), + for (final r in _people) + Material( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: () => _message(r), + borderRadius: BorderRadius.circular(8), + child: Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.all(12), + child: Row( + children: [ + DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 36), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${r['name']}', style: const TextStyle(fontSize: 14)), + Text('${r['title'] ?? ''}', style: const TextStyle(fontSize: 12, color: kDeskMute)), + ], + ), + ), + const Text('发消息', style: TextStyle(fontSize: 12, color: kDeskBind)), + ], + ), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/desktop/pages/desk_pick_people_page.dart b/apps/native/lib/desktop/pages/desk_pick_people_page.dart new file mode 100644 index 0000000..b95fbfd --- /dev/null +++ b/apps/native/lib/desktop/pages/desk_pick_people_page.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; + +import '../../api/oa_client.dart'; +import '../desk_theme.dart'; +import '../widgets/desk_widgets.dart'; + +class DeskPickPeoplePage extends StatefulWidget { + const DeskPickPeoplePage({ + super.key, + required this.api, + required this.title, + this.multiple = true, + this.exclude = const {}, + }); + + final OaClient api; + final String title; + final bool multiple; + final Set exclude; + + @override + State createState() => _DeskPickPeoplePageState(); +} + +class _DeskPickPeoplePageState extends State { + List> _items = []; + final _selected = {}; + String _q = ''; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final data = await widget.api.get('/staff'); + if (!mounted) return; + setState(() { + _items = asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + void _confirm() { + Navigator.pop( + context, + _selected.entries.map((e) => {'id': e.key, 'name': e.value}).toList(), + ); + } + + @override + Widget build(BuildContext context) { + final shown = _items.where((e) { + final id = '${e['id']}'; + if (widget.exclude.contains(id)) return false; + if (_q.isEmpty) return true; + return '${e['name']}${e['department']}${e['title']}'.contains(_q); + }).toList(); + + return Scaffold( + backgroundColor: kDeskBg, + body: Column( + children: [ + DeskPaneHeader( + title: widget.title, + leading: IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context)), + actions: [ + if (widget.multiple) + TextButton(onPressed: _selected.isEmpty ? null : _confirm, child: Text('确定(${_selected.length})')), + ], + bottom: Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 10), + child: DeskSearchField(hint: '搜索同事', onChanged: (v) => setState(() => _q = v.trim())), + ), + ), + if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind), + Expanded( + child: ListView.builder( + itemCount: shown.length, + itemBuilder: (_, i) { + final r = shown[i]; + final id = '${r['id']}'; + final name = '${r['name'] ?? ''}'; + final on = _selected.containsKey(id); + return Material( + color: on ? kDeskActive : Colors.white, + child: InkWell( + onTap: () { + if (!widget.multiple) { + Navigator.pop(context, [ + {'id': id, 'name': name, 'avatarFileId': '${r['avatarFileId'] ?? ''}'}, + ]); + return; + } + setState(() { + if (on) { + _selected.remove(id); + } else { + _selected[id] = name; + } + }); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 10), + child: Row( + children: [ + DeskAvatar(label: name, fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 36), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + Text('${r['department'] ?? ''} ${r['title'] ?? ''}'.trim(), + style: const TextStyle(fontSize: 12, color: kDeskMute)), + ], + ), + ), + if (widget.multiple) + Icon(on ? Icons.check_circle : Icons.circle_outlined, color: on ? kDeskBind : kDeskMute, size: 20), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/desktop/pages/desk_profile_edit_page.dart b/apps/native/lib/desktop/pages/desk_profile_edit_page.dart new file mode 100644 index 0000000..8bc9433 --- /dev/null +++ b/apps/native/lib/desktop/pages/desk_profile_edit_page.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../api/oa_client.dart'; +import '../../session/session.dart'; +import '../desk_theme.dart'; +import '../widgets/desk_widgets.dart'; + +class DeskProfileEditPage extends StatefulWidget { + const DeskProfileEditPage({super.key, required this.session, required this.api}); + final SessionStore session; + final OaClient api; + + @override + State createState() => _DeskProfileEditPageState(); +} + +class _DeskProfileEditPageState extends State { + bool _busy = false; + + Future _refreshMe() async { + final me = await widget.api.get('/auth/me'); + if (me is Map) await widget.session.patchUser(Map.from(me)); + if (mounted) setState(() {}); + } + + Future _pickAvatar() async { + final x = await ImagePicker().pickImage(source: ImageSource.gallery, imageQuality: 85, maxWidth: 800); + if (x == null) return; + setState(() => _busy = true); + try { + await widget.api.uploadFile( + filePath: x.path, + filename: x.name.isEmpty ? 'avatar.jpg' : x.name, + bizType: 'USER_AVATAR', + bizId: widget.session.userId, + ); + await _refreshMe(); + if (mounted) deskToast(context, '头像已更新'); + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _editName() async { + final c = TextEditingController(text: widget.session.displayName); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('修改姓名'), + content: TextField(controller: c, decoration: const InputDecoration(labelText: '显示名')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('保存')), + ], + ), + ); + if (ok != true || c.text.trim().isEmpty) return; + try { + final me = await widget.api.patch('/auth/profile', {'displayName': c.text.trim()}); + if (me is Map) await widget.session.patchUser(Map.from(me)); + if (mounted) { + setState(() {}); + deskToast(context, '已保存'); + } + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } + } + + Future _editPassword() async { + final oldC = TextEditingController(); + final newC = TextEditingController(); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('修改密码'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: oldC, obscureText: true, decoration: const InputDecoration(labelText: '当前密码')), + const SizedBox(height: 8), + TextField(controller: newC, obscureText: true, decoration: const InputDecoration(labelText: '新密码')), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('保存')), + ], + ), + ); + if (ok != true) return; + try { + await widget.api.post('/auth/change-password', {'oldPassword': oldC.text, 'newPassword': newC.text}); + if (mounted) deskToast(context, '密码已修改'); + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } + } + + @override + Widget build(BuildContext context) { + final s = widget.session; + final u = s.user; + return ColoredBox( + color: kDeskBg, + child: Column( + children: [ + DeskPaneHeader( + title: '个人资料', + leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)), + ), + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + Center( + child: Stack( + children: [ + DeskAvatar(label: s.displayName, fileId: '${u['avatarFileId'] ?? ''}', api: widget.api, size: 80), + if (_busy) + const Positioned.fill(child: CircularProgressIndicator(strokeWidth: 2, color: kDeskBind)), + ], + ), + ), + const SizedBox(height: 12), + Center( + child: TextButton(onPressed: _busy ? null : _pickAvatar, child: const Text('更换头像')), + ), + const SizedBox(height: 16), + _row('姓名', s.displayName, onTap: _editName), + _row('手机', '${u['mobile'] ?? '未填写'}'), + _row('邮箱', '${u['email'] ?? '未填写'}'), + _row('部门', '${u['department'] ?? ''}'), + _row('职位', '${u['title'] ?? ''}'), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton(onPressed: _editPassword, child: const Text('修改密码')), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _row(String k, String v, {VoidCallback? onTap}) { + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + SizedBox(width: 64, child: Text(k, style: const TextStyle(color: kDeskMute, fontSize: 13))), + Expanded(child: Text(v, style: const TextStyle(fontSize: 14))), + if (onTap != null) const Icon(Icons.chevron_right, size: 18, color: kDeskMute), + ], + ), + ), + ), + ); + } +} diff --git a/apps/native/lib/desktop/pages/desk_profile_page.dart b/apps/native/lib/desktop/pages/desk_profile_page.dart new file mode 100644 index 0000000..93be066 --- /dev/null +++ b/apps/native/lib/desktop/pages/desk_profile_page.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; + +import '../../api/oa_client.dart'; +import '../../app_config.dart'; +import '../../ota/updater.dart'; +import 'desk_legal_page.dart'; +import 'desk_profile_edit_page.dart'; +import '../../session/session.dart'; +import '../desk_theme.dart'; +import '../widgets/desk_widgets.dart'; + +class DeskProfilePage extends StatefulWidget { + const DeskProfilePage({super.key, required this.session, required this.api}); + + final SessionStore session; + final OaClient api; + + @override + State createState() => _DeskProfilePageState(); +} + +class _DeskProfilePageState extends State { + AppRelease? _rel; + bool _checking = false; + + @override + void initState() { + super.initState(); + widget.session.addListener(() { + if (mounted) setState(() {}); + }); + _peek(); + } + + Future _peek() async { + final rel = await OtaUpdater(widget.api).fetch(); + if (mounted) setState(() => _rel = rel); + } + + Future _logout() async { + try { + await widget.api.post('/auth/logout', {'refreshToken': widget.session.refreshToken}); + } catch (_) {} + await widget.session.clear(); + } + + Future _checkUpdate() async { + setState(() => _checking = true); + try { + final rel = await OtaUpdater(widget.api).fetch(); + if (!mounted) return; + setState(() => _rel = rel); + if (rel != null) await OtaUpdater(widget.api).prompt(context, rel, manual: true); + } finally { + if (mounted) setState(() => _checking = false); + } + } + + @override + Widget build(BuildContext context) { + final s = widget.session; + return ColoredBox( + color: kDeskBg, + child: Column( + children: [ + const DeskPaneHeader(title: '我'), + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + Material( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => DeskProfileEditPage(session: s, api: widget.api), + )), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + DeskAvatar( + label: s.displayName, + fileId: '${s.user['avatarFileId'] ?? ''}', + api: widget.api, + size: 56, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(s.displayName, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text('${s.user['department'] ?? ''} ${s.user['title'] ?? ''}'.trim(), + style: const TextStyle(color: kDeskMute, fontSize: 13)), + ], + ), + ), + const Icon(Icons.chevron_right, color: kDeskMute), + ], + ), + ), + ), + ), + const SizedBox(height: 12), + _section([ + _row('检查更新', trailing: _rel?.newer == true ? '有新版本' : '已是最新', onTap: _checking ? null : _checkUpdate), + _row('用户协议', onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => DeskLegalPage(api: widget.api, kind: 'terms')))), + _row('隐私政策', onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => DeskLegalPage(api: widget.api, kind: 'privacy')))), + ]), + const SizedBox(height: 12), + _section([ + _row('版本', trailing: 'v${AppConfig.version}'), + ]), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: OutlinedButton( + style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(40)), + onPressed: _logout, + child: const Text('退出登录', style: TextStyle(color: Color(0xFFFA5151))), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _section(List children) { + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + clipBehavior: Clip.antiAlias, + child: Column(children: children), + ); + } + + Widget _row(String title, {String? trailing, VoidCallback? onTap}) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Expanded(child: Text(title, style: const TextStyle(fontSize: 14))), + if (trailing != null) Text(trailing, style: const TextStyle(fontSize: 13, color: kDeskMute)), + if (onTap != null) const Icon(Icons.chevron_right, size: 18, color: kDeskMute), + ], + ), + ), + ), + ); + } +} diff --git a/apps/native/lib/desktop/pages/desk_record_detail_page.dart b/apps/native/lib/desktop/pages/desk_record_detail_page.dart new file mode 100644 index 0000000..0c2e08d --- /dev/null +++ b/apps/native/lib/desktop/pages/desk_record_detail_page.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; + +import '../../api/oa_client.dart'; +import '../../nav/biz_route.dart'; +import '../../pages/record_detail_page.dart' show flattenRecord; +import '../desk_theme.dart'; +import '../widgets/desk_widgets.dart'; +import 'desk_pick_people_page.dart'; + +void deskOpenRecord(BuildContext context, OaClient api, Map row, {String? title}) { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => DeskRecordDetailPage(api: api, seed: row, title: title ?? detailTitleOf(row)), + )); +} + +class DeskRecordDetailPage extends StatefulWidget { + const DeskRecordDetailPage({super.key, required this.api, required this.seed, required this.title}); + final OaClient api; + final Map seed; + final String title; + + @override + State createState() => _DeskRecordDetailPageState(); +} + +class _DeskRecordDetailPageState extends State { + Map _row = {}; + bool _loading = true; + String _err = ''; + + @override + void initState() { + super.initState(); + _row = Map.from(widget.seed); + _load(); + } + + Future _load() async { + final path = fetchPathOf(widget.seed); + if (path == null) { + setState(() => _loading = false); + return; + } + try { + final data = await widget.api.get(path); + if (!mounted) return; + if (data is Map) { + setState(() { + _row = {...widget.seed, ...Map.from(data)}; + _loading = false; + _err = ''; + }); + } else { + setState(() => _loading = false); + } + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _err = '$e'; + }); + } + } + + Future _forward() async { + final combined = {...widget.seed, ..._row}; + final path = fetchPathOf(combined); + if (path == null || !path.startsWith('/')) { + deskToast(context, '当前信息暂不支持转发', error: true); + return; + } + final picked = await Navigator.of(context).push>>( + MaterialPageRoute( + builder: (_) => DeskPickPeoplePage(api: widget.api, title: '转发给', multiple: true, exclude: {widget.api.session.userId}), + ), + ); + if (picked == null || picked.isEmpty || !mounted) return; + final visible = flattenRecord(combined).where((e) => e.$2.trim().isNotEmpty).take(2).map((e) => '${e.$1}:${e.$2}').join(' · '); + try { + for (final person in picked) { + await widget.api.post('/im/messages', { + 'peerId': '${person['id'] ?? ''}', + 'body': widget.title, + 'contentType': 'business', + 'meta': { + 'title': widget.title, + 'summary': visible, + 'fetchPath': path, + 'recordId': '${combined['id'] ?? combined['bizId'] ?? ''}', + 'bizType': '${combined['bizType'] ?? combined['source'] ?? ''}', + }, + }); + } + if (mounted) deskToast(context, '已转发给 ${picked.length} 人'); + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } + } + + Future _act(String label, Future Function() run) async { + try { + await run(); + if (mounted) { + deskToast(context, label); + await _load(); + } + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } + } + + Future _decide(String path, String result) async { + final c = TextEditingController(); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(result == 'APPROVED' ? '通过' : '驳回'), + content: TextField(controller: c, decoration: const InputDecoration(hintText: '意见(可选)')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确认')), + ], + ), + ); + if (ok != true) return; + await _act('已提交', () async { + await widget.api.post(path, {'result': result, if (c.text.trim().isNotEmpty) 'comment': c.text.trim()}); + }); + } + + List get _actions { + final id = '${_row['id'] ?? widget.seed['id'] ?? ''}'; + final bizId = '${_row['bizId'] ?? ''}'; + final status = '${_row['status'] ?? ''}'; + final source = '${_row['source'] ?? ''}'; + final kind = '${_row['kind'] ?? ''}'; + final biz = '${_row['bizType'] ?? ''}'; + final actions = _row['myActions'] is Map ? Map.from(_row['myActions'] as Map) : {}; + final out = []; + + if (status == 'DRAFT' && (source == 'EXPENSE' && kind != 'LOAN' || _row.containsKey('claimNo'))) { + out.add(FilledButton(onPressed: () => _act('已提交审批', () => widget.api.post('/expenses/$id/submit')), child: const Text('提交报销'))); + } + if (status == 'DRAFT' && (kind == 'LOAN' || _row.containsKey('loanNo'))) { + out.add(FilledButton(onPressed: () => _act('已提交审批', () => widget.api.post('/loans/$id/submit')), child: const Text('提交借款'))); + } + if (biz.isNotEmpty && status == 'PENDING' && id.isNotEmpty && widget.seed['assigneeId'] != null) { + out.add(FilledButton(onPressed: () => _decide('/office/approvals/$id/decide', 'APPROVED'), child: const Text('通过'))); + out.add(OutlinedButton(onPressed: () => _decide('/office/approvals/$id/decide', 'REJECTED'), child: const Text('驳回'))); + } + if (source == 'OFFICE' && status == 'PENDING') { + out.add(FilledButton(onPressed: () => _decide('/office/applies/$id/decide', 'APPROVED'), child: const Text('通过'))); + out.add(OutlinedButton(onPressed: () => _decide('/office/applies/$id/decide', 'REJECTED'), child: const Text('驳回'))); + } + if ((biz == 'HR_LEAVE' || source == 'HR') && status == 'PENDING' && (bizId.isNotEmpty || id.isNotEmpty)) { + final hid = bizId.isNotEmpty ? bizId : id; + out.add(FilledButton(onPressed: () => _decide('/leave-requests/$hid/review', 'APPROVED'), child: const Text('通过'))); + out.add(OutlinedButton(onPressed: () => _decide('/leave-requests/$hid/review', 'REJECTED'), child: const Text('驳回'))); + } + if (actions['canReview'] == true && id.isNotEmpty) { + out.add(FilledButton(onPressed: () => _decide('/bid-cases/$id/reviews', 'APPROVED'), child: const Text('审批通过'))); + out.add(OutlinedButton(onPressed: () => _decide('/bid-cases/$id/reviews', 'REJECTED'), child: const Text('审批驳回'))); + } + if ('${_row['status']}' == 'OPEN' && _row['assigneeId'] != null && !_row.containsKey('source')) { + out.add(FilledButton( + onPressed: () => _act('已办结', () => widget.api.patch('/office/todos/$id', {'status': 'DONE'})), + child: const Text('标为已办'), + )); + } + return out; + } + + @override + Widget build(BuildContext context) { + final pairs = flattenRecord(_row); + return ColoredBox( + color: kDeskBg, + child: Column( + children: [ + DeskPaneHeader( + title: widget.title, + actions: [ + IconButton(tooltip: '转发', onPressed: _loading ? null : _forward, icon: const Icon(Icons.forward_to_inbox_outlined, size: 20)), + IconButton(tooltip: '刷新', onPressed: _load, icon: const Icon(Icons.refresh, size: 20)), + ], + ), + if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind), + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + if (_err.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 8), child: Text(_err, style: const TextStyle(color: kDeskMute, fontSize: 12))), + Material( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for (var i = 0; i < pairs.length; i++) + _kvRow(pairs[i].$1, pairs[i].$2, i == pairs.length - 1), + ], + ), + ), + if (_actions.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Wrap(spacing: 8, runSpacing: 8, children: _actions), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _kvRow(String k, String v, bool last) { + return Container( + decoration: BoxDecoration(border: last ? null : const Border(bottom: BorderSide(color: kDeskLine))), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 100, child: Text(k, style: const TextStyle(fontSize: 13, color: kDeskMute))), + Expanded(child: Text(v, style: const TextStyle(fontSize: 14))), + ], + ), + ); + } +} diff --git a/apps/native/lib/desktop/pages/desk_system_notice_page.dart b/apps/native/lib/desktop/pages/desk_system_notice_page.dart new file mode 100644 index 0000000..24bdc69 --- /dev/null +++ b/apps/native/lib/desktop/pages/desk_system_notice_page.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import '../../api/oa_client.dart'; +import '../../nav/notice_route.dart'; +import '../../session/session.dart'; +import '../desk_theme.dart'; +import '../widgets/desk_widgets.dart'; + +class DeskSystemNoticePage extends StatefulWidget { + const DeskSystemNoticePage({super.key, required this.session, required this.api, required this.conversationId}); + + final SessionStore session; + final OaClient api; + final String conversationId; + + @override + State createState() => _DeskSystemNoticePageState(); +} + +class _DeskSystemNoticePageState extends State { + List> _items = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final data = await widget.api.get('/im/messages', query: {'conversationId': widget.conversationId}); + if (!mounted) return; + setState(() { + _items = data is Map ? asMaps(data['items'] ?? data) : asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + Map _meta(Map row) { + final raw = row['meta']; + if (raw is Map) return Map.from(raw); + if (raw is String && raw.isNotEmpty) { + try { + final d = jsonDecode(raw); + if (d is Map) return Map.from(d); + } catch (_) {} + } + return {}; + } + + void _open(Map row) { + final meta = _meta(row); + openNoticeTarget( + context, + widget.api, + session: widget.session, + kind: '${meta['kind'] ?? row['kind'] ?? ''}', + bizType: '${meta['bizType'] ?? ''}', + bizId: '${meta['bizId'] ?? ''}', + title: '${row['body'] ?? meta['title'] ?? '系统通知'}', + ); + } + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: const Color(0xFFF5F5F5), + child: Column( + children: [ + const DeskPaneHeader(title: '系统通知'), + if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind), + Expanded( + child: _items.isEmpty + ? const Center(child: Text('暂无通知', style: TextStyle(color: kDeskMute))) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _items.length, + itemBuilder: (_, i) { + final r = _items[i]; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Material( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: () => _open(r), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + const Icon(Icons.notifications_none, color: kDeskBind), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${r['body'] ?? '通知'}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + const SizedBox(height: 4), + Text(shortTime(r['createdAt']), style: const TextStyle(fontSize: 12, color: kDeskMute)), + ], + ), + ), + const Icon(Icons.chevron_right, color: kDeskMute, size: 18), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/desktop/pages/desk_work_assign_page.dart b/apps/native/lib/desktop/pages/desk_work_assign_page.dart new file mode 100644 index 0000000..72dba3b --- /dev/null +++ b/apps/native/lib/desktop/pages/desk_work_assign_page.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; + +import '../../api/oa_client.dart'; +import '../../session/session.dart'; +import '../desk_theme.dart'; +import '../widgets/desk_data_table.dart'; +import '../widgets/desk_widgets.dart'; +import 'desk_record_detail_page.dart'; + +class DeskWorkAssignPage extends StatefulWidget { + const DeskWorkAssignPage({super.key, required this.session, required this.api}); + final SessionStore session; + final OaClient api; + + @override + State createState() => _DeskWorkAssignPageState(); +} + +class _DeskWorkAssignPageState extends State { + List> _items = []; + bool _loading = true; + + bool get _canAssign { + const codes = ['admin', 'owner', 'biz_director', 'tech_director', 'rd_director', '3d_director', 'video_director', 'material_director', 'pm']; + return widget.session.roles.any(codes.contains); + } + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() => _loading = true); + try { + final data = await widget.api.get('/office/work-assignments'); + if (!mounted) return; + setState(() { + _items = asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + Future _create() async { + final staff = asMaps(await widget.api.get('/staff')); + if (!mounted) return; + final title = TextEditingController(); + final content = TextEditingController(); + String? assignee; + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSt) => AlertDialog( + title: const Text('安排工作'), + content: SizedBox( + width: 440, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: title, decoration: const InputDecoration(labelText: '标题')), + const SizedBox(height: 8), + TextField(controller: content, decoration: const InputDecoration(labelText: '内容'), maxLines: 3), + const SizedBox(height: 8), + DropdownButtonFormField( + decoration: const InputDecoration(labelText: '执行人'), + items: [ + for (final s in staff) + DropdownMenuItem(value: '${s['id']}', child: Text('${s['name'] ?? ''} · ${s['department'] ?? ''}')), + ], + onChanged: (v) => setSt(() => assignee = v), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('下达')), + ], + ), + ), + ); + if (ok != true) return; + try { + await widget.api.post('/office/work-assignments', { + 'title': title.text.trim(), + 'content': content.text.trim(), + 'assigneeId': assignee, + }); + if (mounted) { + deskToast(context, '已下达'); + _load(); + } + } catch (e) { + if (mounted) deskToast(context, '$e', error: true); + } + } + + @override + Widget build(BuildContext context) { + return DeskListScaffold( + title: '工作安排', + loading: _loading, + actions: [ + if (_canAssign) + FilledButton.icon( + style: FilledButton.styleFrom(backgroundColor: kDeskBind), + onPressed: _create, + icon: const Icon(Icons.add, size: 18), + label: const Text('安排工作'), + ), + IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20)), + ], + body: DeskDataTable( + columns: const ['标题', '执行人', '时间', '状态'], + rows: [ + for (final r in _items) + [ + '${r['title'] ?? ''}', + '${r['assignee'] is Map ? r['assignee']['displayName'] : ''}', + fmtTime(r['dueAt'] ?? r['createdAt']), + '${r['status'] ?? ''}', + ], + ], + emptyHint: '没有工作安排', + onRowTap: (i) => deskOpenRecord(context, widget.api, _items[i]), + ), + ); + } +} diff --git a/apps/native/lib/desktop/pages/desk_workbench_page.dart b/apps/native/lib/desktop/pages/desk_workbench_page.dart new file mode 100644 index 0000000..1232efb --- /dev/null +++ b/apps/native/lib/desktop/pages/desk_workbench_page.dart @@ -0,0 +1,398 @@ +import 'package:flutter/material.dart'; + +import '../../api/oa_client.dart'; +import '../../device/device_bridge.dart'; +import '../../labels.dart'; +import '../../nav/open_module.dart'; +import '../../pages/module_list_page.dart' show RestShell, shellFor; +import '../../session/session.dart'; +import '../desk_theme.dart'; +import '../widgets/desk_data_table.dart'; +import '../widgets/desk_widgets.dart'; +import 'desk_attendance_page.dart'; +import 'desk_hr_apply_page.dart'; +import 'desk_legal_page.dart'; +import 'desk_office_list_page.dart'; +import 'desk_record_detail_page.dart'; +import 'desk_work_assign_page.dart'; + +typedef DeskOpenTab = void Function(String key, String title, IconData icon, Color color, Widget page); + +class DeskWorkbenchPage extends StatefulWidget { + const DeskWorkbenchPage({super.key, required this.session, required this.api, required this.onOpenTab}); + + final SessionStore session; + final OaClient api; + final DeskOpenTab onOpenTab; + + @override + State createState() => _DeskWorkbenchPageState(); +} + +class _DeskWorkbenchPageState extends State { + Map _ov = {}; + String _greet = ''; + bool _loading = true; + String _q = ''; + String _cat = 'all'; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final ov = await widget.api.get('/office/overview'); + String greet = ''; + try { + final w = await widget.api.get('/office/weather'); + if (w is Map) greet = '${w['greeting'] ?? w['text'] ?? ''}'; + } catch (_) {} + if (!mounted) return; + setState(() { + _ov = Map.from(ov as Map? ?? {}); + _greet = greet; + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + bool _match(String name) => _q.isEmpty || name.contains(_q); + + void _open(String key, String title, IconData icon, Color color, Widget page) { + widget.onOpenTab(key, title, icon, color, page); + } + + List<_App> _personalApps() { + return [ + if (_match('待办')) + _App('todos', '待办', '处理待办事项与提醒', Icons.task_alt, const Color(0xFFFA9D3B), + DeskOfficeListPage(api: widget.api, title: '待办', path: '/office/todos', buckets: const [('OPEN', '待办'), ('DONE', '已办'), ('', '全部')], defaultBucket: 'OPEN')), + if (_match('审批')) + _App('approvals', '待审批', '审批各类办公申请', Icons.fact_check, kDeskBind, + DeskOfficeListPage(api: widget.api, title: '待我审批', path: '/office/approvals', buckets: const [('PENDING', '待审批'), ('APPROVED', '已通过'), ('REJECTED', '已驳回'), ('', '全部')], defaultBucket: 'PENDING')), + if (_match('申请')) + _App('flow', '我的申请', '查看我发起的申请', Icons.assignment_outlined, const Color(0xFF6267F2), + DeskOfficeListPage(api: widget.api, title: '我的申请', path: '/office/flow', buckets: const [('', '全部'), ('pending', '待审批'), ('approved', '已通过'), ('rejected', '已驳回')], defaultBucket: '')), + if (_match('日程')) + _App('calendar', '日程', '会议与日程安排', Icons.calendar_month, const Color(0xFF10AEFF), + DeskOfficeListPage( + api: widget.api, + title: '我的日程', + path: '/office/calendar', + columns: const ['标题', '时间', '地点'], + rowBuilder: (r) => ['${r['title'] ?? ''}', fmtTime(r['startAt'] ?? r['createdAt']), '${r['location'] ?? ''}'], + )), + if (_match('安排')) + _App('work', '工作安排', '任务分配与跟进', Icons.event_note, const Color(0xFF00B578), + DeskWorkAssignPage(session: widget.session, api: widget.api)), + if (_match('打卡') || _match('考勤')) + _App('attendance', '考勤打卡', '上下班打卡记录', Icons.access_time_filled, const Color(0xFF267EF0), + DeskAttendancePage(api: widget.api)), + if (_match('人事')) + _App('hr', '人事申请', '请假、加班、外出等', Icons.beach_access, const Color(0xFF8B5CF6), DeskHrApplyPage(api: widget.api)), + ]; + } + + List<_App> _menuApps() { + final out = <_App>[]; + for (final m in widget.session.menus) { + if (m.name == '工作台' || m.name == '个人办公') continue; + if (m.name == '系统设置' || m.code.startsWith('system')) continue; + if (m.children.isNotEmpty) { + for (var i = 0; i < m.children.length; i++) { + final c = m.children[i]; + if (_skip(c)) continue; + if (!(_q.isEmpty || c.name.contains(_q) || m.name.contains(_q))) continue; + out.add(_App(c.code, c.name, m.name, iconFor(c.code, c.name), colorFor(c.code, c.name, i), _pageFor(c))); + } + } else if (!_skip(m) && _match(m.name)) { + out.add(_App(m.code, m.name, '业务应用', iconFor(m.code, m.name), colorFor(m.code, m.name), _pageFor(m))); + } + } + return out; + } + + bool _skip(MenuNode n) { + const names = {'待办', '待审批', '我的申请', '日程', '公告', '公司公告', '工作安排', '人事申请', '工作台', '个人办公', '消息', '通讯录', '员工通讯', '发送短信', '系统设置'}; + if (n.code.startsWith('office:') || n.code.startsWith('system')) return true; + if (n.code.contains('sms') || n.name.contains('短信')) return true; + if (n.name.contains('公告') || n.name == '员工通讯') return true; + return names.contains(n.name); + } + + String _legalKind(MenuNode n) { + final p = '${n.path ?? ''} ${n.code}'.toLowerCase(); + if (p.contains('privacy') || p.contains('隐私')) return 'privacy'; + return 'user-agreement'; + } + + Widget _pageFor(MenuNode n) { + if (n.code.contains('legal') || (n.path ?? '').startsWith('/legal')) { + return DeskLegalPage(api: widget.api, kind: _legalKind(n)); + } + if (n.code == 'office:assign' || n.name.contains('工作安排')) { + return DeskWorkAssignPage(session: widget.session, api: widget.api); + } + final shell = shellFor(n.code, n.path) ?? _shellFromPath(n.path); + if (shell != null) { + return DeskModuleListPage(api: widget.api, title: n.name, shell: shell); + } + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('${n.name} 暂未配置桌面入口', style: const TextStyle(color: kDeskMute)), + if ((n.path ?? '').startsWith('http')) + TextButton( + onPressed: () => DeviceBridge.openUrl(n.path!), + child: const Text('在浏览器打开'), + ), + ], + ), + ); + } + + RestShell? _shellFromPath(String? path) { + final p = (path ?? '').trim(); + if (p.isEmpty || !p.startsWith('/') || p.contains(':')) return null; + const blocked = [ + '/office/overview', + '/office/todos', + '/office/approvals', + '/office/flow', + '/office/calendar', + '/office/weather', + '/office/geo', + '/auth', + '/im', + '/system', + '/push', + '/files', + '/health', + ]; + if (blocked.any((b) => p.startsWith(b))) return null; + return RestShell(p); + } + + List<_App> get _shown { + final personal = _personalApps(); + final menu = _menuApps(); + if (_cat == 'personal') return personal; + if (_cat == 'biz') return menu; + return [...personal, ...menu]; + } + + @override + Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + final cols = width >= 1100 ? 3 : 2; + return ColoredBox( + color: kDeskBg, + child: Column( + children: [ + DeskPaneHeader( + title: '工作台', + actions: [ + SizedBox(width: 200, child: DeskSearchField(hint: '搜索应用', onChanged: (v) => setState(() => _q = v.trim()))), + const SizedBox(width: 8), + TextButton(onPressed: _load, child: const Text('刷新')), + ], + bottom: Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 10), + child: DeskFilterChips( + value: _cat, + onChanged: (v) => setState(() => _cat = v), + items: const [('all', '全部应用'), ('personal', '个人办公'), ('biz', '业务模块')], + ), + ), + ), + if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 24), + children: [ + Container( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 16), + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [Color(0xFFEEF4FF), Color(0xFFF8FBFF)]), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFDCE8FF)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _greet.isEmpty ? '你好,${widget.session.displayName}' : _greet, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + const Text('个人办公与业务入口', style: TextStyle(color: kDeskMute, fontSize: 13)), + ], + ), + ), + const SizedBox(height: 14), + Row( + children: [ + _stat('待办', '${_ov['pendingTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), DeskOfficeListPage(api: widget.api, title: '待办', path: '/office/todos', buckets: const [('OPEN', '待办'), ('DONE', '已办'), ('', '全部')], defaultBucket: 'OPEN'))), + const SizedBox(width: 10), + _stat('待审批', '${_ov['pendingApprovals'] ?? 0}', () => _open('approvals', '待审批', Icons.fact_check, kDeskBind, DeskOfficeListPage(api: widget.api, title: '待我审批', path: '/office/approvals', buckets: const [('PENDING', '待审批'), ('', '全部')], defaultBucket: 'PENDING'))), + const SizedBox(width: 10), + _stat('我的申请', '${_ov['myPending'] ?? 0}', () => _open('flow', '我的申请', Icons.assignment_outlined, const Color(0xFF6267F2), DeskOfficeListPage(api: widget.api, title: '我的申请', path: '/office/flow'))), + const SizedBox(width: 10), + _stat('逾期', '${_ov['overdueTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), DeskOfficeListPage(api: widget.api, title: '待办', path: '/office/todos'))), + ], + ), + const SizedBox(height: 18), + if (_shown.isEmpty) + const Padding(padding: EdgeInsets.only(top: 40), child: Center(child: Text('没有匹配的应用', style: TextStyle(color: kDeskMute)))) + else + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 2.9, + ), + itemCount: _shown.length, + itemBuilder: (_, i) { + final a = _shown[i]; + return DeskAppCard( + title: a.title, + desc: a.desc, + icon: a.icon, + color: a.color, + onTap: () => _open(a.key, a.title, a.icon, a.color, a.page), + ); + }, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _stat(String label, String value, VoidCallback onTap) { + return Expanded( + child: Material( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Column( + children: [ + Text(value, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kDeskBind)), + const SizedBox(height: 2), + Text(label, style: const TextStyle(fontSize: 12, color: kDeskMute)), + ], + ), + ), + ), + ), + ); + } +} + +class _App { + _App(this.key, this.title, this.desc, this.icon, this.color, this.page); + final String key; + final String title; + final String desc; + final IconData icon; + final Color color; + final Widget page; +} + +/// 桌面端业务模块列表(表格),不沿用移动端 ModuleListPage。 +class DeskModuleListPage extends StatefulWidget { + const DeskModuleListPage({super.key, required this.api, required this.title, required this.shell}); + final OaClient api; + final String title; + final RestShell shell; + + @override + State createState() => _DeskModuleListPageState(); +} + +class _DeskModuleListPageState extends State { + List> _items = []; + bool _loading = true; + String _q = ''; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() => _loading = true); + try { + final data = await widget.api.get(widget.shell.path, query: {'page': '1', 'pageSize': '100', ...?widget.shell.query}); + if (!mounted) return; + setState(() { + _items = asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + List> get _shown { + if (_q.isEmpty) return _items; + return _items.where((e) => pickTitle(e).contains(_q) || pickSub(e).contains(_q)).toList(); + } + + @override + Widget build(BuildContext context) { + final shown = _shown; + return DeskListScaffold( + title: widget.title, + loading: _loading, + actions: [IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20))], + filters: DeskSearchField(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())), + body: DeskDataTable( + columns: const ['名称', '摘要', '状态'], + rows: [ + for (final r in shown) + [pickTitle(r), zh(pickSub(r)), '${r['status'] ?? ''}'], + ], + emptyHint: '暂无${widget.title}', + onRowTap: (i) { + final r = shown[i]; + final seed = Map.from(r); + if ('${r['id']}'.length >= 8) seed['_fetch'] = '${widget.shell.path}/${r['id']}'; + deskOpenRecord(context, widget.api, seed, title: pickTitle(r)); + }, + ), + ); + } +} + +String pickTitle(Map r) { + for (final k in ['title', 'name', 'code', 'no']) { + final v = r[k]; + if (v != null && '$v'.isNotEmpty) return '$v'; + } + return '记录'; +} + +String pickSub(Map r) { + for (final k in ['summary', 'remark', 'description', 'partyName', 'projectName']) { + final v = r[k]; + if (v != null && '$v'.isNotEmpty) return '$v'; + } + return ''; +} diff --git a/apps/native/lib/desktop/widgets/desk_data_table.dart b/apps/native/lib/desktop/widgets/desk_data_table.dart new file mode 100644 index 0000000..bbee053 --- /dev/null +++ b/apps/native/lib/desktop/widgets/desk_data_table.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; + +import '../desk_theme.dart'; + +/// 桌面端通用数据表格,替代移动端 KvTile 列表。 +class DeskDataTable extends StatelessWidget { + const DeskDataTable({ + super.key, + required this.columns, + required this.rows, + this.onRowTap, + this.emptyHint = '暂无数据', + }); + + final List columns; + final List> rows; + final void Function(int index)? onRowTap; + final String emptyHint; + + @override + Widget build(BuildContext context) { + if (rows.isEmpty) { + return Center(child: Text(emptyHint, style: const TextStyle(color: kDeskMute))); + } + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + clipBehavior: Clip.antiAlias, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + headingRowHeight: 40, + dataRowMinHeight: 44, + dataRowMaxHeight: 56, + columnSpacing: 24, + horizontalMargin: 16, + columns: [for (final c in columns) DataColumn(label: Text(c))], + rows: [ + for (var i = 0; i < rows.length; i++) + DataRow( + onSelectChanged: onRowTap == null ? null : (_) => onRowTap!(i), + cells: [for (final cell in rows[i]) DataCell(Text(cell, maxLines: 2, overflow: TextOverflow.ellipsis))], + ), + ], + ), + ), + ); + } +} + +class DeskListScaffold extends StatelessWidget { + const DeskListScaffold({ + super.key, + required this.title, + required this.body, + this.filters, + this.actions = const [], + this.loading = false, + }); + + final String title; + final Widget body; + final Widget? filters; + final List actions; + final bool loading; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: kDeskBg, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ColoredBox( + color: kDeskPane, + child: Column( + children: [ + SizedBox( + height: 48, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 16), + child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + ), + const Spacer(), + ...actions, + const SizedBox(width: 8), + ], + ), + ), + if (filters != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), + child: filters!, + ), + const Divider(height: 1, color: kDeskLine), + ], + ), + ), + if (loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind), + Expanded( + child: Padding( + padding: const EdgeInsets.all(16), + child: body, + ), + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/desktop/widgets/desk_widgets.dart b/apps/native/lib/desktop/widgets/desk_widgets.dart new file mode 100644 index 0000000..c5756fd --- /dev/null +++ b/apps/native/lib/desktop/widgets/desk_widgets.dart @@ -0,0 +1,573 @@ +import 'package:flutter/material.dart'; + +import '../../api/oa_client.dart'; +import '../../session/session.dart'; +import '../../widgets/auth_image.dart'; +import '../desk_theme.dart'; + +class DeskNavRail extends StatelessWidget { + const DeskNavRail({ + super.key, + required this.session, + required this.api, + required this.index, + required this.onChanged, + required this.items, + }); + + final SessionStore session; + final OaClient api; + final int index; + final ValueChanged onChanged; + final List<(IconData, IconData, String, int)> items; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: kDeskRail, + child: SizedBox( + width: kDeskRailWidth, + child: Column( + children: [ + const SizedBox(height: 18), + DeskAvatar( + label: session.displayName, + fileId: '${session.user['avatarFileId'] ?? ''}', + api: api, + size: 36, + ), + const SizedBox(height: 18), + Expanded( + child: ListView( + padding: EdgeInsets.zero, + children: [ + for (var i = 0; i < items.length; i++) + _RailItem( + active: index == i, + icon: index == i ? items[i].$2 : items[i].$1, + label: items[i].$3, + badge: items[i].$4, + onTap: () => onChanged(i), + ), + ], + ), + ), + const SizedBox(height: 12), + ], + ), + ), + ); + } +} + +class _RailItem extends StatelessWidget { + const _RailItem({ + required this.active, + required this.icon, + required this.label, + required this.badge, + required this.onTap, + }); + + final bool active; + final IconData icon; + final String label; + final int badge; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + child: Material( + color: active ? kDeskActive : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Icon(icon, size: 22, color: active ? kDeskBind : const Color(0xFF5C5C5C)), + if (badge > 0) + Positioned( + right: -10, + top: -6, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + constraints: const BoxConstraints(minWidth: 16, minHeight: 16), + decoration: BoxDecoration( + color: const Color(0xFFFA5151), + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + badge > 99 ? '99+' : '$badge', + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600), + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Text( + label, + style: TextStyle( + fontSize: 11, + color: active ? kDeskBind : const Color(0xFF5C5C5C), + fontWeight: active ? FontWeight.w600 : FontWeight.w400, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class DeskAvatar extends StatelessWidget { + const DeskAvatar({ + super.key, + required this.label, + required this.api, + this.fileId, + this.size = 40, + this.color, + this.icon, + }); + + final String label; + final OaClient api; + final String? fileId; + final double size; + final Color? color; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final id = fileId ?? ''; + if (id.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(6), + child: AuthImage(fileId: id, api: api, width: size, height: size, fit: BoxFit.cover), + ); + } + final c = color ?? kDeskBind; + final ch = label.isNotEmpty ? label.characters.first : '?'; + return Container( + width: size, + height: size, + decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(6)), + alignment: Alignment.center, + child: icon != null + ? Icon(icon, color: Colors.white, size: size * 0.5) + : Text(ch, style: TextStyle(color: Colors.white, fontSize: size * 0.38, fontWeight: FontWeight.w600)), + ); + } +} + +class DeskPaneHeader extends StatelessWidget { + const DeskPaneHeader({ + super.key, + required this.title, + this.actions = const [], + this.bottom, + this.leading, + }); + + final String title; + final List actions; + final Widget? bottom; + final Widget? leading; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: kDeskPane, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: 48, + child: Row( + children: [ + if (leading != null) leading!, + Padding( + padding: EdgeInsets.only(left: leading == null ? 14 : 4), + child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kDeskInk)), + ), + const Spacer(), + ...actions, + const SizedBox(width: 6), + ], + ), + ), + if (bottom != null) bottom!, + const Divider(height: 1, color: kDeskLine), + ], + ), + ); + } +} + +class DeskSearchField extends StatelessWidget { + const DeskSearchField({super.key, required this.hint, this.onChanged, this.controller}); + + final String hint; + final ValueChanged? onChanged; + final TextEditingController? controller; + + @override + Widget build(BuildContext context) { + return Container( + height: 32, + decoration: BoxDecoration(color: const Color(0xFFEFEFEF), borderRadius: BorderRadius.circular(6)), + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + const Icon(Icons.search, size: 16, color: kDeskMute), + const SizedBox(width: 6), + Expanded( + child: TextField( + controller: controller, + onChanged: onChanged, + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + hintText: hint, + isDense: true, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + hintStyle: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 13), + ), + ), + ), + ], + ), + ); + } +} + +class DeskConvRow extends StatelessWidget { + const DeskConvRow({ + super.key, + required this.name, + required this.preview, + required this.time, + required this.unread, + required this.selected, + required this.onTap, + this.avatarId, + this.api, + this.muted = false, + this.pinned = false, + this.onSecondaryTap, + }); + + final String name; + final String preview; + final String time; + final int unread; + final bool selected; + final VoidCallback onTap; + final String? avatarId; + final OaClient? api; + final bool muted; + final bool pinned; + final void Function(TapDownDetails)? onSecondaryTap; + + @override + Widget build(BuildContext context) { + return Material( + color: selected ? kDeskActive : Colors.transparent, + child: InkWell( + onTap: onTap, + onSecondaryTapDown: onSecondaryTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: Row( + children: [ + if (api != null) + DeskAvatar(label: name, fileId: avatarId, api: api!, size: 40) + else + DeskAvatar(label: name, api: OaClient(SessionStore()), size: 40), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (pinned) + const Padding( + padding: EdgeInsets.only(right: 4), + child: Icon(Icons.push_pin, size: 12, color: kDeskMute), + ), + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: kDeskInk), + ), + ), + Text(time, style: const TextStyle(fontSize: 11, color: kDeskMute)), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text( + preview, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: muted ? kDeskMute : const Color(0xFF9A9A9A)), + ), + ), + if (muted) + const Icon(Icons.notifications_off_outlined, size: 14, color: kDeskMute), + if (unread > 0) ...[ + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: const Color(0xFFFA5151), + borderRadius: BorderRadius.circular(9), + ), + child: Text( + unread > 99 ? '99+' : '$unread', + style: const TextStyle(color: Colors.white, fontSize: 10), + ), + ), + ], + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class DeskEmptyChat extends StatelessWidget { + const DeskEmptyChat({super.key, this.hint = '选择一个会话开始聊天'}); + + final String hint; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: const Color(0xFFF5F5F5), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Opacity( + opacity: 0.1, + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration(color: kDeskBind, borderRadius: BorderRadius.circular(24)), + child: const Icon(Icons.chat_bubble_outline, size: 52, color: Colors.white), + ), + ), + const SizedBox(height: 16), + Text(hint, style: const TextStyle(color: kDeskMute, fontSize: 13)), + ], + ), + ), + ); + } +} + +class DeskTabStrip extends StatelessWidget { + const DeskTabStrip({ + super.key, + required this.tabs, + required this.active, + required this.onSelect, + required this.onClose, + }); + + final List<(String key, String title, IconData icon, Color color)> tabs; + final int active; + final ValueChanged onSelect; + final ValueChanged onClose; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: kDeskPane, + child: Column( + children: [ + SizedBox( + height: 40, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + children: [ + for (var i = 0; i < tabs.length; i++) + Padding( + padding: const EdgeInsets.only(right: 2, top: 6, bottom: 6), + child: Material( + color: active == i ? const Color(0xFFF0F4FA) : Colors.transparent, + borderRadius: BorderRadius.circular(6), + child: InkWell( + onTap: () => onSelect(i), + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: EdgeInsets.fromLTRB(10, 5, i > 0 ? 4 : 10, 5), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(tabs[i].$3, size: 14, color: tabs[i].$4), + const SizedBox(width: 5), + Text( + tabs[i].$2, + style: TextStyle( + fontSize: 12, + color: active == i ? kDeskInk : kDeskMute, + fontWeight: active == i ? FontWeight.w600 : FontWeight.w400, + ), + ), + if (i > 0) ...[ + const SizedBox(width: 2), + InkWell( + onTap: () => onClose(i), + borderRadius: BorderRadius.circular(8), + child: const Padding( + padding: EdgeInsets.all(2), + child: Icon(Icons.close, size: 12, color: kDeskMute), + ), + ), + ], + ], + ), + ), + ), + ), + ), + ], + ), + ), + const Divider(height: 1, color: kDeskLine), + ], + ), + ); + } +} + +class DeskAppCard extends StatelessWidget { + const DeskAppCard({ + super.key, + required this.title, + required this.desc, + required this.icon, + required this.color, + required this.onTap, + }); + + final String title; + final String desc; + final IconData icon; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(10)), + child: Icon(icon, color: Colors.white, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: kDeskInk)), + const SizedBox(height: 4), + Text(desc, maxLines: 2, overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: kDeskMute, height: 1.35)), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class DeskFilterChips extends StatelessWidget { + const DeskFilterChips({super.key, required this.value, required this.onChanged, required this.items}); + + final String value; + final ValueChanged onChanged; + final List<(String, String)> items; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + for (final it in items) ...[ + _chip(it.$1, it.$2), + if (it != items.last) const SizedBox(width: 6), + ], + ], + ); + } + + Widget _chip(String id, String label) { + final on = value == id; + return GestureDetector( + onTap: () => onChanged(id), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: on ? kDeskActive : Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: on ? const Color(0xFFB8D4FF) : kDeskLine), + ), + child: Text(label, style: TextStyle(fontSize: 12, color: on ? kDeskBind : kDeskMute, fontWeight: on ? FontWeight.w600 : FontWeight.w400)), + ), + ); + } +} + +void deskToast(BuildContext context, String msg, {bool error = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg), + backgroundColor: error ? const Color(0xFFFA5151) : null, + behavior: SnackBarBehavior.floating, + width: 320, + ), + ); +} diff --git a/apps/native/lib/device/device_bridge.dart b/apps/native/lib/device/device_bridge.dart new file mode 100644 index 0000000..2d9a5da --- /dev/null +++ b/apps/native/lib/device/device_bridge.dart @@ -0,0 +1,328 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/services.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; + +import '../api/oa_client.dart'; + +/// 系统截屏、高刷、录音、选图(保留 GIF)。 +class DeviceBridge { + DeviceBridge._(); + static const _ch = EventChannel('com.fysxkj.oa/device'); + static const _m = MethodChannel('com.fysxkj.oa/ota'); + static StreamSubscription? _sub; + static final _cbs = {}; + + static final AudioPlayer _player = AudioPlayer(); + static final AudioRecorder _recorder = AudioRecorder(); + static String? _recordingPath; + static DateTime? _recordingStarted; + static String? _playingPath; + static OaClient? _api; + + static void bindApi(OaClient api) => _api = api; + + static bool get _useNativeBridge => Platform.isAndroid; + + static Future preferMaxRefresh() async { + if (!_useNativeBridge) return; + try { + await _m.invokeMethod('preferMaxRefresh'); + } catch (_) {} + } + + static Timer? _clipPoll; + static bool _clipHadImage = false; + static int _lastClipAt = 0; + + static void watchScreenshot(void Function() onShot) { + _cbs.add(onShot); + if (Platform.isAndroid || Platform.isWindows) { + _sub ??= _ch.receiveBroadcastStream().listen((event) { + if (event == 'screenshot') { + for (final cb in [..._cbs]) { + cb(); + } + } + }); + _m.invokeMethod('startScreenshotWatch').catchError((_) {}); + } else if (Platform.isLinux) { + _startLinuxScreenshotPoll(); + } + } + + static void _startLinuxScreenshotPoll() { + _clipPoll ??= Timer.periodic(const Duration(milliseconds: 900), (_) async { + final now = DateTime.now().millisecondsSinceEpoch; + if (now - _lastClipAt < 1500) return; + final has = await _linuxClipboardHasImage(); + if (has && !_clipHadImage) { + _clipHadImage = true; + _lastClipAt = now; + for (final cb in [..._cbs]) { + cb(); + } + } else if (!has) { + _clipHadImage = false; + } + }); + } + + static Future _linuxClipboardHasImage() async { + try { + final x = await Process.run('bash', [ + '-c', + 'command -v xclip >/dev/null && xclip -selection clipboard -t TARGETS -o 2>/dev/null | grep -qi image && echo 1; ' + 'command -v wl-paste >/dev/null && wl-paste --list-types 2>/dev/null | grep -qi image && echo 1', + ]); + return '${x.stdout}'.contains('1'); + } catch (_) { + return false; + } + } + + static void unwatchScreenshot(void Function() onShot) { + _cbs.remove(onShot); + if (_cbs.isNotEmpty) return; + if (Platform.isAndroid || Platform.isWindows) { + _m.invokeMethod('stopScreenshotWatch').catchError((_) {}); + _sub?.cancel(); + _sub = null; + } + _clipPoll?.cancel(); + _clipPoll = null; + _clipHadImage = false; + } + + static Future startVoice() async { + if (_useNativeBridge) { + await _m.invokeMethod('startVoice'); + return; + } + if (!await _recorder.hasPermission()) { + throw Exception('没有麦克风权限'); + } + final dir = await getTemporaryDirectory(); + _recordingPath = '${dir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.m4a'; + _recordingStarted = DateTime.now(); + await _recorder.start(const RecordConfig(encoder: AudioEncoder.aacLc), path: _recordingPath!); + } + + static Future?> stopVoice() async { + if (_useNativeBridge) { + final raw = await _m.invokeMethod('stopVoice'); + if (raw is Map) return Map.from(raw); + return null; + } + final path = await _recorder.stop(); + _recordingPath = null; + if (path == null || path.isEmpty) return null; + final started = _recordingStarted; + _recordingStarted = null; + final seconds = started == null ? 1 : DateTime.now().difference(started).inSeconds.clamp(1, 60); + return {'path': path, 'seconds': seconds}; + } + + static Future cancelVoice() async { + if (_useNativeBridge) { + try { + await _m.invokeMethod('cancelVoice'); + } catch (_) {} + return; + } + try { + await _recorder.stop(); + } catch (_) {} + _recordingPath = null; + _recordingStarted = null; + } + + static Future playVoice(String path) async { + if (_useNativeBridge) { + await _m.invokeMethod('playVoice', {'path': path}); + return; + } + await _player.stop(); + _playingPath = path; + await _player.play(DeviceFileSource(path)); + } + + static Future stopPlay() async { + if (_useNativeBridge) { + try { + await _m.invokeMethod('stopPlay'); + } catch (_) {} + return; + } + await _player.stop(); + _playingPath = null; + } + + static String? get playingPath => _playingPath; + + /// 手机端本地语音转文字(不调 OA 服务端)。 + static Future speechToText({required String audioPath}) async { + if (!_useNativeBridge) return null; + final value = await _m.invokeMethod('speechToText', {'path': audioPath}); + final text = '$value'.trim(); + return value == null || text.isEmpty || text == 'null' ? null : text; + } + + static final AudioPlayer _tonePlayer = AudioPlayer(); + + static Future startCallTone({bool incoming = false}) async { + if (_useNativeBridge) { + try { + await _m.invokeMethod('startCallTone', {'incoming': incoming}); + } catch (_) {} + return; + } + try { + await _tonePlayer.setReleaseMode(ReleaseMode.loop); + await _tonePlayer.play(AssetSource('sounds/ring.wav')); + } catch (_) {} + } + + static Future stopCallTone() async { + if (_useNativeBridge) { + try { + await _m.invokeMethod('stopCallTone'); + } catch (_) {} + return; + } + try { + await _tonePlayer.stop(); + } catch (_) {} + } + + static Future cancelCallNotification() async { + if (!_useNativeBridge) return; + try { + await _m.invokeMethod('cancelCallNotification'); + } catch (_) {} + } + + static Future playMessageSent() async { + if (!_useNativeBridge) return; + try { + await _m.invokeMethod('playMessageSent'); + } catch (_) {} + } + + static Future?> getLocation() async { + if (_useNativeBridge) { + final raw = await _m.invokeMethod('getLocation'); + if (raw is Map) { + final lat = (raw['lat'] as num?)?.toDouble(); + final lng = (raw['lng'] as num?)?.toDouble(); + if (lat != null && lng != null) return {'lat': lat, 'lng': lng}; + } + return null; + } + try { + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return null; + var perm = await Geolocator.checkPermission(); + if (perm == LocationPermission.denied) { + perm = await Geolocator.requestPermission(); + } + if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) { + return null; + } + final pos = await Geolocator.getCurrentPosition(locationSettings: const LocationSettings(accuracy: LocationAccuracy.high)); + return {'lat': pos.latitude, 'lng': pos.longitude}; + } catch (_) { + return null; + } + } + + static Future?> pickFile() async { + if (_useNativeBridge) { + final raw = await _m.invokeMethod('pickFile'); + if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v')); + return null; + } + final picked = await FilePicker.platform.pickFiles(allowMultiple: false); + if (picked == null || picked.files.isEmpty) return null; + final f = picked.files.first; + final path = f.path; + if (path == null || path.isEmpty) return null; + return {'path': path, 'name': f.name, 'mime': f.extension ?? ''}; + } + + static Future openFile(String path, {String? mime}) async { + if (_useNativeBridge) { + await _m.invokeMethod('openFile', {'path': path, 'mime': mime ?? ''}); + return; + } + if (Platform.isLinux) { + await Process.run('xdg-open', [path]); + } else if (Platform.isWindows) { + await Process.start('cmd', ['/c', 'start', '', path], mode: ProcessStartMode.detached); + } + } + + static Future saveImage(String path) async { + if (_useNativeBridge) { + await _m.invokeMethod('saveImage', {'path': path}); + return; + } + final name = path.split('/').last; + final picked = await FilePicker.platform.saveFile(dialogTitle: '保存图片', fileName: name.isEmpty ? 'image.jpg' : name); + if (picked == null || picked.isEmpty) return; + await File(path).copy(picked); + } + + static Future openUrl(String url) async { + if (_useNativeBridge) { + await _m.invokeMethod('openUrl', {'url': url}); + return; + } + if (Platform.isLinux) { + await Process.run('xdg-open', [url]); + } else if (Platform.isWindows) { + await Process.start('cmd', ['/c', 'start', '', url], mode: ProcessStartMode.detached); + } + } + + static Future requestCallMedia() async { + if (!_useNativeBridge) return true; + try { + final ok = await _m.invokeMethod('requestCallMedia'); + return ok == true; + } catch (_) { + return true; + } + } + + static Future?> pickMedia() async { + if (_useNativeBridge) { + final raw = await _m.invokeMethod('pickMedia'); + if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v')); + return null; + } + final picked = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'], + allowMultiple: false, + ); + if (picked == null || picked.files.isEmpty) return null; + final f = picked.files.first; + final path = f.path; + if (path == null || path.isEmpty) return null; + final ext = (f.extension ?? 'jpg').toLowerCase(); + final mime = ext == 'png' + ? 'image/png' + : ext == 'gif' + ? 'image/gif' + : ext == 'webp' + ? 'image/webp' + : 'image/jpeg'; + return {'path': path, 'name': f.name, 'mime': mime}; + } +} diff --git a/apps/native/lib/device/push_bridge.dart b/apps/native/lib/device/push_bridge.dart new file mode 100644 index 0000000..f6e32a2 --- /dev/null +++ b/apps/native/lib/device/push_bridge.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import '../api/oa_client.dart'; + +class PushBridge { + PushBridge._(); + static const _m = MethodChannel('com.fysxkj.oa/ota'); + static const _ev = EventChannel('com.fysxkj.oa/push'); + static StreamSubscription? _sub; + + static Future requestFirstLoginPermissions() async { + try { + await _m.invokeMethod('requestFirstLoginPermissions'); + } catch (_) { + try { + await _m.invokeMethod('requestPushPermission'); + } catch (_) {} + } + } + + static Future register(OaClient api) async { + await requestFirstLoginPermissions(); + for (var i = 0; i < 10; i++) { + try { + final raw = await _m.invokeMethod('getPushToken'); + if (raw is Map) { + final regId = '${raw['regId'] ?? ''}'; + if (regId.isNotEmpty) { + await api.post('/push/devices', { + 'vendor': '${raw['vendor'] ?? 'vivo'}', + 'regId': regId, + 'brand': '${raw['brand'] ?? ''}', + 'model': '${raw['model'] ?? ''}', + }); + return; + } + } + } catch (_) {} + await Future.delayed(const Duration(seconds: 2)); + } + } + + static Future showOnline(String title, String content, {Map extras = const {}}) async { + try { + await _m.invokeMethod('showOnlineNotification', {'title': title, 'content': content, 'extras': extras}); + } catch (_) {} + } + + static Future?> consumeLaunch() async { + try { + final raw = await _m.invokeMethod('consumeLaunchPush'); + if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v')); + } catch (_) {} + return null; + } + + static void listen(void Function(Map) onClick) { + _sub?.cancel(); + _sub = _ev.receiveBroadcastStream().listen((event) { + if (event is Map) { + onClick(event.map((k, v) => MapEntry('$k', '$v'))); + } + }); + } + + static void stop() { + _sub?.cancel(); + _sub = null; + } + + static Future?> oemStatus() async { + try { + final raw = await _m.invokeMethod('oemStatus'); + if (raw is Map) return Map.from(raw); + } catch (_) {} + return null; + } + + static Future openOemKeepAlive() async { + try { + await _m.invokeMethod('openOemKeepAlive'); + } catch (_) {} + } + + static Future requestBattery() async { + try { + await _m.invokeMethod('requestBattery'); + } catch (_) {} + } + + static Future markOemPrompted() async { + try { + await _m.invokeMethod('markOemPrompted'); + } catch (_) {} + } +} diff --git a/apps/native/lib/im/chat_local_store.dart b/apps/native/lib/im/chat_local_store.dart new file mode 100644 index 0000000..9b55db9 --- /dev/null +++ b/apps/native/lib/im/chat_local_store.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// 聊天消息的本地副本。云端只保留 7 天,客户端仍可查看本机历史消息。 +class ChatLocalStore { + ChatLocalStore._(); + + static Future>> load(String conversationId) async { + if (conversationId.isEmpty) return []; + final p = await SharedPreferences.getInstance(); + final raw = p.getString('im.local.$conversationId'); + if (raw == null || raw.isEmpty) return []; + try { + final list = jsonDecode(raw); + if (list is! List) return []; + return list.whereType().map((e) => Map.from(e)).toList(); + } catch (_) { + return []; + } + } + + static Future save(String conversationId, List> items) async { + if (conversationId.isEmpty) return; + final p = await SharedPreferences.getInstance(); + final durable = items.where((e) => e['_pending'] != true).map((e) { + final copy = Map.from(e)..remove('_pending'); + return copy; + }).toList(); + await p.setString('im.local.$conversationId', jsonEncode(durable)); + } +} diff --git a/apps/native/lib/im/chat_prefs.dart b/apps/native/lib/im/chat_prefs.dart new file mode 100644 index 0000000..9c58841 --- /dev/null +++ b/apps/native/lib/im/chat_prefs.dart @@ -0,0 +1,90 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// 会话级本地偏好:免打扰、置顶、背景、本地清空记录。 +class ChatPrefs { + ChatPrefs._(); + static SharedPreferences? _p; + + static Future ensure() async { + _p ??= await SharedPreferences.getInstance(); + // 旧版本曾在滑动过程中误写 hidden,导致会话读完后看似“自动删除”。 + // 升级时清理一次旧标记,之后只有确认“不显示/删除”才会再次隐藏。 + if (_p!.getBool('im.hidden.migration.v3') != true) { + for (final key + in _p!.getKeys().where((k) => k.startsWith('im.hidden.'))) { + await _p!.remove(key); + } + await _p!.setBool('im.hidden.migration.v3', true); + } + } + + static bool muted(String id) => + id.isEmpty ? false : (_p?.getBool('im.mute.$id') ?? false); + static bool pinned(String id) => + id.isEmpty ? false : (_p?.getBool('im.pin.$id') ?? false); + static bool hidden(String id) => + id.isEmpty ? false : (_p?.getBool('im.hidden.$id') ?? false); + static int? bgColor(String id) => id.isEmpty ? null : _p?.getInt('im.bg.$id'); + static DateTime? clearedAt(String id) { + if (id.isEmpty) return null; + final ms = _p?.getInt('im.cleared.$id'); + if (ms == null || ms <= 0) return null; + return DateTime.fromMillisecondsSinceEpoch(ms); + } + + static Future setMuted(String id, bool v) async { + await ensure(); + await _p!.setBool('im.mute.$id', v); + } + + static Future setPinned(String id, bool v) async { + await ensure(); + await _p!.setBool('im.pin.$id', v); + } + + static Future setHidden(String id, bool v) async { + await ensure(); + await _p!.setBool('im.hidden.$id', v); + } + + static Future setBg(String id, int color) async { + await ensure(); + await _p!.setInt('im.bg.$id', color); + } + + static Future clearHistory(String id) async { + await ensure(); + await _p!.setInt('im.cleared.$id', DateTime.now().millisecondsSinceEpoch); + } + + static int fakeUnread(String id) => + id.isEmpty ? 0 : (_p?.getInt('im.fakeUnread.$id') ?? 0); + + static Future setFakeUnread(String id, int n) async { + await ensure(); + if (n <= 0) { + await _p!.remove('im.fakeUnread.$id'); + } else { + await _p!.setInt('im.fakeUnread.$id', n); + } + } + + /// 语音转文字结果,按消息 id 或 fileId 缓存。 + static String? voiceText(String key) { + if (key.isEmpty) return null; + final v = _p?.getString('im.voiceText.$key'); + if (v == null || v.isEmpty) return null; + return v; + } + + static Future setVoiceText(String key, String text) async { + if (key.isEmpty) return; + await ensure(); + final t = text.trim(); + if (t.isEmpty) { + await _p!.remove('im.voiceText.$key'); + } else { + await _p!.setString('im.voiceText.$key', t); + } + } +} diff --git a/apps/native/lib/im/im_bridge.dart b/apps/native/lib/im/im_bridge.dart new file mode 100644 index 0000000..0a32077 --- /dev/null +++ b/apps/native/lib/im/im_bridge.dart @@ -0,0 +1,38 @@ +import 'package:flutter/services.dart'; + +/// 与官方 MobileIMSDK 对齐的生命周期:登录前 init,退出 release。 +/// Android 走 MethodChannel;Windows/Linux 无原生插件时回落到 Dart TCP。 +class ImBridge { + static const _ch = MethodChannel('com.fysxkj.oa/im'); + bool nativeReady = false; + String engine = 'dart-tcp'; + + Future init() async { + try { + final r = await _ch.invokeMethod('init'); + nativeReady = r?['ok'] == true || r?['initialized'] == true; + engine = '${r?['engine'] ?? 'dart-tcp'}'; + } on MissingPluginException { + nativeReady = false; + engine = 'dart-tcp'; + } catch (_) { + nativeReady = false; + engine = 'dart-tcp'; + } + } + + Future release() async { + try { + await _ch.invokeMethod('release'); + } catch (_) {} + nativeReady = false; + } + + Future status() async { + try { + final r = await _ch.invokeMethod('engine'); + if (r != null && r.isNotEmpty) engine = r; + } catch (_) {} + return engine; + } +} diff --git a/apps/native/lib/im/im_lifecycle.dart b/apps/native/lib/im/im_lifecycle.dart new file mode 100644 index 0000000..acb9fd5 --- /dev/null +++ b/apps/native/lib/im/im_lifecycle.dart @@ -0,0 +1,42 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/widgets.dart'; + +import '../session/session.dart'; + +/// 监听网络切换与应用恢复,触发 IM 极速重连。 +class ImLifecycle with WidgetsBindingObserver { + ImLifecycle(this.session); + final SessionStore session; + StreamSubscription>? _netSub; + List _lastNet = const []; + + void start() { + WidgetsBinding.instance.addObserver(this); + _netSub = Connectivity().onConnectivityChanged.listen(_onNet); + Connectivity().checkConnectivity().then((r) => _lastNet = r); + } + + void stop() { + WidgetsBinding.instance.removeObserver(this); + _netSub?.cancel(); + _netSub = null; + } + + void _onNet(List next) { + final wasOffline = _lastNet.every((e) => e == ConnectivityResult.none); + final nowOnline = next.any((e) => e != ConnectivityResult.none); + _lastNet = next; + if (wasOffline && nowOnline && session.signedIn) { + unawaited(session.connectIm(force: true)); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed && session.signedIn) { + unawaited(session.connectIm(force: true)); + } + } +} diff --git a/apps/native/lib/im/im_seq_store.dart b/apps/native/lib/im/im_seq_store.dart new file mode 100644 index 0000000..391dd36 --- /dev/null +++ b/apps/native/lib/im/im_seq_store.dart @@ -0,0 +1,33 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// 本地记录各会话已同步到的最大 seq,断线重连后用于 gap fill。 +class ImSeqStore { + ImSeqStore._(); + static SharedPreferences? _p; + + static Future ensure() async { + _p ??= await SharedPreferences.getInstance(); + } + + static Future lastSeq(String conversationId) async { + if (conversationId.isEmpty) return 0; + await ensure(); + return _p?.getInt('im.seq.$conversationId') ?? 0; + } + + static Future setLastSeq(String conversationId, int seq) async { + if (conversationId.isEmpty || seq <= 0) return; + await ensure(); + final prev = _p?.getInt('im.seq.$conversationId') ?? 0; + if (seq > prev) await _p!.setInt('im.seq.$conversationId', seq); + } + + static Future applyItems(String conversationId, Iterable> items) async { + var max = 0; + for (final e in items) { + final s = (e['seq'] as num?)?.toInt() ?? 0; + if (s > max) max = s; + } + if (max > 0) await setLastSeq(conversationId, max); + } +} diff --git a/apps/native/lib/im/pinyin.dart b/apps/native/lib/im/pinyin.dart new file mode 100644 index 0000000..83cc87a --- /dev/null +++ b/apps/native/lib/im/pinyin.dart @@ -0,0 +1,81 @@ +/// 姓名首字母。按百家姓,避免大量同事被丢进「#」。 +const _surnames = { + '赵': 'Z', '钱': 'Q', '孙': 'S', '李': 'L', '周': 'Z', '吴': 'W', '郑': 'Z', '王': 'W', + '冯': 'F', '陈': 'C', '褚': 'C', '卫': 'W', '蒋': 'J', '沈': 'S', '韩': 'H', '杨': 'Y', + '朱': 'Z', '秦': 'Q', '尤': 'Y', '许': 'X', '何': 'H', '吕': 'L', '施': 'S', '张': 'Z', + '孔': 'K', '曹': 'C', '严': 'Y', '华': 'H', '金': 'J', '魏': 'W', '陶': 'T', '姜': 'J', + '戚': 'Q', '谢': 'X', '邹': 'Z', '喻': 'Y', '柏': 'B', '水': 'S', '窦': 'D', '章': 'Z', + '云': 'Y', '苏': 'S', '潘': 'P', '葛': 'G', '奚': 'X', '范': 'F', '彭': 'P', '郎': 'L', + '鲁': 'L', '韦': 'W', '昌': 'C', '马': 'M', '苗': 'M', '凤': 'F', '花': 'H', '方': 'F', + '俞': 'Y', '任': 'R', '袁': 'Y', '柳': 'L', '酆': 'F', '鲍': 'B', '史': 'S', '唐': 'T', + '费': 'F', '廉': 'L', '岑': 'C', '薛': 'X', '雷': 'L', '贺': 'H', '倪': 'N', '汤': 'T', + '滕': 'T', '殷': 'Y', '罗': 'L', '毕': 'B', '郝': 'H', '邬': 'W', '安': 'A', '常': 'C', + '乐': 'Y', '于': 'Y', '时': 'S', '傅': 'F', '皮': 'P', '卞': 'B', '齐': 'Q', '康': 'K', + '伍': 'W', '余': 'Y', '元': 'Y', '卜': 'B', '顾': 'G', '孟': 'M', '平': 'P', '黄': 'H', + '和': 'H', '穆': 'M', '萧': 'X', '尹': 'Y', '姚': 'Y', '邵': 'S', '湛': 'Z', '汪': 'W', + '祁': 'Q', '毛': 'M', '禹': 'Y', '狄': 'D', '米': 'M', '贝': 'B', '明': 'M', '臧': 'Z', + '计': 'J', '伏': 'F', '成': 'C', '戴': 'D', '谈': 'T', '宋': 'S', '茅': 'M', '庞': 'P', + '熊': 'X', '纪': 'J', '舒': 'S', '屈': 'Q', '项': 'X', '祝': 'Z', '董': 'D', '梁': 'L', + '杜': 'D', '阮': 'R', '蓝': 'L', '闵': 'M', '席': 'X', '季': 'J', '麻': 'M', '强': 'Q', + '贾': 'J', '路': 'L', '娄': 'L', '危': 'W', '江': 'J', '童': 'T', '颜': 'Y', '郭': 'G', + '梅': 'M', '盛': 'S', '林': 'L', '刁': 'D', '钟': 'Z', '徐': 'X', '邱': 'Q', '骆': 'L', + '高': 'G', '夏': 'X', '蔡': 'C', '田': 'T', '樊': 'F', '胡': 'H', '凌': 'L', '霍': 'H', + '虞': 'Y', '万': 'W', '支': 'Z', '柯': 'K', '昝': 'Z', '管': 'G', '卢': 'L', '莫': 'M', + '经': 'J', '房': 'F', '裘': 'Q', '缪': 'M', '干': 'G', '解': 'X', '应': 'Y', '宗': 'Z', + '丁': 'D', '宣': 'X', '贲': 'B', '邓': 'D', '郁': 'Y', '单': 'S', '杭': 'H', '洪': 'H', + '包': 'B', '诸': 'Z', '左': 'Z', '石': 'S', '崔': 'C', '吉': 'J', '钮': 'N', '龚': 'G', + '程': 'C', '嵇': 'J', '邢': 'X', '滑': 'H', '裴': 'P', '陆': 'L', '荣': 'R', '翁': 'W', + '荀': 'X', '羊': 'Y', '於': 'Y', '惠': 'H', '甄': 'Z', '曲': 'Q', '家': 'J', '封': 'F', + '芮': 'R', '羿': 'Y', '储': 'C', '靳': 'J', '汲': 'J', '邴': 'B', '糜': 'M', '松': 'S', + '井': 'J', '段': 'D', '富': 'F', '巫': 'W', '乌': 'W', '焦': 'J', '巴': 'B', '弓': 'G', + '牧': 'M', '隗': 'K', '山': 'S', '谷': 'G', '车': 'C', '侯': 'H', '宓': 'M', '蓬': 'P', + '全': 'Q', '郗': 'X', '班': 'B', '仰': 'Y', '秋': 'Q', '仲': 'Z', '伊': 'Y', '宫': 'G', + '宁': 'N', '仇': 'Q', '栾': 'L', '暴': 'B', '甘': 'G', '钭': 'T', '厉': 'L', '戎': 'R', + '祖': 'Z', '武': 'W', '符': 'F', '刘': 'L', '景': 'J', '詹': 'Z', '束': 'S', '龙': 'L', + '叶': 'Y', '幸': 'X', '司': 'S', '韶': 'S', '郜': 'G', '黎': 'L', '蓟': 'J', '薄': 'B', + '印': 'Y', '宿': 'S', '白': 'B', '怀': 'H', '蒲': 'P', '邰': 'T', '从': 'C', '鄂': 'E', + '索': 'S', '咸': 'X', '籍': 'J', '赖': 'L', '卓': 'Z', '蔺': 'L', '屠': 'T', '蒙': 'M', + '池': 'C', '乔': 'Q', '阴': 'Y', '胥': 'X', '能': 'N', '苍': 'C', '双': 'S', '闻': 'W', + '莘': 'S', '党': 'D', '翟': 'Z', '谭': 'T', '贡': 'G', '劳': 'L', '逄': 'P', '姬': 'J', + '申': 'S', '扶': 'F', '堵': 'D', '冉': 'R', '宰': 'Z', '郦': 'L', '雍': 'Y', '郤': 'X', + '璩': 'Q', '桑': 'S', '桂': 'G', '濮': 'P', '牛': 'N', '寿': 'S', '通': 'T', '边': 'B', + '扈': 'H', '燕': 'Y', '冀': 'J', '郏': 'J', '浦': 'P', '尚': 'S', '农': 'N', '温': 'W', + '别': 'B', '庄': 'Z', '晏': 'Y', '柴': 'C', '瞿': 'Q', '阎': 'Y', '充': 'C', '慕': 'M', + '连': 'L', '茹': 'R', '习': 'X', '宦': 'H', '艾': 'A', '鱼': 'Y', '容': 'R', '向': 'X', + '古': 'G', '易': 'Y', '慎': 'S', '戈': 'G', '廖': 'L', '庾': 'Y', '终': 'Z', '暨': 'J', + '居': 'J', '衡': 'H', '步': 'B', '都': 'D', '耿': 'G', '满': 'M', '弘': 'H', '匡': 'K', + '国': 'G', '文': 'W', '寇': 'K', '广': 'G', '禄': 'L', '阙': 'Q', '东': 'D', '欧': 'O', + '殳': 'S', '沃': 'W', '利': 'L', '蔚': 'W', '越': 'Y', '夔': 'K', '隆': 'L', '师': 'S', + '巩': 'G', '厍': 'S', '聂': 'N', '晁': 'C', '勾': 'G', '敖': 'A', '融': 'R', '冷': 'L', + '訾': 'Z', '辛': 'X', '阚': 'K', '那': 'N', '简': 'J', '饶': 'R', '空': 'K', '曾': 'Z', + '毋': 'W', '沙': 'S', '乜': 'N', '养': 'Y', '鞠': 'J', '须': 'X', '丰': 'F', '巢': 'C', + '关': 'G', '蒯': 'K', '相': 'X', '查': 'Z', '后': 'H', '荆': 'J', '红': 'H', '游': 'Y', + '竺': 'Z', '权': 'Q', '逯': 'L', '盖': 'G', '益': 'Y', '桓': 'H', '公': 'G', '万俟': 'M', + '司马': 'S', '上官': 'S', '欧阳': 'O', '夏侯': 'X', '诸葛': 'Z', '闻人': 'W', '东方': 'D', + '赫连': 'H', '皇甫': 'H', '尉迟': 'Y', '公羊': 'G', '澹台': 'T', '公冶': 'G', '宗政': 'Z', + '濮阳': 'P', '淳于': 'C', '单于': 'C', '太叔': 'T', '申屠': 'S', '公孙': 'G', '仲孙': 'Z', + '轩辕': 'X', '令狐': 'L', '钟离': 'Z', '宇文': 'Y', '长孙': 'Z', '慕容': 'M', '鲜于': 'X', + '闾丘': 'L', '司徒': 'S', '司空': 'S', '丌官': 'Q', '司寇': 'S', '仉': 'Z', '督': 'D', + '子车': 'Z', '颛孙': 'Z', '端木': 'D', '巫马': 'W', '公西': 'G', '漆雕': 'Q', '乐正': 'Y', + '壤驷': 'R', '公良': 'G', '拓跋': 'T', '夹谷': 'J', '宰父': 'Z', '谷梁': 'G', '晋': 'J', + '楚': 'C', '闫': 'Y', '法': 'F', '汝': 'R', '鄢': 'Y', '涂': 'T', '钦': 'Q', '段干': 'D', + '百里': 'B', '东郭': 'D', '南门': 'N', '呼延': 'H', '归': 'G', '海': 'H', '羊舌': 'Y', + '微生': 'W', '岳': 'Y', '帅': 'S', '缑': 'G', '亢': 'K', '况': 'K', '有': 'Y', + '琴': 'Q', '梁丘': 'L', '左丘': 'Z', '东门': 'D', '西门': 'X', '商': 'S', '牟': 'M', + '佘': 'S', '佴': 'N', '伯': 'B', '赏': 'S', '南宫': 'N', '墨': 'M', '哈': 'H', '谯': 'Q', + '笪': 'D', '年': 'N', '爱': 'A', '阳': 'Y', '佟': 'T', '言': 'Y', '福': 'F', '西': 'X', + '南': 'N', '北': 'B', +}; + +String letterOf(String name) { + final n = name.trim(); + if (n.isEmpty) return '#'; + if (n.length >= 2) { + final two = _surnames[n.substring(0, 2)]; + if (two != null) return two; + } + final c = n[0]; + if (RegExp(r'[A-Za-z]').hasMatch(c)) return c.toUpperCase(); + if (RegExp(r'[0-9]').hasMatch(c)) return '#'; + return _surnames[c] ?? '#'; +} diff --git a/apps/native/lib/im/protocol.dart b/apps/native/lib/im/protocol.dart new file mode 100644 index 0000000..02ac71b --- /dev/null +++ b/apps/native/lib/im/protocol.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +/// MobileIMSDK 官方 TCP 帧:4 字节大端长度 + UTF-8 JSON Protocal。 +class ClientType { + static const login = 0; + static const keepAlive = 1; + static const commonData = 2; + static const logout = 3; + static const received = 4; + static const echo = 5; +} + +class ServerType { + static const login = 50; + static const keepAlive = 51; + static const error = 52; + static const echo = 53; + static const kickout = 54; +} + +class Protocal { + Protocal({ + required this.type, + this.dataContent = '', + this.from = '0', + this.to = '0', + this.fp, + this.qos = false, + this.typeu = -1, + this.sm = -1, + }); + + int type; + String dataContent; + String from; + String to; + String? fp; + bool qos; + int typeu; + int sm; + + Map toJson() => { + 'bridge': false, + 'type': type, + 'dataContent': dataContent, + 'from': from, + 'to': to, + 'fp': fp, + 'QoS': qos, + 'typeu': typeu, + 'sm': sm, + }; + + factory Protocal.fromJson(Map j) => Protocal( + type: (j['type'] as num?)?.toInt() ?? 0, + dataContent: '${j['dataContent'] ?? ''}', + from: '${j['from'] ?? '0'}', + to: '${j['to'] ?? '0'}', + fp: j['fp']?.toString(), + qos: j['QoS'] == true || j['qos'] == true, + typeu: (j['typeu'] as num?)?.toInt() ?? -1, + sm: (j['sm'] as num?)?.toInt() ?? -1, + ); +} + +Uint8List encodeFrame(Protocal p) { + final body = utf8.encode(jsonEncode(p.toJson())); + final header = ByteData(4)..setUint32(0, body.length); + return Uint8List.fromList([...header.buffer.asUint8List(), ...body]); +} + +const maxBody = 6 * 1024; diff --git a/apps/native/lib/im/sticker_store.dart b/apps/native/lib/im/sticker_store.dart new file mode 100644 index 0000000..a850328 --- /dev/null +++ b/apps/native/lib/im/sticker_store.dart @@ -0,0 +1,32 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// 自定义表情:服务端 fileId 列表。GIF / 表情面板发出的都走这里。 +class StickerStore { + StickerStore._(); + static const _key = 'im.stickers'; + static SharedPreferences? _p; + static List _ids = []; + + static List get ids => List.unmodifiable(_ids); + + static Future ensure() async { + _p ??= await SharedPreferences.getInstance(); + _ids = _p!.getStringList(_key) ?? []; + } + + static bool has(String fileId) => fileId.isNotEmpty && _ids.contains(fileId); + + static Future add(String fileId) async { + if (fileId.isEmpty) return; + await ensure(); + if (_ids.contains(fileId)) return; + _ids = [fileId, ..._ids]; + await _p!.setStringList(_key, _ids); + } + + static Future remove(String fileId) async { + await ensure(); + _ids = _ids.where((e) => e != fileId).toList(); + await _p!.setStringList(_key, _ids); + } +} diff --git a/apps/native/lib/im/tcp_client.dart b/apps/native/lib/im/tcp_client.dart new file mode 100644 index 0000000..072b8c1 --- /dev/null +++ b/apps/native/lib/im/tcp_client.dart @@ -0,0 +1,300 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:uuid/uuid.dart'; + +import 'protocol.dart'; +import '../device/device_bridge.dart'; +import '../device/push_bridge.dart'; + +class ImIncoming { + ImIncoming({ + required this.from, + required this.text, + this.kind = 'chat', + this.fp, + this.seq = 0, + this.raw = const {}, + }); + final String from; + final String text; + final String kind; + final String? fp; + final int seq; + final Map raw; +} + +class _Pending { + _Pending(this.p); + final Protocal p; + int tries = 0; +} + +/// 官方 TCP ClientCoreSDK 等价实现:登录、心跳、通用数据、QoS 应答与重发。 +class ImTcpClient extends ChangeNotifier { + Socket? _socket; + Uint8List _buf = Uint8List(0); + Timer? _hb; + Timer? _qos; + Timer? _reconnect; + bool connected = false; + bool loggedIn = false; + String lastError = ''; + String userId = ''; + int firstLoginTime = 0; + final Map _pending = {}; + final List inbox = []; + final _uuid = const Uuid(); + String _host = ''; + int _port = 0; + String _token = ''; + bool _want = false; + int _tries = 0; + DateTime _lastHbAck = DateTime.now(); + VoidCallback? onLoggedIn; + + Future connect({ + required String host, + required int port, + required String userId, + required String token, + }) async { + _host = host; + _port = port; + _token = token; + this.userId = userId; + _want = true; + _tries = 0; + await _open(); + } + + Future _open() async { + _tearDownSocket(); + lastError = ''; + try { + _socket = await Socket.connect(_host, _port, + timeout: const Duration(seconds: 8)); + connected = true; + _tries = 0; + notifyListeners(); + _socket!.listen(_onData, onDone: _onDrop, onError: (_) => _onDrop()); + firstLoginTime = DateTime.now().millisecondsSinceEpoch; + _send(Protocal( + type: ClientType.login, + from: userId, + dataContent: jsonEncode({ + 'loginUserId': userId, + 'loginToken': _token, + 'clientKind': 'mobile', + 'extra': '', + 'firstLoginTime': firstLoginTime, + }), + )); + _hb = Timer.periodic(const Duration(seconds: 5), (_) { + if (loggedIn) { + if (DateTime.now().difference(_lastHbAck).inSeconds > 25) { + _onDrop(); + return; + } + _send(Protocal(type: ClientType.keepAlive, from: userId)); + } + }); + _qos = Timer.periodic(const Duration(seconds: 4), (_) => _retryQos()); + } catch (e) { + lastError = 'IM 连接失败:$e'; + connected = false; + loggedIn = false; + notifyListeners(); + _scheduleReconnect(); + } + } + + void _onDrop() { + final was = connected || loggedIn; + _tearDownSocket(); + if (was) notifyListeners(); + if (_want) _scheduleReconnect(); + } + + void _scheduleReconnect() { + if (!_want) return; + _reconnect?.cancel(); + _tries = (_tries + 1).clamp(1, 12); + _reconnect = Timer(Duration(seconds: _tries < 4 ? 2 : 5), () { + if (_want && !loggedIn) _open(); + }); + } + + void disconnect() { + _want = false; + _reconnect?.cancel(); + _reconnect = null; + _tearDownSocket(); + notifyListeners(); + } + + void _tearDownSocket() { + _hb?.cancel(); + _qos?.cancel(); + _hb = null; + _qos = null; + try { + _socket?.destroy(); + } catch (_) {} + _socket = null; + _buf = Uint8List(0); + connected = false; + loggedIn = false; + } + + void bump() { + notifyListeners(); + } + + Future sendChat(String toUserId, String text) async { + if (!loggedIn) return null; + final fp = _uuid.v4(); + final p = Protocal( + type: ClientType.commonData, + from: userId, + to: toUserId, + dataContent: + jsonEncode({'kind': 'chat', 'text': text, 'fromUserId': userId}), + qos: true, + fp: fp, + typeu: 0, + sm: DateTime.now().millisecondsSinceEpoch, + ); + _pending[fp] = _Pending(p); + _send(p); + return fp; + } + + void _retryQos() { + for (final e in [..._pending.entries]) { + e.value.tries++; + if (e.value.tries > 3) { + _pending.remove(e.key); + continue; + } + _send(e.value.p); + } + } + + void _send(Protocal p) { + final s = _socket; + if (s == null) return; + try { + s.add(encodeFrame(p)); + } catch (_) {} + } + + void _onData(List chunk) { + _buf = Uint8List.fromList([..._buf, ...chunk]); + while (_buf.length >= 4) { + final len = ByteData.sublistView(_buf).getUint32(0); + if (len <= 0 || len > maxBody) { + disconnect(); + return; + } + if (_buf.length < 4 + len) break; + final body = utf8.decode(_buf.sublist(4, 4 + len)); + _buf = Uint8List.fromList(_buf.sublist(4 + len)); + try { + _handle(Protocal.fromJson(jsonDecode(body) as Map)); + } catch (_) {} + } + } + + void _handle(Protocal p) { + switch (p.type) { + case ServerType.login: + try { + final info = jsonDecode(p.dataContent.isEmpty ? '{}' : p.dataContent); + loggedIn = info is Map && info['code'] == 0; + if (loggedIn && info['firstLoginTime'] != null) { + firstLoginTime = (info['firstLoginTime'] as num).toInt(); + } + if (!loggedIn) lastError = 'IM 登录被拒绝'; + } catch (_) { + loggedIn = false; + } + if (loggedIn) { + _lastHbAck = DateTime.now(); + onLoggedIn?.call(); + } + notifyListeners(); + break; + case ServerType.keepAlive: + case ServerType.echo: + _lastHbAck = DateTime.now(); + break; + case ServerType.kickout: + _tearDownSocket(); + notifyListeners(); + if (_want) _scheduleReconnect(); + break; + case ClientType.received: + _pending.remove(p.dataContent); + break; + case ClientType.commonData: + if (p.qos && p.fp != null) { + _send(Protocal( + type: ClientType.received, + from: userId, + to: p.from, + dataContent: p.fp!)); + } + String text = p.dataContent; + var kind = 'chat'; + Map raw = {}; + try { + raw = Map.from(jsonDecode(p.dataContent) as Map); + text = '${raw['text'] ?? raw['title'] ?? raw['body'] ?? text}'; + kind = '${raw['kind'] ?? 'chat'}'; + } catch (_) {} + final seq = (raw['seq'] as num?)?.toInt() ?? + (p.sm > 0 ? p.sm : 0); + final fp = '${raw['fingerprint'] ?? p.fp ?? ''}'; + if (fp.isNotEmpty) { + final dup = inbox.any((e) => e.fp == fp); + if (dup) break; + } + inbox.insert( + 0, + ImIncoming( + from: p.from, + text: text, + kind: kind, + fp: fp.isEmpty ? p.fp : fp, + seq: seq, + raw: raw)); + final terminalCall = kind == 'call' && + const {'end', 'reject', 'timeout'} + .contains('${raw['action'] ?? ''}'); + if (terminalCall) { + unawaited(DeviceBridge.cancelCallNotification()); + } else if (p.from != userId && kind != 'receipt' && kind != 'signal') { + unawaited(PushBridge.showOnline( + '${raw['fromName'] ?? raw['title'] ?? '风影办公'}', + text, + extras: { + if (raw['conversationId'] != null) + 'conversationId': '${raw['conversationId']}', + if (raw['kind'] != null) 'kind': '$kind', + if (raw['callId'] != null) 'callId': '${raw['callId']}', + if (raw['callKind'] != null) 'callKind': '${raw['callKind']}', + if (raw['fromName'] != null) 'fromName': '${raw['fromName']}', + if (raw['fromUserId'] != null) 'peerId': '${raw['fromUserId']}', + if (raw['action'] != null) 'action': '${raw['action']}', + }, + )); + } + if (inbox.length > 200) inbox.removeLast(); + notifyListeners(); + break; + } + } +} diff --git a/apps/native/lib/labels.dart b/apps/native/lib/labels.dart new file mode 100644 index 0000000..0669a9b --- /dev/null +++ b/apps/native/lib/labels.dart @@ -0,0 +1,407 @@ +import 'package:flutter/material.dart'; + +import 'theme.dart'; + +/// 后端枚举 / 码 → 中文。对齐 web `labels.ts` + 投标状态。 +const zhMap = { + 'TECH_INITIAL': '技术初审', + 'FETCH': '取标', + 'TECH_FINAL': '技术终审', + 'DRAFTING': '制标', + 'REVIEW1': '商务一审', + 'REVIEW2': '商务二审', + 'PRINT': '印封', + 'LIST': '投标执行', + 'PENDING': '待审批', + 'WON': '中标', + 'LOST': '未中标', + 'FAILED': '流标废标', + 'TERMINATED': '已终止', + 'DRAFT': '草稿', + 'APPROVED': '已通过', + 'REJECTED': '已驳回', + 'CANCELLED': '已取消', + 'ACTIVE': '启用', + 'DISABLED': '停用', + 'OPEN': '进行中', + 'OFFSET': '已冲账', + 'DONE': '已完成', + 'RETURNED': '已交回', + 'PAID': '已付', + 'UNPAID': '待付', + 'TO_ISSUE': '待开', + 'ISSUED': '已开', + 'RECEIVED': '已回', + 'OVERDUE': '逾期', + 'INTERNAL_ACCEPTED': '内部已验收', + 'ACCEPTED': '已验收', + 'ALL': '全部', + 'DEPT': '本部门', + 'SELF': '本人', + 'BID': '投标', + 'PROJECT': '正式项目', + 'ADMIN': '行政', + 'MARKETING': '营销', + 'LABOR': '人工', + 'TRAVEL': '差旅', + 'PURCHASE': '采购', + 'TENDER': '招标', + 'PERFORMANCE': '履约保证金', + 'WARRANTY': '项目质保金', + 'EXPENSE': '报销', + 'BOND': '保证金打出', + 'LOAN': '借款', + 'OFFICE': '办公申请', + 'SEAL': '用章', + 'HR': '人事', + 'LEAVE': '请假', + 'OVERTIME': '加班', + 'TRIP': '出差', + 'OUT': '外出', + 'RESIGN': '离职', + 'PROMOTE': '晋升', + 'INTERN': '实习', + 'PROBATION': '试用', + 'REGULAR': '正式', + 'LEFT': '离职', + 'SINGLE': '单休', + 'DOUBLE': '双休', + 'MALE': '男', + 'FEMALE': '女', + 'HIGH_SCHOOL': '高中及以下', + 'COLLEGE': '专科', + 'BACHELOR': '本科', + 'MASTER': '硕士', + 'DOCTOR': '博士', + 'HIRE': '入职', + 'REGULARIZE': '转正', + 'TRANSFER': '调岗', + 'CREATE': '发起', + 'SUBMIT': '提交', + 'APPROVE': '同意', + 'REJECT': '驳回', + 'DRAFTER': '制标人', + 'FETCHER': '取标人', + 'COORDINATOR': '特殊协调人', + 'BIDDER': '投标人', + 'SPECIAL': '特殊筛选', + 'FLOW': '普通筛选', + 'BID_TECH_INITIAL': '技术初审', + 'BID_TECH_FINAL': '技术终审', + 'BID_FETCH': '取标', + 'BID_ASSIGN_FETCH': '派取标人', + 'BID_ASSIGN_DRAFT': '派制标人', + 'BID_FINAL_PASSED': '终审通过通知', + 'BID_DRAFT_DONE': '制标完成通知', + 'BID_TERMINATED': '投标终止通知', + 'BID_DRAFT': '制标', + 'BID_REVIEW1': '商务一审', + 'BID_REVIEW2': '商务二审', + 'BID_PRINT': '印封', + 'BID_COMPLETE': '补全投标', + 'BID_EXECUTION': '投标执行', + 'BID_ASSIGN_FETCHER': '派取标人', + 'UPDATE_COMPETITOR_WIN': '登记对方中标信息', + 'UPDATE_LEGAL_ENTITY': '修改参与主体', + 'CONFIRMED': '已确认', + 'INTERNAL': '内部验收', + 'CUSTOMER': '客户验收', + 'CONTRACT_APPROVAL': '合同审批', + 'CONTRACT_DRAFT': '完善合同草稿', + 'EXPENSE_APPROVAL': '报销确认', + 'LOAN_APPROVAL': '借款确认', + 'LOAN_OFFSET': '借款冲账', + 'PROJECT_TIMESHEET': '工时填报', + 'PROJECT_ACCEPT': '项目验收', + 'SEAL_APPROVAL': '用章审批', + 'SEAL_RETURN': '印章归还', + 'CREDENTIAL_RETURN': '证照归还', + 'BID_OPEN': '开标反馈', + 'WORK_ASSIGN': '工作安排', + 'OFFICE_APPLY': '办公申请', + 'HR_LEAVE': '人事申请', + 'SEAL_REQUEST': '用章申请', + 'WORK_REPORT': '工作汇报', + 'NOTICE': '通知公告', + 'ASSET_BUY': '资产购置', + 'ASSET_USE': '资产领用', + 'VEHICLE': '用车', + 'DIGITAL': '数字资产', + 'FIXED': '固定资产', + 'IN_USE': '占用中', + 'IN_STOCK': '在库', + 'OUT_STOCK': '已出库', + 'TAKEN_OUT': '外带', + 'LENT': '外借', + 'BORROWED': '已外借', + 'IDLE': '闲置', + 'SCRAPPED': '已报废', + 'AVAILABLE': '可用', + 'MAINTAIN': '维修中', + 'OCCUPIED': '占用中', + 'PENDING_IN': '待入库', + 'ID_CARD': '身份证', + 'PASSPORT': '护照', + 'OTHER_ID': '其他证件', + 'GENERAL': '通用', + 'TODO': '待办', + 'TASK': '任务截止', + 'BORROW': '证照归还', + 'LOGIN': '登录', + 'LOGIN_FAIL': '登录失败', + 'APPROVAL': '审批', + 'FORBIDDEN': '越权', + 'ACL': '权限', + 'SENT': '已提交', + 'UPDATE': '修改', + 'DELETE': '删除', + 'OK': '成功', + 'FAIL': '失败', + 'BUSINESS': '商务', + 'TECH': '技术', + 'FINANCE': '财务', + 'PUBLISHED': '已发布', + 'ANNOUNCE': '公告', + 'POLICY': '制度', + 'direct': '单聊', + 'group': '群聊', + 'admin': '管理员', + 'owner': '负责人', + 'hr': '人事', + 'finance': '财务', + 'finance_manager': '财务经理', + 'cashier': '出纳', + 'seal': '用章管理员', + 'asset': '资产管理员', + 'project_manager': '项目经理', + 'tech_director': '技术总监', + 'marketing': '营销', + 'bidder': '投标', + 'staff': '员工', + 'user': '用户', + 'true': '是', + 'false': '否', + 'INQUIRY': '询价', + 'OPEN_TENDER': '公开招标', + 'INVITE': '邀请招标', + 'COMPETITIVE': '竞争性谈判', + 'SINGLE_SOURCE': '单一来源', +}; + +/// 详情页字段名。没有映射的 camelCase 一律不展示,避免露出英文列名。 +const fieldZh = { + 'title': '标题', + 'name': '名称', + 'purpose': '事由', + 'reason': '事由', + 'remark': '备注', + 'status': '状态', + 'kind': '类型', + 'source': '来源', + 'sourceType': '筛选类型', + 'bizType': '业务', + 'amount': '金额', + 'days': '天数', + 'claimNo': '报销单号', + 'loanNo': '借款单号', + 'bidNo': '招标编号', + 'externalNo': '招标编号', + 'contractNo': '合同号', + 'projectNo': '项目编号', + 'projectType': '项目类型', + 'sealType': '印章类型', + 'destination': '目的地', + 'assetKind': '资产类型', + 'createdAt': '创建时间', + 'updatedAt': '更新时间', + 'dueAt': '截止', + 'startAt': '开始', + 'endAt': '结束', + 'decidedAt': '审批时间', + 'comment': '意见', + 'createdBy': '发起人', + 'bidCase': '关联投标', + 'legalEntity': '参与主体', + 'qualificationNeed': '所需资质', + 'tenderMethod': '招标方式', + 'applyStartAt': '申领开始', + 'applyEndAt': '申领截止', + 'applyMethod': '申领方式', + 'openAt': '开标时间', + 'openMethod': '开标方式', + 'openPlace': '开标地点', + 'bondMethod': '保证金', + 'bondStatus': '保证金状态', + 'bondType': '保证金类型', + 'delivererName': '递交人', + 'contactName': '联系人', + 'contactPhone': '联系电话', + 'nasPath': '标书路径', + 'summary': '摘要', + 'resultNote': '结果说明', + 'printCost': '印封费用', + 'printNote': '印封备注', + 'projectNature': '项目性质', + 'securityLevel': '密级', + 'priceCap': '限价', + 'quoteAmount': '报价', + 'needSurvey': '需要踏勘', + 'fetchedAt': '取标时间', + 'printedAt': '印封时间', + 'openedResultAt': '开标反馈时间', + 'actionLogs': '操作记录', + 'projects': '关联项目', + 'files': '附件', + 'reviewers': '审批人', + 'assignees': '经办人', + 'versions': '标书版本', + 'displayName': '姓名', + 'departmentName': '部门', + 'department': '部门', + 'mobile': '手机', + 'email': '邮箱', + 'party': '客商', + 'applicant': '申请人', + 'assignee': '经办人', + 'keeper': '保管人', + 'holder': '占用人', + 'spec': '规格', + 'unit': '单位', + 'qty': '数量', + 'quantity': '数量', + 'location': '位置', + 'address': '地址', + 'code': '编号', + 'no': '编号', + 'type': '类型', + 'category': '分类', + 'level': '级别', + 'priority': '优先级', + 'progress': '进度', + 'budget': '预算', + 'cost': '成本', + 'owner': '负责人', + 'manager': '负责人', + 'description': '说明', + 'content': '内容', + 'body': '正文', + 'note': '备注', + 'result': '结果', + 'outcome': '结果', + 'method': '方式', + 'channel': '渠道', + 'scope': '范围', + 'role': '角色', + 'roles': '角色', + 'employmentStatus': '用工状态', + 'gender': '性别', + 'idNo': '证件号', + 'idType': '证件类型', + 'idNumber': '证件号码', + 'idCard': '身份证号', + 'jobTitle': '岗位', + 'hireDate': '入职日期', + 'hiredAt': '入职日期', + 'birthday': '出生日期', + 'birthDate': '出生日期', + 'education': '学历', + 'school': '毕业院校', + 'major': '专业', + 'emergencyContact': '紧急联系人', + 'emergencyPhone': '紧急电话', + 'bankName': '开户行', + 'bankAccount': '银行账号', + 'workCity': '工作城市', + 'officeLocation': '办公地点', + 'employeeNo': '工号', + 'staffNo': '工号', + 'jobNo': '工号', + 'realName': '姓名', + 'username': '账号', + 'phone': '手机', + 'wechat': '微信', + 'nation': '民族', + 'nativePlace': '籍贯', + 'maritalStatus': '婚姻状况', + 'addressHome': '家庭住址', + 'entryDate': '入职日期', + 'regularDate': '转正日期', + 'leaveDate': '离职日期', + 'contractEnd': '合同到期', + 'keeperName': '保管人', + 'holderName': '占用人', + 'legalEntityName': '参与主体', + 'winnerName': '中标单位', + 'winnerAmount': '中标金额', +}; + +String zh(dynamic v) { + if (v == null) return '—'; + if (v is bool) return v ? '是' : '否'; + final s = '$v'.trim(); + if (s.isEmpty) return '—'; + return zhMap[s] ?? s; +} + +/// 字段名转中文。仍是英文驼峰的返回空串,调用方应跳过。 +String fieldLabel(String k) { + final key = k.contains('.') ? k.split('.').last : k; + if (fieldZh.containsKey(k)) return fieldZh[k]!; + if (fieldZh.containsKey(key)) return fieldZh[key]!; + if (zhMap.containsKey(key)) return zhMap[key]!; + if (RegExp(r'[\u4e00-\u9fff]').hasMatch(key)) return key; + return ''; +} + +Color statusColor(dynamic v) { + switch ('$v') { + case 'APPROVED': + case 'DONE': + case 'OFFSET': + case 'RETURNED': + case 'PAID': + case 'WON': + case 'ACCEPTED': + case 'ISSUED': + case 'RECEIVED': + return kWeGreen; + case 'PENDING': + case 'OPEN': + case 'DRAFT': + case 'FETCH': + case 'DRAFTING': + case 'TECH_INITIAL': + case 'TECH_FINAL': + case 'REVIEW1': + case 'REVIEW2': + case 'PRINT': + case 'LIST': + return const Color(0xFFFA9D3B); + case 'REJECTED': + case 'CANCELLED': + case 'OVERDUE': + case 'TERMINATED': + case 'FAILED': + case 'LOST': + return kDanger; + default: + return kMute; + } +} + +class StatusDot extends StatelessWidget { + const StatusDot(this.code, {super.key}); + final dynamic code; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: statusColor(code).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + ), + child: Text(zh(code), style: TextStyle(fontSize: 11, color: statusColor(code), fontWeight: FontWeight.w600)), + ); + } +} diff --git a/apps/native/lib/main.dart b/apps/native/lib/main.dart new file mode 100644 index 0000000..3f5f946 --- /dev/null +++ b/apps/native/lib/main.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:window_manager/window_manager.dart'; + +import 'api/oa_client.dart'; +import 'app_runtime.dart'; +import 'device/device_bridge.dart'; +import 'desktop/desk_platform.dart'; +import 'desktop/desk_theme.dart'; +import 'pages/desktop_login_page.dart'; +import 'pages/login_page.dart'; +import 'session/session.dart'; +import 'shell/home_shell.dart'; +import 'theme.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await AppRuntime.load(); + if (isDesktopPlatform) { + await windowManager.ensureInitialized(); + const windowOptions = WindowOptions( + size: Size(1400, 900), + center: true, + title: '风影办公', + ); + windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.show(); + await windowManager.focus(); + }); + } + if (!isDesktopPlatform) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + statusBarColor: Colors.white, + statusBarIconBrightness: Brightness.dark, + )); + DeviceBridge.preferMaxRefresh(); + } + runApp(const FengyingApp()); +} + +class FengyingApp extends StatefulWidget { + const FengyingApp({super.key}); + + @override + State createState() => _FengyingAppState(); +} + +class _FengyingAppState extends State { + final session = SessionStore(); + late final OaClient api = OaClient(session); + + @override + void initState() { + super.initState(); + DeviceBridge.bindApi(api); + session.addListener(_onSession); + _boot(); + } + + Future _boot() async { + await session.load(); + if (session.signedIn) { + try { + final me = await api.get('/auth/me'); + if (me is Map) { + await session.patchUser(Map.from(me)); + } + final menus = await api.get('/system/menus'); + await session.setMenus(asMaps(menus).map(MenuNode.fromJson).toList()); + await session.connectIm(); + } catch (_) { + await session.clear(); + } + } + } + + void _onSession() => setState(() {}); + + @override + void dispose() { + session.removeListener(_onSession); + session.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: '风影办公', + debugShowCheckedModeBanner: false, + theme: isDesktopPlatform ? buildDesktopTheme() : buildTheme(), + home: !session.loaded + ? const Scaffold(backgroundColor: Colors.white, body: Center(child: CircularProgressIndicator(color: kBind))) + : session.signedIn + ? HomeShell(session: session, api: api) + : isDesktopPlatform + ? DesktopLoginPage(session: session, api: api) + : LoginPage(session: session, api: api), + ); + } +} diff --git a/apps/native/lib/nav/biz_route.dart b/apps/native/lib/nav/biz_route.dart new file mode 100644 index 0000000..f9f0029 --- /dev/null +++ b/apps/native/lib/nav/biz_route.dart @@ -0,0 +1,85 @@ +String? fetchPathOf(Map row) { + final forced = '${row['_fetch'] ?? ''}'; + if (forced.startsWith('/')) return forced; + final id = '${row['id'] ?? ''}'; + final bizId = '${row['bizId'] ?? ''}'; + final source = '${row['source'] ?? ''}'; + final kind = '${row['kind'] ?? ''}'; + final biz = '${row['bizType'] ?? ''}'; + final path = '${row['path'] ?? ''}'; + + if (source == 'EXPENSE' && kind == 'LOAN' && id.isNotEmpty) + return '/loans/$id'; + if (source == 'EXPENSE' && id.isNotEmpty) return '/expenses/$id'; + if (source == 'HR' && id.isNotEmpty) return '/leave-requests/$id'; + if (source == 'OFFICE' && id.isNotEmpty) return '/office/applies/$id'; + if (source == 'SEAL' && id.isNotEmpty) return '/seal-requests/$id'; + + if (bizId.isNotEmpty) { + if (biz == 'EXPENSE_APPROVAL' || + biz == 'EXPENSE_DRAFT' || + biz == 'TRAVEL_EXPENSE') return '/expenses/$bizId'; + if (biz == 'LOAN_APPROVAL' || biz == 'LOAN_OFFSET') return '/loans/$bizId'; + if (biz == 'HR_LEAVE') return '/leave-requests/$bizId'; + if (biz == 'OFFICE_APPLY') return '/office/applies/$bizId'; + if (biz == 'SEAL_APPROVAL' || biz == 'SEAL_RETURN' || biz == 'SEAL_REQUEST') + return '/seal-requests/$bizId'; + if (biz.startsWith('BID')) return '/bid-cases/$bizId'; + if (biz.startsWith('CONTRACT')) return '/contracts/$bizId'; + if (biz == 'WORK_REPORT') return '/office/reports/$bizId'; + if (biz == 'WORK_ASSIGN') return null; + if (biz.startsWith('PROJECT')) return '/projects/$bizId'; + } + + if (row['expense'] is Map) + return '/expenses/${(row['expense'] as Map)['id']}'; + if (row['loan'] is Map) return '/loans/${(row['loan'] as Map)['id']}'; + if (row['leaveRequest'] is Map) + return '/leave-requests/${(row['leaveRequest'] as Map)['id']}'; + if (row['officeApply'] is Map) + return '/office/applies/${(row['officeApply'] as Map)['id']}'; + if (row['sealRequest'] is Map) + return '/seal-requests/${(row['sealRequest'] as Map)['id']}'; + if (row['contract'] is Map) + return '/contracts/${(row['contract'] as Map)['id']}'; + if (row['bidCase'] is Map) + return '/bid-cases/${(row['bidCase'] as Map)['id']}'; + if (row['project'] is Map) + return '/projects/${(row['project'] as Map)['id']}'; + + if (path.startsWith('/office/expenses/') && path.length > 17) + return '/expenses/${path.split('/').last}'; + if (path.startsWith('/office/loans/') && path.length > 14) + return '/loans/${path.split('/').last}'; + + if (id.length == 36) { + if (row.containsKey('claimNo') || row.containsKey('expenseCategory')) + return '/expenses/$id'; + if (row.containsKey('loanNo') || kind == 'LOAN') return '/loans/$id'; + if (row.containsKey('bidNo') || row.containsKey('externalNo')) + return '/bid-cases/$id'; + if (row.containsKey('contractNo')) return '/contracts/$id'; + if (row.containsKey('projectNo')) return '/projects/$id'; + if (row.containsKey('sealType') || row.containsKey('takeOut')) + return '/seal-requests/$id'; + } + return null; +} + +String detailTitleOf(Map row) { + for (final k in [ + 'title', + 'name', + 'purpose', + 'reason', + 'claimNo', + 'loanNo', + 'bidNo', + 'contractNo', + 'projectName' + ]) { + final v = row[k]; + if (v != null && '$v'.trim().isNotEmpty) return '$v'; + } + return '详情'; +} diff --git a/apps/native/lib/nav/notice_route.dart b/apps/native/lib/nav/notice_route.dart new file mode 100644 index 0000000..b41743f --- /dev/null +++ b/apps/native/lib/nav/notice_route.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../pages/approvals_page.dart'; +import '../pages/record_detail_page.dart'; +import '../pages/todos_page.dart'; +import '../pages/work_assign_page.dart'; +import '../session/session.dart'; +import 'biz_route.dart'; + +/// Opens a system notification at its business destination. System notices are +/// workflow entries, never an IM peer conversation. +void openNoticeTarget( + BuildContext context, + OaClient api, { + required SessionStore session, + required String kind, + required String bizType, + required String bizId, + String title = '系统通知', +}) { + if (bizType == 'WORK_ASSIGN') { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => WorkAssignPage(session: session, api: api), + )); + return; + } + final seed = { + 'bizType': bizType, + 'bizId': bizId, + 'id': bizId, + 'title': title, + }; + if (bizId.isNotEmpty && fetchPathOf(seed) != null) { + openRecord(context, api, seed, title: title); + return; + } + if (kind == 'approval' || bizType.contains('APPROVAL')) { + Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => ApprovalsPage(api: api))); + return; + } + Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => TodosPage(api: api))); +} diff --git a/apps/native/lib/nav/open_module.dart b/apps/native/lib/nav/open_module.dart new file mode 100644 index 0000000..22df321 --- /dev/null +++ b/apps/native/lib/nav/open_module.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../pages/approvals_page.dart'; +import '../pages/calendar_page.dart'; +import '../pages/directory_page.dart'; +import '../pages/flow_page.dart'; +import '../pages/hr_apply_page.dart'; +import '../pages/module_list_page.dart'; +import '../pages/notices_page.dart'; +import '../pages/todos_page.dart'; +import '../pages/work_assign_page.dart'; +import '../session/session.dart'; + +void pushPage(BuildContext context, Widget page) { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); +} + +void openMenuNode(BuildContext context, SessionStore session, OaClient api, MenuNode n) { + if (n.code.startsWith('system') || n.name == '系统设置') return; + if (n.name == '发送短信' || n.code.contains('sms')) return; + if (n.name == '消息' || n.code.contains('office:im') || n.code == 'im') return; + if (n.name == '公司公告' || n.name == '员工通讯' || n.name == '通讯录') return; + switch (n.code) { + case 'office:overview': + return; + case 'office:todos': + pushPage(context, TodosPage(api: api)); + return; + case 'office:approvals': + pushPage(context, ApprovalsPage(api: api)); + return; + case 'office:flow': + pushPage(context, FlowPage(api: api)); + return; + case 'office:calendar': + pushPage(context, CalendarPage(api: api)); + return; + case 'office:notices': + pushPage(context, NoticesPage(api: api)); + return; + case 'office:assign': + pushPage(context, WorkAssignPage(session: session, api: api)); + return; + case 'office:directory': + pushPage(context, DirectoryPage(session: session, api: api)); + return; + case 'hr:apply': + case 'office:leave': + pushPage(context, HrApplyPage(api: api)); + return; + } + final shell = shellFor(n.code, n.path); + if (shell != null) { + pushPage(context, ModuleListPage(api: api, title: n.name, shell: shell)); + return; + } + if (n.children.isNotEmpty) { + showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + for (final c in n.children) + ListTile( + title: Text(c.name), + onTap: () { + Navigator.pop(ctx); + openMenuNode(context, session, api, c); + }, + ), + ], + ), + ), + ); + } +} + +IconData iconFor(String code, String name) { + final k = '$code $name'; + if (k.contains('todo') || name.contains('待办')) return Icons.task_alt; + if (k.contains('approv') || name.contains('审批')) return Icons.fact_check_outlined; + if (name.contains('申请') || k.contains('flow')) return Icons.assignment_outlined; + if (name.contains('日程') || k.contains('calendar')) return Icons.calendar_month_outlined; + if (name.contains('公告') || k.contains('notice')) return Icons.campaign_outlined; + if (name.contains('安排') || k.contains('assign')) return Icons.event_note_outlined; + if (name.contains('通讯') || k.contains('directory')) return Icons.contacts_outlined; + if (name.contains('人事') || name.contains('员工') || k.contains('hr') || k.contains('employee')) return Icons.badge_outlined; + if (name.contains('投标') || k.contains('bid')) return Icons.gavel_outlined; + if (name.contains('合同') || k.contains('contract')) return Icons.description_outlined; + if (name.contains('用章') || name.contains('证照') || k.contains('seal')) return Icons.verified_outlined; + if (name.contains('资产') || k.contains('asset')) return Icons.devices_other_outlined; + if (name.contains('项目') || k.contains('project')) return Icons.account_tree_outlined; + if (name.contains('财务') || name.contains('报销') || name.contains('付款') || k.contains('finance') || k.contains('expense')) { + return Icons.account_balance_wallet_outlined; + } + if (name.contains('客户') || name.contains('往来') || k.contains('party')) return Icons.handshake_outlined; + if (name.contains('报表') || k.contains('report')) return Icons.bar_chart_outlined; + if (name.contains('请假') || name.contains('加班')) return Icons.beach_access_outlined; + return Icons.apps_outlined; +} + +Color colorFor(String code, String name, [int i = 0]) { + const palette = [ + Color(0xFF267EF0), + Color(0xFF07C160), + Color(0xFFFA9D3B), + Color(0xFF6267F2), + Color(0xFF10AEFF), + Color(0xFFE75D5D), + Color(0xFF00B578), + Color(0xFF8B5CF6), + ]; + return palette[(code.hashCode.abs() + name.hashCode.abs() + i) % palette.length]; +} diff --git a/apps/native/lib/ota/updater.dart b/apps/native/lib/ota/updater.dart new file mode 100644 index 0000000..14aaa60 --- /dev/null +++ b/apps/native/lib/ota/updater.dart @@ -0,0 +1,251 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../api/oa_client.dart'; +import '../app_runtime.dart'; +import '../theme.dart'; + +class AppRelease { + AppRelease({ + required this.version, + required this.build, + required this.url, + this.changelog = '', + this.force = false, + this.windowsUrl = '', + this.linuxUrl = '', + this.linuxDebUrl = '', + }); + final String version; + final int build; + final String url; + final String changelog; + final bool force; + final String windowsUrl; + final String linuxUrl; + final String linuxDebUrl; + + bool get newer => build > AppRuntime.build; + + String get desktopUrl { + if (Platform.isWindows) return windowsUrl; + if (Platform.isLinux) return linuxDebUrl.isNotEmpty ? linuxDebUrl : linuxUrl; + return ''; + } + + factory AppRelease.fromJson(Map j) { + return AppRelease( + version: '${j['version'] ?? ''}', + build: (j['build'] as num?)?.toInt() ?? 0, + url: '${j['url'] ?? j['apkUrl'] ?? ''}', + changelog: '${j['changelog'] ?? ''}', + force: j['force'] == true, + windowsUrl: '${j['windowsUrl'] ?? ''}', + linuxUrl: '${j['linuxUrl'] ?? ''}', + linuxDebUrl: '${j['linuxDebUrl'] ?? ''}', + ); + } +} + +class OtaUpdater { + OtaUpdater(this.api); + final OaClient api; + static const _ch = MethodChannel('com.fysxkj.oa/ota'); + + Future fetch() async { + try { + final data = await api.get('/system/app-release'); + if (data is Map) return AppRelease.fromJson(Map.from(data)); + } catch (_) {} + return null; + } + + Future installApk(String path) async { + await _ch.invokeMethod('installApk', {'path': path}); + } + + Future download(String url, void Function(double p) onProgress, {String filename = 'fengying-oa-update'}) async { + final dir = await getTemporaryDirectory(); + final ext = url.contains('.exe') + ? '.exe' + : url.contains('.deb') + ? '.deb' + : url.contains('.tar.gz') + ? '.tar.gz' + : url.contains('.apk') + ? '.apk' + : ''; + final file = File('${dir.path}/$filename$ext'); + final client = HttpClient(); + try { + final req = await client.getUrl(Uri.parse(url)); + final res = await req.close(); + if (res.statusCode >= 400) { + throw ApiException('下载失败 ${res.statusCode}'); + } + final total = res.contentLength; + var rec = 0; + final sink = file.openWrite(); + await for (final chunk in res) { + rec += chunk.length; + sink.add(chunk); + if (total > 0) onProgress(rec / total); + } + await sink.close(); + return file; + } finally { + client.close(force: true); + } + } + + Future prompt(BuildContext context, AppRelease rel, {bool manual = false}) async { + if (!rel.newer && !manual) return; + if (!rel.newer && manual) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已是最新版本'))); + } + return; + } + if (!context.mounted) return; + final go = await showDialog( + context: context, + barrierDismissible: !rel.force, + builder: (ctx) => AlertDialog( + title: Text('发现新版本 ${rel.version}'), + content: Text(rel.changelog.isEmpty ? '请升级后继续使用。' : rel.changelog), + actions: [ + if (!rel.force) TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('稍后')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('立即升级')), + ], + ), + ); + if (go == true && context.mounted) await start(context, rel); + } + + Future start(BuildContext context, AppRelease rel) async { + final desktop = rel.desktopUrl; + if (Platform.isWindows || Platform.isLinux) { + if (desktop.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('暂无桌面端升级包地址'))); + } + return; + } + final progress = ValueNotifier(0); + if (!context.mounted) return; + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: const Text('正在下载更新'), + content: OtaProgress(progress: progress), + ), + ); + try { + final file = await download(desktop, (p) => progress.value = p, filename: 'fengying-oa-setup'); + if (context.mounted) Navigator.of(context, rootNavigator: true).pop(); + if (Platform.isWindows) { + await Process.start(file.path, ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART'], mode: ProcessStartMode.detached); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('安装程序已启动,请按提示完成升级后重启应用'))); + } + } else if (file.path.endsWith('.deb')) { + final r = await Process.run('pkexec', ['env', 'DEBIAN_FRONTEND=noninteractive', 'dpkg', '-i', file.path]); + if (context.mounted) { + if (r.exitCode == 0) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('升级完成,请重启应用'))); + } else { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('安装失败:${r.stderr}'))); + } + } + } else { + await Process.run('xdg-open', [file.parent.path]); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已下载到 ${file.path},请解压覆盖后重启应用'))); + } + } + } catch (e) { + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('升级失败:$e'))); + } + } finally { + progress.dispose(); + } + return; + } + if (!Platform.isAndroid) return; + if (rel.url.isEmpty) return; + final progress = ValueNotifier(0); + if (!context.mounted) return; + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: const Text('正在下载更新'), + content: OtaProgress(progress: progress), + ), + ); + try { + final file = await download(rel.url, (p) => progress.value = p); + if (context.mounted) Navigator.of(context, rootNavigator: true).pop(); + await installApk(file.path); + } catch (e) { + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('升级失败:$e'))); + } + } finally { + progress.dispose(); + } + } +} + +class OtaProgress extends StatefulWidget { + const OtaProgress({super.key, required this.progress}); + final ValueListenable progress; + @override + State createState() => _OtaProgressState(); +} + +class _OtaProgressState extends State { + @override + void initState() { + super.initState(); + widget.progress.addListener(_on); + } + + void _on() => setState(() {}); + + @override + void dispose() { + widget.progress.removeListener(_on); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final p = widget.progress.value; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + LinearProgressIndicator(value: p <= 0 ? null : p, color: kBind), + const SizedBox(height: 12), + Text('${(p * 100).clamp(0, 100).toStringAsFixed(0)}%'), + ], + ); + } +} + +String prettyJson(Object v) { + try { + return const JsonEncoder.withIndent(' ').convert(v); + } catch (_) { + return '$v'; + } +} diff --git a/apps/native/lib/pages/approvals_page.dart b/apps/native/lib/pages/approvals_page.dart new file mode 100644 index 0000000..2fe1796 --- /dev/null +++ b/apps/native/lib/pages/approvals_page.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../labels.dart'; +import '../pages/record_detail_page.dart'; +import '../widgets/common.dart'; + +class ApprovalsPage extends StatefulWidget { + const ApprovalsPage({super.key, required this.api}); + final OaClient api; + + @override + State createState() => _ApprovalsPageState(); +} + +class _ApprovalsPageState extends State { + List> _items = []; + String _bucket = 'PENDING'; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final data = await widget.api.get('/office/approvals'); + if (!mounted) return; + setState(() { + _items = asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final shown = _bucket.isEmpty ? _items : _items.where((e) => '${e['status']}' == _bucket).toList(); + return Scaffold( + appBar: AppBar(title: const Text('待我审批')), + body: Column( + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row( + children: [ + for (final it in [('PENDING', '待审批'), ('APPROVED', '已通过'), ('REJECTED', '已驳回'), ('', '全部')]) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(it.$2), + selected: _bucket == it.$1, + showCheckmark: false, + visualDensity: VisualDensity.compact, + onSelected: (_) => setState(() => _bucket = it.$1), + ), + ), + ], + ), + ), + if (_loading) const LinearProgressIndicator(minHeight: 2), + Expanded( + child: shown.isEmpty + ? const EmptyHint('没有审批任务') + : RefreshIndicator( + onRefresh: _load, + child: ListView.builder( + itemCount: shown.length, + itemBuilder: (_, i) { + final r = shown[i]; + return KvTile( + title: '${r['title'] ?? ''}', + subtitle: '${zh(r['bizType'])} ${fmtTime(r['createdAt'])}', + status: r['status'], + onTap: () async { + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => RecordDetailPage(api: widget.api, seed: r, title: '${r['title'] ?? '审批'}'), + )); + _load(); + }, + ); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/pages/attendance_page.dart b/apps/native/lib/pages/attendance_page.dart new file mode 100644 index 0000000..f806c14 --- /dev/null +++ b/apps/native/lib/pages/attendance_page.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../device/device_bridge.dart'; +import '../theme.dart'; +import '../widgets/wecom.dart'; + +class AttendancePage extends StatefulWidget { + const AttendancePage({super.key, required this.api, this.embedded = false}); + final OaClient api; + final bool embedded; + @override + State createState() => _AttendancePageState(); +} + +class _AttendancePageState extends State { + Map _status = {}; + Map? _location; + bool _loading = true; + bool _sending = false; + String _mode = 'OFFICE'; + + @override + void initState() { super.initState(); _load(); } + + Future _load() async { + try { + _location = await DeviceBridge.getLocation(); + final q = { + if (_location != null) 'lat': '${_location!['lat']}', + if (_location != null) 'lng': '${_location!['lng']}', + 'mode': _mode, + }; + final data = Map.from(await widget.api.get('/attendance/punch-status', query: q) as Map); + if (mounted) setState(() { _status = data; _loading = false; }); + } catch (_) { if (mounted) setState(() => _loading = false); } + } + + Future _punch(String kind) async { + if (_mode == 'OFFICE' && _status['tripActive'] == true) return; + if (_mode == 'FIELD' && _status['fieldApproved'] != true) return; + setState(() => _sending = true); + try { + await widget.api.post('/attendance/punch', {'kind': kind, 'mode': _mode, if (_location != null) ..._location!}); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('打卡成功'))); + await _load(); + } catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); } + finally { if (mounted) setState(() => _sending = false); } + } + + @override + Widget build(BuildContext context) { + final inKey = _mode == 'FIELD' ? 'fieldIn' : 'clockIn'; + final outKey = _mode == 'FIELD' ? 'fieldOut' : 'clockOut'; + final inAt = _time(_status[inKey]); + final outAt = _time(_status[outKey]); + final hasIn = inAt != '未打卡'; + final hasOut = outAt != '未打卡'; + final trip = _status['tripActive'] == true; + final fieldOk = _status['fieldApproved'] == true; + final canPunch = _mode == 'FIELD' ? fieldOk : !trip && _status['inRange'] != false; + final body = _loading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _load, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 28), + children: [ + SegmentedButton( + segments: const [ + ButtonSegment(value: 'OFFICE', label: Text('上下班打卡'), icon: Icon(Icons.access_time)), + ButtonSegment(value: 'FIELD', label: Text('外勤打卡'), icon: Icon(Icons.location_on_outlined)), + ], + selected: {_mode}, + onSelectionChanged: (v) async { + setState(() => _mode = v.first); + await _load(); + }, + ), + const SizedBox(height: 14), + Card( + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 20, 18, 24), + child: Column( + children: [ + Text(_today(), style: const TextStyle(fontSize: 25, fontWeight: FontWeight.w800)), + const SizedBox(height: 8), + _rangeBanner(canPunch, trip, fieldOk), + const SizedBox(height: 18), + _punchCircle( + enabled: canPunch && !hasIn && !_sending, + label: _mode == 'FIELD' ? '外勤打卡' : '上班打卡', + color: const Color(0xFF2575EA), + onTap: () => _punch('IN'), + ), + const SizedBox(height: 18), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _timeBox('上班', inAt, _status['late'] == true), + _timeBox('下班', outAt, _status['early'] == true), + ], + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: canPunch && hasIn && !hasOut && !_sending ? () => _punch('OUT') : null, + icon: const Icon(Icons.logout), + label: Text(hasOut ? '已打下班卡' : '打下班卡'), + ), + ), + if (_mode == 'OFFICE' && _status['late'] == true) const _Flag(text: '迟到', color: Colors.orange), + if (_mode == 'OFFICE' && _status['early'] == true) const _Flag(text: '早退', color: Colors.deepOrange), + ], + ), + ), + ), + const SizedBox(height: 12), + Card( + elevation: 0, + child: ListTile( + leading: Icon(Icons.place, color: _status['inRange'] == true ? Colors.green : Colors.red), + title: Text('${_status['rangeText'] ?? '正在获取考勤范围'}', style: const TextStyle(fontWeight: FontWeight.w600)), + subtitle: Text(_status['distanceMeters'] == null ? '请开启定位后刷新' : '距离考勤点约 ${_status['distanceMeters']} 米'), + trailing: IconButton(onPressed: _load, icon: const Icon(Icons.refresh)), + ), + ), + ], + ), + ); + if (widget.embedded) { + return ColoredBox( + color: const Color(0xFFF4F6FA), + child: Column( + children: [ + const WxHeader(title: '打卡'), + Expanded(child: body), + ], + ), + ); + } + return Scaffold( + backgroundColor: const Color(0xFFF4F6FA), + appBar: AppBar(title: const Text('打卡'), backgroundColor: Colors.transparent), + body: body, + ); + } + + Widget _rangeBanner(bool canPunch, bool trip, bool fieldOk) { + final text = _mode == 'FIELD' ? (fieldOk ? '外出申请已通过,可进行外勤打卡' : '需先申请并审批通过外出') : (trip ? '出差期间无需打卡' : (canPunch ? '当前可进行上下班打卡' : '未进入考勤范围')); + final color = trip || fieldOk || canPunch ? Colors.green : Colors.orange; + return Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: color.withValues(alpha: .1), borderRadius: BorderRadius.circular(12)), child: Row(children: [Icon(Icons.info_outline, color: color), const SizedBox(width: 8), Expanded(child: Text(text, style: TextStyle(color: color, fontWeight: FontWeight.w600)))])); + } + + Widget _punchCircle({required bool enabled, required String label, required Color color, required VoidCallback onTap}) => GestureDetector(onTap: enabled ? onTap : null, child: AnimatedContainer(duration: const Duration(milliseconds: 180), width: 190, height: 190, decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: enabled ? color : Colors.grey.shade400, width: 10), color: Colors.white), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text(label, style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: enabled ? kInk : kMute)), const SizedBox(height: 6), Text(_nowTime(), style: TextStyle(fontSize: 24, fontWeight: FontWeight.w800, color: enabled ? color : kMute))]))); + Widget _timeBox(String label, String value, bool flag) => Column(children: [Text(label, style: const TextStyle(color: kMute)), const SizedBox(height: 4), Row(children: [Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), if (flag) const Padding(padding: EdgeInsets.only(left: 4), child: Icon(Icons.error, color: Colors.orange, size: 16))])]); + String _today() { final d = DateTime.now(); return '${d.year}年${d.month}月${d.day}日'; } + String _nowTime() { final d = DateTime.now(); return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; } + String _time(dynamic value) { if (value == null || '$value' == 'null' || '$value'.isEmpty) return '未打卡'; final d = DateTime.tryParse('$value')?.toLocal(); return d == null ? '$value' : '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; } +} + +class _Flag extends StatelessWidget { + const _Flag({required this.text, required this.color}); + final String text; final Color color; + @override Widget build(BuildContext context) => Padding(padding: const EdgeInsets.only(top: 8), child: Text(text, style: TextStyle(color: color, fontWeight: FontWeight.w700))); +} diff --git a/apps/native/lib/pages/calendar_page.dart b/apps/native/lib/pages/calendar_page.dart new file mode 100644 index 0000000..020a70c --- /dev/null +++ b/apps/native/lib/pages/calendar_page.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../labels.dart'; +import '../widgets/common.dart'; +import 'record_detail_page.dart'; + +class CalendarPage extends StatefulWidget { + const CalendarPage({super.key, required this.api}); + final OaClient api; + + @override + State createState() => _CalendarPageState(); +} + +class _CalendarPageState extends State { + List> _items = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final data = await widget.api.get('/office/calendar'); + if (!mounted) return; + setState(() { + _items = asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('我的日程')), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _items.isEmpty + ? const EmptyHint('近期没有日程') + : RefreshIndicator( + onRefresh: _load, + child: ListView( + children: [ + for (final r in _items) + KvTile( + title: '${r['title'] ?? r['name'] ?? '日程'}', + subtitle: '${fmtTime(r['start'] ?? r['beginAt'] ?? r['createdAt'])} ${zh(r['type'] ?? r['kind'])}', + onTap: () => openRecord(context, widget.api, r, title: '${r['title'] ?? r['name'] ?? '日程'}'), + ), + ], + ), + ), + ); + } +} diff --git a/apps/native/lib/pages/call_page.dart b/apps/native/lib/pages/call_page.dart new file mode 100644 index 0000000..22006ba --- /dev/null +++ b/apps/native/lib/pages/call_page.dart @@ -0,0 +1,436 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:livekit_client/livekit_client.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; + +import '../api/oa_client.dart'; +import '../device/device_bridge.dart'; +import '../session/session.dart'; +import '../theme.dart'; + +/// LiveKit 音视频房间页面。信令和媒体连接均由 LiveKit 负责,OA 只签发短时房间 Token。 +class CallPage extends StatefulWidget { + const CallPage({ + super.key, + required this.session, + required this.api, + required this.callId, + required this.kind, + required this.title, + required this.initiatorId, + required this.memberIds, + this.peerName = '同事', + this.incoming = false, + }); + final SessionStore session; + final OaClient api; + final String callId; + final String kind; + final String title; + final String initiatorId; + final List memberIds; + final String peerName; + final bool incoming; + + @override + State createState() => _CallPageState(); +} + +class _CallPageState extends State { + Room? _room; + VideoTrack? _remoteVideo; + VideoTrack? _localVideo; + String _status = '正在连接音视频服务…'; + bool _ready = false; + bool _muted = false; + bool _camOff = false; + bool _ending = false; + bool _remoteEnded = false; + bool _tone = false; + DateTime? _connectedAt; + Duration _elapsed = Duration.zero; + Timer? _durationTimer; + Timer? _ringTimeoutTimer; + Timer? _statePollTimer; + bool get _video => widget.kind != 'audio'; + + @override + void initState() { + super.initState(); + widget.session.im.addListener(_onIm); + _statePollTimer = Timer.periodic( + const Duration(seconds: 2), (_) => unawaited(_pollCallState())); + _boot(); + } + + void _onIm() { + if (_ending || _remoteEnded || widget.session.im.inbox.isEmpty) return; + final matches = widget.session.im.inbox.where((item) => + item.kind == 'call' && '${item.raw['callId'] ?? ''}' == widget.callId); + if (matches.isEmpty) return; + final last = matches.first; + final raw = last.raw; + if (last.kind != 'call' || '${raw['callId'] ?? ''}' != widget.callId) + return; + final action = '${raw['action'] ?? ''}'; + if (action != 'end' && action != 'reject' && action != 'timeout') return; + _finishRemote(action == 'timeout' + ? '无人接听' + : action == 'reject' + ? '对方拒绝接听' + : '通话已结束'); + } + + Future _pollCallState() async { + if (_ending || _remoteEnded) return; + try { + final raw = await widget.api.get('/im/calls/${widget.callId}'); + if (raw is! Map || !mounted) return; + final data = Map.from(raw); + final status = '${data['status'] ?? ''}'; + if (status == 'ended') { + _finishRemote(data['peerRejected'] == true ? '对方拒绝接听' : '通话已结束'); + return; + } + if (data['peerJoined'] == true && _ready) { + _stopTone(); + _startDuration(); + if (mounted && _status != '通话中') setState(() => _status = '通话中'); + } + } catch (_) { + // 短时断网由 LiveKit 重连处理,状态轮询下一轮继续。 + } + } + + void _finishRemote(String text) { + if (_remoteEnded || _ending) return; + _remoteEnded = true; + _ending = true; + _stopTone(); + _stopDuration(); + _ringTimeoutTimer?.cancel(); + _statePollTimer?.cancel(); + unawaited(_room?.disconnect()); + if (!mounted) return; + setState(() => _status = text); + Future.delayed(const Duration(milliseconds: 850), () { + if (mounted) Navigator.of(context).pop(); + }); + } + + Future _boot() async { + if (widget.incoming) { + if (mounted) setState(() => _status = '对方邀请你${_video ? '视频' : '语音'}通话'); + _startTone(); + _startRingTimeout(); + return; + } + await _start(); + } + + Future _start() async { + if (_ready) return; + if (widget.incoming) _stopTone(); + if (mounted) + setState(() { + _ready = true; + _status = '正在接通…'; + }); + _startTone(); + _startRingTimeout(); + try { + await DeviceBridge.requestCallMedia(); + await widget.api.post('/im/calls/${widget.callId}/join'); + final auth = + await widget.api.post('/im/calls/${widget.callId}/livekit-token'); + final data = + auth is Map ? Map.from(auth) : {}; + final url = '${data['url'] ?? ''}'; + final token = '${data['token'] ?? ''}'; + if (url.isEmpty || token.isEmpty) throw Exception('音视频服务未配置'); + final room = Room(); + _room = room; + room.events + ..on((_) { + _stopTone(); + _startDuration(); + if (mounted) setState(() => _status = '通话中'); + }) + ..on((_) { + if (!_ending) _finishRemote('通话已结束'); + }) + ..on((event) { + _stopTone(); + _startDuration(); + if (event.track is VideoTrack && mounted) + setState(() => _remoteVideo = event.track as VideoTrack); + if (mounted) setState(() => _status = '通话中'); + }) + ..on((event) { + if (event.track is VideoTrack && mounted) + setState(() => _remoteVideo = null); + }) + ..on((_) { + if (mounted) setState(() => _status = '网络恢复中…'); + }) + ..on((_) { + if (mounted) setState(() => _status = '通话中'); + }) + ..on((_) { + if (!_ending) _finishRemote('通话已断开'); + }); + await room.connect(url, token); + // 接听方完成信令连接后即可停止响铃;拨打方继续响铃直到对方加入房间。 + if (widget.incoming) _stopTone(); + await room.localParticipant?.setMicrophoneEnabled(true); + if (_video) { + final pub = await room.localParticipant?.setCameraEnabled(true); + if (mounted && pub?.track is VideoTrack) + setState(() => _localVideo = pub!.track as VideoTrack); + } + if (room.remoteParticipants.isNotEmpty) { + _stopTone(); + _startDuration(); + } + if (mounted) { + setState(() => _status = room.remoteParticipants.isNotEmpty + ? '通话中' + : widget.initiatorId == widget.session.userId + ? '等待对方接听…' + : '正在建立通话…'); + } + } catch (e) { + if (mounted) setState(() => _status = '$e'); + } + } + + Future _hangup() async { + if (_ending) return; + _ending = true; + _stopTone(); + _stopDuration(); + _ringTimeoutTimer?.cancel(); + _statePollTimer?.cancel(); + try { + await widget.api.post( + '/im/calls/${widget.callId}/${(!_ready && widget.incoming) ? 'reject' : 'end'}'); + } catch (_) {} + await _room?.disconnect(); + if (mounted) Navigator.of(context).pop(); + } + + Future _toggleMute() async { + final next = !_muted; + await _room?.localParticipant?.setMicrophoneEnabled(!next); + if (mounted) setState(() => _muted = next); + } + + Future _toggleCam() async { + final next = !_camOff; + final pub = await _room?.localParticipant?.setCameraEnabled(!next); + if (mounted) + setState(() { + _camOff = next; + if (!next && pub?.track is VideoTrack) + _localVideo = pub!.track as VideoTrack; + }); + } + + @override + void dispose() { + widget.session.im.removeListener(_onIm); + _stopTone(); + _stopDuration(); + _ringTimeoutTimer?.cancel(); + _statePollTimer?.cancel(); + _room?.disconnect(); + super.dispose(); + } + + void _startTone() { + if (_tone) return; + _tone = true; + DeviceBridge.startCallTone(incoming: widget.incoming); + } + + void _stopTone() { + if (!_tone) return; + _tone = false; + DeviceBridge.stopCallTone(); + } + + void _startDuration() { + if (_connectedAt != null) return; + _ringTimeoutTimer?.cancel(); + _ringTimeoutTimer = null; + _connectedAt = DateTime.now(); + _durationTimer?.cancel(); + _durationTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted || _connectedAt == null) return; + setState(() => _elapsed = DateTime.now().difference(_connectedAt!)); + }); + if (mounted) setState(() => _elapsed = Duration.zero); + } + + void _stopDuration() { + _durationTimer?.cancel(); + _durationTimer = null; + } + + void _startRingTimeout() { + _ringTimeoutTimer?.cancel(); + _ringTimeoutTimer = Timer(const Duration(seconds: 45), () { + if (!mounted || _ending || _connectedAt != null) return; + _ending = true; + _remoteEnded = true; + _stopTone(); + unawaited(widget.api + .post( + '/im/calls/${widget.callId}/${widget.incoming ? 'reject' : 'end'}') + .catchError((_) {})); + setState(() => _status = '无人接听'); + Future.delayed(const Duration(milliseconds: 650), () { + if (mounted) Navigator.of(context).pop(); + }); + }); + } + + String get _durationText { + final h = _elapsed.inHours; + final m = _elapsed.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = _elapsed.inSeconds.remainder(60).toString().padLeft(2, '0'); + return h > 0 ? '${h.toString().padLeft(2, '0')}:$m:$s' : '$m:$s'; + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _hangup(); + }, + child: Scaffold( + backgroundColor: const Color(0xFF1C1C1E), + body: SafeArea( + child: Stack(children: [ + if (_video && _remoteVideo != null) + Positioned.fill( + child: VideoTrackRenderer(_remoteVideo!, + fit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover)) + else + Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + CircleAvatar( + radius: 48, + backgroundColor: kBind, + child: Text( + widget.peerName.trim().isEmpty + ? '通' + : String.fromCharCodes(widget.peerName.runes.take(1)), + style: + const TextStyle(color: Colors.white, fontSize: 32))), + const SizedBox(height: 16), + Text(widget.peerName, + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Text(_status, + style: + const TextStyle(color: Color(0xFFBBBBBB), fontSize: 14)), + if (_connectedAt != null) ...[ + const SizedBox(height: 6), + Text(_durationText, + style: const TextStyle( + color: Color(0xFFBBBBBB), fontSize: 14)), + ], + ])), + if (_video && _localVideo != null) + Positioned( + right: 16, + top: 16, + width: 110, + height: 160, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: VideoTrackRenderer(_localVideo!, + mirrorMode: VideoViewMirrorMode.mirror))), + if (_video && _remoteVideo != null) + Positioned( + left: 0, + right: 0, + top: 24, + child: Text(_status, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70))), + Positioned( + left: 0, + right: 0, + bottom: 36, + child: + Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + if (!_ready) ...[ + _round( + Icons.call_end, const Color(0xFFE64340), _hangup, '拒绝'), + const SizedBox(width: 48), + _round(Icons.call, const Color(0xFF07C160), _start, '接听'), + ] else ...[ + _round( + _muted ? Icons.mic_off : Icons.mic, + const Color(0xFF3A3A3C), + _toggleMute, + _muted ? '开麦' : '静音'), + if (_video) ...[ + const SizedBox(width: 28), + _round(_camOff ? Icons.videocam_off : Icons.videocam, + const Color(0xFF3A3A3C), _toggleCam, '摄像头') + ], + const SizedBox(width: 28), + _round( + Icons.call_end, const Color(0xFFE64340), _hangup, '挂断'), + ], + ])), + ])), + ), + ); + } + + Widget _round(IconData icon, Color color, VoidCallback onTap, String label) => + Column(mainAxisSize: MainAxisSize.min, children: [ + InkWell( + onTap: onTap, + customBorder: const CircleBorder(), + child: CircleAvatar( + radius: 28, + backgroundColor: color, + child: Icon(icon, color: Colors.white, size: 26))), + const SizedBox(height: 8), + Text(label, + style: const TextStyle(color: Colors.white70, fontSize: 12)), + ]); +} + +Future openCallPage(BuildContext context, + {required SessionStore session, + required OaClient api, + required Map call, + String peerName = '同事', + bool incoming = false}) async { + final id = '${call['id'] ?? ''}'; + if (id.isEmpty) return; + final members = + ((call['memberIds'] as List?) ?? []).map((e) => '$e').toList(); + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => CallPage( + session: session, + api: api, + callId: id, + kind: '${call['kind'] ?? 'audio'}', + title: '${call['title'] ?? '通话'}', + initiatorId: '${call['initiatorId'] ?? ''}', + memberIds: members, + peerName: peerName, + incoming: incoming))); +} diff --git a/apps/native/lib/pages/chat_detail_page.dart b/apps/native/lib/pages/chat_detail_page.dart new file mode 100644 index 0000000..c689f1d --- /dev/null +++ b/apps/native/lib/pages/chat_detail_page.dart @@ -0,0 +1,344 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../im/chat_prefs.dart'; +import '../session/session.dart'; +import '../theme.dart'; +import '../widgets/wecom.dart'; +import 'pick_people_page.dart'; + +class ChatDetailPage extends StatefulWidget { + const ChatDetailPage({ + super.key, + required this.session, + required this.api, + required this.conversationId, + required this.peerId, + required this.peerName, + required this.isGroup, + this.peerAvatarFileId, + this.messages = const [], + this.onCall, + }); + final SessionStore session; + final OaClient api; + final String conversationId; + final String peerId; + final String peerName; + final bool isGroup; + final String? peerAvatarFileId; + final List> messages; + final void Function(String kind)? onCall; + + @override + State createState() => _ChatDetailPageState(); +} + +class _ChatDetailPageState extends State { + List> _members = []; + bool _mute = false; + bool _pin = false; + + @override + void initState() { + super.initState(); + _mute = ChatPrefs.muted(widget.conversationId); + _pin = ChatPrefs.pinned(widget.conversationId); + _loadMembers(); + } + + Future _loadMembers() async { + if (widget.isGroup && widget.conversationId.isNotEmpty) { + try { + final data = await widget.api.get('/im/groups/${widget.conversationId}/members'); + if (!mounted) return; + setState(() => _members = asMaps(data)); + return; + } catch (_) {} + } + setState(() { + _members = [ + { + 'id': widget.peerId, + 'name': widget.peerName, + 'avatarFileId': widget.peerAvatarFileId, + }, + { + 'id': widget.session.userId, + 'name': widget.session.displayName, + 'avatarFileId': widget.session.user['avatarFileId'], + }, + ]; + }); + } + + Future _addMembers() async { + final exclude = { + widget.session.userId, + ..._members.map((e) => '${e['id'] ?? e['userId'] ?? ''}'), + }; + final picked = await Navigator.of(context).push>>( + MaterialPageRoute( + builder: (_) => PickPeoplePage(api: widget.api, title: '选择同事', exclude: exclude), + ), + ); + if (picked == null || picked.isEmpty) return; + try { + if (widget.isGroup && widget.conversationId.isNotEmpty) { + await widget.api.post('/im/groups/${widget.conversationId}/members', { + 'memberIds': picked.map((e) => '${e['id']}').toList(), + }); + await _loadMembers(); + } else { + await widget.api.post('/im/groups', { + 'name': '${widget.peerName}的群聊', + 'memberIds': [widget.peerId, ...picked.map((e) => '${e['id']}')], + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已创建群聊'))); + Navigator.pop(context); + } + } + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); + } + } + + Future _search() async { + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => _ChatSearchPage(messages: widget.messages, peerName: widget.peerName), + )); + } + + Future _pickBg() async { + const colors = [ + 0xFFF5F5F5, + 0xFFE7F0E4, + 0xFFE8EEF6, + 0xFFF6EFE6, + 0xFFF3E8EE, + ]; + final picked = await showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final c in colors) + GestureDetector( + onTap: () => Navigator.pop(ctx, c), + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Color(c), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFDDDDDD)), + ), + ), + ), + ], + ), + ), + ), + ); + if (picked == null) return; + await ChatPrefs.setBg(widget.conversationId, picked); + if (mounted) setState(() {}); + } + + Future _clear() async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('删除聊天记录'), + content: const Text('将从本机清空当前会话的聊天记录,对方不受影响。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')), + ], + ), + ); + if (ok != true) return; + await ChatPrefs.clearHistory(widget.conversationId); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已删除本机聊天记录'))); + Navigator.pop(context, 'cleared'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: kPaper, + appBar: AppBar(title: const Text('聊天详情')), + body: ListView( + children: [ + const SizedBox(height: 12), + Container( + color: Colors.white, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Wrap( + spacing: 16, + runSpacing: 12, + children: [ + for (final m in _members) + SizedBox( + width: 56, + child: Column( + children: [ + SquareAvatar( + label: '${m['name'] ?? m['displayName'] ?? ''}', + size: 48, + fileId: '${m['avatarFileId'] ?? ''}', + api: widget.api, + ), + const SizedBox(height: 4), + Text( + '${m['name'] ?? m['displayName'] ?? ''}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: kInk), + ), + ], + ), + ), + SizedBox( + width: 56, + child: Column( + children: [ + InkWell( + onTap: _addMembers, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFC8C8C8), style: BorderStyle.solid), + ), + child: const Icon(Icons.add, color: kMute), + ), + ), + const SizedBox(height: 4), + const Text(' ', style: TextStyle(fontSize: 12)), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 12), + CellGroup( + children: [ + Cell(title: '查找聊天内容', onTap: _search, showLine: false), + ], + ), + CellGroup( + children: [ + Cell( + title: '消息免打扰', + trailing: Switch( + value: _mute, + activeTrackColor: kWeGreen, + onChanged: (v) async { + await ChatPrefs.setMuted(widget.conversationId, v); + setState(() => _mute = v); + }, + ), + ), + Cell( + title: '置顶聊天', + trailing: Switch( + value: _pin, + activeTrackColor: kWeGreen, + onChanged: (v) async { + await ChatPrefs.setPinned(widget.conversationId, v); + setState(() => _pin = v); + }, + ), + showLine: false, + ), + ], + ), + CellGroup( + children: [ + Cell(title: '设置当前聊天背景', onTap: _pickBg), + Cell(title: '语音通话', onTap: () => widget.onCall?.call('audio')), + Cell( + title: '视频通话', + onTap: () => widget.onCall?.call(widget.isGroup ? 'meeting' : 'video'), + showLine: false, + ), + ], + ), + CellGroup( + children: [ + Cell(title: '删除聊天记录', onTap: _clear, showLine: false), + ], + ), + CellGroup( + children: [ + Cell( + title: '投诉', + onTap: () { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录投诉,我们会尽快处理'))); + }, + showLine: false, + ), + ], + ), + const SizedBox(height: 24), + ], + ), + ); + } +} + +class _ChatSearchPage extends StatefulWidget { + const _ChatSearchPage({required this.messages, required this.peerName}); + final List> messages; + final String peerName; + + @override + State<_ChatSearchPage> createState() => _ChatSearchPageState(); +} + +class _ChatSearchPageState extends State<_ChatSearchPage> { + String _q = ''; + + @override + Widget build(BuildContext context) { + final q = _q.trim(); + final hits = q.isEmpty + ? const >[] + : widget.messages.where((e) => '${e['body'] ?? ''}'.contains(q)).toList(); + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar(title: const Text('查找聊天内容')), + body: Column( + children: [ + WxSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v)), + Expanded( + child: q.isEmpty + ? const Center(child: Text('输入关键词查找聊天内容', style: TextStyle(color: kMute))) + : hits.isEmpty + ? const Center(child: Text('没有找到相关内容', style: TextStyle(color: kMute))) + : ListView( + children: [ + for (final r in hits) + Cell( + title: '${r['body']}', + subtitle: '${r['fromName'] ?? widget.peerName} ${r['createdAt'] ?? ''}', + showLine: true, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/pages/chat_page.dart b/apps/native/lib/pages/chat_page.dart new file mode 100644 index 0000000..6a9e830 --- /dev/null +++ b/apps/native/lib/pages/chat_page.dart @@ -0,0 +1,2001 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; + +import '../api/oa_client.dart'; +import '../device/device_bridge.dart'; +import '../im/chat_prefs.dart'; +import '../im/chat_local_store.dart'; +import '../im/im_seq_store.dart'; +import '../im/sticker_store.dart'; +import '../session/session.dart'; +import '../theme.dart'; +import '../widgets/auth_image.dart'; +import '../widgets/tencent_map_thumb.dart'; +import '../widgets/voice_message.dart'; +import '../widgets/wecom.dart'; +import 'call_page.dart'; +import 'chat_detail_page.dart'; +import 'location_pick_page.dart'; +import 'pick_people_page.dart'; +import 'record_detail_page.dart'; + +const _emojis = [ + '😀', + '😁', + '😂', + '🤣', + '😃', + '😄', + '😅', + '😆', + '😉', + '😊', + '😋', + '😎', + '😍', + '😘', + '🥰', + '😗', + '😙', + '😚', + '🙂', + '🤗', + '🤩', + '🤔', + '🤨', + '😐', + '😑', + '😶', + '🙄', + '😏', + '😣', + '😥', + '😮', + '🤐', + '😯', + '😪', + '😫', + '🥱', + '😴', + '😌', + '😛', + '😜', + '😝', + '🤤', + '😒', + '😓', + '😔', + '😕', + '🙃', + '🤑', + '😲', + '🙁', + '😖', + '😞', + '😟', + '😤', + '😢', + '😭', + '😦', + '😧', + '😨', + '😩', + '🤯', + '😬', + '😰', + '😱', + '🥵', + '🥶', + '😳', + '🤪', + '😵', + '😡', + '😠', + '🤬', + '😷', + '🤒', + '🤕', + '🤢', + '🤮', + '🤧', + '😇', + '🥳', + '🥺', + '🤠', + '🤡', + '🤥', + '🤫', + '🤭', + '🧐', + '🤓', + '😈', + '👿', + '👹', + '👺', + '💀', + '👻', + '👽', + '🤖', + '💩', + '😺', + '😸', + '😹', + '😻', + '😼', + '😽', + '🙀', + '😿', + '😾', + '🙈', + '🙉', + '🙊', + '💋', + '💌', + '💘', + '💝', + '💖', + '💗', + '💓', + '💞', + '💕', + '💟', + '❣️', + '💔', + '❤️', + '🧡', + '💛', + '💚', + '💙', + '💜', + '🖤', + '👍', + '👎', + '👌', + '✌️', + '🤞', + '🤟', + '🤘', + '🤙', + '👈', + '👉', + '👆', + '👇', + '☝️', + '✋', + '🤚', + '🖐', + '🖖', + '👋', + '🤝', + '🙏', + '💪', + '🦾', + '👏', + '🙌', + '👐', + '🤲', + '✊', + '👊', + '🤛', + '🤜', + '🫶', + '👀', + '🔥', + '⭐', + '🌟', + '✨', + '🎉', + '🎊', + '🎈', + '🎁', + '🏆', + '🥇', + '🎯', + '📌', + '📍', + '💡', + '💯', + '✅', + '❌', + '❓', + '❗', + '💤', + '💢', + '💥', + '💦', + '💨', + '🌸', + '🌹', + '🍀', + '🌙', + '☀️', + '🌈', + '☕', + '🍺', +]; + +bool _isGifFile(String path, {String? name, String? mime}) { + final n = (name ?? path).toLowerCase(); + if (n.endsWith('.gif')) return true; + if ((mime ?? '').toLowerCase().contains('gif')) return true; + try { + final raf = File(path).openSync(); + final b = raf.readSync(6); + raf.closeSync(); + return b.length >= 3 && b[0] == 0x47 && b[1] == 0x49 && b[2] == 0x46; + } catch (_) { + return false; + } +} + +class ChatPage extends StatefulWidget { + const ChatPage({ + super.key, + required this.session, + required this.api, + this.peerId = '', + this.conversationId = '', + required this.peerName, + this.isGroup = false, + this.peerAvatarFileId, + this.embedded = false, + }); + final SessionStore session; + final OaClient api; + final String peerId; + final String conversationId; + final String peerName; + final bool isGroup; + final String? peerAvatarFileId; + final bool embedded; + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State { + final _input = TextEditingController(); + final _scroll = ScrollController(); + final _focus = FocusNode(); + List> _items = []; + String _convId = ''; + bool _sending = false; + bool _plus = false; + bool _emoji = false; + bool _voice = false; + bool _recording = false; + bool _cancelVoice = false; + bool _pressed = false; + int _emojiTab = 0; + DateTime? _voiceDown; + Offset? _downGlobal; + Future? _voiceStart; + bool _firstLoad = true; + Timer? _receiptPoll; + Timer? _playTimer; + Timer? _imDebounce; + String? _playingId; + final Set _voiceTextLoading = {}; + Color _bg = const Color(0xFFF5F5F5); + int _loadSeq = 0; + + @override + void initState() { + super.initState(); + _convId = widget.conversationId; + widget.session.im.addListener(_onIm); + DeviceBridge.watchScreenshot(_onScreenshot); + ChatPrefs.ensure().then((_) { + StickerStore.ensure(); + if (mounted) setState(() => _applyPrefs()); + }); + _load(jump: true); + _ackRead(); + _receiptPoll = Timer.periodic(const Duration(seconds: 10), (_) { + if (mounted) { + _ackRead(); + _load(); + } + }); + } + + @override + void dispose() { + _receiptPoll?.cancel(); + _imDebounce?.cancel(); + _playTimer?.cancel(); + DeviceBridge.stopPlay(); + DeviceBridge.unwatchScreenshot(_onScreenshot); + widget.session.im.removeListener(_onIm); + widget.session.im.bump(); + _input.dispose(); + _scroll.dispose(); + _focus.dispose(); + super.dispose(); + } + + void _applyPrefs() { + final c = ChatPrefs.bgColor(_convId); + _bg = c == null ? const Color(0xFFF5F5F5) : Color(c); + } + + void _hideBoard() { + _focus.unfocus(); + if (_plus || _emoji) + setState(() { + _plus = false; + _emoji = false; + }); + } + + void _onIm() { + final inbox = widget.session.im.inbox; + if (inbox.isNotEmpty) { + final last = inbox.first; + final conv = '${last.raw['conversationId'] ?? ''}'; + final same = conv.isEmpty || conv == _convId; + if (same && last.kind == 'receipt') { + final me = widget.session.userId; + setState(() { + _items = [ + for (final e in _items) + '${e['fromUserId']}' == me + ? { + ...e, + 'readCount': (((e['readCount'] as num?)?.toInt() ?? 0) < 1 + ? 1 + : e['readCount']) + } + : e + ]; + }); + } else if (same && last.kind == 'readSync') { + // 多端已读同步由会话列表角标处理,此处仅刷新消息 + } + } + _ackRead(); + _imDebounce?.cancel(); + _imDebounce = Timer(const Duration(milliseconds: 400), () { + if (mounted) _load(); + }); + } + + Future _ackRead() async { + if (_convId.isEmpty && widget.peerId.isEmpty) return; + try { + await widget.api.post('/im/read', { + if (_convId.isNotEmpty) 'conversationId': _convId, + if (widget.peerId.isNotEmpty) 'peerId': widget.peerId, + }); + } catch (_) {} + } + + Map get _query { + if (_convId.isNotEmpty) return {'conversationId': _convId}; + return {'peerId': widget.peerId}; + } + + Map _metaOf(Map r) { + final m = r['meta']; + if (m is Map) return Map.from(m); + if (m is String && m.isNotEmpty) { + try { + final d = jsonDecode(m); + if (d is Map) return Map.from(d); + } catch (_) {} + } + return {}; + } + + Future _load({bool jump = false}) async { + final seq = ++_loadSeq; + try { + await ImSeqStore.ensure(); + final query = Map.from(_query); + if (_convId.isNotEmpty) { + final since = await ImSeqStore.lastSeq(_convId); + if (since > 0) query['sinceSeq'] = '$since'; + } + final data = await widget.api.get('/im/messages', query: query); + final items = data is Map ? asMaps(data['items'] ?? data) : asMaps(data); + if (data is Map && data['conversationId'] != null) + _convId = '${data['conversationId']}'; + final local = await ChatLocalStore.load(_convId); + final byKey = >{}; + for (final e in [...local, ...items]) { + final fp = '${e['fingerprint'] ?? ''}'; + final s = (e['seq'] as num?)?.toInt() ?? 0; + final key = fp.isNotEmpty + ? 'fp:$fp' + : (s > 0 ? 'seq:$s' : 'id:${e['id'] ?? ''}'); + if (key != '|' && key != 'id:') byKey[key] = e; + } + final merged = byKey.values.toList() + ..sort((a, b) { + final sa = (a['seq'] as num?)?.toInt() ?? 0; + final sb = (b['seq'] as num?)?.toInt() ?? 0; + if (sa > 0 && sb > 0 && sa != sb) return sa.compareTo(sb); + return '${a['createdAt'] ?? ''}'.compareTo('${b['createdAt'] ?? ''}'); + }); + final cut = ChatPrefs.clearedAt(_convId); + final shown = cut == null + ? merged + : merged.where((e) { + final t = DateTime.tryParse('${e['createdAt'] ?? ''}'); + return t == null || t.isAfter(cut); + }).toList(); + if (!mounted || seq != _loadSeq) return; + final pending = _items.where((e) => e['_pending'] == true).toList(); + final fps = {for (final e in shown) '${e['fingerprint'] ?? ''}'}; + final keep = pending + .where((e) => !fps.contains('${e['fingerprint'] ?? ''}')) + .toList(); + setState(() { + _items = [...shown, ...keep]; + _applyPrefs(); + }); + await ChatLocalStore.save(_convId, merged); + if (_convId.isNotEmpty) await ImSeqStore.applyItems(_convId, merged); + final shouldJump = jump || _firstLoad; + _firstLoad = false; + if (shouldJump) { + await Future.delayed(const Duration(milliseconds: 50)); + _jumpBottom(); + } + } catch (_) {} + } + + void _jumpBottom() { + if (_scroll.hasClients) _scroll.jumpTo(_scroll.position.maxScrollExtent); + } + + Future _post(Map body) async { + await _optimisticPost(body); + } + + Future _optimisticPost(Map body) async { + final fp = '${body['fingerprint'] ?? const Uuid().v4()}'; + final local = { + 'id': 'local-$fp', + 'fromUserId': widget.session.userId, + 'fromName': widget.session.displayName, + 'body': body['body'], + 'contentType': body['contentType'] ?? 'text', + 'kind': 'chat', + 'fingerprint': fp, + 'meta': body['meta'], + 'createdAt': DateTime.now().toIso8601String(), + 'readCount': 0, + '_pending': true, + }; + if (mounted) { + setState(() => _items = [..._items, local]); + unawaited(ChatLocalStore.save(_convId, _items)); + WidgetsBinding.instance.addPostFrameCallback((_) => _jumpBottom()); + } + try { + await widget.api.post('/im/messages', { + if (widget.peerId.isNotEmpty) 'peerId': widget.peerId, + if (_convId.isNotEmpty) 'conversationId': _convId, + 'fingerprint': fp, + ...body, + }); + await DeviceBridge.playMessageSent(); + widget.session.im.bump(); + await _load(jump: true); + } catch (e) { + if (mounted) { + setState(() => + _items = _items.where((e) => e['id'] != local['id']).toList()); + } + rethrow; + } + } + + Future _send() async { + final text = _input.text.trim(); + if (text.isEmpty || _sending) return; + _input.clear(); + try { + await _optimisticPost({'body': text}); + } catch (e) { + if (mounted) { + _input.text = text; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + } + + Future _onScreenshot() async { + if (!mounted) return; + try { + await _ensureConv(); + if (_convId.isEmpty && widget.peerId.isEmpty) return; + await widget.api.post('/im/events/screenshot', { + if (_convId.isNotEmpty) 'conversationId': _convId, + if (widget.peerId.isNotEmpty) 'peerId': widget.peerId, + }); + await _load(); + } catch (_) {} + } + + Future _ensureConv() async { + if (_convId.isEmpty) await _load(); + } + + Future> _upload(String path, String name, String bizType, + {String? mime}) async { + await _ensureConv(); + final bizId = _convId.isNotEmpty ? _convId : widget.session.userId; + return widget.api.uploadFile( + filePath: path, + filename: name, + bizType: bizType, + bizId: bizId, + mime: mime); + } + + Future _sendMediaFile({ + required String path, + required String name, + String? mime, + required bool sticker, + }) async { + setState(() => _sending = true); + try { + final gif = sticker || _isGifFile(path, name: name, mime: mime); + Map file; + if (gif) { + try { + file = await _upload(path, name, 'IM_STICKER', mime: mime); + } catch (_) { + file = await _upload(path, name, 'IM_IMAGE', mime: mime); + } + } else { + file = await _upload(path, name, 'IM_IMAGE', mime: mime); + } + final id = '${file['id'] ?? ''}'; + if (gif && id.isNotEmpty) await StickerStore.add(id); + await _post({ + 'body': gif ? '[表情]' : '[图片]', + 'contentType': gif ? 'sticker' : 'image', + 'meta': { + 'fileId': id, + 'fileName': file['fileName'] ?? name, + 'mime': mime ?? (gif ? 'image/gif' : '') + }, + }); + } catch (e) { + if (mounted) showWxToast(context, '$e', error: true); + } finally { + if (mounted) + setState(() { + _sending = false; + _plus = false; + }); + } + } + + Future _pickAlbum() async { + try { + final picked = await DeviceBridge.pickMedia(); + if (picked == null || (picked['path'] ?? '').isEmpty) return; + await _sendMediaFile( + path: picked['path']!, + name: picked['name'] ?? 'image.jpg', + mime: picked['mime']?.isNotEmpty == true ? picked['mime'] : null, + sticker: false, + ); + } catch (e) { + if (mounted) showWxToast(context, '$e', error: true); + } + } + + Future _pickCamera() async { + try { + final x = await ImagePicker().pickImage( + source: ImageSource.camera, imageQuality: 85, maxWidth: 1920); + if (x == null) return; + await _sendMediaFile( + path: x.path, + name: x.name.isEmpty ? 'image.jpg' : x.name, + sticker: false); + } catch (e) { + if (mounted) + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + + Future _addCustomSticker() async { + try { + final picked = await DeviceBridge.pickMedia(); + if (picked == null || (picked['path'] ?? '').isEmpty) return; + setState(() => _sending = true); + await _ensureConv(); + Map file; + try { + file = await _upload( + picked['path']!, picked['name'] ?? 'sticker.gif', 'IM_STICKER', + mime: picked['mime']); + } catch (_) { + file = await _upload( + picked['path']!, picked['name'] ?? 'sticker.gif', 'IM_IMAGE', + mime: picked['mime']); + } + final id = '${file['id'] ?? ''}'; + await StickerStore.add(id); + if (mounted) setState(() {}); + } catch (e) { + if (mounted) + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + Future _sendSticker(String fileId) async { + try { + await _post({ + 'body': '[表情]', + 'contentType': 'sticker', + 'meta': {'fileId': fileId}, + }); + } catch (e) { + if (mounted) + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + + Future _location() async { + final picked = await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => LocationPickPage(api: widget.api), + )); + if (picked == null) return; + await _post({ + 'body': picked.title, + 'contentType': 'location', + 'meta': picked.toMeta(), + }); + if (mounted) setState(() => _plus = false); + } + + Future _sendFile() async { + try { + final picked = await DeviceBridge.pickFile(); + if (picked == null || (picked['path'] ?? '').isEmpty) return; + final name = picked['name'] ?? '文件'; + final mime = picked['mime']; + if (_looksLikeImage(name, mime)) { + await _sendMediaFile( + path: picked['path']!, name: name, mime: mime, sticker: false); + return; + } + setState(() => _sending = true); + Map file; + try { + file = await _upload(picked['path']!, name, 'IM_FILE', mime: mime); + } catch (_) { + file = await _upload(picked['path']!, name, 'IM_IMAGE', mime: mime); + } + await _post({ + 'body': '[文件] $name', + 'contentType': 'file', + 'meta': { + 'fileId': file['id'], + 'fileName': file['fileName'] ?? name, + 'size': file['size'], + 'mime': picked['mime'] ?? file['mimeType'] ?? '', + }, + }); + } catch (e) { + if (mounted) + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } finally { + if (mounted) + setState(() { + _sending = false; + _plus = false; + }); + } + } + + String _sizeText(dynamic n) { + final b = (n as num?)?.toInt() ?? 0; + if (b < 1024) return '$b B'; + if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB'; + return '${(b / 1024 / 1024).toStringAsFixed(1)} MB'; + } + + Future _openChatFile(String fileId, String name, String mime) async { + try { + final bytes = await widget.api.fileBytes(fileId); + if (bytes == null) throw Exception('文件无法下载'); + final dir = await getTemporaryDirectory(); + final safe = name.replaceAll(RegExp(r'[/\\]'), '_'); + final f = File('${dir.path}/$safe'); + await f.writeAsBytes(bytes, flush: true); + await DeviceBridge.openFile(f.path, mime: mime); + } catch (e) { + if (mounted) + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + + bool _looksLikeImage(String name, String? mime) { + final m = (mime ?? '').toLowerCase(); + if (m.startsWith('image/')) return true; + return RegExp(r'\.(jpe?g|png|gif|webp|bmp|heic|heif)(?:$|[?#\s])', + caseSensitive: false) + .hasMatch(name); + } + + Future _call(String kind) async { + try { + final data = await widget.api.post('/im/calls', { + if (_convId.isNotEmpty) 'conversationId': _convId, + if (widget.peerId.isNotEmpty) 'peerId': widget.peerId, + 'kind': kind, + }); + if (!mounted) return; + if (data is Map) { + openCallPage( + context, + session: widget.session, + api: widget.api, + call: Map.from(data), + peerName: widget.peerName, + ); + } + } catch (e) { + if (mounted) showWxToast(context, '$e', error: true); + } + if (mounted) setState(() => _plus = false); + } + + Future _openBusinessCard(Map meta) async { + final path = '${meta['fetchPath'] ?? ''}'; + final title = '${meta['title'] ?? '业务详情'}'; + if (!path.startsWith('/') || path.contains('..')) { + showWxToast(context, '业务卡片地址无效', error: true); + return; + } + try { + // 必须实际请求详情接口,由服务端权限规则决定能否查看。 + final raw = await widget.api.get(path); + if (!mounted) return; + final row = + raw is Map ? Map.from(raw) : {}; + row['_fetch'] = path; + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => RecordDetailPage( + api: widget.api, + seed: row, + title: title, + ), + )); + } catch (e) { + if (!mounted) return; + final message = '$e'; + if (RegExp(r'无权|权限|禁止|403').hasMatch(message)) { + showWxToast(context, '您无权查看本信息', error: true); + } else { + showWxToast(context, message, error: true); + } + } + } + + Future _beginVoice() async { + setState(() { + _recording = true; + _cancelVoice = false; + }); + _voiceDown = DateTime.now(); + _voiceStart = DeviceBridge.startVoice(); + try { + await _voiceStart; + if (!_pressed) { + await DeviceBridge.cancelVoice(); + if (mounted) setState(() => _recording = false); + } + } catch (e) { + if (mounted) { + setState(() => _recording = false); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + } + + Future _endVoice() async { + if (!_pressed && !_recording) return; + final cancel = _cancelVoice; + final start = _voiceDown; + _pressed = false; + _voiceDown = null; + _downGlobal = null; + if (mounted) + setState(() { + _recording = false; + _cancelVoice = false; + }); + try { + await _voiceStart; + } catch (_) { + await DeviceBridge.cancelVoice(); + return; + } + if (cancel || start == null) { + await DeviceBridge.cancelVoice(); + return; + } + final ms = DateTime.now().difference(start).inMilliseconds; + if (ms < 700) { + await DeviceBridge.cancelVoice(); + if (mounted) + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('说话时间太短'))); + return; + } + try { + final file = await DeviceBridge.stopVoice(); + final path = '${file?['path'] ?? ''}'; + if (path.isEmpty) { + if (mounted) + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('说话时间太短'))); + return; + } + setState(() => _sending = true); + final sec = (ms / 1000).round().clamp(1, 60); + final up = await _upload( + path, file?['name']?.toString() ?? 'voice.m4a', 'IM_VOICE', + mime: 'audio/mp4'); + await _post({ + 'body': '[语音] $sec″', + 'contentType': 'voice', + 'meta': { + 'fileId': up['id'], + 'seconds': sec, + 'fileName': up['fileName'] + }, + }); + } catch (e) { + await DeviceBridge.cancelVoice(); + if (mounted) + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + Future _playVoice(Map r) async { + final meta = _metaOf(r); + final fileId = '${meta['fileId'] ?? ''}'; + final sec = (meta['seconds'] as num?)?.toInt() ?? 1; + if (fileId.isEmpty) return; + if (_playingId == fileId) { + await DeviceBridge.stopPlay(); + _playTimer?.cancel(); + setState(() => _playingId = null); + return; + } + try { + final bytes = await widget.api.fileBytes(fileId); + if (bytes == null || bytes.isEmpty) throw Exception('语音无法下载'); + final dir = await getTemporaryDirectory(); + final f = File('${dir.path}/voice_$fileId.m4a'); + await f.writeAsBytes(bytes, flush: true); + await DeviceBridge.playVoice(f.path); + _playTimer?.cancel(); + setState(() => _playingId = fileId); + _playTimer = Timer(Duration(seconds: sec.clamp(1, 60) + 1), () { + if (mounted) setState(() => _playingId = null); + }); + } catch (e) { + if (mounted) + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + + Future _onBubbleLongPress(Map r) async { + final ctype = '${r['contentType'] ?? 'text'}'; + final meta = _metaOf(r); + final fileId = '${meta['fileId'] ?? ''}'; + final mine = '${r['fromUserId']}' == widget.session.userId; + final acts = <(String, String)>[]; + if (ctype == 'text' && + r['recalledAt'] == null && + '${r['body'] ?? ''}'.isNotEmpty) acts.add(('复制', 'copy')); + if ((ctype == 'image' || ctype == 'sticker') && fileId.isNotEmpty) { + acts.add(('查看', 'view')); + acts.add(('保存到相册', 'save')); + } + if (ctype == 'location') acts.add(('查看位置', 'loc')); + if (ctype == 'voice' && fileId.isNotEmpty) acts.add(('语音转文字', 'voiceText')); + if (ctype == 'sticker' && fileId.isNotEmpty) { + acts.add((StickerStore.has(fileId) ? '已在表情' : '添加到表情', 'sticker')); + } + acts.add(('转发', 'forward')); + if (mine && ctype == 'text') { + final t = DateTime.tryParse('${r['createdAt'] ?? ''}'); + if (r['recalledAt'] != null) { + acts.add(('重新编辑', 'edit')); + } else if (t != null && DateTime.now().difference(t).inMinutes < 2) { + acts.add(('编辑', 'edit')); + acts.add(('撤回', 'recall')); + } + } + if (acts.isEmpty) return; + final a = await showWxSheet(context, acts); + if (a == null || !mounted) return; + if (a == 'copy') { + await Clipboard.setData(ClipboardData(text: '${r['body'] ?? ''}')); + if (mounted) showWxToast(context, '已复制'); + } else if (a == 'view' && fileId.isNotEmpty) { + _previewImage(fileId); + } else if (a == 'save' && fileId.isNotEmpty) { + await _saveChatImage(fileId); + } else if (a == 'loc') { + _openLocation(meta, '${r['body'] ?? ''}'); + } else if (a == 'voiceText') { + await _voiceToText(r, fileId); + } else if (a == 'sticker' && fileId.isNotEmpty) { + await StickerStore.add(fileId); + if (mounted) { + setState(() {}); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('已添加到表情'))); + } + } else if (a == 'forward') { + await _forwardMessage(r); + } else if (a == 'edit') { + await _editMessage(r); + } else if (a == 'recall') { + await _recallMessage(r); + } + } + + Future _recallMessage(Map r) async { + final id = '${r['id'] ?? ''}'; + if (id.isEmpty || id.startsWith('local-')) return; + try { + await widget.api.post('/im/messages/$id/recall', {}); + await _load(); + } catch (e) { + if (mounted) showWxToast(context, '$e', error: true); + } + } + + Future _voiceToText(Map row, String fileId) async { + final key = _voiceKey(row, fileId); + final cached = ChatPrefs.voiceText(key); + if (cached != null && cached.isNotEmpty) { + setState(() {}); + return; + } + if (_voiceTextLoading.contains(key)) return; + setState(() => _voiceTextLoading.add(key)); + try { + final bytes = await widget.api.fileBytes(fileId); + if (bytes == null || bytes.isEmpty) throw Exception('语音无法下载'); + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/asr_$fileId.m4a'); + await file.writeAsBytes(bytes, flush: true); + final text = await DeviceBridge.speechToText(audioPath: file.path); + if (!mounted) return; + if (text == null || text.trim().isEmpty) { + showWxToast(context, '未识别到文字', error: true); + return; + } + await ChatPrefs.setVoiceText(key, text.trim()); + if (mounted) setState(() {}); + } catch (e) { + if (mounted) showWxToast(context, '$e', error: true); + } finally { + if (mounted) setState(() => _voiceTextLoading.remove(key)); + } + } + + String _voiceKey(Map row, String fileId) { + final id = '${row['id'] ?? ''}'; + return id.isNotEmpty ? id : fileId; + } + + void _previewImage(String fileId) { + final fileIds = []; + for (final row in _items) { + final ctype = '${row['contentType'] ?? ''}'; + final meta = _metaOf(row); + final id = '${meta['fileId'] ?? ''}'; + final name = '${meta['fileName'] ?? row['body'] ?? ''}'; + if (id.isNotEmpty && + (ctype == 'image' || + ctype == 'sticker' || + _looksLikeImage(name, '${meta['mime'] ?? ''}'))) { + if (!fileIds.contains(id)) fileIds.add(id); + } + } + if (!fileIds.contains(fileId)) fileIds.add(fileId); + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => _ImagePreviewPage( + api: widget.api, + fileIds: fileIds, + initialIndex: fileIds.indexOf(fileId), + ), + )); + } + + void _openLocation(Map meta, String fallback) { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => _LocationViewPage( + api: widget.api, + title: '${meta['title'] ?? fallback}', + address: '${meta['address'] ?? ''}', + lat: (meta['lat'] as num?)?.toDouble(), + lng: (meta['lng'] as num?)?.toDouble(), + ), + )); + } + + Future _saveChatImage(String fileId) async { + try { + final bytes = await widget.api.fileBytes(fileId); + if (bytes == null || bytes.isEmpty) throw Exception('图片无法下载'); + final dir = await getTemporaryDirectory(); + final f = File('${dir.path}/img_$fileId.jpg'); + await f.writeAsBytes(bytes, flush: true); + await DeviceBridge.saveImage(f.path); + if (mounted) + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('已保存到相册'))); + } catch (e) { + if (mounted) + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + + Future _forwardMessage(Map r) async { + final picked = await Navigator.of(context) + .push>>(MaterialPageRoute( + builder: (_) => PickPeoplePage( + api: widget.api, + title: '转发给', + multiple: true, + exclude: {widget.session.userId}), + )); + if (picked == null || picked.isEmpty) return; + final meta = _metaOf(r); + try { + for (final p in picked) { + await widget.api.post('/im/messages', { + 'peerId': '${p['id']}', + 'body': r['body'] ?? '', + 'contentType': r['contentType'] ?? 'text', + if (meta.isNotEmpty) 'meta': meta, + }); + } + if (mounted) + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('已转发给 ${picked.length} 人'))); + } catch (e) { + if (mounted) + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + + Future _editMessage(Map r) async { + final id = '${r['id'] ?? ''}'; + if (id.isEmpty || id.startsWith('local-')) return; + final c = TextEditingController(text: '${r['body'] ?? ''}'); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(r['recalledAt'] != null ? '重新编辑' : '编辑消息'), + content: TextField(controller: c, maxLines: 4, autofocus: true), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消')), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('保存')), + ], + ), + ); + if (ok != true) return; + try { + await widget.api.patch('/im/messages/$id', {'body': c.text.trim()}); + await _load(); + } catch (e) { + if (mounted) showWxToast(context, '$e', error: true); + } + } + + Future _openDetail() async { + if (_convId.isEmpty) await _load(); + if (!mounted) return; + final r = await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => ChatDetailPage( + session: widget.session, + api: widget.api, + conversationId: _convId, + peerId: widget.peerId, + peerName: widget.peerName, + isGroup: widget.isGroup, + peerAvatarFileId: widget.peerAvatarFileId, + messages: _items, + onCall: _call, + ), + )); + if (!mounted) return; + setState(_applyPrefs); + if (r == 'cleared') await _load(jump: true); + } + + @override + Widget build(BuildContext context) { + final me = widget.session.userId; + final myAvatar = '${widget.session.user['avatarFileId'] ?? ''}'; + return Scaffold( + backgroundColor: _bg, + appBar: AppBar( + title: Text(widget.peerName), + automaticallyImplyLeading: !widget.embedded, + leading: widget.embedded ? null : const BackButton(), + actions: [ + IconButton( + onPressed: _openDetail, icon: const Icon(Icons.more_horiz)), + ], + ), + body: Stack( + children: [ + Column( + children: [ + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _hideBoard, + child: ListView.builder( + controller: _scroll, + padding: const EdgeInsets.fromLTRB(10, 12, 10, 12), + itemCount: _items.length, + itemBuilder: (_, i) { + final r = _items[i]; + return KeyedSubtree( + key: ValueKey('${r['id']}_${r['fingerprint']}'), + child: _bubble(r, i, me, myAvatar), + ); + }, + ), + ), + ), + _composer(), + ], + ), + if (_recording) + Positioned.fill( + child: IgnorePointer( + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.35), + child: Column( + children: [ + const Spacer(), + Icon(_cancelVoice ? Icons.undo : Icons.mic, + color: _cancelVoice ? kDanger : Colors.white, + size: 52), + const SizedBox(height: 8), + Text( + _cancelVoice ? '松开手指,取消发送' : '松开发送,上滑取消', + style: TextStyle( + color: _cancelVoice ? kDanger : Colors.white, + fontSize: 14), + ), + const SizedBox(height: 120), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _bubble(Map r, int i, String me, String myAvatar) { + final mine = '${r['fromUserId']}' == me; + final system = widget.peerId == '0' || '${r['fromUserId']}' == '0'; + final kind = '${r['kind'] ?? 'chat'}'; + final ctype = '${r['contentType'] ?? 'text'}'; + var body = '${r['body'] ?? ''}'; + final showTime = i == 0 || + shortTime(r['createdAt']) != shortTime(_items[i - 1]['createdAt']); + if (kind == 'screenshot' || ctype == 'system' && kind == 'screenshot') { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Center( + child: Text(body, + style: + const TextStyle(fontSize: 12, color: Color(0xFFB2B2B2)))), + ); + } + if (r['recalledAt'] != null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 20), + child: Center( + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text(mine ? '你撤回了一条消息' : '对方撤回了一条消息', + style: + const TextStyle(fontSize: 12, color: Color(0xFFB2B2B2))), + if (mine) + TextButton( + onPressed: () => _editMessage(r), + style: TextButton.styleFrom( + minimumSize: Size.zero, + padding: const EdgeInsets.only(left: 5), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('重新编辑', style: TextStyle(fontSize: 12)), + ), + ], + ), + ), + ); + } + final meta = _metaOf(r); + final fileId = '${meta['fileId'] ?? ''}'; + final avatarId = mine + ? myAvatar + : '${r['fromAvatarFileId'] ?? widget.peerAvatarFileId ?? ''}'; + final sticker = ctype == 'sticker' && fileId.isNotEmpty; + final image = fileId.isNotEmpty && + (ctype == 'image' || + (ctype == 'file' && + _looksLikeImage('${meta['fileName'] ?? body} ${body}', + '${meta['mime'] ?? ''}'))); + final voice = ctype == 'voice'; + final loc = ctype == 'location'; + final fileMsg = ctype == 'file' && !image; + final business = ctype == 'business'; + final sec = (meta['seconds'] as num?)?.toInt() ?? 1; + final locLat = (meta['lat'] as num?)?.toDouble(); + final locLng = (meta['lng'] as num?)?.toDouble(); + return Column( + children: [ + if (showTime) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text(shortTime(r['createdAt']), + style: const TextStyle(fontSize: 12, color: Color(0xFFB2B2B2))), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: + mine ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + if (!mine) ...[ + SquareAvatar( + label: system ? '?' : '${r['fromName'] ?? widget.peerName}', + size: 36, + color: system ? const Color(0xFF07C160) : null, + icon: system ? Icons.notifications : null, + fileId: avatarId.isEmpty ? null : avatarId, + api: widget.api, + ), + const SizedBox(width: 8), + ], + Flexible( + child: voice + ? VoiceMessageBlock( + mine: mine, + seconds: sec, + playing: _playingId == fileId, + transcript: ChatPrefs.voiceText(_voiceKey(r, fileId)), + loading: _voiceTextLoading.contains(_voiceKey(r, fileId)), + onTap: () => _playVoice(r), + onLongPress: () => _onBubbleLongPress(r), + readLabel: mine + ? (((r['readCount'] as num?)?.toInt() ?? 0) > 0 + ? '已读' + : '未读') + : null, + ) + : GestureDetector( + onTap: business + ? () => _openBusinessCard(meta) + : ((image || sticker) && fileId.isNotEmpty) + ? () => _previewImage(fileId) + : (loc ? () => _openLocation(meta, body) : null), + onLongPress: () => _onBubbleLongPress(r), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: sticker + ? EdgeInsets.zero + : (image || loc) + ? const EdgeInsets.all(4) + : const EdgeInsets.symmetric( + horizontal: 12, vertical: 9), + constraints: const BoxConstraints(maxWidth: 280), + decoration: BoxDecoration( + color: sticker + ? Colors.transparent + : (loc + ? Colors.white + : (mine ? kBubbleMine : Colors.white)), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: mine + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (business) + SizedBox( + width: 232, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: kBind.withOpacity(.12), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.description_outlined, + color: kBind, size: 22), + ), + const SizedBox(width: 10), + Expanded( + child: Text('${meta['title'] ?? body}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: kInk, + fontSize: 15, + fontWeight: FontWeight.w600)), + ), + ]), + if ('${meta['summary'] ?? ''}'.isNotEmpty) ...[ + const SizedBox(height: 8), + Text('${meta['summary']}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: kMute, + fontSize: 12, + height: 1.35)), + ], + const Padding( + padding: EdgeInsets.only(top: 8), + child: Divider(height: 1, color: kLine), + ), + const Padding( + padding: EdgeInsets.only(top: 7), + child: Row(children: [ + Text('工作台信息', + style: TextStyle( + color: kMute, fontSize: 11)), + Spacer(), + Text('查看详情', + style: TextStyle( + color: kBind, fontSize: 11)), + Icon(Icons.chevron_right, + color: kBind, size: 15), + ]), + ), + ], + ), + ) + else if (sticker) + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 140, maxHeight: 140), + child: AuthImage( + api: widget.api, + fileId: fileId, + fit: BoxFit.contain), + ) + else if (image) + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: AuthImage( + api: widget.api, + fileId: fileId, + width: 180, + height: 180), + ) + else if (loc) + SizedBox( + width: 220, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), + child: Text( + meta['title']?.toString().isNotEmpty == true + ? '${meta['title']}' + : body, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: kInk)), + ), + Padding( + padding: const EdgeInsets.fromLTRB(8, 2, 8, 6), + child: Text('${meta['address'] ?? body}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, color: kMute)), + ), + ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(6), + bottomRight: Radius.circular(6)), + child: locLat != null && locLng != null + ? TencentMapThumb( + api: widget.api, + lat: locLat, + lng: locLng, + width: 220, + height: 110) + : const SizedBox( + height: 80, + child: Center( + child: Icon(Icons.place, + color: kWeGreen))), + ), + ], + ), + ) + else if (fileMsg) + InkWell( + onTap: fileId.isEmpty + ? null + : () => _openChatFile( + fileId, + '${meta['fileName'] ?? body}', + '${meta['mime'] ?? ''}'), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.insert_drive_file_outlined, + color: kBind, size: 28), + const SizedBox(width: 8), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${meta['fileName'] ?? body}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, color: kInk)), + if (meta['size'] != null) + Text(_sizeText(meta['size']), + style: const TextStyle( + fontSize: 11, color: kMute)), + ], + ), + ), + ], + ), + ) + else if (ctype == 'call') + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + '${meta['callKind']}' == 'audio' + ? Icons.call + : Icons.videocam, + color: kBind, + size: 22), + const SizedBox(width: 8), + Flexible( + child: Text(body, + style: const TextStyle( + fontSize: 15, color: kInk))), + ], + ) + else if (r['recalledAt'] != null) + Text(body, + style: const TextStyle( + fontSize: 14, + color: kMute, + fontStyle: FontStyle.italic)) + else + Text(body, + style: const TextStyle( + fontSize: 16, color: kInk, height: 1.35)), + if (mine) + Text( + ((r['readCount'] as num?)?.toInt() ?? 0) > 0 + ? '已读' + : '未读', + style: const TextStyle(fontSize: 10, color: kMute), + ), + ], + ), + ), + ), + ), + if (mine) ...[ + const SizedBox(width: 8), + SquareAvatar( + label: widget.session.displayName, + size: 36, + fileId: myAvatar.isEmpty ? null : myAvatar, + api: widget.api, + ), + ], + ], + ), + ], + ); + } + + Widget _composer() { + return ColoredBox( + color: const Color(0xFFF7F7F7), + child: SafeArea( + top: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(6, 6, 6, 6), + child: Row( + children: [ + IconButton( + onPressed: () => setState(() { + _voice = !_voice; + _plus = false; + _emoji = false; + if (!_voice) { + _focus.requestFocus(); + } else { + _focus.unfocus(); + } + }), + icon: Icon(_voice + ? Icons.keyboard_alt_outlined + : Icons.keyboard_voice_outlined), + ), + Expanded( + child: _voice + ? Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (e) { + _pressed = true; + _downGlobal = e.position; + _beginVoice(); + }, + onPointerMove: (e) { + if (!_recording || _downGlobal == null) return; + final cancel = + (_downGlobal!.dy - e.position.dy) > 70; + if (cancel != _cancelVoice) + setState(() => _cancelVoice = cancel); + }, + onPointerUp: (_) => _endVoice(), + onPointerCancel: (_) { + _cancelVoice = true; + _endVoice(); + }, + child: Container( + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _recording + ? const Color(0xFFE5E5E5) + : Colors.white, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + _recording + ? (_cancelVoice ? '松开取消' : '松开发送') + : '按住 说话', + style: const TextStyle( + color: kMute, fontSize: 16)), + ), + ) + : TextField( + controller: _input, + focusNode: _focus, + minLines: 1, + maxLines: 4, + decoration: const InputDecoration(hintText: ''), + onTap: () => setState(() { + _plus = false; + _emoji = false; + }), + onChanged: (_) => setState(() {}), + onSubmitted: (_) => _send(), + ), + ), + IconButton( + onPressed: () { + _focus.unfocus(); + setState(() { + _emoji = !_emoji; + _plus = false; + }); + }, + icon: Icon(_emoji + ? Icons.keyboard_alt_outlined + : Icons.emoji_emotions_outlined), + ), + _input.text.trim().isNotEmpty + ? Padding( + padding: const EdgeInsets.only(right: 6), + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: kWeGreen, + minimumSize: const Size(56, 32)), + onPressed: _sending ? null : _send, + child: const Text('发送'), + ), + ) + : IconButton( + onPressed: () { + _focus.unfocus(); + setState(() { + _plus = !_plus; + _emoji = false; + }); + }, + icon: const Icon(Icons.add_circle_outline), + ), + ], + ), + ), + if (_emoji) _emojiPanel(), + if (_plus) _plusPanel(), + ], + ), + ), + ); + } + + Widget _emojiPanel() { + return SizedBox( + height: 280, + width: double.infinity, + child: Column( + children: [ + Expanded( + child: _emojiTab == 0 + ? GridView.count( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + crossAxisCount: 8, + children: [ + for (final e in _emojis) + InkWell( + onTap: () { + _input.text += e; + _input.selection = TextSelection.collapsed( + offset: _input.text.length); + setState(() {}); + }, + child: Center( + child: Text(e, + style: const TextStyle(fontSize: 24))), + ), + ], + ) + : GridView.count( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + crossAxisCount: 4, + children: [ + InkWell( + onTap: _addCustomSticker, + child: Container( + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFDDDDDD)), + ), + child: const Icon(Icons.add, color: kMute, size: 32), + ), + ), + for (final id in StickerStore.ids) + GestureDetector( + onTap: () => _sendSticker(id), + onLongPress: () async { + final a = + await showWxSheet(context, [('删除表情', 'del')]); + if (a == 'del') { + await StickerStore.remove(id); + if (mounted) setState(() {}); + } + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: AuthImage( + api: widget.api, + fileId: id, + width: 72, + height: 72, + fit: BoxFit.contain), + ), + ), + ], + ), + ), + ColoredBox( + color: const Color(0xFFEFEFEF), + child: Row( + children: [ + _emojiTabBtn(0, '表情'), + _emojiTabBtn(1, '自定义'), + ], + ), + ), + ], + ), + ); + } + + Widget _emojiTabBtn(int i, String label) { + final on = _emojiTab == i; + return InkWell( + onTap: () => setState(() => _emojiTab = i), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: on ? const Color(0xFFF7F7F7) : Colors.transparent, + border: on + ? const Border(top: BorderSide(color: Color(0xFFDDDDDD))) + : null, + ), + child: Text(label, + style: TextStyle( + fontSize: 13, + color: on ? kInk : kMute, + fontWeight: on ? FontWeight.w600 : FontWeight.w400)), + ), + ); + } + + Widget _plusPanel() { + final items = <(IconData, String, Color, VoidCallback)>[ + (Icons.photo_outlined, '相册', const Color(0xFF07C160), _pickAlbum), + (Icons.photo_camera_outlined, '拍摄', const Color(0xFF10AEFF), _pickCamera), + ( + Icons.insert_drive_file_outlined, + '文件', + const Color(0xFF576B95), + _sendFile + ), + (Icons.place_outlined, '位置', const Color(0xFFFA9D3B), _location), + (Icons.call_outlined, '语音通话', kBind, () => _call('audio')), + ( + Icons.videocam_outlined, + '视频通话', + const Color(0xFF6267F2), + () => _call(widget.isGroup ? 'meeting' : 'video') + ), + ]; + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + color: const Color(0xFFF7F7F7), + child: Wrap( + spacing: 22, + runSpacing: 16, + children: [ + for (final it in items) + InkWell( + onTap: it.$4, + child: SizedBox( + width: 64, + child: Column( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12)), + child: Icon(it.$1, color: it.$3), + ), + const SizedBox(height: 6), + Text(it.$2, + style: const TextStyle(fontSize: 12, color: kMute)), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _ImagePreviewPage extends StatefulWidget { + const _ImagePreviewPage( + {required this.api, required this.fileIds, required this.initialIndex}); + final OaClient api; + final List fileIds; + final int initialIndex; + + @override + State<_ImagePreviewPage> createState() => _ImagePreviewPageState(); +} + +class _ImagePreviewPageState extends State<_ImagePreviewPage> { + late final PageController _pages; + late int _index; + + @override + void initState() { + super.initState(); + _index = widget.initialIndex.clamp(0, widget.fileIds.length - 1).toInt(); + _pages = PageController(initialPage: _index); + } + + @override + void dispose() { + _pages.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + title: Text(widget.fileIds.length > 1 + ? '查看图片 ${_index + 1}/${widget.fileIds.length}' + : '查看图片'), + actions: [ + IconButton( + icon: const Icon(Icons.save_alt), + onPressed: () async { + try { + final bytes = + await widget.api.fileBytes(widget.fileIds[_index]); + if (bytes == null) throw Exception('图片无法下载'); + final dir = await getTemporaryDirectory(); + final f = File('${dir.path}/img_${widget.fileIds[_index]}.jpg'); + await f.writeAsBytes(bytes, flush: true); + await DeviceBridge.saveImage(f.path); + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('已保存到相册'))); + } + } catch (e) { + if (context.mounted) + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + }, + ), + ], + ), + body: PageView.builder( + controller: _pages, + itemCount: widget.fileIds.length, + onPageChanged: (value) => setState(() => _index = value), + itemBuilder: (_, index) => LayoutBuilder( + builder: (_, constraints) => InteractiveViewer( + minScale: 0.8, + maxScale: 5, + child: SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: AuthImage( + api: widget.api, + fileId: widget.fileIds[index], + fit: BoxFit.contain, + ), + ), + ), + ), + ), + ); + } +} + +class _LocationViewPage extends StatelessWidget { + const _LocationViewPage({ + required this.api, + required this.title, + required this.address, + this.lat, + this.lng, + }); + final OaClient api; + final String title; + final String address; + final double? lat; + final double? lng; + + Future _openMaps(BuildContext context) async { + if (lat == null || lng == null) return; + final la = lat!; + final ln = lng!; + final q = Uri.encodeComponent(title.isNotEmpty ? title : '$la,$ln'); + final urls = [ + 'amapuri://route/plan/?dlat=$la&dlon=$ln&dname=$q&dev=0', + 'qqmap://map/geocoder?coord=$la,$ln', + 'geo:$la,$ln?q=$la,$ln($q)', + 'https://uri.amap.com/marker?position=$ln,$la&name=$q', + ]; + for (final u in urls) { + try { + await DeviceBridge.openUrl(u); + return; + } catch (_) {} + } + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('无法打开地图应用'))); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar(title: const Text('位置')), + body: ListView( + children: [ + if (lat != null && lng != null) + TencentMapThumb( + api: api, + lat: lat!, + lng: lng!, + width: MediaQuery.sizeOf(context).width, + height: 220), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text(title.isEmpty ? '位置' : title, + style: + const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + ), + if (address.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Text(address, + style: const TextStyle(fontSize: 14, color: kMute)), + ), + Padding( + padding: const EdgeInsets.all(16), + child: FilledButton.icon( + onPressed: lat == null ? null : () => _openMaps(context), + icon: const Icon(Icons.map_outlined), + label: const Text('用地图打开'), + ), + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/pages/desktop_login_page.dart b/apps/native/lib/pages/desktop_login_page.dart new file mode 100644 index 0000000..0061415 --- /dev/null +++ b/apps/native/lib/pages/desktop_login_page.dart @@ -0,0 +1,218 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import '../api/oa_client.dart'; +import '../session/session.dart'; +import '../desktop/desk_theme.dart'; +import '../desktop/widgets/desk_widgets.dart'; +import '../widgets/beian_footer.dart'; + +class DesktopLoginPage extends StatefulWidget { + const DesktopLoginPage({super.key, required this.session, required this.api}); + final SessionStore session; + final OaClient api; + + @override + State createState() => _DesktopLoginPageState(); +} + +class _DesktopLoginPageState extends State { + final _codeCtrl = TextEditingController(); + String _ticket = ''; + String _payload = ''; + String _hint = '打开手机端「消息」右上角 + → 扫一扫,扫描此二维码'; + String _error = ''; + bool _submitting = false; + bool _starting = true; + Timer? _poll; + + @override + void initState() { + super.initState(); + unawaited(_start()); + } + + @override + void dispose() { + _poll?.cancel(); + _codeCtrl.dispose(); + super.dispose(); + } + + Future _start() async { + setState(() { + _starting = true; + _error = ''; + _codeCtrl.clear(); + _hint = '打开手机端「消息」右上角 + → 扫一扫,扫描此二维码'; + }); + _poll?.cancel(); + try { + final raw = await widget.api.post('/auth/qr/start', {}); + final data = raw is Map ? Map.from(raw) : {}; + final ticket = '${data['ticket'] ?? ''}'.trim(); + if (ticket.isEmpty) throw Exception('无法生成登录码'); + if (!mounted) return; + setState(() { + _ticket = ticket; + _payload = '${data['payload'] ?? 'fengyingoa://qr?ticket=$ticket'}'; + _starting = false; + }); + _poll = Timer.periodic(const Duration(milliseconds: 1600), (_) => _pollStatus()); + } catch (e) { + if (!mounted) return; + setState(() { + _starting = false; + _error = '$e'; + }); + } + } + + Future _pollStatus() async { + if (_ticket.isEmpty || _submitting) return; + try { + final raw = await widget.api.get('/auth/qr/status', query: {'ticket': _ticket}); + final data = raw is Map ? Map.from(raw) : {}; + final status = '${data['status'] ?? ''}'; + if (!mounted) return; + if (status == 'code_required') { + setState(() => _hint = '手机已确认,请输入手机屏幕显示的六位登录码'); + return; + } + if (status == 'expired' || status == 'consumed') { + _poll?.cancel(); + setState(() => _hint = '登录码已过期,正在刷新…'); + await _start(); + } + } catch (e) { + if (!mounted) return; + setState(() => _error = '$e'); + } + } + + Future _complete() async { + final code = _codeCtrl.text.trim(); + if (!RegExp(r'^\d{6}$').hasMatch(code)) { + setState(() => _error = '请输入手机端显示的六位登录码'); + return; + } + setState(() { + _submitting = true; + _error = ''; + }); + try { + final raw = await widget.api.post('/auth/qr/complete', {'ticket': _ticket, 'code': code}); + final data = raw is Map ? Map.from(raw) : {}; + if ('${data['accessToken'] ?? ''}'.isEmpty) throw Exception('电脑登录未完成'); + await widget.session.applyLogin(data); + try { + final menus = await widget.api.get('/system/menus'); + await widget.session.setMenus(asMaps(menus).map(MenuNode.fromJson).toList()); + } catch (_) {} + } catch (e) { + if (mounted) setState(() => _error = '$e'); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: kDeskBg, + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Material( + color: kDeskPane, + borderRadius: BorderRadius.circular(12), + elevation: 0, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: kDeskLine), + ), + padding: const EdgeInsets.fromLTRB(36, 40, 36, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DeskAvatar(label: '风', api: widget.api, size: 64, color: kDeskBind, icon: Icons.apartment), + const SizedBox(height: 14), + const Text('风影办公', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kDeskInk)), + const SizedBox(height: 6), + const Text( + '请使用手机 App 扫码,并输入六位登录码', + textAlign: TextAlign.center, + style: TextStyle(color: kDeskMute, fontSize: 13, height: 1.5), + ), + const SizedBox(height: 20), + if (_error.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text(_error, textAlign: TextAlign.center, style: const TextStyle(color: Color(0xFFFA5151))), + ), + if (_starting) + const Padding( + padding: EdgeInsets.symmetric(vertical: 48), + child: CircularProgressIndicator(color: kDeskBind), + ) + else if (_payload.isNotEmpty) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: kDeskLine), + ), + child: QrImageView(data: _payload, size: 200, backgroundColor: Colors.white), + ), + const SizedBox(height: 10), + Text(_hint, textAlign: TextAlign.center, style: const TextStyle(color: kDeskMute, fontSize: 12)), + const SizedBox(height: 18), + TextField( + controller: _codeCtrl, + onChanged: (v) { + final next = v.replaceAll(RegExp(r'\D'), ''); + final clipped = next.length > 6 ? next.substring(0, 6) : next; + if (clipped != v) { + _codeCtrl.value = TextEditingValue( + text: clipped, + selection: TextSelection.collapsed(offset: clipped.length), + ); + } + }, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + maxLength: 6, + inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)], + decoration: const InputDecoration(hintText: '六位登录码', counterText: ''), + style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: 8), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: FilledButton( + style: FilledButton.styleFrom(backgroundColor: kDeskBind, minimumSize: const Size.fromHeight(40)), + onPressed: _submitting || _ticket.isEmpty ? null : _complete, + child: Text(_submitting ? '登录中…' : '确认登录'), + ), + ), + const SizedBox(width: 8), + OutlinedButton(onPressed: _starting ? null : _start, child: const Text('刷新')), + ], + ), + ], + ], + ), + ), + ), + ), + ), + bottomNavigationBar: const BeianFooter(), + ); + } +} diff --git a/apps/native/lib/pages/desktop_workbench_page.dart b/apps/native/lib/pages/desktop_workbench_page.dart new file mode 100644 index 0000000..942b5fd --- /dev/null +++ b/apps/native/lib/pages/desktop_workbench_page.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../nav/open_module.dart'; +import '../pages/approvals_page.dart'; +import '../pages/attendance_page.dart'; +import '../pages/calendar_page.dart'; +import '../pages/flow_page.dart'; +import '../pages/hr_apply_page.dart'; +import '../pages/module_list_page.dart'; +import '../pages/todos_page.dart'; +import '../pages/work_assign_page.dart'; +import '../session/session.dart'; +import '../theme.dart'; +import '../widgets/desktop_ui.dart'; +import '../widgets/wecom.dart'; + +typedef DesktopOpenTab = void Function(String key, String title, IconData icon, Color color, Widget page); + +class DesktopWorkbenchPage extends StatefulWidget { + const DesktopWorkbenchPage({ + super.key, + required this.session, + required this.api, + required this.onOpenTab, + }); + + final SessionStore session; + final OaClient api; + final DesktopOpenTab onOpenTab; + + @override + State createState() => _DesktopWorkbenchPageState(); +} + +class _DesktopWorkbenchPageState extends State { + Map _ov = {}; + String _greet = ''; + bool _loading = true; + String _q = ''; + String _cat = 'all'; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final ov = await widget.api.get('/office/overview'); + String greet = ''; + try { + final w = await widget.api.get('/office/weather'); + if (w is Map) greet = '${w['greeting'] ?? w['text'] ?? ''}'; + } catch (_) {} + if (!mounted) return; + setState(() { + _ov = Map.from(ov as Map? ?? {}); + _greet = greet; + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + bool _match(String name) => _q.isEmpty || name.contains(_q); + + void _open(String key, String title, IconData icon, Color color, Widget page) { + widget.onOpenTab(key, title, icon, color, page); + } + + List<_DeskApp> _personalApps() { + return [ + if (_match('待办')) + _DeskApp('todos', '待办', '处理待办事项与提醒', Icons.task_alt, const Color(0xFFFA9D3B), TodosPage(api: widget.api)), + if (_match('审批')) + _DeskApp('approvals', '待审批', '审批各类办公申请', Icons.fact_check, kBind, ApprovalsPage(api: widget.api)), + if (_match('申请')) + _DeskApp('flow', '我的申请', '查看我发起的申请', Icons.assignment_outlined, const Color(0xFF6267F2), FlowPage(api: widget.api)), + if (_match('日程')) + _DeskApp('calendar', '日程', '会议与日程安排', Icons.calendar_month, const Color(0xFF10AEFF), CalendarPage(api: widget.api)), + if (_match('安排')) + _DeskApp('work', '工作安排', '任务分配与跟进', Icons.event_note, const Color(0xFF00B578), WorkAssignPage(session: widget.session, api: widget.api)), + if (_match('打卡') || _match('考勤')) + _DeskApp('attendance', '考勤打卡', '上下班打卡记录', Icons.access_time_filled, const Color(0xFF267EF0), AttendancePage(api: widget.api)), + if (_match('人事')) + _DeskApp('hr', '人事申请', '请假、加班、外出等', Icons.beach_access, const Color(0xFF8B5CF6), HrApplyPage(api: widget.api)), + ]; + } + + List<_DeskApp> _menuApps() { + final out = <_DeskApp>[]; + for (final m in widget.session.menus) { + if (m.name == '工作台' || m.name == '个人办公' || m.code == 'office:overview') continue; + if (m.name == '系统设置' || m.code.startsWith('system')) continue; + if (m.children.isNotEmpty) { + for (var i = 0; i < m.children.length; i++) { + final c = m.children[i]; + if (_skipChild(c)) continue; + if (!(_q.isEmpty || c.name.contains(_q) || m.name.contains(_q))) continue; + out.add(_DeskApp( + c.code, + c.name, + m.name, + iconFor(c.code, c.name), + colorFor(c.code, c.name, i), + _pageFor(c), + )); + } + } else if (!_skipChild(m) && _match(m.name)) { + out.add(_DeskApp(m.code, m.name, '业务应用', iconFor(m.code, m.name), colorFor(m.code, m.name), _pageFor(m))); + } + } + return out; + } + + bool _skipChild(MenuNode n) { + const names = {'待办', '待审批', '我的申请', '日程', '公告', '公司公告', '工作安排', '人事申请', '工作台', '个人办公', '消息', '通讯录', '员工通讯', '发送短信', '系统设置'}; + if (n.code.startsWith('office:') || n.code.startsWith('system')) return true; + if (n.code.contains('sms') || n.name.contains('短信')) return true; + if (n.name.contains('公告') || n.name == '员工通讯') return true; + return names.contains(n.name); + } + + Widget _pageFor(MenuNode n) { + final shell = shellFor(n.code, n.path); + if (shell != null) { + return ModuleListPage(api: widget.api, title: n.name, shell: shell); + } + return Center(child: Text('${n.name} 暂未配置桌面入口', style: const TextStyle(color: kMute))); + } + + List<_DeskApp> get _shownApps { + final personal = _personalApps(); + final menu = _menuApps(); + if (_cat == 'personal') return personal; + if (_cat == 'biz') return menu; + return [...personal, ...menu]; + } + + @override + Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + final cols = width >= 1200 ? 3 : 2; + return ColoredBox( + color: kDeskBg, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DesktopPaneHeader( + title: '工作台', + actions: [ + SizedBox(width: 220, child: DesktopSearchBar(hint: '搜索应用', onChanged: (v) => setState(() => _q = v.trim()))), + const SizedBox(width: 8), + OutlinedButton(onPressed: _load, child: const Text('刷新')), + ], + bottom: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), + child: Row( + children: [ + _catChip('all', '全部应用'), + const SizedBox(width: 8), + _catChip('personal', '个人办公'), + const SizedBox(width: 8), + _catChip('biz', '业务模块'), + ], + ), + ), + ), + if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind), + Expanded( + child: RefreshIndicator( + onRefresh: _load, + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + children: [ + Container( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 18), + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [Color(0xFFEEF4FF), Color(0xFFF8FBFF)]), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFDCE8FF)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _greet.isEmpty ? '你好,${widget.session.displayName}' : _greet, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: kInk), + ), + const SizedBox(height: 6), + const Text('工作台 · 个人办公与业务入口', style: TextStyle(color: kMute, fontSize: 13)), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + _statCard('待办', '${_ov['pendingTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), TodosPage(api: widget.api))), + const SizedBox(width: 12), + _statCard('待审批', '${_ov['pendingApprovals'] ?? 0}', () => _open('approvals', '待审批', Icons.fact_check, kBind, ApprovalsPage(api: widget.api))), + const SizedBox(width: 12), + _statCard('我的申请', '${_ov['myPending'] ?? 0}', () => _open('flow', '我的申请', Icons.assignment_outlined, const Color(0xFF6267F2), FlowPage(api: widget.api))), + const SizedBox(width: 12), + _statCard('逾期', '${_ov['overdueTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), TodosPage(api: widget.api))), + ], + ), + const SizedBox(height: 20), + if (_shownApps.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 48), + child: Center(child: Text('没有匹配的应用', style: TextStyle(color: kMute))), + ) + else + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 2.8, + ), + itemCount: _shownApps.length, + itemBuilder: (_, i) { + final a = _shownApps[i]; + return DesktopAppCard( + label: a.title, + desc: a.desc, + icon: a.icon, + color: a.color, + onTap: () => _open(a.key, a.title, a.icon, a.color, a.page), + ); + }, + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _catChip(String id, String label) { + final on = _cat == id; + return GestureDetector( + onTap: () => setState(() => _cat = id), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: on ? kDeskActive : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: on ? const Color(0xFFBFD7FF) : kLine), + ), + child: Text(label, style: TextStyle(fontSize: 13, color: on ? kBind : kMute, fontWeight: on ? FontWeight.w600 : FontWeight.w400)), + ), + ); + } + + Widget _statCard(String label, String value, VoidCallback onTap) { + return Expanded( + child: Material( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + children: [ + Text(value, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: kBind)), + const SizedBox(height: 4), + Text(label, style: const TextStyle(fontSize: 12, color: kMute)), + ], + ), + ), + ), + ), + ); + } +} + +class _DeskApp { + _DeskApp(this.key, this.title, this.desc, this.icon, this.color, this.page); + final String key; + final String title; + final String desc; + final IconData icon; + final Color color; + final Widget page; +} diff --git a/apps/native/lib/pages/directory_page.dart b/apps/native/lib/pages/directory_page.dart new file mode 100644 index 0000000..a3fc1ed --- /dev/null +++ b/apps/native/lib/pages/directory_page.dart @@ -0,0 +1,405 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../im/pinyin.dart'; +import '../session/session.dart'; +import '../theme.dart'; +import '../widgets/common.dart'; +import '../widgets/desktop_ui.dart'; +import '../widgets/wecom.dart'; +import 'chat_page.dart'; +import 'org_browse_page.dart'; + +class DirectoryPage extends StatefulWidget { + const DirectoryPage({super.key, required this.session, required this.api, this.embedded = false, this.desktopStyle = false}); + final SessionStore session; + final OaClient api; + final bool embedded; + final bool desktopStyle; + + @override + State createState() => _DirectoryPageState(); +} + +class _DirectoryPageState extends State { + List> _items = []; + Map> _presence = {}; + String _q = ''; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final data = await widget.api.get('/staff'); + Map> presence = {}; + try { + final ids = asMaps(data).map((e) => '${e['id']}').where((e) => e.isNotEmpty).join(','); + final raw = await widget.api.get('/im/presence', query: {'userIds': ids}); + if (raw is Map) { + presence = raw.map((k, v) => MapEntry('$k', (v is List ? v : const []).map((x) => '$x').toList())); + } + } catch (_) {} + if (!mounted) return; + setState(() { + _items = asMaps(data); + _presence = presence; + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + void _openPerson(Map r) { + showModalBottomSheet( + context: context, + backgroundColor: kPaper, + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SquareAvatar( + label: '${r['name'] ?? ''}', + size: 64, + fileId: '${r['avatarFileId'] ?? ''}', + api: widget.api, + ), + const SizedBox(height: 12), + Text('${r['name'] ?? ''}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text( + [r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '), + style: const TextStyle(color: kMute), + ), + const SizedBox(height: 16), + CellGroup( + children: [ + Cell(title: '手机', subtitle: '${r['mobile'] ?? '未填'}', showLine: true), + Cell(title: '邮箱', subtitle: '${r['email'] ?? '未填'}', showLine: false), + ], + ), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () async { + Navigator.pop(ctx); + String conversationId = ''; + // 先让服务端创建/登记单聊会话,避免双方尚未发送消息时列表没有会话记录。 + try { + final raw = await widget.api.post('/im/conversations/direct', {'peerId': '${r['id']}'}); + if (raw is Map) conversationId = '${raw['conversationId'] ?? raw['id'] ?? ''}'; + } catch (_) {} + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => ChatPage( + session: widget.session, + api: widget.api, + peerId: '${r['id']}', + conversationId: conversationId, + peerName: '${r['name'] ?? '同事'}', + peerAvatarFileId: '${r['avatarFileId'] ?? ''}', + ), + )); + widget.session.im.bump(); + }, + child: const Padding(padding: EdgeInsets.symmetric(vertical: 10), child: Text('发消息')), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _desktopBody( + List> shown, + Map>> groups, + List letters, + Set depts, + ) { + if (_loading) return const LinearProgressIndicator(minHeight: 2, color: kBind); + if (shown.isEmpty) return const Center(child: EmptyHint('没有匹配的同事')); + return RefreshIndicator( + onRefresh: _load, + child: Stack( + children: [ + ListView( + padding: const EdgeInsets.fromLTRB(20, 12, 28, 24), + children: [ + if (_q.isEmpty) + Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)), + child: Column( + children: [ + Cell( + title: '组织架构', + subtitle: '${depts.length} 个部门', + leading: const SquareAvatar(label: '组', color: kBind, icon: Icons.account_tree, size: 36), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => OrgBrowsePage(session: widget.session, api: widget.api, staff: _items), + )), + ), + Cell( + title: '企业通讯录', + subtitle: '${_items.length} 人', + leading: const SquareAvatar(label: '通', color: Color(0xFF07C160), icon: Icons.contacts, size: 36), + showLine: false, + ), + ], + ), + ), + for (final letter in letters) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(4, 8, 4, 6), + child: Text(letter, style: const TextStyle(fontSize: 13, color: kMute, fontWeight: FontWeight.w600)), + ), + Container( + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)), + child: Column( + children: [ + for (final r in groups[letter]!) + InkWell( + onTap: () => _openPerson(r), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 0), + child: Row( + children: [ + SquareAvatar(label: '${r['name'] ?? ''}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api), + const SizedBox(width: 12), + Expanded( + child: Container( + padding: const EdgeInsets.only(bottom: 12), + decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('${r['name'] ?? ''}', style: const TextStyle(fontSize: 16, color: kInk)), + const SizedBox(width: 6), + Container(width: 6, height: 6, decoration: BoxDecoration(shape: BoxShape.circle, color: (_presence['${r['id']}'] ?? const []).isNotEmpty ? const Color(0xFF22C55E) : const Color(0xFFB8BEC8))), + const SizedBox(width: 3), + Text((_presence['${r['id']}'] ?? const []).isNotEmpty ? '在线' : '离线', style: const TextStyle(fontSize: 11, color: kMute)), + ], + ), + Text( + [r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '), + style: const TextStyle(fontSize: 12, color: kMute), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ], + ), + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [for (final l in letters) Text(l, style: const TextStyle(fontSize: 10, color: kBind, height: 1.35))], + ), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final me = widget.session.userId; + final shown = _items.where((e) { + if ('${e['id']}' == me) return false; + if (_q.isEmpty) return true; + return '${e['name']}${e['department']}${e['mobile']}${e['email']}${e['title']}'.contains(_q); + }).toList() + ..sort((a, b) { + final la = letterOf('${a['name']}'); + final lb = letterOf('${b['name']}'); + final c = la.compareTo(lb); + if (c != 0) return c; + return '${a['name']}'.compareTo('${b['name']}'); + }); + + final groups = >>{}; + for (final r in shown) { + groups.putIfAbsent(letterOf('${r['name']}'), () => []).add(r); + } + final letters = groups.keys.toList() + ..sort((a, b) { + if (a == '#') return 1; + if (b == '#') return -1; + return a.compareTo(b); + }); + final depts = {}; + for (final r in _items) { + final d = '${r['department'] ?? ''}'; + if (d.isNotEmpty) depts.add(d); + } + + final body = widget.desktopStyle + ? _desktopBody(shown, groups, letters, depts) + : Column( + children: [ + WxSearchBar(hint: '搜索同事、部门、手机', onChanged: (v) => setState(() => _q = v.trim())), + if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind), + Expanded( + child: shown.isEmpty + ? const EmptyHint('没有匹配的同事') + : RefreshIndicator( + onRefresh: _load, + child: Stack( + children: [ + ListView( + children: [ + if (_q.isEmpty) ...[ + CellGroup( + children: [ + Cell( + title: '组织架构', + subtitle: '${depts.length} 个部门', + leading: const SquareAvatar(label: '组', color: kBind, icon: Icons.account_tree, size: 36), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => OrgBrowsePage( + session: widget.session, + api: widget.api, + staff: _items, + ), + )), + ), + Cell( + title: '企业通讯录', + subtitle: '${_items.length} 人', + leading: const SquareAvatar(label: '通', color: Color(0xFF07C160), icon: Icons.contacts, size: 36), + showLine: false, + ), + ], + ), + ], + for (final letter in letters) ...[ + Container( + width: double.infinity, + color: kPaper, + padding: const EdgeInsets.fromLTRB(16, 6, 16, 6), + child: Text(letter, style: const TextStyle(fontSize: 13, color: kMute, fontWeight: FontWeight.w600)), + ), + for (final r in groups[letter]!) + InkWell( + onTap: () => _openPerson(r), + child: Container( + color: Colors.white, + padding: const EdgeInsets.fromLTRB(12, 10, 28, 0), + child: Row( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + SquareAvatar(label: '${r['name'] ?? ''}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api), + if ((_presence['${r['id']}'] ?? const []).isNotEmpty) + Positioned( + right: -3, + bottom: -2, + child: DecoratedBox( + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)), + child: Icon( + (_presence['${r['id']}'] ?? const []).contains('mobile') ? Icons.phone_android : Icons.computer, + size: 14, + color: const Color(0xFF10AEFF), + ), + ), + ), + ], + ), + const SizedBox(width: 10), + Expanded( + child: Container( + padding: const EdgeInsets.only(bottom: 10), + decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('${r['name'] ?? ''}', style: const TextStyle(fontSize: 16, color: kInk)), + const SizedBox(width: 6), + Container(width: 6, height: 6, decoration: BoxDecoration(shape: BoxShape.circle, color: (_presence['${r['id']}'] ?? const []).isNotEmpty ? const Color(0xFF22C55E) : const Color(0xFFB8BEC8))), + const SizedBox(width: 3), + Text((_presence['${r['id']}'] ?? const []).isNotEmpty ? '在线' : '离线', style: const TextStyle(fontSize: 11, color: kMute)), + ], + ), + Text( + [r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '), + style: const TextStyle(fontSize: 12, color: kMute), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + const SizedBox(height: 32), + ], + ), + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final l in letters) + Text(l, style: const TextStyle(fontSize: 10, color: kBind, height: 1.35)), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ); + + if (widget.embedded) { + return ColoredBox( + color: widget.desktopStyle ? kDeskBg : kPaper, + child: Column( + children: [ + widget.desktopStyle + ? DesktopPaneHeader( + title: '通讯录', + bottom: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: DesktopSearchBar(hint: '搜索同事、部门、手机', onChanged: (v) => setState(() => _q = v.trim())), + ), + ) + : const WxHeader(title: '通讯录'), + Expanded(child: widget.desktopStyle ? _desktopBody(shown, groups, letters, depts) : body), + ], + ), + ); + } + return Scaffold(appBar: AppBar(title: const Text('通讯录')), body: body); + } +} diff --git a/apps/native/lib/pages/flow_page.dart b/apps/native/lib/pages/flow_page.dart new file mode 100644 index 0000000..cfad5df --- /dev/null +++ b/apps/native/lib/pages/flow_page.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../labels.dart'; +import '../pages/record_detail_page.dart'; +import '../widgets/common.dart'; + +class FlowPage extends StatefulWidget { + const FlowPage({super.key, required this.api}); + final OaClient api; + + @override + State createState() => _FlowPageState(); +} + +class _FlowPageState extends State { + List> _items = []; + String _bucket = ''; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final q = {}; + if (_bucket.isNotEmpty) q['bucket'] = _bucket; + final data = await widget.api.get('/office/flow', query: q.isEmpty ? null : q); + if (!mounted) return; + setState(() { + _items = asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('我的申请')), + body: Column( + children: [ + BucketBar( + value: _bucket, + extra: const [('occupying', '占用中')], + onChanged: (v) { + setState(() { + _bucket = v; + _loading = true; + }); + _load(); + }, + ), + if (_loading) const LinearProgressIndicator(minHeight: 2), + Expanded( + child: _items.isEmpty + ? const EmptyHint('还没有申请记录') + : RefreshIndicator( + onRefresh: _load, + child: ListView.builder( + itemCount: _items.length, + itemBuilder: (_, i) { + final r = _items[i]; + return KvTile( + title: '${r['title'] ?? ''}', + subtitle: '${zh(r['source'])} · ${zh(r['kind'])} ${fmtTime(r['createdAt'])}', + status: r['status'], + onTap: () => openRecord(context, widget.api, r), + ); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/pages/hr_apply_page.dart b/apps/native/lib/pages/hr_apply_page.dart new file mode 100644 index 0000000..3964968 --- /dev/null +++ b/apps/native/lib/pages/hr_apply_page.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../device/device_bridge.dart'; +import '../labels.dart'; +import '../theme.dart'; +import '../widgets/common.dart'; +import '../widgets/wecom.dart'; +import 'record_detail_page.dart'; + +const _kinds = [ + ('PERSONAL', '请假'), + ('OVERTIME', '加班'), + ('BUSINESS', '出差'), + ('OUT', '外出'), + ('REGULARIZE', '转正'), + ('RESIGN', '离职'), +]; + +class HrApplyPage extends StatefulWidget { + const HrApplyPage({super.key, required this.api}); + final OaClient api; + + @override + State createState() => _HrApplyPageState(); +} + +class _HrApplyPageState extends State { + List> _items = []; + bool _loading = true; + String _bucket = ''; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final q = _bucket.isEmpty ? null : {'bucket': _bucket}; + final data = await widget.api.get('/leave-requests', query: q); + if (!mounted) return; + setState(() { + _items = asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + Future _create() async { + var kind = 'LEAVE'; + final reason = TextEditingController(); + final days = TextEditingController(text: '1'); + final ok = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) => Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(ctx).bottom), + child: StatefulBuilder( + builder: (ctx, setSt) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text('发起人事申请', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + Wrap( + spacing: 8, + children: [ + for (final k in _kinds) + ChoiceChip( + label: Text(k.$2), + selected: kind == k.$1, + onSelected: (_) => setSt(() => kind = k.$1), + ), + ], + ), + const SizedBox(height: 12), + TextField(controller: days, keyboardType: TextInputType.number, decoration: const InputDecoration(hintText: '天数')), + const SizedBox(height: 8), + TextField(controller: reason, maxLines: 3, decoration: const InputDecoration(hintText: '事由')), + const SizedBox(height: 16), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('提交')), + ], + ), + ), + ), + ), + ), + ); + if (ok != true) return; + try { + final created = Map.from(await widget.api.post('/leave-requests', { + 'kind': kind, + 'reason': reason.text.trim(), + 'days': num.tryParse(days.text) ?? 1, + }) as Map); + final id = '${created['id'] ?? ''}'; + if (id.isNotEmpty && mounted) { + final add = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('添加申请材料'), + content: const Text('可以上传病假证明、行程单等附件。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('暂不添加')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('选择附件')), + ], + ), + ); + if (add == true) { + final picked = await DeviceBridge.pickFile(); + if (picked != null && picked['path'] != null) { + await widget.api.uploadFile( + filePath: picked['path']!, + filename: picked['name'] ?? '申请材料', + bizType: 'HR_LEAVE', + bizId: id, + mime: picked['mime'], + ); + } + } + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已提交,由汇报对象和部门总监审批'))); + _load(); + } + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: kPaper, + appBar: AppBar( + title: const Text('人事申请'), + actions: [IconButton(onPressed: _create, icon: const Icon(Icons.add))], + ), + body: Column( + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Row( + children: [ + for (final it in [('', '全部'), ('pending', '待审批'), ('approved', '已通过'), ('rejected', '已驳回')]) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(it.$2), + selected: _bucket == it.$1, + onSelected: (_) { + setState(() => _bucket = it.$1); + _load(); + }, + ), + ), + ], + ), + ), + if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind), + Expanded( + child: _items.isEmpty + ? const EmptyHint('还没有人事申请') + : RefreshIndicator( + onRefresh: _load, + child: ListView( + children: [ + for (final r in _items) + ConvTile( + title: '${zh(r['kind'])} · ${r['reason'] ?? ''}', + preview: '${zh(r['status'])} ${fmtTime(r['createdAt'])}', + avatarIcon: Icons.beach_access_outlined, + avatarColor: kBind, + avatarLabel: '人', + onTap: () => openRecord(context, widget.api, {...r, 'source': 'HR'}), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/pages/legal_page.dart b/apps/native/lib/pages/legal_page.dart new file mode 100644 index 0000000..a1e742e --- /dev/null +++ b/apps/native/lib/pages/legal_page.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../theme.dart'; +import '../widgets/beian_footer.dart'; + +class LegalPage extends StatefulWidget { + const LegalPage({super.key, required this.api, required this.kind}); + final OaClient api; + final String kind; + + @override + State createState() => _LegalPageState(); +} + +class _LegalPageState extends State { + String _title = ''; + String _meta = ''; + List> _sections = []; + String _error = ''; + bool _loading = true; + + @override + void initState() { + super.initState(); + _title = widget.kind == 'privacy' ? '隐私政策' : '用户协议'; + _load(); + } + + Future _load() async { + try { + final data = await widget.api.get('/legal/${widget.kind}'); + if (data is! Map) throw ApiException('文档格式错误'); + final paras = data['paragraphs']; + final sections = >[]; + if (paras is List) { + for (final row in paras) { + if (row is Map) { + sections.add({ + 'heading': '${row['heading'] ?? ''}', + 'body': '${row['body'] ?? ''}', + }); + } + } + } + if (!mounted) return; + setState(() { + _title = '${data['title'] ?? _title}'; + _meta = '${data['operator'] ?? ''} · 更新日期 ${data['updatedAt'] ?? ''}'; + _sections = sections; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = '$e'; + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar(title: Text(_title)), + body: _loading + ? const Center(child: CircularProgressIndicator(color: kBind)) + : _error.isNotEmpty + ? Center(child: Padding(padding: const EdgeInsets.all(24), child: Text(_error, style: const TextStyle(color: kDanger)))) + : ListView( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 40), + children: [ + if (_meta.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text(_meta, style: const TextStyle(color: kMute, fontSize: 12, height: 1.5)), + ), + for (final s in _sections) ...[ + if ((s['heading'] ?? '').isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: Text(s['heading']!, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + Text(s['body'] ?? '', style: const TextStyle(fontSize: 14, height: 1.7, color: Color(0xFF334155))), + const SizedBox(height: 12), + ], + const BeianFooter(), + ], + ), + ); + } +} diff --git a/apps/native/lib/pages/location_pick_page.dart b/apps/native/lib/pages/location_pick_page.dart new file mode 100644 index 0000000..2075c68 --- /dev/null +++ b/apps/native/lib/pages/location_pick_page.dart @@ -0,0 +1,262 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../device/device_bridge.dart'; +import '../theme.dart'; +import '../widgets/tencent_map_thumb.dart'; +import '../widgets/wecom.dart'; + +class GeoPlace { + GeoPlace({required this.lat, required this.lng, required this.title, required this.address, this.distance}); + final double lat; + final double lng; + final String title; + final String address; + final num? distance; + + Map toMeta() => { + 'lat': lat, + 'lng': lng, + 'title': title, + 'address': address, + }; +} + +class LocationPickPage extends StatefulWidget { + const LocationPickPage({super.key, required this.api}); + final OaClient api; + + @override + State createState() => _LocationPickPageState(); +} + +class _LocationPickPageState extends State { + double? _lat; + double? _lng; + GeoPlace? _picked; + List _pois = []; + bool _loading = true; + String _err = ''; + String _q = ''; + Timer? _searchDebounce; + + @override + void initState() { + super.initState(); + _locate(); + } + + @override + void dispose() { + _searchDebounce?.cancel(); + super.dispose(); + } + + Future _locate() async { + setState(() { + _loading = true; + _err = ''; + }); + try { + Map? loc; + try { + loc = await DeviceBridge.getLocation(); + } catch (_) {} + final lat = loc?['lat']; + final lng = loc?['lng']; + if (lat == null || lng == null) { + throw Exception('没有拿到定位,请允许位置权限并打开系统定位'); + } + await _loadAround(lat, lng); + } catch (e) { + if (mounted) setState(() { _loading = false; _err = '$e'; }); + } + } + + Future _loadAround(double lat, double lng) async { + final data = await widget.api.post('/office/geo/reverse', {'lat': lat, 'lng': lng}); + if (!mounted) return; + final map = data is Map ? Map.from(data) : {}; + final here = GeoPlace( + lat: lat, + lng: lng, + title: '${map['title'] ?? '当前位置'}', + address: '${map['address'] ?? ''}', + ); + final pois = [here]; + final raw = map['pois']; + if (raw is List) { + for (final e in raw) { + if (e is! Map) continue; + final plat = (e['lat'] as num?)?.toDouble(); + final plng = (e['lng'] as num?)?.toDouble(); + if (plat == null || plng == null) continue; + pois.add(GeoPlace( + lat: plat, + lng: plng, + title: '${e['title'] ?? e['address'] ?? '地点'}', + address: '${e['address'] ?? ''}', + distance: e['distance'] as num?, + )); + } + } + setState(() { + _lat = lat; + _lng = lng; + _picked = here; + _pois = pois; + _loading = false; + _err = ''; + }); + } + + void _onSearch(String q) { + _q = q.trim(); + _searchDebounce?.cancel(); + if (_q.isEmpty) { + if (_lat != null && _lng != null) _loadAround(_lat!, _lng!); + return; + } + _searchDebounce = Timer(const Duration(milliseconds: 350), () async { + try { + final data = await widget.api.get('/office/geo/suggest', query: { + 'keyword': _q, + if (_lat != null) 'lat': '$_lat', + if (_lng != null) 'lng': '$_lng', + }); + if (!mounted) return; + final list = data is List ? data : (data is Map ? (data['items'] ?? data['data'] ?? []) : []); + final pois = []; + for (final e in (list is List ? list : [])) { + if (e is! Map) continue; + final plat = (e['lat'] as num?)?.toDouble(); + final plng = (e['lng'] as num?)?.toDouble(); + if (plat == null || plng == null) continue; + pois.add(GeoPlace( + lat: plat, + lng: plng, + title: '${e['title'] ?? '地点'}', + address: '${e['address'] ?? ''}', + distance: e['distance'] as num?, + )); + } + setState(() { + _pois = pois; + if (pois.isNotEmpty) _picked = pois.first; + }); + } catch (_) {} + }); + } + + String _dist(num? d) { + if (d == null) return ''; + if (d < 1) return '当前位置'; + if (d < 1000) return '${d.round()}m'; + return '${(d / 1000).toStringAsFixed(1)}km'; + } + + @override + Widget build(BuildContext context) { + final picked = _picked; + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + leading: TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')), + title: const Text('发送位置'), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 8, top: 8, bottom: 8), + child: FilledButton( + style: FilledButton.styleFrom(backgroundColor: kWeGreen, minimumSize: const Size(64, 32)), + onPressed: picked == null ? null : () => Navigator.pop(context, picked), + child: const Text('发送'), + ), + ), + ], + ), + body: Column( + children: [ + SizedBox( + height: 220, + width: double.infinity, + child: Stack( + children: [ + if (picked != null) + Positioned.fill( + child: TencentMapThumb(api: widget.api, lat: picked.lat, lng: picked.lng, height: 220), + ) + else + const ColoredBox(color: Color(0xFFE8F5E9), child: Center(child: CircularProgressIndicator())), + Positioned( + left: 12, + bottom: 12, + child: Material( + color: Colors.white, + shape: const CircleBorder(), + elevation: 2, + child: IconButton( + onPressed: _locate, + icon: const Icon(Icons.my_location, color: kBind), + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: WxSearchBar(hint: '搜索地点', onChanged: _onSearch), + ), + if (_err.isNotEmpty) + Padding( + padding: const EdgeInsets.all(12), + child: Text(_err, style: const TextStyle(color: kDanger, fontSize: 13)), + ), + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView.builder( + itemCount: _pois.length, + itemBuilder: (_, i) { + final p = _pois[i]; + final on = picked?.lat == p.lat && picked?.lng == p.lng && picked?.title == p.title; + return InkWell( + onTap: () => setState(() => _picked = p), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.only(bottom: 12), + decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(p.title, style: const TextStyle(fontSize: 16, color: kInk)), + const SizedBox(height: 2), + Text( + [_dist(p.distance), p.address].where((e) => e.isNotEmpty).join(' | '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: kMute), + ), + ], + ), + ), + ), + if (on) const Padding(padding: EdgeInsets.only(left: 8), child: Icon(Icons.check, color: kWeGreen)), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/pages/login_page.dart b/apps/native/lib/pages/login_page.dart new file mode 100644 index 0000000..b7c2d8b --- /dev/null +++ b/apps/native/lib/pages/login_page.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../app_config.dart'; +import '../pages/legal_page.dart'; +import '../session/session.dart'; +import '../theme.dart'; +import '../widgets/beian_footer.dart'; +import '../widgets/wecom.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({super.key, required this.session, required this.api}); + final SessionStore session; + final OaClient api; + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _phone = TextEditingController(); + final _pass = TextEditingController(); + final _sms = TextEditingController(); + bool _loading = false; + bool _smsLogin = false; + bool _agreed = false; + String _error = ''; + int _wait = 0; + + @override + void initState() { + super.initState(); + _loadOptions(); + } + + Future _loadOptions() async { + try { + final data = await widget.api.get('/auth/login-options'); + if (data is Map && mounted) setState(() => _smsLogin = data['smsLogin'] == true); + } catch (_) {} + } + + Future _sendSms() async { + if (_phone.text.trim().isEmpty) { + setState(() => _error = '请先填写手机号'); + return; + } + setState(() => _error = ''); + try { + await widget.api.post('/auth/sms-code', {'username': _phone.text.trim()}); + setState(() => _wait = 60); + _tick(); + } catch (e) { + setState(() => _error = '$e'); + } + } + + void _tick() { + Future.delayed(const Duration(seconds: 1), () { + if (!mounted || _wait <= 0) return; + setState(() => _wait--); + if (_wait > 0) _tick(); + }); + } + + Future _openLegal(String kind) async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => LegalPage(api: widget.api, kind: kind)), + ); + } + + Future _submit() async { + if (!_agreed) { + setState(() => _error = '请先阅读并同意用户协议和隐私政策'); + return; + } + setState(() { + _loading = true; + _error = ''; + }); + try { + final data = await widget.api.post('/auth/login', { + 'username': _phone.text.trim(), + 'password': _pass.text, + 'agreeTerms': true, + 'clientKind': 'mobile', + if (_smsLogin && _sms.text.trim().isNotEmpty) 'smsCode': _sms.text.trim(), + }); + await widget.session.applyLogin(Map.from(data as Map)); + try { + final menus = await widget.api.get('/system/menus'); + widget.session.setMenus(asMaps(menus).map(MenuNode.fromJson).toList()); + } catch (_) {} + } catch (e) { + if (mounted) setState(() => _error = '$e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + void dispose() { + _phone.dispose(); + _pass.dispose(); + _sms.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(28, 48, 28, 16), + children: [ + const SquareAvatar(label: '风', size: 64, color: kBind, icon: Icons.apartment), + const SizedBox(height: 16), + const Text(AppConfig.shortName, textAlign: TextAlign.center, style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + const Text('手机号登录', textAlign: TextAlign.center, style: TextStyle(color: kMute)), + const SizedBox(height: 36), + if (_error.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text(_error, style: const TextStyle(color: kDanger)), + ), + TextField( + controller: _phone, + keyboardType: TextInputType.phone, + decoration: const InputDecoration(hintText: '手机号'), + ), + const SizedBox(height: 12), + TextField( + controller: _pass, + obscureText: true, + decoration: const InputDecoration(hintText: '密码'), + ), + if (_smsLogin) ...[ + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: TextField(controller: _sms, decoration: const InputDecoration(hintText: '短信验证码'))), + const SizedBox(width: 8), + TextButton(onPressed: _wait > 0 ? null : _sendSms, child: Text(_wait > 0 ? '${_wait}s' : '获取验证码')), + ], + ), + ], + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 24, + height: 24, + child: Checkbox( + value: _agreed, + onChanged: (v) => setState(() => _agreed = v == true), + ), + ), + const SizedBox(width: 4), + Expanded( + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + const Text('我已阅读并同意', style: TextStyle(fontSize: 13, color: kMute)), + GestureDetector( + onTap: () => _openLegal('user-agreement'), + child: const Text('《用户协议》', style: TextStyle(fontSize: 13, color: kBind)), + ), + const Text('和', style: TextStyle(fontSize: 13, color: kMute)), + GestureDetector( + onTap: () => _openLegal('privacy'), + child: const Text('《隐私政策》', style: TextStyle(fontSize: 13, color: kBind)), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 46, + child: FilledButton( + onPressed: _loading || !_agreed ? null : _submit, + child: Text(_loading ? '登录中…' : '登录', style: const TextStyle(fontSize: 16)), + ), + ), + ], + ), + ), + const BeianFooter(), + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/native/lib/pages/messages_page.dart b/apps/native/lib/pages/messages_page.dart new file mode 100644 index 0000000..e07872e --- /dev/null +++ b/apps/native/lib/pages/messages_page.dart @@ -0,0 +1,575 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../im/chat_prefs.dart'; +import '../pages/chat_page.dart'; +import '../pages/notices_page.dart'; +import '../pages/pick_people_page.dart'; +import '../pages/scan_login_page.dart'; +import '../pages/system_notice_page.dart'; +import '../session/session.dart'; +import '../theme.dart'; +import '../widgets/common.dart'; +import '../widgets/desktop_ui.dart'; +import '../widgets/wecom.dart'; + +class MessagesPage extends StatefulWidget { + const MessagesPage({ + super.key, + required this.session, + required this.api, + this.desktopPane = false, + this.desktopStyle = false, + this.selectedId, + this.onSelectConv, + }); + final SessionStore session; + final OaClient api; + final bool desktopPane; + final bool desktopStyle; + final String? selectedId; + final void Function(Map row)? onSelectConv; + + @override + State createState() => _MessagesPageState(); +} + +class _MessagesPageState extends State { + List> _items = []; + bool _loading = true; + String _err = ''; + String _filter = 'all'; + String _q = ''; + bool _showSearch = false; + Timer? _imDebounce; + + @override + void initState() { + super.initState(); + widget.session.im.addListener(_onIm); + ChatPrefs.ensure().then((_) { + if (mounted) setState(() {}); + }); + _load(); + } + + @override + void dispose() { + _imDebounce?.cancel(); + widget.session.im.removeListener(_onIm); + super.dispose(); + } + + void _onIm() { + _imDebounce?.cancel(); + _imDebounce = Timer(const Duration(milliseconds: 250), () { + if (mounted) _load(); + }); + } + + Future _load() async { + try { + final data = await widget.api.get('/im/conversations'); + if (!mounted) return; + setState(() { + _items = asMaps(data); + _loading = false; + _err = ''; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + if (_items.isEmpty) _err = '$e'; + }); + } + } + + int get _unread { + var n = 0; + for (final r in _items) { + if (ChatPrefs.muted('${r['id'] ?? ''}')) continue; + final id = '${r['id'] ?? ''}'; + final fake = ChatPrefs.fakeUnread(id); + if (fake > 0) { + n += fake; + continue; + } + n += (r['unread'] as num?)?.toInt() ?? 0; + } + return n; + } + + Future _showDesktopConvMenu(Map r, Offset pos) async { + final id = '${r['id'] ?? ''}'; + if (id.isEmpty) return; + final muted = ChatPrefs.muted(id); + final pinned = ChatPrefs.pinned(id); + final unread = (r['unread'] as num?)?.toInt() ?? 0; + final fake = ChatPrefs.fakeUnread(id); + final selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB(pos.dx, pos.dy, pos.dx + 1, pos.dy + 1), + items: [ + if (unread == 0 && fake == 0) + const PopupMenuItem(value: 'unread', child: Text('标为未读')), + PopupMenuItem(value: 'mute', child: Text(muted ? '取消免打扰' : '消息免打扰')), + PopupMenuItem(value: 'pin', child: Text(pinned ? '取消置顶' : '置顶')), + const PopupMenuItem(value: 'hide', child: Text('不显示')), + const PopupMenuDivider(), + const PopupMenuItem(value: 'clear', child: Text('清空聊天记录')), + const PopupMenuItem(value: 'delete', child: Text('删除')), + ], + ); + if (!mounted || selected == null) return; + switch (selected) { + case 'unread': + await ChatPrefs.setFakeUnread(id, 1); + case 'mute': + await ChatPrefs.setMuted(id, !muted); + case 'pin': + await ChatPrefs.setPinned(id, !pinned); + case 'hide': + await ChatPrefs.setHidden(id, true); + case 'clear': + await ChatPrefs.clearHistory(id); + case 'delete': + await ChatPrefs.setHidden(id, true); + } + if (mounted) { + setState(() {}); + await _load(); + } + } + + Widget _wrapConv(Map r, Widget child) { + if (!widget.desktopStyle) return child; + return GestureDetector( + onSecondaryTapDown: (d) => _showDesktopConvMenu(r, d.globalPosition), + child: child, + ); + } + + Future _newChat({required bool group}) async { + final picked = await Navigator.of(context).push>>( + MaterialPageRoute( + builder: (_) => PickPeoplePage( + api: widget.api, + title: group ? '选择联系人' : '发起单聊', + multiple: group, + exclude: {widget.session.userId}, + ), + ), + ); + if (picked == null || picked.isEmpty) return; + if (!group) { + final p = picked.first; + if (!mounted) return; + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => ChatPage( + session: widget.session, + api: widget.api, + peerId: '${p['id']}', + peerName: '${p['name'] ?? '同事'}', + peerAvatarFileId: '${p['avatarFileId'] ?? ''}', + ), + )); + _load(); + return; + } + final name = TextEditingController(); + if (!mounted) return; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('群名称'), + content: TextField( + controller: name, + decoration: const InputDecoration(hintText: '例如:三维项目组')), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消')), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('创建')), + ], + ), + ); + if (ok == true) { + await widget.api.post('/im/groups', { + 'name': name.text.trim().isEmpty ? '群聊' : name.text.trim(), + 'memberIds': picked.map((e) => '${e['id']}').toList(), + }); + await _load(); + } + } + + List> get _shown { + var list = _items; + // 只有用户明确执行“不显示/删除”后才隐藏;阅读状态不会改变会话是否存在。 + list = list.where((e) { + final id = '${e['id'] ?? ''}'; + return !ChatPrefs.hidden(id); + }).toList(); + if (_filter == 'unread') + list = + list.where((e) => ((e['unread'] as num?)?.toInt() ?? 0) > 0).toList(); + if (_filter == 'dm') + list = list.where((e) => e['type'] != 'group').toList(); + if (_filter == 'group') + list = list.where((e) => e['type'] == 'group').toList(); + if (_q.isNotEmpty) { + list = list + .where((e) => + '${e['name']}${e['peerName']}${e['lastText']}'.contains(_q)) + .toList(); + } + list = [...list]..sort((a, b) { + final ap = ChatPrefs.pinned('${a['id'] ?? ''}'); + final bp = ChatPrefs.pinned('${b['id'] ?? ''}'); + if (ap == bp) return 0; + return ap ? -1 : 1; + }); + return list; + } + + @override + Widget build(BuildContext context) { + final header = widget.desktopStyle + ? DesktopPaneHeader( + title: _unread > 0 ? '消息($_unread)' : '消息', + actions: [ + IconButton( + onPressed: () => setState(() => _showSearch = !_showSearch), + icon: const Icon(Icons.search, color: kInk, size: 20), + ), + Builder( + builder: (ctx) => IconButton( + onPressed: () => showPlusMenu(ctx, [ + (Icons.qr_code_scanner_outlined, '扫一扫登录电脑', () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ScanLoginPage(api: widget.api)))), + (Icons.chat_bubble_outline, '发起单聊', () => _newChat(group: false)), + (Icons.group_outlined, '发起群聊', () => _newChat(group: true)), + ]), + icon: const Icon(Icons.add, color: kInk, size: 22), + ), + ), + ], + bottom: Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), + child: Column( + children: [ + if (_showSearch) ...[ + DesktopSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())), + const SizedBox(height: 8), + ], + Row( + children: [ + _filterChip('all', '全部'), + const SizedBox(width: 8), + _filterChip('unread', '未读'), + const SizedBox(width: 8), + _filterChip('dm', '单聊'), + const SizedBox(width: 8), + _filterChip('group', '群聊'), + ], + ), + ], + ), + ), + ) + : Column( + children: [ + WxHeader( + title: _unread > 0 ? '消息($_unread)' : '消息', + actions: [ + IconButton( + onPressed: () => setState(() => _showSearch = !_showSearch), + icon: const Icon(Icons.search, color: kInk), + ), + Builder( + builder: (ctx) => IconButton( + onPressed: () => showPlusMenu(ctx, [ + (Icons.qr_code_scanner_outlined, '扫一扫登录电脑', () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ScanLoginPage(api: widget.api)))), + (Icons.chat_bubble_outline, '发起单聊', () => _newChat(group: false)), + (Icons.group_outlined, '发起群聊', () => _newChat(group: true)), + ]), + icon: const Icon(Icons.add_circle_outline, color: kInk), + ), + ), + ], + ), + if (_showSearch) WxSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), + child: Row( + children: [ + _filterChip('all', '全部'), + const SizedBox(width: 8), + _filterChip('unread', '未读'), + const SizedBox(width: 8), + _filterChip('dm', '单聊'), + const SizedBox(width: 8), + _filterChip('group', '群聊'), + ], + ), + ), + ], + ); + + return ColoredBox( + color: Colors.white, + child: Column( + children: [ + header, + if (_loading) + const LinearProgressIndicator(minHeight: 2, color: kBind), + if (_err.isNotEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Text(_err, style: const TextStyle(color: kDanger))), + Expanded( + child: RefreshIndicator( + onRefresh: _load, + child: ListView( + padding: EdgeInsets.zero, + children: [ + ConvTile( + title: '公告', + preview: '公司通知与制度', + avatarIcon: Icons.campaign, + avatarColor: const Color(0xFFE75D5D), + avatarLabel: '告', + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => NoticesPage(api: widget.api), + )), + ), + if (_shown.isEmpty && !_loading) + const Padding( + padding: EdgeInsets.only(top: 48), + child: EmptyHint('还没有会话。点右上角 + 或到通讯录找同事。'), + ), + for (final r in _shown) + _wrapConv( + r, + widget.desktopStyle + ? ConvTile( + title: '${r['name'] ?? r['peerName'] ?? '同事'}', + preview: '${r['lastText'] ?? ''}', + time: shortTime(r['lastAt']), + muted: ChatPrefs.muted('${r['id'] ?? ''}'), + unread: ChatPrefs.muted('${r['id'] ?? ''}') + ? 0 + : (ChatPrefs.fakeUnread('${r['id'] ?? ''}') > 0 + ? ChatPrefs.fakeUnread('${r['id'] ?? ''}') + : ((r['unread'] as num?)?.toInt() ?? 0)), + avatarLabel: '${r['peerId']}' == '0' ? '?' : '${r['name'] ?? r['peerName'] ?? '同'}', + avatarIcon: '${r['peerId']}' == '0' ? Icons.notifications : (r['type'] == 'group' ? Icons.groups : null), + avatarColor: '${r['peerId']}' == '0' ? const Color(0xFF07C160) : (r['type'] == 'group' ? const Color(0xFF07C160) : null), + avatarFileId: r['type'] == 'group' ? null : '${r['avatarFileId'] ?? ''}', + api: widget.api, + selected: widget.desktopPane && widget.selectedId == '${r['id'] ?? ''}', + onTap: () async { + final id = '${r['id'] ?? ''}'; + if (id.isNotEmpty) await ChatPrefs.setFakeUnread(id, 0); + if (widget.desktopPane && widget.onSelectConv != null) { + widget.onSelectConv!(r); + return; + } + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => '${r['peerId']}' == '0' + ? SystemNoticePage(session: widget.session, api: widget.api, conversationId: id) + : ChatPage( + session: widget.session, + api: widget.api, + peerId: '${r['peerId'] ?? ''}', + conversationId: id, + peerName: '${r['name'] ?? r['peerName'] ?? '同事'}', + isGroup: r['type'] == 'group', + peerAvatarFileId: '${r['avatarFileId'] ?? ''}', + ), + )); + _load(); + }, + ) + : _SwipeConversation( + id: '${r['id'] ?? ''}', + api: widget.api, + onChanged: _load, + child: ConvTile( + title: '${r['name'] ?? r['peerName'] ?? '同事'}', + preview: '${r['lastText'] ?? ''}', + time: shortTime(r['lastAt']), + muted: ChatPrefs.muted('${r['id'] ?? ''}'), + unread: ChatPrefs.muted('${r['id'] ?? ''}') + ? 0 + : ((r['unread'] as num?)?.toInt() ?? 0), + avatarLabel: '${r['peerId']}' == '0' + ? '?' + : '${r['name'] ?? r['peerName'] ?? '同'}', + avatarIcon: '${r['peerId']}' == '0' + ? Icons.notifications + : (r['type'] == 'group' ? Icons.groups : null), + avatarColor: '${r['peerId']}' == '0' + ? const Color(0xFF07C160) + : (r['type'] == 'group' + ? const Color(0xFF07C160) + : null), + avatarFileId: r['type'] == 'group' + ? null + : '${r['avatarFileId'] ?? ''}', + api: widget.api, + selected: widget.desktopPane && widget.selectedId == '${r['id'] ?? ''}', + onTap: () async { + if (widget.desktopPane && widget.onSelectConv != null) { + widget.onSelectConv!(r); + return; + } + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => '${r['peerId']}' == '0' + ? SystemNoticePage(session: widget.session, api: widget.api, conversationId: '${r['id'] ?? ''}') + : ChatPage( + session: widget.session, + api: widget.api, + peerId: '${r['peerId'] ?? ''}', + conversationId: '${r['id'] ?? ''}', + peerName: '${r['name'] ?? r['peerName'] ?? '同事'}', + isGroup: r['type'] == 'group', + peerAvatarFileId: '${r['avatarFileId'] ?? ''}', + ), + )); + _load(); + }, + ), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _filterChip(String id, String label) { + final on = _filter == id; + return GestureDetector( + onTap: () => setState(() => _filter = id), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: on ? const Color(0xFFE8F3FF) : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Text(label, + style: TextStyle( + fontSize: 13, + color: on ? kBind : kMute, + fontWeight: on ? FontWeight.w600 : FontWeight.w400)), + ), + ); + } +} + +/// 微信式左滑操作栏:操作不会误删服务器会话,删除仅隐藏本机列表。 +class _SwipeConversation extends StatefulWidget { + const _SwipeConversation( + {required this.id, + required this.api, + required this.child, + required this.onChanged}); + final String id; + final OaClient api; + final Widget child; + final Future Function() onChanged; + + @override + State<_SwipeConversation> createState() => _SwipeConversationState(); +} + +class _SwipeConversationState extends State<_SwipeConversation> { + double _offset = 0; + static const _width = 216.0; + + Future _hide() async { + if (!mounted) return; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('隐藏会话'), + content: const Text('仅从消息列表隐藏,不会删除聊天记录。确定继续吗?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')), + ], + ), + ); + if (ok != true) { + if (mounted) setState(() => _offset = 0); + return; + } + await ChatPrefs.setHidden(widget.id, true); + await widget.onChanged(); + } + + Future _toggleMute() async { + await ChatPrefs.setMuted(widget.id, !ChatPrefs.muted(widget.id)); + if (mounted) setState(() => _offset = 0); + await widget.onChanged(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 76, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: (d) => setState(() => _offset = (_offset + d.delta.dx).clamp(-_width, 0)), + onHorizontalDragEnd: (_) => setState(() => _offset = _offset < -80 ? -_width : 0), + child: Stack( + fit: StackFit.expand, + children: [ + Align( + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _action('免打扰', const Color(0xFFFFA940), _toggleMute), + _action('不显示', const Color(0xFF8C8C8C), _hide), + _action('删除', const Color(0xFFF5222D), _hide), + ], + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + transform: Matrix4.translationValues(_offset, 0, 0), + color: Colors.white, + child: widget.child, + ), + ], + ), + ), + ); + } + + Widget _action(String label, Color color, Future Function() onTap) { + return SizedBox( + width: 72, + height: double.infinity, + child: Material( + color: color, + child: InkWell( + onTap: () async => onTap(), + child: Center( + child: Text(label, + style: const TextStyle(color: Colors.white, fontSize: 13))), + ), + ), + ); + } +} diff --git a/apps/native/lib/pages/mine_page.dart b/apps/native/lib/pages/mine_page.dart new file mode 100644 index 0000000..515b88e --- /dev/null +++ b/apps/native/lib/pages/mine_page.dart @@ -0,0 +1,267 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../device/push_bridge.dart'; +import '../app_config.dart'; +import '../labels.dart'; +import '../ota/updater.dart'; +import '../pages/legal_page.dart'; +import '../pages/profile_page.dart'; +import '../session/session.dart'; +import '../theme.dart'; +import '../widgets/beian_footer.dart'; +import '../widgets/desktop_ui.dart'; +import '../widgets/wecom.dart'; + +class MinePage extends StatefulWidget { + const MinePage({super.key, required this.session, required this.api, this.desktopStyle = false}); + final SessionStore session; + final OaClient api; + final bool desktopStyle; + + @override + State createState() => _MinePageState(); +} + +class _MinePageState extends State { + AppRelease? _rel; + bool _checking = false; + + @override + void initState() { + super.initState(); + widget.session.addListener(_onSession); + _peek(); + } + + @override + void dispose() { + widget.session.removeListener(_onSession); + super.dispose(); + } + + void _onSession() { + if (mounted) setState(() {}); + } + + Future _peek() async { + final rel = await OtaUpdater(widget.api).fetch(); + if (mounted) setState(() => _rel = rel); + } + + Future _logout() async { + try { + await widget.api + .post('/auth/logout', {'refreshToken': widget.session.refreshToken}); + } catch (_) {} + await widget.session.clear(); + } + + Future _checkUpdate() async { + setState(() => _checking = true); + try { + final rel = await OtaUpdater(widget.api).fetch(); + if (!mounted) return; + setState(() => _rel = rel); + if (rel == null) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('暂时无法检查更新'))); + return; + } + await OtaUpdater(widget.api).prompt(context, rel, manual: true); + } finally { + if (mounted) setState(() => _checking = false); + } + } + + @override + Widget build(BuildContext context) { + final s = widget.session; + final avatarId = '${s.user['avatarFileId'] ?? ''}'; + return ColoredBox( + color: widget.desktopStyle ? kDeskBg : kPaper, + child: Column( + children: [ + widget.desktopStyle ? const DesktopPaneHeader(title: '我') : const WxHeader(title: '我'), + Expanded( + child: widget.desktopStyle + ? Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + children: _mineItems(s, avatarId), + ), + ), + ) + : ListView( + children: _mineItems(s, avatarId), + ), + ), + ], + ), + ); + } + + List _mineItems(SessionStore s, String avatarId) { + return [ + const SizedBox(height: 12), + Material( + color: Colors.white, + child: InkWell( + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => ProfilePage(session: s, api: widget.api), + )), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Row( + children: [ + SquareAvatar( + label: s.displayName, + size: 64, + fileId: avatarId.isEmpty ? null : avatarId, + api: widget.api, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(s.displayName, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text('账号 ${s.user['username'] ?? ''}', + style: const TextStyle( + color: kMute, fontSize: 13)), + Text(s.roles.map(zh).join(' · '), + style: const TextStyle( + color: kMute, fontSize: 12)), + ], + ), + ), + const Icon(Icons.chevron_right, + color: Color(0xFFC0C0C0)), + ], + ), + ), + ), + ), + const SizedBox(height: 12), + CellGroup( + children: [ + if (!widget.desktopStyle) + Cell( + title: '后台运行与通知', + subtitle: '允许自启动和后台,消息才能及时送达', + leading: const SquareAvatar( + label: '通', + color: kBind, + icon: Icons.notifications_active_outlined, + size: 32), + onTap: () async { + await PushBridge.requestBattery(); + await PushBridge.openOemKeepAlive(); + }, + ), + if (!widget.desktopStyle) + Cell( + title: '检查更新', + subtitle: _rel != null && _rel!.newer + ? '有新版本 ${_rel!.version}' + : '当前 ${AppConfig.version} (${AppConfig.build})', + leading: const SquareAvatar( + label: '升', + color: kBind, + icon: Icons.system_update_alt, + size: 32), + trailing: _checking + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : (_rel != null && _rel!.newer + ? const UnreadBadge(1, dot: true) + : null), + onTap: _checkUpdate, + ), + if (widget.desktopStyle) + Cell( + title: '检查更新', + subtitle: _rel != null && _rel!.newer + ? '有新版本 ${_rel!.version}' + : '当前 ${AppConfig.version} (${AppConfig.build})', + leading: const SquareAvatar( + label: '升', + color: kBind, + icon: Icons.system_update_alt, + size: 32), + trailing: _checking + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : (_rel != null && _rel!.newer + ? const UnreadBadge(1, dot: true) + : null), + onTap: _checkUpdate, + ), + Cell( + title: '用户协议', + leading: const SquareAvatar( + label: '协', + color: kBind, + icon: Icons.article_outlined, + size: 32), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LegalPage( + api: widget.api, kind: 'user-agreement')), + ), + ), + Cell( + title: '隐私政策', + leading: const SquareAvatar( + label: '私', + color: kBind, + icon: Icons.privacy_tip_outlined, + size: 32), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + LegalPage(api: widget.api, kind: 'privacy')), + ), + showLine: false, + ), + ], + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: Material( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: _logout, + borderRadius: BorderRadius.circular(8), + child: const SizedBox( + height: 48, + child: Center( + child: Text('退出登录', + style: + TextStyle(fontSize: 16, color: kDanger))), + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + '${AppConfig.brand}\n${AppConfig.version} (${AppConfig.build})', + textAlign: TextAlign.center, + style: + const TextStyle(color: kMute, fontSize: 12, height: 1.5), + ), + const BeianFooter(), + ]; + } +} diff --git a/apps/native/lib/pages/module_list_page.dart b/apps/native/lib/pages/module_list_page.dart new file mode 100644 index 0000000..1fc598b --- /dev/null +++ b/apps/native/lib/pages/module_list_page.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../labels.dart'; +import '../widgets/common.dart'; +import 'record_detail_page.dart'; + +class RestShell { + const RestShell(this.path, {this.query}); + final String path; + final Map? query; +} + +RestShell? shellFor(String code, String? path) { + final p = path ?? ''; + final c = code.toLowerCase(); + if (c.startsWith('bid') || p.startsWith('/bid')) return const RestShell('/bid-cases'); + if (c.startsWith('contract:hr') || p.startsWith('/contract/hr') || p.contains('labor-contract')) { + return const RestShell('/labor-contracts'); + } + if (c.startsWith('contract') || p.startsWith('/contract')) return const RestShell('/contracts'); + if (p.startsWith('/party/entities') || c == 'party:entities') return const RestShell('/legal-entities'); + if (p.startsWith('/party/contacts') || c == 'party:contacts') return const RestShell('/contacts'); + if (c.startsWith('party') || p.startsWith('/party')) return const RestShell('/parties'); + if (c == 'seal:borrows' || p.contains('borrow')) return const RestShell('/credential-borrows'); + if (c == 'seal:requests' || p.contains('seal-apply') || p.contains('/seal/requests')) { + return const RestShell('/seal-requests'); + } + if (c == 'seal:registry' || p.contains('/seal/registry')) return const RestShell('/seals'); + if (c.startsWith('seal') || p.startsWith('/seal')) return const RestShell('/qualifications'); + if (c.startsWith('asset') || p.startsWith('/asset') || p.contains('registered-asset')) { + return const RestShell('/registered-assets'); + } + if (p.contains('asset-purchase')) return const RestShell('/asset-purchases'); + if (p.contains('purchase') || c.contains('purchase')) return const RestShell('/purchase-requests'); + if (p.contains('/assets') && !p.contains('registered')) return const RestShell('/assets'); + if (c.contains('timesheet') || p.contains('timesheet')) return const RestShell('/timesheets'); + if (c.contains('task') || p.contains('task')) return const RestShell('/project-tasks'); + if (p.contains('project-change')) return const RestShell('/project-changes'); + if (p.contains('resource-board')) return const RestShell('/resource-board'); + if (c.startsWith('project') || p.startsWith('/project')) return const RestShell('/projects'); + if (p.contains('loan') || c.contains('loan')) return const RestShell('/loans'); + if (p.contains('bond')) return const RestShell('/bonds'); + if (p.contains('invoice')) return const RestShell('/invoices'); + if (p.contains('payment')) return const RestShell('/payments'); + if (p.contains('expense') || c.contains('expense')) return const RestShell('/expenses'); + if (p.contains('my-expense')) return const RestShell('/office/my-expenses'); + if (p.contains('payroll') || c.contains('payroll')) return const RestShell('/payroll'); + if (p.contains('leave') || c.contains('leave')) return const RestShell('/leave-requests'); + if (p.contains('employment')) return const RestShell('/employment-events'); + if (p.contains('departments') || c.contains('dept')) return const RestShell('/departments'); + if (p.contains('applies') || c.contains('apply')) return const RestShell('/office/applies'); + if (p.contains('work-assign')) return const RestShell('/office/work-assignments'); + if (p.contains('cost-target')) return const RestShell('/cost-targets'); + if (p.contains('attendance/punch')) return const RestShell('/attendance/punches'); + if (c.contains('attendance') || p.contains('attendance')) return const RestShell('/attendance'); + if (p.contains('employee') || c.contains('employee') || p.startsWith('/hr')) { + return const RestShell('/employees', query: {'page': '1', 'pageSize': '100'}); + } + if (c.startsWith('report') || p.startsWith('/reports') || p.startsWith('/office/reports')) { + return const RestShell('/office/reports'); + } + if (c.startsWith('legal') || p.startsWith('/legal')) return null; + return null; +} + +class ModuleListPage extends StatefulWidget { + const ModuleListPage({super.key, required this.api, required this.title, required this.shell}); + final OaClient api; + final String title; + final RestShell shell; + + @override + State createState() => _ModuleListPageState(); +} + +class _ModuleListPageState extends State { + List> _items = []; + String _err = ''; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _err = ''; + }); + try { + final data = await widget.api.get( + widget.shell.path, + query: { + 'page': '1', + 'pageSize': '100', + ...?widget.shell.query, + }, + ); + if (!mounted) return; + setState(() { + _items = asMaps(data); + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _err = '$e'; + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.title)), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _err.isNotEmpty + ? EmptyHint(_err) + : _items.isEmpty + ? EmptyHint('暂无${widget.title}') + : RefreshIndicator( + onRefresh: _load, + child: ListView( + children: [ + for (final r in _items) + KvTile( + title: pickTitle(r), + subtitle: zh(pickSub(r)), + status: r['status'], + onTap: () { + final seed = Map.from(r); + if ('${r['id']}'.length >= 8) seed['_fetch'] = '${widget.shell.path}/${r['id']}'; + openRecord(context, widget.api, seed, title: pickTitle(r)); + }, + ), + ], + ), + ), + ); + } +} diff --git a/apps/native/lib/pages/notice_detail_page.dart b/apps/native/lib/pages/notice_detail_page.dart new file mode 100644 index 0000000..40db31c --- /dev/null +++ b/apps/native/lib/pages/notice_detail_page.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../theme.dart'; +import '../widgets/html_body.dart'; + +class NoticeDetailPage extends StatefulWidget { + const NoticeDetailPage({super.key, required this.api, required this.id, this.title = '公告'}); + final OaClient api; + final String id; + final String title; + + @override + State createState() => _NoticeDetailPageState(); +} + +class _NoticeDetailPageState extends State { + Map _row = {}; + bool _loading = true; + String _err = ''; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final data = await widget.api.get('/office/notices/${widget.id}'); + if (!mounted) return; + setState(() { + _row = data is Map ? Map.from(data) : {}; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _err = '$e'; + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final title = '${_row['title'] ?? widget.title}'; + final author = '${(_row['createdBy'] is Map ? (_row['createdBy'] as Map)['displayName'] : '')}'; + final time = fmtTime(_row['createdAt'] ?? _row['publishedAt']); + final content = '${_row['content'] ?? ''}'; + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar(title: const Text('通知公告'), leading: const BackButton()), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _err.isNotEmpty + ? Center(child: Padding(padding: const EdgeInsets.all(24), child: Text(_err, style: const TextStyle(color: kMute)))) + : ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 40), + children: [ + Text(title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kInk, height: 1.35)), + const SizedBox(height: 8), + Text( + [if (author.isNotEmpty) author, if (time.isNotEmpty) time].join(' '), + style: const TextStyle(fontSize: 13, color: kMute), + ), + const SizedBox(height: 20), + HtmlBody(html: content, api: widget.api), + ], + ), + ); + } +} diff --git a/apps/native/lib/pages/notices_page.dart b/apps/native/lib/pages/notices_page.dart new file mode 100644 index 0000000..7653fc3 --- /dev/null +++ b/apps/native/lib/pages/notices_page.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../widgets/common.dart'; +import 'notice_detail_page.dart'; + +class NoticesPage extends StatefulWidget { + const NoticesPage({super.key, required this.api}); + final OaClient api; + + @override + State createState() => _NoticesPageState(); +} + +class _NoticesPageState extends State { + List> _items = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final data = await widget.api.get('/office/notices'); + if (!mounted) return; + setState(() { + _items = asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('公司公告')), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _items.isEmpty + ? const EmptyHint('暂无公告') + : RefreshIndicator( + onRefresh: _load, + child: ListView( + children: [ + for (final r in _items) + KvTile( + title: '${r['title'] ?? ''}', + subtitle: '${r['excerpt'] ?? ''} ${fmtTime(r['createdAt'])}', + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => NoticeDetailPage( + api: widget.api, + id: '${r['id']}', + title: '${r['title'] ?? '公告'}', + ), + )), + ), + ], + ), + ), + ); + } +} diff --git a/apps/native/lib/pages/org_browse_page.dart b/apps/native/lib/pages/org_browse_page.dart new file mode 100644 index 0000000..8ebd64d --- /dev/null +++ b/apps/native/lib/pages/org_browse_page.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../session/session.dart'; +import '../theme.dart'; +import '../widgets/wecom.dart'; +import 'chat_page.dart'; + +class OrgBrowsePage extends StatefulWidget { + const OrgBrowsePage({ + super.key, + required this.session, + required this.api, + required this.staff, + this.parentId, + this.title = '组织架构', + this.deptName, + }); + final SessionStore session; + final OaClient api; + final List> staff; + final String? parentId; + final String title; + final String? deptName; + + @override + State createState() => _OrgBrowsePageState(); +} + +class _OrgBrowsePageState extends State { + List> _depts = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final data = await widget.api.get('/departments'); + if (!mounted) return; + setState(() { + _depts = asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + List> get _children { + if (_depts.isEmpty) return const []; + return _depts.where((e) { + final pid = '${e['parentId'] ?? ''}'; + if (widget.parentId == null || widget.parentId!.isEmpty) return pid.isEmpty || pid == 'null'; + return pid == widget.parentId; + }).toList(); + } + + List> get _people { + final name = widget.deptName; + if (name == null || name.isEmpty) return const []; + final me = widget.session.userId; + return widget.staff.where((e) => '${e['department']}' == name && '${e['id']}' != me).toList(); + } + + int _countOf(Map d) { + final c = d['_count']; + if (c is Map && c['employees'] != null) return (c['employees'] as num).toInt(); + return widget.staff.where((e) => '${e['department']}' == '${d['name']}').length; + } + + void _openPerson(Map r) { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => ChatPage( + session: widget.session, + api: widget.api, + peerId: '${r['id']}', + peerName: '${r['name'] ?? '同事'}', + peerAvatarFileId: '${r['avatarFileId'] ?? ''}', + ), + )); + } + + @override + Widget build(BuildContext context) { + final children = _children; + final people = _people; + final fallbackDepts = {}; + if (_depts.isEmpty && (widget.parentId == null || widget.parentId!.isEmpty)) { + for (final r in widget.staff) { + final d = '${r['department'] ?? ''}'; + if (d.isNotEmpty) fallbackDepts.add(d); + } + } + return Scaffold( + backgroundColor: kPaper, + appBar: AppBar(title: Text(widget.title), leading: const BackButton()), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + children: [ + const SizedBox(height: 12), + if (children.isNotEmpty) + CellGroup( + children: [ + for (var i = 0; i < children.length; i++) + Cell( + title: '${children[i]['name'] ?? ''}', + subtitle: '${_countOf(children[i])} 人', + leading: const SquareAvatar(label: '部', color: kBind, icon: Icons.apartment, size: 36), + showLine: i != children.length - 1, + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => OrgBrowsePage( + session: widget.session, + api: widget.api, + staff: widget.staff, + parentId: '${children[i]['id']}', + title: '${children[i]['name'] ?? '部门'}', + deptName: '${children[i]['name'] ?? ''}', + ), + )), + ), + ], + ) + else if (fallbackDepts.isNotEmpty) + CellGroup( + children: [ + for (final d in (fallbackDepts.toList()..sort())) + Cell( + title: d, + subtitle: '${widget.staff.where((e) => '${e['department']}' == d).length} 人', + leading: const SquareAvatar(label: '部', color: kBind, icon: Icons.apartment, size: 36), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => OrgBrowsePage( + session: widget.session, + api: widget.api, + staff: widget.staff, + parentId: '__leaf__', + title: d, + deptName: d, + ), + )), + ), + ], + ), + if (people.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text('${widget.deptName} · ${people.length} 人', style: const TextStyle(fontSize: 13, color: kMute)), + ), + CellGroup( + children: [ + for (var i = 0; i < people.length; i++) + Cell( + title: '${people[i]['name'] ?? ''}', + subtitle: '${people[i]['title'] ?? ''}', + leading: SquareAvatar( + label: '${people[i]['name'] ?? ''}', + fileId: '${people[i]['avatarFileId'] ?? ''}', + api: widget.api, + size: 36, + ), + showLine: i != people.length - 1, + onTap: () => _openPerson(people[i]), + ), + ], + ), + ], + if (children.isEmpty && fallbackDepts.isEmpty && people.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 48), + child: Center(child: Text('这个部门还没有人', style: TextStyle(color: kMute))), + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/pages/pick_people_page.dart b/apps/native/lib/pages/pick_people_page.dart new file mode 100644 index 0000000..f3dd316 --- /dev/null +++ b/apps/native/lib/pages/pick_people_page.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../theme.dart'; +import '../widgets/wecom.dart'; + +class PickPeoplePage extends StatefulWidget { + const PickPeoplePage({ + super.key, + required this.api, + required this.title, + this.multiple = true, + this.exclude = const {}, + }); + final OaClient api; + final String title; + final bool multiple; + final Set exclude; + + @override + State createState() => _PickPeoplePageState(); +} + +class _PickPeoplePageState extends State { + List> _items = []; + final _selected = {}; + String _q = ''; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final data = await widget.api.get('/staff'); + if (!mounted) return; + setState(() { + _items = asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final shown = _items.where((e) { + final id = '${e['id']}'; + if (widget.exclude.contains(id)) return false; + if (_q.isEmpty) return true; + return '${e['name']}${e['department']}${e['title']}'.contains(_q); + }).toList(); + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: Text(widget.title), + leading: IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), + actions: [ + if (widget.multiple) + TextButton( + onPressed: _selected.isEmpty + ? null + : () => Navigator.pop(context, _selected.entries.map((e) => {'id': e.key, 'name': e.value}).toList()), + child: Text('确定(${_selected.length})'), + ), + ], + ), + body: Column( + children: [ + WxSearchBar(hint: '搜索同事', onChanged: (v) => setState(() => _q = v.trim())), + if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind), + Expanded( + child: ListView.builder( + itemCount: shown.length, + itemBuilder: (_, i) { + final r = shown[i]; + final id = '${r['id']}'; + final name = '${r['name'] ?? ''}'; + final on = _selected.containsKey(id); + return InkWell( + onTap: () { + if (!widget.multiple) { + Navigator.pop(context, [ + {'id': id, 'name': name}, + ]); + return; + } + setState(() { + if (on) { + _selected.remove(id); + } else { + _selected[id] = name; + } + }); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 0), + child: Row( + children: [ + if (widget.multiple) + Padding( + padding: const EdgeInsets.only(right: 10), + child: Icon(on ? Icons.check_circle : Icons.circle_outlined, color: on ? kBind : kMute, size: 22), + ), + SquareAvatar( + label: name, + fileId: '${r['avatarFileId'] ?? ''}', + api: widget.api, + ), + const SizedBox(width: 10), + Expanded( + child: Container( + padding: const EdgeInsets.only(bottom: 10), + decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: const TextStyle(fontSize: 16)), + Text( + [r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '), + style: const TextStyle(fontSize: 12, color: kMute), + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/pages/profile_page.dart b/apps/native/lib/pages/profile_page.dart new file mode 100644 index 0000000..8bc12f0 --- /dev/null +++ b/apps/native/lib/pages/profile_page.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../api/oa_client.dart'; +import '../labels.dart'; +import '../session/session.dart'; +import '../theme.dart'; +import '../widgets/wecom.dart'; + +class ProfilePage extends StatefulWidget { + const ProfilePage({super.key, required this.session, required this.api}); + final SessionStore session; + final OaClient api; + + @override + State createState() => _ProfilePageState(); +} + +class _ProfilePageState extends State { + bool _busy = false; + + Future _refreshMe() async { + final me = await widget.api.get('/auth/me'); + if (me is Map) await widget.session.patchUser(Map.from(me)); + } + + Future _pickAvatar() async { + final x = await ImagePicker().pickImage(source: ImageSource.gallery, imageQuality: 85, maxWidth: 800); + if (x == null) return; + setState(() => _busy = true); + try { + await widget.api.uploadFile( + filePath: x.path, + filename: x.name.isEmpty ? 'avatar.jpg' : x.name, + bizType: 'USER_AVATAR', + bizId: widget.session.userId, + ); + await _refreshMe(); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('头像已更新'))); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _editName() async { + final c = TextEditingController(text: widget.session.displayName); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('修改姓名'), + content: TextField(controller: c, decoration: const InputDecoration(hintText: '显示名')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('保存')), + ], + ), + ); + if (ok != true || c.text.trim().isEmpty) return; + try { + final me = await widget.api.patch('/auth/profile', {'displayName': c.text.trim()}); + if (me is Map) await widget.session.patchUser(Map.from(me)); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已保存'))); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); + } + } + + Future _editPassword() async { + final oldC = TextEditingController(); + final newC = TextEditingController(); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('修改密码'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: oldC, obscureText: true, decoration: const InputDecoration(hintText: '当前密码')), + const SizedBox(height: 8), + TextField(controller: newC, obscureText: true, decoration: const InputDecoration(hintText: '新密码(至少 6 位)')), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('保存')), + ], + ), + ); + if (ok != true) return; + try { + await widget.api.patch('/auth/password', {'oldPassword': oldC.text, 'newPassword': newC.text}); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('密码已更新'))); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); + } + } + + @override + Widget build(BuildContext context) { + final s = widget.session; + final avatarId = '${s.user['avatarFileId'] ?? ''}'; + return Scaffold( + backgroundColor: kPaper, + appBar: AppBar(title: const Text('个人资料')), + body: ListView( + children: [ + const SizedBox(height: 12), + CellGroup( + children: [ + Cell( + title: '头像', + trailing: SquareAvatar( + label: s.displayName, + size: 48, + fileId: avatarId.isEmpty ? null : avatarId, + api: widget.api, + ), + onTap: _busy ? null : _pickAvatar, + ), + Cell(title: '姓名', subtitle: s.displayName, onTap: _editName), + Cell(title: '账号', subtitle: '${s.user['username'] ?? ''}'), + Cell( + title: '角色', + subtitle: s.roles.map(zh).join(' · '), + showLine: false, + ), + ], + ), + CellGroup( + children: [ + Cell(title: '修改密码', onTap: _editPassword, showLine: false), + ], + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/pages/record_detail_page.dart b/apps/native/lib/pages/record_detail_page.dart new file mode 100644 index 0000000..d424541 --- /dev/null +++ b/apps/native/lib/pages/record_detail_page.dart @@ -0,0 +1,401 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../labels.dart'; +import '../nav/biz_route.dart'; +import '../theme.dart'; +import '../widgets/wecom.dart'; +import 'pick_people_page.dart'; + +void openRecord(BuildContext context, OaClient api, Map row, + {String? title}) { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => RecordDetailPage( + api: api, seed: row, title: title ?? detailTitleOf(row)), + )); +} + +class RecordDetailPage extends StatefulWidget { + const RecordDetailPage( + {super.key, required this.api, required this.seed, required this.title}); + final OaClient api; + final Map seed; + final String title; + + @override + State createState() => _RecordDetailPageState(); +} + +class _RecordDetailPageState extends State { + Map _row = {}; + bool _loading = true; + String _err = ''; + + @override + void initState() { + super.initState(); + _row = Map.from(widget.seed); + _load(); + } + + Future _load() async { + final path = fetchPathOf(widget.seed); + if (path == null) { + setState(() => _loading = false); + return; + } + try { + final data = await widget.api.get(path); + if (!mounted) return; + if (data is Map) { + setState(() { + _row = {...widget.seed, ...Map.from(data)}; + _loading = false; + _err = ''; + }); + } else { + setState(() => _loading = false); + } + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _err = '$e'; + }); + } + } + + Future _forward() async { + final combined = {...widget.seed, ..._row}; + final path = fetchPathOf(combined); + if (path == null || !path.startsWith('/')) { + showWxToast(context, '当前信息暂不支持转发', error: true); + return; + } + final picked = await Navigator.of(context) + .push>>(MaterialPageRoute( + builder: (_) => PickPeoplePage( + api: widget.api, + title: '转发给', + multiple: true, + exclude: {widget.api.session.userId}, + ), + )); + if (picked == null || picked.isEmpty || !mounted) return; + final visible = flattenRecord(combined) + .where((e) => e.$2.trim().isNotEmpty) + .take(2) + .map((e) => '${e.$1}:${e.$2}') + .join(' · '); + try { + for (final person in picked) { + await widget.api.post('/im/messages', { + 'peerId': '${person['id'] ?? ''}', + 'body': widget.title, + 'contentType': 'business', + 'meta': { + 'title': widget.title, + 'summary': visible, + 'fetchPath': path, + 'recordId': '${combined['id'] ?? combined['bizId'] ?? ''}', + 'bizType': '${combined['bizType'] ?? combined['source'] ?? ''}', + }, + }); + } + if (mounted) showWxToast(context, '已转发给 ${picked.length} 人'); + } catch (e) { + if (mounted) showWxToast(context, '$e', error: true); + } + } + + Future _act(String label, Future Function() run) async { + try { + await run(); + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(label))); + await _load(); + } + } catch (e) { + if (mounted) + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + + Future _decide(String path, String result) async { + final c = TextEditingController(); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(result == 'APPROVED' ? '通过' : '驳回'), + content: TextField( + controller: c, + decoration: const InputDecoration(hintText: '意见(可选)')), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消')), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('确认')), + ], + ), + ); + if (ok != true) return; + await _act('已提交', () async { + await widget.api.post(path, { + 'result': result, + if (c.text.trim().isNotEmpty) 'comment': c.text.trim(), + }); + }); + } + + List get _actions { + final id = '${_row['id'] ?? widget.seed['id'] ?? ''}'; + final bizId = '${_row['bizId'] ?? ''}'; + final status = '${_row['status'] ?? ''}'; + final source = '${_row['source'] ?? ''}'; + final kind = '${_row['kind'] ?? ''}'; + final biz = '${_row['bizType'] ?? ''}'; + final actions = _row['myActions'] is Map + ? Map.from(_row['myActions'] as Map) + : {}; + final out = []; + + if (status == 'DRAFT' && + (source == 'EXPENSE' && kind != 'LOAN' || + _row.containsKey('claimNo'))) { + out.add(FilledButton( + onPressed: () => + _act('已提交审批', () => widget.api.post('/expenses/$id/submit')), + child: const Text('提交报销'))); + } + if (status == 'DRAFT' && (kind == 'LOAN' || _row.containsKey('loanNo'))) { + out.add(FilledButton( + onPressed: () => + _act('已提交审批', () => widget.api.post('/loans/$id/submit')), + child: const Text('提交借款'))); + } + if (biz.isNotEmpty && + status == 'PENDING' && + id.isNotEmpty && + widget.seed['assigneeId'] != null) { + out.add(FilledButton( + onPressed: () => _decide('/office/approvals/$id/decide', 'APPROVED'), + child: const Text('通过'))); + out.add(OutlinedButton( + onPressed: () => _decide('/office/approvals/$id/decide', 'REJECTED'), + child: const Text('驳回'))); + } + if (source == 'OFFICE' && status == 'PENDING') { + out.add(FilledButton( + onPressed: () => _decide('/office/applies/$id/decide', 'APPROVED'), + child: const Text('通过'))); + out.add(OutlinedButton( + onPressed: () => _decide('/office/applies/$id/decide', 'REJECTED'), + child: const Text('驳回'))); + } + if ((biz == 'HR_LEAVE' || source == 'HR') && + status == 'PENDING' && + (bizId.isNotEmpty || id.isNotEmpty)) { + final hid = bizId.isNotEmpty ? bizId : id; + out.add(FilledButton( + onPressed: () => _decide('/leave-requests/$hid/review', 'APPROVED'), + child: const Text('通过'))); + out.add(OutlinedButton( + onPressed: () => _decide('/leave-requests/$hid/review', 'REJECTED'), + child: const Text('驳回'))); + } + if (actions['canReview'] == true && id.isNotEmpty) { + out.add(FilledButton( + onPressed: () => _decide('/bid-cases/$id/reviews', 'APPROVED'), + child: const Text('审批通过'))); + out.add(OutlinedButton( + onPressed: () => _decide('/bid-cases/$id/reviews', 'REJECTED'), + child: const Text('审批驳回'))); + } + if (actions['canTerminate'] == true && id.isNotEmpty) { + out.add(OutlinedButton( + onPressed: () async { + final c = TextEditingController(); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('终止投标'), + content: TextField( + controller: c, + maxLines: 3, + decoration: + const InputDecoration(hintText: '请填写终止原因')), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消')), + FilledButton( + onPressed: () => + Navigator.pop(ctx, c.text.trim().isNotEmpty), + child: const Text('确认')) + ])); + if (ok == true) + await _act( + '已终止', + () => widget.api.post( + '/bid-cases/$id/terminate', {'comment': c.text.trim()})); + }, + child: const Text('终止投标'), + )); + } + if ('${_row['status']}' == 'OPEN' && + _row['assigneeId'] != null && + !_row.containsKey('source')) { + out.add(FilledButton( + onPressed: () => _act('已办结', + () => widget.api.patch('/office/todos/$id', {'status': 'DONE'})), + child: const Text('标为已办'))); + } + return out; + } + + @override + Widget build(BuildContext context) { + final pairs = flattenRecord(_row); + return Scaffold( + backgroundColor: kPaper, + appBar: AppBar( + title: Text(widget.title, maxLines: 1, overflow: TextOverflow.ellipsis), + actions: [ + IconButton( + tooltip: '转发给同事', + onPressed: _loading ? null : _forward, + icon: const Icon(Icons.forward_to_inbox_outlined), + ), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.fromLTRB(0, 12, 0, 32), + children: [ + if (_err.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text(_err, + style: const TextStyle(color: kMute, fontSize: 12)), + ), + CellGroup( + children: [ + for (var i = 0; i < pairs.length; i++) + _kv(pairs[i].$1, pairs[i].$2, i == pairs.length - 1), + ], + ), + if (_actions.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Wrap(spacing: 8, runSpacing: 8, children: _actions), + ), + ], + ), + ); + } + + Widget _kv(String k, String v, bool last) { + final isStatus = k == '状态'; + return Cell( + title: k, + subtitle: isStatus ? null : v, + trailing: + isStatus ? StatusDot(widget.seed['status'] ?? _row['status']) : null, + showLine: !last, + ); + } +} + +final _uuid = RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'); + +const _skip = { + 'id', + 'tenantId', + 'applicantId', + 'assigneeId', + 'createdById', + 'decidedById', + 'managerId', + 'employeeId', + 'bizId', + 'path', + 'bucket', + '_fetch', + 'parentId', + 'departmentId', + 'userId', + 'roleIds', + 'managerIds', + 'accounts', + 'users', + 'excerpt', + 'legalEntityId', + 'bidCaseId', + 'partyId', + 'fingerprint', + 'meta', + 'actionLogs', + 'projects', + 'reviewers', + 'assignees', + 'versions', + 'files', + 'logs', +}; + +bool _skipKey(String k, String key, dynamic v) { + if (_skip.contains(k) || _skip.contains(key)) return true; + if (key.startsWith('can') && (v is bool || v == 'true' || v == 'false')) + return true; + if (key.endsWith('Id') || key.endsWith('Ids')) return true; + return false; +} + +List<(String, String)> flattenRecord(Map row) { + final out = <(String, String)>[]; + void add(String k, dynamic v, [int depth = 0]) { + if (v == null) return; + final key = k.contains('.') ? k.split('.').last : k; + if (_skipKey(k, key, v)) return; + if (k == 'content' && '$v'.contains('<')) return; + final label = fieldLabel(k); + if (label.isEmpty) return; + if (v is Map) { + final name = + v['displayName'] ?? v['name'] ?? v['title'] ?? v['departmentName']; + if (name != null && + '$name'.trim().isNotEmpty && + !'$name'.startsWith('{')) { + out.add((label, zh(name))); + return; + } + if (depth > 0) return; + v.forEach((ck, cv) => add('$k.$ck', cv, depth + 1)); + return; + } + if (v is List) { + if (v.isEmpty) return; + out.add((label, '${v.length} 项')); + return; + } + final raw = '$v'.trim(); + if (raw.isEmpty || raw == 'null') return; + if (raw.startsWith('{') && raw.contains('id:')) return; + if (_uuid.hasMatch(raw)) return; + final s = v is DateTime + ? fmtTime(v.toIso8601String()) + : (k.endsWith('At') || k.contains('Time') ? fmtTime(v) : zh(v)); + if (s == '—' || s.isEmpty) return; + if (RegExp(r'^[A-Z][A-Z0-9_]+$').hasMatch(s) && zh(s) == s) return; + out.add((label, s)); + } + + row.forEach(add); + return out; +} diff --git a/apps/native/lib/pages/scan_login_page.dart b/apps/native/lib/pages/scan_login_page.dart new file mode 100644 index 0000000..7f3aaff --- /dev/null +++ b/apps/native/lib/pages/scan_login_page.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +import '../api/oa_client.dart'; +import '../theme.dart'; + +class ScanLoginPage extends StatefulWidget { + const ScanLoginPage({super.key, required this.api, this.ticket = ''}); + final OaClient api; + final String ticket; + + @override + State createState() => _ScanLoginPageState(); +} + +class _ScanLoginPageState extends State { + late final TextEditingController _ticket; + final MobileScannerController _scanner = MobileScannerController(); + bool _loading = false; + bool _scanned = false; + String _err = ''; + String _code = ''; + + @override + void initState() { + super.initState(); + _ticket = TextEditingController(text: widget.ticket); + } + + @override + void dispose() { + _ticket.dispose(); + _scanner.dispose(); + super.dispose(); + } + + String _ticketFrom(String raw) { + final value = raw.trim(); + final uri = Uri.tryParse(value); + if (uri?.scheme == 'fengyingoa' && uri?.host == 'qr') { + return uri?.queryParameters['ticket']?.trim() ?? ''; + } + return value; + } + + Future _ok([String? scanned]) async { + final t = _ticketFrom(scanned ?? _ticket.text); + if (t.isEmpty) { + setState(() => _err = '请扫描电脑端二维码'); + return; + } + setState(() { + _loading = true; + _err = ''; + }); + try { + final raw = await widget.api.post('/auth/qr/confirm', {'ticket': t}); + final map = + raw is Map ? Map.from(raw) : {}; + if (!mounted) return; + setState(() { + _ticket.text = t; + _code = '${map['code'] ?? ''}'; + _scanned = true; + }); + await _scanner.stop(); + } catch (e) { + if (mounted) setState(() => _err = '$e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _onDetect(BarcodeCapture capture) async { + if (_loading || _scanned) return; + final raw = capture.barcodes + .map((b) => b.rawValue?.trim() ?? '') + .firstWhere((v) => v.isNotEmpty, orElse: () => ''); + if (raw.isEmpty) return; + await _ok(raw); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar(title: const Text('扫一扫登录电脑')), + body: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_code.isEmpty) ...[ + const Text('扫描电脑端二维码后,手机会生成一次性六位登录码。请把该六码填写到电脑端,电脑才会完成登录。', + style: TextStyle(color: kMute, height: 1.5)), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: AspectRatio( + aspectRatio: 1, + child: + MobileScanner(controller: _scanner, onDetect: _onDetect), + ), + ), + ] else ...[ + const Text('请在电脑端输入以下一次性六位登录码:', + style: TextStyle(color: kMute, height: 1.5)), + const SizedBox(height: 24), + Center( + child: SelectableText( + _code, + style: const TextStyle( + fontSize: 42, + fontWeight: FontWeight.w700, + letterSpacing: 12, + color: kInk), + ), + ), + const SizedBox(height: 12), + const Center( + child: Text('两分钟内有效;不要把此码发给他人。', + style: TextStyle(color: kMute))), + const SizedBox(height: 24), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('我已在电脑端输入'), + ), + ], + const SizedBox(height: 16), + if (_code.isEmpty) + TextField( + controller: _ticket, + decoration: const InputDecoration(hintText: '扫码异常时,可粘贴二维码内容'), + textCapitalization: TextCapitalization.characters, + ), + if (_err.isNotEmpty) ...[ + const SizedBox(height: 12), + Text(_err, style: const TextStyle(color: kDanger)), + ], + if (_code.isEmpty) ...[ + const SizedBox(height: 20), + FilledButton( + onPressed: _loading ? null : _ok, + child: Text(_loading ? '确认中…' : '确认扫码'), + ), + ], + ], + ), + ), + ); + } +} diff --git a/apps/native/lib/pages/system_notice_page.dart b/apps/native/lib/pages/system_notice_page.dart new file mode 100644 index 0000000..c0196d0 --- /dev/null +++ b/apps/native/lib/pages/system_notice_page.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../im/chat_local_store.dart'; +import '../nav/notice_route.dart'; +import '../session/session.dart'; +import '../theme.dart'; +import '../widgets/wecom.dart'; + +class SystemNoticePage extends StatefulWidget { + const SystemNoticePage( + {super.key, + required this.session, + required this.api, + this.conversationId}); + final SessionStore session; + final OaClient api; + final String? conversationId; + @override + State createState() => _SystemNoticePageState(); +} + +class _SystemNoticePageState extends State { + List> _items = []; + bool _loading = true; + + Map _metaOf(Map row) { + final raw = row['meta']; + if (raw is Map) return Map.from(raw); + if (raw is String && raw.isNotEmpty) { + try { + final decoded = jsonDecode(raw); + if (decoded is Map) return Map.from(decoded); + } catch (_) {} + } + return {}; + } + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final raw = await widget.api.get('/im/messages', query: { + 'peerId': '0', + if (widget.conversationId != null) + 'conversationId': widget.conversationId!, + }); + final remote = asMaps(raw); + final cid = widget.conversationId ?? + (raw is Map ? '${raw['conversationId'] ?? ''}' : ''); + final local = await ChatLocalStore.load(cid); + final byKey = >{}; + for (final e in [...local, ...remote]) { + final fp = '${e['fingerprint'] ?? ''}'; + byKey[fp.isNotEmpty ? 'fp:$fp' : 'id:${e['id'] ?? ''}'] = e; + } + final merged = byKey.values.toList() + ..sort((a, b) => + '${a['createdAt'] ?? ''}'.compareTo('${b['createdAt'] ?? ''}')); + if (!mounted) return; + setState(() { + _items = merged; + _loading = false; + }); + await ChatLocalStore.save(cid, merged); + if (widget.conversationId != null) { + await widget.api + .post('/im/read', {'conversationId': widget.conversationId}); + } + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF5F6F8), + appBar: AppBar(title: const Text('系统通知'), backgroundColor: Colors.white), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _load, + child: ListView.builder( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 28), + itemCount: _items.isEmpty ? 1 : _items.length, + itemBuilder: (context, index) { + if (_items.isEmpty) + return const Padding( + padding: EdgeInsets.only(top: 100), + child: Center( + child: Text('暂无系统通知', + style: TextStyle(color: kMute)))); + final r = _items[index]; + final title = '${r['title'] ?? r['body'] ?? '系统通知'}'; + final body = '${r['body'] ?? ''}'; + final time = '${r['createdAt'] ?? ''}' + .replaceFirst('T', ' ') + .replaceFirst(RegExp(r'\.\d+Z$'), ''); + return Card( + elevation: 0, + margin: const EdgeInsets.only(bottom: 10), + child: InkWell( + onTap: () { + final meta = _metaOf(r); + openNoticeTarget( + context, + widget.api, + session: widget.session, + kind: '${meta['kind'] ?? r['kind'] ?? 'todo'}', + bizType: '${meta['bizType'] ?? ''}', + bizId: '${meta['bizId'] ?? ''}', + title: + '${meta['title'] ?? r['title'] ?? r['body'] ?? '系统通知'}', + ); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SquareAvatar( + label: '系', + color: Color(0xFF07C160), + icon: Icons.notifications, + size: 40), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600)), + if (body.isNotEmpty && body != title) ...[ + const SizedBox(height: 5), + Text(body, + style: const TextStyle(color: kMute)) + ], + const SizedBox(height: 7), + Text(time, + style: const TextStyle( + fontSize: 12, color: kMute)), + ])), + ]), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/apps/native/lib/pages/todos_page.dart b/apps/native/lib/pages/todos_page.dart new file mode 100644 index 0000000..ff6a15d --- /dev/null +++ b/apps/native/lib/pages/todos_page.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../labels.dart'; +import '../pages/record_detail_page.dart'; +import '../widgets/common.dart'; + +class TodosPage extends StatefulWidget { + const TodosPage({super.key, required this.api, this.embedded = false}); + final OaClient api; + final bool embedded; + + @override + State createState() => _TodosPageState(); +} + +class _TodosPageState extends State { + List> _items = []; + String _bucket = 'OPEN'; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final data = await widget.api.get('/office/todos'); + if (!mounted) return; + setState(() { + _items = asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final filtered = _bucket.isEmpty ? _items : _items.where((e) => '${e['status']}' == _bucket).toList(); + final body = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row( + children: [ + for (final it in [('OPEN', '待办'), ('DONE', '已办'), ('', '全部')]) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(it.$2), + selected: _bucket == it.$1, + showCheckmark: false, + visualDensity: VisualDensity.compact, + onSelected: (_) => setState(() => _bucket = it.$1), + ), + ), + ], + ), + ), + if (_loading) const LinearProgressIndicator(minHeight: 2), + Expanded( + child: filtered.isEmpty + ? const EmptyHint('没有待办') + : RefreshIndicator( + onRefresh: _load, + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (_, i) { + final r = filtered[i]; + return KvTile( + title: '${r['title'] ?? ''}', + subtitle: '${zh(r['bizType'])} ${fmtTime(r['dueAt'] ?? r['createdAt'])}', + status: r['status'], + onTap: () => openRecord(context, widget.api, r), + ); + }, + ), + ), + ), + ], + ); + if (widget.embedded) return body; + return Scaffold(appBar: AppBar(title: const Text('待办事项')), body: body); + } +} diff --git a/apps/native/lib/pages/work_assign_page.dart b/apps/native/lib/pages/work_assign_page.dart new file mode 100644 index 0000000..8adb21f --- /dev/null +++ b/apps/native/lib/pages/work_assign_page.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../session/session.dart'; +import '../widgets/common.dart'; +import 'record_detail_page.dart'; + +class WorkAssignPage extends StatefulWidget { + const WorkAssignPage({super.key, required this.session, required this.api}); + final SessionStore session; + final OaClient api; + + @override + State createState() => _WorkAssignPageState(); +} + +class _WorkAssignPageState extends State { + List> _items = []; + bool _loading = true; + + bool get _canAssign { + const codes = ['admin', 'owner', 'biz_director', 'tech_director', 'rd_director', '3d_director', 'video_director', 'material_director', 'pm']; + return widget.session.roles.any(codes.contains); + } + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final data = await widget.api.get('/office/work-assignments'); + if (!mounted) return; + setState(() { + _items = asMaps(data); + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + Future _create() async { + final staff = asMaps(await widget.api.get('/staff')); + if (!mounted) return; + final title = TextEditingController(); + final content = TextEditingController(); + String? assignee; + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSt) => AlertDialog( + title: const Text('安排工作'), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: title, decoration: const InputDecoration(labelText: '标题')), + const SizedBox(height: 8), + TextField(controller: content, decoration: const InputDecoration(labelText: '内容'), maxLines: 3), + const SizedBox(height: 8), + DropdownButtonFormField( + decoration: const InputDecoration(labelText: '执行人'), + items: [ + for (final s in staff) + DropdownMenuItem(value: '${s['id']}', child: Text('${s['name'] ?? ''} ${s['department'] ?? ''}')), + ], + onChanged: (v) => setSt(() => assignee = v), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('下达')), + ], + ), + ), + ); + if (ok != true) return; + try { + await widget.api.post('/office/work-assignments', { + 'title': title.text.trim(), + 'content': content.text.trim(), + 'assigneeId': assignee, + }); + await _load(); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('工作安排'), + actions: [ + if (_canAssign) IconButton(onPressed: _create, icon: const Icon(Icons.add)), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _items.isEmpty + ? const EmptyHint('没有工作安排') + : RefreshIndicator( + onRefresh: _load, + child: ListView( + children: [ + for (final r in _items) + KvTile( + title: '${r['title'] ?? ''}', + subtitle: '${r['assignee'] is Map ? r['assignee']['displayName'] : ''} ${fmtTime(r['dueAt'] ?? r['createdAt'])}', + status: r['status'], + onTap: () => openRecord(context, widget.api, r), + ), + ], + ), + ), + ); + } +} diff --git a/apps/native/lib/pages/workbench_page.dart b/apps/native/lib/pages/workbench_page.dart new file mode 100644 index 0000000..d756efa --- /dev/null +++ b/apps/native/lib/pages/workbench_page.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../nav/open_module.dart'; +import '../ota/updater.dart'; +import '../pages/approvals_page.dart'; +import '../pages/attendance_page.dart'; +import '../pages/calendar_page.dart'; +import '../pages/flow_page.dart'; +import '../pages/hr_apply_page.dart'; +import '../pages/todos_page.dart'; +import '../pages/work_assign_page.dart'; +import '../session/session.dart'; +import '../theme.dart'; +import '../widgets/wecom.dart'; + +class WorkbenchPage extends StatefulWidget { + const WorkbenchPage({super.key, required this.session, required this.api, this.embedded = true}); + final SessionStore session; + final OaClient api; + final bool embedded; + + @override + State createState() => _WorkbenchPageState(); +} + +class _WorkbenchPageState extends State { + Map _ov = {}; + String _greet = ''; + AppRelease? _rel; + bool _loading = true; + String _q = ''; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final ov = await widget.api.get('/office/overview'); + String greet = ''; + try { + final w = await widget.api.get('/office/weather'); + if (w is Map) greet = '${w['greeting'] ?? w['text'] ?? ''}'; + } catch (_) {} + AppRelease? rel; + try { + rel = await OtaUpdater(widget.api).fetch(); + } catch (_) {} + if (!mounted) return; + setState(() { + _ov = Map.from(ov as Map? ?? {}); + _greet = greet; + _rel = rel; + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + bool _match(String name) => _q.isEmpty || name.contains(_q); + + bool _skipGroup(MenuNode m) { + if (m.name == '工作台' || m.name == '个人办公' || m.code == 'office:overview') return true; + if (m.name == '系统设置' || m.code.startsWith('system')) return true; + return false; + } + + bool _skipChild(MenuNode n) { + const names = { + '待办', + '待审批', + '我的申请', + '日程', + '公告', + '公司公告', + '工作安排', + '人事申请', + '工作台', + '个人办公', + '消息', + '通讯录', + '员工通讯', + '发送短信', + '人事设置', + '数据字典', + '权限设置', + '编号规则', + '短信服务', + '消息通道', + '手机端升级', + '操作日志', + '系统设置', + }; + if (n.code.startsWith('office:')) return true; + if (n.code.startsWith('system')) return true; + if (n.code.contains('sms') || n.name.contains('短信')) return true; + if (n.name.contains('公告') || n.name == '员工通讯') return true; + return names.contains(n.name); + } + + @override + Widget build(BuildContext context) { + final body = RefreshIndicator( + onRefresh: _load, + child: ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind), + if (_rel != null && _rel!.newer) + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: Material( + color: const Color(0xFFE8F3FF), + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: () => OtaUpdater(widget.api).prompt(context, _rel!, manual: true), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + child: Row( + children: [ + const Icon(Icons.system_update_alt, color: kBind), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('发现新版本 ${_rel!.version}', style: const TextStyle(fontWeight: FontWeight.w600, color: kInk)), + Text(_rel!.changelog.isEmpty ? '点击升级到最新手机版' : _rel!.changelog, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: kMute)), + ], + ), + ), + const Text('升级', style: TextStyle(color: kBind, fontWeight: FontWeight.w600)), + ], + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_greet.isEmpty ? '你好,${widget.session.displayName}' : _greet, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: kInk)), + const SizedBox(height: 4), + const Text('工作台', style: TextStyle(color: kMute, fontSize: 13)), + ], + ), + ), + GroupCard( + title: '待处理', + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 14), + child: Row( + children: [ + _stat('待办', '${_ov['pendingTodos'] ?? 0}', () => pushPage(context, TodosPage(api: widget.api))), + _stat('待审批', '${_ov['pendingApprovals'] ?? 0}', () => pushPage(context, ApprovalsPage(api: widget.api))), + _stat('我的申请', '${_ov['myPending'] ?? 0}', () => pushPage(context, FlowPage(api: widget.api))), + _stat('逾期', '${_ov['overdueTodos'] ?? 0}', () => pushPage(context, TodosPage(api: widget.api))), + ], + ), + ), + ), + if (_match('待办') || _match('审批') || _match('申请') || _match('日程') || _match('人事')) + GroupCard( + title: '个人办公', + child: AppGrid( + items: [ + if (_match('待办')) + AppGridItem(label: '待办', icon: Icons.task_alt, color: const Color(0xFFFA9D3B), onTap: () => pushPage(context, TodosPage(api: widget.api))), + if (_match('审批')) + AppGridItem(label: '待审批', icon: Icons.fact_check, color: kBind, onTap: () => pushPage(context, ApprovalsPage(api: widget.api))), + if (_match('申请')) + AppGridItem(label: '我的申请', icon: Icons.assignment_outlined, color: const Color(0xFF6267F2), onTap: () => pushPage(context, FlowPage(api: widget.api))), + if (_match('日程')) + AppGridItem(label: '日程', icon: Icons.calendar_month, color: const Color(0xFF10AEFF), onTap: () => pushPage(context, CalendarPage(api: widget.api))), + if (_match('安排')) + AppGridItem(label: '工作安排', icon: Icons.event_note, color: const Color(0xFF00B578), onTap: () => pushPage(context, WorkAssignPage(session: widget.session, api: widget.api))), + if (_match('打卡') || _match('考勤')) + AppGridItem(label: '考勤打卡', icon: Icons.access_time_filled, color: const Color(0xFF267EF0), onTap: () => pushPage(context, AttendancePage(api: widget.api))), + if (_match('人事') || _match('请假') || _match('加班')) + AppGridItem(label: '人事申请', icon: Icons.beach_access, color: const Color(0xFF8B5CF6), onTap: () => pushPage(context, HrApplyPage(api: widget.api))), + ], + ), + ), + ..._menuGroups(), + ], + ), + ); + if (!widget.embedded) { + return Scaffold(appBar: AppBar(title: const Text('工作台')), body: body); + } + return ColoredBox( + color: kPaper, + child: Column( + children: [ + WxHeader( + title: '工作台', + actions: [ + IconButton( + onPressed: () async { + final c = TextEditingController(text: _q); + final v = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('搜索应用'), + content: TextField(controller: c, autofocus: true, decoration: const InputDecoration(hintText: '待办、投标、报销…')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, ''), child: const Text('清除')), + FilledButton(onPressed: () => Navigator.pop(ctx, c.text.trim()), child: const Text('搜索')), + ], + ), + ); + if (v != null) setState(() => _q = v); + }, + icon: const Icon(Icons.search, color: kInk), + ), + ], + ), + Expanded(child: body), + ], + ), + ); + } + + List _menuGroups() { + final out = []; + for (final m in widget.session.menus) { + if (_skipGroup(m)) continue; + if (m.children.isNotEmpty) { + final items = []; + for (var i = 0; i < m.children.length; i++) { + final c = m.children[i]; + if (_skipChild(c)) continue; + if (!(_q.isEmpty || c.name.contains(_q) || m.name.contains(_q))) continue; + items.add(AppGridItem( + label: c.name, + icon: iconFor(c.code, c.name), + color: colorFor(c.code, c.name, i), + onTap: () => openMenuNode(context, widget.session, widget.api, c), + )); + } + if (items.isEmpty) continue; + out.add(GroupCard(title: m.name, child: AppGrid(items: items))); + } else if (!_skipChild(m) && _match(m.name)) { + out.add(GroupCard( + title: m.name, + child: AppGrid(items: [ + AppGridItem( + label: m.name, + icon: iconFor(m.code, m.name), + color: colorFor(m.code, m.name), + onTap: () => openMenuNode(context, widget.session, widget.api, m), + ), + ]), + )); + } + } + return out; + } + + Widget _stat(String label, String value, VoidCallback onTap) { + return Expanded( + child: InkWell( + onTap: onTap, + child: Column( + children: [ + Text(value, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kBind)), + const SizedBox(height: 2), + Text(label, style: const TextStyle(fontSize: 12, color: kMute)), + ], + ), + ), + ); + } +} diff --git a/apps/native/lib/session/session.dart b/apps/native/lib/session/session.dart new file mode 100644 index 0000000..75b4612 --- /dev/null +++ b/apps/native/lib/session/session.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../app_config.dart'; +import '../im/im_bridge.dart'; +import '../im/tcp_client.dart'; + +class MenuNode { + MenuNode({required this.name, required this.code, this.path, this.icon, this.children = const []}); + final String name; + final String code; + final String? path; + final String? icon; + final List children; + + factory MenuNode.fromJson(Map j) { + final kids = (j['children'] as List? ?? []).whereType().map((e) => MenuNode.fromJson(Map.from(e))).toList(); + return MenuNode( + name: '${j['name'] ?? ''}', + code: '${j['code'] ?? ''}', + path: j['path']?.toString(), + icon: j['icon']?.toString(), + children: kids, + ); + } +} + +class SessionStore extends ChangeNotifier { + String apiBase = AppConfig.defaultApiBase; + String imHost = AppConfig.defaultImHost; + int imPort = AppConfig.defaultImPort; + String accessToken = ''; + String refreshToken = ''; + Map user = {}; + List menus = []; + bool loaded = false; + final ImTcpClient im = ImTcpClient(); + final ImBridge bridge = ImBridge(); + + String get userId => '${user['id'] ?? ''}'; + String get displayName => '${user['displayName'] ?? user['username'] ?? ''}'; + List get roles => (user['roles'] as List? ?? []).map((e) => '$e').toList(); + List get permissions => (user['permissions'] as List? ?? []).map((e) => '$e').toList(); + bool get signedIn => accessToken.isNotEmpty && userId.isNotEmpty; + + bool canSee(String code) { + if (roles.contains('admin') || roles.contains('owner')) return true; + return permissions.contains(code) || permissions.any((p) => p.startsWith('$code:')); + } + + Future load() async { + final p = await SharedPreferences.getInstance(); + apiBase = p.getString('apiBase') ?? AppConfig.defaultApiBase; + imHost = p.getString('imHost') ?? AppConfig.defaultImHost; + imPort = p.getInt('imPort') ?? AppConfig.defaultImPort; + accessToken = p.getString('accessToken') ?? ''; + refreshToken = p.getString('refreshToken') ?? ''; + final u = p.getString('user'); + if (u != null && u.isNotEmpty) { + try { + user = Map.from(jsonDecode(u) as Map); + } catch (_) { + user = {}; + } + } + loaded = true; + notifyListeners(); + } + + Future saveEndpoints({required String api, required String host, required int port}) async { + apiBase = api.trim().isEmpty ? AppConfig.defaultApiBase : api.trim(); + imHost = host.trim().isEmpty ? AppConfig.defaultImHost : host.trim(); + imPort = port <= 0 ? AppConfig.defaultImPort : port; + final p = await SharedPreferences.getInstance(); + await p.setString('apiBase', apiBase); + await p.setString('imHost', imHost); + await p.setInt('imPort', imPort); + notifyListeners(); + } + + Future applyLogin(Map data) async { + accessToken = '${data['accessToken'] ?? ''}'; + refreshToken = '${data['refreshToken'] ?? ''}'; + user = Map.from(data['user'] as Map? ?? {}); + final p = await SharedPreferences.getInstance(); + await p.setString('accessToken', accessToken); + await p.setString('refreshToken', refreshToken); + await p.setString('user', jsonEncode(user)); + notifyListeners(); + await connectIm(); + } + + Future setTokens(String access, String refresh) async { + final changed = access != accessToken; + accessToken = access; + refreshToken = refresh; + final p = await SharedPreferences.getInstance(); + await p.setString('accessToken', access); + await p.setString('refreshToken', refresh); + notifyListeners(); + if (changed && signedIn) await connectIm(force: true); + } + + Future patchUser(Map next) async { + user = next; + final p = await SharedPreferences.getInstance(); + await p.setString('user', jsonEncode(user)); + notifyListeners(); + } + + Future setMenus(List items) async { + menus = items; + notifyListeners(); + } + + Future connectIm({bool force = false}) async { + if (!signedIn) return; + if (force) im.disconnect(); + await bridge.init(); + await im.connect( + host: imHost, + port: imPort, + userId: userId, + token: accessToken, + ); + } + + Future clear() async { + im.disconnect(); + await bridge.release(); + accessToken = ''; + refreshToken = ''; + user = {}; + menus = []; + final p = await SharedPreferences.getInstance(); + await p.remove('accessToken'); + await p.remove('refreshToken'); + await p.remove('user'); + notifyListeners(); + } + + @override + void dispose() { + im.disconnect(); + im.dispose(); + super.dispose(); + } +} diff --git a/apps/native/lib/shell/desktop_shell.dart b/apps/native/lib/shell/desktop_shell.dart new file mode 100644 index 0000000..8a12dc2 --- /dev/null +++ b/apps/native/lib/shell/desktop_shell.dart @@ -0,0 +1 @@ +export '../desktop/desk_shell.dart'; diff --git a/apps/native/lib/shell/home_shell.dart b/apps/native/lib/shell/home_shell.dart new file mode 100644 index 0000000..c669154 --- /dev/null +++ b/apps/native/lib/shell/home_shell.dart @@ -0,0 +1,405 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../device/push_bridge.dart'; +import '../ota/updater.dart'; +import '../nav/notice_route.dart'; +import '../pages/call_page.dart'; +import '../pages/attendance_page.dart'; +import '../pages/chat_page.dart'; +import '../pages/directory_page.dart'; +import '../pages/messages_page.dart'; +import '../pages/mine_page.dart'; +import '../pages/scan_login_page.dart'; +import '../pages/system_notice_page.dart'; +import '../pages/workbench_page.dart'; +import '../session/session.dart'; +import '../im/im_lifecycle.dart'; +import '../desktop/desk_platform.dart'; +import '../desktop/desk_shell.dart'; +import '../theme.dart'; +import '../widgets/wecom.dart'; + +class HomeShell extends StatefulWidget { + const HomeShell({super.key, required this.session, required this.api}); + final SessionStore session; + final OaClient api; + + @override + State createState() => _HomeShellState(); +} + +class _HomeShellState extends State { + int _tab = 0; + int _msgBadge = 0; + bool _otaOnce = false; + Timer? _badgeDebounce; + Timer? _callPoll; + String _ringingId = ''; + bool _checkingCall = false; + final _declinedCalls = {}; + late List _pages; + ImLifecycle? _imLife; + + @override + void initState() { + super.initState(); + _imLife = ImLifecycle(widget.session)..start(); + widget.session.im.onLoggedIn = () { + _refreshBadge(); + }; + _pages = [ + MessagesPage(session: widget.session, api: widget.api), + WorkbenchPage(session: widget.session, api: widget.api), + AttendancePage(api: widget.api), + DirectoryPage(session: widget.session, api: widget.api, embedded: true), + MinePage(session: widget.session, api: widget.api), + ]; + widget.session.im.addListener(_onIm); + _refreshBadge(); + PushBridge.listen(_openFromPush); + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkOta(); + _bootPush(); + _checkIncoming(); + }); + _callPoll = + Timer.periodic(const Duration(seconds: 8), (_) => _checkIncoming()); + } + + @override + void dispose() { + _badgeDebounce?.cancel(); + _callPoll?.cancel(); + widget.session.im.onLoggedIn = null; + _imLife?.stop(); + widget.session.im.removeListener(_onIm); + PushBridge.stop(); + super.dispose(); + } + + void _onIm() { + final inbox = widget.session.im.inbox; + if (inbox.isNotEmpty) { + final last = inbox.first; + if (last.kind == 'readSync') { + _refreshBadge(); + return; + } + } + _badgeDebounce?.cancel(); + _badgeDebounce = Timer(const Duration(milliseconds: 1200), _refreshBadge); + final hit = widget.session.im.inbox.where((e) => e.kind == 'call').toList(); + if (hit.isNotEmpty) _checkIncoming(); + } + + Future _checkIncoming() async { + if (!mounted || _ringingId.isNotEmpty || _checkingCall) return; + _checkingCall = true; + try { + final rows = asMaps(await widget.api.get('/im/calls/incoming')); + final me = widget.session.userId; + Map? ring; + for (final r in rows) { + if ('${r['initiatorId']}' != me && '${r['status']}' == 'ringing') { + ring = r; + break; + } + } + if (ring == null) return; + final id = '${ring['id'] ?? ''}'; + if (id.isEmpty || id == _ringingId || _declinedCalls.contains(id)) return; + final created = DateTime.tryParse('${ring['createdAt'] ?? ''}'); + if (created != null && + DateTime.now().difference(created).inSeconds > 45) { + _declinedCalls.add(id); + return; + } + await _showIncoming(ring); + } catch (_) { + } finally { + _checkingCall = false; + } + } + + Future _showIncoming(Map call, + {String fallbackName = '同事'}) async { + final id = '${call['id'] ?? ''}'; + if (!mounted || + id.isEmpty || + _ringingId.isNotEmpty || + _declinedCalls.contains(id)) return; + _ringingId = id; + await openCallPage( + context, + session: widget.session, + api: widget.api, + call: call, + peerName: '${call['fromName'] ?? call['title'] ?? fallbackName}', + incoming: true, + ); + _declinedCalls.add(id); + if (_declinedCalls.length > 40) _declinedCalls.remove(_declinedCalls.first); + if (mounted) _ringingId = ''; + } + + Future _refreshBadge() async { + var n = 0; + try { + final data = await widget.api.get('/im/conversations'); + for (final r in asMaps(data)) { + n += (r['unread'] as num?)?.toInt() ?? 0; + } + } catch (_) {} + if (mounted && n != _msgBadge) setState(() => _msgBadge = n); + } + + Future _bootPush() async { + if (Platform.isWindows) return; + await PushBridge.requestFirstLoginPermissions(); + final launch = await PushBridge.consumeLaunch(); + if (launch != null && mounted) _openFromPush(launch); + // 通知点击先进入来电页,厂商 token 注册放到后台,不能阻塞接听。 + unawaited(PushBridge.register(widget.api)); + await _promptOem(); + } + + Future _promptOem() async { + final st = await PushBridge.oemStatus(); + if (st == null || !mounted) return; + if (st['prompted'] == true) return; + await PushBridge.requestBattery(); + final title = '${st['title'] ?? '后台运行与通知'}'; + final hint = '${st['hint'] ?? '请允许通知和后台运行,以便及时收到消息。'}'; + final go = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(title), + content: Text(hint), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('稍后')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('去设置')), + ], + ), + ); + await PushBridge.markOemPrompted(); + if (go == true) { + await PushBridge.requestBattery(); + await PushBridge.openOemKeepAlive(); + } + } + + String _openedPush = ''; + + void _openFromPush(Map extras) { + if (extras['kind'] == 'qr-login') { + final ticket = extras['ticket'] ?? ''; + if (ticket.isEmpty) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ScanLoginPage(api: widget.api, ticket: ticket), + ), + ); + return; + } + final cid = extras['conversationId'] ?? ''; + final kind = extras['kind'] ?? ''; + final key = '$cid|$kind|${extras['callId'] ?? extras['bizId'] ?? ''}'; + if (key == _openedPush) return; + _openedPush = key; + if (kind == 'call') { + unawaited(_openIncomingCallFromPush(extras)); + return; + } + if (kind == 'todo' || + kind == 'approval' || + ((extras['bizType'] ?? '').isNotEmpty && kind != 'chat')) { + openNoticeTarget( + context, + widget.api, + session: widget.session, + kind: kind, + bizType: extras['bizType'] ?? '', + bizId: extras['bizId'] ?? '', + title: extras['title'] ?? '系统通知', + ); + return; + } + if (cid.isEmpty) { + if (mounted) setState(() => _tab = 0); + return; + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => extras['peerId'] == '0' + ? SystemNoticePage( + session: widget.session, api: widget.api, conversationId: cid) + : ChatPage( + session: widget.session, + api: widget.api, + conversationId: cid, + peerName: extras['fromName'] ?? extras['title'] ?? '聊天'), + ), + ); + } + + Future _openIncomingCallFromPush(Map extras) async { + final callId = (extras['callId'] ?? '').isNotEmpty + ? extras['callId']! + : (extras['bizId'] ?? ''); + try { + Map? call; + if (callId.isNotEmpty) { + final raw = await widget.api.get('/im/calls/$callId'); + if (raw is Map) { + final row = Map.from(raw); + if ('${row['status'] ?? ''}' == 'ringing' && + '${row['memberStatus'] ?? ''}' == 'invited') { + call = row; + } + } + } else { + final rows = asMaps(await widget.api.get('/im/calls/incoming')); + for (final row in rows) { + if ('${row['status'] ?? ''}' == 'ringing') { + call = row; + break; + } + } + } + if (call != null && mounted) { + call['fromName'] ??= extras['fromName']; + await _showIncoming(call, fallbackName: extras['fromName'] ?? '同事'); + return; + } + } catch (_) {} + // 通话已被接听、拒绝或超时,点击历史通知只回到对应聊天,不再显示错误的接听页。 + if (mounted && cidOrEmpty(extras).isNotEmpty) { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => ChatPage( + session: widget.session, + api: widget.api, + conversationId: cidOrEmpty(extras), + peerName: extras['fromName'] ?? extras['title'] ?? '聊天', + ), + )); + } + } + + String cidOrEmpty(Map extras) => + extras['conversationId'] ?? ''; + + Future _checkOta() async { + if (_otaOnce) return; + _otaOnce = true; + if (isDesktopPlatform) return; + final rel = await OtaUpdater(widget.api).fetch(); + if (rel != null && rel.newer && mounted) { + await OtaUpdater(widget.api).prompt(context, rel); + } + } + + @override + Widget build(BuildContext context) { + if (isDesktopPlatform) { + return DeskShell(session: widget.session, api: widget.api); + } + final wide = MediaQuery.sizeOf(context).width >= 960; + final barItems = <(IconData, IconData, String, int)>[ + (Icons.chat_bubble_outline, Icons.chat_bubble, '消息', _msgBadge), + (Icons.apps_outlined, Icons.apps, '工作台', 0), + (Icons.location_on_outlined, Icons.location_on, '打卡', 0), + (Icons.account_tree_outlined, Icons.account_tree, '通讯录', 0), + (Icons.person_outline, Icons.person, '我', 0), + ]; + final body = IndexedStack(index: _tab, children: _pages); + if (wide) { + return Scaffold( + backgroundColor: kPaper, + body: Row( + children: [ + ColoredBox( + color: const Color(0xFFF7F7F7), + child: SafeArea( + child: SizedBox( + width: 88, + child: Column( + children: [ + const SizedBox(height: 16), + SquareAvatar( + label: widget.session.displayName, + size: 48, + fileId: '${widget.session.user['avatarFileId'] ?? ''}', + api: widget.api, + ), + const SizedBox(height: 20), + for (var i = 0; i < barItems.length; i++) + InkWell( + onTap: () => setState(() { + _tab = i; + if (i == 0) widget.session.im.bump(); + }), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Icon( + _tab == i + ? barItems[i].$2 + : barItems[i].$1, + color: _tab == i + ? kBind + : const Color(0xFF646464)), + if (barItems[i].$4 > 0) + Positioned( + right: -10, + top: -4, + child: UnreadBadge(barItems[i].$4)), + ], + ), + const SizedBox(height: 4), + Text(barItems[i].$3, + style: TextStyle( + fontSize: 11, + color: _tab == i + ? kBind + : const Color(0xFF646464))), + ], + ), + ), + ), + ], + ), + ), + ), + ), + const VerticalDivider(width: 1, color: kLine), + Expanded(child: body), + ], + ), + ); + } + return Scaffold( + backgroundColor: kPaper, + body: body, + bottomNavigationBar: WxTabBar( + index: _tab, + onChanged: (i) { + setState(() => _tab = i); + if (i == 0) widget.session.im.bump(); + }, + items: barItems, + ), + ); + } +} diff --git a/apps/native/lib/theme.dart b/apps/native/lib/theme.dart new file mode 100644 index 0000000..0603e0a --- /dev/null +++ b/apps/native/lib/theme.dart @@ -0,0 +1,77 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// 企业微信 / 微信手机端配色。 +const kInk = Color(0xFF191919); +const kPaper = Color(0xFFEDEDED); +const kBind = Color(0xFF267EF0); +const kBind2 = Color(0xFF1A6FD4); +const kWeGreen = Color(0xFF07C160); +const kMute = Color(0xFF888888); +const kLine = Color(0xFFE5E5E5); +const kBubbleMine = Color(0xFFC9E7FF); +const kDanger = Color(0xFFFA5151); +const kHeader = Colors.white; + +ThemeData buildTheme() { + const scheme = ColorScheme.light( + primary: kBind, + secondary: kBind2, + surface: Colors.white, + onPrimary: Colors.white, + onSurface: kInk, + ); + return ThemeData( + colorScheme: scheme, + useMaterial3: true, + scaffoldBackgroundColor: kPaper, + splashFactory: NoSplash.splashFactory, + highlightColor: const Color(0x14267EF0), + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: CupertinoPageTransitionsBuilder(), + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.linux: CupertinoPageTransitionsBuilder(), + TargetPlatform.windows: CupertinoPageTransitionsBuilder(), + }, + ), + fontFamily: null, + appBarTheme: const AppBarTheme( + backgroundColor: kHeader, + foregroundColor: kInk, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, + titleTextStyle: TextStyle(color: kInk, fontSize: 17, fontWeight: FontWeight.w600), + systemOverlayStyle: SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + ), + ), + dividerColor: kLine, + cardTheme: const CardThemeData( + color: Colors.white, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8))), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.white, + hintStyle: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 15), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6), borderSide: const BorderSide(color: kBind)), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: kBind, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ), + ), + ); +} diff --git a/apps/native/lib/widgets/auth_image.dart b/apps/native/lib/widgets/auth_image.dart new file mode 100644 index 0000000..4b3e88e --- /dev/null +++ b/apps/native/lib/widgets/auth_image.dart @@ -0,0 +1,85 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../theme.dart'; + +class AuthImage extends StatefulWidget { + const AuthImage({ + super.key, + required this.api, + required this.fileId, + this.width, + this.height, + this.fit = BoxFit.cover, + this.placeholder, + }); + final OaClient api; + final String fileId; + final double? width; + final double? height; + final BoxFit fit; + final Widget? placeholder; + + @override + State createState() => _AuthImageState(); +} + +class _AuthImageState extends State { + Uint8List? _bytes; + bool _fail = false; + + @override + void initState() { + super.initState(); + _bytes = widget.api.peekFile(widget.fileId); + if (_bytes == null) _load(); + } + + @override + void didUpdateWidget(covariant AuthImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.fileId != widget.fileId) { + _bytes = widget.api.peekFile(widget.fileId); + _fail = false; + if (_bytes == null) _load(); + } + } + + Future _load() async { + try { + final b = await widget.api.fileBytes(widget.fileId); + if (!mounted) return; + setState(() { + _bytes = b; + _fail = b == null; + }); + } catch (_) { + if (mounted) setState(() => _fail = true); + } + } + + @override + Widget build(BuildContext context) { + if (_bytes != null) { + return Image.memory( + _bytes!, + width: widget.width, + height: widget.height, + fit: widget.fit, + gaplessPlayback: true, + filterQuality: FilterQuality.medium, + ); + } + return SizedBox( + width: widget.width, + height: widget.height, + child: widget.placeholder ?? + ColoredBox( + color: const Color(0xFFEDEDED), + child: _fail ? const Icon(Icons.broken_image_outlined, color: kMute, size: 18) : const SizedBox.shrink(), + ), + ); + } +} diff --git a/apps/native/lib/widgets/beian_footer.dart b/apps/native/lib/widgets/beian_footer.dart new file mode 100644 index 0000000..065b5b3 --- /dev/null +++ b/apps/native/lib/widgets/beian_footer.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import '../app_config.dart'; +import '../device/device_bridge.dart'; +import '../theme.dart'; + +class BeianFooter extends StatelessWidget { + const BeianFooter({super.key}); + + Future _open(String url) async { + try { + await DeviceBridge.openUrl(url); + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + const style = TextStyle(color: kMute, fontSize: 11, height: 1.6); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + children: [ + const Text(AppConfig.company, textAlign: TextAlign.center, style: style), + Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + GestureDetector( + onTap: () => _open(AppConfig.icpUrl), + child: const Text(AppConfig.icp, style: style), + ), + const Text(' | ', style: style), + GestureDetector( + onTap: () => _open('tel:${AppConfig.phone}'), + child: const Text('联系电话 ${AppConfig.phone}', style: style), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/widgets/common.dart b/apps/native/lib/widgets/common.dart new file mode 100644 index 0000000..6476fb1 --- /dev/null +++ b/apps/native/lib/widgets/common.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; + +import '../theme.dart'; +import '../labels.dart'; + +/// 与 Web `ApplyBucketBar` 对齐:Segmented + 选中态。 +class BucketBar extends StatelessWidget { + const BucketBar({super.key, required this.value, required this.onChanged, this.extra = const []}); + + final String value; + final ValueChanged onChanged; + final List<(String, String)> extra; + + @override + Widget build(BuildContext context) { + final items = <(String, String)>[ + ('', '全部'), + ('pending', '待审批'), + ('approved', '已通过'), + ('rejected', '已驳回'), + ...extra, + ]; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row( + children: [ + for (final it in items) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(it.$2), + selected: value == it.$1, + showCheckmark: false, + visualDensity: VisualDensity.compact, + selectedColor: kBind.withValues(alpha: 0.16), + onSelected: (_) => onChanged(it.$1), + ), + ), + ], + ), + ); + } +} + +class EmptyHint extends StatelessWidget { + const EmptyHint(this.text, {super.key}); + final String text; + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text(text, style: const TextStyle(color: kMute)), + ), + ); + } +} + +class KvTile extends StatelessWidget { + const KvTile({super.key, required this.title, this.subtitle, this.trailing, this.onTap, this.badge, this.status}); + final String title; + final String? subtitle; + final Widget? trailing; + final VoidCallback? onTap; + final int? badge; + final dynamic status; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.white, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 12, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.only(bottom: 12), + decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, color: kInk)), + if (subtitle != null && subtitle!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(subtitle!, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, color: kMute)), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(top: 2), + child: Column( + children: [ + if (status != null) StatusDot(status), + if (badge != null && badge! > 0) + Padding( + padding: const EdgeInsets.only(top: 6), + child: CircleAvatar( + radius: 10, + backgroundColor: kDanger, + child: Text('$badge', style: const TextStyle(color: Colors.white, fontSize: 10)), + ), + ), + if (trailing != null) trailing!, + if (onTap != null) const Icon(Icons.chevron_right, color: Color(0xFFC0C0C0), size: 20), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/native/lib/widgets/desktop_ui.dart b/apps/native/lib/widgets/desktop_ui.dart new file mode 100644 index 0000000..5fd7d7c --- /dev/null +++ b/apps/native/lib/widgets/desktop_ui.dart @@ -0,0 +1,411 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../session/session.dart'; +import '../theme.dart'; +import 'wecom.dart'; + +const kDeskBg = Color(0xFFEDEDED); +const kDeskSide = Color(0xFFF7F7F7); +const kDeskPane = Colors.white; +const kDeskActive = Color(0xFFE8F3FF); + +class DesktopSidebar extends StatelessWidget { + const DesktopSidebar({ + super.key, + required this.session, + required this.api, + required this.index, + required this.onChanged, + required this.items, + }); + + final SessionStore session; + final OaClient api; + final int index; + final ValueChanged onChanged; + final List<(IconData, IconData, String, int)> items; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: kDeskSide, + child: SafeArea( + child: SizedBox( + width: 210, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 10), + child: Row( + children: [ + SquareAvatar( + label: session.displayName, + size: 36, + fileId: '${session.user['avatarFileId'] ?? ''}', + api: api, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + session.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: kInk), + ), + ), + ], + ), + ), + const Divider(height: 1, color: kLine), + const SizedBox(height: 6), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 10), + children: [ + for (var i = 0; i < items.length; i++) + _NavItem( + active: index == i, + icon: index == i ? items[i].$2 : items[i].$1, + label: items[i].$3, + badge: items[i].$4, + onTap: () => onChanged(i), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _NavItem extends StatelessWidget { + const _NavItem({ + required this.active, + required this.icon, + required this.label, + required this.badge, + required this.onTap, + }); + + final bool active; + final IconData icon; + final String label; + final int badge; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Material( + color: active ? kDeskActive : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Icon(icon, size: 20, color: active ? kBind : const Color(0xFF646464)), + if (badge > 0) + Positioned(right: -8, top: -4, child: UnreadBadge(badge)), + ], + ), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 14, + color: active ? kBind : const Color(0xFF323232), + fontWeight: active ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class DesktopPaneHeader extends StatelessWidget { + const DesktopPaneHeader({ + super.key, + required this.title, + this.actions = const [], + this.leading, + this.bottom, + }); + + final String title; + final List actions; + final Widget? leading; + final Widget? bottom; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: kDeskPane, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: 52, + child: Row( + children: [ + if (leading != null) leading!, + Padding( + padding: EdgeInsets.only(left: leading == null ? 16 : 4), + child: Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: kInk)), + ), + const Spacer(), + ...actions, + const SizedBox(width: 8), + ], + ), + ), + if (bottom != null) bottom!, + const Divider(height: 1, color: kLine), + ], + ), + ); + } +} + +class DesktopSearchBar extends StatelessWidget { + const DesktopSearchBar({super.key, required this.hint, this.onChanged, this.controller}); + final String hint; + final ValueChanged? onChanged; + final TextEditingController? controller; + + @override + Widget build(BuildContext context) { + return Container( + height: 34, + decoration: BoxDecoration(color: const Color(0xFFF3F3F3), borderRadius: BorderRadius.circular(6)), + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + const Icon(Icons.search, size: 18, color: kMute), + const SizedBox(width: 6), + Expanded( + child: TextField( + controller: controller, + onChanged: onChanged, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + hintText: hint, + isDense: true, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + hintStyle: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 14), + ), + ), + ), + ], + ), + ); + } +} + +class DesktopAppCard extends StatelessWidget { + const DesktopAppCard({ + super.key, + required this.label, + required this.desc, + required this.icon, + required this.color, + required this.onTap, + }); + + final String label; + final String desc; + final IconData icon; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(10)), + child: Icon(icon, color: Colors.white, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kInk)), + const SizedBox(height: 4), + Text(desc, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: kMute, height: 1.35)), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class DesktopEmptyPane extends StatelessWidget { + const DesktopEmptyPane({super.key, this.hint = '选择左侧会话开始聊天'}); + + final String hint; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: const Color(0xFFF5F5F5), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Opacity( + opacity: 0.12, + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration(color: kBind, borderRadius: BorderRadius.circular(28)), + child: const Icon(Icons.apartment, size: 64, color: Colors.white), + ), + ), + const SizedBox(height: 20), + Text(hint, style: const TextStyle(color: kMute, fontSize: 14)), + ], + ), + ), + ); + } +} + +class DesktopTabBar extends StatelessWidget { + const DesktopTabBar({ + super.key, + required this.tabs, + required this.active, + required this.onSelect, + required this.onClose, + }); + + final List<(String key, String title, IconData icon, Color color)> tabs; + final int active; + final ValueChanged onSelect; + final ValueChanged onClose; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: kDeskPane, + child: Column( + children: [ + SizedBox( + height: 44, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + children: [ + for (var i = 0; i < tabs.length; i++) + _TabChip( + title: tabs[i].$2, + icon: tabs[i].$3, + color: tabs[i].$4, + active: active == i, + closable: i > 0, + onTap: () => onSelect(i), + onClose: () => onClose(i), + ), + ], + ), + ), + const Divider(height: 1, color: kLine), + ], + ), + ); + } +} + +class _TabChip extends StatelessWidget { + const _TabChip({ + required this.title, + required this.icon, + required this.color, + required this.active, + required this.closable, + required this.onTap, + required this.onClose, + }); + + final String title; + final IconData icon; + final Color color; + final bool active; + final bool closable; + final VoidCallback onTap; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 4, top: 6, bottom: 6), + child: Material( + color: active ? const Color(0xFFF3F6FB) : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: EdgeInsets.fromLTRB(10, 6, closable ? 4 : 10, 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Text(title, style: TextStyle(fontSize: 13, color: active ? kInk : kMute, fontWeight: active ? FontWeight.w600 : FontWeight.w400)), + if (closable) ...[ + const SizedBox(width: 4), + InkWell( + onTap: onClose, + borderRadius: BorderRadius.circular(10), + child: const Padding( + padding: EdgeInsets.all(2), + child: Icon(Icons.close, size: 14, color: kMute), + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/native/lib/widgets/html_body.dart b/apps/native/lib/widgets/html_body.dart new file mode 100644 index 0000000..07d0e31 --- /dev/null +++ b/apps/native/lib/widgets/html_body.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../theme.dart'; +import 'auth_image.dart'; + +/// 公告 HTML:按块渲染标题、段落、列表、加粗,不依赖 flutter_html。 +class HtmlBody extends StatelessWidget { + const HtmlBody({super.key, required this.html, this.api}); + final String html; + final OaClient? api; + + @override + Widget build(BuildContext context) { + final blocks = parseHtmlBlocks(html); + if (blocks.isEmpty) { + return const Text('暂无正文', style: TextStyle(color: kMute, fontSize: 15)); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final b in blocks) ...[ + if (b.kind == 'img' && b.src != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _img(b.src!), + ) + else + Padding( + padding: EdgeInsets.only(bottom: b.kind.startsWith('h') ? 10 : 12, left: b.kind == 'li' ? 12 : 0), + child: Text.rich( + TextSpan(children: b.spans), + style: _style(b.kind), + ), + ), + ], + ], + ); + } + + TextStyle _style(String kind) { + switch (kind) { + case 'h1': + return const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kInk, height: 1.4); + case 'h2': + return const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: kInk, height: 1.4); + case 'h3': + return const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: kInk, height: 1.45); + case 'li': + return const TextStyle(fontSize: 16, color: kInk, height: 1.7); + default: + return const TextStyle(fontSize: 16, color: kInk, height: 1.7); + } + } + + Widget _img(String src) { + final m = RegExp(r'/files/([0-9a-f-]{36})', caseSensitive: false).firstMatch(src); + if (m != null && api != null) { + return ClipRRect( + borderRadius: BorderRadius.circular(6), + child: AuthImage(api: api!, fileId: m.group(1)!, fit: BoxFit.contain), + ); + } + return const SizedBox.shrink(); + } +} + +class HtmlBlock { + HtmlBlock(this.kind, this.spans, {this.src}); + final String kind; + final List spans; + final String? src; +} + +List parseHtmlBlocks(String raw) { + var s = raw.trim(); + if (s.isEmpty) return []; + s = s.replaceAll(RegExp(r'<(script|style)[^>]*>[\s\S]*?', caseSensitive: false), ''); + if (!RegExp(r'<[a-z][\s\S]*>', caseSensitive: false).hasMatch(s)) { + return s + .split(RegExp(r'\n+')) + .where((e) => e.trim().isNotEmpty) + .map((e) => HtmlBlock('p', _inline(e.trim()))) + .toList(); + } + s = s + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'<(p|div|blockquote)[^>]*>', caseSensitive: false), '\n') + .replaceAll(RegExp(r']*>', caseSensitive: false), '\n#h1#') + .replaceAll(RegExp(r']*>', caseSensitive: false), '\n#h2#') + .replaceAll(RegExp(r']*>', caseSensitive: false), '\n#h3#') + .replaceAll(RegExp(r']*>', caseSensitive: false), '\n#li#') + .replaceAll(RegExp(r']*>', caseSensitive: false), '\n') + .replaceAll(RegExp(r'<(strong|b)[^>]*>', caseSensitive: false), '«b»') + .replaceAll(RegExp(r'', caseSensitive: false), '«/b»'); + final imgRe = RegExp(r']*src=["'']([^"'']+)["''][^>]*>', caseSensitive: false); + s = s.replaceAllMapped(imgRe, (m) => '\n#img#${m.group(1)}#\n'); + s = s.replaceAll(RegExp(r'<[^>]+>'), ''); + s = decodeEntities(s); + final out = []; + for (final line in s.split('\n')) { + final t = line.trim(); + if (t.isEmpty) continue; + if (t.startsWith('#img#') && t.endsWith('#')) { + out.add(HtmlBlock('img', const [], src: t.substring(5, t.length - 1))); + continue; + } + var kind = 'p'; + var text = t; + if (t.startsWith('#h1#')) { + kind = 'h1'; + text = t.substring(4); + } else if (t.startsWith('#h2#')) { + kind = 'h2'; + text = t.substring(4); + } else if (t.startsWith('#h3#')) { + kind = 'h3'; + text = t.substring(4); + } else if (t.startsWith('#li#')) { + kind = 'li'; + text = '• ${t.substring(4)}'; + } + text = text.trim(); + if (text.isEmpty) continue; + out.add(HtmlBlock(kind, _inline(text))); + } + return out; +} + +List _inline(String text) { + final out = []; + final re = RegExp(r'«b»(.*?)«/b»'); + var i = 0; + for (final m in re.allMatches(text)) { + if (m.start > i) out.add(TextSpan(text: text.substring(i, m.start))); + out.add(TextSpan(text: m.group(1), style: const TextStyle(fontWeight: FontWeight.w700))); + i = m.end; + } + if (i < text.length) out.add(TextSpan(text: text.substring(i))); + if (out.isEmpty) out.add(TextSpan(text: text)); + return out; +} + +String decodeEntities(String s) { + return s + .replaceAll(' ', ' ') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAllMapped(RegExp(r'&#(\d+);'), (m) { + final n = int.tryParse(m.group(1)!); + return n == null ? m.group(0)! : String.fromCharCode(n); + }); +} diff --git a/apps/native/lib/widgets/location_view_page.dart b/apps/native/lib/widgets/location_view_page.dart new file mode 100644 index 0000000..48c259e --- /dev/null +++ b/apps/native/lib/widgets/location_view_page.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../device/device_bridge.dart'; +import '../theme.dart'; +import 'tencent_map_thumb.dart'; + +class LocationViewPage extends StatelessWidget { + const LocationViewPage({ + super.key, + required this.api, + required this.title, + required this.address, + this.lat, + this.lng, + }); + + final OaClient api; + final String title; + final String address; + final double? lat; + final double? lng; + + Future _openMaps(BuildContext context) async { + if (lat == null || lng == null) return; + final la = lat!; + final ln = lng!; + final q = Uri.encodeComponent(title.isNotEmpty ? title : '$la,$ln'); + final urls = [ + 'https://uri.amap.com/marker?position=$ln,$la&name=$q', + 'geo:$la,$ln?q=$la,$ln($q)', + ]; + for (final u in urls) { + try { + await DeviceBridge.openUrl(u); + return; + } catch (_) {} + } + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('无法打开地图'))); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar(title: const Text('位置')), + body: ListView( + children: [ + if (lat != null && lng != null) + TencentMapThumb(api: api, lat: lat!, lng: lng!, width: MediaQuery.sizeOf(context).width, height: 220), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text(title.isEmpty ? '位置' : title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + ), + if (address.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Text(address, style: const TextStyle(fontSize: 14, color: kMute)), + ), + Padding( + padding: const EdgeInsets.all(16), + child: FilledButton.icon( + onPressed: lat == null ? null : () => _openMaps(context), + icon: const Icon(Icons.map_outlined), + label: const Text('用地图打开'), + ), + ), + ], + ), + ); + } +} diff --git a/apps/native/lib/widgets/tencent_map_thumb.dart b/apps/native/lib/widgets/tencent_map_thumb.dart new file mode 100644 index 0000000..082c419 --- /dev/null +++ b/apps/native/lib/widgets/tencent_map_thumb.dart @@ -0,0 +1,65 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../theme.dart'; + +class TencentMapThumb extends StatefulWidget { + const TencentMapThumb({ + super.key, + required this.api, + required this.lat, + required this.lng, + this.height = 110, + this.width, + }); + final OaClient api; + final double lat; + final double lng; + final double height; + final double? width; + + @override + State createState() => _TencentMapThumbState(); +} + +class _TencentMapThumbState extends State { + Uint8List? _bytes; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(covariant TencentMapThumb oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.lat != widget.lat || oldWidget.lng != widget.lng) _load(); + } + + Future _load() async { + final b = await widget.api.getBytes('/office/geo/static-map', query: { + 'lat': '${widget.lat}', + 'lng': '${widget.lng}', + }); + if (!mounted) return; + setState(() => _bytes = b); + } + + @override + Widget build(BuildContext context) { + if (_bytes != null) { + return Image.memory(_bytes!, width: widget.width, height: widget.height, fit: BoxFit.cover); + } + return SizedBox( + width: widget.width, + height: widget.height, + child: const ColoredBox( + color: Color(0xFFE8F5E9), + child: Center(child: Icon(Icons.place, color: kWeGreen, size: 32)), + ), + ); + } +} diff --git a/apps/native/lib/widgets/voice_message.dart b/apps/native/lib/widgets/voice_message.dart new file mode 100644 index 0000000..eaf7e1f --- /dev/null +++ b/apps/native/lib/widgets/voice_message.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; + +import '../theme.dart'; + +/// 微信式语音消息:气泡 + 下方独立白底转写框。 +class VoiceMessageBlock extends StatelessWidget { + const VoiceMessageBlock({ + super.key, + required this.mine, + required this.seconds, + required this.playing, + this.transcript, + this.loading = false, + this.onTap, + this.onLongPress, + this.bubbleColor, + this.iconColor, + this.readLabel, + this.bottomMargin = 8, + }); + + final bool mine; + final int seconds; + final bool playing; + final String? transcript; + final bool loading; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final Color? bubbleColor; + final Color? iconColor; + final String? readLabel; + final double bottomMargin; + + @override + Widget build(BuildContext context) { + final bubble = mine ? (bubbleColor ?? kBubbleMine) : Colors.white; + final ink = iconColor ?? kInk; + final width = (80 + seconds * 4).clamp(80, 180).toDouble(); + + return Column( + crossAxisAlignment: mine ? CrossAxisAlignment.end : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Container( + margin: EdgeInsets.only(bottom: bottomMargin), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + constraints: BoxConstraints(maxWidth: 280, minWidth: width), + decoration: BoxDecoration( + color: bubble, + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: + mine ? CrossAxisAlignment.end : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: width, + child: Row( + mainAxisAlignment: + mine ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + if (!mine) ...[ + Icon( + playing + ? Icons.stop_circle_outlined + : Icons.volume_up_outlined, + size: 20, + color: ink, + ), + const SizedBox(width: 6), + Text('$seconds″', + style: TextStyle(fontSize: 16, color: ink)), + ] else ...[ + Text('$seconds″', + style: TextStyle(fontSize: 16, color: ink)), + const SizedBox(width: 6), + Icon( + playing + ? Icons.stop_circle_outlined + : Icons.volume_up_outlined, + size: 20, + color: ink, + ), + ], + ], + ), + ), + if (readLabel != null) + Text(readLabel!, + style: const TextStyle(fontSize: 10, color: kMute)), + ], + ), + ), + ), + if (loading) _transcriptBox(const Text('转文字中…', style: TextStyle(fontSize: 14, color: kMute))) + else if (transcript != null && transcript!.isNotEmpty) + _transcriptBox(Text( + transcript!, + style: const TextStyle(fontSize: 15, color: kInk, height: 1.35), + )), + ], + ); + } + + Widget _transcriptBox(Widget child) { + return Container( + margin: EdgeInsets.only(bottom: bottomMargin), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + constraints: const BoxConstraints(maxWidth: 280), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: child, + ); + } +} diff --git a/apps/native/lib/widgets/wecom.dart b/apps/native/lib/widgets/wecom.dart new file mode 100644 index 0000000..50854f4 --- /dev/null +++ b/apps/native/lib/widgets/wecom.dart @@ -0,0 +1,540 @@ +import 'package:flutter/material.dart'; + +import '../api/oa_client.dart'; +import '../theme.dart'; +import '../widgets/auth_image.dart'; + +class WxHeader extends StatelessWidget { + const WxHeader({super.key, required this.title, this.leading, this.actions = const []}); + final String title; + final Widget? leading; + final List actions; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: kHeader, + child: SafeArea( + bottom: false, + child: SizedBox( + height: 48, + child: Row( + children: [ + SizedBox(width: 48, child: leading ?? const SizedBox.shrink()), + Expanded( + child: Text( + title, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: kInk), + ), + ), + SizedBox( + width: actions.isEmpty ? 48 : null, + child: Row(mainAxisAlignment: MainAxisAlignment.end, children: actions), + ), + ], + ), + ), + ), + ); + } +} + +class WxSearchBar extends StatelessWidget { + const WxSearchBar({super.key, this.hint = '搜索', this.onChanged, this.onTap, this.readonly = false}); + final String hint; + final ValueChanged? onChanged; + final VoidCallback? onTap; + final bool readonly; + + @override + Widget build(BuildContext context) { + final field = Container( + height: 36, + decoration: BoxDecoration(color: const Color(0xFFF3F3F3), borderRadius: BorderRadius.circular(6)), + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + const Icon(Icons.search, size: 18, color: kMute), + const SizedBox(width: 6), + Expanded( + child: readonly + ? Text(hint, style: const TextStyle(color: Color(0xFFB2B2B2), fontSize: 15)) + : TextField( + onChanged: onChanged, + decoration: InputDecoration( + hintText: hint, + isDense: true, + filled: true, + fillColor: Colors.transparent, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + ); + return ColoredBox( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 10), + child: onTap == null ? field : GestureDetector(onTap: onTap, child: field), + ), + ); + } +} + +class SquareAvatar extends StatelessWidget { + const SquareAvatar({super.key, required this.label, this.size = 44, this.color, this.icon, this.fileId, this.api}); + final String label; + final double size; + final Color? color; + final IconData? icon; + final String? fileId; + final OaClient? api; + + @override + Widget build(BuildContext context) { + final bg = color ?? _colorOf(label); + final id = (fileId ?? '').trim(); + return Container( + width: size, + height: size, + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)), + alignment: Alignment.center, + clipBehavior: Clip.antiAlias, + child: id.isNotEmpty && api != null + ? AuthImage(api: api!, fileId: id, width: size, height: size, fit: BoxFit.cover) + : icon != null + ? Icon(icon, color: Colors.white, size: size * 0.5) + : Text( + label.isEmpty ? '?' : String.fromCharCodes(label.runes.take(1)), + style: TextStyle(color: Colors.white, fontSize: size * 0.38, fontWeight: FontWeight.w600), + ), + ); + } +} + +Color _colorOf(String s) { + const palette = [ + Color(0xFF267EF0), + Color(0xFF07C160), + Color(0xFFFA9D3B), + Color(0xFF6267F2), + Color(0xFF10AEFF), + Color(0xFFE75D5D), + Color(0xFF00B578), + Color(0xFF8B5CF6), + ]; + return palette[s.hashCode.abs() % palette.length]; +} + +class UnreadBadge extends StatelessWidget { + const UnreadBadge(this.count, {super.key, this.dot = false}); + final int count; + final bool dot; + + @override + Widget build(BuildContext context) { + if (count <= 0 && !dot) return const SizedBox.shrink(); + if (dot || count <= 0) { + return Container(width: 8, height: 8, decoration: const BoxDecoration(color: kDanger, shape: BoxShape.circle)); + } + final text = count > 99 ? '99+' : '$count'; + return Container( + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), + padding: const EdgeInsets.symmetric(horizontal: 5), + decoration: BoxDecoration(color: kDanger, borderRadius: BorderRadius.circular(9)), + alignment: Alignment.center, + child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600)), + ); + } +} + +class ConvTile extends StatelessWidget { + const ConvTile({ + super.key, + required this.title, + this.preview, + this.time, + this.unread = 0, + this.muted = false, + this.avatarLabel, + this.avatarColor, + this.avatarIcon, + this.avatarFileId, + this.api, + this.onTap, + this.selected = false, + }); + final String title; + final String? preview; + final String? time; + final int unread; + final bool muted; + final String? avatarLabel; + final Color? avatarColor; + final IconData? avatarIcon; + final String? avatarFileId; + final OaClient? api; + final VoidCallback? onTap; + final bool selected; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Container( + color: selected ? const Color(0xFFE8F3FF) : Colors.white, + padding: const EdgeInsets.fromLTRB(12, 10, 12, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SquareAvatar(label: avatarLabel ?? title, color: avatarColor, icon: avatarIcon, fileId: avatarFileId, api: api), + const SizedBox(width: 10), + Expanded( + child: Container( + padding: const EdgeInsets.only(bottom: 10), + decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, color: kInk)), + ), + if (time != null && time!.isNotEmpty) + Text(time!, style: const TextStyle(fontSize: 12, color: Color(0xFFB2B2B2))), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text( + preview ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13, color: kMute), + ), + ), + if (muted) const Padding(padding: EdgeInsets.only(left: 6), child: Icon(Icons.notifications_off_outlined, size: 14, color: kMute)), + if (unread > 0) Padding(padding: const EdgeInsets.only(left: 6), child: UnreadBadge(unread)), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class GroupCard extends StatelessWidget { + const GroupCard({super.key, required this.title, required this.child, this.trailing}); + final String title; + final Widget child; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB(12, 0, 12, 12), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 12, 4), + child: Row( + children: [ + Expanded(child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kInk))), + if (trailing != null) trailing!, + ], + ), + ), + child, + ], + ), + ); + } +} + +class AppGrid extends StatelessWidget { + const AppGrid({super.key, required this.items}); + final List items; + + @override + Widget build(BuildContext context) { + return GridView.count( + crossAxisCount: 4, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(4, 4, 4, 12), + childAspectRatio: 0.92, + children: [ + for (final it in items) + InkWell( + onTap: it.onTap, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration(color: it.color, borderRadius: BorderRadius.circular(10)), + child: Icon(it.icon, color: Colors.white, size: 24), + ), + const SizedBox(height: 6), + Text( + it.label, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: kInk), + ), + ], + ), + ), + ], + ); + } +} + +class AppGridItem { + const AppGridItem({required this.label, required this.icon, required this.color, required this.onTap}); + final String label; + final IconData icon; + final Color color; + final VoidCallback onTap; +} + +class CellGroup extends StatelessWidget { + const CellGroup({super.key, required this.children}); + final List children; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB(12, 0, 12, 12), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)), + child: Column(children: children), + ); + } +} + +class Cell extends StatelessWidget { + const Cell({super.key, required this.title, this.subtitle, this.leading, this.trailing, this.onTap, this.showLine = true}); + final String title; + final String? subtitle; + final Widget? leading; + final Widget? trailing; + final VoidCallback? onTap; + final bool showLine; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.only(left: 16), + child: Container( + padding: const EdgeInsets.fromLTRB(0, 14, 12, 14), + decoration: showLine ? const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))) : null, + child: Row( + children: [ + if (leading != null) ...[leading!, const SizedBox(width: 12)], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontSize: 16, color: kInk)), + if (subtitle != null && subtitle!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text(subtitle!, style: const TextStyle(fontSize: 12, color: kMute)), + ), + ], + ), + ), + if (trailing != null) trailing!, + if (onTap != null) const Icon(Icons.chevron_right, color: Color(0xFFC0C0C0), size: 20), + ], + ), + ), + ), + ); + } +} + +class WxTabBar extends StatelessWidget { + const WxTabBar({super.key, required this.index, required this.onChanged, required this.items}); + final int index; + final ValueChanged onChanged; + final List<(IconData, IconData, String, int)> items; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: const Color(0xFFF7F7F7), + child: SafeArea( + top: false, + child: SizedBox( + height: 54, + child: Row( + children: [ + for (var i = 0; i < items.length; i++) + Expanded( + child: InkWell( + onTap: () => onChanged(i), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Icon(index == i ? items[i].$2 : items[i].$1, size: 24, color: index == i ? kBind : const Color(0xFF646464)), + if (items[i].$4 > 0) + Positioned(right: -10, top: -4, child: UnreadBadge(items[i].$4)), + ], + ), + const SizedBox(height: 2), + Text(items[i].$3, style: TextStyle(fontSize: 10, color: index == i ? kBind : const Color(0xFF646464))), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +Future showWxSheet(BuildContext context, List<(String, T)> actions) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) => Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), + child: Column( + children: [ + for (var i = 0; i < actions.length; i++) + InkWell( + onTap: () => Navigator.pop(ctx, actions[i].$2), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: i == 0 ? null : const BoxDecoration(border: Border(top: BorderSide(color: kLine, width: 0.5))), + alignment: Alignment.center, + child: Text(actions[i].$1, style: const TextStyle(fontSize: 17, color: kInk)), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Material( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: () => Navigator.pop(ctx), + borderRadius: BorderRadius.circular(12), + child: const SizedBox( + width: double.infinity, + height: 52, + child: Center(child: Text('取消', style: TextStyle(fontSize: 17, color: kBind, fontWeight: FontWeight.w500))), + ), + ), + ), + ], + ), + ), + ); +} + +/// 微信风格的页内提示:不遮挡输入框和系统导航区域。 +void showWxToast(BuildContext context, String message, {bool error = false}) { + final overlay = Overlay.maybeOf(context); + if (overlay == null || message.trim().isEmpty) return; + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (ctx) => Positioned( + top: MediaQuery.paddingOf(ctx).top + 54, + left: 32, + right: 32, + child: IgnorePointer( + child: Material( + color: Colors.white, + elevation: 8, + shadowColor: Colors.black26, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + child: Row( + children: [ + Icon(error ? Icons.error_outline : Icons.check_circle_outline, + size: 20, color: error ? kDanger : kWeGreen), + const SizedBox(width: 9), + Expanded( + child: Text(message, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14, color: kInk)), + ), + ], + ), + ), + ), + ), + ), + ); + overlay.insert(entry); + Future.delayed(const Duration(milliseconds: 1800), () { + if (entry.mounted) entry.remove(); + }); +} + +void showPlusMenu(BuildContext context, List<(IconData, String, VoidCallback)> items) { + final box = context.findRenderObject() as RenderBox?; + final overlay = Overlay.of(context).context.findRenderObject() as RenderBox?; + final pos = box != null && overlay != null + ? RelativeRect.fromRect( + Rect.fromPoints(box.localToGlobal(Offset(box.size.width - 8, box.size.height), ancestor: overlay), box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay)), + Offset.zero & overlay.size, + ) + : const RelativeRect.fromLTRB(80, 80, 16, 0); + showMenu( + context: context, + position: pos, + color: const Color(0xFF4C4C4C), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + items: [ + for (var i = 0; i < items.length; i++) + PopupMenuItem( + value: i, + child: Row( + children: [ + Icon(items[i].$1, color: Colors.white, size: 20), + const SizedBox(width: 12), + Text(items[i].$2, style: const TextStyle(color: Colors.white, fontSize: 15)), + ], + ), + ), + ], + ).then((i) { + if (i != null) items[i].$3(); + }); +} diff --git a/apps/native/linux/.gitignore b/apps/native/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/apps/native/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/apps/native/linux/CMakeLists.txt b/apps/native/linux/CMakeLists.txt new file mode 100644 index 0000000..569b9b9 --- /dev/null +++ b/apps/native/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "fengying_oa") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.fysxkj.fengying_oa") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/apps/native/linux/runner/CMakeLists.txt b/apps/native/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/apps/native/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/apps/native/linux/runner/main.cc b/apps/native/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/apps/native/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/apps/native/linux/runner/my_application.cc b/apps/native/linux/runner/my_application.cc new file mode 100644 index 0000000..db534af --- /dev/null +++ b/apps/native/linux/runner/my_application.cc @@ -0,0 +1,130 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "风影办公"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "fengying_oa"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/apps/native/linux/runner/my_application.h b/apps/native/linux/runner/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/apps/native/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/apps/native/packaging/build-deb.sh b/apps/native/packaging/build-deb.sh new file mode 100755 index 0000000..adad7f7 --- /dev/null +++ b/apps/native/packaging/build-deb.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# 构建 Linux .deb(Ubuntu / Debian / Deepin 25 amd64) +set -euo pipefail +cd "$(dirname "$0")/.." + +export PATH="/opt/flutter/bin:$PATH" +export PUB_HOSTED_URL="${PUB_HOSTED_URL:-https://pub.dev}" +export FLUTTER_STORAGE_BASE_URL="${FLUTTER_STORAGE_BASE_URL:-https://storage.googleapis.com}" + +APP_ID="com.fysxkj.fengying_oa" +APP_NAME="风影办公" +VERSION="${1:-3.1.10}" +BUILD="${2:-324}" +PKG="fengying-oa" +DEB="${PKG}_${VERSION}-${BUILD}_amd64.deb" + +echo "== Flutter build linux ${VERSION}+${BUILD} ==" +flutter pub get +flutter build linux --release --build-name="$VERSION" --build-number="$BUILD" + +BUNDLE="build/linux/x64/release/bundle" +STAGE="build/deb-stage" +rm -rf "$STAGE" +mkdir -p "$STAGE/DEBIAN" +mkdir -p "$STAGE/opt/${PKG}" +mkdir -p "$STAGE/usr/bin" +mkdir -p "$STAGE/usr/share/applications" +mkdir -p "$STAGE/usr/share/icons/hicolor/256x256/apps" + +cp -a "$BUNDLE/." "$STAGE/opt/${PKG}/" + +# 图标:优先用 bundle 内 icon,否则占位 +if [ -f "$BUNDLE/data/flutter_assets/assets/icon.png" ]; then + cp "$BUNDLE/data/flutter_assets/assets/icon.png" "$STAGE/usr/share/icons/hicolor/256x256/apps/${APP_ID}.png" +elif [ -f "assets/icon.png" ]; then + cp "assets/icon.png" "$STAGE/usr/share/icons/hicolor/256x256/apps/${APP_ID}.png" +else + convert -size 256x256 xc:'#07C160' -fill white -gravity center -pointsize 48 -annotate 0 '风影' \ + "$STAGE/usr/share/icons/hicolor/256x256/apps/${APP_ID}.png" 2>/dev/null || \ + cp "$BUNDLE/fengying_oa" "$STAGE/usr/share/icons/hicolor/256x256/apps/${APP_ID}.png" 2>/dev/null || true +fi + +cat > "$STAGE/usr/share/applications/${APP_ID}.desktop" < "$STAGE/usr/bin/${PKG}" <<'EOF' +#!/bin/sh +APP_DIR="/opt/fengying-oa" +export LD_LIBRARY_PATH="${APP_DIR}/lib:${LD_LIBRARY_PATH:-}" +cd "$APP_DIR" || exit 1 +exec "$APP_DIR/fengying_oa" "$@" +EOF +chmod 755 "$STAGE/usr/bin/${PKG}" + +cat > "$STAGE/DEBIAN/control" < +Homepage: https://oa.fysxkj.com +Depends: libgtk-3-0, libglib2.0-0, libgstreamer1.0-0, libgstreamer-plugins-base1.0-0, libsecret-1-0, libsqlite3-0, libstdc++6, ca-certificates, zenity | kdialog +Description: 风影智慧办公综合平台 + 企业微信风格桌面客户端,支持消息、工作台、审批、考勤等。 + 适配 Ubuntu 22.04+、Debian 12+、Deepin 25 等 amd64 系统。 +EOF + +cat > "$STAGE/DEBIAN/postinst" <<'EOF' +#!/bin/sh +set -e +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database /usr/share/applications || true +fi +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -f -t /usr/share/icons/hicolor 2>/dev/null || true +fi +EOF +chmod 755 "$STAGE/DEBIAN/postinst" + +cat > "$STAGE/DEBIAN/prerm" <<'EOF' +#!/bin/sh +set -e +exit 0 +EOF +chmod 755 "$STAGE/DEBIAN/prerm" + +mkdir -p build +rm -f "build/${DEB}" +dpkg-deb --root-owner-group --build "$STAGE" "build/${DEB}" +cp "build/${DEB}" "build/fengying-oa_amd64.deb" +echo "Built: build/${DEB}" +echo "Stable URL copy: build/fengying-oa_amd64.deb" +ls -lh "build/${DEB}" "build/fengying-oa_amd64.deb" + +# 同时保留 tar.gz 便于手动解压 +tar -czf "build/fengying-oa-linux.tar.gz" -C "$BUNDLE" . +echo "Also: build/fengying-oa-linux.tar.gz" diff --git a/apps/native/pubspec.lock b/apps/native/pubspec.lock new file mode 100644 index 0000000..3bb362d --- /dev/null +++ b/apps/native/pubspec.lock @@ -0,0 +1,986 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70 + url: "https://pub.dev" + source: hosted + version: "6.6.0" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91 + url: "https://pub.dev" + source: hosted + version: "6.4.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9 + url: "https://pub.dev" + source: hosted + version: "5.2.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + url: "https://pub.dev" + source: hosted + version: "1.19.0" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_webrtc: + dependency: transitive + description: + name: dart_webrtc + sha256: f6d615bddea5e458ce180a914f3055c234ffb52fb7397a51b3491e76d6d7edb2 + url: "https://pub.dev" + source: hosted + version: "1.8.1" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "72d146c6d7098689ff5c5f66bcf593ac11efc530095385356e131070333e64da" + url: "https://pub.dev" + source: hosted + version: "11.3.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "8c9250b2bd2d8d4268e39c82543bacbaca0fda7d29e0728c3c4bbb7c820fd711" + url: "https://pub.dev" + source: hosted + version: "0.9.4+3" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + url: "https://pub.dev" + source: hosted + version: "0.9.3+4" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "6382ce712ff69b0f719640ce957559dde459e55ecd433c767e06d139ddf16cab" + url: "https://pub.dev" + source: hosted + version: "2.0.29" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_webrtc: + dependency: "direct main" + description: + name: flutter_webrtc + sha256: b832dc76c0d1577f14aaf35e9c38d4ed7667cbc89c492b7bf4505d8d5f62e08b + url: "https://pub.dev" + source: hosted + version: "0.12.12+hotfix.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2 + url: "https://pub.dev" + source: hosted + version: "13.0.4" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73" + url: "https://pub.dev" + source: hosted + version: "2.3.14" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "94db8255dc183d268765df682580440617ca35877fc82cacb5420ad03b86198d" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: e83b2b05141469c5e19d77e1dfa11096b6b1567d09065b2265d7c6904560050c + url: "https://pub.dev" + source: hosted + version: "0.8.13" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e + url: "https://pub.dev" + source: hosted + version: "0.8.13" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + url: "https://pub.dev" + source: hosted + version: "10.0.7" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + url: "https://pub.dev" + source: hosted + version: "3.0.8" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + livekit_client: + dependency: "direct main" + description: + name: livekit_client + sha256: "0cfb2f48eff7a93ea8927696dc6f218aebd2fcd1fcc1b1a7b2f53ff3597fdb52" + url: "https://pub.dev" + source: hosted + version: "2.3.6" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760 + url: "https://pub.dev" + source: hosted + version: "5.2.3" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + platform_detect: + dependency: transitive + description: + name: platform_detect + sha256: a62f99417fc4fa2d099ce0ccdbb1bd3977920f2a64292c326271f049d4bc3a4f + url: "https://pub.dev" + source: hosted + version: "2.1.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + record: + dependency: "direct main" + description: + name: record + sha256: "2e3d56d196abcd69f1046339b75e5f3855b2406fc087e5991f6703f188aa03a6" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + record_android: + dependency: transitive + description: + name: record_android + sha256: eb1732e42d0d2a1895b8db86e4fc917287e6d8491b6ed59918aea8bed6c69de4 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + record_darwin: + dependency: transitive + description: + name: record_darwin + sha256: e487eccb19d82a9a39cd0126945cfc47b9986e0df211734e2788c95e3f63c82c + url: "https://pub.dev" + source: hosted + version: "1.2.2" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "74d41a9ebb1eb498a38e9a813dd524e8f0b4fdd627270bda9756f437b110a3e3" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + record_platform_interface: + dependency: "direct overridden" + description: + name: record_platform_interface + sha256: "8a575828733d4c3cb5983c914696f40db8667eab3538d4c41c50cbb79e722ef4" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + sdp_transform: + dependency: transitive + description: + name: sdp_transform + sha256: "73e412a5279a5c2de74001535208e20fff88f225c9a4571af0f7146202755e45" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" + url: "https://pub.dev" + source: hosted + version: "2.4.11" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + url: "https://pub.dev" + source: hosted + version: "1.12.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "https://pub.dev" + source: hosted + version: "3.3.0+3" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + url: "https://pub.dev" + source: hosted + version: "0.7.3" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + url: "https://pub.dev" + source: hosted + version: "14.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + webrtc_interface: + dependency: transitive + description: + name: webrtc_interface + sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f + url: "https://pub.dev" + source: hosted + version: "1.5.1" + win32: + dependency: transitive + description: + name: win32 + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e + url: "https://pub.dev" + source: hosted + version: "5.10.1" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + url: "https://pub.dev" + source: hosted + version: "1.1.5" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "732896e1416297c63c9e3fb95aea72d0355f61390263982a47fd519169dc5059" + url: "https://pub.dev" + source: hosted + version: "0.4.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" +sdks: + dart: ">=3.6.2 <4.0.0" + flutter: ">=3.27.0" diff --git a/apps/native/pubspec.yaml b/apps/native/pubspec.yaml new file mode 100644 index 0000000..087072f --- /dev/null +++ b/apps/native/pubspec.yaml @@ -0,0 +1,42 @@ +name: fengying_oa +description: 风影智慧办公综合平台 Android / Windows 原生客户端 +publish_to: "none" +version: 3.1.10+324 + +environment: + sdk: ^3.6.2 + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + http: ^1.2.2 + shared_preferences: ^2.3.3 + path_provider: ^2.1.5 + intl: ^0.19.0 + uuid: ^4.5.1 + image_picker: ^1.1.2 + mobile_scanner: ^5.2.3 + flutter_webrtc: ^0.12.12 + livekit_client: ^2.3.6 + qr_flutter: ^4.1.0 + window_manager: ^0.4.3 + audioplayers: ^6.1.0 + file_picker: ^8.1.4 + record: ^5.2.1 + package_info_plus: ^8.1.2 + geolocator: ^13.0.2 + connectivity_plus: ^6.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +dependency_overrides: + record_platform_interface: 1.2.0 + +flutter: + uses-material-design: true + assets: + - assets/sounds/ring.wav diff --git a/apps/native/scripts/publish-apk.sh b/apps/native/scripts/publish-apk.sh new file mode 100755 index 0000000..132ce53 --- /dev/null +++ b/apps/native/scripts/publish-apk.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +APK="$ROOT/build/app/outputs/flutter-apk/app-release.apk" +DEST_DIR="${OA_RELEASE_DIR:-/opt/apps/native/dist}" +DEST="$DEST_DIR/fengying-oa.apk" +if [[ ! -f "$APK" ]]; then + echo "先执行 flutter build apk --release" >&2 + exit 1 +fi +mkdir -p "$DEST_DIR" +cp -f "$APK" "$DEST" +chmod 644 "$DEST" +if [[ -d /opt/apps/web/dist ]]; then + cp -f "$APK" /opt/apps/web/dist/fengying-oa.apk +fi +echo "已发布 $DEST" +echo "请确认 GET https://oa.fysxkj.com/fengying-oa.apk 可下载,并到系统设置 · 手机端升级登记 build。" diff --git a/apps/native/test/widget_test.dart b/apps/native/test/widget_test.dart new file mode 100644 index 0000000..f755279 --- /dev/null +++ b/apps/native/test/widget_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fengying_oa/app_config.dart'; + +void main() { + test('brand', () { + expect(AppConfig.brand.contains('风影'), isTrue); + expect(AppConfig.defaultImPort, 8901); + expect(AppConfig.version, '3.1.8'); + expect(AppConfig.build, 318); + }); +} diff --git a/apps/native/windows/.gitignore b/apps/native/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/apps/native/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/apps/native/windows/CMakeLists.txt b/apps/native/windows/CMakeLists.txt new file mode 100644 index 0000000..2dd2d54 --- /dev/null +++ b/apps/native/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(fengying_oa LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "fengying_oa") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100" /utf-8) + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/apps/native/windows/runner/CMakeLists.txt b/apps/native/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/apps/native/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/apps/native/windows/runner/Runner.rc b/apps/native/windows/runner/Runner.rc new file mode 100644 index 0000000..fef6d64 --- /dev/null +++ b/apps/native/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.fysxkj" "\0" + VALUE "FileDescription", "Fengying OA" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "fengying_oa" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.fysxkj. All rights reserved." "\0" + VALUE "OriginalFilename", "fengying_oa.exe" "\0" + VALUE "ProductName", "Fengying OA" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/apps/native/windows/runner/flutter_window.cpp b/apps/native/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..bde9ab8 --- /dev/null +++ b/apps/native/windows/runner/flutter_window.cpp @@ -0,0 +1,164 @@ +#include "flutter_window.h" + +#include + +#include +#include +#include +#include +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + + static bool im_initialized = false; + im_channel_ = std::make_unique>( + flutter_controller_->engine()->messenger(), "com.fysxkj.oa/im", + &flutter::StandardMethodCodec::GetInstance()); + im_channel_->SetMethodCallHandler( + [](const flutter::MethodCall& call, + std::unique_ptr> result) { + if (call.method_name() == "init") { + im_initialized = true; + flutter::EncodableMap map; + map[flutter::EncodableValue("ok")] = flutter::EncodableValue(true); + map[flutter::EncodableValue("initialized")] = + flutter::EncodableValue(true); + map[flutter::EncodableValue("engine")] = + flutter::EncodableValue("dart-tcp"); + result->Success(flutter::EncodableValue(map)); + } else if (call.method_name() == "release") { + im_initialized = false; + flutter::EncodableMap map; + map[flutter::EncodableValue("ok")] = flutter::EncodableValue(true); + result->Success(flutter::EncodableValue(map)); + } else if (call.method_name() == "engine") { + result->Success(flutter::EncodableValue( + im_initialized ? "dart-tcp" : "idle")); + } else { + result->NotImplemented(); + } + }); + + ota_channel_ = std::make_unique>( + flutter_controller_->engine()->messenger(), "com.fysxkj.oa/ota", + &flutter::StandardMethodCodec::GetInstance()); + ota_channel_->SetMethodCallHandler( + [](const flutter::MethodCall& call, + std::unique_ptr> result) { + if (call.method_name() == "startScreenshotWatch" || + call.method_name() == "stopScreenshotWatch" || + call.method_name() == "preferMaxRefresh") { + result->Success(flutter::EncodableValue(true)); + } else { + result->NotImplemented(); + } + }); + + device_channel_ = std::make_unique>( + flutter_controller_->engine()->messenger(), "com.fysxkj.oa/device", + &flutter::StandardMethodCodec::GetInstance()); + device_channel_->SetStreamHandler( + std::make_unique>( + [this](const flutter::EncodableValue*, + std::unique_ptr>&& events) + -> std::unique_ptr> { + screenshot_sink_ = std::move(events); + return nullptr; + }, + [this](const flutter::EncodableValue*) + -> std::unique_ptr> { + screenshot_sink_.reset(); + return nullptr; + })); + + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + AddClipboardFormatListener(GetHandle()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::EmitScreenshot() { + const ULONGLONG now = GetTickCount64(); + if (now - last_shot_at_ < 1500) return; + last_shot_at_ = now; + if (screenshot_sink_) { + screenshot_sink_->Success(flutter::EncodableValue("screenshot")); + } +} + +void FlutterWindow::OnDestroy() { + if (GetHandle()) { + RemoveClipboardFormatListener(GetHandle()); + } + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + case WM_CLIPBOARDUPDATE: { + if (OpenClipboard(nullptr)) { + const bool image = IsClipboardFormatAvailable(CF_BITMAP) || + IsClipboardFormatAvailable(CF_DIB) || + IsClipboardFormatAvailable( + RegisterClipboardFormatW(L"PNG")); + CloseClipboard(); + if (image) EmitScreenshot(); + } + break; + } + case WM_KEYUP: + case WM_SYSKEYUP: + if (wparam == VK_SNAPSHOT) { + EmitScreenshot(); + } + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/apps/native/windows/runner/flutter_window.h b/apps/native/windows/runner/flutter_window.h new file mode 100644 index 0000000..ee93c60 --- /dev/null +++ b/apps/native/windows/runner/flutter_window.h @@ -0,0 +1,39 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "win32_window.h" + +class FlutterWindow : public Win32Window { + public: + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + void EmitScreenshot(); + + flutter::DartProject project_; + std::unique_ptr flutter_controller_; + std::unique_ptr> im_channel_; + std::unique_ptr> ota_channel_; + std::unique_ptr> device_channel_; + std::unique_ptr> screenshot_sink_; + ULONGLONG last_shot_at_ = 0; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/apps/native/windows/runner/main.cpp b/apps/native/windows/runner/main.cpp new file mode 100644 index 0000000..9ff8faf --- /dev/null +++ b/apps/native/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1400, 900); + if (!window.Create(L"\u98ce\u5f71\u529e\u516c", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/apps/native/windows/runner/resource.h b/apps/native/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/apps/native/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/apps/native/windows/runner/resources/app_icon.ico b/apps/native/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/apps/native/windows/runner/resources/app_icon.ico differ diff --git a/apps/native/windows/runner/runner.exe.manifest b/apps/native/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/apps/native/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/apps/native/windows/runner/utils.cpp b/apps/native/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/apps/native/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/apps/native/windows/runner/utils.h b/apps/native/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/apps/native/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/apps/native/windows/runner/win32_window.cpp b/apps/native/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/apps/native/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/apps/native/windows/runner/win32_window.h b/apps/native/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/apps/native/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..74872fd --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,50 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default tseslint.config({ + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` +- Optionally add `...tseslint.configs.stylisticTypeChecked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: + +```js +// eslint.config.js +import react from 'eslint-plugin-react' + +export default tseslint.config({ + // Set the react version + settings: { react: { version: '18.3' } }, + plugins: { + // Add the react plugin + react, + }, + rules: { + // other rules... + // Enable its recommended rules + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + }, +}) +``` diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..eac9f8a --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,18 @@ + + + + + + 风影智慧办公综合平台 + + + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..5b64622 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,47 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@ant-design/icons": "^5.5.2", + "@tanstack/react-query": "^5.102.8", + "@wangeditor/editor": "^5.1.23", + "@wangeditor/editor-for-react": "^1.0.6", + "antd": "^5.22.6", + "axios": "^1.20.0", + "dayjs": "^1.11.23", + "livekit-client": "^2.22.1", + "mammoth": "^1.12.2", + "pdfjs-dist": "4.10.38", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "xlsx": "^0.18.5", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "eslint": "^9.13.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.11.0", + "typescript": "~5.6.2", + "typescript-eslint": "^8.11.0", + "vite": "^5.4.10" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + } +} diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml new file mode 100644 index 0000000..6f4bd68 --- /dev/null +++ b/apps/web/pnpm-lock.yaml @@ -0,0 +1,3217 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@ant-design/icons': + specifier: ^5.5.2 + version: 5.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-query': + specifier: ^5.102.8 + version: 5.102.8(react@18.3.1) + antd: + specifier: ^5.22.6 + version: 5.22.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + axios: + specifier: ^1.20.0 + version: 1.20.0 + dayjs: + specifier: ^1.11.23 + version: 1.11.23 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.28.0 + version: 6.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + zustand: + specifier: ^5.0.15 + version: 5.0.15(@types/react@18.3.31)(react@18.3.1) + devDependencies: + '@eslint/js': + specifier: ^9.13.0 + version: 9.39.5 + '@types/react': + specifier: ^18.3.12 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.31) + '@vitejs/plugin-react': + specifier: ^4.3.3 + version: 4.7.0(vite@5.4.21) + eslint: + specifier: ^9.13.0 + version: 9.39.5 + eslint-plugin-react-hooks: + specifier: ^5.0.0 + version: 5.2.0(eslint@9.39.5) + eslint-plugin-react-refresh: + specifier: ^0.4.14 + version: 0.4.26(eslint@9.39.5) + globals: + specifier: ^15.11.0 + version: 15.15.0 + typescript: + specifier: ~5.6.2 + version: 5.6.3 + typescript-eslint: + specifier: ^8.11.0 + version: 8.68.0(eslint@9.39.5)(typescript@5.6.3) + vite: + specifier: ^5.4.10 + version: 5.4.21 + +packages: + + '@ant-design/colors@7.2.1': + resolution: {integrity: sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==} + + '@ant-design/cssinjs-utils@1.1.3': + resolution: {integrity: sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@ant-design/cssinjs@1.24.0': + resolution: {integrity: sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/fast-color@2.0.6': + resolution: {integrity: sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==} + engines: {node: '>=8.x'} + + '@ant-design/icons-svg@4.5.0': + resolution: {integrity: sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==} + + '@ant-design/icons@5.5.2': + resolution: {integrity: sha512-xc53rjVBl9v2BqFxUjZGti/RfdDeA8/6KYglmInM2PNqSXc/WfuGDTifJI/ZsokJK0aeKvOIbXc9y2g8ILAhEA==} + engines: {node: '>=8'} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/react-slick@1.1.2': + resolution: {integrity: sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==} + peerDependencies: + react: '>=16.9.0' + + '@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/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.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'} + + '@ctrl/tinycolor@3.6.1': + resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} + engines: {node: '>=10'} + + '@emotion/hash@0.8.0': + resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} + + '@emotion/unitless@0.7.5': + resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.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==} + + '@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] + + '@rc-component/async-validator@5.1.2': + resolution: {integrity: sha512-WYbrZSjzznU1ekD0qFq2qRxt309VoS61MTG5npnFQlKYcoy9IzU8T+ZCIhq5bGAXRbXysABFWTspicMfmWFwow==} + engines: {node: '>=14.x'} + + '@rc-component/color-picker@2.0.1': + resolution: {integrity: sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/context@1.4.0': + resolution: {integrity: sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/mini-decimal@1.1.4': + resolution: {integrity: sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==} + engines: {node: '>=8.x'} + + '@rc-component/mutate-observer@1.1.0': + resolution: {integrity: sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/portal@1.1.2': + resolution: {integrity: sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/qrcode@1.0.1': + resolution: {integrity: sha512-g8eeeaMyFXVlq8cZUeaxCDhfIYjpao0l9cvm5gFwKXy/Vm1yDWV7h2sjH5jHYzdFedlVKBpATFB1VKMrHzwaWQ==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/tour@1.15.1': + resolution: {integrity: sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/trigger@2.3.1': + resolution: {integrity: sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@remix-run/router@1.21.0': + resolution: {integrity: sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==} + engines: {node: '>=14.0.0'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.63.1': + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.1': + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.1': + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.1': + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.1': + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.1': + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.63.1': + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.63.1': + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.63.1': + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.63.1': + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.63.1': + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.1': + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.1': + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.1': + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} + cpu: [x64] + os: [win32] + + '@tanstack/query-core@5.102.8': + resolution: {integrity: sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==} + + '@tanstack/react-query@5.102.8': + resolution: {integrity: sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==} + peerDependencies: + react: ^18 || ^19 + + '@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/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + + '@typescript-eslint/eslint-plugin@8.68.0': + resolution: {integrity: sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.68.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.68.0': + resolution: {integrity: sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.68.0': + resolution: {integrity: sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.68.0': + resolution: {integrity: sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.68.0': + resolution: {integrity: sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.68.0': + resolution: {integrity: sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.68.0': + resolution: {integrity: sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.68.0': + resolution: {integrity: sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.68.0': + resolution: {integrity: sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.68.0': + resolution: {integrity: sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@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 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + antd@5.22.6: + resolution: {integrity: sha512-ZYURSV3FR8qQgbfpa554thlO07L6PeHwhAM0wmxnobOBogND/HqSnTU+UZTqT2b2y9MxSfAIu5Xn1uEM9UpceQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + 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'} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + 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==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + compute-scroll-into-view@3.1.1: + resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + copy-to-clipboard@3.3.3: + resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + + 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 + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.416: + resolution: {integrity: sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json2mq@0.2.0: + resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + rc-cascader@3.30.0: + resolution: {integrity: sha512-rrzSbk1Bdqbu+pDwiLCLHu72+lwX9BZ28+JKzoi0DWZ4N29QYFeip8Gctl33QVd2Xg3Rf14D3yAOG76ElJw16w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-checkbox@3.3.0: + resolution: {integrity: sha512-Ih3ZaAcoAiFKJjifzwsGiT/f/quIkxJoklW4yKGho14Olulwn8gN7hOBve0/WGDg5o/l/5mL0w7ff7/YGvefVw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-collapse@3.9.0: + resolution: {integrity: sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-dialog@9.6.0: + resolution: {integrity: sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-drawer@7.2.0: + resolution: {integrity: sha512-9lOQ7kBekEJRdEpScHvtmEtXnAsy+NGDXiRWc2ZVC7QXAazNVbeT4EraQKYwCME8BJLa8Bxqxvs5swwyOepRwg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-dropdown@4.2.1: + resolution: {integrity: sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==} + peerDependencies: + react: '>=16.11.0' + react-dom: '>=16.11.0' + + rc-field-form@2.7.1: + resolution: {integrity: sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-image@7.11.1: + resolution: {integrity: sha512-XuoWx4KUXg7hNy5mRTy1i8c8p3K8boWg6UajbHpDXS5AlRVucNfTi5YxTtPBTBzegxAZpvuLfh3emXFt6ybUdA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-input-number@9.3.0: + resolution: {integrity: sha512-JQ363ywqRyxwgVxpg2z2kja3CehTpYdqR7emJ/6yJjRdbvo+RvfE83fcpBCIJRq3zLp8SakmEXq60qzWyZ7Usw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-input@1.6.4: + resolution: {integrity: sha512-lBZhfRD4NSAUW0zOKLUeI6GJuXkxeZYi0hr8VcJgJpyTNOvHw1ysrKWAHcEOAAHj7guxgmWYSi6xWrEdfrSAsA==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + rc-mentions@2.17.0: + resolution: {integrity: sha512-sfHy+qLvc+p8jx8GUsujZWXDOIlIimp6YQz7N5ONQ6bHsa2kyG+BLa5k2wuxgebBbH97is33wxiyq5UkiXRpHA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-menu@9.16.1: + resolution: {integrity: sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-motion@2.9.5: + resolution: {integrity: sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-notification@5.6.4: + resolution: {integrity: sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-overflow@1.5.0: + resolution: {integrity: sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-pagination@5.0.0: + resolution: {integrity: sha512-QjrPvbAQwps93iluvFM62AEYglGYhWW2q/nliQqmvkTi4PXP4HHoh00iC1Sa5LLVmtWQHmG73fBi2x6H6vFHRg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-picker@4.8.3: + resolution: {integrity: sha512-hJ45qoEs4mfxXPAJdp1n3sKwADul874Cd0/HwnsEOE60H+tgiJUGgbOD62As3EG/rFVNS5AWRfBCDJJfmRqOVQ==} + engines: {node: '>=8.x'} + peerDependencies: + date-fns: '>= 2.x' + dayjs: '>= 1.x' + luxon: '>= 3.x' + moment: '>= 2.x' + react: '>=16.9.0' + react-dom: '>=16.9.0' + peerDependenciesMeta: + date-fns: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + + rc-progress@4.0.0: + resolution: {integrity: sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-rate@2.13.1: + resolution: {integrity: sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-resize-observer@1.4.3: + resolution: {integrity: sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-segmented@2.5.0: + resolution: {integrity: sha512-B28Fe3J9iUFOhFJET3RoXAPFJ2u47QvLSYcZWC4tFYNGPEjug5LAxEasZlA/PpAxhdOPqGWsGbSj7ftneukJnw==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + rc-select@14.16.8: + resolution: {integrity: sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '*' + react-dom: '*' + + rc-slider@11.1.9: + resolution: {integrity: sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-steps@6.0.1: + resolution: {integrity: sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-switch@4.1.0: + resolution: {integrity: sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-table@7.49.0: + resolution: {integrity: sha512-/FoPLX94muAQOxVpi1jhnpKjOIqUbT81eELQPAzSXOke4ky4oCWYUXOcVpL31ZCO90xScwVSXRd7coqtgtB1Ng==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tabs@15.4.0: + resolution: {integrity: sha512-llKuyiAVqmXm2z7OrmhX5cNb2ueZaL8ZyA2P4R+6/72NYYcbEgOXibwHiQCFY2RiN3swXl53SIABi2CumUS02g==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-textarea@1.8.2: + resolution: {integrity: sha512-UFAezAqltyR00a8Lf0IPAyTd29Jj9ee8wt8DqXyDMal7r/Cg/nDt3e1OOv3Th4W6mKaZijjgwuPXhAfVNTN8sw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tooltip@6.2.1: + resolution: {integrity: sha512-rws0duD/3sHHsD905Nex7FvoUGy2UBQRhTkKxeEvr2FB+r21HsOxcDJI0TzyO8NHhnAA8ILr8pfbSBg5Jj5KBg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tree-select@5.24.5: + resolution: {integrity: sha512-PnyR8LZJWaiEFw0SHRqo4MNQWyyZsyMs8eNmo68uXZWjxc7QqeWcjPPoONN0rc90c3HZqGF9z+Roz+GLzY5GXA==} + peerDependencies: + react: '*' + react-dom: '*' + + rc-tree@5.10.1: + resolution: {integrity: sha512-FPXb3tT/u39mgjr6JNlHaUTYfHkVGW56XaGDahDpEFLGsnPxGcVLNTjcqoQb/GNbSCycl7tD7EvIymwOTP0+Yw==} + engines: {node: '>=10.x'} + peerDependencies: + react: '*' + react-dom: '*' + + rc-upload@4.8.1: + resolution: {integrity: sha512-toEAhwl4hjLAI1u8/CgKWt30BR06ulPa4iGQSMvSXoHzO88gPCslxqV/mnn4gJU7PDoltGIC9Eh+wkeudqgHyw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-util@5.44.4: + resolution: {integrity: sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-virtual-list@3.19.2: + resolution: {integrity: sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@6.28.0: + resolution: {integrity: sha512-kQ7Unsl5YdyOltsPGl31zOjLrDv+m2VcIEcIHqYYD3Lp0UppLjrzcfJqDJwXxFw3TH/yvapbnUvPlAj7Kx5nbg==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.28.0: + resolution: {integrity: sha512-HrYdIFqdrnhDw0PqG/AKjAqEqM7AvxCz0DQ4h2W8k6nqmc5uRBYDag0SBxx9iYz5G8gnuNVLzUe13wl9eAsXXg==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + rollup@4.63.1: + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + + 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 + + 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'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + string-convert@0.2.1: + resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + toggle-selection@1.0.6: + resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.68.0: + resolution: {integrity: sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zustand@5.0.15: + resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@ant-design/colors@7.2.1': + dependencies: + '@ant-design/fast-color': 2.0.6 + + '@ant-design/cssinjs-utils@1.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ant-design/cssinjs': 1.24.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@babel/runtime': 7.29.7 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@ant-design/cssinjs@1.24.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@emotion/hash': 0.8.0 + '@emotion/unitless': 0.7.5 + classnames: 2.5.1 + csstype: 3.2.3 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + stylis: 4.4.0 + + '@ant-design/fast-color@2.0.6': + dependencies: + '@babel/runtime': 7.29.7 + + '@ant-design/icons-svg@4.5.0': {} + + '@ant-design/icons@5.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ant-design/colors': 7.2.1 + '@ant-design/icons-svg': 4.5.0 + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@ant-design/react-slick@1.1.2(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + json2mq: 0.2.0 + react: 18.3.1 + resize-observer-polyfill: 1.5.1 + throttle-debounce: 5.0.2 + + '@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': + 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) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + 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.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + 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)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@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': + 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 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@ctrl/tinycolor@3.6.1': {} + + '@emotion/hash@0.8.0': {} + + '@emotion/unitless@0.7.5': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5)': + dependencies: + eslint: 9.39.5 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.2 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@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 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rc-component/async-validator@5.1.2': + dependencies: + '@babel/runtime': 7.29.7 + + '@rc-component/color-picker@2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ant-design/fast-color': 2.0.6 + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/context@1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/mini-decimal@1.1.4': + dependencies: + '@babel/runtime': 7.29.7 + + '@rc-component/mutate-observer@1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/portal@1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/qrcode@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/tour@1.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/trigger@2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@remix-run/router@1.21.0': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.63.1': + optional: true + + '@rollup/rollup-android-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-x64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.1': + optional: true + + '@tanstack/query-core@5.102.8': {} + + '@tanstack/react-query@5.102.8(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.102.8 + react: 18.3.1 + + '@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/json-schema@7.0.15': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5)(typescript@5.6.3))(eslint@9.39.5)(typescript@5.6.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.68.0(eslint@9.39.5)(typescript@5.6.3) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/type-utils': 8.68.0(eslint@9.39.5)(typescript@5.6.3) + '@typescript-eslint/utils': 8.68.0(eslint@9.39.5)(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 8.68.0 + eslint: 9.39.5 + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.6.3) + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.68.0(eslint@9.39.5)(typescript@5.6.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 8.68.0 + debug: 4.4.3 + eslint: 9.39.5 + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.68.0(typescript@5.6.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@5.6.3) + '@typescript-eslint/types': 8.68.0 + debug: 4.4.3 + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.68.0': + dependencies: + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 + + '@typescript-eslint/tsconfig-utils@8.68.0(typescript@5.6.3)': + dependencies: + typescript: 5.6.3 + + '@typescript-eslint/type-utils@8.68.0(eslint@9.39.5)(typescript@5.6.3)': + dependencies: + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(typescript@5.6.3) + '@typescript-eslint/utils': 8.68.0(eslint@9.39.5)(typescript@5.6.3) + debug: 4.4.3 + eslint: 9.39.5 + ts-api-utils: 2.5.0(typescript@5.6.3) + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.68.0': {} + + '@typescript-eslint/typescript-estree@8.68.0(typescript@5.6.3)': + dependencies: + '@typescript-eslint/project-service': 8.68.0(typescript@5.6.3) + '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@5.6.3) + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.6.3) + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.68.0(eslint@9.39.5)(typescript@5.6.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(typescript@5.6.3) + eslint: 9.39.5 + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.68.0': + dependencies: + '@typescript-eslint/types': 8.68.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@4.7.0(vite@5.4.21)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21 + transitivePeerDependencies: + - supports-color + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + antd@5.22.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@ant-design/colors': 7.2.1 + '@ant-design/cssinjs': 1.24.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/cssinjs-utils': 1.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/icons': 5.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/react-slick': 1.1.2(react@18.3.1) + '@babel/runtime': 7.29.7 + '@ctrl/tinycolor': 3.6.1 + '@rc-component/color-picker': 2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/mutate-observer': 1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/qrcode': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tour': 1.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + copy-to-clipboard: 3.3.3 + dayjs: 1.11.23 + rc-cascader: 3.30.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-checkbox: 3.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-collapse: 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-dialog: 9.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-drawer: 7.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-dropdown: 4.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-field-form: 2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-image: 7.11.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-input: 1.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-input-number: 9.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-mentions: 2.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-menu: 9.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-notification: 5.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-pagination: 5.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-picker: 4.8.3(dayjs@1.11.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-progress: 4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-rate: 2.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-segmented: 2.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-select: 14.16.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-slider: 11.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-steps: 6.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-switch: 4.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-table: 7.49.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tabs: 15.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-textarea: 1.8.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tooltip: 6.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tree: 5.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tree-select: 5.24.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-upload: 4.8.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + scroll-into-view-if-needed: 3.1.0 + throttle-debounce: 5.0.2 + transitivePeerDependencies: + - date-fns + - luxon + - moment + + argparse@2.0.1: {} + + asynckit@0.4.0: {} + + axios@1.20.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.20: {} + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.416 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.8) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001810: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + classnames@2.5.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + compute-scroll-into-view@3.1.1: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + copy-to-clipboard@3.3.3: + dependencies: + toggle-selection: 1.0.6 + + 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: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.416: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@5.2.0(eslint@9.39.5): + dependencies: + eslint: 9.39.5 + + eslint-plugin-react-refresh@0.4.26(eslint@9.39.5): + dependencies: + eslint: 9.39.5 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + follow-redirects@1.16.0: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@15.15.0: {} + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json2mq@0.2.0: + dependencies: + string-convert: 0.2.1 + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.54: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + rc-cascader@3.30.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-select: 14.16.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tree: 5.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-checkbox@3.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-collapse@3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-dialog@9.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-drawer@7.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-dropdown@4.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-field-form@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/async-validator': 5.1.2 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-image@7.11.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-dialog: 9.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-input-number@9.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/mini-decimal': 1.1.4 + classnames: 2.5.1 + rc-input: 1.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-input@1.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-mentions@2.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-input: 1.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-menu: 9.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-textarea: 1.8.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-menu@9.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-overflow: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-motion@2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-notification@5.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-overflow@1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-pagination@5.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-picker@4.8.3(dayjs@1.11.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-overflow: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + dayjs: 1.11.23 + + rc-progress@4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-rate@2.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-resize-observer@1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + resize-observer-polyfill: 1.5.1 + + rc-segmented@2.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-select@14.16.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-overflow: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-virtual-list: 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-slider@11.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-steps@6.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-switch@4.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-table@7.49.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/context': 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-virtual-list: 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-tabs@15.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-dropdown: 4.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-menu: 9.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-textarea@1.8.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-input: 1.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-tooltip@6.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-tree-select@5.24.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-select: 14.16.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tree: 5.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-tree@5.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-virtual-list: 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-upload@4.8.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-util@5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + + rc-virtual-list@3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@18.3.1: {} + + react-refresh@0.17.0: {} + + react-router-dom@6.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.21.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.28.0(react@18.3.1) + + react-router@6.28.0(react@18.3.1): + dependencies: + '@remix-run/router': 1.21.0 + react: 18.3.1 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + resize-observer-polyfill@1.5.1: {} + + resolve-from@4.0.0: {} + + rollup@4.63.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.1 + '@rollup/rollup-android-arm64': 4.63.1 + '@rollup/rollup-darwin-arm64': 4.63.1 + '@rollup/rollup-darwin-x64': 4.63.1 + '@rollup/rollup-freebsd-arm64': 4.63.1 + '@rollup/rollup-freebsd-x64': 4.63.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 + '@rollup/rollup-linux-arm-musleabihf': 4.63.1 + '@rollup/rollup-linux-arm64-gnu': 4.63.1 + '@rollup/rollup-linux-arm64-musl': 4.63.1 + '@rollup/rollup-linux-loong64-gnu': 4.63.1 + '@rollup/rollup-linux-loong64-musl': 4.63.1 + '@rollup/rollup-linux-ppc64-gnu': 4.63.1 + '@rollup/rollup-linux-ppc64-musl': 4.63.1 + '@rollup/rollup-linux-riscv64-gnu': 4.63.1 + '@rollup/rollup-linux-riscv64-musl': 4.63.1 + '@rollup/rollup-linux-s390x-gnu': 4.63.1 + '@rollup/rollup-linux-x64-gnu': 4.63.1 + '@rollup/rollup-linux-x64-musl': 4.63.1 + '@rollup/rollup-openbsd-x64': 4.63.1 + '@rollup/rollup-openharmony-arm64': 4.63.1 + '@rollup/rollup-win32-arm64-msvc': 4.63.1 + '@rollup/rollup-win32-ia32-msvc': 4.63.1 + '@rollup/rollup-win32-x64-gnu': 4.63.1 + '@rollup/rollup-win32-x64-msvc': 4.63.1 + fsevents: 2.3.3 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + scroll-into-view-if-needed@3.1.0: + dependencies: + compute-scroll-into-view: 3.1.1 + + semver@6.3.1: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-js@1.2.1: {} + + string-convert@0.2.1: {} + + strip-json-comments@3.1.1: {} + + stylis@4.4.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + throttle-debounce@5.0.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + toggle-selection@1.0.6: {} + + ts-api-utils@2.5.0(typescript@5.6.3): + dependencies: + typescript: 5.6.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.68.0(eslint@9.39.5)(typescript@5.6.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5)(typescript@5.6.3))(eslint@9.39.5)(typescript@5.6.3) + '@typescript-eslint/parser': 8.68.0(eslint@9.39.5)(typescript@5.6.3) + '@typescript-eslint/typescript-estree': 8.68.0(typescript@5.6.3) + '@typescript-eslint/utils': 8.68.0(eslint@9.39.5)(typescript@5.6.3) + eslint: 9.39.5 + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + typescript@5.6.3: {} + + update-browserslist-db@1.3.2(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.26 + rollup: 4.63.1 + optionalDependencies: + fsevents: 2.3.3 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zustand@5.0.15(@types/react@18.3.31)(react@18.3.1): + optionalDependencies: + '@types/react': 18.3.31 + react: 18.3.1 diff --git a/apps/web/public/brand/fengying-logo-1024-opaque.png b/apps/web/public/brand/fengying-logo-1024-opaque.png new file mode 100644 index 0000000..b6b7d50 Binary files /dev/null and b/apps/web/public/brand/fengying-logo-1024-opaque.png differ diff --git a/apps/web/public/brand/fengying-logo-1024.png b/apps/web/public/brand/fengying-logo-1024.png new file mode 100644 index 0000000..a06e34f Binary files /dev/null and b/apps/web/public/brand/fengying-logo-1024.png differ diff --git a/apps/web/public/brand/fengying-logo-512-opaque.png b/apps/web/public/brand/fengying-logo-512-opaque.png new file mode 100644 index 0000000..b8fab88 Binary files /dev/null and b/apps/web/public/brand/fengying-logo-512-opaque.png differ diff --git a/apps/web/public/brand/fengying-logo-512.png b/apps/web/public/brand/fengying-logo-512.png new file mode 100644 index 0000000..0e9f647 Binary files /dev/null and b/apps/web/public/brand/fengying-logo-512.png differ diff --git a/apps/web/public/brand/fengying-logo.svg b/apps/web/public/brand/fengying-logo.svg new file mode 100644 index 0000000..9c6985e --- /dev/null +++ b/apps/web/public/brand/fengying-logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/web/public/vite.svg b/apps/web/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/web/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..07dba47 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,405 @@ +import type { ReactNode } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { useEffect } from 'react'; +import { Spin } from 'antd'; +import { useAuth } from './store/auth'; +import AppLayout from './layouts/AppLayout'; +import LoginPage from './pages/LoginPage'; +import DesktopLoginPage from './pages/DesktopLoginPage'; +import LegalPage from './pages/LegalPage'; +import OverviewPage from './pages/OverviewPage'; +import BidCreatePage from './pages/BidCreatePage'; +import BidListPage from './pages/BidListPage'; +import BidDetailPage from './pages/BidDetailPage'; +import ContractListPage from './pages/ContractListPage'; +import ContractDetailPage from './pages/ContractDetailPage'; +import LaborContractPage from './pages/LaborContractPage'; +import OnboardPage from './pages/OnboardPage'; +import WorkAssignPage from './pages/WorkAssignPage'; +import { AssetRegistryPage } from './pages/AssetRegistryPage'; +import OfficeApprovalsPage from './pages/OfficeApprovalsPage'; +import OfficeTodosPage from './pages/OfficeTodosPage'; +import OfficeApplyPage from './pages/OfficeApplyPage'; +import MyFlowPage from './pages/MyFlowPage'; +import NoticePage from './pages/NoticePage'; +import NoticeDetailPage from './pages/NoticeDetailPage'; +import HrNoticePage from './pages/HrNoticePage'; +import DirectoryPage from './pages/DirectoryPage'; +import ImPage from './pages/im/ImPage'; +import MyExpensesPage from './pages/MyExpensesPage'; +import ExpenseDetailPage from './pages/ExpenseDetailPage'; +import ExpenseListPage, { LoanListPage } from './pages/ExpenseListPage'; +import LoanDetailPage from './pages/LoanDetailPage'; +import AssetPurchaseLedgerPage from './pages/AssetPurchaseLedgerPage'; +import BondListPage from './pages/BondListPage'; +import InvoiceListPage from './pages/InvoiceListPage'; +import ProjectListPage from './pages/ProjectListPage'; +import ProjectDetailPage from './pages/ProjectDetailPage'; +import TaskListPage, { TimesheetPage } from './pages/TaskListPage'; +import QualificationListPage, { BorrowListPage } from './pages/QualificationListPage'; +import SealRequestListPage from './pages/SealRequestListPage'; +import CalendarPage from './pages/CalendarPage'; +import OrgPage from './pages/OrganizationPage'; +import EmployeeListPage from './pages/EmployeePage'; +import HrApplyPage from './pages/HrApplyPage'; +import HrLeaveDetailPage from './pages/HrLeaveDetailPage'; +import { + AttendancePage, + LifecyclePage, + PayrollPage, + PerformancePage, +} from './pages/HrPages'; +import { AttendanceRulesPage, HrParamsPage } from './pages/HrSettingsPage'; +import { + AclPage, + AclPermsPage, + AssetPage, + CashLockPage, + ConversionPage, + EfficiencyPage, + LogsPage, + LostBidsPage, + NumberRulesPage, + PaymentPage, + ProfitPage, + PurchasePage, + ResourceBoardPage, + SealRegistryPage, + WorkReportPage, +} from './pages/OpsPages'; +import { ForbiddenPage, NotFoundPage } from './pages/StatusPages'; +import SmsPage from './pages/SmsPage'; +import AppReleasePage from './pages/AppReleasePage'; +import MessagesChannelPage from './pages/MessagesChannelPage'; +import OfficeSmsPage from './pages/OfficeSmsPage'; +import { + ContactDirectoryPage, + DictsPage, + LegalEntityPage, + TenderPartyPage, +} from './pages/PartyPages'; + +const NOTICE_PUBLISH_ROLES = [ + 'hr', + 'biz_director', + 'tech_director', + 'rd_director', + '3d_director', + 'video_director', + 'material_director', +]; + +function canSee(permissions: string[], roles: string[], code: string) { + if (roles.includes('admin') || roles.includes('owner')) return true; + if ((code === 'hr:notices' || code === 'office:sms') && NOTICE_PUBLISH_ROLES.some((r) => roles.includes(r))) return true; + return permissions.includes(code) || permissions.some((p) => p.startsWith(`${code}:`)); +} + +function Require({ code, children }: { code: string; children: ReactNode }) { + const roles = useAuth((s) => s.roles); + const permissions = useAuth((s) => s.permissions); + if (!canSee(permissions, roles, code)) return ; + return <>{children}; +} + +function Guard({ children, allowOnboard }: { children: ReactNode; allowOnboard?: boolean }) { + const { loaded, username, loadSession, needsOnboard } = useAuth(); + useEffect(() => { + void loadSession(); + }, [loadSession]); + if (!loaded) { + return ( +
+ +
+ ); + } + if (!username) return ; + if (needsOnboard && !allowOnboard) return ; + return <>{children}; +} + +export default function App() { + return ( + + } /> + } /> + } /> + + + + } + /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + + + + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } + /> + } /> + } /> + } /> + + + + } + /> + } /> + }> + } /> + } /> + + }> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + } + /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } + /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + } /> + } /> + + } + /> + } /> + } /> + } /> + } /> + }> + } /> + + }> + } /> + + + + + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/apps/web/src/StatusTag.tsx b/apps/web/src/StatusTag.tsx new file mode 100644 index 0000000..dbabe31 --- /dev/null +++ b/apps/web/src/StatusTag.tsx @@ -0,0 +1,8 @@ +import { Tag } from 'antd'; +import { ZH_COLOR, zhCtx } from './labels'; + +export function StatusTag({ value, context }: { value?: string | number | null; context?: string }) { + if (value === undefined || value === null || value === '') return ; + const key = String(value); + return {zhCtx(key, context)}; +} diff --git a/apps/web/src/access.ts b/apps/web/src/access.ts new file mode 100644 index 0000000..f7b9d38 --- /dev/null +++ b/apps/web/src/access.ts @@ -0,0 +1,32 @@ +const ADMIN = ['admin', 'owner']; + +export function canHr(roles: string[]) { + return [...ADMIN, 'hr', 'biz_director'].some((r) => roles.includes(r)); +} + +export function canConfirmPay(roles: string[]) { + return [...ADMIN, 'hr', 'biz_director'].some((r) => roles.includes(r)); +} + +export function canApproveFin(roles: string[]) { + return [...ADMIN, 'finance', 'biz_director'].some((r) => roles.includes(r)); +} + +const ACTIONS = new Set(['view', 'create', 'edit', 'delete', 'approve']); + +/** 菜单下的具体操作。还没配操作项时,有该菜单就视为能做。 */ +export function canDo( + permissions: string[], + roles: string[], + menuCode: string, + action: 'view' | 'create' | 'edit' | 'delete' | 'approve', +) { + if (ADMIN.some((r) => roles.includes(r))) return true; + if (permissions.includes(`${menuCode}:${action}`)) return true; + const hasActions = permissions.some((p) => { + const tail = p.slice(menuCode.length + 1); + return p.startsWith(`${menuCode}:`) && ACTIONS.has(tail); + }); + if (hasActions) return false; + return permissions.includes(menuCode) || permissions.some((p) => p.startsWith(`${menuCode}:`)); +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts new file mode 100644 index 0000000..e89994c --- /dev/null +++ b/apps/web/src/api/client.ts @@ -0,0 +1,165 @@ +import axios from 'axios'; + +function requestId() { + const c = globalThis.crypto as Crypto | undefined; + if (c && typeof c.randomUUID === 'function') { + return c.randomUUID(); + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => { + const n = (Math.random() * 16) | 0; + const v = ch === 'x' ? n : (n & 0x3) | 0x8; + return v.toString(16); + }); +} + +const mem = { access: '', refresh: '', kind: 'web' }; +const tabCh = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('fy-web-session') : null; + +function persistKind(kind?: string | null) { + const v = kind === 'desktop' || kind === 'mobile' ? kind : 'web'; + mem.kind = v; + sessionStorage.setItem('clientKind', v); +} + +function applyTokens(access?: string | null, refresh?: string | null, kind?: string | null, broadcast = true) { + if (access) { + mem.access = access; + sessionStorage.setItem('accessToken', access); + } + if (refresh) { + mem.refresh = refresh; + sessionStorage.setItem('refreshToken', refresh); + } + if (kind) persistKind(kind); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + if (broadcast) { + tabCh?.postMessage({ type: 'set', access: mem.access, refresh: mem.refresh, kind: mem.kind }); + } +} + +tabCh?.addEventListener('message', (e: MessageEvent) => { + const data = e.data as { type?: string; access?: string; refresh?: string; kind?: string } | undefined; + if (data?.type === 'ask' && mem.access) { + tabCh.postMessage({ type: 'set', access: mem.access, refresh: mem.refresh, kind: mem.kind }); + return; + } + if (data?.type === 'set' && data.access) { + applyTokens(data.access, data.refresh, data.kind, false); + return; + } + if (data?.type === 'clear') { + mem.access = ''; + mem.refresh = ''; + sessionStorage.removeItem('accessToken'); + sessionStorage.removeItem('refreshToken'); + sessionStorage.removeItem('clientKind'); + } +}); + +export const tokenStore = { + getAccess() { + return mem.access || sessionStorage.getItem('accessToken') || ''; + }, + getRefresh() { + return mem.refresh || sessionStorage.getItem('refreshToken') || ''; + }, + getKind() { + return mem.kind || sessionStorage.getItem('clientKind') || 'web'; + }, + set(access?: string | null, refresh?: string | null, kind?: string | null) { + applyTokens(access, refresh, kind); + }, + clear() { + mem.access = ''; + mem.refresh = ''; + mem.kind = 'web'; + sessionStorage.removeItem('accessToken'); + sessionStorage.removeItem('refreshToken'); + sessionStorage.removeItem('clientKind'); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + tabCh?.postMessage({ type: 'clear' }); + }, + /** 同浏览器多标签共用会话;关浏览器后 sessionStorage 清空,需重新登录。 */ + waitShare() { + if (this.getAccess() || !tabCh) return Promise.resolve(); + return new Promise((resolve) => { + const timer = window.setTimeout(() => { + tabCh.removeEventListener('message', onMsg); + resolve(); + }, 160); + const onMsg = (e: MessageEvent) => { + if (e.data?.type === 'set' && e.data.access) { + window.clearTimeout(timer); + tabCh.removeEventListener('message', onMsg); + resolve(); + } + }; + tabCh.addEventListener('message', onMsg); + tabCh.postMessage({ type: 'ask' }); + }); + }, +}; + +let refreshing: Promise | null = null; + +export const api = axios.create({ + baseURL: '/api/v1', + timeout: 30000, +}); + +api.interceptors.request.use((config) => { + const token = tokenStore.getAccess(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + config.headers['X-Request-Id'] = requestId(); + return config; +}); + +api.interceptors.response.use( + (res) => res.data, + async (error) => { + const original = error.config; + if (error.response?.status === 401 && !original._retry) { + original._retry = true; + const refreshToken = tokenStore.getRefresh(); + if (refreshToken && !original.url?.includes('/auth/refresh')) { + try { + if (!refreshing) { + refreshing = api + .post('/auth/refresh', { refreshToken, clientKind: tokenStore.getKind() || 'web' }) + .then((data: { data?: { accessToken?: string; refreshToken?: string } }) => { + const body = (data?.data || data) as { accessToken?: string; refreshToken?: string }; + tokenStore.set(body.accessToken, body.refreshToken, tokenStore.getKind()); + return Boolean(body.accessToken); + }) + .catch(() => false) + .finally(() => { + refreshing = null; + }); + } + const ok = await refreshing; + if (ok) { + original.headers.Authorization = `Bearer ${tokenStore.getAccess()}`; + return api(original); + } + } catch { + /* fallthrough */ + } + tokenStore.clear(); + window.location.href = '/login'; + } else { + tokenStore.clear(); + if (window.location.pathname !== '/login' && !window.location.pathname.startsWith('/login/')) { + window.location.href = '/login'; + } + } + } + const message = error.response?.data?.message || error.message || '请求失败'; + return Promise.reject(new Error(message)); + }, +); + +export type ApiOk = { code: number; message: string; data: T; requestId: string }; diff --git a/apps/web/src/assets/react.svg b/apps/web/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/apps/web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/components/AmbientBackground.tsx b/apps/web/src/components/AmbientBackground.tsx new file mode 100644 index 0000000..1bd5daa --- /dev/null +++ b/apps/web/src/components/AmbientBackground.tsx @@ -0,0 +1,67 @@ +import { useEffect, useRef } from 'react'; + +export default function AmbientBackground() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let frame = 0; + let width = (canvas.width = window.innerWidth); + let height = (canvas.height = window.innerHeight); + const onResize = () => { + width = canvas.width = window.innerWidth; + height = canvas.height = window.innerHeight; + }; + window.addEventListener('resize', onResize); + + const particles = Array.from({ length: 32 }, () => ({ + x: Math.random() * width, + y: Math.random() * height, + size: Math.random() * 3 + 1.2, + speedY: -(Math.random() * 0.4 + 0.15), + speedX: (Math.random() - 0.5) * 0.2, + opacity: Math.random() * 0.5 + 0.2, + pulse: Math.random() * Math.PI, + pulseSpeed: Math.random() * 0.03 + 0.01, + })); + + const render = () => { + ctx.clearRect(0, 0, width, height); + for (const p of particles) { + p.y += p.speedY; + p.x += p.speedX; + p.pulse += p.pulseSpeed; + if (p.y < -10) { + p.y = height + 10; + p.x = Math.random() * width; + } + if (p.x < -10) p.x = width + 10; + if (p.x > width + 10) p.x = -10; + const a = p.opacity * (0.7 + 0.3 * Math.sin(p.pulse)); + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fillStyle = `rgba(107, 56, 212, ${a * 0.45})`; + ctx.fill(); + } + frame = requestAnimationFrame(render); + }; + render(); + return () => { + window.removeEventListener('resize', onResize); + cancelAnimationFrame(frame); + }; + }, []); + + return ( +
+
+
+
+ +
+ ); +} diff --git a/apps/web/src/components/BrandLogo.tsx b/apps/web/src/components/BrandLogo.tsx new file mode 100644 index 0000000..b21b057 --- /dev/null +++ b/apps/web/src/components/BrandLogo.tsx @@ -0,0 +1,40 @@ +export function BrandLogo({ size = 36, className }: { size?: number; className?: string }) { + const id = `fy-${size}`; + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/components/DesktopDownload.tsx b/apps/web/src/components/DesktopDownload.tsx new file mode 100644 index 0000000..47bd778 --- /dev/null +++ b/apps/web/src/components/DesktopDownload.tsx @@ -0,0 +1,42 @@ +import { Button, Popover, Typography } from 'antd'; +import { DownloadOutlined, LaptopOutlined } from '@ant-design/icons'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../api/client'; + +export function DesktopDownload() { + const q = useQuery({ + queryKey: ['desktop-release-public'], + queryFn: async () => { + const res = await api.get('/system/app-release'); + return ((res as { data?: { windowsUrl?: string; version?: string } }).data ?? res) as { + windowsUrl?: string; + version?: string; + }; + }, + staleTime: 60_000, + }); + const url = q.data?.windowsUrl || ''; + const version = q.data?.version || ''; + const content = url ? ( +
+ Windows 桌面端{version ? ` v${version}` : ''} + + + 下载后运行 .exe 安装 + +
+ ) : ( +
+ Windows 桌面版暂未发布 +
+ ); + return ( + + + + ); +} diff --git a/apps/web/src/components/LinuxDownload.tsx b/apps/web/src/components/LinuxDownload.tsx new file mode 100644 index 0000000..1a4434d --- /dev/null +++ b/apps/web/src/components/LinuxDownload.tsx @@ -0,0 +1,53 @@ +import { Button, Popover, Typography } from 'antd'; +import { CodeOutlined, DownloadOutlined } from '@ant-design/icons'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../api/client'; + +export function LinuxDownload() { + const q = useQuery({ + queryKey: ['linux-release-public'], + queryFn: async () => { + const res = await api.get('/system/app-release'); + return ((res as { data?: { linuxDebUrl?: string; linuxUrl?: string; version?: string } }).data ?? res) as { + linuxDebUrl?: string; + linuxUrl?: string; + version?: string; + }; + }, + staleTime: 60_000, + }); + const debUrl = q.data?.linuxDebUrl || ''; + const tarUrl = q.data?.linuxUrl || ''; + const version = q.data?.version || ''; + const content = debUrl || tarUrl ? ( +
+ Linux 桌面端{version ? ` v${version}` : ''} + {debUrl ? ( + + ) : null} + {tarUrl ? ( + + ) : null} + + {debUrl + ? 'deb 适用于 Ubuntu / Debian / Deepin 25:sudo dpkg -i fengying-oa_amd64.deb' + : '解压 tar.gz 后运行 fengying_oa'} + +
+ ) : ( +
+ Linux 桌面版暂未发布 +
+ ); + return ( + + + + ); +} diff --git a/apps/web/src/components/MobileDownload.tsx b/apps/web/src/components/MobileDownload.tsx new file mode 100644 index 0000000..e3817e6 --- /dev/null +++ b/apps/web/src/components/MobileDownload.tsx @@ -0,0 +1,48 @@ +import { Button, Popover, QRCode, Typography } from 'antd'; +import type { TooltipPlacement } from 'antd/es/tooltip'; +import { DownloadOutlined, MobileOutlined } from '@ant-design/icons'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../api/client'; + +function fallbackApk() { + if (typeof window === 'undefined') return 'https://oa.fysxkj.com/fengying-oa.apk'; + return `${window.location.origin}/fengying-oa.apk`; +} + +export function MobileDownload({ + compact = false, + placement = 'bottomRight', +}: { + compact?: boolean; + placement?: TooltipPlacement; +}) { + const q = useQuery({ + queryKey: ['app-release-public'], + queryFn: async () => { + const res = await api.get('/system/app-release'); + return ((res as { data?: { url?: string; version?: string } }).data ?? res) as { + url?: string; + version?: string; + }; + }, + staleTime: 60_000, + }); + const url = q.data?.url || fallbackApk(); + const version = q.data?.version || ''; + const card = ( +
+ + 手机扫码安装 Android 版{version ? ` v${version}` : ''} + +
+ ); + return ( + + + + ); +} diff --git a/apps/web/src/components/SiteFooter.tsx b/apps/web/src/components/SiteFooter.tsx new file mode 100644 index 0000000..72ba72a --- /dev/null +++ b/apps/web/src/components/SiteFooter.tsx @@ -0,0 +1,17 @@ +const ICP = '苏ICP备2026017371号-2'; +const COMPANY = '江苏风影随行科技有限公司'; +const PHONE = '13142801138'; + +export function SiteFooter({ compact = false }: { compact?: boolean }) { + return ( + + ); +} diff --git a/apps/web/src/deptTree.ts b/apps/web/src/deptTree.ts new file mode 100644 index 0000000..919781c --- /dev/null +++ b/apps/web/src/deptTree.ts @@ -0,0 +1,76 @@ +export type DeptNode = { + id: string; + name: string; + code?: string | null; + parentId?: string | null; +}; + +function companyRootId(depts: DeptNode[]) { + const roots = depts.filter((d) => !d.parentId); + const named = roots.find((d) => d.name?.includes('公司') || d.code === 'ROOT' || d.code === 'OA_1'); + return named?.id || roots[0]?.id; +} + +export function isTopLevelDept(depts: DeptNode[], dept: DeptNode) { + const rootId = companyRootId(depts); + if (!rootId) return !dept.parentId; + if (dept.id === rootId) return false; + return dept.parentId === rootId || !dept.parentId; +} + +export function topLevelDepartments(depts: DeptNode[]) { + return depts.filter((d) => isTopLevelDept(depts, d)).sort((a, b) => a.name.localeCompare(b.name, 'zh')); +} + +export function topLevelOf(depts: DeptNode[], deptId?: string | null): DeptNode | undefined { + if (!deptId) return undefined; + const byId = new Map(depts.map((d) => [d.id, d])); + const rootId = companyRootId(depts); + let cur = byId.get(deptId); + const seen = new Set(); + while (cur && !seen.has(cur.id)) { + seen.add(cur.id); + if (isTopLevelDept(depts, cur)) return cur; + if (!cur.parentId || cur.parentId === rootId) return cur.id === rootId ? undefined : cur; + cur = byId.get(cur.parentId); + } + return undefined; +} + +export function topLevelNameOf(depts: DeptNode[], deptId?: string | null, fallback?: string | null) { + return topLevelOf(depts, deptId)?.name || fallback || ''; +} + +export function topLevelNameByLeafName(depts: DeptNode[], name?: string | null) { + if (!name) return ''; + const leaf = depts.find((d) => d.name === name); + if (!leaf) return name; + return topLevelOf(depts, leaf.id)?.name || name; +} + +export function descendantIds(depts: DeptNode[], root?: string) { + if (!root) return null; + const kids = new Map(); + for (const d of depts) { + if (!d.parentId) continue; + const arr = kids.get(d.parentId) || []; + arr.push(d.id); + kids.set(d.parentId, arr); + } + const out = new Set([root]); + const stack = [root]; + while (stack.length) { + const id = stack.pop()!; + for (const c of kids.get(id) || []) { + if (!out.has(c)) { + out.add(c); + stack.push(c); + } + } + } + return out; +} + +export function topLevelSelectOptions(depts: DeptNode[]) { + return topLevelDepartments(depts).map((d) => ({ value: d.id, label: d.name })); +} diff --git a/apps/web/src/geo/gcj02.ts b/apps/web/src/geo/gcj02.ts new file mode 100644 index 0000000..18e0407 --- /dev/null +++ b/apps/web/src/geo/gcj02.ts @@ -0,0 +1,36 @@ +/** 浏览器/卫星 GPS 是 WGS-84,腾讯地图是 GCJ-02。国内不转换会偏约 300–600 米。 */ +const A = 6378245.0; +const EE = 0.00669342162296594323; +const PI = Math.PI; + +function outOfChina(lat: number, lng: number) { + return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271; +} + +function transformLat(x: number, y: number) { + let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x)); + ret += ((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) / 3.0; + ret += ((20.0 * Math.sin(y * PI) + 40.0 * Math.sin((y / 3.0) * PI)) * 2.0) / 3.0; + ret += ((160.0 * Math.sin((y / 12.0) * PI) + 320 * Math.sin((y * PI) / 30.0)) * 2.0) / 3.0; + return ret; +} + +function transformLng(x: number, y: number) { + let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x)); + ret += ((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) / 3.0; + ret += ((20.0 * Math.sin(x * PI) + 40.0 * Math.sin((x / 3.0) * PI)) * 2.0) / 3.0; + ret += ((150.0 * Math.sin((x / 12.0) * PI) + 300.0 * Math.sin((x / 30.0) * PI)) * 2.0) / 3.0; + return ret; +} + +export function wgs84ToGcj02(lat: number, lng: number) { + if (outOfChina(lat, lng)) return { lat, lng }; + let dLat = transformLat(lng - 105.0, lat - 35.0); + let dLng = transformLng(lng - 105.0, lat - 35.0); + const radLat = (lat / 180.0) * PI; + let magic = 1 - EE * Math.sin(radLat) * Math.sin(radLat); + const sqrtMagic = Math.sqrt(magic); + dLat = (dLat * 180.0) / (((A * (1 - EE)) / (magic * sqrtMagic)) * PI); + dLng = (dLng * 180.0) / ((A / sqrtMagic) * Math.cos(radLat) * PI); + return { lat: lat + dLat, lng: lng + dLng }; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css new file mode 100644 index 0000000..de8c586 --- /dev/null +++ b/apps/web/src/index.css @@ -0,0 +1,1264 @@ +:root { + --ink: #191c1e; + --ink-2: #3d3a4a; + --paper: #f7f9fb; + --sheet: rgba(255, 255, 255, 0.55); + --bind: #6b38d4; + --bind-2: #4648d4; + --bind-soft: rgba(107, 56, 212, 0.12); + --brass: #8455ef; + --brass-soft: #f3edff; + --stamp: #c0266b; + --stamp-soft: #fce7f3; + --mute: #64748b; + --line: rgba(199, 196, 215, 0.55); + --type: 'Noto Sans SC', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + --display: 'Noto Sans SC', 'PingFang SC', sans-serif; + --data: ui-monospace, 'SFMono-Regular', 'Menlo', 'Consolas', monospace; + --glass: rgba(255, 255, 255, 0.45); + --glass-border: rgba(255, 255, 255, 0.8); + --shadow: 0 10px 40px -10px rgba(107, 56, 212, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.9); +} + +html, +body, +#root { + margin: 0; + min-height: 100%; +} + +body { + font-family: var(--type); + color: var(--ink); + background-color: #f7f9fb; + background-image: + radial-gradient(at 0% 0%, hsla(253, 100%, 94%, 0.9) 0px, transparent 50%), + radial-gradient(at 100% 0%, hsla(225, 100%, 96%, 0.8) 0px, transparent 50%), + radial-gradient(at 50% 100%, hsla(253, 100%, 95%, 0.7) 0px, transparent 50%), + radial-gradient(at 100% 100%, hsla(339, 60%, 95%, 0.5) 0px, transparent 50%); + background-attachment: fixed; +} + +a { + color: var(--bind); +} + +.yzl-ambient { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + overflow: hidden; +} + +.yzl-orb { + position: absolute; + border-radius: 50%; + filter: blur(110px); + pointer-events: none; +} + +.yzl-orb-a { + top: 18%; + left: 18%; + width: 32rem; + height: 32rem; + background: rgba(165, 180, 252, 0.35); +} + +.yzl-orb-b { + top: 28%; + right: 16%; + width: 28rem; + height: 28rem; + background: rgba(216, 180, 254, 0.3); +} + +.yzl-orb-c { + bottom: 12%; + left: 28%; + width: 36rem; + height: 36rem; + background: rgba(196, 181, 253, 0.25); +} + +.yzl-particles { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.yzl-shell { + min-height: 100vh; + position: relative; + z-index: 1; + display: flex; + flex-direction: column; +} + +.yzl-topbar { + position: sticky; + top: 0; + z-index: 50; + height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px 0 16px; + background: rgba(255, 255, 255, 0.45); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border-bottom: 1px solid rgba(255, 255, 255, 0.6); + box-shadow: 0 1px 12px rgba(88, 28, 135, 0.05); +} + +.yzl-topbar-right { + display: flex; + align-items: center; + gap: 4px; +} + +.mobile-dl-btn { + color: #475569 !important; + font-weight: 500; +} + +.mobile-dl-card { + width: 180px; + display: flex; + flex-direction: column; + align-items: center; + padding: 4px 0 2px; + text-align: center; +} + +.mobile-dl-card .ant-typography { + font-size: 12px; + margin-top: 8px; +} + +.mobile-dl-link { + display: block; + margin-top: 4px; + font-size: 12px; + color: #6b38d4; +} + +.client-dl-card { + width: 200px; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + padding: 4px 0 2px; + text-align: center; +} + +.client-dl-card .ant-typography { + font-size: 12px; +} + +.client-dl-btn { + width: 100%; +} + +.client-dl-hint { + font-size: 11px !important; + line-height: 1.4; +} + +.mobile-dl-link-btn { + padding: 0; + height: auto; + font-size: 12px; +} + +.mobile-dl-pop .ant-popover-inner { + padding: 12px 14px; +} + +.yzl-brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + color: inherit; +} + +.yzl-logo { + width: 36px; + height: 36px; + border-radius: 12px; + overflow: hidden; + display: grid; + place-items: center; + box-shadow: 0 4px 16px -4px rgba(132, 85, 239, 0.45); + line-height: 0; +} + +.yzl-logo svg { + display: block; +} + +.yzl-brand-name { + font-size: 16px; + font-weight: 700; + letter-spacing: 0; + line-height: 1.25; + background: linear-gradient(90deg, #4338ca, #6b38d4, #7e22ce); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.yzl-body { + display: flex; + flex: 1; + min-height: 0; +} + +.ops-sider.ant-layout-sider { + background: rgba(255, 255, 255, 0.42) !important; + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border-right: 1px solid rgba(255, 255, 255, 0.6); + box-shadow: 8px 0 32px -12px rgba(107, 56, 212, 0.08); +} + +.ops-sider .ant-layout-sider-children { + display: flex; + flex-direction: column; +} + +.ops-sider .ant-menu { + background: transparent !important; + border-inline-end: 0 !important; +} + +.ops-sider .ant-menu-item-selected { + background: rgba(237, 233, 254, 0.85) !important; + color: #6b21a8 !important; +} + +.ops-sider .ant-menu-submenu-selected > .ant-menu-submenu-title { + color: #5b21b6 !important; +} + +.ops-sider .ant-menu-inline.ant-menu-root > .ant-menu-item, +.ops-sider .ant-menu-inline.ant-menu-root > .ant-menu-submenu > .ant-menu-submenu-title { + font-size: 14px !important; + font-weight: 650 !important; + color: #1e1b4b; +} + +.ops-sider .ant-menu-sub > .ant-menu-item, +.ops-sider .ant-menu-sub > .ant-menu-submenu > .ant-menu-submenu-title { + font-size: 13px !important; + font-weight: 500 !important; + color: #475569; +} + +.ops-sider .ant-menu-sub .ant-menu-sub > .ant-menu-item, +.ops-sider .ant-menu-sub .ant-menu-sub > .ant-menu-submenu > .ant-menu-submenu-title { + font-size: 12px !important; + font-weight: 400 !important; + color: #64748b; +} + +.ops-sider .ant-menu-inline.ant-menu-root > .ant-menu-item.ant-menu-item-selected { + font-weight: 700 !important; +} + +.ops-sider .ant-menu-sub > .ant-menu-item.ant-menu-item-selected { + font-weight: 600 !important; +} + +.ops-sider .ant-menu-sub .ant-menu-sub > .ant-menu-item.ant-menu-item-selected { + font-weight: 500 !important; +} + +.ops-sider .ant-menu-item:hover, +.ops-sider .ant-menu-submenu-title:hover { + color: #4f46e5 !important; + background: rgba(238, 242, 255, 0.7) !important; +} + +.ops-brand { + padding: 18px 16px 8px; + color: #475569; +} + +.ops-brand strong { + display: block; + font-size: 13px; + font-weight: 600; + color: #312e81; +} + +.ops-brand span { + display: block; + margin-top: 4px; + color: #94a3b8; + font-size: 11px; + letter-spacing: 0.12em; +} + +.ops-page-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; + gap: 16px; +} + +.ops-toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.ops-table .ant-table { + background: transparent; +} + +.ops-table .ant-table-container { + overflow-x: auto; +} + +.ops-table .ant-table-content > table, +.ops-table .ant-table-header > table, +.ops-table .ant-table-body > table { + width: max-content !important; + min-width: 100%; +} + +.ops-table .ant-table-thead > tr > th { + background: rgba(248, 250, 252, 0.8) !important; + color: var(--mute); + font-weight: 600; + font-size: 12px; + letter-spacing: 0.04em; +} + +.ops-table .ant-table-tbody > tr > td { + background: rgba(255, 255, 255, 0.28); + border-bottom: 1px solid rgba(255, 255, 255, 0.55) !important; + transition: background 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.ops-table .ant-table-tbody > tr:hover > td { + background: rgba(255, 255, 255, 0.82) !important; +} + +.ops-table .ant-table-tbody > tr:hover { + box-shadow: 0 10px 24px -10px rgba(107, 56, 212, 0.14); + position: relative; + z-index: 2; +} + +.ops-table .ant-tag { + border-radius: 999px; + padding: 0 8px; + line-height: 20px; +} + +.ops-table .ant-pagination { + margin: 16px 0 0; +} + +.ops-sheet { + background: rgba(255, 255, 255, 0.5); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + padding: 24px 28px 32px; + min-height: 360px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.8); + box-shadow: var(--shadow); +} + +.ops-content { + padding: 20px 24px 32px; + position: relative; + z-index: 1; +} + +.ops-workbench { + display: flex; + flex-direction: column; + gap: 20px; +} + +.wb-head { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 16px; +} + +.wb-kicker { + margin: 0 0 6px; + color: #6b38d4; + font-size: 12px; + letter-spacing: 0.22em; +} + +.wb-title { + margin: 0; + font-family: var(--display); + font-size: 28px; + font-weight: 700; + letter-spacing: 0.02em; + background: linear-gradient(90deg, #4338ca, #6b38d4); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.wb-date { + color: var(--mute); + font-size: 13px; +} + +.wb-weather { + max-width: 420px; + text-align: right; +} + +.wb-hello { + color: #4c1d95; + font-size: 14px; + font-weight: 600; + line-height: 1.5; +} + +.wb-wx { + margin-top: 4px; + color: #64748b; + font-size: 12px; + line-height: 1.5; +} + +.wb-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.wb-tile { + display: block; + padding: 16px 18px 14px; + background: rgba(255, 255, 255, 0.55); + backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.8); + border-radius: 14px; + text-decoration: none; + color: inherit; + box-shadow: var(--shadow); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.wb-tile:hover { + transform: translateY(-2px); + box-shadow: 0 12px 28px -12px rgba(107, 56, 212, 0.22); +} + +.wb-tile.is-warn { + box-shadow: inset 3px 0 0 #f59e0b, var(--shadow); +} + +.wb-tile.is-alert { + box-shadow: inset 3px 0 0 #e11d48, var(--shadow); +} + +.wb-tile .label { + color: var(--mute); + font-size: 12px; + letter-spacing: 0.08em; +} + +.wb-tile .value { + margin-top: 8px; + font-family: var(--data); + font-size: 28px; + font-variant-numeric: tabular-nums; + line-height: 1; + color: var(--ink); +} + +.wb-tile .hint { + margin-top: 8px; + color: var(--mute); + font-size: 12px; +} + +.wb-boards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.wb-board { + background: rgba(255, 255, 255, 0.5); + backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.8); + border-radius: 14px; + overflow: hidden; + box-shadow: var(--shadow); +} + +.wb-board header { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 12px 16px; + border-bottom: 1px solid rgba(226, 232, 240, 0.7); +} + +.wb-board header b { + font-weight: 600; +} + +.wb-board header a { + color: var(--bind); + font-size: 12px; +} + +.wb-row { + display: flex; + gap: 12px; + align-items: baseline; + padding: 10px 16px; + border-bottom: 1px dashed rgba(226, 232, 240, 0.8); + font-size: 13px; +} + +.wb-row:last-child { + border-bottom: 0; +} + +.wb-row .no { + font-family: var(--data); + color: var(--bind); + min-width: 128px; +} + +.wb-row .name { + flex: 1; + color: var(--ink); +} + +.wb-row .meta { + color: var(--mute); + white-space: nowrap; +} + +.wb-empty { + padding: 28px 16px; + color: var(--mute); + font-size: 13px; +} + +.ops-login { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + padding: 32px 16px 0; + position: relative; + overflow: hidden; +} + +.ops-login-wrap { + position: relative; + z-index: 2; + width: 100%; + max-width: 440px; + flex: 1; + display: flex; + align-items: center; +} + +.site-beian { + position: relative; + z-index: 2; + width: 100%; + flex-shrink: 0; + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 0 10px; + padding: 14px 16px 18px; + font-size: 12px; + line-height: 1.8; + color: #94a3b8; + text-align: center; +} + +.site-beian a { + color: #94a3b8; + text-decoration: none; +} + +.site-beian a:hover { + color: #64748b; + text-decoration: underline; +} + +.site-beian-sep { + color: #cbd5e1; + user-select: none; +} + +.site-beian-compact { + padding: 8px 16px 10px; + background: rgba(255, 255, 255, 0.38); + border-top: 1px solid rgba(255, 255, 255, 0.55); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + +@media (max-width: 640px) { + .site-beian { + font-size: 11px; + } + .site-beian-sep { + display: none; + } + .site-beian span, + .site-beian a { + width: 100%; + } +} + +.ops-login-card { + width: 100%; + background: rgba(255, 255, 255, 0.5); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid rgba(255, 255, 255, 0.8); + border-radius: 20px; + padding: 40px 36px 32px; + box-shadow: 0 24px 60px -20px rgba(107, 56, 212, 0.18); +} + +.ops-login-card .login-mark { + width: 72px; + height: 72px; + margin: 0 auto 12px; + border-radius: 20px; + display: grid; + place-items: center; + overflow: hidden; + box-shadow: 0 8px 20px rgba(107, 56, 212, 0.18); + line-height: 0; +} + +.ops-login-card h1 { + margin: 0; + text-align: center; + font-size: 22px; + font-weight: 700; + line-height: 1.35; + background: linear-gradient(90deg, #4f46e5, #6b38d4, #7e22ce); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.ops-login-card .login-sub { + margin: 8px 0 24px; + text-align: center; + color: #64748b; + font-size: 14px; +} + +.login-agree { + color: #64748b; + font-size: 13px; + line-height: 1.6; +} + +.login-agree a { + color: #6b38d4; +} + +.login-mobile-dl { + margin-top: 12px; + text-align: center; +} + +.ops-legal-wrap { + max-width: 760px; +} + +.ops-legal-card { + max-height: min(90vh, 900px); + overflow: auto; +} + +.ops-legal-section h2 { + margin: 18px 0 8px; + font-size: 15px; + font-weight: 650; + color: #1e293b; +} + +.ops-legal-section p { + margin: 0; + font-size: 14px; + line-height: 1.75; + color: #475569; +} + +.ops-legal-nav { + margin: 24px 0 0; + text-align: center; + font-size: 13px; +} + +.ops-legal-nav a { + color: #6b38d4; +} + +.yzl-page-title.ant-typography { + letter-spacing: -0.02em; + font-weight: 700 !important; +} + +.yzl-search.ant-input-search, +.yzl-search.ant-input-group-wrapper { + max-width: 360px; +} + +.yzl-search.ant-input-search .ant-input-affix-wrapper, +.yzl-search.ant-input-search .ant-input { + border-radius: 8px 0 0 8px !important; + background: #fff !important; + border-color: #d9d9d9 !important; +} + +.yzl-search.ant-input-search .ant-input-affix-wrapper-focused, +.yzl-search.ant-input-search .ant-input-affix-wrapper:focus { + z-index: 1; + border-color: #6b38d4 !important; + box-shadow: 0 0 0 2px rgba(107, 56, 212, 0.12) !important; +} + +.yzl-search.ant-input-search .ant-input-group-addon { + background: transparent !important; + border: 0 !important; + padding: 0 !important; +} + +.yzl-search.ant-input-search .ant-input-search-button.ant-btn, +.yzl-search.ant-input-search .ant-input-search-button.ant-btn-primary { + margin: 0 !important; + height: 32px !important; + border-radius: 0 8px 8px 0 !important; + border: 1px solid #d9d9d9 !important; + border-left: 0 !important; + box-shadow: none !important; + transform: none !important; +} + +.ant-btn { + transition: transform 0.15s ease, box-shadow 0.3s ease, filter 0.2s ease; +} + +.ant-btn:active { + transform: scale(0.95); +} + +.gradient-btn.ant-btn-primary, +.ant-btn-primary:not(.ant-input-search-button) { + background: linear-gradient(135deg, #4648d4 0%, #6b38d4 100%) !important; + border: 0 !important; + border-radius: 999px !important; + box-shadow: 0 4px 16px -4px rgba(132, 85, 239, 0.45); +} + +.ant-btn-primary:hover { + filter: brightness(1.05); + transform: translateY(-1px); + box-shadow: 0 6px 22px -4px rgba(132, 85, 239, 0.55) !important; +} + +.ops-sider .ant-menu-item, +.ops-sider .ant-menu-submenu-title { + border-radius: 10px !important; + margin-inline: 8px !important; + width: calc(100% - 16px) !important; + transition: transform 0.15s ease, background 0.2s ease; +} + +.ops-sider .ant-menu-item:active { + transform: scale(0.97); +} + +.ops-sider .ant-tabs-ink-bar, +.ant-tabs-ink-bar { + background: linear-gradient(90deg, #4648d4, #8455ef) !important; +} + +.org-page { + display: grid; + grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); + gap: 16px; + align-items: start; +} + +.emp-toolbar { + margin: 0 0 12px; +} + +.emp-wizard { + display: flex; + flex: 1; + min-height: 520px; +} + +.emp-wizard-nav { + width: 128px; + flex-shrink: 0; + padding: 18px 12px 20px; + background: + linear-gradient(180deg, rgba(107, 56, 212, 0.16), rgba(70, 72, 212, 0.08)), + linear-gradient(180deg, #f6f2ff, #efe8ff); + border-right: 1px solid rgba(107, 56, 212, 0.12); +} + +.emp-wizard-nav-head { + margin: 0 6px 16px; + color: var(--bind); + font-size: 12px; + font-weight: 650; + letter-spacing: 0.14em; +} + +.emp-wizard-track { + position: relative; + display: flex; + flex-direction: column; + gap: 4px; +} + +.emp-wizard-track::before { + content: ''; + position: absolute; + top: 16px; + bottom: 16px; + left: 21px; + width: 2px; + background: linear-gradient(180deg, var(--bind), rgba(107, 56, 212, 0.12)); + border-radius: 2px; +} + +.emp-wizard-nav button { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 8px; + border: 0; + background: transparent; + color: var(--mute); + text-align: left; + cursor: pointer; + border-radius: 999px; + transition: background 0.16s ease, color 0.16s ease, transform 0.12s ease, box-shadow 0.16s ease; +} + +.emp-wizard-nav button:hover { + color: var(--ink); + background: rgba(255, 255, 255, 0.7); +} + +.emp-wizard-nav button:active { + transform: scale(0.97); +} + +.emp-wizard-nav button.locked, +.emp-wizard-nav button:disabled { + color: #a8a3b8; + cursor: not-allowed; + background: transparent; + box-shadow: none; +} + +.emp-wizard-nav button.locked:hover, +.emp-wizard-nav button:disabled:hover { + color: #a8a3b8; + background: transparent; +} + +.emp-wizard-nav button.locked .emp-wizard-n, +.emp-wizard-nav button:disabled .emp-wizard-n { + background: rgba(255, 255, 255, 0.55); + color: #b0abc0; + border-color: rgba(107, 56, 212, 0.1); +} + +.emp-wizard-nav button.done { + color: var(--ink-2); +} + +.emp-wizard-nav button.on { + color: #fff; + font-weight: 650; + background: linear-gradient(135deg, var(--bind), var(--bind-2)); + box-shadow: 0 8px 18px rgba(107, 56, 212, 0.28); +} + +.emp-wizard-n { + width: 22px; + height: 22px; + border-radius: 50%; + display: inline-grid; + place-items: center; + font-size: 11px; + background: #fff; + border: 1px solid rgba(107, 56, 212, 0.2); + color: var(--bind); + flex-shrink: 0; +} + +.emp-wizard-nav button.done .emp-wizard-n { + background: var(--bind); + border-color: var(--bind); + color: #fff; +} + +.emp-wizard-nav button.on .emp-wizard-n { + background: #fff; + color: var(--bind); + border-color: #fff; +} + +.emp-wizard-t { + font-size: 13px; +} + +.emp-wizard-doc { + display: flex; + align-items: baseline; + gap: 10px; + margin: 4px 0 18px; +} + +.emp-wizard-doc span { + color: var(--bind); + font-size: 12px; +} + +.emp-wizard-doc strong { + font-size: 18px; + color: var(--ink); +} + +.emp-wizard-doc em { + font-style: normal; + color: var(--mute); + font-size: 13px; +} + +.form-drawer .ant-drawer-content { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(247, 249, 251, 0.96)); +} + +.form-drawer .ant-drawer-header { + border-bottom: 1px solid var(--line); +} + +.form-drawer .ant-btn, +.form-drawer-btn { + transition: transform 0.12s ease, box-shadow 0.16s ease, background 0.16s ease; +} + +.form-drawer .ant-btn:hover, +.form-drawer-btn:hover { + box-shadow: 0 4px 14px var(--bind-soft); +} + +.form-drawer .ant-btn:active, +.form-drawer-btn:active { + transform: scale(0.96); +} + +.form-drawer .ant-input, +.form-drawer .ant-select-selector, +.form-drawer .ant-picker, +.form-drawer .ant-input-number { + transition: border-color 0.16s ease, box-shadow 0.16s ease; +} + +.form-drawer .ant-input:focus, +.form-drawer .ant-input-focused, +.form-drawer .ant-select-focused .ant-select-selector, +.form-drawer .ant-picker-focused, +.form-drawer .ant-input-number-focused { + border-color: var(--bind) !important; + box-shadow: 0 0 0 3px var(--bind-soft) !important; +} + +.ops-table-click .ant-table-tbody > tr.ant-table-row { + cursor: pointer; + transition: background 0.16s ease; +} + +.ops-table-click .ant-table-tbody > tr.ant-table-row:hover > td { + background: var(--bind-soft) !important; +} + +.ops-table-click .ant-table-tbody > tr.ant-table-row:active > td { + background: var(--brass-soft) !important; +} + +.emp-wizard-body { + flex: 1; + min-width: 0; + padding: 16px 28px 12px; + overflow: auto; +} + +.emp-wizard-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.emp-wizard-note { + color: var(--mute); + font-size: 13px; +} + +.emp-archive-section { + margin-bottom: 22px; + padding: 14px 16px 4px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid var(--line); +} + +.emp-archive-section h3 { + margin: 0 0 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--line); + color: var(--bind); + font-size: 13px; + letter-spacing: 0.08em; +} + +.hr-settings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 20px; + max-width: 760px; +} + +.sms-account .ant-descriptions-item-label { + width: 160px; +} + +@media (max-width: 960px) { + .wb-metrics, + .wb-boards { + grid-template-columns: 1fr 1fr; + } + .org-page, + .hr-settings-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .wb-metrics, + .wb-boards, + .wb-head { + grid-template-columns: 1fr; + display: grid; + } + .wb-weather { + text-align: left; + max-width: none; + } +} + +.rpt-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} +.rpt-kpi { + background: #fff; + border: 1px solid var(--line, #ecebff); + border-radius: 12px; + padding: 14px 16px; +} +.rpt-kpi b { + display: block; + font-size: 22px; + line-height: 1.2; + font-weight: 700; +} +.rpt-kpi span { + display: block; + margin-top: 6px; + color: #64748b; + font-size: 13px; +} +.rpt-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 20px; +} +.rpt-card { + background: #fff; + border: 1px solid var(--line, #ecebff); + border-radius: 12px; + padding: 16px 18px; +} +.rpt-card-title { + font-weight: 600; + margin-bottom: 12px; +} +.rpt-pie { + display: flex; + align-items: center; + gap: 16px; +} +.rpt-pie ul { + list-style: none; + margin: 0; + padding: 0; + flex: 1; +} +.rpt-pie li { + display: flex; + align-items: center; + gap: 8px; + margin: 6px 0; + font-size: 13px; +} +.rpt-pie li i { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} +.rpt-pie li b { + margin-left: auto; +} +.rpt-funnel-row { + display: flex; + align-items: center; + gap: 10px; + margin: 8px 0; +} +.rpt-funnel-bar { + min-width: 48px; + color: #fff; + font-size: 12px; + line-height: 28px; + padding: 0 10px; + border-radius: 6px; + text-align: right; +} +.rpt-bar-row { + display: grid; + grid-template-columns: 120px 1fr 88px; + gap: 8px; + align-items: center; + margin: 7px 0; + font-size: 13px; +} +.rpt-bar-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.rpt-bar-track { + height: 10px; + background: #f0effa; + border-radius: 99px; + overflow: hidden; +} +.rpt-bar-fill { + height: 100%; + border-radius: 99px; +} +.rpt-bar-row b { + text-align: right; + font-weight: 600; +} +.rpt-tree { + display: flex; + flex-wrap: wrap; + min-height: 180px; + border-radius: 10px; + overflow: hidden; +} +.rpt-tree-cell { + min-width: 88px; + min-height: 72px; + color: #fff; + padding: 10px 12px; + display: flex; + flex-direction: column; + justify-content: space-between; +} +.rpt-tree-cell b { + font-size: 13px; +} +.rpt-tree-empty { + color: #8b8fa3; + padding: 24px; +} +.form-created { + color: #6b6f80; + font-size: 13px; + margin-bottom: 12px; +} + +.notice-editor { + border: 1px solid var(--line, #e8e8ef); + border-radius: 10px; + overflow: hidden; + background: #fff; +} + +.notice-html { + line-height: 1.75; + color: var(--ink); + word-break: break-word; +} + +.notice-html img { + max-width: 100%; + height: auto; +} + +.notice-html p { + margin: 0 0 12px; +} + +@media (max-width: 900px) { + .rpt-grid, + .rpt-kpis { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + } +} diff --git a/apps/web/src/labels.ts b/apps/web/src/labels.ts new file mode 100644 index 0000000..062b686 --- /dev/null +++ b/apps/web/src/labels.ts @@ -0,0 +1,224 @@ +/** 后端枚举 → 中文。未知码才回落到原文,避免表格直接露出英文。 */ +export const ZH: Record = { + TECH_INITIAL: '技术初审', + FETCH: '取标', + TECH_FINAL: '技术终审', + DRAFTING: '制标', + REVIEW1: '商务一审', + REVIEW2: '商务二审', + PRINT: '印封', + LIST: '投标执行', + PENDING: '待办理', + WON: '中标', + LOST: '未中标', + FAILED: '流标废标', + TERMINATED: '已终止', + DRAFT: '草稿', + APPROVED: '已通过', + REJECTED: '已驳回', + CANCELLED: '已取消', + ACTIVE: '启用', + DISABLED: '停用', + OPEN: '未还 / 进行中', + OFFSET: '已冲账', + DONE: '已完成', + RETURNED: '已交回', + PAID: '已付', + UNPAID: '待付', + TO_ISSUE: '待开', + ISSUED: '已开', + RECEIVED: '已回', + OVERDUE: '逾期', + INTERNAL_ACCEPTED: '内部已验收', + ACCEPTED: '已验收', + ALL: '全部', + DEPT: '本部门', + SELF: '本人', + BID: '投标', + PROJECT: '正式项目', + ADMIN: '行政', + MARKETING: '营销', + LABOR: '人工', + TRAVEL: '差旅', + PURCHASE: '采购', + TENDER: '投标保证金', + PERFORMANCE: '履约保证金', + WARRANTY: '项目质保金', + EXPENSE: '报销打款', + BOND: '保证金打出', + INTERN: '实习', + PROBATION: '试用', + REGULAR: '正式', + LEFT: '离职', + SINGLE: '单休', + DOUBLE: '双休', + MALE: '男', + FEMALE: '女', + HIGH_SCHOOL: '高中及以下', + COLLEGE: '专科', + BACHELOR: '本科', + MASTER: '硕士', + DOCTOR: '博士', + HIRE: '入职', + REGULARIZE: '转正', + TRANSFER: '调岗', + LEAVE: '离职', + CREATE: '发起', + SUBMIT: '提交', + APPROVE: '同意', + REJECT: '驳回', + DRAFTER: '制标人', + FETCHER: '取标人', + COORDINATOR: '特殊协调人', + BIDDER: '投标人', + SPECIAL: '特殊筛选', + FLOW: '普通筛选', + BID_TECH_INITIAL: '技术初审', + BID_TECH_FINAL: '技术终审', + BID_FETCH: '取标', + BID_ASSIGN_FETCH: '派取标人', + BID_ASSIGN_DRAFT: '派制标人', + BID_FINAL_PASSED: '终审通过通知', + BID_DRAFT_DONE: '制标完成通知', + BID_TERMINATED: '投标终止通知', + BID_DRAFT: '制标', + BID_REVIEW1: '商务一审', + BID_REVIEW2: '商务二审', + BID_PRINT: '印封', + BID_COMPLETE: '补全投标', + BID_EXECUTION: '投标执行', + BID_ASSIGN_FETCHER: '派取标人', + CONFIRMED: '已确认', + INTERNAL: '内部验收', + CUSTOMER: '客户验收', + CONTRACT_APPROVAL: '合同审批', + CONTRACT_DRAFT: '完善合同草稿', + EXPENSE_APPROVAL: '报销确认', + LOAN_APPROVAL: '借款确认', + LOAN_OFFSET: '借款冲账', + PROJECT_TIMESHEET: '工时填报', + PROJECT_ACCEPT: '项目验收', + SEAL_APPROVAL: '用章审批', + SEAL_RETURN: '印章归还', + CREDENTIAL_RETURN: '证照归还', + BID_OPEN: '开标反馈', + WORK_ASSIGN: '工作安排', + OFFICE_APPLY: '办公申请', + HR_LEAVE: '人事申请', + SEAL_REQUEST: '用章申请', + WORK_REPORT: '工作汇报', + NOTICE: '通知公告', + ASSET_BUY: '资产购置', + ASSET_USE: '资产领用', + VEHICLE: '用车', + DIGITAL: '数字资产', + FIXED: '固定资产', + IN_USE: '在用', + IN_STOCK: '在库', + OUT_STOCK: '已出库', + TAKEN_OUT: '外带', + ID_CARD: '身份证', + PASSPORT: '护照', + GENERAL: '通用', + TODO: '待付款', + TASK: '任务截止', + BORROW: '证照归还', + SEAL: '印章归还', + LOGIN: '登录', + LOGIN_FAIL: '登录失败', + APPROVAL: '审批', + FORBIDDEN: '越权', + ACL: '权限', + SENT: '已提交', + UPDATE: '修改', + DELETE: '删除', + OK: '成功', + FAIL: '失败', + BUSINESS: '商务', + TECH: '技术', + HR: '人事', + FINANCE: '财务', + meal: '餐饮', + meals: '餐饮', + food: '餐饮', + traffic: '交通', + transport: '交通', + transportation: '交通', + hotel: '住宿', + office: '办公', + entertain: '招待', + entertainment: '招待', + OVERTIME: '加班', + MAKEUP: '补卡', + PROMOTE: '晋升', + RESIGN: '离职', + PENDING_IN: '待入库', + OCCUPIED: '占用中', + LENT: '外借', + BORROWED: '已外借', + IDLE: '闲置', + SCRAPPED: '已报废', + AVAILABLE: '可用', + MAINTAIN: '维修中', + PUBLISHED: '已发布', + ANNOUNCE: '公告', + POLICY: '制度', +}; + +/** 同一枚举码在不同业务里中文不同,按字段上下文取。 */ +const ZH_CTX: Record> = { + OUT: { seal: '外带', hr: '外出', punch: '下班' }, +}; + +export function zhCtx(code?: string | number | null, context?: string) { + if (code === undefined || code === null || code === '') return '—'; + const key = String(code); + if (context && ZH_CTX[key]?.[context]) return ZH_CTX[key][context]; + return ZH[key] || key; +} + +export const ZH_COLOR: Record = { + TECH_INITIAL: 'processing', + FETCH: 'processing', + TECH_FINAL: 'processing', + DRAFTING: 'gold', + REVIEW1: 'gold', + REVIEW2: 'gold', + PRINT: 'gold', + LIST: 'blue', + PENDING: 'processing', + WON: 'success', + LOST: 'default', + FAILED: 'default', + TERMINATED: 'error', + DRAFT: 'default', + APPROVED: 'success', + REJECTED: 'error', + CANCELLED: 'default', + ACTIVE: 'success', + DISABLED: 'default', + OPEN: 'warning', + OFFSET: 'success', + DONE: 'success', + RETURNED: 'success', + PAID: 'success', + UNPAID: 'default', + TO_ISSUE: 'default', + ISSUED: 'processing', + RECEIVED: 'success', + OVERDUE: 'error', + INTERNAL_ACCEPTED: 'gold', + ACCEPTED: 'success', + REGULAR: 'success', + PROBATION: 'gold', + INTERN: 'blue', + LEFT: 'default', + OK: 'success', + FAIL: 'error', +}; + +export function zh(code?: string | number | null) { + if (code === undefined || code === null || code === '') return '—'; + const key = String(code); + return ZH[key] || key; +} diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx new file mode 100644 index 0000000..a00a8ff --- /dev/null +++ b/apps/web/src/layouts/AppLayout.tsx @@ -0,0 +1,213 @@ +import type { ReactNode } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { Layout, Menu, Dropdown, Button, Badge } from 'antd'; +import { + HomeOutlined, + AppstoreOutlined, + ClusterOutlined, + SafetyCertificateOutlined, + AuditOutlined, + FileProtectOutlined, + ProjectOutlined, + AccountBookOutlined, + TeamOutlined, + BarChartOutlined, + SettingOutlined, + DatabaseOutlined, + LogoutOutlined, + EditOutlined, + LockOutlined, +} from '@ant-design/icons'; +import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; +import type { MenuProps } from 'antd'; +import { useAuth, type MenuItem } from '../store/auth'; +import AmbientBackground from '../components/AmbientBackground'; +import { BrandLogo } from '../components/BrandLogo'; +import { MobileDownload } from '../components/MobileDownload'; +import { DesktopDownload } from '../components/DesktopDownload'; +import { LinuxDownload } from '../components/LinuxDownload'; +import { SiteFooter } from '../components/SiteFooter'; +import { ProfileDrawer, UserAvatar } from '../pages/ProfileDrawer'; + +const { Sider, Content } = Layout; + +const ICONS: Record = { + HomeOutlined: , + AppstoreOutlined: , + ClusterOutlined: , + SafetyCertificateOutlined: , + AuditOutlined: , + FileProtectOutlined: , + ProjectOutlined: , + AccountBookOutlined: , + TeamOutlined: , + BarChartOutlined: , + DatabaseOutlined: , + SettingOutlined: , +}; + +function bare(path?: string | null) { + return (path || '').split('?')[0]; +} + +function toItems(menus: MenuItem[]): MenuProps['items'] { + const walk = (m: MenuItem): NonNullable[number] => ({ + key: m.path || m.code, + icon: m.icon ? ICONS[m.icon] : undefined, + label: m.children?.length ? m.name : {m.name}, + children: m.children?.length ? m.children.map(walk) : undefined, + }); + return menus.map(walk); +} + +function collectOpenKeys(menus: MenuItem[], pathname: string): string[] { + const keys: string[] = []; + const walk = (items: MenuItem[], ancestors: string[]) => { + for (const m of items) { + const key = m.path || m.code; + const next = [...ancestors, key]; + const p = bare(m.path); + const hit = Boolean(p && (pathname === p || pathname.startsWith(`${p}/`))); + const childHit = Boolean(m.children?.length && pathInTree(m.children, pathname)); + if (hit || childHit) keys.push(...next); + if (m.children?.length) walk(m.children, next); + } + }; + walk(menus, []); + return [...new Set(keys)]; +} + +function rootSubmenuKeys(menus: MenuItem[]): string[] { + return menus.filter((m) => m.children?.length).map((m) => m.path || m.code); +} + +function accordionKeys(menus: MenuItem[], pathname: string, openKeys: string[]): string[] { + const roots = new Set(rootSubmenuKeys(menus)); + const fromPath = collectOpenKeys(menus, pathname); + const openRoot = openKeys.find((k) => roots.has(k)) || fromPath.find((k) => roots.has(k)); + const nested = [...openKeys, ...fromPath].filter((k) => !roots.has(k)); + return openRoot ? [...new Set([openRoot, ...nested])] : [...new Set(nested)]; +} + +function pathInTree(items: MenuItem[], pathname: string): boolean { + return items.some( + (m) => + (m.path && (pathname === bare(m.path) || pathname.startsWith(`${bare(m.path)}/`))) || + (m.children?.length ? pathInTree(m.children, pathname) : false), + ); +} + +function selectedMenuKeys(menus: MenuItem[], pathname: string, search: string): string[] { + const keys: string[] = []; + const collect = (items: MenuItem[]) => { + for (const m of items) { + if (m.path) keys.push(m.path); + if (m.children?.length) collect(m.children); + } + }; + collect(menus); + const full = `${pathname}${search}`; + if (keys.includes(full)) return [full]; + if (keys.includes(pathname)) return [pathname]; + let best = ''; + for (const k of keys) { + if (k.includes('?')) continue; + const p = bare(k); + if (p && (pathname === p || pathname.startsWith(`${p}/`)) && p.length >= bare(best).length) best = k; + } + return best ? [best] : [full]; +} + +export default function AppLayout() { + const location = useLocation(); + const navigate = useNavigate(); + const { displayName, menus, logout, idPhotoFileId } = useAuth(); + const [profileMode, setProfileMode] = useState<'profile' | 'password' | null>(null); + const [openKeys, setOpenKeys] = useState([]); + const isWorkbench = location.pathname === '/workbench' || location.pathname === '/office/overview'; + const rootKeys = useMemo(() => rootSubmenuKeys(menus), [menus]); + + useEffect(() => { + setOpenKeys(accordionKeys(menus, location.pathname, [])); + }, [location.pathname, menus]); + + const onOpenChange: MenuProps['onOpenChange'] = (keys) => { + const latest = keys.find((k) => !openKeys.includes(k)); + if (latest && rootKeys.includes(latest)) { + setOpenKeys([latest]); + return; + } + const openRoot = keys.find((k) => rootKeys.includes(k)); + setOpenKeys(openRoot ? keys.filter((k) => k === openRoot || !rootKeys.includes(k)) : []); + }; + + return ( +
+ +
+ + + + + 风影智慧办公综合平台 + +
+ + + + , label: '修改个人资料', onClick: () => setProfileMode('profile') }, + { key: 'password', icon: , label: '修改密码', onClick: () => setProfileMode('password') }, + { type: 'divider' }, + { + key: 'logout', + icon: , + label: '退出登录', + onClick: async () => { + await logout(); + navigate('/login'); + }, + }, + ], + }} + > + + +
+ setProfileMode(null)} /> +
+
+ +
+ 智能工作空间 + 内部经营 +
+ + + + +
+ +
+
+
+
+ +
+ ); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..3d858a7 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,51 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ConfigProvider, App as AntApp } from 'antd'; +import zhCN from 'antd/locale/zh_CN'; +import dayjs from 'dayjs'; +import 'dayjs/locale/zh-cn'; +import 'antd/dist/reset.css'; +import './index.css'; +import App from './App'; + +dayjs.locale('zh-cn'); +const client = new QueryClient(); + +createRoot(document.getElementById('root')!).render( + + + + + + + + + + + , +); diff --git a/apps/web/src/mammoth-browser.d.ts b/apps/web/src/mammoth-browser.d.ts new file mode 100644 index 0000000..38664d5 --- /dev/null +++ b/apps/web/src/mammoth-browser.d.ts @@ -0,0 +1,3 @@ +declare module 'mammoth/mammoth.browser' { + export function convertToHtml(input: { arrayBuffer: ArrayBuffer }): Promise<{ value: string; messages: unknown[] }>; +} diff --git a/apps/web/src/pages/AppReleasePage.tsx b/apps/web/src/pages/AppReleasePage.tsx new file mode 100644 index 0000000..05f8a21 --- /dev/null +++ b/apps/web/src/pages/AppReleasePage.tsx @@ -0,0 +1,87 @@ +import { Alert, Button, Form, Input, InputNumber, Switch, message } from 'antd'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { api } from '../api/client'; +import { PageHeader } from './ResourcePage'; + +type Release = { + version?: string; + build?: number; + url?: string; + windowsUrl?: string; + linuxUrl?: string; + linuxDebUrl?: string; + changelog?: string; + force?: boolean; + publishedAt?: string; +}; + +export default function AppReleasePage() { + const [form] = Form.useForm(); + const qc = useQueryClient(); + const q = useQuery({ + queryKey: ['app-release'], + queryFn: async () => { + const res = await api.get('/system/app-release'); + const row = (res as { data?: Release }).data ?? res; + return row as Release; + }, + }); + + const save = useMutation({ + mutationFn: (v: Release) => api.patch('/system/app-release', v), + onSuccess: () => { + message.success('已登记新版本,手机端下次打开会提示升级'); + qc.invalidateQueries({ queryKey: ['app-release'] }); + }, + }); + + return ( + <> + + {q.data ? ( + + ) : null} +
save.mutate(v)} + > + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + ); +} diff --git a/apps/web/src/pages/ApplyBucketBar.tsx b/apps/web/src/pages/ApplyBucketBar.tsx new file mode 100644 index 0000000..f646cf3 --- /dev/null +++ b/apps/web/src/pages/ApplyBucketBar.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from 'react'; +import { Segmented, Space } from 'antd'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +export type ApplyBucket = { value: string; label: string }; + +/** 申请类列表统一分段筛选。空字符串表示全部。会保留当前 URL 上的其它参数。 */ +export function ApplyBucketBar({ + basePath, + buckets, + extra, +}: { + basePath: string; + buckets: ApplyBucket[]; + extra?: ReactNode; +}) { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const current = params.get('bucket') || ''; + const path = basePath.split('?')[0]; + return ( + + { + const next = new URLSearchParams(params); + const val = String(v); + if (val) next.set('bucket', val); + else next.delete('bucket'); + const s = next.toString(); + navigate(s ? `${path}?${s}` : path); + }} + options={[{ label: '全部', value: '' }, ...buckets]} + /> + {extra} + + ); +} + +export const PROCESS_BUCKETS: ApplyBucket[] = [ + { value: 'pending', label: '待审批' }, + { value: 'approved', label: '已通过' }, + { value: 'rejected', label: '已驳回' }, +]; diff --git a/apps/web/src/pages/AssetPurchaseLedgerPage.tsx b/apps/web/src/pages/AssetPurchaseLedgerPage.tsx new file mode 100644 index 0000000..a52855f --- /dev/null +++ b/apps/web/src/pages/AssetPurchaseLedgerPage.tsx @@ -0,0 +1,55 @@ +import { Table, Tag } from 'antd'; +import { useQuery } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import { api } from '../api/client'; +import { PageHeader } from './ResourcePage'; +import { money } from './financeStatus'; + +type Row = { + id: string; + title: string; + amount?: string | number | null; + reason?: string | null; + status: string; + createdAt: string; + applicant?: { displayName?: string } | null; + project?: { projectNo?: string; name?: string } | null; + purchase?: { id: string; status: string } | null; + inboundAsset?: { code?: string | null; name?: string; status?: string } | null; +}; + +const STATUS: Record = { + PENDING: { color: 'processing', text: '待审批' }, + APPROVED: { color: 'success', text: '已通过' }, + REJECTED: { color: 'error', text: '已驳回' }, +}; + +export default function AssetPurchaseLedgerPage() { + const q = useQuery({ queryKey: ['/asset-purchases'], queryFn: () => api.get('/asset-purchases') }); + const rows = (q.data?.data ?? []) as Row[]; + return ( + <> + + money(v) }, + { title: '申请人', width: 120, render: (_: unknown, r: Row) => r.applicant?.displayName || '—' }, + { title: '关联项目', width: 220, render: (_: unknown, r: Row) => r.project ? `${r.project.projectNo || ''} ${r.project.name || ''}`.trim() : '—' }, + { title: '采购记录', width: 120, render: (_: unknown, r: Row) => r.purchase ? 已入采购 : '待审批' }, + { title: '待入库资产', width: 180, render: (_: unknown, r: Row) => r.inboundAsset ? `${r.inboundAsset.code || '待编码'} ${r.inboundAsset.name || ''}`.trim() : '—' }, + { title: '状态', dataIndex: 'status', width: 110, render: (v: string) => {(STATUS[v] || {}).text || v} }, + { title: '申请时间', dataIndex: 'createdAt', width: 180, render: (v: string) => v ? String(v).replace('T', ' ').slice(0, 19) : '—' }, + ]} + /> + + ); +} diff --git a/apps/web/src/pages/AssetRegistryPage.tsx b/apps/web/src/pages/AssetRegistryPage.tsx new file mode 100644 index 0000000..17b627e --- /dev/null +++ b/apps/web/src/pages/AssetRegistryPage.tsx @@ -0,0 +1,209 @@ +import { Button, Checkbox, Form, Input, InputNumber, Popconfirm, Select, Space, Table, Tag, message } from 'antd'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { api } from '../api/client'; +import { FormDrawer } from './FormDrawer'; +import { PageHeader, unwrapList } from './ResourcePage'; +import { money } from './financeStatus'; +import { ApplyBucketBar } from './ApplyBucketBar'; + +export function AssetRegistryPage({ kind, title }: { kind: 'DIGITAL' | 'FIXED'; title: string }) { + const qc = useQueryClient(); + const [params] = useSearchParams(); + const bucket = params.get('bucket') || ''; + const basePath = kind === 'DIGITAL' ? '/asset/digital' : '/asset/fixed'; + const endpoint = `/registered-assets?kind=${kind}`; + const q = useQuery({ queryKey: [endpoint], queryFn: () => api.get(endpoint) }); + const entities = useQuery({ queryKey: ['/legal-entities'], queryFn: () => api.get('/legal-entities') }); + const staff = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') }); + const { items } = unwrapList(q.data); + const [form] = Form.useForm(); + const [drawer, setDrawer] = useState(null); + const entityOpts = ((entities.data as { data?: { id: string; name: string; shortName?: string }[] })?.data || []).map( + (e) => ({ value: e.id, label: e.shortName || e.name }), + ); + const staffOpts = ((staff.data as { data?: { name: string; department?: string }[] })?.data || []).map((s) => ({ + value: s.name, + label: `${s.name}${s.department ? `(${s.department})` : ''}`, + })); + const filtered = useMemo(() => { + return items.filter((r) => { + if (bucket === 'pending_in') return r.status === 'PENDING_IN'; + if (bucket === 'occupying') return Boolean((r.occupancies as { holder: string }[] | undefined)?.length); + if (bucket === 'vehicle') return r.usage === 'VEHICLE'; + return true; + }); + }, [items, bucket]); + const mut = useMutation({ + mutationFn: (body: Record) => api.post('/registered-assets', { ...body, kind }), + onSuccess: () => { + message.success('已登记'); + qc.invalidateQueries({ queryKey: [endpoint] }); + }, + onError: (e: Error) => message.error(e.message), + }); + + return ( + <> + { + form.resetFields(); + setDrawer('create'); + }} + > + 登记 + + } + /> + } + /> + setDrawer(null)} + okText="提交保存" + okLoading={mut.isPending} + onOk={async () => { + const v = await form.validateFields(); + const body = { ...v, usage: kind === 'FIXED' && v.vehicle ? 'VEHICLE' : 'GENERAL' }; + delete body.vehicle; + if (drawer === 'create') await mut.mutateAsync(body); + else if (drawer && typeof drawer === 'object') { + await api.patch(`/registered-assets/${drawer.id}`, body); + message.success('已保存'); + qc.invalidateQueries({ queryKey: [endpoint] }); + } + setDrawer(null); + }} + > +
+ + + + + + + + + + {kind === 'FIXED' ? ( + + 这是车辆(用车申请会优先列出) + + ) : null} + + + + + + + + + + + + + +
+
v || '—' }, + { title: '规格', dataIndex: 'spec', ellipsis: true, render: (v: string) => v || '—' }, + { + title: '状态', + width: 90, + render: (_: unknown, r: Record) => + r.status === 'PENDING_IN' ? 待入库 : r.usage === 'VEHICLE' ? 车辆 : 在库, + }, + { + title: '所属公司', + width: 140, + render: (_: unknown, r: Record) => { + const e = r.legalEntity as { shortName?: string; name?: string } | null; + return e?.shortName || e?.name || '—'; + }, + }, + { title: '保管', dataIndex: 'keeper', width: 100, render: (v: string) => v || '—' }, + { + title: '占用', + width: 110, + render: (_: unknown, r: Record) => { + const occ = r.occupancies as { holder: string }[] | undefined; + return occ?.length ? `${occ[0].holder}在用` : '空闲'; + }, + }, + { title: '位置', dataIndex: 'location', width: 140, render: (v: string) => v || '—' }, + { title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) }, + { + title: '操作', + width: 200, + render: (_: unknown, r: Record) => ( + + {r.status === 'PENDING_IN' ? ( + + ) : null} + + { + await api.delete(`/registered-assets/${r.id}`); + message.success('已删除'); + qc.invalidateQueries({ queryKey: [endpoint] }); + }} + > + + + + ), + }, + ]} + /> + + ); +} diff --git a/apps/web/src/pages/BidCreatePage.tsx b/apps/web/src/pages/BidCreatePage.tsx new file mode 100644 index 0000000..043a759 --- /dev/null +++ b/apps/web/src/pages/BidCreatePage.tsx @@ -0,0 +1,286 @@ +import { useEffect, useState } from 'react'; +import { AutoComplete, Button, Checkbox, Col, DatePicker, Form, Input, Row, Select, Upload, message } from 'antd'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../api/client'; +import { FormDrawer } from './FormDrawer'; +import { OPEN_METHOD_OPTIONS, TENDER_METHOD_OPTIONS, TYPE_REVIEW_ROLES } from './bidStatus'; + +function asList(raw: unknown): T[] { + const body = (raw as { data?: unknown })?.data ?? raw; + if (Array.isArray(body)) return body as T[]; + if (body && typeof body === 'object' && 'items' in body) return ((body as { items: T[] }).items ?? []) as T[]; + return []; +} + +type Director = { id: string; name: string; department?: string; roles?: string[] }; + +function pickReviewers(type: string, directors: Director[]) { + const wanted = TYPE_REVIEW_ROLES[type] || ['tech_director']; + const ids = directors.filter((d) => (d.roles || []).some((r) => wanted.includes(r))).map((d) => d.id); + return type === '集成' ? [...new Set(ids)] : ids.slice(0, 1); +} + +export default function BidCreatePage({ + open = true, + onClose, + special = false, +}: { + open?: boolean; + onClose?: () => void; + special?: boolean; +}) { + const [form] = Form.useForm(); + const navigate = useNavigate(); + const listPath = special ? '/bid/special-screening' : '/bid/screening'; + const close = () => { + if (onClose) onClose(); + else navigate(listPath); + }; + const [files, setFiles] = useState([]); + const types = useQuery({ queryKey: ['dict', 'projectType'], queryFn: () => api.get('/system/dicts?type=projectType') }); + const quals = useQuery({ + queryKey: ['dict', 'qualificationNeed'], + queryFn: () => api.get('/system/dicts?type=qualificationNeed'), + }); + const entities = useQuery({ queryKey: ['legal-entities'], queryFn: () => api.get('/legal-entities') }); + const directors = useQuery({ queryKey: ['directors'], queryFn: () => api.get('/directors') }); + const coordinators = useQuery({ + queryKey: ['special-coordinators'], + queryFn: () => api.get('/bid-cases/special-coordinators'), + enabled: special && open, + }); + const directorList = asList(directors.data); + const coordinatorList = asList<{ id: string; displayName?: string; name?: string; username?: string; employee?: { department?: { name?: string } } }>( + coordinators.data, + ); + + useEffect(() => { + if (!open) return; + form.resetFields(); + setFiles([]); + }, [open, form]); + + const mut = useMutation({ + mutationFn: async (values: Record) => { + const res = await api.post('/bid-cases', values); + const id = res.data?.id as string | undefined; + if (id) { + for (const f of files) { + const fd = new FormData(); + fd.append('file', f); + fd.append('bizType', 'BID_ANNOUNCE'); + fd.append('bizId', id); + await api.post('/files', fd); + } + } + return res; + }, + onSuccess: (res: { data?: { id?: string } }) => { + message.success(special ? '已提交特殊筛选' : '已提交项目筛选'); + if (onClose) onClose(); + navigate(res.data?.id ? `/bid/cases/${res.data.id}` : listPath); + }, + onError: (e: Error) => message.error(e.message), + }); + + const dictOpts = (q: { data?: unknown }) => + asList<{ label: string; value: string }>(q.data).map((i) => ({ label: i.label, value: i.value })); + + return ( + form.submit()} + okText={special ? '提交特殊筛选' : '提交筛选'} + okLoading={mut.isPending} + > +
{ + mut.mutate({ + ...values, + sourceType: special ? 'SPECIAL' : 'FLOW', + applyStartAt: values.applyRange?.[0]?.toISOString(), + applyEndAt: values.applyRange?.[1]?.toISOString(), + openAt: values.openAt?.toISOString(), + applyRange: undefined, + }); + }} + > + +
+ + + + + + + + + + + + + + + + + + + + + + 是否需要踏勘 + + + + + + + + + + + + + + + + + + + + + + + {special ? ( + + + ({ + label: `${d.name}${d.department ? `(${d.department})` : ''}`, + value: d.id, + }))} + /> + + + + + { + setFiles((prev) => [...prev, file]); + return false; + }} + onRemove={(file) => { + setFiles((prev) => prev.filter((f) => f.name !== file.name)); + }} + > + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/pages/BidDetailPage.tsx b/apps/web/src/pages/BidDetailPage.tsx new file mode 100644 index 0000000..3fe80c9 --- /dev/null +++ b/apps/web/src/pages/BidDetailPage.tsx @@ -0,0 +1,1151 @@ +import { useState, type ReactNode } from 'react'; +import { + Alert, + Button, + DatePicker, + Descriptions, + Form, + Input, + InputNumber, + Modal, + Radio, + Select, + Space, + Steps, + Table, + Tag, + Timeline, + Typography, + message, +} from 'antd'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import dayjs from 'dayjs'; +import { api } from '../api/client'; +import { useAuth } from '../store/auth'; +import { PageHeader } from './ResourcePage'; +import { FormDrawer } from './FormDrawer'; +import { BizFileList, BizFileUpload } from './FileAttachments'; +import { contractNoText, contractStatusMeta } from './contractStatus'; +import { projectStatusMeta } from './projectStatus'; +import { + ACTION_LABEL, + ASSIGN_ROLE_LABEL, + COST_LABEL, + inferTenderMethod, + NAS_BID_DIR, + NAS_BID_PLACEHOLDER, + OPEN_METHOD_OPTIONS, + REVIEW_RESULT, + STAGE_LABEL, + listPath, + statusMeta, + stepIndex, + tenderNo, +} from './bidStatus'; + +type UserRef = { id: string; displayName: string; username?: string }; +type Staff = { id: string; name: string; department?: string; title?: string }; + +type Reviewer = { + id: string; + userId: string; + stage: string; + cycle: number; + result: string; + comment?: string | null; + decidedAt?: string | null; + user?: UserRef; +}; + +type ActionLog = { + id: string; + action: string; + fromStatus?: string | null; + toStatus?: string | null; + comment?: string | null; + createdAt: string; + actor?: UserRef; +}; + +type BidDetail = { + id: string; + bidNo: string; + name: string; + externalNo?: string; + projectType?: string; + source?: string; + qualificationNeed?: string; + tenderMethod?: string; + applyMethod?: string; + applyStartAt?: string | null; + applyEndAt?: string | null; + openAt?: string | null; + openMethod?: string; + openPlace?: string; + nasPath?: string | null; + summary?: string | null; + remark?: string | null; + status: string; + sourceType?: string; + bondMethod?: string | null; + bondStatus?: string | null; + delivererName?: string | null; + priceCap?: string | null; + quoteAmount?: string | null; + printCost?: string | number | null; + printNote?: string | null; + costDisposition?: string | null; + resultNote?: string | null; + winnerName?: string | null; + winnerAmount?: string | number | null; + winnerNote?: string | null; + createdAt: string; + party?: { name: string } | null; + legalEntity?: { id: string; name: string } | null; + createdBy?: UserRef | null; + reviewers: Reviewer[]; + actionLogs: ActionLog[]; + assignees: { id: string; role: string; userId: string; user?: UserRef }[]; + versions: { + id: string; + versionNo: number; + nasPath: string; + note?: string | null; + createdAt: string; + submittedBy?: UserRef; + }[]; + expenses: { id: string; claimNo: string; amount: string | number; costType: string; remark?: string | null; projectId?: string | null }[]; + contracts?: { + id: string; + contractNo: string; + name: string; + status: string; + amount: string | number; + projects?: { id: string; projectNo: string; name: string; status: string }[]; + }[]; + files?: { id: string; fileName: string; size: number; mimeType: string; bizType?: string }[]; + myActions: { + canReview: boolean; + reviewStage: string | null; + reviewKind: 'tech' | 'commercial'; + canAssignFetcher: boolean; + canFetch: boolean; + canUploadFetchFile?: boolean; + canAssignDrafter: boolean; + canSubmitDraft: boolean; + canPrint: boolean; + canCompleteExecution: boolean; + canOpenResult: boolean; + canEditCompetitor?: boolean; + canEditLegalEntity?: boolean; + canConvertToContract: boolean; + canTerminate: boolean; + canRestore?: boolean; + canRollback?: boolean; + rollbackTarget?: string | null; + }; +}; + +function fmt(v?: string | null) { + return v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '—'; +} +function fmtCreated(v?: string | null) { + return v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '—'; +} + +export default function BidDetailPage({ + id: idProp, + embedded, + officeOpen, +}: { + id?: string; + embedded?: boolean; + officeOpen?: boolean; +} = {}) { + const { id: routeId } = useParams(); + const id = idProp || routeId; + const navigate = useNavigate(); + const qc = useQueryClient(); + const userId = useAuth((s) => s.id); + const [reviewOpen, setReviewOpen] = useState<'APPROVED' | 'REJECTED' | null>(null); + const [termOpen, setTermOpen] = useState(false); + const [rollbackOpen, setRollbackOpen] = useState(false); + const [reviewForm] = Form.useForm(); + const [fetchForm] = Form.useForm(); + const [termForm] = Form.useForm(); + const [rollbackForm] = Form.useForm(); + const [fetcherForm] = Form.useForm(); + const [assignForm] = Form.useForm(); + const [draftForm] = Form.useForm(); + const [printForm] = Form.useForm(); + const [execForm] = Form.useForm(); + const [openForm] = Form.useForm(); + const [competitorOpen, setCompetitorOpen] = useState(false); + const [competitorForm] = Form.useForm(); + const [entityOpen, setEntityOpen] = useState(false); + const [entityForm] = Form.useForm(); + const [contractForm] = Form.useForm(); + + const q = useQuery({ + queryKey: ['bid', id], + enabled: Boolean(id), + queryFn: () => api.get(`/bid-cases/${id}`), + }); + const staff = useQuery({ + queryKey: ['staff'], + queryFn: () => api.get('/staff'), + }); + const entities = useQuery({ queryKey: ['legal-entities'], queryFn: () => api.get('/legal-entities') }); + const bid = q.data?.data as BidDetail | undefined; + const staffOpts = ((staff.data?.data ?? []) as Staff[]).map((s) => ({ + label: `${s.name}${s.department ? `(${s.department})` : ''}`, + value: s.id, + })); + const entityOpts = (((entities.data?.data ?? []) as { id: string; name: string }[])).map((e) => ({ + label: e.name, + value: e.id, + })); + + const invalidate = async () => { + await qc.invalidateQueries({ queryKey: ['bid', id] }); + await qc.invalidateQueries({ queryKey: ['office-overview'] }); + await qc.invalidateQueries({ queryKey: ['/office/approvals'] }); + await qc.invalidateQueries({ queryKey: ['/office/todos'] }); + await qc.invalidateQueries({ + predicate: (query) => String(query.queryKey[0]).includes('bid-cases'), + }); + }; + + const reviewMut = useMutation({ + mutationFn: (body: { result: 'APPROVED' | 'REJECTED'; comment?: string }) => + api.post(`/bid-cases/${id}/reviews`, body), + onSuccess: async () => { + message.success('已提交'); + setReviewOpen(null); + reviewForm.resetFields(); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const fetchMut = useMutation({ + mutationFn: (body: Record) => api.post(`/bid-cases/${id}/fetch`, body), + onSuccess: async () => { + message.success('取标完成,已进入技术终审'); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const termMut = useMutation({ + mutationFn: (body: { comment: string }) => api.post(`/bid-cases/${id}/terminate`, body), + onSuccess: async () => { + message.success('已终止'); + setTermOpen(false); + termForm.resetFields(); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const restoreMut = useMutation({ + mutationFn: () => api.post(`/bid-cases/${id}/restore`), + onSuccess: async () => { + message.success('已恢复到终止前的办理步骤'); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const rollbackMut = useMutation({ + mutationFn: (body: { comment: string }) => api.post(`/bid-cases/${id}/rollback`, body), + onSuccess: async () => { + message.success('已退回上一步,可重新办理'); + setRollbackOpen(false); + rollbackForm.resetFields(); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const fetcherMut = useMutation({ + mutationFn: (body: Record) => api.post(`/bid-cases/${id}/assign-fetcher`, body), + onSuccess: async () => { + message.success('已指定取标人'); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const assignMut = useMutation({ + mutationFn: (body: Record) => api.post(`/bid-cases/${id}/assign-drafter`, body), + onSuccess: async () => { + message.success('已派制标人'); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const draftMut = useMutation({ + mutationFn: (body: Record) => api.post(`/bid-cases/${id}/submit-draft`, body), + onSuccess: async () => { + message.success('已提交标书,进入一审'); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const printMut = useMutation({ + mutationFn: (body: Record) => api.post(`/bid-cases/${id}/print`, body), + onSuccess: async () => { + message.success('印封完成,进入投标执行'); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const execMut = useMutation({ + mutationFn: (body: Record) => api.post(`/bid-cases/${id}/complete`, body), + onSuccess: async () => { + message.success('已派投标人。开标反馈已进对方个人办公待办,由投标人登记结果和费用。'); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const openMut = useMutation({ + mutationFn: (body: Record) => api.post(`/bid-cases/${id}/open-result`, body), + onSuccess: async () => { + message.success('已登记开标结果'); + await invalidate(); + if (officeOpen) navigate('/office/todos'); + }, + onError: (e: Error) => message.error(e.message), + }); + const competitorMut = useMutation({ + mutationFn: (body: Record) => api.patch(`/bid-cases/${id}/competitor`, body), + onSuccess: async () => { + message.success('已登记对方中标信息'); + setCompetitorOpen(false); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const entityMut = useMutation({ + mutationFn: (body: { legalEntityId: string }) => api.patch(`/bid-cases/${id}/legal-entity`, body), + onSuccess: async () => { + message.success('已修改参与主体'); + setEntityOpen(false); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const convertMut = useMutation({ + mutationFn: (body: Record) => + api.post('/contracts', { bidCaseId: id, ...body }), + onSuccess: async (res: { data?: { id?: string } }) => { + message.success('已生成合同草稿'); + await invalidate(); + if (res.data?.id) navigate(`/contract/${res.data.id}`); + }, + onError: (e: Error) => message.error(e.message), + }); + + if (q.isError) { + return ( + navigate(-1)} size="small"> + 返回 + + } + /> + ); + } + + if (!bid) { + return 加载中…; + } + + const meta = statusMeta(bid.status); + const current = stepIndex(bid.status); + const hasFetcher = bid.assignees.some((a) => a.role === 'FETCHER'); + const specialCurrent = bid.status === 'FETCH' ? (hasFetcher ? 1 : 0) : current; + const commercial = bid.myActions.reviewKind === 'commercial'; + const ledgerProject = bid.contracts?.flatMap((c) => c.projects || [])[0]; + const stepStatus = + bid.status === 'TERMINATED' + ? 'error' + : ['WON', 'DRAFTING'].includes(bid.status) && current >= 8 + ? 'finish' + : 'process'; + + const page = ( + <> + + {embedded || officeOpen ? null : ( + + )} + {bid.myActions.canRollback ? ( + + ) : null} + {bid.myActions.canTerminate && !officeOpen ? ( + + ) : null} + {bid.myActions.canRestore && !officeOpen ? ( + + ) : null} + {bid.myActions.canEditCompetitor ? ( + + ) : null} + {bid.myActions.canEditLegalEntity ? ( + + ) : null} + + } + /> + + + {bid.sourceType === 'SPECIAL' ? 特殊筛选 : null} + {meta.text} + {bid.legalEntity?.name ? {bid.legalEntity.name} : null} + {bid.costDisposition ? {COST_LABEL[bid.costDisposition] || bid.costDisposition} : null} + + + {bid.status === 'TERMINATED' ? ( + + ) : null} + {bid.status === 'WON' ? ( + + ) : null} + {bid.status === 'LOST' || bid.status === 'FAILED' ? ( + + ) : null} + + {officeOpen ? null : ( + + )} + + + {tenderNo(bid)} + {bid.legalEntity?.name || '—'} + {bid.winnerName || '—'} + {bid.winnerAmount != null && bid.winnerAmount !== '' ? bid.winnerAmount : '—'} + {bid.projectType || '—'} + {inferTenderMethod(bid.name, bid.tenderMethod) || '—'} + {bid.qualificationNeed || '—'} + {bid.source || '—'} + {fmt(bid.openAt)} + {bid.openPlace || '—'} + {bid.bondMethod || '—'} + {bid.delivererName || '—'} + {bid.applyMethod || '—'} + {bid.openMethod || '—'} + + {bid.applyStartAt || bid.applyEndAt ? `${fmt(bid.applyStartAt)} ~ ${fmt(bid.applyEndAt)}` : '—'} + + + {bid.nasPath || '—'} + + {bid.createdBy?.displayName || '—'} + {fmtCreated(bid.createdAt)} + + {bid.remark || bid.resultNote || '—'} + + + {(() => { + const files = bid.myActions.canUploadFetchFile + ? bid.files?.filter((f) => f.bizType !== 'BID_FILE') + : bid.files; + return files?.length ? ( +
+ 附件 + +
+ ) : null; + })()} + + {bid.myActions.canReview ? ( + + + + {bid.myActions.canRollback ? ( + + ) : null} + + } + /> + ) : null} + + {bid.myActions.canAssignFetcher ? ( + +
fetcherMut.mutate(v)}> + + + + + + + + + + {bid.myActions.canAssignDrafter ? ( + +
assignMut.mutate(v)} + > + + + + + +
+ ) : null} + + {bid.myActions.canSubmitDraft ? ( + +
draftMut.mutate(v)}> + + + + + + + + +
+ ) : null} + + {bid.myActions.canPrint ? ( + +
printMut.mutate(v)}> + + + + + + + + +
+ ) : null} + + {bid.myActions.canCompleteExecution && !officeOpen ? ( + +
+ execMut.mutate({ + ...values, + openAt: values.openAt ? values.openAt.toISOString() : undefined, + }) + } + > + + + + + + + + + + + + + + + + + + + + ) : null + } + + + + + + +
+ ) : null} + + {bid.myActions.canConvertToContract && !officeOpen ? ( + +
convertMut.mutate(v)} + > + + + + + + + + + + + + + + + + + + + + +
+ ) : null} + + {bid.contracts?.length ? ( + <> + 关联合同 / 项目 +
[0]) => ( + {contractNoText(v)} + ), + }, + { title: '名称', dataIndex: 'name' }, + { title: '金额', dataIndex: 'amount', width: 120 }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (v: string) => { + const s = contractStatusMeta(v); + return {s.text}; + }, + }, + { + title: '项目台账', + render: (_: unknown, r: NonNullable[0]) => { + const p = r.projects?.[0]; + if (!p) return '—'; + const s = projectStatusMeta(p.status); + return ( + + {p.projectNo} + {s.text} + + ); + }, + }, + ]} + /> + + ) : null} + + {officeOpen || (bid.status === 'WON' && ledgerProject) ? ( + + + {ledgerProject ? ( + <> + + + + ) : ( + <> + + + + )} + + + ) : null} + + {bid.assignees?.length ? ( + <> + 人员 +
ASSIGN_ROLE_LABEL[v] || v }, + { title: '姓名', render: (_: unknown, r: BidDetail['assignees'][0]) => r.user?.displayName || r.userId }, + ]} + /> + + ) : null} + + {bid.versions?.length ? ( + <> + 标书版本 +
`V${v}` }, + { title: 'NAS 路径', dataIndex: 'nasPath' }, + { title: '说明', dataIndex: 'note', render: (v: string) => v || '—' }, + { title: '提交人', render: (_: unknown, r: BidDetail['versions'][0]) => r.submittedBy?.displayName }, + { title: '时间', dataIndex: 'createdAt', width: 170, render: (v: string) => fmt(v) }, + ]} + /> + + ) : null} + + 会签 / 审核 +
STAGE_LABEL[v] || v }, + { + title: '办理人', + render: (_: unknown, r: Reviewer) => ( + + {r.user?.displayName || staffOpts.find((s) => s.value === r.userId)?.label.split('(')[0] || r.userId} + {r.userId === userId ? '(我)' : ''} + + ), + }, + { + title: '结果', + dataIndex: 'result', + width: 100, + render: (v: string) => { + const s = REVIEW_RESULT[v] || { color: 'default', text: v }; + return {s.text}; + }, + }, + { title: '意见', dataIndex: 'comment', render: (v: string) => v || '—' }, + { title: '时间', dataIndex: 'decidedAt', width: 170, render: (v: string) => fmt(v) }, + ]} + /> + + 办理记录 + ({ + children: ( +
+
+ {ACTION_LABEL[log.action] || log.action} + {log.toStatus ? ` → ${statusMeta(log.toStatus).text}` : ''} +
+
+ {log.actor?.displayName} · {fmt(log.createdAt)} +
+ {log.comment ?
{log.comment}
: null} +
+ ), + }))} + /> + + setReviewOpen(null)} + onOk={() => reviewForm.submit()} + confirmLoading={reviewMut.isPending} + okButtonProps={{ danger: reviewOpen === 'REJECTED' }} + okText={reviewOpen === 'REJECTED' ? (commercial ? '确认打回' : '确认驳回') : '确认通过'} + > +
{ + if (!reviewOpen) return; + reviewMut.mutate({ result: reviewOpen, comment: v.comment }); + }} + > + + + + +
+ + setTermOpen(false)} + onOk={() => termForm.submit()} + confirmLoading={termMut.isPending} + okButtonProps={{ danger: true }} + okText="确认终止" + > +
termMut.mutate({ comment: v.comment })}> + + + + +
+ + setRollbackOpen(false)} + onOk={() => rollbackForm.submit()} + confirmLoading={rollbackMut.isPending} + okText="确认退回" + > +
rollbackMut.mutate({ comment: v.comment })}> + + + + +
+ + setCompetitorOpen(false)} + onOk={() => competitorForm.submit()} + confirmLoading={competitorMut.isPending} + okText="保存" + > + + 没中或不做的项目,登记别人中了多少钱,经营报表「落标对照」里能查到。 + +
+ competitorMut.mutate({ + winnerName: v.winnerName, + winnerAmount: v.winnerAmount, + winnerNote: v.winnerNote, + }) + } + > + + + + + + + + + + +
+ + ); + if (embedded) return page; + return ( + navigate(officeOpen ? '/office/todos' : listPath(bid.status, bid.sourceType))} + > + {page} + + ); +} + +function Panel({ + title, + hint, + children, +}: { + title: string; + hint: string; + children: ReactNode; +}) { + return ( +
+ {title} + {hint} + {children} +
+ ); +} diff --git a/apps/web/src/pages/BidListPage.tsx b/apps/web/src/pages/BidListPage.tsx new file mode 100644 index 0000000..c623f08 --- /dev/null +++ b/apps/web/src/pages/BidListPage.tsx @@ -0,0 +1,375 @@ +import { Button, Form, Input, InputNumber, Modal, Select, Space, Tag, Typography, message } from 'antd'; +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useAuth } from '../store/auth'; +import BidCreatePage from './BidCreatePage'; +import BidDetailPage from './BidDetailPage'; +import { FormDrawer } from './FormDrawer'; +import { inferTenderMethod, OPEN_METHOD_OPTIONS, statusMeta, tenderNo, BID_STATUS_META } from './bidStatus'; +import { ResourcePage } from './ResourcePage'; +import { api } from '../api/client'; + +function text(v: unknown) { + return v ? String(v) : '—'; +} + +function day(v: unknown) { + if (!v) return '—'; + return String(v).slice(0, 10); +} + +export default function BidListPage({ + title, + bucket, + extra, + special, +}: { + title: string; + bucket?: string; + extra?: boolean; + special?: boolean; +}) { + const [creating, setCreating] = useState(false); + const [openRow, setOpenRow] = useState>(); + const [editRow, setEditRow] = useState>(); + const [competitorForm] = Form.useForm(); + const [projectType, setProjectType] = useState(); + const [status, setStatus] = useState(); + const [legalEntityId, setLegalEntityId] = useState(); + const roles = useAuth((s) => s.roles); + const permissions = useAuth((s) => s.permissions); + const qc = useQueryClient(); + const entities = useQuery({ queryKey: ['/legal-entities'], queryFn: () => api.get('/legal-entities') }); + const canCreateSpecial = roles.some((r) => r === 'admin' || r === 'owner'); + const canCreate = + ['admin', 'owner', 'biz_director', 'biz_staff'].some((r) => roles.includes(r)) || permissions.includes('bid:screening'); + const params = new URLSearchParams(); + if (bucket) params.set('bucket', bucket); + if (special) { + params.set('sourceType', 'SPECIAL'); + if (!bucket) params.set('bucket', 'screening'); + } + if (projectType) params.set('projectType', projectType); + if (status) params.set('status', status); + if (legalEntityId) params.set('legalEntityId', legalEntityId); + const qs = params.toString(); + const listBucket = bucket || (special ? 'screening' : undefined); + const statusOpts = ( + listBucket === 'screening' + ? ['TECH_INITIAL', 'FETCH', 'TECH_FINAL'] + : listBucket === 'drafting' + ? ['DRAFTING', 'REVIEW1', 'REVIEW2', 'PRINT'] + : listBucket === 'execution' + ? ['LIST', 'PENDING'] + : listBucket === 'result' + ? ['WON', 'LOST', 'FAILED'] + : listBucket === 'terminated' + ? ['TERMINATED'] + : Object.keys(BID_STATUS_META) + ).map((v) => ({ value: v, label: BID_STATUS_META[v]?.text || v })); + return ( + <> + setOpenRow(row)} + emptyText={ + extra + ? '还没有在途筛选。右上角可以发起。' + : bucket === 'drafting' + ? '没有正在制标的事项。终审通过后才会进来。' + : bucket === 'execution' + ? '没有待派投标人或待开标的事项。' + : bucket === 'result' + ? '还没有开标结果。' + : bucket === 'terminated' + ? '没有终止的投标。' + : '还没有记录。' + } + endpoint={qs ? `/bid-cases?${qs}` : '/bid-cases'} + toolbarExtra={ + + setProjectType(v)} + options={[ + { label: '软件', value: '软件' }, + { label: '三维', value: '三维' }, + { label: '摄制', value: '摄制' }, + { label: '物资', value: '物资' }, + { label: '集成', value: '集成' }, + ]} + /> + + + + + + + + + + + + ); +} diff --git a/apps/web/src/pages/BondListPage.tsx b/apps/web/src/pages/BondListPage.tsx new file mode 100644 index 0000000..937470a --- /dev/null +++ b/apps/web/src/pages/BondListPage.tsx @@ -0,0 +1,237 @@ +import { Button, DatePicker, Form, Input, InputNumber, Popconfirm, Select, Space, Tag, message } from 'antd'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import dayjs from 'dayjs'; +import { api } from '../api/client'; +import { FormDrawer } from './FormDrawer'; +import { ResourcePage } from './ResourcePage'; +import { CostTargetLink, targetName } from './FinanceCells'; +import { BOND_BUCKET_META, BOND_TYPE_LABEL, money } from './financeStatus'; +import { ApplyBucketBar } from './ApplyBucketBar'; + +const META: Record< + string, + { title: string; description: string; path: string; needBid?: boolean; needProject?: boolean } +> = { + TENDER: { + title: '投标保证金', + description: '挂投标。开标后该退未退会标成逾期。后续中标转项目时仍可回看这笔。', + path: '/finance/bonds/tender', + needBid: true, + }, + PERFORMANCE: { + title: '履约保证金', + description: '挂中标后的投标或已立项项目。缴出未退会进资金占用。', + path: '/finance/bonds/performance', + }, + WARRANTY: { + title: '项目质保金', + description: '挂正式项目。验收后按约定日期退回,逾期单独列出。', + path: '/finance/bonds/warranty', + needProject: true, + }, +}; + +export default function BondListPage({ bondType }: { bondType: 'TENDER' | 'PERFORMANCE' | 'WARRANTY' }) { + const [params] = useSearchParams(); + const bucket = params.get('bucket') || ''; + const meta = META[bondType]; + const endpoint = `/bonds?bondType=${bondType}${bucket ? `&bucket=${bucket}` : ''}`; + const qc = useQueryClient(); + const [form] = Form.useForm(); + const [creating, setCreating] = useState(false); + const targets = useQuery({ + queryKey: ['cost-targets'], + queryFn: () => api.get('/cost-targets'), + }); + const bids = ((targets.data?.data?.bids ?? []) as { id: string; bidNo: string; name: string }[]).map((b) => ({ + value: b.id, + label: `${b.bidNo} ${b.name}`, + })); + const projects = ((targets.data?.data?.projects ?? []) as { id: string; projectNo: string; name: string }[]).map((p) => ({ + value: p.id, + label: `${p.projectNo} ${p.name}`, + })); + + const createMut = useMutation({ + mutationFn: (body: Record) => api.post('/bonds', body), + onSuccess: () => { + message.success('已登记'); + qc.invalidateQueries({ queryKey: [endpoint] }); + qc.invalidateQueries({ queryKey: ['/bonds'] }); + }, + onError: (e: Error) => message.error(e.message), + }); + const actMut = useMutation({ + mutationFn: ({ id, action, dueBackAt }: { id: string; action: 'pay' | 'return'; dueBackAt?: string }) => + api.post(`/bonds/${id}/${action}`, action === 'pay' ? { dueBackAt } : {}), + onSuccess: () => { + message.success('已更新'); + qc.invalidateQueries(); + }, + onError: (e: Error) => message.error(e.message), + }); + + const openCreate = () => { + form.resetFields(); + form.setFieldsValue({ bondType }); + setCreating(true); + }; + + return ( + <> + + 登记 + + } + /> + } + columns={[ + { title: '编号', dataIndex: 'bondNo', width: 150 }, + { title: '类型', dataIndex: 'bondType', width: 120, render: (v: string) => BOND_TYPE_LABEL[v] || v }, + { title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) }, + { + title: '对象', + width: 160, + render: (_: unknown, row: Record) => ( + + ), + }, + { + title: bondType === 'TENDER' ? '投标' : '项目', + ellipsis: true, + render: (_: unknown, row: Record) => + targetName( + row.bidCase as { name?: string } | null, + row.project as { name?: string } | null, + row.contract as { name?: string } | null, + ), + }, + { + title: '状态', + dataIndex: 'bucket', + width: 100, + render: (v: string) => { + const s = BOND_BUCKET_META[v] || { color: 'default', text: v || '—' }; + return {s.text}; + }, + }, + { + title: '应退日', + dataIndex: 'dueBackAt', + width: 120, + render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—'), + }, + { + title: '操作', + width: 180, + render: (_: unknown, row: Record) => ( + + {row.status === 'UNPAID' ? ( + + ) : null} + {row.status === 'PAID' ? ( + + ) : null} + { + await api.delete(`/bonds/${row.id}`); + message.success('已删除'); + qc.invalidateQueries(); + }} + > + + + + ), + }, + ]} + /> + setCreating(false)} + okText="提交保存" + okLoading={createMut.isPending} + onOk={async () => { + const v = await form.validateFields(); + await createMut.mutateAsync({ + ...v, + bondType, + dueBackAt: v.dueBackAt ? v.dueBackAt.toISOString() : undefined, + }); + setCreating(false); + }} + > +
+ + + + + + + + + + ) : ( + + + + +
+ + ); +} diff --git a/apps/web/src/pages/CalendarPage.tsx b/apps/web/src/pages/CalendarPage.tsx new file mode 100644 index 0000000..653a759 --- /dev/null +++ b/apps/web/src/pages/CalendarPage.tsx @@ -0,0 +1,75 @@ +import { Table, Tag } from 'antd'; +import { useQuery } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import dayjs from 'dayjs'; +import { api } from '../api/client'; +import { PageHeader } from './ResourcePage'; + +const KIND_LABEL: Record = { + BID_OPEN: '开标', + TASK: '任务截止', + TODO: '待办', + BORROW: '证照归还', + SEAL: '印章归还', + VEHICLE: '用车', +}; + +type CalItem = { + id: string; + kind: string; + title: string; + start?: string | null; + place?: string | null; + ref?: string | null; + overdue?: boolean; + path?: string | null; +}; + +export default function CalendarPage() { + const q = useQuery({ + queryKey: ['/office/calendar'], + queryFn: () => api.get('/office/calendar'), + }); + const items = (q.data?.data ?? []) as CalItem[]; + + return ( + <> + +
KIND_LABEL[v] || v, + }, + { + title: '事项', + dataIndex: 'title', + render: (v: string, r: CalItem) => (r.path ? {v} : v), + }, + { title: '编号', dataIndex: 'ref', width: 150, render: (v: string) => v || '—' }, + { + title: '时间', + dataIndex: 'start', + width: 170, + render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '—'), + }, + { title: '地点 / 说明', dataIndex: 'place', render: (v: string) => v || '—' }, + { + title: '状态', + width: 90, + render: (_: unknown, r: CalItem) => + r.overdue ? 逾期 : 待办, + }, + ]} + /> + + ); +} diff --git a/apps/web/src/pages/ContractDetailPage.tsx b/apps/web/src/pages/ContractDetailPage.tsx new file mode 100644 index 0000000..85afbe4 --- /dev/null +++ b/apps/web/src/pages/ContractDetailPage.tsx @@ -0,0 +1,591 @@ +import { useEffect, type ReactNode } from 'react'; +import { + Alert, + Button, + Checkbox, + DatePicker, + Descriptions, + Form, + Input, + InputNumber, + Modal, + Space, + Table, + Tag, + Timeline, + Typography, + message, +} from 'antd'; +import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import dayjs from 'dayjs'; +import { api } from '../api/client'; +import { PageHeader } from './ResourcePage'; +import { FormDrawer } from './FormDrawer'; +import { BizFileList } from './FileAttachments'; +import { REVIEW_RESULT } from './bidStatus'; +import { CONTRACT_ACTION_LABEL, contractNoText, contractStatusMeta } from './contractStatus'; + +type UserRef = { id: string; displayName: string; username?: string }; + +type ContractDetail = { + id: string; + contractNo: string; + name: string; + amount: string | number; + status: string; + partyA?: string | null; + partyB?: string | null; + nasPath?: string | null; + remark?: string | null; + signedAt?: string | null; + submittedAt?: string | null; + approvedAt?: string | null; + createdAt: string; + bidCase?: { id: string; bidNo: string; name: string; status: string; quoteAmount?: string | null } | null; + legalEntity?: { name: string } | null; + createdBy?: UserRef | null; + milestones: { + id: string; + name: string; + ratio: string | number; + dueAt?: string | null; + trigger?: string | null; + invoice?: { id: string; status: string; invoiceNo?: string | null } | null; + }[]; + reviewers: { + id: string; + userId: string; + result: string; + comment?: string | null; + decidedAt?: string | null; + user?: UserRef; + }[]; + actionLogs: { + id: string; + action: string; + fromStatus?: string | null; + toStatus?: string | null; + comment?: string | null; + createdAt: string; + actor?: UserRef; + }[]; + sealRequests: { + id: string; + sealType: string; + reason: string; + takeOut: boolean; + status: string; + createdAt: string; + }[]; + projects: { id: string; projectNo: string; status: string; presalesCost?: string | number }[]; + files?: { id: string; fileName: string; size: number; mimeType: string; bizType?: string }[]; + myActions: { + canEdit: boolean; + canSubmit: boolean; + canRequestSeal: boolean; + canReview: boolean; + }; +}; + +function fmt(v?: string | null) { + return v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '—'; +} + +export default function ContractDetailPage({ + id: idProp, + embedded, +}: { + id?: string; + embedded?: boolean; +} = {}) { + const { id: routeId } = useParams(); + const id = idProp || routeId; + const navigate = useNavigate(); + const qc = useQueryClient(); + const [infoForm] = Form.useForm(); + const [mileForm] = Form.useForm(); + const [sealForm] = Form.useForm(); + const [reviewForm] = Form.useForm(); + + const q = useQuery({ + queryKey: ['contract', id], + enabled: Boolean(id), + queryFn: () => api.get(`/contracts/${id}`), + }); + const row = q.data?.data as ContractDetail | undefined; + + useEffect(() => { + if (!row) return; + infoForm.setFieldsValue({ + contractNo: row.contractNo || undefined, + name: row.name, + amount: Number(row.amount) || undefined, + partyA: row.partyA || undefined, + partyB: row.partyB || undefined, + nasPath: row.nasPath || undefined, + remark: row.remark || undefined, + }); + mileForm.setFieldsValue({ + items: row.milestones.length + ? row.milestones.map((m) => ({ + name: m.name, + ratio: Number(m.ratio), + dueAt: m.dueAt ? dayjs(m.dueAt) : undefined, + trigger: m.trigger || undefined, + })) + : [ + { name: '预付款', ratio: 30 }, + { name: '进度款', ratio: 40 }, + { name: '验收款', ratio: 30 }, + ], + }); + }, [row, infoForm, mileForm]); + + const invalidate = async () => { + await qc.invalidateQueries({ queryKey: ['contract', id] }); + await qc.invalidateQueries({ queryKey: ['/contracts'] }); + await qc.invalidateQueries({ queryKey: ['/projects'] }); + await qc.invalidateQueries({ queryKey: ['/office/approvals'] }); + await qc.invalidateQueries({ queryKey: ['/office/todos'] }); + }; + + const updateMut = useMutation({ + mutationFn: (body: Record) => api.patch(`/contracts/${id}`, body), + onSuccess: async () => { + message.success('已保存'); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const mileMut = useMutation({ + mutationFn: (items: Array>) => api.put(`/contracts/${id}/milestones`, { items }), + onSuccess: async () => { + message.success('收款条款已保存'); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const sealMut = useMutation({ + mutationFn: (body: Record) => + api.post(`/contracts/${id}/seal`, { + ...body, + returnAt: body.returnAt ? dayjs(body.returnAt as string).format('YYYY-MM-DD') : undefined, + }), + onSuccess: async () => { + message.success('已申请用章'); + sealForm.resetFields(); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const submitMut = useMutation({ + mutationFn: () => api.post(`/contracts/${id}/submit`), + onSuccess: async () => { + message.success('已提交审批'); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const reviewMut = useMutation({ + mutationFn: (body: { result: 'APPROVED' | 'REJECTED'; comment?: string }) => + api.post(`/contracts/${id}/reviews`, body), + onSuccess: async () => { + message.success('已办理'); + reviewForm.resetFields(); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const invoiceMut = useMutation({ + mutationFn: (milestoneId: string) => api.post('/invoices', { contractId: id, milestoneId }), + onSuccess: async () => { + message.success('已生成待开票,请到开票收款登记发票号'); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + + if (q.isError) { + return ( + navigate('/contract/tender')} size="small"> + 返回 + + } + /> + ); + } + + if (!row) { + return 加载中…; + } + + const meta = contractStatusMeta(row.status); + const project = row.projects[0]; + + const decide = (result: 'APPROVED' | 'REJECTED') => { + if (result === 'REJECTED') { + Modal.confirm({ + title: '驳回合同', + content: ( +
+ + + + + ), + okButtonProps: { danger: true }, + okText: '确认驳回', + onOk: async () => { + const v = await reviewForm.validateFields(); + await reviewMut.mutateAsync({ result: 'REJECTED', comment: v.comment }); + }, + }); + return; + } + reviewMut.mutate({ result: 'APPROVED' }); + }; + + const page = ( + <> + + {embedded ? null : ( + + )} + {row.bidCase ? ( + + ) : null} + + } + /> + + + {meta.text} + {row.legalEntity?.name ? {row.legalEntity.name} : null} + {row.bidCase?.bidNo ? {row.bidCase.bidNo} : null} + {project ? ( + + 已立项 {project.projectNo} + + ) : null} + + + {row.status === 'APPROVED' && project ? ( + + ) : null} + {(row.status === 'DRAFT' || row.status === 'REJECTED') && project && row.myActions.canEdit ? ( + + ) : null} + {row.myActions.canReview ? ( + + + + + } + /> + ) : null} + + + {contractNoText(row.contractNo)} + {row.partyA || '—'} + {row.partyB || row.legalEntity?.name || '—'} + {row.amount} + {row.createdBy?.displayName === '系统管理员' ? '—' : row.createdBy?.displayName || '—'} + {fmt(row.submittedAt)} + {fmt(row.approvedAt)} + + {row.nasPath || '—'} + + + {row.remark || '—'} + + +
+ 附件 + +
+ + {row.myActions.canEdit ? ( + +
updateMut.mutate(v)} + > + + + + + + + + + + + + + + + + + + + + + + + +
+ ) : null} + + + {row.myActions.canEdit ? ( +
+ mileMut.mutate( + (v.items || []).map((i: { name: string; ratio: number; dueAt?: dayjs.Dayjs; trigger?: string }) => ({ + name: i.name, + ratio: i.ratio, + dueAt: i.dueAt ? i.dueAt.toISOString() : undefined, + trigger: i.trigger, + })), + ) + } + > + + {(fields, { add, remove }) => ( + <> + {fields.map((field) => ( + + + + + + + + + + + + + + remove(field.name)} /> + + ))} + + + + + )} + + + + ) : ( +
fmt(v) }, + { title: '触发条件', dataIndex: 'trigger', render: (v: string) => v || '—' }, + { + title: '开票', + width: 140, + render: (_: unknown, m: ContractDetail['milestones'][0]) => { + if (row.status !== 'APPROVED') return '—'; + if (m.invoice) { + return {m.invoice.invoiceNo || m.invoice.status}; + } + return ( + + ); + }, + }, + ]} + /> + )} + + + + {row.sealRequests.length ? ( +
(v ? '是' : '否') }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (v: string) => { + const s = REVIEW_RESULT[v] || { color: 'default', text: v }; + return {s.text}; + }, + }, + ]} + /> + ) : ( + 尚未申请用章。 + )} + {row.myActions.canRequestSeal ? ( + sealMut.mutate(v)}> + + + + + + + + 外带 + + + {() => + sealForm.getFieldValue('takeOut') ? ( + + + + ) : null + } + + + + ) : null} + + + {row.myActions.canSubmit ? ( + + + + ) : null} + + {row.reviewers.length ? ( + <> + 会签 +
r.user?.displayName || r.userId }, + { + title: '结果', + dataIndex: 'result', + width: 110, + render: (v: string) => { + const s = REVIEW_RESULT[v] || { color: 'default', text: v }; + return {s.text}; + }, + }, + { title: '意见', dataIndex: 'comment', render: (v: string) => v || '—' }, + { title: '时间', dataIndex: 'decidedAt', width: 170, render: (v: string) => fmt(v) }, + ]} + /> + + ) : null} + + {row.actionLogs.length ? ( + <> + 办理记录 + ({ + children: ( + + {fmt(l.createdAt)} · {l.actor?.displayName || ''} · {CONTRACT_ACTION_LABEL[l.action] || l.action} + {l.comment ? `:${l.comment}` : ''} + + ), + }))} + /> + + ) : null} + + ); + if (embedded) return page; + return ( + navigate(row.bidCase ? '/contract/tender' : '/contract/incremental')}> + {page} + + ); +} + +function Panel({ title, hint, children }: { title: string; hint: string; children: ReactNode }) { + return ( +
+ {title} + {hint} + {children} +
+ ); +} diff --git a/apps/web/src/pages/ContractListPage.tsx b/apps/web/src/pages/ContractListPage.tsx new file mode 100644 index 0000000..7aab549 --- /dev/null +++ b/apps/web/src/pages/ContractListPage.tsx @@ -0,0 +1,243 @@ +import { Button, Form, Input, InputNumber, Select, Tag, message } from 'antd'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import ContractDetailPage from './ContractDetailPage'; +import { FormDrawer } from './FormDrawer'; +import { ResourcePage } from './ResourcePage'; +import { contractHandler, contractNoText, contractStatusMeta } from './contractStatus'; +import { money } from './financeStatus'; +import { tenderNo } from './bidStatus'; +import { api } from '../api/client'; + +export default function ContractListPage({ + title, + description, + bucket, +}: { + title: string; + description?: string; + bucket?: string; +}) { + const kind = bucket === 'tender' ? 'TENDER' : bucket === 'direct' ? 'DIRECT' : 'OTHER'; + const createLabel = bucket === 'tender' ? '新建投标合同' : bucket === 'direct' ? '新建直签合同' : '新建其他合同'; + const qc = useQueryClient(); + const [openRow, setOpenRow] = useState>(); + const [creating, setCreating] = useState(false); + const [form] = Form.useForm(); + const endpoint = bucket ? `/contracts?bucket=${bucket}` : '/contracts'; + const entities = useQuery({ + queryKey: ['/legal-entities'], + queryFn: () => api.get('/legal-entities'), + }); + const bids = useQuery({ + queryKey: ['/bid-cases', 'won'], + queryFn: () => api.get('/bid-cases?bucket=result'), + enabled: bucket === 'tender', + }); + const entityOpts = ((entities.data as { data?: { id: string; name: string; shortName?: string }[] })?.data || []).map( + (e) => ({ value: e.id, label: e.shortName || e.name }), + ); + const bidItems = ((bids.data as { data?: { items?: { id: string; bidNo: string; name: string; status: string }[] } })?.data?.items || + []) as { id: string; bidNo: string; name: string; status: string }[]; + const bidOpts = bidItems + .filter((b) => b.status === 'WON') + .map((b) => ({ value: b.id, label: `${b.bidNo} ${b.name}` })); + const createMut = useMutation({ + mutationFn: (body: Record) => api.post('/contracts', { ...body, kind }), + onSuccess: () => { + message.success(`已${createLabel}`); + qc.invalidateQueries({ queryKey: [endpoint] }); + }, + onError: (e: Error) => message.error(e.message), + }); + + const columns = [ + { + title: '合同号', + dataIndex: 'contractNo', + width: 180, + render: (v: string) => contractNoText(v), + }, + { + title: '名称', + dataIndex: 'name', + width: 220, + ellipsis: true, + render: (v: string, row: Record) => ( + + ), + }, + ...(bucket === 'tender' + ? [ + { + title: '招标编号', + width: 150, + render: (_: unknown, row: Record) => { + const bid = row.bidCase as { bidNo?: string; externalNo?: string } | null; + return tenderNo(bid); + }, + }, + ] + : []), + { + title: '甲方', + dataIndex: 'partyA', + width: 180, + ellipsis: true, + render: (v: string, row: Record) => { + const bid = row.bidCase as { party?: { name?: string } } | null; + return v || bid?.party?.name || '—'; + }, + }, + { + title: '乙方', + dataIndex: 'partyB', + width: 180, + ellipsis: true, + render: (v: string, row: Record) => { + const e = row.legalEntity as { shortName?: string; name?: string } | null; + return v || e?.name || '—'; + }, + }, + { + title: '我方主体', + width: 120, + ellipsis: true, + render: (_: unknown, row: Record) => { + const e = row.legalEntity as { shortName?: string; name?: string } | null; + return e?.shortName || e?.name || '—'; + }, + }, + { title: '金额', dataIndex: 'amount', width: 120, render: (v: string) => money(v) }, + { + title: '经办', + width: 90, + render: (_: unknown, row: Record) => + contractHandler({ + createdBy: row.createdBy as { displayName?: string; username?: string } | null, + bidCase: row.bidCase as { createdBy?: { displayName?: string } | null } | null, + }), + }, + { + title: '签订', + dataIndex: 'signedAt', + width: 110, + render: (v: string) => (v ? String(v).slice(0, 10) : '—'), + }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: (v: string) => { + const s = contractStatusMeta(v); + return {s.text}; + }, + }, + { + title: '操作', + width: 90, + render: (_: unknown, row: Record) => ( + + ), + }, + ]; + + return ( + <> + { + form.resetFields(); + setCreating(true); + }} + > + {createLabel} + + } + filters={[ + { + key: 'status', + label: '状态', + options: [ + { label: '草稿', value: 'DRAFT' }, + { label: '审批中', value: 'PENDING' }, + { label: '已通过', value: 'APPROVED' }, + { label: '已驳回', value: 'REJECTED' }, + ], + }, + ]} + onRowClick={(row) => setOpenRow(row)} + endpoint={endpoint} + columns={columns} + /> + setCreating(false)} + okText="提交保存" + okLoading={createMut.isPending} + onOk={async () => { + const v = await form.validateFields(); + await createMut.mutateAsync(v); + setCreating(false); + }} + > +
+ + + + {bucket === 'tender' ? ( + + + + + + + + + + + + + + + + {costType === 'BID' ? ( + + + + + + + ) : null} + + ) : null} + {showCategory ? ( + + + + ) : null} + {hideAmount ? null : ( + + + + )} + + + + + ); +} diff --git a/apps/web/src/pages/DesktopLoginPage.tsx b/apps/web/src/pages/DesktopLoginPage.tsx new file mode 100644 index 0000000..ef2246f --- /dev/null +++ b/apps/web/src/pages/DesktopLoginPage.tsx @@ -0,0 +1,118 @@ +import { Alert, Button, Input, QRCode, Typography } from 'antd'; +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../api/client'; +import { useAuth } from '../store/auth'; +import AmbientBackground from '../components/AmbientBackground'; +import { BrandLogo } from '../components/BrandLogo'; +import { SiteFooter } from '../components/SiteFooter'; + +type QrStart = { ticket: string; expiresAt: string; payload: string }; + +export default function DesktopLoginPage() { + const navigate = useNavigate(); + const acceptSession = useAuth((s) => s.acceptSession); + const [ticket, setTicket] = useState(''); + const [payload, setPayload] = useState(''); + const [error, setError] = useState(''); + const [hint, setHint] = useState('打开手机端「消息」右上角 + → 扫一扫,扫描此二维码'); + const [mobileCode, setMobileCode] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const start = useCallback(async () => { + setError(''); + const res = await api.post('/auth/qr/start', {}); + const data = (res.data || res) as QrStart; + setTicket(data.ticket); + setPayload(data.payload || `fengyingoa://qr?ticket=${data.ticket}`); + setMobileCode(''); + setHint('打开手机端「消息」右上角 + → 扫一扫,扫描此二维码'); + }, []); + + useEffect(() => { + void start().catch((e) => setError(e instanceof Error ? e.message : '无法生成登录码')); + }, [start]); + + useEffect(() => { + if (!ticket) return; + const timer = window.setInterval(async () => { + try { + const res = await api.get(`/auth/qr/status?ticket=${encodeURIComponent(ticket)}`); + const data = (res.data || res) as { + status?: string; + accessToken?: string; + refreshToken?: string; + clientKind?: string; + }; + if (data.status === 'code_required') { + setHint('手机已确认,请输入手机屏幕显示的六位登录码'); + return; + } + if (data.status === 'expired' || data.status === 'consumed') { + window.clearInterval(timer); + setHint('登录码已过期,正在刷新…'); + await start(); + } + } catch (e) { + setError(e instanceof Error ? e.message : '查询登录状态失败'); + } + }, 1600); + return () => window.clearInterval(timer); + }, [ticket, acceptSession, navigate, start]); + + const complete = async () => { + if (!/^\d{6}$/.test(mobileCode)) { + setError('请输入手机端显示的六位登录码'); + return; + } + setSubmitting(true); + setError(''); + try { + const res = await api.post('/auth/qr/complete', { ticket, code: mobileCode }); + const data = (res.data || res) as { accessToken?: string; refreshToken?: string; clientKind?: string }; + if (!data.accessToken) throw new Error('电脑登录未完成'); + await acceptSession(data.accessToken, data.refreshToken, data.clientKind || 'desktop'); + navigate('/workbench'); + } catch (e) { + setError(e instanceof Error ? e.message : '登录码校验失败'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ +
+
+
+ +
+

电脑端登录

+

请用已登录的手机 App 扫码并输入手机生成的六位码,不可用账号密码登录电脑端

+ {error ? : null} + {payload ? ( +
+ +
+ ) : null} + {ticket || '……'} + {hint} + value.replace(/\D/g, '')} + style={{ marginBottom: 16 }} + /> +
+ + +
+
+ +
+ ); +} diff --git a/apps/web/src/pages/DirectoryPage.tsx b/apps/web/src/pages/DirectoryPage.tsx new file mode 100644 index 0000000..b9ab8b2 --- /dev/null +++ b/apps/web/src/pages/DirectoryPage.tsx @@ -0,0 +1,55 @@ +import { Button, Table } from 'antd'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../api/client'; +import { PageHeader } from './ResourcePage'; +import { useAuth } from '../store/auth'; + +type StaffRow = { + id: string; + name: string; + username?: string; + department?: string | null; + title?: string | null; + mobile?: string | null; + email?: string | null; +}; + +export default function DirectoryPage() { + const q = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') }); + const items = ((q.data as { data?: StaffRow[] })?.data || []) as StaffRow[]; + const navigate = useNavigate(); + const me = useAuth((s) => s.id); + + return ( + <> + +
`共 ${n} 人` }} + columns={[ + { title: '姓名', dataIndex: 'name', width: 120 }, + { title: '部门', dataIndex: 'department', width: 160, render: (v: string) => v || '—' }, + { title: '职务', dataIndex: 'title', width: 140, render: (v: string) => v || '—' }, + { title: '手机', dataIndex: 'mobile', width: 140, render: (v: string) => v || '—' }, + { title: '邮箱', dataIndex: 'email', width: 220, render: (v: string) => v || '—' }, + { + title: '沟通', + width: 100, + render: (_: unknown, r: StaffRow) => + r.id === me ? ( + '—' + ) : ( + + ), + }, + ]} + /> + + ); +} diff --git a/apps/web/src/pages/EmployeePage.tsx b/apps/web/src/pages/EmployeePage.tsx new file mode 100644 index 0000000..87e860a --- /dev/null +++ b/apps/web/src/pages/EmployeePage.tsx @@ -0,0 +1,1288 @@ +import { + Button, + Checkbox, + Col, + DatePicker, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Radio, + Row, + Select, + Space, + Table, + Tag, + Typography, + Upload, + message, +} from 'antd'; +import type { Key, ReactNode } from 'react'; +import { IdcardOutlined } from '@ant-design/icons'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import dayjs from 'dayjs'; +import { api } from '../api/client'; +import { useAuth } from '../store/auth'; +import { PageHeader } from './ResourcePage'; +import { downloadExcel } from './exportExcel'; +import { BizFileActions, BizFileUpload, IdScanUpload } from './FileAttachments'; +import { FormDrawer } from './FormDrawer'; +import { descendantIds, topLevelSelectOptions } from '../deptTree'; +import { canDo, canHr } from '../access'; +import { + EDUCATION_LABEL, + EMP_STATUS_META, + GENDER_LABEL, + ID_TYPE_LABEL, + LIFE_KIND_LABEL, + MARITAL_LABEL, + JOB_TYPE_LABEL, + PERSON_CATEGORY_LABEL, + REST_LABEL, + DEPT_TITLE_BY_CATEGORY, + DEPT_TITLE_BY_TYPE, + money, +} from './hrStatus'; + +type Dept = { id: string; name: string; parentId?: string | null; deptType?: string; categoryCode?: string | null }; +type AttendRule = { id: string; name: string; restSchedule: string; enabled: boolean }; +type Emp = { + id: string; + name: string; + employeeNo: string; + userId?: string | null; + idPhotoFileId?: string | null; + idFrontFileId?: string | null; + idBackFileId?: string | null; + title?: string | null; + restSchedule: string; + employmentStatus: string; + hiredAt?: string | null; + internStartAt?: string | null; + probationEndAt?: string | null; + leftAt?: string | null; + departmentId: string; + departmentName?: string; + managerId?: string | null; + managerIds?: string | string[] | null; + managerName?: string | null; + gender?: string | null; + email?: string | null; + mobile?: string | null; + idNo?: string | null; + idType?: string | null; + certFiles?: { id: string; fileName: string; size: number; mimeType: string; bizType: string }[]; + education?: string | null; + birthday?: string | null; + ethnicity?: string | null; + maritalStatus?: string | null; + hukouAddress?: string | null; + liveAddress?: string | null; + emergencyName?: string | null; + emergencyPhone?: string | null; + idIssuer?: string | null; + idValidFrom?: string | null; + idValidTo?: string | null; + personCategory?: string | null; + ccTo?: string | null; + shortMobile?: string | null; + workMobile?: string | null; + school?: string | null; + major?: string | null; + graduatedAt?: string | null; + professionalTitle?: string | null; + certificates?: string | null; + companyName?: string | null; + jobRole?: string | null; + jobLevel?: string | null; + attendGroup?: string | null; + probationMonths?: number | null; + contractStartAt?: string | null; + contractEndAt?: string | null; + recruitSource?: string | null; + landline?: string | null; + landlineExt?: string | null; + groupShortNo?: string | null; + socialNo?: string | null; + housingFundNo?: string | null; + familyNote?: string | null; + jobType?: string | null; + ccDaily?: string | null; + ccWeekly?: string | null; + ccMonthly?: string | null; + bankName?: string | null; + bankAccount?: string | null; + remark?: string | null; + baseSalary: number; + postSalary?: number; + attendanceBonus?: number; + phoneSubsidy?: number; + transportSubsidy?: number; + mealSubsidy?: number; + probationSalary?: number; + perfBase: number; + socialBase: number; + compLeaveHours: number; + accountUsername?: string | null; + accountRoles?: string[]; + createdAt?: string; +}; + +const STEPS = [ + { key: 'basic', title: '基本', fields: ['name', 'gender'] }, + { key: 'edu', title: '教育', fields: ['education'] }, + { key: 'post', title: '岗位', fields: ['departmentId', 'hiredAt'] }, + { key: 'pay', title: '财务', fields: [] as string[] }, + { key: 'other', title: '其他', fields: [] as string[] }, + { key: 'cc', title: '抄送', fields: [] as string[] }, +]; + +function unwrapItems(data: unknown): T[] { + const body = (data as { data?: unknown })?.data ?? data; + if (Array.isArray(body)) return body as T[]; + if (body && typeof body === 'object' && 'items' in body) return ((body as { items: T[] }).items ?? []) as T[]; + return []; +} + +function toDate(v: unknown) { + return v ? dayjs(v as string) : undefined; +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const LEAD_TITLE = /经理|总监|主管|负责人/; +const LEAD_ROLES = new Set(['owner', 'pm', 'admin', 'biz_director']); + +function parseCc(raw?: string | string[] | null) { + if (Array.isArray(raw)) return raw.filter((s) => UUID_RE.test(String(s))); + if (!raw) return []; + return raw.split(/[,,、\s]+/).filter((s) => UUID_RE.test(s)); +} + +function parseManagers(emp: { managerId?: string | null; managerIds?: string | string[] | null }) { + const extra = parseCc(emp.managerIds); + if (extra.length) return extra; + return parseCc(emp.managerId); +} + +function isPmLead(e: Emp) { + if (/总监/.test(`${e.title || ''}${e.jobRole || ''}`)) return false; + if (/项目经理/.test(`${e.title || ''}${e.jobRole || ''}`)) return true; + return (e.accountRoles || []).includes('pm'); +} + +function isDirectorLead(e: Emp) { + if (/总监/.test(`${e.title || ''}${e.jobRole || ''}`)) return true; + return (e.accountRoles || []).some((r) => r.endsWith('_director') || r === 'biz_director' || r === 'owner'); +} + +function isTechStaffTitle(title?: string | null, jobType?: string | null) { + const t = title || ''; + if (/经理|总监|主管|负责人/.test(t)) return false; + if (/技术岗|普通岗位/.test(t)) return true; + return jobType === 'TECH'; +} + +function isDeptLead(e: Emp) { + const text = `${e.title || ''}${e.jobRole || ''}${e.professionalTitle || ''}`; + if (LEAD_TITLE.test(text)) return true; + return (e.accountRoles || []).some((r) => LEAD_ROLES.has(r) || r.endsWith('_director')); +} + +function directorCcIds(items: Emp[], depts: Dept[], deptId?: string, selfId?: string, managerIds?: string | string[] | null) { + if (!deptId) return []; + const exclude = new Set(parseCc(managerIds)); + const byId = new Map(depts.map((d) => [d.id, d])); + const scope = new Set(); + let cur: string | undefined = deptId; + const seen = new Set(); + while (cur && !seen.has(cur)) { + seen.add(cur); + scope.add(cur); + const hasDir = items.some((e) => e.departmentId === cur && e.id !== selfId && isDirectorLead(e)); + if (hasDir) break; + cur = byId.get(cur)?.parentId || undefined; + } + return items + .filter((e) => scope.has(e.departmentId) && e.id !== selfId && !exclude.has(e.id) && isDirectorLead(e)) + .map((e) => e.id); +} + +function keepCc(raw: string | string[] | null | undefined, allowed: string[]) { + const allow = new Set(allowed); + return parseCc(raw).filter((id) => allow.has(id)); +} + +function titlesForDepartment(dept: Dept | undefined, employees: Emp[], current?: string | null) { + const fromPeople = employees.map((e) => (dept && e.departmentId === dept.id ? e.title : null)).filter(Boolean) as string[]; + const fromCat = dept?.categoryCode ? DEPT_TITLE_BY_CATEGORY[dept.categoryCode] || [] : []; + const fromType = dept?.deptType ? DEPT_TITLE_BY_TYPE[dept.deptType] || [] : []; + const seen = new Set(); + const out: { value: string; label: string }[] = []; + for (const t of [...fromPeople, ...fromCat, ...fromType, current || '']) { + const name = t.trim(); + if (!name || seen.has(name)) continue; + seen.add(name); + out.push({ value: name, label: name }); + } + return out; +} + +const EMP_EXPORT_COLS = [ + { key: 'name', title: '姓名' }, + { key: 'employeeNo', title: '工号' }, + { key: 'departmentName', title: '部门' }, + { key: 'title', title: '岗位' }, + { key: 'mobile', title: '手机' }, + { key: 'email', title: '邮箱' }, + { key: 'accountUsername', title: '登录账号' }, + { key: 'employmentStatus', title: '状态' }, + { key: 'managerName', title: '汇报' }, + { key: 'idPhoto', title: '证件照' }, +]; + +export default function EmployeePage() { + const qc = useQueryClient(); + const roles = useAuth((s) => s.roles); + const permissions = useAuth((s) => s.permissions); + const emps = useQuery({ queryKey: ['/employees'], queryFn: () => api.get('/employees') }); + const depts = useQuery({ queryKey: ['/departments'], queryFn: () => api.get('/departments') }); + const settings = useQuery({ queryKey: ['/hr-settings'], queryFn: () => api.get('/hr-settings') }); + const attendRules = useQuery({ queryKey: ['/attendance-rules'], queryFn: () => api.get('/attendance-rules') }); + const entities = useQuery({ queryKey: ['/legal-entities'], queryFn: () => api.get('/legal-entities') }); + const items = unwrapItems(emps.data); + const deptRows = unwrapItems(depts.data); + const ruleRows = unwrapItems(attendRules.data); + const entityRows = unwrapItems<{ id: string; name: string; shortName?: string; isDefault?: boolean }>(entities.data); + const deptOpts = deptRows.map((d) => ({ value: d.id, label: d.name })); + const filterDeptOpts = topLevelSelectOptions(deptRows); + const companyOpts = entityRows.map((e) => ({ value: e.name, label: e.shortName ? `${e.name}(${e.shortName})` : e.name })); + const hrCfg = ((settings.data as { data?: { defaultRest?: string; probationMonths?: number; autoAccount?: boolean } })?.data ?? + settings.data) as { defaultRest?: string; probationMonths?: number; autoAccount?: boolean } | undefined; + const [editing, setEditing] = useState(null); + const [step, setStep] = useState(0); + const [maxStep, setMaxStep] = useState(0); + const [keyword, setKeyword] = useState(''); + const [deptFilter, setDeptFilter] = useState(); + const [statusFilter, setStatusFilter] = useState(); + const [selectedKeys, setSelectedKeys] = useState([]); + const [exportOpen, setExportOpen] = useState(false); + const [exportKeys, setExportKeys] = useState(EMP_EXPORT_COLS.map((c) => c.key)); + const [exportScope, setExportScope] = useState<'selected' | 'filtered'>('filtered'); + const [form] = Form.useForm(); + const watchedAttend = Form.useWatch('attendGroup', form); + const watchedTitle = Form.useWatch('title', form); + const watchedManagerId = Form.useWatch('managerId', form); + const current = editing && editing !== 'new' ? editing : undefined; + const isNew = editing === 'new'; + const [deptId, setDeptId] = useState(); + const leadOptions = useMemo(() => { + if (!deptId) return []; + return items + .filter((e) => e.departmentId === deptId && e.id !== current?.id && isDeptLead(e)) + .map((e) => ({ + value: e.id, + label: `${e.name}${e.title ? ` · ${e.title}` : ''}${isPmLead(e) ? '(项目经理)' : isDirectorLead(e) ? '(总监)' : ''}`, + })); + }, [items, deptId, current?.id]); + const ccOptions = useMemo(() => { + const allowed = new Set(directorCcIds(items, deptRows, deptId, current?.id, watchedManagerId)); + return items + .filter((e) => allowed.has(e.id)) + .map((e) => ({ + value: e.id, + label: `${e.name}${e.title ? ` · ${e.title}` : ''}${ + e.departmentName && e.departmentId !== deptId ? ` · ${e.departmentName}` : '' + }(总监)`, + })); + }, [items, deptRows, deptId, current?.id, watchedManagerId]); + useEffect(() => { + const allowed = directorCcIds(items, deptRows, deptId, current?.id, watchedManagerId); + const patch: Partial> = {}; + for (const k of ['ccDaily', 'ccWeekly', 'ccMonthly'] as const) { + const cur = parseCc(form.getFieldValue(k)); + const next = keepCc(cur, allowed); + if (next.join(',') !== cur.join(',')) patch[k] = next; + } + if (Object.keys(patch).length) form.setFieldsValue(patch); + }, [watchedManagerId, deptId, items, deptRows, current?.id, form]); + const applyReportLine = (nextDept?: string, nextTitle?: string) => { + const did = nextDept || deptId; + if (!did) return; + const title = nextTitle !== undefined ? nextTitle : form.getFieldValue('title'); + const inDept = items.filter((e) => e.departmentId === did && e.id !== current?.id); + const pms = inDept.filter(isPmLead); + const dirIds = directorCcIds(items, deptRows, did, current?.id); + const dirs = dirIds.map((id) => items.find((e) => e.id === id)).filter(Boolean) as Emp[]; + const staff = isTechStaffTitle(title, form.getFieldValue('jobType')); + const managerIds = staff + ? pms.length + ? pms.map((p) => p.id) + : dirs.map((d) => d.id) + : dirs.length + ? dirs.map((d) => d.id) + : pms.map((p) => p.id); + const mgrSet = new Set(managerIds); + const cc = dirs.map((d) => d.id).filter((id) => id && !mgrSet.has(id)); + form.setFieldsValue({ managerId: managerIds, ccDaily: cc, ccWeekly: cc, ccMonthly: cc }); + }; + const createMut = useMutation({ + mutationFn: (body: Record) => api.post('/employees', body), + onError: (e: Error) => message.error(e.message), + }); + const patchMut = useMutation({ + mutationFn: ({ id, body }: { id: string; body: Record }) => api.patch(`/employees/${id}`, body), + onError: (e: Error) => message.error(e.message), + }); + + const filtered = useMemo(() => { + const q = keyword.trim().toLowerCase(); + const deptSet = descendantIds(deptRows, deptFilter); + return items.filter((e) => { + if (deptSet && !deptSet.has(e.departmentId)) return false; + if (statusFilter && e.employmentStatus !== statusFilter) return false; + if (!statusFilter && e.employmentStatus === 'LEFT') return false; + if (!q) return true; + return [e.name, e.employeeNo, e.mobile, e.departmentName, e.title, e.accountUsername].some((v) => + String(v || '') + .toLowerCase() + .includes(q), + ); + }); + }, [items, keyword, deptFilter, statusFilter, deptRows]); + const deleteMut = useMutation({ + mutationFn: (id: string) => api.delete(`/employees/${id}`), + onSuccess: async () => { + message.success('已删除员工档案'); + setEditing(null); + await qc.invalidateQueries({ queryKey: ['/employees'] }); + }, + onError: (e: Error) => message.error(e.message), + }); + const doEmpExport = () => { + const picked = EMP_EXPORT_COLS.filter((c) => exportKeys.includes(c.key)); + if (!picked.length) return; + const set = new Set(selectedKeys.map(String)); + const rows = (exportScope === 'selected' ? filtered.filter((e) => set.has(e.id)) : filtered).map((e) => ({ + ...e, + employmentStatus: EMP_STATUS_META[e.employmentStatus]?.text || e.employmentStatus, + idPhoto: e.idPhotoFileId ? '已上传' : '未上传', + })); + if (!rows.length) { + message.warning('没有可导出的行'); + return; + } + downloadExcel('在职员工', picked, rows as unknown as Record[]); + setExportOpen(false); + }; + + const open = async (row?: Emp) => { + setStep(0); + setMaxStep(row ? STEPS.length - 1 : 0); + if (row) { + const detail = ((await api.get(`/employees/${row.id}`)) as { data?: Emp }).data || row; + const mgrs = parseManagers(detail); + form.setFieldsValue({ + ...detail, + hiredAt: toDate(detail.hiredAt), + internStartAt: toDate(detail.internStartAt), + probationEndAt: toDate(detail.probationEndAt), + birthday: toDate(detail.birthday), + idValidFrom: toDate(detail.idValidFrom), + idValidTo: toDate(detail.idValidTo), + graduatedAt: toDate(detail.graduatedAt), + contractStartAt: toDate(detail.contractStartAt), + contractEndAt: toDate(detail.contractEndAt), + managerId: mgrs, + ccDaily: keepCc(detail.ccDaily, directorCcIds(items, deptRows, detail.departmentId, detail.id, mgrs)), + ccWeekly: keepCc(detail.ccWeekly, directorCcIds(items, deptRows, detail.departmentId, detail.id, mgrs)), + ccMonthly: keepCc(detail.ccMonthly, directorCcIds(items, deptRows, detail.departmentId, detail.id, mgrs)), + }); + setDeptId(detail.departmentId); + setEditing(detail); + } else { + form.resetFields(); + setDeptId(undefined); + form.setFieldsValue({ + restSchedule: hrCfg?.defaultRest || 'SINGLE', + employmentStatus: 'PROBATION', + hiredAt: dayjs(), + attendGroup: + ruleRows.find((r) => r.enabled && r.restSchedule === (hrCfg?.defaultRest || 'SINGLE'))?.name || + ruleRows.find((r) => r.enabled)?.name, + companyName: entityRows.find((e) => e.isDefault)?.name || entityRows[0]?.name, + idType: 'ID_CARD', + probationMonths: hrCfg?.probationMonths ?? 3, + }); + setEditing('new'); + } + }; + + const save = async () => { + const v = await form.validateFields(); + const body = { + ...v, + hiredAt: v.hiredAt ? dayjs(v.hiredAt).format('YYYY-MM-DD') : undefined, + internStartAt: v.internStartAt ? dayjs(v.internStartAt).format('YYYY-MM-DD') : undefined, + probationEndAt: v.probationEndAt ? dayjs(v.probationEndAt).format('YYYY-MM-DD') : undefined, + birthday: v.birthday ? dayjs(v.birthday).format('YYYY-MM-DD') : undefined, + idValidFrom: v.idValidFrom ? dayjs(v.idValidFrom).format('YYYY-MM-DD') : undefined, + idValidTo: v.idValidTo ? dayjs(v.idValidTo).format('YYYY-MM-DD') : undefined, + graduatedAt: v.graduatedAt ? dayjs(v.graduatedAt).format('YYYY-MM-DD') : undefined, + contractStartAt: v.contractStartAt ? dayjs(v.contractStartAt).format('YYYY-MM-DD') : undefined, + contractEndAt: v.contractEndAt ? dayjs(v.contractEndAt).format('YYYY-MM-DD') : undefined, + managerId: parseCc(v.managerId), + ccDaily: keepCc(v.ccDaily, directorCcIds(items, deptRows, v.departmentId || deptId, current?.id, v.managerId)).join(',') || null, + ccWeekly: keepCc(v.ccWeekly, directorCcIds(items, deptRows, v.departmentId || deptId, current?.id, v.managerId)).join(',') || null, + ccMonthly: keepCc(v.ccMonthly, directorCcIds(items, deptRows, v.departmentId || deptId, current?.id, v.managerId)).join(',') || null, + }; + delete body.employeeNo; + if (editing && editing !== 'new') { + await patchMut.mutateAsync({ id: editing.id, body }); + message.success('员工档案已保存'); + } else { + await createMut.mutateAsync(body); + message.success('已入职。状态变更请走入转调离;登录账号请到人事设置 · 员工账号开通。'); + } + setEditing(null); + await qc.invalidateQueries({ queryKey: ['/employees'] }); + }; + + const goNext = async () => { + const check = isNew ? STEPS[step].fields : STEPS[step].fields.filter((f) => f !== 'hiredAt'); + if (check.length) await form.validateFields(check); + const next = Math.min(step + 1, STEPS.length - 1); + setStep(next); + setMaxStep((m) => Math.max(m, next)); + }; + + const goStep = (i: number) => { + if (i <= maxStep) { + setStep(i); + return; + } + if (i === step + 1) void goNext(); + }; + + return ( + <> + void open()}>新建员工 : null} + /> +
+ { + if (!e.target.value) setKeyword(''); + }} + /> + ({ label: m.text, value }))} + /> + +
+ setExportOpen(false)} + onOk={doEmpExport} + okText="开始导出" + > +

勾选要导出的列,再选范围。选一级部门时,下级部门的人也在筛选结果里。

+ setExportScope(e.target.value)} + style={{ marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 6 }} + > + + 仅导出勾选的 {selectedKeys.length} 人 + + 导出当前筛选结果({filtered.length} 人) + + 0 && exportKeys.length < EMP_EXPORT_COLS.length} + checked={exportKeys.length === EMP_EXPORT_COLS.length} + onChange={(e) => setExportKeys(e.target.checked ? EMP_EXPORT_COLS.map((c) => c.key) : [])} + > + 全选列 + + setExportKeys(v as string[])} + options={EMP_EXPORT_COLS.map((c) => ({ label: c.title, value: c.key }))} + /> +
+
setSelectedKeys(keys) }} + loading={emps.isLoading} + dataSource={filtered} + scroll={{ x: 'max-content' }} + tableLayout="fixed" + columns={[ + { title: '姓名', dataIndex: 'name', width: 90 }, + { title: '工号', dataIndex: 'employeeNo', width: 140 }, + { title: '部门', dataIndex: 'departmentName', width: 130 }, + { title: '岗位', dataIndex: 'title', width: 130, render: (v: string) => v || '—' }, + { title: '手机', dataIndex: 'mobile', width: 130, render: (v: string) => v || '—' }, + { title: '邮箱', dataIndex: 'email', width: 200, ellipsis: true, render: (v: string) => v || '—' }, + { + title: '入职', + dataIndex: 'hiredAt', + width: 110, + render: (v: string) => (v ? String(v).slice(0, 10) : '—'), + }, + { title: '作息', dataIndex: 'restSchedule', width: 80, render: (v: string) => REST_LABEL[v] || v }, + { + title: '状态', + dataIndex: 'employmentStatus', + width: 90, + render: (v: string) => { + const s = EMP_STATUS_META[v] || { color: 'default', text: v }; + return {s.text}; + }, + }, + { title: '登录账号', dataIndex: 'accountUsername', width: 110, render: (v: string) => v || '未开通' }, + { + title: '证件照', + width: 80, + render: (_: unknown, row: Emp) => (row.idPhotoFileId ? 已传 : 未传), + }, + { title: '汇报', dataIndex: 'managerName', width: 140, render: (v: string) => v || '—' }, + { + title: '创建时间', + dataIndex: 'createdAt', + width: 170, + render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '—'), + }, + canHr(roles) + ? { + title: '操作', + width: 140, + render: (_: unknown, row: Emp) => ( + + + deleteMut.mutate(row.id)} + > + + + + ), + } + : { title: '', width: 1 }, + ]} + pagination={{ + defaultPageSize: 10, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50], + showTotal: (n) => `共 ${n} 条`, + }} + locale={{ emptyText: '暂无员工。点右上角新建。' }} + onRow={(row) => ({ + onClick: () => { + if (canHr(roles)) void open(row); + }, + style: { cursor: canHr(roles) ? 'pointer' : undefined }, + })} + /> + setEditing(null)} + steps={STEPS} + step={step} + maxStep={maxStep} + onStep={goStep} + onPrev={() => setStep((s) => s - 1)} + onNext={() => void goNext()} + onOk={canHr(roles) ? () => void save() : undefined} + hideOk={!canHr(roles)} + showOkAlways={!isNew} + okText={isNew ? '提交保存' : '保存'} + okLoading={createMut.isPending || patchMut.isPending} + createdAt={isNew ? undefined : current?.createdAt} + > + { + if ('departmentId' in changed) { + setDeptId(changed.departmentId); + applyReportLine(changed.departmentId, undefined); + } + if ('title' in changed) applyReportLine(deptId, changed.title); + }} + > + {step === 0 ? ( + <> + {current ? ( +
+ + { + const fd = new FormData(); + fd.append('file', opt.file as File); + if (current.userId) { + fd.append('bizType', 'USER_ID_PHOTO'); + fd.append('bizId', current.userId); + } else { + fd.append('bizType', 'EMPLOYEE_ID_PHOTO'); + fd.append('bizId', current.id); + } + try { + await api.post('/files', fd); + message.success('证件照已存入员工档案'); + await qc.invalidateQueries({ queryKey: ['/employees'] }); + const detail = ((await api.get(`/employees/${current.id}`)) as { data?: Emp }).data; + if (detail) setEditing(detail); + opt.onSuccess?.({}, new XMLHttpRequest()); + } catch (e) { + message.error((e as Error).message); + opt.onError?.(e as Error); + } + }} + > + + + {current.idPhotoFileId ? ( + + ) : ( + 档案里还没有证件照 + )} + +
+ ) : null} +
+ +
+ + + + + + + + + + + + e.name === current.companyName) + ? [...companyOpts, { value: current.companyName, label: current.companyName }] + : companyOpts + } + placeholder={entityRows.length ? '选择参与主体' : '请先在往来单位维护参与主体'} + /> + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + {current ? ( + <> + + + { + await qc.invalidateQueries({ queryKey: ['/employees'] }); + const detail = ((await api.get(`/employees/${current.id}`)) as { data?: Emp }).data; + if (detail) setEditing(detail); + }} + /> + + + + + { + await qc.invalidateQueries({ queryKey: ['/employees'] }); + const detail = ((await api.get(`/employees/${current.id}`)) as { data?: Emp }).data; + if (detail) setEditing(detail); + }} + /> + + + + ) : ( + + 先保存档案,再上传身份证正反面。 + + )} + + + + ) : null} + {step === 1 ? ( +
+ +
+ + + + + + + + + + + + + + + + + + + + + + {current ? ( + { + const detail = ((await api.get(`/employees/${current.id}`)) as { data?: Emp }).data; + if (detail) setEditing(detail); + }} + /> + ) : ( + 先保存档案,再上传个人证书。 + )} + + + + + ) : null} + {step === 2 ? ( + + + + d.id === deptId), + items, + watchedTitle, + )} + /> + + + + + + + + + + 班次在 + 考勤设置 + 维护。 + + } + > + + + + {isNew ? ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : null} + {step === 3 ? ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {current ? ( + + 调休结余 {current.compLeaveHours} 小时 · 基本工资 {money(current.baseSalary)} + + ) : null} + + ) : null} + {step === 4 ? ( + <> + + + + + + + + + ({ value, label }))} /> + + + + + + + {current ? ( + + 登录账号:{current.accountUsername || '未开通'}。开户、重置密码请到{' '} + 人事设置 · 员工账号 + {hrCfg?.autoAccount ? '(入职自动开户已停用,请手工开通)。' : '。'} + + ) : ( + + 保存档案后,到人事设置 · 员工账号开通登录。试用期默认 {hrCfg?.probationMonths ?? 3} 个月。 + + )} + {current ? : null} + + ) : null} + {step === 5 ? ( + + + + + + + + +
LIFE_KIND_LABEL[v] || v }, + { title: '生效日', dataIndex: 'effectiveAt', width: 120, render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—') }, + { title: '说明', dataIndex: 'comment', render: (v: string) => v || '—' }, + ]} + /> + + ); +} diff --git a/apps/web/src/pages/ExpenseDetailPage.tsx b/apps/web/src/pages/ExpenseDetailPage.tsx new file mode 100644 index 0000000..66ec03a --- /dev/null +++ b/apps/web/src/pages/ExpenseDetailPage.tsx @@ -0,0 +1,482 @@ +import { useEffect, useRef, useState } from 'react'; +import { Alert, Button, Descriptions, Form, Select, Space, Spin, Table, Tag, message } from 'antd'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import dayjs from 'dayjs'; +import { api } from '../api/client'; +import { FormDrawer } from './FormDrawer'; +import { CostObjectFields } from './CostObjectFields'; +import { ExpenseInvoiceFields, type LineFile } from './ExpenseInvoiceFields'; +import { BizFileActions, FileAttachments } from './FileAttachments'; +import { belongLabel, CostTargetLink } from './FinanceCells'; +import { + COST_TYPE_LABEL, + EXPENSE_LINE_KIND_LABEL, + EXPENSE_STATUS_META, + BUDGET_CATEGORY_LABEL, + money, +} from './financeStatus'; + +type ExpenseLine = { + id?: string; + kind: string; + amount: string | number; + invoiceNo?: string | null; + occurAt?: string | null; + remark?: string | null; + files?: LineFile[]; +}; + +type Expense = { + id: string; + claimNo: string; + costType: string; + amount: string | number; + status: string; + remark?: string | null; + rejectComment?: string | null; + budgetCategory?: string | null; + bidCaseId?: string | null; + projectId?: string | null; + createdAt?: string; + loanId?: string | null; + expenseCategory?: string | null; + purpose?: string | null; + prepaid?: boolean; + payerId?: string | null; + payer?: { displayName: string } | null; + lines?: ExpenseLine[]; + bidCase?: { id: string; bidNo: string; name: string } | null; + project?: { id: string; projectNo: string; name: string } | null; + loan?: { id: string; loanNo: string; status: string } | null; + applicant?: { displayName: string; employee?: { department?: { name?: string } } }; + files?: { id: string; fileName: string }[]; + payout?: { kind: string; name?: string | null; bankName?: string | null; bankAccount?: string | null }; + payment?: { id: string; payNo: string; status: string } | null; + myActions?: { + canEdit: boolean; + canSubmit: boolean; + canDelete?: boolean; + canReview: boolean; + canAttachFiles?: boolean; + canLinkLoan?: boolean; + }; + approvalChain?: { role: string; name: string; status: string }[]; +}; + +function packExpense(v: Record) { + const lines = ((v.lines as ExpenseLine[]) || []).map((l) => ({ + id: l.id, + kind: l.kind, + amount: Number(l.amount) > 0 ? Number(l.amount) : 0, + invoiceNo: l.invoiceNo || undefined, + occurAt: l.occurAt ? dayjs(l.occurAt).format('YYYY-MM-DD') : undefined, + remark: l.remark || undefined, + })); + const kind = v.budgetCategory === 'PURCHASE' ? 'PURCHASE' : 'TRAVEL'; + const envelope = v.budgetEnvelope === 'TECH' || v.budgetCategory === 'TECH' ? 'TECH' : 'BUSINESS'; + return { + ...v, + prepaid: Boolean(v.prepaid), + payerId: v.prepaid ? v.payerId : null, + lines, + amount: lines.reduce((s, l) => s + Number(l.amount || 0), 0), + budgetCategory: v.costType === 'PROJECT' ? (kind === 'PURCHASE' ? 'PURCHASE' : envelope === 'TECH' ? 'TECH' : 'TRAVEL') : undefined, + }; +} + +export default function ExpenseDetailPage() { + const { id } = useParams(); + const [params] = useSearchParams(); + const location = useLocation(); + const onDesk = location.pathname.startsWith('/finance'); + const listPath = onDesk ? '/finance/expenses' : '/office/expenses'; + const isNew = id === 'new' || !id; + const navigate = useNavigate(); + const qc = useQueryClient(); + const [form] = Form.useForm(); + const bootstrapped = useRef(false); + const [createTick, setCreateTick] = useState(0); + const [creating, setCreating] = useState(isNew); + const [createError, setCreateError] = useState(null); + const [linkLoanId, setLinkLoanId] = useState(); + const prepaid = Form.useWatch('prepaid', form); + + const q = useQuery({ + queryKey: ['expense', id], + enabled: !isNew && Boolean(id), + queryFn: () => api.get(`/expenses/${id}`), + }); + const loans = useQuery({ + queryKey: ['loans', 'mine-for-offset'], + queryFn: () => api.get('/loans?bucket=mine&pageSize=100'), + }); + const row = q.data?.data as Expense | undefined; + + useEffect(() => { + if (!isNew || bootstrapped.current) return; + bootstrapped.current = true; + setCreating(true); + setCreateError(null); + (async () => { + try { + const res = (await api.post('/expenses', { + costType: params.get('costType') || 'BID', + bidCaseId: params.get('bidCaseId') || undefined, + projectId: params.get('projectId') || undefined, + loanId: params.get('loanId') || undefined, + prepaid: false, + lines: [{ kind: 'AIR', amount: 0 }], + })) as { data?: { id?: string } }; + if (res.data?.id) navigate(`${listPath}/${res.data.id}`, { replace: true }); + } catch (e) { + bootstrapped.current = false; + setCreateError((e as Error).message || '新建报销单失败'); + } finally { + setCreating(false); + } + })(); + }, [isNew, listPath, navigate, params, createTick]); + + const lineIds = (row?.lines || []).map((l) => l.id).join(','); + useEffect(() => { + if (isNew || !row) return; + const currentIds = ((form.getFieldValue('lines') as { id?: string }[]) || []).map((l) => l.id).join(','); + if (currentIds && currentIds === lineIds) return; + form.setFieldsValue({ + costType: row.costType, + bidCaseId: row.bidCaseId || undefined, + projectId: row.projectId || undefined, + loanId: row.loanId || undefined, + amount: Number(row.amount) || undefined, + remark: row.remark || undefined, + budgetCategory: row.budgetCategory === 'TECH' ? 'TRAVEL' : row.budgetCategory || undefined, + budgetEnvelope: row.budgetCategory === 'TECH' || row.budgetCategory === 'PURCHASE' ? 'TECH' : 'BUSINESS', + prepaid: Boolean(row.prepaid), + payerId: row.payerId || undefined, + lines: row.lines?.length + ? row.lines.map((l) => ({ + id: l.id, + kind: l.kind, + amount: Number(l.amount) || undefined, + invoiceNo: l.invoiceNo || undefined, + occurAt: l.occurAt ? dayjs(l.occurAt) : undefined, + remark: l.remark || undefined, + })) + : [{ kind: 'AIR' }], + }); + }, [isNew, row, form, lineIds]); + + const invalidate = () => qc.invalidateQueries(); + const persist = async (lines?: Record[]) => { + const body = packExpense({ ...form.getFieldsValue(), ...(lines ? { lines } : {}) }); + const res = (isNew + ? await api.post('/expenses', body) + : await api.patch(`/expenses/${id}`, body)) as { data?: Expense }; + if (res.data) qc.setQueryData(['expense', res.data.id], { data: res.data }); + if (isNew && res.data?.id) navigate(`${listPath}/${res.data.id}`, { replace: true }); + return res.data; + }; + const saveMut = useMutation({ + mutationFn: async () => persist(), + onSuccess: () => message.success('已保存草稿'), + onError: (e: Error) => message.error(e.message), + }); + const submitMut = useMutation({ + mutationFn: async (savedId?: string) => { + const target = savedId || id; + if (!target || target === 'new') throw new Error('请先保存'); + return api.post(`/expenses/${target}/submit`); + }, + onSuccess: async () => { + message.success('已提交审批'); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const reviewMut = useMutation({ + mutationFn: (body: { result: 'APPROVED' | 'REJECTED'; comment?: string }) => + api.post(`/expenses/${id}/reviews`, body), + onSuccess: async () => { + message.success('已办理'); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + const linkLoanMut = useMutation({ + mutationFn: (loanId: string) => api.post(`/expenses/${id}/link-loan`, { loanId }), + onSuccess: async () => { + message.success('已关联冲账借款,并同步报销归属'); + setLinkLoanId(undefined); + await invalidate(); + }, + onError: (e: Error) => message.error(e.message), + }); + + const allLoans = (loans.data?.data?.items ?? []) as { + id: string; + loanNo: string; + remaining?: number; + costType: string; + status: string; + bidCaseId?: string | null; + projectId?: string | null; + bidCase?: { bidNo?: string | null; name?: string | null } | null; + project?: { projectNo?: string | null; name?: string | null } | null; + }[]; + const offsetLoans = allLoans.filter((l) => Number(l.remaining || 0) > 0 && ['OPEN', 'PENDING'].includes(l.status)); + const openLoanCount = offsetLoans.filter((l) => l.status === 'OPEN').length; + const loanTarget = (l: (typeof offsetLoans)[number]) => { + if (l.costType === 'BID') return l.bidCase?.name || l.bidCase?.bidNo || '投标费用'; + if (l.costType === 'PROJECT') return l.project?.name || l.project?.projectNo || '项目费用'; + return l.costType === 'ADMIN' ? '行政费用' : '部门费用'; + }; + + const meta = EXPENSE_STATUS_META[row?.status || 'DRAFT'] || EXPENSE_STATUS_META.DRAFT; + const editable = isNew || row?.myActions?.canEdit; + + return ( + navigate(listPath)} + hideOk + createdAt={isNew ? undefined : row?.createdAt} + > + {row ? ( + + {meta.text} + {COST_TYPE_LABEL[row.costType] || row.costType} + {row.bidCase ? {row.bidCase.bidNo} : null} + {row.project ? {row.project.projectNo} : null} + {row.budgetCategory ? {BUDGET_CATEGORY_LABEL[row.budgetCategory] || row.budgetCategory} : null} + + ) : null} + {row?.status === 'REJECTED' ? ( + + ) : null} + {row?.approvalChain?.length ? ( +

+ 审批: + {row.approvalChain + .map((s) => { + const st = s.status === 'APPROVED' ? '已过' : s.status === 'PENDING' ? '待审' : s.status === 'REJECTED' ? '驳回' : '等待'; + return `${s.role} ${s.name}(${st})`; + }) + .join(' → ')} +

+ ) : null} + {row?.myActions?.canReview ? ( + + + + + } + /> + ) : null} + + {!editable && row ? ( + + {money(row.amount)} + {row.applicant?.displayName || '—'} + {row.prepaid ? row.payer?.displayName || '已代付' : '自付'} + + {row.payout?.kind === 'OFFSET' + ? '冲借款,不打款' + : row.payout?.name + ? `${row.payout.kind === 'PAYER' ? '代付人' : '申请人'} ${row.payout.name}` + : '—'} + + + {row.payout?.kind === 'OFFSET' + ? '—' + : [row.payout?.bankName, row.payout?.bankAccount].filter(Boolean).join(' ') || '档案缺银行卡'} + + {row.payment?.payNo || (row.status === 'APPROVED' ? '待生成' : '通过后进入付款执行')} + {belongLabel(row)} + + + + {row.prepaid ? '代付不冲账' : row.loan?.loanNo || '无'} + + {row.remark || '—'} + + + ) : null} + {!editable && row?.myActions?.canLinkLoan ? ( + +
String(i)} + dataSource={row.lines} + columns={[ + { title: '票种', dataIndex: 'kind', width: 90, render: (v: string) => EXPENSE_LINE_KIND_LABEL[v] || v }, + { title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) }, + { title: '发票号', dataIndex: 'invoiceNo', render: (v: string) => v || '—' }, + { title: '日期', dataIndex: 'occurAt', width: 120, render: (v: string) => (v ? String(v).slice(0, 10) : '—') }, + { title: '说明', dataIndex: 'remark', render: (v: string) => v || '—' }, + { + title: '附件', + render: (_: unknown, r: ExpenseLine) => + r.files?.length + ? r.files.map((f) => ( + + )) + : '—', + }, + ]} + /> + ) : null} + {!editable && row ? ( + + ) : null} + {isNew && creating ? ( +
+ +

正在打开报销单,请稍候…

+
+ ) : null} + {isNew && createError ? ( + { + bootstrapped.current = false; + setCreateTick((n) => n + 1); + }} + > + 重试 + + } + /> + ) : null} + {!editable && row ? null : isNew ? null : ( + saveMut.mutate()}> + + l.id).map((l) => [l.id as string, l.files || []]))} + onPersist={persist} + /> + {prepaid ? null : ( + + + + {payer ? ( + payer.bankReady ? ( + + ) : ( + + ) + ) : null} + + ) : null} + + { + if (!value?.length) throw new Error('至少填一张发票'); + }, + }, + ]} + > + {(fields, { add, remove }, { errors }) => ( +
+
发票明细
+ {fields.map((field, index) => { + const lineId = lines?.[index]?.id; + return ( + { + remove(field.name); + const next = ((form.getFieldValue('lines') as Record[]) || []).filter((_, i) => i !== index); + await persistLines(next); + }} + > + 删除 + + ) + } + style={{ marginBottom: 12 }} + > + + +
+ + + + + + + + + + + + + + + + { + const current = (form.getFieldValue('lines') as Record[]) || []; + const saved = await persistLines(current); + return saved?.lines?.[index]?.id; + }} + /> + + + + ); + })} + {disabled ? null : ( + + + 合计 {money(sum)} + + )} + + + )} + + + ); +} diff --git a/apps/web/src/pages/ExpenseListPage.tsx b/apps/web/src/pages/ExpenseListPage.tsx new file mode 100644 index 0000000..52c4bae --- /dev/null +++ b/apps/web/src/pages/ExpenseListPage.tsx @@ -0,0 +1,211 @@ +import { Tag } from 'antd'; +import { Link, Outlet, useNavigate, useSearchParams } from 'react-router-dom'; +import { ResourcePage } from './ResourcePage'; +import { belongLabel, CostTargetLink } from './FinanceCells'; +import { applicantDept, applicantName, EXPENSE_STATUS_META, expenseCategoryLabel, money } from './financeStatus'; +import { ApplyBucketBar } from './ApplyBucketBar'; + +export default function ExpenseListPage() { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const bucket = params.get('bucket') || ''; + const endpoint = bucket ? `/expenses?bucket=${bucket}` : '/expenses'; + return ( + <> + navigate(`/finance/expenses/${row.id}`)} + title="费用报销" + description="只看已提交的报销。草稿只在填写人「我的费用」里。没确认通过的不会进付款和报表。" + endpoint={endpoint} + searchPlaceholder="搜索单号、申请人、用途" + exportName="费用报销" + filters={[ + { + key: 'status', + label: '状态', + options: Object.entries(EXPENSE_STATUS_META).map(([value, m]) => ({ label: m.text, value })), + }, + ]} + extra={ + + } + columns={[ + { + title: '单号', + dataIndex: 'claimNo', + width: 160, + render: (v: string, row: Record) => {v}, + }, + { + title: '申请人', + width: 90, + render: (_: unknown, row: Record) => applicantName(row), + }, + { + title: '部门', + width: 110, + render: (_: unknown, row: Record) => applicantDept(row), + }, + { title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) }, + { + title: '代付', + width: 90, + render: (_: unknown, row: Record) => + row.prepaid ? (row.payer as { displayName?: string } | null)?.displayName || '代付' : '自付', + }, + { title: '类别', width: 80, render: (_: unknown, row: Record) => expenseCategoryLabel(row.expenseCategory as string) }, + { title: '用途', ellipsis: true, render: (_: unknown, row: Record) => String(row.purpose || '—') }, + { + title: '归属', + ellipsis: true, + render: (_: unknown, row: Record) => + belongLabel({ + costType: row.costType as string, + project: row.project as { name?: string } | null, + bidCase: row.bidCase as { name?: string } | null, + applicant: row.applicant as { employee?: { department?: { name?: string } } } | null, + }), + }, + { + title: '对象', + width: 150, + render: (_: unknown, row: Record) => ( + + ), + }, + { + title: '日期', + width: 110, + render: (_: unknown, row: Record) => + String(row.submittedAt || row.createdAt || '').slice(0, 10) || '—', + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (v: string) => { + const s = EXPENSE_STATUS_META[v] || { color: 'default', text: v }; + return {s.text}; + }, + }, + { + title: '操作', + width: 80, + render: (_: unknown, row: Record) => 办理, + }, + ]} + /> + + + ); +} + +export function LoanListPage() { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const bucket = params.get('bucket') || ''; + const endpoint = bucket ? `/loans?bucket=${bucket}` : '/loans'; + return ( + <> + navigate(`/finance/loans/${row.id}`)} + title="借支记录" + description="全公司借支台账。员工在「我的费用」申请,财务在「待我审批」确认。未还借款会拦住同一成本对象上的新报销。" + endpoint={endpoint} + searchPlaceholder="搜索借款单号、用途、申请人" + exportName="借支记录" + extra={ + + } + columns={[ + { + title: '单号', + dataIndex: 'loanNo', + width: 160, + render: (v: string, row: Record) => {v}, + }, + { + title: '申请人', + width: 90, + render: (_: unknown, row: Record) => applicantName(row), + }, + { + title: '部门', + width: 110, + render: (_: unknown, row: Record) => applicantDept(row), + }, + { title: '用途', dataIndex: 'purpose', ellipsis: true }, + { title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) }, + { title: '未还', dataIndex: 'remaining', width: 110, render: (v: string) => money(v) }, + { + title: '归属', + ellipsis: true, + render: (_: unknown, row: Record) => + belongLabel({ + costType: row.costType as string, + project: row.project as { name?: string } | null, + bidCase: row.bidCase as { name?: string } | null, + applicant: row.applicant as { employee?: { department?: { name?: string } } } | null, + }), + }, + { + title: '对象', + width: 150, + render: (_: unknown, row: Record) => ( + + ), + }, + { + title: '日期', + width: 110, + render: (_: unknown, row: Record) => + String(row.submittedAt || row.createdAt || '').slice(0, 10) || '—', + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (v: string) => { + const map: Record = { + DRAFT: { color: 'default', text: '草稿' }, + PENDING: { color: 'processing', text: '待确认' }, + OPEN: { color: 'warning', text: '未还' }, + OFFSET: { color: 'success', text: '已冲账' }, + REJECTED: { color: 'error', text: '被打回' }, + }; + const s = map[v] || { color: 'default', text: v }; + return {s.text}; + }, + }, + { + title: '操作', + width: 90, + render: (_: unknown, row: Record) => 办理, + }, + ]} + /> + + + ); +} diff --git a/apps/web/src/pages/FileAttachments.tsx b/apps/web/src/pages/FileAttachments.tsx new file mode 100644 index 0000000..1aaf210 --- /dev/null +++ b/apps/web/src/pages/FileAttachments.tsx @@ -0,0 +1,463 @@ +import { Suspense, lazy, useState } from 'react'; +import { Button, Modal, Space, Spin, Upload, message } from 'antd'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { api } from '../api/client'; + +const PdfPreview = lazy(() => import('./PdfPreview').then((m) => ({ default: m.PdfPreview }))); + +type FileRow = { id: string; fileName: string; size: number; mimeType: string; bizType?: string }; + +export const FILE_KIND_LABEL: Record = { + BID_ANNOUNCE: '招标 / 询价文件', + BID_FILE: '投标附件', + BID_FORMAL: '正式标书', + BID_PROOF: '递交 / 保证金证明', + CONTRACT: '合同扫描件', + CONTRACT_ACCEPT: '验收单', + CONTRACT_AWARD: '中标通知书', + PROJECT: '项目附件', + PROJECT_ACCEPT: '验收材料', + PURCHASE: '采购合同', + PURCHASE_ACCEPT: '采购验收', + EXPENSE: '发票 / 凭证', + LOAN: '借据 / 凭证', + USER_AVATAR: '头像', + USER_ID_PHOTO: '证件照', + EMPLOYEE_ID_PHOTO: '证件照', + EMPLOYEE_ID_FRONT: '身份证正面', + EMPLOYEE_ID_BACK: '身份证反面', + EMPLOYEE_CERT: '个人证书', + EMPLOYEE_LABOR: '劳动合同', + NOTICE: '通知附件', +}; + +/** 纠正 multer latin1 解出来的中文文件名。 */ +export function displayFileName(name: string) { + if (!name) return '附件'; + if (/[\u4e00-\u9fff]/.test(name)) return name; + try { + const bytes = Uint8Array.from(name, (c) => c.charCodeAt(0) & 0xff); + const decoded = new TextDecoder('utf-8').decode(bytes); + if (/[\u4e00-\u9fff]/.test(decoded) && !decoded.includes('\uFFFD')) return decoded; + } catch { + /* keep original */ + } + return name; +} + +function sniffMime(bytes: Uint8Array, name?: string, declared?: string) { + const declaredType = (declared || '').toLowerCase(); + if (bytes.length >= 4 && bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) { + return 'application/pdf'; + } + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'; + if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) { + return 'image/png'; + } + if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif'; + if (declaredType.startsWith('image/') || declaredType === 'application/pdf') return declaredType; + if (/\.pdf$/i.test(name || '')) return 'application/pdf'; + if (/\.(png|jpe?g|gif|webp|bmp)$/i.test(name || '')) { + const ext = (name || '').split('.').pop()?.toLowerCase(); + if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg'; + if (ext === 'png') return 'image/png'; + if (ext === 'gif') return 'image/gif'; + if (ext === 'webp') return 'image/webp'; + if (ext === 'bmp') return 'image/bmp'; + } + return declaredType; +} + +function isPreviewable(name: string, mime?: string) { + const type = (mime || '').toLowerCase(); + if (type.startsWith('image/') || type === 'application/pdf') return true; + return /\.(png|jpe?g|gif|webp|bmp|pdf)$/i.test(name); +} + +async function fetchBizFileBlob(id: string, mime?: string, name?: string) { + const raw = (await api.get(`/files/${id}`, { responseType: 'blob' })) as unknown; + let blob: Blob | null = null; + if (raw instanceof Blob) blob = raw; + else if (raw && typeof raw === 'object' && (raw as { data?: unknown }).data instanceof Blob) { + blob = (raw as { data: Blob }).data; + } + if (!blob) throw new Error('无法读取附件'); + const head = new Uint8Array(await blob.slice(0, 24).arrayBuffer()); + const headText = new TextDecoder().decode(head).trimStart(); + if (headText.startsWith('{') || headText.startsWith('<')) { + const full = await blob.text(); + try { + const parsed = JSON.parse(full) as { message?: string }; + throw new Error(parsed.message || '无法打开附件'); + } catch (e) { + if (e instanceof Error && e.message !== '无法打开附件' && !e.message.includes('JSON')) throw e; + throw new Error('无法打开附件'); + } + } + const type = sniffMime(head, name, mime || blob.type); + if (type && blob.type !== type) return new Blob([await blob.arrayBuffer()], { type }); + return blob; +} + +export async function downloadBizFile(id: string, name: string) { + const blob = await fetchBizFileBlob(id, undefined, name); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = displayFileName(name); + a.click(); + URL.revokeObjectURL(url); +} + +export function nonImageFiles(files?: FileRow[]) { + return (files || []).filter((f) => !(f.mimeType || '').startsWith('image/')); +} + +export async function uploadBizFile(bizType: string, bizId: string, file: File) { + const fd = new FormData(); + fd.append('file', file); + fd.append('bizType', bizType); + fd.append('bizId', bizId); + const res = (await api.post('/files', fd)) as { data: FileRow }; + return res.data; +} + +export function IdScanUpload({ + label, + bizType, + bizId, + fileId, + onDone, +}: { + label: string; + bizType: string; + bizId: string; + fileId?: string | null; + onDone?: () => void | Promise; +}) { + return ( + + { + try { + const fd = new FormData(); + fd.append('file', opt.file as File); + fd.append('bizType', bizType); + fd.append('bizId', bizId); + await api.post('/files', fd); + message.success(`${label}已上传`); + await onDone?.(); + opt.onSuccess?.({}, new XMLHttpRequest()); + } catch (e) { + message.error((e as Error).message); + opt.onError?.(e as Error); + } + }} + > + + + {fileId ? ( + + ) : ( + 未上传 + )} + + ); +} + +export type FilePreview = + | { kind: 'image'; url: string; fileName: string } + | { kind: 'pdf'; data: ArrayBuffer; fileName: string } + | { kind: 'html'; html: string; fileName: string }; + +export async function loadBizPreview(id: string, mime?: string, name?: string): Promise { + const display = displayFileName(name || ''); + const blob = await fetchBizFileBlob(id, mime, display); + if (isPreviewable(display, blob.type)) { + const isImage = (blob.type || '').startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp)$/i.test(display); + if (isImage) { + return { kind: 'image', url: URL.createObjectURL(blob), fileName: display || '图片' }; + } + const buf = await blob.arrayBuffer(); + return { kind: 'pdf', data: buf.slice(0), fileName: display || '附件.pdf' }; + } + const buffer = await blob.arrayBuffer(); + if (/\.docx$/i.test(display) || blob.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { + const mammoth = await import('mammoth/mammoth.browser'); + const result = await mammoth.convertToHtml({ arrayBuffer: buffer }); + return { + kind: 'html', + fileName: display || 'Word 文档', + html: `${result.value}`, + }; + } + if (/\.(xlsx?|csv)$/i.test(display) || /spreadsheet|ms-excel|csv/i.test(blob.type)) { + const XLSX = await import('xlsx'); + const workbook = XLSX.read(buffer, { type: 'array' }); + const sheets = workbook.SheetNames.map((sheetName) => { + const heading = `

${sheetName.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)}

`; + return heading + XLSX.utils.sheet_to_html(workbook.Sheets[sheetName]); + }).join(''); + return { + kind: 'html', + fileName: display || 'Excel 表格', + html: `${sheets}`, + }; + } + await downloadBizFile(id, display); + message.info('旧版 Word .doc 等格式已下载,请用本地软件打开'); + return null; +} + +export function AttachmentPreviewModal({ + preview, + onClose, +}: { + preview: FilePreview | null; + onClose: () => void; +}) { + return ( + + {preview?.kind === 'image' ? ( + {preview.fileName} + ) : preview?.kind === 'pdf' ? ( + }> + + + ) : preview?.kind === 'html' ? ( +