feat: 报销审批流重构与管家计划全链路贯通

- 重构报销状态注册表、审批流路由与平台风险标记
- 完善管家意图规划器与模型计划构建器全链路
- 新增 OCR Worker 脚本、数据库会话管理与通知状态
- 优化文档中心、日志视图、预算中心与员工管理交互
- 增强工作台摘要、图标资源与全局主题样式
- 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-06 17:19:07 +08:00
parent f60cebadb8
commit e124e4bbcb
162 changed files with 9161 additions and 1941 deletions

View File

@@ -1,10 +1,10 @@
<template>
<div class="picker-filter" :class="{ open: activeFilterPopover === id }">
<div class="picker-filter document-filter" :class="{ open: activeFilterPopover === id }">
<button
class="picker-trigger"
class="picker-trigger filter-btn"
type="button"
:aria-expanded="activeFilterPopover === id"
aria-haspopup="dialog"
aria-haspopup="listbox"
@click="emit('toggle', id)"
>
<span class="picker-label">{{ label }}</span>
@@ -12,28 +12,21 @@
</button>
<div
v-if="activeFilterPopover === id"
class="picker-popover"
role="dialog"
class="picker-popover document-filter-menu"
role="listbox"
:aria-label="title"
>
<header>
<strong>{{ title }}</strong>
<button type="button" :aria-label="closeLabel" @click="emit('close')">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
v-for="option in options"
:key="option.value || `all-${id}`"
type="button"
class="picker-option"
:class="{ active: selectedValue === option.value }"
@click="emit('select', option.value)"
>
{{ option.label }}
</button>
</div>
<button
v-for="option in options"
:key="option.value || `all-${id}`"
type="button"
role="option"
:aria-selected="selectedValue === option.value"
:class="{ active: selectedValue === option.value }"
@click="emit('select', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</template>
@@ -56,5 +49,4 @@ defineProps({
const emit = defineEmits(['toggle', 'close', 'select'])
</script>
<style scoped src="../../assets/styles/views/audit-view.css"></style>
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>
<style scoped src="../../assets/styles/components/document-list-shared.css"></style>

View File

@@ -1,12 +1,13 @@
<template>
<article v-if="visible" class="detail-card panel run-products-card">
<div class="card-head">
<div>
<h3>本次任务产物</h3>
<p>{{ productSubtitle }}</p>
</div>
<EnterpriseDetailCard
v-if="visible"
class="run-products-card"
title="本次任务产物"
:description="productSubtitle"
>
<template #actions>
<span class="edit-badge">{{ productBadge }}</span>
</div>
</template>
<div v-if="loading" class="run-product-state">
<i class="mdi mdi-loading mdi-spin"></i>
@@ -211,12 +212,13 @@
</div>
</section>
</template>
</article>
</EnterpriseDetailCard>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import EnterpriseDetailCard from '../shared/EnterpriseDetailCard.vue'
import { fetchRunRiskObservations } from '../../services/riskObservations.js'
import {
extractWorkRecordToolSummary,

View File

@@ -157,34 +157,26 @@
</template>
</EnterpriseListPage>
<!-- 详情视图 (全屏样式,参考 AuditJsonRiskRuleDetail) -->
<div v-else key="detail" class="json-risk-editor-shell panel work-records-detail-stage">
<div v-if="detailLoading" class="work-record-detail-state panel" style="min-height: 200px; display: grid; place-items: center; border: 0;">
<TableLoadingState
variant="panel"
title="详情加载中"
message="正在读取该次工作记录的完整执行信息"
icon="mdi mdi-clipboard-text-search-outline"
/>
</div>
<div v-else-if="detailError" class="work-record-detail-state error panel" style="min-height: 200px; display: grid; place-items: center; text-align: center; border: 0; color: #dc2626;">
<i class="mdi mdi-alert-circle-outline" style="font-size: 32px; margin-bottom: 8px;"></i>
<strong>工作记录详情加载失败</strong>
<p>{{ detailError }}</p>
<button class="minor-action" type="button" @click="reloadSelectedDetail" style="margin-top: 12px;">重新加载</button>
</div>
<div v-else class="json-risk-editor-body work-record-detail-shell">
<section class="json-risk-main-stage work-record-detail-body inline-detail">
<!-- 卡片1基本信息 -->
<article class="detail-card panel json-risk-summary-card">
<div class="card-head">
<div>
<h3>基本信息</h3>
<p>此次运行的执行周期、触发来源、标识信息与最终状态。</p>
</div>
</div>
<EnterpriseDetailPage
v-else
key="detail"
variant="work-record-detail-page"
actions-class="work-record-detail-actions"
:loading="detailLoading"
:error="detailError"
error-title="工作记录详情加载失败"
loading-title="详情加载中"
loading-message="正在读取该次工作记录的完整执行信息"
loading-icon="mdi mdi-clipboard-text-search-outline"
back-label="返回工作记录列表"
@back="closeWorkRecordDetail"
>
<template #main>
<EnterpriseDetailCard
class="work-record-detail-card work-record-summary-card"
title="基本信息"
description="此次运行的执行周期触发来源标识信息与最终状态"
>
<div class="json-risk-meta-grid">
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">Run ID</span>
@@ -211,75 +203,63 @@
<span class="json-risk-meta-value">{{ resolveWorkRecordStatusNote(selectedRunDetail) || '-' }}</span>
</div>
</div>
</article>
</EnterpriseDetailCard>
<!-- 卡片2执行摘要 -->
<article class="detail-card panel json-risk-description-card">
<div class="card-head">
<div>
<h3>执行摘要</h3>
<p>本次数字员工工作流的执行内容与结果摘要。</p>
</div>
<EnterpriseDetailCard
class="work-record-detail-card work-record-summary-copy-card"
title="执行摘要"
description="本次数字员工工作流的执行内容与结果摘要"
>
<template #actions>
<span class="edit-badge">{{ resolveWorkRecordSummaryMeta(selectedRunDetail) }}</span>
</div>
<p class="json-risk-description-text" style="padding: 0 12px 12px; margin: 0;">{{ selectedRunDetail.result_summary || '暂无执行摘要。' }}</p>
<p v-if="selectedRunDetail.error_message" class="work-record-error-text" style="margin: 0 12px 12px; padding: 10px 12px; border: 1px solid #fecaca; border-radius: 4px; background: #fef2f2; color: #b91c1c;">
</template>
<p class="json-risk-description-text work-record-summary-text">{{ selectedRunDetail.result_summary || '暂无执行摘要。' }}</p>
<p v-if="selectedRunDetail.error_message" class="work-record-error-text">
{{ selectedRunDetail.error_message }}
</p>
</article>
</p>
</EnterpriseDetailCard>
<DigitalEmployeeRunProducts :run="selectedRunDetail" />
<DigitalEmployeeRunProducts :run="selectedRunDetail" />
</template>
<!-- 卡片3工具调用 -->
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>工具调用</h3>
<p>此任务在执行期间调用的外部系统/工具细节与执行状态。</p>
</div>
<template #side>
<EnterpriseDetailCard
class="work-record-detail-card"
title="工具调用"
description="此任务在执行期间调用的外部系统/工具细节与执行状态"
>
<template #actions>
<span class="edit-badge">{{ (selectedRunDetail.tool_calls || []).length }} 次调用</span>
</div>
<div v-if="(selectedRunDetail.tool_calls || []).length" class="work-record-tool-list" style="padding: 0 12px 12px; display: grid; gap: 8px;">
<article v-for="toolCall in selectedRunDetail.tool_calls" :key="toolCall.id" style="display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border: 1px solid #edf2f7; border-radius: 4px; background: #f8fafc;">
<strong style="color: #0f172a; font-size: 13px;">{{ toolCall.tool_name }}</strong>
<span style="color: #64748b; font-size: 12px;">{{ toolCall.tool_type || 'tool' }} · {{ toolCall.status || 'unknown' }}</span>
</template>
<div v-if="(selectedRunDetail.tool_calls || []).length" class="work-record-tool-list">
<article v-for="toolCall in selectedRunDetail.tool_calls" :key="toolCall.id" class="work-record-tool-item">
<strong>{{ toolCall.tool_name }}</strong>
<span>{{ toolCall.tool_type || 'tool' }} · {{ toolCall.status || 'unknown' }}</span>
</article>
</div>
<div v-else class="work-record-inline-empty" style="padding: 0 12px 12px; color: #94a3b8; font-size: 13px;">当前暂无工具调用明细。</div>
</article>
</div>
<div v-else class="work-record-inline-empty">当前暂无工具调用明细。</div>
</EnterpriseDetailCard>
<!-- 卡片4执行上下文 -->
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>执行上下文</h3>
<p>后台调度的运行时配置与状态信息JSON 格式)。</p>
</div>
</div>
<div style="padding: 0 12px 12px;">
<pre class="work-record-code-block" style="max-height: 320px; margin: 0; padding: 12px; overflow: auto; border: 1px solid #e2e8f0; border-radius: 4px; background: #0f172a; color: #e2e8f0; font-size: 12px; line-height: 1.55;">{{ formatJson(selectedRunDetail.route_json) }}</pre>
</div>
</article>
</section>
</div>
<EnterpriseDetailCard
class="work-record-detail-card"
title="执行上下文"
description="后台调度的运行时配置与状态信息JSON 格式"
>
<pre class="work-record-code-block">{{ formatJson(selectedRunDetail.route_json) }}</pre>
</EnterpriseDetailCard>
</template>
<footer class="detail-actions">
<button class="back-action" type="button" @click="closeWorkRecordDetail">
<i class="mdi mdi-arrow-left"></i>
<span>返回工作记录列表</span>
<template #actions>
<button class="minor-action" type="button" :disabled="!selectedRunDetail?.run_id" @click="openTraceCenter">
<i class="mdi mdi-timeline-text-outline"></i>
<span>查看 Trace</span>
</button>
<div class="detail-action-group">
<button class="minor-action" type="button" :disabled="!selectedRunDetail?.run_id" @click="openTraceCenter">
<i class="mdi mdi-timeline-text-outline"></i>
<span>查看 Trace</span>
</button>
<button class="minor-action" type="button" :disabled="detailLoading" @click="reloadSelectedDetail">
<i class="mdi mdi-refresh"></i>
<span>{{ detailLoading ? '刷新中...' : '刷新详情' }}</span>
</button>
</div>
</footer>
</div>
<button class="minor-action" type="button" :disabled="detailLoading" @click="reloadSelectedDetail">
<i class="mdi mdi-refresh"></i>
<span>{{ detailLoading ? '刷新中...' : '刷新详情' }}</span>
</button>
</template>
</EnterpriseDetailPage>
</Transition>
</section>
</template>
@@ -290,8 +270,9 @@ import { useRouter } from 'vue-router'
import AuditPickerFilter from './AuditPickerFilter.vue'
import DigitalEmployeeRunProducts from './DigitalEmployeeRunProducts.vue'
import EnterpriseDetailCard from '../shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../shared/EnterpriseDetailPage.vue'
import EnterpriseListPage from '../shared/EnterpriseListPage.vue'
import TableLoadingState from '../shared/TableLoadingState.vue'
import { fetchAgentRunDetail, fetchAgentRuns } from '../../services/agentAssets.js'
import { useToast } from '../../composables/useToast.js'
import {
@@ -650,103 +631,5 @@ onBeforeUnmount(() => {
<style scoped src="../../assets/styles/views/audit-view.css"></style>
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>
<style scoped>
.digital-work-records {
height: 100%;
}
.digital-employee-list-panel,
.digital-work-records-list-stage {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.digital-work-records-table {
min-width: 1180px;
table-layout: fixed;
}
.digital-work-records-table .col-time { width: 14%; }
.digital-work-records-table .col-module { width: 13%; }
.digital-work-records-table .col-source { width: 10%; }
.digital-work-records-table .col-status { width: 16%; }
.digital-work-records-table .col-summary { width: 31%; }
.digital-work-records-table .col-trace { width: 16%; }
.work-record-row {
outline: none;
}
.work-record-row:focus-visible {
box-shadow: inset 0 0 0 2px rgba(58, 124, 165, 0.28);
}
.work-record-summary-cell {
text-align: left !important;
}
.work-record-summary-cell strong,
.work-record-summary-cell span,
.work-record-summary-cell em {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-record-summary-cell strong {
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.work-record-summary-cell span {
margin-top: 4px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.work-record-summary-cell em {
margin-top: 6px;
color: #94a3b8;
font-size: 12px;
font-style: normal;
}
.work-record-trace-cell {
color: #2563eb !important;
}
.work-records-detail-stage,
.work-record-detail-shell {
flex: 1 1 0;
min-height: 0;
}
.digital-work-records :deep(.toolbar-actions .picker-filter),
.digital-work-records :deep(.toolbar-actions .picker-trigger) {
min-width: 148px;
}
.digital-refresh-now {
width: 40px;
min-width: 40px;
padding: 0;
}
.digital-refresh-now .mdi {
font-size: 18px;
}
.work-record-detail-body.inline-detail {
padding: 0;
overflow: visible;
background: transparent;
}
</style>
<style scoped src="../../assets/styles/components/digital-employee-work-records.css"></style>
<style scoped src="../../assets/styles/components/digital-employee-work-records-overrides.css"></style>

View File

@@ -9,7 +9,9 @@
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${workbenchHeroBackground})` }">
<div class="assistant-copy">
<h1>{{ displayUserName }}我是您的 <span>小财管家</span></h1>
<h1 class="assistant-hero-title">
{{ typedTitlePrefix }}<span v-if="titleTypingDone">小财管家</span><span v-if="!titleTypingDone" class="typing-cursor">|</span>
</h1>
<input
ref="fileInputRef"
@@ -173,11 +175,12 @@
<div :class="['capability-grid', capabilityGridClass]" aria-label="AI 财务助手能力">
<button
v-for="item in visibleAssistantCapabilities"
v-for="(item, index) in visibleAssistantCapabilities"
:key="item.key"
type="button"
class="capability-card panel"
:class="`capability-card--${item.tone}`"
:style="{ '--delay': `${index * 80 + 100}ms` }"
@click="openCapabilityAssistant(item)"
>
<WorkbenchListIcon
@@ -196,18 +199,19 @@
</div>
<div class="workbench-content-grid">
<article class="panel workbench-card progress-panel">
<article class="panel workbench-card progress-panel" style="--delay: 200ms;">
<div class="section-head">
<h2>费用进度</h2>
</div>
<div class="progress-list">
<button
v-for="item in visibleProgressItems"
v-for="(item, index) in visibleProgressItems"
:key="item.id"
type="button"
class="progress-row"
:class="{ 'has-long-duration-divider': item.hasLongDurationDivider }"
:style="{ '--item-index': index }"
@click="openWorkbenchTarget(item)"
>
<span class="progress-time-wrapper">
@@ -256,7 +260,7 @@
</article>
<aside class="side-column">
<article class="panel workbench-card side-panel expense-stats-panel">
<article class="panel workbench-card side-panel expense-stats-panel" style="--delay: 300ms;">
<div class="section-head side-card-head">
<h2>费用统计</h2>
<button
@@ -273,10 +277,11 @@
<div class="insight-metric-list" aria-label="费用统计">
<div
v-for="item in visibleExpenseStatItems"
v-for="(item, index) in visibleExpenseStatItems"
:key="item.key"
class="insight-metric-row"
:class="`insight-metric-row--${item.tone}`"
:style="{ '--item-index': index }"
>
<span class="insight-metric-icon" aria-hidden="true">
<i :class="item.icon"></i>
@@ -289,7 +294,7 @@
</div>
</article>
<article class="panel workbench-card side-panel usage-profile-panel">
<article class="panel workbench-card side-panel usage-profile-panel" style="--delay: 400ms;">
<div class="section-head side-card-head">
<h2>用户画像</h2>
<button
@@ -306,10 +311,11 @@
<div class="insight-profile-list" aria-label="用户画像">
<div
v-for="metric in visibleUsageProfileMetrics"
v-for="(metric, index) in visibleUsageProfileMetrics"
:key="metric.key"
class="insight-profile-card"
:class="`insight-profile-card--${metric.tone}`"
:style="{ '--item-index': index }"
>
<span class="insight-profile-icon" aria-hidden="true">
<i :class="metric.icon"></i>
@@ -441,6 +447,35 @@ const displayUserName = computed(() => {
const user = currentUser.value || {}
return String(user.name || user.username || '同事').trim() || '同事'
})
const heroTitleText = computed(() => `嗨,${displayUserName.value},我是您的 `)
const typedTitlePrefix = ref('')
const titleTypingDone = ref(false)
let typingInterval = null
const startTypewriter = () => {
typedTitlePrefix.value = ''
titleTypingDone.value = false
clearInterval(typingInterval)
let i = 0
const text = heroTitleText.value
typingInterval = setInterval(() => {
if (i < text.length) {
typedTitlePrefix.value += text.charAt(i)
i++
} else {
clearInterval(typingInterval)
titleTypingDone.value = true
}
}, 60)
}
watch(displayUserName, (newVal, oldVal) => {
if (oldVal !== newVal && titleTypingDone.value) {
typedTitlePrefix.value = `嗨,${newVal},我是您的 `
}
})
const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销'))
const isComposerPending = computed(() => Boolean(pendingAction.value))
const composerPendingLabel = computed(() => {
@@ -861,6 +896,7 @@ async function handleExpenseConversationAction() {
}
onMounted(() => {
startTypewriter()
refreshLocalExpenseSnapshot()
refreshLatestExpenseConversation()
loadCurrentEmployeeProfile()
@@ -869,6 +905,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
clearInterval(typingInterval)
clearPendingAction()
document.removeEventListener('click', handleWorkbenchDatePickerOutside)
window.removeEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)

View File

@@ -1,20 +1,25 @@
<template>
<div class="system-agent-ratio-bar">
<div class="agent-ratio-legend" aria-hidden="true">
<span v-for="agent in resolvedAgents" :key="agent.key">
<i :style="{ background: agent.resolvedColor }"></i>{{ agent.name }}
</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 } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { CustomChart as EChartsCustomChart } 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 { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, LegendComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
use([GridComponent, TooltipComponent, EChartsCustomChart, CanvasRenderer])
const props = defineProps({
labels: { type: Array, required: true },
@@ -36,36 +41,31 @@ const ariaLabel = computed(() =>
const parts = resolvedAgents.value.map((agent) => (
`${agent.name}${props.series[agent.key]?.[dayIndex] || 0}%`
))
return `${label}${parts.join('')}`
return `${label}${parts.join('')}`
}).join('')
)
const stackedAgentRatioData = computed(() => props.labels.map((_, index) => [
index,
...resolvedAgents.value.map((agent) => Number(props.series[agent.key]?.[index] || 0))
]))
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
}
},
animationDuration: 1200,
animationDurationUpdate: 1200,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
grid: {
top: 38,
top: 8,
right: 16,
bottom: 24,
left: 34,
containLabel: true
},
tooltip: {
trigger: 'axis',
trigger: 'item',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
@@ -78,7 +78,7 @@ const chartOptions = computed(() => ({
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
valueFormatter: (value) => `${value}%`
formatter: (params) => formatStackedTooltip(params)
},
xAxis: {
type: 'category',
@@ -104,32 +104,161 @@ const chartOptions = computed(() => ({
},
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
series: [{
name: '智能体调用占比',
type: 'custom',
data: stackedAgentRatioData.value,
renderItem: renderStackedAgentRatioBar,
animationDelay: (index) => index * 18,
tooltip: {
formatter: (params) => formatStackedTooltip(params)
}
}))
}]
}))
useEcharts(chartElement, chartOptions)
function renderStackedAgentRatioBar(params, api) {
const categoryIndex = Number(api.value(0))
const zeroPoint = api.coord([categoryIndex, 0])
const xCenter = zeroPoint[0]
const zeroY = zeroPoint[1]
const categoryWidth = api.size([1, 0])?.[0] || 48
const barWidth = Math.max(24, Math.min(34, categoryWidth * 0.48))
const barX = xCenter - barWidth / 2
let accumulated = 0
const values = resolvedAgents.value.map((_, index) => Number(api.value(index + 1) || 0))
const lastVisibleIndex = values.reduce((last, value, index) => (value > 0 ? index : last), -1)
const children = []
let topY = zeroY
values.forEach((value, index) => {
if (value <= 0) {
return
}
const lower = accumulated
const upper = accumulated + value
const lowerY = api.coord([categoryIndex, lower])[1]
const upperY = api.coord([categoryIndex, upper])[1]
const height = Math.max(1, lowerY - upperY)
topY = Math.min(topY, upperY)
accumulated = upper
children.push({
type: 'rect',
shape: {
x: barX,
y: upperY,
width: barWidth,
height,
r: index === lastVisibleIndex ? [4, 4, 0, 0] : 0
},
style: {
fill: resolvedAgents.value[index]?.resolvedColor || themeColors.value.chartPrimary,
stroke: index === lastVisibleIndex ? 'transparent' : '#ffffff',
lineWidth: index === lastVisibleIndex ? 0 : 1
}
})
})
if (!children.length) {
return {
type: 'group',
children: []
}
}
const totalHeight = Math.max(1, zeroY - topY)
return {
type: 'group',
originX: xCenter,
originY: zeroY,
scaleY: 1,
enterFrom: {
scaleY: 0
},
transition: ['scaleY'],
clipPath: {
type: 'rect',
shape: {
x: barX,
y: topY,
width: barWidth,
height: totalHeight
},
enterFrom: {
shape: {
x: barX,
y: zeroY,
width: barWidth,
height: 0
}
},
transition: ['shape']
},
children
}
}
function formatStackedTooltip(params) {
const index = Number(params?.data?.[0] ?? params?.dataIndex ?? 0)
const label = props.labels[index] || params?.axisValueLabel || ''
const rows = resolvedAgents.value
.map((agent) => ({
name: agent.name,
color: agent.resolvedColor,
value: Number(props.series[agent.key]?.[index] || 0)
}))
.filter((item) => item.value > 0)
const details = rows.map((item) => (
`<span style="display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px;background:${item.color};"></span>${item.name}${item.value}%`
))
return [label, ...details].join('<br/>')
}
</script>
<style scoped>
.system-agent-ratio-bar {
height: 292px;
display: flex;
flex-direction: column;
}
.agent-ratio-legend {
min-height: 30px;
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 6px 14px;
color: #475569;
font-size: 12px;
line-height: 1.4;
margin-bottom: 8px;
}
.agent-ratio-legend span {
max-width: 132px;
display: inline-flex;
align-items: center;
min-width: 0;
color: #475569;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-ratio-legend i {
flex: 0 0 auto;
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
margin-right: 5px;
}
.chart-body {
width: 100%;
height: 100%;
flex: 1;
min-height: 0;
}
</style>

View File

@@ -364,8 +364,9 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
import { useTopBarNotificationStates } from '../../composables/useTopBarNotificationStates.js'
import { useTopBarWorkbenchPopovers } from '../../composables/useTopBarWorkbenchPopovers.js'
import { createCurrentYearDateRange, formatDateValue } from '../../utils/dateRangeDefaults.js'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
import { createCurrentYearDateRange, formatDateValue } from '../../utils/dateRangeDefaults.js'
import { resolveDocumentNotificationId } from '../../utils/documentCenterNewState.js'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
const props = defineProps({
currentView: { type: Object, required: true },
@@ -520,7 +521,7 @@ function resolveWorkbenchNotificationId(item, index) {
const documentNotificationItems = computed(() =>
documentInboxNotificationRows.value
.map((row) => {
const id = normalizeNotificationId(`document:${row.documentKey || row.claimId || row.documentNo}`)
const id = normalizeNotificationId(resolveDocumentNotificationId(row))
if (!id || isNotificationHidden(id)) {
return null
}

View File

@@ -0,0 +1,57 @@
<template>
<div class="picker-filter document-filter" :class="[rootClass, { open: isOpen }]">
<button
class="picker-trigger filter-btn"
:class="triggerClass"
type="button"
:aria-expanded="isOpen"
aria-haspopup="listbox"
@click="emit('toggle', id)"
>
<i v-if="icon" :class="icon"></i>
<span class="picker-label">{{ label }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="isOpen"
class="picker-popover document-filter-menu"
:class="menuClass"
role="listbox"
:aria-label="title || label"
>
<button
v-for="option in options"
:key="option.value || `all-${id}`"
type="button"
role="option"
:aria-selected="selectedValue === option.value"
:class="{ active: selectedValue === option.value }"
@click="emit('select', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
id: { type: String, required: true },
title: { type: String, default: '' },
activeFilterKey: { type: String, default: '' },
label: { type: String, default: '' },
options: { type: Array, default: () => [] },
selectedValue: { type: [String, Number, Boolean], default: '' },
rootClass: { type: [String, Array, Object], default: '' },
triggerClass: { type: [String, Array, Object], default: '' },
menuClass: { type: [String, Array, Object], default: '' },
icon: { type: String, default: '' }
})
const emit = defineEmits(['toggle', 'select'])
const isOpen = computed(() => props.activeFilterKey === props.id)
</script>
<style scoped src="../../assets/styles/components/document-list-shared.css"></style>

View File

@@ -70,7 +70,7 @@ const props = defineProps({
severityLabel: { type: String, default: '中风险' }
})
const FONT = "Helvetica, Arial, sans-serif"
const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC', 'Segoe UI', Arial, sans-serif"
const TEXT = '#0d0d0d'
const MUTED = '#6e6e80'
const NEUTRAL_LINE = '#cbd5e1'

View File

@@ -39,13 +39,15 @@ const iconStyle = computed(() => iconMeta.value.style)
.workbench-list-icon__halo {
position: absolute;
top: 8px;
bottom: 8px;
left: 0;
width: 3px;
inset: 6px auto 6px -2px;
width: 4px;
border-radius: 2px;
background: color-mix(in srgb, var(--icon-color, var(--theme-primary)) 78%, #ffffff);
opacity: 0.72;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 78%, #ffffff),
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 28%, #ffffff)
);
opacity: 0.88;
}
.workbench-list-icon__panel {
@@ -57,18 +59,20 @@ const iconStyle = computed(() => iconMeta.value.style)
place-items: center;
overflow: hidden;
border-radius: 4px;
border: 1px solid color-mix(in srgb, var(--icon-color, var(--theme-primary)) 22%, var(--line, #e2e8f0));
border: 1px solid color-mix(in srgb, var(--icon-color, var(--theme-primary)) 24%, var(--line, #e2e8f0));
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.44)),
radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0) 48%),
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.52)),
linear-gradient(
135deg,
color-mix(in srgb, var(--icon-accent, var(--theme-primary-soft)) 64%, #fff) 0%,
#fff 52%,
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 8%, var(--surface-soft, #f8fafc)) 100%
color-mix(in srgb, var(--icon-accent, var(--theme-primary-soft)) 72%, #fff) 0%,
#fff 46%,
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 12%, var(--surface-soft, #f8fafc)) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 1px 2px rgba(15, 23, 42, 0.045);
inset 0 -1px 0 color-mix(in srgb, var(--icon-color, var(--theme-primary)) 8%, rgba(255, 255, 255, 0.9)),
0 8px 18px rgba(15, 23, 42, 0.055);
}
.workbench-list-icon__shine {
@@ -95,10 +99,37 @@ const iconStyle = computed(() => iconMeta.value.style)
}
.workbench-list-icon--outline .workbench-list-icon__art :deep(.workbench-heroicon) {
stroke-width: 1.65;
stroke-width: 1.55;
}
.workbench-list-icon__art :deep(.icon-fill) {
fill: currentColor;
stroke: none;
opacity: 0.09;
}
.workbench-list-icon__art :deep(.icon-accent) {
opacity: 0.36;
}
.workbench-list-icon__art :deep(.icon-muted) {
opacity: 0.62;
}
.workbench-list-icon--solid .workbench-list-icon__art :deep(.workbench-heroicon path) {
opacity: 0.96;
}
.workbench-list-icon__art :deep(.workbench-image-icon) {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
max-width: none;
object-fit: contain;
display: block;
filter: drop-shadow(0 4px 8px rgba(15, 23, 42, 0.15));
}
</style>

View File

@@ -148,104 +148,111 @@
</div>
</div>
<div
v-if="message.role === 'assistant' && message.applicationPreview"
class="application-preview-table"
role="table"
aria-label="申请信息核对表"
>
<div class="application-preview-row head" role="row">
<span role="columnheader">字段</span>
<span role="columnheader">内容</span>
</div>
<Transition name="structured-card-reveal" appear>
<div
v-for="row in ui.resolveApplicationPreviewRows(message)"
:key="`${message.id}-${row.key}`"
class="application-preview-row"
:class="{
missing: row.missing,
editable: row.editable && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy,
highlight: row.highlight
}"
role="row"
:tabindex="row.editable && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy ? 0 : -1"
:aria-label="row.editable ? `编辑${row.label}` : row.label"
@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)"
v-if="message.role === 'assistant' && message.applicationPreview"
class="application-preview-shell"
aria-label="申请信息核对结果"
>
<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) === 'text'"
v-model="ui.applicationPreviewEditor.draftValue"
class="application-preview-input"
type="text"
autofocus
@click.stop
@keydown.stop="ui.handleApplicationPreviewEditorKeydown($event, message)"
@blur="ui.commitApplicationPreviewEditor(message)"
/>
<EnterpriseSelect
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)"
clearable
:teleported="false"
autofocus
@click.stop
@change="ui.commitApplicationPreviewEditor(message)"
@keydown.stop="ui.handleApplicationPreviewEditorKeydown($event, message)"
@blur="ui.commitApplicationPreviewEditor(message)"
/>
<template v-else>
<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"
class="application-preview-edit-btn"
title="修改内容"
aria-label="修改内容"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click.stop="ui.openApplicationPreviewEditor(message, row.key, row.value)"
>
<i class="mdi mdi-pencil-outline"></i>
</button>
</template>
</span>
</div>
</div>
<div
v-if="message.role === 'assistant' && message.applicationPreview && ui.resolveApplicationPreviewMissingFields(message)?.length"
class="application-preview-footer application-preview-footer-missing"
aria-live="polite"
>
<span class="application-preview-missing-prefix">当前还需要补充</span>
<span class="application-preview-missing-list">
<template
v-for="(field, index) in ui.resolveApplicationPreviewMissingFields(message)"
:key="`${message.id}-missing-${field}`"
<div
class="application-preview-table"
role="table"
aria-label="申请信息核对表"
>
<span class="application-preview-missing-chip">{{ field }}</span>
<span
v-if="index < ui.resolveApplicationPreviewMissingFields(message).length - 1"
class="application-preview-missing-separator"
></span>
</template>
</span>
<span class="application-preview-missing-suffix">补齐后我再帮您提交申请</span>
</div>
<div
v-else-if="message.role === 'assistant' && message.applicationPreview && ui.buildApplicationPreviewFooterText(message)"
class="application-preview-footer message-answer-content message-answer-markdown"
v-html="ui.renderMarkdown(ui.buildApplicationPreviewFooterText(message))"
@click="ui.handleAssistantMarkdownClick($event, message)"
></div>
<div class="application-preview-row head" role="row">
<span role="columnheader">字段</span>
<span role="columnheader">内容</span>
</div>
<div
v-for="row in ui.resolveApplicationPreviewRows(message)"
:key="`${message.id}-${row.key}`"
class="application-preview-row"
:class="{
missing: row.missing,
editable: row.editable && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy,
highlight: row.highlight
}"
role="row"
:tabindex="row.editable && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy ? 0 : -1"
:aria-label="row.editable ? `编辑${row.label}` : row.label"
@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) === 'text'"
v-model="ui.applicationPreviewEditor.draftValue"
class="application-preview-input"
type="text"
autofocus
@click.stop
@keydown.stop="ui.handleApplicationPreviewEditorKeydown($event, message)"
@blur="ui.commitApplicationPreviewEditor(message)"
/>
<EnterpriseSelect
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)"
clearable
:teleported="false"
autofocus
@click.stop
@change="ui.commitApplicationPreviewEditor(message)"
@keydown.stop="ui.handleApplicationPreviewEditorKeydown($event, message)"
@blur="ui.commitApplicationPreviewEditor(message)"
/>
<template v-else>
<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"
class="application-preview-edit-btn"
title="修改内容"
aria-label="修改内容"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click.stop="ui.openApplicationPreviewEditor(message, row.key, row.value)"
>
<i class="mdi mdi-pencil-outline"></i>
</button>
</template>
</span>
</div>
</div>
<div
v-if="ui.resolveApplicationPreviewMissingFields(message)?.length"
class="application-preview-footer application-preview-footer-missing"
aria-live="polite"
>
<span class="application-preview-missing-prefix">当前还需要补充</span>
<span class="application-preview-missing-list">
<template
v-for="(field, index) in ui.resolveApplicationPreviewMissingFields(message)"
:key="`${message.id}-missing-${field}`"
>
<span class="application-preview-missing-chip">{{ field }}</span>
<span
v-if="index < ui.resolveApplicationPreviewMissingFields(message).length - 1"
class="application-preview-missing-separator"
></span>
</template>
</span>
<span class="application-preview-missing-suffix">补齐后我再帮您提交申请</span>
</div>
<div
v-else-if="ui.buildApplicationPreviewFooterText(message)"
class="application-preview-footer message-answer-content message-answer-markdown"
v-html="ui.renderMarkdown(ui.buildApplicationPreviewFooterText(message))"
@click="ui.handleAssistantMarkdownClick($event, message)"
></div>
</div>
</Transition>
<div
v-if="message.role === 'assistant' && message.welcomeQuickActions?.length"
@@ -267,32 +274,34 @@
</div>
</div>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
class="message-suggested-actions"
>
<button
v-for="action in message.suggestedActions"
:key="`${message.id}-${action.action_type}-${action.label}`"
type="button"
class="message-suggested-action-btn"
:class="{
selected: ui.isSuggestedActionSelected(message, action),
locked: message.suggestedActionsLocked
}"
:disabled="message.suggestedActionsLocked || ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.handleSuggestedAction(message, action)"
<Transition name="structured-card-reveal" appear>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
class="message-suggested-actions"
>
<span class="message-suggested-action-icon" aria-hidden="true">
<i :class="action.icon || 'mdi mdi-shape-outline'"></i>
</span>
<span class="message-suggested-action-copy">
<span class="message-suggested-action-title">{{ action.label }}</span>
<small v-if="action.description">{{ action.description }}</small>
</span>
<i class="message-suggested-action-arrow mdi mdi-arrow-right" aria-hidden="true"></i>
</button>
</div>
<button
v-for="action in message.suggestedActions"
:key="`${message.id}-${action.action_type}-${action.label}`"
type="button"
class="message-suggested-action-btn"
:class="{
selected: ui.isSuggestedActionSelected(message, action),
locked: message.suggestedActionsLocked
}"
:disabled="message.suggestedActionsLocked || ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.handleSuggestedAction(message, action)"
>
<span class="message-suggested-action-icon" aria-hidden="true">
<i :class="action.icon || 'mdi mdi-shape-outline'"></i>
</span>
<span class="message-suggested-action-copy">
<span class="message-suggested-action-title">{{ action.label }}</span>
<small v-if="action.description">{{ action.description }}</small>
</span>
<i class="message-suggested-action-arrow mdi mdi-arrow-right" aria-hidden="true"></i>
</button>
</div>
</Transition>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block">
<strong>风险标签</strong>
@@ -481,20 +490,26 @@
</footer>
</template>
<template v-else>
<div class="reimbursement-draft-card" role="group" aria-label="报销草稿已生成">
<div
class="reimbursement-draft-card"
role="group"
:aria-label="ui.canOpenDraftDetail(message) ? '报销草稿已生成' : '报销草稿待保存'"
>
<span class="reimbursement-draft-icon" aria-hidden="true">
<i class="mdi mdi-file-document-edit-outline"></i>
</span>
<div class="reimbursement-draft-main">
<strong>报销草稿已生成</strong>
<strong>{{ ui.canOpenDraftDetail(message) ? '报销草稿已生成' : '报销草稿待保存' }}</strong>
<p>
单号<span>{{ ui.resolveReimbursementDraftClaimNo(message.draftPayload) }}</span>
<button
v-if="ui.canOpenDraftDetail(message)"
type="button"
class="reimbursement-draft-link"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.openApplicationDraftDetail(message)"
>查看详情</button>
<span v-else class="reimbursement-draft-pending-detail">保存后可查看详情</span>
</p>
</div>
</div>
@@ -580,21 +595,53 @@
</span>
</div>
</div>
</div>
<div
v-if="ui.isOperationFeedbackVisible(message)"
class="message-feedback-bubble"
v-if="ui.shouldShowAssistantMessageActions(message)"
class="message-action-toolbar"
role="toolbar"
aria-label="系统消息操作"
>
<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)"
/>
<button
type="button"
class="message-action-btn"
title="复制"
aria-label="复制"
@click="ui.copyAssistantMessage(message)"
>
<i class="mdi mdi-content-copy"></i>
</button>
<button
type="button"
class="message-action-btn"
title="语音播报"
aria-label="语音播报"
@click="ui.speakAssistantMessage(message)"
>
<i class="mdi mdi-volume-high"></i>
</button>
<button
type="button"
class="message-action-btn"
:class="{ active: ui.isMessageFeedbackSelected(message, 5) }"
:disabled="Boolean(message.operationFeedback?.submitting)"
title="点赞"
aria-label="点赞"
@click="ui.submitOperationFeedbackForMessage(message, { rating: 5, reason: 'thumbs_up' })"
>
<i :class="ui.isMessageFeedbackSelected(message, 5) ? 'mdi mdi-thumb-up' : 'mdi mdi-thumb-up-outline'"></i>
</button>
<button
type="button"
class="message-action-btn"
:class="{ active: ui.isMessageFeedbackSelected(message, 1) }"
:disabled="Boolean(message.operationFeedback?.submitting)"
title="点踩"
aria-label="点踩"
@click="ui.submitOperationFeedbackForMessage(message, { rating: 1, reason: 'thumbs_down' })"
>
<i :class="ui.isMessageFeedbackSelected(message, 1) ? 'mdi mdi-thumb-down' : 'mdi mdi-thumb-down-outline'"></i>
</button>
</div>
</div>
</article>
</template>
@@ -602,14 +649,12 @@
<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,
OperationFeedbackInlineCard
EnterpriseSelect
},
props: {
message: {