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