20260923230500

This commit is contained in:
oneao committed 2026-09-23 23:05:01 +08:00
1 parent e6935b8fa4
commit 0405cd198a
36 files changed
+3668 -911

No files matched your search

@@ -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
@@ -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()
+38 -14
View File
@@ -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 () => {
@@ -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()
})
})
@@ -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')
})
})
@@ -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([])
})
})
@@ -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()
})
})
@@ -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)
})
})