Skip to content

Commit 2e70ebf

Browse files
committed
feat(web): 窄屏表格转卡片(Table cards + TableCell label)
列多的表格(凭证 7 列、请求明细 13 列、模型列表 7 列)在 <768px 从横向滚动 改为「一行 = 一张卡」:列名收成一排可点的排序片,每格用 data-label 在行首 显示字段名(CSS ::before,不新增节点)。 - Table 加 cards 开关,TableCell 加 label;只改样式不改 DOM——六张表被页面 测试按 xxx-table / 行 testid 断言,读屏也不该拿到重复内容。 - display:block 会让 Chromium/WebKit 丢掉隐式表格语义,故卡片模式下经 TableCardsContext 补显式 role;不开卡片模式不加任何 role。 - 卡片模式下放开列宽约束(w-8 / max-w-*),否则「字段名 : 值」被挤成两行。 - 覆盖凭证、请求明细、分组统计、用户、审计、告警、API Key、模型列表共 9 张表; 渠道组头(跨列仪表盘)维持原样。 新增 web/src/components/__tests__/Table.cards.test.tsx(7 用例)。
1 parent 0078320 commit 2e70ebf

14 files changed

Lines changed: 400 additions & 80 deletions

File tree

‎README.en.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ upstreams, with a shared credential pool, unified scheduling, and per-user usage
2020
- **Growth center** (CodeBuddy only): travel gifts, Buddy dispatch, task rewards, streak repair, streak redemption, blind boxes; irreversible steps off via `GROWTH_IRREVERSIBLE_ACTIONS=false`; runnable from the admin UI.
2121
- **Privacy-preserving stats**: no conversation content stored; 90-day request detail, permanent hourly rollups; per user / channel / model / credential views; cost estimated from OpenRouter list prices (CNY primary).
2222
- **Admin security**: login rate limiting, CSRF checks, body limits, Host allowlist.
23+
- **Responsive tables**: wide tables (credentials, request detail, model list, …) turn into one card per row below 768px — column names collapse into a row of tappable sort chips and each cell shows its field name inline. The DOM is unchanged, so screen-reader semantics match the wide layout.
2324

2425
Protocol-level implementation (private envelopes, signing, gate spoofing) is in [`TECHNICAL.md`](TECHNICAL.md) (Chinese).
2526

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- **成长中心**(仅 CodeBuddy):自动领 Buddy 旅行礼物、派 Buddy、领任务奖、断登补登、连登兑换、开盲盒;不可逆动作可用 `GROWTH_IRREVERSIBLE_ACTIONS=false` 关停;管理台可手动执行。
1717
- **脱敏统计**:不存对话内容;明细 90 天、小时汇总永久;按人/渠道/模型/凭证可视化;按 OpenRouter 刊例价估算成本(人民币为主)。
1818
- **管理台安全加固**:登录限流、CSRF 校验、请求体上限、Host 白名单。
19+
- **窄屏表格转卡片**:列多的表格(凭证 / 明细 / 模型列表等)在 <768px 自动由「一行 = 一张卡」呈现——列名收成一排可点的排序片,每个字段在自己行首显示字段名;DOM 不变,读屏语义与宽屏一致。
1920

2021
协议层实现(私有信封、签名、门禁伪装等)见 [`TECHNICAL.md`](TECHNICAL.md)。
2122

‎TECHNICAL.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,3 +638,4 @@ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/users
638638
- **批量 `toggle` 传显式 `enabled` 而非「各自取反」**:混选「已启用 + 已暂停」时取反会让一半号走向相反方向,而操作员点「暂停」的意图只有一个。
639639
- **批量审计只记一条汇总**(`toggle` / `revive`):50 个号的批量暂停是一次操作、一个意图,逐条落库会把审计流水刷成噪声。`probe` / `checkin` 与单条端点同为只读语义、不写审计。
640640
- **批量 `probe` / `checkin` 用信号量限制在途数,不复用后台 Pacer**:Pacer 是跨后台任务的严格串行化节流,批量端点由操作员手动触发,完全串行会让 50 个号等上几分钟才返回。折中是 `BATCH_CONCURRENCY=4` 的在途上限——既不打爆上游,也不长时间挂住请求。`toggle` / `revive` 是本地 DB 写,无上游压力,不受该上限约束。
641+
- **窄屏表格卡片化改样式不改 DOM**(`Table cards` + `styles.css`「窄屏表 → 卡片」):列多的表格(凭证 7 列、明细 13 列)在 <768px 从横向滚动改为「一行 = 一张卡」。**刻意不做「按视口渲染两套 JSX」**——六张表被页面测试按 `xxx-table` / 行 testid 断言,DOM 一变「同一行」的语义就没了,读屏也会拿到一份重复内容。字段名走 `TableCell label` → `data-label` + CSS `::before`(不新增节点)。**`display:block` 会让 Chromium/WebKit 丢掉隐式表格语义**,故卡片模式打开时由 `TableCardsContext` 给 table/thead/tbody/tr/th/td 补显式 `role`(`table`/`rowgroup`/`row`/`columnheader`/`cell`);不开卡片模式的表格不加任何 `role`,沿用浏览器隐式语义。列宽约束(`w-8` / `max-w-*`)在卡片模式下被统一放开,否则「字段名 : 值」会被挤成两行。断点与 Tailwind `md` 对齐(`767.98px`)。

‎design-system/coding2api/MASTER.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@
122122
| `ToastViewport` + `useToasts` | 操作结果浮层(顶部居中,`web/src/components/Toast.tsx`):数秒自动消失、可手动关闭;`danger` 用 `role=alert`、其余 `role=status`;同 `testId` 只保留最新一条;语气用 `Badge`/`Notice` 同款 tint 底 + `-ink` 图标(亮色口径色描边叠白底对比过低,不可只用描边) |
123123
| `EmptyState` | 空态:图标 + 标题 + 描述 + 可选操作 |
124124
| `Tabs` | 分段器;激活项 `bg-background` + `shadow-sm` |
125-
| `Table` | 圆角容器 + 表头底色 + 行 hover;移动端横向滚动 |
125+
| `Table` | 圆角容器 + 表头底色 + 行 hover;移动端横向滚动。窄屏(<768px)可开 `cards` 转卡片:列名变排序片、每格用 `TableCell label` 显示字段名(DOM 不变,见 `styles.css`「窄屏表 → 卡片」) |
126126
| `PageHeader` | eyebrow(可选)+ 标题 + 说明 + 右侧操作槽 |
127127
| `PageSkeleton` | 加载骨架,替代纯文字「载入中…」 |
128128

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
Table,
5+
TableBody,
6+
TableCell,
7+
TableHead,
8+
TableHeader,
9+
TableRow,
10+
} from "../../ui";
11+
12+
/** data-testid 透传到 <table> 本体(外层滚动容器只带 data-slot)。 */
13+
function tableEl() {
14+
return screen.getByTestId("t");
15+
}
16+
17+
function renderTable(cards: boolean) {
18+
return render(
19+
<Table data-testid="t" cards={cards}>
20+
<TableHeader>
21+
<TableRow>
22+
<TableHead>名称</TableHead>
23+
<TableHead className="text-right">数值</TableHead>
24+
</TableRow>
25+
</TableHeader>
26+
<TableBody>
27+
<TableRow data-testid="row">
28+
<TableCell label="名称">甲</TableCell>
29+
<TableCell label="数值" className="text-right">
30+
1
31+
</TableCell>
32+
</TableRow>
33+
</TableBody>
34+
</Table>,
35+
);
36+
}
37+
38+
describe("Table 窄屏卡片模式", () => {
39+
it("cards 打开时表体带 data-table-cards,窄屏 CSS 据此转卡片", () => {
40+
renderTable(true);
41+
expect(tableEl()).toHaveAttribute("data-table-cards");
42+
});
43+
44+
it("默认不开:宽屏表格不被打上卡片标记", () => {
45+
renderTable(false);
46+
expect(tableEl()).not.toHaveAttribute("data-table-cards");
47+
});
48+
49+
it("label 落到单元格的 data-label,供 CSS ::before 取字段名", () => {
50+
renderTable(true);
51+
const cells = screen.getByTestId("row").querySelectorAll("td");
52+
expect(cells[0]).toHaveAttribute("data-label", "名称");
53+
expect(cells[1]).toHaveAttribute("data-label", "数值");
54+
});
55+
56+
it("不给 label 时不产生 data-label(空占位格不出字段名)", () => {
57+
render(
58+
<Table data-testid="t" cards>
59+
<TableBody>
60+
<TableRow data-testid="row">
61+
<TableCell aria-hidden />
62+
</TableRow>
63+
</TableBody>
64+
</Table>,
65+
);
66+
expect(screen.getByTestId("row").querySelector("td")).not.toHaveAttribute(
67+
"data-label",
68+
);
69+
});
70+
71+
it("DOM 结构仍是标准表:宽屏与读屏拿到的 row / columnheader 不因卡片模式改变", () => {
72+
renderTable(true);
73+
// 卡片模式只改样式,不改结构——否则页面里按行断言的语义会被破坏
74+
expect(screen.getAllByRole("row")).toHaveLength(2);
75+
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
76+
expect(screen.getByRole("columnheader", { name: "名称" })).toBeInTheDocument();
77+
});
78+
79+
it("卡片模式补显式表格角色(display:block 会让浏览器丢掉隐式语义)", () => {
80+
renderTable(true);
81+
expect(screen.getByRole("table")).toBeInTheDocument();
82+
expect(screen.getAllByRole("rowgroup")).toHaveLength(2);
83+
expect(screen.getAllByRole("cell")).toHaveLength(2);
84+
});
85+
86+
it("不开卡片模式时不加任何显式 role(沿用浏览器隐式语义)", () => {
87+
renderTable(false);
88+
const table = tableEl();
89+
expect(table).not.toHaveAttribute("role");
90+
expect(table.querySelector("th")).not.toHaveAttribute("role");
91+
expect(table.querySelector("td")).not.toHaveAttribute("role");
92+
});
93+
});

‎web/src/components/ui/table.tsx‎

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,58 @@
11
import * as React from "react"
22
import { cn } from "cn"
33

4+
/**
5+
* 卡片模式开关向下传递:窄屏把 table 的 display 改成 block 后,Chromium/WebKit
6+
* 会**丢掉隐式表格语义**(读屏不再报 row / columnheader)。所以卡片模式打开时
7+
* 给 table/thead/tbody/tr/th/td 挂上显式 ARIA role,语义与宽屏一致。
8+
*
9+
* 用 context 而不是给每个子组件加 prop:既有的 6 个页面只用 `<Table cards>`,
10+
* 子组件不该为了这个开关逐个改调用点。
11+
*/
12+
const TableCardsContext = React.createContext(false)
13+
414
function Table({
515
className,
616
containerClassName,
17+
cards = false,
718
...props
819
}: React.ComponentProps<"table"> & {
920
/** 滚动容器(table-container)附加样式:长列表用它设 max-height + 吸顶表头所需的高度约束。 */
1021
containerClassName?: string
22+
/**
23+
* 窄屏(<768px)转卡片布局。只在**呈现层**生效:DOM 仍是标准 table,
24+
* 表头变一排可换行的排序片、每个数据格用 `TableCell label` 在自己行首显示字段名。
25+
*
26+
* 之所以不做「按视口渲染两套 JSX」:表格被 6 个页面按 `xxx-table` / 行 testid
27+
* 断言,DOM 一变「同一行」的语义就没了,读屏也会拿到一份重复内容。
28+
* 具体样式见 `styles.css` 的「窄屏表 → 卡片」段。
29+
*/
30+
cards?: boolean
1131
}) {
1232
return (
1333
<div
1434
data-slot="table-container"
1535
className={cn("relative w-full overflow-x-auto", containerClassName)}
1636
>
17-
<table
18-
data-slot="table"
19-
className={cn("w-full caption-bottom text-sm", className)}
20-
{...props}
21-
/>
37+
<TableCardsContext.Provider value={cards}>
38+
<table
39+
data-slot="table"
40+
data-table-cards={cards ? "" : undefined}
41+
role={cards ? "table" : undefined}
42+
className={cn("w-full caption-bottom text-sm", className)}
43+
{...props}
44+
/>
45+
</TableCardsContext.Provider>
2246
</div>
2347
)
2448
}
2549

2650
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
51+
const cards = React.useContext(TableCardsContext)
2752
return (
2853
<thead
2954
data-slot="table-header"
55+
role={cards ? "rowgroup" : undefined}
3056
className={cn(
3157
"[&_tr]:border-b [&_tr]:border-border [&_tr]:bg-muted/40 [&_tr:hover]:bg-muted/40",
3258
className
@@ -37,9 +63,11 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
3763
}
3864

3965
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
66+
const cards = React.useContext(TableCardsContext)
4067
return (
4168
<tbody
4269
data-slot="table-body"
70+
role={cards ? "rowgroup" : undefined}
4371
className={cn("[&_tr:last-child]:border-0", className)}
4472
{...props}
4573
/>
@@ -60,9 +88,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
6088
}
6189

6290
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
91+
const cards = React.useContext(TableCardsContext)
6392
return (
6493
<tr
6594
data-slot="table-row"
95+
role={cards ? "row" : undefined}
6696
className={cn(
6797
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
6898
className
@@ -73,9 +103,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
73103
}
74104

75105
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
106+
const cards = React.useContext(TableCardsContext)
76107
return (
77108
<th
78109
data-slot="table-head"
110+
role={cards ? "columnheader" : undefined}
79111
className={cn(
80112
"h-10 px-2 text-left align-middle text-xs font-medium tracking-wide whitespace-nowrap text-muted-foreground [&:has([role=checkbox])]:pr-0",
81113
className
@@ -85,10 +117,24 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
85117
)
86118
}
87119

88-
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
120+
function TableCell({
121+
className,
122+
label,
123+
...props
124+
}: React.ComponentProps<"td"> & {
125+
/**
126+
* 窄屏卡片模式下的字段名(`Table cards` 打开时生效)。宽屏不渲染,
127+
* 用 CSS `::before` 从 `data-label` 取值——不额外产生 DOM 节点,
128+
* 宽屏与读屏拿到的结构与不开卡片模式时完全一致。
129+
*/
130+
label?: string
131+
}) {
132+
const cards = React.useContext(TableCardsContext)
89133
return (
90134
<td
91135
data-slot="table-cell"
136+
data-label={label}
137+
role={cards ? "cell" : undefined}
92138
className={cn(
93139
"px-2 py-2.5 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
94140
className

‎web/src/pages/AlertsPage.tsx‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export function AlertsPage() {
5757
data-testid="no-alerts"
5858
/>
5959
) : (
60-
<Table data-testid="alerts-table">
60+
<Table data-testid="alerts-table" cards>
6161
<TableHeader>
6262
<TableRow>
6363
<SortableHead label="时间" columnKey="ts" active={sort === "ts"}
@@ -76,10 +76,10 @@ export function AlertsPage() {
7676
<TableBody>
7777
{alerts.map((alert) => (
7878
<TableRow key={alert.id} data-testid={`alert-row-${alert.id}`}>
79-
<TableCell className="whitespace-nowrap text-xs tabular-nums">
79+
<TableCell label="时间" className="whitespace-nowrap text-xs tabular-nums">
8080
{formatTime(alert.ts)}
8181
</TableCell>
82-
<TableCell>
82+
<TableCell label="级别">
8383
{alert.resolved_at !== null ? (
8484
<Badge tone="ok" dot>
8585
已恢复
@@ -90,12 +90,14 @@ export function AlertsPage() {
9090
</Badge>
9191
)}
9292
</TableCell>
93-
<TableCell className="text-xs">
93+
<TableCell label="规则" className="text-xs">
9494
{ALERT_RULE_LABEL[alert.rule] ?? alert.rule}
9595
</TableCell>
96-
<TableCell className="font-mono text-xs">{alert.scope || "—"}</TableCell>
97-
<TableCell className="text-xs">{alert.message}</TableCell>
98-
<TableCell>
96+
<TableCell label="对象" className="font-mono text-xs">
97+
{alert.scope || "—"}
98+
</TableCell>
99+
<TableCell label="说明" className="text-xs">{alert.message}</TableCell>
100+
<TableCell label="推送">
99101
{alert.delivered === 1 ? (
100102
<Badge tone="ok" dot>
101103
已推送

‎web/src/pages/ApiKeysPage.tsx‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,7 @@ export function ApiKeysPage() {
530530
data-testid="no-keys"
531531
/>
532532
) : (
533-
<Table data-testid="keys-table">
533+
<Table data-testid="keys-table" cards>
534534
<TableHeader>
535535
<TableRow>
536536
<SortableHead label="名称" columnKey="name" active={sort === "name"}
@@ -554,25 +554,27 @@ export function ApiKeysPage() {
554554
<TableBody>
555555
{keys.map((key) => (
556556
<TableRow key={key.id}>
557-
<TableCell>{key.name || "—"}</TableCell>
558-
<TableCell className="font-mono text-xs">{key.preview}</TableCell>
559-
<TableCell className="text-xs" data-testid={`key-binding-${key.id}`}>
557+
<TableCell label="名称">{key.name || "—"}</TableCell>
558+
<TableCell label="Key" className="font-mono text-xs">{key.preview}</TableCell>
559+
<TableCell label="渠道" className="text-xs" data-testid={`key-binding-${key.id}`}>
560560
{providerBindingLabel(key.provider_binding)}
561561
</TableCell>
562-
<TableCell className="font-mono text-xs" data-testid={`key-ips-${key.id}`}>
562+
<TableCell label="来源 IP" className="font-mono text-xs" data-testid={`key-ips-${key.id}`}>
563563
{key.allowed_ips || "不限制"}
564564
</TableCell>
565-
<TableCell className="font-mono text-xs" data-testid={`key-models-${key.id}`}>
565+
<TableCell label="模型白名单" className="font-mono text-xs" data-testid={`key-models-${key.id}`}>
566566
{key.allowed_models || "不限制"}
567567
</TableCell>
568-
<TableCell className="text-xs" data-testid={`key-expires-${key.id}`}>
568+
<TableCell label="到期" className="text-xs" data-testid={`key-expires-${key.id}`}>
569569
{key.expires_at ? formatTime(key.expires_at) : "永不过期"}
570570
</TableCell>
571-
<TableCell className="text-xs">{formatTime(key.created_at)}</TableCell>
572-
<TableCell className="text-xs">
571+
<TableCell label="创建时间" className="text-xs">
572+
{formatTime(key.created_at)}
573+
</TableCell>
574+
<TableCell label="最后使用" className="text-xs">
573575
{key.last_used_at ? formatTime(key.last_used_at) : "从未使用"}
574576
</TableCell>
575-
<TableCell className="text-right">
577+
<TableCell label="操作" className="text-right">
576578
{confirming === key.id ? (
577579
<span className="inline-flex gap-2">
578580
<Button size="sm" variant="danger" onClick={() => remove(key.id)}>

‎web/src/pages/AuditPage.tsx‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ export function AuditPage() {
134134
/>
135135
) : (
136136
<>
137-
<Table data-testid="audit-table">
137+
<Table data-testid="audit-table" cards>
138138
<TableHeader>
139139
<TableRow>
140140
<SortableHead label="时间" columnKey="ts" active={sort === "ts"}
@@ -153,22 +153,24 @@ export function AuditPage() {
153153
<TableBody>
154154
{events.map((event) => (
155155
<TableRow key={event.id} data-testid={`audit-row-${event.id}`}>
156-
<TableCell className="whitespace-nowrap text-xs">
156+
<TableCell label="时间" className="whitespace-nowrap text-xs">
157157
{formatTime(event.ts)}
158158
</TableCell>
159-
<TableCell className="text-xs">{event.actor}</TableCell>
160-
<TableCell>
159+
<TableCell label="操作者" className="text-xs">{event.actor}</TableCell>
160+
<TableCell label="动作">
161161
{/* 失败动作对读屏不可见(颜色语义):加「失败」文案 */}
162162
<Badge tone={event.ok ? "muted" : "danger"}>
163163
{labels[event.action] ?? event.action}
164164
{!event.ok && "(失败)"}
165165
</Badge>
166166
</TableCell>
167-
<TableCell className="text-xs">{event.target ?? "—"}</TableCell>
168-
<TableCell className="text-xs text-muted-foreground">
167+
<TableCell label="对象" className="text-xs">{event.target ?? "—"}</TableCell>
168+
<TableCell label="详情" className="text-xs text-muted-foreground">
169169
{event.detail || "—"}
170170
</TableCell>
171-
<TableCell className="font-mono text-xs">{event.ip ?? "—"}</TableCell>
171+
<TableCell label="来源 IP" className="font-mono text-xs">
172+
{event.ip ?? "—"}
173+
</TableCell>
172174
</TableRow>
173175
))}
174176
</TableBody>

0 commit comments

Comments
 (0)