feat(web): 站点按类型分组、列表模式修样式、下掉自定义 RSS 源与索引器入口;智能体会话分对话 / 自动巡检两栏

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 23:56:27 +08:00
1 parent 8da8679a04
commit 8eb8a68295
11 files changed
+364 -312

No files matched your search

+5
View File
@@ -4,6 +4,11 @@
## [Unreleased]
### 调整
- 站点页按类型分组(PT 站 / 公开 BT 站;旧的 RSS 源有的话单列),去掉类型筛选;添加站点只保留 PT 站和内置公开 BT 站,自定义 RSS 源与索引器入口先下掉(已有的照常显示)。
- 站点列表模式:操作按钮改成图标,数值列不换行,窄屏隐藏次要列;公开 BT 站的表只列出口、资源搜索、资源更新;公开站卡片不再显示 RSS。
- 智能体页会话分两栏:对话(用户发起)/ 自动巡检(定时、事件触发),子会话随父会话。
## [0.3.19] - 2026-10-03
### 修复
+6
View File
@@ -122,6 +122,12 @@ export function registerAgentRoutes(
if (query.status) q = q.where("status", "=", query.status);
if (query.trigger) q = q.where("trigger", "=", query.trigger);
if (query.parentRunId) q = q.where("parent_run_id", "=", query.parentRunId);
if (query.group) {
// 自动 = 定时 / 事件触发,子会话按父会话归类
const auto = sql<boolean>`(trigger in ('schedule', 'event') or (trigger = 'subagent' and parent_run_id in
(select p.id from agent_run p where p.trigger in ('schedule', 'event'))))`;
q = q.where(query.group === "auto" ? auto : sql<boolean>`not ${auto}`);
}
const cur = decode(query.cursor);
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
const rows = await q
+43
View File
@@ -0,0 +1,43 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createAgentModule } from "../../src/modules/agent/index.ts";
import { createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
import { DEV_KEY, json } from "../helpers.ts";
// 智能体页会话分栏:对话(用户发起)/ 自动巡检(定时、事件触发);子会话随父会话归类
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("会话列表 group 筛选(真实 PG)", () => {
let h: Harness;
beforeAll(async () => {
h = await createHarness({ extraModules: [createAgentModule()] });
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("auto = schedule / event 及其子会话;conversation = 其余", async () => {
const run = async (trigger: string, goal: string, parent?: string) =>
(
await h.db
.insertInto("agent_run")
.values({ trigger, goal, status: "succeeded", mode: "execute", parent_run_id: parent ?? null } as never)
.returning("id")
.executeTakeFirstOrThrow()
).id;
const patrol = await run("schedule", "巡检");
await run("subagent", "巡检子会话", patrol);
await run("event", "事件");
const chat = await run("user", "对话");
await run("subagent", "对话子会话", chat);
const goals = async (group: string) =>
(
await json(
await h.app.request(`/api/v1/agent/runs?group=${group}`, { headers: { authorization: `Bearer ${DEV_KEY}` } }),
)
).data.items
.map((r: { goal: string }) => r.goal)
.sort();
expect(await goals("auto")).toEqual(["事件", "巡检", "巡检子会话"].sort());
expect(await goals("conversation")).toEqual(["对话", "对话子会话"].sort());
});
});
+15 -6
View File
@@ -120,8 +120,8 @@ function useAgentMockGate(): boolean {
export const agentKeys = {
all: ["agent"] as const,
runs: (status?: AgentRunStatus, trigger?: AgentTrigger) =>
["agent", "list", status ?? "all", trigger ?? "any"] as const,
runs: (status?: AgentRunStatus, trigger?: AgentTrigger, group?: AgentRunGroup) =>
["agent", "list", status ?? "all", trigger ?? "any", group ?? "any"] as const,
run: (id: string) => ["agent", "detail", id] as const,
};
@@ -137,19 +137,28 @@ export const TRIGGER_LABEL: Record<AgentTrigger, string> = {
};
/** 会话列表:按状态、来源(trigger)在服务端筛选,游标翻页 */
export function useAgentRuns(status?: AgentRunStatus, trigger?: AgentTrigger) {
/** 会话分栏:对话 = 用户发起;自动 = 定时巡检 / 事件触发(子会话随父会话) */
export type AgentRunGroup = "conversation" | "auto";
const isAutoTrigger = (t: AgentTrigger) => t === "schedule" || t === "event";
export function useAgentRuns(status?: AgentRunStatus, trigger?: AgentTrigger, group?: AgentRunGroup) {
const mock = useAgentMockGate();
return useInfiniteQuery({
queryKey: [...agentKeys.runs(status, trigger), mock],
queryKey: [...agentKeys.runs(status, trigger, group), mock],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam, signal }) => {
if (mock)
return {
items: MOCK_RUNS().filter((r) => (!status || r.status === status) && (!trigger || r.trigger === trigger)),
items: MOCK_RUNS().filter(
(r) =>
(!status || r.status === status) &&
(!trigger || r.trigger === trigger) &&
(!group || isAutoTrigger(r.trigger) === (group === "auto")),
),
nextCursor: null,
};
return apiParsed(RunPage, AGENT_BASE, {
params: cleanParams({ status, trigger, cursor: pageParam, limit: 30 }),
params: cleanParams({ status, trigger, group, cursor: pageParam, limit: 30 }),
signal,
});
},
@@ -1,23 +1,29 @@
import { Bot, ChevronRight } from "lucide-react";
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { type AgentRunView, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
import { type AgentRunGroup, type AgentRunView, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
import { CHEVRON_CLASS } from "~/components/agentui/disclosure.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Input } from "~/components/ui/input.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { cn } from "~/lib/utils.ts";
import { contextLabel, RunStatusBadge } from "./agent-conversation.tsx";
import { shortGoalTitle, splitContextGoal } from "./agent-store.ts";
// 会话列表(AGUI-3):会话页左栏与抽屉的「历史」弹层共用。
// 筛选只有搜索 + 全部 / 等你处理 / 失败;「等你处理」(等选方案、等审批)置顶;子会话收在父会话下面。
// 分两栏:对话(用户发起)/ 自动巡检(定时、事件触发),子会话随父会话;栏内筛选只有搜索 + 全部 / 等你处理 / 失败;「等你处理」(等选方案、等审批)置顶;子会话收在父会话下面。
export type HistoryView = "all" | "pending" | "failed";
export type HistoryFilters = { q: string; view: HistoryView };
export const EMPTY_FILTERS: HistoryFilters = { q: "", view: "all" };
export type HistoryFilters = { q: string; view: HistoryView; group: AgentRunGroup };
export const EMPTY_FILTERS: HistoryFilters = { q: "", view: "all", group: "conversation" };
const GROUPS: { value: AgentRunGroup; label: string }[] = [
{ value: "conversation", label: "对话" },
{ value: "auto", label: "自动巡检" },
];
const VIEWS: { value: HistoryView; label: string }[] = [
{ value: "all", label: "全部" },
@@ -45,7 +51,7 @@ export type HistoryRow = { run: AgentRunView; children: AgentRunView[] };
*/
export function arrangeHistory(
runs: AgentRunView[],
f: HistoryFilters,
f: Pick<HistoryFilters, "q" | "view">,
limit = Number.POSITIVE_INFINITY,
): { pinned: HistoryRow[]; rows: HistoryRow[] } {
const uniq = [...new Map(runs.map((r) => [r.id, r] as const)).values()];
@@ -204,9 +210,9 @@ export function AgentSessionList({
if (remember) drawerMemory.filters = next;
return next;
});
const runs = useAgentRuns();
const runs = useAgentRuns(undefined, undefined, f.group);
// 等你处理的会话可能是很早以前建的:单独按 waiting 取一页,保证置顶
const waiting = useAgentRuns("waiting");
const waiting = useAgentRuns("waiting", undefined, f.group);
const loaded = runs.data?.pages.flatMap((p) => p.items) ?? [];
const pending = waiting.data?.pages.flatMap((p) => p.items) ?? [];
const arranged = arrangeHistory([...pending.filter(awaitingUser), ...loaded], f);
@@ -236,6 +242,15 @@ export function AgentSessionList({
return (
<nav aria-label="智能体会话列表" className={cn("flex min-h-0 flex-col", className)}>
<div className="mb-2 space-y-1.5">
<Tabs value={f.group} onValueChange={(v) => set({ group: v as AgentRunGroup })} className="pb-1">
<TabsList aria-label="会话分栏" className="grid w-full grid-cols-2">
{GROUPS.map((g) => (
<TabsTrigger key={g.value} value={g.value}>
{g.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Input
type="search"
value={f.q}
@@ -281,7 +296,9 @@ export function AgentSessionList({
? f.view === "pending"
? "没有等你处理的会话"
: "没有符合条件的会话"
: "还没有会话,写一个目标开始"
: f.group === "auto"
? "还没有巡检会话,巡检到点后会出现在这里"
: "还没有会话,写一个目标开始"
}
/>
) : (
@@ -12,7 +12,7 @@ export type DataColumn<T> = {
header: ReactNode;
cell: (row: T) => ReactNode;
/** 窄屏隐藏该列,避免横向溢出 */
hideBelow?: "sm" | "md" | "lg";
hideBelow?: "sm" | "md" | "lg" | "xl";
align?: "left" | "right";
className?: string;
};
@@ -31,6 +31,7 @@ const HIDE: Record<NonNullable<DataColumn<unknown>["hideBelow"]>, string> = {
sm: "hidden sm:table-cell",
md: "hidden md:table-cell",
lg: "hidden lg:table-cell",
xl: "hidden xl:table-cell",
};
/**
+34 -144
View File
@@ -12,8 +12,6 @@ import { ArrowLeft, Check, Globe, LoaderCircle, Rss, Search, Server, ShieldCheck
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { useFetchFeedNow } from "~/api/feeds.ts";
import { apiParsed } from "~/api/parse.ts";
import {
useCreateSite,
useHealthCheck,
@@ -49,20 +47,12 @@ import { ADAPTER_LABEL, hostOf, type LoginKind, supportedKinds } from "./site-fi
type Step = 0 | 1 | 2 | 3;
const PT_STEPS = ["选择类型", "选择站点", "填写凭证", "测试"] as const;
const FEED_STEPS = ["选择类型", "填写地址"] as const;
const FEED_STEPS = ["选择类型", "选择站点"] as const;
const GROUP_ORDER = ["nexusphp", "mteam_api", "api", "generic_rss"] as const;
const FeedPage = cursorPage(FeedSource);
const KIND_ORDER: SiteKind[] = ["pt", "public_bt", "rss_feed", "torznab"];
/** 自定义 RSS 源与索引器入口暂时不开放(已有的 RSS 源站点照常显示) */
const KIND_ORDER: SiteKind[] = ["pt", "public_bt"];
const KIND_ICON = { pt: ShieldCheck, public_bt: Globe, rss_feed: Rss, torznab: Server } as const;
function isHttpUrl(v: string): boolean {
try {
return /^https?:$/.test(new URL(v.trim()).protocol);
} catch {
return false;
}
}
/** 馒头默认 API Key,其余默认 Cookie(验证码站点账密大概率失败) */
function defaultKind(kinds: LoginKind[]): LoginKind {
if (kinds.includes("api_key") && !kinds.includes("cookie")) return "api_key";
@@ -72,10 +62,9 @@ function defaultKind(kinds: LoginKind[]): LoginKind {
export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; initialKind?: SiteKind | null }) {
const defs = useSiteDefinitions();
const navigate = useNavigate();
const startKind = initialKind && SITE_KINDS[initialKind]?.implemented ? initialKind : null;
const startKind = initialKind && KIND_ORDER.includes(initialKind) ? initialKind : null;
const [kind, setKind] = useState<SiteKind | null>(startKind);
const [step, setStep] = useState<Step>(startKind ? 1 : 0);
const [rssUrl, setRssUrl] = useState("");
const [q, setQ] = useState("");
const [defKey, setDefKey] = useState<string | null>(null);
const [form, setForm] = useState({ name: "", baseUrl: "", key: "" });
@@ -91,7 +80,6 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
const put = usePutCredentials();
const refresh = useRefreshLogin();
const check = useHealthCheck();
const fetchFeed = useFetchFeedNow();
const feedKind = kind !== null && kind !== "pt";
const stepLabels: readonly string[] = feedKind ? FEED_STEPS : PT_STEPS;
@@ -102,41 +90,6 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
setStep(1);
};
/** 公开 BT 站 / 自定义 RSS 源:建站点 + RSS(服务端一个事务),立即抓一次,进 RSS Tab */
const saveFeedSite = async () => {
const fe: Record<string, string> = {};
const url = rssUrl.trim();
const base = form.baseUrl.trim();
if (!form.name.trim()) fe.name = "填写名称";
if (kind === "rss_feed" && !isHttpUrl(url)) fe.rssUrl = "填写完整的 RSS 地址(http / https)";
if (kind === "public_bt" && url && !isHttpUrl(url)) fe.rssUrl = "填写完整的 RSS 地址(http / https)";
if (kind === "public_bt" && !isHttpUrl(base)) fe.baseUrl = "填完整地址,如 https://example.org";
if (kind === "rss_feed" && base && !isHttpUrl(base)) fe.baseUrl = "填完整地址,如 https://example.org";
setErrors(fe);
if (Object.keys(fe).length || !kind) return;
try {
setPhase("保存中…");
const site = await create.mutateAsync({
kind,
adapter: "generic_rss",
name: form.name.trim(),
baseUrl: base || new URL(url).origin,
...(url ? { rssUrl: url } : {}),
});
if (url) {
const feeds = await apiParsed(FeedPage, "/api/v1/feed-sources", { params: { siteId: site.id } });
for (const f of feeds.items) fetchFeed.mutate(f.id);
}
toast.success(`已添加「${site.name}」`);
onClose();
navigate(`${consolePath("sites", site.id)}?tab=rss`);
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
} finally {
setPhase(null);
}
};
const items = defs.data?.items ?? [];
const def = items.find((d) => d.key === defKey);
const kinds: LoginKind[] = supportedKinds(def);
@@ -212,90 +165,33 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
</ul>
);
const feedBody = (
<form
id="site-add-feed"
noValidate
onSubmit={(e) => {
e.preventDefault();
void saveFeedSite();
}}
className="min-w-0"
>
{kind === "public_bt" && publicDefs.length ? (
<div className="mb-4">
<p className="mb-2 text-caption font-medium text-foreground">内置站点</p>
<ul className="grid gap-2 sm:grid-cols-2">
{publicDefs.map((d) => (
<li key={d.key}>
<button
type="button"
disabled={busy || !!d.siteId}
onClick={() => void addPublic(d)}
className="flex w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-border/70 px-3 py-2 text-left transition-colors hover:border-foreground/40 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
>
<span className="min-w-0">
<span className="block truncate text-caption font-medium text-foreground">{d.name}</span>
<span className="block truncate text-meta text-muted-foreground">{hostOf(d.baseUrl)}</span>
</span>
{d.siteId ? <StatusBadge className="shrink-0">已添加</StatusBadge> : null}
</button>
</li>
))}
</ul>
<p className="mt-3 mb-2 text-meta text-muted-foreground">
内置站点支持搜索和定时抓取最新资源,不需要账号。其他公开站可以在下面手动填 RSS 地址。
</p>
</div>
<div className="min-w-0">
{publicDefs.length ? (
<ul className="grid gap-2 sm:grid-cols-2">
{publicDefs.map((d) => (
<li key={d.key}>
<button
type="button"
disabled={busy || !!d.siteId}
onClick={() => void addPublic(d)}
className="flex w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-border/70 px-3 py-2 text-left transition-colors hover:border-foreground/40 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
>
<span className="min-w-0">
<span className="block truncate text-caption font-medium text-foreground">{d.name}</span>
<span className="block truncate text-meta text-muted-foreground">{hostOf(d.baseUrl)}</span>
</span>
{d.siteId ? <StatusBadge className="shrink-0">已添加</StatusBadge> : null}
</button>
</li>
))}
</ul>
) : defs.isPending ? (
<LoadingState rows={2} />
) : null}
<FormField label="名称" htmlFor="add-feed-name" required error={errors.name}>
<Input
id="add-feed-name"
autoFocus
value={form.name}
aria-invalid={!!errors.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
disabled={busy}
/>
</FormField>
<FormField
label="RSS 地址"
htmlFor="add-feed-rss"
required={kind === "rss_feed"}
error={errors.rssUrl}
hint={kind === "public_bt" ? "可选;之后可在站点的 RSS 里添加" : "地址加密保存,页面上只显示去掉密钥参数的地址"}
>
<Input
id="add-feed-rss"
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
placeholder="https://"
value={rssUrl}
aria-invalid={!!errors.rssUrl}
onChange={(e) => setRssUrl(e.target.value)}
disabled={busy}
/>
</FormField>
<FormField
label="网址"
htmlFor="add-feed-url"
required={kind === "public_bt"}
error={errors.baseUrl}
hint={kind === "rss_feed" ? "可选;留空时用 RSS 地址的域名" : undefined}
>
<Input
id="add-feed-url"
inputMode="url"
placeholder="https://"
value={form.baseUrl}
aria-invalid={!!errors.baseUrl}
onChange={(e) => setForm((f) => ({ ...f, baseUrl: e.target.value }))}
disabled={busy}
/>
</FormField>
<p className="text-meta text-muted-foreground">不需要账号。保存后立即抓一次 RSS。</p>
</form>
<p className="mt-3 text-meta text-muted-foreground">
不需要账号;添加后默认走代理,定时抓取最新资源,也能在资源搜索里选用。
</p>
</div>
);
const validate = () => {
@@ -562,16 +458,10 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
取消
</Button>
) : feedKind ? (
<>
<Button variant="ghost" onClick={() => setStep(0)} disabled={busy}>
<ArrowLeft />
重选类型
</Button>
<Button type="submit" form="site-add-feed" disabled={busy}>
{busy ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
</>
<Button variant="ghost" onClick={() => setStep(0)} disabled={busy}>
<ArrowLeft />
重选类型
</Button>
) : step === 2 ? (
<>
<Button variant="ghost" onClick={() => setStep(1)} disabled={busy || !!created}>
+199 -118
View File
@@ -16,7 +16,7 @@ import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useFeeds } from "~/api/feeds.ts";
import { snapshotStale, useSnapshotSite, useToggleSite, useUpdateSite } from "~/api/sites.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
@@ -180,23 +180,25 @@ function CheckButtons({ site, checks, compact }: Omit<Props, "onDialog"> & { com
<>
<Button
variant="outline"
size="sm"
size={compact ? "icon-sm" : "sm"}
disabled={h || !site.enabled}
title={site.enabled ? "首页连通 + 登录态,走站点限流" : "站点已停用"}
aria-label="检测健康"
title={site.enabled ? "检测健康:首页连通 + 登录态,走站点限流" : "站点已停用"}
onClick={() => void checks.checkHealth(site)}
>
{h ? <LoaderCircle className="animate-spin" /> : <Activity />}
{compact ? <span className="sr-only sm:not-sr-only">检测健康</span> : "检测健康"}
{compact ? null : "检测健康"}
</Button>
<Button
variant="outline"
size="sm"
size={compact ? "icon-sm" : "sm"}
disabled={l || !hasAccount}
title={hasAccount ? "用凭证刷新登录状态并拉取用户数据" : "先填写凭证"}
aria-label="刷新登录"
title={hasAccount ? "刷新登录:用凭证刷新登录状态并拉取用户数据" : "先填写凭证"}
onClick={() => void checks.refreshLogin(site)}
>
{l ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
{compact ? <span className="sr-only sm:not-sr-only">刷新登录</span> : "刷新登录"}
{compact ? null : "刷新登录"}
</Button>
</>
);
@@ -295,7 +297,47 @@ function FeedSiteCard({ site, onDialog }: Omit<Props, "checks">) {
);
}
/** 内置公开 BT 站(专用适配器,无账号):出口、状态、资源更新,不走 RSS */
function PublicSiteCard({ site, onDialog }: Omit<Props, "checks">) {
const health = healthLine(site.lastHealth, site.lastHealthAt);
const stale = site.snapshotEnabled && snapshotStale(site);
return (
<article className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-4", !site.enabled && "opacity-75")}>
<SiteCardHeader site={site} />
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 rounded-lg border border-border/60 px-3 py-2.5 text-caption">
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">出口</dt>
<dd className="text-foreground">{site.proxy ? "代理" : "直连"}</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">状态</dt>
<dd className="truncate" title={healthMessage(site.lastHealth) ?? undefined}>
<StatusDot tone={health.tone} />
{health.text}
</dd>
</div>
<div className="col-span-2 min-w-0">
<dt className="text-meta text-muted-foreground">资源更新</dt>
<dd className={cn("text-foreground", stale && "text-warning")}>
{!site.snapshotEnabled
? "未开启"
: site.lastSnapshotAt
? `${relTime(site.lastSnapshotAt)}${stale ? " · 已过期" : ""}`
: "还没抓取过"}
</dd>
</div>
</dl>
<div aria-hidden className="min-h-4 flex-1" />
<footer className="mt-auto flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
{siteCan(site, "search") ? <SearchDefaultSwitch site={site} /> : <span className="mr-auto" />}
<SiteMoreMenu site={site} onDialog={onDialog} />
</footer>
</article>
);
}
export function SiteCard({ site, checks, onDialog }: Props) {
if (!hasLogin(site) && siteCan(site, "list")) return <PublicSiteCard site={site} onDialog={onDialog} />;
if (!hasLogin(site)) return <FeedSiteCard site={site} onDialog={onDialog} />;
return <AccountSiteCard site={site} checks={checks} onDialog={onDialog} />;
}
@@ -433,128 +475,167 @@ export function SiteTable({
sites,
checks,
onDialog,
variant = "pt",
}: {
sites: SiteListItem[];
checks: Checks;
onDialog: (d: SiteDialog) => void;
/** pt = 带账号与流量列;public = 公开站(无账号),只列状态与资源更新 */
variant?: "pt" | "public";
}) {
const [params] = useSearchParams();
const name: DataColumn<SiteListItem> = {
key: "name",
header: "站点",
cell: (s) => (
<div className="min-w-0">
<Link to={consolePath("sites", s.id)} className="font-medium whitespace-nowrap text-foreground hover:underline">
{s.name}
</Link>
<p className="truncate text-meta text-muted-foreground">{hostOf(s.baseUrl)}</p>
</div>
),
};
const snapshot: DataColumn<SiteListItem> = {
key: "snapshot",
header: "资源更新",
hideBelow: "md",
cell: (s) => (
<span
className={cn(
"whitespace-nowrap",
s.snapshotEnabled && snapshotStale(s) ? "text-warning" : "text-muted-foreground",
)}
>
{!siteCan(s, "list")
? "只有 RSS"
: !s.snapshotEnabled
? "未开启"
: s.lastSnapshotAt
? relTime(s.lastSnapshotAt)
: "还没有"}
</span>
),
};
const actions: DataColumn<SiteListItem> = {
key: "actions",
header: <span className="sr-only">操作</span>,
align: "right",
className: "w-px",
cell: (s) => (
<div className="flex items-center justify-end gap-1.5 whitespace-nowrap">
{hasLogin(s) && primaryAccount(s.accounts) ? <CheckButtons site={s} checks={checks} compact /> : null}
<EnableSwitch site={s} />
<SiteMoreMenu site={s} onDialog={onDialog} />
</div>
),
};
const columns: DataColumn<SiteListItem>[] =
variant === "public"
? [
name,
{
key: "proxy",
header: "出口",
hideBelow: "sm",
cell: (s) => <span className="whitespace-nowrap text-muted-foreground">{s.proxy ? "代理" : "直连"}</span>,
},
{
key: "search",
header: "资源搜索",
hideBelow: "sm",
cell: (s) => (
<span className="whitespace-nowrap text-muted-foreground">{s.searchDefault ? "默认参与" : "手动选"}</span>
),
},
snapshot,
actions,
]
: [
name,
{
key: "account",
header: "账号",
hideBelow: "xl",
cell: (s) => (
<div className="flex flex-wrap items-center gap-1">
<AccountBadges site={s} />
</div>
),
},
{
key: "login",
header: "上次登录",
hideBelow: "xl",
cell: (s) => (
<span className="whitespace-nowrap text-muted-foreground">
{relTime(primaryAccount(s.accounts)?.lastLoginAt)}
</span>
),
},
{
key: "traffic",
header: "上传 / 下载",
hideBelow: "md",
cell: (s) => {
const u = dataAccount(s.accounts)?.userData;
return u ? (
<span className="whitespace-nowrap tabular-nums">
{bytes(u.upload)} / {bytes(u.download)}
</span>
) : (
"—"
);
},
},
{
key: "ratio",
header: "分享率",
align: "right",
cell: (s) => (
<span className="whitespace-nowrap tabular-nums">{ratioText(dataAccount(s.accounts)?.userData)}</span>
),
},
{
key: "level",
header: "等级 · 做种",
hideBelow: "lg",
cell: (s) => {
const u = dataAccount(s.accounts)?.userData;
const t = u ? `${u.userLevel ?? "—"} · ${u.seeding}` : "—";
return (
<span className="block max-w-40 truncate whitespace-nowrap" title={t}>
{t}
</span>
);
},
},
{
key: "health",
header: "状态",
hideBelow: "md",
cell: (s) => {
if (!hasLogin(s)) return <span className="text-muted-foreground">—</span>;
const h = healthLine(s.lastHealth, s.lastHealthAt);
return (
<span className="whitespace-nowrap text-caption" title={healthMessage(s.lastHealth) ?? h.text}>
<StatusDot tone={h.tone} />
{h.text}
</span>
);
},
},
snapshot,
actions,
];
// 站点数量不多:分组表格不分页
return (
<DataTable
rows={sites}
pagination={sites.length > 50}
paginationKey={params.toString()}
rowKey={(r) => r.id}
columns={[
{
key: "name",
header: "站点",
cell: (s) => (
<div className="min-w-0">
<Link to={consolePath("sites", s.id)} className="font-medium text-foreground hover:underline">
{s.name}
</Link>
<p className="truncate text-meta text-muted-foreground">{hostOf(s.baseUrl)}</p>
</div>
),
},
{
key: "kind",
header: "类型",
hideBelow: "sm",
cell: (s) => <span className="whitespace-nowrap text-muted-foreground">{kindLabel(s.kind)}</span>,
},
{
key: "account",
header: "账号",
cell: (s) => (
<div className="flex flex-wrap items-center gap-1">
<AccountBadges site={s} />
</div>
),
},
{
key: "login",
header: "上次登录",
hideBelow: "lg",
cell: (s) => (
<span className="text-muted-foreground">{relTime(primaryAccount(s.accounts)?.lastLoginAt)}</span>
),
},
{
key: "traffic",
header: "上传 / 下载",
hideBelow: "md",
cell: (s) => {
const u = dataAccount(s.accounts)?.userData;
return u ? (
<span className="whitespace-nowrap tabular-nums">
{bytes(u.upload)} / {bytes(u.download)}
</span>
) : (
"—"
);
},
},
{
key: "ratio",
header: "分享率",
hideBelow: "sm",
align: "right",
cell: (s) => <span className="tabular-nums">{ratioText(dataAccount(s.accounts)?.userData)}</span>,
},
{
key: "level",
header: "等级 · 做种",
hideBelow: "lg",
cell: (s) => {
const u = dataAccount(s.accounts)?.userData;
return u ? `${u.userLevel ?? "—"} · ${u.seeding}` : "—";
},
},
{
key: "health",
header: "状态",
hideBelow: "md",
cell: (s) => {
if (!hasLogin(s)) return <span className="text-muted-foreground">—</span>;
const h = healthLine(s.lastHealth, s.lastHealthAt);
return (
<span className="text-caption" title={healthMessage(s.lastHealth) ?? undefined}>
<StatusDot tone={h.tone} />
{h.text}
</span>
);
},
},
{
key: "snapshot",
header: "资源更新",
hideBelow: "lg",
cell: (s) => (
<span className={s.snapshotEnabled && snapshotStale(s) ? "text-warning" : "text-muted-foreground"}>
{!siteCan(s, "list")
? "只有 RSS"
: !s.snapshotEnabled
? "未开启"
: s.lastSnapshotAt
? relTime(s.lastSnapshotAt)
: "还没有"}
</span>
),
},
{
key: "actions",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (s) => (
<div className="flex items-center justify-end gap-1.5">
<EnableSwitch site={s} />
{hasLogin(s) && primaryAccount(s.accounts) ? <CheckButtons site={s} checks={checks} compact /> : null}
<SiteMoreMenu site={s} onDialog={onDialog} />
</div>
),
},
]}
columns={columns}
/>
);
}
+32 -34
View File
@@ -6,25 +6,27 @@ import { useSites } from "~/api/sites.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { SearchDefaultDialog } from "~/components/sites/search-default-dialog.tsx";
import { SiteAddDialog } from "~/components/sites/site-add-dialog.tsx";
import { SiteCard, SiteTable } from "~/components/sites/site-card.tsx";
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
import { needsAttention, type SortKey, sortSites } from "~/components/sites/site-fields.ts";
import { Button } from "~/components/ui/button.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { cn } from "~/lib/utils.ts";
// 获取 → 站点:站点、凭证、健康、账号数据、RSS 的唯一入口(docs/design/site-center.md §4);
// IA-2 起站点有类型(PT 站 / 公开 BT 站 / 自定义 RSS 源),列表可按类型筛选,?new=<类型> 直接打开添加。
// 按类型分组显示:PT 站 / 公开 BT 站(旧的自定义 RSS 源有的话单列);?new=<类型> 直接打开添加。
const VIEW_KEY = "umt.sites.view";
type View = "cards" | "list";
type Filter = "all" | "enabled" | "attention";
const KIND_OPTIONS = (Object.keys(SITE_KINDS) as SiteKind[])
.filter((k) => SITE_KINDS[k].implemented)
.map((k) => ({ value: k, label: SITE_KINDS[k].label }));
/** 分组顺序;公开 BT 站的表只列它用得到的列 */
const GROUPS: { kind: SiteKind; title: string; variant: "pt" | "public" }[] = [
{ kind: "pt", title: "PT 站", variant: "pt" },
{ kind: "public_bt", title: "公开 BT 站", variant: "public" },
{ kind: "rss_feed", title: "RSS 源", variant: "public" },
{ kind: "torznab", title: "索引器", variant: "public" },
];
const FILTERS: { value: Filter; label: string }[] = [
{ value: "all", label: "全部" },
@@ -66,7 +68,6 @@ export default function SitesPage() {
const view: View = urlView === "list" || urlView === "cards" ? urlView : storedView;
const filter = (FILTERS.find((f) => f.value === params.get("filter"))?.value ?? "all") as Filter;
const sort = (SORTS.find((s) => s.value === params.get("sort"))?.value ?? "name") as SortKey;
const kindFilter = KIND_OPTIONS.find((k) => k.value === params.get("kind"))?.value ?? "";
const setParam = (k: string, v: string | null) =>
setParams(
@@ -109,15 +110,12 @@ export default function SitesPage() {
const [searchDefaults, setSearchDefaults] = useState(false);
const attention = all.filter(needsAttention).length;
const shown = sortSites(
all.filter(
(s) =>
(filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true) &&
(!kindFilter || s.kind === kindFilter),
),
all.filter((s) => (filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true)),
sort,
);
const paged = useListPagination(shown, { resetKey: JSON.stringify({ filter, kindFilter, sort }) });
const groups = GROUPS.map((g) => ({ ...g, sites: shown.filter((s) => s.kind === g.kind) })).filter(
(g) => g.sites.length,
);
return (
<>
@@ -153,14 +151,6 @@ export default function SitesPage() {
label: f.value === "attention" && attention ? `${f.label} ${attention}` : f.label,
}))}
/>
{new Set(all.map((s) => s.kind)).size > 1 || kindFilter ? (
<FilterSelect
label="类型"
value={kindFilter}
onChange={(v) => setParam("kind", v || null)}
options={KIND_OPTIONS}
/>
) : null}
<FilterSelect
label="排序"
value={sort === "name" ? "" : sort}
@@ -191,7 +181,7 @@ export default function SitesPage() {
<EmptyState
icon={Globe}
title="还没有站点"
description="添加 PT 站、公开 BT 站或 RSS 源后,这里显示账号数据、健康和 RSS 状态"
description="添加 PT 站或公开 BT 站后,这里显示账号数据、健康和资源更新"
action={
<Button size="sm" onClick={() => setAdding("any")}>
<Plus />
@@ -202,23 +192,31 @@ export default function SitesPage() {
) : !shown.length ? (
<EmptyState
icon={Globe}
title={kindFilter ? "没有这类站点" : filter === "attention" ? "没有需处理的站点" : "没有已启用的站点"}
description={kindFilter ? "清除类型筛选查看其余站点" : "切换到「全部」查看其余站点"}
title={filter === "attention" ? "没有需处理的站点" : "没有已启用的站点"}
description="切换到「全部」查看其余站点"
/>
) : view === "list" ? (
<SiteTable sites={shown} checks={checks} onDialog={setDialog} />
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{paged.items?.map((s) => (
<SiteCard key={s.id} site={s} checks={checks} onDialog={setDialog} />
<div className="space-y-6">
{groups.map((g) => (
<section key={g.kind} aria-label={g.title} className="min-w-0">
<h2 className="mb-2 flex items-baseline gap-2 text-caption font-semibold text-foreground">
{g.title}
<span className="text-meta font-normal text-muted-foreground">{g.sites.length}</span>
</h2>
{view === "list" ? (
<SiteTable sites={g.sites} checks={checks} onDialog={setDialog} variant={g.variant} />
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{g.sites.map((s) => (
<SiteCard key={s.id} site={s} checks={checks} onDialog={setDialog} />
))}
</div>
)}
</section>
))}
</div>
)}
{view !== "list" && shown.length > 0 ? (
<Pagination {...paged.pagination} className="mt-2 border-t-0 px-0" />
) : null}
{adding ? <SiteAddDialog initialKind={adding === "any" ? null : adding} onClose={() => setAdding(null)} /> : null}
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} />
<SearchDefaultDialog open={searchDefaults} onOpenChange={setSearchDefaults} sites={all} />
+2
View File
@@ -358,6 +358,8 @@ export const AgentRunListQuery = z.object({
limit: z.coerce.number().int().min(1).max(200).default(50),
status: AgentRunStatus.optional(),
trigger: AgentTrigger.optional(),
/** auto = 定时巡检 / 事件触发的会话(及其子会话);conversation = 用户发起的会话(及其子会话) */
group: z.enum(["conversation", "auto"]).optional(),
/** 只列某个会话直接派生的子会话 */
parentRunId: Id.optional(),
});
+1 -1
View File
@@ -43,7 +43,7 @@ export const SITE_KINDS: Record<SiteKind, SiteKindSpec> = {
},
public_bt: {
label: "公开 BT 站",
description: "不用登录的公开站。内置 Nyaa、蜜柑、AniBT、AnimeGarden、萌番组,其他站填网址和 RSS 地址",
description: "不用登录的公开站:Nyaa、蜜柑、AniBT、AnimeGarden、萌番组",
capabilities: ["search", "list", "rss", "detail"],
implemented: true,
fromDefinition: false,