20260821230656

This commit is contained in:
oneao committed 2026-08-21 23:06:56 +08:00
1 parent 2e5d532f23
commit c43b4c8364
144 files changed
+7665 -242

No files matched your search

@@ -0,0 +1,6 @@
// 前端默认语言列表(默认语言排第一)。
// 后续若引入语言类型表(s_i18n_type)再改为接口加载。
export const DEFAULT_LANGUAGES = [
{ code: 'zh-CN', name: '简体中文', default: true },
{ code: 'en-US', name: 'English', default: false },
]
@@ -0,0 +1,245 @@
<script setup>
/**
* 字段分组管理弹窗:管理 s_field_group 树形分组。
* 独立保存到后端,保存后 emit("saved", groups, idMapping),父级用 idMapping
* 重映射列表/编辑配置中的 b_group_id(临时 ID → 雪花 ID)。
*/
import { computed, ref, watch } from 'vue'
import Message from '@/components/ui/message/message-manager'
import Modal from '@/components/ui/modal/modal.vue'
import Button from '@/components/ui/button/button.vue'
import Input from '@/components/ui/input/input.vue'
import Switch from '@/components/ui/switch/switch.vue'
import Tree from '@/components/ui/tree/tree.vue'
import { Plus, Trash2, FolderPlus } from '@lucide/vue'
import { buildTree } from '@/utils/tree'
import { createTempId } from '@/utils/tempId'
import { cloneData, diffRows, allocateTemporaryIds, tableChange, hasChanges } from '@/utils/dataChanges'
import { saveObjectApi } from '@/services/api'
const props = defineProps({
open: { type: Boolean, default: false },
moduleId: { type: String, required: true },
groups: { type: Array, default: () => [] },
referencedGroupIds: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:open', 'saved'])
const localGroups = ref([])
const selectedGroupId = ref(null)
const saving = ref(false)
watch(
() => props.groups,
(val) => {
localGroups.value = cloneData(val || [])
},
{ immediate: true },
)
const treeData = computed(() => buildTree(localGroups.value))
const selectedGroup = computed(() =>
localGroups.value.find((g) => String(g.b_id) === String(selectedGroupId.value)),
)
function onTreeSelect(keys) {
selectedGroupId.value = keys.length ? String(keys[0]) : null
}
function addGroup(parentId) {
const newGroup = {
b_id: createTempId(),
b_module_id: props.moduleId,
b_parent_id: parentId || null,
b_title: '',
b_xh: (localGroups.value.length + 1) * 10,
b_canuse: 1,
}
localGroups.value.push(newGroup)
selectedGroupId.value = String(newGroup.b_id)
}
function deleteGroupById(id) {
if (!id) return
const hasChildren = localGroups.value.some(
(g) => String(g.b_parent_id) === String(id) && String(g.b_id) !== String(id),
)
if (hasChildren) {
Message.warning('该分组下还有子分组,请先删除子分组')
return
}
if (props.referencedGroupIds.some((gid) => String(gid) === String(id))) {
Message.warning('该分组仍被列表或编辑配置引用,请先清空引用')
return
}
localGroups.value = localGroups.value.filter((g) => String(g.b_id) !== String(id))
if (String(selectedGroupId.value) === String(id)) selectedGroupId.value = null
}
async function save() {
if (saving.value) return
const changes = diffRows(localGroups.value, cloneData(props.groups || []))
const req = tableChange('s_field_group', changes)
if (!hasChanges(req)) {
Message.info('没有需要保存的变更')
emit('update:open', false)
emit('saved', localGroups.value, new Map())
return
}
saving.value = true
try {
const mapping = await allocateTemporaryIds([req.inserts || [], req.updates || []])
await saveObjectApi([req])
Message.success('分组已保存')
emit('update:open', false)
emit('saved', localGroups.value, mapping)
} catch (err) {
Message.error('保存失败:' + (err?.message || err))
} finally {
saving.value = false
}
}
</script>
<template>
<Modal
:open="open"
title="分组管理"
width="640"
:confirm-loading="saving"
:resizable="false"
@update:open="(v) => emit('update:open', v)"
@ok="save"
>
<div class="mm-groups">
<aside class="mm-groups__tree">
<div class="mm-groups__toolbar">
<Button type="outline" @click="addGroup(null)">
<Plus :size="14" /> 新增根分组
</Button>
<Button
type="outline"
:disabled="!selectedGroupId"
@click="addGroup(selectedGroupId)"
>
<FolderPlus :size="14" /> 新增子分组
</Button>
</div>
<div class="mm-groups__scroll">
<Tree
:tree-data="treeData"
:field-names="{ key: 'b_id', title: 'b_title', children: 'children' }"
default-expand-all
:selected-keys="selectedGroupId ? [selectedGroupId] : []"
@select="onTreeSelect"
/>
</div>
</aside>
<section class="mm-groups__detail">
<template v-if="selectedGroup">
<div class="mm-groups__form">
<div class="mm-form-row">
<label>分组标题</label>
<Input v-model="selectedGroup.b_title" placeholder="分组标题" />
</div>
<div class="mm-form-row">
<label>排序号</label>
<Input v-model="selectedGroup.b_xh" type="number" />
</div>
<div class="mm-form-row">
<label>启用</label>
<Switch
:model-value="Number(selectedGroup.b_canuse) === 1"
@update:model-value="(v) => (selectedGroup.b_canuse = v ? 1 : 0)"
/>
</div>
</div>
<div class="mm-groups__danger">
<Button type="danger" @click="deleteGroupById(selectedGroup.b_id)">
<Trash2 :size="14" /> 删除该分组
</Button>
</div>
</template>
<div v-else class="mm-groups__empty">请选择左侧分组节点</div>
</section>
</div>
</Modal>
</template>
<style lang="scss" scoped>
.mm-groups {
display: flex;
gap: 8px;
height: 420px;
min-height: 0;
&__tree {
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 280px;
min-height: 0;
padding: 8px;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-secondary-hover);
}
&__toolbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding-bottom: 8px;
}
&__scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
&__detail {
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
padding: 12px;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
}
&__form {
display: flex;
flex-direction: column;
gap: 12px;
}
&__danger {
margin-top: 20px;
}
&__empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--fms-text-secondary);
font-size: 13px;
}
}
.mm-form-row {
display: grid;
grid-template-columns: 88px 1fr;
align-items: center;
gap: 8px;
label {
font-size: 13px;
color: var(--fms-text-secondary);
text-align: right;
}
}
</style>
@@ -0,0 +1,150 @@
<script setup>
/**
* 快速设置多语言弹窗:按 key 加载 s_i18n 各语言值,保存后 emit("saved", key)。
* 调用方通过 ref 调用 open(key, defaultValue),defaultValue 用于默认语言的初始值。
* 语言列表暂用前端常量;后续若引入语言类型表(s_i18n_type)再改为接口加载。
*/
import { ref } from 'vue'
import Message from '@/components/ui/message/message-manager'
import Modal from '@/components/ui/modal/modal.vue'
import Input from '@/components/ui/input/input.vue'
import { loadDataApi, saveObjectApi } from '@/services/api'
import { DEFAULT_LANGUAGES } from '../common'
const emit = defineEmits(['saved'])
const visible = ref(false)
const saving = ref(false)
const i18nKey = ref('')
const entries = ref([])
async function open(key, defaultValue = '') {
if (!key) {
Message.warning('多语言键为空')
return
}
i18nKey.value = key
const escaped = key.replace(/'/g, "''")
try {
const res = await loadDataApi('s_i18n', `b_key = '${escaped}'`, 'b_locale ASC')
const rows = res.data || []
const byLocale = {}
for (const row of rows) {
byLocale[row.b_locale] = row
}
const defaultLocale = String(DEFAULT_LANGUAGES[0]?.code || '')
const values = []
for (const language of DEFAULT_LANGUAGES) {
const existing = byLocale[language.code]
let value = ''
if (existing) {
value = existing.b_value
} else if (language.code === defaultLocale) {
value = defaultValue
}
values.push({
b_id: existing ? existing.b_id : null,
b_locale: language.code,
b_name: language.name,
b_value: value,
})
}
entries.value = values
} catch {
entries.value = []
}
visible.value = true
}
async function save() {
if (!i18nKey.value) {
Message.warning('请填写多语言键')
return
}
saving.value = true
try {
const req = { table: 's_i18n', key_field: 'b_id', inserts: [], updates: [] }
for (const e of entries.value) {
const value = e.b_value == null ? '' : e.b_value
if (!e.b_id && !value) continue
const row = { b_key: i18nKey.value, b_locale: e.b_locale, b_value: value, b_canuse: 1 }
if (e.b_id) req.updates.push({ ...row, b_id: e.b_id })
else req.inserts.push(row)
}
if (req.inserts.length || req.updates.length) {
await saveObjectApi([req])
}
Message.success('多语言已保存')
visible.value = false
emit('saved', i18nKey.value)
} catch (err) {
Message.error('保存失败:' + (err?.message || err))
} finally {
saving.value = false
}
}
defineExpose({ open })
</script>
<template>
<Modal
v-model:open="visible"
title="快速设置多语言"
:confirm-loading="saving"
:resizable="false"
@ok="save"
>
<div class="mm-i18n-quick">
<div class="mm-i18n-quick__key">
<label>多语言键</label>
<Input v-model="i18nKey" disabled />
</div>
<div class="mm-i18n-quick__rows">
<div v-for="(item, idx) in entries" :key="idx" class="mm-i18n-quick__row">
<span class="mm-i18n-quick__locale">{{ item.b_name }} ({{ item.b_locale }})</span>
<Input v-model="item.b_value" class="mm-i18n-quick__value" />
</div>
</div>
</div>
</Modal>
</template>
<style lang="scss" scoped>
.mm-i18n-quick {
display: flex;
flex-direction: column;
gap: 12px;
&__key {
display: grid;
grid-template-columns: 88px 1fr;
align-items: center;
gap: 8px;
label {
font-size: 13px;
color: var(--fms-text-secondary);
text-align: right;
}
}
&__row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
&__locale {
flex: none;
width: 140px;
color: var(--fms-text-secondary);
font-size: 13px;
}
&__value {
flex: 1;
}
}
</style>
@@ -0,0 +1,60 @@
<script setup>
/**
* 图标选择器:基于 Select(内置搜索过滤),选项为常用 lucide 图标子集。
* 图标名首字母大写,如 "Settings"、"Users"。
*/
import { ref } from 'vue'
import Select from '@/components/ui/select/select.vue'
import { getIcon } from '@/utils/lucideIcons'
const props = defineProps({
modelValue: { type: String, default: '' },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
const ICON_NAMES = [
'LayoutGrid', 'Home', 'Folder', 'FolderOpen', 'FolderTree', 'Boxes', 'Database',
'Table2', 'List', 'ListChecks', 'ClipboardList', 'FileText', 'FilePlus2', 'Files',
'Settings', 'Settings2', 'SlidersHorizontal', 'Cog', 'Wrench', 'Shield', 'ShieldCheck',
'Users', 'User', 'UserPlus', 'UserCog', 'Building2', 'Building', 'Contact',
'Briefcase', 'Banknote', 'ShoppingCart', 'Truck', 'Package', 'Box', 'Warehouse',
'CalendarDays', 'CalendarClock', 'Clock', 'Timer', 'History', 'AlarmClock',
'Search', 'Filter', 'Eye', 'EyeOff', 'Lock', 'Unlock', 'Key', 'KeyRound',
'Plus', 'Minus', 'Pencil', 'Trash2', 'Copy', 'Save', 'Upload', 'Download',
'RefreshCw', 'RotateCw', 'Undo2', 'Redo2', 'ExternalLink', 'Link', 'Share2',
'Printer', 'Mail', 'Phone', 'MapPin', 'Globe', 'Globe2', 'Languages', 'Translate',
'BookOpen', 'BookMarked', 'GraduationCap', 'Award', 'Star', 'Heart', 'Flag',
'Check', 'CheckCircle2', 'XCircle', 'AlertCircle', 'AlertTriangle', 'Info',
'ChevronRight', 'ChevronDown', 'ChevronUp', 'ChevronsUpDown', 'ArrowRight',
'ArrowLeft', 'ArrowUpDown', 'ArrowDownUp', 'MoveUp', 'MoveDown', 'GripVertical',
'BarChart3', 'PieChart', 'TrendingUp', 'TrendingDown', 'Activity', 'Gauge',
'CircleDot', 'Radio', 'Play', 'Pause', 'Video', 'Image', 'Camera', 'Palette',
'Music', 'MessageSquare', 'MessageCircle', 'Bell', 'BellRing', 'Inbox',
'Send', 'Paperclip', 'AttachFile', 'Cloud', 'CloudUpload', 'CloudDownload',
'Cpu', 'HardDrive', 'Monitor', 'Laptop', 'Smartphone', 'Tablet', 'Mouse',
'Network', 'Server', 'Plug', 'Wifi', 'Zap', 'Battery', 'Power', 'LogIn', 'LogOut',
]
const iconOptions = ref(
ICON_NAMES.map((name) => ({ label: name, value: name })),
)
function iconOf(name) {
return getIcon(name)
}
</script>
<template>
<Select
:model-value="modelValue"
:options="iconOptions"
:disabled="disabled"
filterable
clearable
placeholder="搜索图标名称"
/>
</template>
<style lang="scss" scoped></style>
@@ -0,0 +1,329 @@
<script setup>
/**
* 菜单配置面板(s_menu):基础信息 + 权限 + 多语言。
* 权限点 b_power_type=menu、b_resource=菜单编码;i18n 键 menu.{code} / menu.{code}.{action}。
*/
import { computed, ref, watch } from 'vue'
import { Languages, Info, Shield, Globe } from '@lucide/vue'
import Input from '@/components/ui/input/input.vue'
import Select from '@/components/ui/select/select.vue'
import Switch from '@/components/ui/switch/switch.vue'
import Form from '@/components/ui/form/form.vue'
import FormItem from '@/components/ui/form/form-item.vue'
import Tabs from '@/components/ui/tabs/tabs.vue'
import TabPane from '@/components/ui/tabs/tab-pane.vue'
import { buildModuleI18nKey, validateModuleCode } from '@/utils/i18n'
import IconSelect from './IconSelect.vue'
import ModulePowersPanel from './ModulePowersPanel.vue'
import ModuleI18nPanel from './ModuleI18nPanel.vue'
import I18nQuickSetModal from './I18nQuickSetModal.vue'
const props = defineProps({
/** 当前菜单草稿对象(s_menu 行) */
menu: { type: Object, default: null },
parentName: { type: String, default: '' },
/** data/query 模块选项 [{label, value}](b_module_id 关联) */
moduleOptions: { type: Array, default: () => [] },
/** 菜单权限点草稿(s_power,b_power_type=menu) */
powers: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['update', 'updatePowers', 'i18nSaved'])
const activeSection = ref('basic')
const formData = ref(null)
watch(
() => props.menu,
(val) => {
if (val === formData.value) return
formData.value = val ? { ...val } : null
},
{ immediate: true },
)
const menuTypeOptions = [
{ label: '目录', value: 'directory' },
{ label: '页面', value: 'page' },
{ label: '数据页面', value: 'data' },
{ label: '外链', value: 'external' },
]
const isDirectory = computed(() => formData.value?.b_menu_type === 'directory')
const isPage = computed(() => formData.value?.b_menu_type === 'page')
const isData = computed(() => formData.value?.b_menu_type === 'data')
const isExternal = computed(() => formData.value?.b_menu_type === 'external')
/* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
const formRules = computed(() => ({
b_code: [
{ required: true, message: '请填写菜单编码' },
{ validator: (value) => validateModuleCode(value) || true },
],
b_name: [{ required: true, message: '请填写菜单名称' }],
b_menu_type: [{ required: true, message: '请选择菜单类型' }],
b_route: [{ required: true, message: '请填写页面路由' }],
b_module_id: [{ required: true, message: '请选择关联模块' }],
}))
const formRef = ref(null)
// 保存前强制校验:返回带错误的字段列表 [{ name, errors }]
function validate() {
// 先切回基本信息 tab,确保用户能看到标红字段
activeSection.value = 'basic'
return formRef.value?.validateAll?.() ?? Promise.resolve([])
}
function emitUpdate() {
if (formData.value) emit('update', formData.value)
}
function setField(field, value) {
if (!formData.value) return
formData.value[field] = value === '' ? null : value
emitUpdate()
}
// 编码变化时自动派生菜单多语言键
function onCodeInput(v) {
setField('b_code', v)
if (formData.value) {
formData.value.b_i18n = buildModuleI18nKey('menu', v)
emitUpdate()
}
}
const i18nModalRef = ref(null)
function openI18nModal() {
if (!formData.value || !i18nModalRef.value) return
const key = formData.value.b_i18n || buildModuleI18nKey('menu', formData.value.b_code)
i18nModalRef.value.open(key, formData.value.b_name || '')
}
function onI18nSaved(key) {
setField('b_i18n', key)
i18nPanelRef.value?.refreshKey?.(key)
emit('i18nSaved', key)
}
const i18nPanelRef = ref(null)
/** 供父组件保存时调用:按最终草稿态生成 s_i18n 请求 */
function saveI18n() {
const panel = i18nPanelRef.value
if (!panel) return null
if (typeof panel.reconcileManagedRows === 'function') panel.reconcileManagedRows(i18nItems.value)
return typeof panel.buildSaveRequest === 'function' ? panel.buildSaveRequest() : null
}
function markI18nSaved() {
i18nPanelRef.value?.markSaved?.()
}
defineExpose({ validate, saveI18n, markI18nSaved })
/* ---- i18n 受管键组装(菜单本身 + 权限点) ---- */
const i18nItems = computed(() => {
const items = []
if (formData.value) {
const key = formData.value.b_i18n || buildModuleI18nKey('menu', formData.value.b_code)
if (key) {
items.push({
key,
managed: true,
defaultValue: formData.value.b_name || '',
id: `menu:${key}`,
})
}
}
for (const power of props.powers || []) {
const key = String(power.b_i18n || '')
if (key) {
items.push({
key,
managed: true,
defaultValue: power.b_name || '',
id: `power:${key}`,
})
}
}
return items
})
</script>
<template>
<div class="mm-menu-detail" v-if="formData">
<Tabs v-model="activeSection" class="mm-menu-tabs">
<TabPane tab-key="basic">
<template #label><Info :size="14" /> 基本信息</template>
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical" :columns="2" max-width="720px">
<FormItem label="菜单编码" name="b_code" required>
<Input :model-value="formData.b_code" :disabled="loading" @update:model-value="onCodeInput" />
</FormItem>
<FormItem label="菜单名称" name="b_name" required>
<Input
:model-value="formData.b_name"
:disabled="loading"
@update:model-value="(v) => setField('b_name', v)"
/>
</FormItem>
<FormItem label="菜单类型" name="b_menu_type" required>
<Select
:model-value="formData.b_menu_type"
:options="menuTypeOptions"
:disabled="loading"
@update:model-value="(v) => setField('b_menu_type', v)"
/>
</FormItem>
<FormItem label="上级菜单" name="b_parent_id">
<Input :model-value="parentName" readonly />
</FormItem>
<FormItem label="多语言键" name="b_i18n">
<div class="mm-basic__i18n">
<Input :model-value="formData.b_i18n" readonly />
<button
type="button"
class="mm-basic__i18n-btn"
title="快速设置多语言"
@click="openI18nModal"
>
<Languages :size="16" />
</button>
</div>
</FormItem>
<FormItem label="排序号" name="b_xh">
<Input
:model-value="formData.b_xh"
type="number"
:disabled="loading"
@update:model-value="(v) => setField('b_xh', v)"
/>
</FormItem>
<FormItem label="菜单图标" name="b_icon">
<IconSelect
:model-value="formData.b_icon || ''"
:disabled="loading"
@update:model-value="(v) => setField('b_icon', v)"
/>
</FormItem>
<FormItem label="启用" name="b_canuse">
<Switch
:model-value="Number(formData.b_canuse) === 1"
:disabled="loading"
@update:model-value="(v) => setField('b_canuse', v ? 1 : 0)"
/>
</FormItem>
<FormItem v-if="isPage || isExternal" label="页面路由" name="b_route" required>
<Input
:model-value="formData.b_route"
:disabled="loading"
placeholder="固定页面路由或外链地址"
@update:model-value="(v) => setField('b_route', v)"
/>
</FormItem>
<FormItem v-if="isData" label="关联模块" name="b_module_id" required>
<Select
:model-value="String(formData.b_module_id || '')"
:options="moduleOptions"
:disabled="loading"
placeholder="选择数据/查询模块"
clearable
@update:model-value="(v) => setField('b_module_id', v)"
/>
</FormItem>
<FormItem v-if="isPage || isExternal || isData" label="访问权限编码" name="b_power_code">
<Input
:model-value="formData.b_power_code"
:disabled="loading"
placeholder="对应 s_power.b_code"
@update:model-value="(v) => setField('b_power_code', v)"
/>
</FormItem>
</Form>
</TabPane>
<TabPane tab-key="powers">
<template #label><Shield :size="14" /> 权限配置</template>
<ModulePowersPanel
:module-id="String(formData.b_id || '')"
:module-code="formData.b_code || ''"
:powers="powers"
:loading="loading"
power-type="menu"
:resource="formData.b_code || ''"
i18n-prefix="menu"
@update="(v) => emit('updatePowers', v)"
@i18n-saved="(key) => emit('i18nSaved', key)"
/>
</TabPane>
<TabPane tab-key="i18n">
<template #label><Globe :size="14" /> 多语言</template>
<ModuleI18nPanel
:module-id="String(formData.b_id || '')"
:i18n-items="i18nItems"
:loading="loading"
ref="i18nPanelRef"
/>
</TabPane>
</Tabs>
<I18nQuickSetModal ref="i18nModalRef" @saved="onI18nSaved" />
</div>
</template>
<style lang="scss" scoped>
.mm-menu-detail {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-menu-tabs {
flex: 1;
min-height: 0;
:deep(.tabs-content) {
flex: 1;
min-height: 0;
overflow: auto;
}
}
.mm-basic__i18n {
display: flex;
align-items: center;
gap: 4px;
:deep(.input-wrapper) {
flex: 1;
min-width: 0;
}
}
.mm-basic__i18n-btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--fms-control-radius);
background: transparent;
color: var(--fms-text-secondary);
cursor: pointer;
&:hover {
background: var(--fms-secondary);
color: var(--fms-text);
}
}
</style>
@@ -0,0 +1,298 @@
<script setup>
/**
* 自动编码面板(s_autocode):单条规则表单 + 实时预览。
* 保存时 b_current_period / b_current_value 由服务端维护,前端不提交。
*/
import { computed, ref, watch } from 'vue'
import Input from '@/components/ui/input/input.vue'
import Select from '@/components/ui/select/select.vue'
import Switch from '@/components/ui/switch/switch.vue'
import Form from '@/components/ui/form/form.vue'
import FormItem from '@/components/ui/form/form-item.vue'
import { createTempId } from '@/utils/tempId'
const props = defineProps({
moduleId: { type: String, required: true },
autoCode: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['update'])
const DATE_FORMAT_OPTIONS = [
{ label: '不使用日期', value: '' },
{ label: 'yyyy', value: 'yyyy' },
{ label: 'yy', value: 'yy' },
{ label: 'yyyyMM', value: 'yyyyMM' },
{ label: 'yyMM', value: 'yyMM' },
{ label: 'yyyyMMdd', value: 'yyyyMMdd' },
{ label: 'yyyy-MM', value: 'yyyy-MM' },
{ label: 'yyyy-MM-dd', value: 'yyyy-MM-dd' },
{ label: 'yyyy/MM', value: 'yyyy/MM' },
{ label: 'yyyy/MM/dd', value: 'yyyy/MM/dd' },
]
const RESET_OPTIONS = [
{ label: '不重置', value: 'none' },
{ label: '每年', value: 'year' },
{ label: '每月', value: 'month' },
{ label: '每日', value: 'day' },
]
const localRow = ref(null)
const autoCodeEnabled = computed(() => Number(localRow.value?.b_canuse) === 1)
/* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
const formRules = computed(() => {
const field = (props.fields || []).find(
(item) => String(item.b_id) === String(localRow.value?.b_field_id || ''),
)
return {
b_field_id: [
{
validator: (value) => {
if (!autoCodeEnabled.value) return true
if (!value) return '请选择业务编号字段'
if (!field || Number(field.b_canuse) !== 1) return '请选择已启用的业务编号字段'
if (field.b_type !== 'input' && field.b_type !== 'textarea') {
return '业务编号字段必须使用文本控件类型'
}
return true
},
},
],
b_startvalue: [
{
type: 'integer',
validator: (value) => {
if (!autoCodeEnabled.value) return true
if (value === '' || value == null) return '请填写起始值'
if (Number(value) < 0) return '起始值必须是大于或等于 0 的整数'
return true
},
message: '起始值必须是大于或等于 0 的整数',
},
],
b_resettype: [{ required: true, message: '请选择重置周期' }],
b_seqwidth: [
{
validator: (value) => {
if (!autoCodeEnabled.value) return true
const width = Number(value)
if (value === '' || value == null) return '请填写流水号位数'
if (!Number.isInteger(width) || width < 1 || width > 18) {
return '流水号位数必须是 1 到 18 之间的整数'
}
return true
},
},
],
}
})
const formRef = ref(null)
// 保存前强制校验:返回带错误的字段列表 [{ name, errors }]
function validate() {
return formRef.value?.validateAll?.() ?? Promise.resolve([])
}
defineExpose({ validate })
const fieldOptions = computed(() => {
const options = []
for (const field of props.fields || []) {
if (Number(field.b_canuse) !== 1) continue
if (field.b_field === 'b_id') continue
if (field.b_type !== 'input' && field.b_type !== 'textarea') continue
options.push({
label: `${field.b_name || field.b_field} (${field.b_field})`,
value: String(field.b_id),
})
}
return options
})
function createDraft(moduleId, fields, enabled = false) {
const textFields = (fields || []).filter(
(item) =>
Number(item.b_canuse) === 1 &&
item.b_field !== 'b_id' &&
(item.b_type === 'input' || item.b_type === 'textarea'),
)
return {
b_id: createTempId(),
b_module_id: String(moduleId),
b_field_id: textFields[0]?.b_id != null ? String(textFields[0].b_id) : null,
b_prefix: '',
b_dateformat: 'yyyyMM',
b_separator: '-',
b_seqwidth: 5,
b_resettype: 'month',
b_startvalue: 1,
b_currentperiod: null,
b_currentvalue: 0,
b_canuse: enabled ? 1 : 0,
}
}
watch(
() => props.autoCode,
(rows) => {
const row = rows && rows.length ? rows[0] : null
localRow.value = row ? { ...row } : createDraft(props.moduleId, props.fields, false)
},
{ immediate: true },
)
function notifyUpdate() {
if (localRow.value) emit('update', [{ ...localRow.value }])
}
function toggleEnabled(checked) {
if (!localRow.value) return
localRow.value.b_canuse = checked ? 1 : 0
notifyUpdate()
}
function setRowValue(field, value) {
if (!localRow.value) return
localRow.value[field] = value
notifyUpdate()
}
function pad(value) {
return String(value).padStart(2, '0')
}
function formatDate(date, format) {
const yyyy = String(date.getFullYear())
const yy = yyyy.slice(-2)
const MM = pad(date.getMonth() + 1)
const dd = pad(date.getDate())
return String(format || '')
.replaceAll('yyyy', yyyy)
.replaceAll('yy', yy)
.replaceAll('MM', MM)
.replaceAll('dd', dd)
}
function currentPeriod(date, resetType) {
const yyyy = String(date.getFullYear())
const MM = pad(date.getMonth() + 1)
const dd = pad(date.getDate())
if (resetType === 'none') return '*'
if (resetType === 'year') return yyyy
if (resetType === 'day') return yyyy + MM + dd
return yyyy + MM
}
const preview = computed(() => {
if (!localRow.value) return ''
const today = new Date()
const startValue = Number(localRow.value.b_startvalue) || 1
const storedValue = Number(localRow.value.b_currentvalue) || 0
const activePeriod = currentPeriod(today, localRow.value.b_resettype)
const sequence =
localRow.value.b_currentperiod === activePeriod ? storedValue + 1 : startValue
const parts = []
if (localRow.value.b_prefix) parts.push(localRow.value.b_prefix)
const dateText = formatDate(today, localRow.value.b_dateformat)
if (dateText) parts.push(dateText)
parts.push(String(sequence).padStart(Number(localRow.value.b_seqwidth) || 1, '0'))
return parts.join(localRow.value.b_separator || '')
})
</script>
<template>
<div v-if="localRow" class="mm-auto-code">
<Form ref="formRef" :model="localRow" :rules="formRules" layout="vertical" :columns="2" max-width="720px">
<FormItem label="启用自动编码" name="b_canuse">
<Switch
:model-value="autoCodeEnabled"
:disabled="loading"
@update:model-value="toggleEnabled"
/>
</FormItem>
<FormItem label="业务编号字段" name="b_field_id">
<Select
:model-value="String(localRow.b_field_id || '')"
:options="fieldOptions"
:disabled="loading || !autoCodeEnabled"
placeholder="选择文本字段"
@update:model-value="(v) => setRowValue('b_field_id', v)"
/>
</FormItem>
<FormItem label="固定前缀" name="b_prefix">
<Input
:model-value="localRow.b_prefix"
:disabled="loading || !autoCodeEnabled"
@update:model-value="(v) => setRowValue('b_prefix', v)"
/>
</FormItem>
<FormItem label="日期格式" name="b_dateformat">
<Select
:model-value="localRow.b_dateformat"
:options="DATE_FORMAT_OPTIONS"
:disabled="loading || !autoCodeEnabled"
allow-free-input
@update:model-value="(v) => setRowValue('b_dateformat', v)"
/>
</FormItem>
<FormItem label="分隔符" name="b_separator">
<Input
:model-value="localRow.b_separator"
:disabled="loading || !autoCodeEnabled"
@update:model-value="(v) => setRowValue('b_separator', v)"
/>
</FormItem>
<FormItem label="流水号位数" name="b_seqwidth">
<Input
:model-value="localRow.b_seqwidth"
type="number"
:disabled="loading || !autoCodeEnabled"
@update:model-value="(v) => setRowValue('b_seqwidth', v)"
/>
</FormItem>
<FormItem label="起始值" name="b_startvalue">
<Input
:model-value="localRow.b_startvalue"
type="number"
:disabled="loading || !autoCodeEnabled"
@update:model-value="(v) => setRowValue('b_startvalue', v)"
/>
</FormItem>
<FormItem label="重置周期" name="b_resettype">
<Select
:model-value="localRow.b_resettype"
:options="RESET_OPTIONS"
:disabled="loading || !autoCodeEnabled"
@update:model-value="(v) => setRowValue('b_resettype', v)"
/>
</FormItem>
<FormItem label="编号预览" name="b_preview" :span="2">
<div class="mm-auto-code__preview">{{ preview }}</div>
</FormItem>
</Form>
</div>
</template>
<style lang="scss" scoped>
.mm-auto-code {
display: flex;
flex-direction: column;
}
.mm-auto-code__preview {
min-height: 32px;
padding: 4px 10px;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-secondary-hover);
color: var(--fms-text);
font-family: Consolas, Monaco, monospace;
line-height: 22px;
}
</style>
@@ -0,0 +1,285 @@
<script setup>
/**
* 模块基础信息面板(s_module)。
* 注意:新系统将图标/路由移到 s_menu,模块本身只维护数据语义:
* category 分类节点 / data 数据模块 / query 查询模块。
*/
import { computed, ref, watch } from 'vue'
import { Languages } from '@lucide/vue'
import Input from '@/components/ui/input/input.vue'
import Select from '@/components/ui/select/select.vue'
import Form from '@/components/ui/form/form.vue'
import FormItem from '@/components/ui/form/form-item.vue'
import { buildModuleI18nKey, validateModuleCode } from '@/utils/i18n'
import I18nQuickSetModal from './I18nQuickSetModal.vue'
const props = defineProps({
data: { type: Object, default: null },
parentName: { type: String, default: '' },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['update', 'i18nSaved'])
const formData = ref(null)
watch(
() => props.data,
(val) => {
if (val === formData.value) return
formData.value = val ? { ...val } : null
},
{ immediate: true },
)
const typeOptions = [
{ label: '分类', value: 'category' },
{ label: '数据模块', value: 'data' },
{ label: '查询模块', value: 'query' },
]
const deletePolicyOptions = [
{ label: '禁止删除(restrict)', value: 'restrict' },
{ label: '生命周期(lifecycle)', value: 'lifecycle' },
{ label: '物理删除(hard)', value: 'hard' },
]
const isCategory = computed(() => formData.value?.b_module_type === 'category')
const isData = computed(() => formData.value?.b_module_type === 'data')
const isQuery = computed(() => formData.value?.b_module_type === 'query')
/* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
const formRules = computed(() => ({
b_code: [
{
required: true,
message: () => (isCategory.value ? '请填写分类编码' : '请填写模块编码'),
},
{
// validateModuleCode 通过时返回空字符串,需转成 true(通过)
validator: (value) => validateModuleCode(value) || true,
},
],
b_name: [{ required: true, message: '请填写模块名称' }],
b_module_type: [{ required: true, message: '请选择模块类型' }],
b_viewtable: [{ required: true, message: '请填写查询视图' }],
b_keyfield: [{ required: true, message: '请填写主键字段' }],
}))
const formRef = ref(null)
// 保存前强制校验:返回带错误的字段列表 [{ name, errors }]
function validate() {
return formRef.value?.validateAll?.() ?? Promise.resolve([])
}
defineExpose({ validate })
function emitUpdate() {
if (formData.value) emit('update', formData.value)
}
function setField(field, value) {
if (!formData.value) return
formData.value[field] = value === '' ? null : value
emitUpdate()
}
// 编码变化时始终以完整编码重新派生多语言键
function onCodeInput(v) {
setField('b_code', v)
if (formData.value) {
formData.value.b_i18n = buildModuleI18nKey(formData.value.b_module_type, v)
emitUpdate()
}
}
function onModuleTypeChange(v) {
if (!formData.value) return
formData.value.b_module_type = v
formData.value.b_i18n = buildModuleI18nKey(v, formData.value.b_code)
emitUpdate()
}
const i18nModalRef = ref(null)
function openI18nModal() {
if (!formData.value || !i18nModalRef.value) return
const key =
formData.value.b_i18n || buildModuleI18nKey(formData.value.b_module_type, formData.value.b_code)
i18nModalRef.value.open(key, formData.value.b_name || '')
}
function onI18nSaved(key) {
setField('b_i18n', key)
emit('i18nSaved', key)
}
</script>
<template>
<div v-if="formData" class="mm-basic">
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical" :columns="2" max-width="720px">
<FormItem :label="isCategory ? '分类编码' : '模块编码'" name="b_code" required>
<Input :model-value="formData.b_code" :disabled="loading" @update:model-value="onCodeInput" />
</FormItem>
<FormItem label="模块名称" name="b_name" required>
<Input
:model-value="formData.b_name"
:disabled="loading"
@update:model-value="(v) => setField('b_name', v)"
/>
</FormItem>
<FormItem label="模块类型" name="b_module_type" required>
<Select
:model-value="formData.b_module_type"
:options="typeOptions"
:disabled="loading"
@update:model-value="onModuleTypeChange"
/>
</FormItem>
<FormItem label="上级模块" name="b_parent_id">
<Input :model-value="parentName" readonly />
</FormItem>
<FormItem label="多语言键" name="b_i18n">
<div class="mm-basic__i18n">
<Input :model-value="formData.b_i18n" readonly />
<button
type="button"
class="mm-basic__i18n-btn"
title="快速设置多语言"
@click="openI18nModal"
>
<Languages :size="16" />
</button>
</div>
</FormItem>
<FormItem label="排序号" name="b_xh">
<Input
:model-value="formData.b_xh"
type="number"
:disabled="loading"
@update:model-value="(v) => setField('b_xh', v)"
/>
</FormItem>
<template v-if="isData || isQuery">
<FormItem label="查询视图" name="b_viewtable" required>
<Input
:model-value="formData.b_viewtable"
:disabled="loading"
placeholder="data/query 必填"
@update:model-value="(v) => setField('b_viewtable', v)"
/>
</FormItem>
<FormItem label="主键字段" name="b_keyfield" required>
<Input
:model-value="formData.b_keyfield"
:disabled="loading"
placeholder="如 b_id"
@update:model-value="(v) => setField('b_keyfield', v)"
/>
</FormItem>
<FormItem v-if="isData" label="保存表" name="b_savetable">
<Input
:model-value="formData.b_savetable"
:disabled="loading"
placeholder="data 模块的保存目标"
@update:model-value="(v) => setField('b_savetable', v)"
/>
</FormItem>
<FormItem v-if="isData" label="默认排序" name="b_orderfield">
<Input
:model-value="formData.b_orderfield"
:disabled="loading"
placeholder="如 b_xh ASC, b_id DESC"
@update:model-value="(v) => setField('b_orderfield', v)"
/>
</FormItem>
<FormItem v-if="isData" label="删除策略" name="b_delete_policy">
<Select
:model-value="formData.b_delete_policy"
:options="deletePolicyOptions"
:disabled="loading"
clearable
@update:model-value="(v) => setField('b_delete_policy', v)"
/>
</FormItem>
</template>
<FormItem label="描述" name="b_bz" :span="2">
<Input
:model-value="formData.b_bz"
type="textarea"
:rows="2"
:disabled="loading"
@update:model-value="(v) => setField('b_bz', v)"
/>
</FormItem>
</Form>
<I18nQuickSetModal ref="i18nModalRef" @saved="onI18nSaved" />
</div>
</template>
<style lang="scss" scoped>
.mm-basic {
display: flex;
flex-direction: column;
}
.mm-basic__i18n {
display: flex;
align-items: center;
gap: 4px;
:deep(.input-wrapper) {
flex: 1;
min-width: 0;
}
}
.mm-basic__i18n-btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--fms-control-radius);
background: transparent;
color: var(--fms-text-secondary);
cursor: pointer;
&:hover {
background: var(--fms-secondary);
color: var(--fms-text);
}
}
/* 多语言键行:输入框 + 快捷按钮 */
.mm-basic__i18n {
display: flex;
gap: 6px;
.mm-basic__i18n-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--fms-control-height);
height: var(--fms-control-height);
padding: 0;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-card);
color: var(--fms-text-secondary);
cursor: pointer;
&:hover {
color: var(--fms-primary);
border-color: var(--fms-primary);
}
}
}
</style>
@@ -0,0 +1,217 @@
<script setup>
/**
* 编辑配置面板(s_field_edit):显示/必填/只读/禁用/分组/排序,支持行拖拽与分组管理。
*/
import { computed, ref, shallowRef, watch } from 'vue'
import Message from '@/components/ui/message/message-manager'
import Button from '@/components/ui/button/button.vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import FieldGroupManager from './FieldGroupManager.vue'
const props = defineProps({
moduleId: { type: String, required: true },
editConfig: { type: Array, default: () => [] },
listConfig: { type: Array, default: () => [] },
queryConfig: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] },
referencedGroupIds: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['update', 'groupsSaved', 'syncList', 'syncQuery'])
const localEdit = ref([])
const groupManagerVisible = ref(false)
const activeKey = ref(null)
watch(
() => props.editConfig,
(val) => {
if (val === localEdit.value) return
localEdit.value = val?.map((row) => ({ ...row })) || []
},
{ immediate: true },
)
function emitUpdate() {
emit('update', localEdit.value)
}
function onRowOrderChange(rows) {
const sorted = rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
localEdit.value = sorted
emitUpdate()
}
const fieldNameMap = computed(() => {
const map = new Map()
for (const field of props.fields || []) {
map.set(String(field.b_id), field.b_name || field.b_field)
}
return map
})
const groupOptions = computed(() => {
const options = []
for (const group of props.groups || []) {
if (Number(group.b_canuse) !== 1) continue
options.push({ label: group.b_title || `分组-${group.b_id}`, value: String(group.b_id) })
}
return options
})
function syncToList() {
if (props.loading) return
const visibleMap = new Map()
for (const row of localEdit.value) {
if (row.b_field_id != null) {
visibleMap.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0)
}
}
if (visibleMap.size === 0) {
Message.info('编辑配置中没有字段')
return
}
let count = 0
const updated = (props.listConfig || []).map((row) => {
const target = visibleMap.get(String(row.b_field_id))
if (target !== undefined) {
count++
return { ...row, b_visible: target }
}
return row
})
emit('syncList', updated)
Message.success(`已同步 ${count} 条到列表配置`)
}
function syncToQuery() {
if (props.loading) return
const visibleMap = new Map()
for (const row of localEdit.value) {
if (row.b_field_id != null) {
visibleMap.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0)
}
}
if (visibleMap.size === 0) {
Message.info('编辑配置中没有字段')
return
}
let count = 0
const updated = (props.queryConfig || []).map((row) => {
const target = visibleMap.get(String(row.b_field_id))
if (target !== undefined) {
count++
return { ...row, b_canuse: target }
}
return row
})
emit('syncQuery', updated)
Message.success(`已同步 ${count} 条到查询配置`)
}
const columns = shallowRef([
{
dataIndex: 'b_field_id',
title: '字段',
width: 180,
customCell: ({ row }) => fieldNameMap.value.get(String(row.b_field_id)) || row.b_field_id || '',
},
{
dataIndex: 'b_visible',
title: '显示',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{
dataIndex: 'b_required',
title: '必填',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{
dataIndex: 'b_readonly',
title: '只读',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{
dataIndex: 'b_disabled',
title: '禁用',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{
dataIndex: 'b_group_id',
title: '编辑分组',
width: 160,
editor: { type: 'select', options: () => groupOptions.value },
},
{ dataIndex: 'b_xh', title: '排序号', width: 90 },
])
</script>
<template>
<div class="mm-panel">
<div class="mm-panel__toolbar">
<Button type="outline" :disabled="loading" @click="groupManagerVisible = true">
分组管理
</Button>
<Button type="outline" :disabled="loading || !listConfig.length" @click="syncToList">
同步到列表配置
</Button>
<Button type="outline" :disabled="loading || !queryConfig.length" @click="syncToQuery">
同步到查询配置
</Button>
</div>
<div class="mm-panel__body">
<FmsTable
v-model:columns="columns"
:data-source="localEdit"
:editable="!loading"
:row-draggable="!loading"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row.b_id))"
@change="emitUpdate"
@row-order-change="onRowOrderChange"
/>
</div>
<FieldGroupManager
v-model:open="groupManagerVisible"
:module-id="moduleId"
:groups="groups"
:referenced-group-ids="referencedGroupIds"
@saved="(groups, idMapping) => emit('groupsSaved', groups, idMapping)"
/>
</div>
</template>
<style lang="scss" scoped>
.mm-panel {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-panel__toolbar {
display: flex;
flex: none;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 8px;
}
.mm-panel__body {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
</style>
@@ -0,0 +1,178 @@
<script setup>
/**
* 字段定义面板(s_field):可编辑表格维护模块字段。
* i18n 键按 field.{moduleCode}.{fieldName} 自动同步。
*/
import { h, ref, shallowRef, watch } from 'vue'
import { Languages } from '@lucide/vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { buildModuleItemI18nKey, syncModuleItemI18nKeys } from '@/utils/i18n'
import I18nQuickSetModal from './I18nQuickSetModal.vue'
const props = defineProps({
moduleId: { type: String, required: true },
moduleCode: { type: String, default: '' },
fields: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['update', 'i18nSaved', 'syncFields'])
const FIELD_TYPES = [
{ label: '输入框', value: 'input' },
{ label: '文本域', value: 'textarea' },
{ label: '数字', value: 'number' },
{ label: '金额', value: 'money' },
{ label: '日期', value: 'date' },
{ label: '日期时间', value: 'datetime' },
{ label: '复选框', value: 'checkbox' },
{ label: '下拉选择', value: 'select' },
]
const DB_TYPES = [
{ label: 'varchar', value: 'varchar' },
{ label: 'nvarchar', value: 'nvarchar' },
{ label: 'int', value: 'int' },
{ label: 'bigint', value: 'bigint' },
{ label: 'decimal', value: 'decimal' },
{ label: 'date', value: 'date' },
{ label: 'datetime2', value: 'datetime2' },
{ label: 'bit', value: 'bit' },
{ label: 'text', value: 'text' },
]
const localFields = ref([])
const activeKey = ref(null)
const i18nModalRef = ref(null)
watch(
() => [props.fields, props.moduleCode],
([val, moduleCode]) => {
if (val === localFields.value) return
const rows = val?.map((row) => ({ ...row })) || []
const changed = syncModuleItemI18nKeys(rows, 'field', moduleCode, 'b_field')
localFields.value = rows
if (changed) emit('update', rows)
},
{ immediate: true },
)
function emitUpdate() {
emit('update', localFields.value.map((row) => ({ ...row })))
}
function onCellChange() {
syncModuleItemI18nKeys(localFields.value, 'field', props.moduleCode, 'b_field')
emitUpdate()
}
function openI18n(row) {
const key = buildModuleItemI18nKey('field', props.moduleCode, row.b_field)
if (!key) return
i18nModalRef.value?.open(key, row.b_name || row.b_field || '')
}
const I18nKeyCell = ({ row, cellValue }) =>
h('span', { class: 'mm-i18n-cell' }, [
h('span', { class: 'mm-i18n-cell__text' }, cellValue || ''),
h(
'button',
{
type: 'button',
title: '设置多语言',
class: 'mm-i18n-cell__btn',
onClick: (event) => {
event.stopPropagation()
openI18n(row)
},
},
[h(Languages, { size: 14 })],
),
])
const columns = shallowRef([
{ dataIndex: 'b_field', title: '字段名', width: 150 },
{ dataIndex: 'b_name', title: '字段名称', width: 140, editor: { type: 'input' } },
{
dataIndex: 'b_type',
title: '控件类型',
width: 130,
editor: { type: 'select', options: FIELD_TYPES },
},
{
dataIndex: 'b_dbtype',
title: '数据库类型',
width: 130,
editor: { type: 'select', options: DB_TYPES },
},
{
dataIndex: 'b_length',
title: '长度',
width: 80,
editor: { type: 'input', inputType: 'number' },
},
{ dataIndex: 'b_default_value', title: '默认值', width: 120, editor: { type: 'input' } },
{ dataIndex: 'b_i18n', title: '多语言键', width: 220, customCell: I18nKeyCell },
{
dataIndex: 'b_canuse',
title: '启用',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
])
</script>
<template>
<div class="mm-fields">
<FmsTable
v-model:columns="columns"
:data-source="localFields"
:editable="!loading"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row.b_id))"
@change="onCellChange"
/>
<I18nQuickSetModal ref="i18nModalRef" @saved="(key) => emit('i18nSaved', key)" />
</div>
</template>
<style lang="scss" scoped>
.mm-fields {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-i18n-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
width: 100%;
}
.mm-i18n-cell__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mm-i18n-cell__btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
border: none;
background: transparent;
color: var(--fms-text-secondary);
cursor: pointer;
&:hover {
color: var(--fms-primary);
}
}
</style>
@@ -0,0 +1,342 @@
<script setup>
/**
* 多语言面板(s_i18n):透视展示——每行一个 b_key,每种语言一列。
* 受管键(模块/字段/权限的 b_i18n)自动维护:默认语言为空时自动跟随当前名称。
* 语言列表使用前端常量(DEFAULT_LANGUAGES),后续可切换语言类型表。
*/
import { computed, h, ref, watch } from 'vue'
import Message from '@/components/ui/message/message-manager'
import Button from '@/components/ui/button/button.vue'
import Input from '@/components/ui/input/input.vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { createTempId } from '@/utils/tempId'
import { cloneData } from '@/utils/dataChanges'
import { consolidatePivotRows } from '@/utils/i18n'
import { loadDataApi } from '@/services/api'
import { DEFAULT_LANGUAGES } from '../common'
const props = defineProps({
moduleId: { type: String, required: true },
/** 受管键项:[{ key, sourceKey, defaultValue, id }] */
i18nItems: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const langOptions = DEFAULT_LANGUAGES
const originalsFlat = ref([])
const pivotRows = ref([])
const isLoading = ref(false)
const searchKey = ref('')
const activeKey = ref(null)
const tableRef = ref(null)
const managedItems = computed(() => props.i18nItems.filter((item) => item && item.managed))
const requestedKeys = computed(() => {
const keys = new Set()
for (const item of props.i18nItems || []) {
if (!item) continue
if (item.key) keys.add(String(item.key))
if (item.sourceKey) keys.add(String(item.sourceKey))
}
return Array.from(keys)
})
function isManagedRow(row) {
return Boolean(row && row._managedId)
}
// 保证模块、字段、权限的受管键始终存在。默认语言为空或仍为上一次默认名称时,
// 自动跟随当前名称;人工修改后的翻译不再被名称变化覆盖。
function reconcileManagedRows(itemsOverride) {
const items = itemsOverride || managedItems.value
const rows = [...pivotRows.value]
const rowByManagedId = new Map()
const unclaimedByKey = new Map()
for (const row of rows) {
if (row._managedId) rowByManagedId.set(String(row._managedId), row)
else unclaimedByKey.set(String(row.b_key), row)
}
const currentManagedIds = new Set()
const defaultLocale = langOptions[0]?.code || ''
for (const item of items) {
const managedId = String(item.id || item.key)
currentManagedIds.add(managedId)
let row = rowByManagedId.get(managedId)
if (!row) row = unclaimedByKey.get(String(item.key))
if (!row && item.sourceKey) row = unclaimedByKey.get(String(item.sourceKey))
if (!row) {
row = { _id: createTempId(), b_key: item.key, _localeIds: {} }
rows.push(row)
}
const previousDefault = row._defaultValue
const nextDefault = item.defaultValue == null ? '' : String(item.defaultValue)
if (
defaultLocale &&
(row[defaultLocale] == null ||
row[defaultLocale] === '' ||
row[defaultLocale] === previousDefault)
) {
row[defaultLocale] = nextDefault
}
row.b_key = String(item.key)
row._managedId = managedId
row._defaultValue = nextDefault
row._sortType = String(item.key).split('.', 1)[0]
}
// 已从配置中移除的条目不再受保护,保留其翻译供管理员自行处理
for (const row of rows) {
if (!row._managedId || row._syncInjected || currentManagedIds.has(String(row._managedId)))
continue
delete row._managedId
delete row._defaultValue
}
pivotRows.value = consolidatePivotRows(rows)
}
// 将扁平记录按 key 聚合成一行(每种语言一列)
function buildPivot(flat) {
const byKey = new Map()
for (const r of flat) {
if (!byKey.has(r.b_key)) byKey.set(r.b_key, [])
byKey.get(r.b_key).push(r)
}
const rows = []
for (const [key, recs] of byKey) {
const row = { _id: createTempId(), b_key: key, _localeIds: {} }
for (const r of recs) {
row[r.b_locale] = r.b_value
row._localeIds[r.b_locale] = r.b_id
}
rows.push(row)
}
return rows
}
async function loadI18n() {
const moduleId = String(props.moduleId)
isLoading.value = true
try {
const keys = requestedKeys.value
const quotedKeys = keys.map((key) => "'" + key.replace(/'/g, "''") + "'")
const where = keys.length ? 'b_key IN (' + quotedKeys.join(', ') + ')' : null
const res = where ? await loadDataApi('s_i18n', where, 'b_key ASC, b_locale ASC') : { data: [] }
if (String(props.moduleId) !== moduleId) return
const flat = (res.data || []).map((row) => ({
b_id: row.b_id,
b_key: row.b_key,
b_locale: row.b_locale,
b_value: row.b_value,
}))
originalsFlat.value = cloneData(flat)
pivotRows.value = buildPivot(flat)
reconcileManagedRows()
} catch (e) {
Message.error('加载多语言失败:' + (e?.message || e))
} finally {
if (String(props.moduleId) === moduleId) isLoading.value = false
}
}
watch(
() => props.moduleId,
() => {
loadI18n()
},
{ immediate: true },
)
function onCellChange() {
reconcileManagedRows()
}
function removeRow(row) {
if (isManagedRow(row)) return
pivotRows.value = pivotRows.value.filter((r) => r !== row)
}
function removeByManagedIds(ids) {
const set = new Set(ids.map(String))
pivotRows.value = pivotRows.value.filter((r) => !set.has(String(r._managedId)))
}
function refreshKey(key) {
if (!key) return
loadI18n()
}
const filteredRows = computed(() => {
const kw = String(searchKey.value || '').trim().toLowerCase()
if (!kw) return pivotRows.value
return pivotRows.value.filter((row) => String(row.b_key || '').toLowerCase().includes(kw))
})
// 动态列:key 列 + 每种语言一列 + 操作列
const columns = computed(() => {
const cols = [
{
dataIndex: 'b_key',
title: '多语言键',
width: 260,
customCell: ({ row }) =>
h('span', { class: 'mm-i18n-key' }, [
h('span', { class: 'mm-i18n-key__text' }, row.b_key || ''),
isManagedRow(row) ? h('span', { class: 'mm-i18n-key__tag' }, '自动') : null,
]),
},
]
for (const lang of langOptions) {
cols.push({
dataIndex: lang.code,
title: lang.name,
width: 200,
editor: { type: 'input' },
})
}
cols.push({
dataIndex: 'actions',
title: '',
width: 60,
actions: [
{
type: 'delete',
label: '删除',
disabled: (row) => isManagedRow(row),
confirm: '确定删除该多语言键?',
onClick: (row) => removeRow(row),
},
],
})
return cols
})
// 供父组件保存时调用:按最终草稿态生成 s_i18n 请求
function buildSaveRequest() {
reconcileManagedRows()
const inserts = []
const updates = []
const deletes = []
const originals = new Map(
originalsFlat.value.map((r) => [`${r.b_key}|${r.b_locale}`, r]),
)
const seen = new Set()
for (const row of pivotRows.value) {
const key = String(row.b_key || '')
if (!key) continue
for (const lang of langOptions) {
const locale = lang.code
const cellKey = `${key}|${locale}`
seen.add(cellKey)
const value = row[locale] == null ? '' : String(row[locale])
const original = originals.get(cellKey)
const localeId = row._localeIds?.[locale]
if (localeId) {
if (!original || String(original.b_value ?? '') !== value) {
updates.push({ b_id: localeId, b_key: key, b_locale: locale, b_value: value, b_canuse: 1 })
}
} else if (value !== '') {
inserts.push({ b_key: key, b_locale: locale, b_value: value, b_canuse: 1 })
}
}
}
for (const r of originalsFlat.value) {
const cellKey = `${r.b_key}|${r.b_locale}`
if (!seen.has(cellKey)) deletes.push({ b_id: r.b_id })
}
if (!inserts.length && !updates.length && !deletes.length) return null
return { table: 's_i18n', key_field: 'b_id', inserts, updates, deletes }
}
function markSaved() {
const flat = []
for (const row of pivotRows.value) {
const key = String(row.b_key || '')
if (!key) continue
for (const lang of langOptions) {
const locale = lang.code
const localeId = row._localeIds?.[locale]
const value = row[locale] == null ? '' : String(row[locale])
if (localeId) flat.push({ b_id: localeId, b_key: key, b_locale: locale, b_value: value })
else if (value !== '') flat.push({ b_id: null, b_key: key, b_locale: locale, b_value: value })
}
}
originalsFlat.value = flat
}
defineExpose({ buildSaveRequest, markSaved, refreshKey, removeByManagedIds, reconcileManagedRows })
</script>
<template>
<div class="mm-i18n">
<div class="mm-panel__toolbar">
<div class="mm-i18n__search">
<Input v-model="searchKey" placeholder="按多语言键过滤" allow-clear />
</div>
<Button type="outline" :disabled="loading || isLoading" @click="loadI18n">刷新</Button>
</div>
<div class="mm-panel__body">
<FmsTable
ref="tableRef"
:columns="columns"
:data-source="filteredRows"
:editable="!loading && !isLoading"
row-key="_id"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row._id))"
@change="onCellChange"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.mm-i18n {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-panel__toolbar {
display: flex;
flex: none;
gap: 8px;
padding: 0 0 8px;
}
.mm-i18n__search {
width: 300px;
}
.mm-panel__body {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-i18n-key {
display: inline-flex;
align-items: center;
gap: 6px;
width: 100%;
}
.mm-i18n-key__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mm-i18n-key__tag {
flex: none;
padding: 0 6px;
border-radius: 999px;
font-size: 11px;
line-height: 16px;
background: var(--fms-secondary);
color: var(--fms-text-secondary);
}
</style>
@@ -0,0 +1,197 @@
<script setup>
/**
* 列表配置面板(s_field_view):列宽/分组/显示/排序,支持行拖拽与分组管理。
*/
import { computed, ref, shallowRef, watch } from 'vue'
import Message from '@/components/ui/message/message-manager'
import Button from '@/components/ui/button/button.vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import FieldGroupManager from './FieldGroupManager.vue'
const props = defineProps({
moduleId: { type: String, required: true },
listConfig: { type: Array, default: () => [] },
editConfig: { type: Array, default: () => [] },
queryConfig: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] },
referencedGroupIds: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['update', 'groupsSaved', 'syncEdit', 'syncQuery'])
const localList = ref([])
const groupManagerVisible = ref(false)
const activeKey = ref(null)
watch(
() => props.listConfig,
(val) => {
if (val === localList.value) return
localList.value = val?.map((row) => ({ ...row })) || []
},
{ immediate: true },
)
function emitUpdate() {
emit('update', localList.value)
}
function onRowOrderChange(rows) {
const sorted = rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
localList.value = sorted
emitUpdate()
}
const fieldNameMap = computed(() => {
const map = new Map()
for (const field of props.fields || []) {
map.set(String(field.b_id), field.b_name || field.b_field)
}
return map
})
const groupOptions = computed(() => {
const options = []
for (const group of props.groups || []) {
if (Number(group.b_canuse) !== 1) continue
options.push({ label: group.b_title || `分组-${group.b_id}`, value: String(group.b_id) })
}
return options
})
function syncToEdit() {
if (props.loading) return
const visibleMap = new Map()
for (const row of localList.value) {
if (row.b_field_id != null) {
visibleMap.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0)
}
}
if (visibleMap.size === 0) {
Message.info('列表配置中没有字段')
return
}
let count = 0
const updated = (props.editConfig || []).map((row) => {
const target = visibleMap.get(String(row.b_field_id))
if (target !== undefined) {
count++
return { ...row, b_visible: target }
}
return row
})
emit('syncEdit', updated)
Message.success(`已同步 ${count} 条到编辑显示`)
}
function syncToQuery() {
if (props.loading) return
const visibleMap = new Map()
for (const row of localList.value) {
if (row.b_field_id != null) {
visibleMap.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0)
}
}
if (visibleMap.size === 0) {
Message.info('列表配置中没有字段')
return
}
let count = 0
const updated = (props.queryConfig || []).map((row) => {
const target = visibleMap.get(String(row.b_field_id))
if (target !== undefined) {
count++
return { ...row, b_canuse: target }
}
return row
})
emit('syncQuery', updated)
Message.success(`已同步 ${count} 条到查询配置`)
}
const columns = shallowRef([
{
dataIndex: 'b_field_id',
title: '字段',
width: 180,
customCell: ({ row }) => fieldNameMap.value.get(String(row.b_field_id)) || row.b_field_id || '',
},
{ dataIndex: 'b_width', title: '列宽', width: 90, editor: { type: 'input', inputType: 'number' } },
{
dataIndex: 'b_group_id',
title: '分组',
width: 160,
editor: { type: 'select', options: () => groupOptions.value },
},
{
dataIndex: 'b_visible',
title: '显示',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{ dataIndex: 'b_xh', title: '排序号', width: 90 },
])
</script>
<template>
<div class="mm-panel">
<div class="mm-panel__toolbar">
<Button type="outline" :disabled="loading" @click="groupManagerVisible = true">
分组管理
</Button>
<Button type="outline" :disabled="loading || !editConfig.length" @click="syncToEdit">
同步到编辑显示
</Button>
<Button type="outline" :disabled="loading || !queryConfig.length" @click="syncToQuery">
同步到查询配置
</Button>
</div>
<div class="mm-panel__body">
<FmsTable
v-model:columns="columns"
:data-source="localList"
:editable="!loading"
:row-draggable="!loading"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row.b_id))"
@change="emitUpdate"
@row-order-change="onRowOrderChange"
/>
</div>
<FieldGroupManager
v-model:open="groupManagerVisible"
:module-id="moduleId"
:groups="groups"
:referenced-group-ids="referencedGroupIds"
@saved="(groups, idMapping) => emit('groupsSaved', groups, idMapping)"
/>
</div>
</template>
<style lang="scss" scoped>
.mm-panel {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-panel__toolbar {
display: flex;
flex: none;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 8px;
}
.mm-panel__body {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
</style>
@@ -0,0 +1,237 @@
<script setup>
/**
* 权限配置面板(s_power):模块操作权限点(b_power_type=action)。
* i18n 键按 power.{moduleCode}.{powerCode} 自动同步。
*/
import { computed, h, nextTick, ref, shallowRef, watch } from 'vue'
import { Languages } from '@lucide/vue'
import Message from '@/components/ui/message/message-manager'
import Modal from '@/components/ui/modal/modal.vue'
import Button from '@/components/ui/button/button.vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { buildModuleItemI18nKey, syncModuleItemI18nKeys } from '@/utils/i18n'
import { createTempId } from '@/utils/tempId'
import I18nQuickSetModal from './I18nQuickSetModal.vue'
const props = defineProps({
moduleId: { type: String, required: true },
moduleCode: { type: String, default: '' },
powers: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
/** 权限点类型:模块操作 action / 菜单访问 menu */
powerType: { type: String, default: 'action' },
/** 资源编码(b_resource):默认取 moduleCode */
resource: { type: String, default: '' },
/** i18n 键前缀:power / menu */
i18nPrefix: { type: String, default: 'power' },
})
const emit = defineEmits(['update', 'i18nSaved'])
const localPowers = ref([])
const activeKey = ref(null)
const tableRef = ref(null)
watch(
() => [props.powers, props.moduleCode],
([val, moduleCode]) => {
if (val === localPowers.value) return
const rows = val?.map((row) => ({ ...row })) || []
const changed = syncModuleItemI18nKeys(rows, props.i18nPrefix, moduleCode, 'b_code')
localPowers.value = rows
if (changed) emit('update', rows)
},
{ immediate: true },
)
function emitUpdate() {
emit('update', localPowers.value)
}
async function addRow() {
if (props.loading) return
const newId = createTempId()
const newRow = {
b_id: newId,
b_module_id: props.moduleId,
b_code: '',
b_name: '',
b_i18n: null,
b_power_type: props.powerType,
b_resource: props.resource || props.moduleCode || null,
b_operation: props.powerType === 'menu' ? 'access' : 'execute',
b_xh: (localPowers.value.length + 1) * 10,
b_canuse: 1,
}
localPowers.value.push(newRow)
emitUpdate()
activeKey.value = String(newId)
await nextTick()
tableRef.value?.editRow?.(String(newId), 'b_code')
}
function deleteRow(record) {
if (props.loading) return
localPowers.value = localPowers.value.filter((r) => r.b_id !== record.b_id)
selectedRowKeys.value = selectedRowKeys.value.filter((x) => x !== String(record.b_id))
emitUpdate()
}
const selectedRowKeys = ref([])
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys
},
}))
function batchDelete() {
if (props.loading) return
const keys = selectedRowKeys.value
if (!keys.length) return
const delSet = new Set(keys)
localPowers.value = localPowers.value.filter((r) => !delSet.has(String(r.b_id)))
selectedRowKeys.value = []
if (activeKey.value && delSet.has(activeKey.value)) activeKey.value = ''
emitUpdate()
Message.success(`已删除 ${keys.length} 条权限`)
}
function onCellChange() {
syncModuleItemI18nKeys(localPowers.value, props.i18nPrefix, props.moduleCode, 'b_code')
emitUpdate()
}
function openI18n(row) {
const key = buildModuleItemI18nKey(props.i18nPrefix, props.moduleCode, row.b_code)
if (!key) {
Message.warning('请先填写操作编码')
return
}
i18nModalRef.value?.open(key, row.b_name || '')
}
const I18nKeyCell = ({ row, cellValue }) =>
h('span', { class: 'mm-i18n-cell' }, [
h('span', { class: 'mm-i18n-cell__text' }, cellValue ?? ''),
h(
'button',
{
type: 'button',
title: '设置多语言',
class: 'mm-i18n-cell__btn',
onClick: (event) => {
event.stopPropagation()
openI18n(row)
},
},
[h(Languages, { size: 14 })],
),
])
const columns = shallowRef([
{ dataIndex: 'b_code', title: '操作编码', width: 160, editor: { type: 'input' } },
{ dataIndex: 'b_name', title: '操作名称', width: 160, editor: { type: 'input' } },
{ dataIndex: 'b_i18n', title: '多语言键', width: 200, customCell: I18nKeyCell },
{ dataIndex: 'b_xh', title: '排序', width: 90, editor: { type: 'input', inputType: 'number' } },
{
dataIndex: 'b_canuse',
title: '启用',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{
dataIndex: 'actions',
title: '操作',
width: 70,
actions: [
{
type: 'delete',
label: '删除',
confirm: '确定删除该权限点?',
onClick: (row) => deleteRow(row),
},
],
},
])
</script>
<template>
<div class="mm-panel">
<div class="mm-panel__toolbar">
<Button type="primary" :disabled="loading" @click="addRow">新增权限</Button>
<Button type="danger" :disabled="loading || !selectedRowKeys.length" @click="batchDelete">
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
</Button>
</div>
<div class="mm-panel__body">
<FmsTable
ref="tableRef"
v-model:columns="columns"
:data-source="localPowers"
:editable="!loading"
row-key="b_id"
:active-row-key="activeKey"
:row-selection="rowSelection"
@row-click="(_, row) => (activeKey = String(row.b_id))"
@change="onCellChange"
/>
</div>
<I18nQuickSetModal ref="i18nModalRef" @saved="(key) => emit('i18nSaved', key)" />
</div>
</template>
<style lang="scss" scoped>
.mm-panel {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-panel__toolbar {
display: flex;
flex: none;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 8px;
}
.mm-panel__body {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-i18n-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
width: 100%;
}
.mm-i18n-cell__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mm-i18n-cell__btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
border: none;
background: transparent;
color: var(--fms-text-secondary);
cursor: pointer;
&:hover {
color: var(--fms-primary);
}
}
</style>
@@ -0,0 +1,160 @@
<script setup>
/**
* 查询配置面板(s_field_query):查询控件/操作符/默认值/启用/排序,支持行拖拽与新增。
*/
import { computed, ref, shallowRef, watch } from 'vue'
import Button from '@/components/ui/button/button.vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { createTempId } from '@/utils/tempId'
const props = defineProps({
moduleId: { type: String, required: true },
queryConfig: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['update'])
const COMPONENT_OPTIONS = [
{ label: '输入框', value: 'input' },
{ label: '下拉选择', value: 'select' },
{ label: '日期选择', value: 'date' },
{ label: '日期时间', value: 'datetime' },
{ label: '数字', value: 'number' },
{ label: '复选框', value: 'checkbox' },
]
const OPERATOR_OPTIONS = [
{ label: '等于 (eq)', value: 'eq' },
{ label: '模糊 (like)', value: 'like' },
{ label: '大于 (gt)', value: 'gt' },
{ label: '大于等于 (ge)', value: 'ge' },
{ label: '小于 (lt)', value: 'lt' },
{ label: '小于等于 (le)', value: 'le' },
{ label: '包含 (in)', value: 'in' },
{ label: '范围 (between)', value: 'between' },
]
const localQuery = ref([])
const activeKey = ref(null)
watch(
() => props.queryConfig,
(val) => {
if (val === localQuery.value) return
localQuery.value = val?.map((row) => ({ ...row })) || []
},
{ immediate: true },
)
function emitUpdate() {
emit('update', localQuery.value)
}
function onRowOrderChange(rows) {
const sorted = rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
localQuery.value = sorted
emitUpdate()
}
const fieldNameMap = computed(() => {
const map = new Map()
for (const field of props.fields || []) {
map.set(String(field.b_id), field.b_name || field.b_field)
}
return map
})
function addRow() {
if (props.loading) return
localQuery.value.push({
b_id: createTempId(),
b_module_id: props.moduleId,
b_field_module_id: props.moduleId,
b_field_id: null,
b_relation_id: null,
b_component: 'input',
b_operator: 'eq',
b_default_value: null,
b_xh: (localQuery.value.length + 1) * 10,
b_canuse: 1,
})
emitUpdate()
}
const columns = shallowRef([
{
dataIndex: 'b_field_id',
title: '字段',
width: 180,
customCell: ({ row }) => fieldNameMap.value.get(String(row.b_field_id)) || row.b_field_id || '',
},
{
dataIndex: 'b_component',
title: '查询组件',
width: 150,
editor: { type: 'select', options: COMPONENT_OPTIONS },
},
{
dataIndex: 'b_operator',
title: '操作符',
width: 150,
editor: { type: 'select', options: OPERATOR_OPTIONS },
},
{ dataIndex: 'b_default_value', title: '默认查询值', width: 150, editor: { type: 'input' } },
{
dataIndex: 'b_canuse',
title: '启用',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{ dataIndex: 'b_xh', title: '排序号', width: 90 },
])
</script>
<template>
<div class="mm-panel">
<div class="mm-panel__toolbar">
<Button type="primary" :disabled="loading" @click="addRow">新增查询条件</Button>
</div>
<div class="mm-panel__body">
<FmsTable
v-model:columns="columns"
:data-source="localQuery"
:editable="!loading"
:row-draggable="!loading"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row.b_id))"
@change="emitUpdate"
@row-order-change="onRowOrderChange"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.mm-panel {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-panel__toolbar {
display: flex;
flex: none;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 8px;
}
.mm-panel__body {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
</style>
File diff suppressed because it is too large. Load diff