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:
1 parent
2dba857880
commit
ce3a9d87f8
5 files changed
+87
-200
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>
|
||||
|
||||
|
||||
+27
-151
@@ -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,13 +630,20 @@ provide('moduleStructureTree', {
|
||||
onNodeMove,
|
||||
onNodeDelete,
|
||||
onNodeAddSub,
|
||||
openContextMenu,
|
||||
setContextNode,
|
||||
commitRename,
|
||||
cancelRename,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 右键菜单: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"
|
||||
@@ -698,36 +667,7 @@ provide('moduleStructureTree', {
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</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,
|
||||
|
||||
+45
-43
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user