Files
UHD_Media_Tool/apps/web/app/components/sites/site-sheet.tsx
T
rootandClaude Opus 5.5 21c55dfb3a feat(web): 记录抽屉与两层导航标准
控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?open=,刷新、分享、前进后退都能还原,
后退就是关抽屉。标准写在 docs/design/console-ia.md §9,ui-controls.md 引用。

- DetailSheet 加 xl 宽度、标题下操作行;抽屉里再开的 DetailSheet 在同一抽屉里换成子视图(返回 / Esc)
- record-sheet.tsx:useRecordSheet / RecordLink / useOpenRecord / recordPath / taskPath
- 整理记录、订阅、站点(含页签)、下载、日志任务改成抽屉;旧业务任务 / 下载任务 id 在任务抽屉里换算
- 回收站并进文件管理右侧视图(?view=trash);设置左栏只列分区
- 规则组、策略版本、媒体库未识别 / 外部身份、设置实例编辑走 ?open=
- 旧地址 /console/{organize,subscriptions,sites,downloads}/:id、/tasks/:id、/files/trash 跳到列表页抽屉
- 站内链接、铃铛、命令面板、任务时间线改拼新地址;冒烟按 ?open= 检查抽屉

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 14:03:47 +08:00

559 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FeedHealth, ResolutionStatus, SiteListItem, WorkbenchRelease } from "@umt/contracts";
import {
Activity,
Bot,
Camera,
Globe,
LoaderCircle,
Plus,
Radar,
RefreshCw,
SearchX,
TriangleAlert,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { useInventory } from "~/api/inventory.ts";
import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts";
import { accessSummary, snapshotStale, useSiteRequestStats, useSites, useSnapshotSite } from "~/api/sites.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { SiteOriginPanel } from "~/components/browser/desktop-view.tsx";
import { DetailSheet } 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 { useStickyId } from "~/components/console/record-sheet.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { QuickFilterBar } from "~/components/search/quick-filter-bar.tsx";
import { formatQuickFilter, selectedCount, useQuickFilters } from "~/components/search/quick-filter-state.ts";
import { SiteAccountsTab } from "~/components/sites/site-accounts-tab.tsx";
import { SiteMoreMenu } from "~/components/sites/site-card.tsx";
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
import {
ACCOUNT_OVERALL,
hasLogin,
healthLine,
hostOf,
kindLabel,
overallStatus,
primaryAccount,
siteCan,
} from "~/components/sites/site-fields.ts";
import { SiteOverview } from "~/components/sites/site-overview.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx";
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
import { categoryLabel, circuitLabel, isFree, relTime } from "~/components/workbench/release-fields.ts";
import { ReleaseRow } from "~/components/workbench/release-list.tsx";
import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx";
import { errorMessage } from "~/lib/api.ts";
const RESOLUTION: { value: ResolutionStatus; label: string }[] = [
{ value: "resolved", label: "已识别" },
{ value: "unresolved", label: "未识别" },
{ value: "ambiguous", label: "匹配到多部作品" },
{ value: "unidentifiable", label: "无法识别" },
];
const AGE = [
{ value: "1", label: "24 小时内" },
{ value: "7", label: "7 天内" },
{ value: "30", label: "30 天内" },
];
/**
* 服务端不支持的维度(上传时间)在已加载结果里筛选,界面上写明。H&R(POLISH-1)、做种与大小(SEARCH-4)
* 在快速筛选里,按全部本地资源计数和筛选。
*/
type LocalFilters = { age: string };
function localMatch(r: WorkbenchRelease, f: LocalFilters, now: number) {
if (f.age) {
const t = r.sourcePublishedAt ? new Date(r.sourcePublishedAt).getTime() : null;
if (t === null || now - t > Number(f.age) * 86_400_000) return false;
}
return true;
}
function useDebounced<T>(value: T, ms = 400) {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
const TABS = ["overview", "resources", "origin", "snapshots", "rss", "accounts"] as const;
type Tab = (typeof TABS)[number];
/** 旧页签(整理视图)落到"资源";旧 RSS 页的 ?tab=feeds 落到 RSS */
const LEGACY_TAB: Record<string, string> = { organized: "resources", feeds: "rss" };
/** 按站点类型与可用获取方式显示的 Tab(IA-2):自定义 RSS 源没有原站视图、抓取、账号 */
function visibleTabs(s: SiteListItem | undefined): Tab[] {
if (!s) return [...TABS];
return TABS.filter((t) => {
if (t === "origin") return s.kind !== "rss_feed";
if (t === "snapshots") return siteCan(s, "list");
if (t === "rss") return siteCan(s, "rss");
if (t === "accounts") return hasLogin(s);
return true;
});
}
const TAB_LABEL: Record<Tab, string> = {
overview: "概览",
resources: "资源",
origin: "原站视图",
snapshots: "抓取",
rss: "RSS",
accounts: "账号",
};
/**
* 站点抽屉(console-ia.md §9):站点页上 ?open=<站点 id> 打开的宽抽屉,里面一层页签(?tab=);
* 资源、RSS 的详情在同一个抽屉里换成子视图,不叠第二层。
*/
export function SiteSheet({
id,
params,
onPatch,
onClose,
}: {
id: string | null;
params: URLSearchParams;
onPatch: (extra: Record<string, string | null>) => void;
onClose: () => void;
}) {
const shown = useStickyId(id);
const sites = useSites();
const site = sites.data?.items.find((x) => x.id === shown);
return (
<DetailSheet
open={!!id}
onOpenChange={(o) => !o && onClose()}
width="xl"
title={site?.name ?? "站点"}
description={site ? <SiteBadges s={site} /> : undefined}
actions={site ? <SiteActions s={site} onDeleted={onClose} /> : undefined}
>
{shown ? <SiteWorkbench siteId={shown} params={params} onPatch={onPatch} onClose={onClose} /> : null}
</DetailSheet>
);
}
function SiteWorkbench({
siteId,
params,
onPatch,
onClose,
}: {
siteId: string;
params: URLSearchParams;
onPatch: (extra: Record<string, string | null>) => void;
onClose: () => void;
}) {
const [creatingFeed, setCreatingFeed] = useState(false);
const [feedHealth, setFeedHealth] = useState("");
const [dialog, setDialog] = useState<SiteDialog | null>(null);
const checks = useSiteChecks(setDialog);
const sites = useSites();
const snap = useSnapshotSite();
const site = sites.data?.items.find((x) => x.id === siteId);
const tabs = visibleTabs(site);
const rawTab = params.get("tab") ?? "";
let mapped = LEGACY_TAB[rawTab] ?? rawTab;
// 只有 RSS 的站点:旧的"抓取与 RSS"深链落到 RSS
if (mapped === "snapshots" && site && !siteCan(site, "list")) mapped = "rss";
const tab: Tab = (tabs as string[]).includes(mapped) ? (mapped as Tab) : "overview";
/** 旧 RSS 页 / 任务页的 ?feed=<id>:打开该 RSS 的详情 */
const feedParam = params.get("feed");
const [qInput, setQInput] = useState("");
const q = useDebounced(qInput.trim());
const [category, setCategory] = useState("");
const [resolution, setResolution] = useState("");
const [local, setLocal] = useState<LocalFilters>({ age: "" });
// 快速筛选(SEARCH-3):服务端按本地资源表数据统计与筛选,不触发站点请求
const [quick, setQuick] = useQuickFilters("site");
const qf = formatQuickFilter(quick);
// 实时补查只对开启时的快速筛选生效;改快速筛选不会再次请求站点
const [liveAt, setLiveAt] = useState<string | null>(null);
const live = liveAt !== null && liveAt === (qf ?? "");
const setLive = (v: boolean) => setLiveAt(v ? (qf ?? "") : null);
const [openId, setOpenId] = useState<string | null>(null);
const filters: ReleaseFilters = {
q: q || undefined,
category: category || undefined,
resolution: (resolution || undefined) as ResolutionStatus | undefined,
qf,
};
const releases = useSiteReleases(tab === "resources" ? siteId : "", filters, live);
// 实时补查只对当前搜索生效一次
// biome-ignore lint/correctness/useExhaustiveDependencies: 条件变化时回到快照优先
useEffect(() => setLiveAt(null), [q, category, resolution]);
const pages = releases.data?.pages ?? [];
const all = useMemo(() => pages.flatMap((p) => p.items), [pages]);
const coverage = pages[0]?.coverage;
const quickTotal = pages[0]?.total;
/** 本地有资源,但快速筛选后一条都没有 */
const quickHidden = selectedCount(quick) > 0 && (pages[0]?.unfilteredTotal ?? 0) > 0 && quickTotal === 0;
const now = Date.now();
const rows = all.filter((r) => localMatch(r, local, now));
const paged = useListPagination(rows, {
hasMore: releases.hasNextPage,
onLoadMore: () => releases.fetchNextPage(),
loadingMore: releases.isFetchingNextPage,
error: releases.error,
resetKey: JSON.stringify({ siteId, filters, local, live }),
});
const localActive = Object.values(local).some(Boolean);
const inventory = useInventory(all.flatMap((r) => (r.entity ? [r.entity.id] : [])));
const categories = useMemo(() => {
const m = new Map<string, string>();
for (const r of all) if (r.categoryRaw) m.set(r.categoryRaw, categoryLabel(r) ?? r.categoryRaw);
if (category && !m.has(category)) m.set(category, category);
return [...m].map(([value, label]) => ({ value, label }));
}, [all, category]);
const s = site;
const refreshFailed = !!releases.error && !!releases.data;
// 刷新失败时整页数据都标过期;否则早于 2 个快照周期确认的条目标过期
const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null;
const selected = all.find((r) => r.id === openId) ?? null;
const freeCount = all.filter(isFree).length;
const reqStats = useSiteRequestStats(tab === "resources" ? siteId : undefined);
const reset = () => {
setQInput("");
setCategory("");
setQuick({});
setResolution("");
setLocal({ age: "" });
};
const anyFilter = !!(qInput || category || resolution || localActive || selectedCount(quick));
if (sites.error && !sites.data) return <ErrorState plain error={sites.error} onRetry={() => void sites.refetch()} />;
if (sites.data && !s) return <EmptyState plain icon={Globe} title="站点不存在或已删除" />;
return (
<>
<Tabs value={tab} onValueChange={(v) => onPatch({ tab: v === "overview" ? null : v, feed: null })}>
<TabsList aria-label="站点分节">
{tabs.map((t) => (
<TabsTrigger key={t} value={t}>
{TAB_LABEL[t]}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="overview">{s ? <SiteOverview site={s} /> : <LoadingState rows={4} />}</TabsContent>
<TabsContent value="resources">
<FilterBar
search={{ value: qInput, onChange: setQInput, placeholder: "搜索标题 / 副标题 / 站点 ID" }}
onReset={anyFilter ? reset : undefined}
actions={
<>
{s && !siteCan(s, "list") ? null : (
<Button
variant="outline"
size="sm"
disabled={!s?.enabled || snap.isPending}
title={
reqStats.data
? `抓取站点列表前 ${reqStats.data.access.snapshotMaxPages} 页(受站点限流)`
: "抓取站点列表(受站点限流)"
}
onClick={() =>
snap.mutate(
{ id: siteId },
{
onSuccess: () => toast.success("已开始抓取"),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
>
<Camera />
刷新
</Button>
)}
{s && !siteCan(s, "search") ? null : (
<Button
size="sm"
disabled={!q || !s?.enabled || (live && releases.isFetching)}
title={q ? "跳过本地资源,直接在站点搜索" : "先输入搜索词"}
onClick={() => {
if (live) void releases.refetch();
else setLive(true);
}}
>
<Radar className={live && releases.isFetching ? "animate-pulse" : undefined} />
去站点搜
</Button>
)}
</>
}
>
<FilterSelect label="分类" value={category} onChange={setCategory} options={categories} />
<FilterSelect label="识别" value={resolution} onChange={setResolution} options={RESOLUTION} />
<FilterSelect
label="上传"
value={local.age}
onChange={(v) => setLocal((l) => ({ ...l, age: v }))}
options={AGE}
/>
</FilterBar>
{/* 「清除筛选」在上方筛选行,一并清掉快速筛选 */}
{/* 分类已有上方的站点分类下拉(与 MP 站点资源一致),不再给分面 */}
<QuickFilterBar
facets={pages[0]?.facets}
value={quick}
onChange={setQuick}
hide={["site", "category"]}
hideClear
/>
{coverage || refreshFailed ? (
<CoverageBanner coverage={coverage} refreshError={refreshFailed ? releases.error : null} />
) : null}
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
{releases.isPending ? (
<LoadingState rows={6} className="p-4" />
) : releases.error && !releases.data ? (
<ErrorState plain title="读取资源失败" error={releases.error} onRetry={() => void releases.refetch()} />
) : rows.length === 0 ? (
<EmptyState
plain
icon={all.length || quickHidden ? SearchX : Globe}
title={
all.length
? "已加载的结果里没有符合筛选的资源"
: quickHidden
? "没有符合筛选条件的资源"
: q
? "本地资源里没有匹配的资源"
: s && !siteCan(s, "list")
? "还没有资源"
: "还没抓取过"
}
description={
all.length
? "上传时间只筛选已加载的结果。翻页加载后续资源或放宽条件。"
: quickHidden
? "减少快速筛选条件再看。"
: coverage?.liveError
? `去站点搜失败(${coverage.liveError}),站点上可能仍有该资源。`
: s && !siteCan(s, "list")
? "RSS 抓到的资源会出现在这里。"
: q
? "点「去站点搜」直接在站点搜索。"
: "点「刷新」抓取站点列表。"
}
/>
) : (
<>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border/60 px-4 py-2 text-meta text-muted-foreground">
<span>
{quickTotal !== undefined && selectedCount(quick)
? `共 ${quickTotal} 条,已加载 ${all.length} 条`
: `已加载 ${all.length} 条`}
{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
</span>
{freeCount ? <span>其中免费 {freeCount}</span> : null}
{reqStats.data ? (
<span className="ml-auto" title={accessSummary(reqStats.data)}>
近 24 小时请求站点 {reqStats.data.last24h} 次
</span>
) : null}
{releases.isFetching && !releases.isFetchingNextPage ? <span>刷新中…</span> : null}
</div>
<ul className="divide-y divide-border/60">
{paged.items?.map((r) => (
<ReleaseRow
key={r.id}
r={r}
entry={r.entity ? inventory.data?.get(r.entity.id) : undefined}
inventoryLoading={inventory.isLoading}
staleBefore={staleBefore}
selected={openId === r.id}
onOpen={() => setOpenId(r.id)}
/>
))}
</ul>
</>
)}
{all.length > 0 || releases.hasNextPage ? <Pagination {...paged.pagination} /> : null}
</div>
</TabsContent>
<TabsContent value="origin">{s ? <SiteOriginPanel site={s} /> : <LoadingState rows={4} />}</TabsContent>
<TabsContent value="snapshots">{s ? <SnapshotPanel site={s} /> : <LoadingState rows={3} />}</TabsContent>
<TabsContent value="rss">
{/* 原独立 RSS 页的全部能力(IA-2):新建 / 编辑 / 启停 / 立即抓取 / 抓取状态 / 最近条目 */}
<FilterBar
onReset={feedHealth ? () => setFeedHealth("") : undefined}
actions={
<Button size="sm" onClick={() => setCreatingFeed(true)} disabled={!s}>
<Plus />
新建 RSS
</Button>
}
>
<FilterSelect
label="健康"
value={feedHealth}
onChange={setFeedHealth}
options={Object.entries(FEED_HEALTH).map(([value, h]) => ({ value, label: h.label }))}
/>
</FilterBar>
<FeedTable
siteId={siteId}
health={(feedHealth || undefined) as FeedHealth | undefined}
filtered={!!feedHealth}
creating={creatingFeed}
onCreatingChange={setCreatingFeed}
openFeedId={feedParam}
/>
</TabsContent>
<TabsContent value="accounts">
{s ? <SiteAccountsTab site={s} checks={checks} onDialog={setDialog} /> : <LoadingState rows={3} />}
</TabsContent>
</Tabs>
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} onDeleted={onClose} />
<ReleaseDetailSheet
siteId={siteId}
release={selected}
entry={selected?.entity ? inventory.data?.get(selected.entity.id) : undefined}
onClose={() => setOpenId(null)}
/>
</>
);
}
/** 抽屉标题下的一行状态:域名、类型、账号、健康、停用、熔断、抓取过期 */
function SiteBadges({ s }: { s: SiteListItem }) {
const login = hasLogin(s);
const checkable = s.capabilities.some((c) => c !== "rss");
const account = login ? ACCOUNT_OVERALL[overallStatus(s)] : null;
const health = checkable ? healthLine(s.lastHealth, s.lastHealthAt) : null;
const circuit = circuitLabel(s);
return (
<span className="flex flex-wrap items-center gap-1.5">
<a
href={s.baseUrl}
target="_blank"
rel="noreferrer noopener"
className="text-meta text-muted-foreground hover:text-foreground hover:underline"
>
{hostOf(s.baseUrl)}
</a>
{s.kind !== "pt" ? <StatusBadge>{kindLabel(s.kind)}</StatusBadge> : null}
{account ? <StatusBadge tone={account.tone}>账号{account.label}</StatusBadge> : null}
{health ? <StatusBadge tone={health.tone}>健康 {health.text}</StatusBadge> : null}
{!s.enabled ? <StatusBadge tone="warning">站点已停用,只显示历史数据</StatusBadge> : null}
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
{s.snapshotEnabled && snapshotStale(s) ? <StatusBadge tone="warning">抓取已过期</StatusBadge> : null}
</span>
);
}
/** 抽屉标题下的操作:检测健康、刷新登录、更多、问智能体 */
function SiteActions({ s, onDeleted }: { s: SiteListItem; onDeleted: () => void }) {
const [dialog, setDialog] = useState<SiteDialog | null>(null);
const checks = useSiteChecks(setDialog);
const login = hasLogin(s);
const checkable = s.capabilities.some((c) => c !== "rss");
const hasAccount = !!s.accounts?.some((a) => a.enabled && a.authKind !== "passkey");
return (
<>
{checkable ? (
<>
<Button
variant="outline"
size="sm"
disabled={!s.enabled || checks.isBusy(s.id, "health")}
onClick={() => void checks.checkHealth(s)}
>
{checks.isBusy(s.id, "health") ? <LoaderCircle className="animate-spin" /> : <Activity />}
<span className="sr-only sm:not-sr-only">检测健康</span>
</Button>
{login ? (
<Button
variant="outline"
size="sm"
disabled={!hasAccount || checks.isBusy(s.id, "login")}
title={hasAccount ? undefined : "先填写凭证"}
onClick={() => void checks.refreshLogin(s, primaryAccount(s.accounts))}
>
{checks.isBusy(s.id, "login") ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
<span className="sr-only sm:not-sr-only">刷新登录</span>
</Button>
) : null}
</>
) : null}
<SiteMoreMenu site={s} onDialog={setDialog} />
<Button variant="ghost" size="sm" onClick={() => openAgentDrawer({ label: `站点:${s.name}`, siteId: s.id })}>
<Bot />
<span className="sr-only sm:not-sr-only">问智能体</span>
</Button>
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} onDeleted={onDeleted} />
</>
);
}
function CoverageBanner({
coverage,
refreshError,
}: {
coverage: import("@umt/contracts").WorkbenchCoverage | undefined;
refreshError: unknown;
}) {
const notes: { tone: "warning" | "neutral" | "success"; text: string }[] = [];
if (refreshError) notes.push({ tone: "warning", text: `刷新失败,显示上次数据:${errorMessage(refreshError)}` });
if (coverage) {
if (!coverage.siteEnabled) notes.push({ tone: "warning", text: "站点已停用,只显示历史数据" });
if (coverage.stale)
notes.push({
tone: "warning",
text: coverage.lastSnapshotAt
? `抓取已过期(上次 ${relTime(coverage.lastSnapshotAt)})`
: "还没抓取过,列表只来自 RSS 和站点搜索",
});
if (coverage.liveError) notes.push({ tone: "warning", text: `去站点搜失败:${coverage.liveError}` });
else if (coverage.live)
notes.push({ tone: "success", text: coverage.coalesced ? "已合并到进行中的同一搜索" : "站点搜索完成" });
}
if (!notes.length) return null;
return (
<div className="mb-3 space-y-1.5">
{notes.map((n) => (
<p
key={n.text}
className={
n.tone === "warning"
? "flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning"
: n.tone === "success"
? "flex items-start gap-2 rounded-lg border border-success/30 bg-success/10 px-3 py-2 text-caption text-success"
: "rounded-lg border border-border px-3 py-2 text-caption text-muted-foreground"
}
>
{n.tone === "warning" ? <TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" /> : null}
<span className="min-w-0 break-words">{n.text}</span>
</p>
))}
</div>
);
}