feat: 文件安全检测接入(列表检测列+悬停详情浮窗,不通过禁用分享传输,文件夹确认后仅分享通过内容,管理端复核/重新检测,检测中自动轮询,开关关闭隐藏检测列)
This commit is contained in:
@@ -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 } });
|
||||
};
|
||||
|
||||
138
src/components/security-status-badge.tsx
Normal file
138
src/components/security-status-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
12
src/hooks/use-security-enabled.ts
Normal file
12
src/hooks/use-security-enabled.ts
Normal 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 };
|
||||
};
|
||||
39
src/routes/(user)/my/-components/SecuritySkipWarning.tsx
Normal file
39
src/routes/(user)/my/-components/SecuritySkipWarning.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
18
src/routes/admin/file-record/-hooks/useReviewFileSecurity.ts
Normal file
18
src/routes/admin/file-record/-hooks/useReviewFileSecurity.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user