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

+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}
/>
);
}