docs(plan): v0.2 adds Pi subagents and scheduled-jobs board

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-28 02:09:39 +08:00
1 parent b839ed4056
commit 16ab2a6c84
97 files changed
+592 -648

No files matched your search

+3 -7
View File
@@ -24,7 +24,7 @@ const actorLabel = (t: string) => ACTOR_TYPES.find((a) => a.value === t)?.label
const ACTION_LABEL: Record<string, string> = {
"admin.setup": "初始化管理员",
"admin.login_success": "登录",
"admin.logout": "登出",
"admin.logout": "退出登录",
"admin.password_change": "修改密码",
"admin.totp_enable": "启用二次验证",
"admin.totp_disable": "停用二次验证",
@@ -36,7 +36,7 @@ const ACTION_LABEL: Record<string, string> = {
"secret.rotate": "轮换凭证",
"secret.delete": "删除凭证",
"site_account.credentials_set": "录入站点凭证",
"engine.run_cancel": "取消引擎 run",
"engine.run_cancel": "取消引擎运行",
};
const VERB: Record<string, string> = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" };
const NOUN: Record<string, string> = {
@@ -111,10 +111,7 @@ export default function AuditPage() {
return (
<>
<PageHeader
title="审计日志"
description="登录、设置、密钥与实例变更的操作留痕,新的在前。凭证内容不会写进审计。"
/>
<PageHeader title="审计日志" description="登录、设置、密钥与实例变更,新的在前。不记录凭证内容。" />
<FilterBar
search={{ value: target, onChange: setTarget, placeholder: "对象类型,如 downloader_instance" }}
onReset={
@@ -142,7 +139,6 @@ export default function AuditPage() {
onRetry={() => q.refetch()}
empty={{
title: filtered ? "没有符合条件的审计事件" : "还没有审计事件",
description: filtered ? "换个筛选条件试试。" : undefined,
}}
onRowClick={setSelected}
selectedKey={selected?.id ?? null}
+12 -12
View File
@@ -17,7 +17,7 @@ import { errorMessage } from "~/lib/api.ts";
import { engineRunLabel, formatDuration, formatRelative, spanSec as runDurationSec } from "~/lib/labels.ts";
import { formatDateTime } from "~/lib/utils.ts";
export const meta = () => [{ title: "Run 详情 · 工作流引擎" }];
export const meta = () => [{ title: "运行详情 · 工作流引擎" }];
const KIND_LABEL: Record<string, string> = {
function: "函数",
@@ -102,7 +102,7 @@ export default function EngineRunPage() {
className={buttonVariants({ variant: "ghost", size: "sm", className: "-ml-2 mb-2" })}
>
<ArrowLeft />
返回 runs
返回列表
</Link>
);
@@ -118,7 +118,7 @@ export default function EngineRunPage() {
return (
<>
{back}
<ErrorState title="加载 run 失败" error={q.error} onRetry={() => q.refetch()} />
<ErrorState title="加载失败" error={q.error} onRetry={() => q.refetch()} />
</>
);
}
@@ -150,19 +150,19 @@ export default function EngineRunPage() {
trigger={
<Button variant="destructive" size="sm">
<Ban />
取消 run
取消运行
</Button>
}
title="取消这个 run?"
description="先标记业务侧取消,再通知引擎停止;正在执行的步骤会在下一个检查点退出。"
confirmLabel="取消 run"
title="取消这次运行?"
description="正在执行的步骤会在下一个检查点退出。"
confirmLabel="取消运行"
cancelLabel="不取消"
destructive
onConfirm={async () => {
try {
const res = await cancel.mutateAsync({ id: r.id, reason: "控制台手动取消" });
if (res.canceled) toast.success("已取消");
else toast.warning("run 已结束,无需取消");
else toast.warning("运行已结束");
} catch (err) {
toast.error(errorMessage(err, "取消失败"));
throw err;
@@ -177,8 +177,8 @@ export default function EngineRunPage() {
重跑
</Button>
}
title="重跑这个 run?"
description="按原任务名和原始输入新建一个 run(新的幂等键),原 run 保持不变。已完成的文件操作由任务自己的操作账本去重。"
title="重跑这次运行?"
description="用原始输入新建一次运行,原记录不变。已完成的文件操作不会重复执行。"
confirmLabel="重跑"
cancelLabel="不重跑"
onConfirm={async () => {
@@ -226,7 +226,7 @@ export default function EngineRunPage() {
<section className="pg-slab rounded-xl p-5">
<DetailSection title={`信号(${r.signals.length})`}>
{r.signals.length === 0 ? (
<p className="text-caption text-muted-foreground">没有投递到这个 run 的信号。</p>
<p className="text-caption text-muted-foreground">没有信号。</p>
) : (
<ul className="space-y-3">
{r.signals.map((sg) => (
@@ -246,7 +246,7 @@ export default function EngineRunPage() {
<section className="pg-slab min-w-0 rounded-xl p-5">
<DetailSection title={`步骤尝试(${r.stepAttempts.length})`}>
{r.stepAttempts.length === 0 ? (
<EmptyState plain title="还没有执行任何步骤" className="py-8" />
<EmptyState plain title="还没有步骤" className="py-8" />
) : (
<Timeline items={stepItems(r.stepAttempts)} />
)}
+4 -4
View File
@@ -96,7 +96,7 @@ export default function EnginePage() {
<>
<PageHeader
title="工作流引擎"
description="持久任务引擎的运行记录,只读诊断用;业务进度请看「下载与任务」。"
description="引擎的运行记录,只读。业务进度看「下载与任务」。"
actions={
<Button
variant="outline"
@@ -144,7 +144,7 @@ export default function EnginePage() {
{s && s.activeWorkers.length > 0
? s.activeWorkers.map((w) => `${w.workerId}(${w.running})`).join("、")
: "无"}
<span className="text-muted-foreground/80">(快照,非权威注册表)</span>
<span className="text-muted-foreground/80">(快照)</span>
</span>
</div>
@@ -169,8 +169,8 @@ export default function EnginePage() {
error={runs.error}
onRetry={() => runs.refetch()}
empty={{
title: status || workflowName ? "没有符合条件的 run" : "引擎还没有运行记录",
description: status || workflowName ? "换个筛选条件试试。" : "有任务入队后会出现在这里。",
title: status || workflowName ? "没有符合条件的运行" : "引擎还没有运行记录",
description: status || workflowName ? undefined : "任务入队后出现在这里。",
}}
onRowClick={(r) => navigate(consolePath("engine", r.id))}
hasMore={!!runs.hasNextPage}
+8 -8
View File
@@ -76,7 +76,7 @@ export default function FilesTrashPage() {
if (r.restored.length) toast.success(`已还原 ${r.restored.length} 项`);
for (const f of r.failed)
toast.error(
f.code === "exists" || f.code === "conflict" ? "原位置已被占用,可用“还原到…”换个目录" : f.message,
f.code === "exists" || f.code === "conflict" ? "原位置已被占用。用“还原到…”换个目录" : f.message,
{ description: rows?.find((x) => x.id === f.id)?.originalPath },
);
},
@@ -135,7 +135,7 @@ export default function FilesTrashPage() {
{snap?.title ? (
<p className="text-meta text-muted-foreground">
删除时关联:{snap.title}
{snap.contentUnit ? ` ${snap.contentUnit}` : ""}(还原不会自动恢复资产)
{snap.contentUnit ? ` ${snap.contentUnit}` : ""}(还原后资产不会自动恢复)
</p>
) : null}
</div>
@@ -210,7 +210,7 @@ export default function FilesTrashPage() {
<>
<PageHeader
title="回收站"
description="删除的文件先移到这里,保留 7 天后由后台任务清除;期间可还原到原位置或其他目录。"
description="删除的文件在这里保留到期后自动清除,到期前可以还原。"
actions={
<>
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
@@ -262,7 +262,7 @@ export default function FilesTrashPage() {
</Button>
</>
) : (
<span className="ml-auto text-meta text-muted-foreground">立即清除仅管理员会话可用。</span>
<span className="ml-auto text-meta text-muted-foreground">立即清除需要管理员会话。</span>
)}
</div>
<DataTable
@@ -275,7 +275,7 @@ export default function FilesTrashPage() {
hasMore={trash.hasNextPage}
onLoadMore={() => void trash.fetchNextPage()}
loadingMore={trash.isFetchingNextPage}
empty={{ icon: Trash2, title: "回收站是空的", description: "在文件页删除的项会先移到这里。" }}
empty={{ icon: Trash2, title: "回收站是空的", description: "在文件页删除的项会出现在这里。" }}
/>
<ConfirmDialog
@@ -296,12 +296,12 @@ export default function FilesTrashPage() {
if (!o) setConfirmText("");
}}
title="清空回收站?"
description={`将立即清除全部 ${rows?.length ?? 0} 项(含未到期的),不可恢复。`}
description={`全部 ${rows?.length ?? 0} 项(含未到期)将被清除,不可恢复。`}
confirmLabel="清空"
destructive
onConfirm={() => {
if (confirmText.trim() !== "清空回收站") {
toast.error("请输入“清空回收站”确认");
toast.error("输入“清空回收站”以确认");
return Promise.reject(new Error("unconfirmed"));
}
return doPurge(rows?.map((r) => r.id)).then(() => setConfirmText(""));
@@ -345,7 +345,7 @@ function RestoreToDialog({
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>还原到…</DialogTitle>
<DialogDescription>{ids.length} 项 · 选一个可写的目录;同名已存在的项会失败,不会覆盖。</DialogDescription>
<DialogDescription>{ids.length} 项 · 选一个可写目录。同名项已存在时跳过,不覆盖。</DialogDescription>
</DialogHeader>
<div className="max-h-80 overflow-y-auto rounded-xl border border-border p-1.5">
<DirTree roots={roots.data ?? []} current={target} onSelect={setTarget} pickOnly />
+13 -15
View File
@@ -77,7 +77,7 @@ import { cn, formatBytes } from "~/lib/utils.ts";
const HEALTH_REASON: Record<string, string> = {
root_missing: "挂载点不存在",
sentinel_missing: "根目录里找不到哨兵文件,可能挂载未生效",
sentinel_missing: "根目录里没有哨兵文件,挂载可能未生效",
timeout: "探测超时",
permission_denied: "没有读取权限",
};
@@ -247,7 +247,7 @@ export default function FilesPage() {
setQInput("");
};
const open = (e: FileEntry) => {
if (e.escape) return toast.error("链接指向登记根之外,已禁用");
if (e.escape) return toast.error("链接指向登记目录之外,不能打开");
if (e.type === "dir") go({ root: rootId, path: e.path });
else patch({ sel: e.path });
};
@@ -267,9 +267,7 @@ export default function FilesPage() {
onSuccess: (r) => {
const ok = r.items.filter((i) => i.status === "ok").length;
toast.success(`探测完成:${ok}/${r.items.length} 个读到媒体信息`, {
description: r.items.some((i) => i.savedToAsset)
? "已登记资产已写回媒体信息"
: "未登记文件的结果缓存 24 小时",
description: r.items.some((i) => i.savedToAsset) ? "媒体信息已写入资产" : "未登记文件的结果缓存 24 小时",
});
},
onError: (err) => toast.error(filesErrorText(err, "探测失败")),
@@ -308,7 +306,7 @@ export default function FilesPage() {
if (multi)
items.push({
key: "wizard",
label: `整理向导(${list.length} 项)`,
label: `整理 ${list.length} 项…`,
icon: Wand2,
disabled: why("organize"),
onSelect: () => setDialog({ kind: "wizard", entries: list }),
@@ -334,7 +332,7 @@ export default function FilesPage() {
key: "copy",
label: "复制到…",
icon: Copy,
disabled: e.escape ? "链接指向登记根之外,已禁用" : null,
disabled: e.escape ? "链接指向登记目录之外" : null,
onSelect: () => setDialog({ kind: "transfer", items: refs(list), mode: "copy" }),
},
);
@@ -422,7 +420,7 @@ export default function FilesPage() {
if (items.every((i) => i.root === target.root && parentRel(i.path) === target.path)) return;
}
const tRoot = rootList.find((r) => r.id === target.root);
if (!tRoot?.writable) return toast.error("只能拖到可写的媒体库根");
if (!tRoot?.writable) return toast.error("只能拖到可写的库根");
setDialog({ kind: "transfer", items, mode: ev.altKey ? "copy" : "move", target });
};
@@ -443,8 +441,8 @@ export default function FilesPage() {
<EmptyState
plain
icon={FolderTree}
title="还没有可浏览的根"
description="在系统设置里登记媒体库根或下载器路径映射后出现在这里。"
title="还没有库根"
description="在系统设置里登记库根或下载器路径映射。"
className="px-2 py-8"
/>
) : null}
@@ -473,7 +471,7 @@ export default function FilesPage() {
<p className="text-lead font-medium text-foreground">{root.name} 离线</p>
<p className="mt-1.5 max-w-md text-caption text-muted-foreground">
{offline ? filesErrorText(list.error) : healthReasonText(root.healthReason)}
。不会把离线当成空目录,也不会改变任何资产状态。
。离线期间资产状态不变。
</p>
<p className="mt-1 text-meta text-muted-foreground">上次检查:{relTime(root.checkedAt)}</p>
<Button
@@ -506,7 +504,7 @@ export default function FilesPage() {
plain
icon={SearchX}
title="没有符合筛选的项"
description={q ? `搜索“${q}”没有结果;支持子串与 glob(如 *.mkv)。` : undefined}
description={q ? `没有匹配“${q}”的项。支持子串与 glob,如 *.mkv` : undefined}
action={
<Button variant="outline" size="sm" onClick={clearFilters}>
<X />
@@ -519,7 +517,7 @@ export default function FilesPage() {
plain
icon={FolderOpen}
title="空目录"
description={hidden ? undefined : "隐藏文件未显示,可在工具栏打开。"}
description={hidden ? undefined : "隐藏文件未显示。"}
action={
root.writable ? (
<Button variant="outline" size="sm" onClick={() => setDialog({ kind: "mkdir" })}>
@@ -617,7 +615,7 @@ export default function FilesPage() {
<>
<PageHeader
title="文件"
description="只浏览已登记的库根与下载目录;每行带业务状态,删除进回收站,改名同步资产与路径绑定。"
description="已登记的库根与下载目录。删除先进回收站,改名同步更新资产。"
actions={
<>
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
@@ -790,7 +788,7 @@ export default function FilesPage() {
{first?.truncated ? (
<div role="status" className="border-b border-warning/30 bg-warning/10 px-3 py-1.5 text-meta text-warning">
目录过大,只读取了前 {first.total.toLocaleString("zh-CN")} 项。请用搜索或进入子目录缩小范围。
目录过大,只读取了前 {first.total.toLocaleString("zh-CN")} 项。用搜索或进入子目录缩小范围。
</div>
) : null}
+5 -8
View File
@@ -132,8 +132,8 @@ function PoolTab() {
selectedKey={openId}
empty={{
icon: SearchX,
title: filtered ? "没有符合条件的待识别资源" : "待识别池是空的",
description: "识别证据不足或冲突的资源会留在这里,可按原始标题找回;新证据到来时会自动重试。",
title: filtered ? "没有符合条件的待识别资源" : "没有待识别资源",
description: "证据不足或冲突的资源会出现在这里,有新证据时自动重试。",
}}
hasMore={pool.hasNextPage}
onLoadMore={() => pool.fetchNextPage()}
@@ -161,7 +161,7 @@ function ExternalTab() {
header: "外部身份",
cell: (r) => (
<span className="inline-flex items-center gap-1.5 font-mono text-caption break-all text-foreground">
{r.locked ? <Lock className="h-3 w-3 shrink-0 text-muted-foreground" aria-label="人工锁" /> : null}
{r.locked ? <Lock className="h-3 w-3 shrink-0 text-muted-foreground" aria-label="已锁定" /> : null}
{identityRef(r)}
</span>
),
@@ -233,7 +233,7 @@ function ExternalTab() {
empty={{
icon: Fingerprint,
title: "没有外部身份",
description: "TMDB 等外部 ID 只是辅助映射;没有记录时作品仍可先建本地档案入库。",
description: "没有外部身份的作品也能建档入库。",
}}
hasMore={list.hasNextPage}
onLoadMore={() => list.fetchNextPage()}
@@ -251,10 +251,7 @@ export default function IdentityPage() {
const tab = (TABS as readonly string[]).includes(params.get("tab") ?? "") ? params.get("tab")! : "pool";
return (
<>
<PageHeader
title="身份与元数据"
description="本地 UUID 是主身份,外部 ID 是辅助映射。自动模糊匹配不会覆盖人工锁定的关系。"
/>
<PageHeader title="身份与元数据" description="以本地 ID 为准。自动匹配不覆盖人工锁定的外部身份。" />
<Tabs
value={tab}
onValueChange={(v) =>
+7 -9
View File
@@ -41,10 +41,10 @@ function entityOf(d: Decision): string | null {
}
const IMPACT_KEY: Record<string, string> = {
slots: "收录目标",
slots: "版本目标",
units: "内容单元",
files: "文件",
releases: "来源资源",
releases: "资源",
tasks: "任务",
entities: "作品",
};
@@ -113,12 +113,10 @@ function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
description={
<>
{option.description ? `${option.description}。` : ""}
{option.capabilityId
? `将以能力 ${option.capabilityId} 执行,仍经授权校验。`
: "处理结果会记录并通知相关任务。"}
{option.capabilityId ? `由能力 ${option.capabilityId} 执行,仍做授权校验。` : null}
</>
}
confirmLabel="确认"
confirmLabel={option.label}
onConfirm={() =>
resolve.mutateAsync({ id: d.id, optionKey: option.key, note: note.trim() || undefined }).then(
() => toast.success(`已处理:${option.label}`),
@@ -145,7 +143,7 @@ function DismissButton({ d }: { d: Decision }) {
</Button>
}
title="忽略这条事项?"
description="只关闭提醒,不改变失败本身;同根因的新证据出现时仍会重新提出。"
description="只关闭提醒,问题本身不变;同一根因出现新证据时会重新提出。"
confirmLabel="忽略"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, dismiss: true }).then(
@@ -279,7 +277,7 @@ export default function InboxPage() {
<>
<PageHeader
title="待处理中心"
description="Agent 先在授权内调查与有限重试,解决不了的才进这里;同一作品同类问题合并展示"
description="Agent 调查和重试后仍未解决的事项,按作品和问题类型合并"
actions={
<>
<span className="hidden items-center gap-1.5 text-meta text-muted-foreground sm:inline-flex">
@@ -345,7 +343,7 @@ export default function InboxPage() {
<EmptyState
icon={CheckCheck}
title={isOpenView ? "没有需要你处理的事项" : "没有符合条件的事项"}
description={isOpenView ? "系统在正常推进目标;新的待决定事项会实时出现在这里。" : "换个状态或类型看看。"}
description={isOpenView ? "新事项会实时出现在这里。" : undefined}
/>
) : (
<div className="space-y-4">
+27 -30
View File
@@ -94,13 +94,13 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) {
const rootOf = (id: string | null) => roots.data?.items.find((r) => r.id === id);
const bindings = detail.pathBindings.filter((b) => b.status !== "superseded");
return (
<Panel title="实际路径与库根" description="路径绑定一旦建立不随译名变化;执行进程可访问的绝对路径按库根拼接">
<Panel title="实际路径与库根" description="路径绑定建立后不随译名变化">
{bindings.length === 0 && detail.assets.length === 0 ? (
<EmptyState
plain
icon={FolderTree}
title="还没有入库文件"
description="收录目标满足后,文件位置会显示在这里。"
description="版本目标满足后,文件位置显示在这里。"
className="py-8"
/>
) : (
@@ -171,17 +171,23 @@ function IdentityPanel({ detail }: { detail: LibraryDetail }) {
const candidates = detail.identities.filter((i) => i.status === "proposed" || i.status === "conflicted");
return (
<Panel
title="外部身份映射"
description="本地 UUID 是主身份,TMDB 等外部 ID 是可修订的辅助映射"
title="外部身份"
description="以本地 ID 为准,外部身份可修改"
actions={
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<PencilLine />
改映射
修改
</Button>
}
>
{list.length === 0 ? (
<EmptyState plain icon={SearchX} title="还没有外部映射" description="可以手动指定 TMDB id。" className="py-8" />
<EmptyState
plain
icon={SearchX}
title="还没有外部身份"
description="点「修改」手动指定 TMDB ID。"
className="py-8"
/>
) : (
<ul className="space-y-2">
{list.map((i) => (
@@ -244,7 +250,7 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
return (
<Panel
title="元数据"
description={`字段级来源;锁定的字段刷新时不会被改写 · 最近刷新 ${formatDateTime(detail.entity.lastMetadataRefreshAt)}`}
description={`锁定的字段刷新时不变 · 最近刷新 ${formatDateTime(detail.entity.lastMetadataRefreshAt)}`}
actions={
<Button
variant="outline"
@@ -252,7 +258,7 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
disabled={refresh.isPending}
onClick={() =>
refresh.mutate(undefined, {
onSuccess: () => toast.success("已提交元数据刷新(锁定字段保持不变)"),
onSuccess: () => toast.success("已提交刷新"),
onError: (err) => toast.error(errorMessage(err, "刷新失败")),
})
}
@@ -299,7 +305,7 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
</Button>
}
title={`解锁「${ENTITY_FIELD_LABEL[field]}」?`}
description="取消人工覆盖与锁定,下次刷新会按来源优先级重新取值。"
description="取消人工修改,下次刷新按来源优先级取值。"
confirmLabel="解锁"
onConfirm={() =>
unlock.mutateAsync(field).then(
@@ -348,15 +354,15 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUnit[] }) {
if (detail.slots.length === 0) {
return (
<Panel title="收录目标(Slot)">
<Panel title="版本目标">
<EmptyState
plain
icon={CalendarClock}
title="这部作品还没有收录目标"
description="在订阅与收录规则里新建订阅或 Slot,系统会按目标持续补齐与升级。"
title="还没有版本目标"
description="新建订阅或版本目标后,按目标补齐和升级。"
action={
<Button asChild variant="outline" size="sm">
<Link to={consolePath("subscriptions")}>去订阅与收录规则</Link>
<Link to={consolePath("subscriptions")}>去订阅</Link>
</Button>
}
className="py-8"
@@ -366,7 +372,7 @@ function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUn
}
const assetOf = (id: string | null) => detail.assets.find((a) => a.asset.id === id);
return (
<Panel title="收录目标(Slot)" description="每个目标独立判断硬条件、软偏好与升级;状态均配文字">
<Panel title="版本目标" description="每个目标单独判断硬条件、软偏好和升级">
<div className="grid gap-3 xl:grid-cols-2">
{detail.slots.map((s) => {
const cur = assetOf(s.currentAssetId);
@@ -449,11 +455,7 @@ function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDet
a.segment.localeCompare(b.segment),
);
return (
<Panel
title="内容单元"
description="季 / 集 / 期与分段;临时单元是按解析结果先建的,确认或改编号后锁定"
bodyClassName="p-0 sm:p-0"
>
<Panel title="内容单元" description="临时单元按解析结果创建,确认或改编号后锁定" bodyClassName="p-0 sm:p-0">
<DataTable
plain
columns={columns}
@@ -463,9 +465,8 @@ function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDet
error={entity.error}
onRetry={() => entity.refetch()}
empty={{
title: detail.entity.kind === "movie" ? "电影没有分集单元" : "还没有内容单元",
description:
detail.entity.kind === "movie" ? "电影按内容版本核对完整性。" : "识别到新集或期后会自动建临时单元。",
title: detail.entity.kind === "movie" ? "电影没有季和集" : "还没有内容单元",
description: detail.entity.kind === "movie" ? "电影按内容版本核对完整性。" : "识别到新集后会建临时单元。",
}}
/>
</Panel>
@@ -548,11 +549,7 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
},
];
return (
<Panel
title="下载历史与校验结果"
description="下载与入库串成一个业务过程;整理记录含逐文件核验"
bodyClassName="p-0 sm:p-0"
>
<Panel title="下载历史与校验结果" bodyClassName="p-0 sm:p-0">
<DataTable
plain
columns={columns}
@@ -595,7 +592,7 @@ export default function LibraryDetailPage() {
{back}
<ErrorState
title={notFound ? "作品不存在" : "作品加载失败"}
error={notFound ? "这个 ID 没有对应的作品,可能已被删除。" : detail.error}
error={notFound ? "找不到这个 ID 的作品。" : detail.error}
onRetry={notFound ? undefined : () => detail.refetch()}
/>
</>
@@ -638,13 +635,13 @@ export default function LibraryDetailPage() {
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("subscriptions")}?mediaEntityId=${e.id}`}>
<CalendarClock />
订阅与目标
查看订阅
</Link>
</Button>
{e.status !== "merged" ? (
<Button variant="outline" size="sm" onClick={() => setMerging(true)}>
<GitMerge />
合并到另一作品
合并作品
</Button>
) : null}
</div>
+5 -6
View File
@@ -88,7 +88,7 @@ export default function LibraryPage() {
</div>
),
},
{ key: "slots", header: "收录目标", cell: (r) => <SlotSummary counts={r.slotCounts} /> },
{ key: "slots", header: "版本目标", cell: (r) => <SlotSummary counts={r.slotCounts} /> },
{
key: "assets",
header: "资产",
@@ -123,7 +123,6 @@ export default function LibraryPage() {
<>
<PageHeader
title="媒体库"
description="已登记资产或正在追踪的作品;每个作品按独立收录目标(Slot)显示满足情况"
actions={
<Button variant="outline" size="sm" onClick={() => list.refetch()} disabled={list.isFetching}>
<RotateCw className={list.isFetching ? "animate-spin" : undefined} />
@@ -148,14 +147,14 @@ export default function LibraryPage() {
onRetry={() => list.refetch()}
empty={
filtered
? { title: "没有符合条件的作品", description: "换个关键字或清除筛选试试。", icon: LibraryIcon }
? { title: "没有符合条件的作品", description: "换个关键字或清除筛选。", icon: LibraryIcon }
: {
title: "媒体库还是空的",
description: "登记库根并完成首次扫描,或新建订阅后,作品会出现在这里。",
title: "媒体库为空",
description: "扫描库根或新建订阅后,作品会出现在这里。",
icon: LibraryIcon,
action: (
<Button asChild variant="outline" size="sm">
<Link to={consolePath("subscriptions")}>去新建订阅</Link>
<Link to={consolePath("subscriptions")}>新建订阅</Link>
</Button>
),
}
+3 -3
View File
@@ -12,8 +12,8 @@ import { Button } from "~/components/ui/button.tsx";
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
const ACTION_LABEL = {
copy: "Copy",
move: "Move",
copy: "复制",
move: "移动",
rename: "重命名",
relocate: "目录调整",
hardlink: "硬链接",
@@ -226,7 +226,7 @@ export default function OrganizeRecordPage() {
</ul>
) : (
<p className="px-4 pb-4 text-caption text-muted-foreground">
{r.status === "queued" ? "排队中,尚未生成文件明细。" : "没有文件级结果。"}
{r.status === "queued" ? "排队中,还没有文件明细。" : "没有文件级结果。"}
</p>
)}
</section>
+4 -4
View File
@@ -15,8 +15,8 @@ import { relTime } from "~/components/workbench/release-fields.ts";
import { useSse } from "~/lib/sse.ts";
const ACTION_LABEL = {
copy: "Copy",
move: "Move",
copy: "复制",
move: "移动",
rename: "重命名",
relocate: "目录调整",
hardlink: "硬链接",
@@ -111,7 +111,7 @@ export default function OrganizePage() {
<>
<PageHeader
title="整理记录"
description="Copy/Move 入库前后路径、动作与逐文件结果;自动与手动整理产生同格式记录。"
description="每次整理的前后路径与逐文件结果,自动和手动整理都记在这里。"
actions={
<Button size="sm" onClick={() => setOrganizing(true)}>
<FolderInput />
@@ -165,7 +165,7 @@ export default function OrganizePage() {
empty={{
icon: FileStack,
title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
description: "下载完成后自动整理,或点“手动整理”选择文件发起。",
description: "下载完成后会自动整理,也可以点“手动整理”。",
}}
/>
{organizing ? <OrganizeDialog open onOpenChange={(o) => !o && setOrganizing(false)} /> : null}
+11 -14
View File
@@ -110,7 +110,7 @@ export default function PreferencesPage() {
const next = t.status === "active" ? "disabled" : "active";
try {
await update.mutateAsync({ id: t.id, status: next });
toast.success(next === "active" ? "已重新启用,同语义抑制已解除" : "已停用,留下抑制记录");
toast.success(next === "active" ? "已启用,抑制已解除" : "已停用");
} catch (err) {
toast.error(errorMessage(err));
}
@@ -125,7 +125,7 @@ export default function PreferencesPage() {
at: s.createdAt,
ts: Date.parse(s.createdAt),
tone: "warning",
title: `「${label}」被${supReason(s.reason)},写入抑制记录`,
title: `「${label}」加入抑制(${supReason(s.reason)})`,
meta: actorName(s.suppressedBy),
});
if (s.liftedAt)
@@ -158,7 +158,7 @@ export default function PreferencesPage() {
<>
<PageHeader
title="偏好标签"
description="一个偏好一个标签。软偏好影响排序,硬规则直接排除;手动取消的偏好不会被 Agent 自动加回。"
description="软偏好影响排序,硬规则直接排除。手动取消的偏好 Agent 不会再加回。"
actions={
<Button onClick={() => setForm({ open: true, tag: null })}>
<Plus />
@@ -201,10 +201,10 @@ export default function PreferencesPage() {
<EmptyState
icon={Tags}
title={filtered ? "没有符合条件的偏好" : "还没有偏好标签"}
description="偏好可以在这里添加,也可以在 Agent 会话里说「以后都这样」形成长期偏好。"
description="点「添加偏好」,或在 Agent 会话里说明长期偏好。"
/>
) : (
<Panel title="偏好" description={`${tags.data.items.length} 个标签,点开看规则与证据`}>
<Panel title="偏好" description={`${tags.data.items.length} 个标签`}>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{tags.data.items.map((t) => (
<TagCard key={t.id} tag={t} onOpen={() => setSelectedId(t.id)} />
@@ -216,10 +216,7 @@ export default function PreferencesPage() {
<Panel title="抑制记录" description={`${activeSups.length} 条生效中`} bodyClassName="space-y-3">
<div className="flex gap-2 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-foreground">
<ShieldOff className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p>
手动停用、删除或修改语义的偏好会留下抑制记录,Agent
不会根据同一旧证据自动加回;只有你重新启用、重新添加或在这里明确解除后才会恢复。
</p>
<p>停用、删除或修改语义的偏好会留下抑制记录,Agent 不会按旧证据加回。重新启用、重新添加或解除抑制后恢复。</p>
</div>
{sups.isPending ? (
<LoadingState rows={2} />
@@ -249,7 +246,7 @@ export default function PreferencesPage() {
)}
</Panel>
<Panel title="历史" description="停用、删除、解除抑制的记录(新 → 旧)">
<Panel title="历史" description="停用、删除、解除抑制(新 → 旧)">
{sups.isPending || deleted.isPending ? (
<LoadingState rows={2} />
) : history.length === 0 ? (
@@ -271,13 +268,13 @@ export default function PreferencesPage() {
<ConfirmDialog
trigger={<Button variant="destructive">删除</Button>}
title={`删除「${selected.label}」?`}
description="删除后会留下抑制记录,Agent 不会据同一旧证据自动加回;以后需要可重新添加。"
description="删除后留下抑制记录,Agent 不会按旧证据加回。"
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(selected.id);
toast.success("已删除,留下抑制记录");
toast.success("已删除");
setSelectedId(null);
} catch (err) {
toast.error(errorMessage(err));
@@ -344,7 +341,7 @@ function TagRevisions({ id }: { id: string }) {
if (q.isPending) return <LoadingState rows={2} />;
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
if (q.data.length === 0)
return <p className="text-caption text-muted-foreground">没有修订记录(W4 之前创建的标签不带历史)。</p>;
return <p className="text-caption text-muted-foreground">没有修订记录。W4 之前创建的标签没有历史。</p>;
return (
<ul className="space-y-2">
{q.data.map((r) => (
@@ -396,7 +393,7 @@ function SuppressionRow({
</Button>
}
title="解除这条抑制?"
description="解除后,Agent 可以再次根据证据提出同一偏好(仍需走正常授权)。标签本身不会自动恢复。"
description="解除后,Agent 可按证据再次提出这个偏好,仍需授权。标签不会恢复。"
confirmLabel="解除"
onConfirm={onLift}
/>
+16 -27
View File
@@ -155,7 +155,7 @@ export default function SiteWorkbenchPage() {
<span className="flex flex-wrap items-center gap-1.5">
{health ? <StatusBadge tone={health.tone}>{health.label}</StatusBadge> : null}
<StatusBadge tone={account.tone}>账号:{account.label}</StatusBadge>
{!s.enabled ? <StatusBadge tone="warning">站点已停用,只展示历史数据</StatusBadge> : null}
{!s.enabled ? <StatusBadge tone="warning">站点已停用,只显示历史数据</StatusBadge> : null}
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
<span className="text-meta text-muted-foreground">
快照 {relTime(coverage?.lastSnapshotAt ?? s.lastSnapshotAt)}
@@ -206,7 +206,7 @@ export default function SiteWorkbenchPage() {
snap.mutate(
{ id: siteId, maxPages: 2 },
{
onSuccess: () => toast.success("已排队快照,完成后刷新列表"),
onSuccess: () => toast.success("快照已排队"),
onError: (e) => toast.error(errorMessage(e)),
},
)
@@ -218,7 +218,7 @@ export default function SiteWorkbenchPage() {
<Button
size="sm"
disabled={!q || !s?.enabled || (live && releases.isFetching)}
title={q ? "绕过本地快照,向站点实时搜索当前关键词" : "先输入搜索词"}
title={q ? "跳过本地快照,直接在站点搜索" : "先输入搜索词"}
onClick={() => {
if (live) void releases.refetch();
else setLive(true);
@@ -273,12 +273,7 @@ export default function SiteWorkbenchPage() {
{releases.isPending ? (
<LoadingState rows={6} className="p-4" />
) : releases.error && !releases.data ? (
<ErrorState
plain
title="读取站点资源失败"
error={releases.error}
onRetry={() => void releases.refetch()}
/>
<ErrorState plain title="读取资源失败" error={releases.error} onRetry={() => void releases.refetch()} />
) : rows.length === 0 ? (
<EmptyState
plain
@@ -288,12 +283,12 @@ export default function SiteWorkbenchPage() {
}
description={
all.length
? "H&R / 做种 / 大小 / 上传时间只在已加载结果中筛选,可以加载更多或放宽条件。"
? "H&R、做种、大小、上传时间只筛选已加载的结果。加载更多或放宽条件。"
: coverage?.liveError
? `实时补查未成功(${coverage.liveError}),这不代表站点没有该资源。`
? `实时补查失败(${coverage.liveError}),站点上可能仍有该资源。`
: q
? "可以点“实时补查”向站点直接搜索。"
: "点“快照”抓取站点列表;快照受站点限流。"
? "点“实时补查”直接在站点搜索。"
: "点“快照”抓取站点列表。"
}
/>
) : (
@@ -338,13 +333,8 @@ export default function SiteWorkbenchPage() {
<TabsContent value="origin">
<EmptyState
icon={Globe}
title="原站视图在 M2 提供"
description={
<>
原站视图会接入后端浏览器会话(camofox-browser),在同一会话里查看真实页面、人工登录或处理验证,并与 Agent
交接控制权。首版只提供整理视图;需要看原站时,整理视图的详情里保留了资源的站点 ID 与来源时间。
</>
}
title="原站视图尚未提供"
description={<>计划在 M2 提供。资源详情里有站点 ID 和来源时间,可据此到原站查看。</>}
/>
</TabsContent>
</Tabs>
@@ -367,20 +357,19 @@ function CoverageBanner({
refreshError: unknown;
}) {
const notes: { tone: "warning" | "neutral" | "success"; text: string }[] = [];
if (refreshError)
notes.push({ tone: "warning", text: `刷新失败,保留上次数据并标为过期:${errorMessage(refreshError)}` });
if (refreshError) notes.push({ tone: "warning", text: `刷新失败,显示上次数据:${errorMessage(refreshError)}` });
if (coverage) {
if (!coverage.siteEnabled) notes.push({ tone: "warning", text: "站点已停用,只展示历史数据" });
if (!coverage.siteEnabled) notes.push({ tone: "warning", text: "站点已停用,只显示历史数据" });
if (coverage.stale)
notes.push({
tone: "warning",
text: coverage.lastSnapshotAt
? `快照已过期(上次 ${relTime(coverage.lastSnapshotAt)}),列表为既有数据`
: "从未快照,列表只来自 Feed 观察与实时补查",
? `快照已过期(上次 ${relTime(coverage.lastSnapshotAt)})`
: "没有快照,列表只来自 Feed 和实时补查",
});
if (coverage.liveError) notes.push({ tone: "warning", text: `实时补查失败:${coverage.liveError};已保留本地数据` });
if (coverage.liveError) notes.push({ tone: "warning", text: `实时补查失败:${coverage.liveError}` });
else if (coverage.live)
notes.push({ tone: "success", text: coverage.coalesced ? "已合并到进行中的同一实时搜索" : "已实时补查站点" });
notes.push({ tone: "success", text: coverage.coalesced ? "已合并到进行中的同一搜索" : "已实时补查" });
}
if (!notes.length) return null;
return (
+4 -5
View File
@@ -51,7 +51,7 @@ function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
toggle.mutate(
{ id: site.id, enabled: v },
{
onSuccess: () => toast.success(v ? `已启用 ${site.name}` : `已停用 ${site.name},自动访问停止`),
onSuccess: () => toast.success(v ? `已启用 ${site.name}` : `已停用 ${site.name}`),
onError: (e) => toast.error(errorMessage(e)),
},
)
@@ -87,12 +87,12 @@ function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
variant="outline"
size="sm"
disabled={!site.enabled || snap.isPending}
title={site.enabled ? "立即快照(受站点限流)" : "站点已停用"}
title={site.enabled ? "抓取快照(受站点限流)" : "站点已停用"}
onClick={() =>
snap.mutate(
{ id: site.id },
{
onSuccess: () => toast.success("已排队快照,完成后自动刷新"),
onSuccess: () => toast.success("快照已排队"),
onError: (e) => toast.error(errorMessage(e)),
},
)
@@ -122,7 +122,6 @@ export default function SitesPage() {
<>
<PageHeader
title="PT 站点工作台"
description="按统一格式浏览站点资源,并叠加本地库存;下载走与自动收录相同的提交流程。"
actions={
<Button variant="outline" size="sm" onClick={() => void sites.refetch()} disabled={sites.isFetching}>
<RefreshCw className={sites.isFetching ? "animate-spin" : undefined} />
@@ -140,7 +139,7 @@ export default function SitesPage() {
<EmptyState
icon={Globe}
title="还没有登记站点"
description="在“来源 / Feed / 快照”或系统设置里登记站点与账号后,这里会列出可浏览的站点。"
description="在来源页或系统设置里登记站点和账号后,站点会列在这里。"
action={
<Button asChild variant="outline" size="sm">
<Link to="/console/sources">去来源页</Link>
+5 -6
View File
@@ -62,14 +62,14 @@ function FeedActions({ f }: { f: FeedSource }) {
<Button
variant="ghost"
size="icon-sm"
title="立即抓取(仍受站点限流)"
title="立即抓取(受站点限流)"
aria-label="立即抓取"
disabled={!f.enabled || fetchNow.isPending}
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
fetchNow.mutate(f.id, {
onSuccess: () => toast.success("已排队抓取"),
onSuccess: () => toast.success("抓取已排队"),
onError: (err) => toast.error(errorMessage(err)),
});
}}
@@ -161,7 +161,6 @@ export default function SourcesPage() {
<>
<PageHeader
title="来源 / Feed / 快照"
description="RSS 与站点快照的抓取状态、新鲜度与健康;站点不可访问时不会把零条目当作“没有更新”。"
actions={
<Button size="sm" onClick={() => setEditing("new")}>
<Plus />
@@ -216,7 +215,7 @@ export default function SourcesPage() {
empty={{
icon: Rss,
title: health || siteFilter ? "没有符合筛选的 Feed" : "还没有 Feed",
description: "新建 RSS Feed 后按轮询策略抓取,新条目进入识别与收录评估。",
description: health || siteFilter ? undefined : "新建 Feed 后按轮询间隔抓取,新资源进入识别和收录评估。",
}}
/>
</TabsContent>
@@ -226,7 +225,7 @@ export default function SourcesPage() {
) : sites.error ? (
<ErrorState error={sites.error} onRetry={() => void sites.refetch()} />
) : !sites.data.items.length ? (
<EmptyState title="还没有登记站点" description="登记站点后可以在这里看快照覆盖范围与新鲜度。" />
<EmptyState title="还没有登记站点" description="登记站点后,这里显示快照覆盖范围和新鲜度。" />
) : (
<div className="grid gap-4 lg:grid-cols-2">
{sites.data.items.map((s) => (
@@ -254,7 +253,7 @@ export default function SourcesPage() {
action.mutate(
{ id: open.id, action: "reset" },
{
onSuccess: () => toast.success("已关闭熔断并清空退避"),
onSuccess: () => toast.success("熔断已复位"),
onError: (e) => toast.error(errorMessage(e)),
},
)
+19 -26
View File
@@ -73,7 +73,6 @@ export default function SubscriptionsPage() {
<>
<PageHeader
title="订阅与收录规则"
description="订阅决定追什么,收录目标决定要哪种版本,策略决定默认怎么做;启用即授权范围内的持续操作。"
actions={
tab === "subscriptions" ? (
<Button onClick={() => setCreatingSub(true)}>
@@ -240,7 +239,7 @@ function SubscriptionsTab() {
empty={{
icon: CalendarClock,
title: filtered ? "没有符合条件的订阅" : "还没有订阅",
description: "订阅一部作品后,系统按收录目标持续补齐与升级;也可以只填标题,等作品建档后自动关联。",
description: filtered ? undefined : "订阅的作品会按版本目标补齐和升级。点「新建订阅」添加。",
}}
/>
<SubscriptionSheet sub={open} onClose={() => setOpenId(null)} />
@@ -256,7 +255,7 @@ function SubscriptionSheet({ sub, onClose }: { sub: Subscription | null; onClose
const runSearch = () =>
sub &&
search.mutate(sub.id, {
onSuccess: () => toast.success("已开始在已开启站点搜索,结果会按收录目标自动评估"),
onSuccess: () => toast.success("已在启用的站点开始搜索"),
onError: (e) => toast.error(errorMessage(e)),
});
const title = useEntityTitle(sub?.mediaEntityId);
@@ -284,20 +283,16 @@ function SubscriptionSheet({ sub, onClose }: { sub: Subscription | null; onClose
{sub.mediaEntityId ? (
<Button variant="outline" onClick={runSearch} disabled={search.isPending}>
<Search />
{search.isPending ? "搜索中…" : "立即搜索"}
{search.isPending ? "搜索中…" : "搜索资源"}
</Button>
) : null}
{sub.status === "active" || sub.status === "pending" ? (
<Button
variant="outline"
onClick={() => setStatus("paused", "已暂停,不再发起新下载")}
disabled={update.isPending}
>
<Button variant="outline" onClick={() => setStatus("paused", "已暂停")} disabled={update.isPending}>
<Pause />
暂停
</Button>
) : (
<Button variant="outline" onClick={() => setStatus("active", "已恢复追踪")} disabled={update.isPending}>
<Button variant="outline" onClick={() => setStatus("active", "已恢复")} disabled={update.isPending}>
<Play />
恢复
</Button>
@@ -310,7 +305,7 @@ function SubscriptionSheet({ sub, onClose }: { sub: Subscription | null; onClose
</Button>
}
title="取消这个订阅?"
description="不再追踪后续内容;已入库文件、已有收录目标与下载历史都保留。"
description="不再追踪后续内容。已入库文件、版本目标和下载记录保留。"
confirmLabel="取消订阅"
destructive
onConfirm={() => setStatus("cancelled", "订阅已取消")}
@@ -329,7 +324,7 @@ function SubscriptionSheet({ sub, onClose }: { sub: Subscription | null; onClose
label: "作品",
value: sub.mediaEntityId ? (
<Link className="underline-offset-4 hover:underline" to={consolePath("library", sub.mediaEntityId)}>
打开媒体详情
查看作品
</Link>
) : (
`按标题追踪:${sub.titleQuery ?? "—"}(尚未建档)`
@@ -356,11 +351,11 @@ function SubscriptionSheet({ sub, onClose }: { sub: Subscription | null; onClose
<DetailSection title="追踪方式">
<SubscriptionEditForm key={`${sub.id}-${sub.updatedAt}`} sub={sub} />
</DetailSection>
<DetailSection title="收录目标">
<DetailSection title="版本目标">
{sub.mediaEntityId ? (
<SlotsPanel mediaEntityId={sub.mediaEntityId} subscriptionId={sub.id} />
) : (
<p className="text-caption text-muted-foreground">作品建档并关联后才能设置收录目标。</p>
<p className="text-caption text-muted-foreground">作品建档后才能设置版本目标。</p>
)}
</DetailSection>
</>
@@ -396,7 +391,7 @@ function ProgressPanel({ subId, readOnly }: { subId: string; readOnly: boolean }
{ id: subId, seasonNumber: Number(season), total: Number(total), locked },
{
onSuccess: () => {
toast.success(locked ? "总集数已锁定" : "总集数已更新(只升)");
toast.success(locked ? "总集数已锁定" : "总集数已更新");
setTotalValue("");
},
onError: (e) => toast.error(errorMessage(e)),
@@ -404,10 +399,8 @@ function ProgressPanel({ subId, readOnly }: { subId: string; readOnly: boolean }
);
return (
<div className="space-y-3">
{!p.completable ? (
<p className="text-meta text-muted-foreground">总集数未知或元数据未就绪:继续追踪,不会自动结束。</p>
) : null}
{p.seasons.length === 0 ? <p className="text-caption text-muted-foreground">还没有季信息。</p> : null}
{!p.completable ? <p className="text-meta text-muted-foreground">总集数未知,订阅不会自动结束。</p> : null}
{p.seasons.length === 0 ? <p className="text-caption text-muted-foreground">没有季信息。</p> : null}
{p.seasons.map((s) => (
<div key={s.seasonNumber} className="space-y-1.5">
<p className="text-sm font-medium text-foreground">
@@ -439,7 +432,7 @@ function ProgressPanel({ subId, readOnly }: { subId: string; readOnly: boolean }
<ul className="space-y-0.5 text-meta text-muted-foreground">
{p.slots.map((x) => (
<li key={x.slotId}>
{x.name}:在途 {x.inflightPlans.length} 个计划,延后 {x.deferred} 个发布
{x.name}:在途 {x.inflightPlans.length} 个计划,延后 {x.deferred} 个资源
</li>
))}
</ul>
@@ -532,7 +525,7 @@ function SubscriptionEditForm({ sub }: { sub: Subscription }) {
</FormField>
</div>
{mode === "continuous" && scope === "following_seasons" && sub.mediaKind !== "movie" ? (
<p className="text-meta text-muted-foreground">特别篇、衍生节目和同名重启不直接视作下一季,按证据识别。</p>
<p className="text-meta text-muted-foreground">特别篇、衍生节目和同名重启不算作下一季。</p>
) : null}
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={save} disabled={!dirty || update.isPending}>
@@ -616,14 +609,14 @@ function CreateSubscriptionDialog({ onClose }: { onClose: () => void }) {
const v = SubscriptionCreate.safeParse(body);
if (!v.success) {
const errs = zodFieldErrors(v.error.issues);
if (errs[""]) errs.target = byTitle ? "请填写要追踪的标题" : "请选择一部作品";
if (errs[""]) errs.target = byTitle ? "填写标题" : "选择作品";
setErrors(errs);
return;
}
setErrors({});
create.mutate(v.data, {
onSuccess: () => {
toast.success("订阅已创建,将按收录目标开始检查");
toast.success("订阅已创建");
onClose();
},
onError: (e) => toast.error(errorMessage(e)),
@@ -635,7 +628,7 @@ function CreateSubscriptionDialog({ onClose }: { onClose: () => void }) {
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>新建订阅</DialogTitle>
<DialogDescription>选择库里的作品,或先按标题追踪,作品建档后自动关联。</DialogDescription>
<DialogDescription>选择库里的作品,或按标题追踪,建档后自动关联。</DialogDescription>
</DialogHeader>
<div className="min-w-0 space-y-3">
<fieldset className="flex gap-1" aria-label="订阅对象">
@@ -709,7 +702,7 @@ function CreateSubscriptionDialog({ onClose }: { onClose: () => void }) {
) : search.isError ? (
<p className="px-3 py-2 text-caption text-destructive">{errorMessage(search.error)}</p>
) : (search.data?.items ?? []).filter((e) => e.status !== "merged").length === 0 ? (
<p className="px-3 py-2 text-caption text-muted-foreground">没有匹配的作品,可以改用"按标题追踪"。</p>
<p className="px-3 py-2 text-caption text-muted-foreground">没有匹配的作品。可改用"按标题追踪"。</p>
) : (
<ul className="divide-y divide-border/60">
{(search.data?.items ?? [])
@@ -757,7 +750,7 @@ function CreateSubscriptionDialog({ onClose }: { onClose: () => void }) {
labels={SUB_MODE}
/>
</FormField>
<FormField label="季范围" htmlFor={`${id}-scope`} hint={mediaKind === "movie" ? "电影不适用" : "随时可改"}>
<FormField label="季范围" htmlFor={`${id}-scope`} hint={mediaKind === "movie" ? "电影不适用" : undefined}>
<EnumSelect
id={`${id}-scope`}
value={mediaKind === "movie" ? "current_season" : scope}
@@ -13,7 +13,6 @@ export default function DownloadTaskPage() {
<>
<PageHeader
title={task.data?.name ?? "下载任务"}
description="下载 → 整理 → 核验的业务时间线与子任务"
className="[&_h1]:break-all"
actions={
<Button asChild variant="ghost" size="sm">
+3 -3
View File
@@ -159,7 +159,7 @@ export default function TasksPage() {
<>
<PageHeader
title="下载与任务"
description="下载、整理、核验串成一条业务时间线;阈值暂停不能手动绕过,无进展只记录不自动换源。"
description="阈值暂停不能手动绕过;无进展只记录,不自动换源。"
actions={
<Button variant="outline" size="sm" onClick={() => void qc.invalidateQueries({ queryKey: ["downloads"] })}>
<RefreshCw className={downloads.isFetching ? "animate-spin" : undefined} />
@@ -216,7 +216,7 @@ export default function TasksPage() {
empty={{
icon: Download,
title: status || managed ? "没有符合筛选的下载" : "还没有下载任务",
description: "自动收录或在站点工作台手动下载后,任务会出现在这里。",
description: "自动收录或在站点工作台下载后出现在这里。",
}}
/>
</TabsContent>
@@ -244,7 +244,7 @@ export default function TasksPage() {
empty={{
icon: ListChecks,
title: "还没有下载计划",
description: "规则评估选中候选后生成计划并预留 Slot 与预算。",
description: "候选被选中后生成计划,同时预留版本目标与预算。",
}}
/>
</TabsContent>
+14 -14
View File
@@ -42,21 +42,21 @@ const SetupForm = AdminSetupRequest.extend({ confirm: z.string() }).refine((v) =
/** 后端错误码 → 文案(不直接透传后端 message,setup token 类除外) */
function errorText(err: unknown, mode: Mode): string {
if (!(err instanceof ApiRequestError)) return "发生未知错误,请重试";
if (!(err instanceof ApiRequestError)) return "请求失败,稍后重试";
switch (err.code) {
case "rate_limited":
return "失败次数过多,请 15 分钟后再试";
return "失败次数过多,15 分钟后再试";
case "upstream_unavailable":
return "无法连接服务,请检查服务端是否在运行";
return "连不上服务端,确认它在运行";
case "unauthorized":
if (mode === "totp") return "验证码错误或已使用,请等待下一个验证码";
if (mode === "setup") return "初始化口令无效,请以服务端日志里最新打印的为准";
if (mode === "totp") return "验证码错误或已用过,等下一个验证码再试";
if (mode === "setup") return "初始化口令无效,用服务端日志里最新的一条";
return "用户名或密码错误";
case "validation_failed":
case "bad_request":
return "输入格式不正确";
case "conflict":
return "管理员已初始化,请直接登录";
return "管理员已初始化,直接登录";
default:
return err.message || "登录失败";
}
@@ -130,7 +130,7 @@ export default function LoginPage() {
const fe: Record<string, string> = {};
for (const i of parsed.error.issues) fe[String(i.path[0])] ??= i.message;
if (fe.setupToken) fe.setupToken = "初始化口令至少 16 个字符";
if (fe.username && !username.trim()) fe.username = "请输入用户名";
if (fe.username && !username.trim()) fe.username = "输入用户名";
setFieldErrors(fe);
return;
}
@@ -152,8 +152,8 @@ export default function LoginPage() {
if (!parsed.success) {
const fe: Record<string, string> = {};
for (const i of parsed.error.issues) fe[String(i.path[0])] ??= i.message;
if (fe.username) fe.username = "请输入用户名";
if (fe.password) fe.password = "请输入密码";
if (fe.username) fe.username = "输入用户名";
if (fe.password) fe.password = "输入密码";
setFieldErrors(fe);
return;
}
@@ -169,7 +169,7 @@ export default function LoginPage() {
} catch (err) {
if (err instanceof ApiRequestError && err.code === "precondition_failed") {
setMode("setup");
setError("管理员尚未初始化,请先完成初始化");
setError("管理员尚未初始化");
} else {
setError(errorText(err, mode));
}
@@ -210,10 +210,10 @@ export default function LoginPage() {
<section className="hidden lg:block">
<p className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">媒体自动化</p>
<h1 className="mt-3 max-w-xl font-display text-display-sm leading-tight font-semibold text-foreground">
把想看的片子交代一句,剩下的交给流水线。
订阅、下载、整理入库
</h1>
<p className="mt-4 max-w-lg text-lead text-muted-foreground">
订阅、选片、下载、核验、入库,每一步都有记录可查;拿不准的事项会停下来等你决定。
每一步都有记录。拿不准的事项进待处理,等你决定。
</p>
<ul className="mt-8 space-y-3 text-body text-muted-foreground">
<li className="flex items-center gap-2.5">
@@ -222,7 +222,7 @@ export default function LoginPage() {
</li>
<li className="flex items-center gap-2.5">
<LockKeyhole className="h-4 w-4 text-foreground/70" />
凭证加密存储,任何页面都不回显
凭证加密存储,页面不回显
</li>
</ul>
</section>
@@ -235,7 +235,7 @@ export default function LoginPage() {
<div className="mt-4 flex gap-2.5 rounded-lg border border-border bg-foreground/[0.03] p-3 text-meta text-muted-foreground">
<Terminal className="mt-0.5 h-4 w-4 shrink-0" />
<p>
初始化口令只在服务端首次启动时打印到日志一次,可用
初始化口令在服务端日志里,用
<code className="mx-1 rounded-tight bg-foreground/[0.06] px-1 font-mono">
docker compose logs app | grep 'setup token'
</code>