feat: 报销预审会话状态管理与工作台交互增强

- 新增差旅报销会话状态管理与对话模型重构
- 增强风险观测服务与运行时聊天上下文作用域
- 优化工作台图标资源、助理意图识别与摘要工具
- 完善报销创建视图样式与差旅详情页标准调整交互
- 补充风险观测、运行时聊天与报销端点测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-04 11:03:29 +08:00
parent 87da5df91b
commit 1cbf3fee44
60 changed files with 4156 additions and 393 deletions

138
web/src/services/steward.js Normal file
View File

@@ -0,0 +1,138 @@
import { apiRequest, getRuntimeApiBaseUrl } from './api.js'
export function fetchStewardPlan(payload, options = {}) {
return apiRequest('/steward/plans', {
method: 'POST',
body: JSON.stringify(payload),
...options
})
}
export async function fetchStewardPlanStream(payload, handlers = {}, options = {}) {
const {
timeoutMs = 0,
timeoutMessage = '小财管家任务规划超时,请稍后重试。'
} = options
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
const timeoutId = controller && Number(timeoutMs) > 0
? globalThis.setTimeout(() => controller.abort(), Number(timeoutMs))
: 0
let response
try {
response = await fetch(`${getRuntimeApiBaseUrl()}/steward/plans/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload),
signal: controller?.signal
})
} catch (error) {
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
if (error?.name === 'AbortError') {
throw new Error(timeoutMessage)
}
throw new Error('无法连接小财管家流式服务,请确认后端已启动。')
}
if (!response.ok) {
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
throw new Error(await resolveStreamError(response))
}
if (!response.body?.getReader) {
const text = await response.text()
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
return consumeNdjsonText(text, handlers)
}
const decoder = new TextDecoder('utf-8')
const reader = response.body.getReader()
let buffer = ''
let finalPlan = null
try {
while (true) {
const { value, done } = await reader.read()
if (done) {
break
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const event = parseStreamLine(line)
if (!event) continue
finalPlan = handleStreamEvent(event, handlers) || finalPlan
}
}
buffer += decoder.decode()
if (buffer.trim()) {
const event = parseStreamLine(buffer)
if (event) {
finalPlan = handleStreamEvent(event, handlers) || finalPlan
}
}
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(timeoutMessage)
}
throw error
} finally {
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
}
if (!finalPlan) {
throw new Error('小财管家流式结果缺少最终任务计划。')
}
return finalPlan
}
async function resolveStreamError(response) {
try {
const payload = await response.json()
return String(payload?.detail || payload?.message || '').trim() || '小财管家流式接口请求失败。'
} catch {
return '小财管家流式接口请求失败。'
}
}
function consumeNdjsonText(text, handlers) {
let finalPlan = null
String(text || '').split('\n').forEach((line) => {
const event = parseStreamLine(line)
if (!event) return
finalPlan = handleStreamEvent(event, handlers) || finalPlan
})
return finalPlan
}
function parseStreamLine(line) {
const normalized = String(line || '').trim()
if (!normalized) {
return null
}
return JSON.parse(normalized)
}
function handleStreamEvent(event, handlers) {
if (event.event === 'error') {
throw new Error(String(event.data?.message || '').trim() || '小财管家规划失败,请稍后重试。')
}
handlers.onEvent?.(event)
if (event.event === 'plan') {
return event.data
}
return null
}