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

@@ -61,28 +61,41 @@
</section>
<section class="profile-panel profile-radar-panel" aria-label="行为雷达图">
<div class="profile-section-title">
<div class="profile-section-title profile-radar-title">
<div>
<span>行为雷达</span>
<small>分数越高行为特征越明显</small>
<small>{{ currentRadarView.description }}</small>
</div>
<ElSelect
v-model="selectedRadarView"
class="profile-radar-view-select"
size="small"
aria-label="切换行为雷达视角"
>
<ElOption
v-for="option in radarViewOptions"
:key="option.value"
:label="option.shortLabel"
:value="option.value"
/>
</ElSelect>
</div>
<div v-if="radarDimensions.length" class="profile-radar-layout">
<div v-if="filteredRadarDimensions.length" class="profile-radar-layout">
<RadarChart
:key="radarRenderKey"
class="profile-radar-chart"
:items="radarDimensions"
label="用户画像评分"
:items="filteredRadarDimensions"
:label="`${currentRadarView.shortLabel}评分`"
/>
</div>
<p v-else class="profile-panel-empty profile-radar-empty">暂无可展示的雷达维度</p>
<div v-if="tags.length" class="profile-behavior-tags" aria-label="行为标签">
<div :class="['profile-behavior-tags', { 'is-empty': !filteredBehaviorTags.length }]" :aria-hidden="!filteredBehaviorTags.length" aria-label="行为标签">
<span class="profile-behavior-tags-title">行为标签</span>
<div class="profile-behavior-tag-list">
<div v-if="filteredBehaviorTags.length" class="profile-behavior-tag-list">
<span
v-for="tag in tags"
v-for="tag in filteredBehaviorTags"
:key="`behavior-${tag.code}`"
:class="[
'profile-behavior-tag',
@@ -137,10 +150,12 @@
import { computed, nextTick, ref, watch } from 'vue'
import { ElButton } from 'element-plus/es/components/button/index.mjs'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
import { ElOption, ElSelect } from 'element-plus/es/components/select/index.mjs'
import { ElTag } from 'element-plus/es/components/tag/index.mjs'
import ExpenseProfileTagPager from './ExpenseProfileTagPager.vue'
import RadarChart from '../charts/RadarChart.vue'
import { USER_PROFILE_RADAR_VIEW_OPTIONS, filterUserProfileRadarDimensions, filterUserProfileTagsByRadarView } from '../../utils/employeeProfileViewModel.js'
const props = defineProps({
visible: { type: Boolean, default: false },
@@ -148,6 +163,7 @@ const props = defineProps({
metrics: { type: Array, default: () => [] },
tags: { type: Array, default: () => [] },
radarDimensions: { type: Array, default: () => [] },
radarDefaultView: { type: String, default: 'financial_risk' },
operations: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
errorMessage: { type: String, default: '' },
@@ -156,6 +172,11 @@ const props = defineProps({
const emit = defineEmits(['close'])
const radarRenderKey = ref(0)
const selectedRadarView = ref(props.radarDefaultView)
const radarViewOptions = USER_PROFILE_RADAR_VIEW_OPTIONS
const currentRadarView = computed(() => radarViewOptions.find((option) => option.value === selectedRadarView.value) || radarViewOptions[0])
const filteredRadarDimensions = computed(() => filterUserProfileRadarDimensions(props.radarDimensions, selectedRadarView.value))
const filteredBehaviorTags = computed(() => filterUserProfileTagsByRadarView(props.tags, selectedRadarView.value))
function emitClose() {
emit('close')
@@ -225,6 +246,27 @@ watch(
})
}
)
watch(
() => props.radarDefaultView,
(value) => {
selectedRadarView.value = value || 'financial_risk'
},
{ immediate: true }
)
watch(
[filteredRadarDimensions, selectedRadarView],
async () => {
if (!props.visible) {
return
}
await nextTick()
scheduleRadarFrame(() => {
radarRenderKey.value += 1
})
}
)
</script>
<style scoped>
@@ -469,6 +511,21 @@ watch(
font-weight: 850;
}
.profile-radar-title { align-items: flex-start; }
.profile-radar-view-select {
width: 118px;
flex: 0 0 118px;
}
.profile-radar-view-select :deep(.el-select__wrapper) {
min-height: 28px;
border-radius: 4px;
box-shadow: 0 0 0 1px #cbd5e1 inset;
color: #334155;
font-size: 12px;
font-weight: 750;
}
.profile-operation-list {
display: grid;
gap: 8px;
@@ -536,9 +593,12 @@ watch(
display: grid;
gap: 8px;
padding-top: 10px;
min-height: 59px;
border-top: 1px solid #e8eef5;
}
.profile-behavior-tags.is-empty { visibility: hidden; }
.profile-behavior-tags-title {
color: #0f172a;
font-size: 12px;

View File

@@ -27,9 +27,35 @@
maxlength="1000"
rows="2"
placeholder="请输入费用申请、报销问题、预算查询或制度问答..."
:readonly="isComposerPending"
@keydown.enter.prevent="handleWorkbenchEnter"
/>
<div
v-if="composerPendingLabel"
class="assistant-intent-status"
role="status"
aria-live="polite"
>
<i class="mdi mdi-loading mdi-spin"></i>
<span>{{ composerPendingLabel }}</span>
</div>
<div v-if="workbenchDateTagLabel" class="workbench-date-chip-row">
<span class="workbench-date-chip">
<i class="mdi mdi-calendar-check"></i>
<span>{{ workbenchDateTagLabel }}</span>
<button
type="button"
aria-label="移除日期"
:disabled="Boolean(pendingAction)"
@click="removeWorkbenchDateTag"
>
<i class="mdi mdi-close"></i>
</button>
</span>
</div>
<div class="composer-toolbar">
<button
type="button"
@@ -42,16 +68,70 @@
<i class="mdi mdi-paperclip"></i>
</button>
<button
type="button"
class="composer-related-button"
:disabled="Boolean(pendingAction)"
@click="triggerFileUpload"
>
<i class="mdi mdi-source-branch"></i>
<span>关联单据</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div class="workbench-date-anchor">
<button
type="button"
class="composer-icon-button"
:class="{ active: workbenchDatePickerOpen }"
title="选择日期"
aria-label="选择日期"
:aria-expanded="workbenchDatePickerOpen"
:disabled="Boolean(pendingAction)"
@click.stop="toggleWorkbenchDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="workbenchDatePickerOpen"
class="composer-date-popover"
role="dialog"
aria-label="日期选择"
@click.stop
>
<div class="composer-date-mode-tabs">
<button
type="button"
class="composer-date-mode-btn"
:class="{ active: workbenchDateMode === 'single' }"
@click="setWorkbenchDateMode('single')"
>
当天
</button>
<button
type="button"
class="composer-date-mode-btn"
:class="{ active: workbenchDateMode === 'range' }"
@click="setWorkbenchDateMode('range')"
>
时间段
</button>
</div>
<div v-if="workbenchDateMode === 'single'" class="composer-date-fields">
<label class="composer-date-field">
<span>日期</span>
<input v-model="workbenchSingleDate" type="date" @change="handleWorkbenchDateInputChange('single')" />
</label>
</div>
<div v-else class="composer-date-fields composer-date-fields-range">
<label class="composer-date-field">
<span>开始</span>
<input v-model="workbenchRangeStartDate" type="date" @change="handleWorkbenchDateInputChange('range-start')" />
</label>
<span class="composer-date-range-sep"></span>
<label class="composer-date-field">
<span>结束</span>
<input v-model="workbenchRangeEndDate" type="date" :min="workbenchRangeStartDate" @change="handleWorkbenchDateInputChange('range-end')" />
</label>
</div>
<p v-if="workbenchDateMode === 'range' && !workbenchCanApplyDateSelection" class="composer-date-hint">
请确认结束日期不早于开始日期
</p>
</div>
</div>
<span class="composer-count">{{ assistantDraft.length }}/1000</span>
@@ -59,10 +139,10 @@
type="button"
class="composer-send-button"
:disabled="Boolean(pendingAction)"
:aria-label="pendingAction === 'expense' ? '处理中' : expenseActionLabel"
:aria-label="composerPendingLabel || expenseActionLabel"
@click="handleExpenseConversationAction"
>
<i :class="pendingAction === 'expense' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
<i :class="pendingAction ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
</button>
</div>
</div>
@@ -98,7 +178,7 @@
type="button"
class="capability-card panel"
:class="`capability-card--${item.tone}`"
@click="openPromptAssistant(item.prompt)"
@click="openCapabilityAssistant(item)"
>
<span class="capability-icon"><i :class="item.icon"></i></span>
<span class="capability-copy">
@@ -263,6 +343,7 @@
:metrics="expenseProfileModalMetrics"
:tags="expenseProfileTags"
:radar-dimensions="expenseProfileRadarDimensions"
:radar-default-view="expenseProfileRadarDefaultView"
:operations="expenseProfileOperations"
:loading="employeeProfileLoading"
:error-message="employeeProfileError"
@@ -280,6 +361,7 @@ import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import homepageBackground from '../../assets/homepage_backgraound.png'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposerDate.js'
import {
assistantCapabilities,
buildExpenseStatItems,
@@ -295,15 +377,16 @@ import {
ASSISTANT_SESSION_SNAPSHOT_EVENT,
hasAssistantSessionSnapshot
} from '../../utils/assistantSessionSnapshot.js'
import { buildWorkbenchCapabilityAssistantPayload } from '../../utils/personalWorkbenchAssistantEntry.js'
import {
buildProfileOperationsFromAgentRuns,
buildUserProfileMetricCards,
buildUserProfileSummaryMetrics,
normalizeUserProfileRadarDimensions,
normalizeUserProfileTags,
resolveUserProfileDefaultRadarView,
resolveCurrentUserProfileError
} from '../../utils/employeeProfileViewModel.js'
const props = defineProps({
showHeader: { type: Boolean, default: true },
assistantModalOpen: { type: Boolean, default: false },
@@ -318,6 +401,27 @@ const assistantInputRef = ref(null)
const fileInputRef = ref(null)
const selectedFiles = ref([])
const pendingAction = ref('')
let pendingActionTimer = 0
const {
workbenchDatePickerOpen,
workbenchDateMode,
workbenchSingleDate,
workbenchRangeStartDate,
workbenchRangeEndDate,
workbenchDateTagLabel,
workbenchCanApplyDateSelection,
clearWorkbenchDateSelection,
toggleWorkbenchDatePicker,
closeWorkbenchDatePicker,
setWorkbenchDateMode,
handleWorkbenchDatePickerOutside,
handleWorkbenchDateInputChange,
removeWorkbenchDateTag,
buildWorkbenchPromptText
} = useWorkbenchComposerDate({
draft: assistantDraft,
focusInput: focusAssistantInput
})
const latestExpenseConversation = ref(null)
const hasLocalExpenseSnapshot = ref(false)
const expenseProfileModalOpen = ref(false)
@@ -342,6 +446,16 @@ const displayUserName = computed(() => {
return String(user.name || user.username || '同事').trim() || '同事'
})
const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销'))
const isComposerPending = computed(() => Boolean(pendingAction.value))
const composerPendingLabel = computed(() => {
if (pendingAction.value === 'intent') {
return '正在识别意图,准备进入对应助手...'
}
if (pendingAction.value === 'expense') {
return '正在恢复最近报销会话...'
}
return ''
})
const currentRoleCodes = computed(() => {
const user = currentUser.value || {}
const rawCodes = Array.isArray(user.roleCodes)
@@ -387,6 +501,7 @@ const expenseProfileModalMetrics = computed(() => {
})
const expenseProfileTags = computed(() => normalizeUserProfileTags(employeeProfile.value))
const expenseProfileRadarDimensions = computed(() => normalizeUserProfileRadarDimensions(employeeProfile.value))
const expenseProfileRadarDefaultView = computed(() => resolveUserProfileDefaultRadarView(employeeProfile.value))
const expenseProfileOperations = computed(() =>
buildProfileOperationsFromAgentRuns(employeeProfileRuns.value, currentUser.value)
)
@@ -446,7 +561,7 @@ function resolveCurrentUserId() {
function buildAssistantPayload() {
return {
prompt: assistantDraft.value.trim(),
prompt: buildWorkbenchPromptText(),
source: 'workbench',
files: Array.from(selectedFiles.value)
}
@@ -462,6 +577,34 @@ function clearSelectedFiles() {
function resetWorkbenchDraft() {
assistantDraft.value = ''
clearSelectedFiles()
clearWorkbenchDateSelection()
}
function clearPendingAction() {
pendingAction.value = ''
if (pendingActionTimer) {
window.clearTimeout(pendingActionTimer)
pendingActionTimer = 0
}
}
function startPendingAction(action) {
clearPendingAction()
pendingAction.value = action
pendingActionTimer = window.setTimeout(() => {
if (pendingAction.value !== action) {
return
}
clearPendingAction()
toast('进入助手耗时较长,请稍后重试。')
}, 16000)
}
function shouldShowIntentPending(payload = {}) {
return !props.assistantModalOpen
&& String(payload.prompt || '').trim()
&& String(payload.source || 'workbench').trim() === 'workbench'
&& !String(payload.sessionType || '').trim()
}
function emitAssistant(payload) {
@@ -492,12 +635,24 @@ function openPromptAssistant(prompt) {
return
}
emitAssistant({
prompt: String(prompt || '').trim(),
const payload = {
prompt: buildWorkbenchPromptText(prompt),
source: 'workbench',
files: Array.from(selectedFiles.value),
conversation: null
})
}
if (shouldShowIntentPending(payload)) {
startPendingAction('intent')
}
emitAssistant(payload)
}
function openCapabilityAssistant(item) {
if (pendingAction.value) {
return
}
emitAssistant(buildWorkbenchCapabilityAssistantPayload(item, buildAssistantPayload()))
}
async function loadCurrentEmployeeProfile() {
@@ -597,6 +752,9 @@ async function handleExpenseConversationAction() {
const shouldOpenImmediately = Boolean(nextPayload.prompt || nextPayload.files.length)
if (shouldOpenImmediately) {
if (shouldShowIntentPending(nextPayload)) {
startPendingAction('intent')
}
emitAssistant({
...nextPayload,
conversation: null
@@ -607,7 +765,7 @@ async function handleExpenseConversationAction() {
return
}
pendingAction.value = 'expense'
startPendingAction('expense')
try {
await clearKnowledgeHistoryBeforeExpense()
@@ -621,7 +779,7 @@ async function handleExpenseConversationAction() {
console.warn('Failed to open expense conversation:', error)
toast(error?.message || '打开报销会话失败,请稍后重试。')
} finally {
pendingAction.value = ''
clearPendingAction()
}
}
@@ -629,16 +787,22 @@ onMounted(() => {
refreshLocalExpenseSnapshot()
refreshLatestExpenseConversation()
loadCurrentEmployeeProfile()
document.addEventListener('click', handleWorkbenchDatePickerOutside)
window.addEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
onBeforeUnmount(() => {
clearPendingAction()
document.removeEventListener('click', handleWorkbenchDatePickerOutside)
window.removeEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
watch(
() => props.assistantModalOpen,
(open, previous) => {
if (open) {
clearPendingAction()
}
if (previous && !open) {
refreshLatestExpenseConversation()
}
@@ -653,5 +817,6 @@ watch(currentUserProfileKey, (nextKey, previousKey) => {
</script>
<style scoped src="../../assets/styles/components/personal-workbench.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-composer-date.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-insights.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-responsive.css"></style>