feat(web): file manager v2 and shared organize wizard (FE-4)
- 左树可折叠(44px 图标条)、下载目录在前、上一级、初始位置记忆;列表名称副行(作品·季集 / 解析提示 / 目录计数)、状态列 120px - 行内 hover 2 个主操作;右键/⋯ 菜单按 v2 顺序,不适用隐藏、被阻止灰显带原因;批量栏"更多" - 详情抽屉:识别节(解析 + Top5 候选 + 按此整理)、整理记录节、版本目标、文本预览前 256 KB - 共享整理向导 components/organize-wizard:3 步(文件与作品表格/批量条 → 选项 → 预览并执行), TMDB 候选 external 就地建作品、最小体积、onExisting、命名来源、入库后刷新元数据、只看问题、分段着色、 plan_stale 自动重新预览、执行进度回查 /files/records;来源支持文件/下载任务/整理记录 - 重命名默认值取 suggest-name;批量刷新元数据走 scrape-batch;回收站条数、原位置链接、占用后改"还原到…" - 快捷键 Alt+↑、Ctrl/⌘+Enter、?;移动端单栏工具栏与底部批量栏 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
09a6b0b5d3
commit
c31df8b69a
24 files changed
+4820
-1607
No files matched your search
@@ -17,9 +17,9 @@ import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { DirTree } from "~/components/files/dir-tree.tsx";
|
||||
import { DirPicker } from "~/components/files/dir-picker.tsx";
|
||||
import { Checkbox } from "~/components/files/file-list.tsx";
|
||||
import { baseName, newIdempotencyKey } from "~/components/files/model.ts";
|
||||
import { baseName, newIdempotencyKey, parentRel } from "~/components/files/model.ts";
|
||||
import { Button, buttonVariants } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -55,6 +55,8 @@ export default function FilesTrashPage() {
|
||||
const [purgeAll, setPurgeAll] = useState(false);
|
||||
const [purgeIds, setPurgeIds] = useState<string[] | null>(null);
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
/** 还原失败的项:id → 原因(原位置被占用时按钮换成"还原到…") */
|
||||
const [failedIds, setFailedIds] = useState<Map<string, { code: string; message: string }>>(new Map());
|
||||
const rows = trash.data?.pages.flatMap((p) => p.items);
|
||||
const rootName = useMemo(() => new Map((roots.data ?? []).map((r) => [r.id, r])), [roots.data]);
|
||||
const expired = rows?.filter((r) => Date.parse(r.expiresAt) <= Date.now()) ?? [];
|
||||
@@ -74,12 +76,25 @@ export default function FilesTrashPage() {
|
||||
onSuccess: (r) => {
|
||||
setPicked(new Set());
|
||||
setRestoreTo(null);
|
||||
if (r.restored.length) toast.success(`已还原 ${r.restored.length} 项`);
|
||||
for (const f of r.failed)
|
||||
toast.error(
|
||||
f.code === "exists" || f.code === "conflict" ? "原位置已被占用。用“还原到…”换个目录" : f.message,
|
||||
{ description: rows?.find((x) => x.id === f.id)?.originalPath },
|
||||
setFailedIds((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const x of r.restored) next.delete(x.id);
|
||||
for (const f of r.failed) next.set(f.id, { code: f.code, message: f.message });
|
||||
return next;
|
||||
});
|
||||
if (r.restored.length)
|
||||
toast.success(
|
||||
`已还原 ${r.restored.length} 项`,
|
||||
r.recordId
|
||||
? {
|
||||
action: {
|
||||
label: "整理记录",
|
||||
onClick: () => window.location.assign(`/console/organize/${r.recordId}`),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
if (r.failed.length) toast.error(`${r.failed.length} 项没有还原,原因见列表`);
|
||||
},
|
||||
onError: (e) => toast.error(filesErrorText(e, "还原失败")),
|
||||
},
|
||||
@@ -115,28 +130,27 @@ export default function FilesTrashPage() {
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
header: "原位置",
|
||||
header: "名称",
|
||||
cell: (r) => {
|
||||
const root = rootName.get(r.rootId);
|
||||
const snap = r.assetSnapshot as { title?: string; contentUnit?: string | null } | null;
|
||||
const fail = failedIds.get(r.id);
|
||||
return (
|
||||
<div className="flex min-w-0 max-w-lg items-start gap-2">
|
||||
<div className="flex min-w-0 max-w-md items-start gap-2">
|
||||
{r.isDir ? (
|
||||
<Folder className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FileIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-foreground" title={r.originalPath}>
|
||||
<p className="truncate text-foreground" title={r.trashPath}>
|
||||
{baseName(r.originalPath)}
|
||||
</p>
|
||||
<p className="truncate font-mono text-meta text-muted-foreground" title={r.trashPath}>
|
||||
{root?.name ?? r.rootId} / {r.originalPath}
|
||||
</p>
|
||||
{snap?.title ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
删除时关联:{snap.title}
|
||||
{snap.contentUnit ? ` ${snap.contentUnit}` : ""}(还原后资产不会自动恢复)
|
||||
{fail ? (
|
||||
<p className="text-meta text-destructive">
|
||||
{fail.code === "exists" || fail.code === "conflict"
|
||||
? "原位置已被占用"
|
||||
: fail.code === "too_large"
|
||||
? "跨卷还原超过大小上限"
|
||||
: fail.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -144,6 +158,26 @@ export default function FilesTrashPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "origin",
|
||||
header: "原位置",
|
||||
hideBelow: "md",
|
||||
cell: (r) => {
|
||||
const root = rootName.get(r.rootId);
|
||||
const dir = parentRel(r.originalPath);
|
||||
return root ? (
|
||||
<Link
|
||||
to={`/console/files?root=${encodeURIComponent(r.rootId)}${dir ? `&path=${encodeURIComponent(dir)}` : ""}`}
|
||||
className="block max-w-xs truncate font-mono text-meta text-muted-foreground hover:text-foreground hover:underline"
|
||||
title={`${root.hostPath}/${r.originalPath}`}
|
||||
>
|
||||
{dir ? `${root.name}/${dir}` : root.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="block max-w-xs truncate font-mono text-meta text-muted-foreground">{r.rootId}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
header: "大小",
|
||||
@@ -176,19 +210,50 @@ export default function FilesTrashPage() {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "asset",
|
||||
header: "关联资产",
|
||||
hideBelow: "lg",
|
||||
cell: (r) => {
|
||||
const snap = r.assetSnapshot as { title?: string; contentUnit?: string | null; mediaEntityId?: string } | null;
|
||||
if (!snap?.title) return <span className="text-muted-foreground">—</span>;
|
||||
const label = `${snap.title}${snap.contentUnit ? ` ${snap.contentUnit}` : ""}`;
|
||||
return (
|
||||
<span className="block max-w-48 text-meta" title="删除时已退役;还原不会自动恢复资产">
|
||||
{snap.mediaEntityId ? (
|
||||
<Link className="underline underline-offset-2" to={`/console/library/${snap.mediaEntityId}`}>
|
||||
{label}
|
||||
</Link>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
<span className="block text-muted-foreground">已退役</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "act",
|
||||
header: <span className="sr-only">操作</span>,
|
||||
align: "right",
|
||||
cell: (r) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="xs" variant="outline" onClick={() => doRestore([r.id])} disabled={restore.isPending}>
|
||||
<RotateCcw />
|
||||
还原
|
||||
</Button>
|
||||
<Button size="xs" variant="ghost" onClick={() => setRestoreTo([r.id])}>
|
||||
还原到…
|
||||
</Button>
|
||||
{failedIds.get(r.id)?.code === "exists" || failedIds.get(r.id)?.code === "conflict" ? (
|
||||
<Button size="xs" variant="outline" onClick={() => setRestoreTo([r.id])}>
|
||||
<RotateCcw />
|
||||
还原到…
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button size="xs" variant="outline" onClick={() => doRestore([r.id])} disabled={restore.isPending}>
|
||||
<RotateCcw />
|
||||
还原
|
||||
</Button>
|
||||
<Button size="xs" variant="ghost" onClick={() => setRestoreTo([r.id])}>
|
||||
还原到…
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isAdmin ? (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -209,13 +274,13 @@ export default function FilesTrashPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="回收站"
|
||||
description="删除的文件在这里保留到期后自动清除,到期前可以还原。"
|
||||
description="到期后自动清除,到期前可以还原。还原不会恢复资产状态,需要时重新整理。"
|
||||
actions={
|
||||
<>
|
||||
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
|
||||
<Link to="/console/files" className={buttonVariants({ variant: "outline", size: "sm" })}>
|
||||
<ArrowLeft />
|
||||
文件
|
||||
文件管理
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
@@ -228,13 +293,13 @@ export default function FilesTrashPage() {
|
||||
onClick={() => doRestore([...picked])}
|
||||
>
|
||||
{restore.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCcw />}
|
||||
还原选中 {picked.size || ""}
|
||||
{picked.size ? `还原 ${picked.size} 项` : "还原选中"}
|
||||
</Button>
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<Button size="sm" variant="outline" disabled={!picked.size} onClick={() => setPurgeIds([...picked])}>
|
||||
<Trash2 />
|
||||
清除选中
|
||||
{picked.size ? `清除 ${picked.size} 项` : "清除选中"}
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
@@ -274,7 +339,7 @@ export default function FilesTrashPage() {
|
||||
hasMore={trash.hasNextPage}
|
||||
onLoadMore={() => void trash.fetchNextPage()}
|
||||
loadingMore={trash.isFetchingNextPage}
|
||||
empty={{ icon: Trash2, title: "回收站是空的", description: "在文件页删除的项会出现在这里。" }}
|
||||
empty={{ icon: Trash2, title: "回收站是空的", description: "在文件管理里删除的项会出现在这里。" }}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -287,7 +352,7 @@ export default function FilesTrashPage() {
|
||||
onConfirm={() => doPurge(purgeIds ?? undefined).then(() => setPurgeIds(null))}
|
||||
/>
|
||||
|
||||
{/* 清空全部:二次确认,需输入“清空回收站” */}
|
||||
{/* 清空全部:二次确认,需输入“清空” */}
|
||||
<ConfirmDialog
|
||||
open={purgeAll}
|
||||
onOpenChange={(o) => {
|
||||
@@ -297,10 +362,11 @@ export default function FilesTrashPage() {
|
||||
title="清空回收站?"
|
||||
description={`全部 ${rows?.length ?? 0} 项(含未到期)将被清除,不可恢复。`}
|
||||
confirmLabel="清空"
|
||||
confirmDisabled={confirmText.trim() !== "清空"}
|
||||
destructive
|
||||
onConfirm={() => {
|
||||
if (confirmText.trim() !== "清空回收站") {
|
||||
toast.error("输入“清空回收站”以确认");
|
||||
if (confirmText.trim() !== "清空") {
|
||||
toast.error("输入“清空”以确认");
|
||||
return Promise.reject(new Error("unconfirmed"));
|
||||
}
|
||||
return doPurge(rows?.map((r) => r.id)).then(() => setConfirmText(""));
|
||||
@@ -309,8 +375,8 @@ export default function FilesTrashPage() {
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="输入 清空回收站"
|
||||
aria-label="输入 清空回收站 以确认"
|
||||
placeholder="输入 清空"
|
||||
aria-label="输入 清空 以确认"
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
|
||||
@@ -344,11 +410,9 @@ function RestoreToDialog({
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>还原到…</DialogTitle>
|
||||
<DialogDescription>{ids.length} 项 · 选一个可写目录。同名项已存在时跳过,不覆盖。</DialogDescription>
|
||||
<DialogDescription>{ids.length} 项 · 选一个可写库根下的目录。同名项已存在时不覆盖。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-80 overflow-y-auto rounded-xl border border-border p-1.5">
|
||||
<DirTree roots={roots.data ?? []} current={target} onSelect={setTarget} pickOnly />
|
||||
</div>
|
||||
<DirPicker roots={roots.data ?? []} value={target} onChange={setTarget} className="max-h-80" />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user