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:
1 parent
57ce40168d
commit
2c8c33c31c
15 files changed
+86
-138
No files matched your search
@@ -155,7 +155,7 @@ export const DOWNLOAD_STATUS_TONE: Record<DownloadStatus, "success" | "warning"
|
||||
|
||||
export const PAUSE_REASON_LABEL = {
|
||||
user: "用户暂停",
|
||||
threshold: "阈值暂停",
|
||||
threshold: "下载盘满暂停",
|
||||
policy: "策略暂停",
|
||||
agent: "智能体暂停",
|
||||
} as const;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,10 +140,14 @@ const INVESTIGATE_CARD: CardSpec = {
|
||||
}),
|
||||
};
|
||||
|
||||
const DEV_ONLY_FIELDS = new Set(["agent.sessionDir", "agent.fakeScript"]);
|
||||
|
||||
const ADVANCED_CARD: CardSpec = {
|
||||
anchor: "advanced",
|
||||
title: "高级",
|
||||
fields: [
|
||||
// 会话目录与 fake 脚本是开发调试项,生产构建不显示(DEAI-2)
|
||||
fields: (
|
||||
[
|
||||
{ name: "agent.goalPollSec", label: "目标轮询间隔(秒)", kind: "number", hint: "等下载、入库结束时的检查间隔" },
|
||||
{
|
||||
name: "agent.goalWaitHours",
|
||||
@@ -186,7 +190,8 @@ const ADVANCED_CARD: CardSpec = {
|
||||
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: "站点 / 账号",
|
||||
|
||||
@@ -12,7 +12,6 @@ import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatTile } from "~/components/console/stat-tile.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
|
||||
@@ -520,25 +519,24 @@ export default function InboxPage() {
|
||||
{summary.isError ? (
|
||||
<ErrorState className="mb-4" title="汇总加载失败" error={summary.error} onRetry={() => summary.refetch()} />
|
||||
) : (
|
||||
<div className="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<StatTile
|
||||
label={isOpenView ? "未关闭" : (DECISION_STATUS[status as DecisionStatus]?.label ?? "全部")}
|
||||
value={summary.isPending ? "…" : (summary.data?.total ?? 0)}
|
||||
hint={isOpenView ? "含调查中、等你决定" : undefined}
|
||||
icon={Inbox}
|
||||
active={!kind}
|
||||
onClick={() => set("kind", "")}
|
||||
/>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-1.5" role="group" aria-label="按问题类型筛选">
|
||||
<Button variant={kind ? "ghost" : "secondary"} size="sm" onClick={() => set("kind", "")} aria-pressed={!kind}>
|
||||
<Inbox />
|
||||
{isOpenView ? "全部" : (DECISION_STATUS[status as DecisionStatus]?.label ?? "全部")}
|
||||
<span className="tabular-nums">{summary.isPending ? "…" : (summary.data?.total ?? 0)}</span>
|
||||
</Button>
|
||||
{byKind.map(([k, v]) => (
|
||||
<StatTile
|
||||
<Button
|
||||
key={k}
|
||||
label={DECISION_KIND[k]}
|
||||
value={v.count}
|
||||
hint={`最高级别:${SEVERITY[v.sev].label}`}
|
||||
tone={SEVERITY[v.sev].tone}
|
||||
active={kind === k}
|
||||
variant={kind === k ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
aria-pressed={kind === k}
|
||||
onClick={() => set("kind", kind === k ? "" : k)}
|
||||
/>
|
||||
className={v.sev === "critical" ? "text-destructive" : undefined}
|
||||
>
|
||||
{DECISION_KIND[k]}
|
||||
<span className="tabular-nums">{v.count}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import type { FeedHealth, Site } from "@umt/contracts";
|
||||
import { Globe, Plus, RefreshCw } from "lucide-react";
|
||||
import type { FeedHealth } from "@umt/contracts";
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useFeeds } from "~/api/feeds.ts";
|
||||
import { snapshotStale, useSiteAccounts, useSites } from "~/api/sites.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { useSites } from "~/api/sites.ts";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { useQueryState } from "~/components/console/use-query-state.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx";
|
||||
import { accountSummary, circuitLabel, relTime, SITE_HEALTH } from "~/components/workbench/release-fields.ts";
|
||||
import { legacyRedirectLoader } from "./redirects.ts";
|
||||
|
||||
// 来源健康:跨站点的 Feed 列表与站点健康总表;单个站点的快照、Feed、账号在站点详情。
|
||||
// RSS:跨站点的 RSS 列表;站点健康只在站点页(DEAI-2 去掉重复的站点表)。
|
||||
|
||||
export const meta = () => [{ title: "RSS · UHD 媒体自动化控制台" }];
|
||||
|
||||
@@ -27,59 +22,11 @@ export default function SourcesPage() {
|
||||
const f = useQueryState(["health", "siteId"] as const);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const sites = useSites();
|
||||
const accounts = useSiteAccounts();
|
||||
const feeds = useFeeds({
|
||||
health: (f.values.health || undefined) as FeedHealth | undefined,
|
||||
siteId: f.values.siteId || undefined,
|
||||
});
|
||||
|
||||
const siteColumns: DataColumn<Site>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "站点",
|
||||
cell: (s) => (
|
||||
<Link
|
||||
to={`${consolePath("sites", s.id)}?tab=snapshots`}
|
||||
className="font-medium text-foreground hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "health",
|
||||
header: "健康",
|
||||
cell: (s) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<StatusBadge tone={SITE_HEALTH[s.healthStatus].tone}>{SITE_HEALTH[s.healthStatus].label}</StatusBadge>
|
||||
{circuitLabel(s) ? <StatusBadge tone="warning">{circuitLabel(s)}</StatusBadge> : null}
|
||||
{!s.enabled && s.healthStatus !== "disabled" ? <StatusBadge>已停用</StatusBadge> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "account",
|
||||
header: "账号",
|
||||
hideBelow: "md",
|
||||
cell: (s) => {
|
||||
const a = accountSummary((accounts.data?.items ?? []).filter((x) => x.siteId === s.id));
|
||||
return <StatusBadge tone={a.tone}>{a.label}</StatusBadge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "snapshot",
|
||||
header: "最近抓取",
|
||||
hideBelow: "sm",
|
||||
cell: (s) => (
|
||||
<span className="flex flex-wrap items-center gap-1.5 whitespace-nowrap text-muted-foreground">
|
||||
{s.snapshotEnabled ? relTime(s.lastSnapshotAt) : "未开启"}
|
||||
{s.snapshotEnabled && snapshotStale(s) ? <StatusBadge tone="warning">已过期</StatusBadge> : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@@ -92,22 +39,7 @@ export default function SourcesPage() {
|
||||
}
|
||||
/>
|
||||
<div className="space-y-8">
|
||||
<DetailSection title="站点">
|
||||
<DataTable
|
||||
columns={siteColumns}
|
||||
rows={sites.data?.items}
|
||||
rowKey={(s) => s.id}
|
||||
loading={sites.isPending}
|
||||
error={sites.error}
|
||||
onRetry={() => void sites.refetch()}
|
||||
empty={{
|
||||
icon: Globe,
|
||||
title: "还没有登记站点",
|
||||
description: "在站点页添加。",
|
||||
}}
|
||||
/>
|
||||
</DetailSection>
|
||||
<DetailSection title="RSS">
|
||||
<DetailSection title="RSS 地址">
|
||||
<FilterBar
|
||||
onReset={f.active ? f.reset : undefined}
|
||||
actions={
|
||||
|
||||
@@ -274,6 +274,9 @@ export default function SubscriptionsPage() {
|
||||
mediaKind={(v.kind || undefined) as "series" | "movie" | "variety" | undefined}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* 一条订阅都没有时只留空态和「新建订阅」,不铺筛选 */}
|
||||
{allCount === 0 && !filtered && !v.group && !v.kind ? null : (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<Pills
|
||||
@@ -332,6 +335,8 @@ export default function SubscriptionsPage() {
|
||||
options={opts(SORT_LABEL)}
|
||||
/>
|
||||
</FilterBar>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selecting ? (
|
||||
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
|
||||
@@ -393,7 +398,7 @@ export default function SubscriptionsPage() {
|
||||
icon={CalendarClock}
|
||||
title={filtered || v.group || v.kind ? "没有符合条件的订阅" : "还没有订阅"}
|
||||
description={
|
||||
filtered || v.group || v.kind ? undefined : "从媒体库、搜索或智能体添加,也可以点「新建订阅」。"
|
||||
filtered || v.group || v.kind ? undefined : "在搜索或媒体库里点「订阅」,或点右上角「新建订阅」。"
|
||||
}
|
||||
/>
|
||||
) : grouped ? (
|
||||
|
||||
Reference in new issue
Block a user