Merge branch 'main' into copy/c6

This commit is contained in:
root committed 2026-10-07 02:05:16 +08:00
commit 8a9cbaff6a
146 files changed
+901 -529

No files matched your search

@@ -1,4 +1,5 @@
import { join } from "node:path";
import { simplifiedChineseTagline } from "@umt/adapters";
import {
type AcquisitionPlan,
type AcquisitionSlot,
@@ -405,6 +406,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : null,
})),
scrape: await latestEntityScrape(d, id),
displayTagline: heroTagline(e!.metadata),
};
return detail;
},
@@ -642,3 +644,11 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
{ tag: TAG },
);
}
/** 详情页头部的标语:只显示简体中文(见 simplifiedChineseTagline);存储的标语与语言不变 */
function heroTagline(metadata: unknown): string | null {
const m = (metadata ?? {}) as Record<string, { value?: unknown; language?: string | null } | undefined>;
const text = typeof m.tagline?.value === "string" ? m.tagline.value : null;
const orig = typeof m.originalLanguage?.value === "string" ? m.originalLanguage.value : null;
return simplifiedChineseTagline(text, m.tagline?.language ?? null, orig);
}
+2 -1
View File
@@ -3,6 +3,7 @@ import {
fetchRichDetail,
fetchRichSeason,
type MetadataCandidate,
simplifiedChineseTagline,
TmdbError,
type TmdbRichDetail,
} from "@umt/adapters";
@@ -453,7 +454,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
out.title = rich.title || out.title;
out.overview = rich.overview ?? out.overview;
out.titleZh = rich.titleZh;
out.tagline = rich.tagline;
out.tagline = simplifiedChineseTagline(rich.tagline, rich.taglineLanguage, rich.originalLanguage);
out.genres = rich.genres;
out.voteAverage = rich.voteAverage;
out.voteCount = rich.voteCount;
@@ -336,4 +336,32 @@ describe.skipIf(!hasPg)("LIBV-1 入库版本", () => {
expect(response.body.data.downloads).toHaveLength(105);
expect(response.body.data.droppedPlans).toHaveLength(51);
});
it("作品详情头部标语只给简体中文(displayTagline);存储的 metadata.tagline 不变", async () => {
const field = (value: unknown, language: string | null = null) => ({
value,
source: "tmdb",
language,
at: "2026-10-01T00:00:00.000Z",
locked: false,
});
const cases: [string, string, string | null][] = [
["我们注定要离开地球", "en", "我们注定要离开地球"],
["我們註定要離開地球", "en", null],
["Mankind was born on Earth.", "en", null],
["我們註定要離開地球", "zh", "我們註定要離開地球"],
];
for (const [tagline, orig, want] of cases) {
const entityId = await seedEntity(h.db, { kind: "movie", title: `标语 ${tagline}`, year: 2026 });
await h.db
.updateTable("media_entity")
.set({ metadata: JSON.stringify({ tagline: field(tagline), originalLanguage: field(orig) }) })
.where("id", "=", entityId)
.execute();
const r = await h.api("GET", `/library/${entityId}`);
expect(r.status).toBe(200);
expect(r.body.data.displayTagline).toBe(want);
expect(r.body.data.entity.metadata.tagline.value).toBe(tagline);
}
});
});
@@ -33,6 +33,7 @@ function detail(
overview: null,
overviewLanguage: null,
tagline: null,
taglineLanguage: null,
year: null,
releaseDate: null,
lastAirDate: null,
@@ -68,6 +68,7 @@ const detail = (entityType: "movie" | "tv", externalId: string): TmdbRichDetail
overview: null,
overviewLanguage: null,
tagline: null,
taglineLanguage: null,
year: 2022,
releaseDate: null,
lastAirDate: null,
+79 -1
View File
@@ -352,6 +352,16 @@
--mat-pill-secondary-bg: rgba(20, 22, 28, 0.03);
--mat-pill-secondary-bg-hover: rgba(20, 22, 28, 0.07);
--mat-pill-secondary-border: rgba(20, 22, 28, 0.12);
/* pill-solid:详情页头图区的唯一主操作(订阅),主色实心;只用在 MediaHero 的操作行 */
--mat-pill-solid-bg: var(--primary);
--mat-pill-solid-bg-hover: color-mix(in oklab, var(--primary) 84%, var(--background));
--mat-pill-solid-fg: var(--primary-foreground);
--mat-pill-solid-shadow: 0 1px 2px rgba(20, 22, 28, 0.14), 0 6px 16px -8px rgba(20, 22, 28, 0.4);
/* media-hero:详情页头图。剧照铺在内容区顶部、渐隐到页面底色;左侧文字区压近乎不透明的底色,右侧露出剧照。
窄屏海报与文字上下排,改为自上而下压底色 */
--mat-hero-image-opacity: 1;
--mat-hero-scrim-side: linear-gradient(90deg, var(--background) 0%, color-mix(in oklab, var(--background) 96%, transparent) 38%, color-mix(in oklab, var(--background) 82%, transparent) 55%, color-mix(in oklab, var(--background) 40%, transparent) 72%, transparent 92%);
--mat-hero-scrim-stack: linear-gradient(180deg, color-mix(in oklab, var(--background) 30%, transparent) 0%, color-mix(in oklab, var(--background) 72%, transparent) 45%, color-mix(in oklab, var(--background) 92%, transparent) 75%, var(--background) 100%);
/* 分段控件的滑块(侧栏当前项、SegmentedTabs):日间是托起的白片,夜间是薄白玻璃 */
--mat-seg-thumb-bg: rgba(255, 255, 255, 0.8);
--mat-seg-thumb-border: rgba(20, 22, 28, 0.06);
@@ -493,6 +503,13 @@
--mat-pill-secondary-bg: rgba(255, 255, 255, 0.04);
--mat-pill-secondary-bg-hover: rgba(255, 255, 255, 0.1);
--mat-pill-secondary-border: rgba(255, 255, 255, 0.15);
--mat-pill-solid-bg: var(--primary);
--mat-pill-solid-bg-hover: color-mix(in oklab, var(--primary) 84%, var(--background));
--mat-pill-solid-fg: var(--primary-foreground);
--mat-pill-solid-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 6px 16px -8px rgba(0, 0, 0, 0.6);
--mat-hero-image-opacity: 0.85;
--mat-hero-scrim-side: linear-gradient(90deg, var(--background) 0%, color-mix(in oklab, var(--background) 94%, transparent) 38%, color-mix(in oklab, var(--background) 78%, transparent) 55%, color-mix(in oklab, var(--background) 36%, transparent) 72%, transparent 92%);
--mat-hero-scrim-stack: linear-gradient(180deg, color-mix(in oklab, var(--background) 30%, transparent) 0%, color-mix(in oklab, var(--background) 72%, transparent) 45%, color-mix(in oklab, var(--background) 92%, transparent) 75%, var(--background) 100%);
--mat-seg-thumb-bg: rgba(255, 255, 255, 0.08);
--mat-seg-thumb-border: rgba(255, 255, 255, 0.15);
--mat-seg-thumb-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 4px 18px -6px rgba(0, 0, 0, 0.4);
@@ -1313,9 +1330,70 @@
color: var(--content-strong);
}
/* 实心主操作:只给详情页头图区的唯一主操作(订阅)用(2026-10-07 用户定的例外),
其余页面主操作仍是 pill-primary;hover 只微调明度,不换色 */
.pill-solid {
background: var(--mat-pill-solid-bg);
border: 1px solid transparent;
color: var(--mat-pill-solid-fg);
box-shadow: var(--mat-pill-solid-shadow);
transition:
background-color var(--duration-base) var(--ease-standard),
transform 220ms var(--ease-spring);
}
.pill-solid:hover {
background: var(--mat-pill-solid-bg-hover);
}
/* 详情页头图(MediaHero):剧照层上下、右缘渐隐成透明,露出页面底色与光斑,不留硬边;
::after 是压在剧照上的底色渐变(左侧文字区近乎不透明),参数见 --mat-hero-* */
.media-hero-backdrop {
-webkit-mask-image:
linear-gradient(to bottom, transparent 0%, #000 14%, #000 55%, transparent 100%),
linear-gradient(to right, #000 0%, #000 90%, transparent 100%);
-webkit-mask-composite: source-in;
mask-image:
linear-gradient(to bottom, transparent 0%, #000 14%, #000 55%, transparent 100%),
linear-gradient(to right, #000 0%, #000 90%, transparent 100%);
mask-composite: intersect;
}
.media-hero-backdrop > img {
opacity: var(--mat-hero-image-opacity);
}
.media-hero-backdrop::after {
content: "";
position: absolute;
inset: 0;
background: var(--mat-hero-scrim-stack);
}
@media (min-width: 640px) {
.media-hero-backdrop::after {
background: var(--mat-hero-scrim-side);
}
}
/* 折叠的长文本(ClampText):最后一行行尾渐隐,「展开」压在渐隐处;行数由 --clamp-lines 给 */
.clamp-fade {
max-height: calc(var(--clamp-lines, 3) * 1lh);
overflow: hidden;
-webkit-mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 62%, transparent 88%);
-webkit-mask-size: 100% calc(100% - 1lh), 100% 1lh;
-webkit-mask-position: top, bottom;
-webkit-mask-repeat: no-repeat;
mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 62%, transparent 88%);
mask-size: 100% calc(100% - 1lh), 100% 1lh;
mask-position: top, bottom;
mask-repeat: no-repeat;
}
/* 按下回弹,与 .btn-glass 同一手感 */
.pill-primary:active,
.pill-secondary:active {
.pill-secondary:active,
.pill-solid:active {
transform: scale(0.97);
}
@@ -86,7 +86,7 @@ export const BUDGET_LABEL: Record<string, string> = {
const COMPACT_STATUS: Record<string, string> = {
completed: "已完成",
declined: "没有压缩",
aborted: "已中止",
aborted: "已停止",
failed: "失败",
};
@@ -108,7 +108,7 @@ export function contextLabel(context: string | null): string | null {
const first = context.split(" · ")[0] ?? "";
// 前缀里第一段是界面给的名称(「作品:沙丘2」);没有名称时只剩 key=ID,不上屏
if (!first || first.includes("=")) return null;
return first.replace(/^(作品|资源|下载|整理记录|站点|任务|引擎任务)[::]\s*/, "");
return first.replace(/^(作品|资源|下载|整理记录|站点|任务|引擎任务|后台任务)[::]\s*/, "");
}
export function UserMessage({
@@ -289,7 +289,7 @@ export function StepSummary({
const thinking = activity.some((a) => a.kind === "thinking");
const head = current
? `${done ? `${done},` : ""}${runningText(current)}…`
: done || (running ? "思考中…" : thinking ? "想了一下" : "过程说明");
: done || (running ? "思考中…" : thinking ? "思考过程" : "过程说明");
return (
<div className="w-full min-w-0" data-slot="step-summary">
<button
@@ -365,7 +365,7 @@ export function DeniedLines({ runId, calls }: { runId: string; calls: ToolCallVi
{
onSuccess: () => {
setSent((s) => new Set(s).add(c.capabilityId));
toast.success("已批准,智能体重试这一步");
toast.success("已批准,智能体会重试这一步");
},
onError: (e) => toast.error(errorMessage(e)),
},
@@ -399,7 +399,7 @@ function EndNotice({ item }: { item: Extract<StreamItem, { kind: "end" }> }) {
<CircleSlash aria-hidden className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 break-words">
{budget
? `超出预算(${BUDGET_LABEL[item.budget ?? ""] ?? "用量"}),已停止。已完成的动作不会撤销;调大预算后发消息可以继续。`
? `超出预算(${BUDGET_LABEL[item.budget ?? ""] ?? "用量"}),已停止。已完成的操作不会撤销,调大预算后发消息可继续`
: aborted
? "已停止。"
: `运行失败${item.message ? `:${humanizeReason(item.message) ?? item.message}` : ""}`}
@@ -523,7 +523,7 @@ export function AgentConversation({
<UserMessage text={run.goal} tag={isProposeFirst(run) ? "先给方案" : undefined} />
{blocks.map((b, bi) =>
b.kind === "user" ? (
<UserMessage key={b.key} text={b.text} note={b.pending ? "已送达,等待智能体读取" : undefined} animateIn />
<UserMessage key={b.key} text={b.text} note={b.pending ? "已发送,等智能体读取" : undefined} animateIn />
) : (
<section key={b.key} aria-label="智能体回合" className="flex w-full min-w-0 flex-col items-start gap-2">
{b.parts.map((p, pi) => {
@@ -587,7 +587,7 @@ export function AgentConversation({
return (
<MessageMarker key={p.key}>
<Layers aria-hidden className="size-3.5 shrink-0" />
整理上下文 ·{" "}
压缩上下文 ·{" "}
{p.item.done ? (COMPACT_STATUS[p.item.status ?? "completed"] ?? p.item.status) : "进行中…"}
</MessageMarker>
);
@@ -626,7 +626,7 @@ function DraftCheckCard({ jobId, name }: { jobId: string; name: string }) {
<CalendarCog aria-hidden className="size-4 shrink-0 text-muted-foreground" />
巡检草稿「{name}」<StatusBadge>未启用</StatusBadge>
</p>
<p className="mt-0.5 text-meta text-muted-foreground">到计划页检查目标、间隔和能力,确认后打开开关启用。</p>
<p className="mt-0.5 text-meta text-muted-foreground">在计划页检查目标、间隔和能力后启用。</p>
</div>
<Button asChild size="sm" variant="outline">
<Link to={`${consolePath("schedules")}?job=${encodeURIComponent(jobId)}`}>检查并启用</Link>
@@ -672,8 +672,8 @@ export function resultFacts(report: GoalReport | null): ResultFact[] {
});
}
if (r.imported && r.imported !== done.length) facts.push({ text: `已整理 ${r.imported} 个` });
if (r.inProgress) facts.push({ text: `${r.inProgress} 个还在进行` });
if (r.failed) facts.push({ text: `${r.failed} 个没有成功`, tone: "warning" });
if (r.inProgress) facts.push({ text: `${r.inProgress} 个进行中` });
if (r.failed) facts.push({ text: `${r.failed} 个失败`, tone: "warning" });
return facts.length ? facts : [{ text: "没有提交下载或整理" }];
}
@@ -859,7 +859,7 @@ export function RunAnnouncer({ status, finished }: { status: AgentRunStatus; fin
const [msg, setMsg] = useState("");
const prev = useRef(status);
useEffect(() => {
if (prev.current !== status) setMsg(`运行${RUN_STATUS_LABEL[status]}`);
if (prev.current !== status) setMsg(`会话${RUN_STATUS_LABEL[status]}`);
prev.current = status;
}, [status]);
useEffect(() => {
@@ -51,7 +51,7 @@ export function useContextName(ctx: AgentContext | null): string | null {
const task = useDownloadTask(path ? ctx?.downloadTaskId : undefined);
const record = useRecord(path ? ctx?.recordId : undefined);
if (!ctx) return null;
if (!path) return ctx.label ? ctx.label.replace(/^(作品|资源|下载|整理记录|站点|任务)[::]\s*/, "") : null;
if (!path) return ctx.label ? ctx.label.replace(/^(作品|资源|下载|整理记录|站点|任务|后台任务)[::]\s*/, "") : null;
if (ctx.entityId) return entity.data?.entity.title ?? "这部作品";
if (ctx.siteId) return site.data?.name ?? ctx.siteId;
if (ctx.downloadTaskId) return task.data?.name ?? "这个下载任务";
@@ -162,8 +162,8 @@ export function AgentDrawer() {
<Button
variant="ghost"
size="icon-sm"
aria-label="最小化(运行继续)"
title="最小化(运行继续)"
aria-label="最小化,会话继续运行"
title="最小化,会话继续运行"
onClick={minimizeAgentDrawer}
>
<Minus />
@@ -256,8 +256,8 @@ function AgentMiniDock({ runId }: { runId: string }) {
<Button
variant="ghost"
size="icon-xs"
aria-label="关闭小窗(运行在后台继续)"
title="关闭小窗(运行在后台继续)"
aria-label="关闭小窗,会话在后台继续"
title="关闭小窗,会话在后台继续"
onClick={() => setAgentDrawerOpen(false)}
>
<X />
@@ -143,7 +143,7 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt
const risks = options.flatMap((o) => o.risks.map((r) => ({ key: `${o.id}:${r}`, option: o.title, text: r })));
const submit = (optionId: string | null) => {
if (optionId === null && !reason.trim()) {
toast.error("写一句不要的原因,智能体按它调整");
toast.error("写一下为什么都不要");
return;
}
setPicking(optionId ?? "__none__");
@@ -153,7 +153,7 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt
onSuccess: () => {
setSent({ optionId, reason: reason.trim() });
const o = view.options.find((x) => x.id === optionId);
toast.success(optionId ? (o?.action?.approves ? "已选定并批准" : "已选定") : "已退回,智能体调整方案");
toast.success(optionId ? (o?.action?.approves ? "已选定并批准" : "已选定") : "已发送,智能体会重新给方案");
},
onError: (e) => toast.error(errorMessage(e)),
onSettled: () => setPicking(null),
@@ -286,7 +286,7 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt
disabled={!reason.trim() || choose.isPending}
onClick={() => submit(null)}
>
退回
发送
</Button>
</div>
) : null}
@@ -336,7 +336,7 @@ export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTi
...(s === "rejected" && reason.trim() ? { reason: reason.trim() } : {}),
},
{
onSuccess: () => toast.success(s === "approved" ? "已批准,智能体继续执行" : "已拒绝,智能体按理由调整"),
onSuccess: () => toast.success(s === "approved" ? "已批准,智能体继续执行" : "已拒绝,智能体会调整做法"),
onError: (e) => toast.error(errorMessage(e)),
},
);
@@ -364,7 +364,7 @@ export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTi
value={reason}
maxLength={2000}
aria-label="拒绝理由"
placeholder="理由或建议(可选),智能体按它调整"
placeholder="理由或建议,可不填"
onChange={(e) => setReason(e.target.value)}
/>
) : null}
@@ -119,7 +119,7 @@ export function GoalComposer({
maxRows={10}
maxLength={4000}
aria-label="目标"
placeholder="例如:把沙丘:第二部的 4K 收进来"
placeholder="例如:下载沙丘 2 的 4K 版本"
submitLabel={pending ? "提交中" : "开始"}
commands={NEW_RUN_COMMANDS}
context={context}
@@ -491,7 +491,7 @@ export function AgentRunPanel({
maxRows={6}
maxLength={8000}
aria-label="发消息"
placeholder={ended ? "接着说,输入 / 看指令" : "补充或纠正"}
placeholder={ended ? "继续对话,输入 / 查看指令" : "补充或纠正"}
commands={RUN_COMMANDS}
/>
</div>
@@ -499,13 +499,13 @@ export function AgentRunPanel({
<ConfirmDialog
open={confirmStop}
onOpenChange={setConfirmStop}
title="停止这次运行?"
description="后续步骤不再执行,已完成的动作(如已提交的下载)不会撤销。"
confirmLabel="停止运行"
title="停止这个会话?"
description="后续步骤不再执行。已完成的操作不会撤销,已提交的下载会继续。"
confirmLabel="停止会话"
destructive
onConfirm={() =>
abort.mutateAsync(r.id).then(
() => toast.success("已请求停止"),
() => toast.success("正在停止"),
(e) => {
toast.error(errorMessage(e));
throw e;
@@ -380,7 +380,7 @@ export function AgentSessionList({
? "没有等你处理的会话"
: "没有符合条件的会话"
: f.group === "auto"
? "还没有巡检会话,巡检到点后会出现在这里"
? "还没有巡检会话"
: "还没有会话,写一个目标开始"
}
/>
@@ -245,7 +245,7 @@ describe("拒绝:缩成一行", () => {
it("授权拒绝 → 需要你批准 + [批准并重试];白名单拦截没有按钮", () => {
expect(deniedReason(denied)).toEqual({ text: "需要你批准", approvable: true });
expect(deniedReason(call({ status: "denied", reason: "不在白名单" }))).toEqual({
text: "本次没有开放",
text: "这次会话没开放这个能力",
approvable: false,
});
const t = text(
@@ -529,7 +529,7 @@ describe("实际结果行(FIX-9):只列系统核实的事实", () => {
const html = render(
createElement(ResultLine, { run: RUN, report: done, live: withText("提交了 2 个下载,整理了 1 个。") }),
);
expect(text(html).replace(/\s*、\s*/g, "、")).toContain("提交了 2 个下载、完成 1 次整理、1 个还在进行 · 41 秒");
expect(text(html).replace(/\s*、\s*/g, "、")).toContain("提交了 2 个下载、完成 1 次整理、1 个进行中 · 41 秒");
expect(html).toContain(`href="/console/organize/${ID(3)}"`);
expect(html).toContain('href="/console/downloads"');
expect(html).not.toContain("与汇报不一致");
+1 -1
View File
@@ -180,7 +180,7 @@ export function contextFromPath(pathname: string): AgentContext | null {
case "organize":
return { label: `整理记录 ${short}`, recordId: id, fromPath: true };
case "engine":
return { label: `引擎任务 ${short}`, taskId: id, fromPath: true };
return { label: `后台任务 ${short}`, taskId: id, fromPath: true };
default:
return null;
}
@@ -46,9 +46,9 @@ function budgetText(b: Record<string, number | undefined>) {
const parts: string[] = [];
if (b.maxToolCalls !== undefined) parts.push(`${b.maxToolCalls} 次调用`);
if (b.maxTokens !== undefined) parts.push(`${tokens(b.maxTokens)} token`);
if (b.maxDurationSec !== undefined) parts.push(`${b.maxDurationSec}s`);
if (b.maxDurationSec !== undefined) parts.push(`${b.maxDurationSec} 秒`);
if (b.maxCostUsd !== undefined) parts.push(`$${b.maxCostUsd}`);
return parts.length ? parts.join(" · ") : "沿用设置";
return parts.length ? parts.join(" · ") : "沿用默认";
}
function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<string, AgentRunNode[]>; depth: number }) {
@@ -119,7 +119,7 @@ export function SubagentTree({ snapshot, className }: { snapshot: RunSnapshot; c
</p>
<RunLink id={parent.id}>{shortGoalTitle(parent.goal)}</RunLink>
<p className="text-meta text-muted-foreground">
第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "}
第 {run.depth} 层子会话 · 权限不超过父会话 · 可用能力 {caps} 项 · 预算{" "}
{budgetText(run.budget as Record<string, number | undefined>)}
{run.spawnReason ? ` · 理由:${run.spawnReason}` : ""}
</p>
@@ -129,7 +129,7 @@ export function SubagentTree({ snapshot, className }: { snapshot: RunSnapshot; c
<div className="space-y-1">
<p className="flex flex-wrap items-center gap-1.5 text-meta text-muted-foreground">
<GitBranch aria-hidden className="size-3.5" />
子会话 {children.length} 个{familyUsage ? <FamilyUsage usage={familyUsage} /> : null}
{children.length} 个子会话{familyUsage ? <FamilyUsage usage={familyUsage} /> : null}
</p>
<ul className="space-y-0.5">
{direct.map((c) => (
@@ -143,5 +143,5 @@ export function SubagentTree({ snapshot, className }: { snapshot: RunSnapshot; c
}
function FamilyUsage({ usage }: { usage: AgentFamilyUsage }) {
return <span className="ml-auto tabular-nums">会话树合计 {usageLine(usage)}</span>;
return <span className="ml-auto tabular-nums">合计 {usageLine(usage)}</span>;
}
@@ -29,7 +29,7 @@ export function authDenial(call: Pick<ToolCallView, "status" | "outputSummary">)
/** 被服务端拒绝(白名单拦截或授权校验失败) */
export const isDenied = (call: ToolCallView) => call.status === "denied" || authDenial(call) !== null;
/** 拒绝的一行说明:需要批准 / 本次没有开放 / 次数用完 */
/** 拒绝的一行说明:需要批准 / 这次会话没开放 / 次数用完 */
export function deniedReason(call: ToolCallView): { text: string; approvable: boolean } {
const auth = authDenial(call);
if (auth) {
@@ -37,8 +37,8 @@ export function deniedReason(call: ToolCallView): { text: string; approvable: bo
if (/自动化总开关|只读对账/.test(auth)) return { text: "自动执行已关闭", approvable: false };
return { text: "需要你批准", approvable: true };
}
if (call.reason && /预算|次数/.test(call.reason)) return { text: "调用次数用完了", approvable: false };
return { text: "本次没有开放", approvable: false };
if (call.reason && /预算|次数/.test(call.reason)) return { text: "工具调用次数已用完", approvable: false };
return { text: "这次会话没开放这个能力", approvable: false };
}
// ── 进度行的人话计数 ──
+1 -1
View File
@@ -30,7 +30,7 @@ const RULES: Rule[] = [
},
{
match: /^\/console\/organize/,
goals: ["识别需要处理的整理记录里的作品", "这次整理为什么有文件失败", "把失败的文件重新整理一遍"],
goals: ["识别整理记录里没识别出的作品", "这次整理为什么有文件失败", "把失败的文件重新整理一遍"],
},
{
match: /^\/console\/files/,
@@ -66,7 +66,7 @@ function ToolCallIoSheet({ call, onClose }: { call: ToolCallView; onClose: () =>
<JsonView value={output.value ?? call.reason} maxHeight="18rem" tone={failed ? "destructive" : "neutral"} />
</DetailSection>
{input.truncated || output.truncated ? (
<p className="text-caption text-muted-foreground">事件流只保存 500 字符以内的摘要,超出部分已截断。</p>
<p className="text-caption text-muted-foreground">只保存前 500 个字符,其余已截断。</p>
) : null}
<DetailSection title="输入 schema">
{tools.isPending ? (
@@ -76,7 +76,7 @@ function ToolCallIoSheet({ call, onClose }: { call: ToolCallView; onClose: () =>
) : tool ? (
<JsonView value={tool.inputJsonSchema} maxHeight="20rem" />
) : (
<p className="text-caption text-muted-foreground">这个能力没有注册,没有 schema。</p>
<p className="text-caption text-muted-foreground">这个能力未注册,没有 schema。</p>
)}
</DetailSection>
{tool ? (
@@ -20,7 +20,7 @@ export type ApprovalCardStatus =
| "consumed";
const LABEL: Record<ApprovalCardStatus, string> = {
pending: "待批准",
pending: "等你批准",
submitting: "提交中",
approved: "已批准",
rejected: "已拒绝",
@@ -101,7 +101,7 @@ export function CodeBlock({
) : (
<Check aria-hidden className="size-3" />
)}
{streaming ? "输出中" : "完整"}
{streaming ? "输出中" : "已完成"}
</span>
{copyable ? (
<button
@@ -19,10 +19,10 @@ export interface TodoItem {
}
const LABEL: Record<TodoItemStatus, string> = {
pending: "待办",
pending: "未开始",
"in-progress": "进行中",
completed: "已完成",
cancelled: "被拒绝",
cancelled: "已取消",
error: "失败",
skipped: "已跳过",
};
@@ -26,7 +26,7 @@ export interface ToolApprovalParameter {
}
const COPY: Record<ToolApprovalStatus, string> = {
pending: "待授权",
pending: "等你批准",
approving: "提交中",
approved: "已批准",
denied: "已拒绝",
@@ -68,7 +68,7 @@ export interface ToolApprovalProps {
export function ToolApproval({
tool,
title = "允许执行这个工具?",
title = "允许调用这个工具?",
description,
parameters = [],
status = "pending",
+4 -4
View File
@@ -48,9 +48,9 @@ const WAITING_TEXT: Record<string, string> = {
captcha: "登录页需要验证码",
challenge: "页面需要人工验证",
two_factor: "需要二次验证或本人设备确认",
challenge_unsolved: "自动处理页面挑战未成功",
challenge_unsolved: "页面验证没有自动通过",
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
lease_expired: "接管超时未交还",
lease_expired: "接管超时,没有交还",
agent_request: "智能体请求人工处理",
human_released_waiting: "交还时选择继续等待",
};
@@ -80,9 +80,9 @@ export function credentialBadge(
case "injected":
return { tone: "success", text: `登录态:${src ?? "已注入"}`, title };
case "expired":
return { tone: "warning", text: "登录态已过期:在画面里登录一次即可全站生效", title };
return { tone: "warning", text: "登录态已过期:在画面里登录一次,全站生效", title };
case "none":
return { tone: "neutral", text: "账号没有登录态:在画面里登录一次即可全站生效", title };
return { tone: "neutral", text: "账号没有登录态:在画面里登录一次,全站生效", title };
case "unsupported":
return { tone: "warning", text: "浏览器服务不支持注入登录态", title };
default:
@@ -81,7 +81,7 @@ export function SiteOriginPanel({ site }: { site: SiteListItem }) {
if (!s || !canDesktop || mode === "screenshot") {
let note: string | null = null;
if (s && desktop && !desktop.available && desktop.reason) note = `桌面模式不可用:${desktop.reason}。`;
else if (s && desktop?.available && !secure) note = "桌面模式需要通过 HTTPS 访问控制台,当前使用截图模式。";
else if (s && desktop?.available && !secure) note = "桌面模式需要用 HTTPS 打开控制台,现在是截图模式。";
return (
<div className="space-y-3">
{note || (canDesktop && mode === "screenshot") ? (
@@ -109,7 +109,7 @@ export function ReloadNotice({ session }: { session: BrowserSessionView }) {
if (!at || dismissed === at) return null;
return (
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone="warning">浏览器重启过,页面已重新加载,请核对</StatusBadge>
<StatusBadge tone="warning">浏览器重启过,页面已重新加载,表单输入和滚动位置已丢失</StatusBadge>
<Button variant="ghost" size="xs" onClick={() => setDismissed(at)}>
知道了
</Button>
@@ -40,7 +40,7 @@ export function ResourceDownload({ session }: { session: BrowserSessionView }) {
} else if (result.submitted) {
setPlanId(result.plan?.id ?? null);
toast.success("已提交下载");
} else toast.warning(result.rejected.map((v) => v.message).join(";") || "没有提交下载");
} else toast.warning(result.rejected.map((v) => v.message).join(";") || "没有提交");
}
} catch (e) {
toast.error(errorMessage(e));
@@ -53,7 +53,7 @@ export function ResourceDownload({ session }: { session: BrowserSessionView }) {
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" disabled={busy || session.controller === "closed"} onClick={() => void prepare(false)}>
<Download />
{busy ? "正在处理…" : "下载当前资源"}
{busy ? "提交中…" : "下载当前资源"}
</Button>
<Button
size="sm"
@@ -88,7 +88,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
<EmptyState
icon={Globe}
title="没有启用的浏览器服务"
description="登记 camofox 地址与访问凭证后,这里显示站点的真实页面。"
description="在设置里登记 camofox 地址和凭证后,这里显示站点的原站页面。"
/>
);
if (!site.enabled) return <EmptyState icon={Globe} title="站点已停用" description="启用站点后才能打开原站页面。" />;
@@ -97,7 +97,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
<EmptyState
icon={Globe}
title="还没有打开这个站点的浏览器会话"
description="打开后显示站点首页;验证码等需本人操作在此完成。"
description="打开后显示站点首页。验证码等需要本人操作的步骤在这里完成。"
action={
<Button
size="sm"
@@ -152,7 +152,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
return (
<div className="space-y-3">
<p className="text-caption text-muted-foreground">验证码等需本人操作在此完成。</p>
<p className="text-caption text-muted-foreground">验证码等需要本人操作的步骤在这里完成。</p>
<ReloadNotice session={s} />
{site.kind === "subtitle" ? <SubtitleFromPage session={s} /> : <ResourceDownload session={s} />}
@@ -169,8 +169,8 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
<StatusBadge tone={cred.tone}>{cred.text}</StatusBadge>
</span>
) : null}
{unsupported ? <StatusBadge tone="destructive">该浏览器服务不支持画面与输入</StatusBadge> : null}
{unverified ? <StatusBadge tone="warning">画面与输入未对真实实例核验</StatusBadge> : null}
{unsupported ? <StatusBadge tone="destructive">这个浏览器服务不支持画面和输入</StatusBadge> : null}
{unverified ? <StatusBadge tone="warning">画面和输入没有在真实实例上验证过</StatusBadge> : null}
<div className="ml-auto flex flex-wrap items-center gap-2">
{human ? (
<Button
@@ -40,7 +40,7 @@ function ChangePasswordDialog({ open, onOpenChange }: { open: boolean; onOpenCha
setErrors({});
try {
const r = await change.mutateAsync({ currentPassword: form.currentPassword, newPassword: form.newPassword });
toast.success(r.revokedSessions ? `密码已修改,已注销其它 ${r.revokedSessions} 个会话` : "密码已修改");
toast.success(r.revokedSessions ? `密码已修改,其他 ${r.revokedSessions} 个会话已退出` : "密码已修改");
setForm({ currentPassword: "", newPassword: "", confirm: "" });
onOpenChange(false);
} catch (err) {
@@ -58,7 +58,7 @@ function ChangePasswordDialog({ open, onOpenChange }: { open: boolean; onOpenCha
<DialogContent>
<DialogHeader>
<DialogTitle>修改密码</DialogTitle>
<DialogDescription>修改后其它设备上的会话会被注销,当前会话保留。</DialogDescription>
<DialogDescription>其他设备上的会话会退出登录,当前会话保留。</DialogDescription>
</DialogHeader>
<form id="pwd-form" onSubmit={submit} className="grid gap-1" noValidate>
<FormField label="当前密码" htmlFor="pwd-cur" error={errors.currentPassword}>
+5 -5
View File
@@ -51,7 +51,7 @@ export const NAV_GROUPS: NavGroup[] = [
to: "/console",
label: "首页",
icon: House,
description: "在忙什么、要我处理什么、最近整理了什么",
description: "进行中的任务、等你批准的操作、最近的整理",
},
{
to: "/console/agent",
@@ -68,11 +68,11 @@ export const NAV_GROUPS: NavGroup[] = [
to: "/console/sites",
label: "站点",
icon: Globe,
description: "PT 站、公开 BT 站与 RSS 源:账号、资源与 RSS",
description: "PT 站、公开 BT 站和自定义 RSS 源",
},
{ to: "/console/discover", label: "发现", icon: Compass, description: "bgm.tv 每日放送与豆瓣热门" },
{ to: "/console/subscriptions", label: "订阅", icon: CalendarClock, description: "单次收录与持续追踪" },
{ to: "/console/rules", label: "规则", icon: ListChecks, description: "收录策略与偏好标签" },
{ to: "/console/rules", label: "规则", icon: ListChecks, description: "收录策略与规则组" },
],
},
{
@@ -106,7 +106,7 @@ export const NAV_GROUPS: NavGroup[] = [
to: "/console/logs",
label: "日志",
icon: ScrollText,
description: "任务、定时与自动任务、登录与操作留痕,可实时查看",
description: "任务、定时任务、登录与操作记录,可实时查看",
},
{
to: "/console/status",
@@ -131,7 +131,7 @@ const NAV_ALIASES: Record<string, string> = {
/** 不在侧栏但有页面的路由:顶栏标题用 */
const EXTRA_TITLES: Record<string, string> = {
"/console/preferences": "偏好标签",
"/console/preferences": "规则",
"/console/search": "搜索",
};
@@ -11,7 +11,7 @@ export function WorkspacePlaceholder({ milestone = "W3" }: { milestone?: string
return (
<>
<PageHeader title={item?.label ?? "未命名页面"} description={item?.description} />
<EmptyState icon={item?.icon ?? Construction} title="尚未接入" description={`计划在 ${milestone} 接入。`} />
<EmptyState icon={item?.icon ?? Construction} title="还没接入" description={`计划在 ${milestone} 接入。`} />
</>
);
}
@@ -171,7 +171,7 @@ function RuntimeOverview({ downloaders }: { downloaders?: Dashboard["sources"]["
</p>
</>
) : (
<EmptyState plain icon={Gauge} title="暂无运行状态" className="py-6" />
<EmptyState plain icon={Gauge} title="没有运行状态数据" className="py-6" />
)}
</OverviewCard>
);
@@ -192,18 +192,18 @@ function DownloaderOverview({ downloaders }: { downloaders?: Dashboard["sources"
downloaders ??
instances.map((d) => ({ id: d.instanceId, name: d.name, enabled: true, health: "unknown", lastSeenAt: null }));
return (
<OverviewCard title="下载器情况" icon={Download} to={consolePath("downloads")}>
<OverviewCard title="下载器" icon={Download} to={consolePath("downloads")}>
{q.isPending ? (
<LoadingState rows={2} />
) : q.isError ? (
<ErrorState plain title="下载器状态加载失败" onRetry={() => q.refetch()} className="py-6" />
) : !sources.length ? (
<EmptyState plain icon={Download} title={downloaders ? "尚未配置下载器" : "暂无启用下载器"} className="py-6" />
<EmptyState plain icon={Download} title={downloaders ? "还没有下载器" : "没有启用的下载器"} className="py-6" />
) : (
<>
<p className="mb-3 text-title-sm font-semibold tabular-nums">
{board?.counts.active}
<span className="ml-1.5 text-caption font-normal text-muted-foreground">项进行中</span>
<span className="ml-1.5 text-caption font-normal text-muted-foreground">个下载进行中</span>
</p>
<ul className="divide-y divide-border/60">
{sources.map((d) => {
@@ -245,10 +245,10 @@ function DownloaderOverview({ downloaders }: { downloaders?: Dashboard["sources"
})}
</ul>
<p className="mt-3 text-meta text-muted-foreground">
进行中含等待计划、暂停和异常任务 · 速度含下载器全部任务
进行中包括还没提交、已暂停和出错的下载 · 速度是下载器里全部任务的合计
</p>
{board?.liveAt ? (
<p className="mt-1 text-meta text-muted-foreground">速度采样于 {formatRelative(board.liveAt)}</p>
<p className="mt-1 text-meta text-muted-foreground">速度更新于 {formatRelative(board.liveAt)}</p>
) : null}
</>
)}
@@ -271,7 +271,7 @@ function ExploreResults({ s }: { s: ExploreState }) {
if (!items.length)
return (
<EmptyState
title={s.list === "search" ? "没有搜到作品" : "没有条目"}
title={s.list === "search" ? "没有搜到作品" : "没有作品"}
description={s.list === "search" ? "换个关键词,或换个来源再搜。" : "换个列表或放宽筛选条件。"}
/>
);
@@ -83,7 +83,7 @@ function Section({ section, actions }: { section: RecommendSection; actions: Ret
</div>
) : q.isSuccess && items.length === 0 ? (
<p className="rounded-xl border border-dashed border-border px-4 py-6 text-caption text-muted-foreground">
这里暂时没有条目。
这一栏暂时没有作品。
</p>
) : (
<ul className="scroll-bleed flex gap-3 overflow-x-auto">
@@ -24,15 +24,15 @@ import { formatDateTime } from "~/lib/utils.ts";
const TaskPage = cursorPage(Task);
export const TASK_KIND_LABEL: Record<TaskKind, string> = {
ingestFeed: "抓取 Feed",
ingestFeed: "抓取 RSS",
resolveObservation: "识别资源",
evaluateAcquisition: "评估候选",
evaluateAcquisition: "评估资源",
submitDownload: "提交下载",
verifyAndImport: "核验整理",
refreshMetadata: "刷新元数据",
reconcileLibrary: "文件核对",
cleanupDownloadCache: "清理缓存",
resolveDecision: "处理待处理事项",
resolveDecision: "处理事项",
agentRun: "智能体运行",
};
@@ -40,13 +40,13 @@ export const TASK_STATUS: Record<
TaskStatus,
{ label: string; tone: "success" | "warning" | "destructive" | "neutral" }
> = {
pending: { label: "排队", tone: "neutral" },
pending: { label: "排队中", tone: "neutral" },
running: { label: "运行中", tone: "neutral" },
waiting: { label: "等待", tone: "warning" },
succeeded: { label: "成功", tone: "success" },
failed: { label: "失败", tone: "destructive" },
cancelled: { label: "已取消", tone: "neutral" },
dead: { label: "放弃", tone: "destructive" },
dead: { label: "已放弃", tone: "destructive" },
};
export const ACTIVE_TASK: TaskStatus[] = ["pending", "running", "waiting"];
@@ -245,8 +245,8 @@ export function BusinessTasks() {
loadingMore={list.isFetchingNextPage}
empty={{
icon: ListTodo,
title: f.active ? "没有符合筛选的任务" : "还没有业务任务",
description: f.active ? undefined : "提交下载、核验整理、清理缓存、对账等后台任务会出现在这里。",
title: f.active ? "没有符合筛选的任务" : "还没有后台任务",
description: f.active ? undefined : "提交下载、核验整理、清理缓存、文件核对等任务会出现在这里。",
}}
/>
<DetailSheet
@@ -259,7 +259,7 @@ export function BusinessTasks() {
<>
<TaskActions t={open} />
<Button asChild size="sm">
<Link to={consolePath("tasks", open.id)}>在独立页面打开</Link>
<Link to={consolePath("tasks", open.id)}>打开详情页</Link>
</Button>
</>
) : null
@@ -36,7 +36,7 @@ export function BusinessTimeline({ events, loading }: { events: TimelineEvent[];
})}
</div>
{ongoing.length ? (
<p className="text-caption text-muted-foreground">当前:{ongoing.map((e) => e.title).join(";")}</p>
<p className="text-caption text-muted-foreground">当前:{ongoing.map((e) => e.title).join(";")}</p>
) : null}
{loading && !events.length ? (
<LoadingState rows={3} />
@@ -59,9 +59,7 @@ export function BusinessTimeline({ events, loading }: { events: TimelineEvent[];
}))}
/>
)}
{loading && events.length ? (
<p className="text-meta text-muted-foreground">正在加载关联的整理记录与引擎运行…</p>
) : null}
{loading && events.length ? <p className="text-meta text-muted-foreground">加载整理记录和引擎运行…</p> : null}
</div>
);
}
@@ -81,9 +81,9 @@ function VolumeBar({ v }: { v: Volume }) {
/>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{pct === null ? <StatusBadge tone="warning">读不到磁盘状态,已暂停新提交</StatusBadge> : null}
{tripped ? <StatusBadge tone="destructive">超过暂停线,停止新下载</StatusBadge> : null}
{tripped ? <StatusBadge tone="destructive">超过暂停线,已停止新下载</StatusBadge> : null}
{v.pausedByThreshold ? (
<StatusBadge tone="warning">因下载盘暂停 {v.pausedByThreshold} 个任务</StatusBadge>
<StatusBadge tone="warning">超过暂停线,暂停了 {v.pausedByThreshold} 个任务</StatusBadge>
) : null}
</div>
{v.notes.map((n) => (
@@ -119,7 +119,7 @@ export function CacheVolumes() {
</Button>
}
title="按规则清理下载缓存?"
description="只清理已整理、且满足保留期与 H&R 的本工具任务,其余不动。"
description="只清理本工具管理、已整理且满足保留期和 H&R 要求的任务,其余不动。"
confirmLabel="清理"
onConfirm={() =>
cleanup.mutateAsync(undefined).then(
@@ -138,7 +138,7 @@ export function CacheVolumes() {
) : vols.error ? (
<ErrorState plain error={vols.error} onRetry={() => void vols.refetch()} />
) : !vols.data.length ? (
<p className="text-caption text-muted-foreground">还没有缓存卷数据。登记下载器并完成巡检后显示。</p>
<p className="text-caption text-muted-foreground">还没有下载盘。添加下载器后显示。</p>
) : (
<div className="grid gap-5 md:grid-cols-2">
{vols.data.map((v) => (
@@ -333,11 +333,11 @@ export const BOARD_EMPTY: Record<DownloadBoardState, { title: string; descriptio
active: {
icon: Download,
title: "没有进行中的下载",
description: "自动收录或在站点页下载后出现在这里,等批准、等磁盘空间的也在这里;下载完成的在「已完成」。",
description: "自动收录和手动下载的任务都在这里,包括等批准、等磁盘空间的。下载完成后移到「已完成」。",
},
completed: {
icon: CheckCircle2,
title: "还没有下载完成的任务",
description: "下载完成(含做种)后出现在这里。",
description: "下载完成的任务在这里,包括做种中的。",
},
};
@@ -49,7 +49,7 @@ export function DownloadStatusBadges({ t }: { t: DownloadTask }) {
<span className="flex flex-wrap gap-1">
<StatusBadge tone={DOWNLOAD_STATUS_TONE[t.status]}>{DOWNLOAD_STATUS_LABEL[t.status]}</StatusBadge>
{t.pauseReason === "threshold" ? (
<StatusBadge tone="warning">下载盘满,已暂停</StatusBadge>
<StatusBadge tone="warning">超过暂停线,已暂停</StatusBadge>
) : t.pauseReason ? (
<StatusBadge>{PAUSE_REASON_LABEL[t.pauseReason]}</StatusBadge>
) : null}
@@ -151,7 +151,8 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
) : null}
{task.noProgressSince ? (
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
自 {formatDateTime(task.noProgressSince)} 起无进展,不会自动换源;开了自动调查时智能体会在授权内尝试恢复。
{formatDateTime(task.noProgressSince)}{" "}
起没有进展,不会自动换源。开启自动调查时,智能体会在持续授权范围内尝试恢复。
</p>
) : null}
@@ -85,7 +85,7 @@ describe("保存目录", () => {
expect(custom).not.toHaveProperty("downloaderInstanceId");
});
it("界面:并排两个下拉,下方写明将保存到哪;自定义时展开输入框", () => {
it("界面:并排两个下拉,下方写明保存到哪;自定义时展开输入框", () => {
const props = {
formId: "f",
instances: paths.instances,
@@ -101,10 +101,10 @@ describe("保存目录", () => {
const html = render(createElement(SaveTargetFields, props));
expect(html).toContain("下载器");
expect(html).toContain("自动:qb-main");
expect(html).toContain("将保存到:/data/downloads(本机 /mnt/dl)");
expect(html).toContain("保存到:/data/downloads(本机 /mnt/dl)");
expect(html).not.toContain("自定义路径</label>");
const custom = render(createElement(SaveTargetFields, { ...props, dir: CUSTOM_DIR, custom: "/x" }));
expect(custom).toContain("将保存到:/x");
expect(custom).toContain("保存到:/x");
expect(custom).toContain('id="f-custom"');
const none = render(
createElement(SaveTargetFields, { ...props, instances: [], autoInstanceId: null, target: null }),
@@ -136,7 +136,7 @@ describe("作品身份", () => {
expect(html).toContain("未识别");
expect(html).toContain("TMDB 编号");
expect(html).toContain('id="f-tmdb"');
expect(html).toContain("暂未识别到作品");
expect(html).toContain("没识别出作品");
});
it("已识别:折叠只显示当前作品", () => {
@@ -282,7 +282,7 @@ export function ManualDownloadDialog({
}}
>
<Bot />
委托智能体
交给智能体
</Button>
) : null}
<Button
@@ -329,7 +329,7 @@ export function ResourceSummary({ resource: r }: { resource: ManualDownloadResou
{r.leechers ?? "—"}
</span>
) : null}
{r.publishedAt ? <span title={absTime(r.publishedAt)}>发布 {relTime(r.publishedAt)}</span> : null}
{r.publishedAt ? <span title={absTime(r.publishedAt)}>发布于 {relTime(r.publishedAt)}</span> : null}
</div>
</div>
);
@@ -372,7 +372,7 @@ export function SaveTargetFields({
: !target && dir !== CUSTOM_DIR
? "没有可用的下载器,先在设置里添加下载器和保存目录"
: resolved
? `将保存到:${savePathText(resolved)}`
? `保存到:${savePathText(resolved)}`
: "填写下载器能访问的路径";
return (
<div className="grid items-start gap-x-3 sm:grid-cols-2">
@@ -504,7 +504,7 @@ export function IdentitySection({
<div id={`${formId}-identity`} className="space-y-3 border-t border-border px-3 pt-3 pb-1">
{unrecognized && !changed ? (
<p className="text-caption text-warning">
暂未识别到作品。指定后绑定到这个资源;不指定也能下载,整理时再补全识别和元数据。
没识别出作品。指定后绑定到这个资源;不指定也能下载,整理时再识别。
</p>
) : null}
{picked ? (
@@ -533,7 +533,7 @@ export function IdentitySection({
<FormField
label="TMDB 编号"
htmlFor={`${formId}-tmdb`}
error={tmdbId && !valid ? "TMDB 编号是数字" : preview.error ? errorMessage(preview.error) : null}
error={tmdbId && !valid ? "TMDB 编号只能是数字" : preview.error ? errorMessage(preview.error) : null}
hint={
preview.data
? `${preview.data.title}${preview.data.year ? ` (${preview.data.year})` : ""}${preview.data.localEntityId ? " · 在媒体库" : ""}`
@@ -602,8 +602,8 @@ export function ExistingVersionsSection({
selected: string[];
onSelectedChange: (ids: string[]) => void;
}) {
if (loading) return <p className="text-caption text-muted-foreground">正在读取已有版本…</p>;
if (error) return <p className="text-caption text-destructive">已有版本读取失败,重试后再选洗版</p>;
if (loading) return <p className="text-caption text-muted-foreground">读取已有版本…</p>;
if (error) return <p className="text-caption text-destructive">读取已有版本失败,重新打开后再选洗版</p>;
if (!versions.length) return null;
return (
<div className="min-w-0 space-y-2">
@@ -640,7 +640,7 @@ export function ExistingVersionsSection({
))}
</fieldset>
<p className="text-meta text-muted-foreground">
本次新文件整理成功后替换所选旧文件。剧集只替换成功对应的季集,其余保留。
新文件整理成功后替换所选旧文件。剧集只替换整理成功的季集,其余保留。
</p>
</>
) : null}
@@ -213,7 +213,7 @@ function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) {
.then(
() => toast.success(withRule ? "已忽略,并建了忽略规则" : "已忽略"),
(err) => {
toast.error(errorMessage(err, "操作失败"));
toast.error(errorMessage(err, "忽略失败"));
throw err;
},
)
@@ -269,12 +269,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
trigger={
<Button size="sm">
<Bot />
确认智能体建议
采用智能体建议
</Button>
}
title="按智能体建议的规则处理?"
title="按智能体建议建规则?"
description={`${view.confirmAgent.description ?? ""}。规则在作品页的集数映射里可以再改。`}
confirmLabel="确认"
confirmLabel="建规则并重算"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, optionKey: view.confirmAgent!.key }).then(
() => toast.success("已建规则并重算"),
@@ -291,12 +291,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
trigger={
<Button size="sm" variant={view.confirmAgent && view.agentSuggestion ? "outline" : "default"}>
<Check />
确认
采用建议规则
</Button>
}
title="按建议规则处理?"
title="按建议建规则?"
description={`${view.confirm.description ?? ""}。规则在作品页的集数映射里可以再改。`}
confirmLabel="确认"
confirmLabel="建规则并重算"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, optionKey: view.confirm!.key }).then(
() => toast.success("已建规则并重算"),
@@ -32,7 +32,7 @@ export function SubscriptionMappingSummary({
return (
<Panel
title="集数映射"
description={`${season != null ? `第 ${season} 季` : "全部季"}最近资源按发布组的识别方式`}
description={`按发布组列出${season != null ? `第 ${season} 季` : "全部季"}最近资源的集数识别方式`}
actions={
<Link
to={mappingHref(consolePath("library", entityId))}
@@ -101,14 +101,12 @@ export function IdentityDialog({
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>修改外部身份</DialogTitle>
<DialogDescription>
同类型的旧 TMDB 身份会退役,保留修订记录。加人工锁后,自动匹配和刷新不再改动。
</DialogDescription>
<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>
<p className="text-caption font-medium text-foreground">已记录的身份</p>
<ul className="grid gap-1.5">
{usable.map((c) => (
<li key={c.id}>
@@ -227,7 +225,7 @@ export function FieldLockDialog({
if (field === "year") {
const n = Number(r.data.value);
if (!Number.isInteger(n) || n < 1870 || n > 2200) {
setErrors({ value: "年份须是 1870–2200 的整数" });
setErrors({ value: "年份填 1870–2200 之间的整数" });
return;
}
v = n;
@@ -63,5 +63,5 @@ export function originText(
/** 切换确认的说明:改的是作品设置,影响季表、集数映射、订阅与刮削 */
export function switchDescription(target: string): string {
return `作品改按「${target}」分季:季与集按 TMDB 原季集迁到新位置,已整理文件跟着走;集数映射、订阅和刮削都按新季集,已绑定的资源会重新换算。之后可以再切回来。`;
return `改按「${target}」分季后,已整理文件随季集一起调整;集数映射、订阅和刮削都按新季集,已绑定的资源会重新换算。之后可以再切回来。`;
}
@@ -149,8 +149,7 @@ export function EnrichSection({ item }: { item: IdentityPoolItem }) {
>
{!out ? (
<p className="text-caption text-muted-foreground">
TMDB
查不到时,按标题搜索网页:读平台和百科网站,其它网站只用搜索摘要,抽取字段并附出处和置信度。先看建议,确认后才建作品。
TMDB 查不到时,按标题搜索网页,从平台和百科页面抽取字段,附出处和置信度。确认后才建作品。
</p>
) : (
<div className="space-y-3">
@@ -160,12 +160,12 @@ function ManualAssign({ item }: { item: IdentityPoolItem }) {
await dismiss.mutateAsync({ id: item.id });
toast.success("已忽略");
} catch (err) {
toast.error(errorMessage(err, "操作失败"));
toast.error(errorMessage(err, "忽略失败"));
}
}}
>
<BellOff />
忽略此资源
忽略这个资源
</Button>
</DetailSection>
);
@@ -229,7 +229,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
<EnrichSection key={item.id} item={item} />
) : null}
<DetailSection title={`候选(${item.candidates.length})`}>
<DetailSection title={`候选作品(${item.candidates.length})`}>
{item.candidates.length ? (
<ul className="space-y-2">
{item.candidates.map((c, i) => (
@@ -245,7 +245,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
<p className="text-caption text-muted-foreground">
{item.reason === "unmatched"
? "没有命中订阅或本地作品,未查询 TMDB。点「重新识别」会查一次 TMDB;新增相近别名时自动重试。"
: "没有候选。有新证据时自动重试。"}
: "没有候选作品。有新证据时自动重试。"}
</p>
)}
</DetailSection>
@@ -73,7 +73,7 @@ export function PoolTab() {
},
{
key: "cands",
header: "候选",
header: "候选作品",
hideBelow: "sm",
align: "right",
cell: (r) => <span className="tabular-nums">{r.candidates.length}</span>,
@@ -139,7 +139,7 @@ export function PoolTab() {
empty={{
icon: SearchX,
title: filtered ? "没有符合条件的未识别资源" : "没有未识别资源",
description: "未命中订阅、证据不足或冲突的资源会出现在这里;新增相近别名时自动重试,也可手动指定。",
description: "未命中订阅、证据不足或有冲突的资源。新增相近别名时会自动重试,也可以手动指定。",
}}
hasMore={pool.hasNextPage}
onLoadMore={() => pool.fetchNextPage()}
+17 -17
View File
@@ -43,12 +43,12 @@ export const ENTITY_STATUS: Record<MediaEntityStatus, ToneLabel> = {
/** Slot 状态(REQ §6.1):每个状态有明确文本,不只靠颜色 */
export const SLOT_STATUS: Record<SlotStatus, ToneLabel> = {
empty: { label: "空缺", tone: "neutral" },
reserved: { 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" },
attention: { label: "需要处理", tone: "destructive" },
};
export const SLOT_STATUS_ORDER: SlotStatus[] = [
"attention",
@@ -64,7 +64,7 @@ export const IDENTITY_STATUS: Record<IdentityStatus, ToneLabel> = {
accepted: { label: "已确认", tone: "success" },
proposed: { label: "待确认", tone: "warning" },
conflicted: { label: "冲突", tone: "destructive" },
retired: { label: "已退役", tone: "neutral" },
retired: { label: "已停用", tone: "neutral" },
};
export const EVIDENCE_LEVEL: Record<string, string> = {
@@ -100,7 +100,7 @@ export const LOCATION_KIND: Record<string, string> = {
};
export const DOWNLOAD_STATUS: Record<DownloadStatus, ToneLabel> = {
queued: { label: "排队", tone: "neutral" },
queued: { label: "排队中", tone: "neutral" },
submitting: { label: "提交中", tone: "neutral" },
downloading: { label: "下载中", tone: "neutral" },
paused: { label: "已暂停", tone: "warning" },
@@ -110,15 +110,15 @@ export const DOWNLOAD_STATUS: Record<DownloadStatus, ToneLabel> = {
imported: { label: "已整理", tone: "success" },
failed: { label: "失败", tone: "destructive" },
removed: { label: "已移除", tone: "neutral" },
missing: { label: "下载器中缺失", tone: "warning" },
missing: { label: "下载器里没有", tone: "warning" },
};
export const ORG_STATUS: Record<OrganizationStatus, ToneLabel> = {
queued: { label: "排队", tone: "neutral" },
queued: { label: "排队中", tone: "neutral" },
running: { label: "整理中", tone: "neutral" },
succeeded: { label: "核验通过", tone: "success" },
partial: { label: "部分成功", tone: "warning" },
failed: { label: "失败", tone: "destructive" },
succeeded: { label: "已整理", tone: "success" },
partial: { label: "部分整理", tone: "warning" },
failed: { label: "整理失败", tone: "destructive" },
cancelled: { label: "已取消", tone: "neutral" },
};
@@ -127,8 +127,8 @@ export const CANDIDATE_DECISION: Record<string, ToneLabel> = {
upgrade: { label: "可升级", tone: "success" },
reject: { label: "拒绝", tone: "destructive" },
unknown_field: { label: "字段未知", tone: "warning" },
skipped: { label: "跳过", tone: "neutral" },
deferred: { label: "延后:已有计划在途", tone: "warning" },
skipped: { label: "已跳过", tone: "neutral" },
deferred: { label: "延后:已有下载在进行", tone: "warning" },
};
export const DECISION_KIND: Record<DecisionKind, string> = {
@@ -141,7 +141,7 @@ export const DECISION_KIND: Record<DecisionKind, string> = {
space_insufficient: "空间不足",
approval_required: "需要审批",
metadata_conflict: "元数据冲突",
source_unreachable: "来源不可达",
source_unreachable: "来源连不上",
downloader_offline: "下载器连不上",
cache_volume_unknown: "下载目录读不到容量",
other: "其他",
@@ -163,7 +163,7 @@ export const SEVERITY: Record<Severity, ToneLabel> = {
};
export const POOL_REASON: Record<IdentityCandidateReason, ToneLabel> = {
ambiguous: { label: "多个候选", tone: "warning" },
ambiguous: { label: "匹配到多部作品", tone: "warning" },
no_match: { label: "没有匹配", tone: "neutral" },
proposed: { label: "待确认", tone: "warning" },
lookup_failed: { label: "查询失败", tone: "destructive" },
@@ -207,7 +207,7 @@ export const POLICY_STATUS: Record<PolicyStatus, ToneLabel> = {
export const SUB_MODE: Record<SubscriptionMode, string> = { once: "单次收录", continuous: "持续追踪" };
export const SEASON_SCOPE: Record<SeasonScope, string> = {
current_season: "仅本季",
following_seasons: "接续后续季",
following_seasons: "包括后续季",
};
export const PRIORITY: Record<AcquisitionPriority, string> = {
immediate: "立即",
@@ -336,9 +336,9 @@ export const opts = <K extends string>(rec: Record<K, string | { label: string }
/** 收录版本 / 候选的原因串(如 `blocked:no_main_video`、`deferred:slot_busy;inflight:<id>`)→ 中文;不认识的原样显示 */
const SLOT_REASON: Record<string, string> = {
"blocked:no_main_video": "没有可整理的主视频",
"deferred:slot_busy": "已有计划在途,等它结束再评估",
verified: "已核验整理",
verified_partial: "部分核验整理",
"deferred:slot_busy": "已有下载在进行,结束后再评估",
verified: "已整理",
verified_partial: "部分整理",
hard_conditions_met: "硬条件全部满足",
};
export function slotReasonText(raw: string): string {
@@ -108,8 +108,8 @@ export function PreferenceFormDialog({
<DialogTitle>{tag ? "编辑偏好" : "添加偏好"}</DialogTitle>
<DialogDescription>
{tag
? "修改作用范围或规则会留下旧语义的抑制记录,智能体不会按旧证据加回。"
: "手动添加等同对话确认。之前取消过的偏好,重新添加会解除抑制。"}
? "改了作用范围或规则后,智能体不会再按旧证据把它加回来。"
: "手动添加和在对话里确认效果相同。之前取消过的偏好,重新添加后恢复生效。"}
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-2" noValidate>
@@ -26,7 +26,7 @@ export const SCRAPE_STATUS: Record<ScrapeRecord["status"], { label: string; tone
const ACTION: Record<ScrapeFileResult["action"], { label: string; tone: StatusTone }> = {
written: { label: "已写入", tone: "success" },
skipped: { label: "跳过", tone: "neutral" },
skipped: { label: "已跳过", tone: "neutral" },
failed: { label: "失败", tone: "destructive" },
};
@@ -144,7 +144,7 @@ export function ScrapePanel({ detail }: { detail: LibraryDetail }) {
scrape.mutateAsync(mode ? { mode } : {}).then(
(r) => toast.success(r.deduplicated ? "已在排队" : "已提交刮削"),
(err) => {
toast.error(errorMessage(err, "刮削没有提交"));
toast.error(errorMessage(err, "刮削提交失败"));
throw err;
},
);
@@ -78,7 +78,7 @@ export function SlotCard({
</dl>
{latest.length ? (
<div className="mt-3 border-t border-border/60 pt-3">
<p className="mb-2 text-meta text-muted-foreground">最近候选及选用理由(每个资源最近一次评估)</p>
<p className="mb-2 text-meta text-muted-foreground">最近评估的资源和理由(每个资源只列最近一次)</p>
<ul className="space-y-2">
{latest.map((c) => (
<li key={c.sourceReleaseId} className="min-w-0">
@@ -53,7 +53,7 @@ const KIND_OPTS: { value: string; label: string }[] = SubscriptionMediaKind.opti
}));
export const PUBLISH_NOTICE =
"发布后,作用范围内的下载、追更和整理按此策略自动执行,不再逐次审批。升级替换、媒体清理和下载缓存清理仍需单独审批。旧的已发布版本被替代,授权随之失效。";
"发布后,作用范围内的下载、追更和整理按这个策略自动执行,不再逐次审批;升级替换、媒体清理和下载缓存清理仍要单独审批。上一个已发布版本的授权随之失效。";
type TemplateDraft = { key: string; name: string; rules: SlotRulesDraft; open: boolean };
type PolicyDraft = {
@@ -164,7 +164,7 @@ function buildPolicy(d: PolicyDraft):
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}`;
errors.rules = `规则格式有误:${i?.path.join(".")} ${i?.message}`;
}
if (Object.keys(errors).length || !meta.success) return { ok: false, errors };
return {
@@ -307,8 +307,7 @@ function PolicyGroup({
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => onDraftFrom(head)}>
<FilePlus2 />
基于 r{head.revision} 新建草案
<FilePlus2 />从 r{head.revision} 新建草案
</Button>
)
}
@@ -449,7 +448,7 @@ function PolicyDetailSheet({
>
{!pv?.success ? (
<p className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-warning">
这个草案还没有预览。先预览可以看到影响范围。
还没有预览。预览后能看到影响范围。
</p>
) : null}
</ConfirmDialog>
@@ -458,14 +457,14 @@ function PolicyDetailSheet({
<>
<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}?`}
@@ -537,9 +536,7 @@ const scopeText = (scope: JsonObject) => {
function PreviewBlock({ value }: { value: JsonObject | null }) {
if (!value)
return (
<p className="text-caption text-muted-foreground">
还没有预览。预览用最近已识别的资源评估模板,不会执行任何操作。
</p>
<p className="text-caption text-muted-foreground">还没有预览。预览用最近识别的资源试算模板,不会下载或整理。</p>
);
const p = PolicyPreview.safeParse(value);
if (!p.success) return <JsonView value={value} />;
@@ -565,7 +562,7 @@ function PreviewBlock({ value }: { value: JsonObject | null }) {
))}
</div>
<p className="text-meta text-muted-foreground">
计算于 {formatDateTime(v.evaluatedAt)};接受/排除按"资源 × 模板"计数。
计算于 {formatDateTime(v.evaluatedAt)};接受 / 排除按「资源 × 模板」计数。
</p>
{v.samples.length ? (
<ul className="divide-y divide-border/60 rounded-lg border border-border/70">
@@ -586,7 +583,7 @@ function PreviewBlock({ value }: { value: JsonObject | null }) {
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">没有可用于回放的已识别资源。</p>
<p className="text-caption text-muted-foreground">没有可回放的已识别资源。</p>
)}
</div>
);
@@ -659,7 +656,7 @@ function PolicyEditorSheet({
open
onOpenChange={(o) => !o && !pending && onClose()}
width="lg"
title={editId ? `编辑草案 r${base?.revision}` : base ? `基于 r${base.revision} 新建草案` : "新建收录策略草案"}
title={editId ? `编辑草案 r${base?.revision}` : base ? `从 r${base.revision} 新建草案` : "新建收录策略草案"}
description="保存为草案,发布后才生效。"
footer={
<>
@@ -66,7 +66,7 @@ export function SlotsPanel({
plain
icon={Target}
title="还没有收录版本"
description="没有时按已发布收录策略的默认模板创建,也可以手动新建。"
description="订阅时按已发布收录策略的默认模板自动创建,也可以手动新建。"
className="py-8"
/>
) : (
@@ -80,7 +80,7 @@ export function SlotsPanel({
<span>
{[s.currentAsset.resolution, s.currentAsset.sourceType, s.currentAsset.releaseGroup]
.filter(Boolean)
.join(" · ") || "交付特征未知"}
.join(" · ") || "规格未知"}
{s.currentAsset.sizeBytes ? ` · ${formatBytes(s.currentAsset.sizeBytes, 1)}` : ""}
{s.currentAsset.status !== "active" ? ` · ${s.currentAsset.status}` : ""}
</span>
@@ -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 = {
@@ -180,7 +180,9 @@ function SlotEditorSheet({
width="lg"
title={slot ? `编辑收录版本 ${slot.name}` : "新建收录版本"}
description={
slot ? `当前 r${slot.revision}。保存时若已被修改,会提示刷新。` : "不满足硬条件的资源不收;软偏好决定先收哪个。"
slot
? `当前 r${slot.revision}。保存时如果已被别处修改,会提示刷新。`
: "不满足硬条件的资源不收;软偏好决定先收哪个。"
}
footer={
<>
@@ -195,7 +197,7 @@ function SlotEditorSheet({
>
<div className="grid gap-3 sm:grid-cols-2">
<FormField
label="目标名称"
label="名称"
htmlFor={`${id}-name`}
required
error={errors.name}
@@ -213,7 +215,7 @@ function SlotEditorSheet({
label="组合分组"
htmlFor={`${id}-grp`}
error={errors.distinctGroup}
hint="同组目标可要求由不同文件满足"
hint="同组的收录版本可以要求由不同文件满足"
>
<Input
id={`${id}-grp`}
@@ -226,7 +228,7 @@ function SlotEditorSheet({
<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>
<p className="text-meta text-muted-foreground">同一个文件不能同时满足同组的两个收录版本</p>
</div>
<Switch checked={requireDistinct} onCheckedChange={setRequireDistinct} aria-label="要求独立文件" />
</div>
+6 -6
View File
@@ -70,10 +70,10 @@ const ACTION_LABEL: Record<string, string> = {
"pi_upstream.oauth_start": "开始订阅登录",
"pi_upstream.oauth_login": "完成订阅登录",
"pi_upstream.oauth_logout": "退出订阅登录",
"schedule.run_now": "立即执行定时任务",
"schedule.run_now": "立即运行定时任务",
"schedule.skip": "跳过定时任务",
"schedule.update": "修改定时任务",
"storage.cleanup": "立即清理存储",
"storage.cleanup": "清理存储",
"acquisition_plan.manual_submit": "手动提交下载",
"acquisition_plan.cancel": "取消下载",
"capability.invoke": "智能体操作",
@@ -81,7 +81,7 @@ const ACTION_LABEL: Record<string, string> = {
"site.created": "登记站点",
"site.updated": "修改站点",
"feed_source.created": "新建 RSS",
"identity.release_assigned": "指定资源身份",
"identity.release_assigned": "为资源指定作品",
"files.mkdir": "新建目录",
"files.rename": "重命名",
"files.move": "移动文件",
@@ -149,13 +149,13 @@ const SCHEDULE_STATUS: Record<string, { label: string; tone: StatusTone }> = {
running: { label: "进行中", tone: "neutral" },
};
const AGENT_STATUS: Record<string, { label: string; tone: StatusTone }> = {
queued: { label: "排队", tone: "neutral" },
waiting: { label: "等待", tone: "neutral" },
queued: { label: "排队中", tone: "neutral" },
waiting: { label: "等待中", tone: "neutral" },
cancelled: { label: "已停止", tone: "warning" },
budget_exceeded: { label: "超出预算", tone: "warning" },
};
const ENGINE_STATUS: Record<string, { label: string; tone: StatusTone }> = {
pending: { label: "排队", tone: "neutral" },
pending: { label: "排队中", tone: "neutral" },
canceled: { label: "已取消", tone: "warning" },
};
+2 -2
View File
@@ -115,7 +115,7 @@ function ScheduleRunBody({ e }: { e: LogEntry }) {
{ to: logsPath({ tab: "schedules", job: jobId }), label: "定时任务设置" },
{
to: objectLogsPath("scheduled_job", jobId),
label: "这个定时任务的全部运行",
label: "这个定时任务的全部运行记录",
icon: <History className="h-3.5 w-3.5" />,
},
]
@@ -240,7 +240,7 @@ function RunSheet({
<Button asChild variant="ghost" size="sm">
<Link to={`/console/tasks/${encodeURIComponent(r.id)}`}>
<ExternalLink />
独立页面
打开任务页
</Link>
</Button>
{entry ? <AskAgentButton entries={[entry]} /> : null}
@@ -0,0 +1,120 @@
import { createElement, Fragment } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { ClampText, type MediaFacts, MediaHero } from "./media-detail.tsx";
// 详情页头图(方案 A):背景图渐隐到页面底色、无背景图退化为普通头部;状态徽标并进事实行;
// 操作按钮并进头图区且只有一个实心主操作;简介折叠只留一个「展开」入口。
const facts = (over: Partial<MediaFacts> = {}): MediaFacts => ({
title: "人生切割术",
originalTitle: "Severance",
year: 2022,
kind: "series",
kindLabel: "剧集",
genres: ["剧情"],
voteAverage: 8.4,
voteCount: 1200,
airStatus: "Returning Series",
runtime: 55,
seasonCount: 2,
episodeCount: 19,
releaseDate: "2022-02-17",
lastAirDate: "2025-03-20",
platform: "Apple TV",
originCountry: ["US"],
overview: "故事发生在一家名为卢蒙的超级大公司内。",
tagline: null,
posterPath: "/sev-poster.jpg",
backdropPath: "/severance-bg.jpg",
externalLinks: [{ label: "TMDB", href: "https://www.themoviedb.org/tv/95396" }],
...over,
});
const render = (props: Parameters<typeof MediaHero>[0]) => renderToStaticMarkup(createElement(MediaHero, props));
/** 取 data-slot 标记的元素起始处之后的一段 HTML(足够断言其中的内容) */
const slotHtml = (html: string, slot: string) => {
const i = html.indexOf(`data-slot="${slot}"`);
return i < 0 ? null : html.slice(i, html.indexOf("</div>", i) + 6);
};
describe("MediaHero 头图", () => {
it("有背景图:剧照层经图片代理、带渐隐材质,不再是深色圆角卡片;文字用常规前景色", () => {
const html = render({ facts: facts() });
expect(html).toContain('data-backdrop="image"');
expect(html).toContain("media-hero-backdrop");
expect(html).toContain('src="/api/v1/img/tmdb/w1280/severance-bg.jpg"');
expect(html).not.toMatch(/bg-scrim|text-on-media|rounded-2xl/);
expect(html).toContain("text-foreground");
});
it("没有背景图:退化为普通头部,不渲染剧照层和空的深色块", () => {
const html = render({ facts: facts({ backdropPath: null }) });
expect(html).toContain('data-backdrop="none"');
expect(html).not.toContain("media-hero-backdrop");
expect(html).not.toContain("w1280");
expect(html).not.toMatch(/bg-scrim/);
expect(html).toContain("人生切割术");
});
it("状态徽标(正常 / Emby 等)与播出状态在同一行", () => {
const html = render({
facts: facts(),
badges: StatusBadge({ tone: "success", children: "正常" }),
});
const row = slotHtml(html, "media-hero-badges");
expect(row).toContain("连载中");
expect(row).toContain("正常");
});
it("操作按钮并进头图区、在外链之后;只有订阅是实心主操作,其余是描边次操作,没有棱镜描边", () => {
const html = render({
facts: facts(),
actions: createElement(
Fragment,
null,
createElement(Button, { variant: "solid", size: "sm" }, "订阅"),
createElement(Button, { variant: "outline", size: "sm" }, "搜索资源"),
createElement(Button, { variant: "outline", size: "sm" }, "查看订阅"),
),
});
const actions = slotHtml(html, "media-hero-actions");
expect(actions).not.toBeNull();
expect(html.indexOf('data-slot="media-hero-actions"')).toBeGreaterThan(html.indexOf("TMDB"));
expect(actions!.match(/pill-solid/g)).toHaveLength(1);
expect(actions!.match(/pill-secondary/g)).toHaveLength(2);
expect(actions).not.toContain("pill-primary");
expect(actions!.indexOf("pill-solid")).toBeLessThan(actions!.indexOf("订阅"));
});
it("没有操作时不渲染操作行;标语有值才显示", () => {
const plain = render({ facts: facts() });
expect(plain).not.toContain("media-hero-actions");
expect(plain).not.toContain("media-hero-tagline");
const tagged = render({ facts: facts({ tagline: "工作与生活,一刀两断" }) });
expect(slotHtml(tagged, "media-hero-tagline")).toContain("工作与生活,一刀两断");
});
});
describe("ClampText 折叠", () => {
const long = "很长的简介。".repeat(60);
it("超出行数:折叠成行尾渐隐 + 一个「展开」入口,不再另加省略号", () => {
const html = renderToStaticMarkup(createElement(ClampText, { text: long, lines: 4 }));
expect(html).toContain("clamp-fade");
expect(html).toContain("--clamp-lines:4");
expect(html).not.toContain("line-clamp");
expect(html).not.toMatch(/…|\.\.\./);
expect(html.match(/展开/g)).toHaveLength(1);
expect(html).toContain('aria-expanded="false"');
});
it("放得下时不折叠、不给「展开」", () => {
const html = renderToStaticMarkup(createElement(ClampText, { text: "短简介", lines: 4 }));
expect(html).not.toContain("clamp-fade");
expect(html).not.toContain("展开");
});
});
+75 -51
View File
@@ -1,6 +1,6 @@
import { type MediaCredit, tmdbImageUrl } from "@umt/contracts";
import { ChevronDown, ExternalLink, ImageOff, Lock, PencilLine, Star, UserRound } from "lucide-react";
import { type ReactNode, useState } from "react";
import { type CSSProperties, type ReactNode, useLayoutEffect, useRef, useState } from "react";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { CoverPlaceholder } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
@@ -8,7 +8,7 @@ import { cn } from "~/lib/utils.ts";
// 媒体详情(MD-1,对照 MP 媒体详情页):背景图 + 海报 + 标题 / 原名 / 年份 / 类型 / 评分 / 状态 / 集数 + 简介、
// 主创、季折叠与每集行。媒体库详情(本地作品)与仅 TMDB 的作品详情共用;数据由页面整理成下面的结构传入。
// 图片一律经图片代理(tmdbImageUrl);背景图上压 scrim 遮罩,文字用 on-media 色(不随日夜翻转)。
// 图片一律经图片代理(tmdbImageUrl);背景图渐隐到页面底色(--mat-hero-*),文字用常规前景色,随日夜翻转。
/** TMDB status 原文 → 界面用词 */
export const AIR_STATUS: Record<string, { label: string; tone: StatusTone }> = {
@@ -59,33 +59,44 @@ function runtimeText(min: number | null, kind: string): string | null {
return kind === "movie" ? s : `单集 ${s}`;
}
/** 可折叠的长文本:默认最多 lines 行,超出给「展开」 */
export function ClampText({
text,
lines = 3,
className,
onMedia = false,
}: {
text: string;
lines?: 2 | 3 | 4;
className?: string;
onMedia?: boolean;
}) {
/** 可折叠的长文本:默认最多 lines 行;放不下时最后一行行尾渐隐,「展开」压在渐隐处(只有这一个入口,不另加省略号) */
export function ClampText({ text, lines = 3, className }: { text: string; lines?: 2 | 3 | 4; className?: string }) {
const [open, setOpen] = useState(false);
const long = text.length > lines * 38;
const clamp = lines === 2 ? "line-clamp-2" : lines === 3 ? "line-clamp-3" : "line-clamp-4";
const ref = useRef<HTMLParagraphElement>(null);
// 首屏(含服务端渲染)按字数估计,挂载后按实际排版量:内容高度超过 lines 行才折叠
const [long, setLong] = useState(() => text.length > lines * 38);
useLayoutEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === "undefined") return;
const measure = () => {
const cs = getComputedStyle(el);
const lh = Number.parseFloat(cs.lineHeight) || Number.parseFloat(cs.fontSize) * 1.5;
setLong(el.scrollHeight > lines * lh + 1);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [lines]);
const clamped = long && !open;
return (
<div className={className}>
<p className={cn("break-words whitespace-pre-line", !open && long && clamp)}>{text}</p>
<div className={cn("relative", className)}>
<p
ref={ref}
data-clamped={clamped || undefined}
className={cn("break-words whitespace-pre-line", clamped && "clamp-fade")}
style={clamped ? ({ "--clamp-lines": lines } as CSSProperties) : undefined}
>
{text}
</p>
{long ? (
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={cn(
"mt-0.5 cursor-pointer text-meta underline-offset-4 hover:underline",
onMedia
? "text-on-media-tertiary hover:text-on-media-strong"
: "text-muted-foreground hover:text-foreground",
"cursor-pointer text-meta font-medium text-foreground/80 underline-offset-4 hover:text-foreground hover:underline",
clamped ? "absolute right-0 bottom-0 leading-[inherit]" : "mt-0.5",
)}
>
{open ? "收起" : "展开"}
@@ -95,8 +106,12 @@ export function ClampText({
);
}
/** 头部:背景图(压暗)+ 海报 + 标题与事实行 + 简介 + 外部链接;badges 放标题下方,操作按钮由页面放在头部之后 */
export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: ReactNode }) {
/**
* 头部(方案 A,2026-10-07):有背景图时剧照铺在内容区顶部,上下和右缘渐隐到页面底色,左侧文字区压近乎不透明的底色;
* 没有背景图时是普通头部。海报、标题、事实行(评分 · 播出状态 · 页面给的状态徽标 · 类型年份)、标语、简介、日期与外链、
* 操作按钮都压在背景上;文字用常规前景色 token,日夜同一结构。badges 并进事实行,actions 放在外链行下方。
*/
export function MediaHero({ facts, badges, actions }: { facts: MediaFacts; badges?: ReactNode; actions?: ReactNode }) {
const [bgFailed, setBgFailed] = useState(false);
const bg = bgFailed ? null : tmdbImageUrl(facts.backdropPath, "w1280");
const status = facts.airStatus ? (AIR_STATUS[facts.airStatus] ?? { label: facts.airStatus, tone: "neutral" }) : null;
@@ -120,73 +135,77 @@ export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: React
facts.kind !== "movie" && facts.lastAirDate ? `最近播出 ${facts.lastAirDate}` : null,
].filter(Boolean);
return (
<section className="relative isolate overflow-hidden rounded-2xl border border-border bg-scrim text-on-media">
<section className="relative isolate" data-backdrop={bg ? "image" : "none"}>
{bg ? (
<div
aria-hidden
data-slot="media-hero-backdrop"
className="media-hero-backdrop pointer-events-none absolute -inset-x-4 -top-16 -z-10 h-[26rem] overflow-hidden sm:-top-20 sm:h-[calc(100%+7rem)] sm:min-h-[30rem] md:-inset-x-6"
>
<img
src={bg}
alt=""
aria-hidden
onError={() => setBgFailed(true)}
className="absolute inset-0 -z-10 h-full w-full object-cover object-top opacity-55"
className="absolute inset-0 h-full w-full object-cover object-top"
/>
</div>
) : null}
<div aria-hidden className="absolute inset-0 -z-10 bg-gradient-to-r from-scrim/90 via-scrim/70 to-scrim/35" />
<div
aria-hidden
className="absolute inset-0 -z-10 bg-gradient-to-t from-scrim/80 via-transparent to-transparent"
/>
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:gap-6 sm:p-6">
<div className={cn("flex flex-col gap-4 sm:flex-row sm:gap-6", bg && "pt-24 sm:pt-6")}>
<CoverPlaceholder
title={facts.title}
kind={facts.kind}
posterPath={facts.posterPath}
className="h-40 w-28 self-start border-on-media-wash/20 shadow-lg sm:h-60 sm:w-40"
className="h-40 w-28 self-start shadow-lg sm:h-60 sm:w-40"
/>
<div className="min-w-0 flex-1">
<h1 className="text-title-lg font-semibold tracking-tight break-words text-on-media-strong sm:text-headline-sm">
<h1 className="text-title-lg font-semibold tracking-tight break-words text-foreground sm:text-headline-sm">
{facts.title}
{facts.year ? <span className="font-normal text-on-media-tertiary"> ({facts.year})</span> : null}
{facts.year ? <span className="font-normal text-muted-foreground"> ({facts.year})</span> : null}
</h1>
{sub.length ? <p className="mt-1 text-body break-words text-on-media-secondary">{sub.join(" · ")}</p> : null}
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-caption text-on-media-secondary">
{sub.length ? <p className="mt-1 text-body break-words text-muted-foreground">{sub.join(" · ")}</p> : null}
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-caption text-muted-foreground">
{facts.voteAverage ? (
<span className="inline-flex items-center gap-1 font-medium text-on-media-strong">
<span className="inline-flex items-center gap-1 font-medium text-foreground">
<Star className="h-3.5 w-3.5 fill-current" aria-hidden />
{facts.voteAverage.toFixed(1)}
{facts.voteCount ? (
<span className="font-normal text-on-media-tertiary">({facts.voteCount} 人评分)</span>
<span className="font-normal text-muted-foreground">({facts.voteCount} 人评分)</span>
) : null}
</span>
) : null}
{status || badges ? (
<span data-slot="media-hero-badges" className="inline-flex flex-wrap items-center gap-1.5">
{status ? <StatusBadge tone={status.tone}>{status.label}</StatusBadge> : null}
{badges}
</span>
) : null}
{line.length ? <span>{line.join(" · ")}</span> : null}
</div>
{facts.genres.length ? (
<ul className="mt-2 flex flex-wrap gap-1.5" aria-label="类型">
{facts.genres.map((g) => (
<li
key={g}
className="rounded-full border border-on-media-wash/25 px-2 py-0.5 text-meta text-on-media-secondary"
>
<li key={g} className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">
{g}
</li>
))}
</ul>
) : null}
{badges ? <div className="mt-2 flex flex-wrap items-center gap-1.5">{badges}</div> : null}
{facts.tagline ? <p className="mt-3 text-caption text-on-media-tertiary italic">{facts.tagline}</p> : null}
{facts.tagline ? (
<p data-slot="media-hero-tagline" className="mt-3 text-caption text-muted-foreground italic">
{facts.tagline}
</p>
) : null}
{facts.overview ? (
<ClampText
text={facts.overview}
lines={4}
onMedia
className="mt-2 max-w-3xl text-caption leading-relaxed text-on-media"
className="mt-2 max-w-2xl text-caption leading-relaxed text-foreground/90"
/>
) : (
<p className="mt-2 text-meta text-on-media-tertiary">暂无简介</p>
<p className="mt-2 text-meta text-muted-foreground">暂无简介</p>
)}
{dates.length || facts.externalLinks.length ? (
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-on-media-tertiary">
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground">
{dates.map((d) => (
<span key={d}>{d}</span>
))}
@@ -196,7 +215,7 @@ export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: React
href={l.href}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 underline-offset-4 hover:text-on-media-strong hover:underline"
className="inline-flex items-center gap-1 underline-offset-4 hover:text-foreground hover:underline"
>
{l.label}
<ExternalLink className="h-3 w-3" aria-hidden />
@@ -204,6 +223,11 @@ export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: React
))}
</div>
) : null}
{actions ? (
<div data-slot="media-hero-actions" className="mt-4 flex flex-wrap items-center gap-2">
{actions}
</div>
) : null}
</div>
</div>
</section>
@@ -25,13 +25,13 @@ const GROUP_ORDER = ["title", "episode", "tech", "id", "ours"] as const;
const ROLE: Record<NamingPreviewResult["segments"][number]["role"], { label: string; className: string }> = {
category: { label: "分类", className: "text-muted-foreground" },
root: { label: "作品根", className: "text-primary font-medium" },
root: { label: "作品目录", className: "text-primary font-medium" },
season: { label: "季目录", className: "text-foreground" },
sub: { label: "子目录", className: "text-muted-foreground" },
file: { label: "文件名", className: "text-foreground font-medium" },
};
export const NAMING_NOTE = "已建立目录的作品不受模板影响,模板只决定新目录和新文件名。";
export const NAMING_NOTE = "已建立目录的作品沿用原目录,模板只决定新目录和文件名。";
function useDebounced<T>(v: T, ms = 300): T {
const [d, setD] = useState(v);
@@ -167,7 +167,7 @@ export function NamingTemplateEditor({
<span>
{override && !value.trim()
? `留空:沿用${inherited?.[active]?.source ?? "上级"}模板`
: "/ 分目录,最后一段是文件名,须以 {{fileExt}} 结尾"}
: "用 / 分目录,最后一段是文件名,以 {{fileExt}} 结尾"}
</span>
<span className="tabular-nums">{value.length} / 1024</span>
</div>
@@ -317,10 +317,10 @@ function PreviewBody({
))}
</div>
)}
{r.usesBinding ? <Note>这部作品已有目录,目录沿用已建立的路径,模板只决定文件名。</Note> : null}
{r.usesBinding ? <Note>这部作品已有目录,沿用原目录,模板只决定文件名。</Note> : null}
{r.missing.length ? (
<p className="text-meta text-muted-foreground">
无值的变量(输出为空):<span className="font-mono">{r.missing.join("、")}</span>
没有值的变量:<span className="font-mono">{r.missing.join("、")}</span>
</p>
) : null}
{r.warnings.map((w) => (
@@ -108,7 +108,7 @@ export function RuleGroupEditor({
const save = async () => {
if (!form.name.trim()) {
setNameError("名称必填");
setNameError("填写名称");
return;
}
const scope: RuleGroupScope = {
@@ -263,7 +263,7 @@ export function RuleGroupEditor({
label="作用范围"
hint={
group && (group.scope.entityIds?.length || group.scope.siteIds?.length || group.scope.subscriptionId)
? `另有:${scopeText({ ...group.scope, mediaTypes: undefined, mediaKinds: undefined } as RuleGroupScope)}(此处不改)`
? `另有:${scopeText({ ...group.scope, mediaTypes: undefined, mediaKinds: undefined } as RuleGroupScope)}(这里不改)`
: "不选 = 全部类型"
}
className="sm:col-span-2"
@@ -277,15 +277,15 @@ export function RuleGroupEditor({
</FormField>
<div className="flex items-center justify-between gap-3 py-1 sm:col-span-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">
{form.ladderRequired ? "资源至少要命中一层" : "只影响排序,不中的资源 pri 为 0"}
{form.ladderRequired ? "资源至少要命中一层" : "只影响排序,没命中的资源 pri 为 0"}
</p>
</div>
<Switch
checked={form.ladderRequired}
onCheckedChange={(v) => set("ladderRequired", v)}
aria-label="一层都不中时拒绝"
aria-label="一层都没命中时拒绝"
/>
</div>
<div className="flex items-center justify-between gap-3 py-1 sm:col-span-2">
@@ -379,7 +379,7 @@ const FIELD_LABEL: Record<string, string> = {
scope: "作用范围",
layers: "阶梯",
hardExcludes: "硬排除",
ladderRequired: "一层不中拒绝",
ladderRequired: "一层都没命中时拒绝",
enabled: "启用",
label: "名称",
rule: "规则",
+2 -2
View File
@@ -29,7 +29,7 @@ export const SUPPRESS_REASON: Record<string, string> = {
disabled: "停用",
"status:disabled": "停用",
"status:deleted": "删除",
edited: "修改语义",
edited: "改了条件",
};
export const suppressReason = (r: string | null) => (r ? (SUPPRESS_REASON[r] ?? r) : "未注明");
@@ -59,5 +59,5 @@ export function ruleSummary(g: RuleGroup): string {
parts.push(`${g.ladderRequired ? "要求" : "偏好"} ${g.layers.map((l) => exprToString(l.expr)).join(" > ")}`);
for (const h of g.hardExcludes) parts.push(`排除 ${exprToString(h)}`);
if (parts.length) return parts.join(";");
return g.legacyKind ? "旧偏好标签,没有可评估的条件" : "没有条件";
return g.legacyKind ? "旧版偏好,没有可评估的条件" : "没有条件";
}
@@ -47,7 +47,7 @@ export function ladderFrom(layers: RuleLayer[]): LadderState {
/** 保存用:有错误 / 解析中 / 有空层时不可保存 */
export function ladderProblem(s: LadderState): string | null {
if (s.parsing) return "规则串还在解析";
if (s.parsing) return "规则串解析中";
if (s.failure) return s.failure;
if (s.errors.length) return "规则串有错误";
if (s.cards?.some((c) => c.items.length === 0)) return "有空层";
@@ -253,7 +253,7 @@ export function RuleStringField({
<li key={`${e.code}-${e.position}-${i}`} className="text-meta text-destructive">
<ErrorCaret text={state.text} position={e.position} />
<span>
第 {e.position + 1} 个字符{single ? "" : `(第 ${e.layer + 1} 层)`}:{e.message}
第 {e.position + 1} 个字符{single ? "" : `(第 ${e.layer + 1} 层)`}:{e.message}
</span>
{e.suggestions?.length ? (
<span className="ml-1 inline-flex flex-wrap gap-1 align-middle">
@@ -338,7 +338,7 @@ function AtomChip({
item.neg ? "border-destructive/35 bg-destructive/10 text-destructive" : "border-border bg-foreground/[0.03]",
unknown && "border-warning/50",
)}
title={keyword ? `${keyword.name}:${keyword.description}` : "未知条件"}
title={keyword ? `${keyword.name}:${keyword.description}` : "未知条件"}
>
<button
type="button"
@@ -150,7 +150,7 @@ function PreviewResult({ result }: { result: RulePreviewResult }) {
</details>
) : null}
<p className="mt-1 text-meta text-muted-foreground sm:hidden">
{[...it.excludedBy.map((x) => `排除 ${x}`), ...it.reasons.map(reasonText)].join(";")}
{[...it.excludedBy.map((x) => `排除 ${x}`), ...it.reasons.map(reasonText)].join(";")}
</p>
</td>
<td className="px-3 py-2 whitespace-nowrap">
@@ -164,7 +164,7 @@ function PreviewResult({ result }: { result: RulePreviewResult }) {
)}
</td>
<td className={cn("hidden px-3 py-2 text-meta text-muted-foreground sm:table-cell")}>
{[...it.excludedBy.map((x) => `排除 ${x}`), ...it.reasons.map(reasonText)].join(";") || "—"}
{[...it.excludedBy.map((x) => `排除 ${x}`), ...it.reasons.map(reasonText)].join(";") || "—"}
</td>
</tr>
))}
+2 -2
View File
@@ -257,7 +257,7 @@ function atomSummary(a: RuleAtom): string {
if (a.minSeeders !== null) p.push(`做种 ≥ ${a.minSeeders}`);
if (a.publishDelayMinutes) p.push(`发布 ${a.publishDelayMinutes.min ?? 0}–${a.publishDelayMinutes.max ?? "∞"} 分钟`);
if (a.free) p.push("免费");
return p.join(";") || "没有条件";
return p.join(";") || "没有条件";
}
export function AtomsTab() {
@@ -299,7 +299,7 @@ export function AtomsTab() {
<Trash2 />
</Button>
}
title={`删除条件 ${a.key}?`}
title={`删除条件 ${a.key}?`}
description="被规则组引用时不能删除。"
confirmLabel="删除"
destructive
@@ -199,7 +199,7 @@ export function AgentCheckCard({
<footer className="mt-auto flex flex-wrap items-center gap-1.5">
<Button size="sm" disabled={!a.canRun} onClick={a.run}>
{a.runNow.isPending ? <LoaderCircle className="animate-spin" /> : <Play />}
立即执行
立即运行
</Button>
<Button size="sm" variant="ghost" disabled={!a.canSkip} onClick={a.skipNext}>
<SkipForward />
@@ -337,7 +337,7 @@ function AgentCheckDetail({
</Field>
<Field label="触发条件">
{CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]}
{c.reportAsDecision ? <span className="ml-1 text-meta text-muted-foreground">· 结果写入待处理</span> : null}
{c.reportAsDecision ? <span className="ml-1 text-meta text-muted-foreground">· 生成巡检报告</span> : null}
</Field>
<Field label="预算">{budgetParts.length ? budgetParts.join(" · ") : "默认"}</Field>
<Field label="模型">
@@ -169,11 +169,11 @@ export function AgentCheckDialog({
rows={4}
value={d.goal}
aria-invalid={!!errors.goal}
placeholder="检查 {{openDecisions}} 条待处理事项,给出处理建议"
placeholder="检查 {{openDecisions}} 条需要处理的事项,给出处理建议"
onChange={(e) => set("goal", e.target.value)}
/>
</FormField>
<FormField label="能力白名单" hint="不选则放行全部只读能力">
<FormField label="能力白名单" hint="不选就允许全部只读能力">
<div className="grid max-h-48 gap-1 overflow-y-auto rounded-tight border border-border/70 px-2.5 py-2 sm:grid-cols-2">
{READONLY_CAPS.map((c) => {
const on = d.caps.includes(c.id);
@@ -264,15 +264,17 @@ export function AgentCheckDialog({
<div className="grid gap-2 pb-2">
<label htmlFor="ac-report" className="flex items-center justify-between gap-3 text-caption">
<span>
写入待处理
<span className="ml-2 text-meta text-muted-foreground">结果汇总成一条待处理事项</span>
生成巡检报告
<span className="ml-2 text-meta text-muted-foreground">运行成功后把结果汇总成一条报告</span>
</span>
<Switch id="ac-report" checked={d.reportAsDecision} onCheckedChange={(v) => set("reportAsDecision", v)} />
</label>
<label htmlFor="ac-recover" className="flex items-center justify-between gap-3 text-caption">
<span>
允许授权内恢复
<span className="ml-2 text-meta text-muted-foreground">需有效持续授权,否则只读</span>
<span className="ml-2 text-meta text-muted-foreground">
可重试任务、重新校验、确认识别;没有有效的持续授权时只读
</span>
</span>
<Switch id="ac-recover" checked={d.allowRecover} onCheckedChange={(v) => set("allowRecover", v)} />
</label>
@@ -60,7 +60,7 @@ export function useJobActions(job: ScheduledJob) {
runNow.mutate(
{ id: job.id },
{
onSuccess: (r) => toast.success(r.queued ? "已排队执行" : "已在排队或运行中"),
onSuccess: (r) => toast.success(r.queued ? "已加入队列" : "已在排队或运行中"),
onError: (e) => toast.error(errorMessage(e)),
},
),
@@ -149,8 +149,8 @@ function AutoTaskRow({ job, now, highlight }: { job: ScheduledJob; now: number;
<Button
size="icon-sm"
variant="ghost"
aria-label={`立即执行${job.name}`}
title="立即执行"
aria-label={`立即运行${job.name}`}
title="立即运行"
disabled={!a.canRun}
onClick={a.run}
>
@@ -21,7 +21,7 @@ import { errorMessage } from "~/lib/api.ts";
// 对话里给「检查并启用」卡片,用户在计划页检查、修改后启用。
const EXAMPLES = [
"每天早上 9 点前看看有没有失败或卡住的下载,有就写成待处理",
"每天早上 9 点前看看有没有失败或卡住的下载,有就汇报",
"每隔 6 小时检查一下站点登录有没有失效",
"每周汇报一次订阅追更情况和缺集",
];
@@ -14,7 +14,7 @@ describe("计划页标签", () => {
{ text: "巡检待处理(" },
{ placeholder: "now", label: "当前时间" },
{ text: "):有 " },
{ placeholder: "openDecisions", label: "待处理事项数" },
{ placeholder: "openDecisions", label: "需要处理的事项数" },
{ text: " 条,{{unknown}}" },
]);
});
+3 -3
View File
@@ -48,8 +48,8 @@ export const TRIGGER_LABEL: Record<ScheduleTrigger, string> = {
export const CHECK_TRIGGER_LABEL: Record<AgentCheckTrigger, string> = {
always: "每次运行",
open_decisions: "有待处理时",
stalled_downloads: "有下载无进展时",
open_decisions: "有需要处理的事项时",
stalled_downloads: "有下载无进展或失败时",
subscriptions_active: "有追踪订阅时",
};
@@ -135,7 +135,7 @@ export function formatClock(iso: string | null | undefined): string {
export const GOAL_PLACEHOLDER_LABEL: Record<string, string> = {
now: "当前时间",
date: "今天日期",
openDecisions: "待处理事项数",
openDecisions: "需要处理的事项数",
stalledDownloads: "异常下载数",
activeSubscriptions: "追踪中的订阅数",
};
@@ -55,7 +55,7 @@ export function RunsSheet({ job, onClose }: { job: ScheduledJob | null; onClose:
to={consolePath("agent", r.agentRunId)}
className="w-fit text-meta text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
查看智能体会话{r.agentStatus ? `(${agentRunLabel(r.agentStatus).label})` : ""}
查看智能体会话{r.agentStatus ? `(${agentRunLabel(r.agentStatus).label})` : ""}
</Link>
) : null}
</li>
@@ -193,9 +193,7 @@ function PaletteBody({ initial }: { initial: string }) {
go(searchPagePath(term));
}}
/>
<p className="py-6 text-center text-caption text-muted-foreground">
输入作品名、别名或页面名称。选中作品打开详情页。
</p>
<p className="py-6 text-center text-caption text-muted-foreground">输入作品名、别名或页面名</p>
</div>
) : (
<>
@@ -320,7 +318,7 @@ function actionItem(a: SearchAction, run: () => void): Item {
icon: actionIcon(a.kind),
title: a.title,
meta: a.group,
hint: "前往",
hint: "打开",
run,
};
}
@@ -181,7 +181,7 @@ export function ReleaseResults({
</h2>
{first ? (
<p className="mt-0.5 text-meta break-words text-muted-foreground tabular-nums">
{first.sites.length} 个站点 · {first.cached ? "缓存结果" : "站点实时结果"} · 抓取于{" "}
{first.sites.length} 个站点 · {first.cached ? "缓存结果" : "实时结果"} · 上次抓取{" "}
{relTime(first.fetchedAt)}
</p>
) : null}
@@ -199,7 +199,7 @@ export function ReleaseResults({
onClick={() => refresh.mutate(api, { onError: (e) => toast.error(errorMessage(e, "刷新失败")) })}
>
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
{refresh.isPending ? "查询中…" : "刷新"}
{refresh.isPending ? "刷新中…" : "刷新"}
</Button>
</div>
</div>
@@ -240,7 +240,7 @@ export function ReleaseResults({
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
{res.isPending ? (
<div className="p-4">
<p className="mb-3 text-meta text-muted-foreground">正在查询各站点,最慢约 15 秒…</p>
<p className="mb-3 text-meta text-muted-foreground">查询各站点中,最长约 15 秒…</p>
<LoadingState rows={4} />
</div>
) : res.isError && !rows.length ? (
@@ -361,7 +361,7 @@ function CoverageNotice({ page }: { page: ReleaseSearchPage }) {
<ul className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-meta text-muted-foreground">
{page.failedSites.map((s) => (
<li key={s.siteId} className="break-all" title={s.error ?? undefined}>
{s.siteName}:{SITE_STATUS[s.status]}
{s.siteName}:{SITE_STATUS[s.status]}
{s.error ? `(${s.error})` : ""}
</li>
))}
@@ -384,7 +384,7 @@ function CoverageNotice({ page }: { page: ReleaseSearchPage }) {
<ul className="mt-1 space-y-0.5 pl-4">
{skipped.map((s) => (
<li key={s.siteId} className="break-all">
{s.siteName}:{s.error ?? "站点不支持搜索"}
{s.siteName}:{s.error ?? "不支持搜索"}
</li>
))}
</ul>
@@ -650,11 +650,11 @@ function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () =>
</span>
<span className="lg:w-16 lg:text-right">{r.sizeBytes === null ? "—" : formatBytes(r.sizeBytes, 1)}</span>
<span className="inline-flex items-center gap-0.5 lg:w-28" title="做种 / 下载">
<ArrowUp aria-label="做种" className="h-3.5 w-3.5" />
<ArrowUp aria-label="做种数" className="h-3.5 w-3.5" />
<span className={cn("text-body", seedless ? "text-muted-foreground" : "font-semibold text-foreground")}>
{r.seeders ?? "—"}
</span>
<ArrowDown aria-label="下载" className="ml-1.5 h-3 w-3" />
<ArrowDown aria-label="下载数" className="ml-1.5 h-3 w-3" />
{r.leechers ?? "—"}
</span>
<span
@@ -667,15 +667,15 @@ function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () =>
<span className="flex flex-wrap items-center gap-1">
{r.sourceReleaseId ? (
<Button asChild variant="ghost" size="sm">
<Link to={`/console/sites/${r.siteId}`} title="在站点工作台查看">
<Link to={`/console/sites/${r.siteId}`} title="打开站点页">
<ExternalLink />
工作台
站点页
</Link>
</Button>
) : null}
<Button variant="ghost" size="sm" onClick={() => delegateToAgent(r)}>
<Bot />
委托智能体
交给智能体
</Button>
<Button size="sm" onClick={onDownload}>
<Download />
@@ -74,7 +74,7 @@ export function WorksSection({ q }: { q: string }) {
plain
icon={Film}
title="没有匹配的作品"
description="换个译名、原名或别名再搜;资源标题可以点「按关键词搜资源」。"
description="换个译名、原名或别名再搜,或点「按关键词搜资源」直接搜资源标题。"
className="py-8"
/>
</div>
@@ -134,7 +134,7 @@ export function SitePickerDialog({
))}
</fieldset>
{sitesQ.isPending ? (
<p className="text-caption text-muted-foreground">加载站点…</p>
<p className="text-caption text-muted-foreground">加载中…</p>
) : sites.length === 0 ? (
<p className="text-caption text-muted-foreground">
还没有启用的站点。
@@ -156,7 +156,7 @@ export function SitePickerDialog({
.filter((s) => ids.includes(s.id))
.map((s) => s.name)
.join("、")
: "没有站点勾选「默认参与搜索」。在站点页打开,或改选全部站点。"}
: "没有站点勾选「默认参与搜索」。到站点页勾选,或改选「全部站点」。"}
</p>
)}
<DialogFooter>
@@ -101,7 +101,7 @@ function PasskeyRow({ item }: { item: PasskeyInfo }) {
}
title={`删除 Passkey「${item.name}」?`}
description="删除后这把钥匙不能再登录控制台;设备上的钥匙要在系统设置里另外删。"
confirmLabel="删除"
confirmLabel="删除 Passkey"
destructive
onConfirm={async () => {
try {
+5 -5
View File
@@ -41,7 +41,7 @@ function PasswordBlock({ disabled }: { disabled: boolean }) {
setErrors({});
try {
const r = await change.mutateAsync({ currentPassword: form.currentPassword, newPassword: form.newPassword });
toast.success(r.revokedSessions ? `密码已修改,注销了其它 ${r.revokedSessions} 个会话` : "密码已修改");
toast.success(r.revokedSessions ? `密码已修改,其他 ${r.revokedSessions} 个会话已退出` : "密码已修改");
setForm(EMPTY_PWD);
} catch (err) {
const msg = errorMessage(err);
@@ -58,7 +58,7 @@ function PasswordBlock({ disabled }: { disabled: boolean }) {
section="account"
anchor="password"
title="修改密码"
description="修改后其它设备上的会话全部注销,当前会话保留。"
description="其他设备上的会话会退出登录,当前会话保留。"
dirty={dirty}
>
<form onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-3">
@@ -136,13 +136,13 @@ function SessionBlock() {
</dd>
<dt className="text-muted-foreground">当前会话</dt>
<dd>{s?.expiresAt ? `到 ${formatDateTime(s.expiresAt)} 失效` : "未使用登录会话"}</dd>
<dt className="text-muted-foreground">其它会话</dt>
<dd className="text-muted-foreground">修改密码时全部注销</dd>
<dt className="text-muted-foreground">其他会话</dt>
<dd className="text-muted-foreground">修改密码时全部退出登录</dd>
</dl>
<div className="mt-4 border-t border-border/60 pt-4">
<p className="text-caption font-medium">本机 Token</p>
<p className="mb-2 text-meta text-muted-foreground">
只存在这个浏览器,有值时请求会带上它。{hasToken ? "已设置。" : "未设置。"}
只保存在这个浏览器,有值时每个请求都会带上。{hasToken ? "已设置。" : "未设置。"}
</p>
<div className="flex max-w-md flex-wrap items-center gap-2">
<SecretInput
+4 -4
View File
@@ -88,20 +88,20 @@ function SearchSitesBlock() {
export const IDENTITY_CARD: CardSpec = {
anchor: "identity",
title: "资源识别",
description: "默认只在资源命中订阅、作品别名或新作品规则,或你手动识别和搜索时查 TMDB;其余放进未识别,不进待处理。",
description: "默认只在资源命中订阅、作品别名或新作品规则,或你手动识别和搜索时查 TMDB;其余资源留在未识别。",
fields: [
{
name: "identity.eagerTmdbLookup",
label: "每条新资源都查 TMDB",
kind: "switch",
hint: "每条新资源都查一次 TMDB。RSS 量大时每天可能上千次请求,站点独有的短剧和综艺多半查不到",
hint: "RSS 量大时每天可能上千次请求;站点独有的短剧和综艺多半查不到",
wide: true,
},
{
name: "identity.detailIdsPerSiteHour",
label: "识别时每站每小时最多抓详情页",
kind: "number",
hint: "标题认不准时,抓站点详情页取 IMDb、TMDB 或豆瓣 ID 再识别;只在搜索、订阅相关资源、补全和手动重新识别时抓,RSS 收进来的资源不抓。0 = 不抓",
hint: "标题认不准时,抓站点详情页取 IMDb、TMDB 或豆瓣 ID 再识别;只在搜索、命中订阅、补全和手动重新识别时抓,RSS 收进来的资源不抓。0 = 不抓",
},
{
name: "identity.autoEnrich",
@@ -125,7 +125,7 @@ export const IDENTITY_CARD: CardSpec = {
},
{
name: "identity.enrichSnippetAllSites",
label: "也用其它网站的搜索摘要",
label: "也用其他网站的搜索摘要",
kind: "switch",
hint: "短剧多半只有聚合站收录。打开后,白名单以外网站的搜索摘要也用来抽年份、集数、主演、简介(只看搜索引擎给的摘要,不打开这些网页)",
wide: true,
@@ -37,7 +37,7 @@ import { SettingsBlock } from "./settings-block.tsx";
export const KIND_LABEL: Record<CapabilityKind, string> = {
read: "只读",
preview: "预览",
propose: "提案",
propose: "方案",
approve: "审批",
execute: "执行",
};
@@ -49,7 +49,7 @@ const RISK: Record<RiskLevel, { label: string; tone: StatusTone }> = {
};
const AUTH: Record<CapabilityAuthState, { label: string; tone: StatusTone; hint: string }> = {
read_only: { label: "无需授权", tone: "neutral", hint: "只读或预览,不落变更,Key 有对应 scope 即可调用" },
read_only: { label: "不需要授权", tone: "neutral", hint: "只读或预览,不改数据;Key 有对应 scope 就能调用" },
granted: { label: "有持续授权", tone: "success", hint: "智能体、MCP、API Key 调用按持续授权放行" },
needs_approval: {
label: "需审批",
+1 -1
View File
@@ -105,7 +105,7 @@ export const BUDGET_CARD: CardSpec = {
export const INVESTIGATE_CARD: CardSpec = {
anchor: "investigate",
title: "自动处理",
description: "整理时识别不到作品就叫智能体识别;下载失败、集数对不上等问题出现时,自动发起一次有界调查。",
description: "整理时识别不到作品就叫智能体识别;下载失败、集数对不上时自动发起一次调查,受下面的上限约束。",
fields: [
{
name: "agent.autoIdentifyOrganize",
@@ -50,7 +50,7 @@ function Verified({ p }: { p: BrowserProviderView }) {
function CapabilityTable({ p }: { p: BrowserProviderView }) {
const caps = p.capabilities;
if (!caps) return <p className="mt-4 text-caption text-muted-foreground">还没有探测过。点"测试连通"读取能力。</p>;
if (!caps) return <p className="mt-4 text-caption text-muted-foreground">还没有探测过。点「测试连通」读取能力。</p>;
return (
<div className="mt-5 space-y-2">
<h3 className="text-caption font-semibold">
@@ -81,7 +81,7 @@ function CapabilityTable({ p }: { p: BrowserProviderView }) {
})}
</ul>
<p className="text-meta text-muted-foreground">
"已核验"只来自对真实实例的逐项核验记录(见运行手册),探测只能判断接口在不在。未核验的能力在原站视图里标出。
「已核验」只来自对真实实例的逐项核验记录(见运行手册),探测只能判断接口在不在。未核验的能力在原站视图里标出。
</p>
</div>
);
@@ -124,7 +124,7 @@ export function BrowserProvidersBlock() {
},
{
key: "key",
header: "访问凭据",
header: "访问凭证",
hideBelow: "md",
cell: (r) => (r.hasAccessKey ? "已设置" : "未设置"),
},
@@ -148,7 +148,7 @@ export function BrowserProvidersBlock() {
const auth = (v.health?.detail as { error?: string } | undefined)?.error === "auth";
return {
ok: v.health?.status === "ok",
message: auth ? "访问凭据不对" : v.health?.status === "degraded" ? "浏览器服务正在恢复" : "连不上",
message: auth ? "访问凭证不对" : v.health?.status === "degraded" ? "浏览器服务正在恢复" : "连不上",
version: v.capabilities?.upstreamVersion ?? null,
};
}}
+1 -1
View File
@@ -279,7 +279,7 @@ export const TTL_CARD: CardSpec = {
fields: [
ttlField("tmdbSearchDays", "TMDB 搜索与识别结果"),
ttlField("tmdbDetailDays", "TMDB 详情"),
ttlField("tmdbMissDays", "TMDB 空结果", "搜索无结果、未识别时的负缓存"),
ttlField("tmdbMissDays", "TMDB 空结果", "搜索无结果或没识别出的结果也缓存,期内不重复查"),
ttlField("imageDays", "TMDB 人物与图片"),
ttlField("metaSearchDays", "豆瓣 / Bangumi 搜索"),
ttlField("metaDetailDays", "豆瓣 / Bangumi 条目"),
@@ -118,7 +118,7 @@ export function DownloadersTable() {
config={{
noun: "下载器实例",
description:
"只接管带管理标签的任务。可添加多个下载器:订阅或手动下载指定了下载器就用指定的,否则用站点指定的,再否则用默认下载器。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和路径映射。",
"只管理带管理标签的任务。选下载器的顺序:订阅或手动下载指定的、站点指定的、默认下载器。多个实例共用一块下载盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和路径映射。",
emptyHint: "添加 qBittorrent 后才能提交下载。",
drawerExtra: (r) => <PathMappingsPanel viewKind="downloader" instanceId={r.id} />,
columns: [
@@ -180,7 +180,7 @@ export function DownloadersTable() {
placeholder: "qbt_…",
visible: (v) => v.authKind === "api_key",
},
{ name: "managedTag", label: "管理标签", kind: "text", placeholder: "umt", hint: "只接管带此标签的任务" },
{ name: "managedTag", label: "管理标签", kind: "text", placeholder: "umt", hint: "只管理带这个标签的任务" },
{ name: "cacheVolumeKey", label: "缓存卷标识", kind: "text", placeholder: "cache-1" },
{
name: "hardPct",
@@ -357,7 +357,7 @@ const MEDIA_KIND = [
{ value: "series", label: "剧集" },
{ value: "variety", label: "综艺" },
{ value: "mixed", label: "混合" },
{ value: "other", label: "其它" },
{ value: "other", label: "其他" },
];
/** 库根路径 ↔ 存储 + 路径:Alist 上的库根存成 /@alist/<存储 id>/<Alist 内路径> */
+1 -1
View File
@@ -23,7 +23,7 @@ export function LibrarySection() {
section="library"
anchor="roots"
title="媒体库目录"
description="整理把作品放进这些目录;媒体服务器扫的也是它们。"
description="整理后的作品放在这些目录里,媒体服务器也扫描这些目录。"
plain
>
<LibraryRootsTable />
@@ -69,7 +69,7 @@ export const LANGUAGE_CARD: CardSpec = {
label: "语言回退链",
kind: "text",
placeholder: DEFAULT_LANGUAGES.join(", "),
hint: "标题和每集信息按顺序取第一个有值的语言,简介默认只取链里的中文;中文名取链中第一个中文值。只认简繁中文、英文和作品原语言(如日语作品的 ja-JP),其它语言跳过。逗号分隔,如 zh-CN, zh-SG",
hint: "逗号分隔,如 zh-CN, zh-SG。标题和每集信息取链上第一个有值的语言,简介默认只取中文。只认简繁中文、英文和作品原语言(如日语作品的 ja-JP),其他语言跳过",
wide: true,
},
{
+1 -1
View File
@@ -190,7 +190,7 @@ function RootNamingEditor({ root }: { root: LibraryRoot }) {
<div className="flex flex-wrap items-center gap-2">
{KINDS.map((k) => (
<StatusBadge key={k} tone={saved[k] ? "success" : "neutral"}>
{NAMING_KIND_LABEL[k]}:{saved[k] ? "媒体库目录覆盖" : "沿用全局"}
{NAMING_KIND_LABEL[k]}:{saved[k] ? "媒体库目录覆盖" : "沿用全局"}
</StatusBadge>
))}
</div>
@@ -59,7 +59,7 @@ export function ClassificationSection() {
})
}
>
让智能体协助
问智能体
</Button>
}
>
@@ -159,7 +159,7 @@ function SourceEditor({ initial, onClose }: { initial: OrganizeSourceView | null
<DialogHeader>
<DialogTitle>{initial ? "编辑整理来源" : "新增整理来源"}</DialogTitle>
<DialogDescription>
首次保存或更换来源目录先建立当前文件基线,只自动处理后续新增文件。历史文件请使用「整理存量」。
首次保存或更换来源目录时记下现有文件作为基线,之后只自动处理新增文件。已有文件用「整理存量」整理。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
@@ -316,7 +316,7 @@ function SourceEditor({ initial, onClose }: { initial: OrganizeSourceView | null
},
{
onSuccess: () => {
toast.success("来源已保存,当前存量不会自动整理");
toast.success("来源已保存,已有文件不会自动整理");
onClose();
},
onError: (e) => toast.error(filesErrorText(e, "保存来源失败")),
@@ -324,7 +324,7 @@ function SourceEditor({ initial, onClose }: { initial: OrganizeSourceView | null
);
}}
>
{save.isPending ? "保存并建立基线…" : "保存来源"}
{save.isPending ? "保存中…" : "保存来源"}
</Button>
</div>
</DialogContent>
@@ -360,7 +360,7 @@ export function OrganizeSourcesSection() {
section="library"
anchor="sources"
title="整理来源"
description="每条配置绑定一个来源与一个媒体库目标。新增来源建立基线,历史存量由你预览后执行。"
description="每条配置把一个来源目录对应到一个媒体库目录。新增的来源只自动处理之后出现的文件,已有文件用「整理存量」预览后整理。"
actions={
<Button size="sm" onClick={() => setEditor(null)}>
新增来源
@@ -369,13 +369,13 @@ export function OrganizeSourcesSection() {
>
<div className="space-y-3">
{sources.isPending ? (
<p>读取来源中…</p>
<p>加载中…</p>
) : sources.error ? (
<p role="alert" className="text-destructive">
{filesErrorText(sources.error, "读取来源失败")}
</p>
) : !sources.data?.items.length ? (
<p className="text-caption text-muted-foreground">尚未配置整理来源。</p>
<p className="text-caption text-muted-foreground">还没有整理来源。</p>
) : (
paged.items?.map((p) => (
<div key={p.id} className="space-y-2 rounded-xl border border-border p-3">
@@ -389,7 +389,7 @@ export function OrganizeSourcesSection() {
const { id, baselineAt: _, lastScanAt: _s, lastError: _e, fileCount: _c, ...body } = p;
save.mutate(
{ id, body: { ...body, enabled } },
{ onError: (e) => toast.error(filesErrorText(e, "切换启用失败")) },
{ onError: (e) => toast.error(filesErrorText(e, "切换失败")) },
);
}}
/>
@@ -408,7 +408,7 @@ export function OrganizeSourcesSection() {
r.items.length
? setWizard(stockWizardSource(p, r))
: toast.info(
`没有可整理文件;跳过 ${r.skipped},不可读取 ${r.unavailable}${r.truncated ? ",扫描达到上限" : ""}`,
`没有可整理的文件;跳过 ${r.skipped} 个,读不到 ${r.unavailable} 个${r.truncated ? ",已达扫描上限" : ""}`,
),
onError: (e) => toast.error(filesErrorText(e, "扫描存量失败")),
},
@@ -435,8 +435,8 @@ export function OrganizeSourcesSection() {
: "手动"}
</p>
<p className="text-meta text-muted-foreground">
基线:{p.baselineAt ? formatDateTime(p.baselineAt) : "尚未建立"} · 最近扫描:
{p.lastScanAt ? formatDateTime(p.lastScanAt) : "尚未扫描"} · 快照 {p.fileCount} 个文件
基线:{p.baselineAt ? formatDateTime(p.baselineAt) : "未建立"} · 最近扫描:
{p.lastScanAt ? formatDateTime(p.lastScanAt) : "未扫描"} · 快照 {p.fileCount} 个文件
</p>
{p.lastError ? <p className="text-caption text-destructive">{p.lastError}</p> : null}
</div>
@@ -451,7 +451,7 @@ export function OrganizeSourcesSection() {
<DialogContent>
<DialogHeader>
<DialogTitle>删除来源「{deleting.name}」?</DialogTitle>
<DialogDescription>删除配置及监控快照,文件仍保留在原目录。</DialogDescription>
<DialogDescription>配置和监控快照会删除,目录里的文件不动。</DialogDescription>
</DialogHeader>
<Button
variant="outline"
@@ -463,7 +463,7 @@ export function OrganizeSourcesSection() {
})
}
>
确认删除来源
删除来源
</Button>
</DialogContent>
</Dialog>
@@ -61,7 +61,7 @@ export const SCRAPE_CARD: CardSpec = {
name: "organize.scrapeAfterImport",
label: "整理后自动刮削",
kind: "switch",
hint: "只对认出作品(有 TMDB ID 或本地元数据)的整理排刮削;没刮削的在整理记录上标「未刮削」",
hint: "只刮削认出作品的整理(有 TMDB ID 或本地元数据);没刮削的整理记录标「未刮削」",
},
{
name: "organize.scrapeMode",
@@ -102,7 +102,7 @@ export function PiOAuthLogin({
abortRef.current = ac;
while (!ac.signal.aborted) {
if (Date.now() > new Date(s.expiresAt).getTime()) {
finish("expired", "授权已超时。");
finish("expired", "授权已超时");
return;
}
try {
@@ -258,7 +258,7 @@ export function ModelFetchBar({
: remote
? `来源:远程 · ${formatRelative(data?.fetchedAt)}更新`
: unsupported
? "来源:静态(这个 provider 没有模型列表接口)"
? "来源:静态(这个提供方没有模型列表接口)"
: data
? "来源:静态"
: null}
@@ -70,7 +70,7 @@ export function PiUpstreamWizard({ open, onClose }: { open: boolean; onClose: ()
: (created?.name ?? "");
const description =
step === "provider"
? "第 1 步,共 3 步:选 provider"
? "第 1 步,共 3 步:选提供方"
: step === "config"
? "第 2 步,共 3 步:凭证与模型"
: created?.authKind === "oauth"
@@ -116,7 +116,7 @@ function ProviderPicker({ onPick }: { onPick: (p: PiProviderInfo) => void }) {
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="筛选 provider"
aria-label="筛选提供方"
placeholder="筛选,例如 openai"
value={filter}
onChange={(e) => setFilter(e.target.value)}
@@ -148,7 +148,7 @@ function ProviderPicker({ onPick }: { onPick: (p: PiProviderInfo) => void }) {
</DetailSection>
);
})}
{list.length === 0 ? <p className="text-caption text-muted-foreground">没有匹配的 provider。</p> : null}
{list.length === 0 ? <p className="text-caption text-muted-foreground">没有匹配的提供方。</p> : null}
</div>
);
}
@@ -252,7 +252,7 @@ function ConfigStep({
<div className="sm:col-span-2">
<Button type="button" variant="ghost" size="xs" onClick={onBack} className="-ml-2 mb-2">
<ChevronLeft />
换一个 provider
换一个提供方
</Button>
{provider.note && !custom ? <p className="mb-3 text-caption text-muted-foreground">{provider.note}</p> : null}
</div>
@@ -356,7 +356,7 @@ function ConfigStep({
htmlFor="pw-model"
error={errors.defaultModel}
required={custom}
hint={custom ? "端点上的模型 id" : "留空用 provider 的默认模型"}
hint={custom ? "端点上的模型 id" : "留空用提供方的默认模型"}
className="sm:col-span-2"
>
<ModelFetchBar
+1 -1
View File
@@ -94,7 +94,7 @@ export const NETWORK_CARD: CardSpec = {
label: "User-Agent",
kind: "text",
placeholder: "跟随浏览器",
hint: "留空跟随浏览器(推荐),站点请求和浏览器用同一个 UA。填了则账号没单独填 UA 时都用它,和浏览器不一致",
hint: "留空时站点请求和浏览器用同一个 UA(推荐)。填写后,没单独设 UA 的账号都用这个值,与浏览器的 UA 不一致",
wide: true,
},
{
@@ -123,7 +123,7 @@ export const STORAGE_FIELDS: FieldSpec[] = [
name: "uploadAsTask",
label: "上传交给 Alist 后台任务",
kind: "switch",
hint: "开启后上传由 Alist 排队完成,本工具等任务结束再继续;缺省关闭(同 MP)",
hint: "开启后上传由 Alist 排队完成,本工具等任务结束再继续;缺省关闭",
},
{
name: "snapshotCheckFolderModtime",
@@ -197,7 +197,7 @@ export function StoragesTable() {
description:
"登记 Alist / OpenList 后,媒体库目录、整理来源和文件管理都能用它。网盘方向的上传、下载、复制和移动以接口成功、目标存在为准,不比对大小和哈希。",
emptyHint: "还没有登记 Alist / OpenList。",
deleteHint: "只删除登记和保存的凭据;被媒体库目录使用时不能删除。",
deleteHint: "只删除登记和保存的凭证;被媒体库目录使用时不能删除。",
columns: [
{
key: "name",
+5 -5
View File
@@ -59,9 +59,9 @@ const BUDGET_KEYS = ["pgDataGb", "pgDataWarnGb", "imagesGb", "backupsGb", "sessi
export const STORAGE_BUDGET_CARD: CardSpec = {
anchor: "storage",
title: "存储预算",
description: "超过预算时健康页告警;图片缓存超出容量按最久未访问删除。单位 GB。",
description: "超过预算时运行状态页告警;图片缓存超出容量按最久未访问删除。单位 GB。",
fields: [
gbField("pgDataGb", "数据库预算", "超过为严重"),
gbField("pgDataGb", "数据库预算", "超过时标为需要处理"),
gbField("pgDataWarnGb", "数据库告警线", "不高于数据库预算"),
gbField("imagesGb", "图片缓存容量"),
gbField("backupsGb", "备份目录预算"),
@@ -104,11 +104,11 @@ export const STORAGE_RETENTION_CARD: CardSpec = {
"未识别资源归档",
"未命中的条目这么久没有新证据就归档,仍可手动指定",
),
dayField("retentionDays", "engineRun", "后台运行记录", "仍被任务记录引用的跟随任务保留"),
dayField("retentionDays", "engineRun", "后台运行记录", "被任务记录引用的随任务一起保留"),
dayField("retentionDays", "outboxEvent", "已投递的内部事件"),
dayField("retentionDays", "resolutionAttempt", "识别尝试记录", "每个资源至少留最近 3 次"),
dayField("retentionDays", "identityResolved", "已识别的待识别条目", "忽略和归档的不删"),
dayField("retentionDays", "siteSnapshot", "站点快照", "每个站点最近一次成功的保留"),
dayField("retentionDays", "identityResolved", "未识别中已识别的条目", "忽略和归档的不删"),
dayField("retentionDays", "siteSnapshot", "站点抓取结果", "每个站点保留最近一次成功的抓取"),
],
fromSettings: (s) =>
Object.fromEntries(RETENTION_KEYS.map((k) => [`storage.retentionDays.${k}`, n(s.storage.retentionDays[k])])),
@@ -118,7 +118,7 @@ function AccountBlock({
onConfirm={async () => {
try {
await del.mutateAsync(a.id);
toast.success(`已删除账号 ${a.label}`);
toast.success(`已删除账号「${a.label}」`);
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
@@ -260,7 +260,7 @@ function PasskeyForm({ site, label, has }: { site: SiteListItem; label: string;
}
}}
>
<h2 className="text-lead font-semibold text-foreground">站点 Passkey(RSS 与下载链接用)</h2>
<h2 className="text-lead font-semibold text-foreground">站点 Passkey</h2>
<p className="mb-3 text-caption text-muted-foreground">
用于 RSS 和下载链接,不参与登录。{has ? "填写新值保存会替换。" : "还没有保存。"}
</p>
@@ -375,7 +375,7 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
)}
</div>
<p className="mt-3 text-meta text-muted-foreground">
"未实测"的定义没有在真实站点上验证过,列表和登录可能失败。
标「未实测」的站点定义没有在真实站点上验证过,列表和登录可能失败。
</p>
</div>
) : step === 2 ? (
+3 -3
View File
@@ -183,7 +183,7 @@ function CheckButtons({ site, checks, compact }: Omit<Props, "onDialog"> & { com
size={compact ? "icon-sm" : "sm"}
disabled={h || !site.enabled}
aria-label="检测健康"
title={site.enabled ? "检测健康:首页连通 + 登录态,走站点限流" : "站点已停用"}
title={site.enabled ? "检测健康:首页能否打开、登录态是否有效,计入站点限流" : "站点已停用"}
onClick={() => void checks.checkHealth(site)}
>
{h ? <LoaderCircle className="animate-spin" /> : <Activity />}
@@ -194,7 +194,7 @@ function CheckButtons({ site, checks, compact }: Omit<Props, "onDialog"> & { com
size={compact ? "icon-sm" : "sm"}
disabled={l || !hasAccount}
aria-label="刷新登录"
title={hasAccount ? "刷新登录:用凭证刷新登录状态并拉取用户数据" : "先填写凭证"}
title={hasAccount ? "刷新登录:用凭证重新登录并拉取用户数据" : "先填写凭证"}
onClick={() => void checks.refreshLogin(site)}
>
{l ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
@@ -289,7 +289,7 @@ function FeedSiteCard({ site, onDialog }: Omit<Props, "checks">) {
<div aria-hidden className="min-h-4 flex-1" />
<footer className="mt-auto flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("sites", site.id)}?tab=rss`}>{f.loaded && !f.count ? "添加 RSS" : "管理 RSS"}</Link>
<Link to={`${consolePath("sites", site.id)}?tab=rss`}>{f.loaded && !f.count ? "新建 RSS" : "管理 RSS"}</Link>
</Button>
<SiteMoreMenu site={site} onDialog={onDialog} />
</footer>
@@ -507,7 +507,7 @@ export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose:
</span>
</label>
</div>
<p className="text-meta text-muted-foreground">User-Agent 随凭证保存,在"更新凭证"里填写。</p>
<p className="text-meta text-muted-foreground">User-Agent 随凭证保存,在「更新凭证」里填写。</p>
</div>
</DetailSheet>
);
@@ -544,9 +544,9 @@ export function DeleteSiteDialog({
}}
>
<ul className="list-disc space-y-1 pl-5 text-caption text-muted-foreground">
<li>站点、{site.accounts?.length ?? 0} 个账号和全部凭证删除</li>
<li>已抓到的资源保留,标为"站点已关闭",不再产生新候选</li>
<li>该站 RSS 停用;已有下载任务按原策略继续</li>
<li>站点、{site.accounts?.length ?? 0} 个账号和全部凭证都会删除</li>
<li>已抓到的资源保留,标为「站点已关闭」,不再进入收录评估</li>
<li>这个站点的 RSS 停用;已有下载任务继续</li>
<li>同一站点以后可以重新添加</li>
</ul>
</ConfirmDialog>
@@ -70,7 +70,7 @@ export function SiteFailureSamples({ siteId, items }: { siteId: string; items: S
if (!items.length) return null;
return (
<section className="grid gap-2">
<h3 className="text-meta text-muted-foreground">最近的失败页面(已脱敏,留最近 5 份)</h3>
<h3 className="text-meta text-muted-foreground">最近的失败页面(已脱敏,最多保留 5 份)</h3>
<ul className="grid gap-2">
{items.map((s) => (
<FailureSampleRow key={s.id} siteId={siteId} sample={s} />
@@ -23,7 +23,7 @@ import { cn } from "~/lib/utils.ts";
const PURPOSES: { value: FeedPurpose; label: string; hint: string }[] = [
{ value: "generic", label: "发现", hint: "全站新种,进入识别和收录评估" },
{ value: "search", label: "追踪", hint: "按关键词、分类或收藏筛过的 RSS" },
{ value: "promotion", label: "促销", hint: "只含免费 / 促销种子的 RSS" },
{ value: "promotion", label: "促销", hint: "只含免费 / 促销资源的 RSS" },
];
/** 首抓最多等 90 秒(站点限流排队);超时后提示去列表看 */
@@ -106,7 +106,7 @@ export function SiteFeedDialog({
<Dialog open onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>添加 {site.name} RSS</DialogTitle>
<DialogTitle>新建 RSS · {site.name}</DialogTitle>
<DialogDescription>地址加密保存,页面上只显示去掉 passkey 的地址。</DialogDescription>
</DialogHeader>
@@ -219,8 +219,8 @@ export function SiteFeedDialog({
</FormField>
<p className="text-caption text-muted-foreground">
{account ? `账号 ${account.label}` : "该站没有账号,按地址里的 passkey 抓取"} · 解析器按站点定义 · 每 30
分钟抓一次,可在 RSS 详情里改
{account ? `账号 ${account.label}` : "这个站点没有账号,按地址里的 passkey 抓取"} · 解析器按站点定义 · 每
30 分钟抓一次,可在 RSS 详情里改
</p>
</form>
)}
@@ -43,7 +43,7 @@ describe("cloudflareLine / cloudflareTechItems(CF-2)", () => {
const until = new Date("2026-09-30T17:30:00+08:00");
const line = cloudflareLine(cfg({ lastChallengeAt: at, browserUntil: until.toISOString() }), now);
const hm = `${String(until.getHours()).padStart(2, "0")}:${String(until.getMinutes()).padStart(2, "0")}`;
expect(line?.text).toBe(`Cloudflare:浏览器代理中,至 ${hm}`);
expect(line?.text).toBe(`Cloudflare:浏览器代抓中,到 ${hm}`);
expect(cloudflareLine(cfg({ lastChallengeAt: at, lastSolvedBy: "vision" }), now)?.text).toBe(
"Cloudflare:自动通过",
);
@@ -63,8 +63,8 @@ describe("cloudflareLine / cloudflareTechItems(CF-2)", () => {
}),
);
expect(items.map((i) => i.label)).toEqual([
"最近遇到挑战",
"挑战规则",
"最近遇到验证",
"验证规则",
"处理结果",
"最近通过方式",
"cf_clearance 复用",
+12 -12
View File
@@ -158,7 +158,7 @@ export function healthMessage(h: SiteLastHealth | null): string | null {
const HEALTH_REASON_TEXT: Record<string, string> = {
logged_in: "首页有登录标志",
user_data: "首页没找到登录标志,按用户数据解析成功判为已登录",
user_data: "首页没有登录标志,但用户数据解析成功,按已登录处理",
login_page: "返回登录页",
permission_page: "返回权限提示页",
challenge: "疑似人机验证",
@@ -198,7 +198,7 @@ export function cloudflareLine(
if (st.browserUntil && Date.parse(st.browserUntil) > now.getTime()) {
const t = new Date(st.browserUntil);
const hm = `${String(t.getHours()).padStart(2, "0")}:${String(t.getMinutes()).padStart(2, "0")}`;
return { text: `Cloudflare:浏览器代理中,至 ${hm}`, tone: "neutral", takeover: false };
return { text: `Cloudflare:浏览器代抓中,到 ${hm}`, tone: "neutral", takeover: false };
}
if (st.lastSolvedBy) return { text: "Cloudflare:自动通过", tone: "success", takeover: false };
if (st.lastError) return { text: "Cloudflare:没能通过", tone: "warning", takeover: false };
@@ -206,8 +206,8 @@ export function cloudflareLine(
}
const CF_SOLVED_BY: Record<string, string> = {
auto: "等它自动放行",
vision: "视觉子流程点了复选框",
auto: "自动放行",
vision: "自动点了验证复选框",
human: "你在原站视图里通过",
};
const CF_RESULT: Record<string, string> = {
@@ -227,8 +227,8 @@ export function cloudflareTechItems(config: unknown): { label: string; value: st
if (!st?.lastChallengeAt) return [];
const t = (v: string | null) => (v ? new Date(v).toLocaleString("zh-CN", { hour12: false }) : "");
return [
{ label: "最近遇到挑战", value: t(st.lastChallengeAt) },
{ label: "挑战规则", value: st.lastRule ?? "", mono: true },
{ label: "最近遇到验证", value: t(st.lastChallengeAt) },
{ label: "验证规则", value: st.lastRule ?? "", mono: true },
{ label: "处理结果", value: st.lastResult ? `${CF_RESULT[st.lastResult] ?? st.lastResult}(${st.lastResult})` : "" },
{ label: "最近通过方式", value: st.lastSolvedBy ? (CF_SOLVED_BY[st.lastSolvedBy] ?? st.lastSolvedBy) : "" },
{ label: "最近通过时间", value: t(st.lastSolvedAt) },
@@ -236,7 +236,7 @@ export function cloudflareTechItems(config: unknown): { label: string; value: st
label: "cf_clearance 复用",
value: st.cfReuse === "ok" ? "有效(ok)" : st.cfReuse === "fails" ? "无效(fails)" : "",
},
{ label: "浏览器代理至", value: t(st.browserUntil) },
{ label: "浏览器代抓到", value: t(st.browserUntil) },
{ label: "最近尝试", value: t(st.lastAttemptAt) },
{ label: "最近错误", value: st.lastError ?? "" },
].filter((it) => it.value);
@@ -317,7 +317,7 @@ export function loginFailureText(
return { text: "登录失败:站点要求二次验证。填写 TOTP 密钥后重试,或改用 Cookie", suggestCookie: true };
case "challenge":
return {
text: "站点要求人机验证(Cloudflare)。会用浏览器自动过,过不去时在铃铛里提醒你",
text: "站点要求人机验证(Cloudflare)。会先用浏览器自动通过,失败时在铃铛里提醒",
suggestCookie: false,
};
case "bad_credentials":
@@ -333,11 +333,11 @@ export function loginFailureText(
suggestCookie: false,
};
case "network":
return { text: `连不上站点${message ? `:${message}` : ""}`, suggestCookie: false };
return { text: `连不上站点${message ? `:${message}` : ""}`, suggestCookie: false };
case "unsupported":
return { text: message ?? "这个站点不支持该凭证类型的自动登录", suggestCookie: false };
return { text: message ?? "这个站点不支持用这种凭证自动登录", suggestCookie: false };
default:
return { text: message ?? "登录失败,原因未知", suggestCookie: false };
return { text: message ?? "登录失败", suggestCookie: false };
}
}
@@ -365,7 +365,7 @@ export const REASON_SHORT: Record<SiteLoginFailureReason, string> = {
two_factor: "需二次验证",
bad_credentials: "密码错误",
account_blocked: "账号封禁",
challenge: "Cloudflare 挑战",
challenge: "Cloudflare 验证",
auth_expired: "凭证失效",
network: "网络失败",
unsupported: "不支持",
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more