Files
airz-uniapp/packages/finance/compare/compare.uvue
T

244 lines
21 KiB
Plaintext
Raw Normal View History

<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>