From 9a7c46a3c6910c36b339b566090c3a226c888afb Mon Sep 17 00:00:00 2001 From: HIsheR Date: Mon, 26 Oct 2026 13:08:25 +0800 Subject: [PATCH] =?UTF-8?q?iter(g88):=20=E6=95=B0=E6=8D=AE=E8=A1=A8?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AF=BC=E5=87=BA=20CSV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ua/backlog.json | 8 ++++ .ua/index.json | 15 ++++++- apps/demo/main.ts | 3 +- llms-full.txt | 5 ++- packages/core/table-core/scripts/smoke.mjs | 18 +++++++++ packages/core/table-core/src/core.ts | 7 +++- .../core/table-core/src/plugins/pagination.ts | 4 +- packages/core/table-core/src/types.ts | 2 + packages/skin/ui/src/csv.ts | 39 +++++++++++++++++++ packages/skin/ui/src/dataTable.ts | 2 + packages/skin/ui/src/types.ts | 2 + packages/skin/ui/ua.manifest.json | 13 ++++++- 12 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 packages/skin/ui/src/csv.ts diff --git a/.ua/backlog.json b/.ua/backlog.json index 5335d90..3454655 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -1060,6 +1060,14 @@ "acceptance": "焦点进入(focusin)与移出(focusout)同样暂停自动播放,两者都不在才前进;区域补 aria-roledescription=轮播,每张补 aria-roledescription=幻灯片 且标签为「第 x 张,共 n 张」;演示描述同步;npm run check 全绿", "notes": "自补:无障碍;hover 与 focus 分开计数,避免一个 pointerleave 就误解除暂停", "status": "done" + }, + { + "id": "g88", + "title": "数据表支持导出 CSV", + "motivation": "表格能筛能排却导不出来,用户只能截图或手抄;导出还必须导「筛选+排序后的全量行」而不是当前页,但内核只有 getVisibleRows()(已分页裁剪),UI 层拿不到全量派生行", + "acceptance": "内核 getVisibleRows(paged=false) 跳过 pagination 阶段并新增 getAllRows(),派生上下文带 paged 标记(缺省分页),分页快照不受影响;dataTable 新增 exportable / exportName,工具栏按钮导出筛选+排序后的全量行、单元格走 format;CSV 带 BOM + CRLF、逗号引号换行正确转义;冒烟补 4 条不变量;npm run check 全绿", + "notes": "自补:功能缺口;核心只加可选参数与一个实例方法,不动既有签名", + "status": "open" } ] } diff --git a/.ua/index.json b/.ua/index.json index 549db7a..11e5b24 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T05:03:25.043Z", + "generatedAt": "2026-10-26T05:08:07.129Z", "repoRoot": ".", "packages": [ { @@ -1767,6 +1767,16 @@ "n": "selected", "t": "Signal", "d": "受控选中行信号:与内核选择双向同步(内容比较,不来回派发),供批量操作读取/改写" + }, + { + "n": "exportable", + "t": "boolean", + "d": "工具栏显示「导出 CSV」:导出筛选 + 排序后的全量行(不分页);缺省 false" + }, + { + "n": "exportName", + "t": "string", + "d": "导出文件名;缺省 table.csv" } ], "example": "dataTable({ data: users, selectable: true, columns: [{ key: 'name', header: '姓名', sortable: true }], pageSize: 5 })", @@ -1782,7 +1792,8 @@ "数据在途时传 loading(布尔或信号):表格体换成骨架行、筛选与翻页禁用,返回后置回 false 即恢复", "需要独立分页器联动时传 page 信号:const page = signal(1),dataTable({ page }) 与 pagination({ page }) 共用", "需要批量操作时传 selected 信号:内部勾选实时写回,外部置空或赋值即同步整表(跨页保留,按 rowKey 存)", - "可排序表头的 aria-sort 跟随排序态同步(ascending / descending / none),读屏能直接播报当前列序,箭头只是视觉补充" + "可排序表头的 aria-sort 跟随排序态同步(ascending / descending / none),读屏能直接播报当前列序,箭头只是视觉补充", + "需要导出时开 exportable:按钮导的是筛选+排序后的全量行,与当前页无关,单元格按 format 输出" ] }, { diff --git a/apps/demo/main.ts b/apps/demo/main.ts index 9484343..6c11dcb 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -1077,10 +1077,11 @@ const content = h( section( 'sec-table', '数据表格', - '粘性表头、斑马纹、行选择(全选三态 + 已选计数,选中行低视觉比重)、点表头或 Tab 聚焦后按 Enter/空格 排序、定宽列表头右缘可拖拽或 ←→ 调列宽(双击复位)、工具栏筛选、底部分页。', + '粘性表头、斑马纹、行选择(全选三态 + 已选计数,选中行低视觉比重)、点表头或 Tab 聚焦后按 Enter/空格 排序、定宽列表头右缘可拖拽或 ←→ 调列宽(双击复位)、工具栏筛选、底部分页;工具栏「导出 CSV」导的是筛选 + 排序后的全量行(不含勾选,与当前页无关)。', card({ title: '用户表' }, dataTable({ data: users, selectable: true, + exportable: true, loading: tableLoading, columns: [ { key: 'name', header: '姓名', sortable: true, width: 160 }, diff --git a/llms-full.txt b/llms-full.txt index 6007b03..e6c89a7 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T05:03:25.043Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T05:08:07.129Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -936,6 +936,8 @@ const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label: - page:Signal —— 受控页码信号:与内核分页双向同步,传同一个信号给独立 pagination 组件即可联动 - loading:boolean | Signal —— 加载态:为真时数据行换成骨架行(行数随 pageSize),筛选输入与翻页按钮临时禁用,容器带 aria-busy - selected:Signal —— 受控选中行信号:与内核选择双向同步(内容比较,不来回派发),供批量操作读取/改写 +- exportable:boolean —— 工具栏显示「导出 CSV」:导出筛选 + 排序后的全量行(不分页);缺省 false +- exportName:string —— 导出文件名;缺省 table.csv 示例: @@ -957,6 +959,7 @@ dataTable({ data: users, selectable: true, columns: [{ key: 'name', header: '姓 10. 需要独立分页器联动时传 page 信号:const page = signal(1),dataTable({ page }) 与 pagination({ page }) 共用 11. 需要批量操作时传 selected 信号:内部勾选实时写回,外部置空或赋值即同步整表(跨页保留,按 rowKey 存) 12. 可排序表头的 aria-sort 跟随排序态同步(ascending / descending / none),读屏能直接播报当前列序,箭头只是视觉补充 +13. 需要导出时开 exportable:按钮导的是筛选+排序后的全量行,与当前页无关,单元格按 format 输出 现场演示:apps/docs/demo.html#sec-table diff --git a/packages/core/table-core/scripts/smoke.mjs b/packages/core/table-core/scripts/smoke.mjs index 3360965..7e36fe0 100644 --- a/packages/core/table-core/scripts/smoke.mjs +++ b/packages/core/table-core/scripts/smoke.mjs @@ -154,4 +154,22 @@ assert( 'reset 回到默认态(排序/筛选清空,分页回 1/20)', ); +// getAllRows:过筛选 + 排序但跳过分页,供导出用 +const all = createTable({ + data: users, + columns: [{ key: 'name', header: 'Name' }, { key: 'city', header: 'City' }], + rowKey: 'id', + plugins: [filterPlugin(), sortPlugin(), paginationPlugin()], + initialState: { pagination: { page: 1, pageSize: 2 } }, +}); +assert(all.getVisibleRows().rows.length === 2, 'getVisibleRows 受分页约束只给当页'); +all.dispatch({ type: 'setFilter', key: 'city', value: 'Beijing' }); +assert(all.getAllRows().rows.length === 2 && all.getVisibleRows().rows.length === 2, 'getAllRows 跳过分页但与筛选一致(Beijing 2 行)'); +all.dispatch({ type: 'toggleSort', key: 'age' }); +assert( + JSON.stringify(all.getAllRows().rows.map((r) => r.id)) === JSON.stringify([4, 2]), + 'getAllRows 仍按排序规则排(age 升序 4→2)', +); +assert(all.getPageInfo().totalPages === 1, 'getAllRows 不影响分页快照(2 行 / 每页 2 = 1 页)'); + console.log('SMOKE OK'); diff --git a/packages/core/table-core/src/core.ts b/packages/core/table-core/src/core.ts index fdbc412..f8988f4 100644 --- a/packages/core/table-core/src/core.ts +++ b/packages/core/table-core/src/core.ts @@ -80,12 +80,14 @@ export function createTable( return cachedBase; } - /*** 可见行模型:依序过插件 derive(filter → sort → paginate) ***/ - function getVisibleRows(): RowModel { + /*** 可见行模型:依序过插件 derive(filter → sort → paginate);paged=false 时跳过分页阶段取全量派生行 ***/ + function getVisibleRows(paged = true): RowModel { + ctx.paged = paged; // 由插件自行判断是否裁剪,核心不认插件细节 let model = baseModel(); for (const p of plugins) { if (p.derive) model = p.derive(model, state, ctx); } + ctx.paged = true; // 还原缺省,避免影响后续调用 return model; } @@ -94,6 +96,7 @@ export function createTable( dispatch, subscribe, getVisibleRows, + getAllRows: () => getVisibleRows(false), // 筛选 + 排序后、未分页的全量行(导出等场景) getColumns: () => columns, getCellValue, isRowSelected: (key) => state.selection.includes(key), diff --git a/packages/core/table-core/src/plugins/pagination.ts b/packages/core/table-core/src/plugins/pagination.ts index d281ebd..2a4bd0f 100644 --- a/packages/core/table-core/src/plugins/pagination.ts +++ b/packages/core/table-core/src/plugins/pagination.ts @@ -1,5 +1,6 @@ import type { Action, + DeriveContext, RowModel, TableInstance, TablePlugin, @@ -32,7 +33,8 @@ export function paginationPlugin(): TablePlugin { return state; } }, - derive(model: RowModel, state: TableState): RowModel { + derive(model: RowModel, state: TableState, ctx: DeriveContext): RowModel { + if (ctx.paged === false) return model; // 不分页:原样放行上游模型(导出全量行) if (model === lastInput && state.pagination === lastPage && cached) return cached; const { page, pageSize } = state.pagination; const totalPages = Math.max(1, Math.ceil(model.total / pageSize)); diff --git a/packages/core/table-core/src/types.ts b/packages/core/table-core/src/types.ts index eb947be..078d7b5 100644 --- a/packages/core/table-core/src/types.ts +++ b/packages/core/table-core/src/types.ts @@ -61,6 +61,7 @@ export interface RowModel { /*** 派生上下文:核心注入给插件的取值能力 ***/ export interface DeriveContext { + paged?: boolean; // 是否分页裁剪;false 时 pagination 插件原样放行(导出全量行用) getCellValue(row: T, key: string): unknown; // 单元格取值(走列 accessor) getKey(row: T): RowKey; // 行标识取值 } @@ -89,6 +90,7 @@ export interface TableInstance { dispatch(action: Action): void; // 派发动作,唯一变更入口 subscribe(fn: () => void): () => void; // 订阅变更,返回取消函数 getVisibleRows(): RowModel; // 可见行模型(过完整管线) + getAllRows(): RowModel; // 筛选 + 排序后、未分页的全量行(导出用) getColumns(): ColumnDef[]; // 列定义 getCellValue(row: T, key: string): unknown; // 单元格取值 isRowSelected(key: RowKey): boolean; // 行是否已选 diff --git a/packages/skin/ui/src/csv.ts b/packages/skin/ui/src/csv.ts new file mode 100644 index 0000000..f525d7c --- /dev/null +++ b/packages/skin/ui/src/csv.ts @@ -0,0 +1,39 @@ +import { h } from '@ui-arch/frame'; +import type { RowModel } from '@ui-arch/table-core'; +import { button } from './button.js'; +import type { TableColumn } from './types.js'; + +/*** 单元格转义:含逗号 / 引号 / 换行时整体加引号,内部引号翻倍 ***/ +function cell(v: unknown): string { + const s = v == null ? '' : String(v); + return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +/*** 导出 CSV:前置 BOM + CRLF 换行,Excel 打开中文不乱码 ***/ +export function downloadCsv(name: string, header: string[], rows: unknown[][]): void { + const text = '\ufeff' + [header, ...rows].map((r) => r.map(cell).join(',')).join('\r\n') + '\r\n'; + const url = URL.createObjectURL(new Blob([text], { type: 'text/csv;charset=utf-8' })); + const a = h('a', { href: url, download: name }) as HTMLAnchorElement; + document.body.appendChild(a); // 挂进文档再点击:Safari 对游离锚点不触发下载 + a.click(); + a.remove(); // 立即摘除,不留残留节点 + URL.revokeObjectURL(url); +} + +/*** 导出按钮:取筛选 + 排序后的全量行(不分页),列序与表头一致、单元格走 format ***/ +export function exportButton( + table: { getAllRows(): RowModel; getCellValue(row: T, key: string): unknown }, + columns: TableColumn[], + name: string, +): HTMLElement { + const run = (): void => { + const rows = table.getAllRows().rows; + const header = columns.map((c) => String(c.header ?? c.key)); + const body = rows.map((row) => columns.map((c) => { + const raw = table.getCellValue(row, c.key); + return c.format ? c.format(raw, row) : raw; // 与单元格显示同源,导出内容与看到的一致 + })); + downloadCsv(name, header, body); + }; + return button({ label: '导出 CSV', variant: 'ghost', onClick: run }); +} diff --git a/packages/skin/ui/src/dataTable.ts b/packages/skin/ui/src/dataTable.ts index 3426f46..bb95f2a 100644 --- a/packages/skin/ui/src/dataTable.ts +++ b/packages/skin/ui/src/dataTable.ts @@ -8,6 +8,7 @@ import { import type { PageInfo, RowKey, TableInstance } from '@ui-arch/table-core'; import { computed, effect, h, renderList, signal } from '@ui-arch/frame'; import { attachResize } from './columnResize.js'; +import { exportButton } from './csv.js'; import { button } from './button.js'; import { skeleton } from './skeleton.js'; import { textField } from './textField.js'; @@ -184,6 +185,7 @@ export function dataTable(props: DataTableProps): H { class: 'ua-table-toolbar' }, filterInput, selectable ? h('span', { class: 'ua-sel-count' }, () => (selectedCount() ? `已选 ${selectedCount()} 行` : '')) : null, + props.exportable ? exportButton(table, props.columns, props.exportName ?? 'table.csv') : null, // 导出筛选+排序后的全量行 ), h('div', { class: 'ua-table-scroll' }, h('table', null, colgroup, h('thead', null, h('tr', null, selectable ? headCheck : null, ...props.columns.map(th))), tbody)), h( diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index 26a54f9..2e61840 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -63,6 +63,8 @@ export interface DataTableProps { page?: Signal; // 受控页码信号:与内核分页双向同步,可接独立 pagination 组件联动 loading?: boolean | Signal; // 加载态:为真时数据行换成骨架行,筛选与翻页禁用 selected?: Signal; // 受控选中行信号:与内核选择双向同步,供批量操作读取 + exportable?: boolean; // 工具栏显示导出按钮:导出筛选 + 排序后的全量行(不分页);缺省 false + exportName?: string; // 导出文件名;缺省 table.csv } /*** 选项条目:select / radioGroup / tabs / segmented 共用的选项形状 ***/ diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index 7e42430..a18fc44 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -1638,6 +1638,16 @@ "n": "selected", "t": "Signal", "d": "受控选中行信号:与内核选择双向同步(内容比较,不来回派发),供批量操作读取/改写" + }, + { + "n": "exportable", + "t": "boolean", + "d": "工具栏显示「导出 CSV」:导出筛选 + 排序后的全量行(不分页);缺省 false" + }, + { + "n": "exportName", + "t": "string", + "d": "导出文件名;缺省 table.csv" } ], "example": "dataTable({ data: users, selectable: true, columns: [{ key: 'name', header: '姓名', sortable: true }], pageSize: 5 })", @@ -1653,7 +1663,8 @@ "数据在途时传 loading(布尔或信号):表格体换成骨架行、筛选与翻页禁用,返回后置回 false 即恢复", "需要独立分页器联动时传 page 信号:const page = signal(1),dataTable({ page }) 与 pagination({ page }) 共用", "需要批量操作时传 selected 信号:内部勾选实时写回,外部置空或赋值即同步整表(跨页保留,按 rowKey 存)", - "可排序表头的 aria-sort 跟随排序态同步(ascending / descending / none),读屏能直接播报当前列序,箭头只是视觉补充" + "可排序表头的 aria-sort 跟随排序态同步(ascending / descending / none),读屏能直接播报当前列序,箭头只是视觉补充", + "需要导出时开 exportable:按钮导的是筛选+排序后的全量行,与当前页无关,单元格按 format 输出" ] }, {