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