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

662 lines
20 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 { 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 workbenchRequests = computed(() =>
mergeWorkbenchRequests(requests.value, workbenchApprovalRequests.value)
)
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 = workbenchRequests.value.find((item) => isSameRequestIdentity(item, 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 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,
workbenchRequests,
workbenchSummary,
requestsError,
requestsLoading,
reloadRequests,
reloadDocumentCenterRequests,
requests,
search,
selectedRequest,
setView,
smartEntryContext,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntryRevealToken,
smartEntrySessionId,
detailAlerts,
detailBackLabel,
toast,
topBarView
}
}