修复流水异常标签展示与导出

This commit is contained in:
wkc
2026-03-19 10:20:58 +08:00
parent e058cec78e
commit 144897237b
17 changed files with 530 additions and 5 deletions

View File

@@ -41,6 +41,10 @@ public class CcdiBankStatementExcel {
@Excel(name = "交易类型")
private String cashType;
/** 异常标签 */
@Excel(name = "异常标签")
private String hitTags;
/** 交易金额 */
@Excel(name = "交易金额")
private BigDecimal displayAmount;

View File

@@ -3,7 +3,9 @@ package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* 流水明细详情VO
@@ -96,4 +98,7 @@ public class CcdiBankStatementDetailVO {
/** 原始文件上传时间 */
private Date uploadTime;
/** 命中异常标签 */
private List<CcdiBankStatementHitTagVO> hitTags = new ArrayList<>();
}

View File

@@ -0,0 +1,25 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 流水命中异常标签VO
*/
@Data
public class CcdiBankStatementHitTagVO {
/** 规则编码 */
private String ruleCode;
/** 规则名称 */
private String ruleName;
/** 风险等级 */
private String riskLevel;
/** 命中原因 */
private String reasonDetail;
/** 流水ID */
private Long bankStatementId;
}

View File

@@ -3,6 +3,8 @@ package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 流水明细列表VO
@@ -38,4 +40,7 @@ public class CcdiBankStatementListVO {
/** 页面展示金额 */
private BigDecimal displayAmount;
/** 命中异常标签 */
private List<CcdiBankStatementHitTagVO> hitTags = new ArrayList<>();
}

View File

@@ -2,6 +2,7 @@ package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagResult;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@@ -20,6 +21,18 @@ public interface CcdiBankTagResultMapper extends BaseMapper<CcdiBankTagResult> {
*/
int deleteByProjectAndModel(@Param("projectId") Long projectId, @Param("modelCode") String modelCode);
/**
* 按项目和流水ID批量查询命中的异常标签
*
* @param projectId 项目ID
* @param bankStatementIds 流水ID列表
* @return 命中的异常标签列表
*/
List<CcdiBankStatementHitTagVO> selectStatementTagsByProjectAndStatementIds(
@Param("projectId") Long projectId,
@Param("bankStatementIds") List<Long> bankStatementIds
);
/**
* 批量插入结果
*

View File

@@ -5,16 +5,21 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankStatementService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.Collections;
import java.util.Map;
import java.util.stream.Collectors;
/**
@@ -31,6 +36,9 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
@Resource
private CcdiBankStatementMapper bankStatementMapper;
@Resource
private CcdiBankTagResultMapper bankTagResultMapper;
@Override
public CcdiBankStatementFilterOptionsVO getFilterOptions(Long projectId) {
CcdiBankStatementFilterOptionsVO options = bankStatementMapper.selectFilterOptions(projectId);
@@ -42,7 +50,9 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
CcdiBankStatementQueryDTO queryDTO) {
CcdiBankStatementQueryDTO normalizedQuery = queryDTO == null ? new CcdiBankStatementQueryDTO() : queryDTO;
normalizeQuery(normalizedQuery);
return bankStatementMapper.selectStatementPage(page, normalizedQuery);
Page<CcdiBankStatementListVO> result = bankStatementMapper.selectStatementPage(page, normalizedQuery);
attachHitTags(result == null ? Collections.emptyList() : result.getRecords(), normalizedQuery.getProjectId());
return result;
}
@Override
@@ -53,12 +63,58 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
if (rows == null || rows.isEmpty()) {
return Collections.emptyList();
}
attachHitTags(rows, normalizedQuery.getProjectId());
return rows.stream().map(this::toExcel).collect(Collectors.toList());
}
@Override
public CcdiBankStatementDetailVO getStatementDetail(Long bankStatementId) {
return bankStatementMapper.selectStatementDetailById(bankStatementId);
CcdiBankStatementDetailVO detail = bankStatementMapper.selectStatementDetailById(bankStatementId);
if (detail == null || detail.getProjectId() == null || detail.getBankStatementId() == null) {
return detail;
}
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = loadHitTagMap(
detail.getProjectId(),
List.of(detail.getBankStatementId())
);
detail.setHitTags(new ArrayList<>(hitTagMap.getOrDefault(detail.getBankStatementId(), Collections.emptyList())));
return detail;
}
private void attachHitTags(List<CcdiBankStatementListVO> rows, Long projectId) {
if (rows == null || rows.isEmpty() || projectId == null) {
return;
}
List<Long> bankStatementIds = rows.stream()
.map(CcdiBankStatementListVO::getBankStatementId)
.filter(item -> item != null)
.distinct()
.collect(Collectors.toList());
if (bankStatementIds.isEmpty()) {
return;
}
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = loadHitTagMap(projectId, bankStatementIds);
rows.forEach(row -> row.setHitTags(new ArrayList<>(
hitTagMap.getOrDefault(row.getBankStatementId(), Collections.emptyList())
)));
}
private Map<Long, List<CcdiBankStatementHitTagVO>> loadHitTagMap(Long projectId, List<Long> bankStatementIds) {
if (projectId == null || bankStatementIds == null || bankStatementIds.isEmpty()) {
return Collections.emptyMap();
}
List<CcdiBankStatementHitTagVO> hitTags =
bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(projectId, bankStatementIds);
if (hitTags == null || hitTags.isEmpty()) {
return Collections.emptyMap();
}
return hitTags.stream()
.filter(item -> item.getBankStatementId() != null)
.collect(Collectors.groupingBy(
CcdiBankStatementHitTagVO::getBankStatementId,
LinkedHashMap::new,
Collectors.toList()
));
}
private void normalizeQuery(CcdiBankStatementQueryDTO queryDTO) {
@@ -138,7 +194,19 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
excel.setCustomerAccountNo(row.getCustomerAccountNo());
excel.setUserMemo(row.getUserMemo());
excel.setCashType(row.getCashType());
excel.setHitTags(formatHitTags(row.getHitTags()));
excel.setDisplayAmount(row.getDisplayAmount());
return excel;
}
private String formatHitTags(List<CcdiBankStatementHitTagVO> hitTags) {
if (hitTags == null || hitTags.isEmpty()) {
return "";
}
return hitTags.stream()
.map(CcdiBankStatementHitTagVO::getRuleName)
.filter(item -> item != null && !item.isBlank())
.distinct()
.collect(Collectors.joining(""));
}
}

View File

@@ -29,6 +29,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="remark" column="remark"/>
</resultMap>
<resultMap id="CcdiBankStatementHitTagVOResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO">
<result property="ruleCode" column="rule_code"/>
<result property="ruleName" column="rule_name"/>
<result property="riskLevel" column="risk_level"/>
<result property="reasonDetail" column="reason_detail"/>
<result property="bankStatementId" column="bank_statement_id"/>
</resultMap>
<delete id="deleteByProjectAndModel">
delete from ccdi_bank_statement_tag_result
where project_id = #{projectId}
@@ -37,6 +45,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</if>
</delete>
<select id="selectStatementTagsByProjectAndStatementIds" resultMap="CcdiBankStatementHitTagVOResultMap">
select
rule_code,
rule_name,
risk_level,
reason_detail,
bank_statement_id
from ccdi_bank_statement_tag_result
where project_id = #{projectId}
and bank_statement_id is not null
and bank_statement_id IN
<foreach collection="bankStatementIds" item="item" open="(" separator="," close=")">
#{item}
</foreach>
order by bank_statement_id asc, id asc
</select>
<insert id="insertBatch" parameterType="java.util.List">
insert into ccdi_bank_statement_tag_result (
project_id, model_code, model_name, rule_code, rule_name, indicator_code,

View File

@@ -0,0 +1,31 @@
package com.ruoyi.ccdi.project.domain.vo;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import static org.junit.jupiter.api.Assertions.assertNotNull;
class CcdiBankStatementHitTagsContractTest {
@Test
void listVo_shouldExposeHitTagsField() throws Exception {
Field field = CcdiBankStatementListVO.class.getDeclaredField("hitTags");
assertNotNull(field, "流水列表VO应返回命中异常标签");
}
@Test
void detailVo_shouldExposeHitTagsField() throws Exception {
Field field = CcdiBankStatementDetailVO.class.getDeclaredField("hitTags");
assertNotNull(field, "流水详情VO应返回命中异常标签");
}
@Test
void excelVo_shouldExposeHitTagsField() throws Exception {
Field field = com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel.class.getDeclaredField("hitTags");
assertNotNull(field, "流水导出对象应返回异常标签文本列");
}
}

View File

@@ -2,9 +2,12 @@ package com.ruoyi.ccdi.project.mapper;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiBankTagResultMapperXmlTest {
@@ -20,4 +23,25 @@ class CcdiBankTagResultMapperXmlTest {
assertTrue(xml.contains("model_code = #{modelCode}"));
}
}
@Test
void mapper_shouldExposeStatementTagQueryForBankStatementDetails() {
Method method = Arrays.stream(CcdiBankTagResultMapper.class.getDeclaredMethods())
.filter(item -> "selectStatementTagsByProjectAndStatementIds".equals(item.getName()))
.findFirst()
.orElse(null);
assertNotNull(method, "应提供按项目和流水ID批量查询异常标签的方法");
}
@Test
void xml_shouldDefineStatementTagQueryForBankStatementDetails() throws Exception {
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(xml.contains("selectStatementTagsByProjectAndStatementIds"), xml);
assertTrue(xml.contains("bank_statement_id IN"), xml);
assertTrue(xml.contains("project_id = #{projectId}"), xml);
}
}
}

View File

@@ -5,9 +5,11 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementOptionVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -36,6 +38,9 @@ class CcdiBankStatementServiceImplTest {
@Mock
private CcdiBankStatementMapper bankStatementMapper;
@Mock
private CcdiBankTagResultMapper bankTagResultMapper;
@Test
void getFilterOptions_shouldReturnProjectWideOptions() {
CcdiBankStatementFilterOptionsVO options = new CcdiBankStatementFilterOptionsVO();
@@ -54,6 +59,7 @@ class CcdiBankStatementServiceImplTest {
queryDTO.setProjectId(100L);
queryDTO.setOrderBy("amount");
queryDTO.setOrderDirection("desc");
page.setRecords(List.of(new CcdiBankStatementListVO()));
doReturn(page).when(bankStatementMapper).selectStatementPage(eq(page), same(queryDTO));
service.selectStatementPage(page, queryDTO);
@@ -111,14 +117,66 @@ class CcdiBankStatementServiceImplTest {
assertEquals("6222", result.get(0).getLeAccountNo());
}
@Test
void selectStatementListForExport_shouldIncludeHitTagNames() {
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
queryDTO.setProjectId(43L);
CcdiBankStatementListVO row = new CcdiBankStatementListVO();
row.setBankStatementId(51274L);
row.setLeAccountNo("6222");
when(bankStatementMapper.selectStatementListForExport(same(queryDTO))).thenReturn(List.of(row));
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
hitTag.setBankStatementId(51274L);
hitTag.setRuleName("大额存现交易");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(51274L)))
.thenReturn(List.of(hitTag));
List<CcdiBankStatementExcel> result = service.selectStatementListForExport(queryDTO);
assertEquals(1, result.size());
assertEquals("大额存现交易", result.get(0).getHitTags());
}
@Test
void selectStatementPage_shouldAttachHitTags() {
Page<CcdiBankStatementListVO> page = new Page<>(1, 10);
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
queryDTO.setProjectId(43L);
CcdiBankStatementListVO row = new CcdiBankStatementListVO();
row.setBankStatementId(51274L);
page.setRecords(List.of(row));
doReturn(page).when(bankStatementMapper).selectStatementPage(eq(page), same(queryDTO));
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
hitTag.setBankStatementId(51274L);
hitTag.setRuleCode("LARGE_CASH_DEPOSIT");
hitTag.setRuleName("大额存现交易");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(51274L)))
.thenReturn(List.of(hitTag));
Page<CcdiBankStatementListVO> result = service.selectStatementPage(page, queryDTO);
assertEquals(1, result.getRecords().get(0).getHitTags().size());
assertEquals("LARGE_CASH_DEPOSIT", result.getRecords().get(0).getHitTags().get(0).getRuleCode());
}
@Test
void getStatementDetail_shouldDelegateToMapper() {
CcdiBankStatementDetailVO detailVO = new CcdiBankStatementDetailVO();
detailVO.setBankStatementId(200L);
detailVO.setProjectId(43L);
when(bankStatementMapper.selectStatementDetailById(200L)).thenReturn(detailVO);
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
hitTag.setBankStatementId(200L);
hitTag.setRuleName("大额存现交易");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(200L)))
.thenReturn(List.of(hitTag));
CcdiBankStatementDetailVO result = service.getStatementDetail(200L);
assertSame(detailVO, result);
assertEquals(1, result.getHitTags().size());
assertEquals("大额存现交易", result.getHitTags().get(0).getRuleName());
}
}

View File

@@ -0,0 +1,26 @@
# 流水明细异常标签后端实施记录
## 修改内容
- 流水列表/详情返回异常标签
- 导出流水返回异常标签列
- 流水标签结果批量查询能力
- 后端单元测试补充
## 实施说明
- 新增 `CcdiBankStatementHitTagVO`,统一承载流水命中的异常标签编码、名称、风险等级和命中原因。
-`CcdiBankStatementListVO``CcdiBankStatementDetailVO` 中新增 `hitTags` 字段,作为流水列表页和详情弹窗的统一返回结构。
-`CcdiBankStatementExcel` 中新增“异常标签”导出列,导出时将命中的标签名称按顺序拼接为文本。
-`CcdiBankTagResultMapper` 中新增按 `projectId + bankStatementIds` 批量查询命中标签的方法,并在 XML 中补充对应 SQL。
-`CcdiBankStatementServiceImpl` 中补充标签挂载逻辑:
- 列表查询完成后,按当前页流水 ID 批量回查标签并分组挂载到每条记录。
- 详情查询完成后,按当前流水 ID 回查标签并挂载到详情对象。
- 导出查询完成后,复用同一套标签回查逻辑,将命中标签名称写入 Excel 导出对象。
- 本次未改动流水标签判定规则,只修复“标签结果已入库但接口未返回”的数据链路缺口。
## 问题定位结论
- 数据库中 `project_id = 43`、摘要为 `ATM现金存款` 的流水记录已存在 `LARGE_CASH_DEPOSIT` 等命中结果。
- 原因是 `ccdi_bank_statement` 列表接口与详情接口此前未将 `ccdi_bank_statement_tag_result` 的命中标签组装到返回 VO 中,导致前端无法展示。
## 验证执行
- 执行 `mvn -pl ccdi-project test -Dtest=CcdiBankTagResultMapperXmlTest,CcdiBankStatementHitTagsContractTest,CcdiBankStatementServiceImplTest`,结果通过。
- 执行 `mvn -pl ccdi-project test -Dtest=CcdiBankStatementHitTagsContractTest,CcdiBankStatementServiceImplTest,CcdiBankStatementControllerTest`,结果通过。

View File

@@ -0,0 +1,22 @@
# 流水明细异常标签前端实施记录
## 修改内容
- 列表异常标签列
- 详情异常标签模块
- 静态测试与构建验证
## 实施说明
-`DetailQuery.vue` 的列表表格中新增“异常标签”列,仅展示 `hitTags` 中的标签名称,并根据风险等级映射标签颜色。
- 在详情弹窗中新增“命中异常标签”模块,展示标签名称、风险等级中文文案和命中原因摘要。
- 列表接口返回值与详情接口返回值统一对 `hitTags` 做数组归一化,避免后端返回 `null` 时影响空态展示。
- 为空标签场景补充统一空态文案,保持列表和详情展示一致。
## 验证执行
- 执行 `node tests/unit/detail-query-filter-layout.test.js`,结果通过。
- 执行 `node tests/unit/detail-query-detail-dialog.test.js`,结果通过。
- 执行 `node tests/unit/detail-query-hit-tags-list.test.js`,结果通过。
- 执行 `npm run build:prod`,结果通过,存在项目既有体积告警,但无新增编译错误。
## 接口说明
- 本次未修改 `ruoyi-ui/src/api/ccdiProjectBankStatement.js`
- 原因:继续复用现有列表与详情接口,仅消费后端扩展后的响应数据结构,不新增前端请求入口。

View File

@@ -0,0 +1,27 @@
# 流水明细异常标签后端验证记录
## 验证范围
- 流水列表返回 `hitTags`
- 流水详情返回 `hitTags`
- 导出流水返回异常标签列
- 标签结果 Mapper 查询能力
- 已入库标签结果的链路核对
## 数据核对
- 2026-03-19 查询 `ccdi_bank_statement``project_id = 43` 下存在摘要为 `ATM现金存款` 的流水记录 `bank_statement_id=51274``bank_statement_id=49342`
- 2026-03-19 查询 `ccdi_bank_statement_tag_result`:上述两条流水均存在 `LARGE_CASH_DEPOSIT``SINGLE_LARGE_INCOME` 命中结果。
## 自动验证
- 2026-03-19 执行 `mvn -pl ccdi-project test -Dtest=CcdiBankTagResultMapperXmlTest,CcdiBankStatementHitTagsContractTest,CcdiBankStatementServiceImplTest`,结果:通过
- 2026-03-19 执行 `mvn -pl ccdi-project test -Dtest=CcdiBankStatementHitTagsContractTest,CcdiBankStatementServiceImplTest,CcdiBankStatementControllerTest`,结果:通过
## 验证结果
- [x] 列表查询结果可挂载命中异常标签
- [x] 详情查询结果可挂载命中异常标签
- [x] 导出对象包含“异常标签”文本列
- [x] 标签结果 Mapper 支持按项目和流水 ID 批量回查
- [x] `ATM现金存款` 对应流水的标签数据已在库内存在
## 联调建议
- 前端刷新 `project_id=43` 的流水明细页面后,重点核对 `bank_statement_id=51274``bank_statement_id=49342` 是否显示“大额存现交易”等异常标签。
- 使用相同筛选条件执行“导出流水”,核对导出文件中的“异常标签”列是否包含“大额存现交易”等标签名称。

View File

@@ -0,0 +1,22 @@
# 流水明细异常标签前端验证记录
## 验证范围
- 列表异常标签列
- 详情异常标签模块
- 空态展示
- 导出入口回归
## 自动验证
- 2026-03-19 执行 `node tests/unit/detail-query-filter-layout.test.js`,结果:通过
- 2026-03-19 执行 `node tests/unit/detail-query-detail-dialog.test.js`,结果:通过
- 2026-03-19 执行 `node tests/unit/detail-query-hit-tags-list.test.js`,结果:通过
- 2026-03-19 执行 `npm run build:prod`,结果:通过
## 验证结果
- [x] 列表显示命中标签名称
- [x] 详情显示名称、风险等级、命中原因摘要
- [x] 无标签时显示空态
- [ ] 导出入口仍可触发下载
## 联调待确认
- 点击“导出流水”按钮后是否仍能正常触发下载

View File

@@ -227,6 +227,22 @@
</div>
</template>
</el-table-column>
<el-table-column label="异常标签" min-width="220">
<template slot-scope="scope">
<div v-if="scope.row.hitTags && scope.row.hitTags.length" class="hit-tag-list">
<el-tag
v-for="(tag, index) in scope.row.hitTags"
:key="`${scope.row.bankStatementId}-tag-${index}`"
size="mini"
:type="mapRiskLevelToTagType(tag.riskLevel)"
effect="plain"
>
{{ tag.ruleName }}
</el-tag>
</div>
<span v-else class="empty-text">-</span>
</template>
</el-table-column>
<el-table-column
label="交易金额"
prop="displayAmount"
@@ -340,6 +356,28 @@
</div>
</div>
</div>
<div class="detail-hit-tag-section">
<div class="detail-section-title">命中异常标签</div>
<div
v-if="detailData.hitTags && detailData.hitTags.length"
class="detail-hit-tag-items"
>
<div
v-for="(tag, index) in detailData.hitTags"
:key="`detail-tag-${index}`"
class="detail-hit-tag-item"
>
<div class="detail-hit-tag-header">
<span class="detail-hit-tag-name">{{ formatField(tag.ruleName) }}</span>
<el-tag size="mini" :type="mapRiskLevelToTagType(tag.riskLevel)" effect="plain">
{{ formatRiskLevel(tag.riskLevel) }}
</el-tag>
</div>
<div class="detail-hit-tag-reason">{{ formatField(tag.reasonDetail) }}</div>
</div>
</div>
<div v-else class="detail-hit-tag-empty">当前流水未命中异常标签</div>
</div>
</div>
<div slot="footer" class="detail-dialog-footer">
<el-button @click="closeDetailDialog">取消</el-button>
@@ -396,6 +434,8 @@ const createEmptyOptionData = () => ({
ourAccountOptions: [],
});
const normalizeHitTags = (hitTags) => (Array.isArray(hitTags) ? hitTags : []);
const createEmptyDetailData = () => ({
bankStatementId: "",
projectId: "",
@@ -427,6 +467,7 @@ const createEmptyDetailData = () => ({
uploadTime: "",
sourceFileName: "",
fileName: "",
hitTags: [],
});
export default {
@@ -489,7 +530,10 @@ export default {
this.loading = true;
try {
const res = await listBankStatement(this.queryParams);
this.list = res.rows || [];
this.list = (res.rows || []).map((item) => ({
...item,
hitTags: normalizeHitTags(item && item.hitTags),
}));
this.total = res.total || 0;
this.listError = "";
} catch (error) {
@@ -575,9 +619,11 @@ export default {
this.detailLoading = true;
try {
const res = await getBankStatementDetail(row.bankStatementId);
const detail = res.data || {};
this.detailData = {
...createEmptyDetailData(),
...(res.data || {}),
...detail,
hitTags: normalizeHitTags(detail.hitTags),
};
} catch (error) {
this.detailData = createEmptyDetailData();
@@ -665,6 +711,29 @@ export default {
}
return this.formatDate(detail.uploadTime);
},
formatRiskLevel(value) {
const level = String(value || "").toUpperCase();
if (level === "HIGH") {
return "高风险";
}
if (level === "MEDIUM") {
return "中风险";
}
if (level === "LOW") {
return "低风险";
}
return "未标注";
},
mapRiskLevelToTagType(riskLevel) {
const level = String(riskLevel || "").toUpperCase();
if (level === "HIGH") {
return "danger";
}
if (level === "MEDIUM") {
return "warning";
}
return "info";
},
},
};
</script>
@@ -821,6 +890,18 @@ export default {
line-height: 18px;
}
.hit-tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.empty-text {
color: #909399;
font-size: 13px;
line-height: 20px;
}
.amount-text {
font-weight: 600;
}
@@ -841,6 +922,7 @@ export default {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px 32px;
margin-bottom: 24px;
}
.detail-field {
@@ -900,6 +982,60 @@ export default {
line-height: 18px;
}
.detail-hit-tag-section {
border-top: 1px solid #ebeef5;
padding-top: 24px;
}
.detail-section-title {
margin-bottom: 16px;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.detail-hit-tag-items {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-hit-tag-item {
padding: 12px 16px;
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fafafa;
}
.detail-hit-tag-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.detail-hit-tag-name {
color: #303133;
font-size: 14px;
font-weight: 600;
line-height: 22px;
word-break: break-all;
}
.detail-hit-tag-reason {
margin-top: 8px;
color: #606266;
font-size: 13px;
line-height: 20px;
word-break: break-word;
}
.detail-hit-tag-empty {
color: #909399;
font-size: 13px;
line-height: 20px;
}
.detail-dialog-footer {
display: flex;
justify-content: center;
@@ -975,6 +1111,11 @@ export default {
flex-direction: column;
}
.detail-hit-tag-header {
flex-direction: column;
align-items: stretch;
}
:deep(.detail-dialog) {
width: calc(100vw - 24px) !important;
margin-top: 8vh !important;

View File

@@ -32,6 +32,16 @@ assert(
);
});
[
"命中异常标签",
"detail-hit-tag-section",
"detailData.hitTags",
"当前流水未命中异常标签",
"mapRiskLevelToTagType(tag.riskLevel)",
].forEach((token) => {
assert(source.includes(token), `详情弹窗缺少异常标签结构: ${token}`);
});
const tableBlockMatch = source.match(/<el-table[\s\S]*?class="result-table"[\s\S]*?>/m);
assert(tableBlockMatch, "未找到流水明细列表表格");
assert(

View File

@@ -0,0 +1,19 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const componentPath = path.resolve(
__dirname,
"../../src/views/ccdiProject/components/detail/DetailQuery.vue"
);
const source = fs.readFileSync(componentPath, "utf8");
assert(source.includes('label="异常标签"'), "列表应新增异常标签列");
assert(source.includes("scope.row.hitTags"), "异常标签列应读取当前行的 hitTags");
assert(
source.includes('v-for="(tag, index) in scope.row.hitTags"'),
"异常标签列应逐个渲染命中标签"
);
assert(source.includes("tag.ruleName"), "异常标签列应展示标签名称");
console.log("detail-query-hit-tags-list test passed");