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

@@ -0,0 +1,278 @@
import type { FeedHealth, FeedSource } from "@umt/contracts";
import { RotateCcw, Rss, Zap } 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 { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { FeedFormDialog, PURPOSE_LABEL } from "./feed-form.tsx";
import { relTime } from "./release-fields.ts";
export const FEED_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" },
};
const lastErrorText = (f: FeedSource) =>
f.lastError ? String((f.lastError as { message?: unknown }).message ?? JSON.stringify(f.lastError)) : null;
/** 游标窗口摘要:服务端 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>
);
}
/**
* Feed 列表 + 详情抽屉 + 新建/编辑对话框。来源健康页列全部,站点详情按 siteId 过滤。
*/
export function FeedTable({
siteId,
health,
creating,
onCreatingChange,
filtered = false,
}: {
siteId?: string;
health?: FeedHealth;
creating: boolean;
onCreatingChange: (v: boolean) => void;
/** 列表外层有筛选条件(影响空态文案) */
filtered?: boolean;
}) {
const feeds = useFeeds({ health, siteId });
const sites = useSites();
const accounts = useSiteAccounts();
const [editingFeed, setEditingFeed] = useState<FeedSource | null>(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 editing: FeedSource | "new" | null = creating ? "new" : editingFeed;
const setEditing = (v: FeedSource | null) => {
setEditingFeed(v);
if (!v) onCreatingChange(false);
};
const columns: DataColumn<FeedSource>[] = [
{
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>
),
},
{
key: "health",
header: "健康",
cell: (f) => {
const gap = feedGapHint(f);
return (
<div className="flex flex-col items-start gap-1">
<StatusBadge tone={FEED_HEALTH[f.healthStatus].tone}>{FEED_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}
</div>
),
},
{ key: "ops", header: "", align: "right", cell: (f) => <FeedActions f={f} /> },
];
return (
<>
<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: filtered ? "没有符合筛选的 Feed" : "还没有 Feed",
description: filtered ? undefined : "新建 Feed 后按轮询间隔抓取,新资源进入识别和收录评估。",
}}
/>
<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={FEED_HEALTH[open.healthStatus].tone}>
{FEED_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}
</>
);
}
@@ -0,0 +1,171 @@
import { useQueryClient } from "@tanstack/react-query";
import { type Site, SiteCredentialsPut } from "@umt/contracts";
import { KeyRound, LoaderCircle, UserRound } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { usePutSiteCredentials } from "~/api/instances.ts";
import { siteKeys, useSiteAccounts } from "~/api/sites.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { errorMessage } from "~/lib/api.ts";
import { ACCOUNT_STATUS, relTime } from "./release-fields.ts";
const AUTH_KINDS = [
{ value: "cookie", label: "Cookie" },
{ value: "passkey", label: "Passkey" },
{ value: "api_key", label: "API Key" },
{ value: "password", label: "账号密码" },
] as const;
type AuthKind = (typeof AUTH_KINDS)[number]["value"];
/** 站点详情"账号"页签:已录入账号 + 按标签新建或替换凭证(凭证只写,不回显) */
export function SiteAccountsPanel({ site }: { site: Site }) {
const accounts = useSiteAccounts(site.id);
const put = usePutSiteCredentials();
const qc = useQueryClient();
const [v, setV] = useState({ label: "default", authKind: "cookie" as AuthKind, username: "", credential: "" });
const [errors, setErrors] = useState<Record<string, string>>({});
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const parsed = SiteCredentialsPut.safeParse({
label: v.label.trim() || "default",
authKind: v.authKind,
credential: v.credential.trim(),
username: v.authKind === "password" ? v.username.trim() || undefined : undefined,
});
if (!parsed.success) {
const fe = zodFieldErrors(parsed.error.issues);
if (fe.credential) fe.credential = "填写凭证";
setErrors(fe);
return;
}
setErrors({});
try {
const r = await put.mutateAsync({ siteId: site.id, body: parsed.data });
toast.success(r.created ? "已添加站点账号" : "已替换凭证");
setV((s) => ({ ...s, credential: "" }));
void qc.invalidateQueries({ queryKey: siteKeys.all });
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
const busy = put.isPending;
return (
<div className="grid gap-6 pt-2 lg:grid-cols-2">
<DetailSection title="站点账号">
<DataTable
rows={accounts.data?.items}
rowKey={(r) => r.id}
loading={accounts.isPending}
error={accounts.error}
onRetry={() => void accounts.refetch()}
empty={{ icon: UserRound, title: "还没有账号", description: "保存凭证后出现在这里。" }}
columns={[
{
key: "label",
header: "标签",
cell: (r) => (
<div className="min-w-0">
<p className="truncate font-medium text-foreground">{r.label}</p>
<p className="text-meta text-muted-foreground">
{AUTH_KINDS.find((k) => k.value === r.authKind)?.label ?? r.authKind}
</p>
</div>
),
},
{
key: "status",
header: "状态",
cell: (r) => (
<div className="flex flex-wrap gap-1">
<StatusBadge tone={ACCOUNT_STATUS[r.status].tone}>{ACCOUNT_STATUS[r.status].label}</StatusBadge>
{!r.enabled ? <StatusBadge>已停用</StatusBadge> : null}
</div>
),
},
{
key: "verified",
header: "上次验证",
hideBelow: "sm",
cell: (r) => <span className="text-muted-foreground">{relTime(r.lastVerifiedAt)}</span>,
},
]}
/>
</DetailSection>
<form
onSubmit={submit}
noValidate
className="pg-slab grid content-start gap-x-4 gap-y-1 rounded-xl p-5 sm:grid-cols-2"
>
<p className="mb-3 text-caption text-muted-foreground sm:col-span-2">
同一标签再次保存会替换凭证。凭证加密保存,不回显。
</p>
<FormField label="标签" htmlFor="acct-label" error={errors.label} hint="同一站点多个账号时区分">
<Input
id="acct-label"
value={v.label}
aria-invalid={!!errors.label}
onChange={(e) => setV((s) => ({ ...s, label: e.target.value }))}
disabled={busy}
/>
</FormField>
<FormField label="凭证类型" htmlFor="acct-kind">
<Select value={v.authKind} onValueChange={(x) => setV((s) => ({ ...s, authKind: x as AuthKind }))}>
<SelectTrigger id="acct-kind" className="w-full" disabled={busy}>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{AUTH_KINDS.map((k) => (
<SelectItem key={k.value} value={k.value}>
{k.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
{v.authKind === "password" ? (
<FormField label="登录名" htmlFor="acct-user" error={errors.username} className="sm:col-span-2">
<Input
id="acct-user"
value={v.username}
autoComplete="off"
onChange={(e) => setV((s) => ({ ...s, username: e.target.value }))}
disabled={busy}
/>
</FormField>
) : null}
<FormField
label={v.authKind === "password" ? "密码" : "凭证"}
htmlFor="acct-cred"
required
error={errors.credential}
className="sm:col-span-2"
>
<Input
id="acct-cred"
type="password"
autoComplete="new-password"
value={v.credential}
aria-invalid={!!errors.credential}
placeholder={v.authKind === "cookie" ? "粘贴整段 Cookie" : undefined}
onChange={(e) => setV((s) => ({ ...s, credential: e.target.value }))}
disabled={busy}
/>
</FormField>
<div className="sm:col-span-2">
<Button type="submit" size="sm" disabled={busy}>
{busy ? <LoaderCircle className="animate-spin" /> : <KeyRound />}
保存凭证
</Button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,174 @@
import { SiteAdapter, SiteCreate } from "@umt/contracts";
import { LoaderCircle } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { useCreateSite, useSiteDefinitions, useSites } from "~/api/sites.ts";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
export const ADAPTER_LABEL: Record<SiteAdapter, string> = {
nexusphp: "NexusPHP",
mteam_api: "M-Team API",
api: "站点 API",
generic_rss: "通用 RSS",
};
const CUSTOM = "__custom";
type Draft = { key: string; name: string; adapter: SiteAdapter; baseUrl: string; snapshotEnabled: boolean };
const EMPTY: Draft = { key: "", name: "", adapter: "nexusphp", baseUrl: "", snapshotEnabled: true };
/** 登记站点:从内置站点定义选,或自定义。登记后去站点详情的"账号"页签填凭证 */
export function SiteRegisterDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
const defs = useSiteDefinitions();
const sites = useSites();
const create = useCreateSite();
const navigate = useNavigate();
const [preset, setPreset] = useState(CUSTOM);
const [d, setD] = useState<Draft>(EMPTY);
const [errors, setErrors] = useState<Record<string, string>>({});
const taken = new Set((sites.data?.items ?? []).map((s) => s.key));
const available = (defs.data?.items ?? []).filter((x) => !taken.has(x.key));
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => setD((s) => ({ ...s, [k]: v }));
const pick = (key: string) => {
setPreset(key);
const def = available.find((x) => x.key === key);
setD(
def ? { key: def.key, name: def.name, adapter: def.adapter, baseUrl: def.baseUrl, snapshotEnabled: true } : EMPTY,
);
setErrors({});
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const parsed = SiteCreate.safeParse({ ...d, key: d.key.trim(), name: d.name.trim(), baseUrl: d.baseUrl.trim() });
if (!parsed.success) {
const fe = zodFieldErrors(parsed.error.issues);
if (fe.key) fe.key = "小写字母开头,只含小写字母、数字、- 和 _";
if (fe.baseUrl) fe.baseUrl = "填完整地址,如 https://example.org";
setErrors(fe);
return;
}
if (taken.has(parsed.data.key)) {
setErrors({ key: "这个标识已被占用" });
return;
}
setErrors({});
try {
const site = await create.mutateAsync(parsed.data);
toast.success(`已登记 ${site.name}`);
onOpenChange(false);
navigate(`${consolePath("sites", site.id)}?tab=accounts`);
} catch (err) {
toast.error(errorMessage(err, "登记失败"));
}
};
const busy = create.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>登记站点</DialogTitle>
<DialogDescription>登记后在站点的"账号"页签填写凭证。</DialogDescription>
</DialogHeader>
<form id="site-register" onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
<FormField label="站点定义" htmlFor="site-preset" className="sm:col-span-2">
<Select value={preset} onValueChange={pick} disabled={busy}>
<SelectTrigger id="site-preset" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={CUSTOM}>自定义</SelectItem>
{available.map((x) => (
<SelectItem key={x.key} value={x.key}>
{x.name}
{x.shippedEnabled ? "" : "(未实测)"}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label="名称" htmlFor="site-name" required error={errors.name}>
<Input
id="site-name"
value={d.name}
aria-invalid={!!errors.name}
onChange={(e) => set("name", e.target.value)}
disabled={busy}
/>
</FormField>
<FormField label="标识" htmlFor="site-key" required error={errors.key} hint="登记后不能改">
<Input
id="site-key"
value={d.key}
aria-invalid={!!errors.key}
onChange={(e) => set("key", e.target.value)}
disabled={busy || preset !== CUSTOM}
className="font-mono"
/>
</FormField>
<FormField label="类型" htmlFor="site-adapter">
<Select value={d.adapter} onValueChange={(v) => set("adapter", v as SiteAdapter)} disabled={busy}>
<SelectTrigger id="site-adapter" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{SiteAdapter.options.map((a) => (
<SelectItem key={a} value={a}>
{ADAPTER_LABEL[a]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label="地址" htmlFor="site-url" required error={errors.baseUrl}>
<Input
id="site-url"
inputMode="url"
value={d.baseUrl}
aria-invalid={!!errors.baseUrl}
onChange={(e) => set("baseUrl", e.target.value)}
placeholder="https://"
disabled={busy}
/>
</FormField>
<div className="flex items-center gap-2 pb-4 sm:col-span-2">
<Switch
id="site-snapshot"
checked={d.snapshotEnabled}
onCheckedChange={(v) => set("snapshotEnabled", v)}
disabled={busy}
/>
<label htmlFor="site-snapshot" className="text-caption text-foreground">
定时抓取快照
</label>
</div>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
取消
</Button>
<Button type="submit" form="site-register" disabled={busy}>
{busy ? <LoaderCircle className="animate-spin" /> : null}
登记
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}