refactor(module-management): R1 清场——删除无引用的 MenuDetailPanel/IconSelect 与过期 spec,useConfirm 合回 index.vue

This commit is contained in:
oneao committed 2026-08-28 16:15:30 +08:00
1 parent 91237c4a47
commit 2dba857880
5 files changed
+64 -3509

No files matched your search

@@ -1,60 +0,0 @@
<script setup>
/**
* 图标选择器:基于 Select(内置搜索过滤),选项为常用 lucide 图标子集。
* 图标名首字母大写,如 "Settings"、"Users"。
*/
import { ref } from 'vue'
import { Select } from '@/components/ui'
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', '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>
@@ -1,347 +0,0 @@
<script setup>
/**
* 菜单配置面板(s_menu):基础信息 + 权限 + 多语言。
* 权限点 b_power_type=menu、b_resource=菜单编码;i18n 键 menu.{code} / menu.{code}.{action}。
*/
import { computed, ref, watch } from 'vue'
import { Languages } from '@lucide/vue'
import { Input, Select, Switch, Form, FormItem } from '@/components/ui'
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 },
/** 当前激活的配置分区(由父级导航栏驱动) */
activeSection: { type: String, default: 'basic' },
})
const emit = defineEmits(['update', 'updatePowers', 'i18nSaved', 'update:activeSection'])
const formData = ref(null)
// 权限点数量(导航项徽标)
const powersCount = computed(() => props.powers?.length ?? 0)
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')
// 类型变为目录时,若停留在「权限配置」分区(目录不展示该分区)则切回基本信息
watch(
() => formData.value?.b_menu_type,
(type) => {
if (type === 'directory' && props.activeSection === 'powers') {
emit('update:activeSection', 'basic')
}
},
)
/* ---------- 校验规则:错误直接显示在对应 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() {
// 先切回基本信息分区,确保用户能看到标红字段
emit('update:activeSection', '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?.()
}
/** 父组件为 i18n inserts 分配真实主键后调用,回写行内 ID 避免二次保存重复插入 */
function applyI18nInsertedIds(inserts) {
i18nPanelRef.value?.applyInsertedIds?.(inserts)
}
defineExpose({ validate, saveI18n, markI18nSaved, applyI18nInsertedIds })
/* ---- 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">
<!-- 配置导航已上提至页面级中栏(index.vue),本组件只负责内容分区 -->
<div class="mm-config-content">
<div v-show="activeSection === 'basic'" class="mm-config-pane">
<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>
</div>
<div v-if="!isDirectory" v-show="activeSection === 'powers'" class="mm-config-pane">
<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)"
/>
</div>
<div v-show="activeSection === 'i18n'" class="mm-config-pane">
<ModuleI18nPanel
:module-id="String(formData.b_id || '')"
:i18n-items="i18nItems"
:loading="loading"
ref="i18nPanelRef"
/>
</div>
</div>
<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-config-content {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 12px 16px 16px;
}
.mm-config-pane {
flex: 1;
min-width: 0;
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>
@@ -1,82 +0,0 @@
/**
* 两套确认弹窗:
* - 通用确认(ui/modal 为组件式 API):showConfirm/onConfirmOk/confirmState
* - 切换对象未保存保护:switchConfirm/onSwitch*(保存并切换 / 放弃修改 / 取消)
*/
import { ref } from 'vue'
import { Message } from '@/components/ui'
export function useConfirm({ saveModule, moduleSelectedId }) {
/* ================= 通用确认弹窗 ================= */
const confirmVisible = ref(false)
const confirmState = ref(null)
// 确认操作进行中:按钮转圈、弹窗锁定,等待 onOk 完成后再关闭
const confirmSaving = ref(false)
function showConfirm({ title, content, okText, okType, onOk }) {
confirmState.value = { title, content, okText: okText || '确定', okType, onOk }
confirmVisible.value = true
}
async function onConfirmOk() {
const state = confirmState.value
if (!state?.onOk || confirmSaving.value) return
confirmSaving.value = true
try {
await state.onOk()
} catch (e) {
// onOk 内部已处理错误提示,这里兜底避免未捕获异常
Message.error('操作失败:' + (e?.message || e))
} finally {
confirmSaving.value = false
confirmVisible.value = false
}
}
/* ================= 切换模块未保存保护(保存并切换 / 放弃修改 / 取消) ================= */
const switchConfirm = ref(null) // { pendingId, name }
const switchSaving = ref(false)
function onSwitchCancel() {
switchConfirm.value = null
switchSaving.value = false
}
function onSwitchDiscard() {
const state = switchConfirm.value
if (!state) return
moduleSelectedId.value = state.pendingId
switchConfirm.value = null
}
async function onSwitchSave() {
const state = switchConfirm.value
if (!state || switchSaving.value) return
switchSaving.value = true
try {
const ok = await saveModule()
if (!ok) {
// 保存失败(原因已由 saveModule 内部提示):留在当前对象,弹窗不关闭,可重试或放弃
Message.warning('保存未完成,已留在当前对象,请处理后再切换')
return
}
moduleSelectedId.value = state.pendingId
switchConfirm.value = null
} finally {
switchSaving.value = false
}
}
return {
confirmVisible,
confirmState,
confirmSaving,
showConfirm,
onConfirmOk,
switchConfirm,
switchSaving,
onSwitchCancel,
onSwitchDiscard,
onSwitchSave,
}
}
@@ -3,8 +3,9 @@
* 模块管理:模块配置(单分区)。
* 模块配置:左侧模块树 + 右侧分节面板(基础/字段/列表/编辑/查询/自动编码/权限/多语言)。
* 数据直接经通用 API(loadDataApi / saveObjectApi / nextIdApi / describeApi)读写元数据表。
* 树/草稿/保存/确认弹窗逻辑分别抽到 composables/useModuleTree、useModuleConfig、
* useModuleSave、useConfirm;本文件只保留模板、状态接线与面板编排。
* 树/草稿/保存逻辑分别抽到 composables/useModuleTree、useModuleConfig、
* useModuleSave;确认弹窗状态机仅本页使用,直接内联在下方。
* 本文件保留模板、状态接线与面板编排。
*/
import { computed, ref } from 'vue'
import { useHotkeys } from '@/composables/useHotkeys'
@@ -33,12 +34,12 @@ import {
SplitterPanel,
Tabs,
Modal,
Message,
} from '@/components/ui'
import { buildModuleI18nKey, buildModuleItemI18nKey } from '@/utils/i18n'
import { useModuleTree, findModuleNode } from './composables/useModuleTree'
import { useModuleConfig } from './composables/useModuleConfig'
import { useModuleSave } from './composables/useModuleSave'
import { useConfirm } from './composables/useConfirm'
import ModuleBasicPanel from './components/ModuleBasicPanel.vue'
import ModuleFieldsPanel from './components/ModuleFieldsPanel.vue'
import ModuleListConfigPanel from './components/ModuleListConfigPanel.vue'
@@ -213,18 +214,66 @@ const { saveModule } = useModuleSave({
getFinalI18nItems: () => finalI18nItems.value,
})
const {
confirmVisible,
confirmState,
confirmSaving,
showConfirm,
onConfirmOk,
switchConfirm,
switchSaving,
onSwitchCancel,
onSwitchDiscard,
onSwitchSave,
} = useConfirm({ saveModule, moduleSelectedId })
/* ================= 确认弹窗状态机(仅本页使用,不抽 composable) ================= */
/* 通用确认弹窗(ui/modal 为组件式 API) */
const confirmVisible = ref(false)
const confirmState = ref(null)
// 确认操作进行中:按钮转圈、弹窗锁定,等待 onOk 完成后再关闭
const confirmSaving = ref(false)
function showConfirm({ title, content, okText, okType, onOk }) {
confirmState.value = { title, content, okText: okText || '确定', okType, onOk }
confirmVisible.value = true
}
async function onConfirmOk() {
const state = confirmState.value
if (!state?.onOk || confirmSaving.value) return
confirmSaving.value = true
try {
await state.onOk()
} catch (e) {
// onOk 内部已处理错误提示,这里兜底避免未捕获异常
Message.error('操作失败:' + (e?.message || e))
} finally {
confirmSaving.value = false
confirmVisible.value = false
}
}
/* 切换模块未保存保护(保存并切换 / 放弃修改 / 取消) */
const switchConfirm = ref(null) // { pendingId, name }
const switchSaving = ref(false)
function onSwitchCancel() {
switchConfirm.value = null
switchSaving.value = false
}
function onSwitchDiscard() {
const state = switchConfirm.value
if (!state) return
moduleSelectedId.value = state.pendingId
switchConfirm.value = null
}
async function onSwitchSave() {
const state = switchConfirm.value
if (!state || switchSaving.value) return
switchSaving.value = true
try {
const ok = await saveModule()
if (!ok) {
// 保存失败(原因已由 saveModule 内部提示):留在当前对象,弹窗不关闭,可重试或放弃
Message.warning('保存未完成,已留在当前对象,请处理后再切换')
return
}
moduleSelectedId.value = state.pendingId
switchConfirm.value = null
} finally {
switchSaving.value = false
}
}
const {
filteredModuleTree,
File diff suppressed because it is too large. Load diff