- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示) - Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话 - 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别 - 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务; 哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS - 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随 Co-Authored-By: Claude Opus 5.5 <[email protected]>
156 lines
5.9 KiB
TypeScript
156 lines
5.9 KiB
TypeScript
import type { Subscription, SubscriptionCoverage, SubscriptionOverviewItem } from "@umt/contracts";
|
|
import { Check, Wand2 } from "lucide-react";
|
|
import { useEffect, useId, useMemo, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { useFillSlot, useSetSlotOrder, useUpdateSubscription } from "~/api/subscriptions.ts";
|
|
import { Panel } from "~/components/library/parts.tsx";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { errorMessage } from "~/lib/api.ts";
|
|
import {
|
|
AUTOFILL_SOURCE,
|
|
type DraftErrors,
|
|
draftFromSubscription,
|
|
draftToParams,
|
|
type SubscriptionDraft,
|
|
} from "./model.ts";
|
|
import { SlotOrderList } from "./slot-order.tsx";
|
|
import { AutoFillFields } from "./subscription-form.tsx";
|
|
|
|
// 对比视图的"收录版本"区(设计 §2.2):拖动排序(PUT slot-order)、行尾计数、补全此版本、自动补全开关与预算。
|
|
|
|
type SlotRow = SubscriptionCoverage["rows"][number] & { id: string };
|
|
|
|
export function SlotPanel({
|
|
sub,
|
|
cov,
|
|
item,
|
|
canFill,
|
|
}: {
|
|
sub: Subscription;
|
|
cov: SubscriptionCoverage | undefined;
|
|
item: SubscriptionOverviewItem | null | undefined;
|
|
canFill: boolean;
|
|
}) {
|
|
const id = useId();
|
|
const setOrder = useSetSlotOrder();
|
|
const fill = useFillSlot();
|
|
const update = useUpdateSubscription();
|
|
const serverRows = useMemo<SlotRow[]>(
|
|
() => (cov?.rows ?? []).filter((r) => r.slotId !== null).map((r) => ({ ...r, id: r.slotId! })),
|
|
[cov],
|
|
);
|
|
const [rows, setRows] = useState<SlotRow[]>(serverRows);
|
|
useEffect(() => setRows(serverRows), [serverRows]);
|
|
const dirty = rows.map((r) => r.id).join() !== serverRows.map((r) => r.id).join();
|
|
|
|
const base = useMemo(() => draftFromSubscription(sub, "off"), [sub]);
|
|
const [af, setAf] = useState<Pick<SubscriptionDraft, "autoFill" | "unitsPerDay" | "gbPerDay">>(base);
|
|
useEffect(() => setAf(base), [base]);
|
|
const [errs, setErrs] = useState<DraftErrors>({});
|
|
const afDirty = af.autoFill !== base.autoFill || af.unitsPerDay !== base.unitsPerDay || af.gbPerDay !== base.gbPerDay;
|
|
|
|
const saveOrder = () =>
|
|
setOrder.mutate(
|
|
{ id: sub.id, slotIds: rows.map((r) => r.id) },
|
|
{
|
|
onSuccess: () => toast.success(`主版本:${rows[0]?.name ?? ""}`),
|
|
onError: (e) => {
|
|
toast.error(errorMessage(e));
|
|
setRows(serverRows);
|
|
},
|
|
},
|
|
);
|
|
const fillSlot = (r: SlotRow) =>
|
|
fill.mutate(
|
|
{ id: sub.id, slotId: r.id },
|
|
{
|
|
onSuccess: (x) => toast.success(x.deduplicated ? "补全任务已在排队" : `已开始补全「${r.name}」`),
|
|
onError: (e) => toast.error(errorMessage(e, "补全失败")),
|
|
},
|
|
);
|
|
const saveAutoFill = () => {
|
|
const { params, errors } = draftToParams({ ...base, ...af }, sub.params, {
|
|
upgradeChanged: false,
|
|
totalChanged: false,
|
|
});
|
|
setErrs(errors);
|
|
if (Object.keys(errors).length) return;
|
|
update.mutate(
|
|
{ id: sub.id, params },
|
|
{ onSuccess: () => toast.success("自动补全设置已保存"), onError: (e) => toast.error(errorMessage(e)) },
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Panel
|
|
title="收录版本"
|
|
description="拖动调整顺序,第一个是主版本。主版本缺的照常追更;其他版本按下面的自动补全设置补齐。"
|
|
actions={
|
|
dirty ? (
|
|
<>
|
|
<Button variant="ghost" size="sm" onClick={() => setRows(serverRows)} disabled={setOrder.isPending}>
|
|
还原
|
|
</Button>
|
|
<Button size="sm" onClick={saveOrder} disabled={setOrder.isPending}>
|
|
<Check />
|
|
保存顺序
|
|
</Button>
|
|
</>
|
|
) : null
|
|
}
|
|
>
|
|
{rows.length === 0 ? (
|
|
<p className="text-caption text-muted-foreground">
|
|
{sub.mediaEntityId ? "还没有收录版本。在下方规则区新建。" : "作品加入媒体库后才有收录版本。"}
|
|
</p>
|
|
) : (
|
|
<SlotOrderList
|
|
items={rows}
|
|
onChange={setRows}
|
|
disabled={setOrder.isPending}
|
|
renderExtra={(r, i) => (
|
|
<span className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums max-sm:w-full max-sm:pl-9">
|
|
<span>已入库 {r.summary.have}</span>
|
|
<span className={r.summary.missing ? "text-destructive" : undefined}>缺 {r.summary.missing}</span>
|
|
<span>在途 {r.summary.inflight}</span>
|
|
{i === 0 ? null : <span>{r.autoFill ? "自动补全" : "只登记缺失"}</span>}
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
onClick={() => fillSlot(r)}
|
|
disabled={!canFill || fill.isPending || r.summary.missing === 0}
|
|
title={canFill ? undefined : "订阅不在追踪中,先恢复"}
|
|
>
|
|
<Wand2 />
|
|
补全此版本
|
|
</Button>
|
|
</span>
|
|
)}
|
|
/>
|
|
)}
|
|
<div className="mt-5 space-y-2 border-t border-border/60 pt-4">
|
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
<h3 className="text-sm font-semibold text-foreground">自动补全其他版本</h3>
|
|
{item ? (
|
|
<span className="text-meta text-muted-foreground">
|
|
当前{item.autoFill.enabled ? "开启" : "关闭"} · 来自{AUTOFILL_SOURCE[item.autoFill.source]}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<AutoFillFields d={af} patch={(p) => setAf((x) => ({ ...x, ...p }))} errors={errs} idPrefix={id} />
|
|
{afDirty ? (
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="ghost" size="sm" onClick={() => setAf(base)} disabled={update.isPending}>
|
|
还原
|
|
</Button>
|
|
<Button size="sm" onClick={saveAutoFill} disabled={update.isPending}>
|
|
<Check />
|
|
保存
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</Panel>
|
|
);
|
|
}
|