2026-05-06 17:50:29 +08:00
|
|
|
<script setup lang="ts">
|
2026-05-09 18:15:10 +08:00
|
|
|
import { type Ref, computed, inject, ref } from 'vue';
|
2026-05-18 16:49:12 +08:00
|
|
|
import { useAuth } from '@/hooks/business/auth';
|
2026-05-06 17:50:29 +08:00
|
|
|
|
|
|
|
|
defineOptions({ name: 'ModuleTreeNode' });
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
module: Api.Product.RequirementModule;
|
|
|
|
|
level?: number;
|
|
|
|
|
selectedModuleId?: string;
|
|
|
|
|
editingNodeId?: string | undefined;
|
|
|
|
|
editingName?: string;
|
|
|
|
|
addingChildParentId?: string | undefined;
|
|
|
|
|
newChildModuleName?: string;
|
|
|
|
|
rootModuleId?: string;
|
|
|
|
|
moduleRequirementCountMap?: Map<string, number>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
|
|
|
level: 0
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits([
|
|
|
|
|
'select',
|
|
|
|
|
'edit',
|
|
|
|
|
'editConfirm',
|
|
|
|
|
'editCancel',
|
|
|
|
|
'delete',
|
|
|
|
|
'addChild',
|
|
|
|
|
'addChildConfirm',
|
|
|
|
|
'addChildCancel',
|
|
|
|
|
'updateEditingName',
|
|
|
|
|
'updateNewChildModuleName'
|
|
|
|
|
]);
|
|
|
|
|
|
2026-05-18 16:49:12 +08:00
|
|
|
const { hasObjectAuth } = useAuth();
|
|
|
|
|
const isRootModule = computed(() => props.module.id === props.rootModuleId);
|
|
|
|
|
|
|
|
|
|
const hasAnyActionPermission = computed(() => {
|
|
|
|
|
if (isRootModule.value) {
|
|
|
|
|
return hasObjectAuth('project:product:create');
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
hasObjectAuth('project:product:create') ||
|
|
|
|
|
hasObjectAuth('project:product:update') ||
|
|
|
|
|
hasObjectAuth('project:product:delete')
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-09 13:42:04 +08:00
|
|
|
const collapsedModuleIds = inject<Ref<Set<string>>>('collapsedModuleIds', ref(new Set()));
|
|
|
|
|
const toggleCollapse = inject<(id: string) => void>('toggleCollapse', () => {});
|
2026-05-06 17:50:29 +08:00
|
|
|
|
|
|
|
|
const isSelected = computed(() => props.selectedModuleId === props.module.id);
|
|
|
|
|
const isEditing = computed(() => props.editingNodeId === props.module.id);
|
|
|
|
|
const isAddingChild = computed(() => props.addingChildParentId === props.module.id);
|
|
|
|
|
const hasChildren = computed(() => props.module.children && props.module.children.length > 0);
|
2026-05-09 18:15:10 +08:00
|
|
|
const isCollapsed = computed(() =>
|
|
|
|
|
hasChildren.value && props.module.id ? collapsedModuleIds.value.has(props.module.id) : false
|
|
|
|
|
);
|
2026-05-06 17:50:29 +08:00
|
|
|
|
|
|
|
|
const hasRequirements = computed(() => {
|
|
|
|
|
const moduleId = props.module.id;
|
|
|
|
|
if (!moduleId || !props.moduleRequirementCountMap) return false;
|
|
|
|
|
return (props.moduleRequirementCountMap.get(moduleId) || 0) > 0;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const canDeleteModule = computed(() => !hasChildren.value && !hasRequirements.value);
|
|
|
|
|
|
|
|
|
|
const indentStyle = computed(() => {
|
|
|
|
|
if (props.level === 0) return {};
|
|
|
|
|
const indent = 24 + (props.level - 1) * 24;
|
|
|
|
|
return {
|
|
|
|
|
width: `calc(100% - ${indent}px)`,
|
|
|
|
|
marginLeft: `${indent}px`
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function handleClick() {
|
|
|
|
|
if (props.editingNodeId || props.addingChildParentId) return;
|
|
|
|
|
emit('select', props.module.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleStartEdit() {
|
|
|
|
|
emit('edit', props.module);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleEditConfirm() {
|
|
|
|
|
emit('editConfirm', props.module);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleEditCancel() {
|
|
|
|
|
emit('editCancel');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleStartAddChild() {
|
|
|
|
|
emit('addChild', props.module);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleDelete() {
|
|
|
|
|
emit('delete', props.module);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleAddChildConfirm() {
|
|
|
|
|
emit('addChildConfirm');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleAddChildCancel() {
|
|
|
|
|
emit('addChildCancel');
|
|
|
|
|
}
|
2026-05-09 13:42:04 +08:00
|
|
|
|
|
|
|
|
function handleToggle() {
|
|
|
|
|
if (props.module.id) {
|
|
|
|
|
toggleCollapse(props.module.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-06 17:50:29 +08:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<div class="module-tree-node">
|
|
|
|
|
<div
|
|
|
|
|
class="module-tree-item"
|
|
|
|
|
:class="{
|
|
|
|
|
'is-root': isRootModule,
|
|
|
|
|
'is-active': isSelected,
|
|
|
|
|
'is-editing': isEditing
|
|
|
|
|
}"
|
|
|
|
|
:style="indentStyle"
|
|
|
|
|
@click="handleClick"
|
|
|
|
|
>
|
2026-05-09 18:15:10 +08:00
|
|
|
<div
|
|
|
|
|
class="module-tree-item__toggle"
|
|
|
|
|
:class="{ 'is-expanded': hasChildren && !isCollapsed }"
|
|
|
|
|
@click.stop="handleToggle"
|
|
|
|
|
>
|
2026-05-09 13:42:04 +08:00
|
|
|
<icon-ic-round-chevron-right v-if="hasChildren" class="text-14px" />
|
|
|
|
|
</div>
|
2026-05-06 17:50:29 +08:00
|
|
|
<div class="module-tree-item__icon">
|
|
|
|
|
<icon-mdi-folder-open v-if="isRootModule" class="text-16px" />
|
|
|
|
|
<icon-mdi-folder-outline v-else class="text-16px" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="module-tree-item__content">
|
2026-05-13 23:09:35 +08:00
|
|
|
<ElTooltip v-if="!isEditing" :content="module.moduleName" placement="top" :show-after="500">
|
|
|
|
|
<span class="module-tree-item__label">{{ module.moduleName }}</span>
|
|
|
|
|
</ElTooltip>
|
2026-05-06 17:50:29 +08:00
|
|
|
<ElInput
|
|
|
|
|
v-else
|
|
|
|
|
:model-value="editingName"
|
|
|
|
|
size="small"
|
|
|
|
|
class="module-tree-item__input"
|
|
|
|
|
placeholder="请输入模块名"
|
|
|
|
|
@update:model-value="emit('updateEditingName', $event)"
|
|
|
|
|
@blur="handleEditConfirm"
|
|
|
|
|
@keyup.enter="handleEditConfirm"
|
|
|
|
|
@keyup.esc="handleEditCancel"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-18 16:49:12 +08:00
|
|
|
<div v-if="!isEditing && hasAnyActionPermission" class="module-tree-item__actions" @click.stop>
|
2026-05-06 17:50:29 +08:00
|
|
|
<ElDropdown trigger="click">
|
|
|
|
|
<ElButton text size="small" class="module-tree-item__more-btn">
|
|
|
|
|
<icon-mdi-dots-horizontal class="text-14px" />
|
|
|
|
|
</ElButton>
|
|
|
|
|
<template #dropdown>
|
|
|
|
|
<ElDropdownMenu>
|
2026-05-18 16:49:12 +08:00
|
|
|
<ElDropdownItem v-if="hasObjectAuth('project:product:create')" @click="handleStartAddChild">
|
2026-05-06 17:50:29 +08:00
|
|
|
<div class="flex items-center gap-6px">
|
|
|
|
|
<icon-ic-round-plus class="text-14px" />
|
|
|
|
|
<span>新增子模块</span>
|
|
|
|
|
</div>
|
|
|
|
|
</ElDropdownItem>
|
2026-05-18 16:49:12 +08:00
|
|
|
<ElDropdownItem v-if="!isRootModule && hasObjectAuth('project:product:update')" @click="handleStartEdit">
|
2026-05-06 17:50:29 +08:00
|
|
|
<div class="flex items-center gap-6px">
|
|
|
|
|
<icon-mdi-pencil-outline class="text-14px" />
|
|
|
|
|
<span>编辑</span>
|
|
|
|
|
</div>
|
|
|
|
|
</ElDropdownItem>
|
|
|
|
|
<ElDropdownItem
|
2026-05-18 16:49:12 +08:00
|
|
|
v-if="!isRootModule && canDeleteModule && hasObjectAuth('project:product:delete')"
|
2026-05-06 17:50:29 +08:00
|
|
|
divided
|
|
|
|
|
@click="handleDelete"
|
|
|
|
|
>
|
|
|
|
|
<div class="flex items-center gap-6px text-error">
|
|
|
|
|
<icon-mdi-delete-outline class="text-14px" />
|
|
|
|
|
<span>删除</span>
|
|
|
|
|
</div>
|
|
|
|
|
</ElDropdownItem>
|
|
|
|
|
</ElDropdownMenu>
|
|
|
|
|
</template>
|
|
|
|
|
</ElDropdown>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-09 13:42:04 +08:00
|
|
|
<template v-if="hasChildren && !isCollapsed">
|
2026-05-06 17:50:29 +08:00
|
|
|
<ModuleTreeNode
|
|
|
|
|
v-for="child in module.children"
|
|
|
|
|
:key="child.id"
|
|
|
|
|
:module="child"
|
|
|
|
|
:level="level + 1"
|
|
|
|
|
:selected-module-id="selectedModuleId"
|
|
|
|
|
:editing-node-id="editingNodeId"
|
|
|
|
|
:editing-name="editingName"
|
|
|
|
|
:adding-child-parent-id="addingChildParentId"
|
|
|
|
|
:new-child-module-name="newChildModuleName"
|
|
|
|
|
:root-module-id="rootModuleId"
|
|
|
|
|
:module-requirement-count-map="moduleRequirementCountMap"
|
|
|
|
|
@select="emit('select', $event)"
|
|
|
|
|
@edit="emit('edit', $event)"
|
|
|
|
|
@edit-confirm="emit('editConfirm', $event)"
|
|
|
|
|
@edit-cancel="emit('editCancel')"
|
|
|
|
|
@delete="emit('delete', $event)"
|
|
|
|
|
@add-child="emit('addChild', $event)"
|
|
|
|
|
@add-child-confirm="emit('addChildConfirm')"
|
|
|
|
|
@add-child-cancel="emit('addChildCancel')"
|
|
|
|
|
@update-editing-name="emit('updateEditingName', $event)"
|
|
|
|
|
@update-new-child-module-name="emit('updateNewChildModuleName', $event)"
|
|
|
|
|
/>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
v-if="isAddingChild"
|
|
|
|
|
class="module-tree-item module-tree-item--new"
|
|
|
|
|
:style="{
|
|
|
|
|
width: indentStyle.width,
|
|
|
|
|
marginLeft: level === 0 ? '24px' : `calc(24px + ${level * 24}px)`
|
|
|
|
|
}"
|
|
|
|
|
>
|
|
|
|
|
<div class="module-tree-item__icon">
|
|
|
|
|
<icon-mdi-folder-plus-outline class="text-16px" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="module-tree-item__content">
|
|
|
|
|
<ElInput
|
|
|
|
|
:model-value="newChildModuleName"
|
|
|
|
|
size="small"
|
|
|
|
|
class="new-child-module-input module-tree-item__input"
|
|
|
|
|
placeholder="请输入模块名"
|
|
|
|
|
@update:model-value="emit('updateNewChildModuleName', $event)"
|
|
|
|
|
@blur="handleAddChildConfirm"
|
|
|
|
|
@keyup.enter="handleAddChildConfirm"
|
|
|
|
|
@keyup.esc="handleAddChildCancel"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.module-tree-node {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item {
|
2026-05-18 16:49:12 +08:00
|
|
|
position: relative;
|
2026-05-06 17:50:29 +08:00
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
2026-05-18 16:49:12 +08:00
|
|
|
gap: 8px;
|
|
|
|
|
min-height: 36px;
|
|
|
|
|
padding: 6px 12px;
|
|
|
|
|
padding-left: 16px;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
color: #475569;
|
2026-05-06 17:50:29 +08:00
|
|
|
font-size: 14px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition:
|
2026-05-18 16:49:12 +08:00
|
|
|
background-color 0.15s ease,
|
|
|
|
|
color 0.15s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item::before {
|
|
|
|
|
content: '';
|
|
|
|
|
position: absolute;
|
|
|
|
|
left: 0;
|
|
|
|
|
top: 50%;
|
|
|
|
|
transform: translateY(-50%);
|
|
|
|
|
width: 3px;
|
|
|
|
|
height: 0;
|
|
|
|
|
border-radius: 0 2px 2px 0;
|
|
|
|
|
background-color: transparent;
|
|
|
|
|
transition:
|
|
|
|
|
height 0.15s ease,
|
|
|
|
|
background-color 0.15s ease;
|
2026-05-06 17:50:29 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item:hover {
|
2026-05-18 16:49:12 +08:00
|
|
|
background-color: #f1f5f9;
|
2026-05-06 17:50:29 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item.is-active {
|
2026-05-18 16:49:12 +08:00
|
|
|
background-color: #f0fdfa;
|
|
|
|
|
color: #0d9488;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item.is-active::before {
|
|
|
|
|
height: 60%;
|
|
|
|
|
background-color: #14b8a6;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item.is-root {
|
2026-05-06 17:50:29 +08:00
|
|
|
font-weight: 600;
|
2026-05-18 16:49:12 +08:00
|
|
|
color: #1e293b;
|
2026-05-06 17:50:29 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-18 16:49:12 +08:00
|
|
|
.module-tree-item.is-root:hover {
|
|
|
|
|
background-color: #f8fafc;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item.is-root.is-active {
|
|
|
|
|
background-color: #f0fdfa;
|
|
|
|
|
color: #0d9488;
|
2026-05-06 17:50:29 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item--new {
|
2026-05-18 16:49:12 +08:00
|
|
|
border: 1px dashed #cbd5e1;
|
|
|
|
|
background-color: #f8fafc;
|
2026-05-06 17:50:29 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-18 16:49:12 +08:00
|
|
|
.module-tree-item--new:hover {
|
|
|
|
|
background-color: #f1f5f9;
|
2026-05-06 17:50:29 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-09 13:42:04 +08:00
|
|
|
.module-tree-item__toggle {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
2026-05-18 16:49:12 +08:00
|
|
|
width: 18px;
|
|
|
|
|
height: 18px;
|
2026-05-09 13:42:04 +08:00
|
|
|
flex-shrink: 0;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
user-select: none;
|
|
|
|
|
transition: transform 0.2s ease;
|
2026-05-18 16:49:12 +08:00
|
|
|
color: #94a3b8;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item__toggle:hover {
|
|
|
|
|
background-color: #e2e8f0;
|
|
|
|
|
color: #64748b;
|
2026-05-09 13:42:04 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item__toggle.is-expanded svg {
|
|
|
|
|
transform: rotate(90deg);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 16:49:12 +08:00
|
|
|
.module-tree-item__icon {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item.is-active .module-tree-item__icon {
|
|
|
|
|
color: #14b8a6;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 17:50:29 +08:00
|
|
|
.module-tree-item__content {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item__label {
|
|
|
|
|
display: block;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item__input {
|
|
|
|
|
width: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item__input :deep(.el-input__inner) {
|
2026-05-18 16:49:12 +08:00
|
|
|
height: 26px;
|
2026-05-06 17:50:29 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item__actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
opacity: 0;
|
2026-05-18 16:49:12 +08:00
|
|
|
transition: opacity 0.15s ease;
|
2026-05-06 17:50:29 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item:hover .module-tree-item__actions {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item.is-editing .module-tree-item__actions {
|
|
|
|
|
opacity: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item__more-btn {
|
|
|
|
|
padding: 4px;
|
2026-05-18 16:49:12 +08:00
|
|
|
border-radius: 4px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-item__more-btn:hover {
|
|
|
|
|
background-color: #e2e8f0;
|
2026-05-06 17:50:29 +08:00
|
|
|
}
|
|
|
|
|
</style>
|