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

193 lines
15 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>
<text v-if="ids.length > 0" class="scroll-hint">左右滑动查看各产品 · 长内容可在单元格内上下滑动</text>
<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>
<view v-else 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 v-if="column.data!.item.product.demo" class="demo">演示产品 · 非真实申请</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>
<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 assessmentId = ref(''); const ids = ref<string[]>([]); const columns = ref<CompareColumn[]>([]); const routeError = ref('')
let visible = false; let generation = 0
const rows: CompareRow[] = [
{ id: 'availability', label: '当前状态', height: 160 }, { id: 'coverage', label: '需求覆盖依据', height: 360 },
{ 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 cell = (column: CompareColumn, key: string): string => {
if (!usable(column)) return column.loading ? '读取中…' : '本列未就绪'
const m = column.data!.item; const p = m.product
if (key == 'availability') return m.availabilityLabel + ';不改变历史结论'
if (key == 'coverage') {
const demand = m.positive.concat(m.blocking).concat(m.cautions).filter((r: MatchReason): boolean => r.fields.some((f: string): boolean => ['requestedAmountYuan','requestedTermMonths','purposeCode'].includes(f)))
return compareReasonText(demand, '未提供需求覆盖依据,请查看完整匹配详情。')
}
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) => {
const column = columns.value.find((c: CompareColumn): boolean => c.id == id)
if (column == null || column.loading) return
const session = getSession()
if (session == null) { uni.redirectTo({ url: '/packages/account/login/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)
if (visible && request == generation && getSession()?.getString('token') == token) column.data = result
} catch (err) {
if (!visible || request != generation) return
if (getSession()?.getString('token') != token) {
generation++; columns.value = []
if (getSession() == null) uni.redirectTo({ url: '/packages/account/login/login' })
else routeError.value = '账户已切换,请返回本次匹配重新读取。'
return
}
column.error = err instanceof Error ? err.message : '本列读取失败,请重试。'
}
finally { if (request == generation) column.loading = false }
}
const toMatches = () => {
if (!validRecordId(assessmentId.value)) { uni.redirectTo({ url: '/packages/finance/archive/archive' }); return }
uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, ids.value) })
}
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else toMatches() }
const consult = () => { uni.navigateTo({ url: assistantRoute(assessmentId.value, ids.value, 'compare') }) }
const remove = (id: string) => {
const next = ids.value.filter((value: string): boolean => value != id)
saveCompareSelection(assessmentId.value, next)
// 用新路由替换当前页,使刷新/分享恢复实际选择,而不是已移除的旧ID。
uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, next) })
}
const detail = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product' + compareQuery(assessmentId.value, ids.value) + '&productId=' + id + '&tab=explanation&from=compare' }) }
onLoad((options) => {
assessmentId.value = options?.['assessmentId'] ?? ''
if (!validRecordId(assessmentId.value)) { routeError.value = '评估编号无效,请从融资档案重新选择。'; return }
try { ids.value = parseCompareIds(options?.['productIds'] ?? ''); saveCompareSelection(assessmentId.value, ids.value) }
catch (err) { routeError.value = err instanceof Error ? err.message : '对比参数无效' }
})
onShow(() => {
visible = true; generation++
if (routeError.value.length > 0) return
if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
// 依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; generation++; columns.value = [] })
onUnload(() => { visible = false; generation++; columns.value = [] })
</script>
<style>
.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>