Files
workspace/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue
T
2026-09-03 22:10:55 +08:00

514 lines
17 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_field_edit + s_field_group + 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,
Checkbox,
DatePicker,
Form,
FormGroup,
FormItem,
Input,
Message,
Modal,
Select,
} from '@/components/ui'
import { loadDataApi, loadDataBySqlApi, nextIdApi, saveObjectApi } from '@/services/api'
import { diffRow } from '@/utils/dataChanges'
import {
identifierRe,
isEmptyValue,
parseOptions,
} from '@/components/fms-module-list/moduleListUtils'
const props = defineProps({
/** 受控打开 */
open: { type: Boolean, default: false },
/** 模块编码(s_module.b_code),必填 */
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 configLoading = ref(false)
const saving = ref(false)
const configError = ref('')
const moduleMeta = ref(null) // s_module 行
const fields = ref([]) // s_field
const groups = ref([]) // s_field_group
const editRows = ref([]) // s_field_edit 可见行(含关联字段引用)
const formData = ref({})
const keyField = ref('b_id')
/** 编辑基线(origin):打开弹窗时对 record 深拷贝快照,保存只提交与它的差异;新建模式为 null */
const originRef = ref(null)
/* ================= 分组结构推导(与 FormPreview 一致) ================= */
const sections = computed(() => {
const byGroup = new Map()
const sortedRows = [...editRows.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
built.push({ key: `g-${gid}`, title: (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_code = 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)
const [fieldRes, editRes, groupRes] = await Promise.all([
loadDataApi('s_field', `b_module_id = ${moduleId}`, 'b_id ASC'),
loadDataApi('s_field_edit', `b_module_id = ${moduleId}`, 'b_xh ASC, b_id ASC'),
loadDataApi('s_field_group', `b_module_id = ${moduleId}`, 'b_xh ASC, b_id ASC'),
])
moduleMeta.value = module
keyField.value = module.b_keyfield || 'b_id'
groups.value = groupRes.data || []
// 关联字段信息
const fieldById = new Map()
const validFields = []
for (const field of fieldRes.data || []) {
if (Number(field.b_canuse) !== 1) continue
validFields.push(field)
fieldById.set(String(field.b_id), field)
}
fields.value = validFields
// 只保留可见行并注入字段对象
const rows = []
for (const row of editRes.data || []) {
if (Number(row.b_visible) !== 1) continue
const field = fieldById.get(String(row.b_field_id))
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 editRows.value) {
const fieldName = row.field.b_field
if (fieldName === keyField.value) continue // 主键不参与表单
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.field.b_default_value)) {
data[fieldName] = row.field.b_type === 'number' || row.field.b_type === 'money' ? null : ''
} else {
data[fieldName] = row.field.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 true
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_savetable 优先,其次 b_viewtable) */
function saveTableName() {
return String(moduleMeta.value?.b_savetable || moduleMeta.value?.b_viewtable || '')
}
/** 构造完整合并行(含主键;checkbox→0/1、''→null 归一化),供保存前准备与 diff 使用 */
function collectRowData() {
const isNew = !props.record
const row = {}
if (isNew)
row[keyField.value] = null // 主键待 nextIdApi 分配
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() {
// 必填校验:s_field_edit.b_required=1 且值为空 → 提示并定位
for (const editRow of editRows.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_viewtable || '')
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 && !row[keyField.value]) {
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
</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">加载中...</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" layout="vertical" :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.filter((r) => r.field.b_field !== keyField)"
:key="row.b_id"
:name="row.field.b_field"
:label="fieldLabel(row)"
:required="Number(row.b_required) === 1"
:span="spanOf(row)"
>
<template v-if="isFieldDisabled(row)">
<!-- 禁用/只读:展示值但不允许编辑 -->
<Checkbox
v-if="row.field.b_type === 'checkbox'"
:model-value="Number(formData[row.field.b_field]) === 1"
disabled
/>
<Input v-else :model-value="formData[row.field.b_field] ?? ''" disabled readonly />
</template>
<Input
v-else-if="row.field.b_type === 'input'"
v-model="formData[row.field.b_field]"
:placeholder="row.b_placeholder"
/>
<Input
v-else-if="row.field.b_type === 'textarea'"
v-model="formData[row.field.b_field]"
type="textarea"
:placeholder="row.b_placeholder"
/>
<Select
v-else-if="row.field.b_type === 'select'"
v-model="formData[row.field.b_field]"
:options="selectOptionsOf(row.field)"
:placeholder="row.b_placeholder"
/>
<Checkbox
v-else-if="row.field.b_type === 'checkbox'"
:model-value="Number(formData[row.field.b_field]) === 1"
@update:model-value="(v) => (formData[row.field.b_field] = v ? 1 : 0)"
/>
<DatePicker
v-else-if="row.field.b_type === 'date'"
v-model="formData[row.field.b_field]"
value-format="YYYY-MM-DD"
/>
<DatePicker
v-else-if="row.field.b_type === 'datetime'"
v-model="formData[row.field.b_field]"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
/>
<Input
v-else
v-model="formData[row.field.b_field]"
:type="
row.field.b_type === 'number' || row.field.b_type === 'money' ? 'number' : 'default'
"
:placeholder="row.b_placeholder"
/>
</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>