From 1d7d75eeba428450812234ce368bf44badb6a820 Mon Sep 17 00:00:00 2001 From: ZhangAo Date: Sat, 17 Oct 2026 17:17:39 +0800 Subject: [PATCH] u --- code/g3soft-erp/CODEBUDDY.md | 49 +- code/g3soft-erp/web/.env.development | 28 +- code/g3soft-erp/web/TROUBLESHOOTING.md | 2 +- code/g3soft-erp/web/package.json | 1 + .../src/components/biz-tree/BizTreePanel.vue | 363 +++- .../web/src/components/table/G3Table.vue | 98 + .../web/src/components/table/index.ts | 1 + .../web/src/composables/useBizTree.ts | 547 ++++++ code/g3soft-erp/web/src/dev/auto-login.ts | 9 +- code/g3soft-erp/web/src/dev/credentials.ts | 36 + code/g3soft-erp/web/src/router/index.ts | 4 +- code/g3soft-erp/web/src/types/env.d.ts | 6 +- code/g3soft-erp/web/src/utils/querySchema.ts | 621 +++++++ code/g3soft-erp/web/src/utils/schemaJson.ts | 10 +- code/g3soft-erp/web/src/views/auth/Login.vue | 114 +- .../{I18nType.vue => i18n-type/index.vue} | 0 .../module/menu-management/MenuTreePanel.vue | 246 --- .../index.vue} | 145 +- .../module-management/ModuleFieldsPanel.vue | 4 +- .../module-management/ModuleI18nPanel.vue | 4 +- .../module-management/ModulePowerPanel.vue | 5 +- .../module-management/ModuleQueryPanel.vue | 1018 ++++++++++ .../module-management/ModuleTreePanel.vue | 266 --- .../views/module/module-management/index.vue | 218 ++- .../web/src/views/table-test/index.vue | 19 +- .../components/biz-tree/BizTreePanel.spec.ts | 232 ++- .../web/tests/composables/useBizTree.spec.ts | 404 ++++ .../web/tests/utils/querySchema.spec.ts | 504 +++++ .../menu-management/MenuTreePanel.spec.ts | 119 -- .../module-management/ModuleTreePanel.spec.ts | 159 -- .../.changeset/table-cell-status-shapes.md | 18 - .../.changeset/table-checkbox-current-cell.md | 11 - .../.changeset/table-date-editor.md | 18 - .../.changeset/table-editing-cell-visual.md | 12 - .../.changeset/table-fixed-columns.md | 17 - .../.changeset/table-keyboard-arrows-enter.md | 30 - .../.changeset/table-no-vertical-lines.md | 14 - .../.changeset/table-number-precision.md | 23 - .../.changeset/table-readonly-grey-gate.md | 13 - .../.changeset/table-tab-navigation.md | 14 - .../table-validation-editing-yield.md | 13 - code/g3soft-libs/COMMIT_MESSAGE.md | 94 - code/g3soft-libs/docs/.vitepress/config.mts | 1 - .../g3soft-libs/docs/content/ui/data/table.md | 286 --- .../docs/content/ui/usage/typography.md | 78 +- .../docs/examples/table/actions.vue | 83 - .../g3soft-libs/docs/examples/table/basic.vue | 27 - .../docs/examples/table/editable.vue | 101 - .../docs/examples/table/resizable.vue | 100 - .../docs/examples/table/selection.vue | 51 - .../docs/examples/table/sorting.vue | 48 - .../docs/examples/table/virtual.vue | 36 - code/g3soft-libs/docs/table-design.md | 328 ---- .../packages/ui/e2e-docs/table-docs.spec.ts | 258 --- .../packages/ui/e2e-erp/booking.spec.ts | 60 - .../packages/ui/e2e-erp/repro-select.spec.ts | 87 - .../packages/ui/e2e-erp/verify-fix.spec.ts | 202 -- .../ui/e2e-erp/verify-rowheight.spec.ts | 65 - .../g3soft-libs/packages/ui/e2e/table.spec.ts | 1656 ----------------- code/g3soft-libs/packages/ui/package.json | 3 +- .../packages/ui/playground/main.ts | 251 --- .../packages/ui/playwright.docs.config.ts | 2 +- .../packages/ui/playwright.erp.config.ts | 16 - .../packages/ui/probe-boundary.mjs | 65 - .../g3soft-libs/packages/ui/probe-correct.mjs | 71 - code/g3soft-libs/packages/ui/probe-cover.mjs | 91 - code/g3soft-libs/packages/ui/probe-h80.mjs | 106 -- .../packages/ui/probe-overflow.mjs | 69 - .../packages/ui/probe-pixel-sweep.mjs | 81 - code/g3soft-libs/packages/ui/probe-planb.mjs | 104 -- code/g3soft-libs/packages/ui/probe-rgb.mjs | 69 - code/g3soft-libs/packages/ui/probe-scroll.mjs | 78 - .../g3soft-libs/packages/ui/probe-scroll2.mjs | 79 - code/g3soft-libs/packages/ui/probe-shadow.mjs | 92 - code/g3soft-libs/packages/ui/probe-sweep.mjs | 78 - .../packages/ui/probe-table-ab.mjs | 383 ---- .../packages/ui/probe-table-attrib.mjs | 254 --- .../packages/ui/probe-table-attrib2.mjs | 450 ----- .../packages/ui/probe-table-churn.mjs | 231 --- .../packages/ui/probe-table-decisive.mjs | 430 ----- .../packages/ui/probe-table-final.mjs | 378 ---- .../packages/ui/probe-table-gpu.mjs | 173 -- .../packages/ui/probe-table-loaf.mjs | 278 --- .../packages/ui/probe-table-perf.mjs | 284 --- .../packages/ui/probe-table-prod.mjs | 260 --- .../packages/ui/probe-table-separate.mjs | 259 --- .../packages/ui/probe-table-trace.mjs | 334 ---- code/g3soft-libs/packages/ui/probe-which.mjs | 41 - code/g3soft-libs/packages/ui/src/index.ts | 3 - .../packages/ui/src/table/G3Table.vue | 1112 ----------- .../packages/ui/src/table/index.ts | 20 - .../ui/src/table/style/_overrides.scss | 121 -- .../packages/ui/src/table/style/index.scss | 21 - .../packages/ui/src/table/types.ts | 197 -- .../packages/ui/src/table/utils/actions.ts | 260 --- .../packages/ui/src/table/utils/editable.ts | 317 ---- .../packages/ui/src/table/utils/selection.ts | 162 -- code/g3soft-libs/pnpm-lock.yaml | 8 - 98 files changed, 4144 insertions(+), 12063 deletions(-) create mode 100644 code/g3soft-erp/web/src/components/table/G3Table.vue create mode 100644 code/g3soft-erp/web/src/components/table/index.ts create mode 100644 code/g3soft-erp/web/src/composables/useBizTree.ts create mode 100644 code/g3soft-erp/web/src/dev/credentials.ts create mode 100644 code/g3soft-erp/web/src/utils/querySchema.ts rename code/g3soft-erp/web/src/views/module/{I18nType.vue => i18n-type/index.vue} (100%) delete mode 100644 code/g3soft-erp/web/src/views/module/menu-management/MenuTreePanel.vue rename code/g3soft-erp/web/src/views/module/{MenuManagement.vue => menu-management/index.vue} (87%) create mode 100644 code/g3soft-erp/web/src/views/module/module-management/ModuleQueryPanel.vue delete mode 100644 code/g3soft-erp/web/src/views/module/module-management/ModuleTreePanel.vue create mode 100644 code/g3soft-erp/web/tests/composables/useBizTree.spec.ts create mode 100644 code/g3soft-erp/web/tests/utils/querySchema.spec.ts delete mode 100644 code/g3soft-erp/web/tests/views/module/menu-management/MenuTreePanel.spec.ts delete mode 100644 code/g3soft-erp/web/tests/views/module/module-management/ModuleTreePanel.spec.ts delete mode 100644 code/g3soft-libs/.changeset/table-cell-status-shapes.md delete mode 100644 code/g3soft-libs/.changeset/table-checkbox-current-cell.md delete mode 100644 code/g3soft-libs/.changeset/table-date-editor.md delete mode 100644 code/g3soft-libs/.changeset/table-editing-cell-visual.md delete mode 100644 code/g3soft-libs/.changeset/table-fixed-columns.md delete mode 100644 code/g3soft-libs/.changeset/table-keyboard-arrows-enter.md delete mode 100644 code/g3soft-libs/.changeset/table-no-vertical-lines.md delete mode 100644 code/g3soft-libs/.changeset/table-number-precision.md delete mode 100644 code/g3soft-libs/.changeset/table-readonly-grey-gate.md delete mode 100644 code/g3soft-libs/.changeset/table-tab-navigation.md delete mode 100644 code/g3soft-libs/.changeset/table-validation-editing-yield.md delete mode 100644 code/g3soft-libs/COMMIT_MESSAGE.md delete mode 100644 code/g3soft-libs/docs/content/ui/data/table.md delete mode 100644 code/g3soft-libs/docs/examples/table/actions.vue delete mode 100644 code/g3soft-libs/docs/examples/table/basic.vue delete mode 100644 code/g3soft-libs/docs/examples/table/editable.vue delete mode 100644 code/g3soft-libs/docs/examples/table/resizable.vue delete mode 100644 code/g3soft-libs/docs/examples/table/selection.vue delete mode 100644 code/g3soft-libs/docs/examples/table/sorting.vue delete mode 100644 code/g3soft-libs/docs/examples/table/virtual.vue delete mode 100644 code/g3soft-libs/docs/table-design.md delete mode 100644 code/g3soft-libs/packages/ui/e2e-docs/table-docs.spec.ts delete mode 100644 code/g3soft-libs/packages/ui/e2e-erp/booking.spec.ts delete mode 100644 code/g3soft-libs/packages/ui/e2e-erp/repro-select.spec.ts delete mode 100644 code/g3soft-libs/packages/ui/e2e-erp/verify-fix.spec.ts delete mode 100644 code/g3soft-libs/packages/ui/e2e-erp/verify-rowheight.spec.ts delete mode 100644 code/g3soft-libs/packages/ui/e2e/table.spec.ts delete mode 100644 code/g3soft-libs/packages/ui/playwright.erp.config.ts delete mode 100644 code/g3soft-libs/packages/ui/probe-boundary.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-correct.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-cover.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-h80.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-overflow.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-pixel-sweep.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-planb.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-rgb.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-scroll.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-scroll2.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-shadow.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-sweep.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-table-ab.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-table-attrib.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-table-attrib2.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-table-churn.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-table-decisive.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-table-final.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-table-gpu.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-table-loaf.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-table-perf.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-table-prod.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-table-separate.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-table-trace.mjs delete mode 100644 code/g3soft-libs/packages/ui/probe-which.mjs delete mode 100644 code/g3soft-libs/packages/ui/src/table/G3Table.vue delete mode 100644 code/g3soft-libs/packages/ui/src/table/index.ts delete mode 100644 code/g3soft-libs/packages/ui/src/table/style/_overrides.scss delete mode 100644 code/g3soft-libs/packages/ui/src/table/style/index.scss delete mode 100644 code/g3soft-libs/packages/ui/src/table/types.ts delete mode 100644 code/g3soft-libs/packages/ui/src/table/utils/actions.ts delete mode 100644 code/g3soft-libs/packages/ui/src/table/utils/editable.ts delete mode 100644 code/g3soft-libs/packages/ui/src/table/utils/selection.ts diff --git a/code/g3soft-erp/CODEBUDDY.md b/code/g3soft-erp/CODEBUDDY.md index 5912a6a2..9b80e1ef 100644 --- a/code/g3soft-erp/CODEBUDDY.md +++ b/code/g3soft-erp/CODEBUDDY.md @@ -33,22 +33,36 @@ mvn "-Dspring-boot.run.jvmArguments=-Djava.security.properties=./config/legacy-t ## 开发期免登录(省掉每次填账号 + 拖滑块) -`web/.env.development` 里配好了测试账号,`pnpm dev` 打开页面**已是登录态**: +两条互补的路径,共用 `web/.env.development` 里的同一组 `VITE_DEV_*`: + +| 场景 | 机制 | +|---|---| +| 打开任意非 `/login` 页面 | `web/src/dev/auto-login.ts` 自动登录,页面直接是登录态 | +| 打开 `/login`(或点了退出) | 登录页底部的「**一键登录**」按钮,一步进去 | + +登录页的按钮只在 **DEV 且 `VITE_DEV_ORG`/`VITE_DEV_ACCOUNT` 配齐**时渲染(`devCred`), +调的是同一个 `loginApi`,因此同样不需要滑块验证码。 + +`web/.env.development` 里配好了测试账号: | 变量 | 值 | 说明 | |---|---|---| -| `VITE_DEV_AUTO_LOGIN` | `1` | 设为 `0` 关闭(例如要调登录页本身) | +| `VITE_DEV_AUTO_LOGIN` | `1` | 设为 `0` 关闭自动登录(例如要调登录页本身),**一键登录按钮不受它影响** | | `VITE_DEV_ORG` | `G3HD` | 机构码,需对应 `server/config/dbconfigs/{机构码}.properties` | -| `VITE_DEV_ACCOUNT` | `demo_zhangwei` | 实测可登录,但**只有审批中心权限**(见下) | -| `VITE_DEV_PASSWORD` | `demo_zhangwei` | 演示账号密码=账号名 | +| `VITE_DEV_ACCOUNT` | `g3soft` | 前后端唯一超管,免查 `s_power`,能进全部页面 | +| `VITE_DEV_PASSWORD` | `g3soft` | 2026-10-14 实测有效 | -实现见 `web/src/dev/auto-login.ts`:调 `POST /api/auth/login` 拿 token 写进 store, -**只在 `import.meta.env.DEV` 下生效**,生产构建里被 tree-shake。 +实现见 `web/src/dev/auto-login.ts`(自动登录)与 `web/src/dev/credentials.ts`(凭据读取, +两个消费方共用),**只在 `import.meta.env.DEV` 下生效**,生产构建里被 tree-shake。 滑块验证码是纯前端校验(不发请求),所以走接口登录不需要它。 -覆盖面:入口是 `/login` 时不自动登录;已有 token 时不重复登录。 +覆盖面:入口是 `/login` 时不自动登录(留给「一键登录」按钮);已有 token 时不重复登录。 个人覆盖用 `web/.env.development.local`(已被 gitignore,优先级更高)。 +> ⚠️ `g3soft` 是超管,且 `VITE_DEV_ACCOUNT` 已默认改成它 —— 若有人在用同一个 `g3soft` +> 跑系统,你的 dev server 会把他踢下线(反之亦然)。需要并行时用 +> `.env.development.local` 换回 `demo_zhangwei`(权限窄,只够进审批页)。 + 命令行取 token(例如要用 curl 直接打业务接口): ```bash @@ -90,7 +104,8 @@ pnpm test:e2e:ui # 带 UI 的调试模式 重复登录会互踢(业务码 `40101`)。并发 worker 各登录一次就会互相把对方踢下线。 > 所以:**不要在用例里提交登录表单**。要测登录流程本身,得用一个专用账号, -> 并把 `VITE_DEV_AUTO_LOGIN` 设为 `0`(否则应用自己会先把你登录了)。 +> 并把 `VITE_DEV_AUTO_LOGIN` 设为 `0`(否则应用自己会先把你登录了); +> 登录页那个「一键登录」按钮同样会真的登进去,测登录表单时别点它。 账号沿用 `web/.env.development` 的 `VITE_DEV_*`,可用 `E2E_ORG` / `E2E_ACCOUNT` / `E2E_PASSWORD` 覆盖;`E2E_BASE_URL` 换前端地址,`E2E_API_TARGET` 换后端地址,`E2E_CHANNEL=msedge|chrome` @@ -99,8 +114,9 @@ pnpm test:e2e:ui # 带 UI 的调试模式 ### 写用例时注意 - 落点别选 `/dashboard`:它**没有任何权限点**(`sql/20_registration/14_dashboard_menu.sql` - 明确「不建权限点」),只有超管可见;默认账号 `demo_zhangwei` 进它会被守卫送到 `/403`。 - `e2e/smoke.spec.ts` 用 `/approval/todo`,演示账号和超管都能进。 + 明确「不建权限点」),只有超管可见。默认走 `VITE_DEV_ACCOUNT=g3soft`(超管)时能进, + 但**换个非超管账号(`E2E_ACCOUNT=demo_zhangwei`)就会被守卫送到 `/403`**。 + `e2e/smoke.spec.ts` 用 `/approval/todo`,超管和演示账号都能进 —— 换账号也不会挂。 - 断言用 `toHaveURL(/\/approval\/todo$/)` 这种**正向**断言,别用 `not.toHaveURL(/\/login/)`: `goto` 之后 URL 立刻就是目标值,否定断言会在重定向发生之前就通过,等于什么都没断言。 - dev server 是按需转译的,某页**第一次**访问要现编依赖图,实测 5-11s。 @@ -110,16 +126,17 @@ pnpm test:e2e:ui # 带 UI 的调试模式 | 账号 | 密码 | 权限 | 用途 | |---|---|---|---| +| `g3soft` | `g3soft` | 前后端唯一超管,免查 `s_power`(`permission.ts:61`、`SqlPermissionService.ADMIN_ACCOUNT`) | **默认账号**,能进全部页面 | | `demo_zhangwei` / `demo_liqiang` / `demo_wangfang` / `demo_liumin` | 同账号名 | 只授了 `menu.approval%` 与 `action.v_b_othercompany.%`(`sql/90_mock/02_*.sql:186`) | 已实测可登录;侧栏只有「审批中心」,其他页面会被守卫拦到 `/403` | -| `g3soft` | **未知** | 前后端唯一超管,免查 `s_power`(`permission.ts:61`、`SqlPermissionService.ADMIN_ACCOUNT`) | 测全部页面得用它 | 机构码统一 `G3HD`。 -**`g3soft` 的密码待确认**:2026-10-14 实测 `G3HD/g3soft/空密码` 返回 `code=401` -(`AuthServiceTests` 里「超管空密码」是纯 mock,不代表 G3HD 库的真实数据)。 -拿到真实密码后填进 `web/.env.development` 的 `VITE_DEV_PASSWORD` 即可—— -**一次配置,不必每次查库**。另外 `g3soft` 在 `b_user` 里是否存在也需确认: -本仓库的 `sql/` 没有任何脚本创建它(只建 `demo_*`),假定它来自 G3HD 核心库。 +**`g3soft` 的密码已确认**:2026-10-14 实测 `G3HD/g3soft/g3soft` 登录成功 +(返回 `user.name = 超级管理员`);`/空密码` 返回 `code=401`。 +已填进 `web/.env.development` 的 `VITE_DEV_PASSWORD` —— **不必再查库**。 + +> 需要**并行**时才换账号:`g3soft` 是超管且默认,你和别人共用它就会互相踢下线, +> 此时用 `web/.env.development.local` 换回 `demo_zhangwei`(记得它只有审批页权限)。 账号密码不用去查库。库里没有的账号靠 `sql/` 下的脚本建(执行顺序见 `sql/README.md`); 要给 `demo_*` 放开更多菜单,用 `sql/README.md` 里「可选:给普通账号授权」那段。 diff --git a/code/g3soft-erp/web/.env.development b/code/g3soft-erp/web/.env.development index f737b821..9e28c249 100644 --- a/code/g3soft-erp/web/.env.development +++ b/code/g3soft-erp/web/.env.development @@ -7,26 +7,28 @@ # 只在 DEV 生效:构建时 import.meta.env.DEV 被替换为 false,整段逻辑被 tree-shake, # 不会进生产包。 # 置 0 可关闭,例如要调登录页本身时。 +# 另:入口是 /login 时不自动登录(见 src/dev/auto-login.ts), +# 此时用登录页的「一键登录」按钮,同样是这套账号。 VITE_DEV_AUTO_LOGIN=1 # 登录机构码:必须对应 server/config/dbconfigs/{机构码}.properties 里存在的机构。 VITE_DEV_ORG=G3HD -# 登录账号。 +# 登录账号:前后端唯一的超管(前端 permission.ts:61、后端 +# SqlPermissionService.ADMIN_ACCOUNT 都特判它),免查 s_power,能进全部页面。 # # 2026-10-14 实测(`node scripts/dev-login.mjs`): -# G3HD / demo_zhangwei / demo_zhangwei → 登录成功 +# G3HD / demo_zhangwei / demo_zhangwei → 登录成功,但只授了 menu.approval% +# (sql/90_mock/02_fms_workflow_runtime_mock.sql:186), +# 侧栏只有「审批中心」,其他页面被守卫拦到 /403 # G3HD / g3soft / 空密码 → code=401「机构码、账号或密码错误」 +# G3HD / g3soft / g3soft → 登录成功(超级管理员) # -# g3soft 是前后端唯一的超管账号(前端 permission.ts:61、后端 -# SqlPermissionService.ADMIN_ACCOUNT),但 G3HD 库里它的 b_password 不是空串 -# (也可能该账号不存在)。把真实密码填进 VITE_DEV_PASSWORD 即可 —— 一次配置, -# 不必每次去查库。 -# -# 当前默认用 demo_zhangwei:开箱可用,但它只授了 menu.approval% 权限 -# (sql/90_mock/02_fms_workflow_runtime_mock.sql:186),侧栏只有「审批中心」, -# 访问其他页面会被守卫拦到 /403。要测全部页面就换成 g3soft(需密码)。 -VITE_DEV_ACCOUNT=demo_zhangwei +# 用 g3soft 而非 demo_*:只有它能进全部页面,调非审批页时不用再换账号。 +# ⚠️ 同一账号重复登录会互踢(后端 ActiveSessionRegistry,业务码 40101)。 +# 若有人在用 g3soft 跑系统,你的 dev server 会把他踢下线(反之亦然)。 +# 需要并行时在 web/.env.development.local 里换回 VITE_DEV_ACCOUNT=demo_zhangwei。 +VITE_DEV_ACCOUNT=g3soft -# 登录密码:演示账号的密码就是账号本身(见 sql/README.md)。 -VITE_DEV_PASSWORD=demo_zhangwei +# 登录密码。 +VITE_DEV_PASSWORD=g3soft diff --git a/code/g3soft-erp/web/TROUBLESHOOTING.md b/code/g3soft-erp/web/TROUBLESHOOTING.md index affb94f4..43d11b19 100644 --- a/code/g3soft-erp/web/TROUBLESHOOTING.md +++ b/code/g3soft-erp/web/TROUBLESHOOTING.md @@ -22,7 +22,7 @@ - 上下方向同理:Tab / 点选靠边的字段时,浏览器只把它自动滚到"刚好进入视野"(贴边),光晕照样被裁。 **修复**: -- 文件:`web/src/views/module/module-management/index.vue`、`web/src/views/module/MenuManagement.vue`。 +- 文件:`web/src/views/module/module-management/index.vue`、`web/src/views/module/menu-management/index.vue`。 - 把内边距从 `.mm-config-stack` 挪到滚动容器 `.mm-config-pane` 自己身上(`padding: 16px`,视觉位置不变)。 - `.mm-config-pane` 同时加 `scroll-padding: 16px`:让「聚焦自动滚动」也留出同样余量。 - 原型 fms-vue 本来就是这么写的(`.mm-config-pane { padding: 12px 16px 16px }`),移植时放错了层。 diff --git a/code/g3soft-erp/web/package.json b/code/g3soft-erp/web/package.json index 2afb791f..924f7f12 100644 --- a/code/g3soft-erp/web/package.json +++ b/code/g3soft-erp/web/package.json @@ -29,6 +29,7 @@ "nprogress": "^0.2.0", "pinia": "^4.0.3", "pinia-plugin-persistedstate": "^4.7.1", + "stk-table-vue": "^1.2.7", "vue": "^3.5.41", "vue-router": "^5.2.0" }, diff --git a/code/g3soft-erp/web/src/components/biz-tree/BizTreePanel.vue b/code/g3soft-erp/web/src/components/biz-tree/BizTreePanel.vue index ab352381..8f32cd96 100644 --- a/code/g3soft-erp/web/src/components/biz-tree/BizTreePanel.vue +++ b/code/g3soft-erp/web/src/components/biz-tree/BizTreePanel.vue @@ -1,6 +1,6 @@ + + + + diff --git a/code/g3soft-erp/web/src/components/table/index.ts b/code/g3soft-erp/web/src/components/table/index.ts new file mode 100644 index 00000000..0e26e6c0 --- /dev/null +++ b/code/g3soft-erp/web/src/components/table/index.ts @@ -0,0 +1 @@ +export { default as G3Table } from './G3Table.vue' diff --git a/code/g3soft-erp/web/src/composables/useBizTree.ts b/code/g3soft-erp/web/src/composables/useBizTree.ts new file mode 100644 index 00000000..7dbbfe2b --- /dev/null +++ b/code/g3soft-erp/web/src/composables/useBizTree.ts @@ -0,0 +1,547 @@ +import { reactive, ref, shallowReactive, type Component, type Ref, type VNode } from 'vue' +import { G3Message } from '@g3soft/ui' +import type { TreeKey } from '@g3soft/ui' +import { loadDataApi } from '@/services/api' +import { + applyTreePosition, + buildSortedTree, + decorateTreeNodes, + findTreeNode, + insertTreeNode, + sortTree, + type TreeRow, +} from '@/utils/tree' + +/** + * 业务树的「取数与状态」封装:树数据、加载态、未落库草稿集合, + * 以及重载时保留草稿的那套编排。 + * + * 为什么是 composable 而不是塞进 BizTree / BizTreePanel: + * 树数据在页面里不只用于渲染 —— 建树、查节点、插入 / 删除、拖拽落位、 + * 算祖先链、保存前 diff 都要读它。若把取数收进组件、状态也留在组件内部, + * 页面这些逻辑就全都够不到树了。所以所有权留在页面,只把「重复的编排」抽出来。 + * (组件库 @g3soft/ui 同样刻意不碰网络:G3Tree 只提供 lazy + emit('load') 的能力。) + * + * 设计口径:**每个环节都有默认实现,也都能被覆盖**。 + * 取数 → load 建树 → transform + * 查询 → condition 装饰 → typeField / nonLeafValues / iconMap + * 报错 → onError 草稿 → preserveDrafts + * 懒加载 → lazy(默认开) 子节点条件 → childrenCondition + * 只需要换个数据源时传 load,只需要改查询时传 condition,不必二选一地二开。 + * + * ## 懒加载(默认) + * + * `lazy` 默认 **true**:页面打开只拉根节点,展开某节点时才拉它的直接子节点。 + * 87 行的 s_module 也要 4 个请求才能看到全貌,但首屏只花 1 个、且不会把 + * 从未展开的分支塞进内存 —— 树越大收益越明显。 + * + * 想恢复「一次拉全量 + 前端建整棵树」,传 `lazy: false` 即可,行为与以前完全一致。 + * + * 懒加载下三处需要配合(都已在本文件内处理好,页面只需照常调用): + * - `loadedKeys`:交给 G3Tree 的 `loaded-keys`,决定节点展开箭头与是否可再展开; + * - `loadChildren(node)`:传给 G3Tree 的 `@load`(展开时才触发); + * - `loadAll()`:搜索需要全量数据时补拉一次(之后复用,不再重复请求)。 + * + * ⚠️ 懒加载的固有限制(不是 bug,是「按需」的代价): + * 搜索只作用于**已加载**的节点。所以面板搜索前应调 `loadAll()` 补齐全量, + * 否则会漏掉没展开过的分支。页面里已按这个约定处理。 + */ +export interface BizTreeLoadContext { + /** 表名 / 视图名 */ + table: string + /** 查询条件(不含 WHERE) */ + condition: string + /** 排序字段 */ + orderBy: string +} + +/** 建树上下文:让自定义 transform 能读到装饰配置,而不必在外部重复声明 */ +export interface BizTreeTransformContext { + typeField?: string + nonLeafValues?: unknown[] + iconMap?: Record +} + +export interface UseBizTreeOptions { + /** 表名 / 视图名。默认取数走 loadDataApi(table, condition, orderBy) */ + table?: string + /** 查询条件,不含 WHERE;默认 '1 = 1' 全量(仅 lazy: false 时使用) */ + condition?: string + /** 排序;默认 'b_xh ASC, b_id ASC' */ + orderBy?: string + /** + * 节点类型字段名(如 b_module_type / b_menu_type)。 + * 传了才按 nonLeafValues / iconMap 装饰;不传则原样使用建树结果。 + */ + typeField?: string + /** 能承载子节点的类型取值:这些类型即使暂无子节点也强制显示展开箭头 */ + nonLeafValues?: unknown[] + /** 类型值 → 图标组件,预载到 node.icon 由 G3Tree 渲染 */ + iconMap?: Record + + /** + * 是否懒加载,**默认 true**:首屏只取根节点,展开时才取直接子节点。 + * 传 false 则一次拉全量并在前端建整棵树(旧行为,树很小时更省事)。 + */ + lazy?: boolean + /** 父级关联字段名,默认 b_parent_id(懒加载按它查子节点) */ + parentField?: string + /** 根节点查询条件;默认「b_parent_id 为空」 */ + rootCondition?: string + /** + * 子节点查询条件构造:给父 id,返回查询条件。 + * 默认 `${parentField} = '父id'`(单引号会转义)。 + * 自定义 load 的场景若无法按父级查,可在这里换成走 b_path 前缀等。 + */ + childrenCondition?: (parentId: string) => string + + /** + * 自定义取数:完全接管数据来源(换接口、加缓存、分批拉取都行)。 + * 不传则用 loadDataApi + table / condition / orderBy。 + * 懒加载下会被逐层调用,ctx.condition 是**该层**的条件,并非整表条件。 + */ + load?: (ctx: BizTreeLoadContext) => Promise + /** + * 自定义建树:接收原始扁平行,返回最终树。 + * 不传则 buildSortedTree → decorateTreeNodes(按 typeField 决定是否装饰)。 + * 懒加载下它按「一层」被调用(子节点批次里父级不在列表中,建出来自然是同级数组)。 + */ + transform?: (rows: TreeRow[], ctx: BizTreeTransformContext) => T[] + + /** + * 重载时是否保留未落库草稿,默认 true。 + * + * 为什么需要:草稿在服务端没有行,重载(排序失败回滚、保存后刷新等) + * 会把它们整体丢弃,连带丢掉其它人正在编辑的新建节点。 + * 没有草稿概念的数据源(纯只读树)可以关掉。 + */ + preserveDrafts?: boolean + + /** 加载失败的提示;默认弹 G3Message(文案带上 label)。自行提示时传空函数即可静默 */ + onError?: (error: unknown) => void + /** 文案里的名词,默认「数据」:用于「加载{label}树失败」 */ + label?: string + /** 初始数据(SSR / 预取场景直接喂进来,省一次请求) */ + initialData?: T[] +} + +/** 判断失败:把 unknown 收敛成可读文案(与页面原有 errorText 同口径) */ +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** SQL 字符串字面量(转义单引号):后端按 search_condition 拼 SQL,必须转义 */ +function sqlLiteral(value: unknown): string { + return `'${String(value ?? '').replace(/'/g, "''")}'` +} + +/** + * 业务树状态与加载编排。 + * + * @returns 响应式状态 + 操作函数;`treeData` 直接是 `Ref`, + * 页面对它的全部读写都照旧直接用。 + */ +export function useBizTree(options: UseBizTreeOptions = {}) { + const { + table = '', + condition = '1 = 1', + orderBy = 'b_xh ASC, b_id ASC', + typeField, + nonLeafValues = [], + iconMap = {}, + lazy = true, + parentField = 'b_parent_id', + childrenCondition, + load, + transform, + preserveDrafts = true, + label = '数据', + initialData = [], + } = options + + /** 树数据(懒加载下是「已加载的部分」) */ + const treeData = ref(initialData) as Ref + /** 加载态:供 BizTree / BizTreePanel 的 loading 使用 */ + const loading = ref(false) + /** 子节点加载中:按节点 key 记,便于树节点自己转圈 */ + const loadingKeys = ref([]) + + /** + * 已加载过子节点的 key 集合。传给 G3Tree 的 loaded-keys: + * 它决定「节点还有没有展开箭头」——加载完且无子节点的节点会自动变成叶子。 + */ + const loadedKeys = ref([]) + + /** + * 未落库草稿的 id 集合。 + * 用 reactive 包装:树的未保存标记都读它,裸 Set 的增删不触发渲染,标记会滞后。 + */ + const unsavedIds = reactive(new Set()) + + /** + * 尚未能挂到树上的草稿(父节点还没被加载)。 + * key 是父 id;对应父节点的子节点加载完成后自动接上去。 + * 懒加载必须留这个暂存区,否则「在未展开的父节点下新建」会凭空丢节点。 + * + * 用 shallowReactive + 显式断言:reactive() 的深度解包会把 T 变成 UnwrapRefSimple, + * 与对外暴露的 T[] 对不上(put/get 两侧类型会打架)。这里只需 Map 结构本身响应式, + * 值里的节点对象已经是响应式的(来自 treeData),不必再深包一层。 + */ + const pendingDrafts = shallowReactive(new Map()) as Map + + /** 在树中按 id 查节点;id 为空时返回 null(避免 findTreeNode 把 '' 当合法键去比) */ + function findNode(id: TreeKey | null | undefined): T | null { + if (id == null || id === '') return null + return findTreeNode(treeData.value, id) as T | null + } + + /** 收集某节点整棵子树的 id(含自身)到 set */ + function collectSubtreeIds(node: TreeRow, set = new Set()): Set { + set.add(String(node.b_id)) + for (const child of (node.children || []) as TreeRow[]) collectSubtreeIds(child, set) + return set + } + + /** 是否有**已落库**的直接或下级子节点(删除守卫:先把已保存的子节点删掉) */ + function hasSavedDescendant(node: TreeRow): boolean { + for (const child of (node.children || []) as TreeRow[]) { + if (!unsavedIds.has(String(child.b_id))) return true + if (hasSavedDescendant(child)) return true + } + return false + } + + /** + * 某节点是否「可能还有未加载的下级」。 + * 懒加载下删除守卫不能只信内存:没展开过的分支根本没加载, + * 直接放行会删掉一个有子节点的节点。页面可据此决定是否先加载再判。 + */ + function isChildrenLoaded(node: TreeRow): boolean { + if (!lazy) return true + return loadedKeys.value.some((key) => String(key) === String(node.b_id)) + } + + /** 递归重算子树的位置派生列(b_parent_id 为权威关系,b_depth / b_path 由它派生) */ + function syncSubtreePosition(node: T, parentNode: TreeRow | null): void { + applyTreePosition(node, parentNode) + for (const child of (node.children || []) as T[]) syncSubtreePosition(child, node) + } + + /** 标记某节点为未落库草稿 */ + function markUnsaved(id: TreeKey): void { + unsavedIds.add(String(id)) + } + + /** 取消**单个**节点的草稿标记(与 markUnsaved 对称;如保存后被正式编码替代的旧临时 id) */ + function unmarkUnsaved(id: TreeKey): void { + unsavedIds.delete(String(id)) + } + + /** 取消某节点及其整棵子树的草稿标记(节点保存落库或删除后调用) */ + function unmarkUnsavedSubtree(node: TreeRow | null | undefined): void { + if (!node) return + collectSubtreeIds(node).forEach((key) => unsavedIds.delete(key)) + // 暂存区里同批草稿也要清掉,否则下次展开父节点会把它又挂回来 + const ids = collectSubtreeIds(node) + for (const [parentId, list] of pendingDrafts) { + const rest = list.filter((draft) => !ids.has(String(draft.b_id))) + if (rest.length) pendingDrafts.set(parentId, rest) + else pendingDrafts.delete(parentId) + } + } + + /** 清空全部草稿标记 */ + function clearUnsaved(): void { + unsavedIds.clear() + pendingDrafts.clear() + } + + /** + * 摘出未落库的草稿子树(草稿节点连同其后代一起带走,故命中后不再下钻)。 + * 懒加载时还要算上「暂存区里等父节点展开」的那批,否则重载一次就丢了。 + */ + function extractDraftNodes(nodes: T[], out: T[] = []): T[] { + for (const node of nodes || []) { + if (unsavedIds.has(String(node.b_id))) { + out.push(node) + continue + } + if (node.children?.length) extractDraftNodes(node.children as T[], out) + } + return out + } + + /** 默认建树:扁平行 → 排序后的树 → 按类型装饰图标 / 非叶子 */ + function defaultTransform(rows: TreeRow[]): T[] { + const built = buildSortedTree(rows) + if (!typeField) return built as T[] + const decorated = decorateTreeNodes(built, { + typeField, + nonLeafValues, + iconMap, + }) as T[] + + /* + * 懒加载下还要把**叶子类型**显式标成 isLeaf = true。 + * + * 为什么必须补这一半:G3Tree 的 isLeafNode 在 lazy 模式下,对「没有 children + * 且没声明 isLeaf」的节点一律当成【可展开】——它分不清「真没子节点」和「还没加载」。 + * 于是 page / external / data / virtual 这些按业务规则永不可能有子节点的类型, + * 也会带上展开箭头,点一下白发一次请求(实测:点「首页」确实发了一条空查询)。 + * + * nonLeafValues 表示「能承载子节点的类型」,decorateTreeNodes 只把它们的 + * isLeaf 标成 false;另一半(其余类型是叶子)没人负责,这里补上。 + * 已有 children 的节点不动 —— isLeaf = true 的优先级高于 children, + * 标错了会把真实子节点藏起来。 + */ + if (lazy && nonLeafValues.length) { + const markLeaf = (nodes: TreeRow[]): void => { + for (const node of nodes) { + if (node.children?.length) { + markLeaf(node.children as TreeRow[]) + continue + } + if (node.isLeaf === undefined && !nonLeafValues.includes(node[typeField])) { + node.isLeaf = true + } + } + } + markLeaf(decorated) + } + + return decorated + } + + /** 拉一层数据并建成本层节点数组 */ + async function fetchLevel(cond: string): Promise { + const ctx: BizTreeLoadContext = { table, condition: cond, orderBy } + const rows = load ? await load(ctx) : await loadDataApi(table, cond, orderBy) + return transform ? transform(rows, { typeField, nonLeafValues, iconMap }) : defaultTransform(rows) + } + + /** 根节点条件 */ + function resolvedRootCondition(): string { + return options.rootCondition ?? `(${parentField} IS NULL OR ${parentField} = '')` + } + + /** 子节点条件 */ + function resolvedChildrenCondition(parentId: string): string { + if (childrenCondition) return childrenCondition(parentId) + return `${parentField} = ${sqlLiteral(parentId)}` + } + + /** 把某父节点下暂存的草稿接上(父节点刚加载完子节点时调用) */ + function flushPendingDrafts(parent: T): void { + const parentKey = String(parent.b_id) + const queued = pendingDrafts.get(parentKey) + if (!queued?.length) return + pendingDrafts.delete(parentKey) + + for (const draft of queued) { + if (draft.b_parent_id != null && !findNode(draft.b_parent_id)) draft.b_parent_id = null + syncSubtreePosition(draft, parent) + insertTreeNode([parent] as TreeRow[], draft, { sort: sortTree }) + } + // 父节点现在有子节点了,不能再被当成叶子 + if (parent.isLeaf === true) parent.isLeaf = false + } + + /** + * 重载树。 + * + * 草稿处理顺序很关键: + * 1. 先摘出草稿子树(读的是旧树 + 当前 unsavedIds + 暂存区); + * 2. 再拉数据、整体替换 treeData; + * 3. 最后把草稿插回去 —— 父节点已不在服务端数据里(或尚未加载)时降级为顶级节点, + * 否则按落位后的父节点重算 b_depth / b_path。 + * + * 懒加载下「重载」不等于「塌回根节点」:已展开过的分支会一并重新拉取, + * 免得保存一次就把用户辛苦展开的树收起来。 + */ + async function reload(): Promise { + const draftNodes = preserveDrafts ? extractDraftNodes(treeData.value) : [] + if (preserveDrafts) { + // 暂存区里的草稿也一并纳入(它们不在 treeData 里) + for (const list of pendingDrafts.values()) draftNodes.push(...list) + } + const draftIds = new Set() + if (preserveDrafts) { + for (const node of draftNodes) collectSubtreeIds(node, draftIds) + } + + // 记住哪些父节点已加载过子节点,重载时照原样拉回来 + const previouslyLoaded = lazy ? [...loadedKeys.value].map(String) : [] + + loading.value = true + try { + treeData.value = lazy ? await loadLazyRoots(previouslyLoaded) : await fetchLevel(condition) + + if (lazy) { + // 根节点本身算「已加载」:否则每个根都会显示成可展开却点了没反应 + loadedKeys.value = previouslyLoaded + } + + if (preserveDrafts) { + pendingDrafts.clear() + for (const node of draftNodes) { + const parentId = node.b_parent_id == null ? null : String(node.b_parent_id) + if (parentId == null) { + node.b_parent_id = null + syncSubtreePosition(node, null) + insertTreeNode(treeData.value, node, { sort: sortTree }) + continue + } + const parent = findNode(parentId) + if (parent) { + syncSubtreePosition(node, parent) + insertTreeNode(treeData.value, node, { sort: sortTree }) + continue + } + // 父节点不在树上,两种模式语义不同: + // 全量模式:整棵树都拉回来了,找不到就是真没了(父级被删 / 改了编码) + // → 降级为顶级,别让草稿凭空消失; + // 懒加载模式:父节点可能只是没展开过 → 暂存,等它加载后再挂上。 + // 这里若也降级,会静默把节点提到根层,层级就错了。 + if (!lazy) { + node.b_parent_id = null + syncSubtreePosition(node, null) + insertTreeNode(treeData.value, node, { sort: sortTree }) + } else { + const list = pendingDrafts.get(parentId) ?? [] + list.push(node) + pendingDrafts.set(parentId, list) + } + } + // 草稿集合整体重建:摘出的子树仍在编辑中,其余(已落库 / 已删除)不再算草稿 + unsavedIds.clear() + for (const id of draftIds) unsavedIds.add(id) + } else { + // 不保留草稿时它们已随重载被丢弃,标记必须一并清掉, + // 否则会留下「标记为未保存、树上却找不到该节点」的脏状态 + unsavedIds.clear() + pendingDrafts.clear() + } + + return treeData.value + } catch (error) { + if (options.onError) options.onError(error) + else G3Message.error(`加载${label}树失败:${errorText(error)}`) + return treeData.value + } finally { + loading.value = false + } + } + + /** + * 懒加载下重建树:先拉根,再把「上次已展开」的分支逐层拉回来。 + * + * 只重拉用户展开过的分支,不是整棵树 —— 请求数 ≈ 展开过的节点数 + 1。 + */ + async function loadLazyRoots(previouslyLoaded: string[]): Promise { + const roots = await fetchLevel(resolvedRootCondition()) + if (!previouslyLoaded.length) return roots + + // 展开过的父节点:把它们的子节点补回来(深度优先,逐层) + const loadNode = async (node: T): Promise => { + const key = String(node.b_id) + if (!previouslyLoaded.includes(key)) return + const children = await fetchLevel(resolvedChildrenCondition(key)) + if (children.length) { + node.children = children + for (const child of children) await loadNode(child) + } + } + for (const root of roots) await loadNode(root) + return roots + } + + /** + * 加载某节点的直接子节点(展开时调用,接 G3Tree 的 @load)。 + * + * 幂等:已加载过就直接返回,不会重复请求。 + */ + async function loadChildren(node: T): Promise { + if (!lazy) return (node.children || []) as T[] + const key = String(node.b_id) + if (loadedKeys.value.some((k) => String(k) === key)) return (node.children || []) as T[] + + loadingKeys.value = [...loadingKeys.value, key] + try { + const children = await fetchLevel(resolvedChildrenCondition(key)) + if (children.length) node.children = children + else delete node.children + if (!loadedKeys.value.some((k) => String(k) === key)) { + loadedKeys.value = [...loadedKeys.value, key] + } + flushPendingDrafts(node) + return children + } catch (error) { + if (options.onError) options.onError(error) + else G3Message.error(`加载${label}子节点失败:${errorText(error)}`) + return [] + } finally { + loadingKeys.value = loadingKeys.value.filter((k) => String(k) !== key) + } + } + + /** + * 一次性拉全量并建整棵树(懒加载下用于「搜索需要全量数据」)。 + * + * 拉完把所有节点记为已加载,之后展开不再发请求; + * 草稿逻辑与 reload 一致(已经挂在树上的自然带在 treeData 里)。 + */ + async function loadAll(): Promise { + if (!lazy) return reload() + loading.value = true + try { + const all = await fetchLevel(condition) + const loaded: string[] = [] + const walk = (nodes: T[]) => { + for (const node of nodes) { + if (node.children?.length) { + loaded.push(String(node.b_id)) + walk(node.children as T[]) + } + } + } + walk(all) + treeData.value = all + loadedKeys.value = loaded + return all + } catch (error) { + if (options.onError) options.onError(error) + else G3Message.error(`加载${label}树失败:${errorText(error)}`) + return treeData.value + } finally { + loading.value = false + } + } + + return { + /* 状态 */ + treeData, + loading, + loadingKeys, + loadedKeys, + unsavedIds, + /** 是否处于懒加载模式(页面据此决定要不要传 loaded-keys / @load) */ + lazy, + /* 查询 */ + findNode, + collectSubtreeIds, + hasSavedDescendant, + isChildrenLoaded, + syncSubtreePosition, + /* 草稿标记 */ + markUnsaved, + unmarkUnsaved, + unmarkUnsavedSubtree, + clearUnsaved, + /* 加载 */ + reload, + loadChildren, + loadAll, + } +} + +export type UseBizTreeReturn = ReturnType> diff --git a/code/g3soft-erp/web/src/dev/auto-login.ts b/code/g3soft-erp/web/src/dev/auto-login.ts index e40e929a..b1707e47 100644 --- a/code/g3soft-erp/web/src/dev/auto-login.ts +++ b/code/g3soft-erp/web/src/dev/auto-login.ts @@ -1,3 +1,4 @@ +import { devCredentials } from '@/dev/credentials' import { loginApi } from '@/services/api/auth' import { useUserStore } from '@/stores' @@ -28,9 +29,9 @@ export async function devAutoLogin(): Promise { if (!import.meta.env.DEV) return if (import.meta.env.VITE_DEV_AUTO_LOGIN !== '1') return - const orgId = import.meta.env.VITE_DEV_ORG || '' - const account = import.meta.env.VITE_DEV_ACCOUNT || '' - if (!orgId || !account) return + const credentials = devCredentials() + if (!credentials) return + const { orgId, account, password } = credentials const userStore = useUserStore() if (userStore.isAuthenticated) return @@ -40,7 +41,7 @@ export async function devAutoLogin(): Promise { if (path === '/login') return try { - const result = await loginApi(orgId, account, import.meta.env.VITE_DEV_PASSWORD || '') + const result = await loginApi(orgId, account, password) userStore.setSession(result, orgId) console.info(`[dev] 已自动登录 ${orgId}/${account}(关掉请设 VITE_DEV_AUTO_LOGIN=0)`) } catch (error) { diff --git a/code/g3soft-erp/web/src/dev/credentials.ts b/code/g3soft-erp/web/src/dev/credentials.ts new file mode 100644 index 00000000..a4f05de3 --- /dev/null +++ b/code/g3soft-erp/web/src/dev/credentials.ts @@ -0,0 +1,36 @@ +/** + * 开发期测试账号 —— `web/.env.development` 里 `VITE_DEV_*` 的**唯一读取口径**。 + * + * 两个消费方共用这一份配置,避免各写一套: + * - `auto-login.ts`:`pnpm dev` 打开页面时自动登录(省掉手点); + * - `views/auth/Login.vue`:登录页的「一键登录」按钮(入口是 /login 时自动登录不生效, + * 那条路刻意留给「有人在调登录页」的场景,一键登录正好补上)。 + * + * ⚠️ 凭据不会进生产包,靠两道保险: + * 1. `.env.development` 只在 mode=development 加载,`vite build` 下 + * `import.meta.env.VITE_DEV_*` 被替换为 undefined; + * 2. 调用方一律用 `import.meta.env.DEV` 包住,构建时该分支被折叠为死代码后 tree-shake。 + */ + +/** 开发期测试账号 */ +export interface DevCredentials { + /** 机构码,需对应 server/config/dbconfigs/{机构码}.properties */ + orgId: string + /** 登录账号 */ + account: string + /** 密码:允许空串(超管在库里可能就是空密码),所以判定配置齐不齐只看机构码与账号 */ + password: string +} + +/** + * 读取开发期测试账号。 + * + * @returns 机构码与账号都配了才返回对象;缺任一项返回 null,调用方应静默跳过。 + */ +export function devCredentials(): DevCredentials | null { + const orgId = import.meta.env.VITE_DEV_ORG || '' + const account = import.meta.env.VITE_DEV_ACCOUNT || '' + if (!orgId || !account) return null + + return { orgId, account, password: import.meta.env.VITE_DEV_PASSWORD || '' } +} diff --git a/code/g3soft-erp/web/src/router/index.ts b/code/g3soft-erp/web/src/router/index.ts index e814baec..90ff10f2 100644 --- a/code/g3soft-erp/web/src/router/index.ts +++ b/code/g3soft-erp/web/src/router/index.ts @@ -128,13 +128,13 @@ export const protectedRoutes: RouteRecordRaw[] = [ { path: '/module/menu-management', name: 'module-menu-management', - component: () => import('@/views/module/MenuManagement.vue'), + component: () => import('@/views/module/menu-management/index.vue'), meta: { title: '菜单管理', adminOnly: true }, }, { path: '/module/i18n-type', name: 'module-i18n-type', - component: () => import('@/views/module/I18nType.vue'), + component: () => import('@/views/module/i18n-type/index.vue'), meta: { title: '多语言类型', adminOnly: true }, }, diff --git a/code/g3soft-erp/web/src/types/env.d.ts b/code/g3soft-erp/web/src/types/env.d.ts index 662989bc..f5fe2ce6 100644 --- a/code/g3soft-erp/web/src/types/env.d.ts +++ b/code/g3soft-erp/web/src/types/env.d.ts @@ -15,11 +15,11 @@ interface ImportMetaEnv { readonly VITE_APP_TITLE?: string /** 开发期自动登录开关,`1` 开启、`0` 关闭(默认关闭)。仅 DEV 生效,见 src/dev/auto-login.ts */ readonly VITE_DEV_AUTO_LOGIN?: string - /** 开发期自动登录的机构码,如 G3HD */ + /** 开发期测试账号的机构码,如 G3HD。登录页「一键登录」按钮也用这一组,见 src/dev/credentials.ts */ readonly VITE_DEV_ORG?: string - /** 开发期自动登录的账号,如 g3soft */ + /** 开发期测试账号的登录名,如 g3soft(超管,可进全部页面) */ readonly VITE_DEV_ACCOUNT?: string - /** 开发期自动登录的密码;超管 g3soft 在库里是空串 */ + /** 开发期测试账号的密码 */ readonly VITE_DEV_PASSWORD?: string } diff --git a/code/g3soft-erp/web/src/utils/querySchema.ts b/code/g3soft-erp/web/src/utils/querySchema.ts new file mode 100644 index 00000000..68bbfe2d --- /dev/null +++ b/code/g3soft-erp/web/src/utils/querySchema.ts @@ -0,0 +1,621 @@ +/** + * 查询配置(s_module_schema.b_schema_json,b_schema_type = 'query')。 + * + * 五类查询入口全部落在**同一份** JSON 里,不写版本号 —— 结构变了直接改这里, + * 不做多版本兼容(服务端没存过旧格式的消费者,读到不认识的结构就按空处理)。 + * + * 1. 快捷查询 keyword —— 多个字段共用一个输入框 + * 2. 普通查询 —— filters 里 pinned = true 的项,平铺露出 + * 3. 更多查询 —— **不落库、运行时派生**(见 deriveMoreEntries): + * 列表列 − 普通查询已用的列 + pinned = false 的 filters / customs + * 4. 多 tags 搜索 tags —— 独立的开关组(如「核销状态:待核销 / 已核销」), + * 选项由 SQL 配置,可带每项数量(见 QueryTag) + * 5. 自定义查询 customs —— 列衍生不出来的条件(如「仅看逾期」,条件片段由人写) + * + * 为什么 tags 单独一组、不混进 filters: + * filters 是「一个字段一个控件、填什么由用户决定」;tags 是「一组枚举值、点哪个就筛哪个」, + * 而且它的选项通常来自另一张表(甚至要带数量),字段与选项来源都要单独配。 + * 混进 filters 会让「这一项到底是普通控件还是开关组」的判断散落在运行时的每个分支里。 + * + * 为什么「更多查询」不配置: + * 配置了一份列清单就要跟着列表配置一起维护,列表加一列忘了回来改,查询面板就少一项。 + * 改成派生后,列表加列 → 查询面板自动多一项,永远不会脱节。 + * + * 为什么 filters 与 customs 分开: + * 渲染形态不同。filter 是「控件 + 填值」(还带字段名可以拼 SQL); + * custom 是「固定条件」(勾上即生效,条件片段是人写的、不是由字段值拼的)。 + * 混在一个数组里,运行时渲染时要一直判断「这项是哪种」。 + */ + +/* ================= 控件与操作符 ================= */ + +/** 查询控件类型(与 s_field.b_type 同一套词汇) */ +export const QUERY_COMPONENT_OPTIONS: Array<{ label: string; value: string }> = [ + { label: '输入框', value: 'input' }, + { label: '文本域', value: 'textarea' }, + { label: '数字', value: 'number' }, + { label: '金额', value: 'money' }, + { label: '日期', value: 'date' }, + { label: '日期时间', value: 'datetime' }, + { label: '复选框', value: 'checkbox' }, + { label: '下拉选择', value: 'select' }, +] + +/** 操作符全集(label 用于配置界面;value 是落库值,由运行时翻译成 SQL) */ +export const QUERY_OPERATOR_OPTIONS: Array<{ label: string; value: string }> = [ + { label: '包含', value: 'like' }, + { label: '等于', value: 'eq' }, + { label: '不等于', value: 'ne' }, + { label: '大于', value: 'gt' }, + { label: '大于等于', value: 'ge' }, + { label: '小于', value: 'lt' }, + { label: '小于等于', value: 'le' }, + { label: '区间', value: 'between' }, + { label: '属于', value: 'in' }, + { label: '不属于', value: 'not_in' }, + { label: '为空', value: 'is_null' }, + { label: '不为空', value: 'is_not_null' }, +] + +/** + * 各控件可用的操作符。 + * + * 只给「类型上说得通」的组合:文本没有「大于」,数字没有「包含」, + * 复选框只有等值。配置界面据此收窄下拉,避免配出运行时拼不出合法 SQL 的组合。 + * + * 注意这里没有 tags:多标签搜索是独立的 QueryTag 组(固定按 in 拼), + * 不走 filters 的 component 体系,两边不要混。 + */ +const OPERATORS_BY_COMPONENT: Record = { + input: ['like', 'eq', 'ne'], + textarea: ['like', 'eq', 'ne'], + number: ['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'between'], + money: ['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'between'], + date: ['eq', 'between', 'ge', 'le'], + datetime: ['eq', 'between', 'ge', 'le'], + checkbox: ['eq'], + select: ['eq', 'ne', 'in'], +} + +/** 多标签搜索固定使用的操作符(属于组内的语义,不对外暴露成可选项) */ +export const TAGS_OPERATOR = 'in' + +/** 某控件可用的操作符下拉项 */ +export function operatorOptionsOf(component: string): Array<{ label: string; value: string }> { + const allowed = OPERATORS_BY_COMPONENT[component] ?? OPERATORS_BY_COMPONENT.input + return QUERY_OPERATOR_OPTIONS.filter((option) => allowed.includes(option.value)) +} + +/** 某控件的默认操作符(取其可用列表的第一项) */ +export function defaultOperatorOf(component: string): string { + return (OPERATORS_BY_COMPONENT[component] ?? OPERATORS_BY_COMPONENT.input)[0] +} + +/** + * 该控件是否需要「可选项」。 + * + * 只有 select:多标签搜索是独立的 QueryTag 组,它自己必然要配选项, + * 不经过这个判断(否则「tags 要不要选项」会出现两处结论)。 + */ +export function needsOptions(component: string): boolean { + return component === 'select' +} + +/** + * 按 s_field.b_type 推断默认查询控件。 + * + * 推断出来的值不落库(配置里省掉就是「跟随字段类型」):字段类型改了, + * 查询控件自动跟着变,不用回来重配。只有配置者显式改过才写成固定值。 + */ +export function inferComponent(fieldType: string | null | undefined): string { + const type = String(fieldType ?? '').trim() + switch (type) { + case 'textarea': + return 'textarea' + case 'number': + return 'number' + case 'money': + return 'money' + case 'date': + return 'date' + case 'datetime': + return 'datetime' + case 'checkbox': + return 'checkbox' + case 'select': + return 'select' + default: + return 'input' + } +} + +/* ================= 结构 ================= */ + +/** + * 选项来源。 + * + * - static:配置里直接写死 { label, value, count? },适合枚举很少且不会变的场景; + * - sql:写一条只读查询,**返回列必须叫 value / label(启用数量时再加 count)** + * (后端按列名取值,写错列名运行时拿到空列表,而这个错误在配置界面看不出来, + * 所以保存时要试跑)。 + * + * ⚠️ SQL 会被拼进查询语句,后端 SqlText.validateReadOnlySql 只拦 `;`、注释与 + * 写关键字(含 `into`,字段名里带 into 也会被误拦)。条件片段同理。 + */ +export interface QueryOptions { + source: 'static' | 'sql' + /** source = 'sql' 时使用:只读查询,返回 value / label(及 count)列 */ + sql?: string + /** source = 'static' 时使用;count 仅在 showCount 时有意义 */ + items?: Array<{ label: string; value: string; count?: number }> +} + +/** 普通/更多查询的一个条件项 */ +export interface QueryFilter { + /** 视图 / 表里的列名 */ + field: string + /** + * 是否在「普通查询」区露出。 + * true = 普通查询;false / 省略 = 收进「更多查询」。 + */ + pinned?: boolean + /** 覆盖按 b_type 推断出来的控件(省略 = 跟随字段类型) */ + component?: string + /** 覆盖默认操作符(省略 = 取该控件的第一个可用操作符) */ + operator?: string + /** select / tags 的选项来源 */ + options?: QueryOptions +} + +/** + * 自定义查询项。 + * + * 两种形态,靠 component 区分: + * - checkbox:固定条件。勾上即生效,用 condition 里的条件片段(人写); + * - 其它:虚拟字段 + 填值。用 field 存字段表达式(如 DATEDIFF(day, b_x, GETDATE())), + * 运行时按 field + operator + 用户输入拼条件。 + */ +export interface QueryCustom { + /** 稳定标识(运行时用它区分多个自定义项,不进 SQL) */ + key: string + /** 界面上显示的名称 */ + label: string + component: string + /** component = 'checkbox' 时使用:固定条件片段 */ + condition?: string + /** component ≠ 'checkbox' 时使用:字段或字段表达式 */ + field?: string + operator?: string + /** 是否在「普通查询」区露出 */ + pinned?: boolean + /** select / tags 的选项来源 */ + options?: QueryOptions +} + +/** + * 一组多 tags 搜索(如「核销状态」「审核状态」)。 + * + * 与 filters 的区别不只是控件形态: + * - filters 一项对应**一个字段**,用户填什么由输入决定; + * - tags 一项是**一组枚举开关**,选项来自另一处(SQL 或静态),点哪个筛哪个。 + * 所以它有独立的 field(筛哪个列)、独立的 label(组名)、独立的选项与数量开关。 + * + * 选项 SQL 约定(在 QueryOptions 之上多一列): + * value —— 该选项对应的字段值(筛选用) + * label —— 显示文案(待核销 / 已核销) + * count —— 可选。showCount 为 true 时必须返回,用作选项右上角的数量 + * + * 数量为什么要「可选」: + * 带数量意味着每次查询都要多一次聚合,小表上没意义;由配置者决定要不要付这个成本。 + */ +export interface QueryTag { + /** 稳定标识(运行时用它区分多组 tags,不进 SQL) */ + key: string + /** 组名,如「核销状态」 */ + label: string + /** 筛哪个列 */ + field: string + /** 是否在「普通查询」区露出;false / 省略 = 收进「更多查询」 */ + pinned?: boolean + /** 选项来源(SQL 或静态) */ + options?: QueryOptions + /** 是否显示每项数量(要求选项来源带 count 列;静态选项则在 items 里写 count) */ + showCount?: boolean + /** 默认选中项(value 数组);运行时首次渲染就用它 */ + defaultValues?: string[] +} + +/** + * 快捷查询:多个字段共用一个输入框。 + * + * 不配 placeholder:「搜索名称、编码」这类提示由前端按所选字段自动生成, + * 手工配一份就容易和实际字段对不上(改了字段忘了改文案)。 + */ +export interface QueryKeyword { + enabled: boolean + /** 参与快捷查询的字段(按数组顺序拼 OR like) */ + fields: string[] +} + +/** 查询配置整体结构 */ +export interface QuerySchema { + keyword: QueryKeyword + /** 普通查询与更多查询的条件池(顺序即露出的顺序) */ + filters: QueryFilter[] + /** 多 tags 搜索组(核销状态 / 审核状态这类枚举开关) */ + tags: QueryTag[] + /** 列衍生不出来的自定义条件 */ + customs: QueryCustom[] +} + +/** 空配置(新建模块 / 读到不认识的结构时回退到这个) */ +export function createEmptyQuerySchema(): QuerySchema { + return { + keyword: { enabled: true, fields: [] }, + filters: [], + tags: [], + customs: [], + } +} + +/** 取字符串(去空白) */ +function textOf(raw: unknown): string { + return typeof raw === 'string' ? raw.trim() : '' +} + +/** + * 解析库里的 b_schema_json。 + * + * 宽容读取:只认自己认识的字段名,其余忽略;缺数组时补空数组。 + * 不认识的旧结构(如某些模块存的 groups / quick)会被解析成空配置 —— + * 这是刻意的:宁可让配置界面从空白开始重配,也不要把两套结构混着渲染。 + */ +export function parseQuerySchema(raw: unknown): QuerySchema { + const empty = createEmptyQuerySchema() + let value: unknown = raw + if (typeof raw === 'string') { + const text = raw.trim() + if (!text) return empty + try { + value = JSON.parse(text) + } catch { + return empty + } + } + if (!value || typeof value !== 'object' || Array.isArray(value)) return empty + + const source = value as Record + const keywordRaw = source.keyword + const keywordSource = + keywordRaw && typeof keywordRaw === 'object' && !Array.isArray(keywordRaw) + ? (keywordRaw as Record) + : null + + const filters = Array.isArray(source.filters) + ? (source.filters as unknown[]) + .map((item): QueryFilter | null => { + if (!item || typeof item !== 'object') return null + const row = item as Record + const field = textOf(row.field) + if (!field) return null + const filter: QueryFilter = { field } + if (row.pinned === true || row.pinned === false) filter.pinned = row.pinned + const component = textOf(row.component) + if (component) filter.component = component + const operator = textOf(row.operator) + if (operator) filter.operator = operator + const options = parseOptions(row.options) + if (options) filter.options = options + return filter + }) + .filter((item): item is QueryFilter => item !== null) + : [] + + const customs = Array.isArray(source.customs) + ? (source.customs as unknown[]) + .map((item): QueryCustom | null => { + if (!item || typeof item !== 'object') return null + const row = item as Record + const key = textOf(row.key) + const label = textOf(row.label) + if (!key || !label) return null + const custom: QueryCustom = { + key, + label, + component: textOf(row.component) || 'checkbox', + } + const condition = textOf(row.condition) + if (condition) custom.condition = condition + const field = textOf(row.field) + if (field) custom.field = field + const operator = textOf(row.operator) + if (operator) custom.operator = operator + if (row.pinned === true || row.pinned === false) custom.pinned = row.pinned + const options = parseOptions(row.options) + if (options) custom.options = options + return custom + }) + .filter((item): item is QueryCustom => item !== null) + : [] + + const tags = Array.isArray(source.tags) + ? (source.tags as unknown[]) + .map((item): QueryTag | null => { + if (!item || typeof item !== 'object') return null + const row = item as Record + const key = textOf(row.key) + const label = textOf(row.label) + const field = textOf(row.field) + if (!key || !label || !field) return null + const tag: QueryTag = { key, label, field } + if (row.pinned === true || row.pinned === false) tag.pinned = row.pinned + if (row.showCount === true) tag.showCount = true + const options = parseOptions(row.options) + if (options) tag.options = options + if (Array.isArray(row.defaultValues)) { + const values = (row.defaultValues as unknown[]).map(textOf).filter(Boolean) + if (values.length) tag.defaultValues = values + } + return tag + }) + .filter((item): item is QueryTag => item !== null) + : [] + + return { + keyword: { + enabled: keywordSource ? keywordSource.enabled !== false : true, + fields: Array.isArray(keywordSource?.fields) + ? (keywordSource.fields as unknown[]).map(textOf).filter(Boolean) + : [], + }, + filters, + tags, + customs, + } +} + +/** 解析选项来源(缺 source 或 source 非法时返回 undefined) */ +function parseOptions(raw: unknown): QueryOptions | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined + const row = raw as Record + const source = textOf(row.source) + if (source === 'sql') return { source: 'sql', sql: textOf(row.sql) || undefined } + if (source !== 'static') return undefined + const items = Array.isArray(row.items) + ? (row.items as unknown[]) + .map((item) => { + if (!item || typeof item !== 'object') return null + const option = item as Record + const label = textOf(option.label) + const optionValue = textOf(option.value) + if (!label || !optionValue) return null + const result: { label: string; value: string; count?: number } = { + label, + value: optionValue, + } + // count 只在确实是数字时保留:静态选项支持手写数量, + // 空串 / 非数字都当「不显示数量」,不要落成 NaN + const count = Number(option.count) + if (Number.isFinite(count) && String(option.count ?? '').trim() !== '') { + result.count = count + } + return result + }) + .filter((item): item is { label: string; value: string; count?: number } => item !== null) + : [] + return { source: 'static', items } +} + +/* ================= 派生:普通 / 更多查询 ================= */ + +/** 「更多查询」里的一项(运行时派生,不落库) */ +export interface MoreQueryEntry { + /** 字段名;custom / tag 项为它的 key */ + field: string + label: string + component: string + /** 来源:列表列衍生 / 显式收进更多查询 / 自定义项 / tags 组 */ + from: 'column' | 'filter' | 'custom' | 'tag' +} + +/** 普通查询区要露出的 filters(pinned = true,保持数组顺序) */ +export function pinnedFilters(schema: QuerySchema): QueryFilter[] { + return schema.filters.filter((filter) => filter.pinned === true) +} + +/** 普通查询区要露出的 tags 组(pinned = true) */ +export function pinnedTags(schema: QuerySchema): QueryTag[] { + return schema.tags.filter((tag) => tag.pinned === true) +} + +/** 普通查询区要露出的 customs(pinned = true,保持数组顺序) */ +export function pinnedCustoms(schema: QuerySchema): QueryCustom[] { + return schema.customs.filter((custom) => custom.pinned === true) +} + +/** 普通查询已占用的字段(更多查询要排除它们) */ +export function pinnedFieldSet(schema: QuerySchema): Set { + const used = new Set() + for (const filter of pinnedFilters(schema)) used.add(filter.field) + // tags 组也是普通查询的一部分:它筛的列同样不该再出现在更多查询里, + // 否则同一个列会出现两个入口(一个按枚举点,一个自由填) + for (const tag of pinnedTags(schema)) used.add(tag.field) + for (const custom of pinnedCustoms(schema)) { + if (custom.field) used.add(custom.field) + } + return used +} + +/** + * 派生「更多查询」的条目。 + * + * 规则(按顺序拼接,去重): + * 1. 列表列 − 普通查询已用的列 ← 这就是「不配置、由列表衍生」那一半 + * 2. filters 里 pinned ≠ true 的项(显式收进更多查询,可以不是列表列) + * 3. tags 里 pinned ≠ true 的组 + * 4. customs 里 pinned ≠ true 的项 + * + * @param schema 查询配置 + * @param viewColumns 列表配置的列(按列表顺序) + * @param fieldsByField 字段名 → 字段行(用来取中文名与 b_type) + */ +export function deriveMoreEntries( + schema: QuerySchema, + viewColumns: Array<{ field?: string }>, + fieldsByField: Map = new Map(), +): MoreQueryEntry[] { + const pinned = pinnedFieldSet(schema) + const entries: MoreQueryEntry[] = [] + const seen = new Set() + + const labelOf = (field: string): string => { + const row = fieldsByField.get(field) + return textOf(row?.b_name) || field + } + const componentOf = (field: string): string => + inferComponent(textOf(fieldsByField.get(field)?.b_type)) + + const push = (entry: MoreQueryEntry): void => { + if (seen.has(entry.field)) return + seen.add(entry.field) + entries.push(entry) + } + + // 1. 列表列衍生 + for (const column of viewColumns || []) { + const field = textOf(column?.field) + if (!field || pinned.has(field)) continue + push({ field, label: labelOf(field), component: componentOf(field), from: 'column' }) + } + + // 2. 收进更多查询的 filters + for (const filter of schema.filters) { + if (filter.pinned === true) continue + push({ + field: filter.field, + label: labelOf(filter.field), + component: filter.component || componentOf(filter.field), + from: 'filter', + }) + } + + // 3. 收进更多查询的 tags 组(按 key 去重,不与字段名混用) + for (const tag of schema.tags) { + if (tag.pinned === true) continue + push({ field: tag.key, label: tag.label, component: 'tags', from: 'tag' }) + } + + // 4. 收进更多查询的 customs + for (const custom of schema.customs) { + if (custom.pinned === true) continue + push({ + field: custom.key, + label: custom.label, + component: custom.component, + from: 'custom', + }) + } + + return entries +} + +/** + * 保存前的结构自检:返回问题清单(空数组即通过)。 + * + * 只校验「运行时一定拼不出合法 SQL」的硬错误,不做主观风格检查: + * - 同一字段在 filters 里重复出现(会拼出互相矛盾的重复条件); + * - custom / tag 的 key 重复(运行时靠 key 区分,重了会串); + * - checkbox 形态的 custom 缺 condition;其它形态缺 field; + * - tags 组缺字段或选项; + * - select 缺选项来源或选项为空; + * - 开了 showCount 但选项是静态且没写 count。 + */ +export function validateQuerySchema(schema: QuerySchema): string[] { + const errors: string[] = [] + const usedFields = new Set() + for (const filter of schema.filters) { + const field = textOf(filter.field) + if (!field) { + errors.push('普通/更多查询里存在空字段项') + continue + } + if (usedFields.has(field)) errors.push(`字段「${field}」在查询条件里重复出现`) + usedFields.add(field) + } + + for (const filter of schema.filters) { + const component = filter.component || inferComponent(undefined) + if (needsOptions(component) && !hasUsableOptions(filter.options)) { + errors.push(`字段「${filter.field}」是下拉选择,需要配置选项`) + } + } + + // tags 组:标识 / 名称 / 字段 / 选项逐个查。 + // 完全空白的新建组(刚点「新增一组」还没填)不报错 —— 那是「还没配」, + // 不是「配错了」;等填了任意一项才校验,避免一新增就满屏红字。 + const usedTagKeys = new Set() + for (const tag of schema.tags) { + const key = textOf(tag.key) + if (!key) errors.push('多标签搜索存在空标识项') + else if (usedTagKeys.has(key)) errors.push(`多标签搜索标识「${key}」重复`) + else usedTagKeys.add(key) + + if (isBlankTag(tag)) continue + + const name = textOf(tag.label) || key || '(未命名)' + if (!textOf(tag.label)) errors.push(`多标签搜索「${name}」缺少名称`) + if (!textOf(tag.field)) errors.push(`多标签搜索「${name}」缺少筛选字段`) + if (!hasUsableOptions(tag.options)) { + errors.push(`多标签搜索「${name}」需要配置选项(静态选项或 SQL)`) + } + // 数量来自选项:算不出来就让配置者知道,而不是运行时静默显示 0 + if (tag.showCount && tag.options?.source === 'static') { + const items = tag.options.items ?? [] + if (items.length && items.every((item) => item.count == null)) { + errors.push(`多标签搜索「${name}」开启了数量,但静态选项都没写数量`) + } + } + } + + const usedKeys = new Set() + for (const custom of schema.customs) { + const key = textOf(custom.key) + if (!key) errors.push('自定义查询存在空标识项') + else if (usedKeys.has(key)) errors.push(`自定义查询标识「${key}」重复`) + else usedKeys.add(key) + + if (!textOf(custom.label)) errors.push(`自定义查询「${key || '(未命名)'}」缺少名称`) + + if (custom.component === 'checkbox') { + if (!textOf(custom.condition)) { + errors.push(`自定义查询「${custom.label}」是固定条件形态,必须填写条件片段`) + } + } else if (!textOf(custom.field)) { + errors.push(`自定义查询「${custom.label}」缺少字段或字段表达式`) + } + + if (needsOptions(custom.component) && !hasUsableOptions(custom.options)) { + errors.push(`自定义查询「${custom.label}」需要配置选项(静态选项或 SQL)`) + } + } + + return errors +} + +/** 选项来源是否可用(sql 有语句 / static 有至少一项) */ +function hasUsableOptions(options: QueryOptions | undefined): boolean { + if (!options) return false + if (options.source === 'sql') return Boolean(textOf(options.sql)) + return Array.isArray(options.items) && options.items.length > 0 +} + +/** + * 是否是一个「还没开始填」的 tags 组。 + * + * 刚点「新增一组」时组里什么都没有:没组名、没选项。这种组不该报错 —— + * 那是「还没配」,不是「配错了」。一新增就满屏红字会让人以为坏了。 + * 填了组名或配了选项之后才进入校验。 + */ +function isBlankTag(tag: QueryTag): boolean { + return !textOf(tag.label) && !hasUsableOptions(tag.options) +} diff --git a/code/g3soft-erp/web/src/utils/schemaJson.ts b/code/g3soft-erp/web/src/utils/schemaJson.ts index f964f860..54026962 100644 --- a/code/g3soft-erp/web/src/utils/schemaJson.ts +++ b/code/g3soft-erp/web/src/utils/schemaJson.ts @@ -36,14 +36,8 @@ export function createDefaultSchema(schemaType: string): SchemaJson { // edit schemaVersion 2:children 由「行节点 + 分组节点」构成,行是显式结构 // (v1 是平铺字段节点 + newline 标记,加载时仍可容错折成行) if (schemaType === 'edit') return { schemaVersion: 2, children: [] } - if (schemaType === 'query') { - // schemaVersion 6:查询入口 = 快捷查询(keyword)+ 平铺条件(filters)+ 筛选组(groups) - return { - schemaVersion: 6, - filters: [], - groups: [], - } - } + // query 不走这里:查询配置的结构由 @/utils/querySchema 独立定义 + // (keyword / filters / customs),此处只留通用兜底,避免两套结构并存。 return { schemaVersion: 1 } } diff --git a/code/g3soft-erp/web/src/views/auth/Login.vue b/code/g3soft-erp/web/src/views/auth/Login.vue index 926f68cb..dffc0f84 100644 --- a/code/g3soft-erp/web/src/views/auth/Login.vue +++ b/code/g3soft-erp/web/src/views/auth/Login.vue @@ -1,7 +1,7 @@