Files
cn-rdms-web/src/views/product/requirement/modules/module-tree-node.vue

406 lines
10 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { type Ref, computed, inject, ref } from 'vue';
import { useAuth } from '@/hooks/business/auth';
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,
selectedModuleId: undefined,
editingNodeId: undefined,
editingName: undefined,
addingChildParentId: undefined,
newChildModuleName: undefined,
rootModuleId: undefined,
moduleRequirementCountMap: undefined
});
const emit = defineEmits([
'select',
'edit',
'editConfirm',
'editCancel',
'delete',
'addChild',
'addChildConfirm',
'addChildCancel',
'updateEditingName',
'updateNewChildModuleName'
]);
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')
);
});
const collapsedModuleIds = inject<Ref<Set<string>>>('collapsedModuleIds', ref(new Set()));
const toggleCollapse = inject<(id: string) => void>('toggleCollapse', () => {});
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 isCollapsed = computed(() =>
hasChildren.value && props.module.id ? collapsedModuleIds.value.has(props.module.id) : false
);
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');
}
function handleToggle() {
if (props.module.id) {
toggleCollapse(props.module.id);
}
}
</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__toggle"
:class="{ 'is-expanded': hasChildren && !isCollapsed }"
@click.stop="handleToggle"
>
<icon-ic-round-chevron-right v-if="hasChildren" class="text-14px" />
</div>
<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">
<ElTooltip v-if="!isEditing" :content="module.moduleName" placement="top" :show-after="500">
<span class="module-tree-item__label">{{ module.moduleName }}</span>
</ElTooltip>
<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="!isEditing && hasAnyActionPermission" class="module-tree-item__actions" @click.stop>
<ElTooltip v-if="hasObjectAuth('project:product:create')" content="新增子模块" placement="top">
<ElButton link type="primary" class="module-tree-item__action-btn" @click="handleStartAddChild">
<icon-mdi-plus class="text-14px" />
</ElButton>
</ElTooltip>
<ElTooltip v-if="!isRootModule && hasObjectAuth('project:product:update')" content="编辑" placement="top">
<ElButton link type="primary" class="module-tree-item__action-btn" @click="handleStartEdit">
<icon-mdi-pencil-outline class="text-14px" />
</ElButton>
</ElTooltip>
<ElPopconfirm
v-if="!isRootModule && canDeleteModule && hasObjectAuth('project:product:delete')"
title="确定删除该模块吗?"
@confirm="handleDelete"
>
<template #reference>
<span class="inline-flex" @click.stop>
<ElTooltip content="删除" placement="top">
<ElButton link type="danger" class="module-tree-item__action-btn">
<icon-mdi-delete-outline class="text-14px" />
</ElButton>
</ElTooltip>
</span>
</template>
</ElPopconfirm>
</div>
</div>
<template v-if="hasChildren && !isCollapsed">
<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 {
position: relative;
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 6px 12px;
padding-left: 16px;
border-radius: 8px;
color: #475569;
font-size: 14px;
cursor: pointer;
transition:
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;
}
.module-tree-item:hover {
background-color: #f1f5f9;
}
.module-tree-item.is-active {
background-color: #f0fdfa;
color: #0d9488;
font-weight: 500;
}
.module-tree-item.is-active::before {
height: 60%;
background-color: #14b8a6;
}
.module-tree-item.is-root {
font-weight: 600;
color: #1e293b;
}
.module-tree-item.is-root:hover {
background-color: #f8fafc;
}
.module-tree-item.is-root.is-active {
background-color: #f0fdfa;
color: #0d9488;
}
.module-tree-item--new {
border: 1px dashed #cbd5e1;
background-color: #f8fafc;
}
.module-tree-item--new:hover {
background-color: #f1f5f9;
}
.module-tree-item__toggle {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
user-select: none;
transition: transform 0.2s ease;
color: #94a3b8;
border-radius: 4px;
}
.module-tree-item__toggle:hover {
background-color: #e2e8f0;
color: #64748b;
}
.module-tree-item__toggle.is-expanded svg {
transform: rotate(90deg);
}
.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;
}
.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: 26px;
}
.module-tree-item__actions {
display: flex;
align-items: center;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s 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__action-btn {
padding: 2px;
min-width: auto;
height: auto;
margin-left: 2px;
line-height: 1;
}
.module-tree-item__action-btn:first-child {
margin-left: 0;
}
</style>