import { computed, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useNavigation, navItems } from './useNavigation.js' import { mapExpenseClaimToRequest, useRequests } from './useRequests.js' import { useSystemState } from './useSystemState.js' import { useToast } from './useToast.js' import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../services/reimbursements.js' import { fetchOntologyParse } from '../services/ontology.js' import { fetchLatestConversation } from '../services/orchestrator.js' import { markAiWorkbenchConversationDraftDeleted } from '../utils/aiWorkbenchConversationStore.js' import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js' import { isApplicationDocumentNo } from '../utils/documentClassification.js' import { ASSISTANT_SCOPE_SESSION_STEWARD, buildUnsupportedBusinessScopeConversation, resolveAssistantScopeGuard } from '../utils/assistantSessionScope.js' import { buildDetailAlerts } from '../utils/detailAlerts.js' import { normalizeRequestForUi } from '../utils/requestViewModel.js' import { buildWorkbenchIntentOntologyContext, resolveWorkbenchSessionTypeFallback, resolveWorkbenchSessionTypeFromOntology } from '../utils/workbenchAssistantIntent.js' import { buildWorkbenchSummary } from '../utils/workbenchSummary.js' import { createCurrentYearDateRange } from '../utils/dateRangeDefaults.js' const SESSION_TYPE_EXPENSE = 'expense' const SMART_ENTRY_SOURCE_APPLICATION = 'application' const SMART_ENTRY_SOURCE_REIMBURSEMENT = 'topbar' export function useAppShell() { const route = useRoute() const router = useRouter() const smartEntryOpen = ref(false) const smartEntryContext = ref({ prompt: '', source: 'documents', request: null, files: [], conversation: null, scope: null, sessionType: '', budgetContext: null, initialPromptAutoSubmit: true, initialApplicationPreview: null }) const smartEntrySessionId = ref(0) const smartEntryRevealToken = ref(0) const smartEntryInvalidatedDraftClaimId = ref('') const selectedRequestSnapshot = ref(null) const documentCenterRefreshToken = ref(0) const workbenchApprovalRequests = ref([]) const { activeView, currentView, setView } = useNavigation() const { requests, loading: requestsLoading, error: requestsError, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest, ensureLoaded: ensureRequestsLoaded, reload: reloadRequests } = useRequests() const { currentUser } = useSystemState() const { toast } = useToast() const customRange = ref(createCurrentYearDateRange()) const selectedRequest = computed(() => { const requestId = String(route.params.requestId || '') if (!requestId) { return null } const snapshot = normalizeRequestForUi(selectedRequestSnapshot.value) if (isSameRequestIdentity(snapshot, requestId)) { return snapshot } const rawRequest = requests.value.find( (item) => String(item.claimId || '').trim() === requestId || String(item.id || '').trim() === requestId ) const normalizedRequest = normalizeRequestForUi(rawRequest) if (normalizedRequest) { return normalizedRequest } return null }) const detailMode = computed(() => route.name === 'app-document-detail') const detailReturnTarget = computed(() => String(route.query.returnTo || '').trim()) const detailBackLabel = computed(() => ( detailReturnTarget.value === 'workbench' ? '返回首页' : '返回单据中心' )) const detailAlerts = computed(() => ( detailMode.value ? buildDetailAlerts(selectedRequest.value, { currentUser: currentUser.value }) : [] )) async function reloadDocumentCenterRequests() { documentCenterRefreshToken.value += 1 return reloadRequests() } async function reloadWorkbenchApprovalRequests() { try { const payload = await fetchAllApprovalExpenseClaims() workbenchApprovalRequests.value = Array.isArray(payload) ? payload.map((item) => mapExpenseClaimToRequest(item)) : [] } catch { workbenchApprovalRequests.value = [] } } async function reloadWorkbenchRequests() { const [payload] = await Promise.all([ reloadRequests({ silent: true }), reloadWorkbenchApprovalRequests() ]) return payload } function resolveWorkbenchRequestKey(request) { return String(request?.claimId || request?.id || request?.claimNo || '').trim() } function mergeWorkbenchRequests(primaryRequests = [], approvalRequests = []) { const merged = new Map() for (const request of [...primaryRequests, ...approvalRequests]) { const key = resolveWorkbenchRequestKey(request) if (key) { merged.set(key, request) } } return Array.from(merged.values()) } function isSameRequestIdentity(request, requestId) { const normalizedId = String(requestId || '').trim() if (!request || !normalizedId) { return false } return [ request.claimId, request.id, request.claimNo, request.documentNo ].some((value) => String(value || '').trim() === normalizedId) } function isDetailLookupOnlyPayload(payload = {}) { return Boolean(payload?.detailLookupOnly || payload?.detail_lookup_only) } function resolveRequestDetailLookupId(requestOrId = selectedRequestSnapshot.value) { if (typeof requestOrId === 'string') { return requestOrId.trim() } return String( requestOrId?.claimId || requestOrId?.claim_id || requestOrId?.id || requestOrId?.claimNo || requestOrId?.claim_no || requestOrId?.documentNo || requestOrId?.document_no || '' ).trim() } function upsertRequestSnapshot(nextRequest) { if (!nextRequest) { return } selectedRequestSnapshot.value = nextRequest const nextIdValues = [ nextRequest.claimId, nextRequest.id, nextRequest.claimNo, nextRequest.documentNo ].map((item) => String(item || '').trim()).filter(Boolean) const nextIdSet = new Set(nextIdValues) const index = requests.value.findIndex((item) => [ item.claimId, item.id, item.claimNo, item.documentNo ].some((value) => nextIdSet.has(String(value || '').trim()))) if (index >= 0) { requests.value.splice(index, 1, nextRequest) } } async function refreshSelectedRequestDetail(requestOrId = selectedRequestSnapshot.value) { const lookupId = resolveRequestDetailLookupId(requestOrId) if (!lookupId) { return } try { const payload = await fetchExpenseClaimDetail(lookupId) const mappedRequest = mapExpenseClaimToRequest(payload) const routeRequestId = String(route.params.requestId || '').trim() if (!routeRequestId || isSameRequestIdentity(mappedRequest, routeRequestId) || routeRequestId === lookupId) { upsertRequestSnapshot(mappedRequest) } } catch { // 保留当前快照,避免详情刷新失败时把页面置空。 } } watch( () => [activeView.value, route.name], ([view]) => { if (route.name === 'app-document-detail') { void ensureRequestsLoaded() void refreshSelectedRequestDetail(String(route.params.requestId || '')) return } if (view === 'documents') { void reloadDocumentCenterRequests() return } if (view === 'workbench') { void reloadWorkbenchRequests() } }, { immediate: true } ) const workbenchRequests = computed(() => mergeWorkbenchRequests(requests.value, workbenchApprovalRequests.value) ) const workbenchSummary = computed(() => buildWorkbenchSummary(workbenchRequests.value, currentUser.value) ) const topBarView = computed(() => { if (detailMode.value) { const request = selectedRequest.value || {} const claimNo = request.claimNo || request.claim_no || request.documentNo || request.id || '' const isApplicationDocument = isApplicationDocumentPayload(request, claimNo) return { title: isApplicationDocument ? '申请单详情' : '报销单详情', desc: isApplicationDocument ? '查看申请信息、预计金额与审批进度。' : '查看报销明细、票据材料、审批进度与风险提示。' } } return currentView.value }) const requestSummary = computed(() => filteredRequests.value.reduce( (summary, item) => { const request = normalizeRequestForUi(item) if (!request) { return summary } summary.total += 1 if (request.approvalKey === 'draft') { summary.draft += 1 } else if (request.approvalKey === 'in_progress') { summary.inProgress += 1 } else if (request.approvalKey === 'supplement') { summary.supplement += 1 } else if (request.approvalKey === 'completed') { summary.completed += 1 } return summary }, { total: 0, draft: 0, inProgress: 0, supplement: 0, completed: 0 } ) ) function handleApprove(request) { const message = approveRequest(request) toast(message) } function handleReject(request) { const message = rejectRequest(request) toast(message) } function handleNavigate(view) { smartEntryOpen.value = false const shouldRefreshCurrentDocumentCenter = view === 'documents' && activeView.value === 'documents' && route.name === 'app-documents' const navigation = setView(view) if (shouldRefreshCurrentDocumentCenter) { void reloadDocumentCenterRequests() } return navigation } function openFinancialAssistantCreate(source) { if (smartEntryOpen.value) { smartEntryRevealToken.value += 1 return } smartEntryOpen.value = true smartEntryContext.value = { prompt: '', source, request: null, files: [], conversation: null, scope: null, sessionType: '', budgetContext: null, initialPromptAutoSubmit: true, initialApplicationPreview: null } smartEntrySessionId.value += 1 } function openTravelCreate() { openFinancialAssistantCreate(SMART_ENTRY_SOURCE_REIMBURSEMENT) } function openExpenseApplicationCreate() { openFinancialAssistantCreate(SMART_ENTRY_SOURCE_APPLICATION) } function resolveCurrentUserId() { const user = currentUser.value || {} return String(user.username || user.name || 'anonymous').trim() || 'anonymous' } function resolveSmartEntryClaimScope(payload = {}) { const request = payload.request && typeof payload.request === 'object' ? payload.request : null const payloadScope = payload.scope && typeof payload.scope === 'object' ? payload.scope : null const claimId = String( payloadScope?.claimId || payloadScope?.claim_id || request?.claimId || request?.claim_id || '' ).trim() if (!claimId) { return null } return { type: 'claim', claimId } } function isDetailClaimScopedPayload(payload = {}) { return String(payload.source || '').trim() === 'detail' && Boolean(resolveSmartEntryClaimScope(payload)) } async function resolveSmartEntrySessionType(payload = {}) { const explicitSessionType = String(payload.sessionType || '').trim() if (explicitSessionType) { return explicitSessionType } const source = String(payload.source || 'workbench').trim() if (source !== 'workbench') { return '' } const prompt = String(payload.prompt || '').trim() const files = Array.isArray(payload.files) ? payload.files : [] const fallbackSessionType = resolveWorkbenchSessionTypeFallback(prompt, { attachmentCount: files.length }) if (!prompt) { return fallbackSessionType } if (fallbackSessionType === ASSISTANT_SCOPE_SESSION_STEWARD) { return fallbackSessionType } try { const ontology = await fetchOntologyParse( { query: prompt, user_id: resolveCurrentUserId(), context_json: { ...buildWorkbenchIntentOntologyContext({ currentUser: currentUser.value, files }), user_input_text: prompt, fallback_session_type: fallbackSessionType } }, { timeoutMs: 12000, timeoutMessage: '意图识别超时,已使用本地规则进入助手。' } ) return resolveWorkbenchSessionTypeFromOntology(ontology, prompt, fallbackSessionType) } catch (error) { console.warn('Workbench model intent routing failed, fallback to local routing:', error) return fallbackSessionType } } function isApplicationDocumentPayload(payload = {}, claimNo = '') { const documentType = String( payload.documentType || payload.document_type || payload.documentTypeCode || payload.document_type_code || payload.draftType || payload.draft_type || '' ).trim() const normalizedClaimNo = String(claimNo || payload.claimNo || payload.claim_no || '').trim().toUpperCase() return ( documentType === 'application' || documentType === 'expense_application' || isApplicationDocumentNo(normalizedClaimNo) ) } async function resolveSmartEntryConversation(payload = {}) { if (payload.conversation) { return payload.conversation } if (isDetailClaimScopedPayload(payload)) { return null } if (!payload.restoreLatestConversation) { return null } try { const latestPayload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, { preferRecoverable: true }) return latestPayload?.found ? latestPayload.conversation || null : null } catch (error) { console.warn('Failed to restore latest expense conversation for smart entry:', error) toast(error?.message || '恢复最近报销会话失败,请稍后重试。') return null } } async function openSmartEntry(payload = {}) { const shouldReplaceOpenEntry = Boolean( payload?.source === 'budget' || payload?.sessionType || String(payload?.prompt || '').trim() || (Array.isArray(payload?.files) && payload.files.length) || payload?.conversation || payload?.applicationPreview ) if (smartEntryOpen.value && !shouldReplaceOpenEntry) { smartEntryRevealToken.value += 1 return } const prompt = String(payload.prompt || '').trim() const files = Array.isArray(payload.files) ? payload.files : [] const scopeGuard = prompt ? resolveAssistantScopeGuard(prompt, String(payload.sessionType || '').trim(), { attachmentCount: files.length }) : null if (scopeGuard?.blocked) { smartEntryOpen.value = true smartEntryContext.value = { prompt: '', source: payload.source ?? 'workbench', request: payload.request ?? selectedRequest.value, files, conversation: buildUnsupportedBusinessScopeConversation(prompt, { attachmentCount: files.length }), scope: null, sessionType: ASSISTANT_SCOPE_SESSION_STEWARD, budgetContext: payload.budgetContext && typeof payload.budgetContext === 'object' ? payload.budgetContext : null, initialPromptAutoSubmit: false, initialApplicationPreview: null } smartEntrySessionId.value += 1 return } const [conversation, sessionType] = await Promise.all([ resolveSmartEntryConversation(payload), resolveSmartEntrySessionType(payload) ]) const scope = resolveSmartEntryClaimScope(payload) smartEntryOpen.value = true smartEntryContext.value = { prompt: payload.prompt ?? '', source: payload.source ?? 'workbench', request: payload.request ?? selectedRequest.value, files: Array.isArray(payload.files) ? payload.files : [], conversation, scope, sessionType, budgetContext: payload.budgetContext && typeof payload.budgetContext === 'object' ? payload.budgetContext : null, initialPromptAutoSubmit: payload.initialPromptAutoSubmit !== false, initialApplicationPreview: payload.applicationPreview && typeof payload.applicationPreview === 'object' ? payload.applicationPreview : null } smartEntrySessionId.value += 1 } function closeSmartEntry() { smartEntryOpen.value = false } async function handleDraftSaved(payload = {}) { const claimNo = String(payload.claimNo || payload.claim_no || '').trim() const status = String(payload.status || payload.claimStatus || '').trim() const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim() const isApplicationDocument = isApplicationDocumentPayload(payload, claimNo) await reloadWorkbenchRequests() if (status === 'submitted') { if (isApplicationDocument) { toast(`${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}。`) return } smartEntryOpen.value = false toast(`${claimNo || '该'}单据已提交审批${approvalStage ? `,当前节点:${approvalStage}` : '。'}`) router.push({ name: 'app-documents' }) return } toast( isApplicationDocument ? `${claimNo || '该'}申请单已保存为草稿,可继续补充申请信息。` : `${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息。` ) } function buildDocumentDetailQuery(options = {}) { const nextQuery = { ...route.query } const returnTo = String(options.returnTo || '').trim() if (returnTo === 'workbench') { nextQuery.returnTo = 'workbench' } else { delete nextQuery.returnTo } return nextQuery } function buildDocumentReturnQuery() { const { returnTo, ...nextQuery } = route.query return nextQuery } function openRequestDetail(request, options = {}) { const requestId = resolveRequestDetailLookupId(request) if (!requestId) { return } const isDetailLookupOnlyRequest = isDetailLookupOnlyPayload(request) selectedRequestSnapshot.value = isDetailLookupOnlyRequest ? null : request || null router.push({ name: 'app-document-detail', params: { requestId }, query: buildDocumentDetailQuery(options) }) void refreshSelectedRequestDetail(isDetailLookupOnlyRequest ? requestId : request) } function closeRequestDetail() { if (detailReturnTarget.value === 'workbench') { router.push({ name: 'app-workbench' }) return } router.push({ name: 'app-documents', query: buildDocumentReturnQuery() }) } async function handleRequestUpdated(payload = {}) { if (payload?.claim && typeof payload.claim === 'object') { const mappedRequest = mapExpenseClaimToRequest(payload.claim) upsertRequestSnapshot(mappedRequest) } const claimId = String(payload?.claimId || payload?.claim_id || route.params.requestId || '').trim() await reloadWorkbenchRequests() await refreshSelectedRequestDetail(claimId) } async function handleRequestDeleted(payload = {}) { const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim() if (deletedClaimId) { clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE) markAiWorkbenchConversationDraftDeleted(currentUser.value || {}, payload) smartEntryInvalidatedDraftClaimId.value = deletedClaimId } await reloadRequests() selectedRequestSnapshot.value = null router.push({ name: 'app-documents', query: buildDocumentReturnQuery() }) } return { activeRange, activeView, closeRequestDetail, closeSmartEntry, currentView, customRange, detailMode, documentCenterRefreshToken, filteredRequests, filters, handleApprove, handleDraftSaved, handleNavigate, handleReject, handleRequestDeleted, handleRequestUpdated, navItems, openExpenseApplicationCreate, openRequestDetail, openSmartEntry, openTravelCreate, ranges, requestSummary, workbenchSummary, requestsError, requestsLoading, reloadRequests, reloadDocumentCenterRequests, requests, search, selectedRequest, setView, smartEntryContext, smartEntryInvalidatedDraftClaimId, smartEntryOpen, smartEntryRevealToken, smartEntrySessionId, detailAlerts, detailBackLabel, toast, topBarView } }