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
+364
-312
No files matched your search
@@ -4,6 +4,11 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 调整
|
||||||
|
- 站点页按类型分组(PT 站 / 公开 BT 站;旧的 RSS 源有的话单列),去掉类型筛选;添加站点只保留 PT 站和内置公开 BT 站,自定义 RSS 源与索引器入口先下掉(已有的照常显示)。
|
||||||
|
- 站点列表模式:操作按钮改成图标,数值列不换行,窄屏隐藏次要列;公开 BT 站的表只列出口、资源搜索、资源更新;公开站卡片不再显示 RSS。
|
||||||
|
- 智能体页会话分两栏:对话(用户发起)/ 自动巡检(定时、事件触发),子会话随父会话。
|
||||||
|
|
||||||
## [0.3.19] - 2026-10-03
|
## [0.3.19] - 2026-10-03
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
@@ -122,6 +122,12 @@ export function registerAgentRoutes(
|
|||||||
if (query.status) q = q.where("status", "=", query.status);
|
if (query.status) q = q.where("status", "=", query.status);
|
||||||
if (query.trigger) q = q.where("trigger", "=", query.trigger);
|
if (query.trigger) q = q.where("trigger", "=", query.trigger);
|
||||||
if (query.parentRunId) q = q.where("parent_run_id", "=", query.parentRunId);
|
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);
|
const cur = decode(query.cursor);
|
||||||
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
|
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
|
||||||
const rows = await q
|
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 = {
|
export const agentKeys = {
|
||||||
all: ["agent"] as const,
|
all: ["agent"] as const,
|
||||||
runs: (status?: AgentRunStatus, trigger?: AgentTrigger) =>
|
runs: (status?: AgentRunStatus, trigger?: AgentTrigger, group?: AgentRunGroup) =>
|
||||||
["agent", "list", status ?? "all", trigger ?? "any"] as const,
|
["agent", "list", status ?? "all", trigger ?? "any", group ?? "any"] as const,
|
||||||
run: (id: string) => ["agent", "detail", id] as const,
|
run: (id: string) => ["agent", "detail", id] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,19 +137,28 @@ export const TRIGGER_LABEL: Record<AgentTrigger, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** 会话列表:按状态、来源(trigger)在服务端筛选,游标翻页 */
|
/** 会话列表:按状态、来源(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();
|
const mock = useAgentMockGate();
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: [...agentKeys.runs(status, trigger), mock],
|
queryKey: [...agentKeys.runs(status, trigger, group), mock],
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
queryFn: async ({ pageParam, signal }) => {
|
queryFn: async ({ pageParam, signal }) => {
|
||||||
if (mock)
|
if (mock)
|
||||||
return {
|
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,
|
nextCursor: null,
|
||||||
};
|
};
|
||||||
return apiParsed(RunPage, AGENT_BASE, {
|
return apiParsed(RunPage, AGENT_BASE, {
|
||||||
params: cleanParams({ status, trigger, cursor: pageParam, limit: 30 }),
|
params: cleanParams({ status, trigger, group, cursor: pageParam, limit: 30 }),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
import { Bot, ChevronRight } from "lucide-react";
|
import { Bot, ChevronRight } from "lucide-react";
|
||||||
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
|
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { Link } from "react-router";
|
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 { CHEVRON_CLASS } from "~/components/agentui/disclosure.tsx";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||||
import { Input } from "~/components/ui/input.tsx";
|
import { Input } from "~/components/ui/input.tsx";
|
||||||
import { Pagination } from "~/components/ui/pagination.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 { relTime } from "~/components/workbench/release-fields.ts";
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { contextLabel, RunStatusBadge } from "./agent-conversation.tsx";
|
import { contextLabel, RunStatusBadge } from "./agent-conversation.tsx";
|
||||||
import { shortGoalTitle, splitContextGoal } from "./agent-store.ts";
|
import { shortGoalTitle, splitContextGoal } from "./agent-store.ts";
|
||||||
|
|
||||||
// 会话列表(AGUI-3):会话页左栏与抽屉的「历史」弹层共用。
|
// 会话列表(AGUI-3):会话页左栏与抽屉的「历史」弹层共用。
|
||||||
// 筛选只有搜索 + 全部 / 等你处理 / 失败;「等你处理」(等选方案、等审批)置顶;子会话收在父会话下面。
|
// 分两栏:对话(用户发起)/ 自动巡检(定时、事件触发),子会话随父会话;栏内筛选只有搜索 + 全部 / 等你处理 / 失败;「等你处理」(等选方案、等审批)置顶;子会话收在父会话下面。
|
||||||
|
|
||||||
export type HistoryView = "all" | "pending" | "failed";
|
export type HistoryView = "all" | "pending" | "failed";
|
||||||
export type HistoryFilters = { q: string; view: HistoryView };
|
export type HistoryFilters = { q: string; view: HistoryView; group: AgentRunGroup };
|
||||||
export const EMPTY_FILTERS: HistoryFilters = { q: "", view: "all" };
|
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 }[] = [
|
const VIEWS: { value: HistoryView; label: string }[] = [
|
||||||
{ value: "all", label: "全部" },
|
{ value: "all", label: "全部" },
|
||||||
@@ -45,7 +51,7 @@ export type HistoryRow = { run: AgentRunView; children: AgentRunView[] };
|
|||||||
*/
|
*/
|
||||||
export function arrangeHistory(
|
export function arrangeHistory(
|
||||||
runs: AgentRunView[],
|
runs: AgentRunView[],
|
||||||
f: HistoryFilters,
|
f: Pick<HistoryFilters, "q" | "view">,
|
||||||
limit = Number.POSITIVE_INFINITY,
|
limit = Number.POSITIVE_INFINITY,
|
||||||
): { pinned: HistoryRow[]; rows: HistoryRow[] } {
|
): { pinned: HistoryRow[]; rows: HistoryRow[] } {
|
||||||
const uniq = [...new Map(runs.map((r) => [r.id, r] as const)).values()];
|
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;
|
if (remember) drawerMemory.filters = next;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
const runs = useAgentRuns();
|
const runs = useAgentRuns(undefined, undefined, f.group);
|
||||||
// 等你处理的会话可能是很早以前建的:单独按 waiting 取一页,保证置顶
|
// 等你处理的会话可能是很早以前建的:单独按 waiting 取一页,保证置顶
|
||||||
const waiting = useAgentRuns("waiting");
|
const waiting = useAgentRuns("waiting", undefined, f.group);
|
||||||
const loaded = runs.data?.pages.flatMap((p) => p.items) ?? [];
|
const loaded = runs.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
const pending = waiting.data?.pages.flatMap((p) => p.items) ?? [];
|
const pending = waiting.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
const arranged = arrangeHistory([...pending.filter(awaitingUser), ...loaded], f);
|
const arranged = arrangeHistory([...pending.filter(awaitingUser), ...loaded], f);
|
||||||
@@ -236,6 +242,15 @@ export function AgentSessionList({
|
|||||||
return (
|
return (
|
||||||
<nav aria-label="智能体会话列表" className={cn("flex min-h-0 flex-col", className)}>
|
<nav aria-label="智能体会话列表" className={cn("flex min-h-0 flex-col", className)}>
|
||||||
<div className="mb-2 space-y-1.5">
|
<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
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
value={f.q}
|
value={f.q}
|
||||||
@@ -281,7 +296,9 @@ export function AgentSessionList({
|
|||||||
? f.view === "pending"
|
? f.view === "pending"
|
||||||
? "没有等你处理的会话"
|
? "没有等你处理的会话"
|
||||||
: "没有符合条件的会话"
|
: "没有符合条件的会话"
|
||||||
: "还没有会话,写一个目标开始"
|
: f.group === "auto"
|
||||||
|
? "还没有巡检会话,巡检到点后会出现在这里"
|
||||||
|
: "还没有会话,写一个目标开始"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type DataColumn<T> = {
|
|||||||
header: ReactNode;
|
header: ReactNode;
|
||||||
cell: (row: T) => ReactNode;
|
cell: (row: T) => ReactNode;
|
||||||
/** 窄屏隐藏该列,避免横向溢出 */
|
/** 窄屏隐藏该列,避免横向溢出 */
|
||||||
hideBelow?: "sm" | "md" | "lg";
|
hideBelow?: "sm" | "md" | "lg" | "xl";
|
||||||
align?: "left" | "right";
|
align?: "left" | "right";
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
@@ -31,6 +31,7 @@ const HIDE: Record<NonNullable<DataColumn<unknown>["hideBelow"]>, string> = {
|
|||||||
sm: "hidden sm:table-cell",
|
sm: "hidden sm:table-cell",
|
||||||
md: "hidden md:table-cell",
|
md: "hidden md:table-cell",
|
||||||
lg: "hidden lg: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 { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useFetchFeedNow } from "~/api/feeds.ts";
|
|
||||||
import { apiParsed } from "~/api/parse.ts";
|
|
||||||
import {
|
import {
|
||||||
useCreateSite,
|
useCreateSite,
|
||||||
useHealthCheck,
|
useHealthCheck,
|
||||||
@@ -49,20 +47,12 @@ import { ADAPTER_LABEL, hostOf, type LoginKind, supportedKinds } from "./site-fi
|
|||||||
|
|
||||||
type Step = 0 | 1 | 2 | 3;
|
type Step = 0 | 1 | 2 | 3;
|
||||||
const PT_STEPS = ["选择类型", "选择站点", "填写凭证", "测试"] as const;
|
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 GROUP_ORDER = ["nexusphp", "mteam_api", "api", "generic_rss"] as const;
|
||||||
const FeedPage = cursorPage(FeedSource);
|
/** 自定义 RSS 源与索引器入口暂时不开放(已有的 RSS 源站点照常显示) */
|
||||||
const KIND_ORDER: SiteKind[] = ["pt", "public_bt", "rss_feed", "torznab"];
|
const KIND_ORDER: SiteKind[] = ["pt", "public_bt"];
|
||||||
const KIND_ICON = { pt: ShieldCheck, public_bt: Globe, rss_feed: Rss, torznab: Server } as const;
|
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(验证码站点账密大概率失败) */
|
/** 馒头默认 API Key,其余默认 Cookie(验证码站点账密大概率失败) */
|
||||||
function defaultKind(kinds: LoginKind[]): LoginKind {
|
function defaultKind(kinds: LoginKind[]): LoginKind {
|
||||||
if (kinds.includes("api_key") && !kinds.includes("cookie")) return "api_key";
|
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 }) {
|
export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; initialKind?: SiteKind | null }) {
|
||||||
const defs = useSiteDefinitions();
|
const defs = useSiteDefinitions();
|
||||||
const navigate = useNavigate();
|
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 [kind, setKind] = useState<SiteKind | null>(startKind);
|
||||||
const [step, setStep] = useState<Step>(startKind ? 1 : 0);
|
const [step, setStep] = useState<Step>(startKind ? 1 : 0);
|
||||||
const [rssUrl, setRssUrl] = useState("");
|
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [defKey, setDefKey] = useState<string | null>(null);
|
const [defKey, setDefKey] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState({ name: "", baseUrl: "", key: "" });
|
const [form, setForm] = useState({ name: "", baseUrl: "", key: "" });
|
||||||
@@ -91,7 +80,6 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
|
|||||||
const put = usePutCredentials();
|
const put = usePutCredentials();
|
||||||
const refresh = useRefreshLogin();
|
const refresh = useRefreshLogin();
|
||||||
const check = useHealthCheck();
|
const check = useHealthCheck();
|
||||||
const fetchFeed = useFetchFeedNow();
|
|
||||||
const feedKind = kind !== null && kind !== "pt";
|
const feedKind = kind !== null && kind !== "pt";
|
||||||
const stepLabels: readonly string[] = feedKind ? FEED_STEPS : PT_STEPS;
|
const stepLabels: readonly string[] = feedKind ? FEED_STEPS : PT_STEPS;
|
||||||
|
|
||||||
@@ -102,41 +90,6 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
|
|||||||
setStep(1);
|
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 items = defs.data?.items ?? [];
|
||||||
const def = items.find((d) => d.key === defKey);
|
const def = items.find((d) => d.key === defKey);
|
||||||
const kinds: LoginKind[] = supportedKinds(def);
|
const kinds: LoginKind[] = supportedKinds(def);
|
||||||
@@ -212,90 +165,33 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
|
|||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
const feedBody = (
|
const feedBody = (
|
||||||
<form
|
<div className="min-w-0">
|
||||||
id="site-add-feed"
|
{publicDefs.length ? (
|
||||||
noValidate
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
onSubmit={(e) => {
|
{publicDefs.map((d) => (
|
||||||
e.preventDefault();
|
<li key={d.key}>
|
||||||
void saveFeedSite();
|
<button
|
||||||
}}
|
type="button"
|
||||||
className="min-w-0"
|
disabled={busy || !!d.siteId}
|
||||||
>
|
onClick={() => void addPublic(d)}
|
||||||
{kind === "public_bt" && publicDefs.length ? (
|
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"
|
||||||
<div className="mb-4">
|
>
|
||||||
<p className="mb-2 text-caption font-medium text-foreground">内置站点</p>
|
<span className="min-w-0">
|
||||||
<ul className="grid gap-2 sm:grid-cols-2">
|
<span className="block truncate text-caption font-medium text-foreground">{d.name}</span>
|
||||||
{publicDefs.map((d) => (
|
<span className="block truncate text-meta text-muted-foreground">{hostOf(d.baseUrl)}</span>
|
||||||
<li key={d.key}>
|
</span>
|
||||||
<button
|
{d.siteId ? <StatusBadge className="shrink-0">已添加</StatusBadge> : null}
|
||||||
type="button"
|
</button>
|
||||||
disabled={busy || !!d.siteId}
|
</li>
|
||||||
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"
|
</ul>
|
||||||
>
|
) : defs.isPending ? (
|
||||||
<span className="min-w-0">
|
<LoadingState rows={2} />
|
||||||
<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>
|
|
||||||
) : null}
|
) : null}
|
||||||
<FormField label="名称" htmlFor="add-feed-name" required error={errors.name}>
|
<p className="mt-3 text-meta text-muted-foreground">
|
||||||
<Input
|
不需要账号;添加后默认走代理,定时抓取最新资源,也能在资源搜索里选用。
|
||||||
id="add-feed-name"
|
</p>
|
||||||
autoFocus
|
</div>
|
||||||
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 = () => {
|
const validate = () => {
|
||||||
@@ -562,16 +458,10 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
|
|||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
) : feedKind ? (
|
) : feedKind ? (
|
||||||
<>
|
<Button variant="ghost" onClick={() => setStep(0)} disabled={busy}>
|
||||||
<Button variant="ghost" onClick={() => setStep(0)} disabled={busy}>
|
<ArrowLeft />
|
||||||
<ArrowLeft />
|
重选类型
|
||||||
重选类型
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button type="submit" form="site-add-feed" disabled={busy}>
|
|
||||||
{busy ? <LoaderCircle className="animate-spin" /> : null}
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : step === 2 ? (
|
) : step === 2 ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={() => setStep(1)} disabled={busy || !!created}>
|
<Button variant="ghost" onClick={() => setStep(1)} disabled={busy || !!created}>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Link, useSearchParams } from "react-router";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useFeeds } from "~/api/feeds.ts";
|
import { useFeeds } from "~/api/feeds.ts";
|
||||||
import { snapshotStale, useSnapshotSite, useToggleSite, useUpdateSite } from "~/api/sites.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 { consolePath } from "~/components/console/nav.ts";
|
||||||
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";
|
||||||
@@ -180,23 +180,25 @@ function CheckButtons({ site, checks, compact }: Omit<Props, "onDialog"> & { com
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size={compact ? "icon-sm" : "sm"}
|
||||||
disabled={h || !site.enabled}
|
disabled={h || !site.enabled}
|
||||||
title={site.enabled ? "首页连通 + 登录态,走站点限流" : "站点已停用"}
|
aria-label="检测健康"
|
||||||
|
title={site.enabled ? "检测健康:首页连通 + 登录态,走站点限流" : "站点已停用"}
|
||||||
onClick={() => void checks.checkHealth(site)}
|
onClick={() => void checks.checkHealth(site)}
|
||||||
>
|
>
|
||||||
{h ? <LoaderCircle className="animate-spin" /> : <Activity />}
|
{h ? <LoaderCircle className="animate-spin" /> : <Activity />}
|
||||||
{compact ? <span className="sr-only sm:not-sr-only">检测健康</span> : "检测健康"}
|
{compact ? null : "检测健康"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size={compact ? "icon-sm" : "sm"}
|
||||||
disabled={l || !hasAccount}
|
disabled={l || !hasAccount}
|
||||||
title={hasAccount ? "用凭证刷新登录状态并拉取用户数据" : "先填写凭证"}
|
aria-label="刷新登录"
|
||||||
|
title={hasAccount ? "刷新登录:用凭证刷新登录状态并拉取用户数据" : "先填写凭证"}
|
||||||
onClick={() => void checks.refreshLogin(site)}
|
onClick={() => void checks.refreshLogin(site)}
|
||||||
>
|
>
|
||||||
{l ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
|
{l ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
|
||||||
{compact ? <span className="sr-only sm:not-sr-only">刷新登录</span> : "刷新登录"}
|
{compact ? null : "刷新登录"}
|
||||||
</Button>
|
</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) {
|
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} />;
|
if (!hasLogin(site)) return <FeedSiteCard site={site} onDialog={onDialog} />;
|
||||||
return <AccountSiteCard site={site} checks={checks} onDialog={onDialog} />;
|
return <AccountSiteCard site={site} checks={checks} onDialog={onDialog} />;
|
||||||
}
|
}
|
||||||
@@ -433,128 +475,167 @@ export function SiteTable({
|
|||||||
sites,
|
sites,
|
||||||
checks,
|
checks,
|
||||||
onDialog,
|
onDialog,
|
||||||
|
variant = "pt",
|
||||||
}: {
|
}: {
|
||||||
sites: SiteListItem[];
|
sites: SiteListItem[];
|
||||||
checks: Checks;
|
checks: Checks;
|
||||||
onDialog: (d: SiteDialog) => void;
|
onDialog: (d: SiteDialog) => void;
|
||||||
|
/** pt = 带账号与流量列;public = 公开站(无账号),只列状态与资源更新 */
|
||||||
|
variant?: "pt" | "public";
|
||||||
}) {
|
}) {
|
||||||
const [params] = useSearchParams();
|
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 (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={sites}
|
rows={sites}
|
||||||
|
pagination={sites.length > 50}
|
||||||
paginationKey={params.toString()}
|
paginationKey={params.toString()}
|
||||||
rowKey={(r) => r.id}
|
rowKey={(r) => r.id}
|
||||||
columns={[
|
columns={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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,25 +6,27 @@ import { useSites } 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 { FilterSelect } from "~/components/console/filter-bar.tsx";
|
import { FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||||
import { PageHeader } from "~/components/console/page-header.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 { SearchDefaultDialog } from "~/components/sites/search-default-dialog.tsx";
|
||||||
import { SiteAddDialog } from "~/components/sites/site-add-dialog.tsx";
|
import { SiteAddDialog } from "~/components/sites/site-add-dialog.tsx";
|
||||||
import { SiteCard, SiteTable } from "~/components/sites/site-card.tsx";
|
import { SiteCard, SiteTable } from "~/components/sites/site-card.tsx";
|
||||||
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
|
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
|
||||||
import { needsAttention, type SortKey, sortSites } from "~/components/sites/site-fields.ts";
|
import { needsAttention, type SortKey, sortSites } from "~/components/sites/site-fields.ts";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { Pagination } from "~/components/ui/pagination.tsx";
|
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
|
|
||||||
// 获取 → 站点:站点、凭证、健康、账号数据、RSS 的唯一入口(docs/design/site-center.md §4);
|
// 获取 → 站点:站点、凭证、健康、账号数据、RSS 的唯一入口(docs/design/site-center.md §4);
|
||||||
// IA-2 起站点有类型(PT 站 / 公开 BT 站 / 自定义 RSS 源),列表可按类型筛选,?new=<类型> 直接打开添加。
|
// 按类型分组显示:PT 站 / 公开 BT 站(旧的自定义 RSS 源有的话单列);?new=<类型> 直接打开添加。
|
||||||
|
|
||||||
const VIEW_KEY = "umt.sites.view";
|
const VIEW_KEY = "umt.sites.view";
|
||||||
type View = "cards" | "list";
|
type View = "cards" | "list";
|
||||||
type Filter = "all" | "enabled" | "attention";
|
type Filter = "all" | "enabled" | "attention";
|
||||||
const KIND_OPTIONS = (Object.keys(SITE_KINDS) as SiteKind[])
|
/** 分组顺序;公开 BT 站的表只列它用得到的列 */
|
||||||
.filter((k) => SITE_KINDS[k].implemented)
|
const GROUPS: { kind: SiteKind; title: string; variant: "pt" | "public" }[] = [
|
||||||
.map((k) => ({ value: k, label: SITE_KINDS[k].label }));
|
{ 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 }[] = [
|
const FILTERS: { value: Filter; label: string }[] = [
|
||||||
{ value: "all", label: "全部" },
|
{ value: "all", label: "全部" },
|
||||||
@@ -66,7 +68,6 @@ export default function SitesPage() {
|
|||||||
const view: View = urlView === "list" || urlView === "cards" ? urlView : storedView;
|
const view: View = urlView === "list" || urlView === "cards" ? urlView : storedView;
|
||||||
const filter = (FILTERS.find((f) => f.value === params.get("filter"))?.value ?? "all") as Filter;
|
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 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) =>
|
const setParam = (k: string, v: string | null) =>
|
||||||
setParams(
|
setParams(
|
||||||
@@ -109,15 +110,12 @@ export default function SitesPage() {
|
|||||||
const [searchDefaults, setSearchDefaults] = useState(false);
|
const [searchDefaults, setSearchDefaults] = useState(false);
|
||||||
const attention = all.filter(needsAttention).length;
|
const attention = all.filter(needsAttention).length;
|
||||||
const shown = sortSites(
|
const shown = sortSites(
|
||||||
all.filter(
|
all.filter((s) => (filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true)),
|
||||||
(s) =>
|
|
||||||
(filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true) &&
|
|
||||||
(!kindFilter || s.kind === kindFilter),
|
|
||||||
),
|
|
||||||
sort,
|
sort,
|
||||||
);
|
);
|
||||||
|
const groups = GROUPS.map((g) => ({ ...g, sites: shown.filter((s) => s.kind === g.kind) })).filter(
|
||||||
const paged = useListPagination(shown, { resetKey: JSON.stringify({ filter, kindFilter, sort }) });
|
(g) => g.sites.length,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -153,14 +151,6 @@ export default function SitesPage() {
|
|||||||
label: f.value === "attention" && attention ? `${f.label} ${attention}` : f.label,
|
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
|
<FilterSelect
|
||||||
label="排序"
|
label="排序"
|
||||||
value={sort === "name" ? "" : sort}
|
value={sort === "name" ? "" : sort}
|
||||||
@@ -191,7 +181,7 @@ export default function SitesPage() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Globe}
|
icon={Globe}
|
||||||
title="还没有站点"
|
title="还没有站点"
|
||||||
description="添加 PT 站、公开 BT 站或 RSS 源后,这里显示账号数据、健康和 RSS 状态"
|
description="添加 PT 站或公开 BT 站后,这里显示账号数据、健康和资源更新"
|
||||||
action={
|
action={
|
||||||
<Button size="sm" onClick={() => setAdding("any")}>
|
<Button size="sm" onClick={() => setAdding("any")}>
|
||||||
<Plus />
|
<Plus />
|
||||||
@@ -202,23 +192,31 @@ export default function SitesPage() {
|
|||||||
) : !shown.length ? (
|
) : !shown.length ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Globe}
|
icon={Globe}
|
||||||
title={kindFilter ? "没有这类站点" : filter === "attention" ? "没有需处理的站点" : "没有已启用的站点"}
|
title={filter === "attention" ? "没有需处理的站点" : "没有已启用的站点"}
|
||||||
description={kindFilter ? "清除类型筛选查看其余站点" : "切换到「全部」查看其余站点"}
|
description="切换到「全部」查看其余站点"
|
||||||
/>
|
/>
|
||||||
) : view === "list" ? (
|
|
||||||
<SiteTable sites={shown} checks={checks} onDialog={setDialog} />
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="space-y-6">
|
||||||
{paged.items?.map((s) => (
|
{groups.map((g) => (
|
||||||
<SiteCard key={s.id} site={s} checks={checks} onDialog={setDialog} />
|
<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>
|
</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}
|
{adding ? <SiteAddDialog initialKind={adding === "any" ? null : adding} onClose={() => setAdding(null)} /> : null}
|
||||||
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} />
|
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} />
|
||||||
<SearchDefaultDialog open={searchDefaults} onOpenChange={setSearchDefaults} sites={all} />
|
<SearchDefaultDialog open={searchDefaults} onOpenChange={setSearchDefaults} sites={all} />
|
||||||
|
|||||||
@@ -358,6 +358,8 @@ export const AgentRunListQuery = z.object({
|
|||||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
status: AgentRunStatus.optional(),
|
status: AgentRunStatus.optional(),
|
||||||
trigger: AgentTrigger.optional(),
|
trigger: AgentTrigger.optional(),
|
||||||
|
/** auto = 定时巡检 / 事件触发的会话(及其子会话);conversation = 用户发起的会话(及其子会话) */
|
||||||
|
group: z.enum(["conversation", "auto"]).optional(),
|
||||||
/** 只列某个会话直接派生的子会话 */
|
/** 只列某个会话直接派生的子会话 */
|
||||||
parentRunId: Id.optional(),
|
parentRunId: Id.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const SITE_KINDS: Record<SiteKind, SiteKindSpec> = {
|
|||||||
},
|
},
|
||||||
public_bt: {
|
public_bt: {
|
||||||
label: "公开 BT 站",
|
label: "公开 BT 站",
|
||||||
description: "不用登录的公开站。内置 Nyaa、蜜柑、AniBT、AnimeGarden、萌番组,其他站填网址和 RSS 地址",
|
description: "不用登录的公开站:Nyaa、蜜柑、AniBT、AnimeGarden、萌番组",
|
||||||
capabilities: ["search", "list", "rss", "detail"],
|
capabilities: ["search", "list", "rss", "detail"],
|
||||||
implemented: true,
|
implemented: true,
|
||||||
fromDefinition: false,
|
fromDefinition: false,
|
||||||
|
|||||||
Reference in new issue
Block a user