20260831223203

This commit is contained in:
oneao committed 2026-08-31 22:32:03 +08:00
1 parent 4d46ce6c53
commit 3bbcafc5be
91 files changed
+20921 -584

No files matched your search

@@ -45,7 +45,7 @@ function actionButton(action, row, triggerAction, className = 'fms-action-btn')
// 确认弹窗:状态化渲染(替代 antdv 的 Modal.confirm 命令式调用),
// 由内部 Modal 承载;okType=danger(默认)时用 danger 按钮定制 footer。
function createConfirmDialog(state, close) {
const { action, row } = state
const { action, row } = state.value
const config = typeof action.confirm === 'string' ? { title: action.confirm } : action.confirm || {}
const title = config.title || '确认操作'
const description = config.description ?? config.content ?? '确定要执行此操作吗?'
@@ -132,6 +132,9 @@ function createActionsCell(actions) {
.filter((action) => isActionVisible(action, row))
.map((action) => actionButton(action, row, triggerAction))
const visibleMoreItems = (moreAction?.items || []).filter((item) => isActionVisible(item, row))
// 确认弹窗必须先于「无更多项」的提前 return 渲染——否则仅内联操作(如删除)点击后
// confirmState 已设置但弹窗永远不会出现(表现为「删除按钮没有反应」)。
if (confirmState.value) children.push(createConfirmDialog(confirmState, closeConfirm))
if (!visibleMoreItems.length) return h('span', { class: 'fms-actions-cell' }, children)
const menuItems = visibleMoreItems.map((item, index) => ({
@@ -200,7 +203,6 @@ function createActionsCell(actions) {
)
}
if (confirmState.value) children.push(createConfirmDialog(confirmState, closeConfirm))
return h('span', { class: 'fms-actions-cell' }, children)
}
}
@@ -149,20 +149,16 @@ watch(
{ immediate: true },
)
function notifyUpdate() {
if (localRow.value) emit('update', [{ ...localRow.value }])
}
function toggleEnabled(checked) {
if (!localRow.value) return
localRow.value.b_canuse = checked ? 1 : 0
notifyUpdate()
if (localRow.value) emit('update', [{ ...localRow.value }])
}
function setRowValue(field, value) {
if (!localRow.value) return
localRow.value[field] = value
notifyUpdate()
if (localRow.value) emit('update', [{ ...localRow.value }])
}
function pad(value) {
@@ -77,14 +77,10 @@ function validate() {
defineExpose({ validate })
function emitUpdate() {
if (formData.value) emit('update', formData.value)
}
function setField(field, value) {
if (!formData.value) return
formData.value[field] = value === '' ? null : value
emitUpdate()
emit('update', formData.value)
}
// 编码变化时始终以完整编码重新派生多语言键
@@ -92,7 +88,7 @@ function onCodeInput(v) {
setField('b_code', v)
if (formData.value) {
formData.value.b_i18n = buildModuleI18nKey(formData.value.b_module_type, v)
emitUpdate()
emit('update', formData.value)
}
}
@@ -112,7 +108,7 @@ function onModuleTypeChange(v) {
formData.value.b_delete_policy = null
}
formData.value.b_i18n = buildModuleI18nKey(v, formData.value.b_code)
emitUpdate()
emit('update', formData.value)
}
</script>
@@ -3,8 +3,8 @@
* 字段定义面板(s_field):可编辑表格维护模块字段。
* i18n 键按 field.{moduleCode}.{fieldName} 自动同步。
* 表单/列表/查询三列为「参与勾选」:在一行内表达该字段参与了哪些界面,
* 勾选即改即生效(生成/隐藏默认配置行);布局细节仍在对应设计器调整。
* 参与是派生态(配置行存在且行内开关为 1),物化为字段行的 __form/__list/__query
* 勾选即改即生效(建行/删行);布局细节仍在对应设计器调整。
* 参与是派生态(配置行存在 = 参与),物化为字段行的 __form/__list/__query
* 虚拟字段交给 FmsTable 内置 checkbox 渲染(与「启用」列同款),change 按列路由
* 回 toggle 写配置草稿。参与开关语义见本文件内「字段参与开关」一节。
*/
@@ -16,11 +16,13 @@ import { createListConfigRow, createQueryConfigRow } from './components/fieldDef
/* ================= 字段参与开关(原 fieldParticipation.js 内联) =================
* 「字段是否参与 表单/列表/查询」的判定与切换。语义沿用 fieldAreaDefaults 的
* 项目约定(行存在 = 已配置,行内开关 = 是否参与):
* 项目约定(行存在 = 参与):
* - 表单参与:editConfig 行存在且 b_visible=1;关 = 置 b_visible=0,不删行
* (保留画布布局/栅格,与列表「删除列=隐藏」同一哲学)
* - 列表参与:listConfig 行存在且 b_visible=1(行由加载自动补全,通常恒存在)
* - 查询参与:queryConfig 行存在且 b_canuse=1(行不自动生成,开 = 需要时按默认生成)
* (保留画布布局/栅格)
* - 列表参与:listConfig 行存在即参与(行由用户手动管理,加载时不自动补全,
* 开 = 建行、关 = 删行,与查询面板的行存在语义一致)
* - 查询参与:queryConfig 行存在即参与(无 b_canuse 开关,开 = 建行、关 = 删行,
* 与查询面板「左字段库 + 右表格」的行存在语义一致)
*
* 所有 toggle 返回新数组:目标行浅拷贝后修改,其余行保持原引用;
* 行不存在且要「关」时无操作,原样返回(调用方可直接 emit)。
@@ -51,16 +53,14 @@ function isFormOn(editConfig, field) {
}
function isListOn(listConfig, field) {
const row = findParticipationRow(listConfig, field.b_id)
return !!row && Number(row.b_visible) === 1
return !!findParticipationRow(listConfig, field.b_id)
}
function isQueryOn(queryConfig, field) {
const row = findParticipationRow(queryConfig, field.b_id)
return !!row && Number(row.b_canuse) === 1
return !!findParticipationRow(queryConfig, field.b_id)
}
/** 行不存在时追加到末尾;forceOn 的行由工厂置参与标志为 1(显式意愿) */
/** 行不存在时追加到末尾 */
function appendRow(rows, row) {
return [...(rows || []), row]
}
@@ -75,15 +75,17 @@ function toggleForm(editConfig, field, moduleId) {
function toggleList(listConfig, field, moduleId) {
const rows = listConfig || []
const row = findParticipationRow(rows, field.b_id)
if (row) return withRowToggled(rows, row, 'b_visible')
return appendRow(rows, createListConfigRow(moduleId, field, rows, { forceOn: true }))
// 行存在 = 参与:取消勾选即删行;不存在 = 未参与:勾选即建默认行
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_id)
if (row) return withRowToggled(rows, row, 'b_canuse')
return appendRow(rows, createQueryConfigRow(moduleId, field, rows, { forceOn: true }))
// 行存在 = 参与:取消勾选即删行;不存在 = 未参与:勾选即建默认行
if (row) return rows.filter((item) => item !== row)
return appendRow(rows, createQueryConfigRow(moduleId, field, rows))
}
const props = defineProps({
@@ -161,17 +163,6 @@ watch(
{ immediate: true },
)
function emitUpdate() {
// 剥离参与虚拟字段:__ 前缀仅供表格渲染,不进字段草稿与保存请求
emit(
'update',
localFields.value.map((row) => {
const { __form, __list, __query, ...field } = row
return field
}),
)
}
function onCellChange(row, col) {
// 参与勾选:内置 checkbox 已把值写在字段行虚拟字段上(展示用途),
// 真实状态经 toggle 写回对应配置草稿,不走字段 i18n 同步
@@ -188,7 +179,14 @@ function onCellChange(row, col) {
return
}
syncModuleItemI18nKeys(localFields.value, 'field', props.moduleCode, 'b_field')
emitUpdate()
// 剥离参与虚拟字段:__ 前缀仅供表格渲染,不进字段草稿与保存请求
emit(
'update',
localFields.value.map((row) => {
const { __form, __list, __query, ...field } = row
return field
}),
)
}
const columns = shallowRef(buildColumns())
@@ -1,233 +1,101 @@
<script setup>
/**
* 多语言面板(s_i18n):透视展示——每行一个 b_key,每种语言一列。
* 受管键(模块/字段/权限的 b_i18n)自动维护:默认语言为空时自动跟随当前名称。
* 语言列表使用前端常量(DEFAULT_LANGUAGES),后续可切换语言类型表。
* 数据由 index.vue 维护(加载、受管键同步、保存 diff),
* 本面板只负责编辑交互(新增/删除/编辑单元格)并 emit 更新。
*/
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { Message, Button, Input } from '@/components/ui'
import { computed, nextTick, ref, watch } from 'vue'
import { Plus, Trash2 } from '@lucide/vue'
import { Button, Input } from '@/components/ui'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { createTempId } from '@/utils/tempId'
import { cloneData } from '@/utils/dataChanges'
import { consolidatePivotRows, getI18nNamespace } from '@/utils/i18n'
import { loadDataApi } from '@/services/api'
// 前端默认语言列表(默认语言排第一)。
// 后续若引入语言类型表(s_i18n_type)再改为接口加载。
const DEFAULT_LANGUAGES = [
{ code: 'zh-CN', name: '简体中文', default: true },
{ code: 'en-US', name: 'English', default: false },
]
const props = defineProps({
moduleId: { type: String, required: true },
/** 受管键项:[{ key, sourceKey, defaultValue, id }] */
i18nItems: { type: Array, default: () => [] },
/** 透视行数组(由 index.vue 维护) */
rows: { type: Array, default: () => [] },
/** 语言列表(index.vue 传入,保存 diff 使用同一份) */
languages: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const langOptions = DEFAULT_LANGUAGES
const originalsFlat = ref([])
const pivotRows = ref([])
const isLoading = ref(false)
const emit = defineEmits(['update'])
const langOptions = props.languages
const localRows = ref([])
const searchKey = ref('')
const activeKey = ref(null)
const tableRef = ref(null)
const selectedRowKeys = ref([])
const managedItems = computed(() => props.i18nItems.filter((item) => item && item.managed))
const requestedKeys = computed(() => {
const keys = new Set()
for (const item of props.i18nItems || []) {
if (!item) continue
if (item.key) keys.add(String(item.key))
if (item.sourceKey) keys.add(String(item.sourceKey))
}
return Array.from(keys)
})
watch(
() => props.rows,
(val) => {
if (val === localRows.value) return
localRows.value = val.map((row) => ({ ...row }))
},
{ immediate: true },
)
function isManagedRow(row) {
return Boolean(row && row._managedId)
}
// 判断受管行是否为"无内容":非默认语言全空、默认语言只有自动跟随的名称
// (来自数据库 _localeIds 或用户改过任何值都算有内容)。编码输入的中间态
// 键(module.b → module.b_ → module.b_user)只会产生这种空行。
function isEmptyManagedRow(row, defaultLocale) {
if (row._localeIds && Object.keys(row._localeIds).length) return false
for (const lang of langOptions) {
const value = row[lang.code]
if (value == null || value === '') continue
if (lang.code === defaultLocale && String(value) === String(row._defaultValue ?? '')) continue
return false
}
return true
}
// 保证模块、字段、权限的受管键始终存在。默认语言为空或仍为上一次默认名称时,
// 自动跟随当前名称;人工修改后的翻译不再被名称变化覆盖。
function reconcileManagedRows(itemsOverride) {
const items = itemsOverride || managedItems.value
const rows = [...pivotRows.value]
const rowByManagedId = new Map()
const unclaimedByKey = new Map()
for (const row of rows) {
if (row._managedId) rowByManagedId.set(String(row._managedId), row)
else unclaimedByKey.set(String(row.b_key), row)
}
const currentManagedIds = new Set()
const defaultLocale = langOptions[0]?.code || ''
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))
if (!row) {
// _syncInjected 标记由 reconcile 自动创建(非用户手输),键失效且无内容时可整行丢弃
row = { _id: createTempId(), b_key: item.key, _localeIds: {}, _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)
) {
row[defaultLocale] = nextDefault
}
row.b_key = String(item.key)
row._managedId = managedId
row._defaultValue = nextDefault
row._sortType = String(item.key).split('.', 1)[0]
}
// 键已失效的受管行:自动创建且无内容的直接丢弃(如编码输入中间态产生的空行,
// 不丢弃会残留 module.b / module.b_ / module.b_user 一串);有翻译或来自数据库的
// 解除保护保留,供管理员自行处理
const keptRows = []
for (const row of rows) {
if (row._managedId && !currentManagedIds.has(String(row._managedId))) {
if (row._syncInjected && isEmptyManagedRow(row, defaultLocale)) continue
delete row._managedId
delete row._defaultValue
}
keptRows.push(row)
}
pivotRows.value = consolidatePivotRows(keptRows)
}
// 将扁平记录按 key 聚合成一行(每种语言一列)
function buildPivot(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)
}
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)
}
return rows
}
async function loadI18n() {
const moduleId = String(props.moduleId)
isLoading.value = true
try {
const keys = requestedKeys.value
const quotedKeys = keys.map((key) => "'" + key.replace(/'/g, "''") + "'")
const where = keys.length ? 'b_key IN (' + quotedKeys.join(', ') + ')' : null
const res = where ? await loadDataApi('s_i18n', where, 'b_key ASC, b_locale ASC') : { data: [] }
if (String(props.moduleId) !== moduleId) return
const flat = (res.data || []).map((row) => ({
b_id: row.b_id,
b_key: row.b_key,
b_locale: row.b_locale,
b_value: row.b_value,
}))
originalsFlat.value = cloneData(flat)
pivotRows.value = buildPivot(flat)
reconcileManagedRows()
} catch (e) {
Message.error('加载多语言失败:' + (e?.message || e))
} finally {
if (String(props.moduleId) === moduleId) isLoading.value = false
}
}
watch(
() => props.moduleId,
() => {
loadI18n()
},
{ immediate: true },
)
// 字段/权限草稿变化(含同步字段、改名、编码输入)时实时补齐受管行:finalI18nItems
// 每次重算都是新数组,引用变化即触发。防抖 300ms——编码连续输入时合并中间态,
// 只 reconcile 最终键。加载中不做(loadI18n 完成后会自行 reconcile,避免竞态)。
let reconcileTimer = null
watch(
() => props.i18nItems,
() => {
if (isLoading.value) return
clearTimeout(reconcileTimer)
reconcileTimer = setTimeout(() => reconcileManagedRows(), 300)
},
)
onBeforeUnmount(() => clearTimeout(reconcileTimer))
function onCellChange() {
reconcileManagedRows()
}
async function addRow() {
if (props.loading || isLoading.value) return
if (props.loading) return
const newId = createTempId()
// stk-table 对同一引用数组的 push 不敏感,必须换新引用才会渲染新行
pivotRows.value = [...pivotRows.value, { _id: newId, b_key: '', _localeIds: {} }]
localRows.value = [...localRows.value, { _id: newId, b_key: '', _localeIds: {} }]
activeKey.value = String(newId)
emit('update', localRows.value)
await nextTick()
tableRef.value?.editRow?.(String(newId), 'b_key')
}
function removeRow(row) {
if (isManagedRow(row)) return
pivotRows.value = pivotRows.value.filter((r) => r !== row)
localRows.value = localRows.value.filter((r) => r !== row)
selectedRowKeys.value = selectedRowKeys.value.filter((x) => x !== String(row._id))
emit('update', localRows.value)
}
function removeByManagedIds(ids) {
const set = new Set(ids.map(String))
pivotRows.value = pivotRows.value.filter((r) => !set.has(String(r._managedId)))
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
getCheckboxProps: (row) => ({ disabled: isManagedRow(row) }),
onChange: (keys) => {
selectedRowKeys.value = keys
},
}))
function batchDelete() {
if (props.loading) return
const keys = selectedRowKeys.value
if (!keys.length) return
const delSet = new Set(keys)
const deletable = localRows.value.filter((r) => delSet.has(String(r._id)) && !isManagedRow(r))
if (!deletable.length) return
const delIds = new Set(deletable.map((r) => String(r._id)))
localRows.value = localRows.value.filter((r) => !delIds.has(String(r._id)))
selectedRowKeys.value = []
if (activeKey.value && delIds.has(String(activeKey.value))) activeKey.value = null
emit('update', localRows.value)
}
function refreshKey(key) {
if (!key) return
loadI18n()
function onCellChange() {
emit('update', localRows.value)
}
const filteredRows = computed(() => {
const kw = String(searchKey.value || '').trim().toLowerCase()
if (!kw) return pivotRows.value
return pivotRows.value.filter((row) => String(row.b_key || '').toLowerCase().includes(kw))
if (!kw) return localRows.value
return localRows.value.filter((row) => String(row.b_key || '').toLowerCase().includes(kw))
})
// 动态列:key 列 + 每种语言一列 + 操作列
const columns = computed(() => {
const cols = [
{
dataIndex: 'b_key',
title: '多语言键',
width: 260,
// 不能带 customCell:resolveCustomCell 会让 customCell 优先而忽略 editor,导致新增行无法进入输入框
editor: { type: 'input', disabled: (row) => isManagedRow(row) },
},
]
@@ -248,111 +116,12 @@ const columns = computed(() => {
type: 'delete',
label: '删除',
disabled: (row) => isManagedRow(row),
confirm: '确定删除该多语言键?',
onClick: (row) => removeRow(row),
},
],
})
return cols
})
// 供父组件保存时调用:按最终草稿态生成 s_i18n 请求
function buildSaveRequest() {
reconcileManagedRows()
const inserts = []
const updates = []
const deletes = []
// 模块命名空间:这些键属于当前模块元数据,落库时写入 b_module_id 便于按模块级联删除;
// 其余(common.* 等通用文案)b_module_id 保持 NULL,不归属任何模块
const MODULE_NAMESPACES = ['module', 'menu', 'field', 'power', 'group']
function moduleIdOf(key) {
const ns = getI18nNamespace(key)
return MODULE_NAMESPACES.includes(ns) ? props.moduleId : null
}
const originals = new Map(
originalsFlat.value.map((r) => [`${r.b_key}|${r.b_locale}`, r]),
)
const seen = new Set()
for (const row of pivotRows.value) {
const key = String(row.b_key || '')
if (!key) continue
for (const lang of langOptions) {
const locale = lang.code
const cellKey = `${key}|${locale}`
seen.add(cellKey)
const value = row[locale] == null ? '' : String(row[locale])
const original = originals.get(cellKey)
const localeId = row._localeIds?.[locale]
if (localeId) {
if (!original || String(original.b_value ?? '') !== value) {
updates.push({
b_id: localeId,
b_key: key,
b_locale: locale,
b_value: value,
b_module_id: moduleIdOf(key),
b_canuse: 1,
})
}
} else if (value !== '') {
inserts.push({
// b_id 为临时主键,由父组件分配真实主键后经 applyInsertedIds 回写;
// _rowId / _locale 为回写标记,后端按表元数据过滤会忽略这两个字段
b_id: createTempId(),
_rowId: row._id,
_locale: locale,
b_key: key,
b_locale: locale,
b_value: value,
b_module_id: moduleIdOf(key),
b_canuse: 1,
})
}
}
}
for (const r of originalsFlat.value) {
const cellKey = `${r.b_key}|${r.b_locale}`
if (!seen.has(cellKey)) deletes.push({ b_id: r.b_id })
}
if (!inserts.length && !updates.length && !deletes.length) return null
return { table: 's_i18n', key_field: 'b_id', inserts, updates, deletes }
}
function markSaved() {
const flat = []
for (const row of pivotRows.value) {
const key = String(row.b_key || '')
if (!key) continue
for (const lang of langOptions) {
const locale = lang.code
const localeId = row._localeIds?.[locale]
const value = row[locale] == null ? '' : String(row[locale])
if (localeId) flat.push({ b_id: localeId, b_key: key, b_locale: locale, b_value: value })
else if (value !== '') flat.push({ b_id: null, b_key: key, b_locale: locale, b_value: value })
}
}
originalsFlat.value = flat
}
// 父组件为 inserts 分配真实主键后调用,把 ID 回写到行内 _localeIds,
// 使 markSaved 后的基线记录带真实 b_id,避免下次保存重复插入同一 (b_key, b_locale)。
function applyInsertedIds(inserts) {
for (const ins of inserts || []) {
if (!ins._rowId || !ins._locale || ins.b_id == null) continue
const row = pivotRows.value.find((r) => String(r._id) === String(ins._rowId))
if (row) row._localeIds[ins._locale] = String(ins.b_id)
}
}
defineExpose({
buildSaveRequest,
markSaved,
refreshKey,
removeByManagedIds,
reconcileManagedRows,
applyInsertedIds,
loadI18n,
})
</script>
<template>
@@ -361,15 +130,18 @@ defineExpose({
<div class="mm-i18n__search">
<Input v-model="searchKey" placeholder="按多语言键过滤" allow-clear />
</div>
<Button type="primary" :disabled="loading || isLoading" @click="addRow">新增多语言</Button>
<Button type="outline" :disabled="loading || isLoading" @click="loadI18n">刷新</Button>
<Button type="primary" :disabled="loading" @click="addRow"><Plus :size="14" /> 新增多语言</Button>
<Button type="danger" :disabled="loading || !selectedRowKeys.length" @click="batchDelete">
<Trash2 :size="14" /> 批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
</Button>
</div>
<div class="mm-panel__body">
<FmsTable
ref="tableRef"
:columns="columns"
:data-source="filteredRows"
:editable="!loading && !isLoading"
:editable="!loading"
:row-selection="rowSelection"
row-key="_id"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row._id))"
@@ -404,5 +176,4 @@ defineExpose({
min-height: 0;
flex: 1;
}
</style>
</style>
@@ -3,7 +3,7 @@
* 列表配置面板壳组件(s_field_view)。
* 实际编辑器为共享组件 components/design-editor/TableDesignEditor.vue(mode="list"),
* 本壳只做 props/emits 接线 + 行工厂注入(列表行默认属性来自业务层 fieldDefaults.js)。
* 「删除列 = 隐藏」等列表侧差异由编辑器按 mode 处理。
* 列表配置为手动管理(行存在即展示,删除即真删),由编辑器按 mode 处理。
*/
import TableDesignEditor from './components/design-editor/TableDesignEditor.vue'
import { listRowDefaults } from './components/fieldDefaults'
@@ -27,7 +27,7 @@ const createRow = (field, groupId) => ({
b_group_id: groupId || null,
b_i18n: null,
b_xh: 0,
...listRowDefaults(field), // b_visible / b_width / b_sortable
...listRowDefaults(field), // b_width / b_sortable(b_visible 恒为 1,行存在即展示)
})
</script>
@@ -4,7 +4,8 @@
* i18n 键按 power.{moduleCode}.{powerCode} 自动同步。
*/
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import { Message, Modal, Button } from '@/components/ui'
import { Plus, Trash2 } from '@lucide/vue'
import { Message, Button } from '@/components/ui'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { syncModuleItemI18nKeys } from '@/utils/i18n'
import { createTempId } from '@/utils/tempId'
@@ -40,10 +41,6 @@ watch(
{ immediate: true },
)
function emitUpdate() {
emit('update', localPowers.value)
}
async function addRow() {
if (props.loading) return
const newId = createTempId()
@@ -61,7 +58,7 @@ async function addRow() {
}
// stk-table 对同一引用数组的 push 不敏感(内部 shallowRef + 引用 watch),必须换新引用才会渲染新行
localPowers.value = [...localPowers.value, newRow]
emitUpdate()
emit('update', localPowers.value)
activeKey.value = String(newId)
await nextTick()
tableRef.value?.editRow?.(String(newId), 'b_code')
@@ -71,7 +68,7 @@ function deleteRow(record) {
if (props.loading) return
localPowers.value = localPowers.value.filter((r) => r.b_id !== record.b_id)
selectedRowKeys.value = selectedRowKeys.value.filter((x) => x !== String(record.b_id))
emitUpdate()
emit('update', localPowers.value)
}
const selectedRowKeys = ref([])
@@ -90,13 +87,13 @@ function batchDelete() {
localPowers.value = localPowers.value.filter((r) => !delSet.has(String(r.b_id)))
selectedRowKeys.value = []
if (activeKey.value && delSet.has(activeKey.value)) activeKey.value = ''
emitUpdate()
emit('update', localPowers.value)
Message.success(`已删除 ${keys.length} 条权限`)
}
function onCellChange() {
syncModuleItemI18nKeys(localPowers.value, props.i18nPrefix, props.moduleCode, 'b_code')
emitUpdate()
emit('update', localPowers.value)
}
const columns = shallowRef([
@@ -119,7 +116,6 @@ const columns = shallowRef([
{
type: 'delete',
label: '删除',
confirm: '确定删除该权限点?',
onClick: (row) => deleteRow(row),
},
],
@@ -130,9 +126,9 @@ const columns = shallowRef([
<template>
<div class="mm-panel">
<div class="mm-panel__toolbar">
<Button type="primary" :disabled="loading" @click="addRow">新增权限</Button>
<Button type="primary" :disabled="loading" @click="addRow"><Plus :size="14" /> 新增权限</Button>
<Button type="danger" :disabled="loading || !selectedRowKeys.length" @click="batchDelete">
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
<Trash2 :size="14" /> 批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
</Button>
</div>
<div class="mm-panel__body">
@@ -1,26 +1,34 @@
<script setup>
/**
* 查询配置面板(s_field_query):字段/查询控件/操作符/默认值/启用/排序,支持行拖拽与新增。
* 选择字段时按字段控件类型智能默认查询组件与操作符(可再手动改)。
* 参与语义:生成的条件行用「启用」开关表达是否参与运行时——停用行保留配置,
* 勾选即恢复;删除仅用于清掉误加的行(补充缺失会按字段重新生成)。
* 查询配置面板(s_field_query):左字段库 + 右查询条件表格。
* 参与语义:行存在即参与(无单独「启用」开关)——字段库点击加入/定位、
* 点击 ✓ 或删除行即移除;删错的行可从字段库随时加回(生成默认组件/操作符)。
*
* 查询配置不自动生成:同步字段时不会为任何字段生成查询行(列表/表单设计不同),
* 查询条件完全由用户在字段库手动点击加入。
*
* 交互:
* - 左字段库(复用 design-editor 的 FieldLibrary):未加入的字段点击即按字段
* 控件类型生成默认查询行;已加入的显示 ✓,点击定位到表格对应行。
* - 右表格:新增查询条件(手动选字段)、编辑 查询组件/操作符/默认值、
* 行拖拽排序、删除行;选字段时自动重置组件/操作符为字段类型默认值。
*/
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import { Button } from '@/components/ui'
import { Plus, Trash2 } from '@lucide/vue'
import { Button, Message } from '@/components/ui'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { createTempId } from '@/utils/tempId'
import { queryRowDefaults } from './components/fieldDefaults'
import FieldLibrary from './components/design-editor/common/FieldLibrary.vue'
import { createQueryConfigRow, queryRowDefaults } from './components/fieldDefaults'
const props = defineProps({
moduleId: { type: String, required: true },
queryConfig: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
/** 启用字段中尚未配置查询条件的数量(父级派生),>0 时显示补充入口 */
missingCount: { type: Number, default: 0 },
})
const emit = defineEmits(['update', 'fill-missing'])
const emit = defineEmits(['update'])
const COMPONENT_OPTIONS = [
{ label: '输入框', value: 'input' },
@@ -45,6 +53,7 @@ const OPERATOR_OPTIONS = [
const localQuery = ref([])
const activeKey = ref(null)
const tableRef = ref(null)
const selectedRowKeys = ref([])
watch(
() => props.queryConfig,
@@ -55,14 +64,10 @@ watch(
{ immediate: true },
)
function emitUpdate() {
emit('update', localQuery.value)
}
function onRowOrderChange(rows) {
const sorted = rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
localQuery.value = sorted
emitUpdate()
emit('update', localQuery.value)
}
/* ================= 字段引用 ================= */
@@ -100,7 +105,7 @@ function onCellChange(row, col) {
row.b_default_value = null
}
}
emitUpdate()
emit('update', localQuery.value)
}
/* ================= 行增删 ================= */
@@ -120,10 +125,9 @@ async function addRow() {
b_operator: 'like',
b_default_value: null,
b_xh: (localQuery.value.length + 1) * 10,
b_canuse: 1,
},
]
emitUpdate()
emit('update', localQuery.value)
activeKey.value = String(newId)
await nextTick()
// 聚焦第一个可编辑列(字段),选定字段后再补组件/操作符
@@ -133,7 +137,57 @@ async function addRow() {
function removeRow(row) {
localQuery.value = localQuery.value.filter((item) => item !== row)
if (String(row.b_id) === String(activeKey.value)) activeKey.value = null
emitUpdate()
selectedRowKeys.value = selectedRowKeys.value.filter((x) => x !== String(row.b_id))
emit('update', localQuery.value)
}
/* ================= 批量删除 ================= */
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys
},
}))
function batchDelete() {
if (props.loading) return
const keys = selectedRowKeys.value
if (!keys.length) return
const delSet = new Set(keys)
localQuery.value = localQuery.value.filter((r) => !delSet.has(String(r.b_id)))
selectedRowKeys.value = []
if (activeKey.value && delSet.has(activeKey.value)) activeKey.value = null
emit('update', localQuery.value)
Message.success(`已删除 ${keys.length} 条查询条件`)
}
/* ================= 字段库交互 ================= */
// 已加入的字段(供 FieldLibrary ✓ 标记与点击定位)
function findRowByFieldId(fieldId) {
return localQuery.value.find((row) => String(row.b_field_id) === String(fieldId))
}
// 当前选中行对应的字段 id(高亮字段库)
const selectedFieldId = computed(() => {
const row = localQuery.value.find((item) => String(item.b_id) === String(activeKey.value))
return row?.b_field_id != null ? String(row.b_field_id) : null
})
// 字段库点击:已加入 → 定位表格行;未加入 → 生成默认查询行加入
function onFieldSelect(field) {
if (!field || props.loading) return
const fieldId = String(field.b_id)
const existing = findRowByFieldId(fieldId)
if (existing) {
activeKey.value = String(existing.b_id)
nextTick(() => tableRef.value?.scrollToRow?.(String(existing.b_id)))
return
}
const row = createQueryConfigRow(props.moduleId, field, localQuery.value)
localQuery.value = [...localQuery.value, row]
emit('update', localQuery.value)
activeKey.value = String(row.b_id)
nextTick(() => tableRef.value?.scrollToRow?.(String(row.b_id)))
}
const columns = shallowRef([
@@ -156,13 +210,6 @@ const columns = shallowRef([
editor: { type: 'select', options: OPERATOR_OPTIONS },
},
{ dataIndex: 'b_default_value', title: '默认查询值', width: 150, editor: { type: 'input' } },
{
dataIndex: 'b_canuse',
title: '启用',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{ dataIndex: 'b_xh', title: '排序号', width: 90 },
{
dataIndex: 'actions',
@@ -172,7 +219,6 @@ const columns = shallowRef([
{
type: 'delete',
label: '删除',
confirm: '确定删除该查询条件?',
onClick: (row) => removeRow(row),
},
],
@@ -181,53 +227,72 @@ const columns = shallowRef([
</script>
<template>
<div class="mm-panel">
<div class="mm-panel__toolbar">
<Button type="primary" :disabled="loading" @click="addRow">新增查询条件</Button>
<Button
v-if="missingCount > 0"
type="outline"
:disabled="loading"
title="为尚未配置查询条件的启用字段生成默认行"
@click="emit('fill-missing')"
>
补充缺失字段 ({{ missingCount }})
</Button>
</div>
<div class="mm-panel__body">
<FmsTable
ref="tableRef"
v-model:columns="columns"
:data-source="localQuery"
:editable="!loading"
:row-draggable="!loading"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row.b_id))"
@change="onCellChange"
@row-order-change="onRowOrderChange"
/>
<div class="mm-qdesign">
<FieldLibrary
:fields="fields"
:rows="localQuery"
:selected-field-id="selectedFieldId"
:loading="loading"
drag-mode="native"
item-title="点击加入查询;已加入的点击定位;从右侧表格删除行即移除"
added-title="已加入查询"
@select="onFieldSelect"
/>
<div class="mm-qdesign__main">
<div class="mm-qdesign__toolbar">
<Button type="primary" :disabled="loading" @click="addRow"><Plus :size="14" /> 新增查询条件</Button>
<Button type="danger" :disabled="loading || !selectedRowKeys.length" @click="batchDelete">
<Trash2 :size="14" /> 批量删除
</Button>
</div>
<div class="mm-qdesign__body">
<FmsTable
ref="tableRef"
v-model:columns="columns"
:data-source="localQuery"
:editable="!loading"
:row-draggable="!loading"
:row-selection="rowSelection"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row.b_id))"
@change="onCellChange"
@row-order-change="onRowOrderChange"
/>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.mm-panel {
display: flex;
flex-direction: column;
min-height: 0;
/* 双栏布局:左字段库(FieldLibrary 自带右侧分割线与内边距)+ 右主区 */
.mm-qdesign {
display: grid;
grid-template-columns: 184px minmax(0, 1fr);
flex: 1;
min-height: 0;
min-width: 0;
background: var(--fms-card);
}
.mm-panel__toolbar {
.mm-qdesign__main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
padding: 10px 12px;
}
.mm-qdesign__toolbar {
display: flex;
flex: none;
flex-wrap: wrap;
gap: 8px;
align-items: center;
gap: 12px;
padding: 0 0 8px;
}
.mm-panel__body {
.mm-qdesign__body {
display: flex;
flex-direction: column;
min-height: 0;
@@ -200,15 +200,11 @@ watch(
{ immediate: true },
)
function emitUpdate() {
emit('update', localEdit.value)
}
/* ================= 数据视图:行排序 ================= */
function onRowOrderChange(rows) {
const sorted = rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
localEdit.value = sorted
emitUpdate()
emit('update', localEdit.value)
}
function normalizedRow(row) {
@@ -221,7 +217,7 @@ function normalizedRow(row) {
function emitRows(rows) {
localEdit.value = rows.map(normalizedRow)
emitUpdate()
emit('update', localEdit.value)
}
/* ================= 公共查询表 ================= */
@@ -553,7 +549,7 @@ function deleteGroupDirect(section) {
for (const row of localEdit.value) {
if (String(row.b_group_id || '') === gid) row.b_group_id = null
}
emitUpdate()
emit('update', localEdit.value)
// 列表配置中引用该分组的行清理 b_group_id(引用完整性,非可见性抄写)
const listUpdated = (props.listConfig || []).map((r) =>
String(r.b_group_id || '') === gid ? { ...r, b_group_id: null } : r,
@@ -1020,7 +1016,7 @@ function moveSelectedRow(delta) {
function toggleRowFlag(row, key) {
if (!row || props.loading) return
toggleFlag(row, key)
emitUpdate()
emit('update', localEdit.value)
}
function onLabelChip(row, key) {
@@ -1045,7 +1041,7 @@ function pastePropsToSelected() {
const row = selectedRow.value
if (!row || props.loading || !clipboardProps.value) return
applyClipboardProps(row, clipboardProps.value.props)
emitUpdate()
emit('update', localEdit.value)
}
/* 字段库拖拽(clone 到画布):布局冻结模式下画布侧不会收到 add 事件,
@@ -1143,7 +1139,7 @@ function toggleInspector(key) {
const row = selectedRow.value
if (!row || props.loading) return
toggleFlag(row, key)
emitUpdate()
emit('update', localEdit.value)
}
function onColspanChange(value) {
@@ -1165,7 +1161,7 @@ function onColspanChange(value) {
sorted[i].b_colspan = Math.max(1, base + (k < rem ? 1 : 0))
})
}
emitUpdate()
emit('update', localEdit.value)
}
// 数字输入框 change(失焦/回车)提交:与原值不同才真正写入
@@ -1181,7 +1177,7 @@ function onHeightChange(value) {
const row = selectedRow.value
if (!row || !isTextarea(row) || props.loading) return
row.b_height = Math.min(480, Math.max(64, Number(value) || 120))
emitUpdate()
emit('update', localEdit.value)
}
/* 占位符:change(失焦/回车)提交,有变化才真正写入 */
@@ -1191,7 +1187,7 @@ function onPlaceholderChange(value) {
const next = String(value ?? '').trim()
if ((row.b_placeholder || '') === next) return
row.b_placeholder = next || null
emitUpdate()
emit('update', localEdit.value)
}
function removeRow(row) {
@@ -1700,7 +1696,7 @@ onBeforeUnmount(() => {
:active-key="activeKey"
@back="switchView('design')"
@row-click="(row) => (activeKey = String(row.b_id))"
@change="emitUpdate"
@change="() => emit('update', localEdit.value)"
@row-order-change="onRowOrderChange"
/>
@@ -2811,6 +2807,7 @@ textarea.mm-fdesign__ctrl {
}
.mm-fdesign__preview-empty {
grid-column: 1 / -1;
padding: 40px 0;
text-align: center;
color: var(--fms-text-secondary);
@@ -12,8 +12,8 @@
* - 删除列 = 真删;分组可启停;剪贴板全量键集;数据视图含 必填/只读/禁用 列
* mode = 'list'(列表配置,s_field_view 行):
* - 头部无编辑方式切换器
* - 删除列 = 隐藏(b_visible=0,预览置灰);无分组启停;剪贴板仅 visible/width;
* 数据视图含 标题多语言 列
* - 删除列 = 真删行(行存在即展示,与查询配置同语义);无分组启停;
* 剪贴板仅 列宽;数据视图含 标题多语言 列
*
* 用法:
* <TableDesignEditor
@@ -103,8 +103,8 @@ const editing = useStructureEditing({
applyGroups: (groups, removedGroupIds) => emit('updateGroups', groups, removedGroupIds),
applyRows: (rows) => emit('update', rows),
requestRename: (gid) => structureTreeRef.value?.startRename(gid),
// 子表:全量键集;列表:仅 显示/列宽
clipboardKeys: isSubtable.value ? undefined : ['b_visible', 'b_width'],
// 子表:全量键集;列表:仅 列宽
clipboardKeys: isSubtable.value ? undefined : ['b_width'],
fieldNameOf: (row) => fieldName(row),
getField: (fieldId) => fieldMap.value.get(fieldId),
})
@@ -143,33 +143,24 @@ const { visualColumns, bandRows, headerColumns, ungroupedCount } = useBandLayout
const groupCount = computed(() => (props.groups || []).length)
function emitUpdate() {
emit('update', localRows.value)
}
/* ================= 数据视图:行排序 ================= */
function onRowOrderChange(rows) {
localRows.value = renumberRows(rows)
emitUpdate()
emit('update', localRows.value)
}
/* ================= 列删除语义 ================= */
// list 模式:列自动包含全部启用字段,「删除列」= 隐藏(b_visible=0,预览置灰);
// subtable 模式:真删行
function deleteOrHideRow(row) {
/* ================= 列删除 ================= */
// list / subtable 统一真删行(列表不再「隐藏」语义,行存在即展示)
function deleteRow(row) {
if (!row || props.loading) return
if (isSubtable.value) removeRow(row)
else {
row.b_visible = 0
emitUpdate()
}
removeRow(row)
}
function onNodeDelete({ key }) {
if (props.loading || !key) return
if (key.startsWith('c:')) {
const row = editing.findRowByFieldId(key.slice(2))
if (row) deleteOrHideRow(row)
if (row) deleteRow(row)
} else if (key.startsWith('g:') && key !== 'g:ungrouped') {
const band = bandMeta.value.metaMap.get(key.slice(2))
if (band) deleteBand(band)
@@ -181,7 +172,7 @@ function onRemoveSelected() {
deleteBand(selectedBand.value)
return
}
deleteOrHideRow(selectedRow.value)
deleteRow(selectedRow.value)
}
/* ================= 结构树事件转发 ================= */
@@ -211,7 +202,7 @@ function onStructureContextMenuClick({ item, node }) {
} else if (item.key === 'paste') {
selectRow(node.row)
pastePropsToSelected()
} else if (item.key === 'delete') deleteOrHideRow(node.row)
} else if (item.key === 'delete') deleteRow(node.row)
}
}
@@ -263,7 +254,7 @@ function toggleInspector(key) {
const row = selectedRow.value
if (!row || props.loading) return
toggleFlag(row, key)
emitUpdate()
emit('update', localRows.value)
}
/* ================= 视图切换 ================= */
@@ -303,7 +294,6 @@ const dataColumns = computed(() => {
} else {
cols.push(
{ dataIndex: 'b_width', title: '列宽', width: 90, editor: { type: 'input', inputType: 'number' } },
{ dataIndex: 'b_visible', title: '显示', width: 70, align: 'center', editor: { type: 'checkbox' } },
{ dataIndex: 'b_i18n', title: '标题多语言', width: 220 },
)
}
@@ -410,7 +400,7 @@ const treeFeatures = computed(() => ({
:field-of="fieldOf"
:group-id-of-row="groupIdOfRow"
:commit-col-drag="commitColDrag"
:on-commit="emitUpdate"
:on-commit="() => emit('update', localRows.value)"
:empty-text="isSubtable ? undefined : '暂无列配置'"
/>
@@ -480,7 +470,7 @@ const treeFeatures = computed(() => ({
:row-drag-handle="false"
@back="switchView('design')"
@row-click="onDataRowClick"
@change="emitUpdate"
@change="() => emit('update', localRows.value)"
@row-order-change="onRowOrderChange"
/>
</div>
@@ -40,7 +40,7 @@ const props = defineProps({
/** 剪贴板是否有列属性(决定右键「粘贴」可用性) */
pasteEnabled: { type: Boolean, default: false },
/** 宿主能力开关(默认全开):groupEnable 分组启停、colClipboard 列属性复制粘贴、
* colDelete 列删除——列表配置等宿主「行存在即已配置、不删行」时关闭 */
* colDelete 列删除——列表配置等宿主需按 mode 选择关闭 */
features: { type: Object, default: () => ({}) },
})
@@ -41,7 +41,7 @@ const canMoveDown = computed(
() => !isUngrouped.value && !ctx.loading.value && !isEditing.value && props.node.canDown !== false,
)
const canDelete = computed(() => !isUngrouped.value && !ctx.loading.value && !isEditing.value)
// 列节点的删除按钮按宿主 features 整体隐藏(列表配置语义:行存在即已配置,不删行)
// 列节点的删除按钮按宿主 features 整体隐藏(列表配置行存在即展示,列删除按钮由宿主导航区/操作列提供)
const showDeleteBtn = computed(() => isGroup.value || ctx.features.value.colDelete)
// 分组:新增子分组按钮(未分组容器 / 列无此操作)
const canAddSub = computed(() => isGroup.value && !ctx.loading.value && !isEditing.value)
@@ -32,7 +32,7 @@ const props = defineProps({
widthOf: { type: Function, required: true },
/** 状态开关 chips 配置([{flag,label,icon,iconOff?}]),缺省为子表编辑的四项 */
statusFlags: { type: Array, default: null },
/** 是否显示「删除列」按钮(列表配置不删行,false 隐藏) */
/** 是否显示「删除列」按钮(缺省显示;按宿主需要隐藏) */
showRowDelete: { type: Boolean, default: true },
/** 是否显示分组「启用」开关(列表配置无分组启停,false 隐藏) */
showBandEnable: { type: Boolean, default: true },
@@ -376,6 +376,7 @@ function mockValue(col) {
<style lang="scss" scoped>
/* 预览区:最上面的实时表格,只拖列宽 */
.mm-fdesign__preview {
display: block;
flex: 0 0 auto;
max-height: 46%;
min-height: 0;
@@ -643,5 +644,6 @@ function mockValue(col) {
min-height: 80px;
color: var(--fms-text-secondary);
font-size: 11px;
white-space: nowrap;
}
</style>
@@ -1,7 +1,7 @@
/**
* 字段区域默认策略(业务层,非编辑器内核)。
* 「字段定义 → 列表列 / 查询条件默认配置行」的规则集中于此,供
* index.vue(同步字段、补充缺失)、查询面板(选定字段后的组件/操作符重置)
* index.vue(同步字段)、查询面板(选定字段后的组件/操作符重置)
* 与字段定义面板的参与开关共用。
*
* 编辑器内核(design-editor/common/useStructureEditing.js)不依赖本文件——
@@ -9,11 +9,6 @@
*/
import { createTempId } from '@/utils/tempId'
/** 大文本字段:默认不进列表、默认不作为查询条件 */
function isLargeText(field) {
return field.b_type === 'textarea' || field.b_dbtype === 'text'
}
/** 列表列默认宽度(按控件类型) */
const LIST_WIDTH_DEFAULTS = {
input: 120,
@@ -26,10 +21,13 @@ const LIST_WIDTH_DEFAULTS = {
select: 110,
}
/** 字段 → 列表列默认属性(b_visible / b_width / b_sortable) */
/**
* 字段 → 列表列默认属性(b_visible: 1 / b_width / b_sortable)。
* 列表配置为手动管理(行存在即展示),b_visible 恒为 1,不再作为显示/隐藏开关。
*/
export function listRowDefaults(field) {
return {
b_visible: isLargeText(field) ? 0 : 1,
b_visible: 1,
b_width: LIST_WIDTH_DEFAULTS[field.b_type] ?? 100,
b_sortable: 0,
}
@@ -45,7 +43,7 @@ const QUERY_COMPONENT_DEFAULTS = {
checkbox: { component: 'checkbox', operator: 'eq' },
}
/** 字段 → 查询条件默认属性(b_component / b_operator / b_canuse) */
/** 字段 → 查询条件默认属性(b_component / b_operator) */
export function queryRowDefaults(field) {
const component = QUERY_COMPONENT_DEFAULTS[field.b_type] || {
component: 'input',
@@ -54,14 +52,13 @@ export function queryRowDefaults(field) {
return {
b_component: component.component,
b_operator: component.operator,
b_canuse: isLargeText(field) ? 0 : 1,
}
}
/* ---------- 默认配置行工厂 ----------
* 与 index.vue 的默认配置补全、字段定义面板参与开关共用,
* 保证「同步字段自动补全」与「手动打开参与开关」生成的行结构一致。
* forceOn:显式意愿(手动开开关)时无视大文本默认不参与的策略,置 1。
* 与字段定义面板的参与开关共用,
* 保证「手动打开参与开关」生成的行结构一致。
* 列表/查询配置均为手动管理(行存在即展示/参与),行由用户在字段库手动添加生成。
*/
function nextXh(rows) {
@@ -74,7 +71,7 @@ function nextXh(rows) {
}
/** 字段 → 列表列配置行(s_field_view),追加到末尾时使用 */
export function createListConfigRow(moduleId, field, rows, { forceOn = false } = {}) {
export function createListConfigRow(moduleId, field, rows) {
return {
b_id: createTempId(),
b_module_id: String(moduleId),
@@ -83,12 +80,11 @@ export function createListConfigRow(moduleId, field, rows, { forceOn = false } =
b_i18n: field.b_i18n || null,
b_xh: nextXh(rows),
...listRowDefaults(field),
...(forceOn ? { b_visible: 1 } : {}),
}
}
/** 字段 → 查询条件配置行(s_field_query) */
export function createQueryConfigRow(moduleId, field, rows, { forceOn = false } = {}) {
export function createQueryConfigRow(moduleId, field, rows) {
return {
b_id: createTempId(),
b_module_id: String(moduleId),
@@ -98,6 +94,5 @@ export function createQueryConfigRow(moduleId, field, rows, { forceOn = false }
b_default_value: null,
b_xh: nextXh(rows),
...queryRowDefaults(field),
...(forceOn ? { b_canuse: 1 } : {}),
}
}
@@ -6,7 +6,7 @@
* 模块树 / 草稿加载与脏检查 / 保存编排逻辑直接内联在下方;确认弹窗状态机仅本页使用。
* 设计器共用逻辑在 components/useStructureEditing.js,本文件不包含。
*/
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useHotkeys } from '@/composables/useHotkeys'
import {
Plus,
@@ -42,6 +42,8 @@ import {
buildModuleI18nKey,
buildModuleItemI18nKey,
syncModuleItemI18nKeys,
consolidatePivotRows,
getI18nNamespace,
} from '@/utils/i18n'
import { createTempId } from '@/utils/tempId'
import {
@@ -52,7 +54,6 @@ import {
tableChange,
hasChanges,
} from '@/utils/dataChanges'
import { createListConfigRow, createQueryConfigRow } from './components/fieldDefaults'
import UnsavedChangesModal from '@/components/UnsavedChangesModal.vue'
import ModuleBasicPanel from './ModuleBasicPanel.vue'
import ModuleFieldsPanel from './ModuleFieldsPanel.vue'
@@ -80,7 +81,6 @@ const drafts = ref(null)
const activeSection = ref('basic')
const basicPanelRef = ref(null)
const autoCodePanelRef = ref(null)
const i18nPanelRef = ref(null)
// 新建模块时跳过 watch(moduleSelectedId) 的自动加载;非响应式 holder,跨 composable 共享
const skipModuleLoad = { value: false }
@@ -137,6 +137,175 @@ const finalI18nItems = computed(() => {
return items
})
// 前端默认语言列表(默认语言排第一);与面板共用同一份,保证保存 diff 与展示一致
const I18N_LANGUAGES = [
{ code: 'zh-CN', name: '简体中文', default: true },
{ code: 'en-US', name: 'English', default: false },
]
/* ================= i18n 数据维护(多语言面板数据由 index.vue 持有) ================= */
// 扁平记录(b_key/b_locale/b_value)→ 透视行(每行一个 key,每种语言一列)
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)
}
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)
}
return rows
}
function isI18nManaged(row) {
return Boolean(row && row._managedId)
}
function isEmptyI18nManaged(row, defaultLocale) {
if (row._localeIds && Object.keys(row._localeIds).length) return false
for (const lang of I18N_LANGUAGES) {
const value = row[lang.code]
if (value == null || value === '') continue
if (lang.code === defaultLocale && String(value) === String(row._defaultValue ?? '')) continue
return false
}
return true
}
// 保证模块/字段/权限受管键始终存在:默认语言为空时自动跟随当前名称,人工翻译不被覆盖
function reconcileI18nRows() {
if (!drafts.value) return
const items = finalI18nItems.value
const rows = [...drafts.value.i18n]
const rowByManagedId = new Map()
const unclaimedByKey = new Map()
for (const row of rows) {
if (row._managedId) rowByManagedId.set(String(row._managedId), row)
else unclaimedByKey.set(String(row.b_key), row)
}
const currentManagedIds = new Set()
const defaultLocale = I18N_LANGUAGES[0]?.code || ''
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))
if (!row) {
row = { _id: createTempId(), b_key: item.key, _localeIds: {}, _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)
) {
row[defaultLocale] = nextDefault
}
row.b_key = String(item.key)
row._managedId = managedId
row._defaultValue = nextDefault
row._sortType = String(item.key).split('.', 1)[0]
}
// 键已失效的受管行:自动创建且无内容的直接丢弃,有翻译或来自数据库的解除保护保留
const keptRows = []
for (const row of rows) {
if (row._managedId && !currentManagedIds.has(String(row._managedId))) {
if (row._syncInjected && isEmptyI18nManaged(row, defaultLocale)) continue
delete row._managedId
delete row._defaultValue
}
keptRows.push(row)
}
drafts.value.i18n = consolidatePivotRows(keptRows)
}
// 保存:按最终草稿态生成 s_i18n 请求(与加载时的扁平基线 diff)
function buildI18nSaveRequest() {
reconcileI18nRows()
const rows = drafts.value?.i18n || []
const originals = new Map(
(originals.value?.i18n || []).map((r) => [`${r.b_key}|${r.b_locale}`, r]),
)
const inserts = []
const updates = []
const deletes = []
// 模块命名空间:这些键属于当前模块元数据,落库写入 b_module_id 便于级联删除;其余保持 NULL
const MODULE_NAMESPACES = ['module', 'menu', 'field', 'power', 'group']
function moduleIdOf(key) {
return MODULE_NAMESPACES.includes(getI18nNamespace(key)) ? String(selectedModule.value?.b_id) : null
}
const seen = new Set()
for (const row of rows) {
const key = String(row.b_key || '')
if (!key) continue
for (const lang of I18N_LANGUAGES) {
const locale = lang.code
const cellKey = `${key}|${locale}`
seen.add(cellKey)
const value = row[locale] == null ? '' : String(row[locale])
const original = originals.get(cellKey)
const localeId = row._localeIds?.[locale]
if (localeId) {
if (!original || String(original.b_value ?? '') !== value) {
updates.push({ b_id: localeId, b_key: key, b_locale: locale, b_value: value, b_module_id: moduleIdOf(key), b_canuse: 1 })
}
} else if (value !== '') {
inserts.push({
b_id: createTempId(), _rowId: row._id, _locale: locale,
b_key: key, b_locale: locale, b_value: value, b_module_id: moduleIdOf(key), b_canuse: 1,
})
}
}
}
for (const r of originals.value?.i18n || []) {
const cellKey = `${r.b_key}|${r.b_locale}`
if (!seen.has(cellKey)) deletes.push({ b_id: r.b_id })
}
if (!inserts.length && !updates.length && !deletes.length) return null
return { table: 's_i18n', key_field: 'b_id', inserts, updates, deletes }
}
// 为新增记录回写真实主键到透视行 _localeIds,避免二次保存重复插入
function applyI18nInsertedIds(inserts) {
for (const ins of inserts || []) {
if (!ins._rowId || !ins._locale || ins.b_id == null) continue
const row = (drafts.value?.i18n || []).find((r) => String(r._id) === String(ins._rowId))
if (row) row._localeIds[ins._locale] = String(ins.b_id)
}
}
// 同步字段时删除失效受管键(_managedId 为 `field:${i18nKey}` 等,见 finalI18nItems)
function removeI18nByManagedIds(ids) {
if (!drafts.value) return
const set = new Set(ids.map(String))
drafts.value.i18n = (drafts.value.i18n || []).filter((r) => !set.has(String(r._managedId)))
}
// 字段/权限草稿变化(改名、编码输入)时防抖补齐受管行;加载中不处理(load 完成后自行 reconcile)
let i18nReconcileTimer = null
watch(
finalI18nItems,
() => {
if (configLoading.value || !drafts.value) return
clearTimeout(i18nReconcileTimer)
i18nReconcileTimer = setTimeout(() => reconcileI18nRows(), 300)
},
)
onBeforeUnmount(() => clearTimeout(i18nReconcileTimer))
/* 右侧分组配置导航:概览 / 数据结构 / 界面配置 / 业务能力 / 高级设置。
每个导航项唯一激活(activeSection)。 */
const configGroups = computed(() => {
@@ -159,14 +328,9 @@ const configGroups = computed(() => {
if (isDataModule.value) {
uiItems.push({ key: 'edit', label: '表单设计', icon: Pencil })
}
// 列表配置行在加载时自动补全全部启用字段,无「缺失」概念,不带徽标
// 列表/查询配置行均为手动管理(行存在即展示/参与),无「缺失」概念,不带徽标
uiItems.push({ key: 'list', label: '列表配置', icon: List })
uiItems.push({
key: 'query',
label: '查询配置',
icon: Search,
badge: missingQueryCount.value ? `缺 ${missingQueryCount.value}` : '',
})
uiItems.push({ key: 'query', label: '查询配置', icon: Search })
groups.push({ key: 'display', label: '界面配置', items: uiItems })
const capabilityItems = []
@@ -297,20 +461,15 @@ async function saveModule() {
}
}
const i18nPanel = i18nPanelRef.value
if (i18nPanel && typeof i18nPanel.reconcileManagedRows === 'function') {
i18nPanel.reconcileManagedRows(finalI18nItems.value)
}
if (i18nPanel && typeof i18nPanel.buildSaveRequest === 'function') {
const i18nReq = i18nPanel.buildSaveRequest()
if (i18nReq) {
// 为新增的多语言记录分配真实主键,并回写内部状态,避免二次保存重复插入
if (i18nReq.inserts?.length) {
await allocateTemporaryIds([i18nReq.inserts])
i18nPanel.applyInsertedIds?.(i18nReq.inserts)
}
saveReqs.push(i18nReq)
// i18n 数据独立保存于 s_i18n:先补齐受管键,再按基线 diff 生成请求
const i18nReq = buildI18nSaveRequest()
if (i18nReq) {
// 为新增的多语言记录分配真实主键,并回写草稿,避免二次保存重复插入
if (i18nReq.inserts?.length) {
await allocateTemporaryIds([i18nReq.inserts])
applyI18nInsertedIds(i18nReq.inserts)
}
saveReqs.push(i18nReq)
}
if (saveReqs.length === 0) {
@@ -328,8 +487,6 @@ async function saveModule() {
const prevSection = activeSection.value
await loadModuleConfig(moduleId)
activeSection.value = prevSection
// i18n 数据独立保存于 s_i18n,不随模块配置草稿重载,单独从后端刷新
i18nPanelRef.value?.loadI18n?.()
Message.success('保存成功')
return true
} catch (e) {
@@ -711,12 +868,24 @@ async function loadModuleConfig(moduleId) {
}
originals.value = original
drafts.value = cloneData(original)
// 列表配置自动包含全部启用字段:缺失的按控件类型生成默认列。
// 「删除列」语义为隐藏(b_visible=0)而非删行——行删了下次加载又会自动补回。
const listGenerated = buildMissingDefaultConfigs(original.module, original.fields)
if (listGenerated.listConfig.length) {
drafts.value.listConfig = [...(drafts.value.listConfig || []), ...listGenerated.listConfig]
// i18n 独立加载(s_i18n 不随模块配置草稿加载,按受管键查询)
if (type === 'data' || type === 'query') {
const keys = [...new Set(finalI18nItems.value.flatMap((it) => [it.key, it.sourceKey]).filter(Boolean).map(String))]
const quotedKeys = keys.map((key) => "'" + key.replace(/'/g, "''") + "'")
const where = keys.length ? 'b_key IN (' + quotedKeys.join(', ') + ')' : null
const res = where ? await loadDataApi('s_i18n', where, 'b_key ASC, b_locale ASC') : { data: [] }
if (String(moduleSelectedId.value) !== id) return
const flat = (res.data || []).map((row) => ({
b_id: row.b_id, b_key: row.b_key, b_locale: row.b_locale, b_value: row.b_value,
}))
originals.value.i18n = flat
drafts.value.i18n = buildI18nPivot(flat)
reconcileI18nRows()
} else {
originals.value.i18n = []
drafts.value.i18n = []
}
// 列表配置不再自动补全:行存在即展示,删除即真删,由用户通过字段库手动管理(与查询配置一致)。
loadedModuleId.value = id
activeSection.value = 'basic'
} catch (e) {
@@ -843,44 +1012,10 @@ function onAutoCodeUpdate(rows) {
}
/* ================= 模块:同步字段 ================= */
// 已配置过对应配置行的字段 id(列表/编辑/查询),用于判断新增字段是否缺默认配置
function configuredFieldIds(section) {
const ids = new Set()
for (const source of [originals.value, drafts.value]) {
for (const row of source?.[section] || []) {
if (row.b_field_id != null) ids.add(String(row.b_field_id))
}
}
return ids
}
// 为尚未配置的字段生成列表/查询默认行(行结构统一走 useStructureEditing 的工厂)。
// 默认不参与的字段(大文本等)同样生成行、开关置 0,保留配置空间;
// 表单设计(editConfig)不自动生成:字段由用户在表单设计器中逐个添加,
// 自动铺满会与设计器的画布/分组/栅格编辑冲突。
function buildMissingDefaultConfigs(module, fields) {
const moduleId = String(module.b_id)
const listFieldIds = configuredFieldIds('listConfig')
const queryFieldIds = configuredFieldIds('queryConfig')
const generated = { listConfig: [], queryConfig: [] }
let orderIndex = 0
for (const field of fields) {
if (Number(field.b_canuse) !== 1) continue
orderIndex++
const fieldId = String(field.b_id)
const order = orderIndex * 10
if (!listFieldIds.has(fieldId)) {
generated.listConfig.push({ ...createListConfigRow(moduleId, field, []), b_xh: order })
}
if (!queryFieldIds.has(fieldId)) {
generated.queryConfig.push({ ...createQueryConfigRow(moduleId, field, []), b_xh: order })
}
}
return generated
}
// 同步只合并字段定义与移除已消失字段的配置行/i18n,不自动生成任何配置行——
// 列表/查询配置为手动管理(行存在即展示),表单设计由用户逐个添加。
// 计算同步计划(纯计算,不改草稿):describe 结果与现有字段合并、
// 找出视图已不存在的字段、为新字段生成列表/编辑/查询默认配置。
// 找出视图已不存在的字段。
function buildSyncPlan(module, columns) {
const viewColumnSet = new Set(columns.map((col) => String(col.name || '').toLowerCase()))
const existingMap = new Map()
@@ -924,9 +1059,7 @@ function buildSyncPlan(module, columns) {
for (const field of mergedFields) {
field.b_i18n = buildModuleItemI18nKey('field', module.b_code, field.b_field)
}
const generated = buildMissingDefaultConfigs(module, mergedFields)
const generatedCount = generated.listConfig.length + generated.queryConfig.length
return { mergedFields, removedIds, removedI18nIds, generated, addedCount, generatedCount }
return { mergedFields, removedIds, removedI18nIds, addedCount }
}
function applySyncPlan(plan) {
@@ -936,18 +1069,10 @@ function applySyncPlan(plan) {
(row) => !plan.removedIds.has(String(row.b_field_id)),
)
}
i18nPanelRef.value?.removeByManagedIds?.(Array.from(plan.removedI18nIds))
}
for (const section of ['listConfig', 'queryConfig']) {
const rows = plan.generated[section]
if (!rows.length) continue
drafts.value[section] = [...(drafts.value[section] || []), ...rows]
removeI18nByManagedIds(Array.from(plan.removedI18nIds))
}
drafts.value.fields = plan.mergedFields
const parts = [`已同步 ${plan.mergedFields.length} 个字段`]
if (plan.addedCount) parts.push(`新增 ${plan.addedCount} 个并补充 ${plan.generatedCount} 条默认配置`)
if (plan.removedIds.size) parts.push(`移除 ${plan.removedIds.size} 个及其关联配置`)
Message.success(parts.join(','))
Message.success(`已成功同步 ${plan.mergedFields.length} 个字段`)
}
async function syncFields() {
@@ -973,43 +1098,6 @@ async function syncFields() {
}
}
/* ================= 欠配置统计与一键补充 ================= */
// 启用字段中尚未出现在指定配置(listConfig/queryConfig)的数量
function missingConfigCount(section) {
return computed(() => {
const fields = drafts.value?.fields || []
if (!fields.length) return 0
const configured = new Set()
for (const row of drafts.value?.[section] || []) {
if (row.b_field_id != null) configured.add(String(row.b_field_id))
}
let count = 0
for (const field of fields) {
if (Number(field.b_canuse) !== 1) continue
if (!configured.has(String(field.b_id))) count++
}
return count
})
}
const missingQueryCount = missingConfigCount('queryConfig')
// 为缺失的启用字段补充默认配置行(复用同步字段的默认行生成逻辑)
function fillMissingConfigs(sections) {
const module = drafts.value?.module
if (!module || busy.value) return
const generated = buildMissingDefaultConfigs(module, drafts.value.fields)
let total = 0
for (const section of sections) {
const rows = generated[section]
if (!rows?.length) continue
drafts.value[section] = [...(drafts.value[section] || []), ...rows]
total += rows.length
}
if (total) Message.success(`已补充 ${total} 条默认配置`)
else Message.info('没有需要补充的配置')
}
/* ================= 快捷键 ================= */
// 仅保留 Ctrl/Cmd + S 保存当前对象(通用封装见 @/composables/useHotkeys)
useHotkeys([
@@ -1221,9 +1309,7 @@ loadModuleTree()
:query-config="drafts?.queryConfig"
:fields="drafts?.fields"
:loading="busy"
:missing-count="missingQueryCount"
@update="(v) => onModuleUpdate('queryConfig', v)"
@fill-missing="() => fillMissingConfigs(['queryConfig'])"
/>
</div>
@@ -1258,10 +1344,11 @@ loadModuleTree()
class="mm-config-pane"
>
<ModuleI18nPanel
ref="i18nPanelRef"
:module-id="String(selectedModule.b_id)"
:i18n-items="finalI18nItems"
:rows="drafts?.i18n"
:languages="I18N_LANGUAGES"
:loading="busy"
@update="(v) => onModuleUpdate('i18n', v)"
/>
</div>
</div>
@@ -0,0 +1,187 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi, beforeAll, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { registerFeature, useAreaSelection } from 'stk-table-vue'
import ModuleQueryConfigPanel from '@/views/system/module-management/ModuleQueryConfigPanel.vue'
import ModulePowersPanel from '@/views/system/module-management/ModulePowersPanel.vue'
import ModuleI18nPanel from '@/views/system/module-management/ModuleI18nPanel.vue'
registerFeature(useAreaSelection)
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get() { return 5000 }
})
})
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(async () => ({ data: [] })),
saveObjectApi: vi.fn(),
nextIdApi: vi.fn(),
describeApi: vi.fn(),
}))
const fields = [
{ b_id: '1', b_name: '姓名', b_field: 'name', b_type: 'input', b_canuse: 1 }
]
describe('module-management 删除(单个无确认弹窗 + 批量)', () => {
beforeEach(() => setActivePinia(createPinia()))
async function settle() {
await nextTick()
await new Promise((r) => setTimeout(r, 50))
}
async function clickDelete(wrapper) {
const trs = wrapper.findAll('.stk-tbody-main tr')
expect(trs.length).toBeGreaterThan(0)
const delBtn = trs[0].find('button.fms-action-btn')
expect(delBtn, '行内删除按钮应存在').toBeTruthy()
await delBtn.trigger('click')
await settle()
}
async function checkNoModal() {
expect(document.body.querySelector('.fms-modal'), '删除不应弹出确认弹窗').toBeNull()
}
async function selectRowCheckbox(wrapper, index = 0) {
const trs = wrapper.findAll('.stk-tbody-main tr')
expect(trs.length).toBeGreaterThan(0)
const checkbox = trs[index].find('input[type="checkbox"]')
expect(checkbox, '行复选框应存在').toBeTruthy()
await checkbox.setValue(true)
await settle()
}
async function clickToolbarBatch(wrapper) {
const btn = wrapper.findAll('button').find((b) => b.text().includes('批量删除'))
expect(btn, '批量删除按钮应存在').toBeTruthy()
await btn.trigger('click')
await settle()
}
it('查询面板:单个删除不弹弹窗且行被移除', async () => {
const wrapper = mount(ModuleQueryConfigPanel, {
props: { moduleId: 'm1', queryConfig: [], fields, loading: false },
attachTo: document.body,
})
await settle()
await wrapper.findAll('button').find((b) => b.text().includes('新增查询条件')).trigger('click')
await settle()
expect(wrapper.findAll('.stk-tbody-main tr').length).toBe(1)
await clickDelete(wrapper)
await checkNoModal()
expect(wrapper.findAll('.stk-tbody-main tr').length, '单个删除后行应被移除').toBe(0)
wrapper.unmount()
})
it('查询面板:批量删除选中多行', async () => {
const wrapper = mount(ModuleQueryConfigPanel, {
props: { moduleId: 'm1', queryConfig: [], fields, loading: false },
attachTo: document.body,
})
await settle()
for (let i = 0; i < 2; i++) {
await wrapper.findAll('button').find((b) => b.text().includes('新增查询条件')).trigger('click')
await settle()
}
expect(wrapper.findAll('.stk-tbody-main tr').length).toBe(2)
await selectRowCheckbox(wrapper, 0)
await selectRowCheckbox(wrapper, 1)
await clickToolbarBatch(wrapper)
await checkNoModal()
expect(wrapper.findAll('.stk-tbody-main tr').length, '批量删除后应无行').toBe(0)
wrapper.unmount()
})
it('权限面板:单个删除不弹弹窗且行被移除', async () => {
const wrapper = mount(ModulePowersPanel, {
props: { moduleId: 'm1', moduleCode: 'demo', powers: [], loading: false },
attachTo: document.body,
})
await settle()
await wrapper.findAll('button').find((b) => b.text().includes('新增权限')).trigger('click')
await settle()
expect(wrapper.findAll('.stk-tbody-main tr').length).toBe(1)
await clickDelete(wrapper)
await checkNoModal()
expect(wrapper.findAll('.stk-tbody-main tr').length, '单个删除后行应被移除').toBe(0)
wrapper.unmount()
})
it('权限面板:批量删除选中多行', async () => {
const wrapper = mount(ModulePowersPanel, {
props: { moduleId: 'm1', moduleCode: 'demo', powers: [], loading: false },
attachTo: document.body,
})
await settle()
for (let i = 0; i < 2; i++) {
await wrapper.findAll('button').find((b) => b.text().includes('新增权限')).trigger('click')
await settle()
}
expect(wrapper.findAll('.stk-tbody-main tr').length).toBe(2)
await selectRowCheckbox(wrapper, 0)
await selectRowCheckbox(wrapper, 1)
await clickToolbarBatch(wrapper)
await checkNoModal()
expect(wrapper.findAll('.stk-tbody-main tr').length, '批量删除后应无行').toBe(0)
wrapper.unmount()
})
it('多语言面板:单个删除不弹弹窗且行被移除', async () => {
const wrapper = mount(ModuleI18nPanel, {
props: {
moduleId: 'm1',
rows: [],
languages: [
{ code: 'zh-CN', name: '简体中文', default: true },
{ code: 'en-US', name: 'English', default: false },
],
loading: false,
},
attachTo: document.body,
})
await settle()
await wrapper.findAll('button').find((b) => b.text().includes('新增多语言')).trigger('click')
await settle()
const before = wrapper.findAll('.stk-tbody-main tr').length
expect(before).toBeGreaterThan(0)
await clickDelete(wrapper)
await checkNoModal()
expect(wrapper.findAll('.stk-tbody-main tr').length, '单个删除后行应减少').toBe(before - 1)
wrapper.unmount()
})
it('多语言面板:批量删除选中多行', async () => {
const wrapper = mount(ModuleI18nPanel, {
props: {
moduleId: 'm1',
rows: [],
languages: [
{ code: 'zh-CN', name: '简体中文', default: true },
{ code: 'en-US', name: 'English', default: false },
],
loading: false,
},
attachTo: document.body,
})
await settle()
const before = wrapper.findAll('.stk-tbody-main tr').length
for (let i = 0; i < 2; i++) {
await wrapper.findAll('button').find((b) => b.text().includes('新增多语言')).trigger('click')
await settle()
}
const total = wrapper.findAll('.stk-tbody-main tr').length
expect(total).toBeGreaterThanOrEqual(before + 2)
await selectRowCheckbox(wrapper, 0)
await selectRowCheckbox(wrapper, 1)
await clickToolbarBatch(wrapper)
await checkNoModal()
expect(wrapper.findAll('.stk-tbody-main tr').length, '批量删除后应只剩受管行').toBe(before)
wrapper.unmount()
})
})