Files
workspace/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue
T
2026-09-17 21:29:08 +08:00

534 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
/**
* FmsModuleEditModal —— 元数据驱动的通用新增/编辑弹窗。
*
* 与 FmsModuleListPage 同族:传 data-code,组件按模块元数据自动渲染编辑表单,
* 字段/分组/校验/控件类型全部来自数据库(s_module_schema.edit JSON + s_field)。
*
* 渲染结构对齐表单设计器的 FormPreview(组内 24 栅格):
* Form(columns=24) → FormGroup(span=整行, 组内 24 栅格) → FormItem(span=b_colspan)
*
* 用法:
* <FmsModuleEditModal
* v-model:open="visible"
* data-code="s_i18n_type"
* :record="editingRow" // null=新增
* :on-before-save="handleBeforeSave" // 可选:返回 false 阻止保存;返回 { extraUpdates } 追加
* @saved="onSaved"
* />
*
* on-before-save(rowData, ctx): Promise<boolean | { extraUpdates?: [] }>
* ctx = { isNew, record }
*/
import { computed, ref, watch } from 'vue'
import { Button, Form, FormGroup, FormItem, Message, Modal, Spin } from '@/components/ui'
import FmsField from '@/components/fms-field/FmsField.vue'
import { loadDataApi, loadDataBySqlApi, nextIdApi, saveObjectApi } from '@/services/api'
import { diffRow } from '@/utils/dataChanges'
import { buildGroupI18nKey } from '@/utils/schemaJson'
import { identifierRe, isEmptyValue } from '@/components/fms-module-common/queryUtils'
import { parseOptions } from '@/components/fms-module-common/fieldUtils'
import { loadModuleSchemas, buildEditRender } from '@/components/fms-module-common/schemaRender'
import { useI18nStore } from '@/stores/i18n'
const props = defineProps({
/** 受控打开 */
open: { type: Boolean, default: false },
/** 模块编码(s_module.b_id,即字符串业务键主键),必填 */
dataCode: { type: String, default: '' },
/** 编辑对象(含主键等现有值);null/undefined 表示新增 */
record: { type: Object, default: null },
/** 弹窗标题;缺省按新增/编辑自动生成 */
title: { type: String, default: '' },
/** 保存前业务钩子:返回 false 阻止;返回 { extraUpdates: [] } 追加到同表 updates */
onBeforeSave: { type: Function, default: null },
/** 额外禁用规则:(fieldName, ctx) => boolean,ctx={ isNew, record, formData } */
fieldDisabled: { type: Function, default: null },
/** 弹窗宽度(px 或 CSS 宽度);缺省(0)按表单栅格布局自动推断 */
width: { type: [Number, String], default: 0 },
/** 排序字段(如 'b_xh'):新增且该字段未填时自动取当前最大值 + 10 */
orderField: { type: String, default: '' },
})
const emit = defineEmits(['update:open', 'saved'])
const i18nStore = useI18nStore()
/* ================= 元数据状态 ================= */
const configLoading = ref(false)
const saving = ref(false)
const configError = ref('')
const moduleMeta = ref(null) // s_module 行
const fields = ref([]) // s_field
const groups = ref([]) // 编辑分组节点(由 edit JSON 还原)
const editRows = ref([]) // 编辑行(由 edit JSON 还原,含关联字段引用)
const formData = ref({})
const keyField = ref('b_id')
/** 编辑基线(origin):打开弹窗时对 record 深拷贝快照,保存只提交与它的差异;新建模式为 null */
const originRef = ref(null)
const isNewMode = computed(() => !props.record)
/* 业务键主键支持:新增且主键字段未被配置进表单时,自动追加主键输入行
* (字符串业务键模块如 s_i18n_type 需用户录入编码;已配置则原样展示) */
const editableRows = computed(() => {
const rows = [...editRows.value]
if (
isNewMode.value &&
keyField.value &&
!rows.some((row) => row.field.b_field === keyField.value)
) {
const keyRowField = fields.value.find((field) => field.b_field === keyField.value)
if (keyRowField) {
rows.unshift({
b_module_id: moduleMeta.value?.b_id ?? null,
b_field: keyField.value,
b_visible: 1,
b_required: 1,
b_readonly: 0,
b_disabled: 0,
b_colspan: 24,
b_placeholder: '主键/编码,新增必填',
field: keyRowField,
})
}
}
return rows
})
/* ================= 分组结构推导(与 FormPreview 一致) ================= */
const sections = computed(() => {
const byGroup = new Map()
const sortedRows = [...editableRows.value].sort((a, b) => (a.b_xh || 0) - (b.b_xh || 0))
for (const row of sortedRows) {
const gid = row.b_group_id != null ? String(row.b_group_id) : ''
if (!byGroup.has(gid)) byGroup.set(gid, [])
byGroup.get(gid).push(row)
}
const built = []
const sortedGroups = [...groups.value].sort((a, b) => (a.b_xh || 0) - (b.b_xh || 0))
for (const group of sortedGroups) {
const gid = String(group.b_id)
const rows = byGroup.get(gid) || []
if (!rows.length) continue
// 分组标题:节点手填 i18n 优先,未填按分组 id 派生键(与模块管理页同一规则)取译文;
// 键无译文时回退 b_title,与旧行为一致
built.push({
key: `g-${gid}`,
title: i18nStore.t(
group.b_i18n || buildGroupI18nKey(moduleMeta.value?.b_id, 'edit', gid),
(group.b_title || '').trim(),
),
group,
rows,
})
}
const ungrouped = byGroup.get('') || []
if (ungrouped.length) built.push({ key: 'ungrouped', title: '', group: null, rows: ungrouped })
return built
})
const hasAnyField = computed(() => editRows.value.length > 0)
/* ================= 元数据加载 ================= */
async function loadConfig() {
const code = String(props.dataCode || '').trim()
if (!code) return
configLoading.value = true
configError.value = ''
try {
const moduleRes = await loadDataApi(
's_module',
`b_id = N'${code.replaceAll("'", "''")}' AND b_module_type = 'data' AND b_canuse = 1`,
'b_id ASC',
)
const module = moduleRes.data?.[0]
if (!module) throw new Error(`未找到编码为 ${code} 的启用数据模块`)
const moduleId = String(module.b_id)
// 字段(s_field)+ 编辑界面配置(s_module_schema.edit JSON)
const [fieldRes, schemas] = await Promise.all([
loadDataApi(
's_field',
`b_module_id = N'${moduleId.replaceAll("'", "''")}'`,
'b_xh ASC, b_field ASC',
),
loadModuleSchemas(moduleId),
])
moduleMeta.value = module
keyField.value = module.b_key_field || 'b_id'
// 关联字段信息(字段身份 = 字段列名 b_field)
const fieldByField = new Map()
const validFields = []
for (const field of fieldRes.data || []) {
if (Number(field.b_canuse) !== 1) continue
validFields.push(field)
fieldByField.set(String(field.b_field), field)
}
fields.value = validFields
// edit JSON → 分组 + 编辑行;只保留可见行并注入字段对象
const render = buildEditRender(schemas.edit)
groups.value = render.groups
const rows = []
for (const row of render.rows) {
if (Number(row.b_visible) !== 1) continue
const field = fieldByField.get(String(row.b_field))
if (!field) continue
rows.push({ ...row, field })
}
editRows.value = rows
configLoading.value = false
// 初始值:编辑回填;新增按字段默认值/类型初始化
buildFormData()
} catch (e) {
configError.value = e?.message || String(e)
configLoading.value = false
}
}
/** 初始化表单数据:编辑用 record 回填,新增按默认值 */
function buildFormData() {
const isNew = !props.record
// 编辑态保留一份不可直接编辑的 origin 基线(深拷贝),保存时与其 diff;
// 新建模式没有真实基线,保存走整行 insert
originRef.value = isNew ? null : JSON.parse(JSON.stringify(props.record || {}))
const source = props.record || {}
const data = {}
for (const row of editableRows.value) {
const fieldName = row.field.b_field
const existing = source[fieldName]
if (existing !== undefined && existing !== null) {
data[fieldName] = existing
} else if (row.field.b_type === 'checkbox') {
data[fieldName] = 0
} else if (isEmptyValue(row.b_default_value)) {
data[fieldName] = row.field.b_type === 'number' || row.field.b_type === 'money' ? null : ''
} else {
data[fieldName] = row.b_default_value
}
}
formData.value = data
}
watch(
() => props.open,
(visible) => {
if (visible) loadConfig()
},
)
/* ================= 字段级禁用(编辑配置 readonly/disabled + 主键 + 页面动态规则) ================= */
function isFieldDisabled(row) {
// 字符串业务键:新增时主键可录入(语言编码等),已保存记录主键锁定
if (row.field.b_field === keyField.value) return !isNewMode.value
if (Number(row.b_readonly) === 1 || Number(row.b_disabled) === 1) return true
if (props.fieldDisabled) {
return Boolean(
props.fieldDisabled(row.field.b_field, {
isNew: !props.record,
record: props.record,
formData: formData.value,
}),
)
}
return false
}
function fieldLabel(row) {
return row.field.b_name || row.field.b_field
}
const modalTitle = computed(() => {
if (props.title) return props.title
const base = moduleMeta.value?.b_name || '记录'
return props.record ? `编辑${base}` : `新增${base}`
})
/* ================= 栅格跨度(与表单设计器一致:b_colspan ∈ [1,24],非法回退整行) ================= */
function spanOf(row) {
const v = Number(row.b_colspan)
return v >= 1 && v <= 24 ? v : 24
}
/* ================= 弹窗宽度自动推断 ================= */
// 按 sections 内字段顺序模拟 24 栅格换行,统计最大视觉行并列字段数,
// 选宽度档位:1 列→480、2 列→720、3 列→840、4 列+→960。
// 外部 width prop 显式传入时(非零)直接使用。
const WIDTH_TIERS = [
{ maxFields: 1, width: 480 },
{ maxFields: 2, width: 720 },
{ maxFields: 3, width: 840 },
{ maxFields: Infinity, width: 960 },
]
function maxFieldsPerRow() {
let max = 1
for (const section of sections.value) {
let acc = 0
let count = 0
for (const row of section.rows) {
const span = spanOf(row)
if (acc + span > 24 && count > 0) {
if (count > max) max = count
acc = 0
count = 0
}
acc += span
count++
}
if (count > max) max = count
}
return max
}
const resolvedWidth = computed(() => {
if (props.width) return props.width
const n = maxFieldsPerRow()
return (WIDTH_TIERS.find((t) => n <= t.maxFields) || WIDTH_TIERS[WIDTH_TIERS.length - 1]).width
})
/* ================= 保存(handleSave → beforeSave → buildSaveData → 接口 → afterSave) ================= */
/** 保存过程上下文:beforeSave 阶段准备的数据,buildSaveData / afterSave 阶段消费 */
let saveCtx = { isNew: false, row: null, extraUpdates: [] }
/** 模块保存表(s_module.b_save_table 优先,其次 b_view_table) */
function saveTableName() {
return String(moduleMeta.value?.b_save_table || moduleMeta.value?.b_view_table || '')
}
/** 构造完整合并行(含主键;checkbox→0/1、''→null 归一化),供保存前准备与 diff 使用 */
function collectRowData() {
const isNew = !props.record
const row = {}
if (isNew)
// 字符串业务键主键由用户在表单录入;未配置主键行时留空(由下方雪花兜底)
row[keyField.value] = formData.value[keyField.value] ?? ''
else row[keyField.value] = props.record[keyField.value]
for (const editRow of editRows.value) {
const fieldName = editRow.field.b_field
if (fieldName === keyField.value) continue
if (isFieldDisabled(editRow)) continue // 只读/禁用字段不提交
let value = formData.value[fieldName]
if (editRow.field.b_type === 'checkbox') value = value ? 1 : 0
row[fieldName] = value === '' ? null : value
}
return { row, isNew }
}
/** 保存前校验、数据准备与业务钩子;返回 false 终止保存 */
async function beforeSave() {
// 必填校验:编辑节点 required 且值为空 → 提示并定位
for (const editRow of editableRows.value) {
if (Number(editRow.b_required) !== 1) continue
if (editRow.field.b_type === 'checkbox') continue
const value = formData.value[editRow.field.b_field]
if (isEmptyValue(value)) {
Message.warning(`请填写「${fieldLabel(editRow)}」`)
return false
}
}
if (!saveTableName()) {
Message.error('该模块未配置保存表')
return false
}
const { row, isNew } = collectRowData()
// 新增时排序字段未填 → 自动取当前最大值 + 10
if (
isNew &&
props.orderField &&
identifierRe.test(props.orderField) &&
!Number(row[props.orderField])
) {
try {
const viewTable = String(moduleMeta.value?.b_view_table || '')
if (viewTable) {
const res = await loadDataBySqlApi(
`SELECT ISNULL(MAX([${props.orderField}]), 0) AS mx FROM [${viewTable}]`,
)
row[props.orderField] = (Number(res.data?.[0]?.mx) || 0) + 10
}
} catch {
// 查最大失败时不阻塞,让用户手填或存 0
}
}
// 新增:业务键主键必填(模块定义了主键字段);未定义主键字段时回退雪花自增
if (isNew && !String(row[keyField.value] || '').trim()) {
const hasKeyRow = fields.value.some((field) => field.b_field === keyField.value)
if (hasKeyRow) {
Message.warning(`请填写主键/编码「${keyField.value}」`)
return false
}
row[keyField.value] = String((await nextIdApi()).data)
}
// 外部业务钩子:接收完整合并行,返回 false 终止;返回 { extraUpdates } 追加到同表请求
let extraUpdates = []
if (props.onBeforeSave) {
const result = await props.onBeforeSave(row, { isNew, record: props.record })
if (result === false) {
saveCtx.row = null
return false
}
if (result?.extraUpdates?.length) extraUpdates = result.extraUpdates
}
saveCtx = { isNew, row, extraUpdates }
return true
}
/** 组装保存请求:编辑态用 diffRow 与 origin 做字段级对比,只提交变化字段;无变化返回 null(不调保存接口) */
function buildSaveData() {
const { isNew, row, extraUpdates } = saveCtx
if (!row) return null
const request = { table: saveTableName(), key_field: keyField.value }
if (isNew) {
request.inserts = [row]
if (extraUpdates.length) request.updates = extraUpdates
return [request]
}
// 编辑态:字段级 diff(checkbox/null/数字归一已内置于 diffRow)
const changes = diffRow({ current: row, original: originRef.value, primaryKey: keyField.value })
if (!changes.hasChanges && !extraUpdates.length) return null
// 本行有变化:主键 + 变化字段;仅外部钩子追加了 extraUpdates:只提交追加行,避免仅主键的空更新
request.updates = changes.hasChanges ? [...changes.updates, ...extraUpdates] : extraUpdates
return [request]
}
/** 保存成功后的提示、事件与关闭 */
function afterSave() {
const { isNew, row } = saveCtx
Message.success(isNew ? '新增成功' : '保存成功')
emit('saved', { row, isNew })
emit('update:open', false)
}
async function handleSave() {
if (saving.value) return false
saveCtx = { isNew: false, row: null, extraUpdates: [] }
saving.value = true
try {
if (!(await beforeSave())) return false
const saveData = buildSaveData()
if (!saveData) {
// 编辑态无任何字段变化:按规范不调用保存接口,直接提示并关闭
Message.info('没有需要保存的变更')
emit('update:open', false)
return true
}
await saveObjectApi(saveData)
await afterSave()
return true
} catch (e) {
Message.error('保存失败:' + (e?.message || e))
return false
} finally {
saving.value = false
}
}
function handleClose() {
if (saving.value) return
emit('update:open', false)
}
/* ================= 控件渲染 ================= */
const selectOptionsOf = (field) => parseOptions(field).options
/* 控件类型:禁用/只读态统一降级为 Input(checkbox 除外)只展示原值,与旧行为一致;
否则按字段 b_type 交给 FmsField 多态渲染 */
function controlType(row) {
if (isFieldDisabled(row)) return row.field.b_type === 'checkbox' ? 'checkbox' : 'input'
return row.field.b_type
}
/* 喂给 FmsField 内部控件的额外属性:select 的选项、文本类控件的占位(日期/复选框无占位) */
function controlPropsOf(row) {
if (isFieldDisabled(row)) return {}
const type = row.field.b_type
const extra = {}
if (type === 'select') extra.options = selectOptionsOf(row.field)
if (row.b_placeholder && type !== 'checkbox' && type !== 'date' && type !== 'datetime') {
extra.placeholder = row.b_placeholder
}
return extra
}
</script>
<template>
<Modal
:open="open"
:title="modalTitle"
:confirm-loading="saving"
:mask-closable="!saving"
:keyboard="!saving"
:width="resolvedWidth"
@update:open="handleClose"
@ok="handleSave"
>
<div v-if="configLoading" class="fme-loading">
<Spin />
</div>
<div v-else-if="configError" class="fme-error">
<span>{{ configError }}</span>
<Button type="primary" @click="loadConfig">重新加载</Button>
</div>
<template v-else>
<div v-if="!hasAnyField" class="fme-empty">
该模块未配置可编辑字段(请在模块管理的表单设计中添加)
</div>
<Form v-else :model="formData" :columns="24" :gap="{ row: 16, col: 12 }">
<FormGroup
v-for="section in sections"
:key="section.key"
:title="section.title"
:variant="section.title ? 'plain' : 'line'"
:collapsible="false"
:columns="24"
>
<FormItem
v-for="row in section.rows"
:key="row.b_field || row.field.b_field"
:name="row.field.b_field"
:label="fieldLabel(row)"
:required="Number(row.b_required) === 1"
:span="spanOf(row)"
>
<FmsField
v-model="formData[row.field.b_field]"
:type="controlType(row)"
:disabled="isFieldDisabled(row)"
:readonly="isFieldDisabled(row)"
:control-props="controlPropsOf(row)"
/>
</FormItem>
</FormGroup>
</Form>
</template>
</Modal>
</template>
<style scoped>
.fme-loading,
.fme-error,
.fme-empty {
display: flex;
min-height: 120px;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--fms-text-secondary, #646a73);
text-align: center;
}
.fme-error {
flex-direction: column;
}
</style>