From bf63096bb2ae48180274855c5dd3eddfeeee403c Mon Sep 17 00:00:00 2001 From: leietal Date: Sun, 1 Nov 2026 20:29:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=87=E4=BB=B6=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E6=8E=A5=E5=85=A5(=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E5=88=97+=E6=82=AC=E5=81=9C=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E6=B5=AE=E7=AA=97,=E4=B8=8D=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E7=A6=81=E7=94=A8=E5=88=86=E4=BA=AB=E4=BC=A0=E8=BE=93,?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=B9=E7=A1=AE=E8=AE=A4=E5=90=8E=E4=BB=85?= =?UTF-8?q?=E5=88=86=E4=BA=AB=E9=80=9A=E8=BF=87=E5=86=85=E5=AE=B9,?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=AB=AF=E5=A4=8D=E6=A0=B8/=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E6=A3=80=E6=B5=8B,=E6=A3=80=E6=B5=8B=E4=B8=AD?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=BD=AE=E8=AF=A2,=E5=BC=80=E5=85=B3?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E9=9A=90=E8=97=8F=E6=A3=80=E6=B5=8B=E5=88=97?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/file.ts | 31 ++++ src/components/security-status-badge.tsx | 138 ++++++++++++++++ src/hooks/use-fetch-files.ts | 12 +- src/hooks/use-security-enabled.ts | 12 ++ .../my/-components/SecuritySkipWarning.tsx | 39 +++++ .../(user)/my/-components/ShareDialog.tsx | 17 +- .../my/-components/TransferConfirmDialog.tsx | 23 ++- src/routes/(user)/my/index.tsx | 154 ++++++++---------- .../-components/SecurityReviewDialog.tsx | 122 ++++++++++++++ .../admin/file-record/-hooks/useFetchList.ts | 15 +- .../-hooks/useReviewFileSecurity.ts | 18 ++ src/routes/admin/file-record/index.tsx | 95 ++++++++++- 12 files changed, 580 insertions(+), 96 deletions(-) create mode 100644 src/components/security-status-badge.tsx create mode 100644 src/hooks/use-security-enabled.ts create mode 100644 src/routes/(user)/my/-components/SecuritySkipWarning.tsx create mode 100644 src/routes/admin/file-record/-components/SecurityReviewDialog.tsx create mode 100644 src/routes/admin/file-record/-hooks/useReviewFileSecurity.ts diff --git a/src/apis/file.ts b/src/apis/file.ts index c566b32..710fa2c 100644 --- a/src/apis/file.ts +++ b/src/apis/file.ts @@ -168,3 +168,34 @@ export const getShareFileList = ({ shareId, ...params }: any) => { export const getDiffRiskRule = (fileCode: string) => { return http.get(`/file_risk/dify_risk_rule`, { params: { fileCode } }); }; + +// MeshSafe 安全检测状态:1检测中/2通过/3不通过/4检测失败 +export const SecurityScanStatus = { + SCANNING: "1", + PASSED: "2", + REJECTED: "3", + FAILED: "4", +} as const; + +export type SecurityScanStatus = + (typeof SecurityScanStatus)[keyof typeof SecurityScanStatus]; + +// 查询文件最新安全检测结果 +export const getFileSecurityDetail = (fileCode: string) => { + return http.get("/file_security/detail", { params: { fileCode } }); +}; + +// 人工复核修改检测结果(pass通过/reject不通过) +export const reviewFileSecurity = (data: any) => { + return http.post("/file_security/review", data); +}; + +// 重新提交安全检测 +export const rescanFileSecurity = (fileCode: string) => { + return http.post("/file_security/rescan", null, { params: { fileCode } }); +}; + +// 分享/传输预检:统计文件及子文件的安全检测拦截情况 +export const shareSecurityCheck = (userFileId: number) => { + return http.get("/file_security/share_check", { params: { userFileId } }); +}; diff --git a/src/components/security-status-badge.tsx b/src/components/security-status-badge.tsx new file mode 100644 index 0000000..5a30ce8 --- /dev/null +++ b/src/components/security-status-badge.tsx @@ -0,0 +1,138 @@ +import { useMemo, useRef, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import clsx from "clsx"; +import { FileType, SecurityScanStatus } from "@/apis/file"; + +export const securityStatusOptions: Record = { + [SecurityScanStatus.SCANNING]: { + text: "检测中", + color: "bg-amber-500/10 text-amber-500", + }, + [SecurityScanStatus.PASSED]: { + text: "检测通过", + color: "bg-emerald-500/10 text-emerald-500", + }, + [SecurityScanStatus.REJECTED]: { + text: "检测不通过", + color: "bg-red-500/10 text-red-500", + }, + [SecurityScanStatus.FAILED]: { + text: "检测失败", + color: "bg-gray-500/10 text-gray-500", + }, +}; + +// 检测动作说明 +const securityActionOptions: Record = { + pass: "机器判定:通过", + manual_review: "人工复核改判", + reject: "机器判定:不通过", +}; + +type SecurityStatusBadgeProps = { + status?: number | string | null; + reason?: string | null; + action?: string | null; + reviewComment?: string | null; + time?: string | null; + fileType?: string | null; +}; + +// 悬停浮窗展示安全检测全信息(基于Popover受控模式,鼠标可移入浮窗不消失) +export function SecurityStatusBadge({ + status, + reason, + action, + reviewComment, + time, + fileType, +}: SecurityStatusBadgeProps) { + const [open, setOpen] = useState(false); + const closeTimer = useRef(null); + + const show = () => { + window.clearTimeout(closeTimer.current); + setOpen(true); + }; + const hide = () => { + window.clearTimeout(closeTimer.current); + closeTimer.current = window.setTimeout(() => setOpen(false), 150); + }; + + const option = useMemo(() => securityStatusOptions[String(status) as keyof typeof securityStatusOptions], [status]); + + // 文件目录无检测概念,直接显示 -(置于hooks之后,保证hooks调用顺序稳定) + if (fileType === FileType.DIRECTORY) { + return
-
; + } + + if (!option) { + return
未检测
; + } + + // 徽章下方的原因摘要:人工复核优先展示复核说明 + const summary = reviewComment || reason; + const hasDetail = !!(reason || action || reviewComment || time); + + const badgeBlock = ( +
+
+ {option.text} +
+ {summary && ( +
+ {summary} +
+ )} +
+ ); + + if (!hasDetail) { + return badgeBlock; + } + + return ( + + +
+ {badgeBlock} +
+
+ e.preventDefault()} + onMouseEnter={show} + onMouseLeave={hide} + > +
+
+ {option.text} + {action && {securityActionOptions[action] || action}} +
+ {reason && ( +
+
结论说明
+
{reason}
+
+ )} + {reviewComment && ( +
+
人工复核说明
+
{reviewComment}
+
+ )} + {time && ( + <> + +
检测时间:{time}
+ + )} +
+
+
+ ); +} diff --git a/src/hooks/use-fetch-files.ts b/src/hooks/use-fetch-files.ts index 413ae56..66b1994 100644 --- a/src/hooks/use-fetch-files.ts +++ b/src/hooks/use-fetch-files.ts @@ -1,6 +1,6 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { getFilePage } from "@/apis/file"; +import { getFilePage, SecurityScanStatus } from "@/apis/file"; import { useReactTable } from "@tanstack/react-table"; import { getCoreRowModel } from "@tanstack/react-table"; @@ -25,7 +25,7 @@ export const useFetchFiles = ({ pageSize: 10, }); - // 查询数据 + // 查询数据(存在安全检测中的文件时每2秒轮询,检测结论回写后自动刷新状态) const query = useQuery({ queryKey: [...queryKey, pagination, parentId, params], queryFn: () => @@ -35,6 +35,14 @@ export const useFetchFiles = ({ pageNo: pagination.pageIndex + 1, ...params, }).then((res) => res.data), + refetchInterval: (q: any) => { + const list: any[] = q?.state?.data?.entityList || []; + return list.some( + (e) => e.securityStatus === SecurityScanStatus.SCANNING, + ) + ? 2000 + : false; + }, }); // 切换上级ID diff --git a/src/hooks/use-security-enabled.ts b/src/hooks/use-security-enabled.ts new file mode 100644 index 0000000..212b765 --- /dev/null +++ b/src/hooks/use-security-enabled.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { http } from "@/lib/http"; + +// MeshSafe安全检测开关(meshsafe.enabled),关闭时列表隐藏检测列 +export const useSecurityEnabled = () => { + const { data } = useQuery({ + queryKey: ["securityEnabled"], + queryFn: () => http.get("/file_security/enabled").then((res: any) => res.data), + staleTime: 5 * 60 * 1000, + }); + return { securityEnabled: !!data }; +}; diff --git a/src/routes/(user)/my/-components/SecuritySkipWarning.tsx b/src/routes/(user)/my/-components/SecuritySkipWarning.tsx new file mode 100644 index 0000000..25d04f4 --- /dev/null +++ b/src/routes/(user)/my/-components/SecuritySkipWarning.tsx @@ -0,0 +1,39 @@ +import { ShieldAlert } from "lucide-react"; + +type SecuritySkipWarningProps = { + check?: any; + actionName: string; +}; + +// 文件夹内存在检测未通过/检测中文件的提示块,确认后仅分享/传输通过的内容 +export function SecuritySkipWarning({ check, actionName }: SecuritySkipWarningProps) { + const blocked: string[] = check?.blockedFileNames || []; + const scanning: string[] = check?.scanningFileNames || []; + if (!blocked.length && !scanning.length) { + return null; + } + return ( +
+
+ + + 该目录下有 {blocked.length} 个文件安全检测未通过 + {scanning.length > 0 && `、${scanning.length} 个正在检测中`} + ,确认后仅{actionName}检测通过的文件 + +
+
+ {blocked.map((name: string) => ( +
+ • {name} +
+ ))} + {scanning.map((name: string) => ( +
+ • {name}(检测中) +
+ ))} +
+
+ ); +} diff --git a/src/routes/(user)/my/-components/ShareDialog.tsx b/src/routes/(user)/my/-components/ShareDialog.tsx index 87530f7..3d6abf5 100644 --- a/src/routes/(user)/my/-components/ShareDialog.tsx +++ b/src/routes/(user)/my/-components/ShareDialog.tsx @@ -19,10 +19,12 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Spinner } from "@/components/ui/spinner"; import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useShareFile } from "../-hooks/useShareFile"; import { Label } from "@/components/ui/label"; import { Folder, File } from "lucide-react"; -import { FileType } from "@/apis/file"; +import { FileType, shareSecurityCheck } from "@/apis/file"; +import { SecuritySkipWarning } from "./SecuritySkipWarning"; import { Select, SelectContent, @@ -50,6 +52,17 @@ export function ShareDialog({ open, setOpen, data }: ShareDialogProps) { }, }); + const isFolder = data?.fileType === FileType.DIRECTORY; + + // 文件夹分享预检:存在检测未通过/检测中的文件时提示并跳过 + const { data: check } = useQuery({ + queryKey: ["shareSecurityCheck", data?.id, open], + queryFn: () => + shareSecurityCheck(data.id).then((res: any) => res.data), + enabled: open && isFolder, + }); + const skipCount = check?.excludeFileIds?.length || 0; + const { share, shareIsPending } = useShareFile({ onSuccess: () => { setOpen(false); @@ -60,6 +73,7 @@ export function ShareDialog({ open, setOpen, data }: ShareDialogProps) { share({ userFileId: data.id, validDays: values.validDays, + onlyPassed: skipCount > 0, }); }; @@ -83,6 +97,7 @@ export function ShareDialog({ open, setOpen, data }: ShareDialogProps) { )} {data?.fileName} + {isFolder && skipCount > 0 && }
({ + queryKey: ["transferSecurityCheck", data?.id, open], + queryFn: () => + shareSecurityCheck(data.id).then((res: any) => res.data), + enabled: open && isFolder, + }); + const skipCount = check?.excludeFileIds?.length || 0; + const { transfer, transferIsPending } = useTransferFile({ onSuccess() { setOpen(false); @@ -32,7 +45,10 @@ export function TransferConfirmDialog({ }); const onSubmit = () => { - transfer({ userFileId: data?.id }); + transfer({ + userFileId: data?.id, + onlyPassed: skipCount > 0, + }); }; return ( @@ -51,6 +67,9 @@ export function TransferConfirmDialog({ )} {data?.fileName} + {isFolder && skipCount > 0 && ( + + )} diff --git a/src/routes/(user)/my/index.tsx b/src/routes/(user)/my/index.tsx index 5e9af42..784e571 100644 --- a/src/routes/(user)/my/index.tsx +++ b/src/routes/(user)/my/index.tsx @@ -1,11 +1,5 @@ import { createFileRoute, useRouter } from "@tanstack/react-router"; -import { - File, - FileUp, - Folder, - FolderPlus, - FolderInput, -} from "lucide-react"; +import { File, FileUp, Folder, FolderPlus, FolderInput } from "lucide-react"; import { Button } from "@/components/ui/button"; import { EditFileDialog } from "./-components/EditFileDialog"; import { CreateFileDialog } from "./-components/CreateFileDialog"; @@ -14,7 +8,7 @@ import { DirectoryBreadcrumb } from "@/components/directory-breadcrumb"; import { useMemo, useState } from "react"; import { ShareDialog } from "./-components/ShareDialog"; import { TransferConfirmDialog } from "./-components/TransferConfirmDialog"; -import { downloadFile, FileStatus, FileType, RootType } from "@/apis/file"; +import { downloadFile, FileStatus, FileType, RootType, SecurityScanStatus } from "@/apis/file"; import { formatFileSize } from "@/lib/utils"; import { MoveFileDialog } from "@/components/move-file-dialog"; import { useFetchFiles } from "@/hooks/use-fetch-files"; @@ -24,9 +18,19 @@ import { FilePreviewDialog } from "@/components/file-preview-dialog"; import { useFileCategoryName } from "@/hooks/use-file-category-name"; import { FileCategoryFilter } from "@/components/file-category-filter"; import { useUploadConfig } from "@/hooks/use-upload-config"; +import { useSecurityEnabled } from "@/hooks/use-security-enabled"; +import { SecurityStatusBadge } from "@/components/security-status-badge"; const isInside = import.meta.env.VITE_IS_INSIDE === "true"; +// 检测不通过/检测中的文件禁止分享传输(下载预览不受限),与后端校验一致 +export function isSecurityBlocked(record: any) { + return ( + record?.fileType === FileType.FILE && + (record?.securityStatus === SecurityScanStatus.REJECTED || record?.securityStatus === SecurityScanStatus.SCANNING) + ); +} + export const Route = createFileRoute("/(user)/my/")({ component: RouteComponent, }); @@ -39,6 +43,7 @@ export function useColumns({ handleTransfer, handleMove, handleFilePreview, + securityEnabled, }: any) { return [ { @@ -62,10 +67,7 @@ export function useColumns({ ) : ( <> - + )} {row.getValue("fileName")} @@ -80,28 +82,45 @@ export function useColumns({ { accessorKey: "fileSize", header: "文件大小", - cell: ({ row }: any) => ( -
{formatFileSize(row.getValue("fileSize"))}
- ), + cell: ({ row }: any) =>
{formatFileSize(row.getValue("fileSize"))}
, }, { accessorKey: "fileCategory", header: "文件种类", cell: ({ row }: any) => { return ( -
- {row.original.fileType === FileType.FILE - ? useFileCategoryName(row.getValue("fileCategory")) - : "-"} -
+
{row.original.fileType === FileType.FILE ? useFileCategoryName(row.getValue("fileCategory")) : "-"}
); }, }, + ...(securityEnabled + ? [ + { + accessorKey: "securityStatus", + header: "安全检测", + cell: ({ row }: any) => ( + + ), + }, + ] + : []), { id: "actions", enableHiding: false, header: "操作", cell: ({ row }: any) => { + const securityBlocked = isSecurityBlocked(row.original); + const securityTip = + row.original?.securityStatus === SecurityScanStatus.REJECTED + ? "安全检测未通过,禁止分享与传输" + : "文件正在安全检测中,请稍后再试"; return (
- - - + + + + + - {row.original?.fileType === FileType.FILE && ( @@ -161,6 +173,8 @@ export function useColumns({ function RouteComponent() { const router = useRouter(); + // MeshSafe安全检测开关:关闭时隐藏检测列 + const { securityEnabled } = useSecurityEnabled(); // 机构上传配置:关闭上传时隐藏上传入口 const { data: uploadConfig } = useUploadConfig(); const uploadDisabled = !!uploadConfig && uploadConfig.uploadEnabled === false; @@ -196,8 +210,7 @@ function RouteComponent() { }; // 传输 - const [openTransferConfirmDialog, setOpenTransferConfirmDialog] = - useState(false); + const [openTransferConfirmDialog, setOpenTransferConfirmDialog] = useState(false); const handleTransfer = (record?: any) => { setCurrentEntity(record); setOpenTransferConfirmDialog(true); @@ -226,16 +239,10 @@ function RouteComponent() { }; }, [fileCategory]); - const { - openDirectorys, - changeOpenDirectorys, - parentId, - table, - query, - reset, - } = useFetchFiles({ + const { openDirectorys, changeOpenDirectorys, parentId, table, query, reset } = useFetchFiles({ useColumns: (handleChangeParentId: any) => useColumns({ + securityEnabled, handleChangeParentId, handleEditFile, handleRemove, @@ -270,11 +277,7 @@ function RouteComponent() { data={currentEntity} refetch={query.refetch} /> - + - +
我的文件
-
- 您可以进行云端文件的管理、分享、内网传输 -
+
您可以进行云端文件的管理、分享、内网传输
- + )}
- +
- +
diff --git a/src/routes/admin/file-record/-components/SecurityReviewDialog.tsx b/src/routes/admin/file-record/-components/SecurityReviewDialog.tsx new file mode 100644 index 0000000..6e5bf7b --- /dev/null +++ b/src/routes/admin/file-record/-components/SecurityReviewDialog.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Spinner } from "@/components/ui/spinner"; +import { File, Folder } from "lucide-react"; +import { toast } from "sonner"; +import { FileType } from "@/apis/file"; +import { useReviewFileSecurity } from "../-hooks/useReviewFileSecurity"; + +type SecurityReviewDialogProps = { + open: boolean; + setOpen: (open: boolean) => void; + data?: any; + refetch?: () => void; +}; + +// 人工复核修改安全检测结果(避免误测导致文件不能分享) +export function SecurityReviewDialog({ + open, + setOpen, + data, + refetch, +}: SecurityReviewDialogProps) { + const [decision, setDecision] = useState("pass"); + const [comment, setComment] = useState(""); + const { reviewFileSecurityMutate, reviewIsPending } = + useReviewFileSecurity(); + + useEffect(() => { + if (open) { + setDecision("pass"); + setComment(""); + } + }, [open]); + + const onSubmit = () => { + if (!comment.trim()) { + toast.error("请填写复核说明"); + return; + } + reviewFileSecurityMutate({ + fileCode: data?.fileCode, + decision, + comment: comment.trim(), + }).then(() => { + toast.success("复核结果已保存"); + refetch?.(); + setOpen(false); + }); + }; + + return ( + + + + 安全检测复核 +
+ {data?.fileType === FileType.FILE ? ( + + ) : ( + + )} + {data?.fileName} +
+ {data?.securityReason && ( +
+ 最近检测结论:{data.securityReason} +
+ )} +
+ +
+
+
复核结论
+ +
+
+
复核说明(必填)
+