feat: 新增风险图谱算法与系统仪表盘及操作反馈体系
后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL 校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计, 优化 agent 运行和编排执行链路,清理旧开发文档,前端新增 系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈 对话框和工作台日期选择器,优化报销创建和审批详情交互, 补充单元测试覆盖。
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useNavigation, navItems } from './useNavigation.js'
|
||||
import { useRequests } from './useRequests.js'
|
||||
import { useRequests } from './useRequests.js'
|
||||
import { useSystemState } from './useSystemState.js'
|
||||
import { useToast } from './useToast.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 { buildWorkbenchSummary } from '../utils/workbenchSummary.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'
|
||||
@@ -24,10 +30,11 @@ export function useAppShell() {
|
||||
prompt: '',
|
||||
source: 'documents',
|
||||
request: null,
|
||||
files: [],
|
||||
conversation: null,
|
||||
scope: null
|
||||
})
|
||||
files: [],
|
||||
conversation: null,
|
||||
scope: null,
|
||||
sessionType: ''
|
||||
})
|
||||
const smartEntrySessionId = ref(0)
|
||||
const smartEntryRevealToken = ref(0)
|
||||
const smartEntryInvalidatedDraftClaimId = ref('')
|
||||
@@ -48,10 +55,10 @@ export function useAppShell() {
|
||||
ensureLoaded: ensureRequestsLoaded,
|
||||
reload: reloadRequests
|
||||
} = useRequests()
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
|
||||
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
|
||||
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 || '')
|
||||
@@ -171,7 +178,8 @@ export function useAppShell() {
|
||||
request: null,
|
||||
files: [],
|
||||
conversation: null,
|
||||
scope: null
|
||||
scope: null,
|
||||
sessionType: ''
|
||||
}
|
||||
smartEntrySessionId.value += 1
|
||||
}
|
||||
@@ -209,6 +217,52 @@ export function useAppShell() {
|
||||
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
|
||||
@@ -227,32 +281,32 @@ export function useAppShell() {
|
||||
|| 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 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'
|
||||
@@ -265,38 +319,42 @@ export function useAppShell() {
|
||||
smartEntryRevealToken.value += 1
|
||||
return
|
||||
}
|
||||
const conversation = await resolveSmartEntryConversation(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
|
||||
}
|
||||
smartEntrySessionId.value += 1
|
||||
}
|
||||
|
||||
function closeSmartEntry() {
|
||||
smartEntryOpen.value = false
|
||||
}
|
||||
|
||||
async function handleDraftSaved(payload = {}) {
|
||||
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(
|
||||
isApplicationDocument
|
||||
? `${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}。`
|
||||
: `${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`
|
||||
)
|
||||
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
||||
router.push({ name: 'app-documents' })
|
||||
return
|
||||
}
|
||||
@@ -306,7 +364,7 @@ export function useAppShell() {
|
||||
: `${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息。`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function openRequestDetail(request) {
|
||||
selectedRequestSnapshot.value = request || null
|
||||
router.push({
|
||||
@@ -322,57 +380,57 @@ export function useAppShell() {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
|
||||
return {
|
||||
activeRange,
|
||||
activeView,
|
||||
closeRequestDetail,
|
||||
closeSmartEntry,
|
||||
currentView,
|
||||
customRange,
|
||||
detailMode,
|
||||
filteredRequests,
|
||||
filters,
|
||||
handleApprove,
|
||||
handleDraftSaved,
|
||||
handleNavigate,
|
||||
handleReject,
|
||||
handleRequestDeleted,
|
||||
filters,
|
||||
handleApprove,
|
||||
handleDraftSaved,
|
||||
handleNavigate,
|
||||
handleReject,
|
||||
handleRequestDeleted,
|
||||
handleRequestUpdated,
|
||||
navItems,
|
||||
openExpenseApplicationCreate,
|
||||
openRequestDetail,
|
||||
openSmartEntry,
|
||||
openTravelCreate,
|
||||
ranges,
|
||||
requestSummary,
|
||||
workbenchSummary,
|
||||
requestsError,
|
||||
requestsLoading,
|
||||
reloadRequests,
|
||||
requests,
|
||||
search,
|
||||
selectedRequest,
|
||||
setView,
|
||||
smartEntryContext,
|
||||
openTravelCreate,
|
||||
ranges,
|
||||
requestSummary,
|
||||
workbenchSummary,
|
||||
requestsError,
|
||||
requestsLoading,
|
||||
reloadRequests,
|
||||
requests,
|
||||
search,
|
||||
selectedRequest,
|
||||
setView,
|
||||
smartEntryContext,
|
||||
smartEntryInvalidatedDraftClaimId,
|
||||
smartEntryOpen,
|
||||
smartEntryRevealToken,
|
||||
smartEntrySessionId,
|
||||
detailAlerts,
|
||||
toast,
|
||||
topBarView
|
||||
}
|
||||
}
|
||||
detailAlerts,
|
||||
toast,
|
||||
topBarView
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useSystemState } from './useSystemState.js'
|
||||
import { runOrchestrator } from '../services/orchestrator.js'
|
||||
import { filterVisibleMessageMeta } from '../utils/assistantMessageMeta.js'
|
||||
|
||||
const initialMessages = [
|
||||
{
|
||||
@@ -73,18 +74,6 @@ export function useChat(activeView) {
|
||||
function buildOrchestratorMeta(payload) {
|
||||
const items = []
|
||||
|
||||
if (payload?.selected_agent) {
|
||||
items.push(`Agent: ${payload.selected_agent}`)
|
||||
}
|
||||
|
||||
if (payload?.permission_level) {
|
||||
items.push(`权限: ${payload.permission_level}`)
|
||||
}
|
||||
|
||||
if (payload?.trace_summary?.tool_count) {
|
||||
items.push(`工具: ${payload.trace_summary.tool_count}`)
|
||||
}
|
||||
|
||||
if (payload?.trace_summary?.degraded) {
|
||||
items.push('已降级')
|
||||
}
|
||||
@@ -93,11 +82,7 @@ export function useChat(activeView) {
|
||||
items.push('待确认')
|
||||
}
|
||||
|
||||
if (payload?.run_id) {
|
||||
items.push(`Run: ${payload.run_id}`)
|
||||
}
|
||||
|
||||
return items
|
||||
return filterVisibleMessageMeta(items)
|
||||
}
|
||||
|
||||
function buildAssistantMessage(payload, fallbackText) {
|
||||
|
||||
161
web/src/composables/useOperationFeedback.js
Normal file
161
web/src/composables/useOperationFeedback.js
Normal file
@@ -0,0 +1,161 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { createOperationFeedback } from '../services/operationFeedback.js'
|
||||
|
||||
const LOW_RATING_MAX = 3
|
||||
|
||||
function pickText(...values) {
|
||||
for (const value of values) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveUserId(currentUser = {}) {
|
||||
return pickText(
|
||||
currentUser.username,
|
||||
currentUser.email,
|
||||
currentUser.name,
|
||||
'anonymous'
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeOperationFeedbackContext(context = {}, currentUser = {}) {
|
||||
const traceSummary = context.trace_summary || context.traceSummary || null
|
||||
const result = context.result && typeof context.result === 'object' ? context.result : {}
|
||||
const resultSummary = pickText(
|
||||
context.result_summary,
|
||||
context.resultSummary,
|
||||
result.answer,
|
||||
result.message
|
||||
)
|
||||
|
||||
return {
|
||||
runId: pickText(context.run_id, context.runId),
|
||||
conversationId: pickText(context.conversation_id, context.conversationId),
|
||||
userId: pickText(context.user_id, context.userId, resolveUserId(currentUser)),
|
||||
agent: pickText(context.selected_agent, context.selectedAgent, context.agent),
|
||||
source: pickText(context.source, 'user_message'),
|
||||
sessionType: pickText(context.session_type, context.sessionType),
|
||||
operationType: pickText(context.operation_type, context.operationType, 'assistant_round'),
|
||||
operationStatus: pickText(context.operation_status, context.operationStatus, context.status),
|
||||
routeReason: pickText(context.route_reason, context.routeReason),
|
||||
entrySource: pickText(context.entry_source, context.entrySource),
|
||||
traceSummary,
|
||||
resultSummary: resultSummary.slice(0, 500)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOperationFeedbackPayload(context = {}, feedback = {}, currentUser = {}) {
|
||||
const normalizedContext = normalizeOperationFeedbackContext(context, currentUser)
|
||||
const rating = Number(feedback.rating || 0)
|
||||
const reason = String(feedback.reason || '').trim()
|
||||
|
||||
return {
|
||||
run_id: normalizedContext.runId || null,
|
||||
conversation_id: normalizedContext.conversationId || null,
|
||||
user_id: normalizedContext.userId || null,
|
||||
agent: normalizedContext.agent || null,
|
||||
source: normalizedContext.source || null,
|
||||
session_type: normalizedContext.sessionType || null,
|
||||
operation_type: normalizedContext.operationType || 'assistant_round',
|
||||
operation_status: normalizedContext.operationStatus || null,
|
||||
rating,
|
||||
reason: reason || null,
|
||||
context_json: {
|
||||
entry_source: normalizedContext.entrySource,
|
||||
route_reason: normalizedContext.routeReason,
|
||||
trace_summary: normalizedContext.traceSummary,
|
||||
result_summary: normalizedContext.resultSummary,
|
||||
low_rating: rating > 0 && rating <= LOW_RATING_MAX
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useOperationFeedback({ currentUser, toast } = {}) {
|
||||
const operationFeedbackDialog = ref({
|
||||
open: false,
|
||||
submitting: false,
|
||||
error: '',
|
||||
context: null
|
||||
})
|
||||
const promptedRunIds = new Set()
|
||||
|
||||
function openOperationFeedback(context = {}) {
|
||||
const normalized = normalizeOperationFeedbackContext(context, currentUser?.value || {})
|
||||
if (!normalized.runId || promptedRunIds.has(normalized.runId)) {
|
||||
return
|
||||
}
|
||||
if (normalized.operationStatus && normalized.operationStatus !== 'succeeded') {
|
||||
return
|
||||
}
|
||||
|
||||
promptedRunIds.add(normalized.runId)
|
||||
globalThis.setTimeout(() => {
|
||||
operationFeedbackDialog.value = {
|
||||
open: true,
|
||||
submitting: false,
|
||||
error: '',
|
||||
context: normalized
|
||||
}
|
||||
}, 320)
|
||||
}
|
||||
|
||||
function closeOperationFeedback() {
|
||||
if (operationFeedbackDialog.value.submitting) {
|
||||
return
|
||||
}
|
||||
operationFeedbackDialog.value = {
|
||||
...operationFeedbackDialog.value,
|
||||
open: false,
|
||||
error: ''
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOperationFeedbackRating(feedback = {}) {
|
||||
const rating = Number(feedback.rating || 0)
|
||||
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
|
||||
operationFeedbackDialog.value = {
|
||||
...operationFeedbackDialog.value,
|
||||
error: '请选择 1 到 5 星评分。'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const context = operationFeedbackDialog.value.context || {}
|
||||
operationFeedbackDialog.value = {
|
||||
...operationFeedbackDialog.value,
|
||||
submitting: true,
|
||||
error: ''
|
||||
}
|
||||
|
||||
try {
|
||||
await createOperationFeedback(
|
||||
buildOperationFeedbackPayload(context, feedback, currentUser?.value || {})
|
||||
)
|
||||
operationFeedbackDialog.value = {
|
||||
open: false,
|
||||
submitting: false,
|
||||
error: '',
|
||||
context: null
|
||||
}
|
||||
toast?.('评价已记录,后续会纳入智能体质量统计。')
|
||||
} catch (error) {
|
||||
operationFeedbackDialog.value = {
|
||||
...operationFeedbackDialog.value,
|
||||
submitting: false,
|
||||
error: error?.message || '评价提交失败,请稍后重试。'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
operationFeedbackDialog,
|
||||
openOperationFeedback,
|
||||
closeOperationFeedback,
|
||||
submitOperationFeedbackRating
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,53 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { fetchFinanceDashboard, fetchSystemDashboard } from '../services/analytics.js'
|
||||
import { fetchRiskObservationDashboard } from '../services/riskObservations.js'
|
||||
|
||||
import {
|
||||
metricBlueprints,
|
||||
systemMetricBlueprints,
|
||||
trendRanges,
|
||||
trendSeries,
|
||||
spendByCategory,
|
||||
exceptionMix,
|
||||
spendByCategory as fallbackSpendByCategory,
|
||||
exceptionMix as fallbackExceptionMix,
|
||||
departmentRangeOptions,
|
||||
bottlenecks,
|
||||
budgetSummary
|
||||
bottlenecks as fallbackBottlenecks,
|
||||
budgetSummary as fallbackBudgetSummary,
|
||||
systemDashboardTotals as fallbackSystemDashboardTotals,
|
||||
systemAgentDailyRatio as fallbackSystemAgentDailyRatio,
|
||||
systemLoginWave as fallbackSystemLoginWave,
|
||||
systemTokenDailyWave as fallbackSystemTokenDailyWave,
|
||||
systemUsageDurationSummary as fallbackSystemUsageDurationSummary,
|
||||
systemUserTokenUsage as fallbackSystemUserTokenUsage,
|
||||
systemAccuracyComparison as fallbackSystemAccuracyComparison,
|
||||
systemTrendSeries,
|
||||
systemToolCallMix,
|
||||
systemExecutionMix,
|
||||
systemToolRankings,
|
||||
systemModelUsage,
|
||||
systemFeedbackSummary as fallbackSystemFeedbackSummary,
|
||||
systemLoadHeatmap,
|
||||
systemToolDetailRows as fallbackSystemToolDetailRows
|
||||
} from '../data/metrics.js'
|
||||
|
||||
export function useOverviewView() {
|
||||
export function useOverviewView(options = {}) {
|
||||
const activeTrendRange = ref(trendRanges[0])
|
||||
const activeDepartmentRange = ref(departmentRangeOptions[0])
|
||||
const riskWindowOptions = [
|
||||
{ label: '近 7 天', value: 7 },
|
||||
{ label: '近 30 天', value: 30 },
|
||||
{ label: '近 90 天', value: 90 }
|
||||
]
|
||||
const activeRiskWindowDays = ref(30)
|
||||
const financeDashboardPayload = ref(null)
|
||||
const financeDashboardLoading = ref(false)
|
||||
const financeDashboardError = ref(null)
|
||||
const systemDashboardPayload = ref(null)
|
||||
const systemDashboardLoading = ref(false)
|
||||
const systemDashboardError = ref(null)
|
||||
const riskDashboardPayload = ref(null)
|
||||
const riskDashboardLoading = ref(false)
|
||||
const riskDashboardError = ref(null)
|
||||
|
||||
const demoTotals = {
|
||||
pendingCount: 128,
|
||||
@@ -40,6 +74,15 @@ export function useOverviewView() {
|
||||
|
||||
const formatCurrency = (value) => formatCompact(value)
|
||||
|
||||
const formatNumberCompact = (value) => {
|
||||
const number = Number(value || 0)
|
||||
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`
|
||||
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`
|
||||
return `${Math.round(number)}`
|
||||
}
|
||||
|
||||
const formatPercent = (value) => `${Math.round(Number(value || 0) * 100)}%`
|
||||
|
||||
const formatMetricValue = (metric, value) => {
|
||||
if (metric.key === 'pendingAmount') return formatCurrency(Math.round(value))
|
||||
if (metric.key === 'avgSla') return `${value.toFixed(1)} ${metric.unit}`
|
||||
@@ -48,34 +91,382 @@ export function useOverviewView() {
|
||||
return `${Math.round(value)}`
|
||||
}
|
||||
|
||||
const formatSystemMetricValue = (metric, value) => {
|
||||
const numericValue = Number(value || 0)
|
||||
if (metric.key === 'modelTokens') return formatNumberCompact(numericValue)
|
||||
if (metric.key === 'avgOnlineMinutes') return `${numericValue.toFixed(1)} ${metric.unit}`
|
||||
if (metric.key === 'executionSuccessRate') return `${numericValue.toFixed(1)}${metric.unit}`
|
||||
if (metric.key === 'positiveFeedback') {
|
||||
const negativeFeedback = Math.round(Number(systemDashboardTotals.value.negativeFeedback || 0))
|
||||
return `${Math.round(numericValue)} / ${negativeFeedback}`
|
||||
}
|
||||
if (metric.unit) return `${formatNumberCompact(numericValue)} ${metric.unit}`
|
||||
return formatNumberCompact(numericValue)
|
||||
}
|
||||
|
||||
const getFinanceRangeParams = () => {
|
||||
const activeRange = String(options.activeRange || '近10日')
|
||||
const customRange = options.customRange || {}
|
||||
const isCustomRange = activeRange === 'custom'
|
||||
|
||||
return {
|
||||
rangeKey: activeRange,
|
||||
startDate: isCustomRange ? customRange.start : '',
|
||||
endDate: isCustomRange ? customRange.end : '',
|
||||
trendRange: activeTrendRange.value,
|
||||
departmentRange: activeDepartmentRange.value
|
||||
}
|
||||
}
|
||||
|
||||
const loadFinanceDashboard = async () => {
|
||||
financeDashboardLoading.value = true
|
||||
financeDashboardError.value = null
|
||||
|
||||
try {
|
||||
financeDashboardPayload.value = await fetchFinanceDashboard(getFinanceRangeParams())
|
||||
} catch (error) {
|
||||
financeDashboardPayload.value = null
|
||||
financeDashboardError.value = error
|
||||
} finally {
|
||||
financeDashboardLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadSystemDashboard = async () => {
|
||||
systemDashboardLoading.value = true
|
||||
systemDashboardError.value = null
|
||||
|
||||
try {
|
||||
systemDashboardPayload.value = await fetchSystemDashboard({ days: 7 })
|
||||
} catch (error) {
|
||||
systemDashboardPayload.value = null
|
||||
systemDashboardError.value = error
|
||||
} finally {
|
||||
systemDashboardLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadRiskDashboard = async () => {
|
||||
riskDashboardLoading.value = true
|
||||
riskDashboardError.value = null
|
||||
|
||||
try {
|
||||
riskDashboardPayload.value = await fetchRiskObservationDashboard({
|
||||
windowDays: activeRiskWindowDays.value,
|
||||
limit: 500
|
||||
})
|
||||
} catch (error) {
|
||||
riskDashboardPayload.value = null
|
||||
riskDashboardError.value = error
|
||||
} finally {
|
||||
riskDashboardLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setRiskWindowDays = (value) => {
|
||||
const days = Number(value || 30)
|
||||
const matched = riskWindowOptions.some((item) => Number(item.value) === days)
|
||||
activeRiskWindowDays.value = matched ? days : 30
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadFinanceDashboard()
|
||||
void loadSystemDashboard()
|
||||
void loadRiskDashboard()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [
|
||||
options.activeRange,
|
||||
options.customRange?.start,
|
||||
options.customRange?.end,
|
||||
activeTrendRange.value,
|
||||
activeDepartmentRange.value
|
||||
],
|
||||
() => {
|
||||
void loadFinanceDashboard()
|
||||
}
|
||||
)
|
||||
|
||||
watch(activeRiskWindowDays, () => {
|
||||
void loadRiskDashboard()
|
||||
})
|
||||
|
||||
const systemDashboardTotals = computed(() => (
|
||||
systemDashboardPayload.value?.totals || fallbackSystemDashboardTotals
|
||||
))
|
||||
const systemAgentDailyRatio = computed(() => (
|
||||
systemDashboardPayload.value?.agentDailyRatio || fallbackSystemAgentDailyRatio
|
||||
))
|
||||
const systemLoginWave = computed(() => (
|
||||
systemDashboardPayload.value?.loginWave || fallbackSystemLoginWave
|
||||
))
|
||||
const systemTokenDailyWave = computed(() => (
|
||||
systemDashboardPayload.value?.tokenDailyWave || fallbackSystemTokenDailyWave
|
||||
))
|
||||
const systemUsageDurationSummary = computed(() => (
|
||||
systemDashboardPayload.value?.usageDurationSummary || fallbackSystemUsageDurationSummary
|
||||
))
|
||||
const systemUserTokenUsage = computed(() => (
|
||||
systemDashboardPayload.value?.userTokenUsage || fallbackSystemUserTokenUsage
|
||||
))
|
||||
const systemAccuracyComparison = computed(() => (
|
||||
systemDashboardPayload.value?.accuracyComparison || fallbackSystemAccuracyComparison
|
||||
))
|
||||
const systemFeedbackSummary = computed(() => (
|
||||
systemDashboardPayload.value?.feedbackSummary || fallbackSystemFeedbackSummary
|
||||
))
|
||||
const systemToolDetailRows = computed(() => (
|
||||
systemDashboardPayload.value?.toolDetailRows || fallbackSystemToolDetailRows
|
||||
))
|
||||
const riskDashboard = computed(() => (
|
||||
riskDashboardPayload.value || {
|
||||
windowDays: activeRiskWindowDays.value,
|
||||
totalObservations: 0,
|
||||
pendingCount: 0,
|
||||
highOrAboveCount: 0,
|
||||
confirmedCount: 0,
|
||||
falsePositiveCount: 0,
|
||||
totalAmount: 0,
|
||||
averageScore: 0,
|
||||
confirmationRate: 0,
|
||||
falsePositiveRate: 0,
|
||||
candidateRuleCount: 0,
|
||||
levelDistribution: {},
|
||||
statusDistribution: {},
|
||||
signalDistribution: {},
|
||||
sourceDistribution: {},
|
||||
automationDistribution: {},
|
||||
departmentDistribution: {},
|
||||
expenseTypeDistribution: {},
|
||||
riskTypeDistribution: {},
|
||||
supplierDistribution: {},
|
||||
employeeGradeDistribution: {},
|
||||
dailyTrend: [],
|
||||
topRiskSignals: [],
|
||||
topDepartments: [],
|
||||
topEmployees: [],
|
||||
topSuppliers: [],
|
||||
topExpenseTypes: [],
|
||||
topRules: [],
|
||||
recentHighObservations: []
|
||||
}
|
||||
))
|
||||
const financeDashboardTotals = computed(() => (
|
||||
financeDashboardPayload.value?.totals || demoTotals
|
||||
))
|
||||
const financeMetricMeta = computed(() => (
|
||||
financeDashboardPayload.value?.metricMeta || {}
|
||||
))
|
||||
const financeTrend = computed(() => (
|
||||
financeDashboardPayload.value?.trend || trendSeries[activeTrendRange.value]
|
||||
))
|
||||
const financeSpendByCategory = computed(() => (
|
||||
financeDashboardPayload.value?.spendByCategory || fallbackSpendByCategory
|
||||
))
|
||||
const financeExceptionMix = computed(() => (
|
||||
financeDashboardPayload.value?.exceptionMix || fallbackExceptionMix
|
||||
))
|
||||
const financeDepartmentRanking = computed(() => (
|
||||
financeDashboardPayload.value?.departmentRanking || demoDepartments
|
||||
))
|
||||
const financeBottlenecks = computed(() => (
|
||||
financeDashboardPayload.value?.bottlenecks || fallbackBottlenecks
|
||||
))
|
||||
const financeBudgetSummary = computed(() => (
|
||||
financeDashboardPayload.value?.budgetSummary || fallbackBudgetSummary
|
||||
))
|
||||
|
||||
const resolveSystemMetricMeta = (metric) => {
|
||||
const totals = systemDashboardTotals.value
|
||||
const realDashboardLoaded = Boolean(systemDashboardPayload.value)
|
||||
|
||||
if (!realDashboardLoaded) {
|
||||
return {
|
||||
changeText: metric.change,
|
||||
delta: metric.delta,
|
||||
trend: metric.trend
|
||||
}
|
||||
}
|
||||
|
||||
if (metric.key === 'toolCalls' || metric.key === 'modelTokens') {
|
||||
const changeValue = Number(totals[`${metric.key}Change`] || 0)
|
||||
return {
|
||||
changeText: `${changeValue >= 0 ? '+' : ''}${changeValue.toFixed(1)}%`,
|
||||
delta: '较上一周期',
|
||||
trend: changeValue < 0 ? 'down' : 'up'
|
||||
}
|
||||
}
|
||||
|
||||
if (metric.key === 'executionSuccessRate') {
|
||||
const errorRate = Math.max(0, 100 - Number(totals.executionSuccessRate || 0))
|
||||
return {
|
||||
changeText: '实时',
|
||||
delta: `错误率 ${errorRate.toFixed(1)}%`,
|
||||
trend: 'up'
|
||||
}
|
||||
}
|
||||
|
||||
if (metric.key === 'positiveFeedback') {
|
||||
return {
|
||||
changeText: '实时',
|
||||
delta: `差评 ${Math.round(Number(totals.negativeFeedback || 0))} 次`,
|
||||
trend: 'up'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changeText: '实时',
|
||||
delta: metric.key === 'onlineUsers' ? '活跃会话' : '按最近会话统计',
|
||||
trend: metric.trend
|
||||
}
|
||||
}
|
||||
|
||||
const resolveFinanceMetricMeta = (metric) => {
|
||||
const meta = financeMetricMeta.value[metric.key]
|
||||
|
||||
if (!financeDashboardPayload.value || !meta) {
|
||||
return {
|
||||
changeText: metric.change,
|
||||
delta: metric.delta,
|
||||
trend: metric.trend
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changeText: meta.changeText || metric.change,
|
||||
delta: meta.delta || metric.delta,
|
||||
trend: meta.trend || metric.trend
|
||||
}
|
||||
}
|
||||
|
||||
const kpiMetrics = computed(() => metricBlueprints.map((metric, index) => {
|
||||
const rawValue = demoTotals[metric.key]
|
||||
const rawValue = Number(financeDashboardTotals.value[metric.key] || 0)
|
||||
const displayValue = formatMetricValue(metric, rawValue)
|
||||
const metricMeta = resolveFinanceMetricMeta(metric)
|
||||
|
||||
return {
|
||||
...metric,
|
||||
...metricMeta,
|
||||
displayValue,
|
||||
changeText: metric.change,
|
||||
delay: index * 55
|
||||
}
|
||||
}))
|
||||
|
||||
const activeTrend = computed(() => trendSeries[activeTrendRange.value])
|
||||
const spendTotal = computed(() => spendByCategory.reduce((sum, item) => sum + item.value, 0))
|
||||
const riskTotal = computed(() => exceptionMix.reduce((sum, item) => sum + item.value, 0))
|
||||
const systemKpiMetrics = computed(() => systemMetricBlueprints.map((metric, index) => {
|
||||
const rawValue = systemDashboardTotals.value[metric.key]
|
||||
const displayValue = formatSystemMetricValue(metric, rawValue)
|
||||
const metricMeta = resolveSystemMetricMeta(metric)
|
||||
|
||||
const spendLegend = computed(() => spendByCategory.map((item) => ({
|
||||
return {
|
||||
...metric,
|
||||
...metricMeta,
|
||||
displayValue,
|
||||
delay: index * 55
|
||||
}
|
||||
}))
|
||||
|
||||
const riskKpiMetrics = computed(() => {
|
||||
const data = riskDashboard.value
|
||||
const rows = [
|
||||
{
|
||||
label: '新增风险数',
|
||||
value: formatNumberCompact(data.totalObservations),
|
||||
changeText: `${data.windowDays}天`,
|
||||
delta: '统一观察池',
|
||||
trend: 'up',
|
||||
icon: 'mdi mdi-shield-search',
|
||||
accent: 'var(--theme-primary)'
|
||||
},
|
||||
{
|
||||
label: '高风险待处置',
|
||||
value: formatNumberCompact(data.highOrAboveCount),
|
||||
changeText: data.highOrAboveCount > 0 ? '需关注' : '稳定',
|
||||
delta: '高/重大风险',
|
||||
trend: data.highOrAboveCount > 0 ? 'down' : 'up',
|
||||
icon: 'mdi mdi-alert-octagon-outline',
|
||||
accent: '#ef4444'
|
||||
},
|
||||
{
|
||||
label: '涉及金额',
|
||||
value: formatCurrency(Number(data.totalAmount || 0)),
|
||||
changeText: '归集',
|
||||
delta: '关联单据金额',
|
||||
trend: 'up',
|
||||
icon: 'mdi mdi-cash-multiple',
|
||||
accent: '#0f766e'
|
||||
},
|
||||
{
|
||||
label: '已确认风险',
|
||||
value: formatNumberCompact(data.confirmedCount),
|
||||
changeText: formatPercent(data.confirmationRate),
|
||||
delta: '人工确认',
|
||||
trend: 'up',
|
||||
icon: 'mdi mdi-check-decagram-outline',
|
||||
accent: 'var(--success)'
|
||||
},
|
||||
{
|
||||
label: '误报数量',
|
||||
value: formatNumberCompact(data.falsePositiveCount),
|
||||
changeText: formatPercent(data.falsePositiveRate),
|
||||
delta: '反馈校准',
|
||||
trend: data.falsePositiveRate > 0.2 ? 'down' : 'up',
|
||||
icon: 'mdi mdi-tune-variant',
|
||||
accent: '#8b5cf6'
|
||||
},
|
||||
{
|
||||
label: '待复核',
|
||||
value: formatNumberCompact(data.pendingCount),
|
||||
changeText: '待处理',
|
||||
delta: '人工闭环',
|
||||
trend: data.pendingCount > 0 ? 'down' : 'up',
|
||||
icon: 'mdi mdi-account-clock-outline',
|
||||
accent: '#f59e0b'
|
||||
}
|
||||
]
|
||||
|
||||
return rows.map((item, index) => ({
|
||||
...item,
|
||||
displayValue: item.value,
|
||||
delay: index * 55
|
||||
}))
|
||||
})
|
||||
|
||||
const activeTrend = computed(() => financeTrend.value)
|
||||
const spendTotal = computed(() => financeSpendByCategory.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
|
||||
const riskTotal = computed(() => financeExceptionMix.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
|
||||
const spendCenterValue = computed(() => formatCurrency(Math.round(spendTotal.value)))
|
||||
|
||||
const spendLegend = computed(() => financeSpendByCategory.value.map((item) => ({
|
||||
...item,
|
||||
display: `${Math.round((item.value / spendTotal.value) * 100)}%`
|
||||
display: spendTotal.value ? `${Math.round((Number(item.value || 0) / spendTotal.value) * 100)}%` : '0%'
|
||||
})))
|
||||
|
||||
const riskLegend = computed(() => exceptionMix.map((item) => ({
|
||||
const riskLegend = computed(() => financeExceptionMix.value.map((item) => ({
|
||||
...item,
|
||||
display: `${item.value} 单`
|
||||
})))
|
||||
|
||||
const systemToolTotal = computed(() =>
|
||||
systemToolCallMix.reduce((sum, item) => sum + item.value, 0)
|
||||
)
|
||||
const systemExecutionTotal = computed(() =>
|
||||
systemExecutionMix.reduce((sum, item) => sum + item.value, 0)
|
||||
)
|
||||
const systemToolCallLegend = computed(() => systemToolCallMix.map((item) => ({
|
||||
...item,
|
||||
display: `${Math.round((item.value / systemToolTotal.value) * 100)}%`
|
||||
})))
|
||||
const systemExecutionLegend = computed(() => systemExecutionMix.map((item) => ({
|
||||
...item,
|
||||
display: `${Math.round((item.value / systemExecutionTotal.value) * 1000) / 10}%`
|
||||
})))
|
||||
|
||||
const rankedDepartments = computed(() => {
|
||||
const rows = demoDepartments
|
||||
const rows = financeDepartmentRanking.value.map((item) => ({
|
||||
...item,
|
||||
amount: Number(item.amount || item.value || 0)
|
||||
}))
|
||||
const max = Math.max(...rows.map((item) => item.amount), 1)
|
||||
|
||||
return rows.slice(0, 5).map((item, index) => ({
|
||||
@@ -88,25 +479,215 @@ export function useOverviewView() {
|
||||
}))
|
||||
})
|
||||
|
||||
const systemToolRankingItems = computed(() => systemToolRankings.map((item, index) => ({
|
||||
...item,
|
||||
rank: index + 1,
|
||||
shortName: item.name,
|
||||
amount: item.value
|
||||
})))
|
||||
|
||||
const systemModelUsageRows = computed(() => systemModelUsage.map((item) => ({
|
||||
...item,
|
||||
tokenLabel: `${formatNumberCompact(item.tokens)} tokens`,
|
||||
width: `${Math.max(Math.min(item.share, 100), 8)}%`
|
||||
})))
|
||||
|
||||
const systemExecutionRows = computed(() => systemExecutionMix.map((item) => ({
|
||||
...item,
|
||||
display: `${Math.round((item.value / systemExecutionTotal.value) * 1000) / 10}%`,
|
||||
width: `${Math.max((item.value / systemExecutionTotal.value) * 100, 1)}%`
|
||||
})))
|
||||
|
||||
const systemToolDetailItems = computed(() => {
|
||||
const maxCalls = Math.max(...systemToolDetailRows.value.map((item) => item.calls), 1)
|
||||
|
||||
return systemToolDetailRows.value.map((item) => ({
|
||||
...item,
|
||||
callLabel: `${formatNumberCompact(item.calls)} 次`,
|
||||
tokenLabel: `${formatNumberCompact(item.tokens)} tokens`,
|
||||
width: `${Math.max((item.calls / maxCalls) * 100, 10)}%`
|
||||
}))
|
||||
})
|
||||
|
||||
const systemUsageDurationRows = computed(() => {
|
||||
const rows = systemUsageDurationSummary.value.rows || []
|
||||
const maxValue = Math.max(...rows.map((item) => item.value), 1)
|
||||
|
||||
return rows.map((item) => ({
|
||||
...item,
|
||||
width: `${Math.max((item.value / maxValue) * 100, 12)}%`
|
||||
}))
|
||||
})
|
||||
|
||||
const riskLevelLegend = computed(() => buildRiskDistributionLegend(
|
||||
riskDashboard.value.levelDistribution,
|
||||
{
|
||||
critical: '重大风险',
|
||||
high: '高风险',
|
||||
medium: '中风险',
|
||||
low: '低风险'
|
||||
},
|
||||
{
|
||||
critical: '#b91c1c',
|
||||
high: '#ef4444',
|
||||
medium: '#f59e0b',
|
||||
low: '#3b82f6'
|
||||
}
|
||||
))
|
||||
const riskSourceLegend = computed(() => buildRiskDistributionLegend(
|
||||
riskDashboard.value.sourceDistribution,
|
||||
{
|
||||
financial_risk_graph: '风险图谱',
|
||||
rule_center: '规则中心',
|
||||
unknown: '未知来源'
|
||||
},
|
||||
{
|
||||
financial_risk_graph: 'var(--theme-primary)',
|
||||
rule_center: '#0f766e',
|
||||
unknown: '#94a3b8'
|
||||
}
|
||||
))
|
||||
const riskSignalRanking = computed(() => {
|
||||
const rows = Array.isArray(riskDashboard.value.topRiskSignals)
|
||||
? riskDashboard.value.topRiskSignals
|
||||
: []
|
||||
const fallbackRows = Object.entries(riskDashboard.value.signalDistribution || {})
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
|
||||
return (rows.length ? rows : fallbackRows)
|
||||
.slice(0, 6)
|
||||
.map((item, index) => ({
|
||||
name: formatRiskSignalName(item.name),
|
||||
shortName: formatRiskSignalName(item.name),
|
||||
value: Number(item.count || 0),
|
||||
color: [
|
||||
'#ef4444',
|
||||
'#f59e0b',
|
||||
'var(--theme-primary)',
|
||||
'#3b82f6',
|
||||
'#8b5cf6',
|
||||
'#0f766e'
|
||||
][index] || '#64748b'
|
||||
}))
|
||||
})
|
||||
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 maxValue = Math.max(...normalizedRows.map((item) => item.total), 1)
|
||||
|
||||
return normalizedRows.map((item) => ({
|
||||
...item,
|
||||
width: `${Math.max((item.total / maxValue) * 100, 4)}%`,
|
||||
highWidth: `${Math.max((item.highOrAbove / maxValue) * 100, item.highOrAbove ? 4 : 0)}%`
|
||||
}))
|
||||
})
|
||||
|
||||
function buildRiskDistributionLegend(distribution, labels, colors) {
|
||||
const entries = Object.entries(distribution || {})
|
||||
.filter(([, value]) => Number(value || 0) > 0)
|
||||
|
||||
if (!entries.length) {
|
||||
return [
|
||||
{
|
||||
name: '暂无数据',
|
||||
value: 1,
|
||||
display: '0项',
|
||||
color: '#cbd5e1'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return entries.map(([key, value]) => ({
|
||||
name: labels[key] || formatRiskSignalName(key),
|
||||
value: Number(value || 0),
|
||||
display: `${Number(value || 0)}项`,
|
||||
color: colors[key] || 'var(--theme-primary)'
|
||||
}))
|
||||
}
|
||||
|
||||
function formatRiskSignalName(value) {
|
||||
const text = String(value || '').trim()
|
||||
const labels = {
|
||||
duplicate_invoice: '重复发票',
|
||||
split_billing: '拆分报销',
|
||||
frequent_small_claims: '高频小额',
|
||||
location_mismatch: '地点不一致',
|
||||
amount_outlier: '金额异常',
|
||||
preapproval_absent: '缺少事前申请'
|
||||
}
|
||||
return labels[text] || text.replace(/_/g, ' ') || '未知风险'
|
||||
}
|
||||
|
||||
const bottlenecks = financeBottlenecks
|
||||
const budgetSummary = financeBudgetSummary
|
||||
const spendByCategory = financeSpendByCategory
|
||||
const exceptionMix = financeExceptionMix
|
||||
|
||||
return {
|
||||
activeDepartmentRange,
|
||||
activeRiskWindowDays,
|
||||
activeTrend,
|
||||
activeTrendRange,
|
||||
bottlenecks,
|
||||
budgetSummary,
|
||||
departmentRangeOptions,
|
||||
exceptionMix,
|
||||
financeDashboardError,
|
||||
financeDashboardLoading,
|
||||
formatCompact,
|
||||
formatCurrency,
|
||||
formatMetricValue,
|
||||
formatNumberCompact,
|
||||
formatSystemMetricValue,
|
||||
kpiMetrics,
|
||||
metricBlueprints,
|
||||
rankedDepartments,
|
||||
riskDashboard,
|
||||
riskDashboardError,
|
||||
riskDashboardLoading,
|
||||
riskDailyTrendRows,
|
||||
riskLegend,
|
||||
riskKpiMetrics,
|
||||
riskLevelLegend,
|
||||
riskSignalRanking,
|
||||
riskSourceLegend,
|
||||
riskTotal,
|
||||
riskWindowOptions,
|
||||
setRiskWindowDays,
|
||||
spendByCategory,
|
||||
spendCenterValue,
|
||||
spendLegend,
|
||||
spendTotal,
|
||||
systemDashboardTotals,
|
||||
systemDashboardError,
|
||||
systemDashboardLoading,
|
||||
systemAgentDailyRatio,
|
||||
systemLoginWave,
|
||||
systemTokenDailyWave,
|
||||
systemUsageDurationRows,
|
||||
systemUsageDurationSummary,
|
||||
systemUserTokenUsage,
|
||||
systemAccuracyComparison,
|
||||
systemExecutionLegend,
|
||||
systemExecutionMix,
|
||||
systemExecutionTotal,
|
||||
systemFeedbackSummary,
|
||||
systemKpiMetrics,
|
||||
systemLoadHeatmap,
|
||||
systemExecutionRows,
|
||||
systemMetricBlueprints,
|
||||
systemModelUsageRows,
|
||||
systemToolDetailItems,
|
||||
systemToolCallLegend,
|
||||
systemToolCallMix,
|
||||
systemToolRankingItems,
|
||||
systemToolRankings,
|
||||
systemToolTotal,
|
||||
systemTrendSeries,
|
||||
trendRanges,
|
||||
trendSeries
|
||||
}
|
||||
|
||||
@@ -44,15 +44,18 @@ const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'
|
||||
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
|
||||
const DOCUMENT_TYPE_APPLICATION = 'application'
|
||||
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
||||
const RELATED_APPLICATION_STEP_LABEL = '关联单据'
|
||||
const ARCHIVED_STEP_LABEL = '已归档'
|
||||
|
||||
const REIMBURSEMENT_PROGRESS_LABELS = [
|
||||
'创建单据',
|
||||
RELATED_APPLICATION_STEP_LABEL,
|
||||
'待提交',
|
||||
'AI预审',
|
||||
'直属领导审批',
|
||||
'财务审批',
|
||||
'待付款',
|
||||
'已付款'
|
||||
'已付款',
|
||||
ARCHIVED_STEP_LABEL
|
||||
]
|
||||
|
||||
const APPLICATION_PROGRESS_LABELS = [
|
||||
@@ -366,7 +369,7 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
|
||||
const normalizedNode = String(workflowNode || '').trim()
|
||||
|
||||
if (approvalMeta.key === 'completed') {
|
||||
return 6
|
||||
return 7
|
||||
}
|
||||
|
||||
if (approvalMeta.key === 'pending_payment') {
|
||||
@@ -380,7 +383,7 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
|
||||
return 5
|
||||
}
|
||||
if (normalizedNode.includes('归档') || normalizedNode.includes('入账')) {
|
||||
return 6
|
||||
return 7
|
||||
}
|
||||
if (normalizedNode.includes('财务')) {
|
||||
return 4
|
||||
@@ -589,6 +592,116 @@ function findLatestPaymentEvent(claim) {
|
||||
)
|
||||
}
|
||||
|
||||
function findApplicationHandoffEvent(claim) {
|
||||
const handoffEvents = getRiskFlags(claim).filter((flag) => (
|
||||
flag
|
||||
&& typeof flag === 'object'
|
||||
&& normalizeText(flag.source) === 'application_handoff'
|
||||
&& normalizeText(flag.application_claim_no || flag.applicationClaimNo)
|
||||
))
|
||||
return getLatestEvent(handoffEvents) || handoffEvents[handoffEvents.length - 1] || null
|
||||
}
|
||||
|
||||
function normalizeApplicationHandoffDetail(flag = {}) {
|
||||
const detail = flag?.application_detail || flag?.applicationDetail || {}
|
||||
return detail && typeof detail === 'object' ? detail : {}
|
||||
}
|
||||
|
||||
function resolveRelatedApplicationAmountLabel(flag, detail, claim) {
|
||||
const explicitLabel = normalizeText(
|
||||
flag?.application_amount_label
|
||||
|| flag?.applicationAmountLabel
|
||||
|| detail?.application_amount_label
|
||||
|| detail?.applicationAmountLabel
|
||||
)
|
||||
if (explicitLabel) return explicitLabel
|
||||
|
||||
const rawAmount = normalizeText(
|
||||
flag?.application_amount
|
||||
|| flag?.applicationAmount
|
||||
|| flag?.application_budget_amount
|
||||
|| flag?.applicationBudgetAmount
|
||||
|| detail?.application_amount
|
||||
|| detail?.applicationAmount
|
||||
|| detail?.amount
|
||||
|| claim?.amount
|
||||
)
|
||||
const amountValue = parseNumber(rawAmount)
|
||||
return amountValue > 0 ? formatAmount(amountValue) : rawAmount
|
||||
}
|
||||
|
||||
function resolveRelatedApplicationInfo(claim, typeLabel = '') {
|
||||
const handoff = findApplicationHandoffEvent(claim)
|
||||
if (!handoff) {
|
||||
return null
|
||||
}
|
||||
|
||||
const detail = normalizeApplicationHandoffDetail(handoff)
|
||||
const claimNo = normalizeText(handoff.application_claim_no || handoff.applicationClaimNo)
|
||||
const applicationType = normalizeText(
|
||||
detail.application_type
|
||||
|| detail.applicationType
|
||||
|| handoff.application_type
|
||||
|| handoff.applicationType
|
||||
|| typeLabel
|
||||
)
|
||||
const location = normalizeText(
|
||||
detail.application_location
|
||||
|| detail.applicationLocation
|
||||
|| detail.location
|
||||
|| handoff.application_location
|
||||
|| handoff.applicationLocation
|
||||
|| claim?.location
|
||||
)
|
||||
const reason = normalizeText(
|
||||
detail.application_reason
|
||||
|| detail.applicationReason
|
||||
|| detail.reason
|
||||
|| handoff.application_reason
|
||||
|| handoff.applicationReason
|
||||
|| claim?.reason
|
||||
)
|
||||
const content = normalizeText(
|
||||
detail.application_content
|
||||
|| detail.applicationContent
|
||||
|| handoff.application_content
|
||||
|| handoff.applicationContent
|
||||
) || [applicationType, location].filter(Boolean).join(' / ')
|
||||
const rawTime = normalizeText(
|
||||
detail.application_time
|
||||
|| detail.applicationTime
|
||||
|| detail.time
|
||||
|| handoff.application_time
|
||||
|| handoff.applicationTime
|
||||
|| claim?.occurred_at
|
||||
)
|
||||
|
||||
return {
|
||||
id: normalizeText(handoff.application_claim_id || handoff.applicationClaimId),
|
||||
claimNo,
|
||||
content,
|
||||
reason,
|
||||
days: normalizeText(
|
||||
detail.application_days
|
||||
|| detail.applicationDays
|
||||
|| detail.days
|
||||
|| handoff.application_days
|
||||
|| handoff.applicationDays
|
||||
),
|
||||
location,
|
||||
time: formatDate(rawTime) || rawTime,
|
||||
amountLabel: resolveRelatedApplicationAmountLabel(handoff, detail, claim),
|
||||
statusLabel: normalizeText(handoff.application_status_label || handoff.applicationStatusLabel),
|
||||
transportMode: normalizeText(
|
||||
detail.application_transport_mode
|
||||
|| detail.applicationTransportMode
|
||||
|| detail.transport_mode
|
||||
|| handoff.application_transport_mode
|
||||
|| handoff.applicationTransportMode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function findLatestApplicationReturnEvent(claim) {
|
||||
return getLatestEvent(
|
||||
getRiskFlags(claim).filter((flag) => {
|
||||
@@ -608,6 +721,28 @@ function findLatestApplicationReturnEvent(claim) {
|
||||
)
|
||||
}
|
||||
|
||||
function findMergedApplicationBudgetApprovalEvent(claim) {
|
||||
return getLatestEvent(
|
||||
getRiskFlags(claim).filter((flag) => {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return false
|
||||
}
|
||||
const source = normalizeText(flag.source)
|
||||
const eventType = normalizeText(flag.event_type || flag.eventType)
|
||||
const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
|
||||
const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
|
||||
const mergedFlag = Boolean(flag.budget_approval_merged || flag.budgetApprovalMerged)
|
||||
return (
|
||||
source === 'manual_approval'
|
||||
&& eventType === 'expense_application_approval'
|
||||
&& previousStage.includes('直属领导')
|
||||
&& nextStage.includes('审批完成')
|
||||
&& mergedFlag
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function buildProgressStepMeta(time, detail = '', title = '') {
|
||||
return {
|
||||
time,
|
||||
@@ -620,6 +755,15 @@ function buildCompletedStepMeta(claim, label) {
|
||||
const stepLabel = normalizeText(label)
|
||||
const employeeName = normalizeText(claim?.employee_name) || '申请人'
|
||||
|
||||
if (stepLabel === RELATED_APPLICATION_STEP_LABEL) {
|
||||
const relatedApplication = resolveRelatedApplicationInfo(claim)
|
||||
const createdAt = formatDateTime(claim?.created_at)
|
||||
if (relatedApplication?.claimNo) {
|
||||
return buildProgressStepMeta(`已关联 ${relatedApplication.claimNo}`, createdAt)
|
||||
}
|
||||
return buildProgressStepMeta('待核对关联单据', createdAt)
|
||||
}
|
||||
|
||||
if (stepLabel === '创建单据' || stepLabel === '创建申请') {
|
||||
const createdAt = formatDateTime(claim?.created_at)
|
||||
return buildProgressStepMeta(stepLabel === '创建申请' ? `${employeeName}发起申请` : `${employeeName}创建`, createdAt)
|
||||
@@ -694,6 +838,11 @@ function buildCompletedStepMeta(claim, label) {
|
||||
return buildProgressStepMeta('归档入账', archivedAt)
|
||||
}
|
||||
|
||||
if (stepLabel === ARCHIVED_STEP_LABEL) {
|
||||
const archivedAt = formatDateTime(claim?.updated_at)
|
||||
return buildProgressStepMeta(ARCHIVED_STEP_LABEL, archivedAt)
|
||||
}
|
||||
|
||||
if (stepLabel === '审批完成') {
|
||||
const completedAt = formatDateTime(claim?.updated_at)
|
||||
return buildProgressStepMeta('审批完成', completedAt)
|
||||
@@ -704,7 +853,7 @@ function buildCompletedStepMeta(claim, label) {
|
||||
|
||||
function resolveCurrentStepStartedAt(claim, label) {
|
||||
const stepLabel = normalizeText(label)
|
||||
if (stepLabel === '创建单据' || stepLabel === '创建申请') {
|
||||
if (stepLabel === RELATED_APPLICATION_STEP_LABEL || stepLabel === '创建单据' || stepLabel === '创建申请') {
|
||||
return claim?.created_at
|
||||
}
|
||||
if (stepLabel === '待提交') {
|
||||
@@ -733,7 +882,7 @@ function resolveCurrentStepStartedAt(claim, label) {
|
||||
const paymentEvent = findLatestPaymentEvent(claim)
|
||||
return paymentEvent?.created_at || paymentEvent?.createdAt || claim?.updated_at || claim?.submitted_at
|
||||
}
|
||||
if (stepLabel === '归档入账' || stepLabel === '审批完成') {
|
||||
if (stepLabel === '归档入账' || stepLabel === ARCHIVED_STEP_LABEL || stepLabel === '审批完成') {
|
||||
return claim?.updated_at || claim?.submitted_at
|
||||
}
|
||||
return ''
|
||||
@@ -746,17 +895,26 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
|
||||
&& Boolean(findLatestApplicationReturnEvent(claim))
|
||||
&& approvalMeta.key === 'supplement'
|
||||
)
|
||||
const hasMergedApplicationBudgetApproval = (
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
&& Boolean(findMergedApplicationBudgetApprovalEvent(claim))
|
||||
)
|
||||
const progressLabels =
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
? hasApplicationReturnStep
|
||||
? ['创建申请', '直属领导审批', '退回', '待提交']
|
||||
: APPLICATION_PROGRESS_LABELS
|
||||
: hasMergedApplicationBudgetApproval
|
||||
? ['创建申请', '直属领导审批', '审批完成']
|
||||
: APPLICATION_PROGRESS_LABELS
|
||||
: REIMBURSEMENT_PROGRESS_LABELS
|
||||
const currentIndex =
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
? hasApplicationReturnStep
|
||||
? 3
|
||||
: resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode)
|
||||
: Math.min(
|
||||
resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode),
|
||||
Math.max(0, progressLabels.length - 1)
|
||||
)
|
||||
: resolveProgressCurrentIndex(approvalMeta, workflowNode)
|
||||
const currentTime =
|
||||
approvalMeta.key === 'completed'
|
||||
@@ -902,6 +1060,7 @@ export function mapExpenseClaimToRequest(claim) {
|
||||
const riskSummary = buildRiskSummary(claim?.risk_flags_json)
|
||||
const expenseItems = buildExpenseItems(claim, riskSummary)
|
||||
const applyDateTime = claim?.submitted_at || claim?.created_at
|
||||
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
|
||||
|
||||
return {
|
||||
id: String(claim?.claim_no || claim?.id || '').trim(),
|
||||
@@ -958,6 +1117,7 @@ export function mapExpenseClaimToRequest(claim) {
|
||||
: `共 ${expenseItems.length} 条费用明细,待补充票据`)
|
||||
: '暂无费用明细',
|
||||
note: String(claim?.reason || '').trim(),
|
||||
relatedApplication,
|
||||
progressSteps: buildProgressSteps(approvalMeta, workflowNode, claim, {
|
||||
documentTypeCode: documentTypeMeta.documentTypeCode
|
||||
}),
|
||||
|
||||
@@ -8,18 +8,24 @@ import {
|
||||
testBootstrapDatabase,
|
||||
testBootstrapRuntime
|
||||
} from '../services/bootstrap.js'
|
||||
import { login as loginByAccount } from '../services/auth.js'
|
||||
import { login as loginByAccount } from '../services/auth.js'
|
||||
import { setRuntimeApiBaseUrl } from '../services/api.js'
|
||||
import { checkBackendHealth } from './useBackendHealth.js'
|
||||
import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js'
|
||||
import { useToast } from './useToast.js'
|
||||
import { fetchSettings } from '../services/settings.js'
|
||||
import { setThemeSkin } from './useThemeSkin.js'
|
||||
import { fetchSettings } from '../services/settings.js'
|
||||
import { setThemeSkin } from './useThemeSkin.js'
|
||||
import {
|
||||
clearAuthSessionMetrics,
|
||||
finalizeAuthSession,
|
||||
incrementAuthActivityCount,
|
||||
persistAuthSessionMetrics
|
||||
} from '../utils/authSessionMetrics.js'
|
||||
|
||||
const AUTH_STORAGE_KEY = 'x-financial-authenticated'
|
||||
const AUTH_USERNAME_KEY = 'x-financial-auth-username'
|
||||
const AUTH_USER_KEY = 'x-financial-auth-user'
|
||||
const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
|
||||
const AUTH_USERNAME_KEY = 'x-financial-auth-username'
|
||||
const AUTH_USER_KEY = 'x-financial-auth-user'
|
||||
const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
|
||||
const DEFAULT_USER_NAME = '系统管理员'
|
||||
const DEFAULT_USER_ROLE = '管理员'
|
||||
const SESSION_ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'scroll', 'touchstart', 'visibilitychange']
|
||||
@@ -193,13 +199,13 @@ function readStoredUser() {
|
||||
return legacyUsername ? buildLegacyAdminUser(legacyUsername) : buildAnonymousUser()
|
||||
}
|
||||
|
||||
function readLastActivityAt() {
|
||||
if (typeof window === 'undefined') {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0)
|
||||
}
|
||||
function readLastActivityAt() {
|
||||
if (typeof window === 'undefined') {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0)
|
||||
}
|
||||
|
||||
function isSessionExpired(now = Date.now()) {
|
||||
if (!readAuthState()) {
|
||||
@@ -215,24 +221,26 @@ function isSessionExpired(now = Date.now()) {
|
||||
return now - lastActivityAt > authIdleTimeoutMs
|
||||
}
|
||||
|
||||
function persistAuthState(value, user = null) {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
function persistAuthState(value, user = null, sessionId = '') {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
if (value) {
|
||||
window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true')
|
||||
const normalizedUser = user || buildAnonymousUser()
|
||||
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim())
|
||||
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
|
||||
return
|
||||
}
|
||||
const normalizedUser = user || buildAnonymousUser()
|
||||
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim())
|
||||
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
|
||||
persistAuthSessionMetrics(sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
|
||||
window.sessionStorage.removeItem(AUTH_USERNAME_KEY)
|
||||
window.sessionStorage.removeItem(AUTH_USER_KEY)
|
||||
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
|
||||
}
|
||||
window.sessionStorage.removeItem(AUTH_USERNAME_KEY)
|
||||
window.sessionStorage.removeItem(AUTH_USER_KEY)
|
||||
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
|
||||
clearAuthSessionMetrics()
|
||||
}
|
||||
|
||||
function clearSessionTimeout() {
|
||||
if (typeof window === 'undefined' || !sessionTimeoutHandle) {
|
||||
@@ -294,19 +302,27 @@ function touchAuthActivity(force = false) {
|
||||
scheduleSessionTimeout()
|
||||
return
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(AUTH_LAST_ACTIVITY_KEY, String(now))
|
||||
incrementAuthActivityCount()
|
||||
lastActivityWriteAt = now
|
||||
scheduleSessionTimeout()
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(AUTH_LAST_ACTIVITY_KEY, String(now))
|
||||
lastActivityWriteAt = now
|
||||
scheduleSessionTimeout()
|
||||
}
|
||||
|
||||
function handleSessionActivity(event) {
|
||||
if (typeof document !== 'undefined' && event?.type === 'visibilitychange' && document.visibilityState !== 'visible') {
|
||||
return
|
||||
}
|
||||
|
||||
touchAuthActivity()
|
||||
}
|
||||
function handleSessionActivity(event) {
|
||||
if (typeof document !== 'undefined' && event?.type === 'visibilitychange' && document.visibilityState !== 'visible') {
|
||||
return
|
||||
}
|
||||
|
||||
touchAuthActivity()
|
||||
}
|
||||
|
||||
function handleSessionUnload(event) {
|
||||
if (event?.type === 'pagehide' && event.persisted) {
|
||||
return
|
||||
}
|
||||
finalizeAuthSession('pagehide', { unload: true })
|
||||
}
|
||||
|
||||
function installSessionMonitoring() {
|
||||
if (sessionMonitoringInstalled || typeof window === 'undefined') {
|
||||
@@ -314,10 +330,12 @@ function installSessionMonitoring() {
|
||||
}
|
||||
|
||||
sessionMonitoringInstalled = true
|
||||
SESSION_ACTIVITY_EVENTS.forEach((eventName) => {
|
||||
window.addEventListener(eventName, handleSessionActivity, { passive: true })
|
||||
})
|
||||
}
|
||||
SESSION_ACTIVITY_EVENTS.forEach((eventName) => {
|
||||
window.addEventListener(eventName, handleSessionActivity, { passive: true })
|
||||
})
|
||||
window.addEventListener('pagehide', handleSessionUnload, { passive: true })
|
||||
window.addEventListener('beforeunload', handleSessionUnload, { passive: true })
|
||||
}
|
||||
|
||||
function syncAuthSession(options = {}) {
|
||||
const shouldNotify = Boolean(options.notify)
|
||||
@@ -641,11 +659,11 @@ async function handleLogin(credentials) {
|
||||
...responseUser,
|
||||
roleCodes: responseRoleCodes,
|
||||
isAdmin: resolvePlatformAdminFlag(responseUser, responseRoleCodes)
|
||||
}
|
||||
loggedIn.value = true
|
||||
persistAuthState(true, user)
|
||||
currentUser.value = user
|
||||
touchAuthActivity(true)
|
||||
}
|
||||
loggedIn.value = true
|
||||
persistAuthState(true, user, response?.sessionId || '')
|
||||
currentUser.value = user
|
||||
touchAuthActivity(true)
|
||||
return true
|
||||
} catch (error) {
|
||||
logout('invalid', { redirect: false })
|
||||
@@ -658,12 +676,13 @@ async function handleLogin(credentials) {
|
||||
}
|
||||
|
||||
function logout(reason = 'manual', options = {}) {
|
||||
const notify = options.notify ?? reason === 'timeout'
|
||||
const redirect = options.redirect ?? reason !== 'invalid'
|
||||
|
||||
loggedIn.value = false
|
||||
persistAuthState(false)
|
||||
currentUser.value = buildAnonymousUser()
|
||||
const notify = options.notify ?? reason === 'timeout'
|
||||
const redirect = options.redirect ?? reason !== 'invalid'
|
||||
|
||||
finalizeAuthSession(reason)
|
||||
loggedIn.value = false
|
||||
persistAuthState(false)
|
||||
currentUser.value = buildAnonymousUser()
|
||||
clearSessionTimeout()
|
||||
|
||||
if (notify) {
|
||||
|
||||
144
web/src/composables/useWorkbenchComposerDate.js
Normal file
144
web/src/composables/useWorkbenchComposerDate.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
buildWorkbenchDateLabel,
|
||||
canApplyWorkbenchDateSelection,
|
||||
getTodayDateValue,
|
||||
mergeWorkbenchDateLabelIntoDraft,
|
||||
stripWorkbenchDateLabelFromDraft
|
||||
} from '../utils/workbenchComposerDate.js'
|
||||
|
||||
export function useWorkbenchComposerDate({ draft, focusInput } = {}) {
|
||||
const workbenchDatePickerOpen = ref(false)
|
||||
const workbenchDateMode = ref('single')
|
||||
const workbenchSingleDate = ref(getTodayDateValue())
|
||||
const workbenchRangeStartDate = ref(getTodayDateValue())
|
||||
const workbenchRangeEndDate = ref(getTodayDateValue())
|
||||
const workbenchDateTagLabel = ref('')
|
||||
|
||||
const workbenchCanApplyDateSelection = computed(() =>
|
||||
canApplyWorkbenchDateSelection({
|
||||
mode: workbenchDateMode.value,
|
||||
singleDate: workbenchSingleDate.value,
|
||||
rangeStartDate: workbenchRangeStartDate.value,
|
||||
rangeEndDate: workbenchRangeEndDate.value
|
||||
})
|
||||
)
|
||||
|
||||
function clearWorkbenchDateSelection() {
|
||||
const today = getTodayDateValue()
|
||||
workbenchDatePickerOpen.value = false
|
||||
workbenchDateMode.value = 'single'
|
||||
workbenchSingleDate.value = today
|
||||
workbenchRangeStartDate.value = today
|
||||
workbenchRangeEndDate.value = today
|
||||
workbenchDateTagLabel.value = ''
|
||||
}
|
||||
|
||||
function ensureWorkbenchDateDefaults() {
|
||||
const today = getTodayDateValue()
|
||||
workbenchSingleDate.value ||= today
|
||||
workbenchRangeStartDate.value ||= workbenchSingleDate.value || today
|
||||
workbenchRangeEndDate.value ||= workbenchRangeStartDate.value || today
|
||||
}
|
||||
|
||||
function toggleWorkbenchDatePicker() {
|
||||
if (workbenchDatePickerOpen.value) {
|
||||
workbenchDatePickerOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
ensureWorkbenchDateDefaults()
|
||||
workbenchDatePickerOpen.value = true
|
||||
}
|
||||
|
||||
function closeWorkbenchDatePicker() {
|
||||
workbenchDatePickerOpen.value = false
|
||||
}
|
||||
|
||||
function setWorkbenchDateMode(mode) {
|
||||
const today = getTodayDateValue()
|
||||
const nextMode = mode === 'range' ? 'range' : 'single'
|
||||
|
||||
if (nextMode === 'range') {
|
||||
const baseDate = workbenchSingleDate.value || today
|
||||
workbenchRangeStartDate.value ||= baseDate
|
||||
workbenchRangeEndDate.value ||= workbenchRangeStartDate.value
|
||||
} else {
|
||||
workbenchSingleDate.value ||= workbenchRangeStartDate.value || today
|
||||
}
|
||||
|
||||
workbenchDateMode.value = nextMode
|
||||
}
|
||||
|
||||
function handleWorkbenchDatePickerOutside(event) {
|
||||
if (!workbenchDatePickerOpen.value) {
|
||||
return
|
||||
}
|
||||
if (event.target instanceof Element && event.target.closest('.workbench-date-anchor')) {
|
||||
return
|
||||
}
|
||||
workbenchDatePickerOpen.value = false
|
||||
}
|
||||
|
||||
function applyWorkbenchDateSelection() {
|
||||
const label = buildWorkbenchDateLabel({
|
||||
mode: workbenchDateMode.value,
|
||||
singleDate: workbenchSingleDate.value,
|
||||
rangeStartDate: workbenchRangeStartDate.value,
|
||||
rangeEndDate: workbenchRangeEndDate.value
|
||||
})
|
||||
if (!label) {
|
||||
return
|
||||
}
|
||||
|
||||
workbenchDateTagLabel.value = label
|
||||
draft.value = stripWorkbenchDateLabelFromDraft(draft.value)
|
||||
workbenchDatePickerOpen.value = false
|
||||
focusInput?.()
|
||||
}
|
||||
|
||||
function handleWorkbenchDateInputChange(part = 'single') {
|
||||
if (workbenchDateMode.value !== 'range' || part === 'single') {
|
||||
applyWorkbenchDateSelection()
|
||||
return
|
||||
}
|
||||
|
||||
if (part === 'range-start') {
|
||||
if (!workbenchRangeEndDate.value || workbenchRangeEndDate.value < workbenchRangeStartDate.value) {
|
||||
workbenchRangeEndDate.value = workbenchRangeStartDate.value
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
applyWorkbenchDateSelection()
|
||||
}
|
||||
|
||||
function removeWorkbenchDateTag() {
|
||||
workbenchDateTagLabel.value = ''
|
||||
focusInput?.()
|
||||
}
|
||||
|
||||
function buildWorkbenchPromptText(rawText = draft.value) {
|
||||
return mergeWorkbenchDateLabelIntoDraft(rawText, workbenchDateTagLabel.value)
|
||||
}
|
||||
|
||||
return {
|
||||
workbenchDatePickerOpen,
|
||||
workbenchDateMode,
|
||||
workbenchSingleDate,
|
||||
workbenchRangeStartDate,
|
||||
workbenchRangeEndDate,
|
||||
workbenchDateTagLabel,
|
||||
workbenchCanApplyDateSelection,
|
||||
clearWorkbenchDateSelection,
|
||||
toggleWorkbenchDatePicker,
|
||||
closeWorkbenchDatePicker,
|
||||
setWorkbenchDateMode,
|
||||
handleWorkbenchDatePickerOutside,
|
||||
applyWorkbenchDateSelection,
|
||||
handleWorkbenchDateInputChange,
|
||||
removeWorkbenchDateTag,
|
||||
buildWorkbenchPromptText
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user