Merge branch 'deai2'
Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # apps/server/src/modules/acquisition-organize/services/organize.ts # apps/web/app/routes/console/sites.$siteId.tsx
This commit is contained in:
commit
12bc4664b6
247 files changed
+2658
-2148
No files matched your search
@@ -383,7 +383,7 @@ export function MergeDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>合并作品</DialogTitle>
|
||||
<DialogDescription>
|
||||
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品,订阅、资产和历史记录一并转过去。合并错了可以再修订。
|
||||
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品,订阅、文件和历史记录一并转过去。合并错了可以再修订。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<EntityPicker value={target} onChange={setTarget} excludeId={entity.id} />
|
||||
@@ -392,7 +392,7 @@ export function MergeDialog({
|
||||
id="merge-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="重复建档"
|
||||
placeholder="重复添加"
|
||||
/>
|
||||
</FormField>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -68,7 +68,7 @@ function FieldRow({ k, s, enrichmentId }: { k: EnrichFieldName; s: Suggestion; e
|
||||
{k !== "posterUrl" ? <p className="text-sm break-words text-foreground">{String(v.value)}</p> : null}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Chip>{EVIDENCE_LEVEL[v.evidenceLevel] ?? v.evidenceLevel}</Chip>
|
||||
{v.verbatim ? null : <Chip>Agent 归纳</Chip>}
|
||||
{v.verbatim ? null : <Chip>智能体归纳</Chip>}
|
||||
<SourceLink url={v.sourceUrl} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,7 +142,7 @@ export function EnrichSection({ item }: { item: IdentityPoolItem }) {
|
||||
{out.notice}
|
||||
{" · "}
|
||||
{out.cached ? "7 天内的缓存结果" : `搜索 ${out.usage.searchCalls} 次,读取网页 ${out.usage.fetchCalls} 次`}
|
||||
{s ? ` · ${s.extractor === "model" ? `Agent 抽取${s.model ? `(${s.model})` : ""}` : "规则抽取"}` : ""}
|
||||
{s ? ` · ${s.extractor === "model" ? `智能体抽取${s.model ? `(${s.model})` : ""}` : "规则抽取"}` : ""}
|
||||
{` · 今日 ${out.budget.usedToday}/${out.budget.dailyLimit}`}
|
||||
</p>
|
||||
{out.error ? <p className="text-caption text-warning">{out.error}</p> : null}
|
||||
|
||||
@@ -23,7 +23,7 @@ const ACTION: Record<IdentityRevision["action"], string> = {
|
||||
locked: "加人工锁",
|
||||
unlocked: "解除人工锁",
|
||||
};
|
||||
const ACTOR: Record<string, string> = { user: "用户", agent: "Agent", system: "系统", api_key: "API Key" };
|
||||
const ACTOR: Record<string, string> = { user: "用户", agent: "智能体", system: "系统", api_key: "API Key" };
|
||||
|
||||
export const identityRef = (i: Pick<ExternalIdentity, "provider" | "entityType" | "externalId">) =>
|
||||
`${i.provider}:${i.entityType}:${i.externalId}`;
|
||||
|
||||
@@ -76,7 +76,7 @@ function CandidateRow({ c, index }: { c: PoolCandidate; index: number }) {
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-meta text-muted-foreground">本地未建档</span>
|
||||
<span className="text-meta text-muted-foreground">不在媒体库</span>
|
||||
)}
|
||||
</div>
|
||||
{c.evidence.length ? (
|
||||
|
||||
@@ -148,7 +148,7 @@ export function PoolTab() {
|
||||
selectedKey={openId}
|
||||
empty={{
|
||||
icon: SearchX,
|
||||
title: filtered ? "没有符合条件的待识别资源" : "没有待识别资源",
|
||||
title: filtered ? "没有符合条件的未识别资源" : "没有未识别资源",
|
||||
description: "未命中订阅、证据不足或冲突的资源会出现在这里;新增相近别名时自动重试,也可手动指定。",
|
||||
}}
|
||||
hasMore={pool.hasNextPage}
|
||||
@@ -249,7 +249,7 @@ export function ExternalTab() {
|
||||
empty={{
|
||||
icon: Fingerprint,
|
||||
title: "没有外部身份",
|
||||
description: "没有外部身份的作品也能建档入库。",
|
||||
description: "没有外部编号的作品也能入库。",
|
||||
}}
|
||||
hasMore={list.hasNextPage}
|
||||
onLoadMore={() => list.fetchNextPage()}
|
||||
|
||||
@@ -34,7 +34,7 @@ export const MEDIA_KIND: Record<MediaKind, string> = {
|
||||
};
|
||||
|
||||
export const ENTITY_STATUS: Record<MediaEntityStatus, ToneLabel> = {
|
||||
provisional: { label: "临时建档", tone: "warning" },
|
||||
provisional: { label: "临时作品", tone: "warning" },
|
||||
active: { label: "正常", tone: "success" },
|
||||
merged: { label: "已合并", tone: "neutral" },
|
||||
archived: { label: "已归档", tone: "neutral" },
|
||||
@@ -132,11 +132,11 @@ export const CANDIDATE_DECISION: Record<string, ToneLabel> = {
|
||||
};
|
||||
|
||||
export const DECISION_KIND: Record<DecisionKind, string> = {
|
||||
identity_ambiguous: "身份歧义",
|
||||
identity_unresolved: "待识别",
|
||||
identity_ambiguous: "没识别出作品",
|
||||
identity_unresolved: "未识别",
|
||||
download_stalled: "下载无进展",
|
||||
download_failed: "下载失败",
|
||||
path_conflict: "路径冲突",
|
||||
path_conflict: "整理冲突",
|
||||
space_insufficient: "空间不足",
|
||||
approval_required: "需要审批",
|
||||
metadata_conflict: "元数据冲突",
|
||||
@@ -146,7 +146,7 @@ export const DECISION_KIND: Record<DecisionKind, string> = {
|
||||
|
||||
export const DECISION_STATUS: Record<DecisionStatus, ToneLabel> = {
|
||||
open: { label: "待处理", tone: "warning" },
|
||||
investigating: { label: "Agent 调查中", tone: "neutral" },
|
||||
investigating: { label: "智能体调查中", tone: "neutral" },
|
||||
awaiting_user: { label: "等你决定", tone: "warning" },
|
||||
resolved: { label: "已处理", tone: "success" },
|
||||
dismissed: { label: "已忽略", tone: "neutral" },
|
||||
@@ -168,7 +168,7 @@ export const POOL_REASON: Record<IdentityCandidateReason, ToneLabel> = {
|
||||
};
|
||||
|
||||
export const POOL_STATUS: Record<string, ToneLabel> = {
|
||||
pending: { label: "待识别", tone: "warning" },
|
||||
pending: { label: "未识别", tone: "warning" },
|
||||
resolved: { label: "已识别", tone: "success" },
|
||||
dismissed: { label: "已忽略", tone: "neutral" },
|
||||
archived: { label: "已归档", tone: "neutral" },
|
||||
@@ -178,13 +178,13 @@ export const PREF_KIND: Record<PreferenceKind, { label: string; hint: string }>
|
||||
soft: { label: "软偏好", hint: "影响排序,不排除" },
|
||||
hard: { label: "硬规则", hint: "命中即排除" },
|
||||
version: { label: "版本偏好", hint: "同一作品内选哪种版本" },
|
||||
target: { label: "版本目标", hint: "为作品新建一个版本目标" },
|
||||
target: { label: "收录版本", hint: "为作品新建一个收录版本" },
|
||||
};
|
||||
|
||||
export const PREF_ORIGIN: Record<PreferenceOrigin, string> = {
|
||||
manual: "手动",
|
||||
conversation: "对话确认",
|
||||
agent_inferred: "Agent 推断",
|
||||
agent_inferred: "智能体推断",
|
||||
};
|
||||
|
||||
export const PREF_STATUS: Record<PreferenceStatus, ToneLabel> = {
|
||||
@@ -219,12 +219,12 @@ export const SUB_STATUS: Record<SubscriptionStatus, ToneLabel> = {
|
||||
completed: { label: "已完成", tone: "neutral" },
|
||||
cancelled: { label: "已取消", tone: "neutral" },
|
||||
};
|
||||
export const SUB_ORIGIN: Record<string, string> = { user: "用户", agent: "Agent", policy: "策略", import: "导入" };
|
||||
export const SUB_ORIGIN: Record<string, string> = { user: "用户", agent: "智能体", policy: "策略", import: "导入" };
|
||||
|
||||
export const METADATA_SOURCE: Record<string, string> = {
|
||||
tmdb: "TMDB",
|
||||
user: "人工",
|
||||
agent: "Agent",
|
||||
agent: "智能体",
|
||||
nfo: "NFO",
|
||||
site: "站点",
|
||||
import: "导入",
|
||||
@@ -316,7 +316,7 @@ export const opts = <K extends string>(rec: Record<K, string | { label: string }
|
||||
return { value, label: typeof v === "string" ? v : v.label };
|
||||
});
|
||||
|
||||
/** 版本目标 / 候选的原因串(如 `blocked:no_main_video`、`deferred:slot_busy;inflight:<id>`)→ 中文;不认识的原样显示 */
|
||||
/** 收录版本 / 候选的原因串(如 `blocked:no_main_video`、`deferred:slot_busy;inflight:<id>`)→ 中文;不认识的原样显示 */
|
||||
const SLOT_REASON: Record<string, string> = {
|
||||
"blocked:no_main_video": "没有可入库的主视频",
|
||||
"deferred:slot_busy": "已有计划在途,等它结束再评估",
|
||||
|
||||
@@ -108,7 +108,7 @@ export function PreferenceFormDialog({
|
||||
<DialogTitle>{tag ? "编辑偏好" : "添加偏好"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{tag
|
||||
? "修改作用范围或规则会留下旧语义的抑制记录,Agent 不会按旧证据加回。"
|
||||
? "修改作用范围或规则会留下旧语义的抑制记录,智能体不会按旧证据加回。"
|
||||
: "手动添加等同对话确认。之前取消过的偏好,重新添加会解除抑制。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -39,7 +39,7 @@ export function SlotCard({
|
||||
{slot.requireDistinctAsset ? (
|
||||
<Chip>
|
||||
<Link2 className="h-3 w-3" />
|
||||
需不同物理资产{slot.distinctGroup ? ` · ${slot.distinctGroup}` : ""}
|
||||
需独立文件{slot.distinctGroup ? ` · ${slot.distinctGroup}` : ""}
|
||||
</Chip>
|
||||
) : null}
|
||||
<span className="ml-auto text-meta text-muted-foreground tabular-nums">r{slot.revision}</span>
|
||||
@@ -72,7 +72,7 @@ export function SlotCard({
|
||||
<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>
|
||||
<dt className="mb-1 text-meta text-muted-foreground">当前文件</dt>
|
||||
<dd className="text-caption break-words text-foreground">{currentAsset ?? "无"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -353,8 +353,8 @@ function RulesSummary({ rules, valid }: { rules: AcquisitionPolicyRules | undefi
|
||||
return (
|
||||
<div className="space-y-2 text-caption">
|
||||
<div>
|
||||
<span className="text-muted-foreground">默认版本目标 </span>
|
||||
<ChipList items={(rules.defaultSlots ?? []).map(tplLine)} empty="无(订阅没有版本目标时不自动创建)" />
|
||||
<span className="text-muted-foreground">默认收录版本 </span>
|
||||
<ChipList items={(rules.defaultSlots ?? []).map(tplLine)} empty="无(订阅没有收录版本时不自动创建)" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">新作品收录 </span>
|
||||
@@ -718,7 +718,7 @@ function PolicyEditorSheet({
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection
|
||||
title="默认版本目标模板"
|
||||
title="默认收录版本模板"
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -730,7 +730,7 @@ function PolicyEditorSheet({
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="mb-2 text-meta text-muted-foreground">订阅的作品没有版本目标时,按这些模板创建。</p>
|
||||
<p className="mb-2 text-meta text-muted-foreground">订阅的作品没有收录版本时,按这些模板创建。</p>
|
||||
<TemplateList list={d.defaultSlots} onChange={(v) => set("defaultSlots", v)} errors={errors} />
|
||||
</DetailSection>
|
||||
|
||||
@@ -768,7 +768,7 @@ function PolicyEditorSheet({
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-meta text-muted-foreground">新作品的版本目标模板</p>
|
||||
<p className="text-meta text-muted-foreground">新作品的收录版本模板</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
|
||||
@@ -51,10 +51,10 @@ export function SlotsPanel({
|
||||
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>
|
||||
<p className="text-meta text-muted-foreground">每个收录版本单独判定和升级。这里只改规则。</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing("new")}>
|
||||
<Plus />
|
||||
新建版本目标
|
||||
新建收录版本
|
||||
</Button>
|
||||
</div>
|
||||
{q.isPending ? (
|
||||
@@ -65,7 +65,7 @@ export function SlotsPanel({
|
||||
<EmptyState
|
||||
plain
|
||||
icon={Target}
|
||||
title="还没有版本目标"
|
||||
title="还没有收录版本"
|
||||
description="没有时按已发布收录策略的默认模板创建,也可以手动新建。"
|
||||
className="py-8"
|
||||
/>
|
||||
@@ -140,7 +140,7 @@ function SlotEditorSheet({
|
||||
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 = "要求不同物理资产时须填写分组";
|
||||
if (requireDistinct && !distinctGroup.trim()) errs.distinctGroup = "要求独立文件时须填写分组";
|
||||
setErrors(errs);
|
||||
if (Object.keys(errs).length || !meta.success || !rules.ok) return;
|
||||
const common = {
|
||||
@@ -178,7 +178,7 @@ function SlotEditorSheet({
|
||||
open
|
||||
onOpenChange={(o) => !o && !pending && onClose()}
|
||||
width="lg"
|
||||
title={slot ? `编辑版本目标 ${slot.name}` : "新建版本目标"}
|
||||
title={slot ? `编辑收录版本 ${slot.name}` : "新建收录版本"}
|
||||
description={
|
||||
slot ? `当前 r${slot.revision}。保存时若已被修改,会提示刷新。` : "不满足硬条件的资源不收;软偏好决定先收哪个。"
|
||||
}
|
||||
@@ -225,10 +225,10 @@ function SlotEditorSheet({
|
||||
</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-caption font-medium text-foreground">要求独立文件</p>
|
||||
<p className="text-meta text-muted-foreground">同一个文件不能同时满足两个目标</p>
|
||||
</div>
|
||||
<Switch checked={requireDistinct} onCheckedChange={setRequireDistinct} aria-label="要求不同物理资产" />
|
||||
<Switch checked={requireDistinct} onCheckedChange={setRequireDistinct} aria-label="要求独立文件" />
|
||||
</div>
|
||||
<SlotRulesEditor value={draft} onChange={setDraft} errors={errors} />
|
||||
</DetailSheet>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { SearchWork } from "@umt/contracts";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearch } from "~/api/search.ts";
|
||||
import { LoadingState } from "~/components/console/empty-state.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 { cn } from "~/lib/utils.ts";
|
||||
import { MEDIA_KIND } from "./labels.ts";
|
||||
|
||||
/** 选中的作品:本地作品给 mediaEntityId,只在 TMDB 上的给 tmdb */
|
||||
export type AssignedWork =
|
||||
| { mediaEntityId: string; title: string }
|
||||
| { tmdb: { type: "movie" | "tv"; id: string }; title: string };
|
||||
|
||||
const workTitle = (w: SearchWork) => `${w.title}${w.year ? ` (${w.year})` : ""}`;
|
||||
|
||||
/**
|
||||
* 「指定作品」:搜本地媒体库与 TMDB(复用全局搜索 /search?scope=media),选一个交给 onAssign。
|
||||
* onAssign 返回 Promise:执行中按钮禁用,resolve 后关闭,reject 时保持打开(错误由调用方提示)。
|
||||
*/
|
||||
export function WorkAssignDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialQuery = "",
|
||||
subject,
|
||||
onAssign,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialQuery?: string;
|
||||
/** 在说明里点名要指定的是什么(文件名 / 资源标题) */
|
||||
subject?: string | null;
|
||||
onAssign: (w: AssignedWork) => Promise<unknown>;
|
||||
}) {
|
||||
const [q, setQ] = useState(initialQuery);
|
||||
const [debounced, setDebounced] = useState(initialQuery);
|
||||
const [picked, setPicked] = useState<SearchWork | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(q), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [q]);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQ(initialQuery);
|
||||
setDebounced(initialQuery);
|
||||
setPicked(null);
|
||||
}
|
||||
}, [open, initialQuery]);
|
||||
const search = useSearch(debounced, "media", 12, open);
|
||||
const works = search.data?.media?.works ?? [];
|
||||
const tmdbOff = search.data?.media && !search.data.media.tmdb.configured;
|
||||
|
||||
const confirm = async () => {
|
||||
if (!picked) return;
|
||||
const target: AssignedWork | null = picked.localEntityId
|
||||
? { mediaEntityId: picked.localEntityId, title: workTitle(picked) }
|
||||
: picked.tmdb
|
||||
? { tmdb: { type: picked.tmdb.entityType, id: picked.tmdb.externalId }, title: workTitle(picked) }
|
||||
: null;
|
||||
if (!target) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await onAssign(target);
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
// 调用方已提示,保持打开
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => (pending ? undefined : onOpenChange(o))}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>指定作品</DialogTitle>
|
||||
<DialogDescription className="break-all">
|
||||
{subject ? `「${subject}」属于哪部作品?` : "选这个资源属于哪部作品。"}
|
||||
选中后加人工锁,自动识别不再改它。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-w-0 space-y-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
|
||||
autoFocus
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setPicked(null);
|
||||
}}
|
||||
placeholder="作品名,中英文都行"
|
||||
className="pl-9"
|
||||
aria-label="搜索作品"
|
||||
/>
|
||||
</div>
|
||||
{debounced.trim() ? (
|
||||
<div className="max-h-72 overflow-y-auto rounded-lg border border-border">
|
||||
{search.isPending ? (
|
||||
<LoadingState rows={3} className="p-2" />
|
||||
) : search.isError ? (
|
||||
<p className="p-3 text-meta text-destructive">{(search.error as Error).message}</p>
|
||||
) : works.length === 0 ? (
|
||||
<p className="p-3 text-meta text-muted-foreground">
|
||||
没有找到{tmdbOff ? "本地作品;TMDB 未配置,只搜了媒体库" : "匹配的作品"}
|
||||
</p>
|
||||
) : (
|
||||
<ul>
|
||||
{works.map((w) => {
|
||||
const active = picked?.key === w.key;
|
||||
return (
|
||||
<li key={w.key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPicked(w)}
|
||||
aria-pressed={active}
|
||||
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]",
|
||||
active && "bg-foreground/[0.07]",
|
||||
)}
|
||||
>
|
||||
{active ? <Check className="h-4 w-4 shrink-0 text-success" /> : null}
|
||||
<span className="min-w-0 flex-1 truncate text-foreground">{workTitle(w)}</span>
|
||||
<span className="shrink-0 text-meta text-muted-foreground">
|
||||
{MEDIA_KIND[w.kind]} · {w.localEntityId ? "在媒体库" : "TMDB"}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => void confirm()} disabled={!picked || pending}>
|
||||
{pending ? "处理中…" : "指定"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user