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:
1 parent
b839ed4056
commit
16ab2a6c84
97 files changed
+592
-648
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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)} />
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user