2026-09-10 10:57:40 +08:00
|
|
|
|
<template>
|
2026-09-28 14:56:46 +08:00
|
|
|
|
<view class="page product-page" :style="pageStyle()">
|
|
|
|
|
|
<AppHeader :title="assessmentId.length > 0 ? '银行匹配详情' : '产品详情'" :customBack="true" @back="back" />
|
|
|
|
|
|
<scroll-view scroll-y :style="contentStyle(96, false)">
|
|
|
|
|
|
<view v-if="pageActive" class="product-content">
|
|
|
|
|
|
<text v-if="loading" class="muted loading">正在读取产品资料…</text>
|
|
|
|
|
|
<view v-if="error.length > 0" class="error-panel"><text class="heading">暂时无法读取</text><text class="body">{{ error }}</text><button class="secondary" @click="load">重新加载</button><text class="link" @click="back">返回选择产品</text></view>
|
|
|
|
|
|
<template v-if="product != null">
|
|
|
|
|
|
<view class="bank-header">
|
2026-10-14 14:53:13 +08:00
|
|
|
|
<view class="bank-mark"><image class="bank-icon" :src="product!.logo.length > 0 ? product!.logo : '/static/mobile/icons-generated/building.png'" @error="product!.logo = ''" mode="aspectFit" /></view>
|
2026-09-28 14:56:46 +08:00
|
|
|
|
<view class="bank-info">
|
2026-10-14 14:53:13 +08:00
|
|
|
|
<view class="bank-title-row"><text v-if="product!.bank.length > 0" class="bank-name">{{ product!.bank }}</text><text v-if="match != null" :class="match!.state == 'eligible' ? 'badge good' : (match!.state == 'ineligible' ? 'badge blocked' : 'badge pending')">{{ match!.state == 'eligible' ? (match!.score != null ? '匹配度 ' + match!.score!.toString() + '%' : '可供参考') : (match!.state == 'ineligible' ? '暂不推荐' : match!.stateLabel) }}</text></view>
|
2026-09-28 14:56:46 +08:00
|
|
|
|
<text v-if="match != null" :class="match!.state == 'eligible' ? 'state good-text' : 'state attention-text'">{{ match!.stateLabel }}</text>
|
|
|
|
|
|
<text class="product-name">{{ product!.name }}</text>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
2026-10-14 14:53:13 +08:00
|
|
|
|
<view v-if="hasProductValue(product!.amount) || hasProductValue(product!.rate)" class="product-summary"><text v-if="hasProductValue(product!.amount)" class="summary-tag">额度范围 {{ product!.amount }}</text><text v-if="hasProductValue(product!.rate)" class="summary-tag">年化利率 {{ product!.rate }}</text></view>
|
2026-09-28 14:56:46 +08:00
|
|
|
|
<view class="tabs">
|
|
|
|
|
|
<view v-if="assessmentId.length > 0" :class="tab == 'explanation' ? 'tab active' : 'tab'" @click="switchTab('explanation')"><text :class="tab == 'explanation' ? 'tab-label selected' : 'tab-label'">AI解释</text></view>
|
|
|
|
|
|
<view :class="tab == 'details' ? 'tab active' : 'tab'" @click="switchTab('details')"><text :class="tab == 'details' ? 'tab-label selected' : 'tab-label'">产品详情</text></view>
|
|
|
|
|
|
<view :class="tab == 'conditions' ? 'tab active' : 'tab'" @click="switchTab('conditions')"><text :class="tab == 'conditions' ? 'tab-label selected' : 'tab-label'">申请条件</text></view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
<template v-if="tab == 'explanation' && match != null">
|
|
|
|
|
|
<view class="reason-section">
|
2026-10-14 14:53:13 +08:00
|
|
|
|
<text class="heading good-text">{{ match!.state == 'eligible' ? '推荐理由(' + match!.positive.length + '条)' : '参考理由(' + match!.positive.length + '条)' }}</text>
|
2026-09-28 14:56:46 +08:00
|
|
|
|
<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>
|
2026-09-28 17:50:46 +08:00
|
|
|
|
<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>
|
2026-09-28 14:56:46 +08:00
|
|
|
|
</view>
|
|
|
|
|
|
<text v-if="match!.positive.length > 3" class="expand" @click="expanded = !expanded">{{ expanded ? '收起' : '展开其余' + (match!.positive.length - 3) + '条依据' }}</text>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
<view class="reason-section attention-section">
|
|
|
|
|
|
<text class="heading attention-text">不推荐/需注意的点({{ attention.length }}条)</text>
|
|
|
|
|
|
<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>
|
2026-09-28 17:50:46 +08:00
|
|
|
|
<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>
|
2026-09-28 14:56:46 +08:00
|
|
|
|
</view>
|
|
|
|
|
|
<text v-if="attention.length > 3" class="expand" @click="attentionExpanded = !attentionExpanded">{{ attentionExpanded ? '收起' : '展开其余' + (attention.length - 3) + '条注意事项' }}</text>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
<view class="ai-panel">
|
2026-09-28 17:50:46 +08:00
|
|
|
|
<text class="link" @click="consult">我有疑问,咨询AI ›</text>
|
2026-09-28 14:56:46 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
<text v-else-if="match!.aiStatus == 'failed'" class="ai-note">解释生成未完成,规则依据不受影响。{{ match!.aiCanRetry ? '可以重新尝试。' : '本次已达到重试上限。' }}</text>
|
|
|
|
|
|
<text v-else class="ai-note">当前为规则依据。可让AI逐条说明条件含义和资料核对建议。</text>
|
|
|
|
|
|
<text v-if="aiError.length > 0" class="attention-text ai-note">{{ aiError }}</text>
|
2026-09-30 19:11:44 +08:00
|
|
|
|
<text v-if="!match!.aiCanRequest" class="ai-note">历史版本保留已保存的规则与解释。</text>
|
|
|
|
|
|
<button v-if="match!.aiCanRequest && !aiPending && match!.aiStatus != 'completed' && (match!.aiStatus != 'failed' || match!.aiCanRetry)" class="ai-button" :disabled="aiBusy" @click="generateExplanation">{{ aiBusy ? '正在提交…' : (match!.aiStatus == 'failed' ? '重试AI解释' : '生成AI详细解释') }}</button>
|
2026-09-28 14:56:46 +08:00
|
|
|
|
<button v-if="aiPending && (aiError.length > 0 || pollStopped)" class="ai-button" :disabled="aiBusy" @click="checkExplanation">重新查询状态</button>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-if="tab == 'details'">
|
|
|
|
|
|
<view class="details-section"><text class="heading">产品信息</text>
|
2026-10-14 14:53:13 +08:00
|
|
|
|
<view v-if="hasProductValue(product!.amount)" class="row"><text class="label">额度范围</text><text class="value emphasis">{{ product!.amount }}</text></view>
|
|
|
|
|
|
<view v-if="hasProductValue(product!.rate)" class="row"><text class="label">年化利率</text><text class="value">{{ product!.rate }}</text></view>
|
|
|
|
|
|
<view v-if="hasProductValue(product!.term)" class="row"><text class="label">贷款期限</text><text class="value">{{ product!.term }}</text></view>
|
2026-09-28 14:56:46 +08:00
|
|
|
|
<view class="row"><text class="label">适用主体</text><text class="value">{{ product!.objects }}</text></view>
|
|
|
|
|
|
<view class="row"><text class="label">适用地区</text><text class="value">{{ product!.regions }}</text></view>
|
2026-10-14 14:53:13 +08:00
|
|
|
|
<view v-if="hasProductValue(product!.repayment)" class="row"><text class="label">还款方式</text><text class="value">{{ product!.repayment }}</text></view>
|
|
|
|
|
|
<view v-if="hasProductValue(product!.channel)" class="row"><text class="label">办理渠道</text><text class="value">{{ product!.channel }}</text></view>
|
|
|
|
|
|
<view v-if="hasProductValue(product!.approval)" class="row"><text class="label">审批时长</text><text class="value">{{ product!.approval }}</text></view>
|
2026-09-28 14:56:46 +08:00
|
|
|
|
</view>
|
2026-10-14 14:53:13 +08:00
|
|
|
|
<view v-if="hasProductValue(product!.source) || hasProductValue(product!.sourceRef) || hasProductValue(product!.checkedOn)" class="details-section"><text class="heading">资料来源</text><text v-if="hasProductValue(product!.source)" class="body">{{ product!.source }}</text><text v-if="hasProductValue(product!.sourceRef)" class="evidence">引用:{{ product!.sourceRef }}</text><text v-if="hasProductValue(product!.checkedOn)" class="evidence">核对日期:{{ product!.checkedOn }}</text></view>
|
2026-09-28 14:56:46 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
<template v-if="tab == 'conditions'">
|
|
|
|
|
|
<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>
|
2026-10-14 14:53:13 +08:00
|
|
|
|
<view class="details-section"><text class="heading">资料准备说明</text><view v-for="(material, index) in product!.materialList" :key="index" class="material-item"><text class="condition">{{ material.name }} · {{ material.required ? '必需' : '补充' }} · {{ material.objects }}</text><text v-if="material.note.length > 0" class="evidence">{{ material.note }}</text></view><text v-if="product!.materialList.length == 0 || product!.materials != '未提供'" class="body">{{ product!.materials }}</text><text class="muted">仅提供材料引导,不向机构提交申请。实际条件和材料须向机构核实。</text></view>
|
2026-09-28 14:56:46 +08:00
|
|
|
|
</template>
|
2026-10-14 14:53:13 +08:00
|
|
|
|
<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 v-if="match != null && match!.score != null" class="record-note">匹配度表示条件匹配程度,不代表获批概率。</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>
|
2026-09-28 14:56:46 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</scroll-view>
|
|
|
|
|
|
<view v-if="pageActive && product != null" class="action-bar" :style="bottomStyle(22)">
|
2026-10-14 14:53:13 +08:00
|
|
|
|
<template v-if="match != null && canPrepare"><button class="footer-button secondary" @click="consultAdvisor">咨询顾问</button><button class="footer-button primary" @click="prepare">申请办理</button></template>
|
2026-09-28 17:50:46 +08:00
|
|
|
|
<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>
|
2026-09-28 14:56:46 +08:00
|
|
|
|
<button v-else class="footer-button primary" @click="switchTab(tab == 'conditions' ? 'details' : 'conditions')">{{ tab == 'conditions' ? '查看产品详情' : '查看申请条件' }}</button>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
2026-09-10 10:57:40 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="uts">
|
2026-09-28 14:56:46 +08:00
|
|
|
|
import AppHeader from '@/components/AppHeader.uvue'
|
|
|
|
|
|
import { usePageLayout } from '@/services/layout.uts'
|
|
|
|
|
|
import { getSession } from '@/services/api.uts'
|
2026-10-14 14:53:13 +08:00
|
|
|
|
import { MatchView, MatchReason, MatchDetail, ProductView, fetchMatchDetailRecord, fetchPublicProduct, validRecordId, requestProductExplanation, hasProductValue } from '@/packages/finance/services/matches.uts'
|
2026-10-07 10:01:05 +08:00
|
|
|
|
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery, sameMatchSnapshot, matchReturnPath } from '@/packages/finance/services/compare.uts'
|
2026-09-28 17:50:46 +08:00
|
|
|
|
import { assistantRoute } from '@/packages/finance/services/assistant.uts'
|
2026-09-28 14:56:46 +08:00
|
|
|
|
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
2026-09-30 19:11:44 +08:00
|
|
|
|
const revisionId = ref(''); const assessmentId = ref(''); const productId = ref(''); const tab = ref('details')
|
2026-09-28 14:56:46 +08:00
|
|
|
|
const selectedIds = ref<string[]>([]); let from = ''
|
|
|
|
|
|
const product = ref<ProductView | null>(null); const match = ref<MatchView | null>(null)
|
|
|
|
|
|
const loading = ref(false); const error = ref(''); const expanded = ref(false)
|
|
|
|
|
|
const pageActive = ref(false); const attentionExpanded = ref(false); const openReason = ref('')
|
|
|
|
|
|
const aiBusy = ref(false); const aiError = ref(''); const pollStopped = ref(false)
|
|
|
|
|
|
let timer: number = 0; let pollCount = 0; let requestKey = ''
|
|
|
|
|
|
let routeError = ''; let visible = false; let generation = 0
|
2026-10-07 10:01:05 +08:00
|
|
|
|
let owner = ''; let accountTimer = 0; let frozenDetail: MatchDetail | null = null
|
2026-09-28 14:56:46 +08:00
|
|
|
|
const positiveVisible = computed((): MatchReason[] => match.value == null ? [] : (expanded.value ? match.value!.positive : match.value!.positive.slice(0, 3)))
|
|
|
|
|
|
const attention = computed((): MatchReason[] => match.value == null ? [] : match.value!.blocking.concat(match.value!.cautions))
|
|
|
|
|
|
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')
|
2026-09-28 17:50:46 +08:00
|
|
|
|
// 缩短重复的规则模板标题;核验限制仍保留在逐条依据和页尾,不改判定含义。
|
|
|
|
|
|
const reasonTitle = (reason: MatchReason): string => reason.text.replace('(资料尚需核验)。', '').replace('本产品已配置条件', '产品条件')
|
|
|
|
|
|
function stopPoll(): void { if (timer != 0) clearTimeout(timer); timer = 0 }
|
2026-10-07 10:01:05 +08:00
|
|
|
|
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
|
|
|
|
|
|
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeMatch=1&matchPage=product&assessmentId=' + assessmentId.value + '&revisionId=' + revisionId.value + '&productIds=' + selectedIds.value.join(',') + '&productId=' + productId.value + '&tab=' + tab.value + '&from=' + from }) }
|
|
|
|
|
|
const current = (request: number, token: string): boolean => {
|
|
|
|
|
|
if (!visible || generation != request) return false
|
|
|
|
|
|
if (getSession()?.getString('token') == token) return true
|
|
|
|
|
|
generation++; stopPoll(); stopAccountTimer(); product.value = null; match.value = null; frozenDetail = null; openReason.value = ''; requestKey = ''; loading.value = false; aiBusy.value = false; aiError.value = ''
|
|
|
|
|
|
if (getSession() == null) login(); else error.value = '账户已切换,请重新加载本人产品资料。'
|
|
|
|
|
|
selectedIds.value = []
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
const ready = (): boolean => current(generation, owner) && product.value != null && !loading.value
|
|
|
|
|
|
const updateUrl = () => {
|
|
|
|
|
|
// #ifdef WEB
|
|
|
|
|
|
window.history.replaceState(window.history.state, '', location.pathname + location.search + '#' + matchReturnPath('product', assessmentId.value, selectedIds.value.join(','), revisionId.value, productId.value, 'eligible', tab.value, from))
|
|
|
|
|
|
// #endif
|
|
|
|
|
|
}
|
2026-09-28 17:50:46 +08:00
|
|
|
|
const explanationError = (request: number, token: string, message: string) => {
|
|
|
|
|
|
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> {
|
2026-10-07 10:01:05 +08:00
|
|
|
|
if (!ready() || aiBusy.value || match.value == null) return
|
|
|
|
|
|
const request = generation; const token = owner
|
2026-09-28 17:50:46 +08:00
|
|
|
|
aiError.value = ''; aiBusy.value = true
|
|
|
|
|
|
try {
|
2026-09-30 19:11:44 +08:00
|
|
|
|
const detail = await fetchMatchDetailRecord(assessmentId.value, productId.value, revisionId.value)
|
2026-10-07 10:01:05 +08:00
|
|
|
|
if (!current(request, token)) return
|
|
|
|
|
|
if (frozenDetail == null || !sameMatchSnapshot(frozenDetail!, detail)) throw new Error('解释状态与本次冻结结果不一致,请重新加载。')
|
|
|
|
|
|
match.value = detail.item; schedulePoll()
|
2026-09-28 17:50:46 +08:00
|
|
|
|
} 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 () => {
|
2026-10-07 10:01:05 +08:00
|
|
|
|
if (!ready() || aiBusy.value || match.value == null || !match.value!.aiCanRequest || (match.value!.aiStatus == 'failed' && !match.value!.aiCanRetry) || aiPending.value || match.value!.aiStatus == 'completed') return
|
|
|
|
|
|
const request = generation; const token = owner
|
2026-09-28 17:50:46 +08:00
|
|
|
|
// 网络结果不确定时保留同一个幂等键,只有收到明确失败状态后才开始新的尝试。
|
|
|
|
|
|
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 {
|
2026-09-30 19:11:44 +08:00
|
|
|
|
const result = await requestProductExplanation(assessmentId.value, productId.value, requestKey, revisionId.value)
|
2026-09-28 17:50:46 +08:00
|
|
|
|
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 }
|
|
|
|
|
|
}
|
2026-09-28 14:56:46 +08:00
|
|
|
|
const load = async () => {
|
2026-10-07 10:01:05 +08:00
|
|
|
|
stopPoll(); stopAccountTimer(); pollCount = 0; pollStopped.value = false; aiError.value = ''; aiBusy.value = false
|
|
|
|
|
|
const request = ++generation; product.value = null; match.value = null; frozenDetail = null; error.value = ''; expanded.value = false; loading.value = false
|
2026-09-28 14:56:46 +08:00
|
|
|
|
if (routeError.length > 0) { error.value = routeError; return }
|
|
|
|
|
|
const session = getSession()
|
2026-10-07 10:01:05 +08:00
|
|
|
|
if (session == null) { login(); return }
|
|
|
|
|
|
const token = session.getString('token') ?? ''; if (owner.length > 0 && owner != token) { selectedIds.value = []; requestKey = '' }; owner = token; loading.value = true
|
|
|
|
|
|
accountTimer = setInterval(() => { current(generation, owner) }, 1000)
|
2026-09-28 14:56:46 +08:00
|
|
|
|
try {
|
|
|
|
|
|
// 带评估编号始终读取冻结结果;404/越权/失败不能降级为公共资料。
|
|
|
|
|
|
if (assessmentId.value.length > 0) {
|
2026-09-30 19:11:44 +08:00
|
|
|
|
const detail = await fetchMatchDetailRecord(assessmentId.value, productId.value, revisionId.value)
|
|
|
|
|
|
const result = detail.item
|
2026-10-07 10:01:05 +08:00
|
|
|
|
if (current(request, token)) { revisionId.value = detail.revisionId; frozenDetail = detail; match.value = result; product.value = result.product; saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value); updateUrl(); schedulePoll() }
|
2026-09-28 14:56:46 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
const result = await fetchPublicProduct(productId.value)
|
2026-09-30 19:11:44 +08:00
|
|
|
|
if (current(request, token)) product.value = result
|
2026-09-28 14:56:46 +08:00
|
|
|
|
}
|
2026-10-07 10:01:05 +08:00
|
|
|
|
} catch (err) { if (current(request, token)) error.value = err instanceof Error ? err.message : '产品读取失败' }
|
2026-09-28 14:56:46 +08:00
|
|
|
|
finally { if (generation == request) loading.value = false }
|
|
|
|
|
|
}
|
2026-09-28 17:50:46 +08:00
|
|
|
|
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
|
|
|
|
|
|
// 仅携带选择ID;助手会重新校验归属并读取冻结事实,进入页面不会调用模型。
|
2026-10-07 10:01:05 +08:00
|
|
|
|
const consult = () => { if (ready() && match.value != null) uni.navigateTo({ url: assistantRoute(assessmentId.value, [productId.value], 'product', '', revisionId.value) }) }
|
|
|
|
|
|
function toMatches(): void { if (owner.length > 0 && !current(generation, owner)) return; if (assessmentId.value.length > 0) uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }); else history() }
|
2026-09-28 14:56:46 +08:00
|
|
|
|
// 替换当前路由保留栏目、来源与对比选择,刷新后仍停留在同一栏。
|
|
|
|
|
|
const switchTab = (value: string) => {
|
2026-10-07 10:01:05 +08:00
|
|
|
|
if (!ready() || value == tab.value) return
|
2026-09-30 19:11:44 +08:00
|
|
|
|
const query = assessmentId.value.length > 0 ? compareQuery(assessmentId.value, selectedIds.value, revisionId.value) + '&' : '?'
|
2026-09-28 14:56:46 +08:00
|
|
|
|
uni.redirectTo({ url: '/packages/finance/product/product' + query + 'productId=' + productId.value + '&tab=' + value + '&from=' + from })
|
|
|
|
|
|
}
|
2026-09-28 17:50:46 +08:00
|
|
|
|
// 下一步页重新核对当前可用性,避免沿用产品详情进入时的旧状态。
|
2026-10-14 14:53:13 +08:00
|
|
|
|
const consultAdvisor = () => {
|
|
|
|
|
|
if (!ready() || product.value == null) return
|
|
|
|
|
|
if (match.value != null && match.value!.availability != 'available') { uni.showToast({ title: '产品当前不可用,请联系平台核对。', icon: 'none' }); return }
|
|
|
|
|
|
const phone = product.value!.consultPhone
|
|
|
|
|
|
if (phone.length == 0) { uni.showToast({ title: '暂未配置顾问电话,请联系平台。', icon: 'none' }); return }
|
|
|
|
|
|
uni.makePhoneCall({ phoneNumber: phone })
|
|
|
|
|
|
}
|
2026-10-07 10:01:05 +08:00
|
|
|
|
const prepare = () => { if (ready() && canPrepare.value) uni.navigateTo({ url: '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&productId=' + productId.value + '§ion=prepare' }) }
|
|
|
|
|
|
const back = () => { if (owner.length > 0 && !current(generation, owner)) return; if (getCurrentPages().length > 1) uni.navigateBack(); else if (from == 'compare') uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }); else toMatches() }
|
2026-09-28 14:56:46 +08:00
|
|
|
|
const openCompare = () => {
|
2026-10-07 10:01:05 +08:00
|
|
|
|
if (!ready() || match.value == null) return
|
2026-09-28 14:56:46 +08:00
|
|
|
|
if (!selectedIds.value.includes(productId.value)) {
|
|
|
|
|
|
if (selectedIds.value.length >= 3) { uni.showToast({ title: '已选满3项,请在对比页调整选择', icon: 'none' }); return }
|
|
|
|
|
|
selectedIds.value.push(productId.value)
|
|
|
|
|
|
}
|
2026-09-30 19:11:44 +08:00
|
|
|
|
saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value)
|
|
|
|
|
|
uni.navigateTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) })
|
2026-09-28 14:56:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
onLoad((options) => {
|
2026-09-30 19:11:44 +08:00
|
|
|
|
revisionId.value = options?.['revisionId'] ?? '';
|
|
|
|
|
|
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError = '版本编号无效,请返回重新选择。'
|
2026-09-28 14:56:46 +08:00
|
|
|
|
assessmentId.value = options?.['assessmentId'] ?? ''; const oldId = options?.['id'] ?? ''
|
|
|
|
|
|
productId.value = options?.['productId'] ?? oldId
|
|
|
|
|
|
if (options?.['assessmentId'] != null && !validRecordId(assessmentId.value)) routeError = '评估编号无效,请从报告重新进入。'
|
|
|
|
|
|
if (!validRecordId(productId.value) || (oldId.length > 0 && oldId != productId.value)) routeError = '产品编号无效或冲突,请重新选择。'
|
|
|
|
|
|
tab.value = options?.['tab'] ?? (assessmentId.value.length > 0 ? 'explanation' : 'details')
|
|
|
|
|
|
if (!['explanation','details','conditions'].includes(tab.value) || (assessmentId.value.length == 0 && tab.value == 'explanation')) routeError = '页面栏目参数无效,请重新选择。'
|
|
|
|
|
|
from = options?.['from'] ?? ''
|
|
|
|
|
|
if (!['','matches','product','compare','report','next-step','archive'].includes(from)) routeError = '页面来源参数无效,请重新选择。'
|
|
|
|
|
|
if (options?.['productIds'] != null) {
|
2026-09-30 19:11:44 +08:00
|
|
|
|
try { saveCompareSelection(assessmentId.value, parseCompareIds(options!['productIds']!), revisionId.value) }
|
2026-09-28 14:56:46 +08:00
|
|
|
|
catch (err) { routeError = err instanceof Error ? err.message : '对比参数无效' }
|
|
|
|
|
|
}
|
2026-10-07 10:01:05 +08:00
|
|
|
|
if (matchReturnPath('product', assessmentId.value, options?.['productIds'] ?? '', revisionId.value, productId.value, 'eligible', tab.value, from).length == 0) routeError = '产品页面参数无效,请返回重新选择。'
|
2026-09-28 14:56:46 +08:00
|
|
|
|
})
|
2026-09-30 19:11:44 +08:00
|
|
|
|
onShow(() => { visible = true; pageActive.value = true; selectedIds.value = compareSelection(assessmentId.value, revisionId.value); load() })
|
2026-10-07 10:01:05 +08:00
|
|
|
|
onHide(() => { pageActive.value = false; visible = false; stopPoll(); stopAccountTimer(); generation++; product.value = null; match.value = null; frozenDetail = null; error.value = ''; aiError.value = ''; aiBusy.value = false; loading.value = false; openReason.value = '' })
|
|
|
|
|
|
onUnload(() => { pageActive.value = false; visible = false; stopPoll(); stopAccountTimer(); generation++; product.value = null; match.value = null; frozenDetail = null; requestKey = '' })
|
2026-09-10 10:57:40 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
<style>
|
2026-09-28 14:56:46 +08:00
|
|
|
|
.product-page { background-color: #fff; }
|
|
|
|
|
|
.product-content { padding: 18rpx 32rpx 200rpx; }
|
|
|
|
|
|
.loading { padding: 36rpx 0; }
|
|
|
|
|
|
.bank-header { flex-direction: row; align-items: center; padding: 20rpx 0 10rpx; }
|
|
|
|
|
|
.bank-mark { width: 108rpx; height: 108rpx; border-radius: 54rpx; background-color: #edf5ff; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; }
|
|
|
|
|
|
.bank-icon { width: 86rpx; height: 86rpx; }
|
|
|
|
|
|
.bank-info { flex: 1; }
|
|
|
|
|
|
.bank-title-row { flex-direction: row; flex-wrap: wrap; align-items: center; }
|
|
|
|
|
|
.bank-name { color: #16253c; font-size: 33rpx; font-weight: 700; line-height: 48rpx; margin-right: 14rpx; }
|
|
|
|
|
|
.badge { font-size: 21rpx; line-height: 32rpx; padding: 3rpx 12rpx; border-radius: 6rpx; }
|
|
|
|
|
|
.good { color: #249a76; background-color: #e9f8f1; }
|
|
|
|
|
|
.blocked { color: #697387; background-color: #f0f2f6; }
|
|
|
|
|
|
.pending { color: #ac802d; background-color: #fff7e5; }
|
|
|
|
|
|
.state { font-size: 24rpx; line-height: 36rpx; margin-top: 5rpx; }
|
|
|
|
|
|
.product-name { color: #788294; font-size: 24rpx; line-height: 36rpx; margin-top: 3rpx; }
|
|
|
|
|
|
.demo { color: #8d96a4; font-size: 21rpx; line-height: 32rpx; margin-top: 10rpx; }
|
2026-10-14 14:53:13 +08:00
|
|
|
|
.product-summary { flex-direction: row; flex-wrap: wrap; margin-top: 12rpx; }
|
|
|
|
|
|
.summary-tag { color: #1670ed; background-color: #edf4ff; font-size: 23rpx; line-height: 38rpx; padding: 4rpx 12rpx; border-radius: 6rpx; margin-right: 10rpx; margin-top: 6rpx; }
|
|
|
|
|
|
.material-item { margin-top: 12rpx; }
|
2026-09-28 14:56:46 +08:00
|
|
|
|
.tabs { flex-direction: row; border-bottom: 1rpx solid #e8edf4; margin-top: 18rpx; }
|
|
|
|
|
|
.tab { flex: 1; align-items: center; padding: 22rpx 0; }
|
|
|
|
|
|
.active { border-bottom: 4rpx solid #0868f7; }
|
|
|
|
|
|
.tab-label { font-size: 26rpx; color: #687588; }
|
|
|
|
|
|
.selected { color: #0868f7; font-weight: 700; }
|
|
|
|
|
|
.reason-section { margin-top: 28rpx; }
|
|
|
|
|
|
.attention-section { margin-top: 30rpx; }
|
|
|
|
|
|
.heading { font-size: 28rpx; color: #243752; font-weight: 700; line-height: 42rpx; }
|
|
|
|
|
|
.good-text { color: #259d79; }
|
|
|
|
|
|
.attention-text { color: #e37b55; }
|
|
|
|
|
|
.reason-row { flex-direction: row; align-items: flex-start; margin-top: 22rpx; }
|
|
|
|
|
|
.number { width: 42rpx; height: 42rpx; border-radius: 21rpx; color: #fff; font-size: 25rpx; line-height: 42rpx; text-align: center; flex-shrink: 0; margin-right: 16rpx; margin-top: 2rpx; }
|
|
|
|
|
|
.green-number { background-color: #35b38c; }
|
|
|
|
|
|
.orange-number { background-color: #f49162; }
|
|
|
|
|
|
.reason-content { flex: 1; }
|
2026-09-28 17:50:46 +08:00
|
|
|
|
.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; }
|
2026-09-28 14:56:46 +08:00
|
|
|
|
.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; }
|
|
|
|
|
|
.expand { color: #4784c9; font-size: 23rpx; padding: 12rpx 0 0 58rpx; }
|
|
|
|
|
|
.empty-note { color: #8c97a6; font-size: 23rpx; line-height: 38rpx; margin-top: 16rpx; }
|
|
|
|
|
|
.ai-panel { background-color: #f0f6ff; border-radius: 12rpx; padding: 24rpx; margin-top: 34rpx; }
|
|
|
|
|
|
.ai-title { color: #316aac; font-size: 25rpx; font-weight: 600; }
|
|
|
|
|
|
.ai-note { color: #7890ab; font-size: 23rpx; line-height: 38rpx; margin-top: 10rpx; }
|
|
|
|
|
|
.ai-button { font-size: 24rpx; color: #0868f7; background-color: #fff; border: 1rpx solid #d6e6fb; border-radius: 10rpx; margin-top: 20rpx; }
|
|
|
|
|
|
.details-section { padding-top: 30rpx; }
|
|
|
|
|
|
.row { flex-direction: row; border-bottom: 1rpx solid #f0f3f7; padding: 23rpx 0; }
|
|
|
|
|
|
.label { color: #8190a3; width: 146rpx; font-size: 24rpx; line-height: 38rpx; }
|
|
|
|
|
|
.value { flex: 1; text-align: right; color: #35465f; font-size: 25rpx; line-height: 38rpx; }
|
|
|
|
|
|
.emphasis { color: #24578c; font-weight: 700; }
|
|
|
|
|
|
.body { color: #53657e; font-size: 25rpx; line-height: 42rpx; margin-top: 18rpx; }
|
|
|
|
|
|
.muted { color: #8694a7; font-size: 23rpx; line-height: 38rpx; margin-top: 12rpx; }
|
|
|
|
|
|
.condition { padding: 20rpx 0; border-bottom: 1rpx solid #edf1f7; font-size: 25rpx; color: #435b77; line-height: 42rpx; }
|
|
|
|
|
|
.footnotes { padding: 26rpx 0 0; margin-top: 28rpx; border-top: 1rpx solid #edf1f6; }
|
|
|
|
|
|
.record-note { color: #939dac; font-size: 21rpx; line-height: 36rpx; }
|
|
|
|
|
|
.link { font-size: 24rpx; color: #0868f7; padding: 22rpx 0; }
|
|
|
|
|
|
.error-panel { padding: 20rpx 0; }
|
|
|
|
|
|
.action-bar { position: fixed; bottom: 0; left: 0; right: 0; padding: 18rpx 32rpx 22rpx; flex-direction: row; background-color: #fff; border-top: 1rpx solid #edf1f7; }
|
|
|
|
|
|
.footer-button { flex: 1; height: 80rpx; line-height: 80rpx; padding: 0 10rpx; font-size: 25rpx; border-radius: 12rpx; margin: 0; }
|
|
|
|
|
|
.primary { background-color: #0868f7; color: #fff; }
|
|
|
|
|
|
.secondary { background-color: #edf4ff; color: #1769ca; font-size: 25rpx; border-radius: 12rpx; }
|
|
|
|
|
|
.action-bar .secondary { margin-right: 20rpx; }
|
|
|
|
|
|
/* #ifndef APP */
|
|
|
|
|
|
@media (min-width: 768px) { .product-content { max-width: 760px; margin: 0 auto; } .action-bar { max-width: 760px; margin: 0 auto; } }
|
|
|
|
|
|
/* #endif */
|
2026-09-10 10:57:40 +08:00
|
|
|
|
</style>
|