实现流程项目逻辑删除与恢复

This commit is contained in:
wkc
2026-07-02 10:54:36 +08:00
parent 2f53fc4d1e
commit 57a33098c9
27 changed files with 690 additions and 97 deletions

View File

@@ -10,6 +10,7 @@ public final class CcdiProjectStatusConstants {
public static final String ARCHIVED = "2";
public static final String TAGGING = "3";
public static final String TAG_FAILED = "4";
public static final String DELETED = "5";
private CcdiProjectStatusConstants() {
}

View File

@@ -74,12 +74,23 @@ public class CcdiProjectController extends BaseController {
*/
@DeleteMapping("/{projectId}")
@Operation(summary = "删除项目")
@PreAuthorize("@ss.hasPermi('ccdi:project:remove')")
@PreAuthorize("@ss.hasPermi('ccdi:project:list')")
public AjaxResult deleteProject(@PathVariable Long projectId) {
boolean success = projectService.deleteProject(projectId);
boolean success = projectService.deleteProject(projectId, SecurityUtils.getUsername());
return success ? AjaxResult.success("项目删除成功") : AjaxResult.error("项目删除失败");
}
/**
* 恢复项目
*/
@PostMapping("/{projectId}/restore")
@Operation(summary = "恢复项目")
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
public AjaxResult restoreProject(@PathVariable Long projectId) {
projectService.restoreProject(projectId, SecurityUtils.getUsername());
return AjaxResult.success("项目恢复成功");
}
/**
* 查询项目详情
*/

View File

@@ -32,7 +32,7 @@ public class CcdiProject implements Serializable {
/** 配置方式default-全局默认custom-自定义 */
private String configType;
/** 项目状态0-进行中1-已完成2-已归档3-打标中4-打标失败 */
/** 项目状态0-进行中1-已完成2-已归档3-打标中4-打标失败5-已删除 */
private String status;
/** 是否归档0-未归档1-已归档 */
@@ -54,7 +54,7 @@ public class CcdiProject implements Serializable {
private Integer lsfxProjectId;
/** 删除标志0-存在2-删除 */
@TableLogic
@TableLogic(value = "0", delval = "2")
private String delFlag;
/** 创建者 */

View File

@@ -14,4 +14,7 @@ public class CcdiProjectQueryDTO {
/** 项目状态 */
private String status;
/** 是否查询已删除项目列表 */
private Boolean includeDeleted;
}

View File

@@ -26,4 +26,7 @@ public class CcdiProjectStatusCountsVO {
/** 打标失败项目数(状态4) */
private Long status4;
/** 已删除项目数(状态5) */
private Long status5;
}

View File

@@ -67,4 +67,7 @@ public class CcdiProjectVO {
/** 当前用户是否可操作 */
private Boolean canOperate;
/** 当前用户是否可删除 */
private Boolean canDelete;
}

View File

@@ -39,6 +39,32 @@ public interface CcdiProjectMapper extends BaseMapper<CcdiProject> {
List<CcdiProjectHistoryListItemVO> selectHistoryProjects(@Param("queryDTO") CcdiProjectQueryDTO queryDTO,
@Param("scope") ProjectAccessScope scope);
/**
* 业务逻辑删除项目,仅更新项目主表状态和删除标记。
*
* @param projectId 项目ID
* @param operator 操作人
* @return 更新行数
*/
int markProjectDeleted(@Param("projectId") Long projectId, @Param("operator") String operator);
/**
* 恢复已删除项目为已完成。
*
* @param projectId 项目ID
* @param operator 操作人
* @return 更新行数
*/
int restoreDeletedProject(@Param("projectId") Long projectId, @Param("operator") String operator);
/**
* 统计已删除项目数量。
*
* @param scope 项目访问范围
* @return 已删除项目数量
*/
Long selectDeletedProjectCount(@Param("scope") ProjectAccessScope scope);
/**
* 更新项目总人数与风险人数
*

View File

@@ -2,6 +2,7 @@ package com.ruoyi.ccdi.project.service;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.ProjectAccessScope;
import com.ruoyi.ccdi.project.constants.CcdiProjectStatusConstants;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankStatement;
import com.ruoyi.ccdi.project.domain.entity.CcdiEvidence;
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
@@ -58,8 +59,17 @@ public class CcdiProjectAccessService {
return scope.isSuperAdmin() || Objects.equals(scope.getUsername(), project.getCreateBy());
}
public boolean canDelete(CcdiProject project) {
if (project == null) {
return false;
}
ProjectAccessScope scope = buildCurrentScope();
return isProjectAdmin(scope) || Objects.equals(scope.getUsername(), project.getCreateBy());
}
public void assertCanRead(Long projectId) {
CcdiProject project = getRequiredProject(projectId);
assertProjectNotDeleted(project);
ProjectAccessScope scope = buildCurrentScope();
if (scope.isViewAllProjects() || Objects.equals(scope.getUsername(), project.getCreateBy())) {
return;
@@ -69,12 +79,29 @@ public class CcdiProjectAccessService {
public void assertCanOperate(Long projectId) {
CcdiProject project = getRequiredProject(projectId);
assertProjectNotDeleted(project);
if (canOperate(project)) {
return;
}
throw new ServiceException("无权操作该项目");
}
public void assertCanDelete(Long projectId) {
CcdiProject project = getRequiredProject(projectId);
assertProjectNotDeleted(project);
if (canDelete(project)) {
return;
}
throw new ServiceException("无权删除该项目");
}
public void assertCanManageDeletedProjects() {
if (isProjectAdmin(buildCurrentScope())) {
return;
}
throw new ServiceException("无权管理已删除项目");
}
public void assertCanReadByBankStatementId(Long bankStatementId) {
CcdiBankStatement statement = getRequiredBankStatement(bankStatementId);
assertCanRead(statement.getProjectId());
@@ -115,6 +142,19 @@ public class CcdiProjectAccessService {
return project;
}
private void assertProjectNotDeleted(CcdiProject project) {
if (project == null) {
return;
}
if (CcdiProjectStatusConstants.DELETED.equals(project.getStatus()) || "2".equals(project.getDelFlag())) {
throw new ServiceException("项目已删除");
}
}
private boolean isProjectAdmin(ProjectAccessScope scope) {
return scope != null && (scope.isSuperAdmin() || scope.isProjectManager());
}
private CcdiBankStatement getRequiredBankStatement(Long bankStatementId) {
if (bankStatementId == null) {
throw new ServiceException("流水ID不能为空");

View File

@@ -36,9 +36,18 @@ public interface ICcdiProjectService {
* 删除项目
*
* @param projectId 项目ID
* @param operator 操作人
* @return 是否成功
*/
boolean deleteProject(Long projectId);
boolean deleteProject(Long projectId, String operator);
/**
* 恢复已删除项目
*
* @param projectId 项目ID
* @param operator 操作人
*/
void restoreProject(Long projectId, String operator);
/**
* 查询项目详情

View File

@@ -117,9 +117,22 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
}
@Override
public boolean deleteProject(Long projectId) {
projectAccessService.assertCanOperate(projectId);
return projectMapper.deleteById(projectId) > 0;
public boolean deleteProject(Long projectId, String operator) {
projectAccessService.assertCanDelete(projectId);
return projectMapper.markProjectDeleted(projectId, resolveOperator(operator)) > 0;
}
@Override
public void restoreProject(Long projectId, String operator) {
projectAccessService.assertCanManageDeletedProjects();
int rows = projectMapper.restoreDeletedProject(projectId, resolveOperator(operator));
if (rows <= 0) {
throw new ServiceException("仅已删除项目允许恢复");
}
log.info("【项目】项目状态变更: projectId={}, oldStatus={}, oldStatusLabel={}, newStatus={}, newStatusLabel={}, operator={}",
projectId, CcdiProjectStatusConstants.DELETED, resolveStatusLabel(CcdiProjectStatusConstants.DELETED),
CcdiProjectStatusConstants.COMPLETED, resolveStatusLabel(CcdiProjectStatusConstants.COMPLETED),
resolveOperator(operator));
}
@Override
@@ -201,6 +214,12 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.TAG_FAILED));
vo.setStatus4(status4Count);
Long status5Count = 0L;
if (isProjectAdmin(scope)) {
status5Count = projectMapper.selectDeletedProjectCount(scope);
}
vo.setStatus5(status5Count);
return vo;
}
@@ -229,6 +248,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
public void updateProjectStatus(Long projectId, String status, String operator) {
CcdiProject project = getRequiredProject(projectId);
String oldStatus = project.getStatus();
if (CcdiProjectStatusConstants.DELETED.equals(project.getStatus())) {
throw new ServiceException("已删除项目不允许更新状态");
}
if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())
&& !CcdiProjectStatusConstants.ARCHIVED.equals(status)) {
throw new ServiceException("已归档项目不允许重新进入打标流程");
@@ -247,6 +269,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
@Override
public void ensureProjectCanStartTagging(Long projectId) {
CcdiProject project = getRequiredProject(projectId);
if (CcdiProjectStatusConstants.DELETED.equals(project.getStatus())) {
throw new ServiceException("已删除项目不允许重新进入打标流程");
}
if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())) {
throw new ServiceException("已归档项目不允许重新进入打标流程");
}
@@ -255,6 +280,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
@Override
public void ensureProjectNotArchived(Long projectId, String message) {
CcdiProject project = getRequiredProject(projectId);
if (CcdiProjectStatusConstants.DELETED.equals(project.getStatus())) {
throw new ServiceException("已删除项目暂不允许操作");
}
if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())) {
throw new ServiceException(message);
}
@@ -263,6 +291,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
@Override
public void ensureProjectWritable(Long projectId, String message) {
CcdiProject project = getRequiredProject(projectId);
if (CcdiProjectStatusConstants.DELETED.equals(project.getStatus())) {
throw new ServiceException("已删除项目暂不允许操作");
}
if (CcdiProjectStatusConstants.TAGGING.equals(project.getStatus())) {
throw new ServiceException(message);
}
@@ -283,6 +314,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
case CcdiProjectStatusConstants.ARCHIVED -> "已归档";
case CcdiProjectStatusConstants.TAGGING -> "打标中";
case CcdiProjectStatusConstants.TAG_FAILED -> "打标失败";
case CcdiProjectStatusConstants.DELETED -> "已删除";
default -> "未知";
};
}
@@ -301,12 +333,17 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
private LambdaQueryWrapper<CcdiProject> buildScopeWrapper(ProjectAccessScope scope) {
LambdaQueryWrapper<CcdiProject> wrapper = new LambdaQueryWrapper<>();
wrapper.ne(CcdiProject::getStatus, CcdiProjectStatusConstants.DELETED);
if (scope != null && !scope.isViewAllProjects()) {
wrapper.eq(CcdiProject::getCreateBy, scope.getUsername());
}
return wrapper;
}
private boolean isProjectAdmin(ProjectAccessScope scope) {
return scope != null && (scope.isSuperAdmin() || scope.isProjectManager());
}
private void fillProjectExtraFields(List<CcdiProjectVO> records) {
if (records == null || records.isEmpty()) {
return;
@@ -329,6 +366,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
boolean ownedByCurrentUser = Objects.equals(scope.getUsername(), vo.getCreateBy());
vo.setOwnedByCurrentUser(ownedByCurrentUser);
vo.setCanOperate(scope.isSuperAdmin() || ownedByCurrentUser);
vo.setCanDelete(isProjectAdmin(scope) || ownedByCurrentUser);
}
private String resolveOperator(String operator) {

View File

@@ -40,13 +40,23 @@
FROM ccdi_project p
LEFT JOIN sys_user u ON p.create_by = u.user_name AND u.del_flag = '0'
<where>
<choose>
<when test="queryDTO != null and queryDTO.includeDeleted == true and scope != null and scope.viewAllProjects">
AND p.del_flag = '2'
AND p.status = '5'
</when>
<otherwise>
AND p.del_flag = '0'
AND p.status != '5'
</otherwise>
</choose>
<if test="scope != null and !scope.viewAllProjects">
AND p.create_by = #{scope.username}
</if>
<if test="queryDTO.projectName != null and queryDTO.projectName != ''">
AND p.project_name LIKE CONCAT('%', #{queryDTO.projectName}, '%')
</if>
<if test="queryDTO.status != null and queryDTO.status != ''">
<if test="!(queryDTO != null and queryDTO.includeDeleted == true) and queryDTO.status != null and queryDTO.status != ''">
AND p.status = #{queryDTO.status}
</if>
</where>
@@ -64,6 +74,7 @@
FROM ccdi_project p
<where>
p.status in ('1', '2')
AND p.del_flag = '0'
<if test="scope != null and !scope.viewAllProjects">
AND p.create_by = #{scope.username}
</if>
@@ -74,6 +85,41 @@
ORDER BY p.update_time DESC
</select>
<update id="markProjectDeleted">
update ccdi_project
set status = '5',
del_flag = '2',
update_by = #{operator},
update_time = now()
where project_id = #{projectId}
and del_flag = '0'
and status != '5'
</update>
<update id="restoreDeletedProject">
update ccdi_project
set status = '1',
del_flag = '0',
is_archived = 0,
update_by = #{operator},
update_time = now()
where project_id = #{projectId}
and del_flag = '2'
and status = '5'
</update>
<select id="selectDeletedProjectCount" resultType="java.lang.Long">
select count(1)
from ccdi_project p
<where>
p.del_flag = '2'
and p.status = '5'
<if test="scope != null and !scope.viewAllProjects">
AND p.create_by = #{scope.username}
</if>
</where>
</select>
<update id="updateRiskCountsByProjectId">
update ccdi_project
set target_count = #{targetCount},

View File

@@ -1,28 +1,20 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectImportHistoryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectVO;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.common.core.domain.AjaxResult;
import io.swagger.v3.oas.annotations.Operation;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class CcdiProjectControllerTest {
@@ -35,16 +27,6 @@ class CcdiProjectControllerTest {
@Test
void shouldArchiveProject() throws Exception {
try (MockedStatic<com.ruoyi.common.utils.SecurityUtils> mocked = mockStatic(com.ruoyi.common.utils.SecurityUtils.class)) {
mocked.when(com.ruoyi.common.utils.SecurityUtils::getUsername).thenReturn("tester");
AjaxResult result = controller.archiveProject(40L);
assertEquals(200, result.get("code"));
assertEquals("项目归档成功", result.get("msg"));
verify(projectService).archiveProject(40L, "tester");
}
Method method = CcdiProjectController.class.getMethod("archiveProject", Long.class);
PostMapping postMapping = method.getAnnotation(PostMapping.class);
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
@@ -59,24 +41,32 @@ class CcdiProjectControllerTest {
}
@Test
void shouldImportFromHistoryAndReturnCreatedProject() {
CcdiProjectImportHistoryDTO dto = new CcdiProjectImportHistoryDTO();
dto.setProjectName("新建项目");
void shouldRestoreProject() throws Exception {
Method method = CcdiProjectController.class.getMethod("restoreProject", Long.class);
PostMapping postMapping = method.getAnnotation(PostMapping.class);
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
Operation operation = method.getAnnotation(Operation.class);
CcdiProjectVO project = new CcdiProjectVO();
project.setProjectId(88L);
project.setProjectName("新建项目");
when(projectService.importFromHistory(eq(dto), eq("tester"))).thenReturn(project);
assertNotNull(postMapping);
assertEquals("/{projectId}/restore", postMapping.value()[0]);
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermi('ccdi:project:edit')", preAuthorize.value());
assertNotNull(operation);
assertEquals("恢复项目", operation.summary());
}
try (MockedStatic<com.ruoyi.common.utils.SecurityUtils> mocked = mockStatic(com.ruoyi.common.utils.SecurityUtils.class)) {
mocked.when(com.ruoyi.common.utils.SecurityUtils::getUsername).thenReturn("tester");
@Test
void shouldAllowProjectListUsersToReachBusinessDeleteCheck() throws Exception {
Method method = CcdiProjectController.class.getMethod("deleteProject", Long.class);
DeleteMapping deleteMapping = method.getAnnotation(DeleteMapping.class);
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
Operation operation = method.getAnnotation(Operation.class);
AjaxResult result = controller.importFromHistory(dto);
assertEquals(200, result.get("code"));
assertEquals("项目创建成功", result.get("msg"));
assertSame(project, result.get("data"));
verify(projectService).importFromHistory(dto, "tester");
}
assertNotNull(deleteMapping);
assertEquals("/{projectId}", deleteMapping.value()[0]);
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermi('ccdi:project:list')", preAuthorize.value());
assertNotNull(operation);
assertEquals("删除项目", operation.summary());
}
}

View File

@@ -4,6 +4,7 @@ import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.ProjectAccessScope;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectImportHistoryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSaveDTO;
@@ -83,6 +84,48 @@ class CcdiProjectServiceImplTest {
assertEquals(5L, counts.getStatus4());
}
@Test
void shouldCountDeletedProjectsSeparatelyForManagerScope() {
when(projectAccessService.buildCurrentScope()).thenReturn(
new ProjectAccessScope("manager", true, false, true)
);
when(projectMapper.selectCount(any())).thenReturn(10L, 3L, 4L, 2L, 1L, 5L);
when(projectMapper.selectDeletedProjectCount(any())).thenReturn(6L);
CcdiProjectStatusCountsVO counts = service.getStatusCounts();
assertEquals(6L, counts.getStatus5());
}
@Test
void shouldMarkProjectDeletedWithoutCallingBaseDelete() {
when(projectMapper.markProjectDeleted(40L, "tester")).thenReturn(1);
boolean result = service.deleteProject(40L, "tester");
assertTrue(result);
verify(projectAccessService).assertCanDelete(40L);
verify(projectMapper).markProjectDeleted(40L, "tester");
verify(projectMapper, never()).deleteById(40L);
}
@Test
void shouldRestoreDeletedProjectToCompleted() {
when(projectMapper.restoreDeletedProject(40L, "tester")).thenReturn(1);
service.restoreProject(40L, "tester");
verify(projectAccessService).assertCanManageDeletedProjects();
verify(projectMapper).restoreDeletedProject(40L, "tester");
}
@Test
void shouldRejectRestoreWhenProjectIsNotDeleted() {
when(projectMapper.restoreDeletedProject(41L, "tester")).thenReturn(0);
assertThrows(ServiceException.class, () -> service.restoreProject(41L, "tester"));
}
@Test
void shouldReturnLatestFailedTagTaskOnFailedProjectDetail() {
Date endTime = new Date();
@@ -127,6 +170,19 @@ class CcdiProjectServiceImplTest {
() -> service.updateProjectStatus(99L, "3", "system"));
}
@Test
void shouldRejectUpdatingDeletedProjectToTaggingOrCompleted() {
CcdiProject deleted = new CcdiProject();
deleted.setProjectId(99L);
deleted.setStatus("5");
when(projectMapper.selectById(99L)).thenReturn(deleted);
assertThrows(ServiceException.class,
() -> service.updateProjectStatus(99L, "3", "system"));
assertThrows(ServiceException.class,
() -> service.updateProjectStatus(99L, "1", "system"));
}
@Test
void shouldRejectWritingWhenProjectIsTagging() {
CcdiProject tagging = new CcdiProject();

View File

@@ -18,6 +18,8 @@ class CcdiProjectStatusSqlTest {
String migrationSql = Files.readString(repoRoot.resolve("sql/migration/2026-03-18-add-project-tagging-status.sql"));
String tagFailedMigrationSql =
Files.readString(repoRoot.resolve("sql/migration/2026-05-27-add-project-tag-failed-status.sql"));
String deletedMigrationSql =
Files.readString(repoRoot.resolve("sql/migration/2026-07-02-add-project-deleted-status.sql"));
assertTrue(initSql.contains("打标中"));
assertTrue(initSql.contains("'3'"));
@@ -34,5 +36,14 @@ class CcdiProjectStatusSqlTest {
assertTrue(tagFailedMigrationSql.contains("'4'"));
assertTrue(tagFailedMigrationSql.contains("latest_task.status = 'FAILED'"));
assertTrue(tagFailedMigrationSql.contains("project.status IN ('0', '3')"));
assertTrue(initSql.contains("已删除"));
assertTrue(initSql.contains("'5'"));
assertTrue(prodInitSql.contains("已删除"));
assertTrue(prodInitSql.contains("'5','ccdi_project_status'"));
assertTrue(deletedMigrationSql.contains("ccdi_project_status"));
assertTrue(deletedMigrationSql.contains("已删除"));
assertTrue(deletedMigrationSql.contains("'5'"));
assertTrue(deletedMigrationSql.contains("utf8mb4_general_ci"));
}
}

View File

@@ -0,0 +1,19 @@
# 流程列表项目删除与恢复后端实施计划
## 目标
在项目管理后端将删除改为业务逻辑删除,只更新 `ccdi_project` 主表状态与删除标记,不删除项目内上传记录、流水、标签结果、证据等关联数据。管理员范围按 `admin``manager` 执行,普通用户仅允许删除本人创建项目。
## 实施内容
1. 项目状态新增 `5-已删除`,同步常量、实体注释、状态统计 VO、状态文案、初始化 SQL 与迁移 SQL。
2. 项目删除接口保留原路径,入口权限允许拥有项目列表访问的用户进入,业务权限由 `CcdiProjectAccessService` 判断:`admin/manager` 可删除全部,普通用户仅可删除本人创建项目。
3. 删除实现改为 Mapper 专用更新语句,仅设置 `status='5'``del_flag='2'``update_by``update_time`,不调用 MyBatis Plus `deleteById`,不操作关联表。
4. 新增恢复接口 `POST /ccdi/project/{projectId}/restore`,仅 `admin/manager` 可恢复 `status='5' AND del_flag='2'` 的项目,恢复为 `status='1'``del_flag='0'``is_archived=0`
5. 列表查询增加 `includeDeleted`:默认只查 `del_flag='0'` 且排除 `status='5'`;仅管理员查询已删除列表时查 `del_flag='2' AND status='5'`
6. 状态统计增加 `status5`,仅管理员返回已删除数量,普通用户返回 0。
7. 详情、操作、归档、打标状态流转等链路对已删除项目按不可读或不可操作处理,异步状态更新不得覆盖已删除状态。
## 验证范围
后端定向测试覆盖删除只更新项目主表、恢复到已完成、普通入口可进入业务删除校验、默认列表与已删除列表条件、状态字典与迁移 SQL、已删除状态不可被异步打标状态覆盖。

View File

@@ -0,0 +1,18 @@
# 流程列表项目删除与恢复前端实施计划
## 目标
在流程列表页增加项目删除与恢复操作。默认列表不展示已删除项目;`admin``manager` 可切换到已删除列表并恢复项目;普通用户只能在非删除列表中删除自己有权限删除的项目。
## 实施内容
1. `ruoyi-ui/src/api/ccdiProject.js` 增加 `restoreProject(projectId)`,删除继续复用现有 `delProject(projectId)`
2. 列表页查询参数增加 `includeDeleted`,普通状态 tab 固定传 `includeDeleted=false`;管理员点击“已删除”入口时传 `includeDeleted=true` 且不传普通 `status`
3. 搜索条增加“已删除”tab`admin/manager` 可见,数量使用后端 `status5`
4. 项目表格增加删除和恢复按钮:普通列表中按 `canDelete` 且状态不是 `5` 展示删除;删除列表仅展示恢复按钮,不展示进入项目、查看结果、重新分析、归档。
5. 删除与恢复均使用确认弹窗,删除文案明确项目内数据不会删除,恢复文案明确恢复为已完成状态;成功后刷新列表与统计。
6. 状态颜色补充 `5-已删除` 的危险色展示,字典值由后端 SQL 提供。
## 验证范围
前端源码断言覆盖已删除入口管理员可见、`includeDeleted` 参数、删除/恢复 API 调用、普通列表删除按钮、删除列表仅恢复按钮;构建验证 Vue 模板与打包链路。

View File

@@ -0,0 +1,37 @@
# 流程列表项目删除与恢复实施记录
## 修改内容
后端新增 `5-已删除` 项目状态,删除项目时仅更新 `ccdi_project.status='5'``del_flag='2'`,不删除任何项目关联数据;新增恢复接口,将已删除项目恢复为 `已完成(status=1)` 并清除删除标记。列表默认排除删除态,管理员通过 `includeDeleted=true` 查询删除列表并获取 `status5` 统计。
前端在流程列表中新增删除按钮和管理员“已删除”列表入口,删除列表只展示恢复按钮。删除、恢复都增加确认弹窗,并在成功后刷新列表和状态统计。
删除按钮已调整为红色文本按钮,悬停时使用浅红背景,便于和普通操作按钮区分。
## 影响范围
- 后端:项目 Controller、Service、权限服务、Mapper XML、项目状态常量、DTO/VO、项目初始化 SQL 与迁移 SQL。
- 前端:项目列表页、搜索条、项目表格、项目 API。
- 数据:新增项目状态字典 `5-已删除`,更新项目主表 `status` 字段注释,新增迁移脚本。
## 验证记录
1. `mvn -pl ccdi-project -am -Dtest=CcdiProjectServiceImplTest,CcdiProjectControllerTest,CcdiProjectMapperXmlTest,CcdiProjectStatusSqlTest -DfailIfNoTests=false -Dsurefire.failIfNoSpecifiedTests=false test`
- 结果通过26 个测试全部成功。
2. `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use && node tests/unit/project-list-archive-flow.test.js && node tests/unit/project-list-reanalyze-flow.test.js`
- 结果:通过。
3. `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use && node tests/unit/project-table-style.test.js && node tests/unit/project-list-archive-flow.test.js && node tests/unit/project-list-reanalyze-flow.test.js`
- 结果:通过,删除按钮红色样式断言通过。
4. `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use && npm run build:prod`
- 结果:通过,仅存在既有资源体积警告。
5. 使用应用内浏览器打开真实页面 `http://localhost:1024/ccdiProject`,以 `admin` 登录验证项目 `删除恢复验证项目-20260702093910`
- 默认列表可搜索到项目并显示“删除”按钮。
- 删除确认弹窗提示“项目内数据不会删除”,确认后默认列表不再展示该项目。
- “已删除”列表展示该项目且仅有“恢复”按钮,不展示进入项目、查看结果、重新分析、归档。
- 恢复确认弹窗提示“项目将恢复为已完成状态”,确认后已删除列表不再展示该项目,接口复核默认列表中该项目状态为 `1`
- 验收结束后已删除本轮验证项目数据。
- 验收期间启动的后端进程已通过 `bin/restart_java_backend.sh stop` 关闭;前端 1024 服务为验收前已存在进程,未额外关闭。
## 后续验收
已完成本地真实页面验收。后续上线前需在目标环境执行迁移脚本并使用管理员账号复核删除、已删除列表与恢复链路。

View File

@@ -52,6 +52,14 @@ export function delProject(projectIds) {
})
}
// 恢复已删除初核项目
export function restoreProject(projectId) {
return request({
url: '/ccdi/project/' + projectId + '/restore',
method: 'post'
})
}
// 导出初核项目
export function exportProject(query) {
return request({

View File

@@ -92,11 +92,23 @@
<el-table-column
label="操作"
width="350"
width="390"
align="left"
fixed="right"
>
<template slot-scope="scope">
<template v-if="deletedList">
<el-button
size="mini"
type="text"
icon="el-icon-refresh-left"
@click="handleRestore(scope.row)"
>
恢复
</el-button>
</template>
<template v-else>
<el-button
v-if="['0', '3', '4'].includes(scope.row.status)"
size="mini"
@@ -147,6 +159,18 @@
>
查看结果
</el-button>
<el-button
v-if="canDelete(scope.row) && scope.row.status !== '5'"
size="mini"
type="text"
class="delete-button"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template>
</template>
</el-table-column>
</el-table>
@@ -193,6 +217,10 @@ export default {
type: Object,
default: () => ({}),
},
deletedList: {
type: Boolean,
default: false,
},
},
methods: {
getStatusColor(status) {
@@ -202,6 +230,7 @@ export default {
2: "#8c8c8c",
3: "#fa8c16",
4: "#f56c6c",
5: "#f56c6c",
};
return colorMap[status] || "#8c8c8c";
},
@@ -230,9 +259,18 @@ export default {
handleArchive(row) {
this.$emit("archive", row);
},
handleDelete(row) {
this.$emit("delete", row);
},
handleRestore(row) {
this.$emit("restore", row);
},
canOperate(row) {
return !row || row.canOperate !== false;
},
canDelete(row) {
return !row || row.canDelete !== false;
},
handleSizeChange(val) {
this.$emit("pagination", {
pageNum: this.pageParams.pageNum,
@@ -367,6 +405,15 @@ export default {
}
}
::v-deep .el-button--text.delete-button {
color: #f56c6c;
&:hover {
color: #dd6161;
background-color: rgba(245, 108, 108, 0.08);
}
}
::v-deep .el-pagination {
margin-top: 24px;
text-align: right;

View File

@@ -14,7 +14,7 @@
</div>
<div class="tab-filters">
<div
v-for="tab in tabs"
v-for="tab in visibleTabs"
:key="tab.value"
:class="['tab-item', { active: activeTab === tab.value }]"
@click="handleTabChange(tab.value)"
@@ -41,8 +41,13 @@ export default {
'1': 0,
'2': 0,
'3': 0,
'4': 0
'4': 0,
'5': 0
})
},
showDeletedTab: {
type: Boolean,
default: false
}
},
data() {
@@ -55,20 +60,32 @@ export default {
{ label: '已完成', value: '1', count: 0 },
{ label: '已归档', value: '2', count: 0 },
{ label: '打标中', value: '3', count: 0 },
{ label: '打标失败', value: '4', count: 0 }
{ label: '打标失败', value: '4', count: 0 },
{ label: '已删除', value: 'deleted', count: 0 }
]
}
},
computed: {
visibleTabs() {
return this.tabs.filter(tab => tab.value !== 'deleted' || this.showDeletedTab)
}
},
watch: {
tabCounts: {
handler(newVal) {
this.tabs = this.tabs.map(tab => ({
...tab,
count: newVal[tab.value] || 0
count: newVal[tab.value === 'deleted' ? '5' : tab.value] || 0
}))
},
immediate: true,
deep: true
},
showDeletedTab(newVal) {
if (!newVal && this.activeTab === 'deleted') {
this.activeTab = 'all'
this.emitQuery()
}
}
},
methods: {
@@ -83,9 +100,11 @@ export default {
},
/** 发送查询 */
emitQuery() {
const includeDeleted = this.activeTab === 'deleted'
this.$emit('query', {
projectName: this.searchKeyword || null,
status: this.activeTab === 'all' ? null : this.activeTab
status: includeDeleted || this.activeTab === 'all' ? null : this.activeTab,
includeDeleted
})
}
}

View File

@@ -10,6 +10,7 @@
<search-bar
:show-search="showSearch"
:tab-counts="tabCounts"
:show-deleted-tab="isProjectAdmin"
@query="handleQuery"
/>
@@ -20,11 +21,14 @@
:total="total"
:page-params="queryParams"
:re-analyze-loading-map="reAnalyzeLoadingMap"
:deleted-list="queryParams.includeDeleted === true"
@pagination="handlePagination"
@enter="handleEnter"
@view-result="handleViewResult"
@re-analyze="handleReAnalyze"
@archive="handleArchive"
@delete="handleDelete"
@restore="handleRestore"
/>
<!-- 快捷入口区 -->
@@ -63,7 +67,7 @@
</template>
<script>
import {archiveProject, getStatusCounts, listProject, rebuildProjectTags} from '@/api/ccdiProject'
import {archiveProject, delProject, getStatusCounts, listProject, rebuildProjectTags, restoreProject} from '@/api/ccdiProject'
import SearchBar from './components/SearchBar'
import ProjectTable from './components/ProjectTable'
import QuickEntry from './components/QuickEntry'
@@ -96,7 +100,8 @@ export default {
pageNum: 1,
pageSize: 10,
projectName: null,
status: null
status: null,
includeDeleted: false
},
// 标签页数量统计
tabCounts: {
@@ -105,7 +110,8 @@ export default {
'1': 0,
'2': 0,
'3': 0,
'4': 0
'4': 0,
'5': 0
},
// 新增/编辑弹窗
addDialogVisible: false,
@@ -123,6 +129,12 @@ export default {
created() {
this.getList();
},
computed: {
isProjectAdmin() {
const roles = this.$store && this.$store.getters ? this.$store.getters.roles || [] : []
return roles.includes("admin") || roles.includes("manager")
}
},
methods: {
/** 查询项目列表 */
getList() {
@@ -145,7 +157,8 @@ export default {
'1': counts.status1 || 0,
'2': counts.status2 || 0,
'3': counts.status3 || 0,
'4': counts.status4 || 0
'4': counts.status4 || 0,
'5': counts.status5 || 0
}
this.loading = false
@@ -157,10 +170,21 @@ export default {
},
/** 搜索按钮操作 */
handleQuery(queryParams) {
const nextQueryParams = { ...this.queryParams }
if (queryParams) {
this.queryParams = { ...this.queryParams, ...queryParams };
Object.assign(nextQueryParams, queryParams)
}
this.queryParams.pageNum = 1;
if (nextQueryParams.includeDeleted && !this.isProjectAdmin) {
nextQueryParams.includeDeleted = false
nextQueryParams.status = null
}
if (nextQueryParams.includeDeleted) {
nextQueryParams.status = null
} else {
nextQueryParams.includeDeleted = false
}
nextQueryParams.pageNum = 1
this.queryParams = nextQueryParams
this.getList();
},
/** 分页事件处理 */
@@ -312,9 +336,60 @@ export default {
this.$modal.msgError(message)
}
},
/** 删除项目 */
async handleDelete(row) {
if (!this.canDelete(row)) {
this.$modal.msgWarning("当前项目不能删除")
return
}
try {
await this.$modal.confirm(
`确认删除项目“${row.projectName}”吗?删除后项目进入已删除列表,项目内数据不会删除。`
)
} catch (confirmError) {
if (confirmError === "cancel" || confirmError === "close") {
return
}
throw confirmError
}
try {
await delProject(row.projectId)
this.$modal.msgSuccess("项目删除成功")
this.getList()
} catch (error) {
const message = error && error.message ? error.message : "项目删除失败,请稍后重试"
this.$modal.msgError(message)
}
},
/** 恢复已删除项目 */
async handleRestore(row) {
if (!this.isProjectAdmin) {
this.$modal.msgWarning("当前用户不能恢复项目")
return
}
try {
await this.$modal.confirm(`确认恢复项目“${row.projectName}”吗?项目将恢复为已完成状态。`)
} catch (confirmError) {
if (confirmError === "cancel" || confirmError === "close") {
return
}
throw confirmError
}
try {
await restoreProject(row.projectId)
this.$modal.msgSuccess("项目恢复成功")
this.getList()
} catch (error) {
const message = error && error.message ? error.message : "项目恢复失败,请稍后重试"
this.$modal.msgError(message)
}
},
canOperate(row) {
return !row || row.canOperate !== false
},
canDelete(row) {
return !row || row.canDelete !== false
},
},
};
</script>

View File

@@ -10,6 +10,46 @@ const dialogPath = path.resolve(
const pageSource = fs.readFileSync(pagePath, "utf8");
const dialogSource = fs.readFileSync(dialogPath, "utf8");
assert(
pageSource.includes("delProject") && pageSource.includes("restoreProject"),
"项目列表页应引入删除和恢复接口"
);
assert(
pageSource.includes("includeDeleted: false"),
"默认查询参数应不包含已删除项目"
);
assert(
pageSource.includes(':show-deleted-tab="isProjectAdmin"'),
"已删除入口应仅对项目管理员角色展示"
);
assert(
pageSource.includes("'5': counts.status5 || 0"),
"状态统计应接入已删除数量"
);
assert(
pageSource.includes('await delProject(row.projectId)'),
"删除确认后应调用项目删除接口"
);
assert(
pageSource.includes('await restoreProject(row.projectId)'),
"恢复确认后应调用项目恢复接口"
);
assert(
pageSource.includes("项目内数据不会删除"),
"删除确认文案应明确项目内数据不会删除"
);
assert(
pageSource.includes("项目将恢复为已完成状态"),
"恢复确认文案应明确恢复到已完成状态"
);
assert(
pageSource.includes("await archiveProject(data.projectId)"),
"确认归档后应调用真实归档接口"

View File

@@ -6,6 +6,48 @@ const pagePath = path.resolve(__dirname, "../../src/views/ccdiProject/index.vue"
const tablePath = path.resolve(__dirname, "../../src/views/ccdiProject/components/ProjectTable.vue");
const pageSource = fs.readFileSync(pagePath, "utf8");
const tableSource = fs.readFileSync(tablePath, "utf8");
const searchBarPath = path.resolve(__dirname, "../../src/views/ccdiProject/components/SearchBar.vue");
const searchBarSource = fs.readFileSync(searchBarPath, "utf8");
assert(
searchBarSource.includes("{ label: '已删除', value: 'deleted'"),
"搜索条应提供独立的已删除列表入口"
);
assert(
searchBarSource.includes("tab.value !== 'deleted' || this.showDeletedTab"),
"已删除入口应由 showDeletedTab 控制可见性"
);
assert(
searchBarSource.includes("const includeDeleted = this.activeTab === 'deleted'"),
"切换已删除入口时应生成 includeDeleted 查询态"
);
assert(
searchBarSource.includes("status: includeDeleted || this.activeTab === 'all' ? null : this.activeTab"),
"已删除列表不应和普通状态 tab 混用"
);
assert(
tableSource.includes('v-if="deletedList"'),
"删除列表应使用独立操作区"
);
assert(
tableSource.includes('@click="handleRestore(scope.row)"'),
"删除列表应提供恢复按钮"
);
assert(
tableSource.includes('v-if="canDelete(scope.row) && scope.row.status !== \'5\'"'),
"普通列表应按 canDelete 展示删除按钮且排除已删除状态"
);
assert(
/<template v-if="deletedList">[\s\S]*?handleRestore\(scope\.row\)[\s\S]*?<\/template>\s*<template v-else>[\s\S]*?handleViewResult/.test(tableSource),
"删除列表只展示恢复动作,进入项目和查看结果应留在普通列表分支"
);
assert(
pageSource.includes("rebuildProjectTags({ projectId: row.projectId })"),

View File

@@ -23,5 +23,14 @@ assert(
!source.includes("::v-deep .el-table"),
"项目列表不应继续保留自定义深度表格皮肤"
);
assert(
source.includes('class="delete-button"'),
"项目列表删除按钮应使用独立红色样式类"
);
assert(
source.includes("::v-deep .el-button--text.delete-button") &&
source.includes("color: #f56c6c"),
"项目列表删除按钮应渲染为红色"
);
console.log("project-table-style test passed");

File diff suppressed because one or more lines are too long

View File

@@ -11,7 +11,7 @@ CREATE TABLE `ccdi_project` (
`project_name` VARCHAR(200) NOT NULL COMMENT '项目名称',
`description` VARCHAR(500) DEFAULT NULL COMMENT '项目描述',
`config_type` VARCHAR(20) NOT NULL DEFAULT 'default' COMMENT '配置方式default-全局默认custom-自定义',
`status` CHAR(1) NOT NULL DEFAULT '0' COMMENT '项目状态0-进行中1-已完成2-已归档3-打标中4-打标失败',
`status` CHAR(1) NOT NULL DEFAULT '0' COMMENT '项目状态0-进行中1-已完成2-已归档3-打标中4-打标失败5-已删除',
`is_archived` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否归档0-未归档1-已归档',
`target_count` INT NOT NULL DEFAULT 0 COMMENT '目标人数',
`high_risk_count` INT NOT NULL DEFAULT 0 COMMENT '高风险人数',
@@ -29,7 +29,7 @@ CREATE TABLE `ccdi_project` (
INDEX `idx_is_archived` (`is_archived`),
INDEX `idx_del_flag` (`del_flag`),
INDEX `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='纪检初核项目表';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='纪检初核项目表';
-- ----------------------------
-- 3. 插入项目状态字典
@@ -43,7 +43,8 @@ VALUES
(2, '已完成', '1', 'ccdi_project_status', '', 'success', 'N', '0', 'admin', NOW()),
(3, '已归档', '2', 'ccdi_project_status', '', 'info', 'N', '0', 'admin', NOW()),
(4, '打标中', '3', 'ccdi_project_status', '', 'warning', 'N', '0', 'admin', NOW()),
(5, '打标失败', '4', 'ccdi_project_status', '', 'danger', 'N', '0', 'admin', NOW());
(5, '打标失败', '4', 'ccdi_project_status', '', 'danger', 'N', '0', 'admin', NOW()),
(6, '已删除', '5', 'ccdi_project_status', '', 'danger', 'N', '0', 'admin', NOW());
-- ----------------------------
-- 4. 插入配置方式字典

View File

@@ -0,0 +1,41 @@
ALTER TABLE ccdi_project
MODIFY COLUMN status CHAR(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '0'
COMMENT '项目状态0-进行中1-已完成2-已归档3-打标中4-打标失败5-已删除';
INSERT INTO sys_dict_data (
dict_sort,
dict_label,
dict_value,
dict_type,
css_class,
list_class,
is_default,
status,
create_by,
create_time,
remark
)
SELECT
6,
'已删除',
'5',
'ccdi_project_status',
'',
'danger',
'N',
'0',
'admin',
NOW(),
'项目逻辑删除状态'
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data
WHERE dict_type = 'ccdi_project_status'
AND dict_value = '5'
);
UPDATE sys_role
SET remark = '可查看全部项目,可删除和恢复全部项目,其他操作由后端项目归属校验控制',
update_by = 'admin',
update_time = NOW()
WHERE role_key = 'manager';