新增项目证据库一期功能

This commit is contained in:
wjj
2026-04-21 16:46:47 +08:00
parent d4ac165723
commit addea20fa1
26 changed files with 1489 additions and 13 deletions

View File

@@ -0,0 +1,69 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiEvidenceQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiEvidenceSaveDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiEvidenceVO;
import com.ruoyi.ccdi.project.service.ICcdiEvidenceService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 项目证据Controller
*
* @author ruoyi
*/
@RestController
@RequestMapping("/ccdi/evidence")
@Tag(name = "项目证据")
public class CcdiEvidenceController extends BaseController {
@Resource
private ICcdiEvidenceService evidenceService;
/**
* 保存证据
*/
@PostMapping
@Operation(summary = "保存证据")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult saveEvidence(@Validated @RequestBody CcdiEvidenceSaveDTO dto) {
CcdiEvidenceVO vo = evidenceService.saveEvidence(dto, SecurityUtils.getUsername());
return AjaxResult.success("证据入库成功", vo);
}
/**
* 查询项目证据列表
*/
@GetMapping("/list")
@Operation(summary = "查询项目证据列表")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult listEvidence(CcdiEvidenceQueryDTO queryDTO) {
List<CcdiEvidenceVO> list = evidenceService.listEvidence(queryDTO);
return AjaxResult.success(list);
}
/**
* 查询证据详情
*/
@GetMapping("/{evidenceId}")
@Operation(summary = "查询证据详情")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getEvidence(@PathVariable Long evidenceId) {
CcdiEvidenceVO vo = evidenceService.getEvidence(evidenceId);
return AjaxResult.success(vo);
}
}

View File

@@ -0,0 +1,21 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
/**
* 项目证据查询入参
*
* @author ruoyi
*/
@Data
public class CcdiEvidenceQueryDTO {
/** 项目ID */
private Long projectId;
/** 证据类型FLOW/MODEL/ASSET */
private String evidenceType;
/** 关键词:姓名、标题、摘要、证据编号 */
private String keyword;
}

View File

@@ -0,0 +1,54 @@
package com.ruoyi.ccdi.project.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 保存项目证据入参
*
* @author ruoyi
*/
@Data
public class CcdiEvidenceSaveDTO {
/** 项目ID */
@NotNull(message = "项目ID不能为空")
private Long projectId;
/** 证据类型FLOW/MODEL/ASSET */
@NotBlank(message = "证据类型不能为空")
private String evidenceType;
/** 关联人员姓名 */
@NotBlank(message = "关联人员不能为空")
private String relatedPersonName;
/** 关联人员标识 */
private String relatedPersonId;
/** 证据标题 */
@NotBlank(message = "证据标题不能为空")
private String evidenceTitle;
/** 证据摘要 */
@NotBlank(message = "证据摘要不能为空")
private String evidenceSummary;
/** 来源类型 */
@NotBlank(message = "来源类型不能为空")
private String sourceType;
/** 来源记录ID */
private String sourceRecordId;
/** 来源页面名称 */
private String sourcePage;
/** 证据快照JSON */
private String snapshotJson;
/** 确认理由/备注 */
@NotBlank(message = "确认理由/备注不能为空")
private String confirmReason;
}

View File

@@ -0,0 +1,84 @@
package com.ruoyi.ccdi.project.domain.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* 项目证据对象 ccdi_evidence
*
* @author ruoyi
*/
@Data
@TableName("ccdi_evidence")
public class CcdiEvidence implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 证据ID */
@TableId(type = IdType.AUTO)
private Long evidenceId;
/** 项目ID */
private Long projectId;
/** 证据类型FLOW/MODEL/ASSET */
private String evidenceType;
/** 关联人员姓名 */
private String relatedPersonName;
/** 关联人员标识,优先存身份证号或员工号 */
private String relatedPersonId;
/** 证据标题 */
private String evidenceTitle;
/** 证据摘要 */
private String evidenceSummary;
/** 来源类型BANK_STATEMENT/MODEL_DETAIL/ASSET_DETAIL */
private String sourceType;
/** 来源记录ID */
private String sourceRecordId;
/** 来源页面名称 */
private String sourcePage;
/** 证据快照JSON */
private String snapshotJson;
/** 确认理由/备注 */
private String confirmReason;
/** 确认人 */
private String confirmBy;
/** 确认时间 */
private Date confirmTime;
/** 创建者 */
@TableField(fill = FieldFill.INSERT)
private String createBy;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private Date createTime;
/** 更新者 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
}

View File

@@ -0,0 +1,56 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.util.Date;
/**
* 项目证据返回对象
*
* @author ruoyi
*/
@Data
public class CcdiEvidenceVO {
/** 证据ID */
private Long evidenceId;
/** 项目ID */
private Long projectId;
/** 证据类型FLOW/MODEL/ASSET */
private String evidenceType;
/** 关联人员姓名 */
private String relatedPersonName;
/** 关联人员标识 */
private String relatedPersonId;
/** 证据标题 */
private String evidenceTitle;
/** 证据摘要 */
private String evidenceSummary;
/** 来源类型 */
private String sourceType;
/** 来源记录ID */
private String sourceRecordId;
/** 来源页面名称 */
private String sourcePage;
/** 证据快照JSON */
private String snapshotJson;
/** 确认理由/备注 */
private String confirmReason;
/** 确认人 */
private String confirmBy;
/** 确认时间 */
private Date confirmTime;
}

View File

@@ -10,6 +10,8 @@ import lombok.Data;
@Data
public class CcdiProjectPersonAnalysisObjectRecordVO {
private String modelCode;
private String title;
private String subtitle;

View File

@@ -0,0 +1,12 @@
package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.ccdi.project.domain.entity.CcdiEvidence;
/**
* 项目证据Mapper接口
*
* @author ruoyi
*/
public interface CcdiEvidenceMapper extends BaseMapper<CcdiEvidence> {
}

View File

@@ -0,0 +1,40 @@
package com.ruoyi.ccdi.project.service;
import com.ruoyi.ccdi.project.domain.dto.CcdiEvidenceQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiEvidenceSaveDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiEvidenceVO;
import java.util.List;
/**
* 项目证据Service接口
*
* @author ruoyi
*/
public interface ICcdiEvidenceService {
/**
* 保存证据
*
* @param dto 保存入参
* @param operator 操作人
* @return 证据
*/
CcdiEvidenceVO saveEvidence(CcdiEvidenceSaveDTO dto, String operator);
/**
* 查询项目证据列表
*
* @param queryDTO 查询入参
* @return 证据列表
*/
List<CcdiEvidenceVO> listEvidence(CcdiEvidenceQueryDTO queryDTO);
/**
* 查询证据详情
*
* @param evidenceId 证据ID
* @return 证据
*/
CcdiEvidenceVO getEvidence(Long evidenceId);
}

View File

@@ -0,0 +1,84 @@
package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ruoyi.ccdi.project.domain.dto.CcdiEvidenceQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiEvidenceSaveDTO;
import com.ruoyi.ccdi.project.domain.entity.CcdiEvidence;
import com.ruoyi.ccdi.project.domain.vo.CcdiEvidenceVO;
import com.ruoyi.ccdi.project.mapper.CcdiEvidenceMapper;
import com.ruoyi.ccdi.project.service.ICcdiEvidenceService;
import com.ruoyi.common.exception.ServiceException;
import jakarta.annotation.Resource;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.Date;
import java.util.List;
/**
* 项目证据Service实现类
*
* @author ruoyi
*/
@Service
public class CcdiEvidenceServiceImpl implements ICcdiEvidenceService {
@Resource
private CcdiEvidenceMapper evidenceMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public CcdiEvidenceVO saveEvidence(CcdiEvidenceSaveDTO dto, String operator) {
CcdiEvidence evidence = new CcdiEvidence();
BeanUtils.copyProperties(dto, evidence);
evidence.setConfirmBy(operator);
evidence.setConfirmTime(new Date());
evidenceMapper.insert(evidence);
return toVO(evidence);
}
@Override
public List<CcdiEvidenceVO> listEvidence(CcdiEvidenceQueryDTO queryDTO) {
if (queryDTO.getProjectId() == null) {
throw new ServiceException("项目ID不能为空");
}
LambdaQueryWrapper<CcdiEvidence> wrapper = new LambdaQueryWrapper<CcdiEvidence>()
.eq(CcdiEvidence::getProjectId, queryDTO.getProjectId())
.orderByDesc(CcdiEvidence::getConfirmTime)
.orderByDesc(CcdiEvidence::getEvidenceId);
if (StringUtils.hasText(queryDTO.getEvidenceType())) {
wrapper.eq(CcdiEvidence::getEvidenceType, queryDTO.getEvidenceType());
}
if (StringUtils.hasText(queryDTO.getKeyword())) {
String keyword = queryDTO.getKeyword().trim();
wrapper.and(item -> item
.like(CcdiEvidence::getRelatedPersonName, keyword)
.or()
.like(CcdiEvidence::getRelatedPersonId, keyword)
.or()
.like(CcdiEvidence::getEvidenceTitle, keyword)
.or()
.like(CcdiEvidence::getEvidenceSummary, keyword)
);
}
return evidenceMapper.selectList(wrapper).stream().map(this::toVO).toList();
}
@Override
public CcdiEvidenceVO getEvidence(Long evidenceId) {
CcdiEvidence evidence = evidenceMapper.selectById(evidenceId);
if (evidence == null) {
throw new ServiceException("证据不存在");
}
return toVO(evidence);
}
private CcdiEvidenceVO toVO(CcdiEvidence evidence) {
CcdiEvidenceVO vo = new CcdiEvidenceVO();
BeanUtils.copyProperties(evidence, vo);
return vo;
}
}

View File

@@ -741,6 +741,7 @@
<select id="selectPersonAnalysisObjectRows" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO">
select
max(tr.model_code) as modelCode,
coalesce(max(staff.name), max(relation.relation_name), max(tr.object_key), max(tr.object_type)) as title,
max(case
when tr.object_type = 'STAFF_ID_CARD' then '员工对象'

View File

@@ -0,0 +1,44 @@
# 证据库最小改造验证清单
## 验证范围
- 流水证据:流水详情中小号「加入证据库」按钮、确认弹窗、保存入库。
- 模型证据:模型详情异常对象卡片中小号「加入证据库」按钮、确认弹窗、保存入库。
- 资产证据:资产详情中小号「加入证据库」按钮、确认弹窗、保存入库。
- 证据线索:项目详情右上角小号「证据线索」入口、右侧抽屉列表、搜索框基础展示。
## 版式约束
- 不新增独立证据库页面。
- 不改变原详情页主体字段、表格列、字号体系和业务阅读顺序。
- 「加入证据库」只能作为低频辅助按钮出现,使用 mini 尺寸、弱边框、弱背景。
- 「证据线索」只能作为轻入口和右侧抽屉,不遮挡或重排项目详情主体内容。
## 功能验证
- 项目详情页能正常打开,顶部「证据线索」按钮可打开抽屉。
- 抽屉无证据时展示空状态,有证据时展示编号、类型、关联人员、摘要、来源、确认人、备注。
- 模型详情点击「加入证据库」后,弹窗自动带出证据类型、关联人员、证据摘要。
- 流水详情点击「加入证据库」后,弹窗自动带出流水证据摘要,`source_record_id` 使用 `md5(本方账号+本方名称+对方账号+对方名称+交易时间+金额+摘要)`
- 资产详情点击「加入证据库」后,弹窗自动带出资产证据摘要。
- 模型证据 `source_record_id` 使用 `md5(人员身份证+模型编码)`,缺少人员身份证或模型编码时不允许入库。
- 资产证据 `source_record_id` 使用 `md5(人员身份证+资产字段)`,当前资产负债聚合口径的资产字段为家庭总收入、家庭总负债、家庭总资产、风险等级编码。
- 确认理由为空时不能提交。
- 填写确认理由后可以提交,提交成功后自动打开或刷新证据线索抽屉。
- 保存后的证据落库到 `ccdi_evidence`
## 技术验证
- 后端 `ccdi-project` 编译通过。
- 前端 `npm run build:prod` 通过。
- 数据库表 `ccdi_evidence` 存在。
- 流水证据 `source_record_id` 不依赖 `statementId/bankStatementId`,应为 32 位 MD5 指纹。
- 模型证据、资产证据的 `source_record_id` 均不拼接项目 ID项目归属仅存 `project_id` 字段。
- 页面控制台不出现由本次改造引入的明显错误。
- 不提交或误动无关文件。
## 本期不做
- 证据卡片「查看详情」真实跳转原记录。
- 跨项目引用/复用 UI。
- 重复证据拦截。

View File

@@ -0,0 +1,51 @@
# 证据库最小改造验证记录
## 验证时间
2026-04-21
## 验证环境
- 前端:`http://localhost:62319`
- 后端:`http://localhost:62318`
- 项目:`test`
- 项目 ID`90337`
## 验证结果
| 验证项 | 结果 | 说明 |
| --- | --- | --- |
| 后端编译 | 通过 | `mvn -pl ccdi-project -am compile -DskipTests` 成功 |
| 前端构建 | 通过 | `npm run build:prod` 成功,仅存在原有包体积 warning |
| 数据库表 | 通过 | `ccdi_evidence` 已存在 |
| 模型证据入库 | 通过 | 模型详情小号「加入证据库」可打开弹窗并保存,生成 `EV-001` |
| 流水证据入库 | 通过 | 流水详情小号「加入证据库」可打开弹窗并保存,当前代码已改为使用 32 位 MD5 指纹作为 `source_record_id` |
| 资产证据入库 | 通过 | 资产详情小号「加入证据库」可打开弹窗并保存,已验证旧规则测试数据 `EV-003` 与新指纹规则测试数据 `EV-004` |
| 证据线索抽屉 | 通过 | 抽屉展示三类证据,包含编号、类型、关联人员、摘要、来源、确认人、备注 |
| 前端控制台 | 通过 | 验证后未发现 error/warn |
| 模型/资产来源指纹更新 | 通过 | 已重启后端并通过 MCP 页面验证:模型证据、资产证据均可打开确认弹窗,本次未确认入库,避免新增测试数据 |
| 证据抽屉跳转入口 | 通过 | 本期不做原记录跳转,已移除抽屉卡片中的「查看流水详情」「查看模型详情」「查看资产详情」按钮 |
## 落库核对
项目 `90337` 当前证据数:
| 类型 | 数量 |
| --- | ---: |
| FLOW | 1 |
| MODEL | 1 |
| ASSET | 2 |
| 合计 | 4 |
## 注意事项
- 本次验证产生了测试证据数据,如正式交付前需要干净环境,可按项目 ID 清理。
- 历史已保存的测试证据可能保留旧来源标识,新保存的流水、模型、资产证据会按当前规则生成 MD5 指纹。
- 当前代码已将模型证据来源标识改为 `md5(人员身份证+模型编码)`,资产证据来源标识改为 `md5(人员身份证+资产字段)`,均不拼接项目 ID。
- 为让模型详情前端拿到模型编码,后端仅补充返回 `modelCode` 字段,不涉及表结构和接口路径变更。
## 后续边界
- 证据卡片「查看详情」本期不做真实跳转,当前抽屉不展示跳转按钮;后续如要定位原记录,可基于 `source_type``source_record_id``snapshot_json` 增加跳转逻辑。
- 跨项目引用/复用 UI 本期不做;当前 `source_record_id` 已按不拼接项目 ID 的规则生成,后续具备按同一来源指纹做跨项目比对的基础。
- 重复证据拦截本期不做;当前允许同一项目内重复确认,后续可按 `project_id + evidence_type + source_type + source_record_id` 增加唯一性提示或软拦截。

View File

@@ -0,0 +1,27 @@
import request from '@/utils/request'
// 保存证据
export function saveEvidence(data) {
return request({
url: '/ccdi/evidence',
method: 'post',
data
})
}
// 查询项目证据列表
export function listEvidence(params) {
return request({
url: '/ccdi/evidence/list',
method: 'get',
params
})
}
// 查询证据详情
export function getEvidence(evidenceId) {
return request({
url: '/ccdi/evidence/' + evidenceId,
method: 'get'
})
}

View File

@@ -0,0 +1,78 @@
import md5 from "@/utils/md5";
export const FLOW_EVIDENCE_FINGERPRINT_RULE =
"md5(leAccountNo+leAccountName+customerAccountNo+customerAccountName+trxDate+displayAmount+userMemo)";
export const MODEL_EVIDENCE_FINGERPRINT_RULE = "md5(personIdCard+modelCode)";
export const ASSET_EVIDENCE_FINGERPRINT_RULE =
"md5(staffIdCard+totalIncome+totalDebt+totalAsset+riskLevelCode)";
function normalizeFingerprintValue(value) {
if (value === null || value === undefined) {
return "";
}
return String(value).trim();
}
function resolveCounterpartyName(detail) {
return detail.customerAccountName || detail.customerName || detail.counterpartyName || "";
}
export function buildFlowEvidenceFingerprintSource(detail = {}) {
return [
detail.leAccountNo,
detail.leAccountName,
detail.customerAccountNo,
resolveCounterpartyName(detail),
detail.trxDate,
detail.displayAmount,
detail.userMemo,
]
.map(normalizeFingerprintValue)
.join("");
}
export function buildFlowEvidenceFingerprint(detail = {}) {
const source = buildFlowEvidenceFingerprintSource(detail);
return source ? md5(source) : "";
}
export function buildFlowEvidenceSnapshot(detail = {}) {
const evidenceFingerprint = buildFlowEvidenceFingerprint(detail);
return {
...detail,
evidenceFingerprint,
evidenceFingerprintRule: FLOW_EVIDENCE_FINGERPRINT_RULE,
};
}
export function buildModelEvidenceFingerprint(personIdCard, modelCode) {
const idCard = normalizeFingerprintValue(personIdCard);
const code = normalizeFingerprintValue(modelCode);
return idCard && code ? md5(idCard + code) : "";
}
export function buildAssetEvidenceFingerprint(row = {}) {
const idCard = normalizeFingerprintValue(row.staffIdCard);
const assetSource = [
row.totalIncome,
row.totalDebt,
row.totalAsset,
row.riskLevelCode,
]
.map(normalizeFingerprintValue)
.join("");
return idCard && assetSource ? md5(idCard + assetSource) : "";
}
export function buildAssetEvidenceSnapshot(row = {}, detail = {}, summary = {}) {
const evidenceFingerprint = buildAssetEvidenceFingerprint(row);
return {
row,
detail,
summary,
evidenceFingerprint,
evidenceFingerprintRule: ASSET_EVIDENCE_FINGERPRINT_RULE,
};
}

161
ruoyi-ui/src/utils/md5.js Normal file
View File

@@ -0,0 +1,161 @@
function safeAdd(x, y) {
const lsw = (x & 0xffff) + (y & 0xffff);
const msw = (x >> 16) + (y >> 16) + (lsw >> 16);
return (msw << 16) | (lsw & 0xffff);
}
function rotateLeft(num, cnt) {
return (num << cnt) | (num >>> (32 - cnt));
}
function cmn(q, a, b, x, s, t) {
return safeAdd(rotateLeft(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b);
}
function ff(a, b, c, d, x, s, t) {
return cmn((b & c) | (~b & d), a, b, x, s, t);
}
function gg(a, b, c, d, x, s, t) {
return cmn((b & d) | (c & ~d), a, b, x, s, t);
}
function hh(a, b, c, d, x, s, t) {
return cmn(b ^ c ^ d, a, b, x, s, t);
}
function ii(a, b, c, d, x, s, t) {
return cmn(c ^ (b | ~d), a, b, x, s, t);
}
function wordsToRaw(input) {
let output = "";
for (let i = 0; i < input.length * 32; i += 8) {
output += String.fromCharCode((input[i >> 5] >>> (i % 32)) & 0xff);
}
return output;
}
function rawToWords(input) {
const output = [];
output[(input.length >> 2) - 1] = undefined;
for (let i = 0; i < output.length; i++) {
output[i] = 0;
}
for (let i = 0; i < input.length * 8; i += 8) {
output[i >> 5] |= (input.charCodeAt(i / 8) & 0xff) << (i % 32);
}
return output;
}
function calculate(words, len) {
words[len >> 5] |= 0x80 << (len % 32);
words[(((len + 64) >>> 9) << 4) + 14] = len;
let a = 1732584193;
let b = -271733879;
let c = -1732584194;
let d = 271733878;
for (let i = 0; i < words.length; i += 16) {
const olda = a;
const oldb = b;
const oldc = c;
const oldd = d;
a = ff(a, b, c, d, words[i], 7, -680876936);
d = ff(d, a, b, c, words[i + 1], 12, -389564586);
c = ff(c, d, a, b, words[i + 2], 17, 606105819);
b = ff(b, c, d, a, words[i + 3], 22, -1044525330);
a = ff(a, b, c, d, words[i + 4], 7, -176418897);
d = ff(d, a, b, c, words[i + 5], 12, 1200080426);
c = ff(c, d, a, b, words[i + 6], 17, -1473231341);
b = ff(b, c, d, a, words[i + 7], 22, -45705983);
a = ff(a, b, c, d, words[i + 8], 7, 1770035416);
d = ff(d, a, b, c, words[i + 9], 12, -1958414417);
c = ff(c, d, a, b, words[i + 10], 17, -42063);
b = ff(b, c, d, a, words[i + 11], 22, -1990404162);
a = ff(a, b, c, d, words[i + 12], 7, 1804603682);
d = ff(d, a, b, c, words[i + 13], 12, -40341101);
c = ff(c, d, a, b, words[i + 14], 17, -1502002290);
b = ff(b, c, d, a, words[i + 15], 22, 1236535329);
a = gg(a, b, c, d, words[i + 1], 5, -165796510);
d = gg(d, a, b, c, words[i + 6], 9, -1069501632);
c = gg(c, d, a, b, words[i + 11], 14, 643717713);
b = gg(b, c, d, a, words[i], 20, -373897302);
a = gg(a, b, c, d, words[i + 5], 5, -701558691);
d = gg(d, a, b, c, words[i + 10], 9, 38016083);
c = gg(c, d, a, b, words[i + 15], 14, -660478335);
b = gg(b, c, d, a, words[i + 4], 20, -405537848);
a = gg(a, b, c, d, words[i + 9], 5, 568446438);
d = gg(d, a, b, c, words[i + 14], 9, -1019803690);
c = gg(c, d, a, b, words[i + 3], 14, -187363961);
b = gg(b, c, d, a, words[i + 8], 20, 1163531501);
a = gg(a, b, c, d, words[i + 13], 5, -1444681467);
d = gg(d, a, b, c, words[i + 2], 9, -51403784);
c = gg(c, d, a, b, words[i + 7], 14, 1735328473);
b = gg(b, c, d, a, words[i + 12], 20, -1926607734);
a = hh(a, b, c, d, words[i + 5], 4, -378558);
d = hh(d, a, b, c, words[i + 8], 11, -2022574463);
c = hh(c, d, a, b, words[i + 11], 16, 1839030562);
b = hh(b, c, d, a, words[i + 14], 23, -35309556);
a = hh(a, b, c, d, words[i + 1], 4, -1530992060);
d = hh(d, a, b, c, words[i + 4], 11, 1272893353);
c = hh(c, d, a, b, words[i + 7], 16, -155497632);
b = hh(b, c, d, a, words[i + 10], 23, -1094730640);
a = hh(a, b, c, d, words[i + 13], 4, 681279174);
d = hh(d, a, b, c, words[i], 11, -358537222);
c = hh(c, d, a, b, words[i + 3], 16, -722521979);
b = hh(b, c, d, a, words[i + 6], 23, 76029189);
a = hh(a, b, c, d, words[i + 9], 4, -640364487);
d = hh(d, a, b, c, words[i + 12], 11, -421815835);
c = hh(c, d, a, b, words[i + 15], 16, 530742520);
b = hh(b, c, d, a, words[i + 2], 23, -995338651);
a = ii(a, b, c, d, words[i], 6, -198630844);
d = ii(d, a, b, c, words[i + 7], 10, 1126891415);
c = ii(c, d, a, b, words[i + 14], 15, -1416354905);
b = ii(b, c, d, a, words[i + 5], 21, -57434055);
a = ii(a, b, c, d, words[i + 12], 6, 1700485571);
d = ii(d, a, b, c, words[i + 3], 10, -1894986606);
c = ii(c, d, a, b, words[i + 10], 15, -1051523);
b = ii(b, c, d, a, words[i + 1], 21, -2054922799);
a = ii(a, b, c, d, words[i + 8], 6, 1873313359);
d = ii(d, a, b, c, words[i + 15], 10, -30611744);
c = ii(c, d, a, b, words[i + 6], 15, -1560198380);
b = ii(b, c, d, a, words[i + 13], 21, 1309151649);
a = ii(a, b, c, d, words[i + 4], 6, -145523070);
d = ii(d, a, b, c, words[i + 11], 10, -1120210379);
c = ii(c, d, a, b, words[i + 2], 15, 718787259);
b = ii(b, c, d, a, words[i + 9], 21, -343485551);
a = safeAdd(a, olda);
b = safeAdd(b, oldb);
c = safeAdd(c, oldc);
d = safeAdd(d, oldd);
}
return [a, b, c, d];
}
function rawToHex(input) {
const hex = "0123456789abcdef";
let output = "";
for (let i = 0; i < input.length; i++) {
const x = input.charCodeAt(i);
output += hex.charAt((x >>> 4) & 0x0f) + hex.charAt(x & 0x0f);
}
return output;
}
function toUtf8Raw(input) {
return unescape(encodeURIComponent(input));
}
export default function md5(input) {
const value = input === null || input === undefined ? "" : String(input);
const raw = toUtf8Raw(value);
return rawToHex(wordsToRaw(calculate(rawToWords(raw), raw.length * 8)));
}

View File

@@ -283,10 +283,23 @@
:visible.sync="detailVisible"
append-to-body
custom-class="detail-dialog"
title="流水详情"
width="980px"
@close="closeDetailDialog"
>
<template slot="title">
<div class="detail-dialog-title">
<span>流水详情</span>
<el-button
class="evidence-corner-btn"
size="mini"
plain
:disabled="detailLoading || !buildFlowEvidenceFingerprint(detailData)"
@click="handleAddEvidence"
>
加入证据库
</el-button>
</div>
</template>
<div v-loading="detailLoading" class="detail-dialog-body">
<div class="detail-overview-grid">
<div class="detail-field">
@@ -394,6 +407,7 @@ import {
getBankStatementOptions,
getBankStatementDetail,
} from "@/api/ccdiProjectBankStatement";
import { buildFlowEvidenceFingerprint, buildFlowEvidenceSnapshot } from "@/utils/ccdiEvidence";
const TAB_MAP = {
all: "all",
@@ -518,6 +532,7 @@ export default {
},
},
methods: {
buildFlowEvidenceFingerprint,
async getList() {
this.syncProjectId();
if (!this.queryParams.projectId) {
@@ -638,6 +653,29 @@ export default {
this.detailLoading = false;
this.detailData = createEmptyDetailData();
},
handleAddEvidence() {
const detail = this.detailData || {};
const sourceRecordId = buildFlowEvidenceFingerprint(detail);
const amountText = this.formatSignedAmount(detail.displayAmount);
const counterparty = this.formatCounterpartyName(detail);
const hitTagText = Array.isArray(detail.hitTags) && detail.hitTags.length
? `,命中${detail.hitTags.map((tag) => tag.ruleName).filter(Boolean).join("、")}标签`
: "";
this.$emit("evidence-confirm", {
evidenceType: "FLOW",
relatedPersonName: this.resolveFlowRelatedPerson(detail),
relatedPersonId: detail.cretNo || "",
evidenceTitle: `${this.resolveFlowRelatedPerson(detail)} / ${this.formatField(detail.leAccountNo)}`,
evidenceSummary: `${this.formatField(detail.trxDate)}${this.resolveFlowRelatedPerson(detail)}账户与${counterparty}发生交易,金额${amountText}${hitTagText}`,
sourceType: "BANK_STATEMENT",
sourceRecordId,
sourcePage: "流水详情",
snapshotJson: JSON.stringify(buildFlowEvidenceSnapshot(detail)),
});
},
resolveFlowRelatedPerson(detail) {
return this.formatField(detail.leAccountName) === "-" ? "关联人员" : this.formatField(detail.leAccountName);
},
handleExport() {
if (this.total === 0) {
return;
@@ -751,6 +789,26 @@ export default {
gap: 12px;
}
.detail-dialog-title {
display: inline-flex;
align-items: center;
gap: 10px;
}
.evidence-corner-btn {
padding: 4px 9px;
font-size: 12px;
color: #5b7fb8;
border-color: #d6e4f7;
background: #f8fbff;
&:hover {
color: #2474e8;
border-color: #9fc3ff;
background: #edf5ff;
}
}
.shell-sidebar,
.shell-main {
border: 1px solid #ebeef5;

View File

@@ -0,0 +1,136 @@
<template>
<el-dialog
:visible.sync="dialogVisible"
append-to-body
title="确认为证据"
width="560px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form label-position="top" class="evidence-confirm-form">
<el-form-item label="证据类型">
<el-input :value="typeLabel" readonly />
</el-form-item>
<el-form-item label="关联人员">
<el-input :value="payload.relatedPersonName || '-'" readonly />
</el-form-item>
<el-form-item label="证据摘要">
<el-input
:value="payload.evidenceSummary || '-'"
readonly
type="textarea"
:rows="3"
/>
</el-form-item>
<el-form-item label="确认理由/备注" required>
<el-input
v-model.trim="confirmReason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请填写为什么将该详情确认为证据"
/>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
确认入库
</el-button>
</div>
</el-dialog>
</template>
<script>
import { saveEvidence } from "@/api/ccdiEvidence";
const TYPE_LABEL_MAP = {
FLOW: "流水证据",
MODEL: "模型证据",
ASSET: "资产证据",
};
export default {
name: "EvidenceConfirmDialog",
props: {
visible: {
type: Boolean,
default: false,
},
payload: {
type: Object,
default: () => ({}),
},
},
data() {
return {
confirmReason: "",
submitting: false,
};
},
computed: {
dialogVisible: {
get() {
return this.visible;
},
set(value) {
if (!value) {
this.handleClose();
}
},
},
typeLabel() {
return TYPE_LABEL_MAP[this.payload.evidenceType] || this.payload.evidenceType || "-";
},
},
watch: {
visible(value) {
if (value) {
this.confirmReason = "";
}
},
},
methods: {
handleClose() {
if (this.submitting) {
return;
}
this.$emit("update:visible", false);
},
async handleSubmit() {
if (!this.confirmReason) {
this.$message.warning("请填写确认理由/备注");
return;
}
this.submitting = true;
try {
const data = {
...this.payload,
confirmReason: this.confirmReason,
};
const response = await saveEvidence(data);
this.$message.success("证据入库成功");
this.$emit("saved", response.data);
this.$emit("update:visible", false);
} catch (error) {
this.$message.error("证据入库失败");
console.error("证据入库失败", error);
} finally {
this.submitting = false;
}
},
},
};
</script>
<style lang="scss" scoped>
.evidence-confirm-form {
:deep(.el-form-item__label) {
padding-bottom: 6px;
font-weight: 600;
color: #606266;
}
}
</style>

View File

@@ -0,0 +1,180 @@
<template>
<el-drawer
:visible.sync="drawerVisible"
append-to-body
title="证据线索"
size="420px"
custom-class="evidence-drawer"
@open="loadEvidence"
>
<div class="evidence-drawer-body">
<el-input
v-model.trim="keyword"
clearable
size="small"
prefix-icon="el-icon-search"
placeholder="搜索姓名、账号、证据编号"
@keyup.enter.native="loadEvidence"
@clear="loadEvidence"
>
<el-button slot="append" @click="loadEvidence">查询</el-button>
</el-input>
<div class="evidence-list" v-loading="loading">
<el-empty
v-if="!loading && evidenceList.length === 0"
:image-size="80"
description="暂无证据线索"
/>
<article
v-for="item in evidenceList"
:key="item.evidenceId"
class="evidence-card"
>
<div class="evidence-card__header">
<span class="evidence-code">EV-{{ formatEvidenceId(item.evidenceId) }} {{ formatType(item.evidenceType) }}</span>
<el-tag size="mini" type="danger" effect="plain">确认可疑</el-tag>
</div>
<div class="evidence-title">{{ item.evidenceTitle }}</div>
<div class="evidence-summary">{{ item.evidenceSummary }}</div>
<div class="evidence-meta">
来源{{ item.sourcePage || formatSource(item.sourceType) }}确认人{{ item.confirmBy || "-" }}
</div>
<div v-if="item.confirmReason" class="evidence-meta">
备注{{ item.confirmReason }}
</div>
</article>
</div>
</div>
</el-drawer>
</template>
<script>
import { listEvidence } from "@/api/ccdiEvidence";
const TYPE_LABEL_MAP = {
FLOW: "流水证据",
MODEL: "模型证据",
ASSET: "资产证据",
};
const SOURCE_LABEL_MAP = {
BANK_STATEMENT: "流水详情",
MODEL_DETAIL: "模型详情",
ASSET_DETAIL: "资产详情",
};
export default {
name: "EvidenceDrawer",
props: {
visible: {
type: Boolean,
default: false,
},
projectId: {
type: [String, Number],
default: null,
},
},
data() {
return {
keyword: "",
loading: false,
evidenceList: [],
};
},
computed: {
drawerVisible: {
get() {
return this.visible;
},
set(value) {
this.$emit("update:visible", value);
},
},
},
methods: {
async loadEvidence() {
if (!this.projectId) {
this.evidenceList = [];
return;
}
this.loading = true;
try {
const response = await listEvidence({
projectId: this.projectId,
keyword: this.keyword,
});
this.evidenceList = response.data || [];
} catch (error) {
this.evidenceList = [];
this.$message.error("加载证据线索失败");
console.error("加载证据线索失败", error);
} finally {
this.loading = false;
}
},
formatEvidenceId(value) {
return String(value || "").padStart(3, "0");
},
formatType(type) {
return TYPE_LABEL_MAP[type] || type || "证据";
},
formatSource(sourceType) {
return SOURCE_LABEL_MAP[sourceType] || sourceType || "-";
},
},
};
</script>
<style lang="scss" scoped>
.evidence-drawer-body {
padding: 0 16px 16px;
}
.evidence-list {
margin-top: 14px;
}
.evidence-card {
padding: 14px;
margin-bottom: 12px;
border: 1px solid #e5eaf2;
border-radius: 10px;
background: #fff;
}
.evidence-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.evidence-code {
color: #2474e8;
font-size: 13px;
font-weight: 700;
}
.evidence-title {
margin-top: 8px;
font-size: 15px;
font-weight: 700;
color: #1f2937;
}
.evidence-summary {
margin-top: 8px;
font-size: 13px;
line-height: 1.6;
color: #475467;
}
.evidence-meta {
margin-top: 8px;
font-size: 12px;
color: #8a96a8;
line-height: 1.5;
}
</style>

View File

@@ -21,10 +21,24 @@
</template>
<el-table-column type="expand" width="1">
<template slot-scope="scope">
<div class="family-detail-wrapper">
<div class="family-detail-toolbar">
<span class="family-detail-title">资产详情</span>
<el-button
class="evidence-corner-btn"
size="mini"
plain
:disabled="Boolean(detailLoadingMap[scope.row.staffIdCard]) || !buildAssetEvidenceFingerprint(scope.row)"
@click="handleAddEvidence(scope.row)"
>
加入证据库
</el-button>
</div>
<family-asset-liability-detail
:detail="detailCache[scope.row.staffIdCard]"
:loading="Boolean(detailLoadingMap[scope.row.staffIdCard])"
/>
</div>
</template>
</el-table-column>
<el-table-column type="index" label="序号" width="60" />
@@ -67,6 +81,7 @@
<script>
import { getFamilyAssetLiabilityDetail } from "@/api/ccdi/projectSpecialCheck";
import { buildAssetEvidenceFingerprint, buildAssetEvidenceSnapshot } from "@/utils/ccdiEvidence";
import FamilyAssetLiabilityDetail from "./FamilyAssetLiabilityDetail";
export default {
@@ -114,6 +129,7 @@ export default {
},
},
methods: {
buildAssetEvidenceFingerprint,
resolveRiskTagType(riskLevelCode) {
const riskTagTypeMap = {
NORMAL: "success",
@@ -164,6 +180,30 @@ export default {
this.detailCache = {};
this.detailLoadingMap = {};
},
handleAddEvidence(row) {
if (!row || !row.staffIdCard) {
return;
}
const sourceRecordId = buildAssetEvidenceFingerprint(row);
if (!sourceRecordId) {
this.$message.warning("缺少人员身份证或资产字段,暂不能加入证据库");
return;
}
const detail = this.detailCache[row.staffIdCard] || {};
const summary = detail.summary || {};
const evidenceSummary = `${row.staffName}家庭资产负债核查:家庭总年收入${this.formatAmount(row.totalIncome)},家庭总负债${this.formatAmount(row.totalDebt)},家庭总资产${this.formatAmount(row.totalAsset)},风险情况${row.riskLevelName || "-" }`;
this.$emit("evidence-confirm", {
evidenceType: "ASSET",
relatedPersonName: row.staffName || "关联人员",
relatedPersonId: row.staffIdCard || "",
evidenceTitle: `${row.staffName || "关联人员"} / 家庭资产负债核查`,
evidenceSummary,
sourceType: "ASSET_DETAIL",
sourceRecordId,
sourcePage: "资产详情",
snapshotJson: JSON.stringify(buildAssetEvidenceSnapshot(row, detail, summary)),
});
},
},
};
</script>
@@ -204,6 +244,39 @@ export default {
overflow: hidden;
}
.family-detail-wrapper {
padding: 12px 0;
}
.family-detail-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-bottom: 10px;
}
.family-detail-title {
margin-right: auto;
color: #64748b;
font-size: 13px;
font-weight: 600;
}
.evidence-corner-btn {
padding: 4px 9px;
font-size: 12px;
color: #5b7fb8;
border-color: #d6e4f7;
background: #f8fbff;
&:hover {
color: #2474e8;
border-color: #9fc3ff;
background: #edf5ff;
}
}
:deep(.family-table th) {
background: #f8fafc;
color: #64748b;

View File

@@ -33,7 +33,10 @@
@selection-change="handleRiskModelSelectionChange"
@view-project-analysis="handleRiskModelProjectAnalysis"
/>
<risk-detail-section :section-data="currentData.riskDetails" />
<risk-detail-section
:section-data="currentData.riskDetails"
@evidence-confirm="$emit('evidence-confirm', $event)"
/>
</div>
<project-analysis-dialog
:visible.sync="projectAnalysisDialogVisible"
@@ -43,6 +46,7 @@
:model-summary="projectAnalysisModelSummary"
:project-name="projectInfo.projectName"
@close="handleProjectAnalysisDialogClose"
@evidence-confirm="$emit('evidence-confirm', $event)"
/>
</div>
</template>

View File

@@ -75,15 +75,28 @@
<div v-else-if='group.groupType === "OBJECT"' class="object-card-grid">
<article
v-for="(item, index) in group.records || []"
:key="`${item.title || index}-object`"
:key="`${resolveGroupKey(group, groupIndex)}-${item.title || 'object'}-${index}`"
class="object-card"
>
<div class="object-card__header">
<div>
<div class="object-card__title">{{ item.title || "-" }}</div>
<div class="object-card__subtitle">{{ item.subtitle || "-" }}</div>
</div>
<el-button
class="evidence-corner-btn"
size="mini"
plain
:disabled="!buildModelEvidenceFingerprint(resolvePersonIdCard(), item.modelCode)"
@click="handleAddModelEvidence(item, group)"
>
加入证据库
</el-button>
</div>
<div v-if="item.riskTags && item.riskTags.length" class="tag-list">
<el-tag
v-for="(tag, tagIndex) in item.riskTags"
:key="`${item.title || index}-risk-${tagIndex}`"
:key="`${resolveGroupKey(group, groupIndex)}-${item.title || index}-risk-${tagIndex}`"
size="mini"
effect="plain"
>
@@ -97,7 +110,7 @@
<p class="object-card__summary">{{ item.summary || "-" }}</p>
<div
v-for="(field, fieldIndex) in item.extraFields || []"
:key="`${item.title || index}-field-${fieldIndex}`"
:key="`${resolveGroupKey(group, groupIndex)}-${item.title || index}-field-${fieldIndex}`"
class="summary-row"
>
<span class="summary-row__label">{{ field.label }}</span>
@@ -113,6 +126,8 @@
</template>
<script>
import { buildModelEvidenceFingerprint, MODEL_EVIDENCE_FINGERPRINT_RULE } from "@/utils/ccdiEvidence";
export default {
name: "ProjectAnalysisAbnormalTab",
props: {
@@ -122,6 +137,14 @@ export default {
groups: [],
}),
},
person: {
type: Object,
default: () => ({}),
},
projectId: {
type: [String, Number],
default: null,
},
},
data() {
return {
@@ -150,6 +173,7 @@ export default {
},
},
methods: {
buildModelEvidenceFingerprint,
resolveGroupKey(group, index = 0) {
return group.groupCode || group.groupName || `BANK_STATEMENT_${index}`;
},
@@ -170,6 +194,48 @@ export default {
const groupKey = this.resolveGroupKey(group);
this.$set(this.statementPageMap, groupKey, page);
},
handleAddModelEvidence(item, group) {
const safeItem = item || {};
const safeGroup = group || {};
const relatedPersonName = this.resolveRelatedPersonName(safeItem);
const personIdCard = this.resolvePersonIdCard();
const sourceRecordId = buildModelEvidenceFingerprint(personIdCard, safeItem.modelCode);
if (!sourceRecordId) {
this.$message.warning("缺少人员身份证或模型编码,暂不能加入证据库");
return;
}
const riskTags = Array.isArray(safeItem.riskTags) ? safeItem.riskTags.join("、") : "";
const reason = safeItem.reasonDetail || safeItem.summary || "-";
const payload = {
evidenceType: "MODEL",
relatedPersonName,
relatedPersonId: personIdCard,
evidenceTitle: `${relatedPersonName} / ${safeItem.title || safeGroup.groupName || "模型异常"}`,
evidenceSummary: `${safeItem.title || safeGroup.groupName || "模型异常"}${reason}`,
sourceType: "MODEL_DETAIL",
sourceRecordId,
sourcePage: "模型详情",
snapshotJson: JSON.stringify({
group: safeGroup,
item: safeItem,
person: this.person,
riskTags,
evidenceFingerprint: sourceRecordId,
evidenceFingerprintRule: MODEL_EVIDENCE_FINGERPRINT_RULE,
}),
};
this.$emit("evidence-confirm", payload);
this.$root.$emit("ccdi-evidence-confirm", payload);
},
resolvePersonIdCard() {
return (this.person && (this.person.idNo || this.person.staffIdCard)) || "";
},
resolveRelatedPersonName(item) {
if (this.person && (this.person.name || this.person.staffName)) {
return this.person.name || this.person.staffName;
}
return item.title || "关联人员";
},
},
};
</script>
@@ -252,12 +318,35 @@ export default {
background: #f8fafc;
}
.object-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.object-card__title {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
.evidence-corner-btn {
flex: 0 0 auto;
padding: 4px 9px;
font-size: 12px;
color: #5b7fb8;
border-color: #d6e4f7;
background: #f8fbff;
&:hover {
color: #2474e8;
border-color: #9fc3ff;
background: #edf5ff;
}
}
.object-card__subtitle {
margin-top: 6px;
font-size: 12px;

View File

@@ -46,7 +46,12 @@
</el-alert>
<el-tabs v-model="activeTab" class="project-analysis-tabs" stretch>
<el-tab-pane label="异常明细" name="abnormalDetail">
<project-analysis-abnormal-tab :detail-data="dialogData.abnormalDetail" />
<project-analysis-abnormal-tab
:detail-data="dialogData.abnormalDetail"
:person="person"
:project-id="projectId"
@evidence-confirm="$emit('evidence-confirm', $event)"
/>
</el-tab-pane>
<el-tab-pane label="资产分析" name="assetAnalysis">
<project-analysis-placeholder-tab :tab-data="getTabData('assetAnalysis')" />

View File

@@ -207,10 +207,23 @@
:visible.sync="detailVisible"
append-to-body
custom-class="detail-dialog"
title="流水详情"
width="980px"
@close="closeDetailDialog"
>
<template slot="title">
<div class="detail-dialog-title">
<span>流水详情</span>
<el-button
class="evidence-corner-btn"
size="mini"
plain
:disabled="detailLoading || !buildFlowEvidenceFingerprint(detailData)"
@click="handleAddEvidence"
>
加入证据库
</el-button>
</div>
</template>
<div v-loading="detailLoading" class="detail-dialog-body">
<div class="detail-overview-grid">
<div class="detail-field">
@@ -318,6 +331,7 @@ import {
getOverviewSuspiciousTransactions,
} from "@/api/ccdi/projectOverview";
import { getBankStatementDetail } from "@/api/ccdiProjectBankStatement";
import { buildFlowEvidenceFingerprint, buildFlowEvidenceSnapshot } from "@/utils/ccdiEvidence";
const SUSPICIOUS_TYPE_OPTIONS = [
{ value: "ALL", label: "全部可疑人员类型" },
@@ -428,6 +442,7 @@ export default {
},
},
methods: {
buildFlowEvidenceFingerprint,
async handleSuspiciousTypeChange(command) {
this.currentSuspiciousType = command;
this.suspiciousPageNum = 1;
@@ -586,6 +601,31 @@ export default {
this.detailLoading = false;
this.detailData = createEmptyDetailData();
},
handleAddEvidence() {
const detail = this.detailData || {};
const sourceRecordId = buildFlowEvidenceFingerprint(detail);
const amountText = this.formatSignedAmount(detail.displayAmount);
const counterparty = this.formatCounterpartyName(detail);
const relatedPersonName = this.resolveFlowRelatedPerson(detail);
const hitTagText = Array.isArray(detail.hitTags) && detail.hitTags.length
? `,命中${detail.hitTags.map((tag) => tag.ruleName).filter(Boolean).join("、")}标签`
: "";
this.$emit("evidence-confirm", {
evidenceType: "FLOW",
relatedPersonName,
relatedPersonId: detail.cretNo || "",
evidenceTitle: `${relatedPersonName} / ${this.formatField(detail.leAccountNo)}`,
evidenceSummary: `${this.formatField(detail.trxDate)}${relatedPersonName}账户与${counterparty}发生交易,金额${amountText}${hitTagText}`,
sourceType: "BANK_STATEMENT",
sourceRecordId,
sourcePage: "流水详情",
snapshotJson: JSON.stringify(buildFlowEvidenceSnapshot(detail)),
});
},
resolveFlowRelatedPerson(detail) {
const value = this.formatField(detail.leAccountName);
return value === "-" ? "关联人员" : value;
},
handleRiskDetailExport() {
if (!this.projectId) {
return;
@@ -960,6 +1000,26 @@ export default {
gap: 12px;
}
.detail-dialog-title {
display: inline-flex;
align-items: center;
gap: 10px;
}
.evidence-corner-btn {
padding: 4px 9px;
font-size: 12px;
color: #5b7fb8;
border-color: #d6e4f7;
background: #f8fbff;
&:hover {
color: #2474e8;
border-color: #9fc3ff;
background: #edf5ff;
}
}
:deep(.detail-dialog) {
border-radius: 8px;

View File

@@ -19,6 +19,7 @@
:project-id="projectId"
:title="sectionTitle"
:subtitle="sectionSubtitle"
@evidence-confirm="$emit('evidence-confirm', $event)"
/>
<section class="graph-placeholder-card">

View File

@@ -33,6 +33,15 @@
</div>
</div>
<div class="header-right">
<el-button
class="evidence-entry-btn"
size="mini"
plain
icon="el-icon-collection-tag"
@click="evidenceDrawerVisible = true"
>
证据线索
</el-button>
<el-menu
:default-active="activeTab"
mode="horizontal"
@@ -59,6 +68,18 @@
@name-selected="handleNameSelected"
@generate-report="handleGenerateReport"
@fetch-bank-info="handleFetchBankInfo"
@evidence-confirm="handleEvidenceConfirm"
/>
<evidence-confirm-dialog
:visible.sync="evidenceConfirmVisible"
:payload="evidencePayload"
@saved="handleEvidenceSaved"
/>
<evidence-drawer
ref="evidenceDrawer"
:visible.sync="evidenceDrawerVisible"
:project-id="projectId"
/>
</div>
</template>
@@ -69,6 +90,8 @@ import ParamConfig from "./components/detail/ParamConfig";
import PreliminaryCheck from "./components/detail/PreliminaryCheck";
import SpecialCheck from "./components/detail/SpecialCheck";
import DetailQuery from "./components/detail/DetailQuery";
import EvidenceConfirmDialog from "./components/detail/EvidenceConfirmDialog";
import EvidenceDrawer from "./components/detail/EvidenceDrawer";
import { getProject } from "@/api/ccdiProject";
export default {
@@ -79,6 +102,8 @@ export default {
PreliminaryCheck,
SpecialCheck,
DetailQuery,
EvidenceConfirmDialog,
EvidenceDrawer,
},
data() {
return {
@@ -102,6 +127,9 @@ export default {
warningThreshold: 60,
projectStatus: "0",
},
evidenceConfirmVisible: false,
evidenceDrawerVisible: false,
evidencePayload: {},
projectStatusPollingTimer: null,
projectStatusPollingInterval: 1000,
projectStatusPollingLoading: false,
@@ -139,8 +167,10 @@ export default {
// 初始化页面数据
this.initActiveTabFromRoute();
this.initPageData();
this.$root.$on("ccdi-evidence-confirm", this.handleEvidenceConfirm);
},
beforeDestroy() {
this.$root.$off("ccdi-evidence-confirm", this.handleEvidenceConfirm);
this.stopProjectStatusPolling();
},
methods: {
@@ -400,6 +430,21 @@ export default {
handleRefreshProject() {
this.initPageData();
},
handleEvidenceConfirm(payload) {
this.evidencePayload = {
projectId: this.projectId,
...(payload || {}),
};
this.evidenceConfirmVisible = true;
},
handleEvidenceSaved() {
this.evidenceDrawerVisible = true;
this.$nextTick(() => {
if (this.$refs.evidenceDrawer) {
this.$refs.evidenceDrawer.loadEvidence();
}
});
},
/** 导出报告 */
handleExport() {
console.log("导出报告");
@@ -496,6 +541,21 @@ export default {
.header-right {
display: flex;
align-items: center;
gap: 10px;
.evidence-entry-btn {
padding: 6px 10px;
font-size: 12px;
color: #5b7fb8;
border-color: #d6e4f7;
background: #f8fbff;
&:hover {
color: var(--ccdi-primary);
border-color: #9fc3ff;
background: #edf5ff;
}
}
.nav-menu {
// 移除默认背景色和边框

View File

@@ -0,0 +1,26 @@
-- 项目证据表
CREATE TABLE IF NOT EXISTS ccdi_evidence (
evidence_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '证据ID',
project_id BIGINT NOT NULL COMMENT '项目ID',
evidence_type VARCHAR(32) NOT NULL COMMENT '证据类型FLOW/MODEL/ASSET',
related_person_name VARCHAR(100) NOT NULL COMMENT '关联人员姓名',
related_person_id VARCHAR(64) DEFAULT NULL COMMENT '关联人员标识,优先存身份证号或员工号',
evidence_title VARCHAR(255) NOT NULL COMMENT '证据标题',
evidence_summary VARCHAR(1000) NOT NULL COMMENT '证据摘要',
source_type VARCHAR(64) NOT NULL COMMENT '来源类型BANK_STATEMENT/MODEL_DETAIL/ASSET_DETAIL',
source_record_id VARCHAR(128) DEFAULT NULL COMMENT '来源记录ID',
source_page VARCHAR(100) DEFAULT NULL COMMENT '来源页面名称',
snapshot_json LONGTEXT DEFAULT NULL COMMENT '证据快照JSON',
confirm_reason VARCHAR(1000) NOT NULL COMMENT '确认理由/备注',
confirm_by VARCHAR(64) DEFAULT NULL COMMENT '确认人',
confirm_time DATETIME DEFAULT NULL COMMENT '确认时间',
create_by VARCHAR(64) DEFAULT '' COMMENT '创建者',
create_time DATETIME DEFAULT NULL COMMENT '创建时间',
update_by VARCHAR(64) DEFAULT '' COMMENT '更新者',
update_time DATETIME DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (evidence_id),
KEY idx_ccdi_evidence_project (project_id),
KEY idx_ccdi_evidence_type (evidence_type),
KEY idx_ccdi_evidence_person (related_person_id),
KEY idx_ccdi_evidence_source (source_type, source_record_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目证据表';