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:
1 parent
8ff1817845
commit
c9b4ceb301
32 files changed
+753
-639
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user