20260903173038

This commit is contained in:
oneao committed 2026-09-03 17:30:39 +08:00
1 parent c8940f6e03
commit 0faf44cffc
55 files changed
+3278 -208

No files matched your search

@@ -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' },
],
})
+19 -6
View File
@@ -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'
+9
View File
@@ -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),
+94
View File
@@ -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,
}
})
+3 -3
View File
@@ -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);
+37
View File
@@ -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),
}
}
+196 -108
View File
@@ -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>
@@ -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;
@@ -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 -1
View File
@@ -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', () => ({
+32
View File
@@ -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 键管理、多语言编辑入口)。