This commit is contained in:
oneao committed 2026-10-12 17:32:50 +08:00
1 parent 78eb811ff7
commit 97fe2ff306
83 files changed
+10083 -3662

No files matched your search

+1 -1
View File
@@ -170,10 +170,10 @@ export default defineConfig({
icon: 'table-properties',
collapsed: false,
items: [
{ text: 'Attachment 附件管理器', link: '/ui/data/attachment' },
{ text: 'DatePicker 日期选择', link: '/ui/data/date' },
{ text: 'Pagination 分页器', link: '/ui/data/pagination' },
{ text: 'Splitter 分割面板', link: '/ui/data/splitter' },
{ text: 'Table 表格', link: '/ui/data/table' },
{ text: 'Tabs 标签页', link: '/ui/data/tabs' },
{ text: 'Tree 树形控件', link: '/ui/data/tree' },
],
@@ -1,234 +0,0 @@
---
title: Attachment 附件管理器
description: 分类 / 列表(可选预览)的附件管理面板,上传(选择、拖拽、粘贴)、批量下载删除与分页
---
# Attachment 附件管理器
`Attachment` 是「一个业务对象的附件集合」管理器:左栏分类、中栏文件(卡片 / 列表双视图),支持选择、拖拽、粘贴上传,多选批量下载 / 删除与分页。右栏预览默认关闭,需要时 `show-preview` 打开。
它**不请求任何接口**,也不认识「模块 / 业务主键」这类概念:数据从 `files` / `categories` 进来,用户意图以事件出去;需要组件代跑请求时,传 `uploadRequest` / `removeRequest` 即可。
## 何时使用
- 需要管理某个业务单据 / 项目的附件集合(上传、分类、下载、删除);
- 文件可能有很多,需要分类过滤、分页、批量操作;
- 只需要一个简单的「选择文件」入口时,用 [Input](/ui/form/input) 或原生 `<input type="file">` 更轻。
## 基础用法
给 `files`(附件)和 `categories`(分类,可选)两份数据,上传与删除交给业务:
<demo vue="attachment/basic.vue" />
## 多选与批量操作
没勾选时工具栏只有「全选」;一旦勾选,工具栏浮出「已选 N 项 + 下载 + 删除」。示例关掉了 `builtin-download`,下载只上报事件:
<demo vue="attachment/select.vue" />
## 只读
`readonly` 会隐藏上传 / 删除入口,并让拖拽、粘贴失效,适合审批查看场景:
<demo vue="attachment/readonly.vue" />
## 弹窗用法
附件组件本身不是弹窗,需要弹窗时用 [Modal](/ui/overlay/modal) 包一层即可(高度用 `height` 给):
<demo vue="attachment/modal.vue" />
## 上传与校验
三种入队方式:点底栏「选择」、把文件拖进列表区、`paste-upload` 打开后在页面任意处 `Ctrl/Cmd + V`。
入队前按 `accept` / `maxSize` / `maxCount` 校验,被拒的文件通过 `invalid` 事件抛出(`info.message` 是组件按当前语言包生成的提示原文,可直接 toast):
```ts
function onInvalid(info: AttachmentInvalidInfo) {
// reason: 'accept' | 'max-size' | 'max-count'
message.warning(info.message)
}
```
队列里的文件先渲染在列表最前面(带「待上传」标记),点「上传」后组件把它们交出去:
- **没传 `upload-request`**:emit `upload(files, ctx)`,并**立即清空队列**(文件所有权移交业务);
- **传了 `upload-request`**:组件托管请求,期间「上传」按钮 loading;成功后 emit `upload-success(files, result)`,`result` 是数组时自动追加进 `files`;失败 emit `upload-error(err, files)` 且**保留队列**(可重试)。
## 数据契约
列表项的字段是中性的,不含任何业务字段名:
```ts
const files = ref<AttachmentItem[]>([
{ id: 12, name: '合同.pdf', size: 512_000, categoryId: 'contract', url: '/files/12.pdf' },
])
```
业务侧字段名不同也没关系 —— 组件的所有事件都把原始数据放在 `item.raw` 里原样回传,字段映射写在业务侧的一个 adapter 就够:
```ts
// 后端行 → 组件项
const toItem = (row: BfFilesRow): AttachmentItem => ({
id: row.subid,
name: row.mx_filename,
size: Number(row.mx_filesize),
ext: row.mx_fileext,
url: row.mx_mapfilename,
categoryId: row.mx_cate_id,
raw: row,
})
```
## 分页
两种模式,由 `total` 区分:
| 模式 | 传参 | 行为 |
| --- | --- | --- |
| 客户端分页 | 只给 `files` | `files` 是完整列表,组件按 `page` / `pageSize` 自行切片 |
| 服务端分页 | 再给 `total` | `files` 已经是当前页数据,组件只画不切,翻页靠 `update:page` |
`page` / `pageSize` 是**非受控**的:不传时组件内部维护(默认 1 / 20),传了就完全听你的(`v-model:page-size` 等)。
## API
### Props
| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `files` | `AttachmentItem[]` | `[]` | 附件列表,配合 `v-model:files` |
| `categories` | `AttachmentCategory[]` | `[]` | 分类数据;为空则不渲染左栏 |
| `categoryKey` | `string \| number` | `''` | 选中分类,`''` 表示「全部」,配合 `v-model:categoryKey` |
| `showCategory` | `boolean` | `true` | 是否显示左栏(无 `categories` 时自动隐藏) |
| `showPreview` | `boolean` | `false` | 是否显示右栏预览;关掉时 `preview` 事件照常抛出,可自行开弹窗预览 |
| `readonly` | `boolean` | `false` | 只读:隐藏上传 / 删除,拖拽与粘贴失效 |
| `loading` | `boolean` | `false` | 列表加载中 |
| `categoryLoading` | `boolean` | `false` | 分类加载中 |
| `selectable` | `boolean` | `true` | 是否可勾选 |
| `multiple` | `boolean` | `true` | 是否允许多选 |
| `accept` | `string` | — | 接受的文件类型,同原生 `accept`(支持 `.pdf` / `image/*` / `application/pdf`) |
| `maxSize` | `number` | `0` | 单文件大小上限(字节),`0` 表示不限制 |
| `maxCount` | `number` | `0` | 待上传队列上限,`0` 表示不限制 |
| `pasteUpload` | `boolean` | `false` | 监听 `document` 的粘贴上传(多实例场景请只开一个) |
| `confirmRemove` | `boolean` | `true` | 删除前二次确认(内建 Modal) |
| `builtinDownload` | `boolean` | `true` | 依据 `item.url` 内建触发下载;关掉后只上报 `download` |
| `pagination` | `boolean` | `true` | 是否显示分页(数据不足一页时自动隐藏) |
| `page` | `number` | — | 当前页,非受控(内部默认 `1`) |
| `pageSize` | `number` | — | 每页条数,非受控(内部默认 `20`) |
| `total` | `number` | — | 总条数;**传了即为服务端分页**,`files` 视为当前页 |
| `pageSizeOptions` | `Array<number \| string>` | `[10, 20, 50, 100]` | 每页条数选项 |
| `showSizeChanger` | `boolean` | `false` | 显示每页条数切换器 |
| `height` | `number \| string` | — | 根容器高度,数字按 px;默认撑满父容器(父级无高度时保底 320px) |
| `uploadRequest` | `(files: File[], ctx: AttachmentUploadContext) => Promise<AttachmentItem[] \| void> \| AttachmentItem[] \| void` | — | 托管上传 |
| `removeRequest` | `(items: AttachmentItem[]) => Promise<void> \| void` | — | 托管删除 |
### Events
| 名称 | 参数 | 说明 |
| --- | --- | --- |
| `update:files` | `(files: AttachmentItem[])` | 删除成功 / 托管上传回填后更新列表 |
| `update:categoryKey` | `(key: string \| number)` | 分类切换 |
| `update:page` | `(page: number)` | 翻页 |
| `update:pageSize` | `(size: number)` | 每页条数变化 |
| `change` | `(files: AttachmentItem[])` | 列表增删(上传成功 / 删除成功) |
| `select` | `(items: AttachmentItem[])` | 勾选变化 |
| `categoryChange` | `(key: string \| number, category: AttachmentCategory \| null)` | 分类切换,带原始分类对象 |
| `preview` | `(item: AttachmentItem \| null)` | 点击某项预览(翻页同样触发) |
| `download` | `(items: AttachmentItem[])` | 下载 |
| `remove` | `(items: AttachmentItem[])` | 用户确认删除且请求成功 |
| `removeError` | `(error: unknown, items: AttachmentItem[])` | 删除失败 |
| `upload` | `(files: File[], ctx: AttachmentUploadContext)` | 未传 `uploadRequest` 时把待上传文件交给业务 |
| `uploadSuccess` | `(files: File[], result: AttachmentItem[] \| void)` | 托管上传成功 |
| `uploadError` | `(error: unknown, files: File[])` | 托管上传失败(队列保留) |
| `invalid` | `(info: AttachmentInvalidInfo)` | 文件被 `accept` / `maxSize` / `maxCount` 拒绝 |
### Exposed(通过 ref 调用)
| 名称 | 类型 | 说明 |
| --- | --- | --- |
| `selectedItems` | `AttachmentItem[]` | 当前勾选的附件 |
| `pendingFiles` | `AttachmentPendingFile[]` | 待上传队列 |
| `openFileDialog()` | — | 打开系统文件选择框 |
| `clearSelection()` | — | 清空勾选 |
| `selectAll()` | — | 全选当前页 |
| `addFiles(files)` | `File[] \| FileList => File[]` | 入队(走 `accept` / `maxSize` / `maxCount` 校验),返回被接受的文件 |
| `clearPending()` | — | 清空待上传队列(业务自行接管上传时调用) |
### 类型定义
```ts
interface AttachmentItem {
id: string | number
name: string
size?: number
ext?: string
url?: string
thumbnailUrl?: string
categoryId?: string | number
status?: 'ready' | 'uploading' | 'error'
percent?: number
raw?: unknown
[key: string]: unknown
}
interface AttachmentCategory {
id: string | number
name: string
count?: number
disabled?: boolean
children?: AttachmentCategory[]
}
interface AttachmentPendingFile {
file: File
name: string
size: number
ext: string
url?: string
}
interface AttachmentInvalidInfo {
reason: 'accept' | 'max-size' | 'max-count'
files: File[]
message: string
}
interface AttachmentUploadContext {
categoryId?: string | number
}
```
### 样式变量
类型色(图标 + 扩展名那一小块)按文件族取色,全部可覆盖;`--g3-color-*` 未加载时组件自带兜底值:
| 变量 | 说明 |
| --- | --- |
| `--g3-attachment-type-image` | 图片:默认 `--g3-color-primary` |
| `--g3-attachment-type-doc` | 文档(pdf / doc / txt …):默认 `--g3-color-danger` |
| `--g3-attachment-type-sheet` | 表格:默认 `--g3-color-success` |
| `--g3-attachment-type-slide` | 演示:默认 `--g3-color-warning` |
| `--g3-attachment-type-archive` | 压缩包:默认 `#d98e04`(暗色 `#e0a63c`) |
| `--g3-attachment-type-media` | 音视频:默认 `#1f9ea8`(暗色 `#38bcc6`) |
| `--g3-attachment-type-other` | 未知类型:默认 `--g3-text-3` |
其余尺寸 / 间距 / 圆角 / 过渡全部走令牌(`--g3-control-height`、`--g3-space-*`、`--g3-radius*`、`--g3-duration*`),换肤时不需要动组件。
## 实现说明
- **默认两栏,预览按需开**:布局用 [Splitter](/ui/data/splitter)——分类栏固定 200px(150–320 可拖),列表栏自适应(≥280);`show-preview` 打开后追加 360px(≥280)的预览栏。`showCategory` / `showPreview` 为假时对应面板直接不渲染,只剩两栏或一栏。预览栏关闭时 `preview` 事件照常抛出,业务可以接自己的弹窗 / 抽屉。
- **面板之间有常显的分割线**:关掉了 Splitter 的 `barOnHover`(默认只在 hover 时显示),并把线宽收成 1px——否则分类栏与卡片区之间看不出边界。拖拽命中区仍是 10px,只是视觉线更细。
- **工具栏整行同一档规格**:全选(复选框文案)、已选计数、批量按钮统一到 **12px 字 / 24px 控件**(按钮 `size="small"`),高度**写死** `24px + 上下各 8px`——必须用 `height` 而不是 `min-height`:默认 `content-box` 下 `min-height` 只管内容盒,实际高度 = 内容盒 + 上下 padding,勾选前后仍会变。行内元素一律 `flex: none`(不收缩)+ `flex-wrap: nowrap` + `overflow: hidden`。勾选后浮出「| 已选 N 项 · 下载 · 删除」,用 1px 竖线把「选择」与「操作」分组——字号一致、控件等高,才不会看着"歪"或空荡。删除与下载用 [Button](/ui/general/button) 的 `danger-outline` / `outline` 变体,在工具栏里有明确边界(`ghost` 的纯文字按钮会像裸文本飘着)。**窄面板**走容器查询(按面板宽度、不是视口宽度):先收掉按钮文案只留图标,再窄一点连「全选」文案也收起,保证一行永远放得下。
- **操作藏在 hover 里**:卡片右下角的「下载 / 删除」平时不显示,hover 或键盘聚焦(`:focus-within`)才浮现,`@media (hover: none)` 的触屏设备下常显。工具栏同理——没勾选任何文件时不渲染「下载 / 删除」,只留全选;勾选后浮出「已选 N 项 + 操作」。
- **空态就是投放区**:虚线框铺满列表区,hover 转主色;文案两行(「暂无文件」+「拖拽文件到此处,或点击选择」),并把 `accept` / `maxSize` / `maxCount` 的限制拼成第三行提示,省得用户上传后才被拒。
- **底栏是"脏"状态指示**:待上传队列非空时,底栏整块染主色淡底、计数变主色;上传与分页合成同一块(只有一条顶边),不会出现两条并排的分隔线。
- **分类树用 [Tree](/ui/data/tree)**:`categories` 之上合成了一个「全部」根节点(对外用 `categoryKey === ''` 表示),因此可以给层级数据;`categoryChange` 会把原始分类对象一起带出来。
- **删除确认是内建 Modal**:不走 `Modal.confirm()` 命令式(那会让组件依赖全局挂载容器)。`confirmRemove` 关掉后直接进请求。
- **`update:files` 的语义**:受控组件不擅自改数据。删除成功、或托管上传返回数组时,组件会 emit 过滤 / 追加后的新数组,方便 `v-model:files` 一行接上;完全自管数据时忽略它即可。
- **blob URL 的回收**:待上传图片的本地预览地址由组件创建,在出队、清空、切换分类、卸载时释放,业务无需关心。
- **上传进度**:`files` 里某项 `status: 'uploading'` 时,卡片封面底边出现 2px 进度条、列表视图在大小列显示细轨 + 百分比(进度值读 `percent`)。
- **粘贴上传默认关闭**:它监听 `document`,一个页面多个附件组件同时开会互相抢,所以做成显式 opt-in。
- **RTL**:布局全部用逻辑属性(`inset-inline-start` / `inset-block-end` / `text-align: start` 等),宿主 `<html dir="rtl">` 下自动镜像,分隔条拖拽与键盘方向由 Splitter 的 `useDirection` 处理。
@@ -0,0 +1,183 @@
---
title: Table 表格
description: 高性能虚拟滚动表格,支持排序、固定列、多选、行内编辑、列宽 / 列序拖拽与行拖拽排序
---
# Table 表格
`Table` 用于展示结构化数据,底层是 [`stk-table-vue`](https://ja-plus.github.io/stk-table-vue/),虚拟滚动 / 列宽拖拽 / 列序拖拽 / 区域选择 / 行拖拽排序都由它提供;组件负责**性能门控**、**内部列管理**与**行内编辑态**。
## 何时使用
- 数据量大(成百上千行)且需要滚动流畅时;
- 需要行内编辑、多选批量操作、列宽 / 列序可调时;
- 数据量很小、只要一张静态表时,原生 `<table>` 或 [Grid](/ui/general/grid) 更轻。
## 基础用法
给 `columns` 与 `dataSource` 两份数据即可。序号列(`seq`)默认开启,外框与圆角默认有(`bordered`)。
<demo vue="table/basic.vue" />
## 虚拟滚动与性能
组件内置两条门控,**自动**决定是否启用虚拟滚动,不需要业务手动切换:
| 方向 | 阈值 | 低于阈值时 | 原因 |
| --- | --- | --- | --- |
| 纵向 | 行数 > 100 | 全量渲染 | 库的虚拟滚动在「行数略多于单屏行数」时会把最后几行算出视口外,必须滚动才可见;行数少时全量渲染没有性能代价,却换来确定性正确的渲染 |
| 横向 | 列数 > 20 | 关掉横向虚拟 | 库在横向虚拟开启时会把每列宽度钉死为 `minWidth ?? width ?? 100`,未设 `width` 的列会失去「按比例铺满容器」的原生自适应 |
可以用 `virtual` / `virtualX` 关掉,但**不能**把它们强制打开到阈值以下。
<demo vue="table/virtual.vue" />
除了门控,还有三处性能相关设计:
- **无编辑行时不注入 `customCell`**:纯展示列与非编辑态的可编辑列走库的原生 `td` 渲染路径,滚动时每格少一个函数式组件实例;
- **尺寸变化 150ms 防抖**:折叠动画 / 窗口缩放期间 ResizeObserver 每帧都会通知,逐帧重算会强制 reflow 并重渲染可见行列;组件合并到尺寸稳定后只刷新一次,并区分「仅宽度变化」只重算横向;
- **选择列用原生 `<input type="checkbox">`**:视觉由 CSS 模拟成 [Checkbox](/ui/form/checkbox) 的外观,但每行不创建组件实例。
## 多选与批量操作
传 `rowSelection` 即在最前注入选择列;`getCheckboxProps` 可禁用个别行(禁用行不计入「全选」的分母)。
<demo vue="table/selection.vue" />
## 排序
列上写 `sorter: true` 即可点表头排序(组件内排序,不请求接口)。`sortType` 决定按数字还是字符串比较,默认字符串;需要自定义规则时 `sorter` 也可以传函数。
<demo vue="table/sorting.vue" />
## 行内编辑
列上加 `editor` 即可编辑:单击单元格进入,`Enter` 确认、`Esc` 取消。
| 编辑器类型 | 说明 |
| --- | --- |
| `text` / `number` / `textarea` | 用 [Input](/ui/form/input) 渲染 |
| `select` | 用 [Select](/ui/form/select) 渲染;`options` 可以是数组,也可以是接收当前行的函数 |
| `checkbox` | 点击即切换,无需先激活整行;约定 `1` = 选中、`0` = 未选中(与后端 tinyint 对齐) |
编辑器的输入值先写进内部草稿,**失焦才落库并抛出 `change`**。这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。用 `editable={false}` 可整体禁用编辑。
<demo vue="table/editable.vue" />
## 操作列
列上加 `actions` 渲染成一排小按钮;`type: 'more'` 的项会收进「更多」下拉。带 `confirm` 的操作先弹确认框,确认后才执行 `onClick`。
操作列建议显式写 `resizable: false`:它的宽度由内容决定,拖了不会落库,列一重建就丢。
<demo vue="table/actions.vue" />
## 列宽、列序与行拖拽
列宽 / 列序拖拽要求用 `v-model:columns` 绑定列定义——否则组件拖完无处回写(松手回弹),会直接关掉这两个功能。行拖拽排序用 `row-draggable`,顺序变化通过 `row-order-change` 回传。
<demo vue="table/resizable.vue" />
## API
### Props
| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `columns` | `G3TableColumn<T>[]` | `[]` | 列定义,配合 `v-model:columns` 接收拖拽后的回写 |
| `dataSource` | `T[]` | `[]` | 数据源 |
| `rowKey` | `string \| (row) => string \| number` | `'id'` | 行唯一键 |
| `virtual` | `boolean` | `true` | 纵向虚拟滚动总开关(实际是否开启受行数门控) |
| `virtualX` | `boolean` | `true` | 横向虚拟滚动总开关(实际是否开启受列数门控) |
| `rowHeight` | `number` | `30` | 行高,同时驱动虚拟滚动计算 |
| `headerRowHeight` | `number` | `34` | 表头行高 |
| `bordered` | `boolean \| string` | `true` | 外框圆角描边 + 行分隔线;`false` 时只留行分隔线 |
| `colResizable` | `boolean \| object` | `true` | 列宽拖拽;未绑定 `v-model:columns` 时自动关闭。可用 `{ disabled: (col) => boolean }` 按列禁用 |
| `columnDraggable` | `boolean \| object` | `true` | 列序拖拽;同上要求绑定 `v-model:columns` |
| `rowDraggable` | `boolean` | `false` | 行拖拽排序 |
| `rowSelection` | `TableRowSelection \| null` | `null` | 多选配置,见下 |
| `seq` | `boolean \| { title?, width? }` | `true` | 序号列 |
| `activeRowKey` | `string \| number \| null` | `null` | 当前高亮行 |
| `editable` | `boolean` | `true` | 行内编辑总开关 |
| `noDataFull` | `boolean` | `true` | 空态铺满容器高度 |
| `showOverflow` | `boolean` | `true` | 内容溢出省略 |
| `autoFitWidth` | `boolean` | `false` | 非横向虚拟时铺满容器宽度(开启后关闭列宽拖拽) |
| `theme` | `'light' \| 'dark'` | — | 不传则跟随全局主题(`ConfigProvider` / `html[data-g3-theme]`) |
| `footerData` | `T[]` | `[]` | 表尾数据 |
| `footerConfig` | `object` | `` | 表尾配置 |
### 列定义 `G3TableColumn<T>`
在 `stk-table-vue` 的列类型基础上扩展;下面只列自有字段,其余(`width` / `minWidth` / `fixed` / `align` / `headerAlign` / `sorter` / `sortType` / `children` / `mergeCells` 等)原样透传。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `dataIndex` | `keyof T & string` | 取值字段 |
| `title` | `string` | 表头文字 |
| `resizable` | `boolean` | 该列是否可拖宽;操作列 / 展开列建议设 `false` |
| `editor` | `TableEditor<T>` | 行内编辑器,见下 |
| `actions` | `TableAction<T>[]` | 操作列按钮 |
| `customCell` | `(props) => VNode \| string` | 自定义单元格渲染(逃生舱),传了就不再自动包装 |
### `editor`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `type` | `'text' \| 'number' \| 'password' \| 'textarea' \| 'select' \| 'checkbox'` | 编辑器类型 |
| `options` | `TableSelectOption[] \| ((row) => TableSelectOption[])` | select 选项,可依赖当前行其它字段 |
| `disabled` | `boolean \| ((row) => boolean)` | 是否禁用该格编辑 |
| `allowClear` | `boolean` | select 是否可清空,默认 `true` |
| `emptyValue` | `unknown` | select 清空后落库的值,默认 `null` |
| `onChange` | `(value, row, col) => void` | 本列值变化回调(早于组件的 `change`) |
### `actions`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `key` | `string \| number` | 唯一键;`type: 'more'` 的子项用它做下拉 key |
| `label` | `string` | 文案 |
| `type` | `'default' \| 'danger' \| 'delete' \| 'more'` | 语义类型 |
| `icon` | `Component` | 图标组件 |
| `visible` | `boolean \| ((row) => boolean)` | 是否显示 |
| `disabled` | `boolean \| ((row) => boolean)` | 是否禁用 |
| `confirm` | `string \| object` | 二次确认;字符串为标题,对象支持 `title` / `description` / `okText` / `cancelText` / `okType` / `width` / `maskClosable` |
| `onClick` | `(row) => void` | 点击回调(带 `confirm` 时在确认后执行) |
| `items` | `TableAction<T>[]` | `type: 'more'` 时的子项 |
### Events
| 名称 | 参数 | 说明 |
| --- | --- | --- |
| `update:columns` | `(columns)` | 列宽 / 列序拖拽后回写(内部列已剥离) |
| `update:selectedRowKeys` | `(keys)` | 勾选变化 |
| `change` | `(row, col, value)` | 行内编辑的值变化 |
| `row-order-change` | `(rows, sourceIndex, targetIndex)` | 行拖拽排序完成 |
| `area-selection-change` | `(ranges)` | 单元格选区变化(行内编辑依赖它) |
### Exposed
| 名称 | 类型 | 说明 |
| --- | --- | --- |
| `scrollToRow(rowKey)` | `Promise<void>` | 滚动到指定行 |
| `editRow(rowKey, focusCol?)` | `Promise<void>` | 滚动到指定行并进入编辑 |
| `initVirtualScroll()` / `refreshLayout()` | — | 手动重算虚拟滚动布局 |
| `getSelectedArea()` / `clearSelectedArea()` | — | 读取 / 清空单元格选区 |
| `setRowExpand(...)` | — | 展开 / 收起行(需配 `expand` 列) |
### Slots
| 名称 | 说明 |
| --- | --- |
| `empty` | 空态内容,默认显示「暂无数据」 |
| `expand` | 展开行内容(需列配置 `type: 'expand'`) |
## 实现说明
- **底层是 `stk-table-vue`**:虚拟滚动、列宽 / 列序拖拽、区域选择、行拖拽、二级表头、固定列、合并单元格都由它实现。组件不重复造这些轮子,只做门控与适配。
- **库样式已并入 `dist/style.css`**:库的 CSS 是独立文件(其 JS 里不含任何 `.css` import),组件 `@use` 了它,消费方不需要额外引一次第三方样式。
- 注意 SCSS 里**不能**写 `@use 'stk-table-vue/lib/style.css'`:带 `.css` 后缀时 Sass 不会内联,只原样输出一条 CSS `@import` 语句,产物里会留下一条浏览器解析不了的裸包路径。去掉后缀才会真正内联。
- **主题通过覆盖库的 CSS 变量实现**:库把 41 个主题值都做成了 `.stk-table` 作用域内的变量,组件把它们映射到 `--g3-*` 令牌(覆盖层在 `src/table/style/_overrides.scss`)。改变量而不是逐个选择器改样式,库升级时不容易失效。
- **区域选择按需自注册**:库的单元格区域选择是全局注册的(`registerFeature(useAreaSelection)`),不注册则 `area-selection-change` 永不触发、行内编辑失效。组件库不拥有 app 启动流程,因此**首次挂载时自动注册一次**;需要显式控制时可调用导出的 `registerTableFeatures()`。
- **`data-testid` 透传到内层**:组件 `inheritAttrs: false`,`$attrs` 落在内层 `StkTable` 上。外框 / 圆角在我们自己的根节点 `.g3-table` 上,写 E2E 时按类名定位根节点。
- **行编辑态存在 `WeakMap` 里**:按行对象存,不给业务数据打标记;行被回收时状态自动释放。行与行之间切换编辑**不翻转** `editingActive`,避免整表列重建。
- **RTL**:布局用逻辑属性,带符号位移走 `--g3-dir-sign`;分隔条拖拽与键盘方向由库的 `useDirection` 处理。
@@ -157,5 +157,9 @@ export interface InputFocusOptions {
- **数字模式的取舍**:输入过程保留字符串(否则用户打不出 `12.`),只在 `change` / 失焦时转为 `Number`,空串保持 `''`,避免 `Number('')` 变成 `0`;
- 光标位置在数字过滤后会重新计算(`setSelectionRange`),因此在中间插入非法字符不会把光标弹到末尾;
- 多行模式的高度拖拽用 Pointer Events + `requestAnimationFrame` 合并更新,拖拽期间改的是内联 `height`(从小高度往大拖不会跳变);
- `placeholder` 等原生属性声明为 prop 是刻意的:容器是 `<div>`,如果不声明,`placeholder` 会作为 fallthrough 属性挂到外层容器上,对真实输入框无效;
- **`$attrs` 的落点**:根节点是包裹 `<div>`,真正的 `<input>` / `<textarea>` 在内部,所以组件设了 `inheritAttrs: false` 并手工分流 —— 归属输入行为的属性(`id` / `name` / `autocomplete` / 原生事件等)`v-bind` 到**内部控件**,表达布局的 `class` / `style` 留在**包裹层**。这样 `<label for="x">` 能正确聚焦、`@keydown` 的 `event.target` 是真正的输入框;
- `placeholder` / `autocomplete` / `maxlength` 这类声明为 prop 是历史原因(早期未做 attrs 分流),保留是为了不打断既有调用方 —— 新代码可直接透传原生属性;
- 聚焦样式用 `--g3-color-primary-ring` 画 2px 外环(而非 `outline`),这样在 `Space` / `Grid` 里不会因为 outline 被相邻元素裁掉。
> 注意:`type` 仅支持 `default` / `password` / `number` / `textarea` 四个值(组件内部据此切换模式与净化逻辑)。传 `url` / `email` 这类原生类型不会生效,会按 `default` 处理 —— 需要在表单里做 URL/邮箱校验时,用 `G3FormItem` 的 `type` 规则。
@@ -1,6 +1,6 @@
---
title: RichText 富文本编辑器
description: 基于 Tiptap 的富文本编辑,支持字体、字号、对齐、文字与背景色、上下标、图片视频上传与表格
description: 基于 Tiptap 的富文本编辑,支持字体、字号、对齐、文字与背景色、上下标、列表样式、图片视频上传、表格、预览打印、整理格式与源代码
---
# RichText 富文本编辑器
@@ -23,12 +23,32 @@ description: 基于 Tiptap 的富文本编辑,支持字体、字号、对齐
## 工具条
工具条按「一天点几次」而不是「功能重不重要」排布,**始终一行、高度恒定 48px**:
工具条按「一天点几次」而不是「功能重不重要」排布,并分成 7 组(组间靠**间距**区分,没有分割线):
- **常驻**(顺序:历史 → 正文格式 → 行内格式 → 对齐 → 颜色 → 列表与插入):撤销、重做 | 段落格式(正文 / 标题 1-3)| 加粗、斜体、下划线 | 对齐(左 / 中 / 右)| 字体颜色、背景颜色 | 无序列表、有序列表、链接、图片、插入表格;
- **收进 `⋯ 更多`**:`字体` / `字号`(二级菜单,选中态跟随光标)、`格式` 组(删除线 / 上标 / 下标 / 引用)、`插入` 组(视频)。菜单项带分组标题与快捷键提示(对着 Tiptap 的真实键位写的,如删除线 `Ctrl+Shift+S`)。
- **常驻**(顺序:历史 → 段落格式 → 行内格式 → 对齐 → 颜色 → 列表与插入 → 更多):撤销、重做 | 段落格式(正文 / 标题 1-6)| 加粗、斜体、下划线 | 对齐(左 / 中 / 右)| 字体颜色、背景颜色 | 无序列表、有序列表、图片、链接、插入表格 | `⋯`;
- **收进 `⋯ 更多`**:`字体` / `字号`(二级菜单)、`格式` 组(删除线 / 上标 / 下标 / 引用 / **整理格式**)、`插入` 组(视频)、`查看与导出` 组(**预览** / **打印** / **源代码**)。菜单项带分组标题与快捷键提示(对着 Tiptap 的真实键位写的,如删除线 `Ctrl+Shift+S`)。
图标 16px、按钮 32px(等于 `--g3-control-height`)、组内间隙 4px、内边距 8px / 10px(左右 10px 让首列图标与正文的 12px 内边距对齐);图标前景用 `text-1`(88% 黑)而不是 ghost 按钮默认的 `text-2`(65%),描边保持 lucide 原生粗细 —— 实测「发虚」的主因是浅灰前景而不是线宽,所以只加深颜色、不加重描边。整条工具条自然宽度约 **744px**:760px 以上的容器都是一行,再窄才折行,且折行以「组」为单位,不会把分隔线甩到行首/行尾。表格插入按钮留在工具条上而不是进 `⋯`:它要先选行列,收起后承载不了那个面板。
> 无序列表 / 有序列表的**样式**不在「更多」里 —— 它们挂在两个列表按钮自己的下拉上(点按钮展开样式面板),见「列表」一节。
图标 **18px**、按钮 32px(等于 `--g3-control-height`)、内边距 8px / 10px。图标前景用 `text-1`(88% 黑)而不是 ghost 按钮默认的 `text-2`(65%),描边保持 lucide 原生粗细 —— 实测「发虚」的主因是浅灰前景而不是线宽,所以只加深颜色、不加重描边。
### 分组与折行
分组用 `role="group"` + `aria-label` 表达语义,**视觉上不画分割线**(对齐 TinyMCE 的 `tox-toolbar__group`):
```html
<div role="group" aria-label="行内格式" class="g3-rich-text__group">…</div>
```
分组容器用了 `display: contents` —— 组的盒子**不参与布局**,组内按钮被提升为工具条的直接 flex 项,因此**折行以按钮为单位**,每行都会填满、不会出现「整组被推到下一行、右边空一大片」。代价是间距不能再用组的 padding / `gap` 表达,改由按钮的 `margin-inline-end` 承担:组内 2px、组间 12px(6 倍对比构成分组感)。
> 用 `flex-wrap: wrap` + `min-width: 0` 给组**做不到**按按钮折行:组对工具条来说始终是一个不可分割的 flex 项,`flex-wrap` 只影响组内部如何排列。
### 键盘操作
工具条是 `role="toolbar"` 的**单个 Tab 停靠点**(ARIA 作者实践):`Tab` 进入工具条后,用 **`←` / `→`** 在按钮间移动(两端循环),**`Home` / `End`** 跳到首 / 尾,再 `Tab` 离开落到正文。RTL 下左右键按屏幕方向解释。这样避免「20 多个按钮各占一个 Tab 位、键盘用户要按二十几次才能穿过工具条」。
内部用 roving tabindex 实现:焦点所在按钮 `tabindex=0`,其余 `-1`;按钮的禁用态(撤销 / 重做、图片上的对齐)变化后会重新归一化,不会出现「全部 -1 导致 Tab 进不来」。
## 对齐与颜色
@@ -38,7 +58,22 @@ description: 基于 Tiptap 的富文本编辑,支持字体、字号、对齐
- **字体颜色**(`A` 图标 + 当前色条):写 `color`;
- **背景颜色**(荧光笔图标 + 当前色条):写 `background-color`。
两个取色按钮都是「图标 + 当前色条」的形式,色条显示选区当前的颜色(未设色时是空位)。点开后是预设色板面板:常用色一屏排满,`无颜色` 清除该属性,右侧彩虹色块打开系统取色器选任意颜色。选完色面板自动收起。
两个取色按钮都是「图标 + 当前色条」的形式,色条显示选区当前的颜色(未设色时是空位)。点开后是预设色板面板:常用色一屏排满(24px 色块),`无颜色` 清除该属性,右侧彩虹色块打开系统取色器选任意颜色。选完色面板自动收起。
### 换一套色板
预设色板走 CSS 变量(`--g3-rich-text-swatch-text-1..10` / `--g3-rich-text-swatch-bg-1..10`),宿主可在任意祖先上覆盖,**不需要改组件代码、不需要发版**:
```css
/* 换成品牌色板 */
.g3-rich-text__toolbar {
--g3-rich-text-swatch-text-5: #ff4d4f;
}
```
变量化只解决「可覆盖」,默认色值不变(仍是 Ant Design 的 6 号色 / 1 号色)。**暗色主题下不会自动换一套色值** —— 正文颜色是文档内容的一部分,同一份 HTML 在不同主题下显示成不同颜色会让「所见即所得」失效;需要暗色调色板的宿主自行覆盖这些变量。
色板显示走变量,但**写进文档的始终是解析后的具体颜色**(`rgb(...)`),不会把 `var(--g3-…)` 存进 HTML:邮件正文、复制出去的内容、别人家的页面都没有这个变量,存变量会渲染成透明或黑色。
## 图片 / 视频上传
@@ -57,6 +92,129 @@ type Upload = (file: File) => Promise<{
}>
```
## 链接
工具条「链接」按钮弹出一个输入面板(不是 `window.prompt`),两个字段:**链接地址** 与 **显示文字**。填好后回车或点「确定」应用。两个输入框都**不设 placeholder** —— 字段名已经说明要填什么,示例文案属于噪音。
**操作区**(一行,「取消 / 确定」始终靠右成组):
| 位置 | 按钮 | 说明 |
| --- | --- | --- |
| 左 | 移除链接 | 仅光标已在链接上时出现;危险语义与主操作分开,不让它和「确定」挨着 |
| 右 | 取消 | Ghost 低强调,关闭面板且**不写入文档** |
| 右 | 确定 | Primary 主操作;**只要地址非空就可用**(见下) |
按钮用**默认尺寸**(32px,等于 `--g3-control-height`)而不是 `size="small"`(24px):这是面板里唯一的决定动作,24px 在弹层里偏小,且无法与 `G3Input` 的 32px 等高、同一行里不齐平。用 `justify-content: space-between` 而非各组均分,是为了让**「确定」的位置在编辑态 / 非编辑态下始终不变**(编辑态左侧多出「移除链接」,靠右的主操作不该被挤动)。
**「显示文字」不是必填**(只填地址就能提交)。链接文字按「填的显示文字 → 选中文字 → 地址本身」依次兜底,因此最后一种情况下链接文字就是 URL:
| 场景 | 「显示文字」的行为 |
| --- | --- |
| 有选中文字 | 预填选中的文字;改成别的内容即**替换选中文字**;留空则**沿用原文字** |
| 空选区(光标停在某处) | 作为**链接文字插入**(等价于「先打字再选中」);留空则用**地址本身**当文字 |
| 光标已在链接上 | 预填该链接的文字;改它就替换链接文字,留空则只改地址 |
其他规则:
- **地址不带协议时自动补 `https://`**:`example.com` → `https://example.com`;已是完整 URL、`#` 锚点、`/` 站内路径、`mailto:` 的一律不动;
- **光标已在链接上**:面板预填当前地址与链接文字,并多出一个「移除链接」按钮;
- 为防 XSS,只有 `http` / `https` / `mailto` / `tel` / `#` / `/` 开头的地址会被写入。
### 右键菜单
在正文的**链接**上右键,会在鼠标位置弹出操作菜单(在非链接处右键仍走浏览器原生菜单):
| 菜单项 | 行为 |
| --- | --- |
| 编辑链接 | 打开链接面板并预填该链接的地址与文字 |
| 打开链接 | 新标签页打开(带 `noopener,noreferrer`) |
| 删除链接 | **只去掉链接、保留文字**(想连文字一起删,用选中 + `Delete` 更直接) |
菜单作用于**右键落点**那条链接,而不是当前光标所在处 —— 右键不一定移动光标,直接按选区操作会删错链接。
## 列表
工具条上的「无序列表 / 有序列表」是**分裂按钮**(对齐 TinyMCE 的 `bullist` / `numlist`):**点图标 = 一键建 / 撤列表**,**点右侧箭头 = 打开样式面板**:
| 面板 | 档位 |
| --- | --- |
| 无序列表 | 默认、实心圆点、空心圆圈、实心方块 |
| 有序列表 | 默认、小写字母、小写罗马数字、小写希腊字母、大写罗马数字、大写字母 |
样式面板里每一项画成**缩略图**(三条内容线 + 左侧真实的项目符号 / 编号,与 TinyMCE 图标同一画法),下方标注档位名,**一眼扫过就能分辨**,不需要记住 `disc` / `circle` / `square` 这些英文枚举。当前档位打勾并高亮。
点某一档即把光标所在的列表设成该样式;**光标不在列表里时会先建列表再设样式** ——「想插一个方块列表」一次选择即可,不必先点无序列表再挑样式(内部是两个事务:建列表 + 设样式,按两次 `Ctrl+Z` 依次退回)。
### 样式存成属性,不存成类名
样式写进文档的是 **HTML 属性**(`<ul type="square">` / `<ol type="I">`),而不是 `class="g3-list-square"`。属性是自描述的:导出的 HTML 到邮件客户端、别人家的页面都保持原样;用类名的话离开本组件的样式表就退化成默认圆点,而富文本正文的第一要求是「导出即正确」。
**小写希腊字母是唯一例外**:`<ol type="lower-greek">` 不是合法的 HTML 属性值(`<ol type>` 只认 `1 / a / A / i / I`),浏览器会当作未知值忽略、静默退回数字编号。这一档改用 `style="list-style-type: lower-greek"` 表达。组件的 `OrderedListWithGreek` 扩展补齐了官方 Tiptap 在这条路上的两个缺口(读回时不认 `lower-greek`、写出时会生成非法的 `type` 属性),因此这一档与其它档位一样能完整往返。
### 自定义档位
`bullet-list-types` / `ordered-list-types` 可传自己的选项(结构与 `{ label, value, glyph }` 一致),用于裁剪档位或本地化文案:
```vue
<G3RichText v-model="html" :bullet-list-types="[{ label: '默认', value: '', glyph: '•' }]" />
```
不传时用内置的 `DEFAULT_BULLET_LIST_TYPES` / `DEFAULT_ORDERED_LIST_TYPES`(已从包入口导出,便于在其基础上裁剪)。
## 预览 / 打印
「更多 → 查看与导出」里有两项:
- **预览**:在模态框里按正文的真实排版**只读**渲染当前 HTML(标题层级、列表、表格、颜色、对齐都与编辑区一致),弹窗里还有一个「打印」按钮;
- **打印**:**只打印正文**,不带宿主页面的导航、按钮与工具条。
两者都用模态框 / 隐藏 iframe,而不是 `window.open`:嵌入式 WebView 与 iframe 里新窗口常被拦截,点了没反应;模态框复用库内 `G3Modal`,观感与全库一致,E2E 也能直接断言。
### 预览为什么不是「再开一个编辑器」
预览用 `v-html` 直接渲染,而不是把内容塞进第二个 Tiptap 实例:后者会把 ProseMirror 的**编辑态装饰**(空文档占位符、选中节点描边、列宽拖拽手柄)一起带进来,那正是「预览」不想要的。预览区复刻了一份与编辑器内一致的排版规则,保证「编辑时看到的」「预览看到的」「打印出来的」三者一致。
预览打开时会**取一份内容快照**:预览的语义是「看一眼当前成果」,快照也保证了「从预览里打印」与「屏幕上看到的」是同一份内容。
### 打印的实现
用**隐藏 iframe** 装载正文 HTML 后调 `iframe.contentWindow.print()`,而不是直接 `window.print()` 打印整个页面(那会把宿主页面的界面一起打出来)。iframe 用「零尺寸 + 移出视口」隐藏,**不用** `display: none`(部分浏览器会跳过渲染、打印出空白页)也**不用** `visibility: hidden`(会被打印管线继承,同样打出白纸)。打印样式内置一份精简的文档排版(`@page` 页边距、标题层级、表格边框、图片不溢出),表格在纸上用 `table-layout: auto`(纸上没有「拖列宽」,按内容分配才不会把列压成一条缝)。
## 整理格式
「更多 → 格式 → 整理格式」清理**整篇文档**的结构垃圾 —— 它不同于工具条上的橡皮擦(那个只作用于选区的行内记号):
| 清理项 | 说明 |
| --- | --- |
| 空段落 | 粘贴后常见的 `<p></p>` 连排:视觉上是一块空白,但用户按退格删不掉 |
| 空白文本 | `<p> </p>` 里那三个空格 —— 让「看起来是空的」段落躲过空段落判断 |
| 空行内记号 | `<strong> </strong>` 这类挂了个寂寞的 mark(Word 粘贴重灾区) |
判据走**文档模型**(「这个段落节点的 `content.size` 是否为 0」)而不是对 HTML 跑正则:正则会误伤 `<p><span></span></p>` 这类非空段落,也会漏掉带属性的 `<p style="…"></p>`。只删**真正的空** —— 段落里只要还有一个可见字符就原样保留,只含图片 / 视频 / 表格 / 分割线的段落也不受影响(它们的内容不为空)。
清理在**一个事务**里完成:撤销栈里只留一步,按一次 `Ctrl+Z` 即可整体退回。没有任何可清理项时**不会** dispatch(撤销栈不被无谓地污染),并通过 `cleanup` 事件把统计(删了几个空段落、几个空白文本、几个空记号)报给宿主。
## 源代码
「更多 → 查看与导出 → 源代码」打开一个模态框,直接编辑正文的 HTML,点**保存**写回编辑器(对齐 TinyMCE 的 `code` 插件)。
- **保存是显式的**:只有「保存」才写回,改到一半可以直接「取消」关掉;「重置」把草稿恢复成打开时的内容;
- **写回是一次可撤销的操作**:`Ctrl+Z` 能整体退回改源码之前;
- **会产生 `source-save` 事件**(参数是写回后的 HTML),宿主可以据此做提示或埋点;
- **不认识的标签会被过滤**:写回走的是 Tiptap 的 `setContent`,schema 之外的内容会被清洗掉 —— 界面里对此有明确说明。组件只拦「彻底解析失败」的情况(`DOMParser` 抛错时不允许保存),不规范但能解析的写法交给 schema 处理,不假装能完整往返。
也可以不开工具条菜单、由宿主自己唤起(`ref` 调用):
```vue
<G3RichText ref="editor" v-model="html" />
<script setup>
const editor = ref()
editor.value?.openSourceCode()
</script>
```
<demo vue="rich-text/tools.vue" />
## 只读 / 禁用
`readonly` 可聚焦可复制但不可编辑;`disabled` 整体灰化不可交互。
@@ -65,19 +223,29 @@ type Upload = (file: File) => Promise<{
## 表格
工具条「插入表格」按钮弹出行 / 列选择面板(支持关闭表头行);光标进入表格后,表格下方会浮出就近操作栏:
工具条「插入表格」按钮弹出**网格选择器**(对齐 TinyMCE / Word):移入网格即高亮出 `行 × 列` 的矩形区域,下方实时显示尺寸(如 `3 × 4`),点击即插入;支持**单击**与**拖拽划选**两种用法。网格上限 **8 行 × 10 列**,可选范围由网格本身限定,因此不存在「填了非法值再夹回」的问题。面板里还有「包含表头行」勾选(默认勾选)。
光标进入表格后,表格下方会浮出就近操作栏:
- **行**:上方插入行、下方插入行、删除行;
- **列**:左侧插入列、右侧插入列、删除列;
- **单元格**:合并单元格、拆分单元格(两个独立按钮,不可用时各自置灰);
- **表头**:切换表头列、切换表头单元格;
- **删除**:整表删除(与增删行列用分隔线隔开,防误点)。
单元格内文字的对齐、颜色在顶部工具条里设置(单元格内容也是普通段落),操作栏不再重复提供。
列宽拖拽:悬停列分隔线出现 `col-resize` 光标,左右拖动调整列宽。拖拽采用 `table-layout: fixed`,**只动被拖列、其余列纹丝不动**(Excel 语义);列宽以 `colwidth` 属性持久化进 HTML,最小列宽 10px。拖宽后表格总宽超过容器时,在表格容器内横向滚动,不撑破编辑器内容区。`Tab` / `Shift+Tab` 在单元格间跳转,末格 `Tab` 自动追加行。
列宽拖拽:悬停列分隔线出现 `col-resize` 光标,左右拖动调整列宽。拖拽采用 `table-layout: fixed`,**只动被拖列、其余列纹丝不动**(Excel 语义);列宽以 `colwidth` 属性持久化进 HTML,最小列宽 10px。`Tab` / `Shift+Tab` 在单元格间跳转,末格 `Tab` 自动追加行。
> 行列数限制为 1–20,超出会在插入时自动夹回,避免一次性生成超大表格卡死编辑器。
### 为什么插入多列表格不会出滚动条
插入与拖拽对「总宽超出容器」的处理**刻意相反**:
| 时机 | 行为 | 原因 |
| --- | --- | --- |
| 插入表格 | 列宽**等比压缩**到容器宽以内 | 刚生成的表由浏览器按内容均分,列一多必然超出;用户要的是「铺满」而不是「横滚」 |
| 拖拽列宽结束 | **保留**超宽,超出部分在表格容器内横向滚动 | 用户是故意把列拖宽的,压缩等于立刻撤销他的操作(Excel 语义:表格可变宽) |
压缩时目标宽度取 `容器宽 - 1px` —— 这是 `border-collapse: collapse` 的舍入修正:折叠边框下浏览器会在表格外沿各补半像素,声明宽度 1060px 的表实际渲染为 1061px,而只要 `scrollWidth > clientWidth`(哪怕 1px)就会出现滚动条。
## 获取纯文本
@@ -102,6 +270,8 @@ type Upload = (file: File) => Promise<{
| `uploadVideo` | `(file: File) => Promise<RichTextUploadResult>` | — | 视频上传函数,返回完整元数据 |
| `fonts` | `RichTextFontOption[]` | 内置字体表 | 「更多」菜单里字体子菜单的选项 |
| `fontSizes` | `RichTextFontSizeOption[]` | 内置字号表 | 「更多」菜单里字号子菜单的选项 |
| `bulletListTypes` | `RichTextListTypeOption[]` | 内置 4 档 | 无序列表样式子菜单的选项 |
| `orderedListTypes` | `RichTextListTypeOption[]` | 内置 6 档 | 有序列表样式子菜单的选项 |
### Events
@@ -112,6 +282,9 @@ type Upload = (file: File) => Promise<{
| `blur` | `(event: FocusEvent)` | 失去焦点 |
| `upload-inline` | `(result: RichTextUploadResult, kind: 'image' \| 'video')` | 内联资源上传完成,上报给外层收集 |
| `upload-error` | `(error: Error, kind: 'image' \| 'video')` | 上传失败 |
| `source-save` | `(html: string)` | 「源代码」对话框保存并写回后触发(参数为清洗后的 HTML) |
| `cleanup` | `(result: RichTextCleanupResult)` | 整理格式执行完成(`changed: false` 表示本来就没有可清理的内容) |
| `print` | `()` | 发起打印(工具条菜单或预览弹窗里的「打印」都会触发) |
### Exposed(通过 ref 调用)
@@ -122,6 +295,12 @@ type Upload = (file: File) => Promise<{
| `getText` | `() => string` | 纯文本(邮件 text/plain) |
| `focus` | `() => void` | 聚焦 |
| `clear` | `() => void` | 清空内容 |
| `openSourceCode` | `() => void` | 打开「源代码」对话框 |
| `openPreview` | `() => void` | 打开「预览」对话框 |
| `print` | `() => void` | 打印正文(只打印正文,不带宿主页面) |
| `tidyFormat` | `() => RichTextCleanupResult` | 整理格式:清理空段落 / 空白文本 / 空行内记号 |
> 后四个能力寄居在工具条里(按钮与对话框都在工具条组件内)。因此它们需要工具条被渲染 —— `toolbar: false` 时这些方法安全地什么都不做。
### 类型定义
@@ -144,6 +323,21 @@ export interface RichTextFontSizeOption {
label: string
value: string
}
export interface RichTextListTypeOption {
label: string
/** 写进文档的值:无序对应 <ul type>、有序对应 <ol type>;空串表示「默认」不写属性 */
value: string
/** 菜单里画出来的真实符号(• / ○ / ■ / a. / I. …) */
glyph: string
}
export interface RichTextCleanupResult {
removedEmptyParagraphs: number
trimmedBlankTexts: number
removedEmptyMarks: number
changed: boolean
}
```
### 样式变量
@@ -157,6 +351,25 @@ export interface RichTextFontSizeOption {
| `--g3-color-primary-soft` | 主色 12% | 按钮激活底、选中单元格底 |
| `--g3-rich-text-toolbar-shadow-near` | `rgba(34,47,62,.1)` | 工具条投影近层(`0 2px 2px -2px`,`toolbar="bottom"` 时翻到上方) |
| `--g3-rich-text-toolbar-shadow-far` | `rgba(34,47,62,.07)` | 工具条投影远层(`0 8px 8px -4px` 弥散) |
| `--g3-rich-text-swatch-text-1..10` | 见下 | 字体颜色预设色板(10 个) |
| `--g3-rich-text-swatch-bg-1..10` | 见下 | 背景颜色预设色板(10 个) |
预设色板默认值(文字色取 Ant Design 6 号色、背景色取 1 号色):
| 序号 | 文字色 | 背景色 |
| --- | --- | --- |
| 1 | `#000000` | `#f5f5f5` |
| 2 | `#434343` | `#fff1f0` |
| 3 | `#8c8c8c` | `#fff7e6` |
| 4 | `#bfbfbf` | `#feffe6` |
| 5 | `#f5222d` | `#f6ffed` |
| 6 | `#fa8c16` | `#e6fffb` |
| 7 | `#52c41a` | `#e6f4ff` |
| 8 | `#1677ff` | `#f0f5ff` |
| 9 | `#722ed1` | `#f9f0ff` |
| 10 | `#eb2f96` | `#fff0f6` |
这些变量挂在 `.g3-rich-text__toolbar` 上(不是 `:root`),作用域收紧在组件内;覆盖时在任意祖先上声明即可(就近原则生效)。
## 实现说明
@@ -166,8 +379,21 @@ export interface RichTextFontSizeOption {
- **对齐**:官方 `@tiptap/extension-text-align`,只挂到 `heading` / `paragraph`,`alignments` 限左中右;面板里是 `toggleTextAlign`,点当前档位即取消;
- **内容区即编辑区**:`minHeight` / `maxHeight` 作用在内容区上,可编辑区(`.tiptap`)用 `flex: 1 0 auto` 撑满 —— 内容短时整块都能点(点空白即聚焦、光标落到正文末尾),内容长时按内容高度撑开、由内容区滚动且不裁切。注意 EditorContent 会在内容区与可编辑区之间再插一层包裹 DOM,所以 `.tiptap` 上的 `min-height: inherit` 取的是那一层的值(恒为 0),撑不高可编辑区;
- **聚焦高亮只给正文区**:编辑器获得焦点时(根节点 `:focus-within`),正文区四周亮起 2px 主色内描边,工具条不参与 —— 谁可编辑谁亮。用 `inset box-shadow` 而不是 `border`(不参与布局,聚焦不挤动正文),也不用 TinyMCE 那种 `::after` 覆盖层(正文区就是滚动容器,覆盖层会跟着正文滚走,而滚动容器的阴影不随内容滚动,天然钉在内容盒上);
- **工具条密度与「更多」**:按钮 32px(等于 `--g3-control-height`)+ 组内 4px 间隙,字体 / 字号与低频功能收进 `⋯` 菜单,自然宽度从 928px 降到 744px;`⋯` 用 `G3Dropdown` 现成的 `type: 'label'`(分组标题)/ `children`(子菜单)/ `shortcut` / `selected` 能力拼出来,与工具条共用同一份 `ToolButton` 定义,激活态与禁用态一致。菜单内容约 295px,靠 Dropdown 默认的视口上限(`calc(100vh - 48px)`)保证面板内部不出滚动条,不需要额外传 `menuMaxHeight`;
- **图标分量**:16px 图标 + lucide 原生描边(16px 下约 1.33px),只把前景从 ghost 默认的 `text-2`(65%)提到 `text-1`(88%)。试过描边 2.5 与放大到 24px,都偏重;实测「发虚」的主因是浅灰前景,动颜色这一项性价比最高;
- **工具条密度与「更多」**:按钮 32px(等于 `--g3-control-height`),字体 / 字号与低频功能收进 `⋯` 菜单;`⋯` 用 `G3Dropdown` 现成的 `type: 'label'`(分组标题)/ `children`(子菜单)/ `shortcut` / `selected` 能力拼出来,与工具条共用同一份 `ToolButton` 定义,激活态与禁用态一致。菜单内容约 295px,靠 Dropdown 默认的视口上限(`calc(100vh - 48px)`)保证面板内部不出滚动条,不需要额外传 `menuMaxHeight`;
- **工具条分组用 `display: contents`**:组盒子不参与布局,组内按钮被提升为工具条的直接 flex 项,折行才能**以按钮为单位**(否则整组被推到下一行、右边空一大片)。代价是间距改由按钮的 `margin-inline-end` 承担(组内 2px / 组间 12px)。`flex-wrap: wrap` + `min-width: 0` 给组**做不到**这件事 —— 组对工具条始终是一个不可分割的 flex 项;
- **工具条是可键盘操作的整体**:`role="toolbar"` + roving tabindex,`←` / `→` 漫游、`Home` / `End` 跳首尾。按钮集合用 DOM 查询取得(按钮由多组数据拼出、还有 Dropdown / Popover 的触发器,声明式清单容易漏),DOM 顺序天然等于方向键该走的顺序。禁用态变化后用 `watch(..., { flush: 'post' })` 重新归一化 tabindex —— 否则可能出现「全部 -1」导致 Tab 进不来工具条;
- **图标分量**:18px 图标 + lucide 原生描边(18px 下约 1.5px),只把前景从 ghost 默认的 `text-2`(65%)提到 `text-1`(88%)。试过描边 2.5、放大到 24px 都偏重;实测「发虚」的主因是浅灰前景,动颜色这一项性价比最高;
- **段落格式下拉按真实排版渲染**:`headingLevels` 里带 `fontSize` / `fontWeight`,菜单项直接用它们画出「标题 1 是 2em 粗体」的样子,选择变成「看起来像我要的那级」而不是「记住它是几号」。这三个值必须与 `RichText.vue` 里 `.tiptap h1..h6` 的规则一致,否则预览会撒谎。用 `#menu` 插槽而非 `:items`:`DropdownItem` 的图标位只适合小图标,塞整块文字排版会被行高与省略号规则裁掉;
- **色板可覆盖 / 色值必解析**:预设色板挂 CSS 变量(`--g3-rich-text-swatch-*`)以便宿主换肤,但写进文档的色值在点击时用 `getComputedStyle` 从色块解析成具体 `rgb(...)` —— 正文里存 `var(--g3-…)` 是错的(邮件 / 复制出去的内容没有这个变量)。选中环的判断同样走这套解析(`resolveSwatchColor`),保证「点了哪个色块」与「哪个色块显示为选中」一致;两侧色值的比较先统一归一化成 `rgb()`,否则文档里的 `#f5222d` 与变量渲染出的 `rgb(245,34,45)` 永远对不上;
- **链接面板替代 `window.prompt`**:prompt 观感割裂(全库只有它用原生弹窗)、无法校验(`javascript: URL` 会被直接写进 href)、嵌入式环境常被拦截、E2E 无法对 UI 断言。改为 `G3Popover` + 「链接地址 / 显示文字」两个输入框,光标已在链接上时多出「移除链接」。地址只放行 `http` / `https` / `mailto` / `tel` / `#` / `/` 开头的值。**可提交性只看地址**(「显示文字」不是必填):链接文字按「填的显示文字 → 选中文字 → 地址本身」兜底,所以只填地址也能插入,用地址当文字;
- **改链接文字必须先落文字再选中再加链接**:`insertContent` 会把选区塌缩到插入内容之后,此时直接 `setLink` 挂的是空区间(等于没加),所以要显式 `TextSelection.create` 回选刚落下的文字。而「编辑已有链接」要**分两条路**:文字没变就只 `extendMarkRange` + `setLink` 原地改 href;文字变了要先 `extendMarkRange` 选中整条链接再替换 —— 若照搬「在光标处插入」,会把一个链接**劈成三段**(断点两边各留一段旧 href);
- **链接右键菜单**(`LinkMenu.vue`):在正文的 `<a>` 上右键弹出「编辑链接 / 打开链接 / 删除链接」,带图标。没有复用 `G3Popover` 的 `trigger="contextmenu"` —— 那个模式要求浮层**包住**触发器元素,而 `<a>` 由 ProseMirror 渲染、随时重建,塞不进任何插槽;改用与 `TableMenu` 相同的手法(监听编辑器 DOM 的右键 + `position: fixed` 到鼠标位置 + `popup` 档 z-index)。删除只去链接、保留文字,且操作前把选区移到落点那个 `<a>` 上 —— 右键不一定移动光标,否则会删错链接;
- **视频自写 node**:Tiptap 官方无视频扩展,`VideoNode.ts` 定义了轻量 `video` block node,输出 `<video controls>` 标签;邮件场景由业务层决定是否降级为「封面图 + 附件」(编辑器不绑定邮件语义);
- **内联资源上报**:图片 / 视频上传后 `emit('upload-inline', result, kind)`,外层把 `result` 收集进 `inlines` 数组,与邮件「inline 附件」模型对齐(参考 Email-Vue 的 `@upload-image`);
- **依赖策略**:Tiptap 核心走 `peerDependencies`,扩展走 `dependencies`,不把编辑器全家桶强塞给所有 `@g3soft/ui` 使用者。
- **列表样式走节点属性**:无序写 `<ul type="disc|circle|square">`、有序写 `<ol type="a|i|I|A">`,不写类名 —— 属性自描述,导出的 HTML 离开组件样式表也正确。样式面板挂在两个列表按钮自己的下拉上(**分裂按钮**:点图标 `toggleList` 建/撤列表,点右侧箭头打开 `ListTypeIcon` 画出的 TinyMCE 同款缩略图面板)。点某一档时若光标不在列表里会**先建列表再设样式**。 ⚠️ 这两步**必须分两个事务执行、不能串在同一条 chain 里**:`updateAttributes` 是按 `state.doc`(本次事务开始前的文档)定位目标节点的,串在一起时它扫的还是「还没有列表」的旧文档,会静默什么都不做(表现为「选了方块列表,结果只是建了个默认圆点列表」)。代价是撤销栈里两步,但这正是「建列表」与「调样式」两件事的诚实反映。`updateAttributes` 清样式必须显式传 `null`(Tiptap 的 `type` 默认值是 `null`,传 `undefined` 会被跳过、旧样式留着);
- **小写希腊字母要自写扩展**:`<ol type="lower-greek">` 是非法 HTML 值(`<ol type>` 只认 `1 / a / A / i / I`),浏览器会静默退回数字。`OrderedListWithGreek`(`extensions.ts`)在官方 OrderedList 上补了两处:`renderHTML` 对 CSS-only 档位改写 `style="list-style-type: …"` 而不写非法 `type`,`addAttributes().type.parseHTML` 在官方逻辑(不认 `lower-greek`)返回空时补认它。注册时**必须在 StarterKit 里关掉内置的 `orderedList`**,否则同名扩展重复注册会直接报错;
- **整理格式走文档模型而不是正则**:判据是「块节点的内容里有没有非空文本 / 非叶子节点」(`content.size === 0` 不足以覆盖 `<p><strong> </strong></p>`)。正则会误伤 `<p><span></span></p>`、也会漏掉带属性的空段落。多个待删区间先**归一化合并**再**从后往前**删 —— 嵌套区间(空段落 `[5,8]` 与段内空白文本 `[6,7]`)若只维护一个游标会把外层段落判为「已覆盖」而跳过(这正是初版写的反了的地方);取消空 mark 的区间要用 `tr.mapping` 映射到删除后的坐标,否则会取消到错误位置。整篇清理合成**一个事务**,撤销栈里只留一步;
- **预览用 `v-html` 而不是第二个编辑器实例**:后者会把编辑态装饰(占位符、选中节点描边、列宽手柄)一起带进来。预览区复刻一份与 `.tiptap` 一致的排版规则,保证「编辑时 / 预览 / 打印」三者观感一致。打开时取**内容快照**,使「从预览里打印」与「屏幕上看到的」是同一份内容;
- **打印用隐藏 iframe**:直接 `window.print()` 会把宿主的导航与按钮一起打出来。iframe 用「零尺寸 + 移出视口」隐藏 —— **不能**用 `display: none`(部分浏览器跳过渲染、打印空白),也**不能**用 `visibility: hidden`(会被打印管线继承,同样打出白纸)。`srcdoc` 必须等 `load` 事件后再 `print()`,否则文档还没解析完、打出空白页;清理挂在 `afterprint` 上并用 `setTimeout` 兜底(WebKit 的 `print()` 不阻塞);
- **源代码对话框**:只有「保存」写回(改到一半可直接取消),写回走 `setContent` 并保持一次可撤销;校验只用 `DOMParser` 拦「彻底解析失败」,不规范但能解析的写法交给 Tiptap 的 schema 清洗,界面里如实告知「不认识的标签会被过滤」,不假装能完整往返;
- **依赖策略**:Tiptap 扩展(`@tiptap/extension-*` / `starter-kit`)走 `dependencies`,随 `@g3soft/ui` 自动装上。`@tiptap/extension-list` 是**显式声明**的依赖(`OrderedListWithGreek` 直接 import 它)—— 它虽然也是 `starter-kit` 的传递依赖,但 pnpm 的严格隔离下传递依赖不可直接 import,不声明会在构建时解析失败;`@tiptap/core` / `@tiptap/pm` / `@tiptap/vue-3` 目前在 `devDependencies`,但构建产物会 `import '@tiptap/vue-3'` —— **发布前必须把它们移到 `peerDependencies`(或 `dependencies`)**,否则使用方装包后会报模块缺失。
@@ -52,6 +52,23 @@ description: 点击 / 悬停 / 右键触发的操作列表,支持子菜单与
<demo vue="dropdown/header-slot.vue" />
## 分裂按钮
分裂按钮(对齐 Ant Design 的 `Dropdown.Button` / TinyMCE 的分裂下拉按钮)由 **`G3ButtonGroup` 组合**而成,`Dropdown` 本身不提供 `split` prop:图标按钮是独立的主操作(点它执行动作、不开菜单),箭头按钮是 `Dropdown` 自己的触发器(点它才展开菜单)。边框贴合、首尾圆角、hover 抬层全由 `G3ButtonGroup` 承担,无需自绘分隔线。
```vue
<G3ButtonGroup>
<G3Button @click="onToggleList">无序列表</G3Button>
<G3Dropdown>
<template #trigger>
<G3Button aria-label="无序列表样式"><ChevronDown /></G3Button>
</template>
<G3DropdownItem item-key="square">实心方块</G3DropdownItem>
<G3DropdownItem item-key="circle">空心圆圈</G3DropdownItem>
</G3Dropdown>
</G3ButtonGroup>
```
## 键盘操作
菜单打开后焦点会进入菜单容器,因此可以直接用键盘:
@@ -1,80 +0,0 @@
<script setup lang="ts">
// 最小用法:只喂 files + categories,上传 / 删除交给业务(组件不发请求)
import { ref } from 'vue'
import { G3Attachment } from '@g3soft/ui'
import type { AttachmentCategory, AttachmentItem, AttachmentUploadContext } from '@g3soft/ui'
// 分类(可选):不给 categories 就只渲染「列表 + 预览」两栏
const categories: AttachmentCategory[] = [
{ id: 'contract', name: '合同文件' },
{ id: 'invoice', name: '发票凭证' },
{ id: 'photo', name: '现场照片' },
]
// 内联 SVG,保证文档站离线也能看到图片预览
const PHOTO = `data:image/svg+xml;utf8,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200"><rect width="320" height="200" fill="#e5e7eb"/><text x="160" y="108" font-size="18" text-anchor="middle" fill="#6b7280">现场照片</text></svg>',
)}`
const files = ref<AttachmentItem[]>([
{ id: 1, name: '2026年度框架合同.pdf', size: 512_000, categoryId: 'contract' },
{ id: 2, name: '供应商报价明细.xlsx', size: 86_000, categoryId: 'invoice' },
{ id: 3, name: '现场照片.jpg', size: 1_240_000, categoryId: 'photo', url: PHOTO },
{ id: 4, name: '验收纪要.docx', size: 24_000, categoryId: 'contract' },
])
const lastEvent = ref('等待操作…')
// 没传 upload-request 时,组件把待上传文件抛出来,落库由业务决定
function onUpload(incoming: File[], context: AttachmentUploadContext) {
files.value = [
...files.value,
...incoming.map((file, index) => ({
id: `local-${Date.now()}-${index}`,
name: file.name,
size: file.size,
categoryId: context.categoryId,
// 真实项目换成接口返回的地址;示例用 blob 地址即时预览
url: URL.createObjectURL(file),
})),
]
lastEvent.value = `upload:${incoming.map((file) => file.name).join('、')}`
}
function onRemove(items: AttachmentItem[]) {
const removed = new Set(items.map((item) => item.id))
files.value = files.value.filter((file) => !removed.has(file.id))
lastEvent.value = `remove:${items.map((item) => item.name).join('、')}`
}
function onDownload(items: AttachmentItem[]) {
lastEvent.value = `download:${items.map((item) => item.name).join('、')}`
}
function onInvalid(info: { message: string }) {
lastEvent.value = `invalid:${info.message}`
}
</script>
<template>
<div>
<G3Attachment
v-model:files="files"
:categories="categories"
:height="560"
@upload="onUpload"
@remove="onRemove"
@download="onDownload"
@invalid="onInvalid"
/>
<p class="hint">{{ lastEvent }}</p>
</div>
</template>
<style scoped>
.hint {
margin: 8px 0 0;
color: #8c8c8c;
font-size: 12px;
}
</style>
@@ -1,58 +0,0 @@
<script setup lang="ts">
// 弹窗用法:业务自己用 G3Modal 包一层,附件组件并不知道「弹窗」的存在
import { ref } from 'vue'
import { G3Attachment, G3Button, G3Modal } from '@g3soft/ui'
import type { AttachmentItem, AttachmentUploadContext } from '@g3soft/ui'
const open = ref(false)
// 从空列表开始:正好看到空态投放区 → 入队(底栏变"脏")→ 上传的完整过程
const files = ref<AttachmentItem[]>([])
const lastEvent = ref('')
function onUpload(incoming: File[], context: AttachmentUploadContext) {
files.value = [
...files.value,
...incoming.map((file, index) => ({
id: `local-${Date.now()}-${index}`,
name: file.name,
size: file.size,
categoryId: context.categoryId,
url: URL.createObjectURL(file),
})),
]
lastEvent.value = `已加入 ${incoming.length} 个文件`
}
function onRemove(items: AttachmentItem[]) {
const removed = new Set(items.map((item) => item.id))
files.value = files.value.filter((file) => !removed.has(file.id))
lastEvent.value = `已删除 ${items.length} 个文件`
}
</script>
<template>
<div>
<G3Button @click="open = true">打开附件管理</G3Button>
<p class="hint">{{ lastEvent }}</p>
<!-- 弹窗高度用 vh,附件组件再按 100% 撑满;不给 categories 就只有列表一栏 -->
<G3Modal v-model:open="open" title="附件管理" width="80%" :fullscreen="false">
<G3Attachment
v-model:files="files"
height="60vh"
@upload="onUpload"
@remove="onRemove"
/>
</G3Modal>
</div>
</template>
<style scoped>
.hint {
margin: 8px 0 0;
color: #8c8c8c;
font-size: 12px;
}
</style>
@@ -1,20 +0,0 @@
<script setup lang="ts">
// 只读:隐藏上传 / 删除入口,只保留浏览与下载
import { G3Attachment } from '@g3soft/ui'
import type { AttachmentItem } from '@g3soft/ui'
const PHOTO = `data:image/svg+xml;utf8,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200"><rect width="320" height="200" fill="#dbeafe"/><text x="160" y="108" font-size="18" text-anchor="middle" fill="#1d4ed8">现场照片</text></svg>',
)}`
const files: AttachmentItem[] = [
{ id: 1, name: '立项批复.pdf', size: 320_000 },
{ id: 2, name: '现场照片.jpg', size: 1_024_000, url: PHOTO },
{ id: 3, name: '预算表.xlsx', size: 64_000 },
]
</script>
<template>
<!-- readonly 同时关掉「全选 / 批量删除 / 底栏上传」,拖拽与粘贴也会失效 -->
<G3Attachment :files="files" readonly :height="420" />
</template>
@@ -1,61 +0,0 @@
<script setup lang="ts">
// 多选与批量操作:勾选后工具栏浮出「下载 / 删除」,行为全部走事件
import { ref } from 'vue'
import { G3Attachment } from '@g3soft/ui'
import type { AttachmentItem } from '@g3soft/ui'
const files = ref<AttachmentItem[]>([
{ id: 'a1', name: '设备清单.xlsx', size: 128_000 },
{ id: 'a2', name: '施工方案.pdf', size: 2_048_000 },
{ id: 'a3', name: '进度汇报.pptx', size: 640_000 },
{ id: 'a4', name: '材料验收单.docx', size: 32_000 },
{ id: 'a5', name: '现场记录.zip', size: 8_192_000 },
])
const selected = ref<AttachmentItem[]>([])
const lastEvent = ref('')
function onSelect(items: AttachmentItem[]) {
selected.value = items
}
function onRemove(items: AttachmentItem[]) {
const removed = new Set(items.map((item) => item.id))
files.value = files.value.filter((file) => !removed.has(file.id))
lastEvent.value = `remove:${items.map((item) => item.name).join('、')}`
}
function onDownload(items: AttachmentItem[]) {
// 真实项目里在这里调接口拿签名地址再下载
lastEvent.value = `download:${items.map((item) => item.name).join('、')}`
}
</script>
<template>
<div>
<!-- 每页 3 条演示分页;builtin-download 关掉后「下载」只上报事件 -->
<G3Attachment
v-model:files="files"
:height="420"
:builtin-download="false"
:show-size-changer="true"
:page-size="3"
:page-size-options="[3, 5]"
@select="onSelect"
@remove="onRemove"
@download="onDownload"
/>
<p class="hint">
已选 {{ selected.length }} 个<span v-if="selected.length">:{{ selected.map((item) => item.name).join('、') }}</span>
<template v-if="lastEvent"> | {{ lastEvent }}</template>
</p>
</div>
</template>
<style scoped>
.hint {
margin: 8px 0 0;
color: #8c8c8c;
font-size: 12px;
}
</style>
@@ -0,0 +1,39 @@
<script setup lang="ts">
// 列表样式 / 预览 / 打印 / 整理格式 / 源代码
import { ref } from 'vue'
import { G3Button, G3RichText, G3Space } from '@g3soft/ui'
import type { RichTextCleanupResult, RichTextExpose } from '@g3soft/ui'
// 内含一段带空段落的正文,便于演示「整理格式」
const html = ref(
'<h2>季度总结</h2><p>本季度完成如下事项:</p><ul type="square"><li><p>上线 A 模块</p></li><li><p>修复 B 缺陷</p></li></ul><ol type="I"><li><p>需求评审</p></li><li><p>技术方案</p></li></ol><p></p><p></p><p>备注:以上内容见附件。</p>',
)
const editorRef = ref<RichTextExpose | null>(null)
const cleanupText = ref('')
/** 整理格式:清理空段落 / 空白文本 / 空行内记号,并把统计显示出来 */
function tidy() {
const result: RichTextCleanupResult | undefined = editorRef.value?.tidyFormat()
if (!result) return
cleanupText.value = result.changed
? `清理了 ${result.removedEmptyParagraphs} 个空段落、${result.trimmedBlankTexts} 处空白文本、${result.removedEmptyMarks} 个空格式`
: '没有需要整理的内容'
}
</script>
<template>
<G3Space vertical :size="12" style="display: flex">
<G3Space :size="8">
<G3Button size="small" type="outline" @click="tidy">整理格式(ref)</G3Button>
<G3Button size="small" type="outline" @click="editorRef?.openPreview()">预览</G3Button>
<G3Button size="small" type="outline" @click="editorRef?.print()">打印</G3Button>
<G3Button size="small" type="outline" @click="editorRef?.openSourceCode()">源代码</G3Button>
</G3Space>
<!-- 列表样式在「更多 → 无序列表样式 / 有序列表样式」里选 -->
<G3RichText ref="editorRef" v-model="html" :min-height="220" />
<p v-if="cleanupText" style="font-size: 13px; color: #666">{{ cleanupText }}</p>
</G3Space>
</template>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Pencil, Trash2 } from '@lucide/vue'
import { G3Message, G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 操作列:列上加 actions 即可,渲染成一排小按钮。
// - type: 'more' 的项会收进「更多」下拉(这里没配,全部内联显示)
// - confirm 给字符串或对象时先弹确认框,确认后才执行 onClick
// - 操作列建议显式写 resizable: false(宽度由内容决定,拖了不落库)
const columns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'name', title: '名称', width: 180 },
{ dataIndex: 'amount', title: '金额', width: 120, align: 'right' },
{
dataIndex: 'op',
title: '操作',
width: 160,
resizable: false,
actions: [
{
label: '编辑',
icon: Pencil,
onClick: (row) => G3Message.info(`编辑:${row.name}`),
},
{
label: '删除',
icon: Trash2,
type: 'danger',
confirm: { title: '确认删除', description: '删除后不可恢复,确定继续吗?' },
onClick: (row) => G3Message.success(`已删除:${row.name}`),
},
],
},
]
const data = ref<TableRow[]>([
{ id: 1, name: '框架合同', amount: 120000 },
{ id: 2, name: '采购订单', amount: 86000 },
{ id: 3, name: '验收单', amount: 43000 },
])
</script>
<template>
<div style="height: 260px">
<G3Table :columns="columns" :data-source="data" />
</div>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 列定义:dataIndex 指向行数据的字段
const columns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'name', title: '名称', width: 180 },
{ dataIndex: 'amount', title: '金额', width: 120, align: 'right' },
{ dataIndex: 'owner', title: '负责人' },
]
// seq 默认开启(最前面的序号列),bordered 默认 true
const data = ref<TableRow[]>([
{ id: 1, name: '框架合同', amount: 120000, owner: '张三' },
{ id: 2, name: '采购订单', amount: 86000, owner: '李四' },
{ id: 3, name: '验收单', amount: 43000, owner: '王五' },
])
</script>
<template>
<div style="height: 260px">
<G3Table :columns="columns" :data-source="data" />
</div>
</template>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 行内编辑:列上加 editor 即可。
// 单击单元格进入编辑;Enter 确认、Esc 取消。值变化通过 change 事件抛出。
//
// 编辑器类型:text / number / textarea / select / checkbox
// checkbox 约定 1 = 选中、0 = 未选中(与后端 tinyint 对齐)。
const columns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'name', title: '名称', width: 200, editor: { type: 'text' } },
{
dataIndex: 'status',
title: '状态',
width: 140,
editor: {
type: 'select',
options: [
{ label: '启用', value: 1 },
{ label: '停用', value: 0 },
],
},
},
{ dataIndex: 'enabled', title: '启用', width: 80, editor: { type: 'checkbox' } },
{ dataIndex: 'qty', title: '数量', width: 120, align: 'right', editor: { type: 'number' } },
]
const data = ref<TableRow[]>([
{ id: 1, name: '框架合同', status: 1, enabled: 1, qty: 12 },
{ id: 2, name: '采购订单', status: 0, enabled: 0, qty: 45 },
])
const lastChange = ref('')
function onChange(row: TableRow, col: G3TableColumn<TableRow>, value: unknown) {
lastChange.value = `${row.name} 的「${col.title}」改为 ${value}`
}
</script>
<template>
<div>
<p style="margin: 0 0 8px; color: #666; font-size: 13px">
最近一次修改:{{ lastChange || '(点击单元格试试)' }}
</p>
<div style="height: 240px">
<G3Table :columns="columns" :data-source="data" :seq="false" @change="onChange" />
</div>
</div>
</template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 列宽 / 列序拖拽与「列定义回写」:
// 两者都要求用 v-model:columns 绑定,否则组件拖完无处回写(松手回弹),
// 组件检测到没绑定会直接关掉这两个功能。
const columns = ref<G3TableColumn<TableRow>[]>([
{ dataIndex: 'name', title: '名称(可拖宽)', width: 200 },
{ dataIndex: 'amount', title: '金额', width: 140, align: 'right' },
{ dataIndex: 'owner', title: '负责人', width: 140 },
// 操作列声明 resizable: false 后不可拖宽
{ dataIndex: 'op', title: '操作', width: 100, resizable: false },
])
// 行拖拽排序:开启后显示拖拽手柄,拖完通过 row-order-change 回传新顺序
const dragColumns = ref<G3TableColumn<TableRow>[]>([
{ dataIndex: 'name', title: '名称' },
{ dataIndex: 'amount', title: '金额', align: 'right' },
])
const data = ref<TableRow[]>([
{ id: 1, name: '框架合同', amount: 120000, owner: '张三', op: '—' },
{ id: 2, name: '采购订单', amount: 86000, owner: '李四', op: '—' },
{ id: 3, name: '验收单', amount: 43000, owner: '王五', op: '—' },
])
const dragData = ref<TableRow[]>([
{ id: 1, name: '第一步', amount: 100 },
{ id: 2, name: '第二步', amount: 200 },
{ id: 3, name: '第三步', amount: 300 },
])
const orderText = ref('')
function onRowOrderChange(rows: TableRow[]) {
dragData.value = rows
orderText.value = rows.map((row) => row.name).join(' → ')
}
</script>
<template>
<div>
<p style="margin: 0 0 8px; color: #666; font-size: 13px">
拖动表头分隔线改列宽、拖表头文字改列序。
</p>
<div style="height: 240px">
<G3Table v-model:columns="columns" :data-source="data" :row-selection="null" />
</div>
<p style="margin: 16px 0 8px; color: #666; font-size: 13px">
行拖拽排序:{{ orderText || '(拖动左侧手柄试试)' }}
</p>
<div style="height: 200px">
<G3Table
v-model:columns="dragColumns"
:data-source="dragData"
:seq="false"
row-draggable
@row-order-change="onRowOrderChange"
/>
</div>
</div>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 多选:传 rowSelection 即在最前注入选择列。
// getCheckboxProps 可禁用个别行(禁用行不参与「全选」的分母)。
const columns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'name', title: '名称', width: 180 },
{ dataIndex: 'amount', title: '金额', width: 120, align: 'right' },
]
const data: TableRow[] = [
{ id: 1, name: '框架合同', amount: 120000 },
{ id: 2, name: '采购订单', amount: 86000, locked: true },
{ id: 3, name: '验收单', amount: 43000 },
]
const selectedRowKeys = ref<Array<string | number>>([])
const selectedText = ref('')
function onSelectionChange(keys: Array<string | number>, rows: TableRow[]) {
selectedRowKeys.value = keys
selectedText.value = rows.map((row) => row.name).join('、')
}
</script>
<template>
<div>
<p style="margin: 0 0 8px; color: #666; font-size: 13px">
已选:{{ selectedText || '(无)' }}
</p>
<div style="height: 260px">
<G3Table
:columns="columns"
:data-source="data"
:row-selection="{
selectedRowKeys,
onChange: onSelectionChange,
getCheckboxProps: (row) => ({ disabled: Boolean(row.locked) }),
}"
/>
</div>
</div>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 排序:列上加 sorter: true 即可点表头排序(组件内排,不请求接口)。
// sortType 决定按数字还是字符串比较,默认按字符串。
const columns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'name', title: '名称', width: 180 },
{ dataIndex: 'amount', title: '金额', width: 140, align: 'right', sorter: true, sortType: 'number' },
{ dataIndex: 'qty', title: '数量', width: 120, align: 'right', sorter: true, sortType: 'number' },
]
const data = ref<TableRow[]>([
{ id: 1, name: '框架合同', amount: 120000, qty: 12 },
{ id: 2, name: '采购订单', amount: 86000, qty: 45 },
{ id: 3, name: '验收单', amount: 43000, qty: 7 },
])
</script>
<template>
<div style="height: 260px">
<G3Table :columns="columns" :data-source="data" />
</div>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 性能门控:行数超过 100 才开启纵向虚拟滚动。
// 组件内部按 dataSource.length 自动判断,业务不需要手动切换 virtual。
const rowCount = ref(5000)
const virtualColumns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'id', title: 'ID', width: 90 },
{ dataIndex: 'name', title: '名称', width: 200 },
{ dataIndex: 'value', title: '数值', width: 140, align: 'right' },
]
const virtualData = computed(() =>
Array.from({ length: rowCount.value }, (_, i) => ({
id: i + 1,
name: `记录 ${i + 1}`,
value: (i + 1) * 7,
})),
)
</script>
<template>
<div>
<p style="margin: 0 0 8px; color: #666; font-size: 13px">
当前 {{ rowCount }} 行 → {{ rowCount > 100 ? '已开启' : '未开启(行数不足 100 时全量渲染)' }}纵向虚拟滚动
</p>
<div style="height: 320px">
<G3Table :columns="virtualColumns" :data-source="virtualData" :seq="false" />
</div>
</div>
</template>
+285
View File
@@ -0,0 +1,285 @@
# G3Table 设计方案(性能优先)
> 参考实现:`D:\workspace\code\fms\fms-vue\src\components\fms-table`(`FmsTable.vue` 954 行 + 4 个 utils)
> 底座:`stk-table-vue@1.1.0`(MIT,零运行时依赖、零 peer 依赖,纯 ESM)
> 状态:**待评审**,确认后再开工
---
## 1. 先讲清楚参考实现到底是什么
这一点决定后面所有取舍,所以放在最前面。
**`FmsTable.vue` 不是一个表格实现,它是 `stk-table-vue` 的一层适配器(约 950 行)。**
虚拟滚动 / 列宽拖拽 / 列序拖拽 / 区域选择 / 行拖拽排序 / 二级表头 / 固定列 / 合并单元格
**全部是库的能力**。该文件真正的增量只有四类:
| 类别 | 内容 | 是否值得搬 |
|---|---|---|
| **A. 性能门控启发式** | `MIN_VIRTUAL_ROW_COUNT = 100`(行数少时**关**虚拟滚动)、`MIN_VIRTUAL_X_COLUMN_COUNT = 20`、`editingActive` 抑制编辑态下的逐格 `customCell` 注入 | ✅ 这是真正的核心价值,必须搬 |
| **B. 防抖 / 节流** | resize 150ms trailing 防抖、键盘编辑激活 100ms 合并、rAF 合并同一帧多次通知 | ✅ 直接搬 |
| **C. 针对 v1.1.0 的 workaround** | `effectiveVirtualX`(库在 virtualX 下会把列宽钉死,破坏原生自适应)、`--bg-border-right` 局部清除、`border-left: none`、`td:has(...)` 补高等 | ⚠️ **不抄注释,按我们自己的验证结论重写** |
| **D. 业务耦合** | `useAppStore` 取暗色模式、`@/components/ui/*` 直接 import 内部组件 | ❌ 必须替换成 `@g3soft/*` 对应物 |
**C 类是最大的风险。** 该文件里约 15 条注释在描述「与库内部行为作斗争」,它们修的是
**v1.1.0 特定内部实现**的 bug。整段照搬等于把一堆针对某个版本的补丁搬进我们的库,
之后库一升级就要重新验证每一条。**正确做法**:只搬 A、B 两类,C 类在实现时逐条实测
(用 E2E 锁住几何),确认仍然必要才保留,并在注释里写「为什么」而不是「库做了什么」。
---
## 2. 底座集成(已实测确认的四个硬约束)
这些都是我实际读 `node_modules` 确认的,不是假设:
### 2.1 纯 ESM,可直接用
```
lib/stk-table-vue.js → 真实 import/export,无 require
```
与 `@g3soft/ui` 的 `"type": "module"` + `preserveModules` 兼容。
`vue` 是裸导入(由宿主解析),**不会被我们打包进去**。
### 2.2 样式是**独立文件**,必须单独引入
实测:`lib/stk-table-vue.js` 与 `StkTable-6OMgH-gT.js` 里**没有任何 `.css` import**。
fms 在 `src/main.js` 手动 `import 'stk-table-vue/lib/style.css'`。
**对我们的影响(重要)**:`@g3soft/ui` 的产物是 `dist/style.css`(`sideEffects: ["**/*.css"]`)。
组件库不能要求消费方额外引一次第三方样式。方案:
- 在 `src/table/style/` 下建一个入口,`@import` 库样式 **+** 我们的覆盖层,由 vite 打进 `dist/style.css`;
- 覆盖层用 `.g3-table__inner.stk-table` 双类选择器(权重 (0,2,1) > 库默认 (0,1,1)),
与我们现有「不用 scoped、BEM + `g3-` 前缀」的约定一致。
> 待确认:库样式里是否有 `:root` 级变量可能与 `--g3-*` 冲突。开工第一步先做这件事。
### 2.3 区域选择是**按需注册**的全局副作用
```js
// fms/src/main.js
import { registerFeature, useAreaSelection } from 'stk-table-vue'
registerFeature(useAreaSelection) // 一次性,mount 之前
```
行内编辑依赖 `area-selection-change` 事件,**不注册就没有该事件**。
**对我们的影响**:组件库不拥有 app 启动流程,不能在 bootstrap 里替用户调用。
方案:在 `G3Table` 内部按需自注册(`registerFeature` 幂等,模块级一次性 flag 包住),
并额外暴露 `G3Table.registerFeatures()` 供显式控制。避免「组件静默不工作」这类最难查的
问题——这正好是 `docs-examples.test.ts` 那类隐形失败的同源风险。
### 2.4 完整的 TS 类型可用
`lib/src/StkTable/types/index.d.ts` 导出 `StkTableColumn<T>` / `SortConfig` /
`AreaSelectionRange` / `CustomCellProps<T>` 等。我们的 `G3TableColumn<T>` 应当
**`Omit` + 扩展**它,而不是从零重写一遍(重写会随库版本漂移)。
---
## 3. 组件 API
### 3.1 目录结构
```
packages/ui/src/table/
G3Table.vue # 主组件(对齐 FmsTable.vue 的职责划分)
types.ts # G3TableColumn / G3TableProps / emits / expose
index.ts
style/
index.scss # @import 库样式 + 我们的覆盖层
_overrides.scss
utils/
column.ts # 内部列注入 / 剥离(序号列、选择列、拖拽列)
selection.ts # createSelectionColumn:原生 checkbox,零组件开销
actions.ts # createActionsCell:操作列
editable.ts # createEditableCell:行内编辑
focus.ts # 编辑器焦点恢复 / 草稿保持
G3Table.test.ts # 组件契约
utils/*.test.ts # 纯函数
```
### 3.2 Props(对齐 fms,命名转 kebab-case,全部走 `withDefaults`)
| prop | 类型 | 默认 | 说明 |
|---|---|---|---|
| `columns` | `G3TableColumn<T>[]` | `[]` | 列定义,配合 `v-model:columns`(列宽/列序拖拽回写) |
| `dataSource` | `T[]` | `[]` | 数据 |
| `rowKey` | `string \| (row) => key` | `'id'` | 行唯一键 |
| `virtual` | `boolean` | `true` | 纵向虚拟滚动(**受 §4.1 门控**) |
| `virtualX` | `boolean` | `true` | 横向虚拟滚动(**受 §4.2 门控**) |
| `rowHeight` / `headerRowHeight` | `number` | `30` / `34` | 唯一真源,同时驱动虚拟计算与 CSS 变量 |
| `bordered` | `boolean \| string` | `true` | 自绘外框 + 行分隔线 |
| `colResizable` | `boolean \| object` | `true` | 列宽拖拽(未绑定 `v-model:columns` 时自动关闭) |
| `columnDraggable` | `boolean \| object` | `true` | 列序拖拽 |
| `rowDraggable` | `boolean` | `false` | 行拖拽排序 |
| `rowSelection` | `object \| null` | `null` | `{ selectedRowKeys, onChange, getCheckboxProps }` |
| `seq` | `boolean \| object` | `true` | 序号列 |
| `activeRowKey` | `string \| number \| null` | `null` | 当前高亮行 |
| `editable` | `boolean` | `true` | 行内编辑总开关 |
| `noDataFull` / `showOverflow` | `boolean` | `true` | 空态铺满 / 溢出省略 |
| `autoFitWidth` | `boolean` | `false` | 非横向虚拟时铺满容器 |
| `theme` | `'light' \| 'dark'` | `undefined` | 不传则跟随全局主题(**按 §4.5 从 ConfigProvider 取,不依赖 pinia**) |
| `footerData` / `footerConfig` | `array` / `object` | `[]` / `` | 表尾合计 |
### 3.3 Events / Expose
```ts
// events
'update:columns' | 'change' (row, col, newValue) | 'row-order-change'
'update:selectedRowKeys' | 'area-selection-change'
// expose
scrollToRow(rowKey) | editRow(rowKey, focusCol?) | initVirtualScroll() | refreshLayout()
getSelectedArea() | clearSelectedArea() | setRowExpand(...)
```
### 3.4 列定义 `G3TableColumn<T>`
```ts
export type G3TableColumn<T> = Omit<StkTableColumn<T>, 'customCell'> & {
dataIndex: keyof T & string
/** 表头/单元格对齐已由 §3.2 的 headerAlign 推断补齐 */
/** 行内编辑(首版按「全量对齐」实现) */
editor?: {
type: 'text' | 'number' | 'select' | 'checkbox' | 'textarea'
options?: SelectOption[] | ((row: T) => SelectOption[]) // 选项可依赖同行其它列
disabled?: boolean | ((row: T) => boolean)
allowClear?: boolean
emptyValue?: unknown
onChange?: (value, row, col) => void
}
/** 操作列 */
actions?: G3TableAction<T>[]
/** 覆盖内置 customCell 渲染(逃生舱) */
customCell?: (props: CustomCellProps<T>) => VNode
resizable?: boolean // false = 该列禁止拖宽(操作列/展开列)
}
```
---
## 4. 性能策略(本次的核心)
按「收益 ÷ 维护成本」排序,全部来自 A/B 两类:
### 4.1 虚拟滚动门控:行数 ≤ 100 时**关闭**虚拟滚动
```ts
const MIN_VIRTUAL_ROW_COUNT = 100
const effectiveVirtual = computed(
() => props.virtual && props.dataSource.length > MIN_VIRTUAL_ROW_COUNT,
)
```
**原因(照搬并保留注释)**:库的虚拟滚动在「行数 > 单屏行数」时开启,会导致
「容器一屏放 18 行、数据 20 行」这种情况下最后几行被虚拟化吞掉、必须滚动才可见——
一个纯粹的正确性 bug。行数少时全量渲染没有任何性能问题。
### 4.2 横向虚拟门控:列数 ≤ 20 时关闭
**原因**:库在 `virtualX = true` 时会把每列宽度**钉死**为 `minWidth ?? width ?? 100`
(`cellStyleMap` 的 `--cw` + `width/min-width/max-width` 三重锁),未设 `width` 的列
会失去「按比例铺满容器」的原生自适应。列数少、总宽不超容器时用不上横向虚拟。
### 4.3 `editingActive`:无编辑行时**不注入** `customCell`
```ts
// 有行在编辑时列才带 customCell;无编辑行时全部走库原生 td
const displayColumns = computed(() => {
const cols = props.columns.map((c) => resolveCustomCell(c, editingCtx, editingActive.value))
// ...
})
```
**这是整个方案里单点收益最大的优化**:纯展示 / 非编辑态的可编辑列不再包裹函数式组件,
滚动时每格省掉一个组件实例。行与行之间切换编辑**不翻转** `editingActive`,避免整表列重建。
### 4.4 尺寸与键盘防抖
- **resize 150ms trailing 防抖**:折叠动画 / 窗口缩放期间 ResizeObserver 每帧通知,
但中间尺寸是瞬态的;逐帧 `initVirtualScroll()` 会每帧强制 reflow 并重渲染可见行列。
合并到尺寸稳定后刷新一次。并区分「仅宽度变化」→ 只重算 X 方向。
- **rAF 合并**同一帧内的多次通知。
- **键盘编辑 100ms 合并**:按住方向键滚动时全程保持 0 编辑器,松手后再激活;
快速离散连按(<150ms)也走防抖,避免每键挂载/卸载整行编辑器。
### 4.5 主题:从 ConfigProvider 取,不依赖 pinia
参考实现用 `useAppStore().darkMode`——那是业务 store。我们改走
`ConfigProvider` / `documentElement.dataset`(与现有组件一致),
并保留 `theme` prop 显式覆盖。
### 4.6 零组件开销的选择列
行 checkbox 用**原生 `<input type="checkbox">` + 自绘方框**,不用 `G3Checkbox` 组件。
每行一个组件实例在滚动时的开销不可接受。样式模拟成 `G3Checkbox` 外观(CSS 层,非组件)。
---
## 5. 与现有约定的对齐(必改项)
| 参考实现 | 我们必须改成 |
|---|---|
| `<style scoped>` + 非 scoped 全局覆盖 | **禁止 scoped**(CODEBUDDY.md 明确),全走 `g3-` 前缀 + BEM |
| `@/components/ui/button` 等直接 import | `G3Button` / `G3Modal` / `G3Select` / `G3Input` |
| `var(--fms-*)` 业务变量 | `--g3-*` 令牌,**每个都带 fallback** |
| `useAppStore()` 取主题 | ConfigProvider |
| `dataIndex` 无类型约束(JS) | 手写 `types.ts` + 泛型 `G3TableColumn<T>` |
| 无 `resizable: false` 语义 | 保留(操作列/展开列必须禁止拖宽,见 §4.1 注释原因) |
**RTL**:全部用逻辑属性,带符号位移走 `--g3-dir-sign`;水平方向键走
`horizontalArrowStep(key, direction)`。表头拖拽 / 列序拖拽需单独验 RTL 几何。
---
## 6. 新增依赖
```yaml
# packages/ui/package.json
dependencies:
stk-table-vue: ^1.1.0
```
- MIT,零运行时依赖、零 peer 依赖,纯 ESM,体积可控(主包 ~23KB + 分块 185KB)。
- **不改** `pnpm-workspace.yaml` 的 `allowBuilds`(该包无安装脚本)。
- 需确认发布物无 `exports` 字段(实测确实没有),我们按 `lib/stk-table-vue.js` 深路径引用时
要留意——**建议用主入口 `import { StkTable } from 'stk-table-vue'`**,样式单独引。
---
## 7. 测试计划
按仓库三层测试约定:
- **L0 类型**:`pnpm --filter @g3soft/ui typecheck`。必须单独跑——`pnpm build` 不 gate 类型错误。
- **L1/L2 单测**(happy-dom):
- `utils/column.test.ts`:内部列注入 / 剥离、`headerAlign` 推断、列宽回写
- `utils/selection.test.ts`:全选 / 半选 / 禁用行 / 筛选后计数
- `utils/editable.test.ts`:草稿保持、`notifyChange` 去重、焦点判定矩阵
- `G3Table.test.ts`:组件契约(列渲染、门控开关、空态、只读)
- **L3 E2E**(必须在真浏览器,happy-dom 无布局引擎):
- **虚拟滚动几何**:100 行以下不虚拟、101 行以上虚拟;滚动后行位置正确
- **列宽/列序拖拽**:`page.mouse` 前先 `scrollIntoViewIfNeeded()`
- **resize 防抖**:容器尺寸变化后不出现中间态抖动
- **RTL 几何**:LTR / RTL 各测一次(单侧通过可能是巧合)
- **playground**:新增可交互元素必须挂 `data-testid`(E2E 只按 testid 找元素)。
- `tests/docs-examples.test.ts` 会校验文档示例的 prop 契约——**新增示例的 prop 名必须与
运行时 props 对账**,写错会被拦(这正是它存在的意义)。
**注意**:`publint` 在 `prepublishOnly` 跑,新增深路径引用可能触发 `exports` 一致性检查失败。
---
## 8. 实施顺序(建议分 4 批,每批可独立验证)
1. **底座打通**:装依赖 → 样式合流进 `dist/style.css` → 最小可渲染表格 → 验证 ESM / 类型 / 主题变量不冲突
2. **核心只读**:列定义 + 类型 + 虚拟门控(§4.1/4.2)+ 序号列 + 排序 + 固定列 + 空态 + 单测
3. **交互层**:多选列(§4.6)+ resize/键盘防抖(§4.4)+ 列宽/列序拖拽 + E2E 几何
4. **编辑与操作**:`editingActive`(§4.3)+ 行内编辑 + 操作列 + 行拖拽 + 表尾合计 + 文档/示例
---
## 9. 待你确认的点
1. **§2.2 样式合流**:接受把库样式打进我们的 `dist/style.css` 吗?(否则消费方要多引一次)
2. **§2.3 区域选择自注册**:组件内自动注册 vs 暴露显式 API?(我倾向自动 + 显式覆盖)
3. **§4.1 门控阈值 100 / 20**:沿用,还是按我们的场景调整?
4. **命名**:`G3Table` / `table`(与现有 `G3Splitter` / `splitter` 一致)
5. **C 类 workaround**:确认「只搬 A/B、C 类逐条实测」这个原则(避免把版本补丁搬进库)