diff --git a/code/fms/fms-vue/src/components/fms-module-common/queryUtils.js b/code/fms/fms-vue/src/components/fms-module-common/queryUtils.js index 714b264c..2d9b9834 100644 --- a/code/fms/fms-vue/src/components/fms-module-common/queryUtils.js +++ b/code/fms/fms-vue/src/components/fms-module-common/queryUtils.js @@ -28,6 +28,21 @@ export function queryItemLabel(item, translate) { return typeof translate === 'function' ? translate(field.b_i18n, fallback) : fallback } +/** + * 关键词输入框占位文案:按可搜索字段名在运行时生成,不写进模块配置。 + * 字段多于 5 个时只列前 5 个,末尾加「等」。 + * @param items 关键词字段项(queryItem[]) + * @param translate 多语言函数(t(key, fallback)),缺省用中文默认文案 + */ +export function buildKeywordPlaceholder(items, translate) { + const labels = (items || []).map((item) => queryItemLabel(item, translate)).filter(Boolean) + if (!labels.length) return '' + const text = (key, fallback) => (typeof translate === 'function' ? translate(key, fallback) : fallback) + const head = labels.slice(0, 5).join('、') + const tail = labels.length > 5 ? ` ${text('common.etc', '等')}` : '' + return `${text('common.keywordSearch', '搜索')} ${head}${tail}` +} + export function isRangeQuery(item) { return ( item.config.b_operator === 'between' || @@ -111,7 +126,8 @@ export function sqlLiteral(value, field, endOfRange = false) { /* ---------- 统一查询条件:AST → SQL ---------- * 页签条件、关键词、快捷筛选、更多筛选都归一到同一套结构,最终由 astToSql 生成 SQL: * 条件 { type: 'condition', field, operator, value, join } - * 条件组 { type: 'group', children: [条件 | 条件组], logic? } + * 条件组 { type: 'group', children: [条件 | 条件组 | 原始片段], logic? } + * 原始片段 { type: 'raw', sql }(页签条件由配置人员直接写 SQL,整段作为一个子项参与连接) * join 表示「本项与上一相邻项如何连接」(且 / 或);首项 join 忽略。 * 页签 / 快捷筛选 / 更多筛选同层一律「且」,只有关键词在多个字段之间取「或」。 */ @@ -336,6 +352,23 @@ export function buildConditionSql(node, fieldMap) { return `${column} ${operator === 'ne' ? '<>' : '='} ${literal}` } +/* 原始 SQL 片段 → SQL:整段加括号,保证它作为子项参与连接时优先级独立。 + * 不做任何改写(分号 / 注释由配置面板拦截):运行时静默丢弃条件会把它放宽成「更多数据」, + * 比原样执行更危险。 */ +function buildRawSql(node) { + const sql = String(node?.sql ?? '').trim() + return sql ? `(${sql})` : '' +} + +/** 页签条件 SQL 的基础校验:只防手误(分号、注释),不当安全边界 */ +export function validateConditionSql(sql) { + const text = String(sql ?? '').trim() + if (!text) return true + if (text.includes(';')) return '不能包含分号' + if (text.includes('--') || text.includes('/*')) return '不能包含注释' + return true +} + /** 条件组 → SQL(递归)。子项自带 join 时按各自连接符拼接: * 全部同向 → 简单连接;混合 → 从左到右逐项加括号,与界面阅读顺序一致 * (a 且 b 或 c = (a 且 b) 或 c)。 @@ -345,7 +378,11 @@ export function buildGroupSql(group, fieldMap) { const parts = [] for (const child of group.children || []) { const sql = - child?.type === 'group' ? buildGroupSql(child, fieldMap) : buildConditionSql(child, fieldMap) + child?.type === 'group' + ? buildGroupSql(child, fieldMap) + : child?.type === 'raw' + ? buildRawSql(child) + : buildConditionSql(child, fieldMap) if (sql) parts.push({ sql, join: child.join }) } if (!parts.length) return '' @@ -373,6 +410,7 @@ export function buildGroupSql(group, fieldMap) { export function astToSql(ast, fieldMap) { if (!ast) return '' if (ast.type === 'group') return buildGroupSql(ast, fieldMap) + if (ast.type === 'raw') return buildRawSql(ast) return buildConditionSql(ast, fieldMap) } @@ -395,13 +433,14 @@ export function buildQuickAst(items, values) { } /** - * 工作队列页签 → AST。 - * 页签条件是配置里写好的完整条件(含日期预设),直接沿用 AST 形状交给 astToSql, - * 因此页签不需要第二套 SQL 生成逻辑。 - * @param view query JSON 中的页签节点({ conditions: [...] }) - * @param fieldMap 「字段编码 → s_field 行」的 Map;字段缺失的条件直接丢弃 + * 工作队列页签 → AST:条件为配置直写的 SQL 片段(conditionSql),整段作为 raw 子项参与连接。 + * 存量页签的结构化 conditions 继续兼容(含日期语义值),因此不需要第二套 SQL 生成逻辑。 + * @param view query JSON 中的页签节点({ conditionSql } 或 { conditions: [...] }) + * @param fieldMap 「字段编码 → s_field 行」的 Map;旧结构里字段缺失的条件直接丢弃 */ export function buildViewAst(view, fieldMap) { + const sql = String(view?.conditionSql ?? '').trim() + if (sql) return { type: 'raw', sql } const children = [] for (const condition of view?.conditions || []) { if (!condition || condition.field == null) continue diff --git a/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js b/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js index 7fe8ae21..d77646d6 100644 --- a/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js +++ b/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js @@ -131,14 +131,14 @@ export function buildEditRender(editSchema) { } /** - * query JSON(schemaVersion 2)→ 查询入口渲染结构。 + * query JSON(schemaVersion 3)→ 查询入口渲染结构。 * * 产出(运行时组件只消费这一份,不再感知 JSON 节点差异): - * - views 工作队列页签(按 order 排序) - * - defaultViewCode 模块配置的默认页签编码 - * - keywordSearch 关键词搜索(占位文案 / 触发方式 / 字段项),未配置时为 null + * - views 工作队列页签(按 order 排序,带分组;组内单选、组间条件叠加) + * - defaultViewCodes 分组 → 默认页签编码(每组至多一个) + * - keywordSearch 关键词搜索(字段项;占位文案由调用方按字段名运行时生成),未配置时为 null * - quickFilters 快捷筛选字段项(按 order 排序) - * - filterGroups 更多筛选分组(组内字段项按配置顺序) + * - moreFilters 更多筛选字段项(字段查询元数据全集,已进快捷筛选的不重复出现) * * 字段项(queryItem)统一为 { field, config, options, optionConfig },与 FmsQueryControl / * buildQuickAst 消费的形状一致;字段级算子只在 queryFields 里定义一处。 @@ -177,31 +177,43 @@ export function buildQueryRender(querySchema, fields, allowedFields = null) { const views = [] for (const [index, view] of (querySchema?.views || []).entries()) { if (!view || view.code == null) continue + const conditionSql = String(view.conditionSql ?? '').trim() const conditions = (view.conditions || []).filter( (condition) => condition && condition.field != null, ) - // 条件字段不可用(未定义 / 无查询权限)时丢弃整个页签:静默丢单个条件会把 - // 「待处理」悄悄放宽成更多数据,与页签语义不符。 - if (conditions.some((condition) => !itemOf(condition.field))) continue + // 旧结构(结构化条件)里条件字段不可用(未定义 / 无查询权限)时丢弃整个页签:静默丢单个 + // 条件会把「待处理」悄悄放宽成更多数据,与页签语义不符。 + // SQL 条件无法解析字段引用,原样保留(由配置人员核对)。 + if (!conditionSql && conditions.some((condition) => !itemOf(condition.field))) continue views.push({ code: String(view.code), label: String(view.label ?? view.code), + group: String(view.group ?? ''), order: Number(view.order) || (index + 1) * 10, showCount: view.showCount === true, + isDefault: view.isDefault === true, + conditionSql, conditions, }) } views.sort((a, b) => a.order - b.order) - const keywordNode = querySchema?.keywordSearch || null - const keywordItems = (keywordNode?.fields || []).map(itemOf).filter(Boolean) - const keywordSearch = keywordItems.length - ? { - placeholder: String(keywordNode.placeholder || ''), - trigger: keywordNode.trigger === 'debounce' ? 'debounce' : 'enter_or_debounce', - items: keywordItems, - } - : null + // 默认页签按组给:显式 isDefault 优先(每组取第一个);老数据只有顶层 defaultViewCode, + // 按其所在组补一个默认,保证老配置切过去仍落在原页签上 + const defaultViewCodes = {} + for (const view of views) { + if (!view.isDefault || defaultViewCodes[view.group] != null) continue + defaultViewCodes[view.group] = view.code + } + const legacyDefaultCode = String(querySchema?.defaultViewCode ?? '') + if (legacyDefaultCode && !views.some((view) => view.isDefault)) { + const target = views.find((view) => view.code === legacyDefaultCode) + if (target) defaultViewCodes[target.group] = target.code + } + + // 关键词:字段清单来自配置,占位文案与触发方式都在运行时定(不入配置) + const keywordItems = (querySchema?.keywordSearch?.fields || []).map(itemOf).filter(Boolean) + const keywordSearch = keywordItems.length ? { items: keywordItems } : null const quickFilters = (querySchema?.quickFilters || []) .map((entry, index) => { @@ -212,27 +224,14 @@ export function buildQueryRender(querySchema, fields, allowedFields = null) { .filter(Boolean) .toSorted((a, b) => a.order - b.order) - // 已进快捷筛选的字段不再进更多筛选:同一条条件不给两个入口 + // 更多筛选:字段查询元数据全集(已进快捷筛选的字段不重复出现:同一条条件不给两个入口), + // 顺序即配置顺序,不再需要人工分组 const quickFieldNames = new Set(quickFilters.map((item) => String(item.field.b_field))) - const filterGroups = (querySchema?.filterGroups || []) - .map((group, index) => ({ - code: String(group?.code ?? `group${index + 1}`), - label: String(group?.label ?? ''), - order: Number(group?.order) || (index + 1) * 10, - items: (group?.fields || []) - .map(itemOf) - .filter((item) => item && !quickFieldNames.has(String(item.field.b_field))), - })) - .filter((group) => group.items.length) - .toSorted((a, b) => a.order - b.order) + const moreFilters = Object.keys(queryFields) + .map(itemOf) + .filter((item) => item && !quickFieldNames.has(String(item.field.b_field))) - return { - views, - defaultViewCode: String(querySchema?.defaultViewCode ?? views[0]?.code ?? ''), - keywordSearch, - quickFilters, - filterGroups, - } + return { views, defaultViewCodes, keywordSearch, quickFilters, moreFilters } } /** 节点(含分组子树)在 settings 顺序中的最小序号,用于同层稳定排序 */ diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsFilterDrawer.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsFilterDrawer.vue index eae50e97..d198e117 100644 --- a/code/fms/fms-vue/src/components/fms-module-list/FmsFilterDrawer.vue +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsFilterDrawer.vue @@ -1,8 +1,8 @@ diff --git a/code/fms/fms-vue/src/views/module/module-management/components/design-editor/common/schemaModel.js b/code/fms/fms-vue/src/views/module/module-management/components/design-editor/common/schemaModel.js index c6d9ff07..11e2651a 100644 --- a/code/fms/fms-vue/src/views/module/module-management/components/design-editor/common/schemaModel.js +++ b/code/fms/fms-vue/src/views/module/module-management/components/design-editor/common/schemaModel.js @@ -4,7 +4,7 @@ * 《FMS新系统核心表结构设计》V2 中,列表/表单/查询布局统一存 s_module_schema.b_schema_json: * - view { schemaVersion, columns: [ {field,...} | {type:'group',title,i18n,children:[...]} ] } * - edit { schemaVersion, children: [ {field,...} | {type:'group',...} ] } - * - query { schemaVersion, quick: [], groups: [ { logic, conditions: [{field,operator,...}] } ] } + * - query { schemaVersion, views: [...], keywordSearch, quickFilters, queryFields } * * 编辑器内核(useStructureEditing / StructureTree / TableDesignEditor / FormDesignEditor) * 仍以「行 + 分组」的可变草稿工作;本文件负责两侧转换,使编辑器对外的数据模型就是 JSON 节点, @@ -16,6 +16,7 @@ */ import { createTempId } from '@/utils/common' import { normalizeGroupIds } from '@/utils/schemaJson' +import { buildConditionSql } from '@/components/fms-module-common/queryUtils' import { createDefaultSchema, stableStringify } from '../../../utils' function sortByXh(nodes) { @@ -193,62 +194,136 @@ export function editorToSchema(schemaType, rows, groups, baseSchema) { return out } -/* ================= 查询配置(schemaVersion 2)JSON ↔ 编辑器草稿 ================= - * query JSON 由五块构成:字段查询元数据(queryFields)、工作队列页签(views + - * defaultViewCode)、关键词搜索(keywordSearch)、快捷筛选(quickFilters)、 - * 更多筛选分组(filterGroups)。编辑器以「一份草稿」承载五块,整体与基线比较后再回写; - * 未建模属性经 _node / baseSchema 原样保留,加载本身不产生回写。 +/* ================= 查询配置(schemaVersion 3)JSON ↔ 编辑器草稿 ================= + * query JSON 由四块构成:字段查询元数据(queryFields)、工作队列页签(views)、 + * 关键词搜索(keywordSearch)、快捷筛选(quickFilters);更多筛选由 queryFields 自动铺开。 + * 编辑器以「一份草稿」承载四块,整体与基线比较后再回写; + * 未建模属性经 _node / baseSchema 原样保留,加载本身不产生回写(规整差异不算编辑)。 + * + * 老数据在草稿层规整为新形态: + * - 页签条件 { conditions } → conditionSql 文本(值里的日期语义值转成 SQL Server 日期函数); + * - 顶层 defaultViewCode → 对应页签的 isDefault; + * - 已废弃的 filterGroups / groups / quick 丢弃。 + * 规整是纯函数:草稿与基线两侧走同一条路径,因此不会因此误报「未保存」。 */ -/** query JSON → 编辑器草稿(moduleId 仅用于行草稿归属,便于调试与导出) */ -export function querySchemaToDraft(schema, moduleId) { - const fields = Object.entries(schema?.queryFields || {}).map(([field, config], index) => ({ - _rowKey: createTempId(), - _node: config && typeof config === 'object' ? { ...config } : {}, - b_module_id: moduleId, - b_field: field, - b_component: config?.component || 'input', - b_operator: config?.operator || 'like', - b_default_value: config?.defaultValue ?? null, - b_canuse: 1, - b_xh: (index + 1) * 10, - })) - const views = (schema?.views || []).map((view, index) => ({ - _rowKey: createTempId(), - _node: view && typeof view === 'object' ? { ...view } : {}, - code: String(view?.code ?? ''), - label: String(view?.label ?? ''), - order: Number(view?.order) || (index + 1) * 10, - showCount: view?.showCount === true, - isDefault: String(schema?.defaultViewCode ?? '') === String(view?.code ?? ''), - conditions: (view?.conditions || []).map((condition) => ({ ...condition })), - })) - const keyword = { - placeholder: String(schema?.keywordSearch?.placeholder ?? ''), - trigger: schema?.keywordSearch?.trigger === 'debounce' ? 'debounce' : 'enter_or_debounce', - fields: (schema?.keywordSearch?.fields || []).map((field) => String(field)), +/** + * 日期语义值({ preset })→ SQL Server 日期表达式。 + * 语义与运行时 resolveDatePreset 一致(本周以周一为起点,近三个月含当月)。 + * 1900-01-01 是周一,故 DATEDIFF(day, 0, GETDATE()) % 7 即「今天是本周第几天(0=周一)」。 + */ +const PRESET_SQL = { + today: { start: 'CAST(GETDATE() AS date)', end: 'CAST(GETDATE() AS date)' }, + this_week: { + start: 'DATEADD(day, -(DATEDIFF(day, 0, GETDATE()) % 7), CAST(GETDATE() AS date))', + end: 'DATEADD(day, 6 - (DATEDIFF(day, 0, GETDATE()) % 7), CAST(GETDATE() AS date))', + }, + this_month: { + start: 'DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1)', + end: 'EOMONTH(GETDATE())', + }, + recent_3_months: { + start: 'DATEADD(month, -3, CAST(GETDATE() AS date))', + end: 'CAST(GETDATE() AS date)', + }, +} + +/** 日期字段的上下界比较:date 含端点,datetime 用「小于次日」保证当天末刻也被覆盖 */ +function rangeSqlOfPreset(column, field, preset, operator) { + const expr = PRESET_SQL[preset] + if (!expr) return '' + const upper = field?.b_type === 'datetime' ? `${column} < DATEADD(day, 1, ${expr.end})` : `${column} <= ${expr.end}` + if (operator === 'ge') return `${column} >= ${expr.start}` + if (operator === 'le') return upper + return `${column} >= ${expr.start} AND ${upper}` +} + +/** 页签结构化条件 → SQL 文本(迁移展示用;字段定义缺失时该条件丢弃) */ +function conditionsToSql(conditions, fieldMap) { + const parts = [] + for (const condition of conditions || []) { + if (!condition || condition.field == null) continue + const field = String(condition.field) + const preset = condition.value?.preset + const sql = preset + ? rangeSqlOfPreset(`[${field}]`, fieldMap?.get?.(field), String(preset), condition.operator) + : buildConditionSql( + { field, operator: condition.operator, value: condition.value }, + fieldMap, + ) + if (sql) parts.push(sql) } - const quickFilters = (schema?.quickFilters || []).map((entry, index) => ({ + return parts.join(' AND ') +} + +/** + * query JSON → 编辑器草稿。 + * @param moduleId 行草稿归属(便于调试与导出) + * @param fields s_field 行;老页签条件转 SQL 需要字段业务类型(缺失时保留原结构不转换) + */ +export function querySchemaToDraft(schema, moduleId, fields) { + const source = schema && typeof schema === 'object' ? schema : {} + const fieldMap = new Map((fields || []).map((field) => [String(field.b_field), field])) + const legacyDefaultCode = String(source.defaultViewCode ?? '') + const fieldRows = Object.entries(source.queryFields || {}).map(([field, config], index) => { + const node = config && typeof config === 'object' ? { ...config } : {} + const fromList = node.fromList === true + delete node.fromList + return { + _rowKey: createTempId(), + _node: node, + b_module_id: moduleId, + b_field: field, + b_component: node.component || 'input', + b_operator: node.operator || 'like', + b_default_value: node.defaultValue ?? null, + b_canuse: 1, + b_xh: (index + 1) * 10, + fromList, + } + }) + const views = (source.views || []).map((view, index) => { + const code = String(view?.code ?? '') + const legacyConditions = (view?.conditions || []).filter( + (condition) => condition && condition.field != null, + ) + const conditionSql = + String(view?.conditionSql ?? '').trim() || conditionsToSql(legacyConditions, fieldMap) + return { + _rowKey: createTempId(), + // _node 保留全部原属性(含老结构 conditions):未改写为 SQL 时回写原样带回 + _node: view && typeof view === 'object' ? { ...view } : {}, + code, + label: String(view?.label ?? ''), + group: String(view?.group ?? ''), + order: Number(view?.order) || (index + 1) * 10, + showCount: view?.showCount === true, + isDefault: + view?.isDefault === true || (legacyDefaultCode !== '' && code === legacyDefaultCode), + conditionSql, + legacyConditions, + } + }) + // 关键词只配可搜索字段:占位文案与触发方式都在运行时定,旧配置里的这两个键直接丢弃 + const keyword = { + fields: (source.keywordSearch?.fields || []).map((field) => String(field)), + } + const quickFilters = (source.quickFilters || []).map((entry, index) => ({ _rowKey: createTempId(), field: String(entry?.field ?? ''), order: Number(entry?.order) || (index + 1) * 10, })) - const filterGroups = (schema?.filterGroups || []).map((group, index) => ({ - _rowKey: createTempId(), - code: String(group?.code ?? ''), - label: String(group?.label ?? ''), - order: Number(group?.order) || (index + 1) * 10, - fields: (group?.fields || []).map((field) => String(field)), - })) - return { fields, views, keyword, quickFilters, filterGroups } + return { fields: fieldRows, views, keyword, quickFilters } } /** 编辑器草稿 → query JSON(未建模属性经 _node / baseSchema 保留,建模属性以草稿为准) */ export function queryDraftToSchema(draft, baseSchema) { const base = { ...(baseSchema || createDefaultSchema('query')) } - // schemaVersion 2 不再使用旧结构的 groups/quick,改写旧模块时一并清掉 + // 旧结构的 groups / quick / filterGroups / defaultViewCode 一律不再使用,改写时一并清掉 delete base.groups delete base.quick + delete base.filterGroups + delete base.defaultViewCode const queryFields = {} for (const row of draft?.fields || []) { @@ -264,10 +339,12 @@ export function queryDraftToSchema(draft, baseSchema) { } else { delete config.defaultValue } + // fromList:由「列表」勾选自动补的行。用户改过组件/算子后由面板摘掉标记,转为自定义行 + if (row.fromList) config.fromList = true + else delete config.fromList queryFields[field] = config } - let defaultViewCode = '' const views = [] for (const row of draft?.views || []) { const code = String(row.code ?? '').trim() @@ -275,24 +352,28 @@ export function queryDraftToSchema(draft, baseSchema) { const view = { ...(row._node || {}), code } if (String(row.label ?? '').trim()) view.label = String(row.label).trim() else delete view.label + // 分组:空串即默认组,不写出属性 + if (String(row.group ?? '').trim()) view.group = String(row.group).trim() + else delete view.group view.order = Number(row.order) || 10 if (row.showCount) view.showCount = true else delete view.showCount - const conditions = (row.conditions || []).filter( - (condition) => condition && String(condition.field ?? '') !== '', - ) - if (conditions.length) view.conditions = conditions - else delete view.conditions - if (row.isDefault) defaultViewCode = code + if (row.isDefault) view.isDefault = true + else delete view.isDefault + const conditionSql = String(row.conditionSql ?? '').trim() + if (conditionSql) { + // 已改写为 SQL:旧结构化条件不再保留 + view.conditionSql = conditionSql + delete view.conditions + } else { + delete view.conditionSql + if (!(view.conditions || []).length) delete view.conditions + } views.push(view) } const keywordFields = [...new Set((draft?.keyword?.fields || []).map((field) => String(field)).filter(Boolean))] - const keywordSearch = { - placeholder: String(draft?.keyword?.placeholder ?? '').trim(), - trigger: draft?.keyword?.trigger === 'debounce' ? 'debounce' : 'enter_or_debounce', - fields: keywordFields, - } + const keywordSearch = { fields: keywordFields } const quickFilters = (draft?.quickFilters || []) .map((row, index) => ({ @@ -301,23 +382,12 @@ export function queryDraftToSchema(draft, baseSchema) { })) .filter((entry) => entry.field) - const filterGroups = (draft?.filterGroups || []) - .map((row, index) => ({ - code: String(row.code ?? '').trim(), - label: String(row.label ?? '').trim(), - order: Number(row.order) || (index + 1) * 10, - fields: [...new Set((row.fields || []).map((field) => String(field)).filter(Boolean))], - })) - .filter((group) => group.code && group.fields.length) - return { ...base, - schemaVersion: 2, - defaultViewCode, + schemaVersion: 3, views, keywordSearch, quickFilters, - filterGroups, queryFields, } } @@ -425,24 +495,59 @@ function listWidthOf(field) { return LIST_WIDTH_DEFAULTS[field?.b_type] ?? 100 } -/** 字段是否被查询配置引用(queryFields / 快捷筛选 / 更多筛选分组 / 关键词 / 页签条件任一处) */ +/** view JSON(columns 节点树)→ 字段编码列表(先序,含分组子节点,去重) */ +function listFieldsOf(viewSchema) { + const out = [] + const walk = (nodes) => { + for (const node of nodes || []) { + if (!node || typeof node !== 'object') continue + if (node.type === 'group') { + walk(node.children) + continue + } + if (node.field != null) out.push(String(node.field)) + } + } + walk(viewSchema?.columns || []) + return [...new Set(out)] +} + +/** + * 列表字段 ↔ 查询元数据同步(幂等,保存前兜底;字段定义面板的「列表」勾选另有即时联动): + * - 列表(view JSON)里的字段若查询里没有 → 补一行(fromList 标记,默认算子按字段类型推导); + * - 带 fromList 标记但已不在列表里的行 → 回收; + * - 用户手工加过或调过的行(无标记)保留,不受影响。 + * 无变化时返回原对象,避免无谓的新引用。 + */ +export function syncQueryFieldsWithList(viewSchema, querySchema, fields) { + const fieldMap = new Map((fields || []).map((field) => [String(field.b_field), field])) + const listFields = listFieldsOf(viewSchema).filter((field) => fieldMap.has(field)) + const listSet = new Set(listFields) + let next = querySchema || createDefaultSchema('query') + let changed = false + + const queryFields = { ...(next.queryFields || {}) } + for (const [field, config] of Object.entries(queryFields)) { + if (config?.fromList !== true || listSet.has(field)) continue + delete queryFields[field] + changed = true + } + if (changed) next = { ...next, queryFields } + + for (const field of listFields) { + if (next.queryFields?.[field]) continue + next = addQueryCondition(next, fieldMap.get(field), { fromList: true }) + changed = true + } + return changed ? next : querySchema +} + +/** 字段是否被查询配置引用(字段查询元数据 / 快捷筛选 / 关键词搜索任一处) */ export function hasQueryCondition(schema, fieldId) { const key = String(fieldId) if (schema?.queryFields && key in schema.queryFields) return true if ((schema?.quickFilters || []).some((entry) => String(entry?.field) === key)) return true - if ( - (schema?.filterGroups || []).some((group) => - (group?.fields || []).some((field) => String(field) === key), - ) - ) - return true if ((schema?.keywordSearch?.fields || []).some((field) => String(field) === key)) return true - if ( - (schema?.views || []).some((view) => - (view?.conditions || []).some((condition) => String(condition?.field) === key), - ) - ) - return true return false } @@ -461,56 +566,48 @@ export function defaultQueryCondition(field) { return { component: preset.component, operator: preset.operator } } -/** 字段加入查询配置:补字段元数据并进入第一个更多筛选分组(返回新的配置对象) */ -export function addQueryCondition(schema, field) { +/** + * 字段加入查询配置:补字段元数据行(返回新的配置对象)。 + * @param options.fromList 由「列表」勾选自动补的行(取消列表勾选时可回收;用户改过即摘标记) + */ +export function addQueryCondition(schema, field, { fromList = false } = {}) { const base = schema || createDefaultSchema('query') const key = String(field.b_field) - const preset = defaultQueryCondition(field) const queryFields = { ...(base.queryFields || {}) } - if (!queryFields[key]) queryFields[key] = { ...preset } - const filterGroups = Array.isArray(base.filterGroups) ? base.filterGroups.map((g) => ({ ...g })) : [] - if (!filterGroups.length) { - filterGroups.push({ code: 'basic', label: '基础信息', order: 10, fields: [key] }) - } else if (!(filterGroups[0].fields || []).some((f) => String(f) === key)) { - filterGroups[0] = { ...filterGroups[0], fields: [...(filterGroups[0].fields || []), key] } + // 已有行不动:可能是用户手工加的自定义行,勾列表不该覆盖 + if (!queryFields[key]) { + const config = { ...defaultQueryCondition(field) } + if (fromList) config.fromList = true + queryFields[key] = config } - return { ...base, schemaVersion: 2, queryFields, filterGroups } + return { ...base, schemaVersion: 3, queryFields } } -/** 字段从查询配置移除:清元数据并退出所有入口(返回新的配置对象) */ -export function removeQueryCondition(schema, fieldId) { +/** + * 字段从查询配置移除:清元数据并退出快捷筛选 / 关键词搜索(返回新的配置对象)。 + * @param options.onlyFromList 只回收「列表勾选自动补」的行;自定义行(无标记)保持不动 + * + * 页签条件已是直写 SQL,无法解析字段引用,因此不再随字段删除自动清理 + * (页签条件由配置人员自行核对)。 + */ +export function removeQueryCondition(schema, fieldId, { onlyFromList = false } = {}) { const base = schema || createDefaultSchema('query') const key = String(fieldId) const queryFields = { ...(base.queryFields || {}) } - delete queryFields[key] - const filterGroups = (base.filterGroups || []) - .map((group) => ({ - ...group, - fields: (group.fields || []).filter((field) => String(field) !== key), - })) - .filter((group) => (group.fields || []).length) - const quickFilters = (base.quickFilters || []).filter( - (entry) => String(entry?.field) !== key, - ) - const keywordFields = (base.keywordSearch?.fields || []).filter((field) => String(field) !== key) - // 预设视图的条件被清空即失去语义,整条移除;本来就无条件的视图(「全部」)保留 - const views = [] - for (const view of base.views || []) { - const hadConditions = (view?.conditions || []).length > 0 - const conditions = (view?.conditions || []).filter( - (condition) => String(condition?.field) !== key, - ) - if (hadConditions && !conditions.length) continue - views.push({ ...view, conditions }) + if (onlyFromList) { + if (queryFields[key]?.fromList !== true) return base + delete queryFields[key] + return { ...base, queryFields } } + delete queryFields[key] + const quickFilters = (base.quickFilters || []).filter((entry) => String(entry?.field) !== key) + const keywordFields = (base.keywordSearch?.fields || []).filter((field) => String(field) !== key) return { ...base, - schemaVersion: 2, + schemaVersion: 3, queryFields, - filterGroups, quickFilters, keywordSearch: { ...(base.keywordSearch || {}), fields: keywordFields }, - views, } } diff --git a/code/fms/fms-vue/src/views/module/module-management/components/design-editor/common/useStructureEditing.js b/code/fms/fms-vue/src/views/module/module-management/components/design-editor/common/useStructureEditing.js index e928e3dc..26049bab 100644 --- a/code/fms/fms-vue/src/views/module/module-management/components/design-editor/common/useStructureEditing.js +++ b/code/fms/fms-vue/src/views/module/module-management/components/design-editor/common/useStructureEditing.js @@ -8,7 +8,7 @@ * 4. 行编辑纯函数 renumberRows / 剪贴板 / toggleFlag / filterLibraryFields / createEditRowFactory * 5. 结构编辑内核 useStructureEditing(分组树 + 行归属 + 平铺重编 + 选中态 + 列属性剪贴板) * - * 业务默认策略(listRowDefaults / queryRowDefaults / createListConfigRow / createQueryConfigRow) + * 业务默认策略(listRowDefaults / queryRowDefaults / createListConfigRow) * 已拆至 ../fieldDefaults.js(业务层);编辑器内核不依赖它——差异行工厂由宿主注入。 * * 语义约定(useStructureEditing): diff --git a/code/fms/fms-vue/src/views/module/module-management/components/fieldDefaults.js b/code/fms/fms-vue/src/views/module/module-management/components/fieldDefaults.js index bc4bafd4..3525cebe 100644 --- a/code/fms/fms-vue/src/views/module/module-management/components/fieldDefaults.js +++ b/code/fms/fms-vue/src/views/module/module-management/components/fieldDefaults.js @@ -81,16 +81,3 @@ export function createListConfigRow(moduleId, field, rows) { ...listRowDefaults(field), } } - -/** 字段 → 查询条件配置行(编辑器内部行草稿,身份为 b_field + b_condition_no) */ -export function createQueryConfigRow(moduleId, field, rows) { - return { - b_module_id: String(moduleId), - b_field: String(field.b_field), - b_condition_no: 1, - b_default_value: null, - b_canuse: 1, - b_xh: nextXh(rows), - ...queryRowDefaults(field), - } -} diff --git a/code/fms/fms-vue/src/views/module/module-management/index.vue b/code/fms/fms-vue/src/views/module/module-management/index.vue index faa4e1fb..01c671e7 100644 --- a/code/fms/fms-vue/src/views/module/module-management/index.vue +++ b/code/fms/fms-vue/src/views/module/module-management/index.vue @@ -83,7 +83,7 @@ import { schemaToJsonText, syncModuleItemI18nKeys, } from './utils' -import { setSchemaFieldReadonly } from './components/design-editor/common/schemaModel' +import { setSchemaFieldReadonly, syncQueryFieldsWithList } from './components/design-editor/common/schemaModel' import { cloneData, diffRow, @@ -465,6 +465,14 @@ async function beforeSave() { // 字段同步:受管 i18n 键物化(写回字段草稿,保存 diff 以物化后的行与基线比较) if (supportsConfig.value) { syncModuleItemI18nKeys(fields.value, 'field', moduleCode, 'b_field') + // 列表 ↔ 查询元数据同步(幂等):列表字段自动进查询(fromList 标记),列表已移除的 + // 自动行回收;用户手工加过或调过的查询行(无标记)保留。 + // 字段定义面板的「列表」勾选已即时联动,这里兜住列表配置(设计器)里增删列的路径 + query_schema.value = syncQueryFieldsWithList( + view_schema.value, + query_schema.value, + fields.value, + ) } moduleData.b_i18n = buildModuleI18nKey(moduleCode) return true diff --git a/code/fms/fms-vue/src/views/system/workflow/designer/WorkflowCanvas.vue b/code/fms/fms-vue/src/views/system/workflow/designer/WorkflowCanvas.vue index e52892c2..067a8586 100644 --- a/code/fms/fms-vue/src/views/system/workflow/designer/WorkflowCanvas.vue +++ b/code/fms/fms-vue/src/views/system/workflow/designer/WorkflowCanvas.vue @@ -17,16 +17,29 @@ import LogicFlow from '@logicflow/core' import '@logicflow/core/es/index.css' // 只引 dagre 子模块:包的入口还导出 elkLayout,从入口引会顺带把 elkjs 打进产物 import { Dagre } from '@logicflow/layout/es/dagre' -import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue' -import { registerWorkflowNodes } from './nodes' -import { workflowDomainToLogicFlow } from './domain' +// 折线路径算法(插件内部同一份):后处理移动节点后要用同样的算法重算连线路径 +import { processEdges } from '@logicflow/layout/es/utils/processEdge' +import { computed, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue' +import { registerWorkflowEdges, registerWorkflowNodes } from './nodes' +import { applyRuntimeStatus, workflowDomainToLogicFlow } from './domain' import { CANVAS_TO_NODE_TYPE } from './constants' +import { spreadBranchNodes } from './utils' const props = defineProps({ /** 领域草稿:只在初始化时渲染一次,后续结构以画布为准 */ draft: { type: Object, required: true }, + /** 渲染模式:design=设计态(类型色、可编辑);runtime=运行态(状态色、只读) */ + mode: { type: String, default: 'design' }, + /** + * 运行态数据(mode=runtime 时用):节点/连线编码 → 状态。 + * 由调用方从流程实例数据整理(见 domain.js 的 applyRuntimeStatus),画布不感知 wf_* 表结构。 + */ + runtime: { type: Object, default: null }, }) +/** 运行态是只读呈现:不产生结构变更,也不响应编辑快捷键 */ +const isRuntime = computed(() => props.mode === 'runtime') + const emit = defineEmits(['graph-change', 'node-click', 'edge-click', 'blank-click', 'history-change']) const containerRef = ref(null) @@ -55,6 +68,9 @@ function createIdGenerator(draft) { } } +/** 一键整理的排布方向:审批流程按纵向阅读,主链路自上而下 */ +const LAYOUT_DIRECTION = 'TB' + /** 会引起结构变化的事件:统一重算快照 */ const CHANGE_EVENTS = [ 'node:add', @@ -92,16 +108,17 @@ function handleResize() { onMounted(() => { const container = containerRef.value + const runtimeMode = isRuntime.value const instance = new LogicFlow({ container, // 不传 width/height:LogicFlow 按容器尺寸自适应,容器变化时靠下面的 ResizeObserver 同步 grid: { size: 10, visible: true, type: 'dot', config: { color: '#dde3ec', size: 1 } }, background: { backgroundColor: '#ffffff' }, - // 编辑体验:撤销重做、对齐辅助线、拖拽连线时可调整折点 - history: true, - snapline: true, - adjustEdge: true, - keyboard: { enabled: true }, + // 编辑体验:撤销重做、对齐辅助线、拖拽连线时可调整折点(运行态只读,全部关掉) + history: !runtimeMode, + snapline: !runtimeMode, + adjustEdge: !runtimeMode, + keyboard: { enabled: !runtimeMode }, edgeType: 'polyline', // 画布内滚轮只平移不缩放,缩放交给工具栏,避免误触 stopScrollGraph: true, @@ -110,14 +127,36 @@ onMounted(() => { plugins: [Dagre], }) - registerWorkflowNodes(instance) - instance.render(workflowDomainToLogicFlow(props.draft)) + if (runtimeMode) { + // 只读:不拖节点、不显示锚点、双击不能改节点/连线文字 + instance.updateEditConfig({ + adjustNodePosition: false, + hideAnchors: true, + nodeTextEdit: false, + edgeTextEdit: false, + textEdit: false, + }) + } - CHANGE_EVENTS.forEach((name) => instance.on(name, snapshot)) + registerWorkflowNodes(instance) + registerWorkflowEdges(instance) + const graphData = workflowDomainToLogicFlow(props.draft) + instance.render(runtimeMode ? applyRuntimeStatus(graphData, props.runtime) : graphData) + + // 点击类事件两种模式都抛(运行态要用来打开节点详情),结构变更与快捷键只在设计态接 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')) - instance.on('history:change', ({ data }) => emit('history-change', data)) + + if (!runtimeMode) { + CHANGE_EVENTS.forEach((name) => instance.on(name, snapshot)) + instance.on('history:change', ({ data }) => emit('history-change', data)) + // 内置快捷键只绑了复制/粘贴/撤销/重做/删除(@logicflow/core 的 initDefaultShortcut), + // 剪切自己补:监听 document,不掺进它那套 Mousetrap 绑定、也不依赖画布焦点 + document.addEventListener('keydown', handleKeydown) + // 删完元素焦点会丢,还给画布,内置的那批快捷键才能连着按 + ;['node:delete', 'edge:delete'].forEach((name) => instance.on(name, refocusCanvas)) + } lf.value = instance // 留空:ResizeObserver 挂上后第一次回调就会按真实尺寸 resize 一次 @@ -128,12 +167,101 @@ onMounted(() => { }) onBeforeUnmount(() => { + document.removeEventListener('keydown', handleKeydown) resizeObserver?.disconnect() resizeObserver = null lf.value?.destroy?.() lf.value = null }) +/* ---------------------------------------------------------------------------- + 快捷键 + ---------------------------------------------------------------------------- */ + +/** 焦点在输入类元素里时,按键交给输入框自己处理 */ +function isEditingField(target) { + if (!target) return false + const tag = target.tagName + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true +} + +/** 事件是不是从画布内部冒上来的(是的话 LogicFlow 内置的键盘绑定已经处理过) */ +function isEventInCanvas(event) { + const container = containerRef.value + return !!container && event.target instanceof Node && container.contains(event.target) +} + +/** 内置快捷键(@logicflow/core 的 Keyboard)在我们这里的键位 */ +const BUILTIN_SHORTCUTS = { c: 'ctrl+c', v: 'ctrl+v', z: 'ctrl+z', y: 'ctrl+y' } + +/** + * 全局 keydown:内置快捷键只在「事件冒泡经过画布容器」时才生效(LogicFlow 把 + * Mousetrap 绑在容器上,容器要持有焦点),鼠标点节点、删掉一个元素之后焦点就不在 + * 画布上了,表现成「执行一次就得再点一次画布」。所以这里: + * 1. 补一个内置没有的剪切; + * 2. 事件没经过画布时,用 Mousetrap 的 trigger 代理内置那几个动作。 + * 焦点在输入框里时整体让行,避免抢掉属性面板里的文本操作。 + */ +function handleKeydown(event) { + const instance = lf.value + if (!instance || isEditingField(event.target)) return + const mousetrap = instance.keyboard?.mousetrap + if (!mousetrap || event.altKey) return + + const key = String(event.key || '').toLowerCase() + const accel = event.ctrlKey || event.metaKey + + if (accel && key === 'x') { + if (cutSelection()) event.preventDefault() + return + } + // 已经冒泡经过画布:内置绑定处理过了,别再代理一遍 + if (isEventInCanvas(event)) return + + if (accel) { + const shortcut = BUILTIN_SHORTCUTS[key] + if (!shortcut) return + mousetrap.trigger(shortcut) + event.preventDefault() + return + } + if (key === 'backspace' || key === 'delete') { + mousetrap.trigger('backspace') + event.preventDefault() + } +} + +/** 删除元素后把焦点还给画布:内置的复制/粘贴/撤销/删除都靠容器聚焦才收得到键盘事件 */ +function refocusCanvas() { + containerRef.value?.focus({ preventScroll: true }) +} + +/** + * 剪切选中元素:LogicFlow 内置快捷键只有复制/粘贴/撤销/重做/删除,没有剪切。 + * + * 直接借用内置的复制逻辑(往它的剪贴板里放一份),剪贴板就与 Ctrl+C 互通 —— + * Ctrl+X 之后按 Ctrl+V 能粘回来;随后按内置 backspace 的做法把选中元素删掉。 + * @returns {boolean} 是否已处理(true 时调用方阻止浏览器默认行为) + */ +function cutSelection() { + const instance = lf.value + if (!instance) return false + // 正在编辑节点/连线文字时让输入框自己处理 + if (instance.graphModel.textEditElement) return false + // 拿不到内置剪贴板就别剪了,否则会变成「只删除、粘不回来」 + const mousetrap = instance.keyboard?.mousetrap + if (!mousetrap) return false + + const selected = instance.getSelectElements(true) + if (!selected.nodes.length && !selected.edges.length) return false + + mousetrap.trigger('ctrl+c') + instance.clearSelectElements() + selected.edges.forEach((edge) => edge.id && instance.deleteEdge(edge.id)) + selected.nodes.forEach((node) => node.id && instance.deleteNode(node.id)) + return true +} + /* ---------------------------------------------------------------------------- 对外能力(工具栏与校验面板通过 ref 调用) ---------------------------------------------------------------------------- */ @@ -171,10 +299,11 @@ function autoLayout() { const before = positionKey() try { dagre.layout({ - // 审批流程按纵向阅读,主链路自上而下 - rankdir: 'TB', + rankdir: LAYOUT_DIRECTION, align: 'UL', - nodesep: 60, + // 同层间距(同层的并行分支之间)。网关旁支离主干多远不归它管: + // dagre 只保证「不重叠」,旁支贴不贴主干由下面的 applyBranchSpread 统一处理 + nodesep: 100, ranksep: 80, // 布局后按新方向重算连线路径,避免折线还停在旧坐标上 isDefaultAnchor: true, @@ -184,6 +313,10 @@ function autoLayout() { console.error('[workflow] dagre 自动布局失败', error) return false } + + // dagre 只保证旁支「不压主干」,这里把它推到主干外侧的固定留白处 + applyBranchSpread() + const changed = positionKey() !== before snapshot() @@ -191,6 +324,42 @@ function autoLayout() { return changed } +/** + * 布局后处理:把网关分出的旁支推到主干外侧。 + * + * 坐标变了必须重新渲染;被移动节点上的连线路径是 dagre 插件按旧坐标算好、写进 pointsList 的, + * 所以要用插件同一套折线算法按新坐标重算这些边 —— 交给 LogicFlow 自己算会走成左右锚点, + * 和其余边的走线风格不一致。 + */ +function applyBranchSpread() { + const graph = lf.value.getGraphData() + const moved = spreadBranchNodes(graph, { defaultExitTargets: collectDefaultExitTargets() }) + if (!moved.size) return + + const affected = lf.value.graphModel.edges.filter( + (edge) => moved.has(edge.sourceNodeId) || moved.has(edge.targetNodeId), + ) + const refreshed = new Map( + processEdges(lf.value, LAYOUT_DIRECTION, true, affected, graph.nodes).map((edge) => [edge.id, edge]), + ) + graph.edges = graph.edges.map((edge) => refreshed.get(edge.id) || edge) + lf.value.renderRawData(graph) +} + +/** + * 领域草稿里条件为空的出线就是网关的默认出口 —— 视觉主干沿它走。 + * 草稿是最新的(属性面板改条件会同步回上层),不能靠画布上的连线文案来判断。 + */ +function collectDefaultExitTargets() { + const targets = new Map() + ;(props.draft?.edges || []).forEach((edge) => { + if (edge.condition) return + if (!targets.has(edge.from)) targets.set(edge.from, new Set()) + targets.get(edge.from).add(edge.to) + }) + return targets +} + function undo() { lf.value?.undo() } @@ -250,7 +419,11 @@ defineExpose({ diff --git a/code/fms/fms-vue/src/views/system/workflow/designer/WorkflowDesigner.vue b/code/fms/fms-vue/src/views/system/workflow/designer/WorkflowDesigner.vue index a9257c1f..899785c8 100644 --- a/code/fms/fms-vue/src/views/system/workflow/designer/WorkflowDesigner.vue +++ b/code/fms/fms-vue/src/views/system/workflow/designer/WorkflowDesigner.vue @@ -236,7 +236,8 @@ function onAutoLayout() { /* 节点面板悬浮在画板左上角:不占画布宽度,拖节点时视野更大。 height:auto 必须写 —— 舞台是定位上下文且有确定高度,不覆盖的话面板自带的 - height:100% 会让它铺满整个画布高度;overflow 保持面板自带的 auto,内容超高时自己滚。 */ + height:100% 会让它铺满整个画布高度;overflow 保持面板自带的 auto,内容超高时自己滚。 + 画布是白底,所以这里靠描边 + 轻阴影把它托起来,不然白面板贴在白画布上分不出边界。 */ .workflow-designer .workflow-designer__palette { position: absolute; top: 12px; @@ -245,8 +246,10 @@ function onAutoLayout() { width: 112px; height: auto; max-height: calc(100% - 24px); + background: var(--fms-card); border: 1px solid var(--fms-border); - border-radius: 6px; + border-radius: 8px; + box-shadow: var(--fms-popover-shadow); } /* 右栏与画布之间常显的分割线:Splitter 的分隔条平时是隐藏的(hover 才显), diff --git a/code/fms/fms-vue/src/views/system/workflow/designer/WorkflowNodePanel.vue b/code/fms/fms-vue/src/views/system/workflow/designer/WorkflowNodePanel.vue index 6aceca09..b16b8a47 100644 --- a/code/fms/fms-vue/src/views/system/workflow/designer/WorkflowNodePanel.vue +++ b/code/fms/fms-vue/src/views/system/workflow/designer/WorkflowNodePanel.vue @@ -6,11 +6,14 @@ * 假节点、吸附线与落点换算(WorkflowCanvas 暴露 startDrag)。 * 面板本身不碰画布实例,也不感知落点坐标。 * - * 每一项只保留「形状 + 名称」:形状与画布节点一一对应(圆=开始/结束、 - * 矩形=人工/抄送/服务、菱形=分支),看一眼就知道拖出来长什么样。 + * 每一项是「形状 + 图标 + 名称」的一行:形状放固定宽度的槽里,三种形状的标签左边缘才对得齐 + * (圆=开始/结束、矩形=人工/抄送/服务、菱形=分支),看一眼就知道拖出来长什么样; + * 形状中心再叠一个节点图标(头像=人工审批、纸飞机=抄送、分叉=分支……),用节点描边色, + * 图标取 icons.js 的 NODE_ICONS(同一份 lucide 数据,画布用的是它的 SVG 形态)。 * 悬浮定位与尺寸由 WorkflowDesigner 的 .workflow-designer__palette 控制。 */ import { NODE_META, NODE_PALETTE_ORDER, NODE_TYPE_TO_CANVAS } from './constants' +import { NODE_ICONS } from './icons' const emit = defineEmits(['drag-node']) @@ -35,14 +38,24 @@ function onPointerDown(nodeType, event) { :title="NODE_META[nodeType].hint" @pointerdown="onPointerDown(nodeType, $event)" > - + + + + + + + {{ NODE_META[nodeType].label }} @@ -52,65 +65,94 @@ function onPointerDown(nodeType, event) { .node-panel { display: flex; flex-direction: column; - gap: 4px; + gap: 2px; width: 100%; height: 100%; padding: 6px; overflow: auto; - background: var(--fms-background); + background: var(--fms-card); } +/* 整行都是拖拽区:靠 hover 底色提示可点,不用给每一项描边框 */ .node-panel__item { display: flex; - flex-direction: column; - gap: 4px; + flex: none; + gap: 8px; align-items: center; - justify-content: center; - padding: 6px 4px; + height: 32px; + padding: 0 6px; + color: var(--fms-text); cursor: grab; background: transparent; - border: 1px solid var(--fms-border); + border: 0; border-radius: var(--fms-control-radius); } .node-panel__item:hover { - border-color: var(--fms-primary); + background: var(--fms-secondary-hover); } .node-panel__item:active { + background: var(--fms-primary-soft); cursor: grabbing; } -/* 形状预览:尺寸与画布上的节点成比例,圆/矩形/菱形一眼可辨 */ +/* 形状槽:固定宽度 + 居中,圆形/矩形/菱形三种宽窄不一的形状也能让标签左边缘对齐; + 槽里叠了「形状底 + 中心图标」两层,图标要相对本槽居中,所以这里做定位上下文 */ .node-panel__shape { + position: relative; + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 30px; + height: 22px; +} + +.node-panel__glyph { box-sizing: border-box; display: block; border: 2px solid var(--fms-border); } -.node-panel__shape--circle { - width: 16px; - height: 16px; +/* 三种形状的视觉尺寸大致等比,菱形旋转后外接方框与圆形相当; + 形状要给 12px 的图标留出内圈,所以比无图标的版本略大 */ +.node-panel__glyph--circle { + width: 20px; + height: 20px; border-radius: 50%; } -.node-panel__shape--rect { - width: 28px; - height: 16px; - border-radius: 3px; +.node-panel__glyph--rect { + width: 26px; + height: 19px; + border-radius: 4px; } -.node-panel__shape--diamond { - width: 12px; - height: 12px; - border-radius: 2px; +.node-panel__glyph--diamond { + width: 16px; + height: 16px; + border-radius: 3px; transform: rotate(45deg); } +/* 节点图标:叠在形状中心(颜色与尺寸由模板传入,三种形状共用 12px)。 + 固定 12×12 而不是靠内容撑开,避免 inline 元素带出基线间隙 */ +.node-panel__icon { + position: absolute; + top: 50%; + left: 50%; + display: flex; + width: 12px; + height: 12px; + transform: translate(-50%, -50%); +} + .node-panel__label { + overflow: hidden; font-size: 12px; line-height: 1.2; - color: var(--fms-text); white-space: nowrap; + text-overflow: ellipsis; } diff --git a/code/fms/fms-vue/src/views/system/workflow/designer/constants.js b/code/fms/fms-vue/src/views/system/workflow/designer/constants.js index 622500a0..7607a867 100644 --- a/code/fms/fms-vue/src/views/system/workflow/designer/constants.js +++ b/code/fms/fms-vue/src/views/system/workflow/designer/constants.js @@ -9,6 +9,7 @@ * - 条件操作符 §7.1 * * 数据库存稳定编码,中文文案只在本文件出现一次,页面直接引用。 + * 图标不在这里(面板要 Vue 组件、画布要 SVG 数据),见 icons.js。 */ /** 工作流节点类型(落 wf_node.b_node_type) */ @@ -46,6 +47,9 @@ export const CANVAS_TO_NODE_TYPE = Object.fromEntries( * * 画布是 SVG,fill/stroke 走的是表现属性而不是 CSS,`var(--fms-*)` 在这里不生效, * 因此配色写成字面量(与 tokens.css 同色系)。深色模式下的画布配色待后续统一处理。 + * + * 面板与画布都用「形状 + 图标 + 名称」表现节点,图标按类型取 icons.js 的 NODE_ICONS, + * 这里只描述节点自身(形状、尺寸、配色、文案),不掺渲染形态。 */ export const NODE_META = { [NODE_TYPES.START]: { @@ -171,3 +175,88 @@ export const OPERATORS_WITHOUT_VALUE = ['is_null', 'is_not_null'] export function labelOf(list, value) { return list.find((item) => item.value === value)?.label || value || '' } + +/* ---------------------------------------------------------------------------- + 运行态(查看审批进度) + ---------------------------------------------------------------------------- */ + +/** + * 节点执行状态:pending / running / completed / skipped 对应 wf_node_run.b_status(§8.2); + * rejected、returned 是界面派生值 —— 节点出现驳回动作(§9.7)时按驳回策略落到这两个上。 + */ +export const RUN_STATUS = { + PENDING: 'pending', + RUNNING: 'running', + COMPLETED: 'completed', + SKIPPED: 'skipped', + REJECTED: 'rejected', + RETURNED: 'returned', +} + +/** + * 运行态的节点配色。 + * + * 查看进度时「颜色」这个通道整体让给执行状态,类型仍由图标与节点形状表达 —— + * 设计态用 NODE_META.style 的类型色,两种模式不在同一张图上混用,否则 + * 红色到底是「驳回了」还是「条件分支」会分不清。 + * + * fill 取状态色的极浅版,stroke 同时用于描边、图标与角标;badge 取 icons.js 的 + * RUN_STATUS_ICONS('dot' 是实心圆点,节点侧自绘)。textColor 只给弱化的状态用。 + */ +export const RUN_STATUS_META = { + [RUN_STATUS.COMPLETED]: { + label: '已完成', + fill: '#f0fdf4', + stroke: '#16a34a', + badge: 'check', + }, + [RUN_STATUS.RUNNING]: { + label: '进行中', + fill: '#eff6ff', + stroke: '#2563eb', + badge: 'dot', + /** 呼吸动画:类名挂在节点最外层,CSS 见 WorkflowCanvas */ + pulse: true, + }, + [RUN_STATUS.PENDING]: { + label: '待审批', + fill: '#ffffff', + stroke: '#a1a1aa', + dash: '6 4', + }, + [RUN_STATUS.SKIPPED]: { + label: '未经过', + fill: '#fafafa', + stroke: '#d4d4d8', + dash: '6 4', + textColor: '#a1a1aa', + }, + [RUN_STATUS.REJECTED]: { + label: '已驳回', + fill: '#fef2f2', + stroke: '#dc2626', + badge: 'x', + }, + [RUN_STATUS.RETURNED]: { + label: '已退回', + fill: '#fff7ed', + stroke: '#ea580c', + badge: 'undo', + }, +} + +/** 连线在运行态的状态(落 data.properties.fms_run_status) */ +export const RUN_EDGE_STATUS = { + PASSED: 'passed', + PENDING: 'pending', +} + +/** + * 运行态的连线配色:只表达「实际走过的路径」—— 走过实线高亮、没走到浅灰虚线。 + * 驳回/退回这类终止信息由节点状态与结束节点的结局色承担,不靠连线表达, + * 所以这里没有对应的线型。 + */ +export const RUN_EDGE_META = { + [RUN_EDGE_STATUS.PASSED]: { stroke: '#16a34a', strokeWidth: 2 }, + [RUN_EDGE_STATUS.PENDING]: { stroke: '#cbd5e1', strokeWidth: 1.5, dash: '6 4' }, +} diff --git a/code/fms/fms-vue/src/views/system/workflow/designer/domain.js b/code/fms/fms-vue/src/views/system/workflow/designer/domain.js index 2df218eb..cb0d45f1 100644 --- a/code/fms/fms-vue/src/views/system/workflow/designer/domain.js +++ b/code/fms/fms-vue/src/views/system/workflow/designer/domain.js @@ -26,6 +26,8 @@ import { NODE_TYPE_TO_CANVAS, NODE_TYPES, OPERATORS_WITHOUT_VALUE, + RUN_EDGE_STATUS, + RUN_STATUS, labelOf, } from './constants' @@ -148,6 +150,62 @@ export function workflowDomainToLogicFlow(draft) { } } +/** + * 把运行态状态合并进画布数据(只加 properties,不动已有结构与坐标)。 + * + * 图里要显示流程的全部节点,没走到的也得在图上,所以缺省状态由 defaultStatus 给 + * (不给按「待审批」): + * - 实例还在跑:没轮到的节点是「待审批」,流程还可能走到它们; + * - 实例已终态(驳回 / 退回 / 撤回 / 取消):没走到的节点是「未经过」,不会再执行了。 + * 这类节点在 wf_node_run 里没有记录(从未激活),状态只能由调用方按实例状态派生。 + * + * 连线缺省按「没走到」(浅灰虚线),走过的路径由 runtime.edges 显式给出; + * 连线只表达实际走过的路径,不在图上补「流程配置里没有」的线。 + * + * nodeTiming 是节点的时间口径(停留时长、以及后端按 dueHours 判定的超时时长), + * 单位毫秒;图上按需显示耗时小字,超时不受显示开关影响。elapsedMs / overdueMs 缺失 + * 或 ≤ 0 即视为没有可显示的时间。 + * + * @param {object} graph workflowDomainToLogicFlow 的产出 + * @param {{ + * nodes?: Record, + * edges?: Record, + * nodeTiming?: Record, + * defaultStatus?: string, + * showElapsed?: boolean, + * }} runtime 节点/连线编码 → 状态(RUN_STATUS / RUN_EDGE_STATUS 的键) + */ +export function applyRuntimeStatus(graph, runtime) { + const nodeStatus = runtime?.nodes || {} + const edgeStatus = runtime?.edges || {} + const nodeTiming = runtime?.nodeTiming || {} + const showElapsed = !!runtime?.showElapsed + const defaultStatus = runtime?.defaultStatus || RUN_STATUS.PENDING + return { + nodes: graph.nodes.map((node) => { + const timing = nodeTiming[node.id] || {} + return { + ...node, + properties: { + ...node.properties, + fms_run_status: nodeStatus[node.id] || defaultStatus, + fms_elapsed_ms: timing.elapsedMs, + fms_overdue_ms: timing.overdueMs, + fms_show_elapsed: showElapsed, + }, + } + }), + edges: graph.edges.map((edge) => ({ + ...edge, + type: 'fms-runtime-edge', + properties: { + ...edge.properties, + fms_run_status: edgeStatus[edge.id] || RUN_EDGE_STATUS.PENDING, + }, + })), + } +} + /* ---------------------------------------------------------------------------- 画布模型 → 领域模型 diff --git a/code/fms/fms-vue/src/views/system/workflow/designer/icons.js b/code/fms/fms-vue/src/views/system/workflow/designer/icons.js new file mode 100644 index 00000000..ad0a77f7 --- /dev/null +++ b/code/fms/fms-vue/src/views/system/workflow/designer/icons.js @@ -0,0 +1,47 @@ +/** + * 流程设计器 —— 节点图标与运行态角标图标。 + * + * 同一份 lucide 图标要出现在两个渲染上下文里,所以成对提供: + * - component:Vue 侧(节点面板、运行态图例)用 @lucide/vue 的图标组件; + * - node:画布是 LogicFlow 的 preact/SVG 树,塞不进 Vue 组件,改取 lucide 的 + * iconNode([元素名, 属性] 数组),由 nodes.js 用 h() 画成 SVG。 + * + * iconNode 走 deep import 到具体图标模块:包内没有 exports 限制,且一个图标一个模块, + * 打包只会带上这里用到的这几个(图标为 ISC 许可,随 @lucide/vue 一起分发)。 + */ +import { Check, Cog, Flag, GitBranch, GitFork, Play, Send, Undo2, UserRound, X } from '@lucide/vue' +import { __iconNode as checkNode } from '@lucide/vue/dist/esm/icons/check.mjs' +import { __iconNode as cogNode } from '@lucide/vue/dist/esm/icons/cog.mjs' +import { __iconNode as flagNode } from '@lucide/vue/dist/esm/icons/flag.mjs' +import { __iconNode as gitBranchNode } from '@lucide/vue/dist/esm/icons/git-branch.mjs' +import { __iconNode as gitForkNode } from '@lucide/vue/dist/esm/icons/git-fork.mjs' +import { __iconNode as playNode } from '@lucide/vue/dist/esm/icons/play.mjs' +import { __iconNode as sendNode } from '@lucide/vue/dist/esm/icons/send.mjs' +import { __iconNode as undo2Node } from '@lucide/vue/dist/esm/icons/undo-2.mjs' +import { __iconNode as userRoundNode } from '@lucide/vue/dist/esm/icons/user-round.mjs' +import { __iconNode as xNode } from '@lucide/vue/dist/esm/icons/x.mjs' +import { NODE_TYPES } from './constants' + +/** + * 节点类型 → 图标:开始=启动、人工审批=头像、抄送=纸飞机、 + * 条件分支=择一分叉、并行分支=多头分叉、服务节点=齿轮、结束=终点旗。 + */ +export const NODE_ICONS = { + [NODE_TYPES.START]: { component: Play, node: playNode }, + [NODE_TYPES.USER_TASK]: { component: UserRound, node: userRoundNode }, + [NODE_TYPES.CC_TASK]: { component: Send, node: sendNode }, + [NODE_TYPES.EXCLUSIVE_GATEWAY]: { component: GitBranch, node: gitBranchNode }, + [NODE_TYPES.PARALLEL_GATEWAY]: { component: GitFork, node: gitForkNode }, + [NODE_TYPES.SERVICE_TASK]: { component: Cog, node: cogNode }, + [NODE_TYPES.END]: { component: Flag, node: flagNode }, +} + +/** + * 运行态角标图标(键对应 RUN_STATUS_META.badge): + * 已完成=对勾、已驳回=叉、已退回=回头箭头;「进行中」的圆点在节点侧自绘,不在这里。 + */ +export const RUN_STATUS_ICONS = { + check: { component: Check, node: checkNode }, + x: { component: X, node: xNode }, + undo: { component: Undo2, node: undo2Node }, +} diff --git a/code/fms/fms-vue/src/views/system/workflow/designer/nodes.js b/code/fms/fms-vue/src/views/system/workflow/designer/nodes.js index ab1796e4..d2303f03 100644 --- a/code/fms/fms-vue/src/views/system/workflow/designer/nodes.js +++ b/code/fms/fms-vue/src/views/system/workflow/designer/nodes.js @@ -1,23 +1,42 @@ /** - * 流程设计器 —— LogicFlow 自定义节点注册。 + * 流程设计器 —— LogicFlow 自定义节点与运行态连线注册。 * * 7 种画布节点与 wf_node.b_node_type 一一映射(§19.5): * fms-start / fms-user-task / fms-cc-task / fms-exclusive-gateway * fms-parallel-gateway / fms-service-task / fms-end * - * 形状与尺寸在模型层用 setAttributes() 定(矩形 width/height、圆 r、菱形 rx/ry), - * 配色覆写 getNodeStyle()。本项目没装 @logicflow/vue-node-registry, - * 因此节点一律用 LogicFlow 原生 SVG 形状渲染,不内嵌 Vue 组件。 + * 一个节点 = 形状 + 图标 + 名称: + * - 形状仍用 LogicFlow 的 shape 组件(Rect / Circle / Polygon),配色覆写 getNodeStyle(); + * - 图标是 lucide 的 iconNode(见 icons.js),由 renderIcon() 画进同一棵 SVG,取节点描边色; + * - 名称仍交给 LogicFlow 的 BaseText:开了 autoWrap 后走 foreignObject + HTML, + * 默认左对齐,位置只能在 getTextStyle() 里用 x/y/textWidth/textAlign 摆。 + * 两者的位置都在下方三组 *_LAYOUT 里:文字块要避开图标、四角不越出形状边界。 + * + * 配色分两种模式,同一张图只用一种(配色见 constants.js): + * - 设计态(节点没有 fms_run_status):按节点类型着色,看颜色形状就知道是什么节点; + * - 运行态(properties.fms_run_status 有值):颜色让给执行状态,右上角加状态角标, + * 类型仍由图标与形状表达 —— 见 resolveNodeStyle() 与 renderBadge()。 + * + * 形状与尺寸在模型层用 setAttributes() 定(矩形 width/height、圆 r、菱形 rx/ry)。 + * 本项目没装 @logicflow/vue-node-registry,节点一律用 LogicFlow 原生 SVG 绘制,不内嵌 Vue 组件。 */ import { + Circle, CircleNode, CircleNodeModel, DiamondNode, DiamondNodeModel, + Polygon, + PolylineEdge, + PolylineEdgeModel, + Rect, RectNode, RectNodeModel, + h, } from '@logicflow/core' -import { CANVAS_TO_NODE_TYPE, NODE_META } from './constants' +import { CANVAS_TO_NODE_TYPE, NODE_META, RUN_EDGE_META, RUN_STATUS_META } from './constants' +import { NODE_ICONS, RUN_STATUS_ICONS } from './icons' +import { formatDuration } from './time' const TEXT_STYLE = { color: '#1f2329', @@ -26,11 +45,93 @@ const TEXT_STYLE = { wrapPadding: '6,8', } +/** 图标线宽:iconNode 基于 24×24 的 viewBox,会随图标尺寸等比缩放 */ +const ICON_STROKE_WIDTH = 2 + +/** 运行态角标:半径与「贴在节点外框右上角」的位置系数 */ +const BADGE_RADIUS = 9 +const BADGE_INSET = 0.72 + +/** 矩形节点(160×48):图标在左,名称占右侧剩余宽度 */ +const RECT_LAYOUT = { + iconSize: 18, + /** 图标中心距节点左边的距离 */ + iconInset: 20, + /** 文字块与图标、节点右边的间距 */ + textInsetLeft: 38, + textInsetRight: 12, +} + +/** + * 圆形节点(r=26):图标在上、名称在下。 + * 文字块实际占位 34×13(两三个字),四角落在半径 24(去掉 2px 描边)的圆内; + * textWidth 只当折行阈值,给得比实际宽度宽一点,免得 4 个字被折成两行挤出去。 + */ +const CIRCLE_LAYOUT = { + iconSize: 15, + /** 图标中心相对节点中心的纵向偏移(负值在上) */ + iconOffsetY: -8, + textOffsetY: 10, + textWidth: 36, + fontSize: 11, +} + +/** + * 菱形节点(rx=ry=34):同理,但菱形下半部收得更快,文字宽度要按 + * 「半宽 + 纵向偏移 + 半个文字行高 ≤ 34」倒推,所以字更小、宽度更窄。 + * 折行阈值 52 保证「条件分支」这类四字名不折行(实际占位 44×13)。 + */ +const DIAMOND_LAYOUT = { + iconSize: 15, + iconOffsetY: -12, + textOffsetY: 5, + textWidth: 52, + fontSize: 11, +} + /** 画布类型 → 节点元数据 */ function metaOf(canvasType) { return NODE_META[CANVAS_TO_NODE_TYPE[canvasType]] } +/* ---------------------------------------------------------------------------- + 运行态:查看审批进度时的状态渲染 + ---------------------------------------------------------------------------- */ + +/** 节点的运行态状态;设计态没有这个属性,返回 null */ +function nodeRunStatus(model) { + return model.properties?.fms_run_status || null +} + +/** + * 节点配色:运行态按状态取色,设计态按类型取色。 + * + * 运行态另外给一份 kebab 的 stroke-width —— Circle / Polygon 的 shape 组件只铺属性、 + * 不做大小写转换,设计态那份 camelCase 的 strokeWidth 对它们其实不生效(沿用原样,不动设计态)。 + */ +function resolveNodeStyle(meta, status) { + const run = RUN_STATUS_META[status] + if (!run) return { ...meta.style, strokeWidth: 2 } + return { + fill: run.fill, + stroke: run.stroke, + 'stroke-width': 2, + ...(run.dash ? { 'stroke-dasharray': run.dash } : null), + } +} + +/** 运行态里「未经过」这类弱化状态把文字压淡,其余状态保持默认字色 */ +function withRunTextColor(style, status) { + const color = RUN_STATUS_META[status]?.textColor + return color ? { ...style, color } : style +} + +/** 运行态「进行中」的节点挂一个呼吸动画类(动画定义在 WorkflowCanvas 的样式里) */ +function withRunPulseClass(attrs, status) { + if (!RUN_STATUS_META[status]?.pulse) return attrs + return { ...attrs, className: `${attrs?.className || ''} fms-node--running`.trim() } +} + /** * 连线规则(§19.6 编辑器侧的即时校验): * 开始节点只能作为起点,结束节点只能作为终点,避免连出语义不成立的流程。 @@ -46,8 +147,111 @@ function connectionRules(canvasType) { return {} } +/** + * 画一个 lucide 图标:iconNode 的坐标基于 24×24 的 viewBox, + * 这里用 transform 平移 + 缩放整个图标,不去改子元素自身的坐标。 + * 描边色由调用方给(设计态是类型色、运行态是状态色),线宽/圆角照 lucide 的约定。 + */ +function renderIcon(nodeType, cx, cy, size, stroke) { + const iconNode = NODE_ICONS[nodeType]?.node + if (!iconNode) return null + const scale = size / 24 + return h( + 'g', + { + transform: `translate(${cx - size / 2} ${cy - size / 2}) scale(${scale})`, + fill: 'none', + stroke, + 'stroke-width': ICON_STROKE_WIDTH, + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }, + iconNode.map(([tag, attrs]) => h(tag, { ...attrs })), + ) +} + +/** + * 运行态角标:白底圆 + 状态符号,压在节点外框的右上角。 + * 「进行中」的符号是实心圆点(配合呼吸动画),其余状态取 RUN_STATUS_ICONS 的图标。 + */ +function renderBadge(status, cx, cy) { + const run = RUN_STATUS_META[status] + if (!run?.badge) return null + const children = [ + h('circle', { + cx, + cy, + r: BADGE_RADIUS, + fill: '#ffffff', + stroke: run.stroke, + 'stroke-width': 1.5, + }), + ] + const icon = RUN_STATUS_ICONS[run.badge] + if (icon) { + const size = 11 + const scale = size / 24 + children.push( + h( + 'g', + { + transform: `translate(${cx - size / 2} ${cy - size / 2}) scale(${scale})`, + fill: 'none', + stroke: run.stroke, + 'stroke-width': 2.6, + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }, + icon.node.map(([tag, attrs]) => h(tag, { ...attrs })), + ), + ) + } else { + children.push(h('circle', { cx, cy, r: 3.5, fill: run.stroke })) + } + return h('g', { className: 'fms-node-badge' }, children) +} + +/** 节点下方耗时小字的两种字色:普通耗时灰、超时琥珀(不占用状态色的通道) */ +const TIMING_TONE = '#71717a' +const OVERDUE_TONE = '#d97706' + +/** 节点该显示的耗时文案:超时不受开关影响,普通耗时只在「显示耗时」打开时出 */ +function timingOf(model) { + const overdue = Number(model.properties?.fms_overdue_ms) || 0 + if (overdue > 0) return { text: `超时 ${formatDuration(overdue)}`, tone: OVERDUE_TONE } + if (!model.properties?.fms_show_elapsed) return null + const elapsed = Number(model.properties?.fms_elapsed_ms) || 0 + if (elapsed <= 0) return null + return { text: `用时 ${formatDuration(elapsed)}`, tone: TIMING_TONE } +} + +/** + * 节点下方的耗时小字。 + * 画在节点外的正下方而不是节点里面:三种形状的内部空间都给了图标和名称, + * 塞不下第二行;白色描边(paint-order)保证压在连线上时也读得清。 + */ +function renderTiming(model, x, y) { + const timing = timingOf(model) + if (!timing) return null + return h( + 'text', + { + x, + y, + textAnchor: 'middle', + fill: timing.tone, + fontSize: 11, + stroke: '#ffffff', + 'stroke-width': 3, + 'paint-order': 'stroke', + }, + timing.text, + ) +} + /** 圆形节点(开始 / 结束) */ function circleConfig(canvasType) { + const nodeType = CANVAS_TO_NODE_TYPE[canvasType] const meta = metaOf(canvasType) const rules = connectionRules(canvasType) class Model extends CircleNodeModel { @@ -56,11 +260,26 @@ function circleConfig(canvasType) { } getNodeStyle() { - return { ...super.getNodeStyle(), ...meta.style, strokeWidth: 2 } + return { ...super.getNodeStyle(), ...resolveNodeStyle(meta, nodeRunStatus(this)) } } getTextStyle() { - return { ...super.getTextStyle(), ...TEXT_STYLE } + return withRunTextColor( + { + ...super.getTextStyle(), + ...TEXT_STYLE, + fontSize: CIRCLE_LAYOUT.fontSize, + textAlign: 'center', + textWidth: CIRCLE_LAYOUT.textWidth, + x: this.x, + y: this.y + CIRCLE_LAYOUT.textOffsetY, + }, + nodeRunStatus(this), + ) + } + + getOuterGAttributes() { + return withRunPulseClass(super.getOuterGAttributes(), nodeRunStatus(this)) } getConnectedSourceRules() { @@ -73,12 +292,36 @@ function circleConfig(canvasType) { return rules.target ? [...base, rules.target] : base } } - class View extends CircleNode {} + + class View extends CircleNode { + getShape() { + const model = this.props.model + const style = model.getNodeStyle() + return h('g', {}, [ + h(Circle, { ...style, x: model.x, y: model.y, r: model.r }), + renderIcon( + nodeType, + model.x, + model.y + CIRCLE_LAYOUT.iconOffsetY, + CIRCLE_LAYOUT.iconSize, + style.stroke, + ), + renderBadge( + nodeRunStatus(model), + model.x + model.r * BADGE_INSET, + model.y - model.r * BADGE_INSET, + ), + renderTiming(model, model.x, model.y + model.r + 16), + ]) + } + } + return { type: canvasType, view: View, model: Model } } /** 矩形节点(人工审批 / 抄送 / 服务节点) */ function rectConfig(canvasType) { + const nodeType = CANVAS_TO_NODE_TYPE[canvasType] const meta = metaOf(canvasType) class Model extends RectNodeModel { setAttributes() { @@ -88,19 +331,66 @@ function rectConfig(canvasType) { } getNodeStyle() { - return { ...super.getNodeStyle(), ...meta.style, strokeWidth: 2 } + return { ...super.getNodeStyle(), ...resolveNodeStyle(meta, nodeRunStatus(this)) } } getTextStyle() { - return { ...super.getTextStyle(), ...TEXT_STYLE } + const textWidth = this.width - RECT_LAYOUT.textInsetLeft - RECT_LAYOUT.textInsetRight + return withRunTextColor( + { + ...super.getTextStyle(), + ...TEXT_STYLE, + textAlign: 'left', + textWidth, + // 文字块左对齐,所以整体右移到图标右侧:块中心 = 块左边界 + 半个块宽 + x: this.x - this.width / 2 + RECT_LAYOUT.textInsetLeft + textWidth / 2, + y: this.y, + }, + nodeRunStatus(this), + ) + } + + getOuterGAttributes() { + return withRunPulseClass(super.getOuterGAttributes(), nodeRunStatus(this)) } } - class View extends RectNode {} + + class View extends RectNode { + getShape() { + const model = this.props.model + const style = model.getNodeStyle() + return h('g', {}, [ + h(Rect, { + ...style, + x: model.x, + y: model.y, + width: model.width, + height: model.height, + radius: model.radius, + }), + renderIcon( + nodeType, + model.x - model.width / 2 + RECT_LAYOUT.iconInset, + model.y, + RECT_LAYOUT.iconSize, + style.stroke, + ), + renderBadge( + nodeRunStatus(model), + model.x + model.width / 2, + model.y - model.height / 2, + ), + renderTiming(model, model.x, model.y + model.height / 2 + 16), + ]) + } + } + return { type: canvasType, view: View, model: Model } } /** 菱形节点(条件分支 / 并行分支) */ function diamondConfig(canvasType) { + const nodeType = CANVAS_TO_NODE_TYPE[canvasType] const meta = metaOf(canvasType) class Model extends DiamondNodeModel { setAttributes() { @@ -109,14 +399,52 @@ function diamondConfig(canvasType) { } getNodeStyle() { - return { ...super.getNodeStyle(), ...meta.style, strokeWidth: 2 } + return { ...super.getNodeStyle(), ...resolveNodeStyle(meta, nodeRunStatus(this)) } } getTextStyle() { - return { ...super.getTextStyle(), ...TEXT_STYLE } + return withRunTextColor( + { + ...super.getTextStyle(), + ...TEXT_STYLE, + fontSize: DIAMOND_LAYOUT.fontSize, + textAlign: 'center', + textWidth: DIAMOND_LAYOUT.textWidth, + x: this.x, + y: this.y + DIAMOND_LAYOUT.textOffsetY, + }, + nodeRunStatus(this), + ) + } + + getOuterGAttributes() { + return withRunPulseClass(super.getOuterGAttributes(), nodeRunStatus(this)) } } - class View extends DiamondNode {} + + class View extends DiamondNode { + getShape() { + const model = this.props.model + const style = model.getNodeStyle() + return h('g', {}, [ + h(Polygon, { ...style, points: model.points, x: model.x, y: model.y }), + renderIcon( + nodeType, + model.x, + model.y + DIAMOND_LAYOUT.iconOffsetY, + DIAMOND_LAYOUT.iconSize, + style.stroke, + ), + renderBadge( + nodeRunStatus(model), + model.x + meta.size.rx * BADGE_INSET, + model.y - meta.size.ry * BADGE_INSET, + ), + renderTiming(model, model.x, model.y + meta.size.ry + 16), + ]) + } + } + return { type: canvasType, view: View, model: Model } } @@ -132,3 +460,36 @@ export function registerWorkflowNodes(lf) { diamondConfig('fms-parallel-gateway'), ]) } + +/** + * 连线的运行态配色:走过的路径实线高亮、没走到的分支浅灰虚线, + * 箭头取 getEdgeStyle 的描边色(BaseEdgeModel 的箭头样式跟着 stroke 走)。 + * 设计态的边用内置 polyline,不走这里。 + */ +function runEdgeStyle(baseStyle, status) { + const run = RUN_EDGE_META[status] + if (!run) return baseStyle + return { + ...baseStyle, + stroke: run.stroke, + 'stroke-width': run.strokeWidth, + ...(run.dash ? { 'stroke-dasharray': run.dash } : null), + } +} + +/** 运行态折线边:正常流转路径 */ +class RuntimePolylineEdgeModel extends PolylineEdgeModel { + getEdgeStyle() { + return runEdgeStyle(super.getEdgeStyle(), this.properties?.fms_run_status) + } +} + +/** + * 把运行态用到的边注册到 LogicFlow 实例。 + * + * 连线只表达「实际走过的路径」:走过=实线、没走到=虚线。驳回/退回这类终止信息 + * 由节点角标与结束节点的结局色承担,不往图上补线。 + */ +export function registerWorkflowEdges(lf) { + lf.register({ type: 'fms-runtime-edge', view: PolylineEdge, model: RuntimePolylineEdgeModel }) +} diff --git a/code/fms/fms-vue/src/views/system/workflow/designer/time.js b/code/fms/fms-vue/src/views/system/workflow/designer/time.js new file mode 100644 index 00000000..984a5b48 --- /dev/null +++ b/code/fms/fms-vue/src/views/system/workflow/designer/time.js @@ -0,0 +1,34 @@ +/** + * 流程设计器 —— 时长的展示口径。 + * + * 图上的耗时小字、抽屉里的停留时间、横幅的总耗时都走这里,同一段时间在三个地方 + * 长得一样。只做「人类可读」,不做业务计算:超时与否由后端按节点 dueHours 判定 + * (§6.4),前端拿到的就是「超了多久」这个结论。 + */ + +const SECOND = 1000 +const MINUTE = 60 * SECOND +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR + +/** + * 毫秒 → 人类可读时长:不到 1 分钟到秒、不到 1 小时到分、不到 1 天到「时+分」、 + * 再往上到「天+时」。只保留两级单位,不出现小数(避免「2.37 小时」这种读数)。 + * 非法值与非正数返回空串,调用方拿空串即当「没有可显示的时间」。 + * @param {number} ms + */ +export function formatDuration(ms) { + if (!Number.isFinite(ms) || ms <= 0) return '' + if (ms < MINUTE) return `${Math.max(1, Math.round(ms / SECOND))} 秒` + if (ms < HOUR) return `${Math.round(ms / MINUTE)} 分钟` + if (ms < DAY) { + return joinUnits(Math.floor(ms / HOUR), Math.round((ms % HOUR) / MINUTE), '小时', '分') + } + return joinUnits(Math.floor(ms / DAY), Math.round((ms % DAY) / HOUR), '天', '小时') +} + +/** 低位单位是 0 时只留高位:「3 天」而不是「3 天 0 小时」 */ +function joinUnits(high, low, highUnit, lowUnit) { + if (!low) return `${high} ${highUnit}` + return `${high} ${highUnit} ${low} ${lowUnit}` +} diff --git a/code/fms/fms-vue/src/views/system/workflow/designer/utils.js b/code/fms/fms-vue/src/views/system/workflow/designer/utils.js new file mode 100644 index 00000000..04d89310 --- /dev/null +++ b/code/fms/fms-vue/src/views/system/workflow/designer/utils.js @@ -0,0 +1,320 @@ +/** + * 流程设计器 —— 一键整理的布局后处理。 + * + * dagre 只管「节点不重叠」,不管整张图好不好读,于是有这些难看的排布: + * 1. 主干被排成两列(并行段前后断开),连不出一条竖线; + * 2. 并行网关贴在最左那条支线上,只有一条连线是直的; + * 3. 网关的旁支被排到与主干同一列,直连的长边从旁支节点正中穿过去; + * 4. 旁支即使错开,也只让开到「刚好不重叠」,看着仍像一列。 + * 这里在 dagre 布局之后依次处理:并行段对称 → 主干拉直居中 → 被线穿过的节点推开 → + * 旁支留白。每一步都用上一步调完的坐标做判定。 + */ +import { CANVAS_TO_NODE_TYPE, NODE_META, NODE_TYPES } from './constants' + +/** 旁支(或被穿过节点)与主干之间保留的水平留白(px,指节点边界到线的距离) */ +export const BRANCH_GAP = 120 + +/** 判断「同一列」的坐标容差(px) */ +const SAME_COLUMN_TOLERANCE = 1 + +/** 连线折线的默认转角偏移(px):与 @logicflow/layout 的取法保持一致 */ +const DEFAULT_EDGE_OFFSET = 50 + +/** 判定「穿过」时把节点外扩的余量(px):贴边也算穿过,免得看着像压线 */ +const CROSS_PADDING = 2 + +const GATEWAY_TYPES = [NODE_TYPES.EXCLUSIVE_GATEWAY, NODE_TYPES.PARALLEL_GATEWAY] + +function isGatewayNode(node) { + return GATEWAY_TYPES.includes(CANVAS_TO_NODE_TYPE[node?.type]) +} + +function isParallelGatewayNode(node) { + return CANVAS_TO_NODE_TYPE[node?.type] === NODE_TYPES.PARALLEL_GATEWAY +} + +/** 这些节点是否排在同一层(y 相同) */ +function isSameRow(peers) { + return peers.every((node) => Math.abs(node.y - peers[0].y) < SAME_COLUMN_TOLERANCE) +} + +/** + * 只平移横坐标。节点文本可能是带独立坐标的对象(画布数据回读出来的就是这种), + * 不同步搬的话渲染出来文字会留在原地 —— 看着就是「框动了、字还在原来那儿」。 + */ +function moveNodeX(node, shift) { + node.x += shift + if (node.text && typeof node.text === 'object' && typeof node.text.x === 'number') { + node.text.x += shift + } +} + +/** 画布节点尺寸:优先用模型尺寸,取不到时退回节点元数据的默认尺寸 */ +function nodeWidth(node) { + if (typeof node.width === 'number' && node.width > 0) return node.width + const meta = NODE_META[CANVAS_TO_NODE_TYPE[node.type]] + return meta?.size?.width ?? (meta?.size?.rx ? meta.size.rx * 2 : 150) +} + +function nodeHeight(node) { + if (typeof node.height === 'number' && node.height > 0) return node.height + const meta = NODE_META[CANVAS_TO_NODE_TYPE[node.type]] + return meta?.size?.height ?? (meta?.size?.ry ? meta.size.ry * 2 : 50) +} + +/** + * TB 布局下一条连线重算出来的折线(与 @logicflow/layout 的走线一致): + * 源节点底部 → 向下 offset → 水平移到目标列 → 向下到目标节点顶部。 + * 同列时退化成一条竖直直线。反向连线(目标在源上方)走侧面折线,这里不参与判定。 + */ +function verticalEdgeSegments(source, target, offset) { + const sourceBottom = source.y + nodeHeight(source) / 2 + const targetTop = target.y - nodeHeight(target) / 2 + if (Math.abs(source.x - target.x) < SAME_COLUMN_TOLERANCE) { + return [{ x1: source.x, y1: sourceBottom, x2: target.x, y2: targetTop }] + } + const midY = sourceBottom + offset + return [ + { x1: source.x, y1: sourceBottom, x2: source.x, y2: midY }, + { x1: source.x, y1: midY, x2: target.x, y2: midY }, + { x1: target.x, y1: midY, x2: target.x, y2: targetTop }, + ] +} + +/** 线段(水平或竖直)与节点矩形是否相交 */ +function segmentCrossesNode(segment, node, width, height) { + const left = node.x - width / 2 - CROSS_PADDING + const right = node.x + width / 2 + CROSS_PADDING + const top = node.y - height / 2 - CROSS_PADDING + const bottom = node.y + height / 2 + CROSS_PADDING + return ( + Math.max(segment.x1, segment.x2) >= left && + Math.min(segment.x1, segment.x2) <= right && + Math.max(segment.y1, segment.y2) >= top && + Math.min(segment.y1, segment.y2) <= bottom + ) +} + +/** + * 收集一条旁支上的节点:从分支首节点沿出边向下走,遇到「与网关同列」的节点 + * (说明已经汇合回主干)就停下,不含汇合点本身。 + */ +function collectBranchNodes(rootId, byId, outgoing, trunkX) { + const collected = [] + const seen = new Set() + const queue = [rootId] + while (queue.length) { + const id = queue.shift() + if (seen.has(id)) continue + seen.add(id) + const node = byId.get(id) + if (!node) continue + if (Math.abs(node.x - trunkX) < SAME_COLUMN_TOLERANCE) continue + collected.push(node) + ;(outgoing.get(id) || []).forEach((nextId) => queue.push(nextId)) + } + return collected +} + +/** + * 并行网关与它的分支组中轴对称:分叉网关居中到下面各分支的中间,汇合网关居中到上面各分支的 + * 中间 —— dagre 会把网关对齐到某一列(通常是主干那列,也就是最左那条支线),连线只有一条是直的。 + */ +function centerParallelGateways(nodes, byId, outgoing, incoming, moved) { + const centerOn = (gateway, branches) => { + const left = Math.min(...branches.map((node) => node.x - nodeWidth(node) / 2)) + const right = Math.max(...branches.map((node) => node.x + nodeWidth(node) / 2)) + const shift = (left + right) / 2 - gateway.x + if (Math.abs(shift) < SAME_COLUMN_TOLERANCE) return + moveNodeX(gateway, shift) + moved.add(gateway.id) + } + + nodes.filter(isParallelGatewayNode).forEach((gateway) => { + const branches = (outgoing.get(gateway.id) || []).map((id) => byId.get(id)).filter(Boolean) + if (branches.length >= 2 && isSameRow(branches)) { + centerOn(gateway, branches) + return + } + const sources = (incoming.get(gateway.id) || []).map((id) => byId.get(id)).filter(Boolean) + if (sources.length >= 2 && isSameRow(sources)) { + centerOn(gateway, sources) + } + }) +} + +/** 并行分支的汇合点:各支线第一跳的共同下游 */ +function findJoinNode(branchIds, outgoing) { + const sets = branchIds.map((id) => new Set(outgoing.get(id) || [])) + if (!sets.length) return undefined + const [first, ...rest] = sets + for (const candidate of first) { + if (rest.every((set) => set.has(candidate))) return candidate + } + return undefined +} + +/** + * 视觉主干:从开始节点出发,每遇分叉走默认出口,走到结束节点。 + * 并行网关没有默认出口,主干在它这里直接跳到汇合网关(中间的支线算旁支)。 + * @param {Map>} [defaultExitTargets] source → 该节点上默认出口的出线目标 + */ +function findTrunkNodes(nodes, outgoing, incoming, defaultExitTargets) { + const byId = new Map(nodes.map((node) => [node.id, node])) + const trunk = [] + const seen = new Set() + let current = nodes.find((node) => !(incoming.get(node.id) || []).length) + while (current && !seen.has(current.id)) { + seen.add(current.id) + trunk.push(current) + const nextIds = outgoing.get(current.id) || [] + if (!nextIds.length) break + let nextId = nextIds[0] + if (nextIds.length > 1 && isParallelGatewayNode(current)) { + // 并行分支没有「默认出口」可言,主干直接跳到汇合网关(中间几条支线都算旁支) + nextId = findJoinNode(nextIds, outgoing) ?? nextIds[0] + } else if (nextIds.length > 1) { + const defaults = defaultExitTargets?.get(current.id) + nextId = nextIds.find((id) => defaults?.has(id)) ?? nextIds[0] + } + current = byId.get(nextId) + } + return trunk +} + +/** 挂在主干节点下面的旁支(不在主干上的节点),一路收到汇合回主干为止 */ +function collectBranchSubtree(rootId, byId, outgoing, trunkIds) { + const collected = [] + const seen = new Set() + const queue = [...(outgoing.get(rootId) || [])] + while (queue.length) { + const id = queue.shift() + if (seen.has(id) || trunkIds.has(id)) continue + seen.add(id) + const node = byId.get(id) + if (!node) continue + collected.push(node) + ;(outgoing.get(id) || []).forEach((nextId) => queue.push(nextId)) + } + return collected +} + +/** + * 主干拉直成一条垂直中线:主干节点对齐到中轴(主干 x 的中位数 —— 并行段居中后它就是 + * 分支组的中心),挂在主干节点下面的旁支跟着搬,保持它与主干的相对位置。 + */ +function alignTrunk(nodes, byId, outgoing, incoming, defaultExitTargets, moved) { + const trunk = findTrunkNodes(nodes, outgoing, incoming, defaultExitTargets) + if (trunk.length < 2) return + + const axis = trunk + .map((node) => node.x) + .sort((a, b) => a - b)[Math.floor(trunk.length / 2)] + const trunkIds = new Set(trunk.map((node) => node.id)) + const shifted = new Set() + trunk.forEach((node) => { + const shift = axis - node.x + if (Math.abs(shift) < SAME_COLUMN_TOLERANCE) return + moveNodeX(node, shift) + moved.add(node.id) + collectBranchSubtree(node.id, byId, outgoing, trunkIds).forEach((branch) => { + if (shifted.has(branch.id)) return + shifted.add(branch.id) + moveNodeX(branch, shift) + moved.add(branch.id) + }) + }) +} + +/** 被连线折线穿过的节点,推到该折线外侧的固定留白处 */ +function spreadCrossedNodes(nodes, edges, byId, moved, gap, pushed) { + edges.forEach((edge) => { + const source = byId.get(edge.sourceNodeId) + const target = byId.get(edge.targetNodeId) + if (!source || !target) return + // 反向连线走侧面折线,几何规则不同,先不参与判定 + if (source.y > target.y + SAME_COLUMN_TOLERANCE) return + + const offset = Number(edge.offset) || DEFAULT_EDGE_OFFSET + const segments = verticalEdgeSegments(source, target, offset) + nodes.forEach((node) => { + if (node.id === source.id || node.id === target.id) return + const width = nodeWidth(node) + const height = nodeHeight(node) + const hit = segments.find((segment) => segmentCrossesNode(segment, node, width, height)) + if (!hit) return + const toRight = node.x >= hit.x2 + const shift = width / 2 + gap - (toRight ? node.x - hit.x2 : hit.x2 - node.x) + if (shift <= 0) return + moveNodeX(node, toRight ? shift : -shift) + moved.add(node.id) + pushed.add(node.id) + }) + }) +} + +/** 网关分出的旁支贴主干太近时,推到主干外侧的固定留白处 */ +function spreadGatewayBranches(nodes, edges, byId, outgoing, moved, gap, pushed) { + nodes.filter(isGatewayNode).forEach((gateway) => { + const gatewayEdge = nodeWidth(gateway) / 2 + ;(outgoing.get(gateway.id) || []).forEach((targetId) => { + const branch = byId.get(targetId) + if (!branch) return + // 与网关排在同一列的出线是视觉主干(默认出口直下),保持不动 + if (Math.abs(branch.x - gateway.x) < SAME_COLUMN_TOLERANCE) return + // 这条旁支已经因为「连线穿过它」被推开了,不重复推 + if (pushed.has(branch.id)) return + + const toRight = branch.x > gateway.x + const branchEdge = toRight + ? branch.x - nodeWidth(branch) / 2 + : branch.x + nodeWidth(branch) / 2 + const currentGap = toRight + ? branchEdge - (gateway.x + gatewayEdge) + : gateway.x - gatewayEdge - branchEdge + if (currentGap >= gap) return + + const dx = (gap - currentGap) * (toRight ? 1 : -1) + collectBranchNodes(branch.id, byId, outgoing, gateway.x).forEach((node) => { + if (pushed.has(node.id)) return + pushed.add(node.id) + moveNodeX(node, dx) + moved.add(node.id) + }) + }) + }) +} + +/** + * 一键整理的布局后处理(原地修改 graph.nodes 的 x)。 + * + * @param {{ nodes?: object[], edges?: object[] }} graph 画布图数据(LogicFlow GraphConfigData) + * @param {{ gap?: number, defaultExitTargets?: Map> }} [options] + * gap 留白;defaultExitTargets 各节点的默认出口出线目标(辨认主干要走哪条,缺省时退化为第一条出线) + * @returns {Set} 被移动过的节点 id + */ +export function spreadBranchNodes(graph, options = {}) { + const { gap = BRANCH_GAP, defaultExitTargets } = options + const nodes = graph?.nodes || [] + const edges = graph?.edges || [] + const byId = new Map(nodes.map((node) => [node.id, node])) + const outgoing = new Map() + const incoming = new Map() + edges.forEach((edge) => { + if (!outgoing.has(edge.sourceNodeId)) outgoing.set(edge.sourceNodeId, []) + outgoing.get(edge.sourceNodeId).push(edge.targetNodeId) + if (!incoming.has(edge.targetNodeId)) incoming.set(edge.targetNodeId, []) + incoming.get(edge.targetNodeId).push(edge.sourceNodeId) + }) + + // 按顺序做,每一步的几何判定都用上一步调完的坐标: + // 并行段居中 → 主干拉直居中 → 被线穿过的节点推开 → 旁支留白 + const moved = new Set() + // 已经被推离主干的节点:后一步不再重复推同一批节点 + const pushed = new Set() + centerParallelGateways(nodes, byId, outgoing, incoming, moved) + alignTrunk(nodes, byId, outgoing, incoming, defaultExitTargets, moved) + spreadCrossedNodes(nodes, edges, byId, moved, gap, pushed) + spreadGatewayBranches(nodes, edges, byId, outgoing, moved, gap, pushed) + return moved +} diff --git a/code/fms/fms-vue/src/views/system/workflow/runtime-demo.vue b/code/fms/fms-vue/src/views/system/workflow/runtime-demo.vue new file mode 100644 index 00000000..aec5e47e --- /dev/null +++ b/code/fms/fms-vue/src/views/system/workflow/runtime-demo.vue @@ -0,0 +1,785 @@ + + + + + diff --git a/code/fms/fms-vue/tests/unit/fms-module-list-page.spec.js b/code/fms/fms-vue/tests/unit/fms-module-list-page.spec.js index d32b34b2..3d05c076 100644 --- a/code/fms/fms-vue/tests/unit/fms-module-list-page.spec.js +++ b/code/fms/fms-vue/tests/unit/fms-module-list-page.spec.js @@ -14,11 +14,22 @@ vi.mock('vue-router', () => ({ useRoute: () => ({ path: '/module/i18n-type', meta: {} }), })) -// 查询界面配置 JSON:模块管理的查询配置面板只写 groups(quick 留空),这里按面板真实产物组织 +// 查询界面配置 JSON(s_module_schema.query,schemaVersion 3):按模块管理查询配置面板的真实产物组织 const querySchema = { - schemaVersion: 1, - quick: ['b_name'], - groups: [{ logic: 'and', conditions: [{ field: 'b_name', operator: 'like' }] }], + schemaVersion: 3, + views: [ + { code: 'all', label: '全部', order: 10, isDefault: true }, + { + code: 'named', + label: '有名称', + order: 20, + showCount: true, + conditionSql: 'b_name IS NOT NULL', + }, + ], + keywordSearch: { fields: ['b_name'] }, + quickFilters: [{ field: 'b_name', order: 10 }], + queryFields: { b_name: { component: 'input', operator: 'like' } }, } let withSummary = false diff --git a/code/fms/fms-vue/tests/unit/module-fields-panel-participation.spec.js b/code/fms/fms-vue/tests/unit/module-fields-panel-participation.spec.js index b28305b4..15e87d11 100644 --- a/code/fms/fms-vue/tests/unit/module-fields-panel-participation.spec.js +++ b/code/fms/fms-vue/tests/unit/module-fields-panel-participation.spec.js @@ -34,7 +34,18 @@ function makeFields() { ] } -function mountPanel() { +/** 空查询配置(schemaVersion 3) */ +function makeQuerySchema() { + return { + schemaVersion: 3, + views: [], + keywordSearch: { fields: [] }, + quickFilters: [], + queryFields: {}, + } +} + +function mountPanel(overrides = {}) { const localFields = makeFields() const wrapper = mount(ModuleFieldsPanel, { props: { @@ -43,10 +54,11 @@ function mountPanel() { // amount 已参与表单与列表;查询未配置 editSchema: { schemaVersion: 1, children: [{ field: 'amount', span: 24 }] }, viewSchema: { schemaVersion: 1, columns: [{ field: 'amount', width: 110 }] }, - querySchema: { schemaVersion: 1, quick: [], groups: [] }, + querySchema: makeQuerySchema(), i18n: [], module: { b_id: 'demo' }, loading: false, + ...overrides, }, attachTo: document.body, }) @@ -80,10 +92,8 @@ describe('ModuleFieldsPanel 参与勾选', () => { const events = wrapper.emitted('update:querySchema') expect(events?.length).toBeGreaterThan(0) const schema = events.at(-1)[0] - const condition = schema.groups[0].conditions[0] - expect(condition.field).toBe('amount') - expect(condition.operator).toBe('eq') - expect(condition.component).toBe('number') + // 手工勾查询 → 自定义行(不带 fromList 标记) + expect(schema.queryFields.amount).toEqual({ component: 'number', operator: 'eq' }) wrapper.unmount() }) @@ -101,6 +111,48 @@ describe('ModuleFieldsPanel 参与勾选', () => { wrapper.unmount() }) + it('勾选列表 → 查询配置自动补一行(fromList 标记)', async () => { + setActivePinia(createPinia()) + const { wrapper, localFields } = mountPanel() + await settle() + const table = wrapper.findComponent({ name: 'FmsTable' }) + // remark 未参与列表:勾选后查询里应自动出现该字段 + table.vm.$emit('change', localFields[1], { dataIndex: '__list' }) + await nextTick() + const schema = wrapper.emitted('update:querySchema').at(-1)[0] + expect(schema.queryFields.remark).toEqual({ + component: 'input', + operator: 'like', + fromList: true, + }) + wrapper.unmount() + }) + + it('取消列表 → 只回收自动补的行(fromList),自定义行保留', async () => { + setActivePinia(createPinia()) + const { wrapper, localFields } = mountPanel({ + viewSchema: { + schemaVersion: 1, + columns: [{ field: 'amount', width: 110 }, { field: 'remark' }], + }, + querySchema: { + ...makeQuerySchema(), + queryFields: { + amount: { component: 'number', operator: 'eq', fromList: true }, + remark: { component: 'input', operator: 'like' }, + }, + }, + }) + await settle() + const table = wrapper.findComponent({ name: 'FmsTable' }) + table.vm.$emit('change', localFields[0], { dataIndex: '__list' }) + await nextTick() + const schema = wrapper.emitted('update:querySchema').at(-1)[0] + expect('amount' in schema.queryFields).toBe(false) + expect(schema.queryFields.remark).toEqual({ component: 'input', operator: 'like' }) + wrapper.unmount() + }) + it('普通列 change → emit update:modelValue,派生虚拟字段可由 stripInternalKeys 剥离', async () => { setActivePinia(createPinia()) const { wrapper, localFields } = mountPanel() diff --git a/code/fms/fms-vue/tests/unit/module-pref.spec.js b/code/fms/fms-vue/tests/unit/module-pref.spec.js index 5cc43b2a..328f9f1d 100644 --- a/code/fms/fms-vue/tests/unit/module-pref.spec.js +++ b/code/fms/fms-vue/tests/unit/module-pref.spec.js @@ -16,7 +16,7 @@ const { currentUserId, loadFieldPower, loadModulePref, - mergeDefaultViewCode, + mergeDefaultViewCodes, removeModulePref, saveModulePref, viewCodeDelta, @@ -35,22 +35,46 @@ describe('用户模块偏好', () => { }) it('个人默认页签:与系统默认一致时不产生覆盖', () => { - expect(viewCodeDelta('all', 'all')).toBeNull() - expect(viewCodeDelta('', 'all')).toBeNull() - expect(viewCodeDelta('pending', 'all')).toEqual({ - schemaVersion: 1, - defaultViewCode: 'pending', + expect(viewCodeDelta({}, {})).toBeNull() + expect(viewCodeDelta({ 状态: '' }, { 状态: 'all' })).toBeNull() + expect(viewCodeDelta({ 状态: 'all' }, { 状态: 'all' })).toBeNull() + expect(viewCodeDelta({ 状态: 'pending' }, { 状态: 'all' })).toEqual({ + schemaVersion: 2, + defaultViewCodes: { 状态: 'pending' }, }) }) - it('个人默认页签必须仍是当前可用页签,否则回落系统默认', () => { - expect(mergeDefaultViewCode('all', { defaultViewCode: 'pending' }, ['all', 'pending'])).toBe( - 'pending', - ) - expect(mergeDefaultViewCode('all', { defaultViewCode: 'deleted' }, ['all', 'pending'])).toBe( - 'all', - ) - expect(mergeDefaultViewCode('all', null, ['all'])).toBe('all') + it('个人默认页签只记与系统默认不同的分组', () => { + expect(viewCodeDelta({ 状态: 'pending', 审核状态: 'audited' }, { 状态: 'all' })).toEqual({ + schemaVersion: 2, + defaultViewCodes: { 状态: 'pending', 审核状态: 'audited' }, + }) + // 该分组系统没有默认:个人选了即产生覆盖 + expect(viewCodeDelta({ 状态: 'all' }, {})).toEqual({ + schemaVersion: 2, + defaultViewCodes: { 状态: 'all' }, + }) + }) + + it('个人默认页签必须仍是本组可用页签,否则回落系统默认', () => { + const allowed = { 状态: new Set(['all', 'pending']) } + expect( + mergeDefaultViewCodes({ 状态: 'all' }, { defaultViewCodes: { 状态: 'pending' } }, allowed), + ).toEqual({ 状态: 'pending' }) + expect( + mergeDefaultViewCodes({ 状态: 'all' }, { defaultViewCodes: { 状态: 'deleted' } }, allowed), + ).toEqual({ 状态: 'all' }) + expect(mergeDefaultViewCodes({ 状态: 'all' }, null, allowed)).toEqual({ 状态: 'all' }) + }) + + it('兼容 v1 个人层:单一 defaultViewCode 视作默认组的覆盖', () => { + const allowed = { '': new Set(['all', 'pending']) } + expect(mergeDefaultViewCodes({ '': 'all' }, { defaultViewCode: 'pending' }, allowed)).toEqual({ + '': 'pending', + }) + expect(mergeDefaultViewCodes({ '': 'all' }, { defaultViewCode: 'deleted' }, allowed)).toEqual({ + '': 'all', + }) }) it('读取个人偏好时按用户/模块/schema 类型过滤,解析失败返回 null', async () => { diff --git a/code/fms/fms-vue/tests/unit/module-query-config-panel.spec.js b/code/fms/fms-vue/tests/unit/module-query-config-panel.spec.js index 0e9dfbc1..f6965b27 100644 --- a/code/fms/fms-vue/tests/unit/module-query-config-panel.spec.js +++ b/code/fms/fms-vue/tests/unit/module-query-config-panel.spec.js @@ -1,16 +1,16 @@ /** * 查询配置面板回写语义(module-management): - * - query 行草稿与 query JSON 不是一一映射(缺省 component/quick/groups、运行时写入的 - * area 等),只加载(含保存后同模块重载、schema ref 换新对象)不得回写, - * 否则草稿与库中基线文本不一致 → 切树节点误报「未保存」,并静默改写未建模属性; - * - 用户真实编辑(改操作符/组件/默认值、增删行)必须回写; - * - 回写保留编辑器未建模的条件属性(如 area)。 + * - 草稿与 query JSON 不是一一映射(缺省 component、老结构 conditions 转 SQL、已废弃的 + * filterGroups / defaultViewCode 被清理),只加载(含父级重载换新对象)不得回写, + * 否则草稿与库中基线不一致 → 切树节点误报「未保存」,并静默改写库中数据; + * - 用户真实编辑(改分组 / 改页签条件 SQL)必须回写,且回写为 schemaVersion 3 形态。 */ import { beforeAll, beforeEach, describe, expect, it } from 'vitest' import { mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { nextTick } from 'vue' import ModuleQueryConfigPanel from '@/views/module/module-management/ModuleQueryConfigPanel.vue' +import FmsTable from '@/components/fms-table/FmsTable.vue' // jsdom 无布局:StkTable 在 clientWidth 为 0 时启用虚拟列裁剪,补成宽容器 beforeAll(() => { @@ -22,25 +22,30 @@ beforeAll(() => { }) }) -/** 库中历史查询配置:缺 quick 键、条件未显式写 component,且带运行时写入的 area */ -const legacySchema = { - schemaVersion: 1, - groups: [ - { - logic: 'and', - conditions: [ - { field: 'b_name', operator: 'like', area: 'hidden' }, - { field: 'b_amount', operator: 'eq', component: 'number' }, - ], - }, - ], -} - const fields = [ - { b_field: 'b_name', b_name: '名称', b_type: 'input', b_canuse: 1 }, + { b_field: 'b_status', b_name: '状态', b_type: 'select', b_canuse: 1 }, { b_field: 'b_amount', b_name: '金额', b_type: 'money', b_canuse: 1 }, ] +/** 库中历史查询配置(schemaVersion 2:条件为结构化 conditions、默认页签在顶层、还有 filterGroups) */ +const legacySchema = { + schemaVersion: 2, + defaultViewCode: 'pending', + views: [ + { + code: 'pending', + label: '待处理', + order: 10, + showCount: true, + conditions: [{ field: 'b_status', operator: 'eq', value: '1' }], + }, + ], + keywordSearch: { placeholder: '', trigger: 'enter_or_debounce', fields: [] }, + quickFilters: [], + filterGroups: [{ code: 'basic', label: '基础', order: 10, fields: ['b_status'] }], + queryFields: { b_status: { component: 'select', operator: 'eq' } }, +} + async function mountPanel(modelValue) { const pinia = createPinia() pinia.install({ provide() {}, config: { globalProperties: {} } }) @@ -59,80 +64,93 @@ 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) +} + beforeEach(() => { document.body.innerHTML = '' }) describe('查询配置面板:加载不回写、编辑才回写', () => { - it('仅加载(schema 换新对象)不回写草稿', async () => { + it('仅加载(含父级重载换新对象)不回写', async () => { const wrapper = await mountPanel(legacySchema) expect(lastSchema(wrapper)).toBe(null) - // 模拟父级重载:同一份历史 JSON 以新引用下发(与 parseSchema 后的重载一致) + // 模拟父级重载:同一份历史 JSON 以新引用下发 await wrapper.setProps({ modelValue: JSON.parse(JSON.stringify(legacySchema)) }) await nextTick() expect(lastSchema(wrapper)).toBe(null) - - // 再模拟一次:父级下发的已是规范化 JSON,同样不得回写 - await wrapper.setProps({ - modelValue: { - schemaVersion: 1, - quick: [], - groups: [ - { - logic: 'and', - conditions: [ - { field: 'b_name', operator: 'like', component: 'input', area: 'hidden' }, - { field: 'b_amount', operator: 'eq', component: 'number' }, - ], - }, - ], - }, - }) - await nextTick() - expect(lastSchema(wrapper)).toBe(null) + wrapper.unmount() }) - it('用户编辑操作符时回写,并保留未建模的 area 属性与 quick', async () => { - const wrapper = await mountPanel({ ...legacySchema, quick: ['b_name'] }) - wrapper.vm.queryConfig = wrapper.vm.queryConfig.map((row, index) => - index === 0 ? { ...row, b_operator: 'eq' } : row, - ) + it('老结构条件在草稿里转成 SQL 展示,但加载本身不回写', async () => { + const wrapper = await mountPanel(legacySchema) + const rows = (await openViewsTab(wrapper)).props('dataSource') + expect(rows[0].conditionSql).toBe("[b_status] = N'1'") + expect(rows[0].isDefault).toBe(true) + expect(lastSchema(wrapper)).toBe(null) + wrapper.unmount() + }) + + it('改分组后回写为 schemaVersion 3(已废弃键被清理)', async () => { + const wrapper = await mountPanel(legacySchema) + const table = await openViewsTab(wrapper) + const rows = table.props('dataSource') + rows[0].group = '状态' + table.vm.$emit('change', rows[0], { dataIndex: 'group' }) await nextTick() const next = lastSchema(wrapper) expect(next).not.toBe(null) - expect(next.quick).toEqual(['b_name']) - expect(next.groups[0].conditions[0]).toEqual({ - field: 'b_name', - operator: 'eq', - component: 'input', - area: 'hidden', + expect(next.schemaVersion).toBe(3) + expect(next.views[0]).toMatchObject({ + code: 'pending', + group: '状态', + isDefault: true, + conditionSql: "[b_status] = N'1'", }) + expect(next.filterGroups).toBeUndefined() + expect(next.defaultViewCode).toBeUndefined() + // 关键词只保留可搜索字段:旧的占位文案 / 触发方式随改写清理 + expect(next.keywordSearch).toEqual({ fields: [] }) + wrapper.unmount() }) - it('改字段后不复用原字段的扩展属性', async () => { + it('页签条件改为 SQL 后回写', async () => { const wrapper = await mountPanel(legacySchema) - wrapper.vm.queryConfig = wrapper.vm.queryConfig.map((row, index) => - index === 0 ? { ...row, b_field: 'b_amount', b_component: 'number', b_operator: 'eq' } : row, - ) + await openViewsTab(wrapper) + await wrapper.find('button.mm-qdesign__sql-cell').trigger('click') + await nextTick() + await wrapper.find('textarea').setValue("b_status = N'2'") + const buttons = wrapper.findAll('.fms-modal-footer button') + await buttons[buttons.length - 1].trigger('click') await nextTick() const next = lastSchema(wrapper) - expect(next.groups[0].conditions[0]).toEqual({ - field: 'b_amount', - operator: 'eq', - component: 'number', - }) + expect(next).not.toBe(null) + expect(next.views[0].conditionSql).toBe("b_status = N'2'") + wrapper.unmount() }) - it('删除行后回写(条件为空 → groups 置空)', async () => { + it('条件 SQL 含分号时不回写(拦截手误)', async () => { const wrapper = await mountPanel(legacySchema) - wrapper.vm.queryConfig = [] + await openViewsTab(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") + const buttons = wrapper.findAll('.fms-modal-footer button') + await buttons[buttons.length - 1].trigger('click') await nextTick() - const next = lastSchema(wrapper) - expect(next.groups).toEqual([]) - expect(next.quick).toEqual([]) + expect(lastSchema(wrapper)).toBe(null) + wrapper.unmount() }) }) diff --git a/code/fms/fms-vue/tests/unit/query-ast.spec.js b/code/fms/fms-vue/tests/unit/query-ast.spec.js index 78da496b..39a9d136 100644 --- a/code/fms/fms-vue/tests/unit/query-ast.spec.js +++ b/code/fms/fms-vue/tests/unit/query-ast.spec.js @@ -3,6 +3,7 @@ import { astToSql, buildFilterStateAst, buildKeywordAst, + buildKeywordPlaceholder, buildQuickAst, buildViewAst, combineAst, @@ -145,6 +146,26 @@ describe('查询入口 → AST(页签 / 关键词 / 快捷筛选 / 更多筛 expect(buildViewAst({ code: 'all' }, fields)).toBeNull() }) + it('页签条件为 SQL 片段时整段成组,优先于旧结构 conditions', () => { + const view = { + code: 'pending', + conditionSql: "b_status = N'1'", + conditions: [{ field: 'status', operator: 'eq', value: '失败' }], + } + expect(astToSql(buildViewAst(view, fields), fields)).toBe("(b_status = N'1')") + expect(buildViewAst({ code: 'all', conditionSql: ' ' }, fields)).toBeNull() + }) + + it('页签 SQL 与其他来源按「且」合并', () => { + const ast = combineAst( + buildViewAst({ code: 'pending', conditionSql: "b_status = N'1'" }, fields), + buildKeywordAst('东方', [{ field: fields.get('name'), config: {} }]), + ) + expect(astToSql(ast, fields)).toBe( + "((b_status = N'1') AND ([name] LIKE N'%东方%'))", + ) + }) + it('关键词跨字段取「或」,空关键词不产生条件', () => { const items = [ { field: fields.get('name'), config: {} }, @@ -213,4 +234,22 @@ describe('查询入口 → AST(页签 / 关键词 / 快捷筛选 / 更多筛 expect(describeConditionText({ field: 'name', operator: 'like', value: '' }, items)).toBe('') expect(describeConditionText({ field: 'ghost', operator: 'eq', value: 'x' }, items)).toBe('') }) + + it('关键词占位文案按字段名生成:多于 5 个只列前 5 个加「等」', () => { + const makeItems = (labels) => + labels.map((label, index) => ({ + field: { b_field: `f${index}`, b_name: label, b_type: 'input' }, + })) + expect(buildKeywordPlaceholder(makeItems(['单号', '客户']))).toBe('搜索 单号、客户') + expect(buildKeywordPlaceholder([])).toBe('') + expect( + buildKeywordPlaceholder(makeItems(['a', 'b', 'c', 'd', 'e', 'f'])), + ).toBe('搜索 a、b、c、d、e 等') + // 传入翻译函数时前缀与「等」走多语言 + expect( + buildKeywordPlaceholder(makeItems(['Order No']), (key, fallback) => + key === 'common.keywordSearch' ? 'Search' : fallback, + ), + ).toBe('Search Order No') + }) }) diff --git a/code/fms/fms-vue/tests/unit/schema-render.spec.js b/code/fms/fms-vue/tests/unit/schema-render.spec.js index 2dfa2b6e..2061a4b4 100644 --- a/code/fms/fms-vue/tests/unit/schema-render.spec.js +++ b/code/fms/fms-vue/tests/unit/schema-render.spec.js @@ -82,70 +82,116 @@ describe('buildQueryRender(query JSON → 查询入口)', () => { { b_field: 'b_denied', b_name: '无权限字段', b_type: 'input' }, ] const query = { - schemaVersion: 2, - defaultViewCode: 'pending', + schemaVersion: 3, views: [ { code: 'all', label: '全部', order: 20 }, - { code: 'pending', label: '待处理', order: 10, showCount: true, - conditions: [{ field: 'b_status', operator: 'eq', value: '1' }] }, - { code: 'ghost', label: '幽灵页签', order: 30, - conditions: [{ field: 'b_denied', operator: 'eq', value: 'x' }] }, + { + code: 'pending', + label: '待处理', + order: 10, + showCount: true, + group: '状态', + isDefault: true, + }, + { + code: 'audited', + label: '已审核', + order: 15, + group: '审核状态', + conditionSql: "b_status = N'2'", + }, + { + code: 'ghost', + label: '幽灵页签', + order: 30, + conditions: [{ field: 'b_denied', operator: 'eq', value: 'x' }], + }, ], - keywordSearch: { placeholder: '搜索', trigger: 'enter_or_debounce', fields: ['b_no', 'b_denied'] }, + keywordSearch: { fields: ['b_no', 'b_denied'] }, quickFilters: [ { field: 'b_created', order: 20 }, { field: 'b_status', order: 10 }, { field: 'b_denied', order: 30 }, ], - filterGroups: [ - { code: 'basic', label: '基础', order: 10, fields: ['b_no', 'b_status', 'b_denied'] }, - { code: 'time', label: '时间', order: 20, fields: ['b_created'] }, - ], queryFields: { b_status: { component: 'select', operator: 'eq' }, b_created: { component: 'datetime', operator: 'between' }, + b_denied: { component: 'input', operator: 'eq' }, }, } - it('产出页签 / 关键词 / 快捷筛选 / 分组,条目按 order 排序', () => { + it('产出分组页签 / 关键词 / 快捷筛选 / 更多筛选,条目按 order 排序', () => { const render = buildQueryRender(query, fields) - expect(render.views.map((v) => [v.code, v.showCount])).toEqual([ - ['pending', true], - ['all', false], - ['ghost', false], + expect(render.views.map((v) => [v.code, v.group, v.showCount, v.conditionSql])).toEqual([ + ['pending', '状态', true, ''], + ['audited', '审核状态', false, "b_status = N'2'"], + ['all', '', false, ''], + ['ghost', '', false, ''], ]) - expect(render.defaultViewCode).toBe('pending') + expect(render.defaultViewCodes).toEqual({ 状态: 'pending' }) expect(render.quickFilters.map((item) => item.field.b_field)).toEqual(['b_status', 'b_created']) - expect(render.filterGroups.map((g) => g.code)).toEqual(['basic', 'time']) - expect(render.keywordSearch.items.map((item) => item.field.b_field)).toEqual(['b_no']) + // 更多筛选 = 字段查询元数据全集去掉已进快捷筛选的字段(顺序即配置顺序) + expect(render.moreFilters.map((item) => item.field.b_field)).toEqual(['b_no', 'b_denied']) + expect(render.keywordSearch.items.map((item) => item.field.b_field)).toEqual([ + 'b_no', + 'b_denied', + ]) }) it('字段项统一为 FmsQueryControl 消费的形状,算子缺省按字段类型推导', () => { const render = buildQueryRender(query, fields) const statusItem = render.quickFilters[0] - expect(statusItem.config).toMatchObject({ b_field: 'b_status', b_component: 'select', b_operator: 'eq' }) - const createdItem = render.filterGroups[1].items[0] - expect(createdItem.config.b_operator).toBe('between') + expect(statusItem.config).toMatchObject({ + b_field: 'b_status', + b_component: 'select', + b_operator: 'eq', + }) + // 已登记的字段沿用配置算子 + const deniedItem = render.moreFilters.find((item) => item.field.b_field === 'b_denied') + expect(deniedItem.config.b_operator).toBe('eq') // queryFields 未登记的字段按 b_type 推导:input → like - const noItem = render.filterGroups[0].items[0] + const noItem = render.moreFilters.find((item) => item.field.b_field === 'b_no') expect(noItem.config.b_operator).toBe('like') }) - it('无权限字段不进任何入口;引用它的页签整条丢弃', () => { + it('无权限字段不进任何入口;旧结构页签引用它则整条丢弃(SQL 条件无法解析,原样保留)', () => { const render = buildQueryRender(query, fields, new Set(['b_no', 'b_status', 'b_created'])) - expect(render.views.map((v) => v.code)).toEqual(['pending', 'all']) + expect(render.views.map((v) => v.code)).toEqual(['pending', 'audited', 'all']) expect(render.quickFilters.map((item) => item.field.b_field)).toEqual(['b_status', 'b_created']) - expect(render.keywordSearch).toBeNull() - expect(render.filterGroups[0].items.map((item) => item.field.b_field)).toEqual(['b_no', 'b_status']) + expect(render.keywordSearch.items.map((item) => item.field.b_field)).toEqual(['b_no']) + expect(render.moreFilters.map((item) => item.field.b_field)).toEqual(['b_no']) + }) + + it('老数据兼容:顶层 defaultViewCode 落到所属分组,结构化条件原样保留', () => { + const legacy = { + schemaVersion: 2, + defaultViewCode: 'pending', + views: [ + { + code: 'pending', + label: '待处理', + order: 10, + conditions: [{ field: 'b_status', operator: 'eq', value: '1' }], + }, + { code: 'audited', label: '已审核', order: 20, group: '审核状态' }, + ], + queryFields: {}, + } + const render = buildQueryRender(legacy, fields) + expect(render.defaultViewCodes).toEqual({ '': 'pending' }) + expect(render.views[0].conditionSql).toBe('') + expect(render.views[0].conditions).toEqual([ + { field: 'b_status', operator: 'eq', value: '1' }, + ]) }) it('未配置查询时返回空结构', () => { const render = buildQueryRender(null, fields) expect(render.views).toEqual([]) - expect(render.defaultViewCode).toBe('') + expect(render.defaultViewCodes).toEqual({}) expect(render.keywordSearch).toBeNull() expect(render.quickFilters).toEqual([]) - expect(render.filterGroups).toEqual([]) + expect(render.moreFilters).toEqual([]) }) }) diff --git a/code/fms/fms-vue/tests/unit/workflow-canvas-layout.spec.js b/code/fms/fms-vue/tests/unit/workflow-canvas-layout.spec.js index abed83d9..b4c4be43 100644 --- a/code/fms/fms-vue/tests/unit/workflow-canvas-layout.spec.js +++ b/code/fms/fms-vue/tests/unit/workflow-canvas-layout.spec.js @@ -77,3 +77,99 @@ describe('画布一键整理(@logicflow/layout 的 dagre 插件)', () => { wrapper.unmount() }) }) + +/** 派发一个带 ctrl 的按键(LogicFlow 内置快捷键没有剪切,这条我们自己监听) */ +function pressCtrl(target, key) { + target.dispatchEvent(new KeyboardEvent('keydown', { key, ctrlKey: true, bubbles: true, cancelable: true })) +} + +/** 派发一个不带修饰键的按键(删除) */ +function pressKey(target, key) { + target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) +} + +describe('画布快捷键:剪切(ctrl+x)', () => { + it('删掉选中节点,与它相连的边一并消失', async () => { + const wrapper = mount(WorkflowCanvas, { + props: { draft: messyDraft() }, + attachTo: document.body, + }) + await nextTick() + + wrapper.vm.focusNode('user_task_1') + await nextTick() + pressCtrl(wrapper.find('.workflow-canvas').element, 'x') + await nextTick() + + const graph = wrapper.emitted('graph-change').at(-1)[0] + expect(graph.nodes.map((item) => item.id)).toEqual(['start', 'end']) + expect(graph.edges).toEqual([]) + + wrapper.unmount() + }) + + it('没有选中元素时不动画布', async () => { + const wrapper = mount(WorkflowCanvas, { + props: { draft: messyDraft() }, + attachTo: document.body, + }) + await nextTick() + + pressCtrl(wrapper.find('.workflow-canvas').element, 'x') + await nextTick() + + expect(wrapper.emitted('graph-change')).toBeUndefined() + + wrapper.unmount() + }) + + it('焦点不在画布上也能连着剪(删完一次不用再点画布)', async () => { + const wrapper = mount(WorkflowCanvas, { + props: { draft: messyDraft() }, + attachTo: document.body, + }) + await nextTick() + + // 事件从 body 冒泡:快捷键监听在 document 上,不受画布焦点影响 + wrapper.vm.focusNode('user_task_1') + await nextTick() + pressCtrl(document.body, 'x') + await nextTick() + + wrapper.vm.focusNode('end') + await nextTick() + pressCtrl(document.body, 'x') + await nextTick() + + const graph = wrapper.emitted('graph-change').at(-1)[0] + expect(graph.nodes.map((item) => item.id)).toEqual(['start']) + + wrapper.unmount() + }) + + it('焦点不在画布上时,内置的复制/粘贴/删除照样能用', async () => { + const wrapper = mount(WorkflowCanvas, { + props: { draft: messyDraft() }, + attachTo: document.body, + }) + await nextTick() + + wrapper.vm.focusNode('user_task_1') + await nextTick() + pressCtrl(document.body, 'c') + await nextTick() + pressCtrl(document.body, 'v') + await nextTick() + + let graph = wrapper.emitted('graph-change').at(-1)[0] + expect(graph.nodes.length).toBe(4) + + pressKey(document.body, 'Backspace') + await nextTick() + + graph = wrapper.emitted('graph-change').at(-1)[0] + expect(graph.nodes.length).toBe(3) + + wrapper.unmount() + }) +}) diff --git a/code/fms/fms-vue/tests/unit/workflow-designer-utils.spec.js b/code/fms/fms-vue/tests/unit/workflow-designer-utils.spec.js new file mode 100644 index 00000000..d707c661 --- /dev/null +++ b/code/fms/fms-vue/tests/unit/workflow-designer-utils.spec.js @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest' +import { spreadBranchNodes } from '@/views/system/workflow/designer/utils' + +/** 构造一个画布节点 */ +function node(id, type, x, y, width, height, text) { + return { id, type, x, y, width, height, text: text ?? { value: id, x, y } } +} + +function edge(id, sourceNodeId, targetNodeId) { + return { id, sourceNodeId, targetNodeId, offset: 15 } +} + +/** 默认出口表:source → 出线目标 */ +function defaultExits(entries) { + return new Map(Object.entries(entries).map(([source, targets]) => [source, new Set(targets)])) +} + +describe('一键整理的布局后处理', () => { + it('旁支与默认出口排在同一列(连线从它正中穿过)时,推到该连线的外侧', () => { + const graph = { + nodes: [ + node('amount_gateway', 'fms-exclusive-gateway', 200, 300, 68, 68), + node('gm_approve', 'fms-user-task', 200, 400, 160, 48), + node('finance_review', 'fms-user-task', 200, 500, 160, 48), + ], + edges: [ + edge('e3', 'amount_gateway', 'gm_approve'), + edge('e4', 'amount_gateway', 'finance_review'), + edge('e5', 'gm_approve', 'finance_review'), + ], + } + + const moved = spreadBranchNodes(graph) + + expect(moved.has('gm_approve')).toBe(true) + // 线在 x=200(金额判断与财务复核同列),旁支被推到线右侧、留白 120 + expect(graph.nodes[1].x).toBe(400) + // 源、目标本身不动 + expect(graph.nodes[0].x).toBe(200) + expect(graph.nodes[2].x).toBe(200) + }) + + it('文本坐标跟着节点一起搬,否则框动了字还留在原地', () => { + const graph = { + nodes: [ + node('amount_gateway', 'fms-exclusive-gateway', 200, 300, 68, 68, { value: '金额判断', x: 200, y: 300 }), + node('gm_approve', 'fms-user-task', 200, 400, 160, 48, { value: '总经理审批', x: 200, y: 400 }), + node('finance_review', 'fms-user-task', 200, 500, 160, 48, { value: '财务复核', x: 200, y: 500 }), + ], + edges: [ + edge('e3', 'amount_gateway', 'gm_approve'), + edge('e4', 'amount_gateway', 'finance_review'), + edge('e5', 'gm_approve', 'finance_review'), + ], + } + + spreadBranchNodes(graph) + + expect(graph.nodes[1].text.x).toBe(graph.nodes[1].x) + expect(graph.nodes[0].text.x).toBe(200) + }) + + it('网关旁支与主干贴得太近时,推到固定留白处(含旁支下游整条)', () => { + const graph = { + nodes: [ + node('split', 'fms-exclusive-gateway', 0, 0, 68, 68), + node('branch', 'fms-user-task', 120, 200, 160, 48), + node('branch_2', 'fms-user-task', 120, 320, 160, 48), + node('join', 'fms-user-task', 0, 440, 160, 48), + ], + edges: [ + edge('e1', 'split', 'branch'), + edge('e2', 'branch', 'branch_2'), + edge('e3', 'branch_2', 'join'), + edge('e4', 'split', 'join'), + ], + } + + const moved = spreadBranchNodes(graph, { defaultExitTargets: defaultExits({ split: ['join'] }) }) + + // 间距只有 6px,补到 120:120 + (120 - 6) = 234 + expect(moved.has('branch')).toBe(true) + expect(moved.has('branch_2')).toBe(true) + expect(graph.nodes[1].x).toBe(234) + expect(graph.nodes[2].x).toBe(234) + // 与网关同列的默认出口不动 + expect(graph.nodes[3].x).toBe(0) + }) + + it('旁支已经让开足够距离时不动它', () => { + const graph = { + nodes: [ + node('split', 'fms-exclusive-gateway', 0, 0, 68, 68), + node('branch', 'fms-user-task', 300, 200, 160, 48), + node('join', 'fms-user-task', 0, 400, 160, 48), + ], + edges: [ + edge('e1', 'split', 'branch'), + edge('e2', 'branch', 'join'), + edge('e3', 'split', 'join'), + ], + } + + const moved = spreadBranchNodes(graph, { defaultExitTargets: defaultExits({ split: ['join'] }) }) + + expect(moved.size).toBe(0) + expect(graph.nodes[1].x).toBe(300) + }) + + it('并行网关对齐到三条分支的水平中心,中间那条直上直下', () => { + const graph = { + nodes: [ + node('split', 'fms-parallel-gateway', 0, 0, 68, 68), + node('a', 'fms-user-task', 0, 200, 160, 48), + node('b', 'fms-user-task', 260, 200, 160, 48), + node('c', 'fms-user-task', 520, 200, 160, 48), + node('join', 'fms-parallel-gateway', 260, 400, 68, 68), + ], + edges: [ + edge('e1', 'split', 'a'), + edge('e2', 'split', 'b'), + edge('e3', 'split', 'c'), + edge('e4', 'a', 'join'), + edge('e5', 'b', 'join'), + edge('e6', 'c', 'join'), + ], + } + + const moved = spreadBranchNodes(graph) + + expect(moved.has('split')).toBe(true) + // 三条分支的中间那条在 260,分叉点与汇合点都对齐到这里 + expect(graph.nodes[0].x).toBe(260) + expect(graph.nodes[4].x).toBe(260) + // 分支节点自己不动 + expect(graph.nodes[1].x).toBe(0) + expect(graph.nodes[3].x).toBe(520) + }) + + it('并行网关下面两条分支时,网关落在两条分支中间', () => { + const graph = { + nodes: [ + node('split', 'fms-parallel-gateway', 0, 0, 68, 68), + node('a', 'fms-user-task', 0, 200, 160, 48), + node('b', 'fms-user-task', 260, 200, 160, 48), + node('join', 'fms-parallel-gateway', 0, 400, 68, 68), + ], + edges: [edge('e1', 'split', 'a'), edge('e2', 'split', 'b'), edge('e3', 'a', 'join'), edge('e4', 'b', 'join')], + } + + spreadBranchNodes(graph) + + expect(graph.nodes[0].x).toBe(130) + expect(graph.nodes[3].x).toBe(130) + }) + + it('主干排成两列时拉直成一条竖线,挂在主干上的旁支跟着搬', () => { + // dagre 的常见输出:并行段前面那段主干在 0,并行段之后在 260 + const graph = { + nodes: [ + node('start', 'fms-start', 0, 0, 52, 52), + node('check', 'fms-user-task', 0, 200, 160, 48), + node('gm', 'fms-user-task', 234, 300, 160, 48), + node('split', 'fms-parallel-gateway', 260, 400, 68, 68), + node('a', 'fms-user-task', 0, 600, 160, 48), + node('b', 'fms-user-task', 260, 600, 160, 48), + node('c', 'fms-user-task', 520, 600, 160, 48), + node('join', 'fms-parallel-gateway', 260, 800, 68, 68), + node('end', 'fms-end', 260, 1000, 52, 52), + ], + edges: [ + edge('e1', 'start', 'check'), + edge('e2', 'check', 'split'), + edge('e3', 'check', 'gm'), + edge('e4', 'gm', 'split'), + edge('e5', 'split', 'a'), + edge('e6', 'split', 'b'), + edge('e7', 'split', 'c'), + edge('e8', 'a', 'join'), + edge('e9', 'b', 'join'), + edge('e10', 'c', 'join'), + edge('e11', 'join', 'end'), + ], + } + + spreadBranchNodes(graph, { defaultExitTargets: defaultExits({ check: ['split'] }) }) + + // 主轴取主干 x 的中位数 = 260(并行段中心),前半段整段拉过来 + expect(graph.nodes.map((item) => [item.id, item.x])).toEqual([ + ['start', 260], + ['check', 260], + // 旁支跟着主干走,保持它相对主干的偏移(234 - 0) + ['gm', 494], + ['split', 260], + ['a', 0], + ['b', 260], + ['c', 520], + ['join', 260], + ['end', 260], + ]) + }) + + it('条件网关的出线落在不同层时不居中(默认出口继续直下)', () => { + const graph = { + nodes: [ + node('gateway', 'fms-exclusive-gateway', 0, 0, 68, 68), + node('branch', 'fms-user-task', 300, 200, 160, 48), + node('next', 'fms-user-task', 0, 400, 160, 48), + ], + edges: [ + edge('e1', 'gateway', 'branch'), + edge('e2', 'gateway', 'next'), + edge('e3', 'branch', 'next'), + ], + } + + spreadBranchNodes(graph, { defaultExitTargets: defaultExits({ gateway: ['next'] }) }) + + expect(graph.nodes[0].x).toBe(0) + }) +}) diff --git a/code/test/vue-vben-admin b/code/test/vue-vben-admin index 2d708760..edbc42ca 160000 --- a/code/test/vue-vben-admin +++ b/code/test/vue-vben-admin @@ -1 +1 @@ -Subproject commit 2d7087602b732e6c23c7696ee8430eb1e8221b56 +Subproject commit edbc42caaaa5b33fe61349063cc94cd36a1c5f30