refactor(web): 下载盘、RSS、订阅、设置与待处理的剩余去噪项(DEAI-2)

- 下载面板:下载盘只显示「已用 x / 总量」与进度条,暂停线 / 在途 / 预留 / 可清理 / 做种保留收进详情;
  阈值文案统一「暂停线 / 恢复线 / 下载盘满暂停」
- RSS 页去掉与站点页重复的站点表,节标题「RSS 地址」;用途只在非通用时显示
- 订阅:一条都没有时不铺筛选;表单里「空 = 24;0 = 关」等等式改成 placeholder + 一句说明
- 设置:浏览器服务、元数据识别、MCP 说明去掉组件名与实现细节;会话目录与 fake 脚本只在开发构建显示;
  API Key scope 前加中文名;命名模板变量去掉「MP 无」
- 待处理顶部计数卡改成一行筛选按钮
- 智能体悬浮按钮只在没有侧栏的窄屏出现,抽屉打开时不再显示「收起」胶囊

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 21:02:30 +08:00
1 parent 57ce40168d
commit 2c8c33c31c
15 files changed
+185 -237

No files matched your search

@@ -128,7 +128,7 @@ function useHideOnScroll(): boolean {
/**
* 右下角悬浮按钮 + 全局快捷键。挂在 console layout 里一次。
* - Agent 会话页本身不显示(页面就是 Agent);最小化小窗在右下角时让位给小窗。
* - 抽屉打开时变成"收起",停在抽屉左侧(窄屏抽屉占满全宽,用抽屉自带的关闭钮,FAB 隐藏)。
* - 只在没有侧栏的窄屏显示(xl 起侧栏顶部就是入口);抽屉打开时隐藏,用抽屉自带的关闭钮。
*/
export function AgentLauncher() {
useAgentHotkey();
@@ -161,7 +161,8 @@ export function AgentLauncher() {
"transition-[transform,bottom,right] duration-layout ease-spring active:scale-[0.96] focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none",
"animate-in fade-in-0 zoom-in-95 duration-base motion-reduce:animate-none",
"md:px-2 md:pr-5",
open && "z-[60] hidden md:right-[calc(36rem+1rem)] md:flex",
// 桌面(xl 起)侧栏已有入口,抽屉打开时用抽屉自己的关闭钮:这两种情况都不显示悬浮按钮
open ? "hidden" : "xl:hidden",
!open && scrolledAway && "translate-x-[calc(100%+2rem)] focus-visible:translate-x-0",
)}
>
@@ -8,6 +8,7 @@ import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath, settingsPath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
@@ -37,10 +38,11 @@ function VolumeBar({ v }: { v: Volume }) {
<span className="truncate">{volumeLabel(v)}</span>
</span>
<span className="text-meta text-muted-foreground tabular-nums">
{pct === null ? "使用率未知" : `${pct.toFixed(1)}%`}
{v.usedBytes !== null && v.totalBytes !== null
? ` · ${formatBytes(v.usedBytes, 1)} / ${formatBytes(v.totalBytes, 1)}`
: ""}
? `已用 ${formatBytes(v.usedBytes, 0)} / ${formatBytes(v.totalBytes, 1)}`
: pct === null
? "用量未知"
: `已用 ${Math.round(pct)}%`}
</span>
</div>
<div className="relative mt-1.5 h-1.5 rounded-full bg-foreground/[0.08]">
@@ -57,28 +59,32 @@ function VolumeBar({ v }: { v: Volume }) {
aria-hidden
className="absolute -top-1 h-3.5 w-px bg-destructive"
style={{ left: `${v.hardThresholdPercent}%` }}
title={`硬阈值 ${v.hardThresholdPercent}%`}
title={`暂停线 ${v.hardThresholdPercent}%`}
/>
<span
aria-hidden
className="absolute -top-1 h-3.5 w-px bg-muted-foreground"
style={{ left: `${v.resumePercent}%` }}
title={`恢复水位 ${v.resumePercent}%`}
title={`恢复线 ${v.resumePercent}%`}
/>
</div>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-meta text-muted-foreground">
<span>
硬阈值 {v.hardThresholdPercent}% / 恢复 {v.resumePercent}%
</span>
<span>在途 {formatBytes(v.pendingBytes, 1)}</span>
<span>预留 {formatBytes(v.reservedBytes, 1)}</span>
<span>可清理 {formatBytes(v.cleanableBytes, 1)}</span>
<span>保留锁定 {formatBytes(v.retentionLockedBytes, 1)}</span>
</div>
<TechInfo
title="详情"
className="mt-1"
items={[
{ label: "暂停线 / 恢复线", value: `${v.hardThresholdPercent}% / ${v.resumePercent}%`, mono: false },
{ label: "下载中还要占用", value: formatBytes(v.pendingBytes, 1), mono: false },
{ label: "已预留", value: formatBytes(v.reservedBytes, 1), mono: false },
{ label: "可清理", value: formatBytes(v.cleanableBytes, 1), mono: false },
{ label: "做种保留中", value: formatBytes(v.retentionLockedBytes, 1), mono: false },
]}
/>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{pct === null ? <StatusBadge tone="warning">读不到磁盘状态,已暂停新提交</StatusBadge> : null}
{tripped ? <StatusBadge tone="destructive">已达硬阈值,停止新提交</StatusBadge> : null}
{v.pausedByThreshold ? <StatusBadge tone="warning">阈值暂停 {v.pausedByThreshold} 个任务</StatusBadge> : null}
{tripped ? <StatusBadge tone="destructive">超过暂停线,停止新下载</StatusBadge> : null}
{v.pausedByThreshold ? (
<StatusBadge tone="warning">因下载盘暂停 {v.pausedByThreshold} 个任务</StatusBadge>
) : null}
</div>
{v.notes.map((n) => (
<p key={n} className="mt-1 text-meta text-muted-foreground">
@@ -97,7 +103,7 @@ export function CacheVolumes() {
return (
<section className="pg-slab mb-6 rounded-xl p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">缓存水位</h2>
<h2 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">下载盘</h2>
<div className="flex flex-wrap gap-2">
<Button asChild variant="ghost" size="sm">
<Link to={settingsPath("automation", "cache")}>
@@ -50,7 +50,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}
@@ -71,14 +71,14 @@ export function PauseResume({ t, size = "sm" }: { t: DownloadTask; size?: "sm" |
variant="outline"
size={size}
disabled={act.isPending}
title={threshold ? "阈值暂停,缓存回落到恢复水位后自动恢复" : undefined}
title={threshold ? "下载盘超过暂停线,降到恢复线后自动恢复" : undefined}
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
act.mutate(
{ id: t.id, action: canResume ? "resume" : "pause" },
{
onSuccess: () => toast.success(canResume ? "已恢复" : "已暂停。阈值回落时不会自动恢复"),
onSuccess: () => toast.success(canResume ? "已恢复" : "已暂停。下载盘回落时不会自动恢复"),
onError: (err) => toast.error(errorMessage(err)),
},
);
@@ -444,7 +444,6 @@ function VariablePalette({
>
<span className="flex flex-wrap items-center gap-1.5">
<span className="font-mono text-caption text-foreground">{v.name}</span>
{v.ours ? <span className="text-micro text-muted-foreground">MP 无</span> : null}
{v.example ? (
<span className="ml-auto max-w-full truncate font-mono text-meta text-muted-foreground">
{v.example}
@@ -86,7 +86,7 @@ function McpServerBlock() {
section="agent"
anchor="mcp"
title="MCP 服务端"
description="外部智能体用 API Key 经 MCP 调用同一套能力。授权、审计、幂等与控制台一致,有副作用的工具没有持续授权或单次审批时被拒绝。"
description="外部程序用 API Key 经 MCP 调用和智能体同一套能力,授权与审计规则相同。"
>
{status.isPending ? (
<LoadingState rows={3} />
+50 -45
View File
@@ -1,7 +1,7 @@
import { AgentToolsBlock } from "./agent-tools.tsx";
import { WEBSEARCH_CARD } from "./agent-websearch.tsx";
import { PiUpstreamsBlock } from "./pi-upstreams.tsx";
import type { FieldValue } from "./resource-section.tsx";
import type { FieldSpec, FieldValue } from "./resource-section.tsx";
import { num, str } from "./resource-section.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
@@ -140,53 +140,58 @@ const INVESTIGATE_CARD: CardSpec = {
}),
};
const DEV_ONLY_FIELDS = new Set(["agent.sessionDir", "agent.fakeScript"]);
const ADVANCED_CARD: CardSpec = {
anchor: "advanced",
title: "高级",
fields: [
{ name: "agent.goalPollSec", label: "目标轮询间隔(秒)", kind: "number", hint: "等下载、入库结束时的检查间隔" },
{
name: "agent.goalWaitHours",
label: "目标最长等待(小时)",
kind: "number",
hint: "超时后按「仍在等待」汇报结束",
},
{
name: "agent.optionsTimeoutSec",
label: "方案选择等待(秒)",
kind: "number",
hint: "智能体给出方案后等你选择的最长时间",
},
{
name: "agent.optionsTimeoutAction",
label: "超时未选",
kind: "select",
options: [
{ value: "recommended", label: "自动选推荐项" },
{ value: "stop", label: "不选,智能体收尾" },
],
},
{
name: "agent.approvalTimeoutSec",
label: "会话内审批有效期(秒)",
kind: "number",
hint: "步骤处的审批卡过期未批准,按拒绝处理",
},
{
name: "agent.sessionDir",
label: "会话目录",
kind: "text",
wide: true,
placeholder: "留空用 DATA_DIR/agent-sessions",
hint: "绝对路径,api 与 worker 都要可读写;有运行中的会话时不要改",
},
{
name: "agent.fakeScript",
label: "fake 脚本(JSON)",
kind: "textarea",
hint: "只对 fake 模型服务生效,留空用内置演示脚本;格式见 docs/runbook/agent.md",
},
],
// 会话目录与 fake 脚本是开发调试项,生产构建不显示(DEAI-2)
fields: (
[
{ name: "agent.goalPollSec", label: "目标轮询间隔(秒)", kind: "number", hint: "等下载、入库结束时的检查间隔" },
{
name: "agent.goalWaitHours",
label: "目标最长等待(小时)",
kind: "number",
hint: "超时后按「仍在等待」汇报结束",
},
{
name: "agent.optionsTimeoutSec",
label: "方案选择等待(秒)",
kind: "number",
hint: "智能体给出方案后等你选择的最长时间",
},
{
name: "agent.optionsTimeoutAction",
label: "超时未选",
kind: "select",
options: [
{ value: "recommended", label: "自动选推荐项" },
{ value: "stop", label: "不选,智能体收尾" },
],
},
{
name: "agent.approvalTimeoutSec",
label: "会话内审批有效期(秒)",
kind: "number",
hint: "步骤处的审批卡过期未批准,按拒绝处理",
},
{
name: "agent.sessionDir",
label: "会话目录",
kind: "text",
wide: true,
placeholder: "留空用 DATA_DIR/agent-sessions",
hint: "绝对路径,api 与 worker 都要可读写;有运行中的会话时不要改",
},
{
name: "agent.fakeScript",
label: "fake 脚本(JSON)",
kind: "textarea",
hint: "只对 fake 模型服务生效,留空用内置演示脚本;格式见 docs/runbook/agent.md",
},
] as FieldSpec[]
).filter((f) => import.meta.env.DEV || !DEV_ONLY_FIELDS.has(f.name)),
fromSettings: (s) => ({
"agent.goalPollSec": n(s.agent.goalPollSec),
"agent.goalWaitHours": n(s.agent.goalWaitHours),
@@ -25,10 +25,10 @@ import { SettingsBlock } from "./settings-block.tsx";
// ─── API Key ───
const SCOPES: { value: ApiScope; label: string; hint: string }[] = [
{ value: "read", label: "read", hint: "只读查询全部数据" },
{ value: "organize.read", label: "organize.read", hint: "只读整理记录,供外部增量同步" },
{ value: "organize.execute", label: "organize.execute", hint: "发起整理" },
{ value: "admin", label: "admin", hint: "全部管理权限,含改设置" },
{ value: "read", label: "只读 · read", hint: "只读查询全部数据" },
{ value: "organize.read", label: "读整理记录 · organize.read", hint: "只读整理记录,供外部增量同步" },
{ value: "organize.execute", label: "执行整理 · organize.execute", hint: "发起整理" },
{ value: "admin", label: "管理员 · admin", hint: "全部管理权限,含改设置" },
];
function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; onClose: () => void }) {
@@ -200,7 +200,7 @@ export function ApiKeysSection() {
checked={on}
onChange={() => setScopes((xs) => (on ? xs.filter((x) => x !== s.value) : [...xs, s.value]))}
/>
<code className="text-caption">{s.label}</code>
<span className="text-caption">{s.label}</span>
<span className="text-meta text-muted-foreground">{s.hint}</span>
</label>
);
@@ -250,7 +250,7 @@ function ScopeLegend() {
{SCOPES.map((s) => (
<div key={s.value} className="contents">
<dt>
<code className="rounded-tight bg-foreground/[0.06] px-1.5 py-0.5">{s.label}</code>
<span className="rounded-tight bg-foreground/[0.06] px-1.5 py-0.5">{s.label}</span>
</dt>
<dd className="text-muted-foreground">{s.hint}</dd>
</div>
@@ -97,7 +97,7 @@ export function BrowserProvidersBlock() {
section="sites"
anchor="browser"
title="浏览器服务"
description="原站视图和智能体打开站点网页用的 camofox-browser。部署自带的一台由环境变量登记(camofox-env)。"
description="打开站点网页用(原站视图、智能体)。随部署提供的一台显示为内置。"
plain
>
<ResourceSection<BrowserProviderView>
@@ -202,21 +202,20 @@ function ImageCacheStatus() {
const IDENTITY_CARD: CardSpec = {
anchor: "identity",
title: "资源识别",
description:
"缺省只在资源命中订阅或本地作品别名、新作品收录规则前置条件通过、你手动识别或搜索时查 TMDB;其余进未识别资源,不进待处理。",
description: "默认只在资源命中订阅、作品别名或新作品规则,或你手动识别和搜索时查 TMDB;其余放进未识别,不进待处理。",
fields: [
{
name: "identity.eagerTmdbLookup",
label: "每条新资源都查 TMDB",
kind: "switch",
hint: "旧行为。每条资源约 1–2 次搜索 + 命中时 1 次详情(识别缓存命中为 0);每小时 100 条的 RSS 约每天 2400–7200 次请求,站点独有的短剧 / 综艺多半查不到",
hint: "每条新资源都查一次 TMDB。RSS 量大时每天可能上千次请求,站点独有的短剧和综艺多半查不到",
wide: true,
},
{
name: "identity.autoEnrich",
label: "TMDB 查不到时自动补全元数据",
kind: "switch",
hint: "资源命中订阅或新作品收录规则、TMDB 却没有结果时,自动搜索网页出一份补全建议(每条资源一次,不建作品)。每次约 1–2 次网页搜索、最多读 3 个网页、1 次 模型服务调用",
hint: "资源命中订阅或新作品规则、TMDB 却查不到时,搜索网页出一份补全建议(每条资源一次,不建作品)。每次最多读 3 个网页,调用一次模型服务",
wide: true,
},
{
@@ -591,11 +591,12 @@ export function SubscriptionFormDialog({
label="总集数"
htmlFor={f("total")}
error={err("total")}
hint={d.totalLocked ? "锁定后元数据刷新不再改写" : "空 = 按 TMDB;不锁定时只升不降"}
hint={d.totalLocked ? "锁定后元数据刷新不再改写" : "留空按 TMDB"}
>
<Input
id={f("total")}
inputMode="numeric"
placeholder="按 TMDB"
value={d.total}
aria-invalid={!!err("total")}
onChange={(e) => {
@@ -729,11 +730,7 @@ export function SubscriptionFormDialog({
<TabsContent value="advanced" className="mt-4 min-w-0 space-y-5">
<Section title="过滤">
<FormField
label="规则组"
htmlFor={f("groups")}
hint="按选择顺序串联;不选 = 订阅默认规则组。收录版本自己的规则组另算"
>
<FormField label="规则组" htmlFor={f("groups")} hint="不选时用默认规则组">
{groups.isPending ? (
<p className="text-caption text-muted-foreground">读取规则组…</p>
) : (groups.data?.items ?? []).length === 0 ? (
@@ -835,11 +832,12 @@ export function SubscriptionFormDialog({
label="主动搜索间隔(小时)"
htmlFor={f("interval")}
error={err("searchIntervalHours")}
hint="空 = 24;0 = 关"
hint="0 表示不主动搜索"
>
<Input
id={f("interval")}
inputMode="decimal"
placeholder="24"
value={d.searchIntervalHours}
aria-invalid={!!err("searchIntervalHours")}
onChange={(e) => patch({ searchIntervalHours: e.target.value })}
@@ -161,7 +161,12 @@ export function FeedTable({
);
},
},
{ key: "purpose", header: "用途", hideBelow: "md", cell: (f) => PURPOSE_LABEL[f.purpose] },
{
key: "purpose",
header: "用途",
hideBelow: "md",
cell: (f) => (f.purpose === "generic" ? null : PURPOSE_LABEL[f.purpose]),
},
{
key: "site",
header: "站点 / 账号",