20260903173038
This commit is contained in:
1 parent
c8940f6e03
commit
0faf44cffc
55 files changed
+3278
-208
No files matched your search
@@ -0,0 +1,454 @@
|
||||
<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=新增
|
||||
* :before-save="handleBeforeSave" // 可选:返回 false 阻止保存;返回 { extraUpdates } 追加
|
||||
* @saved="onSaved"
|
||||
* />
|
||||
*
|
||||
* 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 { 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 */
|
||||
beforeSave: { 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')
|
||||
|
||||
/* ================= 分组结构推导(与 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 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
|
||||
})
|
||||
|
||||
/* ================= 保存 ================= */
|
||||
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 }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (saving.value) return
|
||||
// 必填校验: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
|
||||
}
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const { row, isNew } = collectRowData()
|
||||
const table = String(moduleMeta.value.b_savetable || moduleMeta.value.b_viewtable || '')
|
||||
if (!table) {
|
||||
Message.error('该模块未配置保存表')
|
||||
return
|
||||
}
|
||||
|
||||
// 新增时排序字段未填 → 自动取当前最大值 + 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)
|
||||
}
|
||||
|
||||
// 业务前处理钩子
|
||||
let extraUpdates = []
|
||||
if (props.beforeSave) {
|
||||
const result = await props.beforeSave(row, { isNew, record: props.record })
|
||||
if (result === false) return
|
||||
if (result?.extraUpdates?.length) extraUpdates = result.extraUpdates
|
||||
}
|
||||
|
||||
const request = { table, key_field: keyField.value }
|
||||
if (isNew) {
|
||||
request.inserts = [row]
|
||||
if (extraUpdates.length) request.updates = extraUpdates
|
||||
} else {
|
||||
request.updates = [row, ...extraUpdates]
|
||||
}
|
||||
await saveObjectApi([request])
|
||||
Message.success(isNew ? '新增成功' : '保存成功')
|
||||
emit('saved', { row, isNew })
|
||||
emit('update:open', false)
|
||||
} catch (e) {
|
||||
Message.error('保存失败:' + (e?.message || e))
|
||||
} 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>
|
||||
Reference in new issue
Block a user