feat(web): 工作台 AI 模式与差旅/风险建议交互优化
- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源 - 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore 及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿 - 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局 - 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
This commit is contained in:
79
web/src/utils/aiApplicationDraftModel.js
Normal file
79
web/src/utils/aiApplicationDraftModel.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// AI 模式下的申请单草稿模型。
|
||||
// 独立于旧的引导式报销/申请状态机,只在 AI 对话页内驱动逐项收集,
|
||||
// 不调用旧视图的申请模板/预览钩子,也不复用 buildLocalApplicationPreview。
|
||||
|
||||
const DEFAULT_FIELD_STEPS = [
|
||||
{ key: 'reason', label: '出差事由', prompt: '先告诉我这次出差的事由,例如:去上海支持上海电力部署项目。' },
|
||||
{ key: 'time_range', label: '出差时间/天数', prompt: '出差时间和天数是什么?例如:2026-06-20 至 2026-06-22,出差 3 天。' },
|
||||
{ key: 'location', label: '出差地点', prompt: '出差地点是哪里?可以填城市或具体客户地点。' },
|
||||
{ key: 'amount', label: '预计金额', prompt: '预计金额是多少?如果还没有汇总,可以回复“待核算”。' }
|
||||
]
|
||||
|
||||
const SUMMARY_STEP_KEY = 'summary'
|
||||
|
||||
function normalizeAnswer(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
export function getAiApplicationSteps() {
|
||||
return DEFAULT_FIELD_STEPS
|
||||
}
|
||||
|
||||
export function createAiApplicationDraft(expenseType, expenseTypeLabel) {
|
||||
return {
|
||||
expenseType: normalizeAnswer(expenseType),
|
||||
expenseTypeLabel: normalizeAnswer(expenseTypeLabel),
|
||||
values: {},
|
||||
stepKey: DEFAULT_FIELD_STEPS[0].key
|
||||
}
|
||||
}
|
||||
|
||||
export function getAiApplicationCurrentStep(draft) {
|
||||
const stepKey = normalizeAnswer(draft?.stepKey)
|
||||
return DEFAULT_FIELD_STEPS.find((step) => step.key === stepKey) || DEFAULT_FIELD_STEPS[0]
|
||||
}
|
||||
|
||||
export function buildAiApplicationStepPrompt(draft) {
|
||||
const step = getAiApplicationCurrentStep(draft)
|
||||
const label = normalizeAnswer(draft?.expenseTypeLabel) || '出差申请'
|
||||
const stepIndex = Math.max(0, DEFAULT_FIELD_STEPS.findIndex((item) => item.key === step.key))
|
||||
return [
|
||||
`好的,那我们先把${label}申请在当前对话里理一下。`,
|
||||
'',
|
||||
`第 ${stepIndex + 1} 步 · ${step.label}`,
|
||||
step.prompt
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function applyAiApplicationAnswer(draft, answer, files = []) {
|
||||
const current = draft && typeof draft === 'object' ? draft : createAiApplicationDraft()
|
||||
const step = getAiApplicationCurrentStep(current)
|
||||
const nextValues = { ...(current.values || {}) }
|
||||
nextValues[step.key] = normalizeAnswer(answer)
|
||||
|
||||
const currentIndex = DEFAULT_FIELD_STEPS.findIndex((item) => item.key === step.key)
|
||||
const nextStep = DEFAULT_FIELD_STEPS[currentIndex + 1]
|
||||
return {
|
||||
...current,
|
||||
values: nextValues,
|
||||
stepKey: nextStep ? nextStep.key : SUMMARY_STEP_KEY
|
||||
}
|
||||
}
|
||||
|
||||
export function isAiApplicationDraftComplete(draft) {
|
||||
return Boolean(draft) && normalizeAnswer(draft?.stepKey) === SUMMARY_STEP_KEY
|
||||
}
|
||||
|
||||
export function buildAiApplicationSummary(draft) {
|
||||
const label = normalizeAnswer(draft?.expenseTypeLabel) || '出差申请'
|
||||
const values = draft?.values || {}
|
||||
const lines = [`已完成「${label}」的要点收集,请核对:`, '']
|
||||
|
||||
DEFAULT_FIELD_STEPS.forEach((step) => {
|
||||
const value = normalizeAnswer(values[step.key])
|
||||
lines.push(`- ${step.label}:${value || '待补充'}`)
|
||||
})
|
||||
|
||||
lines.push('', '如果哪一项需要修改,直接告诉我;确认无误后我会帮你整理成申请草稿内容,再提交到申请助手生成单据。')
|
||||
return lines.join('\n')
|
||||
}
|
||||
113
web/src/utils/aiExpenseDraftModel.js
Normal file
113
web/src/utils/aiExpenseDraftModel.js
Normal file
@@ -0,0 +1,113 @@
|
||||
// AI 模式下的报销草稿模型。
|
||||
// 独立于旧的引导式报销状态机,只在 AI 对话页内驱动逐项收集,
|
||||
// 不调用 steward、不复用 guidedFlow 的编排流程。
|
||||
|
||||
const DEFAULT_FIELD_STEPS = [
|
||||
{ key: 'reason', label: '事由', prompt: '先告诉我这笔报销的事由,例如:项目现场支持、客户接待。' },
|
||||
{ key: 'time_range', label: '发生时间', prompt: '费用是什么时候发生的?例如:2026-06-15。' },
|
||||
{ key: 'location', label: '地点/对象', prompt: '费用发生的地点或对象是哪里?' },
|
||||
{ key: 'amount', label: '金额', prompt: '本次报销金额是多少?' },
|
||||
{ key: 'attachments', label: '票据', prompt: '票据可以现在上传,或回复“稍后上传”。' }
|
||||
]
|
||||
|
||||
const SUMMARY_STEP_KEY = 'summary'
|
||||
|
||||
function normalizeAnswer(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function normalizeFileNames(files) {
|
||||
return Array.from(files || [])
|
||||
.map((file) => String(file?.name || '').trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function getAiExpenseSteps() {
|
||||
return DEFAULT_FIELD_STEPS
|
||||
}
|
||||
|
||||
export function createAiExpenseDraft(expenseType, expenseTypeLabel) {
|
||||
return {
|
||||
expenseType: normalizeAnswer(expenseType),
|
||||
expenseTypeLabel: normalizeAnswer(expenseTypeLabel),
|
||||
applicationClaim: null,
|
||||
values: {},
|
||||
stepKey: DEFAULT_FIELD_STEPS[0].key
|
||||
}
|
||||
}
|
||||
|
||||
export function getAiExpenseCurrentStep(draft) {
|
||||
const stepKey = normalizeAnswer(draft?.stepKey)
|
||||
return DEFAULT_FIELD_STEPS.find((step) => step.key === stepKey) || DEFAULT_FIELD_STEPS[0]
|
||||
}
|
||||
|
||||
export function buildAiExpenseStepPrompt(draft) {
|
||||
const step = getAiExpenseCurrentStep(draft)
|
||||
const label = normalizeAnswer(draft?.expenseTypeLabel) || '报销'
|
||||
const stepIndex = Math.max(0, DEFAULT_FIELD_STEPS.findIndex((item) => item.key === step.key))
|
||||
return [
|
||||
`已选择「${label}」,我们逐项把信息理一下。`,
|
||||
'',
|
||||
`第 ${stepIndex + 1} 步 · ${step.label}`,
|
||||
step.prompt
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function applyAiExpenseAnswer(draft, answer, files = []) {
|
||||
const current = draft && typeof draft === 'object' ? draft : createAiExpenseDraft()
|
||||
const step = getAiExpenseCurrentStep(current)
|
||||
const nextValues = { ...(current.values || {}) }
|
||||
const fileNames = normalizeFileNames(files)
|
||||
|
||||
if (step.key === 'attachments') {
|
||||
if (fileNames.length) {
|
||||
nextValues.attachment_names = Array.from(
|
||||
new Set([...(nextValues.attachment_names || []), ...fileNames])
|
||||
)
|
||||
}
|
||||
nextValues.attachments = normalizeAnswer(answer)
|
||||
|| (nextValues.attachment_names?.length ? `已选择 ${nextValues.attachment_names.length} 份附件` : '稍后上传')
|
||||
} else {
|
||||
nextValues[step.key] = normalizeAnswer(answer)
|
||||
}
|
||||
|
||||
const currentIndex = DEFAULT_FIELD_STEPS.findIndex((item) => item.key === step.key)
|
||||
const nextStep = DEFAULT_FIELD_STEPS[currentIndex + 1]
|
||||
return {
|
||||
...current,
|
||||
values: nextValues,
|
||||
stepKey: nextStep ? nextStep.key : SUMMARY_STEP_KEY
|
||||
}
|
||||
}
|
||||
|
||||
export function isAiExpenseDraftComplete(draft) {
|
||||
return Boolean(draft) && normalizeAnswer(draft?.stepKey) === SUMMARY_STEP_KEY
|
||||
}
|
||||
|
||||
export function buildAiExpenseSummary(draft) {
|
||||
const label = normalizeAnswer(draft?.expenseTypeLabel) || '报销'
|
||||
const values = draft?.values || {}
|
||||
const application = draft?.applicationClaim || null
|
||||
const lines = [`已完成「${label}」的信息收集,请核对:`, '']
|
||||
|
||||
if (application && normalizeAnswer(application.application_claim_no)) {
|
||||
const parts = [
|
||||
application.application_claim_no,
|
||||
application.application_reason,
|
||||
application.application_business_time,
|
||||
application.application_location
|
||||
].map(normalizeAnswer).filter(Boolean)
|
||||
lines.push(`- 关联申请单:${parts.join(' / ')}`)
|
||||
}
|
||||
|
||||
DEFAULT_FIELD_STEPS.forEach((step) => {
|
||||
const value = step.key === 'attachments'
|
||||
? (values.attachment_names?.length
|
||||
? values.attachment_names.join('、')
|
||||
: normalizeAnswer(values.attachments) || '稍后上传')
|
||||
: normalizeAnswer(values[step.key])
|
||||
lines.push(`- ${step.label}:${value || '待补充'}`)
|
||||
})
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
81
web/src/utils/aiSidebarBusinessAccess.js
Normal file
81
web/src/utils/aiSidebarBusinessAccess.js
Normal file
@@ -0,0 +1,81 @@
|
||||
export const AI_SIDEBAR_BASE_BUSINESS_VIEW_IDS = ['documents', 'receiptFolder', 'policies']
|
||||
|
||||
const ROLE_VIEW_ADDITIONS = {
|
||||
budget: ['budget'],
|
||||
finance: ['overview', 'audit', 'digitalEmployees'],
|
||||
manager: ['overview', 'employees'],
|
||||
executive: ['budget', 'overview'],
|
||||
admin: ['budget', 'overview', 'audit', 'digitalEmployees', 'employees']
|
||||
}
|
||||
|
||||
function normalizeRoleCode(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
return normalized === 'auditor' ? 'budget_monitor' : normalized
|
||||
}
|
||||
|
||||
function normalizedRoleCodes(user = {}) {
|
||||
return Array.isArray(user.roleCodes)
|
||||
? user.roleCodes.map((item) => normalizeRoleCode(item)).filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
function normalizeProfileText(user = {}) {
|
||||
return [
|
||||
user.role,
|
||||
user.position,
|
||||
user.department,
|
||||
user.departmentName,
|
||||
user.department_name,
|
||||
user.grade
|
||||
]
|
||||
.map((item) => String(item || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function isPlatformAdminProfile(user = {}, roleCodes = []) {
|
||||
const username = String(user.username || user.account || '').trim().toLowerCase()
|
||||
const role = String(user.role || '').trim().toLowerCase()
|
||||
|
||||
return (
|
||||
Boolean(user.isAdmin)
|
||||
|| username === 'admin'
|
||||
|| role === 'admin'
|
||||
|| role === '管理员'
|
||||
|| role === '系统管理员'
|
||||
|| roleCodes.includes('admin')
|
||||
)
|
||||
}
|
||||
|
||||
function addViewIds(target, ids = []) {
|
||||
ids.forEach((id) => target.add(id))
|
||||
}
|
||||
|
||||
export function resolveAiSidebarBusinessViewIds(user = {}) {
|
||||
const roleCodes = normalizedRoleCodes(user)
|
||||
const roleCodeSet = new Set(roleCodes)
|
||||
const profileText = normalizeProfileText(user)
|
||||
const viewIds = new Set(AI_SIDEBAR_BASE_BUSINESS_VIEW_IDS)
|
||||
|
||||
if (isPlatformAdminProfile(user, roleCodes)) {
|
||||
addViewIds(viewIds, ROLE_VIEW_ADDITIONS.admin)
|
||||
}
|
||||
|
||||
if (roleCodeSet.has('budget_monitor') || profileText.includes('预算')) {
|
||||
addViewIds(viewIds, ROLE_VIEW_ADDITIONS.budget)
|
||||
}
|
||||
|
||||
if (roleCodeSet.has('finance') || profileText.includes('财务')) {
|
||||
addViewIds(viewIds, ROLE_VIEW_ADDITIONS.finance)
|
||||
}
|
||||
|
||||
if (roleCodeSet.has('manager') || profileText.includes('经理') || profileText.includes('主管')) {
|
||||
addViewIds(viewIds, ROLE_VIEW_ADDITIONS.manager)
|
||||
}
|
||||
|
||||
if (roleCodeSet.has('executive') || profileText.includes('高管') || profileText.includes('总监')) {
|
||||
addViewIds(viewIds, ROLE_VIEW_ADDITIONS.executive)
|
||||
}
|
||||
|
||||
return Array.from(viewIds)
|
||||
}
|
||||
155
web/src/utils/aiWorkbenchConversationStore.js
Normal file
155
web/src/utils/aiWorkbenchConversationStore.js
Normal file
@@ -0,0 +1,155 @@
|
||||
const STORAGE_KEY_PREFIX = 'x-financial:workbench-ai-conversations'
|
||||
const MAX_CONVERSATION_HISTORY = 30
|
||||
const MAX_STORED_MESSAGES = 80
|
||||
|
||||
function safeString(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function resolveUserStorageKey(user = {}) {
|
||||
const identity = safeString(user.username || user.email || user.name || 'anonymous')
|
||||
return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}`
|
||||
}
|
||||
|
||||
function canUseStorage() {
|
||||
return typeof window !== 'undefined' && Boolean(window.localStorage)
|
||||
}
|
||||
|
||||
function readStoredList(user = {}) {
|
||||
if (!canUseStorage()) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(window.localStorage.getItem(resolveUserStorageKey(user)) || '[]')
|
||||
return Array.isArray(payload) ? payload : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredList(user = {}, conversations = []) {
|
||||
if (!canUseStorage()) {
|
||||
return
|
||||
}
|
||||
|
||||
const normalized = conversations
|
||||
.map((item) => normalizeConversation(item))
|
||||
.filter((item) => item.id)
|
||||
.sort((left, right) => Number(right.updatedAt || 0) - Number(left.updatedAt || 0))
|
||||
.slice(0, MAX_CONVERSATION_HISTORY)
|
||||
|
||||
window.localStorage.setItem(resolveUserStorageKey(user), JSON.stringify(normalized))
|
||||
}
|
||||
|
||||
function normalizeMessage(message = {}) {
|
||||
return {
|
||||
id: safeString(message.id) || `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
role: safeString(message.role) || 'assistant',
|
||||
content: safeString(message.content),
|
||||
pending: false,
|
||||
feedback: safeString(message.feedback),
|
||||
stewardPlan: message.stewardPlan && typeof message.stewardPlan === 'object'
|
||||
? {
|
||||
...message.stewardPlan,
|
||||
streamStatus: safeString(message.stewardPlan.streamStatus) || 'completed'
|
||||
}
|
||||
: null,
|
||||
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConversation(conversation = {}) {
|
||||
const updatedAt = Number(conversation.updatedAt || conversation.updated_at || Date.now())
|
||||
const messages = Array.isArray(conversation.messages)
|
||||
? conversation.messages.slice(-MAX_STORED_MESSAGES).map((message) => normalizeMessage(message))
|
||||
: []
|
||||
const title = safeString(conversation.title) || buildConversationTitle(messages)
|
||||
const desc = safeString(conversation.desc || conversation.description) || buildConversationDescription(messages)
|
||||
return {
|
||||
id: safeString(conversation.id || conversation.conversationId),
|
||||
title,
|
||||
desc,
|
||||
time: formatConversationTime(updatedAt),
|
||||
prompt: safeString(conversation.prompt) || resolveFirstUserPrompt(messages),
|
||||
source: safeString(conversation.source) || 'workbench',
|
||||
sessionType: safeString(conversation.sessionType) || 'steward',
|
||||
conversationId: safeString(conversation.conversationId || conversation.id),
|
||||
stewardState: conversation.stewardState && typeof conversation.stewardState === 'object'
|
||||
? conversation.stewardState
|
||||
: null,
|
||||
messages,
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStoredConversationId(conversation = {}) {
|
||||
return safeString(conversation.id || conversation.conversationId)
|
||||
}
|
||||
|
||||
function buildConversationTitle(messages = []) {
|
||||
const firstUserMessage = messages.find((message) => message.role === 'user' && message.content)
|
||||
return safeString(firstUserMessage?.content).slice(0, 18) || '新对话'
|
||||
}
|
||||
|
||||
function buildConversationDescription(messages = []) {
|
||||
const lastMessage = [...messages].reverse().find((message) => safeString(message.content))
|
||||
return safeString(lastMessage?.content).replace(/\s+/g, ' ').slice(0, 32) || '小财管家对话'
|
||||
}
|
||||
|
||||
function resolveFirstUserPrompt(messages = []) {
|
||||
const firstUserMessage = messages.find((message) => message.role === 'user' && message.content)
|
||||
return safeString(firstUserMessage?.content)
|
||||
}
|
||||
|
||||
function formatConversationTime(timestamp) {
|
||||
const date = new Date(Number(timestamp || Date.now()))
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
||||
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000
|
||||
const timeText = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
const value = date.getTime()
|
||||
|
||||
if (value >= startOfToday) {
|
||||
return `今天 ${timeText}`
|
||||
}
|
||||
if (value >= startOfYesterday) {
|
||||
return '昨天'
|
||||
}
|
||||
return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function loadAiWorkbenchConversationHistory(user = {}) {
|
||||
return readStoredList(user)
|
||||
.map((item) => normalizeConversation(item))
|
||||
.filter((item) => item.id)
|
||||
.sort((left, right) => Number(right.updatedAt || 0) - Number(left.updatedAt || 0))
|
||||
}
|
||||
|
||||
export function saveAiWorkbenchConversation(user = {}, conversation = {}) {
|
||||
const normalized = normalizeConversation({
|
||||
...conversation,
|
||||
updatedAt: conversation.updatedAt || Date.now()
|
||||
})
|
||||
if (!normalized.id || !normalized.messages.length) {
|
||||
return loadAiWorkbenchConversationHistory(user)
|
||||
}
|
||||
|
||||
const nextList = [
|
||||
normalized,
|
||||
...readStoredList(user).filter((item) => resolveStoredConversationId(item) !== normalized.id)
|
||||
]
|
||||
writeStoredList(user, nextList)
|
||||
return loadAiWorkbenchConversationHistory(user)
|
||||
}
|
||||
|
||||
export function deleteAiWorkbenchConversation(user = {}, conversationId = '') {
|
||||
const normalizedId = safeString(conversationId)
|
||||
const nextList = readStoredList(user).filter((item) => resolveStoredConversationId(item) !== normalizedId)
|
||||
writeStoredList(user, nextList)
|
||||
return loadAiWorkbenchConversationHistory(user)
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
const EXPENSE_SCENE_SELECTION_OPTIONS = [
|
||||
{ key: 'travel', label: '差旅费', description: '出差行程、住宿、跨城交通等费用', icon: 'mdi mdi-bag-suitcase-outline' },
|
||||
{ key: 'transport', label: '交通费', description: '市内交通、打车、停车、通行等费用', icon: 'mdi mdi-car-outline' },
|
||||
{ key: 'hotel', label: '住宿费', description: '单独住宿或酒店发票报销', icon: 'mdi mdi-bed-outline' },
|
||||
{ key: 'meal', label: '业务招待费', description: '客户接待、工作餐、加班餐、餐饮票据等费用', icon: 'mdi mdi-food-fork-drink' },
|
||||
{ key: 'meeting', label: '会务费', description: '会议、论坛、会场、参会等费用', icon: 'mdi mdi-account-tie-voice-outline' },
|
||||
{ key: 'office', label: '办公用品费', description: '办公用品、低值易耗品等费用', icon: 'mdi mdi-briefcase-outline' },
|
||||
{ key: 'training', label: '培训费', description: '培训课程、讲师费、教材认证等费用', icon: 'mdi mdi-school-outline' },
|
||||
{ key: 'communication', label: '通讯费', description: '话费、流量、宽带、网络等费用', icon: 'mdi mdi-cellphone-message' },
|
||||
{ key: 'welfare', label: '福利费', description: '团建、体检、慰问、节日福利等费用', icon: 'mdi mdi-gift-outline' },
|
||||
{ key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline' }
|
||||
{ key: 'travel', label: '差旅费', description: '出差行程、住宿、跨城交通等费用', icon: 'mdi mdi-bag-suitcase-outline', requires_application_before_reimbursement: true, next_session_type: 'application' },
|
||||
{ key: 'transport', label: '交通费', description: '市内交通、打车、停车、通行等费用', icon: 'mdi mdi-car-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
|
||||
{ key: 'hotel', label: '住宿费', description: '单独住宿或酒店发票报销', icon: 'mdi mdi-bed-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
|
||||
{ key: 'meal', label: '业务招待费', description: '客户接待、工作餐、加班餐、餐饮票据等费用', icon: 'mdi mdi-food-fork-drink', requires_application_before_reimbursement: true, next_session_type: 'application' },
|
||||
{ key: 'meeting', label: '会务费', description: '会议、论坛、会场、参会等费用', icon: 'mdi mdi-account-tie-voice-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
|
||||
{ key: 'office', label: '办公用品费', description: '办公用品、低值易耗品等费用', icon: 'mdi mdi-briefcase-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
|
||||
{ key: 'training', label: '培训费', description: '培训课程、讲师费、教材认证等费用', icon: 'mdi mdi-school-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
|
||||
{ key: 'communication', label: '通讯费', description: '话费、流量、宽带、网络等费用', icon: 'mdi mdi-cellphone-message', requires_application_before_reimbursement: false, next_session_type: 'expense' },
|
||||
{ key: 'welfare', label: '福利费', description: '团建、体检、慰问、节日福利等费用', icon: 'mdi mdi-gift-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
|
||||
{ key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' }
|
||||
]
|
||||
|
||||
const EXPENSE_INTENT_CONFIRMATION_ACTION = {
|
||||
@@ -28,7 +28,9 @@ export function buildExpenseSceneSelectionActions(rawText) {
|
||||
payload: {
|
||||
expense_type: option.key,
|
||||
expense_type_label: option.label,
|
||||
original_message: originalMessage
|
||||
original_message: originalMessage,
|
||||
requires_application_before_reimbursement: option.requires_application_before_reimbursement,
|
||||
next_session_type: option.next_session_type
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -89,6 +89,22 @@ function padDatePart(value) {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
function formatMonthKey(date) {
|
||||
return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}`
|
||||
}
|
||||
|
||||
function formatMonthLabel(date) {
|
||||
return `${date.getMonth() + 1}月`
|
||||
}
|
||||
|
||||
function shiftMonth(date, offset) {
|
||||
return new Date(date.getFullYear(), date.getMonth() + offset, 1)
|
||||
}
|
||||
|
||||
function resolveMonthStart(date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1)
|
||||
}
|
||||
|
||||
function formatDateTimeLabel(value) {
|
||||
if (value instanceof Date) {
|
||||
return [
|
||||
@@ -558,6 +574,55 @@ function buildExpenseOperationRows(todoItems, notifications, progressItems) {
|
||||
.slice(0, 8)
|
||||
}
|
||||
|
||||
function buildMonthlyAmountMap(ownedRequests) {
|
||||
const rows = new Map()
|
||||
|
||||
for (const request of ownedRequests) {
|
||||
const date = toDate(resolveClaimDate(request))
|
||||
if (!date) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = formatMonthKey(date)
|
||||
rows.set(key, (rows.get(key) || 0) + parseNumber(request?.amount))
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
function resolveTrendAnchorDate(ownedRequests) {
|
||||
const dates = ownedRequests
|
||||
.map((request) => toDate(resolveClaimDate(request)))
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => right.getTime() - left.getTime())
|
||||
|
||||
return resolveMonthStart(dates[0] || new Date())
|
||||
}
|
||||
|
||||
function buildReimbursementTrendRows(ownedRequests) {
|
||||
const monthlyAmountMap = buildMonthlyAmountMap(ownedRequests)
|
||||
const anchor = resolveTrendAnchorDate(ownedRequests)
|
||||
|
||||
return Array.from({ length: 6 }, (_, index) => {
|
||||
const month = shiftMonth(anchor, index - 5)
|
||||
const previousMonth = shiftMonth(month, -12)
|
||||
const key = formatMonthKey(month)
|
||||
const previousKey = formatMonthKey(previousMonth)
|
||||
const amount = monthlyAmountMap.get(key) || 0
|
||||
const previousAmount = monthlyAmountMap.get(previousKey) || 0
|
||||
|
||||
return {
|
||||
key,
|
||||
label: formatMonthLabel(month),
|
||||
amount,
|
||||
amountLabel: formatCurrency(amount),
|
||||
previousKey,
|
||||
previousAmount,
|
||||
previousAmountLabel: formatCurrency(previousAmount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildWorkbenchSummary(requests, currentUser) {
|
||||
const allRequests = Array.isArray(requests)
|
||||
? requests
|
||||
@@ -602,6 +667,7 @@ export function buildWorkbenchSummary(requests, currentUser) {
|
||||
highRiskCount,
|
||||
todoItems,
|
||||
progressItems,
|
||||
reimbursementTrendRows: buildReimbursementTrendRows(ownedRequests),
|
||||
notifications,
|
||||
expenseStatsDetail,
|
||||
unreadNotificationCount: notifications.filter((item) => item.unread).length
|
||||
|
||||
Reference in New Issue
Block a user