Files
X-Financial/web/src/views/AppShellRouteView.vue
caoxiaozhu 0cda750ff0 feat(web): AI 工作台会话与文档卡片渲染增强
- aiConversationHtmlRenderer 识别单据记录类表格并渲染为卡片列表,新增删除申请单详情的禁用占位链接
- aiWorkbenchConversationStore 增加草稿删除后会话链接失效处理,避免点击已删除单据跳转
- aiApplicationPreviewActions 调整提交/草稿调用路径,PersonalWorkbenchAiMode 接入新的会话存储与渲染
- ConfirmDialog/TravelRequestDeleteDialog/useAppShell/AppShellRouteView 配套适配,同步更新相关前端测试
2026-06-20 21:44:16 +08:00

505 lines
18 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>
<div
class="app"
:class="{
'sidebar-collapsed': sidebarCollapsed,
'workbench-ai-sidebar-active': isAiShellMode,
'mobile-sidebar-open': mobileSidebarOpen
}"
>
<div class="mobile-overlay" aria-hidden="true" @click="mobileSidebarOpen = false"></div>
<button
type="button"
class="mobile-hamburger-btn"
aria-label="打开移动端导航"
:aria-expanded="mobileSidebarOpen ? 'true' : 'false'"
@click="mobileSidebarOpen = true"
>
<i class="mdi mdi-menu" aria-hidden="true"></i>
</button>
<div class="app-sidebar">
<Transition name="sidebar-mode-fade" mode="out-in">
<AiSidebarRail
v-if="isAiShellMode"
key="ai-sidebar"
:nav-items="filteredNavItems"
:active-view="activeView"
:active-conversation-id="aiActiveConversationId"
:conversation-history="aiConversationHistory"
:current-user="currentUser"
:brand-name="PRODUCT_DISPLAY_NAME"
:brand-logo="companyProfile.logo"
:collapsed="sidebarCollapsed"
@navigate="handleNavigateWithMobileClose"
@new-chat="openAiSidebarNewChat"
@open-recent="openAiSidebarRecent"
@rename-conversation="handleAiConversationRename"
@logout="handleLogout"
/>
<SidebarRail
v-else
key="standard-sidebar"
:nav-items="filteredNavItems"
:active-view="activeView"
:company-name="PRODUCT_DISPLAY_NAME"
:company-logo="companyProfile.logo"
:current-user="currentUser"
:collapsed="sidebarCollapsed"
@open-chat="openSmartEntry"
@logout="handleLogout"
@toggle-collapse="toggleSidebarCollapsed"
@navigate="handleNavigateWithMobileClose"
/>
</Transition>
</div>
<main
class="main"
:class="{
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'documents-main': activeView === 'documents',
'receipt-folder-main': activeView === 'receiptFolder',
'budget-main': activeView === 'budget',
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
'audit-detail-main': activeView === 'audit' && auditDetailOpen,
'digital-employees-detail-main': activeView === 'digitalEmployees' && digitalEmployeeDetailOpen,
'digital-employees-main': activeView === 'digitalEmployees',
'employees-main': activeView === 'employees',
'settings-main': activeView === 'settings'
}"
>
<TopBar
v-if="activeView !== 'settings'"
:current-view="resolvedTopBarView"
:search="search"
:active-view="activeView"
:ranges="ranges"
:active-range="activeRange"
:employee-summary="employeeSummary"
:knowledge-summary="knowledgeSummary"
:request-summary="requestSummary"
:document-summary="documentSummary"
:workbench-summary="workbenchSummary"
:workbench-mode="workbenchMode"
:digital-employee-summary="digitalEmployeeSummary"
:company-name="ENTERPRISE_DISPLAY_NAME"
:detail-mode="resolvedDetailMode"
:detail-alerts="resolvedDetailAlerts"
:detail-kpis="resolvedDetailKpis"
:custom-range="customRange"
:overview-dashboard="overviewDashboard"
@update:search="search = $event"
@update:active-range="activeRange = $event"
@update:custom-range="customRange = $event"
@update:overview-dashboard="overviewDashboard = $event"
@batch-approve="toast('已批量通过 23 条审批任务')"
@new-application="openExpenseApplicationCreate"
@open-document="openWorkbenchDocument"
@navigate="handleNavigate"
@toggle-workbench-mode="toggleWorkbenchMode"
/>
<FilterBar
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'receiptFolder' && activeView !== 'budget' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'digitalEmployees' && activeView !== 'employees' && activeView !== 'settings'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
:active-range="activeRange"
@update:active-range="activeRange = $event"
/>
<section
class="workarea"
:class="{
'documents-workarea': activeView === 'documents',
'receipt-folder-workarea': activeView === 'receiptFolder',
'workbench-workarea': activeView === 'workbench',
'workbench-workarea-ai-mode': isWorkbenchAiMode,
'budget-workarea': activeView === 'budget',
'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit',
'digital-employees-workarea': activeView === 'digitalEmployees',
'employees-workarea': activeView === 'employees',
'settings-workarea': activeView === 'settings'
}"
>
<OverviewView
v-if="activeView === 'overview'"
:filtered-requests="filteredRequests"
:dashboard="overviewDashboard"
:active-range="activeRange"
:custom-range="customRange"
@approve="handleApprove"
@reject="handleReject"
/>
<PersonalWorkbenchView
v-else-if="activeView === 'workbench'"
:assistant-modal-open="smartEntryOpen"
:workbench-summary="workbenchSummary"
:workbench-mode="workbenchMode"
:ai-sidebar-command="aiSidebarCommand"
@ai-conversation-change="handleAiConversationChange"
@ai-conversation-history-change="handleAiConversationHistoryChange"
@open-assistant="openSmartEntry"
@open-document="openWorkbenchDocument"
/>
<TravelRequestDetailView
v-else-if="activeView === 'documents' && detailMode && selectedRequest"
:request="selectedRequest"
:back-label="detailBackLabel"
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
@request-updated="handleRequestUpdated"
@request-deleted="handleDetailRequestDeleted"
/>
<section
v-else-if="activeView === 'documents' && detailMode && !selectedRequest"
class="document-detail-loading panel"
aria-live="polite"
>
<i class="mdi mdi-loading mdi-spin" aria-hidden="true"></i>
<div>
<strong>正在加载完整单据详情</strong>
<p>正在读取申请表审批进度和详情字段加载完成后再展示详情表格</p>
</div>
</section>
<DocumentsCenterView
v-else-if="activeView === 'documents'"
:filtered-requests="requests"
:has-data="requests.length > 0"
:loading="requestsLoading"
:error="requestsError"
:refresh-token="documentCenterRefreshToken"
@open-document="openRequestDetail"
@create-request="openTravelCreate"
@create-application="openExpenseApplicationCreate"
@reload="reloadRequests"
@summary-change="documentSummary = $event"
/>
<ReceiptFolderView
v-else-if="activeView === 'receiptFolder'"
@open-assistant="openSmartEntry"
@detail-open-change="receiptFolderDetailOpen = $event"
@detail-topbar-change="detailTopBarPayload = $event"
/>
<BudgetCenterView
v-else-if="activeView === 'budget'"
:current-user="currentUser"
@open-assistant="openSmartEntry"
@detail-open-change="budgetDetailOpen = $event"
@detail-topbar-change="detailTopBarPayload = $event"
/>
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
<AuditView
v-else-if="activeView === 'audit'"
@detail-open-change="auditDetailOpen = $event"
@detail-topbar-change="detailTopBarPayload = $event"
/>
<DigitalEmployeesView
v-else-if="activeView === 'digitalEmployees'"
@summary-change="digitalEmployeeSummary = $event"
@detail-open-change="digitalEmployeeDetailOpen = $event"
@detail-topbar-change="detailTopBarPayload = $event"
/>
<EmployeeManagementView v-else-if="activeView === 'employees'" @overview-change="employeeSummary = $event" />
<SettingsView v-else />
</section>
</main>
<TravelReimbursementCreateView
v-if="smartEntryOpen"
:key="smartEntrySessionId"
:initial-prompt="smartEntryContext.prompt"
:initial-files="smartEntryContext.files"
:initial-conversation="smartEntryContext.conversation"
:initial-session-type="smartEntryContext.sessionType"
:initial-budget-context="smartEntryContext.budgetContext"
:initial-prompt-auto-submit="smartEntryContext.initialPromptAutoSubmit"
:initial-application-preview="smartEntryContext.initialApplicationPreview"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
:reopen-token="smartEntryRevealToken"
@close="closeSmartEntry"
@draft-saved="handleDraftSaved"
@request-updated="handleRequestUpdated"
/>
</div>
</template>
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
import AiSidebarRail from '../components/layout/AiSidebarRail.vue'
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
import AuditView from './AuditView.vue'
import BudgetCenterView from './BudgetCenterView.vue'
import DigitalEmployeesView from './DigitalEmployeesView.vue'
import DocumentsCenterView from './DocumentsCenterView.vue'
import EmployeeManagementView from './EmployeeManagementView.vue'
import OverviewView from './OverviewView.vue'
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
import PoliciesView from './PoliciesView.vue'
import ReceiptFolderView from './ReceiptFolderView.vue'
import SettingsView from './SettingsView.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import { useAppShell } from '../composables/useAppShell.js'
import { useSystemState } from '../composables/useSystemState.js'
import { filterNavItemsByAccess, isPlatformAdminUser } from '../utils/accessControl.js'
import { loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../utils/aiWorkbenchConversationStore.js'
const employeeSummary = ref(null)
const knowledgeSummary = ref(null)
const documentSummary = ref(null)
const digitalEmployeeSummary = ref(null)
const detailTopBarPayload = ref(null)
const auditDetailOpen = ref(false)
const digitalEmployeeDetailOpen = ref(false)
const receiptFolderDetailOpen = ref(false)
const budgetDetailOpen = ref(false)
const sidebarCollapsed = ref(false)
const sidebarCollapsedBeforeAiMode = ref(false)
const mobileSidebarOpen = ref(false)
const overviewDashboard = ref('finance')
const workbenchMode = ref('traditional')
const aiSidebarCommandSeq = ref(0)
const aiSidebarCommand = ref({ seq: 0, type: '', payload: null })
const aiActiveConversationId = ref('')
const aiConversationHistory = ref([])
function toggleSidebarCollapsed() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
function handleNavigateWithMobileClose(viewId) {
void handleNavigate(viewId)
mobileSidebarOpen.value = false
}
function toggleWorkbenchMode() {
const nextMode = workbenchMode.value === 'ai' ? 'traditional' : 'ai'
if (nextMode === 'ai') {
sidebarCollapsedBeforeAiMode.value = sidebarCollapsed.value
workbenchMode.value = nextMode
sidebarCollapsed.value = false
return
}
workbenchMode.value = nextMode
sidebarCollapsed.value = sidebarCollapsedBeforeAiMode.value
}
const {
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
customRange,
detailAlerts,
detailBackLabel,
detailMode,
documentCenterRefreshToken,
filteredRequests,
filters,
handleApprove,
handleDraftSaved,
handleNavigate,
handleReject,
handleRequestDeleted,
handleRequestUpdated,
navItems,
openExpenseApplicationCreate,
openRequestDetail,
openSmartEntry,
openTravelCreate,
ranges,
requestSummary,
workbenchSummary,
requestsError,
requestsLoading,
reloadDocumentCenterRequests,
reloadRequests,
requests,
search,
selectedRequest,
smartEntryContext,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntryRevealToken,
smartEntrySessionId,
toast,
topBarView
} = useAppShell()
const { companyProfile, currentUser, logout } = useSystemState()
const PRODUCT_DISPLAY_NAME = '易财费控'
const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司'
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
const isAiShellMode = computed(() => workbenchMode.value === 'ai')
const isWorkbenchAiMode = computed(() => activeView.value === 'workbench' && workbenchMode.value === 'ai')
const DETAIL_TOPBAR_FALLBACKS = {
audit: {
title: '规则中心详情',
desc: '查看规则配置、版本审核、测试结果与上线状态。'
},
digitalEmployees: {
title: '数字员工详情',
desc: '查看数字员工配置、执行计划、运行记录与源文件。'
},
receiptFolder: {
title: '票据详情',
desc: '查看票据源文件、OCR 识别信息与关联状态。'
},
budget: {
title: '预算详情',
desc: '查看预算周期、费用占比、审核信息与预算明细。'
}
}
const customDetailTopBarActive = computed(() => (
(activeView.value === 'audit' && auditDetailOpen.value) ||
(activeView.value === 'digitalEmployees' && digitalEmployeeDetailOpen.value) ||
(activeView.value === 'receiptFolder' && receiptFolderDetailOpen.value) ||
(activeView.value === 'budget' && budgetDetailOpen.value)
))
const resolvedTopBarView = computed(() => (
customDetailTopBarActive.value
? detailTopBarPayload.value?.view || DETAIL_TOPBAR_FALLBACKS[activeView.value] || topBarView.value
: topBarView.value
))
const resolvedDetailMode = computed(() => (
detailMode.value ||
customDetailTopBarActive.value
))
const resolvedDetailAlerts = computed(() => (
customDetailTopBarActive.value
? detailTopBarPayload.value?.alerts || []
: detailAlerts.value
))
const resolvedDetailKpis = computed(() => (
customDetailTopBarActive.value ? detailTopBarPayload.value?.kpis || [] : []
))
function openWorkbenchDocument(payload = {}) {
const requestId = String(payload.claimId || payload.id || payload.claimNo || payload.documentNo || '').trim()
if (!requestId) {
return
}
const request = requests.value.find((item) => (
String(item.claimId || '').trim() === requestId
|| String(item.id || '').trim() === requestId
|| String(item.claimNo || '').trim() === requestId
|| String(item.documentNo || '').trim() === requestId
))
const returnTo = (
String(payload.returnTo || '').trim() === 'workbench'
|| String(payload.source || '').trim() === 'workbench'
|| activeView.value === 'workbench'
)
? 'workbench'
: ''
const detailPayload = request || {
...payload,
id: payload.id || requestId,
claimId: payload.claimId || requestId,
claimNo: payload.claimNo || payload.documentNo || requestId,
documentNo: payload.documentNo || requestId,
detailLookupOnly: true
}
openRequestDetail(detailPayload, { returnTo })
}
function dispatchAiSidebarCommand(type, payload = null) {
aiSidebarCommandSeq.value += 1
aiSidebarCommand.value = {
seq: aiSidebarCommandSeq.value,
type,
payload
}
}
async function openAiConversationWorkspace(type, payload = null) {
if (activeView.value !== 'workbench') {
const navigation = handleNavigate('workbench')
if (navigation && typeof navigation.then === 'function') {
await navigation
}
await nextTick()
}
dispatchAiSidebarCommand(type, payload)
}
function openAiSidebarNewChat() {
aiActiveConversationId.value = ''
void openAiConversationWorkspace('new-chat')
}
function openAiSidebarRecent(item = {}) {
aiActiveConversationId.value = String(item.id || '').trim()
void openAiConversationWorkspace('open-recent', item)
}
function handleAiConversationChange(payload = {}) {
aiActiveConversationId.value = String(payload.id || '').trim()
}
function handleAiConversationHistoryChange(payload = []) {
aiConversationHistory.value = Array.isArray(payload) ? payload : []
}
async function handleDetailRequestDeleted(payload = {}) {
await handleRequestDeleted(payload)
aiConversationHistory.value = loadAiWorkbenchConversationHistory(currentUser.value || {})
}
function handleAiConversationRename(payload = {}) {
const conversationId = String(payload.id || '').trim()
const title = String(payload.title || '').trim()
if (!conversationId || !title) {
return
}
const target = aiConversationHistory.value.find((item) => String(item.id || '').trim() === conversationId)
if (!target) {
return
}
aiConversationHistory.value = saveAiWorkbenchConversation(currentUser.value || {}, {
...target,
title
})
if (aiActiveConversationId.value === conversationId) {
dispatchAiSidebarCommand('open-recent', {
...target,
title
})
}
}
function handleLogout() {
logout('manual')
}
watch(
() => currentUser.value,
(user) => {
aiConversationHistory.value = loadAiWorkbenchConversationHistory(user || {})
},
{ immediate: true }
)
</script>