Files
airz-uniapp/packages/finance/assistant/assistant.uvue
T
hujun 2c9d3feab5 feat: 完善登录流程、UI适配及协议文档更新
本次提交包含以下修改:
1. 新增微信登录相关服务及页面逻辑,实现首次微信绑定手机号流程
2. 优化产品匹配卡片 UI,移除不必要状态显示
3. 调整首页 scroll-view 属性及 banner 背景样式
4. 完善积分规则校验逻辑,增加时区及策略格式验证
5. 更新协议草案文档,补充资料清单及功能说明
6. 修复分析结果路由路径,适配新的匹配结果页面
7. 调整品牌 Logo 安卓端字体对齐样式
2026-10-22 11:24:15 +08:00

240 lines
24 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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 history-entry" @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" @click="contextOpen = !contextOpen"><text class="context-summary">{{ context!.title }} · {{ context!.products.length > 0 ? context!.products.length.toString() + '项产品' : '报告解读' }}</text><text class="context-toggle">{{ contextOpen ? '收起' : '展开' }}</text></view>
<template v-if="contextOpen">
<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></view>
<text class="context-link context-source" @click="source">{{ sourceLabel }}</text>
</template>
</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 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 revisionId = ref(''); const assessmentId = ref(''); const productIds = ref<string[]>([]); const chatId = ref(''); const from = ref('')
const context = ref<AssistantContext | null>(null); const messages = ref<AssistantTurn[]>([])
// 展开只影响展示;切换会话或账户时恢复折叠,不改变已绑定的报告和产品。
const contextOpen = ref(false)
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 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 + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '')
if (from.value == 'product' && productIds.value.length == 1) url = '/packages/finance/product/product?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&productId=' + productIds.value[0] + '&tab=explanation'
if (from.value == 'compare') url = '/packages/finance/compare/compare?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&productIds=' + productIds.value.join(',')
if (from.value == 'next-step') url = '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + (productIds.value.length == 1 ? '&productId=' + productIds.value[0] + '&section=prepare' : '&section=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 + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.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; contextOpen.value = false; 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, revisionId.value)
if (!current(ticket, token)) return
const page = await assistantMessages(chatId.value, '')
if (!current(ticket, token)) return
context.value = chat.context; revisionId.value = chat.context.revisionId; 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, revisionId.value); if (!current(ticket, token)) return; context.value = data; revisionId.value = data.revisionId }
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, revisionId.value))
// #endif
}
const transmit = async () => {
if (sending.value || context.value == null) return
// 发请求前核对账户,避免轮询尚未发现切换时将旧草稿带新Token发送。
if (!current(generation, scope)) 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, revisionId.value); 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, '', revisionId.value) }) }
onLoad((options) => {
revisionId.value = options?.['revisionId'] ?? '';
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError.value = '版本编号无效,请返回重新选择。'
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>
.context-summary { flex: 1; font-size: 24rpx; line-height: 36rpx; color: #45617f; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.context-toggle { flex-shrink: 0; color: #527ba8; font-size: 22rpx; margin-left: 16rpx; line-height: 36rpx; }
.context-source { margin-top: 16rpx; }
.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; }
.history-entry { color: #7b899c; font-size: 21rpx; }
.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>