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:
2026-09-15 12:16:14 +08:00
commit d92d7d6752
532 changed files with 177420 additions and 0 deletions

View 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
View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
};
};

View 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,
};
};

View 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,
};
};

View 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>
</>
);
}

View 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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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,
};
};

View 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,
};
};

View 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,
};
};

View 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,
};
};

View 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>
</>
);
}

View 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} />;
}

View File

@@ -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>
);
}

View 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>
);
}

View 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,
};
};

View 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>
</>
);
}