- library list (kind/status/search, cursor paging) and detail: paths and roots, external identity mapping with lock, field-level metadata sources and locks, slot cards with rejection reasons, content units (provisional flag), downloads with verification; remap/lock/refresh/merge actions - subscriptions: list/create/update, slot rules editor (hard/soft/upgrade/completion), policy revision draft -> preview -> publish (grant warning) -> rollback - preferences: tag CRUD, disable/enable, suppression records and lift, history timeline - inbox: grouped by entity and kind, evidence/suggestion/impact/preview, resolve options, SSE refresh - identity: pool assign/dismiss/retry, external identity revisions and lock, title alias management - all responses validated against contracts zod schemas Co-Authored-By: Claude Opus 5.5 <[email protected]>
669 lines
24 KiB
TypeScript
669 lines
24 KiB
TypeScript
import type {
|
|
ContentUnit,
|
|
EntityMetadataField,
|
|
ExternalIdentity,
|
|
LibraryAssetView,
|
|
LibraryDetail,
|
|
LibraryDownloadView,
|
|
MediaEntityDetail,
|
|
} from "@umt/contracts";
|
|
import { EntityMetadataField as FieldEnum, FinalFieldValue } from "@umt/contracts";
|
|
import {
|
|
ArrowLeft,
|
|
CalendarClock,
|
|
ChevronRight,
|
|
FolderTree,
|
|
GitMerge,
|
|
Lock,
|
|
LockOpen,
|
|
PencilLine,
|
|
RefreshCw,
|
|
SearchX,
|
|
} from "lucide-react";
|
|
import { useState } from "react";
|
|
import { Link, useParams } from "react-router";
|
|
import { toast } from "sonner";
|
|
import {
|
|
useEntityDetail,
|
|
useLibraryDetail,
|
|
useLibraryRoots,
|
|
useRefreshMetadata,
|
|
useUnlockField,
|
|
} from "~/api/library.ts";
|
|
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
|
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
|
import { consolePath } from "~/components/console/nav.ts";
|
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
|
import { evidenceLabel, FieldLockDialog, IdentityDialog, MergeDialog } from "~/components/library/entity-dialogs.tsx";
|
|
import {
|
|
CONTENT_TYPE,
|
|
DOWNLOAD_STATUS,
|
|
ENTITY_FIELD_LABEL,
|
|
ENTITY_STATUS,
|
|
IDENTITY_STATUS,
|
|
LOCATION_KIND,
|
|
LOCATION_STATUS,
|
|
MEDIA_KIND,
|
|
METADATA_SOURCE,
|
|
ORG_STATUS,
|
|
SEGMENT,
|
|
UNIT_KIND,
|
|
VERIFY_STATUS,
|
|
} from "~/components/library/labels.ts";
|
|
import { Chip, ChipList, CoverPlaceholder, Panel, ProgressLine, relTime, Tone } from "~/components/library/parts.tsx";
|
|
import { SlotCard } from "~/components/library/slot-card.tsx";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { errorMessage } from "~/lib/api.ts";
|
|
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
|
|
|
// 作品详情(REQ §13.1 / §13.4.3):实际路径、外部身份映射及状态、元数据来源、各 Slot、候选拒绝理由、
|
|
// 内容单元、下载历史与校验结果,同一页同时展示。
|
|
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
|
|
function unitCode(u: ContentUnit): string {
|
|
const seg = SEGMENT[u.segment] ? `(${SEGMENT[u.segment]})` : "";
|
|
if (u.kind === "season") return u.seasonNumber !== null ? `S${pad(u.seasonNumber)}` : "季";
|
|
if (u.kind === "episode") {
|
|
const s = u.seasonNumber !== null ? `S${pad(u.seasonNumber)}` : "";
|
|
const e = u.episodeNumber !== null ? `E${pad(u.episodeNumber)}` : "";
|
|
return `${s}${e}${seg}` || (u.sourceIssue ?? "集");
|
|
}
|
|
return `${UNIT_KIND[u.kind] ?? u.kind}${u.episodeNumber !== null ? ` ${u.episodeNumber}` : ""}${seg}`;
|
|
}
|
|
|
|
function deliveryLine(a: LibraryAssetView["asset"]): string {
|
|
const d = a.delivery;
|
|
return [
|
|
d.resolution,
|
|
d.sourceType,
|
|
d.videoCodec,
|
|
d.hdr?.join("/"),
|
|
d.releaseGroup,
|
|
a.contentEdition ? `版本 ${a.contentEdition}` : null,
|
|
a.sizeBytes !== null ? formatBytes(a.sizeBytes, 1) : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ");
|
|
}
|
|
|
|
// ── 实际路径与库根 ──
|
|
function PathsPanel({ detail }: { detail: LibraryDetail }) {
|
|
const roots = useLibraryRoots();
|
|
const rootOf = (id: string | null) => roots.data?.items.find((r) => r.id === id);
|
|
const bindings = detail.pathBindings.filter((b) => b.status !== "superseded");
|
|
return (
|
|
<Panel title="实际路径与库根" description="路径绑定一旦建立不随译名变化;执行进程可访问的绝对路径按库根拼接">
|
|
{bindings.length === 0 && detail.assets.length === 0 ? (
|
|
<EmptyState
|
|
plain
|
|
icon={FolderTree}
|
|
title="还没有入库文件"
|
|
description="收录目标满足后,文件位置会显示在这里。"
|
|
className="py-8"
|
|
/>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{bindings.length ? (
|
|
<ul className="space-y-2">
|
|
{bindings.map((b) => {
|
|
const root = rootOf(b.libraryRootId);
|
|
return (
|
|
<li key={b.id} className="rounded-lg border border-border/70 px-3 py-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Chip>{b.bindingKind === "season" ? `第 ${b.seasonNumber} 季目录` : "作品目录"}</Chip>
|
|
<span className="text-meta text-muted-foreground">库根 {root?.name ?? "未知库根"}</span>
|
|
{b.status === "conflict" ? <StatusBadge tone="destructive">绑定冲突</StatusBadge> : null}
|
|
</div>
|
|
<p className="mt-1 font-mono text-caption break-all text-foreground">
|
|
{root ? `${root.localPath.replace(/\/+$/, "")}/` : ""}
|
|
{b.relativePath}
|
|
</p>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
) : null}
|
|
{detail.assets.map((a) => (
|
|
<div key={a.asset.id} className="rounded-lg border border-border/70 px-3 py-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-sm font-medium text-foreground">{deliveryLine(a.asset) || "未知规格"}</span>
|
|
{a.asset.status !== "active" ? <StatusBadge tone="warning">{a.asset.status}</StatusBadge> : null}
|
|
{a.asset.verifiedAt ? (
|
|
<StatusBadge tone="success">已核验 {relTime(a.asset.verifiedAt)}</StatusBadge>
|
|
) : (
|
|
<StatusBadge>未核验</StatusBadge>
|
|
)}
|
|
</div>
|
|
<ul className="mt-2 space-y-1.5">
|
|
{a.locations.map((l) => (
|
|
<li key={l.id} className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<Chip>{LOCATION_KIND[l.locationKind] ?? l.locationKind}</Chip>
|
|
{l.isPrimary ? <Chip>主位置</Chip> : null}
|
|
<Tone v={LOCATION_STATUS[l.status]} />
|
|
{l.libraryRootId ? (
|
|
<span className="text-meta text-muted-foreground">
|
|
{rootOf(l.libraryRootId)?.name ?? "库根"}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<p className="mt-0.5 font-mono text-meta break-all text-muted-foreground">
|
|
{l.absolutePath ?? l.relativePath}
|
|
</p>
|
|
</li>
|
|
))}
|
|
{a.locations.length === 0 ? <li className="text-meta text-muted-foreground">没有登记位置</li> : null}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
// ── 外部身份映射 ──
|
|
function IdentityPanel({ detail }: { detail: LibraryDetail }) {
|
|
const [open, setOpen] = useState(false);
|
|
const list = [...detail.identities].sort((a, b) => order(a) - order(b));
|
|
const candidates = detail.identities.filter((i) => i.status === "proposed" || i.status === "conflicted");
|
|
return (
|
|
<Panel
|
|
title="外部身份映射"
|
|
description="本地 UUID 是主身份,TMDB 等外部 ID 是可修订的辅助映射"
|
|
actions={
|
|
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
|
<PencilLine />
|
|
改映射
|
|
</Button>
|
|
}
|
|
>
|
|
{list.length === 0 ? (
|
|
<EmptyState plain icon={SearchX} title="还没有外部映射" description="可以手动指定 TMDB id。" className="py-8" />
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{list.map((i) => (
|
|
<IdentityRow key={i.id} i={i} />
|
|
))}
|
|
</ul>
|
|
)}
|
|
<IdentityDialog
|
|
entityId={detail.entity.id}
|
|
entityKind={detail.entity.kind}
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
candidates={candidates}
|
|
/>
|
|
</Panel>
|
|
);
|
|
}
|
|
const order = (i: ExternalIdentity) => ["accepted", "conflicted", "proposed", "retired"].indexOf(i.status);
|
|
|
|
function IdentityRow({ i }: { i: ExternalIdentity }) {
|
|
const reason = typeof i.evidence.reason === "string" ? i.evidence.reason : null;
|
|
return (
|
|
<li className="rounded-lg border border-border/70 px-3 py-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="font-mono text-caption break-all text-foreground">
|
|
{i.provider}:{i.entityType}:{i.externalId}
|
|
</span>
|
|
<Tone v={IDENTITY_STATUS[i.status]} />
|
|
{i.locked ? (
|
|
<StatusBadge>
|
|
<Lock className="h-3 w-3" />
|
|
人工锁
|
|
</StatusBadge>
|
|
) : null}
|
|
</div>
|
|
<p className="mt-1 text-meta text-muted-foreground">
|
|
证据 {evidenceLabel(i.evidenceLevel)} · {VERIFY_STATUS[i.verifyStatus]?.label ?? i.verifyStatus}
|
|
{i.verifiedAt ? `(${relTime(i.verifiedAt)})` : ""} · r{i.revision}
|
|
</p>
|
|
{reason ? <p className="mt-0.5 text-meta break-words text-muted-foreground">{reason}</p> : null}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
// ── 元数据字段来源与锁 ──
|
|
function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: MediaEntityDetail | undefined }) {
|
|
const id = detail.entity.id;
|
|
const refresh = useRefreshMetadata(id);
|
|
const unlock = useUnlockField(id);
|
|
const [editing, setEditing] = useState<EntityMetadataField | null>(null);
|
|
const locks = new Set(detail.metadataSources.fieldLocks);
|
|
const fields = detail.metadataSources.fields;
|
|
const finals = FieldEnum.options.map((f) => {
|
|
const parsed = FinalFieldValue.safeParse(fields[f]);
|
|
return { field: f, final: parsed.success ? parsed.data : null, locked: locks.has(f) };
|
|
});
|
|
const candidatesOf = (f: string) => (entity?.metadataFields ?? []).filter((m) => m.field === f);
|
|
const current = editing ? finals.find((x) => x.field === editing)?.final?.value : undefined;
|
|
|
|
return (
|
|
<Panel
|
|
title="元数据"
|
|
description={`字段级来源;锁定的字段刷新时不会被改写 · 最近刷新 ${formatDateTime(detail.entity.lastMetadataRefreshAt)}`}
|
|
actions={
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={refresh.isPending}
|
|
onClick={() =>
|
|
refresh.mutate(undefined, {
|
|
onSuccess: () => toast.success("已提交元数据刷新(锁定字段保持不变)"),
|
|
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
|
|
})
|
|
}
|
|
>
|
|
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
|
|
刷新元数据
|
|
</Button>
|
|
}
|
|
>
|
|
<ul className="divide-y divide-border/60">
|
|
{finals.map(({ field, final, locked }) => {
|
|
const cands = candidatesOf(field);
|
|
return (
|
|
<li key={field} className="flex flex-wrap items-start gap-x-3 gap-y-1 py-2 first:pt-0 last:pb-0">
|
|
<div className="w-24 shrink-0 pt-0.5 text-meta text-muted-foreground">{ENTITY_FIELD_LABEL[field]}</div>
|
|
<div className="min-w-0 flex-1 basis-48">
|
|
<p className="text-sm break-words text-foreground">
|
|
{final ? String(final.value ?? "—") : <span className="text-muted-foreground">未提供</span>}
|
|
</p>
|
|
{final ? (
|
|
<p className="text-meta text-muted-foreground">
|
|
来源 {METADATA_SOURCE[final.source] ?? final.source}
|
|
{final.language ? ` · ${final.language}` : ""} · {relTime(final.at)}
|
|
</p>
|
|
) : null}
|
|
{cands.length ? (
|
|
<p className="mt-0.5 text-meta break-words text-muted-foreground">
|
|
候选:
|
|
{cands.map((c) => `${METADATA_SOURCE[c.source] ?? c.source}「${String(c.value)}」`).join(" / ")}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
{locked ? (
|
|
<>
|
|
<StatusBadge>
|
|
<Lock className="h-3 w-3" />
|
|
已锁定
|
|
</StatusBadge>
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button variant="ghost" size="icon-sm" aria-label={`解锁${ENTITY_FIELD_LABEL[field]}`}>
|
|
<LockOpen />
|
|
</Button>
|
|
}
|
|
title={`解锁「${ENTITY_FIELD_LABEL[field]}」?`}
|
|
description="取消人工覆盖与锁定,下次刷新会按来源优先级重新取值。"
|
|
confirmLabel="解锁"
|
|
onConfirm={() =>
|
|
unlock.mutateAsync(field).then(
|
|
() => toast.success("已解锁"),
|
|
(err) => {
|
|
toast.error(errorMessage(err, "解锁失败"));
|
|
throw err;
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</>
|
|
) : null}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label={`修改并锁定${ENTITY_FIELD_LABEL[field]}`}
|
|
onClick={() => setEditing(field)}
|
|
>
|
|
<PencilLine />
|
|
</Button>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
{entity?.aliases.length ? (
|
|
<div className="mt-3 border-t border-border/60 pt-3">
|
|
<p className="mb-1.5 text-meta text-muted-foreground">别名(用于模糊召回)</p>
|
|
<ChipList
|
|
items={entity.aliases.map((a) => (
|
|
<span key={a.id}>
|
|
{a.alias}
|
|
{a.locked ? <Lock className="ml-1 inline h-3 w-3" /> : null}
|
|
</span>
|
|
))}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<FieldLockDialog entityId={id} field={editing} current={current} onOpenChange={(o) => !o && setEditing(null)} />
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
// ── Slot ──
|
|
function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUnit[] }) {
|
|
if (detail.slots.length === 0) {
|
|
return (
|
|
<Panel title="收录目标(Slot)">
|
|
<EmptyState
|
|
plain
|
|
icon={CalendarClock}
|
|
title="这部作品还没有收录目标"
|
|
description="在订阅与收录规则里新建订阅或 Slot,系统会按目标持续补齐与升级。"
|
|
action={
|
|
<Button asChild variant="outline" size="sm">
|
|
<Link to={consolePath("subscriptions")}>去订阅与收录规则</Link>
|
|
</Button>
|
|
}
|
|
className="py-8"
|
|
/>
|
|
</Panel>
|
|
);
|
|
}
|
|
const assetOf = (id: string | null) => detail.assets.find((a) => a.asset.id === id);
|
|
return (
|
|
<Panel title="收录目标(Slot)" description="每个目标独立判断硬条件、软偏好与升级;状态均配文字">
|
|
<div className="grid gap-3 xl:grid-cols-2">
|
|
{detail.slots.map((s) => {
|
|
const cur = assetOf(s.currentAssetId);
|
|
const unit = units.find((u) => u.id === s.contentUnitId);
|
|
return (
|
|
<SlotCard
|
|
key={s.id}
|
|
slot={s}
|
|
unitLabel={unit ? unitCode(unit) : undefined}
|
|
currentAsset={cur ? deliveryLine(cur.asset) || "未知规格" : undefined}
|
|
candidates={detail.candidates.filter((c) => c.slotId === s.id).slice(0, 5)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
// ── 内容单元 ──
|
|
function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDetail>; detail: LibraryDetail }) {
|
|
const covered = new Set(detail.assets.flatMap((a) => a.contentUnitIds));
|
|
const columns: DataColumn<ContentUnit>[] = [
|
|
{
|
|
key: "code",
|
|
header: "编号",
|
|
cell: (u) => <span className="font-mono text-caption whitespace-nowrap">{unitCode(u)}</span>,
|
|
},
|
|
{
|
|
key: "title",
|
|
header: "标题 / 期号",
|
|
cell: (u) => (
|
|
<div className="min-w-0">
|
|
<p className="max-w-[12rem] truncate text-sm sm:max-w-xs">{u.title ?? u.sourceIssue ?? "—"}</p>
|
|
<p className="text-meta text-muted-foreground">
|
|
{UNIT_KIND[u.kind]} · {CONTENT_TYPE[u.contentType]}
|
|
{u.airDate ? ` · ${u.airDate}` : ""}
|
|
</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "status",
|
|
header: "状态",
|
|
cell: (u) => (
|
|
<div className="flex flex-wrap gap-1">
|
|
{u.status === "provisional" ? (
|
|
<StatusBadge tone="warning">临时单元</StatusBadge>
|
|
) : u.status === "retired" ? (
|
|
<StatusBadge>已退役</StatusBadge>
|
|
) : (
|
|
<StatusBadge tone="success">已确认</StatusBadge>
|
|
)}
|
|
{u.numberingLocked ? (
|
|
<StatusBadge>
|
|
<Lock className="h-3 w-3" />
|
|
编号锁定
|
|
</StatusBadge>
|
|
) : null}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "asset",
|
|
header: "已有文件",
|
|
hideBelow: "sm",
|
|
cell: (u) =>
|
|
covered.has(u.id) ? (
|
|
<StatusBadge tone="success">已入库</StatusBadge>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
),
|
|
},
|
|
];
|
|
const units = [...(entity.data?.contentUnits ?? [])].sort(
|
|
(a, b) =>
|
|
(a.seasonNumber ?? 999) - (b.seasonNumber ?? 999) ||
|
|
(a.kind === "season" ? -1 : 0) - (b.kind === "season" ? -1 : 0) ||
|
|
(a.episodeNumber ?? 0) - (b.episodeNumber ?? 0) ||
|
|
a.segment.localeCompare(b.segment),
|
|
);
|
|
return (
|
|
<Panel
|
|
title="内容单元"
|
|
description="季 / 集 / 期与分段;临时单元是按解析结果先建的,确认或改编号后锁定"
|
|
bodyClassName="p-0 sm:p-0"
|
|
>
|
|
<DataTable
|
|
plain
|
|
columns={columns}
|
|
rows={entity.data ? units : undefined}
|
|
rowKey={(u) => u.id}
|
|
loading={entity.isPending}
|
|
error={entity.error}
|
|
onRetry={() => entity.refetch()}
|
|
empty={{
|
|
title: detail.entity.kind === "movie" ? "电影没有分集单元" : "还没有内容单元",
|
|
description:
|
|
detail.entity.kind === "movie" ? "电影按内容版本核对完整性。" : "识别到新集或期后会自动建临时单元。",
|
|
}}
|
|
/>
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
// ── 下载历史与校验 ──
|
|
function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
|
|
const columns: DataColumn<LibraryDownloadView>[] = [
|
|
{
|
|
key: "name",
|
|
header: "下载",
|
|
cell: (d) => (
|
|
<div className="min-w-0">
|
|
<Link
|
|
to={consolePath("tasks", d.downloadTask.id)}
|
|
className="block max-w-[14rem] truncate font-mono text-caption text-foreground hover:underline sm:max-w-md"
|
|
title={d.downloadTask.name}
|
|
>
|
|
{d.downloadTask.name}
|
|
</Link>
|
|
<p className="text-meta text-muted-foreground">
|
|
{d.plan
|
|
? `${d.plan.purpose === "upgrade" ? "升级" : d.plan.purpose === "fill" ? "补缺" : "换源"}计划 · `
|
|
: ""}
|
|
{formatDateTime(d.downloadTask.createdAt)}
|
|
</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "status",
|
|
header: "下载状态",
|
|
cell: (d) => (
|
|
<div className="min-w-24 space-y-1">
|
|
<Tone v={DOWNLOAD_STATUS[d.downloadTask.status]} />
|
|
{d.downloadTask.status !== "imported" ? (
|
|
<div className="flex items-center gap-2">
|
|
<ProgressLine value={d.downloadTask.progress} className="w-16" />
|
|
<span className="text-meta text-muted-foreground tabular-nums">
|
|
{Math.round(d.downloadTask.progress * 100)}%
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "size",
|
|
header: "大小",
|
|
align: "right",
|
|
hideBelow: "md",
|
|
cell: (d) => (
|
|
<span className="text-meta tabular-nums">
|
|
{d.downloadTask.sizeBytes !== null ? formatBytes(d.downloadTask.sizeBytes, 1) : "—"}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: "verify",
|
|
header: "整理与校验",
|
|
cell: (d) =>
|
|
d.organizationRecordIds.length ? (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{d.lastOrganizationStatus ? <Tone v={ORG_STATUS[d.lastOrganizationStatus]} /> : null}
|
|
{d.organizationRecordIds.map((rid, i) => (
|
|
<Link
|
|
key={rid}
|
|
to={consolePath("organize", rid)}
|
|
className="inline-flex items-center text-meta text-muted-foreground hover:text-foreground hover:underline"
|
|
>
|
|
记录 {i + 1}
|
|
<ChevronRight className="h-3 w-3" />
|
|
</Link>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span className="text-meta text-muted-foreground">尚未整理</span>
|
|
),
|
|
},
|
|
];
|
|
return (
|
|
<Panel
|
|
title="下载历史与校验结果"
|
|
description="下载与入库串成一个业务过程;整理记录含逐文件核验"
|
|
bodyClassName="p-0 sm:p-0"
|
|
>
|
|
<DataTable
|
|
plain
|
|
columns={columns}
|
|
rows={detail.downloads}
|
|
rowKey={(d) => d.downloadTask.id}
|
|
empty={{ title: "还没有下载记录" }}
|
|
/>
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
export default function LibraryDetailPage() {
|
|
const { id } = useParams();
|
|
const detail = useLibraryDetail(id);
|
|
const entity = useEntityDetail(id);
|
|
const [merging, setMerging] = useState(false);
|
|
|
|
const back = (
|
|
<Link
|
|
to={consolePath("library")}
|
|
className="mb-4 inline-flex items-center gap-1 text-caption text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
媒体库
|
|
</Link>
|
|
);
|
|
|
|
if (detail.isPending) {
|
|
return (
|
|
<>
|
|
{back}
|
|
<LoadingState rows={8} />
|
|
</>
|
|
);
|
|
}
|
|
if (detail.isError) {
|
|
const notFound = (detail.error as { code?: string }).code === "not_found";
|
|
return (
|
|
<>
|
|
{back}
|
|
<ErrorState
|
|
title={notFound ? "作品不存在" : "作品加载失败"}
|
|
error={notFound ? "这个 ID 没有对应的作品,可能已被删除。" : detail.error}
|
|
onRetry={notFound ? undefined : () => detail.refetch()}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
const d = detail.data;
|
|
const e = d.entity;
|
|
const redirected = entity.data?.redirectedFrom;
|
|
return (
|
|
<>
|
|
{back}
|
|
{e.status === "merged" && e.mergedIntoId ? (
|
|
<div className="mb-4 rounded-xl border border-warning/30 bg-warning/10 px-4 py-3 text-caption text-warning">
|
|
这部作品已合并。
|
|
<Link to={consolePath("library", e.mergedIntoId)} className="ml-1 underline">
|
|
查看合并后的作品
|
|
</Link>
|
|
</div>
|
|
) : null}
|
|
{redirected ? (
|
|
<p className="mb-4 text-meta text-muted-foreground">旧 ID {redirected} 已跳转到当前作品。</p>
|
|
) : null}
|
|
<header className="mb-6 flex flex-wrap items-start gap-4">
|
|
<CoverPlaceholder title={e.title} kind={e.kind} className="h-28 w-20 sm:h-36 sm:w-24" />
|
|
<div className="min-w-0 flex-1 basis-56">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h1 className="min-w-0 text-title-lg font-semibold tracking-tight break-words text-foreground">
|
|
{e.title}
|
|
</h1>
|
|
<Tone v={ENTITY_STATUS[e.status]} />
|
|
</div>
|
|
<p className="mt-1 text-body text-muted-foreground">
|
|
{MEDIA_KIND[e.kind]}
|
|
{e.year ? ` · ${e.year}` : ""}
|
|
{e.originalTitle ? ` · ${e.originalTitle}` : ""}
|
|
</p>
|
|
<p className="mt-1 font-mono text-meta break-all text-muted-foreground">{e.id}</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button asChild variant="outline" size="sm">
|
|
<Link to={`${consolePath("subscriptions")}?mediaEntityId=${e.id}`}>
|
|
<CalendarClock />
|
|
订阅与目标
|
|
</Link>
|
|
</Button>
|
|
{e.status !== "merged" ? (
|
|
<Button variant="outline" size="sm" onClick={() => setMerging(true)}>
|
|
<GitMerge />
|
|
合并到另一作品
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</header>
|
|
|
|
<div className="space-y-5">
|
|
<SlotsPanel detail={d} units={entity.data?.contentUnits ?? []} />
|
|
<div className="grid gap-5 xl:grid-cols-2">
|
|
<IdentityPanel detail={d} />
|
|
<MetadataPanel detail={d} entity={entity.data} />
|
|
</div>
|
|
<PathsPanel detail={d} />
|
|
{e.kind !== "movie" || (entity.data?.contentUnits.length ?? 0) > 0 ? (
|
|
<UnitsPanel entity={entity} detail={d} />
|
|
) : null}
|
|
<DownloadsPanel detail={d} />
|
|
</div>
|
|
<MergeDialog entity={e} open={merging} onOpenChange={setMerging} />
|
|
</>
|
|
);
|
|
}
|