20260911173133

This commit is contained in:
oneao committed 2026-09-11 17:31:34 +08:00
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,
@@ -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 作用域内,需全局隐藏:
@@ -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')
@@ -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')"
@@ -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"
@@ -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,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)
@@ -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]
}