314 lines
8.2 KiB
Vue
314 lines
8.2 KiB
Vue
|
|
<script setup lang="ts">
|
||
|
|
import { computed } from 'vue';
|
||
|
|
|
||
|
|
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'
|
||
|
|
]);
|
||
|
|
|
||
|
|
const isRootModule = computed(() => props.module.id === props.rootModuleId);
|
||
|
|
|
||
|
|
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);
|
||
|
|
|
||
|
|
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');
|
||
|
|
}
|
||
|
|
</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"
|
||
|
|
>
|
||
|
|
<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">
|
||
|
|
<span v-if="!isEditing" class="module-tree-item__label">{{ module.moduleName }}</span>
|
||
|
|
<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>
|
||
|
|
|
||
|
|
<div v-if="!isRootModule && !isEditing" class="module-tree-item__actions">
|
||
|
|
<ElDropdown trigger="click">
|
||
|
|
<ElButton text size="small" class="module-tree-item__more-btn">
|
||
|
|
<icon-mdi-dots-horizontal class="text-14px" />
|
||
|
|
</ElButton>
|
||
|
|
<template #dropdown>
|
||
|
|
<ElDropdownMenu>
|
||
|
|
<ElDropdownItem
|
||
|
|
v-auth="{ code: 'project:product:create', source: 'object' }"
|
||
|
|
@click="handleStartAddChild"
|
||
|
|
>
|
||
|
|
<div class="flex items-center gap-6px">
|
||
|
|
<icon-ic-round-plus class="text-14px" />
|
||
|
|
<span>新增子模块</span>
|
||
|
|
</div>
|
||
|
|
</ElDropdownItem>
|
||
|
|
<ElDropdownItem v-auth="{ code: 'project:product:update', source: 'object' }" @click="handleStartEdit">
|
||
|
|
<div class="flex items-center gap-6px">
|
||
|
|
<icon-mdi-pencil-outline class="text-14px" />
|
||
|
|
<span>编辑</span>
|
||
|
|
</div>
|
||
|
|
</ElDropdownItem>
|
||
|
|
<ElDropdownItem
|
||
|
|
v-if="canDeleteModule"
|
||
|
|
v-auth="{ code: 'project:product:delete', source: 'object' }"
|
||
|
|
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>
|
||
|
|
|
||
|
|
<template v-if="hasChildren">
|
||
|
|
<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;
|
||
|
|
gap: 10px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.module-tree-item {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 10px;
|
||
|
|
min-height: 42px;
|
||
|
|
padding: 0 14px;
|
||
|
|
border: 1px solid rgb(226 232 240 / 92%);
|
||
|
|
border-radius: 14px;
|
||
|
|
background-color: rgb(248 250 252 / 96%);
|
||
|
|
color: rgb(71 85 105 / 94%);
|
||
|
|
font-size: 14px;
|
||
|
|
cursor: pointer;
|
||
|
|
transition:
|
||
|
|
border-color 0.2s ease,
|
||
|
|
background-color 0.2s ease,
|
||
|
|
color 0.2s ease,
|
||
|
|
transform 0.2s ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
.module-tree-item:hover {
|
||
|
|
transform: translateY(-1px);
|
||
|
|
border-color: rgb(148 163 184 / 56%);
|
||
|
|
}
|
||
|
|
|
||
|
|
.module-tree-item.is-active {
|
||
|
|
border-color: rgb(13 148 136 / 42%);
|
||
|
|
background-color: rgb(240 253 250 / 98%);
|
||
|
|
color: rgb(15 118 110 / 96%);
|
||
|
|
font-weight: 600;
|
||
|
|
}
|
||
|
|
|
||
|
|
.module-tree-item.is-root:not(.is-active) .module-tree-item__icon {
|
||
|
|
color: rgb(13 148 136 / 80%);
|
||
|
|
}
|
||
|
|
|
||
|
|
.module-tree-item--new {
|
||
|
|
border-style: dashed;
|
||
|
|
border-color: rgb(148 163 184 / 56%);
|
||
|
|
}
|
||
|
|
|
||
|
|
.module-tree-item__icon {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
flex-shrink: 0;
|
||
|
|
color: rgb(100 116 139 / 80%);
|
||
|
|
}
|
||
|
|
|
||
|
|
.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) {
|
||
|
|
height: 28px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.module-tree-item__actions {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
flex-shrink: 0;
|
||
|
|
opacity: 0;
|
||
|
|
transition: opacity 0.2s ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
.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;
|
||
|
|
}
|
||
|
|
</style>
|