20260903173038

This commit is contained in:
oneao committed 2026-09-03 17:30:39 +08:00
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>