20260919211702
This commit is contained in:
1 parent
f56e70b050
commit
f4bfc1ddfc
97 files changed
+6059
-1712
No files matched your search
@@ -295,9 +295,12 @@ const resolvedWidth = computed(() => {
|
||||
/** 保存过程上下文:beforeSave 阶段准备的数据,buildSaveData / afterSave 阶段消费 */
|
||||
let saveCtx = { isNew: false, row: null, extraUpdates: [] }
|
||||
|
||||
/** 模块保存表(s_module.b_save_table 优先,其次 b_view_table) */
|
||||
/**
|
||||
* 模块保存表:只使用 s_module.b_save_table(设计 §6.1)。
|
||||
* 视图表通常不可写,回退到 b_view_table 会让写入在数据库层失败;为空时由 beforeSave 拒绝。
|
||||
*/
|
||||
function saveTableName() {
|
||||
return String(moduleMeta.value?.b_save_table || moduleMeta.value?.b_view_table || '')
|
||||
return String(moduleMeta.value?.b_save_table || '')
|
||||
}
|
||||
|
||||
/** 构造完整合并行(含主键;checkbox→0/1、''→null 归一化),供保存前准备与 diff 使用 */
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, h, nextTick, onActivated, reactive, ref, shallowRef, useSlots, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Columns3, Download, Ellipsis, FileSpreadsheet, RefreshCw, Save } from '@lucide/vue'
|
||||
import { Button, Dropdown, Message, Pagination, Spin } from '@/components/ui'
|
||||
import { loadDataApi, nextIdApi, pageDataApi, saveObjectApi } from '@/services/api'
|
||||
import { Download, Ellipsis, FileSpreadsheet, RefreshCw, RotateCcw, Save } from '@lucide/vue'
|
||||
import { Button, ButtonGroup, Dropdown, Message, Pagination, Spin, Tag } from '@/components/ui'
|
||||
import { deleteApi, loadDataApi, nextIdApi, pageDataApi, saveObjectApi } from '@/services/api'
|
||||
import { exportListApi, loadTotalsApi } from '@/services/exportService'
|
||||
import { useI18nStore } from '@/stores/i18n'
|
||||
import {
|
||||
@@ -33,16 +33,22 @@ import FmsAdvancedQueryPanel from './FmsAdvancedQueryPanel.vue'
|
||||
import FmsQuerySettingsDrawer from './FmsQuerySettingsDrawer.vue'
|
||||
import FmsExportDialog from './FmsExportDialog.vue'
|
||||
import {
|
||||
PREF_SCHEMA_TYPES,
|
||||
buildDefaultQueryLayout,
|
||||
columnSettingsDelta,
|
||||
currentUserId,
|
||||
isEmptyColumnDelta,
|
||||
isEmptyDelta,
|
||||
layoutDelta,
|
||||
loadFieldPower,
|
||||
loadModulePref,
|
||||
mergeColumnPref,
|
||||
mergeQueryLayout,
|
||||
removeModulePref,
|
||||
sameColumnSettings,
|
||||
saveModulePref,
|
||||
} from './useModulePref'
|
||||
import { usePermissionStore } from '@/stores/permissions'
|
||||
|
||||
const props = defineProps({
|
||||
dataCode: { type: String, default: '' },
|
||||
@@ -73,6 +79,7 @@ const emit = defineEmits([
|
||||
const slots = useSlots()
|
||||
const route = useRoute()
|
||||
const { t } = useI18nStore()
|
||||
const permissions = usePermissionStore()
|
||||
const tableRef = ref(null)
|
||||
// keep-alive 中的缓存实例必须保留各自的路由上下文,不能跟随全局 route 变化。
|
||||
const instanceRoutePath = route.path
|
||||
@@ -112,6 +119,17 @@ const queryLayout = ref({ area: {}, order: {} })
|
||||
const defaultQueryLayout = ref({ area: {}, order: {} })
|
||||
// query 界面配置 JSON(s_module_schema.query),「保存为通用」时回写
|
||||
const querySchema = shallowRef(null)
|
||||
// ── 栏位设置(列宽/顺序)──
|
||||
// 基线:进入本页(或保存/重载)时的栏位快照,用于判断「列是否已被调整」。
|
||||
// 与 tableRef.getColumnSettings() 的当前值逐项比对,决定工具条上「已调整」操作条是否出现。
|
||||
const columnBaseline = shallowRef([])
|
||||
const columnDirty = ref(false)
|
||||
// 个人层栏位偏好(s_user_module_pref, b_schema_type='view')是否已存在,决定 insert 还是 update
|
||||
const columnPrefRowExists = ref(false)
|
||||
// 通用栏位行(s_module_schema.view 展开结果):个人增量与其比较,「保存为通用」也以它为底
|
||||
const columnGlobalRows = shallowRef([])
|
||||
// 「保存为通用」会改到所有人的默认视图,限系统管理员(与 /module/* 的 meta.adminOnly 同一判据)
|
||||
const canSaveColumnGlobal = computed(() => permissions.isG3soft)
|
||||
// 无查询权限的字段(s_user_field_power.b_query = 0),设置抽屉中灰显
|
||||
const disabledFields = shallowRef([])
|
||||
const keyField = ref('')
|
||||
@@ -150,6 +168,7 @@ const effectiveRowDraggable = computed(
|
||||
)
|
||||
|
||||
// ── 外部工具 ──
|
||||
// 栏位设置不在此菜单:列宽/顺序改动后由工具条按需浮出操作条承载(见 columnDirty)
|
||||
const moreMenuItems = computed(() => [
|
||||
{
|
||||
key: 'batch-export',
|
||||
@@ -162,17 +181,6 @@ const moreMenuItems = computed(() => [
|
||||
label: t('common.refreshData', '刷新数据'),
|
||||
icon: h(RefreshCw, { size: 14 }),
|
||||
},
|
||||
{
|
||||
key: 'reset-columns',
|
||||
label: t('common.resetColumnSettings', '恢复栏位设置'),
|
||||
icon: h(Columns3, { size: 14 }),
|
||||
},
|
||||
{
|
||||
key: 'save-column-settings',
|
||||
label: t('common.saveColumnSettings', '保存栏位设置'),
|
||||
icon: h(Save, { size: 14 }),
|
||||
disabled: columnSaving.value,
|
||||
},
|
||||
])
|
||||
const cellSlotNames = computed(() => Object.keys(slots).filter((name) => name.startsWith('cell-')))
|
||||
// 高级面板可选字段:排除用户在设置中隐藏的字段
|
||||
@@ -311,6 +319,9 @@ async function loadModuleConfig() {
|
||||
if (!fieldById.has(String(item.b_field))) continue
|
||||
lists.push(item)
|
||||
}
|
||||
// 通用栏位(lists)即「保存为通用」的基线与个人增量的比较基准;
|
||||
// 个人层只叠加宽度与顺序,不增删字段,因此先裁到同一字段集再合并。
|
||||
columnGlobalRows.value = lists
|
||||
|
||||
// 查询条件:query JSON(quick 顺序优先)
|
||||
const queryRender = buildQueryRender(schemas.query)
|
||||
@@ -335,7 +346,11 @@ async function loadModuleConfig() {
|
||||
|
||||
// 两层配置(系统默认 → 个人)+ 字段查询权限;权限在偏好合并之后裁剪,不得放宽权限
|
||||
const userId = currentUserId()
|
||||
const savedPref = await loadModulePref(userId, dataId)
|
||||
// 查询布局(query)与栏位布局(view)各有一份个人层增量,互不影响
|
||||
const [savedPref, savedColumnPref] = await Promise.all([
|
||||
loadModulePref(userId, dataId),
|
||||
loadModulePref(userId, dataId, PREF_SCHEMA_TYPES.view),
|
||||
])
|
||||
const fieldPower = await loadFieldPower(userId, dataId)
|
||||
if (getDataCode() !== code) return false
|
||||
|
||||
@@ -358,6 +373,12 @@ async function loadModuleConfig() {
|
||||
defaultQueryLayout.value = buildDefaultQueryLayout(allowedQueries, queryRender.quick)
|
||||
queryLayout.value = mergeQueryLayout(defaultQueryLayout.value, savedPref, allowedFields)
|
||||
|
||||
// 栏位:通用(lists)→ 个人层叠加宽度与顺序;个人层不增删字段(通用没有的字段一律忽略)
|
||||
const mergedLists = mergeColumnPref(lists, savedColumnPref)
|
||||
columnPrefRowExists.value = Boolean(savedColumnPref)
|
||||
columnBaseline.value = columnRowsOf(mergedLists)
|
||||
columnDirty.value = false
|
||||
|
||||
// 默认条件取全部启用字段:简单查询与高级查询共用同一批启用字段
|
||||
const defaultValues = {}
|
||||
for (const item of allowedQueries) defaultValues[item.field.b_field] = defaultQueryValue(item)
|
||||
@@ -371,7 +392,7 @@ async function loadModuleConfig() {
|
||||
dataModule.value = runtimeData
|
||||
activeFields.value = fields
|
||||
activeFieldById.value = fieldById
|
||||
listConfig.value = lists
|
||||
listConfig.value = mergedLists
|
||||
queryItems.value = allowedQueries
|
||||
viewSchema.value = schemas.view
|
||||
editSchema.value = schemas.edit
|
||||
@@ -667,11 +688,96 @@ function reload() {
|
||||
return fetchData()
|
||||
}
|
||||
|
||||
/* ================= 栏位设置(列宽 / 顺序) =================
|
||||
* 编辑方式:直接在表格上拖拽列宽、拖拽列头换序。
|
||||
* 提交方式:工具条按需浮出「撤销 / 保存为个人 / 保存为通用」——
|
||||
* 拖完才出现,因此平时不占内容区,也不改变表格高度。
|
||||
* 默认落点是个人层(s_user_module_pref, b_schema_type='view',只存增量),
|
||||
* 「保存为通用」才写 s_module_schema(影响所有人,需要权限)。
|
||||
*/
|
||||
|
||||
/** 按当前 listConfig 重建列(丢弃未保存的拖拽结果);等 DOM 更新后再重置虚拟滚动 */
|
||||
async function resetColumns() {
|
||||
await nextTick()
|
||||
if (tableRef.value) tableRef.value.resetColumns()
|
||||
}
|
||||
|
||||
/** 栏位行 → 用于比对的精简结构(只含字段与宽度,顺序即数组顺序) */
|
||||
function columnRowsOf(rows) {
|
||||
return (rows || []).map((row) => ({ b_field: String(row.b_field), b_width: row.b_width }))
|
||||
}
|
||||
|
||||
/** 表格当前栏位设置(列宽取真实渲染宽度);表格未就绪时回退到已加载的 listConfig */
|
||||
function currentColumnSettings() {
|
||||
const fromTable = tableRef.value?.getColumnSettings?.()
|
||||
if (Array.isArray(fromTable) && fromTable.length) return columnRowsOf(fromTable)
|
||||
return columnRowsOf(listConfig.value)
|
||||
}
|
||||
|
||||
/** 重新比对当前栏位与基线,决定操作条是否出现 */
|
||||
function refreshColumnDirty() {
|
||||
columnDirty.value = !sameColumnSettings(currentColumnSettings(), columnBaseline.value)
|
||||
}
|
||||
|
||||
/** 表格列变化(拖宽 / 拖序)时由 FmsModuleTable 上抛 */
|
||||
function handleColumnsChange() {
|
||||
refreshColumnDirty()
|
||||
}
|
||||
|
||||
/** 撤销:丢弃未保存的列宽/顺序改动,回到基线 */
|
||||
async function undoColumnSettings() {
|
||||
await resetColumns()
|
||||
columnDirty.value = false
|
||||
}
|
||||
|
||||
/** 保存为个人:只记「相对通用的差异」,通用后续调整仍能透传到这个人 */
|
||||
async function saveColumnAsPersonal() {
|
||||
if (loading.value || columnSaving.value || !tableRef.value) return
|
||||
const moduleId = String(dataModule.value?.b_id || '').trim()
|
||||
const userId = currentUserId()
|
||||
if (!moduleId || !userId) return
|
||||
|
||||
const current = currentColumnSettings()
|
||||
const delta = columnSettingsDelta(current, columnGlobalRows.value)
|
||||
// 与通用完全一致 → 没有个人覆盖可言,顺手清掉旧记录,避免留下空壳覆盖
|
||||
if (isEmptyColumnDelta(delta)) {
|
||||
if (columnPrefRowExists.value) {
|
||||
columnSaving.value = true
|
||||
try {
|
||||
await removeModulePref(userId, moduleId, PREF_SCHEMA_TYPES.view)
|
||||
columnPrefRowExists.value = false
|
||||
columnBaseline.value = columnRowsOf(listConfig.value)
|
||||
columnDirty.value = false
|
||||
Message.success('已与通用栏位一致,个人设置已清除')
|
||||
} catch (error) {
|
||||
Message.error('清除个人栏位设置失败:' + (error?.message || error))
|
||||
} finally {
|
||||
columnSaving.value = false
|
||||
}
|
||||
} else {
|
||||
columnBaseline.value = columnRowsOf(listConfig.value)
|
||||
columnDirty.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
columnSaving.value = true
|
||||
try {
|
||||
await saveModulePref(userId, moduleId, delta, columnPrefRowExists.value, PREF_SCHEMA_TYPES.view)
|
||||
columnPrefRowExists.value = true
|
||||
// 保存成功即成为新基线:操作条立即消失,不必等整页重载
|
||||
listConfig.value = mergeColumnPref(columnGlobalRows.value, delta)
|
||||
await resetColumns()
|
||||
columnBaseline.value = columnRowsOf(listConfig.value)
|
||||
columnDirty.value = false
|
||||
Message.success('已保存为个人栏位设置')
|
||||
} catch (error) {
|
||||
Message.error('个人栏位设置保存失败:' + (error?.message || error))
|
||||
} finally {
|
||||
columnSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveColumnSettings() {
|
||||
if (loading.value || columnSaving.value || !tableRef.value) return
|
||||
if (typeof tableRef.value.getColumnSettings !== 'function') return
|
||||
@@ -715,7 +821,11 @@ async function saveColumnSettings() {
|
||||
savedListConfig.push({ ...current, b_xh: saved.b_xh, b_width: saved.b_width })
|
||||
}
|
||||
listConfig.value = savedListConfig
|
||||
Message.success('栏位设置已保存')
|
||||
// 通用已是新值:它同时成为「个人增量」的比较基准与新基线
|
||||
columnGlobalRows.value = savedListConfig
|
||||
columnBaseline.value = columnRowsOf(savedListConfig)
|
||||
columnDirty.value = false
|
||||
Message.success('栏位设置已保存为通用')
|
||||
} catch (error) {
|
||||
let errorMessage = error
|
||||
if (error && error.message) errorMessage = error.message
|
||||
@@ -751,9 +861,13 @@ function handleCellChange({ row, field }) {
|
||||
emit('dirty-change', { count: dirtyRows.value.size })
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入目标表:只使用 b_save_table(设计 §6.1)。
|
||||
* 视图表通常不可写,回退到 b_view_table 会让写入在数据库层失败,这里直接返回空串由调用方拒绝。
|
||||
*/
|
||||
function resolveSaveTable() {
|
||||
if (!dataModule.value) throw new Error('数据模块尚未加载')
|
||||
return String(dataModule.value.b_save_table || dataModule.value.b_view_table || '')
|
||||
return String(dataModule.value.b_save_table || '')
|
||||
}
|
||||
|
||||
function dirtyUpdateRows() {
|
||||
@@ -876,8 +990,12 @@ async function saveChanges() {
|
||||
|
||||
async function deleteSelected(rowKeys) {
|
||||
if (saving.value || !rowKeys?.length) return
|
||||
const table = resolveSaveTable()
|
||||
if (!table) {
|
||||
const moduleCode = String(dataModule.value?.b_id || '').trim()
|
||||
if (!moduleCode) {
|
||||
Message.error('该模块未配置模块编码,无法删除')
|
||||
return
|
||||
}
|
||||
if (!resolveSaveTable()) {
|
||||
Message.error('该模块未配置保存表,无法删除')
|
||||
return
|
||||
}
|
||||
@@ -907,13 +1025,14 @@ async function deleteSelected(rowKeys) {
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await saveObjectApi([
|
||||
{
|
||||
table,
|
||||
key_field: keyField.value,
|
||||
deletes: persistedKeys.map((key) => ({ [keyField.value]: key })),
|
||||
},
|
||||
])
|
||||
// 统一删除接口:传 moduleCode + ids,由后端读取保存表、展开 cascade、
|
||||
// 执行 restrict 与模块 SQL 规则(设计 §6.1)。前端不再拼 saveobjt 的 deletes 行。
|
||||
// 行键即主键值(resolveRowKey 取 keyField 列),直接作为 ids 提交。
|
||||
await deleteApi({
|
||||
target: 'module',
|
||||
moduleCode,
|
||||
ids: persistedKeys.map((key) => String(key)),
|
||||
})
|
||||
// 清除被删行的脏记录
|
||||
const deletedSet = new Set(persistedKeys.map((key) => String(key)))
|
||||
const remaining = new Map()
|
||||
@@ -1018,15 +1137,6 @@ async function handleMoreAction({ key }) {
|
||||
}
|
||||
if (key === 'refresh') {
|
||||
await reload()
|
||||
return
|
||||
}
|
||||
if (key === 'reset-columns') {
|
||||
await resetColumns()
|
||||
Message.success(t('common.columnSettingsReset', '已恢复栏位设置'))
|
||||
return
|
||||
}
|
||||
if (key === 'save-column-settings') {
|
||||
await saveColumnSettings()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1244,6 +1354,27 @@ defineExpose({
|
||||
/>
|
||||
</div>
|
||||
<div class="list-toolbar__status">{{ statusText }}</div>
|
||||
<!-- 栏位已调整时才浮出的操作条:平时不渲染,因此不占内容区、不改变表格高度。
|
||||
状态用 Tag(胶囊提示)、动作簇用 ButtonGroup(项目统一的贴合按钮组)。 -->
|
||||
<div v-if="columnDirty" class="list-toolbar__group list-column-bar">
|
||||
<Tag variant="secondary">{{ t('common.columnAdjusted', '列宽或顺序已调整') }}</Tag>
|
||||
<ButtonGroup>
|
||||
<Button type="outline" :disabled="columnSaving" @click="undoColumnSettings">
|
||||
<RotateCcw :size="14" /> {{ t('common.undo', '撤销') }}
|
||||
</Button>
|
||||
<Button type="outline" :disabled="columnSaving" @click="saveColumnAsPersonal">
|
||||
{{ t('common.saveAsPersonal', '保存为个人') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canSaveColumnGlobal"
|
||||
type="primary"
|
||||
:disabled="columnSaving"
|
||||
@click="saveColumnSettings"
|
||||
>
|
||||
{{ t('common.saveAsGlobal', '保存为通用') }}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div class="list-toolbar__group list-toolbar__group--tools">
|
||||
<Button
|
||||
type="outline"
|
||||
@@ -1292,6 +1423,7 @@ defineExpose({
|
||||
@expand="$emit('expand', $event)"
|
||||
@cell-change="handleCellChange"
|
||||
@row-order-change="handleRowOrderChange"
|
||||
@columns-change="handleColumnsChange"
|
||||
>
|
||||
<template v-for="slotName in cellSlotNames" :key="slotName" #[slotName]="slotProps">
|
||||
<slot :name="slotName" v-bind="slotProps" />
|
||||
@@ -1476,6 +1608,14 @@ defineExpose({
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 栏位已调整操作条:仅在 columnDirty 时渲染,不占常驻空间。
|
||||
容器不画框、不填色 —— 状态由 Tag 承担、动作由 ButtonGroup 承担,
|
||||
样式(圆角 / 描边 / 间距 / 主题色)全部来自组件库,跟随主题且不会与项目风格脱节。 */
|
||||
.list-column-bar {
|
||||
flex: none;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 底部分页条:左总数、右分页器 */
|
||||
.list-footer {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup>
|
||||
<script setup>
|
||||
import { computed, h, ref, shallowRef, useSlots, watch } from 'vue'
|
||||
import { Checkbox } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
@@ -31,6 +31,8 @@ const emit = defineEmits([
|
||||
'expand',
|
||||
'cell-change',
|
||||
'row-order-change',
|
||||
// 列宽/列顺序变化:栏位设置可能已偏离基线,父级据此刷新「已调整」状态
|
||||
'columns-change',
|
||||
])
|
||||
const i18nStore = useI18nStore()
|
||||
const { t } = i18nStore
|
||||
@@ -443,6 +445,16 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* FmsTable 回传列宽/列序(v-model:columns 的写入口)。
|
||||
* 显式接管而不直接用 v-model,是为了在写回的同时把「列已变化」上抛:
|
||||
* 父级据此判断栏位设置是否已偏离基线(配置变化触发的 rebuild 不走这里,不会误报)。
|
||||
*/
|
||||
function onColumnsUpdate(nextColumns) {
|
||||
columns.value = nextColumns
|
||||
emit('columns-change', getColumnSettings())
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetColumns,
|
||||
refreshLayout,
|
||||
@@ -457,7 +469,8 @@ defineExpose({
|
||||
<template>
|
||||
<FmsTable
|
||||
ref="tableRef"
|
||||
v-model:columns="columns"
|
||||
:columns="columns"
|
||||
@update:columns="onColumnsUpdate"
|
||||
:data-source="dataSource"
|
||||
:footer-data="footerData"
|
||||
:row-key="rowKey"
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
// 查询布局读写与两层继承合并(系统默认 → 个人)。
|
||||
// 布局读写与两层继承合并(系统默认 → 个人)。
|
||||
// s_module_schema —— 系统默认。查询布局以 query JSON(quick 列表 + 条件)为来源;
|
||||
// 由 schemaRender.applyQueryLayout 在「保存为通用」时回写。
|
||||
// 列宽顺序以 view JSON(columns 节点)为来源。
|
||||
// 分别由 schemaRender.applyQueryLayout / applyColumnSettings 在
|
||||
// 「保存为通用」时回写。
|
||||
// s_user_module_pref —— 个人覆盖(b_schema_type + b_schema_json),只存相对系统默认的增量。
|
||||
// 布局只能调整区域与顺序,不得放宽字段权限——合并后仍按字段权限裁剪(开发规范 通用原则 10)。
|
||||
// 两种布局(query 查询布局 / view 栏位布局)共用本文件的读写与增量计算,靠 schemaType 区分。
|
||||
// 布局只能调整区域、顺序与列宽,不得放宽字段权限——合并后仍按字段权限裁剪(开发规范 通用原则 10)。
|
||||
import { loadDataApi, saveObjectApi } from '@/services/api'
|
||||
import { escapeSqlText } from '@/components/fms-module-common/queryUtils'
|
||||
import { formatLocalDateTime } from '@/components/fms-module-common/fieldUtils'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const SCHEMA_TYPE = 'query'
|
||||
/** 个人偏好层可记录的布局类型:query=查询布局,view=列表栏位布局 */
|
||||
export const PREF_SCHEMA_TYPES = { query: 'query', view: 'view' }
|
||||
const DEFAULT_SCHEMA_TYPE = PREF_SCHEMA_TYPES.query
|
||||
const USER_PREF_TABLE = 's_user_module_pref'
|
||||
const USER_PREF_KEY = 'b_user_id,b_module_id,b_schema_type'
|
||||
|
||||
@@ -86,11 +91,126 @@ export function layoutDelta(current, base) {
|
||||
|
||||
/** 增量是否为空(无实际覆盖则无需落库) */
|
||||
export function isEmptyDelta(delta) {
|
||||
return !delta || (Object.keys(delta.area || {}).length === 0 && Object.keys(delta.order || {}).length === 0)
|
||||
return (
|
||||
!delta ||
|
||||
(Object.keys(delta.area || {}).length === 0 && Object.keys(delta.order || {}).length === 0)
|
||||
)
|
||||
}
|
||||
|
||||
/* ================= 栏位布局(view)个人层 =================
|
||||
* 与查询布局同构:系统默认(s_module_schema.view 的 columns 节点顺序与 width)
|
||||
* → 个人层增量(s_user_module_pref, b_schema_type='view')。
|
||||
*
|
||||
* 增量结构:{ schemaVersion: 1, columns: { [b_field]: { width } }, order: [b_field, ...] }
|
||||
* - columns:逐字段只记「与通用不同的宽度」,同宽不记,保证通用改宽仍能透传;
|
||||
* - order :顺序是全局排列,无法只记「谁和谁换了位」,因此整体记当前顺序。
|
||||
* 个人层只调整顺序与列宽,不得增删字段(开发规范 通用原则 10)。
|
||||
*/
|
||||
|
||||
/** 栏位行 → 规范化 { b_field, b_width }(忽略无字段的行) */
|
||||
function normalizeColumnRows(rows) {
|
||||
return (rows || [])
|
||||
.filter((row) => row && String(row.b_field ?? '') !== '')
|
||||
.map((row) => ({ b_field: String(row.b_field), b_width: row.b_width }))
|
||||
}
|
||||
|
||||
/** 宽度归一:null / undefined / '' / 非数字一律视为「未设置」,与数字 0 区分开 */
|
||||
function widthOf(raw) {
|
||||
if (raw == null || raw === '') return null
|
||||
const value = Number(raw)
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
/** 空对象常量:避免逐处内联空字面量 */
|
||||
const emptyColumns = Object.freeze({})
|
||||
|
||||
/**
|
||||
* 个人层覆盖叠加到通用栏位之上,返回新的栏位行数组。
|
||||
* 个人只改宽度与顺序,不新增字段:通用里没有的字段一律忽略。
|
||||
* @param baseRows 通用栏位行(buildListConfig 结果,含 b_visible/options 等)
|
||||
* @param pref 个人层增量(可能为 null)
|
||||
*/
|
||||
export function mergeColumnPref(baseRows, pref) {
|
||||
const base = normalizeColumnRows(baseRows)
|
||||
if (!pref) return baseRows || []
|
||||
const widthById = pref.columns || {}
|
||||
const orderList = Array.isArray(pref.order) ? pref.order.map(String) : []
|
||||
|
||||
const known = new Set(base.map((row) => row.b_field))
|
||||
const rank = new Map()
|
||||
orderList.forEach((field, index) => {
|
||||
if (known.has(field)) rank.set(field, index)
|
||||
})
|
||||
|
||||
// 有 order 名次的按名次排;没有的(含未列入 order 的新字段)保持原有相对顺序追加在后
|
||||
const ordered = base
|
||||
.map((row, index) => ({ row, index }))
|
||||
.toSorted((a, b) => {
|
||||
const ra = rank.has(a.row.b_field) ? rank.get(a.row.b_field) : Number.MAX_SAFE_INTEGER
|
||||
const rb = rank.has(b.row.b_field) ? rank.get(b.row.b_field) : Number.MAX_SAFE_INTEGER
|
||||
return ra === rb ? a.index - b.index : ra - rb
|
||||
})
|
||||
.map((item) => item.row)
|
||||
|
||||
// 回到 baseRows 的原始形态(保留 b_visible/options 等),只改顺序与宽度并重编 b_xh
|
||||
const rowByField = new Map((baseRows || []).map((row) => [String(row?.b_field ?? ''), row]))
|
||||
return ordered.map((item, index) => {
|
||||
const original = rowByField.get(item.b_field) || item
|
||||
const override = widthById[item.b_field]
|
||||
const nextWidth = override?.width != null ? widthOf(override.width) : widthOf(original.b_width)
|
||||
return { ...original, b_xh: (index + 1) * 10, b_width: nextWidth }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前栏位设置相对通用默认的增量(只保留差异)。
|
||||
* @param current getColumnSettings() 结果([{ b_field, b_xh, b_width }])
|
||||
* @param base 通用栏位行(含 b_field / b_width,数组顺序即通用顺序)
|
||||
*/
|
||||
export function columnSettingsDelta(current, base) {
|
||||
const baseRows = normalizeColumnRows(base)
|
||||
const curRows = normalizeColumnRows(current)
|
||||
const baseWidth = new Map(baseRows.map((row) => [row.b_field, widthOf(row.b_width)]))
|
||||
|
||||
// 宽度:只记与通用不同、且通用里确实存在的字段
|
||||
const columns = {}
|
||||
for (const row of curRows) {
|
||||
if (!baseWidth.has(row.b_field)) continue
|
||||
const next = widthOf(row.b_width)
|
||||
if (next == null || next === baseWidth.get(row.b_field)) continue
|
||||
columns[row.b_field] = { width: next }
|
||||
}
|
||||
|
||||
// 顺序:与通用一致时不记,避免把通用顺序固化、导致管理员后续调整无法透传
|
||||
const baseOrder = baseRows.map((row) => row.b_field)
|
||||
const curOrder = curRows.map((row) => row.b_field)
|
||||
const sameOrder =
|
||||
baseOrder.length === curOrder.length && baseOrder.every((field, i) => field === curOrder[i])
|
||||
|
||||
const delta = { schemaVersion: 1, columns }
|
||||
if (!sameOrder) delta.order = curOrder
|
||||
return delta
|
||||
}
|
||||
|
||||
/** 栏位增量是否为空(宽度与顺序都与通用一致 → 无需落库,可清空个人层) */
|
||||
export function isEmptyColumnDelta(delta) {
|
||||
if (!delta) return true
|
||||
return Object.keys(delta.columns ?? emptyColumns).length === 0 && !delta.order
|
||||
}
|
||||
|
||||
/** 当前栏位设置与基线是否一致(列宽或顺序任一不同即为「已调整」) */
|
||||
export function sameColumnSettings(current, baseline) {
|
||||
const cur = normalizeColumnRows(current)
|
||||
const base = normalizeColumnRows(baseline)
|
||||
if (cur.length !== base.length) return false
|
||||
return cur.every((row, index) => {
|
||||
const other = base[index]
|
||||
return row.b_field === other.b_field && widthOf(row.b_width) === widthOf(other.b_width)
|
||||
})
|
||||
}
|
||||
|
||||
/** 读取个人层(s_user_module_pref)某一 schema 类型的覆盖 JSON;无记录或解析失败返回 null */
|
||||
export async function loadModulePref(userId, moduleId, schemaType = SCHEMA_TYPE) {
|
||||
export async function loadModulePref(userId, moduleId, schemaType = DEFAULT_SCHEMA_TYPE) {
|
||||
if (!userId || !moduleId) return null
|
||||
const condition =
|
||||
`b_user_id = N'${escapeSqlText(userId)}' AND b_module_id = N'${escapeSqlText(moduleId)}' ` +
|
||||
@@ -116,7 +236,13 @@ function parseRow(row) {
|
||||
/**
|
||||
* 写个人层覆盖(s_user_module_pref)。后端 saveobject 无 upsert:insert 冲突重试 updates。
|
||||
*/
|
||||
export async function saveModulePref(userId, moduleId, pref, rowExists, schemaType = SCHEMA_TYPE) {
|
||||
export async function saveModulePref(
|
||||
userId,
|
||||
moduleId,
|
||||
pref,
|
||||
rowExists,
|
||||
schemaType = DEFAULT_SCHEMA_TYPE,
|
||||
) {
|
||||
if (!userId || !moduleId) return false
|
||||
const row = {
|
||||
b_user_id: userId,
|
||||
@@ -143,7 +269,7 @@ export async function saveModulePref(userId, moduleId, pref, rowExists, schemaTy
|
||||
* 删除某层覆盖(「恢复默认」即删除个人层,回落到系统/企业层或系统默认)。
|
||||
* schemaType 为空数组则删除该模块全部类型(供「恢复出厂」等管理操作使用)。
|
||||
*/
|
||||
export async function removeModulePref(userId, moduleId, schemaType = SCHEMA_TYPE) {
|
||||
export async function removeModulePref(userId, moduleId, schemaType = DEFAULT_SCHEMA_TYPE) {
|
||||
if (!userId || !moduleId) return false
|
||||
const keys = Array.isArray(schemaType) ? schemaType : [schemaType]
|
||||
const deletes = keys.map((type) => ({
|
||||
@@ -171,4 +297,4 @@ export async function loadFieldPower(userId, moduleId) {
|
||||
power.set(String(row.b_field_id), Number(row.b_query) === 0 ? 0 : 1)
|
||||
}
|
||||
return power
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,15 @@ export function saveObjectApi(requests) {
|
||||
return post('/data/saveobjt', requests)
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一删除接口。
|
||||
* 模块数据传 { target: 'module', moduleCode, ids };
|
||||
* 受控后端服务传 { target: 'direct', table, key_field, ids }。
|
||||
*/
|
||||
export function deleteApi(params) {
|
||||
return post('/data/delete', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下一个雪花 ID;传入 count 可批量获取指定数量的 ID(返回数组)。
|
||||
*/
|
||||
|
||||
@@ -146,11 +146,13 @@ function onFailed() {
|
||||
{{ loginHint }}
|
||||
</div>
|
||||
|
||||
<!-- 表单 -->
|
||||
<!-- 表单:登录页固定上下结构(label 在上、控件在下),
|
||||
不跟随 App.vue 的「中文横向 / 其他语言纵向」全局切换 -->
|
||||
<Form
|
||||
class="login-form"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
layout="vertical"
|
||||
:gap="20"
|
||||
@submit="onSubmit"
|
||||
@submit-failed="onFailed"
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
* b_source_module_id + b_source_field → b_target_module_id + b_target_field,
|
||||
* 另有 b_relation_type(one_to_one / one_to_many / many_to_one)、b_on_delete、b_canuse、b_xh。
|
||||
*
|
||||
* 删除行为(两列):
|
||||
* - 「删除连带」写 s_relation.b_on_delete:删源记录时对目标行不连带 / 连带删除 / 连带归档;
|
||||
* 多对一是「多条指向同一条」,只允许不连带(连带会波及其他引用方)。
|
||||
* - 「有引用时拒绝删除」不写 s_relation:勾选时在 refRules 草稿(s_rule,scope_type='relation')
|
||||
* 里增/删一条引用规则,由父级随保存一并提交。
|
||||
* 删除行为:由 s_relation.b_on_delete 表达,三值 restrict / cascade / none:
|
||||
* - restrict:被引用方仍有外部引用时拒绝删除(新建关系的默认值);
|
||||
* - cascade:引用方记录加入本次物理删除清单;
|
||||
* - none:不自动处理关联记录。
|
||||
* 多对一不再被硬编码禁止 cascade(设计 §3.2):是否允许级联由配置和业务语义决定,
|
||||
* 后端负责检查环路、重复记录和删除层级。删除行为的编辑来源收敛到「删除策略」面板。
|
||||
*
|
||||
* 方向性:上区只编辑「本模块作为源」的关系(父级已按 b_source_module_id 拆好);
|
||||
* 下区只读展示「其他模块指向本模块」的引用,避免对方配置的关系完全不可见。
|
||||
@@ -22,10 +23,9 @@ import { computed, nextTick, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { Plus, Trash2 } from '@lucide/vue'
|
||||
import { Button, Message, Spin } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { loadDataApi, nextIdApi } from '@/services/api'
|
||||
import { loadDataApi } from '@/services/api'
|
||||
import { createTempId } from '@/utils/common'
|
||||
import { sqlStringLiteral } from './utils'
|
||||
import { createRefRuleRow, isCompleteRelationKey, relationScopeId } from './ruleUtils'
|
||||
|
||||
const props = defineProps({
|
||||
/** 当前模块编码:源侧关系的 b_source_module_id */
|
||||
@@ -36,16 +36,13 @@ const props = defineProps({
|
||||
modules: { type: Array, default: () => [] },
|
||||
/** 其他模块指向本模块的引用行(只读展示) */
|
||||
reverseRows: { type: Array, default: () => [] },
|
||||
/** 删除行为由「删除策略」面板编辑时为 true,本面板该列改只读 */
|
||||
deletePolicyReadonly: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
/** 当前模块作为源的关系行(s_relation),父级 v-model 绑定 relations */
|
||||
const relations = defineModel({ type: Array, default: () => [] })
|
||||
/**
|
||||
* 关系上的引用规则草稿(s_rule,scope_type='relation'),父级 v-model:ref-rules 绑定。
|
||||
* 它不属于 s_relation:勾选「有引用时拒绝删除」时在这里增/删行,走规则表的正常 diff。
|
||||
*/
|
||||
const refRules = defineModel('refRules', { type: Array, default: () => [] })
|
||||
|
||||
const RELATION_TYPE_OPTIONS = [
|
||||
{ label: '一对一', value: 'one_to_one' },
|
||||
@@ -55,29 +52,16 @@ const RELATION_TYPE_OPTIONS = [
|
||||
const RELATION_TYPE_VALUES = RELATION_TYPE_OPTIONS.map((item) => item.value)
|
||||
const RELATION_TYPE_LABELS = new Map(RELATION_TYPE_OPTIONS.map((item) => [item.value, item.label]))
|
||||
|
||||
/** 删除连带策略:连带删除/连带归档只在源→目标(一对多 / 一对一)上成立 */
|
||||
/** 关联删除处理:三值,多对一同样允许配置 cascade(设计 §3.2) */
|
||||
const DELETE_POLICY_OPTIONS = [
|
||||
{ label: '不连带', value: 'none' },
|
||||
{ label: '拒绝删除(有引用)', value: 'restrict' },
|
||||
{ label: '连带删除', value: 'cascade' },
|
||||
{ label: '连带归档', value: 'archive' },
|
||||
{ label: '不处理', value: 'none' },
|
||||
]
|
||||
const DELETE_POLICY_VALUES = DELETE_POLICY_OPTIONS.map((item) => item.value)
|
||||
/** 关系主键列(改动即换业务键,挂在旧键上的引用规则必须作废) */
|
||||
const KEY_COLUMNS = new Set(['b_source_field', 'b_target_module_id', 'b_target_field'])
|
||||
|
||||
function isManyToOne(row) {
|
||||
return String(row?.b_relation_type ?? '') === 'many_to_one'
|
||||
}
|
||||
|
||||
/* ================= 行本地派生列 ================= */
|
||||
// FmsTable 的行身份用 _rowKey;_hasRefRule / _refScopeId 是引用规则勾选态与旧业务键的记忆,
|
||||
// 三者都是 _ 前缀:不落 s_relation、不参与 diff 与脏判定,加载回填不会置脏
|
||||
function hasRefRule(row) {
|
||||
if (!isCompleteRelationKey(row)) return 0
|
||||
const key = relationScopeId(row)
|
||||
return refRules.value.some((rule) => String(rule.b_scope_id ?? '') === key) ? 1 : 0
|
||||
}
|
||||
|
||||
// FmsTable 的行身份用 _rowKey;_ 前缀不落 s_relation、不参与 diff 与脏判定
|
||||
function syncRelationRows() {
|
||||
let changed = false
|
||||
for (const row of relations.value || []) {
|
||||
@@ -85,51 +69,11 @@ function syncRelationRows() {
|
||||
row._rowKey = createTempId()
|
||||
changed = true
|
||||
}
|
||||
const nextKey = isCompleteRelationKey(row)
|
||||
? relationScopeId(row)
|
||||
: String(row._refScopeId ?? '')
|
||||
if (row._refScopeId !== nextKey) {
|
||||
row._refScopeId = nextKey
|
||||
changed = true
|
||||
}
|
||||
const nextRef = hasRefRule(row)
|
||||
if (row._hasRefRule !== nextRef) {
|
||||
row._hasRefRule = nextRef
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) relations.value = [...relations.value]
|
||||
}
|
||||
|
||||
watch([() => relations.value, () => refRules.value], syncRelationRows, {
|
||||
deep: true,
|
||||
immediate: true,
|
||||
})
|
||||
|
||||
/** 勾选态与规则草稿的联动:勾上建行(先取雪花号)、取消删行 */
|
||||
async function setRefRule(row, enabled) {
|
||||
const key = relationScopeId(row)
|
||||
if (!key || !isCompleteRelationKey(row)) {
|
||||
row._hasRefRule = 0
|
||||
return
|
||||
}
|
||||
const exists = refRules.value.some((rule) => String(rule.b_scope_id ?? '') === key)
|
||||
if (enabled && !exists) {
|
||||
const idRes = await nextIdApi()
|
||||
refRules.value = [...refRules.value, createRefRuleRow(key, String(idRes?.data ?? ''))]
|
||||
} else if (!enabled && exists) {
|
||||
refRules.value = refRules.value.filter((rule) => String(rule.b_scope_id ?? '') !== key)
|
||||
}
|
||||
}
|
||||
|
||||
/** 按业务键删掉引用规则(删行 / 换主键时调用) */
|
||||
function removeRefRulesOf(keys) {
|
||||
const set = new Set(keys.filter(Boolean).map(String))
|
||||
if (!set.size) return false
|
||||
const before = refRules.value.length
|
||||
refRules.value = refRules.value.filter((rule) => !set.has(String(rule.b_scope_id ?? '')))
|
||||
return refRules.value.length !== before
|
||||
}
|
||||
watch(() => relations.value, syncRelationRows, { deep: true, immediate: true })
|
||||
|
||||
/* ================= 模块选项与名称解析 ================= */
|
||||
const moduleById = computed(
|
||||
@@ -256,22 +200,15 @@ const columns = shallowRef([
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_on_delete',
|
||||
title: '删除连带',
|
||||
width: 130,
|
||||
// 多对一是「多条指向同一条」:删源连带删目标会波及其他引用方,只允许不连带
|
||||
title: '关联删除处理',
|
||||
width: 150,
|
||||
// 三值对多对一同样开放(设计 §3.2):不再按关系类型禁用 cascade
|
||||
editor: {
|
||||
type: 'select',
|
||||
options: DELETE_POLICY_OPTIONS,
|
||||
disabled: (row) => isManyToOne(row),
|
||||
disabled: () => props.deletePolicyReadonly,
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: '_hasRefRule',
|
||||
title: '有引用时拒绝删除',
|
||||
width: 140,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox', disabled: (row) => !isCompleteRelationKey(row) },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_canuse',
|
||||
title: '启用',
|
||||
@@ -313,25 +250,6 @@ function onCellChange(row, col) {
|
||||
row.b_target_field = null
|
||||
void ensureTargetFields(row.b_target_module_id)
|
||||
}
|
||||
// 关系类型切到多对一时连带策略不再成立,清回不连带
|
||||
if (dataIndex === 'b_relation_type' && isManyToOne(row) && row.b_on_delete !== 'none') {
|
||||
row.b_on_delete = 'none'
|
||||
Message.warning('多对一关系不支持连带删除/连带归档,已重置为不连带')
|
||||
}
|
||||
// 勾选/取消「有引用时拒绝删除」:在规则草稿里增/删一条引用规则
|
||||
if (dataIndex === '_hasRefRule') {
|
||||
void setRefRule(row, row._hasRefRule === 1)
|
||||
}
|
||||
// 关系主键列改动 → 业务键变了,挂在旧键上的引用规则必须作废(否则库里留下孤儿规则)
|
||||
if (KEY_COLUMNS.has(dataIndex)) {
|
||||
const prevKey = String(row._refScopeId ?? '')
|
||||
const nextKey = isCompleteRelationKey(row) ? relationScopeId(row) : ''
|
||||
if (prevKey && prevKey !== nextKey) {
|
||||
const removed = removeRefRulesOf([prevKey])
|
||||
row._hasRefRule = 0
|
||||
if (removed) Message.info('关系主键已变化,「有引用时拒绝删除」已取消,请重新勾选')
|
||||
}
|
||||
}
|
||||
// 行对象已就地 patch,换新数组引用触发表格重渲染
|
||||
relations.value = [...relations.value]
|
||||
}
|
||||
@@ -345,7 +263,8 @@ async function addRow() {
|
||||
b_target_module_id: null,
|
||||
b_target_field: null,
|
||||
b_relation_type: 'many_to_one',
|
||||
b_on_delete: 'none',
|
||||
// 新建关系默认 restrict:被引用时拒绝删除(设计 §3.1)
|
||||
b_on_delete: 'restrict',
|
||||
b_canuse: 1,
|
||||
b_xh: (relations.value.length + 1) * 10,
|
||||
}
|
||||
@@ -358,8 +277,6 @@ async function addRow() {
|
||||
|
||||
function removeRow(row) {
|
||||
relations.value = relations.value.filter((item) => item !== row)
|
||||
// 关系行没了,挂在它上面的引用规则也要一起消失
|
||||
removeRefRulesOf([row._refScopeId])
|
||||
if (String(row._rowKey) === String(activeKey.value)) activeKey.value = null
|
||||
selectedRowKeys.value = selectedRowKeys.value.filter((key) => key !== String(row._rowKey))
|
||||
}
|
||||
@@ -369,9 +286,7 @@ function batchDelete() {
|
||||
const keys = selectedRowKeys.value
|
||||
if (!keys.length) return
|
||||
const delSet = new Set(keys.map(String))
|
||||
const removed = relations.value.filter((row) => delSet.has(String(row._rowKey)))
|
||||
relations.value = relations.value.filter((row) => !delSet.has(String(row._rowKey)))
|
||||
removeRefRulesOf(removed.map((row) => row._refScopeId))
|
||||
selectedRowKeys.value = []
|
||||
if (activeKey.value && delSet.has(String(activeKey.value))) activeKey.value = null
|
||||
Message.success(`已删除 ${keys.length} 条关联`)
|
||||
@@ -414,7 +329,7 @@ function relationKeyOf(row) {
|
||||
.join('|')
|
||||
}
|
||||
|
||||
/** 保存前校验:四列非空、关系类型合法、组合不重复、删除连带合法(空列会撞行身份,落库即主键冲突) */
|
||||
/** 保存前校验:四列非空、关系类型合法、组合不重复、删除处置合法(空列会撞行身份,落库即主键冲突) */
|
||||
function validate() {
|
||||
const errors = []
|
||||
const seen = new Set()
|
||||
@@ -426,11 +341,11 @@ function validate() {
|
||||
if (!RELATION_TYPE_VALUES.includes(String(row.b_relation_type ?? ''))) {
|
||||
errors.push(`${label}:关系类型不合法`)
|
||||
}
|
||||
const policy = String(row.b_on_delete ?? 'none')
|
||||
// 三值对多对一同样合法(设计 §3.2):不再按关系类型额外限制。
|
||||
// 存量行可能没有 b_on_delete(列晚于数据),按历史默认 none 兜底,不阻断保存。
|
||||
const policy = String(row.b_on_delete ?? '') || 'none'
|
||||
if (!DELETE_POLICY_VALUES.includes(policy)) {
|
||||
errors.push(`${label}:删除连带取值不合法`)
|
||||
} else if (policy !== 'none' && isManyToOne(row)) {
|
||||
errors.push(`${label}:多对一关系不支持连带删除/连带归档`)
|
||||
errors.push(`${label}:关联删除处理取值不合法`)
|
||||
}
|
||||
const key = relationKeyOf(row)
|
||||
if (seen.has(key)) errors.push(`${label}:与前面的关联重复`)
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 删除规则编辑弹窗(s_rule)。
|
||||
* 删除 SQL 规则编辑弹窗(s_delete_rule)。
|
||||
*
|
||||
* 规则没有名称/编码,界面上只填"什么情况不许删"和"拦下来说什么":
|
||||
* - 勾条件:复用高级查询抽屉(FmsAdvancedQueryPanel)编辑,保存为 {"kind":"dsl","expr":AST},
|
||||
* 提示文案可留空(引擎按条件自动生成人话);
|
||||
* - 写 SQL:文本直存,`:ids` 会自动替换为待删记录 id,查出有结果即拦截;提示文案必填。
|
||||
* - b_sql:只读查询,用 :ids 标记本次待删记录 id;查出有结果即拦截;
|
||||
* - b_message:拦下时给用户看的提示文案(第一阶段固定文案,不做占位符替换)。
|
||||
* 主键 b_id 在打开弹窗前由面板取号,本弹窗不涉及。
|
||||
* 编辑对象是父级传入的行副本,取消不污染草稿。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Button, Form, FormItem, Input, Modal, Radio, RadioGroup, Switch } from '@/components/ui'
|
||||
import FmsAdvancedQueryPanel from '@/components/fms-module-list/FmsAdvancedQueryPanel.vue'
|
||||
import {
|
||||
advancedQueryFromAst,
|
||||
buildAdvancedAst,
|
||||
describeAdvancedQuery,
|
||||
} from '@/components/fms-module-common/queryUtils'
|
||||
import { useI18nStore } from '@/stores/i18n'
|
||||
import { RULE_HOOK_DELETE, itemMapOf, parseRulePredicate } from './ruleUtils'
|
||||
import { Button, Form, FormItem, Input, Modal, Switch } from '@/components/ui'
|
||||
import { IDS_PLACEHOLDER } from './ruleUtils'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
@@ -26,97 +18,59 @@ const props = defineProps({
|
||||
row: { type: Object, default: null },
|
||||
/** 新增还是编辑:只影响标题 */
|
||||
isNew: { type: Boolean, default: false },
|
||||
/** 条件面板 items(当前模块字段 + 查询配置) */
|
||||
items: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['update:open', 'ok'])
|
||||
|
||||
const { t } = useI18nStore()
|
||||
|
||||
const RULE_TYPE_OPTIONS = [
|
||||
{ value: 'dsl', label: '勾条件' },
|
||||
{ value: 'sql', label: '写 SQL' },
|
||||
]
|
||||
|
||||
const ruleType = ref('dsl')
|
||||
const conditionState = ref({ nodes: [], groups: [] })
|
||||
const conditionOpen = ref(false)
|
||||
const conditionDegraded = ref(false)
|
||||
const sqlText = ref('')
|
||||
const conditionError = ref('')
|
||||
const messageText = ref('')
|
||||
const sqlError = ref('')
|
||||
const messageError = ref('')
|
||||
|
||||
const itemByField = computed(() => itemMapOf(props.items))
|
||||
const conditionSummary = computed(() =>
|
||||
describeAdvancedQuery(conditionState.value, itemByField.value, t),
|
||||
)
|
||||
const modalTitle = computed(() => (props.isNew ? '新增删除规则' : '编辑删除规则'))
|
||||
|
||||
// 每次打开按当前行还原表单(predicate 三态 → 类型 + 各自内容)
|
||||
// 每次打开按当前行还原表单
|
||||
watch(
|
||||
() => [props.open, props.row],
|
||||
() => {
|
||||
if (!props.open || !props.row) return
|
||||
const parsed = parseRulePredicate(props.row)
|
||||
ruleType.value = parsed.kind === 'sql' ? 'sql' : 'dsl'
|
||||
sqlText.value = parsed.kind === 'sql' ? parsed.sql : ''
|
||||
if (parsed.kind === 'dsl') {
|
||||
const state = advancedQueryFromAst(parsed.expr)
|
||||
conditionState.value = { nodes: state.nodes, groups: state.groups }
|
||||
conditionDegraded.value = state.degraded
|
||||
} else {
|
||||
conditionState.value = { nodes: [], groups: [] }
|
||||
conditionDegraded.value = false
|
||||
}
|
||||
conditionError.value = ''
|
||||
sqlText.value = String(props.row.b_sql ?? '')
|
||||
messageText.value = String(props.row.b_message ?? '')
|
||||
sqlError.value = ''
|
||||
messageError.value = ''
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function onTypeChange() {
|
||||
conditionError.value = ''
|
||||
sqlError.value = ''
|
||||
}
|
||||
|
||||
function onConditionChange() {
|
||||
conditionError.value = ''
|
||||
}
|
||||
|
||||
function onConditionApply() {
|
||||
conditionOpen.value = false
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
async function onOk() {
|
||||
function onOk() {
|
||||
const row = props.row
|
||||
if (!row) return close()
|
||||
conditionError.value = ''
|
||||
sqlError.value = ''
|
||||
messageError.value = ''
|
||||
|
||||
if (ruleType.value === 'sql') {
|
||||
const sql = sqlText.value.trim()
|
||||
if (!sql) {
|
||||
sqlError.value = '请填写规则 SQL'
|
||||
return
|
||||
}
|
||||
if (!String(row.b_message ?? '').trim()) {
|
||||
sqlError.value = '写 SQL 的规则必须填写提示文案'
|
||||
return
|
||||
}
|
||||
row.b_predicate = sql
|
||||
} else {
|
||||
const ast = buildAdvancedAst(conditionState.value)
|
||||
if (!(ast.children || []).length) {
|
||||
conditionError.value = '勾条件规则至少要有一个条件'
|
||||
return
|
||||
}
|
||||
row.b_predicate = JSON.stringify({ kind: 'dsl', expr: ast })
|
||||
const sql = sqlText.value.trim()
|
||||
if (!sql) {
|
||||
sqlError.value = '请填写规则 SQL'
|
||||
return
|
||||
}
|
||||
row.b_hook = RULE_HOOK_DELETE
|
||||
if (!/^(select|with)\b/i.test(sql)) {
|
||||
sqlError.value = '规则 SQL 必须是只读查询(以 select 或 with 开头)'
|
||||
return
|
||||
}
|
||||
if (!sql.includes(IDS_PLACEHOLDER)) {
|
||||
sqlError.value = `规则 SQL 必须包含 ${IDS_PLACEHOLDER} 标记,用于限定本次待删记录`
|
||||
return
|
||||
}
|
||||
if (!messageText.value.trim()) {
|
||||
messageError.value = '请填写提示文案'
|
||||
return
|
||||
}
|
||||
|
||||
row.b_sql = sql
|
||||
row.b_message = messageText.value.trim()
|
||||
emit('ok', row)
|
||||
emit('update:open', false)
|
||||
}
|
||||
@@ -130,48 +84,22 @@ async function onOk() {
|
||||
centered
|
||||
:resizable="false"
|
||||
:fullscreen="false"
|
||||
:mask-closable="false"
|
||||
:mask-closable="true"
|
||||
@update:open="(value) => emit('update:open', value)"
|
||||
@ok="onOk"
|
||||
>
|
||||
<Form v-if="row" :model="row" :columns="1">
|
||||
<FormItem label="规则类型" name="_rule_type">
|
||||
<RadioGroup v-model="ruleType" @change="onTypeChange">
|
||||
<Radio v-for="option in RULE_TYPE_OPTIONS" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
v-if="ruleType === 'dsl'"
|
||||
label="判定条件"
|
||||
name="_condition"
|
||||
:validate-status="conditionError ? 'error' : ''"
|
||||
:help="conditionError"
|
||||
>
|
||||
<div class="mre-condition">
|
||||
<span class="mre-condition__text">{{ conditionSummary || '尚未配置条件' }}</span>
|
||||
<Button type="outline" @click="conditionOpen = true">编辑条件</Button>
|
||||
</div>
|
||||
<div v-if="conditionDegraded" class="mre-condition__warn">
|
||||
原条件含多层分组,已按单层展开,保存前请确认
|
||||
</div>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
v-else
|
||||
label="规则 SQL"
|
||||
name="_sql"
|
||||
name="b_sql"
|
||||
:validate-status="sqlError ? 'error' : ''"
|
||||
:help="sqlError"
|
||||
extra=":ids 会自动替换为本次待删记录的 id 列表;查出有结果即拦截,每次执行都会记技术日志"
|
||||
>
|
||||
<Input
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
:rows="7"
|
||||
:model-value="sqlText"
|
||||
placeholder="select b_id from bill where b_id in (:ids) and b_settled = 1"
|
||||
placeholder="select b_id from cw_receipt where b_id in (:ids) and b_status in ('审核中', '已生效')"
|
||||
@update:model-value="(value) => (sqlText = value)"
|
||||
/>
|
||||
</FormItem>
|
||||
@@ -179,16 +107,13 @@ async function onOk() {
|
||||
<FormItem
|
||||
label="提示文案"
|
||||
name="b_message"
|
||||
:extra="
|
||||
ruleType === 'sql'
|
||||
? '写 SQL 时必填,可用 {列名} 引用查询结果'
|
||||
: '留空则由系统按条件自动生成'
|
||||
"
|
||||
:validate-status="messageError ? 'error' : ''"
|
||||
:help="messageError"
|
||||
>
|
||||
<Input
|
||||
:model-value="row.b_message"
|
||||
:placeholder="ruleType === 'sql' ? '如:单据 {b_no} 已销账,不能删除' : '可留空'"
|
||||
@update:model-value="(value) => (row.b_message = value)"
|
||||
:model-value="messageText"
|
||||
placeholder="如:单据已审核或已生效,不能删除"
|
||||
@update:model-value="(value) => (messageText = value)"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
@@ -200,36 +125,9 @@ async function onOk() {
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<!-- 条件抽屉:挂在弹窗之上(滚动锁是引用计数,关闭抽屉不会解锁弹窗) -->
|
||||
<FmsAdvancedQueryPanel
|
||||
v-model="conditionState"
|
||||
v-model:open="conditionOpen"
|
||||
:items="items"
|
||||
@apply="onConditionApply"
|
||||
@update:model-value="onConditionChange"
|
||||
/>
|
||||
<template #footer>
|
||||
<Button @click="close">取消</Button>
|
||||
<Button type="primary" @click="onOk">确定</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mre-condition {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mre-condition__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--fms-text);
|
||||
font-size: var(--fms-font-size);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mre-condition__warn {
|
||||
margin-top: 6px;
|
||||
color: var(--fms-warning, #d97706);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,62 +1,33 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 模块删除规则面板(s_rule,scope_type='module'):当前模块下"什么情况不许删"的规则表。
|
||||
* 删除 SQL 规则面板(s_delete_rule):当前模块下"什么情况不许删"的规则表。
|
||||
*
|
||||
* 规则没有名称/编码:列表靠内容自述识别——勾条件显示条件中文摘要,写 SQL 显示它的提示文案,
|
||||
* 规则没有名称/编码:列表靠内容自述识别——优先显示提示文案(人话),没填时显示 SQL 片段;
|
||||
* 主键是新增时取号的雪花 b_id(IT 不可见)。
|
||||
*
|
||||
* 数据契约:默认 v-model 与父级 rules 双向绑定(父级另持 rules_org 基线用于保存 diff);
|
||||
* 关系上的引用规则(scope_type='relation')不在本面板编辑,只在只读区展示并可跳转到模块关联。
|
||||
* 数据契约:默认 v-model 与父级 rules 双向绑定(父级另持 rules_org 基线用于保存 diff)。
|
||||
* 关系上的关联处置(restrict / cascade / none)由「关联删除处理」面板维护,不在本面板编辑。
|
||||
*
|
||||
* 列派生说明:_rowKey / _typeText / _labelText 都是界面派生列(_ 前缀不落库、不判脏),
|
||||
* 由 watch 就地补齐后换数组引用触发表格刷新。
|
||||
* 列派生说明:_rowKey / _labelText 是界面派生列(_ 前缀不落库、不判脏)。
|
||||
*/
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { Plus, Trash2 } from '@lucide/vue'
|
||||
import { Button, Message, Spin } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { nextIdApi } from '@/services/api'
|
||||
import { useI18nStore } from '@/stores/i18n'
|
||||
import { createTempId } from '@/utils/common'
|
||||
import ModuleRuleEditModal from './ModuleRuleEditModal.vue'
|
||||
import {
|
||||
REF_RULE_HINT,
|
||||
buildRuleQueryItems,
|
||||
createRuleRow,
|
||||
itemMapOf,
|
||||
parseRelationScopeId,
|
||||
parseRulePredicate,
|
||||
ruleHasCondition,
|
||||
ruleLabel,
|
||||
ruleTypeText,
|
||||
} from './ruleUtils'
|
||||
import { createRuleRow, ruleHasSql, ruleIsReadOnlySql, ruleLabel } from './ruleUtils'
|
||||
|
||||
const props = defineProps({
|
||||
/** 当前模块编码:规则的 b_scope_id */
|
||||
/** 当前模块编码:规则的 b_module_id */
|
||||
moduleId: { type: String, default: '' },
|
||||
/** 当前模块字段(勾条件的可选字段) */
|
||||
fields: { type: Array, default: () => [] },
|
||||
/** 查询配置 JSON:字段的操作符 / 控件 / 选项从它继承 */
|
||||
querySchema: { type: Object, default: () => ({}) },
|
||||
/** 其他模块指向本模块的关系上挂的引用规则(只读展示) */
|
||||
refRulesReverse: { type: Array, default: () => [] },
|
||||
/** 全量模块:把引用规则里的模块编码解析成名称 */
|
||||
modules: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
/** 当前模块的删除规则行(s_rule,scope_type='module'),父级 v-model 绑定 rules */
|
||||
/** 当前模块的删除规则行(s_delete_rule),父级 v-model 绑定 rules */
|
||||
const rules = defineModel({ type: Array, default: () => [] })
|
||||
|
||||
/** navigate:跳到关系所属模块(父级决定切 tab 还是切模块) */
|
||||
const emit = defineEmits(['navigate'])
|
||||
|
||||
const { t } = useI18nStore()
|
||||
|
||||
/** 条件面板 items 与字段索引(勾条件的字段下拉与中文摘要共用) */
|
||||
const items = computed(() => buildRuleQueryItems(props.fields, props.querySchema))
|
||||
const itemByField = computed(() => itemMapOf(items.value))
|
||||
|
||||
/* ================= 派生列 ================= */
|
||||
function syncRuleRows() {
|
||||
let changed = false
|
||||
@@ -65,12 +36,7 @@ function syncRuleRows() {
|
||||
row._rowKey = createTempId()
|
||||
changed = true
|
||||
}
|
||||
const typeText = ruleTypeText(row)
|
||||
if (row._typeText !== typeText) {
|
||||
row._typeText = typeText
|
||||
changed = true
|
||||
}
|
||||
const labelText = ruleLabel(row, itemByField.value, t)
|
||||
const labelText = ruleLabel(row)
|
||||
if (row._labelText !== labelText) {
|
||||
row._labelText = labelText
|
||||
changed = true
|
||||
@@ -79,7 +45,7 @@ function syncRuleRows() {
|
||||
if (changed) rules.value = [...rules.value]
|
||||
}
|
||||
|
||||
watch([() => rules.value, itemByField], syncRuleRows, { deep: true, immediate: true })
|
||||
watch(() => rules.value, syncRuleRows, { deep: true, immediate: true })
|
||||
|
||||
/* ================= 表格 ================= */
|
||||
const tableRef = ref(null)
|
||||
@@ -87,8 +53,7 @@ const activeKey = ref(null)
|
||||
const selectedRowKeys = ref([])
|
||||
|
||||
const columns = shallowRef([
|
||||
{ dataIndex: '_typeText', title: '类型', width: 80 },
|
||||
{ dataIndex: '_labelText', title: '判定条件 / 提示文案', minWidth: 320 },
|
||||
{ dataIndex: '_labelText', title: '提示文案 / 规则 SQL', minWidth: 380 },
|
||||
{
|
||||
dataIndex: 'b_canuse',
|
||||
title: '启用',
|
||||
@@ -120,7 +85,7 @@ function onRowOrderChange(rows) {
|
||||
rules.value = rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
|
||||
}
|
||||
|
||||
function onCellChange(row) {
|
||||
function onCellChange() {
|
||||
// 就地 patch 后换新数组引用触发表格重渲染
|
||||
rules.value = [...rules.value]
|
||||
}
|
||||
@@ -143,7 +108,6 @@ function batchDelete() {
|
||||
}
|
||||
|
||||
/* ================= 编辑弹窗 ================= */
|
||||
// 弹窗编辑的是行副本:取消不污染草稿;确定后按 _rowKey 替换(新增则追加)
|
||||
const ruleModalOpen = ref(false)
|
||||
const editingRow = ref(null)
|
||||
const editingIsNew = ref(false)
|
||||
@@ -176,74 +140,25 @@ function onRuleOk(row) {
|
||||
activeKey.value = String(row._rowKey)
|
||||
}
|
||||
|
||||
/* ================= 引用规则只读区 ================= */
|
||||
const moduleById = computed(
|
||||
() => new Map((props.modules || []).map((item) => [String(item.b_id), item])),
|
||||
)
|
||||
|
||||
function moduleText(code) {
|
||||
const id = String(code ?? '')
|
||||
if (!id) return ''
|
||||
const module = moduleById.value.get(id)
|
||||
return module ? `${module.b_name || id}(${id})` : id
|
||||
}
|
||||
|
||||
const reverseDisplayRows = computed(() =>
|
||||
(props.refRulesReverse || []).map((row, index) => {
|
||||
const key = parseRelationScopeId(row.b_scope_id)
|
||||
return {
|
||||
_rowKey: `ref-${index}-${row.b_scope_id}`,
|
||||
sourceModuleText: moduleText(key.sourceModuleId),
|
||||
sourceFieldText: key.sourceField,
|
||||
targetModuleText: moduleText(key.targetModuleId),
|
||||
targetFieldText: key.targetField,
|
||||
summaryText: REF_RULE_HINT,
|
||||
ownerModuleId: key.sourceModuleId,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const reverseColumns = shallowRef([
|
||||
{ dataIndex: 'sourceModuleText', title: '源模块(谁配的)', width: 200 },
|
||||
{ dataIndex: 'sourceFieldText', title: '源字段', width: 160 },
|
||||
{ dataIndex: 'targetModuleText', title: '目标模块', width: 200 },
|
||||
{ dataIndex: 'targetFieldText', title: '目标字段', width: 160 },
|
||||
{ dataIndex: 'summaryText', title: '说明', minWidth: 160 },
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '操作',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
actions: [
|
||||
{
|
||||
type: 'view',
|
||||
label: '查看',
|
||||
onClick: (row) => emit('navigate', row.ownerModuleId),
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
/* ================= 校验 ================= */
|
||||
/** 保存前校验:规则要有主键;勾条件要真有条件、写 SQL 要有提示文案(引用规则不归本面板管) */
|
||||
/** 保存前校验:规则要有主键、只读 SQL、包含 :ids 标记和提示文案 */
|
||||
function validate() {
|
||||
const errors = []
|
||||
for (const [index, row] of (rules.value || []).entries()) {
|
||||
const label = `第 ${index + 1} 条规则`
|
||||
if (!String(row.b_id ?? '').trim()) errors.push(`${label}:缺少主键(请重新新增)`)
|
||||
|
||||
const parsed = parseRulePredicate(row)
|
||||
if (parsed.kind === 'sql') {
|
||||
if (!String(row.b_message ?? '').trim()) {
|
||||
errors.push(`${label}:写 SQL 的规则必须填写提示文案`)
|
||||
}
|
||||
if (!String(row.b_predicate ?? '').trim()) {
|
||||
errors.push(`${label}:请填写规则 SQL`)
|
||||
}
|
||||
} else if (parsed.kind === 'reference') {
|
||||
errors.push(`${label}:引用规则请到「模块关联」面板维护`)
|
||||
} else if (!ruleHasCondition(row)) {
|
||||
errors.push(`${label}:至少配置一个判定条件`)
|
||||
if (!String(row.b_id ?? '').trim()) {
|
||||
errors.push(`${label}:缺少主键(请重新新增)`)
|
||||
continue
|
||||
}
|
||||
if (!ruleHasSql(row)) {
|
||||
errors.push(`${label}:规则 SQL 必须包含 :ids 标记`)
|
||||
continue
|
||||
}
|
||||
if (!ruleIsReadOnlySql(row)) {
|
||||
errors.push(`${label}:规则 SQL 必须是只读查询(以 select 或 with 开头)`)
|
||||
}
|
||||
if (!String(row.b_message ?? '').trim()) {
|
||||
errors.push(`${label}:请填写提示文案`)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
@@ -254,85 +169,49 @@ defineExpose({ validate })
|
||||
|
||||
<template>
|
||||
<div class="mm-rule">
|
||||
<div class="mm-rule__section mm-rule__section--main">
|
||||
<div class="mm-rule__toolbar">
|
||||
<Button type="primary" :disabled="loading" @click="openRuleModal(null)">
|
||||
<Plus :size="14" /> 新增规则
|
||||
</Button>
|
||||
<Button type="danger" :disabled="loading || !selectedRowKeys.length" @click="batchDelete">
|
||||
<Trash2 :size="14" /> 批量删除
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="loading" class="mm-rule__state"><Spin /></div>
|
||||
<div v-else class="mm-rule__table">
|
||||
<FmsTable
|
||||
ref="tableRef"
|
||||
v-model:columns="columns"
|
||||
:data-source="rules"
|
||||
:editable="!loading"
|
||||
:row-draggable="!loading"
|
||||
:row-selection="rowSelection"
|
||||
row-key="_rowKey"
|
||||
:active-row-key="activeKey"
|
||||
:col-resizable="true"
|
||||
@row-click="(_, row) => (activeKey = String(row._rowKey))"
|
||||
@change="onCellChange"
|
||||
@row-order-change="onRowOrderChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="mm-rule__toolbar">
|
||||
<Button type="primary" :disabled="loading" @click="openRuleModal(null)">
|
||||
<Plus :size="14" /> 新增规则
|
||||
</Button>
|
||||
<Button type="danger" :disabled="loading || !selectedRowKeys.length" @click="batchDelete">
|
||||
<Trash2 :size="14" /> 批量删除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="reverseDisplayRows.length" class="mm-rule__section mm-rule__section--reverse">
|
||||
<div class="mm-rule__caption">
|
||||
其他模块在本模块上配置的引用规则(只读):这些关系指向本模块,存在引用时源侧记录不许删除
|
||||
</div>
|
||||
<div class="mm-rule__table">
|
||||
<FmsTable
|
||||
v-model:columns="reverseColumns"
|
||||
:data-source="reverseDisplayRows"
|
||||
:editable="false"
|
||||
:seq="false"
|
||||
row-key="_rowKey"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="loading" class="mm-rule__state"><Spin /></div>
|
||||
<div v-else class="mm-rule__table">
|
||||
<FmsTable
|
||||
ref="tableRef"
|
||||
v-model:columns="columns"
|
||||
:data-source="rules"
|
||||
:editable="!loading"
|
||||
:row-draggable="!loading"
|
||||
:row-selection="rowSelection"
|
||||
row-key="_rowKey"
|
||||
:active-row-key="activeKey"
|
||||
:col-resizable="true"
|
||||
@row-click="(_, row) => (activeKey = String(row._rowKey))"
|
||||
@change="onCellChange"
|
||||
@row-order-change="onRowOrderChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModuleRuleEditModal
|
||||
v-model:open="ruleModalOpen"
|
||||
:row="editingRow"
|
||||
:is-new="editingIsNew"
|
||||
:items="items"
|
||||
@ok="onRuleOk"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 上区可编辑规则表(撑满剩余高度),下区只读引用规则(内容少时按自身高度) */
|
||||
.mm-rule {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mm-rule__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mm-rule__section--main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mm-rule__section--reverse {
|
||||
flex: none;
|
||||
max-height: 45%;
|
||||
}
|
||||
|
||||
.mm-rule__toolbar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
@@ -341,13 +220,6 @@ defineExpose({ validate })
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.mm-rule__caption {
|
||||
flex: none;
|
||||
padding: 0 0 6px;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: var(--fms-font-size);
|
||||
}
|
||||
|
||||
.mm-rule__state {
|
||||
padding: 24px 0;
|
||||
color: var(--fms-text-secondary);
|
||||
@@ -360,8 +232,4 @@ defineExpose({ validate })
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mm-rule__section--reverse .mm-rule__table {
|
||||
min-height: 120px;
|
||||
}
|
||||
</style>
|
||||
@@ -93,7 +93,6 @@ import ModuleRulePanel from './ModuleRulePanel.vue'
|
||||
import ModuleI18nPanel from './ModuleI18nPanel.vue'
|
||||
import { useI18nStore } from '@/stores/i18n'
|
||||
import { createTempId } from '@/utils/common'
|
||||
import { relationScopeId } from './ruleUtils'
|
||||
|
||||
/** 生成安全 SQL 字符串字面量(search_condition 为后端拼 SQL,需要转义引号) */
|
||||
function sqlText(value) {
|
||||
@@ -133,12 +132,8 @@ const relations = ref([]) // 本模块作为源的关系行(s_relation,仅 d
|
||||
const relations_org = ref([])
|
||||
/** 其他模块指向本模块的引用行(s_relation 目标侧):只读展示,不参与 diff 与脏判定 */
|
||||
const reverseRelations = ref([])
|
||||
const rules = ref([]) // 模块级删除规则(s_rule,scope_type='module')
|
||||
const rules = ref([]) // 删除 SQL 规则(s_delete_rule)
|
||||
const rules_org = ref([])
|
||||
/** 关系上的引用规则(s_rule,scope_type='relation'):源侧可编辑,目标侧只读展示 */
|
||||
const refRules = ref([])
|
||||
const refRules_org = ref([])
|
||||
const refRulesReverse = ref([])
|
||||
const i18n = ref([]) // 多语言透视行(s_i18n)
|
||||
const i18n_org = ref([])
|
||||
/** 多语言草稿的加载基线指纹:s_i18n 不在下面的逐行 pairs 里比较,用稳定指纹判脏 */
|
||||
@@ -170,9 +165,6 @@ function clearModuleData() {
|
||||
reverseRelations.value = []
|
||||
rules.value = []
|
||||
rules_org.value = []
|
||||
refRules.value = []
|
||||
refRules_org.value = []
|
||||
refRulesReverse.value = []
|
||||
i18n.value = []
|
||||
i18n_org.value = []
|
||||
i18nBaselineFingerprint.value = ''
|
||||
@@ -556,33 +548,18 @@ async function buildSaveData() {
|
||||
)
|
||||
if (hasChanges(relationReq)) saveReqs.push(relationReq)
|
||||
|
||||
// 模块级删除规则(s_rule,scope_type='module'):scope 两列物化后按雪花主键 diff
|
||||
// 删除 SQL 规则(s_delete_rule):按雪花主键 diff,模块列在保存前物化
|
||||
for (const row of rules.value) {
|
||||
row.b_scope_type = 'module'
|
||||
if (String(row.b_scope_id ?? '').trim() !== moduleId) row.b_scope_id = moduleId
|
||||
if (String(row.b_module_id ?? '').trim() !== moduleId) row.b_module_id = moduleId
|
||||
}
|
||||
const ruleReq = tableChange(
|
||||
's_rule',
|
||||
's_delete_rule',
|
||||
diffRows(stripInternalKeys(rules.value), stripInternalKeys(rules_org.value), {
|
||||
keyFields: ['b_id'],
|
||||
}),
|
||||
['b_id'],
|
||||
)
|
||||
if (hasChanges(ruleReq)) saveReqs.push(ruleReq)
|
||||
|
||||
// 关系上的引用规则(s_rule,scope_type='relation'):行由关联面板勾选增删,这里只物化 scope
|
||||
for (const row of refRules.value) {
|
||||
row.b_scope_type = 'relation'
|
||||
if (!String(row.b_hook ?? '').trim()) row.b_hook = 'delete.pre'
|
||||
}
|
||||
const refRuleReq = tableChange(
|
||||
's_rule',
|
||||
diffRows(stripInternalKeys(refRules.value), stripInternalKeys(refRules_org.value), {
|
||||
keyFields: ['b_id'],
|
||||
}),
|
||||
['b_id'],
|
||||
)
|
||||
if (hasChanges(refRuleReq)) saveReqs.push(refRuleReq)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,30 +849,13 @@ async function deleteModule(id) {
|
||||
key_field: 'b_object_type,b_object_id',
|
||||
deletes: [{ b_object_type: 'module', b_object_id: id2 }],
|
||||
},
|
||||
// 删除规则(s_rule):模块级规则按 scope 两列清;关系级规则挂在关系业务键上,
|
||||
// 规则表没有模块列,需先查一次双向关系拿到键再批量删
|
||||
// 删除 SQL 规则(s_delete_rule)挂在模块上,随模块删除
|
||||
{
|
||||
table: 's_rule',
|
||||
key_field: 'b_scope_type,b_scope_id',
|
||||
deletes: [{ b_scope_type: 'module', b_scope_id: id2 }],
|
||||
table: 's_delete_rule',
|
||||
key_field: 'b_module_id',
|
||||
deletes: [{ b_module_id: id2 }],
|
||||
},
|
||||
]
|
||||
const relationRes = await loadDataApi(
|
||||
's_relation',
|
||||
`b_source_module_id = ${sqlText(id2)} OR b_target_module_id = ${sqlText(id2)}`,
|
||||
'b_source_module_id ASC, b_xh ASC, b_source_field ASC',
|
||||
)
|
||||
const relationDeletes = (relationRes.data || []).map((row) => ({
|
||||
b_scope_type: 'relation',
|
||||
b_scope_id: relationScopeId(row),
|
||||
}))
|
||||
if (relationDeletes.length) {
|
||||
reqs.push({
|
||||
table: 's_rule',
|
||||
key_field: 'b_scope_type,b_scope_id',
|
||||
deletes: relationDeletes,
|
||||
})
|
||||
}
|
||||
// s_i18n 无 b_module_id:按模块资源键(module.*/field.*/分组键)逐格删除;
|
||||
// 权限点的 action.* 动词键不在模块资源键范围内,天然不被误删
|
||||
const keys = await collectModuleI18nKeys(id2)
|
||||
@@ -1020,11 +980,11 @@ async function loadModuleConfig(moduleId) {
|
||||
'b_source_module_id ASC, b_xh ASC, b_source_field ASC',
|
||||
)
|
||||
: Promise.resolve({ data: [] }),
|
||||
// 模块级删除规则(s_rule,scope_type='module'):无模块列,按 scope 两列定位
|
||||
// 删除 SQL 规则(s_delete_rule):按模块列定位
|
||||
type === 'data'
|
||||
? loadDataApi(
|
||||
's_rule',
|
||||
`b_scope_type = 'module' AND b_scope_id = ${sqlText(id)}`,
|
||||
's_delete_rule',
|
||||
`b_module_id = ${sqlText(id)}`,
|
||||
'b_xh ASC, b_id ASC',
|
||||
)
|
||||
: Promise.resolve({ data: [] }),
|
||||
@@ -1041,36 +1001,13 @@ async function loadModuleConfig(moduleId) {
|
||||
const isSourceRow = (row) => String(row.b_source_module_id ?? '').trim() === id
|
||||
relations_org.value = relationRows.filter(isSourceRow)
|
||||
reverseRelations.value = relationRows.filter((row) => !isSourceRow(row))
|
||||
// 关系上的引用规则(s_rule,scope_type='relation'):规则表没有模块列,
|
||||
// 必须拿双向关系的业务键再查一次;源侧可编辑、目标侧只读展示
|
||||
if (relationRows.length) {
|
||||
const sourceKeys = relations_org.value.map(relationScopeId)
|
||||
const targetKeys = reverseRelations.value.map(relationScopeId)
|
||||
const refRuleRes = await loadDataApi(
|
||||
's_rule',
|
||||
`b_scope_type = 'relation' AND b_scope_id IN (${[...sourceKeys, ...targetKeys]
|
||||
.map(sqlText)
|
||||
.join(', ')})`,
|
||||
'b_scope_id ASC, b_id ASC',
|
||||
)
|
||||
if (String(moduleSelectedId.value) !== id) return
|
||||
const sourceKeySet = new Set(sourceKeys)
|
||||
const refRows = refRuleRes.data || []
|
||||
refRules_org.value = refRows.filter((row) => sourceKeySet.has(String(row.b_scope_id ?? '')))
|
||||
refRulesReverse.value = refRows.filter(
|
||||
(row) => !sourceKeySet.has(String(row.b_scope_id ?? '')),
|
||||
)
|
||||
} else {
|
||||
refRules_org.value = []
|
||||
refRulesReverse.value = []
|
||||
}
|
||||
// 关系上的引用规则已取消:引用保护由 s_relation.b_on_delete = restrict 表达
|
||||
maindata.value = cloneData(maindata_org.value)
|
||||
fields.value = cloneData(fields_org.value)
|
||||
autocode.value = cloneData(autocode_org.value)
|
||||
powers.value = cloneData(powers_org.value)
|
||||
relations.value = cloneData(relations_org.value)
|
||||
rules.value = cloneData(rules_org.value)
|
||||
refRules.value = cloneData(refRules_org.value)
|
||||
|
||||
for (const schemaType of SCHEMA_TYPES) {
|
||||
const row = (schemaRes.data || []).find((item) => String(item.b_schema_type) === schemaType)
|
||||
@@ -1191,7 +1128,6 @@ function hasUnsavedChanges() {
|
||||
[powers, powers_org],
|
||||
[relations, relations_org],
|
||||
[rules, rules_org],
|
||||
[refRules, refRules_org],
|
||||
]
|
||||
return pairs.some(
|
||||
([current, original]) =>
|
||||
@@ -1275,17 +1211,6 @@ function onModuleAdd({ parentId, moduleType: newType }) {
|
||||
void addModule(parentId, newType)
|
||||
}
|
||||
|
||||
/* ================= 删除规则只读区的跳转 ================= */
|
||||
/** 引用规则跳转:本模块的关系直接切到「模块关联」;其他模块则切换模块(走未保存守卫) */
|
||||
function onRuleNavigate(targetModuleId) {
|
||||
const target = String(targetModuleId ?? '')
|
||||
if (!target || target === String(moduleSelectedId.value)) {
|
||||
activeSection.value = 'relation'
|
||||
return
|
||||
}
|
||||
onModuleSelect(target)
|
||||
}
|
||||
|
||||
/* ================= 初始化 ================= */
|
||||
void langStore.loadLanguages()
|
||||
loadModuleTree()
|
||||
@@ -1443,7 +1368,6 @@ loadModuleTree()
|
||||
<ModuleRelationPanel
|
||||
ref="relationPanelRef"
|
||||
v-model="relations"
|
||||
v-model:ref-rules="refRules"
|
||||
:module-id="String(maindata.b_id || '')"
|
||||
:fields="fields"
|
||||
:modules="relationModules"
|
||||
@@ -1457,12 +1381,7 @@ loadModuleTree()
|
||||
ref="rulePanelRef"
|
||||
v-model="rules"
|
||||
:module-id="String(maindata.b_id || '')"
|
||||
:fields="fields"
|
||||
:query-schema="query_schema"
|
||||
:ref-rules-reverse="refRulesReverse"
|
||||
:modules="relationModules"
|
||||
:loading="busy"
|
||||
@navigate="onRuleNavigate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,195 +1,55 @@
|
||||
/**
|
||||
* 删除规则(s_rule)配置工具。
|
||||
* 删除 SQL 规则(s_delete_rule)配置工具。
|
||||
*
|
||||
* 规则类型不单独存列(设计定案):b_predicate 为空 = 引用规则;可解析为 JSON 且 kind='dsl'
|
||||
* = 勾条件;其余 = SQL 文本。scope_type='module' 挂模块(勾条件 / 写 SQL),
|
||||
* scope_type='relation' 挂关系(存在引用即拒绝删除,由模块关联面板勾选生成,一个关系最多一条)。
|
||||
* 规则没有业务键:主键是雪花 b_id(新增时前端取号),界面上不出现任何"编码/名称"字段,
|
||||
* 列表里靠自动摘要(勾条件)或提示文案(写 SQL)识别。
|
||||
* 设计定案(FMS删除策略重构设计.md v1.2):
|
||||
* - 一条规则只表达"哪些记录不允许删除":b_sql 是只读查询,返回至少一行即拒绝本批删除;
|
||||
* - 用 :ids 标记本次待删 ID 集合,执行前由后端替换为经过校验的字面量 IN 列表;
|
||||
* - 关系上的关联处置(含引用保护)由 s_relation.b_on_delete 表达,不再产生规则行。
|
||||
*
|
||||
* 条件面板(FmsAdvancedQueryPanel)需要的 items 在这里按当前模块字段 + 查询配置组装。
|
||||
* 规则没有业务键:主键是雪花 b_id(新增时前端取号),界面上不出现编码/名称字段。
|
||||
*/
|
||||
import {
|
||||
advancedQueryFromAst,
|
||||
buildAdvancedAst,
|
||||
defaultOperator,
|
||||
describeAdvancedQuery,
|
||||
} from '@/components/fms-module-common/queryUtils'
|
||||
import { buildQueryRender } from '@/components/fms-module-common/schemaRender'
|
||||
import { parseOptions } from '@/components/fms-module-common/fieldUtils'
|
||||
import { createTempId } from '@/utils/common'
|
||||
|
||||
export const RULE_SCOPE_MODULE = 'module'
|
||||
export const RULE_SCOPE_RELATION = 'relation'
|
||||
export const RULE_HOOK_DELETE = 'delete.pre'
|
||||
/** 引用规则(predicate 为空)在列表与只读区里的固定说明 */
|
||||
export const REF_RULE_HINT = '存在其他模块引用时拒绝删除'
|
||||
/** :ids 占位标记(与后端 delete 引擎约定一致) */
|
||||
export const IDS_PLACEHOLDER = ':ids'
|
||||
|
||||
/** 关系行的业务键(与 s_relation 四列主键同序):源模块|源字段|目标模块|目标字段 */
|
||||
export function relationScopeId(row) {
|
||||
return [
|
||||
row?.b_source_module_id,
|
||||
row?.b_source_field,
|
||||
row?.b_target_module_id,
|
||||
row?.b_target_field,
|
||||
]
|
||||
.map((value) => String(value ?? '').trim())
|
||||
.join('|')
|
||||
}
|
||||
|
||||
/** 关系行是否已填全四列(拼出的键才可用) */
|
||||
export function isCompleteRelationKey(row) {
|
||||
return [
|
||||
row?.b_source_module_id,
|
||||
row?.b_source_field,
|
||||
row?.b_target_module_id,
|
||||
row?.b_target_field,
|
||||
].every((value) => String(value ?? '').trim())
|
||||
}
|
||||
|
||||
/** 关系业务键 → 四列(与 relationScopeId 互逆),用于把引用规则还原成可读列 */
|
||||
export function parseRelationScopeId(scopeId) {
|
||||
const [sourceModuleId = '', sourceField = '', targetModuleId = '', targetField = ''] = String(
|
||||
scopeId ?? '',
|
||||
).split('|')
|
||||
return { sourceModuleId, sourceField, targetModuleId, targetField }
|
||||
}
|
||||
|
||||
/**
|
||||
* 规则类型三态:reference(引用规则)/ dsl(勾条件)/ sql(写 SQL)。
|
||||
* 非法 JSON(以 { 开头但解析失败)按 SQL 文本处理,避免把内容吞掉。
|
||||
*/
|
||||
export function parseRulePredicate(row) {
|
||||
const text = String(row?.b_predicate ?? '').trim()
|
||||
if (!text) return { kind: 'reference' }
|
||||
if (text.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
if (parsed?.kind === 'dsl' && parsed.expr) return { kind: 'dsl', expr: parsed.expr }
|
||||
} catch {
|
||||
// 解析失败继续按 SQL 处理
|
||||
}
|
||||
}
|
||||
return { kind: 'sql', sql: text }
|
||||
}
|
||||
|
||||
/** 类型列文案 */
|
||||
export function ruleTypeText(row) {
|
||||
const { kind } = parseRulePredicate(row)
|
||||
if (kind === 'dsl') return '条件'
|
||||
if (kind === 'sql') return 'SQL'
|
||||
return '引用'
|
||||
}
|
||||
|
||||
/** 条件摘要:勾条件走中文描述,SQL 取单行化后的前 80 字,引用规则固定文案 */
|
||||
export function ruleSummary(row, itemByField, translate) {
|
||||
const parsed = parseRulePredicate(row)
|
||||
if (parsed.kind === 'dsl') {
|
||||
const state = advancedQueryFromAst(parsed.expr)
|
||||
return describeAdvancedQuery(state, itemByField, translate)
|
||||
}
|
||||
if (parsed.kind === 'sql') return parsed.sql.replace(/\s+/g, ' ').trim().slice(0, 80)
|
||||
return REF_RULE_HINT
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表里识别规则用的文案(没有规则名,靠内容自述):
|
||||
* 勾条件 → 条件中文摘要;写 SQL → 提示文案(写 SQL 必填,是人话),没填时退回 SQL 片段;引用 → 固定说明。
|
||||
*/
|
||||
export function ruleLabel(row, itemByField, translate) {
|
||||
const parsed = parseRulePredicate(row)
|
||||
if (parsed.kind === 'sql') {
|
||||
const message = String(row?.b_message ?? '').trim()
|
||||
return message || ruleSummary(row, itemByField, translate)
|
||||
}
|
||||
return ruleSummary(row, itemByField, translate)
|
||||
}
|
||||
|
||||
/**
|
||||
* 规则是否真有可执行内容:勾条件要有至少一个填好值的条件;写 SQL 要有文本;引用规则恒真。
|
||||
* 用于保存前拦住"改空了条件"的历史数据(弹窗入口也会拦一次)。
|
||||
*/
|
||||
export function ruleHasCondition(row) {
|
||||
const parsed = parseRulePredicate(row)
|
||||
if (parsed.kind === 'reference') return true
|
||||
if (parsed.kind === 'sql') return true
|
||||
const state = advancedQueryFromAst(parsed.expr)
|
||||
return (buildAdvancedAst(state).children || []).length > 0
|
||||
}
|
||||
|
||||
/** 新增模块级规则行(勾条件类型起步,条件在编辑弹窗里填);id 由调用方取号传入 */
|
||||
/** 新增规则行;id 由调用方取号传入 */
|
||||
export function createRuleRow(moduleId, { id, xh = 10 } = {}) {
|
||||
return {
|
||||
_rowKey: createTempId(),
|
||||
b_id: String(id ?? ''),
|
||||
b_scope_type: RULE_SCOPE_MODULE,
|
||||
b_scope_id: String(moduleId || ''),
|
||||
b_hook: RULE_HOOK_DELETE,
|
||||
b_predicate: null,
|
||||
b_module_id: String(moduleId || ''),
|
||||
b_sql: `select b_id from ${String(moduleId || '')} where b_id in (${IDS_PLACEHOLDER})`,
|
||||
b_message: null,
|
||||
b_canuse: 1,
|
||||
b_xh: xh,
|
||||
}
|
||||
}
|
||||
|
||||
/** 引用规则行:由模块关联面板勾选「有引用时拒绝删除」时生成 */
|
||||
export function createRefRuleRow(scopeId, id, xh = 10) {
|
||||
return {
|
||||
_rowKey: createTempId(),
|
||||
b_id: String(id ?? ''),
|
||||
b_scope_type: RULE_SCOPE_RELATION,
|
||||
b_scope_id: String(scopeId || ''),
|
||||
b_hook: RULE_HOOK_DELETE,
|
||||
b_predicate: null,
|
||||
b_message: null,
|
||||
b_canuse: 1,
|
||||
b_xh: xh,
|
||||
}
|
||||
/** SQL 单行化后的摘要(列表里识别规则用,规则没有名称) */
|
||||
export function ruleSummary(row) {
|
||||
const sql = String(row?.b_sql ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!sql) return '未填写 SQL'
|
||||
return sql.length > 80 ? `${sql.slice(0, 80)}…` : sql
|
||||
}
|
||||
|
||||
/** 字段类型 → 条件值控件(与查询配置的默认控件对齐) */
|
||||
const COMPONENT_BY_TYPE = {
|
||||
select: 'select',
|
||||
date: 'date-picker',
|
||||
datetime: 'date-picker',
|
||||
number: 'number',
|
||||
money: 'number',
|
||||
checkbox: 'checkbox',
|
||||
/** 列表识别文案:优先提示文案(人话),没填时退回 SQL 片段 */
|
||||
export function ruleLabel(row) {
|
||||
const message = String(row?.b_message ?? '').trim()
|
||||
return message || ruleSummary(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装条件面板(FmsAdvancedQueryPanel)需要的 items:
|
||||
* 当前模块启用字段,叠加查询配置里的操作符 / 控件 / 选项。
|
||||
* select 字段若在查询配置里没有选项定义,降级为输入框(避免空下拉无路可走)。
|
||||
*/
|
||||
export function buildRuleQueryItems(fields, querySchema) {
|
||||
const fromQuery = new Map()
|
||||
for (const condition of buildQueryRender(querySchema).conditions) {
|
||||
fromQuery.set(String(condition.field), condition)
|
||||
}
|
||||
const items = []
|
||||
for (const field of fields || []) {
|
||||
if (Number(field.b_canuse) !== 1) continue
|
||||
const condition = fromQuery.get(String(field.b_field))
|
||||
const optionInfo = parseOptions({ options: condition?.options })
|
||||
let component = condition?.component || COMPONENT_BY_TYPE[field.b_type] || 'input'
|
||||
if (component === 'select' && !(optionInfo.options || []).length) component = 'input'
|
||||
items.push({
|
||||
config: {
|
||||
b_field: String(field.b_field),
|
||||
b_operator: condition?.operator || defaultOperator(field),
|
||||
b_component: component,
|
||||
b_default_value: null,
|
||||
},
|
||||
field,
|
||||
optionConfig: optionInfo.optionConfig,
|
||||
options: optionInfo.options,
|
||||
})
|
||||
}
|
||||
return items
|
||||
/** 规则是否真有可执行内容:SQL 非空且包含 :ids 标记 */
|
||||
export function ruleHasSql(row) {
|
||||
const sql = String(row?.b_sql ?? '').trim()
|
||||
return sql.length > 0 && sql.includes(IDS_PLACEHOLDER)
|
||||
}
|
||||
|
||||
/** items → 字段编码 Map(describeAdvancedQuery 与摘要展示用) */
|
||||
export function itemMapOf(items) {
|
||||
return new Map((items || []).map((item) => [String(item.field.b_field), item]))
|
||||
/** 规则是否只读查询(前端预检;后端也会校验,这里只为提前提示) */
|
||||
export function ruleIsReadOnlySql(row) {
|
||||
const sql = String(row?.b_sql ?? '').trim()
|
||||
if (!sql) return false
|
||||
return /^(select|with)\b/i.test(sql)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(),
|
||||
saveObjectApi: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({ userInfo: { account: 'u001' } }),
|
||||
}))
|
||||
|
||||
const {
|
||||
PREF_SCHEMA_TYPES,
|
||||
columnSettingsDelta,
|
||||
isEmptyColumnDelta,
|
||||
mergeColumnPref,
|
||||
sameColumnSettings,
|
||||
} = await import('@/components/fms-module-list/useModulePref')
|
||||
|
||||
/** 通用栏位行:字段 + 宽度,数组顺序即通用顺序 */
|
||||
const base = [
|
||||
{ b_field: 'b_id', b_width: 120, b_visible: 1 },
|
||||
{ b_field: 'b_name', b_width: 200, b_visible: 1 },
|
||||
{ b_field: 'b_amount', b_width: 160, b_visible: 1 },
|
||||
]
|
||||
|
||||
/** 表格当前栏位设置(getColumnSettings 的形态) */
|
||||
const current = (...pairs) => pairs.map(([b_field, b_width]) => ({ b_field, b_width }))
|
||||
|
||||
describe('栏位设置:个人层合并', () => {
|
||||
it('无个人偏好时原样返回通用栏位', () => {
|
||||
expect(mergeColumnPref(base, null)).toBe(base)
|
||||
})
|
||||
|
||||
it('只覆盖宽度,保留通用行上的其它属性(b_visible 等)', () => {
|
||||
const merged = mergeColumnPref(base, { columns: { b_name: { width: 300 } } })
|
||||
const name = merged.find((row) => row.b_field === 'b_name')
|
||||
expect(name.b_width).toBe(300)
|
||||
expect(name.b_visible).toBe(1)
|
||||
// 未覆盖的字段保持通用宽度
|
||||
expect(merged.find((row) => row.b_field === 'b_id').b_width).toBe(120)
|
||||
})
|
||||
|
||||
it('按 order 重排并重编 b_xh(10/20/30)', () => {
|
||||
const merged = mergeColumnPref(base, { order: ['b_amount', 'b_id', 'b_name'] })
|
||||
expect(merged.map((row) => row.b_field)).toEqual(['b_amount', 'b_id', 'b_name'])
|
||||
expect(merged.map((row) => row.b_xh)).toEqual([10, 20, 30])
|
||||
})
|
||||
|
||||
it('order 未列出的字段保持原有相对顺序、追加在后', () => {
|
||||
const merged = mergeColumnPref(base, { order: ['b_amount'] })
|
||||
expect(merged.map((row) => row.b_field)).toEqual(['b_amount', 'b_id', 'b_name'])
|
||||
})
|
||||
|
||||
it('个人层不得新增字段:order 里的未知字段一律忽略', () => {
|
||||
const merged = mergeColumnPref(base, { order: ['b_hack', 'b_id'] })
|
||||
expect(merged.map((row) => row.b_field)).toEqual(['b_id', 'b_name', 'b_amount'])
|
||||
})
|
||||
|
||||
it('个人层不得给未知字段设宽度', () => {
|
||||
const merged = mergeColumnPref(base, { columns: { b_hack: { width: 999 } } })
|
||||
expect(merged.some((row) => row.b_field === 'b_hack')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('栏位设置:增量计算', () => {
|
||||
it('完全一致时增量为空且不含 order(不把通用顺序固化)', () => {
|
||||
const delta = columnSettingsDelta(
|
||||
current(['b_id', 120], ['b_name', 200], ['b_amount', 160]),
|
||||
base,
|
||||
)
|
||||
expect(delta.columns).toEqual({})
|
||||
expect(delta.order).toBeUndefined()
|
||||
expect(isEmptyColumnDelta(delta)).toBe(true)
|
||||
})
|
||||
|
||||
it('只记改过的宽度,同宽字段不入增量(通用改宽仍能透传)', () => {
|
||||
const delta = columnSettingsDelta(
|
||||
current(['b_id', 120], ['b_name', 320], ['b_amount', 160]),
|
||||
base,
|
||||
)
|
||||
expect(delta.columns).toEqual({ b_name: { width: 320 } })
|
||||
expect(delta.order).toBeUndefined()
|
||||
})
|
||||
|
||||
it('顺序变化时整体记 order', () => {
|
||||
const delta = columnSettingsDelta(
|
||||
current(['b_amount', 160], ['b_id', 120], ['b_name', 200]),
|
||||
base,
|
||||
)
|
||||
expect(delta.order).toEqual(['b_amount', 'b_id', 'b_name'])
|
||||
expect(isEmptyColumnDelta(delta)).toBe(false)
|
||||
})
|
||||
|
||||
it('宽度与顺序都变了,两部分都记', () => {
|
||||
const delta = columnSettingsDelta(
|
||||
current(['b_name', 320], ['b_id', 120], ['b_amount', 160]),
|
||||
base,
|
||||
)
|
||||
expect(delta.columns).toEqual({ b_name: { width: 320 } })
|
||||
expect(delta.order).toEqual(['b_name', 'b_id', 'b_amount'])
|
||||
})
|
||||
|
||||
it('未知字段不产生宽度增量', () => {
|
||||
const delta = columnSettingsDelta(
|
||||
current(['b_id', 120], ['b_name', 200], ['b_hack', 300]),
|
||||
base,
|
||||
)
|
||||
expect(delta.columns).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('栏位设置:与基线比较', () => {
|
||||
it('宽度相同且顺序相同 → 未调整', () => {
|
||||
expect(
|
||||
sameColumnSettings(current(['b_id', 120], ['b_name', 200], ['b_amount', 160]), base),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('宽度不同 → 已调整', () => {
|
||||
expect(
|
||||
sameColumnSettings(current(['b_id', 120], ['b_name', 260], ['b_amount', 160]), base),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('顺序不同 → 已调整(即使宽度全同)', () => {
|
||||
expect(
|
||||
sameColumnSettings(current(['b_name', 200], ['b_id', 120], ['b_amount', 160]), base),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('数量不同 → 已调整', () => {
|
||||
expect(sameColumnSettings(current(['b_id', 120]), base)).toBe(false)
|
||||
})
|
||||
|
||||
it('当前宽度为 null 而通用有宽度 → 视为已调整', () => {
|
||||
expect(
|
||||
sameColumnSettings(current(['b_id', null], ['b_name', 200], ['b_amount', 160]), base),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('两侧宽度都是 null/未设置时视为一致', () => {
|
||||
const noWidth = [{ b_field: 'b_id', b_width: null }, { b_field: 'b_name' }]
|
||||
expect(sameColumnSettings(current(['b_id', null], ['b_name', undefined]), noWidth)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('栏位设置:schema 类型常量', () => {
|
||||
it('个人层栏位用 view,与查询布局的 query 区分开', () => {
|
||||
expect(PREF_SCHEMA_TYPES.view).toBe('view')
|
||||
expect(PREF_SCHEMA_TYPES.query).toBe('query')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* 栏位设置(列宽/顺序)冒烟测试:模拟真实操作链路。
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. 未改动 → 操作条不出现(不占内容区);
|
||||
* 2. 拖列宽 → 操作条出现,撤销后消失且不落库;
|
||||
* 3. 保存为个人 → 只写 s_user_module_pref(b_schema_type='view'),且只存相对通用的增量;
|
||||
* 4. 保存为通用 → 写 s_module_schema(且只对管理员可见按钮);
|
||||
* 5. 个人层加载 → 合并进列配置,且不得引入通用没有的字段。
|
||||
*/
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { registerFeature, useAreaSelection } from 'stk-table-vue'
|
||||
|
||||
registerFeature(useAreaSelection)
|
||||
|
||||
// vi.mock 工厂被提升到 import 之上,工厂内引用的变量必须同样提升
|
||||
const EMPTY = vi.hoisted(() => Object.freeze({}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ path: '/module/i18n-type', meta: EMPTY }),
|
||||
}))
|
||||
|
||||
vi.mock('@/services/exportService', () => ({
|
||||
exportListApi: vi.fn(async () => 'data.xlsx'),
|
||||
loadTotalsApi: vi.fn(async () => ({ data: EMPTY })),
|
||||
countExportApi: vi.fn(async () => ({ data: EMPTY })),
|
||||
}))
|
||||
|
||||
/** 通用栏位:b_id 120 / b_name 180。测试按需注入个人层偏好。 */
|
||||
const GLOBAL_VIEW = {
|
||||
schemaVersion: 1,
|
||||
columns: [
|
||||
{ field: 'b_id', width: 120 },
|
||||
{ field: 'b_name', width: 180 },
|
||||
],
|
||||
}
|
||||
|
||||
let personalColumnPref = null
|
||||
let currentAccount = 'u001'
|
||||
|
||||
const loadDataApi = vi.fn(async (viewName) => {
|
||||
if (viewName === 's_module') {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
b_id: 's_i18n_type',
|
||||
b_module_type: 'data',
|
||||
b_canuse: 1,
|
||||
b_view_table: 's_i18n_type',
|
||||
b_save_table: 's_i18n_type',
|
||||
b_key_field: 'b_id',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
if (viewName === 's_field') {
|
||||
return {
|
||||
data: [
|
||||
{ b_field: 'b_id', b_name: '语言编码', b_type: 'input', b_canuse: 1, b_i18n: null },
|
||||
{ b_field: 'b_name', b_name: '语言名称', b_type: 'input', b_canuse: 1, b_i18n: null },
|
||||
],
|
||||
}
|
||||
}
|
||||
if (viewName === 's_module_schema') {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
b_module_id: 's_i18n_type',
|
||||
b_schema_type: 'view',
|
||||
b_schema_json: JSON.stringify(GLOBAL_VIEW),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
if (viewName === 's_user_module_pref') {
|
||||
if (!personalColumnPref) return { data: [] }
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
b_user_id: currentAccount,
|
||||
b_module_id: 's_i18n_type',
|
||||
b_schema_type: 'view',
|
||||
b_schema_json: JSON.stringify(personalColumnPref),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return { data: [] }
|
||||
})
|
||||
|
||||
const saveObjectApi = vi.fn(async () => ({}))
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: (...args) => loadDataApi(...args),
|
||||
saveObjectApi: (...args) => saveObjectApi(...args),
|
||||
pageDataApi: vi.fn(async () => ({ data: { rows: [{ b_id: 'zh-CN' }], total: 1 } })),
|
||||
nextIdApi: vi.fn(async () => ({ data: '1' })),
|
||||
loadDataBySqlApi: vi.fn(async () => ({ data: [] })),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', async () => {
|
||||
const actual = await vi.importActual('@/stores/auth')
|
||||
return {
|
||||
...actual,
|
||||
useAuthStore: () => ({
|
||||
userInfo: { account: currentAccount },
|
||||
loginInfo: { user: { account: currentAccount, b_id: currentAccount } },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// 权限 store 决定「保存为通用」按钮是否渲染:默认按非管理员(普通用户)跑
|
||||
let isG3soft = false
|
||||
vi.mock('@/stores/permissions', () => ({
|
||||
usePermissionStore: () => ({
|
||||
get isG3soft() {
|
||||
return isG3soft
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
import FmsModuleListPage from '@/components/fms-module-list/FmsModuleListPage.vue'
|
||||
import FmsModuleTable from '@/components/fms-module-list/FmsModuleTable.vue'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
loadDataApi.mockClear()
|
||||
saveObjectApi.mockClear()
|
||||
personalColumnPref = null
|
||||
currentAccount = 'u001'
|
||||
isG3soft = false
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
get: () => 5000,
|
||||
})
|
||||
})
|
||||
|
||||
async function mountPage() {
|
||||
const wrapper = mount(FmsModuleListPage, {
|
||||
props: { dataCode: 's_i18n_type' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟用户拖拽列宽:FmsTable 收到后会 emit('update:columns', 渲染列数组),
|
||||
* 其中每项保留 dataIndex/key,只有 width 变化——按这个真实形态构造,别只传 b_field/b_width,
|
||||
* 否则 FmsTable.onColumnsUpdate 按 key/dataIndex 匹配不到原始列,回传结果会变成空数组。
|
||||
*/
|
||||
async function resizeColumn(wrapper, dataIndex, width) {
|
||||
const table = wrapper.findComponent(FmsModuleTable)
|
||||
const fmsTable = table.findComponent(FmsTable)
|
||||
const rendered = fmsTable
|
||||
.props('columns')
|
||||
.map((column) => (column.dataIndex === dataIndex ? { ...column, width } : { ...column }))
|
||||
fmsTable.vm.$emit('update:columns', rendered)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
/** 操作条内的按钮 */
|
||||
const barButton = (wrapper, text) =>
|
||||
wrapper.findAll('.list-column-bar button').find((button) => button.text().trim() === text)
|
||||
|
||||
describe('栏位设置冒烟', () => {
|
||||
it('未改动时不出现操作条,也不渲染栏位相关按钮', async () => {
|
||||
const wrapper = await mountPage()
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('下拉菜单不再包含栏位相关项(已迁到操作条)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const menuText = wrapper.find('.list-toolbar__group--tools').text()
|
||||
expect(menuText).not.toContain('恢复栏位设置')
|
||||
expect(menuText).not.toContain('保存栏位设置')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('拖列宽后操作条出现;撤销后消失且不落库', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_name', 320)
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(true)
|
||||
expect(wrapper.find('.list-column-bar').text()).toContain('列宽或顺序已调整')
|
||||
|
||||
await barButton(wrapper, '撤销').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('普通用户看不到「保存为通用」,只看到个人与撤销', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_name', 320)
|
||||
const bar = wrapper.find('.list-column-bar')
|
||||
expect(bar.text()).toContain('撤销')
|
||||
expect(bar.text()).toContain('保存为个人')
|
||||
expect(bar.text()).not.toContain('保存为通用')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('管理员能看到「保存为通用」', async () => {
|
||||
isG3soft = true
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_name', 320)
|
||||
expect(wrapper.find('.list-column-bar').text()).toContain('保存为通用')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('保存为个人:只写 s_user_module_pref,且只存改过的字段宽度', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_name', 320)
|
||||
await barButton(wrapper, '保存为个人').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(saveObjectApi).toHaveBeenCalledTimes(1)
|
||||
const requests = saveObjectApi.mock.calls[0][0]
|
||||
expect(requests).toHaveLength(1)
|
||||
const req = requests[0]
|
||||
expect(req.table).toBe('s_user_module_pref')
|
||||
expect(req.key_field).toBe('b_user_id,b_module_id,b_schema_type')
|
||||
|
||||
const row = req.inserts[0]
|
||||
expect(row.b_user_id).toBe('u001')
|
||||
expect(row.b_module_id).toBe('s_i18n_type')
|
||||
expect(row.b_schema_type).toBe('view')
|
||||
const delta = JSON.parse(row.b_schema_json)
|
||||
// 只有 b_name 改了宽度 → 增量里只应出现它,通用改 b_id 仍能透传
|
||||
expect(delta.columns).toEqual({ b_name: { width: 320 } })
|
||||
// 顺序未变 → 不应写 order(否则会把通用顺序固化)
|
||||
expect(delta.order).toBeUndefined()
|
||||
|
||||
// 保存成功后操作条消失(已成本新基线)
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('保存为个人不碰 s_module_schema(不污染通用)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_name', 320)
|
||||
await barButton(wrapper, '保存为个人').trigger('click')
|
||||
await flushPromises()
|
||||
const tables = saveObjectApi.mock.calls.flatMap((call) => call[0].map((r) => r.table))
|
||||
expect(tables).not.toContain('s_module_schema')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('保存为通用:写 s_module_schema 并回写宽度', async () => {
|
||||
isG3soft = true
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_name', 320)
|
||||
await barButton(wrapper, '保存为通用').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const requests = saveObjectApi.mock.calls.at(-1)[0]
|
||||
const req = requests.find((r) => r.table === 's_module_schema')
|
||||
expect(req).toBeTruthy()
|
||||
expect(req.key_field).toBe('b_module_id,b_schema_type')
|
||||
const patch = req.updates ? req.updates[0] : req.inserts[0]
|
||||
expect(patch.b_schema_type).toBe('view')
|
||||
const schema = JSON.parse(patch.b_schema_json)
|
||||
const nameNode = schema.columns.find((node) => node.field === 'b_name')
|
||||
expect(nameNode.width).toBe(320)
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('个人层加载:合并进列配置(宽度生效)', async () => {
|
||||
personalColumnPref = { schemaVersion: 1, columns: { b_name: { width: 260 } } }
|
||||
const wrapper = await mountPage()
|
||||
const table = wrapper.findComponent(FmsModuleTable)
|
||||
const nameConfig = table.props('listConfig').find((item) => item.b_field === 'b_name')
|
||||
expect(nameConfig.b_width).toBe(260)
|
||||
// 已按个人层加载 → 与基线一致 → 不应立即显示「已调整」
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('个人层不得引入通用没有的字段(越权字段被忽略)', async () => {
|
||||
personalColumnPref = {
|
||||
schemaVersion: 1,
|
||||
columns: { b_hack: { width: 999 } },
|
||||
order: ['b_hack', 'b_id'],
|
||||
}
|
||||
const wrapper = await mountPage()
|
||||
const table = wrapper.findComponent(FmsModuleTable)
|
||||
const fields = table.props('listConfig').map((item) => item.b_field)
|
||||
expect(fields).not.toContain('b_hack')
|
||||
expect(fields).toEqual(['b_id', 'b_name'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('个人层顺序生效,且不误报为「已调整」', async () => {
|
||||
personalColumnPref = { schemaVersion: 1, order: ['b_name', 'b_id'] }
|
||||
const wrapper = await mountPage()
|
||||
const table = wrapper.findComponent(FmsModuleTable)
|
||||
expect(table.props('listConfig').map((item) => item.b_field)).toEqual(['b_name', 'b_id'])
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('保留个人设置再次保存时走 updates 而非 inserts(避免主键冲突)', async () => {
|
||||
personalColumnPref = { schemaVersion: 1, columns: { b_name: { width: 260 } } }
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_name', 300)
|
||||
await barButton(wrapper, '保存为个人').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const req = saveObjectApi.mock.calls.at(-1)[0].find((r) => r.table === 's_user_module_pref')
|
||||
expect(req.updates).toBeTruthy()
|
||||
expect(req.inserts).toBeUndefined()
|
||||
// 增量是相对通用(180),不是相对个人上一次(260)
|
||||
expect(JSON.parse(req.updates[0].b_schema_json).columns).toEqual({ b_name: { width: 300 } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('连续改动只累积一次落库,保存后操作条消失', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_id', 140)
|
||||
await resizeColumn(wrapper, 'b_id', 150)
|
||||
await resizeColumn(wrapper, 'b_name', 260)
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(true)
|
||||
|
||||
await barButton(wrapper, '保存为个人').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(saveObjectApi).toHaveBeenCalledTimes(1)
|
||||
const delta = JSON.parse(saveObjectApi.mock.calls[0][0][0].inserts[0].b_schema_json)
|
||||
// 只记最终值(150 / 260),中间态不入增量
|
||||
expect(delta.columns).toEqual({ b_id: { width: 150 }, b_name: { width: 260 } })
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('保存后再次改动会重新出现操作条(基线已前移)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_name', 320)
|
||||
await barButton(wrapper, '保存为个人').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
|
||||
// 保存后把宽度改回通用值 → 与「新基线 320」不同,仍应提示
|
||||
await resizeColumn(wrapper, 'b_name', 180)
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('改回通用值后保存:清空个人层而非留下空壳覆盖', async () => {
|
||||
personalColumnPref = { schemaVersion: 1, columns: { b_name: { width: 260 } } }
|
||||
const wrapper = await mountPage()
|
||||
// 改回与通用一致(b_name 通用宽度 180)
|
||||
await resizeColumn(wrapper, 'b_name', 180)
|
||||
await barButton(wrapper, '保存为个人').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const req = saveObjectApi.mock.calls.at(-1)[0].find((r) => r.table === 's_user_module_pref')
|
||||
// 无增量可存 → 走删除,不写空 columns
|
||||
expect(req.deletes).toBeTruthy()
|
||||
expect(req.inserts).toBeUndefined()
|
||||
expect(req.updates).toBeUndefined()
|
||||
expect(req.deletes[0]).toMatchObject({
|
||||
b_user_id: 'u001',
|
||||
b_module_id: 's_i18n_type',
|
||||
b_schema_type: 'view',
|
||||
})
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('已有个人设置但本次改动恰好等于通用时,也走删除清理', async () => {
|
||||
// 个人层只改了顺序,本次把顺序也拖回通用顺序
|
||||
personalColumnPref = { schemaVersion: 1, order: ['b_name', 'b_id'] }
|
||||
const wrapper = await mountPage()
|
||||
const table = wrapper.findComponent(FmsModuleTable)
|
||||
const fmsTable = table.findComponent(FmsTable)
|
||||
// 拖回通用顺序 b_id, b_name
|
||||
const reordered = fmsTable.props('columns').toReversed()
|
||||
fmsTable.vm.$emit('update:columns', reordered)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(true)
|
||||
await barButton(wrapper, '保存为个人').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const req = saveObjectApi.mock.calls.at(-1)[0].find((r) => r.table === 's_user_module_pref')
|
||||
expect(req.deletes).toBeTruthy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -2,12 +2,13 @@
|
||||
* 模块关联面板(s_relation):
|
||||
* - 加载回填行只补本地行键(_rowKey),不得改动业务列——否则打开模块即提示「未保存」;
|
||||
* - 新增关联:追加「源模块 = 当前模块」的空行,主键四列留待用户填写;
|
||||
* 新建关系默认关联删除处理为 restrict(设计 §3.1);
|
||||
* - 换目标模块:原目标字段必然失效,需清空并按目标模块加载字段选项;
|
||||
* - 保存前校验:四列非空、关系类型合法、四列组合不重复(空列会撞行身份,落库即主键冲突)。
|
||||
* - 关联删除处理三值对多对一同样开放:类型切成多对一时不得重置 b_on_delete(设计 §3.2);
|
||||
* - 保存前校验:四列非空、关系类型合法、删除处置合法、四列组合不重复。
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import ModuleRelationPanel from '@/views/module/module-management/ModuleRelationPanel.vue'
|
||||
@@ -51,13 +52,13 @@ const sourceRow = {
|
||||
b_target_module_id: 'order_item',
|
||||
b_target_field: 'b_order_id',
|
||||
b_relation_type: 'one_to_many',
|
||||
b_on_delete: 'restrict',
|
||||
b_canuse: 1,
|
||||
b_xh: 10,
|
||||
}
|
||||
|
||||
async function mountPanel(modelValue = [], { refRules = [] } = {}) {
|
||||
async function mountPanel(modelValue = []) {
|
||||
const pinia = createPinia()
|
||||
pinia.install({ provide() {}, config: { globalProperties: {} } })
|
||||
setActivePinia(pinia)
|
||||
const wrapper = mount(ModuleRelationPanel, {
|
||||
props: {
|
||||
@@ -67,7 +68,6 @@ async function mountPanel(modelValue = [], { refRules = [] } = {}) {
|
||||
reverseRows: [],
|
||||
loading: false,
|
||||
modelValue,
|
||||
refRules,
|
||||
},
|
||||
global: { plugins: [pinia] },
|
||||
attachTo: document.body,
|
||||
@@ -82,23 +82,6 @@ const lastRelations = (wrapper) => {
|
||||
return events.length ? events[events.length - 1][0] : null
|
||||
}
|
||||
|
||||
const lastRefRules = (wrapper) => {
|
||||
const events = wrapper.emitted('update:refRules') || []
|
||||
return events.length ? events[events.length - 1][0] : null
|
||||
}
|
||||
|
||||
/** 已勾选引用规则的关系行(refRules 里挂着对应的 relation 规则) */
|
||||
const REF_SCOPE_ID = 'order|b_id|order_item|b_order_id'
|
||||
const refRuleRow = {
|
||||
b_id: '1900000000000000001',
|
||||
b_scope_type: 'relation',
|
||||
b_scope_id: REF_SCOPE_ID,
|
||||
b_hook: 'delete.pre',
|
||||
b_predicate: null,
|
||||
b_canuse: 1,
|
||||
b_xh: 10,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
loadDataApi.mockReset()
|
||||
@@ -119,7 +102,7 @@ describe('模块关联面板', () => {
|
||||
expect(stripInternalKeys(next[0])).toEqual(sourceRow)
|
||||
})
|
||||
|
||||
it('新增关联:追加源模块为当前模块的空行并进入编辑', async () => {
|
||||
it('新增关联:追加源模块为当前模块的空行,默认关联删除处理为 restrict', async () => {
|
||||
const wrapper = await mountPanel([{ ...sourceRow }])
|
||||
await wrapper.vm.addRow()
|
||||
await nextTick()
|
||||
@@ -132,6 +115,7 @@ describe('模块关联面板', () => {
|
||||
b_target_module_id: null,
|
||||
b_target_field: null,
|
||||
b_relation_type: 'many_to_one',
|
||||
b_on_delete: 'restrict',
|
||||
b_canuse: 1,
|
||||
b_xh: 20,
|
||||
})
|
||||
@@ -216,88 +200,31 @@ describe('模块关联面板', () => {
|
||||
expect(wrapper.vm.selectedRowKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('删除连带:加载回填不置脏,多对一自动清回不连带', async () => {
|
||||
const cascadeRow = { ...sourceRow, b_on_delete: 'cascade' }
|
||||
it('关联删除处理:加载回填不置脏,多对一不再被重置(允许 cascade)', async () => {
|
||||
const cascadeRow = { ...sourceRow, b_relation_type: 'many_to_one', b_on_delete: 'cascade' }
|
||||
// 传入副本:面板会往行对象上补 _ 前缀派生列
|
||||
const wrapper = await mountPanel([{ ...cascadeRow }])
|
||||
const row = lastRelations(wrapper)[0]
|
||||
expect(stripInternalKeys(row)).toEqual(cascadeRow)
|
||||
|
||||
// 类型改成多对一:连带策略不再成立,自动清回 none 并提示
|
||||
// 类型在多对一与一对多之间切换:处置值必须原样保留,不再被硬编码清回 none
|
||||
row.b_relation_type = 'many_to_one'
|
||||
wrapper.vm.onCellChange(row, { dataIndex: 'b_relation_type' })
|
||||
await nextTick()
|
||||
|
||||
expect(row.b_on_delete).toBe('none')
|
||||
expect(Message.warning).toHaveBeenCalledWith(
|
||||
'多对一关系不支持连带删除/连带归档,已重置为不连带',
|
||||
)
|
||||
expect(row.b_on_delete).toBe('cascade')
|
||||
expect(Message.warning).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('校验:多对一上配连带删除/归档被拦,取值非法被拦', async () => {
|
||||
it('校验:多对一配 cascade 合法,仅取值非法被拦', async () => {
|
||||
const wrapper = await mountPanel([
|
||||
{ ...sourceRow, b_relation_type: 'many_to_one', b_on_delete: 'cascade' },
|
||||
{ ...sourceRow, b_target_field: 'b_other', b_on_delete: 'remove' },
|
||||
])
|
||||
const errors = wrapper.vm.validate()
|
||||
|
||||
expect(errors).toContain('第 1 行:多对一关系不支持连带删除/连带归档')
|
||||
expect(errors).toContain('第 2 行:删除连带取值不合法')
|
||||
})
|
||||
|
||||
it('引用规则:勾选取号建行、取消删行,回填按业务键还原勾选态', async () => {
|
||||
const wrapper = await mountPanel([{ ...sourceRow }], { refRules: [] })
|
||||
const row = wrapper.vm.relations[0]
|
||||
|
||||
row._hasRefRule = 1
|
||||
wrapper.vm.onCellChange(row, { dataIndex: '_hasRefRule' })
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const added = lastRefRules(wrapper)
|
||||
expect(added).toHaveLength(1)
|
||||
expect(added[0]).toMatchObject({
|
||||
b_id: '1900000000000000009',
|
||||
b_scope_type: 'relation',
|
||||
b_scope_id: REF_SCOPE_ID,
|
||||
b_predicate: null,
|
||||
})
|
||||
|
||||
// 已有规则时加载:勾选态由业务键还原(不置脏)
|
||||
const withRule = await mountPanel([{ ...sourceRow }], { refRules: [{ ...refRuleRow }] })
|
||||
expect(stripInternalKeys(withRule.vm.relations[0])).toEqual(sourceRow)
|
||||
expect(withRule.vm.relations[0]._hasRefRule).toBe(1)
|
||||
|
||||
// 取消勾选:规则行被移除
|
||||
const target = withRule.vm.relations[0]
|
||||
target._hasRefRule = 0
|
||||
withRule.vm.onCellChange(target, { dataIndex: '_hasRefRule' })
|
||||
await nextTick()
|
||||
expect(lastRefRules(withRule)).toEqual([])
|
||||
})
|
||||
|
||||
it('改关系主键:自动取消引用保护并删掉旧键规则,避免库中残留孤儿规则', async () => {
|
||||
const wrapper = await mountPanel([{ ...sourceRow }], { refRules: [{ ...refRuleRow }] })
|
||||
const row = wrapper.vm.relations[0]
|
||||
expect(row._hasRefRule).toBe(1)
|
||||
|
||||
row.b_target_module_id = 'legacy'
|
||||
wrapper.vm.onCellChange(row, { dataIndex: 'b_target_module_id' })
|
||||
await nextTick()
|
||||
|
||||
expect(row._hasRefRule).toBe(0)
|
||||
expect(lastRefRules(wrapper)).toEqual([])
|
||||
expect(Message.info).toHaveBeenCalledWith(
|
||||
'关系主键已变化,「有引用时拒绝删除」已取消,请重新勾选',
|
||||
)
|
||||
})
|
||||
|
||||
it('删掉关系行时,挂在它上面的引用规则一并移除', async () => {
|
||||
const wrapper = await mountPanel([{ ...sourceRow }], { refRules: [{ ...refRuleRow }] })
|
||||
wrapper.vm.removeRow(wrapper.vm.relations[0])
|
||||
await nextTick()
|
||||
|
||||
expect(lastRefRules(wrapper)).toEqual([])
|
||||
expect(lastRelations(wrapper)).toHaveLength(0)
|
||||
// 多对一 + cascade 是允许的场景(如「明细 → 主单」删主单级联删明细)
|
||||
expect(errors.some((item) => item.includes('第 1 行'))).toBe(false)
|
||||
expect(errors).toContain('第 2 行:关联删除处理取值不合法')
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,15 @@
|
||||
/**
|
||||
* 删除规则编辑弹窗(ModuleRuleEditModal):
|
||||
* - 勾条件 → predicate 存 {"kind":"dsl","expr":AST};写 SQL → predicate 存文本;
|
||||
* - 两种类型各自的必填校验(无条件 / 缺提示文案)不通过时不提交;
|
||||
* - 打开时按 predicate 三态回显类型与内容;标题按新增/编辑切换。
|
||||
* 删除 SQL 规则编辑弹窗(ModuleRuleEditModal):
|
||||
* - 只编辑 b_sql + b_message:SQL 必填、必须只读、必须含 :ids;提示文案必填;
|
||||
* - 校验不通过时不提交(不 emit ok);
|
||||
* - 打开时按当前行回显 SQL 与文案;标题按新增/编辑切换。
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import ModuleRuleEditModal from '@/views/module/module-management/ModuleRuleEditModal.vue'
|
||||
import { buildRuleQueryItems } from '@/views/module/module-management/ruleUtils'
|
||||
import { IDS_PLACEHOLDER } from '@/views/module/module-management/ruleUtils'
|
||||
|
||||
vi.mock('@/services/api', () => ({ loadDataApi: vi.fn() }))
|
||||
|
||||
@@ -28,32 +27,21 @@ beforeAll(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const ITEMS = buildRuleQueryItems(
|
||||
[
|
||||
{ b_field: 'b_status', b_name: '状态', b_type: 'select', b_canuse: 1 },
|
||||
{ b_field: 'b_no', b_name: '单号', b_type: 'input', b_canuse: 1 },
|
||||
],
|
||||
{},
|
||||
)
|
||||
|
||||
const ruleRow = () => ({
|
||||
_rowKey: '-1',
|
||||
b_id: '1900000000000000001',
|
||||
b_scope_type: 'module',
|
||||
b_scope_id: 'order',
|
||||
b_hook: 'delete.pre',
|
||||
b_predicate: null,
|
||||
b_message: null,
|
||||
b_module_id: 'order',
|
||||
b_sql: `select b_id from bill where b_id in (${IDS_PLACEHOLDER})`,
|
||||
b_message: '单据已销账,不能删除',
|
||||
b_canuse: 1,
|
||||
b_xh: 10,
|
||||
})
|
||||
|
||||
async function mountModal(row = ruleRow(), isNew = false) {
|
||||
const pinia = createPinia()
|
||||
pinia.install({ provide() {}, config: { globalProperties: {} } })
|
||||
setActivePinia(pinia)
|
||||
const wrapper = mount(ModuleRuleEditModal, {
|
||||
props: { open: true, row, isNew, items: ITEMS },
|
||||
props: { open: true, row, isNew },
|
||||
global: { plugins: [pinia] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
@@ -66,57 +54,9 @@ beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('删除规则编辑弹窗', () => {
|
||||
it('勾条件:没有条件时不提交,选了条件后按 AST 组装 predicate', async () => {
|
||||
const row = ruleRow()
|
||||
const wrapper = await mountModal(row)
|
||||
|
||||
await wrapper.vm.onOk()
|
||||
expect(wrapper.vm.conditionError).toBe('勾条件规则至少要有一个条件')
|
||||
expect(wrapper.emitted('ok')).toBeFalsy()
|
||||
|
||||
wrapper.vm.conditionState = {
|
||||
nodes: [{ id: 'n1', at: 1, join: 'and', field: 'b_status', operator: 'eq', value: 'audit' }],
|
||||
groups: [],
|
||||
}
|
||||
await nextTick()
|
||||
expect(wrapper.vm.conditionSummary).toBe('状态 等于 audit')
|
||||
|
||||
await wrapper.vm.onOk()
|
||||
const saved = wrapper.emitted('ok')?.[0]?.[0]
|
||||
expect(saved.b_hook).toBe('delete.pre')
|
||||
const predicate = JSON.parse(saved.b_predicate)
|
||||
expect(predicate.kind).toBe('dsl')
|
||||
expect(predicate.expr.children[0]).toMatchObject({
|
||||
type: 'condition',
|
||||
field: 'b_status',
|
||||
operator: 'eq',
|
||||
value: 'audit',
|
||||
})
|
||||
expect(wrapper.emitted('update:open')?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
it('写 SQL:缺提示文案不提交,填好后原样存文本', async () => {
|
||||
const row = ruleRow()
|
||||
const wrapper = await mountModal(row)
|
||||
wrapper.vm.ruleType = 'sql'
|
||||
wrapper.vm.sqlText = 'select b_id from bill where b_id in (:ids) and b_settled = 1'
|
||||
await nextTick()
|
||||
|
||||
await wrapper.vm.onOk()
|
||||
expect(wrapper.vm.sqlError).toBe('写 SQL 的规则必须填写提示文案')
|
||||
expect(wrapper.emitted('ok')).toBeFalsy()
|
||||
|
||||
row.b_message = '单据已销账,不能删除'
|
||||
await wrapper.vm.onOk()
|
||||
expect(wrapper.emitted('ok')?.[0]?.[0].b_predicate).toBe(
|
||||
'select b_id from bill where b_id in (:ids) and b_settled = 1',
|
||||
)
|
||||
})
|
||||
|
||||
it('写 SQL:SQL 为空时不提交', async () => {
|
||||
const wrapper = await mountModal(ruleRow())
|
||||
wrapper.vm.ruleType = 'sql'
|
||||
describe('删除 SQL 规则编辑弹窗', () => {
|
||||
it('SQL 为空时不提交', async () => {
|
||||
const wrapper = await mountModal()
|
||||
wrapper.vm.sqlText = ' '
|
||||
await nextTick()
|
||||
|
||||
@@ -125,15 +65,59 @@ describe('删除规则编辑弹窗', () => {
|
||||
expect(wrapper.emitted('ok')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('回显与标题:SQL 类型直接进 SQL 分支;新增/编辑标题不同', async () => {
|
||||
const row = { ...ruleRow(), b_predicate: 'select 1 as b_id' }
|
||||
const wrapper = await mountModal(row, true)
|
||||
it('SQL 非只读时不提交', async () => {
|
||||
const wrapper = await mountModal()
|
||||
wrapper.vm.sqlText = `delete from bill where b_id in (${IDS_PLACEHOLDER})`
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.ruleType).toBe('sql')
|
||||
expect(wrapper.vm.sqlText).toBe('select 1 as b_id')
|
||||
await wrapper.vm.onOk()
|
||||
expect(wrapper.vm.sqlError).toBe('规则 SQL 必须是只读查询(以 select 或 with 开头)')
|
||||
expect(wrapper.emitted('ok')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('SQL 缺少 :ids 标记时不提交', async () => {
|
||||
const wrapper = await mountModal()
|
||||
wrapper.vm.sqlText = 'select b_id from bill'
|
||||
await nextTick()
|
||||
|
||||
await wrapper.vm.onOk()
|
||||
expect(wrapper.vm.sqlError).toBe('规则 SQL 必须包含 :ids 标记,用于限定本次待删记录')
|
||||
expect(wrapper.emitted('ok')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('缺提示文案时不提交,填好后原样存 SQL 与文案', async () => {
|
||||
const row = { ...ruleRow(), b_message: '' }
|
||||
const wrapper = await mountModal(row)
|
||||
wrapper.vm.sqlText = `select b_id from bill where b_id in (${IDS_PLACEHOLDER}) and b_settled = 1`
|
||||
await nextTick()
|
||||
|
||||
await wrapper.vm.onOk()
|
||||
expect(wrapper.vm.messageError).toBe('请填写提示文案')
|
||||
expect(wrapper.emitted('ok')).toBeFalsy()
|
||||
|
||||
wrapper.vm.messageText = '单据已销账,不能删除'
|
||||
await nextTick()
|
||||
await wrapper.vm.onOk()
|
||||
|
||||
const saved = wrapper.emitted('ok')?.[0]?.[0]
|
||||
expect(saved.b_sql).toBe(
|
||||
`select b_id from bill where b_id in (${IDS_PLACEHOLDER}) and b_settled = 1`,
|
||||
)
|
||||
expect(saved.b_message).toBe('单据已销账,不能删除')
|
||||
// 旧字段不再写入
|
||||
expect(saved.b_predicate).toBeUndefined()
|
||||
expect(saved.b_hook).toBeUndefined()
|
||||
expect(wrapper.emitted('update:open')?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
it('回显与标题:按当前行还原 SQL 与文案;新增/编辑标题不同', async () => {
|
||||
const wrapper = await mountModal({ ...ruleRow() }, true)
|
||||
|
||||
expect(wrapper.vm.sqlText).toBe(`select b_id from bill where b_id in (${IDS_PLACEHOLDER})`)
|
||||
expect(wrapper.vm.messageText).toBe('单据已销账,不能删除')
|
||||
expect(wrapper.vm.modalTitle).toBe('新增删除规则')
|
||||
|
||||
const editWrapper = await mountModal({ ...ruleRow() }, false)
|
||||
const editWrapper = await mountModal(ruleRow(), false)
|
||||
expect(editWrapper.vm.modalTitle).toBe('编辑删除规则')
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,17 @@
|
||||
/**
|
||||
* 删除规则面板(module-management/ModuleRulePanel):
|
||||
* - 加载回填只补界面派生列(_rowKey / _typeText / _labelText),业务列不变(不置脏);
|
||||
* - 列表识别:勾条件显示条件中文摘要,写 SQL 显示提示文案;
|
||||
* - 保存前校验:勾条件要有条件、写 SQL 要有 SQL 与提示文案;
|
||||
* - 其他模块在本模块上配的引用规则只读展示,可跳转到源模块。
|
||||
* 删除 SQL 规则面板(module-management/ModuleRulePanel):
|
||||
* - 加载回填只补界面派生列(_rowKey / _labelText),业务列不变(不置脏);
|
||||
* - 列表识别:优先提示文案,缺文案退回 SQL 片段;
|
||||
* - 保存前校验:必须有主键、SQL 含 :ids 且为只读查询、必须填提示文案;
|
||||
* - 新增规则:取号后打开弹窗,确定时才落草稿。
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import ModuleRulePanel from '@/views/module/module-management/ModuleRulePanel.vue'
|
||||
import { stripInternalKeys } from '@/utils/dataChanges'
|
||||
import { IDS_PLACEHOLDER } from '@/views/module/module-management/ruleUtils'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(),
|
||||
@@ -33,47 +33,22 @@ beforeAll(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const FIELDS = [
|
||||
{ b_field: 'b_status', b_name: '状态', b_type: 'select', b_canuse: 1 },
|
||||
{ b_field: 'b_settled', b_name: '已销账', b_type: 'checkbox', b_canuse: 1 },
|
||||
]
|
||||
|
||||
const MODULES = [
|
||||
{ b_id: 'order', b_name: '订单', b_canuse: 1 },
|
||||
{ b_id: 'order_item', b_name: '订单明细', b_canuse: 1 },
|
||||
]
|
||||
|
||||
/** 库中已有的一条勾条件规则:状态等于 audit */
|
||||
const dslRow = {
|
||||
/** 库中已有的一条删除 SQL 规则 */
|
||||
const sqlRow = {
|
||||
b_id: '1900000000000000001',
|
||||
b_scope_type: 'module',
|
||||
b_scope_id: 'order',
|
||||
b_hook: 'delete.pre',
|
||||
b_predicate: JSON.stringify({
|
||||
kind: 'dsl',
|
||||
expr: {
|
||||
type: 'group',
|
||||
children: [
|
||||
{ type: 'condition', field: 'b_status', operator: 'eq', value: 'audit', join: 'and' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
b_message: null,
|
||||
b_module_id: 'order',
|
||||
b_sql: `select b_id from bill where b_id in (${IDS_PLACEHOLDER}) and b_settled = 1`,
|
||||
b_message: '单据已销账,不能删除',
|
||||
b_canuse: 1,
|
||||
b_xh: 10,
|
||||
}
|
||||
|
||||
async function mountPanel({ rules = [], refRulesReverse = [] } = {}) {
|
||||
async function mountPanel({ rules = [] } = {}) {
|
||||
const pinia = createPinia()
|
||||
pinia.install({ provide() {}, config: { globalProperties: {} } })
|
||||
setActivePinia(pinia)
|
||||
const wrapper = mount(ModuleRulePanel, {
|
||||
props: {
|
||||
moduleId: 'order',
|
||||
fields: FIELDS,
|
||||
querySchema: {},
|
||||
refRulesReverse,
|
||||
modules: MODULES,
|
||||
loading: false,
|
||||
modelValue: rules,
|
||||
},
|
||||
@@ -94,56 +69,47 @@ beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('删除规则面板', () => {
|
||||
describe('删除 SQL 规则面板', () => {
|
||||
it('加载回填只补界面派生列,业务列不变', async () => {
|
||||
const wrapper = await mountPanel({ rules: [{ ...dslRow }] })
|
||||
const wrapper = await mountPanel({ rules: [{ ...sqlRow }] })
|
||||
const next = lastRules(wrapper)
|
||||
|
||||
expect(next).toHaveLength(1)
|
||||
expect(next[0]._rowKey).toBeTruthy()
|
||||
expect(next[0]._typeText).toBe('条件')
|
||||
expect(next[0]._labelText).toBe('状态 等于 audit')
|
||||
expect(next[0]._labelText).toBe('单据已销账,不能删除')
|
||||
// 剥掉 _ 前缀的内部键后与库中行完全一致(不置脏)
|
||||
expect(stripInternalKeys(next[0])).toEqual(dslRow)
|
||||
expect(stripInternalKeys(next[0])).toEqual(sqlRow)
|
||||
})
|
||||
|
||||
it('识别文案:写 SQL 的规则用提示文案当列表文案', async () => {
|
||||
const sqlRow = {
|
||||
...dslRow,
|
||||
b_id: '1900000000000000002',
|
||||
b_predicate: 'select b_id from bill where b_settled = 1',
|
||||
b_message: '单据已销账,不能删除',
|
||||
}
|
||||
const wrapper = await mountPanel({ rules: [sqlRow] })
|
||||
it('识别文案:缺提示文案时退回 SQL 片段', async () => {
|
||||
const wrapper = await mountPanel({
|
||||
rules: [{ ...sqlRow, b_message: null }],
|
||||
})
|
||||
const next = lastRules(wrapper)
|
||||
|
||||
expect(next[0]._typeText).toBe('SQL')
|
||||
expect(next[0]._labelText).toBe('单据已销账,不能删除')
|
||||
expect(next[0]._labelText).toContain('select b_id from bill')
|
||||
})
|
||||
|
||||
it('校验:勾条件必须真有条件,写 SQL 必须有 SQL 与提示文案', async () => {
|
||||
const emptyDsl = {
|
||||
...dslRow,
|
||||
it('校验:主键、:ids 标记、只读查询、提示文案都要满足', async () => {
|
||||
const noId = { ...sqlRow, b_id: '' }
|
||||
const noPlaceholder = { ...sqlRow, b_id: '1900000000000000002', b_sql: 'select b_id from bill' }
|
||||
const notReadOnly = {
|
||||
...sqlRow,
|
||||
b_id: '1900000000000000003',
|
||||
b_predicate: JSON.stringify({ kind: 'dsl', expr: { type: 'group', children: [] } }),
|
||||
b_sql: `delete from bill where b_id in (${IDS_PLACEHOLDER})`,
|
||||
}
|
||||
const sqlWithoutMessage = {
|
||||
...dslRow,
|
||||
const noMessage = {
|
||||
...sqlRow,
|
||||
b_id: '1900000000000000004',
|
||||
b_predicate: 'select b_id from bill where b_id in (:ids)',
|
||||
b_message: '',
|
||||
}
|
||||
const wrapper = await mountPanel({ rules: [emptyDsl, sqlWithoutMessage] })
|
||||
const wrapper = await mountPanel({ rules: [noId, noPlaceholder, notReadOnly, noMessage] })
|
||||
const errors = wrapper.vm.validate()
|
||||
|
||||
expect(errors).toContain('第 1 条规则:至少配置一个判定条件')
|
||||
expect(errors).toContain('第 2 条规则:写 SQL 的规则必须填写提示文案')
|
||||
|
||||
// 引用规则不该出现在模块级列表里;没有主键的行也要拦住(新增取号失败的情况)
|
||||
const refWrapper = await mountPanel({ rules: [{ ...dslRow, b_predicate: null }] })
|
||||
expect(refWrapper.vm.validate()).toContain('第 1 条规则:引用规则请到「模块关联」面板维护')
|
||||
const noIdWrapper = await mountPanel({ rules: [{ ...dslRow, b_id: '' }] })
|
||||
expect(noIdWrapper.vm.validate()).toContain('第 1 条规则:缺少主键(请重新新增)')
|
||||
expect(errors).toContain('第 1 条规则:缺少主键(请重新新增)')
|
||||
expect(errors).toContain('第 2 条规则:规则 SQL 必须包含 :ids 标记')
|
||||
expect(errors).toContain('第 3 条规则:规则 SQL 必须是只读查询(以 select 或 with 开头)')
|
||||
expect(errors).toContain('第 4 条规则:请填写提示文案')
|
||||
})
|
||||
|
||||
it('新增规则:取号后打开弹窗,确定时才落草稿', async () => {
|
||||
@@ -153,39 +119,14 @@ describe('删除规则面板', () => {
|
||||
|
||||
expect(wrapper.vm.editingIsNew).toBe(true)
|
||||
expect(wrapper.vm.editingRow.b_id).toBe('1900000000000000009')
|
||||
expect(wrapper.vm.editingRow.b_module_id).toBe('order')
|
||||
expect(wrapper.vm.ruleModalOpen).toBe(true)
|
||||
// 未确定前不进列表
|
||||
expect(lastRules(wrapper)).toBe(null)
|
||||
|
||||
wrapper.vm.onRuleOk({ ...wrapper.vm.editingRow, b_predicate: 'select 1 as b_id' })
|
||||
wrapper.vm.onRuleOk({ ...wrapper.vm.editingRow, b_sql: 'select 1 as b_id' })
|
||||
await nextTick()
|
||||
expect(lastRules(wrapper)).toHaveLength(1)
|
||||
expect(lastRules(wrapper)[0].b_id).toBe('1900000000000000009')
|
||||
})
|
||||
|
||||
it('只读区:把引用规则还原成模块名/字段列,并支持跳转到源模块', async () => {
|
||||
const wrapper = await mountPanel({
|
||||
refRulesReverse: [
|
||||
{
|
||||
b_id: '1900000000000000005',
|
||||
b_scope_type: 'relation',
|
||||
b_scope_id: 'order_item|b_order_id|order|b_id',
|
||||
b_hook: 'delete.pre',
|
||||
b_predicate: null,
|
||||
b_canuse: 1,
|
||||
b_xh: 10,
|
||||
},
|
||||
],
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('其他模块在本模块上配置的引用规则')
|
||||
expect(wrapper.text()).toContain('订单明细(order_item)')
|
||||
expect(wrapper.text()).toContain('订单(order)')
|
||||
|
||||
const viewButton = wrapper.findAll('button').find((button) => button.text().includes('查看'))
|
||||
expect(viewButton, '只读区应有查看按钮').toBeTruthy()
|
||||
await viewButton.trigger('click')
|
||||
expect(wrapper.emitted('navigate')?.[0]).toEqual(['order_item'])
|
||||
})
|
||||
})
|
||||
@@ -1,156 +1,66 @@
|
||||
/**
|
||||
* 删除规则配置工具(module-management/ruleUtils):
|
||||
* 关系业务键拼串/还原、predicate 三态推断、识别文案、新增行构造、条件面板 items 构造。
|
||||
* 删除 SQL 规则配置工具(module-management/ruleUtils):
|
||||
* 新增行构造、列表识别文案、SQL 内容校验。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
REF_RULE_HINT,
|
||||
buildRuleQueryItems,
|
||||
createRefRuleRow,
|
||||
IDS_PLACEHOLDER,
|
||||
createRuleRow,
|
||||
isCompleteRelationKey,
|
||||
itemMapOf,
|
||||
parseRelationScopeId,
|
||||
parseRulePredicate,
|
||||
relationScopeId,
|
||||
ruleHasSql,
|
||||
ruleIsReadOnlySql,
|
||||
ruleLabel,
|
||||
ruleSummary,
|
||||
ruleTypeText,
|
||||
} from '@/views/module/module-management/ruleUtils'
|
||||
|
||||
const relation = {
|
||||
b_source_module_id: 'order',
|
||||
b_source_field: 'b_id',
|
||||
b_target_module_id: 'order_item',
|
||||
b_target_field: 'b_order_id',
|
||||
}
|
||||
|
||||
const fields = [
|
||||
{ b_field: 'b_status', b_name: '状态', b_type: 'select', b_canuse: 1 },
|
||||
{ b_field: 'b_amount', b_name: '金额', b_type: 'money', b_canuse: 1 },
|
||||
{ b_field: 'b_off', b_name: '停用字段', b_type: 'input', b_canuse: 0 },
|
||||
]
|
||||
|
||||
describe('删除规则工具', () => {
|
||||
it('关系业务键与四列互逆', () => {
|
||||
expect(relationScopeId(relation)).toBe('order|b_id|order_item|b_order_id')
|
||||
expect(parseRelationScopeId('order|b_id|order_item|b_order_id')).toEqual({
|
||||
sourceModuleId: 'order',
|
||||
sourceField: 'b_id',
|
||||
targetModuleId: 'order_item',
|
||||
targetField: 'b_order_id',
|
||||
})
|
||||
expect(isCompleteRelationKey(relation)).toBe(true)
|
||||
expect(isCompleteRelationKey({ ...relation, b_target_field: null })).toBe(false)
|
||||
})
|
||||
|
||||
it('predicate 三态:空=引用、JSON=勾条件、其余=SQL(非法 JSON 降级为 SQL)', () => {
|
||||
expect(parseRulePredicate({ b_predicate: null }).kind).toBe('reference')
|
||||
expect(parseRulePredicate({ b_predicate: ' ' }).kind).toBe('reference')
|
||||
|
||||
const dsl = parseRulePredicate({
|
||||
b_predicate: JSON.stringify({
|
||||
kind: 'dsl',
|
||||
expr: { type: 'group', children: [{ type: 'condition', field: 'b_status' }] },
|
||||
}),
|
||||
})
|
||||
expect(dsl.kind).toBe('dsl')
|
||||
expect(dsl.expr.children).toHaveLength(1)
|
||||
|
||||
expect(parseRulePredicate({ b_predicate: 'select 1 where b_id in (:ids)' })).toMatchObject({
|
||||
kind: 'sql',
|
||||
})
|
||||
expect(parseRulePredicate({ b_predicate: '{不是合法 JSON' })).toMatchObject({ kind: 'sql' })
|
||||
// JSON 合法但不是 dsl 形状:按 SQL 文本处理,内容不丢
|
||||
expect(parseRulePredicate({ b_predicate: '{"foo":1}' })).toMatchObject({ kind: 'sql' })
|
||||
})
|
||||
|
||||
it('类型文案与摘要:条件走中文描述、SQL 单行截断、引用固定说明', () => {
|
||||
const itemByField = itemMapOf(buildRuleQueryItems(fields, {}))
|
||||
|
||||
const dslRow = {
|
||||
b_predicate: JSON.stringify({
|
||||
kind: 'dsl',
|
||||
expr: {
|
||||
type: 'group',
|
||||
children: [{ type: 'condition', field: 'b_amount', operator: 'gt', value: 0 }],
|
||||
},
|
||||
}),
|
||||
}
|
||||
expect(ruleTypeText(dslRow)).toBe('条件')
|
||||
expect(ruleSummary(dslRow, itemByField)).toBe('金额 大于 0')
|
||||
|
||||
const sqlRow = { b_predicate: 'select\n b_id\nfrom bill where b_settled = 1' }
|
||||
expect(ruleTypeText(sqlRow)).toBe('SQL')
|
||||
expect(ruleSummary(sqlRow, itemByField)).toBe('select b_id from bill where b_settled = 1')
|
||||
|
||||
const refRow = { b_predicate: null }
|
||||
expect(ruleTypeText(refRow)).toBe('引用')
|
||||
expect(ruleSummary(refRow, itemByField)).toBe(REF_RULE_HINT)
|
||||
})
|
||||
|
||||
it('识别文案:勾条件显示条件摘要,写 SQL 显示提示文案(缺文案退回 SQL 片段)', () => {
|
||||
const itemByField = itemMapOf(buildRuleQueryItems(fields, {}))
|
||||
|
||||
const dslRow = {
|
||||
b_predicate: JSON.stringify({
|
||||
kind: 'dsl',
|
||||
expr: {
|
||||
type: 'group',
|
||||
children: [{ type: 'condition', field: 'b_status', operator: 'eq', value: 'audit' }],
|
||||
},
|
||||
}),
|
||||
}
|
||||
expect(ruleLabel(dslRow, itemByField)).toBe('状态 等于 audit')
|
||||
|
||||
const sqlRow = { b_predicate: 'select 1 as b_id', b_message: '单据已销账,不能删除' }
|
||||
expect(ruleLabel(sqlRow, itemByField)).toBe('单据已销账,不能删除')
|
||||
expect(ruleLabel({ ...sqlRow, b_message: null }, itemByField)).toBe('select 1 as b_id')
|
||||
|
||||
expect(ruleLabel({ b_predicate: null }, itemByField)).toBe(REF_RULE_HINT)
|
||||
})
|
||||
|
||||
it('新增行:主键由调用方取号传入,无名称/编码字段', () => {
|
||||
describe('删除 SQL 规则工具', () => {
|
||||
it('新增行:主键由调用方取号传入,模块列直接物化,无名称/编码字段', () => {
|
||||
const rule = createRuleRow('order', { id: '1900000000000000001', xh: 30 })
|
||||
expect(rule).toMatchObject({
|
||||
b_id: '1900000000000000001',
|
||||
b_scope_type: 'module',
|
||||
b_scope_id: 'order',
|
||||
b_hook: 'delete.pre',
|
||||
b_predicate: null,
|
||||
b_module_id: 'order',
|
||||
b_canuse: 1,
|
||||
b_xh: 30,
|
||||
})
|
||||
// 新增行的 SQL 预填 :ids 骨架,直接可改
|
||||
expect(rule.b_sql).toContain(IDS_PLACEHOLDER)
|
||||
expect(rule.b_code).toBeUndefined()
|
||||
expect(rule.b_name).toBeUndefined()
|
||||
|
||||
const ref = createRefRuleRow('order|b_id|order_item|b_order_id', '1900000000000000002')
|
||||
expect(ref).toMatchObject({
|
||||
b_id: '1900000000000000002',
|
||||
b_scope_type: 'relation',
|
||||
b_scope_id: 'order|b_id|order_item|b_order_id',
|
||||
b_predicate: null,
|
||||
b_hook: 'delete.pre',
|
||||
})
|
||||
// 旧设计的字段不再出现
|
||||
expect(rule.b_scope_type).toBeUndefined()
|
||||
expect(rule.b_scope_id).toBeUndefined()
|
||||
expect(rule.b_predicate).toBeUndefined()
|
||||
expect(rule.b_hook).toBeUndefined()
|
||||
})
|
||||
|
||||
it('items 构造:只列启用字段,类型映射控件,select 缺选项时降级为输入', () => {
|
||||
const items = buildRuleQueryItems(fields, {
|
||||
groups: [
|
||||
{
|
||||
conditions: [
|
||||
{ field: 'b_status', operator: 'in', component: 'select', options: [{ value: 'a' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
it('摘要:SQL 单行化并按长度截断,空 SQL 给可读提示', () => {
|
||||
const row = { b_sql: 'select\n b_id\nfrom bill where b_settled = 1' }
|
||||
expect(ruleSummary(row)).toBe('select b_id from bill where b_settled = 1')
|
||||
expect(ruleSummary({ b_sql: null })).toBe('未填写 SQL')
|
||||
|
||||
expect(items.map((item) => item.field.b_field)).toEqual(['b_status', 'b_amount'])
|
||||
// 查询配置里定义过:沿用其操作符/控件/选项
|
||||
expect(items[0].config).toMatchObject({ b_operator: 'in', b_component: 'select' })
|
||||
expect(items[0].options).toEqual([{ value: 'a', label: 'a' }])
|
||||
// 未定义过:按字段类型推导(金额 → 数字输入)
|
||||
expect(items[1].config.b_component).toBe('number')
|
||||
expect(items[1].config.b_operator).toBe('eq')
|
||||
const long = { b_sql: `select b_id from t where ${'x'.repeat(100)}` }
|
||||
const summary = ruleSummary(long)
|
||||
expect(summary.endsWith('…')).toBe(true)
|
||||
expect(summary.length).toBeLessThanOrEqual(81)
|
||||
})
|
||||
|
||||
it('识别文案:优先提示文案,缺文案退回 SQL 片段', () => {
|
||||
const row = {
|
||||
b_sql: 'select 1 as b_id',
|
||||
b_message: '单据已销账,不能删除',
|
||||
}
|
||||
expect(ruleLabel(row)).toBe('单据已销账,不能删除')
|
||||
expect(ruleLabel({ ...row, b_message: null })).toBe('select 1 as b_id')
|
||||
expect(ruleLabel({ ...row, b_message: ' ' })).toBe('select 1 as b_id')
|
||||
})
|
||||
|
||||
it('SQL 校验:必须非空、包含 :ids、且是只读查询', () => {
|
||||
expect(ruleHasSql({ b_sql: 'select b_id from t where b_id in (:ids)' })).toBe(true)
|
||||
expect(ruleHasSql({ b_sql: 'select b_id from t' })).toBe(false)
|
||||
expect(ruleHasSql({ b_sql: null })).toBe(false)
|
||||
|
||||
expect(ruleIsReadOnlySql({ b_sql: 'select b_id from t' })).toBe(true)
|
||||
expect(ruleIsReadOnlySql({ b_sql: ' WITH x AS (select 1) select * from x' })).toBe(true)
|
||||
expect(ruleIsReadOnlySql({ b_sql: 'delete from t' })).toBe(false)
|
||||
expect(ruleIsReadOnlySql({ b_sql: null })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -31,7 +31,6 @@ vi.mock('@/components/ui/message/message-manager.js', () => {
|
||||
|
||||
import { loadDataApi, saveObjectApi } from '@/services/api'
|
||||
import { Message } from '@/components/ui/message/message-manager.js'
|
||||
import { createRefRuleRow } from '@/views/module/module-management/ruleUtils'
|
||||
|
||||
/** 库中的一条关系:demo.b_name → other.b_name(一对多,源侧即当前模块) */
|
||||
function relationRow() {
|
||||
@@ -110,7 +109,7 @@ function buildDb() {
|
||||
s_autocode: [],
|
||||
s_power: [],
|
||||
s_relation: [],
|
||||
s_rule: [],
|
||||
s_delete_rule: [],
|
||||
s_i18n: [{ b_key: 'module.demo', b_locale: 'en-US', b_value: 'Demo module' }],
|
||||
}
|
||||
}
|
||||
@@ -130,20 +129,6 @@ beforeEach(() => {
|
||||
{ b_id: 'en-US', b_name: 'English', b_default: 0 },
|
||||
],
|
||||
})
|
||||
// s_rule 的 scope 条件是两列(模块级 / 关系级)拼在同一个查询条件里,
|
||||
// 这里按条件过滤,模拟后端 search_condition 的真实行为
|
||||
if (view === 's_rule' && typeof condition === 'string') {
|
||||
if (condition.includes("b_scope_type = 'module'")) {
|
||||
return Promise.resolve({
|
||||
data: rowsOf(view).filter((row) => row.b_scope_type === 'module'),
|
||||
})
|
||||
}
|
||||
if (condition.includes("b_scope_type = 'relation'")) {
|
||||
return Promise.resolve({
|
||||
data: rowsOf(view).filter((row) => row.b_scope_type === 'relation'),
|
||||
})
|
||||
}
|
||||
}
|
||||
return Promise.resolve({ data: rowsOf(view) })
|
||||
})
|
||||
|
||||
@@ -487,34 +472,14 @@ describe('模块管理:保存后的未保存判定', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('删除规则:模块级规则加载不脏,只提交变更(雪花主键)', async () => {
|
||||
it('删除 SQL 规则:加载不脏,只提交变更(雪花主键)', async () => {
|
||||
db.s_relation = [relationRow()]
|
||||
db.s_rule = [
|
||||
db.s_delete_rule = [
|
||||
{
|
||||
b_id: '1900000000000000001',
|
||||
b_scope_type: 'module',
|
||||
b_scope_id: 'demo',
|
||||
b_hook: 'delete.pre',
|
||||
b_predicate: JSON.stringify({
|
||||
kind: 'dsl',
|
||||
expr: {
|
||||
type: 'group',
|
||||
children: [
|
||||
{ type: 'condition', field: 'b_name', operator: 'like', value: 'a', join: 'and' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
b_message: null,
|
||||
b_canuse: 1,
|
||||
b_xh: 10,
|
||||
},
|
||||
{
|
||||
b_id: '1900000000000000002',
|
||||
b_scope_type: 'relation',
|
||||
b_scope_id: 'demo|b_name|other|b_name',
|
||||
b_hook: 'delete.pre',
|
||||
b_predicate: null,
|
||||
b_message: null,
|
||||
b_module_id: 'demo',
|
||||
b_sql: 'select b_id from demo where b_id in (:ids) and b_name like N\'a\'',
|
||||
b_message: '示例模块记录已锁定,不能删除',
|
||||
b_canuse: 1,
|
||||
b_xh: 10,
|
||||
},
|
||||
@@ -526,9 +491,8 @@ describe('模块管理:保存后的未保存判定', () => {
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
// 模块级规则进 rules,关系级进 refRules;打开即干净
|
||||
// 规则进 rules;打开即干净
|
||||
expect(wrapper.vm.rules).toHaveLength(1)
|
||||
expect(wrapper.vm.refRules).toHaveLength(1)
|
||||
expect(wrapper.vm.hasUnsavedChanges()).toBe(false)
|
||||
|
||||
wrapper.vm.rules[0].b_canuse = 0
|
||||
@@ -537,13 +501,12 @@ describe('模块管理:保存后的未保存判定', () => {
|
||||
await wrapper.vm.handleSave()
|
||||
await settleConfigReload(wrapper)
|
||||
|
||||
const ruleReq = savedRequests.find((request) => request.table === 's_rule')
|
||||
expect(ruleReq, '规则变更应提交 s_rule').toBeTruthy()
|
||||
const ruleReq = savedRequests.find((request) => request.table === 's_delete_rule')
|
||||
expect(ruleReq, '规则变更应提交 s_delete_rule').toBeTruthy()
|
||||
expect(ruleReq.key_field).toBe('b_id')
|
||||
expect(ruleReq.updates?.[0]).toMatchObject({
|
||||
b_id: '1900000000000000001',
|
||||
b_scope_type: 'module',
|
||||
b_scope_id: 'demo',
|
||||
b_module_id: 'demo',
|
||||
b_canuse: 0,
|
||||
})
|
||||
// 关系未变:不产生 s_relation 请求
|
||||
@@ -551,7 +514,7 @@ describe('模块管理:保存后的未保存判定', () => {
|
||||
expect(wrapper.vm.hasUnsavedChanges()).toBe(false)
|
||||
})
|
||||
|
||||
it('关联面板勾选引用保护:保存时新增一条 relation 范围的规则', async () => {
|
||||
it('关联删除处理:改 b_on_delete 随 s_relation 一起提交,不产生规则行', async () => {
|
||||
db.s_relation = [relationRow()]
|
||||
const wrapper = await mountManagement()
|
||||
await flushPromises()
|
||||
@@ -559,41 +522,34 @@ describe('模块管理:保存后的未保存判定', () => {
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.refRules).toEqual([])
|
||||
wrapper.vm.refRules = [createRefRuleRow('demo|b_name|other|b_name', '1900000000000000003')]
|
||||
expect(wrapper.vm.rules).toEqual([])
|
||||
wrapper.vm.relations[0].b_on_delete = 'cascade'
|
||||
expect(wrapper.vm.hasUnsavedChanges()).toBe(true)
|
||||
|
||||
await wrapper.vm.handleSave()
|
||||
await settleConfigReload(wrapper)
|
||||
|
||||
const refReq = savedRequests.find((request) => request.table === 's_rule')
|
||||
expect(refReq?.key_field).toBe('b_id')
|
||||
expect(refReq?.inserts?.[0]).toMatchObject({
|
||||
b_id: '1900000000000000003',
|
||||
b_scope_type: 'relation',
|
||||
b_scope_id: 'demo|b_name|other|b_name',
|
||||
b_predicate: null,
|
||||
b_hook: 'delete.pre',
|
||||
})
|
||||
const relationReq = savedRequests.find((request) => request.table === 's_relation')
|
||||
expect(relationReq?.updates?.[0]).toMatchObject({ b_on_delete: 'cascade' })
|
||||
// 关联处置收敛到 s_relation,不再生成任何规则表请求
|
||||
expect(savedRequests.some((request) => request.table === 's_delete_rule')).toBe(false)
|
||||
expect(wrapper.vm.hasUnsavedChanges()).toBe(false)
|
||||
})
|
||||
|
||||
it('删除模块应一并清理模块级与关系级删除规则(s_rule)', async () => {
|
||||
it('删除模块应一并清理删除 SQL 规则(s_delete_rule)', async () => {
|
||||
db.s_relation = [relationRow()]
|
||||
db.s_rule = [
|
||||
db.s_delete_rule = [
|
||||
{
|
||||
b_id: '1900000000000000001',
|
||||
b_scope_type: 'module',
|
||||
b_scope_id: 'demo',
|
||||
b_hook: 'delete.pre',
|
||||
b_predicate: 'select 1 as b_id',
|
||||
b_module_id: 'demo',
|
||||
b_sql: 'select 1 as b_id',
|
||||
b_message: '不能删除',
|
||||
},
|
||||
{
|
||||
b_id: '1900000000000000002',
|
||||
b_scope_type: 'relation',
|
||||
b_scope_id: 'demo|b_name|other|b_name',
|
||||
b_hook: 'delete.pre',
|
||||
b_predicate: null,
|
||||
b_module_id: 'other',
|
||||
b_sql: 'select 1 as b_id',
|
||||
b_message: '不能删除',
|
||||
},
|
||||
]
|
||||
const wrapper = await mountManagement()
|
||||
@@ -607,16 +563,12 @@ describe('模块管理:保存后的未保存判定', () => {
|
||||
await wrapper.vm.onConfirmOk()
|
||||
await flushPromises()
|
||||
|
||||
const ruleReqs = savedRequests.filter((request) => request.table === 's_rule')
|
||||
const moduleReq = ruleReqs.find(
|
||||
(request) => request.deletes?.[0]?.b_scope_type === 'module' && request.deletes.length === 1,
|
||||
const ruleReqs = savedRequests.filter((request) => request.table === 's_delete_rule')
|
||||
const moduleReq = ruleReqs.find((request) => request.deletes?.[0]?.b_module_id === 'demo')
|
||||
expect(moduleReq?.deletes).toEqual([{ b_module_id: 'demo' }])
|
||||
// 只按模块列清理,不误伤其他模块的规则
|
||||
expect(ruleReqs.every((request) => request.deletes.every((row) => row.b_module_id === 'demo'))).toBe(
|
||||
true,
|
||||
)
|
||||
expect(moduleReq?.deletes).toEqual([{ b_scope_type: 'module', b_scope_id: 'demo' }])
|
||||
const relationReq = ruleReqs.find((request) =>
|
||||
request.deletes?.some((row) => row.b_scope_type === 'relation'),
|
||||
)
|
||||
expect(relationReq?.deletes).toEqual([
|
||||
{ b_scope_type: 'relation', b_scope_id: 'demo|b_name|other|b_name' },
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user