20260911173133

This commit is contained in:
oneao committed 2026-09-11 17:31:34 +08:00
1 parent 3838ed74b1
commit f3ad727f1c
60 files changed
+4971 -6621

No files matched your search

@@ -5,33 +5,48 @@ import { isEmptyValue } from './queryUtils'
const padDatePart = (number) => String(number).padStart(2, '0')
export function parseOptions(field) {
if (!field?.b_options) return { optionConfig: {}, options: [] }
try {
const parsed = JSON.parse(field.b_options)
let optionConfig = {}
let source = []
if (Array.isArray(parsed)) {
source = parsed
} else if (parsed && typeof parsed === 'object') {
optionConfig = parsed
if (Array.isArray(parsed.options)) {
source = parsed.options
} else if (Array.isArray(parsed.items)) {
source = parsed.items
}
/** 归一化选项定义:数组 / { options: [] } / { items: [] } */
function normalizeOptions(parsed) {
let optionConfig = {}
let source = []
if (Array.isArray(parsed)) {
source = parsed
} else if (parsed && typeof parsed === 'object') {
optionConfig = parsed
if (Array.isArray(parsed.options)) {
source = parsed.options
} else if (Array.isArray(parsed.items)) {
source = parsed.items
}
const options = source.map((option) => {
if (option && typeof option === 'object') {
const value = option.value ?? option.key ?? option.id
return { ...option, value, label: String(option.label ?? option.name ?? value ?? '') }
}
return { value: option, label: String(option ?? '') }
})
return { optionConfig, options }
} catch {
return { optionConfig: {}, options: [] }
}
const options = source.map((option) => {
if (option && typeof option === 'object') {
const value = option.value ?? option.key ?? option.id
return { ...option, value, label: String(option.label ?? option.name ?? value ?? '') }
}
return { value: option, label: String(option ?? '') }
})
return { optionConfig, options }
}
/**
* 解析字段选项。
* V2:选项定义在界面配置 JSON 节点上(node.options),优先读取;
* 兼容旧字段行:field.b_options 为 JSON 字符串。
*/
export function parseOptions(source) {
if (!source) return { optionConfig: {}, options: [] }
if (source.options !== undefined && source.options !== null) {
return normalizeOptions(source.options)
}
if (source.b_options) {
try {
return normalizeOptions(JSON.parse(source.b_options))
} catch {
return { optionConfig: {}, options: [] }
}
}
return { optionConfig: {}, options: [] }
}
export function formatDateValue(value, withTime = false) {
@@ -0,0 +1,205 @@
/**
* 界面配置 JSON → 运行时渲染配置 适配层(s_module_schema.b_schema_json 消费侧)。
*
* module-management 负责生产 JSON(view/edit/query),本文件负责把 JSON 还原为
* fms-module-list / fms-module-edit 已使用的「行/分组」渲染结构,使运行时组件
* 无需感知 JSON 节点差异。覆盖:
* - view → 列表列配置行(b_field / b_width / b_visible / b_xh,含节点 options)
* - edit → 编辑分组 + 编辑行(b_group_id / b_colspan / b_readonly / b_required 等)
* - query → 查询条件(field / operator / component / defaultValue)
* - 列宽/顺序回写(栏位设置保存)→ 更新 view/edit JSON 节点
*/
import { loadDataApi } from '@/services/api'
import { parseSchema } from '@/utils/schemaJson'
/** 需要还原的界面配置类型 */
const RENDER_SCHEMA_TYPES = ['view', 'edit', 'query']
function sqlText(value) {
return `N'${String(value ?? '').replace(/'/g, "''")}'`
}
/** 加载模块的三类界面配置对象;缺行时回退默认结构,并记录是否已有持久化行 */
export async function loadModuleSchemas(moduleId) {
const id = String(moduleId || '').trim()
const result = {
view: parseSchema('view'),
edit: parseSchema('edit'),
query: parseSchema('query'),
existed: { view: false, edit: false, query: false },
}
if (!id) return result
const res = await loadDataApi(
's_module_schema',
`b_module_id = ${sqlText(id)}`,
'b_schema_type ASC',
)
for (const type of RENDER_SCHEMA_TYPES) {
const row = (res.data || []).find((item) => String(item.b_schema_type) === type)
result[type] = parseSchema(type, row ? row.b_schema_json : null)
result.existed[type] = !!row
}
return result
}
/** 按文档顺序收集节点树中的所有叶子字段节点(分组节点递归展开) */
function collectFieldNodes(nodes) {
const out = []
const walk = (list) => {
for (const node of list || []) {
if (!node || typeof node !== 'object') continue
if (node.type === 'group') {
walk(node.children)
continue
}
if (node.field != null) out.push(node)
}
}
walk(nodes)
return out
}
/**
* view JSON → 列表配置行(列表页/表格消费)。
* 返回结构与旧 s_field_view 行兼容,便于复用既有表格渲染。
*/
export function buildListConfig(viewSchema) {
return collectFieldNodes(viewSchema?.columns || []).map((node, index) => ({
b_field: String(node.field),
b_width: node.width == null ? null : Number(node.width),
b_visible: node.visible === false ? 0 : 1,
b_xh: (index + 1) * 10,
options: node.options,
}))
}
/** 编辑分组 id(JSON 无主键,进入运行时生成临时编码) */
let groupSequence = 0
function nextGroupId() {
groupSequence += 1
return `g${Date.now().toString(36)}${groupSequence.toString(36)}`
}
/**
* edit JSON → { groups, rows }(编辑弹窗消费)。
* groups 为分组节点(b_id/b_title/b_xh),rows 为字段行(含栅格/只读/必填/默认值)。
*/
export function buildEditRender(editSchema) {
const groups = []
const rows = []
const walk = (list, parentGroupId) => {
for (const node of list || []) {
if (!node || typeof node !== 'object') continue
if (node.type === 'group') {
const gid = nextGroupId()
groups.push({
b_id: gid,
b_parent_id: parentGroupId,
b_title: node.title || '',
b_i18n: node.i18n ?? null,
b_xh: (groups.length + 1) * 10,
b_canuse: 1,
b_colspan: Number(node.colspan) || 48,
options: node.options,
node,
})
walk(node.children, gid)
continue
}
if (node.field == null) continue
rows.push({
b_field: String(node.field),
b_group_id: parentGroupId,
b_visible: node.visible === false ? 0 : 1,
b_required: node.required ? 1 : 0,
b_readonly: node.readonly ? 1 : 0,
b_disabled: node.disabled ? 1 : 0,
b_colspan: Number(node.span) || 24,
b_height: node.height ?? null,
b_placeholder: node.placeholder ?? null,
b_default_value: node.defaultValue ?? null,
b_xh: 0,
options: node.options,
node,
})
}
}
walk(editSchema?.children || [], null)
rows.forEach((row, index) => {
row.b_xh = (index + 1) * 10
})
return { groups, rows }
}
/** query JSON → 查询条件列表(含 quick 顺序,供页面排序) */
export function buildQueryRender(querySchema) {
const conditions = []
for (const group of querySchema?.groups || []) {
for (const condition of group?.conditions || []) {
if (condition?.field == null) continue
conditions.push({
field: String(condition.field),
operator: condition.operator || 'like',
component: condition.component || '',
defaultValue: condition.defaultValue ?? null,
options: condition.options,
node: condition,
})
}
}
const quick = Array.isArray(querySchema?.quick) ? [...querySchema.quick] : []
return { quick, conditions }
}
/** 按 quick 顺序优先排列条件(quick 未列出的保持原顺序追加在后) */
export function orderQueryConditions(conditions, quick) {
if (!quick?.length) return conditions
const rank = new Map(quick.map((field, index) => [String(field), index]))
return [...conditions].toSorted((a, b) => {
const ra = rank.has(a.field) ? rank.get(a.field) : Number.MAX_SAFE_INTEGER
const rb = rank.has(b.field) ? rank.get(b.field) : Number.MAX_SAFE_INTEGER
return ra - rb
})
}
/** 节点(含分组子树)在 settings 顺序中的最小序号,用于同层稳定排序 */
function minRank(node, rank) {
if (node?.type === 'group') {
const ranks = (node.children || []).map((child) => minRank(child, rank)).filter((r) => r != null)
return ranks.length ? Math.min(...ranks) : null
}
const key = String(node?.field)
return rank.has(key) ? rank.get(key) : null
}
/**
* 栏位设置(列宽/顺序)回写到 view/edit JSON 对象,返回新的配置对象。
* 仅调整同层节点顺序并更新 width,保留分组结构与未知扩展属性。
*/
export function applyColumnSettings(schemaType, schema, settings) {
const rootKey = schemaType === 'view' ? 'columns' : 'children'
const rank = new Map(settings.map((item, index) => [String(item.b_field), index]))
const width = new Map(
settings.map((item) => [String(item.b_field), item.b_width]).filter(([, w]) => w != null),
)
const patch = (nodes) => {
const mapped = (nodes || []).map((node) => {
if (!node || typeof node !== 'object') return node
if (node.type === 'group') return { ...node, children: patch(node.children) }
const next = { ...node }
const field = String(next.field)
if (width.has(field)) next.width = Number(width.get(field))
return next
})
return mapped.toSorted((a, b) => {
const ra = minRank(a, rank)
const rb = minRank(b, rank)
if (ra == null && rb == null) return 0
if (ra == null) return 1
if (rb == null) return -1
return ra - rb
})
}
const base = schema || parseSchema(schemaType)
return { ...base, [rootKey]: patch(base[rootKey] || []) }
}
@@ -3,7 +3,7 @@
* FmsModuleEditModal —— 元数据驱动的通用新增/编辑弹窗。
*
* 与 FmsModuleListPage 同族:传 data-code,组件按模块元数据自动渲染编辑表单,
* 字段/分组/校验/控件类型全部来自数据库(s_field_edit + s_field_group + s_field)。
* 字段/分组/校验/控件类型全部来自数据库(s_module_schema.edit JSON + s_field)。
*
* 渲染结构对齐表单设计器的 FormPreview(组内 24 栅格):
* Form(columns=24) → FormGroup(span=整行, 组内 24 栅格) → FormItem(span=b_colspan)
@@ -37,6 +37,7 @@ import { loadDataApi, loadDataBySqlApi, nextIdApi, saveObjectApi } from '@/servi
import { diffRow } from '@/utils/dataChanges'
import { identifierRe, isEmptyValue } from '@/components/fms-module-common/queryUtils'
import { parseOptions } from '@/components/fms-module-common/fieldUtils'
import { loadModuleSchemas, buildEditRender } from '@/components/fms-module-common/schemaRender'
const props = defineProps({
/** 受控打开 */
@@ -65,8 +66,8 @@ 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 groups = ref([]) // 编辑分组节点(由 edit JSON 还原)
const editRows = ref([]) // 编辑行(由 edit JSON 还原,含关联字段引用)
const formData = ref({})
const keyField = ref('b_id')
/** 编辑基线(origin):打开弹窗时对 record 深拷贝快照,保存只提交与它的差异;新建模式为 null */
@@ -141,24 +142,18 @@ async function loadConfig() {
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 = N'${moduleId.replaceAll("'", "''")}'`, 'b_xh ASC, b_field ASC'),
// 字段(s_field)+ 编辑界面配置(s_module_schema.edit JSON)
const [fieldRes, schemas] = await Promise.all([
loadDataApi(
's_field_edit',
's_field',
`b_module_id = N'${moduleId.replaceAll("'", "''")}'`,
'b_xh ASC, b_field ASC',
),
loadDataApi(
's_field_group',
`b_module_id = N'${moduleId.replaceAll("'", "''")}'`,
'b_xh ASC, b_id ASC',
),
loadModuleSchemas(moduleId),
])
moduleMeta.value = module
keyField.value = module.b_keyfield || 'b_id'
groups.value = groupRes.data || []
keyField.value = module.b_key_field || 'b_id'
// 关联字段信息(字段身份 = 字段列名 b_field)
const fieldByField = new Map()
@@ -170,9 +165,11 @@ async function loadConfig() {
}
fields.value = validFields
// 只保留可见行并注入字段对象
// edit JSON → 分组 + 编辑行;只保留可见行并注入字段对象
const render = buildEditRender(schemas.edit)
groups.value = render.groups
const rows = []
for (const row of editRes.data || []) {
for (const row of render.rows) {
if (Number(row.b_visible) !== 1) continue
const field = fieldByField.get(String(row.b_field))
if (!field) continue
@@ -204,10 +201,10 @@ function buildFormData() {
data[fieldName] = existing
} else if (row.field.b_type === 'checkbox') {
data[fieldName] = 0
} else if (isEmptyValue(row.field.b_default_value)) {
} else if (isEmptyValue(row.b_default_value)) {
data[fieldName] = row.field.b_type === 'number' || row.field.b_type === 'money' ? null : ''
} else {
data[fieldName] = row.field.b_default_value
data[fieldName] = row.b_default_value
}
}
formData.value = data
@@ -294,9 +291,9 @@ const resolvedWidth = computed(() => {
/** 保存过程上下文:beforeSave 阶段准备的数据,buildSaveData / afterSave 阶段消费 */
let saveCtx = { isNew: false, row: null, extraUpdates: [] }
/** 模块保存表(s_module.b_savetable 优先,其次 b_viewtable) */
/** 模块保存表(s_module.b_save_table 优先,其次 b_view_table) */
function saveTableName() {
return String(moduleMeta.value?.b_savetable || moduleMeta.value?.b_viewtable || '')
return String(moduleMeta.value?.b_save_table || moduleMeta.value?.b_view_table || '')
}
/** 构造完整合并行(含主键;checkbox→0/1、''→null 归一化),供保存前准备与 diff 使用 */
@@ -321,7 +318,7 @@ function collectRowData() {
/** 保存前校验、数据准备与业务钩子;返回 false 终止保存 */
async function beforeSave() {
// 必填校验:s_field_edit.b_required=1 且值为空 → 提示并定位
// 必填校验:编辑节点 required 且值为空 → 提示并定位
for (const editRow of editableRows.value) {
if (Number(editRow.b_required) !== 1) continue
if (editRow.field.b_type === 'checkbox') continue
@@ -346,7 +343,7 @@ async function beforeSave() {
!Number(row[props.orderField])
) {
try {
const viewTable = String(moduleMeta.value?.b_viewtable || '')
const viewTable = String(moduleMeta.value?.b_view_table || '')
if (viewTable) {
const res = await loadDataBySqlApi(
`SELECT ISNULL(MAX([${props.orderField}]), 0) AS mx FROM [${viewTable}]`,
@@ -12,6 +12,15 @@ import {
escapeSqlText,
} from '@/components/fms-module-common/queryUtils'
import { parseOptions } from '@/components/fms-module-common/fieldUtils'
import {
loadModuleSchemas,
buildListConfig,
buildEditRender,
buildQueryRender,
orderQueryConditions,
applyColumnSettings,
} from '@/components/fms-module-common/schemaRender'
import { stableStringify } from '@/utils/schemaJson'
import FmsModuleTable from './FmsModuleTable.vue'
import FmsQueryToolbar from './FmsQueryToolbar.vue'
@@ -56,6 +65,10 @@ const activeFieldById = shallowRef(new Map())
const listConfig = shallowRef([])
const editConfigById = shallowRef(new Map())
const queryItems = shallowRef([])
// 界面配置 JSON(s_module_schema):栏位设置回写时使用
const viewSchema = shallowRef(null)
const editSchema = shallowRef(null)
const schemaExisted = shallowRef({ view: false, edit: false, query: false })
const dataSource = shallowRef([])
const searchConditions = shallowRef([])
const keyField = ref('')
@@ -106,7 +119,7 @@ const cellSlotNames = computed(() => Object.keys(slots).filter((name) => name.st
// ── 模块配置加载 ──
function resolveKeyField(module, fields) {
if (identifierRe.test(module.b_keyfield || '')) return module.b_keyfield
if (identifierRe.test(module.b_key_field || '')) return module.b_key_field
for (const field of fields) {
if (field.b_field === 'b_id') return field.b_field
}
@@ -153,59 +166,65 @@ async function loadModuleConfig() {
'b_id ASC',
)
runtimeData = moduleRes.data?.[0]
if (!runtimeData || !runtimeData.b_viewtable)
if (!runtimeData || !runtimeData.b_view_table)
throw new Error('当前路由没有配置可用的数据模块')
}
const dataId = String(runtimeData.b_id || '').trim()
// 列显示配置:普通模式取列表配置,可编辑模式取编辑配置,二者只取其一。
const listTable = props.editable ? 's_field_edit' : 's_field_view'
const responses = await Promise.all([
// 元数据:字段(s_field)+ 三类界面配置(s_module_schema JSON)
const [fieldRes, schemas] = await Promise.all([
loadDataApi('s_field', `b_module_id = N'${escapeSqlText(dataId)}'`, 'b_xh ASC, b_field ASC'),
loadDataApi(listTable, `b_module_id = N'${escapeSqlText(dataId)}'`, 'b_xh ASC, b_field ASC'),
loadDataApi(
's_field_query',
`b_module_id = N'${escapeSqlText(dataId)}' AND b_canuse = 1`,
'b_xh ASC, b_field ASC',
),
loadModuleSchemas(dataId),
])
if (getDataCode() !== code) return false
// 字符串业务键:字段与布局行均以字段列名 b_field 关联
// (s_field_query 主键含 b_field,只引用本模块字段,无跨模块 b_field_module_id)
const fields = []
const fieldById = new Map()
for (const field of responses[0].data || []) {
for (const field of fieldRes.data || []) {
if (Number(field.b_canuse) !== 1 || !identifierRe.test(field.b_field || '')) continue
fields.push(field)
fieldById.set(String(field.b_field), field)
}
if (getDataCode() !== code) return false
// 可编辑模式下:记录编辑配置的 readonly/disabled,用于生成只读编辑器。
// 列显示配置:普通模式取 view JSON,可编辑模式取 edit JSON
let listRows = []
const editConfigByIdMap = new Map()
for (const item of props.editable ? responses[1].data || [] : []) {
if (Number(item.b_visible) !== 1) continue
if (!fieldById.has(String(item.b_field))) continue
editConfigByIdMap.set(String(item.b_field), item)
if (props.editable) {
const render = buildEditRender(schemas.edit)
listRows = render.rows
for (const row of render.rows) {
if (Number(row.b_visible) !== 1) continue
if (!fieldById.has(String(row.b_field))) continue
editConfigByIdMap.set(String(row.b_field), row)
}
} else {
listRows = buildListConfig(schemas.view)
}
editConfigById.value = editConfigByIdMap
// 列表配置:普通模式为 s_field_view,可编辑模式为 s_field_edit。
const lists = []
for (const item of responses[1].data || []) {
if (item.b_visible !== 1) continue
for (const item of listRows) {
if (Number(item.b_visible) !== 1) continue
if (!fieldById.has(String(item.b_field))) continue
lists.push(item)
}
// 查询条件:query JSON(quick 顺序优先)
const queryRender = buildQueryRender(schemas.query)
const orderedConditions = orderQueryConditions(queryRender.conditions, queryRender.quick)
const queries = []
for (const config of responses[2].data || []) {
const field = fieldById.get(String(config.b_field))
for (const condition of orderedConditions) {
const field = fieldById.get(condition.field)
if (!field) continue
const optionInfo = parseOptions(field)
const optionInfo = parseOptions({ options: condition.options })
queries.push({
config,
config: {
b_field: condition.field,
b_operator: condition.operator,
b_component: condition.component,
b_default_value: condition.defaultValue,
},
field,
optionConfig: optionInfo.optionConfig,
options: optionInfo.options,
@@ -224,6 +243,9 @@ async function loadModuleConfig() {
listConfig.value = lists
queryItems.value = queries
searchConditions.value = conditions
viewSchema.value = schemas.view
editSchema.value = schemas.edit
schemaExisted.value = schemas.existed
keyField.value = resolveKeyField(runtimeData, fields)
dirtyRows.value = new Map()
newRowKeys.value = new Set()
@@ -243,17 +265,17 @@ async function loadModuleConfig() {
// ── 数据查询 ──
function resolveOrderBy() {
if (!dataModule.value) throw new Error('数据模块尚未加载')
const configured = String(dataModule.value.b_orderfield || '').trim()
const configured = String(dataModule.value.b_order_sql || '').trim()
if (configured) return configured
if (keyField.value) return `${keyField.value} DESC`
return 'b_id DESC'
}
async function fetchData() {
if (!dataModule.value || !dataModule.value.b_viewtable) return
if (!dataModule.value || !dataModule.value.b_view_table) return
const currentModule = dataModule.value
const viewName = currentModule.b_viewtable
const viewName = currentModule.b_view_table
const currentCode = getDataCode()
const requestVersion = ++dataRequestVersion
const conditions = []
@@ -307,6 +329,9 @@ async function initialize() {
listConfig.value = []
editConfigById.value = new Map()
queryItems.value = []
viewSchema.value = null
editSchema.value = null
schemaExisted.value = { view: false, edit: false, query: false }
searchConditions.value = []
keyField.value = ''
dirtyRows.value = new Map()
@@ -367,34 +392,33 @@ async function saveColumnSettings() {
Message.info('当前没有可保存的栏位设置')
return
}
const moduleId = String(dataModule.value?.b_id || '').trim()
if (!moduleId) return
const updates = []
for (const row of settings) {
// 字符串业务键:布局配置行主键为 (b_module_id, b_field),无独立 b_id
updates.push({
b_module_id: row.b_module_id,
b_field: row.b_field,
b_xh: row.b_xh,
b_width: row.b_width,
})
}
// V2:栏位设置回写到界面配置 JSON(可编辑模式 → edit,普通模式 → view)
const schemaType = props.editable ? 'edit' : 'view'
const baseSchema = schemaType === 'edit' ? editSchema.value : viewSchema.value
const nextSchema = applyColumnSettings(schemaType, baseSchema, settings)
columnSaving.value = true
try {
// 可编辑表格的列来自编辑配置,栏位设置保存到 s_field_edit;普通模式保存到列表配置。
const table = props.editable ? 's_field_edit' : 's_field_view'
await saveObjectApi([
{
table,
key_field: 'b_module_id,b_field',
updates,
},
])
const currentByField = new Map()
for (const row of listConfig.value) {
currentByField.set(String(row.b_field), row)
const row = {
b_module_id: moduleId,
b_schema_type: schemaType,
b_schema_json: stableStringify(nextSchema),
b_canuse: 1,
}
const request = { table: 's_module_schema', key_field: 'b_module_id,b_schema_type' }
if (schemaExisted.value?.[schemaType]) request.updates = [row]
else request.inserts = [row]
await saveObjectApi([request])
if (schemaType === 'edit') editSchema.value = nextSchema
else viewSchema.value = nextSchema
// 本地列表列宽/顺序同步,避免等待整页重载
const currentByField = new Map()
for (const item of listConfig.value) currentByField.set(String(item.b_field), item)
const savedListConfig = []
for (const saved of settings) {
const current = currentByField.get(String(saved.b_field))
@@ -440,7 +464,7 @@ function handleCellChange({ row, field }) {
function resolveSaveTable() {
if (!dataModule.value) throw new Error('数据模块尚未加载')
return String(dataModule.value.b_savetable || dataModule.value.b_viewtable || '')
return String(dataModule.value.b_save_table || dataModule.value.b_view_table || '')
}
function dirtyUpdateRows() {
@@ -92,14 +92,15 @@ function buildEditor(field, config) {
}
if (field.b_type === 'checkbox') return { type: 'checkbox' }
if (field.b_type === 'select') {
const { options } = parseOptions(field)
// V2:选项来自界面配置节点(config.options);兼容旧字段行 b_options
const { options } = parseOptions({ options: config?.options, b_options: field.b_options })
return { type: 'select', options }
}
return { type: 'input' }
}
function createRuntimeColumn(config, field) {
const optionInfo = parseOptions(field)
const optionInfo = parseOptions({ options: config?.options, b_options: field.b_options })
const extension = resolveExtension(field.b_field)
const { formatter, clickable, cellClassName, onClick: _onClick, onChange, disabled: cellDisabled, ...columnProps } = extension
const slot = slots[`cell-${field.b_field}`]