refactor(module-management): R2a UI 归一——小文件替换为 ui 组件

- ui Input 补 defineExpose(focus/select/blur),供内联重命名程序化聚焦
- index.vue 树头新建按钮、ModuleBasicPanel 多语言按钮 → ui Button
- StructureTreeNode 内联重命名 input → ui Input,六个操作钮 → ui Button
- ModuleStructureTree 自绘右键菜单 → ui Dropdown(trigger=contextmenu),
  删除自绘定位/关闭监听与 .mst-context-* 样式
This commit is contained in:
oneao committed 2026-08-28 16:25:58 +08:00
1 parent 2dba857880
commit ce3a9d87f8
5 files changed
+108 -221

No files matched your search

@@ -34,6 +34,7 @@ const slots = useSlots()
/* ---------- 公共状态 ---------- */
const focused = ref(false)
const hovered = ref(false)
const inputEl = ref(null)
/* ---------- 公共 computed ---------- */
const isTextarea = computed(() => props.type === 'textarea')
@@ -255,6 +256,13 @@ onMounted(() => {
nextTick(measureDefaultTextareaHeight)
})
/* 程序化聚焦/选中文本(内联重命名等场景需要 focus + select) */
defineExpose({
focus: () => inputEl.value?.focus(),
select: () => inputEl.value?.select(),
blur: () => inputEl.value?.blur(),
})
watch(
() => [props.rows, isTextarea.value],
() => {
@@ -285,6 +293,7 @@ onBeforeUnmount(() => {
<input
v-if="!isTextarea"
ref="inputEl"
class="input"
data-slot="input"
:type="inputType"
@@ -6,7 +6,7 @@
*/
import { computed, ref, watch } from 'vue'
import { Languages } from '@lucide/vue'
import { Input, Select, Switch, Form, FormItem } from '@/components/ui'
import { Input, Select, Switch, Form, FormItem, Button } from '@/components/ui'
import { buildModuleI18nKey, validateModuleCode } from '@/utils/i18n'
import I18nQuickSetModal from './I18nQuickSetModal.vue'
@@ -152,14 +152,14 @@ function onI18nSaved(key) {
<FormItem label="多语言键" name="b_i18n" :span="2">
<div class="mm-basic__i18n">
<Input :model-value="formData.b_i18n" readonly />
<button
type="button"
<Button
type="ghost"
class="mm-basic__i18n-btn"
title="快速设置多语言"
@click="openI18nModal"
>
<Languages :size="16" />
</button>
</Button>
</div>
</FormItem>
@@ -14,7 +14,7 @@
* - 选中:受控 selectedKey,双向同步面板属性面板 / 预览高亮。
* - 键盘导航:roving tabindex(↑↓←→ / Enter / Space)。
*/
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, provide, ref, watch } from 'vue'
import {
Clipboard,
Copy,
@@ -25,6 +25,7 @@ import {
Trash2,
} from '@lucide/vue'
import draggable from 'vuedraggable'
import { Dropdown } from '@/components/ui'
import StructureTreeNode from './StructureTreeNode.vue'
import { useGroupTree } from '../../composables/useGroupTree'
import { useFieldLookup } from '../../composables/useFieldLookup'
@@ -523,12 +524,17 @@ function resetDragVisual() {
removeDragBadge()
}
/* ================= 右键菜单 ================= */
const contextState = ref(null) // { items, x, y, node, key }
const contextMenuEl = ref(null)
/* ================= 右键菜单(ui Dropdown,trigger=contextmenu,鼠标位置锚定) ================= */
// 右键目标节点:由 StructureTreeNode 在 contextmenu 冒泡前写入
const contextNode = ref(null)
function buildContextItems(node) {
if (node.key === 'g:ungrouped') return []
function setContextNode(node) {
contextNode.value = node
}
const contextItems = computed(() => {
const node = contextNode.value
if (!node || node.key === 'g:ungrouped') return []
if (node.kind === 'group') {
return [
{ key: 'add-sub', label: '新增子分组', icon: FolderPlus },
@@ -543,58 +549,14 @@ function buildContextItems(node) {
{ key: 'paste', label: '粘贴列属性', icon: Clipboard, disabled: !props.pasteEnabled },
{ key: 'delete', label: '删除列', icon: Trash2, danger: true },
]
}
function openContextMenu(node, event) {
const items = buildContextItems(node)
if (!items || !items.length) return
contextState.value = { items, x: event.clientX, y: event.clientY, node, key: node.key }
nextTick(() => {
const el = contextMenuEl.value
if (!el || !contextState.value) return
const rect = el.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
let { x, y } = contextState.value
if (x + rect.width > vw - 8) x = Math.max(8, x - rect.width)
if (y + rect.height > vh - 8) y = Math.max(8, y - rect.height)
contextState.value.x = x
contextState.value.y = y
})
}
function hideContextMenu() {
contextState.value = null
}
function onMenuItemClick(item, event) {
if (item.disabled) return
const state = contextState.value
emit('context-menu-click', {
item,
node: state?.node ?? null,
key: state?.key ?? null,
event,
})
hideContextMenu()
}
function onDocMouseDown(e) {
if (!contextState.value) return
const el = contextMenuEl.value
if (el && el.contains(e.target)) return
hideContextMenu()
}
function onDocKeydown(e) {
if (e.key === 'Escape') hideContextMenu()
}
onMounted(() => {
document.addEventListener('mousedown', onDocMouseDown, true)
document.addEventListener('keydown', onDocKeydown, true)
})
function onMenuItemClick({ item }) {
const node = contextNode.value
emit('context-menu-click', { item, node, key: node?.key ?? null })
}
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onDocMouseDown, true)
document.removeEventListener('keydown', onDocKeydown, true)
// 拖拽进行中卸载:清理鼠标/键盘监听与跟手卡片,避免泄漏
if (dragKey.value) cleanupDrag()
})
@@ -668,66 +630,44 @@ provide('moduleStructureTree', {
onNodeMove,
onNodeDelete,
onNodeAddSub,
openContextMenu,
setContextNode,
commitRename,
cancelRename,
})
</script>
<template>
<div class="mst-tree" role="tree" @keydown="onTreeKeydown" @dragleave="onRootDragLeave">
<draggable
:list="treeData"
item-key="key"
handle=".mst-node__grip"
:force-fallback="true"
:fallback-on-body="true"
fallback-class="mst-drag-fallback"
:animation="0"
:move="onSortMove"
@start="onSortStart"
@end="onSortEnd"
>
<template #item="{ element: n }">
<StructureTreeNode :node="n" :level="0" />
</template>
<template #footer>
<div v-if="!treeData.length" class="mst-tree__empty">
<slot name="empty">暂无分组</slot>
</div>
</template>
</draggable>
</div>
<!-- 右键菜单(挂载到 body,fixed 定位) -->
<Teleport to="body">
<Transition name="mst-context-fade">
<div
v-if="contextState"
ref="contextMenuEl"
class="mst-context-menu"
:style="{ left: contextState.x + 'px', top: contextState.y + 'px' }"
role="menu"
@mousedown.stop
<!-- 右键菜单:Dropdown 以鼠标位置为锚点弹出,节点 contextmenu 冒泡至此触发 -->
<Dropdown
trigger="contextmenu"
placement="bottom-start"
:items="contextItems"
@click="onMenuItemClick"
>
<div class="mst-tree" role="tree" @keydown="onTreeKeydown" @dragleave="onRootDragLeave">
<draggable
:list="treeData"
item-key="key"
handle=".mst-node__grip"
:force-fallback="true"
:fallback-on-body="true"
fallback-class="mst-drag-fallback"
:animation="0"
:move="onSortMove"
@start="onSortStart"
@end="onSortEnd"
>
<template v-for="(item, index) in contextState.items" :key="item.key || index">
<div v-if="item.divider" class="mst-context-menu__divider" role="separator"></div>
<button
v-else
type="button"
class="mst-context-menu__item"
:class="{ 'is-danger': item.danger, 'is-disabled': item.disabled }"
role="menuitem"
:disabled="item.disabled"
@click="(event) => onMenuItemClick(item, event)"
>
<component :is="item.icon" v-if="item.icon" :size="14" class="mst-context-menu__icon" />
<span>{{ item.label }}</span>
</button>
<template #item="{ element: n }">
<StructureTreeNode :node="n" :level="0" />
</template>
</div>
</Transition>
</Teleport>
<template #footer>
<div v-if="!treeData.length" class="mst-tree__empty">
<slot name="empty">暂无分组</slot>
</div>
</template>
</draggable>
</div>
</Dropdown>
</template>
<style scoped lang="scss">
@@ -747,70 +687,6 @@ provide('moduleStructureTree', {
font-size: 11px;
text-align: center;
}
/* ---------- 右键菜单 ---------- */
.mst-context-menu {
position: fixed;
z-index: 3000;
min-width: 140px;
padding: 4px;
background: var(--fms-card);
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
overflow: hidden;
}
.mst-context-menu__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 5px 10px;
border: none;
background: transparent;
color: var(--fms-text);
font-size: 13px;
line-height: 1.5;
text-align: left;
cursor: pointer;
&:hover:not(:disabled) {
background: var(--fms-secondary-hover);
}
&.is-danger {
color: #f53f3f;
}
&.is-disabled {
color: var(--fms-disabled-text);
cursor: not-allowed;
}
}
.mst-context-menu__icon {
flex: none;
}
.mst-context-menu__divider {
height: 1px;
margin: 4px 8px;
background: var(--fms-border);
}
.mst-context-fade-enter-active {
transition: opacity 0.12s ease;
}
.mst-context-fade-leave-active {
transition: opacity 0.08s ease;
}
.mst-context-fade-enter-from,
.mst-context-fade-leave-to {
opacity: 0;
}
</style>
<!-- 拖拽标题卡片(全局样式):createDragBadge 用 JS 动态创建元素并挂到 body,
@@ -6,6 +6,7 @@
import { computed, inject, onBeforeUnmount, onMounted, ref } from 'vue'
import { ArrowDown, ArrowUp, Check, ChevronRight, Folder, FolderPlus, GripVertical, Inbox, Table2, Trash2, X } from '@lucide/vue'
import draggable from 'vuedraggable'
import { Button, Input } from '@/components/ui'
const props = defineProps({
node: { type: Object, required: true },
@@ -93,8 +94,13 @@ function onDrop(e) {
ctx.handleDrop(props.node, e)
}
function onContextMenu(e) {
if (isUngrouped.value || ctx.loading.value) return
ctx.openContextMenu(props.node, e)
if (isUngrouped.value || ctx.loading.value) {
// 无菜单目标:阻止冒泡到 Dropdown 触发层,避免弹出空菜单
e.stopPropagation()
return
}
// 记录右键目标节点后放行冒泡,由父级 Dropdown(trigger=contextmenu) 在鼠标位置弹出
ctx.setContextNode(props.node)
}
function setEditInput(el) {
ctx.editInputRef.value = el
@@ -133,7 +139,7 @@ function onRenameCancel() {
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
@contextmenu.stop="onContextMenu"
@contextmenu="onContextMenu"
>
<span
v-if="canDrag"
@@ -160,13 +166,12 @@ function onRenameCancel() {
<div class="mst-node__content">
<!-- 分组带内联重命名 -->
<template v-if="isEditing">
<input
<Input
:ref="setEditInput"
v-model="draft"
class="mst-node__edit-input"
maxlength="100"
placeholder="分组名称"
draggable="false"
@click.stop
@keydown.stop
@keydown.enter.prevent="onRenameCommit"
@@ -174,24 +179,24 @@ function onRenameCancel() {
@blur="onRenameCommit"
/>
<span class="mst-node__edit-actions">
<button
type="button"
<Button
type="ghost"
class="mst-node__edit-btn"
title="确认"
aria-label="确认修改分组名"
@mousedown.prevent="onRenameCommit"
>
<Check :size="13" />
</button>
<button
type="button"
</Button>
<Button
type="ghost"
class="mst-node__edit-btn"
title="取消"
aria-label="取消修改分组名"
@mousedown.prevent="onRenameCancel"
>
<X :size="13" />
</button>
</Button>
</span>
</template>
<!-- 普通标题 -->
@@ -205,51 +210,47 @@ function onRenameCancel() {
<!-- 常驻操作:上移 / 下移 / 删除 -->
<span v-if="!isUngrouped" class="mst-node__actions">
<button
type="button"
<Button
type="ghost"
class="mst-node__act"
title="上移"
aria-label="上移"
draggable="false"
:disabled="!canMoveUp"
@click.stop="onMove(-1)"
>
<ArrowUp :size="12" />
</button>
<button
type="button"
</Button>
<Button
type="ghost"
class="mst-node__act"
title="下移"
aria-label="下移"
draggable="false"
:disabled="!canMoveDown"
@click.stop="onMove(1)"
>
<ArrowDown :size="12" />
</button>
<button
</Button>
<Button
v-if="isGroup && !isUngrouped"
type="button"
type="ghost"
class="mst-node__act"
title="新增子分组"
aria-label="新增子分组"
draggable="false"
:disabled="!canAddSub"
@click.stop="onAddSub"
>
<FolderPlus :size="12" />
</button>
<button
type="button"
</Button>
<Button
type="ghost"
class="mst-node__act is-danger"
title="删除"
aria-label="删除"
draggable="false"
:disabled="!canDelete"
@click.stop="onDelete"
>
<Trash2 :size="12" />
</button>
</Button>
</span>
</div>
@@ -438,22 +439,20 @@ function onRenameCancel() {
opacity: 0.85;
}
.mst-node__act {
/* 常驻操作按钮:20px 方形图标钮(覆盖 ui Button 默认尺寸) */
.mst-node__act.button {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 20px;
height: 20px;
min-height: 0;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--fms-text-secondary);
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease;
transform: none;
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--fms-primary) 10%, transparent);
@@ -471,18 +470,15 @@ function onRenameCancel() {
}
}
.mst-node__edit-input {
/* 内联重命名输入框:压缩 ui Input 的默认高度/内边距以适配 28px 行 */
.mst-node__edit-input.input-wrapper {
flex: 1 1 auto;
min-width: 80px;
height: 22px;
min-height: 0;
padding: 0 6px;
border: 1px solid var(--fms-primary);
border-radius: 4px;
background: var(--fms-card);
color: var(--fms-text);
font-size: 12px;
font-family: inherit;
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--fms-primary) 12%, transparent);
}
@@ -493,25 +489,31 @@ function onRenameCancel() {
flex: none;
}
.mst-node__edit-btn {
/* 确认/取消:20px 方形图标钮(覆盖 ui Button 默认尺寸) */
.mst-node__edit-btn.button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
min-height: 0;
padding: 0;
border: 1px solid var(--fms-border);
border-radius: 4px;
background: var(--fms-card);
color: var(--fms-text-secondary);
cursor: pointer;
transition:
border-color 150ms ease,
color 150ms ease;
transform: none;
&:hover {
&:hover:not(:disabled) {
border-color: var(--fms-primary);
color: var(--fms-primary);
background: var(--fms-card);
}
&:last-child:hover {
&:last-child:hover:not(:disabled) {
border-color: #d95757;
color: #d95757;
}
@@ -371,9 +371,9 @@ loadModuleTree()
</Input>
<div class="mm-tree-header__actions">
<Dropdown :items="moduleNewItems" @click="onModuleNewClick">
<button type="button" class="mm-tree-header__icon-btn" title="新建模块" aria-label="新建模块">
<Button type="ghost" class="mm-tree-header__icon-btn" title="新建模块" aria-label="新建模块">
<Plus :size="14" />
</button>
</Button>
</Dropdown>
</div>
</div>