diff --git a/code/g3soft-erp/.gitattributes b/code/g3soft-erp/.gitattributes new file mode 100644 index 00000000..2797e5c2 --- /dev/null +++ b/code/g3soft-erp/.gitattributes @@ -0,0 +1,54 @@ +# 统一行尾:索引内一律 LF,检出时按下面的规则落地。 +# CRLF 只留给 Windows 批处理 —— cmd.exe 处理 LF 结尾的 .cmd/.bat 不稳(标签/goto 会出错)。 +# +# 工作区里已经存在的文件不会被自动改写;要让索引与工作区彻底一致,执行一次: +# git add --renormalize . +# git checkout -- . +# 之后新加的文件会自动按本文件规范化,不再需要人工干预。 + +* text=auto + +# 源码与文本:一律 LF +*.java text eol=lf +*.kt text eol=lf +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.mjs text eol=lf +*.cjs text eol=lf +*.vue text eol=lf +*.scss text eol=lf +*.css text eol=lf +*.html text eol=lf +*.json text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.xml text eol=lf +*.md text eol=lf +*.properties text eol=lf +*.sql text eol=lf +.gitignore text eol=lf +.gitattributes text eol=lf + +# 脚本:Unix 侧必须 LF(mvnw 是 shell 脚本,CRLF 会让它跑不起来) +*.sh text eol=lf +mvnw text eol=lf + +# Windows 批处理:必须 CRLF +*.cmd text eol=crlf +*.bat text eol=crlf + +# 二进制:不做行尾转换、不参与 diff +*.xdb binary +*.jar binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary + +# 锁文件:内容由 pnpm 生成,不展示 diff +pnpm-lock.yaml -diff diff --git a/code/g3soft-erp/.gitignore b/code/g3soft-erp/.gitignore index e4f9cc1b..264917c2 100644 --- a/code/g3soft-erp/.gitignore +++ b/code/g3soft-erp/.gitignore @@ -12,6 +12,11 @@ target/ !.mvn/wrapper/maven-wrapper.jar .mvn/timing.properties +# ---- 后端本地敏感配置与本地库(server/)---- +server/config/dbconfigs/* +!server/config/dbconfigs/README.md +server/src/main/resources/ip2region.xdb + # ---- 日志与运行时产物 ---- *.log npm-debug.log* diff --git a/code/g3soft-erp/README.md b/code/g3soft-erp/README.md index e86dd06c..aaeefe6f 100644 --- a/code/g3soft-erp/README.md +++ b/code/g3soft-erp/README.md @@ -2,8 +2,8 @@ G3Soft ERP 前后端同仓。业务域:海运 / 空运 / 箱管 / 财务 / CRM。 -> **当前状态:前端骨架已搭好并可运行;后端尚未开始。** -> 元数据驱动引擎(通用查询 / 保存 / 权限)**未开始设计**,属后续独立立项。 +> **当前状态:前端骨架已搭好并可运行;后端已从原型 `fms-api` 迁入元数据驱动引擎实现**(见 `server/README.md`)。 +> 元数据驱动引擎(通用查询 / 保存 / 权限)**尚未按业务域重构**,属后续独立立项。 ## 目录结构 @@ -18,7 +18,7 @@ g3soft-erp/ │ ├── .oxlintrc.json │ ├── .oxfmtrc.json │ └── src/ -├── server/ # 后端占位(Maven + Spring Boot,未开始) +├── server/ # 后端(Maven + Spring Boot,已迁入原型实现) ├── docs/ # 设计文档 └── README.md ``` diff --git a/code/g3soft-erp/docs/前端架构设计.md b/code/g3soft-erp/docs/前端架构设计.md index 61e143c1..6b7ebfc9 100644 --- a/code/g3soft-erp/docs/前端架构设计.md +++ b/code/g3soft-erp/docs/前端架构设计.md @@ -11,7 +11,7 @@ | 项 | 状态 | |---|---| | 元数据驱动引擎(通用查询 / 保存 / 权限) | 未设计 | -| 后端设计与 Java 分包 | 未开始 | +| 后端设计与 Java 分包 | 已开始(见 `server/README.md`) | | `stk-table-vue`(虚拟滚动表格) | 待定 | | LogicFlow(审批流设计器) | 待定 | | 登录 / 权限 / 用户模块 | 未开始(骨架已留位) | diff --git a/code/g3soft-erp/docs/后端架构设计.md b/code/g3soft-erp/docs/后端架构设计.md index be9201fd..b046e6f1 100644 --- a/code/g3soft-erp/docs/后端架构设计.md +++ b/code/g3soft-erp/docs/后端架构设计.md @@ -48,7 +48,7 @@ - 关联、聚合或其他配置表达起来不合适的查询,可在模块配置中使用 `b_query_sql`;它是模块配置数据,不要求在后端代码中为每家公司登记 SQL 编码。 - 对于按机构隔离的数据库,机构数据源路由继续使用现有 `OrgContext` / `OrgDataSourceManager`。各机构可在自己的配置库维护不同模块 SQL,不新增租户配置层。 - 运行查询 SQL 必须经过现有只读校验路径;只允许查询语句,不允许用查询 SQL 执行新增、修改、删除或 DDL。写入走现有保存/删除接口或明确的业务服务。 -- `b_order_sql` 是模块默认排序;排序标识符按现有 `DbUtils` 校验规则处理。查询参数和值不得通过未校验的字符串拼接进入 SQL。 +- `b_order_sql` 是模块默认排序;排序标识符按现有 `SqlText` 校验规则处理。查询参数和值不得通过未校验的字符串拼接进入 SQL。 ## 4. 沿用的通用数据 API @@ -64,7 +64,7 @@ | `POST /data/describe` | 探测表或视图列结构 | | `POST /data/nextid`、`/data/nextcode` | 取雪花 ID 或模块业务编码 | -`loaddata` / `page` 使用表或视图名、结构化筛选和排序参数;`loaddatabysql` 用于复杂只读查询。已有 `DbUtils` 负责标识符验证、SQL 只读检查和 SQL 执行。通用保存服务依据真实表结构生成参数化写入,不通过 `b_query_sql` 写业务数据。 +`loaddata` / `page` 使用表或视图名、结构化筛选和排序参数;`loaddatabysql` 用于复杂只读查询。已有 `SqlText` 负责标识符验证与 SQL 只读检查,`MetadataReader` / `RowReader` / `RowWriter` 分别负责表结构探测、只读查询与写入执行。通用保存服务依据真实表结构生成参数化写入,不通过 `b_query_sql` 写业务数据。 新接入系统应沿用这些请求/响应格式和既有身份、机构、权限上下文;模块管理页面负责读取模块配置并组织请求,不需要另造一套模块专属 CRUD API。 @@ -94,7 +94,7 @@ - 客户端不能以任意表名或列名绕过后端标识符验证。 - 普通查询的筛选值以参数传递;字段、排序方向等 SQL 标识由校验后的配置控制。 -- `b_query_sql` 和 `/data/loaddatabysql` 仅用于只读查询,并使用现有 `DbUtils.validateReadOnlySql()` 校验。 +- `b_query_sql` 和 `/data/loaddatabysql` 仅用于只读查询,并使用现有 `SqlText.validateReadOnlySql()` 校验。 - SQL 配置及执行需经过现有机构、用户和数据范围权限上下文;不能因为使用 SQL 扩展绕过权限校验。 - 写操作使用 `/data/saveobjt`、`/data/delete` 或已有业务服务,并纳入现有事务和审计规则。 - 版本恢复同样执行配置权限校验、引用校验和事务处理。 diff --git a/code/g3soft-erp/web/src/layouts/DefaultLayout.vue b/code/g3soft-erp/web/src/layouts/DefaultLayout.vue index f38130f5..b5edb2b5 100644 --- a/code/g3soft-erp/web/src/layouts/DefaultLayout.vue +++ b/code/g3soft-erp/web/src/layouts/DefaultLayout.vue @@ -221,10 +221,21 @@ watch( --sidebar-muted-color: var(--g3-color-text-tertiary, #86909c); --sidebar-title-color: var(--g3-color-foreground, #1f2329); - /* 全局深色(html.dark):整片侧栏切深色。 - 与 LayoutSidebar 的处理保持一致;「深色侧栏」开关由各组件自己的 - .is-dark 覆盖(见 LayoutMixedRail / LayoutMixedColumn)。 */ - .dark & { + /* ---- 深色侧栏:两个入口都要接上 ---- + * html.dark 全局深色模式 + * html.semi-dark-sidebar 「深色侧栏」开关(浅色模式下由偏好开启, + * 见 preferences/update-css-variables.ts:103) + * 二者的并集恰好等于「侧栏该是深色」(semiDarkSidebar || isDark), + * 也就是双列两列各自 is-dark 的判断条件(LayoutMixedRail:32 / LayoutMixedColumn:25)。 + * + * ⚠️ 少接一根就是这个 bug:半深色侧栏**不会**给 html 加 .dark,所以只写 `.dark &` + * 时,双列两列的 `.is-dark` 只把 background 刷成 #1d2129,而 --sidebar-item-color + * 仍停在上面那行的深灰 #1f2329 —— 深字压深底,菜单文字看不见(实测对比度 1.89, + * WCAG 正文下限是 4.5)。 + * 单列布局没这个问题:LayoutSidebar 自带同名变量组,会覆盖这里的值。 + * 深色档取值与 LayoutSidebar 的 `.is-dark` 保持一致,两种布局同色。 */ + .dark &, + .semi-dark-sidebar & { --sidebar-bg: #1d2129; --sidebar-border: #2e3238; --sidebar-item-color: rgb(255 255 255 / 80%); diff --git a/code/g3soft-erp/web/src/layouts/LayoutMixedColumn.vue b/code/g3soft-erp/web/src/layouts/LayoutMixedColumn.vue index b279aae5..2278a0fc 100644 --- a/code/g3soft-erp/web/src/layouts/LayoutMixedColumn.vue +++ b/code/g3soft-erp/web/src/layouts/LayoutMixedColumn.vue @@ -110,12 +110,16 @@ const showLogoText = computed( background: var(--sidebar-bg, #fff); border-right: 1px solid var(--sidebar-border, #e5e6eb); - /* 深色侧栏(semiDarkSidebar):与 LayoutSidebar 的 .is-dark 同一组值。 - 全局深色(html.dark)由 DefaultLayout 根上的 .dark 规则统一处理。 */ - &.is-dark { - background: #1d2129; - border-right-color: #2e3238; - } + /* 深色侧栏:这里**不写任何颜色**。 + 底色与前景(文字 / hover / 选中 / 箭头 / 标题)全部由 DefaultLayout 的 + .layout 调色板按 .dark / .semi-dark-sidebar 整体切换 —— 本列是 .layout 的 + 子节点,直接继承那组 --sidebar-*。 + 早期这里只刷了 background / border,没管前景,于是「深色侧栏 + 双列菜单」下 + 菜单文字仍是浅色档的深灰 #1f2329,压在 #1d2129 上(实测对比度 1.89,看不见)。 + + ⚠️ is-dark 这个类本身**要保留**(模板上仍在):styles/skin-shadcn.scss:123 + 用 `html[data-g3-style='shadcn'] .layout-mixed-column.is-dark` 提权覆盖色板, + 删掉类会让 shadcn 皮肤的深色侧栏静默失效。 */ /* logo 文字行(对照 vben:VbenLogo 放进 #extra-title)。 高度由模板行内 style 给(顶栏高 - 1,与 vben 的 extraTitleStyle 一致)。 diff --git a/code/g3soft-erp/web/src/layouts/LayoutMixedRail.vue b/code/g3soft-erp/web/src/layouts/LayoutMixedRail.vue index a148cd47..185c6bfe 100644 --- a/code/g3soft-erp/web/src/layouts/LayoutMixedRail.vue +++ b/code/g3soft-erp/web/src/layouts/LayoutMixedRail.vue @@ -176,11 +176,15 @@ defineExpose({ railItems, activeRootKey, isLeafOnly }) white-space: nowrap; } - /* 深色侧栏(semiDarkSidebar):与 LayoutSidebar 的 .is-dark 同一组值。 - 全局深色(html.dark)由 DefaultLayout 根上的 .dark 规则统一处理。 */ - &.is-dark { - background: #1d2129; - border-right-color: #2e3238; - } + /* 深色侧栏:这里**不写任何颜色**。 + 底色与前景(文字 / hover / 选中 / 标题)全部由 DefaultLayout 的 .layout + 调色板按 .dark / .semi-dark-sidebar 整体切换 —— 本列是 .layout 的子节点, + 直接继承那组 --sidebar-*。 + 早期这里只刷了 background / border,没管前景,于是「深色侧栏 + 双列菜单」下 + 文字仍是浅色档的深灰 #1f2329,压在 #1d2129 上(实测对比度 1.89,看不见)。 + + ⚠️ is-dark 这个类本身**要保留**(模板上仍在):styles/skin-shadcn.scss:122 + 用 `html[data-g3-style='shadcn'] .layout-mixed-rail.is-dark` 提权覆盖色板, + 删掉类会让 shadcn 皮肤的深色侧栏静默失效。 */ } diff --git a/code/g3soft-erp/web/src/views/ocean/Booking.vue b/code/g3soft-erp/web/src/views/ocean/Booking.vue index 108dbe3d..40d2f37a 100644 --- a/code/g3soft-erp/web/src/views/ocean/Booking.vue +++ b/code/g3soft-erp/web/src/views/ocean/Booking.vue @@ -1,10 +1,318 @@ + + diff --git a/code/g3soft-libs/docs/.vitepress/config.mts b/code/g3soft-libs/docs/.vitepress/config.mts index d4b3c2e9..ea2b7de5 100644 --- a/code/g3soft-libs/docs/.vitepress/config.mts +++ b/code/g3soft-libs/docs/.vitepress/config.mts @@ -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[], }, diff --git a/code/g3soft-libs/docs/.vitepress/theme/G3ApiParams.vue b/code/g3soft-libs/docs/.vitepress/theme/G3ApiParams.vue new file mode 100644 index 00000000..c9c995b3 --- /dev/null +++ b/code/g3soft-libs/docs/.vitepress/theme/G3ApiParams.vue @@ -0,0 +1,38 @@ + + + diff --git a/code/g3soft-libs/docs/.vitepress/theme/G3ApiPayload.vue b/code/g3soft-libs/docs/.vitepress/theme/G3ApiPayload.vue new file mode 100644 index 00000000..e309670c --- /dev/null +++ b/code/g3soft-libs/docs/.vitepress/theme/G3ApiPayload.vue @@ -0,0 +1,57 @@ + + + diff --git a/code/g3soft-libs/docs/.vitepress/theme/api.css b/code/g3soft-libs/docs/.vitepress/theme/api.css new file mode 100644 index 00000000..c39cf312 --- /dev/null +++ b/code/g3soft-libs/docs/.vitepress/theme/api.css @@ -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 里加一个「把首词换成 」的 + 小插件即可,slug 由文本 token 决定,不受影响。 + + 作用域:三张 API 页都带 frontmatter `pageClass: g3-api`(VitePress 把该 class + 落到根
上,见主题 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 的 影响) */ +.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; +} diff --git a/code/g3soft-libs/docs/.vitepress/theme/custom.css b/code/g3soft-libs/docs/.vitepress/theme/custom.css index a0e58119..e013c6fa 100644 --- a/code/g3soft-libs/docs/.vitepress/theme/custom.css +++ b/code/g3soft-libs/docs/.vitepress/theme/custom.css @@ -694,3 +694,80 @@ .vitepress-demo-plugin__container > .vitepress-demo-plugin-preview { overflow-x: auto; } + +/* -------------------------------------------------------------------------- + ⚠ 必须还原 自身:`.vp-doc table { display:block; overflow-x:auto; + margin:20px 0; border-collapse:collapse }` + -------------------------------------------------------------------------- + 这一整条是 VitePress 给 markdown 表格做「宽表横向滚动 + 上下留白」用的, + 但它命中了 G3Table 内部的 `
`,四个属性逐个都有害: + + 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; +} diff --git a/code/g3soft-libs/docs/.vitepress/theme/index.ts b/code/g3soft-libs/docs/.vitepress/theme/index.ts index 6bde867b..84a0f8d0 100644 --- a/code/g3soft-libs/docs/.vitepress/theme/index.ts +++ b/code/g3soft-libs/docs/.vitepress/theme/index.ts @@ -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 里直接写 / , + // 与 一样是「不 import 就能用」的形态。 + app.component('G3ApiParams', G3ApiParams) + app.component('G3ApiPayload', G3ApiPayload) + }, } satisfies Theme diff --git a/code/g3soft-libs/docs/content/api/data.md b/code/g3soft-libs/docs/content/api/data.md new file mode 100644 index 00000000..4f7b50f7 --- /dev/null +++ b/code/g3soft-libs/docs/content/api/data.md @@ -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` + +探测表或视图的列结构,用于动态列、字段同步。 + + + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `table` | string | 是 | 表或视图名 | + + + + + +```json +{ + "table": "v_b_othercompany" +} +``` + + + + + +```json +{ + "code": 0, + "message": "操作成功", + "data": [ + { "name": "b_id", "type": "varchar", "nullable": false, "size": 32 }, + { "name": "b_name", "type": "nvarchar", "nullable": true, "size": 128 } + ] +} +``` + + + +`type` 是数据库原生类型名(`varchar` / `nvarchar` / `decimal` / `datetime2` / `rowversion` …)。表不存在或没有可识别字段时返回 `1000`。 + +## `POST` `/data/loaddata` + +按视图 + 条件 + 排序 + 字段列表查询。 + + + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `view_name` | string | 是 | 表或视图名 | +| `search_columns` | string[] | | 只取这些列;省略等价于 `*` | +| `search_condition` | string | | `WHERE` 片段 | +| `order_by` | string | | 排序,多列逗号分隔 | + + + + + +```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" +} +``` + + + + + +```json +{ + "code": 0, + "message": "操作成功", + "data": [{ "b_id": "C001", "b_name": "XX 公司", "b_canuse": 1 }] +} +``` + + + +> **没有行数上限。** `loaddata` 会把命中的行全部返回;列表页请用 `/data/page`。它的典型用途是取一个小字典(下拉选项)或按主键取单行。 + +## `POST` `/data/page` + +分页查询。 + + + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `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` | + + + + + +```json +{ + "view_name": "v_b_othercompany", + "search_condition": "b_canuse = 1", + "order_by": "b_id ASC", + "page_no": 2, + "page_size": 20 +} +``` + + + + + +```json +{ + "code": 0, + "message": "操作成功", + "data": { + "rows": [{ "b_id": "C021", "b_name": "XX 公司" }], + "row_count": 20, + "total": 137, + "page_no": 2, + "page_size": 20 + } +} +``` + + + + + +| 字段 | 说明 | +|---|---| +| `rows` | 当前页数据 | +| `row_count` | **当前页**的行数(等于 `rows.length`) | +| `total` | 命中总行数 | +| `page_no` / `page_size` | 回显请求里的分页参数 | + + + +> `row_count` 不是总行数,**总行数是 `total`**。算总页数用 `total`,别用 `row_count`。 +> +> `page_no` 或 `page_size` 小于 1、或不是整数,都会得到 `1000`(`page_no 必须是大于 0 的整数`)。 + +翻到最后一页之后(`page_no` 超出范围)`rows` 为空,但 `total` 仍然正确 —— 所以判断「没数据」和「翻过了头」要靠 `total`。 + +## `POST` `/data/loaddatabysql` + +配置表达不出来的复杂只读查询走这里。 + + + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `sql` | string | 是 | 必须以 `SELECT` 或 `WITH` 开头 | + + + + + +```json +{ + "sql": "SELECT TOP 10 b_id, b_name FROM v_b_othercompany ORDER BY b_id" +} +``` + + + + + +```json +{ + "code": 0, + "message": "操作成功", + "data": [{ "b_id": "C001", "b_name": "XX 公司" }] +} +``` + + + +被拒的情况(都是 `1000`):不是 `SELECT` / `WITH` 开头、包含 `;`、包含注释(`--` 或 `/* */`)、出现 `insert` `update` `delete` `drop` `truncate` `alter` `exec` `grant` 等关键字。 + +> 判定前会先把**字符串字面量整体掩掉**,所以在 `WHERE b_name = 'insert'` 这类写法里,字面量里的关键字不会误判。反过来也说明:不能在字面量之外藏写操作。 + +## `POST` `/data/nextid` + +取雪花 ID。 + + + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `count` | number | | 默认 `1`;`<= 1` 时 `data` 是单个 id,否则是数组 | + + + +请求体**可以整个省略** —— 不带 body 或传 `{}` 是等价的,`count` 都取默认的 `1`: + + + +```json +{} +``` + + + + + +```json +{ + "code": 0, + "message": "操作成功", + "data": "1873456789012345678" +} +``` + + + +`count: 3` 时: + + + +```json +{ + "code": 0, + "message": "操作成功", + "data": [ + "1873456789012345678", + "1873456789012345679", + "1873456789012345680" + ] +} +``` + + + +> 返回的是**字符串**(见 [快速开始](/api/getting-started) 里的序列化约定),不要转成数字。 + +## `POST` `/data/nextcode` + +按模块配置的自动编码规则取业务编码(规则来自 `s_autocode`:前缀、日期格式、序号位数、分隔符、重置周期等)。 + + + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `moduleId` | string | 是 | 模块编码(注意是 camelCase 的 `moduleId`) | +| `count` | number | | 默认 `1`;`<= 1` 时返回单个字符串,否则返回数组 | + + + + + +```json +{ + "moduleId": "othercompany", + "count": 1 +} +``` + + + + + +```json +{ + "code": 0, + "message": "操作成功", + "data": "HT20261012001" +} +``` + + + +行为要点: + +- **会占用号段** —— 服务端取号后立即把序号写回 `s_autocode`,所以连续调用得到的是不同编码。别拿它做「探测一下看看格式」的试调,试出来的号就废了。 +- 生成时会**检查目标表里该编码是否已存在**,已被占用(比如人工插过一条)就自动往后顺延,所以不一定严格等于「当前序号 + 1」。 +- 模块没有配置启用的编码规则、或没配保存表 → `1000`(`当前模块未配置启用的自动编码规则` / `模块未配置保存表,无法生成自动编号`)。 +- 生成的编码超过目标字段长度 → `1000`(`自动编号长度超过目标字段长度`)。 diff --git a/code/g3soft-libs/docs/content/api/getting-started.md b/code/g3soft-libs/docs/content/api/getting-started.md index a49e0bb6..92b302cd 100644 --- a/code/g3soft-libs/docs/content/api/getting-started.md +++ b/code/g3soft-libs/docs/content/api/getting-started.md @@ -1,10 +1,200 @@ --- title: 快速开始 -description: 前后端接口的接入说明 +description: 接口约定、鉴权与联调步骤 +pageClass: g3-api --- # 快速开始 -本页说明前后端接口的接入方式,内容包括接口约定、鉴权与联调步骤。 +后端提供的是一套**元数据驱动的通用数据接口**:前端不拼业务 SQL,只声明「查哪张视图 / 存哪张表 + 条件 + 字段」,由后端按 `s_module` / `s_field` 里的配置生成参数化 SQL 并执行。除登录外,所有接口共用同一套鉴权、机构数据源路由与统一响应格式。 -> 内容待补充。 +## 基础约定 + + + +| 项 | 约定 | +|---|---| +| 基础路径 | `/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 状态码判断 | + + + +最后一条最容易踩:鉴权失败、参数错误、系统异常**都不会**返回 4xx / 5xx,而是 200 + `code != 0`。所以不要依赖 `axios` 的错误拦截分支来处理业务失败。 + +## 统一响应格式 + +所有接口返回同一个外壳: + + + +```json +{ + "code": 0, + "message": "操作成功", + "data": {} +} +``` + + + + + +| 字段 | 说明 | +|---|---| +| `code` | `0` 成功,非 0 见下表 | +| `message` | 可直接展示给用户的文案;出错时是原因 | +| `data` | 成功时的载荷;失败时一般为 `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` | 系统异常 | 未预期的异常 | 提示「系统异常,请稍后重试」 | + + + +参数类错误都是 `1000`,`message` 形如 `view_name 不能为空`、`page_no 必须是大于 0 的整数`、`参数类型不正确`、`非法数据库标识符: xxx` —— 通常已经说清是哪个参数错了。 + +## 鉴权 + +### 登录 + + + +```bash +curl -X POST http://localhost:8088/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"orgid":"G3HD","userid":"admin","password":"******"}' +``` + + + + + +```json +{ + "code": 0, + "message": "操作成功", + "data": { + "token": "eyJhbGciOiJIUzI1NiJ9...", + "orgid": "G3HD", + "user": { "id": "g3soft", "account": "g3soft", "name": "G3Soft" } + } +} +``` + + + + + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `orgid` | string | 是 | 机构码,决定连哪个机构的数据库 | +| `userid` | string | 是 | 账号;字符串业务键方案下 `b_user.b_id` 就是账号本身 | +| `password` | string | | 与库里存的密码比对 | + + + +机构码或账号为空 → `1000`(`机构码不能为空` / `用户ID不能为空`);账号、密码、机构任一对不上 → 统一 `401` + `机构码、账号或密码错误`(刻意不区分是哪一个错)。 + +### 带上凭证 + +除 `/api/auth/login` 与 `OPTIONS` 预检外,**所有接口都要带**: + + + +``` +Authorization: Bearer +``` + + + +> **机构码不用在业务接口里传。** token 里带着 `orgId`,服务端据此把请求路由到该机构自己的数据库,前端只管带 token。 +> +> token 默认 24 小时过期(`erp.auth.jwt-expiration`)。同一账号再次登录会让旧 token 失效并返回 `40101`。 + +## 最小可跑示例 + + + +```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 +``` + + + +## 一个必须知道的序列化约定 + +**所有长整型(`Long`)都以 JSON 字符串输出**(后端 `JacksonConfig` 给 `Long` / `long` 注册了 `ToStringSerializer`): + + + +```json +{ + "code": 0, + "message": "操作成功", + "data": "1873456789012345678" +} +``` + + + +原因是雪花 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` 被放行),所以不能用 `` 直接访问,需要按 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` 基本能直接告诉你哪个参数不对。 diff --git a/code/g3soft-libs/docs/content/api/save.md b/code/g3soft-libs/docs/content/api/save.md new file mode 100644 index 00000000..327dd04d --- /dev/null +++ b/code/g3soft-libs/docs/content/api/save.md @@ -0,0 +1,213 @@ +--- +title: 数据写入 +description: saveobjt / delete,含 $original、$expr 与 guards +pageClass: g3-api +--- + +# 数据写入 + +## `POST` `/data/saveobjt` + +请求体是一个**数组**:一次可提交多个表的增、改、删,**全部在同一个事务里** —— 任何一项失败整批回滚,不会留下半套数据。 + +### 请求字段 + + + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `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 | | 前置条件,见下 | + + + + + +```json +[ + { + "table": "b_othercompany", + "key_field": "b_id", + "updates": [{ "b_id": "C001", "b_name": "新名字" }], + "inserts": [{ "b_id": "C002", "b_name": "新公司" }], + "deletes": [{ "b_id": "C003" }] + } +] +``` + + + +成功时 `data` 为 `null`,`message` 为 `保存成功`: + + + +```json +{ + "code": 0, + "message": "保存成功", + "data": null +} +``` + + + +> **没传 `delete_target` 时,`deletes` 是逐行按主键直删**,不会触发模块配置里的联动删除。需要联动就把 `delete_target` 声明成 `module` 并带上 `moduleCode`。 + +### 行内的两个保留键 + +键名以 `$` 开头的不会被当成业务列: + + + +| 键 | 用途 | 说明 | +|---|---|---| +| `$original` | 乐观锁:声明「我改动之前看到的值」 | 这些字段**只进 `WHERE` 不进 `SET`**;对不上就是 `2003`。值为 `null` 时生成 `IS NULL` | +| `$expr` | 字段自增:`{"$expr": "b_amount + 5"}` | 只支持「本表列 ± 数值常量」,列必须是数值类型 | + + + + + +```json +{ + "b_id": "C001", + "b_amount": { "$expr": "b_amount + 5" }, + "$original": { "b_name": "旧名字", "b_row_version": 3 } +} +``` + + + +- `$original` 里带 `b_row_version` 时走**整行版本锁**:服务端校验通过后自动 `+1`,请求里同名的普通值不生效(避免 `SET` 里出现两次该列)。 +- `inserts` 的行**不允许**带 `$original` / `$expr` —— 新增的行没有原值可比,带了会直接报 `1000`。 +- `$expr` 的字段不存在、不可写、不是数值类型、格式不对,都会在**任何一行写入之前**报 `1000`(这类参数错误不会等到写到第 N 行才暴露)。 + +### guards:写入前置条件 + +让「判断」和「写入」发生在**同一条 SQL** 里,避免先查后写的竞态。 + + + +```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 + } + ] + } +} +``` + + + +`guards` 下的三个分节键按动作区分 —— `inserts` / `updates` / **`deletes`**(注意是 `deletes`): + + + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `condition` | string | 是 | SQL 布尔表达式;`{列名}` 是受控占位符,替换为 `t.[列名]`;**不允许注释** | +| `refs` | string[] | | 声明引用的表,只进日志 | +| `message` | string | | 条件不成立时给用户看的文案 | +| `retryable` | boolean | | 默认 `true`;`false` 时前端文案不追加「请刷新后重试」 | + + + +条件不成立时返回 `2002`,`message` 就是上面写的 `message`,`data` 带 `table` / `action` / `index`。 + +两条互斥规则(同时用会报 `1000`): + +- `guards.deletes` 与 `delete_target` 互斥; +- `guards.deletes` 与 `dependent_deletes` 互斥。 + +原因是带条件的删除只能逐行唯一定位,而 `delete_target` / 依赖删除那两条旁路拿不到 guards —— 与其让条件被静默忽略,不如直接拒绝。 + +### 三个错误码怎么分 + + + +| code | 含义 | 典型原因 | `data` 里的定位信息 | +|---|---|---|---| +| `2001` | 保存失败 | 字段不存在、类型不匹配、数据库约束冲突等 | `table` / `action` / `index` / `cause` | +| `2002` | 前置条件不成立 | `guards` 里的条件没满足 | `table` / `action` / `index` | +| `2003` | 并发编辑冲突 | `$original` 的原值已被别人改过、`b_row_version` 不匹配 | `table` / `action` / `index` / `fields` | + + + +`2003` 的正确处理不是重试,而是:**重新拉取该行**,用 `data.fields` 标出哪些字段已被他人修改,让用户决定覆盖还是放弃。 + +`action` 的取值是 `update` / `insert` / `delete`,`index` 是请求数组里的下标(从 `0` 开始)—— 批量保存失败时靠它定位到具体哪一行。 + +## `POST` `/data/delete` + +独立删除接口。和 `saveobjt` 的区别是它**不进保存事务**、也不涉及增改。 + + + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `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` 下为空数组会直接返回 `{}` | + + + +按模块删除(会按模块配置联动删除子表): + + + +```json +{ + "target": "module", + "moduleCode": "othercompany", + "ids": ["C001", "C002"] +} +``` + + + +指定表直接删除: + + + +```json +{ + "target": "direct", + "table": "b_othercompany", + "key_field": "b_id", + "ids": ["C001"] +} +``` + + + + + +```json +{ + "code": 0, + "message": "操作成功", + "data": { "b_othercompany": 2 } +} +``` + + + +`data` 是 **`{表名: 实际删除行数}`** —— `direct` 只有一项;`module` 会带出所有被联动删除的表,所以可能有多项。整个删除同样在单个事务里完成。 + +> `target` 必须显式声明。后端**刻意不做**「没传 `moduleCode` 就当直删」这种隐式分支,避免漏传参数时静默绕过模块规则。 diff --git a/code/g3soft-libs/docs/content/ui/data/table.md b/code/g3soft-libs/docs/content/ui/data/table.md index e2ba1b5c..f1230e5c 100644 --- a/code/g3soft-libs/docs/content/ui/data/table.md +++ b/code/g3soft-libs/docs/content/ui/data/table.md @@ -15,7 +15,7 @@ description: 高性能虚拟滚动表格,支持排序、固定列、多选、 ## 基础用法 -给 `columns` 与 `dataSource` 两份数据即可。序号列(`seq`)默认开启,外框与圆角默认有(`bordered`)。 +给 `columns` 与 `dataSource` 两份数据即可。序号列(`seq`)默认开启,边框(`bordered`)默认有。 @@ -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([]) +watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }, { immediate: true }) +``` + +用错时的症状**极具迷惑性**,不容易联想到数据源: + +- `change` 事件**照常抛出**(数据其实已经改了,父级能拿到) +- 但界面上**仍显示旧值** +- 点一下别处(触发无关重渲染)才「突然」更新 +- 尤其是 `select` 编辑器:选完选项下拉关了、值却没变,很像组件的事件时序 bug + +开发模式下,进入编辑时会检测并打印一条警告指明改法(每个组件实例只警告一次)。 + ## 操作列 @@ -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` 处理。 diff --git a/code/g3soft-libs/docs/examples/table/actions.vue b/code/g3soft-libs/docs/examples/table/actions.vue index 89b9db7c..a243f33f 100644 --- a/code/g3soft-libs/docs/examples/table/actions.vue +++ b/code/g3soft-libs/docs/examples/table/actions.vue @@ -1,20 +1,29 @@ diff --git a/code/g3soft-libs/docs/examples/table/basic.vue b/code/g3soft-libs/docs/examples/table/basic.vue index 3ab6d1d7..096e6329 100644 --- a/code/g3soft-libs/docs/examples/table/basic.vue +++ b/code/g3soft-libs/docs/examples/table/basic.vue @@ -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[] = [ { dataIndex: 'name', title: '名称', width: 180 }, { dataIndex: 'amount', title: '金额', width: 120, align: 'right' }, { dataIndex: 'owner', title: '负责人' }, ] -// seq 默认开启(最前面的序号列),bordered 默认 true const data = ref([ { id: 1, name: '框架合同', amount: 120000, owner: '张三' }, { id: 2, name: '采购订单', amount: 86000, owner: '李四' }, diff --git a/code/g3soft-libs/docs/examples/table/editable.vue b/code/g3soft-libs/docs/examples/table/editable.vue index 3b73b463..a74c853c 100644 --- a/code/g3soft-libs/docs/examples/table/editable.vue +++ b/code/g3soft-libs/docs/examples/table/editable.vue @@ -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[] = [ { 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[] = [ }, }, { 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([ - { 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, value: unknown) { - lastChange.value = `${row.name} 的「${col.title}」改为 ${value}` + // 只有值**真的变化**才会走到这里(组件内部按编辑前的快照去重), + // 所以「点进去又点出来」不会产生无意义的 change。 + lastChange.value = `「${row.name}」的「${col.title}」→ ${value}` }