From e434374db530ff7b343d97da32d3a679dd3e61c7 Mon Sep 17 00:00:00 2001 From: ZhangAo Date: Wed, 14 Oct 2026 17:25:47 +0800 Subject: [PATCH] u --- code/g3soft-erp/.gitignore | 8 + code/g3soft-erp/CODEBUDDY.md | 141 ++++ code/g3soft-erp/README.md | 16 +- code/g3soft-erp/pnpm-lock.yaml | 28 + code/g3soft-erp/scripts/dev-login.mjs | 103 +++ code/g3soft-erp/server/README.md | 5 +- .../erp/config/RequestLoggingFilter.java | 6 +- .../src/main/resources/logback-spring.xml | 5 +- code/g3soft-erp/web/.env.development | 32 + code/g3soft-erp/web/.oxlintrc.json | 11 +- code/g3soft-erp/web/e2e/smoke.spec.ts | 42 + code/g3soft-erp/web/e2e/support/fixtures.ts | 37 + .../web/e2e/support/global-setup.ts | 17 + code/g3soft-erp/web/e2e/support/session.ts | 138 ++++ code/g3soft-erp/web/package.json | 3 + code/g3soft-erp/web/playwright.config.ts | 53 ++ .../web/src/components/biz-tree/BizTree.vue | 247 ++++++ .../src/components/biz-tree/BizTreePanel.vue | 467 +++++++++++ code/g3soft-erp/web/src/dev/auto-login.ts | 53 ++ code/g3soft-erp/web/src/layouts/AppLogo.vue | 90 +++ .../web/src/layouts/LayoutBreadcrumb.vue | 6 - .../web/src/layouts/LayoutHeader.vue | 36 +- .../web/src/layouts/LayoutHeaderMenu.vue | 13 +- .../web/src/layouts/LayoutMixedColumn.vue | 17 +- .../web/src/layouts/LayoutMixedRail.vue | 32 +- .../web/src/layouts/LayoutSidebar.vue | 53 +- .../web/src/layouts/LayoutTabbar.vue | 77 +- .../web/src/layouts/SidebarMenuItem.vue | 1 + code/g3soft-erp/web/src/layouts/icons.ts | 26 + .../web/src/layouts/useDarkSidebar.ts | 23 + .../web/src/layouts/useMixedMenu.ts | 23 +- .../web/src/layouts/useSidebarMenu.ts | 144 +++- code/g3soft-erp/web/src/main.ts | 6 + .../components/PreferencesDrawer.vue | 53 -- code/g3soft-erp/web/src/preferences/config.ts | 11 - code/g3soft-erp/web/src/preferences/index.ts | 1 - code/g3soft-erp/web/src/preferences/types.ts | 25 - code/g3soft-erp/web/src/router/index.ts | 123 ++- code/g3soft-erp/web/src/types/env.d.ts | 8 + code/g3soft-erp/web/src/utils/common.ts | 17 + code/g3soft-erp/web/src/utils/powerCodes.ts | 17 + code/g3soft-erp/web/src/utils/schemaJson.ts | 294 +++++++ code/g3soft-erp/web/src/utils/tree.ts | 510 +++++++++++- .../web/src/views/module/I18nType.vue | 19 + .../web/src/views/module/MenuManagement.vue | 19 + .../module-management/ModuleBasicPanel.vue | 253 ++++++ .../module-management/ModuleFieldsPanel.vue | 113 +++ .../module-management/ModuleTreePanel.vue | 201 +++++ .../views/module/module-management/index.vue | 746 ++++++++++++++++++ .../views/module/module-management/utils.ts | 139 ++++ .../web/src/views/table-test/index.vue | 41 +- .../tests/components/biz-tree/BizTree.spec.ts | 297 +++++++ .../components/biz-tree/BizTreePanel.spec.ts | 65 ++ .../web/tests/layouts/useSidebarMenu.spec.ts | 104 +++ code/g3soft-erp/web/tests/utils/tree.spec.ts | 694 ++++++++++++++++ code/g3soft-erp/web/tsconfig.json | 13 +- code/g3soft-erp/web/vitest.config.ts | 9 +- code/g3soft-libs/docs/content/ui/data/tree.md | 5 +- .../packages/ui/src/button/Button.vue | 21 + .../packages/ui/src/date/DatePicker.vue | 2 +- .../g3soft-libs/packages/ui/src/date/types.ts | 2 +- .../packages/ui/src/form/FormGroup.vue | 46 +- .../packages/ui/src/form/FormItem.vue | 10 + .../packages/ui/src/select/Select.vue | 2 +- .../packages/ui/src/select/types.ts | 2 +- .../packages/ui/src/table/G3Table.test.ts | 58 +- .../packages/ui/src/table/G3Table.vue | 53 +- .../ui/src/table/style/_overrides.scss | 63 ++ .../g3soft-libs/packages/ui/src/tree/Tree.vue | 10 +- .../packages/ui/src/tree/TreeNode.vue | 42 +- .../g3soft-libs/packages/ui/src/tree/types.ts | 8 +- 71 files changed, 5726 insertions(+), 329 deletions(-) create mode 100644 code/g3soft-erp/CODEBUDDY.md create mode 100644 code/g3soft-erp/scripts/dev-login.mjs create mode 100644 code/g3soft-erp/web/.env.development create mode 100644 code/g3soft-erp/web/e2e/smoke.spec.ts create mode 100644 code/g3soft-erp/web/e2e/support/fixtures.ts create mode 100644 code/g3soft-erp/web/e2e/support/global-setup.ts create mode 100644 code/g3soft-erp/web/e2e/support/session.ts create mode 100644 code/g3soft-erp/web/playwright.config.ts create mode 100644 code/g3soft-erp/web/src/components/biz-tree/BizTree.vue create mode 100644 code/g3soft-erp/web/src/components/biz-tree/BizTreePanel.vue create mode 100644 code/g3soft-erp/web/src/dev/auto-login.ts create mode 100644 code/g3soft-erp/web/src/layouts/AppLogo.vue create mode 100644 code/g3soft-erp/web/src/layouts/useDarkSidebar.ts create mode 100644 code/g3soft-erp/web/src/utils/common.ts create mode 100644 code/g3soft-erp/web/src/utils/schemaJson.ts create mode 100644 code/g3soft-erp/web/src/views/module/I18nType.vue create mode 100644 code/g3soft-erp/web/src/views/module/MenuManagement.vue create mode 100644 code/g3soft-erp/web/src/views/module/module-management/ModuleBasicPanel.vue create mode 100644 code/g3soft-erp/web/src/views/module/module-management/ModuleFieldsPanel.vue create mode 100644 code/g3soft-erp/web/src/views/module/module-management/ModuleTreePanel.vue create mode 100644 code/g3soft-erp/web/src/views/module/module-management/index.vue create mode 100644 code/g3soft-erp/web/src/views/module/module-management/utils.ts create mode 100644 code/g3soft-erp/web/tests/components/biz-tree/BizTree.spec.ts create mode 100644 code/g3soft-erp/web/tests/components/biz-tree/BizTreePanel.spec.ts create mode 100644 code/g3soft-erp/web/tests/layouts/useSidebarMenu.spec.ts create mode 100644 code/g3soft-erp/web/tests/utils/tree.spec.ts diff --git a/code/g3soft-erp/.gitignore b/code/g3soft-erp/.gitignore index 6a048941..cfb1e8ac 100644 --- a/code/g3soft-erp/.gitignore +++ b/code/g3soft-erp/.gitignore @@ -25,6 +25,14 @@ logs/ logs-demo/ data/ +# ---- E2E(Playwright)---- +# 产物 +playwright-report/ +test-results/ +blob-report/ +# 登录会话缓存(含可用 token,绝不能提交) +web/e2e/.auth/ + # ---- 编辑器 / 系统 ---- .idea/ *.iml diff --git a/code/g3soft-erp/CODEBUDDY.md b/code/g3soft-erp/CODEBUDDY.md new file mode 100644 index 00000000..b2d0c412 --- /dev/null +++ b/code/g3soft-erp/CODEBUDDY.md @@ -0,0 +1,141 @@ +# CODEBUDDY.md + +给 AI 协作者的项目约定。人看的话直接读 `README.md`(前端)与 `server/README.md`(后端)。 + +## 项目速览 + +前后端同仓、**独立构建、独立版本,唯一契约是 HTTP 接口**(前端不读后端源码,后端不管前端怎么调)。 + +| 目录 | 是什么 | 端口 / 入口 | +|---|---|---| +| `web/` | 前端(Vue 3 + Vite + Pinia),唯一有 `package.json` 的包 | dev server `5090`,`/api` 代理到 `127.0.0.1:8088` | +| `server/` | 后端(Spring Boot 4.1 + Java),多机构 SQL Server | `8088`,context-path `/api` | +| `sql/` | 工作流模块 SQL 脚本与演示数据 | 执行顺序见 `sql/README.md` | + +**根目录没有 `package.json`**,前端命令一律在 `web/` 下跑。 + +## 启动 + +```bash +# 1) 组件库必须先有产物(@g3soft/ui 的 exports 指向 dist) +# 需要 g3soft-libs 与 g3soft-erp 平级 +cd ../g3soft-libs && pnpm --filter @g3soft/tokens build && pnpm --filter @g3soft/ui build + +# 2) 前端 +cd ../g3soft-erp/web && pnpm dev # http://localhost:5090 + +# 3) 后端(连只支持 TLS 1.0 的老 SQL Server 时必须这么起,见 server/config/dbconfigs/README.md) +cd ../g3soft-erp/server +mvn "-Dspring-boot.run.jvmArguments=-Djava.security.properties=./config/legacy-tls.security" spring-boot:run +``` + +改完组件库要重启 dev server(构建会清空 `dist/`,dev server 会崩或白屏),详见根 `README.md`。 + +## 开发期免登录(省掉每次填账号 + 拖滑块) + +`web/.env.development` 里配好了测试账号,`pnpm dev` 打开页面**已是登录态**: + +| 变量 | 值 | 说明 | +|---|---|---| +| `VITE_DEV_AUTO_LOGIN` | `1` | 设为 `0` 关闭(例如要调登录页本身) | +| `VITE_DEV_ORG` | `G3HD` | 机构码,需对应 `server/config/dbconfigs/{机构码}.properties` | +| `VITE_DEV_ACCOUNT` | `demo_zhangwei` | 实测可登录,但**只有审批中心权限**(见下) | +| `VITE_DEV_PASSWORD` | `demo_zhangwei` | 演示账号密码=账号名 | + +实现见 `web/src/dev/auto-login.ts`:调 `POST /api/auth/login` 拿 token 写进 store, +**只在 `import.meta.env.DEV` 下生效**,生产构建里被 tree-shake。 +滑块验证码是纯前端校验(不发请求),所以走接口登录不需要它。 + +覆盖面:入口是 `/login` 时不自动登录;已有 token 时不重复登录。 +个人覆盖用 `web/.env.development.local`(已被 gitignore,优先级更高)。 + +命令行取 token(例如要用 curl 直接打业务接口): + +```bash +node scripts/dev-login.mjs --token-only # 只输出 token 一行 +node scripts/dev-login.mjs # 人看的输出 + curl 示例 +``` + +## E2E(Playwright) + +```bash +cd web +pnpm test:e2e # 全量,headless +pnpm test:e2e:ui # 带 UI 的调试模式 +``` + +前置条件:**后端已在 8088 起来**(前端 dev server 由配置里的 `webServer` 负责,已经在跑就复用)。 +后端没起会在 globalSetup 阶段直接报「连不上后端」,不会跑到用例里。 + +与 Vitest 的分工:`pnpm test` 跑 `tests/**`(happy-dom 单元测试),`pnpm test:e2e` 跑 `e2e/**`(真浏览器 + 真后端)。 + +**单元测试放哪儿**:统一放 `web/tests/`,目录**镜像 `src/` 结构** +(`src/components/biz-tree/BizTree.vue` → `tests/components/biz-tree/BizTree.spec.ts`)。 +不要贴源放进组件目录;`vitest.config.ts` 的 `include` 只收 `tests/**`,src 下误放的 spec 不会被执行。 +测试里引用被测文件一律用 `@/` 别名(如 `@/components/biz-tree/BizTree.vue`),别写相对路径。 + +`playwright.config.ts` 的 `testDir` 必须限定 `./e2e`,否则 Playwright 会把 vitest 的用例也收进来。 + +### 免登录是怎么做的 + +1. `e2e/support/global-setup.ts`:**整轮测试只登录一次**,调 `POST /api/auth/login`, + 会话落到 `e2e/.auth/session.json`(已 gitignore,里面有可用 token)。 +2. `e2e/support/fixtures.ts`:覆盖内置 `context` fixture,用 `addInitScript` 在应用脚本 + 执行**之前**把 `sessionStorage['g3erp:login']` 种好,于是守卫直接放行,用例一进站就是登录态。 + +**为什么不是 `storageState`**:token 存在 `sessionStorage`,而 Playwright 的 `storageState` +只序列化 cookie + localStorage,种不进去。`addInitScript` 是唯一可靠的办法。 + +**为什么必须只登录一次**:后端 `ActiveSessionRegistry` 按 (机构码, 账号) 只保留最新会话, +重复登录会互踢(业务码 `40101`)。并发 worker 各登录一次就会互相把对方踢下线。 + +> 所以:**不要在用例里提交登录表单**。要测登录流程本身,得用一个专用账号, +> 并把 `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` +改用系统已装的浏览器(Chromium 下载不便时)。 + +### 写用例时注意 + +- 落点别选 `/dashboard`:它**没有任何权限点**(`sql/20_registration/14_dashboard_menu.sql` + 明确「不建权限点」),只有超管可见;默认账号 `demo_zhangwei` 进它会被守卫送到 `/403`。 + `e2e/smoke.spec.ts` 用 `/approval/todo`,演示账号和超管都能进。 +- 断言用 `toHaveURL(/\/approval\/todo$/)` 这种**正向**断言,别用 `not.toHaveURL(/\/login/)`: + `goto` 之后 URL 立刻就是目标值,否定断言会在重定向发生之前就通过,等于什么都没断言。 +- dev server 是按需转译的,某页**第一次**访问要现编依赖图,实测 5-11s。 + 配置里已经把 expect 超时放宽到 15s —— 别在用例里写死更短的值。 + +## 测试账号 + +| 账号 | 密码 | 权限 | 用途 | +|---|---|---|---| +| `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 核心库。 + +账号密码不用去查库。库里没有的账号靠 `sql/` 下的脚本建(执行顺序见 `sql/README.md`); +要给 `demo_*` 放开更多菜单,用 `sql/README.md` 里「可选:给普通账号授权」那段。 + +## 踩坑备忘 + +- **同一账号重复登录会互踢**:每次登录都刷新服务端 session(`ActiveSessionRegistry.activate`), + 上一个会话的下一次请求会拿到业务码 `40101` 并被踢回登录页。所以**别和正在用 `g3soft` 的人共用一个账号**—— + 否则双方会在「登录 → 被踢 → 重新登录」之间来回。需要并行时用 `.env.development.local` + 给 AI 换一个账号(`demo_*` 权限窄,测非审批页要另开权限)。 +- **鉴权失败是 HTTP 200 + `body.code=401`**,不是 HTTP 401,判状态码会漏。 +- **菜单是动态的**(读 `s_menu` 表):新增页面必须同时加前端静态路由 +`s_menu` 记录, + 缺一个就表现为「点菜单进 404」。而 Vite 对任意路径都返回 HTTP 200,网络面板里看不出 404, + 判据是**路由是否匹配**。 +- **权限是白名单语义**:`s_power` 没授权 → 侧栏不显示该菜单 + 守卫拦到 `/403`。 +- **token 存 `sessionStorage`**(key `g3erp:login`,pinia-plugin-persistedstate 写的), + 所以浏览器每个新会话/新 context 都是未登录态 —— 这正是自动登录要解决的问题。 +- **`docs/` 目录当前不存在**(提交 `15e0ae5b` 删掉了)。文档写进 `README.md` / `server/README.md` + 或本文件,别再新增指向 `docs/` 的链接。 diff --git a/code/g3soft-erp/README.md b/code/g3soft-erp/README.md index 3e0db61c..e21a99ac 100644 --- a/code/g3soft-erp/README.md +++ b/code/g3soft-erp/README.md @@ -17,7 +17,8 @@ g3soft-erp/ │ ├── tsconfig.json │ ├── .oxlintrc.json │ ├── .oxfmtrc.json -│ └── src/ +│ ├── src/ +│ └── tests/ # Vitest 单元测试(镜像 src 结构,见 CODEBUDDY.md) ├── server/ # 后端(Maven + Spring Boot,已迁入原型实现) ├── docs/ # 设计文档 └── README.md @@ -44,6 +45,9 @@ pnpm dev # http://localhost:5090 ``` > **命令都在 `web/` 下跑**,根目录没有 `package.json`,所以根目录敲不了 `pnpm dev`。 +> +> **开发期默认自动登录**:`web/.env.development` 配了测试账号,打开页面即是登录态, +> 不必手点登录。要调登录页本身就把 `VITE_DEV_AUTO_LOGIN` 设为 `0`。详见 `CODEBUDDY.md`。 ## 脚本(在 `web/` 下执行) @@ -52,7 +56,9 @@ pnpm dev # http://localhost:5090 | `pnpm dev` | 开发服务器(端口 5090,`/api` 代理到 8088) | | `pnpm build` | 类型检查 + 生产构建 | | `pnpm typecheck` | 仅 `vue-tsc --noEmit` | -| `pnpm test` | Vitest | +| `pnpm test` | Vitest(`tests/**` 单元测试,镜像 `src/` 结构) | +| `pnpm test:e2e` | Playwright(`e2e/**`,真浏览器;需后端已启动,见 `CODEBUDDY.md`) | +| `pnpm test:e2e:ui` | Playwright UI 调试模式 | | `pnpm lint` / `pnpm fmt` | oxlint / oxfmt | | `pnpm check` | lint + fmt:check + build(提交前跑这个) | @@ -128,6 +134,12 @@ resolve: { | `VITE_PROXY_TARGET` | `http://127.0.0.1:8088` | 开发环境 `/api` 代理目标 | | `VITE_API_BASE` | `/api` | 接口基础路径 | | `VITE_APP_TITLE` | `G3Soft ERP` | 应用标题 | +| `VITE_DEV_AUTO_LOGIN` | `1`(`web/.env.development`) | 开发期自动登录开关,`0` 关闭 | +| `VITE_DEV_ORG` | `G3HD` | 自动登录机构码 | +| `VITE_DEV_ACCOUNT` | `demo_zhangwei` | 自动登录账号(超管 `g3soft` 密码未知,待确认) | +| `VITE_DEV_PASSWORD` | `demo_zhangwei` | 自动登录密码(演示账号密码=账号名) | + +四个 `VITE_DEV_*` 只在 dev 生效,构建时被 tree-shake,不进生产包。说明见 `CODEBUDDY.md`。 ## 将来加 H5 端 diff --git a/code/g3soft-erp/pnpm-lock.yaml b/code/g3soft-erp/pnpm-lock.yaml index cfefb4c6..8b03ca85 100644 --- a/code/g3soft-erp/pnpm-lock.yaml +++ b/code/g3soft-erp/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: specifier: ^5.2.0 version: 5.4.0(@vue/compiler-sfc@3.5.43)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)))(rolldown@1.2.13)(vite@8.3.3(@types/node@24.19.1)(sass@1.105.1))(vue@3.5.43(typescript@5.9.3)) devDependencies: + '@playwright/test': + specifier: ^1.64.0 + version: 1.64.0 '@types/node': specifier: ^24.0.0 version: 24.19.1 @@ -469,6 +472,11 @@ packages: resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} + '@playwright/test@1.64.0': + resolution: {integrity: sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==} + engines: {node: '>=20'} + hasBin: true + '@rolldown/binding-android-arm-eabi@1.2.13': resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1162,6 +1170,16 @@ packages: pkg-types@2.3.3: resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==} + playwright-core@1.64.0: + resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.0: + resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.29: resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} engines: {node: ^10 || ^12 || >=14} @@ -1653,6 +1671,10 @@ snapshots: '@parcel/watcher-win32-x64': 2.6.0 optional: true + '@playwright/test@1.64.0': + dependencies: + playwright: 1.64.0 + '@rolldown/binding-android-arm-eabi@1.2.13': optional: true @@ -2312,6 +2334,12 @@ snapshots: exsolve: 1.1.3 pathe: 2.0.3 + playwright-core@1.64.0: {} + + playwright@1.64.0: + dependencies: + playwright-core: 1.64.0 + postcss@8.5.29: dependencies: nanoid: 3.3.20 diff --git a/code/g3soft-erp/scripts/dev-login.mjs b/code/g3soft-erp/scripts/dev-login.mjs new file mode 100644 index 00000000..660d6407 --- /dev/null +++ b/code/g3soft-erp/scripts/dev-login.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * 开发期取 token —— 给 AI / 脚本 / 命令行用,不必开浏览器点登录。 + * + * 用法(在仓库根目录执行): + * node scripts/dev-login.mjs # 用 web/.env.development 里的账号 + * node scripts/dev-login.mjs G3HD g3soft # 指定机构码 + 账号 + * node scripts/dev-login.mjs G3HD g3soft pwd # 再指定密码 + * node scripts/dev-login.mjs --token-only # 只输出 token 一行,便于 $(...) 取值 + * node scripts/dev-login.mjs --target http://127.0.0.1:8088 + * + * 直接打后端(默认 127.0.0.1:8088),不经过 Vite 代理。 + * + * ⚠️ 同一账号重复登录会互踢(后端 ActiveSessionRegistry,业务码 40101): + * 这个脚本每跑一次就会把该账号上一个会话踢下线。别在别人正用同一账号时跑。 + */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const ROOT = fileURLToPath(new URL('..', import.meta.url)) +const ENV_FILE = path.join(ROOT, 'web', '.env.development') + +/* ---------------- 读取 web/.env.development 作为默认值 ---------------- */ + +function readEnvValue(key) { + try { + for (const line of readFileSync(ENV_FILE, 'utf8').split(/\r?\n/)) { + const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line) + if (match && match[1] === key) { + return match[2].trim().replace(/^(['"])(.*)\1$/, '$2') + } + } + } catch { + // 文件不存在:回落到内置默认值,下面还会打印提示 + } + return '' +} + +/* ---------------- 参数 ---------------- */ + +const positional = [] +let target = '' +let tokenOnly = false + +const args = process.argv.slice(2) +for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--target') { + target = args[i + 1] ?? '' + i += 1 + } else if (args[i] === '--token-only') { + tokenOnly = true + } else { + positional.push(args[i]) + } +} + +const base = (target || process.env.VITE_PROXY_TARGET || 'http://127.0.0.1:8088').replace(/\/+$/, '') +const orgId = positional[0] || readEnvValue('VITE_DEV_ORG') || 'G3HD' +const account = positional[1] || readEnvValue('VITE_DEV_ACCOUNT') || 'g3soft' +const password = positional[2] ?? readEnvValue('VITE_DEV_PASSWORD') + +/* ---------------- 登录 ---------------- */ + +let body +try { + const response = await fetch(`${base}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orgid: orgId, userid: account, password }), + }) + body = await response.json() +} catch (error) { + console.error(`连不上后端 ${base}:${error.message}`) + console.error('先在 server/ 下起后端(legacy TLS 用这条):') + console.error(' mvn "-Dspring-boot.run.jvmArguments=-Djava.security.properties=./config/legacy-tls.security" spring-boot:run') + process.exit(1) +} + +/* 后端鉴权失败返回 HTTP 200 + body.code=401(不是 HTTP 401),两个都要判。 */ +if (body?.code !== 0 || !body?.data?.token) { + console.error(`登录失败(code=${body?.code}):${body?.message || '响应里没有 token'}`) + console.error(`用的账号:机构码 ${orgId} / 账号 ${account}${password ? ' / 密码已填' : ' / 密码为空'}`) + console.error('账号密码来自 web/.env.development 的 VITE_DEV_*,也可命令行覆盖。') + console.error('超管 g3soft 的密码在库里是空串;若你的库不是,传第三个参数指定密码。') + process.exit(1) +} + +const { token, user } = body.data + +/* ---------------- 输出 ---------------- */ + +if (tokenOnly) { + process.stdout.write(`${token}\n`) +} else { + console.log(`已登录:${orgId}/${account}(${user?.name || '未命名'})`) + console.log('') + console.log('token:') + console.log(token) + console.log('') + console.log('直接调业务接口(浏览器里不用这个,开发期页面已自动登录):') + console.log(` curl -H "Authorization: Bearer ${token}" ${base}/api/data/loaddata`) +} diff --git a/code/g3soft-erp/server/README.md b/code/g3soft-erp/server/README.md index 5377c34f..f755c858 100644 --- a/code/g3soft-erp/server/README.md +++ b/code/g3soft-erp/server/README.md @@ -41,6 +41,9 @@ 控制台输出彩色文本;文件输出**结构化 JSON**(每行一个对象,logstash 格式),默认落在 `server/logs/` (相对进程工作目录的 `./logs`,根 `.gitignore` 已忽略)。 +控制台字段顺序:`时间 级别 [traceId] [orgId/userId] method uri logger: message`。logger 列**不做定宽补齐** +(短名 `ACCESS` 补齐会留下一大段空白),需要精确定位时看文件 JSON 的 `logger_name`。 + | 文件 | 内容 | |---|---| | `logs/erp-api.jsonl` | 全量日志 | @@ -62,7 +65,7 @@ > 入参 `X-Request-Id` 需匹配 `^[A-Za-z0-9._-]{8,64}$` 才会被复用,否则**静默忽略并自行生成**(防日志注入 / 换行伪造)。 > 若发现上游的 traceId 没串起来,先检查这个格式约束。W3C `traceparent` 则要求标准 `00-<32hex>-<16hex>-<2hex>` 格式。 -访问日志每请求一行(`logger_name=ACCESS`、`message=http_access`)。主要字段(均为 JSON 顶层字段): +访问日志每请求一行(`logger_name=ACCESS`,`message` 形如 `200 45ms`,慢请求带 `SLOW` 后缀)。主要字段(均为 JSON 顶层字段): | 字段 | 说明 | |---|---| diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/RequestLoggingFilter.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/RequestLoggingFilter.java index 3ddf8088..ca26de88 100644 --- a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/RequestLoggingFilter.java +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/RequestLoggingFilter.java @@ -81,6 +81,7 @@ public class RequestLoggingFilter extends OncePerRequestFilter { status = response.getStatus(); } finally { long durationMs = (System.nanoTime() - startNanos) / 1_000_000L; + boolean slow = durationMs > logProperties.getSlowRequestMs(); try { // traceId / orgId / userId / method / uri 由 MDC 自动平铺进此行 JSON LoggingEventBuilder event = ACCESS.atInfo() @@ -88,7 +89,7 @@ public class RequestLoggingFilter extends OncePerRequestFilter { .addKeyValue("durationMs", durationMs) .addKeyValue("clientIp", IpUtils.getClientIp(request)) .addKeyValue("userAgent", truncate(request.getHeader(HEADER_USER_AGENT), 256)); - if (durationMs > logProperties.getSlowRequestMs()) { + if (slow) { event.addKeyValue("slow", true); if (!logProperties.isParamLoggingExcluded(request.getServletPath())) { String params = RequestParams.collect(effectiveRequest, logProperties.getMaxParamBytes()); @@ -97,7 +98,8 @@ public class RequestLoggingFilter extends OncePerRequestFilter { } } } - event.log("http_access"); + // 消息只给控制台看:状态码 + 耗时;其余信息在 MDC 和 KV 字段里 + event.log(slow ? status + " " + durationMs + "ms SLOW" : status + " " + durationMs + "ms"); } finally { // 线程池复用线程,必须清理,避免上下文串到下一个请求 MDC.clear(); diff --git a/code/g3soft-erp/server/src/main/resources/logback-spring.xml b/code/g3soft-erp/server/src/main/resources/logback-spring.xml index 3db72bb5..a54b8056 100644 --- a/code/g3soft-erp/server/src/main/resources/logback-spring.xml +++ b/code/g3soft-erp/server/src/main/resources/logback-spring.xml @@ -15,8 +15,11 @@ + + value="%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p){} %clr([%X{traceId:-}]){magenta} %clr([%X{orgId:-}/%X{userId:-}]){green} %clr(%X{method:-} %X{uri:-}){faint} %clr(%logger{39}){cyan}%clr(:){faint} %m%n%wEx"/> diff --git a/code/g3soft-erp/web/.env.development b/code/g3soft-erp/web/.env.development new file mode 100644 index 00000000..f737b821 --- /dev/null +++ b/code/g3soft-erp/web/.env.development @@ -0,0 +1,32 @@ +# 开发期默认值:`pnpm dev` 自动加载本文件。 +# 个人想覆盖(比如换账号、关掉自动登录),建 web/.env.development.local —— 它已被 +# 根 .gitignore 的 `*.local` 忽略,且优先级高于本文件。 +# 相关代码见 web/src/dev/auto-login.ts,完整说明见根目录 CODEBUDDY.md。 + +# 自动登录:打开页面即已是登录态,省掉手点登录(AI 自动化测试必需,人肉开发也省事)。 +# 只在 DEV 生效:构建时 import.meta.env.DEV 被替换为 false,整段逻辑被 tree-shake, +# 不会进生产包。 +# 置 0 可关闭,例如要调登录页本身时。 +VITE_DEV_AUTO_LOGIN=1 + +# 登录机构码:必须对应 server/config/dbconfigs/{机构码}.properties 里存在的机构。 +VITE_DEV_ORG=G3HD + +# 登录账号。 +# +# 2026-10-14 实测(`node scripts/dev-login.mjs`): +# G3HD / demo_zhangwei / demo_zhangwei → 登录成功 +# G3HD / g3soft / 空密码 → code=401「机构码、账号或密码错误」 +# +# 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 + +# 登录密码:演示账号的密码就是账号本身(见 sql/README.md)。 +VITE_DEV_PASSWORD=demo_zhangwei diff --git a/code/g3soft-erp/web/.oxlintrc.json b/code/g3soft-erp/web/.oxlintrc.json index e0558562..ec268948 100644 --- a/code/g3soft-erp/web/.oxlintrc.json +++ b/code/g3soft-erp/web/.oxlintrc.json @@ -9,10 +9,17 @@ "builtin": true, "browser": true }, - "ignorePatterns": ["dist", "node_modules", "coverage"], + "ignorePatterns": [ + "dist", + "node_modules", + "coverage", + "playwright-report", + "test-results", + "e2e/.auth" + ], "overrides": [ { - "files": ["vite.config.ts", "vitest.config.ts"], + "files": ["vite.config.ts", "vitest.config.ts", "playwright.config.ts", "e2e/**/*.ts"], "env": { "node": true } diff --git a/code/g3soft-erp/web/e2e/smoke.spec.ts b/code/g3soft-erp/web/e2e/smoke.spec.ts new file mode 100644 index 00000000..44fe70f2 --- /dev/null +++ b/code/g3soft-erp/web/e2e/smoke.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from './support/fixtures' +import { loadSession } from './support/session' + +/** + * 冒烟用例:证明「免登录」这条基础设施真的通了。 + * + * 落点选 `/approval/todo` 而不是首页: + * - `/dashboard` **没有任何权限点**(`sql/20_registration/14_dashboard_menu.sql` 写明 + * 「不建权限点」,只有 `g3soft` 提权可见); + * - e2e 默认账号 `demo_zhangwei` 只被授了 `menu.approval%` + * (`sql/90_mock/02_fms_workflow_runtime_mock.sql:186`)。 + * 于是非超管进 `/dashboard` 会被守卫送到 `/403`,而 `/approval/todo` 是演示账号有授权的页面, + * 换成 `g3soft`(超管)也照样通过 —— 两边都不挑账号。 + */ + +test('种了会话就能直接进受保护页面,不跳登录页也不跳 403', async ({ page }) => { + await page.goto('/approval/todo') + + // 断言最终 URL 就是目标页,同时覆盖两种失败:跳 /login(会话没种上)、 + // 跳 /403(种上了但没授权)。只写 not.toHaveURL('/login') 会漏掉后者,而且 + // goto 之后 URL 立刻就是目标值,not 断言会在重定向发生前就通过 —— 断言不到东西。 + await expect(page).toHaveURL(/\/approval\/todo$/) +}) + +test('顶栏显示的是会话里的那个用户', async ({ page }) => { + const user = loadSession().user + const expected = user?.name || user?.account || '' + expect(expected, '会话里没有用户名,登录响应结构可能变了').not.toBe('') + + await page.goto('/approval/todo') + + await expect(page.locator('.layout-header__user')).toContainText(expected) +}) + +test('刷新后仍是登录态(会话读自 sessionStorage,不靠内存)', async ({ page }) => { + await page.goto('/approval/todo') + await expect(page).toHaveURL(/\/approval\/todo$/) + + await page.reload() + + await expect(page).toHaveURL(/\/approval\/todo$/) +}) diff --git a/code/g3soft-erp/web/e2e/support/fixtures.ts b/code/g3soft-erp/web/e2e/support/fixtures.ts new file mode 100644 index 00000000..494ba1ac --- /dev/null +++ b/code/g3soft-erp/web/e2e/support/fixtures.ts @@ -0,0 +1,37 @@ +import { test as base, expect } from '@playwright/test' +import { SESSION_STORAGE_KEY, loadSession, type PersistedSession } from './session' + +export { expect } + +/** + * 免登录的 test:每个 page 在应用脚本执行**之前**就把 sessionStorage 种好, + * 于是路由守卫(`isAuthenticated` 只看 token 非空)直接放行,用例一进站就是登录态。 + * + * 为什么不是 `storageState`:token 存在 sessionStorage,而 Playwright 的 storageState + * 只覆盖 cookie + localStorage,种不进去。 + * + * 这里覆盖的是内置 `context` fixture —— 同名依赖拿到的是基类的原始实现(不是自己, + * 否则就无限递归了)。放 `context` 而不是 `page`:addInitScript 挂在 context 上, + * 用例里新开的 tab / iframe 也一并生效。 + * + * 手写等价物: + * sessionStorage.setItem('g3erp:login', JSON.stringify({ loginInfo: { token, user } })) + * + * ⚠️ 别在用例里真去提交登录表单:那会刷新服务端会话,把本轮其他并发用例一起踢下线。 + * 要测登录流程本身,得先用一个**专用账号**,并关掉 VITE_DEV_AUTO_LOGIN。 + */ +export const test = base.extend({ + context: async ({ context }, use) => { + const { token, user } = loadSession() + const seed: PersistedSession = { loginInfo: { token, user } } + + await context.addInitScript( + (payload: { key: string; value: PersistedSession }) => { + window.sessionStorage.setItem(payload.key, JSON.stringify(payload.value)) + }, + { key: SESSION_STORAGE_KEY, value: seed }, + ) + + await use(context) + }, +}) diff --git a/code/g3soft-erp/web/e2e/support/global-setup.ts b/code/g3soft-erp/web/e2e/support/global-setup.ts new file mode 100644 index 00000000..176f7838 --- /dev/null +++ b/code/g3soft-erp/web/e2e/support/global-setup.ts @@ -0,0 +1,17 @@ +import { apiTarget, devAccount, loginForE2E, saveSession } from './session' + +/** + * 整轮测试唯一的登录点。 + * + * 在 globalSetup(主进程、worker 起来之前)登录一次,token 落 `e2e/.auth/session.json`, + * worker 里的 fixtures 只读文件。**不要在 fixture / 用例里再登录**—— + * 同一账号重复登录会互踢(详见 session.ts 的说明),并发用例会互相踢下线。 + */ +export default async function globalSetup(): Promise { + const { orgId, account } = devAccount() + const session = await loginForE2E() + saveSession(session) + console.log( + `[e2e] 已登录 ${orgId}/${account}(后端 ${apiTarget()}),会话写入 e2e/.auth/session.json`, + ) +} diff --git a/code/g3soft-erp/web/e2e/support/session.ts b/code/g3soft-erp/web/e2e/support/session.ts new file mode 100644 index 00000000..df757404 --- /dev/null +++ b/code/g3soft-erp/web/e2e/support/session.ts @@ -0,0 +1,138 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * E2E 会话:整轮测试**只登录一次**,token 落文件,各 worker 复用。 + * + * 两条硬约束,决定了这里为什么不是「每个用例自己登录」: + * + * 1. **token 存在 sessionStorage**(key `g3erp:login`,见 `src/stores/user.ts` 的 + * persist 配置),而 Playwright 的 `storageState` 只序列化 cookie + localStorage, + * **不含 sessionStorage**。所以 login 只负责把 token 取回来落盘,由 + * `support/fixtures.ts` 用 `addInitScript` 在应用脚本执行前把 sessionStorage 种好。 + * + * 2. **同一账号重复登录会互踢**:后端 `ActiveSessionRegistry.activate` 按 + * (机构码, 账号) 只保留最新会话,上一个会话的下一次请求会被判 40101。 + * 并发 worker 各登录一次 → 互相把对方踢下线 → 用例随机失败。 + * + * 命令行等价物:`node ../scripts/dev-login.mjs --token-only`(给人 / 外部脚本用)。 + */ + +export interface SessionUser { + id: string + account: string + name?: string +} + +export interface Session { + token: string + orgId: string + user: SessionUser +} + +/** 与 `src/stores/user.ts` 的 `persist.key` 对应,改那边要同步改这里。 */ +export const SESSION_STORAGE_KEY = 'g3erp:login' + +/** + * sessionStorage 里实际存的形状:pinia-plugin-persistedstate 是 + * `JSON.stringify(被 pick 出来的 state)`,user store 的 pick 是 `['loginInfo']`。 + * 即 `{"loginInfo":{"token":"...","user":{...}}}`。 + */ +export interface PersistedSession { + loginInfo: { token: string; user: SessionUser | null } +} + +const WEB_ROOT = fileURLToPath(new URL('../..', import.meta.url)) +const SESSION_FILE = path.join(WEB_ROOT, 'e2e', '.auth', 'session.json') + +/** 与 Vite 同序读取:后面的文件覆盖前面的。 */ +const ENV_FILES = ['.env.development', '.env.development.local'] + +/** 读 `web/.env.*` 里的一个变量(不引 dotenv,只认 `KEY=value` 这种写法)。 */ +function readEnv(key: string): string { + let value = '' + for (const file of ENV_FILES) { + const full = path.join(WEB_ROOT, file) + if (!existsSync(full)) continue + for (const line of readFileSync(full, 'utf8').split(/\r?\n/)) { + const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line) + if (match?.[1] === key) { + value = match[2].trim().replace(/^(['"])(.*)\1$/, '$2') + } + } + } + return value +} + +/** 直接打后端,不经过 Vite 代理(省掉对 dev server 的依赖)。 */ +export function apiTarget(): string { + return ( + process.env.E2E_API_TARGET || + readEnv('VITE_PROXY_TARGET') || + 'http://127.0.0.1:8088' + ).replace(/\/+$/, '') +} + +/** 账号来源与开发期自动登录同一份配置,避免两处各写一套。 */ +export function devAccount(): { orgId: string; account: string; password: string } { + return { + orgId: process.env.E2E_ORG || readEnv('VITE_DEV_ORG') || 'G3HD', + account: process.env.E2E_ACCOUNT || readEnv('VITE_DEV_ACCOUNT') || 'demo_zhangwei', + // ?? 而非 ||:空密码是合法值(超管在库里就是空串) + password: process.env.E2E_PASSWORD ?? readEnv('VITE_DEV_PASSWORD'), + } +} + +/** 调登录接口换 token。失败信息要足够定位问题,别只丢一个 HTTP 状态码。 */ +export async function loginForE2E(): Promise { + const { orgId, account, password } = devAccount() + const target = apiTarget() + + let response: Response + try { + response = await fetch(`${target}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orgid: orgId, userid: account, password }), + }) + } catch (error) { + throw new Error( + `连不上后端 ${target}:${(error as Error).message}\n` + + 'e2e 需要后端先起来,见 CODEBUDDY.md 的「启动」。', + { cause: error }, + ) + } + + // 后端鉴权失败返回的是 HTTP 200 + body.code=401(不是 HTTP 401),两个都要判 + const body = (await response.json()) as { + code?: number + message?: string + data?: Session + } + if (body.code !== 0 || !body.data?.token) { + throw new Error( + `E2E 登录失败(code=${body.code}):${body.message || '响应里没有 token'}\n` + + `账号:${orgId}/${account}(密码${password ? '已填' : '为空'})\n` + + '账号密码来自 web/.env.development 的 VITE_DEV_*,可用 E2E_ORG / E2E_ACCOUNT / E2E_PASSWORD 覆盖。', + ) + } + + return body.data +} + +export function saveSession(session: Session): void { + mkdirSync(path.dirname(SESSION_FILE), { recursive: true }) + writeFileSync(SESSION_FILE, `${JSON.stringify(session, null, 2)}\n`, 'utf8') +} + +export function loadSession(): Session { + if (!existsSync(SESSION_FILE)) { + throw new Error( + `找不到 ${SESSION_FILE}:globalSetup 没执行成功。重跑 pnpm test:e2e,先看它的报错。`, + ) + } + return JSON.parse(readFileSync(SESSION_FILE, 'utf8')) as Session +} + +export { SESSION_FILE } diff --git a/code/g3soft-erp/web/package.json b/code/g3soft-erp/web/package.json index 33853101..2afb791f 100644 --- a/code/g3soft-erp/web/package.json +++ b/code/g3soft-erp/web/package.json @@ -12,6 +12,8 @@ "typecheck": "vue-tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "lint": "oxlint . --deny-warnings", "lint:fix": "oxlint . --fix", "fmt": "oxfmt --write .", @@ -31,6 +33,7 @@ "vue-router": "^5.2.0" }, "devDependencies": { + "@playwright/test": "^1.64.0", "@types/node": "^24.0.0", "@types/nprogress": "^0.2.3", "@vitejs/plugin-vue": "^6.0.8", diff --git a/code/g3soft-erp/web/playwright.config.ts b/code/g3soft-erp/web/playwright.config.ts new file mode 100644 index 00000000..e20f0718 --- /dev/null +++ b/code/g3soft-erp/web/playwright.config.ts @@ -0,0 +1,53 @@ +import { defineConfig, devices } from '@playwright/test' + +const BASE_URL = process.env.E2E_BASE_URL || 'http://127.0.0.1:5090' + +/** + * E2E(Playwright)。与 Vitest 的分工: + * vitest → `src/**\/*.{test,spec}.ts`,happy-dom 里的单元测试(见 vitest.config.ts) + * playwright → `e2e/**`,真实浏览器 + 真后端 + * + * `testDir` 必须限定为 `./e2e`:Playwright 默认的 testMatch 会顺着包目录把 + * `src/**\/*.spec.ts` 也收进来,那样 vitest 的用例会被塞进浏览器跑,报一堆莫名其妙的错。 + * + * 前置条件:后端已在 8088 起来(见 CODEBUDDY.md)。前端 dev server 由下面的 webServer + * 负责,已经在跑就复用(不会重复占 5090)。 + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']], + // 整轮只在这里登录一次,原因见 e2e/support/session.ts 的「互踢」说明 + globalSetup: './e2e/support/global-setup.ts', + // dev server 是按需转译的:某个页面**第一次**被访问时要现编整张依赖图,冷启动实测 5-11s。 + // 用默认的 5s expect 超时会表现为「同一个用例时好时坏」,所以统一放宽。 + // 后端慢(登录/菜单查询走远程 SQL Server)也吃这个时间。 + timeout: 60_000, + expect: { timeout: 15_000 }, + use: { + baseURL: BASE_URL, + actionTimeout: 15_000, + navigationTimeout: 30_000, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + // 下载 Playwright 自带 Chromium 不便时(慢网 / 离线 / 公司代理), + // 用系统已装的浏览器:E2E_CHANNEL=msedge 或 E2E_CHANNEL=chrome + channel: process.env.E2E_CHANNEL || undefined, + }, + }, + ], + webServer: { + command: 'pnpm dev', + url: BASE_URL, + reuseExistingServer: true, + timeout: 120_000, + }, +}) diff --git a/code/g3soft-erp/web/src/components/biz-tree/BizTree.vue b/code/g3soft-erp/web/src/components/biz-tree/BizTree.vue new file mode 100644 index 00000000..2f3f00d5 --- /dev/null +++ b/code/g3soft-erp/web/src/components/biz-tree/BizTree.vue @@ -0,0 +1,247 @@ + + + + + diff --git a/code/g3soft-erp/web/src/components/biz-tree/BizTreePanel.vue b/code/g3soft-erp/web/src/components/biz-tree/BizTreePanel.vue new file mode 100644 index 00000000..874d3c10 --- /dev/null +++ b/code/g3soft-erp/web/src/components/biz-tree/BizTreePanel.vue @@ -0,0 +1,467 @@ + + + + + diff --git a/code/g3soft-erp/web/src/dev/auto-login.ts b/code/g3soft-erp/web/src/dev/auto-login.ts new file mode 100644 index 00000000..e40e929a --- /dev/null +++ b/code/g3soft-erp/web/src/dev/auto-login.ts @@ -0,0 +1,53 @@ +import { loginApi } from '@/services/api/auth' +import { useUserStore } from '@/stores' + +/** + * 开发期免登录(AI 自动化测试与人肉开发共用)。 + * + * 解决的问题:token 存在 sessionStorage,浏览器每个新会话(无痕窗口、Playwright + * 的每个新 context、换台机器)都是未登录态,于是每次都得起一个浏览器、填机构码 / + * 账号 / 密码、拖一次滑块验证码。自动化测试里这一步既慢又容易被 UI 改动静默搞挂。 + * + * 这里直接调登录接口把 token 塞进 store,绕开整段 UI。 + * 滑块验证码是**纯前端**校验(Login.vue 的 G3SliderCaptcha,不发请求), + * 所以走 API 登录天然不需要它。 + * + * 生效条件(缺一不可,任一不满足都静默返回): + * 1. `import.meta.env.DEV` —— 只在 `pnpm dev` 下为 true,`vite build` 时被静态 + * 替换为 false,整个分支连同本模块一起被 tree-shake,生产包里不存在; + * 2. `VITE_DEV_AUTO_LOGIN=1` —— 显式开关,见 web/.env.development; + * 3. `VITE_DEV_ORG` / `VITE_DEV_ACCOUNT` 都配了; + * 4. 当前没有 token(已登录就不重复登录,避免把人的会话踢掉); + * 5. 入口不是 /login —— 直奔登录页说明有人正在调登录页本身,别抢。 + * + * ⚠️ 同一账号重复登录会互踢(后端 ActiveSessionRegistry,code 40101)。 + * 若发现「人和 AI 抢同一个账号」互相踢下线,给 AI 换 VITE_DEV_ACCOUNT 或用 + * .env.development.local 覆盖,别共用同一个账号。 + */ +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 userStore = useUserStore() + if (userStore.isAuthenticated) return + + // 直奔 /login 时不自动登录:那是有人在调登录页,抢了就没法测了 + const path = window.location.pathname.replace(/\/+$/, '') + if (path === '/login') return + + try { + const result = await loginApi(orgId, account, import.meta.env.VITE_DEV_PASSWORD || '') + userStore.setSession(result, orgId) + console.info(`[dev] 已自动登录 ${orgId}/${account}(关掉请设 VITE_DEV_AUTO_LOGIN=0)`) + } catch (error) { + // 不阻断启动:登录失败就照常停在登录页,由人去看后端是否已启动、账号密码对不对 + console.warn( + `[dev] 自动登录 ${orgId}/${account} 失败,将停在登录页。检查 web/.env.development 的 VITE_DEV_* 与后端是否已启动:`, + error, + ) + } +} diff --git a/code/g3soft-erp/web/src/layouts/AppLogo.vue b/code/g3soft-erp/web/src/layouts/AppLogo.vue new file mode 100644 index 00000000..6a8ccf15 --- /dev/null +++ b/code/g3soft-erp/web/src/layouts/AppLogo.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/code/g3soft-erp/web/src/layouts/LayoutBreadcrumb.vue b/code/g3soft-erp/web/src/layouts/LayoutBreadcrumb.vue index 5ee981f2..142cc3a5 100644 --- a/code/g3soft-erp/web/src/layouts/LayoutBreadcrumb.vue +++ b/code/g3soft-erp/web/src/layouts/LayoutBreadcrumb.vue @@ -1,7 +1,6 @@ diff --git a/code/g3soft-erp/web/src/layouts/icons.ts b/code/g3soft-erp/web/src/layouts/icons.ts index 13dfd7cb..fa59d540 100644 --- a/code/g3soft-erp/web/src/layouts/icons.ts +++ b/code/g3soft-erp/web/src/layouts/icons.ts @@ -1,5 +1,7 @@ import type { Component } from 'vue' +import type { MenuItem, SidebarMenuNode } from '@/types/menu' import { + Blocks, Building2, CircleCheck, Container, @@ -42,6 +44,9 @@ const iconMap: Record = { 'building-2': Building2, 'file-text': FileText, 'layout-dashboard': LayoutDashboard, + // 模块中心(超管固定入口,见 @/router 的 STATIC_MODULE_MENU): + // 只有一级分组项有图标,子项无图标(STATIC_MODULE_MENU.children 不写 icon) + blocks: Blocks, // PascalCase:兼容早期常量菜单写下的名字 CircleCheck, Database, @@ -57,6 +62,7 @@ const iconMap: Record = { Building2, FileText, LayoutDashboard, + Blocks, } /** 把 `circle-check` 归一化成 `CircleCheck`;已是 PascalCase 的原样返回。 */ @@ -79,3 +85,23 @@ export function getMenuIcon(name?: string): Component | undefined { const key = String(name).trim() return iconMap[key] ?? iconMap[toPascalCase(key)] } + +/** + * MenuItem 树 → 渲染用菜单树(**唯一入口**):过滤隐藏项,并把 icon 字符串解析为组件。 + * + * 单列侧栏(useSidebarMenu)与双列菜单(useMixedMenu)此前各写了一份逐字节相同的 + * 实现,改一处必漏另一处。收敛到这里后,图标解析规则只存在一份。 + * + * ⚠️ 幂等性不成立:传入的 icon 必须仍是**字符串**。已经是组件的树再走一遍 + * getMenuIcon 会拿到 undefined(组件进不了 iconMap),表现为整棵树突然没图标。 + * 所以调用方只能传 useVisibleMenuItems() 的原始输出,不要传本函数的返回值。 + */ +export function mapMenuTree(list: MenuItem[]): SidebarMenuNode[] { + return list + .filter((item) => !item.hidden) + .map((item) => ({ + ...item, + icon: getMenuIcon(item.icon), + children: item.children ? mapMenuTree(item.children) : undefined, + })) +} diff --git a/code/g3soft-erp/web/src/layouts/useDarkSidebar.ts b/code/g3soft-erp/web/src/layouts/useDarkSidebar.ts new file mode 100644 index 00000000..4b6b303e --- /dev/null +++ b/code/g3soft-erp/web/src/layouts/useDarkSidebar.ts @@ -0,0 +1,23 @@ +import { computed, type ComputedRef } from 'vue' +import { usePreferencesStore } from '@/preferences' + +/** + * 侧栏是否按深色渲染(唯一判据)。 + * + * 两个来源任一成立即为深色: + * 1. 偏好里的「深色侧栏」开关 → theme.semiDarkSidebar + * (全局深色模式下该开关禁用,见偏好抽屉) + * 2. 全局深色模式 → html 上的 .dark + * 对照 vben 的 `isDark.value || preferences.theme.semiDarkSidebar` + * (packages/effects/layouts/src/basic/layout.vue:98-101)。 + * + * ⚠️ 为什么必须只有一份:三种侧栏形态(单列 / 双列的第一列 / 双列的第二列) + * 各自算一次会漂移,而本判据同时被 JS(is-dark class)与 CSS + * (DefaultLayout 的 --sidebar-* 变量组)依赖。任何一处算错, + * 表现都是「深字压深底、菜单文字看不见」(实测对比度 1.89,WCAG 正文下限 4.5)。 + * 所以统一到这里,三处布局共用。 + */ +export function useDarkSidebar(): ComputedRef { + const prefStore = usePreferencesStore() + return computed(() => prefStore.preferences.theme.semiDarkSidebar || prefStore.isDark) +} diff --git a/code/g3soft-erp/web/src/layouts/useMixedMenu.ts b/code/g3soft-erp/web/src/layouts/useMixedMenu.ts index 087402f8..8f13cadc 100644 --- a/code/g3soft-erp/web/src/layouts/useMixedMenu.ts +++ b/code/g3soft-erp/web/src/layouts/useMixedMenu.ts @@ -1,20 +1,8 @@ import { computed, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { buildMenuItems, findParentKeysByPath } from '@/router' -import { useMenuStore } from '@/stores' -import { getMenuIcon } from './icons' -import type { MenuItem, SidebarMenuNode } from '@/types/menu' - -/** 菜单树转换:过滤隐藏项,并把 icon 字符串解析为组件。递归处理子级。 */ -function mapTree(list: MenuItem[]): SidebarMenuNode[] { - return list - .filter((item) => !item.hidden) - .map((item) => ({ - ...item, - icon: getMenuIcon(item.icon), - children: item.children ? mapTree(item.children) : undefined, - })) -} +import { findParentKeysByPath, useVisibleMenuItems } from '@/router' +import { mapMenuTree } from './icons' +import type { SidebarMenuNode } from '@/types/menu' /** * 该节点是否只是叶子(没有子菜单)。 @@ -98,10 +86,9 @@ function isGroupActive(node: SidebarMenuNode): boolean { export function useMixedMenu() { const route = useRoute() const router = useRouter() - const menuStore = useMenuStore() - /** 完整菜单树(含 children)—— 来自 s_menu 按权限裁剪后的动态菜单 */ - const tree = computed(() => mapTree(buildMenuItems(menuStore.visibleMenus))) + /** 完整菜单树(含 children)—— s_menu 按权限裁剪后的动态菜单 + 超管固定入口 */ + const tree = computed(() => mapMenuTree(useVisibleMenuItems())) /** * 当前路由所属的顶级分组 key。 diff --git a/code/g3soft-erp/web/src/layouts/useSidebarMenu.ts b/code/g3soft-erp/web/src/layouts/useSidebarMenu.ts index 905d8652..1584803a 100644 --- a/code/g3soft-erp/web/src/layouts/useSidebarMenu.ts +++ b/code/g3soft-erp/web/src/layouts/useSidebarMenu.ts @@ -1,20 +1,101 @@ import { computed, ref, watch } from 'vue' import { useRoute } from 'vue-router' import { usePreferencesStore } from '@/preferences' -import { buildMenuItems, findMenuItemByPath, findParentKeysByPath } from '@/router' -import { useMenuStore } from '@/stores' -import { getMenuIcon } from './icons' -import type { MenuItem, SidebarMenuNode } from '@/types/menu' +import { findMenuItemByPath, findParentKeysByPath, useVisibleMenuItems } from '@/router' +import { mapMenuTree } from './icons' +import type { SidebarMenuNode } from '@/types/menu' -/** 菜单树转换:过滤隐藏项,并把 icon 字符串解析为组件。递归处理子级。 */ -function mapTree(list: MenuItem[]): SidebarMenuNode[] { - return list - .filter((item) => !item.hidden) - .map((item) => ({ - ...item, - icon: getMenuIcon(item.icon), - children: item.children ? mapTree(item.children) : undefined, - })) +/* ---------------- 纯树工具(与响应式无关,放在模块作用域,避免每次调用重建) ---------------- */ + +/** 在菜单树里按 key 找一个节点(含任意深度);找不到返回 null。 */ +function findNodeByKey(list: SidebarMenuNode[], key: string): SidebarMenuNode | null { + for (const item of list) { + if (item.key === key) return item + if (!item.children?.length) continue + const found = findNodeByKey(item.children, key) + if (found) return found + } + return null +} + +/** + * 求某个 key 的**祖先链**(从顶级到其直接父级)。 + * + * 手风琴模式必须靠它保留祖先:子分组的容器挂在父级的 + * `v-show="isGroupOpen(父key)"` 里,一旦父 key 被移出 openKeys, + * 子分组连同整块 DOM 都会被隐藏。 + */ +function findAncestorKeys(list: SidebarMenuNode[], targetKey: string): string[] { + const walk = (nodes: SidebarMenuNode[], trail: string[]): string[] | null => { + for (const item of nodes) { + if (item.key === targetKey) return trail + if (!item.children?.length) continue + const found = walk(item.children, [...trail, item.key]) + if (found) return found + } + return null + } + return walk(list, []) ?? [] +} + +/** + * 收集某分组(含自身)的全部子孙 key。 + * 收起一个分组时要连带收起其后代,否则再次展开父级时里面会残留上次展开的孙子分组。 + */ +function collectSubtreeKeys(node: SidebarMenuNode): string[] { + const keys: string[] = [node.key] + for (const child of node.children ?? []) { + keys.push(...collectSubtreeKeys(child)) + } + return keys +} + +/** + * 手风琴模式下点击某分组后,应该得到的新 openKeys(纯函数,便于单测)。 + * + * 规则:**只收起同级的兄弟分组,绝不动祖先链。** + * + * 为什么要单独抽出来测:早期写法是 `index === -1 ? [key] : []`(整表替换), + * 把祖先也一起删了 —— 「系统管理 → 日志」这种两级分组因此永远点不开: + * 点「日志」后 openKeys 从 ['system'] 变成 ['log'],system 丢失, + * 而「日志」的容器正挂在 isGroupOpen('system') 上,于是整棵「系统管理」直接塌掉, + * 表象就是「点子菜单反而把父菜单收起了」。 + * 这个 bug 类型检查、lint 都发现不了,只能靠用例钉住。 + */ +export function computeAccordionToggle( + tree: SidebarMenuNode[], + openKeys: string[], + key: string, +): string[] { + const isOpen = openKeys.includes(key) + const ancestors = findAncestorKeys(tree, key) + + // 与本次点击同级的其它分组(打开新分组时要收掉的那些,含其后代) + const siblingScope = ancestors.length + ? (findNodeByKey(tree, ancestors[ancestors.length - 1])?.children ?? []) + : tree + const siblingSubtreeKeys = siblingScope + .filter((sibling) => sibling.key !== key) + .flatMap((sibling) => collectSubtreeKeys(sibling)) + + // 保留:祖先链 + 不受本次同级收起影响的其它已展开项 + const kept = openKeys.filter( + (k) => + ancestors.includes(k) || + (k !== key && + !siblingSubtreeKeys.includes(k) && + !findAncestorKeys(tree, k).some((ancestor) => siblingSubtreeKeys.includes(ancestor))), + ) + + if (isOpen) { + // 已展开 → 收起它(连带后代);祖先与同级其它分支保持不动 + const target = findNodeByKey(tree, key) + const subtree = target ? collectSubtreeKeys(target) : [key] + return kept.filter((k) => !subtree.includes(k)) + } + + // 未展开 → 打开它,同时收掉同级其它分组 + return [...kept, key] } /** @@ -29,7 +110,6 @@ export function useSidebarMenu() { const route = useRoute() const prefStore = usePreferencesStore() const pref = computed(() => prefStore.preferences) - const menuStore = useMenuStore() /** 展开的父级菜单 key(分组与子分组都记录在此) */ const openKeys = ref([]) @@ -39,8 +119,11 @@ export function useSidebarMenu() { * * 中间映射依赖 router 里的 buildMenuItems —— 那是「s_menu 行 → * MenuItem」的唯一转换点,布局侧不重复实现,避免两处列名映射漂移。 + * + * 入口用 useVisibleMenuItems(而非直接 buildMenuItems(visibleMenus)): + * 它会额外追加超管专属的固定入口「模块中心」,见 @/router 的 STATIC_MODULE_MENU。 */ - const items = computed(() => mapTree(buildMenuItems(menuStore.visibleMenus))) + const items = computed(() => mapMenuTree(useVisibleMenuItems())) /** * 视觉选中的叶子路径(乐观)。 @@ -96,12 +179,20 @@ export function useSidebarMenu() { return walk(items.value) } + /** + * 求某个分组 key 的**祖先链**(从顶级到其直接父级)。 + * + * 手风琴模式必须靠它来保留祖先:子分组的容器是挂在父级的 + * `v-show="isGroupOpen(父key)"` 里的,一旦把父 key 从 openKeys 里删掉, + * 子分组自己连带整块 DOM 都被隐藏 —— 表现就是「点子分组,父级反而收起」。 + */ function toggleGroup(key: string): void { const index = openKeys.value.indexOf(key) - // 手风琴模式:点开一个就收起其他 + // 手风琴模式:只收起**同级的兄弟分组**,绝不动祖先链。 + // 具体规则与「为什么不能整表替换」见 computeAccordionToggle 的注释。 if (pref.value.sidebar.accordion) { - openKeys.value = index === -1 ? [key] : [] + openKeys.value = computeAccordionToggle(items.value, openKeys.value, key) return } @@ -129,9 +220,24 @@ export function useSidebarMenu() { const parentKeys = findParentKeysByPath(path) if (!parentKeys.length) return - // 手风琴模式:只保留当前路径的祖先链 + // 手风琴模式:把祖先链的**顶级**当作当前分支,收起其它顶级分组, + // 但保留这条链上的其它层级(点开「系统管理 → 日志」后跳到日志页, + // 两者都还得开着,否则展开态会在导航后自己丢掉)。 + // + // 注意不能用 `openKeys.value = parentKeys` 整表替换:那会把 + // 用户手动展开的兄弟分支一起抹掉,与手动点击时的规则也不一致。 if (pref.value.sidebar.accordion) { - openKeys.value = parentKeys + const topKey = parentKeys[0] + const siblingSubtreeKeys = items.value + .filter((sibling) => sibling.key !== topKey) + .flatMap((sibling) => collectSubtreeKeys(sibling)) + + openKeys.value = [ + ...openKeys.value.filter( + (k) => !siblingSubtreeKeys.includes(k) || parentKeys.includes(k), + ), + ...parentKeys.filter((k) => !openKeys.value.includes(k)), + ] return } for (const key of parentKeys) { diff --git a/code/g3soft-erp/web/src/main.ts b/code/g3soft-erp/web/src/main.ts index 2f9099bf..07827ae8 100644 --- a/code/g3soft-erp/web/src/main.ts +++ b/code/g3soft-erp/web/src/main.ts @@ -5,6 +5,7 @@ import App from './App.vue' import router from './router' import pinia from './stores/pinia' import { usePreferencesStore } from './preferences' +import { devAutoLogin } from './dev/auto-login' import './styles/index.scss' @@ -43,6 +44,11 @@ app.use(pinia) // 实例化即把偏好写进 CSS 变量,避免首屏闪一下默认主题。 usePreferencesStore() +// 开发期免登录(生产构建里是空操作)。必须赶在 app.use(router) 之前: +// 首次导航由 install router 触发,路由守卫那时就要能看到 token, +// 晚一步守卫已把人送去 /login,再补 token 就得多一次重定向。 +await devAutoLogin() + app.use(router) /** diff --git a/code/g3soft-erp/web/src/preferences/components/PreferencesDrawer.vue b/code/g3soft-erp/web/src/preferences/components/PreferencesDrawer.vue index a5a6bf7d..d8ec53f9 100644 --- a/code/g3soft-erp/web/src/preferences/components/PreferencesDrawer.vue +++ b/code/g3soft-erp/web/src/preferences/components/PreferencesDrawer.vue @@ -113,12 +113,6 @@ const layoutPreset = [ ] // ---- 下拉 / 分段选项 ---- -const headerModeOptions = [ - { label: '静态', value: 'static' }, - { label: '固定', value: 'fixed' }, - { label: '自动隐藏', value: 'auto' }, -] - const headerMenuAlignOptions = [ { label: '左对齐', value: 'start' }, { label: '居中', value: 'center' }, @@ -495,16 +489,6 @@ const hasDiff = computed(() => true) > 折叠侧栏 - - 悬停展开 - - 模式 - true) > 仅一项时隐藏 - - 显示图标 - - - 显示首页 - true) > 中键关闭 - - 显示图标 - true) - - 顶部进度条 - import('@/views/module/module-management/index.vue'), + meta: { title: '模块管理', adminOnly: true }, + }, + { + path: '/module/menu-management', + name: 'module-menu-management', + component: () => import('@/views/module/MenuManagement.vue'), + meta: { title: '菜单管理', adminOnly: true }, + }, + { + path: '/module/i18n-type', + name: 'module-i18n-type', + component: () => import('@/views/module/I18nType.vue'), + meta: { title: '多语言类型', adminOnly: true }, + }, + // ---- 系统管理(占位) ---- { path: '/system/user', @@ -205,14 +234,94 @@ export function buildMenuItems(rows: MenuRow[]): MenuItem[] { } /** - * 当前可见菜单树(响应式)。 + * 固定系统入口 —— 模块中心(模块管理 / 菜单管理 / 多语言类型)。 + * + * ⚠️ 与其余菜单的本质区别:本项**不来自 s_menu**,有意写死在代码里。 + * + * 为什么写死:模块中心里就有「菜单管理」,它是维护 s_menu 的工具本身。 + * 如果超管工具的入口也落在 s_menu,一旦有人误删这条菜单(或误停用、误改路由), + * 就再也没有入口把它改回来了 —— 只能连库修数据。写死后天然免疫。 + * + * key 用 `static-` 前缀,与 s_menu 的 b_id 区分开:它不对应任何权限点 + * (权限由路由的 meta.adminOnly 守卫表达),也不该被 menu.{key} 授权影响。 + */ +export const STATIC_MODULE_MENU: MenuItem = { + key: 'static-module-center', + title: '模块中心', + // 分组节点自身不可导航,path 用空串:布局的跳转逻辑只在**叶子**上生效, + // 且 findParentKeysByPath / 激活态判断都只在叶子比较 path,空串不会被误命中。 + path: '', + icon: 'blocks', + // 子项刻意**不写 icon**:它们是模块中心的同级配置页,语义上不需要各自区分。 + // 侧栏二级项本来就不渲染图标(只有一级项渲染,见 LayoutSidebar 的 __item.is-child), + // 写上也只是死数据 —— 唯一会用到它的是顶栏横向菜单的下拉,去掉后更清爽。 + children: [ + { key: 'static-module-management', title: '模块管理', path: '/module/module-management' }, + { key: 'static-menu-management', title: '菜单管理', path: '/module/menu-management' }, + { key: 'static-i18n-type', title: '多语言类型', path: '/module/i18n-type' }, + ], +} + +/** + * 固定入口是否追加:仅系统管理员(g3soft)。 + * + * 判据直接复用权限 store 的 isG3soft —— 与路由守卫、canAccess / canEnterMenu 的 + * 提权口径**同源**,避免「侧栏显示但守卫拦截」这种两处判定打架的情况。 + */ +function includeStaticModuleMenu(): boolean { + return usePermissionStore().isG3soft +} + +/** + * 把固定入口按路由去重后追加到动态菜单树末尾。 + * + * 去重(防呆):若 s_menu 里历史上已存在同路由的菜单项,则以 s_menu 的为准, + * 静态项里的对应子项不再重复渲染;子项被全部覆盖时整个分组不渲染。 + * 这样即使有人手工在 s_menu 里补了一条 /module/* 菜单,侧栏也不会出现两个入口。 + */ +function appendStaticModuleMenu(items: MenuItem[]): MenuItem[] { + if (!includeStaticModuleMenu()) return items + + const existingPaths = new Set() + const collect = (list: MenuItem[]): void => { + for (const item of list) { + if (item.path) existingPaths.add(item.path) + if (item.children?.length) collect(item.children) + } + } + collect(items) + + const children = (STATIC_MODULE_MENU.children ?? []).filter( + (child) => !existingPaths.has(child.path), + ) + if (!children.length) return items + + return [...items, { ...STATIC_MODULE_MENU, children }] +} + +/** + * 当前可见菜单树(响应式):s_menu 按权限裁剪后的树 + 超管固定入口。 * * 每次调用都新建 computed 会让「同一 tick 内多次调用」产生多个实例, * 但这里只在守卫与布局里各取一次、且都依赖同一个 store 的 computed, * 开销可忽略;若将来频繁调用,应改为模块级单例 computed。 + * + * ⚠️ 固定入口在这里统一注入,而不是在各个布局组件里各加一次: + * 侧栏 / 顶栏横向菜单 / 双列菜单 / 面包屑全都经过本函数(或复用它的 + * findMenuItemByPath 等),在一处注入即可保证四种布局风格一致, + * 也不会出现「切了布局风格菜单就少了入口」。 */ function currentMenuItems(): MenuItem[] { - return buildMenuItems(useMenuStore().visibleMenus) + return appendStaticModuleMenu(buildMenuItems(useMenuStore().visibleMenus)) +} + +/** + * 布局可见菜单树(**唯一入口**)—— 侧栏 / 顶栏横向菜单 / 双列菜单都应调用本函数, + * 而不是自己去 `buildMenuItems(menuStore.visibleMenus)`:后者拿不到超管固定入口, + * 会表现为「换个布局风格菜单就少了『模块中心』」。 + */ +export function useVisibleMenuItems(): MenuItem[] { + return currentMenuItems() } /** @@ -338,6 +447,16 @@ router.beforeEach(async (to) => { return '/403' } + // 系统管理员专用页面(meta.adminOnly,当前为模块中心 /module/*): + // 非 g3soft 一律拦到 403。按**路由标记**而非路径前缀判断,避免「菜单上看不见、 + // 手输地址却能进」的越权;侧栏入口同样仅对 g3soft 渲染(useSidebarMenu)。 + // + // 这条守卫无法被上面的菜单校验替代:模块中心不在 s_menu, + // findMenuByRoute 对它恒返回 null,也就永远不会触发 menu.* 判定。 + if (to.matched.some((record) => record.meta.adminOnly) && !permissionStore.isG3soft) { + return '/403' + } + // vue-router 5 已弃用 next 回调,改用返回值:放行 return true,拦截 return 路径 return true }) diff --git a/code/g3soft-erp/web/src/types/env.d.ts b/code/g3soft-erp/web/src/types/env.d.ts index 93de3e78..662989bc 100644 --- a/code/g3soft-erp/web/src/types/env.d.ts +++ b/code/g3soft-erp/web/src/types/env.d.ts @@ -13,6 +13,14 @@ interface ImportMetaEnv { readonly VITE_API_BASE?: string /** 应用标题 */ readonly VITE_APP_TITLE?: string + /** 开发期自动登录开关,`1` 开启、`0` 关闭(默认关闭)。仅 DEV 生效,见 src/dev/auto-login.ts */ + readonly VITE_DEV_AUTO_LOGIN?: string + /** 开发期自动登录的机构码,如 G3HD */ + readonly VITE_DEV_ORG?: string + /** 开发期自动登录的账号,如 g3soft */ + readonly VITE_DEV_ACCOUNT?: string + /** 开发期自动登录的密码;超管 g3soft 在库里是空串 */ + readonly VITE_DEV_PASSWORD?: string } interface ImportMeta { diff --git a/code/g3soft-erp/web/src/utils/common.ts b/code/g3soft-erp/web/src/utils/common.ts new file mode 100644 index 00000000..1fbdc33d --- /dev/null +++ b/code/g3soft-erp/web/src/utils/common.ts @@ -0,0 +1,17 @@ +/** + * 通用工具。 + */ + +let tempIdSequence = 0 + +/** + * 本地临时 id:负数、进程内自增。 + * + * 未落库的草稿行(新建模块 / 新增字段等)还没有真实主键,先用临时 id 占位: + * 仅用于树 key / 选中 / 未保存判定,不写入数据库。负数与数据库主键空间不重叠, + * 不会被误当成已持久化的业务编码。 + */ +export function createTempId(): string { + tempIdSequence += 1 + return String(-tempIdSequence) +} diff --git a/code/g3soft-erp/web/src/utils/powerCodes.ts b/code/g3soft-erp/web/src/utils/powerCodes.ts index 5889e3d1..31f72f10 100644 --- a/code/g3soft-erp/web/src/utils/powerCodes.ts +++ b/code/g3soft-erp/web/src/utils/powerCodes.ts @@ -55,3 +55,20 @@ export function buildActionPowerCode(moduleCode: string, action: string): string if (!moduleId || !actionCode) return null return `action.${moduleId}.${actionCode}` } + +/** + * 删除权限点行的依赖声明(保存请求的 dependent_deletes)。 + * + * 权限点行被授权行引用(s_user_power.b_power_id → s_power.b_id):删权限点时授权行必须 + * 一起消失,否则用户会继续「拥有」一个不存在的权限点。编码由后端按同一组删除条件查出, + * 前端不查询、不拼编码 —— 只声明「依赖表 / 键列 / 取值列」交给保存层执行。 + * + * 删权限点的多处入口(菜单删除、模块删除、权限面板删动作)共用本声明,避免各写一份漂移。 + */ +export function buildPowerDeleteDependents(): Array<{ + table: string + key_field: string + source_field: string +}> { + return [{ table: 's_user_power', key_field: 'b_power_id', source_field: 'b_id' }] +} diff --git a/code/g3soft-erp/web/src/utils/schemaJson.ts b/code/g3soft-erp/web/src/utils/schemaJson.ts new file mode 100644 index 00000000..8bbc607e --- /dev/null +++ b/code/g3soft-erp/web/src/utils/schemaJson.ts @@ -0,0 +1,294 @@ +/** + * 界面配置 JSON 公共工具(s_module_schema.b_schema_json)。 + * + * 移植自 fms-vue 的 src/utils/schemaJson.js,口径保持一致: + * 解析库中 JSON、生成默认结构、稳定序列化(用于基线比较与落库)、分组节点 id 的生成与归一化。 + * 配置链路(module-management)与运行时渲染链路(列表页)共用。 + */ + +/** 界面配置类型(对应 s_module_schema.b_schema_type) */ +export const SCHEMA_TYPES = ['view', 'edit', 'query'] + +/** 配置节点(分组 / 字段 / 行;未知扩展属性原样保留) */ +export interface SchemaNode { + type?: string + id?: string + i18n?: string + title?: string + field?: string + children?: SchemaNode[] + [key: string]: unknown +} + +/** 配置对象(b_schema_json 解析结果) */ +export interface SchemaJson { + schemaVersion?: number + /** view 的根节点列表 */ + columns?: SchemaNode[] + /** edit / query 的根节点列表 */ + children?: SchemaNode[] + [key: string]: unknown +} + +/** 各类界面配置的默认(空)结构 */ +export function createDefaultSchema(schemaType: string): SchemaJson { + if (schemaType === 'view') return { schemaVersion: 1, columns: [] } + // edit schemaVersion 2:children 由「行节点 + 分组节点」构成,行是显式结构 + // (v1 是平铺字段节点 + newline 标记,加载时仍可容错折成行) + if (schemaType === 'edit') return { schemaVersion: 2, children: [] } + if (schemaType === 'query') { + // schemaVersion 3:页签可分组(组内单选、组间叠加),页签条件直写 SQL, + // 更多筛选由 queryFields 自动铺开(不再手工分组); + // 关键词只配可搜索字段,占位文案与触发方式运行时定 + return { + schemaVersion: 3, + views: [], + keywordSearch: { fields: [] }, + quickFilters: [], + queryFields: {}, + } + } + return { schemaVersion: 1 } +} + +/** 解析库中 b_schema_json 文本/对象为配置对象;为空或非法时回退默认结构 */ +export function parseSchema(schemaType: string, raw: unknown): SchemaJson { + let value: unknown = raw + if (typeof raw === 'string') { + const text = raw.trim() + if (!text) return createDefaultSchema(schemaType) + try { + value = JSON.parse(text) + } catch { + return createDefaultSchema(schemaType) + } + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return createDefaultSchema(schemaType) + } + const schema = value as SchemaJson + if (schema.schemaVersion == null) schema.schemaVersion = 1 + return schema +} + +/** 递归按键排序,得到稳定序列化结果(数组保序),用于基线与脏判断比较 */ +function sortForSerialize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortForSerialize) + if (value && typeof value === 'object') { + const sorted: Record = {} + for (const key of Object.keys(value).toSorted()) { + sorted[key] = sortForSerialize((value as Record)[key]) + } + return sorted + } + return value +} + +export function stableStringify(value: unknown): string { + return JSON.stringify(sortForSerialize(value)) +} + +/** 配置对象 → 稳定 JSON 文本(缺省时用默认结构) */ +export function schemaToJsonText( + schemaType: string, + schema: SchemaJson | null | undefined, +): string { + return stableStringify(schema ?? createDefaultSchema(schemaType)) +} + +/* ================= 分组节点 id ================= + * view / edit 的分组节点在 JSON 中带稳定 id(形如 group1),用于跨会话身份与多语言键派生: + * - 按 schema 类型独立编号:view 与 edit 各自从 group1 开始,互不影响(分组不跨配置引用); + * - 分配后不再重排,允许删除产生的空洞(重排会改身份,进而改多语言键); + * - 归一化必须是确定性纯函数(同一份 JSON 每次结果一致、可重复执行):加载后要与基线比较, + * 若补号依赖外部上下文(运行时会话、其它配置),同一份数据两次归一化结果就会不同而误判脏。 + */ +const GROUP_ID_RE = /^group(\d+)$/ + +function textOf(raw: unknown): string { + return typeof raw === 'string' ? raw.trim() : '' +} + +/** 先序遍历分组节点 */ +function walkGroupNodes(nodes: SchemaNode[] | undefined, visit: (node: SchemaNode) => void): void { + for (const node of nodes || []) { + if (!node || typeof node !== 'object') continue + if (node.type === 'group') visit(node) + if (Array.isArray(node.children)) walkGroupNodes(node.children, visit) + } +} + +function groupRootsOf( + schemaType: string, + schema: SchemaJson | null | undefined, +): SchemaNode[] | null { + if (schemaType === 'view') return schema?.columns ?? null + if (schemaType === 'edit') return schema?.children ?? null + return null +} + +/** 收集配置中已使用的分组 id(去重,含自定义 id) */ +export function collectGroupIds( + schemaType: string, + schema: SchemaJson | null | undefined, +): Set { + const ids = new Set() + walkGroupNodes(groupRootsOf(schemaType, schema) || [], (node) => { + const id = textOf(node.id) + if (id) ids.add(id) + }) + return ids +} + +/** 取下一个可用分组 id:已用编号里最小的未用正整数(如 group1 / group2) */ +export function nextGroupId(usedIds: Iterable): string { + const used = new Set() + for (const value of usedIds || []) { + const matched = GROUP_ID_RE.exec(textOf(value)) + if (matched) used.add(Number(matched[1])) + } + let number = 1 + while (used.has(number)) number += 1 + return `group${number}` +} + +/** + * 补齐 / 去重分组 id,返回新的配置对象(无需改动时返回原对象)。 + * 缺 id 或 id 重复的节点按先序拿最小可用编号;非 group{n} 的自定义 id 视为已占用并原样保留。 + */ +export function normalizeGroupIds(schemaType: string, schema: SchemaJson): SchemaJson { + const roots = groupRootsOf(schemaType, schema) + if (!Array.isArray(roots)) return schema + const claimed = new Set() + const used = new Set() + for (const id of collectGroupIds(schemaType, schema)) { + const matched = GROUP_ID_RE.exec(id) + if (matched) used.add(Number(matched[1])) + } + const takeNumber = (): string => { + let number = 1 + while (used.has(number)) number += 1 + used.add(number) + return `group${number}` + } + const patchNode = (node: SchemaNode): SchemaNode => { + if (!node || typeof node !== 'object') return node + if (node.type !== 'group') return node + // 先给本节点定号再递归子节点(先序):首次出现的 id 保留(视为该分组的身份), + // 重复出现与缺失的一律重新发号 + const id = textOf(node.id) + const kept = Boolean(id) && !claimed.has(id) + if (kept) claimed.add(id) + const nextId = kept ? id : takeNumber() + const nextChildren = Array.isArray(node.children) ? patchList(node.children) : node.children + if (nextId === node.id && nextChildren === node.children) return node + return { ...node, id: nextId, children: nextChildren } + } + const patchList = (list: SchemaNode[]): SchemaNode[] => { + let changed = false + const mapped = list.map((node) => { + const nextNode = patchNode(node) + if (nextNode !== node) changed = true + return nextNode + }) + return changed ? mapped : list + } + const nextRoots = patchList(roots) + if (nextRoots === roots) return schema + const rootKey = schemaType === 'view' ? 'columns' : 'children' + return { ...schema, [rootKey]: nextRoots } +} + +/* ================= 分组资源键 ================= */ + +/** + * 分组资源键前缀 group.{模块编码}.{schema 类型}.(编号段之前)。 + * 用于从 s_i18n 反解该模块该 schema 下已登记过的分组编号。模块编码或 schema 类型缺失时返回空串。 + */ +export function buildGroupI18nKeyPrefix(moduleCode: string, schemaType: string): string { + const moduleCodeText = textOf(moduleCode) + const schemaTypeText = textOf(schemaType) + return moduleCodeText && schemaTypeText ? `group.${moduleCodeText}.${schemaTypeText}.` : '' +} + +/** + * 分组标题资源键:前缀 + 分组编号,如 group.cw_fee.view.group1。 + * 分组编号即节点 id,因此键随 id 稳定;schema 类型必须入键——分组编号只在单个 schema 内唯一, + * 列表与表单的同号分组是两处不同文案。节点上手填的 i18n 优先于派生键。 + */ +export function buildGroupI18nKey( + moduleCode: string, + schemaType: string, + groupId: unknown, +): string | null { + const groupIdValue = textOf(groupId) + const prefix = buildGroupI18nKeyPrefix(moduleCode, schemaType) + return prefix && groupIdValue ? `${prefix}${groupIdValue}` : null +} + +/* ================= 分组编码校验 ================= */ + +/** + * 分组编码长度上限:键为 group.{模块编码}.{schema 类型}.{分组编码}, + * 即 6 + 1 + 50 + 1 + 5 + 1 + 30 = 94,远小于 s_i18n.b_key varchar(150)。 + */ +export const GROUP_CODE_MAX_LENGTH = 30 + +/** 分组编码格式与模块编码(s_module.b_id)同构,另见 ModuleBasicPanel 的同款校验 */ +const GROUP_CODE_RE = /^[a-z][a-z0-9_]*$/ + +/** 保留字:结构树用 'g:ungrouped' 作虚拟「未分组」节点 key,真实分组不能占用该名字 */ +const RESERVED_GROUP_CODES = new Set(['ungrouped']) + +/** 编码集合归一:兼容数组与 Set,去空白后丢弃空值 */ +function normalizeCodes(codes: Iterable): Set { + const set = new Set() + for (const value of codes || []) { + const code = textOf(value) + if (code) set.add(code) + } + return set +} + +/** validateGroupCode 的可选项 */ +export interface ValidateGroupCodeOptions { + /** 本配置内已占用的编码 */ + existingIds?: Iterable + /** 多语言资源里仍留有记录的编码(已删除分组的编号,复用会连带继承其残留译文) */ + reservedIds?: Iterable + /** 编码长度上限 */ + maxLength?: number +} + +/** + * 校验分组编码(新建分组时手填)。契约与 ui Form 的 validator 一致: + * 返回 true 表示通过,返回非空字符串即错误文案。 + * + * 唯一性只在单个配置(单份 b_schema_json)内判定:view 与 edit 各自独立编号, + * 同一编码可以同时存在于列表与表单,故不需要 schema 类型入参。 + */ +export function validateGroupCode( + rawCode: unknown, + { + existingIds = [], + reservedIds = [], + maxLength = GROUP_CODE_MAX_LENGTH, + }: ValidateGroupCodeOptions = {}, +): true | string { + const code = textOf(rawCode) + if (!code) return '分组编码不能为空' + if (code.length < 2 || code.length > maxLength) { + return `分组编码长度需为 2-${maxLength} 个字符` + } + if (!GROUP_CODE_RE.test(code)) { + return '分组编码须以小写字母开头,且只能包含小写字母、数字和下划线' + } + if (RESERVED_GROUP_CODES.has(code)) return `分组编码 ${code} 为系统保留字,请换一个` + if (normalizeCodes(existingIds).has(code)) { + return `分组编码 ${code} 已被本配置的其他分组使用,请换一个` + } + if (normalizeCodes(reservedIds).has(code)) { + return `分组编码 ${code} 曾被使用(多语言资源中仍留有记录),请换一个` + } + return true +} diff --git a/code/g3soft-erp/web/src/utils/tree.ts b/code/g3soft-erp/web/src/utils/tree.ts index 06feb305..b9e20559 100644 --- a/code/g3soft-erp/web/src/utils/tree.ts +++ b/code/g3soft-erp/web/src/utils/tree.ts @@ -1,15 +1,32 @@ /** - * 按 b_parent_id 把扁平行构建为树,并按 b_xh / b_id 排序。 + * 树工具:建树 / 排序 / 查询 / 过滤 / 就地变更 / 可编辑树位置派生。 * - * 对照 fms-vue 的 src/utils/tree.js,只保留菜单渲染所需的两个纯函数: - * 该文件还有一整套可编辑树的位置派生(applyTreePosition / computeTreeMove …), - * 属于「菜单管理 / 模块管理」编辑器的能力,本项目暂无对应页面,不预先搬运。 + * 移植自 fms-vue 的 src/utils/tree.js(去掉与主题无关的部分),字段约定沿用其树结构规范: + * b_id / b_parent_id / b_xh / b_depth / b_path —— b_parent_id 是权威父子关系, + * b_depth 与 b_path 是由业务层维护的派生字段,b_path 形如 /base/base_otherdata/ + * (节点编码前后均带 /)。 */ -/** 树节点:s_menu 行 + 运行时挂上的 children。 */ +import type { Component, VNode } from 'vue' + +/** 树节点:一般形态为「数据库行 + 运行时挂上的 children」。 */ export interface TreeRow { [key: string]: unknown children?: TreeRow[] + /** 节点 ID(业务树默认主键字段) */ + b_id?: string | number + /** 父节点 ID(权威父子关系) */ + b_parent_id?: string | number | null + /** 同级排序权重 */ + b_xh?: number + /** 层级深度(派生字段,根为 0) */ + b_depth?: number + /** 路径(派生字段,形如 /a/b/) */ + b_path?: string + /** 显式叶子判定(false 使暂无子节点的节点也显示展开箭头) */ + isLeaf?: boolean + /** 预载节点图标(G3Tree 支持 Component / VNode) */ + icon?: Component | VNode } /** @@ -21,8 +38,14 @@ export interface TreeRow { * @param list 原始列表 * @param idKey 节点 ID 字段名 * @param parentKey 父节点关联字段名 + * @param childrenKey 子节点字段名 */ -export function buildTree(list: TreeRow[], idKey = 'b_id', parentKey = 'b_parent_id'): TreeRow[] { +export function buildTree( + list: TreeRow[], + idKey = 'b_id', + parentKey = 'b_parent_id', + childrenKey = 'children', +): TreeRow[] { const map = new Map() const roots: TreeRow[] = [] @@ -35,8 +58,9 @@ export function buildTree(list: TreeRow[], idKey = 'b_id', parentKey = 'b_parent if (pid != null && String(pid) !== '') { const parent = map.get(String(pid)) if (parent) { - if (!parent.children) parent.children = [] - parent.children.push(node) + const children = parent[childrenKey] + if (Array.isArray(children)) children.push(node) + else parent[childrenKey] = [node] continue } } @@ -74,3 +98,473 @@ export function buildSortedTree( const copied = (list || []).map((row) => ({ ...row })) return sortTree(buildTree(copied, idKey, parentKey)) } + +/** + * 在树中递归查找指定 id 的节点。 + * @param nodes 树形数组 + * @param id 目标节点 ID + * @param idKey 节点 ID 字段名 + * @param childrenKey 子节点字段名 + */ +export function findTreeNode( + nodes: TreeRow[], + id: string | number, + idKey = 'b_id', + childrenKey = 'children', +): TreeRow | null { + for (const node of nodes) { + if (String(node[idKey]) === String(id)) return node + const children = node[childrenKey] + if (children) { + const found = findTreeNode(children as TreeRow[], id, idKey, childrenKey) + if (found) return found + } + } + return null +} + +/** + * 递归收集所有节点 key(统一转成字符串)。 + * @param nodes 树形数组 + * @param idKey 节点 ID 字段名 + * @param childrenKey 子节点字段名 + */ +export function collectAllKeys( + nodes: TreeRow[], + idKey = 'b_id', + childrenKey = 'children', +): string[] { + const keys: string[] = [] + const walk = (list: TreeRow[]) => { + for (const node of list) { + keys.push(String(node[idKey])) + const children = node[childrenKey] + if (children) walk(children as TreeRow[]) + } + } + walk(nodes) + return keys +} + +/** + * 从根到目标节点的祖先路径(不包含目标节点自身)。 + * @param nodes 树形数组 + * @param targetId 目标节点 ID + * @param idKey 节点 ID 字段名 + * @param childrenKey 子节点字段名 + */ +export function expandAncestors( + nodes: TreeRow[], + targetId: string | number, + idKey = 'b_id', + childrenKey = 'children', +): string[] { + const keys: string[] = [] + const walk = (list: TreeRow[], ancestors: string[]): boolean => { + for (const node of list) { + const key = String(node[idKey]) + if (key === String(targetId)) { + keys.push(...ancestors) + return true + } + const children = node[childrenKey] + if (children && walk(children as TreeRow[], [...ancestors, key])) return true + } + return false + } + walk(nodes, []) + return keys +} + +/** + * 按关键字匹配树节点名称/编码,返回过滤后的浅拷贝视图。 + * + * 保留「自身命中」与「后代命中的祖先」两类节点,祖先链完整;未命中的兄弟分支被剪掉, + * 命中节点的未命中后代同样会被剪掉(命中不豁免后代过滤),命中节点的 children 已是过滤结果。 + * 过滤结果是浅拷贝视图,不可直接用于拖拽落点(索引与原树不一致)。 + * @param nodes 树形数组 + * @param keyword 搜索关键字 + * @param textKeys 搜索匹配的字段名列表 + * @param childrenKey 子节点字段名 + */ +export function filterTreeByText( + nodes: TreeRow[], + keyword: string, + textKeys: string[] = ['b_name', 'b_code'], + childrenKey = 'children', +): TreeRow[] { + const kw = String(keyword || '') + .trim() + .toLowerCase() + if (!kw) return nodes + const result: TreeRow[] = [] + for (const node of nodes) { + const hit = textKeys.some((k) => + String(node[k] || '') + .toLowerCase() + .includes(kw), + ) + const children = node[childrenKey] + const filtered = children + ? filterTreeByText(children as TreeRow[], keyword, textKeys, childrenKey) + : undefined + if (hit || filtered?.length) { + result.push({ ...node, [childrenKey]: filtered }) + } + } + return result +} + +/** insertTreeNode 的可选项。 */ +export interface InsertTreeNodeOptions { + /** 父节点 ID 字段名 */ + parentIdKey?: string + /** 节点 ID 字段名 */ + idKey?: string + /** 子节点字段名 */ + childrenKey?: string + /** 可选排序函数,插入所在层后调用(如 sortTree) */ + sort?: (list: TreeRow[]) => void +} + +/** + * 将节点插入到树中(按 parentIdKey 匹配父节点,顶级节点 parentIdKey 为 null/undefined 时插入根层)。 + * 就地修改树,不返回新数组。 + */ +export function insertTreeNode( + nodes: TreeRow[], + node: TreeRow, + { + parentIdKey = 'b_parent_id', + idKey = 'b_id', + childrenKey = 'children', + sort, + }: InsertTreeNodeOptions = {}, +): void { + if (node[parentIdKey] == null) { + nodes.push(node) + if (sort) sort(nodes) + return + } + for (const n of nodes) { + if (String(n[idKey]) === String(node[parentIdKey])) { + const children = n[childrenKey] + if (Array.isArray(children)) children.push(node) + else n[childrenKey] = [node] + if (sort) sort(n[childrenKey] as TreeRow[]) + return + } + const children = n[childrenKey] + if (children) { + insertTreeNode(children as TreeRow[], node, { parentIdKey, idKey, childrenKey, sort }) + } + } +} + +/** + * 从树中移除指定 id 的节点(就地修改)。 + * @param nodes 树形数组 + * @param id 目标节点 ID + * @param idKey 节点 ID 字段名 + * @param childrenKey 子节点字段名 + */ +export function removeTreeNode( + nodes: TreeRow[], + id: string | number, + idKey = 'b_id', + childrenKey = 'children', +): void { + for (let i = 0; i < nodes.length; i++) { + if (String(nodes[i][idKey]) === String(id)) { + nodes.splice(i, 1) + return + } + const children = nodes[i][childrenKey] + if (children) removeTreeNode(children as TreeRow[], id, idKey, childrenKey) + } +} + +/* ============================================================================ + * 可编辑树的位置派生与就地变更 + * + * 以下函数面向固定字段(b_id / b_parent_id / b_xh / b_depth / b_path), + * 模块树、菜单树、分类树共用同一套约定。 + * ========================================================================== */ + +/** + * 收集某个节点及其全部下级的 ID(按父级关联字段展开整棵子树,含节点自身)。 + * + * 「左树选分类 → 右表按整棵子树过滤」这类需求通用:选中一个节点在业务上等于选中它的整棵子树, + * 因此过滤条件是 IN (自身 + 全部子孙),而不是只匹配直接子节点。 + * + * 输入是数据库里的**扁平行**(邻接表),不需要先 buildTree;与 buildSortedTree / + * applyTreePosition 同族。若手上是已构建好的嵌套树 + childrenMap, + * 用 @g3soft/ui 的 collectDescendants,别混用。 + * + * 两点实现取舍: + * - visited 兜环:b_parent_id 是业务列、无外键约束,脏数据成环时不会死循环; + * - 用显式栈而非递归:子树可能很深,避免爆栈。 + * + * 注意:**即使 rootId 不在 rows 里也会返回 [rootId]**(一个只含自身的集合)。 + * 这样拼出的 IN ('不存在的id') 自然查不到数据,语义安全;调用方不必额外判空。 + */ +export function collectDescendantIds( + rows: TreeRow[] | null | undefined, + rootId: string | number, + { idKey = 'b_id', parentKey = 'b_parent_id' }: { idKey?: string; parentKey?: string } = {}, +): string[] { + const childrenByParent = new Map() + for (const row of rows || []) { + const parentId = row[parentKey] == null ? '' : String(row[parentKey]) + if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, []) + childrenByParent.get(parentId)?.push(String(row[idKey])) + } + + const result: string[] = [] + const visited = new Set() + const pending = [String(rootId)] + while (pending.length) { + const id = pending.pop() as string + if (visited.has(id)) continue + visited.add(id) + result.push(id) + for (const childId of childrenByParent.get(id) || []) pending.push(childId) + } + return result +} + +/** decorateTreeNodes 的可选项。 */ +export interface DecorateTreeNodesOptions { + /** 类型字段名,如 b_module_type */ + typeField: string + /** 取这些类型值时强制非叶子(即使暂无子节点也显示展开箭头) */ + nonLeafValues?: unknown[] + /** 类型值 → 图标组件 */ + iconMap?: Record +} + +/** + * 预载节点图标并指定非叶子类型。 + * 就地修改并返回原数组。 + */ +export function decorateTreeNodes( + nodes: TreeRow[], + { typeField, nonLeafValues = [], iconMap = {} }: DecorateTreeNodesOptions, +): TreeRow[] { + for (const node of nodes) { + if (nonLeafValues.includes(node[typeField])) node.isLeaf = false + const icon = iconMap[String(node[typeField])] + if (icon) node.icon = icon + if (node.children) decorateTreeNodes(node.children, { typeField, nonLeafValues, iconMap }) + } + return nodes +} + +/** + * 在树中查找节点所在的父数组及索引(就地移动节点时配合 splice 使用)。 + * @param nodes 树节点数组 + * @param id 目标节点 ID + */ +export function findNodePosition( + nodes: TreeRow[], + id: string | number, +): { list: TreeRow[]; index: number } | null { + for (let i = 0; i < nodes.length; i++) { + if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i } + if (nodes[i].children) { + const found = findNodePosition(nodes[i].children as TreeRow[], id) + if (found) return found + } + } + return null +} + +/** + * 向 updatedRows 合并一行变更(同一 b_id 累加字段,避免同节点产生多条 update 请求)。 + * @param updatedRows 变更行收集器(就地修改) + * @param row 含 b_id 的行 + */ +export function addRowUpdate(updatedRows: TreeRow[], row: TreeRow): void { + const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id)) + if (existing) Object.assign(existing, row) + else updatedRows.push({ ...row }) +} + +/** + * 按父节点派生行的 b_depth / b_path(b_parent_id 是权威关系,二者为派生字段)。 + * b_path 非空且无默认值,新增时必须随行写入。 + * @param row 目标行(就地修改,b_id 必须是最终编码) + * @param parentNode 树中的父节点;null 表示根节点 + */ +export function applyTreePosition(row: TreeRow, parentNode: TreeRow | null): void { + row.b_depth = parentNode ? Number(parentNode.b_depth ?? 0) + 1 : 0 + row.b_path = parentNode ? `${String(parentNode.b_path || '')}${row.b_id}/` : `/${row.b_id}/` +} + +/** + * 重排某组兄弟的 b_xh(10 起步、步长 10),把变化的行收集到 updatedRows。 + * @param list 兄弟节点数组(就地修改 b_xh) + * @param updatedRows 变化行收集器 + */ +export function renumberSiblings(list: TreeRow[], updatedRows: TreeRow[]): void { + list.forEach((sibling, index) => { + const nextXh = (index + 1) * 10 + if (Number(sibling.b_xh) !== nextXh) { + sibling.b_xh = nextXh + addRowUpdate(updatedRows, { b_id: sibling.b_id, b_xh: nextXh }) + } + }) +} + +/** + * 递归重建整树的 b_depth / b_path(b_parent_id 为权威关系,二者为派生字段)。 + * 仅在值实际变化时收集,避免打开即脏;父子关系变化后调用即可整树收敛。 + * @param nodes 树节点数组(就地修改) + * @param updatedRows 变化行收集器 + * @param parentPath 父节点路径,根层传空串 + * @param depth 当前层级 + */ +export function rebuildTreePositions( + nodes: TreeRow[], + updatedRows: TreeRow[], + parentPath = '', + depth = 0, +): void { + for (const node of nodes) { + // 根层没有 parentPath,必须显式补前导 /:b_path 的约定是节点编码前后均带 / + const path = parentPath ? `${parentPath}${node.b_id}/` : `/${node.b_id}/` + if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) { + node.b_depth = depth + node.b_path = path + addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path }) + } + if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1) + } +} + +/** 拖拽落点位置。 */ +export type TreeMoveDropPosition = 'before' | 'inside' | 'after' + +/** computeTreeMove 的拦截原因。 */ +export type TreeMoveFailureReason = 'self' | 'notFound' | 'cycle' | 'notContainer' | 'unsavedTarget' + +/** computeTreeMove 的拦截提示文案(由调用方提供,组件层不内置文案)。 */ +export interface TreeMoveMessages { + cycle?: string + notContainer?: string + unsavedTarget?: string +} + +/** computeTreeMove 的入参。 */ +export interface TreeMoveOptions { + /** 被拖节点 ID */ + dragKey: string | number + /** 落点节点 ID */ + dropKey: string | number + /** 落点位置 */ + dropPosition: TreeMoveDropPosition + /** 节点类型字段名,配合 nonLeafValues 做「能否承载子节点」守卫 */ + typeField?: string + /** 可作为 inside 落点的类型取值;不传则不校验 */ + nonLeafValues?: unknown[] + /** 判断节点是否尚未落库(未落库的临时 id 不能当父级) */ + isUnsaved?: (id: string) => boolean + /** 拦截提示文案 */ + messages?: TreeMoveMessages +} + +/** computeTreeMove 的结果:成功回带落库行,失败回带原因与文案。 */ +export type TreeMoveResult = + | { ok: true; updatedRows: TreeRow[]; moved: TreeRow; parentChanged: boolean } + | { ok: false; reason: TreeMoveFailureReason; message: string } + +/** + * 计算一次「树节点拖拽移动」要落库的行变更(纯函数:只改传入的树,不请求、不提示)。 + * + * 覆盖宿主树拖拽的全部公共步骤:成环拦截 → 摘除 → 挂载 → 兄弟重排 → 整树重建 depth/path + * → 补写 b_parent_id。差异(表名、文案、类型守卫、未保存草稿)交给调用方处理。 + * + * 新增一棵可拖拽的树时,页面侧只需要: + * 1. loadDataApi(表) + buildSortedTree(行) 得到 nodes; + * 2. drop 事件里调本函数,失败按 message 提示,成功把 updatedRows 提交: + * saveObjectApi(updatedRows.map((row) => ({ table, key_field: 'b_id', updates: [row] }))); + * 3. 提交后重载树。 + */ +export function computeTreeMove(nodes: TreeRow[], options: TreeMoveOptions): TreeMoveResult { + const { + dragKey, + dropKey, + dropPosition, + typeField, + nonLeafValues, + isUnsaved, + messages = {}, + } = options + const dragId = String(dragKey) + const dropId = String(dropKey) + + // 拖到自己身上:不是错误,静默忽略 + if (dragId === dropId) return { ok: false, reason: 'self', message: '' } + + const dragPos = findNodePosition(nodes, dragId) + const dropPos = findNodePosition(nodes, dropId) + // 树数据已变(并发重载等):找不到落点就放弃,不改动任何节点 + if (!dragPos || !dropPos) return { ok: false, reason: 'notFound', message: '' } + + const dragNode = dragPos.list[dragPos.index] + const dropNode = dropPos.list[dropPos.index] + + // 落点不能是被拖节点自身或它的后代:父子关系成环后树与 b_path 都会失真 + if (findTreeNode([dragNode], dropId)) { + return { ok: false, reason: 'cycle', message: messages.cycle || '' } + } + // 只有非叶子类型能承载子节点。必须在摘除被拖节点之前拦截,否则中途 return 会让节点凭空消失 + if (dropPosition === 'inside' && nonLeafValues && typeField) { + if (!nonLeafValues.includes(dropNode[typeField])) { + return { ok: false, reason: 'notContainer', message: messages.notContainer || '' } + } + } + // 未落库的节点其临时 id 不能作为持久化的 b_parent_id + if (dropPosition === 'inside' && isUnsaved?.(dropId)) { + return { ok: false, reason: 'unsavedTarget', message: messages.unsavedTarget || '' } + } + + const dropParentId = dropNode.b_parent_id ?? null + const [moved] = dragPos.list.splice(dragPos.index, 1) + const movedParentId = moved.b_parent_id ?? null + + const updatedRows: TreeRow[] = [] + const renumberSets = new Set() + + if (dropPosition === 'inside') { + moved.b_parent_id = dropNode.b_id + if (!dropNode.children) { + dropNode.children = [] + dropNode.isLeaf = false + } + dropNode.children.push(moved) + renumberSets.add(dropNode.children) + renumberSets.add(dragPos.list) + } else { + moved.b_parent_id = dropParentId + const targetList = dropPos.list + let targetIndex = dropPos.index + (dropPosition === 'after' ? 1 : 0) + // 同一组内拖拽且被拖节点在落点之前:移除后索引需前移一位 + if (dragPos.list === dropPos.list && dragPos.index < dropPos.index) targetIndex -= 1 + targetList.splice(targetIndex, 0, moved) + renumberSets.add(targetList) + } + + renumberSets.forEach((list) => renumberSiblings(list, updatedRows)) + // 父子关系变化后整树重建 b_depth / b_path,仅收集实际变化的行 + rebuildTreePositions(nodes, updatedRows) + // b_parent_id 是权威关系,必须与派生字段同批落库, + // 否则会留下「路径按新父级、父级列还是旧值」的不一致。 + // 单独补一条:renumberSiblings / rebuildTreePositions 只收集变化的派生字段,都不负责父级列本身。 + const parentChanged = String(moved.b_parent_id ?? '') !== String(movedParentId ?? '') + if (parentChanged) { + addRowUpdate(updatedRows, { b_id: moved.b_id, b_parent_id: moved.b_parent_id ?? null }) + } + + return { ok: true, updatedRows, moved, parentChanged } +} diff --git a/code/g3soft-erp/web/src/views/module/I18nType.vue b/code/g3soft-erp/web/src/views/module/I18nType.vue new file mode 100644 index 00000000..64862aaf --- /dev/null +++ b/code/g3soft-erp/web/src/views/module/I18nType.vue @@ -0,0 +1,19 @@ + + + diff --git a/code/g3soft-erp/web/src/views/module/MenuManagement.vue b/code/g3soft-erp/web/src/views/module/MenuManagement.vue new file mode 100644 index 00000000..b9e115c5 --- /dev/null +++ b/code/g3soft-erp/web/src/views/module/MenuManagement.vue @@ -0,0 +1,19 @@ + + + diff --git a/code/g3soft-erp/web/src/views/module/module-management/ModuleBasicPanel.vue b/code/g3soft-erp/web/src/views/module/module-management/ModuleBasicPanel.vue new file mode 100644 index 00000000..af98b404 --- /dev/null +++ b/code/g3soft-erp/web/src/views/module/module-management/ModuleBasicPanel.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/code/g3soft-erp/web/src/views/module/module-management/ModuleFieldsPanel.vue b/code/g3soft-erp/web/src/views/module/module-management/ModuleFieldsPanel.vue new file mode 100644 index 00000000..df0df0fc --- /dev/null +++ b/code/g3soft-erp/web/src/views/module/module-management/ModuleFieldsPanel.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/code/g3soft-erp/web/src/views/module/module-management/ModuleTreePanel.vue b/code/g3soft-erp/web/src/views/module/module-management/ModuleTreePanel.vue new file mode 100644 index 00000000..b521bc38 --- /dev/null +++ b/code/g3soft-erp/web/src/views/module/module-management/ModuleTreePanel.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/code/g3soft-erp/web/src/views/module/module-management/index.vue b/code/g3soft-erp/web/src/views/module/module-management/index.vue new file mode 100644 index 00000000..c378abb6 --- /dev/null +++ b/code/g3soft-erp/web/src/views/module/module-management/index.vue @@ -0,0 +1,746 @@ + + + + + diff --git a/code/g3soft-erp/web/src/views/module/module-management/utils.ts b/code/g3soft-erp/web/src/views/module/module-management/utils.ts new file mode 100644 index 00000000..3d041fab --- /dev/null +++ b/code/g3soft-erp/web/src/views/module/module-management/utils.ts @@ -0,0 +1,139 @@ +/** + * 模块管理公共工具(移植自 fms-vue 的 module-management/utils.js,按需逐步补齐)。 + * + * 当前提供:模块树所需的类型 / 图标 / 新增菜单项,以及删除模块时的多语言键收集 + * (模块名键、字段键、界面配置分组键)——后续面板(字段 / 基础 / 多语言等)用到 + * 的其它工具随对应步骤补充。 + */ +import { Database, Folder, FolderPlus, Search } from '@lucide/vue' +import type { Component } from 'vue' +import { buildGroupI18nKey, type SchemaJson, type SchemaNode } from '@/utils/schemaJson' +import type { TreeRow } from '@/utils/tree' + +/** 模块类型(s_module.b_module_type):module 模块 / data 数据模块 / virtual 虚拟模块 */ +export const MODULE_TYPE_ICON_MAP: Record = { + module: Folder, + data: Database, + virtual: Search, +} + +/** 模块树里能承载子模块的类型:data / virtual 是叶子节点 */ +export const MODULE_NON_LEAF_TYPES = ['module'] + +/** 工具栏 / 右键「新增」菜单项(key 即 b_module_type) */ +export const MODULE_ADD_OPTIONS: Array<{ key: string; label: string; icon: Component }> = [ + { key: 'module', label: '新增模块', icon: FolderPlus }, + { key: 'data', label: '新增数据模块', icon: Database }, + { key: 'virtual', label: '新增虚拟模块', icon: Search }, +] + +/** + * 模块树节点:s_module 行业务列 + 树运行时挂上的 children / icon 等。 + * 与 @/utils/tree 的 TreeRow 兼容(可直接交给建树 / 拖拽等纯函数)。 + */ +export type ModuleNode = TreeRow & { + b_id: string + b_parent_id: string | null + b_name: string + b_i18n: string | null + b_module_type: string + b_view_table: string | null + b_save_table: string | null + b_key_field: string | null + b_scope_field: string | null + b_order_sql: string | null + b_query_sql: string | null + b_config_json: string | null + b_canuse: number + b_xh: number + b_bz: string | null +} + +/** s_field 行的最小投影:基础信息面板「数据范围字段」候选只需要这三个字段 */ +export interface ModuleFieldRow { + b_field?: string | null + b_name?: string | null + b_i18n?: string | null + [key: string]: unknown +} + +/* ================= 多语言资源键(s_i18n,主键 b_key + b_locale) ================= */ + +export function buildModuleItemI18nKey( + prefix: string, + moduleCode: string, + itemCode: string, +): string | null { + const normalizedModuleCode = String(moduleCode || '').trim() + const normalizedItemCode = String(itemCode || '').trim() + return normalizedModuleCode && normalizedItemCode + ? `${prefix}.${normalizedModuleCode}.${normalizedItemCode}` + : null +} + +/** 模块名称资源键:module.{模块编码} */ +export function buildModuleI18nKey(moduleCode: string): string | null { + const normalizedModuleCode = String(moduleCode || '').trim() + return normalizedModuleCode ? `module.${normalizedModuleCode}` : null +} + +/** SQL 字符串字面量(转义单引号);用于拼接只读查询条件 */ +export function sqlStringLiteral(value: unknown): string { + return `N'${String(value ?? '').replace(/'/g, "''")}'` +} + +/** + * 模块多语言资源键匹配条件(s_i18n 无 b_module_id,按已知资源键精确匹配): + * 模块名键 + 字段资源键 + 界面配置分组键。使用精确 IN 而非 LIKE, + * 避免模块编码中 `_` 被当作通配符导致跨模块误匹配。 + */ +export function buildModuleI18nCondition(moduleId: string, keys: string[] = []): string { + const conditions = [`b_key = ${sqlStringLiteral(buildModuleI18nKey(moduleId))}`] + const list = [...new Set((keys || []).map((key) => String(key || '').trim()).filter(Boolean))] + if (list.length) conditions.push(`b_key IN (${list.map(sqlStringLiteral).join(', ')})`) + return conditions.join(' OR ') +} + +/* ================= 界面配置分组键(删除模块时清理 s_i18n 用) ================= */ + +/** 遍历界面配置节点树,访问所有分组节点 */ +function walkGroupNodes(node: SchemaNode, visit: (node: SchemaNode) => void): void { + if (!node || typeof node !== 'object') return + if (node.type === 'group') visit(node) + const children = node.children || (node.columns as SchemaNode[] | undefined) + if (Array.isArray(children)) for (const child of children) walkGroupNodes(child, visit) +} + +/** 界面配置行(s_module_schema 行 + 解析后的配置对象) */ +export interface SchemaEntry { + schemaType: string + schema: SchemaJson | null +} + +/** + * 收集界面配置中的分组标题多语言键。 + * 分组节点手填 i18n 优先;未填时按 group.{模块编码}.{schema}.{分组编号} 派生(分组编号即节点 id), + * 这样分组标题无需人工填键即可进入受管清单、在各语言下维护译文。 + */ +function collectSchemaGroupItems(moduleCode: string, schemas: SchemaEntry[]): string[] { + const keys: string[] = [] + const seen = new Set() + for (const { schemaType, schema } of schemas || []) { + if (!schema) continue + const visit = (node: SchemaNode): void => { + const key = + String(node.i18n || '').trim() || buildGroupI18nKey(moduleCode, schemaType, node.id) + if (!key || seen.has(key)) return + seen.add(key) + keys.push(key) + } + const roots = schema.columns || schema.children + if (Array.isArray(roots)) for (const node of roots) walkGroupNodes(node, visit) + } + return keys +} + +/** 从界面配置中收集全部分组资源键(删除模块时按这些键清理 s_i18n) */ +export function collectSchemaGroupKeys(moduleCode: string, schemas: SchemaEntry[]): string[] { + return collectSchemaGroupItems(moduleCode, schemas) +} diff --git a/code/g3soft-erp/web/src/views/table-test/index.vue b/code/g3soft-erp/web/src/views/table-test/index.vue index b8f3239f..fe563f7c 100644 --- a/code/g3soft-erp/web/src/views/table-test/index.vue +++ b/code/g3soft-erp/web/src/views/table-test/index.vue @@ -1,5 +1,6 @@
{{ locale.empty.description }} diff --git a/code/g3soft-libs/packages/ui/src/tree/TreeNode.vue b/code/g3soft-libs/packages/ui/src/tree/TreeNode.vue index 81b8c74a..684e0c2f 100644 --- a/code/g3soft-libs/packages/ui/src/tree/TreeNode.vue +++ b/code/g3soft-libs/packages/ui/src/tree/TreeNode.vue @@ -38,6 +38,15 @@ const checked = computed(() => tree!.checkedSet.has(nodeKey.value)) const halfChecked = computed(() => tree!.halfSet.has(nodeKey.value)) const loading = computed(() => tree!.loadingSet.has(nodeKey.value)) +/** + * title 插槽渲染器:插槽来自 Tree 的上下文(TreeContext.titleSlot)。 + * 渲染函数里读 node / expanded / selected,保证这些值变化时插槽内容跟着重渲染。 + */ +const titleSlotComp = { + render: () => + tree?.titleSlot?.({ node: props.node, expanded: expanded.value, selected: selected.value }), +} + /** 过滤未命中的节点整体不渲染(不影响数据,只影响可见性) */ const visible = computed( () => !tree!.filterActive || !tree!.filterVisible || tree!.filterVisible.has(nodeKey.value), @@ -219,15 +228,16 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node - - - {{ part.text }} - - + + + + {{ part.text }} +
@@ -262,7 +272,7 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node position: relative; display: flex; align-items: center; - gap: v('space-1', 4px); + gap: v('tree-node-gap-x', 2px); box-sizing: border-box; min-height: v('tree-node-height', 28px); /* 缩进 = 基础留白 + 每层缩进量 × 层级(层级由行内 CSS 变量下发)。 @@ -338,8 +348,10 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node align-items: center; justify-content: center; flex-shrink: 0; - width: v('tree-switcher-size', 22px); - height: v('tree-switcher-size', 22px); + /* 盒子只比图标大一圈:原先 22px 的盒子套 16px 图标,左右各留 3px 死区, + 加上行 gap 与内容 padding,箭头到标题的横向空隙过大。 */ + width: v('tree-switcher-size', 18px); + height: v('tree-switcher-size', 18px); border-radius: v('radius-sm', 4px); color: v('text-3', rgba(0, 0, 0, 0.45)); } @@ -349,10 +361,6 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node visibility: hidden; } -.#{e('tree-node', 'switcher')}:hover:not(.#{is('placeholder')}) { - background: v('fill-hover', rgba(0, 0, 0, 0.08)); -} - .#{e('tree-node', 'switcher-icon')} { width: v('tree-icon-size', 16px); height: v('tree-icon-size', 16px); @@ -386,7 +394,7 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node align-items: center; gap: v('space-1', 4px); min-width: 0; - padding: 0 v('tree-title-padding-x', 6px); + padding: 0 v('tree-title-padding-x', 2px); } .#{e('tree-node', 'content')}.#{is('block')} { diff --git a/code/g3soft-libs/packages/ui/src/tree/types.ts b/code/g3soft-libs/packages/ui/src/tree/types.ts index 52b7f377..b29befcb 100644 --- a/code/g3soft-libs/packages/ui/src/tree/types.ts +++ b/code/g3soft-libs/packages/ui/src/tree/types.ts @@ -1,4 +1,4 @@ -import type { Component, VNode } from 'vue' +import type { Component, Slot, VNode } from 'vue' /** 树节点标识 */ export type TreeKey = string | number @@ -118,6 +118,12 @@ export interface TreeProps { /** 节点级上下文(Tree 通过 provide 下发,TreeNode 消费) */ export interface TreeContext { fieldNames: Required + /** + * Tree 收到的 #title 插槽,随上下文下发。 + * 嵌套层级的节点不经过 Tree 的模板渲染(由 TreeNode 递归渲染), + * 靠上下文才能拿到业务插槽,而不是只有顶层节点生效。 + */ + titleSlot?: Slot checkable: boolean selectable: boolean multiple: boolean