2026-05-06 17:50:29 +08:00
|
|
|
<script setup lang="ts">
|
2026-05-09 13:42:04 +08:00
|
|
|
import { computed, nextTick, provide, ref, watch } from 'vue';
|
2026-05-06 17:50:29 +08:00
|
|
|
import { ElMessageBox } from 'element-plus';
|
|
|
|
|
import {
|
|
|
|
|
fetchCreateRequirementModule,
|
|
|
|
|
fetchDeleteRequirementModule,
|
|
|
|
|
fetchGetRequirementModuleTree,
|
|
|
|
|
fetchUpdateRequirementModule
|
|
|
|
|
} from '@/service/api';
|
|
|
|
|
import { useCurrentProduct } from '../../shared/use-current-product';
|
|
|
|
|
import ModuleTreeNode from './module-tree-node.vue';
|
|
|
|
|
|
|
|
|
|
defineOptions({ name: 'RequirementModuleTree' });
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
requirementTree?: Api.Product.Requirement[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
|
|
|
requirementTree: () => []
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
interface Emits {
|
|
|
|
|
(e: 'select', moduleId: string | undefined): void;
|
|
|
|
|
(e: 'refresh'): void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<Emits>();
|
|
|
|
|
|
|
|
|
|
const { currentObjectId } = useCurrentProduct();
|
|
|
|
|
|
|
|
|
|
const loading = ref(false);
|
|
|
|
|
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
|
|
|
|
|
const selectedModuleId = ref<string | undefined>(undefined);
|
|
|
|
|
|
|
|
|
|
const rootModule = computed<Api.Product.RequirementModule | null>(() => {
|
|
|
|
|
if (moduleTree.value.length === 0) return null;
|
|
|
|
|
return moduleTree.value[0];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const editingNodeId = ref<string | undefined>(undefined);
|
|
|
|
|
const editingName = ref('');
|
|
|
|
|
|
|
|
|
|
const addingChildParentId = ref<string | undefined>(undefined);
|
|
|
|
|
const newChildModuleName = ref('');
|
|
|
|
|
|
2026-05-09 13:42:04 +08:00
|
|
|
const collapsedModuleIds = ref(new Set<string>());
|
|
|
|
|
|
|
|
|
|
function handleToggleCollapse(moduleId: string) {
|
|
|
|
|
const set = collapsedModuleIds.value;
|
|
|
|
|
if (set.has(moduleId)) {
|
|
|
|
|
set.delete(moduleId);
|
|
|
|
|
} else {
|
|
|
|
|
set.add(moduleId);
|
|
|
|
|
}
|
|
|
|
|
collapsedModuleIds.value = new Set(set);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
provide('collapsedModuleIds', collapsedModuleIds);
|
|
|
|
|
provide('toggleCollapse', handleToggleCollapse);
|
|
|
|
|
|
2026-05-06 17:50:29 +08:00
|
|
|
const moduleRequirementCountMap = computed(() => {
|
|
|
|
|
const countMap = new Map<string, number>();
|
|
|
|
|
|
|
|
|
|
function countRequirementsByModule(nodes: Api.Product.Requirement[]): void {
|
|
|
|
|
for (const node of nodes) {
|
|
|
|
|
const currentCount = countMap.get(node.moduleId) || 0;
|
|
|
|
|
countMap.set(node.moduleId, currentCount + 1);
|
|
|
|
|
if (node.children?.length) {
|
|
|
|
|
countRequirementsByModule(node.children);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (props.requirementTree?.length) {
|
|
|
|
|
countRequirementsByModule(props.requirementTree);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return countMap;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function loadModuleTree() {
|
|
|
|
|
if (!currentObjectId.value) {
|
|
|
|
|
moduleTree.value = [];
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
|
|
|
|
|
const { error, data } = await fetchGetRequirementModuleTree(currentObjectId.value);
|
|
|
|
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
|
|
|
|
if (error || !data) {
|
|
|
|
|
moduleTree.value = [];
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
moduleTree.value = data;
|
|
|
|
|
|
|
|
|
|
if (data.length > 0 && !selectedModuleId.value) {
|
|
|
|
|
selectedModuleId.value = data[0].id;
|
|
|
|
|
emit('select', data[0].id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleNodeSelect(moduleId: string) {
|
|
|
|
|
if (editingNodeId.value || addingChildParentId.value) return;
|
|
|
|
|
selectedModuleId.value = moduleId;
|
|
|
|
|
emit('select', moduleId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleStartEdit(module: Api.Product.RequirementModule) {
|
|
|
|
|
editingNodeId.value = module.id;
|
|
|
|
|
editingName.value = module.moduleName;
|
|
|
|
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
const input = document.querySelector('.module-tree-item.is-editing .el-input__inner') as HTMLInputElement;
|
|
|
|
|
input?.focus();
|
|
|
|
|
input?.select();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleEditConfirm(module: Api.Product.RequirementModule) {
|
|
|
|
|
const name = editingName.value.trim();
|
|
|
|
|
|
|
|
|
|
editingNodeId.value = undefined;
|
|
|
|
|
|
|
|
|
|
if (!name || name === module.moduleName) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await handleUpdateModuleName(module, name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleEditCancel() {
|
|
|
|
|
editingNodeId.value = undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleUpdateModuleName(module: Api.Product.RequirementModule, name: string) {
|
|
|
|
|
if (!currentObjectId.value) return;
|
|
|
|
|
|
|
|
|
|
const { error } = await fetchUpdateRequirementModule({
|
|
|
|
|
id: module.id,
|
|
|
|
|
productId: currentObjectId.value,
|
|
|
|
|
parentId: module.parentId,
|
|
|
|
|
moduleName: name,
|
|
|
|
|
remark: module.remark,
|
|
|
|
|
icon: module.icon,
|
|
|
|
|
sort: module.sort
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (error) return;
|
|
|
|
|
|
|
|
|
|
window.$message?.success('模块名称更新成功');
|
|
|
|
|
await loadModuleTree();
|
|
|
|
|
emit('refresh');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleStartAddChild(module: Api.Product.RequirementModule) {
|
2026-05-09 13:42:04 +08:00
|
|
|
if (addingChildParentId.value) return;
|
2026-05-06 17:50:29 +08:00
|
|
|
|
|
|
|
|
addingChildParentId.value = module.id;
|
|
|
|
|
newChildModuleName.value = '';
|
|
|
|
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
const input = document.querySelector('.new-child-module-input input') as HTMLInputElement;
|
|
|
|
|
input?.focus();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleAddChildConfirm() {
|
|
|
|
|
const name = newChildModuleName.value.trim();
|
|
|
|
|
|
|
|
|
|
const parentId = addingChildParentId.value;
|
|
|
|
|
addingChildParentId.value = undefined;
|
|
|
|
|
newChildModuleName.value = '';
|
|
|
|
|
|
|
|
|
|
if (!name) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!currentObjectId.value) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!parentId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { error } = await fetchCreateRequirementModule({
|
|
|
|
|
id: undefined,
|
|
|
|
|
productId: currentObjectId.value,
|
|
|
|
|
parentId,
|
|
|
|
|
moduleName: name,
|
|
|
|
|
remark: null,
|
|
|
|
|
icon: null,
|
|
|
|
|
sort: 0
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.$message?.success('子模块新增成功');
|
|
|
|
|
await loadModuleTree();
|
|
|
|
|
emit('refresh');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleAddChildCancel() {
|
|
|
|
|
addingChildParentId.value = undefined;
|
|
|
|
|
newChildModuleName.value = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleDeleteModule(module: Api.Product.RequirementModule) {
|
|
|
|
|
if (!currentObjectId.value) return;
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-18 16:49:12 +08:00
|
|
|
await ElMessageBox.confirm(`确定要删除模块 "${module.moduleName}" 吗?`, '删除确认', {
|
|
|
|
|
confirmButtonText: '确认删除',
|
|
|
|
|
cancelButtonText: '取消',
|
|
|
|
|
type: 'warning'
|
|
|
|
|
});
|
2026-05-06 17:50:29 +08:00
|
|
|
} catch {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { error } = await fetchDeleteRequirementModule({
|
|
|
|
|
id: module.id,
|
|
|
|
|
productId: currentObjectId.value
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (error) return;
|
|
|
|
|
|
|
|
|
|
window.$message?.success('模块删除成功');
|
|
|
|
|
|
|
|
|
|
if (selectedModuleId.value === module.id) {
|
|
|
|
|
const rootId = rootModule.value?.id || '';
|
|
|
|
|
selectedModuleId.value = rootId;
|
|
|
|
|
emit('select', rootId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await loadModuleTree();
|
|
|
|
|
emit('refresh');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => currentObjectId.value,
|
|
|
|
|
async id => {
|
|
|
|
|
if (id) {
|
|
|
|
|
selectedModuleId.value = '';
|
|
|
|
|
await loadModuleTree();
|
|
|
|
|
} else {
|
|
|
|
|
moduleTree.value = [];
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ immediate: true }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
defineExpose({
|
|
|
|
|
loadModuleTree,
|
|
|
|
|
selectedModuleId
|
|
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-05-13 23:09:35 +08:00
|
|
|
<ElCard class="requirement-module-tree-card card-wrapper">
|
|
|
|
|
<template #header>
|
|
|
|
|
<div class="module-tree-header">
|
|
|
|
|
<span class="module-tree-header__title">模块</span>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
2026-05-06 17:50:29 +08:00
|
|
|
|
|
|
|
|
<div class="module-tree-list">
|
|
|
|
|
<template v-for="data in moduleTree" :key="data.id">
|
|
|
|
|
<ModuleTreeNode
|
|
|
|
|
:module="data"
|
|
|
|
|
:level="0"
|
|
|
|
|
:selected-module-id="selectedModuleId"
|
|
|
|
|
:editing-node-id="editingNodeId"
|
|
|
|
|
:editing-name="editingName"
|
|
|
|
|
:adding-child-parent-id="addingChildParentId"
|
|
|
|
|
:new-child-module-name="newChildModuleName"
|
|
|
|
|
:root-module-id="rootModule?.id"
|
|
|
|
|
:module-requirement-count-map="moduleRequirementCountMap"
|
|
|
|
|
@select="handleNodeSelect"
|
|
|
|
|
@edit="handleStartEdit"
|
|
|
|
|
@edit-confirm="handleEditConfirm"
|
|
|
|
|
@edit-cancel="handleEditCancel"
|
|
|
|
|
@delete="handleDeleteModule"
|
|
|
|
|
@add-child="handleStartAddChild"
|
|
|
|
|
@add-child-confirm="handleAddChildConfirm"
|
|
|
|
|
@add-child-cancel="handleAddChildCancel"
|
|
|
|
|
@update-editing-name="editingName = $event"
|
|
|
|
|
@update-new-child-module-name="newChildModuleName = $event"
|
|
|
|
|
/>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
2026-05-13 23:09:35 +08:00
|
|
|
</ElCard>
|
2026-05-06 17:50:29 +08:00
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-05-13 23:09:35 +08:00
|
|
|
.requirement-module-tree-card {
|
|
|
|
|
height: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.requirement-module-tree-card :deep(.el-card__header) {
|
|
|
|
|
padding: 12px 16px;
|
2026-05-18 16:49:12 +08:00
|
|
|
border-bottom: 1px solid #f1f5f9;
|
2026-05-13 23:09:35 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.requirement-module-tree-card :deep(.el-card__body) {
|
2026-05-18 16:49:12 +08:00
|
|
|
padding: 12px 8px;
|
|
|
|
|
height: calc(100% - 49px);
|
2026-05-13 23:09:35 +08:00
|
|
|
overflow: hidden;
|
2026-05-06 17:50:29 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-header__title {
|
2026-05-18 16:49:12 +08:00
|
|
|
color: #1e293b;
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
font-weight: 600;
|
2026-05-06 17:50:29 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.module-tree-list {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
2026-05-18 16:49:12 +08:00
|
|
|
gap: 2px;
|
2026-05-06 17:50:29 +08:00
|
|
|
min-height: 0;
|
2026-05-13 23:09:35 +08:00
|
|
|
height: 100%;
|
|
|
|
|
overflow-y: auto;
|
2026-05-06 17:50:29 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-18 16:49:12 +08:00
|
|
|
.module-tree-list::-webkit-scrollbar {
|
|
|
|
|
width: 4px;
|
2026-05-06 17:50:29 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-18 16:49:12 +08:00
|
|
|
.module-tree-list::-webkit-scrollbar-track {
|
|
|
|
|
background: transparent;
|
2026-05-06 17:50:29 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-18 16:49:12 +08:00
|
|
|
.module-tree-list::-webkit-scrollbar-thumb {
|
|
|
|
|
background-color: #e2e8f0;
|
|
|
|
|
border-radius: 2px;
|
2026-05-06 17:50:29 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-18 16:49:12 +08:00
|
|
|
.module-tree-list::-webkit-scrollbar-thumb:hover {
|
|
|
|
|
background-color: #cbd5e1;
|
2026-05-06 17:50:29 +08:00
|
|
|
}
|
|
|
|
|
</style>
|