feat: 新增风险规则生成引擎与知识图谱可视化
后端新增风险规则自动生成和模板执行服务,支持从规则资产 批量生成并持久化风险规则文件;知识库入库日志增强图谱 查询和本地 RAG 回退,前端审计页面增加风险规则模型和流 程图组件,知识入库面板拆分为图谱可视化子组件,报销创 建页面增加引导式流程模型,更新知识库索引数据。
This commit is contained in:
@@ -20,7 +20,11 @@ import {
|
||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
|
||||
|
||||
const tabs = ['全部归档', '差旅报销', '招待报销', '其他费用']
|
||||
const ARCHIVE_TAB_ALL = '全部归档'
|
||||
const ARCHIVE_TAB_REIMBURSEMENT = '报销归档'
|
||||
const ARCHIVE_TYPE_REIMBURSEMENT = '报销'
|
||||
const ARCHIVE_TYPE_REIMBURSEMENT_CODE = 'reimbursement'
|
||||
const tabs = [ARCHIVE_TAB_ALL, ARCHIVE_TAB_REIMBURSEMENT]
|
||||
const RISK_FILTER_OPTIONS = [
|
||||
{ value: ARCHIVE_FILTER_ALL, label: '全部风险' },
|
||||
{ value: 'has', label: '有风险' },
|
||||
@@ -40,17 +44,6 @@ function formatCurrency(value) {
|
||||
}).format(Number.isFinite(amount) ? amount : 0)
|
||||
}
|
||||
|
||||
function resolveArchiveTypeTab(request) {
|
||||
const expenseType = String(request?.typeCode || request?.expenseType || '').trim().toLowerCase()
|
||||
if (expenseType === 'travel') {
|
||||
return '差旅报销'
|
||||
}
|
||||
if (expenseType === 'entertainment') {
|
||||
return '招待报销'
|
||||
}
|
||||
return '其他费用'
|
||||
}
|
||||
|
||||
function buildArchiveRow(request) {
|
||||
const normalized = normalizeRequestForUi(request)
|
||||
const riskCount = countClaimRisks(normalized.riskFlags, normalized.riskSummary)
|
||||
@@ -75,6 +68,8 @@ function buildArchiveRow(request) {
|
||||
archivedAt: normalized.updatedAt || normalized.applyTime,
|
||||
archiveMonth,
|
||||
archiveMonthLabel: formatArchiveMonthLabel(archiveMonth),
|
||||
archiveType: ARCHIVE_TYPE_REIMBURSEMENT,
|
||||
archiveTypeCode: ARCHIVE_TYPE_REIMBURSEMENT_CODE,
|
||||
node: normalized.workflowNode || '归档入账',
|
||||
hasRisk,
|
||||
riskCount,
|
||||
@@ -82,7 +77,7 @@ function buildArchiveRow(request) {
|
||||
riskTone,
|
||||
status: '已归档',
|
||||
statusTone: 'archived',
|
||||
archiveTab: resolveArchiveTypeTab(normalized)
|
||||
archiveTab: ARCHIVE_TAB_REIMBURSEMENT
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +93,7 @@ export default {
|
||||
TableEmptyState
|
||||
},
|
||||
setup() {
|
||||
const activeTab = ref('全部归档')
|
||||
const activeTab = ref(ARCHIVE_TAB_ALL)
|
||||
const activeRiskFilter = ref(ARCHIVE_FILTER_ALL)
|
||||
const activeTypeFilter = ref(ARCHIVE_FILTER_ALL)
|
||||
const activeDepartmentFilter = ref(ARCHIVE_FILTER_ALL)
|
||||
@@ -115,7 +110,7 @@ export default {
|
||||
const archiveMonthFilterOptions = computed(() => buildArchiveMonthFilterOptions(rows.value))
|
||||
|
||||
const riskFilterLabel = computed(() => resolveFilterLabel(RISK_FILTER_OPTIONS, activeRiskFilter.value, '全部风险'))
|
||||
const typeFilterLabel = computed(() => resolveFilterLabel(typeFilterOptions.value, activeTypeFilter.value, '费用类型'))
|
||||
const typeFilterLabel = computed(() => resolveFilterLabel(typeFilterOptions.value, activeTypeFilter.value, '归档类型'))
|
||||
const departmentFilterLabel = computed(() => resolveFilterLabel(departmentFilterOptions.value, activeDepartmentFilter.value, '所属部门'))
|
||||
const archiveMonthFilterLabel = computed(() => resolveFilterLabel(archiveMonthFilterOptions.value, activeArchiveMonthFilter.value, '归档月份'))
|
||||
|
||||
@@ -193,8 +188,8 @@ export default {
|
||||
eyebrow: filtersActive ? '筛选结果为空' : '归档中心',
|
||||
title: filtersActive ? '没有符合当前筛选条件的归档单据' : `“${activeTab.value}”里暂时没有归档单据`,
|
||||
desc: filtersActive
|
||||
? '可以调整风险、费用类型、部门或归档月份筛选,也可以修改搜索关键词后重试。'
|
||||
: '可以切换到其他分类查看,或调整筛选条件后重新检索。',
|
||||
? '可以调整风险、归档类型、部门或归档月份筛选,也可以修改搜索关键词后重试。'
|
||||
: '可以切换到其他归档分类查看,或调整筛选条件后重新检索。',
|
||||
icon: 'mdi mdi-archive-outline',
|
||||
actionLabel: null,
|
||||
actionIcon: null,
|
||||
@@ -205,7 +200,7 @@ export default {
|
||||
})
|
||||
|
||||
function resetListFilters() {
|
||||
activeTab.value = '全部归档'
|
||||
activeTab.value = ARCHIVE_TAB_ALL
|
||||
activeRiskFilter.value = ARCHIVE_FILTER_ALL
|
||||
activeTypeFilter.value = ARCHIVE_FILTER_ALL
|
||||
activeDepartmentFilter.value = ARCHIVE_FILTER_ALL
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import { fetchEmployees } from '../../services/employees.js'
|
||||
import RiskRuleFlowDiagram from '../../components/shared/RiskRuleFlowDiagram.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
fetchAgentAssetRuleJson,
|
||||
fetchAgentAssetVersionTimeline,
|
||||
fetchAgentRuns,
|
||||
generateRiskRuleAsset,
|
||||
saveAgentAssetRuleJson,
|
||||
importAgentAssetSpreadsheetContent,
|
||||
restoreAgentAssetVersion,
|
||||
@@ -58,11 +60,17 @@ import {
|
||||
parseRuntimeRuleText,
|
||||
buildMarkdownVersionContent
|
||||
} from './auditViewModel.js'
|
||||
import {
|
||||
createDefaultRiskRuleForm,
|
||||
RISK_RULE_CREATE_DOMAIN_OPTIONS,
|
||||
RISK_RULE_LEVEL_OPTIONS
|
||||
} from './auditViewRiskRuleModel.js'
|
||||
|
||||
export default {
|
||||
name: 'AuditView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
RiskRuleFlowDiagram,
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
@@ -95,6 +103,8 @@ export default {
|
||||
const reviewSubmitReviewer = ref('')
|
||||
const reviewSubmitReviewerLoading = ref(false)
|
||||
const reviewSubmitReviewerOptions = ref([])
|
||||
const riskRuleCreateOpen = ref(false)
|
||||
const riskRuleCreateForm = ref(createDefaultRiskRuleForm())
|
||||
const runLoading = ref(false)
|
||||
const runs = ref([])
|
||||
const spreadsheetUploadInput = ref(null)
|
||||
@@ -152,6 +162,10 @@ export default {
|
||||
!selectedSkill.value?.isPreviewMock &&
|
||||
(isAdmin.value || isFinance.value)
|
||||
)
|
||||
const canCreateRiskRule = computed(
|
||||
() => activeType.value === 'riskRules' && (isAdmin.value || isFinance.value) && !detailBusy.value
|
||||
)
|
||||
const riskRuleCreateBusy = computed(() => actionState.value === 'generate-risk-rule')
|
||||
const canEditMarkdown = computed(() => canEditSelected.value && selectedSkillIsRule.value)
|
||||
const isDisplayingWorkingVersion = computed(
|
||||
() => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
|
||||
@@ -492,6 +506,53 @@ export default {
|
||||
return currentUser.value?.name || currentUser.value?.username || 'system'
|
||||
}
|
||||
|
||||
function openRiskRuleCreateDialog() {
|
||||
if (activeType.value !== 'riskRules') {
|
||||
return
|
||||
}
|
||||
riskRuleCreateForm.value = createDefaultRiskRuleForm()
|
||||
riskRuleCreateOpen.value = true
|
||||
}
|
||||
|
||||
function closeRiskRuleCreateDialog() {
|
||||
if (riskRuleCreateBusy.value) {
|
||||
return
|
||||
}
|
||||
riskRuleCreateOpen.value = false
|
||||
}
|
||||
|
||||
async function submitRiskRuleCreate() {
|
||||
if (!canCreateRiskRule.value || riskRuleCreateBusy.value) {
|
||||
return
|
||||
}
|
||||
const naturalLanguage = String(riskRuleCreateForm.value.natural_language || '').trim()
|
||||
if (naturalLanguage.length < 8) {
|
||||
toast('请至少输入 8 个字的风险规则描述。')
|
||||
return
|
||||
}
|
||||
|
||||
actionState.value = 'generate-risk-rule'
|
||||
try {
|
||||
const detail = await generateRiskRuleAsset(
|
||||
{
|
||||
business_domain: riskRuleCreateForm.value.business_domain,
|
||||
risk_level: riskRuleCreateForm.value.risk_level,
|
||||
natural_language: naturalLanguage
|
||||
},
|
||||
{ actor: resolveActor() }
|
||||
)
|
||||
riskRuleCreateOpen.value = false
|
||||
await refreshCurrentAssets()
|
||||
selectedSkill.value = buildDetailViewModel(detail, runs.value)
|
||||
await loadRiskRuleJson(detail.id)
|
||||
toast('风险规则草稿已生成,请在详情中核对业务说明和判断流程。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '风险规则生成失败,请稍后重试。')
|
||||
} finally {
|
||||
actionState.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function persistRuleRuntimeConfig(asset, runtimeRule) {
|
||||
await updateAgentAsset(
|
||||
asset.id,
|
||||
@@ -1421,6 +1482,7 @@ export default {
|
||||
activeFilterTokens,
|
||||
canManageSelected,
|
||||
canEditSelected,
|
||||
canCreateRiskRule,
|
||||
canSubmitReview,
|
||||
hasReviewSubmitReviewers,
|
||||
canReviewSelected,
|
||||
@@ -1444,6 +1506,11 @@ export default {
|
||||
reviewSubmitReviewer,
|
||||
reviewSubmitReviewerLoading,
|
||||
reviewSubmitReviewerOptions,
|
||||
riskRuleCreateOpen,
|
||||
riskRuleCreateForm,
|
||||
riskRuleCreateBusy,
|
||||
riskRuleCreateDomainOptions: RISK_RULE_CREATE_DOMAIN_OPTIONS,
|
||||
riskRuleLevelOptions: RISK_RULE_LEVEL_OPTIONS,
|
||||
showReviewNote,
|
||||
spreadsheetUploadInput,
|
||||
spreadsheetOnlyOfficeLoading,
|
||||
@@ -1464,6 +1531,9 @@ export default {
|
||||
toggleFilterPopover,
|
||||
selectFilter,
|
||||
closeFilterPopover,
|
||||
openRiskRuleCreateDialog,
|
||||
closeRiskRuleCreateDialog,
|
||||
submitRiskRuleCreate,
|
||||
openVersionSwitch,
|
||||
cancelVersionSwitch,
|
||||
confirmVersionSwitch,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useTravelReimbursementSessionState } from './useTravelReimbursementSess
|
||||
import { useTravelReimbursementReviewDrawer } from './useTravelReimbursementReviewDrawer.js'
|
||||
import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSubmitComposer.js'
|
||||
import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js'
|
||||
import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
|
||||
import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
|
||||
import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js'
|
||||
@@ -536,6 +537,7 @@ export default {
|
||||
currentInsight,
|
||||
reviewFilePreviews,
|
||||
composerUploadIntent,
|
||||
guidedFlowState,
|
||||
insightPanelCollapsed,
|
||||
sessionSwitchBusy,
|
||||
buildEmptySessionState,
|
||||
@@ -871,7 +873,8 @@ export default {
|
||||
})
|
||||
sessionRuntimeRefs = {
|
||||
attachedFiles,
|
||||
composerFilesExpanded
|
||||
composerFilesExpanded,
|
||||
guidedFlowState
|
||||
}
|
||||
const {
|
||||
confirmPendingAttachmentAssociationInternal,
|
||||
@@ -961,6 +964,34 @@ export default {
|
||||
|| composerBusinessTimeTags.value.length
|
||||
)
|
||||
)
|
||||
const {
|
||||
handleGuidedShortcut,
|
||||
handleGuidedComposerSubmit,
|
||||
handleGuidedSuggestedAction,
|
||||
resetGuidedFlowState
|
||||
} = useTravelReimbursementGuidedFlow({
|
||||
guidedFlowState,
|
||||
messages,
|
||||
composerDraft,
|
||||
attachedFiles,
|
||||
composerBusinessTimeTags,
|
||||
composerBusinessTimeDraftTouched,
|
||||
fileInputRef,
|
||||
submitting,
|
||||
reviewActionBusy,
|
||||
sessionSwitchBusy,
|
||||
createMessage,
|
||||
nextTick,
|
||||
scrollToBottom,
|
||||
persistSessionState,
|
||||
clearAttachedFiles,
|
||||
adjustComposerTextareaHeight,
|
||||
buildComposerBusinessTimeContext,
|
||||
openTravelCalculator,
|
||||
lockSuggestedActionMessage,
|
||||
submitExistingComposer: submitComposerInternal,
|
||||
toast
|
||||
})
|
||||
function toggleTravelCalculator() {
|
||||
return toggleTravelCalculatorInternal()
|
||||
}
|
||||
@@ -1050,6 +1081,7 @@ export default {
|
||||
reviewFilePreviews: reviewFilePreviews.value,
|
||||
composerDraft: composerDraft.value,
|
||||
composerUploadIntent: composerUploadIntent.value,
|
||||
guidedFlowState: guidedFlowState.value,
|
||||
insightPanelCollapsed: insightPanelCollapsed.value
|
||||
}),
|
||||
() => {
|
||||
@@ -1168,6 +1200,7 @@ export default {
|
||||
function resetCurrentSessionState() {
|
||||
const emptyState = buildEmptySessionState(activeSessionType.value)
|
||||
sessionSnapshots.value[activeSessionType.value] = emptyState
|
||||
resetGuidedFlowState()
|
||||
applySessionState(emptyState)
|
||||
resetFlowRun({ startedAt: 0, openDrawer: false })
|
||||
}
|
||||
@@ -1239,6 +1272,9 @@ export default {
|
||||
await switchSessionType(shortcut.targetSessionType)
|
||||
return
|
||||
}
|
||||
if (handleGuidedShortcut(shortcut)) {
|
||||
return
|
||||
}
|
||||
|
||||
const prompt = String(shortcut?.prompt || '').trim()
|
||||
if (!prompt) return
|
||||
@@ -1293,6 +1329,7 @@ export default {
|
||||
const actionType = String(action?.action_type || '').trim()
|
||||
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
|
||||
if (message?.suggestedActionsLocked) return
|
||||
if (await handleGuidedSuggestedAction(message, action)) return
|
||||
|
||||
if (actionType === 'confirm_expense_intent') {
|
||||
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
|
||||
@@ -1746,6 +1783,9 @@ export default {
|
||||
// submitting.value = true
|
||||
// recognizeOcrFiles(files)
|
||||
// submitting.value = false
|
||||
if (await handleGuidedComposerSubmit(options)) {
|
||||
return null
|
||||
}
|
||||
return submitComposerInternal(options)
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ export const TAB_META = {
|
||||
typeKey: 'rules',
|
||||
label: '风险规则',
|
||||
typeLabel: '风险规则',
|
||||
createButtonLabel: '风险规则已接入',
|
||||
createButtonLabel: '新建风险规则',
|
||||
hintText: '仅展示平台风险规则;适用场景按差旅、发票、餐饮招待等分类,可用「使用场景」筛选。',
|
||||
searchPlaceholder: '搜索风险规则名称、编码或负责人',
|
||||
tableColumns: RULE_TABLE_COLUMNS,
|
||||
|
||||
@@ -19,6 +19,17 @@ import {
|
||||
TYPE_META,
|
||||
VERSION_STATE_META
|
||||
} from './auditViewMetadata.js'
|
||||
import {
|
||||
buildRiskRuleFieldSummary,
|
||||
formatRiskRuleAge,
|
||||
resolveRiskRuleBusinessDescription,
|
||||
resolveRiskRuleCreatedAt,
|
||||
resolveRiskRuleFields,
|
||||
resolveRiskRuleFlow,
|
||||
resolveRiskRuleFlowDiagramSvg,
|
||||
resolveRiskRuleSeverity,
|
||||
resolveRiskRuleSeverityLabel
|
||||
} from './auditViewRiskRuleModel.js'
|
||||
|
||||
export {
|
||||
DETAIL_TITLES,
|
||||
@@ -413,14 +424,26 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
|
||||
const riskCategory =
|
||||
normalizeText(rulePayload.risk_category) ||
|
||||
resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload })
|
||||
const riskRuleFields = resolveRiskRuleFields(rulePayload)
|
||||
const riskRuleCreatedAt = resolveRiskRuleCreatedAt(rulePayload, target.createdAt || target.updatedAt)
|
||||
|
||||
return {
|
||||
...target,
|
||||
riskRuleDescription: fullDescription,
|
||||
riskRuleBusinessDescription: resolveRiskRuleBusinessDescription(rulePayload, fullDescription),
|
||||
riskRuleSubtitle: buildRiskListSubtitle(fullDescription, 48),
|
||||
riskCategory,
|
||||
scope: riskCategory,
|
||||
riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload),
|
||||
riskRuleSeverity: resolveRiskRuleSeverity(rulePayload),
|
||||
riskRuleSeverityLabel: resolveRiskRuleSeverityLabel(rulePayload),
|
||||
riskRuleCreatedAt: formatDateTime(riskRuleCreatedAt),
|
||||
riskRuleAgeLabel: formatRiskRuleAge(riskRuleCreatedAt),
|
||||
riskRuleFields,
|
||||
riskRuleFieldSummary: buildRiskRuleFieldSummary(riskRuleFields),
|
||||
riskRuleFlow: resolveRiskRuleFlow(rulePayload, riskRuleFields),
|
||||
riskRuleFlowDiagramSvg:
|
||||
normalizeText(apiPayload?.flow_diagram_svg) || resolveRiskRuleFlowDiagramSvg(rulePayload),
|
||||
riskRuleSummary: {
|
||||
name: apiPayload?.name || target.name,
|
||||
evaluator: apiPayload?.evaluator || rulePayload.evaluator || '',
|
||||
@@ -1219,6 +1242,7 @@ export function buildDetailViewModel(detail, runs) {
|
||||
statusValue: detail.status,
|
||||
statusTone: statusMeta.tone,
|
||||
hitRate: buildRowMetric(detail, typeKey),
|
||||
createdAt: detail.created_at,
|
||||
updatedAt: formatDateTime(detail.updated_at),
|
||||
badgeTone: tabMeta.badgeTone,
|
||||
configJson,
|
||||
@@ -1227,8 +1251,17 @@ export function buildDetailViewModel(detail, runs) {
|
||||
riskRuleJsonText: '{}',
|
||||
riskRuleSummary: null,
|
||||
riskRuleDescription: '',
|
||||
riskRuleBusinessDescription: '',
|
||||
riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '',
|
||||
riskRuleSourceRef: '',
|
||||
riskRuleSeverity: 'medium',
|
||||
riskRuleSeverityLabel: '中风险',
|
||||
riskRuleCreatedAt: formatDateTime(detail.created_at),
|
||||
riskRuleAgeLabel: formatRiskRuleAge(detail.created_at),
|
||||
riskRuleFields: [],
|
||||
riskRuleFieldSummary: '未识别字段',
|
||||
riskRuleFlow: resolveRiskRuleFlow({}, []),
|
||||
riskRuleFlowDiagramSvg: normalizeText(configJson.flow_diagram_svg),
|
||||
riskCategory: typeKey === 'rules' ? ruleScenarioCategory : '',
|
||||
ruleDocument,
|
||||
scenarioList: typeKey === 'rules' && ruleScenarioCategory
|
||||
|
||||
166
web/src/views/scripts/auditViewRiskRuleModel.js
Normal file
166
web/src/views/scripts/auditViewRiskRuleModel.js
Normal file
@@ -0,0 +1,166 @@
|
||||
export const RISK_RULE_CREATE_DOMAIN_OPTIONS = [
|
||||
{ value: 'expense', label: '报销' },
|
||||
{ value: 'ar', label: '应收' },
|
||||
{ value: 'ap', label: '应付' }
|
||||
]
|
||||
|
||||
export const RISK_RULE_LEVEL_OPTIONS = [
|
||||
{ value: 'medium', label: '中风险' },
|
||||
{ value: 'high', label: '高风险' },
|
||||
{ value: 'low', label: '低风险' }
|
||||
]
|
||||
|
||||
const RISK_LEVEL_LABELS = {
|
||||
low: '低风险',
|
||||
medium: '中风险',
|
||||
high: '高风险'
|
||||
}
|
||||
|
||||
export function createDefaultRiskRuleForm() {
|
||||
return {
|
||||
business_domain: 'expense',
|
||||
risk_level: 'medium',
|
||||
natural_language: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeRiskRuleText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
export function formatRiskRuleFieldDisplay(field) {
|
||||
const key = normalizeRiskRuleText(field?.key)
|
||||
const label = normalizeRiskRuleText(field?.label || key)
|
||||
if (label && key && label !== key) {
|
||||
return `${label}[${key}]`
|
||||
}
|
||||
return label || key
|
||||
}
|
||||
|
||||
export function resolveRiskRuleSeverity(payload) {
|
||||
const outcomes = payload && typeof payload === 'object' ? payload.outcomes || {} : {}
|
||||
const fail = outcomes && typeof outcomes.fail === 'object' ? outcomes.fail : {}
|
||||
const severity = normalizeRiskRuleText(fail.severity || payload?.severity).toLowerCase()
|
||||
return ['low', 'medium', 'high'].includes(severity) ? severity : 'medium'
|
||||
}
|
||||
|
||||
export function resolveRiskRuleSeverityLabel(payload) {
|
||||
return RISK_LEVEL_LABELS[resolveRiskRuleSeverity(payload)] || '中风险'
|
||||
}
|
||||
|
||||
export function resolveRiskRuleFields(payload) {
|
||||
const inputs = payload && typeof payload === 'object' ? payload.inputs || {} : {}
|
||||
const fieldRows = Array.isArray(inputs.fields) ? inputs.fields : []
|
||||
if (fieldRows.length) {
|
||||
return fieldRows
|
||||
.map((item) => ({
|
||||
key: normalizeRiskRuleText(item?.key),
|
||||
label: normalizeRiskRuleText(item?.label || item?.key),
|
||||
display: formatRiskRuleFieldDisplay(item),
|
||||
source: normalizeRiskRuleText(item?.source),
|
||||
type: normalizeRiskRuleText(item?.type)
|
||||
}))
|
||||
.filter((item) => item.key || item.label)
|
||||
}
|
||||
|
||||
return Object.entries(inputs)
|
||||
.map(([label, key]) => ({
|
||||
key: normalizeRiskRuleText(key),
|
||||
label: normalizeRiskRuleText(label),
|
||||
display: formatRiskRuleFieldDisplay({ key, label }),
|
||||
source: '',
|
||||
type: ''
|
||||
}))
|
||||
.filter((item) => item.key || item.label)
|
||||
}
|
||||
|
||||
export function buildRiskRuleFieldSummary(fields) {
|
||||
const labels = fields.map(formatRiskRuleFieldDisplay).filter(Boolean)
|
||||
if (!labels.length) {
|
||||
return '未识别字段'
|
||||
}
|
||||
if (labels.length <= 4) {
|
||||
return labels.join('、')
|
||||
}
|
||||
return `${labels.slice(0, 4).join('、')} 等 ${labels.length} 项`
|
||||
}
|
||||
|
||||
export function resolveRiskRuleCreatedAt(payload, fallback) {
|
||||
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
|
||||
return normalizeRiskRuleText(metadata.created_at || fallback)
|
||||
}
|
||||
|
||||
export function formatRiskRuleAge(value) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '未记录'
|
||||
}
|
||||
const diffMs = Date.now() - date.getTime()
|
||||
if (diffMs < 0) {
|
||||
return '刚刚创建'
|
||||
}
|
||||
const minutes = Math.floor(diffMs / 60000)
|
||||
if (minutes < 1) {
|
||||
return '刚刚创建'
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `${minutes} 分钟`
|
||||
}
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) {
|
||||
return `${hours} 小时`
|
||||
}
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) {
|
||||
return `${days} 天`
|
||||
}
|
||||
const months = Math.floor(days / 30)
|
||||
if (months < 12) {
|
||||
return `${months} 个月`
|
||||
}
|
||||
return `${Math.floor(months / 12)} 年`
|
||||
}
|
||||
|
||||
export function resolveRiskRuleBusinessDescription(payload, fallback) {
|
||||
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
|
||||
return (
|
||||
normalizeRiskRuleText(metadata.business_explanation) ||
|
||||
normalizeRiskRuleText(payload?.description) ||
|
||||
normalizeRiskRuleText(fallback)
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveRiskRuleFlowDiagramSvg(payload) {
|
||||
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
|
||||
return (
|
||||
normalizeRiskRuleText(payload?.flow_diagram_svg) ||
|
||||
normalizeRiskRuleText(metadata.flow_diagram_svg)
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveRiskRuleConditionSummary(payload) {
|
||||
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
|
||||
const params = payload && typeof payload === 'object' ? payload.params || {} : {}
|
||||
return (
|
||||
normalizeRiskRuleText(metadata.condition_summary) ||
|
||||
normalizeRiskRuleText(params.condition_summary) ||
|
||||
'根据规则字段判断是否命中风险'
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveRiskRuleFlow(payload, fields) {
|
||||
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
|
||||
const flow = metadata && typeof metadata.flow === 'object' ? metadata.flow : {}
|
||||
const fieldSummary = buildRiskRuleFieldSummary(fields)
|
||||
const conditionSummary = resolveRiskRuleConditionSummary(payload)
|
||||
const severityLabel = resolveRiskRuleSeverityLabel(payload)
|
||||
|
||||
return {
|
||||
start: normalizeRiskRuleText(flow.start) || '业务单据提交',
|
||||
evidence: normalizeRiskRuleText(flow.evidence) || `读取 ${fieldSummary}`,
|
||||
decision: normalizeRiskRuleText(flow.decision) || conditionSummary,
|
||||
basis: conditionSummary,
|
||||
pass: normalizeRiskRuleText(flow.pass) || '未命中风险,继续流转',
|
||||
fail: normalizeRiskRuleText(flow.fail) || `命中${severityLabel},进入人工复核`
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
|
||||
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
|
||||
import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js'
|
||||
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
|
||||
import {
|
||||
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||||
GUIDED_ACTION_START_REIMBURSEMENT,
|
||||
GUIDED_ACTION_START_STATUS_QUERY
|
||||
} from './travelReimbursementGuidedFlowModel.js'
|
||||
|
||||
export const SESSION_TYPE_EXPENSE = 'expense'
|
||||
export const SESSION_TYPE_KNOWLEDGE = 'knowledge'
|
||||
@@ -88,34 +93,19 @@ export const ASSISTANT_DISPLAY_NAME = '财务助手'
|
||||
|
||||
export const EXPENSE_WELCOME_QUICK_ACTIONS = [
|
||||
{
|
||||
label: '发起差旅报销',
|
||||
prompt: '我要报销一笔出差费用,请帮我说明需要准备的材料,并引导我上传票据。',
|
||||
icon: 'mdi mdi-bag-suitcase-outline'
|
||||
label: '快速发起报销',
|
||||
action: GUIDED_ACTION_START_REIMBURSEMENT,
|
||||
icon: 'mdi mdi-receipt-text-plus-outline'
|
||||
},
|
||||
{
|
||||
label: '招待费报销',
|
||||
prompt: '我要报销客户招待餐费,请告诉我需要补充的客户、参与人员和票据要求。',
|
||||
icon: 'mdi mdi-food-fork-drink'
|
||||
label: '查询单据状态',
|
||||
action: GUIDED_ACTION_START_STATUS_QUERY,
|
||||
icon: 'mdi mdi-file-search-outline'
|
||||
},
|
||||
{
|
||||
label: '交通费报销',
|
||||
prompt: '我要报销交通出行费用,请帮我识别场景并列出待补充信息。',
|
||||
icon: 'mdi mdi-car-outline'
|
||||
},
|
||||
{
|
||||
label: '上传票据识别',
|
||||
prompt: '我已准备好票据,请帮我识别并整理报销核对信息。',
|
||||
icon: 'mdi mdi-file-upload-outline'
|
||||
},
|
||||
{
|
||||
label: '查询近期报销',
|
||||
prompt: '帮我查询近10天的报销记录和金额汇总。',
|
||||
icon: 'mdi mdi-chart-timeline-variant'
|
||||
},
|
||||
{
|
||||
label: '解释报销风险',
|
||||
prompt: '请结合公司制度,说明酒店超标、发票抬头不一致等常见报销风险。',
|
||||
icon: 'mdi mdi-shield-alert-outline'
|
||||
label: '差旅计算器',
|
||||
action: GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||||
icon: 'mdi mdi-calculator-variant-outline'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -436,22 +426,6 @@ export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedR
|
||||
}))
|
||||
}
|
||||
|
||||
if (entrySource === 'detail' && linkedRequest?.id) {
|
||||
return [
|
||||
{
|
||||
label: '补充当前单据票据',
|
||||
prompt: `请结合单据 ${linkedRequest.id},帮我继续补充票据并更新识别结果。`,
|
||||
icon: 'mdi mdi-file-plus-outline'
|
||||
},
|
||||
{
|
||||
label: '解释本单风险',
|
||||
prompt: `请解释单据 ${linkedRequest.id} 当前存在的报销风险与处理建议。`,
|
||||
icon: 'mdi mdi-shield-alert-outline'
|
||||
},
|
||||
...EXPENSE_WELCOME_QUICK_ACTIONS.slice(0, 4)
|
||||
]
|
||||
}
|
||||
|
||||
return EXPENSE_WELCOME_QUICK_ACTIONS
|
||||
}
|
||||
|
||||
|
||||
508
web/src/views/scripts/travelReimbursementGuidedFlowModel.js
Normal file
508
web/src/views/scripts/travelReimbursementGuidedFlowModel.js
Normal file
@@ -0,0 +1,508 @@
|
||||
export const GUIDED_FLOW_MODE_NONE = ''
|
||||
export const GUIDED_FLOW_MODE_REIMBURSEMENT = 'reimbursement_guide'
|
||||
export const GUIDED_FLOW_MODE_STATUS_QUERY = 'status_query_guide'
|
||||
|
||||
export const GUIDED_ACTION_START_REIMBURSEMENT = 'start_guided_reimbursement'
|
||||
export const GUIDED_ACTION_START_STATUS_QUERY = 'start_guided_status_query'
|
||||
export const GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR = 'open_travel_calculator'
|
||||
export const GUIDED_ACTION_SELECT_EXPENSE_TYPE = 'guided_select_expense_type'
|
||||
export const GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW = 'guided_confirm_reimbursement_review'
|
||||
export const GUIDED_ACTION_CONTINUE_FILLING = 'guided_continue_filling'
|
||||
export const GUIDED_ACTION_PROCESS_INTERRUPTION = 'guided_process_interruption'
|
||||
export const GUIDED_ACTION_SELECT_QUERY_MODE = 'guided_select_query_mode'
|
||||
export const GUIDED_ACTION_SELECT_QUERY_STATUS = 'guided_select_query_status'
|
||||
|
||||
export const GUIDED_EXPENSE_TYPES = [
|
||||
{ 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: 'office', label: '办公用品费', description: '办公用品、文具和低值易耗品', icon: 'mdi mdi-briefcase-outline' },
|
||||
{ key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline' }
|
||||
]
|
||||
|
||||
const GUIDED_REIMBURSEMENT_STEPS = {
|
||||
travel: [
|
||||
{ key: 'reason', summaryLabel: '事由', prompt: '请先告诉我本次出差事由,例如:去上海支持项目部署。' },
|
||||
{ key: 'location', summaryLabel: '出差地点', prompt: '本次出差地点是哪里?可以回复城市或具体客户地点。' },
|
||||
{ key: 'time_range', summaryLabel: '出差时间/天数', prompt: '请补充出差时间或天数,例如:2026-05-20 至 2026-05-23,出差 3 天。' },
|
||||
{ key: 'amount', summaryLabel: '金额', prompt: '请补充本次预计或实际报销金额。如果还没有汇总,可以回复“待核算”。' },
|
||||
{ key: 'attachments', summaryLabel: '票据', prompt: '票据可以现在上传,也可以回复“稍后上传”。上传后我会在生成核对信息时一起处理。' }
|
||||
],
|
||||
transport: [
|
||||
{ key: 'reason', summaryLabel: '出行事由', prompt: '请说明本次交通费事由,例如:送客户去机场。' },
|
||||
{ key: 'time_range', summaryLabel: '出行时间', prompt: '请补充出行时间,例如:2026-05-20 下午。' },
|
||||
{ key: 'location', summaryLabel: '路线/地点', prompt: '请补充出行路线或地点,例如:公司至机场。' },
|
||||
{ key: 'amount', summaryLabel: '金额', prompt: '请补充交通费金额。如果票据里再识别金额,可以回复“以票据为准”。' },
|
||||
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传出租车、网约车、停车或通行费等票据;也可以回复“稍后上传”。' }
|
||||
],
|
||||
hotel: [
|
||||
{ key: 'reason', summaryLabel: '住宿事由', prompt: '请说明住宿事由,例如:项目现场支持期间住宿。' },
|
||||
{ key: 'location', summaryLabel: '城市/酒店地点', prompt: '住宿城市或酒店地点是哪里?' },
|
||||
{ key: 'time_range', summaryLabel: '入住离店时间', prompt: '请补充入住和离店时间,例如:2026-05-20 至 2026-05-23。' },
|
||||
{ key: 'amount', summaryLabel: '金额', prompt: '请补充住宿金额。如果还没有汇总,可以回复“待核算”。' },
|
||||
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传酒店发票或住宿水单;也可以回复“稍后上传”。' }
|
||||
],
|
||||
meal: [
|
||||
{ key: 'customer_name', summaryLabel: '客户单位', prompt: '请补充客户单位或接待对象。' },
|
||||
{ key: 'participants', summaryLabel: '参与人员', prompt: '请补充参与人员,例如:客户 2 人,我方 1 人。' },
|
||||
{ key: 'time_range', summaryLabel: '招待时间', prompt: '请补充招待时间,例如:2026-05-20 晚。' },
|
||||
{ key: 'location', summaryLabel: '招待地点', prompt: '请补充招待地点或商户名称。' },
|
||||
{ key: 'amount', summaryLabel: '金额', prompt: '请补充招待金额。' },
|
||||
{ key: 'reason', summaryLabel: '事由', prompt: '请补充招待事由,例如:项目沟通或客户接待。' },
|
||||
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传餐饮发票或相关凭证;也可以回复“稍后上传”。' }
|
||||
],
|
||||
office: [
|
||||
{ key: 'reason', summaryLabel: '采购用途', prompt: '请说明采购用途,例如:项目现场临时采购办公用品。' },
|
||||
{ key: 'location', summaryLabel: '商户/采购地点', prompt: '请补充商户或采购地点。' },
|
||||
{ key: 'time_range', summaryLabel: '发生时间', prompt: '请补充费用发生时间。' },
|
||||
{ key: 'amount', summaryLabel: '金额', prompt: '请补充办公用品金额。' },
|
||||
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传办公用品发票或购物凭证;也可以回复“稍后上传”。' }
|
||||
],
|
||||
other: [
|
||||
{ key: 'reason', summaryLabel: '费用说明', prompt: '请说明这笔费用的具体内容和用途。' },
|
||||
{ key: 'time_range', summaryLabel: '发生时间', prompt: '请补充费用发生时间。' },
|
||||
{ key: 'location', summaryLabel: '地点/对象', prompt: '请补充费用发生地点或关联对象。' },
|
||||
{ key: 'amount', summaryLabel: '金额', prompt: '请补充费用金额。' },
|
||||
{ key: 'attachments', summaryLabel: '票据', prompt: '请上传相关票据;也可以回复“稍后上传”。' }
|
||||
]
|
||||
}
|
||||
|
||||
export const GUIDED_QUERY_MODES = [
|
||||
{ key: 'claim_no', label: '按单号', description: '输入报销单号精准查询', icon: 'mdi mdi-pound' },
|
||||
{ key: 'status', label: '按状态', description: '查询草稿、审批中或已归档单据', icon: 'mdi mdi-list-status' },
|
||||
{ key: 'time_range', label: '按时间范围', description: '例如上周、去年、2026-05', icon: 'mdi mdi-calendar-search-outline' },
|
||||
{ key: 'keyword', label: '按地点/事由', description: '例如北京、上海电力、服务器部署', icon: 'mdi mdi-map-search-outline' }
|
||||
]
|
||||
|
||||
export const GUIDED_QUERY_STATUS_OPTIONS = [
|
||||
{ key: 'draft', label: '草稿', description: '还没有正式提交的单据' },
|
||||
{ key: 'pending', label: '审批中', description: '正在流转审批的单据' },
|
||||
{ key: 'returned', label: '已退回', description: '需要补充或修改的单据' },
|
||||
{ key: 'archived', label: '已归档', description: '已完成归档的单据' },
|
||||
{ key: 'completed', label: '已完成', description: '已审核完成或已入账的单据' }
|
||||
]
|
||||
|
||||
const NO_ATTACHMENT_TEXT_PATTERN = /^(稍后|暂不|不用|没有|待上传|后面|后续|先不|以票据为准)/u
|
||||
const INTERRUPTION_PATTERN = /(查一下|查询|状态|报销了吗|报销了么|多少|总额|标准|制度|规则|为什么|怎么|可以吗|能不能|差旅计算器|计算一下|解释|风险|打开|跳转|查看|审批|归档|入账|[??])/u
|
||||
|
||||
function uniqueValues(values) {
|
||||
return Array.from(new Set((Array.isArray(values) ? values : []).map((item) => String(item || '').trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function normalizeValues(values) {
|
||||
if (!values || typeof values !== 'object') {
|
||||
return {}
|
||||
}
|
||||
return Object.entries(values).reduce((result, [key, value]) => {
|
||||
if (key === 'attachment_names') {
|
||||
result[key] = uniqueValues(value)
|
||||
return result
|
||||
}
|
||||
result[key] = normalizeText(value)
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function createEmptyGuidedFlowState() {
|
||||
return {
|
||||
mode: GUIDED_FLOW_MODE_NONE,
|
||||
stepKey: '',
|
||||
expenseType: '',
|
||||
values: {},
|
||||
pendingInterruptionText: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeGuidedFlowState(state) {
|
||||
const source = state && typeof state === 'object' ? state : {}
|
||||
const mode = normalizeText(source.mode)
|
||||
const supportedMode = [GUIDED_FLOW_MODE_REIMBURSEMENT, GUIDED_FLOW_MODE_STATUS_QUERY].includes(mode)
|
||||
? mode
|
||||
: GUIDED_FLOW_MODE_NONE
|
||||
if (!supportedMode) {
|
||||
return createEmptyGuidedFlowState()
|
||||
}
|
||||
|
||||
return {
|
||||
mode: supportedMode,
|
||||
stepKey: normalizeText(source.stepKey),
|
||||
expenseType: normalizeText(source.expenseType),
|
||||
values: normalizeValues(source.values),
|
||||
pendingInterruptionText: normalizeText(source.pendingInterruptionText)
|
||||
}
|
||||
}
|
||||
|
||||
export function isGuidedFlowActive(state) {
|
||||
return Boolean(normalizeGuidedFlowState(state).mode)
|
||||
}
|
||||
|
||||
export function getGuidedExpenseType(expenseType) {
|
||||
const key = normalizeText(expenseType)
|
||||
return GUIDED_EXPENSE_TYPES.find((item) => item.key === key) || null
|
||||
}
|
||||
|
||||
export function getGuidedExpenseTypeLabel(expenseType) {
|
||||
return getGuidedExpenseType(expenseType)?.label || ''
|
||||
}
|
||||
|
||||
export function buildGuidedExpenseTypeActions() {
|
||||
return GUIDED_EXPENSE_TYPES.map((option) => ({
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
icon: option.icon,
|
||||
action_type: GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
||||
payload: {
|
||||
expense_type: option.key,
|
||||
expense_type_label: option.label
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildGuidedReimbursementStartText() {
|
||||
return [
|
||||
'请问你要报销的类型?',
|
||||
'',
|
||||
'先选一个最贴近的费用场景,我会按对应流程逐项询问。这个过程只做本地引导,不会自动创建草稿。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function createGuidedReimbursementState() {
|
||||
return {
|
||||
...createEmptyGuidedFlowState(),
|
||||
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||||
stepKey: 'expense_type'
|
||||
}
|
||||
}
|
||||
|
||||
export function selectGuidedExpenseType(state, expenseType) {
|
||||
const type = getGuidedExpenseType(expenseType)
|
||||
if (!type) {
|
||||
return normalizeGuidedFlowState(state)
|
||||
}
|
||||
const steps = GUIDED_REIMBURSEMENT_STEPS[type.key] || []
|
||||
return {
|
||||
...normalizeGuidedFlowState(state),
|
||||
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||||
expenseType: type.key,
|
||||
stepKey: steps[0]?.key || 'summary',
|
||||
pendingInterruptionText: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function getGuidedReimbursementSteps(expenseType) {
|
||||
const key = normalizeText(expenseType)
|
||||
return GUIDED_REIMBURSEMENT_STEPS[key] || []
|
||||
}
|
||||
|
||||
export function getCurrentGuidedStep(state) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
if (current.mode !== GUIDED_FLOW_MODE_REIMBURSEMENT || !current.expenseType) {
|
||||
return null
|
||||
}
|
||||
return getGuidedReimbursementSteps(current.expenseType).find((step) => step.key === current.stepKey) || null
|
||||
}
|
||||
|
||||
export function buildGuidedStepPromptText(state) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
const step = getCurrentGuidedStep(current)
|
||||
const typeLabel = getGuidedExpenseTypeLabel(current.expenseType)
|
||||
if (!step || !typeLabel) {
|
||||
return buildGuidedReimbursementStartText()
|
||||
}
|
||||
const steps = getGuidedReimbursementSteps(current.expenseType)
|
||||
const stepIndex = Math.max(0, steps.findIndex((item) => item.key === step.key))
|
||||
return [
|
||||
`已选择“${typeLabel}”。`,
|
||||
'',
|
||||
`第 ${stepIndex + 1} 步:${step.summaryLabel}`,
|
||||
step.prompt,
|
||||
'',
|
||||
'直接回复这一项即可。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function resolveGuidedExpenseTypeFromText(text) {
|
||||
const normalized = normalizeText(text)
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
const exact = GUIDED_EXPENSE_TYPES.find((item) => normalized === item.label || normalized === item.key)
|
||||
if (exact) {
|
||||
return exact.key
|
||||
}
|
||||
const matched = GUIDED_EXPENSE_TYPES.find((item) => normalized.includes(item.label))
|
||||
return matched?.key || ''
|
||||
}
|
||||
|
||||
export function applyGuidedReimbursementAnswer(state, answerText, attachmentNames = []) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
const step = getCurrentGuidedStep(current)
|
||||
if (!step) {
|
||||
return current
|
||||
}
|
||||
|
||||
const answer = normalizeText(answerText)
|
||||
const nextValues = { ...current.values }
|
||||
if (step.key === 'attachments') {
|
||||
const nextAttachmentNames = uniqueValues([
|
||||
...(Array.isArray(nextValues.attachment_names) ? nextValues.attachment_names : []),
|
||||
...attachmentNames
|
||||
])
|
||||
if (nextAttachmentNames.length) {
|
||||
nextValues.attachment_names = nextAttachmentNames
|
||||
}
|
||||
nextValues.attachments = answer || (nextAttachmentNames.length ? `已选择 ${nextAttachmentNames.length} 份附件` : '稍后上传')
|
||||
} else {
|
||||
nextValues[step.key] = answer
|
||||
}
|
||||
|
||||
const steps = getGuidedReimbursementSteps(current.expenseType)
|
||||
const currentIndex = steps.findIndex((item) => item.key === step.key)
|
||||
const nextStep = steps[currentIndex + 1]
|
||||
return {
|
||||
...current,
|
||||
values: normalizeValues(nextValues),
|
||||
stepKey: nextStep?.key || 'summary',
|
||||
pendingInterruptionText: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function isGuidedReimbursementReadyForReview(state) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
return current.mode === GUIDED_FLOW_MODE_REIMBURSEMENT
|
||||
&& Boolean(current.expenseType)
|
||||
&& current.stepKey === 'summary'
|
||||
}
|
||||
|
||||
export function buildGuidedReimbursementSummaryText(state) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
const typeLabel = getGuidedExpenseTypeLabel(current.expenseType) || '报销'
|
||||
const steps = getGuidedReimbursementSteps(current.expenseType)
|
||||
const lines = [
|
||||
`已完成“${typeLabel}”的引导填写。`,
|
||||
'',
|
||||
'请核查下面的关键信息:'
|
||||
]
|
||||
|
||||
steps.forEach((step) => {
|
||||
const value = step.key === 'attachments'
|
||||
? (current.values.attachment_names?.length
|
||||
? current.values.attachment_names.join('、')
|
||||
: current.values.attachments || '稍后上传')
|
||||
: current.values[step.key]
|
||||
lines.push(`- ${step.summaryLabel}:${value || '待补充'}`)
|
||||
})
|
||||
|
||||
lines.push('')
|
||||
lines.push('如果这些信息无误,我可以继续生成右侧报销核对信息;生成核对信息后,再由你决定保存草稿或继续下一步。')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function buildGuidedReviewConfirmationActions() {
|
||||
return [{
|
||||
label: '生成报销核对信息',
|
||||
description: '进入现有报销核对流程,不会直接保存草稿',
|
||||
icon: 'mdi mdi-clipboard-check-outline',
|
||||
action_type: GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW
|
||||
}]
|
||||
}
|
||||
|
||||
export function buildGuidedReviewSubmitOptions(state, files = []) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
const type = getGuidedExpenseType(current.expenseType)
|
||||
const values = current.values || {}
|
||||
const typeLabel = type?.label || '其他费用'
|
||||
const fieldLines = getGuidedReimbursementSteps(current.expenseType).map((step) => {
|
||||
const value = step.key === 'attachments'
|
||||
? (values.attachment_names?.length ? values.attachment_names.join('、') : values.attachments || '稍后上传')
|
||||
: values[step.key]
|
||||
return `${step.summaryLabel}:${value || '待补充'}`
|
||||
})
|
||||
const rawText = [
|
||||
`报销类型:${typeLabel}`,
|
||||
...fieldLines
|
||||
].join('\n')
|
||||
const reviewFormValues = {
|
||||
expense_type: typeLabel,
|
||||
reimbursement_type: typeLabel,
|
||||
reason: values.reason || values.customer_name || '',
|
||||
reason_value: values.reason || '',
|
||||
customer_name: values.customer_name || '',
|
||||
participants: values.participants || '',
|
||||
location: values.location || '',
|
||||
business_location: values.location || '',
|
||||
time_range: values.time_range || '',
|
||||
business_time: values.time_range || '',
|
||||
amount: values.amount || '',
|
||||
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : []
|
||||
}
|
||||
|
||||
return {
|
||||
rawText,
|
||||
userText: '生成报销核对信息',
|
||||
pendingText: '正在生成右侧报销核对信息...',
|
||||
systemGenerated: true,
|
||||
files,
|
||||
extraContext: {
|
||||
draft_claim_id: '',
|
||||
user_input_text: rawText,
|
||||
expense_scene_selection: {
|
||||
expense_type: type?.key || current.expenseType || 'other',
|
||||
expense_type_label: typeLabel,
|
||||
original_message: rawText
|
||||
},
|
||||
review_form_values: reviewFormValues
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldConfirmGuidedInterruption(text, state) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
if (!current.mode || current.pendingInterruptionText) {
|
||||
return false
|
||||
}
|
||||
const normalized = normalizeText(text)
|
||||
if (!normalized || NO_ATTACHMENT_TEXT_PATTERN.test(normalized)) {
|
||||
return false
|
||||
}
|
||||
return INTERRUPTION_PATTERN.test(normalized)
|
||||
}
|
||||
|
||||
export function buildGuidedInterruptionText(text) {
|
||||
return [
|
||||
`我看到你刚才输入的是:“${normalizeText(text)}”。`,
|
||||
'',
|
||||
'这看起来像一个新的问题。你想继续填写当前引导,还是先暂停当前引导并处理这个问题?'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildGuidedInterruptionActions() {
|
||||
return [
|
||||
{
|
||||
label: '继续填写',
|
||||
description: '保留当前引导,继续回答这一项',
|
||||
icon: 'mdi mdi-pencil-outline',
|
||||
action_type: GUIDED_ACTION_CONTINUE_FILLING
|
||||
},
|
||||
{
|
||||
label: '暂停当前引导并处理这个问题',
|
||||
description: '暂停引导,把刚才输入交给财务助手处理',
|
||||
icon: 'mdi mdi-chat-processing-outline',
|
||||
action_type: GUIDED_ACTION_PROCESS_INTERRUPTION
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function createGuidedStatusQueryState() {
|
||||
return {
|
||||
...createEmptyGuidedFlowState(),
|
||||
mode: GUIDED_FLOW_MODE_STATUS_QUERY,
|
||||
stepKey: 'query_mode'
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGuidedStatusQueryStartText() {
|
||||
return [
|
||||
'你想按什么条件查询单据状态?',
|
||||
'',
|
||||
'先选查询方式,我再向你收集对应条件。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildGuidedQueryModeActions() {
|
||||
return GUIDED_QUERY_MODES.map((option) => ({
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
icon: option.icon,
|
||||
action_type: GUIDED_ACTION_SELECT_QUERY_MODE,
|
||||
payload: {
|
||||
query_mode: option.key,
|
||||
query_mode_label: option.label
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildGuidedQueryStatusActions() {
|
||||
return GUIDED_QUERY_STATUS_OPTIONS.map((option) => ({
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
icon: 'mdi mdi-checkbox-marked-circle-outline',
|
||||
action_type: GUIDED_ACTION_SELECT_QUERY_STATUS,
|
||||
payload: {
|
||||
query_status: option.key,
|
||||
query_status_label: option.label
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
export function resolveGuidedQueryModeFromText(text) {
|
||||
const normalized = normalizeText(text)
|
||||
if (!normalized) return ''
|
||||
const exact = GUIDED_QUERY_MODES.find((item) => normalized === item.label || normalized === item.key)
|
||||
if (exact) return exact.key
|
||||
if (/单号|编号|EXP-/i.test(normalized)) return 'claim_no'
|
||||
if (/状态|草稿|审批|退回|归档|完成/.test(normalized)) return 'status'
|
||||
if (/上周|本周|去年|今年|月份|时间|日期|[0-9]{4}-[0-9]{2}/.test(normalized)) return 'time_range'
|
||||
return 'keyword'
|
||||
}
|
||||
|
||||
export function selectGuidedQueryMode(state, queryMode) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
const mode = GUIDED_QUERY_MODES.find((item) => item.key === normalizeText(queryMode))
|
||||
if (!mode) {
|
||||
return current
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
mode: GUIDED_FLOW_MODE_STATUS_QUERY,
|
||||
stepKey: mode.key === 'status' ? 'status_value' : 'query_value',
|
||||
values: {
|
||||
...current.values,
|
||||
query_mode: mode.key,
|
||||
query_mode_label: mode.label
|
||||
},
|
||||
pendingInterruptionText: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGuidedQueryPromptText(state) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
const mode = normalizeText(current.values.query_mode)
|
||||
if (!mode) {
|
||||
return buildGuidedStatusQueryStartText()
|
||||
}
|
||||
if (mode === 'status') {
|
||||
return [
|
||||
'请选择要查询的单据状态。',
|
||||
'',
|
||||
'我会按所选状态筛选最近的报销单据。'
|
||||
].join('\n')
|
||||
}
|
||||
const prompts = {
|
||||
claim_no: '请输入报销单号,例如 EXP-202605-001。',
|
||||
time_range: '请输入查询时间范围,例如:上周、今年 5 月、2025 年全年。',
|
||||
keyword: '请输入地点、客户或事由关键词,例如:上海电力、北京、服务器部署。'
|
||||
}
|
||||
return prompts[mode] || '请补充查询条件。'
|
||||
}
|
||||
|
||||
export function buildGuidedStatusQueryText(state, valueText) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
const mode = normalizeText(current.values.query_mode)
|
||||
const value = normalizeText(valueText)
|
||||
if (mode === 'claim_no') {
|
||||
return `帮我查询单号 ${value} 的报销单状态`
|
||||
}
|
||||
if (mode === 'status') {
|
||||
return `帮我查询${value}的报销单据,筛选最近的 5 条记录`
|
||||
}
|
||||
if (mode === 'time_range') {
|
||||
return `帮我查询${value}提交或发生的报销单据状态,筛选最近的 5 条记录`
|
||||
}
|
||||
return `帮我查询地点或事由包含“${value}”的报销单据状态,筛选最近的 5 条记录`
|
||||
}
|
||||
439
web/src/views/scripts/useTravelReimbursementGuidedFlow.js
Normal file
439
web/src/views/scripts/useTravelReimbursementGuidedFlow.js
Normal file
@@ -0,0 +1,439 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
|
||||
GUIDED_ACTION_CONTINUE_FILLING,
|
||||
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||||
GUIDED_ACTION_PROCESS_INTERRUPTION,
|
||||
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
||||
GUIDED_ACTION_SELECT_QUERY_MODE,
|
||||
GUIDED_ACTION_SELECT_QUERY_STATUS,
|
||||
GUIDED_ACTION_START_REIMBURSEMENT,
|
||||
GUIDED_ACTION_START_STATUS_QUERY,
|
||||
GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||||
GUIDED_FLOW_MODE_STATUS_QUERY,
|
||||
applyGuidedReimbursementAnswer,
|
||||
buildGuidedExpenseTypeActions,
|
||||
buildGuidedInterruptionActions,
|
||||
buildGuidedInterruptionText,
|
||||
buildGuidedQueryModeActions,
|
||||
buildGuidedQueryPromptText,
|
||||
buildGuidedQueryStatusActions,
|
||||
buildGuidedReimbursementStartText,
|
||||
buildGuidedReimbursementSummaryText,
|
||||
buildGuidedReviewConfirmationActions,
|
||||
buildGuidedReviewSubmitOptions,
|
||||
buildGuidedStatusQueryStartText,
|
||||
buildGuidedStatusQueryText,
|
||||
buildGuidedStepPromptText,
|
||||
createEmptyGuidedFlowState,
|
||||
createGuidedReimbursementState,
|
||||
createGuidedStatusQueryState,
|
||||
getCurrentGuidedStep,
|
||||
isGuidedFlowActive,
|
||||
isGuidedReimbursementReadyForReview,
|
||||
normalizeGuidedFlowState,
|
||||
resolveGuidedExpenseTypeFromText,
|
||||
resolveGuidedQueryModeFromText,
|
||||
selectGuidedExpenseType,
|
||||
selectGuidedQueryMode,
|
||||
shouldConfirmGuidedInterruption
|
||||
} from './travelReimbursementGuidedFlowModel.js'
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function buildFileNames(files) {
|
||||
return Array.from(files || [])
|
||||
.map((file) => normalizeText(file?.name))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function mergePendingFiles(currentFiles, nextFiles) {
|
||||
const merged = [...Array.from(currentFiles || [])]
|
||||
Array.from(nextFiles || []).forEach((file) => {
|
||||
const name = normalizeText(file?.name)
|
||||
if (!name) return
|
||||
const duplicated = merged.some((item) => normalizeText(item?.name) === name && Number(item?.size || 0) === Number(file?.size || 0))
|
||||
if (!duplicated) {
|
||||
merged.push(file)
|
||||
}
|
||||
})
|
||||
return merged
|
||||
}
|
||||
|
||||
export function useTravelReimbursementGuidedFlow({
|
||||
guidedFlowState,
|
||||
messages,
|
||||
composerDraft,
|
||||
attachedFiles,
|
||||
composerBusinessTimeTags,
|
||||
composerBusinessTimeDraftTouched,
|
||||
fileInputRef,
|
||||
submitting,
|
||||
reviewActionBusy,
|
||||
sessionSwitchBusy,
|
||||
createMessage,
|
||||
nextTick,
|
||||
scrollToBottom,
|
||||
persistSessionState,
|
||||
clearAttachedFiles,
|
||||
adjustComposerTextareaHeight,
|
||||
buildComposerBusinessTimeContext,
|
||||
openTravelCalculator,
|
||||
lockSuggestedActionMessage,
|
||||
submitExistingComposer,
|
||||
toast
|
||||
}) {
|
||||
const guidedPendingFiles = ref([])
|
||||
|
||||
function persistAndScroll() {
|
||||
persistSessionState()
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight?.()
|
||||
scrollToBottom?.()
|
||||
})
|
||||
}
|
||||
|
||||
function clearComposerRuntime() {
|
||||
composerDraft.value = ''
|
||||
clearAttachedFiles?.()
|
||||
if (fileInputRef?.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
if (composerBusinessTimeTags) {
|
||||
composerBusinessTimeTags.value = []
|
||||
}
|
||||
if (composerBusinessTimeDraftTouched) {
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pushAssistant(text, extras = {}) {
|
||||
messages.value.push(createMessage('assistant', text, [], extras))
|
||||
}
|
||||
|
||||
function pushUser(text, attachmentNames = []) {
|
||||
const normalizedText = normalizeText(text)
|
||||
messages.value.push(createMessage('user', normalizedText || `上传 ${attachmentNames.length} 份附件`, attachmentNames))
|
||||
}
|
||||
|
||||
function resetGuidedFlowState() {
|
||||
guidedFlowState.value = createEmptyGuidedFlowState()
|
||||
guidedPendingFiles.value = []
|
||||
}
|
||||
|
||||
function startGuidedReimbursement() {
|
||||
guidedFlowState.value = createGuidedReimbursementState()
|
||||
guidedPendingFiles.value = []
|
||||
pushAssistant(buildGuidedReimbursementStartText(), {
|
||||
meta: ['引导式报销'],
|
||||
suggestedActions: buildGuidedExpenseTypeActions()
|
||||
})
|
||||
persistAndScroll()
|
||||
}
|
||||
|
||||
function startGuidedStatusQuery() {
|
||||
guidedFlowState.value = createGuidedStatusQueryState()
|
||||
guidedPendingFiles.value = []
|
||||
pushAssistant(buildGuidedStatusQueryStartText(), {
|
||||
meta: ['引导式查询'],
|
||||
suggestedActions: buildGuidedQueryModeActions()
|
||||
})
|
||||
persistAndScroll()
|
||||
}
|
||||
|
||||
function handleGuidedShortcut(shortcut) {
|
||||
const actionType = normalizeText(shortcut?.action)
|
||||
if (actionType === GUIDED_ACTION_START_REIMBURSEMENT) {
|
||||
startGuidedReimbursement()
|
||||
return true
|
||||
}
|
||||
if (actionType === GUIDED_ACTION_START_STATUS_QUERY) {
|
||||
startGuidedStatusQuery()
|
||||
return true
|
||||
}
|
||||
if (actionType === GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR) {
|
||||
openTravelCalculator?.()
|
||||
pushAssistant('差旅计算器已打开。你可以直接填写目的地、天数和金额,我会按规则中心标准帮你测算。', {
|
||||
meta: ['差旅计算器']
|
||||
})
|
||||
persistAndScroll()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function buildAnswerText(rawText, state) {
|
||||
const text = normalizeText(rawText)
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
const currentStep = getCurrentGuidedStep(state)
|
||||
if (currentStep?.key === 'time_range') {
|
||||
const businessTimeContext = buildComposerBusinessTimeContext?.()
|
||||
return normalizeText(businessTimeContext?.time_range || businessTimeContext?.business_time)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function pushNextReimbursementPrompt() {
|
||||
pushAssistant(buildGuidedStepPromptText(guidedFlowState.value), {
|
||||
meta: ['引导式报销']
|
||||
})
|
||||
}
|
||||
|
||||
function pushReimbursementSummary() {
|
||||
pushAssistant(buildGuidedReimbursementSummaryText(guidedFlowState.value), {
|
||||
meta: ['待生成核对信息'],
|
||||
suggestedActions: buildGuidedReviewConfirmationActions()
|
||||
})
|
||||
}
|
||||
|
||||
function handleReimbursementAnswer(answerText, files) {
|
||||
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
|
||||
const currentStep = getCurrentGuidedStep(currentState)
|
||||
const fileNames = buildFileNames(files)
|
||||
|
||||
if (currentState.stepKey === 'expense_type') {
|
||||
const expenseType = resolveGuidedExpenseTypeFromText(answerText)
|
||||
if (!expenseType) {
|
||||
pushAssistant('我还需要先确认报销类型。请点击下面最贴近的费用场景后,我再继续问下一项。', {
|
||||
meta: ['等待选择报销类型'],
|
||||
suggestedActions: buildGuidedExpenseTypeActions()
|
||||
})
|
||||
return
|
||||
}
|
||||
guidedFlowState.value = selectGuidedExpenseType(currentState, expenseType)
|
||||
pushNextReimbursementPrompt()
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentStep) {
|
||||
pushAssistant(buildGuidedReimbursementStartText(), {
|
||||
meta: ['引导式报销'],
|
||||
suggestedActions: buildGuidedExpenseTypeActions()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!answerText && fileNames.length && currentStep.key !== 'attachments') {
|
||||
guidedPendingFiles.value = mergePendingFiles(guidedPendingFiles.value, files)
|
||||
pushAssistant([
|
||||
`我已先记录 ${fileNames.length} 份附件。`,
|
||||
'',
|
||||
`当前还需要补充:${currentStep.summaryLabel}。`,
|
||||
currentStep.prompt
|
||||
].join('\n'), {
|
||||
meta: ['已记录附件']
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (fileNames.length) {
|
||||
guidedPendingFiles.value = mergePendingFiles(guidedPendingFiles.value, files)
|
||||
}
|
||||
guidedFlowState.value = applyGuidedReimbursementAnswer(currentState, answerText, fileNames)
|
||||
if (isGuidedReimbursementReadyForReview(guidedFlowState.value)) {
|
||||
pushReimbursementSummary()
|
||||
return
|
||||
}
|
||||
pushNextReimbursementPrompt()
|
||||
}
|
||||
|
||||
async function runStatusQuery(queryText, skipUserMessage = true) {
|
||||
const normalizedQuery = normalizeText(queryText)
|
||||
resetGuidedFlowState()
|
||||
clearComposerRuntime()
|
||||
persistAndScroll()
|
||||
if (!normalizedQuery) {
|
||||
return true
|
||||
}
|
||||
await submitExistingComposer({
|
||||
rawText: normalizedQuery,
|
||||
userText: normalizedQuery,
|
||||
pendingText: '正在查询单据状态...',
|
||||
skipUserMessage
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleStatusQueryAnswer(answerText) {
|
||||
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
|
||||
if (currentState.stepKey === 'query_mode') {
|
||||
const queryMode = resolveGuidedQueryModeFromText(answerText)
|
||||
if (!queryMode) {
|
||||
pushAssistant(buildGuidedStatusQueryStartText(), {
|
||||
meta: ['引导式查询'],
|
||||
suggestedActions: buildGuidedQueryModeActions()
|
||||
})
|
||||
return true
|
||||
}
|
||||
guidedFlowState.value = selectGuidedQueryMode(currentState, queryMode)
|
||||
const actions = guidedFlowState.value.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : []
|
||||
pushAssistant(buildGuidedQueryPromptText(guidedFlowState.value), {
|
||||
meta: ['引导式查询'],
|
||||
suggestedActions: actions
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const queryText = buildGuidedStatusQueryText(currentState, answerText)
|
||||
return runStatusQuery(queryText, true)
|
||||
}
|
||||
|
||||
async function handleGuidedComposerSubmit(options = {}) {
|
||||
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
|
||||
if (!isGuidedFlowActive(currentState)) {
|
||||
return false
|
||||
}
|
||||
if (options.systemGenerated || normalizeText(options.extraContext?.review_action)) {
|
||||
return false
|
||||
}
|
||||
if (submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
const files = Array.from(options.files ?? attachedFiles.value ?? [])
|
||||
const fileNames = buildFileNames(files)
|
||||
const answerText = buildAnswerText(options.rawText ?? composerDraft.value, currentState)
|
||||
if (!answerText && !fileNames.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
pushUser(answerText, fileNames)
|
||||
if (shouldConfirmGuidedInterruption(answerText, currentState) && !fileNames.length) {
|
||||
guidedFlowState.value = {
|
||||
...currentState,
|
||||
pendingInterruptionText: answerText
|
||||
}
|
||||
pushAssistant(buildGuidedInterruptionText(answerText), {
|
||||
meta: ['等待确认是否打断'],
|
||||
suggestedActions: buildGuidedInterruptionActions()
|
||||
})
|
||||
clearComposerRuntime()
|
||||
persistAndScroll()
|
||||
return true
|
||||
}
|
||||
|
||||
if (currentState.mode === GUIDED_FLOW_MODE_REIMBURSEMENT) {
|
||||
handleReimbursementAnswer(answerText, files)
|
||||
clearComposerRuntime()
|
||||
persistAndScroll()
|
||||
return true
|
||||
}
|
||||
|
||||
if (currentState.mode === GUIDED_FLOW_MODE_STATUS_QUERY) {
|
||||
clearComposerRuntime()
|
||||
persistAndScroll()
|
||||
await handleStatusQueryAnswer(answerText)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function handleGuidedSuggestedAction(message, action) {
|
||||
const actionType = normalizeText(action?.action_type)
|
||||
if (!actionType) {
|
||||
return false
|
||||
}
|
||||
const guidedActionTypes = new Set([
|
||||
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
||||
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
|
||||
GUIDED_ACTION_CONTINUE_FILLING,
|
||||
GUIDED_ACTION_PROCESS_INTERRUPTION,
|
||||
GUIDED_ACTION_SELECT_QUERY_MODE,
|
||||
GUIDED_ACTION_SELECT_QUERY_STATUS
|
||||
])
|
||||
if (!guidedActionTypes.has(actionType)) {
|
||||
return false
|
||||
}
|
||||
if (submitting.value || reviewActionBusy.value || sessionSwitchBusy.value || message?.suggestedActionsLocked) {
|
||||
return true
|
||||
}
|
||||
if (!lockSuggestedActionMessage(message, action)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (actionType === GUIDED_ACTION_SELECT_EXPENSE_TYPE) {
|
||||
const expenseType = normalizeText(action?.payload?.expense_type)
|
||||
const expenseTypeLabel = normalizeText(action?.payload?.expense_type_label || action?.label)
|
||||
guidedFlowState.value = selectGuidedExpenseType(guidedFlowState.value, expenseType)
|
||||
pushUser(`选择${expenseTypeLabel || '报销类型'}`)
|
||||
pushNextReimbursementPrompt()
|
||||
persistAndScroll()
|
||||
return true
|
||||
}
|
||||
|
||||
if (actionType === GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW) {
|
||||
const submitOptions = buildGuidedReviewSubmitOptions(guidedFlowState.value, guidedPendingFiles.value)
|
||||
resetGuidedFlowState()
|
||||
persistAndScroll()
|
||||
await submitExistingComposer(submitOptions)
|
||||
return true
|
||||
}
|
||||
|
||||
if (actionType === GUIDED_ACTION_CONTINUE_FILLING) {
|
||||
const pendingState = {
|
||||
...normalizeGuidedFlowState(guidedFlowState.value),
|
||||
pendingInterruptionText: ''
|
||||
}
|
||||
guidedFlowState.value = pendingState
|
||||
if (pendingState.mode === GUIDED_FLOW_MODE_STATUS_QUERY) {
|
||||
pushAssistant(buildGuidedQueryPromptText(pendingState), {
|
||||
meta: ['引导式查询'],
|
||||
suggestedActions: pendingState.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : []
|
||||
})
|
||||
} else {
|
||||
pushNextReimbursementPrompt()
|
||||
}
|
||||
persistAndScroll()
|
||||
return true
|
||||
}
|
||||
|
||||
if (actionType === GUIDED_ACTION_PROCESS_INTERRUPTION) {
|
||||
const pendingText = normalizeText(guidedFlowState.value?.pendingInterruptionText)
|
||||
resetGuidedFlowState()
|
||||
persistAndScroll()
|
||||
await submitExistingComposer({
|
||||
rawText: pendingText,
|
||||
userText: pendingText,
|
||||
pendingText: '正在处理你的问题...',
|
||||
skipUserMessage: true
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (actionType === GUIDED_ACTION_SELECT_QUERY_MODE) {
|
||||
const queryMode = normalizeText(action?.payload?.query_mode)
|
||||
const queryModeLabel = normalizeText(action?.payload?.query_mode_label || action?.label)
|
||||
guidedFlowState.value = selectGuidedQueryMode(guidedFlowState.value, queryMode)
|
||||
pushUser(`选择${queryModeLabel || '查询方式'}`)
|
||||
pushAssistant(buildGuidedQueryPromptText(guidedFlowState.value), {
|
||||
meta: ['引导式查询'],
|
||||
suggestedActions: guidedFlowState.value.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : []
|
||||
})
|
||||
persistAndScroll()
|
||||
return true
|
||||
}
|
||||
|
||||
if (actionType === GUIDED_ACTION_SELECT_QUERY_STATUS) {
|
||||
const statusLabel = normalizeText(action?.payload?.query_status_label || action?.label)
|
||||
pushUser(`选择${statusLabel || '单据状态'}`)
|
||||
const queryText = buildGuidedStatusQueryText(guidedFlowState.value, statusLabel)
|
||||
await runStatusQuery(queryText, true)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
handleGuidedShortcut,
|
||||
handleGuidedComposerSubmit,
|
||||
handleGuidedSuggestedAction,
|
||||
resetGuidedFlowState
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SESSION_TYPE_KNOWLEDGE,
|
||||
buildInitialInsightFromConversation,
|
||||
buildWelcomeInsight,
|
||||
buildWelcomeQuickActions,
|
||||
createWelcomeAssistantMessage,
|
||||
hasMeaningfulSessionMessages,
|
||||
normalizeInitialConversationMessages,
|
||||
@@ -25,6 +26,10 @@ import {
|
||||
serializeSessionMessages,
|
||||
shouldPreferPersistedSessionState
|
||||
} from './travelReimbursementConversationModel.js'
|
||||
import {
|
||||
createEmptyGuidedFlowState,
|
||||
normalizeGuidedFlowState
|
||||
} from './travelReimbursementGuidedFlowModel.js'
|
||||
|
||||
export function useTravelReimbursementSessionState({
|
||||
props,
|
||||
@@ -36,9 +41,26 @@ export function useTravelReimbursementSessionState({
|
||||
scrollToBottom,
|
||||
getSessionRuntimeRefs = () => ({})
|
||||
}) {
|
||||
function refreshWelcomeQuickActions(messages, sessionType) {
|
||||
if (!Array.isArray(messages) || !messages.length) {
|
||||
return []
|
||||
}
|
||||
const currentActions = buildWelcomeQuickActions(
|
||||
sessionType,
|
||||
currentUser.value,
|
||||
props.entrySource,
|
||||
linkedRequest.value
|
||||
)
|
||||
return messages.map((message) => (
|
||||
message?.isWelcome
|
||||
? { ...message, welcomeQuickActions: currentActions }
|
||||
: message
|
||||
))
|
||||
}
|
||||
|
||||
function buildConversationSessionState(conversation, fallbackSessionType = SESSION_TYPE_EXPENSE) {
|
||||
const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType
|
||||
const restoredMessages = normalizeInitialConversationMessages(conversation)
|
||||
const restoredMessages = refreshWelcomeQuickActions(normalizeInitialConversationMessages(conversation), sessionType)
|
||||
const initialInsight = buildInitialInsightFromConversation(conversation)
|
||||
const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages)
|
||||
|
||||
@@ -56,6 +78,7 @@ export function useTravelReimbursementSessionState({
|
||||
attachedFiles: [],
|
||||
composerFilesExpanded: false,
|
||||
composerUploadIntent: '',
|
||||
guidedFlowState: createEmptyGuidedFlowState(),
|
||||
insightPanelCollapsed: false
|
||||
}
|
||||
}
|
||||
@@ -79,6 +102,7 @@ export function useTravelReimbursementSessionState({
|
||||
attachedFiles: [],
|
||||
composerFilesExpanded: false,
|
||||
composerUploadIntent: '',
|
||||
guidedFlowState: createEmptyGuidedFlowState(),
|
||||
insightPanelCollapsed: false
|
||||
}
|
||||
}
|
||||
@@ -90,7 +114,7 @@ export function useTravelReimbursementSessionState({
|
||||
}
|
||||
|
||||
const sessionType = String(state.sessionType || snapshot.sessionType || fallbackSessionType || '').trim() || SESSION_TYPE_EXPENSE
|
||||
const restoredMessages = normalizeSnapshotMessages(state.messages)
|
||||
const restoredMessages = refreshWelcomeQuickActions(normalizeSnapshotMessages(state.messages), sessionType)
|
||||
if (
|
||||
!hasMeaningfulSessionMessages(restoredMessages)
|
||||
&& !String(state.conversationId || '').trim()
|
||||
@@ -114,6 +138,7 @@ export function useTravelReimbursementSessionState({
|
||||
attachedFiles: [],
|
||||
composerFilesExpanded: false,
|
||||
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
|
||||
guidedFlowState: normalizeGuidedFlowState(state.guidedFlowState),
|
||||
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)
|
||||
}
|
||||
}
|
||||
@@ -155,6 +180,7 @@ export function useTravelReimbursementSessionState({
|
||||
})
|
||||
const currentInsight = ref(initialSessionState.currentInsight)
|
||||
const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim())
|
||||
const guidedFlowState = ref(normalizeGuidedFlowState(initialSessionState.guidedFlowState))
|
||||
const insightPanelCollapsed = ref(false)
|
||||
const sessionSwitchBusy = ref(false)
|
||||
let knowledgeSessionResetPromise = Promise.resolve()
|
||||
@@ -170,6 +196,7 @@ export function useTravelReimbursementSessionState({
|
||||
reviewFilePreviews: filterPersistableFilePreviews(state.reviewFilePreviews),
|
||||
composerDraft: String(state.composerDraft || ''),
|
||||
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
|
||||
guidedFlowState: normalizeGuidedFlowState(state.guidedFlowState),
|
||||
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)
|
||||
}
|
||||
}
|
||||
@@ -209,6 +236,7 @@ export function useTravelReimbursementSessionState({
|
||||
attachedFiles: runtimeRefs.attachedFiles?.value ?? [],
|
||||
composerFilesExpanded: runtimeRefs.composerFilesExpanded?.value ?? false,
|
||||
composerUploadIntent: composerUploadIntent.value,
|
||||
guidedFlowState: runtimeRefs.guidedFlowState?.value ?? guidedFlowState.value,
|
||||
insightPanelCollapsed: insightPanelCollapsed.value
|
||||
}
|
||||
}
|
||||
@@ -246,6 +274,11 @@ export function useTravelReimbursementSessionState({
|
||||
runtimeRefs.composerFilesExpanded.value = Boolean(nextState.composerFilesExpanded)
|
||||
}
|
||||
composerUploadIntent.value = String(nextState.composerUploadIntent || '').trim()
|
||||
const nextGuidedFlowState = normalizeGuidedFlowState(nextState.guidedFlowState)
|
||||
guidedFlowState.value = nextGuidedFlowState
|
||||
if (runtimeRefs.guidedFlowState) {
|
||||
runtimeRefs.guidedFlowState.value = nextGuidedFlowState
|
||||
}
|
||||
insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed)
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
@@ -322,6 +355,7 @@ export function useTravelReimbursementSessionState({
|
||||
currentInsight,
|
||||
reviewFilePreviews,
|
||||
composerUploadIntent,
|
||||
guidedFlowState,
|
||||
insightPanelCollapsed,
|
||||
sessionSwitchBusy,
|
||||
initialSessionState,
|
||||
|
||||
@@ -685,8 +685,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
|
||||
const orchestratorOptions = isKnowledgeSession.value
|
||||
? {
|
||||
timeoutMs: 18000,
|
||||
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
|
||||
timeoutMs: 75000,
|
||||
timeoutMessage: '知识问答仍在检索整理,已停止等待。请稍后重试,或补充制度名称、费用类型等限定条件。'
|
||||
}
|
||||
: {
|
||||
timeoutMs: 120000,
|
||||
|
||||
Reference in New Issue
Block a user