feat: 风险可见性控制与差旅详情页交互优化
- 新增风险可见性工具函数与风险日趋势图表组件 - 优化差旅请求详情页费用模型与视图交互 - 完善顶部导航栏样式与应用壳路由逻辑 - 补充风险可见性、风险看板与差旅详情测试覆盖
This commit is contained in:
@@ -2,9 +2,10 @@ 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 { mapExpenseClaimToRequest, useRequests } from './useRequests.js'
|
||||
import { useSystemState } from './useSystemState.js'
|
||||
import { useToast } from './useToast.js'
|
||||
import { fetchExpenseClaimDetail } from '../services/reimbursements.js'
|
||||
import { fetchOntologyParse } from '../services/ontology.js'
|
||||
import { fetchLatestConversation } from '../services/orchestrator.js'
|
||||
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
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'
|
||||
@@ -62,36 +64,29 @@ export function useAppShell() {
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
|
||||
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
|
||||
const customRange = ref(createCurrentYearDateRange())
|
||||
|
||||
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 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')
|
||||
@@ -110,9 +105,81 @@ export function useAppShell() {
|
||||
return reloadRequests()
|
||||
}
|
||||
|
||||
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 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
|
||||
|| ''
|
||||
).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
|
||||
@@ -425,6 +492,7 @@ export function useAppShell() {
|
||||
params: { requestId: request.claimId || request.id },
|
||||
query: buildDocumentDetailQuery(options)
|
||||
})
|
||||
void refreshSelectedRequestDetail(request)
|
||||
}
|
||||
|
||||
function closeRequestDetail() {
|
||||
@@ -438,6 +506,7 @@ export function useAppShell() {
|
||||
|
||||
async function handleRequestUpdated() {
|
||||
await reloadRequests()
|
||||
await refreshSelectedRequestDetail(String(route.params.requestId || ''))
|
||||
}
|
||||
|
||||
async function handleRequestDeleted(payload = {}) {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
|
||||
const DEFAULT_OVERVIEW_RANGE = '近10日'
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const RISK_DAILY_TREND_MAX_BUCKETS = 14
|
||||
|
||||
const emptyFinanceTotals = {
|
||||
reimbursementAmount: 0,
|
||||
@@ -137,6 +138,64 @@ function resolveTopRangeKey(range, customRange = {}) {
|
||||
return key || DEFAULT_OVERVIEW_RANGE
|
||||
}
|
||||
|
||||
function formatRiskTrendDateLabel(value) {
|
||||
const date = parseLocalDate(value)
|
||||
if (!date) {
|
||||
return String(value || '-').trim() || '-'
|
||||
}
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${month}-${day}`
|
||||
}
|
||||
|
||||
function buildRiskTrendBucketLabel(first, last) {
|
||||
const start = String(first?.date || '').trim()
|
||||
const end = String(last?.date || '').trim()
|
||||
if (!start || start === end) {
|
||||
return formatRiskTrendDateLabel(start)
|
||||
}
|
||||
return `${formatRiskTrendDateLabel(start)}~${formatRiskTrendDateLabel(end)}`
|
||||
}
|
||||
|
||||
function normalizeRiskTrendRow(item) {
|
||||
return {
|
||||
date: String(item.date || '').trim() || '-',
|
||||
total: Number(item.total || 0),
|
||||
highOrAbove: Number(item.high_or_above ?? item.highOrAbove ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
function aggregateRiskDailyTrendRows(rows, maxBuckets = RISK_DAILY_TREND_MAX_BUCKETS) {
|
||||
const normalizedRows = rows
|
||||
.map(normalizeRiskTrendRow)
|
||||
.filter((item) => item.date !== '-' || item.total > 0 || item.highOrAbove > 0)
|
||||
|
||||
if (normalizedRows.length <= maxBuckets) {
|
||||
return normalizedRows.map((item) => ({
|
||||
...item,
|
||||
date: formatRiskTrendDateLabel(item.date),
|
||||
sourceStartDate: item.date,
|
||||
sourceEndDate: item.date
|
||||
}))
|
||||
}
|
||||
|
||||
const bucketSize = Math.ceil(normalizedRows.length / maxBuckets)
|
||||
const buckets = []
|
||||
for (let index = 0; index < normalizedRows.length; index += bucketSize) {
|
||||
const bucketRows = normalizedRows.slice(index, index + bucketSize)
|
||||
const first = bucketRows[0]
|
||||
const last = bucketRows[bucketRows.length - 1]
|
||||
buckets.push({
|
||||
date: buildRiskTrendBucketLabel(first, last),
|
||||
sourceStartDate: first?.date || '',
|
||||
sourceEndDate: last?.date || '',
|
||||
total: bucketRows.reduce((sum, item) => sum + item.total, 0),
|
||||
highOrAbove: bucketRows.reduce((sum, item) => sum + item.highOrAbove, 0)
|
||||
})
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
export function useOverviewView(options = {}) {
|
||||
const activeDashboardKey = computed(() => {
|
||||
const dashboard = String(options.dashboard || '').trim()
|
||||
@@ -830,11 +889,7 @@ export function useOverviewView(options = {}) {
|
||||
})
|
||||
const riskDailyTrendRows = computed(() => {
|
||||
const rows = Array.isArray(riskDashboard.value.dailyTrend) ? riskDashboard.value.dailyTrend : []
|
||||
const normalizedRows = rows.slice(-7).map((item) => ({
|
||||
date: String(item.date || '').trim() || '-',
|
||||
total: Number(item.total || 0),
|
||||
highOrAbove: Number(item.high_or_above ?? item.highOrAbove ?? 0)
|
||||
}))
|
||||
const normalizedRows = aggregateRiskDailyTrendRows(rows)
|
||||
const maxValue = Math.max(...normalizedRows.map((item) => item.total), 1)
|
||||
|
||||
return normalizedRows.map((item) => ({
|
||||
|
||||
90
web/src/composables/useTopBarWorkbenchPopovers.js
Normal file
90
web/src/composables/useTopBarWorkbenchPopovers.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
/** 工作台 TopBar 帮助气泡静态演示数据 */
|
||||
export const TOPBAR_HELP_DEMO = {
|
||||
productName: 'Smart Expense Operations',
|
||||
version: 'v0.1.0',
|
||||
copyright: 'Copyright © 2024-2026 X-Financial. All Rights Reserved.',
|
||||
updatedAt: '2026-06-03'
|
||||
}
|
||||
|
||||
export function useTopBarWorkbenchPopovers() {
|
||||
const notificationOpen = ref(false)
|
||||
const helpOpen = ref(false)
|
||||
const notificationWrapRef = ref(null)
|
||||
const helpWrapRef = ref(null)
|
||||
|
||||
function toggleNotification() {
|
||||
const next = !notificationOpen.value
|
||||
notificationOpen.value = next
|
||||
if (next) {
|
||||
helpOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleHelp() {
|
||||
const next = !helpOpen.value
|
||||
helpOpen.value = next
|
||||
if (next) {
|
||||
notificationOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeNotification() {
|
||||
notificationOpen.value = false
|
||||
}
|
||||
|
||||
function closeHelp() {
|
||||
helpOpen.value = false
|
||||
}
|
||||
|
||||
function isInsideWrap(wrapRef, target) {
|
||||
const root = wrapRef.value
|
||||
return Boolean(root && target instanceof Node && root.contains(target))
|
||||
}
|
||||
|
||||
function handleTopBarPopoverOutside(event) {
|
||||
if (!notificationOpen.value && !helpOpen.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) {
|
||||
notificationOpen.value = false
|
||||
helpOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (notificationOpen.value && !isInsideWrap(notificationWrapRef, target)) {
|
||||
notificationOpen.value = false
|
||||
}
|
||||
|
||||
if (helpOpen.value && !isInsideWrap(helpWrapRef, target)) {
|
||||
helpOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('pointerdown', handleTopBarPopoverOutside, true)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('pointerdown', handleTopBarPopoverOutside, true)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
notificationOpen,
|
||||
helpOpen,
|
||||
notificationWrapRef,
|
||||
helpWrapRef,
|
||||
topbarHelpDemo: TOPBAR_HELP_DEMO,
|
||||
toggleNotification,
|
||||
toggleHelp,
|
||||
closeNotification,
|
||||
closeHelp
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user