20260911173133
This commit is contained in:
1 parent
3838ed74b1
commit
f3ad727f1c
60 files changed
+4971
-6621
No files matched your search
@@ -1,191 +0,0 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 权限来源库面板(全库聚合的操作 / 按钮权限,快速导入当前菜单)。
|
||||
*
|
||||
* 与 MenuPowersPanel 一样是页面右侧的独立面板:右侧 Splitter 三栏布局
|
||||
* (菜单配置 / 当前菜单权限 / 权限库),Splitter 只认直接子节点 SplitterPanel,
|
||||
* 两张权限卡必须各占一个槽位,无法由同一个组件渲染成两个根节点,故独立成面板。
|
||||
*
|
||||
* 数据契约:默认 v-model 与父级 powers 双向绑定,导入即向其中追加行;
|
||||
* 父级另持 powers_org 基线用于保存 diff。
|
||||
*/
|
||||
import { computed, onMounted, ref, shallowRef } from 'vue'
|
||||
import { Plus, RefreshCw } from '@lucide/vue'
|
||||
import { Button, Message } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { loadDataBySqlApi } from '@/services/api'
|
||||
import { createTempId } from '@/utils/common'
|
||||
|
||||
/**
|
||||
* 来源库数据与「是否已加载过」标记放在模块作用域:
|
||||
* 面板随选中菜单的切换(maindata 置空)反复挂载,而来源库是全库聚合数据、与菜单无关,
|
||||
* 因此整页会话内只加载一次,只有用户点「刷新」才重新拉取(与模块下拉的缓存策略一致)。
|
||||
*/
|
||||
let libraryLoaded = false
|
||||
const cachedLibrary = ref([])
|
||||
|
||||
/** 当前菜单权限点行,父级 v-model 绑定 powers(导入时追加) */
|
||||
const powers = defineModel({ type: Array, default: () => [] })
|
||||
|
||||
const libraryLoading = ref(false)
|
||||
/** 来源库勾选的权限编码 */
|
||||
const selectedCodes = ref([])
|
||||
|
||||
const columns = shallowRef([
|
||||
// 字符串业务键:s_power.b_id 即权限编码
|
||||
{ dataIndex: 'b_id', title: '权限编码' },
|
||||
{ dataIndex: 'b_name', title: '权限名称' },
|
||||
])
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedCodes.value,
|
||||
onChange: (keys) => {
|
||||
selectedCodes.value = keys
|
||||
},
|
||||
}))
|
||||
|
||||
/** 聚合全库出现的操作权限(编码 / 名称,按编码去重) */
|
||||
async function loadPowerLibrary() {
|
||||
libraryLoading.value = true
|
||||
try {
|
||||
const res = await loadDataBySqlApi(
|
||||
`SELECT b_id, MAX(b_name) AS b_name FROM s_power
|
||||
WHERE b_power_type = 'action' AND b_id IS NOT NULL AND b_id <> ''
|
||||
GROUP BY b_id ORDER BY b_id`,
|
||||
)
|
||||
cachedLibrary.value = (res.data || []).map((row) => ({
|
||||
b_id: row.b_id,
|
||||
b_name: row.b_name,
|
||||
}))
|
||||
libraryLoaded = true
|
||||
selectedCodes.value = []
|
||||
} catch (e) {
|
||||
Message.error('加载权限库失败:' + (e?.message || e))
|
||||
cachedLibrary.value = []
|
||||
} finally {
|
||||
libraryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 把来源库勾选的权限导入当前菜单(跳过已存在编码) */
|
||||
function importSelectedPowers() {
|
||||
const codes = selectedCodes.value
|
||||
if (!codes.length) {
|
||||
Message.warning('请先在权限库中勾选要导入的权限')
|
||||
return
|
||||
}
|
||||
// 权限主键即权限编码(字符串业务键),行不再需要临时雪花 b_id
|
||||
const existing = new Set(powers.value.map((r) => String(r.b_id || '').trim()))
|
||||
const added = []
|
||||
for (const code of codes) {
|
||||
if (existing.has(String(code).trim())) continue
|
||||
const lib = cachedLibrary.value.find((r) => String(r.b_id) === String(code))
|
||||
added.push({
|
||||
b_id: String(code),
|
||||
b_name: lib?.b_name || '',
|
||||
b_power_type: 'action',
|
||||
b_canuse: 1,
|
||||
})
|
||||
existing.add(String(code).trim())
|
||||
}
|
||||
if (!added.length) {
|
||||
Message.warning('勾选的权限均已存在,无需导入')
|
||||
return
|
||||
}
|
||||
powers.value = [...powers.value, ...added]
|
||||
selectedCodes.value = []
|
||||
Message.success(`已导入 ${added.length} 条权限`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!libraryLoaded) void loadPowerLibrary()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mm-power-card">
|
||||
<div class="mm-power-card__head">
|
||||
<div class="mm-pane__title">权限库</div>
|
||||
<div class="mm-power-toolbar">
|
||||
<Button type="outline" size="small" :loading="libraryLoading" @click="loadPowerLibrary">
|
||||
<RefreshCw :size="12" /> 刷新
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!selectedCodes.length"
|
||||
@click="importSelectedPowers"
|
||||
>
|
||||
<Plus :size="12" /> 导入选中{{ selectedCodes.length ? `(${selectedCodes.length})` : '' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="libraryLoading" class="mm-power-loading">加载中...</div>
|
||||
<div v-else class="mm-lib-table-wrap">
|
||||
<FmsTable
|
||||
v-model:columns="columns"
|
||||
:data-source="cachedLibrary"
|
||||
:seq="false"
|
||||
row-key="b_id"
|
||||
:row-selection="rowSelection"
|
||||
:auto-fit-width="true"
|
||||
@change="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ---- 权限卡片 ---- */
|
||||
.mm-power-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mm-power-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.mm-pane__title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 区块标题 */
|
||||
.mm-pane__title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
/* 权限点列表工具条 */
|
||||
.mm-power-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mm-power-loading {
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 来源库表格容器(撑满卡片剩余高度) */
|
||||
.mm-lib-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,208 +0,0 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 当前菜单权限点面板(s_power.b_power_type='action',操作 / 按钮权限)。
|
||||
*
|
||||
* 数据契约:默认 v-model 与父级 powers 双向绑定;父级另持 powers_org 基线(JSON 字符串)
|
||||
* 用于未保存判断与保存 diff,就地改行不会污染基线。
|
||||
* 职责:权限点表格展示、新增 / 删除 / 编辑、表格选择状态。
|
||||
* 权限新增行的临时 id 分配与落库由父级保存编排统一处理。
|
||||
*/
|
||||
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import { Plus, Trash2 } from '@lucide/vue'
|
||||
import { Button, Message } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { createTempId } from '@/utils/common'
|
||||
|
||||
const props = defineProps({
|
||||
/** 权限点加载中 */
|
||||
loading: { type: Boolean, default: false },
|
||||
/** 当前菜单 id:切换菜单时清空行勾选,避免上个菜单的选中计数残留到新菜单 */
|
||||
menuId: { type: [String, Number], default: null },
|
||||
})
|
||||
|
||||
/** 当前菜单权限点行,父级 v-model 绑定 powers */
|
||||
const powers = defineModel({ type: Array, default: () => [] })
|
||||
|
||||
/** 权限点表格列配置:权限主键 b_id 即权限编码(字符串业务键) */
|
||||
const columns = shallowRef([
|
||||
{
|
||||
dataIndex: 'b_id',
|
||||
title: '权限编码',
|
||||
width: 150,
|
||||
editor: { type: 'input' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_name',
|
||||
title: '权限名称',
|
||||
width: 180,
|
||||
editor: { type: 'input' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_canuse',
|
||||
title: '启用',
|
||||
align: 'center',
|
||||
width: 70,
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '操作',
|
||||
align: 'center',
|
||||
width: 70,
|
||||
actions: [
|
||||
{
|
||||
type: 'delete',
|
||||
label: '删除',
|
||||
onClick: (row) => removePowerRow(row),
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const tableRef = ref(null)
|
||||
const selectedKeys = ref([])
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedKeys.value = keys
|
||||
},
|
||||
}))
|
||||
|
||||
// 切换菜单后清空勾选:旧菜单的行 id 在新菜单下无意义,会让「删除(N)」显示对不上的计数
|
||||
watch(
|
||||
() => props.menuId,
|
||||
() => {
|
||||
selectedKeys.value = []
|
||||
},
|
||||
)
|
||||
|
||||
/** 行身份统一用本地 _rowKey(_ 前缀不落库/不参与保存比较);
|
||||
* 权限主键 b_id(编码)由用户/导入提供,未填时允许为空行 */
|
||||
watch(
|
||||
() => powers.value,
|
||||
(rows) => {
|
||||
for (const row of rows || []) {
|
||||
if (!row._rowKey) row._rowKey = createTempId()
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
/** 新增操作 / 按钮权限点:编码(b_id 主键)由用户填写,未保存前允许为空 */
|
||||
async function addPowerRow() {
|
||||
if (props.loading) return
|
||||
const newKey = createTempId()
|
||||
powers.value = [
|
||||
...powers.value,
|
||||
{ b_id: '', b_name: '', b_power_type: 'action', b_canuse: 1, _rowKey: newKey },
|
||||
]
|
||||
selectedKeys.value = []
|
||||
await nextTick()
|
||||
tableRef.value?.editRow?.(newKey, 'b_id')
|
||||
}
|
||||
|
||||
function removePowerRow(row) {
|
||||
powers.value = powers.value.filter((r) => r !== row)
|
||||
selectedKeys.value = selectedKeys.value.filter((k) => String(k) !== String(row._rowKey))
|
||||
}
|
||||
|
||||
/** 批量删除勾选的权限点 */
|
||||
function batchDeletePowers() {
|
||||
const keys = selectedKeys.value
|
||||
if (!keys.length) return
|
||||
const delSet = new Set(keys.map(String))
|
||||
powers.value = powers.value.filter((r) => !delSet.has(String(r._rowKey)))
|
||||
selectedKeys.value = []
|
||||
Message.success(`已删除 ${keys.length} 条权限`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mm-power-card">
|
||||
<div class="mm-power-card__head">
|
||||
<div class="mm-pane__title">当前菜单权限</div>
|
||||
<div class="mm-power-toolbar">
|
||||
<Button type="primary" size="small" :disabled="loading" @click="addPowerRow">
|
||||
<Plus :size="12" /> 新增
|
||||
</Button>
|
||||
<Button
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="loading || !selectedKeys.length"
|
||||
@click="batchDeletePowers"
|
||||
>
|
||||
<Trash2 :size="12" /> 删除{{ selectedKeys.length ? `(${selectedKeys.length})` : '' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="mm-power-loading">加载中...</div>
|
||||
<div v-else class="mm-power-table-wrap">
|
||||
<FmsTable
|
||||
ref="tableRef"
|
||||
v-model:columns="columns"
|
||||
:data-source="powers"
|
||||
:editable="true"
|
||||
row-key="_rowKey"
|
||||
:row-selection="rowSelection"
|
||||
:col-resizable="true"
|
||||
@change="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ---- 权限卡片 ---- */
|
||||
.mm-power-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mm-power-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.mm-pane__title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 区块标题 */
|
||||
.mm-pane__title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
/* 权限点列表工具条 */
|
||||
.mm-power-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mm-power-loading {
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 权限点 FmsTable 容器(撑满卡片剩余高度) */
|
||||
.mm-power-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,18 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 菜单管理:管理 s_menu 导航树。
|
||||
* 布局:左侧菜单树 + 右侧属性编辑面板(菜单配置 / 当前菜单权限 / 权限库)。
|
||||
* 布局:左侧菜单树 + 右侧属性编辑面板(菜单配置 / 多语言)。
|
||||
* 菜单类型:directory(目录)、page(页面)、external(外链)。
|
||||
*
|
||||
* 本文件是编排层,只保留三类职责:
|
||||
* 1. 菜单树的加载 / 新增 / 删除 / 拖拽排序,以及树节点启用停用的落库;
|
||||
* 2. 菜单配置(主表 / 多语言 / 权限点)的加载与保存编排
|
||||
* 2. 菜单配置(主表 / 多语言)的加载与保存编排
|
||||
* (校验 → diff → 分配真实主键 → 落库 → 重载);
|
||||
* 3. 跨面板联动:菜单类型决定是否展示权限面板、菜单编码派生多语言键、
|
||||
* 未保存切换确认与统一确认弹窗。
|
||||
* 面板各自的字段编辑、表格交互、权限库加载与导入均在对应面板组件内部,
|
||||
* 3. 跨面板联动:菜单编码派生多语言键、未保存切换确认与统一确认弹窗。
|
||||
* 面板各自的字段编辑与表格交互均在对应面板组件内部,
|
||||
* 数据以 v-model 直接读写本文件持有的草稿,不存在第二份状态源。
|
||||
*
|
||||
* 权限点(s_power)统一在「模块管理 → 权限」维护,菜单保存不再派生 menu.* 权限点。
|
||||
*/
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useHotkeys } from '@/composables/useHotkeys'
|
||||
@@ -32,8 +33,6 @@ import UnsavedChangesModal from '@/components/UnsavedChangesModal.vue'
|
||||
import I18nNameModal from '@/components/I18nNameModal.vue'
|
||||
import MenuTreePanel from './MenuTreePanel.vue'
|
||||
import MenuBasicPanel from './MenuBasicPanel.vue'
|
||||
import MenuPowersPanel from './MenuPowersPanel.vue'
|
||||
import MenuPowerLibraryPanel from './MenuPowerLibraryPanel.vue'
|
||||
import { findNodePosition, markDirectoryNonLeaf, menuRowOf, renumberSiblings } from './utils'
|
||||
import { useI18nStore } from '@/stores/i18n'
|
||||
|
||||
@@ -96,22 +95,6 @@ const i18n_org = ref([])
|
||||
/** 多语言名称编辑弹窗 */
|
||||
const i18nModalVisible = ref(false)
|
||||
|
||||
/** 操作 / 按钮权限点草稿(b_power_type 统一存 action,按钮以 b_name 区分) */
|
||||
const powers = ref([])
|
||||
/** 权限点基线快照(JSON 字符串),用于未保存判断 */
|
||||
const powers_org = ref('')
|
||||
const powersLoading = ref(false)
|
||||
|
||||
/**
|
||||
* 清空权限点草稿与基线:切换菜单 / 新建 / 取消选中都必须走这里,
|
||||
* 否则上个菜单的行会残留到新菜单,「删除(N)」会显示对不上当前数据的计数。
|
||||
*/
|
||||
function resetPowers() {
|
||||
powers.value = []
|
||||
powers_org.value = '[]'
|
||||
powersLoading.value = false
|
||||
}
|
||||
|
||||
/** 菜单基础信息面板(保存前校验入口) */
|
||||
const basicPanelRef = ref(null)
|
||||
|
||||
@@ -122,15 +105,13 @@ const selectedMenu = computed(() =>
|
||||
|
||||
const busy = computed(() => configLoading.value || configSaving.value)
|
||||
|
||||
// 菜单类型以草稿为准:新建/编辑时切换类型,右侧字段(路由/绑定模块/权限面板)
|
||||
// 菜单类型以草稿为准:新建/编辑时切换类型,右侧字段(路由 / 绑定模块)
|
||||
// 必须立即联动;若只读树节点,切换类型后字段区不动,
|
||||
// 改成 page 却拿不到路由输入框 → 保存被「必须填写路由地址」拦下且无从修改(死循环)。
|
||||
// 草稿未就绪(加载中/未选中)时回退树节点。
|
||||
const menuType = computed(
|
||||
() => maindata.value?.b_menu_type || selectedMenu.value?.b_menu_type || null,
|
||||
)
|
||||
// 目录是纯导航容器:无访问权限、无操作/按钮权限点,右侧不展示权限面板
|
||||
const isDirectory = computed(() => menuType.value === 'directory')
|
||||
const menuTypeLocked = computed(
|
||||
() => !!selectedMenu.value && !unsavedMenuIds.has(String(selectedMenu.value.b_id)),
|
||||
)
|
||||
@@ -353,7 +334,6 @@ async function addMenu(parentId, menuTypeValue = 'page') {
|
||||
i18n_values.value = { [defaultLocale.value]: '' }
|
||||
i18n_values_org.value = { [defaultLocale.value]: '' }
|
||||
i18n_org.value = []
|
||||
resetPowers()
|
||||
loadedMenuId.value = newId
|
||||
}
|
||||
|
||||
@@ -379,21 +359,7 @@ async function deleteMenu(id) {
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const menuPowerCode = menu.b_id ? `menu.${String(menu.b_id).trim()}` : ''
|
||||
const reqs = [{ table: 's_i18n', key_field: 'b_id', deletes: [{ b_module_id: menuId }] }]
|
||||
// 清理权限点:访问(type=menu 按 b_resource 关联)+ 操作/按钮(type=action 按 b_module_id 关联)
|
||||
if (menuPowerCode) {
|
||||
reqs.push({
|
||||
table: 's_power',
|
||||
key_field: 'b_id',
|
||||
deletes: [{ b_power_type: 'menu', b_resource: menuPowerCode }],
|
||||
})
|
||||
}
|
||||
reqs.push({
|
||||
table: 's_power',
|
||||
key_field: 'b_id',
|
||||
deletes: [{ b_power_type: 'action', b_module_id: menuId }],
|
||||
})
|
||||
reqs.push({ table: 's_menu', key_field: 'b_id', deletes: [{ b_id: menuId }] })
|
||||
await saveObjectApi(reqs)
|
||||
collectUnsavedMenuIds(menu).forEach((k) => unsavedMenuIds.delete(k))
|
||||
@@ -440,29 +406,6 @@ async function loadMenuConfig(menuId) {
|
||||
i18n_values.value = i18nInitValues
|
||||
i18n_values_org.value = { ...i18nInitValues }
|
||||
i18n_org.value = i18n_org_rows
|
||||
// 操作/按钮权限点:仅页面/外链才有(目录是导航容器,无按钮权限)
|
||||
powersLoading.value = true
|
||||
try {
|
||||
const powerRes =
|
||||
menu && menu.b_menu_type !== 'directory'
|
||||
? await loadDataApi(
|
||||
's_power',
|
||||
`b_power_type = 'action' AND b_module_id = ${sqlText(id)}`,
|
||||
'b_xh ASC, b_id ASC',
|
||||
)
|
||||
: { data: [] }
|
||||
// 字符串业务键:s_power.b_id 即权限编码,行不再有独立 b_code
|
||||
powers.value = (powerRes.data || []).map((row) => ({
|
||||
b_id: row.b_id,
|
||||
b_name: row.b_name,
|
||||
b_power_type: 'action',
|
||||
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
|
||||
}))
|
||||
// 基线只快照刚加载出的行:随后用户在面板里的增删改都算未保存改动
|
||||
powers_org.value = JSON.stringify(powers.value)
|
||||
} finally {
|
||||
powersLoading.value = false
|
||||
}
|
||||
loadedMenuId.value = id
|
||||
} catch (e) {
|
||||
if (String(menuSelectedId.value) === String(menuId)) {
|
||||
@@ -485,7 +428,6 @@ watch(menuSelectedId, (id) => {
|
||||
configLoading.value = false
|
||||
loadedMenuId.value = null
|
||||
maindata.value = null
|
||||
resetPowers()
|
||||
return
|
||||
}
|
||||
// 未落库的新节点:临时 id 在库里查不到行,不能走 loadMenuConfig
|
||||
@@ -504,7 +446,6 @@ watch(menuSelectedId, (id) => {
|
||||
i18n_values.value = { [defaultLocale.value]: node.b_name || '' }
|
||||
i18n_values_org.value = { ...i18n_values.value }
|
||||
i18n_org.value = []
|
||||
resetPowers()
|
||||
loadedMenuId.value = String(id)
|
||||
return
|
||||
}
|
||||
@@ -518,31 +459,6 @@ watch(menuSelectedId, (id) => {
|
||||
const switchConfirm = ref(null)
|
||||
const switchSaving = ref(false)
|
||||
|
||||
/** 解析权限点基线(JSON 字符串);解析失败按空数组处理 */
|
||||
function parsePowersBaseline() {
|
||||
try {
|
||||
return JSON.parse(powers_org.value) || []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限点归一化后序列化,供未保存比较使用:
|
||||
* - b_id:基线来自数据库(number),新增行是临时 id(string),统一按字符串比
|
||||
* - b_canuse:表格编辑器写入布尔值,基线是 0/1,统一归一为 0/1
|
||||
* 否则「勾选后取消」这类回到原语义的操作会被永久判为未保存。
|
||||
*/
|
||||
function serializePowers(rows) {
|
||||
return JSON.stringify(
|
||||
(rows || []).map((r) => ({
|
||||
b_id: r.b_id == null ? null : String(r.b_id),
|
||||
b_name: String(r.b_name || ''),
|
||||
b_canuse: Number(r.b_canuse) === 1 ? 1 : 0,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function hasUnsavedChanges() {
|
||||
if (!maindata.value) return false
|
||||
if (JSON.stringify(maindata.value) !== JSON.stringify(maindata_org.value)) return true
|
||||
@@ -553,8 +469,6 @@ function hasUnsavedChanges() {
|
||||
)
|
||||
)
|
||||
return true
|
||||
// 操作/按钮权限点变化视为未保存
|
||||
if (serializePowers(powers.value) !== serializePowers(parsePowersBaseline())) return true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -662,7 +576,7 @@ async function beforeSave() {
|
||||
return true
|
||||
}
|
||||
|
||||
/** 组装保存请求:s_menu / s_i18n / s_power 均与各自基线对比,只提交差异;全部无变化返回 null */
|
||||
/** 组装保存请求:s_menu / s_i18n 均与各自基线对比,只提交差异;全部无变化返回 null */
|
||||
async function buildSaveData() {
|
||||
const treeId = String(selectedMenu.value.b_id)
|
||||
const menuData = maindata.value
|
||||
@@ -674,10 +588,6 @@ async function buildSaveData() {
|
||||
const menuId = String(menuData.b_id || '').trim()
|
||||
if (!menuId) return null
|
||||
const menuI18nKey = `menu.${menuId}`
|
||||
// 目录是纯导航容器:可见性由子菜单决定,自身不生成菜单访问权限点。
|
||||
// 页面类型菜单的访问权限统一由菜单编码派生。
|
||||
const menuIsDirectory = menuData.b_menu_type === 'directory'
|
||||
const powerCode = `menu.${menuId}`
|
||||
|
||||
const reqs = []
|
||||
// 主表:dataChanges diffRow 字段级对比(新建无基线 → 整行 insert)
|
||||
@@ -726,101 +636,6 @@ async function buildSaveData() {
|
||||
reqs.push(i18nReq)
|
||||
}
|
||||
|
||||
// 目录是纯导航容器:自身不需要菜单访问权限与按钮权限点。
|
||||
// 若该菜单此前(或改为目录前)生成过权限记录,保存时一并清理,避免孤儿数据:
|
||||
// - 访问权限(s_power.b_power_type='menu',按菜单编码派生的权限字符关联)
|
||||
// - 操作/按钮权限(s_power.b_power_type='action',按 b_module_id=菜单 id 关联)
|
||||
if (menuIsDirectory) {
|
||||
const previousCode = String(maindata_org.value?.b_id || '').trim()
|
||||
const prevPowerCode = previousCode ? `menu.${previousCode}` : ''
|
||||
if (prevPowerCode) {
|
||||
reqs.push({
|
||||
table: 's_power',
|
||||
key_field: 'b_id',
|
||||
// 权限主键即权限编码(menu.<菜单编码>)
|
||||
deletes: [{ b_id: prevPowerCode }],
|
||||
})
|
||||
}
|
||||
reqs.push({
|
||||
table: 's_power',
|
||||
key_field: 'b_id',
|
||||
deletes: [{ b_power_type: 'action', b_module_id: menuId }],
|
||||
})
|
||||
}
|
||||
|
||||
// 访问权限点(s_power.b_power_type='menu',由菜单编码自动同步)
|
||||
// 目录必须整块跳过:上方分支刚把该菜单的访问权限点整条 deletes 掉,
|
||||
// 这里若继续按「库里查得到」把它塞进 updates,就会去更新一个本次已被删除的行
|
||||
// (同一事务内先删后改 → 0 行 → 后端抛「修改记录数异常: 0」→ 整个保存回滚,连图标也存不进去);
|
||||
// 查不到时走 inserts 更糟:把本该清掉的目录权限又写回去。
|
||||
if (!menuIsDirectory && powerCode) {
|
||||
const previousCode = String(maindata_org.value?.b_id || '').trim()
|
||||
const previousPowerCode = previousCode ? `menu.${previousCode}` : ''
|
||||
if (previousPowerCode && previousPowerCode !== powerCode) {
|
||||
reqs.push({
|
||||
table: 's_power',
|
||||
key_field: 'b_id',
|
||||
deletes: [{ b_id: previousPowerCode }],
|
||||
})
|
||||
}
|
||||
// 查是否有已存在的访问权限(主键 = 权限编码 menu.<菜单编码>)
|
||||
const accessPowerRes = menuData.b_id
|
||||
? await loadDataApi('s_power', `b_power_type = 'menu' AND b_id = ${sqlText(powerCode)}`)
|
||||
: { data: [] }
|
||||
const existingAccess = accessPowerRes.data?.[0]
|
||||
const accessPayload = {
|
||||
b_id: powerCode,
|
||||
b_power_type: 'menu',
|
||||
b_name: menuData.b_name,
|
||||
b_resource: powerCode,
|
||||
b_operation: 'access',
|
||||
b_canuse: menuData.b_canuse ?? 1,
|
||||
}
|
||||
if (existingAccess) {
|
||||
reqs.push({ table: 's_power', key_field: 'b_id', updates: [accessPayload] })
|
||||
} else {
|
||||
// 新增访问权限行:主键即业务键,无需雪花 id
|
||||
reqs.push({ table: 's_power', key_field: 'b_id', inserts: [accessPayload] })
|
||||
}
|
||||
}
|
||||
|
||||
// 操作/按钮权限点(s_power.b_power_type='action',diff 保存)
|
||||
// 目录是导航容器,不保存操作/按钮权限点。
|
||||
// 基线行的删除必须能独立触发:只看 powers.value.length 会让「全部删光后保存」静默失效
|
||||
// (一行不剩时整个 diff 被跳过,基线里的行删不掉,重新加载后又冒出来)。
|
||||
const baselineIds = new Set()
|
||||
for (const r of parsePowersBaseline()) {
|
||||
if (r.b_id) baselineIds.add(String(r.b_id))
|
||||
}
|
||||
if (!menuIsDirectory && (baselineIds.size || powers.value.length)) {
|
||||
const currentIds = new Set(powers.value.filter((r) => r.b_id).map((r) => String(r.b_id)))
|
||||
// 被删除的行
|
||||
for (const id of baselineIds) {
|
||||
if (!currentIds.has(id)) {
|
||||
reqs.push({ table: 's_power', key_field: 'b_id', deletes: [{ b_id: id }] })
|
||||
}
|
||||
}
|
||||
// 新增/更新的行:权限主键即编码(业务键),无需雪花 id
|
||||
for (const row of powers.value) {
|
||||
const code = String(row.b_id || '').trim()
|
||||
if (!code) continue // 未填编码的行不保存
|
||||
const payload = {
|
||||
b_id: code,
|
||||
b_power_type: 'action',
|
||||
b_name: row.b_name,
|
||||
b_resource: code,
|
||||
b_operation: 'execute',
|
||||
b_module_id: menuId,
|
||||
b_canuse: Number(row.b_canuse) === 1 ? 1 : 0,
|
||||
}
|
||||
if (baselineIds.has(String(row.b_id))) {
|
||||
reqs.push({ table: 's_power', key_field: 'b_id', updates: [payload] })
|
||||
} else {
|
||||
reqs.push({ table: 's_power', key_field: 'b_id', inserts: [payload] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reqs.length ? reqs : null
|
||||
}
|
||||
|
||||
@@ -936,7 +751,7 @@ async function loadModuleOptions() {
|
||||
try {
|
||||
const res = await loadDataApi(
|
||||
's_module',
|
||||
"b_canuse = 1 AND b_module_type IN ('data', 'query')",
|
||||
"b_canuse = 1 AND b_module_type IN ('data', 'virtual')",
|
||||
'b_xh ASC, b_id ASC',
|
||||
)
|
||||
moduleOptions.value = (res.data || []).map((m) => ({
|
||||
@@ -1041,20 +856,6 @@ loadModuleOptions()
|
||||
/>
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- 权限面板:仅页面/外链(目录无权限) -->
|
||||
<SplitterPanel v-if="!isDirectory">
|
||||
<MenuPowersPanel
|
||||
v-model="powers"
|
||||
:loading="powersLoading"
|
||||
:menu-id="String(selectedMenu.b_id)"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- 权限来源库:全库聚合,快速导入 -->
|
||||
<SplitterPanel v-if="!isDirectory" :default-size="260">
|
||||
<MenuPowerLibraryPanel v-model="powers" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
<div v-else-if="configLoading" class="mm-config-state">
|
||||
@@ -1122,8 +923,7 @@ loadModuleOptions()
|
||||
<style lang="scss" scoped>
|
||||
/* ============================================================
|
||||
* 菜单管理页面样式(仅页面布局)
|
||||
* 面板内部样式随各面板组件(MenuTreePanel / MenuBasicPanel /
|
||||
* MenuPowersPanel / MenuPowerLibraryPanel)一起维护
|
||||
* 面板内部样式随各面板组件(MenuTreePanel / MenuBasicPanel)一起维护
|
||||
* ============================================================ */
|
||||
|
||||
/* ---- 页面根 ---- */
|
||||
@@ -1215,7 +1015,7 @@ loadModuleOptions()
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
/* 第一个面板(菜单配置)去左边距;最后一个面板(权限库)去右边距 */
|
||||
/* 第一个面板(菜单配置)去左边距;最后一个面板去右边距 */
|
||||
.mm-edit-splitter :deep(> .fms-splitter-panel:first-child) {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 模块基础信息面板(s_module)。
|
||||
* 注意:新系统将图标/路由移到 s_menu,模块本身只维护数据语义:
|
||||
* category 分类节点 / data 数据模块 / query 查询模块。
|
||||
* 新系统将图标/路由移到 s_menu,模块本身只维护数据语义与查询来源:
|
||||
* module 模块(可含下级)/ data 数据模块 / virtual 虚拟模块。
|
||||
*
|
||||
* 数据契约:默认 v-model 与父级 maindata(s_module 主表行)双向绑定,就地修改字段即可,
|
||||
* 父级另持 maindata_org 原始基线用于保存 diff,就地改不会污染保存基线。
|
||||
@@ -22,30 +22,24 @@ const props = defineProps({
|
||||
const maindata = defineModel({ type: Object, default: null })
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '分类', value: 'category' },
|
||||
{ label: '模块', value: 'module' },
|
||||
{ label: '数据模块', value: 'data' },
|
||||
{ label: '查询模块', value: 'query' },
|
||||
{ label: '虚拟模块', value: 'virtual' },
|
||||
]
|
||||
|
||||
// 取值与设计文档/CK_s_module_delete_policy 对齐(模块层无 cascade,级联由 s_relation 表达)
|
||||
const deletePolicyOptions = [
|
||||
{ label: 'restrict · 主数据', value: 'restrict' },
|
||||
{ label: 'lifecycle · 业务单据', value: 'lifecycle' },
|
||||
{ label: 'hard · 临时记录', value: 'hard' },
|
||||
]
|
||||
|
||||
const isCategory = computed(() => maindata.value?.b_module_type === 'category')
|
||||
const isModuleType = computed(() => maindata.value?.b_module_type === 'module')
|
||||
const isData = computed(() => maindata.value?.b_module_type === 'data')
|
||||
const isQuery = computed(() => maindata.value?.b_module_type === 'query')
|
||||
const isVirtual = computed(() => maindata.value?.b_module_type === 'virtual')
|
||||
// data / virtual 拥有查询来源与字段定义
|
||||
const hasDataSource = computed(() => isData.value || isVirtual.value)
|
||||
|
||||
/* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
|
||||
// data/query 的必填项与数据库 CK_s_module_config 约束对齐:
|
||||
// data:查询视图/保存表/主键字段必填;query:仅查询视图必填(主键字段可选)
|
||||
// data:查询表/视图或查询 SQL 必填、保存表与主键字段必填;virtual:仅查询来源可选
|
||||
const formRules = computed(() => ({
|
||||
b_id: [
|
||||
{
|
||||
required: true,
|
||||
message: () => (isCategory.value ? '请填写分类编码' : '请填写模块编码'),
|
||||
message: () => (isModuleType.value ? '请填写模块编码' : '请填写模块编码'),
|
||||
},
|
||||
{
|
||||
validator: (value) => {
|
||||
@@ -61,12 +55,19 @@ const formRules = computed(() => ({
|
||||
],
|
||||
b_name: [{ required: true, message: '请填写模块名称' }],
|
||||
b_module_type: [{ required: true, message: '请选择模块类型' }],
|
||||
b_viewtable:
|
||||
isData.value || isQuery.value ? [{ required: true, message: '请填写查询视图' }] : [],
|
||||
b_keyfield: isData.value ? [{ required: true, message: '请填写主键字段' }] : [],
|
||||
b_savetable: isData.value ? [{ required: true, message: '请填写保存表' }] : [],
|
||||
// CK_s_module_config 要求 data 的 b_delete_policy 非空、category/query 必须为空
|
||||
b_delete_policy: isData.value ? [{ required: true, message: '请选择删除策略' }] : [],
|
||||
b_view_table: isData.value
|
||||
? [
|
||||
{
|
||||
validator: (value) => {
|
||||
if (String(value ?? '').trim()) return true
|
||||
if (String(maindata.value?.b_query_sql ?? '').trim()) return true
|
||||
return '请填写查询表/视图,或配置查询 SQL'
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
b_key_field: isData.value ? [{ required: true, message: '请填写主键字段' }] : [],
|
||||
b_save_table: isData.value ? [{ required: true, message: '请填写保存表' }] : [],
|
||||
}))
|
||||
|
||||
const formRef = ref(null)
|
||||
@@ -87,28 +88,25 @@ function setField(field, value) {
|
||||
function onCodeInput(v) {
|
||||
setField('b_id', v)
|
||||
if (maindata.value) {
|
||||
maindata.value.b_i18n = buildModuleI18nKey(maindata.value.b_module_type, v)
|
||||
maindata.value.b_i18n = buildModuleI18nKey(v)
|
||||
}
|
||||
}
|
||||
|
||||
function onModuleTypeChange(v) {
|
||||
if (!maindata.value) return
|
||||
maindata.value.b_module_type = v
|
||||
// 切换类型时清掉不适用字段,与数据库 CK_s_module_config 约束对齐
|
||||
// (category 要求数据来源字段全空;query 要求保存表/删除策略为空),否则残留值会导致保存失败
|
||||
if (v === 'category') {
|
||||
maindata.value.b_viewtable = null
|
||||
maindata.value.b_keyfield = null
|
||||
maindata.value.b_savetable = null
|
||||
maindata.value.b_orderfield = null
|
||||
maindata.value.b_delete_policy = null
|
||||
} else if (v === 'query') {
|
||||
maindata.value.b_savetable = null
|
||||
maindata.value.b_delete_policy = null
|
||||
// 切换类型时清掉不适用字段(module 无数据来源;virtual 无保存表)
|
||||
if (v === 'module') {
|
||||
maindata.value.b_view_table = null
|
||||
maindata.value.b_save_table = null
|
||||
maindata.value.b_key_field = null
|
||||
maindata.value.b_order_sql = null
|
||||
maindata.value.b_query_sql = null
|
||||
} else if (v === 'virtual') {
|
||||
maindata.value.b_save_table = null
|
||||
}
|
||||
maindata.value.b_i18n = buildModuleI18nKey(v, maindata.value.b_id)
|
||||
maindata.value.b_i18n = buildModuleI18nKey(maindata.value.b_id)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -122,7 +120,7 @@ function onModuleTypeChange(v) {
|
||||
max-width="720px"
|
||||
>
|
||||
<FormGroup title="标识与归属">
|
||||
<FormItem :label="isCategory ? '分类编码' : '模块编码'" name="b_id" required>
|
||||
<FormItem :label="isModuleType ? '模块编码' : '模块编码'" name="b_id" required>
|
||||
<Input
|
||||
:model-value="maindata.b_id"
|
||||
:disabled="loading || codeReadonly"
|
||||
@@ -153,46 +151,60 @@ function onModuleTypeChange(v) {
|
||||
</FormItem>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="isData || isQuery" title="数据来源">
|
||||
<FormItem label="查询视图" name="b_viewtable" required>
|
||||
<FormGroup v-if="hasDataSource" title="数据来源">
|
||||
<FormItem label="查询表/视图" name="b_view_table" :required="isData">
|
||||
<Input
|
||||
:model-value="maindata.b_viewtable"
|
||||
:model-value="maindata.b_view_table"
|
||||
:disabled="loading"
|
||||
placeholder="data/query 必填"
|
||||
@update:model-value="(v) => setField('b_viewtable', v)"
|
||||
placeholder="data 必填;virtual 可由查询 SQL 提供"
|
||||
@update:model-value="(v) => setField('b_view_table', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="主键字段" name="b_keyfield" :required="isData">
|
||||
<FormItem label="主键字段" name="b_key_field" :required="isData">
|
||||
<Input
|
||||
:model-value="maindata.b_keyfield"
|
||||
:model-value="maindata.b_key_field"
|
||||
:disabled="loading"
|
||||
placeholder="data 必填,query 可选,如 b_id"
|
||||
@update:model-value="(v) => setField('b_keyfield', v)"
|
||||
placeholder="data 必填,如 b_id"
|
||||
@update:model-value="(v) => setField('b_key_field', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem v-if="isData" label="保存表" name="b_savetable" required>
|
||||
<FormItem v-if="isData" label="保存表" name="b_save_table" required>
|
||||
<Input
|
||||
:model-value="maindata.b_savetable"
|
||||
:model-value="maindata.b_save_table"
|
||||
:disabled="loading"
|
||||
placeholder="data 模块的保存目标,必填"
|
||||
@update:model-value="(v) => setField('b_savetable', v)"
|
||||
@update:model-value="(v) => setField('b_save_table', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem v-if="isData" label="默认排序" name="b_orderfield">
|
||||
<FormItem label="默认排序 SQL" name="b_order_sql" :span="isData ? 1 : 3">
|
||||
<Input
|
||||
:model-value="maindata.b_orderfield"
|
||||
:model-value="maindata.b_order_sql"
|
||||
:disabled="loading"
|
||||
placeholder="如 b_xh ASC, b_id DESC"
|
||||
@update:model-value="(v) => setField('b_orderfield', v)"
|
||||
@update:model-value="(v) => setField('b_order_sql', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem v-if="isData" label="删除策略" name="b_delete_policy" required>
|
||||
<Select
|
||||
:model-value="maindata.b_delete_policy"
|
||||
:options="deletePolicyOptions"
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="hasDataSource" title="查询与扩展">
|
||||
<FormItem label="查询 SQL" name="b_query_sql" :span="3">
|
||||
<Input
|
||||
:model-value="maindata.b_query_sql"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:disabled="loading"
|
||||
placeholder="data 模块必填"
|
||||
@update:model-value="(v) => setField('b_delete_policy', v)"
|
||||
placeholder="可选:完整 SELECT 或 SQL 模板,配置后优先于查询表/视图"
|
||||
@update:model-value="(v) => setField('b_query_sql', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="扩展配置 JSON" name="b_config_json" :span="3">
|
||||
<Input
|
||||
:model-value="maindata.b_config_json"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:disabled="loading"
|
||||
placeholder="可选:低频模块扩展配置"
|
||||
@update:model-value="(v) => setField('b_config_json', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
</FormGroup>
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 编辑设计面板(form/table 统一壳)。
|
||||
* 根据模块编辑方式(b_edit_mode)二选一渲染对应编辑器:
|
||||
* - editMode = 'form' → FormDesignEditor(主表表单画布设计)
|
||||
* - editMode = 'table' → TableDesignEditor mode="subtable"(子表表格设计)
|
||||
* 两个编辑器共享同一套 props/emits 契约,本壳负责接线 + subtable 行工厂注入。
|
||||
* 表单设计面板(s_module_schema.b_schema_type = 'edit')。
|
||||
* V2 已移除模块编辑方式(b_edit_mode),表单设计统一使用 FormDesignEditor 画布编辑器,
|
||||
* 编辑结果写入 edit JSON(分组 / 字段节点 / 栅格 / 只读 / 必填)。
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import FormDesignEditor from './components/design-editor/FormDesignEditor.vue'
|
||||
import TableDesignEditor from './components/design-editor/TableDesignEditor.vue'
|
||||
import { createEditRowFactory } from './components/design-editor/common/useStructureEditing'
|
||||
import { createDefaultSchema } from './utils'
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
moduleId: { type: String, required: true },
|
||||
fields: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
@@ -19,59 +15,16 @@ const props = defineProps({
|
||||
active: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
/* 数据全部走 v-model 双向绑定,本壳只负责编辑器路由与 subtable 行工厂注入。 */
|
||||
/** 编辑配置行(s_field_edit),父级 v-model 绑定 */
|
||||
const editConfig = defineModel({ type: Array, default: () => [] })
|
||||
/** 分组行(s_field_group),父级 v-model:groups 绑定 */
|
||||
const groups = defineModel('groups', { type: Array, default: () => [] })
|
||||
/** 列表配置行(s_field_view):表单设计器删除分组时需清理其 b_group_id 引用 */
|
||||
const listConfig = defineModel('listConfig', { type: Array, default: () => [] })
|
||||
/** 模块编辑方式:form 主表(表单画布设计)| table 子表(表格列设计) */
|
||||
const editMode = defineModel('editMode', { type: String, default: 'form' })
|
||||
|
||||
const isTable = computed(() => editMode.value === 'table')
|
||||
|
||||
/* 行工厂(subtable 差异字段):新建行默认列宽按控件类型 */
|
||||
function defaultWidthOf(field) {
|
||||
const map = {
|
||||
checkbox: 90,
|
||||
number: 140,
|
||||
money: 140,
|
||||
date: 140,
|
||||
datetime: 170,
|
||||
select: 160,
|
||||
textarea: 220,
|
||||
}
|
||||
return map[field?.b_type] || 140
|
||||
}
|
||||
const createRow = createEditRowFactory({
|
||||
moduleId: props.moduleId,
|
||||
getExtra: (field) => ({ b_width: defaultWidthOf(field) }),
|
||||
})
|
||||
/** 表单配置对象(edit JSON),父级 v-model 绑定 */
|
||||
const editSchema = defineModel({ type: Object, default: () => createDefaultSchema('edit') })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormDesignEditor
|
||||
v-if="!isTable"
|
||||
v-model="editConfig"
|
||||
v-model:groups="groups"
|
||||
v-model:list-config="listConfig"
|
||||
v-model:edit-mode="editMode"
|
||||
v-model="editSchema"
|
||||
:module-id="moduleId"
|
||||
:fields="fields"
|
||||
:active="active"
|
||||
:loading="loading"
|
||||
/>
|
||||
<TableDesignEditor
|
||||
v-else
|
||||
mode="subtable"
|
||||
v-model="editConfig"
|
||||
v-model:groups="groups"
|
||||
v-model:edit-mode="editMode"
|
||||
:module-id="moduleId"
|
||||
:fields="fields"
|
||||
:create-row="createRow"
|
||||
:active="active"
|
||||
:loading="loading"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,121 +1,50 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 字段定义面板(s_field):可编辑表格维护模块字段。
|
||||
* i18n 键按 field.{moduleCode}.{fieldName} 自动同步。
|
||||
* 表单/列表/查询三列为「参与勾选」:在一行内表达该字段参与了哪些界面,
|
||||
* 勾选即改即生效(建行/删行);布局细节仍在对应设计器调整。
|
||||
* 参与是派生态(配置行存在 = 参与),物化为字段行的 __form/__list/__query
|
||||
* 虚拟字段交给 FmsTable 内置 checkbox 渲染(与「启用」列同款),change 按列路由
|
||||
* 回 toggle 写配置草稿。参与开关语义见本文件内「字段参与开关」一节。
|
||||
* V2 中 s_field 只保存业务元数据(名称 / 业务类型 / 多语言键 / 启用 / 顺序),
|
||||
* 不保存数据库类型、长度等物理结构(由查询表/视图结构决定)。
|
||||
*
|
||||
* 数据契约:字段与三份配置行、i18n 透视行、模块主表行全部走 v-model 双向绑定,
|
||||
* 本面板直接读写父级持有的对应数据(成对 ref 的当前值),不再由父级中转。
|
||||
* 表单/列表/查询三列为「参与勾选」:勾选即在对应界面配置 JSON(s_module_schema)中
|
||||
* 增删字段节点(edit 的 visible 表示表单参与),取消即移除;布局细节在对应设计器调整。
|
||||
*
|
||||
* 数据契约:字段、三份界面配置(view/edit/query JSON)、i18n 透视行、模块主表行全部走
|
||||
* v-model 双向绑定,本面板直接读写父级持有的对应数据。
|
||||
*/
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { RefreshCw } from '@lucide/vue'
|
||||
import { Button, Message } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { describeApi } from '@/services/api'
|
||||
import {
|
||||
hasSchemaField,
|
||||
hasQueryCondition,
|
||||
addSchemaField,
|
||||
removeSchemaField,
|
||||
setSchemaFieldVisible,
|
||||
addQueryCondition,
|
||||
removeQueryCondition,
|
||||
} from './components/design-editor/common/schemaModel'
|
||||
import { buildModuleItemI18nKey, syncModuleItemI18nKeys } from './utils'
|
||||
import { createEditRowFactory } from './components/design-editor/common/useStructureEditing'
|
||||
import { createListConfigRow, createQueryConfigRow } from './components/fieldDefaults'
|
||||
|
||||
/* ================= 字段参与开关(原 fieldParticipation.js 内联) =================
|
||||
* 「字段是否参与 表单/列表/查询」的判定与切换。语义沿用 fieldAreaDefaults 的
|
||||
* 项目约定(行存在 = 参与):
|
||||
* - 表单参与:editConfig 行存在且 b_visible=1;关 = 置 b_visible=0,不删行
|
||||
* (保留画布布局/栅格)
|
||||
* - 列表参与:listConfig 行存在即参与(行由用户手动管理,加载时不自动补全,
|
||||
* 开 = 建行、关 = 删行,与查询面板的行存在语义一致)
|
||||
* - 查询参与:queryConfig 行存在即参与(无 b_canuse 开关,开 = 建行、关 = 删行,
|
||||
* 与查询面板「左字段库 + 右表格」的行存在语义一致)
|
||||
*
|
||||
* 所有 toggle 返回新数组:目标行浅拷贝后修改,其余行保持原引用;
|
||||
* 行不存在且要「关」时无操作,原样返回(调用方可直接 emit)。
|
||||
*/
|
||||
// 字符串业务键:配置行与字段行以字段列名 b_field 关联
|
||||
const findParticipationRow = (rows, fieldKey) =>
|
||||
(rows || []).find((row) => String(row.b_field) === String(fieldKey))
|
||||
|
||||
/** 表单行工厂:b_colspan 24 独占一行(点击新增语义,见 FormEditPanel.insertField) */
|
||||
function createFormRow(field, moduleId, rows) {
|
||||
const row = createEditRowFactory({ moduleId: String(moduleId) })(field, null)
|
||||
// 末尾追加:b_xh 取现有最大值 + 10(工厂默认 0 会排到表单最前)
|
||||
let max = 0
|
||||
for (const item of rows || []) {
|
||||
const xh = Number(item.b_xh) || 0
|
||||
if (xh > max) max = xh
|
||||
}
|
||||
row.b_xh = max + 10
|
||||
return row
|
||||
}
|
||||
|
||||
function withRowToggled(rows, row, key) {
|
||||
return rows.map((item) => (item === row ? { ...item, [key]: Number(item[key]) === 1 ? 0 : 1 } : item))
|
||||
}
|
||||
|
||||
function isFormOn(editConfig, field) {
|
||||
const row = findParticipationRow(editConfig, field.b_field)
|
||||
return !!row && Number(row.b_visible) === 1
|
||||
}
|
||||
|
||||
function isListOn(listConfig, field) {
|
||||
return !!findParticipationRow(listConfig, field.b_field)
|
||||
}
|
||||
|
||||
function isQueryOn(queryConfig, field) {
|
||||
return !!findParticipationRow(queryConfig, field.b_field)
|
||||
}
|
||||
|
||||
/** 行不存在时追加到末尾 */
|
||||
function appendRow(rows, row) {
|
||||
return [...(rows || []), row]
|
||||
}
|
||||
|
||||
function toggleForm(editConfig, field, moduleId) {
|
||||
const rows = editConfig || []
|
||||
const row = findParticipationRow(rows, field.b_field)
|
||||
if (row) return withRowToggled(rows, row, 'b_visible')
|
||||
return appendRow(rows, createFormRow(field, moduleId, rows))
|
||||
}
|
||||
|
||||
function toggleList(listConfig, field, moduleId) {
|
||||
const rows = listConfig || []
|
||||
const row = findParticipationRow(rows, field.b_field)
|
||||
// 行存在 = 参与:取消勾选即删行;不存在 = 未参与:勾选即建默认行
|
||||
if (row) return rows.filter((item) => item !== row)
|
||||
return appendRow(rows, createListConfigRow(moduleId, field, rows))
|
||||
}
|
||||
|
||||
function toggleQuery(queryConfig, field, moduleId) {
|
||||
const rows = queryConfig || []
|
||||
const row = findParticipationRow(rows, field.b_field)
|
||||
// 行存在 = 参与:取消勾选即删行;不存在 = 未参与:勾选即建默认行
|
||||
if (row) return rows.filter((item) => item !== row)
|
||||
return appendRow(rows, createQueryConfigRow(moduleId, field, rows))
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
moduleId: { type: String, required: true },
|
||||
/** 模块类型:表单参与列仅 data 模块展示(查询模块无表单设计) */
|
||||
moduleType: { type: String, default: null },
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
/* 行内字段就地 patch,提交时换新数组引用触发 stk-table 重渲染;
|
||||
* __ 前缀虚拟字段由 diffRows / hasUnsavedChanges 忽略,不进保存请求。 */
|
||||
/** 字段定义行(s_field) */
|
||||
const fields = defineModel({ type: Array, default: () => [] })
|
||||
/** 参与勾选联动的三份配置行 */
|
||||
const editConfig = defineModel('editConfig', { type: Array, default: () => [] })
|
||||
const listConfig = defineModel('listConfig', { type: Array, default: () => [] })
|
||||
const queryConfig = defineModel('queryConfig', { type: Array, default: () => [] })
|
||||
/** 列表配置 JSON(s_module_schema.b_schema_type='view') */
|
||||
const viewSchema = defineModel('viewSchema', { type: Object, default: null })
|
||||
/** 表单配置 JSON(b_schema_type='edit') */
|
||||
const editSchema = defineModel('editSchema', { type: Object, default: null })
|
||||
/** 查询配置 JSON(b_schema_type='query') */
|
||||
const querySchema = defineModel('querySchema', { type: Object, default: null })
|
||||
/** 多语言透视行(s_i18n):字段同步时移除失效字段的受管键 */
|
||||
const i18nRows = defineModel('i18n', { type: Array, default: () => [] })
|
||||
/** 模块主表行:字段同步需要 b_viewtable(同步源)与 b_code(i18n 键前缀) */
|
||||
/** 模块主表行:字段同步需要 b_view_table(同步源)与 b_id(i18n 键前缀) */
|
||||
const moduleRow = defineModel('module', { type: Object, default: null })
|
||||
|
||||
/** 模块编码:字符串业务键下 b_id 即模块编码,是字段 i18n 键(field.{moduleCode}.{字段名})前缀 */
|
||||
/** 模块编码:b_id 即模块编码,是字段 i18n 键(field.{moduleCode}.{字段名})前缀 */
|
||||
const moduleCode = computed(() => String(moduleRow.value?.b_id || '').trim())
|
||||
|
||||
const FIELD_TYPES = [
|
||||
@@ -129,22 +58,44 @@ const FIELD_TYPES = [
|
||||
{ label: '下拉选择', value: 'select' },
|
||||
]
|
||||
|
||||
const DB_TYPES = [
|
||||
{ label: 'varchar', value: 'varchar' },
|
||||
{ label: 'nvarchar', value: 'nvarchar' },
|
||||
{ label: 'int', value: 'int' },
|
||||
{ label: 'bigint', value: 'bigint' },
|
||||
{ label: 'decimal', value: 'decimal' },
|
||||
{ label: 'date', value: 'date' },
|
||||
{ label: 'datetime2', value: 'datetime2' },
|
||||
{ label: 'bit', value: 'bit' },
|
||||
{ label: 'text', value: 'text' },
|
||||
]
|
||||
|
||||
const activeKey = ref(null)
|
||||
|
||||
/** 字段 i18n 键随模块编码/字段名变化同步:syncModuleItemI18nKeys 就地改行并返回是否变化,
|
||||
* 变化后换新数组引用触发表格重渲染(赋值会再次触发本 watch,此时已同步即停止,无循环) */
|
||||
/* ================= 字段参与开关(JSON 节点增删) ================= */
|
||||
function isFormOn(field) {
|
||||
return hasSchemaField('edit', editSchema.value, field.b_field, { requireVisible: true })
|
||||
}
|
||||
function isListOn(field) {
|
||||
return hasSchemaField('view', viewSchema.value, field.b_field)
|
||||
}
|
||||
function isQueryOn(field) {
|
||||
return hasQueryCondition(querySchema.value, field.b_field)
|
||||
}
|
||||
|
||||
// 表单:关 = 置 visible=false(保留画布布局);开 = 已有节点恢复 visible,否则追加节点
|
||||
function toggleForm(field) {
|
||||
if (isFormOn(field)) {
|
||||
editSchema.value = setSchemaFieldVisible('edit', editSchema.value, field.b_field, false)
|
||||
return
|
||||
}
|
||||
if (hasSchemaField('edit', editSchema.value, field.b_field)) {
|
||||
editSchema.value = setSchemaFieldVisible('edit', editSchema.value, field.b_field, true)
|
||||
return
|
||||
}
|
||||
editSchema.value = addSchemaField('edit', editSchema.value, field)
|
||||
}
|
||||
// 列表/查询:节点存在即参与,开 = 追加、关 = 移除
|
||||
function toggleList(field) {
|
||||
viewSchema.value = isListOn(field)
|
||||
? removeSchemaField('view', viewSchema.value, field.b_field)
|
||||
: addSchemaField('view', viewSchema.value, field)
|
||||
}
|
||||
function toggleQuery(field) {
|
||||
querySchema.value = isQueryOn(field)
|
||||
? removeQueryCondition(querySchema.value, field.b_field)
|
||||
: addQueryCondition(querySchema.value, field)
|
||||
}
|
||||
|
||||
/** 字段 i18n 键随模块编码/字段名变化同步 */
|
||||
watch(
|
||||
() => [fields.value, moduleCode.value],
|
||||
() => {
|
||||
@@ -156,18 +107,17 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 参与状态物化到字段行上的 __ 虚拟字段(就地 patch,不替换数组引用,避免整表重建),
|
||||
// 供 FmsTable 内置 checkbox 渲染;__ 前缀被 diffRows / hasUnsavedChanges 忽略,不进保存请求。
|
||||
// 勾选变化经 onCellChange 路由到 toggle 写配置草稿,随后本 watch 重算虚拟字段
|
||||
// 参与状态物化到字段行上的 __ 虚拟字段(就地 patch),供 FmsTable 内置 checkbox 渲染;
|
||||
// __ 前缀被 diffRows / hasUnsavedChanges 忽略,不进保存请求。
|
||||
const PARTICIPATION_KEYS = ['__form', '__list', '__query']
|
||||
|
||||
watch(
|
||||
() => [fields.value, editConfig.value, listConfig.value, queryConfig.value],
|
||||
() => [fields.value, viewSchema.value, editSchema.value, querySchema.value],
|
||||
() => {
|
||||
for (const row of fields.value) {
|
||||
row.__form = isFormOn(editConfig.value, row) ? 1 : 0
|
||||
row.__list = isListOn(listConfig.value, row) ? 1 : 0
|
||||
row.__query = isQueryOn(queryConfig.value, row) ? 1 : 0
|
||||
row.__form = isFormOn(row) ? 1 : 0
|
||||
row.__list = isListOn(row) ? 1 : 0
|
||||
row.__query = isQueryOn(row) ? 1 : 0
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -175,16 +125,12 @@ watch(
|
||||
|
||||
function onCellChange(row, col) {
|
||||
// 参与勾选:内置 checkbox 已把值写在字段行虚拟字段上(展示用途),
|
||||
// 真实状态经 toggle 写回对应配置草稿,不走字段 i18n 同步
|
||||
// 真实状态经 toggle 写回对应界面配置 JSON
|
||||
if (PARTICIPATION_KEYS.includes(col?.dataIndex)) {
|
||||
if (!props.loading) {
|
||||
if (col.dataIndex === '__form') {
|
||||
editConfig.value = toggleForm(editConfig.value, row, props.moduleId)
|
||||
} else if (col.dataIndex === '__list') {
|
||||
listConfig.value = toggleList(listConfig.value, row, props.moduleId)
|
||||
} else {
|
||||
queryConfig.value = toggleQuery(queryConfig.value, row, props.moduleId)
|
||||
}
|
||||
if (col.dataIndex === '__form') toggleForm(row)
|
||||
else if (col.dataIndex === '__list') toggleList(row)
|
||||
else toggleQuery(row)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -195,49 +141,38 @@ function onCellChange(row, col) {
|
||||
|
||||
const columns = shallowRef(buildColumns())
|
||||
|
||||
// 列宽拖拽结果随 v-model 写回 columns,仅在模块类型变化时重建
|
||||
watch(
|
||||
() => props.moduleType,
|
||||
() => {
|
||||
columns.value = buildColumns()
|
||||
},
|
||||
)
|
||||
|
||||
function buildColumns() {
|
||||
const baseColumns = [
|
||||
return [
|
||||
{ dataIndex: 'b_field', title: '字段名', width: 150, sorter: true },
|
||||
{ dataIndex: 'b_name', title: '字段名称', width: 140, sorter: true, editor: { type: 'input' } },
|
||||
{
|
||||
dataIndex: 'b_type',
|
||||
title: '控件类型',
|
||||
title: '业务类型',
|
||||
width: 130,
|
||||
editor: { type: 'select', options: FIELD_TYPES },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_dbtype',
|
||||
title: '数据库类型',
|
||||
width: 130,
|
||||
editor: { type: 'select', options: DB_TYPES },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_length',
|
||||
title: '长度',
|
||||
width: 80,
|
||||
editor: { type: 'input', inputType: 'number' },
|
||||
},
|
||||
{ dataIndex: 'b_default_value', title: '默认值', width: 120, editor: { type: 'input' } },
|
||||
{ dataIndex: 'b_i18n', title: '多语言键', width: 220 },
|
||||
]
|
||||
// 参与勾选列:内置 checkbox editor(居中与交互隔离随「启用」列同款),
|
||||
// 表单列仅数据模块展示(查询模块无表单设计)
|
||||
const participationColumns = [
|
||||
...(props.moduleType === 'data'
|
||||
? [{ dataIndex: '__form', title: '表单', width: 70, align: 'center', editor: { type: 'checkbox' } }]
|
||||
: []),
|
||||
{ dataIndex: '__list', title: '列表', width: 70, align: 'center', editor: { type: 'checkbox' } },
|
||||
{ dataIndex: '__query', title: '查询', width: 70, align: 'center', editor: { type: 'checkbox' } },
|
||||
]
|
||||
const tailColumns = [
|
||||
{
|
||||
dataIndex: '__form',
|
||||
title: '表单',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{
|
||||
dataIndex: '__list',
|
||||
title: '列表',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{
|
||||
dataIndex: '__query',
|
||||
title: '查询',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_canuse',
|
||||
title: '启用',
|
||||
@@ -246,58 +181,64 @@ function buildColumns() {
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
]
|
||||
return [...baseColumns, ...participationColumns, ...tailColumns]
|
||||
}
|
||||
|
||||
/* ================= 字段同步(按数据库视图结构对齐字段定义) =================
|
||||
* 同步只合并字段定义与移除已消失字段的配置行/i18n,不自动生成任何配置行——
|
||||
* 列表/查询配置为手动管理(行存在即展示),表单设计由用户逐个添加。
|
||||
* 计算同步计划(纯计算,不改草稿):describe 结果与现有字段合并、找出视图已不存在的字段。
|
||||
/* ================= 字段同步(按数据库查询表/视图结构对齐字段定义) =================
|
||||
* 同步只合并字段定义与移除已消失字段的界面配置节点/i18n,不自动生成任何布局配置行。
|
||||
*/
|
||||
function buildSyncPlan(module, columns) {
|
||||
const viewColumnSet = new Set(columns.map((col) => String(col.name || '').toLowerCase()))
|
||||
function guessFieldType(dbType) {
|
||||
const type = String(dbType || '').toLowerCase()
|
||||
if (type.includes('char') && (type.includes('max') || type.includes('text'))) return 'textarea'
|
||||
if (type.includes('text')) return 'textarea'
|
||||
if (
|
||||
type.includes('int') ||
|
||||
type.includes('decimal') ||
|
||||
type.includes('numeric') ||
|
||||
type.includes('float') ||
|
||||
type.includes('money')
|
||||
) {
|
||||
return 'number'
|
||||
}
|
||||
if (type.includes('datetime')) return 'datetime'
|
||||
if (type.includes('date')) return 'date'
|
||||
if (type.includes('bit')) return 'checkbox'
|
||||
return 'input'
|
||||
}
|
||||
|
||||
function buildSyncPlan(module, dbColumns) {
|
||||
const viewColumnSet = new Set(dbColumns.map((col) => String(col.name || '').toLowerCase()))
|
||||
const existingMap = new Map()
|
||||
for (const field of fields.value || []) {
|
||||
if (field.b_field) existingMap.set(field.b_field.toLowerCase(), field)
|
||||
}
|
||||
const mergedFields = []
|
||||
let addedCount = 0
|
||||
// describe 返回列顺序即数据库列顺序;同步时按列序统一重排并回写 b_xh,
|
||||
// 使字段定义顺序与视图列一致(保存后仍按 b_xh 排序加载,不会被字母序打乱)
|
||||
columns.forEach((col, index) => {
|
||||
// describe 返回列顺序即数据库列顺序;同步时按列序统一重排并回写 b_xh
|
||||
dbColumns.forEach((col, index) => {
|
||||
const xh = (index + 1) * 10
|
||||
const existing = existingMap.get(String(col.name).toLowerCase())
|
||||
if (existing) {
|
||||
mergedFields.push({ ...existing, b_canuse: 1, b_xh: xh })
|
||||
} else {
|
||||
addedCount++
|
||||
// 字符串业务键:s_field 主键即 (b_module_id, b_field),行不再需要雪花 b_id
|
||||
mergedFields.push({
|
||||
b_module_id: String(module.b_id),
|
||||
b_field: col.name,
|
||||
b_name: col.name,
|
||||
b_i18n: null,
|
||||
b_type: 'input',
|
||||
b_dbtype: col.type || null,
|
||||
b_length: col.size ?? null,
|
||||
b_nullable: col.nullable ?? 1,
|
||||
b_default_value: null,
|
||||
// NOT NULL 无后端补全的列显式给默认值(避免依赖数据库 DEFAULT 缺失时报 NULL)
|
||||
b_writemode: 'direct',
|
||||
b_type: guessFieldType(col.type),
|
||||
b_canuse: 1,
|
||||
b_xh: xh,
|
||||
})
|
||||
}
|
||||
})
|
||||
// 视图中已不存在的字段:应用时移除并级联清理配置行与受管 i18n
|
||||
// 视图中已不存在的字段:应用时移除并级联清理界面配置节点与受管 i18n
|
||||
const removedFields = new Set()
|
||||
// 受管多语言的 _managedId 是 `field:${i18nKey}`,不是字段 b_id
|
||||
const removedI18nIds = new Set()
|
||||
for (const existing of fields.value || []) {
|
||||
if (!viewColumnSet.has(String(existing.b_field).toLowerCase())) {
|
||||
removedFields.add(String(existing.b_field))
|
||||
const key =
|
||||
existing.b_i18n || buildModuleItemI18nKey('field', module.b_id, existing.b_field)
|
||||
const key = existing.b_i18n || buildModuleItemI18nKey('field', module.b_id, existing.b_field)
|
||||
if (key) removedI18nIds.add(`field:${key}`)
|
||||
}
|
||||
}
|
||||
@@ -309,11 +250,10 @@ function buildSyncPlan(module, columns) {
|
||||
|
||||
function applySyncPlan(plan) {
|
||||
if (plan.removedFields.size) {
|
||||
// 失效字段在列表/编辑/查询配置中的行一并移除(行以 b_field 关联字段)
|
||||
for (const config of [listConfig, editConfig, queryConfig]) {
|
||||
config.value = (config.value || []).filter(
|
||||
(row) => !plan.removedFields.has(String(row.b_field)),
|
||||
)
|
||||
for (const fieldId of plan.removedFields) {
|
||||
viewSchema.value = removeSchemaField('view', viewSchema.value, fieldId)
|
||||
editSchema.value = removeSchemaField('edit', editSchema.value, fieldId)
|
||||
querySchema.value = removeQueryCondition(querySchema.value, fieldId)
|
||||
}
|
||||
// 失效字段的受管 i18n 行(_managedId 为 `field:${key}`)一并移除
|
||||
const removedManaged = new Set(Array.from(plan.removedI18nIds).map(String))
|
||||
@@ -330,7 +270,7 @@ const syncing = ref(false)
|
||||
async function syncFields() {
|
||||
const module = moduleRow.value
|
||||
if (!module) return
|
||||
const view = String(module.b_viewtable || '').trim()
|
||||
const view = String(module.b_view_table || '').trim()
|
||||
if (!view || props.loading) return
|
||||
syncing.value = true
|
||||
try {
|
||||
@@ -340,8 +280,6 @@ async function syncFields() {
|
||||
Message.info('字段与视图结构一致,无需同步')
|
||||
return
|
||||
}
|
||||
// 同步只改草稿、不落库(保存前可整体撤销),直接执行;
|
||||
// 差异明细由 applySyncPlan 的成功提示汇总
|
||||
applySyncPlan(plan)
|
||||
} catch (e) {
|
||||
Message.error('同步字段失败:' + (e?.message || e))
|
||||
@@ -353,12 +291,12 @@ async function syncFields() {
|
||||
|
||||
<template>
|
||||
<div class="mm-fields">
|
||||
<div v-if="moduleRow?.b_viewtable" class="mm-fields__toolbar">
|
||||
<div v-if="moduleRow?.b_view_table" class="mm-fields__toolbar">
|
||||
<Button
|
||||
type="outline"
|
||||
:disabled="loading"
|
||||
:loading="syncing"
|
||||
title="按数据库视图结构对齐字段定义"
|
||||
title="按数据库查询表/视图结构对齐字段定义"
|
||||
@click="syncFields"
|
||||
>
|
||||
<RefreshCw :size="14" /> 同步字段
|
||||
|
||||
@@ -37,17 +37,17 @@ function isManagedRow(row) {
|
||||
|
||||
async function addRow() {
|
||||
if (props.loading) return
|
||||
const newId = createTempId()
|
||||
rows.value = [...rows.value, { _id: newId, b_key: '', _localeIds: {} }]
|
||||
activeKey.value = String(newId)
|
||||
const newKey = createTempId()
|
||||
rows.value = [...rows.value, { _rowKey: newKey, b_key: '', _persistedLocales: new Set() }]
|
||||
activeKey.value = String(newKey)
|
||||
await nextTick()
|
||||
tableRef.value?.editRow?.(String(newId), 'b_key')
|
||||
tableRef.value?.editRow?.(String(newKey), 'b_key')
|
||||
}
|
||||
|
||||
function removeRow(row) {
|
||||
if (isManagedRow(row)) return
|
||||
rows.value = rows.value.filter((r) => r !== row)
|
||||
selectedRowKeys.value = selectedRowKeys.value.filter((x) => x !== String(row._id))
|
||||
selectedRowKeys.value = selectedRowKeys.value.filter((x) => x !== String(row._rowKey))
|
||||
}
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
@@ -63,12 +63,12 @@ function batchDelete() {
|
||||
const keys = selectedRowKeys.value
|
||||
if (!keys.length) return
|
||||
const delSet = new Set(keys)
|
||||
const deletable = rows.value.filter((r) => delSet.has(String(r._id)) && !isManagedRow(r))
|
||||
const deletable = rows.value.filter((r) => delSet.has(String(r._rowKey)) && !isManagedRow(r))
|
||||
if (!deletable.length) return
|
||||
const delIds = new Set(deletable.map((r) => String(r._id)))
|
||||
rows.value = rows.value.filter((r) => !delIds.has(String(r._id)))
|
||||
const delKeys = new Set(deletable.map((r) => String(r._rowKey)))
|
||||
rows.value = rows.value.filter((r) => !delKeys.has(String(r._rowKey)))
|
||||
selectedRowKeys.value = []
|
||||
if (activeKey.value && delIds.has(String(activeKey.value))) activeKey.value = null
|
||||
if (activeKey.value && delKeys.has(String(activeKey.value))) activeKey.value = null
|
||||
}
|
||||
|
||||
/* ================= 列定义 ================= */
|
||||
@@ -129,9 +129,9 @@ const columns = computed(() => {
|
||||
:sort-config="{ emptyToBottom: true, stringLocaleCompare: true }"
|
||||
:editable="!loading"
|
||||
:row-selection="rowSelection"
|
||||
row-key="_id"
|
||||
row-key="_rowKey"
|
||||
:active-row-key="activeKey"
|
||||
@row-click="(_, row) => (activeKey = String(row._id))"
|
||||
@row-click="(_, row) => (activeKey = String(row._rowKey))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 列表配置面板壳组件(s_field_view)。
|
||||
* 列表配置面板(s_module_schema.b_schema_type = 'view')。
|
||||
* 实际编辑器为共享组件 components/design-editor/TableDesignEditor.vue(mode="list"),
|
||||
* 本壳只做 props/emits 接线 + 行工厂注入(列表行默认属性来自业务层 fieldDefaults.js)。
|
||||
* 列表配置为手动管理(行存在即展示,删除即真删),由编辑器按 mode 处理。
|
||||
* 本壳只做 props/接线 + 行工厂注入(列表行默认属性来自业务层 fieldDefaults.js)。
|
||||
* 列表配置为手动管理(节点存在即展示,删除即真删)。
|
||||
*/
|
||||
import TableDesignEditor from './components/design-editor/TableDesignEditor.vue'
|
||||
import { listRowDefaults } from './components/fieldDefaults'
|
||||
import { createTempId } from '@/utils/common'
|
||||
import { createDefaultSchema } from './utils'
|
||||
|
||||
const props = defineProps({
|
||||
moduleId: { type: String, required: true },
|
||||
@@ -15,13 +16,11 @@ const props = defineProps({
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
/** 列表配置行(s_field_view),父级 v-model 绑定 */
|
||||
const listConfig = defineModel({ type: Array, default: () => [] })
|
||||
/** 分组行(s_field_group),父级 v-model:groups 绑定 */
|
||||
const groups = defineModel('groups', { type: Array, default: () => [] })
|
||||
/** 列表配置对象(view JSON),父级 v-model 绑定 */
|
||||
const viewSchema = defineModel({ type: Object, default: () => createDefaultSchema('view') })
|
||||
|
||||
/* 行工厂(list 差异字段):列表行默认属性来自业务层;
|
||||
* 行无雪花主键:_rowKey 本地行 key,业务键为 b_module_id+b_field */
|
||||
* 行无业务主键:_rowKey 本地行 key,字段身份为 b_field */
|
||||
const createRow = (field, groupId) => ({
|
||||
_rowKey: createTempId(),
|
||||
b_module_id: props.moduleId,
|
||||
@@ -35,8 +34,7 @@ const createRow = (field, groupId) => ({
|
||||
<template>
|
||||
<TableDesignEditor
|
||||
mode="list"
|
||||
v-model="listConfig"
|
||||
v-model:groups="groups"
|
||||
v-model="viewSchema"
|
||||
:module-id="moduleId"
|
||||
:fields="fields"
|
||||
:create-row="createRow"
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 模块权限面板(s_power):维护当前模块的权限点(仅 data 模块使用)。
|
||||
*
|
||||
* 按《FMS新系统核心表结构设计》第 14 节:
|
||||
* - 权限点挂在模块上:b_object_type='module'、b_object_id=当前模块编码;
|
||||
* - 模块使用权限整模块一个点:b_action='access',权限编码 module.{模块编码}(不带动作后缀);
|
||||
* 标准操作 read / create / update / delete / export 不再各自拆分权限点;
|
||||
* - 业务动作(审核 / 反审 / ...):b_type='action',权限编码 action.{模块编码}.{动作};
|
||||
* - b_type 由动作派生、不手工选择;权限编码 b_id 与多语言键 b_i18n 均派生、不手工编辑;
|
||||
* b_i18n 复用共用资源键:模块行用 module.{模块编码}(模块名),动作行用 action.{动词}
|
||||
* (按钮 + 权限共用,跨模块公用,不随单个模块删除);
|
||||
* - 菜单权限(menu.*)挂在菜单上,不在此维护。
|
||||
*
|
||||
* 数据契约:默认 v-model 与父级 powers 双向绑定;父级另持 powers_org 基线用于保存 diff。
|
||||
*/
|
||||
import { nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import { Plus, Trash2 } from '@lucide/vue'
|
||||
import { Button, Message } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { createTempId } from '@/utils/common'
|
||||
import { MODULE_ACCESS_ACTION, buildPowerI18nKey, typeOfAction } from './utils'
|
||||
|
||||
const props = defineProps({
|
||||
/** 当前模块编码:权限点的挂载对象(b_object_id) */
|
||||
moduleId: { type: String, default: '' },
|
||||
/** 当前模块名称:模块使用权限点的默认名称(多语言复用模块名称键) */
|
||||
moduleName: { type: String, default: '' },
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
/** 当前模块权限点行(s_power),父级 v-model 绑定 powers */
|
||||
const powers = defineModel({ type: Array, default: () => [] })
|
||||
|
||||
/** 权限编码派生:模块使用权限为 module.{模块编码}(不带动作后缀);业务动作为 action.{模块编码}.{动作} */
|
||||
function powerCodeOf(row) {
|
||||
const action = String(row?.b_action || '').trim()
|
||||
const object = String(props.moduleId || '').trim()
|
||||
if (!action || !object) return ''
|
||||
return typeOfAction(action) === 'module' ? `module.${object}` : `action.${object}.${action}`
|
||||
}
|
||||
|
||||
/** 多语言键派生:模块使用权限复用模块名称键;业务动作复用共用动词键(跨模块公用) */
|
||||
function powerI18nKeyOf(row) {
|
||||
return buildPowerI18nKey(row?.b_action, props.moduleId)
|
||||
}
|
||||
|
||||
/** 动作 → 默认权限名称(仅名称留空时填充,不覆盖人工输入) */
|
||||
const ACTION_NAMES = {
|
||||
read: '查看',
|
||||
create: '新增',
|
||||
update: '修改',
|
||||
delete: '删除',
|
||||
export: '导出',
|
||||
audit: '审核',
|
||||
unaudit: '反审',
|
||||
}
|
||||
|
||||
/**
|
||||
* 行规范化:补本地行键、回填挂载对象与显示顺序、派生权限编码,并按编码去重;返回是否发生了修改。
|
||||
* b_i18n 不在此派生(避免打开既有数据即变脏),由新增/编辑时和父级保存前物化。
|
||||
*/
|
||||
function normalizeRows(rows) {
|
||||
let changed = false
|
||||
;(rows || []).forEach((row, index) => {
|
||||
if (!row._rowKey) {
|
||||
row._rowKey = createTempId()
|
||||
changed = true
|
||||
}
|
||||
if (String(row.b_object_type || '') !== 'module') {
|
||||
row.b_object_type = 'module'
|
||||
changed = true
|
||||
}
|
||||
const objectId = String(props.moduleId || '')
|
||||
if (String(row.b_object_id || '') !== objectId) {
|
||||
row.b_object_id = objectId
|
||||
changed = true
|
||||
}
|
||||
const xh = (index + 1) * 10
|
||||
if (Number(row.b_xh) !== xh) {
|
||||
row.b_xh = xh
|
||||
changed = true
|
||||
}
|
||||
const type = typeOfAction(row.b_action)
|
||||
if (row.b_type !== type) {
|
||||
row.b_type = type
|
||||
changed = true
|
||||
}
|
||||
const code = powerCodeOf(row)
|
||||
if (code && row.b_id !== code) {
|
||||
row.b_id = code
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
// 派生编码重复的行只保留第一条,其余清空编码(保存时跳过),避免同一权限点出现多行
|
||||
const seenCodes = new Set()
|
||||
rows.forEach((row) => {
|
||||
const code = String(row.b_id || '').trim()
|
||||
if (!code) return
|
||||
if (seenCodes.has(code)) {
|
||||
row.b_id = ''
|
||||
changed = true
|
||||
return
|
||||
}
|
||||
seenCodes.add(code)
|
||||
})
|
||||
return changed
|
||||
}
|
||||
|
||||
watch(
|
||||
() => powers.value,
|
||||
(rows) => {
|
||||
if (normalizeRows(rows)) powers.value = [...rows]
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
const columns = shallowRef([
|
||||
{ dataIndex: 'b_id', title: '权限编码', width: 190 },
|
||||
{ dataIndex: 'b_name', title: '权限名称', width: 120, editor: { type: 'input' } },
|
||||
{ dataIndex: 'b_action', title: '动作', width: 110, editor: { type: 'input' } },
|
||||
{
|
||||
dataIndex: 'b_canuse',
|
||||
title: '启用',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{ dataIndex: 'b_i18n', title: '多语言键', width: 170 },
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '操作',
|
||||
align: 'center',
|
||||
width: 70,
|
||||
actions: [
|
||||
{
|
||||
type: 'delete',
|
||||
label: '删除',
|
||||
onClick: (row) => removeRow(row),
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const tableRef = ref(null)
|
||||
|
||||
/** 动作变化后重算权限性质、权限编码(主键)与多语言键;名称留空时补默认动作名 */
|
||||
function onCellChange(row, col) {
|
||||
if (col?.dataIndex === 'b_action') {
|
||||
if (!String(row.b_name || '').trim()) {
|
||||
row.b_name =
|
||||
ACTION_NAMES[row.b_action] ||
|
||||
(row.b_action === MODULE_ACCESS_ACTION ? props.moduleName || '模块使用' : '')
|
||||
}
|
||||
row.b_type = typeOfAction(row.b_action)
|
||||
row.b_id = powerCodeOf(row)
|
||||
const i18nKey = powerI18nKeyOf(row)
|
||||
if (i18nKey) row.b_i18n = i18nKey
|
||||
}
|
||||
// 行对象已就地 patch,换新数组引用触发表格重渲染
|
||||
powers.value = [...powers.value]
|
||||
}
|
||||
|
||||
function makeRow(action) {
|
||||
const row = {
|
||||
_rowKey: createTempId(),
|
||||
b_object_type: 'module',
|
||||
b_object_id: String(props.moduleId || ''),
|
||||
b_action: action || '',
|
||||
b_name: ACTION_NAMES[action] || '',
|
||||
b_canuse: 1,
|
||||
b_bz: null,
|
||||
}
|
||||
if (action === MODULE_ACCESS_ACTION && !row.b_name) {
|
||||
row.b_name = props.moduleName || '模块使用'
|
||||
}
|
||||
row.b_type = typeOfAction(row.b_action)
|
||||
row.b_id = powerCodeOf(row)
|
||||
const i18nKey = powerI18nKeyOf(row)
|
||||
if (i18nKey) row.b_i18n = i18nKey
|
||||
return row
|
||||
}
|
||||
|
||||
/** 新增空动作行并直接进入动作单元格编辑:动作决定权限编码,必须先填 */
|
||||
async function addRow() {
|
||||
if (props.loading) return
|
||||
const row = makeRow('')
|
||||
powers.value = [...powers.value, row]
|
||||
await nextTick()
|
||||
tableRef.value?.editRow?.(String(row._rowKey), 'b_action')
|
||||
}
|
||||
|
||||
/** 一键补齐模块使用权限:模块权限整模块一个点(module.{编码}),已存在时跳过 */
|
||||
async function addStandardActions() {
|
||||
if (props.loading) return
|
||||
if ((powers.value || []).some((row) => row.b_type === 'module')) {
|
||||
Message.info('模块使用权限已存在,无需补齐')
|
||||
return
|
||||
}
|
||||
powers.value = [...powers.value, makeRow(MODULE_ACCESS_ACTION)]
|
||||
Message.success('已补齐模块使用权限')
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function removeRow(row) {
|
||||
powers.value = powers.value.filter((item) => item !== row)
|
||||
Message.success('已删除权限点')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mm-power">
|
||||
<div class="mm-power__toolbar">
|
||||
<Button type="outline" :disabled="loading" @click="addStandardActions">
|
||||
补齐模块使用权限
|
||||
</Button>
|
||||
<Button type="primary" :disabled="loading" @click="addRow">
|
||||
<Plus :size="14" /> 新增权限
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="loading" class="mm-power__state">加载中...</div>
|
||||
<div v-else class="mm-power__table">
|
||||
<FmsTable
|
||||
ref="tableRef"
|
||||
v-model:columns="columns"
|
||||
:data-source="powers"
|
||||
:editable="!loading"
|
||||
row-key="_rowKey"
|
||||
:col-resizable="true"
|
||||
@change="onCellChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mm-power {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--fms-card);
|
||||
}
|
||||
|
||||
.mm-power__toolbar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mm-power__state {
|
||||
padding: 24px 0;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mm-power__table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +1,25 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 查询配置面板(s_field_query):左字段库 + 右查询条件表格。
|
||||
* 参与语义:行存在即参与(无单独「启用」开关)——字段库点击加入/定位、
|
||||
* 点击 ✓ 或删除行即移除;删错的行可从字段库随时加回(生成默认组件/操作符)。
|
||||
* 查询配置面板(s_module_schema.b_schema_type = 'query'):左字段库 + 右查询条件表格。
|
||||
* 参与语义:条件节点存在即参与(无单独「启用」开关)——字段库点击加入/定位、
|
||||
* 点击删除行即移除;删错的可从字段库随时加回(生成默认组件/操作符)。
|
||||
*
|
||||
* 查询配置不自动生成:同步字段时不会为任何字段生成查询行(列表/表单设计不同),
|
||||
* 查询条件完全由用户在字段库手动点击加入。
|
||||
*
|
||||
* 交互:
|
||||
* - 左字段库(复用 design-editor 的 FieldLibrary):未加入的字段点击即按字段
|
||||
* 控件类型生成默认查询行;已加入的显示 ✓,点击定位到表格对应行。
|
||||
* - 右表格:新增查询条件(手动选字段)、编辑 查询组件/操作符/默认值、
|
||||
* 行拖拽排序、删除行;选字段时自动重置组件/操作符为字段类型默认值。
|
||||
* 数据契约:对外为 query JSON(父级 v-model),内部经 schemaModel 适配为条件行草稿;
|
||||
* quick 等其余 JSON 属性原样保留。
|
||||
*/
|
||||
import { computed, nextTick, ref, shallowRef } from 'vue'
|
||||
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import { Plus, Trash2 } from '@lucide/vue'
|
||||
import { Button, Message } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { createTempId } from '@/utils/common'
|
||||
import {
|
||||
querySchemaToRows,
|
||||
rowsToQuerySchema,
|
||||
stableStringify,
|
||||
} from './components/design-editor/common/schemaModel'
|
||||
import FieldLibrary from './components/design-editor/common/FieldLibrary.vue'
|
||||
import { createQueryConfigRow, queryRowDefaults } from './components/fieldDefaults'
|
||||
import { createDefaultSchema } from './utils'
|
||||
|
||||
const props = defineProps({
|
||||
moduleId: { type: String, required: true },
|
||||
@@ -27,9 +27,31 @@ const props = defineProps({
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
/** 查询配置行(s_field_query),父级 v-model 绑定 query_config。
|
||||
* 行内字段就地 patch,提交时换新数组引用触发 stk-table 重渲染。 */
|
||||
const queryConfig = defineModel({ type: Array, default: () => [] })
|
||||
/** 查询配置对象(query JSON),父级 v-model 绑定 */
|
||||
const querySchema = defineModel({ type: Object, default: () => createDefaultSchema('query') })
|
||||
|
||||
/** 内部条件行草稿:由 querySchema 适配,变更回写 querySchema */
|
||||
const queryConfig = ref([])
|
||||
let syncedSchema = null
|
||||
watch(
|
||||
querySchema,
|
||||
(val) => {
|
||||
if (val === syncedSchema) return
|
||||
queryConfig.value = querySchemaToRows(val || createDefaultSchema('query'), props.moduleId)
|
||||
syncedSchema = val
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
watch(
|
||||
queryConfig,
|
||||
() => {
|
||||
const next = rowsToQuerySchema(queryConfig.value, querySchema.value)
|
||||
if (stableStringify(next) === stableStringify(querySchema.value)) return
|
||||
syncedSchema = next
|
||||
querySchema.value = next
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const COMPONENT_OPTIONS = [
|
||||
{ label: '输入框', value: 'input' },
|
||||
@@ -103,14 +125,12 @@ async function addRow() {
|
||||
if (props.loading) return
|
||||
const newKey = createTempId()
|
||||
// stk-table 对同一引用数组的 push 不敏感(内部 shallowRef + 引用 watch),必须换新引用才会渲染新行
|
||||
// 行无雪花主键:_rowKey 为本地行 key(_ 前缀不提交),业务键 b_field 由用户选定
|
||||
queryConfig.value = [
|
||||
...queryConfig.value,
|
||||
{
|
||||
_rowKey: newKey,
|
||||
b_module_id: props.moduleId,
|
||||
b_field: null,
|
||||
b_condition_no: 1,
|
||||
b_component: 'input',
|
||||
b_operator: 'like',
|
||||
b_default_value: null,
|
||||
|
||||
+408
-353
@@ -9,17 +9,7 @@
|
||||
* 编辑方式(主表表单/子表表格)切换器在头部,变更经 update:editMode 上抛由父壳路由。
|
||||
*/
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
Trash2,
|
||||
Asterisk,
|
||||
Eye,
|
||||
EyeOff,
|
||||
GripVertical,
|
||||
Check,
|
||||
X,
|
||||
Plus,
|
||||
Settings2,
|
||||
} from '@lucide/vue'
|
||||
import { Trash2, Asterisk, Eye, EyeOff, GripVertical, Check, X, Plus, Settings2 } from '@lucide/vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import {
|
||||
Message,
|
||||
@@ -65,6 +55,8 @@ import {
|
||||
sectionEndIndex,
|
||||
COLSPAN_OPTIONS,
|
||||
} from './form/useFormGrid'
|
||||
import { schemaToEditor, editorToSchema, stableStringify } from './common/schemaModel'
|
||||
import { createDefaultSchema } from '../../utils'
|
||||
|
||||
const props = defineProps({
|
||||
moduleId: { type: String, required: true },
|
||||
@@ -75,29 +67,57 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
/* 数据全部走 v-model 双向绑定:本编辑器直接读写父级持有的对应数据。 */
|
||||
/** 编辑行数组(s_field_edit 草稿,主表表单),父级 v-model 绑定 */
|
||||
const rows = defineModel({ type: Array, default: () => [] })
|
||||
/** 分组行数组(s_field_group),父级 v-model:groups 绑定 */
|
||||
const groups = defineModel('groups', { type: Array, default: () => [] })
|
||||
/** 列表配置行(s_field_view),父级 v-model:list-config 绑定(删除分组时清理分组引用) */
|
||||
const listConfig = defineModel('listConfig', { type: Array, default: () => [] })
|
||||
/** 模块编辑方式:form 主表(表单画布设计)| table 子表(表格列设计),父级 v-model:edit-mode 绑定 */
|
||||
const editMode = defineModel('editMode', { type: String, default: 'form' })
|
||||
/**
|
||||
* 界面配置对象(edit JSON),父级 v-model 绑定。
|
||||
* 编辑器内部仍以「行 + 分组」草稿工作,经 schemaModel 适配层与 JSON 节点互转;
|
||||
* 对外数据模型即 s_module_schema.b_schema_json 对象。
|
||||
*/
|
||||
const schemaModel = defineModel({ type: Object, default: () => createDefaultSchema('edit') })
|
||||
/** 编辑器内部行草稿(由 schemaModel 适配,变更回写 schemaModel) */
|
||||
const rows = ref([])
|
||||
/** 编辑器内部分组草稿 */
|
||||
const groups = ref([])
|
||||
/** 列表配置行:分组已内聚到各配置 JSON,不再跨配置引用,保留空数组兼容内部清理逻辑 */
|
||||
const listConfig = ref([])
|
||||
|
||||
// 加载:配置对象 → 内部行/分组草稿(避免把父级回传的同一对象再解析一遍)
|
||||
let syncedSchema = null
|
||||
watch(
|
||||
schemaModel,
|
||||
(val) => {
|
||||
if (val === syncedSchema) return
|
||||
const parsed = schemaToEditor('edit', val || createDefaultSchema('edit'), props.moduleId)
|
||||
rows.value = parsed.rows
|
||||
groups.value = parsed.groups
|
||||
syncedSchema = val
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 变更:内部草稿 → 配置对象。先与「当前配置的规范化结果」比较,
|
||||
// 空白加载后的规范化差异不回写(避免打开即脏);内容一致则不动,避免循环。
|
||||
watch(
|
||||
[rows, groups],
|
||||
() => {
|
||||
const current = schemaModel.value || createDefaultSchema('edit')
|
||||
const parsedCurrent = schemaToEditor('edit', current, props.moduleId)
|
||||
const normalizedCurrent = editorToSchema(
|
||||
'edit',
|
||||
parsedCurrent.rows,
|
||||
parsedCurrent.groups,
|
||||
current,
|
||||
)
|
||||
const next = editorToSchema('edit', rows.value, groups.value, current)
|
||||
if (stableStringify(next) === stableStringify(normalizedCurrent)) return
|
||||
syncedSchema = next
|
||||
schemaModel.value = next
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const localEdit = ref([])
|
||||
const activeKey = ref(null)
|
||||
|
||||
function onEditModeChange(value) {
|
||||
if (props.loading) return
|
||||
editMode.value = value || 'form'
|
||||
}
|
||||
|
||||
/* ================= 头部切换器选项(ui Segmented) ================= */
|
||||
// 编辑方式的 title 由 #label 插槽挂到 span,保留原生悬浮说明
|
||||
const EDIT_MODE_OPTIONS = [
|
||||
{ label: '主表表单', value: 'form', title: '主表:以表单形式编辑' },
|
||||
{ label: '子表表格', value: 'table', title: '子表:以表格形式编辑' },
|
||||
]
|
||||
const VIEW_OPTIONS = [
|
||||
{ label: '设计', value: 'design' },
|
||||
{ label: '数据', value: 'data' },
|
||||
@@ -146,8 +166,7 @@ function colsOf(key) {
|
||||
|
||||
function setSectionCols(key, val) {
|
||||
const n = Math.round(Number(val))
|
||||
sectionCols[key] =
|
||||
Number.isFinite(n) && n > 0 ? Math.min(Math.max(n, 1), GROUP_GRID) : GROUP_GRID
|
||||
sectionCols[key] = Number.isFinite(n) && n > 0 ? Math.min(Math.max(n, 1), GROUP_GRID) : GROUP_GRID
|
||||
// 同步写回分组对象(b_colspan),交由父级进入待保存状态;未分组无持久化对象
|
||||
if (key !== 'ungrouped') {
|
||||
const gid = key.slice(2)
|
||||
@@ -162,7 +181,9 @@ function setSectionCols(key, val) {
|
||||
const sectionSettingsKey = ref(null)
|
||||
const sectionSettingsOpen = computed({
|
||||
get: () => sectionSettingsKey.value !== null,
|
||||
set: (v) => { if (!v) sectionSettingsKey.value = null },
|
||||
set: (v) => {
|
||||
if (!v) sectionSettingsKey.value = null
|
||||
},
|
||||
})
|
||||
const sectionSettings = reactive({ cols: GROUP_GRID, fieldCols: 24 })
|
||||
|
||||
@@ -193,8 +214,7 @@ function fieldColsOf(key) {
|
||||
|
||||
function setSectionFieldCols(key, val) {
|
||||
const n = Math.round(Number(val))
|
||||
sectionFieldCols[key] =
|
||||
Number.isFinite(n) && n > 0 ? Math.min(Math.max(n, 1), 24) : 24
|
||||
sectionFieldCols[key] = Number.isFinite(n) && n > 0 ? Math.min(Math.max(n, 1), 24) : 24
|
||||
}
|
||||
|
||||
/* ================= 设计工作区:属性面板折叠 ================= */
|
||||
@@ -211,6 +231,11 @@ const TEXTAREA_HEIGHT_OPTIONS = [
|
||||
之后继续移动不再重算意图,松手后不做任何变更(取消真正生效) */
|
||||
let dragCancelled = false
|
||||
|
||||
/* 拖拽中鼠标是否离开过卡片本体:用于区分「真的拖到卡片外」与「只是按下没移动」。
|
||||
只有离开过卡片(落在行间 / 分组空白上)才允许按分区末尾放置,
|
||||
避免单击卡片(未移动)被当成一次拖拽、把字段莫名挪到行尾 */
|
||||
let dragLeftCard = false
|
||||
|
||||
function onDragKeydown(evt) {
|
||||
if (evt.key === 'Escape') {
|
||||
dragCancelled = true
|
||||
@@ -279,8 +304,7 @@ function formDataColumns() {
|
||||
dataIndex: 'b_field',
|
||||
title: '字段',
|
||||
width: 180,
|
||||
customCell: ({ row }) =>
|
||||
fieldNameMap.value.get(String(row.b_field)) || row.b_field || '',
|
||||
customCell: ({ row }) => fieldNameMap.value.get(String(row.b_field)) || row.b_field || '',
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_visible',
|
||||
@@ -737,6 +761,10 @@ function enableTextSelection() {
|
||||
function onFieldDragStart(evt) {
|
||||
dragPayload.value = { kind: 'card' }
|
||||
dragSourceId.value = String(evt?.item?.dataset?.fieldId || '')
|
||||
// 放置意图初值 = 被拖字段所在分区:鼠标只拖出卡片一点点就松手(落在分组内/外的
|
||||
// 空白上)时,仍按「移到该分区末尾」处理,而不是被判为无意图而取消
|
||||
dragOverSection.value = sectionKeyOfField(dragSourceId.value)
|
||||
dragLeftCard = false
|
||||
createDragBadge(evt.item)
|
||||
window.addEventListener('keydown', onDragKeydown)
|
||||
window.addEventListener('mousemove', onDragShadowMove)
|
||||
@@ -766,8 +794,12 @@ function updateDragIntent(evt) {
|
||||
return
|
||||
}
|
||||
|
||||
/* 悬停分区:命中分组卡片时更新;在画布内但落在分组之外的空白(分组下方 / 分组
|
||||
之间的间隙)时保持不变——沿用最后一次命中的分区(初始为被拖字段所在分区),
|
||||
松手后在该分区末尾新起一行。只有鼠标离开画布(上方早退分支)才清空:
|
||||
分组仅一行字段时卡片很矮,往下拖极易出界,清空会让拖拽表现为「什么都没发生」 */
|
||||
const sectionEl = hoverEl.closest('[data-section-key]')
|
||||
dragOverSection.value = sectionEl ? sectionEl.getAttribute('data-section-key') : null
|
||||
if (sectionEl) dragOverSection.value = sectionEl.getAttribute('data-section-key')
|
||||
|
||||
const card = hoverEl.closest('[data-field-id]')
|
||||
const selfId =
|
||||
@@ -776,6 +808,7 @@ function updateDragIntent(evt) {
|
||||
/* 鼠标不在任何卡片上(间隙 / 行内空白):按「整行区域」判定上/下新行(各 50%),
|
||||
命中不了(分组标题 / 行外空白)则视为放到该分组末尾 */
|
||||
if (!card) {
|
||||
dragLeftCard = true // 已离开卡片本体(落在行间空白 / 分组空白上)
|
||||
const rowHit = sectionEl ? rowRectAt(evt.clientX, evt.clientY, sectionEl) : null
|
||||
if (rowHit && rowHit.fieldIds.length) {
|
||||
const relY = (evt.clientY - rowHit.top) / (rowHit.bottom - rowHit.top)
|
||||
@@ -901,6 +934,8 @@ function onFieldDragEnd(evt) {
|
||||
dragCancelled = false
|
||||
const linePos = dragLine.value
|
||||
const overKey = dragOverSection.value
|
||||
const leftCard = dragLeftCard
|
||||
dragLeftCard = false
|
||||
dragPayload.value = null
|
||||
dragSourceId.value = null
|
||||
dragLine.value = null
|
||||
@@ -967,9 +1002,10 @@ function onFieldDragEnd(evt) {
|
||||
rebalanceRowByIds(rows, sourceLineIds)
|
||||
}
|
||||
emitRows(renumberRows(rows))
|
||||
} else if (overKey != null) {
|
||||
// 未悬停到具体卡片但悬停在某分组(空分组 / 间隙):移到该分组末尾,
|
||||
// 独立放置(独占 24),源行剩余成员回收
|
||||
} else if (overKey != null && leftCard) {
|
||||
// 未悬停到具体卡片但已拖到卡片外(分组间隙 / 分组空白 / 分组下方画布空白):
|
||||
// 移到该分组末尾,独立放置(独占 24),源行剩余成员回收。
|
||||
// leftCard 保证单击卡片(按下未移动)不会被当作一次放置
|
||||
const sourceLineIds = lineIdsOf(rowId) // 必须在 splice 前采集,否则源行成员已找不到
|
||||
const gid = overKey === 'ungrouped' ? null : overKey.slice(2)
|
||||
const [m] = rows.splice(movedIdx, 1)
|
||||
@@ -983,7 +1019,7 @@ function onFieldDragEnd(evt) {
|
||||
cleanupSelectedRow()
|
||||
}
|
||||
|
||||
// 被拖字段所在视觉行的其它成员 b_id(拖拽事件时 localEdit 仍是移动前顺序,此刻采集才准确)
|
||||
// 被拖字段所在视觉行的其它成员 _rowKey(拖拽事件时 localEdit 仍是移动前顺序,此刻采集才准确)
|
||||
function lineIdsOf(fieldId) {
|
||||
const rows = localEdit.value
|
||||
const idx = rows.findIndex((r) => String(r.b_field) === fieldId)
|
||||
@@ -993,6 +1029,14 @@ function lineIdsOf(fieldId) {
|
||||
.map((i) => String(rows[i]._rowKey))
|
||||
}
|
||||
|
||||
/** 字段所在画布分区 key(与 canvasSections 的 key 一致:'g-<gid>' / 'ungrouped') */
|
||||
function sectionKeyOfField(fieldId) {
|
||||
const row = localEdit.value.find((r) => String(r.b_field) === String(fieldId))
|
||||
if (!row) return null
|
||||
const gid = groupIdOf(row)
|
||||
return gid == null ? 'ungrouped' : `g-${gid}`
|
||||
}
|
||||
|
||||
/* 提交后清理失效的选中行 */
|
||||
function cleanupSelectedRow() {
|
||||
if (selectedRowKey.value) {
|
||||
@@ -1057,7 +1101,15 @@ function onLabelChip(row, key) {
|
||||
/* 复制/粘贴:仅复制布局与状态属性(不含标识、排序号、分组),用于把某字段的配置套用到其它字段 */
|
||||
const clipboardProps = ref(null)
|
||||
// 表单复制属性键:布局与状态属性(栅格宽度 b_colspan 需按 spanOf 归一)
|
||||
const CLIP_KEYS = ['b_colspan', 'b_required', 'b_readonly', 'b_disabled', 'b_visible', 'b_placeholder', 'b_height']
|
||||
const CLIP_KEYS = [
|
||||
'b_colspan',
|
||||
'b_required',
|
||||
'b_readonly',
|
||||
'b_disabled',
|
||||
'b_visible',
|
||||
'b_placeholder',
|
||||
'b_height',
|
||||
]
|
||||
|
||||
function copyRowProps() {
|
||||
const row = selectedRow.value
|
||||
@@ -1082,6 +1134,9 @@ function onLibDragStart(evt) {
|
||||
fieldId: String(evt?.item?.dataset?.fieldId || ''),
|
||||
}
|
||||
dragSourceId.value = 'lib'
|
||||
// 字段库拖入没有「源分区」:未命中分组时保持空,沿用原有取消语义
|
||||
dragOverSection.value = null
|
||||
dragLeftCard = false
|
||||
createDragBadge(evt.item)
|
||||
window.addEventListener('keydown', onDragKeydown)
|
||||
window.addEventListener('mousemove', onDragShadowMove)
|
||||
@@ -1098,6 +1153,8 @@ function onLibDragEnd() {
|
||||
const payload = dragPayload.value
|
||||
const linePos = dragLine.value
|
||||
const overKey = dragOverSection.value
|
||||
const leftCard = dragLeftCard
|
||||
dragLeftCard = false
|
||||
dragPayload.value = null
|
||||
dragSourceId.value = null
|
||||
dragLine.value = null
|
||||
@@ -1141,7 +1198,7 @@ function onLibDragEnd() {
|
||||
rows.splice(idxs[0], 0, row)
|
||||
}
|
||||
}
|
||||
} else if (overKey != null) {
|
||||
} else if (overKey != null && leftCard) {
|
||||
// 未悬停到具体卡片:放到悬停分区末尾
|
||||
const gid = overKey === 'ungrouped' ? null : overKey.slice(2)
|
||||
row = createEditRow(field, gid)
|
||||
@@ -1333,20 +1390,6 @@ onBeforeUnmount(() => {
|
||||
<!-- 中间列:header(编辑方式 / 视图切换 / 操作)+ 画布 -->
|
||||
<div class="mm-fdesign__main">
|
||||
<div class="mm-edit__head">
|
||||
<div class="mm-edit__head-left">
|
||||
<Segmented
|
||||
class="mm-edit__mode"
|
||||
aria-label="编辑方式"
|
||||
:model-value="editMode || 'form'"
|
||||
:disabled="loading"
|
||||
:options="EDIT_MODE_OPTIONS"
|
||||
@update:model-value="onEditModeChange"
|
||||
>
|
||||
<template #label="{ option }">
|
||||
<span :title="option.title">{{ option.label }}</span>
|
||||
</template>
|
||||
</Segmented>
|
||||
</div>
|
||||
<div class="mm-edit__head-center">
|
||||
<Segmented
|
||||
class="mm-edit__view-switch"
|
||||
@@ -1380,322 +1423,331 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= 表单(主表)设计:画布 ================= -->
|
||||
<div
|
||||
class="mm-fdesign__canvas-wrap"
|
||||
:class="{ 'is-drag-active': dragPayload, 'is-section-drag-active': sectionDragging }"
|
||||
>
|
||||
<div class="mm-fdesign__canvas">
|
||||
<div class="mm-fdesign__sheet" @click="selectRow(null)">
|
||||
<template v-if="canvasSections.length">
|
||||
<draggable
|
||||
v-model="sectionOrder"
|
||||
handle=".mm-fdesign__section-grip"
|
||||
item-key="key"
|
||||
class="mm-fdesign__sections"
|
||||
:animation="150"
|
||||
ghost-class="mm-fdesign__section-ghost"
|
||||
:fallback-on-body="true"
|
||||
:force-fallback="true"
|
||||
fallback-class="mm-fdesign__drag-fallback"
|
||||
@start="sectionDragging = true"
|
||||
@end="onGroupDragEnd"
|
||||
>
|
||||
<template #item="{ element: section }">
|
||||
<div
|
||||
class="mm-fdesign__section"
|
||||
:data-section-key="section.key"
|
||||
:class="{
|
||||
'is-horizontal': section.group?.b_layout_direction === 'horizontal',
|
||||
'is-ungrouped': section.key === 'ungrouped',
|
||||
}"
|
||||
:style="{ gridColumn: `span ${section.cols}` }"
|
||||
>
|
||||
<div class="mm-fdesign__section-title">
|
||||
<button
|
||||
v-if="(section.group || section.key === 'ungrouped') && editingGroupKey !== section.key"
|
||||
type="button"
|
||||
class="mm-fdesign__section-grip"
|
||||
title="拖动调整分组顺序"
|
||||
aria-label="拖动调整分组顺序"
|
||||
>
|
||||
<GripVertical :size="13" />
|
||||
</button>
|
||||
<template v-if="editingGroupKey === section.key">
|
||||
<Input
|
||||
ref="sectionEditInput"
|
||||
v-model="groupTitleDraft"
|
||||
class="mm-fdesign__section-edit"
|
||||
maxlength="100"
|
||||
placeholder="分组名称"
|
||||
@click.stop
|
||||
@keydown.enter.prevent="commitEditGroupTitle(section)"
|
||||
@keydown.esc.prevent="cancelEditGroupTitle"
|
||||
@blur="commitEditGroupTitle(section)"
|
||||
/>
|
||||
<span class="mm-fdesign__section-edit-actions">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-fdesign__section-edit-btn"
|
||||
title="确认"
|
||||
aria-label="确认修改分组名"
|
||||
@mousedown.prevent="commitEditGroupTitle(section)"
|
||||
>
|
||||
<Check :size="13" />
|
||||
</Button>
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-fdesign__section-edit-btn"
|
||||
title="取消"
|
||||
aria-label="取消修改分组名"
|
||||
@mousedown.prevent="cancelEditGroupTitle"
|
||||
>
|
||||
<X :size="13" />
|
||||
</Button>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span
|
||||
v-if="section.title"
|
||||
class="mm-fdesign__section-name"
|
||||
:class="{ 'is-editable': section.group }"
|
||||
:title="section.group ? '点击重命名分组' : undefined"
|
||||
@click.stop="startEditGroupTitle(section)"
|
||||
>{{ section.title }}</span>
|
||||
<span
|
||||
v-else-if="section.group"
|
||||
class="mm-fdesign__section-name is-empty"
|
||||
title="点击设置分组标题"
|
||||
@click.stop="startEditGroupTitle(section)"
|
||||
>空</span>
|
||||
<span class="mm-edit__section-meta">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-fdesign__section-btn"
|
||||
title="分组设置"
|
||||
aria-label="分组设置"
|
||||
@click.stop="openSectionSettings(section)"
|
||||
>
|
||||
<Settings2 :size="13" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="section.group"
|
||||
type="ghost"
|
||||
class="mm-fdesign__section-del"
|
||||
title="删除分组(其下字段移至未分组)"
|
||||
aria-label="删除分组"
|
||||
@click.stop="deleteGroupDirect(section)"
|
||||
>
|
||||
<Trash2 :size="13" />
|
||||
</Button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="mm-fdesign__section-body">
|
||||
<!-- ================= 表单(主表)设计:画布 ================= -->
|
||||
<div
|
||||
class="mm-fdesign__canvas-wrap"
|
||||
:class="{ 'is-drag-active': dragPayload, 'is-section-drag-active': sectionDragging }"
|
||||
>
|
||||
<div class="mm-fdesign__canvas">
|
||||
<div class="mm-fdesign__sheet" @click="selectRow(null)">
|
||||
<template v-if="canvasSections.length">
|
||||
<draggable
|
||||
class="mm-fdesign__grid"
|
||||
:list="section.rows"
|
||||
:group="{ name: 'form' }"
|
||||
item-key="b_id"
|
||||
v-model="sectionOrder"
|
||||
handle=".mm-fdesign__section-grip"
|
||||
item-key="key"
|
||||
class="mm-fdesign__sections"
|
||||
:animation="150"
|
||||
ghost-class="mm-fdesign__sortable-ghost"
|
||||
:drag-class="'mm-fdesign__sortable-drag'"
|
||||
ghost-class="mm-fdesign__section-ghost"
|
||||
:fallback-on-body="true"
|
||||
:force-fallback="true"
|
||||
fallback-class="mm-fdesign__drag-fallback"
|
||||
:filter="'button'"
|
||||
:prevent-on-filter="true"
|
||||
:move="onFieldMove"
|
||||
@start="onFieldDragStart"
|
||||
@end="onFieldDragEnd"
|
||||
@start="sectionDragging = true"
|
||||
@end="onGroupDragEnd"
|
||||
>
|
||||
<template #item="{ element: row }">
|
||||
<template #item="{ element: section }">
|
||||
<div
|
||||
class="mm-fdesign__field"
|
||||
class="mm-fdesign__section"
|
||||
:data-section-key="section.key"
|
||||
:class="{
|
||||
'is-selected': selectedRowKey === String(row._rowKey),
|
||||
'is-dragging': dragSourceId === String(row.b_field),
|
||||
'is-invisible': Number(row.b_visible) !== 1,
|
||||
'is-drop-hover':
|
||||
dragLine &&
|
||||
(dragLine.fieldId === String(row.b_field) ||
|
||||
(dragLineRowIds ? dragLineRowIds.has(String(row.b_field)) : false)),
|
||||
'is-drop-before':
|
||||
dragLine &&
|
||||
dragLine.fieldId === String(row.b_field) &&
|
||||
dragLine.pos === 'before',
|
||||
'is-drop-after':
|
||||
dragLine &&
|
||||
dragLine.fieldId === String(row.b_field) &&
|
||||
dragLine.pos === 'after',
|
||||
'is-drop-newline':
|
||||
dragLine &&
|
||||
dragLine.fieldId === String(row.b_field) &&
|
||||
dragLine.pos === 'newline',
|
||||
'is-drop-line-before':
|
||||
dragLine &&
|
||||
dragLine.fieldId === String(row.b_field) &&
|
||||
dragLine.pos === 'line-before',
|
||||
'is-horizontal': section.group?.b_layout_direction === 'horizontal',
|
||||
'is-ungrouped': section.key === 'ungrouped',
|
||||
}"
|
||||
:data-field-id="String(row.b_field)"
|
||||
:data-row-id="String(row._rowKey)"
|
||||
:style="{ gridColumn: `span ${spanOf(row)}` }"
|
||||
@click.stop="selectRow(row)"
|
||||
:style="{ gridColumn: `span ${section.cols}` }"
|
||||
>
|
||||
<div
|
||||
v-if="dragLine && dragLine.fieldId === String(row.b_field)"
|
||||
class="mm-fdesign__drop-tip"
|
||||
:class="`is-${dragLine.pos}`"
|
||||
>
|
||||
{{ dropTipText(dragLine.pos) }}
|
||||
</div>
|
||||
<div class="mm-fdesign__field-label">
|
||||
<span class="mm-fdesign__field-name">{{ fieldName(row) }}</span>
|
||||
<i
|
||||
v-if="Number(row.b_required) === 1"
|
||||
class="mm-fdesign__required"
|
||||
>*</i
|
||||
<div class="mm-fdesign__section-title">
|
||||
<button
|
||||
v-if="
|
||||
(section.group || section.key === 'ungrouped') &&
|
||||
editingGroupKey !== section.key
|
||||
"
|
||||
type="button"
|
||||
class="mm-fdesign__section-grip"
|
||||
title="拖动调整分组顺序"
|
||||
aria-label="拖动调整分组顺序"
|
||||
>
|
||||
<span class="mm-fdesign__label-chips">
|
||||
<button
|
||||
type="button"
|
||||
class="mm-fdesign__label-chip"
|
||||
:class="{ 'is-on': Number(row.b_required) === 1 }"
|
||||
title="必填"
|
||||
aria-label="切换必填"
|
||||
@click.stop="onLabelChip(row, 'b_required')"
|
||||
<GripVertical :size="13" />
|
||||
</button>
|
||||
<template v-if="editingGroupKey === section.key">
|
||||
<Input
|
||||
ref="sectionEditInput"
|
||||
v-model="groupTitleDraft"
|
||||
class="mm-fdesign__section-edit"
|
||||
maxlength="100"
|
||||
placeholder="分组名称"
|
||||
@click.stop
|
||||
@keydown.enter.prevent="commitEditGroupTitle(section)"
|
||||
@keydown.esc.prevent="cancelEditGroupTitle"
|
||||
@blur="commitEditGroupTitle(section)"
|
||||
/>
|
||||
<span class="mm-fdesign__section-edit-actions">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-fdesign__section-edit-btn"
|
||||
title="确认"
|
||||
aria-label="确认修改分组名"
|
||||
@mousedown.prevent="commitEditGroupTitle(section)"
|
||||
>
|
||||
<Check :size="13" />
|
||||
</Button>
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-fdesign__section-edit-btn"
|
||||
title="取消"
|
||||
aria-label="取消修改分组名"
|
||||
@mousedown.prevent="cancelEditGroupTitle"
|
||||
>
|
||||
<X :size="13" />
|
||||
</Button>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span
|
||||
v-if="section.title"
|
||||
class="mm-fdesign__section-name"
|
||||
:class="{ 'is-editable': section.group }"
|
||||
:title="section.group ? '点击重命名分组' : undefined"
|
||||
@click.stop="startEditGroupTitle(section)"
|
||||
>{{ section.title }}</span
|
||||
>
|
||||
<Asterisk :size="12" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mm-fdesign__label-chip"
|
||||
:class="{ 'is-on': Number(row.b_visible) === 1 }"
|
||||
title="显示"
|
||||
aria-label="切换显示"
|
||||
@click.stop="onLabelChip(row, 'b_visible')"
|
||||
<span
|
||||
v-else-if="section.group"
|
||||
class="mm-fdesign__section-name is-empty"
|
||||
title="点击设置分组标题"
|
||||
@click.stop="startEditGroupTitle(section)"
|
||||
>空</span
|
||||
>
|
||||
<Eye v-if="Number(row.b_visible) === 1" :size="12" />
|
||||
<EyeOff v-else :size="12" />
|
||||
</button>
|
||||
</span>
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-fdesign__tool-btn is-danger"
|
||||
title="删除字段"
|
||||
aria-label="删除字段"
|
||||
@click.stop="removeRow(row)"
|
||||
>
|
||||
<Trash2 :size="12" />
|
||||
</Button>
|
||||
<span class="mm-edit__section-meta">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-fdesign__section-btn"
|
||||
title="分组设置"
|
||||
aria-label="分组设置"
|
||||
@click.stop="openSectionSettings(section)"
|
||||
>
|
||||
<Settings2 :size="13" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="section.group"
|
||||
type="ghost"
|
||||
class="mm-fdesign__section-del"
|
||||
title="删除分组(其下字段移至未分组)"
|
||||
aria-label="删除分组"
|
||||
@click.stop="deleteGroupDirect(section)"
|
||||
>
|
||||
<Trash2 :size="13" />
|
||||
</Button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="mm-fdesign__field-control">
|
||||
<input
|
||||
v-if="fieldType(row) === 'input'"
|
||||
class="mm-fdesign__ctrl"
|
||||
type="text"
|
||||
tabindex="-1"
|
||||
:placeholder="placeholderOf(row)"
|
||||
/>
|
||||
<textarea
|
||||
v-else-if="fieldType(row) === 'textarea'"
|
||||
class="mm-fdesign__ctrl"
|
||||
tabindex="-1"
|
||||
:placeholder="placeholderOf(row)"
|
||||
:style="{ height: `${heightOf(row)}px` }"
|
||||
/>
|
||||
<select
|
||||
v-else-if="fieldType(row) === 'select'"
|
||||
class="mm-fdesign__ctrl"
|
||||
tabindex="-1"
|
||||
<div class="mm-fdesign__section-body">
|
||||
<draggable
|
||||
class="mm-fdesign__grid"
|
||||
:list="section.rows"
|
||||
:group="{ name: 'form' }"
|
||||
item-key="_rowKey"
|
||||
:animation="150"
|
||||
ghost-class="mm-fdesign__sortable-ghost"
|
||||
:drag-class="'mm-fdesign__sortable-drag'"
|
||||
:fallback-on-body="true"
|
||||
:force-fallback="true"
|
||||
fallback-class="mm-fdesign__drag-fallback"
|
||||
:filter="'button'"
|
||||
:prevent-on-filter="true"
|
||||
:move="onFieldMove"
|
||||
@start="onFieldDragStart"
|
||||
@end="onFieldDragEnd"
|
||||
>
|
||||
<option value="">{{ placeholderOf(row) }}</option>
|
||||
</select>
|
||||
<div
|
||||
v-else-if="fieldType(row) === 'checkbox'"
|
||||
class="mm-fdesign__ctrl-checkbox"
|
||||
>
|
||||
<input type="checkbox" tabindex="-1" />
|
||||
<span>{{ fieldOf(row)?.b_default_value || '选项' }}</span>
|
||||
</div>
|
||||
<input
|
||||
v-else-if="fieldType(row) === 'date'"
|
||||
class="mm-fdesign__ctrl"
|
||||
type="date"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<input
|
||||
v-else-if="fieldType(row) === 'datetime'"
|
||||
class="mm-fdesign__ctrl"
|
||||
type="datetime-local"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<input
|
||||
v-else-if="fieldType(row) === 'number' || fieldType(row) === 'money'"
|
||||
class="mm-fdesign__ctrl"
|
||||
type="number"
|
||||
tabindex="-1"
|
||||
:placeholder="placeholderOf(row)"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
class="mm-fdesign__ctrl"
|
||||
type="text"
|
||||
tabindex="-1"
|
||||
:placeholder="placeholderOf(row)"
|
||||
/>
|
||||
<template #item="{ element: row }">
|
||||
<div
|
||||
class="mm-fdesign__field"
|
||||
:class="{
|
||||
'is-selected': selectedRowKey === String(row._rowKey),
|
||||
'is-dragging': dragSourceId === String(row.b_field),
|
||||
'is-invisible': Number(row.b_visible) !== 1,
|
||||
'is-drop-hover':
|
||||
dragLine &&
|
||||
(dragLine.fieldId === String(row.b_field) ||
|
||||
(dragLineRowIds
|
||||
? dragLineRowIds.has(String(row.b_field))
|
||||
: false)),
|
||||
'is-drop-before':
|
||||
dragLine &&
|
||||
dragLine.fieldId === String(row.b_field) &&
|
||||
dragLine.pos === 'before',
|
||||
'is-drop-after':
|
||||
dragLine &&
|
||||
dragLine.fieldId === String(row.b_field) &&
|
||||
dragLine.pos === 'after',
|
||||
'is-drop-newline':
|
||||
dragLine &&
|
||||
dragLine.fieldId === String(row.b_field) &&
|
||||
dragLine.pos === 'newline',
|
||||
'is-drop-line-before':
|
||||
dragLine &&
|
||||
dragLine.fieldId === String(row.b_field) &&
|
||||
dragLine.pos === 'line-before',
|
||||
}"
|
||||
:data-field-id="String(row.b_field)"
|
||||
:data-row-id="String(row._rowKey)"
|
||||
:style="{ gridColumn: `span ${spanOf(row)}` }"
|
||||
@click.stop="selectRow(row)"
|
||||
>
|
||||
<div
|
||||
v-if="dragLine && dragLine.fieldId === String(row.b_field)"
|
||||
class="mm-fdesign__drop-tip"
|
||||
:class="`is-${dragLine.pos}`"
|
||||
>
|
||||
{{ dropTipText(dragLine.pos) }}
|
||||
</div>
|
||||
<div class="mm-fdesign__field-label">
|
||||
<span class="mm-fdesign__field-name">{{ fieldName(row) }}</span>
|
||||
<i v-if="Number(row.b_required) === 1" class="mm-fdesign__required"
|
||||
>*</i
|
||||
>
|
||||
<span class="mm-fdesign__label-chips">
|
||||
<button
|
||||
type="button"
|
||||
class="mm-fdesign__label-chip"
|
||||
:class="{ 'is-on': Number(row.b_required) === 1 }"
|
||||
title="必填"
|
||||
aria-label="切换必填"
|
||||
@click.stop="onLabelChip(row, 'b_required')"
|
||||
>
|
||||
<Asterisk :size="12" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mm-fdesign__label-chip"
|
||||
:class="{ 'is-on': Number(row.b_visible) === 1 }"
|
||||
title="显示"
|
||||
aria-label="切换显示"
|
||||
@click.stop="onLabelChip(row, 'b_visible')"
|
||||
>
|
||||
<Eye v-if="Number(row.b_visible) === 1" :size="12" />
|
||||
<EyeOff v-else :size="12" />
|
||||
</button>
|
||||
</span>
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-fdesign__tool-btn is-danger"
|
||||
title="删除字段"
|
||||
aria-label="删除字段"
|
||||
@click.stop="removeRow(row)"
|
||||
>
|
||||
<Trash2 :size="12" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mm-fdesign__field-control">
|
||||
<input
|
||||
v-if="fieldType(row) === 'input'"
|
||||
class="mm-fdesign__ctrl"
|
||||
type="text"
|
||||
tabindex="-1"
|
||||
:placeholder="placeholderOf(row)"
|
||||
/>
|
||||
<textarea
|
||||
v-else-if="fieldType(row) === 'textarea'"
|
||||
class="mm-fdesign__ctrl"
|
||||
tabindex="-1"
|
||||
:placeholder="placeholderOf(row)"
|
||||
:style="{ height: `${heightOf(row)}px` }"
|
||||
/>
|
||||
<select
|
||||
v-else-if="fieldType(row) === 'select'"
|
||||
class="mm-fdesign__ctrl"
|
||||
tabindex="-1"
|
||||
>
|
||||
<option value="">{{ placeholderOf(row) }}</option>
|
||||
</select>
|
||||
<div
|
||||
v-else-if="fieldType(row) === 'checkbox'"
|
||||
class="mm-fdesign__ctrl-checkbox"
|
||||
>
|
||||
<input type="checkbox" tabindex="-1" />
|
||||
<span>{{ fieldOf(row)?.b_default_value || '选项' }}</span>
|
||||
</div>
|
||||
<input
|
||||
v-else-if="fieldType(row) === 'date'"
|
||||
class="mm-fdesign__ctrl"
|
||||
type="date"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<input
|
||||
v-else-if="fieldType(row) === 'datetime'"
|
||||
class="mm-fdesign__ctrl"
|
||||
type="datetime-local"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<input
|
||||
v-else-if="
|
||||
fieldType(row) === 'number' || fieldType(row) === 'money'
|
||||
"
|
||||
class="mm-fdesign__ctrl"
|
||||
type="number"
|
||||
tabindex="-1"
|
||||
:placeholder="placeholderOf(row)"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
class="mm-fdesign__ctrl"
|
||||
type="text"
|
||||
tabindex="-1"
|
||||
:placeholder="placeholderOf(row)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div v-if="!section.rows.length" class="mm-fdesign__grid-empty">
|
||||
暂无内容
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div v-if="!section.rows.length" class="mm-fdesign__grid-empty">暂无内容</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<draggable
|
||||
v-else
|
||||
class="mm-fdesign__empty-list"
|
||||
data-section-key="ungrouped"
|
||||
:list="sectionRowCache['']"
|
||||
:group="{ name: 'form' }"
|
||||
item-key="_rowKey"
|
||||
:animation="150"
|
||||
ghost-class="mm-fdesign__sortable-ghost"
|
||||
:fallback-on-body="true"
|
||||
:force-fallback="true"
|
||||
fallback-class="mm-fdesign__drag-fallback"
|
||||
:move="onFieldMove"
|
||||
@start="onFieldDragStart"
|
||||
@end="onFieldDragEnd"
|
||||
>
|
||||
<template #item="{ element: row }">
|
||||
<div
|
||||
class="mm-fdesign__field"
|
||||
:data-field-id="String(row.b_field)"
|
||||
:data-row-id="String(row._rowKey)"
|
||||
:style="{ gridColumn: `span ${spanOf(row)}` }"
|
||||
@click.stop="selectRow(row)"
|
||||
>
|
||||
<div class="mm-fdesign__field-label">
|
||||
<span class="mm-fdesign__field-name">{{ fieldName(row) }}</span>
|
||||
</div>
|
||||
<div class="mm-fdesign__field-control">
|
||||
<input class="mm-fdesign__ctrl" type="text" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="mm-fdesign__empty">暂无内容</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
<draggable
|
||||
v-else
|
||||
class="mm-fdesign__empty-list"
|
||||
data-section-key="ungrouped"
|
||||
:list="sectionRowCache['']"
|
||||
:group="{ name: 'form' }"
|
||||
item-key="b_id"
|
||||
:animation="150"
|
||||
ghost-class="mm-fdesign__sortable-ghost"
|
||||
:fallback-on-body="true"
|
||||
:force-fallback="true"
|
||||
fallback-class="mm-fdesign__drag-fallback"
|
||||
:move="onFieldMove"
|
||||
@start="onFieldDragStart"
|
||||
@end="onFieldDragEnd"
|
||||
>
|
||||
<template #item="{ element: row }">
|
||||
<div
|
||||
class="mm-fdesign__field"
|
||||
:data-field-id="String(row.b_field)"
|
||||
:data-row-id="String(row._rowKey)"
|
||||
:style="{ gridColumn: `span ${spanOf(row)}` }"
|
||||
@click.stop="selectRow(row)"
|
||||
>
|
||||
<div class="mm-fdesign__field-label">
|
||||
<span class="mm-fdesign__field-name">{{ fieldName(row) }}</span>
|
||||
</div>
|
||||
<div class="mm-fdesign__field-control">
|
||||
<input class="mm-fdesign__ctrl" type="text" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="mm-fdesign__empty">暂无内容</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= 表单(主表)设计:属性面板 ================= -->
|
||||
<FormInspector
|
||||
@@ -1751,7 +1803,9 @@ onBeforeUnmount(() => {
|
||||
class="mm-fdesign__section-settings-step"
|
||||
:class="{ 'is-active': sectionSettings.cols === v }"
|
||||
@click="sectionSettings.cols = v"
|
||||
>{{ v }}</button>
|
||||
>
|
||||
{{ v }}
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
v-model="sectionSettings.cols"
|
||||
@@ -1774,7 +1828,9 @@ onBeforeUnmount(() => {
|
||||
class="mm-fdesign__section-settings-step"
|
||||
:class="{ 'is-active': sectionSettings.fieldCols === v }"
|
||||
@click="sectionSettings.fieldCols = v"
|
||||
>{{ v }}</button>
|
||||
>
|
||||
{{ v }}
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
v-model="sectionSettings.fieldCols"
|
||||
@@ -2597,7 +2653,6 @@ textarea.mm-fdesign__ctrl {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<!-- fallback 克隆(SortableJS 在 body 上生成的整卡克隆)不在 scoped 作用域内,需全局隐藏:
|
||||
|
||||
+56
-7
@@ -27,7 +27,7 @@
|
||||
* :loading="busy"
|
||||
* />
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Trash2, Plus } from '@lucide/vue'
|
||||
import { Button, Popover, Segmented } from '@/components/ui'
|
||||
import StructureTree from './table/StructureTree.vue'
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
renumberRows,
|
||||
toggleFlag,
|
||||
} from './common/useStructureEditing'
|
||||
import { schemaToEditor, editorToSchema, stableStringify } from './common/schemaModel'
|
||||
import { createDefaultSchema } from '../../utils'
|
||||
import FieldLibrary from './common/FieldLibrary.vue'
|
||||
import TablePreview from './table/TablePreview.vue'
|
||||
import TableInspector from './table/TableInspector.vue'
|
||||
@@ -55,12 +57,59 @@ const props = defineProps({
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
/** 当前编辑的行数组(subtable → editConfig,list → listConfig),父级 v-model 绑定 */
|
||||
const rows = defineModel({ type: Array, default: () => [] })
|
||||
/** 分组行数组(s_field_group),父级 v-model:groups 绑定 */
|
||||
const groups = defineModel('groups', { type: Array, default: () => [] })
|
||||
/** 编辑方式(subtable 专用),父级 v-model:edit-mode 绑定 */
|
||||
const editMode = defineModel('editMode', { type: String, default: 'form' })
|
||||
/**
|
||||
* 界面配置对象(view JSON,subtable 场景为 edit JSON),父级 v-model 绑定。
|
||||
* 编辑器内部仍以「行 + 分组」草稿工作,经 schemaModel 适配层与 JSON 节点互转;
|
||||
* 对外数据模型即 s_module_schema.b_schema_json 对象。
|
||||
*/
|
||||
const schemaModel = defineModel({ type: Object, default: () => createDefaultSchema('view') })
|
||||
/** 编辑器内部行草稿(由 schemaModel 适配,变更回写 schemaModel) */
|
||||
const rows = ref([])
|
||||
/** 编辑器内部分组草稿 */
|
||||
const groups = ref([])
|
||||
/** 编辑方式(subtable 专用),保留以兼容内部切换器 */
|
||||
const editMode = ref('form')
|
||||
|
||||
const schemaType = computed(() => (props.mode === 'subtable' ? 'edit' : 'view'))
|
||||
|
||||
// 加载:配置对象 → 内部行/分组草稿(避免把父级回传的同一对象再解析一遍)
|
||||
let syncedSchema = null
|
||||
watch(
|
||||
schemaModel,
|
||||
(val) => {
|
||||
if (val === syncedSchema) return
|
||||
const parsed = schemaToEditor(
|
||||
schemaType.value,
|
||||
val || createDefaultSchema(schemaType.value),
|
||||
props.moduleId,
|
||||
)
|
||||
rows.value = parsed.rows
|
||||
groups.value = parsed.groups
|
||||
syncedSchema = val
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 变更:内部草稿 → 配置对象。先与「当前配置的规范化结果」比较,
|
||||
// 空白加载后的规范化差异不回写(避免打开即脏);内容一致则不动,避免循环。
|
||||
watch(
|
||||
[rows, groups],
|
||||
() => {
|
||||
const current = schemaModel.value || createDefaultSchema(schemaType.value)
|
||||
const parsedCurrent = schemaToEditor(schemaType.value, current, props.moduleId)
|
||||
const normalizedCurrent = editorToSchema(
|
||||
schemaType.value,
|
||||
parsedCurrent.rows,
|
||||
parsedCurrent.groups,
|
||||
current,
|
||||
)
|
||||
const next = editorToSchema(schemaType.value, rows.value, groups.value, current)
|
||||
if (stableStringify(next) === stableStringify(normalizedCurrent)) return
|
||||
syncedSchema = next
|
||||
schemaModel.value = next
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const isSubtable = computed(() => props.mode === 'subtable')
|
||||
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ const emit = defineEmits(['back', 'row-click', 'change', 'row-order-change'])
|
||||
:data-source="rows"
|
||||
:editable="!loading"
|
||||
:row-draggable="!loading"
|
||||
row-key="b_id"
|
||||
row-key="_rowKey"
|
||||
:active-row-key="activeKey"
|
||||
@row-click="(_, row) => emit('row-click', row)"
|
||||
@change="emit('change')"
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ function onFieldDragStart(event, field) {
|
||||
class="mm-fdesign__lib-list"
|
||||
:list="libraryFields"
|
||||
:group="sortable?.group"
|
||||
item-key="b_id"
|
||||
item-key="b_field"
|
||||
:clone="sortable?.clone"
|
||||
:animation="150"
|
||||
ghost-class="mm-fdesign__lib-ghost"
|
||||
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* 界面配置 JSON 节点模型 ↔ 编辑器行/分组模型 适配层。
|
||||
*
|
||||
* 《FMS新系统核心表结构设计》V2 中,列表/表单/查询布局统一存 s_module_schema.b_schema_json:
|
||||
* - view { schemaVersion, columns: [ {field,...} | {type:'group',title,i18n,children:[...]} ] }
|
||||
* - edit { schemaVersion, children: [ {field,...} | {type:'group',...} ] }
|
||||
* - query { schemaVersion, quick: [], groups: [ { logic, conditions: [{field,operator,...}] } ] }
|
||||
*
|
||||
* 编辑器内核(useStructureEditing / StructureTree / TableDesignEditor / FormDesignEditor)
|
||||
* 仍以「行 + 分组」的可变草稿工作;本文件负责两侧转换,使编辑器对外的数据模型就是 JSON 节点,
|
||||
* 同时保留节点上的未知扩展属性(通过行的 _node 暂存,回写时展开)。
|
||||
*
|
||||
* 约定:分组节点在 JSON 中没有主键,进入编辑器时生成临时分组编码 b_id,回写时丢弃;
|
||||
* 顺序由数组顺序表达,编辑器的 b_xh 只作为内部排序依据。
|
||||
*/
|
||||
import { createTempId } from '@/utils/common'
|
||||
import { createDefaultSchema, stableStringify } from '../../../utils'
|
||||
|
||||
/** 编辑器内部分组临时编码(模块内唯一,不出现在 JSON 中) */
|
||||
let groupSequence = 0
|
||||
function nextGroupId() {
|
||||
groupSequence += 1
|
||||
return `g${Date.now().toString(36)}${groupSequence.toString(36)}`
|
||||
}
|
||||
|
||||
function sortByXh(nodes) {
|
||||
return [...nodes].toSorted((a, b) => (Number(a.b_xh) || 0) - (Number(b.b_xh) || 0))
|
||||
}
|
||||
|
||||
/* ================= JSON 节点 → 编辑器行/分组 ================= */
|
||||
|
||||
function nodeToRow(schemaType, node, moduleId, groupId) {
|
||||
const row = {
|
||||
// 行唯一标识(本地临时 id):选中态/数据视图行定位都按 _rowKey 比对,
|
||||
// 缺失会让 String(row._rowKey) 全为 "undefined" 而同时命中多行(回写 JSON 时丢弃)
|
||||
_rowKey: createTempId(),
|
||||
b_module_id: moduleId,
|
||||
b_field: String(node.field),
|
||||
b_group_id: groupId,
|
||||
b_xh: 0,
|
||||
_node: node,
|
||||
}
|
||||
if (schemaType === 'view') {
|
||||
row.b_visible = node.visible === false ? 0 : 1
|
||||
row.b_width = node.width == null ? null : Number(node.width)
|
||||
} else {
|
||||
row.b_visible = node.visible === false ? 0 : 1
|
||||
row.b_required = node.required ? 1 : 0
|
||||
row.b_readonly = node.readonly ? 1 : 0
|
||||
row.b_disabled = node.disabled ? 1 : 0
|
||||
row.b_colspan = node.span == null ? 24 : Number(node.span)
|
||||
row.b_height = node.height == null ? null : Number(node.height)
|
||||
row.b_placeholder = node.placeholder ?? null
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置对象 → { rows, groups }(编辑器内部草稿)。
|
||||
* @param {'view'|'edit'|'query'} schemaType
|
||||
*/
|
||||
export function schemaToEditor(schemaType, schema, moduleId) {
|
||||
const roots = schema?.columns || schema?.children || []
|
||||
const rows = []
|
||||
const groups = []
|
||||
const walk = (nodes, parentGroupId) => {
|
||||
for (const node of nodes || []) {
|
||||
if (!node || typeof node !== 'object') continue
|
||||
if (node.type === 'group') {
|
||||
const gid = nextGroupId()
|
||||
groups.push({
|
||||
b_id: gid,
|
||||
b_module_id: moduleId,
|
||||
b_parent_id: parentGroupId,
|
||||
b_title: node.title || '',
|
||||
b_i18n: node.i18n ?? null,
|
||||
b_xh: 0,
|
||||
b_canuse: node.canuse === 0 ? 0 : 1,
|
||||
b_colspan: Number(node.colspan) || 48,
|
||||
_node: node,
|
||||
})
|
||||
walk(node.children, gid)
|
||||
continue
|
||||
}
|
||||
if (node.field == null) continue
|
||||
rows.push(nodeToRow(schemaType, node, moduleId, parentGroupId))
|
||||
}
|
||||
}
|
||||
walk(roots, null)
|
||||
// b_xh:行按数组先序,分组按兄弟顺序
|
||||
rows.forEach((row, index) => (row.b_xh = (index + 1) * 10))
|
||||
const assignGroupXh = (parentId) => {
|
||||
const siblings = groups.filter((g) =>
|
||||
parentId == null ? g.b_parent_id == null : String(g.b_parent_id) === String(parentId),
|
||||
)
|
||||
siblings.forEach((group, index) => {
|
||||
group.b_xh = (index + 1) * 10
|
||||
assignGroupXh(group.b_id)
|
||||
})
|
||||
}
|
||||
assignGroupXh(null)
|
||||
return { rows, groups }
|
||||
}
|
||||
|
||||
/* ================= 编辑器行/分组 → JSON 节点 ================= */
|
||||
|
||||
function rowToNode(schemaType, row) {
|
||||
const node = { ...row._node, field: String(row.b_field) }
|
||||
// 布尔属性仅在「非默认」时写出,减少与库中原始节点的格式差异
|
||||
if (schemaType === 'view') {
|
||||
if (Number(row.b_visible) === 1) delete node.visible
|
||||
else node.visible = false
|
||||
if (row.b_width != null && row.b_width !== '') node.width = Number(row.b_width)
|
||||
else delete node.width
|
||||
} else {
|
||||
if (Number(row.b_visible) === 1) delete node.visible
|
||||
else node.visible = false
|
||||
const span = Number(row.b_colspan) || 24
|
||||
if (span !== 24 || row._node?.span !== undefined) node.span = span
|
||||
else delete node.span
|
||||
if (Number(row.b_required) === 1) node.required = true
|
||||
else delete node.required
|
||||
if (Number(row.b_readonly) === 1) node.readonly = true
|
||||
else delete node.readonly
|
||||
if (Number(row.b_disabled) === 1) node.disabled = true
|
||||
else delete node.disabled
|
||||
if (row.b_height != null && row.b_height !== '') node.height = Number(row.b_height)
|
||||
else delete node.height
|
||||
if (row.b_placeholder) node.placeholder = row.b_placeholder
|
||||
else delete node.placeholder
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function groupToNode(group, children) {
|
||||
const node = {
|
||||
...group._node,
|
||||
type: 'group',
|
||||
title: group.b_title || '',
|
||||
children,
|
||||
}
|
||||
if (group.b_i18n) node.i18n = group.b_i18n
|
||||
else delete node.i18n
|
||||
node.colspan = Number(group.b_colspan) || 48
|
||||
if (Number(group.b_canuse) === 1) delete node.canuse
|
||||
else node.canuse = 0
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器草稿 → 配置对象(保留未知顶层属性与节点扩展属性)。
|
||||
*/
|
||||
export function editorToSchema(schemaType, rows, groups, baseSchema) {
|
||||
const rootKey = schemaType === 'view' ? 'columns' : 'children'
|
||||
const groupById = new Map((groups || []).map((g) => [String(g.b_id), g]))
|
||||
const groupsByParent = new Map()
|
||||
for (const group of groups || []) {
|
||||
const pid = group.b_parent_id == null ? null : String(group.b_parent_id)
|
||||
if (!groupsByParent.has(pid)) groupsByParent.set(pid, [])
|
||||
groupsByParent.get(pid).push(group)
|
||||
}
|
||||
const rowsByGroup = new Map()
|
||||
const ungrouped = []
|
||||
for (const row of rows || []) {
|
||||
const gid = row.b_group_id == null ? null : String(row.b_group_id)
|
||||
if (gid != null && groupById.has(gid)) {
|
||||
if (!rowsByGroup.has(gid)) rowsByGroup.set(gid, [])
|
||||
rowsByGroup.get(gid).push(row)
|
||||
} else {
|
||||
ungrouped.push(row)
|
||||
}
|
||||
}
|
||||
const buildGroupNodes = (parentId) => {
|
||||
const siblings = sortByXh(groupsByParent.get(parentId) || [])
|
||||
return siblings.map((group) => {
|
||||
const ownRows = sortByXh(rowsByGroup.get(String(group.b_id)) || [])
|
||||
const children = [
|
||||
...ownRows.map((row) => rowToNode(schemaType, row)),
|
||||
...buildGroupNodes(String(group.b_id)),
|
||||
]
|
||||
return groupToNode(group, children)
|
||||
})
|
||||
}
|
||||
const nodes = [
|
||||
...sortByXh(ungrouped).map((row) => rowToNode(schemaType, row)),
|
||||
...buildGroupNodes(null),
|
||||
]
|
||||
const out = { ...baseSchema }
|
||||
out.schemaVersion = 1
|
||||
out[rootKey] = nodes
|
||||
return out
|
||||
}
|
||||
|
||||
/* ================= 查询配置 JSON ↔ 编辑器行 ================= */
|
||||
|
||||
export function querySchemaToRows(schema, moduleId) {
|
||||
const conditions = []
|
||||
for (const group of schema?.groups || []) {
|
||||
for (const condition of group?.conditions || []) conditions.push(condition)
|
||||
}
|
||||
return conditions.map((condition, index) => ({
|
||||
_rowKey: createTempId(),
|
||||
b_module_id: moduleId,
|
||||
b_field: condition.field ?? null,
|
||||
b_component: condition.component || 'input',
|
||||
b_operator: condition.operator || 'like',
|
||||
b_default_value: condition.defaultValue ?? null,
|
||||
b_canuse: 1,
|
||||
b_xh: (index + 1) * 10,
|
||||
}))
|
||||
}
|
||||
|
||||
export function rowsToQuerySchema(rows, baseSchema) {
|
||||
const conditions = (rows || []).map((row) => {
|
||||
const condition = { field: row.b_field, operator: row.b_operator || 'like' }
|
||||
if (row.b_component) condition.component = row.b_component
|
||||
if (row.b_default_value != null && row.b_default_value !== '') {
|
||||
condition.defaultValue = row.b_default_value
|
||||
}
|
||||
return condition
|
||||
})
|
||||
const out = { ...baseSchema }
|
||||
out.schemaVersion = 1
|
||||
out.quick = Array.isArray(baseSchema?.quick) ? baseSchema.quick : []
|
||||
out.groups = conditions.length ? [{ logic: 'and', conditions }] : []
|
||||
return out
|
||||
}
|
||||
|
||||
/* ================= 字段节点:增删与存在性(参与勾选) =================
|
||||
* 通过字段编码在节点树中定位;增删返回新的配置对象(原地 patch 后换引用)。
|
||||
*/
|
||||
|
||||
function walkNodes(nodes, visit) {
|
||||
for (const node of nodes || []) {
|
||||
if (!node || typeof node !== 'object') continue
|
||||
visit(node)
|
||||
if (Array.isArray(node.children)) walkNodes(node.children, visit)
|
||||
}
|
||||
}
|
||||
|
||||
function rootsOf(schemaType, schema) {
|
||||
return schemaType === 'view' ? schema?.columns || [] : schema?.children || []
|
||||
}
|
||||
|
||||
function filterFieldNode(nodes, fieldId) {
|
||||
const next = []
|
||||
for (const node of nodes || []) {
|
||||
if (!node || typeof node !== 'object') continue
|
||||
if (node.type === 'group') {
|
||||
next.push({ ...node, children: filterFieldNode(node.children, fieldId) })
|
||||
continue
|
||||
}
|
||||
if (String(node.field) === String(fieldId)) continue
|
||||
next.push(node)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** 字段是否出现在配置中(edit 需节点存在即可,可见性由 visible 属性表达) */
|
||||
export function hasSchemaField(schemaType, schema, fieldId, { requireVisible = false } = {}) {
|
||||
let found = false
|
||||
walkNodes(rootsOf(schemaType, schema), (node) => {
|
||||
if (node.type === 'group') return
|
||||
if (String(node.field) !== String(fieldId)) return
|
||||
if (requireVisible && node.visible === false) return
|
||||
found = true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
/** 在配置末尾追加字段节点(返回新的配置对象) */
|
||||
export function addSchemaField(schemaType, schema, field) {
|
||||
const base = schema || createDefaultSchema(schemaType)
|
||||
const rootKey = schemaType === 'view' ? 'columns' : 'children'
|
||||
const nodes = [...(base[rootKey] || [])]
|
||||
const node = { field: String(field.b_field) }
|
||||
if (schemaType === 'view') {
|
||||
node.visible = true
|
||||
node.width = listWidthOf(field)
|
||||
} else {
|
||||
node.span = 24
|
||||
}
|
||||
nodes.push(node)
|
||||
return { ...base, schemaVersion: 1, [rootKey]: nodes }
|
||||
}
|
||||
|
||||
/** 从配置中移除字段节点(返回新的配置对象) */
|
||||
export function removeSchemaField(schemaType, schema, fieldId) {
|
||||
const base = schema || createDefaultSchema(schemaType)
|
||||
const rootKey = schemaType === 'view' ? 'columns' : 'children'
|
||||
return { ...base, [rootKey]: filterFieldNode(base[rootKey] || [], fieldId) }
|
||||
}
|
||||
|
||||
/** 表单参与开关:切换字段节点的 visible(保留布局与其它属性) */
|
||||
export function setSchemaFieldVisible(schemaType, schema, fieldId, visible) {
|
||||
return patchFieldNode(schemaType, schema, fieldId, { visible })
|
||||
}
|
||||
|
||||
/** 自动编码字段在表单中只读:切换字段节点的 readonly */
|
||||
export function setSchemaFieldReadonly(schemaType, schema, fieldId, readonly) {
|
||||
return patchFieldNode(schemaType, schema, fieldId, { readonly })
|
||||
}
|
||||
|
||||
function patchFieldNode(schemaType, schema, fieldId, patch) {
|
||||
const base = schema || createDefaultSchema(schemaType)
|
||||
const rootKey = schemaType === 'view' ? 'columns' : 'children'
|
||||
const walk = (nodes) =>
|
||||
(nodes || []).map((node) => {
|
||||
if (!node || typeof node !== 'object') return node
|
||||
if (node.type === 'group') return { ...node, children: walk(node.children) }
|
||||
if (String(node.field) !== String(fieldId)) return node
|
||||
return { ...node, ...patch }
|
||||
})
|
||||
return { ...base, [rootKey]: walk(base[rootKey] || []) }
|
||||
}
|
||||
|
||||
const LIST_WIDTH_DEFAULTS = {
|
||||
input: 120,
|
||||
textarea: 160,
|
||||
number: 100,
|
||||
money: 110,
|
||||
date: 100,
|
||||
datetime: 150,
|
||||
checkbox: 60,
|
||||
select: 110,
|
||||
}
|
||||
|
||||
function listWidthOf(field) {
|
||||
return LIST_WIDTH_DEFAULTS[field?.b_type] ?? 100
|
||||
}
|
||||
|
||||
/** 查询参与:查询条件节点存在即参与 */
|
||||
export function hasQueryCondition(schema, fieldId) {
|
||||
return (schema?.groups || []).some((group) =>
|
||||
(group?.conditions || []).some((condition) => String(condition.field) === String(fieldId)),
|
||||
)
|
||||
}
|
||||
|
||||
/** 查询条件的组件/操作符默认值 */
|
||||
const QUERY_COMPONENT_DEFAULTS = {
|
||||
number: { component: 'number', operator: 'eq' },
|
||||
money: { component: 'number', operator: 'eq' },
|
||||
date: { component: 'date', operator: 'ge' },
|
||||
datetime: { component: 'datetime', operator: 'ge' },
|
||||
select: { component: 'select', operator: 'eq' },
|
||||
checkbox: { component: 'checkbox', operator: 'eq' },
|
||||
}
|
||||
|
||||
export function defaultQueryCondition(field) {
|
||||
const preset = QUERY_COMPONENT_DEFAULTS[field?.b_type] || { component: 'input', operator: 'like' }
|
||||
return { field: String(field.b_field), operator: preset.operator, component: preset.component }
|
||||
}
|
||||
|
||||
/** 在查询配置中追加条件(返回新的配置对象) */
|
||||
export function addQueryCondition(schema, field) {
|
||||
const base = schema || createDefaultSchema('query')
|
||||
const groups = Array.isArray(base.groups) ? [...base.groups] : []
|
||||
const first = { logic: 'and', ...groups[0] }
|
||||
first.conditions = [...(first.conditions || []), defaultQueryCondition(field)]
|
||||
if (groups.length) groups[0] = first
|
||||
else groups.push(first)
|
||||
return { ...base, schemaVersion: 1, quick: base.quick || [], groups }
|
||||
}
|
||||
|
||||
/** 从查询配置中移除条件(返回新的配置对象) */
|
||||
export function removeQueryCondition(schema, fieldId) {
|
||||
const base = schema || createDefaultSchema('query')
|
||||
const groups = (base.groups || []).map((group) => ({
|
||||
...group,
|
||||
conditions: (group.conditions || []).filter(
|
||||
(condition) => String(condition.field) !== String(fieldId),
|
||||
),
|
||||
}))
|
||||
return { ...base, schemaVersion: 1, quick: base.quick || [], groups }
|
||||
}
|
||||
|
||||
export { stableStringify }
|
||||
+19
-8
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { Message } from '@/components/ui'
|
||||
import { createTempId } from '@/utils/common'
|
||||
|
||||
/* 新建分组的主键 = 分组编码(s_field_group.b_id,模块内唯一、创建后不可修改)。
|
||||
* 无法使用负数临时 id(会被写入库),故生成稳定编码:时间基数 + 递增序号。 */
|
||||
@@ -308,7 +309,8 @@ export function buildClipboardProps(row, fieldNameOf, keys, normalize = {}) {
|
||||
for (const k of keys) {
|
||||
if (normalize[k]) props[k] = normalize[k](row)
|
||||
else if (k === 'b_placeholder') props[k] = row.b_placeholder || null
|
||||
else if (k === 'b_height' || k === 'b_width') props[k] = row[k] == null ? null : Number(row[k]) || null
|
||||
else if (k === 'b_height' || k === 'b_width')
|
||||
props[k] = row[k] == null ? null : Number(row[k]) || null
|
||||
else props[k] = Number(row[k]) === 1 ? 1 : 0
|
||||
}
|
||||
return { name: fieldNameOf(row), props }
|
||||
@@ -326,13 +328,19 @@ export function toggleFlag(row, key) {
|
||||
|
||||
/** 按关键字过滤字段(名称/编码),按名称排序 */
|
||||
export function filterLibraryFields(fields, keyword) {
|
||||
const kw = String(keyword || '').trim().toLowerCase()
|
||||
const kw = String(keyword || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return [...(fields || [])]
|
||||
.filter(
|
||||
(f) =>
|
||||
!kw ||
|
||||
String(f.b_name || '').toLowerCase().includes(kw) ||
|
||||
String(f.b_field || '').toLowerCase().includes(kw),
|
||||
String(f.b_name || '')
|
||||
.toLowerCase()
|
||||
.includes(kw) ||
|
||||
String(f.b_field || '')
|
||||
.toLowerCase()
|
||||
.includes(kw),
|
||||
)
|
||||
.toSorted((a, b) =>
|
||||
String(a.b_name || a.b_field || '').localeCompare(String(b.b_name || b.b_field || ''), 'zh'),
|
||||
@@ -350,6 +358,9 @@ export function filterLibraryFields(fields, keyword) {
|
||||
*/
|
||||
export function createEditRowFactory({ moduleId, getExtra }) {
|
||||
return (field, groupId) => ({
|
||||
// 行唯一标识(本地临时 id):新增行与加载行必须都有唯一 _rowKey,
|
||||
// 否则选中判定会同时命中多张卡片(回写 JSON 时丢弃)
|
||||
_rowKey: createTempId(),
|
||||
b_module_id: moduleId,
|
||||
b_field: String(field.b_field),
|
||||
b_group_id: groupId || null,
|
||||
@@ -588,7 +599,9 @@ export function useStructureEditing({
|
||||
applyGroups(groups, [gid])
|
||||
if (selectedNodeKey.value === `g:${gid}`) selectedNodeKey.value = null
|
||||
Message.success(
|
||||
affected.size > 1 ? `已删除分组「${title}」,其下字段移至「未分组」` : `已删除分组「${title}」`,
|
||||
affected.size > 1
|
||||
? `已删除分组「${title}」,其下字段移至「未分组」`
|
||||
: `已删除分组「${title}」`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -670,9 +683,7 @@ export function useStructureEditing({
|
||||
if (existing) return existing
|
||||
const row = createRow(field, groupId)
|
||||
if (targetId != null) {
|
||||
const tIdx = localRows.value.findIndex(
|
||||
(r) => String(r.b_field) === String(targetId),
|
||||
)
|
||||
const tIdx = localRows.value.findIndex((r) => String(r.b_field) === String(targetId))
|
||||
if (tIdx >= 0) {
|
||||
localRows.value.splice(tIdx + (position === 'after' ? 1 : 0), 0, row)
|
||||
} else appendIntoGroup(row, groupId, localRows.value)
|
||||
|
||||
+3
-3
@@ -78,9 +78,9 @@ export function distributeLine(rows, memberIdxs) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 按 b_id 将一行成员重新均分 24 栅格(用于字段离开/加入后回收原行空间) */
|
||||
/** 按行临时 id(_rowKey)将一行成员重新均分 24 栅格(用于字段离开/加入后回收原行空间) */
|
||||
export function rebalanceRowByIds(rows, ids) {
|
||||
const members = rows.filter((r) => ids.includes(String(r.b_id)))
|
||||
const members = rows.filter((r) => ids.includes(String(r._rowKey)))
|
||||
if (!members.length) return
|
||||
const base = Math.floor(24 / members.length)
|
||||
const rem = 24 - base * members.length
|
||||
@@ -105,4 +105,4 @@ export function sectionEndIndex(rows, gid, groups) {
|
||||
if (prevLast >= 0) return prevLast + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,66 +2,78 @@ import { Database, Folder, FolderPlus, Search } from '@lucide/vue'
|
||||
import { createTempId } from '@/utils/common'
|
||||
import { diffRows } from '@/utils/dataChanges'
|
||||
|
||||
/** 模块类型 → 树节点图标(预载到 node.icon,tree-node.vue 自动渲染) */
|
||||
/** 模块类型(s_module.b_module_type):module 模块 / data 数据模块 / virtual 虚拟模块 */
|
||||
export const MODULE_TYPE_ICON_MAP = {
|
||||
category: Folder,
|
||||
module: Folder,
|
||||
data: Database,
|
||||
query: Search,
|
||||
virtual: Search,
|
||||
}
|
||||
|
||||
export const MODULE_TYPE_LABELS = {
|
||||
module: '模块',
|
||||
data: '数据模块',
|
||||
virtual: '虚拟模块',
|
||||
}
|
||||
|
||||
/** 工具栏 / 右键「新增」菜单项(key 即 b_module_type) */
|
||||
export const MODULE_ADD_OPTIONS = [
|
||||
{ key: 'category', label: '新增分类', icon: FolderPlus },
|
||||
{ key: 'module', label: '新增模块', icon: FolderPlus },
|
||||
{ key: 'data', label: '新增数据模块', icon: Database },
|
||||
{ key: 'query', label: '新增查询模块', icon: Search },
|
||||
{ key: 'virtual', label: '新增虚拟模块', icon: Search },
|
||||
]
|
||||
|
||||
/** s_module 主表列(模块树节点 → 主表草稿行时按此提取,剔除 children/isLeaf/icon 等树注入字段) */
|
||||
const MODULE_ROW_FIELDS = [
|
||||
'b_id',
|
||||
'b_parent_id',
|
||||
'b_depth',
|
||||
'b_path',
|
||||
'b_name',
|
||||
'b_i18n',
|
||||
'b_module_type',
|
||||
'b_view_table',
|
||||
'b_save_table',
|
||||
'b_key_field',
|
||||
'b_order_sql',
|
||||
'b_query_sql',
|
||||
'b_config_json',
|
||||
'b_canuse',
|
||||
'b_xh',
|
||||
'b_bz',
|
||||
]
|
||||
|
||||
/** 界面配置 JSON 的解析/序列化/默认结构(配置链路与运行时渲染链路共用,见 @/utils/schemaJson) */
|
||||
export {
|
||||
SCHEMA_TYPES,
|
||||
createDefaultSchema,
|
||||
parseSchema,
|
||||
stableStringify,
|
||||
schemaToJsonText,
|
||||
} from '@/utils/schemaJson'
|
||||
|
||||
/**
|
||||
* 分类节点标记为非叶子(即使暂无子模块也显示折叠箭头)+ 附加类型图标。
|
||||
* 模块类型装饰:module 类型默认可展开(显示折叠箭头,即使暂无子模块),并附加类型图标。
|
||||
* 就地修改并返回原数组。
|
||||
* @param {Array} nodes 树节点数组
|
||||
* @returns {Array} 原数组
|
||||
*/
|
||||
export function markCategoryNonLeaf(nodes) {
|
||||
export function decorateModuleTree(nodes) {
|
||||
for (const node of nodes) {
|
||||
if (node.b_module_type === 'category') node.isLeaf = false
|
||||
if (node.b_module_type === 'module') node.isLeaf = false
|
||||
const icon = MODULE_TYPE_ICON_MAP[node.b_module_type]
|
||||
if (icon) node.icon = icon
|
||||
if (node.children) markCategoryNonLeaf(node.children)
|
||||
if (node.children) decorateModuleTree(node.children)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* 从树节点提取纯模块行(剔除 children/isLeaf/icon 等树注入字段)。
|
||||
* 未落库模块切回时,用它从树节点重建主表草稿。
|
||||
* @param {Object} node 树节点
|
||||
* @returns {Object} s_module 行
|
||||
*/
|
||||
/** 从树节点提取纯模块行(剔除 children/isLeaf/icon 等树注入字段),供未落库模块切回时重建主表草稿 */
|
||||
export function moduleRowOf(node) {
|
||||
// 字符串业务键方案:b_id 即模块编码,不再有独立 b_code
|
||||
return {
|
||||
b_id: node.b_id,
|
||||
b_parent_id: node.b_parent_id ? String(node.b_parent_id) : null,
|
||||
b_name: node.b_name,
|
||||
b_i18n: node.b_i18n ?? null,
|
||||
b_module_type: node.b_module_type,
|
||||
b_viewtable: node.b_viewtable ?? null,
|
||||
b_savetable: node.b_savetable ?? null,
|
||||
b_keyfield: node.b_keyfield ?? null,
|
||||
b_orderfield: node.b_orderfield ?? null,
|
||||
b_delete_policy: node.b_delete_policy ?? null,
|
||||
b_edit_mode: node.b_edit_mode ?? null,
|
||||
b_canuse: node.b_canuse,
|
||||
b_xh: node.b_xh,
|
||||
b_bz: node.b_bz ?? null,
|
||||
}
|
||||
const row = {}
|
||||
for (const field of MODULE_ROW_FIELDS) row[field] = node[field] ?? null
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* 在树中查找节点所在的父数组及索引。
|
||||
* @param {Array} nodes 树节点数组
|
||||
* @param {string|number} id 目标节点 ID
|
||||
* @returns {{ list: Array, index: number }|null}
|
||||
*/
|
||||
export function findNodePosition(nodes, id) {
|
||||
@@ -75,21 +87,47 @@ export function findNodePosition(nodes, id) {
|
||||
return null
|
||||
}
|
||||
|
||||
/** 向 updatedRows 合并一行变更(同一 b_id 累加字段,避免同节点产生多条 update 请求) */
|
||||
function addRowUpdate(updatedRows, row) {
|
||||
const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id))
|
||||
if (existing) Object.assign(existing, row)
|
||||
else updatedRows.push({ ...row })
|
||||
}
|
||||
|
||||
/**
|
||||
* 重排某组兄弟的 b_xh(10 起步、步长 10),并把变化的行收集到 updatedRows。
|
||||
* 重排某组兄弟的 b_xh(10 起步、步长 10),把变化的行收集到 updatedRows。
|
||||
* @param {Array} list 兄弟节点数组(就地修改 b_xh)
|
||||
* @param {Array} updatedRows 变化行收集器({ b_id, b_xh })
|
||||
* @param {Array} updatedRows 变化行收集器
|
||||
*/
|
||||
export function renumberSiblings(list, updatedRows) {
|
||||
list.forEach((s, i) => {
|
||||
const nextXh = (i + 1) * 10
|
||||
if (s.b_xh !== nextXh) {
|
||||
s.b_xh = nextXh
|
||||
updatedRows.push({ b_id: s.b_id, b_xh: nextXh })
|
||||
list.forEach((sibling, index) => {
|
||||
const nextXh = (index + 1) * 10
|
||||
if (sibling.b_xh !== nextXh) {
|
||||
sibling.b_xh = nextXh
|
||||
addRowUpdate(updatedRows, { b_id: sibling.b_id, b_xh: nextXh })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归重建模块树的 b_depth / b_path(b_parent_id 为权威关系,二者为派生字段)。
|
||||
* 根节点 b_depth = 0,b_path 形如 /sea/container/(节点编码前后均带 /)。
|
||||
* 仅在值实际变化时收集,避免打开即脏;父子关系变化后调用即可整树收敛。
|
||||
*/
|
||||
export function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
|
||||
for (const node of nodes) {
|
||||
const path = `${parentPath}${node.b_id}/`
|
||||
if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
|
||||
node.b_depth = depth
|
||||
node.b_path = path
|
||||
addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
|
||||
}
|
||||
if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 多语言资源键(s_i18n,主键 b_key + b_locale) ================= */
|
||||
|
||||
export function buildModuleItemI18nKey(prefix, moduleCode, itemCode) {
|
||||
const normalizedModuleCode = String(moduleCode || '').trim()
|
||||
const normalizedItemCode = String(itemCode || '').trim()
|
||||
@@ -98,13 +136,58 @@ export function buildModuleItemI18nKey(prefix, moduleCode, itemCode) {
|
||||
: null
|
||||
}
|
||||
|
||||
export function buildModuleI18nKey(moduleType, moduleCode) {
|
||||
/** 模块名称资源键:module.{模块编码} */
|
||||
export function buildModuleI18nKey(moduleCode) {
|
||||
const normalizedModuleCode = String(moduleCode || '').trim()
|
||||
if (!normalizedModuleCode) return null
|
||||
const prefix = moduleType === 'data' || moduleType === 'query' ? 'module' : 'menu'
|
||||
return `${prefix}.${normalizedModuleCode}`
|
||||
return normalizedModuleCode ? `module.${normalizedModuleCode}` : null
|
||||
}
|
||||
|
||||
/* ================= 权限点(s_power,见设计文档第 14 节) ================= */
|
||||
|
||||
/** 标准模块操作:动作属于这些值时归入模块使用权限,其余一律按业务动作 action */
|
||||
export const MODULE_ACTIONS = ['read', 'create', 'update', 'delete', 'export']
|
||||
|
||||
/** 模块使用权限的固定动作(s_power.b_action):整模块一个使用权限点,不再按标准操作拆分 */
|
||||
export const MODULE_ACCESS_ACTION = 'access'
|
||||
|
||||
/** 权限点性质由动作派生:模块使用(access / 标准操作)→ module,其它业务动作 → action */
|
||||
export function typeOfAction(action) {
|
||||
const value = String(action || '').trim()
|
||||
if (value === MODULE_ACCESS_ACTION || MODULE_ACTIONS.includes(value)) return 'module'
|
||||
return 'action'
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准动作的共用动作动词资源键(按钮 + 权限共用,见设计文档第 11 节)。
|
||||
* 「新增」与权限名称共用 action.insert(SQL 语义);未登记动作回退 action.{动作}。
|
||||
*/
|
||||
export const ACTION_I18N_KEYS = {
|
||||
read: 'action.read',
|
||||
create: 'action.insert',
|
||||
update: 'action.update',
|
||||
delete: 'action.delete',
|
||||
export: 'action.export',
|
||||
audit: 'action.audit',
|
||||
unaudit: 'action.unaudit',
|
||||
}
|
||||
|
||||
/** 动作 → 共用动作动词资源键:通用词不分模块,同一动词只保存一份翻译 */
|
||||
export function buildActionI18nKey(action) {
|
||||
const normalizedAction = String(action || '').trim()
|
||||
if (!normalizedAction) return null
|
||||
return ACTION_I18N_KEYS[normalizedAction] || `action.${normalizedAction}`
|
||||
}
|
||||
|
||||
/** 权限点多语言键派生:模块使用权限复用模块名称键 module.{模块编码};业务动作复用共用动词键 action.{动词} */
|
||||
export function buildPowerI18nKey(action, moduleCode) {
|
||||
const normalizedAction = String(action || '').trim()
|
||||
if (!normalizedAction) return null
|
||||
return typeOfAction(normalizedAction) === 'module'
|
||||
? buildModuleI18nKey(moduleCode)
|
||||
: buildActionI18nKey(normalizedAction)
|
||||
}
|
||||
|
||||
/** 字段行 b_i18n 同步为 field.{模块编码}.{字段名} */
|
||||
export function syncModuleItemI18nKeys(rows, prefix, moduleCode, codeField) {
|
||||
let changed = false
|
||||
for (const row of rows || []) {
|
||||
@@ -116,33 +199,41 @@ export function syncModuleItemI18nKeys(rows, prefix, moduleCode, codeField) {
|
||||
return changed
|
||||
}
|
||||
|
||||
/** s_i18n 扁平行 → 透视行(每 key 一行,每语言一列,_localeIds 记录已持久化 cell 的主键) */
|
||||
export function buildI18nPivot(flat) {
|
||||
const byKey = new Map()
|
||||
for (const r of flat || []) {
|
||||
if (!byKey.has(r.b_key)) byKey.set(r.b_key, [])
|
||||
byKey.get(r.b_key).push(r)
|
||||
/** 遍历界面配置节点树,访问所有分组节点 */
|
||||
function walkGroupNodes(node, visit) {
|
||||
if (!node || typeof node !== 'object') return
|
||||
if (node.type === 'group') visit(node)
|
||||
const children = node.children || node.columns
|
||||
if (Array.isArray(children)) for (const child of children) walkGroupNodes(child, visit)
|
||||
}
|
||||
|
||||
/** 收集界面配置中的分组标题多语言键(分组节点手填 i18n,空则用标题原文,不纳入受管) */
|
||||
function collectSchemaGroupItems(schemas) {
|
||||
const items = []
|
||||
const seen = new Set()
|
||||
const visit = (node) => {
|
||||
const key = String(node.i18n || '').trim()
|
||||
if (!key || seen.has(key)) return
|
||||
seen.add(key)
|
||||
items.push({ key, managed: true, defaultValue: node.title || '', id: `group:${key}` })
|
||||
}
|
||||
const rows = []
|
||||
for (const [key, recs] of byKey) {
|
||||
const row = { _id: createTempId(), b_key: key, _localeIds: {} }
|
||||
for (const r of recs) {
|
||||
row[r.b_locale] = r.b_value
|
||||
row._localeIds[r.b_locale] = r.b_id
|
||||
}
|
||||
rows.push(row)
|
||||
for (const schema of schemas) {
|
||||
if (!schema) continue
|
||||
const roots = schema.columns || schema.children
|
||||
if (Array.isArray(roots)) for (const node of roots) walkGroupNodes(node, visit)
|
||||
}
|
||||
return rows
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集模块的受管多语言键项:模块名、字段名、分组标题(b_i18n 手填 key)等系统自动生成的键。
|
||||
* item.id 为稳定标识(module:/field:/group: 前缀),供 reconcile 判失效。
|
||||
* 收集模块的受管多语言键项:模块名、字段名、界面分组标题、权限点引用的共用动作动词键。
|
||||
* item.id 为稳定标识(module:/field:/group:/power: 前缀),供 reconcile 判失效。
|
||||
* action.* 键跨模块公用:只负责登记缺失,不随单个模块删除清理。
|
||||
*/
|
||||
export function collectManagedI18nItems({ module, fields, groups }) {
|
||||
export function collectManagedI18nItems({ module, fields, schemas, powers }) {
|
||||
if (!module) return []
|
||||
const items = []
|
||||
const moduleKey = module.b_i18n || buildModuleI18nKey(module.b_module_type, module.b_id)
|
||||
const moduleKey = module.b_i18n || buildModuleI18nKey(module.b_id)
|
||||
if (moduleKey) {
|
||||
items.push({
|
||||
key: moduleKey,
|
||||
@@ -152,7 +243,7 @@ export function collectManagedI18nItems({ module, fields, groups }) {
|
||||
})
|
||||
}
|
||||
for (const field of fields || []) {
|
||||
const key = buildModuleItemI18nKey('field', module.b_id, field.b_field)
|
||||
const key = field.b_i18n || buildModuleItemI18nKey('field', module.b_id, field.b_field)
|
||||
if (key) {
|
||||
items.push({
|
||||
key,
|
||||
@@ -162,23 +253,23 @@ export function collectManagedI18nItems({ module, fields, groups }) {
|
||||
})
|
||||
}
|
||||
}
|
||||
// 分组标题多语言:b_i18n 为用户手填 key(空则运行时直接用标题原文,不纳入受管)
|
||||
for (const group of groups || []) {
|
||||
const key = String(group.b_i18n || '').trim()
|
||||
if (key) {
|
||||
items.push({
|
||||
key,
|
||||
managed: true,
|
||||
defaultValue: group.b_title || '',
|
||||
id: `group:${group.b_id}`,
|
||||
})
|
||||
}
|
||||
const seenActionKeys = new Set(items.map((item) => item.key))
|
||||
for (const power of powers || []) {
|
||||
const key = String(power?.b_i18n || '').trim()
|
||||
if (!key || !key.startsWith('action.') || seenActionKeys.has(key)) continue
|
||||
seenActionKeys.add(key)
|
||||
items.push({
|
||||
key,
|
||||
managed: true,
|
||||
defaultValue: power.b_name || '',
|
||||
id: `power:${key}`,
|
||||
})
|
||||
}
|
||||
return items
|
||||
return [...items, ...collectSchemaGroupItems(schemas || [])]
|
||||
}
|
||||
|
||||
function isEmptyI18nManaged(row, defaultLocale, languages) {
|
||||
if (row._localeIds && Object.keys(row._localeIds).length) return false
|
||||
if (row._persistedLocales && row._persistedLocales.size) return false
|
||||
for (const lang of languages || []) {
|
||||
const value = row[lang.code]
|
||||
if (value == null || value === '') continue
|
||||
@@ -189,7 +280,7 @@ function isEmptyI18nManaged(row, defaultLocale, languages) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 保证模块/字段/分组受管键始终存在:默认语言为空时自动跟随当前名称,人工翻译不被覆盖。
|
||||
* 保证模块/字段/分组的受管键始终存在:默认语言为空时自动跟随当前名称,人工翻译不被覆盖。
|
||||
* pivotRows 为草稿透视行(只读遍历),返回补齐/合并后的新透视行数组。
|
||||
*/
|
||||
export function reconcileI18nRows(pivotRows, items, { defaultLocale, languages }) {
|
||||
@@ -205,27 +296,24 @@ export function reconcileI18nRows(pivotRows, items, { defaultLocale, languages }
|
||||
for (const item of items) {
|
||||
const managedId = String(item.id || item.key)
|
||||
currentManagedIds.add(managedId)
|
||||
let row = rowByManagedId.get(managedId)
|
||||
if (!row) row = unclaimedByKey.get(String(item.key))
|
||||
if (!row && item.sourceKey) row = unclaimedByKey.get(String(item.sourceKey))
|
||||
let row = rowByManagedId.get(managedId) || unclaimedByKey.get(String(item.key))
|
||||
if (!row) {
|
||||
row = { _id: createTempId(), b_key: item.key, _localeIds: {}, _syncInjected: true }
|
||||
row = {
|
||||
_rowKey: createTempId(),
|
||||
b_key: item.key,
|
||||
_persistedLocales: new Set(),
|
||||
_syncInjected: true,
|
||||
}
|
||||
rows.push(row)
|
||||
}
|
||||
const previousDefault = row._defaultValue
|
||||
const nextDefault = item.defaultValue == null ? '' : String(item.defaultValue)
|
||||
if (
|
||||
defaultLocale &&
|
||||
(row[defaultLocale] == null ||
|
||||
row[defaultLocale] === '' ||
|
||||
row[defaultLocale] === previousDefault)
|
||||
) {
|
||||
if (defaultLocale && (row[defaultLocale] == null || row[defaultLocale] === '' || row[defaultLocale] === previousDefault)) {
|
||||
row[defaultLocale] = nextDefault
|
||||
}
|
||||
row.b_key = String(item.key)
|
||||
row._managedId = managedId
|
||||
row._defaultValue = nextDefault
|
||||
row._sortType = String(item.key).split('.', 1)[0]
|
||||
}
|
||||
|
||||
// 键已失效的受管行:自动创建且无内容的直接丢弃,有翻译或来自数据库的解除保护保留
|
||||
@@ -251,68 +339,102 @@ export function reconcileI18nRows(pivotRows, items, { defaultLocale, languages }
|
||||
continue
|
||||
}
|
||||
for (const [field, value] of Object.entries(row)) {
|
||||
if (field === '_id' || field === '_localeIds') continue
|
||||
if (field === '_rowKey' || field === '_persistedLocales') continue
|
||||
if ((hit[field] == null || hit[field] === '') && value != null) hit[field] = value
|
||||
}
|
||||
Object.assign(hit._localeIds || (hit._localeIds = {}), row._localeIds || {})
|
||||
for (const locale of row._persistedLocales || []) hit._persistedLocales?.add(locale)
|
||||
if (row._managedId && !hit._managedId) hit._managedId = row._managedId
|
||||
if (row._syncInjected && !hit._syncInjected) hit._syncInjected = row._syncInjected
|
||||
if (row._defaultValue != null && hit._defaultValue == null)
|
||||
hit._defaultValue = row._defaultValue
|
||||
if (row._sortType && !hit._sortType) hit._sortType = row._sortType
|
||||
if (row._defaultValue != null && hit._defaultValue == null) hit._defaultValue = row._defaultValue
|
||||
}
|
||||
return mergedRows
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 s_i18n 变更请求:透视草稿与原始扁平行 diff。
|
||||
* 只返回有变化的 cell;完全一致返回 null(无变化不提交)。
|
||||
*/
|
||||
export function buildI18nChangeRequest({ draftRows, originalFlat, moduleId, languages }) {
|
||||
// 模块命名空间:这些键属于当前模块元数据,落库写入 b_module_id 便于级联删除;其余保持 NULL
|
||||
const MODULE_NAMESPACES = ['module', 'menu', 'field', 'power', 'group']
|
||||
const moduleIdOf = (key) => {
|
||||
const namespace = String(key || '').split('.', 1)[0] || 'common'
|
||||
return MODULE_NAMESPACES.includes(namespace) ? String(moduleId) : null
|
||||
/** s_i18n 扁平行 → 透视行(每 key 一行,每语言一列,_persistedLocales 记录已持久化的语言) */
|
||||
export function buildI18nPivot(flat) {
|
||||
const byKey = new Map()
|
||||
for (const record of flat || []) {
|
||||
const key = String(record.b_key)
|
||||
if (!byKey.has(key)) {
|
||||
byKey.set(key, {
|
||||
_rowKey: createTempId(),
|
||||
b_key: key,
|
||||
_persistedLocales: new Set(),
|
||||
})
|
||||
}
|
||||
const row = byKey.get(key)
|
||||
row[record.b_locale] = record.b_value
|
||||
row._persistedLocales.add(String(record.b_locale))
|
||||
}
|
||||
// 字符串业务键方案:s_i18n 主键 b_id = b_key + ':' + b_locale,业务值稳定,
|
||||
// 无需再区分“已持久化 cell 沿用主键 / 新 cell 临时负数”,展平后 diffRows 按 b_id 直接工作。
|
||||
return [...byKey.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 s_i18n 变更请求:透视草稿与原始扁平行按 (b_key, b_locale) 复合键 diff。
|
||||
* 只返回有变化的 cell;完全一致返回 null。
|
||||
*/
|
||||
export function buildI18nChangeRequest({ draftRows, originalFlat, languages }) {
|
||||
const current = []
|
||||
for (const row of draftRows || []) {
|
||||
const key = String(row.b_key || '')
|
||||
if (!key) continue
|
||||
for (const lang of languages || []) {
|
||||
const locale = lang.code
|
||||
const locale = String(lang.code)
|
||||
const value = row[locale] == null ? '' : String(row[locale])
|
||||
const localeId = row._localeIds?.[locale]
|
||||
// 已持久化 cell 保留原主键(值清空仍保留行,交由 update 置空);
|
||||
// 未持久化 cell 仅在有值时登记(主键按业务规则即时生成)
|
||||
if (localeId || value !== '') {
|
||||
current.push({
|
||||
b_id: `${key}:${locale}`,
|
||||
b_key: key,
|
||||
b_locale: locale,
|
||||
b_value: value,
|
||||
})
|
||||
const persisted = row._persistedLocales?.has(locale)
|
||||
// 已持久化 cell 保留行(值清空仍保留,交由 update 置空);未持久化 cell 仅在有值时登记
|
||||
if (persisted || value !== '') {
|
||||
current.push({ b_key: key, b_locale: locale, b_value: value })
|
||||
}
|
||||
}
|
||||
}
|
||||
const changes = diffRows(current, originalFlat || [])
|
||||
const changes = diffRows(current, originalFlat || [], { keyFields: ['b_key', 'b_locale'] })
|
||||
if (!changes.inserts.length && !changes.updates.length && !changes.deletes.length) return null
|
||||
// 落库前补齐命名空间关联列(比较阶段不含它们,避免「打开即脏」)
|
||||
const enrich = (row) => ({
|
||||
b_id: row.b_id,
|
||||
b_key: row.b_key,
|
||||
b_locale: row.b_locale,
|
||||
b_value: row.b_value,
|
||||
b_module_id: moduleIdOf(row.b_key),
|
||||
b_canuse: 1,
|
||||
})
|
||||
return {
|
||||
table: 's_i18n',
|
||||
key_field: 'b_id',
|
||||
inserts: changes.inserts.map(enrich),
|
||||
updates: changes.updates.map(enrich),
|
||||
key_field: 'b_key,b_locale',
|
||||
inserts: changes.inserts.map(enrichI18nRow),
|
||||
updates: changes.updates.map(enrichI18nRow),
|
||||
deletes: changes.deletes,
|
||||
}
|
||||
}
|
||||
|
||||
/** 落库前补齐 s_i18n 的启用列(比较阶段不含它,避免「打开即脏」) */
|
||||
function enrichI18nRow(row) {
|
||||
return { b_key: row.b_key, b_locale: row.b_locale, b_value: row.b_value, b_canuse: 1 }
|
||||
}
|
||||
|
||||
/** SQL 字符串字面量(转义单引号);用于拼接只读查询条件 */
|
||||
function sqlStringLiteral(value) {
|
||||
return `N'${String(value ?? '').replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* 模块多语言资源键匹配条件(s_i18n 无 b_module_id,按已知资源键精确匹配):
|
||||
* 模块名键 + 字段资源键 + 界面配置分组键。使用精确 IN 而非 LIKE,
|
||||
* 避免模块编码中 `_` 被当作通配符导致跨模块误匹配。
|
||||
*/
|
||||
export function buildModuleI18nCondition(moduleId, keys = []) {
|
||||
const conditions = [`b_key = ${sqlStringLiteral(buildModuleI18nKey(moduleId))}`]
|
||||
const list = [...new Set((keys || []).map((key) => String(key || '').trim()).filter(Boolean))]
|
||||
if (list.length) conditions.push(`b_key IN (${list.map(sqlStringLiteral).join(', ')})`)
|
||||
return conditions.join(' OR ')
|
||||
}
|
||||
|
||||
/** 从界面配置中收集全部分组资源键(删除模块时按这些键清理 s_i18n) */
|
||||
export function collectSchemaGroupKeys(schemas) {
|
||||
const keys = new Set()
|
||||
const visit = (node) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
const key = String(node.i18n || '').trim()
|
||||
if (node.type === 'group' && key) keys.add(key)
|
||||
const children = node.children || node.columns
|
||||
if (Array.isArray(children)) for (const child of children) visit(child)
|
||||
}
|
||||
for (const schema of schemas) {
|
||||
if (!schema) continue
|
||||
const roots = schema.columns || schema.children
|
||||
if (Array.isArray(roots)) for (const node of roots) visit(node)
|
||||
}
|
||||
return [...keys]
|
||||
}
|
||||
Reference in new issue
Block a user