feat(web): make the site workbench the home of sites

- 站点工作台: 登记站点 dialog (built-in site definitions or custom).
- Site detail tabs: 整理视图 / 原站视图 / 快照与 Feed / 账号 (account list +
  credential form).
- 来源健康 (was 来源/Feed/快照): cross-site health table + all feeds; site
  names deep-link to the site's 快照与 Feed tab. Feed table extracted to
  components/workbench/feed-table.tsx.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 02:46:55 +08:00
1 parent 909cd2ad49
commit ddb15145d8
7 files changed
+805 -296

No files matched your search

+52 -6
View File
@@ -1,18 +1,21 @@
import type { ResolutionStatus, WorkbenchRelease } from "@umt/contracts";
import { ArrowLeft, Bot, Camera, Globe, Radar, SearchX, TriangleAlert } from "lucide-react";
import { ArrowLeft, Bot, Camera, Globe, Plus, Radar, SearchX, TriangleAlert } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router";
import { Link, useParams, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useInventory } from "~/api/inventory.ts";
import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts";
import { snapshotStale, useSite, useSiteAccounts, useSnapshotSite } from "~/api/sites.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.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 { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { FeedTable } from "~/components/workbench/feed-table.tsx";
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
import {
accountSummary,
@@ -24,6 +27,8 @@ import {
SITE_HEALTH,
} from "~/components/workbench/release-fields.ts";
import { ReleaseRow } from "~/components/workbench/release-list.tsx";
import { SiteAccountsPanel } from "~/components/workbench/site-accounts.tsx";
import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx";
import { errorMessage } from "~/lib/api.ts";
const PROMOS = [
@@ -84,8 +89,16 @@ function useDebounced<T>(value: T, ms = 400) {
return v;
}
const TABS = ["organized", "origin", "snapshots", "accounts"] as const;
export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }];
export default function SiteWorkbenchPage() {
const { siteId = "" } = useParams();
const [params, setParams] = useSearchParams();
const rawTab = params.get("tab") ?? "";
const tab = (TABS as readonly string[]).includes(rawTab) ? rawTab : "organized";
const [creatingFeed, setCreatingFeed] = useState(false);
const site = useSite(siteId);
const accounts = useSiteAccounts(siteId);
const snap = useSnapshotSite();
@@ -169,9 +182,9 @@ export default function SiteWorkbenchPage() {
actions={
<>
<Button asChild variant="ghost" size="sm">
<Link to="/console/sites">
<Link to={consolePath("sites")}>
<ArrowLeft />
站点
站点工作台
</Link>
</Button>
<Button
@@ -185,10 +198,24 @@ export default function SiteWorkbenchPage() {
}
/>
<Tabs defaultValue="organized">
<TabsList>
<Tabs
value={tab}
onValueChange={(v) =>
setParams(
(p) => {
if (v === "organized") p.delete("tab");
else p.set("tab", v);
return p;
},
{ replace: true },
)
}
>
<TabsList aria-label="站点分节">
<TabsTrigger value="organized">整理视图</TabsTrigger>
<TabsTrigger value="origin">原站视图</TabsTrigger>
<TabsTrigger value="snapshots">快照与 Feed</TabsTrigger>
<TabsTrigger value="accounts">账号</TabsTrigger>
</TabsList>
<TabsContent value="organized">
@@ -337,6 +364,25 @@ export default function SiteWorkbenchPage() {
description={<>计划在 M2 提供。资源详情里有站点 ID 和来源时间,可据此到原站查看。</>}
/>
</TabsContent>
<TabsContent value="snapshots">
<div className="space-y-8">
{s ? <SnapshotPanel site={s} /> : <LoadingState rows={3} />}
<DetailSection
title="Feed"
actions={
<Button size="sm" variant="outline" onClick={() => setCreatingFeed(true)}>
<Plus />
新建 Feed
</Button>
}
>
<FeedTable siteId={siteId} creating={creatingFeed} onCreatingChange={setCreatingFeed} />
</DetailSection>
</div>
</TabsContent>
<TabsContent value="accounts">{s ? <SiteAccountsPanel site={s} /> : <LoadingState rows={3} />}</TabsContent>
</Tabs>
<ReleaseDetailSheet
+29 -20
View File
@@ -1,23 +1,19 @@
import type { Site, SiteAccount } from "@umt/contracts";
import { ArrowRight, Camera, Globe, RefreshCw } from "lucide-react";
import { ArrowRight, Camera, Globe, Plus, RefreshCw } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { snapshotStale, useSiteAccounts, useSites, useSnapshotSite, useToggleSite } from "~/api/sites.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.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 { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { accountSummary, circuitLabel, relTime, SITE_HEALTH } from "~/components/workbench/release-fields.ts";
import { ADAPTER_LABEL, SiteRegisterDialog } from "~/components/workbench/site-register-dialog.tsx";
import { errorMessage } from "~/lib/api.ts";
const ADAPTER_LABEL: Record<Site["adapter"], string> = {
nexusphp: "NexusPHP",
mteam_api: "M-Team API",
api: "站点 API",
generic_rss: "通用 RSS",
};
function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
const toggle = useToggleSite();
const snap = useSnapshotSite();
@@ -31,7 +27,7 @@ function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Link
to={`/console/sites/${site.id}`}
to={consolePath("sites", site.id)}
className="block truncate text-lead font-semibold text-foreground hover:underline"
>
{site.name}
@@ -62,7 +58,9 @@ function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
<div className="mt-3 flex flex-wrap gap-1.5">
<StatusBadge tone={health.tone}>{health.label}</StatusBadge>
<StatusBadge tone={account.tone}>账号:{account.label}</StatusBadge>
<Link to={`${consolePath("sites", site.id)}?tab=accounts`} className="hover:opacity-80">
<StatusBadge tone={account.tone}>账号:{account.label}</StatusBadge>
</Link>
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
</div>
@@ -102,8 +100,8 @@ function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
快照
</Button>
<Button asChild size="sm" variant="outline">
<Link to={`/console/sites/${site.id}`}>
打开工作台
<Link to={consolePath("sites", site.id)}>
打开
<ArrowRight />
</Link>
</Button>
@@ -112,8 +110,11 @@ function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
);
}
export const meta = () => [{ title: "站点工作台 · UHD 媒体自动化控制台" }];
export default function SitesPage() {
const sites = useSites();
const [registering, setRegistering] = useState(false);
const accounts = useSiteAccounts();
const bySite = new Map<string, SiteAccount[]>();
for (const a of accounts.data?.items ?? []) bySite.set(a.siteId, [...(bySite.get(a.siteId) ?? []), a]);
@@ -121,12 +122,18 @@ export default function SitesPage() {
return (
<>
<PageHeader
title="PT 站点工作台"
title="站点工作台"
actions={
<Button variant="outline" size="sm" onClick={() => void sites.refetch()} disabled={sites.isFetching}>
<RefreshCw className={sites.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
<>
<Button variant="outline" size="sm" onClick={() => void sites.refetch()} disabled={sites.isFetching}>
<RefreshCw className={sites.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
<Button size="sm" onClick={() => setRegistering(true)}>
<Plus />
登记站点
</Button>
</>
}
/>
{sites.isPending ? (
@@ -139,10 +146,11 @@ export default function SitesPage() {
<EmptyState
icon={Globe}
title="还没有登记站点"
description="在来源页或系统设置里登记站点和账号后,站点会列在这里。"
description="登记站点并填写账号凭证后,可在这里浏览和搜索资源。"
action={
<Button asChild variant="outline" size="sm">
<Link to="/console/sources">去来源页</Link>
<Button size="sm" onClick={() => setRegistering(true)}>
<Plus />
登记站点
</Button>
}
/>
@@ -153,6 +161,7 @@ export default function SitesPage() {
))}
</div>
)}
{registering ? <SiteRegisterDialog open onOpenChange={setRegistering} /> : null}
</>
);
}
+91 -269
View File
@@ -1,188 +1,116 @@
import type { FeedHealth, FeedSource } from "@umt/contracts";
import { Plus, RefreshCw, RotateCcw, Rss, Zap } from "lucide-react";
import type { FeedHealth, Site } from "@umt/contracts";
import { Globe, Plus, RefreshCw } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { feedGapHint, useFeedAction, useFeeds, useFetchFeedNow } from "~/api/feeds.ts";
import { useSiteAccounts, useSites } from "~/api/sites.ts";
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 { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.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 { Switch } from "~/components/ui/switch.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { FeedFormDialog, PURPOSE_LABEL } from "~/components/workbench/feed-form.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
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";
const HEALTH: Record<FeedHealth, { label: string; tone: StatusTone }> = {
unknown: { label: "未抓取", tone: "neutral" },
healthy: { label: "健康", tone: "success" },
degraded: { label: "降级", tone: "warning" },
failing: { label: "失败", tone: "destructive" },
circuit_open: { label: "熔断", tone: "destructive" },
disabled: { label: "已停用", tone: "neutral" },
};
// 来源健康:跨站点的 Feed 列表与站点健康总表;单个站点的快照、Feed、账号在站点详情。
const lastErrorText = (f: FeedSource) =>
f.lastError ? String((f.lastError as { message?: unknown }).message ?? JSON.stringify(f.lastError)) : null;
export const meta = () => [{ title: "来源健康 · UHD 媒体自动化控制台" }];
/** 游标窗口摘要:服务端 cursor.window 的条目数与上次抓取时间 */
function cursorSummary(f: FeedSource): string {
const w = f.cursor.window as { lastFetchedAt?: string | null; lastGuidSet?: unknown[] } | undefined;
if (!w) return "尚无游标";
const n = Array.isArray(w.lastGuidSet) ? w.lastGuidSet.length : null;
return `${n !== null ? `窗口 ${n} 条` : "窗口"}${w.lastFetchedAt ? ` · ${relTime(w.lastFetchedAt)}` : ""}`;
}
function FeedActions({ f }: { f: FeedSource }) {
const fetchNow = useFetchFeedNow();
const action = useFeedAction();
return (
<div className="flex items-center justify-end gap-1">
<Switch
checked={f.enabled}
disabled={action.isPending}
aria-label={f.enabled ? "停用" : "启用"}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
onCheckedChange={(v) =>
action.mutate(
{ id: f.id, action: v ? "enable" : "disable" },
{ onSuccess: () => toast.success(v ? "已启用" : "已停用"), onError: (e) => toast.error(errorMessage(e)) },
)
}
/>
<Button
variant="ghost"
size="icon-sm"
title="立即抓取(受站点限流)"
aria-label="立即抓取"
disabled={!f.enabled || fetchNow.isPending}
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
fetchNow.mutate(f.id, {
onSuccess: () => toast.success("抓取已排队"),
onError: (err) => toast.error(errorMessage(err)),
});
}}
>
<Zap />
</Button>
</div>
);
}
// 旧的 ?tab=snapshots&siteId= 跳到站点详情的"快照与 Feed"
export const clientLoader = legacyRedirectLoader;
export default function SourcesPage() {
const [health, setHealth] = useState("");
const [siteFilter, setSiteFilter] = useState("");
const feeds = useFeeds({ health: (health || undefined) as FeedHealth | undefined, siteId: siteFilter || undefined });
const f = useQueryState(["health", "siteId"] as const);
const [creating, setCreating] = useState(false);
const sites = useSites();
const accounts = useSiteAccounts();
const [editing, setEditing] = useState<FeedSource | null | "new">(null);
const [openId, setOpenId] = useState<string | null>(null);
const action = useFeedAction();
const siteName = new Map((sites.data?.items ?? []).map((s) => [s.id, s.name]));
const accountName = new Map((accounts.data?.items ?? []).map((a) => [a.id, a.label]));
const open = feeds.data?.items.find((f) => f.id === openId) ?? null;
const feeds = useFeeds({
health: (f.values.health || undefined) as FeedHealth | undefined,
siteId: f.values.siteId || undefined,
});
const columns: DataColumn<FeedSource>[] = [
const siteColumns: DataColumn<Site>[] = [
{
key: "name",
header: "名称",
cell: (f) => (
<div className="min-w-0 max-w-xs">
<p className="truncate font-medium text-foreground">{f.name}</p>
<p className="truncate font-mono text-meta text-muted-foreground" title={f.urlDisplay}>
{f.urlDisplay}
</p>
</div>
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: (f) => {
const gap = feedGapHint(f);
return (
<div className="flex flex-col items-start gap-1">
<StatusBadge tone={HEALTH[f.healthStatus].tone}>{HEALTH[f.healthStatus].label}</StatusBadge>
{gap ? (
<span title={gap}>
<StatusBadge tone="warning">{f.possibleGap ? "疑似缺口" : "可能漏项"}</StatusBadge>
</span>
) : null}
</div>
);
},
},
{ key: "purpose", header: "用途", hideBelow: "md", cell: (f) => PURPOSE_LABEL[f.purpose] },
{
key: "site",
header: "站点 / 账号",
hideBelow: "lg",
cell: (f) => (
<span className="text-muted-foreground">
{f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"}
{f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""}
</span>
),
},
{
key: "poll",
header: "轮询",
hideBelow: "lg",
cell: (f) => <span className="whitespace-nowrap">{Math.round(f.pollIntervalSec / 60)} 分钟</span>,
},
{
key: "success",
header: "最后成功",
hideBelow: "sm",
cell: (f) => (
<div className="whitespace-nowrap">
<p className="text-foreground">{relTime(f.lastSuccessAt)}</p>
{f.consecutiveFailures ? (
<p className="text-meta text-destructive">连续失败 {f.consecutiveFailures}</p>
) : null}
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: "ops", header: "", align: "right", cell: (f) => <FeedActions f={f} /> },
{
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
title="来源 / Feed / 快照"
title="来源健康"
description="单个站点的快照、Feed 与账号在站点工作台。"
actions={
<Button size="sm" onClick={() => setEditing("new")}>
<Button size="sm" onClick={() => setCreating(true)}>
<Plus />
新建 Feed
</Button>
}
/>
<Tabs defaultValue="feeds">
<TabsList>
<TabsTrigger value="feeds">Feed</TabsTrigger>
<TabsTrigger value="snapshots">站点快照</TabsTrigger>
</TabsList>
<TabsContent value="feeds">
<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="Feed">
<FilterBar
onReset={
health || siteFilter
? () => {
setHealth("");
setSiteFilter("");
}
: undefined
}
onReset={f.active ? f.reset : undefined}
actions={
<Button variant="ghost" size="sm" onClick={() => void feeds.refetch()} disabled={feeds.isFetching}>
<RefreshCw className={feeds.isFetching ? "animate-spin" : undefined} />
@@ -192,132 +120,26 @@ export default function SourcesPage() {
>
<FilterSelect
label="健康"
value={health}
onChange={setHealth}
options={Object.entries(HEALTH).map(([value, h]) => ({ value, label: h.label }))}
value={f.values.health}
onChange={(v) => f.set("health", v)}
options={Object.entries(FEED_HEALTH).map(([value, h]) => ({ value, label: h.label }))}
/>
<FilterSelect
label="站点"
value={siteFilter}
onChange={setSiteFilter}
value={f.values.siteId}
onChange={(v) => f.set("siteId", v)}
options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))}
/>
</FilterBar>
<DataTable
columns={columns}
rows={feeds.data?.items}
rowKey={(f) => f.id}
loading={feeds.isPending}
error={feeds.error}
onRetry={() => void feeds.refetch()}
onRowClick={(f) => setOpenId(f.id)}
selectedKey={openId}
empty={{
icon: Rss,
title: health || siteFilter ? "没有符合筛选的 Feed" : "还没有 Feed",
description: health || siteFilter ? undefined : "新建 Feed 后按轮询间隔抓取,新资源进入识别和收录评估。",
}}
<FeedTable
health={(f.values.health || undefined) as FeedHealth | undefined}
siteId={f.values.siteId || undefined}
filtered={f.active}
creating={creating}
onCreatingChange={setCreating}
/>
</TabsContent>
<TabsContent value="snapshots">
{sites.isPending ? (
<LoadingState rows={3} />
) : sites.error ? (
<ErrorState error={sites.error} onRetry={() => void sites.refetch()} />
) : !sites.data.items.length ? (
<EmptyState title="还没有登记站点" description="登记站点后,这里显示快照覆盖范围和新鲜度。" />
) : (
<div className="grid gap-4 lg:grid-cols-2">
{sites.data.items.map((s) => (
<SnapshotPanel key={s.id} site={s} />
))}
</div>
)}
</TabsContent>
</Tabs>
<DetailSheet
open={!!open}
onOpenChange={(o) => !o && setOpenId(null)}
title={open?.name ?? ""}
description={open ? <span className="font-mono break-all">{open.urlDisplay}</span> : undefined}
footer={
open ? (
<>
{open.healthStatus === "circuit_open" || open.healthStatus === "failing" || open.backoffUntil ? (
<Button
variant="outline"
size="sm"
disabled={action.isPending}
onClick={() =>
action.mutate(
{ id: open.id, action: "reset" },
{
onSuccess: () => toast.success("熔断已复位"),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
>
<RotateCcw />
复位熔断
</Button>
) : null}
<Button size="sm" onClick={() => setEditing(open)}>
编辑
</Button>
</>
) : null
}
>
{open ? (
<>
<div className="flex flex-wrap gap-1.5">
<StatusBadge tone={HEALTH[open.healthStatus].tone}>{HEALTH[open.healthStatus].label}</StatusBadge>
<StatusBadge>{open.enabled ? "已启用" : "已停用"}</StatusBadge>
<StatusBadge>{PURPOSE_LABEL[open.purpose]}</StatusBadge>
</div>
{feedGapHint(open) ? (
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
{feedGapHint(open)}
</p>
) : null}
{lastErrorText(open) ? (
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption break-words text-destructive">
{lastErrorText(open)}
</p>
) : null}
<DetailSection title="抓取状态">
<KvList
items={[
{ label: "最后成功", value: formatDateTime(open.lastSuccessAt) },
{ label: "最后抓取", value: formatDateTime(open.lastFetchAt) },
{ label: "下次抓取", value: formatDateTime(open.nextFetchAt) },
{ label: "退避至", value: formatDateTime(open.backoffUntil) },
{ label: "连续失败", value: open.consecutiveFailures },
{ label: "轮询间隔", value: `${Math.round(open.pollIntervalSec / 60)} 分钟` },
{ label: "游标", value: cursorSummary(open) },
{ label: "解析器", value: open.parserVersion, mono: true },
{ label: "ETag", value: open.etag, mono: true },
{ label: "Last-Modified", value: open.lastModified, mono: true },
]}
/>
</DetailSection>
<JsonView label="过滤条件" value={open.filter} />
<JsonView label="游标原文" value={open.cursor} />
</>
) : null}
</DetailSheet>
{editing ? (
<FeedFormDialog
open
onOpenChange={(o) => !o && setEditing(null)}
feed={editing === "new" ? null : editing}
sites={sites.data?.items ?? []}
accounts={accounts.data?.items ?? []}
/>
) : null}
</DetailSection>
</div>
</>
);
}