feat(web): site center page, add-site wizard, credentials, health and user-data chart (FE-2)

- 资源 → 站点:卡片 / 列表切换(URL ?view= + localStorage 记住),筛选全部 / 已启用 / 需处理,
  按分享率 / 上传 / 上次登录排序;卡片显示账号状态、凭证类型、上次登录、上传 / 下载 / 分享率、
  等级 / VIP、做种、健康检测结果与时间、快照、限流
- 操作:检测健康、刷新登录(wait:true 同步结果)、更新凭证、编辑(限流 / 快照 / 代理)、启停、
  删除(确认框列出影响)
- 添加站点向导:按适配器分组选 23 个定义(未实测标注)→ 名称 / 域名覆盖 / 凭证三选一
  (馒头默认 API Key)→ 保存并测试(刷新登录 + 健康检测);验证码 / 二次验证提示改用 Cookie
  并链接待处理
- 站点详情:概览(用户数据磁贴 + 30 天上传 / 下载与分享率折线,recharts,无彩)/ 资源 /
  快照与 Feed / 账号(凭证管理、Passkey、登录历史)
- 设置"站点与账号"改为"站点默认"(限流、快照周期、UA、代理、做种页数)
- 侧栏改名"站点";smoke 补站点路由;依赖新增 recharts

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:08:29 +08:00
1 parent 81bf010e3e
commit f8362a3f4c
21 files changed
+3083 -1126

No files matched your search

+117 -3
View File
@@ -1,12 +1,29 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { cursorPage, Id, Site, SiteAccount, type SiteCreate, SiteDefinitionView, SiteSnapshot } from "@umt/contracts";
import {
AuditEvent,
cursorPage,
Id,
Site,
SiteAccount,
type SiteCreate,
type SiteCredentialsBody,
SiteCredentialsResult,
SiteDefinitionView,
SiteHealthResult,
SiteListItem,
SiteRefreshLoginAccepted,
SiteSnapshot,
type SiteUpdate,
SiteUserDataPoint,
} from "@umt/contracts";
import { z } from "zod";
import { TTL_SECOND } from "~/lib/api.ts";
import { apiMutate, apiParsed } from "./parse.ts";
// 站点、站点账号、站点快照(W2-B:/sites、/site-accounts、/sites/{id}/snapshot(s))
// 站点中心(W9-A):include=accounts、凭证 PUT、健康检测、刷新登录、用户数据、删除
const SitePage = cursorPage(Site);
const SitePage = cursorPage(SiteListItem);
const AccountPage = cursorPage(SiteAccount);
const SnapshotPage = cursorPage(SiteSnapshot);
@@ -17,12 +34,17 @@ export const siteKeys = {
accounts: (siteId?: string) => ["sites", "accounts", siteId ?? "all"] as const,
snapshots: (siteId: string) => ["sites", "snapshots", siteId] as const,
definitions: () => ["sites", "definitions"] as const,
userData: (siteId: string, accountId?: string, days = 30) =>
["sites", "user-data", siteId, accountId ?? "first", days] as const,
loginHistory: (accountId: string) => ["sites", "login-history", accountId] as const,
};
/** 站点列表(附账号与整体账号状态,站点页一次请求) */
export function useSites() {
return useQuery({
queryKey: siteKeys.list(),
queryFn: ({ signal }) => apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200 }, signal }),
queryFn: ({ signal }) =>
apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, include: "accounts" }, signal }),
});
}
@@ -108,3 +130,95 @@ export function useCreateSite() {
onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}
/** 修改站点(名称、域名、限流、快照、代理) */
export function useUpdateSite() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: SiteUpdate }) =>
apiMutate("/api/v1/sites/:id", { method: "PATCH", pathParams: { id }, body }).then((d) => Site.parse(d)),
onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}
/** 删除站点:资源标 site_disabled 保留,账号与凭证删除 */
export function useDeleteSite() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiMutate("/api/v1/sites/:id", { method: "DELETE", pathParams: { id } }),
onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}
/** 写凭证(按 kind 判别);只写不回显,账号回到未验证 */
export function usePutCredentials() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ siteId, body }: { siteId: string; body: SiteCredentialsBody }) =>
apiMutate("/api/v1/sites/:id/credentials", { method: "PUT", pathParams: { id: siteId }, body }).then((d) =>
SiteCredentialsResult.parse(d),
),
onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}
/** 删除站点账号及其凭证 */
export function useDeleteSiteAccount() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiMutate("/api/v1/site-accounts/:id", { method: "DELETE", pathParams: { id } }),
onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}
/** 检测健康(同步,≤20 s) */
export function useHealthCheck() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiMutate("/api/v1/sites/:id/health-check", { pathParams: { id }, body: {} }).then((d) =>
SiteHealthResult.parse(d),
),
onSettled: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}
/** 刷新登录;界面上用 wait=true 同步拿结果(≤30 s) */
export function useRefreshLogin() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, accountId }: { id: string; accountId?: string }) =>
apiMutate("/api/v1/sites/:id/refresh-login", {
pathParams: { id },
body: { wait: true, ...(accountId ? { accountId } : {}) },
}).then((d) => SiteRefreshLoginAccepted.parse(d)),
onSettled: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
});
}
/** 用户数据按天历史 */
export function useSiteUserData(siteId: string | undefined, accountId?: string, days = 30) {
return useQuery({
queryKey: siteKeys.userData(siteId ?? "", accountId, days),
enabled: !!siteId,
queryFn: ({ signal }) =>
apiParsed(z.object({ items: z.array(SiteUserDataPoint) }), "/api/v1/sites/:id/user-data", {
pathParams: { id: siteId ?? "" },
params: { days, accountId },
signal,
}),
});
}
/** 登录历史:审计里该账号的凭证与登录事件 */
export function useLoginHistory(accountId: string | undefined) {
return useQuery({
queryKey: siteKeys.loginHistory(accountId ?? ""),
enabled: !!accountId,
queryFn: ({ signal }) =>
apiParsed(cursorPage(AuditEvent), "/api/v1/audit-events", {
params: { targetType: "site_account", targetId: accountId, limit: 20 },
signal,
}),
});
}
+1 -1
View File
@@ -38,7 +38,7 @@ describe("控制台导航", () => {
expect(findNavItem("/console/downloads/plans")?.label).toBe("下载计划");
expect(findNavItem("/console/downloads/abc")?.label).toBe("下载队列");
expect(findNavItem("/console/tasks/abc")?.label).toBe("业务任务");
expect(findNavItem("/console/sites/abc")?.label).toBe("站点工作台");
expect(findNavItem("/console/sites/abc")?.label).toBe("站点");
expect(findNavItem("/nope")).toBeUndefined();
});
+2 -2
View File
@@ -68,9 +68,9 @@ export const NAV_GROUPS: NavGroup[] = [
items: [
{
to: "/console/sites",
label: "站点工作台",
label: "站点",
icon: Globe,
description: "站点、站点账号、快照与 Feed",
description: "站点、凭证、账号数据与健康;资源、快照与 Feed",
},
{
to: "/console/sources",
+84 -613
View File
@@ -1,625 +1,96 @@
import { type Site, type SiteAccount, SiteCredentialsPut, SiteUpdate } from "@umt/contracts";
import { KeyRound, LoaderCircle, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { ArrowRight } from "lucide-react";
import { Link } from "react-router";
import { toast } from "sonner";
import {
useCreateSite,
usePutSiteCredentials,
useRemoveSiteAccount,
useSiteAccounts,
useSiteOptions,
useUpdateSite,
useUpdateSiteAccount,
} from "~/api/instances.ts";
import { snapshotStale, useSiteDefinitions, useToggleSite } from "~/api/sites.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DataTable } from "~/components/console/data-table.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { zodFieldErrors } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatRelative, healthLabel } from "~/lib/labels.ts";
import { FieldControl, type FormValues, num, str } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
import { num, str } from "./resource-section.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 站点与账号:站点登记 / 启停 / 快照周期 / 限流(抽屉),站点账号凭证录入与启停。
// 站点浏览、快照记录在站点工作台。
// 站点默认(site-center §8):站点没单独设置时用的限流、快照周期、UA、代理。
// 站点、凭证、账号在「站点」页管理;站点级覆盖在站点的"编辑"里。
const AUTH_KINDS = [
{ value: "cookie", label: "Cookie" },
{ value: "passkey", label: "Passkey" },
{ value: "api_key", label: "API Key" },
{ value: "password", label: "账号密码" },
];
const authLabel = (k: string) => AUTH_KINDS.find((a) => a.value === k)?.label ?? k;
const ACCOUNT_STATUS: Record<string, { label: string; tone: "success" | "warning" | "destructive" | "neutral" }> = {
active: { label: "有效", tone: "success" },
invalid: { label: "凭证无效", tone: "destructive" },
expired: { label: "已过期", tone: "warning" },
unknown: { label: "未验证", tone: "neutral" },
};
const ADAPTER: Record<string, string> = {
nexusphp: "NexusPHP",
mteam_api: "M-Team API",
api: "站点 API",
generic_rss: "通用 RSS",
};
// ─── 站点列表 ───
function SitesBlock() {
const sites = useSiteOptions();
const accounts = useSiteAccounts();
const toggle = useToggleSite();
const [open, setOpen] = useState<Site | null>(null);
const [registering, setRegistering] = useState(false);
const accountCount = (id: string) => accounts.data?.items.filter((a) => a.siteId === id).length ?? 0;
return (
<SettingsBlock
section="sites"
anchor="sites"
title="站点"
description="停用后不再访问该站点,也不产生新候选;已抓到的资源仍可查询。点行改快照周期和限流。"
plain
actions={
<Button size="sm" onClick={() => setRegistering(true)}>
<Plus />
登记站点
</Button>
}
>
<DataTable
rows={sites.data?.items}
rowKey={(r) => r.id}
loading={sites.isPending}
error={sites.error}
onRetry={() => sites.refetch()}
onRowClick={(r) => setOpen(r)}
empty={{ title: "还没有登记站点", description: "从内置站点定义里登记。" }}
columns={[
{
key: "name",
header: "站点",
cell: (r) => (
<div className="min-w-0">
<p className="font-medium">{r.name}</p>
<p className="truncate text-meta text-muted-foreground">{ADAPTER[r.adapter] ?? r.adapter}</p>
</div>
),
},
{
key: "snapshot",
header: "快照",
hideBelow: "md",
cell: (r) =>
!r.snapshotEnabled ? (
<span className="text-caption text-muted-foreground">关闭</span>
) : (
<div className="text-caption">
<p>每 {Math.round((r.snapshotIntervalSec ?? 3600) / 60)} 分钟</p>
<p className={snapshotStale(r) ? "text-warning" : "text-muted-foreground"}>
{r.lastSnapshotAt ? formatRelative(r.lastSnapshotAt) : "从未快照"}
</p>
</div>
),
},
{ key: "accounts", header: "账号", hideBelow: "sm", cell: (r) => accountCount(r.id) },
{
key: "health",
header: "健康",
hideBelow: "sm",
cell: (r) => {
const h = healthLabel(r.circuitState === "open" ? "circuit_open" : r.healthStatus);
return <StatusBadge tone={h.tone}>{h.label}</StatusBadge>;
},
},
{
key: "enabled",
header: "启用",
align: "right",
cell: (r) => (
// biome-ignore lint/a11y/noStaticElementInteractions: 只拦截冒泡,开关不触发整行打开抽屉
<span onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
<Switch
checked={r.enabled}
aria-label={`${r.enabled ? "停用" : "启用"}${r.name}`}
disabled={toggle.isPending}
onCheckedChange={async (v) => {
try {
await toggle.mutateAsync({ id: r.id, enabled: v });
toast.success(v ? `${r.name}已启用` : `${r.name}已停用`);
} catch (err) {
toast.error(errorMessage(err, "切换失败"));
}
}}
/>
</span>
),
},
]}
/>
<SiteDrawer key={open?.id ?? "none"} site={open} onClose={() => setOpen(null)} />
<RegisterSiteDrawer open={registering} onClose={() => setRegistering(false)} />
</SettingsBlock>
);
}
function SiteDrawer({ site, onClose }: { site: Site | null; onClose: () => void }) {
const update = useUpdateSite();
const [v, setV] = useState<FormValues>(() => ({
baseUrl: site?.baseUrl ?? "",
snapshotEnabled: site?.snapshotEnabled ?? false,
snapshotMinutes: site?.snapshotIntervalSec ? String(Math.round(site.snapshotIntervalSec / 60)) : "",
requestsPerMinute: site?.rateLimit.requestsPerMinute ? String(site.rateLimit.requestsPerMinute) : "",
minIntervalMs: site?.rateLimit.minIntervalMs !== undefined ? String(site.rateLimit.minIntervalMs) : "",
concurrency: site?.rateLimit.concurrency ? String(site.rateLimit.concurrency) : "",
}));
const [errors, setErrors] = useState<Record<string, string>>({});
const set = (k: string) => (x: string | boolean) => setV((s) => ({ ...s, [k]: x }));
const save = async (e: React.FormEvent) => {
e.preventDefault();
if (!site) return;
const minutes = num(v.snapshotMinutes);
const rateLimit = {
...(num(v.requestsPerMinute) !== undefined ? { requestsPerMinute: num(v.requestsPerMinute) } : {}),
...(num(v.minIntervalMs) !== undefined ? { minIntervalMs: num(v.minIntervalMs) } : {}),
...(num(v.concurrency) !== undefined ? { concurrency: num(v.concurrency) } : {}),
const DEFAULTS_CARD: CardSpec = {
anchor: "defaults",
title: "默认值",
description: "站点没有单独设置时使用。限流不会比站点定义自带的更快。",
fields: [
{
name: "sites.defaultRateLimit.requestsPerMinute",
label: "每分钟请求数",
kind: "number",
hint: "每个站点各自计数",
},
{ name: "sites.defaultRateLimit.concurrency", label: "并发", kind: "number", hint: "同一站点同时进行的请求" },
{
name: "sites.defaultSnapshotIntervalSec",
label: "快照周期(分钟)",
kind: "number",
hint: "站点开了定时快照但没设周期时使用;5–10080",
},
{
name: "sites.userDataMaxSeedingPages",
label: "做种列表页数上限",
kind: "number",
hint: "拉取用户数据时最多翻几页做种列表;1–50",
},
{
name: "sites.userAgent",
label: "User-Agent",
kind: "text",
hint: "账号没填 UA 时,登录和拉取用户数据用这个",
wide: true,
},
{
name: "sites.proxyUrl",
label: "代理地址",
kind: "url",
placeholder: "http://127.0.0.1:7890",
hint: "只对开了“使用代理”的站点生效;留空不用代理",
wide: true,
},
],
fromSettings: (s) => ({
"sites.defaultRateLimit.requestsPerMinute": n(s.sites.defaultRateLimit.requestsPerMinute),
"sites.defaultRateLimit.concurrency": n(s.sites.defaultRateLimit.concurrency),
"sites.defaultSnapshotIntervalSec": n(Math.round(s.sites.defaultSnapshotIntervalSec / 60)),
"sites.userDataMaxSeedingPages": n(s.sites.userDataMaxSeedingPages),
"sites.userAgent": s.sites.userAgent,
"sites.proxyUrl": s.sites.proxyUrl ?? "",
}),
toPatch: (v, s) => {
const min = num(v["sites.defaultSnapshotIntervalSec"]);
const rpm = num(v["sites.defaultRateLimit.requestsPerMinute"]);
const concurrency = num(v["sites.defaultRateLimit.concurrency"]);
return {
sites: {
defaultRateLimit: {
...s.sites.defaultRateLimit,
requestsPerMinute: rpm,
concurrency,
},
defaultSnapshotIntervalSec: min === undefined ? undefined : Math.round(min * 60),
userDataMaxSeedingPages: num(v["sites.userDataMaxSeedingPages"]),
userAgent: str(v["sites.userAgent"]) || undefined,
proxyUrl: str(v["sites.proxyUrl"]) || null,
},
};
const parsed = SiteUpdate.safeParse({
baseUrl: str(v.baseUrl),
snapshotEnabled: v.snapshotEnabled === true,
snapshotIntervalSec: minutes === undefined ? null : Math.round(minutes * 60),
rateLimit,
});
if (!parsed.success) {
const fe = zodFieldErrors(parsed.error.issues);
const map: Record<string, string> = {
snapshotIntervalSec: "snapshotMinutes",
"rateLimit.requestsPerMinute": "requestsPerMinute",
"rateLimit.minIntervalMs": "minIntervalMs",
"rateLimit.concurrency": "concurrency",
};
for (const [k, to] of Object.entries(map)) if (fe[k]) fe[to] = "填正整数";
setErrors(fe);
return;
}
setErrors({});
try {
await update.mutateAsync({ id: site.id, body: parsed.data });
toast.success(`${site.name}已保存`);
onClose();
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
return (
<DetailSheet
open={!!site}
onOpenChange={(o) => !o && !update.isPending && onClose()}
title={site?.name ?? ""}
description={
site ? (
<Link to={consolePath("sites", site.id)} className="underline">
在站点工作台打开
</Link>
) : null
}
footer={
<>
<Button variant="outline" onClick={onClose} disabled={update.isPending}>
取消
</Button>
<Button type="submit" form="site-form" disabled={update.isPending}>
{update.isPending ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
</>
}
>
<form id="site-form" onSubmit={save} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
<FieldControl
spec={{ name: "baseUrl", label: "地址", kind: "url", wide: true }}
value={v.baseUrl}
error={errors.baseUrl}
onChange={set("baseUrl")}
disabled={update.isPending}
/>
<FieldControl
spec={{ name: "snapshotEnabled", label: "定时快照", kind: "switch" }}
value={v.snapshotEnabled}
onChange={set("snapshotEnabled")}
disabled={update.isPending}
/>
<FieldControl
spec={{ name: "snapshotMinutes", label: "快照周期(分钟)", kind: "number", placeholder: "60" }}
value={v.snapshotMinutes}
error={errors.snapshotMinutes}
onChange={set("snapshotMinutes")}
disabled={update.isPending || v.snapshotEnabled !== true}
/>
<FieldControl
spec={{ name: "requestsPerMinute", label: "每分钟请求数", kind: "number", hint: "留空用站点定义的默认值" }}
value={v.requestsPerMinute}
error={errors.requestsPerMinute}
onChange={set("requestsPerMinute")}
disabled={update.isPending}
/>
<FieldControl
spec={{ name: "minIntervalMs", label: "最小请求间隔(毫秒)", kind: "number" }}
value={v.minIntervalMs}
error={errors.minIntervalMs}
onChange={set("minIntervalMs")}
disabled={update.isPending}
/>
<FieldControl
spec={{ name: "concurrency", label: "并发", kind: "number", hint: "快照、搜索、浏览器共用" }}
value={v.concurrency}
error={errors.concurrency}
onChange={set("concurrency")}
disabled={update.isPending}
/>
</form>
{site ? <SiteAccountsInline siteId={site.id} /> : null}
</DetailSheet>
);
}
function RegisterSiteDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
const defs = useSiteDefinitions();
const create = useCreateSite();
const [key, setKey] = useState("");
const [error, setError] = useState<string | undefined>();
const available = (defs.data?.items ?? []).filter((d) => !d.siteId);
const def = available.find((d) => d.key === key);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!def) {
setError("选择站点");
return;
}
setError(undefined);
try {
await create.mutateAsync({ key: def.key, name: def.name, adapter: def.adapter, baseUrl: def.baseUrl });
toast.success(`${def.name}已登记`);
setKey("");
onClose();
} catch (err) {
toast.error(errorMessage(err, "登记失败"));
}
};
return (
<DetailSheet
open={open}
onOpenChange={(o) => !o && !create.isPending && onClose()}
title="登记站点"
description="登记后再录入账号凭证。"
footer={
<>
<Button variant="outline" onClick={onClose} disabled={create.isPending}>
取消
</Button>
<Button type="submit" form="register-site-form" disabled={create.isPending || !def}>
{create.isPending ? <LoaderCircle className="animate-spin" /> : null}
登记
</Button>
</>
}
>
<form id="register-site-form" onSubmit={submit} noValidate>
<FieldControl
spec={{
name: "definition",
label: "站点",
kind: "select",
required: true,
placeholder: defs.isPending ? "加载中…" : available.length ? "选择" : "内置站点都已登记",
options: available.map((d) => ({
value: d.key,
label: `${d.name}${d.shippedEnabled ? "" : "(未实测)"}`,
})),
}}
value={key}
error={error}
onChange={(x) => setKey(String(x))}
disabled={create.isPending}
/>
</form>
{def ? (
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-caption">
<dt className="text-muted-foreground">地址</dt>
<dd className="break-all">{def.baseUrl}</dd>
<dt className="text-muted-foreground">适配器</dt>
<dd>{ADAPTER[def.adapter] ?? def.adapter}</dd>
<dt className="text-muted-foreground">凭证类型</dt>
<dd>{def.authSupported.map(authLabel).join("、") || "—"}</dd>
<dt className="text-muted-foreground">默认限流</dt>
<dd>
间隔 {def.defaultRateLimit.minIntervalMs} 毫秒,突发 {def.defaultRateLimit.burst}
</dd>
</dl>
) : null}
</DetailSheet>
);
}
// ─── 站点账号 ───
function CredentialForm({ siteId, onDone }: { siteId?: string; onDone?: () => void }) {
const sites = useSiteOptions();
const put = usePutSiteCredentials();
const [v, setV] = useState<FormValues>({
siteId: siteId ?? "",
label: "default",
authKind: "cookie",
username: "",
credential: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const siteOpts = (sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }));
const set = (k: string) => (x: string | boolean) => setV((s) => ({ ...s, [k]: x }));
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const fe: Record<string, string> = {};
if (!str(v.siteId)) fe.siteId = "选择站点";
const parsed = SiteCredentialsPut.safeParse({
label: str(v.label) || "default",
authKind: str(v.authKind),
credential: str(v.credential),
username: v.authKind === "password" ? str(v.username) || undefined : undefined,
});
if (!parsed.success) Object.assign(fe, zodFieldErrors(parsed.error.issues));
if (fe.credential) fe.credential = "填写凭证";
if (Object.keys(fe).length || !parsed.success) {
setErrors(fe);
return;
}
setErrors({});
try {
const r = await put.mutateAsync({ siteId: str(v.siteId), body: parsed.data });
toast.success(r.created ? "站点账号已添加" : "凭证已替换");
setV((s) => ({ ...s, credential: "" }));
onDone?.();
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
return (
<form onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
{siteId ? null : (
<FieldControl
spec={{
name: "siteId",
label: "站点",
kind: "select",
options: siteOpts,
placeholder: sites.isPending ? "加载中…" : siteOpts.length ? "选择" : "还没有登记站点",
required: true,
}}
value={v.siteId}
error={errors.siteId}
onChange={set("siteId")}
disabled={put.isPending}
/>
)}
<FieldControl
spec={{ name: "label", label: "标签", kind: "text", hint: "同一站点有多个账号时用来区分;同标签会替换凭证" }}
value={v.label}
error={errors.label}
onChange={set("label")}
disabled={put.isPending}
/>
<FieldControl
spec={{ name: "authKind", label: "凭证类型", kind: "select", options: AUTH_KINDS }}
value={v.authKind}
error={errors.authKind}
onChange={set("authKind")}
disabled={put.isPending}
/>
{v.authKind === "password" ? (
<FieldControl
spec={{ name: "username", label: "登录名", kind: "text" }}
value={v.username}
error={errors.username}
onChange={set("username")}
disabled={put.isPending}
/>
) : null}
<FieldControl
spec={{
name: "credential",
label: v.authKind === "password" ? "密码" : "凭证",
kind: "password",
required: true,
wide: true,
placeholder: v.authKind === "cookie" ? "整段 Cookie" : undefined,
hint: "只写入,保存后不回显",
}}
value={v.credential}
error={errors.credential}
onChange={set("credential")}
disabled={put.isPending}
/>
<div className="sm:col-span-2">
<Button type="submit" size="sm" disabled={put.isPending}>
{put.isPending ? <LoaderCircle className="animate-spin" /> : <KeyRound />}
保存凭证
</Button>
</div>
</form>
);
}
function AccountStatus({ a }: { a: SiteAccount }) {
if (!a.enabled) return <StatusBadge tone="neutral">停用</StatusBadge>;
if (!a.secretRef) return <StatusBadge tone="warning">缺凭证</StatusBadge>;
const s = ACCOUNT_STATUS[a.status] ?? ACCOUNT_STATUS.unknown;
return <StatusBadge tone={s?.tone ?? "neutral"}>{s?.label ?? a.status}</StatusBadge>;
}
/** 站点抽屉里:本站账号 + 录入凭证 */
function SiteAccountsInline({ siteId }: { siteId: string }) {
const accounts = useSiteAccounts();
const rows = (accounts.data?.items ?? []).filter((a) => a.siteId === siteId);
const [adding, setAdding] = useState(rows.length === 0);
return (
<DetailSection
title="站点账号"
actions={
adding ? null : (
<Button variant="outline" size="xs" onClick={() => setAdding(true)}>
<Plus />
录入凭证
</Button>
)
}
>
{rows.length ? (
<ul className="mb-3 divide-y divide-border/60 rounded-lg border border-border/60">
{rows.map((a) => (
<li key={a.id} className="flex items-center justify-between gap-2 px-3 py-2 text-caption">
<span className="min-w-0 truncate">
{a.label} · {authLabel(a.authKind)}
</span>
<AccountStatus a={a} />
</li>
))}
</ul>
) : null}
{adding ? <CredentialForm siteId={siteId} onDone={() => setAdding(false)} /> : null}
</DetailSection>
);
}
function AccountsBlock() {
const sites = useSiteOptions();
const accounts = useSiteAccounts();
const update = useUpdateSiteAccount();
const remove = useRemoveSiteAccount();
const [adding, setAdding] = useState(false);
const [deleting, setDeleting] = useState<SiteAccount | null>(null);
const siteName = (id: string) => sites.data?.items.find((s) => s.id === id)?.name ?? id.slice(0, 8);
return (
<SettingsBlock
section="sites"
anchor="accounts"
title="站点账号"
description="按「站点 + 标签」新建或替换凭证。凭证只写入,保存后不回显。"
plain
actions={
<Button size="sm" onClick={() => setAdding(true)}>
<KeyRound />
录入凭证
</Button>
}
>
<DataTable
rows={accounts.data?.items}
rowKey={(r) => r.id}
loading={accounts.isPending}
error={accounts.error}
onRetry={() => accounts.refetch()}
empty={{ title: "还没有站点账号" }}
columns={[
{
key: "site",
header: "站点 / 标签",
cell: (r) => (
<div className="min-w-0">
<p className="truncate font-medium">{siteName(r.siteId)}</p>
<p className="text-meta text-muted-foreground">{r.label}</p>
</div>
),
},
{ key: "kind", header: "类型", hideBelow: "sm", cell: (r) => authLabel(r.authKind) },
{
key: "verified",
header: "最近验证",
hideBelow: "md",
cell: (r) => (r.lastVerifiedAt ? formatRelative(r.lastVerifiedAt) : "—"),
},
{ key: "status", header: "状态", cell: (r) => <AccountStatus a={r} /> },
{
key: "act",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (r) => (
<div className="flex items-center justify-end gap-1">
<Switch
checked={r.enabled}
aria-label={`${r.enabled ? "停用" : "启用"}账号 ${r.label}`}
disabled={update.isPending}
onCheckedChange={async (v) => {
try {
await update.mutateAsync({ id: r.id, body: { enabled: v } });
toast.success(v ? "账号已启用" : "账号已停用");
} catch (err) {
toast.error(errorMessage(err, "切换失败"));
}
}}
/>
<Button
variant="ghost"
size="icon-sm"
aria-label={`删除账号 ${r.label}`}
className="hover:text-destructive"
onClick={() => setDeleting(r)}
>
<Trash2 />
</Button>
</div>
),
},
]}
/>
<DetailSheet
open={adding}
onOpenChange={(o) => !o && setAdding(false)}
title="录入凭证"
description="同一站点、同一标签再次保存会替换旧凭证。"
>
<CredentialForm onDone={() => setAdding(false)} />
</DetailSheet>
<ConfirmDialog
open={!!deleting}
onOpenChange={(o) => !o && setDeleting(null)}
title={`删除账号「${deleting?.label ?? ""}」?`}
description="账号和凭证一并删除,无法恢复。"
confirmLabel="删除账号"
destructive
onConfirm={async () => {
if (!deleting) return;
try {
await remove.mutateAsync(deleting.id);
toast.success("站点账号已删除");
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
</SettingsBlock>
);
}
},
};
export function SitesSection() {
return (
<div className="grid gap-8">
<SitesBlock />
<AccountsBlock />
<div className="space-y-6">
<div className="pg-slab flex flex-wrap items-center justify-between gap-3 rounded-xl px-4 py-3">
<p className="text-caption text-muted-foreground">
站点和凭证在「站点」页管理;单个站点的限流、周期、代理在站点的"编辑"里改。
</p>
<Button asChild size="sm" variant="outline">
<Link to={consolePath("sites")}>
打开站点页
<ArrowRight />
</Link>
</Button>
</div>
<SettingsGate>{(s) => <SettingsCard section="sites" spec={DEFAULTS_CARD} settings={s} />}</SettingsGate>
</div>
);
}
@@ -0,0 +1,213 @@
import type { SiteCredentialsBody } from "@umt/contracts";
import { FormField } from "~/components/console/form-field.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { cn } from "~/lib/utils.ts";
import { AUTH_KIND_LABEL, type LoginKind } from "./site-fields.ts";
// 凭证三选一(账号密码 / Cookie / API Key)。只写不回显;添加站点向导与"更新凭证"对话框共用。
export type CredentialDraft = {
kind: LoginKind;
username: string;
password: string;
totpSecret: string;
saveCookieOnly: boolean;
cookie: string;
apiKey: string;
userAgent: string;
};
export const emptyCredential = (kind: LoginKind): CredentialDraft => ({
kind,
username: "",
password: "",
totpSecret: "",
saveCookieOnly: false,
cookie: "",
apiKey: "",
userAgent: "",
});
/** 草稿 → 请求体;缺必填返回字段错误 */
export function credentialBody(
d: CredentialDraft,
label: string,
): { body: SiteCredentialsBody; errors: null } | { body: null; errors: Record<string, string> } {
const ua = d.userAgent.trim() || undefined;
if (d.kind === "password") {
const errors: Record<string, string> = {};
if (!d.username.trim()) errors.username = "填写登录名";
if (!d.password) errors.password = "填写密码";
if (Object.keys(errors).length) return { body: null, errors };
return {
body: {
kind: "password",
label,
username: d.username.trim(),
password: d.password,
totpSecret: d.totpSecret.trim() || undefined,
saveCookieOnly: d.saveCookieOnly || undefined,
userAgent: ua,
},
errors: null,
};
}
if (d.kind === "cookie") {
if (!d.cookie.trim()) return { body: null, errors: { cookie: "粘贴 Cookie" } };
return { body: { kind: "cookie", label, cookie: d.cookie.trim(), userAgent: ua }, errors: null };
}
if (!d.apiKey.trim()) return { body: null, errors: { apiKey: "填写 API Key" } };
return { body: { kind: "api_key", label, apiKey: d.apiKey.trim() }, errors: null };
}
export function CredentialFields({
value,
onChange,
kinds,
errors,
disabled,
idPrefix = "cred",
}: {
value: CredentialDraft;
onChange: (v: CredentialDraft) => void;
kinds: LoginKind[];
errors: Record<string, string>;
disabled?: boolean;
idPrefix?: string;
}) {
const set = <K extends keyof CredentialDraft>(k: K, v: CredentialDraft[K]) => onChange({ ...value, [k]: v });
const id = (s: string) => `${idPrefix}-${s}`;
return (
<div className="grid gap-x-4 gap-y-1">
{kinds.length > 1 ? (
<div className="mb-4" role="radiogroup" aria-label="凭证类型">
<p className="mb-1.5 text-caption font-medium text-foreground">凭证类型</p>
<div className="inline-flex flex-wrap gap-1 rounded-xl border border-border p-1">
{kinds.map((k) => (
// biome-ignore lint/a11y/useSemanticElements: 分段按钮,radio 语义由 role 表达
<button
key={k}
type="button"
role="radio"
aria-checked={value.kind === k}
disabled={disabled}
onClick={() => set("kind", k)}
className={cn(
"rounded-lg px-3 py-1.5 text-caption transition-colors",
value.kind === k
? "bg-foreground text-background"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{AUTH_KIND_LABEL[k]}
</button>
))}
</div>
</div>
) : null}
{value.kind === "password" ? (
<>
<div className="grid gap-x-4 sm:grid-cols-2">
<FormField label="登录名" htmlFor={id("user")} required error={errors.username}>
<Input
id={id("user")}
autoComplete="off"
value={value.username}
aria-invalid={!!errors.username}
onChange={(e) => set("username", e.target.value)}
disabled={disabled}
/>
</FormField>
<FormField label="密码" htmlFor={id("pass")} required error={errors.password}>
<Input
id={id("pass")}
type="password"
autoComplete="new-password"
value={value.password}
aria-invalid={!!errors.password}
onChange={(e) => set("password", e.target.value)}
disabled={disabled}
/>
</FormField>
</div>
<FormField
label="TOTP 密钥"
htmlFor={id("totp")}
hint="站点开了二次验证时填 base32 密钥,登录时自动生成 6 位码"
>
<Input
id={id("totp")}
type="password"
autoComplete="off"
value={value.totpSecret}
onChange={(e) => set("totpSecret", e.target.value)}
disabled={disabled}
/>
</FormField>
<div className="mb-4 flex items-start gap-3">
<Switch
id={id("cookie-only")}
checked={value.saveCookieOnly}
onCheckedChange={(v) => set("saveCookieOnly", v)}
disabled={disabled}
/>
<label htmlFor={id("cookie-only")} className="text-caption">
<span className="block font-medium text-foreground">只换 Cookie</span>
<span className="block text-muted-foreground">
密码加密保存,用于 Cookie 失效后自动重新登录。打开后登录成功只留 Cookie,不保存密码
</span>
</label>
</div>
</>
) : value.kind === "cookie" ? (
<FormField
label="Cookie"
htmlFor={id("cookie")}
required
error={errors.cookie}
hint="浏览器登录站点后,从开发者工具复制请求头里的整段 Cookie"
>
<Textarea
id={id("cookie")}
rows={3}
className="font-mono text-caption"
value={value.cookie}
aria-invalid={!!errors.cookie}
onChange={(e) => set("cookie", e.target.value)}
disabled={disabled}
/>
</FormField>
) : (
<FormField label="API Key" htmlFor={id("apikey")} required error={errors.apiKey} hint="在站点控制台生成">
<Input
id={id("apikey")}
type="password"
autoComplete="off"
value={value.apiKey}
aria-invalid={!!errors.apiKey}
onChange={(e) => set("apiKey", e.target.value)}
disabled={disabled}
/>
</FormField>
)}
{value.kind !== "api_key" ? (
<FormField
label="User-Agent"
htmlFor={id("ua")}
hint={value.kind === "cookie" ? "Cookie 与浏览器 UA 绑定时填写;留空用站点默认" : "留空用站点默认"}
>
<Input
id={id("ua")}
value={value.userAgent}
onChange={(e) => set("userAgent", e.target.value)}
disabled={disabled}
/>
</FormField>
) : null}
</div>
);
}
@@ -0,0 +1,264 @@
import type { SiteAccount, SiteListItem } from "@umt/contracts";
import { KeyRound, LoaderCircle, RefreshCw, Trash2, UserRound } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { useDeleteSiteAccount, useLoginHistory, usePutCredentials } from "~/api/sites.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import type { SiteDialog, useSiteChecks } from "./site-dialogs.tsx";
import { ACCOUNT_OVERALL, AUTH_KIND_LABEL, loginFailureText, REASON_SHORT } from "./site-fields.ts";
// 站点详情"账号":凭证管理(更新 / 刷新登录 / 删除 / Passkey)+ 登录历史
type Checks = ReturnType<typeof useSiteChecks>;
export function SiteAccountsTab({
site,
checks,
onDialog,
}: {
site: SiteListItem;
checks: Checks;
onDialog: (d: SiteDialog) => void;
}) {
const accounts = site.accounts ?? [];
if (!accounts.length)
return (
<EmptyState
icon={UserRound}
title="还没有账号"
description="填写凭证后,这里显示登录状态和登录历史"
action={
<Button size="sm" onClick={() => onDialog({ kind: "credentials", site })}>
<KeyRound />
填写凭证
</Button>
}
/>
);
return (
<div className="space-y-6 pt-2">
{accounts.map((a) => (
<AccountBlock key={a.id} site={site} account={a} checks={checks} onDialog={onDialog} />
))}
<PasskeyForm site={site} label={accounts[0]?.label ?? site.key} has={accounts.some((a) => a.hasPasskey)} />
</div>
);
}
function AccountBlock({
site,
account: a,
checks,
onDialog,
}: {
site: SiteListItem;
account: SiteAccount;
checks: Checks;
onDialog: (d: SiteDialog) => void;
}) {
const del = useDeleteSiteAccount();
const st = ACCOUNT_OVERALL[a.enabled ? a.status : "unconfigured"];
const busy = checks.isBusy(site.id, "login");
const loginKind = a.authKind === "passkey" ? undefined : a.authKind;
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<section className="pg-slab min-w-0 rounded-xl p-4">
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-lead font-semibold text-foreground">账号 · {a.label}</h2>
<div className="mt-1 flex flex-wrap gap-1.5">
<StatusBadge tone={st.tone}>{a.enabled ? st.label : "已停用"}</StatusBadge>
<span className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">
{AUTH_KIND_LABEL[a.authKind]}
</span>
{a.hasPasskey && a.authKind !== "passkey" ? (
<span className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">
Passkey
</span>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
{a.authKind !== "passkey" ? (
<Button variant="outline" size="sm" disabled={busy} onClick={() => void checks.refreshLogin(site, a)}>
{busy ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
刷新登录
</Button>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => onDialog({ kind: "credentials", site, account: a, loginKind })}
>
<KeyRound />
更新凭证
</Button>
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm" className="hover:text-destructive">
<Trash2 />
删除账号
</Button>
}
title={`删除账号「${a.label}」?`}
description="账号、凭证和用户数据历史一起删除,不可恢复。站点和资源保留。"
confirmLabel="删除账号"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(a.id);
toast.success(`已删除账号 ${a.label}`);
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
</div>
</div>
<KvList
items={[
{ label: "登录名", value: a.loginName },
{ label: "用户名", value: a.userData?.username },
{
label: "上次登录",
value: a.lastLoginAt ? `${relTime(a.lastLoginAt)} · ${formatDateTime(a.lastLoginAt)}` : null,
},
{ label: "上次验证", value: relTime(a.lastVerifiedAt) },
{
label: "二次验证",
value: a.authKind === "password" ? (a.hasTotp ? "已填 TOTP 密钥" : "未填 TOTP 密钥") : null,
},
{
label: "会话 Cookie",
value: a.authKind === "password" ? (a.hasSession ? "已保存" : "还没有") : null,
},
{ label: "User-Agent", value: a.userAgent ?? "站点默认", wide: true, mono: !!a.userAgent },
...(a.lastError
? [
{
label: `最近错误 · ${relTime(a.lastError.at)}`,
value: (
<span className="text-warning">
{loginFailureText(a.lastError.reason, a.lastError.message, a.authKind).text}
</span>
),
wide: true,
},
]
: []),
]}
/>
</section>
<LoginHistory accountId={a.id} />
</div>
);
}
const ACTION_LABEL: Record<string, string> = {
"site_account.login_refreshed": "刷新登录",
"site_account.login_refresh_requested": "请求刷新登录",
"site_account.credentials_set": "更新凭证",
"site_account.created": "添加账号",
"site_account.updated": "修改账号",
};
function LoginHistory({ accountId }: { accountId: string }) {
const q = useLoginHistory(accountId);
const items = (q.data?.items ?? []).filter((e) => e.action in ACTION_LABEL);
return (
<DetailSection title="登录历史">
{q.isPending ? (
<LoadingState rows={3} />
) : q.error ? (
<ErrorState plain error={q.error} onRetry={() => void q.refetch()} />
) : !items.length ? (
<p className="text-caption text-muted-foreground">还没有记录。刷新登录或更新凭证后出现在这里</p>
) : (
<ol className="space-y-2">
{items.map((e) => {
const ok = e.payload.ok;
const reason = typeof e.payload.reason === "string" ? e.payload.reason : null;
return (
<li key={e.id} className="flex items-start justify-between gap-3 text-caption">
<span className="min-w-0">
<span className="text-foreground">{ACTION_LABEL[e.action]}</span>
{ok === true ? (
<StatusBadge tone="success" className="ml-2">
成功
</StatusBadge>
) : ok === false ? (
<StatusBadge tone="destructive" className="ml-2">
{(reason && REASON_SHORT[reason as keyof typeof REASON_SHORT]) || "失败"}
</StatusBadge>
) : null}
</span>
<time className="shrink-0 text-meta text-muted-foreground" dateTime={e.at} title={formatDateTime(e.at)}>
{relTime(e.at)}
</time>
</li>
);
})}
</ol>
)}
</DetailSection>
);
}
function PasskeyForm({ site, label, has }: { site: SiteListItem; label: string; has: boolean }) {
const put = usePutCredentials();
const [v, setV] = useState("");
const [err, setErr] = useState<string | null>(null);
return (
<form
className="pg-slab max-w-xl rounded-xl p-4"
noValidate
onSubmit={async (e) => {
e.preventDefault();
if (!v.trim()) {
setErr("填写 Passkey");
return;
}
setErr(null);
try {
await put.mutateAsync({ siteId: site.id, body: { kind: "passkey", label, passkey: v.trim() } });
setV("");
toast.success("Passkey 已保存");
} catch (error) {
toast.error(errorMessage(error, "保存失败"));
}
}}
>
<h2 className="text-lead font-semibold text-foreground">Passkey</h2>
<p className="mb-3 text-caption text-muted-foreground">
用于 RSS 和下载链接,不参与登录。{has ? "已保存;再次保存会替换。" : "还没有保存。"}
</p>
<div className="flex flex-wrap items-start gap-2">
<FormField label="Passkey" htmlFor="pk" error={err} className="min-w-0 flex-1">
<Input
id="pk"
type="password"
autoComplete="off"
value={v}
onChange={(e) => setV(e.target.value)}
disabled={put.isPending}
/>
</FormField>
<Button type="submit" size="sm" className="mt-6" disabled={put.isPending}>
{put.isPending ? <LoaderCircle className="animate-spin" /> : null}
保存 Passkey
</Button>
</div>
</form>
);
}
@@ -0,0 +1,395 @@
import type { SiteDefinitionView, SiteHealthResult, SiteListItem, SiteLoginResult } from "@umt/contracts";
import { ArrowLeft, Check, LoaderCircle, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import {
useCreateSite,
useHealthCheck,
usePutCredentials,
useRefreshLogin,
useSiteDefinitions,
useUpdateSite,
} from "~/api/sites.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { type CredentialDraft, CredentialFields, credentialBody, emptyCredential } from "./credential-fields.tsx";
import { LoginResultView } from "./site-dialogs.tsx";
import { ADAPTER_LABEL, hostOf, type LoginKind, supportedKinds } from "./site-fields.ts";
// 添加站点向导(site-center §4.3):① 选定义 → ② 名称 / 域名 / 凭证 → ③ 测试(保存站点与凭证,刷新登录 + 检测健康)
const CUSTOM = "__custom_rss";
type Step = 1 | 2 | 3;
const STEP_LABEL = ["选择站点", "填写凭证", "测试"] as const;
const GROUP_ORDER = ["nexusphp", "mteam_api", "api", "generic_rss"] as const;
/** 馒头默认 API Key,其余默认 Cookie(验证码站点账密大概率失败) */
function defaultKind(kinds: LoginKind[]): LoginKind {
if (kinds.includes("api_key") && !kinds.includes("cookie")) return "api_key";
return kinds.includes("cookie") ? "cookie" : (kinds[0] ?? "cookie");
}
export function SiteAddDialog({ onClose }: { onClose: () => void }) {
const defs = useSiteDefinitions();
const navigate = useNavigate();
const [step, setStep] = useState<Step>(1);
const [q, setQ] = useState("");
const [defKey, setDefKey] = useState<string | null>(null);
const [form, setForm] = useState({ name: "", baseUrl: "", key: "" });
const [cred, setCred] = useState<CredentialDraft>(emptyCredential("cookie"));
const [errors, setErrors] = useState<Record<string, string>>({});
const [created, setCreated] = useState<SiteListItem | null>(null);
const [login, setLogin] = useState<SiteLoginResult | null>(null);
const [health, setHealth] = useState<SiteHealthResult | null>(null);
const [phase, setPhase] = useState<string | null>(null);
const create = useCreateSite();
const update = useUpdateSite();
const put = usePutCredentials();
const refresh = useRefreshLogin();
const check = useHealthCheck();
const items = defs.data?.items ?? [];
const def = items.find((d) => d.key === defKey);
const custom = defKey === CUSTOM;
const kinds: LoginKind[] = custom ? [] : supportedKinds(def);
const busy = phase !== null;
const groups = useMemo(() => {
const needle = q.trim().toLowerCase();
const hit = (d: SiteDefinitionView) =>
!needle || d.name.toLowerCase().includes(needle) || d.key.includes(needle) || d.baseUrl.includes(needle);
return GROUP_ORDER.map((a) => ({
adapter: a,
items: items.filter((d) => d.adapter === a && hit(d)).sort((x, y) => x.name.localeCompare(y.name, "zh-CN")),
})).filter((g) => g.items.length);
}, [items, q]);
const pick = (key: string) => {
const d = items.find((x) => x.key === key);
setDefKey(key);
setForm(d ? { name: d.name, baseUrl: d.baseUrl, key: d.key } : { name: "", baseUrl: "", key: "" });
setCred(emptyCredential(defaultKind(d ? supportedKinds(d) : [])));
setErrors({});
setStep(2);
};
const validate = () => {
const fe: Record<string, string> = {};
if (!form.name.trim()) fe.name = "填写名称";
try {
const u = new URL(form.baseUrl.trim());
if (!/^https?:$/.test(u.protocol)) throw new Error();
} catch {
fe.baseUrl = "填完整地址,如 https://example.org";
}
if (custom && !/^[a-z][a-z0-9_-]*$/.test(form.key.trim())) fe.key = "小写字母开头,只含小写字母、数字、- 和 _";
let body = null;
if (kinds.length) {
// 账号标签用站点标识:服务端 secret 名按标签生成且全局唯一,多站都叫 default 会冲突
const b = credentialBody(cred, (custom ? form.key.trim() : def?.key) || "default");
if (b.errors) Object.assign(fe, b.errors);
body = b.body;
}
setErrors(fe);
return Object.keys(fe).length ? null : { body };
};
/** 保存站点(已建则按表单更新)→ 写凭证 → 刷新登录 → 检测健康 */
const test = async () => {
const v = validate();
if (!v) return;
setLogin(null);
setHealth(null);
try {
setPhase("保存站点…");
let site = created;
const base = { name: form.name.trim(), baseUrl: form.baseUrl.trim() };
if (!site) {
site = await create.mutateAsync({
key: custom ? form.key.trim() : (def?.key ?? form.key),
adapter: custom ? "generic_rss" : (def?.adapter ?? "nexusphp"),
snapshotEnabled: !custom,
...base,
});
setCreated(site);
} else if (site.name !== base.name || site.baseUrl !== base.baseUrl) {
site = await update.mutateAsync({ id: site.id, body: base });
setCreated(site);
}
setStep(3);
if (v.body) {
setPhase("保存凭证…");
const saved = await put.mutateAsync({ siteId: site.id, body: v.body });
setPhase("登录中…");
const r = await refresh.mutateAsync({ id: site.id, accountId: saved.siteAccountId });
setLogin(r.result);
}
setPhase("检测健康…");
setHealth(await check.mutateAsync(site.id).catch(() => null));
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
} finally {
setPhase(null);
}
};
const finish = () => {
onClose();
if (created) navigate(consolePath("sites", created.id));
};
return (
<Dialog open onOpenChange={(o) => !o && !busy && onClose()}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>添加站点</DialogTitle>
<DialogDescription asChild>
<ol className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta">
{STEP_LABEL.map((l, i) => (
<li
key={l}
className={cn(
"flex items-center gap-1.5",
step === i + 1 ? "font-medium text-foreground" : "text-muted-foreground",
)}
>
<span
className={cn(
"grid h-5 w-5 place-items-center rounded-full border text-micro",
step > i + 1
? "border-foreground bg-foreground text-background"
: step === i + 1
? "border-foreground"
: "border-border",
)}
>
{step > i + 1 ? <Check className="h-3 w-3" /> : i + 1}
</span>
{l}
</li>
))}
</ol>
</DialogDescription>
</DialogHeader>
{step === 1 ? (
<div className="min-w-0">
<div className="relative mb-3">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索站点名或域名"
className="pl-9"
aria-label="搜索站点定义"
/>
</div>
<div className="max-h-[55vh] space-y-4 overflow-y-auto pr-1">
{defs.isPending ? (
<LoadingState rows={5} />
) : defs.error ? (
<ErrorState plain error={defs.error} onRetry={() => void defs.refetch()} />
) : (
<>
{groups.map((g) => (
<section key={g.adapter}>
<h3 className="mb-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
{ADAPTER_LABEL[g.adapter]} · {g.items.length}
</h3>
<ul className="grid gap-1.5 sm:grid-cols-2">
{g.items.map((d) => {
const taken = !!d.siteId;
return (
<li key={d.key}>
<button
type="button"
disabled={taken}
onClick={() => pick(d.key)}
className="flex w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-border/70 px-3 py-2 text-left transition-colors hover:border-foreground/40 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
>
<span className="min-w-0">
<span className="block truncate text-caption font-medium text-foreground">
{d.name}
</span>
<span className="block truncate text-meta text-muted-foreground">
{hostOf(d.baseUrl)}
</span>
</span>
{taken ? (
<span className="shrink-0 text-meta text-muted-foreground">已添加</span>
) : !d.shippedEnabled ? (
<StatusBadge tone="warning" className="shrink-0">
未实测
</StatusBadge>
) : null}
</button>
</li>
);
})}
</ul>
</section>
))}
{!groups.length ? (
<p className="py-6 text-center text-caption text-muted-foreground">没有匹配的站点定义</p>
) : null}
<section>
<h3 className="mb-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
未适配站点
</h3>
<button
type="button"
onClick={() => pick(CUSTOM)}
className="w-full rounded-lg border border-dashed border-border px-3 py-2 text-left text-caption text-muted-foreground hover:border-foreground/40 hover:text-foreground"
>
自定义 RSS 站点:只抓 Feed,不登录
</button>
</section>
</>
)}
</div>
<p className="mt-3 text-meta text-muted-foreground">
"未实测"的定义没有在真实站点上验证过,列表和登录可能失败。
</p>
</div>
) : step === 2 ? (
<form
id="site-add"
noValidate
onSubmit={(e) => {
e.preventDefault();
void test();
}}
className="max-h-[60vh] min-w-0 overflow-y-auto pr-1"
>
<div className="grid gap-x-4 sm:grid-cols-2">
<FormField label="名称" htmlFor="add-name" required error={errors.name}>
<Input
id="add-name"
value={form.name}
aria-invalid={!!errors.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
disabled={busy}
/>
</FormField>
<FormField
label="地址"
htmlFor="add-url"
required
error={errors.baseUrl}
hint={custom ? undefined : "站点换了域名时改这里"}
>
<Input
id="add-url"
inputMode="url"
placeholder="https://"
value={form.baseUrl}
aria-invalid={!!errors.baseUrl}
onChange={(e) => setForm((f) => ({ ...f, baseUrl: e.target.value }))}
disabled={busy}
/>
</FormField>
{custom ? (
<FormField label="标识" htmlFor="add-key" required error={errors.key} hint="添加后不能改">
<Input
id="add-key"
className="font-mono"
value={form.key}
aria-invalid={!!errors.key}
onChange={(e) => setForm((f) => ({ ...f, key: e.target.value }))}
disabled={busy}
/>
</FormField>
) : null}
</div>
{kinds.length ? (
<>
<div className="mt-1 mb-3 border-t border-border/60" />
<CredentialFields
value={cred}
onChange={setCred}
kinds={kinds}
errors={errors}
disabled={busy}
idPrefix="add"
/>
{kinds.length === 1 && kinds[0] === "api_key" ? (
<p className="text-meta text-muted-foreground">这个站点只支持 API Key 登录。</p>
) : null}
</>
) : (
<p className="text-caption text-muted-foreground">
不需要登录凭证。添加后在站点详情的"快照与 Feed"里新建 Feed。
</p>
)}
</form>
) : (
<div className="min-w-0 space-y-3" data-state={phase ? "running" : "done"} aria-busy={busy}>
<p className="text-caption text-muted-foreground">
{created ? `已添加「${created.name}」。` : ""}
{phase ?? (login?.ok === false ? "可以返回修改凭证后重试,或先保存,之后在站点页处理。" : "")}
</p>
{phase ? <LoadingState rows={2} /> : null}
<LoginResultView
result={login}
health={health}
onUseCookie={
kinds.includes("cookie")
? () => {
setCred(emptyCredential("cookie"));
setStep(2);
}
: undefined
}
/>
</div>
)}
<DialogFooter className="gap-2">
{step === 2 ? (
<>
<Button variant="ghost" onClick={() => setStep(1)} disabled={busy || !!created}>
<ArrowLeft />
重选站点
</Button>
<Button type="submit" form="site-add" disabled={busy}>
{busy ? <LoaderCircle className="animate-spin" /> : null}
{kinds.length ? "保存并测试" : "保存并检测"}
</Button>
</>
) : step === 3 ? (
<>
<Button variant="outline" onClick={() => setStep(2)} disabled={busy}>
<ArrowLeft />
返回修改
</Button>
<Button onClick={finish} disabled={busy}>
完成
</Button>
</>
) : (
<Button variant="outline" onClick={onClose}>
取消
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+455
View File
@@ -0,0 +1,455 @@
import type { SiteListItem } from "@umt/contracts";
import {
Activity,
Camera,
Ellipsis,
ExternalLink,
KeyRound,
LoaderCircle,
Pencil,
Power,
RefreshCw,
Trash2,
} from "lucide-react";
import { type ReactNode, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { snapshotStale, useSnapshotSite, useToggleSite } from "~/api/sites.ts";
import { 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";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { circuitLabel, relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import type { SiteDialog, useSiteChecks } from "./site-dialogs.tsx";
import {
ACCOUNT_OVERALL,
AUTH_KIND_LABEL,
bytes,
dataAccount,
healthLine,
healthMessage,
hostOf,
loginFailureText,
overallStatus,
primaryAccount,
rateLimitText,
ratioText,
} from "./site-fields.ts";
type Checks = ReturnType<typeof useSiteChecks>;
type Props = { site: SiteListItem; checks: Checks; onDialog: (d: SiteDialog) => void };
/** 按凭证类型给"更新 xx"文案 */
function updateCredLabel(site: SiteListItem): string {
const k = primaryAccount(site.accounts)?.authKind;
if (k === "api_key") return "更新 API Key";
if (k === "password") return "更新密码";
if (k === "cookie") return "更新 Cookie";
return "填写凭证";
}
function EnableSwitch({ site, withLabel }: { site: SiteListItem; withLabel?: boolean }) {
const toggle = useToggleSite();
return (
<span className="flex shrink-0 items-center gap-2 text-meta text-muted-foreground">
{withLabel ? <label htmlFor={`site-en-${site.id}`}>{site.enabled ? "已启用" : "已停用"}</label> : null}
<Switch
id={`site-en-${site.id}`}
checked={site.enabled}
disabled={toggle.isPending}
aria-label={`${site.enabled ? "停用" : "启用"}${site.name}`}
onCheckedChange={(v) =>
toggle.mutate(
{ id: site.id, enabled: v },
{
onSuccess: () => toast.success(v ? `已启用 ${site.name}` : `已停用 ${site.name}`),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
</span>
);
}
/** "更多"菜单:更新凭证、编辑、快照、启停、删除 */
export function SiteMoreMenu({ site, onDialog }: { site: SiteListItem; onDialog: (d: SiteDialog) => void }) {
const [open, setOpen] = useState(false);
const snap = useSnapshotSite();
const toggle = useToggleSite();
const item = (
icon: ReactNode,
label: string,
onClick: () => void,
opts: { danger?: boolean; disabled?: boolean } = {},
) => (
<button
type="button"
role="menuitem"
disabled={opts.disabled}
onClick={() => {
setOpen(false);
onClick();
}}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-caption transition-colors hover:bg-accent disabled:opacity-50 [&_svg]:h-3.5 [&_svg]:w-3.5",
opts.danger ? "text-destructive" : "text-foreground",
)}
>
{icon}
{label}
</button>
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={`${site.name} 更多操作`}>
<Ellipsis />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-1.5" role="menu">
{item(<KeyRound />, updateCredLabel(site), () => onDialog({ kind: "credentials", site }))}
{item(<Pencil />, "编辑站点", () => onDialog({ kind: "edit", site }))}
{item(
<Camera />,
"抓取快照",
() =>
snap.mutate(
{ id: site.id },
{ onSuccess: () => toast.success("快照已排队"), onError: (e) => toast.error(errorMessage(e)) },
),
{ disabled: !site.enabled },
)}
{item(<Power />, site.enabled ? "停用站点" : "启用站点", () =>
toggle.mutate(
{ id: site.id, enabled: !site.enabled },
{
onSuccess: () => toast.success(site.enabled ? `已停用 ${site.name}` : `已启用 ${site.name}`),
onError: (e) => toast.error(errorMessage(e)),
},
),
)}
<div className="my-1 border-t border-border/60" />
{item(<Trash2 />, "删除站点", () => onDialog({ kind: "delete", site }), { danger: true })}
</PopoverContent>
</Popover>
);
}
function CheckButtons({ site, checks, compact }: Omit<Props, "onDialog"> & { compact?: boolean }) {
const h = checks.isBusy(site.id, "health");
const l = checks.isBusy(site.id, "login");
const hasAccount = !!site.accounts?.some((a) => a.enabled && a.authKind !== "passkey");
return (
<>
<Button
variant="outline"
size="sm"
disabled={h || !site.enabled}
title={site.enabled ? "首页连通 + 登录态,走站点限流" : "站点已停用"}
onClick={() => void checks.checkHealth(site)}
>
{h ? <LoaderCircle className="animate-spin" /> : <Activity />}
{compact ? <span className="sr-only sm:not-sr-only">检测健康</span> : "检测健康"}
</Button>
<Button
variant="outline"
size="sm"
disabled={l || !hasAccount}
title={hasAccount ? "用凭证刷新登录状态并拉取用户数据" : "先填写凭证"}
onClick={() => void checks.refreshLogin(site)}
>
{l ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
{compact ? <span className="sr-only sm:not-sr-only">刷新登录</span> : "刷新登录"}
</Button>
</>
);
}
function AccountBadges({ site }: { site: SiteListItem }) {
const st = ACCOUNT_OVERALL[overallStatus(site)];
const acc = primaryAccount(site.accounts);
const hasPasskey = site.accounts?.some((a) => a.hasPasskey || a.authKind === "passkey");
return (
<>
<StatusBadge tone={st.tone}>账号{st.label}</StatusBadge>
{acc && acc.authKind !== "passkey" ? (
<span className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">
{AUTH_KIND_LABEL[acc.authKind]}
</span>
) : null}
{hasPasskey ? (
<span className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">Passkey</span>
) : null}
</>
);
}
function Stat({ label, children, className }: { label: string; children: ReactNode; className?: string }) {
return (
<div className={cn("min-w-0", className)}>
<dt className="text-meta text-muted-foreground">{label}</dt>
<dd className="truncate text-caption font-medium text-foreground tabular-nums">{children}</dd>
</div>
);
}
export function SiteCard({ site, checks, onDialog }: Props) {
const acc = primaryAccount(site.accounts);
const dAcc = dataAccount(site.accounts);
const u = dAcc?.userData ?? null;
const health = healthLine(site.lastHealth, site.lastHealthAt);
const hmsg = healthMessage(site.lastHealth);
const stale = site.snapshotEnabled && snapshotStale(site);
const circuit = circuitLabel(site);
const lastErr = acc && acc.status !== "active" ? acc.lastError : null;
return (
<article className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-4", !site.enabled && "opacity-75")}>
<header className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Link
to={consolePath("sites", site.id)}
className="block truncate text-lead font-semibold text-foreground hover:underline"
>
{site.name}
</Link>
<a
href={site.baseUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex max-w-full items-center gap-1 truncate text-meta text-muted-foreground hover:text-foreground"
>
<span className="truncate">{hostOf(site.baseUrl)}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
</div>
<EnableSwitch site={site} withLabel />
</header>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<AccountBadges site={site} />
{u?.vip ? <StatusBadge tone="neutral">VIP</StatusBadge> : null}
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
</div>
{lastErr ? (
<p className="mt-2 line-clamp-2 text-meta text-warning" title={lastErr.message}>
{loginFailureText(lastErr.reason, lastErr.message, acc?.authKind).text}
</p>
) : null}
<div className="mt-3 rounded-lg border border-border/60 px-3 py-2.5">
{u ? (
<>
<dl className="grid grid-cols-3 gap-x-3 gap-y-2">
<Stat label="上传">{bytes(u.upload)}</Stat>
<Stat label="下载">{bytes(u.download)}</Stat>
<Stat label="分享率">{ratioText(u)}</Stat>
<Stat label="等级">{u.userLevel ?? "—"}</Stat>
<Stat label="做种">
{u.seeding}
{u.seedingSize ? <span className="text-muted-foreground"> · {bytes(u.seedingSize)}</span> : null}
</Stat>
{u.bonus !== null ? (
<Stat label="魔力">{u.bonus.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}</Stat>
) : (
<Stat label="未读消息">{u.messageUnread || "—"}</Stat>
)}
</dl>
<p className="mt-2 flex flex-wrap justify-between gap-x-3 text-meta text-muted-foreground">
<span>{u.username ?? ""}</span>
<span>
{u.messageUnread > 0 && u.bonus !== null ? `${u.messageUnread} 条未读 · ` : ""}数据{" "}
{relTime(dAcc?.userDataAt)}
</span>
</p>
</>
) : (
<p className="py-2 text-caption text-muted-foreground">
{acc ? "还没有用户数据。刷新登录后显示" : "还没有凭证。填写凭证后显示账号数据"}
</p>
)}
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-caption">
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">上次登录</dt>
<dd className="text-foreground">{relTime(acc?.lastLoginAt)}</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">健康检测</dt>
<dd className="truncate" title={hmsg ?? undefined}>
<StatusDot tone={health.tone} />
{health.text}
</dd>
</div>
<div className="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>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">限流</dt>
<dd className="truncate text-foreground">{rateLimitText(site.rateLimit)}</dd>
</div>
</dl>
<footer className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
{acc ? (
<CheckButtons site={site} checks={checks} />
) : (
<Button size="sm" onClick={() => onDialog({ kind: "credentials", site })}>
<KeyRound />
填写凭证
</Button>
)}
<SiteMoreMenu site={site} onDialog={onDialog} />
</footer>
</article>
);
}
function StatusDot({ tone }: { tone: "success" | "warning" | "destructive" | "neutral" }) {
return (
<span
aria-hidden
className={cn(
"mr-1.5 inline-block h-1.5 w-1.5 rounded-full align-middle",
tone === "success"
? "bg-success"
: tone === "warning"
? "bg-warning"
: tone === "destructive"
? "bg-destructive"
: "bg-muted-foreground",
)}
/>
);
}
/** 列表视图:同字段,窄屏隐藏次要列 */
export function SiteTable({
sites,
checks,
onDialog,
}: {
sites: SiteListItem[];
checks: Checks;
onDialog: (d: SiteDialog) => void;
}) {
return (
<DataTable
rows={sites}
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: "account",
header: "账号",
cell: (s) => (
<div className="flex flex-wrap items-center gap-1">
<AccountBadges site={s} />
{dataAccount(s.accounts)?.userData?.vip ? <StatusBadge>VIP</StatusBadge> : null}
</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) => {
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"}>
{!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} />
{primaryAccount(s.accounts) ? <CheckButtons site={s} checks={checks} compact /> : null}
<SiteMoreMenu site={s} onDialog={onDialog} />
</div>
),
},
]}
/>
);
}
@@ -0,0 +1,534 @@
import type { SiteAccount, SiteHealthResult, SiteListItem, SiteLoginResult } from "@umt/contracts";
import { CircleCheck, CircleX, KeyRound, LoaderCircle, TriangleAlert } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import {
useDeleteSite,
useHealthCheck,
usePutCredentials,
useRefreshLogin,
useSiteDefinitions,
useUpdateSite,
} from "~/api/sites.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { CredentialFields, credentialBody, emptyCredential } from "./credential-fields.tsx";
import {
AUTH_KIND_LABEL,
bytes,
healthLine,
type LoginKind,
loginFailureText,
primaryAccount,
ratioText,
supportedKinds,
} from "./site-fields.ts";
// 站点页的对话框与动作:检测健康、刷新登录、更新凭证、编辑、删除(卡片、列表、详情页共用)
export type SiteDialog =
| { kind: "credentials"; site: SiteListItem; account?: SiteAccount | null; loginKind?: LoginKind }
| { kind: "edit"; site: SiteListItem }
| { kind: "delete"; site: SiteListItem };
/** 检测健康 + 刷新登录,结果用 toast 说 */
export function useSiteChecks(onDialog?: (d: SiteDialog) => void) {
const health = useHealthCheck();
const login = useRefreshLogin();
const [busy, setBusy] = useState<{ id: string; what: "health" | "login" } | null>(null);
const checkHealth = async (site: SiteListItem) => {
setBusy({ id: site.id, what: "health" });
try {
const r = await health.mutateAsync(site.id);
const line = healthLine(r, r.checkedAt);
if (r.ok) toast.success(`${site.name}:${line.text}`);
else toast.warning(`${site.name}:${line.text}`, { description: r.message ?? undefined });
} catch (e) {
toast.error(errorMessage(e, "检测失败"));
} finally {
setBusy(null);
}
};
const refreshLogin = async (site: SiteListItem, account?: SiteAccount | null) => {
setBusy({ id: site.id, what: "login" });
try {
const r = await login.mutateAsync({ id: site.id, accountId: account?.id });
const res = r.result;
if (!res) toast.success("刷新登录已排队");
else notifyLogin(site, res, account ?? primaryAccount(site.accounts), onDialog);
} catch (e) {
toast.error(errorMessage(e, "刷新登录失败"));
} finally {
setBusy(null);
}
};
return {
checkHealth,
refreshLogin,
isBusy: (id: string, what: "health" | "login") => busy?.id === id && busy.what === what,
};
}
function notifyLogin(
site: SiteListItem,
res: SiteLoginResult,
account: SiteAccount | null,
onDialog?: (d: SiteDialog) => void,
) {
if (res.ok) {
const u = res.userData;
toast.success(`${site.name} 已登录`, {
description: u ? [u.username, u.userLevel, `分享率 ${ratioText(u)}`].filter(Boolean).join(" · ") : undefined,
});
return;
}
const f = loginFailureText(res.reason, res.message, account?.authKind);
toast.error(`${site.name}:${f.text}`, {
duration: 10_000,
action:
f.suggestCookie && onDialog
? { label: "改用 Cookie", onClick: () => onDialog({ kind: "credentials", site, account, loginKind: "cookie" }) }
: undefined,
});
}
/** 登录结果块(向导第 ③ 步、凭证对话框保存后) */
export function LoginResultView({
result,
health,
onUseCookie,
}: {
result: SiteLoginResult | null;
health?: SiteHealthResult | null;
onUseCookie?: () => void;
}) {
return (
<div className="space-y-2">
{result ? (
result.ok ? (
<ResultRow ok title="登录成功">
{result.userData ? (
<span>
{[result.userData.username, result.userData.userLevel].filter(Boolean).join(" · ")}
{result.userData.vip ? " · VIP" : ""} · 上传 {bytes(result.userData.upload)} · 分享率{" "}
{ratioText(result.userData)}
</span>
) : (
<span>{result.message ?? "没有取到用户数据"}</span>
)}
</ResultRow>
) : (
<FailureRow result={result} onUseCookie={onUseCookie} />
)
) : null}
{health ? (
<ResultRow ok={health.ok} warn={!health.ok} title="健康检测">
<span className="block">{healthLine(health, health.checkedAt).text}</span>
{!health.ok && health.message ? <span className="block">{health.message}</span> : null}
</ResultRow>
) : null}
</div>
);
}
function FailureRow({ result, onUseCookie }: { result: SiteLoginResult; onUseCookie?: () => void }) {
const f = loginFailureText(result.reason, result.message);
return (
<ResultRow ok={false} title="登录失败">
<span className="block">{f.text}</span>
{f.suggestCookie || result.decisionId ? (
<span className="mt-1.5 flex flex-wrap gap-2">
{f.suggestCookie && onUseCookie ? (
<Button type="button" size="xs" variant="outline" onClick={onUseCookie}>
<KeyRound />
改用 Cookie
</Button>
) : null}
{result.decisionId ? (
<Button asChild size="xs" variant="ghost">
<Link to={consolePath("inbox")}>查看待处理</Link>
</Button>
) : null}
</span>
) : null}
</ResultRow>
);
}
function ResultRow({
ok,
warn,
title,
children,
}: {
ok: boolean;
warn?: boolean;
title: string;
children: React.ReactNode;
}) {
const Icon = ok ? CircleCheck : warn ? TriangleAlert : CircleX;
return (
<div
className={cn(
"flex items-start gap-2 rounded-lg border px-3 py-2 text-caption",
ok
? "border-success/30 bg-success/10"
: warn
? "border-warning/40 bg-warning/10"
: "border-destructive/30 bg-destructive/10",
)}
>
<Icon
className={cn("mt-0.5 h-4 w-4 shrink-0", ok ? "text-success" : warn ? "text-warning" : "text-destructive")}
/>
<div className="min-w-0 break-words">
<p className="font-medium text-foreground">{title}</p>
<div className="text-muted-foreground">{children}</div>
</div>
</div>
);
}
/** 更新凭证:保存后立即刷新登录,结果留在对话框里 */
export function CredentialDialog({
site,
account,
loginKind,
onClose,
}: {
site: SiteListItem;
account?: SiteAccount | null;
loginKind?: LoginKind;
onClose: () => void;
}) {
const defs = useSiteDefinitions();
const def = defs.data?.items.find((d) => d.siteId === site.id || d.key === site.key);
const kinds = supportedKinds(def ?? { adapter: site.adapter, authSupported: [] });
const current = account ?? primaryAccount(site.accounts);
const initialKind: LoginKind =
loginKind ??
(current && current.authKind !== "passkey" && kinds.includes(current.authKind as LoginKind)
? (current.authKind as LoginKind)
: (kinds[0] ?? "cookie"));
const [draft, setDraft] = useState(() => emptyCredential(initialKind));
const [errors, setErrors] = useState<Record<string, string>>({});
const [result, setResult] = useState<SiteLoginResult | null>(null);
const put = usePutCredentials();
const login = useRefreshLogin();
const busy = put.isPending || login.isPending;
const shownKinds = kinds.includes(draft.kind) ? kinds : [...kinds, draft.kind];
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const b = credentialBody(draft, current?.label ?? site.key);
if (!b.body) {
setErrors(b.errors);
return;
}
setErrors({});
setResult(null);
try {
const saved = await put.mutateAsync({ siteId: site.id, body: b.body });
const r = await login.mutateAsync({ id: site.id, accountId: saved.siteAccountId });
if (r.result) {
setResult(r.result);
if (r.result.ok) {
toast.success(`${site.name} 凭证已更新并登录`);
onClose();
}
} else {
toast.success("凭证已保存,刷新登录已排队");
onClose();
}
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
return (
<Dialog open onOpenChange={(o) => !o && !busy && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>更新凭证 · {site.name}</DialogTitle>
<DialogDescription>
{current
? `当前:${AUTH_KIND_LABEL[current.authKind]}。凭证加密保存,不回显;保存后立即刷新登录`
: "凭证加密保存,不回显;保存后立即刷新登录"}
</DialogDescription>
</DialogHeader>
<form id="site-cred" onSubmit={submit} noValidate>
<CredentialFields
value={draft}
onChange={setDraft}
kinds={shownKinds}
errors={errors}
disabled={busy}
idPrefix="upd"
/>
</form>
{result && !result.ok ? (
<LoginResultView
result={result}
onUseCookie={
draft.kind !== "cookie"
? () => {
setDraft(emptyCredential("cookie"));
setResult(null);
}
: undefined
}
/>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={busy}>
{result ? "关闭" : "取消"}
</Button>
<Button type="submit" form="site-cred" disabled={busy}>
{busy ? <LoaderCircle className="animate-spin" /> : null}
{busy ? (login.isPending ? "登录中…" : "保存中…") : "保存并登录"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** 编辑抽屉:名称、域名、限流(空 = 站点默认)、定时快照、代理 */
export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose: () => void }) {
const update = useUpdateSite();
const [v, setV] = useState({
name: site.name,
baseUrl: site.baseUrl,
rpm: site.rateLimit.requestsPerMinute ? String(site.rateLimit.requestsPerMinute) : "",
concurrency: site.rateLimit.concurrency ? String(site.rateLimit.concurrency) : "",
snapshotEnabled: site.snapshotEnabled,
intervalMin: site.snapshotIntervalSec ? String(Math.round(site.snapshotIntervalSec / 60)) : "",
proxy: site.proxy,
});
const [errors, setErrors] = useState<Record<string, string>>({});
const set = <K extends keyof typeof v>(k: K, val: (typeof v)[K]) => setV((s) => ({ ...s, [k]: val }));
const busy = update.isPending;
const save = async () => {
const fe: Record<string, string> = {};
if (!v.name.trim()) fe.name = "填写名称";
try {
new URL(v.baseUrl.trim());
} catch {
fe.baseUrl = "填完整地址,如 https://example.org";
}
const posInt = (s: string, k: string, min = 1) => {
if (!s.trim()) return undefined;
const n = Number(s);
if (!Number.isInteger(n) || n < min) fe[k] = `填不小于 ${min} 的整数,留空用站点默认`;
return n;
};
const rpm = posInt(v.rpm, "rpm");
const concurrency = posInt(v.concurrency, "concurrency");
const interval = posInt(v.intervalMin, "intervalMin", 5);
if (Object.keys(fe).length) {
setErrors(fe);
return;
}
setErrors({});
const rateLimit = {
...(rpm ? { requestsPerMinute: rpm } : {}),
...(concurrency ? { concurrency } : {}),
};
try {
await update.mutateAsync({
id: site.id,
body: {
name: v.name.trim(),
baseUrl: v.baseUrl.trim(),
rateLimit,
snapshotEnabled: v.snapshotEnabled,
snapshotIntervalSec: interval ? interval * 60 : null,
proxy: v.proxy,
},
});
toast.success(`${v.name.trim()} 已保存`);
onClose();
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
return (
<DetailSheet
open
onOpenChange={(o) => !o && !busy && onClose()}
title={`编辑 · ${site.name}`}
description="限流和快照周期留空时用「设置 → 站点默认」"
footer={
<>
<Button variant="outline" onClick={onClose} disabled={busy}>
取消
</Button>
<Button onClick={() => void save()} disabled={busy}>
{busy ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
</>
}
>
<div className="grid gap-x-4 gap-y-1">
<FormField label="名称" htmlFor="se-name" required error={errors.name}>
<Input id="se-name" value={v.name} onChange={(e) => set("name", e.target.value)} disabled={busy} />
</FormField>
<FormField label="地址" htmlFor="se-url" required error={errors.baseUrl} hint="站点换域名时改这里">
<Input
id="se-url"
inputMode="url"
value={v.baseUrl}
onChange={(e) => set("baseUrl", e.target.value)}
disabled={busy}
/>
</FormField>
<div className="grid gap-x-4 sm:grid-cols-2">
<FormField label="每分钟请求数" htmlFor="se-rpm" error={errors.rpm} hint="留空用站点默认">
<Input
id="se-rpm"
inputMode="numeric"
value={v.rpm}
onChange={(e) => set("rpm", e.target.value)}
disabled={busy}
/>
</FormField>
<FormField label="并发" htmlFor="se-conc" error={errors.concurrency} hint="留空用站点默认">
<Input
id="se-conc"
inputMode="numeric"
value={v.concurrency}
onChange={(e) => set("concurrency", e.target.value)}
disabled={busy}
/>
</FormField>
</div>
<div className="mb-4 flex items-center gap-3">
<Switch
id="se-snap"
checked={v.snapshotEnabled}
onCheckedChange={(x) => set("snapshotEnabled", x)}
disabled={busy}
/>
<label htmlFor="se-snap" className="text-caption text-foreground">
定时抓取快照
</label>
</div>
{v.snapshotEnabled ? (
<FormField label="快照周期(分钟)" htmlFor="se-int" error={errors.intervalMin} hint="留空用站点默认">
<Input
id="se-int"
inputMode="numeric"
value={v.intervalMin}
onChange={(e) => set("intervalMin", e.target.value)}
disabled={busy}
/>
</FormField>
) : null}
<div className="mb-2 flex items-start gap-3">
<Switch id="se-proxy" checked={v.proxy} onCheckedChange={(x) => set("proxy", x)} disabled={busy} />
<label htmlFor="se-proxy" className="text-caption">
<span className="block font-medium text-foreground">使用代理</span>
<span className="block text-muted-foreground">走「站点默认」里的代理地址;没填代理地址时不生效</span>
</label>
</div>
<p className="text-meta text-muted-foreground">User-Agent 随凭证保存,在"更新凭证"里填写。</p>
</div>
</DetailSheet>
);
}
/** 删除站点确认:写明影响 */
export function DeleteSiteDialog({
site,
onClose,
onDeleted,
}: {
site: SiteListItem;
onClose: () => void;
onDeleted?: () => void;
}) {
const del = useDeleteSite();
return (
<ConfirmDialog
open
onOpenChange={(o) => !o && onClose()}
title={`删除站点「${site.name}」?`}
description="不可恢复。"
confirmLabel="删除站点"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(site.id);
toast.success(`已删除 ${site.name}`);
onDeleted?.();
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
>
<ul className="list-disc space-y-1 pl-5 text-caption text-muted-foreground">
<li>站点、{site.accounts?.length ?? 0} 个账号和全部凭证删除</li>
<li>已抓到的资源保留,标为"站点已关闭",不再产生新候选</li>
<li>该站 Feed 停用;已有下载任务按原策略继续</li>
<li>同一站点以后可以重新添加</li>
</ul>
</ConfirmDialog>
);
}
/** 页面级对话框出口 */
export function SiteDialogHost({
dialog,
onClose,
onDeleted,
}: {
dialog: SiteDialog | null;
onClose: () => void;
onDeleted?: () => void;
}) {
if (!dialog) return null;
if (dialog.kind === "credentials")
return (
<CredentialDialog
key={`${dialog.site.id}-${dialog.loginKind ?? ""}`}
site={dialog.site}
account={dialog.account}
loginKind={dialog.loginKind}
onClose={onClose}
/>
);
if (dialog.kind === "edit") return <SiteEditSheet key={dialog.site.id} site={dialog.site} onClose={onClose} />;
return (
<DeleteSiteDialog
site={dialog.site}
onClose={onClose}
onDeleted={() => {
onClose();
onDeleted?.();
}}
/>
);
}
@@ -0,0 +1,208 @@
import type {
SiteAccount,
SiteAccountOverall,
SiteAdapter,
SiteAuthKind,
SiteDefinitionView,
SiteLastHealth,
SiteListItem,
SiteLoginFailureReason,
SiteRateLimit,
} from "@umt/contracts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { formatBytes } from "~/lib/utils.ts";
// 站点页的展示口径(docs/design/site-center.md §4.2)。缺失字段显示"—",不当成 0。
export const ADAPTER_LABEL: Record<SiteAdapter, string> = {
nexusphp: "NexusPHP",
mteam_api: "M-Team API",
api: "站点 API",
generic_rss: "通用 RSS",
};
export const ACCOUNT_OVERALL: Record<SiteAccountOverall, { label: string; tone: StatusTone }> = {
active: { label: "正常", tone: "success" },
expired: { label: "已失效", tone: "destructive" },
invalid: { label: "已失效", tone: "destructive" },
challenge: { label: "需验证", tone: "warning" },
unknown: { label: "未验证", tone: "neutral" },
unconfigured: { label: "未配置", tone: "neutral" },
};
export const AUTH_KIND_LABEL: Record<SiteAuthKind, string> = {
cookie: "Cookie",
api_key: "API Key",
password: "账号密码",
passkey: "Passkey",
};
/** 主凭证三选一(passkey 只用于 RSS / 下载,不参与登录) */
export type LoginKind = "password" | "cookie" | "api_key";
/** 定义支持的登录方式;定义没写 authSupported 时按适配器推断 */
export function supportedKinds(def: Pick<SiteDefinitionView, "adapter" | "authSupported"> | undefined): LoginKind[] {
if (!def) return ["cookie", "password", "api_key"];
const map: Record<string, LoginKind> = {
cookie: "cookie",
credential: "password",
password: "password",
apikey: "api_key",
api_key: "api_key",
};
const listed = [...new Set(def.authSupported.map((a) => map[a]).filter((x): x is LoginKind => !!x))];
if (listed.length) return listed;
if (def.adapter === "mteam_api") return ["api_key"];
if (def.adapter === "nexusphp") return ["cookie", "password"];
if (def.adapter === "api") return ["api_key", "cookie"];
return [];
}
/** 站点主账号:启用的里取最差状态那个;没有启用账号取第一个 */
const WORST: SiteAccount["status"][] = ["invalid", "expired", "challenge", "unknown", "active"];
export function primaryAccount(accounts: SiteAccount[] | undefined): SiteAccount | null {
const list = accounts ?? [];
const enabled = list.filter((a) => a.enabled);
if (!enabled.length) return list[0] ?? null;
return [...enabled].sort((a, b) => WORST.indexOf(a.status) - WORST.indexOf(b.status))[0] ?? null;
}
/** 有用户数据的账号(优先正常的) */
export function dataAccount(accounts: SiteAccount[] | undefined): SiteAccount | null {
const list = (accounts ?? []).filter((a) => a.enabled && a.userData);
return list.find((a) => a.status === "active") ?? list[0] ?? null;
}
export function overallStatus(site: SiteListItem): SiteAccountOverall {
if (site.accountStatus) return site.accountStatus;
const acc = primaryAccount(site.accounts);
return acc?.enabled ? acc.status : "unconfigured";
}
/** 需处理:账号非正常(已配置)或健康故障 */
export function needsAttention(site: SiteListItem): boolean {
const st = overallStatus(site);
return (
site.enabled && (st === "expired" || st === "invalid" || st === "challenge" || site.healthStatus === "failing")
);
}
export const bytes = (n: number | null | undefined) => (n === null || n === undefined ? "—" : formatBytes(n, 2));
/** 分享率:download=0 显示 ∞;null 显示 — */
export function ratioText(d: { ratio: number | null; download: number } | null | undefined): string {
if (!d) return "—";
if (d.download === 0 && d.ratio === null) return "∞";
if (d.ratio === null) return "—";
return d.ratio >= 1000 ? d.ratio.toFixed(0) : d.ratio.toFixed(2);
}
export function hostOf(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}
const HEALTH_STATUS_TEXT: Record<string, string> = {
ok: "正常",
auth_missing: "未登录",
auth_expired: "登录已失效",
auth_invalid: "凭证无效",
rate_limited: "被站点限流",
unreachable: "连不上站点",
site_maintenance: "站点维护中",
parse_error: "页面解析失败",
};
/** 健康检测一行:"正常 · 320 ms · 5 分钟前" / 失败原因一句 */
export function healthLine(h: SiteLastHealth | null, at: string | null): { text: string; tone: StatusTone } {
if (!h) return { text: "未检测", tone: "neutral" };
const parts = [HEALTH_STATUS_TEXT[h.status] ?? h.status];
if (h.latencyMs !== null) parts.push(`${h.latencyMs} ms`);
parts.push(relTime(at));
const tone: StatusTone = h.ok ? "success" : h.status.startsWith("auth_") ? "destructive" : "warning";
return { text: parts.join(" · "), tone };
}
export function healthMessage(h: SiteLastHealth | null): string | null {
if (!h || h.ok) return null;
return h.message;
}
/** 限流:"20 次/分";空对象 = 默认 */
export function rateLimitText(r: SiteRateLimit | null | undefined): string {
if (!r || (!r.requestsPerMinute && !r.minIntervalMs && !r.concurrency)) return "默认";
const parts: string[] = [];
if (r.requestsPerMinute) parts.push(`${r.requestsPerMinute} 次/分`);
if (r.minIntervalMs) parts.push(`间隔 ${(r.minIntervalMs / 1000).toFixed(1).replace(/\.0$/, "")} 秒`);
if (r.concurrency) parts.push(`并发 ${r.concurrency}`);
return parts.join(" · ");
}
/** 登录失败分类文案(先说发生了什么,再说怎么办) */
export function loginFailureText(
reason: SiteLoginFailureReason | null,
message: string | null,
authKind?: SiteAuthKind,
): { text: string; suggestCookie: boolean } {
switch (reason) {
case "captcha":
return { text: "登录失败:站点要求验证码。改用 Cookie,或稍后重试", suggestCookie: true };
case "two_factor":
return { text: "登录失败:站点要求二次验证。填写 TOTP 密钥后重试,或改用 Cookie", suggestCookie: true };
case "challenge":
return { text: "站点返回 Cloudflare 挑战页,无法自动登录。改用 Cookie", suggestCookie: true };
case "bad_credentials":
return { text: "登录失败:账号或密码错误", suggestCookie: false };
case "account_blocked":
return { text: `登录失败:账号被封禁或未确认${message ? `(${message})` : ""}`, suggestCookie: false };
case "auth_expired":
return {
text:
authKind === "api_key"
? "API Key 无效。更新 API Key 后重新刷新登录"
: "Cookie 已失效。更新 Cookie 后重新刷新登录",
suggestCookie: false,
};
case "network":
return { text: `连不上站点${message ? `:${message}` : ""}`, suggestCookie: false };
case "unsupported":
return { text: message ?? "这个站点不支持该凭证类型的自动登录", suggestCookie: false };
default:
return { text: message ?? "登录失败,原因未知", suggestCookie: false };
}
}
/** 列表排序键 */
export type SortKey = "name" | "ratio" | "upload" | "lastLogin";
export function sortSites(items: SiteListItem[], key: SortKey): SiteListItem[] {
const val = (s: SiteListItem): number => {
const acc = dataAccount(s.accounts);
if (key === "ratio")
return acc?.userData ? (acc.userData.download === 0 ? Infinity : (acc.userData.ratio ?? -1)) : -2;
if (key === "upload") return acc?.userData?.upload ?? -1;
if (key === "lastLogin") {
const t = primaryAccount(s.accounts)?.lastLoginAt;
return t ? new Date(t).getTime() : -1;
}
return 0;
};
if (key === "name") return [...items].sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
return [...items].sort((a, b) => val(b) - val(a));
}
/** 失败原因短标签(登录历史徽标) */
export const REASON_SHORT: Record<SiteLoginFailureReason, string> = {
captcha: "需验证码",
two_factor: "需二次验证",
bad_credentials: "密码错误",
account_blocked: "账号封禁",
challenge: "Cloudflare 挑战",
auth_expired: "凭证失效",
network: "网络失败",
unsupported: "不支持",
unknown: "失败",
};
@@ -0,0 +1,115 @@
import type { SiteListItem } from "@umt/contracts";
import { useSiteUserData } from "~/api/sites.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatTile } from "~/components/console/stat-tile.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import {
ACCOUNT_OVERALL,
AUTH_KIND_LABEL,
bytes,
dataAccount,
healthLine,
healthMessage,
overallStatus,
primaryAccount,
rateLimitText,
ratioText,
} from "./site-fields.ts";
import { UserDataChart } from "./user-data-chart.tsx";
/** 站点详情"概览":账号数据磁贴 + 30 天趋势 + 状态 */
export function SiteOverview({ site }: { site: SiteListItem }) {
const dAcc = dataAccount(site.accounts);
const acc = primaryAccount(site.accounts);
const u = dAcc?.userData ?? null;
const history = useSiteUserData(site.id, dAcc?.id, 30);
const st = ACCOUNT_OVERALL[overallStatus(site)];
const h = healthLine(site.lastHealth, site.lastHealthAt);
return (
<div className="space-y-6 pt-2">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<StatTile label="上传" value={bytes(u?.upload)} />
<StatTile label="下载" value={bytes(u?.download)} />
<StatTile label="分享率" value={ratioText(u)} />
<StatTile label="等级" value={u?.userLevel ?? "—"} hint={u?.vip ? "VIP" : undefined} />
<StatTile label="做种" value={u ? u.seeding : "—"} hint={u?.seedingSize ? bytes(u.seedingSize) : undefined} />
<StatTile
label={u?.bonus !== null && u?.bonus !== undefined ? "魔力" : "未读消息"}
value={
u
? u.bonus !== null
? u.bonus.toLocaleString("zh-CN", { maximumFractionDigits: 1 })
: u.messageUnread || "—"
: "—"
}
hint={u && u.bonus !== null && u.messageUnread ? `${u.messageUnread} 条未读` : undefined}
/>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<section className="pg-slab min-w-0 rounded-xl p-4">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-lead font-semibold text-foreground">近 30 天</h2>
<span className="text-meta text-muted-foreground">
{dAcc ? `账号 ${dAcc.label} · 数据 ${relTime(dAcc.userDataAt)}` : "没有账号数据"}
</span>
</div>
{history.isPending && dAcc ? (
<LoadingState rows={4} />
) : history.error ? (
<ErrorState plain title="读取历史失败" error={history.error} onRetry={() => void history.refetch()} />
) : (
<UserDataChart points={history.data?.items ?? []} />
)}
</section>
<div className="space-y-6">
<DetailSection title="账号">
<KvList
columns={1}
items={[
{ label: "状态", value: <StatusBadge tone={st.tone}>{st.label}</StatusBadge> },
{ label: "凭证", value: acc ? AUTH_KIND_LABEL[acc.authKind] : "未配置" },
{ label: "用户名", value: u?.username ?? acc?.loginName },
{ label: "上次登录", value: relTime(acc?.lastLoginAt) },
...(acc?.lastError && acc.status !== "active"
? [{ label: "最近错误", value: <span className="text-warning">{acc.lastError.message}</span> }]
: []),
]}
/>
</DetailSection>
<DetailSection title="站点">
<KvList
columns={1}
items={[
{
label: "健康检测",
value: (
<span>
{h.text}
{healthMessage(site.lastHealth) ? (
<span className="block text-meta text-muted-foreground">{healthMessage(site.lastHealth)}</span>
) : null}
</span>
),
},
{
label: "快照",
value: site.snapshotEnabled
? `${site.lastSnapshotAt ? relTime(site.lastSnapshotAt) : "还没有快照"} · ${site.snapshotIntervalSec ? `每 ${Math.round(site.snapshotIntervalSec / 60)} 分钟` : "默认周期"}`
: "未开启",
},
{ label: "限流", value: rateLimitText(site.rateLimit) },
{ label: "代理", value: site.proxy ? "使用" : "不使用" },
]}
/>
</DetailSection>
</div>
</div>
</div>
);
}
@@ -0,0 +1,199 @@
import type { SiteUserDataPoint } from "@umt/contracts";
import { useState } from "react";
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Button } from "~/components/ui/button.tsx";
import { formatBytes } from "~/lib/utils.ts";
import { ratioText } from "./site-fields.ts";
// 用户数据 30 天折线。主色无彩(interface-materials §1):系列靠明暗 + 实线 / 虚线区分,不用色相;
// 字节与分享率量纲不同,分两张图,不做双轴。
const INK = "var(--foreground)";
const INK_2 = "var(--muted-foreground)";
const GRID = "var(--border)";
type Row = { day: string; upload: number; download: number; ratio: number | null };
const dayLabel = (d: string) => d.slice(5).replace("-", "/");
function Legend({ items }: { items: { label: string; dashed?: boolean; muted?: boolean }[] }) {
return (
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-meta text-muted-foreground">
{items.map((i) => (
<li key={i.label} className="flex items-center gap-1.5">
<svg width="18" height="6" aria-hidden className="shrink-0">
<line
x1="1"
y1="3"
x2="17"
y2="3"
stroke={i.muted ? INK_2 : INK}
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={i.dashed ? "4 3" : undefined}
/>
</svg>
{i.label}
</li>
))}
</ul>
);
}
function TipBox({
active,
payload,
label,
kind,
}: {
active?: boolean;
payload?: { payload: Row }[];
label?: string;
kind: "bytes" | "ratio";
}) {
const row = payload?.[0]?.payload;
if (!active || !row) return null;
return (
<div className="pg-float rounded-lg px-3 py-2 text-meta">
<p className="mb-1 font-medium text-foreground">{label}</p>
{kind === "bytes" ? (
<>
<p className="text-muted-foreground">
上传 <span className="text-foreground tabular-nums">{formatBytes(row.upload)}</span>
</p>
<p className="text-muted-foreground">
下载 <span className="text-foreground tabular-nums">{formatBytes(row.download)}</span>
</p>
</>
) : (
<p className="text-muted-foreground">
分享率 <span className="text-foreground tabular-nums">{ratioText(row)}</span>
</p>
)}
</div>
);
}
const axisProps = {
stroke: GRID,
tick: { fill: "var(--muted-foreground)", fontSize: 11 },
tickLine: false,
axisLine: false,
} as const;
export function UserDataChart({ points }: { points: SiteUserDataPoint[] }) {
const [table, setTable] = useState(false);
const rows: Row[] = [...points]
.sort((a, b) => a.day.localeCompare(b.day))
.map((p) => ({ day: dayLabel(p.day), upload: p.upload, download: p.download, ratio: p.ratio }));
if (rows.length < 2)
return (
<p className="py-6 text-center text-caption text-muted-foreground">
{rows.length ? "只有 1 天的数据,满 2 天后显示趋势" : "还没有历史数据。刷新登录成功后每天记一次"}
</p>
);
return (
<div className="space-y-5">
<figure className="min-w-0">
<figcaption className="mb-2 flex flex-wrap items-center justify-between gap-2">
<span className="text-caption font-medium text-foreground">上传 / 下载</span>
<Legend items={[{ label: "上传" }, { label: "下载", dashed: true, muted: true }]} />
</figcaption>
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={rows} margin={{ top: 6, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID} strokeDasharray="0" vertical={false} />
<XAxis dataKey="day" {...axisProps} minTickGap={24} />
<YAxis {...axisProps} width={64} tickFormatter={(v: number) => formatBytes(v, 0)} />
<Tooltip
cursor={{ stroke: INK_2, strokeWidth: 1 }}
content={(p) => <TipBox {...(p as object)} kind="bytes" />}
/>
<Line
type="monotone"
dataKey="upload"
name="上传"
stroke={INK}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, stroke: "var(--background)", strokeWidth: 2, fill: INK }}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="download"
name="下载"
stroke={INK_2}
strokeWidth={2}
strokeDasharray="5 4"
dot={false}
activeDot={{ r: 4, stroke: "var(--background)", strokeWidth: 2, fill: INK_2 }}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</figure>
<figure className="min-w-0">
<figcaption className="mb-2 text-caption font-medium text-foreground">分享率</figcaption>
<div className="h-32 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={rows} margin={{ top: 6, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID} vertical={false} />
<XAxis dataKey="day" {...axisProps} minTickGap={24} />
<YAxis {...axisProps} width={64} domain={["auto", "auto"]} tickFormatter={(v: number) => v.toFixed(1)} />
<Tooltip
cursor={{ stroke: INK_2, strokeWidth: 1 }}
content={(p) => <TipBox {...(p as object)} kind="ratio" />}
/>
<Line
type="monotone"
dataKey="ratio"
name="分享率"
stroke={INK}
strokeWidth={2}
dot={false}
connectNulls
activeDot={{ r: 4, stroke: "var(--background)", strokeWidth: 2, fill: INK }}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</figure>
<div>
<Button variant="ghost" size="xs" onClick={() => setTable((t) => !t)} aria-expanded={table}>
{table ? "收起数据表" : "查看数据表"}
</Button>
{table ? (
<div className="mt-2 max-h-64 overflow-auto rounded-lg border border-border/60">
<table className="w-full text-meta tabular-nums">
<thead className="sticky top-0 bg-background text-muted-foreground">
<tr>
<th className="px-3 py-1.5 text-left font-medium">日期</th>
<th className="px-3 py-1.5 text-right font-medium">上传</th>
<th className="px-3 py-1.5 text-right font-medium">下载</th>
<th className="px-3 py-1.5 text-right font-medium">分享率</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60 text-foreground">
{[...rows].reverse().map((r) => (
<tr key={r.day}>
<td className="px-3 py-1.5">{r.day}</td>
<td className="px-3 py-1.5 text-right">{formatBytes(r.upload)}</td>
<td className="px-3 py-1.5 text-right">{formatBytes(r.download)}</td>
<td className="px-3 py-1.5 text-right">{ratioText(r)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
</div>
);
}
@@ -1,171 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { type Site, SiteCredentialsPut } from "@umt/contracts";
import { KeyRound, LoaderCircle, UserRound } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { usePutSiteCredentials } from "~/api/instances.ts";
import { siteKeys, useSiteAccounts } from "~/api/sites.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { errorMessage } from "~/lib/api.ts";
import { ACCOUNT_STATUS, relTime } from "./release-fields.ts";
const AUTH_KINDS = [
{ value: "cookie", label: "Cookie" },
{ value: "passkey", label: "Passkey" },
{ value: "api_key", label: "API Key" },
{ value: "password", label: "账号密码" },
] as const;
type AuthKind = (typeof AUTH_KINDS)[number]["value"];
/** 站点详情"账号"页签:已录入账号 + 按标签新建或替换凭证(凭证只写,不回显) */
export function SiteAccountsPanel({ site }: { site: Site }) {
const accounts = useSiteAccounts(site.id);
const put = usePutSiteCredentials();
const qc = useQueryClient();
const [v, setV] = useState({ label: "default", authKind: "cookie" as AuthKind, username: "", credential: "" });
const [errors, setErrors] = useState<Record<string, string>>({});
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const parsed = SiteCredentialsPut.safeParse({
label: v.label.trim() || "default",
authKind: v.authKind,
credential: v.credential.trim(),
username: v.authKind === "password" ? v.username.trim() || undefined : undefined,
});
if (!parsed.success) {
const fe = zodFieldErrors(parsed.error.issues);
if (fe.credential) fe.credential = "填写凭证";
setErrors(fe);
return;
}
setErrors({});
try {
const r = await put.mutateAsync({ siteId: site.id, body: parsed.data });
toast.success(r.created ? "已添加站点账号" : "已替换凭证");
setV((s) => ({ ...s, credential: "" }));
void qc.invalidateQueries({ queryKey: siteKeys.all });
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
const busy = put.isPending;
return (
<div className="grid gap-6 pt-2 lg:grid-cols-2">
<DetailSection title="站点账号">
<DataTable
rows={accounts.data?.items}
rowKey={(r) => r.id}
loading={accounts.isPending}
error={accounts.error}
onRetry={() => void accounts.refetch()}
empty={{ icon: UserRound, title: "还没有账号", description: "保存凭证后出现在这里。" }}
columns={[
{
key: "label",
header: "标签",
cell: (r) => (
<div className="min-w-0">
<p className="truncate font-medium text-foreground">{r.label}</p>
<p className="text-meta text-muted-foreground">
{AUTH_KINDS.find((k) => k.value === r.authKind)?.label ?? r.authKind}
</p>
</div>
),
},
{
key: "status",
header: "状态",
cell: (r) => (
<div className="flex flex-wrap gap-1">
<StatusBadge tone={ACCOUNT_STATUS[r.status].tone}>{ACCOUNT_STATUS[r.status].label}</StatusBadge>
{!r.enabled ? <StatusBadge>已停用</StatusBadge> : null}
</div>
),
},
{
key: "verified",
header: "上次验证",
hideBelow: "sm",
cell: (r) => <span className="text-muted-foreground">{relTime(r.lastVerifiedAt)}</span>,
},
]}
/>
</DetailSection>
<form
onSubmit={submit}
noValidate
className="pg-slab grid content-start gap-x-4 gap-y-1 rounded-xl p-5 sm:grid-cols-2"
>
<p className="mb-3 text-caption text-muted-foreground sm:col-span-2">
同一标签再次保存会替换凭证。凭证加密保存,不回显。
</p>
<FormField label="标签" htmlFor="acct-label" error={errors.label} hint="同一站点多个账号时区分">
<Input
id="acct-label"
value={v.label}
aria-invalid={!!errors.label}
onChange={(e) => setV((s) => ({ ...s, label: e.target.value }))}
disabled={busy}
/>
</FormField>
<FormField label="凭证类型" htmlFor="acct-kind">
<Select value={v.authKind} onValueChange={(x) => setV((s) => ({ ...s, authKind: x as AuthKind }))}>
<SelectTrigger id="acct-kind" className="w-full" disabled={busy}>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{AUTH_KINDS.map((k) => (
<SelectItem key={k.value} value={k.value}>
{k.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
{v.authKind === "password" ? (
<FormField label="登录名" htmlFor="acct-user" error={errors.username} className="sm:col-span-2">
<Input
id="acct-user"
value={v.username}
autoComplete="off"
onChange={(e) => setV((s) => ({ ...s, username: e.target.value }))}
disabled={busy}
/>
</FormField>
) : null}
<FormField
label={v.authKind === "password" ? "密码" : "凭证"}
htmlFor="acct-cred"
required
error={errors.credential}
className="sm:col-span-2"
>
<Input
id="acct-cred"
type="password"
autoComplete="new-password"
value={v.credential}
aria-invalid={!!errors.credential}
placeholder={v.authKind === "cookie" ? "粘贴整段 Cookie" : undefined}
onChange={(e) => setV((s) => ({ ...s, credential: e.target.value }))}
disabled={busy}
/>
</FormField>
<div className="sm:col-span-2">
<Button type="submit" size="sm" disabled={busy}>
{busy ? <LoaderCircle className="animate-spin" /> : <KeyRound />}
保存凭证
</Button>
</div>
</form>
</div>
);
}
@@ -1,174 +0,0 @@
import { SiteAdapter, SiteCreate } from "@umt/contracts";
import { LoaderCircle } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { useCreateSite, useSiteDefinitions, useSites } from "~/api/sites.ts";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
export const ADAPTER_LABEL: Record<SiteAdapter, string> = {
nexusphp: "NexusPHP",
mteam_api: "M-Team API",
api: "站点 API",
generic_rss: "通用 RSS",
};
const CUSTOM = "__custom";
type Draft = { key: string; name: string; adapter: SiteAdapter; baseUrl: string; snapshotEnabled: boolean };
const EMPTY: Draft = { key: "", name: "", adapter: "nexusphp", baseUrl: "", snapshotEnabled: true };
/** 登记站点:从内置站点定义选,或自定义。登记后去站点详情的"账号"页签填凭证 */
export function SiteRegisterDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
const defs = useSiteDefinitions();
const sites = useSites();
const create = useCreateSite();
const navigate = useNavigate();
const [preset, setPreset] = useState(CUSTOM);
const [d, setD] = useState<Draft>(EMPTY);
const [errors, setErrors] = useState<Record<string, string>>({});
const taken = new Set((sites.data?.items ?? []).map((s) => s.key));
const available = (defs.data?.items ?? []).filter((x) => !taken.has(x.key));
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => setD((s) => ({ ...s, [k]: v }));
const pick = (key: string) => {
setPreset(key);
const def = available.find((x) => x.key === key);
setD(
def ? { key: def.key, name: def.name, adapter: def.adapter, baseUrl: def.baseUrl, snapshotEnabled: true } : EMPTY,
);
setErrors({});
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const parsed = SiteCreate.safeParse({ ...d, key: d.key.trim(), name: d.name.trim(), baseUrl: d.baseUrl.trim() });
if (!parsed.success) {
const fe = zodFieldErrors(parsed.error.issues);
if (fe.key) fe.key = "小写字母开头,只含小写字母、数字、- 和 _";
if (fe.baseUrl) fe.baseUrl = "填完整地址,如 https://example.org";
setErrors(fe);
return;
}
if (taken.has(parsed.data.key)) {
setErrors({ key: "这个标识已被占用" });
return;
}
setErrors({});
try {
const site = await create.mutateAsync(parsed.data);
toast.success(`已登记 ${site.name}`);
onOpenChange(false);
navigate(`${consolePath("sites", site.id)}?tab=accounts`);
} catch (err) {
toast.error(errorMessage(err, "登记失败"));
}
};
const busy = create.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>登记站点</DialogTitle>
<DialogDescription>登记后在站点的"账号"页签填写凭证。</DialogDescription>
</DialogHeader>
<form id="site-register" onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
<FormField label="站点定义" htmlFor="site-preset" className="sm:col-span-2">
<Select value={preset} onValueChange={pick} disabled={busy}>
<SelectTrigger id="site-preset" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={CUSTOM}>自定义</SelectItem>
{available.map((x) => (
<SelectItem key={x.key} value={x.key}>
{x.name}
{x.shippedEnabled ? "" : "(未实测)"}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label="名称" htmlFor="site-name" required error={errors.name}>
<Input
id="site-name"
value={d.name}
aria-invalid={!!errors.name}
onChange={(e) => set("name", e.target.value)}
disabled={busy}
/>
</FormField>
<FormField label="标识" htmlFor="site-key" required error={errors.key} hint="登记后不能改">
<Input
id="site-key"
value={d.key}
aria-invalid={!!errors.key}
onChange={(e) => set("key", e.target.value)}
disabled={busy || preset !== CUSTOM}
className="font-mono"
/>
</FormField>
<FormField label="类型" htmlFor="site-adapter">
<Select value={d.adapter} onValueChange={(v) => set("adapter", v as SiteAdapter)} disabled={busy}>
<SelectTrigger id="site-adapter" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{SiteAdapter.options.map((a) => (
<SelectItem key={a} value={a}>
{ADAPTER_LABEL[a]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label="地址" htmlFor="site-url" required error={errors.baseUrl}>
<Input
id="site-url"
inputMode="url"
value={d.baseUrl}
aria-invalid={!!errors.baseUrl}
onChange={(e) => set("baseUrl", e.target.value)}
placeholder="https://"
disabled={busy}
/>
</FormField>
<div className="flex items-center gap-2 pb-4 sm:col-span-2">
<Switch
id="site-snapshot"
checked={d.snapshotEnabled}
onCheckedChange={(v) => set("snapshotEnabled", v)}
disabled={busy}
/>
<label htmlFor="site-snapshot" className="text-caption text-foreground">
定时抓取快照
</label>
</div>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
取消
</Button>
<Button type="submit" form="site-register" disabled={busy}>
{busy ? <LoaderCircle className="animate-spin" /> : null}
登记
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -131,7 +131,7 @@ export default function DownloadsPage() {
empty={{
icon: Download,
title: f.active ? "没有符合筛选的下载任务" : "还没有下载任务",
description: f.active ? undefined : "自动收录或在站点工作台下载后出现在这里。",
description: f.active ? undefined : "自动收录或在站点页下载后出现在这里。",
}}
/>
+106 -40
View File
@@ -1,11 +1,23 @@
import type { ResolutionStatus, WorkbenchRelease } from "@umt/contracts";
import { ArrowLeft, Bot, Camera, Globe, Plus, Radar, SearchX, TriangleAlert } from "lucide-react";
import {
Activity,
ArrowLeft,
Bot,
Camera,
Globe,
LoaderCircle,
Plus,
Radar,
RefreshCw,
SearchX,
TriangleAlert,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link, useParams, useSearchParams } from "react-router";
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useInventory } from "~/api/inventory.ts";
import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts";
import { snapshotStale, useSite, useSiteAccounts, useSnapshotSite } from "~/api/sites.ts";
import { snapshotStale, useSites, useSnapshotSite } from "~/api/sites.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
@@ -13,21 +25,17 @@ import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { SiteAccountsTab } from "~/components/sites/site-accounts-tab.tsx";
import { SiteMoreMenu } from "~/components/sites/site-card.tsx";
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
import { ACCOUNT_OVERALL, healthLine, hostOf, overallStatus, primaryAccount } from "~/components/sites/site-fields.ts";
import { SiteOverview } from "~/components/sites/site-overview.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { FeedTable } from "~/components/workbench/feed-table.tsx";
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
import {
accountSummary,
categoryLabel,
circuitLabel,
hrOf,
isFree,
relTime,
SITE_HEALTH,
} from "~/components/workbench/release-fields.ts";
import { categoryLabel, circuitLabel, hrOf, isFree, relTime } from "~/components/workbench/release-fields.ts";
import { ReleaseRow } from "~/components/workbench/release-list.tsx";
import { SiteAccountsPanel } from "~/components/workbench/site-accounts.tsx";
import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx";
import { errorMessage } from "~/lib/api.ts";
@@ -89,18 +97,23 @@ function useDebounced<T>(value: T, ms = 400) {
return v;
}
const TABS = ["organized", "origin", "snapshots", "accounts"] as const;
const TABS = ["overview", "resources", "snapshots", "accounts"] as const;
/** 旧页签(整理视图 / 原站视图)落到"资源" */
const LEGACY_TAB: Record<string, string> = { organized: "resources", origin: "resources" };
export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }];
export default function SiteWorkbenchPage() {
const { siteId = "" } = useParams();
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const rawTab = params.get("tab") ?? "";
const tab = (TABS as readonly string[]).includes(rawTab) ? rawTab : "organized";
const mapped = LEGACY_TAB[rawTab] ?? rawTab;
const tab = (TABS as readonly string[]).includes(mapped) ? mapped : "overview";
const [creatingFeed, setCreatingFeed] = useState(false);
const site = useSite(siteId);
const accounts = useSiteAccounts(siteId);
const [dialog, setDialog] = useState<SiteDialog | null>(null);
const checks = useSiteChecks(setDialog);
const sites = useSites();
const snap = useSnapshotSite();
const [qInput, setQInput] = useState("");
@@ -118,7 +131,7 @@ export default function SiteWorkbenchPage() {
promotion: promotion || undefined,
resolution: (resolution || undefined) as ResolutionStatus | undefined,
};
const releases = useSiteReleases(siteId, filters, live);
const releases = useSiteReleases(tab === "resources" ? siteId : "", filters, live);
// 实时补查只对当前搜索生效一次
// biome-ignore lint/correctness/useExhaustiveDependencies: 条件变化时回到快照优先
useEffect(() => setLive(false), [q, category, promotion, resolution]);
@@ -137,7 +150,7 @@ export default function SiteWorkbenchPage() {
return [...m].map(([value, label]) => ({ value, label }));
}, [all, category]);
const s = site.data;
const s = sites.data?.items.find((x) => x.id === siteId);
const refreshFailed = !!releases.error && !!releases.data;
// 刷新失败时整页数据都标过期;否则早于 2 个快照周期确认的条目标过期
const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null;
@@ -153,27 +166,49 @@ export default function SiteWorkbenchPage() {
};
const anyFilter = !!(qInput || category || promotion || resolution || localActive);
if (site.error && !s) return <ErrorState error={site.error} onRetry={() => void site.refetch()} />;
if (sites.error && !sites.data) return <ErrorState error={sites.error} onRetry={() => void sites.refetch()} />;
if (sites.data && !s)
return (
<EmptyState
icon={Globe}
title="站点不存在或已删除"
description="回到站点页查看现有站点"
action={
<Button asChild size="sm" variant="outline">
<Link to={consolePath("sites")}>
<ArrowLeft />
返回站点
</Link>
</Button>
}
/>
);
const health = s ? SITE_HEALTH[s.healthStatus] : null;
const account = accountSummary(accounts.data?.items ?? []);
const account = s ? ACCOUNT_OVERALL[overallStatus(s)] : null;
const health = s ? healthLine(s.lastHealth, s.lastHealthAt) : null;
const circuit = s ? circuitLabel(s) : null;
const hasAccount = !!s?.accounts?.some((a) => a.enabled && a.authKind !== "passkey");
return (
<>
<PageHeader
title={s?.name ?? "站点工作台"}
title={s?.name ?? "站点"}
description={
s ? (
<span className="flex flex-wrap items-center gap-1.5">
{health ? <StatusBadge tone={health.tone}>{health.label}</StatusBadge> : null}
<StatusBadge tone={account.tone}>账号:{account.label}</StatusBadge>
<a
href={s.baseUrl}
target="_blank"
rel="noreferrer noopener"
className="text-meta text-muted-foreground hover:text-foreground hover:underline"
>
{hostOf(s.baseUrl)}
</a>
{account ? <StatusBadge tone={account.tone}>账号{account.label}</StatusBadge> : null}
{health ? <StatusBadge tone={health.tone}>健康 {health.text}</StatusBadge> : null}
{!s.enabled ? <StatusBadge tone="warning">站点已停用,只显示历史数据</StatusBadge> : null}
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
<span className="text-meta text-muted-foreground">
快照 {relTime(coverage?.lastSnapshotAt ?? s.lastSnapshotAt)}
</span>
{snapshotStale(s) ? <StatusBadge tone="warning">快照过期</StatusBadge> : null}
{s.snapshotEnabled && snapshotStale(s) ? <StatusBadge tone="warning">快照过期</StatusBadge> : null}
</span>
) : (
"加载中…"
@@ -184,15 +219,40 @@ export default function SiteWorkbenchPage() {
<Button asChild variant="ghost" size="sm">
<Link to={consolePath("sites")}>
<ArrowLeft />
站点工作台
站点
</Link>
</Button>
{s ? (
<>
<Button
variant="outline"
size="sm"
disabled={!s.enabled || checks.isBusy(s.id, "health")}
onClick={() => void checks.checkHealth(s)}
>
{checks.isBusy(s.id, "health") ? <LoaderCircle className="animate-spin" /> : <Activity />}
<span className="sr-only sm:not-sr-only">检测健康</span>
</Button>
<Button
variant="outline"
size="sm"
disabled={!hasAccount || checks.isBusy(s.id, "login")}
title={hasAccount ? undefined : "先填写凭证"}
onClick={() => void checks.refreshLogin(s, primaryAccount(s.accounts))}
>
{checks.isBusy(s.id, "login") ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
<span className="sr-only sm:not-sr-only">刷新登录</span>
</Button>
<SiteMoreMenu site={s} onDialog={setDialog} />
</>
) : null}
<Button
variant="ghost"
size="sm"
onClick={() => openAgentDrawer({ label: `站点:${s?.name ?? siteId}`, siteId })}
>
<Bot />问 Agent
<Bot />
<span className="sr-only sm:not-sr-only">问 Agent</span>
</Button>
</>
}
@@ -203,7 +263,7 @@ export default function SiteWorkbenchPage() {
onValueChange={(v) =>
setParams(
(p) => {
if (v === "organized") p.delete("tab");
if (v === "overview") p.delete("tab");
else p.set("tab", v);
return p;
},
@@ -212,13 +272,15 @@ export default function SiteWorkbenchPage() {
}
>
<TabsList aria-label="站点分节">
<TabsTrigger value="organized">整理视图</TabsTrigger>
<TabsTrigger value="origin">原站视图</TabsTrigger>
<TabsTrigger value="overview">概览</TabsTrigger>
<TabsTrigger value="resources">资源</TabsTrigger>
<TabsTrigger value="snapshots">快照与 Feed</TabsTrigger>
<TabsTrigger value="accounts">账号</TabsTrigger>
</TabsList>
<TabsContent value="organized">
<TabsContent value="overview">{s ? <SiteOverview site={s} /> : <LoadingState rows={4} />}</TabsContent>
<TabsContent value="resources">
<FilterBar
search={{ value: qInput, onChange: setQInput, placeholder: "搜索标题 / 副标题 / 站点 ID" }}
onReset={anyFilter ? reset : undefined}
@@ -357,10 +419,6 @@ export default function SiteWorkbenchPage() {
</div>
</TabsContent>
<TabsContent value="origin">
<EmptyState icon={Globe} title="原站视图暂未提供" description={<>资源详情里有站点 ID,可据此到原站查看。</>} />
</TabsContent>
<TabsContent value="snapshots">
<div className="space-y-8">
{s ? <SnapshotPanel site={s} /> : <LoadingState rows={3} />}
@@ -378,9 +436,17 @@ export default function SiteWorkbenchPage() {
</div>
</TabsContent>
<TabsContent value="accounts">{s ? <SiteAccountsPanel site={s} /> : <LoadingState rows={3} />}</TabsContent>
<TabsContent value="accounts">
{s ? <SiteAccountsTab site={s} checks={checks} onDialog={setDialog} /> : <LoadingState rows={3} />}
</TabsContent>
</Tabs>
<SiteDialogHost
dialog={dialog}
onClose={() => setDialog(null)}
onDeleted={() => navigate(consolePath("sites"))}
/>
<ReleaseDetailSheet
siteId={siteId}
release={selected}
+174 -119
View File
@@ -1,167 +1,222 @@
import type { Site, SiteAccount } from "@umt/contracts";
import { ArrowRight, Camera, Globe, Plus, RefreshCw } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { snapshotStale, useSiteAccounts, useSites, useSnapshotSite, useToggleSite } from "~/api/sites.ts";
import { Globe, LayoutGrid, List, Plus, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import { useSites } from "~/api/sites.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.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 { Switch } from "~/components/ui/switch.tsx";
import { accountSummary, circuitLabel, relTime, SITE_HEALTH } from "~/components/workbench/release-fields.ts";
import { ADAPTER_LABEL, SiteRegisterDialog } from "~/components/workbench/site-register-dialog.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
const toggle = useToggleSite();
const snap = useSnapshotSite();
const health = SITE_HEALTH[site.healthStatus];
const account = accountSummary(accounts);
const stale = site.snapshotEnabled && snapshotStale(site);
const circuit = circuitLabel(site);
// 资源 → 站点:站点、凭证、健康、账号数据的唯一入口(docs/design/site-center.md §4)
return (
<div className="pg-slab flex min-w-0 flex-col rounded-xl p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Link
to={consolePath("sites", site.id)}
className="block truncate text-lead font-semibold text-foreground hover:underline"
>
{site.name}
</Link>
<p className="truncate text-meta text-muted-foreground">
{site.key} · {ADAPTER_LABEL[site.adapter]}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-meta text-muted-foreground">
<label htmlFor={`site-${site.id}`}>{site.enabled ? "已启用" : "已停用"}</label>
<Switch
id={`site-${site.id}`}
checked={site.enabled}
disabled={toggle.isPending}
aria-label={site.enabled ? "停用站点" : "启用站点"}
onCheckedChange={(v) =>
toggle.mutate(
{ id: site.id, enabled: v },
{
onSuccess: () => toast.success(v ? `已启用 ${site.name}` : `已停用 ${site.name}`),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
</div>
</div>
const VIEW_KEY = "umt.sites.view";
type View = "cards" | "list";
type Filter = "all" | "enabled" | "attention";
<div className="mt-3 flex flex-wrap gap-1.5">
<StatusBadge tone={health.tone}>{health.label}</StatusBadge>
<Link to={`${consolePath("sites", site.id)}?tab=accounts`} className="hover:opacity-80">
<StatusBadge tone={account.tone}>账号:{account.label}</StatusBadge>
</Link>
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
</div>
const FILTERS: { value: Filter; label: string }[] = [
{ value: "all", label: "全部" },
{ value: "enabled", label: "已启用" },
{ value: "attention", label: "需处理" },
];
const SORTS: { value: SortKey; label: string }[] = [
{ value: "name", label: "名称" },
{ value: "ratio", label: "分享率" },
{ value: "upload", label: "上传" },
{ value: "lastLogin", label: "上次登录" },
];
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-caption">
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">最近快照</dt>
<dd className="flex flex-wrap items-center gap-1.5 text-foreground">
{site.snapshotEnabled ? relTime(site.lastSnapshotAt) : "未开启快照"}
{stale ? <StatusBadge tone="warning">已过期</StatusBadge> : null}
</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">快照周期</dt>
<dd className="text-foreground">
{site.snapshotIntervalSec ? `${Math.round(site.snapshotIntervalSec / 60)} 分钟` : "默认"}
</dd>
</div>
</dl>
<div className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
<Button
variant="outline"
size="sm"
disabled={!site.enabled || snap.isPending}
title={site.enabled ? "抓取快照(受站点限流)" : "站点已停用"}
onClick={() =>
snap.mutate(
{ id: site.id },
{
onSuccess: () => toast.success("快照已排队"),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
>
<Camera />
快照
</Button>
<Button asChild size="sm" variant="outline">
<Link to={consolePath("sites", site.id)}>
打开
<ArrowRight />
</Link>
</Button>
</div>
</div>
);
function readStoredView(): View | null {
try {
const v = localStorage.getItem(VIEW_KEY);
return v === "cards" || v === "list" ? v : null;
} catch {
return null;
}
}
export const meta = () => [{ title: "站点工作台 · UHD 媒体自动化控制台" }];
export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }];
export default function SitesPage() {
const sites = useSites();
const [registering, setRegistering] = useState(false);
const accounts = useSiteAccounts();
const bySite = new Map<string, SiteAccount[]>();
for (const a of accounts.data?.items ?? []) bySite.set(a.siteId, [...(bySite.get(a.siteId) ?? []), a]);
const [params, setParams] = useSearchParams();
const [adding, setAdding] = useState(false);
const [dialog, setDialog] = useState<SiteDialog | null>(null);
const checks = useSiteChecks(setDialog);
const urlView = params.get("view");
const [storedView, setStoredView] = useState<View>("cards");
useEffect(() => {
const v = readStoredView();
if (v) setStoredView(v);
}, []);
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 setParam = (k: string, v: string | null) =>
setParams(
(p) => {
if (v) p.set(k, v);
else p.delete(k);
return p;
},
{ replace: true },
);
const setView = (v: View) => {
setStoredView(v);
try {
localStorage.setItem(VIEW_KEY, v);
} catch {
// 隐私模式等:只在本次会话记住
}
setParam("view", v);
};
// 待处理"改用 Cookie"深链:?credentials=<siteId>
const credFor = params.get("credentials");
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在数据到达时处理一次
useEffect(() => {
if (!credFor || !sites.data) return;
const s = sites.data.items.find((x) => x.id === credFor);
if (s) setDialog({ kind: "credentials", site: s, loginKind: "cookie" });
setParam("credentials", null);
}, [credFor, sites.data]);
const all = sites.data?.items ?? [];
const attention = all.filter(needsAttention).length;
const shown = sortSites(
all.filter((s) => (filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true)),
sort,
);
return (
<>
<PageHeader
title="站点工作台"
title="站点"
description="凭证、账号数据和健康状态;点站点名看资源、快照与 Feed"
actions={
<>
<Button variant="outline" size="sm" onClick={() => void sites.refetch()} disabled={sites.isFetching}>
<RefreshCw className={sites.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
<Button size="sm" onClick={() => setRegistering(true)}>
<Button size="sm" onClick={() => setAdding(true)}>
<Plus />
登记站点
添加站点
</Button>
</>
}
/>
{all.length ? (
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Segmented
label="筛选"
value={filter}
onChange={(v) => setParam("filter", v === "all" ? null : v)}
options={FILTERS.map((f) => ({
...f,
label: f.value === "attention" && attention ? `${f.label} ${attention}` : f.label,
}))}
/>
<FilterSelect
label="排序"
value={sort === "name" ? "" : sort}
onChange={(v) => setParam("sort", v || null)}
allLabel="名称"
options={SORTS.filter((x) => x.value !== "name")}
/>
</div>
<Segmented
label="视图"
value={view}
onChange={(v) => setView(v as View)}
options={[
{ value: "cards", label: "卡片", icon: <LayoutGrid className="h-3.5 w-3.5" /> },
{ value: "list", label: "列表", icon: <List className="h-3.5 w-3.5" /> },
]}
/>
</div>
) : null}
{sites.isPending ? (
<div className="pg-slab rounded-xl p-4">
<LoadingState rows={4} />
</div>
) : sites.error && !sites.data ? (
<ErrorState error={sites.error} onRetry={() => void sites.refetch()} />
) : !sites.data?.items.length ? (
) : !all.length ? (
<EmptyState
icon={Globe}
title="还没有登记站点"
description="登记站点并填写账号后,在这里浏览和搜索资源。"
title="还没有站点"
description="添加站点并填写凭证后,这里显示账号数据和健康状态"
action={
<Button size="sm" onClick={() => setRegistering(true)}>
<Button size="sm" onClick={() => setAdding(true)}>
<Plus />
登记站点
添加站点
</Button>
}
/>
) : !shown.length ? (
<EmptyState
icon={Globe}
title={filter === "attention" ? "没有需处理的站点" : "没有已启用的站点"}
description="切换到「全部」查看其余站点"
/>
) : view === "list" ? (
<SiteTable sites={shown} checks={checks} onDialog={setDialog} />
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{sites.data.items.map((s) => (
<SiteCard key={s.id} site={s} accounts={bySite.get(s.id) ?? []} />
{shown.map((s) => (
<SiteCard key={s.id} site={s} checks={checks} onDialog={setDialog} />
))}
</div>
)}
{registering ? <SiteRegisterDialog open onOpenChange={setRegistering} /> : null}
{adding ? <SiteAddDialog onClose={() => setAdding(false)} /> : null}
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} />
</>
);
}
function Segmented({
label,
value,
onChange,
options,
}: {
label: string;
value: string;
onChange: (v: string) => void;
options: { value: string; label: string; icon?: React.ReactNode }[];
}) {
return (
<div role="radiogroup" aria-label={label} className="inline-flex gap-0.5 rounded-xl border border-border p-0.5">
{options.map((o) => (
// biome-ignore lint/a11y/useSemanticElements: 分段按钮
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
onClick={() => onChange(o.value)}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-caption transition-colors",
value === o.value
? "bg-foreground text-background"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{o.icon}
{o.label}
</button>
))}
</div>
);
}
+2 -2
View File
@@ -84,7 +84,7 @@ export default function SourcesPage() {
<>
<PageHeader
title="来源健康"
description="单个站点的快照、Feed 和账号在站点工作台管理。"
description="单个站点的快照、Feed 和账号在站点页管理。"
actions={
<Button size="sm" onClick={() => setCreating(true)}>
<Plus />
@@ -104,7 +104,7 @@ export default function SourcesPage() {
empty={{
icon: Globe,
title: "还没有登记站点",
description: "在站点工作台登记。",
description: "在站点页添加。",
}}
/>
</DetailSection>
+1
View File
@@ -29,6 +29,7 @@
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.4",
"react-router": "7.12.0",
"recharts": "^3.10.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6"
+5
View File
@@ -33,6 +33,8 @@ const STATIC_ROUTES = [
"/console/rules",
"/console/preferences",
"/console/sites",
"/console/sites?view=list",
"/console/sites?filter=attention",
"/console/sources",
"/console/downloads",
"/console/downloads/plans",
@@ -59,6 +61,9 @@ const DETAIL_ROUTES = [
{ route: "/console/library/:id", list: "/api/v1/library" },
{ route: "/console/organize/:id", list: "/api/v1/organization-records" },
{ route: "/console/sites/:id", list: "/api/v1/sites" },
{ route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=resources" },
// 旧页签"整理视图"落到"资源"
{ route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=organized" },
{ route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=snapshots" },
{ route: "/console/sites/:id", list: "/api/v1/sites", after: "?tab=accounts" },
{ route: "/console/downloads/:id", list: "/api/v1/download-tasks" },
Generated
+207
View File
@@ -45,6 +45,7 @@
"npm:react-dom@^19.2.4": "[email protected]",
"npm:[email protected]": "[email protected][email protected]",
"npm:react@^19.2.4": "19.3.0",
"npm:recharts@^3.10.1": "[email protected][email protected][email protected]_@[email protected][email protected]",
"npm:shadcn@^3.8.5": "3.8.5_@[email protected][email protected]",
"npm:sonner@^2.0.7": "2.0.8_@[email protected][email protected][email protected]",
"npm:tailwind-merge@^3.5.0": "3.7.0",
@@ -2110,6 +2111,23 @@
"typescript"
]
},
"@reduxjs/[email protected][email protected][email protected]_@[email protected]": {
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"dependencies": [
"@standard-schema/spec",
"@standard-schema/utils",
"immer",
"react",
"react-redux",
"redux",
"redux-thunk",
"reselect"
],
"optionalPeers": [
"react",
"react-redux"
]
},
"@remix-run/[email protected]": {
"integrity": "sha512-SoLMv7dbH+njWzXnOY6fI08dFMI5+/dQ+vY3n8RnnbdG7MdJEgiP28Xj/xWlnRnED/aB6SFw56Zop+LbmaaKqA=="
},
@@ -2292,6 +2310,12 @@
"@stablelib/[email protected]": {
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="
},
"@standard-schema/[email protected]": {
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
},
"@standard-schema/[email protected]": {
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
},
"@tailwindcss/[email protected]": {
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
"dependencies": [
@@ -2414,6 +2438,42 @@
"assertion-error"
]
},
"@types/[email protected]": {
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
},
"@types/[email protected]": {
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"@types/[email protected]": {
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"@types/[email protected]": {
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dependencies": [
"@types/d3-color"
]
},
"@types/[email protected]": {
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
},
"@types/[email protected]": {
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dependencies": [
"@types/d3-time"
]
},
"@types/[email protected]": {
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
"dependencies": [
"@types/d3-path"
]
},
"@types/[email protected]": {
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"@types/[email protected]": {
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"@types/[email protected]": {
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="
},
@@ -2461,6 +2521,9 @@
"@types/[email protected]": {
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="
},
"@types/[email protected]": {
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
},
"@types/[email protected]": {
"integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="
},
@@ -2956,6 +3019,61 @@
"[email protected]": {
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"[email protected]": {
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": [
"internmap"
]
},
"[email protected]": {
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="
},
"[email protected]": {
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="
},
"[email protected]": {
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="
},
"[email protected]": {
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": [
"d3-color"
]
},
"[email protected]": {
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="
},
"[email protected]": {
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dependencies": [
"d3-array",
"d3-format",
"d3-interpolate",
"d3-time",
"d3-time-format"
]
},
"[email protected]": {
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dependencies": [
"d3-path"
]
},
"[email protected]": {
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dependencies": [
"d3-time"
]
},
"[email protected]": {
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"dependencies": [
"d3-array"
]
},
"[email protected]": {
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="
},
"[email protected]": {
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="
},
@@ -2977,6 +3095,9 @@
"ms"
]
},
"[email protected]": {
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
},
"[email protected]": {
"integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="
},
@@ -3137,6 +3258,9 @@
"es-errors"
]
},
"[email protected]": {
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="
},
"[email protected]": {
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"optionalDependencies": [
@@ -3189,6 +3313,9 @@
"[email protected]": {
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="
},
"[email protected]": {
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
},
"[email protected]": {
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="
},
@@ -3612,6 +3739,9 @@
"[email protected]": {
"integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="
},
"[email protected]": {
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ=="
},
"[email protected]": {
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dependencies": [
@@ -3622,6 +3752,9 @@
"[email protected]": {
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"[email protected]": {
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="
},
"[email protected]": {
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w=="
},
@@ -4596,6 +4729,23 @@
"scheduler"
]
},
"[email protected]": {
"integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA=="
},
"[email protected]_@[email protected][email protected][email protected]": {
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"dependencies": [
"@types/react",
"@types/use-sync-external-store",
"react",
"redux",
"use-sync-external-store"
],
"optionalPeers": [
"@types/react",
"redux"
]
},
"[email protected]": {
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="
},
@@ -4672,12 +4822,43 @@
"tslib"
]
},
"[email protected][email protected][email protected][email protected]_@[email protected][email protected]": {
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
"dependencies": [
"@reduxjs/toolkit",
"clsx",
"decimal.js-light",
"es-toolkit",
"eventemitter3",
"immer",
"react",
"react-dom",
"react-is",
"react-redux",
"reselect",
"tiny-invariant",
"use-sync-external-store",
"victory-vendor"
]
},
"[email protected][email protected]": {
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"dependencies": [
"redux"
]
},
"[email protected]": {
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"[email protected]": {
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="
},
"[email protected]": {
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="
},
"[email protected]": {
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="
},
"[email protected]": {
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
},
@@ -5195,6 +5376,12 @@
"@types/react"
]
},
"[email protected][email protected]": {
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
"dependencies": [
"react"
]
},
"[email protected]": {
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
@@ -5213,6 +5400,25 @@
"[email protected]": {
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="
},
"[email protected]": {
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"dependencies": [
"@types/d3-array",
"@types/d3-ease",
"@types/d3-interpolate",
"@types/d3-scale",
"@types/d3-shape",
"@types/d3-time",
"@types/d3-timer",
"d3-array",
"d3-ease",
"d3-interpolate",
"d3-scale",
"d3-shape",
"d3-time",
"d3-timer"
]
},
"[email protected]_@[email protected][email protected]": {
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dependencies": [
@@ -5446,6 +5652,7 @@
"npm:react-dom@^19.2.4",
"npm:[email protected]",
"npm:react@^19.2.4",
"npm:recharts@^3.10.1",
"npm:shadcn@^3.8.5",
"npm:sonner@^2.0.7",
"npm:tailwind-merge@^3.5.0",