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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user