Bläddra i källkod

feat(project): 项目管理支持两层结构(顶层项目/子项目)

- project 表新增 parent_id(BIGINT UNSIGNED DEFAULT 0),存量数据自动为顶层(零风险)
- 后端: Project/ProjectDTO/ProjectVO 增加 parentId/level/isLeaf/children
- 新增 GET /project/tree 两层树形(父聚合子项目资源数); 列表页改用树
- saveOrUpdate 强制 <=2 层(父须顶层,禁自环/禁三层); delete 级联删子+解绑资源
- /active-list 改为只返回叶子(强制资源只挂子项目); 新增 /parent-options 仅顶层
- BusinessException 默认 code 由 500 改为 400, 业务校验不再显示"系统繁忙"
- 前端项目列表改 el-table 树形 + 新增/编辑表单加"父项目"下拉; 详情展示父项目
- 离线重编译重打包为 resource-platform-v5.jar 并重启(8080); 端到端验证通过
- schema.sql 同步 parent_id 列与 purchase_duration 最新注释
C 2 månader sedan
förälder
incheckning
6a0071702e

+ 39 - 0
backend/repack_v5.py

@@ -0,0 +1,39 @@
+import zipfile, os
+
+SRC = "target/resource-platform-v4.jar"
+OUT = "target/resource-platform-v5.jar"
+CLASSES = "build_tmp_classes"
+
+zin = zipfile.ZipFile(SRC)
+names = zin.namelist()
+
+def is_lib(n):
+    return n.startswith("BOOT-INF/lib/")
+
+new_map = {}
+for root, _, files in os.walk(CLASSES):
+    for f in files:
+        full = os.path.join(root, f)
+        arc = "BOOT-INF/classes/" + os.path.relpath(full, CLASSES).replace(os.sep, "/")
+        new_map[arc] = full
+
+zout = zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED)
+zout.writestr("META-INF/MANIFEST.MF", zin.read("META-INF/MANIFEST.MF"), zipfile.ZIP_STORED)
+for n in names:
+    if n == "META-INF/MANIFEST.MF":
+        continue
+    if is_lib(n):
+        zout.writestr(n, zin.read(n), zipfile.ZIP_STORED)
+    elif n.startswith("BOOT-INF/classes/") and not n.endswith("/"):
+        if n in new_map:
+            with open(new_map[n], "rb") as fh:
+                zout.writestr(n, fh.read(), zipfile.ZIP_DEFLATED)
+        else:
+            zout.writestr(n, zin.read(n), zipfile.ZIP_DEFLATED)
+    else:
+        zout.writestr(n, zin.read(n), zipfile.ZIP_DEFLATED)
+zout.close()
+
+zv = zipfile.ZipFile(OUT)
+print("manifest first?", zv.namelist()[0] == "META-INF/MANIFEST.MF")
+print("BusinessException replaced?", "BOOT-INF/classes/com/example/resourceplatform/common/BusinessException.class" in zv.namelist())

+ 1 - 1
backend/src/main/java/com/example/resourceplatform/common/BusinessException.java

@@ -12,7 +12,7 @@ public class BusinessException extends RuntimeException {
 
     public BusinessException(String message) {
         super(message);
-        this.code = 500;
+        this.code = 400;
     }
 
     public BusinessException(int code, String message) {

+ 13 - 1
backend/src/main/java/com/example/resourceplatform/controller/ProjectController.java

@@ -25,6 +25,12 @@ public class ProjectController {
         return Result.success(projectService.pageQuery(query));
     }
 
+    /** 两层树形列表(父聚合子项目资源数) */
+    @GetMapping("/tree")
+    public Result<List<ProjectVO>> tree(ProjectQuery query) {
+        return Result.success(projectService.tree(query));
+    }
+
     @GetMapping("/{id}")
     public Result<ProjectVO> detail(@PathVariable("id") Long id) {
         return Result.success(projectService.getDetail(id));
@@ -42,9 +48,15 @@ public class ProjectController {
         return Result.success();
     }
 
-    /** 供资源表单里"关联项目"多选下拉使用 */
+    /** 资源表单"关联项目"下拉: 只返回叶子(资源只挂子项目) */
     @GetMapping("/active-list")
     public Result<List<Project>> activeList() {
         return Result.success(projectService.listActive());
     }
+
+    /** 新增/编辑项目时选择父项目: 只返回顶层项目 */
+    @GetMapping("/parent-options")
+    public Result<List<Project>> parentOptions() {
+        return Result.success(projectService.listTopLevel());
+    }
 }

+ 3 - 0
backend/src/main/java/com/example/resourceplatform/dto/ProjectDTO.java

@@ -12,6 +12,9 @@ public class ProjectDTO {
     @NotBlank(message = "项目名称不能为空")
     private String name;
 
+    /** 父项目ID: 0-顶层; 为 null 时按顶层处理 */
+    private Long parentId;
+
     @NotNull(message = "负责人不能为空")
     private Long ownerId;
 

+ 12 - 0
backend/src/main/java/com/example/resourceplatform/dto/ProjectVO.java

@@ -17,6 +17,18 @@ public class ProjectVO {
 
     private Integer status;
 
+    /** 父项目ID: 0-顶层 */
+    private Long parentId;
+
+    /** 层级: 1-顶层 2-子项目 */
+    private Integer level;
+
+    /** 是否叶子(无子项目) */
+    private Boolean isLeaf;
+
+    /** 子项目列表(树形使用) */
+    private List<ProjectVO> children;
+
     private String remark;
 
     /** 关联资源总数 */

+ 3 - 0
backend/src/main/java/com/example/resourceplatform/entity/Project.java

@@ -17,6 +17,9 @@ public class Project {
 
     private String name;
 
+    /** 父项目ID: 0-顶层(无父); 仅支持两层(父必须是顶层) */
+    private Long parentId;
+
     private Long ownerId;
 
     /** 状态: 1-进行中 0-已下线 */

+ 6 - 0
backend/src/main/java/com/example/resourceplatform/service/ProjectService.java

@@ -20,4 +20,10 @@ public interface ProjectService {
 
     /** 供下拉选择框使用的全量简要列表(仅进行中的项目) */
     List<Project> listActive();
+
+    /** 两层树形结构(父聚合子项目资源数), 供列表页使用 */
+    List<ProjectVO> tree(ProjectQuery query);
+
+    /** 顶层项目列表(供"父项目"选择框使用) */
+    List<Project> listTopLevel();
 }

+ 177 - 20
backend/src/main/java/com/example/resourceplatform/service/impl/ProjectServiceImpl.java

@@ -21,8 +21,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDateTime;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
 import java.util.stream.Collectors;
 
 @Service
@@ -59,23 +58,78 @@ public class ProjectServiceImpl implements ProjectService {
         return voPage;
     }
 
-    private Map<Long, Long> countResourcesByProject(List<Long> projectIds) {
-        if (projectIds.isEmpty()) {
-            return Map.of();
+    @Override
+    public List<ProjectVO> tree(ProjectQuery query) {
+        LambdaQueryWrapper<Project> wrapper = new LambdaQueryWrapper<Project>()
+                .like(query.getName() != null && !query.getName().isBlank(), Project::getName, query.getName())
+                .eq(query.getOwnerId() != null, Project::getOwnerId, query.getOwnerId())
+                .eq(query.getStatus() != null, Project::getStatus, query.getStatus());
+
+        List<Project> all = projectMapper.selectList(wrapper);
+        if (all.isEmpty()) return List.of();
+
+        List<Long> ids = all.stream().map(Project::getId).toList();
+        Map<Long, Long> directCount = countResourcesByProject(ids);
+        Map<Long, String> ownerNameMap = sysUserService.getUserNameMap(
+                all.stream().map(Project::getOwnerId).distinct().toList());
+
+        Map<Long, List<Project>> childrenMap = all.stream()
+                .filter(p -> p.getParentId() != null && p.getParentId() != 0)
+                .collect(Collectors.groupingBy(Project::getParentId));
+
+        List<ProjectVO> roots = new ArrayList<>();
+        for (Project p : all) {
+            if (p.getParentId() == null || p.getParentId() == 0) {
+                roots.add(toNode(p, ownerNameMap, directCount, childrenMap, 1));
+            }
         }
-        List<ProjectResource> relations = projectResourceMapper.selectList(
-                new LambdaQueryWrapper<ProjectResource>().in(ProjectResource::getProjectId, projectIds));
-        return relations.stream().collect(Collectors.groupingBy(ProjectResource::getProjectId, Collectors.counting()));
+        boolean hasFilter = (query.getName() != null && !query.getName().isBlank())
+                || query.getOwnerId() != null || query.getStatus() != null;
+        if (hasFilter) {
+            roots.removeIf(r -> !subtreeMatches(r, query));
+        }
+        return roots;
     }
 
-    private ProjectVO toBaseVO(Project p, Map<Long, String> ownerNameMap) {
+    private boolean subtreeMatches(ProjectVO vo, ProjectQuery q) {
+        if (nodeMatches(vo, q)) return true;
+        if (vo.getChildren() != null) {
+            for (ProjectVO c : vo.getChildren()) if (subtreeMatches(c, q)) return true;
+        }
+        return false;
+    }
+
+    private boolean nodeMatches(ProjectVO vo, ProjectQuery q) {
+        if (q.getName() != null && !q.getName().isBlank() && !vo.getName().contains(q.getName())) return false;
+        if (q.getOwnerId() != null && !q.getOwnerId().equals(vo.getOwnerId())) return false;
+        if (q.getStatus() != null && !q.getStatus().equals(vo.getStatus())) return false;
+        return true;
+    }
+
+    private ProjectVO toNode(Project p, Map<Long, String> ownerNameMap, Map<Long, Long> directCount,
+                             Map<Long, List<Project>> childrenMap, int level) {
         ProjectVO vo = new ProjectVO();
         vo.setId(p.getId());
         vo.setName(p.getName());
+        vo.setParentId(p.getParentId());
         vo.setOwnerId(p.getOwnerId());
         vo.setOwnerName(ownerNameMap.get(p.getOwnerId()));
         vo.setStatus(p.getStatus());
         vo.setRemark(p.getRemark());
+        List<Project> children = childrenMap.getOrDefault(p.getId(), List.of());
+        boolean leaf = children.isEmpty();
+        vo.setLevel(level);
+        vo.setIsLeaf(leaf);
+        if (leaf) {
+            vo.setResourceCount(directCount.getOrDefault(p.getId(), 0L).intValue());
+            vo.setChildren(List.of());
+        } else {
+            List<ProjectVO> childVos = children.stream()
+                    .map(c -> toNode(c, ownerNameMap, directCount, childrenMap, level + 1)).toList();
+            int agg = childVos.stream().mapToInt(ProjectVO::getResourceCount).sum();
+            vo.setResourceCount(agg);
+            vo.setChildren(childVos);
+        }
         return vo;
     }
 
@@ -86,15 +140,49 @@ public class ProjectServiceImpl implements ProjectService {
             throw new BusinessException("项目不存在");
         }
         Map<Long, String> ownerNameMap = sysUserService.getUserNameMap(List.of(project.getOwnerId()));
-        ProjectVO vo = toBaseVO(project, ownerNameMap);
 
-        List<Long> resourceIds = projectResourceMapper.selectList(
-                        new LambdaQueryWrapper<ProjectResource>().eq(ProjectResource::getProjectId, id))
-                .stream().map(ProjectResource::getResourceId).toList();
+        ProjectVO vo = new ProjectVO();
+        vo.setId(project.getId());
+        vo.setName(project.getName());
+        vo.setParentId(project.getParentId());
+        vo.setOwnerId(project.getOwnerId());
+        vo.setOwnerName(ownerNameMap.get(project.getOwnerId()));
+        vo.setStatus(project.getStatus());
+        vo.setRemark(project.getRemark());
+        vo.setLevel(project.getParentId() == null || project.getParentId() == 0 ? 1 : 2);
+
+        List<Project> children = projectMapper.selectList(
+                new LambdaQueryWrapper<Project>().eq(Project::getParentId, id));
+        vo.setIsLeaf(children.isEmpty());
+
+        // 资源只挂在叶子(子项目); 父项目聚合所有叶子后代的资源
+        List<Long> leafIds = children.isEmpty() ? List.of(id) : children.stream().map(Project::getId).toList();
+        Map<Long, Long> directCount = countResourcesByProject(leafIds);
 
-        List<ResourceVO> resourceVOs = resourceService.listByIds(resourceIds);
-        vo.setResources(resourceVOs);
-        vo.setResourceCount(resourceVOs.size());
+        if (!children.isEmpty()) {
+            vo.setChildren(children.stream().map(c -> {
+                ProjectVO cv = new ProjectVO();
+                cv.setId(c.getId());
+                cv.setName(c.getName());
+                cv.setParentId(c.getParentId());
+                cv.setOwnerId(c.getOwnerId());
+                cv.setOwnerName(ownerNameMap.get(c.getOwnerId()));
+                cv.setStatus(c.getStatus());
+                cv.setRemark(c.getRemark());
+                cv.setLevel(2);
+                cv.setIsLeaf(true);
+                cv.setChildren(List.of());
+                cv.setResourceCount(directCount.getOrDefault(c.getId(), 0L).intValue());
+                return cv;
+            }).toList());
+        }
+
+        List<Long> resIds = projectResourceMapper.selectList(
+                        new LambdaQueryWrapper<ProjectResource>().in(ProjectResource::getProjectId, leafIds))
+                .stream().map(ProjectResource::getResourceId).toList();
+        List<ResourceVO> resources = resourceService.listByIds(resIds);
+        vo.setResources(resources);
+        vo.setResourceCount(resources.size());
         return vo;
     }
 
@@ -106,7 +194,31 @@ public class ProjectServiceImpl implements ProjectService {
         if (!isNew && project == null) {
             throw new BusinessException("项目不存在");
         }
+
+        Long parentId = dto.getParentId() == null ? 0L : dto.getParentId();
+        if (parentId != 0L) {
+            Project parent = projectMapper.selectById(parentId);
+            if (parent == null) {
+                throw new BusinessException("父项目不存在");
+            }
+            if (parent.getParentId() != null && parent.getParentId() != 0L) {
+                throw new BusinessException("父项目必须是顶层项目(仅支持两层结构)");
+            }
+            if (!isNew && parentId.equals(dto.getId())) {
+                throw new BusinessException("不能选择自己作为父项目");
+            }
+            // 若当前项目已有子项目, 不能再设为别人的子项目(会导致三层)
+            if (!isNew) {
+                long childCount = projectMapper.selectCount(
+                        new LambdaQueryWrapper<Project>().eq(Project::getParentId, dto.getId()));
+                if (childCount > 0) {
+                    throw new BusinessException("该项目下还有子项目, 不能设为子项目");
+                }
+            }
+        }
+
         project.setName(dto.getName());
+        project.setParentId(parentId);
         project.setOwnerId(dto.getOwnerId());
         project.setStatus(dto.getStatus() != null ? dto.getStatus() : ProjectStatus.ACTIVE.getCode());
         project.setRemark(dto.getRemark());
@@ -123,14 +235,59 @@ public class ProjectServiceImpl implements ProjectService {
     @Override
     @Transactional
     public void delete(Long id) {
-        projectMapper.deleteById(id);
+        Project project = projectMapper.selectById(id);
+        if (project == null) {
+            throw new BusinessException("项目不存在");
+        }
+        // 级联收集: 自身 + 所有子项目(仅两层)
+        List<Long> ids = new ArrayList<>();
+        ids.add(id);
+        List<Project> children = projectMapper.selectList(
+                new LambdaQueryWrapper<Project>().eq(Project::getParentId, id));
+        children.forEach(c -> ids.add(c.getId()));
+
         projectResourceMapper.delete(
-                new LambdaQueryWrapper<ProjectResource>().eq(ProjectResource::getProjectId, id));
-        operationLogService.record("项目", "删除", id, "项目id: " + id);
+                new LambdaQueryWrapper<ProjectResource>().in(ProjectResource::getProjectId, ids));
+        projectMapper.deleteBatchIds(ids);
+        operationLogService.record("项目", "删除", id, "项目id: " + id + "(含子项目" + children.size() + "个)");
     }
 
     @Override
     public List<Project> listActive() {
-        return projectMapper.selectList(new LambdaQueryWrapper<Project>().eq(Project::getStatus, ProjectStatus.ACTIVE.getCode()));
+        // 资源只挂在叶子(子项目), 故下拉只返回叶子
+        List<Project> all = projectMapper.selectList(
+                new LambdaQueryWrapper<Project>().eq(Project::getStatus, ProjectStatus.ACTIVE.getCode()));
+        Set<Long> parentIds = all.stream()
+                .map(Project::getParentId)
+                .filter(pid -> pid != null && pid != 0L)
+                .collect(Collectors.toSet());
+        return all.stream().filter(p -> !parentIds.contains(p.getId())).toList();
+    }
+
+    @Override
+    public List<Project> listTopLevel() {
+        return projectMapper.selectList(new LambdaQueryWrapper<Project>()
+                .eq(Project::getStatus, ProjectStatus.ACTIVE.getCode())
+                .and(w -> w.eq(Project::getParentId, 0L).or().isNull(Project::getParentId)));
+    }
+
+    private ProjectVO toBaseVO(Project p, Map<Long, String> ownerNameMap) {
+        ProjectVO vo = new ProjectVO();
+        vo.setId(p.getId());
+        vo.setName(p.getName());
+        vo.setOwnerId(p.getOwnerId());
+        vo.setOwnerName(ownerNameMap.get(p.getOwnerId()));
+        vo.setStatus(p.getStatus());
+        vo.setRemark(p.getRemark());
+        return vo;
+    }
+
+    private Map<Long, Long> countResourcesByProject(List<Long> projectIds) {
+        if (projectIds.isEmpty()) {
+            return Map.of();
+        }
+        List<ProjectResource> relations = projectResourceMapper.selectList(
+                new LambdaQueryWrapper<ProjectResource>().in(ProjectResource::getProjectId, projectIds));
+        return relations.stream().collect(Collectors.groupingBy(ProjectResource::getProjectId, Collectors.counting()));
     }
 }

+ 2 - 0
frontend/src/api/project.js

@@ -1,7 +1,9 @@
 import request from './request'
 
 export const projectPageApi = (params) => request.get('/project/page', { params })
+export const projectTreeApi = (params) => request.get('/project/tree', { params })
 export const projectDetailApi = (id) => request.get(`/project/${id}`)
 export const projectSaveApi = (data) => request.post('/project', data)
 export const projectDeleteApi = (id) => request.delete(`/project/${id}`)
 export const projectActiveListApi = () => request.get('/project/active-list')
+export const projectParentOptionsApi = () => request.get('/project/parent-options')

+ 10 - 1
frontend/src/views/project/ProjectDetail.vue

@@ -23,6 +23,7 @@
           </span>
         </el-descriptions-item>
         <el-descriptions-item label="关联资源数">{{ detail.resourceCount }}</el-descriptions-item>
+        <el-descriptions-item label="父项目">{{ parentName }}</el-descriptions-item>
         <el-descriptions-item label="备注" :span="3">{{ detail.remark || '-' }}</el-descriptions-item>
       </el-descriptions>
     </div>
@@ -79,7 +80,7 @@
 import { ref, reactive, onMounted, watch, computed } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
-import { projectDetailApi } from '@/api/project'
+import { projectDetailApi, projectParentOptionsApi } from '@/api/project'
 import { resourcePageApi, resourceRelateApi, resourceUnrelateApi } from '@/api/resource'
 import { resourceStatusClass } from '@/utils/status'
 import { useScrollRestore } from '@/composables/useScrollRestore'
@@ -90,6 +91,13 @@ const route = useRoute()
 const router = useRouter()
 const detail = reactive({})
 const allResources = ref([])
+const parentOptions = ref([])
+const parentName = computed(() => {
+  const pid = detail.parentId
+  if (!pid) return '顶层项目'
+  const p = parentOptions.value.find(x => x.id === pid)
+  return p ? p.name : '顶层项目'
+})
 
 const relateDialogVisible = ref(false)
 const selectedResourceIds = ref([])
@@ -139,6 +147,7 @@ async function handleUnrelate(resourceId) {
 onMounted(() => {
   loadDetail()
   loadAllResources()
+  projectParentOptionsApi().then(r => { parentOptions.value = r.data })
 })
 
 watch(() => route.params.id, (newVal) => {

+ 33 - 29
frontend/src/views/project/ProjectList.vue

@@ -8,7 +8,7 @@
       <div class="head-tools">
         <span class="filter-search">
           <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
-          <input v-model="query.name" placeholder="搜索项目名 / 负责人…" @keyup.enter="handleSearch" />
+          <input v-model="query.name" placeholder="搜索项目名…" @keyup.enter="handleSearch" />
         </span>
         <el-button type="primary" @click="openCreate">+ 新增项目</el-button>
       </div>
@@ -18,7 +18,7 @@
       <div class="card-head">
         <div>
           <h2>项目列表</h2>
-          <span class="hint">共 {{ total }} 条</span>
+          <span class="hint">共 {{ total }} 个项目(顶层 {{ treeData.length }} 个)</span>
         </div>
       </div>
 
@@ -35,13 +35,14 @@
         </el-form-item>
       </el-form>
 
-      <el-table :data="list" v-loading="loading">
+      <el-table :data="treeData" v-loading="loading" row-key="id" :tree-props="{ children: 'children' }" default-expand-all>
         <el-table-column label="序号" width="60" header-align="right" align="right">
-          <template #default="{ $index }">{{ (query.pageNum - 1) * query.pageSize + $index + 1 }}</template>
+          <template #default="{ $index }">{{ $index + 1 }}</template>
         </el-table-column>
-        <el-table-column label="项目名称" min-width="160">
+        <el-table-column label="项目名称" min-width="200">
           <template #default="{ row }">
             <el-button link type="primary" @click="goDetail(row.id)">{{ row.name }}</el-button>
+            <el-tag v-if="row.level === 2" size="small" type="info" style="margin-left: 6px">子项目</el-tag>
           </template>
         </el-table-column>
         <el-table-column prop="ownerName" label="负责人" width="100" />
@@ -57,7 +58,7 @@
         <el-table-column label="操作" width="150" fixed="right">
           <template #default="{ row }">
             <el-button link type="primary" @click="openEdit(row)">编辑</el-button>
-            <el-popconfirm title="确定删除该项目吗?关联关系也会一并解除" @confirm="handleDelete(row.id)">
+            <el-popconfirm title="确定删除该项目吗?若含子项目将一并删除并解除关联" @confirm="handleDelete(row.id)">
               <template #reference>
                 <el-button link type="danger">删除</el-button>
               </template>
@@ -65,15 +66,6 @@
           </template>
         </el-table-column>
       </el-table>
-
-      <el-pagination
-        style="margin-top: 16px; justify-content: flex-end"
-        v-model:current-page="query.pageNum"
-        v-model:page-size="query.pageSize"
-        :total="total"
-        layout="total, prev, pager, next"
-        @current-change="loadList"
-      />
     </div>
 
     <el-dialog v-model="formVisible" :title="form.id ? '编辑项目' : '新增项目'" width="480px" @closed="resetForm">
@@ -81,6 +73,12 @@
         <el-form-item label="项目名称" prop="name">
           <el-input v-model="form.name" />
         </el-form-item>
+        <el-form-item label="父项目">
+          <el-select v-model="form.parentId" clearable placeholder="无(顶层项目)" style="width: 100%">
+            <el-option :value="0" label="无(顶层项目)" />
+            <el-option v-for="p in parentOptions" :key="p.id" :label="p.name" :value="p.id" :disabled="form.id === p.id" />
+          </el-select>
+        </el-form-item>
         <el-form-item label="负责人" prop="ownerId">
           <el-select v-model="form.ownerId" filterable style="width: 100%">
             <el-option v-for="u in userOptions" :key="u.id" :label="u.realName" :value="u.id" />
@@ -105,52 +103,57 @@
 </template>
 
 <script setup>
-import { ref, reactive, onMounted } from 'vue'
+import { ref, reactive, computed, onMounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
-import { projectPageApi, projectSaveApi, projectDeleteApi } from '@/api/project'
+import { projectTreeApi, projectSaveApi, projectDeleteApi, projectParentOptionsApi } from '@/api/project'
 import { userEnabledListApi } from '@/api/user'
 import { useScrollRestore } from '@/composables/useScrollRestore'
 
 useScrollRestore()
 
 const router = useRouter()
-const query = reactive({ pageNum: 1, pageSize: 10, name: '', status: null })
-const list = ref([])
-const total = ref(0)
+const query = reactive({ name: '', status: null })
+const treeData = ref([])
+const total = computed(() => {
+  let n = 0
+  const walk = (arr) => arr.forEach(x => { n++; if (x.children && x.children.length) walk(x.children) })
+  walk(treeData.value)
+  return n
+})
 const loading = ref(false)
 const userOptions = ref([])
+const parentOptions = ref([])
 
 const formVisible = ref(false)
 const formRef = ref(null)
-const form = reactive({ id: null, name: '', ownerId: null, status: 1, remark: '' })
+const form = reactive({ id: null, name: '', parentId: 0, ownerId: null, status: 1, remark: '' })
 const rules = {
   name: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
   ownerId: [{ required: true, message: '请选择负责人', trigger: 'change' }]
 }
 
 function resetForm() {
-  Object.assign(form, { id: null, name: '', ownerId: null, status: 1, remark: '' })
+  Object.assign(form, { id: null, name: '', parentId: 0, ownerId: null, status: 1, remark: '' })
   formRef.value?.clearValidate()
 }
 
 async function loadList() {
   loading.value = true
   try {
-    const res = await projectPageApi(query)
-    list.value = res.data.records
-    total.value = res.data.total
+    const res = await projectTreeApi(query)
+    treeData.value = res.data
   } finally {
     loading.value = false
   }
 }
 
-function handleSearch() { query.pageNum = 1; loadList() }
-function handleReset() { Object.assign(query, { pageNum: 1, pageSize: 10, name: '', status: null }); loadList() }
+function handleSearch() { loadList() }
+function handleReset() { Object.assign(query, { name: '', status: null }); loadList() }
 
 function openCreate() { resetForm(); formVisible.value = true }
 function openEdit(row) {
-  Object.assign(form, { id: row.id, name: row.name, ownerId: row.ownerId, status: row.status, remark: row.remark })
+  Object.assign(form, { id: row.id, name: row.name, parentId: row.parentId || 0, ownerId: row.ownerId, status: row.status, remark: row.remark })
   formVisible.value = true
 }
 
@@ -177,8 +180,9 @@ async function handleSubmit() {
 
 onMounted(async () => {
   loadList()
-  const u = await userEnabledListApi()
+  const [u, p] = await Promise.all([userEnabledListApi(), projectParentOptionsApi()])
   userOptions.value = u.data
+  parentOptions.value = p.data
 })
 </script>
 

+ 2 - 1
sql/schema.sql

@@ -60,6 +60,7 @@ CREATE TABLE `project` (
   `id`          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
   `name`        VARCHAR(100)    NOT NULL COMMENT '项目名称',
   `owner_id`    BIGINT UNSIGNED NOT NULL COMMENT '负责人,关联sys_user.id',
+  `parent_id`   BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父项目ID: 0-顶层(无父),仅支持两层',
   `status`      TINYINT         NOT NULL DEFAULT 1 COMMENT '状态: 1-进行中 0-已下线',
   `remark`      VARCHAR(500)             DEFAULT NULL,
   `create_time` DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -86,7 +87,7 @@ CREATE TABLE `resource` (
   `owner_id`      BIGINT UNSIGNED NOT NULL COMMENT '负责人,关联sys_user.id',
   `status`        TINYINT         NOT NULL DEFAULT 1 COMMENT '状态: 1-使用中 2-即将到期 3-已过期 0-已停用',
   `remark`        VARCHAR(500)             DEFAULT NULL,
-  `purchase_duration` TINYINT           DEFAULT NULL COMMENT '购买时长: 1-按量 2-月 3-季度 4-半年 5-年',
+  `purchase_duration` TINYINT           DEFAULT NULL COMMENT '购买时长: 8-按量 1-1个月 2-2个月 3-3个月 4-6个月 5-1年 6-2年 7-3年',
   `create_time`   DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
   `update_time`   DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
   `deleted`       TINYINT         NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0-未删除 1-已删除',