feat(控制台): 去掉待处理中心,问题回到发生处
- 侧栏与命令面板没有「待处理」;/console/inbox?kind= 按问题类型跳到整理记录「需要处理」、下载面板、运行状态或订阅 - 整理记录加「需要处理」筛选,行内写原因与推荐动作(指定作品 / 重新整理 / 让智能体识别 / 替换·保留两者·跳过 / 忽略), 详情页同样可处理;处理按钮从原待处理页抽成 components/decisions/decision-actions.tsx 复用 - 铃铛只放等你批准的智能体操作与系统健康;首页「要我处理」与运行状态「需要关注」放汇总计数,运行状态加智能体用量 - 设置 → 智能体:预算卡改为「用量与限制」(只剩单次工具调用与时长,附花费统计),自动调查卡加「整理识别不到作品时叫智能体识别」 - 集数待确认的原因显示在作品页集数映射(可确认建议规则 / 智能体建议)与订阅详情;未识别资源在媒体库「未识别」里直接选候选或指定 - 下载、站点、任务里指向待处理的链接改到对应页面 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
1c1a954703
commit
fa76d7563c
36 files changed
+1209
-921
No files matched your search
@@ -0,0 +1,199 @@
|
||||
import type { Decision, DecisionOption } from "@umt/contracts";
|
||||
import { Search } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useResolveDecision } from "~/api/decisions.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
|
||||
import { type AssignedWork, WorkAssignDialog } from "~/components/library/work-assign-dialog.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 { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
// 事项(decision)的处理按钮:待处理中心取消后(2026-10-06),问题在发生处就地处理(整理记录行内等),
|
||||
// 选项与执行仍走 POST /decisions/{id}/resolve(后端注册的执行钩子:指定作品后重新整理、替换 / 保留两者 / 跳过…)。
|
||||
|
||||
/** 处理按钮只用到事项的这几个字段(整理记录里带的是精简版) */
|
||||
export type DecisionLike = Pick<Decision, "id" | "title" | "options" | "evidence">;
|
||||
|
||||
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v : null);
|
||||
|
||||
/** 身份类手动指定选项需要的输入(后端在 option.input.requires 标注) */
|
||||
type Requires = "tmdb" | "mediaEntityId" | null;
|
||||
function requiresOf(option: DecisionOption): Requires {
|
||||
const r = option.input?.requires;
|
||||
return r === "tmdb" || r === "mediaEntityId" ? r : null;
|
||||
}
|
||||
|
||||
/** 「指定作品」两个选项(本地作品 / TMDB)已并进 AssignWorkButton,其余选项各一个按钮 */
|
||||
export function plainOptions(d: DecisionLike): DecisionOption[] {
|
||||
return d.options.filter((o) => o.key !== "dismiss" && requiresOf(o) === null);
|
||||
}
|
||||
|
||||
/** 事项带不带「指定作品」(本地作品或 TMDB) */
|
||||
export function canAssignWork(d: DecisionLike): boolean {
|
||||
return d.options.some((o) => requiresOf(o) !== null);
|
||||
}
|
||||
|
||||
export function OptionButton({
|
||||
d,
|
||||
option,
|
||||
variant = "outline",
|
||||
}: {
|
||||
d: DecisionLike;
|
||||
option: DecisionOption;
|
||||
variant?: "outline" | "default";
|
||||
}) {
|
||||
const resolve = useResolveDecision();
|
||||
const uid = useId();
|
||||
const [note, setNote] = useState("");
|
||||
const requires = requiresOf(option);
|
||||
const [tmdbType, setTmdbType] = useState<"movie" | "tv">("movie");
|
||||
const [tmdbId, setTmdbId] = useState("");
|
||||
const [entity, setEntity] = useState<PickedEntity | null>(null);
|
||||
const tmdbOk = /^\d{1,10}$/.test(tmdbId.trim());
|
||||
const identity =
|
||||
requires === "tmdb"
|
||||
? tmdbOk
|
||||
? { tmdb: { type: tmdbType, id: tmdbId.trim() } }
|
||||
: null
|
||||
: requires === "mediaEntityId"
|
||||
? entity
|
||||
? { mediaEntityId: entity.id }
|
||||
: null
|
||||
: undefined;
|
||||
return (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant={variant} size="sm">
|
||||
{option.label}
|
||||
</Button>
|
||||
}
|
||||
title={option.label}
|
||||
description={option.description ? `${option.description}。` : undefined}
|
||||
confirmLabel={option.label}
|
||||
confirmDisabled={identity === null}
|
||||
onConfirm={() =>
|
||||
resolve
|
||||
.mutateAsync({
|
||||
id: d.id,
|
||||
optionKey: option.key,
|
||||
note: note.trim() || undefined,
|
||||
...(identity ? { identity } : {}),
|
||||
})
|
||||
.then(
|
||||
() => toast.success(`已处理:${option.label}`),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err, "处理失败"));
|
||||
throw err;
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{requires === "tmdb" ? (
|
||||
<div className="grid gap-x-2 sm:grid-cols-[8rem_minmax(0,1fr)]">
|
||||
<FormField label="类型" htmlFor={`${uid}-type`}>
|
||||
<Select value={tmdbType} onValueChange={(v) => setTmdbType(v as "movie" | "tv")}>
|
||||
<SelectTrigger id={`${uid}-type`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="movie">电影</SelectItem>
|
||||
<SelectItem value="tv">剧集</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="TMDB ID" htmlFor={`${uid}-tmdb`} required hint="纯数字,如 872585">
|
||||
<Input
|
||||
id={`${uid}-tmdb`}
|
||||
inputMode="numeric"
|
||||
value={tmdbId}
|
||||
onChange={(e) => setTmdbId(e.target.value)}
|
||||
aria-invalid={tmdbId.trim() !== "" && !tmdbOk}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
) : requires === "mediaEntityId" ? (
|
||||
<FormField label="本地作品" required>
|
||||
<EntityPicker value={entity} onChange={setEntity} />
|
||||
</FormField>
|
||||
) : null}
|
||||
<FormField label="备注" htmlFor={`${uid}-note`} hint="可选,记在处理结果里">
|
||||
<Textarea id={`${uid}-note`} value={note} onChange={(e) => setNote(e.target.value)} rows={2} />
|
||||
</FormField>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 事项里能拿来搜作品的名字:代表资源标题 / 种子名 / 下载名 / 标题冒号后半段 */
|
||||
export function subjectName(d: DecisionLike): string | null {
|
||||
const e = d.evidence;
|
||||
const first = Array.isArray(e.releases) ? (e.releases[0] as { title?: unknown } | undefined) : undefined;
|
||||
return (
|
||||
str(first?.title) ??
|
||||
str(e.torrentName) ??
|
||||
str(e.title) ??
|
||||
str(e.name) ??
|
||||
(d.title.includes(":") ? d.title.split(":").slice(1).join(":") : null)
|
||||
);
|
||||
}
|
||||
|
||||
/** 搜作品用的关键词:去掉扩展名、分隔符、年份之后的规格串 */
|
||||
export function queryOf(name: string | null): string {
|
||||
if (!name) return "";
|
||||
const base = name.replace(/\.(mkv|mp4|ts|m2ts|iso|avi)$/i, "").replace(/[._]+/g, " ");
|
||||
const m = base.match(/^(.*?)(?:\b(19|20)\d{2}\b|\bS\d{1,2}E?\d{0,3}\b|\b(2160|1080|720)p\b)/i);
|
||||
return (m?.[1]?.trim() || base)
|
||||
.replace(/[[\]()]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
/** 「没识别出作品」的主操作:搜作品并指定(本地作品 / TMDB),后端随即按该作品重新整理 */
|
||||
export function AssignWorkButton({ d, variant = "default" }: { d: DecisionLike; variant?: "default" | "outline" }) {
|
||||
const resolve = useResolveDecision();
|
||||
const [open, setOpen] = useState(false);
|
||||
const local = d.options.find((o) => o.input?.requires === "mediaEntityId");
|
||||
const tmdb = d.options.find((o) => o.input?.requires === "tmdb");
|
||||
if (!local && !tmdb) return null;
|
||||
const name = subjectName(d);
|
||||
const assign = (w: AssignedWork) => {
|
||||
const option = "mediaEntityId" in w ? local : tmdb;
|
||||
if (!option) {
|
||||
toast.error("mediaEntityId" in w ? "这条记录只能按 TMDB 指定" : "这条记录只能选媒体库里的作品");
|
||||
return Promise.reject(new Error("unsupported"));
|
||||
}
|
||||
return resolve
|
||||
.mutateAsync({
|
||||
id: d.id,
|
||||
optionKey: option.key,
|
||||
identity: "mediaEntityId" in w ? { mediaEntityId: w.mediaEntityId } : { tmdb: w.tmdb },
|
||||
})
|
||||
.then(
|
||||
() => toast.success(`已指定为「${w.title}」,正在重新整理`),
|
||||
(err) => {
|
||||
toast.error(errorMessage(err, "指定失败"));
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant={variant} onClick={() => setOpen(true)}>
|
||||
<Search />
|
||||
指定作品
|
||||
</Button>
|
||||
<WorkAssignDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
subject={name}
|
||||
initialQuery={queryOf(name)}
|
||||
onAssign={assign}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user