From c80d9adbcddc3df8009f717097460f26dc6925d8 Mon Sep 17 00:00:00 2001 From: shunli Date: Thu, 28 May 2026 11:48:37 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=88=B6=E5=BA=A6=E7=AD=BE?= =?UTF-8?q?=E6=94=B6=E8=BD=BB=E9=87=8F=E5=9B=9E=E6=89=A7=EF=BC=8805-28-oa-?= =?UTF-8?q?policy-acknowledgement-v1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/hyb-oa/backend-fastify-prisma.md | 70 +++ .../check.jsonl | 4 + .../design.md | 176 ++++++++ .../implement.jsonl | 5 + .../05-28-oa-policy-acknowledgement-v1/prd.md | 95 ++++ .../task.json | 34 ++ client/src/api/announcements.ts | 71 ++- client/src/pages/AnnouncementsPage.tsx | 130 +++++- client/src/pages/PortalAnnouncementsPage.tsx | 209 +++++++-- .../e2e/announcements-policy-ack.spec.ts | 307 +++++++++++++ docs/员工快速上手.md | 3 +- docs/管理员手册.md | 8 +- .../migration.sql | 43 ++ server/prisma/schema.prisma | 45 +- server/src/routes/announcements.js | 264 ++++++++++- .../tests/unit/routes/announcements.test.js | 420 ++++++++++++++++++ 16 files changed, 1806 insertions(+), 78 deletions(-) create mode 100644 .trellis/tasks/05-28-oa-policy-acknowledgement-v1/check.jsonl create mode 100644 .trellis/tasks/05-28-oa-policy-acknowledgement-v1/design.md create mode 100644 .trellis/tasks/05-28-oa-policy-acknowledgement-v1/implement.jsonl create mode 100644 .trellis/tasks/05-28-oa-policy-acknowledgement-v1/prd.md create mode 100644 .trellis/tasks/05-28-oa-policy-acknowledgement-v1/task.json create mode 100644 client/tests/e2e/announcements-policy-ack.spec.ts create mode 100644 server/prisma/migrations/20260528113000_policy_acknowledgements/migration.sql create mode 100644 server/tests/unit/routes/announcements.test.js diff --git a/.trellis/spec/hyb-oa/backend-fastify-prisma.md b/.trellis/spec/hyb-oa/backend-fastify-prisma.md index 2f0d3494..7f61236c 100644 --- a/.trellis/spec/hyb-oa/backend-fastify-prisma.md +++ b/.trellis/spec/hyb-oa/backend-fastify-prisma.md @@ -22,3 +22,73 @@ QA script patterns over new abstractions. - `cd server && node --check src/routes/workflow.js` - `cd server && ./scripts/regression-workflow-actions.sh` - `cd server && ./scripts/regression-all.sh` + +## Scenario: Portal Announcement Acknowledgement + +### 1. Scope / Trigger + +- Trigger: extending `announcements_v1` from read-only announcements into formal employee acknowledgement. +- Applies to `server/prisma/schema.prisma`, `server/src/routes/announcements.js`, `client/src/api/announcements.ts`, employee `/announcements`, and admin `/system/announcements`. +- Use this only for lightweight policy acknowledgement; do not treat it as electronic signature, CA signing, contract signing, or full policy CMS. + +### 2. Signatures + +- DB: `PortalAnnouncement` may carry `category`, `requires_acknowledgement`, `acknowledgement_statement`, `acknowledgement_due_at`. +- DB: `PortalAnnouncementAcknowledgement` stores one receipt per `(announcement_id, employee_id)` with statement/title/summary snapshots and `actor_id`. +- API: `GET /api/announcements?manage=0` returns visible announcements and the current employee's acknowledgement status. +- API: `GET /api/announcements?manage=1` requires `system.settings` and returns acknowledgement summary plus minimal pending employees. +- API: `POST /api/announcements/:id/acknowledge` accepts `{ "confirmed": true }`. + +### 3. Contracts + +- Required acknowledgement status values: `not_required`, `pending`, `acknowledged`, `overdue`. +- Employee response must not include other employees' acknowledgement rows or pending lists. +- Admin pending list may include only `id`, `employeeNo`, `name`, and `status`; do not include phone, email, WeCom userid, account identifiers, or sensitive profile fields. +- Acknowledgement is explicit only: opening an announcement or clicking its link must never create a receipt. +- Policy content changes after any acknowledgement require a new announcement version; do not mutate title, summary, category, required flag, or statement in place. + +### 4. Validation & Error Matrix + +- `announcements_v1` disabled -> `409 VALID_001`. +- policy acknowledgement without statement -> `400 VALID_001`. +- `manage=1` without `system.settings` -> `403 PERM_001`. +- acknowledge invisible/missing announcement -> `404 ANN_404`. +- acknowledge normal announcement -> `409 VALID_001`. +- acknowledge without `{ confirmed: true }` -> `400 VALID_001`. +- edit protected policy fields after acknowledgement exists -> `409 VALID_001`. + +### 5. Good/Base/Bad Cases + +- Good: admin creates a policy announcement, employee checks the confirmation box, server creates one receipt and one audit log. +- Base: normal announcement remains compatible and returns `not_required`. +- Bad: frontend displays another employee's receipt status on `/announcements`. +- Bad: admin updates an acknowledged policy title instead of creating a new version. + +### 6. Tests Required + +- Prisma validate after schema/migration changes. +- Route focused tests for required statement, permission, idempotency, normal/invisible announcement rejection, admin summary, and protected edit rejection. +- Frontend typecheck and build. +- Playwright flow for admin create -> employee acknowledge -> admin summary update when UI changes. + +### 7. Wrong vs Correct + +#### Wrong + +```js +await prisma.portalAnnouncement.update({ + where: { id }, + data: { title: "制度 V2" }, +}); +``` + +#### Correct + +```js +const acknowledgementCount = await prisma.portalAnnouncementAcknowledgement.count({ + where: { announcement_id: id }, +}); +if (acknowledgementCount > 0) { + throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409 }); +} +``` diff --git a/.trellis/tasks/05-28-oa-policy-acknowledgement-v1/check.jsonl b/.trellis/tasks/05-28-oa-policy-acknowledgement-v1/check.jsonl new file mode 100644 index 00000000..ef1401d5 --- /dev/null +++ b/.trellis/tasks/05-28-oa-policy-acknowledgement-v1/check.jsonl @@ -0,0 +1,4 @@ +{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Verify Trellis task contract and migration approval boundaries."} +{"file": ".trellis/spec/hyb-oa/backend-fastify-prisma.md", "reason": "Verify Prisma, route and backend test requirements."} +{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Verify frontend page changes follow existing OA design conventions."} +{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Verify acknowledgement APIs and UI do not leak sensitive employee or policy data."} diff --git a/.trellis/tasks/05-28-oa-policy-acknowledgement-v1/design.md b/.trellis/tasks/05-28-oa-policy-acknowledgement-v1/design.md new file mode 100644 index 00000000..87b7ce4e --- /dev/null +++ b/.trellis/tasks/05-28-oa-policy-acknowledgement-v1/design.md @@ -0,0 +1,176 @@ +# 制度签收轻量回执设计 + +## Summary + +采用“公告中心扩展 + 独立回执表”的方案。制度签收公告仍是 `PortalAnnouncement`,新增必签元数据;员工签收写入新回执表。这样复用既有公告入口、权限、feature flag 和 Portal 展示,同时让正式签收回执独立留痕,不把公告阅读误认为签收。 + +## Data Model + +建议扩展 `PortalAnnouncement`: + +- `category`: `String`,默认 `general`,V1 值为 `general` / `policy` +- `requires_acknowledgement`: `Boolean`,默认 `false` +- `acknowledgement_statement`: `String?`,必签时必填 +- `acknowledgement_due_at`: `DateTime?` + +新增 `PortalAnnouncementAcknowledgement`: + +- `id`: uuid +- `announcement_id`: uuid,关联 `PortalAnnouncement` +- `employee_id`: uuid,关联 `Employee` +- `acknowledged_at`: DateTime +- `statement_snapshot`: String +- `title_snapshot`: String +- `summary_snapshot`: String +- `actor_id`: uuid,可与 employee_id 相同,V1 不支持代签 +- `created_at`: DateTime + +索引/约束: + +- `@@unique([announcement_id, employee_id])` +- `@@index([employee_id, acknowledged_at])` +- `@@index([announcement_id, acknowledged_at])` + +## API Contract + +### `GET /api/announcements?manage=0` + +普通员工返回可见公告,并附带本人签收状态: + +```json +{ + "id": "...", + "title": "费用报销制度签收", + "summary": "请阅读并确认小团队 V1 报销规则。", + "category": "policy", + "requiresAcknowledgement": true, + "acknowledgementStatement": "我已阅读并理解该制度。", + "acknowledgementDueAt": "2026-06-01T00:00:00.000Z", + "acknowledgement": { + "status": "pending", + "acknowledgedAt": null, + "overdue": false + } +} +``` + +普通员工只看到自己的签收状态,不看到他人列表。 + +### `GET /api/announcements?manage=1` + +仅 `system.settings` 可用。返回管理视图,包含统计: + +```json +{ + "acknowledgementSummary": { + "required": true, + "totalActiveEmployees": 6, + "acknowledgedCount": 4, + "pendingCount": 2, + "overdueCount": 1 + }, + "pendingEmployees": [ + { "id": "...", "employeeNo": "HYD***", "name": "员工A" } + ] +} +``` + +未签员工列表只返回员工号、姓名和状态,不返回手机号、邮箱、身份证、企业微信 userid 或账号凭据。 + +### `POST /api/announcements/:id/acknowledge` + +普通员工签收本人回执。 + +请求: + +```json +{ + "confirmed": true +} +``` + +校验: + +- `announcements_v1` 必须启用。 +- 公告必须 active 且在可见时间窗口内。 +- 公告 `requires_acknowledgement=true`。 +- `confirmed=true`。 +- 员工必须 active。 +- 重复签收返回已有回执,不新建第二条。 + +响应: + +```json +{ + "item": { + "announcementId": "...", + "status": "acknowledged", + "acknowledgedAt": "..." + } +} +``` + +审计: + +- `PORTAL_ANNOUNCEMENT_ACKNOWLEDGED` + +## Frontend Design + +使用 `frontend-design` 裁决后的方向:**安静、正式、清晰的 OA 合规面板**。 + +员工公告页: + +- 保持公告列表,不做大营销布局。 +- 必签公告使用更明确的状态列/徽标:待签收、已签收、已逾期。 +- 卡片内展示签收声明、截止日期和主按钮。 +- 签收按钮前必须有 checkbox:“我已阅读并理解该制度内容和资料留存边界”。 +- 已签后展示签收时间,不再显示可重复操作按钮。 + +管理员公告页: + +- 左侧新建公告表单增加: + - 公告类型:普通 / 制度签收 + - 签收声明 + - 截止日期 +- 右侧列表显示签收统计,不把未签名单做成大表格;V1 用紧凑列表即可。 +- 页面继续使用现有 `card-std` / PC 操作页模式,不新建独立视觉系统。 + +## Permissions + +- 创建/编辑/归档/查看统计:`system.settings` +- 查看公告:authenticated user +- 签收:authenticated active employee,仅本人 +- 不允许管理员代签,不允许普通员工查看他人签收。 + +## Migration Notes + +- 新增字段全部带默认或 nullable,避免影响既有公告数据。 +- 新增回执表只记录未来签收,不导入历史纸质/电子签收。 +- 生产 migration 只在用户明确批准部署时应用;本任务本地验证不代表公网已启用。 + +## Tests + +后端 focused: + +- 创建普通公告仍兼容。 +- 创建必签公告缺声明 -> 400。 +- 员工获取公告时看到本人 pending 状态。 +- 员工签收必签公告 -> 201/200,审计写入。 +- 重复签收 -> 幂等。 +- 普通公告签收 -> 409。 +- 管理员 manage 查询看到统计和未签列表。 + +前端 E2E: + +- 管理员创建必签公告。 +- 员工进入公告页,勾选确认,点击签收。 +- 员工刷新后状态为已签收。 +- 管理员看到已签/待签统计变化。 + +## Reviewer Need + +本任务涉及 Prisma migration、权限和审计。实施后需要做只读 review,重点看: + +- 普通用户是否可能看到他人签收。 +- 管理接口是否泄露手机号、邮箱、账号、企业微信 userid。 +- 已签回执是否会被公告编辑破坏语义。 diff --git a/.trellis/tasks/05-28-oa-policy-acknowledgement-v1/implement.jsonl b/.trellis/tasks/05-28-oa-policy-acknowledgement-v1/implement.jsonl new file mode 100644 index 00000000..9c409f89 --- /dev/null +++ b/.trellis/tasks/05-28-oa-policy-acknowledgement-v1/implement.jsonl @@ -0,0 +1,5 @@ +{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Trellis task, approvals, commit and migration governance for a non-trivial feature."} +{"file": ".trellis/spec/hyb-oa/backend-fastify-prisma.md", "reason": "Backend route, Prisma migration and validation requirements."} +{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Frontend design authority and PC operation page conventions."} +{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Sensitive data exclusions for policy materials and employee acknowledgement reports."} +{"file": ".trellis/spec/hyb-oa/docs-and-evidence.md", "reason": "Evidence and report boundaries for task artifacts."} diff --git a/.trellis/tasks/05-28-oa-policy-acknowledgement-v1/prd.md b/.trellis/tasks/05-28-oa-policy-acknowledgement-v1/prd.md new file mode 100644 index 00000000..b8cfec5e --- /dev/null +++ b/.trellis/tasks/05-28-oa-policy-acknowledgement-v1/prd.md @@ -0,0 +1,95 @@ +# 制度签收轻量回执 + +## Goal + +在不引入完整电子签章、复杂制度库或合同级法务系统的前提下,为 OA 公告中心增加“正式制度签收回执”能力。管理员可发布需要员工确认的制度公告,员工必须通过明确勾选和点击完成签收,系统保存签收时间、签收人、声明快照和审计记录。目标是补齐 OALAUNCH1 数据治理门禁中的“公告阅读不等于正式签收”缺口。 + +## Scope + +- 基于现有 `announcements_v1` 和公告中心扩展,不新增独立制度 CMS。 +- 支持管理员创建/编辑公告时标记“需要签收”,填写签收声明和签收截止日期。 +- 支持员工在公告页查看待签收状态,并通过明确确认动作完成本人签收。 +- 支持管理员在公告管理页查看签收总数、已签、未签、逾期统计和未签员工脱敏/最小必要列表。 +- 后端保存签收回执、签收声明快照和审计日志。 +- 覆盖后端 focused 测试、前端 typecheck/build 和至少一个公告签收 E2E/组件级流程验证。 + +## What I Already Know + +- 当前 `PortalAnnouncement` 只有公告标题、摘要、链接、启停、优先级和时间窗口,没有签收模型。 +- `EmployeeChecklistTask.task_type = policy_ack` 已存在,但它只服务入职 checklist,不适合作为公司级制度签收台账。 +- `docs/管理员手册.md` 和 `docs/员工快速上手.md` 已明确:公告阅读不等于正式签收,制度签收是正式启用前的数据治理缺口。 +- 公告模块已有 feature flag `announcements_v1`、后台路由 `/api/announcements`、员工页 `/announcements` 和管理页 `/system/announcements`。 +- 前端视觉裁决以 `frontend-design` 为准;本任务应保持 OA 操作界面简洁、密集、可扫读,不做营销页或重视觉重构。 + +## Requirements + +- 管理员创建公告时可选择: + - 普通公告:无需签收,行为保持兼容。 + - 制度签收公告:需要签收,必须有签收声明,建议可填截止日期。 +- 员工看到制度签收公告时必须有明确状态: + - 待签收 + - 已签收 + - 已逾期未签 +- 员工签收必须是显式动作:勾选确认声明后点击签收按钮;不能仅因打开页面或点击链接视为签收。 +- 同一员工对同一公告只能形成一条有效回执;重复点击应幂等返回已签收状态。 +- 签收回执至少记录:公告、员工、签收时间、签收声明快照、公告标题/摘要快照、操作者、创建时间。 +- 管理员可以查看签收概览和未签员工列表;普通员工不能查看他人签收状态。 +- 公告正文和链接仍按现有敏感资料边界处理:不上传涉密合同正文、银行资料、完整路径、账号凭据或敏感报价。 +- 更新已签收的制度公告不自动重置签收;如制度内容变化,应新建一条新版本公告,避免历史回执语义混乱。 + +## Acceptance Criteria + +- [x] 后端模型和迁移支持公告签收回执,Prisma validate 通过。 +- [x] `GET /api/announcements` 对普通员工返回本人签收状态,不泄露他人信息。 +- [x] `POST /api/announcements/:id/acknowledge` 支持本人签收,校验 feature flag、公告可见、需要签收和确认声明。 +- [x] 管理员管理接口能看到签收统计和未签员工最小必要信息。 +- [x] 前端员工公告页能完成待签收 -> 已签收流程,按钮状态、防重复和逾期提示清楚。 +- [x] 前端公告管理页能创建必签公告并查看签收概览。 +- [x] 后端 focused 测试覆盖签收权限、幂等、不可签普通公告、不可签不可见公告。 +- [x] 前端 typecheck/build 通过,签收流程 E2E 或等价自动化通过。 +- [x] 不部署、不改公网配置、不批量写真实签收数据;公网启用需另行批准。 + +## Out Of Scope + +- 不做电子签章、CA、手写签名或法律级电子合同。 +- 不做复杂制度库、制度版本全文管理、在线红线审阅或知识库问答。 +- 不做按部门/角色定向签收;V1 默认面向所有 active 员工。 +- 不做代签、补签证明上传、批量导入历史纸质签收。 +- 不通过企业微信原生审批承载制度签收;企业微信只可作为后续提醒入口。 +- 不把公司制度原文、合同正文、银行资料、工资资料、完整本地路径或敏感附件导入仓库或 Trellis。 + +## Test Plan + +- Backend: + - `cd server && npm run prisma:validate` + - `cd server && node --check src/routes/announcements.js` + - 增加并运行公告签收 focused 单测或现有路由测试。 +- Frontend: + - `cd client && npm run typecheck` + - `cd client && npm run build` + - 增加/运行公告签收 E2E:管理员创建必签公告,员工签收,管理员看到统计。 +- Governance: + - `node scripts/qa/check-trellis-governance.mjs` + - `git diff --check` + +## Rollout / Rollback + +- V1 受现有 `announcements_v1` 控制;不新增部署 env。 +- 本地/测试通过后再决定是否部署公网。 +- 回滚方式:关闭 `announcements_v1` 或隐藏公告入口;已保存回执作为审计记录保留,不自动删除。 + +## Open Questions + +- 建议 V1 默认“所有 active 员工都需要签收”,暂不做定向范围。是否批准按这个范围实现? + +## Technical Notes + +- 参考文件: + - `server/prisma/schema.prisma` + - `server/src/routes/announcements.js` + - `client/src/api/announcements.ts` + - `client/src/pages/AnnouncementsPage.tsx` + - `client/src/pages/PortalAnnouncementsPage.tsx` + - `.trellis/spec/hyb-oa/frontend-react-vite-design.md` + - `.trellis/spec/hyb-oa/backend-fastify-prisma.md` + - `.trellis/spec/hyb-oa/security-privacy.md` diff --git a/.trellis/tasks/05-28-oa-policy-acknowledgement-v1/task.json b/.trellis/tasks/05-28-oa-policy-acknowledgement-v1/task.json new file mode 100644 index 00000000..9ca73cfb --- /dev/null +++ b/.trellis/tasks/05-28-oa-policy-acknowledgement-v1/task.json @@ -0,0 +1,34 @@ +{ + "id": "oa-policy-acknowledgement-v1", + "name": "oa-policy-acknowledgement-v1", + "title": "制度签收轻量回执", + "description": "在现有公告中心上增加轻量制度签收回执,支持管理员发布必签制度公告、员工显式签收、管理员查看签收统计和审计留痕。", + "status": "in_progress", + "dev_type": "feature", + "scope": "server-client-prisma-announcements-policy-acknowledgement", + "package": "hyb-oa", + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-05-28", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "server/prisma/schema.prisma", + "server/src/routes/announcements.js", + "client/src/api/announcements.ts", + "client/src/pages/AnnouncementsPage.tsx", + "client/src/pages/PortalAnnouncementsPage.tsx", + "docs/管理员手册.md", + "docs/员工快速上手.md" + ], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/client/src/api/announcements.ts b/client/src/api/announcements.ts index 29a341c2..18553808 100644 --- a/client/src/api/announcements.ts +++ b/client/src/api/announcements.ts @@ -1,5 +1,29 @@ import { apiRequestData } from "./http"; +export type AnnouncementCategory = "general" | "policy"; +export type AnnouncementAcknowledgementStatus = "not_required" | "pending" | "acknowledged" | "overdue"; + +export type PortalAnnouncementAcknowledgement = { + status: AnnouncementAcknowledgementStatus; + acknowledgedAt: string | null; + overdue: boolean; +}; + +export type PortalAnnouncementAcknowledgementSummary = { + required: boolean; + totalActiveEmployees: number; + acknowledgedCount: number; + pendingCount: number; + overdueCount: number; +}; + +export type PortalAnnouncementPendingEmployee = { + id: string; + employeeNo: string; + name: string; + status: "pending" | "overdue"; +}; + export type PortalAnnouncement = { id: string; title: string; @@ -9,16 +33,18 @@ export type PortalAnnouncement = { endsAt: string | null; enabled: boolean; priority: number; + category: AnnouncementCategory; + requiresAcknowledgement: boolean; + acknowledgementStatement: string | null; + acknowledgementDueAt: string | null; + acknowledgement?: PortalAnnouncementAcknowledgement; + acknowledgementSummary?: PortalAnnouncementAcknowledgementSummary; + pendingEmployees?: PortalAnnouncementPendingEmployee[]; createdAt: string | null; updatedAt: string | null; }; -export async function fetchAnnouncements(manage = false) { - const suffix = manage ? "?manage=1" : ""; - return apiRequestData<{ items: PortalAnnouncement[] }>(`/api/announcements${suffix}`); -} - -export async function createAnnouncement(payload: { +export type AnnouncementWritePayload = { title: string; summary: string; linkUrl?: string | null; @@ -26,22 +52,25 @@ export async function createAnnouncement(payload: { endsAt?: string | null; enabled?: boolean; priority?: number; -}) { + category?: AnnouncementCategory; + requiresAcknowledgement?: boolean; + acknowledgementStatement?: string | null; + acknowledgementDueAt?: string | null; +}; + +export async function fetchAnnouncements(manage = false) { + const suffix = manage ? "?manage=1" : ""; + return apiRequestData<{ items: PortalAnnouncement[] }>(`/api/announcements${suffix}`); +} + +export async function createAnnouncement(payload: AnnouncementWritePayload) { return apiRequestData<{ item: PortalAnnouncement }>("/api/announcements", { method: "POST", body: JSON.stringify(payload), }); } -export async function updateAnnouncement(id: string, payload: Partial<{ - title: string; - summary: string; - linkUrl: string | null; - startsAt: string | null; - endsAt: string | null; - enabled: boolean; - priority: number; -}>) { +export async function updateAnnouncement(id: string, payload: Partial) { return apiRequestData<{ item: PortalAnnouncement }>(`/api/announcements/${id}`, { method: "PATCH", body: JSON.stringify(payload), @@ -54,3 +83,13 @@ export async function archiveAnnouncement(id: string) { body: JSON.stringify({}), }); } + +export async function acknowledgeAnnouncement(id: string) { + return apiRequestData<{ item: { id: string | null; announcementId: string; status: "acknowledged"; acknowledgedAt: string | null } }>( + `/api/announcements/${id}/acknowledge`, + { + method: "POST", + body: JSON.stringify({ confirmed: true }), + }, + ); +} diff --git a/client/src/pages/AnnouncementsPage.tsx b/client/src/pages/AnnouncementsPage.tsx index fda35154..a7aaca24 100644 --- a/client/src/pages/AnnouncementsPage.tsx +++ b/client/src/pages/AnnouncementsPage.tsx @@ -1,21 +1,135 @@ -import { useQuery } from "@tanstack/react-query"; -import { fetchAnnouncements } from "../api/announcements"; +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { acknowledgeAnnouncement, fetchAnnouncements, type AnnouncementAcknowledgementStatus, type PortalAnnouncement } from "../api/announcements"; import { PageHeader } from "../components/common/PageHeader"; +import { Button } from "../components/ui/button"; +import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat"; + +function acknowledgementLabel(status: AnnouncementAcknowledgementStatus) { + if (status === "acknowledged") return "已签收"; + if (status === "overdue") return "已逾期"; + if (status === "pending") return "待签收"; + return "普通公告"; +} + +function acknowledgementBadgeClass(status: AnnouncementAcknowledgementStatus) { + if (status === "acknowledged") return "badge-success"; + if (status === "overdue") return "badge-error"; + if (status === "pending") return "badge-warning"; + return "badge-neutral"; +} + +function AnnouncementCard({ + item, + checked, + submitting, + onCheckedChange, + onAcknowledge, +}: { + item: PortalAnnouncement; + checked: boolean; + submitting: boolean; + onCheckedChange: (checked: boolean) => void; + onAcknowledge: () => void; +}) { + const acknowledgement = item.acknowledgement ?? { status: "not_required", acknowledgedAt: null, overdue: false }; + const requiresAcknowledgement = item.requiresAcknowledgement; + const isAcknowledged = acknowledgement.status === "acknowledged"; + const canSubmit = requiresAcknowledgement && !isAcknowledged && checked && !submitting; + + return ( +
+
+
+
+

{item.title}

+ + {acknowledgementLabel(acknowledgement.status)} + +
+

{item.summary}

+
+ {item.acknowledgementDueAt ? ( +
+ 截止:{formatDisplayDate(item.acknowledgementDueAt)} +
+ ) : null} +
+ + {item.linkUrl ? ( + + 查看链接 + + ) : null} + + {requiresAcknowledgement ? ( +
+
签收声明
+
{item.acknowledgementStatement}
+ {isAcknowledged ? ( +
+ 已于 {formatDisplayDateTime(acknowledgement.acknowledgedAt)} 完成本人签收。 +
+ ) : ( +
+ + +
+ )} +
+ ) : null} +
+ ); +} export function AnnouncementsPage() { + const queryClient = useQueryClient(); + const [checkedById, setCheckedById] = useState>({}); const announcementsQuery = useQuery({ queryKey: ["announcements", "public"], queryFn: () => fetchAnnouncements(false) }); + const acknowledgeMutation = useMutation({ + mutationFn: acknowledgeAnnouncement, + onSuccess: async (_data, id) => { + setCheckedById((current) => ({ ...current, [id]: false })); + await queryClient.invalidateQueries({ queryKey: ["announcements"] }); + toast.success("签收已记录"); + }, + onError: () => { + toast.error("签收失败,请刷新后重试"); + }, + }); return (
- +
+ {announcementsQuery.isLoading ?
公告加载中...
: null} {(announcementsQuery.data?.items ?? []).map((item) => ( -
-
{item.title}
-
{item.summary}
- {item.linkUrl ? 查看链接 : null} -
+ setCheckedById((current) => ({ ...current, [item.id]: checked }))} + onAcknowledge={() => acknowledgeMutation.mutate(item.id)} + /> ))} + {!announcementsQuery.isLoading && !(announcementsQuery.data?.items ?? []).length ? ( +
暂无公告
+ ) : null}
); diff --git a/client/src/pages/PortalAnnouncementsPage.tsx b/client/src/pages/PortalAnnouncementsPage.tsx index 6cd27631..ea6672ae 100644 --- a/client/src/pages/PortalAnnouncementsPage.tsx +++ b/client/src/pages/PortalAnnouncementsPage.tsx @@ -1,11 +1,29 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { archiveAnnouncement, createAnnouncement, fetchAnnouncements, updateAnnouncement } from "../api/announcements"; +import { toast } from "sonner"; +import { archiveAnnouncement, createAnnouncement, fetchAnnouncements, updateAnnouncement, type AnnouncementCategory, type PortalAnnouncement } from "../api/announcements"; import { PageHeader } from "../components/common/PageHeader"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; -import { toast } from "sonner"; +import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; +import { Textarea } from "../components/ui/textarea"; +import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat"; + +function toChinaEndOfDayIso(value: string) { + if (!value) return null; + return new Date(`${value}T23:59:59+08:00`).toISOString(); +} + +function acknowledgementModeLabel(category: AnnouncementCategory, requiresAcknowledgement: boolean) { + return category === "policy" || requiresAcknowledgement ? "制度签收" : "普通公告"; +} + +function formatSummary(item: PortalAnnouncement) { + const summary = item.acknowledgementSummary; + if (!summary?.required) return "无需签收"; + return `已签 ${summary.acknowledgedCount}/${summary.totalActiveEmployees} · 待签 ${summary.pendingCount}`; +} export function PortalAnnouncementsPage() { const queryClient = useQueryClient(); @@ -13,20 +31,39 @@ export function PortalAnnouncementsPage() { const [summary, setSummary] = useState(""); const [linkUrl, setLinkUrl] = useState(""); const [priority, setPriority] = useState("100"); + const [mode, setMode] = useState("general"); + const [acknowledgementStatement, setAcknowledgementStatement] = useState("我已阅读并理解该制度,确认按公司制度执行。"); + const [acknowledgementDueDate, setAcknowledgementDueDate] = useState(""); const listQuery = useQuery({ queryKey: ["announcements", "manage"], queryFn: () => fetchAnnouncements(true) }); const refresh = () => queryClient.invalidateQueries({ queryKey: ["announcements"] }); + const isPolicy = mode === "policy"; const createMutation = useMutation({ - mutationFn: () => createAnnouncement({ title, summary, linkUrl: linkUrl || null, priority: Number(priority || 100) }), + mutationFn: () => createAnnouncement({ + title, + summary, + linkUrl: linkUrl || null, + priority: Number(priority || 100), + category: mode, + requiresAcknowledgement: isPolicy, + acknowledgementStatement: isPolicy ? acknowledgementStatement.trim() : null, + acknowledgementDueAt: isPolicy ? toChinaEndOfDayIso(acknowledgementDueDate) : null, + }), onSuccess: async () => { setTitle(""); setSummary(""); setLinkUrl(""); setPriority("100"); + setMode("general"); + setAcknowledgementStatement("我已阅读并理解该制度,确认按公司制度执行。"); + setAcknowledgementDueDate(""); await refresh(); toast.success("公告已创建"); }, + onError: () => { + toast.error("公告创建失败,请检查填写内容"); + }, }); const archiveMutation = useMutation({ @@ -45,39 +82,149 @@ export function PortalAnnouncementsPage() { }, }); + const handleCreate = () => { + if (!title.trim() || !summary.trim()) { + toast.error("请填写标题和摘要"); + return; + } + if (isPolicy && !acknowledgementStatement.trim()) { + toast.error("制度签收公告必须填写签收声明"); + return; + } + createMutation.mutate(); + }; + return (
- -
-
-

新建公告

-
setTitle(event.target.value)} />
-