20260903221055

This commit is contained in:
oneao committed 2026-09-03 22:10:55 +08:00
1 parent 3d4e2c7413
commit 0928437c67
46 files changed
+2121 -1643

No files matched your search

@@ -13,11 +13,11 @@
* v-model:open="visible"
* data-code="s_i18n_type"
* :record="editingRow" // null=新增
* :before-save="handleBeforeSave" // 可选:返回 false 阻止保存;返回 { extraUpdates } 追加
* :on-before-save="handleBeforeSave" // 可选:返回 false 阻止保存;返回 { extraUpdates } 追加
* @saved="onSaved"
* />
*
* before-save(rowData, ctx): Promise<boolean | { extraUpdates?: [] }>
* on-before-save(rowData, ctx): Promise<boolean | { extraUpdates?: [] }>
* ctx = { isNew, record }
*/
import { computed, ref, watch } from 'vue'
@@ -34,7 +34,12 @@ import {
Select,
} from '@/components/ui'
import { loadDataApi, loadDataBySqlApi, nextIdApi, saveObjectApi } from '@/services/api'
import { identifierRe, isEmptyValue, parseOptions } from '@/components/fms-module-list/moduleListUtils'
import { diffRow } from '@/utils/dataChanges'
import {
identifierRe,
isEmptyValue,
parseOptions,
} from '@/components/fms-module-list/moduleListUtils'
const props = defineProps({
/** 受控打开 */
@@ -45,8 +50,8 @@ const props = defineProps({
record: { type: Object, default: null },
/** 弹窗标题;缺省按新增/编辑自动生成 */
title: { type: String, default: '' },
/** 保存前回调:返回 false 阻止;返回 { extraUpdates: [] } 追加到同表 updates */
beforeSave: { type: Function, default: null },
/** 保存前业务钩子:返回 false 阻止;返回 { extraUpdates: [] } 追加到同表 updates */
onBeforeSave: { type: Function, default: null },
/** 额外禁用规则:(fieldName, ctx) => boolean,ctx={ isNew, record, formData } */
fieldDisabled: { type: Function, default: null },
/** 弹窗宽度(px 或 CSS 宽度);缺省(0)按表单栅格布局自动推断 */
@@ -67,6 +72,8 @@ 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(() => {
@@ -149,6 +156,10 @@ async function loadConfig() {
/** 初始化表单数据:编辑用 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) {
@@ -244,11 +255,21 @@ const resolvedWidth = computed(() => {
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 分配
if (isNew)
row[keyField.value] = null // 主键待 nextIdApi 分配
else row[keyField.value] = props.record[keyField.value]
for (const editRow of editRows.value) {
@@ -262,8 +283,8 @@ function collectRowData() {
return { row, isNew }
}
async function handleSave() {
if (saving.value) return
/** 保存前校验、数据准备与业务钩子;返回 false 终止保存 */
async function beforeSave() {
// 必填校验:s_field_edit.b_required=1 且值为空 → 提示并定位
for (const editRow of editRows.value) {
if (Number(editRow.b_required) !== 1) continue
@@ -271,60 +292,101 @@ async function handleSave() {
const value = formData.value[editRow.field.b_field]
if (isEmptyValue(value)) {
Message.warning(`请填写「${fieldLabel(editRow)}」`)
return
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 {
const { row, isNew } = collectRowData()
const table = String(moduleMeta.value.b_savetable || moduleMeta.value.b_viewtable || '')
if (!table) {
Message.error('该模块未配置保存表')
return
if (!(await beforeSave())) return false
const saveData = buildSaveData()
if (!saveData) {
// 编辑态无任何字段变化:按规范不调用保存接口,直接提示并关闭
Message.info('没有需要保存的变更')
emit('update:open', false)
return true
}
// 新增时排序字段未填 → 自动取当前最大值 + 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)
await saveObjectApi(saveData)
await afterSave()
return true
} catch (e) {
Message.error('保存失败:' + (e?.message || e))
return false
} finally {
saving.value = false
}
@@ -383,12 +445,7 @@ const selectOptionsOf = (field) => parseOptions(field).options
:model-value="Number(formData[row.field.b_field]) === 1"
disabled
/>
<Input
v-else
:model-value="formData[row.field.b_field] ?? ''"
disabled
readonly
/>
<Input v-else :model-value="formData[row.field.b_field] ?? ''" disabled readonly />
</template>
<Input
v-else-if="row.field.b_type === 'input'"
@@ -426,7 +483,9 @@ const selectOptionsOf = (field) => parseOptions(field).options
<Input
v-else
v-model="formData[row.field.b_field]"
:type="row.field.b_type === 'number' || row.field.b_type === 'money' ? 'number' : 'default'"
:type="
row.field.b_type === 'number' || row.field.b_type === 'money' ? 'number' : 'default'
"
:placeholder="row.b_placeholder"
/>
</FormItem>
@@ -4,7 +4,7 @@ import { useRoute } from 'vue-router'
import { Columns3, RefreshCw, Save } from '@lucide/vue'
import { Button, Message, Pagination, Spin } from '@/components/ui'
import { loadDataApi, nextIdApi, pageDataApi, saveObjectApi } from '@/services/api'
import { useI18n } from '@/utils/translate'
import { useI18nStore } from '@/stores/i18n'
import {
identifierRe,
buildQueryCondition,
@@ -30,8 +30,8 @@ const props = defineProps({
editable: { type: Boolean, default: false },
// 行拖拽排序:开启后显示拖拽手柄,拖拽按 orderField 重排并纳入脏保存。
rowDraggable: { type: Boolean, default: false },
// 行拖拽排序字段,仅 rowDraggable 时生效。
orderField: { type: String, default: 'b_xh' },
// 行拖拽排序字段;未显式传入时不启用业务序号排序。
orderField: { type: String, default: '' },
})
const emit = defineEmits([
@@ -44,7 +44,7 @@ const emit = defineEmits([
])
const slots = useSlots()
const route = useRoute()
const { t } = useI18n()
const { t } = useI18nStore()
const tableRef = ref(null)
// keep-alive 中的缓存实例必须保留各自的路由上下文,不能跟随全局 route 变化。
const instanceRoutePath = route.path
@@ -78,6 +78,11 @@ const pagination = reactive({
let dataRequestVersion = 0
const loading = computed(() => configLoading.value || dataLoading.value)
const effectiveOrderField = computed(() => {
const field = String(props.orderField || '').trim()
return ['b_xh', 'mx_xh'].includes(field) ? field : ''
})
const effectiveRowDraggable = computed(() => props.rowDraggable && Boolean(effectiveOrderField.value))
// ── 外部工具 ──
const moreMenuItems = computed(() => [
@@ -513,15 +518,16 @@ async function addRow() {
const newId = String(idRes.data)
const newRow = { [keyField.value]: newId }
// 排序字段取当前最大值 + 10;checkbox 字段默认 0。
if (props.orderField && identifierRe.test(props.orderField)) {
const orderField = effectiveOrderField.value
if (orderField) {
let maxXh = 0
for (const row of dataSource.value) {
maxXh = Math.max(maxXh, Number(row[props.orderField]) || 0)
maxXh = Math.max(maxXh, Number(row[orderField]) || 0)
}
newRow[props.orderField] = maxXh + 10
newRow[orderField] = maxXh + 10
}
for (const field of activeFields.value) {
if (field.b_field === keyField.value || field.b_field === props.orderField) continue
if (field.b_field === keyField.value || field.b_field === orderField) continue
if (newRow[field.b_field] !== undefined) continue
if (field.b_type === 'checkbox') newRow[field.b_field] = 0
}
@@ -629,8 +635,8 @@ async function deleteSelected(rowKeys) {
// editable 模式:标记为脏、随 saveChanges 一起提交;
// 非 editable 模式:直接持久化(行内排序是独立操作,无统一保存入口)。
function handleRowOrderChange(orderedRows) {
if (!props.rowDraggable || !props.orderField) return
if (!identifierRe.test(props.orderField)) return
const orderField = effectiveOrderField.value
if (!effectiveRowDraggable.value) return
const rowByOld = new Map(dataSource.value.map((row) => [String(resolveRowKey(row)), row]))
const changedKeys = new Set()
const nextRows = []
@@ -638,8 +644,8 @@ function handleRowOrderChange(orderedRows) {
for (const row of orderedRows) {
const key = String(resolveRowKey(row))
const oldRow = rowByOld.get(key)
const oldValue = oldRow ? Number(oldRow[props.orderField]) || 0 : 0
const copy = { ...row, [props.orderField]: order }
const oldValue = oldRow ? Number(oldRow[orderField]) || 0 : 0
const copy = { ...row, [orderField]: order }
nextRows.push(copy)
if (oldValue !== order) changedKeys.add(key)
order += 10
@@ -650,7 +656,7 @@ function handleRowOrderChange(orderedRows) {
const dirtyFieldsByKey = new Map()
for (const key of changedKeys) {
const fields = dirtyFieldsByKey.get(key) || new Set()
fields.add(props.orderField)
fields.add(orderField)
dirtyFieldsByKey.set(key, fields)
}
for (const [key, fields] of dirtyFieldsByKey) {
@@ -671,7 +677,8 @@ function handleRowOrderChange(orderedRows) {
/** 非 editable 模式的行拖拽排序持久化:只提交 orderField 变化的行 */
async function persistRowOrder(changedKeys) {
if (!changedKeys.size || saving.value) return
const orderField = effectiveOrderField.value
if (!changedKeys.size || saving.value || !orderField) return
const table = resolveSaveTable()
if (!table) {
Message.error('该模块未配置保存表,无法保存排序')
@@ -688,7 +695,7 @@ async function persistRowOrder(changedKeys) {
for (const key of changedKeys) {
const row = rowByKey.get(key)
if (!row) continue
updates.push({ [keyField.value]: row[keyField.value], [props.orderField]: row[props.orderField] })
updates.push({ [keyField.value]: row[keyField.value], [orderField]: row[orderField] })
}
if (!updates.length) return
await saveObjectApi([{ table, key_field: keyField.value, updates }])
@@ -815,7 +822,7 @@ defineExpose({
:expandable="expandable"
:inline-action-limit="inlineActionLimit"
:editable="editable"
:row-draggable="rowDraggable"
:row-draggable="effectiveRowDraggable"
@row-click="$emit('row-click', $event)"
@cell-click="$emit('cell-click', $event)"
@row-action="$emit('row-action', $event)"
@@ -2,7 +2,7 @@
import { computed, h, ref, shallowRef, useSlots, watch } from 'vue'
import { Checkbox } from '@/components/ui'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { useI18n } from '@/utils/translate'
import { useI18nStore } from '@/stores/i18n'
import { formatDateValue, parseOptions } from './moduleListUtils'
const props = defineProps({
@@ -31,7 +31,9 @@ const emit = defineEmits([
'cell-change',
'row-order-change',
])
const { t, localeValue } = useI18n()
const i18nStore = useI18nStore()
const { t } = i18nStore
const localeValue = computed(() => i18nStore.locale)
const slots = useSlots()
const tableRef = ref(null)
const columns = shallowRef([])
@@ -2,7 +2,7 @@
import { computed, reactive, ref, watch } from 'vue'
import { ChevronDown, ChevronUp, Ellipsis, Search } from '@lucide/vue'
import { Button, Dropdown, FormItem, Select } from '@/components/ui'
import { useI18n } from '@/utils/translate'
import { useI18nStore } from '@/stores/i18n'
import FmsQueryControl from './FmsQueryControl.vue'
import { defaultQueryValue, emptyQueryValue } from './moduleListUtils'
@@ -13,7 +13,7 @@ const props = defineProps({
defaultAdvancedCount: { type: Number, default: 6 },
})
const emit = defineEmits(['search', 'reset', 'more-action'])
const { t } = useI18n()
const { t } = useI18nStore()
const filters = reactive({})
const quickFieldName = ref('')
const quickValue = ref(undefined)
@@ -4,29 +4,6 @@ export const identifierRe = /^[A-Za-z_][A-Za-z0-9_]*$/
const padDatePart = (number) => String(number).padStart(2, '0')
// 序号字段按保存表主键的命名域推断:b_id(系统/基础表)→ b_xh,subid(业务子表)→ mx_xh。
const seqFieldByKeyField = new Map([
['b_id', 'b_xh'],
['subid', 'mx_xh'],
])
/**
* 解析模块的序号字段(用于行拖拽重排、新增自动取 MAX+10)。
* 显式传入 orderField 时直接使用(页面覆盖口);
* 否则按主键域推断,且推断结果必须已登记为模块的数值字段,
* 否则返回 ''(不启用,避免给无序号列的表塞字段,如日志表)。
*/
export function resolveSeqField(orderField, module, fields) {
const explicit = String(orderField || '').trim()
if (identifierRe.test(explicit)) return explicit
const inferred = seqFieldByKeyField.get(String(module?.b_keyfield || '').trim())
if (!inferred) return ''
const field = (fields || []).find((item) => item.b_field === inferred)
if (!field) return ''
if (field.b_type !== 'number' && field.b_type !== 'money') return ''
return inferred
}
export function escapeSqlText(value) {
return String(value).replaceAll("'", "''")
}