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

253 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)"><text class="overview-name">{{ column.data!.item.product.name }}</text><text 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">展示顺序不代表优劣,具体条件以机构核验为准。</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="table">
<view class="rail"><view class="rail-head"><text class="rail-title">对比指标</text><text class="hint">同次评估</text></view><view v-for="row in rows" :key="row.id" class="label-cell" :style="rowStyle(row)"><text class="row-label">{{ row.label }}</text></view></view>
<scroll-view scroll-x class="horizontal">
<view class="columns" :style="columnsStyle()">
<view v-for="column in columns" :key="column.id" class="column">
<scroll-view scroll-y class="product-head"><view class="head-content">
<template v-if="usable(column)">
<text class="product-name">{{ column.data!.item.product.name }}</text>
<text class="hint">{{ column.data!.item.product.bank }} · v{{ column.data!.item.product.version }}</text>
<text :class="column.data!.item.state == 'eligible' ? 'state good' : (column.data!.item.state == 'ineligible' ? 'state blocked' : 'state pending')">{{ column.data!.item.stateLabel }}</text>
<text class="detail" @click="detail(column.id)">查看依据与详情 ›</text>
</template>
<template v-else><text class="product-name">产品 #{{ column.id }}</text><text class="hint">{{ column.loading ? '正在读取历史资料…' : problem(column) }}</text><button v-if="!column.loading" class="retry" @click="loadColumn(column.id)">重试此列</button></template>
<text class="remove" @click="remove(column.id)">移除</text>
</view></scroll-view>
<view v-for="row in rows" :key="row.id" class="cell" :style="rowStyle(row)">
<scroll-view scroll-y class="cell-scroll"><text :class="row.id == 'amount' ? 'cell-text amount' : 'cell-text'">{{ cell(column, row.id) }}</text></scroll-view>
</view>
</view>
</view>
</scroll-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 } 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, height: number }
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 overviewRows: CompareRow[] = [{ id: 'state', label: '匹配状态', height: 0 }, { id: 'amount', label: '额度范围', height: 0 }, { id: 'term', label: '期限范围', height: 0 }, { id: 'rate', label: '年化利率', height: 0 }]
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 rows: CompareRow[] = [
{ id: 'availability', label: '当前状态', height: 160 },
{ id: 'amount', label: '额度范围', height: 120 }, { id: 'term', label: '期限范围', height: 120 }, { id: 'rate', label: '年化利率', height: 120 },
{ id: 'objects', label: '适用对象', height: 120 }, { id: 'regions', label: '适用地区', height: 140 },
{ id: 'positive', label: '已满足条件', height: 360 }, { id: 'blocking', label: '不符条件', height: 300 }, { id: 'cautions', label: '待核实与提示', height: 300 },
{ id: 'conditions', label: '准入条件', height: 300 }, { id: 'repayment', label: '还款方式', height: 120 },
{ id: 'materials', label: '准备材料', height: 260 }, { id: 'channel', label: '办理渠道', height: 120 }, { id: 'valid', label: '产品有效期', height: 160 }, { id: 'source', label: '资料来源', height: 260 }
]
const rowStyle = (row: CompareRow): string => 'height:' + row.height + 'rpx;'
const columnsStyle = (): string => 'width:' + (columns.value.length * 350) + 'rpx;'
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'
if (key != 'state' || !usable(column)) return 'overview-text'
return 'overview-text ' + (column.data!.item.state == 'eligible' ? 'good' : (column.data!.item.state == 'ineligible' ? 'blocked' : 'pending'))
}
const cell = (column: CompareColumn, key: string): string => {
if (!usable(column)) return column.loading ? '读取中…' : '本列未就绪'
const m = column.data!.item; const p = m.product
// 核心表也保留当前不可用提示,不能让历史符合条件被误读为现在仍可办理。
if (key == 'state') return m.stateLabel + (m.availability != 'available' ? ' · ' + m.availabilityLabel : '')
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 p.amount
if (key == 'term') return p.term
if (key == 'rate') return p.rate
if (key == 'objects') return p.objects
if (key == 'regions') return p.regions
if (key == 'materials') return p.materials
if (key == 'valid') return p.valid
if (key == 'source') return p.source + '\n引用:' + p.sourceRef + '\n核对日期:' + p.checkedOn
return '未提供'
}
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-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; }
.table { flex-direction: row; border: 1rpx solid #dfe8f3; border-radius: 14rpx; overflow: hidden; background-color: #fff; }
.rail { width: 152rpx; flex-shrink: 0; background-color: #eef4fb; }
.rail-head { height: 350rpx; padding: 26rpx 16rpx; border-bottom: 1rpx solid #dfe8f3; }
.rail-title { color: #244566; font-size: 24rpx; font-weight: 700; }
.label-cell { padding: 24rpx 16rpx; border-bottom: 1rpx solid #dfe8f3; }
.row-label { font-size: 23rpx; line-height: 36rpx; color: #48627e; }
.horizontal { flex: 1; width: 0; }
.columns { flex-direction: row; }
/* #ifndef APP */
.columns { min-width: 100%; }
/* #endif */
.column { width: 350rpx; flex-grow: 1; flex-shrink: 0; border-left: 1rpx solid #e7edf5; }
.product-head { height: 350rpx; border-bottom: 1rpx solid #dfe8f3; background-color: #fafdff; }
.head-content { padding: 22rpx 20rpx; }
.product-name { font-size: 26rpx; line-height: 37rpx; color: #1d3d60; font-weight: 700; }
.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 { color: #0868f7; font-size: 23rpx; padding-top: 16rpx; }
.remove { color: #77889e; font-size: 22rpx; padding-top: 16rpx; }
.retry { font-size: 23rpx; color: #0868f7; background-color: #eaf3ff; margin-top: 12rpx; }
.cell { padding: 20rpx; border-bottom: 1rpx solid #e7edf5; }
.cell-scroll { flex: 1; height: 0; }
.cell-text { font-size: 24rpx; line-height: 39rpx; color: #455f7c; }
.amount { color: #154779; font-weight: 700; font-size: 27rpx; }
.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>