feat: 新增风险图谱算法与系统仪表盘及操作反馈体系

后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL
校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计,
优化 agent 运行和编排执行链路,清理旧开发文档,前端新增
系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈
对话框和工作台日期选择器,优化报销创建和审批详情交互,
补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-30 15:46:51 +08:00
parent 4c59941ec6
commit 7989f3a159
314 changed files with 30073 additions and 20626 deletions

View File

@@ -160,7 +160,7 @@
</template>
<template #table>
<table>
<table class="audit-asset-table">
<thead>
<tr>
<th>{{ tableColumns.name }}</th>

View File

@@ -67,6 +67,67 @@
@report-saved="emit('report-saved', $event)"
/>
<ConfirmDialog
:open="riskRuleEditOpen"
badge="规则维护"
badge-tone="info"
:title="riskRuleEditMode === 'revision' ? '创建修订版本' : '编辑风险规则'"
:description="riskRuleEditMode === 'revision' ? '已上线规则不会被直接覆盖,系统会先创建一个新的修订草稿。' : '未上线规则可以直接调整标题、费用领域、附件要求和自然语言描述。'"
cancel-text="取消"
:confirm-text="riskRuleEditMode === 'revision' ? '创建修订' : '保存草稿'"
busy-text="保存中..."
confirm-tone="primary"
confirm-icon="mdi mdi-content-save-outline"
:busy="riskRuleEditBusy"
:close-on-mask="!riskRuleEditBusy"
@close="emit('close-risk-rule-edit')"
@confirm="emit('submit-risk-rule-edit')"
>
<div class="risk-rule-create-form">
<label>
<span>费用领域</span>
<EnterpriseSelect
v-model="riskRuleEditForm.expense_category"
:options="riskRuleExpenseCategoryOptions"
:disabled="riskRuleEditBusy"
/>
</label>
<label>
<span>是否上传附件</span>
<EnterpriseSelect
v-model="riskRuleEditForm.requires_attachment"
:options="riskRuleAttachmentOptions"
:disabled="riskRuleEditBusy"
/>
</label>
<label class="span-2">
<span>规则标题</span>
<input
v-model="riskRuleEditForm.rule_title"
:disabled="riskRuleEditBusy"
maxlength="80"
placeholder="例如:差旅目的地与票据城市一致性校验"
/>
</label>
<label class="span-2">
<span>自然语言规则</span>
<textarea
v-model="riskRuleEditForm.natural_language"
:disabled="riskRuleEditBusy"
placeholder="请用自然语言描述风险判断流程、字段、例外条件和处理动作。"
></textarea>
</label>
<label v-if="riskRuleEditMode === 'revision'" class="span-2">
<span>修订原因</span>
<textarea
v-model="riskRuleEditForm.change_reason"
:disabled="riskRuleEditBusy"
placeholder="请说明本次修订要解决的规则问题或业务变化。"
></textarea>
</label>
</div>
</ConfirmDialog>
<ConfirmDialog
:open="riskRuleDeleteOpen"
badge="删除规则"
@@ -239,6 +300,10 @@ const props = defineProps({
riskRuleExpenseCategoryOptions: { type: Array, default: () => [] },
riskRuleAttachmentOptions: { type: Array, default: () => [] },
riskRuleTestOpen: { type: Boolean, default: false },
riskRuleEditOpen: { type: Boolean, default: false },
riskRuleEditMode: { type: String, default: 'draft' },
riskRuleEditForm: { type: Object, default: () => ({}) },
riskRuleEditBusy: { type: Boolean, default: false },
riskRuleDeleteOpen: { type: Boolean, default: false },
riskRuleReturnOpen: { type: Boolean, default: false },
riskRulePublishOpen: { type: Boolean, default: false },
@@ -261,6 +326,8 @@ const emit = defineEmits([
'submit-risk-rule-create',
'close-risk-rule-test',
'report-saved',
'close-risk-rule-edit',
'submit-risk-rule-edit',
'close-delete-risk-rule',
'delete-selected-risk-rule',
'close-return-risk-rule',

View File

@@ -135,7 +135,10 @@
@click="emit('open-employee-detail', employee)"
>
<td>
<strong class="doc-id">{{ employee.name }}</strong>
<div class="digital-skill-cell">
<span class="digital-skill-avatar" :class="employee.badgeTone">{{ employee.short }}</span>
<strong class="doc-id">{{ employee.name }}</strong>
</div>
</td>
<td><span class="doc-kind-tag application">{{ employee.skillCategory }}</span></td>
<td>{{ employee.owner }}</td>

View File

@@ -0,0 +1,404 @@
<template>
<article v-if="visible" class="detail-card panel run-products-card">
<div class="card-head">
<div>
<h3>本次任务产物</h3>
<p>{{ productSubtitle }}</p>
</div>
<span class="edit-badge">{{ productBadge }}</span>
</div>
<div v-if="loading" class="run-product-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在读取本次运行产物</span>
</div>
<div v-else-if="errorMessage" class="run-product-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ errorMessage }}</span>
</div>
<template v-else>
<div v-if="metrics.length" class="json-risk-meta-grid run-product-meta-grid">
<div
v-for="item in metrics"
:key="item.label"
class="json-risk-meta-item"
>
<span class="json-risk-meta-label">{{ item.label }}</span>
<span class="json-risk-meta-value">{{ item.value }}</span>
</div>
</div>
<section v-if="productKind === 'risk_graph'" class="run-product-section">
<div class="run-product-section-head">
<h4>风险观察</h4>
<span>{{ observations.length }} </span>
</div>
<div v-if="observations.length" class="run-product-observation-list">
<article
v-for="item in observations"
:key="item.observationKey || item.id"
class="run-product-observation"
:class="{ 'is-expanded': isActiveObservation(item) }"
role="button"
tabindex="0"
@click="toggleObservation(item)"
@keydown.enter.prevent="toggleObservation(item)"
@keydown.space.prevent="toggleObservation(item)"
>
<div class="run-product-observation-head">
<span class="risk-level-pill" :class="item.riskLevel">
{{ formatRiskLevel(item.riskLevel) }}
</span>
<strong>{{ item.title || formatSignal(item.riskSignal) }}</strong>
<b>{{ item.riskScore }}</b>
</div>
<p>{{ item.description || '暂无风险描述。' }}</p>
<div class="run-product-tags">
<span>单据{{ item.claimNo || item.claimId || '-' }}</span>
<span>证据{{ (item.evidence || []).length }}</span>
<span>图谱关系{{ observationGraphCount(item) }}</span>
<span>{{ item.algorithmVersion || '未记录算法版本' }}</span>
</div>
<div
v-if="isActiveObservation(item)"
class="run-product-observation-detail"
>
<section v-if="scoreRows(item).length">
<span>贡献分</span>
<div class="run-product-score-list">
<div v-for="score in scoreRows(item)" :key="score.key">
<em>{{ score.label }}</em>
<i><b :style="{ width: score.width }"></b></i>
<strong>{{ score.value }}</strong>
</div>
</div>
</section>
<section v-if="evidenceRows(item).length">
<span>关键证据</span>
<ul class="run-product-evidence-list">
<li v-for="evidence in evidenceRows(item)" :key="evidence.key">
<strong>{{ evidence.title }}</strong>
<p>{{ evidence.detail }}</p>
</li>
</ul>
</section>
<section v-if="observationGraphItems(item).length">
<span>异常关系</span>
<div class="run-product-tags">
<span
v-for="graphItem in observationGraphItems(item)"
:key="graphItem"
>
{{ graphItem }}
</span>
</div>
</section>
<section v-if="observationDecisionItems(item).length">
<span>制度与建议</span>
<div class="run-product-tags">
<span
v-for="decisionItem in observationDecisionItems(item)"
:key="decisionItem"
>
{{ decisionItem }}
</span>
</div>
</section>
</div>
</article>
</div>
<p v-else class="run-product-inline-empty">本次运行没有生成新的风险观察</p>
</section>
<section v-else-if="productKind === 'employee_profile'" class="run-product-section">
<div class="run-product-section-head">
<h4>画像快照</h4>
<span>{{ summary.algorithm_version || '算法版本未记录' }}</span>
</div>
<p class="run-product-copy">
本次产物已写入员工行为画像快照用于后续风险图谱审批复核和员工行为基线分析
</p>
</section>
<section v-else-if="productKind === 'knowledge'" class="run-product-section">
<div class="run-product-section-head">
<h4>知识制度整理</h4>
<span>{{ summary.status || '已提交' }}</span>
</div>
<p class="run-product-copy">
{{ summary.summary || '知识制度整理任务已提交,后台会继续归纳文档并刷新知识索引。' }}
</p>
<div class="run-product-tags">
<span>目录{{ summary.folder || routeJson.folder || '全部知识库' }}</span>
<span>文档{{ documentCount }} </span>
<span v-if="summary.agent_run_id">子任务{{ summary.agent_run_id }}</span>
</div>
</section>
</template>
</article>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { fetchRunRiskObservations } from '../../services/riskObservations.js'
import {
extractWorkRecordToolSummary,
resolveWorkRecordProductKind,
resolveWorkRecordTaskLabel
} from '../../views/scripts/digitalEmployeeWorkRecordsModel.js'
const props = defineProps({
run: { type: Object, default: null }
})
const observations = ref([])
const activeObservationKey = ref('')
const loading = ref(false)
const errorMessage = ref('')
let loadSequence = 0
const routeJson = computed(() =>
props.run?.route_json && typeof props.run.route_json === 'object'
? props.run.route_json
: {}
)
const runId = computed(() => String(props.run?.run_id || '').trim())
const productKind = computed(() => resolveWorkRecordProductKind(props.run))
const taskLabel = computed(() => resolveWorkRecordTaskLabel(props.run) || '数字员工任务')
const summary = computed(() => extractWorkRecordToolSummary(props.run))
const visible = computed(() =>
Boolean(productKind.value || loading.value || errorMessage.value)
)
const productSubtitle = computed(() => {
if (productKind.value === 'risk_graph') {
return '展示本次巡检生成的风险观察、证据数量和图谱关系计数。'
}
if (productKind.value === 'employee_profile') {
return '展示本次画像巡检写入的员工画像快照摘要。'
}
if (productKind.value === 'knowledge') {
return '展示本次知识制度整理任务的入队结果与处理范围。'
}
return '展示本次数字员工任务产生的结构化结果。'
})
const productBadge = computed(() => {
if (productKind.value === 'risk_graph') {
return '风险观察'
}
if (productKind.value === 'employee_profile') {
return '画像快照'
}
if (productKind.value === 'knowledge') {
return '知识整理'
}
return taskLabel.value
})
const documentCount = computed(() =>
Array.isArray(summary.value.document_ids) ? summary.value.document_ids.length : 0
)
const metrics = computed(() => {
const payload = summary.value || {}
if (productKind.value === 'risk_graph') {
return [
buildMetric('扫描单据', payload.scanned_claim_count),
buildMetric('风险观察', payload.risk_observation_count ?? observations.value.length),
buildMetric('图谱节点', payload.graph_node_count),
buildMetric('图谱关系', payload.graph_edge_count)
]
}
if (productKind.value === 'employee_profile') {
return [
buildMetric('目标员工', payload.target_employee_count),
buildMetric('画像快照', payload.snapshot_count),
buildMetric('重点关注', payload.high_attention_employee_count),
buildMetric('窗口期', formatWindowDays(payload.window_days))
]
}
if (productKind.value === 'knowledge') {
return [
buildMetric('处理目录', payload.folder || routeJson.value.folder || '全部知识库'),
buildMetric('目标文档', documentCount.value),
buildMetric('复用任务', payload.reused ? '是' : '否'),
buildMetric('后台 Run ID', payload.agent_run_id || '-')
]
}
return []
})
watch(
() => [runId.value, productKind.value],
() => {
void loadProducts()
},
{ immediate: true }
)
async function loadProducts() {
const currentRunId = runId.value
const sequence = ++loadSequence
errorMessage.value = ''
if (productKind.value !== 'risk_graph') {
observations.value = []
loading.value = false
return
}
if (!currentRunId) {
observations.value = []
loading.value = false
return
}
loading.value = true
try {
const payload = await fetchRunRiskObservations(currentRunId)
if (sequence !== loadSequence) {
return
}
observations.value = payload
activeObservationKey.value = payload[0]?.observationKey || payload[0]?.id || ''
} catch (error) {
if (sequence === loadSequence) {
observations.value = []
activeObservationKey.value = ''
errorMessage.value = error?.message || '本次运行产物加载失败。'
}
} finally {
if (sequence === loadSequence) {
loading.value = false
}
}
}
function buildMetric(label, value) {
return {
label,
value: formatMetricValue(value)
}
}
function formatMetricValue(value) {
if (Array.isArray(value)) {
return value.length ? value.join(' / ') : '-'
}
if (value === null || value === undefined || value === '') {
return '-'
}
return String(value)
}
function formatWindowDays(value) {
const days = Array.isArray(value) ? value : []
return days.length ? days.map((item) => `${item}`).join(' / ') : '-'
}
function observationGraphCount(item) {
return (item.graphNodeKeys || []).length + (item.graphEdgeKeys || []).length
}
function observationKey(item) {
return String(item?.observationKey || item?.id || '').trim()
}
function isActiveObservation(item) {
return observationKey(item) === activeObservationKey.value
}
function toggleObservation(item) {
const key = observationKey(item)
activeObservationKey.value = activeObservationKey.value === key ? '' : key
}
function scoreRows(item) {
const scores = item?.contributionScores || {}
return Object.entries(scores).map(([key, value]) => {
const numericValue = Math.max(0, Math.min(Number(value || 0), 100))
return {
key,
label: formatScoreLabel(key),
value: Math.round(numericValue),
width: `${numericValue}%`
}
})
}
function evidenceRows(item) {
return (item?.evidence || []).slice(0, 4).map((evidence, index) => ({
key: `${evidence.code || evidence.title || index}`,
title: String(evidence.title || evidence.code || evidence.source || `证据 ${index + 1}`).trim(),
detail: String(evidence.detail || evidence.message || evidence.summary || '').trim() || '已记录证据。'
}))
}
function observationGraphItems(item) {
return [
...(item?.graphNodeKeys || []).slice(0, 5).map((value) => `节点:${formatChipValue(value)}`),
...(item?.graphEdgeKeys || []).slice(0, 5).map((value) => `关系:${formatChipValue(value)}`)
].filter(Boolean)
}
function observationDecisionItems(item) {
return [
...(item?.policyRefs || []).slice(0, 4).map((value) => `制度:${formatChipValue(value)}`),
...(item?.similarCaseClaimIds || []).slice(0, 4).map((value) => `相似案例:${formatChipValue(value)}`),
...formatRecordItems(item?.decisionTrace, '建议').slice(0, 4)
].filter(Boolean)
}
function formatScoreLabel(value) {
const labels = {
S_rule: '规则命中',
S_anomaly: '画像偏离',
S_graph: '图谱异常',
S_policy: '制度约束',
S_history: '历史反馈'
}
return labels[value] || value
}
function formatChipValue(value) {
if (typeof value === 'string') {
return value.trim()
}
if (value && typeof value === 'object') {
return String(value.key || value.edge_key || value.node_key || JSON.stringify(value)).trim()
}
return String(value || '').trim()
}
function formatRecordItems(value, prefix) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return []
}
return Object.entries(value)
.map(([key, item]) => `${prefix}${key}=${formatChipValue(item)}`)
.filter(Boolean)
}
function formatRiskLevel(value) {
const labels = {
critical: '重大风险',
high: '高风险',
medium: '中风险',
low: '低风险'
}
return labels[String(value || '').trim()] || '未知风险'
}
function formatSignal(value) {
const labels = {
duplicate_invoice: '重复发票',
split_billing: '拆分报销',
frequent_small_claims: '高频小额',
location_mismatch: '地点不一致',
amount_outlier: '金额异常',
preapproval_absent: '缺少事前申请'
}
const normalized = String(value || '').trim()
return labels[normalized] || normalized.replace(/_/g, ' ') || '未知风险'
}
</script>
<style scoped src="../../assets/styles/components/digital-employee-run-products.css"></style>

View File

@@ -229,6 +229,8 @@
</p>
</article>
<DigitalEmployeeRunProducts :run="selectedRunDetail" />
<!-- 卡片3工具调用 -->
<article class="detail-card panel">
<div class="card-head">
@@ -283,6 +285,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AuditPickerFilter from './AuditPickerFilter.vue'
import DigitalEmployeeRunProducts from './DigitalEmployeeRunProducts.vue'
import EnterpriseListPage from '../shared/EnterpriseListPage.vue'
import TableLoadingState from '../shared/TableLoadingState.vue'
import { fetchAgentRunDetail, fetchAgentRuns } from '../../services/agentAssets.js'
@@ -308,6 +311,9 @@ defineOptions({
name: 'DigitalEmployeeWorkRecords'
})
const props = defineProps({
focusRunId: { type: String, default: '' }
})
const emit = defineEmits(['summary-change', 'detail-open-change', 'detail-topbar-change'])
const { toast } = useToast()
@@ -559,6 +565,35 @@ function openWorkRecordDetail(run) {
void loadWorkRecordDetail(runId)
}
async function openWorkRecordById(runId) {
const normalizedRunId = String(runId || '').trim()
if (!normalizedRunId) {
return
}
selectedRunId.value = normalizedRunId
selectedRunDetail.value = runs.value.find((run) => run.run_id === normalizedRunId) || {
run_id: normalizedRunId,
source: 'schedule',
status: 'running',
route_json: {},
result_summary: '正在读取本次运行详情。'
}
detailOpen.value = true
if (!runs.value.some((run) => run.run_id === normalizedRunId)) {
await loadWorkRecords(false)
const refreshedRun = runs.value.find((run) => run.run_id === normalizedRunId)
if (refreshedRun && selectedRunId.value === normalizedRunId) {
selectedRunDetail.value = refreshedRun
}
}
if (selectedRunId.value === normalizedRunId) {
await loadWorkRecordDetail(normalizedRunId)
}
}
function reloadSelectedDetail() {
if (!selectedRunId.value) {
return
@@ -572,6 +607,16 @@ function closeWorkRecordDetail() {
detailError.value = ''
}
watch(
() => props.focusRunId,
(runId) => {
if (String(runId || '').trim()) {
void openWorkRecordById(runId)
}
},
{ immediate: true }
)
function startPolling() {
stopPolling()
pollTimer = window.setInterval(() => {