Files
X-Financial/web/src/views/AppShellRouteView.vue
2026-06-22 11:58:53 +08:00

583 lines
20 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="handleDocumentDetailBack"
@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 { isBusinessDocumentReference } from '../utils/aiDocumentDetailReference.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 { companyProfile, currentUser, logout } = useSystemState()
function resolveDefaultWorkbenchMode(user) {
return isPlatformAdminUser(user) ? 'traditional' : 'ai'
}
function resolveWorkbenchUserKey(user = {}) {
const roleCodes = Array.isArray(user?.roleCodes) ? user.roleCodes.join(',') : ''
return [
user?.id,
user?.userId,
user?.username,
user?.account,
user?.name,
user?.role,
roleCodes,
user?.isAdmin ? 'admin' : 'user'
].map((item) => String(item || '').trim()).join('|')
}
const workbenchMode = ref(resolveDefaultWorkbenchMode(currentUser.value))
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,
workbenchRequests,
workbenchSummary,
requestsError,
requestsLoading,
reloadDocumentCenterRequests,
reloadRequests,
requests,
search,
selectedRequest,
smartEntryContext,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntryRevealToken,
smartEntrySessionId,
toast,
detailReturnTarget,
topBarView
} = useAppShell()
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 DOCUMENT_DETAIL_RETURN_TARGETS = new Set(['workbench', 'conversation'])
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 resolveDocumentDetailReturnTarget(value) {
const target = String(value || '').trim()
return DOCUMENT_DETAIL_RETURN_TARGETS.has(target) ? target : ''
}
function resolveActiveAiConversationSnapshot() {
const conversationId = String(aiActiveConversationId.value || '').trim()
if (!conversationId) {
return null
}
const history = aiConversationHistory.value.length
? aiConversationHistory.value
: loadAiWorkbenchConversationHistory(currentUser.value || {})
return history.find((item) => String(item.id || item.conversationId || '').trim() === conversationId) || null
}
async function handleDocumentDetailBack() {
const shouldRestoreConversation = detailReturnTarget.value === 'conversation'
const activeConversation = shouldRestoreConversation ? resolveActiveAiConversationSnapshot() : null
const navigation = closeRequestDetail()
if (navigation && typeof navigation.then === 'function') {
await navigation
}
if (!shouldRestoreConversation || !activeConversation) {
return
}
workbenchMode.value = 'ai'
sidebarCollapsed.value = false
await nextTick()
dispatchAiSidebarCommand('open-recent', activeConversation)
}
function openWorkbenchDocument(payload = {}) {
const payloadClaimId = String(payload.claimId || payload.claim_id || '').trim()
const payloadId = String(payload.id || '').trim()
const payloadClaimNo = String(
payload.claimNo ||
payload.claim_no ||
payload.documentNo ||
payload.document_no ||
''
).trim()
const requestId = String(payloadClaimId || payloadId || payloadClaimNo).trim()
if (!requestId) {
return
}
const requestCandidates = Array.isArray(workbenchRequests.value) && workbenchRequests.value.length
? workbenchRequests.value
: requests.value
const request = requestCandidates.find((item) => (
String(item.claimId || '').trim() === requestId
|| String(item.id || '').trim() === requestId
|| String(item.claimNo || '').trim() === requestId
|| String(item.documentNo || '').trim() === requestId
))
const explicitReturnTo = resolveDocumentDetailReturnTarget(payload.returnTo)
const fallbackToWorkbench = (
String(payload.source || '').trim() === 'workbench'
|| activeView.value === 'workbench'
)
const returnTo = explicitReturnTo || (fallbackToWorkbench ? 'workbench' : '')
const payloadIdIsBusinessNo = isBusinessDocumentReference(payloadId)
const fallbackClaimId = payloadClaimId || (payloadClaimNo || payloadIdIsBusinessNo ? '' : payloadId || requestId)
const fallbackClaimNo = payloadClaimNo || (payloadIdIsBusinessNo ? payloadId : fallbackClaimId ? '' : requestId)
const detailPayload = request || {
...payload,
id: payloadId || fallbackClaimId || fallbackClaimNo || requestId,
claimId: fallbackClaimId,
claimNo: fallbackClaimNo,
documentNo: String(payload.documentNo || payload.document_no || fallbackClaimNo || requestId).trim(),
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, previousUser) => {
if (resolveWorkbenchUserKey(user) !== resolveWorkbenchUserKey(previousUser)) {
const nextMode = resolveDefaultWorkbenchMode(user)
workbenchMode.value = nextMode
if (nextMode === 'ai') {
sidebarCollapsed.value = false
}
}
aiConversationHistory.value = loadAiWorkbenchConversationHistory(user || {})
},
{ immediate: true }
)
</script>