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
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user