This commit is contained in:
oneao committed 2026-10-12 22:25:41 +08:00
1 parent ae3ca3cfa1
commit 7bd5f1dde9
45 files changed
+3414 -229

No files matched your search

+5 -1
View File
@@ -184,7 +184,11 @@ export default defineConfig({
text: '接口文档',
icon: 'cable',
collapsed: false,
items: [{ text: '快速开始', link: '/api/getting-started' }],
items: [
{ text: '快速开始', link: '/api/getting-started' },
{ text: '数据查询', link: '/api/data' },
{ text: '数据写入', link: '/api/save' },
],
},
] satisfies G3SidebarItem[],
},
@@ -0,0 +1,38 @@
<script setup lang="ts">
/**
* API 参数面板 —— 给 markdown 表格套一层「带标题的框」。
*
* 为什么收 slot 而不是数组 prop:
* 仓库约定 API 表格**直接写在 md 里**(见 CODEBUDDY.md「组件文档写作约定」)。
* 若改成 `:rows="[{ name: 'view_name', ... }]"`,表格会退化成一长行 JS 字面量,
* md 源码既不好读也不好 diff。所以这里只负责外壳与版式,表格本体仍由
* markdown 渲染后塞进 slot。
*
* 配套样式见 theme/api.css(.g3-api-panel)。
*
* variant:
* params(默认)参数表 —— 列序约定「参数 / 类型 / 必填 / 说明」,第 2、3 列的样式
* 按位置写,改列序要同步改 api.css。
* bare 只给框和标题,不套列样式。用于形状不同的表(如错误码表:
* code / 含义 / 典型原因 / 定位)—— 若套上参数表的列样式,散文列
* 会被当成「类型」套等宽、被当成「必填」染成品牌色。
*
* ⚠ 刻意不叫 plain:G3ApiPayload 表示「无语气」的类名就是 .g3-api-panel--plain,
* 两者含义不同,同名迟早埋坑。
*/
withDefaults(defineProps<{ title?: string | null; variant?: 'params' | 'bare' }>(), {
title: '请求参数',
variant: 'params',
})
</script>
<template>
<div class="g3-api-panel" :class="`g3-api-panel--${variant}`">
<!-- title 传 null 时不渲染标题栏:表格已经在自己那节标题下面时,
重复一遍标题只是噪音,只保留边框与行分隔线。 -->
<div v-if="title !== null" class="g3-api-panel__head">{{ title }}</div>
<div class="g3-api-panel__body">
<slot />
</div>
</div>
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
/**
* API 报文面板 —— 请求体 / 响应体,按 **body.code** 着色。
*
* 存在理由:原来请求体和响应体都是裸 ```json 代码块,视觉上完全一样,
* 读者分不清哪块是入参哪块是出参;而 `code: 0` 与 `code: 1000` 也同样是
* 一片灰底,全站最想强调的「HTTP 200 也可能是失败」在版式上零体现。
*
* ⚠ 着色依据是 body.code,**不是 HTTP 状态码**。这正是通用 OpenAPI 渲染器
* 做不到的地方:它们的颜色模型绑死了 HTTP 状态码,会把这里的失败响应
* 渲染成绿色成功块。
*
* tone 的推断规则:给了 code 就是「0 成功、其余失败」;语义特殊的码
* (如部分成功)显式传 tone="warn" 覆盖。
*/
import { computed } from 'vue'
type Tone = 'plain' | 'ok' | 'warn' | 'error'
const props = withDefaults(
defineProps<{
/** 面板标题,如「请求体」「响应」 */
title?: string
/** 响应体里的 code;给出时参与 tone 推断,并在标题栏显示 code 徽章 */
code?: number | string
/** 显式指定语气,覆盖由 code 推断的结果 */
tone?: Tone
}>(),
{ title: '响应' },
)
const resolvedTone = computed<Tone>(() => {
if (props.tone) return props.tone
if (props.code === undefined) return 'plain'
return props.code === 0 ? 'ok' : 'error'
})
/** 颜色之外再给一个文字标签:色觉障碍下也能读出成功/失败 */
const toneLabel = computed(() => {
const map: Record<Tone, string> = { plain: '', ok: '成功', warn: '警告', error: '失败' }
return map[resolvedTone.value]
})
</script>
<template>
<div class="g3-api-panel g3-api-panel--payload" :class="`g3-api-panel--${resolvedTone}`">
<div class="g3-api-panel__head">
<span class="g3-api-panel__dot" />
<span>{{ title }}</span>
<code v-if="code !== undefined" class="g3-api-panel__code">code {{ code }}</code>
<span v-if="toneLabel" class="g3-api-panel__tone">{{ toneLabel }}</span>
</div>
<div class="g3-api-panel__body">
<slot />
</div>
</div>
</template>
@@ -0,0 +1,240 @@
/* ==========================================================================
API 文档版式(docs/content/api/**)
--------------------------------------------------------------------------
三处视觉缺口,对应下面三段:
1. 端点标题 `## `POST` `/data/page`` 与 `## 基础约定` 长得一样 → 方法徽章
2. 参数表是裸 markdown 表格(必填列原来是 emoji)→ G3ApiParams 面板
3. 请求体 / 响应体是两块一模一样的灰底 JSON → G3ApiPayload 面板(按 code 着色)
两条自我约束:
- 只用 --vp-c-* 变量,不引第三方样式表。站点其余部分是逐像素对齐 Docus 的
(见 custom.css),API 页必须留在同一套设计语言里。
- 一律逻辑属性(inline-* / block-*),站内已有 RTL 支持(见 content/ui/usage/rtl)。
========================================================================== */
/* ==========================================================================
1. 端点标题
--------------------------------------------------------------------------
写法:## `POST` `/data/page`
用 :has(> code:first-child) 把端点标题与普通 H2 区分开 —— 这既是样式的需要,
也是「为什么不用组件渲染标题」的答案:标题一旦交给 Vue 组件渲染,就不再进
markdown 的 heading token 流,右侧目录(G3DocAside)会丢掉全部条目、锚点
一并消失。所以标题仍是 markdown H2,只在 CSS 上做文章。
代价:方法徽章的颜色没法按 GET/POST 区分(CSS 不能按文本匹配)。当前
/api/** 下全部是 POST,所以是准确的;将来出现混合方法时,在 config.mts 的
markdown.config 里加一个「把首词换成 <span class="g3-api-method--get">」的
小插件即可,slug 由文本 token 决定,不受影响。
作用域:三张 API 页都带 frontmatter `pageClass: g3-api`(VitePress 把该 class
落到根 <div class="Layout"> 上,见主题 Layout.vue:34),下面全部规则都由
.g3-api 兜住,不会漏到组件页去。
========================================================================== */
.g3-api .vp-doc h2:has(> code:first-child) {
/* 覆盖 VitePress 的 .vp-doc h2(上边框 + padding-top):端点标题改为
「下方一条分隔线」,与紧随其后的参数面板构成一组,读起来是一个接口一节。 */
margin-block: 56px 20px;
padding-block: 0 14px;
border-block-start: 0;
border-block-end: 1px solid var(--vp-c-divider);
font-size: 20px;
line-height: 28px;
}
/* 方法徽章:第一个 code */
.g3-api .vp-doc h2:has(> code:first-child) > code:first-child {
padding: 2px 8px;
border-radius: 6px;
background-color: var(--vp-c-brand-soft);
color: var(--vp-c-brand-1);
font-family: var(--vp-font-family-mono);
font-size: 12px;
font-weight: 700;
line-height: 18px;
letter-spacing: 0.06em;
}
/* 路径:第二个 code(nth-of-type 只数 code,不受中间的空格文本与
末尾 header-anchor 的 <a> 影响) */
.g3-api .vp-doc h2:has(> code:first-child) > code:nth-of-type(2) {
padding: 0;
background-color: transparent;
color: var(--vp-c-text-1);
font-family: var(--vp-font-family-mono);
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
}
/* ==========================================================================
2 & 3. 面板(G3ApiParams / G3ApiPayload)
--------------------------------------------------------------------------
结构:标题栏 + 内容两段式。三个语气走同一组自定义变量,默认中性、
ok / warn / error 分别取 success / warning / danger。
⚠ 语气语义是 **body.code**,不是 HTTP 状态码 —— 这正是通用 OpenAPI
渲染器做不到的地方(它们的颜色模型绑死 HTTP 状态码,会把这里的
失败响应渲染成绿色成功块)。
========================================================================== */
.g3-api-panel {
--g3-api-accent: var(--vp-c-text-3);
--g3-api-accent-soft: var(--vp-c-bg-alt);
margin: 16px 0;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
/* 圆角要裁掉子元素底色(标题栏、代码块),但横向滚动不能裁 ——
滚动交给 __body,见下。 */
overflow: hidden;
}
.g3-api-panel--ok {
--g3-api-accent: var(--vp-c-success-1, var(--vp-c-green-1));
--g3-api-accent-soft: var(--vp-c-success-soft, var(--vp-c-green-soft));
}
.g3-api-panel--warn {
--g3-api-accent: var(--vp-c-warning-1, var(--vp-c-yellow-1));
--g3-api-accent-soft: var(--vp-c-warning-soft, var(--vp-c-yellow-soft));
}
.g3-api-panel--error {
--g3-api-accent: var(--vp-c-danger-1, var(--vp-c-red-1));
--g3-api-accent-soft: var(--vp-c-danger-soft, var(--vp-c-red-soft));
}
.g3-api-panel__head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background-color: var(--vp-c-bg-soft);
border-block-end: 1px solid var(--vp-c-divider);
color: var(--vp-c-text-2);
font-size: 12px;
font-weight: 600;
line-height: 20px;
}
/* 语气点:与语气同色。文字标签(成功/失败)与它并存,
不让颜色成为唯一的区分手段。 */
.g3-api-panel__dot {
flex: 0 0 auto;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--g3-api-accent);
}
.g3-api-panel--ok .g3-api-panel__head,
.g3-api-panel--warn .g3-api-panel__head,
.g3-api-panel--error .g3-api-panel__head {
color: var(--g3-api-accent);
}
.g3-api-panel__tone {
font-weight: 500;
opacity: 0.9;
}
/* code 徽章:仅 G3ApiPayload 传了 code 时出现 */
.g3-api-panel__code {
padding: 1px 7px;
border-radius: 9999px;
background-color: var(--g3-api-accent-soft);
color: inherit;
font-family: var(--vp-font-family-mono);
font-size: 11px;
font-weight: 600;
}
.g3-api-panel__body {
/* 宽表横向滚动由这一层承担:VitePress 给 .vp-doc table 设了
display:block + overflow-x:auto,那会让 table 自己成为滚动容器
(custom.css 里已记录过它导致 sticky 表头失效的问题)。 */
overflow-x: auto;
}
/* --- 参数表:去掉 .vp-doc 给 markdown 表格画的四边框与上下留白 ------ */
.g3-api-panel__body table {
display: table;
width: 100%;
margin: 0;
}
.g3-api-panel__body table th,
.g3-api-panel__body table td {
border: 0;
border-block-end: 1px solid var(--vp-c-divider);
padding: 8px 14px;
background-color: transparent;
}
.g3-api-panel__body table tr:nth-child(2n) {
background-color: transparent;
}
.g3-api-panel__body table tr:last-child td {
border-block-end: 0;
}
.g3-api-panel__body table th {
color: var(--vp-c-text-3);
font-size: 12px;
font-weight: 600;
text-align: start;
white-space: nowrap;
}
/* 列序约定:参数 / 类型 / 必填 / 说明。这两条按位置定样式,所以要两重限定:
1) variant="params" —— 参数表才有这个形状。bare 的表(错误码:code / 含义 /
典型原因 / 定位)若套上,散文列会被当成「类型」套等宽、被当成「必填」染成品牌色。
2) :has(td:nth-child(4)) —— 同一个 params 变体下,「字段 / 说明」两列表(如
/data/page 的 data 字段)第 2 列是散文,也不该套等宽小字。
第 1 列两种表都是标识符(参数名 / 字段名 / code),所以不加限定。 */
.g3-api-panel__body table td:first-child {
color: var(--vp-c-text-1);
font-family: var(--vp-font-family-mono);
font-size: 13px;
}
.g3-api-panel--params .g3-api-panel__body:has(td:nth-child(4)) table td:nth-child(2) {
color: var(--vp-c-text-3);
font-family: var(--vp-font-family-mono);
font-size: 12px;
white-space: nowrap;
}
.g3-api-panel--params .g3-api-panel__body:has(td:nth-child(4)) table td:nth-child(3) {
color: var(--vp-c-brand-1);
font-weight: 500;
white-space: nowrap;
}
/* --- 报文里的代码块:贴边(VitePress 默认 margin:20px 0 + 圆角 8px)- */
.g3-api-panel__body div[class*='language-'] {
margin: 0;
border-radius: 0;
}
/* ==========================================================================
4. 表格列宽:短中文列不被压扁
--------------------------------------------------------------------------
现象:/api/getting-started 的「接口一览」里「板块」列被压成一列一个字
(「数据查询」折成两行)。
原因不是这列太长,而是**太短**:CJK 没有「词」的边界,可以逐字换行,于是
该列的 min-content 宽度只有 1 个字。table-layout: auto 按 min-content 分配
空间时,浏览器会把这列压到最小、把余量让给旁边的长路径与长文案 —— 越短的列
越容易被牺牲,与直觉相反。
nowrap 把该列的 min-content 抬到「整格文字的宽度」,即不可再压。
API 页各表的首列都是短键(板块 / 项 / code / 字段 / 参数名),nowrap 安全;
长文案列(说明、触发场景)不带这条规则,照常换行。
========================================================================== */
.g3-api .vp-doc table th:first-child,
.g3-api .vp-doc table td:first-child {
white-space: nowrap;
}
@@ -694,3 +694,80 @@
.vitepress-demo-plugin__container > .vitepress-demo-plugin-preview {
overflow-x: auto;
}
/* --------------------------------------------------------------------------
⚠ 必须还原 <table> 自身:`.vp-doc table { display:block; overflow-x:auto;
margin:20px 0; border-collapse:collapse }`
--------------------------------------------------------------------------
这一整条是 VitePress 给 markdown 表格做「宽表横向滚动 + 上下留白」用的,
但它命中了 G3Table 内部的 `<table class="stk-table-main">`,四个属性逐个都有害:
display:block + overflow-x:auto
→ table 自身成为**滚动容器**
→ th { position: sticky; top: 0 } 的定位基准变成这张 15 万 px 高、
永不滚动的 table,而不是真正在滚的 .stk-table
→ 纵向滚动时**表头跟着一起滚走**
margin:20px 0
→ 表格在容器内整体下移 20px,表头起始位置就偏了 20px
border-collapse:collapse
→ 库要求 separate(它用 border-spacing:0 + 逐格 background-image 画线),
collapse 会改变边框模型
实测对比(同一组件、同一库版本、同样 5000 行虚拟滚动):
playground(无 .vp-doc):滚动 600px 后表头位移 delta=0 ← 正常
文档站(有 .vp-doc) :delta=-600,且 table 计算值 overflow:auto
还原成 table 的默认呈现,让 sticky 的作用域回到 .stk-table 这一层。
⚠ 不要用 `all: revert` 之类的粗暴重置:那会连带清掉库需要的
border-spacing / border-collapse:separate(库显式依赖二者)。 */
.vp-doc .vitepress-demo-plugin-preview table {
display: table;
overflow: visible;
margin: 0;
border-collapse: separate;
}
/* ==========================================================================
组件 Demo:隔断 VitePress 的正文排版样式
--------------------------------------------------------------------------
`.vp-doc` 是 VitePress 给正文(markdown 渲染结果)套的排版容器,它带一条
`.vp-doc th, .vp-doc td { border: 1px solid var(--vp-c-divider) }` —— 给
markdown 表格画的**四边**框线。而 demo 预览区(.vitepress-demo-plugin-preview)
也在 .vp-doc 里面,于是 G3Table 的每个单元格都被额外套上四边框:
实测(/ui/data/table,docs 站):
单元格计算值 borderTop/Bottom/Left/Right 全是 1px
逐像素量内部横线 2px、竖线 2–3px ← 用户看到的「内部边框很厚」
同一组件在 playground(无 .vp-doc)量到的是干净 1px。
也就是说「很厚」= VitePress 的四边框 + 组件自己的行分隔线叠出来的,
组件本身没问题。修在文档站这一层,别去动组件样式。
注意选择器写在 .vp-doc **之内**限定 demo 预览区,不要写全局
`.g3-table th` —— 那会把组件库样式泄漏到宿主页,反过来污染文档站正文里的
普通 markdown 表格。
========================================================================== */
.vp-doc .vitepress-demo-plugin-preview table th,
.vp-doc .vitepress-demo-plugin-preview table td {
border: 0;
padding: 0;
}
/* 内边距交回组件自己:上面 padding:0 是为了清掉 .vp-doc 给 markdown 单元格
设的 padding,但我们的组件用的是 --cell-padding-x,被清掉后文字会贴边,
所以这里还原回库的变量。
⚠ 这里**不要**再写 border-bottom:组件已改为把边框完全交给库
(传 bordered: true),库的 .stk-table.bordered th,td 会用
--bg-border-bottom 渐变自己画底部线。
早先组件用 bordered-v(库不画底部线)时这里补过一条 border-bottom,
现在两者会在同一像素位置叠成 2px —— 正是「内部边框很厚」的来源。 */
.vp-doc .vitepress-demo-plugin-preview .g3-table__inner.stk-table th,
.vp-doc .vitepress-demo-plugin-preview .g3-table__inner.stk-table td {
padding: var(--cell-padding-y) var(--cell-padding-x);
}
/* 展示态/编辑器内容自己带内边距,别被再套一层 */
.vp-doc .vitepress-demo-plugin-preview .g3-table__inner.stk-table td:has(> .g3-table__cell-editor-root) {
padding: 3px;
}
@@ -2,6 +2,10 @@ import type { Theme } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
import Layout from './Layout.vue'
import './custom.css'
// API 文档版式(content/api/**):端点标题、参数面板、报文面板
import './api.css'
import G3ApiParams from './G3ApiParams.vue'
import G3ApiPayload from './G3ApiPayload.vue'
// 设计令牌的编译产物(经 vite alias 落到 packages/tokens/dist/index.css)。
// 组件样式全部依赖 --g3-* 变量,必须先引入。改 tokens 后需重新 build @g3soft/tokens。
import '@g3soft/tokens'
@@ -9,4 +13,10 @@ import '@g3soft/tokens'
export default {
extends: DefaultTheme,
Layout,
enhanceApp({ app }) {
// API 文档版式组件:全局注册,md 里直接写 <G3ApiParams> / <G3ApiPayload>,
// 与 <demo> 一样是「不 import 就能用」的形态。
app.component('G3ApiParams', G3ApiParams)
app.component('G3ApiPayload', G3ApiPayload)
},
} satisfies Theme
+298
View File
@@ -0,0 +1,298 @@
---
title: 数据查询
description: describe / loaddata / page / loaddatabysql / nextid / nextcode
pageClass: g3-api
---
# 数据查询
六个只读接口。除了 `nextid`(纯取号,不碰数据库),其余都会落到**当前机构自己的数据库**上执行。
共用规则:
- **表名、视图名、列名都会先过标识符校验**(只允许 `[A-Za-z_][A-Za-z0-9_]*`,可带限定名如 `dbo.v_x`),不会做字符串拼接 —— 所以别指望能塞入非法标识符。
- `search_condition` 是**原样拼进 `WHERE` 的 SQL 条件片段**;拼好的整条 SQL 之后会过只读校验(禁分号、禁注释、禁写关键字),所以它只能是布尔表达式。
- `order_by` 由后端校验并加 `[]`:支持 `字段 [ASC|DESC]`,或 `CASE ... END` 表达式,多列用逗号分隔。
- 返回的行是普通对象,键是**库里的原始列名**。
## `POST` `/data/describe`
探测表或视图的列结构,用于动态列、字段同步。
<G3ApiParams>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `table` | string | 是 | 表或视图名 |
</G3ApiParams>
<G3ApiPayload title="请求体">
```json
{
"table": "v_b_othercompany"
}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": [
{ "name": "b_id", "type": "varchar", "nullable": false, "size": 32 },
{ "name": "b_name", "type": "nvarchar", "nullable": true, "size": 128 }
]
}
```
</G3ApiPayload>
`type` 是数据库原生类型名(`varchar` / `nvarchar` / `decimal` / `datetime2` / `rowversion` …)。表不存在或没有可识别字段时返回 `1000`。
## `POST` `/data/loaddata`
按视图 + 条件 + 排序 + 字段列表查询。
<G3ApiParams>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `view_name` | string | 是 | 表或视图名 |
| `search_columns` | string[] | | 只取这些列;省略等价于 `*` |
| `search_condition` | string | | `WHERE` 片段 |
| `order_by` | string | | 排序,多列逗号分隔 |
</G3ApiParams>
<G3ApiPayload title="请求体">
```json
{
"view_name": "v_b_othercompany",
"search_columns": ["b_id", "b_name", "b_canuse"],
"search_condition": "b_canuse = 1",
"order_by": "b_id ASC, b_name DESC"
}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": [{ "b_id": "C001", "b_name": "XX 公司", "b_canuse": 1 }]
}
```
</G3ApiPayload>
> **没有行数上限。** `loaddata` 会把命中的行全部返回;列表页请用 `/data/page`。它的典型用途是取一个小字典(下拉选项)或按主键取单行。
## `POST` `/data/page`
分页查询。
<G3ApiParams>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `view_name` | string | 是 | 表或视图名 |
| `order_by` | string | 是 | 分页必须有序(SQL Server 的 `OFFSET / FETCH` 要求) |
| `search_columns` | string[] | | 只取这些列 |
| `search_condition` | string | | `WHERE` 片段 |
| `page_no` | number | | 默认 `1`,从 1 开始 |
| `page_size` | number | | 默认 `20` |
</G3ApiParams>
<G3ApiPayload title="请求体">
```json
{
"view_name": "v_b_othercompany",
"search_condition": "b_canuse = 1",
"order_by": "b_id ASC",
"page_no": 2,
"page_size": 20
}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": {
"rows": [{ "b_id": "C021", "b_name": "XX 公司" }],
"row_count": 20,
"total": 137,
"page_no": 2,
"page_size": 20
}
}
```
</G3ApiPayload>
<G3ApiParams title="data 字段">
| 字段 | 说明 |
|---|---|
| `rows` | 当前页数据 |
| `row_count` | **当前页**的行数(等于 `rows.length`) |
| `total` | 命中总行数 |
| `page_no` / `page_size` | 回显请求里的分页参数 |
</G3ApiParams>
> `row_count` 不是总行数,**总行数是 `total`**。算总页数用 `total`,别用 `row_count`。
>
> `page_no` 或 `page_size` 小于 1、或不是整数,都会得到 `1000`(`page_no 必须是大于 0 的整数`)。
翻到最后一页之后(`page_no` 超出范围)`rows` 为空,但 `total` 仍然正确 —— 所以判断「没数据」和「翻过了头」要靠 `total`。
## `POST` `/data/loaddatabysql`
配置表达不出来的复杂只读查询走这里。
<G3ApiParams>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `sql` | string | 是 | 必须以 `SELECT` 或 `WITH` 开头 |
</G3ApiParams>
<G3ApiPayload title="请求体">
```json
{
"sql": "SELECT TOP 10 b_id, b_name FROM v_b_othercompany ORDER BY b_id"
}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": [{ "b_id": "C001", "b_name": "XX 公司" }]
}
```
</G3ApiPayload>
被拒的情况(都是 `1000`):不是 `SELECT` / `WITH` 开头、包含 `;`、包含注释(`--` 或 `/* */`)、出现 `insert` `update` `delete` `drop` `truncate` `alter` `exec` `grant` 等关键字。
> 判定前会先把**字符串字面量整体掩掉**,所以在 `WHERE b_name = 'insert'` 这类写法里,字面量里的关键字不会误判。反过来也说明:不能在字面量之外藏写操作。
## `POST` `/data/nextid`
取雪花 ID。
<G3ApiParams>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `count` | number | | 默认 `1`;`<= 1` 时 `data` 是单个 id,否则是数组 |
</G3ApiParams>
请求体**可以整个省略** —— 不带 body 或传 `{}` 是等价的,`count` 都取默认的 `1`:
<G3ApiPayload title="请求体">
```json
{}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": "1873456789012345678"
}
```
</G3ApiPayload>
`count: 3` 时:
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": [
"1873456789012345678",
"1873456789012345679",
"1873456789012345680"
]
}
```
</G3ApiPayload>
> 返回的是**字符串**(见 [快速开始](/api/getting-started) 里的序列化约定),不要转成数字。
## `POST` `/data/nextcode`
按模块配置的自动编码规则取业务编码(规则来自 `s_autocode`:前缀、日期格式、序号位数、分隔符、重置周期等)。
<G3ApiParams>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `moduleId` | string | 是 | 模块编码(注意是 camelCase 的 `moduleId`) |
| `count` | number | | 默认 `1`;`<= 1` 时返回单个字符串,否则返回数组 |
</G3ApiParams>
<G3ApiPayload title="请求体">
```json
{
"moduleId": "othercompany",
"count": 1
}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": "HT20261012001"
}
```
</G3ApiPayload>
行为要点:
- **会占用号段** —— 服务端取号后立即把序号写回 `s_autocode`,所以连续调用得到的是不同编码。别拿它做「探测一下看看格式」的试调,试出来的号就废了。
- 生成时会**检查目标表里该编码是否已存在**,已被占用(比如人工插过一条)就自动往后顺延,所以不一定严格等于「当前序号 + 1」。
- 模块没有配置启用的编码规则、或没配保存表 → `1000`(`当前模块未配置启用的自动编码规则` / `模块未配置保存表,无法生成自动编号`)。
- 生成的编码超过目标字段长度 → `1000`(`自动编号长度超过目标字段长度`)。
@@ -1,10 +1,200 @@
---
title: 快速开始
description: 前后端接口的接入说明
description: 接口约定、鉴权与联调步骤
pageClass: g3-api
---
# 快速开始
本页说明前后端接口的接入方式,内容包括接口约定、鉴权与联调步骤。
后端提供的是一套**元数据驱动的通用数据接口**:前端不拼业务 SQL,只声明「查哪张视图 / 存哪张表 + 条件 + 字段」,由后端按 `s_module` / `s_field` 里的配置生成参数化 SQL 并执行。除登录外,所有接口共用同一套鉴权、机构数据源路由与统一响应格式。
> 内容待补充。
## 基础约定
<G3ApiParams variant="bare" :title="null">
| 项 | 约定 |
|---|---|
| 基础路径 | `/api`(`server.servlet.context-path`),例如 `http://localhost:8088/api/data/page` |
| 方法 | **全部是 `POST`**;唯一的 `GET` 是文件静态访问 `/api/file/static/{fileName}` |
| 请求体 | `application/json`,参数是**一个对象或数组**,不是表单 |
| 编码 | UTF-8 |
| HTTP 状态码 | **始终是 200** —— 成败一律看响应体里的 `code`,不要用 HTTP 状态码判断 |
</G3ApiParams>
最后一条最容易踩:鉴权失败、参数错误、系统异常**都不会**返回 4xx / 5xx,而是 200 + `code != 0`。所以不要依赖 `axios` 的错误拦截分支来处理业务失败。
## 统一响应格式
所有接口返回同一个外壳:
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": {}
}
```
</G3ApiPayload>
<G3ApiParams variant="bare" title="响应字段">
| 字段 | 说明 |
|---|---|
| `code` | `0` 成功,非 0 见下表 |
| `message` | 可直接展示给用户的文案;出错时是原因 |
| `data` | 成功时的载荷;失败时一般为 `null` |
</G3ApiParams>
## 错误码
<G3ApiParams variant="bare" :title="null">
| code | 含义 | 触发场景 | 前端应对 |
|---|---|---|---|
| `0` | 成功 | —— | —— |
| `401` | 未登录 / 凭证失效 | 缺少或无效的 `Authorization`;账号密码错误 | 清 token,回登录页 |
| `40101` | 账号已在其他设备登录 | 同一账号在别处登录,本端会话被顶 | 清 token,提示后回登录页 |
| `1000` | 业务错误(默认) | 参数缺失或类型不对、标识符非法、只读 SQL 校验不通过、目标表没有可识别字段等 | 直接用 `message` 提示 |
| `2001` | 保存失败 | 写入时的数据库错误,`data` 带 `table` / `action` / `index` / `cause` | 提示 `message`;`data.index` 可定位到第几行 |
| `2002` | 保存前置条件不成立 | 请求声明的 guard 条件不满足,`data` 带 `table` / `action` / `index` | 直接用 `message`(那是写条件的人给的业务文案) |
| `2003` | 并发编辑冲突 | `$original` 原值或 `b_row_version` 不匹配,`data` 带 `fields` | 提示后**重新拉取该行**,标出 `fields` 里的字段 |
| `500` | 系统异常 | 未预期的异常 | 提示「系统异常,请稍后重试」 |
</G3ApiParams>
参数类错误都是 `1000`,`message` 形如 `view_name 不能为空`、`page_no 必须是大于 0 的整数`、`参数类型不正确`、`非法数据库标识符: xxx` —— 通常已经说清是哪个参数错了。
## 鉴权
### 登录
<G3ApiPayload title="请求">
```bash
curl -X POST http://localhost:8088/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"orgid":"G3HD","userid":"admin","password":"******"}'
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiJ9...",
"orgid": "G3HD",
"user": { "id": "g3soft", "account": "g3soft", "name": "G3Soft" }
}
}
```
</G3ApiPayload>
<G3ApiParams>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `orgid` | string | 是 | 机构码,决定连哪个机构的数据库 |
| `userid` | string | 是 | 账号;字符串业务键方案下 `b_user.b_id` 就是账号本身 |
| `password` | string | | 与库里存的密码比对 |
</G3ApiParams>
机构码或账号为空 → `1000`(`机构码不能为空` / `用户ID不能为空`);账号、密码、机构任一对不上 → 统一 `401` + `机构码、账号或密码错误`(刻意不区分是哪一个错)。
### 带上凭证
除 `/api/auth/login` 与 `OPTIONS` 预检外,**所有接口都要带**:
<G3ApiPayload title="请求头">
```
Authorization: Bearer <token>
```
</G3ApiPayload>
> **机构码不用在业务接口里传。** token 里带着 `orgId`,服务端据此把请求路由到该机构自己的数据库,前端只管带 token。
>
> token 默认 24 小时过期(`erp.auth.jwt-expiration`)。同一账号再次登录会让旧 token 失效并返回 `40101`。
## 最小可跑示例
<G3ApiPayload title="示例代码">
```js
const api = axios.create({ baseURL: '/api' })
api.interceptors.request.use((config) => {
const token = localStorage.getItem('g3erp:token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// HTTP 200 也可能是业务失败,所以要在这里统一判 code
api.interceptors.response.use((res) => {
const { code, message } = res.data
if (code === 401 || code === 40101) {
localStorage.removeItem('g3erp:token')
location.href = '/login'
}
return res.data
})
// 分页查询
const { data } = await api.post('/data/page', {
view_name: 'v_b_othercompany',
search_columns: ['b_id', 'b_name'],
search_condition: 'b_canuse = 1',
order_by: 'b_id ASC',
page_no: 1,
page_size: 20,
})
// data.rows / data.total / data.page_no / data.page_size
```
</G3ApiPayload>
## 一个必须知道的序列化约定
**所有长整型(`Long`)都以 JSON 字符串输出**(后端 `JacksonConfig` 给 `Long` / `long` 注册了 `ToStringSerializer`):
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": "1873456789012345678"
}
```
</G3ApiPayload>
原因是雪花 ID 是 64 位,超过 JS 的 `Number.MAX_SAFE_INTEGER`(2⁵³−1),按数字解析会**静默丢精度**。所以 `data.nextid`、行里的各种 id 一律当 `string` 处理,不要 `Number()` / `parseInt()`,也不要参与算术。
## 接口一览
- **数据查询** —— [数据查询](/api/data):`/data/describe`、`/data/loaddata`、`/data/page`、`/data/loaddatabysql`、`/data/nextid`、`/data/nextcode`
- **数据写入** —— [数据写入](/api/save):`/data/saveobjt`、`/data/delete`
- **鉴权** —— 本页:`/auth/login`
- **文件** —— 暂无专页:`/file/config`、`/file/upload`、`/file/upload-credential`、`/file/save-record`、`/file/delete`、`GET /file/static/{fileName}`
- **导出** —— 暂无专页:`/export/list`、`/export/count`、`/export/totals`(**返回文件流,不是 `ApiResponse`**)
> `/file/static/{fileName}` 同样受鉴权过滤(只有 `/auth/login` 被放行),所以不能用 `<img src="...">` 直接访问,需要按 blob 取回再用 `URL.createObjectURL`。
## 联调步骤
1. 起后端:`cd server && ./mvnw spring-boot:run`(默认 8088);前端 dev server 的 `/api` 代理到它。
2. 调 `/api/auth/login` 拿 token,存进 `localStorage`(键名沿用组件库示例里的 `g3erp:token`)。
3. 用 `/api/data/describe` 看目标表或视图有哪些列,再调 `/api/data/page` 取数据。
4. 拿到 `code != 0` 先查上面的错误码表;`1000` 的 `message` 基本能直接告诉你哪个参数不对。
+213
View File
@@ -0,0 +1,213 @@
---
title: 数据写入
description: saveobjt / delete,含 $original、$expr 与 guards
pageClass: g3-api
---
# 数据写入
## `POST` `/data/saveobjt`
请求体是一个**数组**:一次可提交多个表的增、改、删,**全部在同一个事务里** —— 任何一项失败整批回滚,不会留下半套数据。
### 请求字段
<G3ApiParams title="请求字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `table` | string | 是 | 保存目标表 |
| `key_field` | string | 是 | 主键列名,用于定位要改/删的行 |
| `updates` | object[] | | 要更新的行,主键值必须在行里 |
| `inserts` | object[] | | 要新增的行 |
| `deletes` | object[] | | 要删除的行(用 `key_field` 定位) |
| `delete_target` | string | | 声明 `deletes` 走哪条路径:`module`(按模块配置联动删子表,需 `moduleCode`)或 `direct`(直接删,需 `table` / `key_field`) |
| `guards` | object | | 前置条件,见下 |
</G3ApiParams>
<G3ApiPayload title="请求体">
```json
[
{
"table": "b_othercompany",
"key_field": "b_id",
"updates": [{ "b_id": "C001", "b_name": "新名字" }],
"inserts": [{ "b_id": "C002", "b_name": "新公司" }],
"deletes": [{ "b_id": "C003" }]
}
]
```
</G3ApiPayload>
成功时 `data` 为 `null`,`message` 为 `保存成功`:
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "保存成功",
"data": null
}
```
</G3ApiPayload>
> **没传 `delete_target` 时,`deletes` 是逐行按主键直删**,不会触发模块配置里的联动删除。需要联动就把 `delete_target` 声明成 `module` 并带上 `moduleCode`。
### 行内的两个保留键
键名以 `$` 开头的不会被当成业务列:
<G3ApiParams title="行内保留键">
| 键 | 用途 | 说明 |
|---|---|---|
| `$original` | 乐观锁:声明「我改动之前看到的值」 | 这些字段**只进 `WHERE` 不进 `SET`**;对不上就是 `2003`。值为 `null` 时生成 `IS NULL` |
| `$expr` | 字段自增:`{"$expr": "b_amount + 5"}` | 只支持「本表列 ± 数值常量」,列必须是数值类型 |
</G3ApiParams>
<G3ApiPayload title="行内示例">
```json
{
"b_id": "C001",
"b_amount": { "$expr": "b_amount + 5" },
"$original": { "b_name": "旧名字", "b_row_version": 3 }
}
```
</G3ApiPayload>
- `$original` 里带 `b_row_version` 时走**整行版本锁**:服务端校验通过后自动 `+1`,请求里同名的普通值不生效(避免 `SET` 里出现两次该列)。
- `inserts` 的行**不允许**带 `$original` / `$expr` —— 新增的行没有原值可比,带了会直接报 `1000`。
- `$expr` 的字段不存在、不可写、不是数值类型、格式不对,都会在**任何一行写入之前**报 `1000`(这类参数错误不会等到写到第 N 行才暴露)。
### guards:写入前置条件
让「判断」和「写入」发生在**同一条 SQL** 里,避免先查后写的竞态。
<G3ApiPayload title="带 guards 的请求体">
```json
{
"table": "b_othercompany",
"key_field": "b_id",
"updates": [{ "b_id": "C001", "b_name": "新名字" }],
"guards": {
"updates": [
{
"condition": "{b_state} = 'draft'",
"refs": ["b_othercompany"],
"message": "只有草稿状态才能改名",
"retryable": false
}
]
}
}
```
</G3ApiPayload>
`guards` 下的三个分节键按动作区分 —— `inserts` / `updates` / **`deletes`**(注意是 `deletes`):
<G3ApiParams title="guards 字段">
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `condition` | string | 是 | SQL 布尔表达式;`{列名}` 是受控占位符,替换为 `t.[列名]`;**不允许注释** |
| `refs` | string[] | | 声明引用的表,只进日志 |
| `message` | string | | 条件不成立时给用户看的文案 |
| `retryable` | boolean | | 默认 `true`;`false` 时前端文案不追加「请刷新后重试」 |
</G3ApiParams>
条件不成立时返回 `2002`,`message` 就是上面写的 `message`,`data` 带 `table` / `action` / `index`。
两条互斥规则(同时用会报 `1000`):
- `guards.deletes` 与 `delete_target` 互斥;
- `guards.deletes` 与 `dependent_deletes` 互斥。
原因是带条件的删除只能逐行唯一定位,而 `delete_target` / 依赖删除那两条旁路拿不到 guards —— 与其让条件被静默忽略,不如直接拒绝。
### 三个错误码怎么分
<G3ApiParams title="错误码" variant="bare">
| code | 含义 | 典型原因 | `data` 里的定位信息 |
|---|---|---|---|
| `2001` | 保存失败 | 字段不存在、类型不匹配、数据库约束冲突等 | `table` / `action` / `index` / `cause` |
| `2002` | 前置条件不成立 | `guards` 里的条件没满足 | `table` / `action` / `index` |
| `2003` | 并发编辑冲突 | `$original` 的原值已被别人改过、`b_row_version` 不匹配 | `table` / `action` / `index` / `fields` |
</G3ApiParams>
`2003` 的正确处理不是重试,而是:**重新拉取该行**,用 `data.fields` 标出哪些字段已被他人修改,让用户决定覆盖还是放弃。
`action` 的取值是 `update` / `insert` / `delete`,`index` 是请求数组里的下标(从 `0` 开始)—— 批量保存失败时靠它定位到具体哪一行。
## `POST` `/data/delete`
独立删除接口。和 `saveobjt` 的区别是它**不进保存事务**、也不涉及增改。
<G3ApiParams>
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `target` | string | 是 | `module` 或 `direct`,其它值报 `1000` |
| `moduleCode` | string | 是(`target=module`) | 模块编码(`s_module.b_id`) |
| `table` | string | 是(`target=direct`) | 表名 |
| `key_field` | string | 是(`target=direct`) | 主键列名 |
| `ids` | any[] | | 要删除的主键值;`direct` 下为空数组会直接返回 `{}` |
</G3ApiParams>
按模块删除(会按模块配置联动删除子表):
<G3ApiPayload title="请求体">
```json
{
"target": "module",
"moduleCode": "othercompany",
"ids": ["C001", "C002"]
}
```
</G3ApiPayload>
指定表直接删除:
<G3ApiPayload title="请求体">
```json
{
"target": "direct",
"table": "b_othercompany",
"key_field": "b_id",
"ids": ["C001"]
}
```
</G3ApiPayload>
<G3ApiPayload title="响应" :code="0">
```json
{
"code": 0,
"message": "操作成功",
"data": { "b_othercompany": 2 }
}
```
</G3ApiPayload>
`data` 是 **`{表名: 实际删除行数}`** —— `direct` 只有一项;`module` 会带出所有被联动删除的表,所以可能有多项。整个删除同样在单个事务里完成。
> `target` 必须显式声明。后端**刻意不做**「没传 `moduleCode` 就当直删」这种隐式分支,避免漏传参数时静默绕过模块规则。
+40 -5
View File
@@ -15,7 +15,7 @@ description: 高性能虚拟滚动表格,支持排序、固定列、多选、
## 基础用法
给 `columns` 与 `dataSource` 两份数据即可。序号列(`seq`)默认开启,外框与圆角默认有(`bordered`)。
给 `columns` 与 `dataSource` 两份数据即可。序号列(`seq`)默认开启,边框(`bordered`)默认有。
<demo vue="table/basic.vue" />
@@ -52,16 +52,46 @@ description: 高性能虚拟滚动表格,支持排序、固定列、多选、
## 行内编辑
列上加 `editor` 即可编辑:单击单元格进入,`Enter` 确认、`Esc` 取消。
列上加 `editor` 即可编辑:单击单元格进入。
| 按键 | 行为 |
| --- | --- |
| `Enter` | **全选当前内容**(便于直接覆写),不提交 |
| `Esc` | **取消编辑**:把该行恢复到进入编辑前的值,然后退出编辑 |
| 失焦(点别处 / Tab) | **提交**并抛出 `change` |
`Enter` 不做提交是刻意的:单元格里的值通常要整体替换,全选后直接输入即可,不必先手动清空。提交一律靠失焦。
| 编辑器类型 | 说明 |
| --- | --- |
| `text` / `number` / `textarea` | 用 [Input](/ui/form/input) 渲染 |
| `text` / `number` / `password` / `textarea` | 用 [Input](/ui/form/input) 渲染 |
| `select` | 用 [Select](/ui/form/select) 渲染;`options` 可以是数组,也可以是接收当前行的函数 |
| `checkbox` | 点击即切换,无需先激活整行;约定 `1` = 选中、`0` = 未选中(与后端 tinyint 对齐) |
编辑器的输入值先写进内部草稿,**失焦才落库并抛出 `change`**。这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。用 `editable={false}` 可整体禁用编辑。
### ⚠ 数据源必须是响应式的
**行对象必须能被 Vue 追踪**,否则编辑回写不会触发重渲染。典型的踩坑写法是用 `computed` 派生数据:
```ts
// ❌ 错:computed 返回的是普通对象数组,行字段不可追踪
const rows = computed(() => Array.from({ length: n.value }, (_, i) => makeRow(i)))
// ✅ 对:ref 会递归代理内部对象
const rows = ref<Row[]>([])
watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }, { immediate: true })
```
用错时的症状**极具迷惑性**,不容易联想到数据源:
- `change` 事件**照常抛出**(数据其实已经改了,父级能拿到)
- 但界面上**仍显示旧值**
- 点一下别处(触发无关重渲染)才「突然」更新
- 尤其是 `select` 编辑器:选完选项下拉关了、值却没变,很像组件的事件时序 bug
开发模式下,进入编辑时会检测并打印一条警告指明改法(每个组件实例只警告一次)。
<demo vue="table/editable.vue" />
## 操作列
@@ -91,7 +121,7 @@ description: 高性能虚拟滚动表格,支持排序、固定列、多选、
| `virtualX` | `boolean` | `true` | 横向虚拟滚动总开关(实际是否开启受列数门控) |
| `rowHeight` | `number` | `30` | 行高,同时驱动虚拟滚动计算 |
| `headerRowHeight` | `number` | `34` | 表头行高 |
| `bordered` | `boolean \| string` | `true` | 外框圆角描边 + 行分隔线;`false` 时只留行分隔线 |
| `bordered` | `boolean` | `true` | 是否显示边框(透传给底座的 `bordered: true`)。边框由底座绘制,不再是自绘外框 + 圆角 |
| `colResizable` | `boolean \| object` | `true` | 列宽拖拽;未绑定 `v-model:columns` 时自动关闭。可用 `{ disabled: (col) => boolean }` 按列禁用 |
| `columnDraggable` | `boolean \| object` | `true` | 列序拖拽;同上要求绑定 `v-model:columns` |
| `rowDraggable` | `boolean` | `false` | 行拖拽排序 |
@@ -178,6 +208,11 @@ description: 高性能虚拟滚动表格,支持排序、固定列、多选、
- 注意 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 时按类名定位根节点。
- **`data-testid` 透传到内层**:组件 `inheritAttrs: false`,`$attrs` 落在内层 `StkTable` 上,所以写 E2E 时按类名 `.g3-table` 定位根节点。
- **边框完全交给底座**:组件把 `bordered` 直接透传为底座的 `bordered: true`,**不再自绘外框与行分隔线**。因此:
- 底座用逐格 `background-image` 渐变画线(不是 `border`),横竖线都来自 `--bg-border-*` 变量;
- 受滚动条影响,**表格右侧与底部的边框会消失**——这是底座文档(`table/basic/bordered`)明确说明的行为,需要时自行补 CSS;
- 没有圆角;`bordered: false` 时四边与格线全部消失。
- **`--th-bgc` 等表头/表尾底色**由覆盖层的变量映射提供,不通过行内 style 注入。
- **行编辑态存在 `WeakMap` 里**:按行对象存,不给业务数据打标记;行被回收时状态自动释放。行与行之间切换编辑**不翻转** `editingActive`,避免整表列重建。
- **RTL**:布局用逻辑属性,带符号位移走 `--g3-dir-sign`;分隔条拖拽与键盘方向由库的 `useDirection` 处理。
@@ -1,20 +1,29 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Pencil, Trash2 } from '@lucide/vue'
import { Pencil, Trash2, Copy, Archive, Download } from '@lucide/vue'
import { G3Message, G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 操作列:列上加 actions 即可,渲染成一排小按钮。
// - type: 'more' 的项会收进「更多」下拉(这里没配,全部内联显示)
// - confirm 给字符串或对象时先弹确认框,确认后才执行 onClick
// - 操作列建议显式写 resizable: false(宽度由内容决定,拖了不落库)
// - type: 'danger' | 'delete' → 危险色
// - visible / disabled 可传 boolean 或 (row) => boolean
// - confirm:字符串=标题,对象=完整配置;确认后才执行 onClick
// - type: 'more' 的项收进「更多」下拉(它的 items 是子项)
//
// 操作列建议显式写 resizable: false —— 宽度由内容决定,拖了不会落库,列一重建就丢。
const data = ref<TableRow[]>([
{ id: 1, name: '框架合同', amount: 120000, status: 'draft' },
{ id: 2, name: '采购订单', amount: 86000, status: 'active' },
{ id: 3, name: '验收单', amount: 43000, status: 'archived' },
])
const columns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'name', title: '名称', width: 180 },
{ dataIndex: 'amount', title: '金额', width: 120, align: 'right' },
{
dataIndex: 'op',
title: '操作',
width: 160,
width: 220,
resizable: false,
actions: [
{
@@ -26,22 +35,49 @@ const columns: G3TableColumn<TableRow>[] = [
label: '删除',
icon: Trash2,
type: 'danger',
// 已归档的单据不允许删除
disabled: (row) => row.status === 'archived',
confirm: { title: '确认删除', description: '删除后不可恢复,确定继续吗?' },
onClick: (row) => G3Message.success(`已删除:${row.name}`),
},
{
type: 'more',
label: '更多',
items: [
{
key: 'copy',
label: '复制',
icon: Copy,
onClick: (row) => G3Message.info(`复制:${row.name}`),
},
{
key: 'archive',
label: '归档',
icon: Archive,
// 已归档的不再显示该项(visible 传函数)
visible: (row) => row.status !== 'archived',
onClick: (row) => G3Message.success(`已归档:${row.name}`),
},
{
key: 'export',
label: '导出',
icon: Download,
onClick: (row) => G3Message.info(`导出:${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>
<p style="margin: 0 0 8px; color: #999; font-size: 12px">
「删除」带二次确认,且对已归档行禁用;「更多」里「归档」对已归档行隐藏。
</p>
<div style="height: 260px">
<G3Table :columns="columns" :data-source="data" />
</div>
</div>
</template>
@@ -3,14 +3,16 @@ import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 列定义:dataIndex 指向行数据的字段
// 最小用法:给 columns 与 dataSource 两份数据即可。
// - seq 默认开启,会在最前列注入序号列
// - bordered 默认开启,边框由底座(stk-table-vue)绘制
// - 没写 width 的列会自动摊掉剩余宽度
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: '李四' },
@@ -3,17 +3,21 @@ import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 行内编辑:列上加 editor 即可。
// 单击单元格进入编辑;Enter 确认、Esc 取消。值变化通过 change 事件抛出。
// 行内编辑:列上加 editor 即可。单击单元格进入编辑,Enter 确认、Esc 取消。
//
// 编辑器类型:text / number / textarea / select / checkbox
// checkbox 约定 1 = 选中、0 = 未选中(与后端 tinyint 对齐)。
// 编辑器类型:
// text / number / password / textarea → 用 G3Input 渲染
// select → 用 G3Select 渲染;options 可传数组,也可传「接收当前行的函数」
// checkbox → 点击即切换,无需先激活整行;约定 1 = 选中、0 = 未选中(对齐后端 tinyint)
//
// 关键行为:输入值先写进内部草稿,**失焦才落库并抛出 change**。
// 这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。
const columns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'name', title: '名称', width: 200, editor: { type: 'text' } },
{
dataIndex: 'status',
title: '状态',
width: 140,
title: '状态(select)',
width: 150,
editor: {
type: 'select',
options: [
@@ -23,25 +27,53 @@ const columns: G3TableColumn<TableRow>[] = [
},
},
{ dataIndex: 'enabled', title: '启用', width: 80, editor: { type: 'checkbox' } },
{ dataIndex: 'qty', title: '数量', width: 120, align: 'right', editor: { type: 'number' } },
{ dataIndex: 'qty', title: '数量(number)', width: 140, align: 'right', editor: { type: 'number' } },
{
dataIndex: 'remark',
title: '备注(textarea)',
width: 180,
editor: { type: 'textarea' },
},
{
dataIndex: 'owner',
title: '负责人(options 依赖本行)',
width: 200,
// options 传函数时会收到当前行 —— 按「状态」给出不同候选人。
// 注意这类列在非编辑态会把值解析成 label 展示。
editor: {
type: 'select',
options: (row: TableRow) =>
row.status === 1
? [
{ label: '张三', value: '张三' },
{ label: '李四', value: '李四' },
]
: [{ label: '王五', value: '王五' }],
},
},
]
const data = ref<TableRow[]>([
{ id: 1, name: '框架合同', status: 1, enabled: 1, qty: 12 },
{ id: 2, name: '采购订单', status: 0, enabled: 0, qty: 45 },
{ id: 1, name: '框架合同', status: 1, enabled: 1, qty: 12, remark: '年度框架', owner: '张三' },
{ id: 2, name: '采购订单', status: 0, enabled: 0, qty: 45, remark: '', owner: '王五' },
])
const lastChange = ref('')
function onChange(row: TableRow, col: G3TableColumn<TableRow>, value: unknown) {
lastChange.value = `${row.name} 的「${col.title}」改为 ${value}`
// 只有值**真的变化**才会走到这里(组件内部按编辑前的快照去重),
// 所以「点进去又点出来」不会产生无意义的 change。
lastChange.value = `「${row.name}」的「${col.title}」→ ${value}`
}
</script>
<template>
<div>
<p style="margin: 0 0 8px; color: #666; font-size: 13px">
最近一次修改:{{ lastChange || '(点击单元格试试)' }}
最近一次 change:{{ lastChange || '(点击单元格试试)' }}
</p>
<p style="margin: 0 0 8px; color: #999; font-size: 12px">
单击进入编辑 · Enter 确认 · Esc 取消 · Tab / 方向键可换格
</p>
<div style="height: 240px">
<G3Table :columns="columns" :data-source="data" :seq="false" @change="onChange" />
@@ -3,21 +3,22 @@ import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 列宽 / 列序拖拽与「列定义回写」:
// 两者都要求用 v-model:columns 绑定,否则组件拖完无处回写(松手回弹),
// 组件检测到没绑定会直接关掉这两个功能。
// 列宽 / 列序拖拽要求用 v-model:columns 绑定列定义。
// 组件会检测父级有没有绑 update: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 后不可拖宽
// 操作列显式声明 resizable: false —— 它的宽度由内容决定,拖了不会落库、列一重建就丢。
// 也可以在 colResizable 里用 disabled(col) 按列禁用。
{ dataIndex: 'op', title: '操作', width: 100, resizable: false },
])
// 行拖拽排序:开启后显示拖拽手柄,拖完通过 row-order-change 回传新顺序
const dragColumns = ref<G3TableColumn<TableRow>[]>([
{ dataIndex: 'name', title: '名称' },
{ dataIndex: 'amount', title: '金额', align: 'right' },
// 按列禁用拖宽的另一种写法:整列标成 noResize。
const columnsByConfig = ref<G3TableColumn<TableRow>[]>([
{ dataIndex: 'name', title: '名称', width: 180 },
{ dataIndex: 'amount', title: '金额(禁用拖宽)', width: 160, align: 'right' },
])
const data = ref<TableRow[]>([
@@ -26,27 +27,60 @@ const data = ref<TableRow[]>([
{ id: 3, name: '验收单', amount: 43000, owner: '王五', op: '—' },
])
// ── 行拖拽排序 ──
// row-draggable 开启后最左侧出现拖拽手柄;拖完通过 row-order-change 回传新顺序。
// 注意回传的是 (rows, sourceIndex, targetIndex) —— rows 是拖拽后的完整数据,直接用即可。
const dragColumns = ref<G3TableColumn<TableRow>[]>([
{ dataIndex: 'name', title: '步骤' },
{ dataIndex: 'amount', title: '耗时(分钟)', align: 'right' },
])
const dragData = ref<TableRow[]>([
{ id: 1, name: '第一步', amount: 100 },
{ id: 2, name: '第二步', amount: 200 },
{ id: 3, name: '第三步', amount: 300 },
{ id: 1, name: '第一步:准备', amount: 100 },
{ id: 2, name: '第二步:执行', amount: 200 },
{ id: 3, name: '第三步:验收', amount: 300 },
])
const orderText = ref('')
function onRowOrderChange(rows: TableRow[]) {
function onRowOrderChange(rows: TableRow[], sourceIndex: number, targetIndex: number) {
dragData.value = rows
orderText.value = rows.map((row) => row.name).join(' → ')
orderText.value = `${sourceIndex} → ${targetIndex}:${rows.map((r) => r.name).join(' → ')}`
}
// 当前列宽(演示 v-model:columns 的回写确实发生了)
const widths = ref('')
function onColumnsUpdate(next: G3TableColumn<TableRow>[]) {
columns.value = next
widths.value = next
.filter((c) => c.dataIndex !== 'op')
.map((c) => `${c.title}=${c.width ?? 'auto'}`)
.join(' · ')
}
</script>
<template>
<div>
<p style="margin: 0 0 8px; color: #666; font-size: 13px">
拖动表头分隔线改列宽、拖表头文字改列序。
<p style="margin: 0 0 8px; color: #999; font-size: 12px">
拖动表头分隔线改列宽、拖动表头文字改列序。「操作」列设了 resizable: false,拖不动。
</p>
<div style="height: 240px">
<G3Table v-model:columns="columns" :data-source="data" :row-selection="null" />
<p style="margin: 0 0 8px; color: #666; font-size: 13px">
回写后的列宽:{{ widths || '(拖一下试试)' }}
</p>
<div style="height: 220px">
<G3Table :columns="columns" :data-source="data" @update:columns="onColumnsUpdate" />
</div>
<p style="margin: 16px 0 8px; color: #999; font-size: 12px">
按列禁用拖宽:用 colResizable 的 disabled(col) 回调。
</p>
<div style="height: 180px">
<G3Table
v-model:columns="columnsByConfig"
:data-source="data"
:seq="false"
:col-resizable="{ disabled: (col) => col.dataIndex === 'amount' }"
/>
</div>
<p style="margin: 16px 0 8px; color: #666; font-size: 13px">
@@ -4,31 +4,37 @@ import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 多选:传 rowSelection 即在最前注入选择列。
// getCheckboxProps 可禁用个别行(禁用行不参与「全选」的分母)。
// - selectedRowKeys 用 v-model 式双向绑定,勾选变化会回写
// - getCheckboxProps 可禁用个别行;**禁用行不计入「全选」的分母**
// (所以下面表格里表头全选只会选中 2 行)
const columns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'name', title: '名称', width: 180 },
{ dataIndex: 'amount', title: '金额', width: 120, align: 'right' },
{ dataIndex: 'owner', title: '负责人', width: 120 },
]
const data: TableRow[] = [
{ id: 1, name: '框架合同', amount: 120000 },
{ id: 2, name: '采购订单', amount: 86000, locked: true },
{ id: 3, name: '验收单', amount: 43000 },
{ id: 1, name: '框架合同', amount: 120000, owner: '张三' },
{ id: 2, name: '采购订单', amount: 86000, owner: '李四', locked: true },
{ id: 3, name: '验收单', amount: 43000, owner: '王五' },
]
const selectedRowKeys = ref<Array<string | number>>([])
const selectedText = ref('')
const selectedNames = ref<string[]>([])
function onSelectionChange(keys: Array<string | number>, rows: TableRow[]) {
selectedRowKeys.value = keys
selectedText.value = rows.map((row) => row.name).join('、')
selectedNames.value = rows.map((row) => String(row.name))
}
</script>
<template>
<div>
<p style="margin: 0 0 8px; color: #666; font-size: 13px">
已选:{{ selectedText || '(无)' }}
已选 {{ selectedRowKeys.length }} 项:{{ selectedNames.join('、') || '(无)' }}
</p>
<p style="margin: 0 0 8px; color: #999; font-size: 12px">
「采购订单」被 getCheckboxProps 禁用,不参与全选。
</p>
<div style="height: 260px">
<G3Table
@@ -3,18 +3,41 @@ import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 排序:列上加 sorter: true 即可点表头排序(组件内排,不请求接口)。
// sortType 决定按数字还是字符串比较,默认按字符串。
// 排序:列上写 sorter: true 即可点表头排序(组件内排序,不请求接口)。
// - sortType 决定按数字还是字符串比较,默认字符串;金额这类数值列要显式写 'number'
// - 需要自定义规则时 sorter 也可以传函数(见「名称长度」列)
const columns: G3TableColumn<TableRow>[] = [
{ dataIndex: 'name', title: '名称', width: 180 },
{ dataIndex: 'amount', title: '金额', width: 140, align: 'right', sorter: true, sortType: 'number' },
{
dataIndex: 'amount',
title: '金额(数字排序)',
width: 160,
align: 'right',
sorter: true,
sortType: 'number',
},
{ dataIndex: 'qty', title: '数量', width: 120, align: 'right', sorter: true, sortType: 'number' },
{
// dataIndex 必须指向一个**真实存在的字段**:库按 row[dataIndex] 渲染单元格,
// 复用 'name' 会让这一列显示名称本身(而不是长度),并且与「名称」列重复。
dataIndex: 'nameLength',
title: '名称长度(自定义)',
width: 160,
align: 'right',
// 自定义排序接收的是**整个数组**与排序状态,返回排好的新数组
// (不是 Array.prototype.sort 的 (a, b) 比较器——写成 (a, b) 会静默不生效)
sorter: (rows: TableRow[], { order }: { order: 'asc' | 'desc' | null }) => {
if (!order) return rows
const sign = order === 'asc' ? 1 : -1
return [...rows].sort((a, b) => (Number(a.nameLength) - Number(b.nameLength)) * sign)
},
},
]
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 },
{ id: 1, name: '框架合同', nameLength: 4, amount: 120000, qty: 12 },
{ id: 2, name: '采购订单补充协议', nameLength: 8, amount: 86000, qty: 45 },
{ id: 3, name: '验收单', nameLength: 3, amount: 43000, qty: 7 },
])
</script>
@@ -3,13 +3,14 @@ import { computed, ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 性能门控:行数超过 100 才开启纵向虚拟滚动。
// 组件内部按 dataSource.length 自动判断,业务不需要手动切换 virtual。
// 性能门控:行数 > 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' },
{ dataIndex: 'value', title: '数值', width: 140, align: 'right', sorter: true, sortType: 'number' },
]
const virtualData = computed(() =>
@@ -24,9 +25,11 @@ const virtualData = computed(() =>
<template>
<div>
<p style="margin: 0 0 8px; color: #666; font-size: 13px">
当前 {{ rowCount }} 行 → {{ rowCount > 100 ? '已开启' : '未开启(行数不足 100 时全量渲染)' }}纵向虚拟滚动
当前 {{ rowCount }} 行 →
{{ rowCount > 100 ? '已开启' : '未开启(行数不足 100 时全量渲染)' }}纵向虚拟滚动
</p>
<div style="height: 320px">
<input v-model.number="rowCount" type="range" min="10" max="5000" step="10" />
<div style="height: 320px; margin-top: 8px">
<G3Table :columns="virtualColumns" :data-source="virtualData" :seq="false" />
</div>
</div>