feat(web): 工作台 AI 模式与差旅/风险建议交互优化

- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源
- 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore
  及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿
- 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局
- 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
This commit is contained in:
caoxiaozhu
2026-06-18 22:12:24 +08:00
parent a6674a1e76
commit 0cde1f8990
65 changed files with 8011 additions and 1608 deletions

View File

@@ -7,168 +7,35 @@
note="把费用申请、报销进度、制度问答和待办处理集中到一个入口。"
/>
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${workbenchHeroBackground})` }">
<div class="assistant-copy">
<h1 class="assistant-hero-title">
{{ typedTitlePrefix }}<span v-if="titleTypingDone">小财管家</span><span v-if="!titleTypingDone" class="typing-cursor">|</span>
</h1>
<article class="panel workbench-trend-hero">
<div class="workbench-trend-card" aria-label="报销趋势同比">
<div class="trend-summary-panel">
<h1>报销趋势</h1>
<p>{{ reimbursementTrendRangeLabel }}</p>
<strong class="trend-total">{{ reimbursementTrendTotalLabel }}</strong>
<span class="trend-change" :class="reimbursementTrendGrowthTone">
<i :class="reimbursementTrendGrowthIcon" aria-hidden="true"></i>
{{ reimbursementTrendGrowthLabel }} 同比去年同期
</span>
<small>{{ displayUserName }} · {{ reimbursementTrendSignalLabel }}</small>
</div>
<input
ref="fileInputRef"
class="assistant-file-input"
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
@change="handleWorkbenchFilesChange"
/>
<div class="trend-chart-panel">
<div class="trend-chart-head">
<strong>月度报销明细</strong>
<span class="trend-chart-source">与分析看板同源</span>
</div>
<div class="assistant-composer">
<textarea
ref="assistantInputRef"
v-model="assistantDraft"
maxlength="1000"
rows="2"
placeholder="一次性描述申请、报销和附件处理事项,小财管家会先拆解再执行..."
:readonly="isComposerPending"
@keydown.enter.prevent="handleWorkbenchEnter"
<TrendChart
class="workbench-trend-chart"
mode="compareAmount"
:labels="reimbursementTrendLabels"
:claim-amount="reimbursementTrendAmounts"
:comparison-amount="reimbursementTrendPreviousAmounts"
primary-label="本期"
comparison-label="去年同期"
compact
/>
<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"
class="composer-icon-button"
title="上传附件"
aria-label="上传附件"
:disabled="Boolean(pendingAction)"
@click="triggerFileUpload"
>
<i class="mdi mdi-paperclip"></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>
<button
type="button"
class="composer-send-button"
:disabled="Boolean(pendingAction)"
:aria-label="composerPendingLabel || expenseActionLabel"
@click="handleExpenseConversationAction"
>
<i :class="pendingAction ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
</button>
</div>
</div>
<div v-if="selectedFiles.length" class="assistant-file-strip">
<span class="assistant-file-note">已带入 {{ selectedFiles.length }} 份附件</span>
<span v-for="file in selectedFiles" :key="file.name" class="assistant-file-chip">{{ file.name }}</span>
<button type="button" class="assistant-file-clear" @click="clearSelectedFiles">清空</button>
</div>
<div class="quick-prompts" aria-label="常用提问">
<span>常用提问</span>
<button
v-for="prompt in quickPromptItems"
:key="prompt"
type="button"
@click="applyQuickPrompt(prompt)"
>
{{ prompt }}
</button>
<button type="button" class="quick-more" @click="emit('open-assistant')">
更多
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
</div>
</article>
@@ -303,29 +170,21 @@
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import TrendChart from '../charts/TrendChart.vue'
import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue'
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
import PersonalWorkbenchProgressPanel from './PersonalWorkbenchProgressPanel.vue'
import workbenchHeroBackground from '../../assets/images/hero-3d-banner.png'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposerDate.js'
import {
buildExpenseStatItems,
filterAssistantCapabilitiesForUser,
quickPromptItems,
resolveWorkbenchCapabilityGridClass,
} from '../../data/personalWorkbench.js'
import { fetchAgentRuns } from '../../services/agentAssets.js'
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
import { fetchCurrentEmployeeLatestProfile } from '../../services/reimbursements.js'
import {
ASSISTANT_SESSION_SNAPSHOT_EVENT,
hasAssistantSessionSnapshot
} from '../../utils/assistantSessionSnapshot.js'
import { buildWorkbenchCapabilityAssistantPayload } from '../../utils/personalWorkbenchAssistantEntry.js'
import {
buildProfileOperationsFromAgentRuns,
@@ -344,35 +203,6 @@ const props = defineProps({
const emit = defineEmits(['open-assistant', 'open-document'])
const { currentUser } = useSystemState()
const { toast } = useToast()
const assistantDraft = ref('')
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 expenseStatsModalOpen = ref(false)
const expenseProfileModalOpen = ref(false)
const employeeProfile = ref(null)
@@ -380,59 +210,13 @@ const employeeProfileRuns = ref([])
const employeeProfileLoading = ref(false)
const employeeProfileError = ref('')
let employeeProfileLoadSeq = 0
const MAX_ATTACHMENTS = 10
const SESSION_TYPE_EXPENSE = 'expense'
const SESSION_TYPE_KNOWLEDGE = 'knowledge'
const SESSION_TYPE_STEWARD = 'steward'
const hasExpenseConversation = computed(() =>
Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId)
|| hasLocalExpenseSnapshot.value
)
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(() => {
if (pendingAction.value === 'intent') {
return '正在识别意图,准备进入对应助手...'
}
if (pendingAction.value === 'expense') {
return '正在恢复最近报销会话...'
}
return ''
})
const visibleAssistantCapabilities = computed(() => filterAssistantCapabilitiesForUser(currentUser.value))
const capabilityGridClass = computed(() => resolveWorkbenchCapabilityGridClass(currentUser.value))
const expenseStatItems = computed(() => buildExpenseStatItems(props.workbenchSummary))
@@ -468,133 +252,100 @@ const currentUserProfileKey = computed(() => {
const user = currentUser.value || {}
return [user.username, user.email, user.name, user.employeeNo, user.employee_no].map((item) => String(item || '').trim()).filter(Boolean).join('|')
})
function buildSelectedFileKey(file) {
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
function formatCurrencyValue(value) {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(Number(value) || 0)
}
function mergeSelectedFiles(existingFiles, incomingFiles) {
const nextFiles = []
const seen = new Set()
for (const file of existingFiles) {
const key = buildSelectedFileKey(file)
if (seen.has(key)) continue
seen.add(key)
nextFiles.push(file)
}
let overflowCount = 0
for (const file of incomingFiles) {
const key = buildSelectedFileKey(file)
if (seen.has(key)) continue
if (nextFiles.length >= MAX_ATTACHMENTS) {
overflowCount += 1
continue
function normalizeTrendRows(rows = []) {
return rows.map((row, index) => {
const amount = Number(row?.amount || 0)
const previousAmount = Number(row?.previousAmount || row?.previous_amount || 0)
return {
key: String(row?.key || `trend-${index}`),
label: String(row?.label || `${index + 1}`),
amount,
amountLabel: String(row?.amountLabel || row?.amount_label || formatCurrencyValue(amount)),
previousKey: String(row?.previousKey || row?.previous_key || `previous-${index}`),
previousAmount,
previousAmountLabel: String(
row?.previousAmountLabel || row?.previous_amount_label || formatCurrencyValue(previousAmount)
)
}
seen.add(key)
nextFiles.push(file)
}
return {
files: nextFiles,
overflowCount
}
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
})
}
const sourceReimbursementTrendRows = computed(() => normalizeTrendRows(props.workbenchSummary.reimbursementTrendRows || []))
const reimbursementTrendHasSignal = computed(() =>
sourceReimbursementTrendRows.value.some((item) => item.amount > 0 || item.previousAmount > 0)
)
const reimbursementTrendRows = computed(() => sourceReimbursementTrendRows.value)
const reimbursementTrendSignalLabel = computed(() =>
reimbursementTrendHasSignal.value ? '来自你的真实单据' : '暂无单据时展示空走势'
)
const reimbursementTrendLabels = computed(() => reimbursementTrendRows.value.map((item) => item.label))
const reimbursementTrendAmounts = computed(() => reimbursementTrendRows.value.map((item) => item.amount))
const reimbursementTrendPreviousAmounts = computed(() => reimbursementTrendRows.value.map((item) => item.previousAmount))
const reimbursementTrendTotal = computed(() =>
reimbursementTrendRows.value.reduce((total, item) => total + item.amount, 0)
)
const reimbursementTrendPreviousTotal = computed(() =>
reimbursementTrendRows.value.reduce((total, item) => total + item.previousAmount, 0)
)
const reimbursementTrendTotalLabel = computed(() => formatCurrencyValue(reimbursementTrendTotal.value))
const reimbursementTrendRangeLabel = computed(() => {
const rows = reimbursementTrendRows.value
const first = rows[0]
const last = rows[rows.length - 1]
if (!first || !last) {
return '近 6 个月'
}
return `${first.label} - ${last.label}`
})
const reimbursementTrendGrowthRate = computed(() => {
const previousTotal = reimbursementTrendPreviousTotal.value
if (previousTotal > 0) {
return ((reimbursementTrendTotal.value - previousTotal) / previousTotal) * 100
}
return reimbursementTrendTotal.value > 0 ? 100 : 0
})
const reimbursementTrendGrowthLabel = computed(() => {
const value = reimbursementTrendGrowthRate.value
const prefix = value >= 0 ? '+' : ''
return `${prefix}${value.toFixed(1)}%`
})
const reimbursementTrendGrowthTone = computed(() =>
reimbursementTrendGrowthRate.value >= 0 ? 'is-up' : 'is-down'
)
const reimbursementTrendGrowthIcon = computed(() =>
reimbursementTrendGrowthRate.value >= 0 ? 'mdi mdi-arrow-up-right' : 'mdi mdi-arrow-down-right'
)
function buildAssistantPayload() {
return {
prompt: buildWorkbenchPromptText(),
prompt: '',
source: 'workbench',
sessionType: SESSION_TYPE_STEWARD,
files: Array.from(selectedFiles.value)
files: []
}
}
function clearSelectedFiles() {
selectedFiles.value = []
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
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) {
emit('open-assistant', payload)
resetWorkbenchDraft()
}
async function loadLatestConversation() {
const payload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
preferRecoverable: true
})
return payload?.found ? payload.conversation || null : null
}
function focusAssistantInput() {
nextTick(() => {
assistantInputRef.value?.focus()
})
}
function applyQuickPrompt(prompt) {
assistantDraft.value = String(prompt || '').trim()
focusAssistantInput()
}
function openPromptAssistant(prompt, sessionType = SESSION_TYPE_STEWARD) {
if (pendingAction.value) {
return
}
const payload = {
prompt: buildWorkbenchPromptText(prompt),
emitAssistant({
prompt: String(prompt || '').trim(),
source: 'workbench',
sessionType,
files: Array.from(selectedFiles.value),
files: [],
conversation: null
}
if (shouldShowIntentPending(payload)) {
startPendingAction('intent')
}
emitAssistant(payload)
})
}
function openWorkbenchTarget(item) {
@@ -614,10 +365,6 @@ function openWorkbenchTarget(item) {
}
function openCapabilityAssistant(item) {
if (pendingAction.value) {
return
}
emitAssistant(buildWorkbenchCapabilityAssistantPayload(item, buildAssistantPayload()))
}
@@ -669,122 +416,10 @@ function closeExpenseProfileModal() {
expenseProfileModalOpen.value = false
}
function handleWorkbenchEnter(event) {
if (event.isComposing) {
return
}
handleExpenseConversationAction()
}
function triggerFileUpload() {
fileInputRef.value?.click()
}
function handleWorkbenchFilesChange(event) {
const mergeResult = mergeSelectedFiles(selectedFiles.value, Array.from(event.target.files ?? []))
selectedFiles.value = mergeResult.files
if (mergeResult.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
async function refreshLatestExpenseConversation() {
refreshLocalExpenseSnapshot()
try {
latestExpenseConversation.value = await loadLatestConversation()
} catch (error) {
console.warn('Failed to refresh latest expense conversation:', error)
latestExpenseConversation.value = null
}
}
function refreshLocalExpenseSnapshot() {
hasLocalExpenseSnapshot.value = hasAssistantSessionSnapshot(resolveCurrentUserId(), SESSION_TYPE_EXPENSE)
}
function handleAssistantSessionSnapshotChange(event) {
const sessionType = String(event?.detail?.sessionType || '').trim()
if (!sessionType || sessionType === SESSION_TYPE_EXPENSE) {
refreshLocalExpenseSnapshot()
}
}
async function clearKnowledgeHistoryBeforeExpense() {
await clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE)
}
async function handleExpenseConversationAction() {
if (pendingAction.value) {
return
}
const nextPayload = buildAssistantPayload()
const shouldOpenImmediately = Boolean(nextPayload.prompt || nextPayload.files.length)
if (shouldOpenImmediately) {
if (shouldShowIntentPending(nextPayload)) {
startPendingAction('intent')
}
emitAssistant({
...nextPayload,
conversation: null
})
void clearKnowledgeHistoryBeforeExpense().catch((error) => {
console.warn('Failed to clear knowledge history before expense:', error)
})
return
}
startPendingAction('expense')
try {
await clearKnowledgeHistoryBeforeExpense()
const conversation = await loadLatestConversation()
latestExpenseConversation.value = conversation
emitAssistant({
...nextPayload,
conversation
})
} catch (error) {
console.warn('Failed to open expense conversation:', error)
toast(error?.message || '打开报销会话失败,请稍后重试。')
} finally {
clearPendingAction()
}
}
onMounted(() => {
startTypewriter()
refreshLocalExpenseSnapshot()
refreshLatestExpenseConversation()
loadCurrentEmployeeProfile()
document.addEventListener('click', handleWorkbenchDatePickerOutside)
window.addEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
onBeforeUnmount(() => {
clearInterval(typingInterval)
clearPendingAction()
document.removeEventListener('click', handleWorkbenchDatePickerOutside)
window.removeEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
watch(
() => props.assistantModalOpen,
(open, previous) => {
if (open) {
clearPendingAction()
}
if (previous && !open) {
refreshLatestExpenseConversation()
}
}
)
watch(currentUserProfileKey, (nextKey, previousKey) => {
if (nextKey && nextKey !== previousKey) {
loadCurrentEmployeeProfile()
@@ -794,6 +429,5 @@ watch(currentUserProfileKey, (nextKey, previousKey) => {
<style scoped src="../../assets/styles/components/personal-workbench.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-glass.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>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<template>
<div class="trend-chart">
<div class="trend-chart" :class="{ 'trend-chart-compact': compact, 'trend-chart-dark': dark }">
<div class="chart-toolbar">
<div class="chart-legend">
<span
@@ -39,6 +39,10 @@ const props = defineProps({
claimCount: { type: Array, default: () => [] },
claimAmount: { type: Array, default: () => [] },
categoryAmountSeries: { type: Array, default: () => [] },
comparisonAmount: { type: Array, default: () => [] },
primaryLabel: { type: String, default: '报销金额' },
comparisonLabel: { type: String, default: '去年同期' },
compact: { type: Boolean, default: false },
applications: { type: Array, default: () => [] },
approved: { type: Array, default: () => [] }
})
@@ -46,6 +50,7 @@ const props = defineProps({
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const isCountMode = computed(() => props.mode === 'count')
const isComparisonMode = computed(() => props.mode === 'compareAmount')
const chartColors = computed(() => ({
primary: themeColors.value.chartPrimary,
blue: themeColors.value.chartBlue,
@@ -93,14 +98,30 @@ const stackedAmountData = computed(() => props.labels.map((_, index) => [
index,
...amountCategorySeries.value.map((item) => Number(item.data?.[index] || 0))
]))
const activeColor = computed(() => (
isCountMode.value ? chartColors.value.primary : chartColors.value.blue
))
const activeColor = computed(() => {
return isCountMode.value ? chartColors.value.primary : chartColors.value.blue
})
const comparisonColor = computed(() => '#cbd5e1')
const legendLabel = computed(() => (
isCountMode.value ? '报销数量' : '报销金额'
isCountMode.value ? '报销数量' : (isComparisonMode.value ? props.primaryLabel : '报销金额')
))
const unitLabel = computed(() => (isCountMode.value ? '单位:单' : '单位:元'))
const legendItems = computed(() => {
if (isComparisonMode.value) {
return [
{
name: props.primaryLabel,
color: activeColor.value,
title: `${props.primaryLabel} ${unitLabel.value}`
},
{
name: props.comparisonLabel,
color: comparisonColor.value,
title: `${props.comparisonLabel} ${unitLabel.value}`
}
]
}
if (amountCategorySeries.value.length) {
return amountCategorySeries.value.map((item, index) => ({
name: item.name || `费用类型 ${index + 1}`,
@@ -114,23 +135,144 @@ const legendItems = computed(() => {
title: `${legendLabel.value} ${unitLabel.value}`
}]
})
const maxValue = computed(() => Math.max(...activeSeries.value.map((value) => Number(value || 0)), 1))
const comparisonSeries = computed(() => (
Array.isArray(props.comparisonAmount) ? props.comparisonAmount : []
))
const maxValue = computed(() => {
const values = [
...activeSeries.value.map((value) => Number(value || 0)),
...(isComparisonMode.value ? comparisonSeries.value.map((value) => Number(value || 0)) : [])
]
const rawMax = Math.max(...values, 0)
if (isCountMode.value) {
return Math.max(rawMax, 5)
}
return Math.max(rawMax, 100)
})
const compactScale = computed(() => ({
axisLabelSize: props.compact ? 12 : 11,
comparisonLineWidth: props.compact ? 3 : 2.5,
comparisonSymbolSize: props.compact ? 7.5 : 6,
defaultLineWidth: props.compact ? 3 : 2.5,
defaultSymbolSize: props.compact ? 8 : 7,
gridBottom: props.compact ? 18 : 22,
gridLeft: props.compact ? 42 : 36,
gridRight: props.compact ? 28 : 24,
gridTop: props.compact ? 10 : 12,
primaryLineWidth: props.compact ? 3.8 : 3,
primarySymbolSize: props.compact ? 8.5 : 7
}))
const chartGrid = computed(() => ({
top: compactScale.value.gridTop,
right: compactScale.value.gridRight,
bottom: compactScale.value.gridBottom,
left: compactScale.value.gridLeft,
containLabel: true
}))
const stackedMaxValue = computed(() => {
if (!amountCategorySeries.value.length) {
if (isComparisonMode.value || !amountCategorySeries.value.length) {
return maxValue.value
}
const dailyTotals = props.labels.map((_, index) => amountCategorySeries.value
.reduce((sum, item) => sum + Number(item.data?.[index] || 0), 0))
return Math.max(...dailyTotals, 1)
const rawMax = Math.max(...dailyTotals, 0)
if (isCountMode.value) {
return Math.max(rawMax, 5)
}
return Math.max(rawMax, 100)
})
function getFormattedMax(val, isCount) {
if (isCount) {
const base = Math.max(val, 4)
if (base <= 4) return 4
if (base <= 6) return 6
if (base <= 10) return 10
return Math.ceil(base / 2) * 2
} else {
const base = Math.max(val, 100)
if (base <= 100) return 100
if (base <= 200) return 200
if (base <= 500) return 500
if (base <= 1000) return 1000
if (base <= 2000) return 2000
if (base <= 5000) return 5000
return Math.ceil(base / 1000) * 1000
}
}
const yAxisMax = computed(() => {
const calculatedMax = Math.ceil(stackedMaxValue.value * 1.18)
return getFormattedMax(calculatedMax, isCountMode.value)
})
const ariaLabel = computed(() =>
props.labels.map((label, index) => (
isCountMode.value
isComparisonMode.value
? `${label}${props.primaryLabel}${formatCurrency(claimAmountSeries.value[index] || 0)}${props.comparisonLabel}${formatCurrency(comparisonSeries.value[index] || 0)}`
: isCountMode.value
? `${label}报销${claimCountSeries.value[index] || 0}`
: `${label}报销金额${formatCurrency(claimAmountSeries.value[index] || 0)}`
)).join('')
)
const chartSeries = computed(() => {
if (isComparisonMode.value) {
return [
{
name: props.primaryLabel,
type: 'line',
data: claimAmountSeries.value,
smooth: true,
symbol: 'circle',
symbolSize: compactScale.value.primarySymbolSize,
lineStyle: {
width: compactScale.value.primaryLineWidth,
color: activeColor.value
},
itemStyle: {
color: '#ffffff',
borderColor: activeColor.value,
borderWidth: props.compact ? 3 : 2.5
},
areaStyle: {
opacity: 1,
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: toRgba(activeColor.value, 0.12) },
{ offset: 1, color: toRgba(activeColor.value, 0.01) }
]
}
},
tooltip: {
valueFormatter: (value) => formatCurrency(value)
}
},
{
name: props.comparisonLabel,
type: 'line',
data: comparisonSeries.value,
smooth: true,
symbol: 'circle',
symbolSize: compactScale.value.comparisonSymbolSize,
lineStyle: {
width: compactScale.value.comparisonLineWidth,
color: comparisonColor.value,
type: 'dashed'
},
itemStyle: {
color: '#ffffff',
borderColor: comparisonColor.value,
borderWidth: props.compact ? 2.5 : 2
},
tooltip: {
valueFormatter: (value) => formatCurrency(value)
}
}
]
}
if (!isCountMode.value && amountCategorySeries.value.length) {
return [{
name: '费用类型占比',
@@ -151,15 +293,15 @@ const chartSeries = computed(() => {
barWidth: 16,
smooth: isCountMode.value,
symbol: isCountMode.value ? 'circle' : 'none',
symbolSize: 7,
symbolSize: compactScale.value.defaultSymbolSize,
lineStyle: {
width: 2.5,
width: compactScale.value.defaultLineWidth,
color: activeColor.value
},
itemStyle: {
color: isCountMode.value ? '#ffffff' : activeColor.value,
borderColor: activeColor.value,
borderWidth: isCountMode.value ? 2.5 : 0,
borderWidth: isCountMode.value ? (props.compact ? 3 : 2.5) : 0,
borderRadius: [4, 4, 0, 0]
},
areaStyle: {
@@ -190,13 +332,7 @@ const chartOptions = computed(() => ({
animationDurationUpdate: 1200,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
grid: {
top: 12,
right: 24,
bottom: 22,
left: 36,
containLabel: true
},
grid: chartGrid.value,
tooltip: {
trigger: 'axis',
confine: true,
@@ -221,20 +357,22 @@ const chartOptions = computed(() => ({
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
axisLabel: {
color: '#64748b',
fontSize: 11,
fontSize: compactScale.value.axisLabelSize,
fontWeight: 700
}
},
yAxis: {
type: 'value',
min: 0,
max: Math.ceil(stackedMaxValue.value * 1.18),
splitNumber: 5,
max: yAxisMax.value,
interval: props.compact ? (yAxisMax.value / 2) : undefined,
splitNumber: props.compact ? 2 : 5,
name: '',
axisLabel: {
color: '#64748b',
fontSize: 11,
fontSize: compactScale.value.axisLabelSize,
fontWeight: 700,
margin: props.compact ? 12 : 8,
formatter: (value) => (isCountMode.value ? `${Math.round(value)}` : formatAxisCurrency(value))
},
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
@@ -352,6 +490,16 @@ function formatTooltip(params) {
if (!first) {
return ''
}
if (isComparisonMode.value) {
const index = Number(first.dataIndex || 0)
const label = props.labels[index] || first.axisValueLabel || first.name || ''
return [
label,
`${props.primaryLabel}${formatCurrency(claimAmountSeries.value[index] || 0)}`,
`${props.comparisonLabel}${formatCurrency(comparisonSeries.value[index] || 0)}`
].join('<br/>')
}
if (!isCountMode.value && amountCategorySeries.value.length) {
return formatStackedTooltip(first)
}
@@ -406,6 +554,11 @@ function formatAxisCurrency(value) {
flex-direction: column;
}
.trend-chart-compact {
height: 100%;
min-height: 124px;
}
.chart-toolbar {
min-height: 30px;
display: flex;
@@ -465,4 +618,39 @@ function formatAxisCurrency(value) {
flex: 1;
min-height: 0;
}
.trend-chart-compact .chart-toolbar {
min-height: 28px;
margin-bottom: 6px;
}
.trend-chart-compact .chart-legend {
gap: 6px 14px;
font-size: 13px;
}
.trend-chart-compact .legend-pill {
max-width: 128px;
}
.trend-chart-compact .chart-legend i {
width: 9px;
height: 9px;
}
.trend-chart-compact .chart-unit {
padding: 2px 8px;
font-size: 12.5px;
}
.trend-chart-dark .chart-legend,
.trend-chart-dark .legend-pill {
color: #64748b;
}
.trend-chart-dark .chart-unit {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: #64748b;
}
</style>

View File

@@ -0,0 +1,321 @@
<template>
<aside class="ai-rail" :class="{ 'rail-collapsed': collapsed }" aria-label="AI模式导航">
<section class="ai-rail-brand" aria-label="当前产品标识">
<span class="ai-brand-logo" aria-hidden="true">
<img v-if="brandLogo" :src="brandLogo" alt="" />
<svg v-else viewBox="0 0 36 36">
<path d="M19.8 4.5c5.7 1.1 9.9 5.7 10.5 11.6-2.8-.9-5.5-.7-7.9.6-2.8 1.5-4.5 4.3-5.2 8.2-4.4-2.8-6.5-6.5-6.3-11.1.2-4.2 3.5-7.8 8.9-9.3Z" />
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
</svg>
</span>
<span class="ai-brand-copy">
<strong>{{ displayBrandName }}</strong>
<small>AI 财务工作台</small>
</span>
</section>
<section class="ai-rail-section ai-rail-quick" aria-label="对话操作">
<template v-for="action in quickActions" :key="action.event">
<label
v-if="action.event === 'search' && conversationSearchOpen"
class="ai-conversation-search"
>
<i class="mdi mdi-magnify" aria-hidden="true"></i>
<input
ref="conversationSearchInputRef"
v-model="conversationSearchQuery"
type="search"
placeholder="搜索对话标题"
@keydown.esc.prevent="closeConversationSearch"
/>
<button
type="button"
:aria-label="conversationSearchQuery ? '清空对话搜索' : '关闭对话搜索'"
@click="handleConversationSearchAuxAction"
>
<i class="mdi mdi-close" aria-hidden="true"></i>
</button>
</label>
<button
v-else
type="button"
class="ai-quick-btn"
:class="{ primary: action.primary }"
@click="handleQuickAction(action.event)"
>
<i :class="action.icon" aria-hidden="true"></i>
<span>{{ action.label }}</span>
</button>
</template>
</section>
<div class="ai-rail-divider"></div>
<nav class="ai-rail-section ai-rail-nav" aria-label="业务导航">
<div class="ai-nav-list">
<button
v-for="item in businessNavItems"
:key="item.id"
type="button"
class="ai-nav-btn"
:class="{ active: activeView === item.id }"
:aria-current="activeView === item.id ? 'page' : undefined"
@click="emit('navigate', item.id)"
>
<span class="ai-nav-icon" aria-hidden="true">
<i :class="item.aiIcon"></i>
</span>
<span class="ai-nav-copy">
<strong>{{ item.displayLabel }}</strong>
</span>
</button>
</div>
</nav>
<div class="ai-rail-divider"></div>
<section class="ai-rail-section ai-rail-recents" aria-label="最近对话">
<h2 class="ai-section-heading">最近对话</h2>
<div class="ai-recents-list">
<div
v-for="recent in filteredConversationHistory"
:key="recent.id"
role="button"
tabindex="0"
class="ai-recent-item"
:class="{ active: activeConversationId === recent.id }"
:aria-current="activeConversationId === recent.id ? 'true' : undefined"
@click="handleRecentClick(recent)"
@dblclick.stop="startEditingRecentTitle(recent)"
@keydown.enter.prevent="emit('open-recent', recent)"
@keydown.space.prevent="emit('open-recent', recent)"
>
<span class="ai-recent-main">
<input
v-if="editingConversationId === recent.id"
ref="editingTitleInputRef"
v-model="editingConversationTitle"
class="ai-recent-title-input"
type="text"
aria-label="编辑对话标题"
@click.stop
@dblclick.stop
@keydown.enter.prevent="commitRecentTitleEdit(recent)"
@keydown.esc.prevent="cancelRecentTitleEdit"
@blur="commitRecentTitleEdit(recent)"
/>
<span v-else class="ai-recent-title">{{ recent.title }}</span>
<span class="ai-recent-desc">{{ recent.desc }}</span>
</span>
<span class="ai-recent-time">{{ recent.time }}</span>
</div>
<p v-if="!normalizedConversationHistory.length" class="ai-recents-empty">暂无历史对话</p>
<p v-else-if="!filteredConversationHistory.length" class="ai-recents-empty">没有匹配的对话</p>
</div>
</section>
<section class="ai-rail-user" aria-label="当前用户">
<div class="ai-user-avatar" aria-hidden="true">{{ displayUser.avatar }}</div>
<div class="ai-user-copy">
<strong>{{ displayUser.name }}</strong>
<span>{{ displayUser.subtitle }}</span>
</div>
<div class="ai-user-actions" aria-label="用户操作">
<button type="button" class="ai-user-action ai-user-logout" aria-label="退出系统" @click="emit('logout')">
<i class="mdi mdi-logout-variant" aria-hidden="true"></i>
</button>
</div>
</section>
</aside>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { resolveAiSidebarBusinessViewIds } from '../../utils/aiSidebarBusinessAccess.js'
const props = defineProps({
navItems: { type: Array, required: true },
activeView: { type: String, required: true },
activeConversationId: { type: String, default: '' },
brandName: { type: String, default: '' },
brandLogo: { type: String, default: '' },
currentUser: {
type: Object,
default: () => ({
name: '系统管理员',
role: '管理员',
avatar: '管'
})
},
collapsed: {
type: Boolean,
default: false
},
conversationHistory: { type: Array, default: () => [] }
})
const emit = defineEmits(['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout'])
const conversationSearchOpen = ref(false)
const conversationSearchQuery = ref('')
const conversationSearchInputRef = ref(null)
const editingConversationId = ref('')
const editingConversationTitle = ref('')
const editingTitleInputRef = ref(null)
let recentClickTimer = null
const quickActions = [
{
label: '新建对话',
icon: 'mdi mdi-plus',
event: 'new-chat',
primary: true
},
{
label: '查询对话',
icon: 'mdi mdi-magnify',
event: 'search'
}
]
const displayBrandName = computed(() => String(props.brandName || '易财费控').trim() || '易财费控')
const sidebarMeta = {
overview: { label: '分析看板', icon: 'mdi mdi-chart-line-variant' },
documents: { label: '单据中心', icon: 'mdi mdi-file-document-outline' },
receiptFolder: { label: '票据夹', icon: 'mdi mdi-receipt-text-outline' },
budget: { label: '预算管理', icon: 'mdi mdi-chart-donut' },
policies: { label: '财务政策', icon: 'mdi mdi-book-open-page-variant-outline' },
audit: { label: '规则管理', icon: 'mdi mdi-shield-check-outline' },
digitalEmployees: { label: '数字员工', icon: 'mdi mdi-robot-outline' },
employees: { label: '员工管理', icon: 'mdi mdi-account-group-outline' },
settings: { label: '系统设置', icon: 'mdi mdi-tune-vertical' }
}
const aiBusinessViewIds = computed(() => new Set(resolveAiSidebarBusinessViewIds(props.currentUser)))
const businessNavItems = computed(() =>
props.navItems
.filter((item) => aiBusinessViewIds.value.has(item.id))
.map((item) => ({
...item,
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
aiIcon: sidebarMeta[item.id]?.icon ?? 'mdi mdi-circle-outline'
}))
)
const normalizedConversationHistory = computed(() => (
Array.isArray(props.conversationHistory) ? props.conversationHistory : []
))
const filteredConversationHistory = computed(() => {
const query = conversationSearchQuery.value.trim().toLowerCase()
if (!query) {
return normalizedConversationHistory.value
}
return normalizedConversationHistory.value.filter((recent) => (
String(recent.title || '').toLowerCase().includes(query)
))
})
const displayUser = computed(() => ({
name: props.currentUser?.name || '系统管理员',
subtitle:
props.currentUser?.email ||
(props.currentUser?.username && props.currentUser?.username !== props.currentUser?.name ? props.currentUser.username : '') ||
props.currentUser?.role ||
'审批负责人',
avatar: props.currentUser?.avatar || String(props.currentUser?.name || '管').trim().slice(0, 1) || '管'
}))
function handleQuickAction(event) {
if (event === 'new-chat') {
emit('new-chat')
return
}
if (event === 'search') {
openConversationSearch()
}
}
function openConversationSearch() {
conversationSearchOpen.value = true
void nextTick(() => {
resolveInputElement(conversationSearchInputRef.value)?.focus()
})
}
function closeConversationSearch() {
conversationSearchOpen.value = false
conversationSearchQuery.value = ''
}
function handleConversationSearchAuxAction() {
if (conversationSearchQuery.value) {
conversationSearchQuery.value = ''
return
}
closeConversationSearch()
}
function startEditingRecentTitle(recent = {}) {
clearRecentClickTimer()
editingConversationId.value = String(recent.id || '').trim()
editingConversationTitle.value = String(recent.title || '').trim()
void nextTick(() => {
const input = resolveInputElement(editingTitleInputRef.value)
input?.focus()
input?.select()
})
}
function handleRecentClick(recent = {}) {
clearRecentClickTimer()
recentClickTimer = window.setTimeout(() => {
emit('open-recent', recent)
recentClickTimer = null
}, 180)
}
function clearRecentClickTimer() {
if (recentClickTimer) {
window.clearTimeout(recentClickTimer)
recentClickTimer = null
}
}
function cancelRecentTitleEdit() {
editingConversationId.value = ''
editingConversationTitle.value = ''
}
function commitRecentTitleEdit(recent = {}) {
if (editingConversationId.value !== String(recent.id || '').trim()) {
return
}
const title = editingConversationTitle.value.trim()
cancelRecentTitleEdit()
if (!title || title === String(recent.title || '').trim()) {
return
}
emit('rename-conversation', {
id: recent.id,
title
})
}
function resolveInputElement(value) {
return Array.isArray(value) ? value[0] : value
}
onBeforeUnmount(() => {
clearRecentClickTimer()
})
</script>
<style scoped src="../../assets/styles/components/ai-sidebar-rail.css"></style>

View File

@@ -56,63 +56,24 @@
</ElTooltip>
</nav>
<div
class="rail-user"
@mouseenter="openCollapsedUserMenu"
@mouseleave="closeCollapsedUserMenu"
@focusin="openCollapsedUserMenu"
@focusout="handleUserFocusOut"
>
<div v-if="!collapsed" class="user-menu" role="menu" aria-label="用户菜单">
<button class="user-menu-item" type="button" @click="emit('logout')">
<i class="mdi mdi-logout-variant"></i>
<span>退出系统</span>
<section class="rail-user" aria-label="当前用户">
<div class="user-avatar" aria-hidden="true">{{ displayUser.avatar }}</div>
<div class="user-copy">
<strong>{{ displayUser.name }}</strong>
<span>{{ displayUser.subtitle }}</span>
</div>
<div class="user-actions" aria-label="用户操作">
<button type="button" class="user-action user-logout" aria-label="退出系统" @click="emit('logout')">
<i class="mdi mdi-logout-variant" aria-hidden="true"></i>
</button>
</div>
<Teleport to="body">
<div
v-if="collapsed && userMenuOpen"
class="rail-user-menu-floating"
:style="userMenuStyle"
role="menu"
aria-label="用户菜单"
@mouseenter="clearUserMenuCloseTimer"
@mouseleave="closeCollapsedUserMenu"
>
<button class="user-menu-item" type="button" @click="handleLogout">
<i class="mdi mdi-logout-variant"></i>
<span>退出系统</span>
</button>
</div>
</Teleport>
<ElTooltip
:content="userTooltipContent"
placement="top"
effect="light"
:disabled="!collapsed || userMenuOpen"
:show-after="180"
:hide-after="0"
:offset="10"
popper-class="rail-tooltip-popper"
>
<div class="user-summary" tabindex="0" :aria-label="userTooltipContent">
<span class="user-avatar">{{ displayUser.avatar }}</span>
<span class="user-copy">
<strong>{{ displayUser.name }}</strong>
<span>{{ displayUser.role }}</span>
</span>
<i class="mdi mdi-chevron-up"></i>
</div>
</ElTooltip>
</div>
</section>
</aside>
</template>
<script setup>
import { ElTooltip } from 'element-plus/es/components/tooltip/index.mjs'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { computed } from 'vue'
const props = defineProps({
navItems: { type: Array, required: true },
@@ -164,99 +125,16 @@ const decoratedNavItems = computed(() =>
const displayUser = computed(() => ({
name: props.currentUser?.name || '系统管理员',
role: props.currentUser?.role || '管理员',
avatar: props.currentUser?.avatar || '管'
subtitle:
props.currentUser?.email ||
(props.currentUser?.username && props.currentUser?.username !== props.currentUser?.name ? props.currentUser.username : '') ||
props.currentUser?.role ||
'管理员',
avatar: props.currentUser?.avatar || String(props.currentUser?.name || '管').trim().slice(0, 1) || '管'
}))
const displayCompanyName = computed(() => props.companyName || '易财费控')
const collapseTooltipContent = computed(() => (props.collapsed ? '展开侧边栏' : '折叠侧边栏'))
const userTooltipContent = computed(() => [displayUser.value.name, displayUser.value.role].filter(Boolean).join(' · '))
const userMenuOpen = ref(false)
let userMenuCloseTimer = null
const userMenuPosition = reactive({
top: 0,
left: 0
})
const userMenuStyle = computed(() => ({
top: `${userMenuPosition.top}px`,
left: `${userMenuPosition.left}px`
}))
function resolveUserMenuAnchor(element) {
return element?.querySelector?.('.user-summary') || element
}
function clearUserMenuCloseTimer() {
if (userMenuCloseTimer) {
clearTimeout(userMenuCloseTimer)
userMenuCloseTimer = null
}
}
function openCollapsedUserMenu(event) {
if (!props.collapsed) {
return
}
clearUserMenuCloseTimer()
const anchor = resolveUserMenuAnchor(event?.currentTarget)
if (!anchor?.getBoundingClientRect) {
return
}
const rect = anchor.getBoundingClientRect()
userMenuPosition.top = rect.top + rect.height / 2
userMenuPosition.left = rect.right + 12
userMenuOpen.value = true
}
function closeCollapsedUserMenu() {
clearUserMenuCloseTimer()
userMenuCloseTimer = setTimeout(() => {
userMenuOpen.value = false
userMenuCloseTimer = null
}, 120)
}
function closeCollapsedUserMenuNow() {
clearUserMenuCloseTimer()
userMenuOpen.value = false
}
function handleUserFocusOut(event) {
if (!props.collapsed) {
return
}
const container = event.currentTarget
const nextTarget = event.relatedTarget
if (nextTarget && container?.contains(nextTarget)) {
return
}
closeCollapsedUserMenuNow()
}
function handleLogout() {
closeCollapsedUserMenuNow()
emit('logout')
}
watch(
() => props.collapsed,
(isCollapsed) => {
if (!isCollapsed) {
closeCollapsedUserMenuNow()
}
}
)
onBeforeUnmount(() => {
closeCollapsedUserMenuNow()
})
</script>

View File

@@ -1,10 +1,11 @@
<template>
<header class="topbar" :class="{ 'chat-mode': isChat, 'detail-mode': isRequestDetail }">
<div class="title-group">
<div v-if="!isWorkbenchAiHome" class="title-group">
<div class="eyebrow">{{ eyebrowLabel }}</div>
<h1>{{ currentView.title }}</h1>
<p>{{ currentView.desc }}</p>
</div>
<div v-else class="title-group" aria-hidden="true"></div>
<div class="top-actions">
<template v-if="isChat">
@@ -278,12 +279,23 @@
</Transition>
</div>
<button class="company-switcher" type="button" aria-label="切换公司">
<span>{{ displayCompanyName }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div>
</template>
<button class="company-switcher" type="button" aria-label="切换公司">
<span>{{ displayCompanyName }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<button
type="button"
class="topbar-ai-mode-toggle"
:class="{ active: isTopbarAiMode }"
:aria-pressed="isTopbarAiMode"
:aria-label="topbarWorkbenchModeTitle"
:title="topbarWorkbenchModeTitle"
@click="toggleTopbarWorkbenchMode"
>
<span class="topbar-ai-mode-toggle__glyph">AI</span>
</button>
</div>
</template>
<template v-else-if="isDocuments">
<div class="kpi-chips">
@@ -345,18 +357,36 @@
</div>
</template>
<template v-else-if="isEmployees">
<div class="kpi-chips">
<div v-for="kpi in employeeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
</template>
</div>
</header>
</template>
<template v-else-if="isEmployees">
<div class="kpi-chips">
<div v-for="kpi in employeeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
</template>
<div v-if="showAiModeUtilityActions" class="topbar-utility-actions" aria-label="AI模式快捷操作">
<button class="company-switcher" type="button" aria-label="切换公司">
<span>{{ displayCompanyName }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<button
type="button"
class="topbar-ai-mode-toggle"
:class="{ active: isTopbarAiMode }"
:aria-pressed="isTopbarAiMode"
:aria-label="topbarWorkbenchModeTitle"
:title="topbarWorkbenchModeTitle"
@click="toggleTopbarWorkbenchMode"
>
<span class="topbar-ai-mode-toggle__glyph">AI</span>
</button>
</div>
</div>
</header>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
@@ -394,14 +424,18 @@ const props = defineProps({
type: Object,
default: () => null
},
workbenchSummary: {
type: Object,
default: () => null
},
companyName: {
type: String,
default: ''
},
workbenchSummary: {
type: Object,
default: () => null
},
workbenchMode: {
type: String,
default: 'traditional'
},
companyName: {
type: String,
default: ''
},
detailMode: {
type: Boolean,
default: false
@@ -431,10 +465,11 @@ const emit = defineEmits([
'update:overviewDashboard',
'batchApprove',
'openChat',
'newApplication',
'openDocument',
'navigate'
])
'newApplication',
'openDocument',
'navigate',
'toggleWorkbenchMode'
])
const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview')
const isWorkbench = computed(() => props.activeView === 'workbench')
@@ -444,12 +479,16 @@ 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 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 isTopbarAiMode = computed(() => props.workbenchMode === 'ai')
const topbarWorkbenchModeTitle = computed(() => (isTopbarAiMode.value ? 'AI 模式,点击切换传统模式' : '传统模式,点击切换 AI 模式'))
const isWorkbenchAiHome = computed(() => isWorkbench.value && isTopbarAiMode.value)
const showAiModeUtilityActions = computed(() => isTopbarAiMode.value && !isWorkbench.value)
const MAX_NOTIFICATION_ITEMS = 30
const {
markDocumentInboxRowRead,
@@ -576,12 +615,16 @@ const readNotifications = computed(() => notificationItems.value.filter((item) =
const activeNotifications = computed(() => (
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
))
const topbarNotificationCount = computed(() => {
const count = unreadNotifications.value.length
return count > 0 ? Math.min(count, 99) : 0
})
function clearDocumentInboxInitialRefreshTimer() {
const topbarNotificationCount = computed(() => {
const count = unreadNotifications.value.length
return count > 0 ? Math.min(count, 99) : 0
})
function toggleTopbarWorkbenchMode() {
emit('toggleWorkbenchMode')
}
function clearDocumentInboxInitialRefreshTimer() {
if (documentInboxInitialRefreshTimer && typeof window !== 'undefined') {
window.clearTimeout(documentInboxInitialRefreshTimer)
documentInboxInitialRefreshTimer = null

View File

@@ -18,32 +18,45 @@
<p>{{ decisionDescription }}</p>
</div>
<div class="employee-risk-decision-action">
<span>建议结论</span>
<strong :class="decisionTone">{{ decisionAction }}</strong>
<span>是否建议通过</span>
<strong :class="decisionTone">{{ decisionBadgeLabel }}</strong>
<p>{{ decisionAction }}</p>
</div>
</section>
<section class="employee-risk-profile-section" aria-label="单据风险依据">
<dl class="employee-risk-review-summary" aria-label="审核建议摘要">
<div
v-for="item in reviewSummaryItems"
:key="item.key"
:class="['employee-risk-review-item', item.tone]"
>
<dt>{{ item.label }}</dt>
<dd>{{ item.value }}</dd>
</div>
</dl>
<section class="employee-risk-profile-section" aria-label="单据关键依据">
<div class="employee-risk-section-head">
<span>{{ stageBasisTitle }}</span>
<small>{{ stageBasisHint }}</small>
</div>
<div v-if="compactEvidenceItems.length" class="employee-risk-profile-list">
<article
v-for="item in compactEvidenceItems"
<details
v-for="(item, index) in compactEvidenceItems"
:key="item.code"
:class="['employee-risk-evidence-row', item.tone]"
:open="index === 0"
>
<div class="employee-risk-evidence-title">
<summary class="employee-risk-evidence-title">
<span>{{ item.label }}</span>
<strong>{{ item.status }}</strong>
</div>
</summary>
<ul v-if="item.evidence.length">
<li v-for="basis in item.evidence" :key="basis">{{ basis }}</li>
</ul>
</article>
</details>
</div>
<p v-else class="employee-risk-muted">当前未识别到需要重点展示的单据风险依据</p>
<p v-else class="employee-risk-muted">当前未识别到需要重点展示的单据依据</p>
</section>
</div>
</article>
@@ -95,12 +108,12 @@ export default {
}
return 'normal'
})
const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : '报销审核建议')
const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请单风险依据' : '报销单风险依据')
const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : 'AI建议')
const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请单关键依据' : '报销单关键依据')
const stageBasisHint = computed(() => (
props.isApplicationDocument
? '仅展示申请单本身的金额、预算触发、事由和规则命中依据。'
: '仅展示报销单本身的票据、金额、行程和规则命中依据。'
? '默认只展开最关键的申请依据,其他细节点开查看。'
: '默认只展开最关键的报销依据,其他细节点开查看。'
))
const decisionTitle = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).title)
const decisionAction = computed(() => {
@@ -111,25 +124,26 @@ export default {
})
const decisionBadgeLabel = computed(() => {
if (decisionTone.value === 'high') {
return '高风险'
return '不通过'
}
if (decisionTone.value === 'medium') {
return '需关注'
return '待补充'
}
return '可审批'
return '可通过'
})
const decisionDescription = computed(() => {
const riskCount = currentRiskCards.value.length
const subject = props.isApplicationDocument ? '申请' : '报销'
if (riskCount) {
if (!props.isApplicationDocument && riskExplanationItems.value.length) {
return `当前报销已识别 ${riskCount} 个需核对风险点,用户已补充异常说明,审批人应核对说明与票据佐证是否充分`
return `当前${subject}识别 ${riskCount} 个需核对风险点,已补充说明但仍建议先核对票据与行程`
}
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}识别 ${riskCount} 个需核对风险点,审批人应优先查看高风险依据。`
return `当前${subject}识别 ${riskCount} 个需核对风险点,优先查看高风险依据。`
}
if (materialIssues.value.length || sceneIssues.value.length) {
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}存在材料或业务说明不完整,建议补齐后再继续处理。`
return `当前${subject}存在材料或业务说明不完整,建议补齐后再处理。`
}
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}未发现中高风险阻断项,可结合当前环节权限按流程处理。`
return `当前${subject}未发现中高风险阻断项,可按流程继续处理。`
})
const stageEvidenceItems = computed(() => (
props.isApplicationDocument ? buildApplicationEvidence() : buildReimbursementEvidence()
@@ -139,6 +153,38 @@ export default {
const sourceItems = abnormalItems.length ? abnormalItems : stageEvidenceItems.value
return sourceItems.map((item) => ({ ...item }))
})
const stageRiskFactSummary = computed(() => buildStageRiskFactSummary({
isApplicationDocument: props.isApplicationDocument,
riskCount: currentRiskCards.value.length,
highCount: highRiskCards.value.length,
mediumCount: mediumRiskCards.value.length,
materialIssueCount: materialIssues.value.length,
sceneIssueCount: sceneIssues.value.length
}))
const stageReviewBasisSummary = computed(() => buildStageReviewBasisSummary(
compactEvidenceItems.value,
props.isApplicationDocument
))
const reviewSummaryItems = computed(() => [
{
key: 'fact',
label: '风险概览',
tone: decisionTone.value,
value: stageRiskFactSummary.value
},
{
key: 'basis',
label: '重点依据',
tone: decisionTone.value,
value: stageReviewBasisSummary.value
},
{
key: 'action',
label: '审核建议',
tone: decisionTone.value,
value: decisionAction.value
}
])
function buildApplicationEvidence() {
const budgetCards = currentRiskCards.value.filter((card) => /预算|余额|占用|超预算/.test(cardText(card)))
@@ -217,28 +263,68 @@ export default {
decisionDescription,
decisionAction,
decisionTitle,
reviewSummaryItems,
stageBasisHint,
stageBasisTitle,
stageEvidenceItems,
stageReviewBasisSummary,
stageRiskFactSummary,
stageTitle
}
}
}
function buildStageRiskFactSummary({
isApplicationDocument,
riskCount = 0,
highCount = 0,
mediumCount = 0,
materialIssueCount = 0,
sceneIssueCount = 0
} = {}) {
const subject = isApplicationDocument ? '申请单' : '报销单'
if (riskCount > 0) {
return `${subject}识别 ${riskCount} 个需核对风险点,高风险 ${highCount} 个,中风险 ${mediumCount} 个。`
}
const issueCount = materialIssueCount + sceneIssueCount
if (issueCount > 0) {
return `${subject}暂无中高风险命中,但仍有 ${issueCount} 个材料或业务说明项需要补齐。`
}
return `${subject}未识别到中高风险阻断项。`
}
function buildStageReviewBasisSummary(evidenceItems = [], isApplicationDocument = false) {
const abnormalLabels = evidenceItems
.filter((item) => isAbnormalEvidence(item))
.map((item) => String(item?.label || '').trim())
.filter(Boolean)
if (abnormalLabels.length) {
return `重点核对${abnormalLabels.join('、')}`
}
return isApplicationDocument
? '重点看申请金额、预算触发和事由是否一致。'
: '重点看票据、金额、行程和附件是否一致。'
}
function resolveDecision(tone, isApplicationDocument) {
const subject = isApplicationDocument ? '申请' : '报销'
const map = {
normal: {
title: `当前${subject}未发现中高风险阻断项`,
action: `可按权限继续审批${isApplicationDocument ? ',系统会按预算结果决定是否跳过预算复核。' : ',后续进入财务或付款流程。'}`
title: '建议通过',
action: isApplicationDocument
? '可按权限继续审核,系统会按预算结果决定是否进入下一步。'
: '可按权限继续审批,后续进入财务或付款流程。'
},
medium: {
title: `当前${subject}存在中风险,建议核对后处理`,
action: isApplicationDocument ? '建议核对预算占用、申请事由和金额依据后再通过。' : '建议核对票据、金额和业务说明后再通过。'
title: '建议补充后通过',
action: isApplicationDocument
? '建议补充预算占用、申请事由和金额依据后再通过。'
: '建议补充票据、金额或业务说明后再通过。'
},
high: {
title: `当前${subject}存在高风险,不建议直接通过`,
action: isApplicationDocument ? '建议退回补充申请依据,或要求预算管理者复核。' : '建议退回补充票据、行程说明或超标原因。'
title: '不建议通过',
action: isApplicationDocument
? '建议退回补充申请依据,或要求预算管理者复核。'
: '建议退回补充票据、行程说明或超标原因。'
}
}
return map[tone] || map.normal

View File

@@ -278,6 +278,7 @@
<div
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
class="message-suggested-actions"
:class="{ 'compact-guidance-actions': message.assistantVariant === 'compact_guidance' }"
>
<button
v-for="action in message.suggestedActions"