完善外部人员预警与项目分析上线内容

This commit is contained in:
wjj
2026-06-30 10:23:55 +08:00
parent 4e90e22ee2
commit 5e4bfca05b
77 changed files with 5788 additions and 333 deletions

View File

@@ -20,6 +20,26 @@ export function getOverviewRiskPeople(params) {
})
}
export function getOverviewExternalPersons(params) {
return request({
url: '/ccdi/project/overview/external-persons',
method: 'get',
params: {
projectId: params.projectId,
pageNum: params.pageNum,
pageSize: params.pageSize
}
})
}
export function getOverviewExternalRiskSummary(projectId) {
return request({
url: '/ccdi/project/overview/external-persons/summary',
method: 'get',
params: { projectId }
})
}
export function getOverviewRiskModelCards(projectId) {
return request({
url: '/ccdi/project/overview/risk-models/cards',
@@ -28,6 +48,14 @@ export function getOverviewRiskModelCards(projectId) {
})
}
export function getOverviewExternalRiskModelCards(projectId) {
return request({
url: '/ccdi/project/overview/external-risk-models/cards',
method: 'get',
params: { projectId }
})
}
export function getOverviewRiskModelPeople(params) {
return request({
url: '/ccdi/project/overview/risk-models/people',
@@ -44,6 +72,21 @@ export function getOverviewRiskModelPeople(params) {
})
}
export function getOverviewExternalRiskModelPeople(params) {
return request({
url: '/ccdi/project/overview/external-risk-models/people',
method: 'get',
params: {
projectId: params.projectId,
modelCodes: params.modelCodes,
matchMode: params.matchMode,
keyword: params.keyword,
pageNum: params.pageNum,
pageSize: params.pageSize
}
})
}
export function getOverviewPersonAnalysisDetail(params) {
return request({
url: '/ccdi/project/overview/person-analysis/detail',

View File

@@ -130,6 +130,7 @@ export function download(url, params, filename, config) {
transformRequest: [(params) => { return tansParams(params) }],
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
responseType: 'blob',
timeout: 120000,
...config
}).then(async (data) => {
const isBlob = blobValidate(data)

View File

@@ -152,7 +152,6 @@
<div class="shell-header">
<div class="shell-title-group">
<span class="shell-title">流水明细查询</span>
<span class="shell-subtitle">按项目范围查询交易明细并查看详情</span>
</div>
<el-button
size="small"
@@ -164,6 +163,14 @@
导出流水
</el-button>
</div>
<el-alert
v-if="prefillTip"
:closable="false"
class="prefill-alert"
show-icon
type="info"
:title="prefillTip"
/>
<el-tabs v-model="activeTab" class="result-tabs" @tab-click="handleTabChange">
<el-tab-pane label="全部" name="all" />
@@ -499,6 +506,10 @@ export default {
projectStatus: "0",
}),
},
detailQueryPrefill: {
type: Object,
default: null,
},
},
data() {
return {
@@ -511,6 +522,7 @@ export default {
list: [],
total: 0,
listError: "",
prefillTip: "",
detailData: createEmptyDetailData(),
queryParams: createDefaultQueryParams(this.projectId),
optionData: createEmptyOptionData(),
@@ -530,6 +542,13 @@ export default {
this.getOptions();
this.getList();
},
detailQueryPrefill: {
immediate: true,
deep: true,
handler(value) {
this.applyDetailQueryPrefill(value);
},
},
},
methods: {
buildFlowEvidenceFingerprint,
@@ -596,10 +615,25 @@ export default {
this.activeTab = "all";
this.dateRange = [];
this.queryParams = createDefaultQueryParams(this.projectId);
this.prefillTip = "";
this.syncProjectId();
this.getOptions();
this.getList();
},
applyDetailQueryPrefill(value) {
if (!value || !Array.isArray(value.ourCertNos) || !value.ourCertNos.length) {
return;
}
this.activeTab = "all";
this.dateRange = [];
this.queryParams = {
...createDefaultQueryParams(this.projectId),
ourCertNos: value.ourCertNos,
};
this.prefillTip = `当前查看:${value.title || "外部人员"} 的本方流水`;
this.syncProjectId();
this.getList();
},
handleTabChange(tab) {
const tabName = tab && tab.name ? tab.name : this.activeTab;
this.activeTab = tabName;

View File

@@ -0,0 +1,461 @@
<template>
<el-dialog
title="外部人员详情"
:visible.sync="visibleProxy"
width="88%"
top="2vh"
append-to-body
custom-class="project-analysis-dialog external-person-analysis-dialog"
@close="handleClosed"
>
<div class="project-analysis-dialog__body external-analysis-dialog__body">
<div class="project-analysis-header">
<div class="project-analysis-header__main">
<div class="project-analysis-header__title-group">
<div class="project-analysis-header__eyebrow">结果总览</div>
<div class="project-analysis-header__title">外部人员详情</div>
</div>
<button class="project-analysis-header__close" type="button" aria-label="关闭" @click="closeDialog">
<i class="el-icon-close" />
</button>
</div>
</div>
<div class="project-analysis-workspace">
<project-analysis-sidebar
class="project-analysis-layout__sidebar"
:sidebar-data="sidebarData"
:field-labels="sidebarFieldLabels"
/>
<div class="project-analysis-layout__main">
<el-alert
v-if="detailError"
:closable="false"
class="external-detail-alert"
type="error"
show-icon
:title="detailError"
/>
<el-tabs v-model="activeTab" class="project-analysis-tabs" stretch @tab-click="handleTabChange">
<el-tab-pane label="异常明细" name="abnormalDetail">
<div v-if="detailLoading" v-loading="detailLoading" class="external-detail-loading" />
<div v-else>
<project-analysis-abnormal-tab
:detail-data="abnormalDetailData"
:person="normalizedPerson"
:project-id="projectId"
@evidence-confirm="$emit('evidence-confirm', $event)"
/>
</div>
</el-tab-pane>
<el-tab-pane label="资金流向" name="fund">
<project-analysis-fund-flow-tab
v-if="activeTab === 'fund'"
ref="fundFlowTab"
:project-id="projectId"
:person="normalizedPerson"
:model-summary="fundModelSummary"
/>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
</el-dialog>
</template>
<script>
import { listBankStatement } from "@/api/ccdiProjectBankStatement";
import ProjectAnalysisAbnormalTab from "./ProjectAnalysisAbnormalTab";
import ProjectAnalysisFundFlowTab from "./ProjectAnalysisFundFlowTab";
import ProjectAnalysisSidebar from "./ProjectAnalysisSidebar";
export default {
name: "ExternalPersonDetailDialog",
components: {
ProjectAnalysisAbnormalTab,
ProjectAnalysisFundFlowTab,
ProjectAnalysisSidebar,
},
props: {
visible: {
type: Boolean,
default: false,
},
person: {
type: Object,
default: () => ({}),
},
projectId: {
type: [String, Number],
default: null,
},
projectName: {
type: String,
default: "",
},
},
data() {
return {
activeTab: "abnormalDetail",
detailLoading: false,
detailError: "",
statementRows: [],
};
},
computed: {
visibleProxy: {
get() {
return this.visible;
},
set(value) {
this.$emit("update:visible", value);
},
},
displayName() {
return (this.person && (this.person.name || this.person.staffName)) || "外部人员";
},
displayCertNo() {
return (this.person && (this.person.idNo || this.person.staffIdCard)) || "-";
},
displaySubjectType() {
return (this.person && (this.person.subjectType || this.person.staffCode)) || "外部人员";
},
relatedObject() {
return (this.person && (this.person.relatedObject || this.person.department)) || "-";
},
normalizedPerson() {
return {
...(this.person || {}),
name: this.displayName,
staffName: this.displayName,
idNo: this.displayCertNo,
staffIdCard: this.displayCertNo,
staffCode: this.displaySubjectType,
department: this.relatedObject,
};
},
riskTags() {
if (Array.isArray(this.person && this.person.riskPointTagList)) {
return this.person.riskPointTagList;
}
if (Array.isArray(this.person && this.person.hitTagList)) {
return this.person.hitTagList;
}
return [];
},
statementTagKeys() {
const keys = new Set();
this.statementRows.forEach((row) => {
if (!Array.isArray(row && row.hitTags)) {
return;
}
row.hitTags.forEach((tag) => {
this.buildTagKeys(tag).forEach((key) => keys.add(key));
});
});
return keys;
},
sidebarFieldLabels() {
return {
staffCode: "主体类型",
department: "证件号",
projectName: "所属项目",
};
},
sidebarData() {
return {
basicInfo: {
name: this.displayName,
riskLevel: (this.person && this.person.riskLevel) || "-",
staffCode: this.displaySubjectType,
department: this.displayCertNo,
projectName: this.projectName || "-",
},
modelSummary: {
modelCount: (this.person && this.person.modelCount) || 0,
riskTags: this.riskTags,
},
};
},
abnormalDetailData() {
const statementGroup = this.buildStatementGroup();
const objectRecords = this.riskTags
.filter((tag) => !this.hasMatchedStatementTag(tag))
.map((tag, index) => this.buildObjectAbnormalRecord(tag, index));
const groups = [];
if (statementGroup.records.length) {
groups.push(statementGroup);
}
if (objectRecords.length) {
groups.push({
groupCode: "EXTERNAL_OBJECT_WARNING",
groupName: "对象异常明细",
groupType: "OBJECT",
records: objectRecords,
});
}
return {
groups,
};
},
fundModelSummary() {
return {
staffIdCard: this.displayCertNo,
modelCount: (this.person && this.person.modelCount) || this.riskTags.length,
riskTags: this.riskTags,
};
},
},
watch: {
visible(value) {
if (value) {
this.activeTab = "abnormalDetail";
this.loadStatementRows();
}
},
},
methods: {
async loadStatementRows() {
if (!this.projectId || !this.displayCertNo || this.displayCertNo === "-") {
this.statementRows = [];
return;
}
this.statementRows = [];
this.detailLoading = true;
this.detailError = "";
try {
const response = await listBankStatement({
projectId: this.projectId,
ourCertNos: [this.displayCertNo],
pageNum: 1,
pageSize: 200,
});
this.statementRows = Array.isArray(response && response.rows) ? response.rows : [];
} catch (error) {
this.statementRows = [];
this.detailError = "外部人员流水异常明细加载失败,请稍后重试";
console.error("加载外部人员流水异常明细失败", error);
} finally {
this.detailLoading = false;
}
},
buildStatementGroup() {
const records = this.statementRows
.map((row) => this.normalizeStatementRow(row))
.filter((row) => row.hitTags.length);
return {
groupCode: "EXTERNAL_BANK_STATEMENT",
groupName: "流水异常明细",
groupType: "BANK_STATEMENT",
records,
};
},
normalizeStatementRow(row) {
const hitTags = Array.isArray(row && row.hitTags)
? row.hitTags.filter((tag) => this.isMatchedRiskTag(tag))
.map((tag) => this.enrichStatementTag(tag))
: [];
return {
...row,
hitTags,
};
},
isMatchedRiskTag(statementTag) {
const statementKeys = this.buildTagKeys(statementTag);
return this.riskTags.some((riskTag) => {
const riskKeys = this.buildTagKeys(riskTag);
return statementKeys.some((key) => riskKeys.includes(key));
});
},
hasMatchedStatementTag(riskTag) {
const riskKeys = this.buildTagKeys(riskTag);
return riskKeys.some((key) => this.statementTagKeys.has(key));
},
buildTagKeys(tag) {
if (!tag) {
return [];
}
if (typeof tag === "string") {
return [`name:${tag}`];
}
return [
tag.ruleCode ? `code:${tag.ruleCode}` : "",
tag.ruleName ? `name:${tag.ruleName}` : "",
].filter(Boolean);
},
enrichStatementTag(tag) {
const matched = this.riskTags.find((item) => item && (
(tag.ruleCode && item.ruleCode === tag.ruleCode)
|| (tag.ruleName && item.ruleName === tag.ruleName)
));
return {
...tag,
modelCode: tag.modelCode || (matched && matched.modelCode),
modelName: tag.modelName || (matched && matched.modelName),
};
},
buildObjectAbnormalRecord(tag, index) {
const safeTag = typeof tag === "object" && tag ? tag : {};
const modelName = safeTag.modelName || "外部人员模型";
const ruleName = safeTag.ruleName || this.formatRiskTag(tag) || "外部人员预警";
return {
modelCode: safeTag.modelCode || `EXTERNAL_MODEL_${index}`,
title: ruleName,
subtitle: modelName,
riskTags: [modelName, this.formatRiskLevel(safeTag.riskLevel)].filter(Boolean),
reasonDetail: safeTag.reasonDetail || this.buildReasonDetail(ruleName, modelName),
summary: ruleName,
extraFields: [],
};
},
buildReasonDetail(ruleName, modelName) {
const parts = [
this.displayName,
`命中${modelName}`,
ruleName,
].filter(Boolean);
return parts.join("");
},
formatRiskLevel(value) {
if (value === "HIGH") {
return "高风险";
}
if (value === "MEDIUM") {
return "中风险";
}
if (value === "LOW") {
return "低风险";
}
return "";
},
formatRiskTag(tag) {
if (typeof tag === "string") {
return tag;
}
if (tag && typeof tag === "object") {
return tag.ruleName || tag.modelName || tag.label || tag.name || "";
}
return "";
},
closeDialog() {
this.visibleProxy = false;
},
handleClosed() {
this.activeTab = "abnormalDetail";
this.detailLoading = false;
this.detailError = "";
this.statementRows = [];
},
handleTabChange() {
this.$nextTick(() => {
const fundFlowTab = this.$refs.fundFlowTab;
if (fundFlowTab && fundFlowTab.ensureGraphReady) {
fundFlowTab.ensureGraphReady();
}
});
},
},
};
</script>
<style lang="scss" scoped>
.project-analysis-dialog__body {
display: flex;
flex-direction: column;
min-height: calc(92vh - 64px);
border: 1px solid #dde3ec;
background: #ffffff;
overflow: hidden;
}
.project-analysis-header {
padding: 28px 40px 24px;
border-bottom: 1px solid #dde3ec;
background: #ffffff;
}
.project-analysis-header__main {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
}
.project-analysis-header__title-group {
min-width: 0;
}
.project-analysis-header__eyebrow {
color: #65758d;
font-size: 15px;
font-weight: 700;
line-height: 1;
}
.project-analysis-header__title {
margin-top: 18px;
color: #101a2b;
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.project-analysis-header__close {
flex: 0 0 auto;
width: 34px;
height: 34px;
border: none;
border-radius: 2px;
background: transparent;
color: #8d99aa;
font-size: 18px;
line-height: 34px;
text-align: center;
cursor: pointer;
}
.project-analysis-header__close:hover {
background: #f2f5f8;
color: #245b8f;
}
.project-analysis-workspace {
display: flex;
align-items: flex-start;
gap: 36px;
min-height: 640px;
max-height: calc(92vh - 150px);
padding: 20px 40px 28px;
overflow: auto;
background: #ffffff;
}
.project-analysis-layout__sidebar {
flex: 0 0 34%;
max-width: 460px;
}
.project-analysis-layout__main {
flex: 1;
min-width: 0;
border-left: 1px solid #dde3ec;
padding-left: 36px;
}
.project-analysis-tabs {
margin-top: -2px;
}
.external-detail-alert {
margin-bottom: 12px;
}
.external-detail-loading {
min-height: 360px;
border: 1px solid #d9e1ed;
background: #ffffff;
}
</style>

View File

@@ -12,6 +12,7 @@
<div class="stats-content">
<div class="stats-label">{{ item.label }}</div>
<div class="stats-value">{{ item.value }}</div>
<div v-if="item.desc" class="stats-desc">{{ item.desc }}</div>
</div>
</div>
</div>
@@ -98,4 +99,12 @@ export default {
font-weight: 700;
color: var(--ccdi-text-primary);
}
.stats-desc {
margin-top: 6px;
font-size: 12px;
line-height: 1.2;
color: var(--ccdi-text-muted);
white-space: nowrap;
}
</style>

View File

@@ -30,18 +30,21 @@
导出报告
</el-button>
</div>
<overview-stats :summary="currentData.summary" />
<overview-stats :summary="visibleSummary" />
<risk-people-section
:project-id="projectId"
:section-data="currentData.riskPeople"
:selected-model-codes="selectedModelCodes"
@view-project-analysis="handleRiskPeopleProjectAnalysis"
@view-external-detail="handleExternalDetail"
@scope-summary-change="handleRiskPeopleScopeSummaryChange"
/>
</section>
<risk-model-section
:section-data="currentData.riskModels"
@selection-change="handleRiskModelSelectionChange"
@view-project-analysis="handleRiskModelProjectAnalysis"
@view-external-detail="handleExternalDetail"
/>
<risk-detail-section
:section-data="currentData.riskDetails"
@@ -58,6 +61,13 @@
@close="handleProjectAnalysisDialogClose"
@evidence-confirm="$emit('evidence-confirm', $event)"
/>
<external-person-detail-dialog
:visible.sync="externalDetailVisible"
:person="currentExternalPerson"
:project-id="projectId"
:project-name="projectInfo.projectName"
@evidence-confirm="$emit('evidence-confirm', $event)"
/>
</div>
</template>
@@ -70,6 +80,7 @@ import {
import {
getOverviewDashboard,
getOverviewEmployeeCreditNegative,
getOverviewExternalRiskSummary,
getOverviewRiskPeople,
getOverviewRiskModelCards,
getOverviewSuspiciousTransactions,
@@ -79,6 +90,7 @@ import RiskPeopleSection from "./RiskPeopleSection";
import RiskModelSection from "./RiskModelSection";
import RiskDetailSection from "./RiskDetailSection";
import ProjectAnalysisDialog from "./ProjectAnalysisDialog";
import ExternalPersonDetailDialog from "./ExternalPersonDetailDialog";
export default {
name: "PreliminaryCheck",
@@ -88,6 +100,7 @@ export default {
RiskModelSection,
RiskDetailSection,
ProjectAnalysisDialog,
ExternalPersonDetailDialog,
},
props: {
projectId: {
@@ -118,6 +131,15 @@ export default {
currentModel: "-",
riskTags: [],
},
riskPeopleScopeSummary: {
activeTab: "employee",
external: {
total: 0,
rows: [],
},
},
currentExternalPerson: null,
externalDetailVisible: false,
};
},
computed: {
@@ -127,6 +149,12 @@ export default {
}
return this.stateDataMap[this.pageState] || this.realData;
},
visibleSummary() {
return {
...this.currentData.summary,
stats: this.buildCombinedSummaryStats(),
};
},
},
watch: {
projectId(newVal) {
@@ -138,6 +166,7 @@ export default {
this.pageState = "empty";
this.selectedModelCodes = [];
this.resetProjectAnalysisDialog();
this.resetExternalDetailDialog();
},
},
created() {
@@ -148,17 +177,90 @@ export default {
this.realData = this.stateDataMap.empty;
this.pageState = "empty";
this.resetProjectAnalysisDialog();
this.resetExternalDetailDialog();
},
methods: {
handleRiskModelSelectionChange(modelCodes) {
this.selectedModelCodes = Array.isArray(modelCodes) ? [...modelCodes] : [];
},
handleRiskPeopleScopeSummaryChange(summary) {
this.riskPeopleScopeSummary = {
activeTab: summary && summary.activeTab ? summary.activeTab : "employee",
external: {
total: summary && summary.external ? summary.external.total || 0 : 0,
rows: summary && summary.external && Array.isArray(summary.external.rows)
? summary.external.rows
: [],
},
};
},
buildCombinedSummaryStats() {
const external = (this.currentData.summary && this.currentData.summary.externalRiskSummary) || {};
const employeeStats = ((this.currentData.summary && this.currentData.summary.stats) || []);
const statMap = employeeStats.reduce((result, item) => {
result[item.key] = item;
return result;
}, {});
const high = Number((statMap.riskPeople && statMap.riskPeople.value) || 0);
const medium = Number((statMap.medium && statMap.medium.value) || 0);
const low = Number((statMap.low && statMap.low.value) || 0);
const noRisk = Number((statMap.count && statMap.count.value) || 0);
const externalTotal = Number(external.total || 0);
const externalHigh = Number(external.high || 0);
const externalMedium = Number(external.medium || 0);
const externalLow = Number(external.low || 0);
const externalNoRisk = Number(external.noRisk || 0);
const buildSplit = (employeeValue, externalValue) => (externalTotal > 0
? `员工 ${employeeValue} / 外部 ${externalValue}`
: "");
return [
{
...(statMap.people || {}),
key: "people",
label: "总人数",
value: Number((statMap.people && statMap.people.value) || 0) + externalTotal,
desc: buildSplit(Number((statMap.people && statMap.people.value) || 0), externalTotal),
},
{
...(statMap.riskPeople || {}),
key: "riskPeople",
label: "高风险",
value: high + externalHigh,
desc: buildSplit(high, externalHigh),
},
{
...(statMap.medium || {}),
key: "medium",
label: "中风险",
value: medium + externalMedium,
desc: buildSplit(medium, externalMedium),
},
{
...(statMap.low || {}),
key: "low",
label: "低风险",
value: low + externalLow,
desc: buildSplit(low, externalLow),
},
{
...(statMap.count || {}),
key: "count",
label: "无风险",
value: noRisk + externalNoRisk,
desc: buildSplit(noRisk, externalNoRisk),
},
];
},
handleRiskPeopleProjectAnalysis(row) {
this.openProjectAnalysisDialog("riskPeople", row);
},
handleRiskModelProjectAnalysis(row) {
this.openProjectAnalysisDialog("riskModelPeople", row);
},
handleExternalDetail(row) {
this.currentExternalPerson = row || null;
this.externalDetailVisible = true;
},
openProjectAnalysisDialog(source, person) {
this.projectAnalysisSource = source || "riskPeople";
this.currentProjectAnalysisPerson = person || null;
@@ -201,6 +303,10 @@ export default {
riskTags,
};
},
resetExternalDetailDialog() {
this.externalDetailVisible = false;
this.currentExternalPerson = null;
},
handleOverviewReportExport() {
if (!this.projectId) {
return;
@@ -219,14 +325,30 @@ export default {
this.pageState = "empty";
this.selectedModelCodes = [];
this.resetProjectAnalysisDialog();
this.resetExternalDetailDialog();
return;
}
this.pageState = "loading";
this.selectedModelCodes = [];
this.riskPeopleScopeSummary = {
activeTab: "employee",
external: {
total: 0,
rows: [],
},
};
this.resetProjectAnalysisDialog();
this.resetExternalDetailDialog();
try {
const [dashboardRes, riskPeopleRes, riskModelCardsRes, suspiciousRes, creditNegativeRes] = await Promise.all([
const [
dashboardRes,
riskPeopleRes,
riskModelCardsRes,
suspiciousRes,
creditNegativeRes,
externalSummaryRes,
] = await Promise.all([
getOverviewDashboard(this.projectId),
getOverviewRiskPeople({
projectId: this.projectId,
@@ -245,12 +367,14 @@ export default {
pageNum: 1,
pageSize: 5,
}),
getOverviewExternalRiskSummary(this.projectId),
]);
const dashboardData = (dashboardRes && dashboardRes.data) || {};
const riskPeopleData = (riskPeopleRes && riskPeopleRes.data) || {};
const riskModelCardsData = (riskModelCardsRes && riskModelCardsRes.data) || {};
const suspiciousData = (suspiciousRes && suspiciousRes.data) || {};
const creditNegativeData = (creditNegativeRes && creditNegativeRes.data) || {};
const externalRiskSummary = (externalSummaryRes && externalSummaryRes.data) || {};
this.realData = createOverviewLoadedData({
projectId: this.projectId,
@@ -259,6 +383,7 @@ export default {
riskModelCardsData,
suspiciousData,
creditNegativeData,
externalRiskSummary,
});
const hasOverviewData = Boolean(
@@ -273,6 +398,7 @@ export default {
this.pageState = "empty";
this.selectedModelCodes = [];
this.resetProjectAnalysisDialog();
this.resetExternalDetailDialog();
console.error("加载结果总览失败", error);
}
},

View File

@@ -24,6 +24,9 @@
{{ dialogData.sourceSummary.currentModelValue }}
</span>
</div>
<button class="project-analysis-header__close" type="button" aria-label="关闭" @click="closeDialog">
<i class="el-icon-close" />
</button>
</div>
</div>
<div v-loading="detailLoading" class="project-analysis-workspace">
@@ -224,6 +227,9 @@ export default {
this.resetDialogState();
this.$emit("close");
},
closeDialog() {
this.visibleProxy = false;
},
handleTabChange() {
this.$nextTick(() => {
const tabRef = this.activeTab === "relationshipGraph"
@@ -303,6 +309,25 @@ export default {
font-weight: 600;
}
.project-analysis-header__close {
flex: 0 0 auto;
width: 34px;
height: 34px;
border: none;
border-radius: 2px;
background: transparent;
color: #8d99aa;
font-size: 18px;
line-height: 34px;
text-align: center;
cursor: pointer;
}
.project-analysis-header__close:hover {
background: #f2f5f8;
color: #245b8f;
}
.project-analysis-workspace {
display: flex;
align-items: flex-start;

View File

@@ -51,7 +51,7 @@ export default {
},
computed: {
isFundOnly() {
return this.initialGraphTab === "fund" && this.graphTabs.length === 2;
return this.initialGraphTab === "fund";
},
rootClasses() {
return {

View File

@@ -11,15 +11,15 @@
</div>
<div class="sidebar-profile__meta">
<div class="sidebar-profile__item">
<span class="sidebar-profile__label">工号</span>
<span class="sidebar-profile__label">{{ normalizedFieldLabels.staffCode }}</span>
<span class="sidebar-profile__value">{{ sidebarData.basicInfo.staffCode || "-" }}</span>
</div>
<div class="sidebar-profile__item">
<span class="sidebar-profile__label">部门</span>
<span class="sidebar-profile__label">{{ normalizedFieldLabels.department }}</span>
<span class="sidebar-profile__value">{{ sidebarData.basicInfo.department || "-" }}</span>
</div>
<div class="sidebar-profile__item">
<span class="sidebar-profile__label">所属项目</span>
<span class="sidebar-profile__label">{{ normalizedFieldLabels.projectName }}</span>
<span class="sidebar-profile__value">{{ sidebarData.basicInfo.projectName || "-" }}</span>
</div>
</div>
@@ -63,6 +63,23 @@ export default {
},
}),
},
fieldLabels: {
type: Object,
default: () => ({
staffCode: "工号",
department: "部门",
projectName: "所属项目",
}),
},
},
computed: {
normalizedFieldLabels() {
return {
staffCode: this.fieldLabels.staffCode || "工号",
department: this.fieldLabels.department || "部门",
projectName: this.fieldLabels.projectName || "所属项目",
};
},
},
methods: {
formatRiskTag(tag) {

View File

@@ -351,6 +351,7 @@ const SUSPICIOUS_TYPE_OPTIONS = [
{ value: "ALL", label: "全部可疑人员类型" },
{ value: "NAME_LIST", label: "名单库命中" },
{ value: "MODEL_RULE", label: "模型规则命中" },
{ value: "EXTERNAL_PERSON", label: "外部人员预警" },
];
const normalizeSuspiciousType = (value) => (

View File

@@ -8,160 +8,209 @@
</div>
</div>
<div class="block">
<div class="block-header">
<div>
<div class="block-title">模型预警次数统计</div>
<div class="block-subtitle">模型汇总预警命中次数与涉及人数</div>
</div>
<el-button size="mini" type="text">导出</el-button>
</div>
<div v-loading="cardLoading" class="model-card-grid">
<button
v-for="item in cards"
:key="item.key"
type="button"
class="model-card"
:class="{ 'is-active': isModelSelected(item.key) }"
@click="toggleModelSelection(item.key)"
>
<div class="model-card-title">{{ item.title }}</div>
<div class="model-card-count">{{ item.count }}</div>
<div class="model-card-meta">涉及 {{ item.peopleCount }} </div>
<div class="model-card-action">
{{ isModelSelected(item.key) ? "再次点击取消" : "点击加入联动" }}
<div class="model-workbench">
<section class="model-list-panel">
<div class="panel-header">
<div>
<div class="panel-title">模型预警统计</div>
<div class="panel-subtitle">点击模型筛选下方命中人员</div>
</div>
</button>
</div>
</div>
<div class="block">
<div class="block-header">
<div>
<div class="block-title">命中模型涉及人员</div>
<div class="block-subtitle">基于筛选条件查看模型命中人员</div>
</div>
</div>
<div class="filter-bar">
<div class="filter-item filter-item--keyword">
<span class="filter-label">员工姓名或工号</span>
<el-input
v-model.trim="keyword"
size="mini"
clearable
placeholder="请输入员工姓名或工号"
@keyup.enter.native="handleQuery"
/>
<el-button size="mini" type="text" :disabled="!projectId" @click="handleModelExport">导出</el-button>
</div>
<div class="filter-item filter-item--dept">
<span class="filter-label">部门</span>
<el-select
v-model="deptId"
size="mini"
clearable
filterable
:loading="deptLoading"
placeholder="请选择部门"
<div v-loading="cardLoading" class="model-card-grid">
<button
v-for="item in displayCards"
:key="item.key"
type="button"
class="model-card"
:class="{ 'is-active': isModelSelected(item.key), 'is-external': item.sourceType === 'external' }"
@click="toggleModelSelection(item.key)"
>
<el-option
v-for="item in deptOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<span class="model-card-head">
<span v-if="item.sourceType === 'external'" class="source-pill is-external">
{{ formatSourceLabel(item) }}
</span>
<span class="model-card-action">{{ isModelSelected(item.key) ? "已筛选" : "筛选" }}</span>
</span>
<span class="model-card-title">{{ item.title }}</span>
<span class="model-card-metrics">
<span><strong>{{ item.count }}</strong><em></em></span>
<span><strong>{{ item.peopleCount }}</strong><em></em></span>
</span>
</button>
</div>
</section>
<section class="model-result-panel">
<div class="panel-header">
<div>
<div class="panel-title">命中模型涉及人员</div>
<div class="panel-subtitle">按模型人员信息和触发方式查看明细</div>
</div>
</div>
<div class="filter-item filter-item--mode">
<span class="filter-label">触发方式</span>
<el-radio-group
v-model="matchMode"
size="mini"
@change="handleMatchModeChange"
>
<el-radio-button label="ANY">任意触发</el-radio-button>
<el-radio-button label="ALL">同时触发</el-radio-button>
</el-radio-group>
</div>
<div class="filter-summary">
<span class="summary-label">已选模型</span>
<span class="summary-value">{{ selectedModelText }}</span>
</div>
<div class="filter-actions">
<el-button size="mini" type="primary" @click="handleQuery">查询</el-button>
<el-button size="mini" plain @click="resetQuery">重置</el-button>
</div>
</div>
<el-table v-loading="tableLoading" :data="peopleList" class="model-table">
<template slot="empty">
<el-empty :image-size="80" description="当前筛选条件下暂无命中人员" />
</template>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="staffCode" label="工号" min-width="120" />
<el-table-column prop="idNo" label="身份证号" min-width="180" />
<el-table-column prop="department" label="所属部门" min-width="140" />
<el-table-column label="命中模型" min-width="180">
<template slot-scope="scope">
<span>{{ formatModelNames(scope.row.modelNames) }}</span>
</template>
</el-table-column>
<el-table-column label="异常标签" min-width="220">
<template slot-scope="scope">
<div v-if="scope.row.hitTagList && scope.row.hitTagList.length" class="hit-tag-list">
<div class="selected-strip">
<span class="selected-label">当前筛选</span>
<div class="selected-content">
<template v-if="selectedCards.length">
<el-tag
v-for="item in selectedCards"
:key="`selected-${item.key}`"
size="mini"
closable
effect="plain"
@close="toggleModelSelection(item.key)"
>
{{ item.title }}
</el-tag>
</template>
<span v-else class="selected-placeholder">全部模型</span>
</div>
<el-button v-if="selectedCards.length" size="mini" type="text" @click="clearSelectedModels">
清空模型
</el-button>
</div>
<div class="filter-bar">
<div class="filter-item filter-item--keyword">
<span class="filter-label">{{ keywordLabel }}</span>
<el-input
v-model.trim="keyword"
size="mini"
clearable
:placeholder="keywordPlaceholder"
@keyup.enter.native="handleQuery"
/>
</div>
<div v-if="activeSourceType === 'employee'" class="filter-item filter-item--dept">
<span class="filter-label">部门</span>
<el-select
v-model="deptId"
size="mini"
clearable
filterable
:loading="deptLoading"
placeholder="请选择部门"
>
<el-option
v-for="item in deptOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
<div class="filter-item filter-item--mode">
<span class="filter-label">模型关系</span>
<el-radio-group
v-model="matchMode"
size="mini"
@change="handleMatchModeChange"
>
<el-radio-button label="ANY">命中任一模型</el-radio-button>
<el-radio-button label="ALL">同时命中全部</el-radio-button>
</el-radio-group>
</div>
<div class="filter-actions">
<el-button size="mini" type="primary" @click="handleQuery">查询</el-button>
<el-button size="mini" plain @click="resetQuery">重置</el-button>
</div>
</div>
<el-table v-loading="tableLoading" :data="peopleList" class="model-table">
<template slot="empty">
<el-empty :image-size="80" description="当前筛选条件下暂无命中人员" />
</template>
<el-table-column type="index" label="序号" width="60" />
<el-table-column v-if="activeSourceType === 'all'" label="来源" width="84">
<template slot-scope="scope">
<el-tag
v-for="(tag, index) in scope.row.hitTagList"
:key="`${scope.row.staffCode || scope.row.idNo || index}-tag-${index}`"
size="mini"
effect="plain"
:type="resolveModelTagType(tag)"
:type="scope.row.sourceType === 'external' ? 'success' : ''"
>
{{ tag.ruleName }}
{{ scope.row.sourceType === "external" ? "外部" : "员工" }}
</el-tag>
</div>
<span v-else class="empty-text">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="right">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="handleViewProject(scope.row)">{{
scope.row.actionLabel || "查看项目"
}}</el-button>
</template>
</el-table-column>
</el-table>
</template>
</el-table-column>
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column
v-if="activeSourceType !== 'external'"
prop="staffCode"
:label="activeSourceType === 'all' ? '工号/主体类型' : '工号'"
min-width="120"
/>
<el-table-column prop="idNo" label="身份证号" min-width="180" />
<el-table-column
prop="department"
:label="departmentLabel"
min-width="140"
/>
<el-table-column label="命中模型" min-width="180">
<template slot-scope="scope">
<span>{{ formatModelNames(scope.row.modelNames) }}</span>
</template>
</el-table-column>
<el-table-column label="异常标签" min-width="220">
<template slot-scope="scope">
<div v-if="scope.row.hitTagList && scope.row.hitTagList.length" class="hit-tag-list">
<el-tag
v-for="(tag, index) in scope.row.hitTagList"
:key="`${scope.row.staffCode || scope.row.idNo || index}-tag-${index}`"
size="mini"
effect="plain"
:type="resolveModelTagType(tag)"
>
{{ tag.ruleName }}
</el-tag>
</div>
<span v-else class="empty-text">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="right">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="handleViewProject(scope.row)">{{
scope.row.actionLabel || "查看项目"
}}</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
background
layout="prev, pager, next"
:current-page="pageNum"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
/>
</div>
<div class="pagination-bar">
<el-pagination
background
layout="prev, pager, next"
:current-page="pageNum"
:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
/>
</div>
</section>
</div>
</div>
</section>
</template>
<script>
import { getOverviewRiskModelPeople } from "@/api/ccdi/projectOverview";
import {
getOverviewExternalRiskModelCards,
getOverviewExternalRiskModelPeople,
getOverviewRiskModelPeople,
} from "@/api/ccdi/projectOverview";
import { deptTreeSelect } from "@/api/system/user";
function normalizePeopleRows(rows) {
function normalizePeopleRows(rows, sourceType) {
if (!Array.isArray(rows)) {
return [];
}
return rows.map((item) => ({
...item,
sourceType,
name: item.staffName || item.name || "",
staffCode: item.staffCode || "",
modelNames: Array.isArray(item.modelNames) ? item.modelNames : [],
@@ -206,6 +255,7 @@ export default {
tableLoading: false,
deptLoading: false,
deptOptions: [],
externalCards: [],
peopleList: [],
total: 0,
};
@@ -218,22 +268,65 @@ export default {
if (!Array.isArray(this.sectionData && this.sectionData.cardList)) {
return [];
}
return this.sectionData.cardList.map((item) => ({
key: item.key || item.modelCode,
title: item.title || item.modelName,
count: item.count || item.warningCount || 0,
peopleCount: item.peopleCount || 0,
}));
return this.sectionData.cardList
.filter((item) => !String(item.key || item.modelCode || "").startsWith("EXTERNAL_"))
.map((item) => ({
key: item.key || item.modelCode,
title: item.title || item.modelName,
count: item.count || item.warningCount || 0,
peopleCount: item.peopleCount || 0,
sourceType: "employee",
}));
},
displayCards() {
return [...this.cards, ...this.externalCards];
},
activeSourceType() {
if (!this.selectedModelCodes.length) {
return "all";
}
const selected = this.displayCards.find((item) => this.selectedModelCodes.includes(item.key));
return selected && selected.sourceType === "external" ? "external" : "employee";
},
keywordLabel() {
if (this.activeSourceType === "external") {
return "姓名或证件号";
}
if (this.activeSourceType === "all") {
return "姓名/证件号/工号";
}
return "员工姓名或工号";
},
keywordPlaceholder() {
if (this.activeSourceType === "external") {
return "请输入姓名或证件号";
}
if (this.activeSourceType === "all") {
return "请输入姓名、证件号或工号";
}
return "请输入员工姓名或工号";
},
departmentLabel() {
if (this.activeSourceType === "external") {
return "涉及对象";
}
if (this.activeSourceType === "all") {
return "所属部门/涉及对象";
}
return "所属部门";
},
selectedModelText() {
if (!this.selectedModelCodes.length) {
return "全部模型";
}
return this.cards
return this.displayCards
.filter((item) => this.selectedModelCodes.includes(item.key))
.map((item) => item.title)
.join("、");
},
selectedCards() {
return this.displayCards.filter((item) => this.selectedModelCodes.includes(item.key));
},
},
watch: {
projectId: {
@@ -242,6 +335,7 @@ export default {
this.selectedModelCodes = [];
this.$emit("selection-change", this.selectedModelCodes);
this.pageNum = 1;
this.loadExternalCards();
this.fetchPeopleList();
},
},
@@ -253,13 +347,31 @@ export default {
isModelSelected(modelCode) {
return this.selectedModelCodes.includes(modelCode);
},
formatSourceLabel(item) {
return item && item.sourceType === "external" ? "外部" : "员工";
},
clearSelectedModels() {
this.selectedModelCodes = [];
this.$emit("selection-change", this.selectedModelCodes);
this.pageNum = 1;
this.fetchPeopleList({ syncCardLoading: true });
},
toggleModelSelection(modelCode) {
const target = this.displayCards.find((item) => item.key === modelCode);
const targetSourceType = target && target.sourceType === "external" ? "external" : "employee";
const currentSourceType = this.activeSourceType;
if (this.selectedModelCodes.length && targetSourceType !== currentSourceType) {
this.selectedModelCodes = [];
}
if (this.selectedModelCodes.includes(modelCode)) {
this.selectedModelCodes = this.selectedModelCodes.filter((item) => item !== modelCode);
} else {
this.selectedModelCodes = [...this.selectedModelCodes, modelCode];
}
this.$emit("selection-change", this.selectedModelCodes);
if (targetSourceType === "external") {
this.deptId = undefined;
}
this.pageNum = 1;
this.fetchPeopleList({ syncCardLoading: true });
},
@@ -284,15 +396,16 @@ export default {
this.pageNum = pageNum;
this.fetchPeopleList();
},
buildPeopleParams() {
buildPeopleParams(sourceType = this.activeSourceType, overrides = {}) {
return {
projectId: this.projectId,
modelCodes: this.selectedModelCodes,
matchMode: this.matchMode,
keyword: this.keyword,
deptId: this.deptId,
deptId: sourceType === "employee" && this.activeSourceType === "employee" ? this.deptId : undefined,
pageNum: this.pageNum,
pageSize: this.pageSize,
...overrides,
};
},
formatModelNames(modelNames) {
@@ -302,6 +415,10 @@ export default {
return modelNames.join("、");
},
handleViewProject(row) {
if ((row && row.sourceType === "external") || this.activeSourceType === "external") {
this.$emit("view-external-detail", row);
return;
}
this.$emit("view-project-analysis", row);
},
resolveModelTagType(tag) {
@@ -325,6 +442,44 @@ export default {
this.deptLoading = false;
}
},
async loadExternalCards() {
if (!this.projectId) {
this.externalCards = [];
return;
}
try {
const response = await getOverviewExternalRiskModelCards(this.projectId);
const rows = Array.isArray(response && response.data && response.data.cardList)
? response.data.cardList
: [];
this.externalCards = rows.map((item) => ({
key: item.modelCode,
title: item.modelName,
count: item.warningCount || 0,
peopleCount: item.peopleCount || 0,
sourceType: "external",
}));
} catch (error) {
this.externalCards = [];
console.error("加载外部人员风险模型失败", error);
}
},
handleModelExport() {
if (!this.projectId) {
return;
}
const url = this.activeSourceType === "external"
? "ccdi/project/overview/external-risk-models/people/export"
: "ccdi/project/overview/risk-models/people/export";
const filePrefix = this.activeSourceType === "external"
? "外部人员风险模型命中明细"
: "风险模型命中明细";
this.download(
url,
this.buildPeopleParams(),
`${filePrefix}_${this.projectId}_${new Date().getTime()}.xlsx`
);
},
async fetchPeopleList(options = {}) {
if (!this.projectId) {
this.peopleList = [];
@@ -338,9 +493,19 @@ export default {
}
this.tableLoading = true;
try {
const response = await getOverviewRiskModelPeople(this.buildPeopleParams());
if (this.activeSourceType === "all") {
await this.fetchAllPeopleList();
return;
}
const requestFn = this.activeSourceType === "external"
? getOverviewExternalRiskModelPeople
: getOverviewRiskModelPeople;
const response = await requestFn(this.buildPeopleParams());
const data = (response && response.data) || {};
this.peopleList = normalizePeopleRows(data.rows);
this.peopleList = normalizePeopleRows(
data.rows,
this.activeSourceType === "external" ? "external" : "employee"
);
this.total = Number(data.total || 0);
} catch (error) {
this.peopleList = [];
@@ -353,6 +518,55 @@ export default {
}
}
},
async fetchAllPeopleList() {
const countParams = {
modelCodes: [],
pageNum: 1,
pageSize: 1,
};
const [employeeCountRes, externalCountRes] = await Promise.all([
getOverviewRiskModelPeople(this.buildPeopleParams("employee", countParams)),
getOverviewExternalRiskModelPeople(this.buildPeopleParams("external", countParams)),
]);
const employeeTotal = Number((employeeCountRes && employeeCountRes.data && employeeCountRes.data.total) || 0);
const externalTotal = Number((externalCountRes && externalCountRes.data && externalCountRes.data.total) || 0);
const offset = (this.pageNum - 1) * this.pageSize;
const rows = [];
if (offset < employeeTotal) {
const employeeLimit = offset + this.pageSize;
const employeeRes = await getOverviewRiskModelPeople(this.buildPeopleParams("employee", {
modelCodes: [],
pageNum: 1,
pageSize: employeeLimit,
}));
const employeeRows = normalizePeopleRows(
employeeRes && employeeRes.data && employeeRes.data.rows,
"employee"
);
rows.push(...employeeRows.slice(offset, offset + this.pageSize));
}
if (rows.length < this.pageSize) {
const externalOffset = Math.max(0, offset - employeeTotal);
if (externalOffset < externalTotal) {
const externalLimit = externalOffset + (this.pageSize - rows.length);
const externalRes = await getOverviewExternalRiskModelPeople(this.buildPeopleParams("external", {
modelCodes: [],
pageNum: 1,
pageSize: externalLimit,
}));
const externalRows = normalizePeopleRows(
externalRes && externalRes.data && externalRes.data.rows,
"external"
);
rows.push(...externalRows.slice(externalOffset, externalOffset + (this.pageSize - rows.length)));
}
}
this.peopleList = rows;
this.total = employeeTotal + externalTotal;
},
},
};
</script>
@@ -363,8 +577,8 @@ export default {
}
.section-card {
padding: 20px;
border-radius: 14px;
padding: 18px;
border-radius: 8px;
background: #fff;
border: 1px solid var(--ccdi-border);
box-shadow: var(--ccdi-shadow);
@@ -389,40 +603,44 @@ export default {
color: var(--ccdi-text-muted);
}
.block + .block {
margin-top: 24px;
}
.block-header {
.model-workbench {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
flex-direction: column;
gap: 14px;
}
.block-title {
position: relative;
padding-left: 10px;
.model-list-panel,
.model-result-panel {
border: 1px solid var(--ccdi-border);
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.model-result-panel {
min-width: 0;
padding-bottom: 12px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 54px;
padding: 12px 14px;
border-bottom: 1px solid #e7eef5;
background: #f8fbfe;
}
.panel-title {
font-size: 15px;
font-weight: 600;
line-height: 20px;
font-weight: 700;
color: var(--ccdi-text-primary);
}
.block-title::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 4px;
height: 14px;
border-radius: 999px;
background: #8ea7be;
transform: translateY(-50%);
}
.block-subtitle {
margin-top: 4px;
padding-left: 10px;
.panel-subtitle {
margin-top: 3px;
font-size: 12px;
color: var(--ccdi-text-muted);
}
@@ -430,62 +648,159 @@ export default {
.model-card-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
gap: 10px;
padding: 12px;
}
.model-card {
display: block;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 104px;
width: 100%;
padding: 18px;
border: 1px solid var(--ccdi-border);
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbfd 100%);
padding: 12px;
border: 1px solid #e4edf6;
border-radius: 6px;
background: #fff;
color: var(--ccdi-text-primary);
text-align: left;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
}
.model-card:hover,
.model-card.is-active {
border-color: #bdd0e2;
box-shadow: 0 10px 22px rgba(47, 93, 138, 0.11);
transform: translateY(-2px);
background: #f3f8fd;
}
.model-card.is-active {
box-shadow: inset 0 3px 0 var(--ccdi-primary);
}
.model-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.source-pill {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 20px;
border-radius: 999px;
background: #edf2f7;
color: #52677d;
font-size: 12px;
flex-shrink: 0;
}
.source-pill.is-external {
background: #edf7ff;
color: #2f5f91;
}
.model-card-title {
font-size: 14px;
font-weight: 600;
color: var(--ccdi-text-primary);
}
.model-card-count {
display: block;
margin-top: 12px;
font-size: 28px;
font-weight: 700;
color: var(--ccdi-primary);
color: var(--ccdi-text-primary);
font-size: 13px;
font-weight: 600;
line-height: 18px;
}
.model-card-meta {
margin-top: 8px;
font-size: 12px;
.model-card-metrics {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
}
.model-card-metrics span {
display: inline-flex;
align-items: baseline;
gap: 4px;
color: var(--ccdi-text-muted);
}
.model-card-action {
margin-top: 12px;
font-size: 12px;
.model-card-metrics strong {
color: var(--ccdi-primary);
font-size: 22px;
line-height: 1;
}
.model-card-metrics em {
color: var(--ccdi-text-muted);
font-size: 12px;
font-style: normal;
}
.model-card-action {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
min-width: 44px;
padding: 0 8px;
border-radius: 4px;
color: var(--ccdi-primary);
font-size: 12px;
background: #eef6ff;
}
.model-card:not(.is-active) .model-card-action {
background: transparent;
}
.model-card.is-active .model-card-action {
font-weight: 600;
}
.selected-strip {
display: flex;
align-items: center;
gap: 10px;
margin: 12px 14px 0;
padding: 8px 10px;
border: 1px solid #e6edf5;
border-radius: 6px;
background: #fbfdff;
}
.selected-label {
color: var(--ccdi-text-secondary);
font-size: 12px;
flex-shrink: 0;
}
.selected-content {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
flex: 1;
}
.selected-placeholder {
color: var(--ccdi-text-muted);
font-size: 13px;
}
.filter-bar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 14px;
padding: 14px 16px;
border: 1px solid var(--ccdi-border);
border-radius: 12px;
background: #f8fbfe;
flex-wrap: wrap;
gap: 10px 14px;
margin: 10px 14px 12px;
padding: 10px;
border: 1px solid #e6edf5;
border-radius: 6px;
background: #fff;
}
.filter-item {
@@ -500,12 +815,12 @@ export default {
.filter-item--keyword,
.filter-item--dept {
min-width: 240px;
min-width: 220px;
}
.filter-item--keyword :deep(.el-input),
.filter-item--dept :deep(.el-select) {
width: 220px;
width: 200px;
}
.filter-label,
@@ -516,23 +831,6 @@ export default {
white-space: nowrap;
}
.filter-summary {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.summary-value {
min-width: 0;
color: var(--ccdi-text-primary);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.filter-actions {
margin-left: auto;
display: flex;
@@ -540,7 +838,10 @@ export default {
}
.model-table {
border-radius: 12px;
margin: 0 14px;
width: calc(100% - 28px);
border: 1px solid #e7eef5;
border-radius: 6px;
overflow: hidden;
}
@@ -562,12 +863,12 @@ export default {
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 16px;
margin: 12px 14px 0;
}
@media (max-width: 1200px) {
.model-card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@@ -575,5 +876,17 @@ export default {
.model-card-grid {
grid-template-columns: 1fr;
}
.filter-item--keyword,
.filter-item--dept,
.filter-item--keyword :deep(.el-input),
.filter-item--dept :deep(.el-select) {
width: 100%;
}
.filter-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>

View File

@@ -1,10 +1,18 @@
<template>
<section class="risk-people-section">
<div class="section-toolbar">
<el-button size="mini" type="text" @click="handleRiskPeopleExport">导出</el-button>
<el-tabs v-model="activeTab" class="people-tabs" @tab-click="handleTabChange">
<el-tab-pane label="员工风险人员" name="employee" />
<el-tab-pane v-if="hasExternalWarnings" :label="externalTabLabel" name="external" />
</el-tabs>
</div>
<el-table v-loading="tableLoading" :data="overviewList" class="people-table">
<el-table
v-if="activeTab === 'employee'"
v-loading="tableLoading"
:data="overviewList"
class="people-table"
>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="idNo" label="身份证号" min-width="180" />
@@ -46,11 +54,69 @@
</template>
</el-table-column>
</el-table>
<el-table
v-else
v-loading="externalLoading"
:data="externalRows"
class="people-table"
>
<template slot="empty">
<el-empty :image-size="80" description="当前项目暂无外部人员预警" />
</template>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="idNo" label="证件号" min-width="180" />
<el-table-column prop="subjectType" label="主体类型" min-width="110">
<template slot-scope="scope">
<el-tag size="mini" effect="plain">{{ scope.row.subjectType || "外部人员" }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="riskLevel" label="风险等级" min-width="110">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.riskLevelType" effect="plain">
{{ scope.row.riskLevel }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="核心异常点" min-width="260">
<template slot-scope="scope">
<div
v-if="scope.row.riskPointTagList && scope.row.riskPointTagList.length"
class="risk-point-tag-list"
>
<el-tag
v-for="(tag, index) in scope.row.riskPointTagList"
:key="`${scope.row.idNo || scope.row.name || index}-external-risk-point-${index}`"
class="core-risk-tag"
:style="resolveModelTagStyle(tag)"
size="mini"
effect="plain"
>
{{ tag.ruleName }}
</el-tag>
</div>
<span v-else class="empty-text">{{ scope.row.riskPoint || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="relatedObject" label="涉及对象" min-width="140" />
<el-table-column label="最近交易时间" min-width="170">
<template slot-scope="scope">
<span>{{ formatLatestTradeTime(scope.row) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="right">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="handleViewExternal(scope.row)">
查看详情
</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="pageNum"
:limit.sync="pageSize"
v-show="currentTotal > 0"
:total="currentTotal"
:page.sync="currentPageNum"
:limit.sync="currentPageSize"
:page-sizes="[5]"
layout="total, prev, pager, next, jumper"
@pagination="handlePageChange"
@@ -59,7 +125,10 @@
</template>
<script>
import { getOverviewRiskPeople } from "@/api/ccdi/projectOverview";
import {
getOverviewExternalPersons,
getOverviewRiskPeople,
} from "@/api/ccdi/projectOverview";
// 历史静态回归锚点scope.row.actionLabel || "查看详情"
const CORE_TAG_PALETTE = {
@@ -157,10 +226,13 @@ function normalizeOverviewRows(rows) {
return [];
}
return rows.map((item) => ({
...item,
riskPointTagList: normalizeRiskPointTags(item.riskPointTagList, item.riskPoint, item.riskLevelType),
}));
return rows.map((item) => {
const riskPointTagList = normalizeRiskPointTags(item.riskPointTagList, item.riskPoint, item.riskLevelType);
return {
...item,
riskPointTagList,
};
});
}
export default {
@@ -182,12 +254,51 @@ export default {
total: 0,
tableLoading: false,
localRows: [],
activeTab: "employee",
externalRows: [],
externalTotal: 0,
externalPageNum: 1,
externalPageSize: 5,
externalLoading: false,
};
},
computed: {
overviewList() {
return this.localRows;
},
externalTabLabel() {
return `外部人员预警(${this.externalTotal})`;
},
hasExternalWarnings() {
return this.externalTotal > 0;
},
currentTotal() {
return this.activeTab === "external" ? this.externalTotal : this.total;
},
currentPageNum: {
get() {
return this.activeTab === "external" ? this.externalPageNum : this.pageNum;
},
set(value) {
if (this.activeTab === "external") {
this.externalPageNum = value;
return;
}
this.pageNum = value;
},
},
currentPageSize: {
get() {
return this.activeTab === "external" ? this.externalPageSize : this.pageSize;
},
set(value) {
if (this.activeTab === "external") {
this.externalPageSize = value;
return;
}
this.pageSize = value;
},
},
},
watch: {
sectionData: {
@@ -198,6 +309,7 @@ export default {
this.total = (this.sectionData && this.sectionData.total) || 0;
this.pageNum = (this.sectionData && this.sectionData.pageNum) || 1;
this.pageSize = (this.sectionData && this.sectionData.pageSize) || 5;
this.loadExternalPage(1);
},
},
},
@@ -205,22 +317,33 @@ export default {
resolveModelTagStyle(tag) {
return CORE_TAG_PALETTE[tag.modelCode] || {};
},
handleRiskPeopleExport() {
if (!this.projectId) {
return;
}
this.download(
"ccdi/project/overview/risk-people/export",
{
projectId: this.projectId,
},
`风险人员总览_${this.projectId}_${new Date().getTime()}.xlsx`
);
},
handleViewProject(row) {
this.$emit("view-project-analysis", row);
},
handleViewExternal(row) {
this.$emit("view-external-detail", row);
},
formatLatestTradeTime(row) {
const value = row && row.latestTradeTime ? String(row.latestTradeTime) : "";
if (!value || value === row.actionLabel) {
return "-";
}
return value;
},
handleTabChange() {
if (this.activeTab === "external" && !this.hasExternalWarnings) {
this.activeTab = "employee";
}
this.emitScopeSummary();
if (this.activeTab === "external" && !this.externalRows.length) {
this.loadExternalPage(1);
}
},
handlePageChange({ page }) {
if (this.activeTab === "external") {
this.loadExternalPage(page);
return;
}
this.loadRiskPeoplePage(page);
},
async loadRiskPeoplePage(pageNum) {
@@ -243,6 +366,44 @@ export default {
this.tableLoading = false;
}
},
async loadExternalPage(pageNum) {
if (!this.projectId) {
this.externalRows = [];
this.externalTotal = 0;
return;
}
this.externalLoading = true;
try {
const response = await getOverviewExternalPersons({
projectId: this.projectId,
pageNum,
pageSize: 5,
});
const data = (response && response.data) || {};
this.externalRows = normalizeOverviewRows(data.rows);
this.externalTotal = data.total || 0;
this.externalPageNum = data.pageNum || pageNum;
this.externalPageSize = data.pageSize || 5;
if (!this.hasExternalWarnings && this.activeTab === "external") {
this.activeTab = "employee";
}
this.emitScopeSummary();
} finally {
this.externalLoading = false;
}
},
emitScopeSummary() {
this.$emit("scope-summary-change", {
activeTab: this.activeTab,
employee: {
total: this.total,
},
external: {
total: this.externalTotal,
rows: this.externalRows,
},
});
},
},
};
</script>
@@ -256,11 +417,19 @@ export default {
.section-toolbar {
display: flex;
justify-content: flex-end;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.people-tabs {
flex: 1;
:deep(.el-tabs__header) {
margin: 0;
}
}
.people-table {
border-radius: 12px;
overflow: hidden;

View File

@@ -8,7 +8,7 @@ export const mockOverviewData = {
{ key: "riskPeople", label: "高风险", value: 10, icon: "el-icon-warning-outline", tone: "red" },
{ key: "medium", label: "中风险", value: 20, icon: "el-icon-s-opportunity", tone: "amber" },
{ key: "low", label: "低风险", value: 38, icon: "el-icon-data-line", tone: "green" },
{ key: "count", label: "无预警人数", value: 432, icon: "el-icon-document", tone: "blue" },
{ key: "count", label: "无风险", value: 432, icon: "el-icon-s-data", tone: "blue" },
],
},
riskPeople: {
@@ -433,6 +433,7 @@ export function createOverviewLoadedData({
riskModelCardsData,
suspiciousData,
creditNegativeData,
externalRiskSummary,
} = {}) {
return {
...mockOverviewData,
@@ -441,6 +442,7 @@ export function createOverviewLoadedData({
...(dashboardData || {}),
actions: mockOverviewData.summary.actions,
stats: normalizeSummaryStats(dashboardData && dashboardData.stats),
externalRiskSummary: externalRiskSummary || {},
},
riskPeople: {
...mockOverviewData.riskPeople,

View File

@@ -69,6 +69,8 @@
@generate-report="handleGenerateReport"
@fetch-bank-info="handleFetchBankInfo"
@evidence-confirm="handleEvidenceConfirm"
@open-detail-query="handleOpenDetailQuery"
:detail-query-prefill="detailQueryPrefill"
/>
<evidence-confirm-dialog
@@ -130,6 +132,7 @@ export default {
evidenceConfirmVisible: false,
evidenceDrawerVisible: false,
evidencePayload: {},
detailQueryPrefill: null,
projectStatusPollingTimer: null,
projectStatusPollingInterval: 1000,
projectStatusPollingLoading: false,
@@ -397,6 +400,14 @@ export default {
// 直接触发菜单选择
this.handleMenuSelect(route);
},
handleOpenDetailQuery(payload = {}) {
this.detailQueryPrefill = {
...payload,
nonce: Date.now(),
};
this.setActiveTab("detail");
this.syncRouteTab("detail");
},
/** UploadData 组件:数据上传完成 */
handleDataUploaded({ type }) {
console.log("数据上传完成:", type);

View File

@@ -33,6 +33,7 @@ module.exports = {
host: '0.0.0.0',
port: port,
open: true,
historyApiFallback: true,
proxy: {
// detail: https://cli.vuejs.org/config/#devserver-proxy
[process.env.VUE_APP_BASE_API]: {