feat: 文件安全检测接入(列表检测列+悬停详情浮窗,不通过禁用分享传输,文件夹确认后仅分享通过内容,管理端复核/重新检测,检测中自动轮询,开关关闭隐藏检测列)

This commit is contained in:
2026-11-01 20:29:42 +08:00
parent 45b62ba131
commit bf63096bb2
12 changed files with 580 additions and 96 deletions

View File

@@ -168,3 +168,34 @@ export const getShareFileList = ({ shareId, ...params }: any) => {
export const getDiffRiskRule = (fileCode: string) => { export const getDiffRiskRule = (fileCode: string) => {
return http.get(`/file_risk/dify_risk_rule`, { params: { fileCode } }); 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 } });
};

View File

@@ -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<string, { text: string; color: string }> = {
[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<string, string> = {
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<any>(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 <div>-</div>;
}
if (!option) {
return <div className="text-black/40">未检测</div>;
}
// 徽章下方的原因摘要:人工复核优先展示复核说明
const summary = reviewComment || reason;
const hasDetail = !!(reason || action || reviewComment || time);
const badgeBlock = (
<div className="space-y-1 cursor-default overflow-hidden">
<div className="flex items-center gap-1">
<Badge className={clsx(option.color)}>{option.text}</Badge>
</div>
{summary && (
<div className="text-xs text-black/50 truncate" title={undefined}>
{summary}
</div>
)}
</div>
);
if (!hasDetail) {
return badgeBlock;
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div onMouseEnter={show} onMouseLeave={hide}>
{badgeBlock}
</div>
</PopoverTrigger>
<PopoverContent
side="left"
align="center"
className="w-80 p-0"
onOpenAutoFocus={(e: any) => e.preventDefault()}
onMouseEnter={show}
onMouseLeave={hide}
>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<Badge className={clsx(option.color)}>{option.text}</Badge>
{action && <span className="text-xs text-black/45">{securityActionOptions[action] || action}</span>}
</div>
{reason && (
<div>
<div className="text-xs text-black/40 mb-1">结论说明</div>
<div className="text-sm leading-relaxed break-all whitespace-pre-wrap">{reason}</div>
</div>
)}
{reviewComment && (
<div>
<div className="text-xs text-black/40 mb-1">人工复核说明</div>
<div className="text-sm leading-relaxed break-all">{reviewComment}</div>
</div>
)}
{time && (
<>
<Separator />
<div className="text-xs text-black/40">检测时间:{time}</div>
</>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getFilePage } from "@/apis/file"; import { getFilePage, SecurityScanStatus } from "@/apis/file";
import { useReactTable } from "@tanstack/react-table"; import { useReactTable } from "@tanstack/react-table";
import { getCoreRowModel } from "@tanstack/react-table"; import { getCoreRowModel } from "@tanstack/react-table";
@@ -25,7 +25,7 @@ export const useFetchFiles = ({
pageSize: 10, pageSize: 10,
}); });
// 查询数据 // 查询数据(存在安全检测中的文件时每2秒轮询,检测结论回写后自动刷新状态)
const query = useQuery<any>({ const query = useQuery<any>({
queryKey: [...queryKey, pagination, parentId, params], queryKey: [...queryKey, pagination, parentId, params],
queryFn: () => queryFn: () =>
@@ -35,6 +35,14 @@ export const useFetchFiles = ({
pageNo: pagination.pageIndex + 1, pageNo: pagination.pageIndex + 1,
...params, ...params,
}).then((res) => res.data), }).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 // 切换上级ID

View File

@@ -0,0 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { http } from "@/lib/http";
// MeshSafe安全检测开关(meshsafe.enabled),关闭时列表隐藏检测列
export const useSecurityEnabled = () => {
const { data } = useQuery<boolean>({
queryKey: ["securityEnabled"],
queryFn: () => http.get("/file_security/enabled").then((res: any) => res.data),
staleTime: 5 * 60 * 1000,
});
return { securityEnabled: !!data };
};

View File

@@ -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 (
<div className="rounded-md border border-red-200 bg-red-500/5 p-2.5 space-y-1.5">
<div className="flex items-start gap-1.5 text-xs text-red-500 leading-relaxed">
<ShieldAlert size={14} className="flex-none mt-0.5" />
<span>
该目录下有 {blocked.length} 个文件安全检测未通过
{scanning.length > 0 && `、${scanning.length} 个正在检测中`}
,确认后仅{actionName}检测通过的文件
</span>
</div>
<div className="max-h-24 overflow-auto space-y-0.5 pl-5">
{blocked.map((name: string) => (
<div key={name} className="text-xs text-red-400 truncate">
• {name}
</div>
))}
{scanning.map((name: string) => (
<div key={name} className="text-xs text-amber-500 truncate">
• {name}(检测中)
</div>
))}
</div>
</div>
);
}

View File

@@ -19,10 +19,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useEffect } from "react"; import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useShareFile } from "../-hooks/useShareFile"; import { useShareFile } from "../-hooks/useShareFile";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Folder, File } from "lucide-react"; import { Folder, File } from "lucide-react";
import { FileType } from "@/apis/file"; import { FileType, shareSecurityCheck } from "@/apis/file";
import { SecuritySkipWarning } from "./SecuritySkipWarning";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -50,6 +52,17 @@ export function ShareDialog({ open, setOpen, data }: ShareDialogProps) {
}, },
}); });
const isFolder = data?.fileType === FileType.DIRECTORY;
// 文件夹分享预检:存在检测未通过/检测中的文件时提示并跳过
const { data: check } = useQuery<any>({
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({ const { share, shareIsPending } = useShareFile({
onSuccess: () => { onSuccess: () => {
setOpen(false); setOpen(false);
@@ -60,6 +73,7 @@ export function ShareDialog({ open, setOpen, data }: ShareDialogProps) {
share({ share({
userFileId: data.id, userFileId: data.id,
validDays: values.validDays, validDays: values.validDays,
onlyPassed: skipCount > 0,
}); });
}; };
@@ -83,6 +97,7 @@ export function ShareDialog({ open, setOpen, data }: ShareDialogProps) {
)} )}
<span className="break-all">{data?.fileName}</span> <span className="break-all">{data?.fileName}</span>
</div> </div>
{isFolder && skipCount > 0 && <SecuritySkipWarning check={check} actionName="分享" />}
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField

View File

@@ -9,8 +9,10 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Folder, File } from "lucide-react"; import { Folder, File } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useTransferFile } from "../-hooks/useTransferFile"; import { useTransferFile } from "../-hooks/useTransferFile";
import { FileType } from "@/apis/file"; import { FileType, shareSecurityCheck } from "@/apis/file";
import { SecuritySkipWarning } from "./SecuritySkipWarning";
type TransferConfirmDialogProps = { type TransferConfirmDialogProps = {
open: boolean; open: boolean;
@@ -25,6 +27,17 @@ export function TransferConfirmDialog({
setOpen, setOpen,
data, data,
}: TransferConfirmDialogProps) { }: TransferConfirmDialogProps) {
const isFolder = data?.fileType === FileType.DIRECTORY;
// 文件夹传输预检:存在检测未通过/检测中的文件时提示并跳过
const { data: check } = useQuery<any>({
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({ const { transfer, transferIsPending } = useTransferFile({
onSuccess() { onSuccess() {
setOpen(false); setOpen(false);
@@ -32,7 +45,10 @@ export function TransferConfirmDialog({
}); });
const onSubmit = () => { const onSubmit = () => {
transfer({ userFileId: data?.id }); transfer({
userFileId: data?.id,
onlyPassed: skipCount > 0,
});
}; };
return ( return (
@@ -51,6 +67,9 @@ export function TransferConfirmDialog({
)} )}
<span className="break-all">{data?.fileName}</span> <span className="break-all">{data?.fileName}</span>
</div> </div>
{isFolder && skipCount > 0 && (
<SecuritySkipWarning check={check} actionName="传输" />
)}
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>

View File

@@ -1,11 +1,5 @@
import { createFileRoute, useRouter } from "@tanstack/react-router"; import { createFileRoute, useRouter } from "@tanstack/react-router";
import { import { File, FileUp, Folder, FolderPlus, FolderInput } from "lucide-react";
File,
FileUp,
Folder,
FolderPlus,
FolderInput,
} from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { EditFileDialog } from "./-components/EditFileDialog"; import { EditFileDialog } from "./-components/EditFileDialog";
import { CreateFileDialog } from "./-components/CreateFileDialog"; import { CreateFileDialog } from "./-components/CreateFileDialog";
@@ -14,7 +8,7 @@ import { DirectoryBreadcrumb } from "@/components/directory-breadcrumb";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { ShareDialog } from "./-components/ShareDialog"; import { ShareDialog } from "./-components/ShareDialog";
import { TransferConfirmDialog } from "./-components/TransferConfirmDialog"; 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 { formatFileSize } from "@/lib/utils";
import { MoveFileDialog } from "@/components/move-file-dialog"; import { MoveFileDialog } from "@/components/move-file-dialog";
import { useFetchFiles } from "@/hooks/use-fetch-files"; 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 { useFileCategoryName } from "@/hooks/use-file-category-name";
import { FileCategoryFilter } from "@/components/file-category-filter"; import { FileCategoryFilter } from "@/components/file-category-filter";
import { useUploadConfig } from "@/hooks/use-upload-config"; 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"; 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/")({ export const Route = createFileRoute("/(user)/my/")({
component: RouteComponent, component: RouteComponent,
}); });
@@ -39,6 +43,7 @@ export function useColumns({
handleTransfer, handleTransfer,
handleMove, handleMove,
handleFilePreview, handleFilePreview,
securityEnabled,
}: any) { }: any) {
return [ return [
{ {
@@ -62,10 +67,7 @@ export function useColumns({
) : ( ) : (
<> <>
<Folder size={18} className="text-amber-500 group-hover:hidden" /> <Folder size={18} className="text-amber-500 group-hover:hidden" />
<FolderInput <FolderInput size={18} className="text-amber-500 group-hover:block hidden" />
size={18}
className="text-amber-500 group-hover:block hidden"
/>
</> </>
)} )}
<span>{row.getValue("fileName")}</span> <span>{row.getValue("fileName")}</span>
@@ -80,28 +82,45 @@ export function useColumns({
{ {
accessorKey: "fileSize", accessorKey: "fileSize",
header: "文件大小", header: "文件大小",
cell: ({ row }: any) => ( cell: ({ row }: any) => <div>{formatFileSize(row.getValue("fileSize"))}</div>,
<div>{formatFileSize(row.getValue("fileSize"))}</div>
),
}, },
{ {
accessorKey: "fileCategory", accessorKey: "fileCategory",
header: "文件种类", header: "文件种类",
cell: ({ row }: any) => { cell: ({ row }: any) => {
return ( return (
<div> <div>{row.original.fileType === FileType.FILE ? useFileCategoryName(row.getValue("fileCategory")) : "-"}</div>
{row.original.fileType === FileType.FILE
? useFileCategoryName(row.getValue("fileCategory"))
: "-"}
</div>
); );
}, },
}, },
...(securityEnabled
? [
{
accessorKey: "securityStatus",
header: "安全检测",
cell: ({ row }: any) => (
<SecurityStatusBadge
status={row.original.securityStatus}
reason={row.original.securityReason}
action={row.original.securityAction}
reviewComment={row.original.securityReviewComment}
time={row.original.securityTime}
fileType={row.original.fileType}
/>
),
},
]
: []),
{ {
id: "actions", id: "actions",
enableHiding: false, enableHiding: false,
header: "操作", header: "操作",
cell: ({ row }: any) => { cell: ({ row }: any) => {
const securityBlocked = isSecurityBlocked(row.original);
const securityTip =
row.original?.securityStatus === SecurityScanStatus.REJECTED
? "安全检测未通过,禁止分享与传输"
: "文件正在安全检测中,请稍后再试";
return ( return (
<div className="space-x-2"> <div className="space-x-2">
<Button <Button
@@ -113,32 +132,25 @@ export function useColumns({
> >
编辑 编辑
</Button> </Button>
<Button <span title={securityBlocked ? securityTip : undefined}>
size="sm" <Button size="sm" variant="outline" disabled={securityBlocked} onClick={() => handleShare(row.original)}>
variant="outline"
onClick={() => handleShare(row.original)}
>
分享 分享
</Button> </Button>
</span>
<span title={securityBlocked ? securityTip : undefined}>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
disabled={securityBlocked}
onClick={() => handleTransfer(row.original)} onClick={() => handleTransfer(row.original)}
> >
{isInside ? "传外网" : "传内网"} {isInside ? "传外网" : "传内网"}
</Button> </Button>
<Button </span>
size="sm" <Button size="sm" variant="destructive" onClick={() => handleRemove(row.original)}>
variant="destructive"
onClick={() => handleRemove(row.original)}
>
删除 删除
</Button> </Button>
<Button <Button size="sm" variant="outline" onClick={() => handleMove(row.original)}>
size="sm"
variant="outline"
onClick={() => handleMove(row.original)}
>
移动 移动
</Button> </Button>
{row.original?.fileType === FileType.FILE && ( {row.original?.fileType === FileType.FILE && (
@@ -161,6 +173,8 @@ export function useColumns({
function RouteComponent() { function RouteComponent() {
const router = useRouter(); const router = useRouter();
// MeshSafe安全检测开关:关闭时隐藏检测列
const { securityEnabled } = useSecurityEnabled();
// 机构上传配置:关闭上传时隐藏上传入口 // 机构上传配置:关闭上传时隐藏上传入口
const { data: uploadConfig } = useUploadConfig(); const { data: uploadConfig } = useUploadConfig();
const uploadDisabled = !!uploadConfig && uploadConfig.uploadEnabled === false; const uploadDisabled = !!uploadConfig && uploadConfig.uploadEnabled === false;
@@ -196,8 +210,7 @@ function RouteComponent() {
}; };
// 传输 // 传输
const [openTransferConfirmDialog, setOpenTransferConfirmDialog] = const [openTransferConfirmDialog, setOpenTransferConfirmDialog] = useState(false);
useState(false);
const handleTransfer = (record?: any) => { const handleTransfer = (record?: any) => {
setCurrentEntity(record); setCurrentEntity(record);
setOpenTransferConfirmDialog(true); setOpenTransferConfirmDialog(true);
@@ -226,16 +239,10 @@ function RouteComponent() {
}; };
}, [fileCategory]); }, [fileCategory]);
const { const { openDirectorys, changeOpenDirectorys, parentId, table, query, reset } = useFetchFiles({
openDirectorys,
changeOpenDirectorys,
parentId,
table,
query,
reset,
} = useFetchFiles({
useColumns: (handleChangeParentId: any) => useColumns: (handleChangeParentId: any) =>
useColumns({ useColumns({
securityEnabled,
handleChangeParentId, handleChangeParentId,
handleEditFile, handleEditFile,
handleRemove, handleRemove,
@@ -270,11 +277,7 @@ function RouteComponent() {
data={currentEntity} data={currentEntity}
refetch={query.refetch} refetch={query.refetch}
/> />
<ShareDialog <ShareDialog open={openShareConfirmDialog} setOpen={setOpenShareConfirmDialog} data={currentEntity} />
open={openShareConfirmDialog}
setOpen={setOpenShareConfirmDialog}
data={currentEntity}
/>
<TransferConfirmDialog <TransferConfirmDialog
open={openTransferConfirmDialog} open={openTransferConfirmDialog}
setOpen={setOpenTransferConfirmDialog} setOpen={setOpenTransferConfirmDialog}
@@ -287,24 +290,14 @@ function RouteComponent() {
params={params} params={params}
refetch={query.refetch} refetch={query.refetch}
/> />
<FilePreviewDialog <FilePreviewDialog open={openFilePreviewDialog} setOpen={setOpenFilePreviewDialog} data={currentEntity} />
open={openFilePreviewDialog}
setOpen={setOpenFilePreviewDialog}
data={currentEntity}
/>
<div className="p-4 box-border min-h-full space-y-4"> <div className="p-4 box-border min-h-full space-y-4">
<div> <div>
<div className="text-lg">我的文件</div> <div className="text-lg">我的文件</div>
<div className="text-xs text-black/50"> <div className="text-xs text-black/50">您可以进行云端文件的管理、分享、内网传输</div>
您可以进行云端文件的管理、分享、内网传输
</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileCategoryFilter <FileCategoryFilter fileCategory={fileCategory} setFileCategory={setFileCategory} reset={reset} />
fileCategory={fileCategory}
setFileCategory={setFileCategory}
reset={reset}
/>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -329,28 +322,17 @@ function RouteComponent() {
上传文件 上传文件
</Button> </Button>
)} )}
<Button <Button variant="default" onClick={() => handleEditFile({ fileType: FileType.DIRECTORY })}>
variant="default"
onClick={() =>
handleEditFile({ fileType: FileType.DIRECTORY })
}
>
<FolderPlus /> <FolderPlus />
创建目录 创建目录
</Button> </Button>
</> </>
)} )}
</div> </div>
<DirectoryBreadcrumb <DirectoryBreadcrumb directorys={openDirectorys} onChangeDirectorys={changeOpenDirectorys} />
directorys={openDirectorys}
onChangeDirectorys={changeOpenDirectorys}
/>
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
<DataTable table={table} query={query} /> <DataTable table={table} query={query} />
<Pagination <Pagination table={table} className="justify-end p-2 sticky bottom-0 left-0 right-0 z-10 bg-white" />
table={table}
className="justify-end p-2 sticky bottom-0 left-0 right-0 z-10 bg-white"
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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<string>("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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-[460px]">
<DialogHeader>
<DialogTitle>安全检测复核</DialogTitle>
<div className="mt-1 flex items-center gap-1 text-sm p-2 rounded-md bg-gray-50">
{data?.fileType === FileType.FILE ? (
<File size={16} className="text-primary flex-none" />
) : (
<Folder size={16} className="text-amber-500 flex-none" />
)}
<span className="break-all">{data?.fileName}</span>
</div>
{data?.securityReason && (
<div className="text-xs text-black/50 break-all">
最近检测结论:{data.securityReason}
</div>
)}
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<div className="text-sm">复核结论</div>
<Select value={decision} onValueChange={setDecision}>
<SelectTrigger className="w-full">
<SelectValue placeholder="请选择复核结论" />
</SelectTrigger>
<SelectContent>
<SelectItem value="pass">通过(允许分享/传输)</SelectItem>
<SelectItem value="reject">不通过(禁止分享/传输)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<div className="text-sm">复核说明(必填)</div>
<Textarea
placeholder="请填写复核说明,例如:经人工查验为误报"
value={comment}
onChange={(e: any) => setComment(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">取消</Button>
</DialogClose>
<Button type="submit" disabled={reviewIsPending} onClick={onSubmit}>
{reviewIsPending && <Spinner />}确定
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,9 +2,9 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useReactTable } from "@tanstack/react-table"; import { useReactTable } from "@tanstack/react-table";
import { getCoreRowModel } from "@tanstack/react-table"; import { getCoreRowModel } from "@tanstack/react-table";
import { getFileRecordPageByPhone } from "@/apis/file"; import { getFileRecordPageByPhone, SecurityScanStatus } from "@/apis/file";
export const useFetchList = (useColumns: any) => { export const useFetchList = (useColumns: any, ...columnArgs: any[]) => {
// 分页 // 分页
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
pageIndex: 0, pageIndex: 0,
@@ -24,6 +24,15 @@ export const useFetchList = (useColumns: any) => {
pageNo: pagination.pageIndex + 1, pageNo: pagination.pageIndex + 1,
}).then((res) => res.data), }).then((res) => res.data),
enabled: !!params.phone, enabled: !!params.phone,
// 存在安全检测中的记录时每2秒轮询,检测结论回写后自动刷新状态
refetchInterval: (q: any) => {
const list: any[] = q?.state?.data?.entityList || [];
return list.some(
(e) => e.securityStatus === SecurityScanStatus.SCANNING,
)
? 2000
: false;
},
}); });
// 查询:应用条件并回到第一页(过滤掉空值条件) // 查询:应用条件并回到第一页(过滤掉空值条件)
@@ -39,7 +48,7 @@ export const useFetchList = (useColumns: any) => {
}; };
// 表格 // 表格
const columns = useColumns(); const columns = useColumns(...columnArgs);
const table = useReactTable({ const table = useReactTable({
data: query?.data?.entityList || [], data: query?.data?.entityList || [],
columns, columns,

View File

@@ -0,0 +1,18 @@
import { useMutation } from "@tanstack/react-query";
import { reviewFileSecurity, rescanFileSecurity } from "@/apis/file";
// 人工复核修改安全检测结果
export const useReviewFileSecurity = () => {
const { mutateAsync: reviewFileSecurityMutate, isPending: reviewIsPending } =
useMutation({ mutationFn: reviewFileSecurity });
const { mutateAsync: rescanMutate, isPending: rescanIsPending } =
useMutation({ mutationFn: (fileCode: string) => rescanFileSecurity(fileCode) });
return {
reviewFileSecurityMutate,
reviewIsPending,
rescanMutate,
rescanIsPending,
};
};

View File

@@ -14,9 +14,14 @@ import {
import { DataTable } from "@/components/data-table"; import { DataTable } from "@/components/data-table";
import { Pagination } from "@/components/pagination"; import { Pagination } from "@/components/pagination";
import { FileStatusBadge } from "@/components/file-status-badge"; import { FileStatusBadge } from "@/components/file-status-badge";
import { SecurityStatusBadge } from "@/components/security-status-badge";
import { formatFileSize } from "@/lib/utils"; import { formatFileSize } from "@/lib/utils";
import { SecurityScanStatus, FileType, rescanFileSecurity } from "@/apis/file";
import { toast } from "sonner";
import { SecurityReviewDialog } from "./-components/SecurityReviewDialog";
import { useFetchList } from "./-hooks/useFetchList"; import { useFetchList } from "./-hooks/useFetchList";
import { DateRangePicker } from "@/components/date-range-picker"; import { DateRangePicker } from "@/components/date-range-picker";
import { useSecurityEnabled } from "@/hooks/use-security-enabled";
export const Route = createFileRoute("/admin/file-record/")({ export const Route = createFileRoute("/admin/file-record/")({
component: RouteComponent, component: RouteComponent,
@@ -36,7 +41,10 @@ const riskLevelOptions: Record<string, { text: string; color: string }> = {
"3": { text: "轻度", color: "bg-emerald-500/10 text-emerald-500" }, "3": { text: "轻度", color: "bg-emerald-500/10 text-emerald-500" },
}; };
export function useColumns() { export function useColumns(
handleSecurityReview: (record?: any) => void,
securityEnabled: boolean,
) {
return [ return [
{ {
accessorKey: "recordType", accessorKey: "recordType",
@@ -117,11 +125,75 @@ export function useColumns() {
); );
}, },
}, },
...(securityEnabled
? [
{
accessorKey: "securityStatus",
header: "安全检测",
cell: ({ row }: any) => (
<SecurityStatusBadge
status={row.original.securityStatus}
reason={row.original.securityReason}
action={row.original.securityAction}
reviewComment={row.original.securityReviewComment}
time={row.original.securityTime}
fileType={row.original.fileType}
/>
),
},
]
: []),
{ {
accessorKey: "createTime", accessorKey: "createTime",
header: "记录时间", header: "记录时间",
cell: ({ row }: any) => <div>{row.getValue("createTime")}</div>, cell: ({ row }: any) => <div>{row.getValue("createTime")}</div>,
}, },
...(securityEnabled
? [
{
id: "actions",
enableHiding: false,
header: "操作",
cell: ({ row }: any) => {
const record = row.original;
if (record.fileType !== FileType.FILE || !record.fileCode) {
return <div>-</div>;
}
const scanning =
record.securityStatus === SecurityScanStatus.SCANNING;
return (
<div className="space-x-2">
{record.securityStatus && (
<Button
size="sm"
variant="outline"
disabled={scanning}
title={scanning ? "文件正在安全检测中" : undefined}
onClick={() => handleSecurityReview(record)}
>
复核
</Button>
)}
<Button
size="sm"
variant="outline"
disabled={scanning}
title={scanning ? "文件正在安全检测中" : undefined}
onClick={() => {
toast.promise(rescanFileSecurity(record.fileCode), {
loading: "正在重新提交检测...",
success: "已重新提交安全检测",
});
}}
>
重新检测
</Button>
</div>
);
},
},
]
: []),
]; ];
} }
@@ -136,7 +208,20 @@ const initialFilters = {
}; };
function RouteComponent() { function RouteComponent() {
const { table, query, search, params } = useFetchList(useColumns); // 复核弹窗
const [currentEntity, setCurrentEntity] = useState(null);
const [openReviewDialog, setOpenReviewDialog] = useState(false);
const handleSecurityReview = (record?: any) => {
setCurrentEntity(record);
setOpenReviewDialog(true);
};
const { securityEnabled } = useSecurityEnabled();
const { table, query, search, params } = useFetchList(
useColumns,
handleSecurityReview,
securityEnabled,
);
// 筛选条件(点击查询后生效) // 筛选条件(点击查询后生效)
const [filters, setFilters] = useState<any>(initialFilters); const [filters, setFilters] = useState<any>(initialFilters);
@@ -163,6 +248,12 @@ function RouteComponent() {
return ( return (
<div className="p-4 box-border min-h-full space-y-4"> <div className="p-4 box-border min-h-full space-y-4">
<SecurityReviewDialog
open={openReviewDialog}
setOpen={setOpenReviewDialog}
data={currentEntity}
refetch={() => query.refetch()}
/>
<div> <div>
<div className="text-lg">记录查询</div> <div className="text-lg">记录查询</div>
<div className="text-xs text-black/50"> <div className="text-xs text-black/50">