feat: 完成原型04-10全链路功能开发,包括AI助手、PDF导出等
This commit is contained in:
@@ -27,7 +27,23 @@
|
||||
import { API_BASE_URL, requestSmsCode, loginWithSms, loginWithPassword } from '@/packages/account/services/auth.uts'
|
||||
import { showAgreementNotice } from '@/services/assessment.uts'
|
||||
import { consumeInitialLaunch } from '@/services/launch.uts'
|
||||
onLoad(() => { consumeInitialLaunch() })
|
||||
import { assistantReturnPath } from '@/services/assistant-return.uts'
|
||||
import { nextStepRoute } from '@/services/next-step-route.uts'
|
||||
import { reportRoute } from '@/services/report-route.uts'
|
||||
let afterLoginUrl = '/pages/home/home'
|
||||
onLoad((options) => {
|
||||
consumeInitialLaunch()
|
||||
if (options?.['resumeAssistant'] == '1') {
|
||||
const target = assistantReturnPath(options?.['assessmentId'] ?? '', options?.['productIds'] ?? '', options?.['chatId'] ?? '', options?.['from'] ?? '')
|
||||
if (target.length > 0) afterLoginUrl = target
|
||||
} else if (options?.['resumeNextStep'] == '1') {
|
||||
const target = nextStepRoute(options?.['assessmentId'] ?? '', options?.['productId'] ?? '', options?.['section'] ?? 'actions')
|
||||
if (target.length > 0) afterLoginUrl = target
|
||||
} else if (options?.['resumeReport'] == '1') {
|
||||
const target = reportRoute(options?.['assessmentId'] ?? '', options?.['tab'] ?? 'overview')
|
||||
if (target.length > 0) afterLoginUrl = target
|
||||
}
|
||||
})
|
||||
const { statusStyle, contentStyle, pageStyle } = usePageLayout()
|
||||
const mode = ref('password')
|
||||
const phone = ref('')
|
||||
@@ -58,7 +74,7 @@
|
||||
if (countdown.value <= 1) { countdown.value = 0; clearInterval(countdownTimer) } else countdown.value -= 1
|
||||
}, 1000)
|
||||
}
|
||||
const finishLogin = (_token: string) => { uni.redirectTo({ url: '/pages/home/home' }) }
|
||||
const finishLogin = (_token: string) => { uni.redirectTo({ url: afterLoginUrl }) }
|
||||
const resendSms = async () => {
|
||||
if (loading.value || countdown.value > 0 || !validPhone()) return
|
||||
if (!agreed.value) { errorMessage.value = '请先阅读并勾选用户协议与隐私政策'; return }
|
||||
|
||||
@@ -1,92 +1,228 @@
|
||||
<template>
|
||||
<view class="assistant-page page" :style="pageStyle()">
|
||||
<AppHeader title="AI融资助手" />
|
||||
<scroll-view scroll-y class="chat-scroll" :scroll-into-view="lastMessageId" :style="contentStyle(210, false)">
|
||||
<view class="chat-content">
|
||||
<view class="assistant-intro"><image src="/packages/finance/static/robot.png" mode="aspectFit" class="assistant-intro-image"></image><view class="assistant-bubble"><text class="bubble-text">您好!我是AI融资助手,可以帮您了解融资条件、准备材料和比较方案。</text></view></view>
|
||||
<view class="quick-questions">
|
||||
<view class="question" v-for="item in questions" :key="item" @click="ask(item)"><text class="question-text">{{ item }}</text><AppIcon name="chevron-right" :size="28" muted /></view>
|
||||
</view>
|
||||
<view v-for="(item,index) in messages" :key="index" :id="'message-' + index" :class="item.role == 'user' ? 'message-row user-row' : 'message-row'">
|
||||
<image v-if="item.role == 'assistant'" class="message-avatar" src="/packages/finance/static/robot.png" mode="aspectFit"></image>
|
||||
<view :class="item.role == 'user' ? 'message-bubble user-bubble' : 'message-bubble'"><text :class="item.role == 'user' ? 'bubble-text user-text' : 'bubble-text'">{{ item.text }}</text></view>
|
||||
</view>
|
||||
<view v-if="sending" class="message-row"><image class="message-avatar" src="/packages/finance/static/robot.png" mode="aspectFit"></image><view class="message-bubble"><text class="sending-text">正在整理建议…</text></view></view>
|
||||
<view v-if="errorMessage.length > 0" class="assistant-error"><text class="error-text">{{ errorMessage }}</text><text @click="retry" class="retry-text">重试</text></view>
|
||||
<view id="chat-end" class="chat-end"></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="chat-input" :style="bottomStyle(17)"><input v-model="draft" confirm-type="send" @confirm="send" placeholder="请输入您的问题..." class="chat-input-input" /><button @click="send" :disabled="sending || draft.trim().length == 0" :class="sending || draft.trim().length == 0 ? 'chat-input-button send-disabled' : 'chat-input-button'"><AppIcon name="send" :size="40" /></button></view>
|
||||
</view>
|
||||
<view class="page assistant-page" :style="pageStyle()">
|
||||
<AppHeader title="AI融资助手" :customBack="true" @back="back" />
|
||||
<view class="chat-shell">
|
||||
<view class="chat-toolbar"><text class="toolbar-note">{{ chatId.length > 0 ? '对话已保存' : '您的融资问题,继续聊聊' }}</text><view class="toolbar-actions"><text class="toolbar-link" @click="showHistory">历史对话</text><text class="toolbar-link" @click="newChat">新对话</text></view></view>
|
||||
<scroll-view scroll-y class="chat-scroll" :scroll-into-view="scrollTarget" :style="contentStyle(300, false)">
|
||||
<view v-if="active" class="chat-content">
|
||||
<view v-if="historyOpen" class="history-panel">
|
||||
<view class="history-title"><text class="context-title">历史对话</text><text class="toolbar-link" @click="historyOpen = false">收起</text></view>
|
||||
<text v-if="histories.length == 0 && !historyLoading" class="status-text">暂无历史对话</text>
|
||||
<view v-for="item in histories" :key="item.id" class="history-item" @click="openHistory(item.id)"><text class="history-label">{{ item.title }}</text><text class="history-date">{{ item.createdAt }} ›</text></view>
|
||||
<text v-if="historyError.length > 0" class="error-text">{{ historyError }}</text>
|
||||
<text v-if="historyMore || historyError.length > 0" class="toolbar-link history-more" @click="loadHistory">{{ historyLoading ? '正在加载…' : '加载更多对话' }}</text>
|
||||
</view>
|
||||
<text v-if="loading" class="status-text">正在读取对话上下文…</text>
|
||||
<view v-if="pageError.length > 0" class="error-panel"><text class="error-title">暂时无法继续对话</text><text class="error-text">{{ pageError }}</text><text v-if="routeError.length == 0" class="toolbar-link" @click="load">重新加载</text><text class="toolbar-link" @click="back">返回来源页面</text></view>
|
||||
<template v-if="context != null">
|
||||
<view v-if="context!.assessmentId.length > 0" class="context-card">
|
||||
<view class="context-heading"><text class="context-title">{{ context!.title }}</text><text class="context-link" @click="source">{{ sourceLabel }}</text></view>
|
||||
<text v-if="context!.products.length == 0" class="context-note">围绕这份已保存报告解答,不重新评估</text>
|
||||
<view v-for="item in context!.products" :key="item.id" class="context-product"><text class="context-product-name">{{ item.name }}</text><text :class="item.state == 'eligible' ? 'product-state good-state' : 'product-state'">{{ item.stateLabel }}</text></view>
|
||||
<text v-if="hasDemo" class="context-note">演示产品 · 仅供功能验证</text>
|
||||
</view>
|
||||
<view class="assistant-intro"><image src="/packages/finance/static/robot.png" mode="aspectFit" class="intro-image" /><view class="intro-bubble"><text class="bubble-text">您好!我是您的 AI 融资助手。
|
||||
{{ context!.assessmentId.length > 0 ? '我可以结合本次报告,帮您理解产品条件、匹配原因和需要准备的资料。' : '我可以帮您解答关于银行产品、申请条件、资料准备等问题。' }}</text></view></view>
|
||||
<view v-if="messages.length == 0" class="quick-questions"><view v-for="question in context!.questions" :key="question" class="question" @click="chooseQuestion(question)"><text class="question-text">{{ question }}</text></view><text class="question-hint">点击问题后,可编辑并发送</text></view>
|
||||
<text v-if="more" class="load-more" @click="older">{{ paging ? '正在加载…' : '加载更早的消息' }}</text>
|
||||
<view v-for="item in messages" :key="item.requestId" class="turn">
|
||||
<view class="message-row user-row"><view class="user-bubble"><text class="user-text" :selectable="true">{{ item.message }}</text></view></view>
|
||||
<view class="message-row"><image class="message-avatar" src="/packages/finance/static/robot.png" mode="aspectFit" /><view class="message-bubble"><text v-if="item.status == 'completed'" class="bubble-text" :selectable="true">{{ item.reply }}</text><text v-else class="status-text">{{ statusLabel(item) }}</text><text v-if="item.canRetry && item.requestId == lastRequestId && !pending" class="retry-link" @click="retryTurn(item)">重新生成回复</text></view></view>
|
||||
</view>
|
||||
<view v-if="sendError.length > 0" class="send-error"><text class="error-text">{{ sendError }}</text><text v-if="uncertain" class="retry-link" @click="retryTransport">重试本次发送</text></view>
|
||||
<text v-if="pollStopped || queryError.length > 0" class="load-more" @click="resumeQuery">{{ queryError.length > 0 ? queryError + ' · 重新查询' : '回复仍在处理中,点击继续查询' }}</text>
|
||||
<view id="chat-end" class="chat-end" />
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="composer" :style="bottomStyle(16)"><view class="input-row"><input v-model="draft" :disabled="context == null || sending || pending || uncertain" :maxlength="800" confirm-type="send" @confirm="send" placeholder="请输入您的问题…" class="chat-input" /><button class="send-button" :disabled="!canSend" @click="send"><image src="/packages/finance/static/send.svg" class="send-icon" mode="aspectFit" /></button></view><text class="composer-note">AI仅供参考,不代表审批结果 · 请勿输入证件或银行卡号</text></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
const { bottomStyle, contentStyle, pageStyle } = usePageLayout()
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import AppIcon from '@/components/AppIcon.uvue'
|
||||
import { sendAssistantMessage } from '@/packages/finance/services/api.uts'
|
||||
const questions = ['我的融资额度受哪些因素影响?', '如何提高贷款通过率?', '企业融资需要哪些材料?', '有哪些适合我的方案?']
|
||||
type ChatMessage = { role: string, text: string }
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const draft = ref('')
|
||||
const lastMessageId = ref('')
|
||||
const lastQuestion = ref('')
|
||||
const sending = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const scrollToEnd = async () => {
|
||||
lastMessageId.value = ''
|
||||
await nextTick()
|
||||
lastMessageId.value = 'chat-end'
|
||||
}
|
||||
const requestAnswer = async (text: string) => {
|
||||
sending.value = true
|
||||
errorMessage.value = ''
|
||||
scrollToEnd()
|
||||
try { const answer = await sendAssistantMessage(text); const response: ChatMessage = { role: 'assistant', text: answer }; messages.value.push(response) } catch (error) { errorMessage.value = '暂未收到回复,请重试' }
|
||||
sending.value = false
|
||||
scrollToEnd()
|
||||
}
|
||||
const send = () => {
|
||||
const text = draft.value.trim()
|
||||
if (text.length == 0 || sending.value) return
|
||||
lastQuestion.value = text
|
||||
const message: ChatMessage = { role: 'user', text: text }
|
||||
messages.value.push(message)
|
||||
draft.value = ''
|
||||
requestAnswer(text)
|
||||
}
|
||||
const ask = (text: string) => { if (sending.value) return; draft.value = text; send() }
|
||||
const retry = () => { if (!sending.value && lastQuestion.value.length > 0) requestAnswer(lastQuestion.value) }
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { validRecordId } from '@/packages/finance/services/matches.uts'
|
||||
import { parseCompareIds } from '@/packages/finance/services/compare.uts'
|
||||
import { AssistantProduct, AssistantContext, AssistantTurn, AssistantHistory, assistantKey, assistantRoute, previewAssistant, loadAssistant, createAssistant, assistantMessages, submitAssistant, assistantHistory } from '@/packages/finance/services/assistant.uts'
|
||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||
const assessmentId = ref(''); const productIds = ref<string[]>([]); const chatId = ref(''); const from = ref('')
|
||||
const context = ref<AssistantContext | null>(null); const messages = ref<AssistantTurn[]>([])
|
||||
const active = ref(false); const loading = ref(false); const sending = ref(false); const paging = ref(false)
|
||||
const draft = ref(''); const pageError = ref(''); const routeError = ref(''); const sendError = ref(''); const queryError = ref('')
|
||||
const scrollTarget = ref(''); const more = ref(false); const cursor = ref(''); const pollStopped = ref(false)
|
||||
const historyOpen = ref(false); const histories = ref<AssistantHistory[]>([]); const historyMore = ref(false); const historyLoading = ref(false); const historyError = ref('')
|
||||
const uncertain = ref(false)
|
||||
let historyCursor = ''; let createKey = assistantKey(); let bindingSpecified = false; let generation = 0; let scope = ''; let timer = 0; let polling = false; let pollCount = 0
|
||||
let sendKey = ''; let sendText = ''; let sendAttempt = 0
|
||||
const pending = computed((): boolean => messages.value.some((t: AssistantTurn): boolean => t.status == 'queued' || t.status == 'running'))
|
||||
const hasDemo = computed((): boolean => context.value?.products.some((p: AssistantProduct): boolean => p.demo) ?? false)
|
||||
const lastRequestId = computed((): string => messages.value.length > 0 ? messages.value[messages.value.length - 1].requestId : '')
|
||||
const canSend = computed((): boolean => context.value != null && !sending.value && !pending.value && !uncertain.value && draft.value.trim().length > 0)
|
||||
const sourceLabel = computed((): string => from.value == 'next-step' ? '返回下一步行动 ›' : (from.value == 'product' && productIds.value.length == 1 ? '返回产品 ›' : (from.value == 'compare' ? '返回对比 ›' : '查看报告 ›')))
|
||||
const statusLabel = (item: AssistantTurn): string => item.status == 'queued' ? '问题已收到,等待AI回复…' : (item.status == 'running' ? '正在整理建议…' : (item.status == 'local' ? (sending.value ? '正在确认发送结果…' : '尚未确认发送结果,请查看下方提示。') : '回复暂未完成。' + (item.canRetry ? '您可以重新尝试。' : '已达到本条消息重试上限。')))
|
||||
const source = () => {
|
||||
if (assessmentId.value.length == 0) { uni.reLaunch({ url: '/pages/home/home' }); return }
|
||||
let url = '/packages/finance/report/report?assessmentId=' + assessmentId.value
|
||||
if (from.value == 'product' && productIds.value.length == 1) url = '/packages/finance/product/product?assessmentId=' + assessmentId.value + '&productId=' + productIds.value[0] + '&tab=explanation'
|
||||
if (from.value == 'compare') url = '/packages/finance/compare/compare?assessmentId=' + assessmentId.value + '&productIds=' + productIds.value.join(',')
|
||||
if (from.value == 'next-step') url = '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + (productIds.value.length == 1 ? '&productId=' + productIds.value[0] + '§ion=prepare' : '§ion=actions')
|
||||
uni.redirectTo({ url })
|
||||
}
|
||||
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else source() }
|
||||
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeAssistant=1&assessmentId=' + assessmentId.value + '&productIds=' + productIds.value.join(',') + '&chatId=' + chatId.value + '&from=' + from.value }) }
|
||||
const stopTimer = () => { if (timer != 0) { clearInterval(timer); timer = 0 } }
|
||||
const clearContent = () => { context.value = null; messages.value = []; draft.value = ''; sendText = ''; sendKey = ''; uncertain.value = false; histories.value = []; historyCursor = ''; historyOpen.value = false; sendError.value = ''; queryError.value = '' }
|
||||
// 请求代次与登录Token双重校验;离页、退出、换账号后旧响应不得回填个人对话。
|
||||
const current = (ticket: number, token: string): boolean => {
|
||||
if (!active.value || ticket != generation) return false
|
||||
if (getSession()?.getString('token') == token) return true
|
||||
generation++; clearContent(); stopTimer(); loading.value = false; sending.value = false
|
||||
pageError.value = '登录状态已变化,请重新登录后读取本人对话。'
|
||||
if (getSession() == null) login()
|
||||
return false
|
||||
}
|
||||
const scrollEnd = async () => { scrollTarget.value = ''; await nextTick(); if (active.value) scrollTarget.value = 'chat-end' }
|
||||
const merge = (items: AssistantTurn[]) => { items.forEach((item: AssistantTurn) => { const index = messages.value.findIndex((old: AssistantTurn): boolean => old.requestId == item.requestId); if (index >= 0) messages.value[index] = item; else messages.value.push(item) }) }
|
||||
const refreshMessages = async () => {
|
||||
if (chatId.value.length == 0 || polling || loading.value) return
|
||||
const ticket = generation; const token = scope; polling = true; queryError.value = ''
|
||||
try { const page = await assistantMessages(chatId.value, ''); if (!current(ticket, token)) return; merge(page.items); pollStopped.value = false; if (pending.value) pollCount++; else { pollCount = 0; scrollEnd() } }
|
||||
catch (error) { if (current(ticket, token)) queryError.value = error instanceof Error ? error.message : '状态读取失败' }
|
||||
finally { if (ticket == generation) polling = false }
|
||||
}
|
||||
const resumeQuery = () => { pollCount = 0; refreshMessages() }
|
||||
const beginTimer = () => {
|
||||
stopTimer(); timer = setInterval(() => {
|
||||
if (!current(generation, scope)) return
|
||||
if (pending.value && !polling && !sending.value && queryError.value.length == 0) { if (pollCount >= 60) { pollStopped.value = true; return }; refreshMessages() }
|
||||
}, 3000)
|
||||
}
|
||||
const load = async () => {
|
||||
const ticket = ++generation; clearContent(); stopTimer(); polling = false; more.value = false; cursor.value = ''; pageError.value = ''; loading.value = false
|
||||
if (routeError.value.length > 0) { pageError.value = routeError.value; return }
|
||||
const session = getSession()
|
||||
if (session == null) { pageError.value = '请先登录后使用AI助手。'; login(); return }
|
||||
scope = session.getString('token') ?? ''; const token = scope; loading.value = true
|
||||
try {
|
||||
if (chatId.value.length > 0) {
|
||||
const chat = await loadAssistant(chatId.value, assessmentId.value, productIds.value, bindingSpecified)
|
||||
if (!current(ticket, token)) return
|
||||
const page = await assistantMessages(chatId.value, '')
|
||||
if (!current(ticket, token)) return
|
||||
context.value = chat.context; assessmentId.value = chat.context.assessmentId; productIds.value = chat.context.productIds
|
||||
messages.value = page.items; more.value = page.more; cursor.value = page.cursor; scrollEnd()
|
||||
} else { const data = await previewAssistant(assessmentId.value, productIds.value); if (!current(ticket, token)) return; context.value = data }
|
||||
pollCount = 0; pollStopped.value = false; beginTimer()
|
||||
} catch (error) { if (current(ticket, token)) { context.value = null; messages.value = []; pageError.value = error instanceof Error ? error.message : '对话读取失败' } }
|
||||
finally { if (ticket == generation) loading.value = false }
|
||||
}
|
||||
const older = async () => {
|
||||
if (paging.value || !more.value) return
|
||||
const ticket = generation; const token = scope; paging.value = true; queryError.value = ''
|
||||
try { const page = await assistantMessages(chatId.value, cursor.value); if (!current(ticket, token)) return; messages.value = page.items.filter((t: AssistantTurn): boolean => !messages.value.some((old: AssistantTurn): boolean => old.requestId == t.requestId)).concat(messages.value); more.value = page.more; cursor.value = page.cursor }
|
||||
catch (error) { if (current(ticket, token)) queryError.value = error instanceof Error ? error.message : '历史消息读取失败' }
|
||||
finally { if (ticket == generation) paging.value = false }
|
||||
}
|
||||
const updateUrl = () => {
|
||||
// H5仅更新地址中的ID,刷新后重新鉴权;不把消息、Token或报告写入URL/本地缓存。
|
||||
// #ifdef WEB
|
||||
history.replaceState(history.state, '', location.pathname + location.search + '#' + assistantRoute(assessmentId.value, productIds.value, from.value, chatId.value))
|
||||
// #endif
|
||||
}
|
||||
const transmit = async () => {
|
||||
if (sending.value || context.value == null) return
|
||||
const ticket = generation; const token = scope; sending.value = true; sendError.value = ''; uncertain.value = true
|
||||
try {
|
||||
if (chatId.value.length == 0) { const chat = await createAssistant(assessmentId.value, productIds.value, createKey); if (!current(ticket, token)) return; chatId.value = chat.id; updateUrl() }
|
||||
const item = await submitAssistant(chatId.value, sendKey, sendText, sendAttempt)
|
||||
if (!current(ticket, token)) return
|
||||
merge([item]); uncertain.value = false; draft.value = ''; pollCount = 0; pollStopped.value = false; scrollEnd()
|
||||
} catch (error) { if (current(ticket, token)) sendError.value = error instanceof Error ? error.message : '发送结果未确认,请重试本次发送' }
|
||||
finally { if (ticket == generation) sending.value = false }
|
||||
}
|
||||
const send = () => { if (!canSend.value) return; sendKey = assistantKey(); sendText = draft.value.trim(); sendAttempt = 0; merge([{ id: '', requestId: sendKey, message: sendText, reply: '', status: 'local', attempt: 0, canRetry: false }]); scrollEnd(); transmit() }
|
||||
const retryTransport = () => { if (!sending.value && uncertain.value) transmit() }
|
||||
const retryTurn = (item: AssistantTurn) => { if (sending.value || pending.value || uncertain.value || !item.canRetry || item.requestId != lastRequestId.value) return; sendKey = item.requestId; sendText = item.message; sendAttempt = item.attempt + 1; transmit() }
|
||||
const chooseQuestion = (text: string) => { if (!sending.value && !pending.value && !uncertain.value) draft.value = text }
|
||||
const loadHistory = async () => {
|
||||
if (historyLoading.value) return
|
||||
const ticket = generation; const token = scope; historyLoading.value = true; historyError.value = ''
|
||||
try { const page = await assistantHistory(historyCursor); if (!current(ticket, token)) return; histories.value = histories.value.concat(page.items); historyMore.value = page.more; historyCursor = page.cursor }
|
||||
catch (error) { if (current(ticket, token)) historyError.value = error instanceof Error ? error.message : '历史对话读取失败' }
|
||||
finally { if (ticket == generation) historyLoading.value = false }
|
||||
}
|
||||
const showHistory = () => { historyOpen.value = !historyOpen.value; if (historyOpen.value && histories.value.length == 0) loadHistory() }
|
||||
const openHistory = (id: string) => { uni.redirectTo({ url: assistantRoute('', [], 'archive', id) }) }
|
||||
const newChat = () => { uni.redirectTo({ url: assistantRoute(assessmentId.value, productIds.value, from.value) }) }
|
||||
onLoad((options) => {
|
||||
assessmentId.value = options?.['assessmentId'] ?? ''; chatId.value = options?.['chatId'] ?? ''; from.value = options?.['from'] ?? ''; bindingSpecified = options?.['assessmentId'] != null || options?.['productIds'] != null
|
||||
try {
|
||||
productIds.value = parseCompareIds(options?.['productIds'] ?? '')
|
||||
if ((assessmentId.value.length > 0 && !validRecordId(assessmentId.value)) || (chatId.value.length > 0 && !validRecordId(chatId.value)) || (productIds.value.length > 0 && assessmentId.value.length == 0)) throw new Error('会话或评估参数无效,请返回重新选择。')
|
||||
if (!['','matches','product','compare','report','next-step','archive'].includes(from.value)) throw new Error('来源页面无效。')
|
||||
} catch (error) { routeError.value = error instanceof Error ? error.message : '参数无效' }
|
||||
})
|
||||
onShow(() => { active.value = true; load() })
|
||||
onHide(() => { active.value = false; generation++; stopTimer(); clearContent(); loading.value = false; sending.value = false; paging.value = false; historyLoading.value = false })
|
||||
onUnload(() => { active.value = false; generation++; stopTimer(); clearContent() })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.assistant-page { background-color: #f4f7fb; }
|
||||
.chat-scroll { }
|
||||
.chat-content { padding: 28rpx 26rpx 50rpx; }
|
||||
.assistant-intro { flex-direction: row; align-items: flex-start; }
|
||||
.assistant-intro-image, .message-avatar { width: 78rpx; height: 78rpx; border-radius: 24rpx; background-color: #e7f2ff; margin-right: 16rpx; }
|
||||
.assistant-bubble, .message-bubble { max-width: 530rpx; padding: 24rpx; border-radius: 8rpx 22rpx 22rpx 22rpx; background-color: #fff; color: #344259; font-size: 25rpx; line-height: 42rpx; border: 1rpx solid #e4ebf5; }
|
||||
.quick-questions { margin: 25rpx 0 36rpx 94rpx; }
|
||||
.question { min-height: 72rpx; margin-top: 13rpx; padding: 14rpx 18rpx; border-radius: 16rpx; border: 2rpx solid #bcd9ff; background-color: #eef6ff; color: #0868f7; font-size: 23rpx; flex-direction: row; align-items: center; justify-content: space-between; }
|
||||
.question-arrow { font-size: 34rpx; }
|
||||
.message-row { margin-top: 22rpx; flex-direction: row; align-items: flex-start; }
|
||||
.user-row { justify-content: flex-end; }
|
||||
.user-bubble { color: #fff; background-color: #0868f7; border-color: #0868f7; border-radius: 22rpx 8rpx 22rpx 22rpx; }
|
||||
.chat-input { position: fixed; left: 0; right: 0; bottom: 0; min-height: 114rpx; padding: 17rpx 24rpx; background-color: #fff; border-top: 1rpx solid #e3eaf4; flex-direction: row; align-items: center; }
|
||||
.chat-input-input { flex: 1; height: 78rpx; border-radius: 39rpx; background-color: #f2f5f9; padding: 0 28rpx; font-size: 25rpx; }
|
||||
.chat-input-button { width: 76rpx; height: 76rpx; line-height: 76rpx; border-radius: 50%; margin-left: 15rpx; padding: 0; background-color: #0868f7; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.send-disabled { opacity: .55; }
|
||||
/* #ifndef APP */
|
||||
.chat-input-button { display: flex; }
|
||||
/* #endif */
|
||||
.bubble-text { font-size: 27rpx; line-height: 43rpx; color: #344259; }
|
||||
.user-text { color: #ffffff; }
|
||||
.question-text { flex: 1; margin-right: 12rpx; color: #0868f7; font-size: 24rpx; line-height: 38rpx; }
|
||||
.sending-text { font-size: 25rpx; line-height: 40rpx; color: #75869d; }
|
||||
.error-text { flex: 1; color: #bb4857; font-size: 23rpx; line-height: 36rpx; }
|
||||
.retry-text { color: #0868f7; font-size: 25rpx; padding: 0 6rpx; }
|
||||
.chat-end { height: 12rpx; }
|
||||
.assistant-error { margin: 18rpx 0 0 94rpx; color: #e05a5a; font-size: 21rpx; flex-direction: row; justify-content: space-between; }
|
||||
.assistant-error-text-last-child { color: #0868f7; margin-left: 18rpx; }
|
||||
.assistant-page { background-color: #fcfdff; }
|
||||
.chat-shell { flex: 1; }
|
||||
.chat-toolbar { padding: 8rpx 30rpx 18rpx; min-height: 60rpx; flex-direction: row; align-items: center; justify-content: space-between; }
|
||||
.toolbar-note { color: #95a1b3; font-size: 21rpx; }
|
||||
.toolbar-actions { flex-direction: row; }
|
||||
.toolbar-link { color: #2672d9; font-size: 23rpx; line-height: 40rpx; margin-left: 22rpx; }
|
||||
.chat-content { padding: 14rpx 30rpx 36rpx; }
|
||||
.context-card { padding: 20rpx 22rpx; margin-bottom: 30rpx; border-radius: 14rpx; background-color: #f0f6ff; border: 1rpx solid #e0ebfb; }
|
||||
.context-heading, .context-product, .history-title { flex-direction: row; justify-content: space-between; align-items: center; }
|
||||
.context-title { color: #315578; font-size: 24rpx; font-weight: 600; }
|
||||
.context-link { color: #2672d9; font-size: 21rpx; }
|
||||
.context-note { color: #7b8da7; font-size: 21rpx; line-height: 34rpx; margin-top: 6rpx; }
|
||||
.context-product { margin-top: 12rpx; align-items: flex-start; }
|
||||
.context-product-name { flex: 1; color: #506580; font-size: 23rpx; line-height: 34rpx; margin-right: 14rpx; }
|
||||
.product-state { color: #bc8151; font-size: 21rpx; line-height: 34rpx; flex-shrink: 0; }
|
||||
.good-state { color: #289875; }
|
||||
.assistant-intro { flex-direction: row; align-items: flex-start; margin-top: 10rpx; }
|
||||
.intro-image { width: 120rpx; height: 138rpx; margin: 6rpx 12rpx 0 -6rpx; flex-shrink: 0; }
|
||||
.intro-bubble { flex: 1; padding: 22rpx 24rpx; border-radius: 8rpx 22rpx 22rpx 22rpx; background-color: #f3f8ff; }
|
||||
.bubble-text { font-size: 26rpx; line-height: 44rpx; color: #4e5e75; }
|
||||
.quick-questions { margin: 34rpx 0 30rpx 26rpx; align-items: flex-start; }
|
||||
.question { padding: 12rpx 23rpx; margin-bottom: 18rpx; max-width: 630rpx; border-radius: 32rpx; background-color: #edf7ff; }
|
||||
.question-text { color: #2674bd; font-size: 25rpx; line-height: 38rpx; }
|
||||
.question-hint { color: #a1abba; font-size: 20rpx; margin: 0 0 0 20rpx; }
|
||||
.message-row { flex-direction: row; align-items: flex-start; margin-top: 24rpx; }
|
||||
.user-row { justify-content: flex-end; }
|
||||
.message-avatar { width: 62rpx; height: 70rpx; margin-right: 12rpx; flex-shrink: 0; }
|
||||
.message-bubble { max-width: 560rpx; flex-shrink: 1; padding: 20rpx 24rpx; background-color: #f1f6fd; border-radius: 6rpx 22rpx 22rpx 22rpx; }
|
||||
.user-bubble { max-width: 570rpx; padding: 20rpx 24rpx; border-radius: 22rpx 6rpx 22rpx 22rpx; background-color: #1673ef; }
|
||||
.user-text { color: #ffffff; font-size: 26rpx; line-height: 42rpx; }
|
||||
.status-text { font-size: 24rpx; color: #8796aa; line-height: 40rpx; }
|
||||
.composer { position: fixed; bottom: 0; left: 0; right: 0; background-color: #fcfdff; padding: 16rpx 30rpx; border-top: 1rpx solid #eef1f6; }
|
||||
.input-row { flex-direction: row; align-items: center; }
|
||||
.chat-input { flex: 1; height: 78rpx; color: #344259; font-size: 26rpx; padding: 0 12rpx; }
|
||||
.send-button { width: 72rpx; height: 72rpx; border-radius: 36rpx; background-color: #1673ef; padding: 0; margin-left: 16rpx; align-items: center; justify-content: center; }
|
||||
.send-icon { width: 38rpx; height: 38rpx; }
|
||||
.composer-note { color: #a0aaba; font-size: 19rpx; text-align: center; margin-top: 10rpx; line-height: 28rpx; }
|
||||
.error-panel, .history-panel { background-color: #f4f7fc; border-radius: 16rpx; padding: 24rpx; margin-bottom: 28rpx; }
|
||||
.error-title { color: #5c6680; font-size: 28rpx; margin-bottom: 14rpx; }
|
||||
.error-text { color: #bb6571; font-size: 23rpx; line-height: 38rpx; }
|
||||
.retry-link { color: #2672d9; font-size: 24rpx; line-height: 42rpx; margin-top: 10rpx; }
|
||||
.send-error { margin: 24rpx 0 0 74rpx; }
|
||||
.load-more { text-align: center; color: #2672d9; font-size: 23rpx; line-height: 40rpx; padding: 18rpx; }
|
||||
.history-item { flex-direction: row; align-items: center; padding: 20rpx 0; border-bottom: 1rpx solid #e4ecf6; }
|
||||
.history-label { flex: 1; color: #4e5e75; font-size: 24rpx; }
|
||||
.history-date { font-size: 20rpx; color: #93a0b2; }
|
||||
.history-more { text-align: center; margin: 14rpx 0 0; }
|
||||
.chat-end { height: 30rpx; }
|
||||
/* #ifndef APP */
|
||||
.send-button { display: flex; }
|
||||
.send-button[disabled] { background-color: #6dacf8; color: #ffffff; }
|
||||
.bubble-text, .user-text { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
/* #endif */
|
||||
/* #ifdef WEB */
|
||||
@media (min-width: 800px) { .chat-shell { max-width: 760px; width: 100%; margin: 0 auto; } .composer { max-width: 760px; margin: 0 auto; } }
|
||||
/* #endif */
|
||||
</style>
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
</scroll-view>
|
||||
</view>
|
||||
<text class="footer-note">历史参数保留评估时版本;当前可用状态单独展示。缺失信息不会被推断为零,演示产品仅供功能验证。</text>
|
||||
<button class="secondary" @click="consult">产品差异,咨询AI</button>
|
||||
<button class="secondary" @click="toMatches">返回本次匹配</button>
|
||||
</template>
|
||||
</view>
|
||||
@@ -45,6 +46,7 @@
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { assistantRoute } from '@/packages/finance/services/assistant.uts'
|
||||
import { MatchDetail, MatchReason, validRecordId, fetchMatchDetailRecord } from '@/packages/finance/services/matches.uts'
|
||||
import { parseCompareIds, saveCompareSelection, compareQuery, sameMatchSnapshot, compareReasonText } from '@/packages/finance/services/compare.uts'
|
||||
type CompareColumn = { id: string, loading: boolean, error: string, data: MatchDetail | null }
|
||||
@@ -120,6 +122,7 @@
|
||||
uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, ids.value) })
|
||||
}
|
||||
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else toMatches() }
|
||||
const consult = () => { uni.navigateTo({ url: assistantRoute(assessmentId.value, ids.value, 'compare') }) }
|
||||
const remove = (id: string) => {
|
||||
const next = ids.value.filter((value: string): boolean => value != id)
|
||||
saveCompareSelection(assessmentId.value, next)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<view class="export-area">
|
||||
<view class="export-row"><text class="export-label">报告已保存至融资档案</text><button class="export-button" :disabled="busy" @click="download">{{ busy ? '正在生成…' : '下载PDF' }}</button></view>
|
||||
<text v-if="message.length > 0" :class="failed ? 'export-message export-error' : 'export-message'">{{ message }}</text>
|
||||
<text v-if="message.length == 0" class="export-message">导出不含身份信息,请妥善保管下载文件。</text>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="uts">
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { ReportDownload, downloadReportPdf } from '@/packages/finance/services/report-export.uts'
|
||||
const props = defineProps({ assessmentId: { type: String, required: true } })
|
||||
const emit = defineEmits(['authrequired', 'sessionchanged'])
|
||||
const busy = ref(false); const message = ref(''); const failed = ref(false)
|
||||
let operation: ReportDownload | null = null; let generation = 0
|
||||
const ownerToken = getSession()?.getString('token') ?? ''
|
||||
const dispose = () => { generation++; operation?.cancel(); operation = null; busy.value = false }
|
||||
const download = async () => {
|
||||
if (busy.value) return
|
||||
if (getSession() == null) { emit('authrequired'); return }
|
||||
if (getSession()?.getString('token') != ownerToken) { emit('sessionchanged'); return }
|
||||
const request = ++generation; busy.value = true; failed.value = false; message.value = ''
|
||||
const token = getSession()?.getString('token') ?? ''
|
||||
operation = downloadReportPdf(props.assessmentId)
|
||||
try { const result = await operation!.done; if (request == generation) message.value = result }
|
||||
catch (err) {
|
||||
if (request != generation) return
|
||||
failed.value = true; message.value = err instanceof Error ? err.message : '下载失败,请检查网络后重试。'
|
||||
if (getSession() == null) emit('authrequired')
|
||||
else if (getSession()?.getString('token') != token) emit('sessionchanged')
|
||||
} finally { if (request == generation) { busy.value = false; operation = null } }
|
||||
}
|
||||
// 父页面离开即移除报告,组件销毁时中止下载,避免隐藏页面导出旧账户资料。
|
||||
onUnmounted(() => dispose())
|
||||
</script>
|
||||
<style>
|
||||
.export-area { margin-top: 18rpx; margin-bottom: 22rpx; }
|
||||
.export-row { flex-direction: row; align-items: center; justify-content: space-between; }
|
||||
.export-label { font-size: 23rpx; color: #73869d; flex: 1; }
|
||||
.export-button { font-size: 24rpx; color: #0868f7; background-color: #eaf3ff; border-radius: 10rpx; margin-left: 18rpx; padding-left: 24rpx; padding-right: 24rpx; }
|
||||
.export-message { color: #8393a6; font-size: 22rpx; line-height: 36rpx; margin-top: 12rpx; }
|
||||
.export-error { color: #a05d37; }
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<view class="page" :style="pageStyle()">
|
||||
<AppHeader title="下一步行动" :customBack="true" @back="back" />
|
||||
<scroll-view scroll-y :scroll-into-view="scrollTarget" :style="contentStyle(96, false)">
|
||||
<view class="content">
|
||||
<text v-if="loading" class="body">正在读取本次报告与产品状态…</text>
|
||||
<view v-if="error.length > 0" class="panel"><text class="heading">暂时无法查看行动建议</text><text class="body">{{ error }}</text><button v-if="routeError.length == 0" class="secondary" @click="load">重新加载</button><text class="link" @click="history">前往融资档案 ›</text></view>
|
||||
<template v-if="report != null && !loading">
|
||||
<text class="eyebrow">评估记录 #{{ assessmentId }} · {{ assessmentType == 'enterprise' ? '企业' : '个人' }}评估</text>
|
||||
<text class="intro">根据本次报告,安排您的下一步</text>
|
||||
<view class="actions">
|
||||
<view class="action-row" @click="prepare"><image class="action-icon" src="/packages/finance/static/action-prepare.svg" /><view class="action-copy"><text class="action-title">{{ productId.length > 0 && !canPrepare ? '核对产品条件' : '准备申请' }}</text><text class="action-note">{{ productId.length == 0 ? '选择本次匹配产品,查看材料准备说明' : '核对产品条件,了解所需材料' }}</text></view><text class="chevron">›</text></view>
|
||||
<view class="action-row" @click="openReport"><image class="action-icon" src="/packages/finance/static/action-report.svg" /><view class="action-copy"><text class="action-title">查看已保存报告</text><text class="action-note">报告已保存至融资档案,可随时查看</text></view><text class="chevron">›</text></view>
|
||||
<view class="action-row" @click="optimize"><image class="action-icon" src="/packages/finance/static/action-improve.svg" /><view class="action-copy"><text class="action-title">优化资料</text><text class="action-note">核对待补充与待核实项,再发起新评估</text></view><text class="chevron">›</text></view>
|
||||
<view class="action-row last" @click="consult"><image class="action-icon" src="/packages/finance/static/action-chat.svg" /><view class="action-copy"><text class="action-title">咨询AI</text><text class="action-note">结合本次报告,为您解释准备方向</text></view><text class="chevron">›</text></view>
|
||||
</view>
|
||||
<view v-if="section == 'prepare'" id="prepare-section" class="panel">
|
||||
<text class="heading">材料准备</text>
|
||||
<text v-if="productId.length == 0" class="body">请先选择本次匹配中的产品,再查看对应材料。本页不会自动替您选择产品。</text>
|
||||
<template v-else-if="matchError.length > 0"><text class="body">{{ matchError }}</text><button class="secondary" @click="load">重新核对产品</button></template>
|
||||
<template v-else-if="match != null">
|
||||
<text class="product-title">{{ match!.product.name }}</text><text class="note">{{ match!.product.bank }} · 评估时版本 v{{ match!.product.version }}</text>
|
||||
<text v-if="match!.product.demo" class="demo">演示产品,仅用于功能验证,不代表真实在售产品。</text>
|
||||
<text class="status">{{ match!.stateLabel }} · {{ match!.availabilityLabel }}</text>
|
||||
<template v-if="canPrepare">
|
||||
<text class="label">所需材料与办理说明</text><text class="body">{{ match!.product.materials }}</text>
|
||||
<text class="label">申请条件(评估时)</text><text v-for="item in match!.product.conditions" :key="item" class="body">{{ item }}</text><text v-if="match!.product.conditions.length == 0" class="body">未提供</text>
|
||||
<text class="label">资料来源</text><text class="body">{{ match!.product.source.trim() == '·' ? '未提供' : match!.product.source }}</text><text class="note">{{ match!.product.sourceRef.length > 0 ? match!.product.sourceRef : '来源引用未提供' }}</text><text class="note">核对日期:{{ match!.product.checkedOn.length > 0 ? match!.product.checkedOn : '未提供' }}</text>
|
||||
<text class="label">机构办理渠道</text><text class="body">未提供。请自行通过机构官方渠道核实产品、材料和办理方式。</text>
|
||||
</template>
|
||||
<text v-else class="body">{{ match!.state != 'eligible' ? '本产品存在不符条件或待核实项,暂不能进入申请准备。您仍可查看报告、优化资料或咨询AI。' : '产品当前不可用或状态未知,暂不能进入申请准备。历史报告与评估时产品信息仍保留。' }}</text>
|
||||
</template>
|
||||
<button v-if="report!.matching.status == 'completed'" class="secondary" @click="chooseProduct">{{ productId.length == 0 ? '选择本次匹配产品' : '查看本次其他产品' }}</button>
|
||||
<text v-else class="note">{{ report!.version == 1 ? '旧版报告未执行产品匹配,可重新评估。' : report!.matchingMessage }}</text>
|
||||
</view>
|
||||
<view v-if="improveOpen" id="improve-section" class="panel">
|
||||
<text class="heading">优化资料后,重新评估</text><text class="note">以下内容来自本次已保存报告。请按实际情况核对资料。</text>
|
||||
<view v-for="item in missingItems" :key="item.label" class="field-row"><text class="field-label">{{ item.label }}</text><text class="field-state">{{ item.value }}</text></view>
|
||||
<text v-if="missingItems.length == 0" class="body">{{ report!.version == 1 ? '旧版报告未记录逐项完整度,请参考以下报告提示。' : '本次报告没有列出待补充或格式需核实字段,自填资料仍需核验。' }}</text>
|
||||
<text v-for="item in report!.dataGaps" :key="item" class="body">{{ item }}</text><text v-for="item in report!.suggestions" :key="item" class="body">{{ item }}</text>
|
||||
<text class="note">将进入同类型空白表单,不带入旧资料。正式提交后生成新记录,原报告保持不变。</text><button class="primary" @click="restart">重新进行{{ assessmentType == 'enterprise' ? '企业' : '个人' }}评估</button>
|
||||
</view>
|
||||
<view class="notice"><text class="notice-title">重要提示</text><text class="notice-text">本页仅提供材料准备与办理引导,不向银行或其他机构提交申请。匹配结果基于自填资料,不代表授信或获批承诺;实际产品条件、材料及办理方式以机构核实为准。</text></view>
|
||||
<ReportExport :assessmentId="assessmentId" @authrequired="login" @sessionchanged="load" />
|
||||
</template>
|
||||
<button class="home-button" @click="home">返回首页</button>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="uts">
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import ReportExport from '@/packages/finance/components/ReportExport.uvue'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { clearAssessmentFlow } from '@/services/assessment-flow.uts'
|
||||
import { nextStepRoute } from '@/services/next-step-route.uts'
|
||||
import { assistantRoute } from '@/packages/finance/services/assistant.uts'
|
||||
import { AnalysisReport, AssessmentField, fetchAnalysisReport, fetchAssessmentStatus } from '@/packages/finance/services/records.uts'
|
||||
import { MatchView, fetchMatchDetailRecord, validRecordId } from '@/packages/finance/services/matches.uts'
|
||||
const { pageStyle, contentStyle } = usePageLayout()
|
||||
const assessmentId = ref(''); const productId = ref(''); const section = ref('actions'); const assessmentType = ref('')
|
||||
const report = ref<AnalysisReport | null>(null); const match = ref<MatchView | null>(null)
|
||||
const loading = ref(false); const error = ref(''); const matchError = ref(''); const routeError = ref(''); const improveOpen = ref(false)
|
||||
const scrollTarget = ref('')
|
||||
let visible = false; let generation = 0; let loadedToken = ''
|
||||
const canPrepare = computed((): boolean => match.value?.state == 'eligible' && match.value?.availability == 'available')
|
||||
const missingItems = computed((): AssessmentField[] => report.value == null ? [] : report.value!.completenessItems.filter((item: AssessmentField): boolean => ['待补充', '需核实'].includes(item.value)))
|
||||
const reset = () => { report.value = null; match.value = null; assessmentType.value = ''; matchError.value = ''; loadedToken = ''; improveOpen.value = false; scrollTarget.value = '' }
|
||||
const home = () => { uni.reLaunch({ url: '/pages/home/home' }) }
|
||||
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
|
||||
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeNextStep=1&assessmentId=' + assessmentId.value + '&productId=' + productId.value + '§ion=' + section.value }) }
|
||||
// 离页或账户变更后,丢弃旧请求;重新进入必须重查归属和当前可用性。
|
||||
const stale = (request: number, token: string): boolean => {
|
||||
if (!visible || request != generation) return true
|
||||
if (getSession()?.getString('token') == token) return false
|
||||
generation++; reset(); loading.value = false
|
||||
if (getSession() == null) login(); else error.value = '账户已切换,请重新加载本人报告。'
|
||||
return true
|
||||
}
|
||||
const load = async () => {
|
||||
const request = ++generation; reset(); error.value = ''; loading.value = false
|
||||
if (routeError.value.length > 0) { error.value = routeError.value; return }
|
||||
const token = getSession()?.getString('token') ?? ''
|
||||
if (token.length == 0) { login(); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const status = await fetchAssessmentStatus(assessmentId.value)
|
||||
if (stale(request, token)) return
|
||||
if (status.id != assessmentId.value || status.status != 'succeeded' || !['personal', 'enterprise'].includes(status.type)) throw new Error('请从本人已成功生成的报告进入下一步行动。')
|
||||
const result = await fetchAnalysisReport(assessmentId.value)
|
||||
if (stale(request, token)) return
|
||||
report.value = result; assessmentType.value = status.type; loadedToken = token
|
||||
if (productId.value.length > 0) {
|
||||
try {
|
||||
if (result.matching.status != 'completed') throw new Error('本报告没有可读取的产品匹配结果。')
|
||||
const detail = await fetchMatchDetailRecord(assessmentId.value, productId.value)
|
||||
if (stale(request, token)) return
|
||||
// 材料必须来自同次评估冻结产品,不允许用公共或其他报告产品补齐。
|
||||
if (detail.assessmentId != assessmentId.value || detail.resultVersion != result.version || detail.snapshotId != result.matching.snapshotId || detail.ruleVersion != result.matching.ruleVersion || detail.orderingVersion != result.matching.orderingVersion) throw new Error('产品与本报告的冻结版本不一致,请重新选择产品。')
|
||||
match.value = detail.item
|
||||
} catch (err) { if (!stale(request, token)) matchError.value = err instanceof Error ? err.message : '产品读取失败,请重试' }
|
||||
}
|
||||
} catch (err) { if (!stale(request, token)) { reset(); error.value = err instanceof Error ? err.message : '报告读取失败,请重试' } }
|
||||
finally {
|
||||
if (request == generation) {
|
||||
loading.value = false
|
||||
if (report.value != null && section.value == 'prepare') nextTick(() => { if (request == generation) scrollTarget.value = 'prepare-section' })
|
||||
}
|
||||
}
|
||||
}
|
||||
const ready = (): boolean => !stale(generation, loadedToken) && report.value != null && !loading.value
|
||||
const openReport = () => { if (ready()) uni.navigateTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value }) }
|
||||
const chooseProduct = () => { if (ready() && report.value!.matching.status == 'completed') uni.navigateTo({ url: '/packages/finance/matches/matches?assessmentId=' + assessmentId.value + '&group=eligible' }) }
|
||||
const prepare = () => {
|
||||
if (!ready()) return
|
||||
if (section.value == 'prepare') { load(); return }
|
||||
uni.redirectTo({ url: nextStepRoute(assessmentId.value, productId.value, 'prepare') })
|
||||
}
|
||||
const optimize = () => {
|
||||
if (!ready()) return
|
||||
improveOpen.value = !improveOpen.value; scrollTarget.value = ''
|
||||
const request = generation
|
||||
if (improveOpen.value) nextTick(() => { if (request == generation) scrollTarget.value = 'improve-section' })
|
||||
}
|
||||
const consult = () => {
|
||||
if (!ready()) return
|
||||
if (productId.value.length > 0 && match.value == null) { uni.showToast({ title: '请先重新核对产品或从报告咨询AI', icon: 'none' }); return }
|
||||
uni.navigateTo({ url: assistantRoute(assessmentId.value, productId.value.length > 0 ? [productId.value] : [], 'next-step') })
|
||||
}
|
||||
const restart = () => {
|
||||
if (!ready() || !improveOpen.value) return
|
||||
clearAssessmentFlow()
|
||||
uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + assessmentType.value })
|
||||
}
|
||||
const back = () => {
|
||||
if (getCurrentPages().length > 1) uni.navigateBack()
|
||||
else if (validRecordId(assessmentId.value)) uni.redirectTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value })
|
||||
else history()
|
||||
}
|
||||
onLoad((options) => {
|
||||
assessmentId.value = options?.['assessmentId'] ?? ''; productId.value = options?.['productId'] ?? ''; section.value = options?.['section'] ?? 'actions'
|
||||
if (nextStepRoute(assessmentId.value, productId.value, section.value).length == 0) routeError.value = '页面参数无效,请从本次报告或匹配产品重新进入。'
|
||||
})
|
||||
onShow(() => { visible = true; load() })
|
||||
onHide(() => { visible = false; generation++; reset(); loading.value = false; error.value = '' })
|
||||
onUnload(() => { visible = false; generation++; reset() })
|
||||
</script>
|
||||
<style>
|
||||
.page { background-color: #fff; }
|
||||
.content { padding: 30rpx 36rpx 64rpx; }
|
||||
.eyebrow { font-size: 22rpx; color: #91a0b2; }
|
||||
.intro { font-size: 27rpx; color: #536882; margin-top: 16rpx; line-height: 42rpx; }
|
||||
.actions { margin-top: 26rpx; }
|
||||
.action-row { flex-direction: row; align-items: center; padding: 30rpx 0; border-bottom: 1rpx solid #eef2f7; }
|
||||
.last { border-bottom-width: 0; }
|
||||
.action-icon { width: 88rpx; height: 88rpx; flex-shrink: 0; }
|
||||
.action-copy { flex: 1; margin-left: 24rpx; }
|
||||
.action-title { font-size: 31rpx; font-weight: 700; color: #263d58; }
|
||||
.action-note { font-size: 23rpx; color: #8795a8; line-height: 36rpx; margin-top: 10rpx; }
|
||||
.chevron { color: #b2c0d2; font-size: 40rpx; margin-left: 16rpx; }
|
||||
.panel { padding: 28rpx; margin-top: 24rpx; border: 1rpx solid #e3ebf5; border-radius: 18rpx; background-color: #f9fbff; }
|
||||
.heading { font-size: 30rpx; color: #243e5c; font-weight: 700; line-height: 44rpx; }
|
||||
.body { font-size: 25rpx; color: #536981; line-height: 42rpx; margin-top: 16rpx; }
|
||||
.note { font-size: 22rpx; color: #8292a6; line-height: 36rpx; margin-top: 14rpx; }
|
||||
.product-title { font-size: 29rpx; color: #214c83; font-weight: 700; margin-top: 24rpx; }
|
||||
.demo, .status { color: #9a753d; font-size: 23rpx; line-height: 38rpx; margin-top: 16rpx; }
|
||||
.label { color: #304b69; font-size: 26rpx; font-weight: 700; margin-top: 28rpx; }
|
||||
.field-row { flex-direction: row; justify-content: space-between; padding: 22rpx 0; border-bottom: 1rpx solid #e8eef6; }
|
||||
.field-label { flex: 1; font-size: 24rpx; color: #526880; }
|
||||
.field-state { font-size: 24rpx; color: #aa7b37; margin-left: 16rpx; }
|
||||
.notice { background-color: #eef6ff; border-radius: 16rpx; padding: 28rpx; margin-top: 32rpx; }
|
||||
.notice-title { color: #2869b6; font-size: 27rpx; font-weight: 700; }
|
||||
.notice-text { font-size: 23rpx; line-height: 39rpx; color: #6e87a4; margin-top: 14rpx; }
|
||||
.primary, .home-button { margin-top: 32rpx; background-color: #0868f7; color: #fff; font-size: 27rpx; border-radius: 12rpx; }
|
||||
.secondary { margin-top: 24rpx; background-color: #e7f0ff; color: #0868f7; font-size: 25rpx; border-radius: 12rpx; }
|
||||
.link { color: #0868f7; font-size: 25rpx; margin-top: 24rpx; }
|
||||
</style>
|
||||
@@ -22,11 +22,11 @@
|
||||
</view>
|
||||
<template v-if="tab == 'explanation' && match != null">
|
||||
<view class="reason-section">
|
||||
<text class="heading good-text">推荐依据({{ match!.positive.length }}条已满足条件)</text>
|
||||
<text class="heading good-text">{{ match!.state == 'eligible' ? '推荐依据(' + match!.positive.length + '条已满足条件)' : '已满足的条件(' + match!.positive.length + '条)' }}</text>
|
||||
<text v-if="match!.positive.length == 0" class="empty-note">暂无已确认满足的条件,不补充未经验证的推荐理由。</text>
|
||||
<view v-for="(reason, index) in positiveVisible" :key="reason.id" class="reason-row">
|
||||
<text class="number green-number">{{ index + 1 }}</text>
|
||||
<view class="reason-content"><text class="reason-title">{{ reason.text }}</text><text class="reason-detail">{{ reason.explanation.length > 0 ? reason.explanation : reason.evidence.join(';') }}</text><text class="evidence-toggle" @click="openReason = openReason == reason.id ? '' : reason.id">{{ openReason == reason.id ? '收起依据' : '查看依据' }}</text><view v-if="openReason == reason.id" class="evidence-box"><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view></view>
|
||||
<view class="reason-content"><text class="reason-title">{{ reasonTitle(reason) }}</text><text class="reason-detail">{{ reason.explanation.length > 0 ? reason.explanation : reason.evidence.join(';') }}</text><text class="evidence-toggle" @click="openReason = openReason == reason.id ? '' : reason.id">{{ openReason == reason.id ? '收起依据' : '查看依据' }}</text><view v-if="openReason == reason.id" class="evidence-box"><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view></view>
|
||||
</view>
|
||||
<text v-if="match!.positive.length > 3" class="expand" @click="expanded = !expanded">{{ expanded ? '收起' : '展开其余' + (match!.positive.length - 3) + '条依据' }}</text>
|
||||
</view>
|
||||
@@ -35,11 +35,12 @@
|
||||
<text v-if="attention.length == 0" class="empty-note">已知规则中暂无不符或待核实项,仍需核验资料,不代表审批结果。</text>
|
||||
<view v-for="(reason, index) in attentionVisible" :key="reason.id" class="reason-row">
|
||||
<text class="number orange-number">{{ index + 1 }}</text>
|
||||
<view class="reason-content"><text class="reason-title">{{ reason.text }}</text><text class="reason-detail">{{ reason.explanation.length > 0 ? reason.explanation : reason.evidence.join(';') }}</text><text class="evidence-toggle" @click="openReason = openReason == reason.id ? '' : reason.id">{{ openReason == reason.id ? '收起依据' : '查看依据' }}</text><view v-if="openReason == reason.id" class="evidence-box"><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view></view>
|
||||
<view class="reason-content"><text class="reason-title">{{ reasonTitle(reason) }}</text><text class="reason-detail">{{ reason.explanation.length > 0 ? reason.explanation : reason.evidence.join(';') }}</text><text class="evidence-toggle" @click="openReason = openReason == reason.id ? '' : reason.id">{{ openReason == reason.id ? '收起依据' : '查看依据' }}</text><view v-if="openReason == reason.id" class="evidence-box"><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view></view>
|
||||
</view>
|
||||
<text v-if="attention.length > 3" class="expand" @click="attentionExpanded = !attentionExpanded">{{ attentionExpanded ? '收起' : '展开其余' + (attention.length - 3) + '条注意事项' }}</text>
|
||||
</view>
|
||||
<view class="ai-panel">
|
||||
<text class="link" @click="consult">我有疑问,咨询AI ›</text>
|
||||
<text class="ai-title">AI详细解释</text>
|
||||
<text v-if="match!.aiStatus == 'completed'" class="ai-note">已将逐条解释补充在上方,生成结果已保存,重新打开无需再次生成。</text>
|
||||
<text v-else-if="aiPending" class="ai-note">{{ pollStopped ? '生成仍在进行,可稍后查看状态。' : '正在解释本次匹配依据,请稍候。离开页面后任务仍会继续。' }}</text>
|
||||
@@ -68,13 +69,13 @@
|
||||
<view class="details-section"><text class="heading">产品准入条件</text><text class="muted">公开条件不代表个人已符合,缺少核验依据时仍需核实。</text><text v-for="(condition, index) in product!.conditions" :key="index" class="condition">{{ condition }}</text></view>
|
||||
<view class="details-section"><text class="heading">资料准备说明</text><text class="body">{{ product!.materials }}</text><text class="muted">仅提供材料引导,不向机构提交申请。实际条件和材料须向机构核实。</text></view>
|
||||
</template>
|
||||
<view class="footnotes"><text class="record-note">{{ assessmentId.length > 0 ? '本次评估 #' + assessmentId : '公共产品资料' }} · 产品版本 v{{ product!.version }}</text><text v-if="match != null" class="record-note">{{ match!.availabilityLabel }},历史参数与结论保留。</text><text v-if="match != null && !canPrepare" class="record-note">{{ match!.state != 'eligible' ? '请先核对不符条件或待核实项。' : '当前产品不可用或状态未知,暂不提供准备入口。' }}</text><text class="record-note">自填信息未经核验;本页面不承诺额度、放款或获批。</text><text v-if="assessmentId.length > 0" class="link" @click="toMatches">返回本次匹配</text><text v-else class="link" @click="history">选择评估查看匹配结果</text></view>
|
||||
<view class="footnotes"><text class="record-note">{{ assessmentId.length > 0 ? '本次评估 #' + assessmentId : '公共产品资料' }} · 产品版本 v{{ product!.version }}</text><text v-if="match != null" class="record-note">{{ match!.availabilityLabel }},历史参数与结论保留。</text><text v-if="match != null && !canPrepare" class="record-note">{{ match!.state != 'eligible' ? '请先核对不符条件或待核实项。' : '当前产品不可用或状态未知,暂不提供准备入口。' }}</text><text class="record-note">自填信息未经核验;本页面不承诺额度、放款或获批。</text><text v-if="assessmentId.length > 0" class="link" @click="openCompare">查看同类产品对比</text><text v-if="assessmentId.length > 0" class="link" @click="toMatches">返回本次匹配</text><text v-else class="link" @click="history">选择评估查看匹配结果</text></view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view v-if="pageActive && product != null" class="action-bar" :style="bottomStyle(22)">
|
||||
<template v-if="match != null && canPrepare"><button class="footer-button secondary" @click="openCompare">查看同类产品对比</button><button class="footer-button primary" @click="prepare">{{ tab == 'conditions' ? '返回本次匹配' : '查看资料准备' }}</button></template>
|
||||
<template v-else-if="match != null"><button class="footer-button secondary" @click="switchTab(tab == 'details' ? 'explanation' : 'details')">{{ tab == 'details' ? '查看匹配解释' : '查看产品详情' }}</button><button class="footer-button primary" @click="openCompare">{{ selectedIds.includes(productId) ? '查看产品对比' : '加入产品对比' }}</button></template>
|
||||
<template v-if="match != null && canPrepare"><button class="footer-button secondary" @click="openCompare">查看同类产品对比</button><button class="footer-button primary" @click="prepare">准备申请</button></template>
|
||||
<template v-else-if="match != null"><button class="footer-button secondary" @click="switchTab(tab == 'details' ? 'explanation' : 'details')">{{ tab == 'details' ? '查看匹配解释' : '查看产品详情' }}</button><button class="footer-button primary" @click="consult">我有疑问,咨询AI</button></template>
|
||||
<button v-else class="footer-button primary" @click="switchTab(tab == 'conditions' ? 'details' : 'conditions')">{{ tab == 'conditions' ? '查看产品详情' : '查看申请条件' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -85,6 +86,7 @@
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { MatchView, MatchReason, ProductView, fetchMatchDetail, fetchPublicProduct, validRecordId, requestProductExplanation } from '@/packages/finance/services/matches.uts'
|
||||
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery } from '@/packages/finance/services/compare.uts'
|
||||
import { assistantRoute } from '@/packages/finance/services/assistant.uts'
|
||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||
const assessmentId = ref(''); const productId = ref(''); const tab = ref('details')
|
||||
const selectedIds = ref<string[]>([]); let from = ''
|
||||
@@ -99,6 +101,46 @@
|
||||
const attentionVisible = computed((): MatchReason[] => attentionExpanded.value ? attention.value : attention.value.slice(0, 3))
|
||||
const aiPending = computed((): boolean => match.value != null && ['queued','running'].includes(match.value!.aiStatus))
|
||||
const canPrepare = computed((): boolean => match.value != null && match.value!.state == 'eligible' && match.value!.availability == 'available')
|
||||
// 缩短重复的规则模板标题;核验限制仍保留在逐条依据和页尾,不改判定含义。
|
||||
const reasonTitle = (reason: MatchReason): string => reason.text.replace('(资料尚需核验)。', '').replace('本产品已配置条件', '产品条件')
|
||||
function stopPoll(): void { if (timer != 0) clearTimeout(timer); timer = 0 }
|
||||
const current = (request: number, token: string): boolean => visible && generation == request && getSession()?.getString('token') == token
|
||||
const explanationError = (request: number, token: string, message: string) => {
|
||||
if (!visible || generation != request) return
|
||||
if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
|
||||
if (current(request, token)) aiError.value = message
|
||||
}
|
||||
let pollAction: () => void = () => {}
|
||||
function schedulePoll(): void {
|
||||
stopPoll()
|
||||
if (!visible || !aiPending.value) return
|
||||
if (++pollCount > 60) { pollStopped.value = true; return }
|
||||
timer = setTimeout(() => { timer = 0; pollAction() }, 3000)
|
||||
}
|
||||
async function refreshExplanation(): Promise<void> {
|
||||
if (aiBusy.value || match.value == null) return
|
||||
const request = generation; const token = getSession()?.getString('token') ?? ''
|
||||
aiError.value = ''; aiBusy.value = true
|
||||
try {
|
||||
const result = await fetchMatchDetail(assessmentId.value, productId.value)
|
||||
if (current(request, token)) { match.value = result; schedulePoll() }
|
||||
} catch (err) { explanationError(request, token, err instanceof Error ? err.message : '解释状态读取失败,请重试')
|
||||
} finally { if (current(request, token)) aiBusy.value = false }
|
||||
}
|
||||
pollAction = () => { refreshExplanation() }
|
||||
const checkExplanation = () => { pollCount = 0; pollStopped.value = false; refreshExplanation() }
|
||||
const generateExplanation = async () => {
|
||||
if (aiBusy.value || match.value == null || aiPending.value || match.value!.aiStatus == 'completed') return
|
||||
const request = generation; const token = getSession()?.getString('token') ?? ''
|
||||
// 网络结果不确定时保留同一个幂等键,只有收到明确失败状态后才开始新的尝试。
|
||||
if (requestKey.length == 0) requestKey = 'explain_' + Date.now().toString() + '_' + Math.random().toString(36).slice(2, 12)
|
||||
aiBusy.value = true; aiError.value = ''; pollCount = 0; pollStopped.value = false
|
||||
try {
|
||||
const result = await requestProductExplanation(assessmentId.value, productId.value, requestKey)
|
||||
if (current(request, token)) { match.value = result; requestKey = ''; schedulePoll() }
|
||||
} catch (err) { explanationError(request, token, err instanceof Error ? err.message : 'AI解释请求失败,请重试')
|
||||
} finally { if (current(request, token)) aiBusy.value = false }
|
||||
}
|
||||
const load = async () => {
|
||||
stopPoll(); pollCount = 0; pollStopped.value = false; aiError.value = ''
|
||||
const request = ++generation; product.value = null; match.value = null; error.value = ''; expanded.value = false
|
||||
@@ -118,46 +160,18 @@
|
||||
} catch (err) { if (visible && generation == request) error.value = err instanceof Error ? err.message : '产品读取失败' }
|
||||
finally { if (generation == request) loading.value = false }
|
||||
}
|
||||
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
|
||||
// 仅携带选择ID;助手会重新校验归属并读取冻结事实,进入页面不会调用模型。
|
||||
const consult = () => { uni.navigateTo({ url: assistantRoute(assessmentId.value, [productId.value], 'product') }) }
|
||||
function toMatches(): void { if (assessmentId.value.length > 0) uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, selectedIds.value) }); else history() }
|
||||
// 替换当前路由保留栏目、来源与对比选择,刷新后仍停留在同一栏。
|
||||
const switchTab = (value: string) => {
|
||||
if (value == tab.value) return
|
||||
const query = assessmentId.value.length > 0 ? compareQuery(assessmentId.value, selectedIds.value) + '&' : '?'
|
||||
uni.redirectTo({ url: '/packages/finance/product/product' + query + 'productId=' + productId.value + '&tab=' + value + '&from=' + from })
|
||||
}
|
||||
const prepare = () => { if (canPrepare.value) { if (tab.value == 'conditions') toMatches(); else switchTab('conditions') } }
|
||||
const stopPoll = () => { if (timer != 0) clearTimeout(timer); timer = 0 }
|
||||
const current = (request: number, token: string): boolean => visible && generation == request && getSession()?.getString('token') == token
|
||||
const refreshExplanation = async () => {
|
||||
if (aiBusy.value || match.value == null) return
|
||||
const request = generation; const token = getSession()?.getString('token') ?? ''
|
||||
aiError.value = ''; aiBusy.value = true
|
||||
try {
|
||||
const result = await fetchMatchDetail(assessmentId.value, productId.value)
|
||||
if (current(request, token)) { match.value = result; schedulePoll() }
|
||||
} catch (err) { if (current(request, token)) aiError.value = err instanceof Error ? err.message : '解释状态读取失败,请重试'
|
||||
} finally { if (current(request, token)) aiBusy.value = false }
|
||||
}
|
||||
const schedulePoll = () => {
|
||||
stopPoll()
|
||||
if (!visible || !aiPending.value) return
|
||||
if (++pollCount > 60) { pollStopped.value = true; return }
|
||||
timer = setTimeout(() => { timer = 0; refreshExplanation() }, 3000)
|
||||
}
|
||||
const checkExplanation = () => { pollCount = 0; pollStopped.value = false; refreshExplanation() }
|
||||
const generateExplanation = async () => {
|
||||
if (aiBusy.value || match.value == null || aiPending.value || match.value!.aiStatus == 'completed') return
|
||||
const request = generation; const token = getSession()?.getString('token') ?? ''
|
||||
// 网络结果不确定时保留同一个幂等键,只有收到明确失败状态后才开始新的尝试。
|
||||
if (requestKey.length == 0) requestKey = 'explain_' + Date.now().toString() + '_' + Math.random().toString(36).slice(2, 12)
|
||||
aiBusy.value = true; aiError.value = ''; pollCount = 0; pollStopped.value = false
|
||||
try {
|
||||
const result = await requestProductExplanation(assessmentId.value, productId.value, requestKey)
|
||||
if (current(request, token)) { match.value = result; requestKey = ''; schedulePoll() }
|
||||
} catch (err) { if (current(request, token)) aiError.value = err instanceof Error ? err.message : 'AI解释请求失败,请重试'
|
||||
} finally { if (current(request, token)) aiBusy.value = false }
|
||||
}
|
||||
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
|
||||
const toMatches = () => { if (assessmentId.value.length > 0) uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, selectedIds.value) }); else history() }
|
||||
// 下一步页重新核对当前可用性,避免沿用产品详情进入时的旧状态。
|
||||
const prepare = () => { if (canPrepare.value) uni.navigateTo({ url: '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + '&productId=' + productId.value + '§ion=prepare' }) }
|
||||
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else if (from == 'compare') uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value) }); else toMatches() }
|
||||
const openCompare = () => {
|
||||
if (match.value == null) return
|
||||
@@ -218,8 +232,8 @@
|
||||
.green-number { background-color: #35b38c; }
|
||||
.orange-number { background-color: #f49162; }
|
||||
.reason-content { flex: 1; }
|
||||
.reason-title { color: #27364c; font-size: 26rpx; line-height: 40rpx; font-weight: 600; }
|
||||
.reason-detail { color: #7b8594; font-size: 23rpx; line-height: 36rpx; margin-top: 4rpx; }
|
||||
.reason-title { color: #27364c; font-size: 28rpx; line-height: 42rpx; font-weight: 600; }
|
||||
.reason-detail { color: #7b8594; font-size: 25rpx; line-height: 39rpx; margin-top: 4rpx; }
|
||||
.evidence-toggle { color: #8596ad; font-size: 21rpx; padding: 8rpx 0; }
|
||||
.evidence-box { background-color: #f5f8fc; border-radius: 8rpx; padding: 8rpx 14rpx 14rpx; margin-top: 4rpx; }
|
||||
.evidence { color: #7f8b9b; font-size: 22rpx; line-height: 36rpx; margin-top: 8rpx; }
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<text v-if="loading" class="body-text">正在读取已保存报告…</text>
|
||||
<view v-if="error.length > 0" class="panel"><text class="section-title">报告暂不可读取</text><text class="body-text">{{ error }}</text><button v-if="routeError.length == 0" class="secondary" @click="loadReport">重新加载报告</button><text class="action" @click="history">前往融资档案</text></view>
|
||||
<template v-if="report != null">
|
||||
<ReportExport :assessmentId="assessmentId" @authrequired="login" @sessionchanged="loadReport" />
|
||||
<text class="report-note">生成于 {{ report!.generatedAt }} · 用户自填资料分析</text>
|
||||
<view v-if="report!.version == 1" class="legacy"><text class="report-note">这是一份旧版自填报告,当时未采集融资需求、未计算完整度与准备状态,也未执行产品匹配。历史内容保持原样。</text></view>
|
||||
<template v-if="tab == 'overview'">
|
||||
@@ -49,6 +50,8 @@
|
||||
<template v-else><view v-for="group in adviceGroups" :key="group.id" class="panel"><text class="section-title">{{ group.label }}</text><text class="report-note">仅展示本组前3项产品的已有依据,可进入完整列表查看更多。</text><text v-if="group.loading" class="body-text">正在读取产品依据…</text><template v-if="group.error.length > 0"><text class="body-text">{{ group.error }}</text><button class="secondary" @click="loadAdvice(group.id)">重试{{ group.label }}</button></template><text v-if="!group.loading && group.error.length == 0 && group.items.length == 0" class="report-note">本组暂无产品依据。</text><view v-for="item in group.items" :key="item.id" class="advice-product"><text class="product-name" @click="openProduct(item.id)">{{ item.product.name }} ›</text><text class="source-note">{{ item.stateLabel }} · 评估时版本 v{{ item.product.version }}</text><view v-for="reason in adviceReasons(item)" :key="reason.id"><text class="body-text">{{ reason.text }}</text><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view><text v-if="item.availability != 'available'" class="availability">{{ item.availabilityLabel }}</text></view><text class="action" @click="allMatches(group.id)">查看全部{{ group.label }} ›</text></view></template>
|
||||
</template>
|
||||
<text class="disclaimer">{{ report!.disclaimer }}</text>
|
||||
<button class="primary" @click="nextStep">下一步行动</button>
|
||||
<button class="secondary" @click="consult">对报告有疑问,咨询AI</button>
|
||||
<view class="bottom-actions"><button class="secondary" @click="start">重新评估</button><button class="secondary" @click="history">查看历史评估</button></view>
|
||||
</template>
|
||||
</view>
|
||||
@@ -57,8 +60,10 @@
|
||||
</template>
|
||||
<script setup lang="uts">
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import ReportExport from '@/packages/finance/components/ReportExport.uvue'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { assistantRoute } from '@/packages/finance/services/assistant.uts'
|
||||
import { clearAssessmentFlow } from '@/services/assessment-flow.uts'
|
||||
import { AnalysisReport, AssessmentField, fetchAnalysisReport } from '@/packages/finance/services/records.uts'
|
||||
import { MatchPage, MatchView, MatchReason, fetchMatchPage, validRecordId } from '@/packages/finance/services/matches.uts'
|
||||
@@ -71,6 +76,7 @@
|
||||
const candidates = ref<MatchView[]>([]); const candidateLoading = ref(false); const candidateError = ref('')
|
||||
const adviceGroups = ref<AdviceGroup[]>([])
|
||||
let generation = 0; let visible = false
|
||||
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeReport=1&assessmentId=' + assessmentId.value + '&tab=' + tab.value }) }
|
||||
const missingItems = computed((): AssessmentField[] => report.value == null ? [] : report.value!.completenessItems.filter((item: AssessmentField): boolean => ['待补充','需核实'].includes(item.value)))
|
||||
const adviceReasons = (item: MatchView): MatchReason[] => item.state == 'ineligible' ? item.blocking.concat(item.cautions) : item.cautions
|
||||
const reset = () => { report.value = null; candidates.value = []; adviceGroups.value = []; candidateLoading.value = false; candidateError.value = ''; factsOpen.value = false; completenessOpen.value = false }
|
||||
@@ -79,7 +85,7 @@
|
||||
if (!visible || request != generation) return true
|
||||
if (getSession()?.getString('token') == token) return false
|
||||
generation++; reset(); loading.value = false
|
||||
if (getSession() == null) uni.redirectTo({ url: '/packages/account/login/login' })
|
||||
if (getSession() == null) login()
|
||||
else error.value = '账户已切换,请重新加载本人报告。'
|
||||
return true
|
||||
}
|
||||
@@ -114,7 +120,7 @@
|
||||
const request = ++generation; reset(); error.value = ''; loading.value = false
|
||||
if (routeError.value.length > 0) { error.value = routeError.value; return }
|
||||
const session = getSession()
|
||||
if (session == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
|
||||
if (session == null) { login(); return }
|
||||
const token = session.getString('token') ?? ''; loading.value = true
|
||||
try {
|
||||
const result = await fetchAnalysisReport(assessmentId.value)
|
||||
@@ -131,6 +137,8 @@
|
||||
// 切栏替换当前路由,刷新恢复所选栏目;只读取已保存快照,不提交评估或触发模型。
|
||||
const switchTab = (value: string) => { if (value != tab.value && routeError.value.length == 0) uni.redirectTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value + '&tab=' + value }) }
|
||||
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
|
||||
const consult = () => { uni.navigateTo({ url: assistantRoute(assessmentId.value, [], 'report') }) }
|
||||
const nextStep = () => { if (report.value != null) uni.navigateTo({ url: '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + '§ion=actions' }) }
|
||||
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else uni.redirectTo({ url: '/packages/finance/archive/archive' }) }
|
||||
const allMatches = (group: string) => { uni.navigateTo({ url: '/packages/finance/matches/matches?assessmentId=' + assessmentId.value + '&group=' + group }) }
|
||||
const openProduct = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product?assessmentId=' + assessmentId.value + '&productId=' + id + '&tab=explanation&from=report' }) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><rect width="52" height="52" rx="12" fill="#0870f7"/><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12h26v23H24l-8 6v-6h-4zM19 21h12m-12 7h8"/></g></svg>
|
||||
|
After Width: | Height: | Size: 296 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><rect width="52" height="52" rx="12" fill="#0870f7"/><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 34v6h6l20-20-6-6zM28 19l6 6M13 15h10M13 23h5"/></g></svg>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><rect width="52" height="52" rx="12" fill="#0870f7"/><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 15h12m-12 7h12m-12 7h7"/><rect x="12" y="9" width="24" height="31" rx="3"/><path d="m28 33 3 3 6-7"/></g></svg>
|
||||
|
After Width: | Height: | Size: 354 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="52" viewBox="0 0 52 52"><rect width="52" height="52" rx="12" fill="#0870f7"/><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 10h15l7 7v23H15zM29 10v9h8M21 25h10m-10 7h10"/></g></svg>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="m5 5 15 7-15 7 3-7-3-7Z" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 12h7" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 292 B |
Reference in New Issue
Block a user