20260924172548
This commit is contained in:
1 parent
0405cd198a
commit
27e49e2e45
22 files changed
+1786
-346
No files matched your search
@@ -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` 定位),并新增一条「四节同页平铺 + 表格定高」断言。
|
||||
@@ -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 <dir> -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 可直接用。
|
||||
@@ -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 <dir> -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。
|
||||
@@ -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 可直接用。
|
||||
@@ -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 文本测量会挂)。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,125 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>查询配置面板预览(调试用,验证完删除)</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
background: #f1f3f6;
|
||||
}
|
||||
/* 复刻 module-management 右栏的 .mm-config-pane:面板就挂在这个滚动容器里 */
|
||||
#pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px 16px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="pane"></div>
|
||||
<script type="module">
|
||||
import { createApp, h, ref } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { registerFeature, useAreaSelection } from 'stk-table-vue'
|
||||
import 'stk-table-vue/lib/style.css'
|
||||
import '/src/styles/index.css'
|
||||
import ModuleQueryConfigPanel from '/src/views/module/module-management/ModuleQueryConfigPanel.vue'
|
||||
|
||||
registerFeature(useAreaSelection)
|
||||
|
||||
const fields = [
|
||||
{ b_field: 'b_no', b_name: '单号', b_type: 'input', b_canuse: 1 },
|
||||
{ b_field: 'b_company', b_name: '客户名称', b_type: 'input', b_canuse: 1 },
|
||||
{ b_field: 'b_state', b_name: '运行状态', b_type: 'select', b_canuse: 1 },
|
||||
{ b_field: 'b_audit_state', b_name: '审核状态', b_type: 'select', b_canuse: 1 },
|
||||
{ b_field: 'b_amount', b_name: '金额', b_type: 'money', b_canuse: 1 },
|
||||
{ b_field: 'b_date', b_name: '业务日期', b_type: 'date', b_canuse: 1 },
|
||||
{ b_field: 'b_owner', b_name: '业务员', b_type: 'select', b_canuse: 1 },
|
||||
{ b_field: 'b_remark', b_name: '备注', b_type: 'textarea', b_canuse: 0 },
|
||||
]
|
||||
|
||||
const schema = ref({
|
||||
schemaVersion: 3,
|
||||
views: [
|
||||
{ code: 'all', label: '全部', group: '', order: 10, showCount: true, isDefault: true, conditionSql: '' },
|
||||
{
|
||||
code: 'pending',
|
||||
label: '待处理',
|
||||
group: '运行状态',
|
||||
order: 20,
|
||||
showCount: true,
|
||||
conditionSql: "[b_state] = N'待处理'",
|
||||
},
|
||||
{
|
||||
code: 'sailed',
|
||||
label: '已开航',
|
||||
group: '运行状态',
|
||||
order: 30,
|
||||
showCount: false,
|
||||
conditionSql: "[b_state] = N'已开航'",
|
||||
},
|
||||
{
|
||||
code: 'audit_wait',
|
||||
label: '待审核',
|
||||
group: '审核状态',
|
||||
order: 40,
|
||||
showCount: true,
|
||||
conditionSql: "[b_audit_state] = N'待审核' AND [b_amount] > 1000",
|
||||
},
|
||||
{
|
||||
code: 'audit_done',
|
||||
label: '已审核',
|
||||
group: '审核状态',
|
||||
order: 50,
|
||||
showCount: false,
|
||||
conditionSql: "[b_audit_state] = N'已审核'",
|
||||
},
|
||||
],
|
||||
keywordSearch: { fields: ['b_no', 'b_company'] },
|
||||
quickFilters: [
|
||||
{ field: 'b_state', order: 10 },
|
||||
{ field: 'b_date', order: 20 },
|
||||
{ field: 'b_owner', order: 30 },
|
||||
],
|
||||
queryFields: {
|
||||
b_no: { component: 'input', operator: 'like' },
|
||||
b_company: { component: 'input', operator: 'like' },
|
||||
b_state: { component: 'select', operator: 'eq' },
|
||||
b_amount: { component: 'number', operator: 'ge' },
|
||||
b_date: { component: 'date', operator: 'between' },
|
||||
b_owner: { component: 'select', operator: 'eq' },
|
||||
b_remark: { component: 'input', operator: 'like' },
|
||||
},
|
||||
})
|
||||
|
||||
const Preview = {
|
||||
setup() {
|
||||
return () =>
|
||||
h(ModuleQueryConfigPanel, {
|
||||
moduleId: 'demo',
|
||||
fields,
|
||||
loading: false,
|
||||
modelValue: schema.value,
|
||||
'onUpdate:modelValue': (value) => {
|
||||
schema.value = value
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const pinia = createPinia()
|
||||
const app = createApp(Preview)
|
||||
app.use(pinia)
|
||||
app.mount('#pane')
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 能按参数精确销毁其实例,且不同参数天然多个独立缓存。
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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 里的一项)。
|
||||
|
||||
@@ -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)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="oc-detail-page" />
|
||||
<div class="fms-page">
|
||||
<div class="fms-card oc-detail">
|
||||
<div class="fms-card__head oc-detail__head">
|
||||
<div class="oc-detail__meta">
|
||||
<span class="fms-card__title">{{ pageTitle }}</span>
|
||||
<span v-if="recordId" class="oc-detail__code">{{ recordId }}</span>
|
||||
<Tag v-if="moduleMeta && isDirty" variant="secondary">未保存</Tag>
|
||||
</div>
|
||||
<div class="fms-card__actions">
|
||||
<Button
|
||||
v-if="canSave"
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="loading || !!loadError || !isDirty"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Save :size="14" />
|
||||
保存
|
||||
</Button>
|
||||
<Button type="outline" :disabled="loading || !!loadError || saving" @click="handleReset">
|
||||
<RotateCcw :size="14" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDelete"
|
||||
type="danger-outline"
|
||||
:loading="deleting"
|
||||
:disabled="saving"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oc-detail__body">
|
||||
<div v-if="loading" class="oc-detail__state">
|
||||
<Spin />
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadError" class="oc-detail__state">
|
||||
<span>{{ loadError }}</span>
|
||||
<div class="oc-detail__state-actions">
|
||||
<Button type="outline" @click="load">重新加载</Button>
|
||||
<Button type="primary" @click="backToList">返回列表</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!hasAnyField" class="oc-detail__state">
|
||||
该模块未配置可编辑字段(请在模块管理的表单设计中添加)
|
||||
</div>
|
||||
|
||||
<Form v-else class="oc-detail__form" :model="maindata" :columns="24" :gap="{ row: 16, col: 12 }">
|
||||
<FormGroup
|
||||
v-for="section in sections"
|
||||
:key="section.key"
|
||||
:title="section.title"
|
||||
:collapsible="false"
|
||||
:columns="24"
|
||||
>
|
||||
<FormItem
|
||||
v-for="row in section.rows"
|
||||
:key="row.field.b_field"
|
||||
:name="row.field.b_field"
|
||||
:label="fieldLabel(row)"
|
||||
:required="isFieldRequired(row)"
|
||||
:span="spanOf(row)"
|
||||
>
|
||||
<FmsField
|
||||
v-model="maindata[row.field.b_field]"
|
||||
:type="controlType(row)"
|
||||
:disabled="isFieldDisabled(row)"
|
||||
:readonly="isFieldDisabled(row)"
|
||||
:control-props="controlPropsOf(row)"
|
||||
/>
|
||||
</FormItem>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.oc-detail-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* 整页一张卡:卡头固定(标题 + 保存/重置/删除),正文区自己滚动 */
|
||||
.oc-detail {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@@ -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"
|
||||
>
|
||||
<template #actions="{ loading }">
|
||||
<Button v-if="canCreateRow" type="primary" :disabled="loading" @click="onCreate">
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 查询配置面板(s_module_schema.b_schema_type = 'query',schemaVersion 3)。
|
||||
* 四个分节对应列表页查询入口的四个来源:
|
||||
* - 字段查询元数据(queryFields):字段 / 查询组件 / 操作符 / 默认查询值
|
||||
* 四个分节对应列表页查询入口的四个来源,自上而下平铺在一页里(不再用页签分组,
|
||||
* 避免「改一处要来回切页签」;整页由外层配置区统一滚动):
|
||||
* - 更多条件字段(queryFields):字段 / 查询组件 / 操作符 / 默认查询值
|
||||
* - 工作队列页签(views):分组 / 编码 / 名称 / 顺序 / 数量 / 默认 / 条件 SQL
|
||||
* - 关键词搜索(keywordSearch):占位文案 / 触发方式 / 可搜索字段
|
||||
* - 快捷筛选(quickFilters):字段 / 顺序
|
||||
* - 关键词字段(keywordSearch):可搜索字段
|
||||
* - 快捷筛选字段(quickFilters):字段 / 顺序
|
||||
* 「更多筛选」不需要配置:运行时把未进快捷筛选的查询字段按配置顺序自动铺进抽屉。
|
||||
*
|
||||
* 页签分组:同一分组的页签显示为一行,组内单选(可取消),组间条件叠加(且)。
|
||||
@@ -20,7 +21,7 @@
|
||||
*/
|
||||
import { computed, h, nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import { Eye, Plus, Trash2 } from '@lucide/vue'
|
||||
import { Button, Message, Modal, Select, Tabs, TabPane } from '@/components/ui'
|
||||
import { Button, Checkbox, Input, Message, Modal, Select } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import FmsWorkQueueTabs from '@/components/fms-module-list/FmsWorkQueueTabs.vue'
|
||||
import FmsKeywordSearch from '@/components/fms-module-list/FmsKeywordSearch.vue'
|
||||
@@ -57,7 +58,6 @@ const props = defineProps({
|
||||
/** 查询配置对象(query JSON),父级 v-model 绑定 */
|
||||
const querySchema = defineModel({ type: Object, default: () => createDefaultSchema('query') })
|
||||
|
||||
const activeTab = ref('fields')
|
||||
/** 编辑器草稿:四块内容一起承载,任何编辑都经它回写 */
|
||||
const draft = ref(null)
|
||||
let syncedSchema = null
|
||||
@@ -96,6 +96,20 @@ watch(
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
/* ================= 表格高度 =================
|
||||
* 分节平铺后表格不再靠 flex 撑满:按行数给一个「够用即止」的高度,
|
||||
* 超过上限才在表格内部滚动;整页高度由外层配置区统一滚动。
|
||||
* 行高与表头高取自 FmsTable 的默认值(rowHeight / headerRowHeight)。 */
|
||||
const TABLE_HEADER_HEIGHT = 34
|
||||
const TABLE_ROW_HEIGHT = 30
|
||||
const TABLE_MIN_HEIGHT = 140
|
||||
const TABLE_MAX_HEIGHT = 420
|
||||
|
||||
function tableHeightOf(rowCount) {
|
||||
const content = TABLE_HEADER_HEIGHT + Math.max(rowCount, 0) * TABLE_ROW_HEIGHT + 2
|
||||
return `${Math.min(Math.max(content, TABLE_MIN_HEIGHT), TABLE_MAX_HEIGHT)}px`
|
||||
}
|
||||
|
||||
/* ================= 字段选项 ================= */
|
||||
const fieldOptions = computed(() => {
|
||||
const options = []
|
||||
@@ -123,6 +137,7 @@ function fieldName(field) {
|
||||
|
||||
/* ================= 分节一:字段查询元数据 ================= */
|
||||
const fieldRows = computed(() => draft.value?.fields || [])
|
||||
const fieldTableHeight = computed(() => tableHeightOf(fieldRows.value.length))
|
||||
const tableRef = ref(null)
|
||||
const activeKey = ref(null)
|
||||
const selectedRowKeys = ref([])
|
||||
@@ -254,8 +269,22 @@ const fieldColumns = shallowRef([
|
||||
|
||||
/* ================= 分节二:工作队列页签 ================= */
|
||||
const viewRows = computed(() => draft.value?.views || [])
|
||||
const viewGroupNames = computed(() => {
|
||||
const names = []
|
||||
for (const row of viewRows.value) {
|
||||
const name = String(row.group ?? '').trim()
|
||||
if (name && !names.includes(name)) names.push(name)
|
||||
}
|
||||
return names
|
||||
})
|
||||
/** 分组行 + 页签行都要占位,按实际渲染行数给高度 */
|
||||
const viewTableHeight = computed(() =>
|
||||
tableHeightOf(viewGroupNames.value.length + viewRows.value.length),
|
||||
)
|
||||
const groupCreateOpen = ref(false)
|
||||
const groupCreateText = ref('')
|
||||
|
||||
function addViewRow() {
|
||||
function addViewRow(group = '') {
|
||||
draft.value = {
|
||||
...draft.value,
|
||||
views: [
|
||||
@@ -265,7 +294,7 @@ function addViewRow() {
|
||||
_node: {},
|
||||
code: '',
|
||||
label: '',
|
||||
group: '',
|
||||
group: String(group || ''),
|
||||
order: (viewRows.value.length + 1) * 10,
|
||||
showCount: false,
|
||||
isDefault: false,
|
||||
@@ -276,12 +305,36 @@ function addViewRow() {
|
||||
}
|
||||
}
|
||||
|
||||
function openGroupCreate() {
|
||||
groupCreateText.value = ''
|
||||
groupCreateOpen.value = true
|
||||
}
|
||||
|
||||
function confirmGroupCreate() {
|
||||
const name = groupCreateText.value.trim()
|
||||
if (!name) {
|
||||
Message.error('请输入页签组名称')
|
||||
return
|
||||
}
|
||||
if (viewGroupNames.value.includes(name)) {
|
||||
Message.error('该页签组已存在')
|
||||
return
|
||||
}
|
||||
addViewRow(name)
|
||||
groupCreateOpen.value = false
|
||||
}
|
||||
|
||||
function removeViewRow(row) {
|
||||
draft.value = { ...draft.value, views: viewRows.value.filter((item) => item !== row) }
|
||||
const rowKey = String(row?._rowKey ?? '')
|
||||
draft.value = {
|
||||
...draft.value,
|
||||
views: viewRows.value.filter((item) => String(item._rowKey ?? '') !== rowKey),
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认页签每组至多一个:勾上即清掉同组其他行 */
|
||||
function onViewCellChange(row, col) {
|
||||
if (col?.dataIndex === 'group') row.group = String(row.group ?? '').trim()
|
||||
if (col?.dataIndex === 'isDefault' && row.isDefault) {
|
||||
const group = String(row.group ?? '')
|
||||
for (const item of viewRows.value) {
|
||||
@@ -301,7 +354,9 @@ const conditionTargetRow = computed(() =>
|
||||
)
|
||||
|
||||
/** 未能转成 SQL 的旧版条件(字段已不存在):填写 SQL 后即被替换 */
|
||||
const conditionLegacyCount = computed(() => (conditionTargetRow.value?.legacyConditions || []).length)
|
||||
const conditionLegacyCount = computed(
|
||||
() => (conditionTargetRow.value?.legacyConditions || []).length,
|
||||
)
|
||||
|
||||
function conditionSummaryOf(row) {
|
||||
const sql = String(row.conditionSql ?? '').trim()
|
||||
@@ -331,14 +386,73 @@ function confirmCondition() {
|
||||
draft.value = {
|
||||
...draft.value,
|
||||
views: viewRows.value.map((row) =>
|
||||
row === target ? { ...row, conditionSql: text, legacyConditions: [] } : row,
|
||||
String(row._rowKey ?? '') === String(target._rowKey ?? '')
|
||||
? { ...row, conditionSql: text, legacyConditions: [] }
|
||||
: row,
|
||||
),
|
||||
}
|
||||
conditionOpen.value = false
|
||||
}
|
||||
|
||||
function patchViewRow(row, key, value) {
|
||||
if (row?._isGroup) return
|
||||
const rowKey = String(row._rowKey ?? '')
|
||||
const nextRows = viewRows.value.map((item) =>
|
||||
String(item._rowKey ?? '') === rowKey ? { ...item, [key]: value } : item,
|
||||
)
|
||||
if (key === 'isDefault' && value) {
|
||||
const group = String(row.group ?? '')
|
||||
for (const item of nextRows) {
|
||||
if (String(item._rowKey ?? '') !== rowKey && String(item.group ?? '') === group) {
|
||||
item.isDefault = false
|
||||
}
|
||||
}
|
||||
}
|
||||
draft.value = { ...draft.value, views: nextRows }
|
||||
}
|
||||
|
||||
function inputCell(row, key, placeholder = '') {
|
||||
if (row?._isGroup) return h('span', { class: 'mm-qdesign__group-cell' })
|
||||
return h(Input, {
|
||||
modelValue: row[key] ?? '',
|
||||
placeholder,
|
||||
class: 'mm-qdesign__view-input',
|
||||
'onUpdate:modelValue': (value) => patchViewRow(row, key, value),
|
||||
onMousedown: (event) => event.stopPropagation(),
|
||||
})
|
||||
}
|
||||
|
||||
function checkboxCell(row, key) {
|
||||
if (row?._isGroup) return null
|
||||
return h(Checkbox, {
|
||||
modelValue: Boolean(row[key]),
|
||||
'onUpdate:modelValue': (value) => patchViewRow(row, key, value),
|
||||
onMousedown: (event) => event.stopPropagation(),
|
||||
})
|
||||
}
|
||||
|
||||
function actionCell(row) {
|
||||
if (row?._isGroup) return null
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'mm-qdesign__row-delete',
|
||||
title: '删除页签',
|
||||
onMousedown: (event) => event.stopPropagation(),
|
||||
onClick: (event) => {
|
||||
event.stopPropagation()
|
||||
removeViewRow(row)
|
||||
},
|
||||
},
|
||||
h(Trash2, { size: 14 }),
|
||||
)
|
||||
}
|
||||
|
||||
function conditionCell(row) {
|
||||
if (row?._isGroup) return null
|
||||
const summary = conditionSummaryOf(row)
|
||||
const label = String(row.conditionSql ?? '').trim() ? summary : '编辑 SQL'
|
||||
// mousedown 也拦下:否则点按钮会连带把整行切进编辑态(表格的激活编辑走 mousedown)
|
||||
const stop = (event) => event.stopPropagation()
|
||||
return h(
|
||||
@@ -353,35 +467,88 @@ function conditionCell(row) {
|
||||
openConditionEditor(row)
|
||||
},
|
||||
},
|
||||
summary,
|
||||
label,
|
||||
)
|
||||
}
|
||||
|
||||
const viewTableData = computed(() => {
|
||||
// 没有命名分组时保持平铺,兼容未分组的旧配置;一旦存在分组就启用树形视图。
|
||||
if (!viewGroupNames.value.length) return viewRows.value
|
||||
const groups = []
|
||||
const groupMap = new Map()
|
||||
for (const row of viewRows.value) {
|
||||
const name = String(row.group ?? '').trim() || '未分组'
|
||||
let group = groupMap.get(name)
|
||||
if (!group) {
|
||||
group = {
|
||||
_rowKey: `view-group:${name}`,
|
||||
_isGroup: true,
|
||||
group: name,
|
||||
treeLabel: name,
|
||||
children: [],
|
||||
}
|
||||
groupMap.set(name, group)
|
||||
groups.push(group)
|
||||
}
|
||||
group.children.push({ ...row, treeLabel: '' })
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function viewRowClassName(row) {
|
||||
return row?._isGroup ? 'mm-qdesign__view-group-row' : ''
|
||||
}
|
||||
|
||||
const viewColumns = shallowRef([
|
||||
{ dataIndex: 'group', title: '分组', width: 100, editor: { type: 'input' } },
|
||||
{ dataIndex: 'code', title: '编码', width: 120, editor: { type: 'input' } },
|
||||
{ dataIndex: 'label', title: '名称', width: 120, editor: { type: 'input' } },
|
||||
{ dataIndex: 'order', title: '顺序', width: 76, editor: { type: 'input' } },
|
||||
{
|
||||
type: 'tree-node',
|
||||
dataIndex: 'treeLabel',
|
||||
title: '页签组 / 名称',
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
dataIndex: 'label',
|
||||
title: '页签名称',
|
||||
width: 160,
|
||||
customCell: ({ row }) => inputCell(row, 'label', '例如:未开航'),
|
||||
},
|
||||
{
|
||||
dataIndex: 'conditionSql',
|
||||
title: 'SQL 条件(优先)',
|
||||
width: 320,
|
||||
customCell: ({ row }) => conditionCell(row),
|
||||
},
|
||||
{
|
||||
dataIndex: 'code',
|
||||
title: '编码',
|
||||
width: 140,
|
||||
customCell: ({ row }) => inputCell(row, 'code', 'not_sailed'),
|
||||
},
|
||||
{
|
||||
dataIndex: 'order',
|
||||
title: '顺序',
|
||||
width: 76,
|
||||
customCell: ({ row }) => inputCell(row, 'order'),
|
||||
},
|
||||
{
|
||||
dataIndex: 'showCount',
|
||||
title: '数量',
|
||||
width: 64,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox' },
|
||||
customCell: ({ row }) => checkboxCell(row, 'showCount'),
|
||||
},
|
||||
{
|
||||
dataIndex: 'isDefault',
|
||||
title: '默认',
|
||||
width: 64,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox' },
|
||||
customCell: ({ row }) => checkboxCell(row, 'isDefault'),
|
||||
},
|
||||
{ dataIndex: 'conditionSql', title: '条件 SQL', width: 280, customCell: ({ row }) => conditionCell(row) },
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '',
|
||||
width: 60,
|
||||
actions: [{ type: 'delete', label: '删除', onClick: (row) => removeViewRow(row) }],
|
||||
customCell: ({ row }) => actionCell(row),
|
||||
},
|
||||
])
|
||||
|
||||
@@ -394,6 +561,7 @@ function patchKeyword(patch) {
|
||||
|
||||
/* ================= 分节四:快捷筛选 ================= */
|
||||
const quickRows = computed(() => draft.value?.quickFilters || [])
|
||||
const quickTableHeight = computed(() => tableHeightOf(quickRows.value.length))
|
||||
|
||||
function addQuickRow() {
|
||||
draft.value = {
|
||||
@@ -456,9 +624,8 @@ const previewMoreItemByField = computed(
|
||||
|
||||
const previewFilterCount = computed(
|
||||
() =>
|
||||
Object.values(previewFilterDraft.value || {}).filter((entry) =>
|
||||
hasConditionValue(entry?.value),
|
||||
).length,
|
||||
Object.values(previewFilterDraft.value || {}).filter((entry) => hasConditionValue(entry?.value))
|
||||
.length,
|
||||
)
|
||||
|
||||
/** 预览的 search_condition:页签(选中的各组)+ 关键词 + 快捷筛选 + 更多筛选,与运行时同一套 AST */
|
||||
@@ -481,10 +648,7 @@ const previewHasEntry = computed(() => {
|
||||
const data = previewData.value
|
||||
if (!data) return false
|
||||
return Boolean(
|
||||
data.views.length ||
|
||||
data.keywordSearch ||
|
||||
data.quickFilters.length ||
|
||||
data.moreFilters.length,
|
||||
data.views.length || data.keywordSearch || data.quickFilters.length || data.moreFilters.length,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -513,14 +677,23 @@ function openPreview() {
|
||||
<div class="mm-qdesign">
|
||||
<div class="mm-qdesign__main">
|
||||
<div class="mm-qdesign__head">
|
||||
<div class="mm-qdesign__head-copy">
|
||||
<strong>查询入口配置</strong>
|
||||
<span>页签、关键词和快捷条件共同组成列表页的查询入口</span>
|
||||
</div>
|
||||
<Button type="outline" :disabled="loading" @click="openPreview">
|
||||
<Eye :size="14" /> 预览
|
||||
<Eye :size="14" /> 预览查询入口
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs v-model="activeTab" class="mm-qdesign__tabs">
|
||||
<TabPane tab-key="fields" label="字段查询元数据">
|
||||
<div class="mm-qdesign__toolbar">
|
||||
<!-- 一、更多条件字段 -->
|
||||
<section class="mm-qdesign__section mm-qdesign__section--fields">
|
||||
<div class="mm-qdesign__section-head">
|
||||
<div class="mm-qdesign__section-title">
|
||||
<strong>更多条件字段</strong>
|
||||
<span>未进快捷筛选的查询字段,运行时按这里的顺序铺进「更多筛选」抽屉</span>
|
||||
</div>
|
||||
<div class="mm-qdesign__section-actions">
|
||||
<Button type="primary" :disabled="loading" @click="addFieldRow">
|
||||
<Plus :size="14" /> 新增字段
|
||||
</Button>
|
||||
@@ -532,74 +705,132 @@ function openPreview() {
|
||||
<Trash2 :size="14" /> 批量删除
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mm-qdesign__body">
|
||||
<FmsTable
|
||||
ref="tableRef"
|
||||
v-model:columns="fieldColumns"
|
||||
:data-source="fieldRows"
|
||||
:editable="!loading"
|
||||
:row-selection="fieldRowSelection"
|
||||
row-key="_rowKey"
|
||||
:active-row-key="activeKey"
|
||||
@row-click="(_, row) => (activeKey = String(row._rowKey))"
|
||||
@change="onCellChange"
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
</div>
|
||||
<div class="mm-qdesign__table" :style="{ height: fieldTableHeight }">
|
||||
<FmsTable
|
||||
ref="tableRef"
|
||||
v-model:columns="fieldColumns"
|
||||
:data-source="fieldRows"
|
||||
:editable="!loading"
|
||||
:row-selection="fieldRowSelection"
|
||||
row-key="_rowKey"
|
||||
:active-row-key="activeKey"
|
||||
@row-click="(_, row) => (activeKey = String(row._rowKey))"
|
||||
@change="onCellChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TabPane tab-key="views" label="工作队列页签">
|
||||
<div class="mm-qdesign__toolbar">
|
||||
<Button type="primary" :disabled="loading" @click="addViewRow">
|
||||
<!-- 二、工作队列页签 -->
|
||||
<section class="mm-qdesign__section mm-qdesign__section--views">
|
||||
<div class="mm-qdesign__section-head">
|
||||
<div class="mm-qdesign__section-title">
|
||||
<strong>工作队列页签</strong>
|
||||
<span>
|
||||
不同组可同时选择并按 AND 叠加;同组页签单选。条件直接写 SQL,例如
|
||||
<code>b_state = N'未开航'</code>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mm-qdesign__section-actions">
|
||||
<Button type="primary" :disabled="loading" @click="addViewRow()">
|
||||
<Plus :size="14" /> 新增页签
|
||||
</Button>
|
||||
<Button type="outline" :disabled="loading" @click="openGroupCreate">
|
||||
<Plus :size="14" /> 新增页签组
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mm-qdesign__body">
|
||||
<FmsTable
|
||||
v-model:columns="viewColumns"
|
||||
:data-source="viewRows"
|
||||
:editable="!loading"
|
||||
row-key="_rowKey"
|
||||
@change="onViewCellChange"
|
||||
</div>
|
||||
<div v-if="viewGroupNames.length" class="view-group-bar">
|
||||
<span class="view-group-bar__label">快速新增到:</span>
|
||||
<Button
|
||||
v-for="group in viewGroupNames"
|
||||
:key="group"
|
||||
type="ghost"
|
||||
size="small"
|
||||
:disabled="loading"
|
||||
@click="addViewRow(group)"
|
||||
>
|
||||
<Plus :size="12" /> {{ group }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mm-qdesign__table" :style="{ height: viewTableHeight }">
|
||||
<FmsTable
|
||||
v-model:columns="viewColumns"
|
||||
:data-source="viewTableData"
|
||||
:editable="false"
|
||||
:tree-config="{ defaultExpandAll: true }"
|
||||
:row-class-name="viewRowClassName"
|
||||
row-key="_rowKey"
|
||||
@change="onViewCellChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 三、关键词字段 -->
|
||||
<section class="mm-qdesign__section mm-qdesign__section--keyword">
|
||||
<div class="mm-qdesign__section-head">
|
||||
<div class="mm-qdesign__section-title">
|
||||
<strong>关键词字段</strong>
|
||||
<span>勾选的字段参与关键词模糊匹配,占位文案按字段名在运行时生成</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="keyword-form">
|
||||
<div class="keyword-form__item">
|
||||
<span class="keyword-form__label">可搜索字段</span>
|
||||
<Select
|
||||
:model-value="keywordDraft.fields"
|
||||
:options="fieldOptions"
|
||||
multiple
|
||||
placeholder="选择字段"
|
||||
@update:model-value="patchKeyword({ fields: $event || [] })"
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TabPane tab-key="keyword" label="关键词搜索">
|
||||
<div class="keyword-form">
|
||||
<div class="keyword-form__item">
|
||||
<span class="keyword-form__label">可搜索字段</span>
|
||||
<Select
|
||||
:model-value="keywordDraft.fields"
|
||||
:options="fieldOptions"
|
||||
multiple
|
||||
placeholder="选择字段"
|
||||
@update:model-value="patchKeyword({ fields: $event || [] })"
|
||||
/>
|
||||
</div>
|
||||
<!-- 四、快捷筛选字段 -->
|
||||
<section class="mm-qdesign__section mm-qdesign__section--quick">
|
||||
<div class="mm-qdesign__section-head">
|
||||
<div class="mm-qdesign__section-title">
|
||||
<strong>快捷筛选字段</strong>
|
||||
<span>直接铺在列表页查询卡上的字段,按顺序号排列</span>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab-key="quick" label="快捷筛选">
|
||||
<div class="mm-qdesign__toolbar">
|
||||
<div class="mm-qdesign__section-actions">
|
||||
<Button type="primary" :disabled="loading" @click="addQuickRow">
|
||||
<Plus :size="14" /> 新增快捷筛选
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mm-qdesign__body">
|
||||
<FmsTable
|
||||
v-model:columns="quickColumns"
|
||||
:data-source="quickRows"
|
||||
:editable="!loading"
|
||||
row-key="_rowKey"
|
||||
@change="onQuickCellChange"
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div class="mm-qdesign__table" :style="{ height: quickTableHeight }">
|
||||
<FmsTable
|
||||
v-model:columns="quickColumns"
|
||||
:data-source="quickRows"
|
||||
:editable="!loading"
|
||||
row-key="_rowKey"
|
||||
@change="onQuickCellChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 页签组没有独立持久化表,创建组时先生成一条属于该组的页签,填写名称/编码/SQL 后即可生效。 -->
|
||||
<Modal v-model:open="groupCreateOpen" title="新增页签组" width="420px" @ok="confirmGroupCreate">
|
||||
<div class="group-create-form">
|
||||
<span class="group-create-form__label">组名称</span>
|
||||
<Input v-model="groupCreateText" placeholder="例如:运行状态、审核状态" />
|
||||
</div>
|
||||
<p class="group-create-form__hint">
|
||||
创建后会自动新增一条页签,请继续填写页签名称和 SQL 条件。
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
<!-- 页签条件 SQL 编辑 -->
|
||||
<Modal v-model:open="conditionOpen" title="页签条件(SQL)" width="760px" @ok="confirmCondition">
|
||||
<Modal
|
||||
v-model:open="conditionOpen"
|
||||
title="页签条件(SQL)"
|
||||
width="760px"
|
||||
@ok="confirmCondition"
|
||||
>
|
||||
<textarea
|
||||
v-model="conditionText"
|
||||
class="mm-qdesign__sql-input"
|
||||
@@ -661,11 +892,11 @@ function openPreview() {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 分节配置面板:外框与列表/表单设计器一致;
|
||||
四个分节(字段查询元数据 / 工作队列页签 / 关键词搜索 / 快捷筛选)共用一份全宽内容区 */
|
||||
四个分节(更多条件字段 / 工作队列页签 / 关键词字段 / 快捷筛选字段)自上而下平铺,
|
||||
面板高度随内容增长(flex: 1 0 auto),滚动交给外层的配置区。 */
|
||||
.mm-qdesign {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex: 1 0 auto;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
background: var(--fms-card);
|
||||
@@ -678,43 +909,165 @@ function openPreview() {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 10px 12px;
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.mm-qdesign__head {
|
||||
display: flex;
|
||||
flex: none;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.mm-qdesign__tabs {
|
||||
.mm-qdesign__head-copy {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.fms-tabs-content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mm-qdesign__toolbar {
|
||||
.mm-qdesign__head-copy strong {
|
||||
color: var(--fms-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mm-qdesign__head-copy span {
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 分节:首节不画顶部分隔线,其余节与上一节之间用一条细线断开 */
|
||||
.mm-qdesign__section {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 0 4px;
|
||||
border-top: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
.mm-qdesign__section--fields {
|
||||
padding-top: 4px;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.mm-qdesign__section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mm-qdesign__section-title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mm-qdesign__section-title strong {
|
||||
color: var(--fms-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mm-qdesign__section-title span {
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mm-qdesign__section-title code {
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--fms-secondary-hover);
|
||||
color: var(--fms-text);
|
||||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.mm-qdesign__section-actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 0 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mm-qdesign__body {
|
||||
.view-group-bar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 0 8px;
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
.view-group-bar__label {
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-create-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.group-create-form__label {
|
||||
flex: none;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.group-create-form__hint {
|
||||
margin: 10px 0 0;
|
||||
color: var(--fms-text-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 表格容器:高度由脚本按行数算好(见 tableHeightOf),不再靠 flex 撑满 */
|
||||
.mm-qdesign__table {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mm-qdesign__view-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mm-qdesign__group-cell {
|
||||
color: var(--fms-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mm-qdesign__row-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--fms-text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mm-qdesign__row-delete:hover {
|
||||
background: color-mix(in srgb, var(--fms-danger) 10%, transparent);
|
||||
color: var(--fms-danger);
|
||||
}
|
||||
|
||||
:deep(.mm-qdesign__view-group-row td) {
|
||||
background: var(--fms-secondary-hover);
|
||||
}
|
||||
|
||||
/* 条件 SQL 单元格:单元格内是一段可点击的摘要文本 */
|
||||
@@ -770,7 +1123,7 @@ function openPreview() {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 560px;
|
||||
padding: 4px 0 8px;
|
||||
padding: 0 0 4px;
|
||||
}
|
||||
|
||||
.keyword-form__item {
|
||||
|
||||
+1
@@ -203,6 +203,7 @@ const {
|
||||
onNodeMove,
|
||||
selectRow,
|
||||
selectField,
|
||||
insertField,
|
||||
onFieldDrop,
|
||||
clipboardProps,
|
||||
copyRowProps,
|
||||
|
||||
+26
@@ -62,6 +62,11 @@ function onFieldDragStart(event, field) {
|
||||
|
||||
<template>
|
||||
<aside class="mm-fdesign__lib" aria-label="字段列表">
|
||||
<div class="mm-fdesign__lib-head">
|
||||
<div class="mm-fdesign__lib-title">
|
||||
<strong>字段库</strong>
|
||||
</div>
|
||||
</div>
|
||||
<Input v-model="libSearch" class="mm-fdesign__lib-search" :placeholder="placeholder" />
|
||||
<div class="mm-fdesign__lib-group">
|
||||
<draggable
|
||||
@@ -151,6 +156,27 @@ function onFieldDragStart(event, field) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mm-fdesign__lib-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mm-fdesign__lib-title {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
color: var(--fms-text);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.mm-fdesign__lib-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,14 @@ const props = defineProps({
|
||||
/** 运行态是只读呈现:不产生结构变更,也不响应编辑快捷键 */
|
||||
const isRuntime = computed(() => props.mode === 'runtime')
|
||||
|
||||
const emit = defineEmits(['graph-change', 'node-click', 'edge-click', 'blank-click', 'history-change'])
|
||||
const emit = defineEmits([
|
||||
'graph-change',
|
||||
'node-click',
|
||||
'edge-click',
|
||||
'blank-click',
|
||||
'history-change',
|
||||
'zoom-change',
|
||||
])
|
||||
|
||||
const containerRef = ref(null)
|
||||
const lf = shallowRef(null)
|
||||
@@ -147,6 +154,9 @@ onMounted(() => {
|
||||
instance.on('node:click', ({ data }) => emit('node-click', data.id))
|
||||
instance.on('edge:click', ({ data }) => emit('edge-click', data.id))
|
||||
instance.on('blank:click', () => emit('blank-click'))
|
||||
// 缩放/平移都会触发,缩放比例交给画布工具条显示(视图状态,两种模式都报)。
|
||||
// 注意载荷是 { type, transform },没有 data 包装(history:change 才有)
|
||||
instance.on('graph:transform', ({ transform }) => emit('zoom-change', transform.SCALE_X))
|
||||
|
||||
if (!runtimeMode) {
|
||||
CHANGE_EVENTS.forEach((name) => instance.on(name, snapshot))
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 流程设计器 —— 画布工具条(悬浮在画布左下角的浮条)。
|
||||
*
|
||||
* 收纳所有「作用于画布/视图」的操作:撤销/重做 | 缩小 · 缩放比例 · 放大 | 适应画布/一键整理。
|
||||
* 与顶部工具栏分开 —— 那边是流程身份与流程级动作(保存草稿/发布),
|
||||
* 这边跟着画布走,放在画布浮层里比挤在页面顶栏更符合直觉。
|
||||
*
|
||||
* 纯图标 + title 提示:这几个操作都有快捷键(Ctrl+Z / Ctrl+滚轮),
|
||||
* 图标足够表意,省下的宽度让浮条更轻、更少遮挡画布。
|
||||
*
|
||||
* 纯展示 + 事件上报,不持有任何状态:撤销/重做的可用性、缩放比例都由上层传进来。
|
||||
* 「一键整理」按纵向流程重排节点坐标,实现见 WorkflowCanvas 的 autoLayout。
|
||||
* 悬浮定位与描边由 WorkflowDesigner 的 .workflow-designer__canvas-toolbar 控制。
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { LayoutTemplate, Maximize, Minus, Plus, Redo2, Undo2 } from '@lucide/vue'
|
||||
import { Button } from '@/components/ui'
|
||||
|
||||
const props = defineProps({
|
||||
canUndo: { type: Boolean, default: false },
|
||||
canRedo: { type: Boolean, default: false },
|
||||
/** 画布缩放比例,1 = 100% */
|
||||
zoom: { type: Number, default: 1 },
|
||||
})
|
||||
|
||||
defineEmits(['undo', 'redo', 'zoom-in', 'zoom-out', 'fit-view', 'auto-layout'])
|
||||
|
||||
const zoomPercent = computed(() => `${Math.round(props.zoom * 100)}%`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="canvas-toolbar">
|
||||
<div class="canvas-toolbar__group">
|
||||
<Button type="icon" title="撤销" :disabled="!canUndo" @click="$emit('undo')">
|
||||
<Undo2 :size="14" />
|
||||
</Button>
|
||||
<Button type="icon" title="重做" :disabled="!canRedo" @click="$emit('redo')">
|
||||
<Redo2 :size="14" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<span class="canvas-toolbar__divider"></span>
|
||||
|
||||
<div class="canvas-toolbar__group">
|
||||
<Button type="icon" title="缩小" @click="$emit('zoom-out')">
|
||||
<Minus :size="14" />
|
||||
</Button>
|
||||
<span class="canvas-toolbar__zoom">{{ zoomPercent }}</span>
|
||||
<Button type="icon" title="放大" @click="$emit('zoom-in')">
|
||||
<Plus :size="14" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<span class="canvas-toolbar__divider"></span>
|
||||
|
||||
<div class="canvas-toolbar__group">
|
||||
<Button type="icon" title="适应画布" @click="$emit('fit-view')">
|
||||
<Maximize :size="14" />
|
||||
</Button>
|
||||
<Button type="icon" title="一键整理" @click="$emit('auto-layout')">
|
||||
<LayoutTemplate :size="14" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.canvas-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 4px 6px;
|
||||
background: var(--fms-card);
|
||||
}
|
||||
|
||||
.canvas-toolbar__group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 浮条里不再给图标按钮描边:整条浮条已经有边框,里面再套一圈方框太重。
|
||||
反馈交给悬停底色(比 .button-icon 自带的 hover 规则多一层父级,能压过它)。 */
|
||||
.canvas-toolbar :deep(.button-icon) {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.canvas-toolbar :deep(.button-icon:hover:not(:disabled)) {
|
||||
background: var(--fms-secondary);
|
||||
}
|
||||
|
||||
/* 分组之间的竖线:与两侧按钮等高的细线,替代文字标签做区隔 */
|
||||
.canvas-toolbar__divider {
|
||||
flex: none;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--fms-border);
|
||||
}
|
||||
|
||||
/* 缩放比例:固定宽度 + 等宽数字,数值变化时两边的按钮不会左右跳 */
|
||||
.canvas-toolbar__zoom {
|
||||
min-width: 40px;
|
||||
font-size: 12px;
|
||||
color: var(--fms-text-secondary);
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { Modal, Message, Splitter, SplitterPanel } from '@/components/ui'
|
||||
import WorkflowCanvas from './WorkflowCanvas.vue'
|
||||
import WorkflowCanvasToolbar from './WorkflowCanvasToolbar.vue'
|
||||
import WorkflowNodePanel from './WorkflowNodePanel.vue'
|
||||
import WorkflowPropertyPanel from './WorkflowPropertyPanel.vue'
|
||||
import WorkflowToolbar from './WorkflowToolbar.vue'
|
||||
@@ -31,6 +32,7 @@ const draft = ref(props.initialDraft)
|
||||
const selection = ref({ type: null, key: null })
|
||||
const canUndo = ref(false)
|
||||
const canRedo = ref(false)
|
||||
const zoom = ref(1)
|
||||
const jsonOpen = ref(false)
|
||||
|
||||
const issues = computed(() => validateWorkflowDomain(draft.value))
|
||||
@@ -92,6 +94,11 @@ function onHistoryChange(state) {
|
||||
canRedo.value = !!state?.redoAble
|
||||
}
|
||||
|
||||
/** 画布缩放比例(1 = 100%):只用于工具条上的数值显示 */
|
||||
function onZoomChange(scale) {
|
||||
zoom.value = scale
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
属性面板 → 草稿(+ 需要同步到画布的部分)
|
||||
---------------------------------------------------------------------------- */
|
||||
@@ -159,21 +166,15 @@ function onAutoLayout() {
|
||||
<template>
|
||||
<div class="workflow-designer">
|
||||
<WorkflowToolbar
|
||||
:can-undo="canUndo"
|
||||
:can-redo="canRedo"
|
||||
:name="draft.name"
|
||||
:code="draft.definitionId"
|
||||
:error-count="errorCount"
|
||||
:warn-count="warnCount"
|
||||
@undo="canvasRef?.undo()"
|
||||
@redo="canvasRef?.redo()"
|
||||
@zoom-in="canvasRef?.zoomIn()"
|
||||
@zoom-out="canvasRef?.zoomOut()"
|
||||
@fit-view="canvasRef?.fitView()"
|
||||
@auto-layout="onAutoLayout"
|
||||
@view-json="jsonOpen = true"
|
||||
/>
|
||||
|
||||
<Splitter class="workflow-designer__body" direction="horizontal">
|
||||
<!-- 画布舞台:节点面板悬浮在它上面,不占画布宽度 -->
|
||||
<!-- 画布舞台:节点面板与画布工具条悬浮在它上面,不占画布宽度 -->
|
||||
<SplitterPanel class="workflow-designer__stage" :min="360">
|
||||
<WorkflowCanvas
|
||||
ref="canvasRef"
|
||||
@@ -183,8 +184,21 @@ function onAutoLayout() {
|
||||
@edge-click="onEdgeClick"
|
||||
@blank-click="onBlankClick"
|
||||
@history-change="onHistoryChange"
|
||||
@zoom-change="onZoomChange"
|
||||
/>
|
||||
<WorkflowNodePanel class="workflow-designer__palette" @drag-node="onDragNode" />
|
||||
<WorkflowCanvasToolbar
|
||||
class="workflow-designer__canvas-toolbar"
|
||||
:can-undo="canUndo"
|
||||
:can-redo="canRedo"
|
||||
:zoom="zoom"
|
||||
@undo="canvasRef?.undo()"
|
||||
@redo="canvasRef?.redo()"
|
||||
@zoom-in="canvasRef?.zoomIn()"
|
||||
@zoom-out="canvasRef?.zoomOut()"
|
||||
@fit-view="canvasRef?.fitView()"
|
||||
@auto-layout="onAutoLayout"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel class="workflow-designer__side" :default-size="300" :min="240" :max="440">
|
||||
@@ -226,6 +240,28 @@ function onAutoLayout() {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 分隔条零宽(不参与 flex 布局),命中区由绝对定位的 ::after 向两侧扩展 ——
|
||||
与 DefaultLayout 的侧栏同一套做法。默认 10px 的命中区会实占宽度且没有背景,
|
||||
在画布与面板之间留出一条不带网格的白条:紧挨着点阵画布,看着像画布边上多出一块白。
|
||||
零宽后网格一直铺到边界线,「间距」改由 __side 的 padding-left 承担(见下)。
|
||||
必须用子代选择器 > 限定为本层的分隔条,否则会命中右栏内层 Splitter(属性/校验)的分隔条 */
|
||||
.workflow-designer__body :deep(> .fms-splitter-bar) {
|
||||
width: 0;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 命中区:以边界线为中心向两侧各展开半个命中区宽度 */
|
||||
.workflow-designer__body :deep(> .fms-splitter-bar)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(var(--fms-splitter-trigger-size) * -0.5);
|
||||
width: var(--fms-splitter-trigger-size);
|
||||
}
|
||||
|
||||
/* 画布舞台:节点面板要悬浮在它之上,所以这里做定位上下文;
|
||||
overflow 也一并裁掉,画布区不该出现滚动条(平移缩放由 LogicFlow 负责)。
|
||||
选择器带上父级是为了压过 .fms-splitter-panel 的 overflow:auto。 */
|
||||
@@ -252,12 +288,29 @@ function onAutoLayout() {
|
||||
box-shadow: var(--fms-popover-shadow);
|
||||
}
|
||||
|
||||
/* 右栏与画布之间常显的分割线:Splitter 的分隔条平时是隐藏的(hover 才显),
|
||||
这里补一条静态细线,让两个区域一眼能分开 */
|
||||
/* 右栏:border-left 是与画布之间的常显分隔线(分隔条命中区已零宽,线由这里承担);
|
||||
padding-left 补回被零宽吃掉的那段间距 —— 只留一条线的话,画布与面板内容会贴到一起。
|
||||
间距放在面板内侧而不是画布侧,画布网格才能一直铺到边界线。 */
|
||||
.workflow-designer .workflow-designer__side {
|
||||
padding-left: var(--fms-splitter-trigger-size);
|
||||
border-left: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
/* 画布工具条悬浮在画板左下角:撤销/缩放这类操作跟着画布走,不占顶部工具栏的位置。
|
||||
与左上角节点面板同为画布浮层,描边 + 轻阴影把白条从白画布上托起来。
|
||||
height:auto 同节点面板 —— 不写的话组件根节点的 height:100% 会铺满整个画布。 */
|
||||
.workflow-designer .workflow-designer__canvas-toolbar {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
z-index: 10;
|
||||
height: auto;
|
||||
background: var(--fms-card);
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--fms-popover-shadow);
|
||||
}
|
||||
|
||||
.workflow-designer__json {
|
||||
max-height: 60vh;
|
||||
margin: 0;
|
||||
|
||||
@@ -2,66 +2,56 @@
|
||||
/**
|
||||
* 流程设计器 —— 顶部工具栏。
|
||||
*
|
||||
* 纯展示 + 事件上报,不持有任何状态:撤销/重做的可用性、校验计数都由上层传进来。
|
||||
* 「保存 / 发布」不在这里 —— 流程写入必须走流程服务(§6.2 / §12),
|
||||
* 接口就绪前先用「查看草稿」把领域 JSON 亮出来,便于对接时核对。
|
||||
* 左侧只展示当前流程的身份(编码 + 名称 + 校验状态),右侧是流程级动作
|
||||
* (查看草稿 / 保存草稿 / 发布)。画布级操作(撤销/重做、缩放、适应画布、
|
||||
* 一键整理)不在这里,见 WorkflowCanvasToolbar —— 那些跟着画布走。
|
||||
*
|
||||
* 「一键整理」按纵向流程重排节点坐标,实现见 WorkflowCanvas 的 autoLayout。
|
||||
* 纯展示 + 事件上报,不持有任何状态:校验计数由上层传进来。
|
||||
* 「保存草稿 / 发布」目前只是入口占位 —— 流程写入必须走流程服务(§6.2 / §12),
|
||||
* 接口就绪前先用「查看草稿」把领域 JSON 亮出来,便于对接时核对。
|
||||
*/
|
||||
import { Braces, LayoutTemplate, Maximize, Minus, Plus, Redo2, Undo2 } from '@lucide/vue'
|
||||
import { Braces, Save, Send } from '@lucide/vue'
|
||||
import { Button, Tag } from '@/components/ui'
|
||||
|
||||
defineProps({
|
||||
canUndo: { type: Boolean, default: false },
|
||||
canRedo: { type: Boolean, default: false },
|
||||
/** 流程名称,空表示新增中的草稿 */
|
||||
name: { type: String, default: '' },
|
||||
/** 流程编码(wf_definition.b_id),空表示尚未落库 */
|
||||
code: { type: String, default: '' },
|
||||
errorCount: { type: Number, default: 0 },
|
||||
warnCount: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
defineEmits(['undo', 'redo', 'zoom-in', 'zoom-out', 'fit-view', 'auto-layout', 'view-json'])
|
||||
defineEmits(['view-json', 'save-draft', 'publish'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="designer-toolbar">
|
||||
<div class="designer-toolbar__group">
|
||||
<Button type="outline" :disabled="!canUndo" @click="$emit('undo')">
|
||||
<Undo2 :size="14" />
|
||||
撤销
|
||||
</Button>
|
||||
<Button type="outline" :disabled="!canRedo" @click="$emit('redo')">
|
||||
<Redo2 :size="14" />
|
||||
重做
|
||||
</Button>
|
||||
<div class="designer-toolbar__meta">
|
||||
<span class="designer-toolbar__name">{{ name || '未命名流程' }}</span>
|
||||
<span class="designer-toolbar__code">{{ code || '未保存' }}</span>
|
||||
<div class="designer-toolbar__group designer-toolbar__status">
|
||||
<Tag v-if="errorCount" variant="danger">错误 {{ errorCount }}</Tag>
|
||||
<Tag v-else variant="success">校验通过</Tag>
|
||||
<Tag v-if="warnCount" variant="secondary">警告 {{ warnCount }}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="designer-toolbar__group">
|
||||
<Button type="outline" @click="$emit('zoom-out')">
|
||||
<Minus :size="14" />
|
||||
</Button>
|
||||
<Button type="outline" @click="$emit('zoom-in')">
|
||||
<Plus :size="14" />
|
||||
</Button>
|
||||
<Button type="outline" @click="$emit('fit-view')">
|
||||
<Maximize :size="14" />
|
||||
适应画布
|
||||
</Button>
|
||||
<Button type="outline" @click="$emit('auto-layout')">
|
||||
<LayoutTemplate :size="14" />
|
||||
一键整理
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="designer-toolbar__group designer-toolbar__group--status">
|
||||
<Tag v-if="errorCount" variant="danger">错误 {{ errorCount }}</Tag>
|
||||
<Tag v-else variant="success">校验通过</Tag>
|
||||
<Tag v-if="warnCount" variant="secondary">警告 {{ warnCount }}</Tag>
|
||||
</div>
|
||||
|
||||
<div class="designer-toolbar__group designer-toolbar__group--end">
|
||||
<Button type="outline" @click="$emit('view-json')">
|
||||
<Braces :size="14" />
|
||||
查看草稿
|
||||
</Button>
|
||||
<div class="designer-toolbar__actions">
|
||||
<div class="designer-toolbar__group">
|
||||
<Button type="outline" @click="$emit('view-json')">
|
||||
<Braces :size="14" />
|
||||
查看草稿
|
||||
</Button>
|
||||
<Button type="outline" @click="$emit('save-draft')">
|
||||
<Save :size="14" />
|
||||
保存草稿
|
||||
</Button>
|
||||
<Button type="primary" @click="$emit('publish')">
|
||||
<Send :size="14" />
|
||||
发布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -73,18 +63,55 @@ defineEmits(['undo', 'redo', 'zoom-in', 'zoom-out', 'fit-view', 'auto-layout', '
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--fms-card);
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
/* 左侧流程身份:名称、编码,再跟一枚校验状态标签,超长省略,别把右侧操作挤下去 */
|
||||
.designer-toolbar__meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.designer-toolbar__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--fms-text);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.designer-toolbar__code {
|
||||
flex: none;
|
||||
font-family: var(--fms-font-mono, monospace);
|
||||
font-size: 12px;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
/* 校验状态:跟在编码后面,与前面留一点距离,不参与名称的省略挤压 */
|
||||
.designer-toolbar__status {
|
||||
flex: none;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* 右侧操作区:多组之间留出分隔感,空间不够时整组换行 */
|
||||
.designer-toolbar__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.designer-toolbar__group {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.designer-toolbar__group--end {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -64,15 +64,9 @@ const lastSchema = (wrapper) => {
|
||||
return events.length ? events[events.length - 1][0] : null
|
||||
}
|
||||
|
||||
/** 切到「工作队列页签」分节并返回该分节内的页签表格(Tabs 懒渲染:未激活过的分节无 DOM) */
|
||||
async function openViewsTab(wrapper) {
|
||||
const tab = wrapper
|
||||
.findAll('.tabs-tab')
|
||||
.find((button) => button.text().includes('工作队列页签'))
|
||||
await tab.trigger('click')
|
||||
await nextTick()
|
||||
const pane = wrapper.findAll('.tabs-pane').at(-1)
|
||||
return pane.findComponent(FmsTable)
|
||||
/** 「工作队列页签」分节内的页签表格(四个分节同页平铺,按分节类名定位) */
|
||||
function viewsTable(wrapper) {
|
||||
return wrapper.find('.mm-qdesign__section--views').findComponent(FmsTable)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -80,6 +74,29 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('查询配置面板:加载不回写、编辑才回写', () => {
|
||||
it('四个分节同页平铺(不用页签分组),各自带一张定高表格', async () => {
|
||||
const wrapper = await mountPanel(legacySchema)
|
||||
// 页签栏与页签面板都不应存在
|
||||
expect(wrapper.findAll('.tabs-tab').length, '不应再有页签').toBe(0)
|
||||
expect(wrapper.findAll('.tabs-pane').length, '不应再有页签面板').toBe(0)
|
||||
|
||||
const sections = wrapper.findAll('.mm-qdesign__section')
|
||||
expect(sections.map((section) => section.find('.mm-qdesign__section-title strong').text())).toEqual(
|
||||
['更多条件字段', '工作队列页签', '关键词字段', '快捷筛选字段'],
|
||||
)
|
||||
|
||||
// 三个带表格的分节:表格容器有按行数算出的显式高度(不再靠 flex 撑满整屏)
|
||||
for (const modifier of ['--fields', '--views', '--quick']) {
|
||||
const tableBox = wrapper.find(`.mm-qdesign__section${modifier} .mm-qdesign__table`)
|
||||
expect(tableBox.exists(), `${modifier} 分节应有表格容器`).toBe(true)
|
||||
expect(tableBox.attributes('style'), `${modifier} 分节表格应有显式高度`).toMatch(
|
||||
/height:\s*\d+px/,
|
||||
)
|
||||
expect(tableBox.findComponent(FmsTable).exists(), `${modifier} 分节应渲染表格`).toBe(true)
|
||||
}
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('仅加载(含父级重载换新对象)不回写', async () => {
|
||||
const wrapper = await mountPanel(legacySchema)
|
||||
expect(lastSchema(wrapper)).toBe(null)
|
||||
@@ -93,7 +110,7 @@ describe('查询配置面板:加载不回写、编辑才回写', () => {
|
||||
|
||||
it('老结构条件在草稿里转成 SQL 展示,但加载本身不回写', async () => {
|
||||
const wrapper = await mountPanel(legacySchema)
|
||||
const rows = (await openViewsTab(wrapper)).props('dataSource')
|
||||
const rows = viewsTable(wrapper).props('dataSource')
|
||||
expect(rows[0].conditionSql).toBe("[b_status] = N'1'")
|
||||
expect(rows[0].isDefault).toBe(true)
|
||||
expect(lastSchema(wrapper)).toBe(null)
|
||||
@@ -102,7 +119,7 @@ describe('查询配置面板:加载不回写、编辑才回写', () => {
|
||||
|
||||
it('改分组后回写为 schemaVersion 3(已废弃键被清理)', async () => {
|
||||
const wrapper = await mountPanel(legacySchema)
|
||||
const table = await openViewsTab(wrapper)
|
||||
const table = viewsTable(wrapper)
|
||||
const rows = table.props('dataSource')
|
||||
rows[0].group = '状态'
|
||||
table.vm.$emit('change', rows[0], { dataIndex: 'group' })
|
||||
@@ -126,7 +143,7 @@ describe('查询配置面板:加载不回写、编辑才回写', () => {
|
||||
|
||||
it('页签条件改为 SQL 后回写', async () => {
|
||||
const wrapper = await mountPanel(legacySchema)
|
||||
await openViewsTab(wrapper)
|
||||
viewsTable(wrapper)
|
||||
await wrapper.find('button.mm-qdesign__sql-cell').trigger('click')
|
||||
await nextTick()
|
||||
await wrapper.find('textarea').setValue("b_status = N'2'")
|
||||
@@ -142,7 +159,7 @@ describe('查询配置面板:加载不回写、编辑才回写', () => {
|
||||
|
||||
it('条件 SQL 含分号时不回写(拦截手误)', async () => {
|
||||
const wrapper = await mountPanel(legacySchema)
|
||||
await openViewsTab(wrapper)
|
||||
viewsTable(wrapper)
|
||||
await wrapper.find('button.mm-qdesign__sql-cell').trigger('click')
|
||||
await nextTick()
|
||||
await wrapper.find('textarea').setValue("b_status = N'2'; DELETE FROM x")
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveTabTitle } from '@/layouts/tabTitle'
|
||||
|
||||
const route = (meta, extra = {}) => ({ meta, ...extra })
|
||||
|
||||
describe('resolveTabTitle', () => {
|
||||
it('appends a query identifier declared by the route', () => {
|
||||
expect(
|
||||
resolveTabTitle(route({ title: '流程设计', tabTitle: 'id' }, { query: { id: 'expense_approval' } })),
|
||||
).toBe('流程设计 expense_approval')
|
||||
})
|
||||
|
||||
it('supports a different query field such as ywid', () => {
|
||||
expect(
|
||||
resolveTabTitle(
|
||||
route(
|
||||
{ title: '业务详情', tabTitle: { key: 'ywid', source: 'query' } },
|
||||
{ query: { ywid: 'YW-001' } },
|
||||
),
|
||||
),
|
||||
).toBe('业务详情 YW-001')
|
||||
})
|
||||
|
||||
it('reads route params when configured or when query has no value', () => {
|
||||
expect(
|
||||
resolveTabTitle(route({ title: '详情', tabTitle: { key: 'id', source: 'params' } }, { params: { id: '42' } })),
|
||||
).toBe('详情 42')
|
||||
expect(
|
||||
resolveTabTitle(route({ title: '详情', tabTitle: 'id' }, { params: { id: '42' }, query: {} })),
|
||||
).toBe('详情 42')
|
||||
})
|
||||
|
||||
it('keeps the base title when the configured value is missing', () => {
|
||||
expect(resolveTabTitle(route({ title: '流程设计', tabTitle: 'id' }, { query: {} }))).toBe('流程设计')
|
||||
})
|
||||
})
|
||||
+1
-1
Submodule code/test/vue-vben-admin updated: edbc42caaa...2d7087602b.
Reference in new issue
Block a user