refactor: enforce 800 line source limits
This commit is contained in:
199
web/src/composables/overviewViewDisplayModel.js
Normal file
199
web/src/composables/overviewViewDisplayModel.js
Normal file
@@ -0,0 +1,199 @@
|
||||
import { formatRiskSignalLabel } from '../utils/riskLabels.js'
|
||||
|
||||
export const emptyFinanceTotals = {
|
||||
reimbursementAmount: 0,
|
||||
reimbursementCount: 0,
|
||||
pendingPaymentAmount: 0,
|
||||
avgClaimAmount: 0,
|
||||
budgetUsageRate: 0,
|
||||
paymentClearanceRate: 0
|
||||
}
|
||||
|
||||
export const emptyFinanceTrend = {
|
||||
labels: [],
|
||||
claimCount: [],
|
||||
claimAmount: [],
|
||||
categoryAmountSeries: [],
|
||||
applications: [],
|
||||
approved: [],
|
||||
avgHours: []
|
||||
}
|
||||
|
||||
export const emptyFinanceDonut = [
|
||||
{ name: '暂无数据', value: 0, color: '#cbd5e1' }
|
||||
]
|
||||
|
||||
export const emptyFinanceBudgetSummary = {
|
||||
ratio: 0,
|
||||
total: '¥0',
|
||||
used: '¥0',
|
||||
left: '¥0'
|
||||
}
|
||||
|
||||
export const emptyFinanceBudgetMetrics = [
|
||||
{ label: '预算池数量', value: '0 个', detail: '年度有效预算池', tone: 'neutral', icon: 'mdi mdi-database-outline' },
|
||||
{ label: '总预算', value: '¥0', detail: '原始预算 + 调整', tone: 'neutral', icon: 'mdi mdi-cash-register' },
|
||||
{ label: '已用预算', value: '¥0', detail: '使用率 0.0%', tone: 'success', icon: 'mdi mdi-chart-arc' },
|
||||
{ label: '预占预算', value: '¥0', detail: '待流转单据占用', tone: 'success', icon: 'mdi mdi-lock-outline' },
|
||||
{ label: '可用预算', value: '¥0', detail: '可继续使用额度', tone: 'success', icon: 'mdi mdi-wallet-outline' },
|
||||
{ label: '预警预算池', value: '0 个', detail: '超支 0 个', tone: 'success', icon: 'mdi mdi-alert-outline' }
|
||||
]
|
||||
|
||||
export const emptySystemDashboardTotals = {
|
||||
toolCalls: 0,
|
||||
modelTokens: 0,
|
||||
onlineUsers: 0,
|
||||
avgOnlineMinutes: 0,
|
||||
executionSuccessRate: 0,
|
||||
positiveFeedback: 0,
|
||||
negativeFeedback: 0,
|
||||
failedRuns: 0,
|
||||
toolCallsChange: 0,
|
||||
modelTokensChange: 0
|
||||
}
|
||||
|
||||
export const emptySystemLoginWave = {
|
||||
labels: Array.from({ length: 24 }, (_, hour) => `${String(hour).padStart(2, '0')}:00`),
|
||||
loginUsers: Array.from({ length: 24 }, () => 0),
|
||||
interactions: Array.from({ length: 24 }, () => 0)
|
||||
}
|
||||
|
||||
export function formatCompact(value) {
|
||||
if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
|
||||
if (value >= 1_000) return `¥${(value / 1_000).toFixed(1)}K`
|
||||
return `¥${value}`
|
||||
}
|
||||
|
||||
export function formatCurrency(value) {
|
||||
return formatCompact(value)
|
||||
}
|
||||
|
||||
export function 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)}`
|
||||
}
|
||||
|
||||
export function formatPercent(value) {
|
||||
return `${Math.round(Number(value || 0) * 100)}%`
|
||||
}
|
||||
|
||||
export function formatMetricValue(metric, value) {
|
||||
if (['reimbursementAmount', 'pendingPaymentAmount', 'avgClaimAmount'].includes(metric.key)) {
|
||||
return formatCurrency(Math.round(value))
|
||||
}
|
||||
if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
|
||||
if (metric.unit) return `${Math.round(value)} ${metric.unit}`
|
||||
return `${Math.round(value)}`
|
||||
}
|
||||
|
||||
export function formatSystemMetricValue(metric, value, totals = emptySystemDashboardTotals) {
|
||||
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(totals.negativeFeedback || 0))
|
||||
return `${Math.round(numericValue)} / ${negativeFeedback}`
|
||||
}
|
||||
if (metric.unit) return `${formatNumberCompact(numericValue)} ${metric.unit}`
|
||||
return formatNumberCompact(numericValue)
|
||||
}
|
||||
|
||||
export function resolveSystemMetricMeta(metric, totals = emptySystemDashboardTotals, realDashboardLoaded = false) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveFinanceMetricMeta({
|
||||
metric,
|
||||
meta,
|
||||
dashboardLoaded,
|
||||
loading,
|
||||
error
|
||||
}) {
|
||||
if (!dashboardLoaded || !meta) {
|
||||
return {
|
||||
changeText: loading ? '加载中' : '实时',
|
||||
delta: error ? '真实数据加载失败' : '等待真实数据',
|
||||
trend: metric.trend
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changeText: meta.changeText || metric.change,
|
||||
delta: meta.delta || metric.delta,
|
||||
trend: meta.trend || metric.trend
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRiskDistributionLegend(distribution, labels, colors, formatter = formatRiskSignalName) {
|
||||
const fallbackColors = ['#ef4444', '#f59e0b', 'var(--theme-primary)', '#3b82f6', '#8b5cf6', '#0f766e']
|
||||
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], index) => ({
|
||||
name: labels[key] || formatter(key),
|
||||
value: Number(value || 0),
|
||||
display: `${Number(value || 0)}项`,
|
||||
color: colors[key] || fallbackColors[index % fallbackColors.length]
|
||||
}))
|
||||
}
|
||||
|
||||
export function formatRiskSignalName(value) {
|
||||
return formatRiskSignalLabel(value)
|
||||
}
|
||||
|
||||
export function isMissingDimension(value) {
|
||||
const text = String(value || '').trim()
|
||||
return !text || ['待补充', '待确认', '未归属部门', '未归属', 'N/A', 'n/a', '-'].includes(text)
|
||||
}
|
||||
122
web/src/composables/overviewViewRangeModel.js
Normal file
122
web/src/composables/overviewViewRangeModel.js
Normal file
@@ -0,0 +1,122 @@
|
||||
export const DEFAULT_OVERVIEW_RANGE = '近10日'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const RISK_DAILY_TREND_MAX_BUCKETS = 14
|
||||
|
||||
function parseLocalDate(value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || '').trim())
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function clampWindowDays(value) {
|
||||
const days = Number(value || 0)
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
return 10
|
||||
}
|
||||
return Math.max(1, Math.min(Math.round(days), 90))
|
||||
}
|
||||
|
||||
function resolveCustomRangeDays(customRange = {}) {
|
||||
const start = parseLocalDate(customRange.start)
|
||||
const end = parseLocalDate(customRange.end)
|
||||
if (!start || !end) {
|
||||
return 10
|
||||
}
|
||||
return clampWindowDays(Math.abs(end.getTime() - start.getTime()) / DAY_MS + 1)
|
||||
}
|
||||
|
||||
export function resolveTopRangeDays(range, customRange = {}) {
|
||||
const key = String(range || DEFAULT_OVERVIEW_RANGE).trim()
|
||||
if (key === 'custom') {
|
||||
return resolveCustomRangeDays(customRange)
|
||||
}
|
||||
if (key === '\u4eca\u65e5') {
|
||||
return 1
|
||||
}
|
||||
if (key === '\u672c\u5468') {
|
||||
const today = new Date()
|
||||
const weekday = today.getDay() || 7
|
||||
return clampWindowDays(weekday)
|
||||
}
|
||||
if (key === '\u672c\u6708') {
|
||||
return clampWindowDays(new Date().getDate())
|
||||
}
|
||||
const match = key.match(/\d+/)
|
||||
return clampWindowDays(match ? Number(match[0]) : 10)
|
||||
}
|
||||
|
||||
export function resolveTopRangeKey(range, customRange = {}) {
|
||||
const key = String(range || DEFAULT_OVERVIEW_RANGE).trim()
|
||||
if (key === 'custom') {
|
||||
return 'custom'
|
||||
}
|
||||
if (key === '\u672c\u5468' || key === '\u4eca\u65e5') {
|
||||
return `recent-${resolveTopRangeDays(key, customRange)}-days`
|
||||
}
|
||||
if (/\d+/.test(key)) {
|
||||
return `recent-${resolveTopRangeDays(key, customRange)}-days`
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
133
web/src/composables/requests/requestClaimMapper.js
Normal file
133
web/src/composables/requests/requestClaimMapper.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
DOCUMENT_TYPE_APPLICATION,
|
||||
buildOccurredDisplay,
|
||||
buildRiskMeta,
|
||||
formatDateTime,
|
||||
parseNumber,
|
||||
parseOptionalAmount,
|
||||
resolveApprovalMeta,
|
||||
resolveDisplayName,
|
||||
resolveDocumentTypeMeta,
|
||||
resolveTypeLabel,
|
||||
resolveWorkflowNode
|
||||
} from './requestShared.js'
|
||||
import { buildExpenseItems } from './requestExpenseItems.js'
|
||||
import { resolveRelatedApplicationInfo } from './requestRelatedApplication.js'
|
||||
import {
|
||||
buildProgressSteps,
|
||||
isApplicationArchivedWorkflow,
|
||||
resolveApplicationLinkedReimbursementNo
|
||||
} from './requestProgressSteps.js'
|
||||
|
||||
export function mapExpenseClaimToRequest(claim) {
|
||||
const typeCode = String(claim?.expense_type || '').trim() || 'other'
|
||||
const typeLabel = resolveTypeLabel(typeCode)
|
||||
const documentTypeMeta = resolveDocumentTypeMeta(claim, typeCode)
|
||||
const isApplicationDocument = documentTypeMeta.documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
const approvalMeta = resolveApprovalMeta(claim?.status)
|
||||
const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument)
|
||||
const applicationArchived = isApplicationDocument && isApplicationArchivedWorkflow(claim, workflowNode)
|
||||
const applicationLinkedReimbursementNo = isApplicationDocument ? resolveApplicationLinkedReimbursementNo(claim) : ''
|
||||
const applicationLinkStatusText = applicationLinkedReimbursementNo
|
||||
? `关联中 ${applicationLinkedReimbursementNo}`
|
||||
: '未关联'
|
||||
const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count))
|
||||
const riskMeta = buildRiskMeta(claim?.risk_flags_json)
|
||||
const riskSummary = riskMeta.summary
|
||||
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
|
||||
const expenseItems = buildExpenseItems(claim, riskMeta)
|
||||
const visibleExpenseAmount = expenseItems.reduce((sum, item) => {
|
||||
const amount = parseOptionalAmount(item.reimbursableAmount) ?? parseNumber(item.itemAmount)
|
||||
return sum + amount
|
||||
}, 0)
|
||||
const amountValue = relatedApplication
|
||||
? expenseItems.length
|
||||
? visibleExpenseAmount
|
||||
: invoiceCount === 0
|
||||
? 0
|
||||
: parseNumber(claim?.amount)
|
||||
: parseNumber(claim?.amount)
|
||||
const applyDateTime = claim?.submitted_at || claim?.created_at
|
||||
const employeeId = String(claim?.employee_id || claim?.employeeId || '').trim()
|
||||
const employeeName = String(claim?.employee_name || claim?.employeeName || '').trim()
|
||||
|
||||
return {
|
||||
id: String(claim?.claim_no || claim?.id || '').trim(),
|
||||
claimNo: String(claim?.claim_no || claim?.id || '').trim(),
|
||||
claimId: String(claim?.id || '').trim(),
|
||||
status: String(claim?.status || '').trim(),
|
||||
employeeId,
|
||||
employee_id: employeeId,
|
||||
profileEmployeeId: employeeId || employeeName,
|
||||
person: String(claim?.employee_name || '').trim() || '待补充',
|
||||
dept: String(claim?.department_name || '').trim() || '待补充',
|
||||
departmentName: String(claim?.department_name || '').trim() || '待补充',
|
||||
employeeName: String(claim?.employee_name || '').trim() || '待补充',
|
||||
employeePosition: String(claim?.employee_position || '').trim(),
|
||||
employeeGrade: String(claim?.employee_grade || '').trim(),
|
||||
managerName: resolveDisplayName(claim?.manager_name),
|
||||
financeApproverName: resolveDisplayName(claim?.finance_approver_name, claim?.financeApproverName),
|
||||
financeOwnerName: resolveDisplayName(claim?.finance_owner_name, claim?.financeOwnerName),
|
||||
budgetApproverName: resolveDisplayName(claim?.budget_approver_name, claim?.budgetApproverName),
|
||||
budgetApproverGrade: String(claim?.budget_approver_grade || claim?.budgetApproverGrade || '').trim(),
|
||||
budgetApproverRoleCode: String(claim?.budget_approver_role_code || claim?.budgetApproverRoleCode || '').trim(),
|
||||
roleLabels: Array.isArray(claim?.role_labels) ? claim.role_labels.filter(Boolean) : [],
|
||||
entity: '',
|
||||
typeCode,
|
||||
typeLabel,
|
||||
...documentTypeMeta,
|
||||
detailVariant: typeCode === 'travel' || typeCode === 'travel_application' ? 'travel' : 'general',
|
||||
title: String(claim?.reason || '').trim() || (isApplicationDocument ? typeLabel : `${typeLabel}报销`),
|
||||
sceneLabel: typeLabel,
|
||||
sceneTarget: String(claim?.location || '').trim() || '待补充',
|
||||
location: String(claim?.location || '').trim() || '待补充',
|
||||
relatedCustomer: '',
|
||||
occurredDisplay: buildOccurredDisplay(claim),
|
||||
occurredAt: claim?.occurred_at || '',
|
||||
applyTime: formatDateTime(applyDateTime) || '待补充',
|
||||
submittedAt: applyDateTime || '',
|
||||
createdAt: claim?.created_at || '',
|
||||
updatedAt: claim?.updated_at || '',
|
||||
amount: amountValue,
|
||||
riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [],
|
||||
riskTone: riskMeta.tone,
|
||||
riskLabel: riskMeta.label,
|
||||
invoiceCount,
|
||||
workflowNode,
|
||||
approvalKey: approvalMeta.key,
|
||||
approvalStatus: approvalMeta.label,
|
||||
approvalTone: approvalMeta.tone,
|
||||
secondaryStatusLabel: isApplicationDocument ? '申请材料' : (typeCode === 'travel' ? '行程状态' : '票据状态'),
|
||||
secondaryStatusValue: isApplicationDocument
|
||||
? approvalMeta.key === 'supplement'
|
||||
? '领导已退回,待重新提交'
|
||||
: applicationArchived
|
||||
? '已归档'
|
||||
: approvalMeta.key === 'completed'
|
||||
? applicationLinkStatusText
|
||||
: '已进入审批流程'
|
||||
: (invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据'),
|
||||
secondaryStatusTone: isApplicationDocument
|
||||
? approvalMeta.key === 'supplement'
|
||||
? 'warning'
|
||||
: approvalMeta.key === 'completed' && !applicationArchived && !applicationLinkedReimbursementNo
|
||||
? 'warning'
|
||||
: 'success'
|
||||
: (invoiceCount > 0 ? 'success' : 'warning'),
|
||||
riskSummary,
|
||||
attachmentSummary: isApplicationDocument ? '申请单' : (invoiceCount > 0 ? `${invoiceCount} 张票据` : '无'),
|
||||
expenseTableSummary: isApplicationDocument
|
||||
? '预计金额已随申请提交'
|
||||
: expenseItems.length
|
||||
? (invoiceCount > 0
|
||||
? `共 ${expenseItems.length} 条费用明细,已关联 ${invoiceCount} 张票据`
|
||||
: `共 ${expenseItems.length} 条费用明细,待补充票据`)
|
||||
: '暂无费用明细',
|
||||
note: String(claim?.reason || '').trim(),
|
||||
relatedApplication,
|
||||
progressSteps: buildProgressSteps(approvalMeta, workflowNode, claim, {
|
||||
documentTypeCode: documentTypeMeta.documentTypeCode
|
||||
}),
|
||||
expenseItems
|
||||
}
|
||||
}
|
||||
306
web/src/composables/requests/requestExpenseItems.js
Normal file
306
web/src/composables/requests/requestExpenseItems.js
Normal file
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
DOCUMENT_BACKED_EXPENSE_TYPES,
|
||||
HOTEL_DESCRIPTION_EXPENSE_TYPES,
|
||||
LONG_DISTANCE_TRAVEL_EXPENSE_TYPES,
|
||||
LOCATION_REQUIRED_EXPENSE_TYPES,
|
||||
ROUTE_DESCRIPTION_EXPENSE_TYPES,
|
||||
STANDARD_ADJUSTMENT_RISK_SOURCE,
|
||||
SYSTEM_GENERATED_EXPENSE_TYPES,
|
||||
formatAmount,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
normalizeText,
|
||||
parseNumber,
|
||||
parseOptionalAmount,
|
||||
resolveTypeLabel
|
||||
} from './requestShared.js'
|
||||
import {
|
||||
findRelatedApplicationEvent,
|
||||
resolveRelatedApplicationInfo
|
||||
} from './requestRelatedApplication.js'
|
||||
|
||||
function buildStandardAdjustmentMapFromClaim(claim = {}) {
|
||||
const flags = Array.isArray(claim?.risk_flags_json)
|
||||
? claim.risk_flags_json
|
||||
: Array.isArray(claim?.riskFlags)
|
||||
? claim.riskFlags
|
||||
: []
|
||||
const adjustmentMap = new Map()
|
||||
|
||||
flags.forEach((flag) => {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return
|
||||
}
|
||||
if (String(flag.source || '').trim() !== STANDARD_ADJUSTMENT_RISK_SOURCE) {
|
||||
return
|
||||
}
|
||||
const itemId = String(flag.item_id || flag.itemId || '').trim()
|
||||
const reimbursableAmount = parseOptionalAmount(flag.reimbursable_amount ?? flag.reimbursableAmount)
|
||||
if (!itemId || reimbursableAmount === null) {
|
||||
return
|
||||
}
|
||||
adjustmentMap.set(itemId, {
|
||||
originalAmount: parseOptionalAmount(flag.original_amount ?? flag.originalAmount),
|
||||
reimbursableAmount,
|
||||
employeeAbsorbedAmount: parseOptionalAmount(flag.employee_absorbed_amount ?? flag.employeeAbsorbedAmount) || 0,
|
||||
message: String(flag.message || flag.summary || '').trim()
|
||||
})
|
||||
})
|
||||
|
||||
return adjustmentMap
|
||||
}
|
||||
|
||||
function normalizeExpenseType(typeCode) {
|
||||
return String(typeCode || '').trim() || 'other'
|
||||
}
|
||||
|
||||
function isLocationRequiredExpenseType(typeCode) {
|
||||
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(typeCode))
|
||||
}
|
||||
|
||||
function resolveLocationDisplay(location, typeCode) {
|
||||
const normalized = String(location || '').trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return isLocationRequiredExpenseType(typeCode) ? '待补充' : '非必填'
|
||||
}
|
||||
|
||||
function resolveExpenseDescriptionDetail(itemType, itemLocation) {
|
||||
const normalizedType = normalizeExpenseType(itemType)
|
||||
if (ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) {
|
||||
return '起始地-目的地'
|
||||
}
|
||||
if (HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) {
|
||||
return '目的地酒店'
|
||||
}
|
||||
return resolveLocationDisplay(itemLocation, normalizedType)
|
||||
}
|
||||
|
||||
function resolveExpenseItemViewId(item, index, claim) {
|
||||
return String(item?.id || `${claim?.id || 'claim'}-item-${index}`)
|
||||
}
|
||||
|
||||
function buildTravelTimeLabelMap(items, claim) {
|
||||
const travelItems = items
|
||||
.map((item, index) => {
|
||||
const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type)
|
||||
return {
|
||||
id: resolveExpenseItemViewId(item, index, claim),
|
||||
index,
|
||||
itemType,
|
||||
itemDate: formatDate(item?.item_date),
|
||||
isSystemGenerated: Boolean(item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||||
}
|
||||
})
|
||||
.filter((item) => !item.isSystemGenerated && LONG_DISTANCE_TRAVEL_EXPENSE_TYPES.has(item.itemType))
|
||||
.sort((left, right) => {
|
||||
const dateCompare = String(left.itemDate || '').localeCompare(String(right.itemDate || ''))
|
||||
return dateCompare || left.index - right.index
|
||||
})
|
||||
|
||||
const labels = new Map()
|
||||
travelItems.forEach((item, index) => {
|
||||
if (index === 0) {
|
||||
labels.set(item.id, '出发时间')
|
||||
} else if (index === travelItems.length - 1) {
|
||||
labels.set(item.id, '返回时间')
|
||||
} else {
|
||||
labels.set(item.id, '中转时间')
|
||||
}
|
||||
})
|
||||
return labels
|
||||
}
|
||||
|
||||
function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, claim, travelTimeLabelMap }) {
|
||||
if (isSystemGenerated) {
|
||||
return '系统自动计算'
|
||||
}
|
||||
if (travelTimeLabelMap?.has(id)) {
|
||||
return travelTimeLabelMap.get(id)
|
||||
}
|
||||
if (itemType === 'ride_ticket') {
|
||||
return '乘车时间'
|
||||
}
|
||||
if (itemType === 'hotel_ticket') {
|
||||
return '住宿时间'
|
||||
}
|
||||
return claim?.expense_type === 'travel' ? '出行时间' : '业务发生时间'
|
||||
}
|
||||
|
||||
function resolveAttachmentDisplayName(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return normalized.split('/').filter(Boolean).pop() || normalized
|
||||
}
|
||||
|
||||
function hasRelatedApplicationContext(claim) {
|
||||
return Boolean(findRelatedApplicationEvent(claim))
|
||||
}
|
||||
|
||||
function isDocumentBackedRawExpenseItem(item) {
|
||||
const invoiceId = normalizeText(item?.invoice_id || item?.invoiceId)
|
||||
if (invoiceId) {
|
||||
return true
|
||||
}
|
||||
|
||||
return DOCUMENT_BACKED_EXPENSE_TYPES.has(normalizeExpenseType(item?.item_type || item?.itemType))
|
||||
}
|
||||
|
||||
function extractTravelDayCount(value) {
|
||||
const matched = normalizeText(value).replace(/\s+/g, '').match(/(\d{1,2})天/)
|
||||
return matched ? parseNumber(matched[1]) : 0
|
||||
}
|
||||
|
||||
function isStaleApplicationAllowanceRawItem(item, claim) {
|
||||
const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
|
||||
if (itemType !== 'travel_allowance') {
|
||||
return false
|
||||
}
|
||||
|
||||
const related = resolveRelatedApplicationInfo(claim)
|
||||
const applicationDays = extractTravelDayCount(related?.days)
|
||||
const itemDays = extractTravelDayCount(item?.item_reason || item?.itemReason)
|
||||
return applicationDays > 0 && itemDays > 0 && applicationDays !== itemDays
|
||||
}
|
||||
|
||||
function isApplicationLinkPlaceholderRawItem(item, claim) {
|
||||
const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
|
||||
if (SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const claimType = normalizeExpenseType(claim?.expense_type || claim?.expenseType)
|
||||
if (itemType && claimType && itemType !== claimType) {
|
||||
return false
|
||||
}
|
||||
|
||||
const reason = normalizeText(item?.item_reason || item?.itemReason)
|
||||
if (!reason || reason === '待补充') {
|
||||
return true
|
||||
}
|
||||
|
||||
const related = resolveRelatedApplicationInfo(claim)
|
||||
const linkedReasons = new Set([
|
||||
normalizeText(claim?.reason),
|
||||
normalizeText(related?.reason)
|
||||
].filter(Boolean))
|
||||
return linkedReasons.has(reason)
|
||||
}
|
||||
|
||||
function filterVisibleExpenseRawItems(items, claim) {
|
||||
const rawItems = Array.isArray(items) ? items : []
|
||||
if (!rawItems.length || !hasRelatedApplicationContext(claim)) {
|
||||
return rawItems
|
||||
}
|
||||
|
||||
const hasRealExpenseItem = rawItems.some((item) => (
|
||||
isDocumentBackedRawExpenseItem(item)
|
||||
&& !SYSTEM_GENERATED_EXPENSE_TYPES.has(normalizeExpenseType(item?.item_type || item?.itemType))
|
||||
))
|
||||
if (!hasRealExpenseItem) {
|
||||
return rawItems.filter((item) => !isApplicationLinkPlaceholderRawItem(item, claim))
|
||||
}
|
||||
|
||||
return rawItems.filter((item) => {
|
||||
const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
|
||||
if (SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) {
|
||||
return !isStaleApplicationAllowanceRawItem(item, claim)
|
||||
}
|
||||
return !isApplicationLinkPlaceholderRawItem(item, claim)
|
||||
})
|
||||
}
|
||||
|
||||
function buildExpenseItems(claim, riskMeta) {
|
||||
if (!Array.isArray(claim?.items)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalizedRiskMeta = typeof riskMeta === 'string'
|
||||
? { summary: riskMeta, tone: riskMeta === '无' ? 'low' : 'medium', label: riskMeta === '无' ? '无' : '待关注' }
|
||||
: {
|
||||
summary: String(riskMeta?.summary || '无').trim() || '无',
|
||||
tone: String(riskMeta?.tone || 'low').trim() || 'low',
|
||||
label: String(riskMeta?.label || '').trim() || (String(riskMeta?.summary || '').trim() === '无' ? '无' : '待关注')
|
||||
}
|
||||
|
||||
const visibleItems = filterVisibleExpenseRawItems(claim.items, claim)
|
||||
const sortedItems = [...visibleItems].sort((left, right) => {
|
||||
const leftType = normalizeExpenseType(left?.item_type)
|
||||
const rightType = normalizeExpenseType(right?.item_type)
|
||||
return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType))
|
||||
})
|
||||
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, claim)
|
||||
const standardAdjustmentMap = buildStandardAdjustmentMapFromClaim(claim)
|
||||
|
||||
return sortedItems.map((item, index) => {
|
||||
const invoiceId = String(item?.invoice_id || '').trim()
|
||||
const attachmentName = resolveAttachmentDisplayName(invoiceId)
|
||||
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
||||
const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type)
|
||||
const isSystemGenerated = Boolean(item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||||
const id = resolveExpenseItemViewId(item, index, claim)
|
||||
const itemTypeLabel = resolveTypeLabel(itemType)
|
||||
const itemLocation = String(item?.item_location || '').trim()
|
||||
const itemReason = String(item?.item_reason || '').trim()
|
||||
const itemNote = String(item?.item_note || item?.itemNote || '').trim()
|
||||
const itemAmount = parseNumber(item?.item_amount)
|
||||
const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充'
|
||||
const standardAdjustment = standardAdjustmentMap.get(id) || null
|
||||
const originalItemAmount = standardAdjustment?.originalAmount ?? itemAmount
|
||||
const reimbursableAmount = standardAdjustment?.reimbursableAmount ?? itemAmount
|
||||
const employeeAbsorbedAmount = standardAdjustment?.employeeAbsorbedAmount || Math.max(originalItemAmount - reimbursableAmount, 0)
|
||||
|
||||
return {
|
||||
id,
|
||||
time: formatDate(item?.item_date) || '待补充',
|
||||
itemDate: formatDate(item?.item_date) || '',
|
||||
filledAt: formatDateTime(item?.created_at) || '待同步',
|
||||
itemType,
|
||||
itemReason,
|
||||
itemLocation,
|
||||
itemNote,
|
||||
itemAmount,
|
||||
originalItemAmount,
|
||||
originalAmountDisplay: originalItemAmount > 0 ? formatAmount(originalItemAmount) : itemAmountDisplay,
|
||||
reimbursableAmount,
|
||||
reimbursableAmountDisplay: reimbursableAmount > 0 ? formatAmount(reimbursableAmount) : '待补充',
|
||||
employeeAbsorbedAmount,
|
||||
employeeAbsorbedAmountDisplay: employeeAbsorbedAmount > 0 ? formatAmount(employeeAbsorbedAmount) : '',
|
||||
hasStandardAdjustment: reimbursableAmount >= 0 && reimbursableAmount < originalItemAmount,
|
||||
standardAdjustmentAccepted: Boolean(standardAdjustment),
|
||||
standardAdjustmentMessage: standardAdjustment?.message || '',
|
||||
invoiceId,
|
||||
isSystemGenerated,
|
||||
dayLabel: resolveExpenseTimeLabel({
|
||||
id,
|
||||
itemType,
|
||||
isSystemGenerated,
|
||||
claim,
|
||||
travelTimeLabelMap
|
||||
}),
|
||||
name: itemTypeLabel,
|
||||
category: itemTypeLabel,
|
||||
desc: itemReason || '待补充',
|
||||
detail: resolveExpenseDescriptionDetail(itemType, itemLocation),
|
||||
amount: itemAmountDisplay,
|
||||
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
|
||||
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据',
|
||||
attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
|
||||
attachments,
|
||||
riskLabel: normalizedRiskMeta.summary === '无' ? '无' : normalizedRiskMeta.label,
|
||||
riskText: normalizedRiskMeta.summary === '无' ? '' : normalizedRiskMeta.summary,
|
||||
riskTone: normalizedRiskMeta.summary === '无' ? 'low' : normalizedRiskMeta.tone
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
buildExpenseItems
|
||||
}
|
||||
704
web/src/composables/requests/requestProgressSteps.js
Normal file
704
web/src/composables/requests/requestProgressSteps.js
Normal file
@@ -0,0 +1,704 @@
|
||||
import {
|
||||
APPLICATION_ARCHIVE_STAGE_LABEL,
|
||||
APPLICATION_LINK_STATUS_STEP_LABEL,
|
||||
APPLICATION_PROGRESS_LABELS,
|
||||
APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET,
|
||||
ARCHIVED_STEP_LABEL,
|
||||
DOCUMENT_TYPE_APPLICATION,
|
||||
RELATED_APPLICATION_STEP_LABEL,
|
||||
REIMBURSEMENT_PROGRESS_LABELS,
|
||||
formatDateTime,
|
||||
formatDurationFrom,
|
||||
getLatestEvent,
|
||||
getRiskFlags,
|
||||
normalizeText,
|
||||
parseNumber,
|
||||
resolveDisplayName
|
||||
} from './requestShared.js'
|
||||
import { resolveRelatedApplicationInfo } from './requestRelatedApplication.js'
|
||||
|
||||
function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
|
||||
const normalizedNode = String(workflowNode || '').trim()
|
||||
|
||||
if (approvalMeta.key === 'completed') {
|
||||
return 6
|
||||
}
|
||||
|
||||
if (approvalMeta.key === 'pending_payment') {
|
||||
return 4
|
||||
}
|
||||
|
||||
if (normalizedNode.includes('已付款')) {
|
||||
return 5
|
||||
}
|
||||
if (normalizedNode.includes('待付款')) {
|
||||
return 4
|
||||
}
|
||||
if (normalizedNode.includes('归档') || normalizedNode.includes('入账')) {
|
||||
return 6
|
||||
}
|
||||
if (normalizedNode.includes('财务')) {
|
||||
return 3
|
||||
}
|
||||
if (
|
||||
normalizedNode.includes('直属领导')
|
||||
|| normalizedNode.includes('领导审批')
|
||||
|| normalizedNode.includes('部门负责人')
|
||||
|| normalizedNode.includes('负责人审批')
|
||||
) {
|
||||
return 2
|
||||
}
|
||||
if (normalizedNode.includes('AI预审') || normalizedNode.includes('AI验审') || normalizedNode.includes('审批流转')) {
|
||||
return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? 1 : 2
|
||||
}
|
||||
if (normalizedNode.includes('待提交')) {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 2
|
||||
}
|
||||
|
||||
function resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) {
|
||||
const normalizedNode = String(workflowNode || '').trim()
|
||||
|
||||
if (approvalMeta.key === 'completed') {
|
||||
return normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) ? 3 : 2
|
||||
}
|
||||
|
||||
if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) {
|
||||
return 3
|
||||
}
|
||||
if (normalizedNode.includes(APPLICATION_LINK_STATUS_STEP_LABEL)) {
|
||||
return 2
|
||||
}
|
||||
if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) {
|
||||
return 2
|
||||
}
|
||||
if (normalizedNode.includes('预算')) {
|
||||
return 2
|
||||
}
|
||||
if (
|
||||
normalizedNode.includes('直属领导')
|
||||
|| normalizedNode.includes('领导审批')
|
||||
|| normalizedNode.includes('部门负责人')
|
||||
|| normalizedNode.includes('负责人审批')
|
||||
) {
|
||||
return 1
|
||||
}
|
||||
if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') {
|
||||
return 0
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
function isApplicationArchivedWorkflow(claim, workflowNode) {
|
||||
const normalizedNode = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode)
|
||||
if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) {
|
||||
return true
|
||||
}
|
||||
return getRiskFlags(claim).some((flag) => (
|
||||
flag
|
||||
&& typeof flag === 'object'
|
||||
&& normalizeText(flag.source) === 'application_archive_sync'
|
||||
))
|
||||
}
|
||||
|
||||
function resolveApplicationLinkedReimbursementNo(claim) {
|
||||
for (const flag of [...getRiskFlags(claim)].reverse()) {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
continue
|
||||
}
|
||||
const generatedNo = normalizeText(
|
||||
flag.generated_draft_claim_no
|
||||
|| flag.generatedDraftClaimNo
|
||||
|| flag.reimbursement_claim_no
|
||||
|| flag.reimbursementClaimNo
|
||||
)
|
||||
if (generatedNo) {
|
||||
return generatedNo
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildApplicationLinkStatusStepMeta(claim) {
|
||||
const reimbursementNo = resolveApplicationLinkedReimbursementNo(claim)
|
||||
const updatedAt = formatDateTime(claim?.updated_at)
|
||||
return reimbursementNo
|
||||
? buildProgressStepMeta(`关联中 ${reimbursementNo}`, updatedAt)
|
||||
: buildProgressStepMeta('未关联', updatedAt)
|
||||
}
|
||||
|
||||
|
||||
function resolveApplicationApproverName(claim) {
|
||||
return resolveDisplayName(
|
||||
claim?.manager_name,
|
||||
claim?.managerName,
|
||||
claim?.profile_manager,
|
||||
claim?.profileManager,
|
||||
claim?.direct_manager_name,
|
||||
claim?.directManagerName
|
||||
) || '直属领导'
|
||||
}
|
||||
|
||||
function resolveReimbursementApproverName(claim, label) {
|
||||
const stepLabel = normalizeText(label)
|
||||
if (stepLabel === '直属领导审批') {
|
||||
return resolveDisplayName(
|
||||
claim?.manager_name,
|
||||
claim?.managerName,
|
||||
claim?.profile_manager,
|
||||
claim?.profileManager,
|
||||
claim?.direct_manager_name,
|
||||
claim?.directManagerName
|
||||
) || '直属领导'
|
||||
}
|
||||
|
||||
if (stepLabel === '财务审批') {
|
||||
const routeEvent = findReimbursementFinanceRouteEvent(claim)
|
||||
return resolveDisplayName(
|
||||
claim?.finance_approver_name,
|
||||
claim?.financeApproverName,
|
||||
routeEvent?.next_approver_name,
|
||||
routeEvent?.nextApproverName,
|
||||
routeEvent?.finance_approver_name,
|
||||
routeEvent?.financeApproverName,
|
||||
claim?.finance_owner_name,
|
||||
claim?.financeOwnerName
|
||||
) || '财务'
|
||||
}
|
||||
|
||||
return stepLabel.replace(/审批$/, '') || '审批人'
|
||||
}
|
||||
|
||||
function resolveApplicationBudgetApproverName(claim) {
|
||||
const routeEvent = findApprovalEventForStep(claim, '直属领导审批')
|
||||
return resolveDisplayName(
|
||||
claim?.budget_approver_name,
|
||||
claim?.budgetApproverName,
|
||||
routeEvent?.next_approver_name,
|
||||
routeEvent?.nextApproverName,
|
||||
routeEvent?.budget_approver_name,
|
||||
routeEvent?.budgetApproverName
|
||||
) || '预算管理者'
|
||||
}
|
||||
|
||||
function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) {
|
||||
const normalizedLabel = normalizeText(label)
|
||||
const workflowNode = normalizeText(claim?.approval_stage || claim?.workflowNode)
|
||||
if (
|
||||
documentTypeCode !== DOCUMENT_TYPE_APPLICATION
|
||||
&& approvalMeta.key !== 'completed'
|
||||
&& normalizedLabel === '直属领导审批'
|
||||
&& workflowNode.includes(normalizedLabel.replace(/审批$/, ''))
|
||||
) {
|
||||
return '等待批复'
|
||||
}
|
||||
|
||||
if (
|
||||
documentTypeCode !== DOCUMENT_TYPE_APPLICATION
|
||||
&& approvalMeta.key !== 'completed'
|
||||
&& normalizedLabel === '财务审批'
|
||||
&& workflowNode.includes(normalizedLabel.replace(/审批$/, ''))
|
||||
) {
|
||||
return `等待 ${resolveReimbursementApproverName(claim, normalizedLabel)} 批复`
|
||||
}
|
||||
|
||||
if (
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
&& approvalMeta.key !== 'completed'
|
||||
&& normalizedLabel === '直属领导审批'
|
||||
&& (
|
||||
workflowNode.includes('直属领导')
|
||||
|| workflowNode.includes('领导审批')
|
||||
|| workflowNode.includes('部门负责人')
|
||||
|| workflowNode.includes('负责人审批')
|
||||
)
|
||||
) {
|
||||
return `等待 ${resolveApplicationApproverName(claim)} 批复`
|
||||
}
|
||||
|
||||
if (
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
&& approvalMeta.key !== 'completed'
|
||||
&& normalizedLabel === '预算管理者审批'
|
||||
&& workflowNode.includes('预算')
|
||||
) {
|
||||
return `等待 ${resolveApplicationBudgetApproverName(claim)} 批复`
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
|
||||
|
||||
function findApprovalEventForStep(claim, label) {
|
||||
const stepLabel = normalizeText(label)
|
||||
const events = getRiskFlags(claim).filter((flag) => {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const source = normalizeText(flag.source)
|
||||
if (!['manual_approval', 'budget_approval', 'finance_approval'].includes(source)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
|
||||
const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
|
||||
|
||||
if (stepLabel === '直属领导审批') {
|
||||
return (
|
||||
previousStage.includes('直属领导')
|
||||
|| previousStage.includes('领导审批')
|
||||
|| nextStage.includes('预算')
|
||||
|| nextStage.includes('财务')
|
||||
)
|
||||
}
|
||||
|
||||
if (stepLabel === '预算管理者审批') {
|
||||
return (
|
||||
source === 'budget_approval'
|
||||
|| previousStage.includes('预算')
|
||||
|| nextStage.includes('审批完成')
|
||||
)
|
||||
}
|
||||
|
||||
if (stepLabel === '财务审批') {
|
||||
return (
|
||||
previousStage.includes('财务')
|
||||
|| nextStage.includes('待付款')
|
||||
|| nextStage.includes('归档')
|
||||
|| nextStage.includes('入账')
|
||||
|| nextStage.includes('完成')
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
return getLatestEvent(events)
|
||||
}
|
||||
|
||||
function findReimbursementFinanceRouteEvent(claim) {
|
||||
return getLatestEvent(
|
||||
getRiskFlags(claim).filter((flag) => {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const source = normalizeText(flag.source)
|
||||
if (!['manual_approval', 'budget_approval'].includes(source)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
|
||||
return nextStage.includes('财务')
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function findLatestReturnEvent(claim) {
|
||||
return getLatestEvent(
|
||||
getRiskFlags(claim).filter((flag) => (
|
||||
flag
|
||||
&& typeof flag === 'object'
|
||||
&& normalizeText(flag.source) === 'manual_return'
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
function findLatestPaymentEvent(claim) {
|
||||
return getLatestEvent(
|
||||
getRiskFlags(claim).filter((flag) => (
|
||||
flag
|
||||
&& typeof flag === 'object'
|
||||
&& (
|
||||
normalizeText(flag.source) === 'payment'
|
||||
|| normalizeText(flag.event_type || flag.eventType) === 'expense_claim_payment_completed'
|
||||
)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function findLatestApplicationReturnEvent(claim) {
|
||||
return getLatestEvent(
|
||||
getRiskFlags(claim).filter((flag) => {
|
||||
if (!flag || typeof flag !== 'object' || normalizeText(flag.source) !== 'manual_return') {
|
||||
return false
|
||||
}
|
||||
const eventType = normalizeText(flag.event_type || flag.eventType)
|
||||
const returnStage = normalizeText(flag.return_stage || flag.returnStage || flag.previous_approval_stage)
|
||||
const stageKey = normalizeText(flag.return_stage_key || flag.returnStageKey)
|
||||
return (
|
||||
eventType === 'expense_application_return'
|
||||
|| stageKey === 'direct_manager'
|
||||
|| returnStage.includes('直属领导')
|
||||
|| returnStage.includes('领导审批')
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
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('审批完成')
|
||||
|| nextStage.includes(APPLICATION_LINK_STATUS_STEP_LABEL)
|
||||
|| nextStage.includes('申请完成')
|
||||
)
|
||||
&& mergedFlag
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function resolveBudgetRouteResult(flag, routeDecision = {}) {
|
||||
if (routeDecision && typeof routeDecision === 'object') {
|
||||
const routeBudgetResult = routeDecision.budget_result || routeDecision.budgetResult
|
||||
if (routeBudgetResult && typeof routeBudgetResult === 'object') {
|
||||
return routeBudgetResult
|
||||
}
|
||||
}
|
||||
|
||||
const flagBudgetResult = flag?.budget_result || flag?.budgetResult
|
||||
return flagBudgetResult && typeof flagBudgetResult === 'object' ? flagBudgetResult : {}
|
||||
}
|
||||
|
||||
function applicationBudgetRouteMeetsThreshold(flag, routeDecision = {}) {
|
||||
const budgetResult = resolveBudgetRouteResult(flag, routeDecision)
|
||||
const metrics = budgetResult.metrics && typeof budgetResult.metrics === 'object' ? budgetResult.metrics : {}
|
||||
const overBudgetAmount = parseNumber(metrics.over_budget_amount ?? metrics.overBudgetAmount)
|
||||
const afterUsageRate = parseNumber(metrics.after_usage_rate ?? metrics.afterUsageRate)
|
||||
const claimAmountRatio = parseNumber(metrics.claim_amount_ratio ?? metrics.claimAmountRatio)
|
||||
|
||||
return overBudgetAmount > 0 || Math.max(afterUsageRate, claimAmountRatio) >= 90
|
||||
}
|
||||
|
||||
function applicationRequiresBudgetReviewStep(claim, workflowNode) {
|
||||
const node = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode)
|
||||
if (node.includes('预算')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return getRiskFlags(claim).some((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 routeDecision = flag.route_decision || flag.routeDecision || {}
|
||||
|
||||
if (source === 'approval_routing' && flag.requires_budget_review === true) {
|
||||
return applicationBudgetRouteMeetsThreshold(flag, flag)
|
||||
}
|
||||
if (
|
||||
routeDecision
|
||||
&& typeof routeDecision === 'object'
|
||||
&& routeDecision.requires_budget_review === true
|
||||
) {
|
||||
return applicationBudgetRouteMeetsThreshold(flag, routeDecision)
|
||||
}
|
||||
return (
|
||||
source === 'budget_approval'
|
||||
|| eventType === 'expense_application_budget_approval'
|
||||
|| previousStage.includes('预算')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function buildProgressStepMeta(time, detail = '', title = '') {
|
||||
return {
|
||||
time,
|
||||
detail,
|
||||
title: title || [time, detail].filter(Boolean).join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
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 === APPLICATION_LINK_STATUS_STEP_LABEL) {
|
||||
return buildApplicationLinkStatusStepMeta(claim)
|
||||
}
|
||||
|
||||
if (stepLabel === '创建单据' || stepLabel === '创建申请') {
|
||||
const createdAt = formatDateTime(claim?.created_at)
|
||||
return buildProgressStepMeta(stepLabel === '创建申请' ? `${employeeName}发起申请` : `${employeeName}创建`, createdAt)
|
||||
}
|
||||
|
||||
if (stepLabel === '待提交') {
|
||||
const submittedAt = formatDateTime(claim?.submitted_at)
|
||||
return buildProgressStepMeta(`${employeeName}提交`, submittedAt)
|
||||
}
|
||||
|
||||
if (stepLabel === '直属领导审批' || stepLabel === '预算管理者审批' || stepLabel === '财务审批') {
|
||||
const approvalEvent = findApprovalEventForStep(claim, stepLabel)
|
||||
if (approvalEvent) {
|
||||
const operator = resolveDisplayName(
|
||||
approvalEvent.operator,
|
||||
approvalEvent.operator_name,
|
||||
approvalEvent.operatorName,
|
||||
stepLabel === '直属领导审批' ? claim?.manager_name : '',
|
||||
stepLabel === '预算管理者审批' ? approvalEvent.next_approver_name : ''
|
||||
) || (stepLabel === '财务审批' ? '财务' : stepLabel === '预算管理者审批' ? '预算管理者' : '直属领导')
|
||||
const approvedAt = formatDateTime(approvalEvent.created_at || approvalEvent.createdAt)
|
||||
return buildProgressStepMeta(`${operator}通过`, approvedAt, `${operator}审批通过 ${approvedAt}`.trim())
|
||||
}
|
||||
|
||||
if (stepLabel === '财务审批') {
|
||||
const updatedAt = formatDateTime(claim?.updated_at)
|
||||
return buildProgressStepMeta('财务通过', updatedAt, `财务审批通过 ${updatedAt}`.trim())
|
||||
}
|
||||
|
||||
if (stepLabel === '直属领导审批') {
|
||||
const returnEvent = findLatestApplicationReturnEvent(claim)
|
||||
if (returnEvent) {
|
||||
const handledAt = formatDateTime(returnEvent.created_at || returnEvent.createdAt)
|
||||
return buildProgressStepMeta('已处理', handledAt, `直属领导已处理 ${handledAt}`.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stepLabel === '退回') {
|
||||
const returnEvent = findLatestApplicationReturnEvent(claim) || findLatestReturnEvent(claim)
|
||||
if (returnEvent) {
|
||||
const operator = resolveDisplayName(
|
||||
returnEvent.operator,
|
||||
returnEvent.operator_name,
|
||||
returnEvent.operatorName,
|
||||
claim?.manager_name
|
||||
) || '直属领导'
|
||||
const returnedAt = formatDateTime(returnEvent.created_at || returnEvent.createdAt)
|
||||
return buildProgressStepMeta(`${operator}退回`, returnedAt, `${operator}退回 ${returnedAt}`.trim())
|
||||
}
|
||||
}
|
||||
|
||||
if (stepLabel === '待付款') {
|
||||
const approvalEvent = findApprovalEventForStep(claim, '财务审批')
|
||||
const pendingAt = formatDateTime(approvalEvent?.created_at || approvalEvent?.createdAt || claim?.updated_at)
|
||||
return buildProgressStepMeta('待付款', pendingAt)
|
||||
}
|
||||
|
||||
if (stepLabel === '已付款') {
|
||||
const paymentEvent = findLatestPaymentEvent(claim)
|
||||
const paidAt = formatDateTime(paymentEvent?.created_at || paymentEvent?.createdAt || claim?.updated_at)
|
||||
return buildProgressStepMeta('已付款', paidAt)
|
||||
}
|
||||
|
||||
if (stepLabel === '归档入账') {
|
||||
const archivedAt = formatDateTime(claim?.updated_at)
|
||||
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)
|
||||
}
|
||||
|
||||
return buildProgressStepMeta('已完成')
|
||||
}
|
||||
|
||||
function resolveCurrentStepStartedAt(claim, label) {
|
||||
const stepLabel = normalizeText(label)
|
||||
if (stepLabel === RELATED_APPLICATION_STEP_LABEL || stepLabel === '创建单据' || stepLabel === '创建申请') {
|
||||
return claim?.created_at
|
||||
}
|
||||
if (stepLabel === '待提交') {
|
||||
const returnEvent = findLatestReturnEvent(claim)
|
||||
return returnEvent?.created_at || returnEvent?.createdAt || claim?.updated_at || claim?.created_at
|
||||
}
|
||||
if (stepLabel === '直属领导审批') {
|
||||
return claim?.submitted_at || claim?.updated_at || claim?.created_at
|
||||
}
|
||||
if (stepLabel === '预算管理者审批') {
|
||||
const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批')
|
||||
return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
|
||||
}
|
||||
if (stepLabel === '财务审批') {
|
||||
const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批')
|
||||
return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
|
||||
}
|
||||
if (stepLabel === '待付款') {
|
||||
const approvalEvent = findApprovalEventForStep(claim, '财务审批')
|
||||
return approvalEvent?.created_at || approvalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
|
||||
}
|
||||
if (stepLabel === '已付款') {
|
||||
const paymentEvent = findLatestPaymentEvent(claim)
|
||||
return paymentEvent?.created_at || paymentEvent?.createdAt || claim?.updated_at || claim?.submitted_at
|
||||
}
|
||||
if (stepLabel === '归档入账' || stepLabel === ARCHIVED_STEP_LABEL || stepLabel === '审批完成') {
|
||||
return claim?.updated_at || claim?.submitted_at
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}) {
|
||||
const documentTypeCode = String(options.documentTypeCode || '').trim()
|
||||
const hasApplicationReturnStep = (
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
&& Boolean(findLatestApplicationReturnEvent(claim))
|
||||
&& approvalMeta.key === 'supplement'
|
||||
)
|
||||
const hasMergedApplicationBudgetApproval = (
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
&& Boolean(findMergedApplicationBudgetApprovalEvent(claim))
|
||||
)
|
||||
const shouldShowApplicationBudgetStep = (
|
||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
&& !hasMergedApplicationBudgetApproval
|
||||
&& applicationRequiresBudgetReviewStep(claim, workflowNode)
|
||||
)
|
||||
const isApplicationDocument = documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||
const applicationArchived = isApplicationDocument && isApplicationArchivedWorkflow(claim, workflowNode)
|
||||
const progressLabels =
|
||||
isApplicationDocument
|
||||
? hasApplicationReturnStep
|
||||
? ['创建申请', '直属领导审批', '退回', '待提交']
|
||||
: hasMergedApplicationBudgetApproval
|
||||
? ['创建申请', '直属领导审批', APPLICATION_LINK_STATUS_STEP_LABEL, ARCHIVED_STEP_LABEL]
|
||||
: shouldShowApplicationBudgetStep
|
||||
? APPLICATION_PROGRESS_LABELS
|
||||
: APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET
|
||||
: REIMBURSEMENT_PROGRESS_LABELS
|
||||
const applicationLinkIndex = progressLabels.indexOf(APPLICATION_LINK_STATUS_STEP_LABEL)
|
||||
const applicationArchiveIndex = progressLabels.indexOf(ARCHIVED_STEP_LABEL)
|
||||
const currentIndex =
|
||||
isApplicationDocument
|
||||
? hasApplicationReturnStep
|
||||
? 3
|
||||
: applicationArchived && applicationArchiveIndex >= 0
|
||||
? applicationArchiveIndex
|
||||
: approvalMeta.key === 'completed' && applicationLinkIndex >= 0
|
||||
? applicationLinkIndex
|
||||
: Math.min(
|
||||
resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode),
|
||||
Math.max(0, progressLabels.length - 1)
|
||||
)
|
||||
: resolveProgressCurrentIndex(approvalMeta, workflowNode)
|
||||
const currentTime =
|
||||
approvalMeta.key === 'completed'
|
||||
? '已完成'
|
||||
: approvalMeta.key === 'pending_payment'
|
||||
? '待付款'
|
||||
: approvalMeta.key === 'supplement'
|
||||
? '待补充'
|
||||
: approvalMeta.key === 'rejected'
|
||||
? '已退回'
|
||||
: '进行中'
|
||||
|
||||
return progressLabels.map((label, index) => {
|
||||
const displayLabel = resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta)
|
||||
if (approvalMeta.key === 'completed' && (!isApplicationDocument || applicationArchived)) {
|
||||
const stepMeta = buildCompletedStepMeta(claim, label)
|
||||
return {
|
||||
index: index + 1,
|
||||
label: displayLabel,
|
||||
rawLabel: label,
|
||||
time: stepMeta.time,
|
||||
detail: stepMeta.detail,
|
||||
title: stepMeta.title,
|
||||
done: true,
|
||||
active: true,
|
||||
current: false
|
||||
}
|
||||
}
|
||||
|
||||
if (index < currentIndex) {
|
||||
const stepMeta = buildCompletedStepMeta(claim, label)
|
||||
return {
|
||||
index: index + 1,
|
||||
label: displayLabel,
|
||||
rawLabel: label,
|
||||
time: stepMeta.time,
|
||||
detail: stepMeta.detail,
|
||||
title: stepMeta.title,
|
||||
done: true,
|
||||
active: true,
|
||||
current: false
|
||||
}
|
||||
}
|
||||
|
||||
if (index === currentIndex) {
|
||||
if (isApplicationDocument && label === APPLICATION_LINK_STATUS_STEP_LABEL) {
|
||||
const stepMeta = buildApplicationLinkStatusStepMeta(claim)
|
||||
return {
|
||||
index: index + 1,
|
||||
label: displayLabel,
|
||||
rawLabel: label,
|
||||
time: stepMeta.time,
|
||||
detail: stepMeta.detail,
|
||||
title: stepMeta.title,
|
||||
done: false,
|
||||
active: true,
|
||||
current: true
|
||||
}
|
||||
}
|
||||
const stayDuration = formatDurationFrom(resolveCurrentStepStartedAt(claim, label))
|
||||
return {
|
||||
index: index + 1,
|
||||
label: displayLabel,
|
||||
rawLabel: label,
|
||||
time: stayDuration ? `停留 ${stayDuration}` : currentTime,
|
||||
detail: '',
|
||||
title: stayDuration ? `当前${displayLabel}已停留 ${stayDuration}` : currentTime,
|
||||
done: false,
|
||||
active: true,
|
||||
current: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
index: index + 1,
|
||||
label: displayLabel,
|
||||
rawLabel: label,
|
||||
time: '待处理',
|
||||
detail: '',
|
||||
title: '待处理',
|
||||
done: false,
|
||||
active: false,
|
||||
current: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
buildProgressSteps,
|
||||
isApplicationArchivedWorkflow,
|
||||
resolveApplicationLinkedReimbursementNo
|
||||
}
|
||||
227
web/src/composables/requests/requestRelatedApplication.js
Normal file
227
web/src/composables/requests/requestRelatedApplication.js
Normal file
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
formatAmount,
|
||||
formatDate,
|
||||
getLatestEvent,
|
||||
getRiskFlags,
|
||||
normalizeText,
|
||||
parseNumber
|
||||
} from './requestShared.js'
|
||||
|
||||
function normalizeApplicationHandoffDetail(flag = {}) {
|
||||
const detail = flag?.application_detail || flag?.applicationDetail || {}
|
||||
const reviewValues = flag?.review_form_values || flag?.reviewFormValues || {}
|
||||
const sceneSelection = flag?.expense_scene_selection || flag?.expenseSceneSelection || {}
|
||||
return [sceneSelection, reviewValues, detail]
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.reduce((acc, item) => ({ ...acc, ...item }), {})
|
||||
}
|
||||
|
||||
function resolveApplicationField(flag = {}, detail = {}, snakeKey, camelKey = '') {
|
||||
return normalizeText(
|
||||
flag?.[snakeKey]
|
||||
|| (camelKey ? flag?.[camelKey] : '')
|
||||
|| detail?.[snakeKey]
|
||||
|| (camelKey ? detail?.[camelKey] : '')
|
||||
)
|
||||
}
|
||||
|
||||
function resolveApplicationValue(flag = {}, detail = {}, keys = []) {
|
||||
for (const key of keys) {
|
||||
const detailValue = normalizeText(detail?.[key])
|
||||
if (detailValue) {
|
||||
return detailValue
|
||||
}
|
||||
const flagValue = normalizeText(flag?.[key])
|
||||
if (flagValue) {
|
||||
return flagValue
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function extractDateRange(value) {
|
||||
const dates = normalizeText(value).match(/\d{4}-\d{2}-\d{2}/g) || []
|
||||
if (!dates.length) {
|
||||
return { startDate: '', endDate: '' }
|
||||
}
|
||||
|
||||
return {
|
||||
startDate: dates[0],
|
||||
endDate: dates[dates.length - 1]
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRelatedApplicationClaimNo(flag = {}) {
|
||||
const detail = normalizeApplicationHandoffDetail(flag)
|
||||
return resolveApplicationField(flag, detail, 'application_claim_no', 'applicationClaimNo')
|
||||
}
|
||||
|
||||
function findRelatedApplicationEvent(claim) {
|
||||
const events = getRiskFlags(claim).filter((flag) => (
|
||||
flag
|
||||
&& typeof flag === 'object'
|
||||
&& resolveRelatedApplicationClaimNo(flag)
|
||||
))
|
||||
return getLatestEvent(events) || events[events.length - 1] || null
|
||||
}
|
||||
|
||||
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 relatedEvent = findRelatedApplicationEvent(claim)
|
||||
if (!relatedEvent) {
|
||||
return null
|
||||
}
|
||||
|
||||
const detail = normalizeApplicationHandoffDetail(relatedEvent)
|
||||
const claimNo = resolveRelatedApplicationClaimNo(relatedEvent)
|
||||
const applicationType = normalizeText(
|
||||
detail.application_type
|
||||
|| detail.applicationType
|
||||
|| relatedEvent.application_type
|
||||
|| relatedEvent.applicationType
|
||||
|| typeLabel
|
||||
)
|
||||
const location = normalizeText(
|
||||
detail.application_location
|
||||
|| detail.applicationLocation
|
||||
|| detail.location
|
||||
|| relatedEvent.application_location
|
||||
|| relatedEvent.applicationLocation
|
||||
|| claim?.location
|
||||
)
|
||||
const reason = normalizeText(
|
||||
detail.application_reason
|
||||
|| detail.applicationReason
|
||||
|| detail.reason
|
||||
|| relatedEvent.application_reason
|
||||
|| relatedEvent.applicationReason
|
||||
|| claim?.reason
|
||||
)
|
||||
const content = normalizeText(
|
||||
detail.application_content
|
||||
|| detail.applicationContent
|
||||
|| relatedEvent.application_content
|
||||
|| relatedEvent.applicationContent
|
||||
) || [applicationType, location].filter(Boolean).join(' / ')
|
||||
const rawTime = normalizeText(
|
||||
detail.application_time
|
||||
|| detail.applicationTime
|
||||
|| detail.application_business_time
|
||||
|| detail.applicationBusinessTime
|
||||
|| detail.business_time
|
||||
|| detail.businessTime
|
||||
|| detail.time_range
|
||||
|| detail.timeRange
|
||||
|| detail.time
|
||||
|| detail.application_date
|
||||
|| detail.applicationDate
|
||||
|| relatedEvent.application_time
|
||||
|| relatedEvent.applicationTime
|
||||
|| relatedEvent.application_business_time
|
||||
|| relatedEvent.applicationBusinessTime
|
||||
|| relatedEvent.business_time
|
||||
|| relatedEvent.businessTime
|
||||
|| relatedEvent.time_range
|
||||
|| relatedEvent.timeRange
|
||||
|| relatedEvent.application_date
|
||||
|| relatedEvent.applicationDate
|
||||
|| claim?.occurred_at
|
||||
)
|
||||
const displayTime = formatDate(rawTime) || rawTime
|
||||
const dateRange = extractDateRange(rawTime || displayTime)
|
||||
const ruleName = resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_rule_name',
|
||||
'applicationRuleName',
|
||||
'rule_name',
|
||||
'ruleName'
|
||||
])
|
||||
const ruleVersion = resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_rule_version',
|
||||
'applicationRuleVersion',
|
||||
'rule_version',
|
||||
'ruleVersion'
|
||||
])
|
||||
|
||||
return {
|
||||
id: resolveApplicationField(relatedEvent, detail, 'application_claim_id', 'applicationClaimId'),
|
||||
claimNo,
|
||||
content,
|
||||
reason,
|
||||
days: normalizeText(
|
||||
detail.application_days
|
||||
|| detail.applicationDays
|
||||
|| detail.days
|
||||
|| relatedEvent.application_days
|
||||
|| relatedEvent.applicationDays
|
||||
),
|
||||
location,
|
||||
time: displayTime,
|
||||
tripStartDate: dateRange.startDate,
|
||||
tripEndDate: dateRange.endDate,
|
||||
amountLabel: resolveRelatedApplicationAmountLabel(relatedEvent, detail, claim),
|
||||
statusLabel: resolveApplicationField(relatedEvent, detail, 'application_status_label', 'applicationStatusLabel'),
|
||||
transportMode: normalizeText(
|
||||
detail.application_transport_mode
|
||||
|| detail.applicationTransportMode
|
||||
|| detail.transport_mode
|
||||
|| relatedEvent.application_transport_mode
|
||||
|| relatedEvent.applicationTransportMode
|
||||
),
|
||||
lodgingDailyCap: resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_lodging_daily_cap',
|
||||
'applicationLodgingDailyCap',
|
||||
'lodging_daily_cap',
|
||||
'lodgingDailyCap'
|
||||
]),
|
||||
subsidyDailyCap: resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_subsidy_daily_cap',
|
||||
'applicationSubsidyDailyCap',
|
||||
'subsidy_daily_cap',
|
||||
'subsidyDailyCap'
|
||||
]),
|
||||
transportPolicy: resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_transport_policy',
|
||||
'applicationTransportPolicy',
|
||||
'transport_policy',
|
||||
'transportPolicy'
|
||||
]),
|
||||
policyEstimate: resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_policy_estimate',
|
||||
'applicationPolicyEstimate',
|
||||
'policy_estimate',
|
||||
'policyEstimate'
|
||||
]),
|
||||
ruleName,
|
||||
ruleVersion,
|
||||
ruleLabel: [ruleName, ruleVersion].filter(Boolean).join(' / ')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
findRelatedApplicationEvent,
|
||||
resolveRelatedApplicationInfo
|
||||
}
|
||||
426
web/src/composables/requests/requestShared.js
Normal file
426
web/src/composables/requests/requestShared.js
Normal file
@@ -0,0 +1,426 @@
|
||||
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
|
||||
import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../../utils/riskFlags.js'
|
||||
|
||||
const EXPENSE_TYPE_LABELS = {
|
||||
travel: '差旅费',
|
||||
travel_application: '差旅费用申请',
|
||||
expense_application: '费用申请',
|
||||
purchase_application: '采购费用申请',
|
||||
meeting_application: '会务费用申请',
|
||||
train_ticket: '火车票',
|
||||
flight_ticket: '机票',
|
||||
ship_ticket: '轮船票',
|
||||
ferry_ticket: '轮船票',
|
||||
hotel_ticket: '住宿票',
|
||||
ride_ticket: '乘车',
|
||||
travel_allowance: '出差补贴',
|
||||
entertainment: '业务招待费',
|
||||
marketing: '市场推广费',
|
||||
office: '办公用品费',
|
||||
meeting: '会务费',
|
||||
training: '培训费',
|
||||
software: '软件服务费',
|
||||
hotel: '住宿费',
|
||||
transport: '交通费',
|
||||
meal: '业务招待费',
|
||||
other: '其他费用'
|
||||
}
|
||||
|
||||
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||
'travel',
|
||||
'train_ticket',
|
||||
'flight_ticket',
|
||||
'hotel_ticket',
|
||||
'ride_ticket',
|
||||
'meeting',
|
||||
'entertainment'
|
||||
])
|
||||
|
||||
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
||||
const STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'
|
||||
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
|
||||
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
|
||||
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
|
||||
const DOCUMENT_BACKED_EXPENSE_TYPES = new Set([
|
||||
'train_ticket',
|
||||
'flight_ticket',
|
||||
'ship_ticket',
|
||||
'ferry_ticket',
|
||||
'hotel_ticket',
|
||||
'ride_ticket'
|
||||
])
|
||||
const DOCUMENT_TYPE_APPLICATION = 'application'
|
||||
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
||||
const RELATED_APPLICATION_STEP_LABEL = '关联单据'
|
||||
const APPLICATION_LINK_STATUS_STEP_LABEL = '关联单据状态'
|
||||
const APPLICATION_ARCHIVE_STAGE_LABEL = '申请归档'
|
||||
const ARCHIVED_STEP_LABEL = '已归档'
|
||||
|
||||
const REIMBURSEMENT_PROGRESS_LABELS = [
|
||||
RELATED_APPLICATION_STEP_LABEL,
|
||||
'待提交',
|
||||
'直属领导审批',
|
||||
'财务审批',
|
||||
'待付款',
|
||||
'已付款',
|
||||
ARCHIVED_STEP_LABEL
|
||||
]
|
||||
|
||||
const APPLICATION_PROGRESS_LABELS = [
|
||||
'创建申请',
|
||||
'直属领导审批',
|
||||
'预算管理者审批',
|
||||
APPLICATION_LINK_STATUS_STEP_LABEL,
|
||||
ARCHIVED_STEP_LABEL
|
||||
]
|
||||
|
||||
const APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET = [
|
||||
'创建申请',
|
||||
'直属领导审批',
|
||||
APPLICATION_LINK_STATUS_STEP_LABEL,
|
||||
ARCHIVED_STEP_LABEL
|
||||
]
|
||||
|
||||
function parseNumber(value) {
|
||||
const nextValue = Number(value)
|
||||
return Number.isFinite(nextValue) ? nextValue : 0
|
||||
}
|
||||
|
||||
function parseOptionalAmount(value) {
|
||||
if (value === null || value === undefined || String(value).trim() === '') {
|
||||
return null
|
||||
}
|
||||
const amount = Number(value)
|
||||
return Number.isFinite(amount) && amount >= 0 ? amount : null
|
||||
}
|
||||
|
||||
|
||||
function toDate(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextDate = new Date(value)
|
||||
return Number.isNaN(nextDate.getTime()) ? null : nextDate
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
const nextDate = toDate(value)
|
||||
if (!nextDate) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const year = nextDate.getFullYear()
|
||||
const month = String(nextDate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(nextDate.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
const nextDate = toDate(value)
|
||||
if (!nextDate) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const hours = String(nextDate.getHours()).padStart(2, '0')
|
||||
const minutes = String(nextDate.getMinutes()).padStart(2, '0')
|
||||
return `${formatDate(nextDate)} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function formatDurationFrom(value, now = Date.now()) {
|
||||
const startAt = toDate(value)
|
||||
if (!startAt) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const diffMs = Math.max(0, Number(now) - startAt.getTime())
|
||||
const totalMinutes = Math.floor(diffMs / (60 * 1000))
|
||||
if (totalMinutes < 1) {
|
||||
return '刚刚'
|
||||
}
|
||||
|
||||
const days = Math.floor(totalMinutes / (24 * 60))
|
||||
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
|
||||
if (days > 0) {
|
||||
return hours > 0 ? `${days}天${hours}小时` : `${days}天`
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
|
||||
}
|
||||
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
|
||||
function formatAmount(value) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: Number.isInteger(value) ? 0 : 2
|
||||
}).format(parseNumber(value))
|
||||
}
|
||||
|
||||
function resolveTypeLabel(typeCode) {
|
||||
return EXPENSE_TYPE_LABELS[String(typeCode || '').trim()] || EXPENSE_TYPE_LABELS.other
|
||||
}
|
||||
|
||||
function resolveDocumentTypeMeta(claim, typeCode) {
|
||||
const explicitType = String(
|
||||
claim?.document_type_code
|
||||
|| claim?.documentTypeCode
|
||||
|| claim?.document_type
|
||||
|| claim?.documentType
|
||||
|| ''
|
||||
).trim()
|
||||
const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase()
|
||||
const normalizedType = String(typeCode || '').trim()
|
||||
const isApplication =
|
||||
explicitType === DOCUMENT_TYPE_APPLICATION
|
||||
|| explicitType === 'expense_application'
|
||||
|| isApplicationDocumentNo(claimNo)
|
||||
|| normalizedType === 'application'
|
||||
|| normalizedType.endsWith('_application')
|
||||
|
||||
return isApplication
|
||||
? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: '申请单' }
|
||||
: { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: '报销单' }
|
||||
}
|
||||
|
||||
function normalizeExpenseType(typeCode) {
|
||||
return String(typeCode || '').trim() || 'other'
|
||||
}
|
||||
|
||||
function resolveApprovalMeta(status) {
|
||||
const normalized = String(status || '').trim().toLowerCase()
|
||||
|
||||
if (normalized === 'draft') {
|
||||
return { key: 'draft', label: '草稿', tone: 'draft' }
|
||||
}
|
||||
|
||||
if (normalized === 'returned') {
|
||||
return { key: 'supplement', label: '待提交', tone: 'warning' }
|
||||
}
|
||||
|
||||
if (normalized === 'supplement') {
|
||||
return { key: 'supplement', label: '待补充', tone: 'warning' }
|
||||
}
|
||||
|
||||
if (normalized === 'pending_payment') {
|
||||
return { key: 'pending_payment', label: '待付款', tone: 'warning' }
|
||||
}
|
||||
|
||||
if (normalized === 'paid') {
|
||||
return { key: 'completed', label: '已付款', tone: 'success' }
|
||||
}
|
||||
|
||||
if (['approved', 'completed', 'paid'].includes(normalized)) {
|
||||
return { key: 'completed', label: '已完成', tone: 'success' }
|
||||
}
|
||||
|
||||
if (['rejected', 'cancelled'].includes(normalized)) {
|
||||
return { key: 'rejected', label: '已退回', tone: 'danger' }
|
||||
}
|
||||
|
||||
return { key: 'in_progress', label: '审批中', tone: 'info' }
|
||||
}
|
||||
|
||||
function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false) {
|
||||
if (String(claim?.status || '').trim().toLowerCase() === 'returned') {
|
||||
return '待提交'
|
||||
}
|
||||
|
||||
const rawNode = String(claim?.approval_stage || '').trim()
|
||||
|
||||
if (rawNode) {
|
||||
if (
|
||||
isApplicationDocument
|
||||
&& approvalMeta.key === 'completed'
|
||||
&& (
|
||||
rawNode === '审批完成'
|
||||
|| rawNode.includes('审批完成')
|
||||
|| rawNode.includes('申请完成')
|
||||
)
|
||||
) {
|
||||
return APPLICATION_LINK_STATUS_STEP_LABEL
|
||||
}
|
||||
if (rawNode === '审批流转' || rawNode.includes('AI预审') || rawNode.includes('AI验审')) {
|
||||
return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? '待提交' : '直属领导审批'
|
||||
}
|
||||
if (rawNode === '待补充') {
|
||||
return '待提交'
|
||||
}
|
||||
return rawNode
|
||||
}
|
||||
|
||||
if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') {
|
||||
return '待提交'
|
||||
}
|
||||
|
||||
if (approvalMeta.key === 'pending_payment') {
|
||||
return '待付款'
|
||||
}
|
||||
|
||||
if (approvalMeta.key === 'completed') {
|
||||
const normalizedStatus = String(claim?.status || '').trim().toLowerCase()
|
||||
return isApplicationDocument ? APPLICATION_LINK_STATUS_STEP_LABEL : normalizedStatus === 'paid' ? '已付款' : '归档入账'
|
||||
}
|
||||
|
||||
return '直属领导审批'
|
||||
}
|
||||
|
||||
function stringifyRiskFlag(value) {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return ''
|
||||
}
|
||||
|
||||
for (const key of ['message', 'label', 'reason', 'name']) {
|
||||
const nextValue = String(value[key] || '').trim()
|
||||
if (nextValue) {
|
||||
return nextValue
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const RISK_TONE_LABELS = {
|
||||
high: '高风险',
|
||||
medium: '中风险',
|
||||
low: '低风险'
|
||||
}
|
||||
|
||||
function resolveHighestRiskTone(flags) {
|
||||
const tones = flags.map((item) => normalizeRiskFlagTone(item)).filter(Boolean)
|
||||
if (tones.includes('high')) {
|
||||
return 'high'
|
||||
}
|
||||
if (tones.includes('medium')) {
|
||||
return 'medium'
|
||||
}
|
||||
if (tones.includes('low')) {
|
||||
return 'low'
|
||||
}
|
||||
return 'low'
|
||||
}
|
||||
|
||||
function buildRiskMeta(riskFlags) {
|
||||
if (!Array.isArray(riskFlags) || !riskFlags.length) {
|
||||
return { summary: '无', tone: 'low', label: '无' }
|
||||
}
|
||||
|
||||
const actionableFlags = filterActionableRiskFlags(riskFlags)
|
||||
const items = actionableFlags.map((item) => stringifyRiskFlag(item)).filter(Boolean)
|
||||
if (!items.length) {
|
||||
return { summary: '无', tone: 'low', label: '无' }
|
||||
}
|
||||
|
||||
const tone = resolveHighestRiskTone(actionableFlags)
|
||||
return {
|
||||
summary: items.join(';'),
|
||||
tone,
|
||||
label: RISK_TONE_LABELS[tone] || '待关注'
|
||||
}
|
||||
}
|
||||
|
||||
function buildRiskSummary(riskFlags) {
|
||||
return buildRiskMeta(riskFlags).summary
|
||||
}
|
||||
|
||||
function buildOccurredDisplay(claim) {
|
||||
const itemDates = Array.isArray(claim?.items)
|
||||
? claim.items.map((item) => formatDate(item?.item_date)).filter(Boolean)
|
||||
: []
|
||||
|
||||
if (!itemDates.length) {
|
||||
return formatDate(claim?.occurred_at) || '待补充'
|
||||
}
|
||||
|
||||
const sortedDates = [...new Set(itemDates)].sort()
|
||||
if (sortedDates.length === 1) {
|
||||
return sortedDates[0]
|
||||
}
|
||||
|
||||
return `${sortedDates[0]} ~ ${sortedDates[sortedDates.length - 1]}`
|
||||
}
|
||||
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function isEmailLike(value) {
|
||||
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizeText(value))
|
||||
}
|
||||
|
||||
function resolveDisplayName(...values) {
|
||||
for (const value of values) {
|
||||
const normalized = normalizeText(value)
|
||||
if (normalized && !isEmailLike(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
function getRiskFlags(claim) {
|
||||
return Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : []
|
||||
}
|
||||
|
||||
function getLatestEvent(events) {
|
||||
const sortedEvents = events
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item) => ({ ...item, eventDate: toDate(item.created_at || item.createdAt) }))
|
||||
.filter((item) => item.eventDate)
|
||||
.sort((a, b) => a.eventDate.getTime() - b.eventDate.getTime())
|
||||
|
||||
return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null
|
||||
}
|
||||
|
||||
export {
|
||||
APPLICATION_ARCHIVE_STAGE_LABEL,
|
||||
APPLICATION_LINK_STATUS_STEP_LABEL,
|
||||
APPLICATION_PROGRESS_LABELS,
|
||||
APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET,
|
||||
ARCHIVED_STEP_LABEL,
|
||||
DOCUMENT_BACKED_EXPENSE_TYPES,
|
||||
DOCUMENT_TYPE_APPLICATION,
|
||||
DOCUMENT_TYPE_REIMBURSEMENT,
|
||||
EXPENSE_TYPE_LABELS,
|
||||
HOTEL_DESCRIPTION_EXPENSE_TYPES,
|
||||
LOCATION_REQUIRED_EXPENSE_TYPES,
|
||||
LONG_DISTANCE_TRAVEL_EXPENSE_TYPES,
|
||||
RELATED_APPLICATION_STEP_LABEL,
|
||||
REIMBURSEMENT_PROGRESS_LABELS,
|
||||
ROUTE_DESCRIPTION_EXPENSE_TYPES,
|
||||
STANDARD_ADJUSTMENT_RISK_SOURCE,
|
||||
SYSTEM_GENERATED_EXPENSE_TYPES,
|
||||
buildOccurredDisplay,
|
||||
buildRiskMeta,
|
||||
buildRiskSummary,
|
||||
formatAmount,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatDurationFrom,
|
||||
getLatestEvent,
|
||||
getRiskFlags,
|
||||
isEmailLike,
|
||||
normalizeExpenseType,
|
||||
normalizeText,
|
||||
parseNumber,
|
||||
parseOptionalAmount,
|
||||
resolveApprovalMeta,
|
||||
resolveDisplayName,
|
||||
resolveDocumentTypeMeta,
|
||||
resolveTypeLabel,
|
||||
resolveWorkflowNode,
|
||||
toDate
|
||||
}
|
||||
@@ -29,6 +29,12 @@ import { createCurrentYearDateRange } from '../utils/dateRangeDefaults.js'
|
||||
const SESSION_TYPE_EXPENSE = 'expense'
|
||||
const SMART_ENTRY_SOURCE_APPLICATION = 'application'
|
||||
const SMART_ENTRY_SOURCE_REIMBURSEMENT = 'topbar'
|
||||
const DOCUMENT_DETAIL_RETURN_TARGETS = new Set(['workbench', 'conversation'])
|
||||
|
||||
function resolveDocumentDetailReturnTarget(value) {
|
||||
const target = String(value || '').trim()
|
||||
return DOCUMENT_DETAIL_RETURN_TARGETS.has(target) ? target : ''
|
||||
}
|
||||
|
||||
export function useAppShell() {
|
||||
const route = useRoute()
|
||||
@@ -99,10 +105,13 @@ export function useAppShell() {
|
||||
})
|
||||
|
||||
const detailMode = computed(() => route.name === 'app-document-detail')
|
||||
const detailReturnTarget = computed(() => String(route.query.returnTo || '').trim())
|
||||
const detailBackLabel = computed(() => (
|
||||
detailReturnTarget.value === 'workbench' ? '返回首页' : '返回单据中心'
|
||||
))
|
||||
const detailReturnTarget = computed(() => resolveDocumentDetailReturnTarget(route.query.returnTo))
|
||||
const detailBackLabel = computed(() => {
|
||||
if (detailReturnTarget.value === 'conversation') {
|
||||
return '返回对话'
|
||||
}
|
||||
return detailReturnTarget.value === 'workbench' ? '返回首页' : '返回单据中心'
|
||||
})
|
||||
const detailAlerts = computed(() => (
|
||||
detailMode.value
|
||||
? buildDetailAlerts(selectedRequest.value, { currentUser: currentUser.value })
|
||||
@@ -553,9 +562,9 @@ export function useAppShell() {
|
||||
|
||||
function buildDocumentDetailQuery(options = {}) {
|
||||
const nextQuery = { ...route.query }
|
||||
const returnTo = String(options.returnTo || '').trim()
|
||||
if (returnTo === 'workbench') {
|
||||
nextQuery.returnTo = 'workbench'
|
||||
const returnTo = resolveDocumentDetailReturnTarget(options.returnTo)
|
||||
if (returnTo) {
|
||||
nextQuery.returnTo = returnTo
|
||||
} else {
|
||||
delete nextQuery.returnTo
|
||||
}
|
||||
@@ -583,12 +592,15 @@ export function useAppShell() {
|
||||
}
|
||||
|
||||
function closeRequestDetail() {
|
||||
if (detailReturnTarget.value === 'workbench') {
|
||||
router.push({ name: 'app-workbench' })
|
||||
return
|
||||
if (detailReturnTarget.value === 'conversation') {
|
||||
return router.push({ name: 'app-workbench' })
|
||||
}
|
||||
|
||||
router.push({ name: 'app-documents', query: buildDocumentReturnQuery() })
|
||||
if (detailReturnTarget.value === 'workbench') {
|
||||
return router.push({ name: 'app-workbench' })
|
||||
}
|
||||
|
||||
return router.push({ name: 'app-documents', query: buildDocumentReturnQuery() })
|
||||
}
|
||||
|
||||
async function handleRequestUpdated(payload = {}) {
|
||||
@@ -655,6 +667,7 @@ export function useAppShell() {
|
||||
smartEntrySessionId,
|
||||
detailAlerts,
|
||||
detailBackLabel,
|
||||
detailReturnTarget,
|
||||
toast,
|
||||
topBarView
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
fetchSystemDashboard
|
||||
} from '../services/analytics.js'
|
||||
import { fetchRiskObservationDashboard } from '../services/riskObservations.js'
|
||||
import { formatRiskSignalLabel } from '../utils/riskLabels.js'
|
||||
import {
|
||||
buildDigitalEmployeeCategoryRows,
|
||||
buildDigitalEmployeeDailyRows,
|
||||
@@ -14,6 +13,32 @@ import {
|
||||
buildDigitalEmployeeTaskRanking,
|
||||
emptyDigitalEmployeeDashboard
|
||||
} from '../views/scripts/overviewDigitalEmployeeDashboardModel.js'
|
||||
import {
|
||||
buildRiskDistributionLegend,
|
||||
emptyFinanceBudgetMetrics,
|
||||
emptyFinanceBudgetSummary,
|
||||
emptyFinanceDonut,
|
||||
emptyFinanceTotals,
|
||||
emptyFinanceTrend,
|
||||
emptySystemDashboardTotals,
|
||||
emptySystemLoginWave,
|
||||
formatCompact,
|
||||
formatCurrency,
|
||||
formatMetricValue,
|
||||
formatNumberCompact,
|
||||
formatPercent,
|
||||
formatRiskSignalName,
|
||||
formatSystemMetricValue as formatSystemMetricValueModel,
|
||||
isMissingDimension,
|
||||
resolveFinanceMetricMeta as resolveFinanceMetricMetaModel,
|
||||
resolveSystemMetricMeta as resolveSystemMetricMetaModel
|
||||
} from './overviewViewDisplayModel.js'
|
||||
import {
|
||||
aggregateRiskDailyTrendRows,
|
||||
DEFAULT_OVERVIEW_RANGE,
|
||||
resolveTopRangeDays,
|
||||
resolveTopRangeKey
|
||||
} from './overviewViewRangeModel.js'
|
||||
|
||||
import {
|
||||
metricBlueprints,
|
||||
@@ -33,186 +58,6 @@ import {
|
||||
systemToolDetailRows as fallbackSystemToolDetailRows
|
||||
} from '../data/metrics.js'
|
||||
|
||||
const DEFAULT_OVERVIEW_RANGE = '近10日'
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const RISK_DAILY_TREND_MAX_BUCKETS = 14
|
||||
|
||||
const emptyFinanceTotals = {
|
||||
reimbursementAmount: 0,
|
||||
reimbursementCount: 0,
|
||||
pendingPaymentAmount: 0,
|
||||
avgClaimAmount: 0,
|
||||
budgetUsageRate: 0,
|
||||
paymentClearanceRate: 0
|
||||
}
|
||||
|
||||
const emptyFinanceTrend = {
|
||||
labels: [],
|
||||
claimCount: [],
|
||||
claimAmount: [],
|
||||
categoryAmountSeries: [],
|
||||
applications: [],
|
||||
approved: [],
|
||||
avgHours: []
|
||||
}
|
||||
|
||||
const emptyFinanceDonut = [
|
||||
{ name: '暂无数据', value: 0, color: '#cbd5e1' }
|
||||
]
|
||||
|
||||
const emptyFinanceBudgetSummary = {
|
||||
ratio: 0,
|
||||
total: '¥0',
|
||||
used: '¥0',
|
||||
left: '¥0'
|
||||
}
|
||||
|
||||
const emptyFinanceBudgetMetrics = [
|
||||
{ label: '预算池数量', value: '0 个', detail: '年度有效预算池', tone: 'neutral', icon: 'mdi mdi-database-outline' },
|
||||
{ label: '总预算', value: '¥0', detail: '原始预算 + 调整', tone: 'neutral', icon: 'mdi mdi-cash-register' },
|
||||
{ label: '已用预算', value: '¥0', detail: '使用率 0.0%', tone: 'success', icon: 'mdi mdi-chart-arc' },
|
||||
{ label: '预占预算', value: '¥0', detail: '待流转单据占用', tone: 'success', icon: 'mdi mdi-lock-outline' },
|
||||
{ label: '可用预算', value: '¥0', detail: '可继续使用额度', tone: 'success', icon: 'mdi mdi-wallet-outline' },
|
||||
{ label: '预警预算池', value: '0 个', detail: '超支 0 个', tone: 'success', icon: 'mdi mdi-alert-outline' }
|
||||
]
|
||||
|
||||
const emptySystemDashboardTotals = {
|
||||
toolCalls: 0,
|
||||
modelTokens: 0,
|
||||
onlineUsers: 0,
|
||||
avgOnlineMinutes: 0,
|
||||
executionSuccessRate: 0,
|
||||
positiveFeedback: 0,
|
||||
negativeFeedback: 0,
|
||||
failedRuns: 0,
|
||||
toolCallsChange: 0,
|
||||
modelTokensChange: 0
|
||||
}
|
||||
|
||||
const emptySystemLoginWave = {
|
||||
labels: Array.from({ length: 24 }, (_, hour) => `${String(hour).padStart(2, '0')}:00`),
|
||||
loginUsers: Array.from({ length: 24 }, () => 0),
|
||||
interactions: Array.from({ length: 24 }, () => 0)
|
||||
}
|
||||
|
||||
function parseLocalDate(value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || '').trim())
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function clampWindowDays(value) {
|
||||
const days = Number(value || 0)
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
return 10
|
||||
}
|
||||
return Math.max(1, Math.min(Math.round(days), 90))
|
||||
}
|
||||
|
||||
function resolveCustomRangeDays(customRange = {}) {
|
||||
const start = parseLocalDate(customRange.start)
|
||||
const end = parseLocalDate(customRange.end)
|
||||
if (!start || !end) {
|
||||
return 10
|
||||
}
|
||||
return clampWindowDays(Math.abs(end.getTime() - start.getTime()) / DAY_MS + 1)
|
||||
}
|
||||
|
||||
function resolveTopRangeDays(range, customRange = {}) {
|
||||
const key = String(range || DEFAULT_OVERVIEW_RANGE).trim()
|
||||
if (key === 'custom') {
|
||||
return resolveCustomRangeDays(customRange)
|
||||
}
|
||||
if (key === '\u4eca\u65e5') {
|
||||
return 1
|
||||
}
|
||||
if (key === '\u672c\u5468') {
|
||||
const today = new Date()
|
||||
const weekday = today.getDay() || 7
|
||||
return clampWindowDays(weekday)
|
||||
}
|
||||
if (key === '\u672c\u6708') {
|
||||
return clampWindowDays(new Date().getDate())
|
||||
}
|
||||
const match = key.match(/\d+/)
|
||||
return clampWindowDays(match ? Number(match[0]) : 10)
|
||||
}
|
||||
|
||||
function resolveTopRangeKey(range, customRange = {}) {
|
||||
const key = String(range || DEFAULT_OVERVIEW_RANGE).trim()
|
||||
if (key === 'custom') {
|
||||
return 'custom'
|
||||
}
|
||||
if (key === '\u672c\u5468' || key === '\u4eca\u65e5') {
|
||||
return `recent-${resolveTopRangeDays(key, customRange)}-days`
|
||||
}
|
||||
if (/\d+/.test(key)) {
|
||||
return `recent-${resolveTopRangeDays(key, customRange)}-days`
|
||||
}
|
||||
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()
|
||||
@@ -254,44 +99,9 @@ export function useOverviewView(options = {}) {
|
||||
const digitalEmployeeDashboardError = ref(null)
|
||||
const digitalEmployeeDashboardLoaded = computed(() => Boolean(digitalEmployeeDashboardPayload.value))
|
||||
|
||||
const formatCompact = (value) => {
|
||||
if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
|
||||
if (value >= 1_000) return `¥${(value / 1_000).toFixed(1)}K`
|
||||
return `¥${value}`
|
||||
}
|
||||
|
||||
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 (['reimbursementAmount', 'pendingPaymentAmount', 'avgClaimAmount'].includes(metric.key)) {
|
||||
return formatCurrency(Math.round(value))
|
||||
}
|
||||
if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
|
||||
if (metric.unit) return `${Math.round(value)} ${metric.unit}`
|
||||
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 formatSystemMetricValue = (metric, value) => (
|
||||
formatSystemMetricValueModel(metric, value, systemDashboardTotals.value)
|
||||
)
|
||||
|
||||
const getFinanceRangeParams = () => {
|
||||
const activeRange = activeRangeValue.value
|
||||
@@ -556,68 +366,17 @@ export function useOverviewView(options = {}) {
|
||||
financeDashboardPayload.value?.budgetMetrics || emptyFinanceBudgetMetrics
|
||||
))
|
||||
|
||||
const resolveSystemMetricMeta = (metric) => {
|
||||
const totals = systemDashboardTotals.value
|
||||
const realDashboardLoaded = Boolean(systemDashboardPayload.value)
|
||||
const resolveSystemMetricMeta = (metric) => (
|
||||
resolveSystemMetricMetaModel(metric, systemDashboardTotals.value, 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: financeDashboardLoading.value ? '加载中' : '实时',
|
||||
delta: financeDashboardError.value ? '真实数据加载失败' : '等待真实数据',
|
||||
trend: metric.trend
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changeText: meta.changeText || metric.change,
|
||||
delta: meta.delta || metric.delta,
|
||||
trend: meta.trend || metric.trend
|
||||
}
|
||||
}
|
||||
const resolveFinanceMetricMeta = (metric) => resolveFinanceMetricMetaModel({
|
||||
metric,
|
||||
meta: financeMetricMeta.value[metric.key],
|
||||
dashboardLoaded: Boolean(financeDashboardPayload.value),
|
||||
loading: financeDashboardLoading.value,
|
||||
error: financeDashboardError.value
|
||||
})
|
||||
|
||||
const kpiMetrics = computed(() => metricBlueprints.map((metric, index) => {
|
||||
const rawValue = Number(financeDashboardTotals.value[metric.key] || 0)
|
||||
@@ -919,39 +678,6 @@ export function useOverviewView(options = {}) {
|
||||
const digitalEmployeeTaskRanking = computed(() => buildDigitalEmployeeTaskRanking(digitalEmployeeDashboard.value))
|
||||
const digitalEmployeeCategoryRows = computed(() => buildDigitalEmployeeCategoryRows(digitalEmployeeDashboard.value))
|
||||
|
||||
function buildRiskDistributionLegend(distribution, labels, colors, formatter = formatRiskSignalName) {
|
||||
const fallbackColors = ['#ef4444', '#f59e0b', 'var(--theme-primary)', '#3b82f6', '#8b5cf6', '#0f766e']
|
||||
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], index) => ({
|
||||
name: labels[key] || formatter(key),
|
||||
value: Number(value || 0),
|
||||
display: `${Number(value || 0)}项`,
|
||||
color: colors[key] || fallbackColors[index % fallbackColors.length]
|
||||
}))
|
||||
}
|
||||
|
||||
function formatRiskSignalName(value) {
|
||||
return formatRiskSignalLabel(value)
|
||||
}
|
||||
|
||||
function isMissingDimension(value) {
|
||||
const text = String(value || '').trim()
|
||||
return !text || ['待补充', '待确认', '未归属部门', '未归属', 'N/A', 'n/a', '-'].includes(text)
|
||||
}
|
||||
|
||||
const bottlenecks = financeBottlenecks
|
||||
const budgetSummary = financeBudgetSummary
|
||||
const budgetMetrics = financeBudgetMetrics
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,787 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useSystemState } from '../useSystemState.js'
|
||||
import { useToast } from '../useToast.js'
|
||||
import { useWorkbenchComposerDate } from '../useWorkbenchComposerDate.js'
|
||||
import { fetchSettings } from '../../services/settings.js'
|
||||
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
|
||||
import { useApplicationPreviewEditor } from '../../views/scripts/useApplicationPreviewEditor.js'
|
||||
import {
|
||||
deleteAiWorkbenchConversation,
|
||||
loadAiWorkbenchConversationHistory,
|
||||
saveAiWorkbenchConversation
|
||||
} from '../../utils/aiWorkbenchConversationStore.js'
|
||||
import { renderAiConversationHtml } from '../../utils/aiConversationHtmlRenderer.js'
|
||||
import {
|
||||
buildAiDocumentDetailRequest,
|
||||
parseAiApplicationDetailHref,
|
||||
parseAiDocumentDetailHref
|
||||
} from '../../utils/aiDocumentDetailReference.js'
|
||||
import {
|
||||
AI_MODE_ACTION_ITEMS,
|
||||
buildSelectedFileCards,
|
||||
shouldRunAiAttachmentAutoAssociation
|
||||
} from './workbenchAiComposerModel.js'
|
||||
import {
|
||||
createWorkbenchAiMessageRuntime,
|
||||
formatMessageTime,
|
||||
normalizeInlineAttachmentOcrDetails
|
||||
} from './workbenchAiMessageModel.js'
|
||||
import { useWorkbenchAiActionRouter } from './useWorkbenchAiActionRouter.js'
|
||||
import { useWorkbenchAiAttachmentAssociationFlow } from './useWorkbenchAiAttachmentAssociationFlow.js'
|
||||
import { useWorkbenchAiApplicationPreviewFlow } from './useWorkbenchAiApplicationPreviewFlow.js'
|
||||
import { useWorkbenchAiComposerFiles } from './useWorkbenchAiComposerFiles.js'
|
||||
import { useWorkbenchAiDocumentQueryFlow } from './useWorkbenchAiDocumentQueryFlow.js'
|
||||
import { useWorkbenchAiExpenseFlow } from './useWorkbenchAiExpenseFlow.js'
|
||||
import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js'
|
||||
import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
|
||||
import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js'
|
||||
|
||||
const AI_SEARCH_CONVERSATION_ID = 'ai-search'
|
||||
const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6
|
||||
const INLINE_ANSWER_STREAM_DELAY_MS = 24
|
||||
const INLINE_AUTO_SCROLL_THRESHOLD = 96
|
||||
const INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS = 260
|
||||
|
||||
export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
const assistantDraft = ref('')
|
||||
const assistantInputRef = ref(null)
|
||||
const fileInputRef = ref(null)
|
||||
const conversationScrollRef = ref(null)
|
||||
const inlineConversationAutoScrollPinned = ref(true)
|
||||
const selectedFiles = ref([])
|
||||
const systemSettings = ref(null)
|
||||
const conversationStarted = ref(false)
|
||||
const conversationMessages = ref([])
|
||||
const conversationId = ref('')
|
||||
const activeConversationTitle = ref('')
|
||||
const sending = ref(false)
|
||||
const stewardState = ref(null)
|
||||
const aiExpenseDraft = ref(null)
|
||||
const thinkingExpandedMessageIds = ref(new Set())
|
||||
const thinkingCollapsedMessageIds = ref(new Set())
|
||||
const attachmentOcrExpandedMessageIds = ref(new Set())
|
||||
const deleteDialogOpen = ref(false)
|
||||
const applicationSubmitConfirmOpen = ref(false)
|
||||
const applicationSubmitConfirmContext = ref(null)
|
||||
const aiAttachmentAssociationRuntime = new Map()
|
||||
const messageRuntime = createWorkbenchAiMessageRuntime()
|
||||
const {
|
||||
createAiAttachmentAssociationId,
|
||||
createInlineMessage,
|
||||
normalizeRuntimeMessage,
|
||||
serializeRuntimeMessage
|
||||
} = messageRuntime
|
||||
|
||||
const {
|
||||
applicationPreviewEditor,
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
refreshApplicationPreviewEstimate,
|
||||
isApplicationPreviewEditing,
|
||||
openApplicationPreviewEditor,
|
||||
commitApplicationPreviewEditor,
|
||||
cancelApplicationPreviewEditor,
|
||||
handleApplicationPreviewEditorKeydown
|
||||
} = useApplicationPreviewEditor({
|
||||
persistSessionState: () => persistCurrentConversation(),
|
||||
toast,
|
||||
calculateTravelReimbursement,
|
||||
currentUser
|
||||
})
|
||||
|
||||
const {
|
||||
workbenchDatePickerOpen,
|
||||
workbenchDateMode,
|
||||
workbenchSingleDate,
|
||||
workbenchRangeStartDate,
|
||||
workbenchRangeEndDate,
|
||||
workbenchDateTagLabel,
|
||||
workbenchCanApplyDateSelection,
|
||||
clearWorkbenchDateSelection,
|
||||
toggleWorkbenchDatePicker,
|
||||
closeWorkbenchDatePicker,
|
||||
setWorkbenchDateMode,
|
||||
handleWorkbenchDatePickerOutside,
|
||||
applyWorkbenchDateSelection,
|
||||
handleWorkbenchDateInputChange,
|
||||
removeWorkbenchDateTag,
|
||||
buildWorkbenchPromptText
|
||||
} = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: focusAiModeInput })
|
||||
|
||||
const aiModeActionItems = AI_MODE_ACTION_ITEMS
|
||||
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value))
|
||||
const displayUserName = computed(() => {
|
||||
const user = currentUser.value || {}
|
||||
return String(user.name || user.username || '同事').trim() || '同事'
|
||||
})
|
||||
const displayModelName = computed(() => {
|
||||
const llmForm = systemSettings.value?.llmForm
|
||||
if (!llmForm) return 'Axiom Ultra 3.1'
|
||||
const model = llmForm.mainModel || ''
|
||||
const provider = llmForm.mainProvider || ''
|
||||
if (!model) return 'Axiom Ultra 3.1'
|
||||
return provider ? `${provider} / ${model}` : model
|
||||
})
|
||||
const modelSelectorTitle = computed(() => {
|
||||
const llmForm = systemSettings.value?.llmForm
|
||||
if (!llmForm) return '当前模型:Axiom Ultra 3.1'
|
||||
const model = llmForm.mainModel || 'Axiom Ultra 3.1'
|
||||
const provider = llmForm.mainProvider || ''
|
||||
return provider ? `当前模型:${provider} / ${model}` : `当前模型:${model}`
|
||||
})
|
||||
|
||||
const filesFlow = useWorkbenchAiComposerFiles({
|
||||
fileInputRef,
|
||||
focusAiModeInput,
|
||||
isInputLocked: () => isAiModeInputLocked.value,
|
||||
selectedFiles,
|
||||
toast
|
||||
})
|
||||
|
||||
const documentQueryFlow = useWorkbenchAiDocumentQueryFlow({
|
||||
conversationMessages,
|
||||
createInlineMessage,
|
||||
inlineConversationAutoScrollPinned,
|
||||
persistCurrentConversation,
|
||||
replaceInlineMessage,
|
||||
scrollInlineConversationToBottom
|
||||
})
|
||||
|
||||
const attachmentFlow = useWorkbenchAiAttachmentAssociationFlow({
|
||||
aiAttachmentAssociationRuntime,
|
||||
conversationMessages,
|
||||
createAiAttachmentAssociationId,
|
||||
createInlineMessage,
|
||||
inlineConversationAutoScrollPinned,
|
||||
persistCurrentConversation,
|
||||
replaceInlineMessage,
|
||||
scrollInlineConversationToBottom,
|
||||
sending,
|
||||
streamOrSetInlineAssistantContent,
|
||||
toast
|
||||
})
|
||||
|
||||
const applicationFlow = useWorkbenchAiApplicationPreviewFlow({
|
||||
activateInlineConversation,
|
||||
applicationPreviewEditor,
|
||||
applicationSubmitConfirmContext,
|
||||
applicationSubmitConfirmOpen,
|
||||
assistantDraft,
|
||||
cancelApplicationPreviewEditor,
|
||||
clearAiModeFiles: filesFlow.clearAiModeFiles,
|
||||
closeWorkbenchDatePicker,
|
||||
commitApplicationPreviewEditor,
|
||||
conversationId,
|
||||
conversationMessages,
|
||||
conversationStarted,
|
||||
createInlineMessage,
|
||||
currentUser,
|
||||
handleApplicationPreviewEditorKeydown,
|
||||
inlineConversationAutoScrollPinned,
|
||||
isApplicationPreviewEditing,
|
||||
openApplicationPreviewEditor,
|
||||
persistCurrentConversation,
|
||||
pushInlineApplicationActionUserMessage,
|
||||
pushInlineUserMessage,
|
||||
refreshApplicationPreviewEstimate,
|
||||
removeWorkbenchDateTag,
|
||||
replaceInlineMessage,
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
resolveInlineThinkingEvents,
|
||||
resolveLatestInlineUserPrompt,
|
||||
scrollInlineConversationToBottom,
|
||||
sending,
|
||||
toast
|
||||
})
|
||||
|
||||
const expenseFlow = useWorkbenchAiExpenseFlow({
|
||||
activateInlineConversation,
|
||||
aiExpenseDraft,
|
||||
assistantDraft,
|
||||
clearAiModeFiles: filesFlow.clearAiModeFiles,
|
||||
closeWorkbenchDatePicker,
|
||||
conversationMessages,
|
||||
conversationStarted,
|
||||
createInlineMessage,
|
||||
currentUser,
|
||||
persistCurrentConversation,
|
||||
pushInlineUserMessage,
|
||||
removeWorkbenchDateTag,
|
||||
resolveLatestInlineUserPrompt,
|
||||
scrollInlineConversationToBottom,
|
||||
startAiApplicationPreview: applicationFlow.startAiApplicationPreview
|
||||
})
|
||||
|
||||
const actionRouter = useWorkbenchAiActionRouter({
|
||||
aiExpenseDraft,
|
||||
applicationFlow,
|
||||
assistantDraft,
|
||||
attachmentFlow,
|
||||
emit,
|
||||
expenseFlow,
|
||||
focusAiModeInput,
|
||||
hasInlineAttachmentOcrDetails,
|
||||
resolveLatestInlineUserPrompt,
|
||||
selectedFiles,
|
||||
startInlineConversation,
|
||||
toast,
|
||||
toggleInlineAttachmentOcrDetails
|
||||
})
|
||||
|
||||
const sessionCommands = useWorkbenchAiSessionCommands({
|
||||
activeConversationTitle,
|
||||
attachmentOcrExpandedMessageIds,
|
||||
conversationId,
|
||||
conversationMessages,
|
||||
conversationStarted,
|
||||
createInlineMessage,
|
||||
currentUser,
|
||||
deleteAiWorkbenchConversation,
|
||||
emit,
|
||||
focusAiModeInput,
|
||||
inlineConversationAutoScrollPinned,
|
||||
normalizeRuntimeMessage,
|
||||
refreshConversationHistory,
|
||||
resetInlineConversationState,
|
||||
scrollInlineConversationToBottom,
|
||||
stewardState,
|
||||
thinkingCollapsedMessageIds,
|
||||
thinkingExpandedMessageIds,
|
||||
toast
|
||||
})
|
||||
|
||||
const messageActions = useWorkbenchAiMessageActions({
|
||||
assistantDraft,
|
||||
focusAiModeInput,
|
||||
persistCurrentConversation,
|
||||
toast
|
||||
})
|
||||
|
||||
const stewardFlow = useWorkbenchAiStewardFlow({
|
||||
activeConversationTitle,
|
||||
collectAiModeReceiptContext: attachmentFlow.collectAiModeReceiptContext,
|
||||
conversationId,
|
||||
conversationMessages,
|
||||
createInlineMessage,
|
||||
currentUser,
|
||||
deleteAiWorkbenchConversation,
|
||||
emit,
|
||||
handleAiDocumentQueryIntent: documentQueryFlow.handleAiDocumentQueryIntent,
|
||||
inlineConversationAutoScrollPinned,
|
||||
persistCurrentConversation,
|
||||
replaceInlineMessage,
|
||||
resolveInlineThinkingEvents,
|
||||
scrollInlineConversationToBottom,
|
||||
sending,
|
||||
stewardState,
|
||||
streamInlineAssistantContent,
|
||||
updateInlineMessageContent,
|
||||
appendInlineMessageContent,
|
||||
toast
|
||||
})
|
||||
|
||||
const applicationPreviewEstimatePending = computed(() => (
|
||||
conversationMessages.value.some((message) => applicationFlow.isApplicationPreviewEstimatePending(message))
|
||||
))
|
||||
const isAiModeInputLocked = computed(() => applicationPreviewEstimatePending.value)
|
||||
const canSubmitAiModePrompt = computed(() => (
|
||||
!isAiModeInputLocked.value && (
|
||||
Boolean(assistantDraft.value.trim()) ||
|
||||
selectedFiles.value.length > 0 ||
|
||||
Boolean(workbenchDateTagLabel.value)
|
||||
)
|
||||
))
|
||||
|
||||
async function loadSystemSettings() {
|
||||
try {
|
||||
systemSettings.value = await fetchSettings()
|
||||
} catch {
|
||||
systemSettings.value = { llmForm: {} }
|
||||
}
|
||||
}
|
||||
|
||||
function focusAiModeInput() {
|
||||
nextTick(() => {
|
||||
assistantInputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function isInlineConversationNearBottom() {
|
||||
const el = conversationScrollRef.value
|
||||
if (!el) {
|
||||
return true
|
||||
}
|
||||
return el.scrollHeight - el.clientHeight - el.scrollTop <= INLINE_AUTO_SCROLL_THRESHOLD
|
||||
}
|
||||
|
||||
function handleInlineConversationScroll() {
|
||||
inlineConversationAutoScrollPinned.value = isInlineConversationNearBottom()
|
||||
}
|
||||
|
||||
function forceInlineConversationToBottom() {
|
||||
const el = conversationScrollRef.value
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
inlineConversationAutoScrollPinned.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function scrollInlineConversationToBottom(options = {}) {
|
||||
const shouldScroll = options.force !== false
|
||||
nextTick(() => {
|
||||
if (!shouldScroll) {
|
||||
return
|
||||
}
|
||||
forceInlineConversationToBottom()
|
||||
window.requestAnimationFrame(() => {
|
||||
forceInlineConversationToBottom()
|
||||
})
|
||||
window.setTimeout(() => {
|
||||
if (inlineConversationAutoScrollPinned.value) {
|
||||
forceInlineConversationToBottom()
|
||||
}
|
||||
}, INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS)
|
||||
})
|
||||
}
|
||||
|
||||
function scrollInlineConversationToTop() {
|
||||
nextTick(() => {
|
||||
const el = conversationScrollRef.value
|
||||
if (el) {
|
||||
inlineConversationAutoScrollPinned.value = false
|
||||
el.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateInlineMessageContent(message, content) {
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
message.content = String(content || '')
|
||||
message.paragraphs = String(message.content || '')
|
||||
.split(/\n{2,}|\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function appendInlineMessageContent(message, delta) {
|
||||
const nextDelta = String(delta || '')
|
||||
if (!nextDelta) {
|
||||
return
|
||||
}
|
||||
updateInlineMessageContent(message, `${message.content || ''}${nextDelta}`)
|
||||
}
|
||||
|
||||
function waitInlineAnswerStreamFrame() {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, INLINE_ANSWER_STREAM_DELAY_MS)
|
||||
})
|
||||
}
|
||||
|
||||
async function streamInlineAssistantContent(messageId, content) {
|
||||
const targetContent = String(content || '').trim()
|
||||
let streamedContent = ''
|
||||
|
||||
for (let index = 0; index < targetContent.length; index += INLINE_ANSWER_STREAM_CHUNK_SIZE) {
|
||||
const message = conversationMessages.value.find((item) => item.id === messageId)
|
||||
if (!message || !message.pending) {
|
||||
return
|
||||
}
|
||||
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
|
||||
streamedContent += targetContent.slice(index, index + INLINE_ANSWER_STREAM_CHUNK_SIZE)
|
||||
updateInlineMessageContent(message, streamedContent)
|
||||
scrollInlineConversationToBottom({ force: shouldAutoScroll })
|
||||
await waitInlineAnswerStreamFrame()
|
||||
}
|
||||
}
|
||||
|
||||
async function streamOrSetInlineAssistantContent(messageId, content) {
|
||||
const targetContent = String(content || '').trim()
|
||||
if (/<!--\s*ai-trusted-html:start\s*-->/.test(targetContent)) {
|
||||
const message = conversationMessages.value.find((item) => item.id === messageId)
|
||||
if (message?.pending) {
|
||||
updateInlineMessageContent(message, targetContent)
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
}
|
||||
return
|
||||
}
|
||||
await streamInlineAssistantContent(messageId, targetContent)
|
||||
}
|
||||
|
||||
function refreshConversationHistory() {
|
||||
const history = loadAiWorkbenchConversationHistory(currentUser.value || {})
|
||||
emit('conversation-history-change', history)
|
||||
return history
|
||||
}
|
||||
|
||||
function isPersistableInlineConversation() {
|
||||
return Boolean(
|
||||
conversationId.value &&
|
||||
conversationId.value !== AI_SEARCH_CONVERSATION_ID &&
|
||||
conversationMessages.value.length
|
||||
)
|
||||
}
|
||||
|
||||
function persistCurrentConversation() {
|
||||
if (!isPersistableInlineConversation()) {
|
||||
refreshConversationHistory()
|
||||
return []
|
||||
}
|
||||
|
||||
const history = saveAiWorkbenchConversation(currentUser.value || {}, {
|
||||
id: conversationId.value,
|
||||
conversationId: conversationId.value,
|
||||
title: activeConversationTitle.value,
|
||||
source: 'workbench',
|
||||
sessionType: 'steward',
|
||||
stewardState: stewardState.value,
|
||||
messages: conversationMessages.value.map((message) => serializeRuntimeMessage(message))
|
||||
})
|
||||
emit('conversation-history-change', history)
|
||||
return history
|
||||
}
|
||||
|
||||
function resetInlineConversationState() {
|
||||
conversationStarted.value = false
|
||||
conversationMessages.value = []
|
||||
conversationId.value = ''
|
||||
stewardState.value = null
|
||||
activeConversationTitle.value = ''
|
||||
assistantDraft.value = ''
|
||||
thinkingExpandedMessageIds.value = new Set()
|
||||
thinkingCollapsedMessageIds.value = new Set()
|
||||
attachmentOcrExpandedMessageIds.value = new Set()
|
||||
deleteDialogOpen.value = false
|
||||
applicationSubmitConfirmOpen.value = false
|
||||
applicationSubmitConfirmContext.value = null
|
||||
clearWorkbenchDateSelection()
|
||||
filesFlow.clearAiModeFiles()
|
||||
}
|
||||
|
||||
function replaceInlineMessage(id, nextMessage) {
|
||||
const index = conversationMessages.value.findIndex((item) => item.id === id)
|
||||
if (index === -1) {
|
||||
conversationMessages.value.push(nextMessage)
|
||||
return
|
||||
}
|
||||
conversationMessages.value.splice(index, 1, nextMessage)
|
||||
}
|
||||
|
||||
function activateInlineConversation(options = {}) {
|
||||
conversationStarted.value = true
|
||||
if (!conversationId.value) {
|
||||
conversationId.value = options.id || `inline-${Date.now()}`
|
||||
}
|
||||
activeConversationTitle.value = options.title || activeConversationTitle.value || '新对话'
|
||||
emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
|
||||
}
|
||||
|
||||
function renderInlineConversationHtml(content) {
|
||||
return renderAiConversationHtml(content)
|
||||
}
|
||||
|
||||
function resolveInlineThinkingEvents(message) {
|
||||
return Array.isArray(message?.stewardPlan?.thinkingEvents) ? message.stewardPlan.thinkingEvents : []
|
||||
}
|
||||
|
||||
function hasInlineThinking(message) {
|
||||
return resolveInlineThinkingEvents(message).length > 0
|
||||
}
|
||||
|
||||
function isInlineThinkingExpanded(message) {
|
||||
if (!message?.id) {
|
||||
return Boolean(message?.pending)
|
||||
}
|
||||
if (thinkingCollapsedMessageIds.value.has(message.id)) {
|
||||
return false
|
||||
}
|
||||
return Boolean(message.pending || thinkingExpandedMessageIds.value.has(message.id))
|
||||
}
|
||||
|
||||
function toggleInlineThinking(message) {
|
||||
if (!message?.id) {
|
||||
return
|
||||
}
|
||||
const nextExpandedIds = new Set(thinkingExpandedMessageIds.value)
|
||||
const nextCollapsedIds = new Set(thinkingCollapsedMessageIds.value)
|
||||
if (isInlineThinkingExpanded(message)) {
|
||||
nextExpandedIds.delete(message.id)
|
||||
nextCollapsedIds.add(message.id)
|
||||
} else {
|
||||
nextCollapsedIds.delete(message.id)
|
||||
nextExpandedIds.add(message.id)
|
||||
}
|
||||
thinkingExpandedMessageIds.value = nextExpandedIds
|
||||
thinkingCollapsedMessageIds.value = nextCollapsedIds
|
||||
}
|
||||
|
||||
function hasInlineAttachmentOcrDetails(message = {}) {
|
||||
const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
|
||||
return Boolean(details?.documents?.length || details?.fileNames?.length)
|
||||
}
|
||||
|
||||
function resolveInlineAttachmentOcrDocuments(message = {}) {
|
||||
return normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)?.documents || []
|
||||
}
|
||||
|
||||
function resolveInlineAttachmentOcrFileCount(message = {}) {
|
||||
const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
|
||||
return Math.max(details?.documents?.length || 0, details?.fileNames?.length || 0)
|
||||
}
|
||||
|
||||
function isInlineAttachmentOcrExpanded(message = {}) {
|
||||
return Boolean(message?.id && attachmentOcrExpandedMessageIds.value.has(message.id))
|
||||
}
|
||||
|
||||
function toggleInlineAttachmentOcrDetails(message = {}, forceExpanded = null) {
|
||||
if (!message?.id || !hasInlineAttachmentOcrDetails(message)) {
|
||||
return
|
||||
}
|
||||
const nextExpandedIds = new Set(attachmentOcrExpandedMessageIds.value)
|
||||
const shouldExpand = forceExpanded === null
|
||||
? !nextExpandedIds.has(message.id)
|
||||
: Boolean(forceExpanded)
|
||||
if (shouldExpand) {
|
||||
nextExpandedIds.add(message.id)
|
||||
} else {
|
||||
nextExpandedIds.delete(message.id)
|
||||
}
|
||||
attachmentOcrExpandedMessageIds.value = nextExpandedIds
|
||||
nextTick(() => {
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
})
|
||||
}
|
||||
|
||||
function buildInlinePromptText(rawPrompt, files = []) {
|
||||
const prompt = buildWorkbenchPromptText(rawPrompt)
|
||||
if (prompt) {
|
||||
return prompt
|
||||
}
|
||||
return files.length ? '请帮我处理已上传的附件。' : ''
|
||||
}
|
||||
|
||||
function handleAiAnswerMarkdownClick(event) {
|
||||
const target = event?.target
|
||||
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
|
||||
if (!link) {
|
||||
return
|
||||
}
|
||||
const href = link.getAttribute('href')
|
||||
const detailReference = parseAiDocumentDetailHref(href) || parseAiApplicationDetailHref(href)
|
||||
if (!detailReference) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('open-document', buildAiDocumentDetailRequest(detailReference))
|
||||
}
|
||||
|
||||
function startInlineConversation(prompt, entry = {}, files = []) {
|
||||
if (isAiModeInputLocked.value) {
|
||||
toast('请等待费用测算完成后再继续操作。')
|
||||
return
|
||||
}
|
||||
const cleanPrompt = buildInlinePromptText(prompt, files)
|
||||
if (!cleanPrompt || sending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (aiExpenseDraft.value && !isAiExpenseDraftComplete(aiExpenseDraft.value)) {
|
||||
expenseFlow.advanceAiExpenseDraft(cleanPrompt, files)
|
||||
return
|
||||
}
|
||||
|
||||
if (applicationFlow.handleInlineApplicationPreviewTextAction(cleanPrompt, applicationPreviewEstimatePending)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (conversationId.value === AI_SEARCH_CONVERSATION_ID) {
|
||||
conversationId.value = ''
|
||||
conversationMessages.value = []
|
||||
activeConversationTitle.value = ''
|
||||
}
|
||||
|
||||
sending.value = true
|
||||
activateInlineConversation({
|
||||
title: entry.label || cleanPrompt.slice(0, 18) || '新对话'
|
||||
})
|
||||
inlineConversationAutoScrollPinned.value = true
|
||||
conversationMessages.value.push(createInlineMessage('user', cleanPrompt))
|
||||
assistantDraft.value = ''
|
||||
removeWorkbenchDateTag()
|
||||
closeWorkbenchDatePicker()
|
||||
filesFlow.clearAiModeFiles()
|
||||
scrollInlineConversationToBottom()
|
||||
persistCurrentConversation()
|
||||
if (shouldRunAiAttachmentAutoAssociation(entry, files, cleanPrompt)) {
|
||||
void attachmentFlow.requestAiAttachmentAssociationReply(cleanPrompt, entry, files)
|
||||
return
|
||||
}
|
||||
void stewardFlow.requestInlineAssistantReply(cleanPrompt, entry, files)
|
||||
}
|
||||
|
||||
function submitAiModePrompt() {
|
||||
if (!canSubmitAiModePrompt.value) {
|
||||
toast('请输入需求后再发送。')
|
||||
focusAiModeInput()
|
||||
return
|
||||
}
|
||||
startInlineConversation(assistantDraft.value, { source: 'workbench', sessionType: 'steward' }, Array.from(selectedFiles.value))
|
||||
}
|
||||
|
||||
function runAiModeAction(item) {
|
||||
if (String(item?.label || '').trim() === '发起报销') {
|
||||
expenseFlow.pushInlineExpenseSceneSelectionPrompt(item.prompt, item.label)
|
||||
return
|
||||
}
|
||||
startInlineConversation(item.prompt, item, Array.from(selectedFiles.value))
|
||||
}
|
||||
|
||||
function regenerateLastReply() {
|
||||
const lastUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user')
|
||||
if (!lastUserMessage || sending.value) {
|
||||
return
|
||||
}
|
||||
const lastAssistantIndex = conversationMessages.value.map((message) => message.role).lastIndexOf('assistant')
|
||||
if (lastAssistantIndex >= 0) {
|
||||
conversationMessages.value.splice(lastAssistantIndex, 1)
|
||||
}
|
||||
sending.value = true
|
||||
void stewardFlow.requestInlineAssistantReply(lastUserMessage.content, { source: 'workbench', sessionType: 'steward' }, [])
|
||||
}
|
||||
|
||||
function pushInlineUserMessage(text) {
|
||||
conversationMessages.value.push(createInlineMessage('user', String(text || '').trim()))
|
||||
}
|
||||
|
||||
function pushInlineApplicationActionUserMessage(text) {
|
||||
pushInlineUserMessage(text)
|
||||
assistantDraft.value = ''
|
||||
removeWorkbenchDateTag()
|
||||
closeWorkbenchDatePicker()
|
||||
filesFlow.clearAiModeFiles()
|
||||
}
|
||||
|
||||
function resolveLatestInlineUserPrompt() {
|
||||
const latestUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user')
|
||||
return String(latestUserMessage?.content || '').trim()
|
||||
}
|
||||
|
||||
function handleVoiceInput() {
|
||||
if (isAiModeInputLocked.value) {
|
||||
toast('请等待费用测算完成后再继续操作。')
|
||||
return
|
||||
}
|
||||
toast('语音输入正在准备中,您可以先输入文字需求。')
|
||||
focusAiModeInput()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.sidebarCommand?.seq,
|
||||
() => {
|
||||
const command = props.sidebarCommand || {}
|
||||
if (command.type === 'new-chat') {
|
||||
sessionCommands.startNewInlineConversation()
|
||||
return
|
||||
}
|
||||
if (command.type === 'search-chat') {
|
||||
sessionCommands.openInlineSearchConversation(activateInlineConversation)
|
||||
return
|
||||
}
|
||||
if (command.type === 'open-recent') {
|
||||
sessionCommands.openInlineRecentConversation(command.payload || {})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadSystemSettings()
|
||||
refreshConversationHistory()
|
||||
document.addEventListener('click', handleWorkbenchDatePickerOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleWorkbenchDatePickerOutside)
|
||||
})
|
||||
|
||||
return {
|
||||
activeConversationTitle,
|
||||
aiModeActionItems,
|
||||
applicationPreviewEditor,
|
||||
applicationSubmitConfirmOpen,
|
||||
assistantInputRef,
|
||||
assistantDraft,
|
||||
canShowInlineSuggestedActions: applicationFlow.canShowInlineSuggestedActions,
|
||||
canSubmitAiModePrompt,
|
||||
cancelDeleteConversation: () => sessionCommands.cancelDeleteConversation(deleteDialogOpen),
|
||||
cancelInlineApplicationSubmitConfirm: applicationFlow.cancelInlineApplicationSubmitConfirm,
|
||||
clearWorkbenchDateSelection,
|
||||
commitInlineApplicationPreviewEditor: applicationFlow.commitInlineApplicationPreviewEditor,
|
||||
confirmDeleteConversation: () => sessionCommands.confirmDeleteConversation(deleteDialogOpen),
|
||||
confirmInlineApplicationSubmit: applicationFlow.confirmInlineApplicationSubmit,
|
||||
conversationMessages,
|
||||
conversationScrollRef,
|
||||
conversationStarted,
|
||||
deleteDialogOpen,
|
||||
displayModelName,
|
||||
displayUserName,
|
||||
fileInputRef,
|
||||
handleAiAnswerMarkdownClick,
|
||||
handleAiModeFilesChange: filesFlow.handleAiModeFilesChange,
|
||||
handleInlineApplicationPreviewEditorKeydown: applicationFlow.handleInlineApplicationPreviewEditorKeydown,
|
||||
handleInlineConversationScroll,
|
||||
handleInlineSuggestedAction: actionRouter.handleInlineSuggestedAction,
|
||||
handleVoiceInput,
|
||||
hasInlineAttachmentOcrDetails,
|
||||
hasInlineThinking,
|
||||
isAiModeInputLocked,
|
||||
isApplicationPreviewEditing: applicationFlow.isApplicationPreviewEditing,
|
||||
isApplicationPreviewEstimatePending: applicationFlow.isApplicationPreviewEstimatePending,
|
||||
isInlineAttachmentOcrExpanded,
|
||||
isInlineSuggestedActionDisabled: applicationFlow.isInlineSuggestedActionDisabled,
|
||||
isInlineThinkingExpanded,
|
||||
markInlineMessageFeedback: messageActions.markInlineMessageFeedback,
|
||||
modelSelectorTitle,
|
||||
openApplicationPreviewEditor: applicationFlow.openApplicationPreviewEditor,
|
||||
quoteInlineMessage: messageActions.quoteInlineMessage,
|
||||
regenerateLastReply,
|
||||
removeAiModeFile: filesFlow.removeAiModeFile,
|
||||
removeWorkbenchDateTag,
|
||||
renderInlineConversationHtml,
|
||||
requestDeleteCurrentConversation: () => sessionCommands.requestDeleteCurrentConversation(deleteDialogOpen),
|
||||
resolveApplicationPreviewEditorOptions: applicationFlow.resolveApplicationPreviewEditorOptions,
|
||||
resolveInlineApplicationPreviewEditorControl: applicationFlow.resolveInlineApplicationPreviewEditorControl,
|
||||
resolveInlineApplicationPreviewMissingFields: applicationFlow.resolveInlineApplicationPreviewMissingFields,
|
||||
resolveInlineApplicationPreviewRows: applicationFlow.resolveInlineApplicationPreviewRows,
|
||||
resolveInlineAttachmentOcrDocuments,
|
||||
resolveInlineAttachmentOcrFileCount,
|
||||
resolveInlineThinkingEvents,
|
||||
runAiModeAction,
|
||||
scrollInlineConversationToTop,
|
||||
selectedFileCards,
|
||||
sending,
|
||||
setWorkbenchDateMode,
|
||||
submitAiModePrompt,
|
||||
toggleInlineAttachmentOcrDetails,
|
||||
toggleInlineThinking,
|
||||
toggleWorkbenchDatePicker,
|
||||
triggerAiModeFileUpload: filesFlow.triggerAiModeFileUpload,
|
||||
workbenchCanApplyDateSelection,
|
||||
workbenchDateMode,
|
||||
workbenchDatePickerOpen,
|
||||
workbenchDateTagLabel,
|
||||
workbenchRangeEndDate,
|
||||
workbenchRangeStartDate,
|
||||
workbenchSingleDate,
|
||||
applyWorkbenchDateSelection,
|
||||
buildInlineApplicationPreviewFooterText: applicationFlow.buildInlineApplicationPreviewFooterText,
|
||||
copyInlineMessage: messageActions.copyInlineMessage,
|
||||
formatMessageTime,
|
||||
handleWorkbenchDateInputChange
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT
|
||||
} from '../../services/aiApplicationPreviewActions.js'
|
||||
import { buildAiDocumentDetailRequest } from '../../utils/aiDocumentDetailReference.js'
|
||||
import {
|
||||
mergeComposerPrefill,
|
||||
resolveSuggestedActionPrefill
|
||||
} from '../../utils/assistantSuggestedActionPrefill.js'
|
||||
import {
|
||||
AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
|
||||
AI_ATTACHMENT_OCR_DETAIL_ACTION
|
||||
} from './workbenchAiMessageModel.js'
|
||||
import { SESSION_TYPE_EXPENSE } from './useWorkbenchAiExpenseFlow.js'
|
||||
|
||||
export function useWorkbenchAiActionRouter({
|
||||
aiExpenseDraft,
|
||||
applicationFlow,
|
||||
assistantDraft,
|
||||
attachmentFlow,
|
||||
emit,
|
||||
expenseFlow,
|
||||
focusAiModeInput,
|
||||
hasInlineAttachmentOcrDetails,
|
||||
resolveLatestInlineUserPrompt,
|
||||
selectedFiles,
|
||||
startInlineConversation,
|
||||
toast,
|
||||
toggleInlineAttachmentOcrDetails
|
||||
}) {
|
||||
function handleInlineSuggestedAction(action = {}, sourceMessage = null) {
|
||||
const prefillText = resolveSuggestedActionPrefill(action)
|
||||
if (prefillText) {
|
||||
assistantDraft.value = mergeComposerPrefill(assistantDraft.value, prefillText)
|
||||
focusAiModeInput()
|
||||
return
|
||||
}
|
||||
|
||||
const actionType = String(action?.action_type || '').trim()
|
||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
if (actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION) {
|
||||
if (!hasInlineAttachmentOcrDetails(sourceMessage)) {
|
||||
toast('当前消息没有可查看的附件识别明细。')
|
||||
return
|
||||
}
|
||||
toggleInlineAttachmentOcrDetails(sourceMessage, true)
|
||||
return
|
||||
}
|
||||
if (actionType === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION) {
|
||||
if (applicationFlow.isInlineSuggestedActionDisabled(action, sourceMessage)) {
|
||||
return
|
||||
}
|
||||
void attachmentFlow.confirmAiAttachmentAssociation(actionPayload, sourceMessage)
|
||||
return
|
||||
}
|
||||
if ([AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType)) {
|
||||
if (applicationFlow.isInlineSuggestedActionDisabled(action, sourceMessage)) {
|
||||
toast('请等待费用测算完成后再继续操作。')
|
||||
return
|
||||
}
|
||||
void applicationFlow.executeInlineApplicationPreviewAction(actionType, sourceMessage, {
|
||||
userText: action.label,
|
||||
draftPayload: actionPayload.draftPayload || actionPayload.draft_payload || null
|
||||
})
|
||||
return
|
||||
}
|
||||
if (actionType === 'open_application_detail') {
|
||||
const claimNo = String(actionPayload.claim_no || actionPayload.claimNo || '').trim()
|
||||
const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim()
|
||||
emit('open-document', buildAiDocumentDetailRequest({
|
||||
reference: claimNo || claimId,
|
||||
claimId,
|
||||
claimNo
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') {
|
||||
const expenseType = String(actionPayload.expense_type || 'travel').trim() || 'travel'
|
||||
const expenseTypeLabel = String(actionPayload.expense_type_label || '差旅费').trim() || '差旅费'
|
||||
expenseFlow.startAiExpenseDraft(expenseType, expenseTypeLabel, true)
|
||||
return
|
||||
}
|
||||
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_application') {
|
||||
aiExpenseDraft.value = null
|
||||
void expenseFlow.startAiApplicationPreviewFromAction(actionPayload)
|
||||
return
|
||||
}
|
||||
if (actionType === 'select_expense_type') {
|
||||
const expenseType = String(action?.payload?.expense_type || '').trim()
|
||||
const expenseTypeLabel = String(action?.payload?.expense_type_label || action?.label || '').trim()
|
||||
const requiresApplicationBeforeReimbursement = Boolean(action?.payload?.requires_application_before_reimbursement)
|
||||
expenseFlow.startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement)
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === 'select_required_application') {
|
||||
expenseFlow.linkAiExpenseApplication(action?.payload || {})
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === 'ai_application_start_inline') {
|
||||
aiExpenseDraft.value = null
|
||||
void expenseFlow.startAiApplicationPreviewFromAction(action?.payload || {}, action?.label)
|
||||
return
|
||||
}
|
||||
|
||||
const carryText = String(action?.payload?.carry_text || action?.label || '').trim()
|
||||
if (!carryText) {
|
||||
return
|
||||
}
|
||||
if (String(action?.payload?.session_type || '').trim() === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
|
||||
expenseFlow.pushInlineExpenseSceneSelectionPrompt(carryText, action.label)
|
||||
return
|
||||
}
|
||||
startInlineConversation(carryText, {
|
||||
label: action.label,
|
||||
source: 'steward-action',
|
||||
sessionType: action?.payload?.session_type || 'steward'
|
||||
}, action?.payload?.carry_files ? Array.from(selectedFiles.value) : [])
|
||||
}
|
||||
|
||||
return {
|
||||
handleInlineSuggestedAction
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
import {
|
||||
buildApplicationPreviewFooterMessage,
|
||||
buildApplicationPreviewRows,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
normalizeApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
buildAiApplicationPrecheck,
|
||||
buildAiApplicationSubmitConflictMessage,
|
||||
isAiApplicationPrecheckBlocking
|
||||
} from '../../utils/aiApplicationPrecheckModel.js'
|
||||
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT,
|
||||
runAiApplicationPreviewAction
|
||||
} from '../../services/aiApplicationPreviewActions.js'
|
||||
import {
|
||||
buildFailedInlineApplicationSubmitThinkingEvents,
|
||||
buildInitialInlineApplicationSubmitThinkingEvents,
|
||||
buildInlineApplicationDetailAction,
|
||||
buildInlineApplicationPreview,
|
||||
buildInlineApplicationPreviewActionResultText,
|
||||
buildInlineApplicationSubmitPrecheckPayload,
|
||||
buildInlineApplicationSubmitThinkingEvents,
|
||||
completeInlineThinkingEvents,
|
||||
extractInlineApplicationDraftPayload,
|
||||
resolveInlineApplicationPreviewActionFromText
|
||||
} from './workbenchAiApplicationPreviewModel.js'
|
||||
import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js'
|
||||
|
||||
function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) {
|
||||
const fields = normalizeApplicationPreview(applicationPreview).fields || {}
|
||||
return [
|
||||
fields.transportPolicy,
|
||||
fields.policyEstimate,
|
||||
fields.transportEstimatedAmount,
|
||||
fields.amount
|
||||
].some((value) => /正在|查询中/.test(String(value || '')))
|
||||
}
|
||||
|
||||
function shouldRefreshInlineApplicationPreviewEstimate(fieldKey = '') {
|
||||
return ['transportMode', 'time', 'location', 'days'].includes(String(fieldKey || '').trim())
|
||||
}
|
||||
|
||||
export function useWorkbenchAiApplicationPreviewFlow({
|
||||
activateInlineConversation,
|
||||
applicationPreviewEditor,
|
||||
applicationSubmitConfirmContext,
|
||||
applicationSubmitConfirmOpen,
|
||||
assistantDraft,
|
||||
cancelApplicationPreviewEditor,
|
||||
clearAiModeFiles,
|
||||
closeWorkbenchDatePicker,
|
||||
commitApplicationPreviewEditor: commitBaseApplicationPreviewEditor,
|
||||
conversationId,
|
||||
conversationMessages,
|
||||
conversationStarted,
|
||||
createInlineMessage,
|
||||
currentUser,
|
||||
handleApplicationPreviewEditorKeydown,
|
||||
inlineConversationAutoScrollPinned,
|
||||
isApplicationPreviewEditing,
|
||||
openApplicationPreviewEditor,
|
||||
persistCurrentConversation,
|
||||
pushInlineApplicationActionUserMessage,
|
||||
pushInlineUserMessage,
|
||||
refreshApplicationPreviewEstimate,
|
||||
removeWorkbenchDateTag,
|
||||
replaceInlineMessage,
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
resolveInlineThinkingEvents,
|
||||
resolveLatestInlineUserPrompt,
|
||||
scrollInlineConversationToBottom,
|
||||
sending,
|
||||
toast
|
||||
}) {
|
||||
function isApplicationPreviewEstimatePending(message = {}) {
|
||||
return Boolean(message?.applicationPreview && isApplicationPreviewEstimatePendingPreview(message.applicationPreview))
|
||||
}
|
||||
|
||||
function canShowInlineSuggestedActions(message = {}) {
|
||||
return Boolean(message?.suggestedActions?.length) && !isApplicationPreviewEstimatePending(message)
|
||||
}
|
||||
|
||||
function isInlineSuggestedActionDisabled(action = {}, message = {}) {
|
||||
const actionType = String(action?.action_type || '').trim()
|
||||
return (
|
||||
Boolean(action?.disabled) ||
|
||||
(actionType === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION && sending.value) ||
|
||||
(
|
||||
[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType) &&
|
||||
isApplicationPreviewEstimatePending(message)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveInlineApplicationPreviewRows(message) {
|
||||
return buildApplicationPreviewRows(message?.applicationPreview || {})
|
||||
}
|
||||
|
||||
function resolveInlineApplicationPreviewMissingFields(message) {
|
||||
return normalizeApplicationPreview(message?.applicationPreview || {}).missingFields || []
|
||||
}
|
||||
|
||||
function resolveInlineApplicationPreviewEditorControl(fieldKey) {
|
||||
const control = resolveApplicationPreviewEditorControl(fieldKey)
|
||||
return control === 'date' ? 'text' : control
|
||||
}
|
||||
|
||||
function buildInlineApplicationPreviewSuggestedActions(applicationPreview = {}, draftPayload = null) {
|
||||
if (isApplicationPreviewEstimatePendingPreview(applicationPreview)) {
|
||||
return []
|
||||
}
|
||||
const normalized = normalizeApplicationPreview(applicationPreview)
|
||||
const actions = [{
|
||||
label: '保存草稿',
|
||||
description: '先保存当前申请表,后续可以继续补充或提交。',
|
||||
icon: 'mdi mdi-content-save-outline',
|
||||
action_type: AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
payload: { draftPayload }
|
||||
}]
|
||||
if (normalized.readyToSubmit) {
|
||||
actions.push({
|
||||
label: '直接提交',
|
||||
description: '提交前先核查相同日期申请单,确认通过后进入审批流程。',
|
||||
icon: 'mdi mdi-send-check-outline',
|
||||
action_type: AI_APPLICATION_ACTION_SUBMIT,
|
||||
payload: { draftPayload }
|
||||
})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
function syncInlineApplicationPreviewMessageContent(message) {
|
||||
if (!message?.applicationPreview) {
|
||||
return
|
||||
}
|
||||
const nextContent = buildLocalApplicationPreviewMessage(message.applicationPreview)
|
||||
message.content = nextContent
|
||||
message.text = nextContent
|
||||
message.suggestedActions = buildInlineApplicationPreviewSuggestedActions(message.applicationPreview, message.draftPayload)
|
||||
}
|
||||
|
||||
async function commitInlineApplicationPreviewEditor(message) {
|
||||
const shouldLockForEstimate = shouldRefreshInlineApplicationPreviewEstimate(applicationPreviewEditor.value.fieldKey)
|
||||
if (shouldLockForEstimate) {
|
||||
message.suggestedActions = []
|
||||
persistCurrentConversation()
|
||||
}
|
||||
const committed = await commitBaseApplicationPreviewEditor(message)
|
||||
syncInlineApplicationPreviewMessageContent(message)
|
||||
persistCurrentConversation()
|
||||
return committed
|
||||
}
|
||||
|
||||
function handleInlineApplicationPreviewEditorKeydown(event, message) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void commitInlineApplicationPreviewEditor(message)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
cancelApplicationPreviewEditor()
|
||||
return
|
||||
}
|
||||
handleApplicationPreviewEditorKeydown(event, message)
|
||||
}
|
||||
|
||||
function buildInlineApplicationPreviewFooterText(message) {
|
||||
const normalized = normalizeApplicationPreview(message?.applicationPreview || {})
|
||||
if (isApplicationPreviewEstimatePending(message)) {
|
||||
return '费用测算正在同步,请稍等,完成后才能保存草稿或直接提交。'
|
||||
}
|
||||
if (normalized.validationIssues?.length || normalized.missingFields?.length) {
|
||||
return buildApplicationPreviewFooterMessage(normalized)
|
||||
}
|
||||
return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
|
||||
}
|
||||
|
||||
function resolveLatestApplicationPreviewMessage() {
|
||||
return [...conversationMessages.value]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'assistant' && message.applicationPreview)
|
||||
}
|
||||
|
||||
function requestInlineApplicationSubmitConfirmation(targetMessage, options = {}) {
|
||||
applicationSubmitConfirmContext.value = {
|
||||
messageId: String(targetMessage?.id || '').trim(),
|
||||
draftPayload: targetMessage?.draftPayload || options.draftPayload || null,
|
||||
userText: String(options.userText || '直接提交').trim() || '直接提交'
|
||||
}
|
||||
applicationSubmitConfirmOpen.value = true
|
||||
persistCurrentConversation()
|
||||
}
|
||||
|
||||
function cancelInlineApplicationSubmitConfirm() {
|
||||
applicationSubmitConfirmOpen.value = false
|
||||
applicationSubmitConfirmContext.value = null
|
||||
}
|
||||
|
||||
function confirmInlineApplicationSubmit() {
|
||||
const context = applicationSubmitConfirmContext.value || {}
|
||||
applicationSubmitConfirmOpen.value = false
|
||||
applicationSubmitConfirmContext.value = null
|
||||
const sourceMessage = conversationMessages.value.find((message) => message.id === context.messageId)
|
||||
if (!sourceMessage?.applicationPreview) {
|
||||
toast('当前申请表已变化,请重新点击直接提交。')
|
||||
return
|
||||
}
|
||||
void executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, sourceMessage, {
|
||||
confirmed: true,
|
||||
skipUserMessage: false,
|
||||
draftPayload: context.draftPayload || null,
|
||||
userText: context.userText || '直接提交'
|
||||
})
|
||||
}
|
||||
|
||||
async function runInlineApplicationSubmitPrecheck(targetMessage, pendingMessage, normalizedPreview, options = {}) {
|
||||
try {
|
||||
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
||||
const precheck = buildAiApplicationPrecheck(normalizedPreview, {
|
||||
claimsPayload: buildInlineApplicationSubmitPrecheckPayload(
|
||||
claimsPayload,
|
||||
targetMessage.draftPayload || options.draftPayload || null
|
||||
),
|
||||
currentUser: currentUser.value || {},
|
||||
expenseType: 'travel'
|
||||
})
|
||||
const thinkingEvents = buildInlineApplicationSubmitThinkingEvents(precheck)
|
||||
const blocked = isAiApplicationPrecheckBlocking(precheck)
|
||||
|
||||
if (blocked) {
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', buildAiApplicationSubmitConflictMessage(normalizedPreview, precheck), {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents
|
||||
}
|
||||
})
|
||||
)
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
return false
|
||||
}
|
||||
|
||||
const message = conversationMessages.value.find((item) => item.id === pendingMessage.id) || pendingMessage
|
||||
message.content = '提交前核查通过,正在提交申请并进入审批流程...'
|
||||
message.paragraphs = ['提交前核查通过,正在提交申请并进入审批流程...']
|
||||
message.stewardPlan = {
|
||||
...(message.stewardPlan || {}),
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents
|
||||
}
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
return true
|
||||
} catch (error) {
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', [
|
||||
'### 提交前核查失败',
|
||||
'系统未能完成相同日期申请单查询,所以本次申请没有提交。',
|
||||
'请稍后重试;如果仍然失败,请先到单据中心核对是否已有同日期申请单。'
|
||||
].join('\n\n'), {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: buildFailedInlineApplicationSubmitThinkingEvents(error)
|
||||
}
|
||||
})
|
||||
)
|
||||
toast('提交前核查失败,已暂停提交。')
|
||||
persistCurrentConversation()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) {
|
||||
const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestApplicationPreviewMessage()
|
||||
if (!targetMessage?.applicationPreview) {
|
||||
toast('当前没有可提交的申请表。')
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview)
|
||||
const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT
|
||||
const userText = String(options.userText || (isSubmit ? '直接提交' : '保存草稿')).trim()
|
||||
|
||||
if (isSubmit && !normalizedPreview.readyToSubmit) {
|
||||
if (!options.skipUserMessage) {
|
||||
pushInlineApplicationActionUserMessage(userText)
|
||||
}
|
||||
const missingText = normalizedPreview.missingFields?.length
|
||||
? `当前还缺少:${normalizedPreview.missingFields.join('、')}。`
|
||||
: ''
|
||||
const validationText = normalizedPreview.validationIssues?.length
|
||||
? normalizedPreview.validationIssues.map((item) => item.message).join(';')
|
||||
: ''
|
||||
conversationMessages.value.push(createInlineMessage('assistant', [
|
||||
'### 暂不能提交申请',
|
||||
missingText || validationText || '当前申请表还未通过提交校验。',
|
||||
'请先点击表格中的字段补充或修正,补齐后我会开放“直接提交”入口。'
|
||||
].filter(Boolean).join('\n\n')))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
return true
|
||||
}
|
||||
|
||||
if (isSubmit && !options.confirmed) {
|
||||
requestInlineApplicationSubmitConfirmation(targetMessage, { ...options, userText })
|
||||
return true
|
||||
}
|
||||
|
||||
if (!options.skipUserMessage) {
|
||||
pushInlineApplicationActionUserMessage(userText)
|
||||
}
|
||||
|
||||
sending.value = true
|
||||
const pendingMessage = createInlineMessage(
|
||||
'assistant',
|
||||
isSubmit ? '正在提交前核查相同日期申请单...' : '正在保存申请草稿...',
|
||||
{
|
||||
pending: true,
|
||||
stewardPlan: {
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents: isSubmit
|
||||
? buildInitialInlineApplicationSubmitThinkingEvents()
|
||||
: [
|
||||
{
|
||||
eventId: 'application-save-draft',
|
||||
title: '保存申请草稿',
|
||||
content: '正在按当前申请表内容保存草稿。',
|
||||
status: 'running'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
conversationMessages.value.push(pendingMessage)
|
||||
scrollInlineConversationToBottom()
|
||||
|
||||
try {
|
||||
if (isSubmit) {
|
||||
const precheckPassed = await runInlineApplicationSubmitPrecheck(
|
||||
targetMessage,
|
||||
pendingMessage,
|
||||
normalizedPreview,
|
||||
options
|
||||
)
|
||||
if (!precheckPassed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await runAiApplicationPreviewAction({
|
||||
actionType,
|
||||
applicationPreview: normalizedPreview,
|
||||
currentUser: currentUser.value || {},
|
||||
conversationId: conversationId.value,
|
||||
draftPayload: targetMessage.draftPayload || options.draftPayload || null
|
||||
})
|
||||
const draftPayload = extractInlineApplicationDraftPayload(payload)
|
||||
if (draftPayload) {
|
||||
targetMessage.draftPayload = draftPayload
|
||||
}
|
||||
targetMessage.suggestedActions = []
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', buildInlineApplicationPreviewActionResultText(actionType, payload), {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
|
||||
},
|
||||
suggestedActions: isSubmit ? buildInlineApplicationDetailAction(draftPayload) : []
|
||||
})
|
||||
)
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
return true
|
||||
} catch (error) {
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'), {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
|
||||
...item,
|
||||
status: 'failed'
|
||||
}))
|
||||
}
|
||||
})
|
||||
)
|
||||
toast(error?.message || (isSubmit ? '申请提交失败。' : '申请草稿保存失败。'))
|
||||
persistCurrentConversation()
|
||||
return true
|
||||
} finally {
|
||||
sending.value = false
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
}
|
||||
}
|
||||
|
||||
function handleInlineApplicationPreviewTextAction(prompt, applicationPreviewEstimatePending) {
|
||||
if (applicationPreviewEstimatePending.value) {
|
||||
toast('请等待费用测算完成后再继续操作。')
|
||||
return true
|
||||
}
|
||||
const actionType = resolveInlineApplicationPreviewActionFromText(prompt)
|
||||
if (!actionType || !resolveLatestApplicationPreviewMessage()) {
|
||||
return false
|
||||
}
|
||||
void executeInlineApplicationPreviewAction(actionType, null, { userText: prompt })
|
||||
return true
|
||||
}
|
||||
|
||||
async function startAiApplicationPreview(expenseType, expenseTypeLabel, sourceText = '', options = {}) {
|
||||
if (!conversationStarted.value) {
|
||||
activateInlineConversation({ title: String(expenseTypeLabel || expenseType || '申请').trim().slice(0, 18) || '申请' })
|
||||
}
|
||||
const previewSourceText = String(sourceText || resolveLatestInlineUserPrompt()).trim()
|
||||
assistantDraft.value = ''
|
||||
removeWorkbenchDateTag()
|
||||
closeWorkbenchDatePicker()
|
||||
clearAiModeFiles()
|
||||
if (options.pushUserMessage !== false) {
|
||||
pushInlineUserMessage(options.userMessage || '确认发起出差申请')
|
||||
}
|
||||
|
||||
const pendingMessage = createInlineMessage('assistant', '正在生成申请核对表,请稍等...', {
|
||||
pending: true,
|
||||
stewardPlan: {
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents: [
|
||||
{
|
||||
eventId: 'application-preview-build',
|
||||
title: '整理申请表字段',
|
||||
content: '正在把已识别的时间、地点、事由和差旅类型整理成可编辑表格。',
|
||||
status: 'running'
|
||||
},
|
||||
{
|
||||
eventId: 'application-preview-estimate',
|
||||
title: '同步费用测算',
|
||||
content: '正在刷新差旅规则和费用测算,完成后会直接展示核对表。',
|
||||
status: 'pending'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
conversationMessages.value.push(pendingMessage)
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
|
||||
try {
|
||||
const preview = await refreshApplicationPreviewEstimate(
|
||||
buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText, currentUser.value || {})
|
||||
)
|
||||
const content = buildLocalApplicationPreviewMessage(preview)
|
||||
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', content, {
|
||||
id: pendingMessage.id,
|
||||
applicationPreview: preview,
|
||||
suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview),
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
|
||||
},
|
||||
text: content
|
||||
}))
|
||||
} catch (error) {
|
||||
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
|
||||
...item,
|
||||
status: 'failed'
|
||||
}))
|
||||
}
|
||||
}))
|
||||
toast(error?.message || '申请核对表生成失败。')
|
||||
} finally {
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buildInlineApplicationPreviewFooterText,
|
||||
buildInlineApplicationPreviewSuggestedActions,
|
||||
canShowInlineSuggestedActions,
|
||||
cancelInlineApplicationSubmitConfirm,
|
||||
commitInlineApplicationPreviewEditor,
|
||||
confirmInlineApplicationSubmit,
|
||||
executeInlineApplicationPreviewAction,
|
||||
handleInlineApplicationPreviewEditorKeydown,
|
||||
handleInlineApplicationPreviewTextAction,
|
||||
isApplicationPreviewEditing,
|
||||
isApplicationPreviewEstimatePending,
|
||||
isInlineSuggestedActionDisabled,
|
||||
openApplicationPreviewEditor,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
resolveInlineApplicationPreviewEditorControl,
|
||||
resolveInlineApplicationPreviewMissingFields,
|
||||
resolveInlineApplicationPreviewRows,
|
||||
startAiApplicationPreview
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
|
||||
import { syncExpenseClaimFilesToDraft } from '../../utils/expenseClaimAttachmentSync.js'
|
||||
import { collectReceiptFiles } from '../../views/scripts/travelReimbursementAttachmentModel.js'
|
||||
import {
|
||||
createExpenseClaimItem,
|
||||
extractExpenseClaimItems,
|
||||
fetchExpenseClaimDetail,
|
||||
fetchExpenseClaims,
|
||||
uploadExpenseClaimItemAttachment
|
||||
} from '../../services/reimbursements.js'
|
||||
import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||
import {
|
||||
AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
|
||||
buildInlineAttachmentOcrDetails
|
||||
} from './workbenchAiMessageModel.js'
|
||||
import { isLikelyAiModeOcrFile } from './workbenchAiComposerModel.js'
|
||||
|
||||
function buildAiAttachmentAssociationThinkingEvents(status = 'running') {
|
||||
const completed = status === 'completed'
|
||||
const failed = status === 'failed'
|
||||
const eventStatus = failed ? 'failed' : completed ? 'completed' : 'running'
|
||||
return [
|
||||
{
|
||||
eventId: 'attachment-ocr',
|
||||
title: '识别上传票据',
|
||||
content: '提取票据里的日期、地点和行程信息。',
|
||||
status: eventStatus
|
||||
},
|
||||
{
|
||||
eventId: 'claim-lookup',
|
||||
title: '查询可关联报销单',
|
||||
content: '查找草稿、待补充和退回状态的可归集单据。',
|
||||
status: eventStatus
|
||||
},
|
||||
{
|
||||
eventId: 'claim-match',
|
||||
title: '匹配票据与报销单',
|
||||
content: '根据票据时间、城市和报销事由判断最可能的关联单据。',
|
||||
status: eventStatus
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function resolveAiAttachmentAssociationClaimNo(payload = {}) {
|
||||
return String(payload?.claim_no || payload?.claimNo || '').trim()
|
||||
}
|
||||
|
||||
function buildAiAttachmentAssociationResultThinkingEvents(status = 'running') {
|
||||
const completed = status === 'completed'
|
||||
const failed = status === 'failed'
|
||||
const eventStatus = failed ? 'failed' : completed ? 'completed' : 'running'
|
||||
return [
|
||||
{
|
||||
eventId: 'attachment-confirm',
|
||||
title: '确认自动归集',
|
||||
content: '正在读取匹配单据并准备写入附件。',
|
||||
status: eventStatus
|
||||
},
|
||||
{
|
||||
eventId: 'attachment-upload',
|
||||
title: '归集票据附件',
|
||||
content: '把本次上传的票据写入报销单明细。',
|
||||
status: eventStatus
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function useWorkbenchAiAttachmentAssociationFlow({
|
||||
aiAttachmentAssociationRuntime,
|
||||
conversationMessages,
|
||||
createAiAttachmentAssociationId,
|
||||
createInlineMessage,
|
||||
inlineConversationAutoScrollPinned,
|
||||
persistCurrentConversation,
|
||||
replaceInlineMessage,
|
||||
scrollInlineConversationToBottom,
|
||||
sending,
|
||||
streamOrSetInlineAssistantContent,
|
||||
toast
|
||||
}) {
|
||||
async function collectAiModeReceiptContext(files = []) {
|
||||
const safeFiles = Array.isArray(files) ? files : []
|
||||
const attachmentNames = safeFiles
|
||||
.map((file) => String(file?.name || '').trim())
|
||||
.filter(Boolean)
|
||||
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
|
||||
const ocrSourceFileNames = ocrFiles
|
||||
.map((file) => String(file?.name || '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const baseContext = {
|
||||
attachmentNames,
|
||||
attachmentCount: attachmentNames.length,
|
||||
ocrSourceFileNames,
|
||||
ocrSummary: '',
|
||||
ocrDocuments: []
|
||||
}
|
||||
|
||||
if (!ocrFiles.length) {
|
||||
return baseContext
|
||||
}
|
||||
|
||||
try {
|
||||
const collected = await collectReceiptFiles({
|
||||
files: ocrFiles,
|
||||
recognizeOcrFiles
|
||||
})
|
||||
return {
|
||||
...baseContext,
|
||||
ocrSummary: String(collected.ocrSummary || '').trim(),
|
||||
ocrDocuments: Array.isArray(collected.ocrDocuments) ? collected.ocrDocuments : []
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('AI mode OCR request failed:', error)
|
||||
return {
|
||||
...baseContext,
|
||||
ocrError: error?.message || 'OCR识别失败,已继续使用附件名称。'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findAiAttachmentAssociationRuntime(options = {}) {
|
||||
const normalizedAssociationId = String(options.associationId || options.association_id || '').trim()
|
||||
const normalizedClaimNo = String(options.claimNo || options.claim_no || '').trim()
|
||||
if (normalizedAssociationId) {
|
||||
const runtime = aiAttachmentAssociationRuntime.get(normalizedAssociationId)
|
||||
if (runtime) {
|
||||
return { associationId: normalizedAssociationId, runtime }
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedClaimNo) {
|
||||
for (const [runtimeId, runtime] of aiAttachmentAssociationRuntime.entries()) {
|
||||
if (String(runtime?.claimNo || '').trim() === normalizedClaimNo && runtime?.files?.length) {
|
||||
return { associationId: runtimeId, runtime }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { associationId: '', runtime: null }
|
||||
}
|
||||
|
||||
function updateAiAttachmentAssociationActionState(message = {}, associationId = '', state = {}, options = {}) {
|
||||
const normalizedAssociationId = String(associationId || '').trim()
|
||||
const normalizedClaimNo = String(options.claimNo || options.claim_no || '').trim()
|
||||
if (!message || !Array.isArray(message.suggestedActions) || (!normalizedAssociationId && !normalizedClaimNo)) {
|
||||
return
|
||||
}
|
||||
message.suggestedActions = message.suggestedActions.map((action) => {
|
||||
const actionAssociationId = String(action?.payload?.association_id || action?.payload?.associationId || '').trim()
|
||||
const actionClaimNo = resolveAiAttachmentAssociationClaimNo(action?.payload || {})
|
||||
const isSameAssociation = normalizedAssociationId && actionAssociationId === normalizedAssociationId
|
||||
const isSameClaim = normalizedClaimNo && actionClaimNo === normalizedClaimNo
|
||||
if (String(action?.action_type || '').trim() !== AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION || (!isSameAssociation && !isSameClaim)) {
|
||||
return action
|
||||
}
|
||||
return {
|
||||
...action,
|
||||
...state
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildAiAttachmentAssociationDetailActions(runtime = {}) {
|
||||
const claimNo = String(runtime.claimNo || '').trim()
|
||||
const claimId = String(runtime.claimId || '').trim()
|
||||
if (!claimNo && !claimId) {
|
||||
return []
|
||||
}
|
||||
return [{
|
||||
label: '查看单据',
|
||||
description: '打开已归集票据的报销单。',
|
||||
icon: 'mdi mdi-open-in-new',
|
||||
action_type: 'open_application_detail',
|
||||
payload: {
|
||||
claim_id: claimId,
|
||||
claim_no: claimNo,
|
||||
document_type: 'expense'
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
async function confirmAiAttachmentAssociation(actionPayload = {}, sourceMessage = null) {
|
||||
const requestedAssociationId = String(actionPayload.association_id || actionPayload.associationId || '').trim()
|
||||
const payloadClaimNo = resolveAiAttachmentAssociationClaimNo(actionPayload)
|
||||
const runtimeResult = findAiAttachmentAssociationRuntime({
|
||||
associationId: requestedAssociationId,
|
||||
claimNo: payloadClaimNo
|
||||
})
|
||||
const associationId = runtimeResult.associationId
|
||||
const runtime = runtimeResult.runtime
|
||||
const actionClaimNo = payloadClaimNo || String(runtime?.claimNo || '').trim()
|
||||
if (!associationId || !runtime?.files?.length) {
|
||||
toast('当前会话里没有可归集的附件原件,请重新上传票据后再确认。')
|
||||
return
|
||||
}
|
||||
|
||||
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
|
||||
label: '正在归集...',
|
||||
disabled: true
|
||||
}, { claimNo: actionClaimNo })
|
||||
persistCurrentConversation()
|
||||
sending.value = true
|
||||
|
||||
const pendingMessage = createInlineMessage('assistant', '', {
|
||||
pending: true,
|
||||
stewardPlan: {
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('running')
|
||||
}
|
||||
})
|
||||
conversationMessages.value.push(pendingMessage)
|
||||
scrollInlineConversationToBottom()
|
||||
|
||||
try {
|
||||
const syncResult = await syncExpenseClaimFilesToDraft({
|
||||
claimId: runtime.claimId,
|
||||
files: runtime.files,
|
||||
fetchExpenseClaimDetail,
|
||||
createExpenseClaimItem,
|
||||
uploadExpenseClaimItemAttachment
|
||||
})
|
||||
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
|
||||
claimNo: runtime.claimNo,
|
||||
fileNames: runtime.fileNames,
|
||||
uploadedCount: syncResult.uploadedCount,
|
||||
skippedCount: syncResult.skippedCount
|
||||
})
|
||||
await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText)
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('completed')
|
||||
},
|
||||
suggestedActions: buildAiAttachmentAssociationDetailActions(runtime)
|
||||
})
|
||||
)
|
||||
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
|
||||
label: '已自动关联',
|
||||
disabled: true
|
||||
}, { claimNo: actionClaimNo })
|
||||
aiAttachmentAssociationRuntime.delete(associationId)
|
||||
persistCurrentConversation()
|
||||
} catch (error) {
|
||||
const finalMessageText = error?.message || '自动归集失败,请稍后重试。'
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('failed')
|
||||
}
|
||||
})
|
||||
)
|
||||
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
|
||||
label: '重新自动关联',
|
||||
disabled: false
|
||||
}, { claimNo: actionClaimNo })
|
||||
toast(finalMessageText)
|
||||
persistCurrentConversation()
|
||||
} finally {
|
||||
sending.value = false
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
}
|
||||
}
|
||||
|
||||
async function requestAiAttachmentAssociationReply(prompt, entry = {}, files = []) {
|
||||
let shouldAutoScrollOnFinish = true
|
||||
const pendingMessage = createInlineMessage('assistant', '', {
|
||||
pending: true,
|
||||
stewardPlan: {
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('running')
|
||||
}
|
||||
})
|
||||
conversationMessages.value.push(pendingMessage)
|
||||
scrollInlineConversationToBottom()
|
||||
|
||||
try {
|
||||
const collected = await collectReceiptFiles({
|
||||
files,
|
||||
recognizeOcrFiles
|
||||
})
|
||||
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files)
|
||||
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
||||
const claims = extractExpenseClaimItems(claimsPayload)
|
||||
const match = aiAttachmentAssociationModel.resolveAiAttachmentAssociationMatch(claims, collected.ocrDocuments)
|
||||
const associationRecord = match.best?.record || match.recommended?.record || null
|
||||
const associationId = associationRecord?.claimId
|
||||
? createAiAttachmentAssociationId()
|
||||
: ''
|
||||
if (associationId) {
|
||||
aiAttachmentAssociationRuntime.set(associationId, {
|
||||
files,
|
||||
fileNames: files.map((file) => file?.name || '').filter(Boolean),
|
||||
claimId: String(associationRecord.claimId || '').trim(),
|
||||
claimNo: String(associationRecord.claimNo || '').trim(),
|
||||
ocrPayload: collected.ocrPayload,
|
||||
ocrSummary: collected.ocrSummary,
|
||||
ocrDocuments: collected.ocrDocuments,
|
||||
ocrFilePreviews: collected.ocrFilePreviews
|
||||
})
|
||||
}
|
||||
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationMessage({
|
||||
match,
|
||||
fileNames: files.map((file) => file?.name || ''),
|
||||
ocrDocuments: collected.ocrDocuments
|
||||
})
|
||||
await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText)
|
||||
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('completed')
|
||||
},
|
||||
attachmentOcrDetails,
|
||||
suggestedActions: aiAttachmentAssociationModel.buildAiAttachmentAssociationActions(match, associationId, {
|
||||
includeOcrDetails: Boolean(attachmentOcrDetails)
|
||||
})
|
||||
})
|
||||
)
|
||||
persistCurrentConversation()
|
||||
} catch (error) {
|
||||
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
||||
const finalMessageText = error?.message || '票据识别或单据匹配失败,请稍后再试。'
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('failed')
|
||||
}
|
||||
})
|
||||
)
|
||||
toast(finalMessageText)
|
||||
persistCurrentConversation()
|
||||
} finally {
|
||||
sending.value = false
|
||||
scrollInlineConversationToBottom({ force: shouldAutoScrollOnFinish })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
collectAiModeReceiptContext,
|
||||
confirmAiAttachmentAssociation,
|
||||
requestAiAttachmentAssociationReply,
|
||||
resolveAiAttachmentAssociationClaimNo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
buildFileIdentity,
|
||||
MAX_ATTACHMENTS,
|
||||
mergeFilesWithLimit
|
||||
} from '../../views/scripts/travelReimbursementAttachmentModel.js'
|
||||
|
||||
export function useWorkbenchAiComposerFiles({
|
||||
fileInputRef,
|
||||
focusAiModeInput,
|
||||
isInputLocked,
|
||||
selectedFiles,
|
||||
toast
|
||||
}) {
|
||||
function triggerAiModeFileUpload() {
|
||||
if (isInputLocked()) {
|
||||
toast('请等待费用测算完成后再继续操作。')
|
||||
return
|
||||
}
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function handleAiModeFilesChange(event) {
|
||||
const fileMergeResult = mergeFilesWithLimit(selectedFiles.value, Array.from(event.target.files || []), MAX_ATTACHMENTS)
|
||||
selectedFiles.value = fileMergeResult.files
|
||||
if (fileMergeResult.overflowCount > 0) {
|
||||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
|
||||
}
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
focusAiModeInput()
|
||||
}
|
||||
|
||||
function removeAiModeFile(fileKey) {
|
||||
selectedFiles.value = selectedFiles.value.filter((file) => buildFileIdentity(file) !== fileKey)
|
||||
if (!selectedFiles.value.length && fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
focusAiModeInput()
|
||||
}
|
||||
|
||||
function clearAiModeFiles() {
|
||||
selectedFiles.value = []
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clearAiModeFiles,
|
||||
handleAiModeFilesChange,
|
||||
removeAiModeFile,
|
||||
triggerAiModeFileUpload
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import {
|
||||
buildAiDocumentQueryConditionSummary,
|
||||
buildAiDocumentQueryMessage,
|
||||
filterAiDocumentQueryRecords,
|
||||
mergeAiDocumentQueryPayloads,
|
||||
resolveAiDocumentQueryIntent
|
||||
} from '../../utils/aiDocumentQueryModel.js'
|
||||
import {
|
||||
extractExpenseClaimItems,
|
||||
fetchApprovalExpenseClaims,
|
||||
fetchExpenseClaims
|
||||
} from '../../services/reimbursements.js'
|
||||
|
||||
const AI_DOCUMENT_QUERY_STEP_DELAY_MS = 320
|
||||
|
||||
function waitForAiDocumentQueryStep() {
|
||||
return new Promise((resolve) => {
|
||||
globalThis.setTimeout(resolve, AI_DOCUMENT_QUERY_STEP_DELAY_MS)
|
||||
})
|
||||
}
|
||||
|
||||
function completeAiDocumentQueryEvent(events, eventId, content = '') {
|
||||
return events.map((event) => (
|
||||
event.eventId === eventId
|
||||
? {
|
||||
...event,
|
||||
content: content || event.content,
|
||||
status: 'completed'
|
||||
}
|
||||
: event
|
||||
))
|
||||
}
|
||||
|
||||
function failAiDocumentQueryEvents(events) {
|
||||
return events.map((event) => ({
|
||||
...event,
|
||||
status: event.status === 'completed' ? 'completed' : 'failed'
|
||||
}))
|
||||
}
|
||||
|
||||
function resolveAiDocumentQueryFetchPendingText(intent = {}) {
|
||||
if (intent.source === 'approval') {
|
||||
return '等待调用待我审核单据接口。'
|
||||
}
|
||||
if (intent.source === 'mine') {
|
||||
return '等待调用我名下单据接口。'
|
||||
}
|
||||
return '等待同时调用我名下单据和待我审核单据接口。'
|
||||
}
|
||||
|
||||
function resolveAiDocumentQueryFetchRunningText(intent = {}) {
|
||||
if (intent.source === 'approval') {
|
||||
return '正在查询待我审核的单据,接口范围为待办/待审单据列表。'
|
||||
}
|
||||
if (intent.source === 'mine') {
|
||||
return '正在查询我名下的单据,接口范围为当前用户本人单据列表。'
|
||||
}
|
||||
return '正在查询我可见的单据,接口范围包含我名下单据和待我审核单据列表。'
|
||||
}
|
||||
|
||||
async function fetchAiDocumentQueryPayload(intent = {}) {
|
||||
const requestParams = { page: 1, pageSize: 100 }
|
||||
if (intent.source === 'approval') {
|
||||
return fetchApprovalExpenseClaims(requestParams)
|
||||
}
|
||||
if (intent.source === 'mine') {
|
||||
return fetchExpenseClaims(requestParams)
|
||||
}
|
||||
const [ownPayload, approvalPayload] = await Promise.all([
|
||||
fetchExpenseClaims(requestParams),
|
||||
fetchApprovalExpenseClaims(requestParams)
|
||||
])
|
||||
return mergeAiDocumentQueryPayloads(
|
||||
ownPayload,
|
||||
{
|
||||
items: extractExpenseClaimItems(approvalPayload),
|
||||
querySource: 'approval'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function useWorkbenchAiDocumentQueryFlow({
|
||||
conversationMessages,
|
||||
createInlineMessage,
|
||||
inlineConversationAutoScrollPinned,
|
||||
persistCurrentConversation,
|
||||
replaceInlineMessage,
|
||||
scrollInlineConversationToBottom
|
||||
}) {
|
||||
async function updateAiDocumentQueryThinking(pendingMessage, thinkingEvents, streamStatus = 'streaming') {
|
||||
const message = conversationMessages.value.find((item) => item.id === pendingMessage.id) || pendingMessage
|
||||
message.stewardPlan = {
|
||||
...(message.stewardPlan || {}),
|
||||
streamStatus,
|
||||
thinkingEvents
|
||||
}
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
async function handleAiDocumentQueryIntent(prompt, pendingMessage) {
|
||||
const intent = resolveAiDocumentQueryIntent(prompt)
|
||||
if (!intent) {
|
||||
return false
|
||||
}
|
||||
|
||||
const conditionSummary = buildAiDocumentQueryConditionSummary(intent)
|
||||
let thinkingEvents = [
|
||||
{
|
||||
eventId: 'document-query-parse',
|
||||
title: '解析自然语言筛选条件',
|
||||
content: `正在从你的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}。`,
|
||||
status: 'running'
|
||||
},
|
||||
{
|
||||
eventId: 'document-query-fetch',
|
||||
title: '查询业务单据接口',
|
||||
content: resolveAiDocumentQueryFetchPendingText(intent),
|
||||
status: 'pending'
|
||||
},
|
||||
{
|
||||
eventId: 'document-query-filter',
|
||||
title: '组合筛选单据',
|
||||
content: '等待接口返回后,再按已识别条件做二次筛选。',
|
||||
status: 'pending'
|
||||
}
|
||||
]
|
||||
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
||||
await waitForAiDocumentQueryStep()
|
||||
|
||||
thinkingEvents = completeAiDocumentQueryEvent(thinkingEvents, 'document-query-parse')
|
||||
thinkingEvents = thinkingEvents.map((event) => (
|
||||
event.eventId === 'document-query-fetch'
|
||||
? {
|
||||
...event,
|
||||
content: resolveAiDocumentQueryFetchRunningText(intent),
|
||||
status: 'running'
|
||||
}
|
||||
: event
|
||||
))
|
||||
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
||||
|
||||
try {
|
||||
const payload = await fetchAiDocumentQueryPayload(intent)
|
||||
const rawCount = extractExpenseClaimItems(payload).length
|
||||
const filteredRecords = filterAiDocumentQueryRecords(payload, intent)
|
||||
thinkingEvents = completeAiDocumentQueryEvent(
|
||||
thinkingEvents,
|
||||
'document-query-fetch',
|
||||
`接口返回 ${rawCount} 张候选单据,开始按自然语言条件筛选。`
|
||||
)
|
||||
thinkingEvents = thinkingEvents.map((event) => (
|
||||
event.eventId === 'document-query-filter'
|
||||
? {
|
||||
...event,
|
||||
content: `按“${conditionSummary}”组合筛选,当前命中 ${filteredRecords.length} 张。`,
|
||||
status: 'running'
|
||||
}
|
||||
: event
|
||||
))
|
||||
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
||||
await waitForAiDocumentQueryStep()
|
||||
|
||||
const finalMessageText = buildAiDocumentQueryMessage(intent, payload)
|
||||
thinkingEvents = completeAiDocumentQueryEvent(
|
||||
thinkingEvents,
|
||||
'document-query-filter',
|
||||
`筛选完成,命中 ${filteredRecords.length} 张单据,已生成卡片结果。`
|
||||
)
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents
|
||||
},
|
||||
suggestedActions: []
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
const finalMessageText = error?.message || '查询单据时出现异常,请稍后再试。'
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: failAiDocumentQueryEvents(thinkingEvents)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
persistCurrentConversation()
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
handleAiDocumentQueryIntent
|
||||
}
|
||||
}
|
||||
186
web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js
Normal file
186
web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||
import {
|
||||
applyAiExpenseAnswer,
|
||||
buildAiExpenseStepPrompt,
|
||||
buildAiExpenseSummary,
|
||||
createAiExpenseDraft,
|
||||
isAiExpenseDraftComplete
|
||||
} from '../../utils/aiExpenseDraftModel.js'
|
||||
import {
|
||||
buildExpenseSceneSelectionMessage,
|
||||
SESSION_TYPE_EXPENSE
|
||||
} from '../../views/scripts/travelReimbursementConversationModel.js'
|
||||
import { buildExpenseSceneSelectionActions } from '../../utils/expenseAssistantActions.js'
|
||||
import {
|
||||
buildRequiredApplicationActions,
|
||||
buildRequiredApplicationMissingText,
|
||||
buildRequiredApplicationSelectionText,
|
||||
filterRequiredApplicationCandidates
|
||||
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
||||
|
||||
export { SESSION_TYPE_EXPENSE }
|
||||
|
||||
export function useWorkbenchAiExpenseFlow({
|
||||
activateInlineConversation,
|
||||
aiExpenseDraft,
|
||||
assistantDraft,
|
||||
clearAiModeFiles,
|
||||
closeWorkbenchDatePicker,
|
||||
conversationMessages,
|
||||
conversationStarted,
|
||||
createInlineMessage,
|
||||
currentUser,
|
||||
persistCurrentConversation,
|
||||
pushInlineUserMessage,
|
||||
removeWorkbenchDateTag,
|
||||
resolveLatestInlineUserPrompt,
|
||||
scrollInlineConversationToBottom,
|
||||
startAiApplicationPreview
|
||||
}) {
|
||||
function pushInlineExpenseSceneSelectionPrompt(originalMessage, selectedLabel = '') {
|
||||
const sourceText = String(originalMessage || '我要报销').trim()
|
||||
if (!conversationStarted.value) {
|
||||
activateInlineConversation({
|
||||
title: String(selectedLabel || sourceText || '报销').trim().slice(0, 18) || '报销'
|
||||
})
|
||||
}
|
||||
assistantDraft.value = ''
|
||||
removeWorkbenchDateTag()
|
||||
closeWorkbenchDatePicker()
|
||||
conversationMessages.value.push(createInlineMessage('user', String(selectedLabel || sourceText).trim()))
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), {
|
||||
suggestedActions: buildExpenseSceneSelectionActions(sourceText)
|
||||
}))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
function startAiApplicationPreviewFromAction(payload = {}, fallbackLabel = '') {
|
||||
const expenseType = String(payload.expense_type || '').trim()
|
||||
const expenseTypeLabel = String(payload.expense_type_label || fallbackLabel || '').trim()
|
||||
return startAiApplicationPreview(
|
||||
expenseType,
|
||||
expenseTypeLabel,
|
||||
payload.carry_text || resolveLatestInlineUserPrompt()
|
||||
)
|
||||
}
|
||||
|
||||
function startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement) {
|
||||
if (!conversationStarted.value) {
|
||||
activateInlineConversation({ title: String(expenseTypeLabel || '报销').trim().slice(0, 18) || '报销' })
|
||||
}
|
||||
assistantDraft.value = ''
|
||||
removeWorkbenchDateTag()
|
||||
closeWorkbenchDatePicker()
|
||||
clearAiModeFiles()
|
||||
pushInlineUserMessage(`选择${expenseTypeLabel || expenseType || '报销'}`)
|
||||
|
||||
if (requiresApplicationBeforeReimbursement) {
|
||||
void resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel)
|
||||
return
|
||||
}
|
||||
|
||||
const draft = createAiExpenseDraft(expenseType, expenseTypeLabel)
|
||||
aiExpenseDraft.value = draft
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(draft)))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
function advanceAiExpenseDraft(answer, files = []) {
|
||||
const fileNames = Array.from(files || [])
|
||||
pushInlineUserMessage(answer || (fileNames.length ? `上传 ${fileNames.length} 份附件` : ''))
|
||||
assistantDraft.value = ''
|
||||
clearAiModeFiles()
|
||||
|
||||
const next = applyAiExpenseAnswer(aiExpenseDraft.value, answer, fileNames)
|
||||
aiExpenseDraft.value = next
|
||||
|
||||
if (isAiExpenseDraftComplete(next)) {
|
||||
conversationMessages.value.push(createInlineMessage('assistant', `${buildAiExpenseSummary(next)}\n\n如果哪一项需要修改,直接告诉我;确认无误后我再帮你生成报销草稿。`))
|
||||
aiExpenseDraft.value = null
|
||||
} else {
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(next)))
|
||||
}
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) {
|
||||
let claims = null
|
||||
try {
|
||||
claims = await fetchExpenseClaims()
|
||||
} catch {
|
||||
aiExpenseDraft.value = null
|
||||
conversationMessages.value.push(createInlineMessage('assistant', '查询可关联申请单时出现异常,请稍后再试,我先暂停这次报销流程。'))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
const candidates = filterRequiredApplicationCandidates(claims, expenseType, currentUser.value || {})
|
||||
aiExpenseDraft.value = createAiExpenseDraft(expenseType, expenseTypeLabel)
|
||||
|
||||
if (!candidates.length) {
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationMissingText(expenseType), {
|
||||
suggestedActions: [{
|
||||
label: '确认发起出差申请',
|
||||
description: '生成完整申请表,并预填已识别的时间、地点和事由',
|
||||
icon: 'mdi mdi-file-plus-outline',
|
||||
action_type: 'ai_application_start_inline',
|
||||
payload: {
|
||||
expense_type: expenseType,
|
||||
expense_type_label: expenseTypeLabel
|
||||
}
|
||||
}]
|
||||
}))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationSelectionText(expenseType, candidates), {
|
||||
suggestedActions: buildRequiredApplicationActions(candidates, 'select_required_application')
|
||||
}))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
function linkAiExpenseApplication(application = {}) {
|
||||
const draft = aiExpenseDraft.value
|
||||
if (!draft) {
|
||||
return
|
||||
}
|
||||
const claimNo = String(application.application_claim_no || '').trim()
|
||||
pushInlineUserMessage(`关联申请单 ${claimNo}`.trim())
|
||||
|
||||
const linked = {
|
||||
...draft,
|
||||
applicationClaim: application,
|
||||
values: {
|
||||
...draft.values,
|
||||
reason: String(application.application_reason || '').trim(),
|
||||
location: String(application.application_location || '').trim(),
|
||||
time_range: String(application.application_business_time || '').trim(),
|
||||
amount: String(application.application_amount_label || application.application_amount || '').trim()
|
||||
},
|
||||
stepKey: 'attachments'
|
||||
}
|
||||
aiExpenseDraft.value = linked
|
||||
conversationMessages.value.push(createInlineMessage('assistant', [
|
||||
`已关联申请单${claimNo ? ` ${claimNo}` : ''},事由、时间、地点、金额我先用申请单的内容预填了。`,
|
||||
'',
|
||||
'再确认一下票据:可以现在上传,或回复“稍后上传”。'
|
||||
].join('\n')))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
return {
|
||||
advanceAiExpenseDraft,
|
||||
linkAiExpenseApplication,
|
||||
pushInlineExpenseSceneSelectionPrompt,
|
||||
startAiApplicationPreviewFromAction,
|
||||
startAiExpenseDraft
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export function useWorkbenchAiMessageActions({
|
||||
assistantDraft,
|
||||
focusAiModeInput,
|
||||
persistCurrentConversation,
|
||||
toast
|
||||
}) {
|
||||
async function copyInlineMessage(message) {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(message.content)
|
||||
toast('已复制内容。')
|
||||
} catch {
|
||||
toast('当前浏览器暂不支持自动复制。')
|
||||
}
|
||||
}
|
||||
|
||||
function quoteInlineMessage(message) {
|
||||
const quote = `> ${message.content}\n\n`
|
||||
assistantDraft.value = assistantDraft.value ? assistantDraft.value + '\n' + quote : quote
|
||||
focusAiModeInput()
|
||||
}
|
||||
|
||||
function markInlineMessageFeedback(message, feedback) {
|
||||
message.feedback = feedback
|
||||
persistCurrentConversation()
|
||||
toast(feedback === 'up' ? '已记录有帮助反馈。' : '已记录需要改进反馈。')
|
||||
}
|
||||
|
||||
return {
|
||||
copyInlineMessage,
|
||||
markInlineMessageFeedback,
|
||||
quoteInlineMessage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
export function useWorkbenchAiSessionCommands({
|
||||
activeConversationTitle,
|
||||
attachmentOcrExpandedMessageIds,
|
||||
conversationId,
|
||||
conversationMessages,
|
||||
conversationStarted,
|
||||
createInlineMessage,
|
||||
currentUser,
|
||||
deleteAiWorkbenchConversation,
|
||||
emit,
|
||||
focusAiModeInput,
|
||||
inlineConversationAutoScrollPinned,
|
||||
normalizeRuntimeMessage,
|
||||
refreshConversationHistory,
|
||||
resetInlineConversationState,
|
||||
scrollInlineConversationToBottom,
|
||||
stewardState,
|
||||
thinkingCollapsedMessageIds,
|
||||
thinkingExpandedMessageIds,
|
||||
toast
|
||||
}) {
|
||||
function startNewInlineConversation() {
|
||||
resetInlineConversationState()
|
||||
emit('conversation-change', { id: '', title: '' })
|
||||
refreshConversationHistory()
|
||||
focusAiModeInput()
|
||||
}
|
||||
|
||||
function openInlineSearchConversation(activateInlineConversation) {
|
||||
conversationMessages.value = [
|
||||
createInlineMessage('assistant', '你可以输入关键词搜索历史对话,也可以直接描述要继续处理的费用事项。')
|
||||
]
|
||||
stewardState.value = null
|
||||
thinkingExpandedMessageIds.value = new Set()
|
||||
thinkingCollapsedMessageIds.value = new Set()
|
||||
attachmentOcrExpandedMessageIds.value = new Set()
|
||||
conversationId.value = 'ai-search'
|
||||
activateInlineConversation({ id: 'ai-search', title: '查询对话' })
|
||||
focusAiModeInput()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
function openInlineRecentConversation(item = {}) {
|
||||
const title = String(item.title || '最近对话').trim()
|
||||
conversationId.value = String(item.id || `recent-${Date.now()}`).trim()
|
||||
activeConversationTitle.value = title
|
||||
stewardState.value = item.stewardState || null
|
||||
thinkingExpandedMessageIds.value = new Set()
|
||||
thinkingCollapsedMessageIds.value = new Set()
|
||||
attachmentOcrExpandedMessageIds.value = new Set()
|
||||
inlineConversationAutoScrollPinned.value = true
|
||||
conversationMessages.value = Array.isArray(item.messages) && item.messages.length
|
||||
? item.messages.map((message) => normalizeRuntimeMessage(message))
|
||||
: [
|
||||
createInlineMessage(
|
||||
'assistant',
|
||||
'这条历史对话没有保存完整消息。你可以继续输入新的问题,小财管家会接着处理。'
|
||||
)
|
||||
]
|
||||
conversationStarted.value = true
|
||||
emit('conversation-change', { id: conversationId.value, title })
|
||||
focusAiModeInput()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
function requestDeleteCurrentConversation(deleteDialogOpen) {
|
||||
if (!conversationMessages.value.length) {
|
||||
return
|
||||
}
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
function cancelDeleteConversation(deleteDialogOpen) {
|
||||
deleteDialogOpen.value = false
|
||||
}
|
||||
|
||||
function confirmDeleteConversation(deleteDialogOpen) {
|
||||
const nextHistory = conversationId.value
|
||||
? deleteAiWorkbenchConversation(currentUser.value || {}, conversationId.value)
|
||||
: refreshConversationHistory()
|
||||
emit('conversation-history-change', nextHistory)
|
||||
resetInlineConversationState()
|
||||
deleteDialogOpen.value = false
|
||||
emit('conversation-change', { id: '', title: '' })
|
||||
toast('已删除当前对话。')
|
||||
focusAiModeInput()
|
||||
}
|
||||
|
||||
return {
|
||||
cancelDeleteConversation,
|
||||
confirmDeleteConversation,
|
||||
openInlineRecentConversation,
|
||||
openInlineSearchConversation,
|
||||
requestDeleteCurrentConversation,
|
||||
startNewInlineConversation
|
||||
}
|
||||
}
|
||||
371
web/src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js
Normal file
371
web/src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js
Normal file
@@ -0,0 +1,371 @@
|
||||
import {
|
||||
fetchStewardPlan,
|
||||
fetchStewardPlanStream
|
||||
} from '../../services/steward.js'
|
||||
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||
import {
|
||||
buildStewardPlanMessageText,
|
||||
buildStewardPlanRequest,
|
||||
buildStewardSuggestedActions,
|
||||
normalizeStewardPlan
|
||||
} from '../../views/scripts/stewardPlanModel.js'
|
||||
import {
|
||||
buildRequiredApplicationActions,
|
||||
buildRequiredApplicationMissingText,
|
||||
buildRequiredApplicationSelectionText,
|
||||
filterRequiredApplicationCandidates
|
||||
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
||||
|
||||
function shouldCheckAiRequiredApplicationGate(prompt) {
|
||||
const compact = String(prompt || '').replace(/\s+/g, '')
|
||||
if (!compact || !/(出差|差旅|部署|实施|支撑|支持|协助|拜访|调研|驻场|上线|验收)/.test(compact)) {
|
||||
return false
|
||||
}
|
||||
if (!/\d{1,2}月\d{1,2}|昨天|前天|上周|上月/.test(compact)) {
|
||||
return false
|
||||
}
|
||||
return !/(申请|报销|草稿|提交|审批|保存|发起|创建)/.test(compact)
|
||||
}
|
||||
|
||||
function serializeRequiredApplicationCandidate(candidate = {}) {
|
||||
return {
|
||||
id: String(candidate.id || '').trim(),
|
||||
claim_no: String(candidate.claim_no || '').trim(),
|
||||
reason: String(candidate.reason || '').trim(),
|
||||
location: String(candidate.location || '').trim(),
|
||||
business_time: String(candidate.business_time || '').trim(),
|
||||
status_label: String(candidate.status_label || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRequiredApplicationGateContinuationFlow(normalizedPlan) {
|
||||
if (String(normalizedPlan?.pendingFlowConfirmation?.status || '').trim() !== 'pending') {
|
||||
return null
|
||||
}
|
||||
const flows = Array.isArray(normalizedPlan?.candidateFlows) ? normalizedPlan.candidateFlows : []
|
||||
const applicationFlow = flows.find((flow) => flow.flowId === 'travel_application')
|
||||
if (flows.length === 1 && applicationFlow && /先发起出差申请/.test(applicationFlow.label)) {
|
||||
return applicationFlow
|
||||
}
|
||||
return flows.find((flow) => (
|
||||
flow.flowId === 'travel_reimbursement' &&
|
||||
/关联已有申请单/.test(flow.label)
|
||||
)) || null
|
||||
}
|
||||
|
||||
function buildAiRequiredApplicationGateAutoMessage(normalizedPlan, flow) {
|
||||
const baseText = buildStewardPlanMessageText({
|
||||
planStatus: normalizedPlan?.planStatus,
|
||||
nextAction: normalizedPlan?.nextAction,
|
||||
summary: normalizedPlan?.summary,
|
||||
pendingFlowConfirmation: normalizedPlan?.pendingFlowConfirmation,
|
||||
candidateFlows: normalizedPlan?.candidateFlows
|
||||
})
|
||||
const contextText = String(baseText || '')
|
||||
.split(/\n\n1\. \*\*/)[0]
|
||||
.trim()
|
||||
.replace('### 需要先确认流程方向', '### 我已先查询申请单')
|
||||
if (flow?.flowId === 'travel_application') {
|
||||
return [
|
||||
contextText || baseText,
|
||||
'这类操作需要你手动确认。请点击下方 **确认发起出差申请**,我再在当前对话里生成完整申请表,并把已识别的信息自动预填。'
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
if (flow?.flowId === 'travel_reimbursement') {
|
||||
return [
|
||||
contextText || baseText,
|
||||
'这类操作需要你手动确认。请点击下方 **确认关联已有申请单**,我再继续查询并展示可关联单据。'
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
return baseText
|
||||
}
|
||||
|
||||
function buildAiRequiredApplicationGateSuggestedActions(flow, prompt = '') {
|
||||
if (flow.flowId === 'travel_application') {
|
||||
return [{
|
||||
label: '确认发起出差申请',
|
||||
description: '确认后生成完整申请表,并预填已识别的时间、地点和事由。',
|
||||
icon: 'mdi mdi-file-plus-outline',
|
||||
action_type: 'ai_application_start_inline',
|
||||
payload: {
|
||||
expense_type: 'travel',
|
||||
expense_type_label: '差旅费',
|
||||
carry_text: prompt
|
||||
}
|
||||
}]
|
||||
}
|
||||
if (flow.flowId === 'travel_reimbursement') {
|
||||
return [{
|
||||
label: '确认关联已有申请单',
|
||||
description: '确认后查询你名下可关联的差旅申请单,并进入关联步骤。',
|
||||
icon: 'mdi mdi-link-variant',
|
||||
action_type: 'steward_confirm_flow',
|
||||
payload: {
|
||||
steward_confirm_flow: true,
|
||||
flow_id: 'travel_reimbursement',
|
||||
expense_type: 'travel',
|
||||
expense_type_label: '差旅费',
|
||||
carry_text: prompt
|
||||
}
|
||||
}]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function normalizeStreamThinkingEvent(event = {}) {
|
||||
const data = event?.data && typeof event.data === 'object' ? event.data : {}
|
||||
const eventId = String(data.event_id || data.eventId || data.stage || `thinking-${Date.now()}`).trim()
|
||||
return {
|
||||
eventId,
|
||||
stage: String(data.stage || '').trim(),
|
||||
title: String(data.title || '小财管家正在分析').trim(),
|
||||
content: String(data.content || '').trim(),
|
||||
status: String(data.status || 'running').trim() || 'running'
|
||||
}
|
||||
}
|
||||
|
||||
export function useWorkbenchAiStewardFlow({
|
||||
activeConversationTitle,
|
||||
collectAiModeReceiptContext,
|
||||
conversationId,
|
||||
conversationMessages,
|
||||
createInlineMessage,
|
||||
currentUser,
|
||||
deleteAiWorkbenchConversation,
|
||||
emit,
|
||||
handleAiDocumentQueryIntent,
|
||||
inlineConversationAutoScrollPinned,
|
||||
persistCurrentConversation,
|
||||
replaceInlineMessage,
|
||||
resolveInlineThinkingEvents,
|
||||
scrollInlineConversationToBottom,
|
||||
sending,
|
||||
stewardState,
|
||||
streamInlineAssistantContent,
|
||||
updateInlineMessageContent,
|
||||
appendInlineMessageContent,
|
||||
toast
|
||||
}) {
|
||||
async function attachAiRequiredApplicationGate(planRequest, prompt) {
|
||||
if (!shouldCheckAiRequiredApplicationGate(prompt)) {
|
||||
return planRequest
|
||||
}
|
||||
|
||||
try {
|
||||
const claims = await fetchExpenseClaims()
|
||||
const candidates = filterRequiredApplicationCandidates(claims, 'travel', currentUser.value || {})
|
||||
planRequest.context_json = {
|
||||
...(planRequest.context_json || {}),
|
||||
required_application_gate: {
|
||||
...((planRequest.context_json || {}).required_application_gate || {}),
|
||||
travel: {
|
||||
checked: true,
|
||||
candidate_count: candidates.length,
|
||||
candidates: candidates.slice(0, 5).map((candidate) => serializeRequiredApplicationCandidate(candidate))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('AI mode required application lookup failed:', error)
|
||||
planRequest.context_json = {
|
||||
...(planRequest.context_json || {}),
|
||||
required_application_gate: {
|
||||
...((planRequest.context_json || {}).required_application_gate || {}),
|
||||
travel: {
|
||||
checked: false,
|
||||
query_failed: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return planRequest
|
||||
}
|
||||
|
||||
function handleInlineStewardStreamEvent(messageId, event) {
|
||||
const message = conversationMessages.value.find((item) => item.id === messageId)
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event?.event === 'answer_delta') {
|
||||
const data = event?.data && typeof event.data === 'object' ? event.data : {}
|
||||
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
|
||||
appendInlineMessageContent(message, data.delta || data.content || data.text || '')
|
||||
message.stewardPlan = {
|
||||
...(message.stewardPlan || {}),
|
||||
streamStatus: 'streaming'
|
||||
}
|
||||
scrollInlineConversationToBottom({ force: shouldAutoScroll })
|
||||
return
|
||||
}
|
||||
|
||||
if (event?.event !== 'thinking') {
|
||||
return
|
||||
}
|
||||
|
||||
const nextEvent = normalizeStreamThinkingEvent(event)
|
||||
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
|
||||
const currentPlan = message.stewardPlan || {}
|
||||
const currentEvents = Array.isArray(currentPlan.thinkingEvents) ? currentPlan.thinkingEvents : []
|
||||
const eventIndex = currentEvents.findIndex((item) => item.eventId && item.eventId === nextEvent.eventId)
|
||||
const nextEvents = eventIndex >= 0
|
||||
? currentEvents.map((item, index) => (index === eventIndex ? { ...item, ...nextEvent } : item))
|
||||
: [...currentEvents, nextEvent]
|
||||
|
||||
message.stewardPlan = {
|
||||
...currentPlan,
|
||||
thinkingEvents: nextEvents,
|
||||
streamStatus: 'streaming'
|
||||
}
|
||||
scrollInlineConversationToBottom({ force: shouldAutoScroll })
|
||||
}
|
||||
|
||||
async function fetchInlineStewardPlan(messageId, payload) {
|
||||
try {
|
||||
return await fetchStewardPlanStream(
|
||||
payload,
|
||||
{
|
||||
onEvent: (event) => handleInlineStewardStreamEvent(messageId, event)
|
||||
},
|
||||
{
|
||||
idleTimeoutMs: 90000,
|
||||
timeoutMessage: '小财管家仍在规划任务,已停止等待。你可以稍后继续追问。'
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
if (String(error?.message || '').includes('流式服务')) {
|
||||
return fetchStewardPlan(payload, {
|
||||
timeoutMs: 75000,
|
||||
timeoutMessage: '小财管家仍在规划任务,已停止等待。你可以稍后继续追问。'
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function requestInlineAssistantReply(prompt, entry = {}, files = []) {
|
||||
let shouldAutoScrollOnFinish = true
|
||||
const pendingMessage = createInlineMessage('assistant', '', {
|
||||
pending: true,
|
||||
stewardPlan: {
|
||||
streamStatus: 'streaming',
|
||||
thinkingEvents: [
|
||||
{
|
||||
eventId: 'init',
|
||||
title: '小财管家正在接入业务流程',
|
||||
content: '正在识别你的意图、上下文和附件信息。',
|
||||
status: 'running'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
conversationMessages.value.push(pendingMessage)
|
||||
scrollInlineConversationToBottom()
|
||||
|
||||
try {
|
||||
if (await handleAiDocumentQueryIntent(prompt, pendingMessage)) {
|
||||
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
||||
return
|
||||
}
|
||||
|
||||
const receiptContext = await collectAiModeReceiptContext(files)
|
||||
const planRequest = buildStewardPlanRequest({
|
||||
rawText: prompt,
|
||||
files,
|
||||
currentUser: currentUser.value || {},
|
||||
conversationId: conversationId.value,
|
||||
stewardState: stewardState.value
|
||||
})
|
||||
planRequest.context_json = {
|
||||
...planRequest.context_json,
|
||||
entry_source: 'workbench_ai_inline',
|
||||
source: entry.source || 'workbench',
|
||||
attachment_names: receiptContext.attachmentNames,
|
||||
attachment_count: receiptContext.attachmentCount,
|
||||
ocr_summary: receiptContext.ocrSummary,
|
||||
ocr_documents: receiptContext.ocrDocuments,
|
||||
ocr_source_file_names: receiptContext.ocrSourceFileNames,
|
||||
...(receiptContext.ocrError ? { ocr_error: receiptContext.ocrError } : {})
|
||||
}
|
||||
await attachAiRequiredApplicationGate(planRequest, prompt)
|
||||
|
||||
const plan = await fetchInlineStewardPlan(pendingMessage.id, planRequest)
|
||||
const normalizedPlan = normalizeStewardPlan(plan, {
|
||||
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
|
||||
initialSummaryOnly: true
|
||||
})
|
||||
const previousThinkingEvents = resolveInlineThinkingEvents(pendingMessage)
|
||||
const nextThinkingEvents = normalizedPlan.thinkingEvents.length
|
||||
? normalizedPlan.thinkingEvents
|
||||
: previousThinkingEvents.map((item) => ({ ...item, status: 'completed' }))
|
||||
const previousConversationId = conversationId.value
|
||||
const nextConversationId = String(normalizedPlan.conversationId || '').trim()
|
||||
if (nextConversationId) {
|
||||
conversationId.value = nextConversationId
|
||||
emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
|
||||
if (previousConversationId && previousConversationId !== nextConversationId) {
|
||||
deleteAiWorkbenchConversation(currentUser.value || {}, previousConversationId)
|
||||
}
|
||||
}
|
||||
if (normalizedPlan.stewardState) {
|
||||
stewardState.value = normalizedPlan.stewardState
|
||||
}
|
||||
const requiredApplicationContinuationFlow = resolveRequiredApplicationGateContinuationFlow(normalizedPlan)
|
||||
const finalMessageText = requiredApplicationContinuationFlow
|
||||
? buildAiRequiredApplicationGateAutoMessage(normalizedPlan, requiredApplicationContinuationFlow)
|
||||
: buildStewardPlanMessageText(plan)
|
||||
const hasServerStreamedContent = Boolean(String(pendingMessage.content || '').trim())
|
||||
if (!hasServerStreamedContent) {
|
||||
await streamInlineAssistantContent(pendingMessage.id, finalMessageText)
|
||||
}
|
||||
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
...normalizedPlan,
|
||||
thinkingEvents: nextThinkingEvents,
|
||||
streamStatus: 'completed'
|
||||
},
|
||||
suggestedActions: requiredApplicationContinuationFlow
|
||||
? buildAiRequiredApplicationGateSuggestedActions(requiredApplicationContinuationFlow, prompt)
|
||||
: buildStewardSuggestedActions(plan)
|
||||
})
|
||||
)
|
||||
persistCurrentConversation()
|
||||
} catch (error) {
|
||||
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage(
|
||||
'assistant',
|
||||
error?.message || '小财管家暂时无法完成规划,请稍后再试。',
|
||||
{
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
|
||||
...item,
|
||||
status: 'failed'
|
||||
}))
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
toast(error?.message || '小财管家暂时无法完成规划。')
|
||||
persistCurrentConversation()
|
||||
} finally {
|
||||
sending.value = false
|
||||
scrollInlineConversationToBottom({ force: shouldAutoScrollOnFinish })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buildRequiredApplicationActions,
|
||||
buildRequiredApplicationMissingText,
|
||||
buildRequiredApplicationSelectionText,
|
||||
filterRequiredApplicationCandidates,
|
||||
requestInlineAssistantReply
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import {
|
||||
buildApplicationTemplatePreview,
|
||||
buildLocalApplicationPreview,
|
||||
normalizeApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { AI_APPLICATION_DETAIL_HREF_PREFIX } from '../../utils/aiDocumentDetailReference.js'
|
||||
import {
|
||||
buildAiApplicationPrecheckThinkingEvents,
|
||||
isAiApplicationPrecheckBlocking
|
||||
} from '../../utils/aiApplicationPrecheckModel.js'
|
||||
import { extractExpenseClaimItems } from '../../services/reimbursements.js'
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT
|
||||
} from '../../services/aiApplicationPreviewActions.js'
|
||||
|
||||
const INLINE_APPLICATION_STATUS_LABELS = {
|
||||
draft: '草稿',
|
||||
submitted: '审批中',
|
||||
pending: '待处理',
|
||||
approved: '已审批',
|
||||
completed: '已完成',
|
||||
archived: '已归档',
|
||||
returned: '已退回',
|
||||
rejected: '已驳回',
|
||||
pending_payment: '待付款',
|
||||
paid: '已付款'
|
||||
}
|
||||
|
||||
function normalizeInlineApplicationResultTableCell(value, fallback = '-') {
|
||||
const text = String(value || '')
|
||||
.replace(/\s*\n+\s*/g, ' ')
|
||||
.replace(/\|/g, '|')
|
||||
.trim()
|
||||
return text || fallback
|
||||
}
|
||||
|
||||
export function normalizeInlineApplicationStatusLabel(value, fallback = '') {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return fallback
|
||||
}
|
||||
return INLINE_APPLICATION_STATUS_LABELS[text.toLowerCase()] || text
|
||||
}
|
||||
|
||||
export function buildInlineApplicationActionDetailHref(reference = '') {
|
||||
const source = reference && typeof reference === 'object' ? reference : { reference }
|
||||
const claimId = String(source.claimId || source.claim_id || source.id || '').trim()
|
||||
const claimNo = String(source.claimNo || source.claim_no || source.documentNo || source.document_no || '').trim()
|
||||
const fallback = String(source.reference || '').trim()
|
||||
if (claimId || claimNo) {
|
||||
const params = new URLSearchParams()
|
||||
if (claimId) {
|
||||
params.set('claim_id', claimId)
|
||||
}
|
||||
if (claimNo) {
|
||||
params.set('claim_no', claimNo)
|
||||
}
|
||||
return `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(params.toString())}`
|
||||
}
|
||||
return fallback ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(fallback)}` : ''
|
||||
}
|
||||
|
||||
export function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
|
||||
const source = draftPayload && typeof draftPayload === 'object' ? draftPayload : {}
|
||||
const body = String(source.body || source.markdown || '').trim()
|
||||
const resolveBodyField = (labels = []) => {
|
||||
for (const label of labels) {
|
||||
const pattern = new RegExp(`${label}\\s*[::]\\s*([^\\n|]+)`, 'u')
|
||||
const match = body.match(pattern)
|
||||
if (match?.[1]) {
|
||||
return String(match[1]).replace(/\*\*/g, '').trim()
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
const startDate = String(source.start_date || source.startDate || source.trip_start_date || source.tripStartDate || '').trim()
|
||||
const endDate = String(source.end_date || source.endDate || source.trip_end_date || source.tripEndDate || '').trim()
|
||||
const dateText = String(
|
||||
source.business_time ||
|
||||
source.businessTime ||
|
||||
source.time ||
|
||||
source.occurred_at ||
|
||||
source.occurredAt ||
|
||||
source.apply_time ||
|
||||
source.applyTime ||
|
||||
''
|
||||
).trim()
|
||||
const rangeText = startDate && endDate && startDate !== endDate
|
||||
? `${startDate} 至 ${endDate}`
|
||||
: startDate || endDate
|
||||
return {
|
||||
claimNo: String(source.claim_no || source.claimNo || source.document_no || source.documentNo || '').trim(),
|
||||
claimId: String(source.claim_id || source.claimId || source.id || '').trim(),
|
||||
statusLabel: normalizeInlineApplicationStatusLabel(source.status_label || source.statusLabel || source.status),
|
||||
approvalStage: String(source.approval_stage || source.approvalStage || '').trim(),
|
||||
dateLabel: rangeText || dateText || resolveBodyField(['时间', '日期', '申请时间']) || '待补充',
|
||||
locationLabel: String(
|
||||
source.location ||
|
||||
source.application_location ||
|
||||
source.applicationLocation ||
|
||||
source.destination ||
|
||||
source.destination_city ||
|
||||
source.destinationCity ||
|
||||
''
|
||||
).trim() || resolveBodyField(['地点', '目的地']) || '待补充',
|
||||
reasonLabel: String(
|
||||
source.reason ||
|
||||
source.business_reason ||
|
||||
source.businessReason ||
|
||||
source.description ||
|
||||
source.title ||
|
||||
''
|
||||
).trim() || resolveBodyField(['事由', '事件', '申请事由']) || '待补充',
|
||||
amountLabel: String(
|
||||
source.amount ||
|
||||
source.application_amount ||
|
||||
source.applicationAmount ||
|
||||
source.estimated_amount ||
|
||||
source.estimatedAmount ||
|
||||
''
|
||||
).trim() || resolveBodyField(['金额', '预计金额', '申请金额']) || '-',
|
||||
documentTypeLabel: String(
|
||||
source.document_type_label ||
|
||||
source.documentTypeLabel ||
|
||||
source.application_type_label ||
|
||||
source.applicationTypeLabel ||
|
||||
source.expense_type_label ||
|
||||
source.expenseTypeLabel ||
|
||||
''
|
||||
).trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInlineApplicationResultTable(draftPayload = {}, options = {}) {
|
||||
const info = resolveInlineApplicationActionDocumentInfo(draftPayload)
|
||||
const reference = info.claimNo || info.claimId
|
||||
const href = buildInlineApplicationActionDetailHref(info)
|
||||
const actionText = href ? `[查看](${href})` : '-'
|
||||
const statusLabel = normalizeInlineApplicationStatusLabel(info.statusLabel, options.statusLabel)
|
||||
return [
|
||||
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 日期 | 地点 | 事由 | 金额 | 操作 |',
|
||||
'| --- | --- | --- | --- | --- | --- | --- | --- | --- |',
|
||||
`| ${normalizeInlineApplicationResultTableCell(info.documentTypeLabel || options.documentTypeLabel, '出差申请')} | ${normalizeInlineApplicationResultTableCell(reference)} | ${normalizeInlineApplicationResultTableCell(statusLabel)} | ${normalizeInlineApplicationResultTableCell(info.approvalStage || options.stageLabel)} | ${normalizeInlineApplicationResultTableCell(info.dateLabel)} | ${normalizeInlineApplicationResultTableCell(info.locationLabel)} | ${normalizeInlineApplicationResultTableCell(info.reasonLabel)} | ${normalizeInlineApplicationResultTableCell(info.amountLabel, '-')} | ${actionText} |`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function extractInlineApplicationDraftPayload(payload = {}) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
return result.draft_payload && typeof result.draft_payload === 'object'
|
||||
? result.draft_payload
|
||||
: payload?.draft_payload && typeof payload.draft_payload === 'object'
|
||||
? payload.draft_payload
|
||||
: null
|
||||
}
|
||||
|
||||
export function buildInlineApplicationPreviewActionResultText(actionType, payload = {}) {
|
||||
const draftPayload = extractInlineApplicationDraftPayload(payload) || {}
|
||||
const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
|
||||
const approvalStage = String(draftPayload.approval_stage || draftPayload.approvalStage || '').trim()
|
||||
if (actionType === AI_APPLICATION_ACTION_SUBMIT) {
|
||||
return [
|
||||
'### 申请单据已生成,并已进入审批流程',
|
||||
approvalStage ? `系统已推送到 **${approvalStage}**,当前节点:${approvalStage}。` : '系统已推送到审批流程,当前节点:审批中。',
|
||||
buildInlineApplicationResultTable(draftPayload, {
|
||||
statusLabel: '审批中',
|
||||
stageLabel: approvalStage || '直属领导审批',
|
||||
documentTypeLabel: '出差申请'
|
||||
}),
|
||||
'需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。'
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
return [
|
||||
'### 申请草稿已保存',
|
||||
claimNo ? `系统已保存当前申请草稿,草稿单号:**${claimNo}**。` : '系统已保存当前申请草稿。',
|
||||
buildInlineApplicationResultTable(draftPayload, {
|
||||
statusLabel: '草稿',
|
||||
stageLabel: '待提交',
|
||||
documentTypeLabel: '出差申请'
|
||||
}),
|
||||
'后续请点击卡片“操作”行的“查看”进入详情页继续核对。'
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
export function buildInlineApplicationDetailAction(draftPayload = {}) {
|
||||
const claimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim()
|
||||
if (!claimNo) {
|
||||
return []
|
||||
}
|
||||
return [{
|
||||
label: '查看单据详情',
|
||||
description: '打开刚生成的申请单详情。',
|
||||
icon: 'mdi mdi-open-in-new',
|
||||
action_type: 'open_application_detail',
|
||||
payload: {
|
||||
claim_no: claimNo,
|
||||
claim_id: String(draftPayload.claim_id || draftPayload.claimId || '').trim(),
|
||||
document_type: 'application'
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
export function resolveInlineApplicationPreviewActionFromText(text = '') {
|
||||
const normalized = String(text || '').replace(/\s+/g, '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) {
|
||||
return AI_APPLICATION_ACTION_SAVE_DRAFT
|
||||
}
|
||||
if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) {
|
||||
return AI_APPLICATION_ACTION_SUBMIT
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function normalizeInlineApplicationTypeLabel(expenseTypeLabel, fallback = '费用申请') {
|
||||
const label = String(expenseTypeLabel || '').trim()
|
||||
if (!label) {
|
||||
return fallback
|
||||
}
|
||||
if (label.endsWith('费用申请') || label.endsWith('申请')) {
|
||||
return label
|
||||
}
|
||||
if (label.endsWith('费用')) {
|
||||
return `${label}申请`
|
||||
}
|
||||
if (label.endsWith('费')) {
|
||||
return `${label.slice(0, -1)}费用申请`
|
||||
}
|
||||
return `${label}申请`
|
||||
}
|
||||
|
||||
export function buildInlineApplicationPreview(expenseTypeLabel, sourceText = '', currentUser = {}) {
|
||||
const rawText = String(sourceText || '').trim()
|
||||
const preview = rawText
|
||||
? buildLocalApplicationPreview(rawText, currentUser)
|
||||
: buildApplicationTemplatePreview(currentUser)
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
return normalizeApplicationPreview({
|
||||
...normalized,
|
||||
fields: {
|
||||
...(normalized.fields || {}),
|
||||
applicationType: normalizeInlineApplicationTypeLabel(
|
||||
expenseTypeLabel,
|
||||
normalized.fields?.applicationType || '费用申请'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveInlineApplicationDraftIdentity(payload = {}) {
|
||||
const source = payload && typeof payload === 'object' ? payload : {}
|
||||
return {
|
||||
claimId: String(source.claim_id || source.claimId || source.id || '').trim(),
|
||||
claimNo: String(source.claim_no || source.claimNo || source.document_no || source.documentNo || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function isSameInlineApplicationDraftClaim(claim = {}, draftPayload = {}) {
|
||||
const draftIdentity = resolveInlineApplicationDraftIdentity(draftPayload)
|
||||
if (!draftIdentity.claimId && !draftIdentity.claimNo) {
|
||||
return false
|
||||
}
|
||||
const claimIdentity = resolveInlineApplicationDraftIdentity(claim)
|
||||
return Boolean(
|
||||
(draftIdentity.claimId && claimIdentity.claimId && draftIdentity.claimId === claimIdentity.claimId) ||
|
||||
(draftIdentity.claimNo && claimIdentity.claimNo && draftIdentity.claimNo === claimIdentity.claimNo)
|
||||
)
|
||||
}
|
||||
|
||||
export function buildInlineApplicationSubmitPrecheckPayload(claimsPayload, draftPayload = {}) {
|
||||
const items = extractExpenseClaimItems(claimsPayload)
|
||||
.filter((claim) => !isSameInlineApplicationDraftClaim(claim, draftPayload))
|
||||
return { items }
|
||||
}
|
||||
|
||||
export function completeInlineThinkingEvents(events = []) {
|
||||
return events.map((event) => ({
|
||||
...event,
|
||||
status: event.status === 'failed' ? 'failed' : 'completed'
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildInitialInlineApplicationSubmitThinkingEvents() {
|
||||
return [
|
||||
{
|
||||
eventId: 'application-precheck-overlap',
|
||||
title: '核查同时间段申请单',
|
||||
content: '正在查询你名下可见申请单,检查是否存在相同或重叠日期。',
|
||||
status: 'running'
|
||||
},
|
||||
{
|
||||
eventId: 'application-precheck-budget',
|
||||
title: '评估预算与审批影响',
|
||||
content: '等待单据重叠核查完成后,继续评估预算占用和审批影响。',
|
||||
status: 'pending'
|
||||
},
|
||||
{
|
||||
eventId: 'application-submit',
|
||||
title: '提交申请单据',
|
||||
content: '等待提交前核查完成。',
|
||||
status: 'pending'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function buildInlineApplicationSubmitThinkingEvents(precheck = {}) {
|
||||
const blocked = isAiApplicationPrecheckBlocking(precheck)
|
||||
return buildAiApplicationPrecheckThinkingEvents(precheck).map((event) => {
|
||||
if (event.eventId !== 'application-precheck-form') {
|
||||
return event
|
||||
}
|
||||
return {
|
||||
eventId: 'application-submit',
|
||||
title: blocked ? '暂停提交申请' : '提交申请单据',
|
||||
content: blocked
|
||||
? '发现相同或重叠日期已有申请单,已暂停本次提交。'
|
||||
: '提交前核查通过,正在生成申请单据并推送审批流程。',
|
||||
status: blocked ? 'completed' : 'running'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildFailedInlineApplicationSubmitThinkingEvents(error) {
|
||||
return [
|
||||
{
|
||||
eventId: 'application-precheck-overlap',
|
||||
title: '核查同时间段申请单',
|
||||
content: `查询已有申请单失败:${String(error?.message || error || '未知错误')}`,
|
||||
status: 'failed'
|
||||
},
|
||||
{
|
||||
eventId: 'application-submit',
|
||||
title: '暂停提交申请',
|
||||
content: '因为未能完成提交前重复日期核查,系统没有提交本次申请。',
|
||||
status: 'failed'
|
||||
}
|
||||
]
|
||||
}
|
||||
100
web/src/composables/workbenchAiMode/workbenchAiComposerModel.js
Normal file
100
web/src/composables/workbenchAiMode/workbenchAiComposerModel.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import { buildFileIdentity } from '../../views/scripts/travelReimbursementAttachmentModel.js'
|
||||
|
||||
export const AI_COMPOSER_FILE_TYPE_META = {
|
||||
pdf: { label: 'PDF', icon: 'mdi mdi-file-pdf-box', tone: 'pdf' },
|
||||
image: { label: '图片', icon: 'mdi mdi-file-image-outline', tone: 'image' },
|
||||
spreadsheet: { label: '表格', icon: 'mdi mdi-file-excel-outline', tone: 'spreadsheet' },
|
||||
document: { label: '文档', icon: 'mdi mdi-file-document-outline', tone: 'document' },
|
||||
archive: { label: '压缩包', icon: 'mdi mdi-folder-zip-outline', tone: 'archive' },
|
||||
file: { label: '文件', icon: 'mdi mdi-file-outline', tone: 'file' }
|
||||
}
|
||||
|
||||
export const AI_MODE_ACTION_ITEMS = [
|
||||
{
|
||||
label: '发起报销',
|
||||
icon: 'mdi mdi-file-document-plus-outline',
|
||||
prompt: '帮我发起一笔报销,并检查需要准备哪些票据材料。',
|
||||
source: 'workbench',
|
||||
sessionType: 'expense'
|
||||
},
|
||||
{
|
||||
label: '查询预算',
|
||||
icon: 'mdi mdi-chart-pie-outline',
|
||||
prompt: '帮我查询当前预算余额和近期费用占用情况。',
|
||||
source: 'budget',
|
||||
sessionType: 'budget'
|
||||
},
|
||||
{
|
||||
label: '解释制度',
|
||||
icon: 'mdi mdi-book-open-page-variant-outline',
|
||||
prompt: '帮我解释公司报销制度,并列出这次需要注意的条款。',
|
||||
source: 'workbench',
|
||||
sessionType: 'knowledge'
|
||||
},
|
||||
{
|
||||
label: '催办审批',
|
||||
icon: 'mdi mdi-bell-ring-outline',
|
||||
prompt: '帮我查询待审批单据,并生成一段礼貌的催办说明。',
|
||||
source: 'workbench',
|
||||
sessionType: 'approval'
|
||||
}
|
||||
]
|
||||
|
||||
export function resolveAiComposerFileName(file) {
|
||||
return String(file?.name || '未命名附件').trim() || '未命名附件'
|
||||
}
|
||||
|
||||
export function resolveAiComposerFileType(file) {
|
||||
const fileName = resolveAiComposerFileName(file).toLowerCase()
|
||||
const mimeType = String(file?.type || '').toLowerCase()
|
||||
const extension = fileName.includes('.') ? fileName.split('.').pop() : ''
|
||||
if (extension === 'pdf' || mimeType.includes('pdf')) {
|
||||
return AI_COMPOSER_FILE_TYPE_META.pdf
|
||||
}
|
||||
if (/^(png|jpe?g|gif|webp|bmp|svg|heic)$/.test(extension) || mimeType.startsWith('image/')) {
|
||||
return AI_COMPOSER_FILE_TYPE_META.image
|
||||
}
|
||||
if (/^(xls|xlsx|csv|numbers)$/.test(extension) || mimeType.includes('spreadsheet') || mimeType.includes('excel')) {
|
||||
return AI_COMPOSER_FILE_TYPE_META.spreadsheet
|
||||
}
|
||||
if (/^(doc|docx|txt|md|pages)$/.test(extension) || mimeType.includes('word') || mimeType.includes('text')) {
|
||||
return AI_COMPOSER_FILE_TYPE_META.document
|
||||
}
|
||||
if (/^(zip|rar|7z|tar|gz)$/.test(extension) || mimeType.includes('zip') || mimeType.includes('compressed')) {
|
||||
return AI_COMPOSER_FILE_TYPE_META.archive
|
||||
}
|
||||
return AI_COMPOSER_FILE_TYPE_META.file
|
||||
}
|
||||
|
||||
export function buildSelectedFileCards(files = []) {
|
||||
return files.map((file) => ({
|
||||
key: buildFileIdentity(file),
|
||||
name: resolveAiComposerFileName(file),
|
||||
...resolveAiComposerFileType(file)
|
||||
}))
|
||||
}
|
||||
|
||||
export function isLikelyAiModeOcrFile(file = {}) {
|
||||
const name = String(file?.name || '').trim()
|
||||
const type = String(file?.type || '').trim()
|
||||
return /\.(pdf|jpe?g|png|webp|bmp)$/i.test(name) || /^(image\/|application\/pdf)/i.test(type)
|
||||
}
|
||||
|
||||
export function isLikelyReceiptAssociationFile(file = {}) {
|
||||
return isLikelyAiModeOcrFile(file)
|
||||
}
|
||||
|
||||
export function shouldKeepAiAttachmentInAssistantReply(prompt = '') {
|
||||
const compact = String(prompt || '').replace(/\s+/g, '')
|
||||
return /(OCR|ocr|识别|票面|票据内容|发票内容|文字|读一下|看一下)/.test(compact)
|
||||
}
|
||||
|
||||
export function shouldRunAiAttachmentAutoAssociation(entry = {}, files = [], prompt = '') {
|
||||
return Boolean(
|
||||
Array.isArray(files) &&
|
||||
files.length &&
|
||||
files.every((file) => isLikelyReceiptAssociationFile(file)) &&
|
||||
!shouldKeepAiAttachmentInAssistantReply(prompt) &&
|
||||
String(entry?.sessionType || '').trim() === 'steward'
|
||||
)
|
||||
}
|
||||
195
web/src/composables/workbenchAiMode/workbenchAiMessageModel.js
Normal file
195
web/src/composables/workbenchAiMode/workbenchAiMessageModel.js
Normal file
@@ -0,0 +1,195 @@
|
||||
export const AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION = 'confirm_ai_attachment_association'
|
||||
export const AI_ATTACHMENT_OCR_DETAIL_ACTION = 'show_ai_attachment_ocr_details'
|
||||
|
||||
function normalizeParagraphs(content) {
|
||||
return String(content || '')
|
||||
.split(/\n{2,}|\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function stripInlineAssociationMarkdown(value = '') {
|
||||
return String(value || '')
|
||||
.replace(/\*\*/g, '')
|
||||
.replace(/`/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function resolveLegacyAiAttachmentAssociationPayload(content = '') {
|
||||
const text = String(content || '')
|
||||
if (!/我已先识别票据,并(?:匹配到最可能的报销单|找到一张可能关联的报销单)/.test(text)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const claimNo = stripInlineAssociationMarkdown(
|
||||
text.match(/推荐关联[::]\s*([^\n]+)/u)?.[1] || ''
|
||||
)
|
||||
if (!claimNo) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
claim_no: claimNo,
|
||||
document_type: 'expense'
|
||||
}
|
||||
}
|
||||
|
||||
export function hydrateInlineAttachmentAssociationSuggestedActions(actions = [], content = '') {
|
||||
const safeActions = Array.isArray(actions) ? actions : []
|
||||
const hasConfirmAction = safeActions.some(
|
||||
(action) => String(action?.action_type || '').trim() === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION
|
||||
)
|
||||
if (hasConfirmAction) {
|
||||
return safeActions
|
||||
}
|
||||
|
||||
const payload = resolveLegacyAiAttachmentAssociationPayload(content)
|
||||
if (!payload) {
|
||||
return safeActions
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: '确认自动关联',
|
||||
description: '将本次票据自动归集到推荐单据。',
|
||||
icon: 'mdi mdi-link-variant',
|
||||
action_type: AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
|
||||
payload
|
||||
},
|
||||
...safeActions
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeInlineAttachmentOcrField(field = {}) {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return null
|
||||
}
|
||||
const value = String(field.value ?? field.text ?? '').trim()
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
label: String(field.label || field.key || field.name || '识别字段').trim() || '识别字段',
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInlineAttachmentOcrDocument(document = {}, index = 0) {
|
||||
const fields = (Array.isArray(document?.document_fields) ? document.document_fields : document?.fields || [])
|
||||
.map((field) => normalizeInlineAttachmentOcrField(field))
|
||||
.filter(Boolean)
|
||||
.slice(0, 12)
|
||||
const summary = String(document?.summary || document?.text || '').replace(/\s+/g, ' ').trim()
|
||||
const filename = String(document?.filename || document?.name || '').trim()
|
||||
if (!filename && !summary && !fields.length) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
filename: filename || `附件 ${index + 1}`,
|
||||
summary,
|
||||
fields
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeInlineAttachmentOcrDetails(details = null) {
|
||||
if (!details || typeof details !== 'object') {
|
||||
return null
|
||||
}
|
||||
const documents = (Array.isArray(details.documents) ? details.documents : details.ocrDocuments || [])
|
||||
.map((document, index) => normalizeInlineAttachmentOcrDocument(document, index))
|
||||
.filter(Boolean)
|
||||
const fileNames = (Array.isArray(details.fileNames) ? details.fileNames : [])
|
||||
.map((name) => String(name || '').trim())
|
||||
.filter(Boolean)
|
||||
if (!documents.length && !fileNames.length) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
fileNames,
|
||||
documents
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInlineAttachmentOcrDetails(collected = {}, files = []) {
|
||||
return normalizeInlineAttachmentOcrDetails({
|
||||
fileNames: files.map((file) => file?.name || '').filter(Boolean),
|
||||
documents: collected?.ocrDocuments || []
|
||||
})
|
||||
}
|
||||
|
||||
export function formatMessageTime(timestamp) {
|
||||
if (!timestamp) return ''
|
||||
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export function createWorkbenchAiMessageRuntime() {
|
||||
let messageSeq = 0
|
||||
|
||||
function nextMessageId() {
|
||||
messageSeq += 1
|
||||
return `${Date.now()}-${messageSeq}`
|
||||
}
|
||||
|
||||
function createAiAttachmentAssociationId() {
|
||||
messageSeq += 1
|
||||
return `ai-attachment-${Date.now()}-${messageSeq}`
|
||||
}
|
||||
|
||||
function createInlineMessage(role, content, options = {}) {
|
||||
const normalizedContent = String(content || '').trim()
|
||||
const suggestedActions = Array.isArray(options.suggestedActions) ? options.suggestedActions : []
|
||||
return {
|
||||
id: options.id || nextMessageId(),
|
||||
role,
|
||||
content: normalizedContent,
|
||||
paragraphs: normalizeParagraphs(normalizedContent),
|
||||
pending: Boolean(options.pending),
|
||||
feedback: String(options.feedback || ''),
|
||||
stewardPlan: options.stewardPlan || null,
|
||||
suggestedActions: role === 'assistant'
|
||||
? hydrateInlineAttachmentAssociationSuggestedActions(suggestedActions, normalizedContent)
|
||||
: suggestedActions,
|
||||
applicationPreview: options.applicationPreview || null,
|
||||
draftPayload: options.draftPayload || null,
|
||||
attachmentOcrDetails: normalizeInlineAttachmentOcrDetails(options.attachmentOcrDetails || null),
|
||||
text: options.text || normalizedContent,
|
||||
createdAt: options.createdAt || Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRuntimeMessage(message = {}) {
|
||||
return createInlineMessage(message.role || 'assistant', message.content || '', {
|
||||
id: message.id,
|
||||
pending: false,
|
||||
feedback: message.feedback || '',
|
||||
stewardPlan: message.stewardPlan || null,
|
||||
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
draftPayload: message.draftPayload || null,
|
||||
attachmentOcrDetails: message.attachmentOcrDetails || null,
|
||||
text: message.text || message.content || ''
|
||||
})
|
||||
}
|
||||
|
||||
function serializeRuntimeMessage(message = {}) {
|
||||
return {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
text: message.text || message.content || '',
|
||||
feedback: message.feedback || '',
|
||||
stewardPlan: message.stewardPlan || null,
|
||||
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
draftPayload: message.draftPayload || null,
|
||||
attachmentOcrDetails: message.attachmentOcrDetails || null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createAiAttachmentAssociationId,
|
||||
createInlineMessage,
|
||||
normalizeRuntimeMessage,
|
||||
serializeRuntimeMessage
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user