From 27e49e2e457609372b48de57c3e2fe5b426dc0ad Mon Sep 17 00:00:00 2001 From: ZhangAo Date: Thu, 24 Sep 2026 17:25:48 +0800 Subject: [PATCH] 20260924172548 --- code/fms/.workbuddy-ai/memory/2026-09-24.md | 12 + code/fms/.workbuddy-ai/memory/MEMORY.md | 188 +----- .../memory/topics/env-and-baseline.md | 29 + .../memory/topics/frontend-ui.md | 60 ++ .../.workbuddy-ai/memory/topics/workflow.md | 55 ++ code/fms/fms-vue/.qdesign-full.png | Bin 0 -> 5851 bytes code/fms/fms-vue/qdesign-preview.html | 125 ++++ .../src/layouts/components/AppTabs.vue | 3 +- code/fms/fms-vue/src/layouts/tabTitle.js | 36 ++ code/fms/fms-vue/src/router/routes.js | 10 +- .../src/views/base/othercompany/detail.vue | 598 +++++++++++++++++- .../src/views/base/othercompany/index.vue | 21 +- .../ModuleQueryConfigPanel.vue | 565 +++++++++++++---- .../design-editor/TableDesignEditor.vue | 1 + .../design-editor/common/FieldLibrary.vue | 26 + .../workflow/designer/WorkflowCanvas.vue | 12 +- .../designer/WorkflowCanvasToolbar.vue | 110 ++++ .../workflow/designer/WorkflowDesigner.vue | 75 ++- .../workflow/designer/WorkflowToolbar.vue | 125 ++-- .../unit/module-query-config-panel.spec.js | 43 +- code/fms/fms-vue/tests/unit/tab-title.spec.js | 36 ++ code/test/vue-vben-admin | 2 +- 22 files changed, 1786 insertions(+), 346 deletions(-) create mode 100644 code/fms/.workbuddy-ai/memory/2026-09-24.md create mode 100644 code/fms/.workbuddy-ai/memory/topics/env-and-baseline.md create mode 100644 code/fms/.workbuddy-ai/memory/topics/frontend-ui.md create mode 100644 code/fms/.workbuddy-ai/memory/topics/workflow.md create mode 100644 code/fms/fms-vue/.qdesign-full.png create mode 100644 code/fms/fms-vue/qdesign-preview.html create mode 100644 code/fms/fms-vue/src/layouts/tabTitle.js create mode 100644 code/fms/fms-vue/src/views/system/workflow/designer/WorkflowCanvasToolbar.vue create mode 100644 code/fms/fms-vue/tests/unit/tab-title.spec.js diff --git a/code/fms/.workbuddy-ai/memory/2026-09-24.md b/code/fms/.workbuddy-ai/memory/2026-09-24.md new file mode 100644 index 00000000..a684c8a5 --- /dev/null +++ b/code/fms/.workbuddy-ai/memory/2026-09-24.md @@ -0,0 +1,12 @@ +# 2026-09-24 + +- 记忆整理:`memory/MEMORY.md` 已达 14.8 KB,注入时被截断(尾部「主题/UI/i18n/导出」长期丢失)。 + 重构为「精简主文件(3.8 KB)+ 分主题明细」:新建 `memory/topics/workflow.md`、`topics/frontend-ui.md`、 + `topics/env-and-baseline.md`,内容零丢失,主文件开头保留指向 topics 的索引。 + 后续新增长期约定:核心不变式写 MEMORY.md,单域细节写对应 topic 文件。 +- 模块管理页「查询配置」改造(`ModuleQueryConfigPanel.vue`):去掉内层页签分组,四个分节 + (更多条件字段 / 工作队列页签 / 关键词字段 / 快捷筛选字段)同页自上而下平铺,整页由外层 + `.mm-config-pane` 统一滚动。面板由 `flex:1 + overflow:hidden` 改为 `flex:1 0 auto`; + 三张 FmsTable 不再靠 flex 撑满,改按行数算高度(34 + 行数×30 + 2,clamp 140~420px)。 + 同步更新 `tests/unit/module-query-config-panel.spec.js`:`openViewsTab`(点页签)换成 + `viewsTable`(按 `.mm-qdesign__section--views` 定位),并新增一条「四节同页平铺 + 表格定高」断言。 diff --git a/code/fms/.workbuddy-ai/memory/MEMORY.md b/code/fms/.workbuddy-ai/memory/MEMORY.md index 9c941408..5770002e 100644 --- a/code/fms/.workbuddy-ai/memory/MEMORY.md +++ b/code/fms/.workbuddy-ai/memory/MEMORY.md @@ -1,167 +1,47 @@ -# FMS 项目长期约定 +# FMS 项目长期约定(主文件) -## 目录 / 文档 / 库 +> 明细已拆分到 `memory/topics/`,需要时再读,避免主文件被截断: +> `topics/workflow.md`(工作流设置器 + dagre 布局)、`topics/frontend-ui.md`(主题/UI 库/i18n/导出)、 +> `topics/env-and-baseline.md`(本机环境坑 + 测试基线)。每日日志见同目录 `YYYY-MM-DD.md`。 -- 后端 `fms-api`(Spring Boot + JDK21,JDBC+Druid 无 ORM);前端 `fms-vue`(Vue 3.5 + Vite 8 + Pinia,纯 JS)。 - `fms-vue-old1/old2` 仅供历史参考,**不要改**。 -- 根目录设计文档:`FMS新系统核心表结构设计.md`(权威表结构)、`FMS业务表设计.md`、`开发规范.md`(AI 规范)、 - `Fms旧系统表结构.md`(旧库 G3HY2025,只有表无视图)、`FMS删除规则引擎设计.md`。 -- 建库/模块注册脚本在 `sql/`(`fms_core.sql`、`fms_business_*.sql`、`fms_module_*.sql`)。 -- SQL Server `118.89.70.199:1433`:旧库 `G3HY2025`,新库 **FMS**(新系统一律用 FMS,**生产库,写前先确认**)。 -- 机构→库映射在 `fms-api/config/dbconfigs/{ORG_ID}.properties`(gitignore):`G3HD.properties` → FMS, - `G3HY2025.properties` → G3HY2025。**没有 FMS.properties**。 -- 后端 8088(context-path `/api`),前端 dev 5082(`/api` → 127.0.0.1:8088)。直连接口需先 `/auth/login`。 +## 目录 / 库 / 端口 -## 元数据核心表(真实列名,已连库核对,别再猜) +- 后端 `fms-api`(Spring Boot 4.1 + JDK21,JDBC+Druid 无 ORM);前端 `fms-vue`(Vue3.5 + Vite8 + Pinia,纯 JS,pnpm)。 + `fms-vue-old1/old2` 仅历史参考,**不要改**。 +- 根目录文档:`FMS新系统核心表结构设计.md`(权威表结构)、`FMS业务表设计.md`、`开发规范.md`(AI 规范)、 + `Fms旧系统表结构.md`(旧库,只有表无视图)、`FMS删除规则引擎设计.md`;建库脚本在 `sql/`。 +- SQL Server `118.89.70.199:1433`:旧库 `G3HY2025`,新库 **FMS**(生产库,写前先确认)。 + 机构→库映射 `fms-api/config/dbconfigs/{ORG_ID}.properties`(gitignore):`G3HD`→FMS、`G3HY2025`→旧库; + **没有 FMS.properties**。 +- 后端 8088(context-path `/api`),前端 dev 5082(`/api`→127.0.0.1:8088)。直连接口需先 `/auth/login`。 -- `s_module`:PK **`b_id`**(无 `b_module_code`;无 `b_route`,那是 `s_menu` 的);本身是树: - `b_parent_id`/`b_depth`/`b_path`;另有 `b_module_type`(module/data/virtual)、`b_view_table`、`b_save_table`、 - `b_key_field`、`b_order_sql`、`b_query_sql`、`b_config_json`、`b_canuse`、`b_xh`、`b_bz`、`b_name`、`b_i18n`。 -- `s_field`:PK **`(b_module_id, b_field)`**,无 `b_id`;`b_type` 实测 `input`/`number`/`checkbox`/`datetime`/`date`。 -- `s_module_schema`:PK `(b_module_id, b_schema_type∈view|edit|query)`,`b_schema_json`。 -- `s_menu`:`b_id`/`b_parent_id`/`b_depth`/`b_path`/`b_menu_type`(directory|page)/`b_route`/`b_icon`;`s_menu_module` 绑菜单与模块。 -- **权限靠 `b_id`**:permissionStore 的 `moduleCodes` 即 `s_module.b_id`,页面 `MODULE_CODE` 必须等于它; - 还要在 `s_menu_module` 里绑菜单,否则非超管看不到授权入口。 -- 列表模块编码统一 `v_`+表名(`v_b_othercompany`),`b_view_table`→视图,`b_save_table`→真实表。 -- schema JSON 形状:`view{columns:[{field,width}|{type:group,title,children}]}`、 +## 元数据表真实列名(已连库核对,别猜) + +- `s_module`:PK **`b_id`**(无 `b_module_code`;`b_route` 属 `s_menu`);树用 `b_parent_id`/`b_depth`/`b_path`; + 另有 `b_module_type`(module/data/virtual)、`b_view_table`、`b_save_table`、`b_key_field`、`b_order_sql`、 + `b_query_sql`、`b_config_json`、`b_canuse`、`b_xh`、`b_bz`、`b_name`、`b_i18n`。查询来源 `b_query_sql` > `b_view_table`。 +- `s_field`:PK `(b_module_id, b_field)`,无 `b_id`;`b_type` 实测 input/number/checkbox/datetime/date。 +- `s_module_schema`:PK `(b_module_id, b_schema_type∈view|edit|query)`,内容在 `b_schema_json`。 +- `s_menu`:`b_id`/`b_parent_id`/`b_depth`/`b_path`/`b_menu_type`(directory|page)/`b_route`/`b_icon`; + `s_menu_module` 绑菜单与模块。 +- **权限靠 `b_id`**:permissionStore.moduleCodes 即 `s_module.b_id`,页面 `MODULE_CODE` 必须等于它; + 还需在 `s_menu_module` 绑菜单,否则非超管看不到授权入口。列表模块编码统一 `v_`+表名(`v_b_othercompany`)。 +- schema JSON:`view{columns:[{field,width}|{type:group,title,children}]}`、 `edit{children:[{field,span,required,readonly,defaultValue}|group]}`、`query{groups:[{conditions}],quick}`。 -- 路由守卫只看 `meta.moduleId`/`meta.adminOnly`;`views/module/**` 是 adminOnly(仅 g3soft)。 +- 路由守卫只看 `meta.moduleId`/`meta.adminOnly`;`views/module/**` 是 adminOnly(仅 g3soft),不进 `s_menu`。 -## 树结构规范(模块/菜单/部门/分类树通用) +## 树结构规范(模块/菜单/部门/分类通用) -`b_parent_id` 是权威关系,`b_depth`(根=0)、`b_path`(`/cn/east/`)是派生字段,移动节点要同事务更新全部后代; -不一致时以 `b_parent_id` 为准。工具在 `src/utils/tree.js`(`buildSortedTree`/`computeTreeMove`/`collectDescendantIds`/ -`applyTreePosition`/`filterTreeByText`/`findTreeNode`),展示层用 `components/fms-tree/FmsTree.vue`(自管虚拟根)。 +`b_parent_id` 权威,`b_depth`(根=0)/`b_path`(`/cn/east/`) 是派生字段,移动节点同事务更新全部后代; +不一致以 `b_parent_id` 为准。工具 `src/utils/tree.js`(buildSortedTree/computeTreeMove/collectDescendantIds/ +applyTreePosition/filterTreeByText/findTreeNode),展示 `components/fms-tree/FmsTree.vue`(自管虚拟根)。 -## 后端通用接口(前端业务数据只走这几个) +## 后端通用接口(业务数据只走这几个) -- `POST /auth/login`:字段是**小写** `orgid`/`userid`/`password`;`orgid=G3HD`。 -- `/data/loaddata`:`view_name`/`search_condition`/**可选** `order_by`/`search_columns`(真 JSON 数组)。 -- `/data/page`:**`order_by` 必填**(`DbUtils.buildPageOrderBy` 会报「order_by 不能为空」), - 分页参数名是 **`page_no`/`page_size`**(不是 pageindex/pagesize)。 +- `POST /auth/login`:字段**小写** `orgid`/`userid`/`password`;`orgid=G3HD`。 +- `/data/loaddata`:`view_name`/`search_condition`/可选 `order_by`/`search_columns`(真 JSON 数组)。 +- `/data/page`:**`order_by` 必填**;分页参数名 **`page_no`/`page_size`**。 - `/data/saveobjt`:body 是数组,每项 `{table, key_field, deletes, updates, inserts}`,**`key_field` 必填** (复合主键逗号分隔),顺序 delete→update→insert。 - 模块元数据不由接口下发,前端读本地 schema JSON,改元数据无需重启后端。 - `DbUtils` 是 SQL 唯一出口:标识符加 `[]`,`validateReadOnlySql()` 只允许查询。 - -## 工作流流程设置器(fms-vue/src/views/system/workflow) - -- **代码现状 = 原始版本(2026-09-23 曾被重构,应用户要求已全部回滚)**: - 草稿是 `schemaVersion: 1`(`{definitionId, name, nodes, edges}`),编辑器是 - `WorkflowDesigner` + `WorkflowCanvas`(LogicFlow) + `WorkflowPropertyPanel` 三件套, - 节点坐标挂在 `node.x/y`,条件存单个叶子 `{field, operator, value}`, - 写入通道未实现(工具栏只有撤销/缩放/查看草稿 JSON)。 - **不要以为仓库里已经有卡片模式/流程树/条件构造器 —— 那些都被回滚了。** - 回滚前的完整实现留在 `D:\workspace\code\fms-workflow-refactor-backup-20260923\`(仓库外,含 `http.js` 的 get/put、 - `tests/unit/workflow-domain.spec.js`、改过 AST 形状的 mock SQL),需要时可取回。 -- **文档是目标,不是现状**:`FMS审批流程设置器交互设计.md`(旧名 `FMS审批流程交互重构设计.md`, - 已整体重写为 258 行)要求把设计器改成钉钉式递归流程树,并**明确作废**"线性卡片 + LogicFlow 高级模式" - 双编辑器方案(§3.2 不再用画布兜底,并行/服务节点等拓扑只读保护); - `FMS审批流程设置器二次重构实施文档.md` 给出增量执行顺序(§1.1 的交互决策已锁定,不必再问); - `FMS工作流与审批设计.md` 管表结构与运行语义(其 §19 的画布选型是旧方案)。 - 参考交互项目:`D:\workspace\code\test\Workflow-Vue3`(只借交互,不复制代码/Element UI/私有字体)。 -- **这些文件未进 git**:`design.vue`、`designer/`(除 `index.vue` 外全部)、`sql/fms_workflow_mock.sql` - 都是未跟踪文件 —— 改它们之前先备份,`git restore` 救不回来(本地历史里也没有)。 -- **`workflow/index.vue` 要小心**:它虽然被 git 跟踪,但**工作区版本比 HEAD 新**(新增按钮、表格「设计」行操作 - 都是未提交改动)。直接 `git restore` 会退回 HEAD 的 18 行空壳,把按钮冲掉 —— 要恢复就用带按钮的 76 行版本。 -- 表结构已建好:`sql/fms_workflow.sql`(13 张表)、`fms_workflow_module.sql`(视图 + s_module 注册)、 - `fms_workflow_menu.sql`(菜单 `workflow` / `approval*`)、`fms_workflow_mock.sql`(`expense_approval` 样本, - 覆盖 7 种节点类型 / 6 种审批方式 / 5 种审批人解析)。 -- **后端 `fms-api/src/main` 目前零工作流代码**:保存/校验/模拟/发布都要走流程服务,接口 404 时必须 - 明确提示"服务未接入",绝不伪造成功。 -- 若将来重新做树形重构,踩过的坑:① `b_priority` 是**同一来源节点内**的判断顺序,不是全表序号; - ② 校验只约束**出边**,入边不能锁成 1(分支汇合点天生多条入线);③ 环要单独拦,否则递归不终止; - ④ 默认出口(`condition: null`)必须唯一且固定最后。 - -### 画布「一键整理」(dagre 自动布局,2026-09-23 已落地) - -- 依赖 **`@logicflow/layout@2.1.5`**(peer 正好是 `@logicflow/core ^2.2.5`)。注意 dagre **不在** - `@logicflow/extension` 里(那只有一个标注"未完善"的 AutoLayout)。 -- 必须**深路径导入** `import { Dagre } from '@logicflow/layout/es/dagre'`:包入口同时导出 `elkLayout`, - 从入口引会把 elkjs 打进产物(实测走深路径只 +92 kB,走入口会 +1MB 以上)。包没有 `exports` 字段,深路径可用。 -- 注册方式:`new LogicFlow({ plugins: [Dagre] })`,调用 `lf.extension.dagre.layout({...})`。 - 插件实例由 `installPlugin` 的"非静态插件"分支创建(`Dagre` 只有 `static pluginName` + `render`,没有 `install`)。 -- **坑**:插件靠 `ToolOverlay` 的 `componentDidMount` → `triggerToolRender` 调用 `render(lf)` 才拿到 - `this.lf`;拿不到时 `layout()` 会**直接抛错**(它的 try/catch 只包住 applyDagreLayout)。 - 所以 `autoLayout()` 里先兜底 `if (!dagre.lf) dagre.lf = lf`,并用 try/catch + 「坐标是否变化」判断结果 - —— 插件失败时只打 console.error,不能假定成功。 -- 布局走 `lf.renderRawData()` 整图重渲染:**会丢选中态**,但节点/连线 **id 保留**(`processEdges` 只改路径字段), - 所以上层按 nodeKey/edgeKey 回填审批人与条件仍然成立。坐标变化由实例的 `history` 自动记录(deepObserve + debounce)。 -- 布局后**必须 `snapshot()`** 把新坐标 emit 给上层,否则草稿里的 x/y 还是旧的。 -- 参数:`rankdir: 'TB'`(审批流程纵向)、`align: 'UL'`、`nodesep: 60`、`ranksep: 80`、`isDefaultAnchor: true`(重算连线路径)。 -- 测试:`tests/unit/workflow-canvas-layout.spec.js`(jsdom 挂载画布真跑一次布局;需给 `SVGElement.prototype.getBBox` 打桩, - 否则 LogicFlow 的 autoWrap 文本测量会挂)。 - -## 环境坑(本机特有) - -- **编译/测试 fms-api 唯一可行命令**(start.cmd 里的 JDK/Maven 路径都是坏的): - ``` - cd /d/workspace/code/fms/fms-api && /d/devtool/jdk/jdk21/bin/java \ - -Dclassworlds.conf="D:/devtool/maven/bin/m2.conf" -Dmaven.home="D:/devtool/maven" \ - -Dmaven.multiModuleProjectDirectory="D:/workspace/code/fms/fms-api" \ - -cp "D:/devtool/maven/boot/plexus-classworlds-2.11.0.jar" \ - org.codehaus.plexus.classworlds.launcher.Launcher -o -Dtest=XxxTests test - ``` - 本地仓库是 `D:\data\maven`(不是 ~/.m2);联网下载去掉 `-o`;`target/` 被 git 跟踪,编译后有噪音。 -- 无 sqlcmd/pyodbc/pymssql:用 `fms-api/tools/migration/RunSqlFile.java` 或一次性 `SqlRunner.java` - + JDBC jar(`~/.m2/.../mssql-jdbc-13.4.0.jre11.jar`)+ `/d/devtool/jdk/jdk17/bin/java`。 - 脚本要按行首 `GO` 分批,路径传绝对路径。 -- `vite build` 被 safe-delete 守卫拦(要删 600+ 文件):用 `--outDir dist-verify --emptyOutDir`; - 清理用 `find dist-verify -type f -delete` 再 `find dist-verify -type d -empty -delete`。 -- `oxfmt --check` 全仓库都报 format issues(版本漂移),**不是自己引入的**,别顺手格式化整个仓库。 -- 本工作区可能被多会话同时编辑,验证失败项先用 `find -mmin -N` 确认是否自己引入。 - -## 测试基线(既有失败,别去修) - -- 后端 `OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry`(断言 Druid retry=0,实测 1)。 -- 前端 `tests/unit/fms-module-list-page.spec.js` 里引用 `.filter-drawer`/`.filter-row__*` 的用例 - (类名已不存在,是高级查询面板改版后的过期断言)—— 另有约 13 个长期失败基线。 -- `tests/helpers/app.js` 的 `mountPage` 用 shallowMount 且默认打桩子组件:给 `Splitter`/`SplitterPanel` - 打桩时键名要写 **Vue 推断名 `'splitter'`/`'panel'`**(文件名小写),写 `'SplitterPanel'` 打不上桩; - 自定义组件传 `false` 可强制真实挂载。`global` 会整体覆盖内部 stubs,别在里面再写 stubs。 - -## 主题约定 - -- 配色唯一真相 `fms-vue/src/theme/tokens.css`:`:root` 浅色 + `.dark` 深色必须同步增删同名变量, - `--fms-primary` 由 JS 写入,衍生色用 `color-mix()`。改主题只动这个文件。 -- **侧栏当前是浅色的**(不存在 `--fms-sidebar-*` 变量;若再看到「侧栏是黑的」即为过期信息)。 - 要改侧栏配色就新增 `--fms-sidebar-*` 只给 `AppSidebar.vue`/`NavMenuItem.vue` 用, - **别动 `--fms-surface`/`--fms-text` 等全局中性色**(顶栏与所有业务页共用)。 -- 深底+主题色高亮必须用「深底 + 白字 + 主色亮描边」:填充 `color-mix(primary 38%, #000)`、 - 描边 `color-mix(primary 40%, #fff)`;「浅底+白字」对比度只有 1.4~2.7:1,不可用。 -- Teleport 到 body 的浮层(如 `.fms-team-menu-*`)不在侧栏内,保持全局浅色 token。 - -## UI 组件库全局配置 - -- 唯一入口 `fms-vue/src/components/ui/config.js`:`uiConfig`(reactive)+ `setUIConfig(patch)`(按组件维度浅合并) - + `resetUIConfig()`,也从 `@/components/ui` 具名导出。**没有 ConfigProvider、没有 app.use 插件**。 -- 回落顺序固定 **自身 prop > uiConfig > 组件内置默认**;要支持全局覆盖的 prop,`default` 必须写 `undefined` - 且 validator 要放行 `undefined`(Vue 会拿 default 跑校验)。 -- Form 的 `layout`/`labelAlign`/`labelWidth`/`validateTrigger` 已接入;provide 下发回落后的最终值。 - 语言联动在 `src/App.vue`:中文 `horizontal`、其他语言 `vertical`(判定 `/^zh/i`)。 -- **多列紧凑网格的表单必须显式 `layout="vertical"`**(如自动编码面板 columns=5), - 否则横排的 88px 标签会把控件压到几十像素。prop 优先于全局配置。 -- `defineModel` 写完**不能立刻回读**:写了只 emit,prop 下一轮才回来;要取刚写入的值就自己先 - `reactive()` 包一层再赋值,返回其中的代理对象。 - -## i18n 运行时 - -- 语言清单 `s_i18n_type`(`b_id` 即语种编码,zh-CN 默认 / en-US);译文 `s_i18n(b_key, b_locale, b_value)`。 -- store `stores/i18n.js`:`loadLanguages()` 拉清单、**`loadMessages(locale)` 拉译文**(按语种缓存 + 并发去重 + - 失败静默;只把「当前语种」的译文写进运行时)。**别只看 locale——不调 loadMessages 界面不会变。** -- 合并顺序 **前端内置 < 库表译文**:内置只有 en-US 的 `common.*`(`src/i18n/builtin-messages.js`, - 代码约定 `t('common.x', '中文兜底')`);业务文案/字段名/菜单名的译文一律进 `s_i18n` 由实施维护。 -- 现状:`s_i18n` 只登记了 zh-CN(约 134 条),en-US 尚无数据 → 英文界面只有框架按钮/提示是英文, - 字段名等仍回落中文,需要补录。 -- 顶栏语种下拉取自 `s_i18n_type` 清单,不要硬编码语言编码。 -- 查询区(`FmsQueryToolbar`)已改用 Form/FormItem,因此也跟随该配置;Form 根靠 `display: contents` - 透明化,让 FormItem 直接参与 `.query-grid`(`minmax(0,200px)`)布局,label 宽局部 72px。 - 高级查询面板(`FmsAdvancedQueryPanel`)仍是自绘 `.filter-*`,**不受**表单布局配置影响。 - -## 导出(P0 已落地,摘要) - -`POST /api/export/list` 返回文件流:`SqlPermissionService` → `ExportSchemaService`(列与列表同源推导) -→ `ExportService`(Fesod 流式写)→ xlsx。**导出列不由前端传**,否则字段权限形同虚设; -校验必须在 `getOutputStream()` 前完成。依赖 `org.apache.fesod:fesod-sheet:2.0.2-incubating`(自带 POI,别再引)。 -Fesod 坑:0 行也要调一次 `write`,否则无工作表;合计行要自己构造标签。 -权限现状:`s_power` 只有 2 行,非超管会被拒,g3soft 可直接用。 diff --git a/code/fms/.workbuddy-ai/memory/topics/env-and-baseline.md b/code/fms/.workbuddy-ai/memory/topics/env-and-baseline.md new file mode 100644 index 00000000..bf51df4e --- /dev/null +++ b/code/fms/.workbuddy-ai/memory/topics/env-and-baseline.md @@ -0,0 +1,29 @@ +# 本机环境坑 + 测试基线 + +## 环境坑(本机特有) + +- **编译/测试 fms-api 唯一可行命令**(`start.cmd` 里的 JDK/Maven 路径都是坏的): + ``` + cd /d/workspace/code/fms/fms-api && /d/devtool/jdk/jdk21/bin/java \ + -Dclassworlds.conf="D:/devtool/maven/bin/m2.conf" -Dmaven.home="D:/devtool/maven" \ + -Dmaven.multiModuleProjectDirectory="D:/workspace/code/fms/fms-api" \ + -cp "D:/devtool/maven/boot/plexus-classworlds-2.11.0.jar" \ + org.codehaus.plexus.classworlds.launcher.Launcher -o -Dtest=XxxTests test + ``` + 本地仓库是 `D:\data\maven`(不是 ~/.m2);联网下载去掉 `-o`;`target/` 被 git 跟踪,编译后有噪音。 +- 无 sqlcmd/pyodbc/pymssql:用 `fms-api/tools/migration/RunSqlFile.java` 或一次性 `SqlRunner.java` + + JDBC jar(`~/.m2/.../mssql-jdbc-13.4.0.jre11.jar`)+ `/d/devtool/jdk/jdk17/bin/java`。 + 脚本要按行首 `GO` 分批,路径传绝对路径。 +- `vite build` 被 safe-delete 守卫拦(要删 600+ 文件):用 `--outDir dist-verify --emptyOutDir`; + 清理用 `find dist-verify -type f -delete` 再 `find dist-verify -type d -empty -delete`。 +- `oxfmt --check` 全仓库都报 format issues(版本漂移),**不是自己引入的**,别顺手格式化整个仓库。 +- 本工作区可能被多会话同时编辑,验证失败项先用 `find -mmin -N` 确认是否自己引入。 + +## 测试基线(既有失败,别去修) + +- 后端 `OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry`(断言 Druid retry=0,实测 1)。 +- 前端 `tests/unit/fms-module-list-page.spec.js` 里引用 `.filter-drawer`/`.filter-row__*` 的用例 + (类名已不存在,是高级查询面板改版后的过期断言)—— 另有约 13 个长期失败基线。 +- `tests/helpers/app.js` 的 `mountPage` 用 shallowMount 且默认打桩子组件:给 `Splitter`/`SplitterPanel` + 打桩时键名要写 **Vue 推断名 `'splitter'`/`'panel'`**(文件名小写),写 `'SplitterPanel'` 打不上桩; + 自定义组件传 `false` 可强制真实挂载。`global` 会整体覆盖内部 stubs,别在里面再写 stubs。 diff --git a/code/fms/.workbuddy-ai/memory/topics/frontend-ui.md b/code/fms/.workbuddy-ai/memory/topics/frontend-ui.md new file mode 100644 index 00000000..7eeee06c --- /dev/null +++ b/code/fms/.workbuddy-ai/memory/topics/frontend-ui.md @@ -0,0 +1,60 @@ +# 主题 / UI 组件库 / i18n / 导出 + +## 主题 + +- 唯一真相 `fms-vue/src/theme/tokens.css`:`:root` 浅色 + `.dark` 深色必须同步增删同名变量, + `--fms-primary` 由 JS 写入,衍生色用 `color-mix()`。改主题只动这个文件。 +- **侧栏当前是浅色的**(不存在 `--fms-sidebar-*` 变量;若再看到「侧栏是黑的」即为过期信息)。 + 要改侧栏配色就新增 `--fms-sidebar-*` 只给 `AppSidebar.vue`/`NavMenuItem.vue` 用, + **别动 `--fms-surface`/`--fms-text` 等全局中性色**(顶栏与所有业务页共用)。 +- 深底 + 主题色高亮必须用「深底 + 白字 + 主色亮描边」:填充 `color-mix(primary 38%, #000)`、 + 描边 `color-mix(primary 40%, #fff)`;「浅底 + 白字」对比度只有 1.4~2.7:1,不可用。 +- Teleport 到 body 的浮层(如 `.fms-team-menu-*`)不在侧栏内,保持全局浅色 token。 + +## UI 组件库全局配置 + +- 唯一入口 `fms-vue/src/components/ui/config.js`:`uiConfig`(reactive)+ `setUIConfig(patch)` + (按组件维度浅合并)+ `resetUIConfig()`,也从 `@/components/ui` 具名导出。 + **没有 ConfigProvider、没有 app.use 插件**。 +- 回落顺序固定 **自身 prop > uiConfig > 组件内置默认**;要支持全局覆盖的 prop,`default` 必须写 `undefined` + 且 validator 要放行 `undefined`(Vue 会拿 default 跑校验)。 +- Form 的 `layout`/`labelAlign`/`labelWidth`/`validateTrigger` 已接入;provide 下发回落后的最终值。 + 语言联动在 `src/App.vue`:中文 `horizontal`、其他语言 `vertical`(判定 `/^zh/i`)。 +- **多列紧凑网格的表单必须显式 `layout="vertical"`**(如自动编码面板 columns=5), + 否则横排的 88px 标签会把控件压到几十像素。prop 优先于全局配置。 +- `defineModel` 写完**不能立刻回读**:写了只 emit,prop 下一轮才回来;要取刚写入的值就自己先 + `reactive()` 包一层再赋值,返回其中的代理对象。 + +## i18n 运行时 + +- 语言清单 `s_i18n_type`(`b_id` 即语种编码,zh-CN 默认 / en-US);译文 `s_i18n(b_key, b_locale, b_value)`。 +- store `stores/i18n.js`:`loadLanguages()` 拉清单、**`loadMessages(locale)` 拉译文**(按语种缓存 + 并发去重 + + 失败静默;只把「当前语种」的译文写进运行时)。**别只看 locale —— 不调 loadMessages 界面不会变。** +- 合并顺序 **前端内置 < 库表译文**:内置只有 en-US 的 `common.*`(`src/i18n/builtin-messages.js`, + 代码约定 `t('common.x', '中文兜底')`);业务文案/字段名/菜单名的译文一律进 `s_i18n` 由实施维护。 +- 现状:`s_i18n` 只登记了 zh-CN(约 134 条),en-US 尚无数据 → 英文界面只有框架按钮/提示是英文, + 字段名等仍回落中文,需要补录。 +- 顶栏语种下拉取自 `s_i18n_type` 清单,不要硬编码语言编码。 +- 查询区(`FmsQueryToolbar`)已改用 Form/FormItem,因此也跟随该配置;Form 根靠 `display: contents` + 透明化,让 FormItem 直接参与 `.query-grid`(`minmax(0,200px)`)布局,label 宽局部 72px。 + 高级查询面板(`FmsAdvancedQueryPanel`)仍是自绘 `.filter-*`,**不受**表单布局配置影响。 + +## 模块管理页的分节布局(2026-09-24) + +- 右栏是「卡头(模块名 + 动作)+ 分节导航(`mm-config-nav`,Tabs `nav-only`)+ `.mm-config-stack`」; + 每个分节是 `.mm-config-pane`(`flex:1; overflow:auto; padding:12px 16px 16px`),**滚动由它负责**。 +- **面板自身不要做「撑满 + 内部滚动」**:需要一页平铺时用 `flex: 1 0 auto`(内容少时填满、内容多时 + 撑高让 `.mm-config-pane` 滚动)。`ModuleQueryConfigPanel` 已按此改造 —— + 四个分节(更多条件字段 / 工作队列页签 / 关键词字段 / 快捷筛选字段)同页平铺,**不再用内层页签**。 +- **`FmsTable` 必须有确定高度**(根 `.fms-table` 是 `height:100%`,且虚拟滚动要容器尺寸)。 + 平铺多个表格时不能靠 `flex:1` 撑满,要按行数算高度:表头 34 + 行数×30 + 2, + clamp 到 140~420px(见 `ModuleQueryConfigPanel.tableHeightOf`),超出上限才在表格内部滚动。 + +## 导出(P0 已落地) + +- `POST /api/export/list` 返回文件流:`SqlPermissionService` → `ExportSchemaService`(列与列表同源推导) + → `ExportService`(Fesod 流式写)→ xlsx。**导出列不由前端传**,否则字段权限形同虚设; + 校验必须在 `getOutputStream()` 前完成。 +- 依赖 `org.apache.fesod:fesod-sheet:2.0.2-incubating`(自带 POI,别再引)。 +- Fesod 坑:0 行也要调一次 `write`,否则无工作表;合计行要自己构造标签。 +- 权限现状:`s_power` 只有 2 行,非超管会被拒,g3soft 可直接用。 diff --git a/code/fms/.workbuddy-ai/memory/topics/workflow.md b/code/fms/.workbuddy-ai/memory/topics/workflow.md new file mode 100644 index 00000000..c4c39dba --- /dev/null +++ b/code/fms/.workbuddy-ai/memory/topics/workflow.md @@ -0,0 +1,55 @@ +# 工作流流程设置器(fms-vue/src/views/system/workflow) + +## 代码现状 = 原始版本(2026-09-23 重构后已全部回滚) + +- 草稿 `schemaVersion:1`(`{definitionId, name, nodes, edges}`),编辑器 + `WorkflowDesigner` + `WorkflowCanvas`(LogicFlow) + `WorkflowPropertyPanel`,坐标挂 `node.x/y`, + 条件存单个叶子 `{field, operator, value}`,写入通道未实现(工具栏只有撤销/缩放/查看草稿 JSON)。 +- **别以为仓库里已经有卡片模式/流程树/条件构造器 —— 都被回滚了。** +- 回滚前的完整实现备份在仓库外 `D:\workspace\code\fms-workflow-refactor-backup-20260923\` + (含 `http.js` 的 get/put、`tests/unit/workflow-domain.spec.js`、改过 AST 形状的 mock SQL)。 + +## 文档是目标,不是现状 + +- `FMS审批流程设置器交互设计.md`(旧名 `FMS审批流程交互重构设计.md`,已重写为 258 行):要求改成钉钉式递归流程树, + 并**明确作废**「线性卡片 + LogicFlow 高级模式」双编辑器方案(§3.2 不再用画布兜底,并行/服务节点等拓扑只读保护)。 +- `FMS审批流程设置器二次重构实施文档.md`:增量执行顺序;§1.1 交互决策已锁定,不必再问。 +- `FMS工作流与审批设计.md`:管表结构与运行语义(其 §19 的画布选型是旧方案)。 +- 参考交互项目 `D:\workspace\code\test\Workflow-Vue3`(只借交互,不复制代码/Element UI/私有字体)。 + +## 文件与表 + +- **未进 git**:`design.vue`、`designer/`(除 `index.vue` 外全部)、`sql/fms_workflow_mock.sql` —— + 改前先备份,`git restore` 救不回来(本地历史里也没有)。 +- **`workflow/index.vue` 被 git 跟踪但工作区版本比 HEAD 新**(新增按钮、表格「设计」行操作均未提交); + 直接 `git restore` 会退回 HEAD 的 18 行空壳把按钮冲掉,要恢复就用带按钮的 76 行版本。 +- 表已建:`sql/fms_workflow.sql`(13 表)、`fms_workflow_module.sql`(视图 + s_module 注册)、 + `fms_workflow_menu.sql`(菜单 `workflow`/`approval*`)、`fms_workflow_mock.sql` + (`expense_approval` 样本:7 种节点类型 / 6 种审批方式 / 5 种审批人解析)。 +- **后端 `fms-api/src/main` 目前零工作流代码**:保存/校验/模拟/发布都要走流程服务, + 接口 404 时必须明确提示「服务未接入」,绝不伪造成功。 + +## 树形重构踩过的坑 + +① `b_priority` 是**同一来源节点内**的判断顺序,不是全表序号; +② 校验只约束**出边**,入边不能锁成 1(分支汇合点天生多条入线); +③ 环要单独拦,否则递归不终止; +④ 默认出口(`condition: null`)必须唯一且固定最后。 + +## 画布「一键整理」(dagre 自动布局,2026-09-23 已落地) + +- 依赖 **`@logicflow/layout@2.1.5`**(peer 正好是 `@logicflow/core ^2.2.5`)。 + 注意 dagre **不在** `@logicflow/extension` 里(那只有一个标注「未完善」的 AutoLayout)。 +- 必须**深路径导入** `import { Dagre } from '@logicflow/layout/es/dagre'`:包入口同时导出 `elkLayout`, + 从入口引会把 elkjs 打进产物(实测深路径只 +92 kB,走入口 +1MB 以上)。包没有 `exports` 字段,深路径可用。 +- 注册方式:`new LogicFlow({ plugins: [Dagre] })`,调用 `lf.extension.dagre.layout({...})`。 + 插件实例由 `installPlugin` 的「非静态插件」分支创建(`Dagre` 只有 `static pluginName` + `render`,没有 `install`)。 +- **坑**:插件靠 `ToolOverlay` 的 `componentDidMount` → `triggerToolRender` 调用 `render(lf)` 才拿到 `this.lf`; + 拿不到时 `layout()` 会**直接抛错**(它的 try/catch 只包住 applyDagreLayout)。所以 `autoLayout()` 里先兜底 + `if (!dagre.lf) dagre.lf = lf`,并用 try/catch + 「坐标是否变化」判断结果 —— 插件失败时只打 console.error。 +- 布局走 `lf.renderRawData()` 整图重渲染:**会丢选中态**,但节点/连线 **id 保留**(`processEdges` 只改路径字段), + 所以上层按 nodeKey/edgeKey 回填审批人与条件仍然成立。坐标变化由实例的 `history` 自动记录(deepObserve + debounce)。 +- 布局后**必须 `snapshot()`** 把新坐标 emit 给上层,否则草稿里的 x/y 还是旧的。 +- 参数:`rankdir:'TB'`(审批流程纵向)、`align:'UL'`、`nodesep:60`、`ranksep:80`、`isDefaultAnchor:true`(重算连线路径)。 +- 测试 `tests/unit/workflow-canvas-layout.spec.js`(jsdom 挂载画布真跑一次布局; + 需给 `SVGElement.prototype.getBBox` 打桩,否则 LogicFlow 的 autoWrap 文本测量会挂)。 diff --git a/code/fms/fms-vue/.qdesign-full.png b/code/fms/fms-vue/.qdesign-full.png new file mode 100644 index 0000000000000000000000000000000000000000..50072c498f025dae154a795a7788ad2bfc445e89 GIT binary patch literal 5851 zcmeAS@N?(olHy`uVBq!ia0y~yU|qn#z}&*Y1QfZU&)CYqAi&`1;uumf=gkd9P6h*> z0~-q8-T$;~@q`m6?%!nus?w-t29g5m7C^GW<{(Ju2`3+rRPZkVk_?qiAo&x*GC12%{Z4U?2?Veyyh8*Q*Q+Y%QR%fYC1g=m^K?0MF?7*68rwXcLJ3gS+>D o)zAO`({o;&2G+Q2p!$fJ;d2@n$E^FTyr4?h)78&qol`;+098^GYXATM literal 0 HcmV?d00001 diff --git a/code/fms/fms-vue/qdesign-preview.html b/code/fms/fms-vue/qdesign-preview.html new file mode 100644 index 00000000..88b91ab3 --- /dev/null +++ b/code/fms/fms-vue/qdesign-preview.html @@ -0,0 +1,125 @@ + + + + + 查询配置面板预览(调试用,验证完删除) + + + +
+ + + diff --git a/code/fms/fms-vue/src/layouts/components/AppTabs.vue b/code/fms/fms-vue/src/layouts/components/AppTabs.vue index bc6f84b9..4bed516a 100644 --- a/code/fms/fms-vue/src/layouts/components/AppTabs.vue +++ b/code/fms/fms-vue/src/layouts/components/AppTabs.vue @@ -13,6 +13,7 @@ import { useRoute, useRouter } from 'vue-router' import { useAppStore } from '@/stores/app' import { Dropdown } from '@/components/ui' import { cacheKeyOf } from '../layoutUtils' +import { resolveTabTitle } from '../tabTitle' const route = useRoute() const router = useRouter() @@ -29,7 +30,7 @@ const tabs = computed(() => appStore.visitedTabs) watch( () => route.fullPath, async () => { - const title = route.meta?.title + const title = resolveTabTitle(route) if (title) { // cacheByKey 路由(如 /detail/:id):每个 fullPath 一个独立组件名, // 使 keep-alive 的 include 能按参数精确销毁其实例,且不同参数天然多个独立缓存。 diff --git a/code/fms/fms-vue/src/layouts/tabTitle.js b/code/fms/fms-vue/src/layouts/tabTitle.js new file mode 100644 index 00000000..3532f517 --- /dev/null +++ b/code/fms/fms-vue/src/layouts/tabTitle.js @@ -0,0 +1,36 @@ +/** + * Resolve the label shown in AppTabs for a route. + * + * A route can opt in with `meta.tabTitle`: + * - `tabTitle: 'id'` reads query first, then params; + * - `tabTitle: { key: 'ywid', source: 'query' }` makes the source explicit. + * + * Keeping this convention in route meta means each detail route declares its + * own identifier without making AppTabs know business-specific field names. + */ +export function resolveTabTitle(route) { + const baseTitle = String(route?.meta?.title || '').trim() + if (!baseTitle) return '' + + const config = route?.meta?.tabTitle + if (!config) return baseTitle + + const options = typeof config === 'string' ? { key: config } : config + const key = String(options?.key || '').trim() + if (!key) return baseTitle + + const source = options?.source + const sources = source ? [source] : ['query', 'params'] + let value = '' + for (const sourceName of sources) { + const candidate = route?.[sourceName]?.[key] + if (candidate !== undefined && candidate !== null && String(candidate).trim()) { + value = String(candidate).trim() + break + } + } + if (!value) return baseTitle + + const separator = options?.separator ?? ' ' + return `${baseTitle}${separator}${value}` +} diff --git a/code/fms/fms-vue/src/router/routes.js b/code/fms/fms-vue/src/router/routes.js index b23e44f1..ad81b447 100644 --- a/code/fms/fms-vue/src/router/routes.js +++ b/code/fms/fms-vue/src/router/routes.js @@ -66,7 +66,7 @@ export const routes = [ path: '/detail/:id', name: 'detail', component: () => import('@/views/detail/DetailPage.vue'), - meta: { title: '详情', cacheByKey: true }, + meta: { title: '详情', cacheByKey: true, tabTitle: { key: 'id', source: 'params' } }, }, // 模块中心:元数据驱动的配置页集合(系统管理员专用,meta.adminOnly: // 路由守卫拦截非 g3soft,菜单管理「路由地址」下拉整体排除本组页面) @@ -123,6 +123,7 @@ export const routes = [ meta: { title: '合作伙伴详情', componentName: 'BaseOthercompanyDetail', + tabTitle: 'id', moduleId: 'v_b_othercompany', }, }, @@ -146,6 +147,7 @@ export const routes = [ meta: { title: '联系人详情', componentName: 'BaseContactDetail', + tabTitle: 'id', moduleId: 'v_b_contact', }, }, @@ -225,7 +227,11 @@ export const routes = [ path: 'workflow/design', name: 'system-workflow-design', component: () => import('@/views/system/workflow/design.vue'), - meta: { title: '流程设计', componentName: 'SystemWorkflowDesign' }, + meta: { + title: '流程设计', + componentName: 'SystemWorkflowDesign', + tabTitle: 'id', + }, }, // 运行态预览(临时演示页):用 mock 状态看「查看审批进度」的节点配色, // 看完可整页删掉(连同这条路由与 EXCLUDED_PATHS 里的一项)。 diff --git a/code/fms/fms-vue/src/views/base/othercompany/detail.vue b/code/fms/fms-vue/src/views/base/othercompany/detail.vue index 9cc4416d..66689c79 100644 --- a/code/fms/fms-vue/src/views/base/othercompany/detail.vue +++ b/code/fms/fms-vue/src/views/base/othercompany/detail.vue @@ -4,19 +4,603 @@ * * 路由契约:`/base/othercompany/detail` * - 不带参数(`route.query.id` 为空)表示新建; - * - `?id=xxx` 表示编辑 b_othercompany.b_id = xxx; - * - 顶部工具栏的保存 / 重置 / 删除、主表与子表(银行账号等)均尚未实现。 + * - `?id=xxx` 表示编辑 b_othercompany.b_id = xxx。 + * + * 表单按模块 v_b_othercompany 的编辑界面配置(s_module_schema.edit)渲染: + * 字段 / 分组 / 栅格 / 必填 / 只读全部来自模块元数据,页面不硬编码字段, + * 与列表(FmsModuleListPage)取同一套元数据口径。 + * + * 保存走「表单数据与变更检测」的统一主流程: + * handleSave → beforeSave → buildSaveData → saveObjectApi → afterSave; + * 主表用 maindata / maindata_org 成对命名,编辑态只提交与基线的字段级差异。 + * + * 新建的两个号:主键 b_id 取雪花号;业务ID b_ywid 按模块的自动编码规则(s_autocode) + * 取号,模块没配自动编码就由用户在表单里录入。 + * 本页只做主表;银行账号明细子表(b_othercompany_accountbank)不在本期范围。 */ +import { computed, onMounted, ref } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { RotateCcw, Save, Trash2 } from '@lucide/vue' +import { Button, Form, FormGroup, FormItem, Message, Modal, Spin, Tag } from '@/components/ui' +import FmsField from '@/components/fms-field/FmsField.vue' +import { deleteApi, loadDataApi, nextCodeApi, nextIdApi, saveObjectApi } from '@/services/api' +import { cloneData, diffRow } from '@/utils/dataChanges' +import { buildGroupI18nKey } from '@/utils/schemaJson' +import { escapeSqlText, isEmptyValue } from '@/components/fms-module-common/queryUtils' +import { parseOptions, formatLocalDateTime } from '@/components/fms-module-common/fieldUtils' +import { loadModuleSchemas, buildEditRender } from '@/components/fms-module-common/schemaRender' +import { usePermissionStore } from '@/stores/permissions' +import { useI18nStore } from '@/stores/i18n' +import { useAuthStore } from '@/stores/auth' +import { useAppStore } from '@/stores/app' + +const MODULE_CODE = 'v_b_othercompany' + +/** 业务ID:not null + 唯一索引;模块配了自动编码就由系统生成,没配则由用户录入 */ +const BUSINESS_ID_FIELD = 'b_ywid' + +/** 审计字段由写入方维护(核心表结构设计 §1.6:不建触发器),界面一律只读 */ +const CREATED_FIELDS = ['b_created_by', 'b_created_at'] +const UPDATED_FIELDS = ['b_updated_by', 'b_updated_at'] + +const route = useRoute() +const router = useRouter() +const permissionStore = usePermissionStore() +const i18nStore = useI18nStore() +const authStore = useAuthStore() +const appStore = useAppStore() + +/** 编辑对象主键;空表示新增(本页签 fullPath 固定,新增保存成功后就地转入编辑态) */ +const recordId = ref(String(route.query.id || '').trim()) + +/* ================= 页面状态 ================= */ +const loading = ref(false) +const loadError = ref('') +const saving = ref(false) +const deleting = ref(false) + +/** 模块元数据(s_module 行 / s_field 行 / 编辑配置还原出的分组与字段行) */ +const moduleMeta = ref(null) +const fields = ref([]) +const groups = ref([]) +const editRows = ref([]) +const keyField = ref('b_id') + +/** 当前草稿与原始基线(成对命名:maindata 可编辑,maindata_org 为库里的原文,新建为 null) */ +const maindata = ref({}) +const maindata_org = ref(null) +/** 新建模式的雪花主键:进入页面即取号,重置沿用同一个号 */ +const newId = ref('') +/** 模块配置的自动编码字段(s_autocode 启用规则);未配置为空串,业务ID 由用户填 */ +const autocodeField = ref('') +/** 新建时按自动编码规则取到的编号(与主键一样,重置沿用同一个) */ +const newAutocode = ref('') + +const isNew = computed(() => !recordId.value) +const pageTitle = computed(() => { + const base = moduleMeta.value?.b_name || '合作伙伴' + return isNew.value ? `新增${base}` : base +}) +const canSave = computed(() => permissionStore.canPower(MODULE_CODE, isNew.value ? 'create' : 'update')) +const canDelete = computed(() => !isNew.value && permissionStore.canPower(MODULE_CODE, 'delete')) + +/** 构造完整合并行(含主键;checkbox 归一为 0/1、空串归一为 null),供脏判断与保存共用 */ +function collectRowData() { + const row = { [keyField.value]: isNew.value ? newId.value : recordId.value } + for (const editRow of editRows.value) { + const fieldName = editRow.field.b_field + if (fieldName === keyField.value) continue + if (isFieldDisabled(editRow)) continue + let value = maindata.value[fieldName] + if (editRow.field.b_type === 'checkbox') value = value ? 1 : 0 + row[fieldName] = value === '' ? null : value + } + return row +} + +/** 未保存状态由草稿与基线比较得出(派生值,不单独存字段) */ +const isDirty = computed(() => { + if (!moduleMeta.value) return false + return diffRow({ + current: collectRowData(), + original: maindata_org.value, + primaryKey: keyField.value, + }).hasChanges +}) + +/* ================= 加载:load → loadNew / loadEdit ================= */ + +/** 页面加载入口:先取模块元数据,再按新建 / 编辑分别初始化 */ +async function load() { + loading.value = true + loadError.value = '' + try { + await loadModuleConfig() + if (isNew.value) await loadNew() + else await loadEdit() + } catch (error) { + loadError.value = error?.message || String(error) + } finally { + loading.value = false + } +} + +/** 模块元数据:s_module + s_field + s_module_schema.edit(与列表页同一口径) */ +async function loadModuleConfig() { + const moduleRes = await loadDataApi( + 's_module', + `b_id = N'${MODULE_CODE}' AND b_module_type = 'data' AND b_canuse = 1`, + 'b_id ASC', + ) + const module = moduleRes.data?.[0] + if (!module) throw new Error(`未找到编码为 ${MODULE_CODE} 的启用数据模块`) + + const [fieldRes, schemas, autocodeRes] = await Promise.all([ + loadDataApi('s_field', `b_module_id = N'${MODULE_CODE}'`, 'b_xh ASC, b_field ASC'), + loadModuleSchemas(MODULE_CODE), + loadDataApi('s_autocode', `b_module_id = N'${MODULE_CODE}' AND b_canuse = 1`, 'b_field ASC'), + ]) + + moduleMeta.value = module + keyField.value = module.b_key_field || 'b_id' + // 自动编码:与后端 nextcode 同一口径,只认第一条启用规则(后端按 b_field 排序取 TOP 1) + autocodeField.value = autocodeRes.data?.[0]?.b_field ? String(autocodeRes.data[0].b_field) : '' + + const fieldByName = new Map() + const validFields = [] + for (const field of fieldRes.data || []) { + if (Number(field.b_canuse) !== 1) continue + validFields.push(field) + fieldByName.set(String(field.b_field), field) + } + fields.value = validFields + + // 编辑配置 → 分组 + 字段行;只保留可见且字段仍启用的行 + const render = buildEditRender(schemas.edit) + groups.value = render.groups + const rows = [] + for (const row of render.rows) { + if (Number(row.b_visible) !== 1) continue + const field = fieldByName.get(String(row.b_field)) + if (!field) continue + rows.push({ ...row, field }) + } + editRows.value = rows +} + +/** 表单初始值:编辑用库里的记录回填,新增取编辑配置的默认值 */ +function buildFormData(record) { + const source = record || {} + const data = {} + for (const editRow of editRows.value) { + const fieldName = editRow.field.b_field + const existing = source[fieldName] + if (existing !== undefined && existing !== null) { + data[fieldName] = existing + } else if (editRow.field.b_type === 'checkbox') { + data[fieldName] = 0 + } else if (isEmptyValue(editRow.b_default_value)) { + const numeric = editRow.field.b_type === 'number' || editRow.field.b_type === 'money' + data[fieldName] = numeric ? null : '' + } else { + data[fieldName] = editRow.b_default_value + } + } + return data +} + +/** 新建:取雪花主键 + 自动编码编号 → 填默认值。b_id 是 bigint 主键,用户不录入 */ +async function loadNew() { + if (!newId.value) newId.value = String((await nextIdApi()).data) + maindata_org.value = null + maindata.value = buildFormData(null) + maindata.value[keyField.value] = newId.value + // 自动编码:配置了规则就由系统生成(取号一次、重置沿用),没配则该字段留给用户填 + if (autocodeField.value) { + if (!newAutocode.value) newAutocode.value = String((await nextCodeApi(MODULE_CODE)).data) + maindata.value[autocodeField.value] = newAutocode.value + } +} + +/** 编辑:按主键读回记录(读视图,写库仍走 s_module.b_save_table) */ +async function loadEdit() { + const viewTable = String(moduleMeta.value?.b_view_table || '') + if (!viewTable) throw new Error('该模块未配置查询视图,无法加载数据') + + const response = await loadDataApi(viewTable, buildKeyCondition(), `${keyField.value} ASC`) + const record = response.data?.[0] + if (!record) throw new Error(`未找到该合作伙伴(${keyField.value} = ${recordId.value})`) + + const loaded = buildFormData(record) + maindata.value = loaded + maindata_org.value = cloneData(loaded) +} + +/** + * 主键条件:雪花 bigint 走裸数字字面量(不经 Number(),避免大整数精度丢失); + * 字符串业务键加单引号但不带 N 前缀 —— 带 N 会把 varchar 列隐式转成 nvarchar,用不上索引。 + */ +function buildKeyCondition() { + const keyDef = fields.value.find((field) => String(field.b_field) === keyField.value) + const numericKey = ['number', 'money'].includes(String(keyDef?.b_type || '')) + const value = recordId.value + return `${keyField.value} = ${numericKey ? value : `'${escapeSqlText(value)}'`}` +} + +/* ================= 保存:handleSave → beforeSave → buildSaveData → afterSave ================= */ +/** 保存过程上下文:beforeSave 阶段准备的数据,buildSaveData / afterSave 阶段消费 */ +let saveCtx = { isNew: false, row: null } + +/** 模块保存表:只使用 s_module.b_save_table(视图表通常不可写) */ +function saveTableName() { + return String(moduleMeta.value?.b_save_table || '') +} + +/** 审计字段:只写模块字段里真实存在的列(新增补创建,保存一律刷新最后修改) */ +function auditFields(isCreating) { + const names = new Set(fields.value.map((field) => String(field.b_field))) + const operator = String(authStore.loginInfo.user?.account || '') + const now = formatLocalDateTime() + const audit = {} + if (isCreating && names.has(CREATED_FIELDS[0])) audit[CREATED_FIELDS[0]] = operator + if (isCreating && names.has(CREATED_FIELDS[1])) audit[CREATED_FIELDS[1]] = now + if (names.has(UPDATED_FIELDS[0])) audit[UPDATED_FIELDS[0]] = operator + if (names.has(UPDATED_FIELDS[1])) audit[UPDATED_FIELDS[1]] = now + return audit +} + +/** 保存前校验与数据准备;返回 false 终止保存 */ +async function beforeSave() { + for (const editRow of editRows.value) { + const fieldName = editRow.field.b_field + // 必填有两个来源:编辑配置的 required;以及未配自动编码时的业务ID + // (b_ywid 是 not null + 唯一索引,没配自动编码就没有系统值,只能由用户录入) + const requiredByConfig = Number(editRow.b_required) === 1 + const requiredByNoAutocode = !autocodeField.value && fieldName === BUSINESS_ID_FIELD + if (!requiredByConfig && !requiredByNoAutocode) continue + if (editRow.field.b_type === 'checkbox') continue + if (isFieldDisabled(editRow)) continue // 只读字段用户填不了,不作必填拦截 + if (isEmptyValue(maindata.value[fieldName])) { + Message.warning(`请填写「${fieldLabel(editRow)}」`) + return false + } + } + if (!saveTableName()) { + Message.error('该模块未配置保存表,无法保存') + return false + } + + const creating = isNew.value + const row = collectRowData() + if (creating) { + row[keyField.value] = newId.value + // 自动编码字段没配进表单时表单里看不到它,这里补进新增行(配置了规则才有值) + if (autocodeField.value && newAutocode.value) row[autocodeField.value] = newAutocode.value + } + Object.assign(row, auditFields(creating)) + saveCtx = { isNew: creating, row } + return true +} + +/** 组装保存请求:新增整行 insert;编辑用 diffRow 只提交变化字段,无变化返回 null */ +function buildSaveData() { + const { isNew: creating, row } = saveCtx + if (!row) return null + const request = { table: saveTableName(), key_field: keyField.value } + if (creating) { + request.inserts = [row] + return [request] + } + const changes = diffRow({ + current: row, + original: maindata_org.value, + primaryKey: keyField.value, + }) + if (!changes.hasChanges) return null + request.updates = changes.updates + return [request] +} + +/** 保存成功后:新增转入编辑态并从服务端重新加载(拿到后端规范化后的值与新基线) */ +async function afterSave() { + const { isNew: creating, row } = saveCtx + Message.success(creating ? '新增成功' : '保存成功') + recordId.value = String(row[keyField.value]) + try { + await loadEdit() + } catch (error) { + loadError.value = error?.message || String(error) + } +} + +async function handleSave() { + if (saving.value) return false + saveCtx = { isNew: false, row: null } + saving.value = true + try { + if (!(await beforeSave())) return false + const saveData = buildSaveData() + if (!saveData) { + // 编辑态无任何字段变化:按规范不调用保存接口 + Message.info('没有需要保存的变更') + return true + } + await saveObjectApi(saveData) + await afterSave() + return true + } catch (error) { + Message.error('保存失败:' + (error?.message || error)) + return false + } finally { + saving.value = false + } +} + +/* ================= 重置 / 删除 ================= */ + +/** 回列表页:记录加载失败与删除成功后的统一出口 */ +function backToList() { + router.push({ name: 'base-othercompany' }) +} + +/** 重置:有基线就回到基线(编辑放弃改动),没有基线就回到默认值(新建,沿用已取的主键) */ +async function handleReset() { + if (!maindata_org.value) { + await loadNew() + return + } + maindata.value = cloneData(maindata_org.value) +} + +function handleDelete() { + if (deleting.value) return + const label = maindata.value.b_name || maindata.value.b_ywid || recordId.value + Modal.confirm({ + title: `确定删除合作伙伴「${label}」?`, + content: '此操作不可恢复。', + okText: '删除', + okButtonProps: { danger: true }, + onOk: async () => { + deleting.value = true + try { + // 统一删除接口:由后端按模块展开级联、执行删除规则(与列表页同一入口) + await deleteApi({ target: 'module', moduleCode: MODULE_CODE, ids: [recordId.value] }) + } catch (error) { + Message.error('删除失败:' + (error?.message || error)) + throw error // 保持确认框打开,便于重试 + } finally { + deleting.value = false + } + Message.success('删除成功') + // 记录已不存在:关掉本页签再回列表,避免留下一个指向已删记录的页签 + appStore.removeVisitedTab(route.fullPath) + backToList() + }, + }) +} + +/* ================= 表单渲染(口径与 FmsModuleEditModal 一致) ================= */ + +/** 编辑分组:按 b_xh 排序,未归组的字段收在最后一组(无标题) */ +const sections = computed(() => { + const byGroup = new Map() + const sortedRows = editRows.value.toSorted((a, b) => (a.b_xh || 0) - (b.b_xh || 0)) + for (const row of sortedRows) { + const groupId = row.b_group_id != null ? String(row.b_group_id) : '' + if (!byGroup.has(groupId)) byGroup.set(groupId, []) + byGroup.get(groupId).push(row) + } + const built = [] + const sortedGroups = groups.value.toSorted((a, b) => (a.b_xh || 0) - (b.b_xh || 0)) + for (const group of sortedGroups) { + const rows = byGroup.get(String(group.b_id)) || [] + if (!rows.length) continue + // 分组标题:节点手填 i18n 优先,未填按分组 id 派生键取译文,无译文回退 b_title + built.push({ + key: `g-${group.b_id}`, + title: i18nStore.t( + group.b_i18n || buildGroupI18nKey(moduleMeta.value?.b_id, 'edit', group.b_id), + (group.b_title || '').trim(), + ), + rows, + }) + } + const ungrouped = byGroup.get('') || [] + if (ungrouped.length) built.push({ key: 'ungrouped', title: '', rows: ungrouped }) + return built +}) + +const hasAnyField = computed(() => editRows.value.length > 0) + +function fieldLabel(row) { + return row.field.b_name || row.field.b_field +} + +/** 只读 / 禁用:主键、审计字段、视图派生列(v_*,保存表里没有这些列)一律不可编辑 */ +function isFieldDisabled(row) { + const fieldName = String(row.field.b_field) + if (fieldName === keyField.value) return true + if (fieldName.startsWith('v_')) return true + if (CREATED_FIELDS.includes(fieldName) || UPDATED_FIELDS.includes(fieldName)) return true + return Number(row.b_readonly) === 1 || Number(row.b_disabled) === 1 +} + +function isFieldRequired(row) { + return Number(row.b_required) === 1 && !isFieldDisabled(row) +} + +/** 栅格跨度:b_colspan ∈ [1,24],非法回退整行 */ +function spanOf(row) { + const span = Number(row.b_colspan) + return span >= 1 && span <= 24 ? span : 24 +} + +/** 只读态统一降级为 Input(checkbox 保留勾选框)只展示原值 */ +function controlType(row) { + if (isFieldDisabled(row)) return row.field.b_type === 'checkbox' ? 'checkbox' : 'input' + return row.field.b_type +} + +function controlPropsOf(row) { + if (isFieldDisabled(row)) return {} + const type = row.field.b_type + const extra = {} + if (type === 'select') { + // V2:选项来自编辑配置节点;兼容旧字段行的 b_options + extra.options = parseOptions({ options: row.options, b_options: row.field.b_options }).options + } + if (row.b_placeholder && type !== 'checkbox' && type !== 'date' && type !== 'datetime') { + extra.placeholder = row.b_placeholder + } + return extra +} + +onMounted(load) + +.oc-detail__head { + padding: 8px 12px; + border-bottom: 1px solid var(--fms-border); +} + +/* 卡头左侧:标题 + 主键 + 未保存标记,超长省略,不挤走右侧动作区 */ +.oc-detail__meta { + display: flex; + gap: 8px; + align-items: center; + min-width: 0; +} + +.oc-detail__code { + flex: none; + font-family: var(--fms-font-mono, monospace); + font-size: 12px; + color: var(--fms-text-secondary); +} + +.oc-detail__body { + flex: 1; + min-height: 0; + padding: 12px; + overflow-x: hidden; + overflow-y: auto; +} + +.oc-detail__state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 160px; + color: var(--fms-text-secondary); + font-size: var(--fms-font-size); + text-align: center; +} + +.oc-detail__state-actions { + display: flex; + gap: 8px; +} + +/* 表单限宽居中:超宽屏下不让字段行被拉得过长,阅读焦点保持在中间 */ +.oc-detail__form { + max-width: 1140px; + margin: 0 auto; +} + \ No newline at end of file diff --git a/code/fms/fms-vue/src/views/base/othercompany/index.vue b/code/fms/fms-vue/src/views/base/othercompany/index.vue index 0cfefab6..78f00b7f 100644 --- a/code/fms/fms-vue/src/views/base/othercompany/index.vue +++ b/code/fms/fms-vue/src/views/base/othercompany/index.vue @@ -180,7 +180,7 @@ function onCategoryToolbarAdd() { onCategoryAdd({ parentId: selected || null }) } -/* ================= 新增:跳转详情页 ================= */ +/* ================= 新增 / 编辑:跳转详情页 ================= */ const router = useRouter() /** 新增:不带参数跳详情页;编辑同页完成,用 ?id=xxx 传主键 */ @@ -188,6 +188,23 @@ function onCreate() { router.push({ name: 'base-othercompany-detail' }) } +const canUpdateRow = computed(() => permissionStore.canPower(MODULE_CODE, 'update')) + +/** 行操作:编辑跳详情页(新增与编辑都在详情页完成) */ +const rowActions = computed(() => { + if (!canUpdateRow.value) return [] + return [{ key: 'edit', label: '编辑', icon: Pencil, onClick: (row) => onEdit(row) }] +}) + +function onEdit(row) { + const id = String(row?.b_id ?? '').trim() + if (!id) { + Message.warning('该行没有主键,无法打开详情') + return + } + router.push({ name: 'base-othercompany-detail', query: { id } }) +} + /* ================= 行多选与批量删除 ================= */ const selectedRowKeys = ref([]) @@ -482,6 +499,8 @@ void loadCategories() :fixed-search-condition="categoryFilterCondition" :column-extensions="columnExtensions" :row-selection="rowSelection" + :row-actions="rowActions" + :inline-action-limit="1" >