feat(web): W3-2 library, subscriptions and rules, preferences, decisions inbox, identity and metadata

- 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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 20:39:02 +08:00
1 parent e5e42b1f0d
commit ef73abe3df
25 files changed
+6670 -19

No files matched your search

@@ -0,0 +1,439 @@
import {
EntityIdentitySet,
type EntityMetadataField,
type ExternalIdentity,
type MediaEntity,
type MediaKind,
} from "@umt/contracts";
import { Check, LoaderCircle, Search } from "lucide-react";
import { type FormEvent, useId, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { z } from "zod";
import { useEntitySearch, useLockField, useMergeEntity, useSetEntityIdentity } from "~/api/library.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { ENTITY_FIELD_LABEL, EVIDENCE_LEVEL, IDENTITY_STATUS, MEDIA_KIND } from "./labels.ts";
import { Tone } from "./parts.tsx";
// 作品详情页的三个操作对话框:改外部映射 / 字段覆盖并锁定 / 合并到另一作品。
// ── 改外部映射 ──
const IdentityForm = EntityIdentitySet.extend({
externalId: z.string().trim().regex(/^\d+$/, "TMDB id 是纯数字"),
});
export function IdentityDialog({
entityId,
entityKind,
open,
onOpenChange,
candidates,
}: {
entityId: string;
entityKind: MediaKind;
open: boolean;
onOpenChange: (o: boolean) => void;
/** 可直接选用的候选(proposed / conflicted 映射) */
candidates: ExternalIdentity[];
}) {
const id = useId();
const set = useSetEntityIdentity(entityId);
const [pick, setPick] = useState<string | null>(null);
const [entityType, setEntityType] = useState<"movie" | "tv">(entityKind === "movie" ? "movie" : "tv");
const [externalId, setExternalId] = useState("");
const [lock, setLock] = useState(true);
const [reason, setReason] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
const choose = (c: ExternalIdentity | null) => {
setPick(c?.id ?? null);
if (c && (c.entityType === "movie" || c.entityType === "tv")) {
setEntityType(c.entityType);
setExternalId(c.externalId);
}
};
const submit = (e: FormEvent) => {
e.preventDefault();
const r = IdentityForm.safeParse({
provider: "tmdb",
entityType,
externalId,
lock,
reason: reason.trim() || undefined,
});
if (!r.success) {
setErrors(zodFieldErrors(r.error.issues));
return;
}
setErrors({});
set.mutate(r.data, {
onSuccess: () => {
toast.success(`已改为 TMDB ${r.data.entityType}:${r.data.externalId}${r.data.lock ? ",并加人工锁" : ""}`);
onOpenChange(false);
},
onError: (err) => toast.error(errorMessage(err, "改映射失败")),
});
};
const usable = candidates.filter((c) => c.provider === "tmdb" && (c.entityType === "movie" || c.entityType === "tv"));
return (
<Dialog open={open} onOpenChange={(o) => !set.isPending && onOpenChange(o)}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>改外部映射</DialogTitle>
<DialogDescription>
旧的同类型 TMDB 映射会退役并留下修订记录;新映射直接确认。加人工锁后自动匹配与刷新不再改动它。
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-3">
{usable.length ? (
<div className="grid gap-1.5">
<p className="text-caption font-medium text-foreground">从候选中选</p>
<ul className="grid gap-1.5">
{usable.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => choose(pick === c.id ? null : c)}
aria-pressed={pick === c.id}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-left transition-colors duration-micro",
pick === c.id ? "pill-selected" : "border-border hover:bg-foreground/[0.04]",
)}
>
{pick === c.id ? <Check className="h-4 w-4 shrink-0" /> : <span className="h-4 w-4 shrink-0" />}
<span className="min-w-0 flex-1 font-mono text-caption break-all">
tmdb:{c.entityType}:{c.externalId}
</span>
<Tone v={IDENTITY_STATUS[c.status]} />
</button>
</li>
))}
</ul>
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-[8rem_1fr]">
<FormField label="类型" htmlFor={`${id}-type`}>
<Select value={entityType} onValueChange={(v) => setEntityType(v as "movie" | "tv")}>
<SelectTrigger id={`${id}-type`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="movie">movie</SelectItem>
<SelectItem value="tv">tv</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="TMDB id" htmlFor={`${id}-ext`} error={errors.externalId} required>
<Input
id={`${id}-ext`}
inputMode="numeric"
value={externalId}
aria-invalid={!!errors.externalId}
onChange={(e) => {
setExternalId(e.target.value);
setPick(null);
}}
placeholder="如 693134"
/>
</FormField>
</div>
<FormField label="原因" htmlFor={`${id}-reason`} error={errors.reason} hint="写进修订历史">
<Textarea id={`${id}-reason`} value={reason} onChange={(e) => setReason(e.target.value)} rows={2} />
</FormField>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<div className="min-w-0">
<p className="text-caption font-medium text-foreground">加人工锁</p>
<p className="text-meta text-muted-foreground">自动匹配不得覆盖人工锁定的关系</p>
</div>
<Switch checked={lock} onCheckedChange={setLock} aria-label="加人工锁" />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={set.isPending}>
取消
</Button>
<Button type="submit" disabled={set.isPending}>
{set.isPending ? <LoaderCircle className="animate-spin" /> : null}
保存映射
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ── 字段覆盖并锁定 ──
const FieldForm = z.object({
value: z.string().trim().min(1, "请填写值"),
reason: z.string().max(2000).optional(),
});
export function FieldLockDialog({
entityId,
field,
current,
onOpenChange,
}: {
entityId: string;
/** null 表示关闭 */
field: EntityMetadataField | null;
current: unknown;
onOpenChange: (o: boolean) => void;
}) {
const id = useId();
const lock = useLockField(entityId);
const [value, setValue] = useState("");
const [reason, setReason] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
const [lastField, setLastField] = useState<string | null>(null);
if (field !== lastField) {
setLastField(field);
setValue(current === null || current === undefined ? "" : String(current));
setReason("");
setErrors({});
}
const submit = (e: FormEvent) => {
e.preventDefault();
if (!field) return;
const r = FieldForm.safeParse({ value, reason: reason.trim() || undefined });
if (!r.success) {
setErrors(zodFieldErrors(r.error.issues));
return;
}
let v: unknown = r.data.value;
if (field === "year") {
const n = Number(r.data.value);
if (!Number.isInteger(n) || n < 1870 || n > 2200) {
setErrors({ value: "年份需为 1870–2200 的整数" });
return;
}
v = n;
}
lock.mutate(
{ field, value: v, reason: r.data.reason },
{
onSuccess: () => {
toast.success(`已锁定「${ENTITY_FIELD_LABEL[field] ?? field}」`);
onOpenChange(false);
},
onError: (err) => toast.error(errorMessage(err, "锁定失败")),
},
);
};
const long = field === "overview";
return (
<Dialog open={!!field} onOpenChange={(o) => !lock.isPending && onOpenChange(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>锁定字段:{field ? (ENTITY_FIELD_LABEL[field] ?? field) : ""}</DialogTitle>
<DialogDescription>保存为人工值并加锁,之后刷新元数据不会改写这个字段;不影响已绑定的路径。</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-3">
<FormField label="值" htmlFor={`${id}-v`} error={errors.value} required>
{long ? (
<Textarea
id={`${id}-v`}
rows={4}
value={value}
aria-invalid={!!errors.value}
onChange={(e) => setValue(e.target.value)}
/>
) : (
<Input
id={`${id}-v`}
value={value}
inputMode={field === "year" ? "numeric" : undefined}
aria-invalid={!!errors.value}
onChange={(e) => setValue(e.target.value)}
/>
)}
</FormField>
<FormField label="原因" htmlFor={`${id}-r`} error={errors.reason}>
<Input id={`${id}-r`} value={reason} onChange={(e) => setReason(e.target.value)} />
</FormField>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={lock.isPending}>
取消
</Button>
<Button type="submit" disabled={lock.isPending}>
{lock.isPending ? <LoaderCircle className="animate-spin" /> : null}
保存并锁定
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ── 合并到另一作品 ──
/** 作品检索选择(合并目标等):输入即搜,点选一项 */
export function EntityPicker({
value,
onChange,
excludeId,
kind,
}: {
value: MediaEntity | null;
onChange: (e: MediaEntity | null) => void;
excludeId?: string;
kind?: MediaKind;
}) {
const [q, setQ] = useState("");
const search = useEntitySearch(q, { kind });
const items = (search.data?.items ?? []).filter((e) => e.id !== excludeId && e.status !== "merged");
return (
<div className="grid gap-2">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索作品标题"
className="pl-9"
aria-label="搜索作品"
/>
</div>
<div className="max-h-56 overflow-y-auto rounded-lg border border-border">
{search.isPending ? (
<p className="px-3 py-3 text-meta text-muted-foreground">搜索中…</p>
) : search.isError ? (
<p className="px-3 py-3 text-meta text-destructive">{errorMessage(search.error, "搜索失败")}</p>
) : items.length === 0 ? (
<p className="px-3 py-3 text-meta text-muted-foreground">没有匹配的作品</p>
) : (
<ul>
{items.map((e) => {
const on = value?.id === e.id;
return (
<li key={e.id} className="border-b border-border/60 last:border-b-0">
<button
type="button"
aria-pressed={on}
onClick={() => onChange(on ? null : e)}
className={cn(
"flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-micro",
on ? "bg-foreground/[0.06]" : "hover:bg-foreground/[0.04]",
)}
>
{on ? <Check className="h-4 w-4 shrink-0" /> : <span className="h-4 w-4 shrink-0" />}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground">{e.title}</span>
<span className="block truncate text-meta text-muted-foreground">
{MEDIA_KIND[e.kind]}
{e.year ? ` · ${e.year}` : ""}
{e.originalTitle ? ` · ${e.originalTitle}` : ""}
</span>
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
export function MergeDialog({
entity,
open,
onOpenChange,
}: {
entity: MediaEntity;
open: boolean;
onOpenChange: (o: boolean) => void;
}) {
const navigate = useNavigate();
const merge = useMergeEntity(entity.id);
const [target, setTarget] = useState<MediaEntity | null>(null);
const [reason, setReason] = useState("");
const [confirm, setConfirm] = useState(false);
return (
<>
<Dialog open={open && !confirm} onOpenChange={(o) => !o && onOpenChange(false)}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>合并到另一作品</DialogTitle>
<DialogDescription>
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品;订阅、资产与历史引用一并转到目标。错误合并可以再修订。
</DialogDescription>
</DialogHeader>
<EntityPicker value={target} onChange={setTarget} excludeId={entity.id} />
<FormField label="原因" htmlFor="merge-reason" hint="写入审计与跳转记录">
<Input
id="merge-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="重复建档"
/>
</FormField>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button disabled={!target} onClick={() => setConfirm(true)}>
下一步
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={confirm}
onOpenChange={(o) => {
setConfirm(o);
if (!o && !merge.isSuccess) return;
}}
title="确认合并?"
description={
target
? `把「${entity.title}」合并到「${target.title}${target.year ? ` (${target.year})` : ""}」。合并后本页将跳转到目标作品。`
: ""
}
confirmLabel="合并"
destructive
onConfirm={async () => {
if (!target) return;
try {
await merge.mutateAsync({ intoId: target.id, reason: reason.trim() || "控制台合并" });
toast.success("已合并");
onOpenChange(false);
navigate(consolePath("library", target.id));
} catch (err) {
toast.error(errorMessage(err, "合并失败"));
throw err;
}
}}
/>
</>
);
}
export const evidenceLabel = (l: string) => EVIDENCE_LEVEL[l] ?? l;
@@ -0,0 +1,217 @@
import { AliasKind, type TitleAlias } from "@umt/contracts";
import { ExternalLink, Lock, Plus, Trash2 } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { z } from "zod";
import { useAliases, useCreateAlias, useDeleteAlias } from "~/api/identity.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { FilterBar } from "~/components/console/filter-bar.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { EntityPicker, type PickedEntity } from "./identity-entity-picker.tsx";
import { METADATA_SOURCE } from "./labels.ts";
import { Chip, Panel } from "./parts.tsx";
// 别名管理:检索尽量广(原名/译名/简称/站点标题),确认关联仍严格;用户新增的别名 source=user
const ALIAS_KIND: Record<z.infer<typeof AliasKind>, string> = {
original: "原名",
translated: "译名",
short: "简称",
site_title: "站点标题",
romanized: "罗马字",
other: "其他",
};
const AliasForm = z.object({
alias: z.string().trim().min(1, "请填写别名").max(200, "最多 200 字"),
kind: AliasKind,
language: z
.string()
.trim()
.max(16, "语言代码过长")
.regex(/^$|^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/, "如 zh-CN、en"),
locked: z.boolean(),
});
function AddAlias({ entity }: { entity: PickedEntity }) {
const create = useCreateAlias();
const [f, setF] = useState({ alias: "", kind: "short" as z.infer<typeof AliasKind>, language: "", locked: true });
const [errors, setErrors] = useState<Record<string, string>>({});
const submit = async (e: FormEvent) => {
e.preventDefault();
const r = AliasForm.safeParse(f);
if (!r.success) {
setErrors(zodFieldErrors(r.error.issues));
return;
}
setErrors({});
try {
await create.mutateAsync({
mediaEntityId: entity.id,
alias: r.data.alias,
kind: r.data.kind,
language: r.data.language || null,
locked: r.data.locked,
source: "user",
});
toast.success(`已为「${entity.title}」添加别名`);
setF((x) => ({ ...x, alias: "" }));
} catch (err) {
toast.error(errorMessage(err));
}
};
return (
<form
onSubmit={submit}
noValidate
className="grid gap-2 border-t border-border/60 pt-3 sm:grid-cols-[1fr_9rem_7rem_auto_auto] sm:items-start"
>
<FormField label="新别名" htmlFor="alias-new" error={errors.alias}>
<Input
id="alias-new"
value={f.alias}
aria-invalid={!!errors.alias}
onChange={(e) => setF((x) => ({ ...x, alias: e.target.value }))}
placeholder="如:沙丘2"
/>
</FormField>
<FormField label="类型" htmlFor="alias-kind">
<Select value={f.kind} onValueChange={(v) => setF((x) => ({ ...x, kind: v as typeof f.kind }))}>
<SelectTrigger id="alias-kind" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{(Object.keys(ALIAS_KIND) as (keyof typeof ALIAS_KIND)[]).map((k) => (
<SelectItem key={k} value={k}>
{ALIAS_KIND[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label="语言" htmlFor="alias-lang" error={errors.language}>
<Input
id="alias-lang"
value={f.language}
aria-invalid={!!errors.language}
onChange={(e) => setF((x) => ({ ...x, language: e.target.value }))}
placeholder="zh-CN"
/>
</FormField>
<FormField label="锁定" htmlFor="alias-lock" hint="自动刷新不删">
<div className="flex h-9 items-center">
<Switch id="alias-lock" checked={f.locked} onCheckedChange={(v) => setF((x) => ({ ...x, locked: v }))} />
</div>
</FormField>
<Button type="submit" className="sm:mt-6" disabled={create.isPending}>
<Plus />
添加
</Button>
</form>
);
}
export function AliasesPanel() {
const [entity, setEntity] = useState<PickedEntity | null>(null);
const [q, setQ] = useState("");
const [dq, setDq] = useState("");
useEffect(() => {
const t = setTimeout(() => setDq(q.trim()), 250);
return () => clearTimeout(t);
}, [q]);
const list = useAliases({ mediaEntityId: entity?.id, q: dq || undefined });
const del = useDeleteAlias();
const rows = list.data?.pages.flatMap((p) => p.items);
const columns: DataColumn<TitleAlias>[] = [
{
key: "alias",
header: "别名",
cell: (a) => (
<span className="inline-flex items-center gap-1.5 break-all text-foreground">
{a.locked ? <Lock className="h-3 w-3 shrink-0 text-muted-foreground" aria-label="已锁定" /> : null}
{a.alias}
</span>
),
},
{ key: "kind", header: "类型", cell: (a) => <Chip>{ALIAS_KIND[a.kind]}</Chip> },
{ key: "lang", header: "语言", hideBelow: "md", cell: (a) => a.language ?? "—" },
{ key: "source", header: "来源", hideBelow: "sm", cell: (a) => METADATA_SOURCE[a.source] ?? a.source },
{
key: "entity",
header: "作品",
hideBelow: "md",
cell: (a) => (
<Link
to={consolePath("library", a.mediaEntityId)}
className="inline-flex items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
查看
<ExternalLink className="h-3 w-3" />
</Link>
),
},
{
key: "act",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (a) => (
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" aria-label={`删除别名 ${a.alias}`}>
<Trash2 />
</Button>
}
title={`删除别名「${a.alias}」?`}
description="删除后该别名不再参与模糊召回;已确认的身份映射不受影响。"
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(a.id);
toast.success("别名已删除");
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
/>
),
},
];
return (
<div className="space-y-4">
<Panel title="按作品管理" description="选中作品后可新增别名;不选则按关键字检索全部别名" bodyClassName="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<EntityPicker value={entity} onChange={setEntity} />
<FilterBar search={{ value: q, onChange: setQ, placeholder: "按别名关键字过滤" }} className="mb-0" />
</div>
{entity ? <AddAlias key={entity.id} entity={entity} /> : null}
</Panel>
<DataTable
columns={columns}
rows={rows}
rowKey={(a) => a.id}
loading={list.isPending}
error={list.error}
onRetry={() => list.refetch()}
empty={{
title: entity ? `「${entity.title}」还没有别名` : "没有匹配的别名",
description: "别名用于从原始标题、译名、简称找回作品。",
}}
hasMore={list.hasNextPage}
onLoadMore={() => list.fetchNextPage()}
loadingMore={list.isFetchingNextPage}
/>
</div>
);
}
@@ -0,0 +1,90 @@
import type { MediaKind } from "@umt/contracts";
import { Check, Search, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useEntitySearch } from "~/api/library.ts";
import { LoadingState } from "~/components/console/empty-state.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { cn } from "~/lib/utils.ts";
import { MEDIA_KIND } from "./labels.ts";
export type PickedEntity = { id: string; title: string; year: number | null; kind: MediaKind };
/** 本地作品选择:输入关键字检索 media_entity,点选一项 */
export function EntityPicker({
value,
onChange,
placeholder = "搜索本地作品(标题/原名)",
id,
}: {
value: PickedEntity | null;
onChange: (v: PickedEntity | null) => void;
placeholder?: string;
id?: string;
}) {
const [q, setQ] = useState("");
const [debounced, setDebounced] = useState("");
useEffect(() => {
const t = setTimeout(() => setDebounced(q), 250);
return () => clearTimeout(t);
}, [q]);
const search = useEntitySearch(debounced, { enabled: !value && debounced.trim().length > 0 });
if (value) {
return (
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-border px-3 py-2">
<Check className="h-4 w-4 shrink-0 text-success" />
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{value.title}
{value.year ? ` (${value.year})` : ""} · {MEDIA_KIND[value.kind]}
</span>
<Button type="button" variant="ghost" size="icon-sm" aria-label="清除选择" onClick={() => onChange(null)}>
<X />
</Button>
</div>
);
}
const items = search.data?.items ?? [];
return (
<div className="min-w-0 space-y-1.5">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input id={id} value={q} onChange={(e) => setQ(e.target.value)} placeholder={placeholder} className="pl-9" />
</div>
{debounced.trim() ? (
<div className="max-h-48 overflow-y-auto rounded-lg border border-border">
{search.isPending ? (
<LoadingState rows={2} className="p-2" />
) : search.isError ? (
<p className="p-3 text-meta text-destructive">{(search.error as Error).message}</p>
) : items.length === 0 ? (
<p className="p-3 text-meta text-muted-foreground">没有匹配的本地作品</p>
) : (
<ul>
{items.map((e) => (
<li key={e.id}>
<button
type="button"
onClick={() => {
onChange({ id: e.id, title: e.title, year: e.year, kind: e.kind });
setQ("");
}}
className={cn(
"flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm transition-colors duration-micro hover:bg-foreground/[0.05]",
)}
>
<span className="min-w-0 flex-1 truncate text-foreground">
{e.title}
{e.year ? ` (${e.year})` : ""}
</span>
<span className="shrink-0 text-meta text-muted-foreground">{MEDIA_KIND[e.kind]}</span>
</button>
</li>
))}
</ul>
)}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,124 @@
import type { ExternalIdentity, IdentityRevision } from "@umt/contracts";
import { ExternalLink, Lock, LockOpen } from "lucide-react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useIdentityRevisions, useUpdateIdentity } from "~/api/identity.ts";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Timeline } from "~/components/console/timeline.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { EVIDENCE_LEVEL, IDENTITY_STATUS, VERIFY_STATUS } from "./labels.ts";
import { Tone } from "./parts.tsx";
const ACTION: Record<IdentityRevision["action"], string> = {
created: "创建",
status_changed: "状态变更",
relinked: "重新关联",
evidence_updated: "证据更新",
locked: "加人工锁",
unlocked: "解除人工锁",
};
const ACTOR: Record<string, string> = { user: "用户", agent: "Agent", system: "系统", api_key: "API Key" };
export const identityRef = (i: Pick<ExternalIdentity, "provider" | "entityType" | "externalId">) =>
`${i.provider}:${i.entityType}:${i.externalId}`;
function Revisions({ id }: { id: string }) {
const q = useIdentityRevisions(id);
if (q.isPending) return <LoadingState rows={2} />;
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
if (q.data.length === 0) return <EmptyState plain title="没有修订记录" className="py-6" />;
const items = [...q.data].sort((a, b) => b.revision - a.revision);
return (
<Timeline
items={items.map((r) => ({
id: r.id,
at: r.createdAt,
tone: r.toStatus ? IDENTITY_STATUS[r.toStatus].tone : "neutral",
title: `r${r.revision} · ${ACTION[r.action]}${
r.fromStatus || r.toStatus
? `:${r.fromStatus ? IDENTITY_STATUS[r.fromStatus].label : "无"} → ${r.toStatus ? IDENTITY_STATUS[r.toStatus].label : "无"}`
: ""
}`,
meta: `${ACTOR[r.actorType] ?? r.actorType}${r.actorId ? ` ${r.actorId}` : ""}`,
description: r.reason ?? undefined,
children: (
<div className="grid gap-2 sm:grid-cols-2">
<JsonView label="修订前" value={r.before} defaultOpen={false} maxHeight="12rem" />
<JsonView label="修订后" value={r.after} defaultOpen={false} maxHeight="12rem" />
</div>
),
}))}
/>
);
}
export function ExternalIdentitySheet({ item, onClose }: { item: ExternalIdentity | null; onClose: () => void }) {
const update = useUpdateIdentity();
if (!item) return null;
const toggleLock = async () => {
try {
await update.mutateAsync({
id: item.id,
expectedRevision: item.revision,
locked: !item.locked,
reason: item.locked ? "控制台解除人工锁" : "控制台加人工锁",
});
toast.success(item.locked ? "已解除人工锁,自动匹配可再次修订" : "已加人工锁,自动匹配不会改动");
onClose();
} catch (err) {
toast.error(errorMessage(err));
}
};
const target = item.mediaEntityId;
return (
<DetailSheet
open
width="lg"
onOpenChange={(o) => !o && onClose()}
title={identityRef(item)}
description="外部身份是辅助映射;本地 UUID 才是主身份"
footer={
<Button variant="outline" onClick={toggleLock} disabled={update.isPending || item.status === "retired"}>
{item.locked ? <LockOpen /> : <Lock />}
{item.locked ? "解除人工锁" : "加人工锁"}
</Button>
}
>
<KvList
items={[
{ label: "状态", value: <Tone v={IDENTITY_STATUS[item.status]} /> },
{ label: "证据等级", value: EVIDENCE_LEVEL[item.evidenceLevel] ?? item.evidenceLevel },
{ label: "人工锁", value: item.locked ? "已锁定" : "未锁定" },
{ label: "外部校验", value: <Tone v={VERIFY_STATUS[item.verifyStatus]} /> },
{ label: "校验时间", value: formatDateTime(item.verifiedAt) },
{ label: "修订号", value: `r${item.revision}` },
{
label: "关联",
value: target ? (
<Link
to={consolePath("library", target)}
className="inline-flex items-center gap-1 underline-offset-4 hover:underline"
>
查看作品
<ExternalLink className="h-3 w-3" />
</Link>
) : (
`内容单元 ${item.contentUnitId ?? "—"}`
),
},
{ label: "有效期", value: item.validUntil ? `至 ${formatDateTime(item.validUntil)}` : "长期" },
]}
/>
<JsonView label="证据" value={Object.keys(item.evidence).length ? item.evidence : null} />
<DetailSection title="修订历史">
<Revisions id={item.id} />
</DetailSection>
</DetailSheet>
);
}
@@ -0,0 +1,282 @@
import type { IdentityPoolItem, PoolCandidate } from "@umt/contracts";
import { ExternalLink, LoaderCircle, RotateCw } from "lucide-react";
import { type FormEvent, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { z } from "zod";
import { useAssignPool, useDismissPool, useRetryPool } from "~/api/identity.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { EntityPicker, type PickedEntity } from "./identity-entity-picker.tsx";
import { EVIDENCE_LEVEL, POOL_REASON, POOL_STATUS } from "./labels.ts";
import { Chip, Tone } from "./parts.tsx";
// 待识别条目详情:候选证据 + 三种人工指定方式(候选 / 手动 TMDB id / 本地作品)+ 忽略 / 重试。指定后默认加人工锁。
const TmdbForm = z.object({
entityType: z.enum(["movie", "tv"]),
externalId: z.string().trim().regex(/^\d+$/, "TMDB id 是纯数字"),
});
/** query 摘要:标题 / 年份 / 季集 */
export function querySummary(q: Record<string, unknown>): string {
const parts: string[] = [];
if (q.title) parts.push(String(q.title));
if (q.year) parts.push(String(q.year));
if (q.season !== undefined && q.season !== null) parts.push(`S${String(q.season).padStart(2, "0")}`);
if (q.episode !== undefined && q.episode !== null) parts.push(`E${String(q.episode).padStart(2, "0")}`);
if (q.segment) parts.push(String(q.segment));
if (q.cut) parts.push(String(q.cut));
return parts.join(" · ") || "—";
}
function CandidateRow({
c,
index,
onPick,
disabled,
}: {
c: PoolCandidate;
index: number;
onPick: () => void;
disabled: boolean;
}) {
return (
<li className="min-w-0 rounded-lg border border-border/80 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium break-words text-foreground">
{index + 1}. {c.title}
{c.year ? ` (${c.year})` : ""}
</p>
<p className="font-mono text-meta break-all text-muted-foreground">
{c.provider}:{c.entityType}:{c.externalId}
</p>
</div>
<Button size="sm" variant="outline" onClick={onPick} disabled={disabled}>
指定此候选
</Button>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Chip>{EVIDENCE_LEVEL[c.level] ?? c.level}</Chip>
{c.localEntityId ? (
<Link
to={consolePath("library", c.localEntityId)}
className="inline-flex items-center gap-1 text-meta text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
本地作品
<ExternalLink className="h-3 w-3" />
</Link>
) : (
<span className="text-meta text-muted-foreground">本地未建档,指定后按外部详情建档</span>
)}
</div>
{c.evidence.length ? (
<ul className="mt-2 list-disc space-y-0.5 pl-5 text-meta text-muted-foreground">
{c.evidence.map((e) => (
<li key={e} className="break-words">
{e}
</li>
))}
</ul>
) : null}
</li>
);
}
export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; onClose: () => void }) {
const assign = useAssignPool();
const dismiss = useDismissPool();
const retry = useRetryPool();
const [tmdb, setTmdb] = useState({ entityType: "movie" as "movie" | "tv", externalId: "" });
const [tmdbErr, setTmdbErr] = useState<Record<string, string>>({});
const [entity, setEntity] = useState<PickedEntity | null>(null);
const [note, setNote] = useState("");
if (!item) return null;
const pending = item.status === "pending";
const busy = assign.isPending || dismiss.isPending || retry.isPending;
const doAssign = async (body: Parameters<typeof assign.mutateAsync>[0], ok: string) => {
try {
await assign.mutateAsync(body);
toast.success(ok);
onClose();
} catch (err) {
toast.error(errorMessage(err));
}
};
const submitTmdb = (e: FormEvent) => {
e.preventDefault();
const r = TmdbForm.safeParse(tmdb);
if (!r.success) {
setTmdbErr(zodFieldErrors(r.error.issues));
return;
}
setTmdbErr({});
void doAssign(
{ id: item.id, external: { provider: "tmdb", ...r.data }, lock: true },
`已指定 TMDB ${r.data.entityType}:${r.data.externalId} 并加人工锁`,
);
};
return (
<DetailSheet
open
width="lg"
onOpenChange={(o) => !o && onClose()}
title={item.release.title}
description={item.release.subtitle ?? undefined}
footer={
<>
<ConfirmDialog
trigger={
<Button variant="outline" disabled={!pending || busy}>
忽略
</Button>
}
title="忽略这条待识别资源?"
description="忽略后不再自动复查;资源仍可在待识别入口按原始标题找回。"
confirmLabel="忽略"
onConfirm={async () => {
try {
await dismiss.mutateAsync({ id: item.id, note: note.trim() || undefined });
toast.success("已忽略");
onClose();
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="备注(可选):为什么忽略"
rows={2}
/>
</ConfirmDialog>
<Button
variant="outline"
disabled={busy}
onClick={async () => {
try {
await retry.mutateAsync(item.id);
toast.success("已重新识别");
} catch (err) {
toast.error(errorMessage(err));
}
}}
>
{retry.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCw />}
立即重试
</Button>
</>
}
>
<KvList
items={[
{ label: "状态", value: <Tone v={POOL_STATUS[item.status]} /> },
{ label: "原因", value: <Tone v={POOL_REASON[item.reason]} /> },
{ label: "解析结果", value: querySummary(item.query), wide: true },
{ label: "站点分类", value: item.release.categoryRaw },
{ label: "首次发现", value: formatDateTime(item.release.firstSeenAt) },
{ label: "尝试次数", value: String(item.attempts) },
{ label: "上次尝试", value: formatDateTime(item.lastAttemptAt) },
{ label: "下次复查", value: item.nextReviewAt ? formatDateTime(item.nextReviewAt) : "不再自动复查" },
{ label: "证据版本", value: item.evidenceVersion, mono: true },
]}
/>
<DetailSection title={`候选(${item.candidates.length})`}>
{item.candidates.length ? (
<ul className="space-y-2">
{item.candidates.map((c, i) => (
<CandidateRow
key={`${c.provider}:${c.entityType}:${c.externalId}`}
c={c}
index={i}
disabled={!pending || busy}
onPick={() =>
doAssign({ id: item.id, candidateIndex: i, lock: true }, `已归入「${c.title}」并加人工锁`)
}
/>
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">没有候选。可以手动指定 TMDB id 或本地作品。</p>
)}
</DetailSection>
{pending ? (
<>
<DetailSection title="手动指定 TMDB">
<form onSubmit={submitTmdb} className="grid gap-2 sm:grid-cols-[8rem_1fr_auto] sm:items-start" noValidate>
<FormField label="类型" htmlFor="pool-tmdb-type">
<Select
value={tmdb.entityType}
onValueChange={(v) => setTmdb((t) => ({ ...t, entityType: v as "movie" | "tv" }))}
>
<SelectTrigger id="pool-tmdb-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="movie">电影 movie</SelectItem>
<SelectItem value="tv">剧集 tv</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="TMDB id" htmlFor="pool-tmdb-id" error={tmdbErr.externalId}>
<Input
id="pool-tmdb-id"
inputMode="numeric"
value={tmdb.externalId}
aria-invalid={!!tmdbErr.externalId}
onChange={(e) => setTmdb((t) => ({ ...t, externalId: e.target.value }))}
placeholder="如 693134"
/>
</FormField>
<Button type="submit" variant="outline" className="sm:mt-6" disabled={busy}>
指定
</Button>
</form>
</DetailSection>
<DetailSection title="指定本地作品">
<div className="grid gap-2">
<EntityPicker value={entity} onChange={setEntity} />
<div className="flex justify-end">
<Button
variant="outline"
disabled={!entity || busy}
onClick={() =>
entity &&
doAssign(
{ id: item.id, mediaEntityId: entity.id, lock: true },
`已归入「${entity.title}」并加人工锁`,
)
}
>
归入该作品
</Button>
</div>
</div>
</DetailSection>
</>
) : null}
<JsonView label="解析条件(原始)" value={item.query} defaultOpen={false} />
</DetailSheet>
);
}
+263
View File
@@ -0,0 +1,263 @@
import type {
AcquisitionPriority,
DecisionKind,
DecisionStatus,
DownloadStatus,
IdentityCandidateReason,
IdentityStatus,
LocationStatus,
MediaEntityStatus,
MediaKind,
OrganizationStatus,
PolicyStatus,
PreferenceKind,
PreferenceOrigin,
PreferenceStatus,
SeasonScope,
Severity,
SlotStatus,
SubscriptionMode,
SubscriptionStatus,
} from "@umt/contracts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
// W3-2 各页共用的取值 → 中文文案 / 状态语气。状态色只表示状态,文案必填。
type ToneLabel = { label: string; tone: StatusTone };
export const MEDIA_KIND: Record<MediaKind, string> = {
movie: "电影",
series: "剧集",
variety: "综艺",
collection: "合集",
other: "其他",
};
export const ENTITY_STATUS: Record<MediaEntityStatus, ToneLabel> = {
provisional: { label: "临时建档", tone: "warning" },
active: { label: "正常", tone: "success" },
merged: { label: "已合并", tone: "neutral" },
archived: { label: "已归档", tone: "neutral" },
};
/** Slot 状态(REQ §6.1):每个状态有明确文本,不只靠颜色 */
export const SLOT_STATUS: Record<SlotStatus, ToneLabel> = {
empty: { label: "空缺", tone: "neutral" },
reserved: { label: "已预留", tone: "neutral" },
downloading: { label: "下载中", tone: "neutral" },
verifying: { label: "核验中", tone: "neutral" },
satisfied: { label: "已满足", tone: "success" },
upgrade_pending: { label: "等待升级", tone: "warning" },
attention: { label: "需处理", tone: "destructive" },
};
export const SLOT_STATUS_ORDER: SlotStatus[] = [
"attention",
"empty",
"upgrade_pending",
"reserved",
"downloading",
"verifying",
"satisfied",
];
export const IDENTITY_STATUS: Record<IdentityStatus, ToneLabel> = {
accepted: { label: "已确认", tone: "success" },
proposed: { label: "待确认", tone: "warning" },
conflicted: { label: "冲突", tone: "destructive" },
retired: { label: "已退役", tone: "neutral" },
};
export const EVIDENCE_LEVEL: Record<string, string> = {
manual: "人工确认",
rule_confirmed: "规则确认",
multi_evidence: "多证据",
needs_review: "待复核",
needs_confirm: "待确认",
rejected: "已排除",
};
export const VERIFY_STATUS: Record<string, ToneLabel> = {
unverified: { label: "未校验", tone: "neutral" },
ok: { label: "校验通过", tone: "success" },
not_found: { label: "外部不存在", tone: "warning" },
type_mismatch: { label: "类型不符", tone: "destructive" },
error: { label: "校验出错", tone: "warning" },
};
export const LOCATION_STATUS: Record<LocationStatus, ToneLabel> = {
available: { label: "可访问", tone: "success" },
unavailable: { label: "暂不可访问", tone: "warning" },
suspected_missing: { label: "疑似缺失", tone: "warning" },
confirmed_missing: { label: "已确认缺失", tone: "destructive" },
removed: { label: "已移除", tone: "neutral" },
};
export const LOCATION_KIND: Record<string, string> = {
library: "媒体库",
download_cache: "下载缓存",
hardlink: "硬链接",
external: "外部",
};
export const DOWNLOAD_STATUS: Record<DownloadStatus, ToneLabel> = {
queued: { label: "排队", tone: "neutral" },
submitting: { label: "提交中", tone: "neutral" },
downloading: { label: "下载中", tone: "neutral" },
paused: { label: "已暂停", tone: "warning" },
stalled: { label: "无进展", tone: "warning" },
completed: { label: "下载完成", tone: "neutral" },
importing: { label: "入库中", tone: "neutral" },
imported: { label: "已入库", tone: "success" },
failed: { label: "失败", tone: "destructive" },
removed: { label: "已移除", tone: "neutral" },
missing: { label: "下载器中缺失", tone: "warning" },
};
export const ORG_STATUS: Record<OrganizationStatus, ToneLabel> = {
queued: { label: "排队", tone: "neutral" },
running: { label: "整理中", tone: "neutral" },
succeeded: { label: "核验通过", tone: "success" },
partial: { label: "部分成功", tone: "warning" },
failed: { label: "失败", tone: "destructive" },
cancelled: { label: "已取消", tone: "neutral" },
};
export const CANDIDATE_DECISION: Record<string, ToneLabel> = {
accept: { label: "接受", tone: "success" },
upgrade: { label: "可升级", tone: "success" },
reject: { label: "拒绝", tone: "destructive" },
unknown_field: { label: "字段未知", tone: "warning" },
skipped: { label: "跳过", tone: "neutral" },
};
export const DECISION_KIND: Record<DecisionKind, string> = {
identity_ambiguous: "身份歧义",
download_stalled: "下载无进展",
download_failed: "下载失败",
path_conflict: "路径冲突",
space_insufficient: "空间不足",
approval_required: "需要授权",
metadata_conflict: "元数据冲突",
source_unreachable: "来源不可达",
other: "其他",
};
export const DECISION_STATUS: Record<DecisionStatus, ToneLabel> = {
open: { label: "待处理", tone: "warning" },
investigating: { label: "Agent 调查中", tone: "neutral" },
awaiting_user: { label: "等你决定", tone: "warning" },
resolved: { label: "已处理", tone: "success" },
dismissed: { label: "已忽略", tone: "neutral" },
expired: { label: "已过期", tone: "neutral" },
};
export const SEVERITY: Record<Severity, ToneLabel> = {
info: { label: "提示", tone: "neutral" },
warning: { label: "警告", tone: "warning" },
critical: { label: "严重", tone: "destructive" },
};
export const POOL_REASON: Record<IdentityCandidateReason, ToneLabel> = {
ambiguous: { label: "多个候选", tone: "warning" },
no_match: { label: "没有匹配", tone: "neutral" },
proposed: { label: "待确认", tone: "warning" },
lookup_failed: { label: "查询失败", tone: "destructive" },
};
export const POOL_STATUS: Record<string, ToneLabel> = {
pending: { label: "待识别", tone: "warning" },
resolved: { label: "已识别", tone: "success" },
dismissed: { label: "已忽略", tone: "neutral" },
archived: { label: "已归档", tone: "neutral" },
};
export const PREF_KIND: Record<PreferenceKind, { label: string; hint: string }> = {
soft: { label: "软偏好", hint: "影响排序,不排除" },
hard: { label: "硬规则", hint: "命中即排除" },
version: { label: "版本偏好", hint: "同一作品内选哪种版本" },
target: { label: "独立目标", hint: "为作品建立独立的收录目标" },
};
export const PREF_ORIGIN: Record<PreferenceOrigin, string> = {
manual: "手动",
conversation: "对话确认",
agent_inferred: "Agent 推断",
};
export const PREF_STATUS: Record<PreferenceStatus, ToneLabel> = {
active: { label: "生效中", tone: "success" },
disabled: { label: "已停用", tone: "neutral" },
deleted: { label: "已删除", tone: "neutral" },
};
export const POLICY_STATUS: Record<PolicyStatus, ToneLabel> = {
draft: { label: "草案", tone: "warning" },
published: { label: "已发布", tone: "success" },
superseded: { label: "已被替代", tone: "neutral" },
rolled_back: { label: "已回滚", tone: "neutral" },
archived: { label: "已归档", tone: "neutral" },
};
export const SUB_MODE: Record<SubscriptionMode, string> = { once: "单次收录", continuous: "持续追踪" };
export const SEASON_SCOPE: Record<SeasonScope, string> = {
current_season: "仅本季",
following_seasons: "接续后续季",
};
export const PRIORITY: Record<AcquisitionPriority, string> = {
immediate: "立即",
tracking: "追更",
new_work: "新作品",
upgrade: "升级",
};
export const SUB_STATUS: Record<SubscriptionStatus, ToneLabel> = {
active: { label: "进行中", tone: "success" },
paused: { label: "已暂停", tone: "warning" },
completed: { label: "已完成", tone: "neutral" },
cancelled: { label: "已取消", tone: "neutral" },
};
export const SUB_ORIGIN: Record<string, string> = { user: "用户", agent: "Agent", policy: "策略", import: "导入" };
export const METADATA_SOURCE: Record<string, string> = {
tmdb: "TMDB",
user: "人工",
agent: "Agent",
nfo: "NFO",
site: "站点",
import: "导入",
};
/** 可覆盖/锁定的作品字段(contracts EntityMetadataField) */
export const ENTITY_FIELD_LABEL: Record<string, string> = {
title: "标题",
originalTitle: "原名",
year: "年份",
overview: "简介",
releaseDate: "上映/首播日期",
originalLanguage: "原始语言",
originCountry: "出品地区",
posterPath: "海报",
};
export const UNIT_KIND: Record<string, string> = { season: "季", episode: "集", special: "特别篇", part: "分段" };
export const SEGMENT: Record<string, string> = {
full: "",
upper: "上",
middle: "中",
lower: "下",
unknown: "分段未知",
};
export const CONTENT_TYPE: Record<string, string> = {
main: "正片",
extra: "花絮",
behind_the_scenes: "幕后",
performance_only: "纯享",
preview: "预告",
reaction: "reaction",
unknown: "类型未知",
};
export const opts = <K extends string>(rec: Record<K, string | { label: string }>) =>
(Object.keys(rec) as K[]).map((value) => {
const v = rec[value];
return { value, label: typeof v === "string" ? v : v.label };
});
+179
View File
@@ -0,0 +1,179 @@
import type { LucideIcon } from "lucide-react";
import { Clapperboard, Film, Layers, Tv } from "lucide-react";
import type { ReactNode } from "react";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { cn } from "~/lib/utils.ts";
// W3-2 页面内的小零件:区块卡片、标签胶囊、多选胶囊、封面占位。
/** 内容卡:pg-slab + 标题行。卡内子块用 border 分隔,不再叠玻璃 */
export function Panel({
title,
description,
actions,
children,
className,
bodyClassName,
}: {
title?: ReactNode;
description?: ReactNode;
actions?: ReactNode;
children: ReactNode;
className?: string;
bodyClassName?: string;
}) {
return (
<section className={cn("pg-slab min-w-0 rounded-xl", className)}>
{title || actions ? (
<header className="flex flex-wrap items-start justify-between gap-2 border-b border-border/60 px-4 py-3 sm:px-5">
<div className="min-w-0">
{title ? <h2 className="text-body font-semibold text-foreground">{title}</h2> : null}
{description ? <p className="mt-0.5 text-meta text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header>
) : null}
<div className={cn("px-4 py-4 sm:px-5", bodyClassName)}>{children}</div>
</section>
);
}
/** 中性小标签(规则摘要、别名等),不是状态 */
export function Chip({ children, className, mono }: { children: ReactNode; className?: string; mono?: boolean }) {
return (
<span
className={cn(
"inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-foreground/[0.03] px-2 py-0.5 text-meta break-all text-foreground",
mono && "font-mono",
className,
)}
>
{children}
</span>
);
}
export function ChipList({ items, empty = "未设置" }: { items: ReactNode[]; empty?: string }) {
if (items.length === 0) return <span className="text-meta text-muted-foreground">{empty}</span>;
return (
<div className="flex flex-wrap gap-1.5">
{items.map((it, i) => (
<Chip key={i}>{it}</Chip>
))}
</div>
);
}
/** 语气 + 文案的状态徽章(label 映射表里的 {label,tone}) */
export function Tone({ v, className }: { v: { label: string; tone: StatusTone } | undefined; className?: string }) {
if (!v) return <StatusBadge className={className}>未知</StatusBadge>;
return (
<StatusBadge tone={v.tone} className={className}>
{v.label}
</StatusBadge>
);
}
/** 多选胶囊:选中用静态 pill-selected;ordered 时显示选择顺序(软偏好"越靠前越好") */
export function ChipToggleGroup<T extends string>({
options,
value,
onChange,
ordered = false,
label,
}: {
options: readonly { value: T; label: string }[];
value: readonly T[];
onChange: (v: T[]) => void;
ordered?: boolean;
label: string;
}) {
return (
<fieldset aria-label={label} className="m-0 flex min-w-0 flex-wrap gap-1.5 border-0 p-0">
{options.map((o) => {
const idx = value.indexOf(o.value);
const on = idx >= 0;
return (
<button
key={o.value}
type="button"
aria-pressed={on}
onClick={() => onChange(on ? value.filter((v) => v !== o.value) : [...value, o.value])}
className={cn(
"inline-flex h-7 cursor-pointer items-center gap-1 rounded-full border px-2.5 text-meta font-medium transition-[background-color,color,transform] duration-micro active:scale-[0.96]",
on
? "pill-selected text-foreground"
: "border-border text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
)}
>
{ordered && on ? <span className="tabular-nums text-muted-foreground">{idx + 1}.</span> : null}
{o.label}
</button>
);
})}
</fieldset>
);
}
const KIND_ICON: Record<string, LucideIcon> = { movie: Film, series: Tv, variety: Clapperboard, collection: Layers };
/** 封面占位:无彩材质 + 类型图标 + 首字(不引入海报色彩) */
export function CoverPlaceholder({
title,
kind,
small = false,
className,
}: {
title: string;
kind: string;
/** 列表缩略:只显示首字,不叠类型图标 */
small?: boolean;
className?: string;
}) {
const Icon = KIND_ICON[kind] ?? Film;
const glyph = title.replace(/^w32-/, "").trim().charAt(0) || "?";
return (
<div
aria-hidden
className={cn(
"relative grid shrink-0 place-items-center overflow-hidden rounded-lg border border-border bg-foreground/[0.05] text-muted-foreground",
className,
)}
>
<span className={cn("font-semibold text-foreground/35", small ? "text-lead" : "text-title")}>{glyph}</span>
{small ? null : <Icon className="absolute right-1.5 bottom-1.5 h-3.5 w-3.5 opacity-60" />}
</div>
);
}
/** 细线进度(不做实心填充) */
export function ProgressLine({ value, className }: { value: number; className?: string }) {
const pct = Math.max(0, Math.min(1, value)) * 100;
return (
<div className={cn("h-1 w-full overflow-hidden rounded-full bg-foreground/[0.08]", className)}>
<div className="h-full rounded-full bg-foreground/60" style={{ width: `${pct}%` }} />
</div>
);
}
/** 相对时间("3 小时前"),给列表用;详情仍用 formatDateTime */
export function relTime(iso: string | null | undefined): string {
if (!iso) return "—";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return iso;
const s = Math.round((Date.now() - t) / 1000);
const abs = Math.abs(s);
const suffix = s >= 0 ? "前" : "后";
if (abs < 60) return "刚刚";
if (abs < 3600) return `${Math.round(abs / 60)} 分钟${suffix}`;
if (abs < 86400) return `${Math.round(abs / 3600)} 小时${suffix}`;
if (abs < 86400 * 30) return `${Math.round(abs / 86400)} 天${suffix}`;
return new Date(iso).toLocaleDateString("zh-CN");
}
/** 逗号/顿号/空白分隔的列表 */
export const splitList = (s: string) =>
s
.split(/[,,、\s]+/)
.map((x) => x.trim())
.filter(Boolean);
@@ -0,0 +1,183 @@
import { type PreferenceKind, PreferenceKind as PreferenceKindSchema, type PreferenceTag } from "@umt/contracts";
import { LoaderCircle } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { useCreateTag, useUpdateTag } from "~/api/preferences.ts";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { PREF_KIND } from "./labels.ts";
// 偏好标签新增/编辑表单:scope / rule 是结构化 JSON(复杂条件放详情),不是自由文本 tag
const jsonObject = (name: string) =>
z.string().transform((s, ctx) => {
if (!s.trim()) return {} as Record<string, unknown>;
try {
const v: unknown = JSON.parse(s);
if (v && typeof v === "object" && !Array.isArray(v)) return v as Record<string, unknown>;
} catch {
// 落到下面的错误
}
ctx.addIssue({ code: "custom", message: `${name}必须是 JSON 对象,如 {"mediaKinds":["movie"]}` });
return z.NEVER;
});
const FormSchema = z.object({
label: z.string().trim().min(1, "请填写标签名").max(80, "最多 80 字"),
kind: PreferenceKindSchema,
description: z.string().trim().max(2000, "最多 2000 字"),
scope: jsonObject("作用范围"),
rule: jsonObject("规则"),
});
type Draft = { label: string; kind: PreferenceKind; description: string; scope: string; rule: string };
const toDraft = (t?: PreferenceTag | null): Draft => ({
label: t?.label ?? "",
kind: t?.kind ?? "soft",
description: t?.description ?? "",
scope: t ? JSON.stringify(t.scope, null, 2) : "{}",
rule: t ? JSON.stringify(t.rule, null, 2) : "{}",
});
export function PreferenceFormDialog({
open,
onOpenChange,
tag,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
/** 有值为编辑 */
tag?: PreferenceTag | null;
}) {
const [draft, setDraft] = useState<Draft>(toDraft(tag));
const [errors, setErrors] = useState<Record<string, string>>({});
const create = useCreateTag();
const update = useUpdateTag();
const pending = create.isPending || update.isPending;
useEffect(() => {
if (open) {
setDraft(toDraft(tag));
setErrors({});
}
}, [open, tag]);
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => setDraft((d) => ({ ...d, [k]: v }));
const submit = async (e: FormEvent) => {
e.preventDefault();
const r = FormSchema.safeParse(draft);
if (!r.success) {
setErrors(zodFieldErrors(r.error.issues));
return;
}
setErrors({});
const body = { ...r.data, description: r.data.description || null };
try {
if (tag) {
await update.mutateAsync({ id: tag.id, ...body });
toast.success("偏好已更新");
} else {
await create.mutateAsync({ ...body, origin: "manual" });
toast.success("偏好已添加");
}
onOpenChange(false);
} catch (err) {
toast.error(errorMessage(err));
}
};
return (
<Dialog open={open} onOpenChange={(o) => !pending && onOpenChange(o)}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{tag ? "编辑偏好" : "添加偏好"}</DialogTitle>
<DialogDescription>
{tag
? "修改作用范围或规则会改变语义:旧语义会留下抑制记录,Agent 不会据旧证据加回。"
: "手动添加的偏好与对话确认等价;若之前被取消过,重新添加即解除对应抑制。"}
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-2" noValidate>
<div className="grid gap-3 sm:grid-cols-[1fr_12rem]">
<FormField label="标签" htmlFor="pref-label" required error={errors.label}>
<Input
id="pref-label"
value={draft.label}
aria-invalid={!!errors.label}
onChange={(e) => set("label", e.target.value)}
placeholder="如:偏爱科幻"
/>
</FormField>
<FormField label="类型" htmlFor="pref-kind" hint={PREF_KIND[draft.kind].hint}>
<Select value={draft.kind} onValueChange={(v) => set("kind", v as PreferenceKind)}>
<SelectTrigger id="pref-kind" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{(Object.keys(PREF_KIND) as PreferenceKind[]).map((k) => (
<SelectItem key={k} value={k}>
{PREF_KIND[k].label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
<FormField label="说明" htmlFor="pref-desc" error={errors.description}>
<Textarea
id="pref-desc"
rows={2}
value={draft.description}
onChange={(e) => set("description", e.target.value)}
placeholder="为什么有这个偏好、适用边界"
/>
</FormField>
<FormField label="作用范围(JSON)" htmlFor="pref-scope" error={errors.scope} hint="{} 表示全局">
<Textarea
id="pref-scope"
rows={3}
className="font-mono text-meta"
value={draft.scope}
aria-invalid={!!errors.scope}
onChange={(e) => set("scope", e.target.value)}
/>
</FormField>
<FormField label="规则(JSON)" htmlFor="pref-rule" error={errors.rule} hint="结构化条件,由代码执行">
<Textarea
id="pref-rule"
rows={4}
className="font-mono text-meta"
value={draft.rule}
aria-invalid={!!errors.rule}
onChange={(e) => set("rule", e.target.value)}
/>
</FormField>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
取消
</Button>
<Button type="submit" disabled={pending}>
{pending ? <LoaderCircle className="animate-spin" /> : null}
{tag ? "保存" : "添加"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,107 @@
import type { AcquisitionSlot, CandidateEvaluation } from "@umt/contracts";
import { Link2 } from "lucide-react";
import type { ReactNode } from "react";
import { formatDateTime } from "~/lib/utils.ts";
import { CANDIDATE_DECISION, SLOT_STATUS } from "./labels.ts";
import { Chip, ChipList, Tone } from "./parts.tsx";
import { summarizeCompletion, summarizeHard, summarizeSoft, summarizeUpgrade } from "./slot-rules.tsx";
/** 一个 Slot 的卡片(卡内子块,不叠玻璃):状态、规则摘要、当前资产、候选拒绝理由 */
export function SlotCard({
slot,
currentAsset,
unitLabel,
candidates = [],
actions,
}: {
slot: AcquisitionSlot;
/** 当前资产的一行说明(交付特征) */
currentAsset?: ReactNode;
/** 绑定到某个内容单元时的说明 */
unitLabel?: string;
candidates?: CandidateEvaluation[];
actions?: ReactNode;
}) {
const st = SLOT_STATUS[slot.status];
const hard = summarizeHard(slot.hardConditions, slot.fallback);
const soft = summarizeSoft(slot.softPreferences);
const upgrade = summarizeUpgrade(slot.upgradeThreshold);
return (
<article className="min-w-0 rounded-xl border border-border/80 bg-foreground/[0.02] p-4">
<header className="flex flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate font-mono text-body font-semibold text-foreground">{slot.name}</h3>
<Tone v={st} />
{unitLabel ? <Chip>{unitLabel}</Chip> : null}
{slot.requireDistinctAsset ? (
<Chip>
<Link2 className="h-3 w-3" />
需不同物理资产{slot.distinctGroup ? ` · ${slot.distinctGroup}` : ""}
</Chip>
) : null}
<span className="ml-auto text-meta text-muted-foreground tabular-nums">r{slot.revision}</span>
{actions}
</header>
{slot.statusReason ? <p className="mt-2 text-caption text-muted-foreground">{slot.statusReason}</p> : null}
<dl className="mt-3 grid gap-3 text-sm sm:grid-cols-2">
<div className="min-w-0">
<dt className="mb-1 text-meta text-muted-foreground">硬条件</dt>
<dd>
<ChipList items={hard} empty="不限" />
</dd>
</div>
<div className="min-w-0">
<dt className="mb-1 text-meta text-muted-foreground">软偏好</dt>
<dd>
<ChipList items={soft} empty="无" />
</dd>
</div>
<div className="min-w-0">
<dt className="mb-1 text-meta text-muted-foreground">升级</dt>
<dd>
<ChipList items={upgrade} empty="未设置" />
</dd>
</div>
<div className="min-w-0">
<dt className="mb-1 text-meta text-muted-foreground">完成条件</dt>
<dd className="text-caption text-foreground">{summarizeCompletion(slot.completion)}</dd>
</div>
<div className="min-w-0 sm:col-span-2">
<dt className="mb-1 text-meta text-muted-foreground">当前资产</dt>
<dd className="text-caption break-words text-foreground">{currentAsset ?? "无"}</dd>
</div>
</dl>
{candidates.length ? (
<div className="mt-3 border-t border-border/60 pt-3">
<p className="mb-2 text-meta text-muted-foreground">最近候选 · 为什么选 / 不选</p>
<ul className="space-y-2">
{candidates.map((c) => (
<li key={`${c.sourceReleaseId}-${c.evaluatedAt}`} className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Tone v={CANDIDATE_DECISION[c.decision]} />
<span className="min-w-0 font-mono text-meta break-all text-foreground">{c.title}</span>
</div>
{c.rejected.length ? (
<ul className="mt-1 list-disc space-y-0.5 pl-5 text-meta text-muted-foreground">
{c.rejected.map((r) => (
<li key={r.code} className="break-words">
{r.message}
</li>
))}
</ul>
) : null}
{c.reasons.length ? (
<p className="mt-1 text-meta break-words text-muted-foreground">{c.reasons.join(";")}</p>
) : null}
<p className="text-micro text-muted-foreground tabular-nums">{formatDateTime(c.evaluatedAt)}</p>
</li>
))}
</ul>
</div>
) : null}
<p className="mt-3 text-micro text-muted-foreground tabular-nums">
最近评估 {formatDateTime(slot.lastEvaluatedAt)}
{slot.nextEvaluateAt ? ` · 下次 ${formatDateTime(slot.nextEvaluateAt)}` : ""}
</p>
</article>
);
}
@@ -0,0 +1,531 @@
import type { JsonObject } from "@umt/contracts";
import { useId } from "react";
import { z } from "zod";
import { FormField } from "~/components/console/form-field.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { formatBytes } from "~/lib/utils.ts";
import { ChipToggleGroup, splitList } from "./parts.tsx";
// Slot 规则:硬条件 / 软偏好 / 回退(字段未知时) / 升级阈值 / 完成条件。
// 结构对应 domain SlotSpec(packages/domain/src/slot/types.ts),落在 acquisition_slot 的 JSON 列与
// AcquisitionPolicyRules 的 SlotTemplate 上;这里只做结构化表单,不生成脚本。
export const RESOLUTIONS = ["2160p", "1080p", "1080i", "720p", "576p", "480p", "4320p", "1440p"] as const;
export const SOURCES = [
"REMUX",
"BluRay",
"BluRay-Disc",
"WEB-DL",
"WEBRip",
"HDTV",
"HDRip",
"DVD",
"DVDRip",
] as const;
export const HDRS = ["DV", "HDR10", "HDR10+", "HLG", "HDR Vivid"] as const;
export const CODECS = ["H.265", "H.264", "AV1", "VC-1", "MPEG-2"] as const;
export const CUTS = [
"default",
"Theatrical",
"Director's Cut",
"Extended",
"IMAX",
"Unrated",
"Uncut",
"Remastered",
"Final Cut",
"Criterion",
"Ultimate",
"Special Edition",
] as const;
export const CONTENT_TYPES = ["main", "extra", "behind_the_scenes", "performance_only", "preview", "reaction"] as const;
const SOURCE_LABEL: Record<string, string> = { BluRay: "BluRay(压制)", "BluRay-Disc": "原盘" };
const CUT_LABEL: Record<string, string> = { default: "默认(无剪辑标记)" };
const CT_LABEL: Record<string, string> = {
main: "正片",
extra: "花絮",
behind_the_scenes: "幕后",
performance_only: "纯享",
preview: "预告",
reaction: "reaction",
};
const UNKNOWN_LABEL = { hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const;
const GB = 1024 ** 3;
const o = <T extends string>(list: readonly T[], labels: Record<string, string> = {}) =>
list.map((v) => ({ value: v, label: labels[v] ?? v }));
// ── 规则结构(zod:读 JSON 列时宽松解析,写回前严格校验) ──
const strArr = z.array(z.string()).optional();
export const HardConditions = z.object({
resolutions: strArr,
sources: strArr,
requireEncode: z.boolean().optional(),
maxSizeBytes: z.number().positive().optional(),
minSizeBytes: z.number().nonnegative().optional(),
requiredSubtitles: strArr,
cuts: strArr,
excludeGroups: strArr,
contentTypes: strArr,
});
export const SoftPreferences = z.object({
hdr: strArr,
groups: strArr,
sources: strArr,
codecs: strArr,
preferredSizeBytes: z.object({ min: z.number().optional(), max: z.number().optional() }).optional(),
});
export const UpgradePolicy = z.object({
enabled: z.boolean(),
minTierSteps: z.number().int().min(0).max(5).optional(),
allowResolutionUpgrade: z.boolean().optional(),
encodeGroupAllowlist: strArr,
ceilingTier: z.number().int().min(0).max(5).optional(),
});
export const UnknownPolicy = z.enum(["hold", "reject", "accept"]);
export const Completion = z.object({ kind: z.enum(["movie", "units"]) });
export type HardConditions = z.infer<typeof HardConditions>;
export type SoftPreferences = z.infer<typeof SoftPreferences>;
export type UpgradePolicy = z.infer<typeof UpgradePolicy>;
/** 编辑器状态(字符串形态,提交时转换) */
export type SlotRulesDraft = {
resolutions: string[];
sources: string[];
requireEncode: boolean;
minSizeGb: string;
maxSizeGb: string;
requiredSubtitles: string;
cuts: string[];
excludeGroups: string;
contentTypes: string[];
unknownPolicy: "" | "hold" | "reject" | "accept";
softHdr: string[];
softGroups: string;
softSources: string[];
softCodecs: string[];
upgradeEnabled: boolean;
minTierSteps: string;
allowResolutionUpgrade: boolean;
encodeGroupAllowlist: string;
ceilingTier: string;
completion: "" | "movie" | "units";
};
export type SlotRulesJson = {
hardConditions: JsonObject;
softPreferences: JsonObject;
fallback: JsonObject;
upgradeThreshold: JsonObject;
completion: JsonObject;
};
const numStr = (n: unknown, div = 1) => (typeof n === "number" ? String(Math.round((n / div) * 100) / 100) : "");
export function draftFromJson(j: Partial<SlotRulesJson>): SlotRulesDraft {
const h = HardConditions.safeParse(j.hardConditions ?? {}).data ?? {};
const s = SoftPreferences.safeParse(j.softPreferences ?? {}).data ?? {};
const u = UpgradePolicy.safeParse(j.upgradeThreshold ?? {}).data;
const up = UnknownPolicy.safeParse((j.fallback as { unknownPolicy?: unknown } | undefined)?.unknownPolicy).data;
const c = Completion.safeParse(j.completion ?? {}).data;
return {
resolutions: h.resolutions ?? [],
sources: h.sources ?? [],
requireEncode: h.requireEncode ?? false,
minSizeGb: numStr(h.minSizeBytes, GB),
maxSizeGb: numStr(h.maxSizeBytes, GB),
requiredSubtitles: (h.requiredSubtitles ?? []).join(", "),
cuts: h.cuts ?? [],
excludeGroups: (h.excludeGroups ?? []).join(", "),
contentTypes: h.contentTypes ?? [],
unknownPolicy: up ?? "",
softHdr: s.hdr ?? [],
softGroups: (s.groups ?? []).join(", "),
softSources: s.sources ?? [],
softCodecs: s.codecs ?? [],
upgradeEnabled: u?.enabled ?? false,
minTierSteps: numStr(u?.minTierSteps),
allowResolutionUpgrade: u?.allowResolutionUpgrade ?? false,
encodeGroupAllowlist: (u?.encodeGroupAllowlist ?? []).join(", "),
ceilingTier: numStr(u?.ceilingTier),
completion: c?.kind ?? "",
};
}
export const emptyDraft = (): SlotRulesDraft => draftFromJson({});
/** 草稿 → JSON 列;返回字段级错误(键同草稿字段名) */
export function draftToJson(
d: SlotRulesDraft,
): { ok: true; value: SlotRulesJson } | { ok: false; errors: Record<string, string> } {
const errors: Record<string, string> = {};
const num = (key: keyof SlotRulesDraft, s: string, opts: { int?: boolean; min?: number; max?: number } = {}) => {
if (s.trim() === "") return undefined;
const n = Number(s);
if (!Number.isFinite(n) || (opts.int && !Number.isInteger(n))) {
errors[key] = opts.int ? "请输入整数" : "请输入数字";
return undefined;
}
if (opts.min !== undefined && n < opts.min) errors[key] = `不能小于 ${opts.min}`;
if (opts.max !== undefined && n > opts.max) errors[key] = `不能大于 ${opts.max}`;
return n;
};
const minGb = num("minSizeGb", d.minSizeGb, { min: 0 });
const maxGb = num("maxSizeGb", d.maxSizeGb, { min: 0.01 });
if (minGb !== undefined && maxGb !== undefined && minGb > maxGb) errors.maxSizeGb = "上限需大于下限";
const minTier = num("minTierSteps", d.minTierSteps, { int: true, min: 0, max: 5 });
const ceiling = num("ceilingTier", d.ceilingTier, { int: true, min: 0, max: 5 });
if (Object.keys(errors).length) return { ok: false, errors };
const arr = (v: string[]) => (v.length ? v : undefined);
const hard: HardConditions = {
resolutions: arr(d.resolutions),
sources: arr(d.sources),
requireEncode: d.requireEncode || undefined,
minSizeBytes: minGb !== undefined ? Math.round(minGb * GB) : undefined,
maxSizeBytes: maxGb !== undefined ? Math.round(maxGb * GB) : undefined,
requiredSubtitles: arr(splitList(d.requiredSubtitles)),
cuts: arr(d.cuts),
excludeGroups: arr(splitList(d.excludeGroups)),
contentTypes: arr(d.contentTypes),
};
const soft: SoftPreferences = {
hdr: arr(d.softHdr),
groups: arr(splitList(d.softGroups)),
sources: arr(d.softSources),
codecs: arr(d.softCodecs),
};
const upgrade: UpgradePolicy | undefined =
d.upgradeEnabled || minTier !== undefined || d.encodeGroupAllowlist.trim()
? {
enabled: d.upgradeEnabled,
minTierSteps: minTier,
allowResolutionUpgrade: d.allowResolutionUpgrade || undefined,
encodeGroupAllowlist: arr(splitList(d.encodeGroupAllowlist)),
ceilingTier: ceiling,
}
: undefined;
const clean = (x: object): JsonObject => JSON.parse(JSON.stringify(x)) as JsonObject;
// 严格校验一遍(与 domain 类型对齐)
const hv = HardConditions.safeParse(clean(hard));
const sv = SoftPreferences.safeParse(clean(soft));
if (!hv.success || !sv.success) return { ok: false, errors: { form: "规则结构不合法" } };
return {
ok: true,
value: {
hardConditions: clean(hard),
softPreferences: clean(soft),
fallback: d.unknownPolicy ? { unknownPolicy: d.unknownPolicy } : {},
upgradeThreshold: upgrade ? clean(upgrade) : {},
completion: d.completion ? { kind: d.completion } : {},
},
};
}
// ── 摘要(卡片/列表上的一行说明) ──
export function summarizeHard(j: JsonObject | undefined, fallback?: JsonObject): string[] {
const h = HardConditions.safeParse(j ?? {}).data;
const out: string[] = [];
if (!h) return ["规则无法解析"];
if (h.resolutions?.length) out.push(`分辨率 ${h.resolutions.join(" / ")}`);
if (h.sources?.length) out.push(`来源 ${h.sources.map((s) => SOURCE_LABEL[s] ?? s).join(" / ")}`);
if (h.requireEncode) out.push("仅蓝光压制(不含原盘/Remux)");
if (h.minSizeBytes !== undefined || h.maxSizeBytes !== undefined)
out.push(
`体积 ${h.minSizeBytes !== undefined ? formatBytes(h.minSizeBytes, 1) : "0"} – ${
h.maxSizeBytes !== undefined ? formatBytes(h.maxSizeBytes, 1) : "不限"
}`,
);
if (h.requiredSubtitles?.length) out.push(`需要字幕 ${h.requiredSubtitles.join(" / ")}`);
if (h.cuts?.length) out.push(`内容版本 ${h.cuts.map((c) => CUT_LABEL[c] ?? c).join(" / ")}`);
if (h.excludeGroups?.length) out.push(`排除组 ${h.excludeGroups.join(" / ")}`);
if (h.contentTypes?.length) out.push(`内容 ${h.contentTypes.map((c) => CT_LABEL[c] ?? c).join(" / ")}`);
const up = UnknownPolicy.safeParse((fallback as { unknownPolicy?: unknown } | undefined)?.unknownPolicy).data;
if (up) out.push(`字段未知时${UNKNOWN_LABEL[up]}`);
return out;
}
export function summarizeSoft(j: JsonObject | undefined): string[] {
const s = SoftPreferences.safeParse(j ?? {}).data;
if (!s) return ["规则无法解析"];
const out: string[] = [];
if (s.hdr?.length) out.push(`HDR ${s.hdr.join(" > ")}`);
if (s.sources?.length) out.push(`来源 ${s.sources.join(" > ")}`);
if (s.codecs?.length) out.push(`编码 ${s.codecs.join(" > ")}`);
if (s.groups?.length) out.push(`发布组 ${s.groups.join(" > ")}`);
if (s.preferredSizeBytes)
out.push(
`体积 ${s.preferredSizeBytes.min ? formatBytes(s.preferredSizeBytes.min, 1) : "0"} – ${
s.preferredSizeBytes.max ? formatBytes(s.preferredSizeBytes.max, 1) : "不限"
}`,
);
return out;
}
export function summarizeUpgrade(j: JsonObject | undefined): string[] {
const u = UpgradePolicy.safeParse(j ?? {}).data;
if (!u) return Object.keys(j ?? {}).length ? ["规则无法解析"] : [];
if (!u.enabled) return ["不自动升级"];
const out = [`来源等级至少提升 ${u.minTierSteps ?? 1} 级`];
if (u.allowResolutionUpgrade) out.push("允许仅因分辨率升级");
if (u.encodeGroupAllowlist?.length) out.push(`合格压制组 ${u.encodeGroupAllowlist.join(" / ")}`);
if (u.ceilingTier !== undefined) out.push(`达到等级 ${u.ceilingTier} 后不再升级`);
return out;
}
export function summarizeCompletion(j: JsonObject | undefined): string {
const c = Completion.safeParse(j ?? {}).data;
if (!c) return "未设置";
return c.kind === "movie" ? "按内容版本核对(电影)" : "按内容单元覆盖核对(剧集/综艺)";
}
// ── 编辑器 ──
export function SlotRulesEditor({
value,
onChange,
errors = {},
}: {
value: SlotRulesDraft;
onChange: (v: SlotRulesDraft) => void;
errors?: Record<string, string>;
}) {
const id = useId();
const set = <K extends keyof SlotRulesDraft>(k: K, v: SlotRulesDraft[K]) => onChange({ ...value, [k]: v });
return (
<div className="space-y-5">
<fieldset className="space-y-3">
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
硬条件 · 已知不满足即拒绝
</legend>
<FormField label="分辨率">
<ChipToggleGroup
label="分辨率"
options={o(RESOLUTIONS)}
value={value.resolutions}
onChange={(v) => set("resolutions", v)}
/>
</FormField>
<FormField label="交付来源" hint="BluRay 指蓝光压制;原盘与 Remux 单列">
<ChipToggleGroup
label="交付来源"
options={o(SOURCES, SOURCE_LABEL)}
value={value.sources}
onChange={(v) => set("sources", v)}
/>
</FormField>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<div className="min-w-0">
<p className="text-caption font-medium text-foreground">只接受蓝光二次压制</p>
<p className="text-meta text-muted-foreground">不含原盘与 Remux;压制不等于"越大越好"</p>
</div>
<Switch
checked={value.requireEncode}
onCheckedChange={(v) => set("requireEncode", v)}
aria-label="只接受蓝光二次压制"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="体积下限(GB)" htmlFor={`${id}-min`} error={errors.minSizeGb}>
<Input
id={`${id}-min`}
inputMode="decimal"
value={value.minSizeGb}
aria-invalid={!!errors.minSizeGb}
onChange={(e) => set("minSizeGb", e.target.value)}
placeholder="不限"
/>
</FormField>
<FormField label="体积上限(GB)" htmlFor={`${id}-max`} error={errors.maxSizeGb}>
<Input
id={`${id}-max`}
inputMode="decimal"
value={value.maxSizeGb}
aria-invalid={!!errors.maxSizeGb}
onChange={(e) => set("maxSizeGb", e.target.value)}
placeholder="不限"
/>
</FormField>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="必需字幕语言" htmlFor={`${id}-subs`} hint="逗号分隔;zh 可被 zh-Hans/zh-Hant 满足">
<Input
id={`${id}-subs`}
value={value.requiredSubtitles}
onChange={(e) => set("requiredSubtitles", e.target.value)}
placeholder="zh"
/>
</FormField>
<FormField label="排除发布组" htmlFor={`${id}-exg`} hint="逗号分隔">
<Input
id={`${id}-exg`}
value={value.excludeGroups}
onChange={(e) => set("excludeGroups", e.target.value)}
/>
</FormField>
</div>
<FormField label="内容版本(剪辑)" hint="与分辨率等交付特征分开;不选即不限">
<ChipToggleGroup
label="内容版本"
options={o(CUTS, CUT_LABEL)}
value={value.cuts}
onChange={(v) => set("cuts", v)}
/>
</FormField>
<FormField label="内容类型" hint="综艺正片 / 花絮等">
<ChipToggleGroup
label="内容类型"
options={o(CONTENT_TYPES, CT_LABEL)}
value={value.contentTypes}
onChange={(v) => set("contentTypes", v)}
/>
</FormField>
<FormField label="字段未知时" hint="资源缺少判断所需字段(如字幕)时的处理">
<Select
value={value.unknownPolicy || "__none"}
onValueChange={(v) => set("unknownPolicy", v === "__none" ? "" : (v as SlotRulesDraft["unknownPolicy"]))}
>
<SelectTrigger className="w-full sm:w-64" aria-label="字段未知时">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="__none">按系统默认</SelectItem>
{(Object.keys(UNKNOWN_LABEL) as (keyof typeof UNKNOWN_LABEL)[]).map((k) => (
<SelectItem key={k} value={k}>
{UNKNOWN_LABEL[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</fieldset>
<fieldset className="space-y-3 border-t border-border/60 pt-4">
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
软偏好 · 硬条件通过后排序,按点选顺序越靠前越好
</legend>
<FormField label="HDR">
<ChipToggleGroup
ordered
label="HDR 偏好"
options={o(HDRS)}
value={value.softHdr}
onChange={(v) => set("softHdr", v)}
/>
</FormField>
<FormField label="来源">
<ChipToggleGroup
ordered
label="来源偏好"
options={o(SOURCES, SOURCE_LABEL)}
value={value.softSources}
onChange={(v) => set("softSources", v)}
/>
</FormField>
<FormField label="编码">
<ChipToggleGroup
ordered
label="编码偏好"
options={o(CODECS)}
value={value.softCodecs}
onChange={(v) => set("softCodecs", v)}
/>
</FormField>
<FormField label="偏好发布组" htmlFor={`${id}-grp`} hint="逗号分隔,越靠前越好">
<Input id={`${id}-grp`} value={value.softGroups} onChange={(e) => set("softGroups", e.target.value)} />
</FormField>
</fieldset>
<fieldset className="space-y-3 border-t border-border/60 pt-4">
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
升级阈值 · 创建替换计划,不直接覆盖旧文件
</legend>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<p className="text-caption font-medium text-foreground">允许自动升级</p>
<Switch
checked={value.upgradeEnabled}
onCheckedChange={(v) => set("upgradeEnabled", v)}
aria-label="允许自动升级"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<FormField
label="最小来源等级提升"
htmlFor={`${id}-tier`}
error={errors.minTierSteps}
hint="默认 1;同等级不反复更换"
>
<Input
id={`${id}-tier`}
inputMode="numeric"
value={value.minTierSteps}
aria-invalid={!!errors.minTierSteps}
onChange={(e) => set("minTierSteps", e.target.value)}
placeholder="1"
/>
</FormField>
<FormField
label="封顶等级"
htmlFor={`${id}-ceil`}
error={errors.ceilingTier}
hint="当前资产达到后不再升级(0–5)"
>
<Input
id={`${id}-ceil`}
inputMode="numeric"
value={value.ceilingTier}
aria-invalid={!!errors.ceilingTier}
onChange={(e) => set("ceilingTier", e.target.value)}
placeholder="不封顶"
/>
</FormField>
</div>
<FormField
label="合格压制组名单"
htmlFor={`${id}-allow`}
hint="设置后名单外的压制不自动升级,低劣压制不会只因 BluRay 标签替换"
>
<Input
id={`${id}-allow`}
value={value.encodeGroupAllowlist}
onChange={(e) => set("encodeGroupAllowlist", e.target.value)}
/>
</FormField>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<p className="text-caption font-medium text-foreground">允许仅因分辨率提升而升级</p>
<Switch
checked={value.allowResolutionUpgrade}
onCheckedChange={(v) => set("allowResolutionUpgrade", v)}
aria-label="允许仅因分辨率提升而升级"
/>
</div>
</fieldset>
<fieldset className="space-y-3 border-t border-border/60 pt-4">
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
完成条件
</legend>
<Select
value={value.completion || "__none"}
onValueChange={(v) => set("completion", v === "__none" ? "" : (v as "movie" | "units"))}
>
<SelectTrigger className="w-full sm:w-72" aria-label="完成条件">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="__none">未设置</SelectItem>
<SelectItem value="movie">按内容版本核对(电影)</SelectItem>
<SelectItem value="units">按内容单元覆盖核对(剧集/综艺)</SelectItem>
</SelectContent>
</Select>
{errors.form ? <p className="text-meta text-destructive">{errors.form}</p> : null}
</fieldset>
</div>
);
}
@@ -0,0 +1,886 @@
import {
AcquisitionPolicyRules,
type JsonObject,
type PolicyRevision,
type SlotTemplate,
SubscriptionMediaKind,
} from "@umt/contracts";
import { ChevronRight, Eye, FilePlus2, History, Pencil, Plus, ScrollText, Trash2, Undo2, Upload } from "lucide-react";
import { useId, useMemo, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import {
PolicyPreview,
useCreatePolicy,
usePolicyRevisions,
usePreviewPolicy,
usePublishPolicy,
useRollbackPolicy,
useUpdatePolicy,
} from "~/api/policies.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { CANDIDATE_DECISION, MEDIA_KIND, POLICY_STATUS } from "./labels.ts";
import { ChipList, ChipToggleGroup, Panel, Tone } from "./parts.tsx";
import {
draftFromJson,
draftToJson,
emptyDraft,
type SlotRulesDraft,
SlotRulesEditor,
summarizeHard,
} from "./slot-rules.tsx";
// 收录策略(kind=acquisition 的 policy_revision):草案 → 预览 → 授权发布 → 版本化 / 回滚(REQ §6.4)。
// 规则只是结构化数据(AcquisitionPolicyRules),不是脚本;发布即对 scope 内正常下载/追更/整理生成持续授权。
const KIND_OPTS: { value: string; label: string }[] = SubscriptionMediaKind.options.map((v) => ({
value: v,
label: MEDIA_KIND[v],
}));
export const PUBLISH_NOTICE =
"发布即授权:scope 范围内的正常下载、追更与整理将按此策略持续自动执行,不再逐次审批;升级替换、媒体清理与下载缓存清理另行授权。旧的已发布版本会被替代,其授权随之吊销。";
type TemplateDraft = { key: string; name: string; rules: SlotRulesDraft; open: boolean };
type PolicyDraft = {
policyKey: string;
title: string;
summary: string;
scopeKinds: string[];
defaultSlots: TemplateDraft[];
newTitleEnabled: boolean;
windowDays: string;
newTitleKinds: string[];
newTitleSlots: TemplateDraft[];
organizeMode: "" | "copy" | "move";
allowCopyWhenHnrPending: boolean;
};
let seq = 0;
const tpl = (t?: SlotTemplate, open = false): TemplateDraft => ({
key: `t${++seq}`,
name: t?.name ?? "",
rules: t ? draftFromJson(t) : emptyDraft(),
open,
});
function draftFromRevision(r: PolicyRevision | null): PolicyDraft {
const rules = r ? AcquisitionPolicyRules.safeParse(r.rules).data : undefined;
const kinds = r ? z.array(z.string()).safeParse((r.scope as { mediaKinds?: unknown }).mediaKinds).data : undefined;
return {
policyKey: r?.policyKey ?? "",
title: r?.title ?? "",
summary: r?.summary ?? "",
scopeKinds: kinds ?? [],
defaultSlots: (rules?.defaultSlots ?? []).map((t) => tpl(t)),
newTitleEnabled: rules?.newTitle?.enabled ?? false,
windowDays: rules?.newTitle?.windowDays ? String(rules.newTitle.windowDays) : "",
newTitleKinds: rules?.newTitle?.mediaKinds ?? [],
newTitleSlots: (rules?.newTitle?.slots ?? []).map((t) => tpl(t)),
organizeMode: rules?.organizeMode ?? "",
allowCopyWhenHnrPending: rules?.allowCopyWhenHnrPending ?? false,
};
}
const Meta = z.object({
policyKey: z
.string()
.trim()
.min(1, "请填写策略键")
.max(64, "最多 64 个字符")
.regex(/^[a-z0-9][a-z0-9_.-]*$/, "只用小写字母、数字和 - _ ."),
title: z.string().trim().min(1, "请填写标题").max(120, "最多 120 个字符"),
summary: z.string().trim().max(500, "最多 500 个字符"),
});
const stripEmpty = (o: JsonObject) => (Object.keys(o).length ? o : undefined);
/** 草稿 → rules/scope;错误键:meta 字段名、`${key}.name` / `${key}.${field}`、windowDays、rules */
function buildPolicy(d: PolicyDraft):
| {
ok: true;
body: { policyKey: string; title: string; summary: string | null; scope: JsonObject; rules: JsonObject };
}
| { ok: false; errors: Record<string, string> } {
const errors: Record<string, string> = {};
const meta = Meta.safeParse(d);
if (!meta.success) for (const i of meta.error.issues) errors[String(i.path[0])] ??= i.message;
const templates = (list: TemplateDraft[]) =>
list.map((t) => {
if (!t.name.trim()) errors[`${t.key}.name`] = "请填写模板名称";
const r = draftToJson(t.rules);
if (!r.ok) {
for (const [k, v] of Object.entries(r.errors)) errors[`${t.key}.${k}`] = v;
return { name: t.name.trim() };
}
return {
name: t.name.trim(),
hardConditions: stripEmpty(r.value.hardConditions),
softPreferences: stripEmpty(r.value.softPreferences),
fallback: stripEmpty(r.value.fallback),
upgradeThreshold: stripEmpty(r.value.upgradeThreshold),
completion: stripEmpty(r.value.completion),
};
});
const defaultSlots = templates(d.defaultSlots);
const newTitleSlots = templates(d.newTitleSlots);
let windowDays: number | undefined;
if (d.windowDays.trim()) {
windowDays = Number(d.windowDays);
if (!Number.isInteger(windowDays) || windowDays < 1 || windowDays > 3650) errors.windowDays = "1–3650 的整数";
}
if (d.newTitleEnabled && newTitleSlots.length === 0) errors.newTitleSlots = "开启新作品收录时至少要有一个目标模板";
const rules = {
...(defaultSlots.length ? { defaultSlots } : {}),
...(d.newTitleEnabled || newTitleSlots.length
? {
newTitle: {
enabled: d.newTitleEnabled,
...(windowDays ? { windowDays } : {}),
...(d.newTitleKinds.length ? { mediaKinds: d.newTitleKinds } : {}),
slots: newTitleSlots,
},
}
: {}),
...(d.organizeMode ? { organizeMode: d.organizeMode } : {}),
...(d.allowCopyWhenHnrPending ? { allowCopyWhenHnrPending: true } : {}),
};
// 与服务端同一份 schema 严格校验
const strict = AcquisitionPolicyRules.strict().safeParse(rules);
if (!strict.success && !Object.keys(errors).length) {
const i = strict.error.issues[0];
errors.rules = `规则结构不合法:${i?.path.join(".")} ${i?.message}`;
}
if (Object.keys(errors).length || !meta.success) return { ok: false, errors };
return {
ok: true,
body: {
policyKey: meta.data.policyKey,
title: meta.data.title,
summary: meta.data.summary || null,
scope: d.scopeKinds.length ? { mediaKinds: d.scopeKinds } : {},
rules: rules as JsonObject,
},
};
}
// ── 列表 ──
export function PolicyWorkspace({
creating,
onCreatingChange,
}: {
creating: boolean;
onCreatingChange: (v: boolean) => void;
}) {
const q = usePolicyRevisions();
const [editor, setEditor] = useState<{ base: PolicyRevision | null; editId: string | null } | null>(null);
const [viewId, setViewId] = useState<string | null>(null);
const groups = useMemo(() => {
const m = new Map<string, PolicyRevision[]>();
for (const r of q.data?.items ?? []) {
if (r.kind !== "acquisition") continue;
m.set(r.policyKey, [...(m.get(r.policyKey) ?? []), r]);
}
return [...m.entries()].map(([key, revs]) => ({ key, revs: revs.sort((a, b) => b.revision - a.revision) }));
}, [q.data]);
const all = q.data?.items ?? [];
const viewing = all.find((r) => r.id === viewId) ?? null;
const editorOpen = creating || !!editor;
const closeEditor = () => {
setEditor(null);
onCreatingChange(false);
};
return (
<div className="space-y-4">
<p className="text-caption text-muted-foreground">
策略只保存结构化条件。草案可以反复预览,发布才生效;回滚会以旧版本内容发布一个新版本,历史只增不改。
</p>
{q.isPending ? (
<div className="pg-slab rounded-xl p-4">
<LoadingState rows={4} />
</div>
) : q.isError ? (
<ErrorState error={q.error} onRetry={() => q.refetch()} />
) : groups.length === 0 ? (
<EmptyState
icon={ScrollText}
title="还没有收录策略"
description="新建一份草案,设置缺省目标模板与新作品收录条件,预览后再发布。"
action={
<Button onClick={() => onCreatingChange(true)}>
<FilePlus2 />
新建策略草案
</Button>
}
/>
) : (
groups.map((g) => (
<PolicyGroup
key={g.key}
policyKey={g.key}
revs={g.revs}
onView={setViewId}
onEdit={(r) => setEditor({ base: r, editId: r.id })}
onDraftFrom={(r) => setEditor({ base: r, editId: null })}
/>
))
)}
{editorOpen ? (
<PolicyEditorSheet
key={editor ? `${editor.base?.id}-${editor.editId}` : "new"}
base={editor?.base ?? null}
editId={editor?.editId ?? null}
onClose={closeEditor}
onSaved={(r) => {
closeEditor();
setViewId(r.id);
}}
/>
) : null}
<PolicyDetailSheet
rev={viewing}
onClose={() => setViewId(null)}
onEdit={(r) => {
setViewId(null);
setEditor({ base: r, editId: r.id });
}}
onDraftFrom={(r) => {
setViewId(null);
setEditor({ base: r, editId: null });
}}
/>
</div>
);
}
function PolicyGroup({
policyKey,
revs,
onView,
onEdit,
onDraftFrom,
}: {
policyKey: string;
revs: PolicyRevision[];
onView: (id: string) => void;
onEdit: (r: PolicyRevision) => void;
onDraftFrom: (r: PolicyRevision) => void;
}) {
const published = revs.find((r) => r.status === "published");
const draft = revs.find((r) => r.status === "draft");
const head = published ?? revs[0]!;
const rules = AcquisitionPolicyRules.safeParse(head.rules).data;
return (
<Panel
title={
<span className="flex flex-wrap items-center gap-2">
<span className="min-w-0 break-all">{head.title}</span>
<span className="font-mono text-meta font-normal text-muted-foreground">{policyKey}</span>
</span>
}
description={
published
? `当前生效 r${published.revision} · ${formatDateTime(published.publishedAt)} 由 ${published.publishedBy ?? "—"} 发布`
: "尚未发布,不产生任何自动操作"
}
actions={
draft ? (
<Button variant="outline" size="sm" onClick={() => onEdit(draft)}>
<Pencil />
继续编辑草案 r{draft.revision}
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => onDraftFrom(head)}>
<FilePlus2 />
基于 r{head.revision} 新建草案
</Button>
)
}
>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="min-w-0 space-y-2">
<p className="text-meta text-muted-foreground">r{head.revision} 规则摘要</p>
<RulesSummary rules={rules} valid={!!rules} />
</div>
<div className="min-w-0">
<p className="mb-2 text-meta text-muted-foreground">版本历史</p>
<ol className="divide-y divide-border/60 rounded-lg border border-border/70">
{revs.map((r) => (
<li key={r.id}>
<button
type="button"
onClick={() => onView(r.id)}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.04]"
>
<span className="w-8 shrink-0 font-mono text-meta text-muted-foreground tabular-nums">
r{r.revision}
</span>
<Tone v={POLICY_STATUS[r.status]} />
<span className="min-w-0 flex-1 truncate text-caption text-foreground">{r.summary ?? r.title}</span>
<span className="hidden shrink-0 text-meta text-muted-foreground tabular-nums sm:inline">
{formatDateTime(r.publishedAt ?? r.createdAt)}
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
</li>
))}
</ol>
</div>
</div>
</Panel>
);
}
function RulesSummary({ rules, valid }: { rules: AcquisitionPolicyRules | undefined; valid: boolean }) {
if (!valid || !rules) return <p className="text-caption text-destructive">规则结构无法解析(不会被执行)</p>;
const tplLine = (t: SlotTemplate) => `${t.name}:${summarizeHard(t.hardConditions, t.fallback).join(",") || "不限"}`;
return (
<div className="space-y-2 text-caption">
<div>
<span className="text-muted-foreground">缺省目标 </span>
<ChipList items={(rules.defaultSlots ?? []).map(tplLine)} empty="无(订阅没有目标时不自动建)" />
</div>
<div>
<span className="text-muted-foreground">新作品收录 </span>
{rules.newTitle?.enabled ? (
<ChipList
items={[
`窗口 ${rules.newTitle.windowDays ?? "默认"} 天`,
rules.newTitle.mediaKinds?.length
? rules.newTitle.mediaKinds.map((k) => MEDIA_KIND[k]).join(" / ")
: "全部类型",
...rules.newTitle.slots.map(tplLine),
]}
/>
) : (
<span className="text-foreground">关闭</span>
)}
</div>
<div className="text-muted-foreground">
整理方式{" "}
<span className="text-foreground">
{rules.organizeMode === "move" ? "Move" : rules.organizeMode === "copy" ? "Copy" : "按系统设置"}
</span>
{rules.allowCopyWhenHnrPending ? " · H&R 未满足时改用 Copy" : ""}
</div>
</div>
);
}
// ── 详情:预览 / 发布 / 回滚 ──
function PolicyDetailSheet({
rev,
onClose,
onEdit,
onDraftFrom,
}: {
rev: PolicyRevision | null;
onClose: () => void;
onEdit: (r: PolicyRevision) => void;
onDraftFrom: (r: PolicyRevision) => void;
}) {
const preview = usePreviewPolicy();
const publish = usePublishPolicy();
const rollback = useRollbackPolicy();
const rules = rev ? AcquisitionPolicyRules.safeParse(rev.rules).data : undefined;
const pv = rev?.preview ? PolicyPreview.safeParse(rev.preview) : null;
const runPreview = (r: PolicyRevision) =>
preview.mutate(r.id, {
onSuccess: () => toast.success(`r${r.revision} 预览已更新`),
onError: (e) => toast.error(errorMessage(e)),
});
return (
<DetailSheet
open={!!rev}
onOpenChange={(o) => !o && onClose()}
width="lg"
title={rev ? `${rev.title} · r${rev.revision}` : ""}
description={rev ? <span className="font-mono">{rev.policyKey}</span> : null}
footer={
rev ? (
<>
{rev.status === "draft" ? (
<>
<Button variant="outline" onClick={() => onEdit(rev)}>
<Pencil />
编辑
</Button>
<Button variant="outline" onClick={() => runPreview(rev)} disabled={preview.isPending}>
<Eye />
{preview.isPending ? "预览中…" : "预览"}
</Button>
<ConfirmDialog
trigger={
<Button disabled={!rules}>
<Upload />
发布
</Button>
}
title={`发布 ${rev.policyKey} r${rev.revision}?`}
description={PUBLISH_NOTICE}
confirmLabel="授权并发布"
onConfirm={() =>
publish
.mutateAsync({ id: rev.id, expectedRevision: rev.revision })
.then(() => toast.success(`r${rev.revision} 已发布并生效`))
.catch((e) => {
toast.error(errorMessage(e));
throw e;
})
}
>
{!pv?.success ? (
<p className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-warning">
这个草案还没有预览结果,建议先预览确认影响范围。
</p>
) : null}
</ConfirmDialog>
</>
) : (
<>
<Button variant="outline" onClick={() => onDraftFrom(rev)}>
<FilePlus2 />
基于此版本新建草案
</Button>
{rev.status === "superseded" || rev.status === "rolled_back" ? (
<ConfirmDialog
trigger={
<Button variant="outline">
<Undo2 />
回滚到此版本
</Button>
}
title={`回滚到 r${rev.revision}?`}
description={`将以 r${rev.revision} 的内容发布一个新版本,当前已发布版本标记为已回滚。${PUBLISH_NOTICE}`}
confirmLabel="回滚并发布"
onConfirm={() =>
rollback
.mutateAsync(rev.id)
.then((n) => toast.success(`已回滚,新版本 r${n.revision} 生效`))
.then(onClose)
.catch((e) => {
toast.error(errorMessage(e));
throw e;
})
}
/>
) : null}
</>
)}
</>
) : null
}
>
{rev ? (
<>
<DetailSection title="版本">
<KvList
items={[
{ label: "状态", value: <Tone v={POLICY_STATUS[rev.status]} /> },
{ label: "作用范围", value: scopeText(rev.scope) },
{ label: "创建", value: `${formatDateTime(rev.createdAt)} · ${rev.createdBy ?? "—"}` },
{
label: "发布",
value: rev.publishedAt ? `${formatDateTime(rev.publishedAt)} · ${rev.publishedBy ?? "—"}` : "未发布",
},
{ label: "说明", value: rev.summary, wide: true },
]}
/>
</DetailSection>
<DetailSection title="规则">
<RulesSummary rules={rules} valid={!!rules} />
<JsonView className="mt-3" label="原始规则" value={rev.rules} defaultOpen={false} />
</DetailSection>
<DetailSection
title="历史回放预览"
actions={
rev.status !== "draft" ? (
<Button variant="ghost" size="xs" onClick={() => runPreview(rev)} disabled={preview.isPending}>
<History />
重新预览
</Button>
) : null
}
>
<PreviewBlock value={rev.preview} />
</DetailSection>
</>
) : null}
</DetailSheet>
);
}
const scopeText = (scope: JsonObject) => {
const k = z.array(z.string()).safeParse((scope as { mediaKinds?: unknown }).mediaKinds).data;
return k?.length ? k.map((x) => MEDIA_KIND[x as keyof typeof MEDIA_KIND] ?? x).join(" / ") : "全部作品";
};
function PreviewBlock({ value }: { value: JsonObject | null }) {
if (!value)
return (
<p className="text-caption text-muted-foreground">
还没有预览。预览用最近已识别的发布评估模板,不产生任何副作用。
</p>
);
const p = PolicyPreview.safeParse(value);
if (!p.success) return <JsonView value={value} />;
const v = p.data;
return (
<div className="space-y-3">
{!v.valid ? (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-caption text-destructive">
规则结构未通过校验,预览按空模板计算;发布会被拒绝。
</p>
) : null}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
["样本发布", v.sampleSize],
["模板数", v.templates],
["会接受", v.matched],
["会排除", v.excluded],
].map(([l, n]) => (
<div key={String(l)} className="rounded-lg border border-border/70 px-3 py-2">
<p className="text-meta text-muted-foreground">{l}</p>
<p className="text-title-xs font-semibold text-foreground tabular-nums">{n}</p>
</div>
))}
</div>
<p className="text-meta text-muted-foreground">
计算于 {formatDateTime(v.evaluatedAt)};接受/排除按"发布 × 模板"计数。
</p>
{v.samples.length ? (
<ul className="divide-y divide-border/60 rounded-lg border border-border/70">
{v.samples.map((s, i) => (
<li
key={`${s.sourceReleaseId}-${s.template}-${i}`}
className="flex flex-wrap items-center gap-2 px-3 py-1.5"
>
<Tone v={CANDIDATE_DECISION[s.decision] ?? { label: s.decision, tone: "neutral" }} />
<span className="font-mono text-meta text-foreground">{s.template}</span>
<span className="min-w-0 truncate font-mono text-micro text-muted-foreground">{s.sourceReleaseId}</span>
</li>
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">没有可用于回放的已识别发布。</p>
)}
</div>
);
}
// ── 草案编辑 ──
function PolicyEditorSheet({
base,
editId,
onClose,
onSaved,
}: {
base: PolicyRevision | null;
editId: string | null;
onClose: () => void;
onSaved: (r: PolicyRevision) => void;
}) {
const id = useId();
const create = useCreatePolicy();
const update = useUpdatePolicy();
const [d, setD] = useState<PolicyDraft>(() => {
const x = draftFromRevision(base);
if (!base) x.defaultSlots = [tpl(undefined, true)];
if (base && !editId) x.summary = "";
return x;
});
const [errors, setErrors] = useState<Record<string, string>>({});
const pending = create.isPending || update.isPending;
const set = <K extends keyof PolicyDraft>(k: K, v: PolicyDraft[K]) => setD((p) => ({ ...p, [k]: v }));
const submit = () => {
const r = buildPolicy(d);
if (!r.ok) {
setErrors(r.errors);
// 展开有错的模板
setD((p) => ({
...p,
defaultSlots: p.defaultSlots.map((t) => ({
...t,
open: t.open || Object.keys(r.errors).some((k) => k.startsWith(`${t.key}.`)),
})),
newTitleSlots: p.newTitleSlots.map((t) => ({
...t,
open: t.open || Object.keys(r.errors).some((k) => k.startsWith(`${t.key}.`)),
})),
}));
toast.error("请先修正表单中的错误");
return;
}
setErrors({});
const done = (rev: PolicyRevision) => {
toast.success(`草案 r${rev.revision} 已保存,预览确认后再发布`);
onSaved(rev);
};
if (editId) {
const { policyKey: _k, ...body } = r.body;
update.mutate({ id: editId, ...body }, { onSuccess: done, onError: (e) => toast.error(errorMessage(e)) });
} else {
create.mutate(
{ ...r.body, kind: "acquisition", basedOnRevisionId: base?.id ?? null },
{ onSuccess: done, onError: (e) => toast.error(errorMessage(e)) },
);
}
};
return (
<DetailSheet
open
onOpenChange={(o) => !o && !pending && onClose()}
width="lg"
title={editId ? `编辑草案 r${base?.revision}` : base ? `基于 r${base.revision} 新建草案` : "新建收录策略草案"}
description="保存只生成草案,不会生效;发布时才授权自动执行。"
footer={
<>
<Button variant="outline" onClick={onClose} disabled={pending}>
取消
</Button>
<Button onClick={submit} disabled={pending}>
{pending ? "保存中…" : "保存草案"}
</Button>
</>
}
>
<DetailSection title="基本">
<div className="grid gap-3 sm:grid-cols-2">
<FormField
label="策略键"
htmlFor={`${id}-key`}
required
error={errors.policyKey}
hint="同一键下版本递增,只有一个生效"
>
<Input
id={`${id}-key`}
value={d.policyKey}
disabled={!!base}
aria-invalid={!!errors.policyKey}
onChange={(e) => set("policyKey", e.target.value)}
placeholder="default"
/>
</FormField>
<FormField label="标题" htmlFor={`${id}-title`} required error={errors.title}>
<Input
id={`${id}-title`}
value={d.title}
aria-invalid={!!errors.title}
onChange={(e) => set("title", e.target.value)}
/>
</FormField>
</div>
<FormField
label="本版说明"
htmlFor={`${id}-sum`}
error={errors.summary}
hint="写清这一版改了什么,出现在版本历史里"
>
<Textarea id={`${id}-sum`} rows={2} value={d.summary} onChange={(e) => set("summary", e.target.value)} />
</FormField>
<FormField label="作用范围" hint="不选即全部作品类型">
<ChipToggleGroup
label="作用范围"
options={KIND_OPTS}
value={d.scopeKinds}
onChange={(v) => set("scopeKinds", v)}
/>
</FormField>
</DetailSection>
<DetailSection
title="缺省目标模板"
actions={
<Button
variant="ghost"
size="xs"
onClick={() => set("defaultSlots", [...d.defaultSlots, tpl(undefined, true)])}
>
<Plus />
添加模板
</Button>
}
>
<p className="mb-2 text-meta text-muted-foreground">订阅对应的作品还没有目标时,按这些模板建 Slot。</p>
<TemplateList list={d.defaultSlots} onChange={(v) => set("defaultSlots", v)} errors={errors} />
</DetailSection>
<DetailSection title="新作品收录">
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<div className="min-w-0">
<p className="text-caption font-medium text-foreground">开启新作品收录</p>
<p className="text-meta text-muted-foreground">按发行/首播日期判断,站点今天上传的旧片不算新作品</p>
</div>
<Switch
checked={d.newTitleEnabled}
onCheckedChange={(v) => set("newTitleEnabled", v)}
aria-label="开启新作品收录"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="时间窗口(天)" htmlFor={`${id}-win`} error={errors.windowDays} hint="发行/首播在窗口内">
<Input
id={`${id}-win`}
inputMode="numeric"
value={d.windowDays}
aria-invalid={!!errors.windowDays}
onChange={(e) => set("windowDays", e.target.value)}
placeholder="默认"
/>
</FormField>
<FormField label="作品类型" hint="不选即全部">
<ChipToggleGroup
label="新作品类型"
options={KIND_OPTS}
value={d.newTitleKinds}
onChange={(v) => set("newTitleKinds", v)}
/>
</FormField>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-meta text-muted-foreground">新作品的目标模板</p>
<Button
variant="ghost"
size="xs"
onClick={() => set("newTitleSlots", [...d.newTitleSlots, tpl(undefined, true)])}
>
<Plus />
添加模板
</Button>
</div>
{errors.newTitleSlots ? <p className="text-meta text-destructive">{errors.newTitleSlots}</p> : null}
<TemplateList list={d.newTitleSlots} onChange={(v) => set("newTitleSlots", v)} errors={errors} />
</div>
</DetailSection>
<DetailSection title="整理">
<div className="space-y-3">
<FormField label="整理方式">
<Select
value={d.organizeMode || "__default"}
onValueChange={(v) => set("organizeMode", v === "__default" ? "" : (v as "copy" | "move"))}
>
<SelectTrigger className="w-full sm:w-64" aria-label="整理方式">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="__default">按系统设置</SelectItem>
<SelectItem value="copy">Copy(保留源文件做种)</SelectItem>
<SelectItem value="move">Move</SelectItem>
</SelectContent>
</Select>
</FormField>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<p className="min-w-0 text-caption font-medium text-foreground">H&R 未满足时经授权改用 Copy</p>
<Switch
checked={d.allowCopyWhenHnrPending}
onCheckedChange={(v) => set("allowCopyWhenHnrPending", v)}
aria-label="H&R 未满足时改用 Copy"
/>
</div>
{errors.rules ? <p className="text-meta text-destructive">{errors.rules}</p> : null}
</div>
</DetailSection>
</DetailSheet>
);
}
function TemplateList({
list,
onChange,
errors,
}: {
list: TemplateDraft[];
onChange: (v: TemplateDraft[]) => void;
errors: Record<string, string>;
}) {
const id = useId();
if (list.length === 0) return <p className="text-caption text-muted-foreground">没有模板。</p>;
const patch = (key: string, p: Partial<TemplateDraft>) =>
onChange(list.map((t) => (t.key === key ? { ...t, ...p } : t)));
return (
<div className="space-y-2">
{list.map((t) => {
const prefix = `${t.key}.`;
const errs = Object.fromEntries(
Object.entries(errors)
.filter(([k]) => k.startsWith(prefix))
.map(([k, v]) => [k.slice(prefix.length), v]),
);
const hasErr = Object.keys(errs).length > 0;
return (
<div key={t.key} className={cn("rounded-lg border", hasErr ? "border-destructive/40" : "border-border/80")}>
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
aria-expanded={t.open}
aria-label={t.open ? "收起模板" : "展开模板"}
onClick={() => patch(t.key, { open: !t.open })}
className="grid h-7 w-7 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
>
<ChevronRight className={cn("h-4 w-4 transition-transform duration-base", t.open && "rotate-90")} />
</button>
<Input
id={`${id}-${t.key}`}
aria-label="模板名称"
aria-invalid={!!errs.name}
value={t.name}
placeholder="模板名称,如 archival"
onChange={(e) => patch(t.key, { name: e.target.value })}
className="h-8 min-w-0 flex-1 font-mono"
/>
<Button
variant="ghost"
size="icon-sm"
aria-label="删除模板"
onClick={() => onChange(list.filter((x) => x.key !== t.key))}
className="hover:text-destructive"
>
<Trash2 />
</Button>
</div>
{errs.name ? <p className="px-3 pb-2 text-meta text-destructive">{errs.name}</p> : null}
{!t.open ? (
<div className="px-3 pb-2">
<ChipList items={summarizeHard(draftToJsonSafe(t.rules))} empty="硬条件不限" />
</div>
) : (
<div className="border-t border-border/60 px-3 py-3">
<SlotRulesEditor value={t.rules} onChange={(v) => patch(t.key, { rules: v })} errors={errs} />
</div>
)}
</div>
);
})}
</div>
);
}
const draftToJsonSafe = (d: SlotRulesDraft): JsonObject => {
const r = draftToJson(d);
return r.ok ? r.value.hardConditions : {};
};
@@ -0,0 +1,227 @@
import type { AcquisitionSlot } from "@umt/contracts";
import { Pencil, Plus, Target } from "lucide-react";
import { useId, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { useCreateSlot, useSlots, useUpdateSlot } from "~/api/slots.ts";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { ApiRequestError, errorMessage } from "~/lib/api.ts";
import { SlotCard } from "./slot-card.tsx";
import { draftFromJson, draftToJson, emptyDraft, type SlotRulesDraft, SlotRulesEditor } from "./slot-rules.tsx";
// 订阅页的 Slot 编辑:列出作品的收录目标,新建 / 编辑规则(乐观并发 expectedRevision)。
const SlotMeta = z.object({
name: z
.string()
.trim()
.min(1, "请填写目标名称")
.max(64, "最多 64 个字符")
.regex(/^[\w\-.+ ]+$/u, "只用字母、数字、空格和 - _ . +"),
distinctGroup: z.string().trim().max(64, "最多 64 个字符"),
});
/** 409:别人(或状态机)已改过这个 Slot */
export function conflictToast(err: unknown, onRefresh: () => void) {
if (err instanceof ApiRequestError && (err.code === "conflict" || err.code === "precondition_failed")) {
toast.error("规则已被修改(版本不一致),已刷新,请在最新版本上重新编辑", { duration: 4000 });
onRefresh();
return;
}
toast.error(errorMessage(err));
}
export function SlotsPanel({
mediaEntityId,
subscriptionId,
}: {
mediaEntityId: string;
subscriptionId?: string | null;
}) {
const q = useSlots({ mediaEntityId });
const [editing, setEditing] = useState<AcquisitionSlot | "new" | null>(null);
const slots = q.data?.items ?? [];
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-meta text-muted-foreground">每个目标独立判定与升级;状态只由状态机推进,这里只改规则。</p>
<Button variant="outline" size="sm" onClick={() => setEditing("new")}>
<Plus />
新建目标
</Button>
</div>
{q.isPending ? (
<LoadingState rows={2} />
) : q.isError ? (
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
) : slots.length === 0 ? (
<EmptyState
plain
icon={Target}
title="还没有收录目标"
description="没有目标时按已发布收录策略的缺省模板建;也可以在这里手动建。"
className="py-8"
/>
) : (
<div className="space-y-3">
{slots.map((s) => (
<SlotCard
key={s.id}
slot={s}
currentAsset={
s.currentAssetId ? <span className="font-mono text-meta">{s.currentAssetId}</span> : undefined
}
actions={
<Button variant="ghost" size="icon-sm" aria-label={`编辑 ${s.name}`} onClick={() => setEditing(s)}>
<Pencil />
</Button>
}
/>
))}
</div>
)}
{editing ? (
<SlotEditorSheet
key={editing === "new" ? "new" : `${editing.id}-${editing.revision}`}
slot={editing === "new" ? null : editing}
mediaEntityId={mediaEntityId}
subscriptionId={subscriptionId ?? null}
onClose={() => setEditing(null)}
onConflict={() => q.refetch()}
/>
) : null}
</div>
);
}
function SlotEditorSheet({
slot,
mediaEntityId,
subscriptionId,
onClose,
onConflict,
}: {
slot: AcquisitionSlot | null;
mediaEntityId: string;
subscriptionId: string | null;
onClose: () => void;
onConflict: () => void;
}) {
const id = useId();
const create = useCreateSlot();
const update = useUpdateSlot();
const [name, setName] = useState(slot?.name ?? "");
const [distinctGroup, setDistinctGroup] = useState(slot?.distinctGroup ?? "");
const [requireDistinct, setRequireDistinct] = useState(slot?.requireDistinctAsset ?? false);
const [draft, setDraft] = useState<SlotRulesDraft>(() => (slot ? draftFromJson(slot) : emptyDraft()));
const [errors, setErrors] = useState<Record<string, string>>({});
const pending = create.isPending || update.isPending;
const submit = () => {
const meta = SlotMeta.safeParse({ name, distinctGroup });
const rules = draftToJson(draft);
const errs: Record<string, string> = {};
if (!meta.success) for (const i of meta.error.issues) errs[String(i.path[0])] ??= i.message;
if (!rules.ok) Object.assign(errs, rules.errors);
if (requireDistinct && !distinctGroup.trim()) errs.distinctGroup = "需要不同物理资产时请填写分组名";
setErrors(errs);
if (Object.keys(errs).length || !meta.success || !rules.ok) return;
const common = {
...rules.value,
distinctGroup: meta.data.distinctGroup || null,
requireDistinctAsset: requireDistinct,
};
if (slot) {
update.mutate(
{ id: slot.id, expectedRevision: slot.revision, ...common },
{
onSuccess: (s) => {
toast.success(`已保存目标 ${s.name}(r${s.revision})`);
onClose();
},
onError: (e) => conflictToast(e, onConflict),
},
);
} else {
create.mutate(
{ mediaEntityId, subscriptionId, name: meta.data.name, ...common },
{
onSuccess: (s) => {
toast.success(`已新建目标 ${s.name}`);
onClose();
},
onError: (e) => toast.error(errorMessage(e)),
},
);
}
};
return (
<DetailSheet
open
onOpenChange={(o) => !o && !pending && onClose()}
width="lg"
title={slot ? `编辑目标 ${slot.name}` : "新建收录目标"}
description={
slot
? `当前版本 r${slot.revision};保存时校验版本,期间被修改会提示刷新。`
: "硬条件决定能不能收,软偏好决定先收哪个。"
}
footer={
<>
<Button variant="outline" onClick={onClose} disabled={pending}>
取消
</Button>
<Button onClick={submit} disabled={pending}>
{pending ? "保存中…" : "保存"}
</Button>
</>
}
>
<div className="grid gap-3 sm:grid-cols-2">
<FormField
label="目标名称"
htmlFor={`${id}-name`}
required
error={errors.name}
hint={slot ? "名称创建后不可改" : "如 archival / streaming"}
>
<Input
id={`${id}-name`}
value={name}
disabled={!!slot}
aria-invalid={!!errors.name}
onChange={(e) => setName(e.target.value)}
/>
</FormField>
<FormField
label="组合分组"
htmlFor={`${id}-grp`}
error={errors.distinctGroup}
hint="同组目标可要求由不同文件满足"
>
<Input
id={`${id}-grp`}
value={distinctGroup}
aria-invalid={!!errors.distinctGroup}
onChange={(e) => setDistinctGroup(e.target.value)}
/>
</FormField>
</div>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<div className="min-w-0">
<p className="text-caption font-medium text-foreground">要求不同物理资产</p>
<p className="text-meta text-muted-foreground">真正的双版本:一个文件不能同时算两个目标</p>
</div>
<Switch checked={requireDistinct} onCheckedChange={setRequireDistinct} aria-label="要求不同物理资产" />
</div>
<SlotRulesEditor value={draft} onChange={setDraft} errors={errors} />
</DetailSheet>
);
}