Files
X-Financial/web/src/composables/useAppShell.js

437 lines
13 KiB
JavaScript
Raw Normal View History

import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js'
import { fetchOntologyParse } from '../services/ontology.js'
import { fetchLatestConversation } from '../services/orchestrator.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.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'
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: ''
})
const smartEntrySessionId = ref(0)
const smartEntryRevealToken = ref(0)
const smartEntryInvalidatedDraftClaimId = ref('')
const selectedRequestSnapshot = ref(null)
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({ start: '2024-07-06', end: '2024-07-12' })
const selectedRequest = computed(() => {
const requestId = String(route.params.requestId || '')
if (!requestId) {
return null
}
const rawRequest = requests.value.find(
(item) => String(item.claimId || '').trim() === requestId || String(item.id || '').trim() === requestId
)
const normalizedRequest = normalizeRequestForUi(rawRequest)
if (normalizedRequest) {
return normalizedRequest
}
const snapshot = normalizeRequestForUi(selectedRequestSnapshot.value)
if (
snapshot
&& (
String(snapshot.claimId || '').trim() === requestId
|| String(snapshot.id || '').trim() === requestId
|| String(snapshot.documentNo || '').trim() === requestId
)
) {
return snapshot
}
return null
})
const detailMode = computed(() => route.name === 'app-document-detail')
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
const requestsNeeded = computed(() => ['documents', 'workbench'].includes(activeView.value))
watch(
requestsNeeded,
(isNeeded) => {
if (isNeeded) {
void ensureRequestsLoaded()
}
},
{ immediate: true }
)
const workbenchSummary = computed(() =>
buildWorkbenchSummary(requests.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
setView(view)
}
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: ''
}
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
}
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'
|| normalizedClaimNo.startsWith('AP-')
|| normalizedClaimNo.startsWith('APP-')
)
}
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
)
if (smartEntryOpen.value && !shouldReplaceOpenEntry) {
smartEntryRevealToken.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
}
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 reloadRequests()
if (status === 'submitted') {
if (isApplicationDocument) {
toast(`${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`)
return
}
smartEntryOpen.value = false
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
router.push({ name: 'app-documents' })
return
}
toast(
isApplicationDocument
? `${claimNo || '该'}申请单已保存为草稿,可继续补充申请信息。`
: `${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息。`
)
}
function openRequestDetail(request) {
selectedRequestSnapshot.value = request || null
router.push({
name: 'app-document-detail',
params: { requestId: request.claimId || request.id }
})
}
function closeRequestDetail() {
router.push({ name: 'app-documents' })
}
async function handleRequestUpdated() {
await reloadRequests()
}
async function handleRequestDeleted(payload = {}) {
const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim()
if (deletedClaimId) {
clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE)
smartEntryInvalidatedDraftClaimId.value = deletedClaimId
}
await reloadRequests()
selectedRequestSnapshot.value = null
router.push({ name: 'app-documents' })
}
return {
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
currentView,
customRange,
detailMode,
filteredRequests,
filters,
handleApprove,
handleDraftSaved,
handleNavigate,
handleReject,
handleRequestDeleted,
handleRequestUpdated,
navItems,
openExpenseApplicationCreate,
openRequestDetail,
openSmartEntry,
openTravelCreate,
ranges,
requestSummary,
workbenchSummary,
requestsError,
requestsLoading,
reloadRequests,
requests,
search,
selectedRequest,
setView,
smartEntryContext,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntryRevealToken,
smartEntrySessionId,
detailAlerts,
toast,
topBarView
}
}