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:
1 parent
909cd2ad49
commit
ddb15145d8
7 files changed
+805
-296
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { cursorPage, Id, Site, SiteAccount, SiteDefinitionView, SiteSnapshot } from "@umt/contracts";
|
import { cursorPage, Id, Site, SiteAccount, type SiteCreate, SiteDefinitionView, SiteSnapshot } from "@umt/contracts";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TTL_SECOND } from "~/lib/api.ts";
|
import { TTL_SECOND } from "~/lib/api.ts";
|
||||||
import { apiMutate, apiParsed } from "./parse.ts";
|
import { apiMutate, apiParsed } from "./parse.ts";
|
||||||
@@ -99,3 +99,12 @@ export function snapshotStale(site: Pick<Site, "lastSnapshotAt" | "snapshotInter
|
|||||||
const interval = (site.snapshotIntervalSec ?? 3600) * 1000;
|
const interval = (site.snapshotIntervalSec ?? 3600) * 1000;
|
||||||
return now - new Date(site.lastSnapshotAt).getTime() > interval * 2;
|
return now - new Date(site.lastSnapshotAt).getTime() > interval * 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 登记站点(POST /sites) */
|
||||||
|
export function useCreateSite() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: SiteCreate) => apiMutate("/api/v1/sites", { body }).then((d) => Site.parse(d)),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
import type { ResolutionStatus, WorkbenchRelease } from "@umt/contracts";
|
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 { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useParams, useSearchParams } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useInventory } from "~/api/inventory.ts";
|
import { useInventory } from "~/api/inventory.ts";
|
||||||
import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts";
|
import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts";
|
||||||
import { snapshotStale, useSite, useSiteAccounts, useSnapshotSite } from "~/api/sites.ts";
|
import { snapshotStale, useSite, useSiteAccounts, useSnapshotSite } from "~/api/sites.ts";
|
||||||
import { openAgentDrawer } from "~/components/agent/agent-store.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 { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.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 { PageHeader } from "~/components/console/page-header.tsx";
|
||||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.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 { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
|
||||||
import {
|
import {
|
||||||
accountSummary,
|
accountSummary,
|
||||||
@@ -24,6 +27,8 @@ import {
|
|||||||
SITE_HEALTH,
|
SITE_HEALTH,
|
||||||
} from "~/components/workbench/release-fields.ts";
|
} from "~/components/workbench/release-fields.ts";
|
||||||
import { ReleaseRow } from "~/components/workbench/release-list.tsx";
|
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";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
|
|
||||||
const PROMOS = [
|
const PROMOS = [
|
||||||
@@ -84,8 +89,16 @@ function useDebounced<T>(value: T, ms = 400) {
|
|||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TABS = ["organized", "origin", "snapshots", "accounts"] as const;
|
||||||
|
|
||||||
|
export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }];
|
||||||
|
|
||||||
export default function SiteWorkbenchPage() {
|
export default function SiteWorkbenchPage() {
|
||||||
const { siteId = "" } = useParams();
|
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 site = useSite(siteId);
|
||||||
const accounts = useSiteAccounts(siteId);
|
const accounts = useSiteAccounts(siteId);
|
||||||
const snap = useSnapshotSite();
|
const snap = useSnapshotSite();
|
||||||
@@ -169,9 +182,9 @@ export default function SiteWorkbenchPage() {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button asChild variant="ghost" size="sm">
|
<Button asChild variant="ghost" size="sm">
|
||||||
<Link to="/console/sites">
|
<Link to={consolePath("sites")}>
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
站点
|
站点工作台
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -185,10 +198,24 @@ export default function SiteWorkbenchPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue="organized">
|
<Tabs
|
||||||
<TabsList>
|
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="organized">整理视图</TabsTrigger>
|
||||||
<TabsTrigger value="origin">原站视图</TabsTrigger>
|
<TabsTrigger value="origin">原站视图</TabsTrigger>
|
||||||
|
<TabsTrigger value="snapshots">快照与 Feed</TabsTrigger>
|
||||||
|
<TabsTrigger value="accounts">账号</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="organized">
|
<TabsContent value="organized">
|
||||||
@@ -337,6 +364,25 @@ export default function SiteWorkbenchPage() {
|
|||||||
description={<>计划在 M2 提供。资源详情里有站点 ID 和来源时间,可据此到原站查看。</>}
|
description={<>计划在 M2 提供。资源详情里有站点 ID 和来源时间,可据此到原站查看。</>}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
|
|
||||||
<ReleaseDetailSheet
|
<ReleaseDetailSheet
|
||||||
|
|||||||
@@ -1,23 +1,19 @@
|
|||||||
import type { Site, SiteAccount } from "@umt/contracts";
|
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 { Link } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { snapshotStale, useSiteAccounts, useSites, useSnapshotSite, useToggleSite } from "~/api/sites.ts";
|
import { snapshotStale, useSiteAccounts, useSites, useSnapshotSite, useToggleSite } from "~/api/sites.ts";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
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 { PageHeader } from "~/components/console/page-header.tsx";
|
||||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { Switch } from "~/components/ui/switch.tsx";
|
import { Switch } from "~/components/ui/switch.tsx";
|
||||||
import { accountSummary, circuitLabel, relTime, SITE_HEALTH } from "~/components/workbench/release-fields.ts";
|
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";
|
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[] }) {
|
function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
|
||||||
const toggle = useToggleSite();
|
const toggle = useToggleSite();
|
||||||
const snap = useSnapshotSite();
|
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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<Link
|
<Link
|
||||||
to={`/console/sites/${site.id}`}
|
to={consolePath("sites", site.id)}
|
||||||
className="block truncate text-lead font-semibold text-foreground hover:underline"
|
className="block truncate text-lead font-semibold text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
{site.name}
|
{site.name}
|
||||||
@@ -62,7 +58,9 @@ function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
|
|||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
<StatusBadge tone={health.tone}>{health.label}</StatusBadge>
|
<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}
|
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -102,8 +100,8 @@ function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
|
|||||||
快照
|
快照
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="sm" variant="outline">
|
<Button asChild size="sm" variant="outline">
|
||||||
<Link to={`/console/sites/${site.id}`}>
|
<Link to={consolePath("sites", site.id)}>
|
||||||
打开工作台
|
打开
|
||||||
<ArrowRight />
|
<ArrowRight />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -112,8 +110,11 @@ function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const meta = () => [{ title: "站点工作台 · UHD 媒体自动化控制台" }];
|
||||||
|
|
||||||
export default function SitesPage() {
|
export default function SitesPage() {
|
||||||
const sites = useSites();
|
const sites = useSites();
|
||||||
|
const [registering, setRegistering] = useState(false);
|
||||||
const accounts = useSiteAccounts();
|
const accounts = useSiteAccounts();
|
||||||
const bySite = new Map<string, SiteAccount[]>();
|
const bySite = new Map<string, SiteAccount[]>();
|
||||||
for (const a of accounts.data?.items ?? []) bySite.set(a.siteId, [...(bySite.get(a.siteId) ?? []), a]);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="PT 站点工作台"
|
title="站点工作台"
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" size="sm" onClick={() => void sites.refetch()} disabled={sites.isFetching}>
|
<>
|
||||||
<RefreshCw className={sites.isFetching ? "animate-spin" : undefined} />
|
<Button variant="outline" size="sm" onClick={() => void sites.refetch()} disabled={sites.isFetching}>
|
||||||
刷新
|
<RefreshCw className={sites.isFetching ? "animate-spin" : undefined} />
|
||||||
</Button>
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setRegistering(true)}>
|
||||||
|
<Plus />
|
||||||
|
登记站点
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{sites.isPending ? (
|
{sites.isPending ? (
|
||||||
@@ -139,10 +146,11 @@ export default function SitesPage() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Globe}
|
icon={Globe}
|
||||||
title="还没有登记站点"
|
title="还没有登记站点"
|
||||||
description="在来源页或系统设置里登记站点和账号后,站点会列在这里。"
|
description="登记站点并填写账号凭证后,可在这里浏览和搜索资源。"
|
||||||
action={
|
action={
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button size="sm" onClick={() => setRegistering(true)}>
|
||||||
<Link to="/console/sources">去来源页</Link>
|
<Plus />
|
||||||
|
登记站点
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -153,6 +161,7 @@ export default function SitesPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{registering ? <SiteRegisterDialog open onOpenChange={setRegistering} /> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,188 +1,116 @@
|
|||||||
import type { FeedHealth, FeedSource } from "@umt/contracts";
|
import type { FeedHealth, Site } from "@umt/contracts";
|
||||||
import { Plus, RefreshCw, RotateCcw, Rss, Zap } from "lucide-react";
|
import { Globe, Plus, RefreshCw } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { Link } from "react-router";
|
||||||
import { feedGapHint, useFeedAction, useFeeds, useFetchFeedNow } from "~/api/feeds.ts";
|
import { useFeeds } from "~/api/feeds.ts";
|
||||||
import { useSiteAccounts, useSites } from "~/api/sites.ts";
|
import { snapshotStale, useSiteAccounts, useSites } from "~/api/sites.ts";
|
||||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
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 { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||||
import { JsonView } from "~/components/console/json-view.tsx";
|
import { consolePath } from "~/components/console/nav.ts";
|
||||||
import { KvList } from "~/components/console/kv-list.tsx";
|
|
||||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
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 { Button } from "~/components/ui/button.tsx";
|
||||||
import { Switch } from "~/components/ui/switch.tsx";
|
import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
import { accountSummary, circuitLabel, relTime, SITE_HEALTH } from "~/components/workbench/release-fields.ts";
|
||||||
import { FeedFormDialog, PURPOSE_LABEL } from "~/components/workbench/feed-form.tsx";
|
import { legacyRedirectLoader } from "./redirects.ts";
|
||||||
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";
|
|
||||||
|
|
||||||
const HEALTH: Record<FeedHealth, { label: string; tone: StatusTone }> = {
|
// 来源健康:跨站点的 Feed 列表与站点健康总表;单个站点的快照、Feed、账号在站点详情。
|
||||||
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) =>
|
export const meta = () => [{ title: "来源健康 · UHD 媒体自动化控制台" }];
|
||||||
f.lastError ? String((f.lastError as { message?: unknown }).message ?? JSON.stringify(f.lastError)) : null;
|
|
||||||
|
|
||||||
/** 游标窗口摘要:服务端 cursor.window 的条目数与上次抓取时间 */
|
// 旧的 ?tab=snapshots&siteId= 跳到站点详情的"快照与 Feed"
|
||||||
function cursorSummary(f: FeedSource): string {
|
export const clientLoader = legacyRedirectLoader;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SourcesPage() {
|
export default function SourcesPage() {
|
||||||
const [health, setHealth] = useState("");
|
const f = useQueryState(["health", "siteId"] as const);
|
||||||
const [siteFilter, setSiteFilter] = useState("");
|
const [creating, setCreating] = useState(false);
|
||||||
const feeds = useFeeds({ health: (health || undefined) as FeedHealth | undefined, siteId: siteFilter || undefined });
|
|
||||||
const sites = useSites();
|
const sites = useSites();
|
||||||
const accounts = useSiteAccounts();
|
const accounts = useSiteAccounts();
|
||||||
const [editing, setEditing] = useState<FeedSource | null | "new">(null);
|
const feeds = useFeeds({
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
health: (f.values.health || undefined) as FeedHealth | undefined,
|
||||||
const action = useFeedAction();
|
siteId: f.values.siteId || undefined,
|
||||||
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 columns: DataColumn<FeedSource>[] = [
|
const siteColumns: DataColumn<Site>[] = [
|
||||||
{
|
{
|
||||||
key: "name",
|
key: "name",
|
||||||
header: "名称",
|
header: "站点",
|
||||||
cell: (f) => (
|
cell: (s) => (
|
||||||
<div className="min-w-0 max-w-xs">
|
<Link
|
||||||
<p className="truncate font-medium text-foreground">{f.name}</p>
|
to={`${consolePath("sites", s.id)}?tab=snapshots`}
|
||||||
<p className="truncate font-mono text-meta text-muted-foreground" title={f.urlDisplay}>
|
className="font-medium text-foreground hover:underline"
|
||||||
{f.urlDisplay}
|
onClick={(e) => e.stopPropagation()}
|
||||||
</p>
|
>
|
||||||
</div>
|
{s.name}
|
||||||
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "health",
|
key: "health",
|
||||||
header: "健康",
|
header: "健康",
|
||||||
cell: (f) => {
|
cell: (s) => (
|
||||||
const gap = feedGapHint(f);
|
<div className="flex flex-wrap gap-1">
|
||||||
return (
|
<StatusBadge tone={SITE_HEALTH[s.healthStatus].tone}>{SITE_HEALTH[s.healthStatus].label}</StatusBadge>
|
||||||
<div className="flex flex-col items-start gap-1">
|
{circuitLabel(s) ? <StatusBadge tone="warning">{circuitLabel(s)}</StatusBadge> : null}
|
||||||
<StatusBadge tone={HEALTH[f.healthStatus].tone}>{HEALTH[f.healthStatus].label}</StatusBadge>
|
{!s.enabled && s.healthStatus !== "disabled" ? <StatusBadge>已停用</StatusBadge> : null}
|
||||||
{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>
|
</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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="来源 / Feed / 快照"
|
title="来源健康"
|
||||||
|
description="单个站点的快照、Feed 与账号在站点工作台。"
|
||||||
actions={
|
actions={
|
||||||
<Button size="sm" onClick={() => setEditing("new")}>
|
<Button size="sm" onClick={() => setCreating(true)}>
|
||||||
<Plus />
|
<Plus />
|
||||||
新建 Feed
|
新建 Feed
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Tabs defaultValue="feeds">
|
<div className="space-y-8">
|
||||||
<TabsList>
|
<DetailSection title="站点">
|
||||||
<TabsTrigger value="feeds">Feed</TabsTrigger>
|
<DataTable
|
||||||
<TabsTrigger value="snapshots">站点快照</TabsTrigger>
|
columns={siteColumns}
|
||||||
</TabsList>
|
rows={sites.data?.items}
|
||||||
<TabsContent value="feeds">
|
rowKey={(s) => s.id}
|
||||||
|
loading={sites.isPending}
|
||||||
|
error={sites.error}
|
||||||
|
onRetry={() => void sites.refetch()}
|
||||||
|
empty={{
|
||||||
|
icon: Globe,
|
||||||
|
title: "还没有登记站点",
|
||||||
|
description: "在站点工作台登记。",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DetailSection>
|
||||||
|
<DetailSection title="Feed">
|
||||||
<FilterBar
|
<FilterBar
|
||||||
onReset={
|
onReset={f.active ? f.reset : undefined}
|
||||||
health || siteFilter
|
|
||||||
? () => {
|
|
||||||
setHealth("");
|
|
||||||
setSiteFilter("");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
actions={
|
actions={
|
||||||
<Button variant="ghost" size="sm" onClick={() => void feeds.refetch()} disabled={feeds.isFetching}>
|
<Button variant="ghost" size="sm" onClick={() => void feeds.refetch()} disabled={feeds.isFetching}>
|
||||||
<RefreshCw className={feeds.isFetching ? "animate-spin" : undefined} />
|
<RefreshCw className={feeds.isFetching ? "animate-spin" : undefined} />
|
||||||
@@ -192,132 +120,26 @@ export default function SourcesPage() {
|
|||||||
>
|
>
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
label="健康"
|
label="健康"
|
||||||
value={health}
|
value={f.values.health}
|
||||||
onChange={setHealth}
|
onChange={(v) => f.set("health", v)}
|
||||||
options={Object.entries(HEALTH).map(([value, h]) => ({ value, label: h.label }))}
|
options={Object.entries(FEED_HEALTH).map(([value, h]) => ({ value, label: h.label }))}
|
||||||
/>
|
/>
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
label="站点"
|
label="站点"
|
||||||
value={siteFilter}
|
value={f.values.siteId}
|
||||||
onChange={setSiteFilter}
|
onChange={(v) => f.set("siteId", v)}
|
||||||
options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))}
|
options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))}
|
||||||
/>
|
/>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
<DataTable
|
<FeedTable
|
||||||
columns={columns}
|
health={(f.values.health || undefined) as FeedHealth | undefined}
|
||||||
rows={feeds.data?.items}
|
siteId={f.values.siteId || undefined}
|
||||||
rowKey={(f) => f.id}
|
filtered={f.active}
|
||||||
loading={feeds.isPending}
|
creating={creating}
|
||||||
error={feeds.error}
|
onCreatingChange={setCreating}
|
||||||
onRetry={() => void feeds.refetch()}
|
|
||||||
onRowClick={(f) => setOpenId(f.id)}
|
|
||||||
selectedKey={openId}
|
|
||||||
empty={{
|
|
||||||
icon: Rss,
|
|
||||||
title: health || siteFilter ? "没有符合筛选的 Feed" : "还没有 Feed",
|
|
||||||
description: health || siteFilter ? undefined : "新建 Feed 后按轮询间隔抓取,新资源进入识别和收录评估。",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</DetailSection>
|
||||||
<TabsContent value="snapshots">
|
</div>
|
||||||
{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}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user