Files
airz-uniapp/packages/finance/compare/compare.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

244 lines
21 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" :style="pageStyle()">
<AppHeader title="本次产品对比" :customBack="true" @back="back" />
<scroll-view scroll-y :style="contentStyle(96, false)">
<view class="content">
<text class="eyebrow">评估 #{{ assessmentId }} · 历史产品快照</text>
<text class="headline">并排看清,选择更有依据</text>
<text class="intro">同次评估 · 金额单位为万元 · 条件判断不代表获批</text>
<view v-if="routeError.length > 0" class="panel"><text class="warning">{{ routeError }}</text><button class="secondary" @click="toMatches">返回本次匹配</button></view>
<template v-else>
<view class="toolbar"><view class="summary"><text class="summary-title">已选 {{ ids.length }} / 3 项</text><text class="hint">{{ complete ? '已读取同一版本的完整对比' : (ids.length < 2 ? '再选产品后开始对比' : '对比尚不完整,请查看各列状态') }}</text></view><button class="add" @click="toMatches">{{ ids.length < 3 ? '补选产品' : '调整选择' }}</button></view>
<view v-if="ids.length == 0" class="panel"><text class="summary-title">还没有选择产品</text><text class="intro">从本次匹配中选择2–3项,也可对照不符条件和待核实的产品。</text><button class="secondary" @click="toMatches">去选择产品</button></view>
<template v-else>
<view class="overview-table">
<view class="overview-row">
<view class="overview-label overview-head"><text class="row-label">核心参数</text></view>
<scroll-view v-for="column in columns" :key="column.id" scroll-y class="overview-value overview-head">
<view class="overview-product">
<template v-if="usable(column)"><image class="overview-logo" :src="column.data!.item.product.logo.length > 0 ? column.data!.item.product.logo : '/static/mobile/icons-generated/building.png'" mode="aspectFit" @error="column.data!.item.product.logo = ''" /><text class="overview-name">{{ column.data!.item.product.name }}</text><text v-if="column.data!.item.product.bank.length > 0" class="overview-bank">{{ column.data!.item.product.bank }}</text><text class="overview-link" @click="detail(column.id)">产品详情 ›</text></template>
<template v-else><text class="overview-name">产品 #{{ column.id }}</text><text class="overview-bank">{{ column.loading ? '读取中…' : problem(column) }}</text><text v-if="!column.loading" class="overview-link" @click="loadColumn(column.id)">重试此列</text></template>
<text class="overview-remove" @click="remove(column.id)">移除</text>
</view>
</scroll-view>
</view>
<view v-for="row in overviewRows" :key="row.id" class="overview-row">
<view class="overview-label"><text class="row-label">{{ row.label }}</text></view>
<view v-for="column in columns" :key="column.id" class="overview-value"><text :class="overviewClass(column, row.id)">{{ cell(column, row.id) }}</text></view>
</view>
</view>
<text class="overview-note">{{ orderingNote }}</text>
<view class="details-toggle" @click="detailsOpen = !detailsOpen"><text class="summary-title">详细条件与匹配依据</text><text class="detail-toggle-label">{{ detailsOpen ? '收起' : '展开' }}</text></view>
<text v-if="detailsOpen" class="scroll-hint">各产品并排展示 · 向下查看完整条件与依据</text>
<view v-if="detailsOpen" class="overview-table detail-table">
<view class="overview-row">
<view class="overview-label"><text class="row-label">对比指标</text><text class="hint">同次评估</text></view>
<view v-for="column in columns" :key="column.id" class="overview-value detail-value">
<template v-if="usable(column)">
<image class="overview-logo" :src="column.data!.item.product.logo.length > 0 ? column.data!.item.product.logo : '/static/mobile/icons-generated/building.png'" mode="aspectFit" @error="column.data!.item.product.logo = ''" />
<text class="overview-name">{{ column.data!.item.product.name }}</text>
<text class="overview-bank">{{ column.data!.item.product.bank }} · v{{ column.data!.item.product.version }}</text>
<text class="overview-link" @click="detail(column.id)">查看依据与详情 ›</text>
</template>
<template v-else><text class="overview-name">产品 #{{ column.id }}</text><text class="hint">{{ column.loading ? '正在读取历史资料…' : problem(column) }}</text><text v-if="!column.loading" class="overview-link" @click="loadColumn(column.id)">重试此列</text></template>
<text class="overview-remove" @click="remove(column.id)">移除</text>
</view>
</view>
<view v-for="row in rows" :key="row.id" class="overview-row">
<view class="overview-label"><text class="row-label">{{ row.label }}</text></view>
<view v-for="column in columns" :key="column.id" class="overview-value detail-value"><text :class="row.id == 'amount' ? 'cell-text cell-amount' : 'cell-text'">{{ cell(column, row.id) }}</text></view>
</view>
</view>
</template>
<text class="footer-note">历史参数保留评估时版本;当前可用状态单独展示。缺失信息不会被推断为零。</text>
<button class="secondary" @click="consult">产品差异,咨询AI</button>
<button class="secondary" @click="toMatches">返回本次匹配</button>
</template>
</view>
</scroll-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 { assistantRoute } from '@/packages/finance/services/assistant.uts'
import { MatchDetail, MatchReason, validRecordId, fetchMatchDetailRecord, hasProductValue, matchPercentage } 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 }
type CompareRow = { id: string, label: string }
const { pageStyle, contentStyle } = usePageLayout()
const revisionId = ref(''); const assessmentId = ref(''); const ids = ref<string[]>([]); const columns = ref<CompareColumn[]>([]); const routeError = ref('')
const detailsOpen = ref(false)
// 按指标组织整行,产品列均分可用宽度,长证据自然撑高行而不截断。
const overviewDefinitions: CompareRow[] = [{ id: 'score', label: '匹配度' }, { id: 'amount', label: '额度范围' }, { id: 'term', label: '期限范围' }, { id: 'rate', label: '年化利率' }, { id: 'availability', label: '当前状态' }]
let visible = false; let generation = 0
let owner = ''; let accountTimer = 0; let frozenDetail: MatchDetail | null = null
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeMatch=1&matchPage=compare' + compareQuery(assessmentId.value, ids.value, revisionId.value).replace('?', '&') }) }
const current = (request: number, token: string): boolean => {
if (!visible || request != generation) return false
if (getSession()?.getString('token') == token) return true
generation++; columns.value = []; frozenDetail = null; detailsOpen.value = false; stopAccountTimer()
if (getSession() == null) login(); else { ids.value = []; routeError.value = '账户已切换,请返回本次匹配重新读取。' }
return false
}
const updateUrl = () => {
// #ifdef WEB
history.replaceState(history.state, '', location.pathname + location.search + '#/packages/finance/compare/compare' + compareQuery(assessmentId.value, ids.value, revisionId.value))
// #endif
}
const detailRows: CompareRow[] = [
{ id: 'availability', label: '当前状态' },
{ id: 'score', label: '匹配度' },
{ id: 'amount', label: '额度范围' }, { id: 'term', label: '期限范围' }, { id: 'rate', label: '年化利率' },
{ id: 'objects', label: '适用对象' }, { id: 'regions', label: '适用地区' },
{ id: 'positive', label: '推荐与参考理由' }, { id: 'blocking', label: '不推荐原因' }, { id: 'cautions', label: '注意事项' },
{ id: 'conditions', label: '准入条件' }, { id: 'repayment', label: '还款方式' },
{ id: 'materials', label: '准备材料' }, { id: 'channel', label: '办理渠道' }, { id: 'source', label: '资料来源' }
]
const problem = (column: CompareColumn): string => {
if (column.error.length > 0) return column.error
if (column.data == null) return '资料尚未读取'
const first = columns.value.find((c: CompareColumn): boolean => c.data != null)
if (first != null && !sameMatchSnapshot(first.data!, column.data!)) return '冻结结果版本不一致,请重试或返回重新选择。'
return ''
}
const usable = (column: CompareColumn): boolean => !column.loading && column.data != null && problem(column).length == 0
const complete = computed((): boolean => ids.value.length >= 2 && columns.value.length == ids.value.length && columns.value.every((c: CompareColumn): boolean => usable(c)))
const overviewClass = (column: CompareColumn, key: string): string => {
if (key == 'amount') return 'overview-text overview-amount'
return 'overview-text'
}
const cell = (column: CompareColumn, key: string): string => {
if (!usable(column)) return column.loading ? '读取中…' : '本列未就绪'
const m = column.data!.item; const p = m.product
if (key == 'score') {
const percentage = matchPercentage(m.state, m.score)
return percentage.length > 0 ? percentage : '—'
}
if (key == 'availability') return m.availabilityLabel + ';不改变历史结论'
if (key == 'positive') return compareReasonText(m.positive, '暂无已确认满足的条件')
if (key == 'blocking') return compareReasonText(m.blocking, '未发现已知不符条件;结果仅供参考')
if (key == 'cautions') return compareReasonText(m.cautions, '暂无需要特别注意的已知条件;不代表审批结果')
if (key == 'conditions') return p.conditions.length > 0 ? p.conditions.join('\n\n') : '未提供'
if (key == 'amount') return hasProductValue(p.amount) ? p.amount : '—'
if (key == 'term') return hasProductValue(p.term) ? p.term : '—'
if (key == 'rate') return hasProductValue(p.rate) ? p.rate : '—'
if (key == 'objects') return p.objects
if (key == 'regions') return p.regions
if (key == 'materials') return p.materials
if (key == 'repayment') return hasProductValue(p.repayment) ? p.repayment : '—'
if (key == 'channel') return hasProductValue(p.channel) ? p.channel : '—'
if (key == 'source') {
const sourceText = [hasProductValue(p.source) ? p.source : '', hasProductValue(p.sourceRef) ? '引用:' + p.sourceRef : '', hasProductValue(p.checkedOn) ? '核对日期:' + p.checkedOn : ''].filter((value: string): boolean => value.length > 0).join('\n')
return sourceText.length > 0 ? sourceText : '—'
}
return '未提供'
}
const rowHasValue = (row: CompareRow): boolean => columns.value.some((column: CompareColumn): boolean => !usable(column) || hasProductValue(cell(column, row.id)))
const overviewRows = computed((): CompareRow[] => overviewDefinitions.filter((row: CompareRow): boolean => !['score', 'amount', 'term', 'rate'].includes(row.id) || rowHasValue(row)))
const rows = computed((): CompareRow[] => detailRows.filter((row: CompareRow): boolean => !['score', 'amount', 'term', 'rate', 'repayment', 'channel', 'source'].includes(row.id) || rowHasValue(row)))
const orderingNote = computed((): string => columns.value.some((column: CompareColumn): boolean => usable(column) && column.data!.orderingVersion == 'product-score-descending-v1') ? '同组产品按匹配度排序,此处按所选顺序对比。匹配度不代表获批概率。' : '按所选顺序对比,具体条件以机构核验为准。')
const loadColumn = async (id: string) => {
if (!current(generation, owner)) return
const column = columns.value.find((c: CompareColumn): boolean => c.id == id)
if (column == null || column.loading) return
const session = getSession()
if (session == null) { login(); return }
const token = session.getString('token') ?? ''; const request = generation
column.loading = true; column.error = ''; column.data = null
try {
const result = await fetchMatchDetailRecord(assessmentId.value, id, revisionId.value)
if (!current(request, token)) return
if (revisionId.value.length > 0 && revisionId.value != result.revisionId) throw new Error('评估版本已变化,请重新加载对比。')
if (frozenDetail != null && !sameMatchSnapshot(frozenDetail!, result)) throw new Error('冻结结果版本不一致,请重试或返回重新选择。')
if (frozenDetail == null) frozenDetail = result
revisionId.value = result.revisionId; column.data = result
saveCompareSelection(assessmentId.value, ids.value, revisionId.value); updateUrl()
} catch (err) {
if (current(request, token)) column.error = err instanceof Error ? err.message : '本列读取失败,请重试。'
}
finally { if (request == generation) column.loading = false }
}
const toMatches = () => {
if (owner.length > 0 && routeError.value.length == 0 && !current(generation, owner)) return
if (!validRecordId(assessmentId.value)) { uni.redirectTo({ url: '/packages/finance/archive/archive' }); return }
uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, ids.value, revisionId.value) })
}
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else toMatches() }
const consult = () => { if (current(generation, owner) && complete.value) uni.navigateTo({ url: assistantRoute(assessmentId.value, ids.value, 'compare', '', revisionId.value) }); else if (routeError.value.length == 0) uni.showToast({ title: '请先读取完整对比后再咨询AI', icon: 'none' }) }
const remove = (id: string) => {
if (!current(generation, owner)) return
const next = ids.value.filter((value: string): boolean => value != id)
saveCompareSelection(assessmentId.value, next, revisionId.value)
// 用新路由替换当前页,使刷新/分享恢复实际选择,而不是已移除的旧ID。
uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, next, revisionId.value) })
}
const detail = (id: string) => { if (current(generation, owner)) uni.navigateTo({ url: '/packages/finance/product/product' + compareQuery(assessmentId.value, ids.value, revisionId.value) + '&productId=' + id + '&tab=explanation&from=compare' }) }
onLoad((options) => {
revisionId.value = options?.['revisionId'] ?? '';
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError.value = '版本编号无效,请返回重新选择。'
assessmentId.value = options?.['assessmentId'] ?? ''
if (!validRecordId(assessmentId.value)) { routeError.value = '评估编号无效,请从融资档案重新选择。'; return }
try { ids.value = parseCompareIds(options?.['productIds'] ?? ''); saveCompareSelection(assessmentId.value, ids.value, revisionId.value) }
catch (err) { routeError.value = err instanceof Error ? err.message : '对比参数无效' }
})
onShow(() => {
visible = true; generation++; stopAccountTimer(); frozenDetail = null
if (routeError.value.length > 0) return
if (getSession() == null) { login(); return }
owner = getSession()?.getString('token') ?? ''
accountTimer = setInterval(() => { current(generation, owner) }, 1000)
// 依URL顺序固定列位;仅最多三个详情请求并行,单列失败不影响其他列。
columns.value = ids.value.map((id: string): CompareColumn => { return { id, loading: false, error: '', data: null } })
ids.value.forEach((id: string) => { loadColumn(id) })
})
onHide(() => { visible = false; stopAccountTimer(); generation++; columns.value = []; frozenDetail = null })
onUnload(() => { visible = false; stopAccountTimer(); generation++; columns.value = []; frozenDetail = null })
</script>
<style>
.overview-table { border: 1rpx solid #dfe8f3; border-radius: 14rpx; overflow: hidden; background-color: #fff; }
.overview-row { flex-direction: row; border-bottom: 1rpx solid #e7edf5; }
.overview-label { width: 120rpx; flex-shrink: 0; padding: 22rpx 12rpx; background-color: #eef4fb; justify-content: center; }
.overview-value { flex: 1; width: 0; padding: 22rpx 12rpx; border-left: 1rpx solid #e7edf5; justify-content: center; }
.overview-head { height: 270rpx; padding-top: 18rpx; padding-bottom: 18rpx; }
.overview-product { padding: 0; }
.overview-name { font-size: 24rpx; line-height: 35rpx; font-weight: 700; color: #243f60; }
.overview-bank { color: #788ba2; font-size: 20rpx; line-height: 30rpx; margin-top: 10rpx; }
.overview-link, .overview-remove { font-size: 22rpx; line-height: 34rpx; color: #0868f7; padding-top: 12rpx; }
.overview-remove { color: #788ba2; }
.overview-text { font-size: 23rpx; line-height: 36rpx; color: #455f7c; }
.overview-logo { width: 56rpx; height: 56rpx; margin-bottom: 10rpx; flex-shrink: 0; }
.overview-amount { font-weight: 700; color: #154779; }
.overview-note { color: #8494a7; font-size: 21rpx; line-height: 34rpx; margin-top: 14rpx; }
.details-toggle { flex-direction: row; align-items: center; justify-content: space-between; padding: 28rpx 0; }
.detail-toggle-label { color: #0868f7; font-size: 24rpx; }
.content { padding: 28rpx 22rpx 60rpx; }
.eyebrow { color: #6d829c; font-size: 22rpx; }
.headline { color: #193653; font-size: 36rpx; font-weight: 700; margin-top: 14rpx; }
.intro { color: #76879c; font-size: 24rpx; line-height: 39rpx; margin-top: 14rpx; }
.panel { padding: 30rpx; background-color: #fff; border-radius: 18rpx; margin-top: 24rpx; }
.warning { color: #a6603e; font-size: 26rpx; line-height: 40rpx; }
.toolbar { flex-direction: row; align-items: center; padding: 24rpx 0; }
.summary { flex: 1; }
.summary-title { color: #243f60; font-size: 28rpx; font-weight: 700; }
.hint { color: #788ba2; font-size: 22rpx; line-height: 34rpx; margin-top: 8rpx; }
.add { color: #0868f7; background-color: #e6f0ff; font-size: 24rpx; border-radius: 12rpx; margin-left: 14rpx; }
.scroll-hint { font-size: 21rpx; color: #8494a7; margin-bottom: 16rpx; }
.row-label { font-size: 23rpx; line-height: 36rpx; color: #48627e; }
.state { font-size: 22rpx; margin-top: 12rpx; }
.good { color: #18805d; }
.blocked { color: #b26341; }
.pending { color: #9b751f; }
.demo { color: #8b97a8; font-size: 20rpx; margin-top: 6rpx; }
.detail-value { justify-content: flex-start; }
.cell-text { font-size: 23rpx; line-height: 36rpx; color: #455f7c; }
.cell-amount { color: #154779; font-weight: 700; }
/* #ifndef APP */
.detail-table .cell-text, .detail-table .overview-name, .detail-table .overview-bank { white-space: pre-wrap; word-break: break-all; }
/* #endif */
.footer-note { color: #8997a8; font-size: 22rpx; line-height: 36rpx; margin-top: 24rpx; }
.secondary { color: #0868f7; background-color: #eaf3ff; font-size: 26rpx; border-radius: 12rpx; margin-top: 20rpx; }
</style>