merge: ui-polish(搜索框、选中态、按钮、存储布局、智能体会话)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
a673d48f01
32 files changed
+559
-445
No files matched your search
@@ -2,6 +2,22 @@
|
||||
|
||||
版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。所有成员包(`apps/*`、`packages/*`)与根 `deno.json` 使用同一个版本号;`/api/v1/health` 与备份清单里的版本取自 `apps/server/deno.json`。
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 调整
|
||||
- 顶栏搜索移进右侧玻璃胶囊(与通知、日夜切换、账户同一块),桌面是带 Ctrl K 提示的淡底胶囊,手机只留图标;不再是旁边单独一个黑边输入框。
|
||||
- 输入框、下拉选择改成淡玻璃:半透明底、细边,悬停时边线加深;聚焦和出错的光晕与红线保持清楚。
|
||||
- 分段选择统一换成滑动的玻璃滑块,不再有实心黑色的选中块:站点的「全部 / 已启用 / 需要处理」和「卡片 / 列表」、下载面板的「进行中 / 已完成」、订阅的类型与视图、文件管理的筛选、智能体会话列表的分栏与筛选、页面里的标签页(媒体库、日志、规则、运行状态的时间范围等)、站点凭证类型与 RSS 用途。
|
||||
- 按钮角色统一:页头与工具栏的次要操作一律是描边胶囊(整理记录的「批量」、订阅的「规则」「批量」、日志的「刷新」、下载盘的「调整阈值」),开关型按钮按下后显示选中外观;文件管理工具栏的图标按钮统一为描边圆钮,排序改用下拉选择。
|
||||
- 下载面板的总速度标上「总速度」,空闲时显示 0 B/s,不再是两道横线。
|
||||
- 分页:一页就放得下时只留总数,不显示翻页按钮;没有条目时不显示;窄卡片里按宽度收起首页 / 末页和「上一页 / 下一页」文字,不折成两行。
|
||||
- 智能体页:列表页和会话页共用同一个外壳,标题和左栏会话列表切换时不再消失、不重播动画,两栏正好占满一屏;会话列表顶部的分栏、搜索和筛选拉开间距。
|
||||
- 设置页左侧当前分区、整理记录顶部的状态计数、搜索结果的多选条件改用玻璃选中态。
|
||||
|
||||
### 修复
|
||||
- 运行状态 → 存储:缓存与数据目录在宽屏并排,「数据库里最大的 10 张表」「数据库语句统计」整行放在下面,不再留出大片空白的左栏。
|
||||
- 站点卡片底部的「⋯」不再单独掉到下一行。
|
||||
|
||||
## [0.3.39] - 2026-10-08
|
||||
|
||||
### 修复
|
||||
|
||||
+30
-12
@@ -392,14 +392,16 @@
|
||||
--mat-lens-shadow: 0 6px 16px -10px rgba(20, 22, 28, 0.28);
|
||||
--mat-lens-blur: blur(2px) saturate(160%);
|
||||
/* field:输入框 / 下拉选择的触发器,与按钮同一套边光;底比按钮实一点,保证字和占位符好读 */
|
||||
/* 输入框是填充型:靠比卡片略深的底色区分,边线按 WCAG 非文本对比 3:1 给(压在白卡片上) */
|
||||
--mat-field-bg: rgba(20, 22, 28, 0.045);
|
||||
--mat-field-edge: rgba(20, 22, 28, 0.46);
|
||||
--mat-field-edge-focus: rgba(20, 22, 28, 0.78);
|
||||
/* 输入框是淡玻璃:半透明白底 + 细边 + 上沿内高光(2026-10-08 调轻:0.46 的边在控制台的筛选条、搜索框里读成一圈黑框);
|
||||
聚焦 / 展开时边线加深并带光晕,出错时红线,这两种状态仍要一眼看得出 */
|
||||
--mat-field-bg: rgba(255, 255, 255, 0.5);
|
||||
--mat-field-edge: rgba(20, 22, 28, 0.16);
|
||||
--mat-field-edge-focus: rgba(20, 22, 28, 0.5);
|
||||
--mat-field-edge-hover: rgba(20, 22, 28, 0.28);
|
||||
/* 登录页的表单卡片:实在的底,先把卡片和页面分开 */
|
||||
--mat-auth-card-bg: rgba(255, 255, 255, 0.84);
|
||||
--mat-field-ring: rgba(20, 22, 28, 0.06);
|
||||
--mat-field-highlight: rgba(255, 255, 255, 0.08);
|
||||
--mat-field-ring: rgba(20, 22, 28, 0.08);
|
||||
--mat-field-highlight: rgba(255, 255, 255, 0.7);
|
||||
/* 小玻璃圆片的烟色玻璃:白色图标坐在上面。日间烟墨、夜间淡白;压在图片上的恒用 media 档 */
|
||||
--mat-chip-glass: rgba(20, 22, 28, 0.56);
|
||||
/* media-hero:详情页头图。剧照铺在内容区顶部、渐隐到页面底色;左侧文字区压近乎不透明的底色,右侧露出剧照。
|
||||
@@ -539,12 +541,14 @@
|
||||
--mat-lens-edge: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
--mat-lens-sheen: inset 1px 1px 0 -0.5px rgba(255, 255, 255, 0.28);
|
||||
--mat-lens-shadow: 0 6px 16px -10px rgba(0, 0, 0, 0.55);
|
||||
/* 夜间:底比卡片略亮,外线是浅色且对比 3:1(黑线压在深色卡片上看不见) */
|
||||
--mat-field-bg: rgba(255, 255, 255, 0.075);
|
||||
--mat-field-edge: rgba(255, 255, 255, 0.36);
|
||||
--mat-field-edge-focus: rgba(255, 255, 255, 0.78);
|
||||
/* 夜间:底比卡片略亮,外线是浅色细线(黑线压在深色卡片上看不见) */
|
||||
--mat-field-bg: rgba(255, 255, 255, 0.05);
|
||||
--mat-field-edge: rgba(255, 255, 255, 0.16);
|
||||
--mat-field-edge-focus: rgba(255, 255, 255, 0.5);
|
||||
--mat-field-edge-hover: rgba(255, 255, 255, 0.28);
|
||||
--mat-auth-card-bg: rgba(26, 27, 32, 0.74);
|
||||
--mat-field-ring: rgba(255, 255, 255, 0.06);
|
||||
--mat-field-ring: rgba(255, 255, 255, 0.07);
|
||||
--mat-field-highlight: rgba(255, 255, 255, 0.04);
|
||||
--mat-chip-glass: rgba(255, 255, 255, 0.12);
|
||||
--mat-hero-image-opacity: 0.85;
|
||||
--mat-hero-scrim-side: linear-gradient(90deg, var(--background) 0%, color-mix(in oklab, var(--background) 94%, transparent) 38%, color-mix(in oklab, var(--background) 78%, transparent) 55%, color-mix(in oklab, var(--background) 36%, transparent) 72%, transparent 92%);
|
||||
@@ -1115,6 +1119,14 @@
|
||||
background: var(--mat-lens-fill-hover);
|
||||
}
|
||||
|
||||
/* 开关型的次按钮(批量、按作品分组、显示隐藏文件)按下后与 pill-selected 同一外观:多一层填充、描边稍深 */
|
||||
.pill-secondary[aria-pressed="true"],
|
||||
.pill-secondary[aria-current="page"] {
|
||||
background: var(--mat-pill-selected-bg);
|
||||
box-shadow: var(--mat-lens-rim), 0 0 0 1px var(--mat-pill-selected-border);
|
||||
color: var(--content-strong);
|
||||
}
|
||||
|
||||
/* 主操作(方案 A,2026-09-26):和其他按钮一样是半透明胶囊,不做实心;靠沿边框缓慢流转的
|
||||
一整圈棱镜光谱突出,内侧再透一层很淡的同色光。门户 .pill-primary 与流媒体
|
||||
.btn-glass-primary 共用;减少动态效果时停转,只留彩色边框 */
|
||||
@@ -1434,9 +1446,14 @@
|
||||
box-shadow: inset 0 1px 0 var(--mat-field-highlight);
|
||||
transition:
|
||||
box-shadow var(--duration-base) var(--ease-standard),
|
||||
border-color var(--duration-base) var(--ease-standard),
|
||||
background-color var(--duration-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.field-glass:hover:not(:focus-within, [data-state="open"], [aria-invalid="true"], :disabled) {
|
||||
border-color: var(--mat-field-edge-hover);
|
||||
}
|
||||
|
||||
.field-glass:focus-visible,
|
||||
.field-glass:focus-within,
|
||||
.field-glass[data-state="open"] {
|
||||
@@ -1446,7 +1463,8 @@
|
||||
}
|
||||
|
||||
.field-glass[aria-invalid="true"] {
|
||||
border-color: color-mix(in oklch, var(--destructive) 65%, transparent);
|
||||
border-color: color-mix(in oklch, var(--destructive) 70%, transparent);
|
||||
box-shadow: 0 0 0 3px color-mix(in oklch, var(--destructive) 12%, transparent);
|
||||
}
|
||||
|
||||
/* 登录类页面的表单卡片:内容卡的配方,但底换成实在的磨砂(夜间内容卡几乎透明,卡片、输入框、页面三层挤在一起分不开) */
|
||||
|
||||
@@ -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>
|
||||
<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 md:text-caption"
|
||||
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}
|
||||
|
||||
@@ -477,6 +477,8 @@ 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" />}
|
||||
{/* 操作与「更多」放一组整体换行,窄卡片里「⋯」不会单独掉到下一行 */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{acc ? (
|
||||
<CheckButtons site={site} checks={checks} />
|
||||
) : (
|
||||
@@ -486,6 +488,7 @@ function AccountSiteCard({ site, checks, onDialog }: Props) {
|
||||
</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>
|
||||
|
||||
@@ -324,69 +324,8 @@ 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">
|
||||
{/* 缓存与数据目录在宽屏并排;两个折叠区(最大的表、语句统计)整行放在下面,收起时不留空栏 */}
|
||||
<div className="grid items-start gap-6 lg:grid-cols-2">
|
||||
<Panel title="缓存" note="条数含还没清理的过期条目">
|
||||
<DataTable<KvRow>
|
||||
rows={s.kvCache}
|
||||
@@ -458,15 +397,72 @@ export function StorageSection({ health }: { health: SystemHealth }) {
|
||||
key: "used",
|
||||
header: "占用",
|
||||
align: "right",
|
||||
cell: (d) => (
|
||||
<span className="whitespace-nowrap tabular-nums">{formatBytes(d.usedBytes, 1)}</span>
|
||||
),
|
||||
cell: (d) => <span className="whitespace-nowrap tabular-nums">{formatBytes(d.usedBytes, 1)}</span>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
</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} />
|
||||
|
||||
|
||||
@@ -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,26 +36,42 @@ 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 (
|
||||
<div className={cn("@container border-t border-border/60", className)}>
|
||||
<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)}
|
||||
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 flex-wrap items-center gap-3 text-meta text-muted-foreground">
|
||||
<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} 条`} · {from}–{to} 条
|
||||
{hasMore ? `已加载 ${total} 条` : `共 ${total} 条`}
|
||||
{single ? null : (
|
||||
<span className="hidden @md:inline">
|
||||
{" "}
|
||||
· {from}–{to} 条
|
||||
</span>
|
||||
{onPageSizeChange ? (
|
||||
)}
|
||||
</span>
|
||||
{showSize ? (
|
||||
<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">
|
||||
<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}>
|
||||
@@ -63,12 +85,14 @@ export function Pagination({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex max-w-full flex-wrap items-center gap-1">
|
||||
{single ? null : (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="第一页"
|
||||
className="hidden @xl:inline-flex"
|
||||
disabled={busy || page <= 1}
|
||||
onClick={() => onPageChange(1)}
|
||||
>
|
||||
@@ -83,7 +107,7 @@ export function Pagination({
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">上一页</span>
|
||||
<span className="hidden @2xl:inline">上一页</span>
|
||||
</Button>
|
||||
{pageNumbers(page, pages).map((n, i) =>
|
||||
n === "…" ? (
|
||||
@@ -95,7 +119,7 @@ export function Pagination({
|
||||
key={n}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={n === page ? "secondary" : "ghost"}
|
||||
variant={n === page ? "outline" : "ghost"}
|
||||
aria-label={`第 ${n} 页`}
|
||||
aria-current={n === page ? "page" : undefined}
|
||||
disabled={busy}
|
||||
@@ -113,7 +137,7 @@ export function Pagination({
|
||||
disabled={busy || page >= pages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>
|
||||
<span className="hidden sm:inline">下一页</span>
|
||||
<span className="hidden @2xl:inline">下一页</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
{!hasMore ? (
|
||||
@@ -122,6 +146,7 @@ export function Pagination({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="最后一页"
|
||||
className="hidden @xl:inline-flex"
|
||||
disabled={busy || page >= pages}
|
||||
onClick={() => onPageChange(pages)}
|
||||
>
|
||||
@@ -129,6 +154,8 @@ export function Pagination({
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</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}
|
||||
|
||||
@@ -32,8 +32,11 @@ export default [
|
||||
route("downloads/:id", "routes/console/downloads.$id.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("agent", "routes/console/agent.tsx", [
|
||||
index("routes/console/agent._index.tsx"),
|
||||
route(":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"),
|
||||
|
||||
@@ -1,42 +1,22 @@
|
||||
import { ArrowLeft, PanelRightOpen } from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { Link, useNavigate, useOutletContext, useParams } from "react-router";
|
||||
import { agentMockEnabled } from "~/api/agent.ts";
|
||||
import {
|
||||
agentListHref,
|
||||
agentRunHref,
|
||||
pageListMemory,
|
||||
usePageListGroup,
|
||||
useRunGroup,
|
||||
} from "~/components/agent/agent-nav.ts";
|
||||
import { agentListHref } from "~/components/agent/agent-nav.ts";
|
||||
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
|
||||
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
|
||||
import { agentReturnPath, showAgentRunInDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import type { AgentOutletContext } from "./agent.tsx";
|
||||
|
||||
// 会话页(AGUI-3):左栏会话列表(xl 以上)· 会话列占满其余宽度(顶部概况条、底部输入区)。
|
||||
// 不再有页头目标描述和右侧栏;步骤、子会话、预算与模型在概况条里。
|
||||
// 会话页的右栏(AGUI-3):会话列占满(顶部概况条、底部输入区);标题与左栏会话列表在外壳 agent.tsx,切换会话不重挂载。
|
||||
// 步骤、子会话、预算与模型在概况条里。
|
||||
export default function AgentRunPage() {
|
||||
const { runId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const mock = agentMockEnabled();
|
||||
// 左栏分栏跟随当前会话(巡检会话及其子会话在「自动巡检」),也可以手动切换(记在 URL)
|
||||
const [group, setGroup] = usePageListGroup(useRunGroup(runId));
|
||||
const { group } = useOutletContext<AgentOutletContext>();
|
||||
return (
|
||||
<>
|
||||
<h1 className="sr-only">智能体会话</h1>
|
||||
<div data-agent-page className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
|
||||
<aside className="pg-slab hidden rounded-xl p-3 xl:block xl:h-[calc(100dvh-7rem)] xl:overflow-hidden">
|
||||
<AgentSessionList
|
||||
activeId={runId}
|
||||
hrefFor={(id) => agentRunHref(id, mock)}
|
||||
group={group}
|
||||
onGroupChange={setGroup}
|
||||
memory={pageListMemory}
|
||||
className="h-full"
|
||||
/>
|
||||
</aside>
|
||||
<div className="pg-slab flex h-[calc(100dvh-7rem)] min-h-[28rem] flex-col gap-2 rounded-xl p-3 md:p-4">
|
||||
<Button asChild variant="ghost" size="sm" className="-mt-1 -ml-2 self-start text-muted-foreground xl:hidden">
|
||||
<div className="pg-slab flex h-[calc(100dvh-10.25rem)] min-h-[28rem] flex-col gap-2 rounded-2xl p-3 md:p-4 lg:h-[calc(100dvh-11.25rem)]">
|
||||
<Button asChild variant="ghost" size="sm" className="-mt-1 -ml-2 self-start text-muted-foreground lg:hidden">
|
||||
<Link to={agentListHref(group, mock)}>
|
||||
<ArrowLeft />
|
||||
全部会话
|
||||
@@ -49,7 +29,7 @@ export default function AgentRunPage() {
|
||||
actions={
|
||||
// 整页 → 抽屉:回到进智能体页面之前的页面,会话在抽屉里接着看
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="在侧边打开"
|
||||
title="在侧边打开,回到之前的页面"
|
||||
@@ -63,7 +43,5 @@ export default function AgentRunPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { agentMockEnabled, useCreateAgentRun } from "~/api/agent.ts";
|
||||
import { agentRunHref, DRAFT_PAGE_GOAL, writeDraft } from "~/components/agent/agent-nav.ts";
|
||||
import { GoalComposer } from "~/components/agent/agent-run-panel.tsx";
|
||||
import { quickGoalsFor } from "~/components/agent/quick-goals.ts";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
// 智能体列表页的右栏:新对话输入框 + 3 个快捷目标(标题与左栏会话列表在外壳 agent.tsx)
|
||||
export default function AgentIndexPage() {
|
||||
const create = useCreateAgentRun();
|
||||
const navigate = useNavigate();
|
||||
const mock = agentMockEnabled();
|
||||
return (
|
||||
<div className="pg-slab mx-auto w-full max-w-[88ch] rounded-2xl p-4">
|
||||
<GoalComposer
|
||||
pending={create.isPending}
|
||||
suggestions={quickGoalsFor("/console/agent")}
|
||||
draftKey={DRAFT_PAGE_GOAL}
|
||||
onSubmit={(goal, opts) =>
|
||||
create.mutate(
|
||||
{ goal, ...opts },
|
||||
{
|
||||
onSuccess: (run) => {
|
||||
writeDraft(DRAFT_PAGE_GOAL, "");
|
||||
navigate(agentRunHref(run.id, mock));
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,27 @@
|
||||
import { FlaskConical } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { agentMockEnabled, useCreateAgentRun } from "~/api/agent.ts";
|
||||
import {
|
||||
agentRunHref,
|
||||
DRAFT_PAGE_GOAL,
|
||||
pageListMemory,
|
||||
usePageListGroup,
|
||||
writeDraft,
|
||||
} from "~/components/agent/agent-nav.ts";
|
||||
import { GoalComposer } from "~/components/agent/agent-run-panel.tsx";
|
||||
import { Outlet, useParams } from "react-router";
|
||||
import { agentMockEnabled } from "~/api/agent.ts";
|
||||
import { agentRunHref, pageListMemory, usePageListGroup, useRunGroup } from "~/components/agent/agent-nav.ts";
|
||||
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
|
||||
import { quickGoalsFor } from "~/components/agent/quick-goals.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 智能体入口页(AGUI-3):左栏会话列表(等你处理置顶),右边新对话输入框 + 3 个快捷目标。
|
||||
export default function AgentPage() {
|
||||
const create = useCreateAgentRun();
|
||||
const navigate = useNavigate();
|
||||
/**
|
||||
* 智能体页的外壳(AGUI-3):列表页与会话页共用。标题、演示提示与左栏会话列表在这里,只挂载一次;
|
||||
* 右栏是子路由(新对话输入框 / 某个会话)。切换会话、回到列表都不重挂载左栏,也不重播入场动画,
|
||||
* 标题与两栏的位置不跳。
|
||||
*/
|
||||
export default function AgentLayout() {
|
||||
const { runId } = useParams();
|
||||
const mock = agentMockEnabled();
|
||||
const [group, setGroup] = usePageListGroup();
|
||||
const runGroupOf = useRunGroup(runId);
|
||||
// 列表页没有 tab 就是「对话」;会话页的左栏分栏跟随当前会话(巡检会话及其子会话在「自动巡检」),也可以手动切换(记在 URL)
|
||||
const [group, setGroup] = usePageListGroup(runId ? runGroupOf : undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="智能体" />
|
||||
<PageHeader title="智能体" className="mb-4 md:mb-6" />
|
||||
{mock ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-caption text-warning">
|
||||
<FlaskConical className="h-4 w-4 shrink-0" />
|
||||
@@ -37,37 +33,31 @@ export default function AgentPage() {
|
||||
) : null}
|
||||
<div
|
||||
data-agent-page
|
||||
className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:items-start"
|
||||
// 智能体页没有右下角悬浮按钮,收回主内容区为它留的底部空白,两栏正好占满一屏
|
||||
className="-mb-16 grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,19rem)_minmax(0,1fr)] lg:items-start"
|
||||
>
|
||||
{/* 窄屏:列表页把会话列表排在输入框下面;会话页不显示列表(顶部有「全部会话」返回) */}
|
||||
<aside
|
||||
className={cn(
|
||||
"pg-slab order-2 rounded-2xl p-3 lg:order-1 lg:block lg:h-[calc(100dvh-11.25rem)] lg:overflow-hidden",
|
||||
runId && "hidden",
|
||||
)}
|
||||
>
|
||||
<div className="pg-slab order-2 rounded-xl p-3 lg:order-1 lg:h-[calc(100dvh-12rem)] lg:overflow-hidden">
|
||||
<AgentSessionList
|
||||
activeId={runId}
|
||||
hrefFor={(id) => agentRunHref(id, mock)}
|
||||
group={group}
|
||||
onGroupChange={setGroup}
|
||||
memory={pageListMemory}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="pg-slab order-1 mx-auto w-full max-w-[88ch] rounded-xl p-4 lg:order-2">
|
||||
<GoalComposer
|
||||
pending={create.isPending}
|
||||
suggestions={quickGoalsFor("/console/agent")}
|
||||
draftKey={DRAFT_PAGE_GOAL}
|
||||
onSubmit={(goal, opts) =>
|
||||
create.mutate(
|
||||
{ goal, ...opts },
|
||||
{
|
||||
onSuccess: (run) => {
|
||||
writeDraft(DRAFT_PAGE_GOAL, "");
|
||||
navigate(agentRunHref(run.id, mock));
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</aside>
|
||||
<div className="order-1 min-w-0 lg:order-2">
|
||||
<Outlet context={{ group } satisfies AgentOutletContext} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export type AgentOutletContext = { group: ReturnType<typeof usePageListGroup>[0] };
|
||||
@@ -97,6 +97,8 @@ import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizar
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Pagination } from "~/components/ui/pagination.tsx";
|
||||
import { SegmentedControl } from "~/components/ui/segmented.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
@@ -797,23 +799,14 @@ export default function FilesPage() {
|
||||
|
||||
// ── 工具栏 ──
|
||||
const chips = (
|
||||
<div className="flex gap-1 overflow-x-auto scroll-bleed">
|
||||
{FILTER_CHIPS.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
aria-pressed={filter === c.value}
|
||||
onClick={() => patch({ f: c.value || null })}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-2.5 py-1 text-meta whitespace-nowrap transition-colors duration-micro",
|
||||
filter === c.value
|
||||
? "pill-selected text-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="min-w-0 overflow-x-auto scroll-bleed">
|
||||
<SegmentedControl
|
||||
label="筛选"
|
||||
size="xs"
|
||||
value={filter || "all"}
|
||||
onChange={(v) => patch({ f: v === "all" ? null : v })}
|
||||
options={FILTER_CHIPS.map((c) => ({ value: c.value || "all", label: c.label }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const searchBox = (
|
||||
@@ -838,21 +831,21 @@ export default function FilesPage() {
|
||||
</div>
|
||||
);
|
||||
const sortControls = (
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
aria-label="排序"
|
||||
value={sort}
|
||||
onChange={(e) => patch({ sort: e.target.value === "name" ? null : e.target.value })}
|
||||
className="field-glass h-8 rounded-xl px-2 text-caption"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={sort} onValueChange={(v) => patch({ sort: v === "name" ? null : v })}>
|
||||
<SelectTrigger size="sm" aria-label="排序" className="min-w-28 text-caption">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
按{o.label}
|
||||
</option>
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={order === "asc" ? "升序,切换为降序" : "降序,切换为升序"}
|
||||
title={order === "asc" ? "升序" : "降序"}
|
||||
@@ -861,13 +854,12 @@ export default function FilesPage() {
|
||||
{order === "asc" ? <ArrowDownAZ /> : <ArrowUpAZ />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-pressed={hidden}
|
||||
aria-label={hidden ? "不显示隐藏文件" : "显示隐藏文件"}
|
||||
title={hidden ? "正在显示隐藏文件" : "显示隐藏文件"}
|
||||
onClick={() => patch({ hidden: hidden ? null : "1" })}
|
||||
className={cn(hidden && "bg-foreground/[0.08] text-foreground")}
|
||||
>
|
||||
{hidden ? <Eye /> : <EyeOff />}
|
||||
</Button>
|
||||
@@ -1007,7 +999,7 @@ export default function FilesPage() {
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={searchOpen ? "收起搜索" : "搜索"}
|
||||
onClick={() => {
|
||||
@@ -1018,7 +1010,7 @@ export default function FilesPage() {
|
||||
{searchOpen ? <X /> : <Search />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="更多"
|
||||
onClick={(ev) => openMenuAt(ev.currentTarget, mobileMore())}
|
||||
@@ -1032,7 +1024,7 @@ export default function FilesPage() {
|
||||
<div className="space-y-2 border-b border-border px-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="上一级"
|
||||
title="上一级(Alt+↑)"
|
||||
@@ -1053,7 +1045,7 @@ export default function FilesPage() {
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="刷新"
|
||||
title="刷新"
|
||||
@@ -1067,7 +1059,7 @@ export default function FilesPage() {
|
||||
<RotateCw className={cn(list.isFetching && !list.isFetchingNextPage && "animate-spin")} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="快捷键"
|
||||
title="快捷键(?)"
|
||||
|
||||
@@ -234,7 +234,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
|
||||
)}
|
||||
>
|
||||
{/* 顶栏不铺磨砂(磨砂栏会成为背景根,里面的玻璃糊不到页面):页面底色渐隐托住标题,
|
||||
右侧通知、智能体、日夜切换与账户收进一枚玻璃胶囊 */}
|
||||
右侧搜索、通知、智能体、日夜切换与账户收进一枚玻璃胶囊 */}
|
||||
<header className="sticky top-0 z-20 flex h-14 items-center bg-gradient-to-b from-background/90 via-background/60 to-transparent px-4 md:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
@@ -255,9 +255,9 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
|
||||
<span className="truncate text-sm font-semibold text-foreground">{activeLabel}</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SearchTrigger />
|
||||
<LiquidSurface tier="regular" radius={20} noRefraction style={{ width: "auto", height: "auto" }}>
|
||||
<div className="flex items-center gap-0.5 p-1">
|
||||
<SearchTrigger className="mr-1" />
|
||||
<InboxBell bare />
|
||||
{/* 桌面有侧栏主入口与右下角悬浮按钮,顶栏的智能体按钮只在窄屏保留 */}
|
||||
<AgentDrawerTrigger className="xl:hidden" />
|
||||
|
||||
@@ -179,14 +179,13 @@ function LogsView({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setLive((v) => !v)}
|
||||
aria-pressed={live}
|
||||
aria-label={live ? "暂停实时" : "继续实时"}
|
||||
>
|
||||
{live ? <Pause /> : <Play />}
|
||||
{live ? "暂停" : "继续"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
stream.clear();
|
||||
@@ -243,7 +242,7 @@ function LogsView({
|
||||
allLabel="不限"
|
||||
/>
|
||||
<Button
|
||||
variant={filters.system === "1" ? "secondary" : "ghost"}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-pressed={filters.system === "1"}
|
||||
title="没有业务对象的任务、例行定时任务的成功运行"
|
||||
|
||||
@@ -326,9 +326,10 @@ function StatsLine({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStatus("")}
|
||||
aria-pressed={!status}
|
||||
className={cn(
|
||||
"rounded-md px-1.5 py-0.5 hover:text-foreground",
|
||||
!status && "bg-foreground/[0.06] text-foreground",
|
||||
"cursor-pointer rounded-full border border-transparent px-2.5 py-0.5 hover:text-foreground",
|
||||
!status && "pill-selected text-foreground",
|
||||
)}
|
||||
>
|
||||
共 {s.total} 条
|
||||
@@ -339,8 +340,8 @@ function StatsLine({
|
||||
aria-pressed={attention}
|
||||
onClick={() => onAttention(!attention)}
|
||||
className={cn(
|
||||
"rounded-md px-1.5 py-0.5 text-warning hover:text-foreground",
|
||||
attention && "bg-foreground/[0.06]",
|
||||
"cursor-pointer rounded-full border border-transparent px-2.5 py-0.5 text-warning hover:text-foreground",
|
||||
attention && "pill-selected",
|
||||
)}
|
||||
>
|
||||
需要处理 {s.attention}
|
||||
@@ -355,9 +356,9 @@ function StatsLine({
|
||||
aria-pressed={status === p.key}
|
||||
onClick={() => onStatus(status === p.key ? "" : p.key)}
|
||||
className={cn(
|
||||
"rounded-md px-1.5 py-0.5 hover:text-foreground",
|
||||
"cursor-pointer rounded-full border border-transparent px-2.5 py-0.5 hover:text-foreground",
|
||||
p.tone,
|
||||
status === p.key && "bg-foreground/[0.06]",
|
||||
status === p.key && "pill-selected",
|
||||
)}
|
||||
>
|
||||
{p.label} {p.n}
|
||||
@@ -668,8 +669,9 @@ export default function OrganizePage() {
|
||||
<>
|
||||
<OrganizeQueueButton open={queueOpen} onOpenChange={setQueueOpen} />
|
||||
<Button
|
||||
variant={selecting ? "secondary" : "ghost"}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-pressed={selecting}
|
||||
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
|
||||
>
|
||||
{selecting ? <X /> : <SquareCheck />}
|
||||
@@ -687,7 +689,7 @@ export default function OrganizePage() {
|
||||
search={{ value: qInput, onChange: setQInput, placeholder: "作品、路径或 hash,支持 * ?" }}
|
||||
actions={
|
||||
<Button
|
||||
variant={grouped ? "secondary" : "ghost"}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-pressed={grouped}
|
||||
onClick={() => setGrouping(grouped ? "none" : "entity")}
|
||||
|
||||
@@ -157,7 +157,7 @@ export default function SettingsPage() {
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors duration-micro",
|
||||
active
|
||||
? "bg-foreground/[0.07] font-medium text-foreground"
|
||||
? "seg-thumb font-medium text-foreground"
|
||||
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { SiteCard, SiteTable } from "~/components/sites/site-card.tsx";
|
||||
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
|
||||
import { needsAttention, type SortKey, sortSites } from "~/components/sites/site-fields.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { SegmentedControl } from "~/components/ui/segmented.tsx";
|
||||
|
||||
// 获取 → 站点:站点、凭证、健康、账号数据、RSS 的唯一入口(docs/design/site-center.md §4);
|
||||
// 按类型分组显示:PT 站 / 公开 BT 站(旧的自定义 RSS 源有的话单列);?new=<类型> 直接打开添加。
|
||||
@@ -143,13 +143,13 @@ export default function SitesPage() {
|
||||
{all.length ? (
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Segmented
|
||||
<SegmentedControl
|
||||
label="筛选"
|
||||
value={filter}
|
||||
onChange={(v) => setParam("filter", v === "all" ? null : v)}
|
||||
options={FILTERS.map((f) => ({
|
||||
...f,
|
||||
label: f.value === "attention" && attention ? `${f.label} ${attention}` : f.label,
|
||||
count: f.value === "attention" && attention ? attention : undefined,
|
||||
}))}
|
||||
/>
|
||||
<FilterSelect
|
||||
@@ -160,13 +160,13 @@ export default function SitesPage() {
|
||||
options={SORTS.filter((x) => x.value !== "name")}
|
||||
/>
|
||||
</div>
|
||||
<Segmented
|
||||
<SegmentedControl
|
||||
label="视图"
|
||||
value={view}
|
||||
onChange={(v) => setView(v as View)}
|
||||
options={[
|
||||
{ value: "cards", label: "卡片", icon: <LayoutGrid className="h-3.5 w-3.5" /> },
|
||||
{ value: "list", label: "列表", icon: <List className="h-3.5 w-3.5" /> },
|
||||
{ value: "cards", label: "卡片", icon: <LayoutGrid /> },
|
||||
{ value: "list", label: "列表", icon: <List /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -224,39 +224,3 @@ export default function SitesPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Segmented({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: { value: string; label: string; icon?: React.ReactNode }[];
|
||||
}) {
|
||||
return (
|
||||
<div role="radiogroup" aria-label={label} className="inline-flex gap-0.5 rounded-xl border border-border p-0.5">
|
||||
{options.map((o) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: 分段按钮
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-caption transition-colors",
|
||||
value === o.value
|
||||
? "bg-foreground text-background"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{o.icon}
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { SubscriptionCard, SubscriptionRow } from "~/components/subscriptions/ov
|
||||
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Pagination } from "~/components/ui/pagination.tsx";
|
||||
import { SegmentedControl } from "~/components/ui/segmented.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { legacyRedirectLoader } from "./redirects.ts";
|
||||
|
||||
@@ -74,27 +75,13 @@ function Pills<T extends string>({
|
||||
items: { value: T | ""; label: string; count?: number }[];
|
||||
}) {
|
||||
return (
|
||||
<div role="tablist" aria-label={label} className="flex min-w-0 gap-1 overflow-x-auto scroll-bleed">
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={it.value || "all"}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === it.value}
|
||||
onClick={() => onChange(it.value)}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-sm font-medium whitespace-nowrap transition-colors duration-micro",
|
||||
value === it.value
|
||||
? "pill-selected text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{it.label}
|
||||
{it.count !== undefined ? (
|
||||
<span className="text-meta text-muted-foreground tabular-nums">{it.count}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
<div className="min-w-0 overflow-x-auto scroll-bleed">
|
||||
<SegmentedControl<T | "all">
|
||||
label={label}
|
||||
value={value || "all"}
|
||||
onChange={(v) => onChange(v === "all" ? "" : v)}
|
||||
options={items.map((it) => ({ value: it.value || "all", label: it.label, count: it.count }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -207,7 +194,7 @@ export default function SubscriptionsPage() {
|
||||
title="订阅"
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={consolePath("rules")}>
|
||||
<ListChecks />
|
||||
规则
|
||||
@@ -235,46 +222,25 @@ export default function SubscriptionsPage() {
|
||||
<div className="flex items-center gap-1">
|
||||
{calendar ? null : (
|
||||
<Button
|
||||
variant={selecting ? "secondary" : "ghost"}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-pressed={selecting}
|
||||
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
|
||||
>
|
||||
{selecting ? <X /> : null}
|
||||
{selecting ? "退出批量" : "批量"}
|
||||
</Button>
|
||||
)}
|
||||
<fieldset className="m-0 flex rounded-full border border-border p-0.5" aria-label="视图">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="卡片视图"
|
||||
aria-pressed={!calendar && view === "card"}
|
||||
className={cn("rounded-full", !calendar && view === "card" && "bg-foreground/[0.08]")}
|
||||
onClick={() => setView("card")}
|
||||
>
|
||||
<LayoutGrid />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="列表视图"
|
||||
aria-pressed={!calendar && view === "list"}
|
||||
className={cn("rounded-full", !calendar && view === "list" && "bg-foreground/[0.08]")}
|
||||
onClick={() => setView("list")}
|
||||
>
|
||||
<List />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="日历"
|
||||
aria-pressed={calendar}
|
||||
className={cn("rounded-full", calendar && "bg-foreground/[0.08]")}
|
||||
onClick={() => f.set("view", "calendar")}
|
||||
>
|
||||
<CalendarDays />
|
||||
</Button>
|
||||
</fieldset>
|
||||
<SegmentedControl
|
||||
label="视图"
|
||||
value={calendar ? "calendar" : view}
|
||||
onChange={(x) => (x === "calendar" ? f.set("view", "calendar") : setView(x))}
|
||||
options={[
|
||||
{ value: "card", label: "", title: "卡片视图", icon: <LayoutGrid /> },
|
||||
{ value: "list", label: "", title: "列表视图", icon: <List /> },
|
||||
{ value: "calendar", label: "", title: "日历", icon: <CalendarDays /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{calendar ? (
|
||||
@@ -459,7 +425,7 @@ export default function SubscriptionsPage() {
|
||||
) : (
|
||||
renderItems(visible)
|
||||
)}
|
||||
{items.length > 0 || query.hasNextPage ? <Pagination {...paged.pagination} /> : null}
|
||||
{items.length > 0 || query.hasNextPage ? <Pagination {...paged.pagination} hideSingle /> : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -23,3 +23,4 @@
|
||||
- `EmptyState`:空态/建设中占位;`plain` 用于卡片内部。
|
||||
- `StatusBadge`:`success | warning | destructive | neutral` 四种语气,必须带文字。
|
||||
- `nav.ts`:12 个工作区的导航表,标题与说明同时供占位页使用。
|
||||
- `SegmentedControl`(`components/ui/segmented.tsx`):单选分段,滑动的 `seg-thumb`;用法见 [ui-controls.md](ui-controls.md)「本项目(控制台)的落地」。
|
||||
@@ -123,9 +123,9 @@
|
||||
|
||||
- 侧栏的选中项由滑块(`seg-thumb`)承担主要形态,导航文字与 hover 保持清晰;分组展开、滚动和路由变化都会影响滑块位置。
|
||||
- 控制台侧栏贴边,不做浮起卡片;右边不画硬分隔线,由 `pg-chrome-side` 给内侧细亮边、外侧细暗线和淡投影。分组小标题(概览、获取、整理、系统)保留:它们和菜单项不同名,是导航的分区。折叠开关在标志行:展开时在名称右侧,收起后标志悬停变为展开图标。
|
||||
- 控制台顶栏与登录页顶栏不铺磨砂(磨砂栏会成为背景根,里面的玻璃糊不到页面),用页面底色渐隐托住标题;控制台右侧通知、智能体、日夜切换与账户收进一枚玻璃胶囊,胶囊里的按钮是平的(`bare`);登录页右侧只有日夜切换一颗,不套胶囊。
|
||||
- 控制台顶栏与登录页顶栏不铺磨砂(磨砂栏会成为背景根,里面的玻璃糊不到页面),用页面底色渐隐托住标题;控制台右侧搜索、通知、智能体、日夜切换与账户收进一枚玻璃胶囊,胶囊里的按钮是平的(`bare`),搜索入口是胶囊里的一格平涂淡底,不套 `.field-glass`;登录页右侧只有日夜切换一颗,不套胶囊。
|
||||
- 登录页只留标题(初始化和二次验证保留一句操作说明),整页居中一张卡,桌面与手机同一套;卡片是 `pg-slab auth-card`(底换成实在的磨砂 `--mat-auth-card-bg`,卡片、输入框、页面三层分得开)。
|
||||
- 输入框、多行输入与下拉选择的触发器统一用 `.field-glass`(淡底 + 1px 实边,聚焦 / 展开时外线加深并带淡光晕,出错时红线;参数 `--mat-field-*`),由 `Input` / `Textarea` / `SelectTrigger` 挂上;原生 `<select>` 与长得像下拉的按钮也挂它。调用处只给尺寸和内边距,不再各写底色、边框、阴影。
|
||||
- 输入框、多行输入与下拉选择的触发器统一用 `.field-glass`(淡底 + 1px 实边,悬停时边线加深一档,聚焦 / 展开时外线加深并带淡光晕,出错时红线加淡红光晕;参数 `--mat-field-*`)。本项目 2026-10-08 把日间边线从 0.46 调到 0.16、底换成半透明白(夜间边线 0.36 → 0.16):控制台的筛选条和搜索框很密,0.46 的边读成一圈黑框;靠聚焦与出错状态保证可辨认,与 uhd_web 的参数在这一处不同,由 `Input` / `Textarea` / `SelectTrigger` 挂上;原生 `<select>` 与长得像下拉的按钮也挂它。调用处只给尺寸和内边距,不再各写底色、边框、阴影。
|
||||
- 改支付界面的样式时,要覆盖确认、二维码、错误与返回路径。套餐的优惠金额从价格推导,不写死。交易摘要用现有的 RemarkText 展示,不为排版改写后端摘要的含义。
|
||||
|
||||
主动使用 Taste 的触发条件和项目适配见前端规范。
|
||||
@@ -20,6 +20,15 @@
|
||||
|
||||
两边的底、边、字色、投影都引用 `--mat-lens-*` / `--mat-pill-*`,改外观只改参数。弹窗底部的按钮行间距 12px(`gap-3`):按钮带外侧细线与光谱高光,8px 会挤在一起。放进玻璃胶囊里的按钮(控制台顶栏的通知、日夜切换、账户)用 `ghost`,不再自带玻璃。调用处只给布局(宽度、高度、间距、定位),不改圆角、底色、边框、投影和字色;不是 `<button>` 的元素(`Link`、`a`)用 `buttonVariants()` 拿同一套类名。分段标签、菜单行、列表行不算按钮,按各自控件规范;分页当前页、角标、选中的季 / 集 / 选项、日历选中日用静态的 `pill-selected`,不借主按钮样式;分段控件(侧栏、SegmentedTabs、求片页的分段)的滑块用 `seg-thumb`。放胶囊或分段控件的横向滚动容器加 `scroll-bleed`:`overflow-x-auto` 会连带裁掉纵向溢出,不加的话胶囊的阴影和描边会被切成直边。
|
||||
|
||||
### 本项目(控制台)的落地
|
||||
|
||||
- **单选分段**用 `SegmentedControl`([segmented.tsx](../../apps/web/app/components/ui/segmented.tsx)):筛选(全部 / 已启用 / 需要处理)、视图(卡片 / 列表 / 日历,只有图标时给 `title`)、分组(进行中 / 已完成)、对话框里的二选一 / 三选一。选中项由一枚滑动的 `seg-thumb` 承担,文字提一档,没有底轨、没有实心块;数量放 `count`,弱一档显示。radix `Tabs` 的 `TabsList` 用同一枚滑块(`useSlidingThumb`),页面级标签页不另写。横向放不下时外面包一层 `overflow-x-auto scroll-bleed`。
|
||||
- **多选条件**(搜索结果的分辨率 / 站点、整理记录顶部的状态计数)是胶囊,选中用 `pill-selected`,不用分段控件。
|
||||
- **开关型按钮**(批量、按作品分组、显示例行记录、显示隐藏文件)是 `variant="outline"` 加 `aria-pressed`,按下时 `.pill-secondary[aria-pressed="true"]` 给出与 `pill-selected` 相同的外观;分页的当前页同理(`aria-current="page"`)。不再用 `secondary` / `ghost` 切换表示开关。
|
||||
- **页头与工具栏**:一屏一个主操作,其余是描边胶囊;工具栏里的图标按钮是描边圆钮(`variant="outline" size="icon-sm"`)。`ghost` 只留给行内文字动作(返回链接、表格行尾的「⋯」、卡片里的「详情」「清除选择」)和顶栏胶囊里的按钮。下拉一律用 `Select`,不用原生 `<select>`。
|
||||
- **分页**(`Pagination`):一页放得下时只留「共 N 条」(超过最小档 10 条时保留每页条数),没有条目时不渲染;卡片网格下面传 `hideSingle`,一页时整块不显示。翻页按钮按容器宽度收起(`@container`),窄卡片里不折行。
|
||||
- **顶栏搜索**在右侧玻璃胶囊的第一格(与 uhd_web 流媒体顶栏一致):桌面是平涂淡底胶囊加 Ctrl K 提示,手机只留图标,都打开命令面板。
|
||||
|
||||
右上角账户模块全站共用 `account-popover.tsx`:用户中心用头像 + 名字的胶囊,流媒体与测速用圆头像;菜单里是另外两块的跳转(当前所在的不列,流媒体与测速需有生效订阅)与退出登录。
|
||||
|
||||
其余控件仍分两处实现:门户原语在 `app/components/ui/`(shadcn 语义 token),流媒体控件在 `app/routes/stream/_sub/`、`app/routes/stream/player/controls.tsx`(浏览页 `content` / `wash`,播放器 `on-media` 族)。需要相同行为时各自实现。
|
||||
|
||||
Reference in new issue
Block a user