实现业务附件受控入库 V1(05-28-business-attachment-governance-v1)

这个提交包含在:
2026-05-28 19:44:15 +08:00
父节点 2c067866ef
当前提交 52e5284954
共修改 25 个文件,包含 1119 行新增和 21 行删除
@@ -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<AttachmentGovernanceFields, "governanceStatus" | "sensitivityLevel" | "attachmentType" | "downloadPolicy">;
}) {
return (
<div className={cn("flex flex-wrap items-center gap-1.5", className)} {...props}>
<span className={governanceStatusClass(attachment.governanceStatus)}>
{GOVERNANCE_STATUS_LABEL[attachment.governanceStatus] ?? attachment.governanceStatus}
</span>
<span className="badge-neutral">
{ATTACHMENT_TYPE_LABEL[attachment.attachmentType] ?? attachment.attachmentType}
</span>
<span className={sensitivityClass(attachment.sensitivityLevel)}>
{SENSITIVITY_LEVEL_LABEL[attachment.sensitivityLevel] ?? attachment.sensitivityLevel}
</span>
{attachmentRequiresDownloadReason(attachment) ? <span className="badge-warning">需填用途</span> : null}
</div>
);
}
@@ -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<AttachmentGovernanceFields, "downloadPolicy" | "sensitivityLevel"> & {
originalName?: string;
};
export function useAttachmentDownloadReasonDialog() {
const [target, setTarget] = useState<DownloadReasonTarget | null>(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<string | null | undefined>((resolve) => {
resolverRef.current = resolve;
});
}, []);
const confirm = useCallback(() => {
const trimmed = reason.trim();
if (!trimmed) {
setError("请填写下载用途");
return;
}
closeWith(trimmed);
}, [closeWith, reason]);
const downloadReasonDialog = (
<Dialog open={Boolean(target)} onOpenChange={(open) => { if (!open) closeWith(null); }}>
<DialogContent className="max-w-md p-5">
<DialogHeader>
<DialogTitle>填写下载用途</DialogTitle>
<DialogDescription>
{target?.originalName ? `附件:${target.originalName}` : "该附件需要用途留痕后才能下载。"}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-2">
<Textarea
value={reason}
onChange={(event) => {
setReason(event.target.value);
if (error) setError("");
}}
aria-label="下载用途"
rows={3}
maxLength={200}
placeholder="例如:付款复核、审批查验、归档核对"
/>
<div className="flex items-center justify-between text-xs">
<span className={error ? "text-red-600" : "text-zinc-500"}>{error || "用途会写入下载审计日志。"}</span>
<span className="text-zinc-400">{reason.trim().length}/200</span>
</div>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => closeWith(null)}>
取消
</Button>
<Button type="button" onClick={confirm}>
继续下载
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
return { requestDownloadReason, downloadReasonDialog };
}