20260923230500
This commit is contained in:
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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user