From 52e5284954d8102460ebf6ac25186d993b84757c Mon Sep 17 00:00:00 2001 From: shunli Date: Thu, 28 May 2026 19:44:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E4=B8=9A=E5=8A=A1=E9=99=84?= =?UTF-8?q?=E4=BB=B6=E5=8F=97=E6=8E=A7=E5=85=A5=E5=BA=93=20V1=EF=BC=8805-2?= =?UTF-8?q?8-business-attachment-governance-v1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/security-privacy.md | 90 +++++++++++ .../check.jsonl | 9 ++ .../design.md | 84 ++++++++++ .../implement.jsonl | 11 ++ .../prd.md | 69 +++++++++ .../task.json | 30 ++++ client/src/api/paymentBatches.ts | 5 +- client/src/api/purchaseRequests.ts | 7 +- client/src/api/reimbursements.ts | 6 +- .../common/AttachmentGovernanceBadges.tsx | 46 ++++++ .../useAttachmentDownloadReasonDialog.tsx | 96 ++++++++++++ client/src/lib/attachmentGovernance.ts | 91 +++++++++++ .../pages/MobilePurchaseRequestDetailPage.tsx | 2 + .../pages/MobileReimbursementDetailPage.tsx | 20 ++- client/src/pages/PaymentBatchesPage.tsx | 20 ++- client/src/pages/PurchaseRequestsPage.tsx | 20 ++- client/src/pages/ReimbursementDetailPage.tsx | 20 ++- .../migration.sql | 46 ++++++ server/prisma/schema.prisma | 38 +++++ server/src/routes/paymentBatches.js | 66 +++++++- server/src/routes/purchaseRequests.js | 86 +++++++++++ server/src/routes/reimbursements.js | 58 +++++++ .../services/businessAttachmentGovernance.js | 143 ++++++++++++++++++ .../paymentBatches.reimbursement.test.js | 45 +++++- .../routes/reimbursements.attachments.test.js | 32 +++- 25 files changed, 1119 insertions(+), 21 deletions(-) create mode 100644 .trellis/tasks/05-28-business-attachment-governance-v1/check.jsonl create mode 100644 .trellis/tasks/05-28-business-attachment-governance-v1/design.md create mode 100644 .trellis/tasks/05-28-business-attachment-governance-v1/implement.jsonl create mode 100644 .trellis/tasks/05-28-business-attachment-governance-v1/prd.md create mode 100644 .trellis/tasks/05-28-business-attachment-governance-v1/task.json create mode 100644 client/src/components/common/AttachmentGovernanceBadges.tsx create mode 100644 client/src/components/common/useAttachmentDownloadReasonDialog.tsx create mode 100644 client/src/lib/attachmentGovernance.ts create mode 100644 server/prisma/migrations/20260528162000_business_attachment_governance_v1/migration.sql create mode 100644 server/src/services/businessAttachmentGovernance.js diff --git a/.trellis/spec/hyb-oa/security-privacy.md b/.trellis/spec/hyb-oa/security-privacy.md index a305b343..e0a2c28b 100644 --- a/.trellis/spec/hyb-oa/security-privacy.md +++ b/.trellis/spec/hyb-oa/security-privacy.md @@ -17,3 +17,93 @@ - `.trellis/tasks/` may contain PRD and acceptance context, but only in redacted form. - `scripts/qa/check-trellis-governance.mjs` must pass before commit. + +## Scenario: Business Attachment Governance + +### 1. Scope / Trigger + +- Trigger: adding or changing OA business attachments for reimbursement, + purchase, payment, contract, project or company-document workflows. +- Applies when an attachment is stored by the OA and can later be previewed, + downloaded, reviewed or archived. +- Do not introduce a generic file-center or free directory upload unless a + Trellis task explicitly reopens that architecture. + +### 2. Signatures + +- DB fields on business attachment models: + `governance_status`, `sensitivity_level`, `attachment_type`, + `reviewed_by`, `reviewed_at`, `review_note`, `archived_by`, + `archived_at`, `archive_note`, `download_policy`. +- Upload multipart fields may include: + `governanceStatus`, `sensitivityLevel`, `attachmentType`, `downloadPolicy`. +- API response fields must use camelCase: + `governanceStatus`, `sensitivityLevel`, `attachmentType`, `downloadPolicy`, + `reviewedBy`, `reviewedAt`, `reviewNote`, `archivedBy`, `archivedAt`, + `archiveNote`. +- Download APIs may accept `reason`; sensitive or reason-required attachments + must reject downloads without a non-empty reason. + +### 3. Contracts + +- Default status is `approved` unless the business workflow has a stricter + state such as `pending_review`. +- Default sensitivity is `internal`; payment receipts and similarly sensitive + financial proof should use `confidential`. +- `index_only` means OA stores the index/metadata, not the original file. +- Frontend labels are informational only; backend permission and download + reason checks remain authoritative. +- Audit logs for downloads must include actor, business target, attachment ID, + attachment type, sensitivity level, governance status and sanitized reason + when provided. + +### 4. Validation & Error Matrix + +- Unknown governance enum in upload -> normalize to the module default, do not + persist arbitrary strings. +- Sensitive or `reason_required` attachment download without reason -> + `400 VALID_001` with `{ reason: "required" }`. +- Missing attachment or missing stored object -> `404`. +- User lacks business read permission -> `403 PERM_001`. +- Download reason longer than the server limit -> trim before writing audit + metadata. + +### 5. Good/Base/Bad Cases + +- Good: a bank receipt upload returns `confidential`, `receipt`, + `reason_required`; download first asks for用途 and writes an audit log. +- Base: a reimbursement supporting material upload defaults to `internal`, + `supporting_material`, `standard` and remains downloadable by authorized + participants. +- Bad: a page hides the download button but the backend route still streams the + file without permission or reason checks. +- Bad: Enterprise WeChat messages or reports include original attachments + instead of OA links and desensitized summaries. + +### 6. Tests Required + +- Prisma validate after schema or migration changes. +- Backend route tests for default governance fields, custom multipart fields + when exposed, sensitive-download reason rejection and audit `extra` metadata. +- Frontend typecheck/build after API type changes. +- Focused UI verification for pages that add attachment governance labels or + download-reason interactions. + +### 7. Wrong vs Correct + +#### Wrong + +```js +return reply.send(stream); +``` + +#### Correct + +```js +const reason = sanitizeDownloadReason(request.query?.reason); +if (requiresDownloadReason(attachment) && !reason) { + throw buildValidationError({ reason: "required" }); +} +await prisma.auditLog.create({ data: buildDownloadAudit({ attachment, reason }) }); +return reply.send(stream); +``` diff --git a/.trellis/tasks/05-28-business-attachment-governance-v1/check.jsonl b/.trellis/tasks/05-28-business-attachment-governance-v1/check.jsonl new file mode 100644 index 00000000..081b1ef3 --- /dev/null +++ b/.trellis/tasks/05-28-business-attachment-governance-v1/check.jsonl @@ -0,0 +1,9 @@ +{"file":".trellis/spec/hyb-oa/index.md","reason":"核对 hyb-oa 项目级规则"} +{"file":".trellis/spec/hyb-oa/backend-fastify-prisma.md","reason":"核对后端路由、Prisma 和错误处理"} +{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"核对前端 UI 和设计边界"} +{"file":".trellis/spec/hyb-oa/reimbursement-invoice-ocr.md","reason":"核对报销附件和票据边界未被破坏"} +{"file":".trellis/spec/hyb-oa/security-privacy.md","reason":"核对敏感资料、日志和报告脱敏"} +{"file":".trellis/spec/backend/index.md","reason":"核对后端质量门禁"} +{"file":".trellis/spec/frontend/index.md","reason":"核对前端质量门禁"} +{"file":".trellis/tasks/05-28-business-attachment-governance-v1/prd.md","reason":"核对需求验收"} +{"file":".trellis/tasks/05-28-business-attachment-governance-v1/design.md","reason":"核对设计一致性"} diff --git a/.trellis/tasks/05-28-business-attachment-governance-v1/design.md b/.trellis/tasks/05-28-business-attachment-governance-v1/design.md new file mode 100644 index 00000000..9c8a242a --- /dev/null +++ b/.trellis/tasks/05-28-business-attachment-governance-v1/design.md @@ -0,0 +1,84 @@ +# 业务附件受控入库 V1 设计 + +## 实施策略 + +本轮采用“增强现有附件模型 + 共享常量/校验 + 下载审计”的方式,不新建独立文件中心。 + +原因: + +- 现有业务已经有报销附件、采购附件、付款回单附件和票据池,直接增强可降低迁移风险。 +- 统一大表会牵涉跨业务数据迁移、权限重写和前端重构,当前 V1 不需要一次性完成。 +- 先把状态、敏感级别、附件类型和下载审计打齐,后续如需统一 `business_attachments` 视图或表,再基于真实使用频率演进。 + +## 数据设计 + +### 统一字段 + +优先给已有业务附件表补字段: + +- `governance_status`:附件治理状态,默认 `approved` 或业务现状兼容值。 +- `sensitivity_level`:敏感级别,默认 `internal`。 +- `attachment_type`:附件类型,默认 `other`。 +- `reviewed_by` / `reviewed_at` / `review_note`:复核信息。 +- `archived_by` / `archived_at` / `archive_note`:归档信息。 +- `download_policy`:下载策略,默认 `standard`。 + +如果某表已有相似字段,则复用或做映射,不重复制造语义。 + +### 迁移与回滚口径 + +本轮迁移只新增列和索引,不改写既有附件文件、不迁移存储位置、不删除历史数据。 + +如上线后必须回滚应用代码,旧代码可忽略新增列继续运行;如必须回滚数据库结构,先确认没有依赖这些字段的新代码在线,再按新增索引和新增列的反向顺序删除。本轮不执行数据库重置、不重写迁移历史。 + +### 下载审计 + +优先复用现有审计日志机制;若现有日志无法表达附件下载,则新增轻量附件下载审计表,字段至少包括: + +- 操作者 +- 业务类型和业务 ID +- 附件模型/附件 ID +- 动作:preview/download +- 原因或用途 +- IP/User-Agent 摘要 +- 时间 + +## API 设计 + +V1 不强制新建独立 `/business-attachments` API。先在现有业务 API 上增强: + +- 上传接口接受可选 `attachmentType`、`sensitivityLevel`。 +- 列表/详情接口返回治理字段。 +- 下载接口接受可选 `reason`,敏感级别较高时要求填写。 +- 服务端统一调用附件治理 helper 做状态、敏感级别和下载审计处理。 + +## 前端设计 + +遵循现有页面,不做大视觉重构: + +- 附件列表增加紧凑标签:状态、类型、敏感级别。 +- 下载入口对敏感附件提示填写用途或原因。 +- 普通用户不展示无权限动作;服务端仍做最终校验。 +- 企业微信消息仍只放 OA 链接和脱敏摘要,不携带附件。 + +## 权限边界 + +- 业务负责人、审批人、财务、合同/项目负责人按业务关系查看原文附件。 +- 普通员工只能看自己相关业务或授权协作范围。 +- 管理员不自动获得业务审批权;技术排障需最小化授权并留痕。 +- 源码、密钥、Secret、完整配置、完整账号凭据和完整本地路径禁止入库。 + +## 分批落地 + +1. 后端 schema/helper/migration/test。 +2. 报销、采购、付款回单 API 兼容增强。 +3. 前端附件列表展示治理字段。 +4. 下载审计与敏感下载原因。 +5. 通过验证后再评估合同/项目专用附件入口和 `EA-DOC` 自动索引。 + +## 本轮实现记录 + +- 已增强 `PurchaseAttachment`、`ReimbursementAttachment`、`PaymentBatchReceiptAttachment`,补齐治理状态、敏感级别、附件类型、复核/归档和下载策略字段。 +- 已新增共享附件治理 helper,统一上传字段解析、默认值映射、前后端展示标签和敏感下载用途判断。 +- 已复用 `AuditLog` 记录报销附件、采购附件和付款回单下载;付款回单默认 `confidential` + `reason_required`,下载必须填写用途。 +- 已在报销详情、移动报销详情、采购详情、移动采购详情和付款批次回单区显示紧凑治理标签。 diff --git a/.trellis/tasks/05-28-business-attachment-governance-v1/implement.jsonl b/.trellis/tasks/05-28-business-attachment-governance-v1/implement.jsonl new file mode 100644 index 00000000..486e86ea --- /dev/null +++ b/.trellis/tasks/05-28-business-attachment-governance-v1/implement.jsonl @@ -0,0 +1,11 @@ +{"file":".trellis/spec/hyb-oa/index.md","reason":"hyb-oa 项目级开发前置规则"} +{"file":".trellis/spec/hyb-oa/backend-fastify-prisma.md","reason":"Fastify/Prisma 后端实现和路由约定"} +{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"React/Vite 前端页面和设计约束"} +{"file":".trellis/spec/hyb-oa/reimbursement-invoice-ocr.md","reason":"报销、票据、附件和 OCR 边界"} +{"file":".trellis/spec/hyb-oa/security-privacy.md","reason":"敏感资料、附件和日志脱敏边界"} +{"file":".trellis/spec/hyb-oa/wecom-boundary.md","reason":"企业微信消息不承载高敏附件的边界"} +{"file":".trellis/spec/backend/index.md","reason":"后端通用质量和结构规范"} +{"file":".trellis/spec/frontend/index.md","reason":"前端通用质量和结构规范"} +{"file":".trellis/tasks/05-28-business-attachment-governance-v1/prd.md","reason":"本任务需求与验收范围"} +{"file":".trellis/tasks/05-28-business-attachment-governance-v1/design.md","reason":"本任务实施设计"} +{"file":".trellis/tasks/05-28-company-docs-oa-ops-mapping/design.md","reason":"上游业务附件治理和资料边界设计"} diff --git a/.trellis/tasks/05-28-business-attachment-governance-v1/prd.md b/.trellis/tasks/05-28-business-attachment-governance-v1/prd.md new file mode 100644 index 00000000..85a52b9f --- /dev/null +++ b/.trellis/tasks/05-28-business-attachment-governance-v1/prd.md @@ -0,0 +1,69 @@ +# 业务附件受控入库 V1 + +## Goal + +在现有 OA 报销、采购、付款、合同/项目资料链路上建立统一的业务附件治理能力:允许授权人员上传必要原文附件,但附件必须依附具体业务对象,具备状态、敏感级别、附件类型、复核/归档口径和下载审计基础,避免把 OA 误用为网盘或全量资料库。 + +## What I Already Know + +- 上游规划任务已确认:OA 不做全量资料原件库,业务必要原文附件可以进入授权业务流程。 +- 当前 V0 暂不接 COS,继续使用部署服务器本地存储;后端已有 `STORAGE_DRIVER=local/s3` 存储抽象。 +- 现有系统已有报销附件、采购附件、付款回单附件、票据池、合同台账、项目资料和审计日志基础。 +- 当前主要缺口是:各业务附件字段不统一、状态/敏感级别/附件类型不足、下载控制和审计口径不一致、合同盖章归档和资料索引口径未统一。 +- 脱敏不是一刀切要求;有权限人员可在 OA 业务流程内处理必要原文。脱敏主要用于普通协作、AI/Codex、企业微信消息、自动化报告、外发材料和低权限查看。 + +## Scope + +纳入本轮 V1: + +- 为已有业务附件模型补齐统一治理字段:状态、敏感级别、附件类型、复核人/时间/意见、归档人/时间、下载原因策略和文件 hash。 +- 覆盖已有附件链路的最小后台能力:报销附件、采购附件、付款回单附件。 +- 增加通用业务附件常量、校验和状态转换工具,避免各模块散落字符串。 +- 增加下载审计记录基础,至少覆盖服务端下载动作和用途/原因字段。 +- 在现有详情页或附件组件上展示状态、敏感级别和附件类型;不做大规模视觉重构。 +- 保持现有上传可用,不破坏报销/OCR/采购/付款现有流程。 + +## Out Of Scope + +暂不纳入本轮: + +- 不做全站网盘、自由目录、批量下载、外链分享或对外公开链接。 +- 不接 COS,不迁移已有附件文件,不改生产存储配置。 +- 不做完整合同在线编辑、电子签章、复杂版本树、签出/签入。 +- 不把 HR 高敏个人资料、工资社保、银行流水、源码密钥纳入普通附件区。 +- 不部署、不写公网真实业务数据。 + +## Requirements + +- R1:定义统一附件状态:`draft`、`under_approval`、`pending_review`、`approved`、`archived`、`returned`、`rejected`、`quarantined`。 +- R2:定义统一敏感级别:`public`、`internal`、`confidential`、`restricted`、`index_only`。 +- R3:定义统一附件类型,至少覆盖合同稿/盖章件、发票、回单、报价、验收、交付物、制度/资料、其他。 +- R4:已有报销、采购、付款回单附件可保存并返回状态、敏感级别和附件类型,旧数据有兼容默认值。 +- R5:下载动作必须服务端鉴权,并写审计记录,记录操作者、业务对象、附件、动作、原因或用途。 +- R6:业务详情展示附件治理字段,前端按钮显隐不替代后端鉴权。 +- R7:不改变现有发票 OCR、报销提交、采购附件上传、付款回单上传的主流程。 +- R8:文档、测试和报告不得输出真实敏感原文、完整路径、完整账号、Secret 或原始 payload。 + +## Acceptance Criteria + +- [x] Prisma schema 和迁移包含业务附件治理字段或等效表结构,且 `prisma validate` 通过。 +- [x] 报销附件、采购附件、付款回单附件 API 返回治理字段并兼容旧记录。 +- [x] 下载接口写入可查询的审计记录或复用现有审计日志记录附件下载动作。 +- [x] 前端详情页/附件列表能显示状态、敏感级别、附件类型。 +- [x] 新增或更新针对附件治理字段、下载审计和兼容默认值的后端测试。 +- [x] 前端 typecheck 和 build 通过。 +- [x] Trellis task validate、governance gate、`git diff --check` 通过。 + +## Test Plan + +- `cd server && npm run prisma:validate` +- 运行相关后端单测:报销附件、采购附件、付款回单/付款批次、审计相关测试。 +- `cd client && npm run typecheck && npm run build` +- `python3 ./.trellis/scripts/task.py validate 05-28-business-attachment-governance-v1` +- `node scripts/qa/check-trellis-governance.mjs` +- `git diff --check` + +## Technical Notes + +- 上游设计来源:`.trellis/tasks/05-28-company-docs-oa-ops-mapping/design.md` +- 本轮偏基础设施和现有流程增强,不做公网部署;面向用户可测前仍需另行批准部署和公网检查。 diff --git a/.trellis/tasks/05-28-business-attachment-governance-v1/task.json b/.trellis/tasks/05-28-business-attachment-governance-v1/task.json new file mode 100644 index 00000000..5b8311f9 --- /dev/null +++ b/.trellis/tasks/05-28-business-attachment-governance-v1/task.json @@ -0,0 +1,30 @@ +{ + "id": "business-attachment-governance-v1", + "name": "business-attachment-governance-v1", + "title": "业务附件受控入库 V1", + "description": "为现有报销、采购、付款等业务附件建立状态、敏感级别、类型、复核/归档和下载审计基础。", + "status": "in_progress", + "dev_type": null, + "scope": "backend-frontend", + "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": "05-28-company-docs-oa-ops-mapping", + "relatedFiles": [ + ".trellis/tasks/05-28-business-attachment-governance-v1/prd.md", + ".trellis/tasks/05-28-business-attachment-governance-v1/design.md", + ".trellis/tasks/05-28-company-docs-oa-ops-mapping/design.md" + ], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/client/src/api/paymentBatches.ts b/client/src/api/paymentBatches.ts index 089cd851..601918ac 100644 --- a/client/src/api/paymentBatches.ts +++ b/client/src/api/paymentBatches.ts @@ -1,4 +1,5 @@ import { apiRequestBlob, apiRequestData, type ApiBlobResponse } from "./http"; +import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/attachmentGovernance"; import type { RiskFlag } from "./feeControl"; export type PaymentBatchEntityType = "reimbursement" | "travel_order"; @@ -64,6 +65,7 @@ export type PaymentBatchReceiptAttachment = { mimeType: string; fileSize: number; fileSha256: string; +} & AttachmentGovernanceFields & { uploadedBy: { id: string; employeeNo: string; @@ -162,8 +164,9 @@ export function paymentBatchReceiptAttachmentFileUrl(batchId: string, attachment export async function downloadPaymentBatchReceiptAttachment( batchId: string, attachmentId: string, + reason?: string, ): Promise { - return apiRequestBlob(paymentBatchReceiptAttachmentFileUrl(batchId, attachmentId)); + return apiRequestBlob(buildAttachmentFileUrl(paymentBatchReceiptAttachmentFileUrl(batchId, attachmentId), reason)); } export async function deletePaymentBatchReceiptAttachment(batchId: string, attachmentId: string) { diff --git a/client/src/api/purchaseRequests.ts b/client/src/api/purchaseRequests.ts index 5c95cd6d..5a6496a3 100644 --- a/client/src/api/purchaseRequests.ts +++ b/client/src/api/purchaseRequests.ts @@ -1,4 +1,5 @@ import { apiRequestData } from "./http"; +import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance"; import type { PrecheckResponse, RiskFlag } from "./feeControl"; export type PurchaseRequestStatus = "draft" | "reviewing" | "approved" | "rejected" | "cancelled"; @@ -89,6 +90,8 @@ export type PurchaseAttachment = { originalName: string; mimeType: string; fileSize: number; + fileSha256: string | null; +} & AttachmentGovernanceFields & { uploadedBy: string; createdAt: string | null; uploader: { @@ -383,12 +386,12 @@ export async function deletePurchaseAttachment(purchaseRequestId: string, attach ); } -export async function issuePurchaseAttachmentSignedUrl(purchaseRequestId: string, attachmentId: string) { +export async function issuePurchaseAttachmentSignedUrl(purchaseRequestId: string, attachmentId: string, reason?: string) { return apiRequestData<{ signedUrl: string; expiresAt: string }>( `/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/signed-url`, { method: "POST", - body: JSON.stringify({}), + body: JSON.stringify(reason ? { reason } : {}), }, ); } diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index 9944cd64..f6da7ad6 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -1,5 +1,6 @@ import { apiRequestBlob, apiRequestData } from "./http"; import type { ExpenseStandardCheck, PrecheckResponse, RiskFlag } from "./feeControl"; +import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/attachmentGovernance"; export type ReimbursementStatus = | "draft" @@ -182,6 +183,7 @@ export type ReimbursementDetail = { mimeType: string; fileSize: number; fileSha256: string; + } & AttachmentGovernanceFields & { uploadedBy: { id: string; name: string; @@ -516,8 +518,8 @@ export function reimbursementAttachmentFileUrl(reimbursementId: string, attachme return `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file`; } -export async function downloadReimbursementAttachment(reimbursementId: string, attachmentId: string) { - return apiRequestBlob(reimbursementAttachmentFileUrl(reimbursementId, attachmentId)); +export async function downloadReimbursementAttachment(reimbursementId: string, attachmentId: string, reason?: string) { + return apiRequestBlob(buildAttachmentFileUrl(reimbursementAttachmentFileUrl(reimbursementId, attachmentId), reason)); } diff --git a/client/src/components/common/AttachmentGovernanceBadges.tsx b/client/src/components/common/AttachmentGovernanceBadges.tsx new file mode 100644 index 00000000..dc1d45f1 --- /dev/null +++ b/client/src/components/common/AttachmentGovernanceBadges.tsx @@ -0,0 +1,46 @@ +import type { ComponentPropsWithoutRef } from "react"; +import { + ATTACHMENT_TYPE_LABEL, + GOVERNANCE_STATUS_LABEL, + SENSITIVITY_LEVEL_LABEL, + attachmentRequiresDownloadReason, + type AttachmentGovernanceFields, +} from "../../lib/attachmentGovernance"; +import { cn } from "../../lib/utils"; + +function governanceStatusClass(value: string) { + if (value === "approved" || value === "archived") return "badge-success"; + if (value === "pending_review" || value === "under_approval" || value === "returned") return "badge-warning"; + if (value === "rejected" || value === "quarantined") return "badge-error"; + return "badge-neutral"; +} + +function sensitivityClass(value: string) { + if (value === "restricted") return "badge-error"; + if (value === "confidential") return "badge-warning"; + if (value === "index_only") return "badge-neutral"; + return "badge-neutral"; +} + +export function AttachmentGovernanceBadges({ + attachment, + className, + ...props +}: ComponentPropsWithoutRef<"div"> & { + attachment: Pick; +}) { + return ( +
+ + {GOVERNANCE_STATUS_LABEL[attachment.governanceStatus] ?? attachment.governanceStatus} + + + {ATTACHMENT_TYPE_LABEL[attachment.attachmentType] ?? attachment.attachmentType} + + + {SENSITIVITY_LEVEL_LABEL[attachment.sensitivityLevel] ?? attachment.sensitivityLevel} + + {attachmentRequiresDownloadReason(attachment) ? 需填用途 : null} +
+ ); +} diff --git a/client/src/components/common/useAttachmentDownloadReasonDialog.tsx b/client/src/components/common/useAttachmentDownloadReasonDialog.tsx new file mode 100644 index 00000000..d5f66305 --- /dev/null +++ b/client/src/components/common/useAttachmentDownloadReasonDialog.tsx @@ -0,0 +1,96 @@ +import { useCallback, useRef, useState } from "react"; +import { + attachmentRequiresDownloadReason, + type AttachmentGovernanceFields, +} from "../../lib/attachmentGovernance"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; +import { Textarea } from "../ui/textarea"; + +type DownloadReasonTarget = Pick & { + originalName?: string; +}; + +export function useAttachmentDownloadReasonDialog() { + const [target, setTarget] = useState(null); + const [reason, setReason] = useState("业务复核"); + const [error, setError] = useState(""); + const resolverRef = useRef<((reason: string | null | undefined) => void) | null>(null); + + const closeWith = useCallback((value: string | null | undefined) => { + resolverRef.current?.(value); + resolverRef.current = null; + setTarget(null); + setReason("业务复核"); + setError(""); + }, []); + + const requestDownloadReason = useCallback((attachment: DownloadReasonTarget) => { + if (!attachmentRequiresDownloadReason(attachment)) { + return Promise.resolve(undefined); + } + + setTarget(attachment); + setReason("业务复核"); + setError(""); + return new Promise((resolve) => { + resolverRef.current = resolve; + }); + }, []); + + const confirm = useCallback(() => { + const trimmed = reason.trim(); + if (!trimmed) { + setError("请填写下载用途"); + return; + } + closeWith(trimmed); + }, [closeWith, reason]); + + const downloadReasonDialog = ( + { if (!open) closeWith(null); }}> + + + 填写下载用途 + + {target?.originalName ? `附件:${target.originalName}` : "该附件需要用途留痕后才能下载。"} + + +
+