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
@@ -12,28 +12,89 @@ import {
|
||||
useProbe,
|
||||
useTextPreview,
|
||||
} from "~/api/files.ts";
|
||||
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, useRecordsForEntity } from "~/api/records.ts";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { Timeline } from "~/components/console/timeline.tsx";
|
||||
import { SLOT_STATUS } from "~/components/library/labels.ts";
|
||||
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
|
||||
import { Button, buttonVariants } from "~/components/ui/button.tsx";
|
||||
import { downloadLabel } from "~/lib/labels.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { EntryStatus } from "./file-list.tsx";
|
||||
import { IdentifyPanel } from "./identify-panel.tsx";
|
||||
import { baseName } from "./model.ts";
|
||||
|
||||
const PREVIEW_MAX = 20 * 1024 * 1024;
|
||||
|
||||
/** 选中单项的详情抽屉:文件信息、作品/Slot/下载链接、sidecars、探测摘要、路径绑定与小文件预览 */
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
copy: "复制",
|
||||
move: "移动",
|
||||
rename: "重命名",
|
||||
relocate: "目录调整",
|
||||
hardlink: "硬链接",
|
||||
trash: "移入回收站",
|
||||
delete: "永久删除",
|
||||
restore: "从回收站还原",
|
||||
};
|
||||
|
||||
/** 抽屉"整理记录"节:已登记作品最近 5 条整理记录 */
|
||||
function RecordsSection({ entityId }: { entityId: string }) {
|
||||
const q = useRecordsForEntity(entityId);
|
||||
const items = q.data?.items.slice(0, 5) ?? [];
|
||||
return (
|
||||
<DetailSection
|
||||
title="整理记录"
|
||||
actions={
|
||||
<Link className="text-meta text-muted-foreground underline" to={`/console/organize?mediaEntityId=${entityId}`}>
|
||||
查看整理记录
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{q.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : q.error ? (
|
||||
<p className="text-caption text-destructive">读取整理记录失败</p>
|
||||
) : !items.length ? (
|
||||
<p className="text-caption text-muted-foreground">这个作品还没有整理记录。</p>
|
||||
) : (
|
||||
<Timeline
|
||||
items={items.map((r) => ({
|
||||
id: r.id,
|
||||
at: r.finishedAt ?? r.startedAt ?? r.createdAt,
|
||||
tone: RECORD_STATUS_TONE[r.status],
|
||||
title: (
|
||||
<Link className="hover:underline" to={`/console/organize/${r.id}`}>
|
||||
{ACTION_LABEL[r.action] ?? r.action} · {RECORD_STATUS_LABEL[r.status]}
|
||||
</Link>
|
||||
),
|
||||
description: r.target.path ? <span className="font-mono text-meta break-all">{r.target.path}</span> : null,
|
||||
meta: `${r.counts.succeeded}/${r.counts.total}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** 选中单项的详情抽屉:文件信息、作品/版本目标/下载任务、识别、整理记录、附属文件、媒体信息、路径绑定与小文件预览 */
|
||||
export function FileDetail({
|
||||
root,
|
||||
entry,
|
||||
onClose,
|
||||
actions,
|
||||
identifyAuto,
|
||||
onOrganize,
|
||||
}: {
|
||||
root: FileRoot;
|
||||
entry: FileEntry;
|
||||
onClose: () => void;
|
||||
actions?: ReactNode;
|
||||
/** 从菜单"识别"进入:自动识别一次 */
|
||||
identifyAuto?: boolean;
|
||||
/** 识别候选"按此整理" */
|
||||
onOrganize?: (preset: WizardEntity | null) => void;
|
||||
}) {
|
||||
const stat = useFileStat(root.id, entry.path);
|
||||
const probe = useProbe();
|
||||
@@ -102,12 +163,12 @@ export function FileDetail({
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ label: "内容单元", value: e.asset.contentUnit ?? "整部" },
|
||||
{ label: "季集", value: e.asset.contentUnit ?? "整部" },
|
||||
{
|
||||
label: "Slot",
|
||||
label: "版本目标",
|
||||
value: e.asset.slotId ? (
|
||||
<Link className="underline underline-offset-2" to={`/console/library/${e.asset.mediaEntityId}#slots`}>
|
||||
{e.asset.slotStatus ?? "查看"}
|
||||
{e.asset.slotStatus ? (SLOT_STATUS[e.asset.slotStatus]?.label ?? e.asset.slotStatus) : "查看"}
|
||||
</Link>
|
||||
) : (
|
||||
"未满足任何版本目标"
|
||||
@@ -116,15 +177,15 @@ export function FileDetail({
|
||||
{ label: "主资产", value: e.asset.isPrimary ? "是" : "否" },
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
className="mt-2 inline-block text-meta text-muted-foreground underline"
|
||||
to={`/console/organize?mediaEntityId=${e.asset.mediaEntityId}`}
|
||||
>
|
||||
在整理记录中查看
|
||||
</Link>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{e.asset ? <RecordsSection entityId={e.asset.mediaEntityId} /> : null}
|
||||
|
||||
{!e.escape && (e.type === "dir" || (e.type === "file" && e.fileKind === "video")) ? (
|
||||
<IdentifyPanel root={root} entry={e} autoRun={identifyAuto} onOrganize={onOrganize} />
|
||||
) : null}
|
||||
|
||||
{e.download ? (
|
||||
<DetailSection title="下载任务">
|
||||
<KvList
|
||||
@@ -225,7 +286,7 @@ export function FileDetail({
|
||||
/>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
未登记文件不自动探测。点“探测”读取 ffprobe 摘要,缓存 24 小时。
|
||||
未登记文件不自动探测。点“探测”读取 ffprobe 摘要,结果缓存 24 小时。
|
||||
</p>
|
||||
)}
|
||||
</DetailSection>
|
||||
@@ -251,14 +312,14 @@ export function FileDetail({
|
||||
filesMockEnabled() ? (
|
||||
<p className="text-caption text-muted-foreground">演示数据没有图片内容。</p>
|
||||
) : (
|
||||
<a href={fileDownloadUrl(root.id, e.path)} target="_blank" rel="noreferrer" className="block">
|
||||
<a href={fileDownloadUrl(root.id, e.path, true)} target="_blank" rel="noreferrer" className="block">
|
||||
<img
|
||||
src={fileDownloadUrl(root.id, e.path)}
|
||||
src={fileDownloadUrl(root.id, e.path, true)}
|
||||
alt={e.name}
|
||||
className="max-h-80 rounded-xl border border-border object-contain"
|
||||
/>
|
||||
<span className="mt-1 inline-flex items-center gap-1 text-meta text-muted-foreground">
|
||||
<ExternalLink className="h-3 w-3" /> 新窗口打开
|
||||
<ExternalLink className="h-3 w-3" /> 新窗口看原图
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
@@ -267,9 +328,12 @@ export function FileDetail({
|
||||
) : text.error ? (
|
||||
<p className="text-caption text-destructive">{filesErrorText(text.error, "预览失败")}</p>
|
||||
) : (
|
||||
<pre className="max-h-80 overflow-auto rounded-xl border border-border bg-foreground/[0.03] p-3 font-mono text-meta whitespace-pre-wrap">
|
||||
{text.data}
|
||||
</pre>
|
||||
<>
|
||||
<pre className="max-h-80 overflow-auto rounded-xl border border-border bg-foreground/[0.03] p-3 font-mono text-meta whitespace-pre-wrap">
|
||||
{text.data?.text}
|
||||
</pre>
|
||||
{text.data?.truncated ? <p className="mt-1 text-meta text-muted-foreground">只显示前 256 KB</p> : null}
|
||||
</>
|
||||
)}
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
Reference in new issue
Block a user