fix(web): 控制台界面细节与 uhd_web 控件规范对齐

- 顶栏搜索移进右侧玻璃胶囊;输入框改淡玻璃(边线 0.46 → 0.16,悬停加深,聚焦 / 出错保留光晕)
- 新增 SegmentedControl(滑动 seg-thumb),替换站点、下载、订阅、文件、智能体列表及对话框里的实心选中块;Tabs 改为同一枚滑块
- 开关型按钮统一 outline + aria-pressed 选中外观;页头 / 工具栏次操作描边、图标钮描边圆;文件排序改 Select;下载总速度加标签
- 分页一页放得下时只留总数、空列表不渲染、按容器宽度收起按钮
- 运行状态存储区重排;智能体列表页与会话页共用外壳路由,标题与左栏不再重挂载
- 站点卡片「⋯」不再单独换行;设置导航、状态计数、多选条件改玻璃选中态

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-08 12:02:48 +08:00
1 parent 8ff1817845
commit c9b4ceb301
32 files changed
+753 -639

No files matched your search

@@ -1,4 +1,4 @@
import { Bot, ChevronLeft, ChevronRight } from "lucide-react";
import { Bot, ChevronLeft, ChevronRight, Search } from "lucide-react";
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { type AgentRunGroup, type AgentRunView, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
@@ -8,6 +8,7 @@ import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import type { PaginationProps } from "~/components/ui/pagination.tsx";
import { SegmentedControl } from "~/components/ui/segmented.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { cn } from "~/lib/utils.ts";
@@ -323,40 +324,38 @@ export function AgentSessionList({
const rowProps = { activeId, hrefFor, onOpen };
return (
<nav aria-label="智能体会话列表" className={cn("flex min-h-0 flex-col", className)}>
<div className="mb-2 space-y-1.5">
<Tabs value={f.group} onValueChange={(v) => set({ group: v as AgentRunGroup })} className="pb-1">
<div className="mb-2 space-y-2.5 border-b border-border/60 px-0.5 pb-3">
<Tabs value={f.group} onValueChange={(v) => set({ group: v as AgentRunGroup })}>
<TabsList aria-label="会话分栏" className="grid w-full grid-cols-2">
{GROUPS.map((g) => (
<TabsTrigger key={g.value} value={g.value}>
<TabsTrigger key={g.value} value={g.value} className="justify-center">
{g.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Input
type="search"
value={f.q}
onChange={(e) => set({ q: e.target.value })}
placeholder="搜索会话"
aria-label="搜索会话"
className="h-8 md:text-caption"
<div className="relative">
<Search
aria-hidden
className="pointer-events-none absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
type="search"
value={f.q}
onChange={(e) => set({ q: e.target.value })}
placeholder="搜索会话"
aria-label="搜索会话"
className="h-8 rounded-full pl-8 md:text-caption"
/>
</div>
<SegmentedControl
label="筛选会话"
size="xs"
fill
value={f.view}
onChange={(v) => set({ view: v })}
options={VIEWS.map((v) => ({ value: v.value, label: v.label }))}
/>
<fieldset aria-label="筛选会话" className="m-0 flex min-w-0 gap-1 border-0 p-0">
{VIEWS.map((v) => (
<button
key={v.value}
type="button"
aria-pressed={f.view === v.value}
onClick={() => set({ view: v.value })}
className={cn(
"h-7 cursor-pointer rounded-full border border-transparent px-2.5 text-meta text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
f.view === v.value && "pill-selected text-foreground",
)}
>
{v.label}
</button>
))}
</fieldset>
</div>
<div
ref={scroller}
@@ -29,7 +29,21 @@ describe("list pagination", () => {
expect(html).toContain('aria-label="第 2 页"');
expect(html).not.toContain('aria-label="最后一页"');
});
it("limits table rows and shares the page-size control even for empty lists", () => {
it("一页放得下时只留总数,不显示翻页;没有条目时不渲染", () => {
const render = (total: number) =>
renderToStaticMarkup(
createElement(Pagination, { page: 1, pageSize: 50, total, onPageChange: () => {}, onPageSizeChange: () => {} }),
);
const short = render(8);
expect(short).toContain("共 8 条");
expect(short).not.toContain("上一页");
expect(short).not.toContain('aria-label="每页条数"');
const fits = render(30);
expect(fits).not.toContain("上一页");
expect(fits).toContain('aria-label="每页条数"');
expect(render(0)).toBe("");
});
it("limits table rows and shares the page-size control", () => {
const table = (rows: { id: string }[]) =>
renderToStaticMarkup(
createElement(DataTable<{ id: string }>, {
@@ -42,6 +56,6 @@ describe("list pagination", () => {
expect(html.split("<tbody>")[1]?.split("</tbody>")[0]?.match(/<tr /g)).toHaveLength(50);
expect(html).toContain('aria-label="每页条数"');
expect(html).not.toContain("item-50</td>");
expect(table([])).toContain('aria-label="每页条数"');
expect(table([])).not.toContain("列表分页");
});
});
@@ -105,7 +105,7 @@ export function CacheVolumes() {
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">下载盘</h2>
<div className="flex flex-wrap gap-2">
<Button asChild variant="ghost" size="sm">
<Button asChild variant="outline" size="sm">
<Link to={settingsPath("downloaders", "cache")}>
<SlidersHorizontal />
调整阈值
@@ -11,6 +11,7 @@ import { Link } from "react-router";
import type { DataColumn } from "~/components/console/data-table.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { SegmentedControl } from "~/components/ui/segmented.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
import { formatEta, formatSpeed, liveStateLabel, organizeState, peersText, progressOf } from "./board-format.ts";
@@ -20,17 +21,28 @@ import { PlanActions, PlanWaitBadge } from "./plan-row.tsx";
// 下载面板(DL-2):顶部总速度 + 「进行中 / 已完成」分段;进行中看进度与速度,已完成看完成时间、分享率与整理状态。
// DL-3:进行中里还有没提交到下载器的下载(等你批准 / 等磁盘空间…),与下载任务按加入时间一起排。
/** 下载 / 上传速度一对(0 显示"—") */
export function SpeedPair({ dl, up, className }: { dl: number; up: number; className?: string }) {
/** 下载 / 上传速度一对(行内 0 显示"—";总速度一行传 showZero,空闲时显示 0 B/s,不留两道横线) */
export function SpeedPair({
dl,
up,
className,
showZero = false,
}: {
dl: number;
up: number;
className?: string;
showZero?: boolean;
}) {
const fmt = (v: number) => (showZero && !(v > 0) ? "0 B/s" : formatSpeed(v));
return (
<span className={cn("inline-flex items-center gap-2.5 tabular-nums whitespace-nowrap", className)}>
<span className="inline-flex items-center gap-0.5" title="下载速度">
<ArrowDown aria-label="下载" className="size-3.5 text-muted-foreground" />
{formatSpeed(dl)}
{fmt(dl)}
</span>
<span className="inline-flex items-center gap-0.5" title="上传速度">
<ArrowUp aria-label="上传" className="size-3.5 text-muted-foreground" />
{formatSpeed(up)}
{fmt(up)}
</span>
</span>
);
@@ -45,9 +57,10 @@ export function TransferLine({ transfer }: { transfer: DownloadBoard["transfer"]
? transfer.instances.map((i) => `${i.name}:↓ ${formatSpeed(i.dlSpeed)} ↑ ${formatSpeed(i.upSpeed)}`).join("\n")
: undefined;
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
<span title={perInstance} className="text-foreground">
<SpeedPair dl={transfer.dlSpeed} up={transfer.upSpeed} />
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-caption">
<span title={perInstance ?? "所有下载器的实时总速度"} className="inline-flex items-center gap-2 text-foreground">
<span className="text-muted-foreground">总速度</span>
<SpeedPair dl={transfer.dlSpeed} up={transfer.upSpeed} showZero />
</span>
{down.length ? (
<span className="text-meta text-warning">{down.map((i) => `「${i.name}」`).join("")}连接失败</span>
@@ -65,36 +78,16 @@ export function BoardSegments({
counts: DownloadBoard["counts"] | undefined;
onChange: (v: DownloadBoardState) => void;
}) {
const options: { value: DownloadBoardState; label: string }[] = [
{ value: "active", label: "进行中" },
{ value: "completed", label: "已完成" },
];
return (
<div
role="radiogroup"
aria-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-3 py-1 text-caption transition-colors duration-micro",
value === o.value
? "bg-foreground text-background"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{o.label}
{counts ? <span className="tabular-nums opacity-80">{counts[o.value]}</span> : null}
</button>
))}
</div>
<SegmentedControl
label="下载任务分组"
value={value}
onChange={onChange}
options={[
{ value: "active", label: "进行中", count: counts?.active },
{ value: "completed", label: "已完成", count: counts?.completed },
]}
/>
);
}
@@ -200,7 +200,7 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
))}
</div>
)}
{checks.length > 0 ? <Pagination {...pagedChecks.pagination} /> : null}
{checks.length > 0 ? <Pagination {...pagedChecks.pagination} hideSingle /> : null}
</section>
<section aria-labelledby="auto">
<SectionHead
@@ -459,20 +459,13 @@ function MorePanel({
className={cn(
"inline-flex h-8 max-w-full cursor-pointer items-center gap-1.5 rounded-full border px-3 text-caption transition-colors duration-micro md:h-7",
on
? "border-primary bg-primary text-primary-foreground"
? "pill-selected text-foreground"
: "border-border text-foreground hover:bg-foreground/[0.06]",
dead ? "cursor-default opacity-40 hover:bg-transparent" : "",
)}
>
<span className="min-w-0 truncate">{o.label}</span>
<span
className={cn(
"text-meta tabular-nums",
on ? "text-primary-foreground/80" : "text-muted-foreground",
)}
>
{o.count}
</span>
<span className="text-meta text-muted-foreground tabular-nums">{o.count}</span>
</button>
);
})}
@@ -4,7 +4,11 @@ import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
import { openSearchPalette, searchShortcutLabel } from "./search-store.ts";
/** 顶栏搜索入口:桌面是带快捷键提示的输入框样按钮,手机是图标按钮;都打开命令面板 */
/**
* 顶栏搜索入口,放在右侧玻璃胶囊里的第一格(与 uhd_web 流媒体顶栏的搜索同一做法):
* 桌面是一枚平涂淡底的胶囊(搜索图标 + 提示 + 快捷键),手机只留图标;都打开命令面板。
* 胶囊本身已经是玻璃,里面不再套 .field-glass 或描边。
*/
export function SearchTrigger({ className }: { className?: string }) {
// 快捷键文案依赖 navigator,挂载后再算,避免水合不一致
const [shortcut, setShortcut] = useState("Ctrl K");
@@ -17,21 +21,20 @@ export function SearchTrigger({ className }: { className?: string }) {
aria-label="搜索"
aria-keyshortcuts="Control+K Meta+K"
className={cn(
// 长得像输入框,材质与输入框同一套(.field-glass)
"field-glass hidden h-8 w-56 cursor-pointer items-center gap-2 rounded-full px-3 text-caption text-muted-foreground outline-none hover:text-foreground md:flex lg:w-72",
"hidden h-8 w-48 cursor-pointer items-center gap-2 rounded-full bg-foreground/[0.05] pr-1.5 pl-3 text-caption text-muted-foreground outline-none transition-colors duration-base hover:bg-foreground/[0.08] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 md:flex lg:w-60",
className,
)}
>
<Search className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-left">搜索作品、页面</span>
<kbd className="rounded-tight border border-border/70 px-1 font-sans text-label leading-4 font-medium">
<kbd className="rounded-full bg-foreground/[0.06] px-1.5 font-sans text-label leading-5 font-medium">
{shortcut}
</kbd>
</button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 md:hidden"
size="icon-sm"
className="md:hidden"
aria-label="搜索"
onClick={() => openSearchPalette()}
>
@@ -185,7 +185,7 @@ export function PiOAuthLogin({
className={cn(
"rounded-full border px-3 py-1 text-caption transition-colors duration-micro",
mode === m
? "border-foreground/30 bg-foreground/[0.08] text-foreground"
? "pill-selected text-foreground"
: "border-border text-muted-foreground hover:bg-foreground/[0.04]",
)}
>
@@ -2,9 +2,9 @@ import type { SiteAccount, SiteCredentialsBody } from "@umt/contracts";
import { FormField } from "~/components/console/form-field.tsx";
import { SecretInput } from "~/components/console/secret-input.tsx";
import { Input } from "~/components/ui/input.tsx";
import { SegmentedControl } from "~/components/ui/segmented.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { secretHint, secretPatch } from "~/lib/secret.ts";
import { cn } from "~/lib/utils.ts";
import { AUTH_KIND_LABEL, type LoginKind } from "./site-fields.ts";
// 凭证三选一(账号密码 / Cookie / API Key)。只写不回显;添加站点向导与"更新凭证"对话框共用。
@@ -106,29 +106,14 @@ export function CredentialFields({
return (
<div className="grid gap-x-4 gap-y-1">
{kinds.length > 1 ? (
<div className="mb-4" role="radiogroup" aria-label="凭证类型">
<div className="mb-4">
<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>
<SegmentedControl
label="凭证类型"
value={value.kind}
onChange={(k) => set("kind", k)}
options={kinds.map((k) => ({ value: k, label: AUTH_KIND_LABEL[k], disabled }))}
/>
</div>
) : null}
@@ -278,11 +278,7 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
<span
className={cn(
"grid h-5 w-5 place-items-center rounded-full border text-micro",
step > i
? "border-foreground bg-foreground text-background"
: step === i
? "border-foreground"
: "border-border",
step > i ? "pill-selected text-foreground" : step === i ? "border-foreground" : "border-border",
)}
>
{step > i ? <Check className="h-3 w-3" /> : i + 1}
+12 -9
View File
@@ -477,15 +477,18 @@ function AccountSiteCard({ site, checks, onDialog }: Props) {
<div aria-hidden className="min-h-4 flex-1" />
<footer className="mt-auto flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
{siteCan(site, "search") ? <SearchDefaultSwitch site={site} /> : <span className="mr-auto" />}
{acc ? (
<CheckButtons site={site} checks={checks} />
) : (
<Button size="sm" onClick={() => onDialog({ kind: "credentials", site })}>
<KeyRound />
填写凭证
</Button>
)}
<SiteMoreMenu site={site} onDialog={onDialog} />
{/* 操作与「更多」放一组整体换行,窄卡片里「⋯」不会单独掉到下一行 */}
<div className="ml-auto flex items-center gap-2">
{acc ? (
<CheckButtons site={site} checks={checks} />
) : (
<Button size="sm" onClick={() => onDialog({ kind: "credentials", site })}>
<KeyRound />
填写凭证
</Button>
)}
<SiteMoreMenu site={site} onDialog={onDialog} />
</div>
</footer>
</article>
);
@@ -13,9 +13,9 @@ import {
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { SegmentedControl } from "~/components/ui/segmented.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
// 站点内添加 RSS:站点固定,账号 / 解析器 / 轮询由服务端按站点带出;只填地址、用途、可选备注。
// 保存后立即抓一次,在对话框里等结果(条目数或错误)。
@@ -163,28 +163,14 @@ export function SiteFeedDialog({
</p>
) : null}
<div className="mb-3" role="radiogroup" aria-label="用途">
<div className="mb-3">
<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">
{PURPOSES.map((p) => (
// biome-ignore lint/a11y/useSemanticElements: 分段按钮,radio 语义由 role 表达
<button
key={p.value}
type="button"
role="radio"
aria-checked={purpose === p.value}
onClick={() => setPurpose(p.value)}
className={cn(
"rounded-lg px-3 py-1.5 text-caption transition-colors",
purpose === p.value
? "bg-foreground text-background"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{p.label}
</button>
))}
</div>
<SegmentedControl
label="用途"
value={purpose}
onChange={setPurpose}
options={PURPOSES.map((p) => ({ value: p.value, label: p.label }))}
/>
<p className="mt-1.5 text-caption text-muted-foreground">
{PURPOSES.find((p) => p.value === purpose)?.hint}
</p>
+138 -142
View File
@@ -324,150 +324,146 @@ export function StorageSection({ health }: { health: SystemHealth }) {
<BudgetBar label="会话与证据" u={s.sessionsEvidence} />
</ul>
<div className="grid gap-6 xl:grid-cols-2">
<TechInfo title={`数据库里最大的 ${TOP_TABLES} 张表`} columns={1}>
<Panel title={`体积最大的表(前 ${TOP_TABLES})`} note="含索引与 TOAST">
<DataTable<TableRow>
rows={s.db.tables.slice(0, TOP_TABLES)}
rowKey={(t) => `${t.schema}.${t.name}`}
empty={{ title: "取不到表体积" }}
columns={[
{
key: "name",
header: "表",
cell: (t) => (
<span className="font-mono text-caption break-all">
{t.schema === "public" ? t.name : `${t.schema}.${t.name}`}
</span>
),
},
{
key: "total",
header: "合计",
align: "right",
cell: (t) => (
<span className="whitespace-nowrap tabular-nums">{formatBytes(t.totalBytes, 1)}</span>
),
},
{
key: "idx",
header: "索引",
align: "right",
hideBelow: "md",
cell: (t) => (
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
{formatBytes(t.indexBytes, 1)}
</span>
),
},
{
key: "rows",
header: "估算行数",
align: "right",
hideBelow: "sm",
cell: (t) => (
<span className="tabular-nums text-muted-foreground">
{t.rowsEstimate === null ? "—" : t.rowsEstimate.toLocaleString("zh-CN")}
</span>
),
},
{
key: "dead",
header: "死元组",
align: "right",
hideBelow: "md",
cell: (t) => (
<span className="tabular-nums text-muted-foreground">
{t.deadRows === null ? "—" : t.deadRows.toLocaleString("zh-CN")}
</span>
),
},
]}
/>
</Panel>
</TechInfo>
<div className="space-y-6">
<Panel title="缓存" note="条数含还没清理的过期条目">
<DataTable<KvRow>
rows={s.kvCache}
rowKey={(r) => r.region}
empty={{ title: "缓存为空" }}
columns={[
{
key: "region",
header: "类别",
cell: (r) => (
<span title={r.region}>
{REGION_LABEL[r.region] ?? <span className="font-mono text-caption">{r.region}</span>}
</span>
),
},
{
key: "entries",
header: "条数",
align: "right",
cell: (r) => <span className="tabular-nums">{r.entries.toLocaleString("zh-CN")}</span>,
},
{
key: "expired",
header: "已过期",
align: "right",
hideBelow: "sm",
cell: (r) => (
<span className="tabular-nums text-muted-foreground">{r.expired.toLocaleString("zh-CN")}</span>
),
},
{
key: "bytes",
header: "体积",
align: "right",
cell: (r) => <span className="whitespace-nowrap tabular-nums">{formatBytes(r.bytes, 1)}</span>,
},
]}
/>
</Panel>
<Panel title="数据目录" note="DATA_DIR 各子目录">
<DataTable<DirRow>
rows={s.dataDir}
rowKey={(d) => d.path}
empty={{ title: "还没有统计", description: "目录用量每 5 分钟统计一次。" }}
columns={[
{
key: "name",
header: "目录",
cell: (d) => (
<div className="min-w-0">
<p className="font-medium">{d.name}</p>
<p className="truncate font-mono text-meta text-muted-foreground" title={d.path}>
{d.path}
</p>
{d.error ? <p className="text-meta text-destructive">{d.error}</p> : null}
</div>
),
},
{
key: "files",
header: "文件",
align: "right",
hideBelow: "sm",
cell: (d) => (
<span className="tabular-nums text-muted-foreground">{d.files.toLocaleString("zh-CN")}</span>
),
},
{
key: "used",
header: "占用",
align: "right",
cell: (d) => (
<span className="whitespace-nowrap tabular-nums">{formatBytes(d.usedBytes, 1)}</span>
),
},
]}
/>
</Panel>
</div>
{/* 缓存与数据目录在宽屏并排;两个折叠区(最大的表、语句统计)整行放在下面,收起时不留空栏 */}
<div className="grid items-start gap-6 lg:grid-cols-2">
<Panel title="缓存" note="条数含还没清理的过期条目">
<DataTable<KvRow>
rows={s.kvCache}
rowKey={(r) => r.region}
empty={{ title: "缓存为空" }}
columns={[
{
key: "region",
header: "类别",
cell: (r) => (
<span title={r.region}>
{REGION_LABEL[r.region] ?? <span className="font-mono text-caption">{r.region}</span>}
</span>
),
},
{
key: "entries",
header: "条数",
align: "right",
cell: (r) => <span className="tabular-nums">{r.entries.toLocaleString("zh-CN")}</span>,
},
{
key: "expired",
header: "已过期",
align: "right",
hideBelow: "sm",
cell: (r) => (
<span className="tabular-nums text-muted-foreground">{r.expired.toLocaleString("zh-CN")}</span>
),
},
{
key: "bytes",
header: "体积",
align: "right",
cell: (r) => <span className="whitespace-nowrap tabular-nums">{formatBytes(r.bytes, 1)}</span>,
},
]}
/>
</Panel>
<Panel title="数据目录" note="DATA_DIR 各子目录">
<DataTable<DirRow>
rows={s.dataDir}
rowKey={(d) => d.path}
empty={{ title: "还没有统计", description: "目录用量每 5 分钟统计一次。" }}
columns={[
{
key: "name",
header: "目录",
cell: (d) => (
<div className="min-w-0">
<p className="font-medium">{d.name}</p>
<p className="truncate font-mono text-meta text-muted-foreground" title={d.path}>
{d.path}
</p>
{d.error ? <p className="text-meta text-destructive">{d.error}</p> : null}
</div>
),
},
{
key: "files",
header: "文件",
align: "right",
hideBelow: "sm",
cell: (d) => (
<span className="tabular-nums text-muted-foreground">{d.files.toLocaleString("zh-CN")}</span>
),
},
{
key: "used",
header: "占用",
align: "right",
cell: (d) => <span className="whitespace-nowrap tabular-nums">{formatBytes(d.usedBytes, 1)}</span>,
},
]}
/>
</Panel>
</div>
<TechInfo title={`数据库里最大的 ${TOP_TABLES} 张表`} columns={1}>
<Panel title={`体积最大的表(前 ${TOP_TABLES})`} note="含索引与 TOAST">
<DataTable<TableRow>
rows={s.db.tables.slice(0, TOP_TABLES)}
rowKey={(t) => `${t.schema}.${t.name}`}
empty={{ title: "取不到表体积" }}
columns={[
{
key: "name",
header: "表",
cell: (t) => (
<span className="font-mono text-caption break-all">
{t.schema === "public" ? t.name : `${t.schema}.${t.name}`}
</span>
),
},
{
key: "total",
header: "合计",
align: "right",
cell: (t) => <span className="whitespace-nowrap tabular-nums">{formatBytes(t.totalBytes, 1)}</span>,
},
{
key: "idx",
header: "索引",
align: "right",
hideBelow: "md",
cell: (t) => (
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
{formatBytes(t.indexBytes, 1)}
</span>
),
},
{
key: "rows",
header: "估算行数",
align: "right",
hideBelow: "sm",
cell: (t) => (
<span className="tabular-nums text-muted-foreground">
{t.rowsEstimate === null ? "—" : t.rowsEstimate.toLocaleString("zh-CN")}
</span>
),
},
{
key: "dead",
header: "死元组",
align: "right",
hideBelow: "md",
cell: (t) => (
<span className="tabular-nums text-muted-foreground">
{t.deadRows === null ? "—" : t.deadRows.toLocaleString("zh-CN")}
</span>
),
},
]}
/>
</Panel>
</TechInfo>
<StatementsInfo st={s.db.statements} />
<Panel title="清理任务" note="立即清理会把维护任务加入队列,结果在运行记录里看">
+116 -89
View File
@@ -14,13 +14,19 @@ export type PaginationProps = {
onPageChange: (page: number) => void;
onPageSizeChange?: (size: number) => void;
className?: string;
/** 一页放得下时整块不显示(卡片网格下面单独一行「共 N 条」显得悬空) */
hideSingle?: boolean;
};
export function pageNumbers(page: number, pages: number): (number | "…")[] {
const values = new Set([1, pages, page - 1, page, page + 1]);
const valid = [...values].filter((n) => n >= 1 && n <= pages).sort((a, b) => a - b);
return valid.flatMap((n, i): (number | "…")[] => (i && n - valid[i - 1] > 1 ? ["…", n] : [n]));
}
/** Shared pagination for tables, cards and cursor-backed record lists. */
/**
* 表格、卡片列表与游标列表共用的分页。
* 一页就放得下(只有 1 页、没有更多)时不显示翻页按钮,只留总数;连每页条数也用不上(不超过最小档)时只剩总数;
* 没有任何条目时整块不渲染(空态已经说明)。翻页按钮按容器宽度收起(`@container`):窄卡片里不折成两行。
*/
export function Pagination({
page,
pageSize,
@@ -30,105 +36,126 @@ export function Pagination({
onPageChange,
onPageSizeChange,
className,
hideSingle = false,
}: PaginationProps) {
const loadedPages = Math.max(1, Math.ceil(total / pageSize));
const pages = loadedPages + (hasMore ? 1 : 0);
const from = total ? (page - 1) * pageSize + 1 : 0;
const to = Math.min(page * pageSize, total);
if (!total && !hasMore) return null;
const single = pages <= 1;
const showSize = !!onPageSizeChange && total > PAGE_SIZES[0];
if (single && hideSingle && !showSize) return null;
return (
<nav
aria-label="列表分页"
aria-busy={busy}
className={cn("flex flex-wrap items-center justify-between gap-3 border-t border-border/60 px-4 py-3", className)}
>
<div className="flex flex-wrap items-center gap-3 text-meta text-muted-foreground">
<span aria-live="polite" className="tabular-nums">
{hasMore ? `已加载 ${total} 条` : `共 ${total} 条`} · {from}–{to} 条
</span>
{onPageSizeChange ? (
<div className="flex items-center gap-2">
<span>每页</span>
<Select value={String(pageSize)} disabled={busy} onValueChange={(value) => onPageSizeChange(Number(value))}>
<SelectTrigger size="sm" aria-label="每页条数" className="min-w-24 text-caption text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" align="start" collisionPadding={16}>
{PAGE_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>
{size} 条
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</div>
<div className="flex max-w-full flex-wrap items-center gap-1">
<Button
type="button"
size="sm"
variant="ghost"
aria-label="第一页"
disabled={busy || page <= 1}
onClick={() => onPageChange(1)}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
type="button"
size="sm"
variant="outline"
aria-label="上一页"
disabled={busy || page <= 1}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden sm:inline">上一页</span>
</Button>
{pageNumbers(page, pages).map((n, i) =>
n === "…" ? (
<span key={`gap-${i}`} aria-hidden className="px-1 text-muted-foreground">
…
</span>
) : (
<div className={cn("@container border-t border-border/60", className)}>
<nav
aria-label="列表分页"
aria-busy={busy}
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-4 py-2.5 @lg:flex-nowrap"
>
<div className="flex min-w-0 items-center gap-3 text-meta whitespace-nowrap text-muted-foreground">
<span aria-live="polite" className="tabular-nums">
{hasMore ? `已加载 ${total} 条` : `共 ${total} 条`}
{single ? null : (
<span className="hidden @md:inline">
{" "}
· {from}–{to} 条
</span>
)}
</span>
{showSize ? (
<div className="flex items-center gap-2">
<span className="hidden @sm:inline">每页</span>
<Select
value={String(pageSize)}
disabled={busy}
onValueChange={(value) => onPageSizeChange?.(Number(value))}
>
<SelectTrigger size="sm" aria-label="每页条数" className="min-w-22 text-caption text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" align="start" collisionPadding={16}>
{PAGE_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>
{size} 条
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</div>
{single ? null : (
<div className="ml-auto flex shrink-0 items-center gap-1">
<Button
key={n}
type="button"
size="sm"
variant={n === page ? "secondary" : "ghost"}
aria-label={`第 ${n} 页`}
aria-current={n === page ? "page" : undefined}
disabled={busy}
onClick={() => onPageChange(n)}
variant="ghost"
aria-label="第一页"
className="hidden @xl:inline-flex"
disabled={busy || page <= 1}
onClick={() => onPageChange(1)}
>
{n}
<ChevronsLeft className="h-4 w-4" />
</Button>
),
<Button
type="button"
size="sm"
variant="outline"
aria-label="上一页"
disabled={busy || page <= 1}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden @2xl:inline">上一页</span>
</Button>
{pageNumbers(page, pages).map((n, i) =>
n === "…" ? (
<span key={`gap-${i}`} aria-hidden className="px-1 text-muted-foreground">
…
</span>
) : (
<Button
key={n}
type="button"
size="sm"
variant={n === page ? "outline" : "ghost"}
aria-label={`第 ${n} 页`}
aria-current={n === page ? "page" : undefined}
disabled={busy}
onClick={() => onPageChange(n)}
>
{n}
</Button>
),
)}
<Button
type="button"
size="sm"
variant="outline"
aria-label="下一页"
disabled={busy || page >= pages}
onClick={() => onPageChange(page + 1)}
>
<span className="hidden @2xl:inline">下一页</span>
<ChevronRight className="h-4 w-4" />
</Button>
{!hasMore ? (
<Button
type="button"
size="sm"
variant="ghost"
aria-label="最后一页"
className="hidden @xl:inline-flex"
disabled={busy || page >= pages}
onClick={() => onPageChange(pages)}
>
<ChevronsRight className="h-4 w-4" />
</Button>
) : null}
</div>
)}
<Button
type="button"
size="sm"
variant="outline"
aria-label="下一页"
disabled={busy || page >= pages}
onClick={() => onPageChange(page + 1)}
>
<span className="hidden sm:inline">下一页</span>
<ChevronRight className="h-4 w-4" />
</Button>
{!hasMore ? (
<Button
type="button"
size="sm"
variant="ghost"
aria-label="最后一页"
disabled={busy || page >= pages}
onClick={() => onPageChange(pages)}
>
<ChevronsRight className="h-4 w-4" />
</Button>
) : null}
</div>
</nav>
</nav>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { type ReactNode, type RefObject, useLayoutEffect, useRef, useState } from "react";
import { cn } from "~/lib/utils.ts";
type Thumb = { x: number; y: number; w: number; h: number };
/**
* 分段控件的滑块:量出容器里当前选中项(`selector`)的位置写进 style,选中项变化、尺寸变化时跟着滑过去。
* 第一次量到之前不画,量到后才挂过渡,避免首屏从左上角滑进来。与 uhd_web SegmentedTabs、控制台侧栏同一做法。
*/
export function useSlidingThumb(container: RefObject<HTMLElement | null>, selector: string, deps: unknown[] = []) {
const [thumb, setThumb] = useState<Thumb | null>(null);
const [animate, setAnimate] = useState(false);
useLayoutEffect(() => {
const el = container.current;
if (!el) return;
const measure = () => {
const active = el.querySelector<HTMLElement>(selector);
setThumb((prev) => {
if (!active) return null;
const next = { x: active.offsetLeft, y: active.offsetTop, w: active.offsetWidth, h: active.offsetHeight };
return prev && prev.x === next.x && prev.y === next.y && prev.w === next.w && prev.h === next.h ? prev : next;
});
};
measure();
const raf = requestAnimationFrame(() => setAnimate(true));
const ro = new ResizeObserver(measure);
ro.observe(el);
for (const child of Array.from(el.children)) ro.observe(child);
// radix Tabs 的选中态写在 data-state 上,不一定引起本组件重渲染
const mo = new MutationObserver(measure);
mo.observe(el, { subtree: true, attributes: true, attributeFilter: ["data-state", "aria-checked"] });
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
mo.disconnect();
};
}, [container, selector, ...deps]);
return { thumb, animate };
}
/** 滑块本体(`seg-thumb` 玻璃),放在相对定位的容器里、选项之前 */
export function SegThumb({ thumb, animate, className }: { thumb: Thumb | null; animate: boolean; className?: string }) {
if (!thumb) return null;
return (
<span
aria-hidden
className={cn(
"seg-thumb pointer-events-none absolute top-0 left-0 rounded-full",
animate && "tab-slider",
className,
)}
style={{ transform: `translate(${thumb.x}px, ${thumb.y}px)`, width: thumb.w, height: thumb.h }}
/>
);
}
export type SegmentedOption<V extends string> = {
value: V;
label: ReactNode;
icon?: ReactNode;
/** 跟在标签后面的数量,弱一档显示 */
count?: ReactNode;
/** 只有图标时给读屏与悬停提示 */
title?: string;
disabled?: boolean;
};
/**
* 单选分段控件:筛选(全部 / 已启用)、视图(卡片 / 列表)、分组(进行中 / 已完成)这类互斥选项。
* 选中项由一枚滑动的 `seg-thumb` 承担,文字提一档;没有实心选中块。多选的筛选条件用 `pill-selected` 胶囊,不用它。
*/
export function SegmentedControl<V extends string>({
value,
onChange,
options,
label,
size = "sm",
className,
fill = false,
}: {
value: V;
onChange: (v: V) => void;
options: SegmentedOption<V>[];
/** 读屏用的组名 */
label: string;
size?: "xs" | "sm";
className?: string;
/** 铺满容器宽度,选项等分 */
fill?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const { thumb, animate } = useSlidingThumb(ref, '[aria-checked="true"]', [value, options.length]);
return (
<div
ref={ref}
role="radiogroup"
aria-label={label}
className={cn(
"relative isolate items-center gap-0.5",
fill ? "flex w-full" : "inline-flex max-w-full",
className,
)}
>
<SegThumb thumb={thumb} animate={animate} className="-z-[1]" />
{options.map((o) => {
const on = o.value === value;
return (
<button
key={o.value}
type="button"
role="radio"
aria-checked={on}
aria-label={o.title}
title={o.title}
disabled={o.disabled}
onClick={() => onChange(o.value)}
className={cn(
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-full font-medium whitespace-nowrap outline-none transition-colors duration-base focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-3.5 [&_svg]:shrink-0",
size === "xs" ? "h-7 px-2.5 text-meta" : "h-8 px-3 text-caption",
o.label === "" && (size === "xs" ? "w-7 px-0" : "w-8 px-0"),
fill && "flex-1",
on ? "text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{o.icon}
{o.label === "" ? null : o.label}
{o.count != null ? (
<span className={cn("tabular-nums", on ? "text-muted-foreground" : "text-muted-foreground/70")}>
{o.count}
</span>
) : null}
</button>
);
})}
</div>
);
}
+13 -5
View File
@@ -1,20 +1,28 @@
import { Tabs as TabsPrimitive } from "radix-ui";
import type * as React from "react";
import { useRef } from "react";
import { SegThumb, useSlidingThumb } from "~/components/ui/segmented.tsx";
import { cn } from "~/lib/utils.ts";
// 分段标签:列表横向可滚(scroll-bleed 保留描边不被裁),选中态用静态 pill-selected
// 分段标签:列表横向可滚(scroll-bleed 保留滑块的投影与描边不被裁),选中项由一枚滑动的 seg-thumb 承担(同 SegmentedControl)
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>) {
function TabsList({ className, children, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
const ref = useRef<HTMLDivElement>(null);
const { thumb, animate } = useSlidingThumb(ref, '[data-slot="tabs-trigger"][data-state="active"]');
return (
<TabsPrimitive.List
ref={ref}
data-slot="tabs-list"
className={cn("scroll-bleed flex max-w-full items-center gap-1 overflow-x-auto pb-1", className)}
className={cn("scroll-bleed relative isolate flex max-w-full items-center gap-1 overflow-x-auto", className)}
{...props}
/>
>
<SegThumb thumb={thumb} animate={animate} className="-z-[1]" />
{children}
</TabsPrimitive.List>
);
}
@@ -23,7 +31,7 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
<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",
"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-colors duration-base outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground [&_svg]:size-4",
className,
)}
{...props}