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) => {
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 { 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<any>({
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

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 { 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<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({
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) {
)}
<span className="break-all">{data?.fileName}</span>
</div>
{isFolder && skipCount > 0 && <SecuritySkipWarning check={check} actionName="分享" />}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField

View File

@@ -9,8 +9,10 @@ import {
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { Folder, File } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useTransferFile } from "../-hooks/useTransferFile";
import { FileType } from "@/apis/file";
import { FileType, shareSecurityCheck } from "@/apis/file";
import { SecuritySkipWarning } from "./SecuritySkipWarning";
type TransferConfirmDialogProps = {
open: boolean;
@@ -25,6 +27,17 @@ export function TransferConfirmDialog({
setOpen,
data,
}: 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({
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({
)}
<span className="break-all">{data?.fileName}</span>
</div>
{isFolder && skipCount > 0 && (
<SecuritySkipWarning check={check} actionName="传输" />
)}
</DialogHeader>
<DialogFooter>

View File

@@ -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({
) : (
<>
<Folder size={18} className="text-amber-500 group-hover:hidden" />
<FolderInput
size={18}
className="text-amber-500 group-hover:block hidden"
/>
<FolderInput size={18} className="text-amber-500 group-hover:block hidden" />
</>
)}
<span>{row.getValue("fileName")}</span>
@@ -80,28 +82,45 @@ export function useColumns({
{
accessorKey: "fileSize",
header: "文件大小",
cell: ({ row }: any) => (
<div>{formatFileSize(row.getValue("fileSize"))}</div>
),
cell: ({ row }: any) => <div>{formatFileSize(row.getValue("fileSize"))}</div>,
},
{
accessorKey: "fileCategory",
header: "文件种类",
cell: ({ row }: any) => {
return (
<div>
{row.original.fileType === FileType.FILE
? useFileCategoryName(row.getValue("fileCategory"))
: "-"}
</div>
<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",
enableHiding: false,
header: "操作",
cell: ({ row }: any) => {
const securityBlocked = isSecurityBlocked(row.original);
const securityTip =
row.original?.securityStatus === SecurityScanStatus.REJECTED
? "安全检测未通过,禁止分享与传输"
: "文件正在安全检测中,请稍后再试";
return (
<div className="space-x-2">
<Button
@@ -113,32 +132,25 @@ export function useColumns({
>
编辑
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleShare(row.original)}
>
分享
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleTransfer(row.original)}
>
{isInside ? "传外网" : "传内网"}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleRemove(row.original)}
>
<span title={securityBlocked ? securityTip : undefined}>
<Button size="sm" variant="outline" disabled={securityBlocked} onClick={() => handleShare(row.original)}>
分享
</Button>
</span>
<span title={securityBlocked ? securityTip : undefined}>
<Button
size="sm"
variant="outline"
disabled={securityBlocked}
onClick={() => handleTransfer(row.original)}
>
{isInside ? "传外网" : "传内网"}
</Button>
</span>
<Button size="sm" variant="destructive" onClick={() => handleRemove(row.original)}>
删除
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleMove(row.original)}
>
<Button size="sm" variant="outline" onClick={() => handleMove(row.original)}>
移动
</Button>
{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}
/>
<ShareDialog
open={openShareConfirmDialog}
setOpen={setOpenShareConfirmDialog}
data={currentEntity}
/>
<ShareDialog open={openShareConfirmDialog} setOpen={setOpenShareConfirmDialog} data={currentEntity} />
<TransferConfirmDialog
open={openTransferConfirmDialog}
setOpen={setOpenTransferConfirmDialog}
@@ -287,24 +290,14 @@ function RouteComponent() {
params={params}
refetch={query.refetch}
/>
<FilePreviewDialog
open={openFilePreviewDialog}
setOpen={setOpenFilePreviewDialog}
data={currentEntity}
/>
<FilePreviewDialog open={openFilePreviewDialog} setOpen={setOpenFilePreviewDialog} data={currentEntity} />
<div className="p-4 box-border min-h-full space-y-4">
<div>
<div className="text-lg">我的文件</div>
<div className="text-xs text-black/50">
您可以进行云端文件的管理、分享、内网传输
</div>
<div className="text-xs text-black/50">您可以进行云端文件的管理、分享、内网传输</div>
</div>
<div className="flex items-center gap-2">
<FileCategoryFilter
fileCategory={fileCategory}
setFileCategory={setFileCategory}
reset={reset}
/>
<FileCategoryFilter fileCategory={fileCategory} setFileCategory={setFileCategory} reset={reset} />
<Button
variant="outline"
size="sm"
@@ -329,28 +322,17 @@ function RouteComponent() {
上传文件
</Button>
)}
<Button
variant="default"
onClick={() =>
handleEditFile({ fileType: FileType.DIRECTORY })
}
>
<Button variant="default" onClick={() => handleEditFile({ fileType: FileType.DIRECTORY })}>
<FolderPlus />
创建目录
</Button>
</>
)}
</div>
<DirectoryBreadcrumb
directorys={openDirectorys}
onChangeDirectorys={changeOpenDirectorys}
/>
<DirectoryBreadcrumb directorys={openDirectorys} onChangeDirectorys={changeOpenDirectorys} />
<div className="relative overflow-hidden">
<DataTable table={table} query={query} />
<Pagination
table={table}
className="justify-end p-2 sticky bottom-0 left-0 right-0 z-10 bg-white"
/>
<Pagination table={table} className="justify-end p-2 sticky bottom-0 left-0 right-0 z-10 bg-white" />
</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 { useReactTable } 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({
pageIndex: 0,
@@ -24,6 +24,15 @@ export const useFetchList = (useColumns: any) => {
pageNo: pagination.pageIndex + 1,
}).then((res) => res.data),
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({
data: query?.data?.entityList || [],
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 { Pagination } from "@/components/pagination";
import { FileStatusBadge } from "@/components/file-status-badge";
import { SecurityStatusBadge } from "@/components/security-status-badge";
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 { DateRangePicker } from "@/components/date-range-picker";
import { useSecurityEnabled } from "@/hooks/use-security-enabled";
export const Route = createFileRoute("/admin/file-record/")({
component: RouteComponent,
@@ -36,7 +41,10 @@ const riskLevelOptions: Record<string, { text: string; color: string }> = {
"3": { text: "轻度", color: "bg-emerald-500/10 text-emerald-500" },
};
export function useColumns() {
export function useColumns(
handleSecurityReview: (record?: any) => void,
securityEnabled: boolean,
) {
return [
{
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",
header: "记录时间",
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() {
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);
@@ -163,6 +248,12 @@ function RouteComponent() {
return (
<div className="p-4 box-border min-h-full space-y-4">
<SecurityReviewDialog
open={openReviewDialog}
setOpen={setOpenReviewDialog}
data={currentEntity}
refetch={() => query.refetch()}
/>
<div>
<div className="text-lg">记录查询</div>
<div className="text-xs text-black/50">