feat: 新增风险图谱算法与系统仪表盘及操作反馈体系
后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL 校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计, 优化 agent 运行和编排执行链路,清理旧开发文档,前端新增 系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈 对话框和工作台日期选择器,优化报销创建和审批详情交互, 补充单元测试覆盖。
This commit is contained in:
@@ -160,7 +160,7 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<table>
|
||||
<table class="audit-asset-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ tableColumns.name }}</th>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
404
web/src/components/audit/DigitalEmployeeRunProducts.vue
Normal file
404
web/src/components/audit/DigitalEmployeeRunProducts.vue
Normal 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>
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -61,28 +61,41 @@
|
||||
</section>
|
||||
|
||||
<section class="profile-panel profile-radar-panel" aria-label="行为雷达图">
|
||||
<div class="profile-section-title">
|
||||
<div class="profile-section-title profile-radar-title">
|
||||
<div>
|
||||
<span>行为雷达</span>
|
||||
<small>分数越高,行为特征越明显</small>
|
||||
<small>{{ currentRadarView.description }}</small>
|
||||
</div>
|
||||
<ElSelect
|
||||
v-model="selectedRadarView"
|
||||
class="profile-radar-view-select"
|
||||
size="small"
|
||||
aria-label="切换行为雷达视角"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in radarViewOptions"
|
||||
:key="option.value"
|
||||
:label="option.shortLabel"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
|
||||
<div v-if="radarDimensions.length" class="profile-radar-layout">
|
||||
<div v-if="filteredRadarDimensions.length" class="profile-radar-layout">
|
||||
<RadarChart
|
||||
:key="radarRenderKey"
|
||||
class="profile-radar-chart"
|
||||
:items="radarDimensions"
|
||||
label="用户画像评分"
|
||||
:items="filteredRadarDimensions"
|
||||
:label="`${currentRadarView.shortLabel}评分`"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="profile-panel-empty profile-radar-empty">暂无可展示的雷达维度。</p>
|
||||
|
||||
<div v-if="tags.length" class="profile-behavior-tags" aria-label="行为标签">
|
||||
<div :class="['profile-behavior-tags', { 'is-empty': !filteredBehaviorTags.length }]" :aria-hidden="!filteredBehaviorTags.length" aria-label="行为标签">
|
||||
<span class="profile-behavior-tags-title">行为标签:</span>
|
||||
<div class="profile-behavior-tag-list">
|
||||
<div v-if="filteredBehaviorTags.length" class="profile-behavior-tag-list">
|
||||
<span
|
||||
v-for="tag in tags"
|
||||
v-for="tag in filteredBehaviorTags"
|
||||
:key="`behavior-${tag.code}`"
|
||||
:class="[
|
||||
'profile-behavior-tag',
|
||||
@@ -137,10 +150,12 @@
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { ElButton } from 'element-plus/es/components/button/index.mjs'
|
||||
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
|
||||
import { ElOption, ElSelect } from 'element-plus/es/components/select/index.mjs'
|
||||
import { ElTag } from 'element-plus/es/components/tag/index.mjs'
|
||||
|
||||
import ExpenseProfileTagPager from './ExpenseProfileTagPager.vue'
|
||||
import RadarChart from '../charts/RadarChart.vue'
|
||||
import { USER_PROFILE_RADAR_VIEW_OPTIONS, filterUserProfileRadarDimensions, filterUserProfileTagsByRadarView } from '../../utils/employeeProfileViewModel.js'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
@@ -148,6 +163,7 @@ const props = defineProps({
|
||||
metrics: { type: Array, default: () => [] },
|
||||
tags: { type: Array, default: () => [] },
|
||||
radarDimensions: { type: Array, default: () => [] },
|
||||
radarDefaultView: { type: String, default: 'financial_risk' },
|
||||
operations: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
errorMessage: { type: String, default: '' },
|
||||
@@ -156,6 +172,11 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const radarRenderKey = ref(0)
|
||||
const selectedRadarView = ref(props.radarDefaultView)
|
||||
const radarViewOptions = USER_PROFILE_RADAR_VIEW_OPTIONS
|
||||
const currentRadarView = computed(() => radarViewOptions.find((option) => option.value === selectedRadarView.value) || radarViewOptions[0])
|
||||
const filteredRadarDimensions = computed(() => filterUserProfileRadarDimensions(props.radarDimensions, selectedRadarView.value))
|
||||
const filteredBehaviorTags = computed(() => filterUserProfileTagsByRadarView(props.tags, selectedRadarView.value))
|
||||
|
||||
function emitClose() {
|
||||
emit('close')
|
||||
@@ -225,6 +246,27 @@ watch(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.radarDefaultView,
|
||||
(value) => {
|
||||
selectedRadarView.value = value || 'financial_risk'
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
[filteredRadarDimensions, selectedRadarView],
|
||||
async () => {
|
||||
if (!props.visible) {
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
scheduleRadarFrame(() => {
|
||||
radarRenderKey.value += 1
|
||||
})
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -469,6 +511,21 @@ watch(
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.profile-radar-title { align-items: flex-start; }
|
||||
|
||||
.profile-radar-view-select {
|
||||
width: 118px;
|
||||
flex: 0 0 118px;
|
||||
}
|
||||
.profile-radar-view-select :deep(.el-select__wrapper) {
|
||||
min-height: 28px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 1px #cbd5e1 inset;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.profile-operation-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -536,9 +593,12 @@ watch(
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: 10px;
|
||||
min-height: 59px;
|
||||
border-top: 1px solid #e8eef5;
|
||||
}
|
||||
|
||||
.profile-behavior-tags.is-empty { visibility: hidden; }
|
||||
|
||||
.profile-behavior-tags-title {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -27,9 +27,35 @@
|
||||
maxlength="1000"
|
||||
rows="2"
|
||||
placeholder="请输入费用申请、报销问题、预算查询或制度问答..."
|
||||
:readonly="isComposerPending"
|
||||
@keydown.enter.prevent="handleWorkbenchEnter"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="composerPendingLabel"
|
||||
class="assistant-intent-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>{{ composerPendingLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="workbenchDateTagLabel" class="workbench-date-chip-row">
|
||||
<span class="workbench-date-chip">
|
||||
<i class="mdi mdi-calendar-check"></i>
|
||||
<span>{{ workbenchDateTagLabel }}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="移除日期"
|
||||
:disabled="Boolean(pendingAction)"
|
||||
@click="removeWorkbenchDateTag"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="composer-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
@@ -42,16 +68,70 @@
|
||||
<i class="mdi mdi-paperclip"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="composer-related-button"
|
||||
:disabled="Boolean(pendingAction)"
|
||||
@click="triggerFileUpload"
|
||||
>
|
||||
<i class="mdi mdi-source-branch"></i>
|
||||
<span>关联单据</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div class="workbench-date-anchor">
|
||||
<button
|
||||
type="button"
|
||||
class="composer-icon-button"
|
||||
:class="{ active: workbenchDatePickerOpen }"
|
||||
title="选择日期"
|
||||
aria-label="选择日期"
|
||||
:aria-expanded="workbenchDatePickerOpen"
|
||||
:disabled="Boolean(pendingAction)"
|
||||
@click.stop="toggleWorkbenchDatePicker"
|
||||
>
|
||||
<i class="mdi mdi-calendar-range"></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="workbenchDatePickerOpen"
|
||||
class="composer-date-popover"
|
||||
role="dialog"
|
||||
aria-label="日期选择"
|
||||
@click.stop
|
||||
>
|
||||
<div class="composer-date-mode-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="composer-date-mode-btn"
|
||||
:class="{ active: workbenchDateMode === 'single' }"
|
||||
@click="setWorkbenchDateMode('single')"
|
||||
>
|
||||
当天
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="composer-date-mode-btn"
|
||||
:class="{ active: workbenchDateMode === 'range' }"
|
||||
@click="setWorkbenchDateMode('range')"
|
||||
>
|
||||
时间段
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="workbenchDateMode === 'single'" class="composer-date-fields">
|
||||
<label class="composer-date-field">
|
||||
<span>日期</span>
|
||||
<input v-model="workbenchSingleDate" type="date" @change="handleWorkbenchDateInputChange('single')" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="composer-date-fields composer-date-fields-range">
|
||||
<label class="composer-date-field">
|
||||
<span>开始</span>
|
||||
<input v-model="workbenchRangeStartDate" type="date" @change="handleWorkbenchDateInputChange('range-start')" />
|
||||
</label>
|
||||
<span class="composer-date-range-sep">至</span>
|
||||
<label class="composer-date-field">
|
||||
<span>结束</span>
|
||||
<input v-model="workbenchRangeEndDate" type="date" :min="workbenchRangeStartDate" @change="handleWorkbenchDateInputChange('range-end')" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="workbenchDateMode === 'range' && !workbenchCanApplyDateSelection" class="composer-date-hint">
|
||||
请确认结束日期不早于开始日期。
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="composer-count">{{ assistantDraft.length }}/1000</span>
|
||||
|
||||
@@ -59,10 +139,10 @@
|
||||
type="button"
|
||||
class="composer-send-button"
|
||||
:disabled="Boolean(pendingAction)"
|
||||
:aria-label="pendingAction === 'expense' ? '处理中' : expenseActionLabel"
|
||||
:aria-label="composerPendingLabel || expenseActionLabel"
|
||||
@click="handleExpenseConversationAction"
|
||||
>
|
||||
<i :class="pendingAction === 'expense' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
|
||||
<i :class="pendingAction ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,7 +178,7 @@
|
||||
type="button"
|
||||
class="capability-card panel"
|
||||
:class="`capability-card--${item.tone}`"
|
||||
@click="openPromptAssistant(item.prompt)"
|
||||
@click="openCapabilityAssistant(item)"
|
||||
>
|
||||
<span class="capability-icon"><i :class="item.icon"></i></span>
|
||||
<span class="capability-copy">
|
||||
@@ -263,6 +343,7 @@
|
||||
:metrics="expenseProfileModalMetrics"
|
||||
:tags="expenseProfileTags"
|
||||
:radar-dimensions="expenseProfileRadarDimensions"
|
||||
:radar-default-view="expenseProfileRadarDefaultView"
|
||||
:operations="expenseProfileOperations"
|
||||
:loading="employeeProfileLoading"
|
||||
:error-message="employeeProfileError"
|
||||
@@ -280,6 +361,7 @@ import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
|
||||
import homepageBackground from '../../assets/homepage_backgraound.png'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposerDate.js'
|
||||
import {
|
||||
assistantCapabilities,
|
||||
buildExpenseStatItems,
|
||||
@@ -295,15 +377,16 @@ import {
|
||||
ASSISTANT_SESSION_SNAPSHOT_EVENT,
|
||||
hasAssistantSessionSnapshot
|
||||
} from '../../utils/assistantSessionSnapshot.js'
|
||||
import { buildWorkbenchCapabilityAssistantPayload } from '../../utils/personalWorkbenchAssistantEntry.js'
|
||||
import {
|
||||
buildProfileOperationsFromAgentRuns,
|
||||
buildUserProfileMetricCards,
|
||||
buildUserProfileSummaryMetrics,
|
||||
normalizeUserProfileRadarDimensions,
|
||||
normalizeUserProfileTags,
|
||||
resolveUserProfileDefaultRadarView,
|
||||
resolveCurrentUserProfileError
|
||||
} from '../../utils/employeeProfileViewModel.js'
|
||||
|
||||
const props = defineProps({
|
||||
showHeader: { type: Boolean, default: true },
|
||||
assistantModalOpen: { type: Boolean, default: false },
|
||||
@@ -318,6 +401,27 @@ const assistantInputRef = ref(null)
|
||||
const fileInputRef = ref(null)
|
||||
const selectedFiles = ref([])
|
||||
const pendingAction = ref('')
|
||||
let pendingActionTimer = 0
|
||||
const {
|
||||
workbenchDatePickerOpen,
|
||||
workbenchDateMode,
|
||||
workbenchSingleDate,
|
||||
workbenchRangeStartDate,
|
||||
workbenchRangeEndDate,
|
||||
workbenchDateTagLabel,
|
||||
workbenchCanApplyDateSelection,
|
||||
clearWorkbenchDateSelection,
|
||||
toggleWorkbenchDatePicker,
|
||||
closeWorkbenchDatePicker,
|
||||
setWorkbenchDateMode,
|
||||
handleWorkbenchDatePickerOutside,
|
||||
handleWorkbenchDateInputChange,
|
||||
removeWorkbenchDateTag,
|
||||
buildWorkbenchPromptText
|
||||
} = useWorkbenchComposerDate({
|
||||
draft: assistantDraft,
|
||||
focusInput: focusAssistantInput
|
||||
})
|
||||
const latestExpenseConversation = ref(null)
|
||||
const hasLocalExpenseSnapshot = ref(false)
|
||||
const expenseProfileModalOpen = ref(false)
|
||||
@@ -342,6 +446,16 @@ const displayUserName = computed(() => {
|
||||
return String(user.name || user.username || '同事').trim() || '同事'
|
||||
})
|
||||
const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销'))
|
||||
const isComposerPending = computed(() => Boolean(pendingAction.value))
|
||||
const composerPendingLabel = computed(() => {
|
||||
if (pendingAction.value === 'intent') {
|
||||
return '正在识别意图,准备进入对应助手...'
|
||||
}
|
||||
if (pendingAction.value === 'expense') {
|
||||
return '正在恢复最近报销会话...'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const currentRoleCodes = computed(() => {
|
||||
const user = currentUser.value || {}
|
||||
const rawCodes = Array.isArray(user.roleCodes)
|
||||
@@ -387,6 +501,7 @@ const expenseProfileModalMetrics = computed(() => {
|
||||
})
|
||||
const expenseProfileTags = computed(() => normalizeUserProfileTags(employeeProfile.value))
|
||||
const expenseProfileRadarDimensions = computed(() => normalizeUserProfileRadarDimensions(employeeProfile.value))
|
||||
const expenseProfileRadarDefaultView = computed(() => resolveUserProfileDefaultRadarView(employeeProfile.value))
|
||||
const expenseProfileOperations = computed(() =>
|
||||
buildProfileOperationsFromAgentRuns(employeeProfileRuns.value, currentUser.value)
|
||||
)
|
||||
@@ -446,7 +561,7 @@ function resolveCurrentUserId() {
|
||||
|
||||
function buildAssistantPayload() {
|
||||
return {
|
||||
prompt: assistantDraft.value.trim(),
|
||||
prompt: buildWorkbenchPromptText(),
|
||||
source: 'workbench',
|
||||
files: Array.from(selectedFiles.value)
|
||||
}
|
||||
@@ -462,6 +577,34 @@ function clearSelectedFiles() {
|
||||
function resetWorkbenchDraft() {
|
||||
assistantDraft.value = ''
|
||||
clearSelectedFiles()
|
||||
clearWorkbenchDateSelection()
|
||||
}
|
||||
|
||||
function clearPendingAction() {
|
||||
pendingAction.value = ''
|
||||
if (pendingActionTimer) {
|
||||
window.clearTimeout(pendingActionTimer)
|
||||
pendingActionTimer = 0
|
||||
}
|
||||
}
|
||||
|
||||
function startPendingAction(action) {
|
||||
clearPendingAction()
|
||||
pendingAction.value = action
|
||||
pendingActionTimer = window.setTimeout(() => {
|
||||
if (pendingAction.value !== action) {
|
||||
return
|
||||
}
|
||||
clearPendingAction()
|
||||
toast('进入助手耗时较长,请稍后重试。')
|
||||
}, 16000)
|
||||
}
|
||||
|
||||
function shouldShowIntentPending(payload = {}) {
|
||||
return !props.assistantModalOpen
|
||||
&& String(payload.prompt || '').trim()
|
||||
&& String(payload.source || 'workbench').trim() === 'workbench'
|
||||
&& !String(payload.sessionType || '').trim()
|
||||
}
|
||||
|
||||
function emitAssistant(payload) {
|
||||
@@ -492,12 +635,24 @@ function openPromptAssistant(prompt) {
|
||||
return
|
||||
}
|
||||
|
||||
emitAssistant({
|
||||
prompt: String(prompt || '').trim(),
|
||||
const payload = {
|
||||
prompt: buildWorkbenchPromptText(prompt),
|
||||
source: 'workbench',
|
||||
files: Array.from(selectedFiles.value),
|
||||
conversation: null
|
||||
})
|
||||
}
|
||||
if (shouldShowIntentPending(payload)) {
|
||||
startPendingAction('intent')
|
||||
}
|
||||
emitAssistant(payload)
|
||||
}
|
||||
|
||||
function openCapabilityAssistant(item) {
|
||||
if (pendingAction.value) {
|
||||
return
|
||||
}
|
||||
|
||||
emitAssistant(buildWorkbenchCapabilityAssistantPayload(item, buildAssistantPayload()))
|
||||
}
|
||||
|
||||
async function loadCurrentEmployeeProfile() {
|
||||
@@ -597,6 +752,9 @@ async function handleExpenseConversationAction() {
|
||||
const shouldOpenImmediately = Boolean(nextPayload.prompt || nextPayload.files.length)
|
||||
|
||||
if (shouldOpenImmediately) {
|
||||
if (shouldShowIntentPending(nextPayload)) {
|
||||
startPendingAction('intent')
|
||||
}
|
||||
emitAssistant({
|
||||
...nextPayload,
|
||||
conversation: null
|
||||
@@ -607,7 +765,7 @@ async function handleExpenseConversationAction() {
|
||||
return
|
||||
}
|
||||
|
||||
pendingAction.value = 'expense'
|
||||
startPendingAction('expense')
|
||||
|
||||
try {
|
||||
await clearKnowledgeHistoryBeforeExpense()
|
||||
@@ -621,7 +779,7 @@ async function handleExpenseConversationAction() {
|
||||
console.warn('Failed to open expense conversation:', error)
|
||||
toast(error?.message || '打开报销会话失败,请稍后重试。')
|
||||
} finally {
|
||||
pendingAction.value = ''
|
||||
clearPendingAction()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,16 +787,22 @@ onMounted(() => {
|
||||
refreshLocalExpenseSnapshot()
|
||||
refreshLatestExpenseConversation()
|
||||
loadCurrentEmployeeProfile()
|
||||
document.addEventListener('click', handleWorkbenchDatePickerOutside)
|
||||
window.addEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPendingAction()
|
||||
document.removeEventListener('click', handleWorkbenchDatePickerOutside)
|
||||
window.removeEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.assistantModalOpen,
|
||||
(open, previous) => {
|
||||
if (open) {
|
||||
clearPendingAction()
|
||||
}
|
||||
if (previous && !open) {
|
||||
refreshLatestExpenseConversation()
|
||||
}
|
||||
@@ -653,5 +817,6 @@ watch(currentUserProfileKey, (nextKey, previousKey) => {
|
||||
</script>
|
||||
|
||||
<style scoped src="../../assets/styles/components/personal-workbench.css"></style>
|
||||
<style scoped src="../../assets/styles/components/personal-workbench-composer-date.css"></style>
|
||||
<style scoped src="../../assets/styles/components/personal-workbench-insights.css"></style>
|
||||
<style scoped src="../../assets/styles/components/personal-workbench-responsive.css"></style>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
143
web/src/components/charts/RiskDailyTrendChart.vue
Normal file
143
web/src/components/charts/RiskDailyTrendChart.vue
Normal 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>
|
||||
146
web/src/components/charts/SystemAccuracyCompareBar.vue
Normal file
146
web/src/components/charts/SystemAccuracyCompareBar.vue
Normal 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>
|
||||
135
web/src/components/charts/SystemAgentRatioBar.vue
Normal file
135
web/src/components/charts/SystemAgentRatioBar.vue
Normal 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>
|
||||
141
web/src/components/charts/SystemLoadHeatmap.vue
Normal file
141
web/src/components/charts/SystemLoadHeatmap.vue
Normal 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>
|
||||
197
web/src/components/charts/SystemLoginWaveChart.vue
Normal file
197
web/src/components/charts/SystemLoginWaveChart.vue
Normal 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>
|
||||
165
web/src/components/charts/SystemTokenDailyWaveChart.vue
Normal file
165
web/src/components/charts/SystemTokenDailyWaveChart.vue
Normal 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>
|
||||
113
web/src/components/charts/SystemTokenTreemap.vue
Normal file
113
web/src/components/charts/SystemTokenTreemap.vue
Normal 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>
|
||||
245
web/src/components/charts/SystemTrendChart.vue
Normal file
245
web/src/components/charts/SystemTrendChart.vue
Normal 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>
|
||||
199
web/src/components/charts/SystemUserTokenPie.vue
Normal file
199
web/src/components/charts/SystemUserTokenPie.vue
Normal 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>
|
||||
708
web/src/components/dashboard/RiskObservationDashboard.vue
Normal file
708
web/src/components/dashboard/RiskObservationDashboard.vue
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
303
web/src/components/shared/OperationFeedbackDialog.vue
Normal file
303
web/src/components/shared/OperationFeedbackDialog.vue
Normal 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>
|
||||
474
web/src/components/shared/OperationFeedbackInlineCard.vue
Normal file
474
web/src/components/shared/OperationFeedbackInlineCard.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
381
web/src/components/travel/RiskObservationEvidenceCard.vue
Normal file
381
web/src/components/travel/RiskObservationEvidenceCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user