实现项目归档功能

This commit is contained in:
wkc
2026-03-24 21:45:55 +08:00
parent bb49d78a3a
commit 294164a504
23 changed files with 680 additions and 87 deletions

View File

@@ -14,6 +14,7 @@ import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import com.ruoyi.common.utils.SecurityUtils;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -53,6 +54,17 @@ public class CcdiProjectController extends BaseController {
return AjaxResult.success("项目更新成功", vo); return AjaxResult.success("项目更新成功", vo);
} }
/**
* 归档项目
*/
@PostMapping("/{projectId}/archive")
@Operation(summary = "归档项目")
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
public AjaxResult archiveProject(@PathVariable Long projectId) {
projectService.archiveProject(projectId, SecurityUtils.getUsername());
return AjaxResult.success("项目归档成功");
}
/** /**
* 删除项目 * 删除项目
*/ */

View File

@@ -60,6 +60,14 @@ public interface ICcdiProjectService {
*/ */
CcdiProjectStatusCountsVO getStatusCounts(); CcdiProjectStatusCountsVO getStatusCounts();
/**
* 归档项目
*
* @param projectId 项目ID
* @param operator 操作人
*/
void archiveProject(Long projectId, String operator);
/** /**
* 更新项目状态 * 更新项目状态
* *
@@ -76,6 +84,14 @@ public interface ICcdiProjectService {
*/ */
void ensureProjectCanStartTagging(Long projectId); void ensureProjectCanStartTagging(Long projectId);
/**
* 校验项目是否未归档
*
* @param projectId 项目ID
* @param message 拒绝文案
*/
void ensureProjectNotArchived(Long projectId, String message);
/** /**
* 校验项目是否允许写入 * 校验项目是否允许写入
* *

View File

@@ -169,6 +169,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
throw new IllegalArgumentException("开始日期不能晚于结束日期"); throw new IllegalArgumentException("开始日期不能晚于结束日期");
} }
projectService.ensureProjectNotArchived(projectId, "已归档项目暂不允许上传或拉取数据");
projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许上传或拉取数据"); projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许上传或拉取数据");
CcdiProject project = projectMapper.selectById(projectId); CcdiProject project = projectMapper.selectById(projectId);
@@ -323,6 +324,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
log.info("【文件上传】开始批量上传: projectId={}, 文件数量={}, username={}", log.info("【文件上传】开始批量上传: projectId={}, 文件数量={}, username={}",
projectId, files.length, username); projectId, files.length, username);
projectService.ensureProjectNotArchived(projectId, "已归档项目暂不允许上传或拉取数据");
projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许上传或拉取数据"); projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许上传或拉取数据");
// 1. 生成批次ID // 1. 生成批次ID

View File

@@ -111,6 +111,7 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
Long projectId = saveDTO.getProjectId(); Long projectId = saveDTO.getProjectId();
if (projectId > 0) { if (projectId > 0) {
projectService.ensureProjectNotArchived(projectId, "已归档项目暂不允许修改参数");
projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许修改参数"); projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许修改参数");
switchToCustomConfigIfNeeded(getRequiredProject(projectId)); switchToCustomConfigIfNeeded(getRequiredProject(projectId));
} }
@@ -192,6 +193,7 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
Long projectId = saveAllDTO.getProjectId(); Long projectId = saveAllDTO.getProjectId();
if (projectId > 0) { if (projectId > 0) {
projectService.ensureProjectNotArchived(projectId, "已归档项目暂不允许修改参数");
projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许修改参数"); projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许修改参数");
switchToCustomConfigIfNeeded(getRequiredProject(projectId)); switchToCustomConfigIfNeeded(getRequiredProject(projectId));
} }

View File

@@ -152,6 +152,26 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
return vo; return vo;
} }
@Override
public void archiveProject(Long projectId, String operator) {
CcdiProject project = getRequiredProject(projectId);
if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())) {
throw new ServiceException("项目已归档,无需重复操作");
}
if (!CcdiProjectStatusConstants.COMPLETED.equals(project.getStatus())) {
throw new ServiceException("仅已完成项目允许归档");
}
project.setStatus(CcdiProjectStatusConstants.ARCHIVED);
project.setIsArchived(1);
project.setUpdateBy(operator);
project.setUpdateTime(new Date());
projectMapper.updateById(project);
log.info("【项目】项目状态变更: projectId={}, projectName={}, oldStatus={}, oldStatusLabel={}, newStatus={}, newStatusLabel={}, operator={}",
project.getProjectId(), project.getProjectName(), CcdiProjectStatusConstants.COMPLETED,
resolveStatusLabel(CcdiProjectStatusConstants.COMPLETED), CcdiProjectStatusConstants.ARCHIVED,
resolveStatusLabel(CcdiProjectStatusConstants.ARCHIVED), resolveOperator(operator));
}
@Override @Override
public void updateProjectStatus(Long projectId, String status, String operator) { public void updateProjectStatus(Long projectId, String status, String operator) {
CcdiProject project = getRequiredProject(projectId); CcdiProject project = getRequiredProject(projectId);
@@ -179,6 +199,14 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
} }
} }
@Override
public void ensureProjectNotArchived(Long projectId, String message) {
CcdiProject project = getRequiredProject(projectId);
if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())) {
throw new ServiceException(message);
}
}
@Override @Override
public void ensureProjectWritable(Long projectId, String message) { public void ensureProjectWritable(Long projectId, String message) {
CcdiProject project = getRequiredProject(projectId); CcdiProject project = getRequiredProject(projectId);

View File

@@ -0,0 +1,33 @@
package com.ruoyi.ccdi.project.controller;
import io.swagger.v3.oas.annotations.Operation;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
class CcdiProjectControllerContractTest {
@Test
void shouldExposeArchiveProjectEndpointContract() throws Exception {
RequestMapping requestMapping = CcdiProjectController.class.getAnnotation(RequestMapping.class);
Method method = CcdiProjectController.class.getMethod("archiveProject", Long.class);
PostMapping postMapping = method.getAnnotation(PostMapping.class);
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
Operation operation = method.getAnnotation(Operation.class);
assertNotNull(requestMapping);
assertEquals("/ccdi/project", requestMapping.value()[0]);
assertNotNull(postMapping);
assertEquals("/{projectId}/archive", postMapping.value()[0]);
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermi('ccdi:project:edit')", preAuthorize.value());
assertNotNull(operation);
assertEquals("归档项目", operation.summary());
}
}

View File

@@ -0,0 +1,55 @@
package com.ruoyi.ccdi.project.controller;
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.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.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class CcdiProjectControllerTest {
@InjectMocks
private CcdiProjectController controller;
@Mock
private ICcdiProjectService projectService;
@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);
Operation operation = method.getAnnotation(Operation.class);
assertNotNull(postMapping);
assertEquals("/{projectId}/archive", postMapping.value()[0]);
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermi('ccdi:project:edit')", preAuthorize.value());
assertNotNull(operation);
assertEquals("归档项目", operation.summary());
}
}

View File

@@ -154,6 +154,8 @@ class CcdiFileUploadServiceImplTest {
assertEquals("admin", inserted.get().get(0).getUploadUser()); assertEquals("admin", inserted.get().get(0).getUploadUser());
assertEquals("uploading", inserted.get().get(0).getFileStatus()); assertEquals("uploading", inserted.get().get(0).getFileStatus());
assertEquals(1, TransactionSynchronizationManager.getSynchronizations().size()); assertEquals(1, TransactionSynchronizationManager.getSynchronizations().size());
verify(projectService).ensureProjectNotArchived(PROJECT_ID, "已归档项目暂不允许上传或拉取数据");
verify(projectService).ensureProjectWritable(PROJECT_ID, "当前项目正在进行银行流水打标,暂不允许上传或拉取数据");
} finally { } finally {
TransactionSynchronizationManager.clearSynchronization(); TransactionSynchronizationManager.clearSynchronization();
} }

View File

@@ -152,6 +152,7 @@ class CcdiModelParamServiceImplTest {
service.saveAllParams(buildSaveAllDto()); service.saveAllParams(buildSaveAllDto());
} }
verify(projectService).ensureProjectNotArchived(40L, "已归档项目暂不允许修改参数");
verify(bankTagService).submitAutoRebuild(40L, TriggerType.AUTO_PARAM_CHANGE); verify(bankTagService).submitAutoRebuild(40L, TriggerType.AUTO_PARAM_CHANGE);
} }
@@ -178,6 +179,7 @@ class CcdiModelParamServiceImplTest {
service.saveParams(saveDTO); service.saveParams(saveDTO);
} }
verify(projectService).ensureProjectNotArchived(40L, "已归档项目暂不允许修改参数");
verify(bankTagService).submitAutoRebuild(40L, TriggerType.AUTO_PARAM_CHANGE); verify(bankTagService).submitAutoRebuild(40L, TriggerType.AUTO_PARAM_CHANGE);
} }

View File

@@ -23,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@@ -68,6 +69,43 @@ class CcdiProjectServiceImplTest {
() -> service.ensureProjectWritable(40L, "当前项目正在进行银行流水打标,暂不允许修改参数")); () -> service.ensureProjectWritable(40L, "当前项目正在进行银行流水打标,暂不允许修改参数"));
} }
@Test
void shouldArchiveCompletedProject() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setProjectName("专案A");
project.setStatus("1");
project.setIsArchived(0);
when(projectMapper.selectById(40L)).thenReturn(project);
service.archiveProject(40L, "tester");
assertEquals("2", project.getStatus());
assertEquals(1, project.getIsArchived());
verify(projectMapper).updateById(project);
}
@Test
void shouldRejectArchivingProjectWhenStatusIsNotCompleted() {
CcdiProject project = new CcdiProject();
project.setProjectId(41L);
project.setStatus("0");
when(projectMapper.selectById(41L)).thenReturn(project);
assertThrows(ServiceException.class, () -> service.archiveProject(41L, "tester"));
}
@Test
void shouldRejectWritingWhenProjectIsArchived() {
CcdiProject archived = new CcdiProject();
archived.setProjectId(42L);
archived.setStatus("2");
when(projectMapper.selectById(42L)).thenReturn(archived);
assertThrows(ServiceException.class,
() -> service.ensureProjectNotArchived(42L, "已归档项目暂不允许修改参数"));
}
@Test @Test
void shouldLogProjectInitialStatusWhenProjectIsCreated() { void shouldLogProjectInitialStatusWhenProjectIsCreated() {
CcdiProjectSaveDTO dto = new CcdiProjectSaveDTO(); CcdiProjectSaveDTO dto = new CcdiProjectSaveDTO();

View File

@@ -0,0 +1,91 @@
# 项目归档后端实施记录
## 本次改动概述
本次后端实施围绕“项目归档”和“归档后禁止继续写入”两条主线展开:
1. 在项目管理控制器中新增归档接口
2. 在项目服务中新增归档动作与归档态校验
3. 将“已归档不可写”保护下沉到上传数据与参数保存服务入口
## 改动文件
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectController.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectService.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImpl.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectControllerTest.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectControllerContractTest.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImplTest.java`
## 实现说明
### 1. 新增项目归档接口
新增接口:
- `POST /ccdi/project/{projectId}/archive`
控制器仅负责:
- 读取 `projectId`
- 获取当前登录用户名
- 调用项目服务归档动作
- 返回“项目归档成功”
### 2. 项目服务新增归档动作
`CcdiProjectServiceImpl` 中新增:
- `archiveProject(Long projectId, String operator)`
- `ensureProjectNotArchived(Long projectId, String message)`
归档动作规则:
- 仅允许 `status = 1` 的已完成项目归档
- 若项目已归档,直接拒绝重复归档
- 归档成功后写入:
- `status = 2`
- `isArchived = 1`
- `updateBy`
- `updateTime`
### 3. 补齐归档态后端写保护
本次不仅做了前端入口限制,还把后端入口一起封住,避免通过绕过页面直接调用接口继续写入。
新增归档校验已接入:
- `CcdiFileUploadServiceImpl`
- 拉取本行信息
- 批量上传文件
- `CcdiModelParamServiceImpl`
- 保存单模型参数
- 保存全部参数
拦截文案分别为:
- `已归档项目暂不允许上传或拉取数据`
- `已归档项目暂不允许修改参数`
## 为什么这样实现
### 为什么新增专用归档接口
归档是独立业务动作,不适合混入“更新项目”接口,否则前端和后端都会失去明确语义,后续状态校验也会分散。
### 为什么把归档保护放到服务层
这次用户要求的是“归档后上传数据和参数配置不可操作”。如果只锁页面页签,依然可能通过接口直调写入数据,因此必须在服务层统一加一层归档态校验,前后端限制才真正一致。
## 结果
本次后端实施后,项目归档链路已经具备完整闭环:
- 列表页可真实归档
- 非法状态无法归档
- 已归档项目无法再上传或拉取数据
- 已归档项目无法再修改参数

View File

@@ -0,0 +1,100 @@
# 项目归档前端实施记录
## 本次改动概述
本次前端实施围绕三个目标完成:
1. 项目列表“归档”按钮接入真实接口
2. 归档确认弹窗收敛到本次需求边界
3. 已归档项目在详情页中锁定“上传数据”“参数配置”,并在子组件中补只读保护
## 改动文件
- `ruoyi-ui/src/views/ccdiProject/index.vue`
- `ruoyi-ui/src/views/ccdiProject/components/ArchiveConfirmDialog.vue`
- `ruoyi-ui/src/views/ccdiProject/detail.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue`
- `ruoyi-ui/tests/unit/project-list-archive-flow.test.js`
- `ruoyi-ui/tests/unit/project-detail-archive-tab-lock.test.js`
- `ruoyi-ui/tests/unit/project-archive-readonly-guard.test.js`
- `ruoyi-ui/tests/unit/upload-data-disabled-cards.test.js`
## 实现说明
### 1. 列表页接入真实归档动作
`index.vue` 中:
- 引入 `archiveProject`
-`handleConfirmArchive` 改为真实异步调用
- 成功后提示“项目归档成功”
- 关闭弹窗并刷新列表
- 失败时展示统一失败提示或透传错误消息
### 2. 收敛归档确认弹窗
`ArchiveConfirmDialog.vue` 中移除了与本次需求无关的旧内容:
- 自动生成 PDF
- 归档库查看 / 恢复
- 同时删除项目相关数据
保留为纯确认弹窗,只表达:
- 确认归档项目
- 归档后上传数据和参数配置页签不可点击
- 项目进入只读查看状态
### 3. 详情页禁用归档态页签
`detail.vue` 中新增:
- `isProjectArchived`
- `isArchiveLockedTab(tab)`
- `resolveAccessibleTab(tab)`
并将其应用到:
- 顶部“上传数据”“参数配置”页签的 `disabled`
- `initActiveTabFromRoute()` 的路由初始化校正
- `handleMenuSelect()` 的点击拦截
- 项目状态变化后的当前页签校正
### 4. 子组件增加归档态只读保护
虽然页签已经不可点,但仍在子组件内补了一层保护,避免未来从其他入口复用组件时绕过限制。
`UploadData.vue`
- 顶部按钮在归档态下禁用
- 拉取本行信息、身份证文件解析、批量上传等操作方法在归档态前置返回
- 流水上传卡片在归档态下置灰
`ParamConfig.vue`
- 展示“已归档项目暂不可修改参数”提示
- 输入框和保存按钮在归档态下禁用
- `handleSaveAll()` 在归档态直接返回
## 为什么这样实现
### 为什么弹窗文案必须收敛
弹窗文案必须和真实业务动作一致,否则会出现“页面承诺了删数据 / 生成 PDF / 归档库,但实际没做”的偏差,容易造成误解。
### 为什么页签禁用和 URL 拦截要同时做
只做禁用态还不够,用户仍可通过地址栏直接访问 `?tab=upload``?tab=config`。因此必须在路由初始化时同步改写到 `overview`
### 为什么组件级保护仍要保留
页签锁定解决的是主入口问题,组件级保护解决的是复用和绕过问题。两层一起做,归档态只读限制才稳。
## 结果
本次前端实施后,用户在列表页归档项目后:
- 列表会显示真实归档结果
- 已归档项目详情页中无法点击“上传数据”“参数配置”
- 即使访问旧链接,也会自动切到“结果总览”

View File

@@ -0,0 +1,40 @@
# 项目归档后端验证记录
## 验证范围
- 归档接口仅允许已完成项目调用
- 归档成功后状态和 `isArchived` 正确写入
- 已归档项目后端禁止上传或拉取数据
- 已归档项目后端禁止修改参数
## 执行命令
```bash
mvn -pl ccdi-project -Dtest=CcdiProjectServiceImplTest,CcdiFileUploadServiceImplTest,CcdiModelParamServiceImplTest,CcdiProjectControllerTest,CcdiProjectControllerContractTest test
```
## 验证结果
- 命令执行成功,退出码为 `0`
- 本次共执行 `44` 条测试
- 结果为:
- `Failures: 0`
- `Errors: 0`
- `Skipped: 0`
## 覆盖结论
- `CcdiProjectControllerTest` / `CcdiProjectControllerContractTest`
- 归档接口路径、权限注解、返回结果已生效
- `CcdiProjectServiceImplTest`
- 已完成项目可归档
- 非已完成项目不可归档
- 已归档项目会被写保护拒绝
- `CcdiFileUploadServiceImplTest`
- 上传/拉取链路会先检查归档态
- `CcdiModelParamServiceImplTest`
- 参数保存链路会先检查归档态
## 结论
后端归档接口、状态流转和归档态写保护均已通过本地验证。

View File

@@ -0,0 +1,44 @@
# 项目归档前端验证记录
## 验证范围
- 列表归档按钮接入真实接口
- 归档确认弹窗文案已收敛
- 详情页上传数据与参数配置页签不可点击
- 归档态下上传页与参数页存在组件级只读保护
## 执行命令
```bash
cd ruoyi-ui
node tests/unit/project-list-archive-flow.test.js
node tests/unit/project-detail-archive-tab-lock.test.js
node tests/unit/upload-data-disabled-cards.test.js
node tests/unit/project-archive-readonly-guard.test.js
node tests/unit/project-detail-tagging-polling.test.js
```
## 验证结果
- 五条前端契约测试均执行成功
- 所有命令退出码为 `0`
## 覆盖结论
- `project-list-archive-flow.test.js`
- 列表归档已接真实接口
- 归档成功提示正确
- 弹窗已清理超需求文案
- `project-detail-archive-tab-lock.test.js`
- 已归档项目的上传数据与参数配置页签被锁定
- 路由初始化会校正受限页签
- `upload-data-disabled-cards.test.js`
- 流水上传卡片在归档态和打标态都会置灰
- `project-archive-readonly-guard.test.js`
- 上传页和参数页都已声明归档态保护
- `project-detail-tagging-polling.test.js`
- 新增归档逻辑未破坏既有打标轮询结构
## 结论
前端归档交互、页签禁用和组件级归档态保护均已通过本地验证。

View File

@@ -27,24 +27,15 @@
</li> </li>
<li> <li>
<i class="el-icon-check"></i> <i class="el-icon-check"></i>
<span>自动生成项目报告PDF</span> <span>项目详情中的上传数据与参数配置页签将不可点击</span>
</li>
<li>
<i class="el-icon-check"></i>
<span>移入归档库可随时查看</span>
</li> </li>
</ul> </ul>
</div> </div>
<!-- 删除选项 -->
<div class="delete-option">
<el-checkbox v-model="deleteData">同时删除项目相关数据不可恢复</el-checkbox>
</div>
<!-- 提示信息 --> <!-- 提示信息 -->
<div class="archive-hint"> <div class="archive-hint">
<i class="el-icon-info"></i> <i class="el-icon-info"></i>
<span>归档后可从"归档库"中查看和恢复</span> <span>归档后项目进入只读查看状态请确认后继续</span>
</div> </div>
</div> </div>
@@ -77,8 +68,7 @@ export default {
}, },
data() { data() {
return { return {
submitting: false, submitting: false
deleteData: false
} }
}, },
computed: { computed: {
@@ -93,44 +83,21 @@ export default {
} }
} }
}, },
watch: {
visible(val) {
if (!val) {
this.deleteData = false
}
}
},
methods: { methods: {
handleConfirm() { handleConfirm() {
if (this.deleteData) { this.doArchive()
this.$confirm('删除后数据将无法恢复,是否确认删除?', '警告', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.doArchive()
}).catch(() => {
// 取消删除,但仍归档
this.deleteData = false
this.doArchive()
})
} else {
this.doArchive()
}
}, },
doArchive() { doArchive() {
this.submitting = true this.submitting = true
setTimeout(() => { setTimeout(() => {
this.submitting = false this.submitting = false
this.$emit('confirm', { this.$emit('confirm', {
projectId: this.projectData.projectId, projectId: this.projectData.projectId
deleteData: this.deleteData
}) })
}, 500) }, 500)
}, },
handleClose() { handleClose() {
this.$emit('close') this.$emit('close')
this.deleteData = false
} }
} }
} }
@@ -203,22 +170,6 @@ export default {
} }
} }
.delete-option {
margin-bottom: 16px;
padding: 12px;
background-color: #fef0f0;
border-radius: 6px;
border: 1px solid #fde2e2;
:deep(.el-checkbox__label) {
color: #F56C6C;
}
:deep(.el-checkbox__input.is-checked + .el-checkbox__label) {
color: #F56C6C;
}
}
.archive-hint { .archive-hint {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="param-config-container" v-loading="loading" element-loading-text="加载中..."> <div class="param-config-container" v-loading="loading" element-loading-text="加载中...">
<div v-if="isProjectTagging" class="readonly-tip"> <div v-if="isProjectTagging || isProjectArchived" class="readonly-tip">
项目正在进行银行流水打标参数暂不可修改 {{ isProjectArchived ? "已归档项目暂不可修改参数" : "项目正在进行银行流水打标参数暂不可修改" }}
</div> </div>
<!-- 模型参数卡片组垂直堆叠 --> <!-- 模型参数卡片组垂直堆叠 -->
@@ -24,7 +24,7 @@
<template #default="{ row }"> <template #default="{ row }">
<el-input <el-input
v-model="row.paramValue" v-model="row.paramValue"
:disabled="isProjectTagging" :disabled="isProjectTagging || isProjectArchived"
placeholder="请输入阈值" placeholder="请输入阈值"
@input="markAsModified(model.modelCode, row)" @input="markAsModified(model.modelCode, row)"
/> />
@@ -42,7 +42,7 @@
<!-- 统一保存按钮 --> <!-- 统一保存按钮 -->
<div class="button-section" v-if="!loading && modelGroups.length > 0"> <div class="button-section" v-if="!loading && modelGroups.length > 0">
<el-button type="primary" :disabled="isProjectTagging || saving" @click="handleSaveAll" :loading="saving"> <el-button type="primary" :disabled="isProjectTagging || isProjectArchived || saving" @click="handleSaveAll" :loading="saving">
保存所有修改 保存所有修改
</el-button> </el-button>
<span v-if="modifiedCount > 0" class="modified-tip"> <span v-if="modifiedCount > 0" class="modified-tip">
@@ -82,6 +82,9 @@ export default {
}, },
isProjectTagging() { isProjectTagging() {
return String(this.projectInfo.projectStatus) === "3"; return String(this.projectInfo.projectStatus) === "3";
},
isProjectArchived() {
return String(this.projectInfo.projectStatus) === "2";
} }
}, },
watch: { watch: {
@@ -159,6 +162,10 @@ export default {
}, },
async handleSaveAll() { async handleSaveAll() {
if (this.isProjectArchived) {
this.$message.warning("已归档项目暂不可修改参数");
return;
}
if (this.isProjectTagging) { if (this.isProjectTagging) {
this.$message.warning("项目正在进行银行流水打标,参数暂不可修改"); this.$message.warning("项目正在进行银行流水打标,参数暂不可修改");
return; return;

View File

@@ -18,7 +18,7 @@
<el-button <el-button
size="small" size="small"
icon="el-icon-download" icon="el-icon-download"
:disabled="isProjectTagging" :disabled="isProjectTagging || isProjectArchived"
@click="handleFetchBankInfo" @click="handleFetchBankInfo"
> >
拉取本行信息 拉取本行信息
@@ -26,7 +26,7 @@
<el-button <el-button
size="small" size="small"
icon="el-icon-upload2" icon="el-icon-upload2"
:disabled="isProjectTagging" :disabled="isProjectTagging || isProjectArchived"
@click="handleGoCreditInfoPage" @click="handleGoCreditInfoPage"
> >
征信导入 征信导入
@@ -34,8 +34,8 @@
</div> </div>
</div> </div>
<div v-if="isProjectTagging" class="tagging-lock-tip"> <div v-if="isProjectTagging || isProjectArchived" class="tagging-lock-tip">
项目正在进行银行流水打标暂不可上传或拉取数据 {{ isProjectArchived ? "项目已归档暂不可上传或拉取数据" : "项目正在进行银行流水打标暂不可上传或拉取数据" }}
</div> </div>
<!-- 上传模块 --> <!-- 上传模块 -->
@@ -183,7 +183,7 @@
v-model="pullBankInfoForm.idCardText" v-model="pullBankInfoForm.idCardText"
type="textarea" type="textarea"
:rows="5" :rows="5"
:disabled="isProjectTagging" :disabled="isProjectTagging || isProjectArchived"
placeholder="支持逗号、中文逗号、换行分隔" placeholder="支持逗号、中文逗号、换行分隔"
/> />
<div class="pull-bank-field-tip"> <div class="pull-bank-field-tip">
@@ -201,7 +201,7 @@
:show-file-list="false" :show-file-list="false"
:file-list="idCardFileList" :file-list="idCardFileList"
accept=".xls,.xlsx" accept=".xls,.xlsx"
:disabled="isProjectTagging" :disabled="isProjectTagging || isProjectArchived"
:on-change="handleIdCardFileChange" :on-change="handleIdCardFileChange"
:on-remove="handleIdCardFileRemove" :on-remove="handleIdCardFileRemove"
> >
@@ -233,7 +233,7 @@
<el-date-picker <el-date-picker
class="pull-bank-range-picker" class="pull-bank-range-picker"
v-model="pullBankInfoForm.dateRange" v-model="pullBankInfoForm.dateRange"
:disabled="isProjectTagging" :disabled="isProjectTagging || isProjectArchived"
type="daterange" type="daterange"
value-format="yyyy-MM-dd" value-format="yyyy-MM-dd"
:picker-options="pullBankInfoDatePickerOptions" :picker-options="pullBankInfoDatePickerOptions"
@@ -247,7 +247,7 @@
<el-button @click="pullBankInfoDialogVisible = false">取消</el-button> <el-button @click="pullBankInfoDialogVisible = false">取消</el-button>
<el-button <el-button
type="primary" type="primary"
:disabled="isProjectTagging" :disabled="isProjectTagging || isProjectArchived"
:loading="pullBankInfoLoading" :loading="pullBankInfoLoading"
@click="handleConfirmPullBankInfo" @click="handleConfirmPullBankInfo"
> >
@@ -268,7 +268,7 @@
drag drag
action="#" action="#"
multiple multiple
:disabled="isProjectTagging" :disabled="isProjectTagging || isProjectArchived"
:auto-upload="false" :auto-upload="false"
:on-change="handleBatchFileChange" :on-change="handleBatchFileChange"
:show-file-list="false" :show-file-list="false"
@@ -307,7 +307,7 @@
<el-button @click="batchUploadDialogVisible = false">取消</el-button> <el-button @click="batchUploadDialogVisible = false">取消</el-button>
<el-button <el-button
type="primary" type="primary"
:disabled="isProjectTagging || selectedFiles.length === 0" :disabled="isProjectTagging || isProjectArchived || selectedFiles.length === 0"
:loading="uploadLoading" :loading="uploadLoading"
@click="handleBatchUpload" @click="handleBatchUpload"
>开始上传</el-button >开始上传</el-button
@@ -450,6 +450,9 @@ export default {
isProjectTagging() { isProjectTagging() {
return String(this.projectInfo.projectStatus) === "3"; return String(this.projectInfo.projectStatus) === "3";
}, },
isProjectArchived() {
return String(this.projectInfo.projectStatus) === "2";
},
isReportDisabled() { isReportDisabled() {
return ["0", "3"].includes(String(this.projectInfo.projectStatus)); return ["0", "3"].includes(String(this.projectInfo.projectStatus));
}, },
@@ -531,7 +534,7 @@ export default {
if (card.key === "transaction") { if (card.key === "transaction") {
return { return {
...card, ...card,
disabled: this.isProjectTagging, disabled: this.isProjectTagging || this.isProjectArchived,
}; };
} }
return card; return card;
@@ -580,7 +583,7 @@ export default {
this.$emit("menu-change", { key: "overview", route: "overview" }); this.$emit("menu-change", { key: "overview", route: "overview" });
}, },
handleGoCreditInfoPage() { handleGoCreditInfoPage() {
if (this.isProjectTagging) { if (this.isProjectTagging || this.isProjectArchived) {
return; return;
} }
this.$router.push("/maintain/creditInfo"); this.$router.push("/maintain/creditInfo");
@@ -636,6 +639,9 @@ export default {
return Array.from(new Set(merged)).join(", "); return Array.from(new Set(merged)).join(", ");
}, },
async handleIdCardFileChange(file, fileList) { async handleIdCardFileChange(file, fileList) {
if (this.isProjectTagging || this.isProjectArchived) {
return;
}
const latestFile = (fileList || []).slice(-1); const latestFile = (fileList || []).slice(-1);
const currentFile = latestFile[0] || file; const currentFile = latestFile[0] || file;
const fileName = (currentFile && currentFile.name) || ""; const fileName = (currentFile && currentFile.name) || "";
@@ -716,8 +722,12 @@ export default {
return this.parseIdCardText(this.pullBankInfoForm.idCardText); return this.parseIdCardText(this.pullBankInfoForm.idCardText);
}, },
async handleConfirmPullBankInfo() { async handleConfirmPullBankInfo() {
if (this.isProjectTagging) { if (this.isProjectTagging || this.isProjectArchived) {
this.$message.warning("项目正在进行银行流水打标,暂不可上传或拉取数据"); this.$message.warning(
this.isProjectArchived
? "项目已归档,暂不可上传或拉取数据"
: "项目正在进行银行流水打标,暂不可上传或拉取数据"
);
return; return;
} }
const idCards = this.buildFinalIdCardList(); const idCards = this.buildFinalIdCardList();
@@ -776,8 +786,12 @@ export default {
}, },
/** 拉取本行信息 */ /** 拉取本行信息 */
handleFetchBankInfo() { handleFetchBankInfo() {
if (this.isProjectTagging) { if (this.isProjectTagging || this.isProjectArchived) {
this.$message.warning("项目正在进行银行流水打标,暂不可上传或拉取数据"); this.$message.warning(
this.isProjectArchived
? "项目已归档,暂不可上传或拉取数据"
: "项目正在进行银行流水打标,暂不可上传或拉取数据"
);
return; return;
} }
this.resetPullBankInfoForm(); this.resetPullBankInfoForm();
@@ -868,8 +882,12 @@ export default {
/** 开始批量上传 */ /** 开始批量上传 */
async handleBatchUpload() { async handleBatchUpload() {
if (this.isProjectTagging) { if (this.isProjectTagging || this.isProjectArchived) {
this.$message.warning("项目正在进行银行流水打标,暂不可上传或拉取数据"); this.$message.warning(
this.isProjectArchived
? "项目已归档,暂不可上传或拉取数据"
: "项目正在进行银行流水打标,暂不可上传或拉取数据"
);
return; return;
} }
if (this.selectedFiles.length === 0) { if (this.selectedFiles.length === 0) {

View File

@@ -39,8 +39,8 @@
@select="handleMenuSelect" @select="handleMenuSelect"
class="nav-menu" class="nav-menu"
> >
<el-menu-item index="upload">上传数据</el-menu-item> <el-menu-item index="upload" :disabled="isArchiveLockedTab('upload')">上传数据</el-menu-item>
<el-menu-item index="config">参数配置</el-menu-item> <el-menu-item index="config" :disabled="isArchiveLockedTab('config')">参数配置</el-menu-item>
<el-menu-item index="overview">结果总览</el-menu-item> <el-menu-item index="overview">结果总览</el-menu-item>
<el-menu-item index="special">专项排查</el-menu-item> <el-menu-item index="special">专项排查</el-menu-item>
<el-menu-item index="detail">流水明细查询</el-menu-item> <el-menu-item index="detail">流水明细查询</el-menu-item>
@@ -107,6 +107,11 @@ export default {
projectStatusPollingLoading: false, projectStatusPollingLoading: false,
}; };
}, },
computed: {
isProjectArchived() {
return String(this.projectInfo.projectStatus) === "2";
},
},
watch: { watch: {
"$route.params.projectId"(newId) { "$route.params.projectId"(newId) {
this.stopProjectStatusPolling(); this.stopProjectStatusPolling();
@@ -123,6 +128,10 @@ export default {
}, },
"projectInfo.projectStatus"() { "projectInfo.projectStatus"() {
this.syncProjectStatusPolling(); this.syncProjectStatusPolling();
const accessibleTab = this.resolveAccessibleTab(this.activeTab);
if (accessibleTab !== this.activeTab) {
this.setActiveTab(accessibleTab);
}
}, },
}, },
created() { created() {
@@ -138,7 +147,16 @@ export default {
const tab = (this.$route.query && this.$route.query.tab) || ""; const tab = (this.$route.query && this.$route.query.tab) || "";
const validTabs = ["upload", "config", "overview", "special", "detail"]; const validTabs = ["upload", "config", "overview", "special", "detail"];
const targetTab = validTabs.includes(tab) ? tab : "upload"; const targetTab = validTabs.includes(tab) ? tab : "upload";
this.setActiveTab(targetTab); this.setActiveTab(this.resolveAccessibleTab(targetTab));
},
isArchiveLockedTab(tab) {
return this.isProjectArchived && ["upload", "config"].includes(tab);
},
resolveAccessibleTab(tab) {
if (this.isArchiveLockedTab(tab)) {
return "overview";
}
return tab;
}, },
setActiveTab(index) { setActiveTab(index) {
this.activeTab = index; this.activeTab = index;
@@ -319,6 +337,9 @@ export default {
}, },
/** 菜单选择事件 */ /** 菜单选择事件 */
handleMenuSelect(index) { handleMenuSelect(index) {
if (this.isArchiveLockedTab(index)) {
return;
}
console.log("菜单选择:", index); console.log("菜单选择:", index);
this.setActiveTab(index); this.setActiveTab(index);
}, },

View File

@@ -62,7 +62,7 @@
</template> </template>
<script> <script>
import {getStatusCounts, listProject, rebuildProjectTags} from '@/api/ccdiProject' import {archiveProject, getStatusCounts, listProject, rebuildProjectTags} from '@/api/ccdiProject'
import SearchBar from './components/SearchBar' import SearchBar from './components/SearchBar'
import ProjectTable from './components/ProjectTable' import ProjectTable from './components/ProjectTable'
import QuickEntry from './components/QuickEntry' import QuickEntry from './components/QuickEntry'
@@ -268,11 +268,17 @@ export default {
this.archiveDialogVisible = true; this.archiveDialogVisible = true;
}, },
/** 确认归档 */ /** 确认归档 */
handleConfirmArchive(data) { async handleConfirmArchive(data) {
console.log("确认归档:", data); try {
this.$modal.msgSuccess("项目已归档"); await archiveProject(data.projectId)
this.archiveDialogVisible = false; this.$modal.msgSuccess("项目归档成功")
this.getList(); this.archiveDialogVisible = false
this.currentArchiveProject = null
this.getList()
} catch (error) {
const message = error && error.message ? error.message : "项目归档失败,请稍后重试"
this.$modal.msgError(message)
}
}, },
}, },
}; };

View File

@@ -0,0 +1,42 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const uploadPath = path.resolve(
__dirname,
"../../src/views/ccdiProject/components/detail/UploadData.vue"
);
const paramPath = path.resolve(
__dirname,
"../../src/views/ccdiProject/components/detail/ParamConfig.vue"
);
const uploadSource = fs.readFileSync(uploadPath, "utf8");
const paramSource = fs.readFileSync(paramPath, "utf8");
assert(
uploadSource.includes('return String(this.projectInfo.projectStatus) === "2";'),
"上传数据页应声明归档态判断"
);
assert(
/if\s*\(this\.isProjectTagging\s*\|\|\s*this\.isProjectArchived\)/.test(uploadSource),
"上传数据页的操作入口应同时拦截打标中和已归档"
);
assert(
paramSource.includes('return String(this.projectInfo.projectStatus) === "2";'),
"参数配置页应声明归档态判断"
);
assert(
paramSource.includes("已归档项目暂不可修改参数"),
"参数配置页应展示归档态只读提示"
);
assert(
/:disabled="isProjectTagging \|\| isProjectArchived \|\| saving"/.test(paramSource) ||
/:disabled="isProjectTagging \|\| isProjectArchived"/.test(paramSource),
"参数保存入口应在归档态下禁用"
);
console.log("project-archive-readonly-guard test passed");

View File

@@ -0,0 +1,40 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const componentPath = path.resolve(
__dirname,
"../../src/views/ccdiProject/detail.vue"
);
const source = fs.readFileSync(componentPath, "utf8");
assert(
source.includes('index="upload"') &&
source.includes(`:disabled="isArchiveLockedTab('upload')"`) &&
source.includes('index="config"') &&
source.includes(`:disabled="isArchiveLockedTab('config')"`),
"上传数据和参数配置页签应支持归档态禁用"
);
assert(
/isProjectArchived\(\)\s*\{\s*return String\(this\.projectInfo\.projectStatus\) === "2";\s*\}/.test(
source
),
"详情页应声明归档态判断"
);
assert(
/initActiveTabFromRoute\(\)\s*\{[\s\S]*?this\.resolveAccessibleTab\(targetTab\)/.test(
source
),
"详情页应在路由初始化时校正归档态不可访问页签"
);
assert(
/handleMenuSelect\(index\)\s*\{[\s\S]*?if\s*\(this\.isArchiveLockedTab\(index\)\)\s*\{\s*return;\s*\}/.test(
source
),
"点击禁用页签时不应切换"
);
console.log("project-detail-archive-tab-lock test passed");

View File

@@ -0,0 +1,43 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const pagePath = path.resolve(__dirname, "../../src/views/ccdiProject/index.vue");
const dialogPath = path.resolve(
__dirname,
"../../src/views/ccdiProject/components/ArchiveConfirmDialog.vue"
);
const pageSource = fs.readFileSync(pagePath, "utf8");
const dialogSource = fs.readFileSync(dialogPath, "utf8");
assert(
pageSource.includes("await archiveProject(data.projectId)"),
"确认归档后应调用真实归档接口"
);
assert(
pageSource.includes('this.$modal.msgSuccess("项目归档成功")') ||
pageSource.includes("this.$modal.msgSuccess('项目归档成功')"),
"归档成功后应提示项目归档成功"
);
assert(
pageSource.includes("项目归档失败,请稍后重试"),
"归档失败时应有统一兜底提示"
);
[
"自动生成项目报告PDF",
"移入归档库,可随时查看",
"同时删除项目相关数据",
"归档后可从\"归档库\"中查看和恢复",
].forEach((token) => {
assert(!dialogSource.includes(token), `归档弹窗不应保留超范围文案: ${token}`);
});
assert(
!dialogSource.includes("deleteData"),
"归档弹窗不应再保留删除数据状态"
);
console.log("project-list-archive-flow test passed");

View File

@@ -23,10 +23,10 @@ assert(
); );
assert( assert(
/syncUploadCardDisabledState\(\)\s*\{[\s\S]*?card\.key === "transaction"[\s\S]*?disabled:\s*this\.isProjectTagging/.test( /syncUploadCardDisabledState\(\)\s*\{[\s\S]*?card\.key === "transaction"[\s\S]*?disabled:\s*this\.isProjectTagging\s*\|\|\s*this\.isProjectArchived/.test(
source source
), ),
"流水导入卡片应在项目打标中时同步置灰" "流水导入卡片应在项目打标中或已归档时同步置灰"
); );
assert( assert(