Files
X-Financial/web/src/components/business/PersonalWorkbench.vue

528 lines
17 KiB
Vue
Raw Normal View History

<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>