后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL 校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计, 优化 agent 运行和编排执行链路,清理旧开发文档,前端新增 系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈 对话框和工作台日期选择器,优化报销创建和审批详情交互, 补充单元测试覆盖。
437 lines
13 KiB
JavaScript
437 lines
13 KiB
JavaScript
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
|
|
}
|
|
}
|