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
This commit is contained in:
1 parent
dd0f6e2c09
commit
21c55dfb3a
60 files changed
+2768
-2461
No files matched your search
@@ -0,0 +1,558 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user