20260821230656
This commit is contained in:
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>
|
||||
+298
@@ -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>
|
||||
+217
@@ -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>
|
||||
+197
@@ -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>
|
||||
+160
@@ -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
Reference in new issue
Block a user