This commit is contained in:
oneao committed 2026-10-13 22:32:50 +08:00
1 parent f3936cec61
commit 15e0ae5b67
115 files changed
+11399 -2741

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
---
title: 登录与鉴权
description: 登录、凭证携带、会话顶替与登录日志
pageClass: g3-api
---
# 登录与鉴权
除 `/auth/login` 外,**所有接口都需要携带凭证**。
## `POST` `/auth/login`
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `orgid` | string | 是 | 机构码,决定登录到**哪个机构的库** |
| `userid` | string | 是 | 用户账号(`b_user.b_id`) |
| `password` | string | 是 | 密码 |
</G3ApiParams>
<G3ApiPayload title="请求">
```bash
curl -X POST http://localhost:8088/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"orgid":"G3HD","userid":"g3soft","password":"******"}'
```
</G3ApiPayload>
<G3ApiPayload title="登录成功" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiJ9...",
"orgid": "G3HD",
"user": {
"id": "g3soft",
"account": "g3soft",
"name": "G3Soft"
}
}
}
```
</G3ApiPayload>
<G3ApiPayload title="账号或密码错误" :code="401">
```json
{
"code": 401,
"message": "机构码、账号或密码错误",
"data": null
}
```
</G3ApiPayload>
**几点要注意**:
- **机构码会归一化**:去掉首尾空白并转大写,所以传 `g3hd` 与 `G3HD` 等价。
- **机构码决定数据源**:登录时按机构码加载该机构的数据库配置;机构码不存在或配置缺失会失败。
- **「账号不存在」与「密码错误」对外是同一个提示**(`机构码、账号或密码错误`),不区分——避免被用来探测账号是否存在。但**登录日志里会分别记录**真实原因,便于排查。
- **`user.id` 与 `user.account` 目前是同一个值**(字符串业务键方案:`b_user.b_id` 即账号本身)。
- 用户编码与机构码都会写进 JWT,后续请求据此路由数据源与判定权限。
## 携带凭证
所有后续请求在请求头带 Bearer token:
```bash
curl -X POST http://localhost:8088/api/data/page \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...' \
-d '{"view_name":"v_b_othercompany","order_by":"b_id ASC"}'
```
| 场景 | 结果 |
|---|---|
| 缺少或格式不对的 `Authorization` | `401` |
| token 无效 / 过期 / 签名不符 | `401` |
| token 有效但**会话已被顶替** | `40101` |
| `OPTIONS` 预检请求 | **放行**(不校验) |
| `/auth/login` | **放行**(唯一不需要凭证的业务接口) |
> **HTTP 状态码始终是 200**,鉴权失败靠响应体的 `code`(`401` / `40101`)判断。
> 不要用 HTTP 状态码或 axios 的错误拦截分支处理鉴权失败。
## 会话与顶替
服务端维护**活动会话表**,同一个「机构 + 账号」只保留一个有效会话:
```text
登录 → 生成 sessionId → 登记为活动会话
再次登录同一账号 → 旧会话被顶替
旧 token 继续用 → 返回 40101(账号已在其他设备登录)
```
**前端处理**:拿到 `401` 或 `40101` 都应**清 token、回登录页**。`40101` 建议给一句「账号已在其他设备登录」,比笼统的「登录已失效」更有助于用户理解。
> 会话是**内存态**:服务重启后全部失效,用户需重新登录。
## 登录日志
每次登录尝试都会记录,含成功与失败:
| 记录内容 | 说明 |
|---|---|
| 机构码、用户账号 | 尝试登录的身份 |
| 成功 / 失败、失败原因 | 「账号不存在」/「密码错误」 |
| 会话 ID | 登录成功时 |
| 客户端信息 | 浏览器、操作系统、设备类型 |
| IP 与归属地 | 来源地址解析 |
查询入口:`v_s_log_login` 视图(走通用查询接口)。日志表本身**不需要登录权限之外的特殊授权**,但建议只给管理员开放。
## 相关配置
| 配置项 | 说明 |
|---|---|
| `erp.auth.jwt-secret` | 签名密钥,**生产必须改**(默认值仅用于开发) |
| `erp.auth.jwt-expiration` | token 有效期,默认 `24h` |
| `erp.database.config-dir` | 机构数据库配置目录,登录时按机构码在此查找 |
## 常见错误
| 场景 | code | 说明 |
|---|---|---|
| 机构码为空 | `1000` | 「机构码不能为空」 |
| 用户 ID 为空 | `1000` | 「用户ID不能为空」 |
| 机构码、账号或密码错误 | `401` | 三者统一提示 |
| 机构码在配置目录里不存在 | `401` | 加载机构配置失败时按机构码错误处理 |
| 未带凭证访问其他接口 | `401` | —— |
| 会话被顶替 | `40101` | 需重新登录 |
+124
View File
@@ -0,0 +1,124 @@
---
title: 导出
description: 列表导出、行数统计与合计查询
pageClass: g3-api
---
# 导出
导出接口,前缀 `/export/**`。三个接口**走完全相同的权限链路与同一套聚合 SQL**,因此「共 N 条」、列表底部合计与导出文件里的内容必然一致。
| 接口 | 返回 | 用途 |
|---|---|---|
| `/export/list` | **文件流**(xlsx 或 zip) | 导出列表 |
| `/export/count` | JSON | 批量导出弹窗的「共 N 条」 |
| `/export/totals` | JSON | 列表底部的「总合计」 |
<G3ApiParams title="共同的请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `moduleId` | string | 是 | 模块编码(决定列、查询表与权限) |
| `searchCondition` | string | | 查询条件(布尔表达式片段) |
| `orderBy` | string | | 排序,如 `b_id ASC`,多列逗号分隔 |
| `rowKeys` | any[] | | 勾选行的主键;**传了就是「按勾选导出」** |
| `keyField` | string | | 配合 `rowKeys` 的主键列名 |
| `columns` | string[] | | 指定导出列(默认按模块配置) |
| `rangeStart` / `rangeEnd` | int | | 行号区间(1 起,闭区间),导出当前页/区间 |
| `volumeSize` | int | | 分卷大小;传了则返回 **zip**,按此条数分卷 |
| `withSummary` | bool | | 是否输出合计行,**默认 `true`** |
</G3ApiParams>
## `POST` `/export/list`
导出并直接返回文件流。
<G3ApiPayload title="请求体">
```json
{
"moduleId": "v_b_othercompany",
"searchCondition": "b_canuse = 1",
"orderBy": "b_id ASC",
"withSummary": true
}
```
</G3ApiPayload>
**响应不是 JSON**,而是文件流,按 `Content-Type` 区分:
| 场景 | Content-Type | 说明 |
|---|---|---|
| 普通导出 | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` | 单个 xlsx |
| 分卷导出(传了 `volumeSize`) | `application/zip` | 多个 xlsx 打包 |
响应头带 `Content-Disposition: attachment; filename*=UTF-8''...`(文件名已做 URL 编码,中文名可直接还原)。
**几种请求形态的差异**:
| 形态 | 传什么 | 结果 |
|---|---|---|
| 全部导出 | 不传 `rowKeys` / 区间 | 当前条件下的**全部行**(不是当前页) |
| 勾选导出 | `rowKeys` + `keyField` | 只导出选中的行 |
| 区间导出 | `rangeStart` / `rangeEnd` | 导出指定行号区间 |
| 分卷导出 | `volumeSize` | 按条数切成多个 xlsx,打包返回 |
> `rowKeys` 里的数字主键**按原值透传**,不做字符串化——否则数字主键变成字符串后索引失效。
## `POST` `/export/count`
符合条件的行数。请求字段同上(通常只传 `moduleId` / `searchCondition`)。
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": { "total": 128 }
}
```
</G3ApiPayload>
> 因为与导出共用同一条权限链路与同一套 WHERE,这个数字**与真正导出的行数一致**——可以直接用来做「共 N 条」提示。
## `POST` `/export/totals`
各合计列的聚合值。请求字段同上。
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": {
"b_amount": 123456.78,
"b_qty": 320
}
}
```
</G3ApiPayload>
返回的键是**列名**,值是聚合结果(`sum` / `count` 等,取决于模块里该列的配置)。
> **没有配置任何合计列时返回空对象** ``,不是错误——前端据此不渲染合计行。
## 错误与权限
- **权限过滤发生在 SQL 里**:导出按 `SqlPermissionService` 的三层判定裁剪字段与数据范围。
- **字段权限**:`b_view=0` 或 `b_export=0` 的列会被**移除**(不会导出空列)。
- **数据范围**:按 `s_user_data_power` 追加条件。
- **没有导出动作权限**时拒绝:`1000`「没有模块「x」的动作权限「export」,请联系管理员授权」。
- **参数错误也在写流之前抛出**,所以失败时响应体仍是 JSON(HTTP 200 + `code != 0`),**不会返回半截损坏的 xlsx**。前端可**按 `Content-Type` 判断**拿到的是文件还是错误 JSON。
| 场景 | 表现 |
|---|---|
| 模块不存在 | `1000`,`message` 含「模块」 |
| 缺少 `moduleId` | `1000`「moduleId 不能为空」 |
| `rangeStart` 等不是整数 | `1000`「rangeStart 必须是有效整数」 |
| 无导出权限 | `1000`「没有模块「x」的动作权限「export」」 |
+203
View File
@@ -0,0 +1,203 @@
---
title: 附件
description: 文件上传(本地 / OSS 直传)、记录落库、删除与静态访问
pageClass: g3-api
---
# 附件
文件接口,前缀 `/file/**`。有两种存储模式,由 `erp.file.storage-type` 决定(`local` / `aliyun-oss`)。
| 模式 | 上传链路 |
|---|---|
| `local` | 前端直接 POST 文件 → 后端写盘 + 落库 |
| `aliyun-oss` | 取凭证 → 前端直传 OSS → 回调后端落库 |
> **第一步先问模式**:调 `/file/config` 拿到当前模式,再决定走哪条链路。前端组件不要写死。
## `POST` `/file/config`
返回当前存储模式。
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": { "storageType": "local" }
}
```
</G3ApiPayload>
## `POST` `/file/upload`
**本地上传**(`storage-type=local` 时使用)。支持多文件,**是 `multipart/form-data`,不是 JSON**。
<G3ApiParams title="表单字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `files` | file[] | 是 | 文件,key 固定为 `files`,可重复 |
| `father` | string | 是 | 宿主业务主键(存宿主 `b_id`) |
| `moduleId` | string | 是 | 模块编码 |
| `cateId` | string | | 分类 ID |
</G3ApiParams>
<G3ApiPayload title="请求">
```bash
curl -X POST http://localhost:8088/api/file/upload \
-H 'Authorization: Bearer <token>' \
-F 'files=@report.pdf' \
-F 'father=1234567890' \
-F 'moduleId=v_b_othercompany' \
-F 'cateId=987654321'
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": [
{
"subid": "1234567890123",
"mx_filename": "report.pdf",
"mx_filesize": "20480",
"mx_fileext": "pdf",
"mx_mapfilename": "/api/file/static/G3HD/2026/10/13/abc.pdf",
"father": "1234567890",
"mx_moduleid": "v_b_othercompany",
"mx_cate_id": "987654321"
}
]
}
```
</G3ApiPayload>
- `mx_mapfilename` 就是**访问地址**,预览 / 下载直接用它。
- `subid` 是雪花 ID,**本地模式由后端自己取号**后写入 `bf_files`(前端不用管)。
## `POST` `/file/upload-credential`
**取 OSS 直传凭证**(`storage-type=aliyun-oss` 时使用)。无请求字段。
后端转发到 g3oss,`bucket` 即当前机构码。
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": {
"accessid": "LTAI...",
"policy": "eyJleHBpcmF0aW9uIjoi...",
"signature": "abc123==",
"dir": "G3HD/",
"host": "https://g3soft.oss-cn-hangzhou.aliyuncs.com",
"expire": 1700000000
}
}
```
</G3ApiPayload>
> **字段以 g3oss 实际返回为准**:后端只做 `readValue(...).Map` 的**原样透传**,不加工也不改名。
> 上面是 OSS 直传的常见字段形状,接入时请以实际返回为准;凭证有有效期,取到后应尽快直传。
## `POST` `/file/save-record`
**OSS 直传完成后回调落库**(`storage-type=aliyun-oss` 时使用)。本地模式不需要单独调它。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `subid` | number | 是 | 上传前取的雪花 ID |
| `father` | string | 是 | 宿主业务主键 |
| `mx_moduleid` | string | 是 | 模块编码 |
| `mx_cate_id` | string | | 分类 ID |
| `mx_filename` | string | 是 | 原始文件名 |
| `mx_filesize` | string | 是 | 文件大小(字节) |
| `mx_fileext` | string | 是 | 扩展名 |
| `mx_mapfilename` | string | 是 | OSS 返回的完整 URL |
</G3ApiParams>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": { "subid": "1234567890123", "message": "记录已保存" }
}
```
</G3ApiPayload>
> **`subid` 在 OSS 链路必须由前端在上传前取号**(调 `/data/nextid`),因为凭证直传时后端不在链路上,无法代为取号。
> 本地链路则相反:**后端自己取号**,前端不用传。两条链路都不能依赖数据库自增。
## `POST` `/file/delete`
删除文件:**先删存储、后删 DB 行**。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `subid` | string | 是 | 文件记录 ID |
</G3ApiParams>
<G3ApiPayload title="响应" :code="0">
```json
{ "code": 0, "message": "操作成功", "data": null }
```
</G3ApiPayload>
外部对象操作**不属于数据库事务**:
- 删存储失败 → **不删数据库行**(保持可重试)。
- 数据库删除失败 → 保留可重试的清理记录。
记录不存在时静默返回成功(幂等)。
## `GET` `/file/static/{fileName}`
**本地模式**下的文件访问入口。不是 JSON 接口,直接返回文件流。
```text
GET /api/file/static/G3HD/2026/10/13/abc.pdf
```
- 路径形如 `{orgId}/{yyyy}/{MM}/{dd}/{file}`,与 `mx_mapfilename` 去掉前缀后的部分对应。
- 带**路径穿越防护**:解析后必须仍在存储根目录内,否则 404。
- 响应头带 `Content-Disposition: inline`,浏览器可直接预览。
## 相关数据表
| 表 | 说明 |
|---|---|
| `bf_files` | 文件记录(主键 `subid` 雪花 ID,`father` 存宿主主键) |
| `bf_files_category` | 文件分类(`b_module_id` 为空表示跨模块通用分类) |
## 常见错误
| 场景 | 表现 |
|---|---|
| 本地上传时缺少 `files` | `1000` 参数错误 |
| 文件静态访问越界或不存在 | **HTTP 404**(这条路径不走统一响应格式) |
| OSS 凭证获取失败 | `1000`「获取 OSS 上传凭证失败: {状态码}」 |
| 存储模式不匹配 | 例如 `local` 模式下调 `/file/save-record` 会落库成功但文件并不存在——**要先查 `/file/config`** |
@@ -0,0 +1,382 @@
---
title: 流程运行
description: 送审、审批动作、转派加签、审批中心与进度查询
pageClass: g3-api
---
# 流程运行
运行侧接口,前缀 `/workflow/runtime/**`。前端**只能提交意图**:改状态、建实例、推进节点、写待办全部在后端一个事务里完成;前端不允许直接改 `wf_instance` / `wf_task` / `wf_cc` 或业务表状态。
流程的**读取**走通用接口(待办/已办/进度这类有专门的运行侧查询接口,见下)。
> 前置:模块要先有**启用的流程绑定**(见[流程配置](/api/workflow)),否则送审会直接报「没有匹配到流程绑定」。
## 送审
### `POST` `/workflow/runtime/submit`
按模块 + 业务事件匹配绑定,建实例并激活首个节点。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `moduleId` | string | 是 | 模块编码 |
| `businessId` | string | 是 | 业务主键(**按字符串传**,不依赖业务表主键类型) |
| `businessNo` | string | | 业务单号,仅用于展示 |
| `event` | string | | 业务事件,默认 `submit`;可选 `submit` / `resubmit` / `change` |
| `context` | object | | 业务字段,参与条件分支与审批人解析(如金额、部门) |
| `idempotencyKey` | string | | 幂等键,重复提交返回同一实例 |
</G3ApiParams>
<G3ApiPayload title="请求体">
```json
{
"moduleId": "v_b_othercompany",
"businessId": "1234567890",
"businessNo": "HT-2026-001",
"event": "submit",
"context": { "b_amount": 3200, "b_dept_id": "D002" },
"idempotencyKey": "submit-1234567890-v1"
}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "已提交",
"data": {
"instanceId": "01a134fe-bde5-7058-a6b9-5f7e4f86b991",
"status": "running",
"currentSummary": { "summary": "待审批:部门负责人审批" },
"pendingTaskCount": 1,
"duplicated": false
}
}
```
</G3ApiPayload>
- `duplicated` 为 `true` 表示**这次请求被判为重复**(幂等键已存在),没有产生新副作用,`instanceId` 是既有实例。
- 送审时会把发起人、机构写进运行变量;审批人解析为空时实例直接进入 `error`,不会跳过节点。
## 审批动作
### `POST` `/workflow/runtime/task/action`
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `taskId` | string | 是 | 待办任务 ID |
| `action` | string | 是 | `approve` / `reject` / `return` |
| `comment` | string | | 审批意见 |
| `idempotencyKey` | string | | 幂等键,重复审批只生效一次 |
</G3ApiParams>
三个动作的差异:
| action | 节点内是否立即生效 | 结果 |
|---|---|---|
| `approve` | 否,凑够应签人数才完成节点 | 节点完成后继续路由;全部走完则实例 `approved` |
| `reject` | 是,第一票即生效 | 按节点配置的驳回策略走:终止 / 退回 / 退回上一节点 |
| `return` | 是 | 直接退回发起人,实例 `returned` |
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "处理成功",
"data": {
"instanceId": "01a134fe-bde5-7058-a6b9-5f7e4f86b991",
"status": "running",
"currentSummary": { "summary": "审批中:部门负责人审批" },
"pendingTaskCount": 0,
"duplicated": false
}
}
```
</G3ApiPayload>
> **注意 `status` 是实例状态**,不是本次动作的结果。同一节点还有其他人待签时,状态仍是 `running`。
### `POST` `/workflow/runtime/task/claim`
抢占待办。仅当节点配置了 `allowClaim` 且调用者**确实是该任务的候选审批人**时可用。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `taskId` | string | 是 | 待办任务 ID |
</G3ApiParams>
候选审批人取自任务生成时落下的**审批人快照**,不是当前任务表——所以组织架构后来变了也不会影响已产生任务的归属判定。不是候选人会被拒绝。
### `POST` `/workflow/runtime/task/transfer`
转交待办给他人。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `taskId` | string | 是 | 待办任务 ID |
| `toUserId` | string | 是 | 接收人(必须是存在且启用的用户) |
| `comment` | string | | 转交说明 |
</G3ApiParams>
原任务被取消,**新建一条任务**给接收人,并记录转交动作与通知事件。
### `POST` `/workflow/runtime/task/add-approver`
加签:给当前节点追加审批人。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `taskId` | string | 是 | 同节点任一任务,用于定位节点 |
| `userIds` | string[] | 是 | 追加的审批人 |
| `comment` | string | | 说明 |
</G3ApiParams>
已是该节点活动审批人的会被跳过;加签会自动顺延应签人数,不会让节点永远凑不满票。
### `POST` `/workflow/runtime/task/urge`
催办:**只发提醒,不改任务状态**(也不要求调用者是审批人,凭动作权限即可)。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `taskId` | string | 是 | 待办任务 ID |
| `comment` | string | | 催办说明 |
</G3ApiParams>
## 实例操作
### `POST` `/workflow/runtime/instance/withdraw`
撤回。发起人撤回运行中的实例。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `instanceId` | string | 是 | 流程实例 ID |
| `comment` | string | | 撤回原因 |
</G3ApiParams>
- 只能撤回**运行中**的实例;实例进入 `withdrawn`,全部未完成待办被取消。
- 发起人撤回自己发起的实例不需要额外权限;非发起人需要 `withdraw` 动作权限。
- 撤回后重新提交会产生**新实例**与新快照。
### `POST` `/workflow/runtime/instance/cancel`
取消(管理员)。需要 `workflow_admin` 动作权限。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `instanceId` | string | 是 | 流程实例 ID |
| `comment` | string | | 取消原因 |
</G3ApiParams>
实例进入 `cancelled`,其中明原因会写入实例摘要与取消动作。
## 审批中心
### `POST` `/workflow/runtime/todo/list`
我的待办:分配给我(含已抢占)且未处理完的任务。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `pageNo` | int | | 页码,默认 1 |
| `pageSize` | int | | 每页条数,默认 50,**上限 200** |
</G3ApiParams>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": {
"rows": [
{
"task_id": "01a134fe-...",
"node_key": "dept_manager",
"task_status": "pending",
"b_seq": 10,
"b_due_at": "2026-10-14T02:00:00Z",
"instance_id": "01a134ff-...",
"b_business_id": "1234567890",
"b_business_no": "HT-2026-001",
"instance_status": "running",
"initiator_name": "G3Soft",
"definition_name": "费用报销审批",
"node_name": "部门负责人审批"
}
],
"total": 1,
"pageNo": 1,
"pageSize": 50
}
}
```
</G3ApiPayload>
> **分页字段名是 `pageNo` / `pageSize`**,响应里也是这两个键(不是 `page` / `size`)。
> **排序**:按应处理时间升序,**没有时限的排在最后** —— 别拿第一行的 `b_due_at` 当作全表最小值。
### `POST` `/workflow/runtime/done/list`
我已处理:我实际处理过的任务(含通过、驳回、转交前的处理)。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `pageNo` / `pageSize` | int | | 分页,默认 1 / 50,`pageSize` 上限 200 |
</G3ApiParams>
### `POST` `/workflow/runtime/instance/mine`
我发起的流程实例。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `pageNo` / `pageSize` | int | | 分页,默认 1 / 50,`pageSize` 上限 200 |
</G3ApiParams>
### `POST` `/workflow/runtime/cc/list`
抄送我的。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `pageNo` / `pageSize` | int | | 分页,默认 1 / 50,`pageSize` 上限 200 |
</G3ApiParams>
### `POST` `/workflow/runtime/cc/read`
批量标记抄送已读。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `ccIds` | string[] | 是 | 抄送记录 ID **数组**(注意是复数) |
</G3ApiParams>
<G3ApiPayload title="请求体">
```json
{ "ccIds": ["01a134fe-bde5-7058-a6b9-5f7e4f86b991"] }
```
</G3ApiPayload>
只允许标记**属于当前用户**的抄送记录,且会记录已读时间。数组为空时报 `1000`「请选择要标记的抄送记录」。
## 进度与详情
### `POST` `/workflow/runtime/instance/graph`
实例图:节点与连线的状态 + 时间线,用于「查看审批进度」。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `instanceId` | string | 是 | 流程实例 ID |
</G3ApiParams>
返回内容包含:
| 项 | 说明 |
|---|---|
| 节点列表 | 每个节点的状态、应签/已签人数、审批人快照 |
| 连线状态 | 哪些连线走过 |
| 时间线 | 动作历史(`wf_action`),按发生时间排列 |
| `timing` | 节点耗时 `elapsedMs` 与进出时间 |
> **超时判定在前端做**:后端只返回节点已耗时与节点配置的 `b_due_hours`,**不判断是否超期、也不算剩余时间**。
> 另外,任务超时被标记为 `expired` 后,会从待办列表移除,但**实例仍保持 `running`**(超时只标记任务,不改流程)。
### `POST` `/workflow/runtime/instance/by-business`
按业务单据查审批状态:业务详情页的状态条用。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `moduleId` | string | 是 | 模块编码 |
| `businessId` | string | 是 | 业务主键 |
</G3ApiParams>
返回:最近一次实例、**当前用户在该单据上的待办**、以及该模块是否已配置绑定。
> 即使模块**没有**配置绑定也会正常返回(提示未接入审批),不会报错——业务详情页可以无脑调用。
### `POST` `/workflow/runtime/task/detail`
任务详情:任务 + 实例 + 节点配置(含表单策略)。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `taskId` | string | 是 | 待办任务 ID |
</G3ApiParams>
返回节点配置里的表单策略(哪些字段只读、哪些必填),审批页据此渲染表单。
## 常见错误
| 场景 | 表现 |
|---|---|
| 模块没有启用的绑定 | `1000`,`message` 形如「模块「x」的事件「submit」没有匹配到流程绑定」 |
| `event` 不合法 | `1000`,只接受 `submit` / `resubmit` / `change` |
| 任务已被他人处理 | `1000`,状态已不是 `pending` / `claimed` |
| 流程已结束 | `1000`,实例状态不是 `running` |
| 不是候选审批人却抢占 | `1000`,「你不是该任务的候选审批人,无法抢占」 |
| 缺少动作权限 | `1000`,「没有模块「x」的动作权限「y」,请联系管理员授权」 |
| 并发推进实例 | `1000`,「流程实例已被并发推进(版本不匹配),请刷新后重试」 |
| 重复请求(带幂等键) | **不是错误**:`code: 0` + `duplicated: true` |
@@ -0,0 +1,393 @@
---
title: 流程配置
description: 流程定义、版本草稿、发布校验与模块绑定
pageClass: g3-api
---
# 流程配置
配置侧接口,前缀 `/workflow/**`。
**读取走通用接口**(列表用 `v_wf_definition` / `v_wf_version` / `v_wf_binding` 视图,设计器读回草稿用 `/data/loaddata` 查 `wf_*` 表);**写入一律走这里**——发布校验、版本快照、「已发布不可改」这些约束必须在后端一个事务内完成,通用保存接口不参与。
> 运行侧(送审、审批动作、待办、抄送)见[流程运行](/api/workflow-runtime)。
## 流程定义
### `POST` `/workflow/definition/save`
新建或修改流程定义。**新建时同时落一条空草稿的 v1 版本**,保证设计器一进去就有可保存的目标。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `definitionId` | string | 是 | 流程编码。新建时校验:**字母开头**,仅字母/数字/下划线,≤ 50 字符 |
| `name` | string | 是 | 流程名称 |
| `i18n` | string | | 多语言资源键 |
| `bz` | string | | 备注 |
| `canuse` | bool | | 是否启用,默认 `true` |
</G3ApiParams>
<G3ApiPayload title="请求体">
```json
{
"definitionId": "expense_approval",
"name": "费用报销审批",
"i18n": "workflow.expense_approval",
"canuse": true
}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "保存成功",
"data": {
"definitionId": "expense_approval",
"versionId": "expense_approval_v1",
"created": true
}
}
```
</G3ApiPayload>
- `created` 为 `true` 表示**新建**(同时建了 v1 草稿);`false` 表示改的是已存在的定义。
- 修改已存在的定义时,`versionId` 返回该定义**当前最新的草稿版本**(没有草稿则为 `null`)。
### `POST` `/workflow/definition/delete`
删除流程定义,连同它的版本、节点、连线、审批人规则与绑定**一起删**。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `definitionId` | string | 是 | 流程编码 |
</G3ApiParams>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "删除成功",
"data": { "versions": 2, "bindings": 1 }
}
```
</G3ApiPayload>
> **有未结束实例时会拒绝删除**:`1000`「该流程还有 N 个未结束的实例,不能删除」。
> 运行历史必须能追溯到版本,不能留下悬空引用。未结束指状态为 `running` / `returned` / `error`。
## 版本与草稿
### `POST` `/workflow/version/draft/save`
保存草稿:**全量替换**该版本的节点 / 连线 / 审批人规则(幂等)。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `definitionId` | string | 是 | 流程编码 |
| `versionId` | string | | 目标版本;**留空则取该定义的草稿,没有就新建** |
| `draft` | object | 是 | 草稿内容 `{ nodes, edges }`,见下 |
| `canvas` | object | | 画布布局(只存不参与执行) |
</G3ApiParams>
`draft` 的结构与前端设计器的领域模型一致:
<G3ApiPayload title="draft 结构">
```json
{
"nodes": [
{
"nodeKey": "start",
"nodeType": "start",
"name": "开始",
"config": {},
"actorRules": []
},
{
"nodeKey": "dept_manager",
"nodeType": "user_task",
"name": "部门负责人审批",
"config": {
"approvalMode": "any",
"rejectMode": "return_initiator",
"dueHours": 24,
"allowClaim": true,
"allowInitiator": false,
"excludePreviousActors": true
},
"actorRules": [
{ "actorType": "dept_manager", "fallbackType": "admin" }
]
}
],
"edges": [
{ "edgeKey": "e1", "from": "start", "to": "dept_manager" }
]
}
```
</G3ApiPayload>
**字段说明**:
| 位置 | 字段 | 说明 |
|---|---|---|
| node | `nodeKey` | 版本内稳定编码,≤ 50 字符,**不可跨版本改**(运行时连线状态按它映射) |
| node | `nodeType` | `start` / `end` / `user_task` / `cc_task` / `service_task` / `exclusive_gateway` / `parallel_gateway` |
| node | `config.approvalMode` | `single` / `any` / `all` / `serial` / `n_of_m` / `percentage`。**留空按 `single` 处理**;无法识别的取值也按 1 票通过 |
| node | `config.rejectMode` | `terminate` / `return_initiator` / `return_previous` |
| node | `config.dueHours` | 审批时限(小时);**不配则任务没有应处理时间,永不超时** |
| node | `config.requiredCount` / `requiredRatio` | 配合 `n_of_m` / `percentage` 用 |
| node | `config.allowInitiator` | **显式 `false`** 才排除发起人(缺省允许) |
| node | `config.excludeCreator` | 排除创建人(当前以发起人近似) |
| node | `config.excludePreviousActors` | 排除此前节点出现过的人 |
| node | `config.allowClaim` | 是否允许抢占待办 |
| edge | `condition` | 条件 AST;**留空 = 无条件(默认边)** |
| rule | `actorType` | `user` / `initiator` / `field_user` / `dept_manager` / `initiator_manager` / `position` / `org_role` |
| rule | `fallbackType` | 解析为空时的兜底:`admin` / `initiator` |
> **`condition` 必须是对象或留空**。给了值却不是对象会直接报错——把脏条件静默当成「无条件边」会让流程走错分支,所以发布期就拒绝。
> **`actorRules` 的数组顺序就是串行会签的先后顺序**。
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "草稿已保存",
"data": {
"versionId": "expense_approval_v1",
"versionNo": 1,
"issues": [],
"edgeKeyMap": {}
}
}
```
</G3ApiPayload>
- `issues` 是**保存时顺带做的校验提示**(不阻断保存)。
- `edgeKeyMap` 只在连线编码因与其它版本冲突而被换新 ID 时出现,前端据此把本地 `edgeKey` 对齐到库里。
> 已发布版本**一律拒绝**保存草稿(`1000`)——改流程要复制新版本。
### `POST` `/workflow/version/validate`
校验草稿。传 `draft` 校验内存草稿(设计器边改边校验),传 `versionId` 读库校验。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `draft` | object | 二选一 | 内存草稿 |
| `versionId` | string | 二选一 | 读库校验的版本 |
</G3ApiParams>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": {
"issues": [
{ "level": "error", "message": "节点「部门负责人审批」没有配置审批人规则" },
{ "level": "warn", "message": "节点「财务复核」没有配置审批时限" }
],
"errorCount": 1,
"warnCount": 1
}
}
```
</G3ApiPayload>
### `POST` `/workflow/version/publish`
发布版本:校验必须**零 error**,然后计算配置摘要并锁定节点与连线。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `versionId` | string | 是 | 要发布的草稿版本 |
</G3ApiParams>
<G3ApiPayload title="发布成功" :code="0">
```json
{
"code": 0,
"message": "发布成功",
"data": {
"published": true,
"checksum": "3f2a...",
"definitionId": "expense_approval",
"issues": []
}
}
```
</G3ApiPayload>
<G3ApiPayload title="校验未通过(仍是 code 0)" :code="0">
```json
{
"code": 0,
"message": "发布校验未通过",
"data": {
"published": false,
"issues": [
{ "level": "error", "message": "流程缺少结束节点" }
]
}
}
```
</G3ApiPayload>
> **校验不通过不是错误**:HTTP 与 `code` 都是成功,要靠 `data.published` 判断。
> 这样调用方能**一次看到全部问题**,而不是改一个报一个。
### `POST` `/workflow/version/retire`
停用版本:`published` → `retired`,不再接受新实例;**已有实例继续用原版本跑完**。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `versionId` | string | 是 | 要停用的已发布版本 |
</G3ApiParams>
### `POST` `/workflow/version/copy`
以某个版本为模板复制出一条**新草稿**(版本号 = 当前最大值 + 1)。
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `versionId` | string | 是 | 源版本(可以是已发布版本) |
</G3ApiParams>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "已复制为新草稿",
"data": { "versionId": "expense_approval_v3", "versionNo": 3 }
}
```
</G3ApiPayload>
> **同一定义同时只允许一条草稿**。已有草稿时会报 `1000`「该流程已有草稿版本「x」,请先发布或删除它」——避免「改的是哪一条」说不清。
## 流程绑定
绑定决定「哪个模块的哪个业务事件走哪个流程版本」。
### `POST` `/workflow/binding/save`
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `moduleId` | string | 是 | 模块编码 |
| `event` | string | 是 | 业务事件:`submit` / `resubmit` / `change` |
| `versionId` | string | 是 | 目标版本,**必须是已发布版本** |
| `bindingId` | string | | 留空=新建,传值=修改 |
| `match` | object | | 匹配条件(条件 AST),决定业务上下文是否走这个流程 |
| `orgScope` | object | | 组织范围 |
| `statusField` | string | | 业务表上的**状态列名**(状态投影写这一列) |
| `statusMap` | object | | 流程状态 → 业务状态值的映射 |
| `priority` | int | | 优先级,默认 0,**小的先匹配** |
| `effectiveFrom` / `effectiveTo` | string | | 生效区间 |
| `canuse` | bool | | 是否启用,默认 `true` |
| `bz` | string | | 备注 |
</G3ApiParams>
<G3ApiPayload title="请求体">
```json
{
"moduleId": "v_b_othercompany",
"event": "submit",
"versionId": "expense_approval_v1",
"match": { "field": "b_amount", "operator": "gt", "value": 0 },
"statusField": "b_status",
"statusMap": { "approved": 2, "rejected": 3, "returned": 4 },
"priority": 10,
"canuse": true
}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "保存成功",
"data": { "bindingId": "1234567890123", "created": true }
}
```
</G3ApiPayload>
**几条硬规则**:
- **绑定只能指向已发布版本**(`1000`,提示当前版本状态)。
- **同一模块 + 同一事件下,不能有两条同优先级的启用绑定**(`1000`)——否则无法确定用哪个流程。冲突时调整 `priority` 或停用其中一条。
- **保存绑定会顺带备好该模块的动作权限点**:否则管理员在用户权限页看不到可授权的权限点,非管理员送审会被白名单拒绝。这一步是幂等的,不会覆盖管理员改过的名称。
> `match` 留空表示**该模块该事件一律走这个流程**。`statusMap` 决定审批结果如何写回业务表状态列;不配 `statusField` 则跳过状态投影(只读模块的正常情况)。
### `POST` `/workflow/binding/delete`
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `bindingId` | string | 是 | 绑定 ID |
</G3ApiParams>
## 常见错误
| 场景 | 表现 |
|---|---|
| 流程编码非法 | `1000`「流程编码只能以字母开头,由字母、数字、下划线组成,且不超过 50 个字符」 |
| 版本不存在 | `1000`「版本「x」不存在」 |
| 非草稿版本发布 | `1000`「只有草稿版本可以发布,当前版本状态:published」 |
| 非已发布版本停用 | `1000`「只有已发布版本可以停用,当前版本状态:draft」 |
| 绑定指向非已发布版本 | `1000`「绑定只能指向已发布版本,当前版本状态:draft」 |
| 同优先级启用绑定冲突 | `1000`「同一模块同一事件下已存在同优先级的启用绑定「x」」 |
| 删除有未结束实例的定义 | `1000`「该流程还有 N 个未结束的实例,不能删除」 |
| 连线/审批人规则条件不是对象 | `1000`,指明是「审批人规则」还是「连线「x」」的条件 |
@@ -0,0 +1,181 @@
---
title: 后端架构
description: g3soft-erp 后端的实际结构:技术栈、包组织、多机构数据源路由、通用数据接口、权限与工作流
---
# 后端架构
本文描述 `g3soft-erp/server` **当前的实现**(不是规划稿)。所有内容以代码为准,涉及取舍的地方都写明原因。
## 1. 技术栈
| 项 | 选型 |
|---|---|
| 框架 | Spring Boot 4.1.0 / Java 21 |
| 数据访问 | `spring-boot-starter-jdbc` + **纯手写 SQL**,**无 ORM** |
| 连接池 | Druid |
| 数据库 | SQL Server(2022) |
| 鉴权 | JJWT |
| 导出 | Apache Fesod + POI |
| 参数校验 | 自研 `ParamUtils` / `RequestParams` |
**为什么不用 ORM**:业务查询大量依赖视图、动态条件、聚合与按机构分库,ORM 的映射层在这些场景反而成为阻碍。查询与写入都直接落到 SQL。
## 2. 包组织
```
cn.g3soft.erp
├── common/ 通用设施
│ ├── ApiResponse 统一响应包体
│ ├── exception/ 业务异常 + 全局异常处理
│ └── util/ 参数、SQL 文本、时间、UUIDv7、雪花 ID
├── config/ 过滤器与配置属性
│ ├── JwtAuthFilter JWT 鉴权
│ ├── RequestLoggingFilter 访问日志(MDC)
│ └── *Properties 按功能分组的配置绑定
├── controller/ 接口层(6 个控制器)
├── database/ 数据访问基础设施
│ ├── MetadataReader 表结构内省
│ ├── RowReader 查询
│ ├── RowWriter 写入
│ ├── model/ 元数据模型
│ └── org/ 多机构数据源路由
└── service/ 业务服务
├── auth/ 登录、JWT、会话、登录日志
├── data/ 通用数据查询 / 保存 / 删除
├── export/ 导出
├── file/ 附件
├── formula/ 公式引擎
├── permission/ 权限判定
└── workflow/ 工作流引擎
```
分层约定:**controller 只做参数接收与响应包装**,业务逻辑一律在 service;SQL 相关的底层能力集中在 `database/`,由各 service 复用。
## 3. 多机构数据源路由
一套部署服务多个机构,每个机构一个独立数据库。
```text
请求 → JwtAuthFilter 解析 JWT,写入 OrgContext(ThreadLocal: orgId / userId)
→ 业务代码注入 @Primary 的 OrgRoutingDataSource
→ 按 OrgContext.requireOrgId() 路由到该机构的连接池
```
| 组件 | 职责 |
|---|---|
| `OrgContext` | `ThreadLocal` 保存当前请求的机构码与用户编码 |
| `OrgRoutingDataSource` | `@Primary` 数据源,每次取连接时按机构路由 |
| `OrgDataSourceManager` | 按机构懒加载并缓存连接池 |
| `OrgDatabaseConfigLoader` | 读取 `config/dbconfigs/{机构码}.properties` |
| `OrgDatabaseConfigWatcher` | 监听配置目录,改动后热切换连接池 |
**几个刻意的做法**:
- **不依赖 Spring 的事务抽象**:全项目不使用 `@Transactional`,事务边界由 service 手动控制(`setAutoCommit(false)` → `commit` / `rollback`)。
- **连接池配置热更新**:配置文件变更后重建连接池,旧池延迟关闭,保证在途请求有时间归还连接。
- **建池即验活**:`initial-size: 1`,配置指向不可达的库时快速失败并保留旧池,不会把整个机构打挂。
- **后台任务要自己设上下文**:无请求上下文的定时任务必须逐个机构设置 `OrgContext` 再取连接,用完清理。
## 4. 通用数据接口
除工作流外,业务数据的读写统一走 `/data/**`,不为每个模块写专用 CRUD。
| 接口 | 作用 |
|---|---|
| `POST /data/describe` | 探测表/视图的列结构 |
| `POST /data/loaddata` | 按视图 + 条件查询列表 |
| `POST /data/loaddatabysql` | 执行只读 SQL |
| `POST /data/page` | 分页查询(返回 `rows` / `total` / `page_no` / `page_size`) |
| `POST /data/saveobjt` | 通用保存(批量,主子表一次提交) |
| `POST /data/delete` | 通用删除 |
| `POST /data/nextid` | 取雪花 ID |
| `POST /data/nextcode` | 取自动编码 |
**共同约定**:
- 表名、视图名、列名**先过标识符校验**(只允许 `[A-Za-z_][A-Za-z0-9_]*`,可带 `dbo.` 限定),不做字符串拼接。
- `search_condition` 是**原样拼进 `WHERE` 的布尔表达式片段**,整条 SQL 之后过只读校验(禁分号、禁注释、禁写关键字)。
- 返回的行是普通 Map,键是**库里的原始列名**。
- **审计列由服务端统一填**,前端不能指定:`b_created_by` / `b_created_at` / `b_updated_by` / `b_updated_at` 由保存层写入,不建触发器。
**保存的并发保护**:`b_row_version` 参与乐观锁;影响行数为 0 时区分「记录不存在」与「版本冲突」,分别返回可识别的错误,前端据此提示刷新。
## 5. 权限模型
前端隐藏菜单只是体验,**真正生效的过滤必须在后端拼进 SQL**。`SqlPermissionService` 是这一层的唯一实现,分三层:
| 层 | 数据来源 | 语义 |
|---|---|---|
| 动作权限 | `s_power` + `s_user_power` | **白名单**:没有对应权限点、或未授权、或任一侧停用即拒绝 |
| 字段权限 | `s_user_field_power` | `b_view` / `b_export`,无记录按模块默认(可看可导出) |
| 数据范围 | `s_user_data_power` | 按操作精确匹配优先,多条规则 OR,整体再与其它条件 AND |
动作权限编码为 `action.{模块}.{动作}`;数据范围的判定字段取自**模块配置** `s_module.b_scope_field`(记录归属人字段),而不是授权行——同一个模块的所有用户必须用同一字段判范围。
## 6. 工作流
工作流是独立子系统,不与通用数据接口混用:**业务单据的状态只能由流程服务推进**,业务表的状态列由流程投影写入。
```text
业务模块 → 流程绑定 → 已发布版本 → 节点与连线 / 审批人规则
业务提交 → 流程实例 → 节点运行 → 待办任务 → 动作 / 审计 / 通知
```
| 接口组 | 前缀 | 职责 |
|---|---|---|
| 配置 | `/workflow/**` | 定义、版本(草稿/发布/停用/复制)、绑定 |
| 运行 | `/workflow/runtime/**` | 送审、审批动作、抢占、转派、加签、催办、撤回、取消、待办/已办/进度/抄送 |
实现上的关键口径:
- 一次动作的全部副作用在**同一事务**内完成:锁定 → 校验 → 写动作 → 更新任务/节点 → 推进或结束 → 投影业务状态 → 写 outbox → 提交。
- **锁序统一为「实例优先」**,避免实例与任务互锁造成的死锁。
- **幂等**:送审按 `b_idempotency_key`,审批按 `wf_action.b_request_id`,各由一条筛选唯一索引兜底。
- 审批人解析为空时实例进入 `error` 而非跳过节点。
- **超时只标记任务、不改流程**:到期任务置 `expired` 并从待办移除,实例保持运行,由管理员加签/转派处理。
## 7. 鉴权与会话
```text
POST /auth/login → 校验账号 → 签发 JWT(含机构码、用户编码)→ 登记活动会话
后续请求 → JwtAuthFilter 校验签名与会话有效性 → 写入 OrgContext 与日志 MDC
```
- 过滤器只放行 `OPTIONS` 与 `/auth/login`,其余请求一律校验。
- **会话登记**:`ActiveSessionRegistry` 记录活动会话,可在登录时判定是否顶掉旧会话。
- 登录日志独立记录(含客户端信息与 IP 归属地),便于审计。
## 8. 公式引擎
`service/formula` 是一套独立的表达式引擎:词法 → 语法 → AST → 求值,配合依赖图与配置服务。
- **依赖分析**:`FormulaDeps` / `FormulaGraph` 解析字段间依赖,支持检测循环引用。
- **校验与配置分离**:`FormulaValidator` 负责保存期校验,`FormulaConfigService` 负责读取与缓存。
- 求值结果与错误都走统一类型,便于前端展示。
## 9. 附件
`POST /file/**` 提供两类存储模式,由 `erp.file.storage-type` 配置:
| 模式 | 流程 |
|---|---|
| `local` | 后端写盘 + 落库,静态资源走 `/file/static/**` |
| `aliyun-oss` | 前后端取号 → 凭证直传 OSS → 回调落库 |
配置前缀为 `erp.file.*`(`storage-dir` / `url-prefix` / `g3oss-url`)。文件记录表使用 `bf_` 业务域前缀。
## 10. 配置
配置按功能分组,统一挂在 `erp.*` 下:
```yaml
erp:
auth: jwt-secret / jwt-expiration
database: config-dir / refresh-check-interval / pool.*
file: storage-type / local.* / oss.*
log: slow-request-ms / max-param-bytes / exclude-param-paths
workflow: expire-sweep-interval
```
**机构数据库配置独立存放**:`server/config/dbconfigs/{机构码}.properties`,含连接串、账号与驱动。该目录已 gitignore,不入版本库。
@@ -0,0 +1,791 @@
---
title: 数据表结构
description: 核心元数据表、组织与权限表、日志表、工作流表的完整字段说明
---
# 数据表结构
本文记录 `G3ERP` 库的结构与约定,**字段以真库为准**。
按前缀分四类,本文**只写系统级表**(业务表详见各自业务域):
| 前缀 | 类别 | 本文是否覆盖 |
|---|---|---|
| `s_` | 系统 / 元数据 / 权限 / 日志 | ✅ 全部 20 张 |
| `wf_` | 工作流 | ✅ 全部 13 张 |
| `bf_` | 文件附件 | ✅ 2 张 |
| `b_` | 业务与主数据 | 只写组织用户等基础表;**业务字典表不写** |
| `v_` | 视图 | 查询入口,见文末 |
## 1. 命名与字段约定
### 1.1 表名
- 统一小写 `snake_case`,下划线分词。
- 系统表 `s_`;业务与主数据 `b_`;业务域表用明确的域前缀(如 `bf_files`);工作流 `wf_`(属业务域前缀,**不用 `s_`**)。
- 表名表达稳定的业务含义,**不用页面名或路由名当表名**。
### 1.2 字段命名
| 用途 | 约定 |
|---|---|
| 主键 | 业务编码主键 `b_id`;关系表用联合主键 |
| 关联 | `<对象>_id`,如 `b_module_id`、`b_dept_id` |
| 排序 | `b_xh`,默认 `0` |
| 启用 | `b_canuse`,`0/1`,默认 `1` |
| 备注 | `b_bz` |
| 名称 | `b_name` |
| 多语言 | `b_i18n`(指向 `s_i18n.b_key`) |
| 主表字段 | `b_` 前缀 |
| 明细 / 子表字段 | `mx_` 前缀 |
### 1.3 类型选择
| 用途 | 类型 |
|---|---|
| 名称、备注、多语言文本 | `nvarchar` |
| 编码、状态、类型、SQL 标识 | `varchar` |
| 日期时间 | **`datetime2`** |
| 启用、顺序标记 | `tinyint`(0/1) |
| 数量、金额 | 按业务精度设 `precision` / `scale` |
### 1.4 审计字段
| 字段 | 含义 |
|---|---|
| `b_created_by` / `b_created_at` | 创建人(用户编码)/ 创建时间 |
| `b_updated_by` / `b_updated_at` | 最后修改人 / 最后修改时间 |
**不建触发器**,由写入方维护(通用保存在服务端统一填,前端不能指定)。
审计字段**不是所有表都必须有**:客户维护、需追溯变更的业务表才需要;系统内部关联表、字典表可省略。**授权类表例外**——`s_user_power` / `s_user_field_power` / `s_user_data_power` 由用户在授权界面操作,需追溯授权变更人,因此保留审计字段。`s_power` 是开发期维护的系统元数据,不设审计字段。
> 这些字段只表示**系统层面**的创建/修改信息,**不替代业务字段**里的录入人、审核人、负责人。
### 1.5 约束
- **不依赖数据库外键、`CHECK` 约束和触发器**保证业务规则,合法性由业务层校验。
- 状态、类型、动作的取值写在**列注释**里,不用 `CHECK` 枚举——加值不需要改表。
- 关联完整性与删除规则由业务层处理,删除策略见 `s_relation.b_on_delete` 与 `s_delete_rule`。
## 2. 键与 ID 策略
| 场景 | 策略 | 示例 |
|---|---|---|
| 配置表(模块、菜单、权限、流程定义) | `varchar` **业务编码主键**(人可读、跨环境稳定) | `s_module.b_id`、`wf_definition.b_id` |
| 业务数据 | 应用层生成的 **bigint 雪花 ID** | `bf_files.subid`、业务表 `b_id` |
| 工作流运行对象 | **UUIDv7**(`uniqueidentifier`,按时间有序) | `wf_instance.b_id`、`wf_task.b_id` |
| 日志 | **UUIDv7** | `s_log_audit.b_id` |
**跨表引用业务主键时统一按字符串保存**(如 `wf_instance.b_business_id`、`s_log_audit.b_data_id`),不依赖业务表物理主键类型——业务表可能是 `bigint`,老表可能是 `varchar`。
## 3. 模块元数据
系统以「模块」为配置单元,五张表配合完成查询与界面配置。
### 3.1 `s_module` — 模块定义
| 列 | 类型 | 说明 |
|---|---|---|
| `b_id` | `varchar(50)` PK | 模块编码 |
| `b_parent_id` | `varchar(50)` | 父模块,NULL 为根 |
| `b_depth` | `int` 默认 0 | 树层级,根为 0 |
| `b_path` | `varchar(1000)` | 根到当前节点路径,如 `/sea/container/` |
| `b_name` | `nvarchar(200)` | 模块名称 |
| `b_i18n` | `varchar(150)` | 多语言资源键 |
| `b_module_type` | `varchar(20)` | `module` / `data` / `virtual` |
| `b_view_table` | `varchar(128)` | 默认查询表或视图 |
| `b_save_table` | `varchar(128)` | 默认保存目标表 |
| `b_key_field` | `varchar(50)` | 主键字段 |
| `b_scope_field` | `varchar(50)` | **数据范围判定字段**(记录归属人字段) |
| `b_order_sql` | `varchar(500)` | 默认排序 SQL |
| `b_query_sql` | `nvarchar(max)` | 可选查询 SQL 或模板 |
| `b_config_json` | `nvarchar(max)` | 低频扩展配置 |
| `b_canuse` | `tinyint` 默认 1 | 是否启用 |
| `b_xh` | `int` 默认 0 | 同级显示顺序 |
| `b_bz` | `nvarchar(2000)` | 备注 |
索引:`ix_s_module_parent(b_parent_id, b_canuse, b_depth, b_xh, b_id)`、`ix_s_module_path(b_path, b_canuse, b_id)`、`ix_s_module_type(b_module_type, b_canuse, b_xh, b_id)`、`ix_s_module_view_table`、`ix_s_module_save_table`。
**模块类型**:
| 取值 | 含义 |
|---|---|
| `module` | 业务分组(海运、财务…),默认不配字段与表 |
| `data` | 具体数据对象,必须配 `b_view_table` 或 `b_query_sql` |
| `virtual` | 不适合标准表表达的能力,由 SQL 或业务处理器实现 |
> **`b_scope_field` 是数据范围权限的唯一判定依据**,配在模块上而非授权行上:同一模块的所有用户必须用同一字段判范围(如 `b_inputuser_id`)。
### 3.2 `s_field` — 模块字段
| 列 | 类型 | 说明 |
|---|---|---|
| `b_module_id` | `varchar(50)` PK | 所属模块 |
| `b_field` | `varchar(50)` PK | 字段编码 / 查询别名 |
| `b_name` | `nvarchar(200)` | 字段名称 |
| `b_i18n` | `varchar(150)` | 多语言资源键 |
| `b_type` | `varchar(30)` | 业务类型 |
| `b_canuse` | `tinyint` 默认 1 | 是否启用 |
| `b_xh` | `int` 默认 0 | 默认顺序 |
主键 `(b_module_id, b_field)`;索引 `ix_s_field_module_order(b_module_id, b_canuse, b_xh, b_field)`。
> **`s_field` 不保存数据库类型、长度、精度**——这些由后端在运行时直接读表/视图结构(`MetadataReader`)。字段同步按来源结构对齐,但**不覆盖人工维护的名称与业务类型**。
> `b_type` 的取值集合由业务层维护,**本设计不做固定枚举**。
### 3.3 `s_module_schema` — 界面配置
列表 / 表单 / 查询配置统一存 JSON,**不再各建一张表**。
| 列 | 类型 | 说明 |
|---|---|---|
| `b_module_id` | `varchar(50)` PK | 模块编码 |
| `b_schema_type` | `varchar(20)` PK | `view` / `edit` / `query` |
| `b_schema_json` | `nvarchar(max)` | 完整 JSON 配置 |
| `b_canuse` | `tinyint` 默认 1 | 是否启用 |
| `b_updated_by` | `varchar(50)` | 最后修改人 |
| `b_updated_at` | `datetime2` | 最后修改时间 |
主键 `(b_module_id, b_schema_type)`。JSON 顶层含 `schemaVersion` 便于结构升级。
### 3.4 `s_relation` — 模块关系
| 列 | 类型 | 说明 |
|---|---|---|
| `b_source_module_id` | `varchar(50)` PK | 源模块 |
| `b_source_field` | `varchar(50)` PK | 源字段 |
| `b_target_module_id` | `varchar(50)` PK | 目标模块 |
| `b_target_field` | `varchar(50)` PK | 目标字段 |
| `b_relation_type` | `varchar(20)` | `one_to_one` / `one_to_many` / `many_to_one` |
| `b_on_delete` | `varchar(20)` 默认 `restrict` | 删除策略:`restrict` / `cascade` / `set_null` |
| `b_canuse` / `b_xh` | — | 启用 / 顺序 |
索引:`ix_s_relation_source`、`ix_s_relation_target`。
### 3.5 `s_autocode` — 自动编码
| 列 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `b_module_id` | `varchar(50)` PK | — | 模块编码 |
| `b_field` | `varchar(50)` PK | — | 字段编码 |
| `b_prefix` | `nvarchar(200)` | `''` | 前缀 |
| `b_dateformat` | `varchar(30)` | `yyyyMM` | 日期段格式 |
| `b_separator` | `nvarchar(20)` | `-` | 分隔符 |
| `b_seqwidth` | `int` | 5 | 流水号位数 |
| `b_resettype` | `varchar(10)` | `month` | 重置周期 |
| `b_startvalue` | `bigint` | 1 | 起始值 |
| `b_currentperiod` | `varchar(20)` | — | 当前周期 |
| `b_currentvalue` | `bigint` | 0 | 当前值 |
| `b_canuse` | `tinyint` | 1 | 是否启用 |
### 3.6 `s_menu` / `s_menu_module`
菜单树与模块挂载关系。
`s_menu`:`b_id`(PK) / `b_parent_id` / `b_depth` / `b_path` / `b_name` / `b_i18n` / `b_menu_type` / `b_route`(路由) / `b_icon` / `b_xh` / `b_canuse`。
`s_menu_module`:`b_menu_id` + `b_module_id`(联合 PK) / `b_xh` / `b_canuse`——把菜单挂到一个或多个模块上。
索引:`ix_s_menu_parent`、`ix_s_menu_path`、`ix_s_menu_route`、`ix_s_menu_module_module`。
### 3.7 多语言
| 表 | 结构 |
|---|---|
| `s_i18n` | `b_key` + `b_locale`(联合 PK) / `b_value nvarchar(1000)` / `b_canuse` |
| `s_i18n_type` | `b_id`(PK, 如 `zh-CN`) / `b_name` / `b_canuse` / `b_default`(是否默认语言) / `b_xh` |
### 3.8 `s_user_module_pref` — 用户个性化
用户对模块界面的个人偏好(列宽、顺序等),覆盖模块默认配置。
`b_user_id` + `b_module_id` + `b_schema_type`(联合 PK) / `b_schema_json` / `b_updated_by` / `b_updated_at`。
### 3.9 `s_delete_rule` — 删除规则
| 列 | 说明 |
|---|---|
| `b_id` | `bigint` 主键 |
| `b_module_id` | 模块编码 |
| `b_sql` | 删除前检查/处理的 SQL |
| `b_message` | 不满足条件时给用户的提示 |
| `b_canuse` / `b_xh` | 启用 / 顺序 |
| `b_created_by` / `b_created_at` / `b_updated_by` / `b_updated_at` | 审计字段 |
## 4. 组织与用户
| 表 | 主键 | 说明 |
|---|---|---|
| `b_user` | `b_id varchar(50)` | 用户账号 |
| `b_dept` | `b_id varchar(50)` | 部门树 |
| `b_position` | `b_id varchar(50)` | 岗位 |
| `b_org_role` | `b_id varchar(50)` | 组织角色 |
| `b_user_position` | `b_user_id` + `b_position_id` | 用户-岗位关联 |
| `b_user_org_role` | `b_user_id` + `b_role_id` | 用户-组织角色关联 |
**两个对工作流审批人解析至关重要的字段**:
| 字段 | 含义 | 用于 |
|---|---|---|
| `b_dept.b_manager_id` | 部门负责人(用户编码) | `dept_manager` 规则 |
| `b_user.b_leader_id` | 用户的直属领导(用户编码) | `initiator_manager` 规则 |
部门树用 `b_parent_id` 作权威关系,`b_depth` / `b_path` 是派生字段;`b_path` 的节点编码前缀用于查下级部门(数据范围 `dept_tree` 依赖)。
关联表都有 `b_canuse` 与审计字段,并建了反向索引(如 `ix_b_user_position_position`)。
## 5. 权限
权限只保留四张表,分别回答四个问题:
### 5.1 `s_power` — 有哪些权限点
| 列 | 类型 | 说明 |
|---|---|---|
| `b_id` | `varchar(150)` PK | 权限点编码,如 `action.{模块}.{动作}` |
| `b_name` | `nvarchar(200)` | 权限点名称 |
| `b_type` | `varchar(20)` | 权限点类型 |
| `b_object_type` | `varchar(20)` | 对象类型,动作权限为 `module` |
| `b_object_id` | `varchar(50)` | 对象编码(模块编码) |
| `b_action` | `varchar(30)` | 动作编码,如 `submit` / `approve` / `export` |
| `b_canuse` / `b_xh` / `b_bz` / `b_i18n` | — | — |
**唯一索引 `ux_s_power_object_action(b_object_type, b_object_id, b_action)`** —— 保证同一对象同一动作只有一个权限点。
另有 `ix_s_power_object`、`ix_s_power_type`。
> `s_power` **不设审计字段**(开发期维护的系统元数据)。
### 5.2 `s_user_power` — 用户有哪些权限点
| 列 | 说明 |
|---|---|
| `b_user_id` + `b_power_id` | 联合主键 |
| `b_canuse` | 是否启用 |
| `b_created_by` / `b_created_at` / `b_updated_by` / `b_updated_at` | 审计字段(授权变更需可追溯) |
索引 `ix_s_user_power_power(b_power_id, b_user_id)`。
### 5.3 `s_user_field_power` — 字段级权限
| 列 | 默认 | 说明 |
|---|---|---|
| `b_user_id` + `b_module_id` + `b_field_id` | 联合 PK | — |
| `b_view` | 1 | 是否可见 |
| `b_edit` | 0 | 是否可编辑 |
| `b_query` | 1 | 是否可作为查询条件 |
| `b_export` | 1 | 是否可导出 |
| `b_canuse` | 1 | 是否启用 |
| `b_created_by` / `b_created_at` / `b_updated_by` / `b_updated_at` | — | 审计字段 |
> **无记录按模块默认**(可看可导出)。`b_view=0` 或 `b_export=0` 的列会在导出时**被移除**。
### 5.4 `s_user_data_power` — 数据范围
| 列 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `b_user_id` | `varchar(50)` PK | — | 用户编码 |
| `b_module_id` | `varchar(50)` PK | — | 模块编码 |
| `b_operation` | `varchar(30)` PK | `*` | 操作;`*` 表示适用于所有操作 |
| `b_xh` | `int` PK | 0 | 同组内的排序 / 区分序号 |
| `b_scope_type` | `varchar(20)` | — | 范围类型,见下 |
| `b_scope_field` | `varchar(50)` | — | 判定字段(**实际取值以模块配置 `s_module.b_scope_field` 为准**) |
| `b_condition_sql` | `nvarchar(max)` | — | `custom` 类型的条件片段 |
| `b_canuse` | `tinyint` | 1 | 是否启用 |
| 审计字段 | — | — | `b_created_*` / `b_updated_*` |
主键 `(b_user_id, b_module_id, b_operation, b_xh)`;索引 `ix_s_user_data_power_module`。
| `b_scope_type` | 含义 |
|---|---|
| `all` 或空 | 全部数据,不追加条件 |
| `self` | 只本人:判定字段 = 当前用户 |
| `dept` | 只本部门:判定字段 = 当前用户所属部门 |
| `dept_tree` | 本部门及下级:按 `b_dept.b_path` 前缀匹配 |
| `custom` | 自定义:使用 `b_condition_sql` |
**判定规则**:
- 按操作**精确匹配优先**——命中精确规则就**不再叠加** `'*'` 规则;没有精确规则时才用 `'*'`。
- 多条规则之间 **OR**,整体再与其它条件 **AND**。
- 判定字段取自**模块配置**而不是授权行:同一模块的所有用户必须用同一字段判范围。
- 未归属部门时 `dept` / `dept_tree` 规则**不生效**(不报错,等于不追加条件)。
- `custom` 的 `b_condition_sql` 只由管理员配置,运行时仅替换 `{userId}` / `{deptId}` 受控占位符,并做只读校验(禁分号、禁注释、禁写关键字)。
## 6. 日志
三张日志表都以 **UUIDv7** 为主键,都建了按时间与业务维度的索引。
### 6.1 `s_log_audit` — 数据变更审计
| 列 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `b_id` | `uniqueidentifier` PK | — | UUIDv7 |
| `b_event_code` | `varchar(50)` | — | 事件编码 |
| `b_module_id` | `varchar(50)` | — | 模块编码 |
| `b_data_id` | `varchar(50)` | — | **业务主键(统一字符串保存)** |
| `b_business_no` | `varchar(50)` | — | 业务单号 |
| `b_operation` | `varchar(100)` | — | 操作名(如「审批状态投影」) |
| `b_operator_id` | `varchar(50)` | — | 操作人 |
| `b_operator_name` | `nvarchar(200)` | — | 操作人姓名 |
| `b_source_type` | `varchar(20)` | `user` | 来源类型(`user` / `system`) |
| `b_visibility` | `varchar(20)` | `internal` | 可见性 |
| `b_request_id` / `b_trace_id` | `varchar(64)` | — | 请求 / 链路 ID |
| `b_payload_text` | `nvarchar(max)` | — | 事件载荷 |
| `b_occurdatetime` | `datetime2` | `getdate()` | 发生时间 |
索引:`ix_s_log_audit_module(b_module_id, b_data_id, b_occurdatetime)`、`ix_s_log_audit_occur(b_occurdatetime, b_id)`。
### 6.2 `s_log_audit_field` — 字段级前后值
| 列 | 说明 |
|---|---|
| `b_id` | PK |
| `b_event_id` | **关联 `s_log_audit.b_id`** |
| `b_module_id` / `b_data_id` | 冗余存储,便于直接按数据查 |
| `b_field` | 字段编码 |
| `b_field_label` | 字段名称 |
| `b_before_value` / `b_after_value` | **原始值** |
| `b_before_display` / `b_after_display` | **展示值**(如枚举翻译后的文本) |
| `b_visibility` | 可见性 |
索引 `ix_s_log_audit_field_event(b_event_id, b_id)`。
> **原始值与展示值分开存**:原始值用于比对与回放,展示值用于界面直接呈现,避免查日志时再翻译一遍。
### 6.3 `s_log_login` — 登录日志
| 列 | 说明 |
|---|---|
| `b_id` | PK(UUIDv7) |
| `b_user_id` | 用户账号(**失败时也可能有值**) |
| `b_login_type` | 登录方式 |
| `b_login_result` | `tinyint`,0 失败 / 1 成功 |
| `b_fail_reason` | 失败原因(「账号不存在」/「密码错误」) |
| `b_ip` / `b_ip_location` | 来源 IP 与归属地 |
| `b_browser` / `b_browser_version` | 浏览器 |
| `b_os` / `b_os_version` | 操作系统 |
| `b_device_type` | 设备类型 |
| `b_user_agent` | 原始 UA |
| `b_session_id` | 会话 ID(成功时) |
| `b_request_id` / `b_trace_id` | 请求 / 链路 ID |
| `b_occurdatetime` | 发生时间 |
索引:`ix_s_log_login_occur`、`ix_s_log_login_user(b_user_id, b_occurdatetime)`。
> **对外提示不区分「账号不存在」与「密码错误」(都是 401 同一句),但日志里分别记录** —— 既能防账号探测,又能支撑排查。
### 6.4 `s_log_technical` — 技术日志
| 列 | 说明 |
|---|---|
| `b_id` | PK(UUIDv7) |
| `b_user_id` | 用户编码 |
| `b_sourcetype` | 来源类型 |
| `b_loglevel` | 日志级别 |
| `b_logcategory` | 日志分类 |
| `b_operation` | 操作名 |
| `b_module_id` | 模块编码 |
| `b_request_id` / `b_trace_id` | 请求 / 链路 ID |
| `b_server_node` | 服务节点 |
| `b_duration_ms` | 耗时(毫秒) |
| `b_result_code` | 结果码 |
| `b_error_code` / `b_error_message` | 错误码与说明 |
| `b_exception_text` | 异常堆栈 |
| `b_context_text` | 上下文 |
| `b_occurdatetime` | 发生时间 |
索引:`ix_s_log_technical_occur`、`ix_s_log_technical_trace(b_trace_id, b_occurdatetime)`——按链路 ID 串起一次请求的全部日志。
## 7. 附件
### 7.1 `bf_files` — 文件记录
| 列 | 类型 | 说明 |
|---|---|---|
| `subid` | `bigint` PK | 雪花 ID(**应用层取号,非自增**) |
| `father` | `bigint` | 宿主业务主键,**不建外键** |
| `mx_filename` | `nvarchar(500)` | 原始文件名 |
| `mx_filesize` | `varchar(50)` | 文件大小 |
| `mx_fileext` | `varchar(50)` | 扩展名 |
| `mx_mapfilename` | `nvarchar(500)` | **访问地址**(预览/下载直接用) |
| `mx_moduleid` | `varchar(50)` | 模块编码 |
| `mx_cate_id` | `bigint` | 分类 ID(关联 `bf_files_category.b_id`) |
| `mx_xh` / `mx_sh` | `int` / `tinyint` | 顺序 / 审核标记(保留,当前未用) |
| `mx_inputuser_id` / `mx_inputdatetime` | — | 录入人 / 录入时间 |
| `mx_shuser_id` / `mx_shdatetime` | — | 审核人 / 审核时间(保留) |
| `mx_cutfilename` / `mx_cutfilesize` | — | 缩略图文件名 / 大小(保留) |
| `mx_updateuser_id` / `mx_updatedatetime` | — | 最后修改人 / 时间(保留) |
| `mx_orderid` / `mx_bz` | — | 排序 / 备注(保留) |
索引 `ix_bf_files_host(father, mx_moduleid, mx_cate_id, mx_xh, subid)` —— 列表查询按宿主 + 模块 + 分类过滤。
> **旧库遗留字段** `mx_sh*` / `mx_cut*` / `mx_orderid` / `mx_bz` / `mx_update*` 保留以兼容历史数据迁移,**新代码不使用**(写入 0 或空值)。但 `subid` / `father` / `mx_*` 的列名已被前后端实现引用,保持现状。
### 7.2 `bf_files_category` — 文件分类
| 列 | 类型 | 说明 |
|---|---|---|
| `b_id` | `bigint` PK | 雪花 ID(与 `bf_files.mx_cate_id` 类型一致) |
| `b_module_id` | `varchar(50)` | 所属模块;**为空表示跨模块通用分类** |
| `b_name` | `nvarchar(50)` | 分类名称 |
| `b_i18n` | `varchar(150)` | 多语言资源键 |
| `b_files_type` | `varchar(50)` | 文件类型 |
| `b_xh` / `b_canuse` | `int` / `tinyint` | 顺序 / 启用 |
| `b_bz` | `nvarchar(200)` | 备注 |
| 审计字段 | — | `b_created_*` / `b_updated_*` |
索引 `ix_bf_files_category_module(b_module_id, b_canuse, b_xh, b_id)`。
分类树按当前模块过滤:`b_module_id IS NULL OR b_module_id = 当前模块`。
## 8. 工作流
工作流是**独立子系统**:配置表用 `varchar` 业务编码主键,运行表用 **UUIDv7**。全部 13 张表。
```text
配置:wf_definition → wf_version → { wf_node, wf_edge, wf_actor_rule }
wf_binding(模块+事件 → 版本)
运行:wf_instance → wf_node_run → wf_task → wf_action
wf_cc(抄送) wf_delegation(委托) wf_outbox(通知)
```
### 8.1 `wf_definition` — 流程定义
稳定逻辑身份,**执行内容在版本表**。
`b_id varchar(50)` PK(编码,**创建后不可修改**)/ `b_name nvarchar(200)` / `b_i18n` / `b_canuse` / `b_bz` / 审计字段。
索引:`ix_wf_definition_canuse(b_canuse, b_id)`。
### 8.2 `wf_version` — 流程版本
| 列 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `b_id` | `varchar(50)` PK | — | 版本 ID |
| `b_definition_id` | `varchar(50)` | — | 所属定义 |
| `b_version_no` | `int` | — | 版本号(同流程内递增) |
| `b_status` | `varchar(20)` | `draft` | `draft` / `published` / `retired` |
| `b_schema_version` | `int` | 1 | 配置结构版本 |
| `b_canvas_json` | `nvarchar(max)` | — | 设计器布局,**不参与执行** |
| `b_checksum` | `varchar(128)` | — | 发布时计算的配置摘要 |
| `b_published_by` / `b_published_at` | — | — | 发布人与时间 |
**四个索引,其中两个是筛选唯一索引**:
| 索引 | 作用 |
|---|---|
| `ux_wf_version_draft(b_definition_id) WHERE b_status='draft'` | **同一定义只能有一条草稿** |
| `ux_wf_version_no(b_definition_id, b_version_no)` | 版本号不重复 |
| `ix_wf_version_status` | 按状态查询 |
> **已发布版本不可原地修改**;运行中的实例固定引用提交时的 `b_version_id`。
### 8.3 `wf_node` — 节点
| 列 | 类型 | 说明 |
|---|---|---|
| `b_id` | `varchar(50)` PK | 节点 ID |
| `b_version_id` | `varchar(50)` | 所属版本 |
| `b_node_key` | `varchar(80)` | **版本内稳定编码**(历史记录引用它) |
| `b_name` / `b_i18n` | — | 名称 / 多语言键 |
| `b_node_type` | `varchar(30)` | 见下 |
| `b_approval_mode` | `varchar(30)` | 见下 |
| `b_reject_mode` | `varchar(30)` | 见下 |
| `b_due_hours` | `decimal(10,2)` | **审批时限(小时)**;不配则该节点任务永不过期 |
| `b_config_json` | `nvarchar(max)` | 扩展配置(`requiredCount` / `requiredRatio` / `formPolicy` 等) |
| `b_xh` / `b_canuse` | — | 顺序 / 启用 |
| 列 | 取值 |
|---|---|
| `b_node_type` | `start` / `user_task` / `cc_task` / `service_task` / `exclusive_gateway` / `parallel_gateway` / `end` |
| `b_approval_mode` | `single` / `any` / `all` / `serial` / `n_of_m` / `percentage` |
| `b_reject_mode` | `terminate` / `return_initiator` / `return_previous` |
唯一索引 `ux_wf_node_key(b_version_id, b_node_key)`——**同一版本内节点编码唯一**。
### 8.4 `wf_edge` — 连线
| 列 | 说明 |
|---|---|
| `b_id` | 连线 ID(`varchar(50)` PK) |
| `b_from_node_key` / `b_to_node_key` | 两端节点编码 |
| `b_condition_json` | 条件 AST,**留空 = 无条件(默认边)** |
| `b_priority` | 判断优先级,**小的先判断** |
| `b_name` | 连线名称(画布标签) |
| `b_canuse` / `b_bz` | — |
索引:`ix_wf_edge_from`、`ix_wf_edge_to`(都含 `b_canuse, b_priority`)。
### 8.5 `wf_actor_rule` — 审批人规则
| 列 | 说明 |
|---|---|
| `b_id` | 规则 ID |
| `b_node_key` | 适用节点 |
| `b_actor_type` | 解析类型,见下 |
| `b_actor_ref` | 用户 / 岗位 / 角色 / 字段编码 |
| `b_resolve_json` | 解析参数 |
| `b_condition_json` | **规则适用条件 AST** |
| `b_fallback_type` | 解析为空时的兜底:`none` / `admin` / `initiator` / `custom` |
| `b_fallback_ref` | 兜底引用 |
| `b_priority` | 优先级——**串行会签的顺序来源** |
| `b_canuse` / `b_bz` | — |
| `b_actor_type` | 含义 | 依赖字段 |
|---|---|---|
| `user` | 指定用户 | `b_actor_ref` |
| `initiator` | 发起人 | — |
| `initiator_manager` | 发起人直属领导 | **`b_user.b_leader_id`** |
| `dept_manager` | 部门负责人 | **`b_dept.b_manager_id`** |
| `position` | 岗位 | `b_user_position` |
| `org_role` | 组织角色 | `b_user_org_role` |
| `field_user` | 业务字段里的用户 | `b_actor_ref` 指定的字段 |
| `custom` | 自定义解析器 | — |
索引 `ix_wf_actor_rule_node(b_version_id, b_node_key, b_canuse, b_priority, b_actor_type)`。
> **规则顺序即优先级顺序,也是串行会签的先后**。组织主数据(岗位 / 角色表)不存在时对应类型会降级为空并记原因,由 `b_fallback_type` 兜底。
### 8.6 `wf_binding` — 模块绑定
决定「哪个模块的哪个业务事件走哪个流程版本」。
| 列 | 说明 |
|---|---|
| `b_module_id` | 模块编码(`data` / `virtual` 模块) |
| `b_event` | `submit` / `resubmit` / `change` |
| `b_version_id` | **必须是已发布版本** |
| `b_match_json` | 业务匹配条件 AST |
| `b_org_scope_json` | 公司 / 组织范围 |
| `b_status_field` | 业务状态列名(状态投影写这一列) |
| `b_status_map_json` | 流程状态 → 业务状态值映射 |
| `b_priority` | 匹配优先级,小的先匹配 |
| `b_effective_from` / `b_effective_to` | 生效区间 |
| `b_canuse` / `b_bz` / 审计字段 | — |
**关键筛选唯一索引**:
```sql
ux_wf_binding_active_priority(b_module_id, b_event, b_priority) WHERE b_canuse = 1
```
→ **同一模块同一事件下不能有两条同优先级的启用绑定**(否则无法确定用哪个流程)。
另有 `ix_wf_binding_select`、`ix_wf_binding_version`。
### 8.7 `wf_instance` — 流程实例
| 列 | 类型 | 说明 |
|---|---|---|
| `b_id` | `uniqueidentifier` PK | UUIDv7 |
| `b_definition_id` / `b_version_id` / `b_binding_id` | `varchar(50)` | 定义 / 版本(提交时固定)/ 命中的绑定 |
| `b_module_id` / `b_event` | `varchar` | 业务模块与事件 |
| `b_business_id` | `varchar(50)` | **业务主键,统一按字符串保存** |
| `b_business_no` | `nvarchar(200)` | 业务单号(列表展示) |
| `b_org_id` | `varchar(50)` | 公司 / 组织编码 |
| `b_initiator_id` / `b_initiator_dept_id` | `varchar(50)` | 发起人及其部门 |
| `b_status` | `varchar(30)` | 见下 |
| `b_context_json` | `nvarchar(max)` | 运行变量(条件分支与审批人解析的输入) |
| `b_snapshot_json` | `nvarchar(max)` | 提交时业务快照 |
| `b_current_summary_json` | `nvarchar(max)` | 列表展示用当前节点摘要 |
| `b_row_version` | `bigint` 默认 0 | **乐观锁版本号** |
| `b_idempotency_key` | `varchar(100)` | 幂等键 |
| `b_started_at` / `b_completed_at` | `datetime2` | 开始 / 结束时间 |
| `b_status` | 含义 |
|---|---|
| `running` | 运行中 |
| `approved` / `rejected` / `returned` | 已通过 / 已驳回 / 已退回 |
| `withdrawn` / `cancelled` | 已撤回 / 已取消 |
| `error` | 异常待处理(审批人解析为空、路由走不通、配置损坏) |
> 列注释里还列了 `expired`,但**实现中实例从不进入该状态**——超时只作用于任务(见 §8.9)。
**关键筛选唯一索引**:
```sql
ux_wf_instance_idempotency(b_module_id, b_business_id, b_idempotency_key)
WHERE b_idempotency_key IS NOT NULL
```
→ 送审幂等的**唯一保证**。代码靠捕获唯一键冲突(SQL 2601/2627/23000)回查既有实例返回 `duplicated`,**索引不存在则幂等完全失效**。
另有 `ix_wf_instance_business`、`ix_wf_instance_initiator`、`ix_wf_instance_version`。
### 8.8 `wf_node_run` — 节点运行
| 列 | 类型 | 说明 |
|---|---|---|
| `b_id` | `uniqueidentifier` PK | UUIDv7 |
| `b_instance_id` | `uniqueidentifier` | 所属实例 |
| `b_node_key` | `varchar(80)` | 节点编码 |
| `b_run_no` | `int` 默认 1 | **同一节点的第几次运行**(退回、循环用) |
| `b_status` | `varchar(30)` | `pending` / `running` / `completed` / `skipped` |
| `b_required_count` | `int` | 应签人数(按比例时激活时换算) |
| `b_completed_count` / `b_rejected_count` | `int` 默认 0 | 已通过 / 已驳回人数 |
| `b_entered_at` / `b_exited_at` | `datetime2` | 进入 / 离开时间 |
| `b_actor_snapshot_json` | `nvarchar(max)` | **审批人规则快照**(含解析结果与说明) |
> **节点状态是 `completed`,不是任务的 `approved`** —— 若这里写 `approved`,并行汇合的「入线来源节点都已完成」判定与「退回上一节点」查找都会失效。
> **审批人快照必须落库**:组织架构以后变了,也不能改动已产生的历史审批责任。
### 8.9 `wf_task` — 待办任务
| 列 | 类型 | 说明 |
|---|---|---|
| `b_id` | `uniqueidentifier` PK | UUIDv7 |
| `b_instance_id` / `b_node_run_id` | `uniqueidentifier` | 实例 / 节点运行(会签聚合判断用) |
| `b_node_key` | `varchar(80)` | 节点编码 |
| `b_actor_rule_id` | `varchar(50)` | 命中的审批人规则 |
| `b_assignee_type` | `varchar(30)` | `user` / `position` / `role` / **`delegate`**(委托物化) |
| `b_assignee_id` | `varchar(50)` | **实际受理人** |
| `b_seq` | `int` 默认 0 | 同节点内处理顺序(**串行会签生效**) |
| `b_status` | `varchar(30)` | 见下 |
| `b_due_at` | `datetime2` | **应处理时间**(由节点 `b_due_hours` 算出) |
| `b_claimed_by` / `b_claimed_at` | — | 抢占人与时间 |
| `b_completed_by` / `b_completed_at` | — | 处理人与时间 |
| `b_rule_snapshot_json` | `nvarchar(max)` | 审批人解析快照(委托时含原审批人与委托单号) |
| `b_status` | 含义 |
|---|---|
| `waiting` | **串行会签排队中**(未轮到) |
| `pending` / `claimed` | 待处理 / 已抢占 |
| `approved` / `rejected` | 已通过 / 已驳回 |
| `cancelled` | 被撤回、取消,或同节点他人已处理而取消 |
| `expired` | **已超期**(仅由超时扫描写入) |
索引:`ix_wf_task_assignee(b_assignee_id, b_status, b_due_at, b_id)`(待办列表)、
`ix_wf_task_due(b_status, b_due_at, b_id)`(**超时扫描**)、
`ix_wf_task_node_run(b_node_run_id, b_status, b_seq, b_id)`(串行推进)、
`ix_wf_task_instance`、`ix_wf_task_completed_by`(已办列表)。
> **`b_due_at` 为空 = 永不超时**:节点没配 `b_due_hours` 时不设应处理时间。
### 8.10 `wf_action` — 审批动作历史
| 列 | 说明 |
|---|---|
| `b_id` | PK(UUIDv7) |
| `b_instance_id` | 实例 |
| `b_node_run_id` / `b_task_id` | 节点运行 / 任务(**实例级动作为 null**) |
| `b_action` | 动作编码,见下 |
| `b_operator_id` | 操作人(**系统动作为 null**) |
| `b_from_status` / `b_to_status` | 变更前后状态 |
| `b_comment` | 审批意见 |
| `b_payload_json` | 动作载荷(命中的条件、边、加签位置等) |
| `b_request_id` / `b_trace_id` | 请求 / 链路 ID |
| `b_occurdatetime` | 发生时间 |
| `b_action` | 含义 |
|---|---|
| `submit` / `approve` / `reject` / `return` | 送审 / 通过 / 驳回 / 退回 |
| `withdraw` / `cancel` | 撤回 / 取消 |
| `claim` / `transfer` / `add_approver` / `urge` | 抢占 / 转交 / 加签 / 催办 |
| `cc` | 抄送 |
| **`activate`** | 串行会签激活下一位 |
| **`service`** | 服务节点自动完成 |
| **`route`** | 分支点路由留痕 |
| **`expire`** | 任务超期(系统动作,操作人为 null) |
**筛选唯一索引**:
```sql
ux_wf_action_request(b_request_id) WHERE b_request_id IS NOT NULL
```
→ 审批幂等的保证(与 `wf_instance` 的幂等键同理)。
> 该列**无 `CHECK` 约束**,加动作码不需要改表;但下游若按枚举过滤需同步。
> **`wf_action` 是不可变历史**,与 `s_log_audit` 分工:前者记流程动作,后者记数据变更。
### 8.11 `wf_cc` — 抄送
`b_id`(PK) / `b_instance_id` / `b_node_run_id` / `b_node_key` / `b_recipient_id` / `b_actor_rule_id` / `b_status`(`unread` 默认 / `read`)/ `b_read_at` / `b_rule_snapshot_json`。
索引:`ix_wf_cc_recipient(b_recipient_id, b_status, b_created_at, b_id)`(抄送列表)、`ix_wf_cc_instance`。
> **抄送节点不产生待办**,也不参与节点完成判定(写入即完成)。
### 8.12 `wf_delegation` — 委托
| 列 | 说明 |
|---|---|
| `b_id` | `varchar(50)` PK |
| `b_from_user_id` / `b_to_user_id` | 委托 / 受托人 |
| `b_scope_json` | **委托范围**:`{moduleIds, actions, orgIds}`,**空数组 = 不限** |
| `b_start_at` / `b_end_at` | 生效 / 失效时间 |
| `b_reason` | 委托原因 |
| `b_canuse` / 审计字段 | — |
索引 `ix_wf_delegation_user(b_from_user_id, b_canuse, b_start_at, b_end_at, b_to_user_id)`。
> **委托在任务生成时就物化**:候选人若有生效中的委托,任务直接建给代理人(`b_assignee_type='delegate'`),这样代理人能在待办里看到它。原审批人与委托单号进任务快照,动作期复核 `actions` 范围。
### 8.13 `wf_outbox` — 通知消息
| 列 | 默认 | 说明 |
|---|---|---|
| `b_id` | — | PK(UUIDv7) |
| `b_event_code` | — | 事件编码 |
| `b_aggregate_type` | — | `instance` / `task` |
| `b_aggregate_id` | — | 聚合 ID |
| `b_payload_json` | — | 消息载荷 |
| `b_status` | `pending` | `pending` / `sent` / `failed` |
| `b_retry_count` | 0 | 重试次数 |
| `b_next_retry_at` | — | 下次重试时间 |
| `b_last_error` | — | 最后错误 |
| `b_occurdatetime` | — | 发生时间 |
索引 `ix_wf_outbox_dispatch(b_status, b_next_retry_at, b_occurdatetime, b_id)`。
**事件码词表**:
| 事件码 | 触发 |
|---|---|
| `instance.submitted` / `instance.withdrawn` / `instance.cancelled` | 实例级状态变化 |
| `instance.approved` / `instance.rejected` / `instance.returned` | 实例结束 |
| `instance.error` | 实例进入异常 |
| `task.created` / `task.activated` | 建任务 / 串行激活下一位 |
| `task.transferred` / `task.approver_added` / `task.urged` | 转交 / 加签 / 催办 |
| `task.expired` | **任务超期**(超时扫描写) |
| `cc.created` | 抄送产生 |
| `instance.projection_skipped` | 业务主键类型不匹配,跳过投影 |
| `instance.projection_missing` | **状态投影影响行数 ≠ 1**(静默丢更新的留痕) |
| `audit.skipped` | `s_log_audit` 不存在,审计无法落库 |
> 最后三个是**降级留痕**:出问题时不会阻断审批,但会在 outbox 里留下可追溯的记录。
## 9. 超时机制(与表结构的关系)
超时是唯一会**主动改任务状态**的后台行为,涉及 `wf_task` 与上述三张表:
```text
节点 b_due_hours → 建任务时算出 b_due_at
↓
[后台扫描,默认 5 分钟]
选出 b_status in ('pending','claimed') and b_due_at < sysutcdatetime()
↓
wf_task.b_status = 'expired'
wf_action += 一条 expire(操作人为 null)
wf_outbox += task.expired
```
**明确不做**:不动 `wf_node_run`、不动 `wf_instance`(仍 `running`)、不自动通过/否决、不置 `error`、**不提升串行后继**。
**几个由此而来的结论**:
- 到期任务从待办列表(只查 `pending`/`claimed`)**自动消失**,但实例仍在运行,需管理员加签或转派。
- **没有「我的超期任务」出口**:`doneList` 查的是 `b_completed_by`,超期任务没人处理过,所以也查不到。
- **串行链上当前审批人超期会停住**:那把唯一的 `pending` 失效后,`waiting` 的不会自动提升,整条链停在那里。
- 配 `erp.workflow.expire-sweep-interval: 0s` 可**完全关闭**扫描。
## 10. 视图
查询入口以视图为主,业务模块通常指向视图而不是物理表:
| 视图 | 说明 |
|---|---|
| `v_wf_definition` / `v_wf_version` / `v_wf_binding` / `v_wf_instance` | 工作流配置与实例(配置侧列表用) |
| `v_s_log_audit` / `v_s_log_login` / `v_s_log_technical` | 日志查询 |
| `v_b_othercompany` / `v_b_contact` / `v_b_user` | 业务与主数据 |
+91 -14
View File
@@ -19,6 +19,35 @@ description: 高性能虚拟滚动表格,支持排序、固定列、多选、
<demo vue="table/basic.vue" />
## 固定列
列上写 `fixed: 'left' | 'right'` 即可固定(底座用 `position: sticky` 实现,非双表格叠加)。三件需要知道的事:
- **序号列与选择列已经固定在左侧**,不需要业务再配;业务列的 `fixed: 'left'` 会自动接在它们后面,`fixed: 'right'` 的列要排在 `columns` 最后。
- **固定列必须写 `width`**:左固定列的吸附位移由「它左侧那些固定列的列宽累加」算出,缺宽度就没有位移(右固定列若不需要吸附效果可以不写,但统一写上最省心)。
- 固定列被滚动内容遮住时才画阴影(`fixedColShadow`,默认关)。它不影响功能,但冻结列与滚过去的列底色同为白、又紧贴在一起,没有这层渐变就看不出边界在哪,业务侧建议打开。
固定列只有在**总列宽超过容器宽**时才看得出效果 —— 整表都看得见时,固定与不固定没有任何区别。
固定列的底色是**不透明的表格基色**(否则横滚时下方内容会透上来),行 hover / 当前行高亮改用内阴影叠色;这层叠色与**单元格选中描边并存**(选中框不会被行色替换掉),正在编辑的那一格则与普通列一致 —— 纯基色 + 焦点环,不叠行色。
同理还有**斑马纹**:开了 `stripe` 时,固定列(含序号列)也会跟着条纹 —— 冻结列的底色必须不透明,组件在它上面补了一层同色内阴影把条纹接上(否则就是「整表隔行灰、只有序号列没有」)。这层处理对**宿主页自己的行底色样式**(例如文档站的正文斑马纹)同样有效。
### ⚠ 固定列 + 横向虚拟的已知限制
同时开**固定列**与**横向虚拟**(列数 > 20 且显式要 `virtual-x`)时,**快速横向拖动**(拖滚动条、触控板甩)会出现一帧位移:被虚拟掉的列在表体左侧留了一个宽度可变的占位,而它是**异步**更新的(库内部用 rAF)。滚动已经到位、占位还是旧宽度的那一帧里,固定列的静态位置被占位推着走 —— 而 `position: sticky` 只能把格子往右收、不能把它往左拉回来,于是**左固定列**先偏一下再吸回去(实测偏 258 → 1628,等于占位全长);右固定列偏差为 0,所以现象只出现在左侧。
这是「占位 + sticky」这套布局的固有代价,组件这层无法在绘制前把它抹平(试过在 scroll 里提前重算窗口:无效,且会让表体多出中间态)。两条出路:
- 要**绝对稳**:别让二者同时存在 —— `:virtual-x="false"`(列数不多时没有任何代价);
- 要**根治**:需要把左占位挪到固定列之后(上游 `stk-table-vue` 的行布局改动)。
```vue
<!-- 左冻结首列、右冻结末列 -->
<G3Table :columns="columns" :data-source="data" fixed-col-shadow />
```
## 虚拟滚动与性能
组件内置两条门控,**自动**决定是否启用虚拟滚动,不需要业务手动切换:
@@ -58,15 +87,30 @@ description: 高性能虚拟滚动表格,支持排序、固定列、多选、
| --- | --- |
| `Enter` | **全选当前内容**(便于直接覆写),不提交 |
| `Esc` | **取消编辑**:把该行恢复到进入编辑前的值,然后退出编辑 |
| 失焦(点别处 / Tab) | **提交**并抛出 `change` |
| `Tab` / `Shift+Tab` | **提交并跳到下一个 / 上一个可编辑格**(见下方「跳格」) |
| 失焦(点别处) | **提交**并抛出 `change` |
`Enter` 不做提交是刻意的:单元格里的值通常要整体替换,全选后直接输入即可,不必先手动清空。提交一律靠失焦。
### 跳格
Tab / Shift+Tab 只在**可编辑格**之间走动:没有 `editor` 的列、以及被 `disabled` 按行禁用的格都会被跳过;行尾自动接到下一行的第一个可编辑格,到表格两头则**循环**(Excel / AG Grid 的手感)。全表没有任何可编辑格时不接管,Tab 保持浏览器默认行为。
为什么不直接交给浏览器的 Tab 序:
- 编辑态的编辑器**只挂在当前编辑行**上(其它行是纯文本,不可聚焦),浏览器按文档顺序在表格里找不到「下一格」,会把焦点送到页面其它元素上 —— 表现出来就是「按了 Tab 但没选中下一个」;
- `select` 更明显:它展开时会把 DOM 焦点移进**下拉浮层**,而浮层是 `Teleport` 到 `body` 的,浮层里的按键事件到不了表格。所以表格在 `document` 的捕获阶段接管 Tab,并用 ARIA 关联(浮层 id ↔ 触发器 `aria-controls`)把浮层追回它所属的单元格。
还有两种「焦点此刻不在编辑器上,但 Tab 仍属于表格」的情况,同样会被接管:
- **编辑行 / 编辑列被虚拟滚动滚出渲染窗口**(编辑器随之卸载、焦点掉到 `body`):这时按 Tab 会把表格**滚回编辑行**并继续前进一格,而不是把焦点送出表格(放行的话浏览器会按文档顺序落到页面上其它元素上)。判据是「编辑格此刻不在 DOM 里」——编辑格还在渲染时(例如点了页面空白)不接管,页面 / 表单的 Tab 序不受影响。
- **长按 Tab / 连按 Tab**(`keydown` 带 `repeat`,或间隔小于底座 100ms 的连按防抖):底座的导航优化在长按期间会**把编辑器全卸载**(连「当前编辑行」都置空,等松手再挂回来),焦点因此落在 `body`。这时 Tab 仍然被认作表格的,一格一格继续走,松手后编辑器落到最后一格。从**只读列**起步(如「订舱号」,没有编辑器)也走这条路径。
| 编辑器类型 | 说明 |
| --- | --- |
| `text` / `number` / `password` / `textarea` | 用 [Input](/ui/form/input) 渲染 |
| `select` | 用 [Select](/ui/form/select) 渲染;`options` 可以是数组,也可以是接收当前行的函数 |
| `checkbox` | 点击即切换,无需先激活整行;约定 `1` = 选中、`0` = 未选中(与后端 tinyint 对齐) |
| `checkbox` | 点击即切换,无需先激活整行;约定 `1` = 选中、`0` = 未选中(与后端 tinyint 对齐)。切换后**该格同时成为当前格**(行高亮 + 选中框 + 进入该行编辑),与点击其它单元格行为一致 |
编辑器的输入值先写进内部草稿,**失焦才落库并抛出 `change`**。这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。用 `editable={false}` 可整体禁用编辑。
@@ -78,7 +122,7 @@ description: 高性能虚拟滚动表格,支持排序、固定列、多选、
- 单元格几何在进入 / 离开编辑时**完全不变**——编辑器的盒高就等于 `rowHeight`,同一行的纯文本列不会跟着跳;
- 水平内边距由单元格统一提供(编辑器和纯文本格用同一套网格),**进出编辑态零位移**;
- 视觉提示只在**焦点、hover 与校验**时出现,且都不占用布局空间:
- 焦点 → 单元格内嵌的主色焦点环(`td:focus-within` 的内阴影)+ 输入框自身的焦点光晕;
- 焦点 → 单元格内嵌的主色焦点环(内阴影)+ 输入框自身的焦点光晕;**正在编辑的那一格始终有环**(判据是编辑器上的标记类,而不是 `:focus-within`——`select` 展开时焦点会被移进浮层,用 `:focus-within` 会时有时无);
- hover → 格内极淡填充;
- 校验(`status` = `error` / `warning`)→ 文字色 + 1px 内描边。
@@ -111,17 +155,44 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }
开发模式下,进入编辑时会检测并打印一条警告指明改法(每个组件实例只警告一次)。
### 编辑期的只读列
### 不可改的格子(灰底,常驻)
某一列没有配 `editor` 时,它在编辑期**不可修改**。为了让人一眼看出「这格点不动」,
该行里**没有编辑器的格子会自动铺一层灰底**、文字也降一档亮度——
不需要业务额外配任何东西,也不需要加锁图标。
在**支持行内编辑的表**里:凡是「现在改不了」的格子,都铺一层灰底 + 文字降一档亮度,
不需要业务额外配任何东西、也不加锁图标。三种情形统一走这套视觉:
灰底只出现在**正在编辑的那一行**(其余行保持原样),且用 `--td-bgc` 叠中性色得到,
所以亮 / 暗主题自动跟随。
| 情形 | 怎么配 | 何时变灰 |
| --- | --- | --- |
| 整列只读 | 列上**没有** `editor` | 始终 |
| 整列禁用 | `editor.disabled: true` | 始终 |
| 按行禁用 | `editor.disabled: (row) => boolean` | 该行判定为 true 时(动态) |
**前提:这张表里得先有人能编辑。** 灰底的语义是「这一格现在改不了」,它靠「旁边有可改的格子」传达意义;所以下面两种情况**不铺灰**:
- 表格里**一个可编辑列都没有**(纯展示表:列上都没写 `editor`)—— 每格都改不了时,灰底不指向任何东西,铺满反而让整张表看上去像「禁用状态」;
- `editable="false"`(编辑功能整体关掉)—— 同理,此时每一格都不可编辑。
按行禁用是最常用的那种:「已放舱之后状态不许再改」这类条件随行数据变化:
```ts
{
dataIndex: 'status',
title: '状态',
editor: { type: 'select', options: STATUS_OPTIONS, disabled: (row) => row.status === 2 },
}
```
几件需要知道的事:
- 判定是**行数据的派生值**,每次渲染重算 —— 数据变了灰底自己消失 / 出现,业务不用管;
- 整列只读 / 整列禁用的列**不会**被注入 `customCell`(走 td 上的类名),零运行时开销;
只有「按行禁用」的列会常驻注入,代价落在这些列上;
- 「既禁用、又校验不过」的格子显示为**灰**:既然改不了,「这里错了」提示了也没用;
- 灰底用 `--td-bgc` 叠中性色得到,亮 / 暗主题自动跟随。
<demo vue="table/editable.vue" />
下面这张 demo 里,第 2 行(状态=停用)的「备注」列就是**按行禁用**的灰格 —— 不点它也能看出来。
## 操作列
列上加 `actions` 渲染成一排小按钮;`type: 'more'` 的项会收进「更多」下拉。带 `confirm` 的操作先弹确认框,确认后才执行 `onClick`。
@@ -150,11 +221,13 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }
| `rowHeight` | `number` | `30` | 行高,同时驱动虚拟滚动计算 |
| `headerRowHeight` | `number` | `34` | 表头行高 |
| `bordered` | `boolean` | `true` | 是否显示边框(透传给底座的 `bordered: true`)。边框由底座绘制,不再是自绘外框 + 圆角 |
| `stripe` | `boolean` | `false` | 斑马纹(隔行底色)。**固定列会一起上条纹**(见「固定列」):库只把条纹画在 `tr` 上,冻结列的底色必须不透明,组件在它上面补一层同色内阴影接上 |
| `fixedColShadow` | `boolean` | `false` | 固定列被滚动内容遮挡时画 10px 渐变阴影(见「固定列」) |
| `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` | 序号列 |
| `seq` | `boolean \| { title?, width? }` | `true` | 序号列,**固定左侧**(见「固定列」) |
| `activeRowKey` | `string \| number \| null` | `null` | 当前高亮行 |
| `editable` | `boolean` | `true` | 行内编辑总开关 |
| `editorSeamless` | `boolean` | `true` | 编辑器无缝嵌入单元格:格内无外间距、无常驻边框,只有焦点 / hover / 校验态才给视觉提示。传 `false` 回到留间距 + 自身描边的形态 |
@@ -215,7 +288,7 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }
布尔 `true` 被当作「通过」、`false` 也被当作「通过」。要让布尔参与判错,
必须返回字符串。这一点容易踩,故单独说明。
- **校验是派生值,不是一次性状态**:每次渲染都按当前行数据重算,
所以用户把空值改成合法值后,红底与角标在下次渲染就自动消失,
所以用户把空值改成合法值后,红底在下次渲染就自动消失,
**不需要**业务手动清状态,组件内部也不维护任何 error 注册表。
- **校验函数抛错会被吞掉**并降级为「通过」(仅 DEV 打印警告)。
因为它在渲染期被调用,抛出去会让整格渲染失败,比「没有红底」严重得多。
@@ -224,11 +297,15 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }
| 值 | 效果 |
| --- | --- |
| `'cell'`(默认) | 单元格铺**浅红底** + 右上角 **`!` 角标**;聚焦时焦点环转为错误色 |
| `'inline'` | 只在编辑器自身画文字色与 1px 内描边,**不**铺底、**不**加角标 |
| `'cell'`(默认) | 单元格铺**浅红底 / 浅琥珀底**;**正在编辑的那一格例外**(见下) |
| `'inline'` | 只在编辑器自身画文字色与 1px 内描边,**不**铺底 |
浅红底取自设计原型,适合 ERP 明细这类以单元格为视觉单位的场景;
若表格本身已经很密、或那一列很窄(角标会挤到文字),用 `'inline'` 更克制。
若表格本身已经很密,用 `'inline'` 更克制。
**编辑态让位**(默认形态下的行为):正在编辑的那一格**不铺校验底色**,底色回到基色,校验信号只留**语义色焦点环 + 红字**(铺着底色时输入区被染色、对比度下降,录入时「看不清」)。离开该格后底色自动回来(校验本身是按行数据派生的,值改对了自然消失)。
> 早先这里还有一个右上角的 `!` 角标,**已整体去掉**(实际录入反馈):它画的 `!` 会压到文字上 —— 右对齐的数字列最明显(数字正好顶在角标下面),而为了给它让位留出的 14px 右边距又实打实缩窄了录入区。
### `actions`