实现业务附件受控入库 V1(05-28-business-attachment-governance-v1)
这个提交包含在:
@@ -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 };
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户