feat(web): 站点按类型分组、列表模式修样式、下掉自定义 RSS 源与索引器入口;智能体会话分对话 / 自动巡检两栏
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8da8679a04
commit
8eb8a68295
11 files changed
+273
-221
No files matched your search
@@ -4,6 +4,11 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 调整
|
||||
- 站点页按类型分组(PT 站 / 公开 BT 站;旧的 RSS 源有的话单列),去掉类型筛选;添加站点只保留 PT 站和内置公开 BT 站,自定义 RSS 源与索引器入口先下掉(已有的照常显示)。
|
||||
- 站点列表模式:操作按钮改成图标,数值列不换行,窄屏隐藏次要列;公开 BT 站的表只列出口、资源搜索、资源更新;公开站卡片不再显示 RSS。
|
||||
- 智能体页会话分两栏:对话(用户发起)/ 自动巡检(定时、事件触发),子会话随父会话。
|
||||
|
||||
## [0.3.19] - 2026-10-03
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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,6 +296,8 @@ 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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,18 +165,8 @@ 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>
|
||||
<div className="min-w-0">
|
||||
{publicDefs.length ? (
|
||||
<ul className="grid gap-2 sm:grid-cols-2">
|
||||
{publicDefs.map((d) => (
|
||||
<li key={d.key}>
|
||||
@@ -242,60 +185,13 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 mb-2 text-meta text-muted-foreground">
|
||||
内置站点支持搜索和定时抓取最新资源,不需要账号。其他公开站可以在下面手动填 RSS 地址。
|
||||
) : defs.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : null}
|
||||
<p className="mt-3 text-meta text-muted-foreground">
|
||||
不需要账号;添加后默认走代理,定时抓取最新资源,也能在资源搜索里选用。
|
||||
</p>
|
||||
</div>
|
||||
) : 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>
|
||||
);
|
||||
|
||||
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>
|
||||
</>
|
||||
) : step === 2 ? (
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setStep(1)} disabled={busy || !!created}>
|
||||
|
||||
@@ -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,39 +475,88 @@ export function SiteTable({
|
||||
sites,
|
||||
checks,
|
||||
onDialog,
|
||||
variant = "pt",
|
||||
}: {
|
||||
sites: SiteListItem[];
|
||||
checks: Checks;
|
||||
onDialog: (d: SiteDialog) => void;
|
||||
/** pt = 带账号与流量列;public = 公开站(无账号),只列状态与资源更新 */
|
||||
variant?: "pt" | "public";
|
||||
}) {
|
||||
const [params] = useSearchParams();
|
||||
return (
|
||||
<DataTable
|
||||
rows={sites}
|
||||
paginationKey={params.toString()}
|
||||
rowKey={(r) => r.id}
|
||||
columns={[
|
||||
{
|
||||
const name: DataColumn<SiteListItem> = {
|
||||
key: "name",
|
||||
header: "站点",
|
||||
cell: (s) => (
|
||||
<div className="min-w-0">
|
||||
<Link to={consolePath("sites", s.id)} className="font-medium text-foreground hover:underline">
|
||||
<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: "kind",
|
||||
header: "类型",
|
||||
key: "search",
|
||||
header: "资源搜索",
|
||||
hideBelow: "sm",
|
||||
cell: (s) => <span className="whitespace-nowrap text-muted-foreground">{kindLabel(s.kind)}</span>,
|
||||
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} />
|
||||
@@ -475,9 +566,11 @@ export function SiteTable({
|
||||
{
|
||||
key: "login",
|
||||
header: "上次登录",
|
||||
hideBelow: "lg",
|
||||
hideBelow: "xl",
|
||||
cell: (s) => (
|
||||
<span className="text-muted-foreground">{relTime(primaryAccount(s.accounts)?.lastLoginAt)}</span>
|
||||
<span className="whitespace-nowrap text-muted-foreground">
|
||||
{relTime(primaryAccount(s.accounts)?.lastLoginAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -498,9 +591,10 @@ export function SiteTable({
|
||||
{
|
||||
key: "ratio",
|
||||
header: "分享率",
|
||||
hideBelow: "sm",
|
||||
align: "right",
|
||||
cell: (s) => <span className="tabular-nums">{ratioText(dataAccount(s.accounts)?.userData)}</span>,
|
||||
cell: (s) => (
|
||||
<span className="whitespace-nowrap tabular-nums">{ratioText(dataAccount(s.accounts)?.userData)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "level",
|
||||
@@ -508,7 +602,12 @@ export function SiteTable({
|
||||
hideBelow: "lg",
|
||||
cell: (s) => {
|
||||
const u = dataAccount(s.accounts)?.userData;
|
||||
return u ? `${u.userLevel ?? "—"} · ${u.seeding}` : "—";
|
||||
const t = u ? `${u.userLevel ?? "—"} · ${u.seeding}` : "—";
|
||||
return (
|
||||
<span className="block max-w-40 truncate whitespace-nowrap" title={t}>
|
||||
{t}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -519,42 +618,24 @@ export function SiteTable({
|
||||
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}>
|
||||
<span className="whitespace-nowrap text-caption" title={healthMessage(s.lastHealth) ?? h.text}>
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
snapshot,
|
||||
actions,
|
||||
];
|
||||
// 站点数量不多:分组表格不分页
|
||||
return (
|
||||
<DataTable
|
||||
rows={sites}
|
||||
pagination={sites.length > 50}
|
||||
paginationKey={params.toString()}
|
||||
rowKey={(r) => r.id}
|
||||
columns={columns}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,22 +192,30 @@ 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="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">
|
||||
{paged.items?.map((s) => (
|
||||
{g.sites.map((s) => (
|
||||
<SiteCard key={s.id} site={s} checks={checks} onDialog={setDialog} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view !== "list" && shown.length > 0 ? (
|
||||
<Pagination {...paged.pagination} className="mt-2 border-t-0 px-0" />
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adding ? <SiteAddDialog initialKind={adding === "any" ? null : adding} onClose={() => setAdding(null)} /> : null}
|
||||
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} />
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user