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(() => {

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>

View File

@@ -29,7 +29,10 @@ import { resolveCssColor, useThemeColors } from '../../composables/useThemeColor
use([GridComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
const props = defineProps({
items: { type: Array, required: true }
items: { type: Array, required: true },
valuePrefix: { type: String, default: '¥' },
valueSuffix: { type: String, default: '' },
compact: { type: Boolean, default: true }
})
const chartElement = shallowRef(null)
@@ -51,7 +54,13 @@ const ariaLabel = computed(() =>
)
const chartMaxValue = computed(() => Math.max(...resolvedItems.value.map((item) => item.value), 1))
const chartAxisMax = computed(() => Math.ceil((chartMaxValue.value * 1.1) / 10000) * 10000)
const axisStep = computed(() => {
if (chartMaxValue.value <= 100) return 10
if (chartMaxValue.value <= 1000) return 100
if (chartMaxValue.value <= 10000) return 1000
return 10000
})
const chartAxisMax = computed(() => Math.ceil((chartMaxValue.value * 1.1) / axisStep.value) * axisStep.value)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
@@ -115,7 +124,7 @@ const chartOptions = computed(() => ({
value: item.value,
itemStyle: { color: item.resolvedColor }
})),
barWidth: 14,
barWidth: 18,
showBackground: true,
backgroundStyle: {
color: 'rgba(226, 232, 240, 0.42)',
@@ -155,9 +164,11 @@ const medalFill = (idx) => {
const formatValue = (value) => {
const number = Number(value || 0)
if (number >= 1_000_000) return `¥${(number / 1_000_000).toFixed(1)}M`
if (number >= 1_000) return `¥${(number / 1_000).toFixed(1)}K`
return `¥${number}`
const prefix = props.valuePrefix
const suffix = props.valueSuffix
if (props.compact && number >= 1_000_000) return `${prefix}${(number / 1_000_000).toFixed(1)}M${suffix}`
if (props.compact && number >= 1_000) return `${prefix}${(number / 1_000).toFixed(1)}K${suffix}`
return `${prefix}${number}${suffix}`
}
</script>
@@ -180,7 +191,7 @@ const formatValue = (value) => {
display: flex;
align-items: center;
gap: 6px;
height: 34px;
height: 38px;
white-space: nowrap;
}

View File

@@ -2,6 +2,10 @@
<div class="gauge-chart">
<div class="gauge-body">
<div ref="chartElement" class="gauge-canvas" role="img" :aria-label="ariaLabel"></div>
<div class="gauge-center-value" aria-hidden="true">
<strong>{{ normalizedRatio }}%</strong>
<span>已执行</span>
</div>
</div>
<div class="gauge-summary">
<div>
@@ -82,22 +86,8 @@ const chartOptions = computed(() => {
splitLine: { show: false },
axisLabel: { show: false },
anchor: { show: false },
detail: {
show: true,
valueAnimation: true,
offsetCenter: [0, '22%'],
formatter: '{value}%',
color: primary,
fontSize: 24,
fontWeight: 850
},
title: {
show: true,
offsetCenter: [0, '46%'],
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
detail: { show: false },
title: { show: false },
data: [{ value: normalizedRatio.value, name: '已执行' }]
}
]
@@ -127,6 +117,36 @@ useEcharts(chartElement, chartOptions)
height: 100%;
}
.gauge-center-value {
position: absolute;
left: 0;
right: 0;
top: 60%;
display: grid;
justify-items: center;
gap: 4px;
min-width: 0;
transform: translateY(-50%);
text-align: center;
pointer-events: none;
}
.gauge-center-value strong {
color: var(--theme-primary);
font-size: 24px;
font-weight: 850;
line-height: 1;
font-variant-numeric: tabular-nums;
letter-spacing: 0;
}
.gauge-center-value span {
color: #64748b;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.gauge-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));

View File

@@ -0,0 +1,143 @@
<template>
<div ref="chartElement" class="risk-daily-trend-chart" role="img" :aria-label="ariaLabel"></div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { BarChart as EChartsBarChart, LineChart as EChartsLineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
use([
GridComponent,
LegendComponent,
TooltipComponent,
EChartsBarChart,
EChartsLineChart,
CanvasRenderer
])
const props = defineProps({
rows: { type: Array, default: () => [] }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const labels = computed(() => props.rows.map((item) => item.date))
const totals = computed(() => props.rows.map((item) => Number(item.total || 0)))
const highValues = computed(() => props.rows.map((item) => Number(item.highOrAbove || 0)))
const maxValue = computed(() => Math.max(...totals.value, ...highValues.value, 1))
const axisMax = computed(() => Math.max(5, Math.ceil(maxValue.value * 1.2)))
const ariaLabel = computed(() =>
props.rows.map((item) => (
`${item.date}风险观察${item.total || 0}项,高风险${item.highOrAbove || 0}`
)).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 900,
animationDurationUpdate: 700,
grid: {
top: 34,
right: 16,
bottom: 24,
left: 28,
containLabel: true
},
legend: {
top: 0,
right: 4,
itemWidth: 8,
itemHeight: 8,
textStyle: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
}
},
tooltip: {
trigger: 'axis',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255,255,255,.98)',
borderColor: 'rgba(148,163,184,.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);'
},
xAxis: {
type: 'category',
data: labels.value,
boundaryGap: true,
axisTick: { show: false },
axisLine: { lineStyle: { color: 'rgba(148,163,184,.26)' } },
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
}
},
yAxis: {
type: 'value',
min: 0,
max: axisMax.value,
splitNumber: 4,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
splitLine: { lineStyle: { color: 'rgba(226,232,240,.74)' } }
},
series: [
{
name: '风险观察',
type: 'bar',
data: totals.value,
barWidth: 14,
itemStyle: {
color: themeColors.value.chartPrimary,
borderRadius: [4, 4, 0, 0]
}
},
{
name: '高风险',
type: 'line',
data: highValues.value,
smooth: true,
symbol: 'circle',
symbolSize: 7,
lineStyle: {
width: 2.5,
color: '#ef4444'
},
itemStyle: {
color: '#ffffff',
borderColor: '#ef4444',
borderWidth: 2.5
}
}
]
}))
useEcharts(chartElement, chartOptions)
</script>
<style scoped>
.risk-daily-trend-chart {
width: 100%;
height: 250px;
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<div class="system-accuracy-compare-bar">
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { BarChart as EChartsBarChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, LegendComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
const props = defineProps({
categories: { type: Array, required: true },
correct: { type: Array, required: true },
wrong: { type: Array, required: true }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const ariaLabel = computed(() =>
props.categories.map((name, index) => (
`${name}正确${props.correct[index] || 0}次,错误${props.wrong[index] || 0}`
)).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 900,
animationEasing: 'cubicOut',
legend: {
top: 0,
left: 0,
itemWidth: 9,
itemHeight: 9,
itemGap: 18,
textStyle: {
color: '#475569',
fontSize: 12,
fontWeight: 700
}
},
grid: {
top: 38,
right: 42,
bottom: 18,
left: 88,
containLabel: false
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
valueFormatter: (value) => `${value}`
},
xAxis: {
type: 'value',
min: 0,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.74)' } }
},
yAxis: {
type: 'category',
data: props.categories,
inverse: true,
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#475569',
fontSize: 12,
fontWeight: 750
}
},
series: [
{
name: '正确',
type: 'bar',
data: props.correct,
barWidth: 18,
itemStyle: {
color: themeColors.value.success,
borderRadius: [0, 4, 4, 0]
},
label: {
show: true,
position: 'right',
color: '#475569',
fontSize: 11,
fontWeight: 800
}
},
{
name: '错误',
type: 'bar',
data: props.wrong,
barWidth: 18,
itemStyle: {
color: themeColors.value.danger,
borderRadius: [0, 4, 4, 0]
},
label: {
show: true,
position: 'right',
color: '#ef4444',
fontSize: 11,
fontWeight: 800
}
}
]
}))
useEcharts(chartElement, chartOptions)
</script>
<style scoped>
.system-accuracy-compare-bar {
height: 292px;
}
.chart-body {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<div class="system-agent-ratio-bar">
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { BarChart as EChartsBarChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, LegendComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
const props = defineProps({
labels: { type: Array, required: true },
agents: { type: Array, required: true },
series: { type: Object, required: true }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const resolvedAgents = computed(() =>
props.agents.map((agent) => ({
...agent,
resolvedColor: resolveCssColor(agent.color, themeColors.value.chartPrimary)
}))
)
const ariaLabel = computed(() =>
props.labels.map((label, dayIndex) => {
const parts = resolvedAgents.value.map((agent) => (
`${agent.name}${props.series[agent.key]?.[dayIndex] || 0}%`
))
return `${label}${parts.join('')}`
}).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 900,
animationEasing: 'cubicOut',
legend: {
top: 0,
left: 0,
itemWidth: 8,
itemHeight: 8,
itemGap: 14,
textStyle: {
color: '#475569',
fontSize: 12,
fontWeight: 700
}
},
grid: {
top: 38,
right: 16,
bottom: 24,
left: 34,
containLabel: true
},
tooltip: {
trigger: 'axis',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
valueFormatter: (value) => `${value}%`
},
xAxis: {
type: 'category',
data: props.labels,
axisTick: { show: false },
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
}
},
yAxis: {
type: 'value',
min: 0,
max: 100,
splitNumber: 5,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700,
formatter: '{value}%'
},
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
},
series: resolvedAgents.value.map((agent, index) => ({
name: agent.name,
type: 'bar',
stack: 'agentRatio',
data: props.series[agent.key] || [],
barWidth: 34,
emphasis: { focus: 'series' },
itemStyle: {
color: agent.resolvedColor,
borderColor: '#ffffff',
borderWidth: index === resolvedAgents.value.length - 1 ? 0 : 1,
borderRadius: index === resolvedAgents.value.length - 1 ? [4, 4, 0, 0] : 0
}
}))
}))
useEcharts(chartElement, chartOptions)
</script>
<style scoped>
.system-agent-ratio-bar {
height: 292px;
}
.chart-body {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<div class="system-load-heatmap">
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { HeatmapChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, VisualMapComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, TooltipComponent, VisualMapComponent, HeatmapChart, CanvasRenderer])
const props = defineProps({
hours: { type: Array, required: true },
tools: { type: Array, required: true },
values: { type: Array, required: true }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const maxValue = computed(() => Math.max(...props.values.map((item) => Number(item[2] || 0)), 1))
const ariaLabel = computed(() =>
props.values.map(([hourIndex, toolIndex, value]) => (
`${props.hours[hourIndex] || ''}${props.tools[toolIndex] || ''}${value || 0}`
)).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 760,
animationEasing: 'cubicOut',
grid: {
top: 20,
right: 18,
bottom: 18,
left: 78,
containLabel: false
},
tooltip: {
position: 'top',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
formatter: (params) => {
const [hourIndex, toolIndex, value] = params.value || []
return `${params.marker}${props.tools[toolIndex] || ''}<br/>${props.hours[hourIndex] || ''}: ${value || 0}`
}
},
visualMap: {
show: false,
min: 0,
max: maxValue.value,
inRange: {
color: [
'#eef6fb',
'#d7e9f3',
themeColors.value.chartPrimary,
themeColors.value.primaryActive
]
}
},
xAxis: {
type: 'category',
data: props.hours,
splitArea: { show: true },
axisTick: { show: false },
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
}
},
yAxis: {
type: 'category',
data: props.tools,
splitArea: { show: true },
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#475569',
fontSize: 12,
fontWeight: 750
}
},
series: [
{
name: '调用热力',
type: 'heatmap',
data: props.values,
label: {
show: true,
color: '#ffffff',
fontSize: 10,
fontWeight: 800,
formatter: ({ value }) => value?.[2] || 0
},
itemStyle: {
borderColor: '#ffffff',
borderWidth: 2,
borderRadius: 4
},
emphasis: {
itemStyle: {
borderColor: themeColors.value.primaryActive,
borderWidth: 2
}
}
}
]
}))
useEcharts(chartElement, chartOptions)
</script>
<style scoped>
.system-load-heatmap {
height: 320px;
}
.chart-body {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<div class="system-login-wave-chart" :class="{ 'is-compact': compact }">
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { LineChart as EChartsLineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, LegendComponent, TooltipComponent, EChartsLineChart, CanvasRenderer])
const props = defineProps({
labels: { type: Array, required: true },
loginUsers: { type: Array, required: true },
interactions: { type: Array, required: true },
compact: { type: Boolean, default: false }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const chartColors = computed(() => ({
primary: themeColors.value.chartPrimary,
blue: themeColors.value.chartBlue
}))
const ariaLabel = computed(() =>
props.labels.map((label, index) => (
`${label}登录${props.loginUsers[index] || 0}人,互动${props.interactions[index] || 0}`
)).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 950,
animationEasing: 'cubicOut',
legend: {
top: 0,
left: 0,
itemWidth: 18,
itemHeight: 8,
itemGap: 16,
textStyle: {
color: '#475569',
fontSize: 12,
fontWeight: 700
}
},
grid: {
top: 38,
right: 36,
bottom: 24,
left: 32,
containLabel: true
},
tooltip: {
trigger: 'axis',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);'
},
xAxis: {
type: 'category',
data: props.labels,
boundaryGap: false,
axisTick: { show: false },
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
}
},
yAxis: [
{
type: 'value',
name: '登录',
min: 0,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
nameTextStyle: { color: '#94a3b8', fontSize: 11, fontWeight: 700 },
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.72)' } }
},
{
type: 'value',
name: '互动',
min: 0,
axisLabel: {
color: '#94a3b8',
fontSize: 11,
fontWeight: 700
},
nameTextStyle: { color: '#94a3b8', fontSize: 11, fontWeight: 700 },
splitLine: { show: false }
}
],
series: [
{
name: '登录人数',
type: 'line',
smooth: 0.42,
symbol: 'circle',
symbolSize: 7,
data: props.loginUsers,
lineStyle: {
width: 3,
color: chartColors.value.primary
},
itemStyle: {
color: '#ffffff',
borderColor: chartColors.value.primary,
borderWidth: 2.5
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: toRgba(chartColors.value.primary, 0.18) },
{ offset: 1, color: toRgba(chartColors.value.primary, 0.02) }
]
}
}
},
{
name: '互动次数',
type: 'line',
yAxisIndex: 1,
smooth: 0.5,
symbol: 'emptyCircle',
symbolSize: 6,
data: props.interactions,
lineStyle: {
width: 2.5,
color: chartColors.value.blue,
type: 'dashed'
},
itemStyle: {
color: '#ffffff',
borderColor: chartColors.value.blue,
borderWidth: 2
}
}
]
}))
useEcharts(chartElement, chartOptions)
function toRgba(color, alpha) {
const normalized = String(color || '').trim()
const hex = normalized.replace('#', '')
if (/^[\da-f]{6}$/i.test(hex)) {
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
return `rgba(58, 124, 165, ${alpha})`
}
</script>
<style scoped>
.system-login-wave-chart {
height: 292px;
}
.system-login-wave-chart.is-compact {
height: 188px;
}
.chart-body {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<div class="system-token-daily-wave-chart">
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { BarChart as EChartsBarChart, LineChart as EChartsLineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, LegendComponent, TooltipComponent, EChartsBarChart, EChartsLineChart, CanvasRenderer])
const props = defineProps({
labels: { type: Array, required: true },
inputTokens: { type: Array, required: true },
outputTokens: { type: Array, required: true },
totalTokens: { type: Array, required: true }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const chartColors = computed(() => ({
amber: themeColors.value.chartAmber,
purple: themeColors.value.chartPurple,
danger: themeColors.value.chartDanger
}))
const ariaLabel = computed(() =>
props.labels.map((label, index) => (
`${label}输入${formatTokens(props.inputTokens[index])},输出${formatTokens(props.outputTokens[index])},合计${formatTokens(props.totalTokens[index])}`
)).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 920,
animationEasing: 'cubicOut',
legend: {
top: 0,
left: 0,
itemWidth: 9,
itemHeight: 9,
itemGap: 16,
textStyle: {
color: '#475569',
fontSize: 12,
fontWeight: 700
}
},
grid: {
top: 38,
right: 22,
bottom: 24,
left: 34,
containLabel: true
},
tooltip: {
trigger: 'axis',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
valueFormatter: (value) => `${formatTokens(value)} tokens`
},
xAxis: {
type: 'category',
data: props.labels,
axisTick: { show: false },
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
}
},
yAxis: {
type: 'value',
min: 0,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700,
formatter: (value) => `${Math.round(value / 1000)}K`
},
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.74)' } }
},
series: [
{
name: '输入 Tokens',
type: 'bar',
stack: 'tokens',
data: props.inputTokens,
barWidth: 24,
itemStyle: {
color: chartColors.value.amber,
borderRadius: [0, 0, 3, 3]
}
},
{
name: '输出 Tokens',
type: 'bar',
stack: 'tokens',
data: props.outputTokens,
barWidth: 24,
itemStyle: {
color: chartColors.value.purple,
borderRadius: [3, 3, 0, 0]
}
},
{
name: '合计波动',
type: 'line',
data: props.totalTokens,
smooth: 0.45,
symbol: 'circle',
symbolSize: 7,
lineStyle: {
width: 2.8,
color: chartColors.value.danger
},
itemStyle: {
color: '#ffffff',
borderColor: chartColors.value.danger,
borderWidth: 2.4
},
emphasis: { focus: 'series' }
}
]
}))
useEcharts(chartElement, chartOptions)
function formatTokens(value) {
const number = Number(value || 0)
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`
return `${Math.round(number)}`
}
</script>
<style scoped>
.system-token-daily-wave-chart {
height: 292px;
}
.chart-body {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,113 @@
<template>
<div class="system-token-treemap">
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { TreemapChart } from 'echarts/charts'
import { TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
use([TooltipComponent, TreemapChart, CanvasRenderer])
const props = defineProps({
items: { type: Array, required: true }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const normalizedItems = computed(() =>
props.items.map((item) => ({
...item,
value: Number(item.tokens || item.value || 0),
resolvedColor: resolveCssColor(item.color, themeColors.value.chartPrimary)
}))
)
const ariaLabel = computed(() =>
normalizedItems.value.map((item) => `${item.name}${formatTokens(item.value)},占比${item.share}%`).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 760,
animationEasing: 'cubicOut',
tooltip: {
trigger: 'item',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
formatter: (params) => `${params.marker}${params.name}<br/>${formatTokens(params.value)} · ${params.data?.share || 0}%`
},
series: [
{
type: 'treemap',
roam: false,
nodeClick: false,
breadcrumb: { show: false },
top: 4,
right: 4,
bottom: 4,
left: 4,
visibleMin: 300,
leafDepth: 1,
label: {
show: true,
color: '#ffffff',
fontSize: 12,
fontWeight: 800,
lineHeight: 16,
formatter: (params) => `${params.name}\n${formatTokens(params.value)}`
},
itemStyle: {
borderColor: '#ffffff',
borderWidth: 3,
borderRadius: 4,
gapWidth: 3
},
upperLabel: { show: false },
data: normalizedItems.value.map((item) => ({
name: item.name,
value: item.value,
share: item.share,
itemStyle: { color: item.resolvedColor }
}))
}
]
}))
useEcharts(chartElement, chartOptions)
function formatTokens(value) {
const number = Number(value || 0)
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M tokens`
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K tokens`
return `${Math.round(number)} tokens`
}
</script>
<style scoped>
.system-token-treemap {
height: 268px;
}
.chart-body {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,245 @@
<template>
<div class="system-trend-chart">
<div class="chart-legend">
<span><i :style="{ background: chartColors.primary }"></i>工具调用</span>
<span><i :style="{ background: chartColors.blue }"></i>Token 消耗K</span>
<span><i :style="{ background: chartColors.purple }"></i>在线人数</span>
<span><i :style="{ background: chartColors.amber }"></i>平均在线时长分钟</span>
</div>
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { BarChart as EChartsBarChart, LineChart as EChartsLineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, TooltipComponent, EChartsBarChart, EChartsLineChart, CanvasRenderer])
const props = defineProps({
labels: { type: Array, required: true },
toolCalls: { type: Array, required: true },
tokens: { type: Array, required: true },
onlineUsers: { type: Array, required: true },
onlineMinutes: { type: Array, required: true }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const chartColors = computed(() => ({
primary: themeColors.value.chartPrimary,
blue: themeColors.value.chartBlue,
purple: themeColors.value.chartPurple,
amber: themeColors.value.chartAmber
}))
const tokenInK = computed(() => props.tokens.map((value) => Math.round(Number(value || 0) / 100) / 10))
const ariaLabel = computed(() =>
props.labels.map((label, index) => (
`${label}工具调用${props.toolCalls[index] || 0}Token消耗${tokenInK.value[index] || 0}K在线${props.onlineUsers[index] || 0}人,平均在线${props.onlineMinutes[index] || 0}分钟`
)).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 1200,
animationDurationUpdate: 1200,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
grid: {
top: 18,
right: 42,
bottom: 22,
left: 38,
containLabel: true
},
tooltip: {
trigger: 'axis',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);'
},
xAxis: {
type: 'category',
data: props.labels,
boundaryGap: true,
axisTick: { show: false },
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
}
},
yAxis: [
{
type: 'value',
min: 0,
max: 360,
splitNumber: 6,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
},
{
type: 'value',
min: 0,
max: 90,
splitNumber: 6,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
splitLine: { show: false }
}
],
series: [
{
name: '工具调用(次)',
type: 'bar',
data: props.toolCalls,
barWidth: 14,
itemStyle: {
color: chartColors.value.primary,
borderRadius: [4, 4, 0, 0]
}
},
{
name: 'Token 消耗K',
type: 'line',
yAxisIndex: 1,
data: tokenInK.value,
smooth: true,
symbol: 'circle',
symbolSize: 7,
lineStyle: {
width: 2.5,
color: chartColors.value.blue
},
itemStyle: {
color: '#ffffff',
borderColor: chartColors.value.blue,
borderWidth: 2.5
},
areaStyle: {
color: buildAreaColor(chartColors.value.blue, 0.11)
}
},
{
name: '在线人数',
type: 'line',
data: props.onlineUsers,
smooth: true,
symbol: 'circle',
symbolSize: 7,
lineStyle: {
width: 2.5,
color: chartColors.value.purple
},
itemStyle: {
color: '#ffffff',
borderColor: chartColors.value.purple,
borderWidth: 2.5
}
},
{
name: '平均在线时长(分钟)',
type: 'line',
yAxisIndex: 1,
data: props.onlineMinutes,
smooth: true,
symbol: 'circle',
symbolSize: 7,
lineStyle: {
width: 2.5,
color: chartColors.value.amber
},
itemStyle: {
color: '#ffffff',
borderColor: chartColors.value.amber,
borderWidth: 2.5
}
}
]
}))
useEcharts(chartElement, chartOptions)
function buildAreaColor(color, alpha) {
return {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: toRgba(color, alpha) },
{ offset: 1, color: toRgba(color, 0.02) }
]
}
}
function toRgba(color, alpha) {
const normalized = String(color || '').trim()
const hex = normalized.replace('#', '')
if (/^[\da-f]{6}$/i.test(hex)) {
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
return `rgba(58, 124, 165, ${alpha})`
}
</script>
<style scoped>
.system-trend-chart {
height: 280px;
display: flex;
flex-direction: column;
}
.chart-legend {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px 16px;
color: #475569;
font-size: 12px;
margin-bottom: 12px;
}
.chart-legend i {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
margin-right: 4px;
vertical-align: middle;
}
.chart-body {
flex: 1;
min-height: 0;
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<div class="system-user-token-pie">
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
<div class="token-user-list">
<div v-for="item in resolvedItems" :key="item.name" class="token-user-row">
<i :style="{ background: item.resolvedColor }"></i>
<div>
<strong>{{ item.name }}</strong>
<span>{{ item.role }}</span>
</div>
<em>{{ formatTokens(item.tokens) }}</em>
</div>
</div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { PieChart } from 'echarts/charts'
import { TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
use([TooltipComponent, PieChart, CanvasRenderer])
const props = defineProps({
items: { type: Array, required: true }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const totalTokens = computed(() =>
props.items.reduce((sum, item) => sum + Number(item.tokens || 0), 0)
)
const resolvedItems = computed(() =>
props.items.map((item) => ({
...item,
tokens: Number(item.tokens || 0),
resolvedColor: resolveCssColor(item.color, themeColors.value.chartPrimary)
}))
)
const ariaLabel = computed(() =>
resolvedItems.value.map((item) => (
`${item.name}${formatTokens(item.tokens)},占比${getShare(item.tokens)}%`
)).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 900,
animationEasing: 'cubicOut',
tooltip: {
trigger: 'item',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
formatter: (params) => `${params.marker}${params.name}<br/>${formatTokens(params.value)} · ${params.percent}%`
},
series: [
{
type: 'pie',
radius: [0, '82%'],
center: ['47%', '50%'],
roseType: 'radius',
minAngle: 8,
avoidLabelOverlap: true,
label: {
show: true,
color: '#475569',
fontSize: 11,
fontWeight: 800,
formatter: '{b}\n{d}%'
},
labelLine: {
length: 10,
length2: 6,
lineStyle: { color: 'rgba(148, 163, 184, 0.55)' }
},
itemStyle: {
borderColor: '#ffffff',
borderWidth: 3,
borderRadius: 4
},
emphasis: {
scale: true,
scaleSize: 4
},
data: resolvedItems.value.map((item) => ({
name: item.name,
value: item.tokens,
itemStyle: { color: item.resolvedColor }
}))
}
]
}))
useEcharts(chartElement, chartOptions)
function getShare(value) {
if (!totalTokens.value) return 0
return Math.round((Number(value || 0) / totalTokens.value) * 1000) / 10
}
function formatTokens(value) {
const number = Number(value || 0)
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`
return `${Math.round(number)}`
}
</script>
<style scoped>
.system-user-token-pie {
min-height: 292px;
display: grid;
grid-template-columns: minmax(0, 1fr) 188px;
gap: 10px;
}
.chart-body {
width: 100%;
min-width: 0;
height: 292px;
}
.token-user-list {
display: grid;
gap: 8px;
align-content: center;
}
.token-user-row {
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid #f1f5f9;
}
.token-user-row:last-child {
border-bottom: 0;
}
.token-user-row i {
width: 8px;
height: 22px;
border-radius: 2px;
}
.token-user-row strong,
.token-user-row span {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.token-user-row strong {
color: #1e293b;
font-size: 12px;
font-weight: 800;
}
.token-user-row span {
margin-top: 2px;
color: #64748b;
font-size: 11px;
}
.token-user-row em {
color: #0f172a;
font-size: 12px;
font-style: normal;
font-weight: 850;
font-variant-numeric: tabular-nums;
}
@media (max-width: 860px) {
.system-user-token-pie {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,708 @@
<template>
<section class="risk-observation-dashboard">
<article class="panel dashboard-card risk-trend-panel">
<div class="card-head">
<h3>风险观察趋势 <i class="mdi mdi-information-outline"></i></h3>
<div class="risk-window-controls">
<span class="risk-window-label"> {{ dashboard.windowDays }} </span>
<EnterpriseSelect
class="risk-window-select"
:model-value="activeWindowDays"
:options="windowOptions"
size="small"
aria-label="风险看板时间窗口"
@update:model-value="emit('update:windowDays', $event)"
/>
</div>
</div>
<div v-if="loading" class="risk-dashboard-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在加载风险看板数据</span>
</div>
<div v-else-if="error" class="risk-dashboard-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ errorMessage }}</span>
</div>
<RiskDailyTrendChart v-else :rows="dailyRows" />
</article>
<article class="panel dashboard-card risk-level-panel">
<div class="card-head">
<h3>风险等级分布 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart
:items="levelLegend"
:center-value="String(dashboard.totalObservations)"
center-label="风险观察"
/>
</article>
<article class="panel dashboard-card risk-source-panel">
<div class="card-head">
<h3>来源分布 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart
:items="sourceLegend"
:center-value="String(dashboard.totalObservations)"
center-label="归集来源"
/>
</article>
<article class="panel dashboard-card risk-dimension-panel">
<div class="card-head">
<h3>业务维度分布 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="risk-dimension-grid">
<section
v-for="group in dimensionGroups"
:key="group.label"
class="risk-dimension-group"
>
<span class="risk-dimension-title">{{ group.label }}</span>
<div v-if="group.rows.length" class="risk-dimension-list">
<div v-for="row in group.rows" :key="row.name" class="risk-dimension-row">
<span>{{ row.name }}</span>
<i><b :style="{ width: row.width }"></b></i>
<strong>{{ row.count }}</strong>
</div>
</div>
<p v-else class="risk-dashboard-inline-empty">暂无数据</p>
</section>
</div>
</article>
<article class="panel dashboard-card risk-signal-panel">
<div class="card-head">
<h3>风险信号排行 <i class="mdi mdi-information-outline"></i></h3>
</div>
<BarChart
v-if="signalRanking.length"
:items="signalRanking"
value-prefix=""
value-suffix=""
/>
<div v-else class="risk-dashboard-empty">
<i class="mdi mdi-shield-check-outline"></i>
<span>当前周期暂无风险信号</span>
</div>
</article>
<article class="panel dashboard-card risk-ranking-panel">
<div class="card-head">
<h3>异常排行 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="risk-ranking-grid">
<section
v-for="group in rankingGroups"
:key="group.label"
class="risk-ranking-group"
>
<span class="risk-ranking-title">{{ group.label }}</span>
<ol v-if="group.rows.length" class="risk-ranking-list">
<li v-for="(row, index) in group.rows" :key="`${group.label}-${row.name}`">
<em>{{ index + 1 }}</em>
<div>
<strong>{{ row.name }}</strong>
<small>{{ row.amountLabel }}</small>
</div>
<span>{{ row.count }}</span>
</li>
</ol>
<p v-else class="risk-dashboard-inline-empty">暂无数据</p>
</section>
</div>
</article>
<article class="panel dashboard-card risk-effect-panel">
<div class="card-head">
<h3>算法闭环效果 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="risk-effect-grid">
<div v-for="item in effectItems" :key="item.label" class="risk-effect-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</article>
<article class="panel dashboard-card risk-recent-panel">
<div class="card-head">
<h3>近期高风险观察 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div v-if="recentHighObservations.length" class="risk-recent-list">
<button
v-for="item in recentHighObservations"
:key="item.observationKey || item.id"
class="risk-recent-row"
type="button"
:disabled="!item.claimId"
@click="openClaim(item)"
>
<span class="risk-recent-level" :class="item.riskLevel">
{{ formatRiskLevel(item.riskLevel) }}
</span>
<span class="risk-recent-main">
<strong>{{ item.title || formatSignal(item.riskSignal) }}</strong>
<small>{{ item.claimNo || item.claimId || '未关联单据' }}</small>
</span>
<span class="risk-recent-score">{{ item.riskScore }}</span>
</button>
</div>
<div v-else class="risk-dashboard-empty">
<i class="mdi mdi-check-circle-outline"></i>
<span>暂无高风险观察</span>
</div>
</article>
</section>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import BarChart from '../charts/BarChart.vue'
import DonutChart from '../charts/DonutChart.vue'
import RiskDailyTrendChart from '../charts/RiskDailyTrendChart.vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
const props = defineProps({
dashboard: { type: Object, required: true },
loading: { type: Boolean, default: false },
error: { type: Object, default: () => null },
levelLegend: { type: Array, default: () => [] },
sourceLegend: { type: Array, default: () => [] },
signalRanking: { type: Array, default: () => [] },
dailyRows: { type: Array, default: () => [] },
windowOptions: { type: Array, default: () => [] },
activeWindowDays: { type: Number, default: 30 }
})
const emit = defineEmits(['update:windowDays'])
const router = useRouter()
const errorMessage = computed(() => props.error?.message || '风险看板数据加载失败')
const recentHighObservations = computed(() => props.dashboard.recentHighObservations || [])
const dimensionGroups = computed(() => [
buildDimensionGroup('部门', props.dashboard.departmentDistribution, 'department'),
buildDimensionGroup('费用类型', props.dashboard.expenseTypeDistribution, 'expense_type'),
buildDimensionGroup('风险类型', props.dashboard.riskTypeDistribution, 'risk_type'),
buildDimensionGroup('供应商', props.dashboard.supplierDistribution, 'supplier'),
buildDimensionGroup('员工职级', props.dashboard.employeeGradeDistribution, 'grade')
])
const rankingGroups = computed(() => [
buildRankingGroup('部门', props.dashboard.topDepartments, 'department'),
buildRankingGroup('员工', props.dashboard.topEmployees, 'employee'),
buildRankingGroup('供应商', props.dashboard.topSuppliers, 'supplier'),
buildRankingGroup('规则', props.dashboard.topRules, 'rule'),
buildRankingGroup('费用类型', props.dashboard.topExpenseTypes, 'expense_type')
])
const effectItems = computed(() => {
const sourceDistribution = props.dashboard.sourceDistribution || {}
const total = Number(props.dashboard.totalObservations || 0)
const pending = Number(props.dashboard.pendingCount || 0)
const processedRate = total > 0 ? Math.max(0, (total - pending) / total) : 0
return [
{ label: '规则命中', value: sourceDistribution.rule_center || 0 },
{ label: '图谱异常', value: sourceDistribution.financial_risk_graph || 0 },
{ label: '确认率', value: formatPercent(props.dashboard.confirmationRate) },
{ label: '误报率', value: formatPercent(props.dashboard.falsePositiveRate) },
{ label: '候选规则', value: props.dashboard.candidateRuleCount || 0 },
{ label: '完成率', value: formatPercent(processedRate) }
]
})
function buildDimensionGroup(label, distribution = {}, kind = '') {
const rows = Object.entries(distribution || {})
.map(([name, count]) => ({
name: formatDimensionName(name, kind),
count: Number(count || 0)
}))
.filter((item) => item.count > 0)
.sort((a, b) => b.count - a.count)
.slice(0, 4)
const max = Math.max(...rows.map((item) => item.count), 1)
return {
label,
rows: rows.map((item) => ({
...item,
width: `${Math.max((item.count / max) * 100, 10)}%`
}))
}
}
function buildRankingGroup(label, rows = [], kind = '') {
return {
label,
rows: (Array.isArray(rows) ? rows : [])
.map((item) => ({
name: formatDimensionName(item.name, kind),
count: Number(item.count || 0),
amount: Number(item.amount || 0),
amountLabel: formatAmount(item.amount)
}))
.filter((item) => item.count > 0)
.slice(0, 5)
}
}
function formatAmount(value) {
const amount = Number(value || 0)
if (amount >= 1_000_000) return `¥${(amount / 1_000_000).toFixed(1)}M`
if (amount >= 1_000) return `¥${(amount / 1_000).toFixed(1)}K`
return `¥${Math.round(amount)}`
}
function formatPercent(value) {
return `${Math.round(Number(value || 0) * 100)}%`
}
function formatRiskLevel(value) {
const labels = {
critical: '重大',
high: '高',
medium: '中',
low: '低'
}
return labels[String(value || '').trim()] || '未知'
}
function formatDimensionName(value, kind = '') {
const text = String(value || '').trim()
if (!text || text === 'unknown') {
const unknownLabels = {
department: '未归集部门',
expense_type: '未归集费用',
risk_type: '未知风险类型',
supplier: '未归集供应商',
grade: '未归集职级',
employee: '未归集员工',
rule: '未关联规则'
}
return unknownLabels[kind] || '未知'
}
if (kind === 'risk_type' || kind === 'expense_type' || kind === 'rule') {
return formatSignal(text)
}
return text.replace(/_/g, ' ')
}
function formatSignal(value) {
const text = String(value || '').trim()
const labels = {
duplicate_invoice: '重复发票',
split_billing: '拆分报销',
frequent_small_claims: '高频小额',
location_mismatch: '地点不一致',
amount_outlier: '金额异常',
preapproval_absent: '缺少事前申请'
}
return labels[text] || text.replace(/_/g, ' ') || '未知风险'
}
function openClaim(item) {
if (!item.claimId) {
return
}
router.push({
name: 'app-document-detail',
params: { requestId: item.claimId }
})
}
</script>
<style scoped>
.risk-observation-dashboard {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 18px;
min-width: 0;
}
.dashboard-card {
min-width: 0;
padding: 18px;
border: 1px solid #edf2f7;
background: #fff;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.card-head h3 {
min-width: 0;
color: #1e293b;
font-size: 15px;
font-weight: 700;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-head .mdi {
color: #94a3b8;
font-size: 12px;
}
.risk-window-label {
flex: 0 0 auto;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.risk-window-controls {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
}
.risk-window-select {
width: 108px;
}
.risk-trend-panel,
.risk-signal-panel,
.risk-dimension-panel,
.risk-ranking-panel {
grid-column: span 6;
}
.risk-level-panel,
.risk-source-panel,
.risk-effect-panel,
.risk-recent-panel {
grid-column: span 3;
}
.risk-dashboard-state,
.risk-dashboard-empty {
min-height: 220px;
display: grid;
place-content: center;
justify-items: center;
gap: 8px;
color: #64748b;
font-size: 13px;
font-weight: 700;
text-align: center;
}
.risk-dashboard-state i,
.risk-dashboard-empty i {
color: #94a3b8;
font-size: 24px;
}
.risk-dashboard-state.error {
color: #b91c1c;
}
.risk-dashboard-inline-empty {
margin: 0;
color: #94a3b8;
font-size: 12px;
font-weight: 700;
}
.risk-dimension-grid,
.risk-ranking-grid {
display: grid;
gap: 10px;
}
.risk-dimension-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.risk-ranking-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.risk-dimension-group,
.risk-ranking-group {
min-width: 0;
display: grid;
align-content: start;
gap: 8px;
padding: 12px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #f8fafc;
}
.risk-dimension-title,
.risk-ranking-title {
color: #334155;
font-size: 12px;
font-weight: 900;
}
.risk-dimension-list {
display: grid;
gap: 8px;
}
.risk-dimension-row {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) 42px;
align-items: center;
gap: 8px;
}
.risk-dimension-row span,
.risk-dimension-row strong {
min-width: 0;
overflow: hidden;
color: #64748b;
font-size: 12px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-dimension-row strong {
color: #0f172a;
text-align: right;
}
.risk-dimension-row i {
height: 7px;
overflow: hidden;
border-radius: 999px;
background: #e2e8f0;
}
.risk-dimension-row b {
display: block;
height: 100%;
border-radius: inherit;
background: var(--theme-primary);
}
.risk-ranking-list {
display: grid;
gap: 7px;
margin: 0;
padding: 0;
list-style: none;
}
.risk-ranking-list li {
min-width: 0;
display: grid;
grid-template-columns: 20px minmax(0, 1fr) 38px;
align-items: center;
gap: 8px;
}
.risk-ranking-list em {
display: grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 999px;
background: #e2e8f0;
color: #475569;
font-size: 11px;
font-style: normal;
font-weight: 900;
}
.risk-ranking-list div {
min-width: 0;
display: grid;
gap: 2px;
}
.risk-ranking-list strong,
.risk-ranking-list small,
.risk-ranking-list span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-ranking-list strong {
color: #0f172a;
font-size: 12px;
font-weight: 850;
}
.risk-ranking-list small {
color: #94a3b8;
font-size: 11px;
font-weight: 700;
}
.risk-ranking-list span {
color: #64748b;
font-size: 12px;
font-weight: 850;
text-align: right;
}
.risk-effect-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.risk-effect-item {
min-height: 84px;
display: grid;
align-content: center;
gap: 6px;
padding: 12px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #f8fafc;
}
.risk-effect-item span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.risk-effect-item strong {
color: #0f172a;
font-size: 22px;
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.risk-recent-list {
display: grid;
gap: 8px;
}
.risk-recent-row {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: 46px minmax(0, 1fr) 42px;
align-items: center;
gap: 10px;
padding: 10px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #fff;
text-align: left;
cursor: pointer;
transition: border-color 180ms ease, background 180ms ease;
}
.risk-recent-row:hover:not(:disabled) {
border-color: rgba(var(--theme-primary-rgb), .26);
background: #f8fafc;
}
.risk-recent-row:disabled {
cursor: default;
}
.risk-recent-level {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
border-radius: 4px;
background: #e2e8f0;
color: #475569;
font-size: 12px;
font-weight: 900;
}
.risk-recent-level.critical,
.risk-recent-level.high {
background: rgba(239, 68, 68, .1);
color: #b91c1c;
}
.risk-recent-level.medium {
background: rgba(245, 158, 11, .12);
color: #b45309;
}
.risk-recent-main {
min-width: 0;
display: grid;
gap: 3px;
}
.risk-recent-main strong,
.risk-recent-main small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-recent-main strong {
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.risk-recent-main small {
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.risk-recent-score {
color: #0f172a;
font-size: 16px;
font-weight: 900;
text-align: right;
font-variant-numeric: tabular-nums;
}
@media (max-width: 1280px) {
.risk-trend-panel,
.risk-signal-panel,
.risk-dimension-panel,
.risk-ranking-panel {
grid-column: span 12;
}
.risk-level-panel,
.risk-source-panel,
.risk-effect-panel,
.risk-recent-panel {
grid-column: span 6;
}
}
@media (max-width: 760px) {
.risk-observation-dashboard {
grid-template-columns: minmax(0, 1fr);
}
.risk-trend-panel,
.risk-signal-panel,
.risk-dimension-panel,
.risk-ranking-panel,
.risk-level-panel,
.risk-source-panel,
.risk-effect-panel,
.risk-recent-panel {
grid-column: 1;
}
.risk-dimension-grid,
.risk-ranking-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<header class="topbar" :class="{ 'chat-mode': isChat }">
<div class="title-group">
<div class="eyebrow">{{ isChat ? 'Smart Finance Q&A' : 'Smart Expense Operations' }}</div>
<div class="eyebrow">{{ eyebrowLabel }}</div>
<h1>{{ currentView.title }}</h1>
<p>{{ currentView.desc }}</p>
</div>
@@ -40,10 +40,10 @@
</div>
</div>
<div class="custom-range-wrap">
<button
class="custom-range-btn"
type="button"
<div class="custom-range-wrap">
<button
class="custom-range-btn"
type="button"
:class="{ active: isCustomRange }"
:aria-expanded="calendarOpen"
aria-haspopup="dialog"
@@ -77,10 +77,20 @@
<button class="apply-btn" type="button" :disabled="!canApplyCustomRange" @click="applyCustomRange">
应用
</button>
</footer>
</div>
</div>
</div>
</footer>
</div>
</div>
<div class="dashboard-switch-wrap">
<EnterpriseSelect
v-model="overviewDashboardValue"
class="dashboard-switch-select"
:options="overviewDashboardOptions"
aria-label="选择看板类型"
size="default"
/>
</div>
</div>
</template>
<template v-else-if="isRequestDetail">
@@ -202,8 +212,10 @@
</header>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
<script setup>
import { computed, ref, watch } from 'vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
const props = defineProps({
currentView: { type: Object, required: true },
@@ -247,31 +259,39 @@ const props = defineProps({
type: Array,
default: () => []
},
customRange: {
type: Object,
default: () => ({ start: '2024-07-06', end: '2024-07-12' })
}
})
const emit = defineEmits([
'update:search',
'update:activeRange',
'update:customRange',
'batchApprove',
'openChat',
'newApplication'
])
const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview')
const isWorkbench = computed(() => props.activeView === 'workbench')
const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees'].includes(props.activeView) && props.detailMode)
customRange: {
type: Object,
default: () => ({ start: '2024-07-06', end: '2024-07-12' })
},
overviewDashboard: {
type: String,
default: 'finance'
}
})
const emit = defineEmits([
'update:search',
'update:activeRange',
'update:customRange',
'update:overviewDashboard',
'batchApprove',
'openChat',
'newApplication'
])
const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview')
const isWorkbench = computed(() => props.activeView === 'workbench')
const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees', 'receiptFolder'].includes(props.activeView) && props.detailMode)
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
const isRequests = computed(() => props.activeView === 'requests')
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
const isApproval = computed(() => props.activeView === 'approval')
const isPolicies = computed(() => props.activeView === 'policies')
const isEmployees = computed(() => props.activeView === 'employees')
const isPolicies = computed(() => props.activeView === 'policies')
const isEmployees = computed(() => props.activeView === 'employees')
const eyebrowLabel = computed(() => (
String(props.currentView?.eyebrow || '').trim()
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
))
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
const topbarNotificationCount = computed(() => {
const summary = props.documentSummary ?? {}
@@ -421,11 +441,20 @@ const employeeKpis = computed(() => {
}
]
})
const calendarOpen = ref(false)
const draftStart = ref(props.customRange.start)
const draftEnd = ref(props.customRange.end)
const rangeOptions = computed(() =>
const calendarOpen = ref(false)
const draftStart = ref(props.customRange.start)
const draftEnd = ref(props.customRange.end)
const overviewDashboardOptions = [
{ label: '财务看板', value: 'finance' },
{ label: '风险看板', value: 'risk' },
{ label: '系统看板', value: 'system' }
]
const overviewDashboardValue = computed({
get: () => props.overviewDashboard,
set: (value) => emit('update:overviewDashboard', value)
})
const rangeOptions = computed(() =>
props.ranges.map((range, index) => ({
value: range,
label: String(range)

View File

@@ -0,0 +1,303 @@
<template>
<ElDialog
:model-value="open"
append-to-body
width="420px"
:show-close="false"
:close-on-click-modal="!busy"
:close-on-press-escape="!busy"
class="operation-feedback-dialog"
modal-class="operation-feedback-overlay"
@update:model-value="handleModelUpdate"
@closed="resetForm"
>
<section class="operation-feedback">
<header class="operation-feedback-head">
<span class="operation-feedback-icon">
<i class="mdi mdi-message-star-outline"></i>
</span>
<div>
<h3>评价本轮处理</h3>
<p>请给本轮智能体处理结果打分</p>
</div>
</header>
<div class="operation-feedback-stars" role="radiogroup" aria-label="本轮处理评分">
<button
v-for="score in scores"
:key="score"
type="button"
class="operation-feedback-star"
:class="{ active: score <= rating }"
:disabled="busy"
:aria-checked="rating === score ? 'true' : 'false'"
role="radio"
@click="rating = score"
@mouseenter="hoverRating = score"
@mouseleave="hoverRating = 0"
>
<i :class="score <= displayRating ? 'mdi mdi-star' : 'mdi mdi-star-outline'"></i>
</button>
</div>
<Transition name="feedback-reason-slide">
<label v-if="showReasonInput" class="operation-feedback-reason">
<span>不满意的原因</span>
<textarea
v-model="reason"
maxlength="500"
rows="3"
:disabled="busy"
placeholder="例如:意图识别不准、信息提取遗漏、流程引导不清晰..."
></textarea>
<small>{{ reason.length }}/500</small>
</label>
</Transition>
<p v-if="errorMessage" class="operation-feedback-error">{{ errorMessage }}</p>
<footer class="operation-feedback-actions">
<button type="button" class="operation-feedback-secondary" :disabled="busy" @click="emit('close')">
稍后评价
</button>
<button
type="button"
class="operation-feedback-primary"
:disabled="busy || !rating"
@click="submit"
>
<i v-if="busy" class="mdi mdi-loading mdi-spin"></i>
<span>{{ busy ? '提交中...' : '提交评价' }}</span>
</button>
</footer>
</section>
</ElDialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
const props = defineProps({
open: { type: Boolean, default: false },
busy: { type: Boolean, default: false },
errorMessage: { type: String, default: '' }
})
const emit = defineEmits(['close', 'submit'])
const scores = [1, 2, 3, 4, 5]
const rating = ref(0)
const hoverRating = ref(0)
const reason = ref('')
const displayRating = computed(() => hoverRating.value || rating.value)
const showReasonInput = computed(() => rating.value > 0 && rating.value <= 3)
function resetForm() {
rating.value = 0
hoverRating.value = 0
reason.value = ''
}
function handleModelUpdate(value) {
if (!value) {
emit('close')
}
}
function submit() {
emit('submit', {
rating: rating.value,
reason: reason.value
})
}
watch(
() => props.open,
(open) => {
if (open) {
resetForm()
}
}
)
</script>
<style scoped>
:deep(.operation-feedback-dialog) {
border-radius: 8px;
}
:deep(.operation-feedback-dialog .el-dialog__header) {
display: none;
}
:deep(.operation-feedback-dialog .el-dialog__body) {
padding: 20px;
}
.operation-feedback {
display: flex;
flex-direction: column;
gap: 18px;
padding: 2px 2px 0;
}
.operation-feedback-head {
display: flex;
gap: 12px;
align-items: flex-start;
}
.operation-feedback-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 6px;
background: #eef4ff;
color: #1d4ed8;
font-size: 20px;
}
.operation-feedback h3 {
margin: 0;
color: #172033;
font-size: 17px;
font-weight: 700;
}
.operation-feedback p {
margin: 5px 0 0;
color: #667085;
font-size: 13px;
line-height: 1.6;
}
.operation-feedback-stars {
display: flex;
justify-content: center;
gap: 8px;
padding: 6px 0 2px;
}
.operation-feedback-star {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border: 1px solid #d6deea;
border-radius: 6px;
background: #fff;
color: #9aa8bb;
cursor: pointer;
font-size: 24px;
transition: border-color 180ms ease, color 180ms ease, background 180ms ease;
}
.operation-feedback-star:hover,
.operation-feedback-star.active {
border-color: #f59e0b;
background: #fff7ed;
color: #f59e0b;
}
.operation-feedback-star:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.operation-feedback-reason {
display: flex;
flex-direction: column;
gap: 8px;
}
.operation-feedback-reason span {
color: #344054;
font-size: 13px;
font-weight: 600;
}
.operation-feedback-reason textarea {
width: 100%;
box-sizing: border-box;
resize: vertical;
min-height: 82px;
border: 1px solid #d0d5dd;
border-radius: 6px;
padding: 10px 12px;
color: #172033;
font: inherit;
line-height: 1.5;
outline: none;
transition: border-color 180ms ease, box-shadow 180ms ease;
}
.operation-feedback-reason textarea:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
}
.operation-feedback-reason small {
align-self: flex-end;
color: #98a2b3;
font-size: 12px;
}
.operation-feedback-error {
margin: 0;
color: #b42318;
font-size: 13px;
}
.operation-feedback-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.operation-feedback-secondary,
.operation-feedback-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 88px;
height: 34px;
border-radius: 6px;
border: 1px solid #d0d5dd;
padding: 0 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.operation-feedback-secondary {
background: #fff;
color: #344054;
}
.operation-feedback-primary {
border-color: #2563eb;
background: #2563eb;
color: #fff;
}
.operation-feedback-secondary:disabled,
.operation-feedback-primary:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.feedback-reason-slide-enter-active,
.feedback-reason-slide-leave-active {
transition: opacity 180ms ease, transform 180ms ease;
}
.feedback-reason-slide-enter-from,
.feedback-reason-slide-leave-to {
opacity: 0;
transform: translateY(-6px);
}
</style>

View File

@@ -0,0 +1,474 @@
<template>
<section
ref="feedbackRootRef"
class="operation-feedback-inline"
:class="{ 'is-submitted': submitted }"
:aria-labelledby="feedbackTitleId"
>
<header class="operation-feedback-inline-head">
<strong :id="feedbackTitleId">这次处理符合预期吗</strong>
<button
v-if="!submitted"
type="button"
class="operation-feedback-link"
:disabled="busy"
@click="emit('dismiss')"
>
稍后
</button>
</header>
<div
class="operation-feedback-stars"
role="radiogroup"
:aria-labelledby="feedbackTitleId"
:aria-describedby="feedbackDescriptionId"
@mouseleave="hoverRating = 0"
>
<button
v-for="option in ratingOptions"
:key="option.value"
type="button"
class="operation-feedback-star"
:class="{ active: option.value === effectiveRating, preview: option.value <= displayRating }"
:disabled="busy || submitted"
:aria-checked="effectiveRating === option.value ? 'true' : 'false'"
:aria-label="`${option.value}星,${option.label}`"
:data-score="option.value"
role="radio"
:tabindex="resolveRatingTabIndex(option.value)"
@click="selectRating(option.value)"
@mouseenter="hoverRating = option.value"
@focus="hoverRating = option.value"
@blur="hoverRating = 0"
@keydown="handleRatingKeydown($event, option.value)"
>
<i
aria-hidden="true"
:class="option.value <= displayRating ? 'mdi mdi-star' : 'mdi mdi-star-outline'"
></i>
</button>
</div>
<p :id="feedbackDescriptionId" class="operation-feedback-status" aria-live="polite">
{{ selectedFeedbackText }}
</p>
<p v-if="submitted" class="operation-feedback-thanks" aria-live="polite">
<i class="mdi mdi-check-circle-outline" aria-hidden="true"></i>
<span>感谢您的反馈谢谢</span>
</p>
<Transition name="feedback-reason-slide">
<div v-if="showReasonInput" class="operation-feedback-low-rating">
<label class="operation-feedback-reason">
<span>哪里不符合预期</span>
<textarea
v-model="reason"
maxlength="500"
rows="2"
:disabled="busy"
placeholder="例如:意图识别不准、信息提取遗漏..."
></textarea>
<small>{{ reason.length }}/500</small>
</label>
<button
type="button"
class="operation-feedback-primary"
:disabled="busy || !rating"
@click="submit"
>
<i v-if="busy" class="mdi mdi-loading mdi-spin"></i>
<span>{{ busy ? '提交中...' : '提交' }}</span>
</button>
</div>
</Transition>
<p v-if="errorMessage" class="operation-feedback-error">{{ errorMessage }}</p>
</section>
</template>
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
const props = defineProps({
busy: { type: Boolean, default: false },
errorMessage: { type: String, default: '' },
resetKey: { type: String, default: '' },
submitted: { type: Boolean, default: false },
submittedRating: { type: Number, default: 0 }
})
const emit = defineEmits(['dismiss', 'submit'])
const feedbackIdSuffix = Math.random().toString(36).slice(2, 10)
const feedbackTitleId = `operation-feedback-title-${feedbackIdSuffix}`
const feedbackDescriptionId = `operation-feedback-note-${feedbackIdSuffix}`
const ratingOptions = [
{ value: 1, label: '很差' },
{ value: 2, label: '不满意' },
{ value: 3, label: '一般' },
{ value: 4, label: '满意' },
{ value: 5, label: '很好' }
]
const rating = ref(0)
const hoverRating = ref(0)
const reason = ref('')
const feedbackRootRef = ref(null)
const normalizedSubmittedRating = computed(() => {
const score = Number(props.submittedRating || 0)
return Number.isInteger(score) && score >= 1 && score <= 5 ? score : 0
})
const effectiveRating = computed(() => (props.submitted ? normalizedSubmittedRating.value || rating.value : rating.value))
const displayRating = computed(() => (props.submitted ? effectiveRating.value : hoverRating.value || rating.value))
const selectedOption = computed(() => ratingOptions.find((option) => option.value === effectiveRating.value) || null)
const selectedFeedbackText = computed(() => (
props.submitted
? '感谢您的反馈。谢谢'
: selectedOption.value
? rating.value <= 3
? `已选择 ${selectedOption.value.value} 星,可补充原因后提交。`
: `已选择 ${selectedOption.value.value} 星,按 Enter 确认。`
: '请选择一个评分。'
))
const showReasonInput = computed(() => !props.submitted && rating.value > 0 && rating.value <= 3)
function focusRatingButton(score) {
nextTick(() => {
feedbackRootRef.value
?.querySelector(`[data-score="${score}"]`)
?.focus()
})
}
function selectRating(score, options = {}) {
if (props.busy || props.submitted) {
return
}
const shouldSubmitHighRating = options.submitHighRating !== false
rating.value = score
if (score > 3 && shouldSubmitHighRating) {
reason.value = ''
emit('submit', {
rating: score,
reason: ''
})
}
}
function resolveRatingTabIndex(score) {
if (props.submitted) {
return -1
}
return rating.value
? rating.value === score ? 0 : -1
: score === 1 ? 0 : -1
}
function handleRatingKeydown(event, score) {
const key = event.key
const currentIndex = ratingOptions.findIndex((option) => option.value === score)
if (currentIndex < 0) {
return
}
const lastIndex = ratingOptions.length - 1
let nextIndex = currentIndex
if (['ArrowRight', 'ArrowDown'].includes(key)) {
nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1
} else if (['ArrowLeft', 'ArrowUp'].includes(key)) {
nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1
} else if (key === 'Home') {
nextIndex = 0
} else if (key === 'End') {
nextIndex = lastIndex
} else {
return
}
event.preventDefault()
const nextScore = ratingOptions[nextIndex].value
selectRating(nextScore, { submitHighRating: false })
focusRatingButton(nextScore)
}
function resetForm() {
rating.value = 0
hoverRating.value = 0
reason.value = ''
}
function submit() {
if (props.submitted) {
return
}
emit('submit', {
rating: rating.value,
reason: reason.value
})
}
watch(
() => props.resetKey,
() => resetForm()
)
watch(
() => props.submittedRating,
(nextRating) => {
const score = Number(nextRating || 0)
if (props.submitted && Number.isInteger(score) && score >= 1 && score <= 5) {
rating.value = score
hoverRating.value = 0
}
}
)
</script>
<style scoped>
.operation-feedback-inline {
width: fit-content;
max-width: min(100%, 360px);
display: inline-grid;
grid-template-columns: minmax(0, auto);
gap: 8px;
padding: 9px 11px;
border: 1px solid #d8e2ee;
border-radius: 14px;
background: #ffffff;
color: #24324a;
box-shadow: 0 8px 18px rgb(148 163 184 / 12%);
}
.operation-feedback-inline.is-submitted {
border-color: #d0d5dd;
background: #f8fafc;
color: #475467;
box-shadow: none;
}
.operation-feedback-inline-head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
}
.operation-feedback-inline strong {
color: #172033;
font-size: 12px;
font-weight: 760;
line-height: 1.35;
}
.operation-feedback-link {
flex: 0 0 auto;
height: 22px;
padding: 0 6px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #475467;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: border-color 0.18s ease, color 0.18s ease, background 0.18s ease;
}
.operation-feedback-link:hover,
.operation-feedback-link:focus-visible {
border-color: #c8d5e6;
background: #f5f8fc;
color: #1d4ed8;
outline: none;
}
.operation-feedback-stars {
display: flex;
align-items: center;
gap: 3px;
}
.operation-feedback-star {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #98a2b3;
cursor: pointer;
font-size: 16px;
line-height: 1;
transition: border-color 0.16s ease, color 0.16s ease, background 0.16s ease;
}
.operation-feedback-star:hover,
.operation-feedback-star:focus-visible {
border-color: #d69a2d;
background: #fffaf0;
color: #b7791f;
outline: none;
}
.operation-feedback-star.preview {
color: #b7791f;
}
.operation-feedback-star.active {
border-color: #c78315;
background: #fff7e6;
color: #8a4f00;
}
.operation-feedback-star:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.operation-feedback-inline.is-submitted .operation-feedback-star.preview {
color: #667085;
}
.operation-feedback-inline.is-submitted .operation-feedback-star.active {
border-color: #d0d5dd;
background: #eef2f6;
color: #475467;
}
.operation-feedback-status {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.operation-feedback-low-rating {
display: grid;
gap: 7px;
min-width: min(320px, calc(100vw - 96px));
}
.operation-feedback-reason {
display: grid;
gap: 6px;
}
.operation-feedback-reason span {
color: #344054;
font-size: 11px;
font-weight: 760;
}
.operation-feedback-reason textarea {
width: 100%;
box-sizing: border-box;
resize: vertical;
min-height: 54px;
padding: 7px 9px;
border: 1px solid #d0d5dd;
border-radius: 4px;
background: #ffffff;
color: #172033;
font: inherit;
font-size: 12px;
line-height: 1.5;
outline: none;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.operation-feedback-reason textarea:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
}
.operation-feedback-reason small {
justify-self: end;
color: #98a2b3;
font-size: 11px;
}
.operation-feedback-error {
margin: 0;
color: #b42318;
font-size: 12px;
}
.operation-feedback-thanks {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 0;
color: #475467;
font-size: 12px;
font-weight: 760;
line-height: 1.45;
}
.operation-feedback-thanks i {
color: #667085;
font-size: 14px;
}
.operation-feedback-primary {
justify-self: end;
min-width: 58px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 10px;
border: 1px solid #2563eb;
border-radius: 4px;
font-size: 11px;
font-weight: 760;
cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease, opacity 0.18s ease;
}
.operation-feedback-primary {
background: #2563eb;
color: #ffffff;
}
.operation-feedback-primary:hover:not(:disabled),
.operation-feedback-primary:focus-visible {
border-color: #1d4ed8;
background: #1d4ed8;
outline: none;
}
.operation-feedback-link:disabled,
.operation-feedback-primary:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.feedback-reason-slide-enter-active,
.feedback-reason-slide-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.feedback-reason-slide-enter-from,
.feedback-reason-slide-leave-to {
opacity: 0;
transform: translateY(-6px);
}
@media (max-width: 540px) {
.operation-feedback-inline {
max-width: 100%;
}
.operation-feedback-low-rating {
min-width: min(280px, calc(100vw - 80px));
}
}
</style>

View File

@@ -121,6 +121,13 @@
</ul>
</div>
<div v-if="buildTraceItems(message.result).length" class="risk-sim-evidence">
<span>执行路径</span>
<ul>
<li v-for="item in buildTraceItems(message.result)" :key="item">{{ item }}</li>
</ul>
</div>
<div v-if="message.result.missing_fields?.length" class="risk-sim-missing-fields">
<span>待补充字段</span>
<b v-for="field in message.result.missing_fields" :key="field.key">
@@ -306,6 +313,7 @@ import {
buildEvidenceItems as buildEvidenceItemsModel,
buildRecognizedFieldRows as buildRecognizedFieldRowsModel,
buildResultFields as buildResultFieldsModel,
buildTraceItems as buildTraceItemsModel,
formatDocumentMeta,
formatFieldLabel,
resolveFileStatusLabel,
@@ -662,6 +670,10 @@ function buildEvidenceItems(result) {
return buildEvidenceItemsModel(result, fields.value)
}
function buildTraceItems(result) {
return buildTraceItemsModel(result, fields.value)
}
function toAttachmentPayload(file) {
const document = file.ocrDocument || {}
return {

View File

@@ -58,6 +58,18 @@ export function buildEvidenceItems(result, fields = []) {
return [...new Set(items)].slice(0, 5)
}
export function buildTraceItems(result, fields = []) {
const steps = Array.isArray(result?.trace?.steps) ? result.trace.steps : []
return steps.slice(0, 6).map((step, index) => {
const title = String(step?.title || step?.node_id || `判断 ${index + 1}`).trim()
const status = step?.result ? '成立' : '不成立'
const inputs = step?.inputs && typeof step.inputs === 'object'
? Object.entries(step.inputs).slice(0, 3).map(([key, value]) => `${formatFieldName(key, fields)}=${formatDebugValue(value)}`).join('')
: ''
return inputs ? `${title}${status}${inputs}` : `${title}${status}`
})
}
export function buildDocumentBrief(document) {
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
if (fields.length) {

View File

@@ -0,0 +1,381 @@
<template>
<article v-if="visible" class="detail-card panel risk-observation-evidence-card">
<div class="detail-card-head">
<div>
<h3 class="detail-card-title-with-icon">
<i class="mdi mdi-shield-search"></i>
<span>风险证据链</span>
</h3>
<p>展示当前单据进入统一风险观察池后的评分证据图谱关系和反馈状态</p>
</div>
<button
class="risk-evidence-refresh"
type="button"
:disabled="loading"
@click="loadObservations"
>
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
<span>刷新</span>
</button>
</div>
<div v-if="loading" class="risk-evidence-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在加载风险证据链</span>
</div>
<div v-else-if="errorMessage" class="risk-evidence-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ errorMessage }}</span>
</div>
<template v-else-if="mainObservation">
<div class="risk-evidence-current-head">
<div>
<span>当前风险观察详情</span>
<strong>{{ mainObservation.title || formatSignal(mainObservation.riskSignal) }}</strong>
</div>
<em>{{ activeObservationPosition }}</em>
</div>
<div
:id="detailRegionId"
class="risk-evidence-detail-region"
role="region"
aria-live="polite"
>
<div class="risk-evidence-summary">
<div class="risk-evidence-score" :class="mainObservation.riskLevel">
<strong>{{ mainObservation.riskScore }}</strong>
<span>{{ formatRiskLevel(mainObservation.riskLevel) }}</span>
</div>
<div class="risk-evidence-copy">
<h4>{{ mainObservation.title || formatSignal(mainObservation.riskSignal) }}</h4>
<p>{{ mainObservation.description || '暂无风险描述。' }}</p>
<div class="risk-evidence-meta">
<span>{{ formatSource(mainObservation.source) }}</span>
<span>{{ mainObservation.algorithmVersion || '未记录算法版本' }}</span>
<span>{{ formatFeedbackStatus(mainObservation.feedbackStatus) }}</span>
</div>
</div>
</div>
<div class="risk-evidence-grid">
<section class="risk-evidence-section">
<span class="risk-evidence-section-title">贡献分</span>
<div class="risk-score-list">
<div v-for="item in scoreItems" :key="item.key" class="risk-score-row">
<span>{{ item.label }}</span>
<i><b :style="{ width: item.width }"></b></i>
<strong>{{ item.value }}</strong>
</div>
</div>
</section>
<section class="risk-evidence-section">
<span class="risk-evidence-section-title">证据</span>
<ul v-if="evidenceRows.length" class="risk-evidence-list">
<li v-for="item in evidenceRows" :key="item.key">
<strong>{{ item.title }}</strong>
<span>{{ item.detail }}</span>
</li>
</ul>
<p v-else class="risk-evidence-empty">暂无证据明细</p>
</section>
<section class="risk-evidence-section">
<span class="risk-evidence-section-title">图谱关系</span>
<div class="risk-chip-list">
<span v-for="item in graphItems" :key="item">{{ item }}</span>
<em v-if="!graphItems.length">暂无图谱关系</em>
</div>
</section>
<section class="risk-evidence-section">
<span class="risk-evidence-section-title">基线与建议</span>
<div class="risk-chip-list">
<span v-for="item in baselineAndDecisionItems" :key="item">{{ item }}</span>
<em v-if="!baselineAndDecisionItems.length">暂无基线或建议动作</em>
</div>
</section>
<section class="risk-evidence-section">
<span class="risk-evidence-section-title">制度与相似案例</span>
<div class="risk-chip-list">
<span v-for="item in policyAndSimilarItems" :key="item">{{ item }}</span>
<em v-if="!policyAndSimilarItems.length">暂无制度引用或相似案例</em>
</div>
</section>
<section class="risk-evidence-section">
<span class="risk-evidence-section-title">反馈历史</span>
<ul v-if="feedbackRows.length" class="risk-evidence-list">
<li v-for="item in feedbackRows" :key="item.key">
<strong>{{ item.title }}</strong>
<span>{{ item.detail }}</span>
</li>
</ul>
<p v-else class="risk-evidence-empty">暂无人工反馈</p>
</section>
</div>
</div>
<div v-if="observations.length > 1" class="risk-observation-list">
<button
v-for="(item, index) in observations"
:key="observationIdentity(item, index)"
type="button"
class="risk-observation-row"
:class="{ active: isActiveObservation(item, index) }"
:aria-pressed="isActiveObservation(item, index)"
:aria-controls="detailRegionId"
@click="selectObservation(item, index)"
>
<span>{{ formatRiskLevel(item.riskLevel) }}</span>
<strong>{{ item.title || formatSignal(item.riskSignal) }}</strong>
<em>{{ item.riskScore }}</em>
</button>
</div>
</template>
</article>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { fetchClaimRiskObservations } from '../../services/riskObservations.js'
const props = defineProps({
claimId: { type: String, default: '' }
})
const observations = ref([])
const loading = ref(false)
const errorMessage = ref('')
const activeObservationKey = ref('')
const detailRegionId = 'risk-observation-active-detail'
let loadSequence = 0
const visible = computed(() =>
loading.value || Boolean(errorMessage.value) || observations.value.length > 0
)
const mainObservation = computed(() => {
if (!observations.value.length) {
return null
}
return (
observations.value.find((item, index) => isActiveObservation(item, index))
|| observations.value[0]
)
})
const activeObservationPosition = computed(() => {
if (observations.value.length <= 1) {
return '单条观察'
}
const activeIndex = observations.value.findIndex((item, index) =>
isActiveObservation(item, index)
)
return activeIndex >= 0 ? `${activeIndex + 1} / ${observations.value.length}` : '未选择'
})
const scoreItems = computed(() => {
const scores = mainObservation.value?.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}%`
}
})
})
const evidenceRows = computed(() =>
(mainObservation.value?.evidence || []).slice(0, 6).map((item, index) => ({
key: `${item.code || item.title || index}`,
title: String(item.title || item.code || item.source || `证据 ${index + 1}`).trim(),
detail: String(item.detail || item.message || item.summary || '').trim() || '已记录证据。'
}))
)
const graphItems = computed(() => [
...(mainObservation.value?.graphNodeKeys || []).slice(0, 4),
...(mainObservation.value?.graphEdgeKeys || []).slice(0, 4)
].map(formatChipValue).filter(Boolean))
const baselineAndDecisionItems = computed(() => [
...formatRecordItems(mainObservation.value?.baseline, '基线'),
...formatRecordItems(mainObservation.value?.decisionTrace, '建议')
])
const policyAndSimilarItems = computed(() => [
...(mainObservation.value?.policyRefs || [])
.map(formatChipValue)
.filter(Boolean)
.map((item) => `制度:${item}`),
...(mainObservation.value?.similarCaseClaimIds || [])
.map(formatChipValue)
.filter(Boolean)
.map((item) => `相似案例:${item}`)
])
const feedbackRows = computed(() =>
(mainObservation.value?.feedbackItems || []).slice(0, 5).map((item, index) => {
const feedbackType = item.feedbackType || item.feedback_type || item.type
const actor = String(item.actor || item.createdBy || item.created_by || '未记录人员').trim()
const createdAt = String(item.createdAt || item.created_at || '').trim()
const comment = String(item.comment || item.remark || item.message || '').trim()
return {
key: `${item.id || createdAt || index}`,
title: `${formatFeedbackType(feedbackType)} · ${actor}`,
detail: [comment || '已记录反馈。', createdAt].filter(Boolean).join(' · ')
}
})
)
watch(
() => props.claimId,
() => {
void loadObservations()
},
{ immediate: true }
)
async function loadObservations() {
const claimId = String(props.claimId || '').trim()
const sequence = ++loadSequence
errorMessage.value = ''
if (!claimId) {
observations.value = []
activeObservationKey.value = ''
loading.value = false
return
}
loading.value = true
try {
const payload = await fetchClaimRiskObservations(claimId)
if (sequence !== loadSequence) {
return
}
observations.value = payload
activeObservationKey.value = observationIdentity(payload[0], 0)
} catch (error) {
if (sequence === loadSequence) {
observations.value = []
activeObservationKey.value = ''
errorMessage.value = error?.message || '风险证据链加载失败。'
}
} finally {
if (sequence === loadSequence) {
loading.value = false
}
}
}
function observationIdentity(item, index = -1) {
const explicitKey = String(item?.observationKey || item?.id || '').trim()
if (explicitKey) {
return explicitKey
}
return [
item?.claimId,
item?.riskSignal,
item?.createdAt,
index >= 0 ? index : ''
].map((value) => String(value || '').trim()).filter(Boolean).join(':')
}
function isActiveObservation(item, index = -1) {
return observationIdentity(item, index) === activeObservationKey.value
}
function selectObservation(item, index = -1) {
const key = observationIdentity(item, index)
if (key) {
activeObservationKey.value = key
}
}
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)
.slice(0, 6)
.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 text = String(value || '').trim()
const labels = {
duplicate_invoice: '重复发票',
split_billing: '拆分报销',
frequent_small_claims: '高频小额',
location_mismatch: '地点不一致',
amount_outlier: '金额异常',
preapproval_absent: '缺少事前申请'
}
return labels[text] || text.replace(/_/g, ' ') || '未知风险'
}
function formatSource(value) {
const labels = {
financial_risk_graph: '风险图谱',
rule_center: '规则中心'
}
return labels[String(value || '').trim()] || '风险观察池'
}
function formatFeedbackType(value) {
const labels = {
confirm: '确认风险',
confirmed: '确认风险',
false_positive: '标记误报',
ignore: '忽略',
ignored: '忽略',
resolve: '已处理',
resolved: '已处理',
comment: '备注'
}
return labels[String(value || '').trim()] || '人工反馈'
}
function formatFeedbackStatus(value) {
const labels = {
confirmed: '已确认',
false_positive: '已标记误报',
ignored: '已忽略',
resolved: '已处理',
unreviewed: '未复核'
}
return labels[String(value || '').trim()] || '未复核'
}
</script>
<style scoped src="../../assets/styles/components/risk-observation-evidence-card.css"></style>

View File

@@ -170,7 +170,6 @@
<time>{{ ui.formatFlowStepDuration(step) }}</time>
</div>
</header>
<p class="flow-step-tool">工具{{ step.tool }}</p>
<p class="flow-step-detail">{{ ui.resolveFlowStepDetail(step) }}</p>
<p v-if="step.error" class="flow-step-error">{{ step.error }}</p>
</div>
@@ -379,7 +378,6 @@
<time>{{ ui.formatFlowStepDuration(step) }}</time>
</div>
</header>
<p class="flow-step-tool">工具{{ step.tool }}</p>
<p class="flow-step-detail">{{ ui.resolveFlowStepDetail(step) }}</p>
<p v-if="step.error" class="flow-step-error">{{ step.error }}</p>
</div>

View File

@@ -62,14 +62,14 @@
role="row"
:tabindex="row.editable && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy ? 0 : -1"
:aria-label="row.editable ? `编辑${row.label}` : row.label"
@click="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
@click.stop="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.enter.prevent="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.space.prevent="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
>
<span class="application-preview-label" role="cell">{{ row.label }}</span>
<span class="application-preview-value" role="cell">
<input
v-if="ui.isApplicationPreviewEditing(message, row.key) && ui.resolveApplicationPreviewEditorControl(row.key) !== 'select'"
v-if="ui.isApplicationPreviewEditing(message, row.key) && ui.resolveApplicationPreviewEditorControl(row.key) === 'text'"
v-model="ui.applicationPreviewEditor.draftValue"
class="application-preview-input"
type="text"
@@ -79,7 +79,7 @@
@blur="ui.commitApplicationPreviewEditor(message)"
/>
<EnterpriseSelect
v-else-if="ui.isApplicationPreviewEditing(message, row.key)"
v-else-if="ui.isApplicationPreviewEditing(message, row.key) && ui.resolveApplicationPreviewEditorControl(row.key) === 'select'"
v-model="ui.applicationPreviewEditor.draftValue"
class="application-preview-select"
:options="ui.resolveApplicationPreviewEditorOptions(row.key)"
@@ -92,7 +92,10 @@
@blur="ui.commitApplicationPreviewEditor(message)"
/>
<template v-else>
<span class="application-preview-text">{{ row.value }}</span>
<span
class="application-preview-text"
:class="{ 'application-preview-date-chip': row.key === 'time' && !row.missing }"
>{{ row.value }}</span>
<button
v-if="row.editable"
type="button"
@@ -156,17 +159,6 @@
</div>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.meta?.length" class="message-meta-row">
<span
v-for="item in message.meta"
:key="item"
class="message-meta-chip"
:class="message.metaTone"
>
{{ item }}
</span>
</div>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
class="message-suggested-actions"
@@ -413,12 +405,52 @@
</div>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.draftPayload" class="draft-preview">
<header>
<strong>{{ message.draftPayload.title }}</strong>
<span>待人工确认</span>
</header>
<pre>{{ message.draftPayload.body }}</pre>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && message.draftPayload"
class="draft-preview"
:class="{ 'application-draft-preview': ui.isApplicationDraftPayload(message.draftPayload) }"
>
<template v-if="ui.isApplicationDraftPayload(message.draftPayload)">
<header class="application-draft-head">
<span class="application-draft-icon" aria-hidden="true">
<i class="mdi mdi-file-document-check-outline"></i>
</span>
<span class="application-draft-title">
<strong>申请单据已生成</strong>
<small>已为本次业务生成申请单请按需查看完整详情</small>
</span>
<span class="application-draft-status">{{ ui.resolveApplicationDraftStatusLabel(message.draftPayload) }}</span>
</header>
<div class="application-draft-brief" role="group" aria-label="申请单据简要信息">
<div
v-for="item in ui.buildApplicationDraftSummaryItems(message.draftPayload)"
:key="`${message.id}-application-draft-${item.label}`"
class="application-draft-brief-item"
:class="{ 'is-primary': item.label === '单号' }"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<footer class="application-draft-footer">
<p>
完整审批链附件和明细可在单据详情中
<button
type="button"
class="application-draft-detail-link"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.openApplicationDraftDetail(message)"
>查看</button>
</p>
</footer>
</template>
<template v-else>
<header>
<strong>{{ message.draftPayload.title }}</strong>
<span>待人工确认</span>
</header>
<pre>{{ message.draftPayload.body }}</pre>
</template>
</div>
<div v-if="message.attachments?.length" class="message-files">
@@ -428,18 +460,35 @@
</span>
</div>
</div>
<div
v-if="ui.isOperationFeedbackVisible(message)"
class="message-feedback-bubble"
>
<OperationFeedbackInlineCard
:busy="Boolean(message.operationFeedback?.submitting)"
:error-message="message.operationFeedback?.error || ''"
:submitted="Boolean(message.operationFeedback?.submitted)"
:submitted-rating="Number(message.operationFeedback?.rating || 0)"
:reset-key="`${message.id}-${message.operationFeedback?.context?.runId || message.operationFeedback?.context?.run_id || ''}`"
@dismiss="ui.dismissOperationFeedbackForMessage(message)"
@submit="ui.submitOperationFeedbackForMessage(message, $event)"
/>
</div>
</article>
</template>
<script>
import BudgetAssistantReport from './BudgetAssistantReport.vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
import OperationFeedbackInlineCard from '../shared/OperationFeedbackInlineCard.vue'
export default {
name: 'TravelReimbursementMessageItem',
components: {
BudgetAssistantReport,
EnterpriseSelect
EnterpriseSelect,
OperationFeedbackInlineCard
},
props: {
message: {