feat(web): W3-1 shared console routes, nav, api/sse/query-keys and shared components
- routes.ts registers all workspaces under /console (+ detail routes, engine, audit, login) - api.ts: cookie session first (credentials include, CSRF header), Bearer only with stored key, 401 -> /login - sse.ts: shared EventSource with ?after=seq catch-up, backoff reconnect, per-type subscribe - shared components: data-table, filter-bar, detail-sheet, timeline, json-view, confirm-dialog, form-field, kv-list, stat-tile, error/loading states; ui tabs and textarea Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
a24bda952b
commit
e5e42b1f0d
32 files changed
+1271
-56
No files matched your search
@@ -11,3 +11,4 @@ coverage/
|
||||
.DS_Store
|
||||
.vite/
|
||||
types/deno.d.ts
|
||||
apps/web/test/screenshots/
|
||||
@@ -6,7 +6,7 @@ import { apiData, TTL_SECOND } from "~/lib/api.ts";
|
||||
export function useHealth() {
|
||||
return useQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: ({ signal }) => apiData<HealthStatus>("/api/v1/health", { signal }),
|
||||
queryFn: ({ signal }) => apiData<HealthStatus>("/health", { signal, noAuthRedirect: true }),
|
||||
refetchInterval: 15 * TTL_SECOND,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
|
||||
/**
|
||||
* 确认弹窗。onConfirm 可返回 Promise:执行中按钮禁用并转圈,resolve 后自动关闭,reject 时保持打开
|
||||
* (错误提示由调用方 toast)。受控(open/onOpenChange)或非受控(trigger)两种用法。
|
||||
* <ConfirmDialog trigger={<Button variant="destructive">删除</Button>} title="删除实例?"
|
||||
* description="…" confirmLabel="删除" destructive onConfirm={() => del.mutateAsync(id)} />
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
trigger,
|
||||
open: openProp,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = "确认",
|
||||
cancelLabel = "取消",
|
||||
destructive = false,
|
||||
onConfirm,
|
||||
children,
|
||||
}: {
|
||||
trigger?: ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
destructive?: boolean;
|
||||
onConfirm: () => unknown | Promise<unknown>;
|
||||
/** 描述下方的额外内容(输入确认等) */
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const [inner, setInner] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const open = openProp ?? inner;
|
||||
const setOpen = (o: boolean) => {
|
||||
if (pending) return;
|
||||
setInner(o);
|
||||
onOpenChange?.(o);
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
setPending(false);
|
||||
setInner(false);
|
||||
onOpenChange?.(false);
|
||||
} catch {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
{trigger ? <AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger> : null}
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
|
||||
</AlertDialogHeader>
|
||||
{children}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={pending}>{cancelLabel}</AlertDialogCancel>
|
||||
<Button variant={destructive ? "destructive" : "default"} onClick={run} disabled={pending}>
|
||||
{pending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "./empty-state.tsx";
|
||||
|
||||
export type DataColumn<T> = {
|
||||
/** 列 key(React key 用) */
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
cell: (row: T) => ReactNode;
|
||||
/** 窄屏隐藏该列,避免横向溢出 */
|
||||
hideBelow?: "sm" | "md" | "lg";
|
||||
align?: "left" | "right";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const HIDE: Record<NonNullable<DataColumn<unknown>["hideBelow"]>, string> = {
|
||||
sm: "hidden sm:table-cell",
|
||||
md: "hidden md:table-cell",
|
||||
lg: "hidden lg:table-cell",
|
||||
};
|
||||
|
||||
/**
|
||||
* 数据表:内置 loading / error / empty 三态与"加载更多"游标翻页。
|
||||
* 外层自带 pg-slab 卡片(plain 时不带);表格在卡片内横向滚动,页面本身不溢出。
|
||||
*
|
||||
* <DataTable columns={cols} rows={q.data?.items} rowKey={(r) => r.id}
|
||||
* loading={q.isPending} error={q.error} onRetry={q.refetch}
|
||||
* empty={{ title: "还没有记录" }} onRowClick={(r) => open(r.id)}
|
||||
* hasMore={!!next} onLoadMore={...} loadingMore={...} />
|
||||
*/
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
rows,
|
||||
rowKey,
|
||||
loading = false,
|
||||
error,
|
||||
onRetry,
|
||||
empty,
|
||||
onRowClick,
|
||||
selectedKey,
|
||||
hasMore = false,
|
||||
onLoadMore,
|
||||
loadingMore = false,
|
||||
plain = false,
|
||||
className,
|
||||
}: {
|
||||
columns: DataColumn<T>[];
|
||||
rows: T[] | undefined;
|
||||
rowKey: (row: T) => string;
|
||||
loading?: boolean;
|
||||
error?: unknown;
|
||||
onRetry?: () => void;
|
||||
empty?: { title: string; description?: ReactNode; icon?: LucideIcon; action?: ReactNode };
|
||||
onRowClick?: (row: T) => void;
|
||||
selectedKey?: string | null;
|
||||
hasMore?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
loadingMore?: boolean;
|
||||
plain?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
let body: ReactNode;
|
||||
if (loading && !rows) {
|
||||
body = <LoadingState rows={5} className="p-4" />;
|
||||
} else if (error && !rows) {
|
||||
body = <ErrorState plain error={error} onRetry={onRetry} />;
|
||||
} else if (!rows || rows.length === 0) {
|
||||
body = (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={empty?.icon}
|
||||
title={empty?.title ?? "暂无数据"}
|
||||
description={empty?.description}
|
||||
action={empty?.action}
|
||||
className="py-12"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{columns.map((c) => (
|
||||
<th
|
||||
key={c.key}
|
||||
scope="col"
|
||||
className={cn(
|
||||
"px-4 py-2.5 text-meta font-medium whitespace-nowrap text-muted-foreground",
|
||||
c.align === "right" && "text-right",
|
||||
c.hideBelow && HIDE[c.hideBelow],
|
||||
c.className,
|
||||
)}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const key = rowKey(row);
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
onKeyDown={
|
||||
onRowClick
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onRowClick(row);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
aria-selected={selectedKey != null ? selectedKey === key : undefined}
|
||||
className={cn(
|
||||
"border-b border-border/60 transition-colors duration-micro last:border-b-0",
|
||||
onRowClick &&
|
||||
"cursor-pointer outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]",
|
||||
selectedKey === key && "bg-foreground/[0.05]",
|
||||
)}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.key}
|
||||
className={cn(
|
||||
"px-4 py-2.5 align-middle",
|
||||
c.align === "right" && "text-right",
|
||||
c.hideBelow && HIDE[c.hideBelow],
|
||||
c.className,
|
||||
)}
|
||||
>
|
||||
{c.cell(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 overflow-hidden rounded-xl", !plain && "pg-slab", className)}>
|
||||
{body}
|
||||
{rows && rows.length > 0 && (hasMore || error) ? (
|
||||
<div className="flex items-center justify-center gap-3 border-t border-border/60 px-4 py-3">
|
||||
{error ? (
|
||||
<span className="text-meta text-destructive">{String((error as Error).message ?? error)}</span>
|
||||
) : null}
|
||||
{hasMore && onLoadMore ? (
|
||||
<Button variant="outline" size="sm" onClick={onLoadMore} disabled={loadingMore}>
|
||||
{loadingMore ? "加载中…" : "加载更多"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
/**
|
||||
* 右侧详情抽屉(手机全宽)。内容区自带纵向滚动;footer 固定在底部放操作按钮。
|
||||
* <DetailSheet open={!!id} onOpenChange={(o) => !o && setId(null)} title="详情" description="…">
|
||||
* <KvList items={…} />
|
||||
* </DetailSheet>
|
||||
*/
|
||||
export function DetailSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
width = "md",
|
||||
className,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
/** 桌面宽度:md ≈ 32rem,lg ≈ 44rem */
|
||||
width?: "md" | "lg";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className={cn("w-full gap-0 p-0", width === "lg" ? "sm:max-w-2xl" : "sm:max-w-lg", className)}
|
||||
>
|
||||
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
|
||||
<SheetTitle className="truncate text-lead">{title}</SheetTitle>
|
||||
{description ? <SheetDescription className="break-words">{description}</SheetDescription> : null}
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">{children}</div>
|
||||
{footer ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** 抽屉/卡片内的小节:大写小标签 + 内容 */
|
||||
export function DetailSection({
|
||||
title,
|
||||
children,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<h3 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">{title}</h3>
|
||||
{actions}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { AlertTriangle, Inbox, RotateCw } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
/** 空态:图标 + 标题 + 说明 + 可选操作。用于"尚无数据"与"功能建设中"占位 */
|
||||
@@ -22,7 +23,11 @@ export function EmptyState({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("pg-slab flex flex-col items-center justify-center rounded-xl px-6 py-14 text-center", className)}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded-xl px-6 py-14 text-center",
|
||||
!plain && "pg-slab",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="mb-4 grid h-11 w-11 place-items-center rounded-full bg-foreground/[0.05] text-muted-foreground">
|
||||
<Icon className="h-5 w-5" />
|
||||
@@ -33,3 +38,53 @@ export function EmptyState({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 错误态:图标 + 标题 + 错误消息 + 可选重试。放在卡片里时用 plain */
|
||||
export function ErrorState({
|
||||
title = "加载失败",
|
||||
error,
|
||||
onRetry,
|
||||
plain = false,
|
||||
className,
|
||||
}: {
|
||||
title?: string;
|
||||
error?: unknown;
|
||||
onRetry?: () => void;
|
||||
plain?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded-xl px-6 py-12 text-center",
|
||||
!plain && "pg-slab",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="mb-4 grid h-11 w-11 place-items-center rounded-full bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
</span>
|
||||
<p className="text-lead font-medium text-foreground">{title}</p>
|
||||
{message ? <p className="mt-1.5 max-w-md text-caption break-words text-muted-foreground">{message}</p> : null}
|
||||
{onRetry ? (
|
||||
<Button variant="outline" size="sm" className="mt-5" onClick={onRetry}>
|
||||
<RotateCw />
|
||||
重试
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 加载态:若干行骨架条 */
|
||||
export function LoadingState({ rows = 3, className }: { rows?: number; className?: string }) {
|
||||
return (
|
||||
<div role="status" aria-busy="true" aria-label="加载中" className={cn("space-y-2.5 py-2", className)}>
|
||||
{Array.from({ length: rows }, (_, i) => (
|
||||
<div key={i} className="h-9 animate-pulse rounded-tight bg-foreground/[0.06]" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Search, X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
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 { cn } from "~/lib/utils.ts";
|
||||
|
||||
/**
|
||||
* 筛选栏:可选搜索框 + 任意筛选控件(children)+ 可选"清除"。窄屏自动换行。
|
||||
* <FilterBar search={{ value: q, onChange: setQ, placeholder: "搜索名称" }} onReset={reset}>
|
||||
* <FilterSelect label="状态" value={s} onChange={setS} options={[...]} />
|
||||
* </FilterBar>
|
||||
*/
|
||||
export function FilterBar({
|
||||
search,
|
||||
children,
|
||||
onReset,
|
||||
actions,
|
||||
className,
|
||||
}: {
|
||||
search?: { value: string; onChange: (v: string) => void; placeholder?: string };
|
||||
children?: ReactNode;
|
||||
/** 有值时显示"清除筛选" */
|
||||
onReset?: () => void;
|
||||
/** 右侧额外操作(刷新等) */
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("mb-4 flex flex-wrap items-center gap-2", className)}>
|
||||
{search ? (
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search.value}
|
||||
onChange={(e) => search.onChange(e.target.value)}
|
||||
placeholder={search.placeholder ?? "搜索"}
|
||||
className="pl-9"
|
||||
aria-label={search.placeholder ?? "搜索"}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
{onReset ? (
|
||||
<Button variant="ghost" size="sm" onClick={onReset}>
|
||||
<X />
|
||||
清除筛选
|
||||
</Button>
|
||||
) : null}
|
||||
{actions ? <div className="ml-auto flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type FilterOption = { value: string; label: string };
|
||||
|
||||
/** 下拉筛选。value 为 "" 表示"全部"(allLabel) */
|
||||
export function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
allLabel = "全部",
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: FilterOption[];
|
||||
allLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const ALL = "__all__";
|
||||
return (
|
||||
<Select value={value || ALL} onValueChange={(v) => onChange(v === ALL ? "" : v)}>
|
||||
<SelectTrigger size="sm" aria-label={label} className={cn("min-w-32", className)}>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value={ALL}>{allLabel}</SelectItem>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Label } from "~/components/ui/label.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
/**
|
||||
* 表单字段:标签 + 控件 + 说明/错误。错误行固定占位,出错时布局不跳。
|
||||
* 控件自己设 id={htmlFor} 与 aria-invalid={!!error}。
|
||||
* <FormField label="名称" htmlFor="name" error={errors.name} hint="…">
|
||||
* <Input id="name" aria-invalid={!!errors.name} … />
|
||||
* </FormField>
|
||||
*/
|
||||
export function FormField({
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
error,
|
||||
required = false,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
htmlFor?: string;
|
||||
hint?: ReactNode;
|
||||
error?: string | null;
|
||||
required?: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("grid min-w-0 gap-1.5", className)}>
|
||||
<Label htmlFor={htmlFor} className="text-caption font-medium text-foreground">
|
||||
{label}
|
||||
{required ? <span className="text-destructive">*</span> : null}
|
||||
</Label>
|
||||
{children}
|
||||
<p
|
||||
className={cn("min-h-4 text-meta", error ? "text-destructive" : "text-muted-foreground")}
|
||||
role={error ? "alert" : undefined}
|
||||
>
|
||||
{error || hint || ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 把 zod safeParse 的错误摊平成 { 字段路径: 第一条消息 } */
|
||||
export function zodFieldErrors(issues: { path: PropertyKey[]; message: string }[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const i of issues) {
|
||||
const k = i.path.map(String).join(".");
|
||||
if (!(k in out)) out[k] = i.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Check, ChevronRight, Copy } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
/**
|
||||
* 只读 JSON 展示:等宽、自动换行、限高滚动,右上角复制。
|
||||
* label 存在时可折叠(defaultOpen 控制初始展开)。null/undefined 显示"无"。
|
||||
*/
|
||||
export function JsonView({
|
||||
value,
|
||||
label,
|
||||
defaultOpen = true,
|
||||
maxHeight = "20rem",
|
||||
tone = "neutral",
|
||||
className,
|
||||
}: {
|
||||
value: unknown;
|
||||
label?: string;
|
||||
defaultOpen?: boolean;
|
||||
maxHeight?: string;
|
||||
/** destructive:错误负载 */
|
||||
tone?: "neutral" | "destructive";
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const empty = value === null || value === undefined;
|
||||
const text = empty ? "" : typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
toast.error("复制失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0", className)}>
|
||||
{label ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="mb-1.5 flex cursor-pointer items-center gap-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase hover:text-foreground"
|
||||
>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform duration-base", open && "rotate-90")} />
|
||||
{label}
|
||||
</button>
|
||||
) : null}
|
||||
{open || !label ? (
|
||||
empty ? (
|
||||
<p className="text-caption text-muted-foreground">无</p>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-lg border bg-foreground/[0.03]",
|
||||
tone === "destructive" ? "border-destructive/30" : "border-border",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
aria-label="复制"
|
||||
className="absolute top-1.5 right-1.5 grid h-7 w-7 cursor-pointer place-items-center rounded-full text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<pre
|
||||
className={cn(
|
||||
"overflow-auto p-3 pr-10 font-mono text-meta leading-relaxed break-all whitespace-pre-wrap",
|
||||
tone === "destructive" ? "text-destructive" : "text-foreground",
|
||||
)}
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
export type KvItem = {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
/** 等宽(ID、哈希、路径) */
|
||||
mono?: boolean;
|
||||
/** 占满整行(长文本) */
|
||||
wide?: boolean;
|
||||
};
|
||||
|
||||
/** 键值列表:窄屏单列,sm 起两列。空值显示 — */
|
||||
export function KvList({ items, columns = 2, className }: { items: KvItem[]; columns?: 1 | 2; className?: string }) {
|
||||
return (
|
||||
<dl className={cn("grid gap-x-6 gap-y-3", columns === 2 && "sm:grid-cols-2", className)}>
|
||||
{items.map((it, i) => (
|
||||
<div key={i} className={cn("min-w-0", it.wide && "sm:col-span-2")}>
|
||||
<dt className="text-meta text-muted-foreground">{it.label}</dt>
|
||||
<dd
|
||||
className={cn("mt-0.5 text-sm break-words text-foreground", it.mono && "font-mono text-caption break-all")}
|
||||
>
|
||||
{it.value === null || it.value === undefined || it.value === "" ? "—" : it.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findNavItem, NAV_ITEMS } from "./nav.ts";
|
||||
import { consolePath, findNavItem, NAV_ITEMS } from "./nav.ts";
|
||||
|
||||
describe("控制台导航", () => {
|
||||
it("12 个工作区,路径唯一", () => {
|
||||
expect(NAV_ITEMS).toHaveLength(12);
|
||||
expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(12);
|
||||
it("12 个工作区 + 引擎面板 + 审计,路径唯一且都在 /console 下", () => {
|
||||
expect(NAV_ITEMS).toHaveLength(14);
|
||||
expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(14);
|
||||
expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true);
|
||||
});
|
||||
|
||||
it("按最长前缀匹配当前项", () => {
|
||||
expect(findNavItem("/")?.label).toBe("首页");
|
||||
expect(findNavItem("/library/abc")?.label).toBe("媒体库");
|
||||
expect(findNavItem("/console")?.label).toBe("首页");
|
||||
expect(findNavItem("/console/")?.label).toBe("首页");
|
||||
expect(findNavItem("/console/library/abc")?.label).toBe("媒体库");
|
||||
expect(findNavItem("/console/engine/r1")?.label).toBe("工作流引擎");
|
||||
expect(findNavItem("/nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("consolePath 拼路径并编码", () => {
|
||||
expect(consolePath()).toBe("/console");
|
||||
expect(consolePath("library", "a b")).toBe("/console/library/a%20b");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
CalendarClock,
|
||||
Download,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
Library,
|
||||
type LucideIcon,
|
||||
Rss,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Tags,
|
||||
} from "lucide-react";
|
||||
@@ -17,58 +19,114 @@ import {
|
||||
export type NavItem = { to: string; label: string; icon: LucideIcon; description: string };
|
||||
export type NavGroup = { title: string; items: NavItem[] };
|
||||
|
||||
/** 控制台 12 个工作区(任务书 §3.1 第 10 条 / REQ §13.1) */
|
||||
/** 控制台路由前缀 */
|
||||
export const CONSOLE_BASE = "/console";
|
||||
|
||||
/** 拼控制台路径:consolePath("library", id) → /console/library/<id> */
|
||||
export function consolePath(...segments: (string | number)[]): string {
|
||||
const rest = segments.map((s) => encodeURIComponent(String(s))).join("/");
|
||||
return rest ? `${CONSOLE_BASE}/${rest}` : CONSOLE_BASE;
|
||||
}
|
||||
|
||||
/** 控制台 12 个工作区(任务书 §3.1 第 10 条 / REQ §13.1)+ 引擎面板与审计两个运维页 */
|
||||
export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
title: "概览",
|
||||
items: [
|
||||
{
|
||||
to: "/",
|
||||
to: "/console",
|
||||
label: "首页",
|
||||
icon: House,
|
||||
description: "自动化概览、当前工作、需要我处理、缓存与保留、来源健康、最近产出",
|
||||
},
|
||||
{ to: "/inbox", label: "待处理中心", icon: Inbox, description: "按作品与根因合并的待决定事项,Agent 已先行调查" },
|
||||
{
|
||||
to: "/console/inbox",
|
||||
label: "待处理中心",
|
||||
icon: Inbox,
|
||||
description: "按作品与根因合并的待决定事项,Agent 已先行调查",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "媒体",
|
||||
items: [
|
||||
{ to: "/library", label: "媒体库", icon: Library, description: "作品、内容单元、版本目标与实际路径" },
|
||||
{ to: "/identity", label: "身份与元数据", icon: Fingerprint, description: "外部身份映射、待识别池与元数据来源" },
|
||||
{ to: "/organize", label: "整理记录", icon: FileStack, description: "Copy/Move 入库前后路径与逐文件结果" },
|
||||
{ to: "/console/library", label: "媒体库", icon: Library, description: "作品、内容单元、版本目标与实际路径" },
|
||||
{
|
||||
to: "/console/identity",
|
||||
label: "身份与元数据",
|
||||
icon: Fingerprint,
|
||||
description: "外部身份映射、待识别池与元数据来源",
|
||||
},
|
||||
{
|
||||
to: "/console/organize",
|
||||
label: "整理记录",
|
||||
icon: FileStack,
|
||||
description: "Copy/Move 入库前后路径与逐文件结果",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "获取",
|
||||
items: [
|
||||
{
|
||||
to: "/subscriptions",
|
||||
to: "/console/subscriptions",
|
||||
label: "订阅与收录规则",
|
||||
icon: CalendarClock,
|
||||
description: "单次收录与持续追踪,版本目标与预算",
|
||||
},
|
||||
{
|
||||
to: "/preferences",
|
||||
to: "/console/preferences",
|
||||
label: "偏好标签",
|
||||
icon: Tags,
|
||||
description: "软偏好与硬规则,手动取消的偏好不会被自动加回",
|
||||
},
|
||||
{ to: "/sites", label: "PT 站点工作台", icon: Globe, description: "统一格式浏览、搜索站点资源并关联本地库存" },
|
||||
{ to: "/sources", label: "来源 / Feed / 快照", icon: Rss, description: "RSS 与站点快照的抓取状态、新鲜度与健康" },
|
||||
{ to: "/tasks", label: "下载与任务", icon: Download, description: "下载、整理、核验的业务时间线与子任务" },
|
||||
{
|
||||
to: "/console/sites",
|
||||
label: "PT 站点工作台",
|
||||
icon: Globe,
|
||||
description: "统一格式浏览、搜索站点资源并关联本地库存",
|
||||
},
|
||||
{
|
||||
to: "/console/sources",
|
||||
label: "来源 / Feed / 快照",
|
||||
icon: Rss,
|
||||
description: "RSS 与站点快照的抓取状态、新鲜度与健康",
|
||||
},
|
||||
{
|
||||
to: "/console/tasks",
|
||||
label: "下载与任务",
|
||||
icon: Download,
|
||||
description: "下载、整理、核验的业务时间线与子任务",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "助手与系统",
|
||||
items: [
|
||||
{ to: "/agent", label: "Agent 会话", icon: Bot, description: "用自然语言交代目标,Agent 在授权内执行并汇报" },
|
||||
{
|
||||
to: "/settings",
|
||||
to: "/console/agent",
|
||||
label: "Agent 会话",
|
||||
icon: Bot,
|
||||
description: "用自然语言交代目标,Agent 在授权内执行并汇报",
|
||||
},
|
||||
{
|
||||
to: "/console/settings",
|
||||
label: "系统设置",
|
||||
icon: Settings,
|
||||
description: "下载器与媒体服务器实例、路径映射、API Key、Pi 上游与自动化开关",
|
||||
},
|
||||
{
|
||||
to: "/console/engine",
|
||||
label: "工作流引擎",
|
||||
icon: Activity,
|
||||
description: "持久任务引擎的运行记录、步骤尝试与信号(只读诊断,仅管理员)",
|
||||
},
|
||||
{
|
||||
to: "/console/audit",
|
||||
label: "审计日志",
|
||||
icon: ScrollText,
|
||||
description: "登录、设置、密钥与实例变更的操作留痕",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -78,8 +136,8 @@ export const NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items);
|
||||
/** 按路径找当前导航项(最长前缀匹配) */
|
||||
export function findNavItem(pathname: string): NavItem | undefined {
|
||||
return (
|
||||
NAV_ITEMS.find((i) => i.to === pathname) ??
|
||||
NAV_ITEMS.filter((i) => i.to !== "/" && pathname.startsWith(`${i.to}/`)).sort(
|
||||
NAV_ITEMS.find((i) => i.to === pathname.replace(/\/+$/, "")) ??
|
||||
NAV_ITEMS.filter((i) => i.to !== CONSOLE_BASE && pathname.startsWith(`${i.to}/`)).sort(
|
||||
(a, b) => b.to.length - a.to.length,
|
||||
)[0]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import type { StatusTone } from "./status-badge.tsx";
|
||||
|
||||
const TONE_TEXT: Record<StatusTone, string> = {
|
||||
success: "text-success",
|
||||
warning: "text-warning",
|
||||
destructive: "text-destructive",
|
||||
neutral: "text-foreground",
|
||||
};
|
||||
|
||||
/**
|
||||
* 数字磁贴:标签 + 大号数值 + 说明。tone 只给数值上色,标签/说明仍要用文字说清含义。
|
||||
* plain 用于已在卡片内(不再叠 pg-slab)。onClick 存在时整块可点(筛选联动)。
|
||||
*/
|
||||
export function StatTile({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
icon: Icon,
|
||||
tone = "neutral",
|
||||
active = false,
|
||||
onClick,
|
||||
plain = false,
|
||||
className,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
hint?: ReactNode;
|
||||
icon?: LucideIcon;
|
||||
tone?: StatusTone;
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
plain?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
||||
{Icon ? <Icon className="h-3.5 w-3.5" /> : null}
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
<div className={cn("mt-1 text-title-sm font-semibold tabular-nums", TONE_TEXT[tone])}>{value}</div>
|
||||
{hint ? <div className="mt-0.5 truncate text-meta text-muted-foreground">{hint}</div> : null}
|
||||
</>
|
||||
);
|
||||
const base = cn(
|
||||
"min-w-0 rounded-xl px-4 py-3 text-left",
|
||||
plain ? "border border-border/70" : "pg-slab",
|
||||
active && "ring-2 ring-ring/40",
|
||||
className,
|
||||
);
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(base, "cursor-pointer transition-colors duration-micro hover:bg-foreground/[0.04]")}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <div className={base}>{content}</div>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import type { StatusTone } from "./status-badge.tsx";
|
||||
|
||||
export type TimelineItem = {
|
||||
id: string;
|
||||
/** ISO 时间;显示为本地时间 */
|
||||
at?: string | null;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
/** 圆点语气,与 StatusBadge 一致;标题里仍要有文字说明状态 */
|
||||
tone?: StatusTone;
|
||||
/** 右侧附加信息(耗时、次数等) */
|
||||
meta?: ReactNode;
|
||||
/** 展开区(json-view 等) */
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const DOT: Record<StatusTone, string> = {
|
||||
success: "bg-success",
|
||||
warning: "bg-warning",
|
||||
destructive: "bg-destructive",
|
||||
neutral: "bg-muted-foreground/60",
|
||||
};
|
||||
|
||||
/** 纵向时间线(业务时间线、步骤尝试、审计轨迹)。items 按展示顺序传入 */
|
||||
export function Timeline({ items, className }: { items: TimelineItem[]; className?: string }) {
|
||||
return (
|
||||
<ol className={cn("relative space-y-4", className)}>
|
||||
{items.map((it, i) => (
|
||||
<li key={it.id} className="relative flex gap-3 pl-1">
|
||||
{i < items.length - 1 ? (
|
||||
<span aria-hidden className="absolute top-4 bottom-[-1rem] left-[0.6875rem] w-px bg-border" />
|
||||
) : null}
|
||||
<span aria-hidden className="relative mt-1.5 grid h-3 w-3 shrink-0 place-items-center">
|
||||
<span className={cn("h-2.5 w-2.5 rounded-full ring-4 ring-background", DOT[it.tone ?? "neutral"])} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
|
||||
<div className="min-w-0 text-sm font-medium break-words text-foreground">{it.title}</div>
|
||||
{it.meta ? <div className="shrink-0 text-meta text-muted-foreground">{it.meta}</div> : null}
|
||||
</div>
|
||||
{it.at ? <p className="text-meta text-muted-foreground tabular-nums">{formatDateTime(it.at)}</p> : null}
|
||||
{it.description ? (
|
||||
<div className="mt-1 text-caption break-words text-muted-foreground">{it.description}</div>
|
||||
) : null}
|
||||
{it.children ? <div className="mt-2">{it.children}</div> : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Tabs as TabsPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 分段标签:列表横向可滚(scroll-bleed 保留描边不被裁),选中态用静态 pill-selected
|
||||
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn("scroll-bleed flex max-w-full items-center gap-1 overflow-x-auto pb-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-transparent px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-[background-color,color] duration-micro outline-none hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-(--mat-pill-selected-border) data-[state=active]:bg-(--mat-pill-selected-bg) data-[state=active]:text-foreground [&_svg]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return <TabsPrimitive.Content data-slot="tabs-content" className={cn("outline-none", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
@@ -0,0 +1,20 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground dark:bg-input/30 border-input flex min-h-16 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
+97
-25
@@ -1,13 +1,24 @@
|
||||
import type { ApiError, ApiResponse } from "@umt/contracts";
|
||||
import type { ApiError, ApiErrorCode, ApiResponse } from "@umt/contracts";
|
||||
|
||||
export type { ApiResponse };
|
||||
|
||||
// ─── API Key 存储 ───────────────────────────────────────────────
|
||||
// 控制台鉴权:cookie 会话优先(credentials: "include",写方法带 CSRF 头);
|
||||
// 只有 localStorage 里存了 API Key 时才额外附加 Bearer(脚本/调试用,Bearer 调用服务端不校验 CSRF)。
|
||||
// 任意请求收到 401 → 跳登录页(带 next 回跳),/auth/* 探测类请求除外。
|
||||
|
||||
// ─── API Key 存储(可选) ─────────────────────────────────────────
|
||||
const TOKEN_KEY = "umt-api-key";
|
||||
const CSRF_COOKIE = "umt_csrf";
|
||||
const CSRF_HEADER = "x-csrf-token";
|
||||
export const LOGIN_PATH = "/login";
|
||||
|
||||
export function getToken(): string {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") return "";
|
||||
return localStorage.getItem(TOKEN_KEY) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
@@ -18,50 +29,85 @@ export function clearAuth() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
function readCookie(name: string): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
for (const part of document.cookie.split(";")) {
|
||||
const [k, ...v] = part.trim().split("=");
|
||||
if (k === name) return decodeURIComponent(v.join("="));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// ─── 请求参数 ────────────────────────────────────────────────────
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
export type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||
|
||||
export type FetcherArg<B = unknown> = {
|
||||
/** 路径变量,如 { id: "..." } 替换 /api/v1/media/:id */
|
||||
/** 路径变量,`:id` 与 `{id}` 两种写法都替换,如 { id: "..." } */
|
||||
pathParams?: Record<string, string | number>;
|
||||
params?: Record<string, string | number | boolean | undefined | null>;
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
params?: QueryParams;
|
||||
method?: HttpMethod;
|
||||
body?: B;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
/** 401 时不跳登录页(登录页自己的探测/提交用) */
|
||||
noAuthRedirect?: boolean;
|
||||
};
|
||||
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
/** 业务接口前缀,与 contracts 的 API_PREFIX 一致 */
|
||||
export const API_V1 = "/api/v1";
|
||||
|
||||
const fail = (code: ApiError["code"], message: string): ApiResponse<never> => ({
|
||||
ok: false,
|
||||
error: { code, message },
|
||||
});
|
||||
|
||||
/**
|
||||
* 核心 fetcher:始终返回 ApiResponse 信封,不抛出网络/解析错误。
|
||||
* queryFn: () => apiFetch<HealthStatus>("/api/v1/health")
|
||||
* mutationFn: (body) => apiFetch("/api/v1/xxx", { method: "POST", body })
|
||||
*/
|
||||
export async function apiFetch<T = unknown>(path: string, arg: FetcherArg = {}): Promise<ApiResponse<T>> {
|
||||
const { method = "GET", headers = {}, body, params, pathParams, signal } = arg;
|
||||
|
||||
let url = `${API_BASE.replace(/\/+$/, "")}${path}`;
|
||||
if (pathParams) {
|
||||
for (const [key, value] of Object.entries(pathParams)) {
|
||||
url = url.replace(`:${key}`, encodeURIComponent(String(value)));
|
||||
/** 拼完整 URL(不含 origin):路径变量 + query */
|
||||
export function buildUrl(path: string, arg: Pick<FetcherArg, "params" | "pathParams"> = {}): string {
|
||||
let url = `${API_BASE.replace(/\/+$/, "")}${path.startsWith("/api/") ? path : `${API_V1}${path}`}`;
|
||||
if (arg.pathParams) {
|
||||
for (const [key, value] of Object.entries(arg.pathParams)) {
|
||||
const v = encodeURIComponent(String(value));
|
||||
url = url.replace(`:${key}`, v).replace(`{${key}}`, v);
|
||||
}
|
||||
}
|
||||
if (params) {
|
||||
if (arg.params) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) qs.append(key, String(value));
|
||||
for (const [key, value] of Object.entries(arg.params)) {
|
||||
if (value !== undefined && value !== null && value !== "") qs.append(key, String(value));
|
||||
}
|
||||
const s = qs.toString();
|
||||
if (s) url += (url.includes("?") ? "&" : "?") + s;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
const reqHeaders: Record<string, string> = { ...headers };
|
||||
/** 当前页不是登录页时跳过去,带 next 回跳 */
|
||||
export function redirectToLogin() {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.location.pathname === LOGIN_PATH) return;
|
||||
const next = window.location.pathname + window.location.search;
|
||||
window.location.assign(`${LOGIN_PATH}?next=${encodeURIComponent(next)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心 fetcher:始终返回 ApiResponse 信封,不抛出网络/解析错误。
|
||||
* path 可写 "/settings"(自动补 /api/v1)或完整 "/api/v1/settings"。
|
||||
* queryFn: () => apiFetch<HealthStatus>("/health")
|
||||
* mutationFn: (body) => apiFetch("/xxx", { method: "POST", body })
|
||||
*/
|
||||
export async function apiFetch<T = unknown>(path: string, arg: FetcherArg = {}): Promise<ApiResponse<T>> {
|
||||
const { method = "GET", headers = {}, body, signal } = arg;
|
||||
const url = buildUrl(path, arg);
|
||||
|
||||
const reqHeaders: Record<string, string> = { Accept: "application/json", ...headers };
|
||||
const token = getToken();
|
||||
if (token) reqHeaders.Authorization = `Bearer ${token}`;
|
||||
if (method !== "GET") {
|
||||
const csrf = readCookie(CSRF_COOKIE);
|
||||
if (csrf) reqHeaders[CSRF_HEADER] = csrf;
|
||||
}
|
||||
|
||||
let reqBody: BodyInit | undefined;
|
||||
if (body instanceof FormData) {
|
||||
@@ -73,7 +119,7 @@ export async function apiFetch<T = unknown>(path: string, arg: FetcherArg = {}):
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, { method, headers: reqHeaders, body: reqBody, signal });
|
||||
res = await fetch(url, { method, headers: reqHeaders, body: reqBody, signal, credentials: "include" });
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") throw err;
|
||||
return fail("upstream_unavailable", "无法连接服务");
|
||||
@@ -81,22 +127,48 @@ export async function apiFetch<T = unknown>(path: string, arg: FetcherArg = {}):
|
||||
|
||||
if (res.status === 401 && typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("unauthorized"));
|
||||
if (!arg.noAuthRedirect) redirectToLogin();
|
||||
}
|
||||
|
||||
try {
|
||||
return (await res.json()) as ApiResponse<T>;
|
||||
} catch {
|
||||
return fail("internal", `响应不是 JSON(HTTP ${res.status})`);
|
||||
return fail(res.status === 401 ? "unauthorized" : "internal", `响应不是 JSON(HTTP ${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 取出 data,失败时抛出(给 react-query 的 queryFn 用,错误进入 error 状态) */
|
||||
/** 带错误码的请求错误(react-query 的 error 就是它) */
|
||||
export class ApiRequestError extends Error {
|
||||
readonly code: ApiErrorCode;
|
||||
constructor(code: ApiErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiRequestError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** 取出 data,失败时抛 ApiRequestError(给 react-query 的 queryFn / mutationFn 用) */
|
||||
export async function apiData<T>(path: string, arg?: FetcherArg): Promise<T> {
|
||||
const r = await apiFetch<T>(path, arg);
|
||||
if (!r.ok) throw Object.assign(new Error(r.error.message), { code: r.error.code });
|
||||
if (!r.ok) throw new ApiRequestError(r.error.code, r.error.message);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
/** 便捷方法:都返回 data,失败抛 ApiRequestError */
|
||||
export const api = {
|
||||
get: <T>(path: string, params?: QueryParams, signal?: AbortSignal) => apiData<T>(path, { params, signal }),
|
||||
post: <T>(path: string, body?: unknown) => apiData<T>(path, { method: "POST", body: body ?? {} }),
|
||||
put: <T>(path: string, body?: unknown) => apiData<T>(path, { method: "PUT", body: body ?? {} }),
|
||||
patch: <T>(path: string, body?: unknown) => apiData<T>(path, { method: "PATCH", body: body ?? {} }),
|
||||
del: <T>(path: string) => apiData<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
/** 从未知错误里取人话消息(toast 用) */
|
||||
export function errorMessage(err: unknown, fallback = "操作失败"): string {
|
||||
if (err instanceof Error && err.message) return err.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** TanStack Query mutationFn 包装 */
|
||||
export function mutateFetcher<B = unknown, T = unknown>(path: string, base?: Omit<FetcherArg<B>, "body">) {
|
||||
return (body: B) => apiFetch<T>(path, { method: "POST", ...base, body });
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// 查询 key 统一在这里生成,SSE 失效与跨页刷新靠它对齐。
|
||||
// 约定:[area, "list", params?] / [area, "detail", id] / [area, 其它子资源...];
|
||||
// invalidate 某区域全部数据用 qk.all(area)。
|
||||
|
||||
export type QueryArea =
|
||||
| "auth"
|
||||
| "health"
|
||||
| "dashboard"
|
||||
| "settings"
|
||||
| "api-keys"
|
||||
| "secrets"
|
||||
| "library-roots"
|
||||
| "downloader-instances"
|
||||
| "media-server-instances"
|
||||
| "path-mappings"
|
||||
| "sites"
|
||||
| "site-accounts"
|
||||
| "audit"
|
||||
| "engine"
|
||||
| "media"
|
||||
| "identity"
|
||||
| "organize"
|
||||
| "subscriptions"
|
||||
| "preferences"
|
||||
| "inbox"
|
||||
| "decisions"
|
||||
| "sources"
|
||||
| "feeds"
|
||||
| "tasks"
|
||||
| "downloads"
|
||||
| "agent"
|
||||
| (string & {});
|
||||
|
||||
export const qk = {
|
||||
all: (area: QueryArea) => [area] as const,
|
||||
list: (area: QueryArea, params?: object) => (params ? ([area, "list", params] as const) : ([area, "list"] as const)),
|
||||
detail: (area: QueryArea, id: string) => [area, "detail", id] as const,
|
||||
sub: (area: QueryArea, ...parts: (string | number | object)[]) => [area, ...parts] as const,
|
||||
|
||||
session: ["auth", "session"] as const,
|
||||
health: ["health"] as const,
|
||||
dashboard: ["dashboard"] as const,
|
||||
settings: ["settings"] as const,
|
||||
engineStats: ["engine", "stats"] as const,
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { EventType } from "@umt/contracts";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { buildUrl } from "./api.ts";
|
||||
|
||||
// SSE 封装:整个页面共用一条 EventSource(按订阅引用计数开关)。
|
||||
// - 服务端按事件类型发命名事件(event: <type>),所以按 type 订阅;"*" 订阅全部已知类型。
|
||||
// - 记住最后一个 seq;断线后按指数退避重连 `/events?after=<seq>` 补读,不漏不重。
|
||||
// - 收到 system.resync(起点已被裁剪)时,订阅者应整页重取(useSseInvalidate 自动处理)。
|
||||
// 鉴权走 cookie 会话(EventSource 不能带 Authorization 头)。
|
||||
|
||||
export type SseEvent<P = unknown> = { seq: number; type: string; at: string; payload: P };
|
||||
export type SseHandler = (event: SseEvent) => void;
|
||||
export type SseStatus = "idle" | "connecting" | "open" | "reconnecting";
|
||||
|
||||
const KNOWN_TYPES: string[] = Object.values(EventType);
|
||||
const WILDCARD = "*";
|
||||
|
||||
type Sub = { types: Set<string>; handler: SseHandler };
|
||||
|
||||
class SseClient {
|
||||
private es: EventSource | null = null;
|
||||
private subs = new Set<Sub>();
|
||||
private listened = new Set<string>();
|
||||
private lastSeq: number | null = null;
|
||||
private retry = 0;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private statusListeners = new Set<(s: SseStatus) => void>();
|
||||
status: SseStatus = "idle";
|
||||
|
||||
subscribe(types: string[], handler: SseHandler): () => void {
|
||||
const sub: Sub = { types: new Set(types), handler };
|
||||
this.subs.add(sub);
|
||||
if (!this.es && !this.timer) this.connect();
|
||||
else for (const t of this.expand(sub.types)) this.listen(t);
|
||||
return () => {
|
||||
this.subs.delete(sub);
|
||||
if (this.subs.size === 0) this.close();
|
||||
};
|
||||
}
|
||||
|
||||
onStatus(fn: (s: SseStatus) => void): () => void {
|
||||
this.statusListeners.add(fn);
|
||||
return () => this.statusListeners.delete(fn);
|
||||
}
|
||||
|
||||
private setStatus(s: SseStatus) {
|
||||
this.status = s;
|
||||
for (const fn of this.statusListeners) fn(s);
|
||||
}
|
||||
|
||||
private expand(types: Set<string>): string[] {
|
||||
return types.has(WILDCARD) ? [...KNOWN_TYPES, ...[...types].filter((t) => t !== WILDCARD)] : [...types];
|
||||
}
|
||||
|
||||
private connect() {
|
||||
if (typeof EventSource === "undefined") return;
|
||||
this.timer = null;
|
||||
this.setStatus(this.retry ? "reconnecting" : "connecting");
|
||||
const url = buildUrl("/events", { params: { after: this.lastSeq ?? undefined } });
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
this.es = es;
|
||||
this.listened.clear();
|
||||
// resync 永远监听
|
||||
this.listen(EventType.resync);
|
||||
for (const sub of this.subs) for (const t of this.expand(sub.types)) this.listen(t);
|
||||
es.onopen = () => {
|
||||
this.retry = 0;
|
||||
this.setStatus("open");
|
||||
};
|
||||
es.onerror = () => {
|
||||
// 自己掌控重连(带 after=seq),不用浏览器默认重连
|
||||
es.close();
|
||||
if (this.es !== es) return;
|
||||
this.es = null;
|
||||
if (this.subs.size === 0) return;
|
||||
const delay = Math.min(30_000, 1000 * 2 ** this.retry);
|
||||
this.retry += 1;
|
||||
this.setStatus("reconnecting");
|
||||
this.timer = setTimeout(() => this.connect(), delay);
|
||||
};
|
||||
}
|
||||
|
||||
private listen(type: string) {
|
||||
if (!this.es || this.listened.has(type)) return;
|
||||
this.listened.add(type);
|
||||
this.es.addEventListener(type, (e) => this.dispatch(type, e as MessageEvent<string>));
|
||||
}
|
||||
|
||||
private dispatch(type: string, e: MessageEvent<string>) {
|
||||
let event: SseEvent;
|
||||
try {
|
||||
const parsed = JSON.parse(e.data) as Partial<SseEvent>;
|
||||
event =
|
||||
typeof parsed.seq === "number"
|
||||
? (parsed as SseEvent)
|
||||
: { seq: this.lastSeq ?? 0, type, at: new Date().toISOString(), payload: parsed };
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (typeof event.seq === "number" && type !== EventType.resync) {
|
||||
if (this.lastSeq !== null && event.seq <= this.lastSeq) return; // 补读重叠去重
|
||||
this.lastSeq = event.seq;
|
||||
}
|
||||
for (const sub of this.subs) {
|
||||
if (type === EventType.resync || sub.types.has(type) || sub.types.has(WILDCARD)) sub.handler(event);
|
||||
}
|
||||
}
|
||||
|
||||
private close() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.es?.close();
|
||||
this.es = null;
|
||||
this.retry = 0;
|
||||
this.setStatus("idle");
|
||||
}
|
||||
}
|
||||
|
||||
export const sse = new SseClient();
|
||||
|
||||
/**
|
||||
* 订阅 SSE 事件。types 为事件类型数组(见 contracts EventType),"*" 表示全部已知类型。
|
||||
* handler 可以每次渲染都变,不会重复订阅。system.resync 总会送达。
|
||||
*/
|
||||
export function useSse(types: string[], handler: SseHandler, enabled = true) {
|
||||
const ref = useRef(handler);
|
||||
ref.current = handler;
|
||||
const key = types.join("|");
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
return sse.subscribe(key.split("|"), (e) => ref.current(e));
|
||||
}, [key, enabled]);
|
||||
}
|
||||
|
||||
/** 连接状态(给页面角落的"实时"指示用) */
|
||||
export function useSseStatus(): SseStatus {
|
||||
const [s, setS] = useState<SseStatus>(sse.status);
|
||||
useEffect(() => sse.onStatus(setS), []);
|
||||
return s;
|
||||
}
|
||||
+16
-2
@@ -1,20 +1,34 @@
|
||||
import { index, layout, type RouteConfig, route } from "@react-router/dev/routes";
|
||||
import { index, layout, prefix, type RouteConfig, route } from "@react-router/dev/routes";
|
||||
|
||||
// 显式路由表。新增工作区:在这里加一行 + components/console/nav.ts 加导航项
|
||||
// 显式路由表(W3-1 维护,其他工作区不改这里)。
|
||||
// 控制台全部挂在 /console 下;/ 重定向到 /console;/login 为登录页(不经过控制台外壳)。
|
||||
// 新增工作区:在这里加一行 + components/console/nav.ts 加导航项。
|
||||
export default [
|
||||
index("routes/index-redirect.tsx"),
|
||||
route("login", "routes/login.tsx"),
|
||||
...prefix("console", [
|
||||
layout("routes/console/layout.tsx", [
|
||||
index("routes/console/home.tsx"),
|
||||
route("inbox", "routes/console/inbox.tsx"),
|
||||
route("library", "routes/console/library.tsx"),
|
||||
route("library/:id", "routes/console/library.$id.tsx"),
|
||||
route("identity", "routes/console/identity.tsx"),
|
||||
route("organize", "routes/console/organize.tsx"),
|
||||
route("organize/:id", "routes/console/organize.$id.tsx"),
|
||||
route("subscriptions", "routes/console/subscriptions.tsx"),
|
||||
route("preferences", "routes/console/preferences.tsx"),
|
||||
route("sites", "routes/console/sites.tsx"),
|
||||
route("sites/:siteId", "routes/console/sites.$siteId.tsx"),
|
||||
route("sources", "routes/console/sources.tsx"),
|
||||
route("tasks", "routes/console/tasks.tsx"),
|
||||
route("tasks/:id", "routes/console/tasks.$id.tsx"),
|
||||
route("agent", "routes/console/agent.tsx"),
|
||||
route("agent/:runId", "routes/console/agent.$runId.tsx"),
|
||||
route("engine", "routes/console/engine.tsx"),
|
||||
route("engine/:runId", "routes/console/engine.$runId.tsx"),
|
||||
route("audit", "routes/console/audit.tsx"),
|
||||
route("settings", "routes/console/settings.tsx"),
|
||||
]),
|
||||
]),
|
||||
route("*", "routes/not-found.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { ChevronLeft, ChevronRight, Clapperboard, Menu } from "lucide-react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Link, NavLink, Outlet, useLocation } from "react-router";
|
||||
import { ApiKeyDialog } from "~/components/console/api-key-dialog.tsx";
|
||||
import { findNavItem, NAV_GROUPS, NAV_ITEMS, type NavGroup } from "~/components/console/nav.ts";
|
||||
import { CONSOLE_BASE, findNavItem, NAV_GROUPS, NAV_ITEMS, type NavGroup } from "~/components/console/nav.ts";
|
||||
import PrismLeak from "~/components/prism-leak.tsx";
|
||||
import { ThemeToggle } from "~/components/theme-toggle.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
@@ -59,7 +59,7 @@ function ExpandedNav({ groups, onNavigate }: { groups: NavGroup[]; onNavigate?:
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
end={item.to === CONSOLE_BASE}
|
||||
onClick={onNavigate}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
@@ -93,7 +93,7 @@ function CollapsedNav() {
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
end={item.to === CONSOLE_BASE}
|
||||
title={item.label}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
@@ -114,7 +114,7 @@ function CollapsedNav() {
|
||||
function Brand({ collapsed = false }: { collapsed?: boolean }) {
|
||||
return (
|
||||
<Link
|
||||
to="/"
|
||||
to="/console"
|
||||
className={cn(
|
||||
"flex items-center transition-opacity hover:opacity-80",
|
||||
collapsed ? "w-full justify-center" : "gap-2",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "react-router";
|
||||
|
||||
// / → 控制台首页
|
||||
export function clientLoader() {
|
||||
return redirect("/console");
|
||||
}
|
||||
|
||||
export default function IndexRedirect() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 登录页:W3-1 实现中
|
||||
export default function LoginPage() {
|
||||
return null;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export default function NotFound() {
|
||||
title="页面不存在"
|
||||
description="检查地址是否正确,或回到首页。"
|
||||
action={
|
||||
<Link to="/" className={buttonVariants({ variant: "outline" })}>
|
||||
<Link to="/console" className={buttonVariants({ variant: "outline" })}>
|
||||
回到首页
|
||||
</Link>
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user