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:
1 parent
81bf010e3e
commit
f8362a3f4c
21 files changed
+3075
-1118
No files matched your search
+117
-3
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -68,9 +68,9 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
items: [
|
||||
{
|
||||
to: "/console/sites",
|
||||
label: "站点工作台",
|
||||
label: "站点",
|
||||
icon: Globe,
|
||||
description: "站点、站点账号、快照与 Feed",
|
||||
description: "站点、凭证、账号数据与健康;资源、快照与 Feed",
|
||||
},
|
||||
{
|
||||
to: "/console/sources",
|
||||
|
||||
@@ -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={[
|
||||
const DEFAULTS_CARD: CardSpec = {
|
||||
anchor: "defaults",
|
||||
title: "默认值",
|
||||
description: "站点没有单独设置时使用。限流不会比站点定义自带的更快。",
|
||||
fields: [
|
||||
{
|
||||
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>
|
||||
),
|
||||
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",
|
||||
},
|
||||
{
|
||||
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>;
|
||||
},
|
||||
name: "sites.userDataMaxSeedingPages",
|
||||
label: "做种列表页数上限",
|
||||
kind: "number",
|
||||
hint: "拉取用户数据时最多翻几页做种列表;1–50",
|
||||
},
|
||||
{
|
||||
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 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,
|
||||
name: "sites.userAgent",
|
||||
label: "User-Agent",
|
||||
kind: "text",
|
||||
hint: "账号没填 UA 时,登录和拉取用户数据用这个",
|
||||
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) : "—"),
|
||||
name: "sites.proxyUrl",
|
||||
label: "代理地址",
|
||||
kind: "url",
|
||||
placeholder: "http://127.0.0.1:7890",
|
||||
hint: "只对开了“使用代理”的站点生效;留空不用代理",
|
||||
wide: true,
|
||||
},
|
||||
{ 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>
|
||||
),
|
||||
],
|
||||
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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -131,7 +131,7 @@ export default function DownloadsPage() {
|
||||
empty={{
|
||||
icon: Download,
|
||||
title: f.active ? "没有符合筛选的下载任务" : "还没有下载任务",
|
||||
description: f.active ? undefined : "自动收录或在站点工作台下载后出现在这里。",
|
||||
description: f.active ? undefined : "自动收录或在站点页下载后出现在这里。",
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
)
|
||||
const VIEW_KEY = "umt.sites.view";
|
||||
type View = "cards" | "list";
|
||||
type Filter = "all" | "enabled" | "attention";
|
||||
|
||||
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: "上次登录" },
|
||||
];
|
||||
|
||||
function readStoredView(): View | null {
|
||||
try {
|
||||
const v = localStorage.getItem(VIEW_KEY);
|
||||
return v === "cards" || v === "list" ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user