feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)
生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。
- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
(metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)
同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距
依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8013196e3f
commit
ceb66f8c77
265 files changed
+17751
-2483
No files matched your search
@@ -95,7 +95,7 @@ function Option({
|
||||
|
||||
/**
|
||||
* 作品选择器(§14 EntityPicker):上半识别候选(本地 + TMDB),下半搜索本地作品与 TMDB。
|
||||
* 选 TMDB 条目 = 行标"将新建作品",执行时由后端建作品(G1)。
|
||||
* 选 TMDB 条目 = 行标"将新建作品",生成预览时由后端建档(G1)。
|
||||
*/
|
||||
export function EntityPicker({
|
||||
candidates = [],
|
||||
@@ -104,6 +104,7 @@ export function EntityPicker({
|
||||
placeholder = "选择作品…",
|
||||
className,
|
||||
invalid,
|
||||
mediaType = "auto",
|
||||
}: {
|
||||
candidates?: FileIdentifyCandidate[];
|
||||
value: WizardEntity | null;
|
||||
@@ -111,6 +112,7 @@ export function EntityPicker({
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
invalid?: boolean;
|
||||
mediaType?: "auto" | "movie" | "tv";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
@@ -126,7 +128,15 @@ export function EntityPicker({
|
||||
setOpen(false);
|
||||
setQ("");
|
||||
};
|
||||
const hits = search.data;
|
||||
const hits = search.data
|
||||
? {
|
||||
...search.data,
|
||||
local: search.data.local.filter(
|
||||
(h) => mediaType === "auto" || (mediaType === "movie") === (h.kind === "movie"),
|
||||
),
|
||||
tmdb: search.data.tmdb.filter((h) => mediaType === "auto" || h.entityType === mediaType),
|
||||
}
|
||||
: undefined;
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
applyMinSize,
|
||||
applyOffsetTo,
|
||||
defaultOptions,
|
||||
dialogRows,
|
||||
displayPath,
|
||||
downloadContentRef,
|
||||
groupRows,
|
||||
@@ -121,6 +122,18 @@ describe("识别结果 → 行", () => {
|
||||
expect(rowProblem({ ...(rows[0] as WizardRow), season: "" })).toMatch(/季号/);
|
||||
expect(rowProblem({ ...(rows[0] as WizardRow), episodes: "1-x" })).toMatch(/集号/);
|
||||
});
|
||||
|
||||
it("只允许单集或连续集号,不让人工覆写被后端忽略", () => {
|
||||
const r = rowsFromIdentify([item("show/a.mkv")], new Set())[0] as WizardRow;
|
||||
expect(rowProblem({ ...r, episodes: "3,5" })).toBe("集号需要连续,如 3-5");
|
||||
expect(wizardStats([{ ...r, episodes: "3,5" }]).pending).toBe(1);
|
||||
expect(rowProblem({ ...r, episodes: "4,3,4" })).toBeNull();
|
||||
expect(rowProblem({ ...r, episodes: "3-5" })).toBeNull();
|
||||
expect(rowProblem({ ...r, episodes: "0" })).toBeNull();
|
||||
expect(rowProblem({ ...r, episodes: "1-51" })).toMatch(/50/);
|
||||
expect(rowProblem({ ...r, episodes: "3,5", excluded: "manual" })).toBeNull();
|
||||
expect(rowProblem({ ...r, entity: tmdbMovie, episodes: "3,5", season: "" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("批量设置", () => {
|
||||
@@ -152,6 +165,55 @@ describe("批量设置", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("统一整理弹窗范围", () => {
|
||||
it("指定作品覆盖整批,清除后恢复逐文件识别,季集解析保留", () => {
|
||||
const rows = rowsFromIdentify([item("a.mkv"), item("b.mkv", { id: "e2" })], new Set());
|
||||
const selected = dialogRows(rows, tmdbMovie, "", false);
|
||||
expect(selected.map((r) => r.entity)).toEqual([tmdbMovie, tmdbMovie]);
|
||||
expect(dialogRows(rows, null, "", false).map((r) => r.entity?.id)).toEqual(["e1", "e2"]);
|
||||
expect(rows.map((r) => r.entity?.id)).toEqual(["e1", "e2"]);
|
||||
expect(selected.map((r) => r.episodes)).toEqual(["2", "2"]);
|
||||
});
|
||||
|
||||
it("成功历史默认参与;开启跳过后未识别的成功文件也不阻挡预览", () => {
|
||||
const old = {
|
||||
...item("old.mkv", { id: null }),
|
||||
records: { count: 2, lastRecordId: "r", lastStatus: "failed" as const, successfulCount: 1 },
|
||||
};
|
||||
const rows = rowsFromIdentify([old, item("new.mkv")], new Set());
|
||||
expect(wizardStats(dialogRows(rows, null, "", false)).pending).toBe(1);
|
||||
const skipped = dialogRows(rows, null, "", true);
|
||||
expect(skipped[0]?.excluded).toBe("successful_record");
|
||||
expect(wizardStats(skipped).pending).toBe(0);
|
||||
expect(
|
||||
organizeRequest(skipped, defaultOptions({ skipSuccessfulRecords: true }), {
|
||||
dryRun: true,
|
||||
idempotencyKey: "k",
|
||||
}).items.map((r) => r.path),
|
||||
).toEqual(["new.mkv"]);
|
||||
});
|
||||
|
||||
it("手动排除与体积排除不被统一作品或季号覆盖", () => {
|
||||
const rows = rowsFromIdentify([item("a.mkv"), item("small.mkv", { skipped: "below_min_size" })], new Set());
|
||||
rows[0]!.excluded = "manual";
|
||||
const result = dialogRows(rows, tmdbMovie, "3", true);
|
||||
expect(result.map((r) => r.excluded)).toEqual(["manual", "below_min_size"]);
|
||||
expect(result.map((r) => r.season)).toEqual(["3", "3"]);
|
||||
});
|
||||
|
||||
it("预览与入队携带相同刮削和历史跳过策略", () => {
|
||||
const rows = rowsFromIdentify([item("a.mkv")], new Set());
|
||||
const options = defaultOptions({ scrapeAfterImport: true, skipSuccessfulRecords: true });
|
||||
const preview = organizeRequest(rows, options, { dryRun: true, idempotencyKey: "preview" });
|
||||
const queue = organizeRequest(rows, options, { dryRun: false, idempotencyKey: "queue", planIds: ["plan"] });
|
||||
expect(defaultOptions().skipSuccessfulRecords).toBe(false);
|
||||
expect(preview).toMatchObject({ scrapeAfterImport: true, skipSuccessfulRecords: true });
|
||||
const { dryRun: _p, idempotencyKey: _pk, planIds: _pp, ...p } = preview;
|
||||
const { dryRun: _q, idempotencyKey: _qk, planIds: _qp, ...q } = queue;
|
||||
expect(p).toEqual(q);
|
||||
});
|
||||
});
|
||||
|
||||
describe("请求体", () => {
|
||||
it("本地作品带 mediaEntityId,TMDB 候选带 external;电影不带季集;排除项不提交", () => {
|
||||
const rows = rowsFromIdentify([item("a.mkv"), item("b.mkv"), item("c.mkv")], new Set());
|
||||
@@ -283,3 +345,63 @@ describe("来源换算", () => {
|
||||
expect(downloadContentRef(null, "i", [], roots)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("来源配置预览与执行", () => {
|
||||
it("自动目标保持交给配置,手动目录可覆盖,两个请求都携带配置 ID", () => {
|
||||
const rows = rowsFromIdentify([item("series/a.mkv")], new Set());
|
||||
const options = defaultOptions({ action: "move" });
|
||||
const dry = organizeRequest(rows, options, {
|
||||
dryRun: true,
|
||||
idempotencyKey: "preview",
|
||||
sourceConfigId: "profile-1",
|
||||
});
|
||||
const exec = organizeRequest(rows, options, {
|
||||
dryRun: false,
|
||||
idempotencyKey: "execute",
|
||||
sourceConfigId: "profile-1",
|
||||
planIds: ["plan-1"],
|
||||
});
|
||||
expect(dry.sourceConfigId).toBe("profile-1");
|
||||
expect(exec.sourceConfigId).toBe(dry.sourceConfigId);
|
||||
expect(exec.items).toEqual(dry.items);
|
||||
expect(dry.action).toBe("move");
|
||||
expect(dry.targetLibraryRootId).toBeUndefined();
|
||||
expect(dry.items[0]?.targetDir).toBeUndefined();
|
||||
const manual = organizeRequest(
|
||||
rows,
|
||||
{ ...options, target: { mode: "dir", ref: { root: "lib:target", path: "chosen" } } },
|
||||
{ dryRun: true, idempotencyKey: "manual", sourceConfigId: "profile-1" },
|
||||
);
|
||||
expect(manual.items[0]?.targetDir).toEqual({ root: "lib:target", path: "chosen" });
|
||||
expect(manual.sourceConfigId).toBe("profile-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("原地入库请求不携带旧搬运目标、分类来源与替换策略,预览和入队一致", () => {
|
||||
const options = defaultOptions({
|
||||
action: "in_place",
|
||||
target: { mode: "dir", ref: { root: "lib:old", path: "old" } },
|
||||
onExisting: "replace_with_verify",
|
||||
naming: "template",
|
||||
});
|
||||
const rows = rowsFromIdentify([item("Original.S01E02.mkv")], new Set());
|
||||
const dry = organizeRequest(rows, options, {
|
||||
dryRun: true,
|
||||
idempotencyKey: "in-place-dry",
|
||||
sourceConfigId: "old-source",
|
||||
});
|
||||
const exec = organizeRequest(rows, options, {
|
||||
dryRun: false,
|
||||
idempotencyKey: "in-place-exec",
|
||||
sourceConfigId: "old-source",
|
||||
planIds: ["plan"],
|
||||
});
|
||||
expect(dry).toMatchObject({ action: "in_place", naming: "keep", onExisting: "conflict" });
|
||||
expect(dry.items).toHaveLength(1);
|
||||
expect(dry.items[0]?.targetDir).toBeUndefined();
|
||||
expect(dry.sourceConfigId).toBeUndefined();
|
||||
expect(dry.targetLibraryRootId).toBeUndefined();
|
||||
expect(exec.action).toBe(dry.action);
|
||||
expect(exec.naming).toBe(dry.naming);
|
||||
expect(exec.onExisting).toBe(dry.onExisting);
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import { formatEpisodes, parentRel, parseEpisodes, shiftEpisodes } from "~/compo
|
||||
|
||||
// ─── 作品 ────────────────────────────────────────────────────────
|
||||
|
||||
/** 行上选中的作品:本地作品(id)或只在 TMDB 上的候选(external,执行时由后端就地建作品) */
|
||||
/** 行上选中的作品:本地作品(id)或只在 TMDB 上的候选(external,生成预览时由后端建档) */
|
||||
export type WizardEntity = {
|
||||
id: string | null;
|
||||
external: { provider: "tmdb"; externalId: string; kind: "movie" | "tv" } | null;
|
||||
@@ -32,7 +32,7 @@ export const isMovieKind = (k: string | null | undefined) => k === "movie";
|
||||
|
||||
// ─── 行 ──────────────────────────────────────────────────────────
|
||||
|
||||
export type ExcludeReason = "manual" | "below_min_size";
|
||||
export type ExcludeReason = "manual" | "below_min_size" | "successful_record";
|
||||
|
||||
export type WizardRow = {
|
||||
key: string;
|
||||
@@ -55,10 +55,14 @@ export type WizardRow = {
|
||||
export function rowProblem(r: WizardRow): string | null {
|
||||
if (r.excluded) return null;
|
||||
if (!r.entity) return "未选择作品";
|
||||
if (isMovieKind(r.entity.kind)) return null;
|
||||
const eps = parseEpisodes(r.episodes);
|
||||
if (eps === null) return "集号格式不对,如 3、3-5、3,4";
|
||||
if (eps === null) return "集号格式不对,请填写单集或连续区间,如 3、3-5";
|
||||
const unique = [...new Set(eps)].sort((a, b) => a - b);
|
||||
if (eps.length > 50) return "单个文件最多指定 50 个集号";
|
||||
if (unique.some((n, i) => i > 0 && n !== unique[i - 1]! + 1)) return "集号需要连续,如 3-5";
|
||||
if (r.season.trim() && !/^\d{1,4}$/.test(r.season.trim())) return "季号必须是数字";
|
||||
if (!isMovieKind(r.entity.kind) && !r.season.trim()) return "剧集需要季号";
|
||||
if (!r.season.trim()) return "剧集需要季号";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -186,6 +190,28 @@ export function wizardStats(rows: WizardRow[]): WizardStats {
|
||||
};
|
||||
}
|
||||
|
||||
/** 共用弹窗的作品、季号作用于整个本次范围;成功历史跳过独立于目标冲突。 */
|
||||
export function dialogRows(
|
||||
rows: WizardRow[],
|
||||
manual: WizardEntity | null,
|
||||
season: string,
|
||||
skipSuccessfulRecords: boolean,
|
||||
): WizardRow[] {
|
||||
return rows.map((row) => {
|
||||
const excluded =
|
||||
!row.excluded && skipSuccessfulRecords && (row.item.records?.successfulCount ?? 0) > 0
|
||||
? ("successful_record" as const)
|
||||
: row.excluded;
|
||||
return {
|
||||
...row,
|
||||
excluded,
|
||||
entity: manual ?? row.entity,
|
||||
slotId: manual && entityKey(manual) !== entityKey(row.entity) ? "" : row.slotId,
|
||||
season: season.trim() || row.season,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 按源目录分组(保持首次出现顺序) */
|
||||
export function groupRows(rows: WizardRow[]): { group: string; dir: string; rows: WizardRow[] }[] {
|
||||
const map = new Map<string, WizardRow[]>();
|
||||
@@ -198,11 +224,13 @@ export function groupRows(rows: WizardRow[]): { group: string; dir: string; rows
|
||||
export type WizardTarget = { mode: "auto" } | { mode: "root"; rootId: string } | { mode: "dir"; ref: FileRef };
|
||||
|
||||
export type WizardOptions = {
|
||||
action: "copy" | "move";
|
||||
action: "copy" | "move" | "in_place";
|
||||
target: WizardTarget;
|
||||
onExisting: FilesOnExisting;
|
||||
naming: "template" | "keep";
|
||||
refreshMetadata: boolean;
|
||||
scrapeAfterImport: boolean;
|
||||
skipSuccessfulRecords: boolean;
|
||||
minSizeBytes: number;
|
||||
};
|
||||
|
||||
@@ -215,6 +243,8 @@ export const defaultOptions = (o: Partial<WizardOptions> = {}): WizardOptions =>
|
||||
onExisting: "conflict",
|
||||
naming: "template",
|
||||
refreshMetadata: true,
|
||||
scrapeAfterImport: false,
|
||||
skipSuccessfulRecords: false,
|
||||
minSizeBytes: DEFAULT_MIN_SIZE,
|
||||
...o,
|
||||
});
|
||||
@@ -235,7 +265,7 @@ export function organizeItems(rows: WizardRow[], o: WizardOptions): FilesOrganiz
|
||||
if (!movie && r.season.trim()) item.season = Number(r.season.trim());
|
||||
if (!movie && eps?.length) item.episodes = eps;
|
||||
if (r.slotId && e.id) item.slotId = r.slotId;
|
||||
if (o.target.mode === "dir") item.targetDir = o.target.ref;
|
||||
if (o.action !== "in_place" && o.target.mode === "dir") item.targetDir = o.target.ref;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
@@ -244,19 +274,23 @@ export function organizeItems(rows: WizardRow[], o: WizardOptions): FilesOrganiz
|
||||
export function organizeRequest(
|
||||
rows: WizardRow[],
|
||||
o: WizardOptions,
|
||||
x: { dryRun: boolean; idempotencyKey: string; planIds?: string[] },
|
||||
x: { dryRun: boolean; idempotencyKey: string; planIds?: string[]; sourceConfigId?: string },
|
||||
): FilesOrganizeRequest {
|
||||
return {
|
||||
idempotencyKey: x.idempotencyKey,
|
||||
items: organizeItems(rows, o),
|
||||
action: o.action,
|
||||
targetLibraryRootId: o.target.mode === "root" ? libraryRootIdOf(o.target.rootId) : undefined,
|
||||
sourceConfigId: o.action === "in_place" ? undefined : x.sourceConfigId,
|
||||
targetLibraryRootId:
|
||||
o.action !== "in_place" && o.target.mode === "root" ? libraryRootIdOf(o.target.rootId) : undefined,
|
||||
dryRun: x.dryRun,
|
||||
planIds: x.planIds,
|
||||
minSizeBytes: o.minSizeBytes,
|
||||
onExisting: o.onExisting,
|
||||
naming: o.naming,
|
||||
onExisting: o.action === "in_place" ? "conflict" : o.onExisting,
|
||||
naming: o.action === "in_place" ? "keep" : o.naming,
|
||||
refreshMetadata: o.refreshMetadata,
|
||||
scrapeAfterImport: o.scrapeAfterImport,
|
||||
skipSuccessfulRecords: o.skipSuccessfulRecords,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -326,6 +360,7 @@ export function nestSidecars(files: GroupFile[]): { main: GroupFile; sidecars: G
|
||||
}
|
||||
|
||||
export const EXCLUDE_REASON: Record<string, string> = {
|
||||
successful_record: "已有成功整理记录",
|
||||
below_min_size: "低于最小体积",
|
||||
sample: "样片",
|
||||
too_small: "体积过小",
|
||||
@@ -339,6 +374,7 @@ export const EXCLUDE_REASON: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const SKIP_REASON: Record<string, string> = {
|
||||
successful_record: "已有成功整理记录",
|
||||
already_in_library: "已在库中",
|
||||
target_exists: "目标已存在",
|
||||
main_not_transferred: "主文件未整理",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -70,6 +70,7 @@ export function StepOptions({
|
||||
roots,
|
||||
hnrCount,
|
||||
kinds,
|
||||
profileLabel,
|
||||
}: {
|
||||
options: WizardOptions;
|
||||
onChange: (o: WizardOptions) => void;
|
||||
@@ -78,6 +79,7 @@ export function StepOptions({
|
||||
hnrCount: number;
|
||||
/** 本次涉及的作品类型(过滤库根) */
|
||||
kinds: Set<string>;
|
||||
profileLabel?: string;
|
||||
}) {
|
||||
const set = (p: Partial<WizardOptions>) => onChange({ ...options, ...p });
|
||||
const libRoots = roots.filter((r) => r.kind === "library" && r.writable && r.health !== "offline");
|
||||
@@ -109,7 +111,6 @@ export function StepOptions({
|
||||
title="移动"
|
||||
hint="入库后删源;跨卷时按复制 → 核验 → 删源"
|
||||
/>
|
||||
<Choice checked={false} onSelect={() => {}} disabled="还不支持硬链接" title="硬链接" />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -144,7 +145,11 @@ export function StepOptions({
|
||||
checked={t.mode === "auto"}
|
||||
onSelect={() => set({ target: { mode: "auto" } })}
|
||||
title="自动"
|
||||
hint="已绑定的作品沿用原目录,否则按作品类型选媒体库目录"
|
||||
hint={
|
||||
profileLabel
|
||||
? `按来源「${profileLabel}」的目标目录与分类规则整理`
|
||||
: "已绑定的作品沿用原目录,否则按作品类型选媒体库目录"
|
||||
}
|
||||
/>
|
||||
{libRoots.map((r) => (
|
||||
<Choice
|
||||
@@ -214,7 +219,9 @@ export function StepOptions({
|
||||
aria-label="入库后刷新元数据"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-meta text-muted-foreground">同名字幕、NFO、图片随主文件一起处理,预览里列出。</p>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
同目录同名字幕和外挂音轨会随视频整理,预览里列出。现有 NFO、图片不搬入媒体库,可在整理后刮削生成。
|
||||
</p>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { Link } from "react-router";
|
||||
import type { FileRoot, FilesOrganizeGroup } from "~/api/files.ts";
|
||||
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE } from "~/api/records.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||
import { baseName, parentRel } from "~/components/files/model.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { recordFileProgress } from "~/components/organize/record-parts.ts";
|
||||
import { Pagination } from "~/components/ui/pagination.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
import {
|
||||
@@ -18,7 +20,6 @@ import {
|
||||
groupHasProblem,
|
||||
nestSidecars,
|
||||
type PreviewStats,
|
||||
pageOf,
|
||||
SKIP_REASON,
|
||||
TEMPLATE_SOURCE,
|
||||
} from "./model.ts";
|
||||
@@ -108,6 +109,8 @@ function FileLine({ f, roots, nested }: { f: GroupFile; roots: FileRoot[]; neste
|
||||
<StatusBadge>跳过{f.skipReason ? ` · ${SKIP_REASON[f.skipReason] ?? f.skipReason}` : ""}</StatusBadge>
|
||||
) : f.action === "replace" ? (
|
||||
<StatusBadge tone="warning">替换</StatusBadge>
|
||||
) : f.target === f.source ? (
|
||||
<StatusBadge>原地登记</StatusBadge>
|
||||
) : !f.target ? (
|
||||
<StatusBadge>不入库</StatusBadge>
|
||||
) : null;
|
||||
@@ -202,6 +205,12 @@ export function PreviewGroupCard({
|
||||
{errorText(g.error)}
|
||||
</p>
|
||||
) : null}
|
||||
{result && record && recordFileProgress(record) ? (
|
||||
<p className="break-all text-caption text-muted-foreground">
|
||||
已完成 {recordFileProgress(record)?.completed}/{recordFileProgress(record)?.total} 个文件
|
||||
{recordFileProgress(record)?.current ? ` · 当前:${recordFileProgress(record)?.current}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{record?.reason && result ? <p className="text-caption text-warning">{record.reason.message}</p> : null}
|
||||
{g.warnings.map((w) => (
|
||||
<p key={w} className="flex items-start gap-1.5 text-caption text-warning">
|
||||
@@ -245,25 +254,21 @@ export function PreviewList({
|
||||
titleOf,
|
||||
roots,
|
||||
onlyProblems,
|
||||
page,
|
||||
onPage,
|
||||
}: {
|
||||
groups: FilesOrganizeGroup[];
|
||||
titleOf: GroupTitle;
|
||||
roots: FileRoot[];
|
||||
onlyProblems: boolean;
|
||||
page: number;
|
||||
onPage: (p: number) => void;
|
||||
}) {
|
||||
const shown = onlyProblems ? groups.filter(groupHasProblem) : groups;
|
||||
const pg = pageOf(shown, page);
|
||||
const { items, pagination } = useListPagination(shown, { resetKey: String(onlyProblems) });
|
||||
if (!shown.length)
|
||||
return (
|
||||
<p className="py-8 text-center text-caption text-muted-foreground">没有问题项。关闭「只看问题」查看全部。</p>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{pg.items.map((g, i) => (
|
||||
{items?.map((g, i) => (
|
||||
<PreviewGroupCard
|
||||
key={`${g.mediaEntityId}-${g.contentUnitId}-${g.planId ?? i}`}
|
||||
g={g}
|
||||
@@ -272,19 +277,7 @@ export function PreviewList({
|
||||
onlyProblems={onlyProblems}
|
||||
/>
|
||||
))}
|
||||
{pg.pages > 1 ? (
|
||||
<div className="flex items-center justify-center gap-2 text-caption">
|
||||
<Button size="sm" variant="outline" disabled={pg.page === 0} onClick={() => onPage(pg.page - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{pg.page + 1} / {pg.pages}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" disabled={pg.page >= pg.pages - 1} onClick={() => onPage(pg.page + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<Pagination {...pagination} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user