534 lines
18 KiB
Vue
534 lines
18 KiB
Vue
<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>
|