514 lines
17 KiB
Vue
514 lines
17 KiB
Vue
<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>
|