20260903173038
This commit is contained in:
1 parent
c8940f6e03
commit
0faf44cffc
55 files changed
+3278
-208
No files matched your search
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
新增「多语言类型」表 dbo.b_i18n_type,并注册独立模块 m_i18n_type 与其权限、管理员授权。
|
||||
新增「多语言类型」表 dbo.s_i18n_type,并注册独立模块 m_i18n_type 与其权限、管理员授权。
|
||||
用于维护系统支持的语言清单(简称、名称、是否默认、是否启用、排序),默认中文为内置默认语言。
|
||||
|
||||
本脚本可重复执行:表、模块、权限均通过存在性判断避免重复播种;
|
||||
@@ -24,22 +24,22 @@ BEGIN TRANSACTION;
|
||||
);
|
||||
|
||||
-- 1. 多语言类型表
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'b_i18n_type')
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N's_i18n_type')
|
||||
BEGIN
|
||||
CREATE TABLE dbo.b_i18n_type (
|
||||
CREATE TABLE dbo.s_i18n_type (
|
||||
b_id bigint NOT NULL,
|
||||
b_code varchar(20) NOT NULL,
|
||||
b_name nvarchar(100) NOT NULL,
|
||||
b_canuse int NOT NULL CONSTRAINT DF_b_i18n_type_canuse DEFAULT (1),
|
||||
b_default int NOT NULL CONSTRAINT DF_b_i18n_type_default DEFAULT (0),
|
||||
b_xh int NOT NULL CONSTRAINT DF_b_i18n_type_xh DEFAULT (0),
|
||||
CONSTRAINT PK_b_i18n_type PRIMARY KEY (b_id),
|
||||
CONSTRAINT UQ_b_i18n_type_code UNIQUE (b_code)
|
||||
b_canuse int NOT NULL CONSTRAINT DF_s_i18n_type_canuse DEFAULT (1),
|
||||
b_default int NOT NULL CONSTRAINT DF_s_i18n_type_default DEFAULT (0),
|
||||
b_xh int NOT NULL CONSTRAINT DF_s_i18n_type_xh DEFAULT (0),
|
||||
CONSTRAINT PK_s_i18n_type PRIMARY KEY (b_id),
|
||||
CONSTRAINT UQ_s_i18n_type_code UNIQUE (b_code)
|
||||
);
|
||||
CREATE INDEX IX_b_i18n_type_xh ON dbo.b_i18n_type (b_xh, b_id);
|
||||
CREATE INDEX IX_s_i18n_type_xh ON dbo.s_i18n_type (b_xh, b_id);
|
||||
|
||||
-- 预置数据:默认中文不可删除,可选英文
|
||||
INSERT INTO dbo.b_i18n_type (b_id, b_code, b_name, b_canuse, b_default, b_xh) VALUES
|
||||
INSERT INTO dbo.s_i18n_type (b_id, b_code, b_name, b_canuse, b_default, b_xh) VALUES
|
||||
(920000000000000001, N'zh-CN', N'简体中文', 1, 1, 0),
|
||||
(920000000000000002, N'en-US', N'English', 1, 0, 10);
|
||||
END
|
||||
|
||||
@@ -393,6 +393,25 @@ BEGIN
|
||||
CREATE INDEX IX_s_role_scope_role ON dbo.s_role_scope (b_role_id, b_module_id, b_operation, b_canuse);
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.s_i18n_type', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.s_i18n_type (
|
||||
b_id bigint NOT NULL,
|
||||
b_code varchar(20) NOT NULL,
|
||||
b_name nvarchar(100) NOT NULL,
|
||||
b_canuse int NOT NULL CONSTRAINT DF_s_i18n_type_canuse DEFAULT (1),
|
||||
b_default int NOT NULL CONSTRAINT DF_s_i18n_type_default DEFAULT (0),
|
||||
b_xh int NOT NULL CONSTRAINT DF_s_i18n_type_xh DEFAULT (0),
|
||||
CONSTRAINT PK_s_i18n_type PRIMARY KEY (b_id),
|
||||
CONSTRAINT UQ_s_i18n_type_code UNIQUE (b_code)
|
||||
);
|
||||
CREATE INDEX IX_s_i18n_type_xh ON dbo.s_i18n_type (b_xh, b_id);
|
||||
|
||||
INSERT INTO dbo.s_i18n_type (b_id, b_code, b_name, b_canuse, b_default, b_xh) VALUES
|
||||
(920000000000000001, N'zh-CN', N'简体中文', 1, 1, 0),
|
||||
(920000000000000002, N'en-US', N'English', 1, 0, 10);
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.s_i18n', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.s_i18n (
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
将旧版 dbo.b_i18n_type 统一为系统表 dbo.s_i18n_type。
|
||||
可重复执行,兼容新库未建表、旧库已有 b_i18n_type,以及两张表同时存在的情况。
|
||||
*/
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
IF OBJECT_ID(N'dbo.s_i18n_type', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.s_i18n_type (
|
||||
b_id bigint NOT NULL,
|
||||
b_code varchar(20) NOT NULL,
|
||||
b_name nvarchar(100) NOT NULL,
|
||||
b_canuse int NOT NULL CONSTRAINT DF_s_i18n_type_canuse DEFAULT (1),
|
||||
b_default int NOT NULL CONSTRAINT DF_s_i18n_type_default DEFAULT (0),
|
||||
b_xh int NOT NULL CONSTRAINT DF_s_i18n_type_xh DEFAULT (0),
|
||||
CONSTRAINT PK_s_i18n_type PRIMARY KEY (b_id),
|
||||
CONSTRAINT UQ_s_i18n_type_code UNIQUE (b_code)
|
||||
);
|
||||
CREATE INDEX IX_s_i18n_type_xh ON dbo.s_i18n_type (b_xh, b_id);
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.b_i18n_type', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
INSERT INTO dbo.s_i18n_type (b_id, b_code, b_name, b_canuse, b_default, b_xh)
|
||||
SELECT old.b_id, old.b_code, old.b_name, old.b_canuse, old.b_default, old.b_xh
|
||||
FROM dbo.b_i18n_type AS old
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM dbo.s_i18n_type AS current_row
|
||||
WHERE current_row.b_code = old.b_code
|
||||
);
|
||||
|
||||
DROP TABLE dbo.b_i18n_type;
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.s_i18n_type)
|
||||
BEGIN
|
||||
INSERT INTO dbo.s_i18n_type (b_id, b_code, b_name, b_canuse, b_default, b_xh) VALUES
|
||||
(920000000000000001, N'zh-CN', N'简体中文', 1, 1, 0),
|
||||
(920000000000000002, N'en-US', N'English', 1, 0, 10);
|
||||
END;
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
import FmsModuleEditModal from './FmsModuleEditModal.vue'
|
||||
|
||||
export { FmsModuleEditModal }
|
||||
@@ -0,0 +1,927 @@
|
||||
<script setup>
|
||||
import { computed, h, nextTick, onActivated, reactive, ref, shallowRef, useSlots, watch } from 'vue'
|
||||
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 {
|
||||
identifierRe,
|
||||
buildQueryCondition,
|
||||
defaultQueryValue,
|
||||
escapeSqlText,
|
||||
parseOptions,
|
||||
requireModuleId,
|
||||
} from './moduleListUtils'
|
||||
import FmsModuleTable from './FmsModuleTable.vue'
|
||||
import FmsQueryToolbar from './FmsQueryToolbar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
dataCode: { type: String, default: '' },
|
||||
fixedSearchCondition: { type: String, default: '' },
|
||||
columnExtensions: { type: Object, default: () => ({}) },
|
||||
rowActions: { type: Array, default: () => [] },
|
||||
rowSelection: { type: Object, default: null },
|
||||
rowClassName: { type: Function, default: undefined },
|
||||
expandable: { type: [Boolean, Object], default: false },
|
||||
pageSize: { type: Number, default: 20 },
|
||||
inlineActionLimit: { type: Number, default: 2 },
|
||||
// 可编辑表格模式:取编辑配置生成行内编辑器,支持保存/批量删除。
|
||||
editable: { type: Boolean, default: false },
|
||||
// 行拖拽排序:开启后显示拖拽手柄,拖拽按 orderField 重排并纳入脏保存。
|
||||
rowDraggable: { type: Boolean, default: false },
|
||||
// 行拖拽排序字段,仅 rowDraggable 时生效。
|
||||
orderField: { type: String, default: 'b_xh' },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'row-click',
|
||||
'cell-click',
|
||||
'row-action',
|
||||
'expand',
|
||||
'loaded',
|
||||
'dirty-change',
|
||||
])
|
||||
const slots = useSlots()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const tableRef = ref(null)
|
||||
// keep-alive 中的缓存实例必须保留各自的路由上下文,不能跟随全局 route 变化。
|
||||
const instanceRoutePath = route.path
|
||||
const instanceRouteDataCode = route.meta.dataCode
|
||||
|
||||
// ── 内部状态 ──
|
||||
const dataModule = ref(null)
|
||||
const activeFields = shallowRef([])
|
||||
const activeFieldById = shallowRef(new Map())
|
||||
const listConfig = shallowRef([])
|
||||
const editConfigById = shallowRef(new Map())
|
||||
const queryItems = shallowRef([])
|
||||
const dataSource = shallowRef([])
|
||||
const searchConditions = shallowRef([])
|
||||
const keyField = ref('')
|
||||
// 可编辑表格:脏行追踪(rowKey -> Set<已修改字段>),仅在 editable 模式下使用。
|
||||
const dirtyRows = shallowRef(new Map())
|
||||
// 可编辑表格:未持久化的新增行 rowKey 集合(新增行保存时作为 inserts 提交)。
|
||||
const newRowKeys = shallowRef(new Set())
|
||||
const saving = ref(false)
|
||||
const configLoading = ref(false)
|
||||
const dataLoading = ref(false)
|
||||
const columnSaving = ref(false)
|
||||
const configError = ref('')
|
||||
const dataError = ref('')
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: Number(props.pageSize) || 20,
|
||||
total: 0,
|
||||
})
|
||||
let dataRequestVersion = 0
|
||||
|
||||
const loading = computed(() => configLoading.value || dataLoading.value)
|
||||
|
||||
// ── 外部工具 ──
|
||||
const moreMenuItems = computed(() => [
|
||||
{
|
||||
key: 'refresh',
|
||||
label: t('common.refreshData', '刷新数据'),
|
||||
icon: h(RefreshCw, { size: 14 }),
|
||||
},
|
||||
{
|
||||
key: 'reset-columns',
|
||||
label: t('common.resetColumnSettings', '恢复栏位设置'),
|
||||
icon: h(Columns3, { size: 14 }),
|
||||
},
|
||||
{
|
||||
key: 'save-column-settings',
|
||||
label: t('common.saveColumnSettings', '保存栏位设置'),
|
||||
icon: h(Save, { size: 14 }),
|
||||
disabled: columnSaving.value,
|
||||
},
|
||||
])
|
||||
const cellSlotNames = computed(() => Object.keys(slots).filter((name) => name.startsWith('cell-')))
|
||||
|
||||
// ── 模块配置加载 ──
|
||||
function resolveKeyField(module, fields) {
|
||||
if (identifierRe.test(module.b_keyfield || '')) return module.b_keyfield
|
||||
for (const field of fields) {
|
||||
if (field.b_field === 'b_id') return field.b_field
|
||||
}
|
||||
for (const field of fields) {
|
||||
if (identifierRe.test(field.b_field || '')) return field.b_field
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getDataCode() {
|
||||
return String(props.dataCode || instanceRouteDataCode || '').trim()
|
||||
}
|
||||
|
||||
async function loadModuleConfig() {
|
||||
const code = getDataCode()
|
||||
configLoading.value = true
|
||||
configError.value = ''
|
||||
|
||||
try {
|
||||
let runtimeData = null
|
||||
|
||||
if (code) {
|
||||
const moduleRes = await loadDataApi(
|
||||
's_module',
|
||||
`b_code = N'${escapeSqlText(code)}' AND b_module_type = 'data' AND b_canuse = 1`,
|
||||
'b_id ASC',
|
||||
)
|
||||
runtimeData = moduleRes.data?.[0]
|
||||
if (!runtimeData) throw new Error(`未找到编码为 ${code} 的启用数据模块`)
|
||||
} else {
|
||||
const routePath = escapeSqlText(instanceRoutePath)
|
||||
// 新系统由 s_menu 持有路由,菜单通过 b_module_id 关联数据模块。
|
||||
const menuRes = await loadDataApi(
|
||||
's_menu',
|
||||
`b_route = N'${routePath}' AND b_canuse = 1`,
|
||||
'b_id ASC',
|
||||
)
|
||||
const menu = menuRes.data?.[0]
|
||||
if (!menu?.b_module_id) throw new Error(`未找到路由 ${instanceRoutePath} 对应的菜单模块`)
|
||||
const moduleRes = await loadDataApi(
|
||||
's_module',
|
||||
`b_id = ${requireModuleId(menu.b_module_id)} AND b_canuse = 1`,
|
||||
'b_id ASC',
|
||||
)
|
||||
runtimeData = moduleRes.data?.[0]
|
||||
if (!runtimeData || !runtimeData.b_viewtable)
|
||||
throw new Error('当前路由没有配置可用的数据模块')
|
||||
}
|
||||
|
||||
const dataId = requireModuleId(runtimeData.b_id)
|
||||
// 列显示配置:普通模式取列表配置,可编辑模式取编辑配置,二者只取其一。
|
||||
const listTable = props.editable ? 's_field_edit' : 's_field_view'
|
||||
const responses = await Promise.all([
|
||||
loadDataApi('s_field', `b_module_id = ${dataId}`, 'b_id ASC'),
|
||||
loadDataApi(listTable, `b_module_id = ${dataId}`, 'b_xh ASC, b_id ASC'),
|
||||
loadDataApi(
|
||||
's_field_query',
|
||||
`b_module_id = ${dataId} AND b_canuse = 1`,
|
||||
'b_xh ASC, b_id ASC',
|
||||
),
|
||||
])
|
||||
if (getDataCode() !== code) return false
|
||||
|
||||
// 查询配置允许引用其它模块字段;补充加载这些字段,保持查询控件的元数据完整。
|
||||
const queryModuleIds = new Set(
|
||||
(responses[2].data || [])
|
||||
.map((item) => item.b_field_module_id)
|
||||
.filter((id) => /^\d+$/.test(String(id)) && String(id) !== dataId),
|
||||
)
|
||||
const foreignFieldResponses = await Promise.all(
|
||||
[...queryModuleIds].map((moduleId) =>
|
||||
loadDataApi('s_field', `b_module_id = ${moduleId}`, 'b_id ASC'),
|
||||
),
|
||||
)
|
||||
if (getDataCode() !== code) return false
|
||||
|
||||
const fields = []
|
||||
const fieldById = new Map()
|
||||
const allFieldRows = [
|
||||
...(responses[0].data || []),
|
||||
...foreignFieldResponses.flatMap((response) => response.data || []),
|
||||
]
|
||||
for (const field of allFieldRows) {
|
||||
if (field.b_canuse !== 1 || !identifierRe.test(field.b_field || '')) continue
|
||||
fields.push(field)
|
||||
fieldById.set(String(field.b_id), field)
|
||||
}
|
||||
|
||||
// 可编辑模式下:记录编辑配置的 readonly/disabled,用于生成只读编辑器。
|
||||
const editConfigByIdMap = new Map()
|
||||
for (const item of props.editable ? responses[1].data || [] : []) {
|
||||
if (item.b_visible !== 1) continue
|
||||
if (!fieldById.has(String(item.b_field_id))) continue
|
||||
editConfigByIdMap.set(String(item.b_field_id), item)
|
||||
}
|
||||
editConfigById.value = editConfigByIdMap
|
||||
|
||||
// 列表配置:普通模式为 s_field_view,可编辑模式为 s_field_edit。
|
||||
const lists = []
|
||||
for (const item of responses[1].data || []) {
|
||||
if (item.b_visible !== 1) continue
|
||||
if (!fieldById.has(String(item.b_field_id))) continue
|
||||
lists.push(item)
|
||||
}
|
||||
|
||||
const queries = []
|
||||
for (const config of responses[2].data || []) {
|
||||
const field = fieldById.get(String(config.b_field_id))
|
||||
if (!field) continue
|
||||
const optionInfo = parseOptions(field)
|
||||
queries.push({
|
||||
config,
|
||||
field,
|
||||
optionConfig: optionInfo.optionConfig,
|
||||
options: optionInfo.options,
|
||||
})
|
||||
}
|
||||
|
||||
const conditions = []
|
||||
for (const item of queries) {
|
||||
const condition = buildQueryCondition(item, defaultQueryValue(item))
|
||||
if (condition) conditions.push(condition)
|
||||
}
|
||||
|
||||
dataModule.value = runtimeData
|
||||
activeFields.value = fields
|
||||
activeFieldById.value = fieldById
|
||||
listConfig.value = lists
|
||||
queryItems.value = queries
|
||||
searchConditions.value = conditions
|
||||
keyField.value = resolveKeyField(runtimeData, fields)
|
||||
dirtyRows.value = new Map()
|
||||
newRowKeys.value = new Set()
|
||||
return true
|
||||
} catch (error) {
|
||||
if (getDataCode() === code) {
|
||||
configError.value = error?.message || String(error)
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
if (getDataCode() === code) {
|
||||
configLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据查询 ──
|
||||
function resolveOrderBy() {
|
||||
if (!dataModule.value) throw new Error('数据模块尚未加载')
|
||||
const configured = String(dataModule.value.b_orderfield || '').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
|
||||
|
||||
const currentModule = dataModule.value
|
||||
const viewName = currentModule.b_viewtable
|
||||
const currentCode = getDataCode()
|
||||
const requestVersion = ++dataRequestVersion
|
||||
const conditions = []
|
||||
const fixedCondition = String(props.fixedSearchCondition || '').trim()
|
||||
if (fixedCondition) conditions.push(`(${fixedCondition})`)
|
||||
conditions.push(...searchConditions.value)
|
||||
dataLoading.value = true
|
||||
dataError.value = ''
|
||||
|
||||
try {
|
||||
const res = await pageDataApi({
|
||||
viewName,
|
||||
orderBy: resolveOrderBy(),
|
||||
searchColumns: [],
|
||||
searchCondition: conditions.join(' AND '),
|
||||
pageNo: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
})
|
||||
|
||||
if (requestVersion !== dataRequestVersion || dataModule.value !== currentModule) return
|
||||
if (getDataCode() !== currentCode) return
|
||||
dataSource.value = res.data?.rows || []
|
||||
pagination.total = Number(res.data?.total ?? res.data?.row_count) || 0
|
||||
} catch (error) {
|
||||
if (
|
||||
requestVersion === dataRequestVersion &&
|
||||
dataModule.value === currentModule &&
|
||||
getDataCode() === currentCode
|
||||
) {
|
||||
dataSource.value = []
|
||||
pagination.total = 0
|
||||
dataError.value = error?.message || String(error)
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
requestVersion === dataRequestVersion &&
|
||||
dataModule.value === currentModule &&
|
||||
getDataCode() === currentCode
|
||||
) {
|
||||
dataLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公开方法 ──
|
||||
async function initialize() {
|
||||
dataRequestVersion++
|
||||
dataModule.value = null
|
||||
activeFields.value = []
|
||||
activeFieldById.value = new Map()
|
||||
listConfig.value = []
|
||||
editConfigById.value = new Map()
|
||||
queryItems.value = []
|
||||
searchConditions.value = []
|
||||
keyField.value = ''
|
||||
dirtyRows.value = new Map()
|
||||
newRowKeys.value = new Set()
|
||||
saving.value = false
|
||||
dataLoading.value = false
|
||||
dataError.value = ''
|
||||
pagination.current = 1
|
||||
dataSource.value = []
|
||||
pagination.total = 0
|
||||
|
||||
const loaded = await loadModuleConfig()
|
||||
if (loaded) await fetchData()
|
||||
}
|
||||
|
||||
function search({ entries = [] }) {
|
||||
if (loading.value) return
|
||||
const conditions = []
|
||||
for (const entry of entries) {
|
||||
const condition = buildQueryCondition(entry.item, entry.value)
|
||||
if (condition) conditions.push(condition)
|
||||
}
|
||||
searchConditions.value = conditions
|
||||
pagination.current = 1
|
||||
return fetchData()
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
if (loading.value) return
|
||||
searchConditions.value = []
|
||||
pagination.current = 1
|
||||
return fetchData()
|
||||
}
|
||||
|
||||
function changePage(page, pageSize) {
|
||||
if (loading.value) return
|
||||
pagination.current = pageSize === pagination.pageSize ? page : 1
|
||||
pagination.pageSize = pageSize
|
||||
return fetchData()
|
||||
}
|
||||
|
||||
function reload() {
|
||||
if (loading.value) return
|
||||
return fetchData()
|
||||
}
|
||||
|
||||
async function resetColumns() {
|
||||
await nextTick()
|
||||
if (tableRef.value) tableRef.value.resetColumns()
|
||||
}
|
||||
|
||||
async function saveColumnSettings() {
|
||||
if (loading.value || columnSaving.value || !tableRef.value) return
|
||||
if (typeof tableRef.value.getColumnSettings !== 'function') return
|
||||
|
||||
const settings = tableRef.value.getColumnSettings()
|
||||
if (!settings.length) {
|
||||
Message.info('当前没有可保存的栏位设置')
|
||||
return
|
||||
}
|
||||
|
||||
const updates = []
|
||||
for (const row of settings) {
|
||||
updates.push({
|
||||
b_id: row.b_id,
|
||||
b_xh: row.b_xh,
|
||||
b_width: row.b_width,
|
||||
})
|
||||
}
|
||||
|
||||
columnSaving.value = true
|
||||
try {
|
||||
// 可编辑表格的列来自编辑配置,栏位设置保存到 s_field_edit;普通模式保存到列表配置。
|
||||
const table = props.editable ? 's_field_edit' : 's_field_view'
|
||||
await saveObjectApi([
|
||||
{
|
||||
table,
|
||||
key_field: 'b_id',
|
||||
updates,
|
||||
},
|
||||
])
|
||||
|
||||
const currentById = new Map()
|
||||
for (const row of listConfig.value) {
|
||||
currentById.set(String(row.b_id), row)
|
||||
}
|
||||
const savedListConfig = []
|
||||
for (const saved of settings) {
|
||||
const current = currentById.get(String(saved.b_id))
|
||||
if (!current) continue
|
||||
savedListConfig.push({ ...current, b_xh: saved.b_xh, b_width: saved.b_width })
|
||||
}
|
||||
listConfig.value = savedListConfig
|
||||
Message.success('栏位设置已保存')
|
||||
} catch (error) {
|
||||
let errorMessage = error
|
||||
if (error && error.message) errorMessage = error.message
|
||||
Message.error('栏位设置保存失败:' + errorMessage)
|
||||
} finally {
|
||||
columnSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRowKey(row) {
|
||||
if (keyField.value) return row[keyField.value]
|
||||
return JSON.stringify(row)
|
||||
}
|
||||
|
||||
// ── 可编辑表格:脏行追踪 / 保存 / 批量删除 ──
|
||||
// 主键与系统审计字段(由后端维护)在可编辑表格中一律只读。
|
||||
const SYSTEM_FIELDS = ['b_inputdatetime', 'b_inputuser_id', 'b_updatedatetime', 'b_updateuser_id']
|
||||
const readonlyFields = computed(() => {
|
||||
const fields = new Set(SYSTEM_FIELDS)
|
||||
if (keyField.value) fields.add(keyField.value)
|
||||
return [...fields]
|
||||
})
|
||||
|
||||
function handleCellChange({ row, field }) {
|
||||
if (!props.editable || !keyField.value) return
|
||||
const rowKey = String(resolveRowKey(row))
|
||||
const current = dirtyRows.value.get(rowKey)
|
||||
if (current) {
|
||||
current.add(field)
|
||||
} else {
|
||||
dirtyRows.value.set(rowKey, new Set([field]))
|
||||
}
|
||||
emit('dirty-change', { count: dirtyRows.value.size })
|
||||
}
|
||||
|
||||
function resolveSaveTable() {
|
||||
if (!dataModule.value) throw new Error('数据模块尚未加载')
|
||||
return String(dataModule.value.b_savetable || dataModule.value.b_viewtable || '')
|
||||
}
|
||||
|
||||
function dirtyUpdateRows() {
|
||||
if (!keyField.value) return []
|
||||
const updates = []
|
||||
const rowByKey = new Map(dataSource.value.map((row) => [String(resolveRowKey(row)), row]))
|
||||
for (const [rowKey, fields] of dirtyRows.value) {
|
||||
// 未持久化的新增行走 inserts 分支,避免对不存在的记录执行 UPDATE。
|
||||
if (newRowKeys.value.has(rowKey)) continue
|
||||
const row = rowByKey.get(rowKey)
|
||||
if (!row) continue
|
||||
const update = { [keyField.value]: row[keyField.value] }
|
||||
for (const field of fields) {
|
||||
if (field === keyField.value) continue
|
||||
update[field] = row[field]
|
||||
}
|
||||
updates.push(update)
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
// 未持久化的新增行:仅收集有实际修改的行,作为 inserts 提交。
|
||||
function dirtyInsertRows() {
|
||||
if (!keyField.value) return []
|
||||
const rowByKey = new Map(dataSource.value.map((row) => [String(resolveRowKey(row)), row]))
|
||||
const inserts = []
|
||||
for (const rowKey of newRowKeys.value) {
|
||||
const fields = dirtyRows.value.get(rowKey)
|
||||
const row = rowByKey.get(rowKey)
|
||||
if (!fields || !row) continue
|
||||
const insert = { [keyField.value]: row[keyField.value] }
|
||||
for (const field of fields) {
|
||||
if (field === keyField.value) continue
|
||||
insert[field] = row[field]
|
||||
}
|
||||
inserts.push(insert)
|
||||
}
|
||||
return inserts
|
||||
}
|
||||
|
||||
// 找到第一个可编辑的列字段(按编辑配置顺序,跳过只读列、主键与 checkbox),
|
||||
// 用于新增行后定位光标聚焦的单元格。
|
||||
function firstEditableField() {
|
||||
const readonly = new Set(readonlyFields.value)
|
||||
for (const config of listConfig.value) {
|
||||
const field = activeFieldById.value.get(String(config.b_field_id))
|
||||
if (!field) continue
|
||||
if (readonly.has(field.b_field)) continue
|
||||
if (field.b_type === 'checkbox') continue
|
||||
return field.b_field
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 可编辑模式下新增一行:生成雪花主键,追加到表尾并让第一个可编辑单元格进入编辑态并聚焦。
|
||||
async function addRow() {
|
||||
if (!props.editable || saving.value || loading.value) return
|
||||
if (!keyField.value) {
|
||||
Message.error('该模块未配置主键,无法新增')
|
||||
return
|
||||
}
|
||||
const idRes = await nextIdApi()
|
||||
const newId = String(idRes.data)
|
||||
const newRow = { [keyField.value]: newId }
|
||||
// 排序字段取当前最大值 + 10;checkbox 字段默认 0。
|
||||
if (props.orderField && identifierRe.test(props.orderField)) {
|
||||
let maxXh = 0
|
||||
for (const row of dataSource.value) {
|
||||
maxXh = Math.max(maxXh, Number(row[props.orderField]) || 0)
|
||||
}
|
||||
newRow[props.orderField] = maxXh + 10
|
||||
}
|
||||
for (const field of activeFields.value) {
|
||||
if (field.b_field === keyField.value || field.b_field === props.orderField) continue
|
||||
if (newRow[field.b_field] !== undefined) continue
|
||||
if (field.b_type === 'checkbox') newRow[field.b_field] = 0
|
||||
}
|
||||
dataSource.value = [...dataSource.value, newRow]
|
||||
newRowKeys.value = new Set([...newRowKeys.value, newId])
|
||||
await nextTick()
|
||||
// 聚焦第一个可编辑字段,让用户可直接输入。
|
||||
tableRef.value?.editRow?.(newId, firstEditableField())
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (saving.value) return
|
||||
const table = resolveSaveTable()
|
||||
if (!table) {
|
||||
Message.error('该模块未配置保存表,无法保存')
|
||||
return
|
||||
}
|
||||
const updates = dirtyUpdateRows()
|
||||
const inserts = dirtyInsertRows()
|
||||
if (!updates.length && !inserts.length) {
|
||||
Message.info('没有需要保存的修改')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const operation = { table, key_field: keyField.value }
|
||||
if (updates.length) operation.updates = updates
|
||||
if (inserts.length) operation.inserts = inserts
|
||||
await saveObjectApi([operation])
|
||||
// 已提交的新增行不再视为未持久化。
|
||||
const insertedSet = new Set(inserts.map((row) => String(row[keyField.value])))
|
||||
newRowKeys.value = new Set([...newRowKeys.value].filter((key) => !insertedSet.has(key)))
|
||||
dirtyRows.value = new Map()
|
||||
// 保存成功后让正在编辑的行退出编辑态。
|
||||
tableRef.value?.clearEditing?.()
|
||||
Message.success('保存成功')
|
||||
emit('dirty-change', { count: 0 })
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
Message.error('保存失败:' + (error?.message || error))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected(rowKeys) {
|
||||
if (saving.value || !rowKeys?.length) return
|
||||
const table = resolveSaveTable()
|
||||
if (!table) {
|
||||
Message.error('该模块未配置保存表,无法删除')
|
||||
return
|
||||
}
|
||||
if (!keyField.value) {
|
||||
Message.error('该模块未配置主键,无法删除')
|
||||
return
|
||||
}
|
||||
// 未持久化的新增行直接本地移除,不调删除接口。
|
||||
const newSet = new Set(newRowKeys.value)
|
||||
const pendingKeys = rowKeys.filter((key) => newSet.has(String(key)))
|
||||
const persistedKeys = rowKeys.filter((key) => !newSet.has(String(key)))
|
||||
if (pendingKeys.length) {
|
||||
const pendingSet = new Set(pendingKeys.map((key) => String(key)))
|
||||
dataSource.value = dataSource.value.filter((row) => !pendingSet.has(String(resolveRowKey(row))))
|
||||
const remaining = new Map()
|
||||
for (const [rowKey, fields] of dirtyRows.value) {
|
||||
if (!pendingSet.has(rowKey)) remaining.set(rowKey, fields)
|
||||
}
|
||||
dirtyRows.value = remaining
|
||||
newRowKeys.value = new Set([...newRowKeys.value].filter((key) => !pendingSet.has(key)))
|
||||
tableRef.value?.clearEditing?.()
|
||||
emit('dirty-change', { count: dirtyRows.value.size })
|
||||
}
|
||||
if (!persistedKeys.length) {
|
||||
Message.success(`已删除 ${rowKeys.length} 条记录`)
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await saveObjectApi([
|
||||
{
|
||||
table,
|
||||
key_field: keyField.value,
|
||||
deletes: persistedKeys.map((key) => ({ [keyField.value]: key })),
|
||||
},
|
||||
])
|
||||
// 清除被删行的脏记录
|
||||
const deletedSet = new Set(persistedKeys.map((key) => String(key)))
|
||||
const remaining = new Map()
|
||||
for (const [rowKey, fields] of dirtyRows.value) {
|
||||
if (!deletedSet.has(rowKey)) remaining.set(rowKey, fields)
|
||||
}
|
||||
dirtyRows.value = remaining
|
||||
// 删除成功后让正在编辑的行退出编辑态。
|
||||
tableRef.value?.clearEditing?.()
|
||||
Message.success(`已删除 ${rowKeys.length} 条记录`)
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 行拖拽排序:回灌排序结果,按 orderField 重新编号。
|
||||
// editable 模式:标记为脏、随 saveChanges 一起提交;
|
||||
// 非 editable 模式:直接持久化(行内排序是独立操作,无统一保存入口)。
|
||||
function handleRowOrderChange(orderedRows) {
|
||||
if (!props.rowDraggable || !props.orderField) return
|
||||
if (!identifierRe.test(props.orderField)) return
|
||||
const rowByOld = new Map(dataSource.value.map((row) => [String(resolveRowKey(row)), row]))
|
||||
const changedKeys = new Set()
|
||||
const nextRows = []
|
||||
let order = 10
|
||||
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 }
|
||||
nextRows.push(copy)
|
||||
if (oldValue !== order) changedKeys.add(key)
|
||||
order += 10
|
||||
}
|
||||
dataSource.value = nextRows
|
||||
|
||||
if (props.editable) {
|
||||
const dirtyFieldsByKey = new Map()
|
||||
for (const key of changedKeys) {
|
||||
const fields = dirtyFieldsByKey.get(key) || new Set()
|
||||
fields.add(props.orderField)
|
||||
dirtyFieldsByKey.set(key, fields)
|
||||
}
|
||||
for (const [key, fields] of dirtyFieldsByKey) {
|
||||
const current = dirtyRows.value.get(key)
|
||||
if (current) {
|
||||
fields.forEach((field) => current.add(field))
|
||||
} else {
|
||||
dirtyRows.value.set(key, fields)
|
||||
}
|
||||
}
|
||||
emit('dirty-change', { count: dirtyRows.value.size })
|
||||
return
|
||||
}
|
||||
|
||||
// 非编辑模式:排序即保存
|
||||
void persistRowOrder(changedKeys)
|
||||
}
|
||||
|
||||
/** 非 editable 模式的行拖拽排序持久化:只提交 orderField 变化的行 */
|
||||
async function persistRowOrder(changedKeys) {
|
||||
if (!changedKeys.size || saving.value) return
|
||||
const table = resolveSaveTable()
|
||||
if (!table) {
|
||||
Message.error('该模块未配置保存表,无法保存排序')
|
||||
return
|
||||
}
|
||||
if (!keyField.value) {
|
||||
Message.error('该模块未配置主键,无法保存排序')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const rowByKey = new Map(dataSource.value.map((row) => [String(resolveRowKey(row)), row]))
|
||||
const updates = []
|
||||
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] })
|
||||
}
|
||||
if (!updates.length) return
|
||||
await saveObjectApi([{ table, key_field: keyField.value, updates }])
|
||||
Message.success('排序已保存')
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
Message.error('排序保存失败:' + (error?.message || error))
|
||||
// 失败恢复数据库顺序
|
||||
await fetchData()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoreAction({ key }) {
|
||||
if (key === 'refresh') {
|
||||
await reload()
|
||||
return
|
||||
}
|
||||
if (key === 'reset-columns') {
|
||||
await resetColumns()
|
||||
Message.success(t('common.columnSettingsReset', '已恢复栏位设置'))
|
||||
return
|
||||
}
|
||||
if (key === 'save-column-settings') {
|
||||
await saveColumnSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 生命周期 ──
|
||||
let hasActivated = false
|
||||
void initialize()
|
||||
|
||||
onActivated(async () => {
|
||||
if (!hasActivated) {
|
||||
hasActivated = true
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
tableRef.value?.refreshLayout?.()
|
||||
})
|
||||
|
||||
watch(() => props.dataCode, initialize)
|
||||
|
||||
watch(
|
||||
() => props.fixedSearchCondition,
|
||||
() => {
|
||||
if (!dataModule.value) return
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => dataSource.value,
|
||||
(rows) => {
|
||||
emit('loaded', { rows, total: pagination.total, module: dataModule.value })
|
||||
},
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
reload,
|
||||
resetColumns,
|
||||
initialize,
|
||||
dataSource,
|
||||
dataModule,
|
||||
pagination,
|
||||
saveChanges,
|
||||
deleteSelected,
|
||||
addRow,
|
||||
dirtyRows,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fms-module-list-page">
|
||||
<div v-if="configError" class="module-config-error">
|
||||
<div class="module-config-error__content">
|
||||
<strong>{{ t('common.moduleConfigLoadFailed', '模块配置加载失败') }}</strong>
|
||||
<span>{{ configError }}</span>
|
||||
<Button type="primary" @click="initialize">
|
||||
{{ t('common.reload', '重新加载') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<FmsQueryToolbar
|
||||
:query-items="queryItems"
|
||||
:loading="loading"
|
||||
:more-items="moreMenuItems"
|
||||
@search="search"
|
||||
@reset="resetSearch"
|
||||
@more-action="handleMoreAction"
|
||||
>
|
||||
<template #actions>
|
||||
<slot
|
||||
name="actions"
|
||||
:reload="reload"
|
||||
:data-module="dataModule"
|
||||
:loading="loading"
|
||||
:editable="editable"
|
||||
:saving="saving"
|
||||
:dirty-count="dirtyRows.size"
|
||||
:save-changes="saveChanges"
|
||||
/>
|
||||
</template>
|
||||
</FmsQueryToolbar>
|
||||
|
||||
<section class="module-table-panel">
|
||||
<div class="module-table-stage">
|
||||
<FmsModuleTable
|
||||
ref="tableRef"
|
||||
:fields="activeFields"
|
||||
:list-config="listConfig"
|
||||
:edit-config-by-id="editConfigById"
|
||||
:readonly-fields="readonlyFields"
|
||||
:data-source="dataSource"
|
||||
:row-key="resolveRowKey"
|
||||
:column-extensions="columnExtensions"
|
||||
:row-actions="rowActions"
|
||||
:row-selection="rowSelection"
|
||||
:row-class-name="rowClassName"
|
||||
:expandable="expandable"
|
||||
:inline-action-limit="inlineActionLimit"
|
||||
:editable="editable"
|
||||
:row-draggable="rowDraggable"
|
||||
@row-click="$emit('row-click', $event)"
|
||||
@cell-click="$emit('cell-click', $event)"
|
||||
@row-action="$emit('row-action', $event)"
|
||||
@expand="$emit('expand', $event)"
|
||||
@cell-change="handleCellChange"
|
||||
@row-order-change="handleRowOrderChange"
|
||||
>
|
||||
<template v-for="slotName in cellSlotNames" :key="slotName" #[slotName]="slotProps">
|
||||
<slot :name="slotName" v-bind="slotProps" />
|
||||
</template>
|
||||
<template v-if="$slots['expanded-row']" #expanded-row="slotProps">
|
||||
<slot name="expanded-row" v-bind="slotProps" />
|
||||
</template>
|
||||
<template v-if="$slots.empty" #empty>
|
||||
<slot name="empty" />
|
||||
</template>
|
||||
</FmsModuleTable>
|
||||
|
||||
<div v-if="loading" class="module-table-loading">
|
||||
<Spin />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="dataError" class="module-data-error">
|
||||
{{ dataError }}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:current="pagination.current"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
show-total
|
||||
:disabled="loading"
|
||||
show-size-changer
|
||||
@change="changePage"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fms-module-list-page {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: var(--fms-surface, #fff);
|
||||
}
|
||||
|
||||
.module-config-error {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.module-config-error__content {
|
||||
display: flex;
|
||||
max-width: 520px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--fms-text-secondary, #646a73);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.module-config-error__content strong {
|
||||
color: var(--fms-text, #1f2329);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.module-table-panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
|
||||
> :deep(.pagination) {
|
||||
flex: none;
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.module-table-stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.module-table-loading {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(255 255 255 / 68%);
|
||||
}
|
||||
|
||||
.module-data-error {
|
||||
flex: none;
|
||||
color: #ff4d4f;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,412 @@
|
||||
<script setup>
|
||||
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 { formatDateValue, parseOptions } from './moduleListUtils'
|
||||
|
||||
const props = defineProps({
|
||||
fields: { type: Array, default: () => [] },
|
||||
listConfig: { type: Array, default: () => [] },
|
||||
editConfigById: { type: [Object, Map], default: () => new Map() },
|
||||
readonlyFields: { type: Array, default: () => [] },
|
||||
dataSource: { type: Array, default: () => [] },
|
||||
rowKey: { type: [String, Function], default: 'b_id' },
|
||||
columnExtensions: { type: Object, default: () => ({}) },
|
||||
rowActions: { type: Array, default: () => [] },
|
||||
rowSelection: { type: Object, default: null },
|
||||
rowClassName: { type: Function, default: undefined },
|
||||
expandable: { type: [Boolean, Object], default: false },
|
||||
inlineActionLimit: { type: Number, default: 2 },
|
||||
editable: { type: Boolean, default: false },
|
||||
// 行拖拽排序:开启后显示拖拽手柄,通过 row-order-change 回传排序后的完整数据。
|
||||
rowDraggable: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'row-click',
|
||||
'cell-click',
|
||||
'row-action',
|
||||
'expand',
|
||||
'cell-change',
|
||||
'row-order-change',
|
||||
])
|
||||
const { t, localeValue } = useI18n()
|
||||
const slots = useSlots()
|
||||
const tableRef = ref(null)
|
||||
const columns = shallowRef([])
|
||||
let expandedRecord = null
|
||||
|
||||
const expansionEnabled = computed(
|
||||
() =>
|
||||
props.expandable === true ||
|
||||
Boolean(props.expandable?.enabled) ||
|
||||
Boolean(slots['expanded-row']),
|
||||
)
|
||||
const expansionOptions = computed(() =>
|
||||
props.expandable && typeof props.expandable === 'object' ? props.expandable : {},
|
||||
)
|
||||
const expandConfig = computed(() => ({ height: Number(expansionOptions.value.height) || 180 }))
|
||||
|
||||
function formatCellValue(value, field, optionInfo) {
|
||||
if (value === null || value === undefined || value === '') return ''
|
||||
if (field.b_type === 'date') return formatDateValue(value, false)
|
||||
if (field.b_type === 'datetime') return formatDateValue(value, true)
|
||||
if (field.b_type === 'checkbox') {
|
||||
return h(Checkbox, { modelValue: value === true || Number(value) === 1, disabled: true })
|
||||
}
|
||||
if (field.b_type === 'select') {
|
||||
const option = optionInfo.options.find((item) => String(item.value) === String(value))
|
||||
return option?.label ?? String(value)
|
||||
}
|
||||
if (field.b_type === 'money') {
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number)) return String(value)
|
||||
const precision = Number.isInteger(optionInfo.optionConfig.precision)
|
||||
? optionInfo.optionConfig.precision
|
||||
: 2
|
||||
return number.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
})
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function resolveExtension(fieldName) {
|
||||
return props.columnExtensions?.[fieldName] || {}
|
||||
}
|
||||
|
||||
// 单元格内交互控件的事件隔离:阻止 StkTable 的行编辑/选区 mousedown 拦截
|
||||
const stop = (event) => event.stopPropagation()
|
||||
|
||||
// 根据字段类型与编辑配置生成行内编辑器配置。
|
||||
function buildEditor(field, config) {
|
||||
if (
|
||||
props.readonlyFields.includes(field.b_field) ||
|
||||
(config && (Number(config.b_readonly) === 1 || Number(config.b_disabled) === 1))
|
||||
) {
|
||||
return { type: 'input', disabled: true }
|
||||
}
|
||||
if (field.b_type === 'checkbox') return { type: 'checkbox' }
|
||||
if (field.b_type === 'select') {
|
||||
const { options } = parseOptions(field)
|
||||
return { type: 'select', options }
|
||||
}
|
||||
return { type: 'input' }
|
||||
}
|
||||
|
||||
function createRuntimeColumn(config, field) {
|
||||
const optionInfo = parseOptions(field)
|
||||
const extension = resolveExtension(field.b_field)
|
||||
const { formatter, clickable, cellClassName, onClick: _onClick, onChange, disabled: cellDisabled, ...columnProps } = extension
|
||||
const slot = slots[`cell-${field.b_field}`]
|
||||
const editConfig = props.editable ? props.editConfigById.get(String(config.b_field_id)) : null
|
||||
const column = {
|
||||
title: t(field.b_i18n, field.b_name || field.b_field),
|
||||
dataIndex: field.b_field,
|
||||
key: field.b_field,
|
||||
width: Number(config.b_width) || 120,
|
||||
checkbox: field.b_type === 'checkbox',
|
||||
align: field.b_type === 'checkbox' ? 'center' : undefined,
|
||||
editor: props.editable ? buildEditor(field, editConfig) : undefined,
|
||||
...columnProps,
|
||||
}
|
||||
|
||||
// 可编辑模式下由 editor 接管单元格渲染(resolveCustomCell 会优先 editor 分支),
|
||||
// 不能再设置 customCell,否则编辑器永远不会被渲染。
|
||||
if (props.editable) return column
|
||||
|
||||
// checkbox 字段且页面提供了 onChange 回调 → 渲染可交互勾选框(非只读)
|
||||
const interactiveCheckbox = field.b_type === 'checkbox' && typeof onChange === 'function'
|
||||
|
||||
column.customCell = ({ row, cellValue, rowIndex, colIndex }) => {
|
||||
const formattedValue = formatter
|
||||
? formatter({ value: cellValue, record: row, field, config })
|
||||
: formatCellValue(cellValue, field, optionInfo)
|
||||
const cellClass =
|
||||
typeof cellClassName === 'function'
|
||||
? cellClassName({ value: cellValue, record: row, field, config })
|
||||
: cellClassName
|
||||
|
||||
let content
|
||||
if (slot) {
|
||||
// cell-{field} 插槽优先级最高
|
||||
content = slot({
|
||||
value: cellValue,
|
||||
displayValue: formattedValue,
|
||||
record: row,
|
||||
field,
|
||||
config,
|
||||
rowIndex,
|
||||
colIndex,
|
||||
})
|
||||
} else if (interactiveCheckbox) {
|
||||
const isDisabled = typeof cellDisabled === 'function' ? Boolean(cellDisabled(row)) : Boolean(cellDisabled)
|
||||
content = h(Checkbox, {
|
||||
modelValue: cellValue === true || Number(cellValue) === 1,
|
||||
disabled: isDisabled,
|
||||
onMousedown: stop,
|
||||
onClick: stop,
|
||||
onChange: (checked) => {
|
||||
const next = checked ? 1 : 0
|
||||
onChange(row, next)
|
||||
emit('cell-change', { row, field: field.b_field, value: next })
|
||||
},
|
||||
})
|
||||
} else {
|
||||
content = formattedValue
|
||||
}
|
||||
|
||||
return h(
|
||||
'span',
|
||||
{
|
||||
class: [
|
||||
'fms-module-cell',
|
||||
cellClass,
|
||||
field.b_type === 'checkbox' ? 'fms-module-cell--center' : '',
|
||||
{ 'fms-module-cell--clickable': clickable || extension.onClick },
|
||||
],
|
||||
title: !interactiveCheckbox && typeof formattedValue === 'string' && formattedValue !== '-' ? formattedValue : '',
|
||||
},
|
||||
content,
|
||||
)
|
||||
}
|
||||
return column
|
||||
}
|
||||
|
||||
function createActionColumn() {
|
||||
if (!props.rowActions.length) return null
|
||||
const actions = props.rowActions.map((action, index) => {
|
||||
const actionContext = (record) => ({ ...record, record, action })
|
||||
return {
|
||||
...action,
|
||||
key: String(action.key ?? action.type ?? index),
|
||||
type: action.type || (action.danger ? 'danger' : 'action'),
|
||||
visible:
|
||||
typeof action.visible === 'function'
|
||||
? (record) => action.visible(actionContext(record))
|
||||
: action.visible,
|
||||
disabled:
|
||||
typeof action.disabled === 'function'
|
||||
? (record) => action.disabled(actionContext(record))
|
||||
: action.disabled,
|
||||
onClick: (record) => {
|
||||
action.onClick?.(record)
|
||||
emit('row-action', {
|
||||
key: String(action.key ?? action.type ?? index),
|
||||
record,
|
||||
action,
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
const inlineLimit = Math.max(0, props.inlineActionLimit)
|
||||
const inlineActions = actions.slice(0, inlineLimit)
|
||||
const overflowActions = actions.slice(inlineLimit)
|
||||
if (overflowActions.length) {
|
||||
inlineActions.push({
|
||||
type: 'more',
|
||||
title: t('common.moreActions', '更多操作'),
|
||||
items: overflowActions,
|
||||
})
|
||||
}
|
||||
return {
|
||||
dataIndex: '__actions__',
|
||||
key: '__actions__',
|
||||
title: t('common.operation', '操作'),
|
||||
width: Math.max(70, inlineActions.length * 52),
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
resizable: false,
|
||||
actions: inlineActions,
|
||||
}
|
||||
}
|
||||
|
||||
function buildColumns() {
|
||||
const fieldById = new Map(props.fields.map((field) => [String(field.b_id), field]))
|
||||
const runtimeColumns = props.listConfig
|
||||
.filter((config) => config.b_visible === 1)
|
||||
.map((config) => {
|
||||
const field = fieldById.get(String(config.b_field_id))
|
||||
return field ? createRuntimeColumn(config, field) : null
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (expansionEnabled.value) {
|
||||
runtimeColumns.unshift({
|
||||
type: 'expand',
|
||||
dataIndex: '__expand__',
|
||||
key: '__expand__',
|
||||
title: '',
|
||||
width: 42,
|
||||
fixed: 'left',
|
||||
resizable: false,
|
||||
})
|
||||
}
|
||||
const actionColumn = createActionColumn()
|
||||
if (actionColumn) runtimeColumns.push(actionColumn)
|
||||
return runtimeColumns
|
||||
}
|
||||
|
||||
function rebuildColumns() {
|
||||
columns.value = buildColumns()
|
||||
}
|
||||
|
||||
function resetColumns() {
|
||||
rebuildColumns()
|
||||
tableRef.value?.clearSelectedArea?.()
|
||||
tableRef.value?.initVirtualScroll?.()
|
||||
}
|
||||
|
||||
function refreshLayout() {
|
||||
tableRef.value?.refreshLayout?.()
|
||||
}
|
||||
|
||||
function getColumnSettings() {
|
||||
const configByFieldId = new Map()
|
||||
for (const config of props.listConfig || []) {
|
||||
configByFieldId.set(String(config.b_field_id), config)
|
||||
}
|
||||
|
||||
const settings = []
|
||||
let order = 10
|
||||
for (const column of columns.value) {
|
||||
if (!column.dataIndex || String(column.dataIndex).startsWith('__')) continue
|
||||
const field = props.fields.find((item) => item.b_field === column.dataIndex)
|
||||
if (!field) continue
|
||||
const config = configByFieldId.get(String(field.b_id))
|
||||
if (!config) continue
|
||||
const parsedWidth = Number.parseFloat(String(column.width))
|
||||
let columnWidth = config.b_width
|
||||
if (Number.isFinite(parsedWidth) && parsedWidth > 0) {
|
||||
columnWidth = Math.round(parsedWidth)
|
||||
}
|
||||
settings.push({
|
||||
...config,
|
||||
b_xh: order,
|
||||
b_width: columnWidth,
|
||||
})
|
||||
order += 10
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
function handleCellClick(event, record, column, meta) {
|
||||
if (!column?.dataIndex || String(column.dataIndex).startsWith('__')) return
|
||||
const field = props.fields.find((item) => item.b_field === column.dataIndex)
|
||||
const payload = {
|
||||
event,
|
||||
record,
|
||||
field,
|
||||
column,
|
||||
value: record?.[column.dataIndex],
|
||||
...meta,
|
||||
}
|
||||
resolveExtension(column.dataIndex).onClick?.(payload)
|
||||
emit('cell-click', payload)
|
||||
}
|
||||
|
||||
function handleRowClick(event, record, meta) {
|
||||
emit('row-click', { event, record, ...meta })
|
||||
}
|
||||
|
||||
function handleCellChange(row, col, value) {
|
||||
emit('cell-change', { row, field: col.dataIndex, value })
|
||||
}
|
||||
|
||||
function handleToggleExpand(payload) {
|
||||
const single = expansionOptions.value.mode !== 'multiple'
|
||||
if (payload.expanded && single && expandedRecord && expandedRecord !== payload.row) {
|
||||
tableRef.value?.setRowExpand?.(expandedRecord, false, { silent: true })
|
||||
}
|
||||
if (payload.expanded) {
|
||||
expandedRecord = payload.row
|
||||
} else if (expandedRecord === payload.row) {
|
||||
expandedRecord = null
|
||||
}
|
||||
emit('expand', { expanded: payload.expanded, record: payload.row, column: payload.col })
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.fields,
|
||||
() => props.listConfig,
|
||||
() => props.columnExtensions,
|
||||
() => props.rowActions,
|
||||
() => props.editable,
|
||||
() => props.editConfigById,
|
||||
localeValue,
|
||||
expansionEnabled,
|
||||
],
|
||||
rebuildColumns,
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.dataSource,
|
||||
() => {
|
||||
expandedRecord = null
|
||||
},
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
resetColumns,
|
||||
refreshLayout,
|
||||
getColumnSettings,
|
||||
clearSelectedArea: () => tableRef.value?.clearSelectedArea?.(),
|
||||
clearEditing: () => tableRef.value?.clearEditing?.(),
|
||||
editRow: (rowKey, focusCol) => tableRef.value?.editRow?.(rowKey, focusCol),
|
||||
scrollToRow: (rowKey) => tableRef.value?.scrollToRow?.(rowKey),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FmsTable
|
||||
ref="tableRef"
|
||||
v-model:columns="columns"
|
||||
:data-source="dataSource"
|
||||
:row-key="rowKey"
|
||||
:row-selection="rowSelection"
|
||||
:row-class-name="rowClassName"
|
||||
:expand-config="expansionEnabled ? expandConfig : undefined"
|
||||
:editable="editable"
|
||||
:row-draggable="rowDraggable"
|
||||
@cell-click="handleCellClick"
|
||||
@row-click="handleRowClick"
|
||||
@toggle-row-expand="handleToggleExpand"
|
||||
@change="handleCellChange"
|
||||
@row-order-change="
|
||||
(rows, sourceIndex, targetIndex) => emit('row-order-change', rows, sourceIndex, targetIndex)
|
||||
"
|
||||
>
|
||||
<template v-if="expansionEnabled" #expand="{ row, col }">
|
||||
<slot name="expanded-row" :record="row" :column="col" />
|
||||
</template>
|
||||
<template v-if="$slots.empty" #empty>
|
||||
<slot name="empty" />
|
||||
</template>
|
||||
</FmsTable>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.fms-module-cell) {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.fms-module-cell--clickable) {
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* checkbox 列内容水平居中(勾选框本身为 inline-flex) */
|
||||
:deep(.fms-module-cell--center) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { DatePicker, Input, RangePicker, Select } from '@/components/ui'
|
||||
import { isDateQuery, isEmptyValue, isNumericField, isRangeQuery } from './moduleListUtils'
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, required: true },
|
||||
modelValue: { type: [String, Number, Boolean, Array, Object], default: undefined },
|
||||
compact: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'enter'])
|
||||
const isRange = computed(() => isRangeQuery(props.item))
|
||||
const isNumeric = computed(() => isNumericField(props.item))
|
||||
const isDate = computed(() => isDateQuery(props.item))
|
||||
const isCheckbox = computed(() => props.item.field.b_type === 'checkbox')
|
||||
const isSelect = computed(
|
||||
() => props.item.config.b_component === 'select' || props.item.field.b_type === 'select',
|
||||
)
|
||||
const isMultipleSelect = computed(() => isSelect.value && props.item.config.b_operator === 'in')
|
||||
const value = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (next) => emit('update:modelValue', next),
|
||||
})
|
||||
const startValue = computed({
|
||||
get: () => (Array.isArray(props.modelValue) ? props.modelValue[0] : ''),
|
||||
set: (next) => {
|
||||
const range = Array.isArray(props.modelValue) ? [...props.modelValue] : ['', '']
|
||||
range[0] = next
|
||||
emit('update:modelValue', range)
|
||||
},
|
||||
})
|
||||
const endValue = computed({
|
||||
get: () => (Array.isArray(props.modelValue) ? props.modelValue[1] : ''),
|
||||
set: (next) => {
|
||||
const range = Array.isArray(props.modelValue) ? [...props.modelValue] : ['', '']
|
||||
range[1] = next
|
||||
emit('update:modelValue', range)
|
||||
},
|
||||
})
|
||||
const checkboxOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '是', value: '1' },
|
||||
{ label: '否', value: '0' },
|
||||
]
|
||||
const selectOptions = computed(() => props.item.options || [])
|
||||
|
||||
function onEnter(event) {
|
||||
if (event?.key === 'Enter') emit('enter')
|
||||
}
|
||||
|
||||
function comparableDate(valueToCompare) {
|
||||
if (isEmptyValue(valueToCompare)) return null
|
||||
const time = new Date(valueToCompare).getTime()
|
||||
return Number.isNaN(time) ? null : time
|
||||
}
|
||||
|
||||
function isDateAfterLimit(valueToCompare) {
|
||||
const current = comparableDate(valueToCompare)
|
||||
const end = comparableDate(endValue.value)
|
||||
return current !== null && end !== null && current > end
|
||||
}
|
||||
|
||||
function isDateBeforeLimit(valueToCompare) {
|
||||
const current = comparableDate(valueToCompare)
|
||||
const start = comparableDate(startValue.value)
|
||||
return current !== null && start !== null && current < start
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isRange"
|
||||
class="fms-query-control fms-query-control__range"
|
||||
:class="{ 'fms-query-control__range--compact': compact }"
|
||||
>
|
||||
<RangePicker
|
||||
v-if="isDate"
|
||||
class="fms-query-control__date-range"
|
||||
:model-value="modelValue"
|
||||
value-format="YYYY-MM-DD"
|
||||
:disabled-date="(date) => isDateAfterLimit(date) || isDateBeforeLimit(date)"
|
||||
@update:model-value="(next) => emit('update:modelValue', next)"
|
||||
/>
|
||||
<template v-else>
|
||||
<Input v-model="startValue" :type="isNumeric ? 'number' : 'default'" @keydown="onEnter" />
|
||||
<span class="fms-query-control__separator">-</span>
|
||||
<Input v-model="endValue" :type="isNumeric ? 'number' : 'default'" @keydown="onEnter" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
v-else-if="isCheckbox"
|
||||
v-model="value"
|
||||
class="fms-query-control"
|
||||
:options="checkboxOptions"
|
||||
:filterable="false"
|
||||
/>
|
||||
<select
|
||||
v-else-if="isMultipleSelect"
|
||||
class="fms-query-control fms-query-control__native-multiple"
|
||||
multiple
|
||||
:value="Array.isArray(modelValue) ? modelValue : []"
|
||||
@change="
|
||||
(event) =>
|
||||
emit(
|
||||
'update:modelValue',
|
||||
[...event.target.selectedOptions].map((option) => option.value),
|
||||
)
|
||||
"
|
||||
>
|
||||
<option v-for="option in selectOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<Select
|
||||
v-else-if="isSelect"
|
||||
v-model="value"
|
||||
class="fms-query-control"
|
||||
:options="selectOptions"
|
||||
clearable
|
||||
:filterable="!compact"
|
||||
/>
|
||||
<DatePicker
|
||||
v-else-if="isDate"
|
||||
v-model="value"
|
||||
class="fms-query-control"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
v-model="value"
|
||||
class="fms-query-control"
|
||||
:type="isNumeric ? 'number' : 'default'"
|
||||
allow-clear
|
||||
@keydown="onEnter"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fms-query-control__range {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr) 18px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fms-query-control__range--compact {
|
||||
gap: 2px;
|
||||
}
|
||||
.fms-query-control__separator {
|
||||
color: var(--fms-text-secondary, #646a73);
|
||||
text-align: center;
|
||||
}
|
||||
.fms-query-control__native-multiple {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--fms-border, #d9dce1);
|
||||
border-radius: 4px;
|
||||
background: var(--fms-surface, #fff);
|
||||
color: var(--fms-text, #1f2329);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,260 @@
|
||||
<script setup>
|
||||
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 FmsQueryControl from './FmsQueryControl.vue'
|
||||
import { defaultQueryValue, emptyQueryValue } from './moduleListUtils'
|
||||
|
||||
const props = defineProps({
|
||||
queryItems: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
moreItems: { type: Array, default: () => [] },
|
||||
defaultAdvancedCount: { type: Number, default: 6 },
|
||||
})
|
||||
const emit = defineEmits(['search', 'reset', 'more-action'])
|
||||
const { t } = useI18n()
|
||||
const filters = reactive({})
|
||||
const quickFieldName = ref('')
|
||||
const quickValue = ref(undefined)
|
||||
const advancedOpen = ref(false)
|
||||
const advancedMoreOpen = ref(false)
|
||||
|
||||
const quickFieldOptions = computed(() =>
|
||||
props.queryItems.map((item) => ({
|
||||
value: item.field.b_field,
|
||||
label: t(item.field.b_i18n, item.field.b_name || item.field.b_field),
|
||||
})),
|
||||
)
|
||||
const quickItem = computed(() =>
|
||||
props.queryItems.find((item) => item.field.b_field === quickFieldName.value),
|
||||
)
|
||||
const visibleAdvancedQueryItems = computed(() =>
|
||||
advancedMoreOpen.value ? props.queryItems : props.queryItems.slice(0, props.defaultAdvancedCount),
|
||||
)
|
||||
const hasHiddenAdvanced = computed(() => props.queryItems.length > props.defaultAdvancedCount)
|
||||
|
||||
function cloneValue(value) {
|
||||
return Array.isArray(value) ? [...value] : value
|
||||
}
|
||||
function initializeDrafts() {
|
||||
Object.keys(filters).forEach((key) => delete filters[key])
|
||||
props.queryItems.forEach((item) => {
|
||||
filters[item.field.b_field] = defaultQueryValue(item)
|
||||
})
|
||||
const first = props.queryItems[0]
|
||||
quickFieldName.value = first?.field.b_field || ''
|
||||
quickValue.value = first ? defaultQueryValue(first) : undefined
|
||||
advancedOpen.value = false
|
||||
advancedMoreOpen.value = false
|
||||
}
|
||||
function handleQuickFieldChange(fieldName) {
|
||||
const item = props.queryItems.find((entry) => entry.field.b_field === fieldName)
|
||||
quickValue.value = item ? emptyQueryValue(item) : undefined
|
||||
}
|
||||
function handleQuickSearch() {
|
||||
emit('search', {
|
||||
mode: 'quick',
|
||||
entries: quickItem.value
|
||||
? [{ item: quickItem.value, value: cloneValue(quickValue.value) }]
|
||||
: [],
|
||||
})
|
||||
}
|
||||
function toggleAdvancedSearch() {
|
||||
advancedOpen.value = !advancedOpen.value
|
||||
if (advancedOpen.value && quickItem.value)
|
||||
filters[quickItem.value.field.b_field] = cloneValue(quickValue.value)
|
||||
if (!advancedOpen.value) advancedMoreOpen.value = false
|
||||
}
|
||||
function handleAdvancedSearch() {
|
||||
emit('search', {
|
||||
mode: 'advanced',
|
||||
entries: props.queryItems.map((item) => ({
|
||||
item,
|
||||
value: cloneValue(filters[item.field.b_field]),
|
||||
})),
|
||||
})
|
||||
}
|
||||
function handleAdvancedReset() {
|
||||
props.queryItems.forEach((item) => {
|
||||
filters[item.field.b_field] = emptyQueryValue(item)
|
||||
})
|
||||
emit('reset')
|
||||
}
|
||||
watch(() => props.queryItems, initializeDrafts, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fms-query-toolbar">
|
||||
<div class="list-toolbar">
|
||||
<div class="toolbar-operations"><slot name="actions" /></div>
|
||||
<div class="toolbar-tools">
|
||||
<div v-if="quickItem && !advancedOpen" class="quick-search">
|
||||
<Select
|
||||
v-model="quickFieldName"
|
||||
class="quick-field-select"
|
||||
:options="quickFieldOptions"
|
||||
:filterable="false"
|
||||
@update:model-value="handleQuickFieldChange"
|
||||
/>
|
||||
<FmsQueryControl
|
||||
v-model="quickValue"
|
||||
:item="quickItem"
|
||||
compact
|
||||
@enter="handleQuickSearch"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
class="quick-submit"
|
||||
:loading="loading"
|
||||
aria-label="搜索"
|
||||
@click="handleQuickSearch"
|
||||
><Search :size="15"
|
||||
/></Button>
|
||||
</div>
|
||||
<Button
|
||||
v-if="queryItems.length"
|
||||
type="ghost"
|
||||
class="toolbar-icon-button"
|
||||
:aria-label="advancedOpen ? '收起高级搜索' : '高级搜索'"
|
||||
@click="toggleAdvancedSearch"
|
||||
>
|
||||
<ChevronUp v-if="advancedOpen" :size="15" /><ChevronDown v-else :size="15" />
|
||||
</Button>
|
||||
<Dropdown
|
||||
v-if="moreItems.length"
|
||||
:items="moreItems"
|
||||
placement="bottom-end"
|
||||
@click="(event) => emit('more-action', event)"
|
||||
>
|
||||
<Button type="ghost" aria-label="更多" :disabled="loading"
|
||||
><Ellipsis :size="17"
|
||||
/></Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="advancedOpen && queryItems.length" class="query-panel">
|
||||
<div class="query-grid">
|
||||
<FormItem
|
||||
v-for="item in visibleAdvancedQueryItems"
|
||||
:key="item.field.b_field"
|
||||
:name="item.field.b_field"
|
||||
:label="t(item.field.b_i18n, item.field.b_name || item.field.b_field)"
|
||||
>
|
||||
<FmsQueryControl v-model="filters[item.field.b_field]" :item="item" />
|
||||
</FormItem>
|
||||
</div>
|
||||
<div class="query-actions">
|
||||
<Button type="primary" :loading="loading" @click="handleAdvancedSearch"
|
||||
><Search :size="14" />搜索</Button
|
||||
>
|
||||
<Button type="ghost" :disabled="loading" @click="handleAdvancedReset">重置</Button>
|
||||
<Button v-if="hasHiddenAdvanced" type="link" @click="advancedMoreOpen = !advancedMoreOpen">
|
||||
{{
|
||||
advancedMoreOpen
|
||||
? '收起条件'
|
||||
: `更多条件(${props.queryItems.length - props.defaultAdvancedCount})`
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fms-query-toolbar {
|
||||
flex: none;
|
||||
padding: 10px 12px 0;
|
||||
}
|
||||
.list-toolbar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.toolbar-operations,
|
||||
.toolbar-tools,
|
||||
.query-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.toolbar-tools {
|
||||
margin-left: auto;
|
||||
}
|
||||
/* ---- 快速搜索:字段下拉 + 搜索框 + 按钮拼成一体(space-compact 风格) ---- */
|
||||
.quick-search {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--fms-border, #e5e6eb);
|
||||
border-radius: var(--fms-control-radius, 4px);
|
||||
}
|
||||
.quick-search > :deep(.quick-field-select) {
|
||||
width: 130px;
|
||||
flex: none;
|
||||
}
|
||||
.quick-search > :deep(.quick-field-select .select-trigger) {
|
||||
border: none;
|
||||
}
|
||||
.quick-search > :deep(.fms-query-control) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--fms-border, #e5e6eb);
|
||||
border-left: 1px solid var(--fms-border, #e5e6eb);
|
||||
}
|
||||
.quick-search > :deep(.fms-query-control .input-wrapper),
|
||||
.quick-search > :deep(.fms-query-control .select-trigger) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
.quick-search > :deep(.quick-submit) {
|
||||
flex: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
/* 极窄屏:快速搜索占满整行 */
|
||||
@media (max-width: 700px) {
|
||||
.quick-search {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
.query-panel {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--fms-border, #e5e6eb);
|
||||
background: var(--fms-surface-muted, #f7f8fa);
|
||||
}
|
||||
.query-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 8px 14px;
|
||||
}
|
||||
.query-grid :deep(.fms-form-item) {
|
||||
min-width: 0;
|
||||
}
|
||||
.query-actions {
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.toolbar-icon-button {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.list-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.toolbar-tools {
|
||||
margin-left: 0;
|
||||
}
|
||||
.quick-search {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
import FmsModuleListPage from './FmsModuleListPage.vue'
|
||||
import FmsModuleTable from './FmsModuleTable.vue'
|
||||
import FmsQueryControl from './FmsQueryControl.vue'
|
||||
import FmsQueryToolbar from './FmsQueryToolbar.vue'
|
||||
|
||||
export { FmsModuleListPage, FmsModuleTable, FmsQueryControl, FmsQueryToolbar }
|
||||
export * from './moduleListUtils.js'
|
||||
@@ -0,0 +1,177 @@
|
||||
// 数据驱动列表的共享工具函数,避免在各组件间重复实现查询/格式化逻辑。
|
||||
|
||||
export const identifierRe = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
|
||||
const padDatePart = (number) => String(number).padStart(2, '0')
|
||||
|
||||
export function escapeSqlText(value) {
|
||||
return String(value).replaceAll("'", "''")
|
||||
}
|
||||
|
||||
export function requireModuleId(value) {
|
||||
const id = String(value ?? '')
|
||||
if (!/^\d+$/.test(id)) throw new Error('模块 ID 格式不正确')
|
||||
return id
|
||||
}
|
||||
|
||||
export function isEmptyValue(value) {
|
||||
return value === undefined || value === null || value === ''
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function isRangeQuery(item) {
|
||||
return (
|
||||
item.config.b_operator === 'between' ||
|
||||
item.config.b_component === 'number-range' ||
|
||||
item.field.b_type === 'datetime'
|
||||
)
|
||||
}
|
||||
|
||||
export function isNumericField(item) {
|
||||
return item.field.b_type === 'number' || item.field.b_type === 'money'
|
||||
}
|
||||
|
||||
export function isDateQuery(item) {
|
||||
return (
|
||||
item.config.b_component === 'date-picker' ||
|
||||
item.field.b_type === 'date' ||
|
||||
item.field.b_type === 'datetime'
|
||||
)
|
||||
}
|
||||
|
||||
export function emptyQueryValue(item) {
|
||||
if (item.field.b_type === 'checkbox') return ''
|
||||
if (isRangeQuery(item) || item.config.b_operator === 'in') return []
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function defaultQueryValue(item) {
|
||||
const value = item.config.b_default_value
|
||||
if (value === null || value === undefined || value === '') return emptyQueryValue(item)
|
||||
if (isRangeQuery(item) || item.config.b_operator === 'in') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch {
|
||||
// 非 JSON 默认值继续按逗号分隔处理。
|
||||
}
|
||||
return String(value)
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
if (isNumericField(item)) {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : undefined
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function sqlLiteral(value, field, endOfRange = false) {
|
||||
if (isEmptyValue(value)) return null
|
||||
|
||||
if (field.b_type === 'number' || field.b_type === 'money') {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? String(number) : null
|
||||
}
|
||||
|
||||
if (field.b_type === 'checkbox') {
|
||||
return value === true || Number(value) === 1 ? '1' : '0'
|
||||
}
|
||||
|
||||
let normalized = String(value)
|
||||
const isDate = field.b_type === 'date' || field.b_type === 'datetime'
|
||||
if (isDate && value && typeof value.format === 'function') {
|
||||
normalized = value.format('YYYY-MM-DD')
|
||||
} else if (isDate) {
|
||||
normalized = String(value).slice(0, 10)
|
||||
}
|
||||
|
||||
if (field.b_type === 'datetime') {
|
||||
normalized += endOfRange ? ' 23:59:59' : ' 00:00:00'
|
||||
}
|
||||
|
||||
return `N'${escapeSqlText(normalized)}'`
|
||||
}
|
||||
|
||||
export function buildQueryCondition(item, value) {
|
||||
const fieldName = item.field.b_field
|
||||
if (!identifierRe.test(fieldName)) return ''
|
||||
|
||||
const field = `[${fieldName}]`
|
||||
if (isRangeQuery(item)) {
|
||||
const values = Array.isArray(value) ? value : []
|
||||
const start = sqlLiteral(values[0], item.field)
|
||||
const end = sqlLiteral(values[1], item.field, true)
|
||||
if (start && end) return `${field} BETWEEN ${start} AND ${end}`
|
||||
if (start) return `${field} >= ${start}`
|
||||
if (end) return `${field} <= ${end}`
|
||||
return ''
|
||||
}
|
||||
|
||||
if (item.config.b_operator === 'in') {
|
||||
let values = value
|
||||
if (!Array.isArray(values)) {
|
||||
values = String(value ?? '')
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
const literals = []
|
||||
for (const entry of values) {
|
||||
const literal = sqlLiteral(entry, item.field)
|
||||
if (literal) literals.push(literal)
|
||||
}
|
||||
return literals.length ? `${field} IN (${literals.join(', ')})` : ''
|
||||
}
|
||||
|
||||
const literal = sqlLiteral(value, item.field)
|
||||
if (!literal) return ''
|
||||
if (item.config.b_operator === 'like') {
|
||||
return `${field} LIKE N'%${escapeSqlText(String(value))}%'`
|
||||
}
|
||||
return `${field} = ${literal}`
|
||||
}
|
||||
|
||||
export function formatDateValue(value, withTime = false) {
|
||||
if (isEmptyValue(value)) return ''
|
||||
if (value && typeof value.format === 'function') {
|
||||
return value.format(withTime ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD')
|
||||
}
|
||||
const text = String(value).replace('T', ' ')
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return withTime ? text.slice(0, 19) : text.slice(0, 10)
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return text
|
||||
const datePart = `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}`
|
||||
return withTime
|
||||
? `${datePart} ${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}:${padDatePart(date.getSeconds())}`
|
||||
: datePart
|
||||
}
|
||||
@@ -55,8 +55,9 @@ const navMain = computed(() => {
|
||||
title: '系统管理',
|
||||
icon: Settings,
|
||||
items: [
|
||||
{ title: '模块管理', url: '/system/module-management' },
|
||||
{ title: '菜单管理', url: '/system/menu-management' },
|
||||
{ title: '模块管理', url: '/module/module-management' },
|
||||
{ title: '菜单管理', url: '/module/menu-management' },
|
||||
{ title: '多语言类型', url: '/module/i18n-type' },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'nprogress/nprogress.css'
|
||||
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermissionStore } from '@/stores/permissions'
|
||||
import { useLanguagesStore } from '@/stores/languages'
|
||||
|
||||
NProgress.configure({ showSpinner: false })
|
||||
|
||||
@@ -96,18 +97,25 @@ const router = createRouter({
|
||||
},
|
||||
// 模块管理:元数据驱动的模块配置中心(先静态路由,权限/动态菜单后续接入)
|
||||
{
|
||||
path: '/system/module-management',
|
||||
name: 'system-module-management',
|
||||
component: () => import('@/views/system/module-management/index.vue'),
|
||||
path: '/module/module-management',
|
||||
name: 'module-module-management',
|
||||
component: () => import('@/views/module/module-management/index.vue'),
|
||||
meta: { title: '模块管理', componentName: 'ModuleManagement' },
|
||||
},
|
||||
// 菜单管理:占位页面,待实现
|
||||
{
|
||||
path: '/system/menu-management',
|
||||
name: 'system-menu-management',
|
||||
component: () => import('@/views/system/menu-management/index.vue'),
|
||||
path: '/module/menu-management',
|
||||
name: 'module-menu-management',
|
||||
component: () => import('@/views/module/menu-management/index.vue'),
|
||||
meta: { title: '菜单管理', componentName: 'MenuManagement' },
|
||||
},
|
||||
// 多语言类型:系统支持的语言清单维护(s_i18n_type)
|
||||
{
|
||||
path: '/module/i18n-type',
|
||||
name: 'module-i18n-type',
|
||||
component: () => import('@/views/module/i18n-type/index.vue'),
|
||||
meta: { title: '多语言类型', componentName: 'I18nTypeManagement' },
|
||||
},
|
||||
// 业务页面后续在此追加,配置 meta.moduleId 后自动走权限校验
|
||||
],
|
||||
},
|
||||
@@ -139,6 +147,11 @@ router.beforeEach(async (to) => {
|
||||
const permissionStore = usePermissionStore()
|
||||
await permissionStore.load().catch(() => {})
|
||||
|
||||
// 业务数据多语言配置(s_i18n_type 语言清单):随权限一并加载,
|
||||
// 供菜单/模块等多语言编辑读取;内部去重、失败不阻断页面
|
||||
const languagesStore = useLanguagesStore()
|
||||
await languagesStore.loadLanguages().catch(() => {})
|
||||
|
||||
const moduleId = to.meta.moduleId
|
||||
if (moduleId && !permissionStore.canAccess(moduleId)) {
|
||||
return '/dashboard'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { watch } from 'vue'
|
||||
import { DEFAULT_PRIMARY } from '@/theme/presets'
|
||||
import { applyPrimaryColor, applyDarkMode } from '@/theme/index'
|
||||
import { setLocale as setRuntimeLocale, translate } from '@/utils/translate'
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: () => ({
|
||||
@@ -18,6 +19,11 @@ export const useAppStore = defineStore('app', {
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// 翻译目录由 utils/translate 维护,store 仅提供旧调用方兼容门面。
|
||||
t:
|
||||
() =>
|
||||
(key, fallback = '') =>
|
||||
translate(key, fallback),
|
||||
/** keep-alive 的 include 列表:当前打开页签对应的组件名(去重)。
|
||||
* 页签关闭时该集合随之缩小,keep-alive 据此销毁对应缓存实例,
|
||||
* 实现"关闭页签即销毁缓存" */
|
||||
@@ -45,6 +51,7 @@ export const useAppStore = defineStore('app', {
|
||||
},
|
||||
toggleLocale() {
|
||||
this.locale = this.locale === 'zh-CN' ? 'en-US' : 'zh-CN'
|
||||
setRuntimeLocale(this.locale)
|
||||
},
|
||||
addVisitedTab(tab) {
|
||||
if (this.visitedTabs.some((t) => t.key === tab.key)) return
|
||||
@@ -106,6 +113,8 @@ export const useAppStore = defineStore('app', {
|
||||
export function initThemeSync(pinia) {
|
||||
const store = useAppStore(pinia)
|
||||
|
||||
setRuntimeLocale(store.locale)
|
||||
|
||||
watch(
|
||||
() => store.darkMode,
|
||||
(val) => applyDarkMode(val),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { loadDataApi } from '@/services/api'
|
||||
|
||||
/**
|
||||
* 业务数据多语言配置 store(s_i18n_type 驱动)。
|
||||
*
|
||||
* 管理「业务数据要翻译成哪些语言」的语言清单(如菜单名 / 模块名等存 s_i18n 的
|
||||
* 目标语言),与界面文案翻译(utils/translate 的 t(key, fallback))无关:
|
||||
* - 界面文案默认走 t() 的 fallback 参数(中文),本 store 不参与;
|
||||
* - 本 store 只提供业务语言清单,完全以数据库 s_i18n_type 为准,无内置回退。
|
||||
*
|
||||
* 用法:
|
||||
* const langStore = useLanguagesStore()
|
||||
* await langStore.loadLanguages() // 启动时调用一次
|
||||
* langStore.languages // 启用的语言(b_canuse=1,按 b_xh 排序)
|
||||
* langStore.defaultLanguage // b_default=1 那条
|
||||
* langStore.nonDefaultLanguages // 其余语言(需要存 s_i18n 表)
|
||||
* langStore.langName('en-US') // 语言编码 → 名称
|
||||
*/
|
||||
export const useLanguagesStore = defineStore('languages', () => {
|
||||
/** 语言清单(s_i18n_type,仅 b_canuse=1,按 b_xh 排序) */
|
||||
const languages = ref([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
let loadingPromise = null
|
||||
|
||||
/** 默认语言(b_default=1);无则返回 null */
|
||||
const defaultLanguage = computed(
|
||||
() => languages.value.find((l) => Number(l.b_default) === 1) || null,
|
||||
)
|
||||
/** 非默认语言(需要走 s_i18n 表的语言) */
|
||||
const nonDefaultLanguages = computed(() =>
|
||||
languages.value.filter((l) => Number(l.b_default) !== 1),
|
||||
)
|
||||
/** code → 名称;未知编码返回编码本身 */
|
||||
const langName = computed(
|
||||
() => (code) => {
|
||||
const lang = languages.value.find((l) => String(l.b_code) === String(code))
|
||||
return lang?.b_name || String(code)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 加载语言配置。并发调用共享同一请求;加载成功后再次调用直接返回。
|
||||
* 失败时置 error 且不清空已有列表(保留上次成功数据),由调用方决定重试。
|
||||
*/
|
||||
async function loadLanguages(force = false) {
|
||||
if (!force && loaded.value) return
|
||||
if (loadingPromise && !force) return loadingPromise
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
loadingPromise = loadDataApi('s_i18n_type', 'b_canuse = 1', 'b_xh ASC, b_id ASC')
|
||||
.then((res) => {
|
||||
languages.value = (res.data || []).map((row) => ({
|
||||
b_id: row.b_id,
|
||||
b_code: row.b_code,
|
||||
b_name: row.b_name,
|
||||
b_canuse: Number(row.b_canuse),
|
||||
b_default: Number(row.b_default),
|
||||
b_xh: Number(row.b_xh) || 0,
|
||||
}))
|
||||
loaded.value = true
|
||||
})
|
||||
.catch((e) => {
|
||||
error.value = e?.message || String(e)
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
loadingPromise = null
|
||||
})
|
||||
return loadingPromise
|
||||
}
|
||||
|
||||
/** 强制重新加载(如 i18n-type 管理页保存后调用,让消费端同步) */
|
||||
function refresh() {
|
||||
return loadLanguages(true)
|
||||
}
|
||||
|
||||
return {
|
||||
languages,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
defaultLanguage,
|
||||
nonDefaultLanguages,
|
||||
langName,
|
||||
loadLanguages,
|
||||
refresh,
|
||||
}
|
||||
})
|
||||
@@ -87,11 +87,11 @@
|
||||
--fms-popover-shadow: 0 3px 8px rgba(0, 0, 0, 0.12);
|
||||
--fms-z-popover: 1000;
|
||||
|
||||
/* 轻提示 / 通知(message / notification) */
|
||||
/* 轻提示 / 通知(message / notification):须浮于弹窗之上(弹窗内操作的成功/失败提示可见) */
|
||||
--fms-feedback-icon-size: 18px;
|
||||
--fms-feedback-offset: 24px;
|
||||
--fms-z-message: 2000;
|
||||
--fms-z-notification: 2000;
|
||||
--fms-z-message: 4000;
|
||||
--fms-z-notification: 4000;
|
||||
|
||||
/* 弹窗(modal / drawer) */
|
||||
--fms-mask-bg: rgba(0, 0, 0, 0.45);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { computed, readonly, ref } from 'vue'
|
||||
|
||||
// 翻译目录独立于 Pinia:组件、服务和非组件代码都可以复用,切换目录时保持响应式。
|
||||
const localeRef = ref('zh-CN')
|
||||
const messagesRef = ref({})
|
||||
|
||||
export function translate(key, fallback = '') {
|
||||
const normalizedKey = String(key || '')
|
||||
const value = messagesRef.value[normalizedKey]
|
||||
if (value !== undefined && value !== null && String(value) !== '') return String(value)
|
||||
return fallback || normalizedKey
|
||||
}
|
||||
|
||||
export function setLocale(locale) {
|
||||
localeRef.value = String(locale || '')
|
||||
}
|
||||
|
||||
export function setMessages(messages, locale = undefined) {
|
||||
messagesRef.value = messages && typeof messages === 'object' ? { ...messages } : {}
|
||||
if (locale !== undefined) setLocale(locale)
|
||||
}
|
||||
|
||||
export function clearMessages() {
|
||||
messagesRef.value = {}
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
return {
|
||||
locale: readonly(localeRef),
|
||||
messages: readonly(messagesRef),
|
||||
t: translate,
|
||||
setLocale,
|
||||
setMessages,
|
||||
clearMessages,
|
||||
localeValue: computed(() => localeRef.value),
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,6 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from '@lucide/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ChevronDown, ChevronUp, Download, Plus, RefreshCw, Search, Trash2, Upload } from '@lucide/vue'
|
||||
import { Button, Dropdown, Input, Pagination, RangePicker, Select } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
|
||||
@@ -27,6 +18,21 @@ const STATUS_OPTIONS = [
|
||||
{ value: 'disabled', label: '禁用' },
|
||||
{ value: 'pending', label: '待审' },
|
||||
]
|
||||
// 低频筛选字段选项(模拟多字段场景)
|
||||
const PRIORITY_OPTIONS = [
|
||||
{ value: 'high', label: '高' },
|
||||
{ value: 'mid', label: '中' },
|
||||
{ value: 'low', label: '低' },
|
||||
]
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: 'manual', label: '手工录入' },
|
||||
{ value: 'import', label: '导入' },
|
||||
{ value: 'api', label: 'API' },
|
||||
]
|
||||
const ENABLED_OPTIONS = [
|
||||
{ value: '1', label: '是' },
|
||||
{ value: '0', label: '否' },
|
||||
]
|
||||
// 额外可编辑文本列数量,与下方业务列合计 20 列
|
||||
const EXTRA_FIELD_COUNT = 14
|
||||
|
||||
@@ -102,16 +108,48 @@ const DEFAULT_ROW_COUNT = 10000
|
||||
const columns = ref(makeColumns())
|
||||
const dataSource = ref(makeRows(DEFAULT_ROW_COUNT))
|
||||
|
||||
/* ---------- 工具栏 UI(仅界面骨架,搜索/查询逻辑后续接入) ---------- */
|
||||
const keyword = ref('')
|
||||
const advancedOpen = ref(false)
|
||||
const advancedForm = ref({
|
||||
/* ---------- 搜索表单 + 操作工具栏(仅界面骨架,查询/操作逻辑后续接入) ---------- */
|
||||
const searchForm = ref({
|
||||
name: '',
|
||||
type: undefined,
|
||||
status: undefined,
|
||||
range: null,
|
||||
owner: undefined,
|
||||
priority: undefined,
|
||||
source: undefined,
|
||||
enabled: undefined,
|
||||
minScore: null,
|
||||
})
|
||||
|
||||
// 搜索字段描述:顺序即表单字段顺序(前 FIELDS_PER_ROW 个为常驻行)
|
||||
const searchFields = [
|
||||
{ key: 'name', label: '名称 / ID', type: 'input', placeholder: '模糊搜索', submitIcon: true },
|
||||
{ key: 'type', label: '类型', type: 'select', options: TYPE_OPTIONS },
|
||||
{ key: 'status', label: '状态', type: 'select', options: STATUS_OPTIONS },
|
||||
{ key: 'range', label: '创建时间', type: 'range' },
|
||||
{ key: 'owner', label: '负责人', type: 'input' },
|
||||
{ key: 'priority', label: '优先级', type: 'select', options: PRIORITY_OPTIONS },
|
||||
{ key: 'source', label: '来源', type: 'select', options: SOURCE_OPTIONS },
|
||||
{ key: 'enabled', label: '启用', type: 'select', options: ENABLED_OPTIONS },
|
||||
{ key: 'minScore', label: '最低评分', type: 'input-number' },
|
||||
]
|
||||
|
||||
/* 每行固定 4 个字段格;字段更多时低频字段另起整行(展开/收起) */
|
||||
const FIELDS_PER_ROW = 4
|
||||
const searchExpanded = ref(false)
|
||||
const fieldsCollapsible = computed(() => searchFields.length > FIELDS_PER_ROW)
|
||||
const firstRowFields = computed(() => searchFields.slice(0, FIELDS_PER_ROW))
|
||||
const moreRowFields = computed(() => searchFields.slice(FIELDS_PER_ROW))
|
||||
const moreCount = computed(() => moreRowFields.value.length)
|
||||
|
||||
function toggleSearchExpanded() {
|
||||
searchExpanded.value = !searchExpanded.value
|
||||
if (!searchExpanded.value) {
|
||||
// 收起时清空被隐藏的低频字段,保证「看到的条件 = 生效的条件」
|
||||
for (const field of moreRowFields.value) searchForm.value[field.key] = null
|
||||
}
|
||||
}
|
||||
|
||||
const moreItems = [
|
||||
{ key: 'export', label: '导出数据', icon: Download },
|
||||
{ key: 'import', label: '导入数据', icon: Upload },
|
||||
@@ -120,35 +158,94 @@ const moreItems = [
|
||||
{ key: 'batch-delete', label: '批量删除', icon: Trash2, danger: true },
|
||||
]
|
||||
|
||||
// TODO: 接入真实搜索 / 高级搜索 / 更多操作 / 新增逻辑
|
||||
// TODO: 接入真实搜索 / 更多操作 / 新增逻辑
|
||||
function onMoreAction() {}
|
||||
function onAdvancedSearch() {}
|
||||
function onAdvancedReset() {
|
||||
advancedForm.value = { name: '', type: undefined, status: undefined, range: null }
|
||||
function onSearch() {}
|
||||
function onReset() {
|
||||
searchForm.value = {
|
||||
name: '',
|
||||
type: undefined,
|
||||
status: undefined,
|
||||
range: null,
|
||||
owner: undefined,
|
||||
priority: undefined,
|
||||
source: undefined,
|
||||
enabled: undefined,
|
||||
minScore: null,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<section class="demo-card">
|
||||
<!-- 工具栏:搜索 + 高级搜索 | 更多操作 + 新增 -->
|
||||
<div class="demo-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<!-- 搜索卡(独立白卡):每行固定 4 个字段格;字段更多时低频字段另起整行(展开/收起),
|
||||
操作区固定末行。「看到的条件 = 生效的条件」,收起时清空隐藏字段 -->
|
||||
<form class="search-card" @submit.prevent="onSearch">
|
||||
<div class="search-row">
|
||||
<div v-for="field in firstRowFields" :key="field.key" class="search-field">
|
||||
<label class="search-label">{{ field.label }}</label>
|
||||
<Input
|
||||
v-model="keyword"
|
||||
class="toolbar-search"
|
||||
placeholder="搜索名称 / ID"
|
||||
v-if="field.type === 'input' || field.type === 'input-number'"
|
||||
v-model="searchForm[field.key]"
|
||||
:input-type="field.type === 'input-number' ? 'number' : 'text'"
|
||||
:placeholder="field.placeholder"
|
||||
allow-clear
|
||||
>
|
||||
<template #prefix><Search :size="14" aria-hidden="true" /></template>
|
||||
<template v-if="field.submitIcon" #suffix>
|
||||
<button type="submit" class="search-submit" aria-label="查询">
|
||||
<Search :size="14" aria-hidden="true" />
|
||||
</button>
|
||||
</template>
|
||||
</Input>
|
||||
<Button
|
||||
type="outline"
|
||||
:class="{ 'toolbar-toggle-active': advancedOpen }"
|
||||
@click="advancedOpen = !advancedOpen"
|
||||
>
|
||||
<SlidersHorizontal :size="14" aria-hidden="true" />
|
||||
高级搜索
|
||||
<Select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="searchForm[field.key]"
|
||||
:options="field.options"
|
||||
clearable
|
||||
/>
|
||||
<RangePicker v-else-if="field.type === 'range'" v-model="searchForm[field.key]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 低频字段行:字段总数超过每行格数时按开关显隐 -->
|
||||
<div v-if="fieldsCollapsible && searchExpanded" class="search-row">
|
||||
<div v-for="field in moreRowFields" :key="field.key" class="search-field">
|
||||
<label class="search-label">{{ field.label }}</label>
|
||||
<Input
|
||||
v-if="field.type === 'input' || field.type === 'input-number'"
|
||||
v-model="searchForm[field.key]"
|
||||
:input-type="field.type === 'input-number' ? 'number' : 'text'"
|
||||
:placeholder="field.placeholder"
|
||||
allow-clear
|
||||
/>
|
||||
<Select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="searchForm[field.key]"
|
||||
:options="field.options"
|
||||
clearable
|
||||
/>
|
||||
<RangePicker v-else-if="field.type === 'range'" v-model="searchForm[field.key]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-actions">
|
||||
<Button type="primary" @click="onSearch">查询</Button>
|
||||
<Button type="secondary" @click="onReset">重置</Button>
|
||||
<Button v-if="fieldsCollapsible" type="text" @click="toggleSearchExpanded">
|
||||
{{ searchExpanded ? '收起' : `展开 ${moreCount} 项` }}
|
||||
<component :is="searchExpanded ? ChevronUp : ChevronDown" :size="14" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 列表卡:操作工具栏 + 表格 + 分页 -->
|
||||
<section class="demo-card">
|
||||
<!-- 操作工具栏:主操作靠左,批量/更多操作靠右 -->
|
||||
<div class="demo-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<Button type="primary">
|
||||
<Plus :size="14" aria-hidden="true" />
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -161,38 +258,6 @@ function onAdvancedReset() {
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<Button type="primary">
|
||||
<Plus :size="14" aria-hidden="true" />
|
||||
新增
|
||||
</Button>
|
||||
<Button type="primary">
|
||||
<Plus :size="14" aria-hidden="true" />
|
||||
Add New
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 高级搜索面板:label 在上、控件等宽网格(适配多语言标签长度),仅 UI 骨架 -->
|
||||
<div v-show="advancedOpen" class="demo-advanced">
|
||||
<div class="advanced-field">
|
||||
<label class="advanced-label">名称 / ID</label>
|
||||
<Input v-model="advancedForm.name" placeholder="模糊搜索" allow-clear />
|
||||
</div>
|
||||
<div class="advanced-field">
|
||||
<label class="advanced-label">类型</label>
|
||||
<Select v-model="advancedForm.type" :options="TYPE_OPTIONS" clearable />
|
||||
</div>
|
||||
<div class="advanced-field">
|
||||
<label class="advanced-label">状态</label>
|
||||
<Select v-model="advancedForm.status" :options="STATUS_OPTIONS" clearable />
|
||||
</div>
|
||||
<div class="advanced-field">
|
||||
<label class="advanced-label">创建时间</label>
|
||||
<RangePicker v-model="advancedForm.range" />
|
||||
</div>
|
||||
<div class="advanced-actions">
|
||||
<Button type="primary" @click="onAdvancedSearch">查询</Button>
|
||||
<Button type="secondary" @click="onAdvancedReset">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -220,7 +285,7 @@ function onAdvancedReset() {
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
/* ---- demo 卡片:工具栏 + 高级搜索 + 表格 + 分页(灰底画布上的白卡,靠阴影分层,无边框线) ---- */
|
||||
/* ---- 列表卡:操作工具栏 + 表格 + 分页(灰底画布上的白卡,靠阴影分层,无边框线) ---- */
|
||||
.demo-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -232,80 +297,102 @@ function onAdvancedReset() {
|
||||
box-shadow: var(--fms-shadow);
|
||||
}
|
||||
|
||||
/* ---- 工具栏:左侧搜索 + 高级搜索,右侧更多操作 + 新增 ---- */
|
||||
/* ---- 操作工具栏:主操作靠左,更多操作靠右(白底,靠 padding 与上下分区) ---- */
|
||||
.demo-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
/* 换行时整组掉下去,避免左右两组元素交错堆叠 */
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
/* 组内不换行:窄屏由外层整组换行兜底 */
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
/* 高级搜索按钮:展开面板时高亮 */
|
||||
.toolbar-toggle-active {
|
||||
color: var(--fms-primary);
|
||||
background: color-mix(in srgb, var(--fms-primary) 10%, var(--fms-card));
|
||||
}
|
||||
|
||||
/* ---- 高级搜索面板:label 在上、控件等宽网格。
|
||||
* 多语言下标签长度不一,label 置顶 + 控件铺满网格单元即可保证各列对齐;
|
||||
* 查询/重置独立一行、靠右。 ---- */
|
||||
.demo-advanced {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px 16px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
background: color-mix(in srgb, var(--fms-background) 30%, var(--fms-card));
|
||||
}
|
||||
|
||||
.advanced-field {
|
||||
/* ---- 搜索卡(独立白卡):行堆叠,每行固定 4 个等宽字段格。
|
||||
* 格内 label 行内居左、控件占满剩余宽度。
|
||||
* 回车提交:表单 submit + 首字段尾部的内嵌查询按钮。 ---- */
|
||||
.search-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--fms-card);
|
||||
box-shadow: var(--fms-shadow);
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
column-gap: 16px;
|
||||
row-gap: 10px;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
/* 控件自适应内容宽度,不撑满整行 */
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.advanced-label {
|
||||
.search-label {
|
||||
font-size: 12px;
|
||||
color: var(--fms-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* 多语言下 label 可能很长:不强制单行,允许换行,防溢出 */
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 控件统一铺满所在网格单元(Input / Select / RangePicker 根节点) */
|
||||
.advanced-field :deep(.input-wrapper),
|
||||
.advanced-field :deep(.select),
|
||||
.advanced-field :deep(.fms-range-picker) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
/* 控件按内容自适应宽度(Input / Select / RangePicker 根节点),带保底宽度 */
|
||||
.search-field :deep(.input-wrapper),
|
||||
.search-field :deep(.select),
|
||||
.search-field :deep(.fms-range-picker) {
|
||||
width: auto;
|
||||
min-width: 160px;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.advanced-actions {
|
||||
grid-column: 1 / -1;
|
||||
/* 内嵌在首字段尾部的小查询按钮(原生 button,避免触发表单内嵌套问题) */
|
||||
.search-submit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 2px;
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-submit:hover {
|
||||
color: var(--fms-primary);
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
/* 右对齐:按钮落在表单末行右下角,与字段的阅读终点衔接 */
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.demo-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
/* 表头文字对齐 12px 轴线:4px 容器 padding + 表格单元格自带 8px 水平 padding */
|
||||
padding: 0 4px;
|
||||
|
||||
/* 行级特殊样式:rowClassName 返回的 class 落在 stk-table 的 tr 上,
|
||||
这里通过 :deep() 跨过组件边界设置字体颜色与背景色 */
|
||||
@@ -334,7 +421,8 @@ function onAdvancedReset() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 底部分页条:总数靠左,分页靠右 ---- */
|
||||
/* ---- 底部分页条:白底,靠 padding 与表格分区(列表卡整体纯白);
|
||||
* 总数靠左,分页靠右 ---- */
|
||||
.demo-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 多语言类型管理(s_i18n_type)
|
||||
* 基于 fms-module-list(列表)+ fms-module-edit(弹窗),全部元数据驱动:
|
||||
* - 查询栏/表格/分页由 FmsModuleListPage 自动处理;
|
||||
* - 新增/编辑由 FmsModuleEditModal 按 data-code 读取编辑配置自动渲染。
|
||||
* 页面仅保留业务规则:
|
||||
* - 默认语言(b_default=1)不可删除、不可取消默认、编码不可修改;
|
||||
* - 任一行设为默认时,其他行的默认标记自动清除;
|
||||
* - 表格「启用/默认」列支持行内点击切换并自动保存。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { Button, Message } from '@/components/ui'
|
||||
import { FmsModuleListPage } from '@/components/fms-module-list'
|
||||
import { FmsModuleEditModal } from '@/components/fms-module-edit'
|
||||
import { loadDataApi, saveObjectApi } from '@/services/api'
|
||||
import { useLanguagesStore } from '@/stores/languages'
|
||||
|
||||
/* ================= 组件引用 / 状态 ================= */
|
||||
const listRef = ref(null)
|
||||
const saving = ref(false)
|
||||
/** 语言配置 store:页面增删改后 refresh,让菜单/模块等多语言消费端同步 */
|
||||
const langStore = useLanguagesStore()
|
||||
|
||||
/* ================= 行多选(默认语言不可勾选) ================= */
|
||||
const selectedRowKeys = ref([])
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
getCheckboxProps: (row) => ({ disabled: row.b_default === 1 }),
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys
|
||||
},
|
||||
}))
|
||||
|
||||
/* ================= 行操作:编辑 / 删除 ================= */
|
||||
const rowActions = [
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
disabled: () => saving.value,
|
||||
onClick: openEdit,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
type: 'delete',
|
||||
label: '删除',
|
||||
confirm: { title: '删除语言', content: '确定删除该语言吗?' },
|
||||
disabled: ({ record }) => saving.value || record.b_default === 1,
|
||||
onClick: removeRow,
|
||||
},
|
||||
]
|
||||
|
||||
/* ================= 列扩展:checkbox 字段直接交互切换 ================= */
|
||||
// columnExtensions 按字段声明单元格行为;checkbox 字段提供 onChange 后,
|
||||
// FmsModuleTable 渲染可交互勾选框(不再需要 cell-* 插槽)。
|
||||
const columnExtensions = {
|
||||
b_canuse: {
|
||||
onChange: (row, value) => saveToggle(row, 'b_canuse', value),
|
||||
disabled: () => saving.value,
|
||||
},
|
||||
b_default: {
|
||||
onChange: (row, value) => saveToggle(row, 'b_default', value),
|
||||
// 默认语言不可取消默认:先设其他语言为默认,再改此项
|
||||
disabled: (row) => saving.value || row.b_default === 1,
|
||||
},
|
||||
}
|
||||
|
||||
/* ================= 表格内联切换(启用/默认)自动保存 ================= */
|
||||
// 默认语言保护:设为默认时需清除其他默认行(查库而非依赖当前页数据,兼容分页)。
|
||||
async function saveToggle(row, field, value) {
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const updates = [{ b_id: row.b_id, [field]: value }]
|
||||
if (field === 'b_default' && value === 1) {
|
||||
const res = await loadDataApi('s_i18n_type', 'b_default = 1', 'b_id ASC')
|
||||
for (const other of res.data || []) {
|
||||
if (String(other.b_id) !== String(row.b_id)) {
|
||||
updates.push({ b_id: other.b_id, b_default: 0 })
|
||||
}
|
||||
}
|
||||
}
|
||||
await saveObjectApi([{ table: 's_i18n_type', key_field: 'b_id', updates }])
|
||||
Message.success('保存成功')
|
||||
langStore.refresh()
|
||||
await listRef.value?.reload()
|
||||
} catch (e) {
|
||||
Message.error('保存失败:' + (e?.message || e))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 新增 / 编辑弹窗(FmsModuleEditModal 元数据驱动) ================= */
|
||||
const modalOpen = ref(false)
|
||||
const editingRow = ref(null) // null = 新增
|
||||
|
||||
function openCreate() {
|
||||
editingRow.value = null
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editingRow.value = row
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
/** 保存前钩子:设为默认语言时清除其他默认行(追加 updates,与 insert/update 同事务) */
|
||||
async function beforeModalSave(rowData, ctx) {
|
||||
if (Number(rowData.b_default) !== 1) return true
|
||||
const res = await loadDataApi('s_i18n_type', 'b_default = 1', 'b_id ASC')
|
||||
const extraUpdates = []
|
||||
for (const other of res.data || []) {
|
||||
if (String(other.b_id) !== String(rowData.b_id)) {
|
||||
extraUpdates.push({ b_id: other.b_id, b_default: 0 })
|
||||
}
|
||||
}
|
||||
return { extraUpdates }
|
||||
}
|
||||
|
||||
/** 额外禁用规则:默认语言(编辑态)的编码与「默认」开关不可改 */
|
||||
function modalFieldDisabled(fieldName, { isNew, record }) {
|
||||
if (isNew) return false
|
||||
if (Number(record?.b_default) !== 1) return false
|
||||
return fieldName === 'b_code' || fieldName === 'b_default'
|
||||
}
|
||||
|
||||
function onModalSaved() {
|
||||
selectedRowKeys.value = []
|
||||
langStore.refresh()
|
||||
void listRef.value?.reload()
|
||||
}
|
||||
|
||||
/* ================= 删除单行 ================= */
|
||||
async function removeRow(row) {
|
||||
if (saving.value || row.b_default === 1) return
|
||||
saving.value = true
|
||||
try {
|
||||
await saveObjectApi([
|
||||
{ table: 's_i18n_type', key_field: 'b_id', deletes: [{ b_id: row.b_id }] },
|
||||
])
|
||||
Message.success('删除成功')
|
||||
langStore.refresh()
|
||||
await listRef.value?.reload()
|
||||
} catch (e) {
|
||||
Message.error('删除失败:' + (e?.message || e))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 批量删除(走组件方法) ================= */
|
||||
async function batchDelete() {
|
||||
const keys = selectedRowKeys.value
|
||||
if (!keys.length) return
|
||||
try {
|
||||
await listRef.value?.deleteSelected(keys)
|
||||
selectedRowKeys.value = []
|
||||
langStore.refresh()
|
||||
} catch (e) {
|
||||
Message.error('删除失败:' + (e?.message || e))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="i18n-type-page">
|
||||
<FmsModuleListPage
|
||||
ref="listRef"
|
||||
data-code="s_i18n_type"
|
||||
:row-selection="rowSelection"
|
||||
:row-actions="rowActions"
|
||||
:column-extensions="columnExtensions"
|
||||
:inline-action-limit="2"
|
||||
:row-draggable="true"
|
||||
order-field="b_xh"
|
||||
>
|
||||
<template #actions="{ loading }">
|
||||
<Button type="primary" :disabled="loading || saving" @click="openCreate">
|
||||
<Plus :size="14" /> 新增
|
||||
</Button>
|
||||
<Button
|
||||
type="danger"
|
||||
:disabled="loading || saving || !selectedRowKeys.length"
|
||||
@click="batchDelete"
|
||||
>
|
||||
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
|
||||
</Button>
|
||||
</template>
|
||||
</FmsModuleListPage>
|
||||
|
||||
<!-- 通用元数据驱动新增/编辑弹窗 -->
|
||||
<!-- order-field:新增且序号未填时,弹窗自动取库中 MAX(b_xh)+10(页面如需覆盖可在 before-save 中改写) -->
|
||||
<FmsModuleEditModal
|
||||
v-model:open="modalOpen"
|
||||
data-code="s_i18n_type"
|
||||
:record="editingRow"
|
||||
:before-save="beforeModalSave"
|
||||
:field-disabled="modalFieldDisabled"
|
||||
order-field="b_xh"
|
||||
@saved="onModalSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.i18n-type-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
+56
-57
@@ -49,14 +49,38 @@ import { cloneData } from '@/utils/dataChanges'
|
||||
import { createTempId } from '@/utils/tempId'
|
||||
import UnsavedChangesModal from '@/components/UnsavedChangesModal.vue'
|
||||
import I18nNameModal from '@/components/I18nNameModal.vue'
|
||||
import { useLanguagesStore } from '@/stores/languages'
|
||||
|
||||
/* ================= 多语言配置 ================= */
|
||||
// 菜单多语言:默认语言(简体中文)直接存 b_name;其他语言存 s_i18n(b_key=menu.编码)
|
||||
const I18N_LANGUAGES = [
|
||||
{ code: 'zh-CN', name: '简体中文', default: true },
|
||||
{ code: 'en-US', name: 'English', default: false },
|
||||
]
|
||||
const nonDefaultLanguages = I18N_LANGUAGES.filter((l) => !l.default)
|
||||
/* ================= 多语言配置(s_i18n_type 驱动) ================= */
|
||||
// 菜单多语言:默认语言(b_default=1)名称直接存 b_name;其余语言存 s_i18n(b_key=menu.编码)。
|
||||
// 语言清单来自数据库(stores/languages),不再写死 zh-CN/en-US。
|
||||
const langStore = useLanguagesStore()
|
||||
|
||||
/** 默认语言 code(b_default=1;数据异常时回退第一个启用语言,均为数据库配置) */
|
||||
const defaultLocale = computed(() => {
|
||||
const def = langStore.defaultLanguage?.b_code
|
||||
if (def) return def
|
||||
return langStore.languages[0]?.b_code || ''
|
||||
})
|
||||
|
||||
/** 给 I18nNameModal 的语言列表({ code, name, default }) */
|
||||
const I18N_LANGUAGES = computed(() =>
|
||||
langStore.languages.map((l) => ({
|
||||
code: l.b_code,
|
||||
name: l.b_name,
|
||||
default: Number(l.b_default) === 1,
|
||||
})),
|
||||
)
|
||||
|
||||
/** 非默认语言(走 s_i18n 表,{ code, name }) */
|
||||
const nonDefaultLanguages = computed(() =>
|
||||
langStore.nonDefaultLanguages.map((l) => ({ code: l.b_code, name: l.b_name })),
|
||||
)
|
||||
|
||||
/** 默认语言名称(hint 文案用,动态取自语言配置) */
|
||||
const defaultLocaleName = computed(
|
||||
() => langStore.defaultLanguage?.b_name || defaultLocale.value || '',
|
||||
)
|
||||
|
||||
/* ================= 类型映射 ================= */
|
||||
const menuTypeOptions = [
|
||||
@@ -65,12 +89,6 @@ const menuTypeOptions = [
|
||||
{ label: '外链', value: 'external' },
|
||||
]
|
||||
|
||||
const menuTypeLabel = {
|
||||
directory: '目录',
|
||||
page: '页面',
|
||||
external: '外链',
|
||||
}
|
||||
|
||||
const menuTypeIconMap = {
|
||||
directory: Folder,
|
||||
page: FileText,
|
||||
@@ -106,11 +124,6 @@ const selectedMenu = computed(() =>
|
||||
|
||||
const busy = computed(() => configLoading.value || configSaving.value)
|
||||
|
||||
const parentMenuName = computed(() => {
|
||||
if (!selectedMenu.value?.b_parent_id) return ''
|
||||
return findTreeNode(menuTreeData.value, selectedMenu.value.b_parent_id)?.b_name || ''
|
||||
})
|
||||
|
||||
const menuType = computed(() => selectedMenu.value?.b_menu_type || null)
|
||||
const isDirectory = computed(() => menuType.value === 'directory')
|
||||
const isPage = computed(() => menuType.value === 'page')
|
||||
@@ -196,7 +209,7 @@ const toolbarAddOptions = [
|
||||
{ key: 'external', label: '新增外链', icon: ExternalLink },
|
||||
]
|
||||
|
||||
function onToolbarAdd(key) {
|
||||
function onToolbarAdd({ key }) {
|
||||
const parentId = selectedMenu.value ? String(selectedMenu.value.b_id) : null
|
||||
void addMenu(parentId, key)
|
||||
}
|
||||
@@ -348,8 +361,8 @@ async function addMenu(parentId, menuTypeValue = 'page') {
|
||||
await nextTick()
|
||||
drafts.value = cloneData({ menu: newMenu })
|
||||
originals.value = cloneData(drafts.value)
|
||||
i18nValues.value = { 'zh-CN': '' }
|
||||
originalI18nValues.value = { 'zh-CN': '' }
|
||||
i18nValues.value = { [defaultLocale.value]: '' }
|
||||
originalI18nValues.value = { [defaultLocale.value]: '' }
|
||||
powers.value = []
|
||||
powersBaseline.value = '[]'
|
||||
loadedMenuId.value = newId
|
||||
@@ -416,11 +429,12 @@ async function loadMenuConfig(menuId) {
|
||||
originals.value = { menu: menu ? { ...menu } : null }
|
||||
drafts.value = cloneData(originals.value)
|
||||
// 多语言:默认语言复用 b_name,其余读 s_i18n(按 b_module_id 关联)
|
||||
const i18nValuesInit = { 'zh-CN': menu?.b_name || '' }
|
||||
const i18nValuesInit = { [defaultLocale.value]: menu?.b_name || '' }
|
||||
if (menu) {
|
||||
const i18nRes = await loadDataApi('s_i18n', `b_module_id = ${id}`, 'b_key ASC, b_locale ASC')
|
||||
for (const row of i18nRes.data || []) {
|
||||
if (row.b_locale && row.b_locale !== 'zh-CN') i18nValuesInit[row.b_locale] = row.b_value
|
||||
if (row.b_locale && row.b_locale !== defaultLocale.value)
|
||||
i18nValuesInit[row.b_locale] = row.b_value
|
||||
}
|
||||
}
|
||||
i18nValues.value = i18nValuesInit
|
||||
@@ -485,7 +499,7 @@ watch(menuSelectedId, (id) => {
|
||||
}
|
||||
drafts.value = cloneData({ menu: menuRowOf(node) })
|
||||
originals.value = cloneData(drafts.value)
|
||||
i18nValues.value = { 'zh-CN': node.b_name || '' }
|
||||
i18nValues.value = { [defaultLocale.value]: node.b_name || '' }
|
||||
originalI18nValues.value = { ...i18nValues.value }
|
||||
powers.value = []
|
||||
powersBaseline.value = '[]'
|
||||
@@ -506,7 +520,9 @@ function hasUnsavedChanges() {
|
||||
if (JSON.stringify(drafts.value.menu) !== JSON.stringify(originals.value.menu)) return true
|
||||
// 多语言非默认语言变化视为未保存
|
||||
if (
|
||||
nonDefaultLanguages.some((l) => i18nValues.value[l.code] !== originalI18nValues.value[l.code])
|
||||
nonDefaultLanguages.value.some(
|
||||
(l) => i18nValues.value[l.code] !== originalI18nValues.value[l.code],
|
||||
)
|
||||
)
|
||||
return true
|
||||
// 操作/按钮权限点变化视为未保存
|
||||
@@ -607,10 +623,7 @@ async function saveMenu() {
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (typeRule.module && !menuData.b_module_id) {
|
||||
Message.warning('页面菜单必须绑定模块')
|
||||
return false
|
||||
}
|
||||
// 绑定模块非必填:页面菜单可不绑定业务模块,留空即保存为空
|
||||
if (!typeRule.route && menuData.b_route) menuData.b_route = null
|
||||
if (!typeRule.module && menuData.b_module_id) menuData.b_module_id = null
|
||||
|
||||
@@ -639,7 +652,7 @@ async function saveMenu() {
|
||||
|
||||
// 多语言:先删后插(同表请求内按 del→upd→ins 顺序执行)
|
||||
const i18nRows = []
|
||||
for (const lang of nonDefaultLanguages) {
|
||||
for (const lang of nonDefaultLanguages.value) {
|
||||
const value = (i18nValues.value[lang.code] || '').trim()
|
||||
if (!value) continue
|
||||
i18nRows.push({
|
||||
@@ -825,7 +838,7 @@ async function saveMenu() {
|
||||
/* ================= 表单字段更新 ================= */
|
||||
function setField(field, value) {
|
||||
if (!drafts.value?.menu) return
|
||||
drafts.value.menu[field] = value === '' ? null : value
|
||||
drafts.value.menu[field] = value === '' || value == null ? null : value
|
||||
}
|
||||
|
||||
/** 设置非默认语言的多语言名称 */
|
||||
@@ -836,15 +849,16 @@ function setI18nValue(locale, value) {
|
||||
/** 多语言弹窗值对象(默认语言=菜单名称,其余=各语言) */
|
||||
const i18nModalModel = computed(() => {
|
||||
const menu = drafts.value?.menu
|
||||
const values = { 'zh-CN': menu?.b_name || '' }
|
||||
for (const lang of nonDefaultLanguages) values[lang.code] = i18nValues.value[lang.code] || ''
|
||||
const values = { [defaultLocale.value]: menu?.b_name || '' }
|
||||
for (const lang of nonDefaultLanguages.value)
|
||||
values[lang.code] = i18nValues.value[lang.code] || ''
|
||||
return values
|
||||
})
|
||||
|
||||
/** 多语言弹窗确定时写回草稿与 i18nValues */
|
||||
function onI18nModalChange(values) {
|
||||
if (drafts.value?.menu) setField('b_name', values['zh-CN'] ?? '')
|
||||
for (const lang of nonDefaultLanguages) setI18nValue(lang.code, values[lang.code] || '')
|
||||
if (drafts.value?.menu) setField('b_name', values[defaultLocale.value] ?? '')
|
||||
for (const lang of nonDefaultLanguages.value) setI18nValue(lang.code, values[lang.code] || '')
|
||||
}
|
||||
|
||||
function onMenuTypeChange(v) {
|
||||
@@ -1162,9 +1176,6 @@ loadPowerLibrary()
|
||||
>
|
||||
{{ node.b_name || node.b_code || '(未命名)' }}
|
||||
</span>
|
||||
<span class="mm-tree-node__tag" v-if="node.b_menu_type !== 'directory'">
|
||||
{{ menuTypeLabel[node.b_menu_type] || '' }}
|
||||
</span>
|
||||
<Checkbox
|
||||
class="mm-tree-node__enable"
|
||||
:model-value="Number(node.b_canuse) === 1"
|
||||
@@ -1219,9 +1230,9 @@ loadPowerLibrary()
|
||||
isPage || isExternal
|
||||
? [{ required: true, message: '请填写路由地址' }]
|
||||
: [],
|
||||
b_module_id: isPage
|
||||
? [{ required: true, message: '请选择绑定模块' }]
|
||||
: [],
|
||||
// 绑定模块非必填:页面菜单可不绑定业务模块(仅用于路由 + 数据来源),
|
||||
// 留空时保存为空,后续需要再补选。
|
||||
b_module_id: [],
|
||||
}"
|
||||
layout="vertical"
|
||||
:gap="20"
|
||||
@@ -1257,9 +1268,6 @@ loadPowerLibrary()
|
||||
@update:model-value="onMenuTypeChange"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="上级菜单" name="b_parent_id">
|
||||
<Input :model-value="parentMenuName || '根节点(顶级菜单)'" readonly />
|
||||
</FormItem>
|
||||
<!-- page/external: 路由地址 -->
|
||||
<FormItem v-if="isPage" label="路由地址" name="b_route" required>
|
||||
<Input
|
||||
@@ -1273,11 +1281,12 @@ loadPowerLibrary()
|
||||
@update:model-value="(v) => setField('b_route', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<!-- page: 绑定模块 -->
|
||||
<FormItem v-if="isPage" label="绑定模块" name="b_module_id" required>
|
||||
<!-- page: 绑定模块(非必填,可不绑定业务模块) -->
|
||||
<FormItem v-if="isPage" label="绑定模块" name="b_module_id">
|
||||
<Select
|
||||
:model-value="drafts.menu.b_module_id"
|
||||
:options="moduleOptions"
|
||||
clearable
|
||||
@update:model-value="(v) => setField('b_module_id', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
@@ -1426,7 +1435,7 @@ loadPowerLibrary()
|
||||
:open="i18nModalVisible"
|
||||
:languages="I18N_LANGUAGES"
|
||||
:model-value="i18nModalModel"
|
||||
hint="简体中文即「菜单名称」;其余语言保存到多语言表(s_i18n,键 menu.编码)。"
|
||||
:hint="`${defaultLocaleName}即「菜单名称」;其余语言保存到多语言表(s_i18n,键 menu.编码)。`"
|
||||
@update:model-value="onI18nModalChange"
|
||||
@update:open="(v) => (i18nModalVisible = v)"
|
||||
/>
|
||||
@@ -1579,16 +1588,6 @@ loadPowerLibrary()
|
||||
}
|
||||
}
|
||||
|
||||
.mm-tree-node__tag {
|
||||
flex-shrink: 0;
|
||||
padding: 0 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--fms-secondary);
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* 树节点启用勾选框:靠右、不随文字缩放 */
|
||||
.mm-tree-node__enable {
|
||||
flex-shrink: 0;
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+14
@@ -66,6 +66,7 @@ import ModuleEditPanel from './ModuleEditPanel.vue'
|
||||
import ModuleQueryConfigPanel from './ModuleQueryConfigPanel.vue'
|
||||
import ModuleAutoCodePanel from './ModuleAutoCodePanel.vue'
|
||||
import ModuleI18nPanel from './ModuleI18nPanel.vue'
|
||||
import { useLanguagesStore } from '@/stores/languages'
|
||||
|
||||
/* ================= 共享状态(index.vue 组装后传入各 composable) ================= */
|
||||
const moduleTreeData = ref([])
|
||||
@@ -90,6 +91,18 @@ const rawI18nRows = ref([])
|
||||
// 新建模块时跳过 watch(moduleSelectedId) 的自动加载;非响应式 holder,跨 composable 共享
|
||||
const skipModuleLoad = { value: false }
|
||||
|
||||
/* ================= 多语言配置(s_i18n_type 驱动) ================= */
|
||||
// ModuleI18nPanel 的语言列表:由 languages store 提供,不再写死 zh-CN/en-US
|
||||
const langStore = useLanguagesStore()
|
||||
/** 语言列表({ code, name, default },供 ModuleI18nPanel) */
|
||||
const i18nLangOptions = computed(() =>
|
||||
langStore.languages.map((l) => ({
|
||||
code: l.b_code,
|
||||
name: l.b_name,
|
||||
default: Number(l.b_default) === 1,
|
||||
})),
|
||||
)
|
||||
|
||||
/* ================= 派生状态(index.vue 组装) ================= */
|
||||
const selectedModule = computed(() =>
|
||||
moduleSelectedId.value ? findTreeNode(moduleTreeData.value, moduleSelectedId.value) : null,
|
||||
@@ -1186,6 +1199,7 @@ loadModuleTree()
|
||||
:fields="drafts?.fields"
|
||||
:groups="drafts?.groups"
|
||||
:raw-rows="rawI18nRows"
|
||||
:languages="i18nLangOptions"
|
||||
:loading="busy"
|
||||
/>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import ModuleTableEditPanel from '@/views/system/module-management/TableEditPanel.vue'
|
||||
import ModuleTableEditPanel from '@/views/module/module-management/TableEditPanel.vue'
|
||||
|
||||
const fields = [
|
||||
{ b_id: '1', b_name: '姓名', b_field: 'name', b_type: 'input' },
|
||||
|
||||
@@ -3,8 +3,8 @@ import { describe, expect, it, vi, beforeAll, beforeEach } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { registerFeature, useAreaSelection } from 'stk-table-vue'
|
||||
import ModuleQueryConfigPanel from '@/views/system/module-management/ModuleQueryConfigPanel.vue'
|
||||
import ModuleI18nPanel from '@/views/system/module-management/ModuleI18nPanel.vue'
|
||||
import ModuleQueryConfigPanel from '@/views/module/module-management/ModuleQueryConfigPanel.vue'
|
||||
import ModuleI18nPanel from '@/views/module/module-management/ModuleI18nPanel.vue'
|
||||
|
||||
registerFeature(useAreaSelection)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
toggleForm,
|
||||
toggleList,
|
||||
toggleQuery,
|
||||
} from '@/views/system/module-management/composables/fieldParticipation'
|
||||
} from '@/views/module/module-management/composables/fieldParticipation'
|
||||
|
||||
const MODULE_ID = 'm1'
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import ModuleFormEditPanel from '@/views/system/module-management/components/form-edit/FormEditPanel.vue'
|
||||
import ModuleFormEditPanel from '@/views/module/module-management/components/form-edit/FormEditPanel.vue'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(async () => ({ data: [] })),
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { useModuleConfig } from '@/views/system/module-management/composables/useModuleConfig'
|
||||
import { useModuleConfig } from '@/views/module/module-management/composables/useModuleConfig'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(),
|
||||
|
||||
@@ -9,7 +9,7 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { registerFeature, useAreaSelection } from 'stk-table-vue'
|
||||
import ModuleFieldsPanel from '@/views/system/module-management/components/ModuleFieldsPanel.vue'
|
||||
import ModuleFieldsPanel from '@/views/module/module-management/components/ModuleFieldsPanel.vue'
|
||||
|
||||
// 与 main.js 一致:注册 stk-table 的按需 feature
|
||||
registerFeature(useAreaSelection)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import FmsQueryControl from '@/components/fms-module-list/FmsQueryControl.vue'
|
||||
import FmsQueryToolbar from '@/components/fms-module-list/FmsQueryToolbar.vue'
|
||||
|
||||
describe('fms-module-list components', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
it('mounts a text query control with the new UI kit', () => {
|
||||
const wrapper = mount(FmsQueryControl, {
|
||||
props: {
|
||||
item: {
|
||||
field: { b_field: 'name', b_type: 'input' },
|
||||
config: { b_operator: 'like', b_component: 'input' },
|
||||
},
|
||||
modelValue: '',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('input').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('mounts an empty toolbar without legacy Ant components', () => {
|
||||
const wrapper = mount(FmsQueryToolbar)
|
||||
expect(wrapper.find('.fms-query-toolbar').exists()).toBe(true)
|
||||
expect(wrapper.html()).not.toContain('<a-')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import ModuleListConfigPanel from '@/views/system/module-management/components/ModuleListConfigPanel.vue'
|
||||
import ModuleListConfigPanel from '@/views/module/module-management/components/ModuleListConfigPanel.vue'
|
||||
|
||||
/**
|
||||
* 列表配置面板与子表设计共用 useStructureEditing / ModuleStructureTree / TablePreview,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildQueryCondition,
|
||||
defaultQueryValue,
|
||||
formatDateValue,
|
||||
parseOptions,
|
||||
} from '@/components/fms-module-list/moduleListUtils'
|
||||
import { setMessages, translate } from '@/utils/translate'
|
||||
|
||||
function item(over = {}) {
|
||||
return {
|
||||
field: { b_field: 'amount', b_type: 'money', b_options: '' },
|
||||
config: { b_operator: 'eq', b_component: 'number' },
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('fms-module-list query utilities', () => {
|
||||
it('uses a standalone translation catalog and fallback text', () => {
|
||||
setMessages({ 'common.search': '查询' }, 'zh-CN')
|
||||
expect(translate('common.search', '搜索')).toBe('查询')
|
||||
expect(translate('common.reset', '重置')).toBe('重置')
|
||||
setMessages({})
|
||||
})
|
||||
|
||||
it('builds numeric and escaped text conditions', () => {
|
||||
expect(buildQueryCondition(item(), 12.5)).toBe('[amount] = 12.5')
|
||||
expect(
|
||||
buildQueryCondition(
|
||||
item({ field: { b_field: 'name', b_type: 'input' }, config: { b_operator: 'like' } }),
|
||||
"O'Reilly",
|
||||
),
|
||||
).toBe("[name] LIKE N'%O''Reilly%'")
|
||||
})
|
||||
|
||||
it('builds range and IN conditions while ignoring empty values', () => {
|
||||
expect(
|
||||
buildQueryCondition(
|
||||
item({ field: { b_field: 'created', b_type: 'date' }, config: { b_operator: 'between' } }),
|
||||
['2026-01-01', '2026-01-31'],
|
||||
),
|
||||
).toBe("[created] BETWEEN N'2026-01-01' AND N'2026-01-31'")
|
||||
expect(
|
||||
buildQueryCondition(
|
||||
item({ field: { b_field: 'status', b_type: 'input' }, config: { b_operator: 'in' } }),
|
||||
['a', '', 'b'],
|
||||
),
|
||||
).toBe("[status] IN (N'a', N'b')")
|
||||
})
|
||||
|
||||
it('parses options and normalizes defaults', () => {
|
||||
const parsed = parseOptions({ b_options: '{"options":[{"key":"1","name":"启用"},"2"]}' })
|
||||
expect(parsed.options).toEqual([
|
||||
{ key: '1', name: '启用', value: '1', label: '启用' },
|
||||
{ value: '2', label: '2' },
|
||||
])
|
||||
expect(
|
||||
defaultQueryValue(
|
||||
item({
|
||||
field: { b_field: 'status', b_type: 'input' },
|
||||
config: { b_operator: 'in', b_default_value: 'a,b' },
|
||||
}),
|
||||
),
|
||||
).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('formats ISO dates consistently', () => {
|
||||
expect(formatDateValue('2026-08-01T12:34:56Z')).toMatch(/^2026-08-01/)
|
||||
expect(formatDateValue(null)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { useModuleSave } from '@/views/system/module-management/composables/useModuleSave'
|
||||
import { useModuleSave } from '@/views/module/module-management/composables/useModuleSave'
|
||||
import { saveObjectApi, nextIdApi } from '@/services/api'
|
||||
import { Message } from '@/components/ui'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import ModuleTableEditPanel from '@/views/system/module-management/components/table-edit/TableEditPanel.vue'
|
||||
import ModuleTableEditPanel from '@/views/module/module-management/components/table-edit/TableEditPanel.vue'
|
||||
|
||||
/**
|
||||
* 专用结构树组件 ModuleStructureTree 的状态驱动拖拽,在结构树内完成全部落点
|
||||
|
||||
@@ -35,10 +35,10 @@ function makeStub(label) {
|
||||
}
|
||||
|
||||
vi.mock('@/views/dashboard/index.vue', () => ({ default: makeStub('Dashboard') }))
|
||||
vi.mock('@/views/system/module-management/index.vue', () => ({
|
||||
vi.mock('@/views/module/module-management/index.vue', () => ({
|
||||
default: makeStub('ModuleManagement'),
|
||||
}))
|
||||
vi.mock('@/views/system/menu-management/index.vue', () => ({
|
||||
vi.mock('@/views/module/menu-management/index.vue', () => ({
|
||||
default: makeStub('MenuManagement'),
|
||||
}))
|
||||
vi.mock('@/views/detail/DetailPage.vue', () => ({
|
||||
@@ -90,7 +90,7 @@ describe('页面 keep-alive 缓存', () => {
|
||||
expect(counters.Dashboard.mounts).toBe(1)
|
||||
|
||||
// 进入模块管理,点两下按钮制造页面状态
|
||||
await router.push('/system/module-management')
|
||||
await router.push('/module/module-management')
|
||||
await flushPromises()
|
||||
expect(counters.ModuleManagement.mounts).toBe(1)
|
||||
const inc = () => wrapper.find('[data-page="ModuleManagement"] [data-inc]').trigger('click')
|
||||
@@ -99,12 +99,12 @@ describe('页面 keep-alive 缓存', () => {
|
||||
expect(wrapper.find('[data-page="ModuleManagement"]').text()).toContain('#2')
|
||||
|
||||
// 切到菜单管理再切回:不重新挂载,clicks 仍为 2
|
||||
await router.push('/system/menu-management')
|
||||
await router.push('/module/menu-management')
|
||||
await flushPromises()
|
||||
expect(counters.ModuleManagement.unmounted).toBe(0)
|
||||
expect(counters.ModuleManagement.deactivated).toBe(1)
|
||||
|
||||
await router.push('/system/module-management')
|
||||
await router.push('/module/module-management')
|
||||
await flushPromises()
|
||||
expect(counters.ModuleManagement.mounts).toBe(1)
|
||||
expect(counters.ModuleManagement.unmounted).toBe(0)
|
||||
@@ -113,19 +113,19 @@ describe('页面 keep-alive 缓存', () => {
|
||||
// 再绕经工作台切回:缓存依旧(包装组件不得依赖当前路由)
|
||||
await router.push('/dashboard')
|
||||
await flushPromises()
|
||||
await router.push('/system/menu-management')
|
||||
await router.push('/module/menu-management')
|
||||
await flushPromises()
|
||||
await router.push('/system/module-management')
|
||||
await router.push('/module/module-management')
|
||||
await flushPromises()
|
||||
expect(counters.ModuleManagement.mounts).toBe(1)
|
||||
expect(wrapper.find('[data-page="ModuleManagement"]').text()).toContain('#2')
|
||||
|
||||
// 关闭页签后缓存销毁:重新进入应重新挂载
|
||||
const appStore = useAppStore()
|
||||
appStore.removeVisitedTab('/system/module-management')
|
||||
appStore.removeVisitedTab('/module/module-management')
|
||||
await router.push('/dashboard')
|
||||
await flushPromises()
|
||||
await router.push('/system/module-management')
|
||||
await router.push('/module/module-management')
|
||||
await flushPromises()
|
||||
expect(counters.ModuleManagement.mounts).toBe(2)
|
||||
expect(wrapper.find('[data-page="ModuleManagement"]').text()).toContain('#0')
|
||||
@@ -160,13 +160,13 @@ describe('页面 keep-alive 缓存', () => {
|
||||
it('双击页签刷新:页面组件重新挂载、状态归零(缓存槽位不变)', async () => {
|
||||
const wrapper = await mountApp()
|
||||
|
||||
await router.push('/system/module-management')
|
||||
await router.push('/module/module-management')
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-page="ModuleManagement"] [data-inc]').trigger('click')
|
||||
expect(wrapper.find('[data-page="ModuleManagement"]').text()).toContain('#1')
|
||||
|
||||
const appStore = useAppStore()
|
||||
appStore.refreshRoute('/system/module-management')
|
||||
appStore.refreshRoute('/module/module-management')
|
||||
await flushPromises()
|
||||
|
||||
expect(counters.ModuleManagement.mounts).toBe(2)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import MenuManagement from '@/views/system/menu-management/index.vue'
|
||||
import MenuManagement from '@/views/module/menu-management/index.vue'
|
||||
import { mountPage } from '../helpers/app.js'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 开发规范(AI 使用)
|
||||
|
||||
## 通用原则
|
||||
|
||||
1. **不搞 placeholder** — 代码中不得出现未实现的占位符、临时桩代码或待填充的 TODO 占位。
|
||||
|
||||
2. **后端接口优先通用** — 优先使用或设计通用接口,尽量不写特殊接口(为单一业务场景定制的接口)。如需编写特殊接口,必须提前询问用户并获得确认。
|
||||
|
||||
## 前端代码风格
|
||||
|
||||
1. **少用语法糖** — 优先使用直白、易读的写法,避免为了炫技而使用语法糖,可读性优先。
|
||||
|
||||
2. **避免无意义的封装** — 没有必要的抽取就不抽取,不为一层简单调用再包一层方法。例如 `emit('xxx')` 这类直接调用即可,无需再封装成自定义方法。
|
||||
|
||||
### 查询列表页面设计
|
||||
|
||||
(待补充)
|
||||
|
||||
### 详细表单页面设计
|
||||
|
||||
保存流程方法的命名与顺序参考旧系统风格(仅约定骨架,实现自行设计):
|
||||
|
||||
1. `handleSave()` — 保存入口
|
||||
2. `beforeSave()` — 保存前校验与数据准备
|
||||
3. `buildSaveData()` — 构建保存请求数据
|
||||
4. `afterSave()` — 保存成功后的处理
|
||||
|
||||
## 多语言(i18n)约定
|
||||
|
||||
1. **module 目录下的页面不做多语言翻译** — 模块管理、菜单管理、多语言类型等内部管理页面仅面向 IT 人员,界面文案固定使用中文,不接入 s_i18n 翻译体系、不提供多语言编辑入口。
|
||||
2. 面向客户使用的业务页面才需要做多语言翻译(界面文案、s_i18n 键管理、多语言编辑入口)。
|
||||
|
||||
Reference in new issue
Block a user