feat: 管理后台增强——用户启停操作、shadcn日期区间/后缀标签组件
1. 用户管理页:新增启用状态列与启用/禁用操作按钮(对接 user/enable|disable) 2. 机构账号/记录查询页:时间区间改用官方 shadcn Range Picker 模式 (Popover+Calendar mode=range 双月面板,新增 components/date-range-picker.tsx) 3. 上传配置页:文件类型限制改为标签式输入(新增 suffix-tag-input.tsx, Badge标签+回车添加+常用后缀快捷点选),替换自由文本框 4. 修复:.env 的 VITE_APP_TITLE 加引号,规避 Bun 1.3.10 env 解析 对行尾多字节中文的截断(标题乱码"森睿智�") 5. 新增共享 hook hooks/use-user-status-actions.ts(从 org-account 迁出) 6. vite 代理保留 本地Docker(localhost:8080)/公司 两种目标,切换注释即可
This commit is contained in:
150
src/routes/(user)/-components/ChartPieDonutText.tsx
Normal file
150
src/routes/(user)/-components/ChartPieDonutText.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Label, Pie, PieChart, Sector } from "recharts";
|
||||
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { useMemo } from "react";
|
||||
import type { PieSectorDataItem } from "recharts/types/polar/Pie";
|
||||
|
||||
// 总量
|
||||
const total = 10 * 1024 * 1024 * 1024;
|
||||
|
||||
export const CustomTooltip = ({ active, payload }: any) => {
|
||||
const isVisible = active && payload && payload.length;
|
||||
const data = payload?.[0]?.payload;
|
||||
return (
|
||||
<div
|
||||
className="p-4 bg-white/50 text-black backdrop-blur-lg rounded-md shadow z-10 border"
|
||||
style={{
|
||||
visibility: isVisible ? "visible" : "hidden",
|
||||
borderColor: isVisible ? data?.fill : "transparent",
|
||||
}}
|
||||
>
|
||||
{isVisible && (
|
||||
<div className="flex items-center gap-2 min-w-[150px]">
|
||||
<span
|
||||
className="flex-none inline-block h-4 w-4 rounded-full bg-black"
|
||||
style={{ backgroundColor: data.fill }}
|
||||
/>
|
||||
<span className="flex-none">{data.name}</span>
|
||||
<span className="flex-auto text-right">{data.sizeString}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
data: any[];
|
||||
categorys: any[];
|
||||
};
|
||||
|
||||
export function ChartPieDonutText({ data, categorys }: Props) {
|
||||
// 已使用
|
||||
const usedTotal = useMemo(() => {
|
||||
if (!data?.length) {
|
||||
return 0;
|
||||
}
|
||||
return data.reduce((count, current) => count + current.totalSize, 0);
|
||||
}, [data]);
|
||||
// 未使用
|
||||
const unusedTotal = useMemo(() => total - usedTotal, [usedTotal]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data?.length) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...data,
|
||||
{
|
||||
totalSize: unusedTotal,
|
||||
fileCategory: "unused",
|
||||
key: "unused",
|
||||
name: "未使用",
|
||||
color: "hsl(var(--muted))",
|
||||
sizeString: formatFileSize(unusedTotal),
|
||||
proportion: Math.round((unusedTotal / total) * 100),
|
||||
fill: `var(--color-unused)`,
|
||||
},
|
||||
];
|
||||
}, [data, unusedTotal]);
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
if (!categorys?.length) {
|
||||
return {};
|
||||
}
|
||||
return Object.fromEntries(
|
||||
categorys.map((item) => [item.key, { ...item, label: item.name }])
|
||||
);
|
||||
}, [categorys]);
|
||||
|
||||
if (!data?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square h-[240px]"
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip cursor={false} content={CustomTooltip} />
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="totalSize"
|
||||
nameKey="fileCategory"
|
||||
innerRadius={60}
|
||||
strokeWidth={4}
|
||||
activeIndex={0}
|
||||
activeShape={({ outerRadius = 0, ...props }: PieSectorDataItem) => (
|
||||
<Sector {...props} outerRadius={outerRadius + 10} />
|
||||
)}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="fill-foreground text-2xl font-bold"
|
||||
>
|
||||
{formatFileSize(unusedTotal)}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 24}
|
||||
className="fill-muted-foreground"
|
||||
>
|
||||
可用容量
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="mt-5 flex items-center justify-center flex-col">
|
||||
<div className="text-base text-black/60 flex items-center justify-between w-[240px] border-b border-b-solid border-b-black/10 py-4">
|
||||
<span>总量:</span>
|
||||
<span>{formatFileSize(total)}</span>
|
||||
</div>
|
||||
<div className="text-base text-black/60 flex items-center justify-between w-[240px] border-b border-b-solid border-b-black/10 py-4">
|
||||
<span>已用:</span>
|
||||
<span>{formatFileSize(usedTotal)}</span>
|
||||
</div>
|
||||
<div className="text-base text-black/60 flex items-center justify-between w-[240px] border-b border-b-solid border-b-black/10 py-4">
|
||||
<span>可用:</span>
|
||||
<span>{formatFileSize(unusedTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
src/routes/(user)/index.tsx
Normal file
139
src/routes/(user)/index.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
File,
|
||||
FileExclamationPoint,
|
||||
FileType,
|
||||
FilePlay,
|
||||
FileImage,
|
||||
FileChartPie,
|
||||
} from "lucide-react";
|
||||
import { ChartPieDonutText } from "./-components/ChartPieDonutText";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getStatistics } from "@/apis/file";
|
||||
import { useMemo } from "react";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
|
||||
export const Route = createFileRoute("/(user)/")({
|
||||
component: Index,
|
||||
});
|
||||
|
||||
const categorys = [
|
||||
{
|
||||
key: "image",
|
||||
name: "图片",
|
||||
icon: <FileImage />,
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
{
|
||||
key: "video",
|
||||
name: "视频",
|
||||
icon: <FilePlay />,
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
{
|
||||
key: "document",
|
||||
name: "文档",
|
||||
icon: <FileType />,
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
{
|
||||
key: "other",
|
||||
name: "其他",
|
||||
icon: <File />,
|
||||
color: "hsl(var(--chart-4))",
|
||||
},
|
||||
{
|
||||
key: "unknown",
|
||||
name: "未知",
|
||||
icon: <FileExclamationPoint />,
|
||||
color: "hsl(var(--chart-5))",
|
||||
},
|
||||
{
|
||||
key: "unused",
|
||||
name: "未使用",
|
||||
icon: <FileChartPie />,
|
||||
color: "hsl(var(--muted))",
|
||||
},
|
||||
];
|
||||
|
||||
const getCategory = (value: string) => {
|
||||
return categorys.find((item) => item.key === value);
|
||||
};
|
||||
|
||||
function Index() {
|
||||
const { data } = useQuery<any[]>({
|
||||
queryKey: ["getStatistics"],
|
||||
queryFn: () =>
|
||||
getStatistics().then((res: any) =>
|
||||
res.data?.map((item: any) => ({
|
||||
...item,
|
||||
fileCategory: item.fileCategory ?? "unknown",
|
||||
})),
|
||||
),
|
||||
});
|
||||
|
||||
const statisticsData = useMemo(() => {
|
||||
if (!data?.length) {
|
||||
return [];
|
||||
}
|
||||
const total = data.reduce((count, current) => count + current.totalSize, 0);
|
||||
return data.map((item: any) => {
|
||||
return {
|
||||
...item,
|
||||
...getCategory(item.fileCategory),
|
||||
sizeString: formatFileSize(item.totalSize),
|
||||
proportion: Math.round((item.totalSize / total) * 100),
|
||||
fill: `var(--color-${item.fileCategory})`,
|
||||
};
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="p-4 min-h-full">
|
||||
<div>
|
||||
<div className="text-lg">首页</div>
|
||||
<div className="text-xs text-black/50">
|
||||
欢迎回来!这里是您的个人控制台
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 p-8 rounded-lg border border-solid border-black/10 bg-white">
|
||||
{!!statisticsData?.length ? (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-auto">
|
||||
<ChartPieDonutText categorys={categorys} data={statisticsData} />
|
||||
</div>
|
||||
<div className="flex-auto">
|
||||
<div>文件类型分布</div>
|
||||
<div className="mt-4">
|
||||
{statisticsData?.map((item: any) => (
|
||||
<div
|
||||
key={item.fileCategory}
|
||||
className="flex gap-4 items-center p-4 mb-2 last:mb-0 rounded-xl border border-solid border-black/10"
|
||||
>
|
||||
<div className="flex-none w-10 h-10 rounded-full bg-primary flex items-center justify-center text-white">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex-auto min-w-[100px]">
|
||||
<div>{item.name}</div>
|
||||
<div className="text-xs text-black/50">
|
||||
{item.sizeString}({item.proportion}%)
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-auto w-full">
|
||||
<Progress value={item.proportion} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="text-sm text-muted-foreground">暂无数据</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
src/routes/(user)/inside/index.tsx
Normal file
234
src/routes/(user)/inside/index.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { File, Folder, FolderInput } from "lucide-react";
|
||||
import { downloadFile, FileStatus, FileType, RootType } from "@/apis/file";
|
||||
import { FileStatusBadge } from "@/components/file-status-badge";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DirectoryBreadcrumb } from "@/components/directory-breadcrumb";
|
||||
import { DataTable } from "@/components/data-table";
|
||||
import { Pagination } from "@/components/pagination";
|
||||
import { useFetchFiles } from "@/hooks/use-fetch-files";
|
||||
import { useMemo, useState } from "react";
|
||||
import { MoveFileDialog } from "@/components/move-file-dialog";
|
||||
import { RemoveFileDialog } from "@/components/remove-file-dialog";
|
||||
import { FilePreviewDialog } from "@/components/file-preview-dialog";
|
||||
import { useFileCategoryName } from "@/hooks/use-file-category-name";
|
||||
import { FileCategoryFilter } from "@/components/file-category-filter";
|
||||
|
||||
export const Route = createFileRoute("/(user)/inside/")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
export function useColumns({
|
||||
handleChangeParentId,
|
||||
handleMove,
|
||||
handleRemove,
|
||||
handleFilePreview,
|
||||
}: any) {
|
||||
return [
|
||||
{
|
||||
accessorKey: "fileName",
|
||||
header: "文件名",
|
||||
cell: ({ row }: any) => (
|
||||
<div
|
||||
className="flex items-center gap-1 break-all cursor-pointer hover:underline hover:text-primary group"
|
||||
onClick={() => {
|
||||
if (row?.original?.fileType === FileType.DIRECTORY) {
|
||||
handleChangeParentId(row?.original);
|
||||
} else {
|
||||
handleFilePreview(row?.original);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{row?.original?.fileType === FileType.FILE ? (
|
||||
<>
|
||||
<File size={18} className="text-primary" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Folder size={18} className="text-amber-500 group-hover:hidden" />
|
||||
<FolderInput
|
||||
size={18}
|
||||
className="text-amber-500 group-hover:block hidden"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span>{row.getValue("fileName")}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createTime",
|
||||
header: "创建日期",
|
||||
cell: ({ row }: any) => <div>{row.getValue("createTime")}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "fileSize",
|
||||
header: "文件大小",
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "fileStatus",
|
||||
header: () => <div className="text-center">状态</div>,
|
||||
cell: ({ row }: any) => (
|
||||
<div className="text-center">
|
||||
<FileStatusBadge status={row.original.fileStatus} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: "操作",
|
||||
cell: ({ row }: any) => {
|
||||
const record = row.original;
|
||||
return (
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleRemove(row.original)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleMove(row.original)}
|
||||
>
|
||||
移动
|
||||
</Button>
|
||||
{record.fileType === FileType.FILE && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
downloadFile({ code: record.fileCode });
|
||||
}}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const isInside = import.meta.env.VITE_IS_INSIDE === "true";
|
||||
|
||||
function RouteComponent() {
|
||||
// 当前选中数据
|
||||
const [currentEntity, setCurrentEntity] = useState(null);
|
||||
// 移动文件
|
||||
const [openMoveFileDialog, setOpenMoveFileDialog] = useState(false);
|
||||
const handleMove = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenMoveFileDialog(true);
|
||||
};
|
||||
// 删除文件
|
||||
const [openRemoveFileDialog, setOpenRemoveFileDialog] = useState(false);
|
||||
const handleRemove = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenRemoveFileDialog(true);
|
||||
};
|
||||
|
||||
// 文件预览
|
||||
const [openFilePreviewDialog, setOpenFilePreviewDialog] = useState(false);
|
||||
const handleFilePreview = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenFilePreviewDialog(true);
|
||||
};
|
||||
|
||||
const [fileCategory, setFileCategory] = useState("all");
|
||||
const params = useMemo(() => {
|
||||
return {
|
||||
rootType: RootType.INSIDE,
|
||||
fileStatusIn: [
|
||||
FileStatus.VALID,
|
||||
FileStatus.PARSING,
|
||||
FileStatus.TRANSFERING,
|
||||
FileStatus.FAILED,
|
||||
].join(","),
|
||||
fileCategory: fileCategory === "all" ? undefined : fileCategory,
|
||||
};
|
||||
}, [fileCategory]);
|
||||
|
||||
const { openDirectorys, changeOpenDirectorys, table, query, reset } =
|
||||
useFetchFiles({
|
||||
useColumns: (handleChangeParentId: any) =>
|
||||
useColumns({
|
||||
handleChangeParentId,
|
||||
handleMove,
|
||||
handleRemove,
|
||||
handleFilePreview,
|
||||
}),
|
||||
queryKey: ["insideFiles"],
|
||||
params,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<MoveFileDialog
|
||||
open={openMoveFileDialog}
|
||||
setOpen={setOpenMoveFileDialog}
|
||||
data={currentEntity}
|
||||
params={params}
|
||||
refetch={query.refetch}
|
||||
/>
|
||||
<RemoveFileDialog
|
||||
open={openRemoveFileDialog}
|
||||
setOpen={setOpenRemoveFileDialog}
|
||||
data={currentEntity}
|
||||
refetch={query.refetch}
|
||||
/>
|
||||
<FilePreviewDialog
|
||||
open={openFilePreviewDialog}
|
||||
setOpen={setOpenFilePreviewDialog}
|
||||
data={currentEntity}
|
||||
/>
|
||||
<div className="p-4 box-border min-h-full space-y-4">
|
||||
<div>
|
||||
<div className="text-lg">{isInside ? "我的发送" : "我的接收"}</div>
|
||||
<div className="text-xs text-black/50">文件传输的状态</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCategoryFilter
|
||||
fileCategory={fileCategory}
|
||||
setFileCategory={setFileCategory}
|
||||
reset={reset}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border bg-white space-y-2">
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
171
src/routes/(user)/my/-components/CreateFileDialog.tsx
Normal file
171
src/routes/(user)/my/-components/CreateFileDialog.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useCallback } from "react";
|
||||
import { ErrorCode, useDropzone } from "react-dropzone";
|
||||
import { Upload } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { FileType, createFile, uploadFile } from "@/apis/file";
|
||||
import { checkUploadConfig } from "@/apis/upload-config";
|
||||
import { toAccept, toMaxSize, useUploadConfig } from "@/hooks/use-upload-config";
|
||||
|
||||
type CreateFileDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
data?: any;
|
||||
parentId?: number;
|
||||
refetch?: () => void;
|
||||
};
|
||||
|
||||
// 机构未限制类型时沿用的可选类型
|
||||
const DEFAULT_ACCEPT =
|
||||
".docx,.txt,.md,.mdx,.markdown,.pdf,.html,.xlsx,.csv,.pptx,.xml,.epub,.mp3,.m4a,.wav,.amr,.mpga,.mp4,.mov,.mpeg,.webm,.jpg,.jpeg,.png,.gif,.svg";
|
||||
|
||||
// 未配置机构大小上限时的默认限制(MB)
|
||||
const DEFAULT_MAX_SIZE_MB = 50;
|
||||
|
||||
export function CreateFileDialog({
|
||||
open,
|
||||
setOpen,
|
||||
parentId,
|
||||
refetch,
|
||||
}: CreateFileDialogProps) {
|
||||
const { data: uploadConfig } = useUploadConfig();
|
||||
// 机构关闭上传
|
||||
const uploadDisabled = !!uploadConfig && uploadConfig.uploadEnabled === false;
|
||||
// 机构限制类型时的可选后缀
|
||||
const allowedSuffixes = uploadConfig?.allowAllType
|
||||
? null
|
||||
: uploadConfig?.allowedSuffixes || null;
|
||||
const accept = toAccept(uploadConfig) ?? DEFAULT_ACCEPT;
|
||||
const maxSizeMB =
|
||||
uploadConfig?.maxFileSize && uploadConfig.maxFileSize > 0
|
||||
? uploadConfig.maxFileSize
|
||||
: DEFAULT_MAX_SIZE_MB;
|
||||
const maxSize = toMaxSize(uploadConfig) ?? DEFAULT_MAX_SIZE_MB * 1024 * 1024;
|
||||
|
||||
const { mutateAsync, isPending } = useMutation({
|
||||
mutationFn: (file: any) =>
|
||||
uploadFile(file).then((res: any) =>
|
||||
createFile({
|
||||
fileCode: res.data?.fileCode,
|
||||
fileName: res.data?.fileName,
|
||||
fileType: FileType.FILE,
|
||||
parentId,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const onDropAccepted = useCallback((files: any) => {
|
||||
Promise.allSettled(
|
||||
files.map(async (file: any) => {
|
||||
try {
|
||||
// 上传预检:机构策略不通过时跳过该文件(原因由统一拦截器提示)
|
||||
if (uploadConfig?.configured) {
|
||||
await checkUploadConfig({
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return mutateAsync(file);
|
||||
}),
|
||||
).then((results) => {
|
||||
const uploaded = results.filter(
|
||||
(item) => item.status === "fulfilled" && item.value,
|
||||
).length;
|
||||
if (uploaded > 0) {
|
||||
toast.success("上传完成");
|
||||
refetch?.();
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onDropRejected = useCallback((files: any) => {
|
||||
const error = files[0].errors[0];
|
||||
if (error.code === ErrorCode.FileTooLarge) {
|
||||
toast.error(`超过单个文件最大限制(单个最大${maxSizeMB}MB)`);
|
||||
return;
|
||||
}
|
||||
if (error.code === ErrorCode.TooManyFiles) {
|
||||
toast.error("超过最大文件数量限制(最大100个)");
|
||||
return;
|
||||
}
|
||||
toast.error("文件上传失败");
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDropAccepted,
|
||||
onDropRejected,
|
||||
maxFiles: 100,
|
||||
minSize: 1,
|
||||
maxSize,
|
||||
disabled: isPending || uploadDisabled,
|
||||
multiple: true,
|
||||
});
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>上传文件</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div
|
||||
className={`relative border border-dashed rounded-lg text-center transition-colors overflow-hidden
|
||||
${
|
||||
uploadDisabled
|
||||
? "border-muted-foreground/25 opacity-60"
|
||||
: "cursor-pointer hover:border-primary"
|
||||
}
|
||||
${
|
||||
isDragActive
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-muted-foreground/25"
|
||||
}`}
|
||||
>
|
||||
{isPending && (
|
||||
<div className="absolute left-0 right-0 top-0 bottom-0 flex items-center justify-center backdrop-blur-xs bg-white/80 z-50">
|
||||
<Spinner className="h-12 w-12 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div {...getRootProps()} className="p-8 rounded-lg outline-none">
|
||||
<input
|
||||
{...getInputProps({
|
||||
accept,
|
||||
})}
|
||||
/>
|
||||
<div className="p-4 rounded-full bg-gray-50 inline-flex justify-center items-center">
|
||||
<Upload className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-2 text-lg">
|
||||
{uploadDisabled
|
||||
? "当前机构已禁止上传文件"
|
||||
: isDragActive
|
||||
? "松开鼠标以选择文件"
|
||||
: "拖放文件到这里,或点击选择"}
|
||||
</p>
|
||||
{uploadDisabled ? (
|
||||
<p className="mt-2 text-sm text-black/50">
|
||||
请联系机构管理员开启上传权限
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-black/50">
|
||||
{allowedSuffixes?.length
|
||||
? `仅允许上传后缀为 ${allowedSuffixes.join("、")} 的文件,`
|
||||
: ""}
|
||||
文件最大不能超过{maxSizeMB}MB
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
127
src/routes/(user)/my/-components/EditFileDialog.tsx
Normal file
127
src/routes/(user)/my/-components/EditFileDialog.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
Form,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useEffect } from "react";
|
||||
import { useSaveFile } from "../-hooks/useSaveFile";
|
||||
import { FileType } from "@/apis/file";
|
||||
|
||||
const formSchema = z.object({
|
||||
fileName: z
|
||||
.string()
|
||||
.min(1, {
|
||||
message: "目录至少1个字符",
|
||||
})
|
||||
.max(32, {
|
||||
message: "目录最多32个字符",
|
||||
})
|
||||
.nonempty({
|
||||
message: "目录不能为空",
|
||||
}),
|
||||
});
|
||||
|
||||
type EditFileDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
data?: any;
|
||||
parentId?: number;
|
||||
refetch?: () => void;
|
||||
};
|
||||
|
||||
export function EditFileDialog({
|
||||
open,
|
||||
setOpen,
|
||||
data,
|
||||
parentId,
|
||||
refetch,
|
||||
}: EditFileDialogProps) {
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
fileName: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { save, saveIsPending } = useSaveFile({
|
||||
onSuccess: () => {
|
||||
refetch?.();
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: z.infer<typeof formSchema>) => {
|
||||
save({
|
||||
...(data || {}),
|
||||
...values,
|
||||
parentId,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({ fileName: data?.fileName || "" });
|
||||
}
|
||||
}, [open, data]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{data?.fileType === FileType.DIRECTORY && !data?.id
|
||||
? "创建目录"
|
||||
: "重命名"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fileName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
data?.fileType === FileType.DIRECTORY
|
||||
? "请输入目录名"
|
||||
: "请输入文件名"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={saveIsPending}>
|
||||
{saveIsPending && <Spinner />}确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
130
src/routes/(user)/my/-components/ShareDialog.tsx
Normal file
130
src/routes/(user)/my/-components/ShareDialog.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
Form,
|
||||
} from "@/components/ui/form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useEffect } from "react";
|
||||
import { useShareFile } from "../-hooks/useShareFile";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Folder, File } from "lucide-react";
|
||||
import { FileType } from "@/apis/file";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const formSchema = z.object({
|
||||
validDays: z.number().or(z.string()),
|
||||
});
|
||||
|
||||
type ShareDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
data?: any;
|
||||
};
|
||||
|
||||
export function ShareDialog({ open, setOpen, data }: ShareDialogProps) {
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
validDays: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const { share, shareIsPending } = useShareFile({
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: z.infer<typeof formSchema>) => {
|
||||
share({
|
||||
userFileId: data.id,
|
||||
validDays: values.validDays,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, data]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>分享文件</DialogTitle>
|
||||
</DialogHeader>
|
||||
<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>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="validDays"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label>有效期</Label>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value.toString()}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="请选择有效期" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="1">1天</SelectItem>
|
||||
<SelectItem value="3">3天</SelectItem>
|
||||
<SelectItem value="5">5天</SelectItem>
|
||||
<SelectItem value="7">7天</SelectItem>
|
||||
<SelectItem value="15">15天</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={shareIsPending}>
|
||||
{shareIsPending && <Spinner />}确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
67
src/routes/(user)/my/-components/TransferConfirmDialog.tsx
Normal file
67
src/routes/(user)/my/-components/TransferConfirmDialog.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Folder, File } from "lucide-react";
|
||||
import { useTransferFile } from "../-hooks/useTransferFile";
|
||||
import { FileType } from "@/apis/file";
|
||||
|
||||
type TransferConfirmDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
data?: any;
|
||||
};
|
||||
|
||||
const isInside = import.meta.env.VITE_IS_INSIDE === "true";
|
||||
|
||||
export function TransferConfirmDialog({
|
||||
open,
|
||||
setOpen,
|
||||
data,
|
||||
}: TransferConfirmDialogProps) {
|
||||
const { transfer, transferIsPending } = useTransferFile({
|
||||
onSuccess() {
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = () => {
|
||||
transfer({ userFileId: data?.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
您确定要传输该{data?.fileType === FileType.FILE ? "文件" : "目录"}到
|
||||
{isInside ? "外网" : "内网"}?
|
||||
</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>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={transferIsPending} onClick={onSubmit}>
|
||||
{transferIsPending && <Spinner />}确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
28
src/routes/(user)/my/-hooks/useSaveFile.ts
Normal file
28
src/routes/(user)/my/-hooks/useSaveFile.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { modifyFile, createFile } from "@/apis/file";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const useSaveFile = (mutationOptions?: any) => {
|
||||
const { mutateAsync: saveMutateAsync, isPending: saveIsPending } =
|
||||
useMutation({
|
||||
mutationFn: (values: any) => {
|
||||
if (values.id) {
|
||||
return modifyFile(values);
|
||||
}
|
||||
return createFile(values);
|
||||
},
|
||||
...(mutationOptions || {}),
|
||||
});
|
||||
|
||||
const save = (values: any) => {
|
||||
toast.promise(saveMutateAsync(values), {
|
||||
loading: "提交中...",
|
||||
success: "操作成功",
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
save,
|
||||
saveIsPending,
|
||||
};
|
||||
};
|
||||
23
src/routes/(user)/my/-hooks/useShareFile.ts
Normal file
23
src/routes/(user)/my/-hooks/useShareFile.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { createShare } from "@/apis/file";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const useShareFile = (mutationOptions?: any) => {
|
||||
const { mutateAsync: shareMutateAsync, isPending: shareIsPending } =
|
||||
useMutation({
|
||||
mutationFn: createShare,
|
||||
...(mutationOptions || {}),
|
||||
});
|
||||
|
||||
const share = (values: any) => {
|
||||
toast.promise(shareMutateAsync(values), {
|
||||
loading: "提交中...",
|
||||
success: "操作成功",
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
share,
|
||||
shareIsPending,
|
||||
};
|
||||
};
|
||||
23
src/routes/(user)/my/-hooks/useTransferFile.ts
Normal file
23
src/routes/(user)/my/-hooks/useTransferFile.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { transferFile } from "@/apis/file";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const useTransferFile = (mutationOptions?: any) => {
|
||||
const { mutateAsync: transferMutateAsync, isPending: transferIsPending } =
|
||||
useMutation({
|
||||
mutationFn: transferFile,
|
||||
...(mutationOptions || {}),
|
||||
});
|
||||
|
||||
const transfer = (values: any) => {
|
||||
toast.promise(transferMutateAsync(values), {
|
||||
loading: "提交中...",
|
||||
success: "操作成功",
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
transfer,
|
||||
transferIsPending,
|
||||
};
|
||||
};
|
||||
359
src/routes/(user)/my/index.tsx
Normal file
359
src/routes/(user)/my/index.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
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";
|
||||
import { RemoveFileDialog } from "@/components/remove-file-dialog";
|
||||
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 { formatFileSize } from "@/lib/utils";
|
||||
import { MoveFileDialog } from "@/components/move-file-dialog";
|
||||
import { useFetchFiles } from "@/hooks/use-fetch-files";
|
||||
import { DataTable } from "@/components/data-table";
|
||||
import { Pagination } from "@/components/pagination";
|
||||
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";
|
||||
|
||||
const isInside = import.meta.env.VITE_IS_INSIDE === "true";
|
||||
|
||||
export const Route = createFileRoute("/(user)/my/")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
export function useColumns({
|
||||
handleEditFile,
|
||||
handleRemove,
|
||||
handleChangeParentId,
|
||||
handleShare,
|
||||
handleTransfer,
|
||||
handleMove,
|
||||
handleFilePreview,
|
||||
}: any) {
|
||||
return [
|
||||
{
|
||||
accessorKey: "fileName",
|
||||
header: "文件名",
|
||||
cell: ({ row }: any) => (
|
||||
<div
|
||||
className="flex items-center gap-1 break-all cursor-pointer hover:underline hover:text-primary group"
|
||||
onClick={() => {
|
||||
if (row?.original?.fileType === FileType.DIRECTORY) {
|
||||
handleChangeParentId(row?.original);
|
||||
} else {
|
||||
handleFilePreview(row?.original);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{row?.original?.fileType === FileType.FILE ? (
|
||||
<>
|
||||
<File size={18} className="text-primary" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Folder size={18} className="text-amber-500 group-hover:hidden" />
|
||||
<FolderInput
|
||||
size={18}
|
||||
className="text-amber-500 group-hover:block hidden"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span>{row.getValue("fileName")}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createTime",
|
||||
header: "创建日期",
|
||||
cell: ({ row }: any) => <div>{row.getValue("createTime")}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "fileSize",
|
||||
header: "文件大小",
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: "操作",
|
||||
cell: ({ row }: any) => {
|
||||
return (
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
handleEditFile(row.original);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</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)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleMove(row.original)}
|
||||
>
|
||||
移动
|
||||
</Button>
|
||||
{row.original?.fileType === FileType.FILE && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
downloadFile({ code: row.original?.fileCode });
|
||||
}}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const router = useRouter();
|
||||
// 机构上传配置:关闭上传时隐藏上传入口
|
||||
const { data: uploadConfig } = useUploadConfig();
|
||||
const uploadDisabled = !!uploadConfig && uploadConfig.uploadEnabled === false;
|
||||
// 当前选中数据
|
||||
const [currentEntity, setCurrentEntity] = useState(null);
|
||||
|
||||
// 编辑
|
||||
const [openEditFileDialog, setOpenEditFileDialog] = useState(false);
|
||||
const handleEditFile = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenEditFileDialog(true);
|
||||
};
|
||||
|
||||
// 创建文件
|
||||
const [openCreateFileDialog, setOpenCreateFileDialog] = useState(false);
|
||||
const handleCreateFile = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenCreateFileDialog(true);
|
||||
};
|
||||
|
||||
// 删除文件
|
||||
const [openRemoveFileDialog, setOpenRemoveFileDialog] = useState(false);
|
||||
const handleRemove = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenRemoveFileDialog(true);
|
||||
};
|
||||
|
||||
// 分享
|
||||
const [openShareConfirmDialog, setOpenShareConfirmDialog] = useState(false);
|
||||
const handleShare = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenShareConfirmDialog(true);
|
||||
};
|
||||
|
||||
// 传输
|
||||
const [openTransferConfirmDialog, setOpenTransferConfirmDialog] =
|
||||
useState(false);
|
||||
const handleTransfer = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenTransferConfirmDialog(true);
|
||||
};
|
||||
|
||||
// 移动文件
|
||||
const [openMoveFileDialog, setOpenMoveFileDialog] = useState(false);
|
||||
const handleMove = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenMoveFileDialog(true);
|
||||
};
|
||||
|
||||
// 文件预览
|
||||
const [openFilePreviewDialog, setOpenFilePreviewDialog] = useState(false);
|
||||
const handleFilePreview = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenFilePreviewDialog(true);
|
||||
};
|
||||
|
||||
const [fileCategory, setFileCategory] = useState("all");
|
||||
const params = useMemo(() => {
|
||||
return {
|
||||
rootType: RootType.MY,
|
||||
fileStatus: FileStatus.VALID,
|
||||
fileCategory: fileCategory === "all" ? undefined : fileCategory,
|
||||
};
|
||||
}, [fileCategory]);
|
||||
|
||||
const {
|
||||
openDirectorys,
|
||||
changeOpenDirectorys,
|
||||
parentId,
|
||||
table,
|
||||
query,
|
||||
reset,
|
||||
} = useFetchFiles({
|
||||
useColumns: (handleChangeParentId: any) =>
|
||||
useColumns({
|
||||
handleChangeParentId,
|
||||
handleEditFile,
|
||||
handleRemove,
|
||||
handleShare,
|
||||
handleTransfer,
|
||||
handleMove,
|
||||
handleFilePreview,
|
||||
}),
|
||||
queryKey: ["myFiles"],
|
||||
params,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditFileDialog
|
||||
open={openEditFileDialog}
|
||||
setOpen={setOpenEditFileDialog}
|
||||
data={currentEntity}
|
||||
parentId={parentId}
|
||||
refetch={query.refetch}
|
||||
/>
|
||||
<CreateFileDialog
|
||||
open={openCreateFileDialog}
|
||||
setOpen={setOpenCreateFileDialog}
|
||||
data={currentEntity}
|
||||
parentId={parentId}
|
||||
refetch={query.refetch}
|
||||
/>
|
||||
<RemoveFileDialog
|
||||
open={openRemoveFileDialog}
|
||||
setOpen={setOpenRemoveFileDialog}
|
||||
data={currentEntity}
|
||||
refetch={query.refetch}
|
||||
/>
|
||||
<ShareDialog
|
||||
open={openShareConfirmDialog}
|
||||
setOpen={setOpenShareConfirmDialog}
|
||||
data={currentEntity}
|
||||
/>
|
||||
<TransferConfirmDialog
|
||||
open={openTransferConfirmDialog}
|
||||
setOpen={setOpenTransferConfirmDialog}
|
||||
data={currentEntity}
|
||||
/>
|
||||
<MoveFileDialog
|
||||
open={openMoveFileDialog}
|
||||
setOpen={setOpenMoveFileDialog}
|
||||
data={currentEntity}
|
||||
params={params}
|
||||
refetch={query.refetch}
|
||||
/>
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCategoryFilter
|
||||
fileCategory={fileCategory}
|
||||
setFileCategory={setFileCategory}
|
||||
reset={reset}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full !bg-transparent"
|
||||
onClick={() => {
|
||||
router.navigate({
|
||||
to: isInside ? "/outside" : "/inside",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FolderInput />
|
||||
我的接收
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border bg-white space-y-2">
|
||||
<div className="flex items-center gap-2 empty:hidden">
|
||||
{fileCategory !== "outside" && (
|
||||
<>
|
||||
{!uploadDisabled && (
|
||||
<Button variant="default" onClick={() => handleCreateFile()}>
|
||||
<FileUp />
|
||||
上传文件
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() =>
|
||||
handleEditFile({ fileType: FileType.DIRECTORY })
|
||||
}
|
||||
>
|
||||
<FolderPlus />
|
||||
创建目录
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
234
src/routes/(user)/outside/index.tsx
Normal file
234
src/routes/(user)/outside/index.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { File, Folder, FolderInput } from "lucide-react";
|
||||
import { DataTable } from "@/components/data-table";
|
||||
import { DirectoryBreadcrumb } from "@/components/directory-breadcrumb";
|
||||
import { downloadFile, FileStatus, FileType, RootType } from "@/apis/file";
|
||||
import { FileStatusBadge } from "@/components/file-status-badge";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pagination } from "@/components/pagination";
|
||||
import { useMemo, useState } from "react";
|
||||
import { MoveFileDialog } from "@/components/move-file-dialog";
|
||||
import { useFetchFiles } from "@/hooks/use-fetch-files";
|
||||
import { RemoveFileDialog } from "@/components/remove-file-dialog";
|
||||
import { FilePreviewDialog } from "@/components/file-preview-dialog";
|
||||
import { useFileCategoryName } from "@/hooks/use-file-category-name";
|
||||
import { FileCategoryFilter } from "@/components/file-category-filter";
|
||||
|
||||
export const Route = createFileRoute("/(user)/outside/")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
export function useColumns({
|
||||
handleChangeParentId,
|
||||
handleMove,
|
||||
handleRemove,
|
||||
handleFilePreview,
|
||||
}: any) {
|
||||
return [
|
||||
{
|
||||
accessorKey: "fileName",
|
||||
header: "文件名",
|
||||
cell: ({ row }: any) => (
|
||||
<div
|
||||
className="flex items-center gap-1 break-all cursor-pointer hover:underline hover:text-primary group"
|
||||
onClick={() => {
|
||||
if (row?.original?.fileType === FileType.DIRECTORY) {
|
||||
handleChangeParentId(row?.original);
|
||||
} else {
|
||||
handleFilePreview(row?.original);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{row?.original?.fileType === FileType.FILE ? (
|
||||
<>
|
||||
<File size={18} className="text-primary" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Folder size={18} className="text-amber-500 group-hover:hidden" />
|
||||
<FolderInput
|
||||
size={18}
|
||||
className="text-amber-500 group-hover:block hidden"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span>{row.getValue("fileName")}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createTime",
|
||||
header: "创建日期",
|
||||
cell: ({ row }: any) => <div>{row.getValue("createTime")}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "fileSize",
|
||||
header: "文件大小",
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "fileStatus",
|
||||
header: () => <div className="text-center">状态</div>,
|
||||
cell: ({ row }: any) => (
|
||||
<div className="text-center">
|
||||
<FileStatusBadge status={row.original.fileStatus} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: "操作",
|
||||
cell: ({ row }: any) => {
|
||||
const record = row.original;
|
||||
return (
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleRemove(row.original)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleMove(row.original)}
|
||||
>
|
||||
移动
|
||||
</Button>
|
||||
{record.fileType === FileType.FILE && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
downloadFile({ code: record.fileCode });
|
||||
}}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const isInside = import.meta.env.VITE_IS_INSIDE === "true";
|
||||
|
||||
function RouteComponent() {
|
||||
// 当前选中数据
|
||||
const [currentEntity, setCurrentEntity] = useState(null);
|
||||
// 移动文件
|
||||
const [openMoveFileDialog, setOpenMoveFileDialog] = useState(false);
|
||||
const handleMove = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenMoveFileDialog(true);
|
||||
};
|
||||
// 删除文件
|
||||
const [openRemoveFileDialog, setOpenRemoveFileDialog] = useState(false);
|
||||
const handleRemove = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenRemoveFileDialog(true);
|
||||
};
|
||||
|
||||
// 文件预览
|
||||
const [openFilePreviewDialog, setOpenFilePreviewDialog] = useState(false);
|
||||
const handleFilePreview = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenFilePreviewDialog(true);
|
||||
};
|
||||
|
||||
const [fileCategory, setFileCategory] = useState("all");
|
||||
const params = useMemo(() => {
|
||||
return {
|
||||
rootType: RootType.OUTSIDE,
|
||||
fileStatusIn: [
|
||||
FileStatus.VALID,
|
||||
FileStatus.PARSING,
|
||||
FileStatus.TRANSFERING,
|
||||
FileStatus.FAILED,
|
||||
].join(","),
|
||||
fileCategory: fileCategory === "all" ? undefined : fileCategory,
|
||||
};
|
||||
}, [fileCategory]);
|
||||
|
||||
const { openDirectorys, changeOpenDirectorys, table, query, reset } =
|
||||
useFetchFiles({
|
||||
useColumns: (handleChangeParentId: any) =>
|
||||
useColumns({
|
||||
handleChangeParentId,
|
||||
handleMove,
|
||||
handleRemove,
|
||||
handleFilePreview,
|
||||
}),
|
||||
queryKey: ["outsideFiles"],
|
||||
params,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<MoveFileDialog
|
||||
open={openMoveFileDialog}
|
||||
setOpen={setOpenMoveFileDialog}
|
||||
data={currentEntity}
|
||||
params={params}
|
||||
refetch={query.refetch}
|
||||
/>
|
||||
<RemoveFileDialog
|
||||
open={openRemoveFileDialog}
|
||||
setOpen={setOpenRemoveFileDialog}
|
||||
data={currentEntity}
|
||||
refetch={query.refetch}
|
||||
/>
|
||||
<FilePreviewDialog
|
||||
open={openFilePreviewDialog}
|
||||
setOpen={setOpenFilePreviewDialog}
|
||||
data={currentEntity}
|
||||
/>
|
||||
<div className="p-4 box-border min-h-full space-y-4">
|
||||
<div>
|
||||
<div className="text-lg">{isInside ? "我的接收" : "我的发送"}</div>
|
||||
<div className="text-xs text-black/50">文件传输的状态</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCategoryFilter
|
||||
fileCategory={fileCategory}
|
||||
setFileCategory={setFileCategory}
|
||||
reset={reset}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border bg-white space-y-2">
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useBatchRemoveFile } from "../-hooks/useBatchRemoveFile";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type BatchRemoveConfirmDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
ids?: number[];
|
||||
refetch?: () => void;
|
||||
};
|
||||
|
||||
export function BatchRemoveConfirmDialog({
|
||||
open,
|
||||
setOpen,
|
||||
ids,
|
||||
refetch,
|
||||
}: BatchRemoveConfirmDialogProps) {
|
||||
const { batchRemove, batchRemoveIsPending } = useBatchRemoveFile({
|
||||
onSuccess() {
|
||||
refetch?.();
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!ids?.length) {
|
||||
toast("请至少选择一个文件/目录");
|
||||
return;
|
||||
}
|
||||
batchRemove(ids);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>您确定要彻底删除选中的文件/目录吗?</DialogTitle>
|
||||
<DialogDescription>
|
||||
当前选中
|
||||
<span className="text-primary px-1">{ids?.length || 0}</span>
|
||||
个文件/目录,彻底删除后文件/目录将被永久删除,无法恢复
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={batchRemoveIsPending}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{batchRemoveIsPending && <Spinner />}确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Folder, File } from "lucide-react";
|
||||
import { useRecoverFile } from "../-hooks/useRecoverFile";
|
||||
import { FileType } from "@/apis/file";
|
||||
|
||||
type RecoverConfirmDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
data?: any;
|
||||
refetch?: () => void;
|
||||
};
|
||||
|
||||
export function RecoverConfirmDialog({
|
||||
open,
|
||||
setOpen,
|
||||
data,
|
||||
refetch,
|
||||
}: RecoverConfirmDialogProps) {
|
||||
const { recover, recoverIsPending } = useRecoverFile({
|
||||
onSuccess() {
|
||||
refetch?.();
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = () => {
|
||||
recover(data?.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
您确定要恢复该{data?.fileType === FileType.FILE ? "文件" : "目录"}?
|
||||
</DialogTitle>
|
||||
{data?.fileType === FileType.FILE ? (
|
||||
<DialogDescription>
|
||||
恢复后文件将被恢复到回收站,可以再次删除
|
||||
</DialogDescription>
|
||||
) : (
|
||||
<DialogDescription>
|
||||
恢复后目录下的文件也会一并被恢复,可以再次删除
|
||||
</DialogDescription>
|
||||
)}
|
||||
<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>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={recoverIsPending} onClick={onSubmit}>
|
||||
{recoverIsPending && <Spinner />}确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Folder, File } from "lucide-react";
|
||||
import { useRemoveFile } from "../-hooks/useRemoveFile";
|
||||
import { FileType } from "@/apis/file";
|
||||
|
||||
type RemoveConfirmDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
data?: any;
|
||||
refetch?: () => void;
|
||||
};
|
||||
|
||||
export function RemoveConfirmDialog({
|
||||
open,
|
||||
setOpen,
|
||||
data,
|
||||
refetch,
|
||||
}: RemoveConfirmDialogProps) {
|
||||
const { remove, removeIsPending } = useRemoveFile({
|
||||
onSuccess() {
|
||||
refetch?.();
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = () => {
|
||||
remove(data?.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
您确定要彻底删除该
|
||||
{data?.fileType === FileType.FILE ? "文件" : "目录"}?
|
||||
</DialogTitle>
|
||||
{data?.fileType === FileType.FILE ? (
|
||||
<DialogDescription>
|
||||
彻底删除后文件将被永久删除,无法恢复
|
||||
</DialogDescription>
|
||||
) : (
|
||||
<DialogDescription>
|
||||
彻底删除后目录下的文件也会一并被删除,无法恢复
|
||||
</DialogDescription>
|
||||
)}
|
||||
<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>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={removeIsPending}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{removeIsPending && <Spinner />}确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
25
src/routes/(user)/recycle/-hooks/useBatchRemoveFile.ts
Normal file
25
src/routes/(user)/recycle/-hooks/useBatchRemoveFile.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { removeFileByIds } from "@/apis/file";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const useBatchRemoveFile = (mutationOptions?: any) => {
|
||||
const {
|
||||
mutateAsync: batchRemoveMutateAsync,
|
||||
isPending: batchRemoveIsPending,
|
||||
} = useMutation<any, Error, number[]>({
|
||||
mutationFn: removeFileByIds,
|
||||
...(mutationOptions || {}),
|
||||
});
|
||||
|
||||
const batchRemove = (ids: number[]) => {
|
||||
toast.promise(batchRemoveMutateAsync(ids), {
|
||||
loading: "删除中",
|
||||
success: "删除成功",
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
batchRemove,
|
||||
batchRemoveIsPending,
|
||||
};
|
||||
};
|
||||
112
src/routes/(user)/recycle/-hooks/useFetchFiles.ts
Normal file
112
src/routes/(user)/recycle/-hooks/useFetchFiles.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getFilePage } from "@/apis/file";
|
||||
import { useReactTable } from "@tanstack/react-table";
|
||||
import { getCoreRowModel } from "@tanstack/react-table";
|
||||
|
||||
type UseFetchFilesProps = {
|
||||
useColumns: any;
|
||||
queryKey: any[];
|
||||
params: any;
|
||||
};
|
||||
|
||||
export const useFetchFiles = ({
|
||||
useColumns,
|
||||
queryKey,
|
||||
params,
|
||||
}: UseFetchFilesProps) => {
|
||||
// 当前打开的目录层级
|
||||
const [openDirectorys, setOpenDirectorys] = useState([]);
|
||||
// 文件目录ID
|
||||
const [parentId, setParentId] = useState(0);
|
||||
// 分页
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
// 选中行
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
|
||||
// 查询数据
|
||||
const query = useQuery<any>({
|
||||
queryKey: [...queryKey, pagination, parentId, params],
|
||||
queryFn: () =>
|
||||
getFilePage({
|
||||
parentId: !parentId ? undefined : parentId,
|
||||
parentNotInvalid: !parentId,
|
||||
pageSize: pagination.pageSize,
|
||||
pageNo: pagination.pageIndex + 1,
|
||||
...params,
|
||||
}).then((res) => res.data),
|
||||
});
|
||||
|
||||
// 切换上级ID
|
||||
const handleChangeParentId = (record: any) => {
|
||||
setParentId(record.id);
|
||||
// 重置分页
|
||||
table.setPageIndex(0);
|
||||
// @ts-ignore
|
||||
setOpenDirectorys((prev: any) => [...prev, record]);
|
||||
// 重置选中数据
|
||||
table.resetRowSelection();
|
||||
};
|
||||
|
||||
// 表格
|
||||
const columns = useColumns(handleChangeParentId);
|
||||
const table = useReactTable({
|
||||
data: query?.data?.entityList || [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: false,
|
||||
rowCount: query?.data?.totalCount || 0,
|
||||
pageCount: query?.data?.pageCount || 0,
|
||||
onPaginationChange: setPagination,
|
||||
autoResetPageIndex: false,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
});
|
||||
|
||||
const changeOpenDirectorys = (record?: any) => {
|
||||
setParentId(record?.id);
|
||||
// 重置分页
|
||||
table.setPageIndex(0);
|
||||
// @ts-ignore
|
||||
setOpenDirectorys((prev: any[]) => {
|
||||
const index = prev.findIndex((item: any) => item.id === record?.id);
|
||||
if (index > -1) {
|
||||
return [...prev.slice(0, index + 1)];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
// 重置选中数据
|
||||
table.resetRowSelection();
|
||||
};
|
||||
|
||||
// 重置
|
||||
const reset = () => {
|
||||
setParentId(0);
|
||||
// 重置分页
|
||||
table.setPageIndex(0);
|
||||
// @ts-ignore
|
||||
setOpenDirectorys([]);
|
||||
// 重置选中数据
|
||||
table.resetRowSelection();
|
||||
};
|
||||
|
||||
return {
|
||||
parentId,
|
||||
setParentId,
|
||||
handleChangeParentId,
|
||||
openDirectorys,
|
||||
changeOpenDirectorys,
|
||||
pagination,
|
||||
table,
|
||||
query,
|
||||
reset,
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
};
|
||||
};
|
||||
23
src/routes/(user)/recycle/-hooks/useRecoverFile.ts
Normal file
23
src/routes/(user)/recycle/-hooks/useRecoverFile.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { recoverFile } from "@/apis/file";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const useRecoverFile = (mutationOptions?: any) => {
|
||||
const { mutateAsync: recoverMutateAsync, isPending: recoverIsPending } =
|
||||
useMutation<any, Error, number>({
|
||||
mutationFn: recoverFile,
|
||||
...(mutationOptions || {}),
|
||||
});
|
||||
|
||||
const recover = (id: number) => {
|
||||
toast.promise(recoverMutateAsync(id), {
|
||||
loading: "恢复中",
|
||||
success: "恢复成功",
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
recover,
|
||||
recoverIsPending,
|
||||
};
|
||||
};
|
||||
23
src/routes/(user)/recycle/-hooks/useRemoveFile.ts
Normal file
23
src/routes/(user)/recycle/-hooks/useRemoveFile.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { removeFile } from "@/apis/file";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const useRemoveFile = (mutationOptions?: any) => {
|
||||
const { mutateAsync: removeMutateAsync, isPending: removeIsPending } =
|
||||
useMutation<any, Error, number>({
|
||||
mutationFn: removeFile,
|
||||
...(mutationOptions || {}),
|
||||
});
|
||||
|
||||
const remove = (id: number) => {
|
||||
toast.promise(removeMutateAsync(id), {
|
||||
loading: "删除中",
|
||||
success: "删除成功",
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
remove,
|
||||
removeIsPending,
|
||||
};
|
||||
};
|
||||
265
src/routes/(user)/recycle/index.tsx
Normal file
265
src/routes/(user)/recycle/index.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { File, Folder, FolderInput, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataTable } from "@/components/data-table";
|
||||
import { DirectoryBreadcrumb } from "@/components/directory-breadcrumb";
|
||||
import { useMemo, useState } from "react";
|
||||
import { RemoveConfirmDialog } from "./-components/RemoveConfirmDialog";
|
||||
import { RecoverConfirmDialog } from "./-components/RecoverConfirmDialog";
|
||||
import { downloadFile, FileStatus, FileType } from "@/apis/file";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { Pagination } from "@/components/pagination";
|
||||
import { useFetchFiles } from "./-hooks/useFetchFiles";
|
||||
import { useFileCategoryName } from "@/hooks/use-file-category-name";
|
||||
import { FileCategoryFilter } from "@/components/file-category-filter";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { toast } from "sonner";
|
||||
import { BatchRemoveConfirmDialog } from "./-components/BatchRemoveConfirmDialog";
|
||||
import { FilePreviewDialog } from "@/components/file-preview-dialog";
|
||||
|
||||
export const Route = createFileRoute("/(user)/recycle/")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
export function useColumns({
|
||||
handleChangeParentId,
|
||||
handleRecover,
|
||||
handleRemove,
|
||||
handleFilePreview,
|
||||
}: any) {
|
||||
return [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }: any) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: any) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "fileName",
|
||||
header: "文件名",
|
||||
cell: ({ row }: any) => (
|
||||
<div
|
||||
className="flex items-center gap-1 break-all cursor-pointer hover:underline hover:text-primary group"
|
||||
onClick={() => {
|
||||
if (row?.original?.fileType === FileType.DIRECTORY) {
|
||||
handleChangeParentId(row?.original);
|
||||
} else {
|
||||
handleFilePreview(row?.original);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{row?.original?.fileType === FileType.FILE ? (
|
||||
<>
|
||||
<File size={18} className="text-primary" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Folder size={18} className="text-amber-500 group-hover:hidden" />
|
||||
<FolderInput
|
||||
size={18}
|
||||
className="text-amber-500 group-hover:block hidden"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span>{row.getValue("fileName")}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updateTime",
|
||||
header: "删除日期",
|
||||
cell: ({ row }: any) => <div>{row.getValue("updateTime")}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "fileSize",
|
||||
header: "文件大小",
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: "操作",
|
||||
cell: ({ row }: any) => {
|
||||
return (
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleRecover(row?.original)}
|
||||
>
|
||||
恢复
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleRemove(row?.original)}
|
||||
>
|
||||
彻底删除
|
||||
</Button>
|
||||
{row.original?.fileType === FileType.FILE && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
downloadFile({ code: row.original?.fileCode });
|
||||
}}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
// 当前选中数据
|
||||
const [currentEntity, setCurrentEntity] = useState<any>(null);
|
||||
|
||||
// 恢复
|
||||
const [openRecoverDialog, setOpenRecoverDialog] = useState<any>(false);
|
||||
const handleRecover = (record: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenRecoverDialog(true);
|
||||
};
|
||||
|
||||
// 彻底删除
|
||||
const [openRemoveConfirmDialog, setOpenRemoveConfirmDialog] =
|
||||
useState<any>(false);
|
||||
const handleRemove = (record: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenRemoveConfirmDialog(true);
|
||||
};
|
||||
|
||||
// 文件预览
|
||||
const [openFilePreviewDialog, setOpenFilePreviewDialog] = useState(false);
|
||||
const handleFilePreview = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenFilePreviewDialog(true);
|
||||
};
|
||||
|
||||
// 批量彻底删除
|
||||
const [openBatchRemoveConfirmDialog, setOpenBatchRemoveConfirmDialog] =
|
||||
useState<any>(false);
|
||||
const handleBatchRemove = () => {
|
||||
if (!table.getFilteredSelectedRowModel().rows.length) {
|
||||
toast.error("请选择要删除的文件");
|
||||
return;
|
||||
}
|
||||
setOpenBatchRemoveConfirmDialog(true);
|
||||
};
|
||||
|
||||
const [fileCategory, setFileCategory] = useState("all");
|
||||
const params = useMemo(() => {
|
||||
return {
|
||||
fileStatus: FileStatus.INVALID,
|
||||
fileCategory: fileCategory === "all" ? undefined : fileCategory,
|
||||
};
|
||||
}, [fileCategory]);
|
||||
|
||||
const { openDirectorys, changeOpenDirectorys, table, query, reset } =
|
||||
useFetchFiles({
|
||||
useColumns: (handleChangeParentId: any) =>
|
||||
useColumns({
|
||||
handleChangeParentId,
|
||||
handleRecover,
|
||||
handleRemove,
|
||||
handleFilePreview,
|
||||
}),
|
||||
queryKey: ["recycleFiles"],
|
||||
params,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BatchRemoveConfirmDialog
|
||||
open={openBatchRemoveConfirmDialog}
|
||||
setOpen={setOpenBatchRemoveConfirmDialog}
|
||||
ids={table
|
||||
.getFilteredSelectedRowModel()
|
||||
.rows.map((row: any) => row.original.id)}
|
||||
refetch={query.refetch}
|
||||
/>
|
||||
<RemoveConfirmDialog
|
||||
open={openRemoveConfirmDialog}
|
||||
setOpen={setOpenRemoveConfirmDialog}
|
||||
data={currentEntity}
|
||||
refetch={query.refetch}
|
||||
/>
|
||||
<RecoverConfirmDialog
|
||||
open={openRecoverDialog}
|
||||
setOpen={setOpenRecoverDialog}
|
||||
data={currentEntity}
|
||||
refetch={query.refetch}
|
||||
/>
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCategoryFilter
|
||||
fileCategory={fileCategory}
|
||||
setFileCategory={setFileCategory}
|
||||
reset={reset}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border bg-white space-y-2">
|
||||
<div className="flex items-center gap-2 empty:hidden">
|
||||
<Button variant="destructive" onClick={handleBatchRemove}>
|
||||
<Trash2 />
|
||||
批量删除
|
||||
</Button>
|
||||
</div>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
src/routes/(user)/route.tsx
Normal file
53
src/routes/(user)/route.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { AppLayout } from "@/components/app-layout";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
Folder,
|
||||
FolderInput,
|
||||
FolderOutput,
|
||||
House,
|
||||
Recycle,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/(user)")({
|
||||
component: Layout,
|
||||
});
|
||||
|
||||
const isInside = import.meta.env.VITE_IS_INSIDE === "true";
|
||||
|
||||
const menus = [
|
||||
{
|
||||
title: "首页",
|
||||
url: "/",
|
||||
icon: <House />,
|
||||
},
|
||||
{
|
||||
title: "我的文件",
|
||||
url: "/my",
|
||||
icon: <Folder />,
|
||||
},
|
||||
{
|
||||
title: isInside ? "我的接收" : "我的发送",
|
||||
url: "/outside",
|
||||
icon: isInside ? <FolderInput /> : <FolderOutput />,
|
||||
},
|
||||
{
|
||||
title: isInside ? "我的发送" : "我的接收",
|
||||
url: "/inside",
|
||||
icon: isInside ? <FolderOutput /> : <FolderInput />,
|
||||
},
|
||||
{
|
||||
title: "分享",
|
||||
url: "/share",
|
||||
icon: <Share2 />,
|
||||
},
|
||||
{
|
||||
title: "回收站",
|
||||
url: "/recycle",
|
||||
icon: <Recycle />,
|
||||
},
|
||||
];
|
||||
|
||||
function Layout() {
|
||||
return <AppLayout menus={menus} />;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Folder, File } from "lucide-react";
|
||||
import { useCancelShareFile } from "../-hooks/useCancelShareFile";
|
||||
import { FileType } from "@/apis/file";
|
||||
|
||||
type CancelShareConfirmDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
data?: any;
|
||||
refetch?: () => void;
|
||||
};
|
||||
|
||||
export function CancelShareConfirmDialog({
|
||||
open,
|
||||
setOpen,
|
||||
data,
|
||||
refetch,
|
||||
}: CancelShareConfirmDialogProps) {
|
||||
const { cancelShare, cancelShareIsPending } = useCancelShareFile({
|
||||
onSuccess() {
|
||||
refetch?.();
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = () => {
|
||||
cancelShare(data?.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
您确定要取消分享该
|
||||
{data?.fileType === FileType.FILE ? "文件" : "目录"}?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
取消分享后,所有用户将无法访问该
|
||||
{data?.fileType === FileType.FILE ? "文件" : "目录"}
|
||||
</DialogDescription>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={cancelShareIsPending}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{cancelShareIsPending && <Spinner />}确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
78
src/routes/(user)/share/-components/ViewLinkDialog.tsx
Normal file
78
src/routes/(user)/share/-components/ViewLinkDialog.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useMemo } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Folder, File } from "lucide-react";
|
||||
import { FileType } from "@/apis/file";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type ViewLinkDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
data?: any;
|
||||
};
|
||||
|
||||
const domain = import.meta.env.VITE_DOMAIN;
|
||||
const basepath = import.meta.env.VITE_BASEPATH;
|
||||
|
||||
export function ViewLinkDialog({ open, setOpen, data }: ViewLinkDialogProps) {
|
||||
const handleCopy = (text: string) => {
|
||||
copy(text);
|
||||
toast.success("复制成功");
|
||||
};
|
||||
|
||||
const shareLink = useMemo(() => {
|
||||
const url = `${domain || location.origin}${basepath}/#/extract?shareId=${
|
||||
data?.id
|
||||
}`;
|
||||
return `文件链接:${url}\n\n提取码:${data?.accessCode}`;
|
||||
}, [data]);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>分享文件</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-1 flex items-center gap-1 text-sm p-2 rounded-md bg-gray-50">
|
||||
{data?.fileType === FileType.FILE ? (
|
||||
<File size={14} className="text-primary" />
|
||||
) : (
|
||||
<Folder size={14} className="text-amber-500" />
|
||||
)}
|
||||
<span>{data?.fileName}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label>有效日期</Label>
|
||||
<Input value={data?.endTime} disabled />
|
||||
</div>
|
||||
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label>分享内容</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Textarea value={shareLink} disabled className="min-h-[100px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">关闭</Button>
|
||||
</DialogClose>
|
||||
<Button onClick={() => handleCopy(shareLink)}>复制分享内容</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
25
src/routes/(user)/share/-hooks/useCancelShareFile.ts
Normal file
25
src/routes/(user)/share/-hooks/useCancelShareFile.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { invalidFile } from "@/apis/file";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const useCancelShareFile = (mutationOptions?: any) => {
|
||||
const {
|
||||
mutateAsync: cancelShareMutateAsync,
|
||||
isPending: cancelShareIsPending,
|
||||
} = useMutation({
|
||||
mutationFn: invalidFile,
|
||||
...(mutationOptions || {}),
|
||||
});
|
||||
|
||||
const cancelShare = (values: any) => {
|
||||
toast.promise(cancelShareMutateAsync(values), {
|
||||
loading: "提交中...",
|
||||
success: "操作成功",
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
cancelShare,
|
||||
cancelShareIsPending,
|
||||
};
|
||||
};
|
||||
219
src/routes/(user)/share/index.tsx
Normal file
219
src/routes/(user)/share/index.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { File, Folder, FolderInput } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataTable } from "@/components/data-table";
|
||||
import { DirectoryBreadcrumb } from "@/components/directory-breadcrumb";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ViewLinkDialog } from "./-components/ViewLinkDialog";
|
||||
import { CancelShareConfirmDialog } from "./-components/CancelShareConfirmDialog";
|
||||
import { downloadFile, FileStatus, FileType, RootType } from "@/apis/file";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { Pagination } from "@/components/pagination";
|
||||
import { useFetchFiles } from "@/hooks/use-fetch-files";
|
||||
import { useFileCategoryName } from "@/hooks/use-file-category-name";
|
||||
import { FileCategoryFilter } from "@/components/file-category-filter";
|
||||
import { FilePreviewDialog } from "@/components/file-preview-dialog";
|
||||
|
||||
export const Route = createFileRoute("/(user)/share/")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
export function useColumns({
|
||||
handleChangeParentId,
|
||||
handleViewLink,
|
||||
handleCancelShare,
|
||||
handleFilePreview,
|
||||
}: any) {
|
||||
return [
|
||||
{
|
||||
accessorKey: "fileName",
|
||||
header: "文件名",
|
||||
cell: ({ row }: any) => (
|
||||
<div
|
||||
className="flex items-center gap-1 break-all cursor-pointer hover:underline hover:text-primary group"
|
||||
onClick={() => {
|
||||
if (row?.original?.fileType === FileType.DIRECTORY) {
|
||||
handleChangeParentId(row?.original);
|
||||
} else {
|
||||
handleFilePreview(row?.original);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{row?.original?.fileType === FileType.FILE ? (
|
||||
<>
|
||||
<File size={18} className="text-primary" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Folder size={18} className="text-amber-500 group-hover:hidden" />
|
||||
<FolderInput
|
||||
size={18}
|
||||
className="text-amber-500 group-hover:block hidden"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span>{row.getValue("fileName")}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createTime",
|
||||
header: "创建日期",
|
||||
cell: ({ row }: any) => <div>{row.getValue("createTime")}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "fileSize",
|
||||
header: "文件大小",
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: "操作",
|
||||
cell: ({ row }: any) => {
|
||||
if (row.original.parentId !== 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleViewLink(row.original)}
|
||||
>
|
||||
查看链接
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCancelShare(row.original)}
|
||||
>
|
||||
取消分享
|
||||
</Button>
|
||||
{row.original?.fileType === FileType.FILE && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
downloadFile({ code: row.original?.fileCode });
|
||||
}}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const [currentEntity, setCurrentEntity] = useState<any>(null);
|
||||
|
||||
// 查看分享链接
|
||||
const [openViewLinkDialog, setOpenViewLinkDialog] = useState(false);
|
||||
const handleViewLink = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenViewLinkDialog(true);
|
||||
};
|
||||
|
||||
// 取消分享
|
||||
const [openCancelShareConfirmDialog, setOpenCancelShareConfirmDialog] =
|
||||
useState(false);
|
||||
const handleCancelShare = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenCancelShareConfirmDialog(true);
|
||||
};
|
||||
|
||||
// 文件预览
|
||||
const [openFilePreviewDialog, setOpenFilePreviewDialog] = useState(false);
|
||||
const handleFilePreview = (record?: any) => {
|
||||
setCurrentEntity(record);
|
||||
setOpenFilePreviewDialog(true);
|
||||
};
|
||||
|
||||
const [fileCategory, setFileCategory] = useState("all");
|
||||
const params = useMemo(() => {
|
||||
return {
|
||||
rootType: RootType.SHARE,
|
||||
fileStatus: FileStatus.VALID,
|
||||
fileCategory: fileCategory === "all" ? undefined : fileCategory,
|
||||
};
|
||||
}, [fileCategory]);
|
||||
|
||||
const { openDirectorys, changeOpenDirectorys, table, query, reset } =
|
||||
useFetchFiles({
|
||||
useColumns: (handleChangeParentId: any) =>
|
||||
useColumns({
|
||||
handleChangeParentId,
|
||||
handleCancelShare,
|
||||
handleViewLink,
|
||||
handleFilePreview,
|
||||
}),
|
||||
queryKey: ["shareFiles"],
|
||||
params,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewLinkDialog
|
||||
open={openViewLinkDialog}
|
||||
setOpen={setOpenViewLinkDialog}
|
||||
data={currentEntity}
|
||||
/>
|
||||
<CancelShareConfirmDialog
|
||||
open={openCancelShareConfirmDialog}
|
||||
setOpen={setOpenCancelShareConfirmDialog}
|
||||
data={currentEntity}
|
||||
refetch={query?.refetch}
|
||||
/>
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCategoryFilter
|
||||
fileCategory={fileCategory}
|
||||
setFileCategory={setFileCategory}
|
||||
reset={reset}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border bg-white space-y-2">
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user