feat: 新增风险图谱算法与系统仪表盘及操作反馈体系
后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL 校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计, 优化 agent 运行和编排执行链路,清理旧开发文档,前端新增 系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈 对话框和工作台日期选择器,优化报销创建和审批详情交互, 补充单元测试覆盖。
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user