merge: copy/c4

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-07 02:03:47 +08:00
commit 11bc66e3f0
31 files changed
+110 -113

No files matched your search

+4 -4
View File
@@ -48,9 +48,9 @@ const WAITING_TEXT: Record<string, string> = {
captcha: "登录页需要验证码",
challenge: "页面需要人工验证",
two_factor: "需要二次验证或本人设备确认",
challenge_unsolved: "自动处理页面挑战未成功",
challenge_unsolved: "页面验证没有自动通过",
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
lease_expired: "接管超时未交还",
lease_expired: "接管超时,没有交还",
agent_request: "智能体请求人工处理",
human_released_waiting: "交还时选择继续等待",
};
@@ -80,9 +80,9 @@ export function credentialBadge(
case "injected":
return { tone: "success", text: `登录态:${src ?? "已注入"}`, title };
case "expired":
return { tone: "warning", text: "登录态已过期:在画面里登录一次即可全站生效", title };
return { tone: "warning", text: "登录态已过期:在画面里登录一次,全站生效", title };
case "none":
return { tone: "neutral", text: "账号没有登录态:在画面里登录一次即可全站生效", title };
return { tone: "neutral", text: "账号没有登录态:在画面里登录一次,全站生效", title };
case "unsupported":
return { tone: "warning", text: "浏览器服务不支持注入登录态", title };
default:
@@ -81,7 +81,7 @@ export function SiteOriginPanel({ site }: { site: SiteListItem }) {
if (!s || !canDesktop || mode === "screenshot") {
let note: string | null = null;
if (s && desktop && !desktop.available && desktop.reason) note = `桌面模式不可用:${desktop.reason}。`;
else if (s && desktop?.available && !secure) note = "桌面模式需要通过 HTTPS 访问控制台,当前使用截图模式。";
else if (s && desktop?.available && !secure) note = "桌面模式需要用 HTTPS 打开控制台,现在是截图模式。";
return (
<div className="space-y-3">
{note || (canDesktop && mode === "screenshot") ? (
@@ -109,7 +109,7 @@ export function ReloadNotice({ session }: { session: BrowserSessionView }) {
if (!at || dismissed === at) return null;
return (
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone="warning">浏览器重启过,页面已重新加载,请核对</StatusBadge>
<StatusBadge tone="warning">浏览器重启过,页面已重新加载,表单输入和滚动位置已丢失</StatusBadge>
<Button variant="ghost" size="xs" onClick={() => setDismissed(at)}>
知道了
</Button>
@@ -40,7 +40,7 @@ export function ResourceDownload({ session }: { session: BrowserSessionView }) {
} else if (result.submitted) {
setPlanId(result.plan?.id ?? null);
toast.success("已提交下载");
} else toast.warning(result.rejected.map((v) => v.message).join(";") || "没有提交下载");
} else toast.warning(result.rejected.map((v) => v.message).join(";") || "没有提交");
}
} catch (e) {
toast.error(errorMessage(e));
@@ -53,7 +53,7 @@ export function ResourceDownload({ session }: { session: BrowserSessionView }) {
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" disabled={busy || session.controller === "closed"} onClick={() => void prepare(false)}>
<Download />
{busy ? "正在处理…" : "下载当前资源"}
{busy ? "提交中…" : "下载当前资源"}
</Button>
<Button
size="sm"
@@ -88,7 +88,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
<EmptyState
icon={Globe}
title="没有启用的浏览器服务"
description="登记 camofox 地址与访问凭证后,这里显示站点的真实页面。"
description="在设置里登记 camofox 地址和凭证后,这里显示站点的原站页面。"
/>
);
if (!site.enabled) return <EmptyState icon={Globe} title="站点已停用" description="启用站点后才能打开原站页面。" />;
@@ -97,7 +97,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
<EmptyState
icon={Globe}
title="还没有打开这个站点的浏览器会话"
description="打开后显示站点首页;验证码等需本人操作在此完成。"
description="打开后显示站点首页。验证码等需要本人操作的步骤在这里完成。"
action={
<Button
size="sm"
@@ -152,7 +152,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
return (
<div className="space-y-3">
<p className="text-caption text-muted-foreground">验证码等需本人操作在此完成。</p>
<p className="text-caption text-muted-foreground">验证码等需要本人操作的步骤在这里完成。</p>
<ReloadNotice session={s} />
{site.kind === "subtitle" ? <SubtitleFromPage session={s} /> : <ResourceDownload session={s} />}
@@ -169,8 +169,8 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
<StatusBadge tone={cred.tone}>{cred.text}</StatusBadge>
</span>
) : null}
{unsupported ? <StatusBadge tone="destructive">该浏览器服务不支持画面与输入</StatusBadge> : null}
{unverified ? <StatusBadge tone="warning">画面与输入未对真实实例核验</StatusBadge> : null}
{unsupported ? <StatusBadge tone="destructive">这个浏览器服务不支持画面和输入</StatusBadge> : null}
{unverified ? <StatusBadge tone="warning">画面和输入没有在真实实例上验证过</StatusBadge> : null}
<div className="ml-auto flex flex-wrap items-center gap-2">
{human ? (
<Button
@@ -24,15 +24,15 @@ import { formatDateTime } from "~/lib/utils.ts";
const TaskPage = cursorPage(Task);
export const TASK_KIND_LABEL: Record<TaskKind, string> = {
ingestFeed: "抓取 Feed",
ingestFeed: "抓取 RSS",
resolveObservation: "识别资源",
evaluateAcquisition: "评估候选",
evaluateAcquisition: "评估资源",
submitDownload: "提交下载",
verifyAndImport: "核验整理",
refreshMetadata: "刷新元数据",
reconcileLibrary: "文件核对",
cleanupDownloadCache: "清理缓存",
resolveDecision: "处理待处理事项",
resolveDecision: "处理事项",
agentRun: "智能体运行",
};
@@ -40,13 +40,13 @@ export const TASK_STATUS: Record<
TaskStatus,
{ label: string; tone: "success" | "warning" | "destructive" | "neutral" }
> = {
pending: { label: "排队", tone: "neutral" },
pending: { label: "排队中", tone: "neutral" },
running: { label: "运行中", tone: "neutral" },
waiting: { label: "等待", tone: "warning" },
succeeded: { label: "成功", tone: "success" },
failed: { label: "失败", tone: "destructive" },
cancelled: { label: "已取消", tone: "neutral" },
dead: { label: "放弃", tone: "destructive" },
dead: { label: "已放弃", tone: "destructive" },
};
export const ACTIVE_TASK: TaskStatus[] = ["pending", "running", "waiting"];
@@ -245,8 +245,8 @@ export function BusinessTasks() {
loadingMore={list.isFetchingNextPage}
empty={{
icon: ListTodo,
title: f.active ? "没有符合筛选的任务" : "还没有业务任务",
description: f.active ? undefined : "提交下载、核验整理、清理缓存、对账等后台任务会出现在这里。",
title: f.active ? "没有符合筛选的任务" : "还没有后台任务",
description: f.active ? undefined : "提交下载、核验整理、清理缓存、文件核对等任务会出现在这里。",
}}
/>
<DetailSheet
@@ -259,7 +259,7 @@ export function BusinessTasks() {
<>
<TaskActions t={open} />
<Button asChild size="sm">
<Link to={consolePath("tasks", open.id)}>在独立页面打开</Link>
<Link to={consolePath("tasks", open.id)}>打开详情页</Link>
</Button>
</>
) : null
@@ -36,7 +36,7 @@ export function BusinessTimeline({ events, loading }: { events: TimelineEvent[];
})}
</div>
{ongoing.length ? (
<p className="text-caption text-muted-foreground">当前:{ongoing.map((e) => e.title).join(";")}</p>
<p className="text-caption text-muted-foreground">当前:{ongoing.map((e) => e.title).join(";")}</p>
) : null}
{loading && !events.length ? (
<LoadingState rows={3} />
@@ -59,9 +59,7 @@ export function BusinessTimeline({ events, loading }: { events: TimelineEvent[];
}))}
/>
)}
{loading && events.length ? (
<p className="text-meta text-muted-foreground">正在加载关联的整理记录与引擎运行…</p>
) : null}
{loading && events.length ? <p className="text-meta text-muted-foreground">加载整理记录和引擎运行…</p> : null}
</div>
);
}
@@ -81,9 +81,9 @@ function VolumeBar({ v }: { v: Volume }) {
/>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{pct === null ? <StatusBadge tone="warning">读不到磁盘状态,已暂停新提交</StatusBadge> : null}
{tripped ? <StatusBadge tone="destructive">超过暂停线,停止新下载</StatusBadge> : null}
{tripped ? <StatusBadge tone="destructive">超过暂停线,已停止新下载</StatusBadge> : null}
{v.pausedByThreshold ? (
<StatusBadge tone="warning">因下载盘暂停 {v.pausedByThreshold} 个任务</StatusBadge>
<StatusBadge tone="warning">超过暂停线,暂停了 {v.pausedByThreshold} 个任务</StatusBadge>
) : null}
</div>
{v.notes.map((n) => (
@@ -119,7 +119,7 @@ export function CacheVolumes() {
</Button>
}
title="按规则清理下载缓存?"
description="只清理已整理、且满足保留期与 H&R 的本工具任务,其余不动。"
description="只清理本工具管理、已整理且满足保留期和 H&R 要求的任务,其余不动。"
confirmLabel="清理"
onConfirm={() =>
cleanup.mutateAsync(undefined).then(
@@ -138,7 +138,7 @@ export function CacheVolumes() {
) : vols.error ? (
<ErrorState plain error={vols.error} onRetry={() => void vols.refetch()} />
) : !vols.data.length ? (
<p className="text-caption text-muted-foreground">还没有缓存卷数据。登记下载器并完成巡检后显示。</p>
<p className="text-caption text-muted-foreground">还没有下载盘。添加下载器后显示。</p>
) : (
<div className="grid gap-5 md:grid-cols-2">
{vols.data.map((v) => (
@@ -333,11 +333,11 @@ export const BOARD_EMPTY: Record<DownloadBoardState, { title: string; descriptio
active: {
icon: Download,
title: "没有进行中的下载",
description: "自动收录或在站点页下载后出现在这里,等批准、等磁盘空间的也在这里;下载完成的在「已完成」。",
description: "自动收录和手动下载的任务都在这里,包括等批准、等磁盘空间的。下载完成后移到「已完成」。",
},
completed: {
icon: CheckCircle2,
title: "还没有下载完成的任务",
description: "下载完成(含做种)后出现在这里。",
description: "下载完成的任务在这里,包括做种中的。",
},
};
@@ -49,7 +49,7 @@ export function DownloadStatusBadges({ t }: { t: DownloadTask }) {
<span className="flex flex-wrap gap-1">
<StatusBadge tone={DOWNLOAD_STATUS_TONE[t.status]}>{DOWNLOAD_STATUS_LABEL[t.status]}</StatusBadge>
{t.pauseReason === "threshold" ? (
<StatusBadge tone="warning">下载盘满,已暂停</StatusBadge>
<StatusBadge tone="warning">超过暂停线,已暂停</StatusBadge>
) : t.pauseReason ? (
<StatusBadge>{PAUSE_REASON_LABEL[t.pauseReason]}</StatusBadge>
) : null}
@@ -151,7 +151,8 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
) : null}
{task.noProgressSince ? (
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
自 {formatDateTime(task.noProgressSince)} 起无进展,不会自动换源;开了自动调查时智能体会在授权内尝试恢复。
{formatDateTime(task.noProgressSince)}{" "}
起没有进展,不会自动换源。开启自动调查时,智能体会在持续授权范围内尝试恢复。
</p>
) : null}
@@ -85,7 +85,7 @@ describe("保存目录", () => {
expect(custom).not.toHaveProperty("downloaderInstanceId");
});
it("界面:并排两个下拉,下方写明将保存到哪;自定义时展开输入框", () => {
it("界面:并排两个下拉,下方写明保存到哪;自定义时展开输入框", () => {
const props = {
formId: "f",
instances: paths.instances,
@@ -101,10 +101,10 @@ describe("保存目录", () => {
const html = render(createElement(SaveTargetFields, props));
expect(html).toContain("下载器");
expect(html).toContain("自动:qb-main");
expect(html).toContain("将保存到:/data/downloads(本机 /mnt/dl)");
expect(html).toContain("保存到:/data/downloads(本机 /mnt/dl)");
expect(html).not.toContain("自定义路径</label>");
const custom = render(createElement(SaveTargetFields, { ...props, dir: CUSTOM_DIR, custom: "/x" }));
expect(custom).toContain("将保存到:/x");
expect(custom).toContain("保存到:/x");
expect(custom).toContain('id="f-custom"');
const none = render(
createElement(SaveTargetFields, { ...props, instances: [], autoInstanceId: null, target: null }),
@@ -136,7 +136,7 @@ describe("作品身份", () => {
expect(html).toContain("未识别");
expect(html).toContain("TMDB 编号");
expect(html).toContain('id="f-tmdb"');
expect(html).toContain("暂未识别到作品");
expect(html).toContain("没识别出作品");
});
it("已识别:折叠只显示当前作品", () => {
@@ -282,7 +282,7 @@ export function ManualDownloadDialog({
}}
>
<Bot />
委托智能体
交给智能体
</Button>
) : null}
<Button
@@ -329,7 +329,7 @@ export function ResourceSummary({ resource: r }: { resource: ManualDownloadResou
{r.leechers ?? "—"}
</span>
) : null}
{r.publishedAt ? <span title={absTime(r.publishedAt)}>发布 {relTime(r.publishedAt)}</span> : null}
{r.publishedAt ? <span title={absTime(r.publishedAt)}>发布于 {relTime(r.publishedAt)}</span> : null}
</div>
</div>
);
@@ -372,7 +372,7 @@ export function SaveTargetFields({
: !target && dir !== CUSTOM_DIR
? "没有可用的下载器,先在设置里添加下载器和保存目录"
: resolved
? `将保存到:${savePathText(resolved)}`
? `保存到:${savePathText(resolved)}`
: "填写下载器能访问的路径";
return (
<div className="grid items-start gap-x-3 sm:grid-cols-2">
@@ -504,7 +504,7 @@ export function IdentitySection({
<div id={`${formId}-identity`} className="space-y-3 border-t border-border px-3 pt-3 pb-1">
{unrecognized && !changed ? (
<p className="text-caption text-warning">
暂未识别到作品。指定后绑定到这个资源;不指定也能下载,整理时再补全识别和元数据。
没识别出作品。指定后绑定到这个资源;不指定也能下载,整理时再识别。
</p>
) : null}
{picked ? (
@@ -533,7 +533,7 @@ export function IdentitySection({
<FormField
label="TMDB 编号"
htmlFor={`${formId}-tmdb`}
error={tmdbId && !valid ? "TMDB 编号是数字" : preview.error ? errorMessage(preview.error) : null}
error={tmdbId && !valid ? "TMDB 编号只能是数字" : preview.error ? errorMessage(preview.error) : null}
hint={
preview.data
? `${preview.data.title}${preview.data.year ? ` (${preview.data.year})` : ""}${preview.data.localEntityId ? " · 在媒体库" : ""}`
@@ -602,8 +602,8 @@ export function ExistingVersionsSection({
selected: string[];
onSelectedChange: (ids: string[]) => void;
}) {
if (loading) return <p className="text-caption text-muted-foreground">正在读取已有版本…</p>;
if (error) return <p className="text-caption text-destructive">已有版本读取失败,重试后再选洗版</p>;
if (loading) return <p className="text-caption text-muted-foreground">读取已有版本…</p>;
if (error) return <p className="text-caption text-destructive">读取已有版本失败,重新打开后再选洗版</p>;
if (!versions.length) return null;
return (
<div className="min-w-0 space-y-2">
@@ -640,7 +640,7 @@ export function ExistingVersionsSection({
))}
</fieldset>
<p className="text-meta text-muted-foreground">
本次新文件整理成功后替换所选旧文件。剧集只替换成功对应的季集,其余保留。
新文件整理成功后替换所选旧文件。剧集只替换整理成功的季集,其余保留。
</p>
</>
) : null}
@@ -25,13 +25,13 @@ const GROUP_ORDER = ["title", "episode", "tech", "id", "ours"] as const;
const ROLE: Record<NamingPreviewResult["segments"][number]["role"], { label: string; className: string }> = {
category: { label: "分类", className: "text-muted-foreground" },
root: { label: "作品根", className: "text-primary font-medium" },
root: { label: "作品目录", className: "text-primary font-medium" },
season: { label: "季目录", className: "text-foreground" },
sub: { label: "子目录", className: "text-muted-foreground" },
file: { label: "文件名", className: "text-foreground font-medium" },
};
export const NAMING_NOTE = "已建立目录的作品不受模板影响,模板只决定新目录和新文件名。";
export const NAMING_NOTE = "已建立目录的作品沿用原目录,模板只决定新目录和文件名。";
function useDebounced<T>(v: T, ms = 300): T {
const [d, setD] = useState(v);
@@ -167,7 +167,7 @@ export function NamingTemplateEditor({
<span>
{override && !value.trim()
? `留空:沿用${inherited?.[active]?.source ?? "上级"}模板`
: "/ 分目录,最后一段是文件名,须以 {{fileExt}} 结尾"}
: "用 / 分目录,最后一段是文件名,以 {{fileExt}} 结尾"}
</span>
<span className="tabular-nums">{value.length} / 1024</span>
</div>
@@ -317,10 +317,10 @@ function PreviewBody({
))}
</div>
)}
{r.usesBinding ? <Note>这部作品已有目录,目录沿用已建立的路径,模板只决定文件名。</Note> : null}
{r.usesBinding ? <Note>这部作品已有目录,沿用原目录,模板只决定文件名。</Note> : null}
{r.missing.length ? (
<p className="text-meta text-muted-foreground">
无值的变量(输出为空):<span className="font-mono">{r.missing.join("、")}</span>
没有值的变量:<span className="font-mono">{r.missing.join("、")}</span>
</p>
) : null}
{r.warnings.map((w) => (
@@ -108,7 +108,7 @@ export function RuleGroupEditor({
const save = async () => {
if (!form.name.trim()) {
setNameError("名称必填");
setNameError("填写名称");
return;
}
const scope: RuleGroupScope = {
@@ -263,7 +263,7 @@ export function RuleGroupEditor({
label="作用范围"
hint={
group && (group.scope.entityIds?.length || group.scope.siteIds?.length || group.scope.subscriptionId)
? `另有:${scopeText({ ...group.scope, mediaTypes: undefined, mediaKinds: undefined } as RuleGroupScope)}(此处不改)`
? `另有:${scopeText({ ...group.scope, mediaTypes: undefined, mediaKinds: undefined } as RuleGroupScope)}(这里不改)`
: "不选 = 全部类型"
}
className="sm:col-span-2"
@@ -277,15 +277,15 @@ export function RuleGroupEditor({
</FormField>
<div className="flex items-center justify-between gap-3 py-1 sm:col-span-2">
<div className="min-w-0">
<p className="text-caption font-medium text-foreground">一层都不中时拒绝</p>
<p className="text-caption font-medium text-foreground">一层都没命中时拒绝</p>
<p className="text-meta text-muted-foreground">
{form.ladderRequired ? "资源至少要命中一层" : "只影响排序,不中的资源 pri 为 0"}
{form.ladderRequired ? "资源至少要命中一层" : "只影响排序,没命中的资源 pri 为 0"}
</p>
</div>
<Switch
checked={form.ladderRequired}
onCheckedChange={(v) => set("ladderRequired", v)}
aria-label="一层都不中时拒绝"
aria-label="一层都没命中时拒绝"
/>
</div>
<div className="flex items-center justify-between gap-3 py-1 sm:col-span-2">
@@ -379,7 +379,7 @@ const FIELD_LABEL: Record<string, string> = {
scope: "作用范围",
layers: "阶梯",
hardExcludes: "硬排除",
ladderRequired: "一层不中拒绝",
ladderRequired: "一层都没命中时拒绝",
enabled: "启用",
label: "名称",
rule: "规则",
+2 -2
View File
@@ -29,7 +29,7 @@ export const SUPPRESS_REASON: Record<string, string> = {
disabled: "停用",
"status:disabled": "停用",
"status:deleted": "删除",
edited: "修改语义",
edited: "改了条件",
};
export const suppressReason = (r: string | null) => (r ? (SUPPRESS_REASON[r] ?? r) : "未注明");
@@ -59,5 +59,5 @@ export function ruleSummary(g: RuleGroup): string {
parts.push(`${g.ladderRequired ? "要求" : "偏好"} ${g.layers.map((l) => exprToString(l.expr)).join(" > ")}`);
for (const h of g.hardExcludes) parts.push(`排除 ${exprToString(h)}`);
if (parts.length) return parts.join(";");
return g.legacyKind ? "旧偏好标签,没有可评估的条件" : "没有条件";
return g.legacyKind ? "旧版偏好,没有可评估的条件" : "没有条件";
}
@@ -47,7 +47,7 @@ export function ladderFrom(layers: RuleLayer[]): LadderState {
/** 保存用:有错误 / 解析中 / 有空层时不可保存 */
export function ladderProblem(s: LadderState): string | null {
if (s.parsing) return "规则串还在解析";
if (s.parsing) return "规则串解析中";
if (s.failure) return s.failure;
if (s.errors.length) return "规则串有错误";
if (s.cards?.some((c) => c.items.length === 0)) return "有空层";
@@ -253,7 +253,7 @@ export function RuleStringField({
<li key={`${e.code}-${e.position}-${i}`} className="text-meta text-destructive">
<ErrorCaret text={state.text} position={e.position} />
<span>
第 {e.position + 1} 个字符{single ? "" : `(第 ${e.layer + 1} 层)`}:{e.message}
第 {e.position + 1} 个字符{single ? "" : `(第 ${e.layer + 1} 层)`}:{e.message}
</span>
{e.suggestions?.length ? (
<span className="ml-1 inline-flex flex-wrap gap-1 align-middle">
@@ -338,7 +338,7 @@ function AtomChip({
item.neg ? "border-destructive/35 bg-destructive/10 text-destructive" : "border-border bg-foreground/[0.03]",
unknown && "border-warning/50",
)}
title={keyword ? `${keyword.name}:${keyword.description}` : "未知条件"}
title={keyword ? `${keyword.name}:${keyword.description}` : "未知条件"}
>
<button
type="button"
@@ -150,7 +150,7 @@ function PreviewResult({ result }: { result: RulePreviewResult }) {
</details>
) : null}
<p className="mt-1 text-meta text-muted-foreground sm:hidden">
{[...it.excludedBy.map((x) => `排除 ${x}`), ...it.reasons.map(reasonText)].join(";")}
{[...it.excludedBy.map((x) => `排除 ${x}`), ...it.reasons.map(reasonText)].join(";")}
</p>
</td>
<td className="px-3 py-2 whitespace-nowrap">
@@ -164,7 +164,7 @@ function PreviewResult({ result }: { result: RulePreviewResult }) {
)}
</td>
<td className={cn("hidden px-3 py-2 text-meta text-muted-foreground sm:table-cell")}>
{[...it.excludedBy.map((x) => `排除 ${x}`), ...it.reasons.map(reasonText)].join(";") || "—"}
{[...it.excludedBy.map((x) => `排除 ${x}`), ...it.reasons.map(reasonText)].join(";") || "—"}
</td>
</tr>
))}
+2 -2
View File
@@ -257,7 +257,7 @@ function atomSummary(a: RuleAtom): string {
if (a.minSeeders !== null) p.push(`做种 ≥ ${a.minSeeders}`);
if (a.publishDelayMinutes) p.push(`发布 ${a.publishDelayMinutes.min ?? 0}–${a.publishDelayMinutes.max ?? "∞"} 分钟`);
if (a.free) p.push("免费");
return p.join(";") || "没有条件";
return p.join(";") || "没有条件";
}
export function AtomsTab() {
@@ -299,7 +299,7 @@ export function AtomsTab() {
<Trash2 />
</Button>
}
title={`删除条件 ${a.key}?`}
title={`删除条件 ${a.key}?`}
description="被规则组引用时不能删除。"
confirmLabel="删除"
destructive
@@ -193,9 +193,7 @@ function PaletteBody({ initial }: { initial: string }) {
go(searchPagePath(term));
}}
/>
<p className="py-6 text-center text-caption text-muted-foreground">
输入作品名、别名或页面名称。选中作品打开详情页。
</p>
<p className="py-6 text-center text-caption text-muted-foreground">输入作品名、别名或页面名</p>
</div>
) : (
<>
@@ -320,7 +318,7 @@ function actionItem(a: SearchAction, run: () => void): Item {
icon: actionIcon(a.kind),
title: a.title,
meta: a.group,
hint: "前往",
hint: "打开",
run,
};
}
@@ -181,7 +181,7 @@ export function ReleaseResults({
</h2>
{first ? (
<p className="mt-0.5 text-meta break-words text-muted-foreground tabular-nums">
{first.sites.length} 个站点 · {first.cached ? "缓存结果" : "站点实时结果"} · 抓取于{" "}
{first.sites.length} 个站点 · {first.cached ? "缓存结果" : "实时结果"} · 上次抓取{" "}
{relTime(first.fetchedAt)}
</p>
) : null}
@@ -199,7 +199,7 @@ export function ReleaseResults({
onClick={() => refresh.mutate(api, { onError: (e) => toast.error(errorMessage(e, "刷新失败")) })}
>
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
{refresh.isPending ? "查询中…" : "刷新"}
{refresh.isPending ? "刷新中…" : "刷新"}
</Button>
</div>
</div>
@@ -240,7 +240,7 @@ export function ReleaseResults({
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
{res.isPending ? (
<div className="p-4">
<p className="mb-3 text-meta text-muted-foreground">正在查询各站点,最慢约 15 秒…</p>
<p className="mb-3 text-meta text-muted-foreground">查询各站点中,最长约 15 秒…</p>
<LoadingState rows={4} />
</div>
) : res.isError && !rows.length ? (
@@ -361,7 +361,7 @@ function CoverageNotice({ page }: { page: ReleaseSearchPage }) {
<ul className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-meta text-muted-foreground">
{page.failedSites.map((s) => (
<li key={s.siteId} className="break-all" title={s.error ?? undefined}>
{s.siteName}:{SITE_STATUS[s.status]}
{s.siteName}:{SITE_STATUS[s.status]}
{s.error ? `(${s.error})` : ""}
</li>
))}
@@ -384,7 +384,7 @@ function CoverageNotice({ page }: { page: ReleaseSearchPage }) {
<ul className="mt-1 space-y-0.5 pl-4">
{skipped.map((s) => (
<li key={s.siteId} className="break-all">
{s.siteName}:{s.error ?? "站点不支持搜索"}
{s.siteName}:{s.error ?? "不支持搜索"}
</li>
))}
</ul>
@@ -650,11 +650,11 @@ function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () =>
</span>
<span className="lg:w-16 lg:text-right">{r.sizeBytes === null ? "—" : formatBytes(r.sizeBytes, 1)}</span>
<span className="inline-flex items-center gap-0.5 lg:w-28" title="做种 / 下载">
<ArrowUp aria-label="做种" className="h-3.5 w-3.5" />
<ArrowUp aria-label="做种数" className="h-3.5 w-3.5" />
<span className={cn("text-body", seedless ? "text-muted-foreground" : "font-semibold text-foreground")}>
{r.seeders ?? "—"}
</span>
<ArrowDown aria-label="下载" className="ml-1.5 h-3 w-3" />
<ArrowDown aria-label="下载数" className="ml-1.5 h-3 w-3" />
{r.leechers ?? "—"}
</span>
<span
@@ -667,15 +667,15 @@ function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () =>
<span className="flex flex-wrap items-center gap-1">
{r.sourceReleaseId ? (
<Button asChild variant="ghost" size="sm">
<Link to={`/console/sites/${r.siteId}`} title="在站点工作台查看">
<Link to={`/console/sites/${r.siteId}`} title="打开站点页">
<ExternalLink />
工作台
站点页
</Link>
</Button>
) : null}
<Button variant="ghost" size="sm" onClick={() => delegateToAgent(r)}>
<Bot />
委托智能体
交给智能体
</Button>
<Button size="sm" onClick={onDownload}>
<Download />
@@ -74,7 +74,7 @@ export function WorksSection({ q }: { q: string }) {
plain
icon={Film}
title="没有匹配的作品"
description="换个译名、原名或别名再搜;资源标题可以点「按关键词搜资源」。"
description="换个译名、原名或别名再搜,或点「按关键词搜资源」直接搜资源标题。"
className="py-8"
/>
</div>
@@ -134,7 +134,7 @@ export function SitePickerDialog({
))}
</fieldset>
{sitesQ.isPending ? (
<p className="text-caption text-muted-foreground">加载站点…</p>
<p className="text-caption text-muted-foreground">加载中…</p>
) : sites.length === 0 ? (
<p className="text-caption text-muted-foreground">
还没有启用的站点。
@@ -156,7 +156,7 @@ export function SitePickerDialog({
.filter((s) => ids.includes(s.id))
.map((s) => s.name)
.join("、")
: "没有站点勾选「默认参与搜索」。在站点页打开,或改选全部站点。"}
: "没有站点勾选「默认参与搜索」。到站点页勾选,或改选「全部站点」。"}
</p>
)}
<DialogFooter>
@@ -118,7 +118,7 @@ function AccountBlock({
onConfirm={async () => {
try {
await del.mutateAsync(a.id);
toast.success(`已删除账号 ${a.label}`);
toast.success(`已删除账号「${a.label}」`);
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
@@ -260,7 +260,7 @@ function PasskeyForm({ site, label, has }: { site: SiteListItem; label: string;
}
}}
>
<h2 className="text-lead font-semibold text-foreground">站点 Passkey(RSS 与下载链接用)</h2>
<h2 className="text-lead font-semibold text-foreground">站点 Passkey</h2>
<p className="mb-3 text-caption text-muted-foreground">
用于 RSS 和下载链接,不参与登录。{has ? "填写新值保存会替换。" : "还没有保存。"}
</p>
@@ -375,7 +375,7 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
)}
</div>
<p className="mt-3 text-meta text-muted-foreground">
"未实测"的定义没有在真实站点上验证过,列表和登录可能失败。
标「未实测」的站点定义没有在真实站点上验证过,列表和登录可能失败。
</p>
</div>
) : step === 2 ? (
+3 -3
View File
@@ -183,7 +183,7 @@ function CheckButtons({ site, checks, compact }: Omit<Props, "onDialog"> & { com
size={compact ? "icon-sm" : "sm"}
disabled={h || !site.enabled}
aria-label="检测健康"
title={site.enabled ? "检测健康:首页连通 + 登录态,走站点限流" : "站点已停用"}
title={site.enabled ? "检测健康:首页能否打开、登录态是否有效,计入站点限流" : "站点已停用"}
onClick={() => void checks.checkHealth(site)}
>
{h ? <LoaderCircle className="animate-spin" /> : <Activity />}
@@ -194,7 +194,7 @@ function CheckButtons({ site, checks, compact }: Omit<Props, "onDialog"> & { com
size={compact ? "icon-sm" : "sm"}
disabled={l || !hasAccount}
aria-label="刷新登录"
title={hasAccount ? "刷新登录:用凭证刷新登录状态并拉取用户数据" : "先填写凭证"}
title={hasAccount ? "刷新登录:用凭证重新登录并拉取用户数据" : "先填写凭证"}
onClick={() => void checks.refreshLogin(site)}
>
{l ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
@@ -289,7 +289,7 @@ function FeedSiteCard({ site, onDialog }: Omit<Props, "checks">) {
<div aria-hidden className="min-h-4 flex-1" />
<footer className="mt-auto flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("sites", site.id)}?tab=rss`}>{f.loaded && !f.count ? "添加 RSS" : "管理 RSS"}</Link>
<Link to={`${consolePath("sites", site.id)}?tab=rss`}>{f.loaded && !f.count ? "新建 RSS" : "管理 RSS"}</Link>
</Button>
<SiteMoreMenu site={site} onDialog={onDialog} />
</footer>
@@ -507,7 +507,7 @@ export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose:
</span>
</label>
</div>
<p className="text-meta text-muted-foreground">User-Agent 随凭证保存,在"更新凭证"里填写。</p>
<p className="text-meta text-muted-foreground">User-Agent 随凭证保存,在「更新凭证」里填写。</p>
</div>
</DetailSheet>
);
@@ -544,9 +544,9 @@ export function DeleteSiteDialog({
}}
>
<ul className="list-disc space-y-1 pl-5 text-caption text-muted-foreground">
<li>站点、{site.accounts?.length ?? 0} 个账号和全部凭证删除</li>
<li>已抓到的资源保留,标为"站点已关闭",不再产生新候选</li>
<li>该站 RSS 停用;已有下载任务按原策略继续</li>
<li>站点、{site.accounts?.length ?? 0} 个账号和全部凭证都会删除</li>
<li>已抓到的资源保留,标为「站点已关闭」,不再进入收录评估</li>
<li>这个站点的 RSS 停用;已有下载任务继续</li>
<li>同一站点以后可以重新添加</li>
</ul>
</ConfirmDialog>
@@ -70,7 +70,7 @@ export function SiteFailureSamples({ siteId, items }: { siteId: string; items: S
if (!items.length) return null;
return (
<section className="grid gap-2">
<h3 className="text-meta text-muted-foreground">最近的失败页面(已脱敏,留最近 5 份)</h3>
<h3 className="text-meta text-muted-foreground">最近的失败页面(已脱敏,最多保留 5 份)</h3>
<ul className="grid gap-2">
{items.map((s) => (
<FailureSampleRow key={s.id} siteId={siteId} sample={s} />
@@ -23,7 +23,7 @@ import { cn } from "~/lib/utils.ts";
const PURPOSES: { value: FeedPurpose; label: string; hint: string }[] = [
{ value: "generic", label: "发现", hint: "全站新种,进入识别和收录评估" },
{ value: "search", label: "追踪", hint: "按关键词、分类或收藏筛过的 RSS" },
{ value: "promotion", label: "促销", hint: "只含免费 / 促销种子的 RSS" },
{ value: "promotion", label: "促销", hint: "只含免费 / 促销资源的 RSS" },
];
/** 首抓最多等 90 秒(站点限流排队);超时后提示去列表看 */
@@ -106,7 +106,7 @@ export function SiteFeedDialog({
<Dialog open onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>添加 {site.name} RSS</DialogTitle>
<DialogTitle>新建 RSS · {site.name}</DialogTitle>
<DialogDescription>地址加密保存,页面上只显示去掉 passkey 的地址。</DialogDescription>
</DialogHeader>
@@ -219,8 +219,8 @@ export function SiteFeedDialog({
</FormField>
<p className="text-caption text-muted-foreground">
{account ? `账号 ${account.label}` : "该站没有账号,按地址里的 passkey 抓取"} · 解析器按站点定义 · 每 30
分钟抓一次,可在 RSS 详情里改
{account ? `账号 ${account.label}` : "这个站点没有账号,按地址里的 passkey 抓取"} · 解析器按站点定义 · 每
30 分钟抓一次,可在 RSS 详情里改
</p>
</form>
)}
@@ -43,7 +43,7 @@ describe("cloudflareLine / cloudflareTechItems(CF-2)", () => {
const until = new Date("2026-09-30T17:30:00+08:00");
const line = cloudflareLine(cfg({ lastChallengeAt: at, browserUntil: until.toISOString() }), now);
const hm = `${String(until.getHours()).padStart(2, "0")}:${String(until.getMinutes()).padStart(2, "0")}`;
expect(line?.text).toBe(`Cloudflare:浏览器代理中,至 ${hm}`);
expect(line?.text).toBe(`Cloudflare:浏览器代抓中,到 ${hm}`);
expect(cloudflareLine(cfg({ lastChallengeAt: at, lastSolvedBy: "vision" }), now)?.text).toBe(
"Cloudflare:自动通过",
);
@@ -63,8 +63,8 @@ describe("cloudflareLine / cloudflareTechItems(CF-2)", () => {
}),
);
expect(items.map((i) => i.label)).toEqual([
"最近遇到挑战",
"挑战规则",
"最近遇到验证",
"验证规则",
"处理结果",
"最近通过方式",
"cf_clearance 复用",
+12 -12
View File
@@ -158,7 +158,7 @@ export function healthMessage(h: SiteLastHealth | null): string | null {
const HEALTH_REASON_TEXT: Record<string, string> = {
logged_in: "首页有登录标志",
user_data: "首页没找到登录标志,按用户数据解析成功判为已登录",
user_data: "首页没有登录标志,但用户数据解析成功,按已登录处理",
login_page: "返回登录页",
permission_page: "返回权限提示页",
challenge: "疑似人机验证",
@@ -198,7 +198,7 @@ export function cloudflareLine(
if (st.browserUntil && Date.parse(st.browserUntil) > now.getTime()) {
const t = new Date(st.browserUntil);
const hm = `${String(t.getHours()).padStart(2, "0")}:${String(t.getMinutes()).padStart(2, "0")}`;
return { text: `Cloudflare:浏览器代理中,至 ${hm}`, tone: "neutral", takeover: false };
return { text: `Cloudflare:浏览器代抓中,到 ${hm}`, tone: "neutral", takeover: false };
}
if (st.lastSolvedBy) return { text: "Cloudflare:自动通过", tone: "success", takeover: false };
if (st.lastError) return { text: "Cloudflare:没能通过", tone: "warning", takeover: false };
@@ -206,8 +206,8 @@ export function cloudflareLine(
}
const CF_SOLVED_BY: Record<string, string> = {
auto: "等它自动放行",
vision: "视觉子流程点了复选框",
auto: "自动放行",
vision: "自动点了验证复选框",
human: "你在原站视图里通过",
};
const CF_RESULT: Record<string, string> = {
@@ -227,8 +227,8 @@ export function cloudflareTechItems(config: unknown): { label: string; value: st
if (!st?.lastChallengeAt) return [];
const t = (v: string | null) => (v ? new Date(v).toLocaleString("zh-CN", { hour12: false }) : "");
return [
{ label: "最近遇到挑战", value: t(st.lastChallengeAt) },
{ label: "挑战规则", value: st.lastRule ?? "", mono: true },
{ label: "最近遇到验证", value: t(st.lastChallengeAt) },
{ label: "验证规则", value: st.lastRule ?? "", mono: true },
{ label: "处理结果", value: st.lastResult ? `${CF_RESULT[st.lastResult] ?? st.lastResult}(${st.lastResult})` : "" },
{ label: "最近通过方式", value: st.lastSolvedBy ? (CF_SOLVED_BY[st.lastSolvedBy] ?? st.lastSolvedBy) : "" },
{ label: "最近通过时间", value: t(st.lastSolvedAt) },
@@ -236,7 +236,7 @@ export function cloudflareTechItems(config: unknown): { label: string; value: st
label: "cf_clearance 复用",
value: st.cfReuse === "ok" ? "有效(ok)" : st.cfReuse === "fails" ? "无效(fails)" : "",
},
{ label: "浏览器代理至", value: t(st.browserUntil) },
{ label: "浏览器代抓到", value: t(st.browserUntil) },
{ label: "最近尝试", value: t(st.lastAttemptAt) },
{ label: "最近错误", value: st.lastError ?? "" },
].filter((it) => it.value);
@@ -317,7 +317,7 @@ export function loginFailureText(
return { text: "登录失败:站点要求二次验证。填写 TOTP 密钥后重试,或改用 Cookie", suggestCookie: true };
case "challenge":
return {
text: "站点要求人机验证(Cloudflare)。会用浏览器自动过,过不去时在铃铛里提醒你",
text: "站点要求人机验证(Cloudflare)。会先用浏览器自动通过,失败时在铃铛里提醒",
suggestCookie: false,
};
case "bad_credentials":
@@ -333,11 +333,11 @@ export function loginFailureText(
suggestCookie: false,
};
case "network":
return { text: `连不上站点${message ? `:${message}` : ""}`, suggestCookie: false };
return { text: `连不上站点${message ? `:${message}` : ""}`, suggestCookie: false };
case "unsupported":
return { text: message ?? "这个站点不支持该凭证类型的自动登录", suggestCookie: false };
return { text: message ?? "这个站点不支持用这种凭证自动登录", suggestCookie: false };
default:
return { text: message ?? "登录失败,原因未知", suggestCookie: false };
return { text: message ?? "登录失败", suggestCookie: false };
}
}
@@ -365,7 +365,7 @@ export const REASON_SHORT: Record<SiteLoginFailureReason, string> = {
two_factor: "需二次验证",
bad_credentials: "密码错误",
account_blocked: "账号封禁",
challenge: "Cloudflare 挑战",
challenge: "Cloudflare 验证",
auth_expired: "凭证失效",
network: "网络失败",
unsupported: "不支持",
@@ -470,7 +470,7 @@ function OnlinePanel({
) : null}
{data && !data.noSites && !data.results.length ? (
<p className="text-caption text-muted-foreground">
没搜到(用了:{data.keywords.join("、") || "无"})。可以换个关键词,或去掉季集限制再试。
没有找到字幕(搜索词:{data.keywords.join("、") || "无"})。换个关键词,或去掉季集限制再试。
</p>
) : null}
{data?.results.length ? (