Files
X-Financial/web/src/components/business/PersonalWorkbench.vue
caoxiaozhu 04cd6d0f81 feat: 新增数字员工管理页面与工作台首页重构
后端优化 agent 资产种子初始化和常量配置,前端新增数字员工
视图和调度对话框组件,重构个人工作台首页布局和洞察面板,
完善审计页面数字员工详情和运行时模型,优化侧边栏导航和图
标配置,新增工作台摘要和工作台数据模块,补充单元测试。
2026-05-28 09:30:34 +08:00

528 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="workbench" aria-label="个人工作台">
<PanelHead
v-if="showHeader"
eyebrow="Personal Workspace"
title="个人工作台"
note="把费用申请、报销进度、制度问答和待办处理集中到一个入口。"
/>
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${homepageBackground})` }">
<div class="assistant-copy">
<h1>你的专属 <span>AI 财务助手</span></h1>
<p>智能理解财务业务提供数据洞察与方案建议高效处理日常事务</p>
<input
ref="fileInputRef"
class="assistant-file-input"
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
@change="handleWorkbenchFilesChange"
/>
<div class="assistant-composer">
<textarea
ref="assistantInputRef"
v-model="assistantDraft"
maxlength="1000"
rows="2"
placeholder="请输入费用申请、报销问题、预算查询或制度问答..."
@keydown.enter.prevent="handleWorkbenchEnter"
/>
<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>
<button
type="button"
class="composer-related-button"
:disabled="Boolean(pendingAction)"
@click="triggerFileUpload"
>
<i class="mdi mdi-source-branch"></i>
<span>关联单据</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<span class="composer-count">{{ assistantDraft.length }}/1000</span>
<button
type="button"
class="composer-send-button"
:disabled="Boolean(pendingAction)"
:aria-label="pendingAction === 'expense' ? '处理中' : expenseActionLabel"
@click="handleExpenseConversationAction"
>
<i :class="pendingAction === 'expense' ? '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>
<div class="capability-grid" aria-label="AI 财务助手能力">
<button
v-for="item in assistantCapabilities"
:key="item.title"
type="button"
class="capability-card panel"
:class="`capability-card--${item.tone}`"
@click="openPromptAssistant(item.prompt)"
>
<span class="capability-icon"><i :class="item.icon"></i></span>
<span class="capability-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.primary }}</small>
<small>{{ item.secondary }}</small>
</span>
<i class="mdi mdi-chevron-right capability-arrow"></i>
</button>
</div>
<div class="workbench-content-grid">
<article class="panel workbench-card todo-panel">
<div class="section-head">
<div class="title-with-badge">
<h2>我的待办</h2>
<span class="soft-badge">{{ todoAlertCount }}</span>
</div>
<button type="button" class="link-action">全部待办 <i class="mdi mdi-chevron-right"></i></button>
</div>
<div class="todo-list">
<button
v-for="item in visibleTodoItems"
:key="item.title"
type="button"
class="todo-row"
@click="openPromptAssistant(`帮我处理:${item.title}${item.description}`)"
>
<WorkbenchListIcon
:icon-key="item.iconKey"
:color="item.color"
:accent="item.accent"
/>
<span class="todo-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.description }}</small>
</span>
<span class="todo-meta">
<span class="todo-status" :class="`todo-status--${item.statusTone}`">{{ item.status }}</span>
<small>{{ item.due }}</small>
</span>
</button>
</div>
</article>
<article class="panel workbench-card progress-panel">
<div class="section-head">
<h2>费用进度</h2>
<button type="button" class="link-action">全部进度 <i class="mdi mdi-chevron-right"></i></button>
</div>
<div class="progress-list">
<button
v-for="item in visibleProgressItems"
:key="item.id"
type="button"
class="progress-row"
@click="openPromptAssistant(`查询 ${item.id} 的费用进度`)"
>
<span class="progress-identity">
<strong>{{ item.id }}</strong>
<small>{{ item.title }}</small>
</span>
<span class="progress-steps" aria-hidden="true">
<span
v-for="(step, index) in progressSteps"
:key="step"
class="progress-step"
:class="{
'is-done': index < item.activeStep,
'is-current': index === item.activeStep,
'is-future': index > item.activeStep
}"
>
<i :class="index <= item.activeStep ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
<small>{{ step }}</small>
</span>
</span>
<span class="progress-result">
<span class="progress-status" :class="`progress-status--${item.statusTone}`">{{ item.status }}</span>
<strong>{{ item.amount }}</strong>
</span>
</button>
</div>
</article>
<aside class="side-column">
<article class="panel workbench-card side-panel expense-stats-panel">
<div class="section-head side-card-head">
<h2>费用统计</h2>
<button
type="button"
class="detail-action"
@click="openPromptAssistant('查看我的费用统计详情,并说明本月报销金额、审批中和待付款的主要变化。')"
>
<span>查看详情</span>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<div class="insight-metric-list" aria-label="费用统计">
<div
v-for="item in visibleExpenseStatItems"
:key="item.key"
class="insight-metric-row"
:class="`insight-metric-row--${item.tone}`"
>
<span class="insight-metric-label">{{ item.label }}</span>
<strong class="insight-metric-value">
{{ item.value }}<small v-if="item.unit">{{ item.unit }}</small>
</strong>
</div>
</div>
</article>
<article class="panel workbench-card side-panel usage-profile-panel">
<div class="section-head side-card-head">
<h2>费用画像</h2>
<button
type="button"
class="detail-action"
@click="openPromptAssistant('查看我的费用画像详情,并总结 AI 使用、提单效率和预审通过率。')"
>
<span>查看详情</span>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<div class="insight-profile-list" aria-label="费用画像">
<div
v-for="metric in visibleUsageProfileMetrics"
:key="metric.key"
class="insight-profile-card"
:class="`insight-profile-card--${metric.tone}`"
>
<span class="insight-profile-icon" aria-hidden="true">
<i :class="metric.icon"></i>
</span>
<div class="insight-profile-copy">
<span class="insight-profile-label">{{ metric.label }}</span>
<strong class="insight-profile-value">
{{ metric.value }}<small>{{ metric.unit }}</small>
</strong>
<span class="insight-profile-hint">{{ metric.hint }}</span>
</div>
</div>
</div>
</article>
</aside>
</div>
</section>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import homepageBackground from '../../assets/homepage_backgraound.png'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import {
assistantCapabilities,
buildExpenseStatItems,
progressItems,
progressSteps,
quickPromptItems,
todoItems,
usageProfileMetrics
} from '../../data/personalWorkbench.js'
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
import {
ASSISTANT_SESSION_SNAPSHOT_EVENT,
hasAssistantSessionSnapshot
} from '../../utils/assistantSessionSnapshot.js'
const props = defineProps({
showHeader: { type: Boolean, default: true },
assistantModalOpen: { type: Boolean, default: false },
workbenchSummary: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['open-assistant'])
const { currentUser } = useSystemState()
const { toast } = useToast()
const assistantDraft = ref('')
const assistantInputRef = ref(null)
const fileInputRef = ref(null)
const selectedFiles = ref([])
const pendingAction = ref('')
const latestExpenseConversation = ref(null)
const hasLocalExpenseSnapshot = ref(false)
const MAX_ATTACHMENTS = 10
const SESSION_TYPE_EXPENSE = 'expense'
const SESSION_TYPE_KNOWLEDGE = 'knowledge'
const hasExpenseConversation = computed(() =>
Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId)
|| hasLocalExpenseSnapshot.value
)
const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销'))
const expenseStatItems = computed(() => buildExpenseStatItems(props.workbenchSummary))
const visibleExpenseStatItems = computed(() => {
const preferredKeys = ['monthly-amount', 'monthly-count', 'in-review', 'pending-payment']
return preferredKeys
.map((key) => expenseStatItems.value.find((item) => item.key === key))
.filter(Boolean)
})
const visibleUsageProfileMetrics = computed(() => {
const preferredKeys = ['ai-usage', 'submit-efficiency', 'auto-pass-rate', 'audit-duration']
return preferredKeys
.map((key) => usageProfileMetrics.find((item) => item.key === key))
.filter(Boolean)
})
const visibleTodoItems = computed(() => todoItems.slice(0, 5))
const visibleProgressItems = computed(() => progressItems.slice(0, 5))
const todoAlertCount = computed(() => visibleTodoItems.value.length)
function buildSelectedFileKey(file) {
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
}
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
}
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'
}
function buildAssistantPayload() {
return {
prompt: assistantDraft.value.trim(),
source: 'workbench',
files: Array.from(selectedFiles.value)
}
}
function clearSelectedFiles() {
selectedFiles.value = []
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function resetWorkbenchDraft() {
assistantDraft.value = ''
clearSelectedFiles()
}
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) {
if (pendingAction.value) {
return
}
emitAssistant({
prompt: String(prompt || '').trim(),
source: 'workbench',
files: Array.from(selectedFiles.value),
conversation: null
})
}
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) {
emitAssistant({
...nextPayload,
conversation: null
})
void clearKnowledgeHistoryBeforeExpense().catch((error) => {
console.warn('Failed to clear knowledge history before expense:', error)
})
return
}
pendingAction.value = '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 {
pendingAction.value = ''
}
}
onMounted(() => {
refreshLocalExpenseSnapshot()
refreshLatestExpenseConversation()
window.addEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
onBeforeUnmount(() => {
window.removeEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
watch(
() => props.assistantModalOpen,
(open, previous) => {
if (previous && !open) {
refreshLatestExpenseConversation()
}
}
)
</script>
<style scoped src="../../assets/styles/components/personal-workbench.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-insights.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-responsive.css"></style>