feat: 完成原型04-10全链路功能开发,包括AI助手、PDF导出等
This commit is contained in:
@@ -1,18 +1,6 @@
|
||||
import { API_DEMO_ENABLED, API_BASE_URL, wait, requestJson } from '@/services/api.uts'
|
||||
import { createAssessment } from './records.uts'
|
||||
export { API_BASE_URL } from '@/services/api.uts'
|
||||
|
||||
export async function submitAssessment(payload: UTSJSONObject): Promise<string> {
|
||||
return await createAssessment(payload)
|
||||
}
|
||||
|
||||
export async function sendAssistantMessage(message: string): Promise<string> {
|
||||
if (API_DEMO_ENABLED) {
|
||||
await wait(600)
|
||||
return '结合您的评估结果,建议优先选择年化利率较低、可随借随还的产品,并重点确认提前还款规则。'
|
||||
}
|
||||
const result = await requestJson('/assistant/messages', 'POST', { message })
|
||||
const reply = result['reply'] as string | null
|
||||
if (reply == null || reply.length == 0) throw new Error('助手暂时没有返回内容')
|
||||
return reply
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { requestJson } from '@/services/api.uts'
|
||||
export type AssistantProduct = { id: string, name: string, state: string, stateLabel: string, demo: boolean }
|
||||
export type AssistantContext = { assessmentId: string, productIds: string[], title: string, products: AssistantProduct[], questions: string[], notice: string }
|
||||
export type AssistantChat = { id: string, title: string, context: AssistantContext }
|
||||
export type AssistantTurn = { id: string, requestId: string, message: string, reply: string, status: string, attempt: number, canRetry: boolean }
|
||||
export type AssistantPage = { items: AssistantTurn[], more: boolean, cursor: string }
|
||||
export type AssistantHistory = { id: string, title: string, createdAt: string }
|
||||
export type AssistantHistoryPage = { items: AssistantHistory[], more: boolean, cursor: string }
|
||||
function text(data: UTSJSONObject, key: string): string { return data.getString(key) ?? '' }
|
||||
function contextView(data: UTSJSONObject): AssistantContext {
|
||||
return { assessmentId: text(data, 'assessmentId'), productIds: data['productIds'] as string[], title: text(data, 'title'),
|
||||
products: (data['products'] as UTSJSONObject[]).map((p: UTSJSONObject): AssistantProduct => ({ id: text(p, 'id'), name: text(p, 'name'), state: text(p, 'state'), stateLabel: text(p, 'stateLabel'), demo: p.getBoolean('isDemo') ?? false })),
|
||||
questions: data['questions'] as string[], notice: text(data, 'notice') }
|
||||
}
|
||||
function chatView(data: UTSJSONObject): AssistantChat { return { id: text(data, 'chatId'), title: text(data, 'title'), context: contextView(data['context'] as UTSJSONObject) } }
|
||||
function turnView(data: UTSJSONObject): AssistantTurn { return { id: text(data, 'id'), requestId: text(data, 'requestId'), message: text(data, 'message'), reply: text(data, 'reply'), status: text(data, 'status'), attempt: data.getNumber('attempt') ?? 0, canRetry: data.getBoolean('canRetry') ?? false } }
|
||||
function contextQuery(assessmentId: string, ids: string[]): UTSJSONObject { return { contextAssessmentId: assessmentId, contextProductIds: ids.join(',') } }
|
||||
export function assistantKey(): string { return 'chat_' + Date.now().toString() + '_' + Math.floor(Math.random() * 1000000000).toString() }
|
||||
export function assistantRoute(assessmentId: string, ids: string[], from: string, chatId: string = ''): string {
|
||||
return '/packages/finance/assistant/assistant?from=' + from + (assessmentId.length > 0 ? '&assessmentId=' + assessmentId + '&productIds=' + ids.join(',') : '') + (chatId.length > 0 ? '&chatId=' + chatId : '')
|
||||
}
|
||||
export async function previewAssistant(assessmentId: string, ids: string[]): Promise<AssistantContext> { return contextView(await requestJson('/assistant/context', 'GET', contextQuery(assessmentId, ids))) }
|
||||
export async function loadAssistant(chatId: string, assessmentId: string, ids: string[], checkBinding: boolean): Promise<AssistantChat> {
|
||||
const result = chatView(await requestJson('/assistant/chats/' + chatId, 'GET', checkBinding ? contextQuery(assessmentId, ids) : new UTSJSONObject()))
|
||||
if (result.id != chatId) throw new Error('返回的会话不一致,请重新加载。')
|
||||
return result
|
||||
}
|
||||
export async function createAssistant(assessmentId: string, ids: string[], requestId: string): Promise<AssistantChat> { return chatView(await requestJson('/assistant/chats', 'POST', { contextAssessmentId: assessmentId.length > 0 ? assessmentId : null, contextProductIds: ids, requestId })) }
|
||||
export async function assistantMessages(chatId: string, cursor: string): Promise<AssistantPage> {
|
||||
const data = await requestJson('/assistant/chats/' + chatId + '/messages', 'GET', { limit: '20', cursor })
|
||||
if (text(data, 'chatId') != chatId) throw new Error('消息不属于本次会话。')
|
||||
return { items: (data['items'] as UTSJSONObject[]).map((v: UTSJSONObject): AssistantTurn => turnView(v)), more: data.getBoolean('hasMore') ?? false, cursor: text(data, 'nextCursor') }
|
||||
}
|
||||
export async function submitAssistant(chatId: string, requestId: string, message: string, attempt: number): Promise<AssistantTurn> {
|
||||
const data = await requestJson('/assistant/chats/' + chatId + '/messages', 'POST', { requestId, message, retryAttempt: attempt })
|
||||
if (text(data, 'chatId') != chatId) throw new Error('回复不属于本次会话。')
|
||||
return turnView(data['item'] as UTSJSONObject)
|
||||
}
|
||||
export async function assistantHistory(cursor: string): Promise<AssistantHistoryPage> {
|
||||
const data = await requestJson('/assistant/chats', 'GET', { limit: '20', cursor })
|
||||
return { items: (data['items'] as UTSJSONObject[]).map((v: UTSJSONObject): AssistantHistory => ({ id: text(v, 'chatId'), title: text(v, 'title'), createdAt: text(v, 'createdAt').slice(0, 10) })), more: data.getBoolean('hasMore') ?? false, cursor: text(data, 'nextCursor') }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { API_BASE_URL, getSession, clearSession } from '@/services/api.uts'
|
||||
import { APP_ENVIRONMENT } from '@/services/config.uts'
|
||||
|
||||
export type ReportDownload = { cancel: () => void, done: Promise<string> }
|
||||
|
||||
export function downloadReportPdf(id: string): ReportDownload {
|
||||
// 首版仅开放普通浏览器H5;其他端明确提示,不生成假文件或成功回执。
|
||||
// #ifndef H5
|
||||
return { cancel: () => {}, done: new Promise<string>((_resolve, reject) => { reject(new Error('当前客户端暂未开放PDF下载,请在普通浏览器打开H5并登录后下载。')) }) }
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
const controller = new AbortController()
|
||||
let cancelled = false
|
||||
const cancel = () => { cancelled = true; controller.abort() }
|
||||
const done = async (): Promise<string> => {
|
||||
if (!/^[1-9][0-9]{0,9}$/.test(id)) throw new Error('报告编号无效,请重新打开本人报告。')
|
||||
if (/MicroMessenger/i.test(navigator.userAgent)) throw new Error('微信内浏览器暂不支持此下载。请点右上角菜单,选择“在浏览器打开”,登录后重新点击下载PDF。')
|
||||
const token = getSession()?.getString('token') ?? ''
|
||||
if (token.length == 0) throw new Error('登录已过期,请重新登录后下载。')
|
||||
if (API_BASE_URL.length == 0 || (APP_ENVIRONMENT == 'production' && !API_BASE_URL.startsWith('https://'))) throw new Error('下载服务地址尚未配置。')
|
||||
const current = () => {
|
||||
if (cancelled || getSession()?.getString('token') != token) throw new Error('页面或登录状态已变化,本次下载已取消。')
|
||||
}
|
||||
const timeout = setTimeout(() => controller.abort(), 45000)
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL + '/assessments/' + id + '/report.pdf', {
|
||||
method: 'GET', headers: { Authorization: 'Bearer ' + token, 'X-Requested-With': 'XMLHttpRequest' },
|
||||
cache: 'no-store', credentials: 'omit', redirect: 'error', signal: controller.signal
|
||||
})
|
||||
current()
|
||||
if (response.status == 401) { clearSession(); throw new Error('登录已过期,请重新登录后下载。') }
|
||||
const mime = (response.headers.get('Content-Type') ?? '').split(';')[0].trim().toLowerCase()
|
||||
if (!response.ok || mime != 'application/pdf') {
|
||||
let message = 'PDF暂不可下载,请稍后重试;已保存报告不受影响。'
|
||||
if (mime == 'application/json') {
|
||||
const body = await response.json()
|
||||
if (typeof body.msg == 'string' && body.msg.length > 0 && body.msg.length < 200) message = body.msg
|
||||
}
|
||||
current(); throw new Error(message)
|
||||
}
|
||||
const declaredSize = Number(response.headers.get('Content-Length') ?? '0')
|
||||
if (declaredSize > 20971520) throw new Error('PDF文件过大,请联系管理员。')
|
||||
const bytes = await response.arrayBuffer()
|
||||
current()
|
||||
const start = new TextDecoder('ascii').decode(bytes.slice(0, 5))
|
||||
const end = new TextDecoder('ascii').decode(bytes.slice(Math.max(0, bytes.byteLength - 1024)))
|
||||
if (bytes.byteLength < 100 || bytes.byteLength > 20971520 || start != '%PDF-' || !end.includes('%%EOF')) throw new Error('下载内容不是完整PDF,请重试。')
|
||||
// 验证响应后才创建本机Blob。令牌只放请求头,不写URL、文件名或本机存储。
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }))
|
||||
const link = document.createElement('a')
|
||||
link.href = url; link.download = 'financing-report-' + id + '.pdf'; link.style.display = 'none'
|
||||
document.body.appendChild(link); link.click(); link.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
return 'PDF已交给浏览器处理,请查看下载列表;若打开预览,可使用浏览器的保存功能。'
|
||||
} catch (err) {
|
||||
if (cancelled) throw new Error('下载已取消。')
|
||||
if (controller.signal.aborted) throw new Error('PDF生成超时,请稍后重试。')
|
||||
if (err instanceof TypeError) throw new Error('网络连接失败,请检查网络后重试。')
|
||||
throw err
|
||||
} finally { clearTimeout(timeout) }
|
||||
}
|
||||
return { cancel, done: done() }
|
||||
// #endif
|
||||
}
|
||||
Reference in New Issue
Block a user