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
+86 -138

No files matched your search

+1 -1
View File
@@ -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} />
+8 -3
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,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: "站点 / 账号",
+15 -17
View File
@@ -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>
)}
+5 -73
View File
@@ -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 ? (