Files
airz-uniapp/packages/finance/compare/compare.uvue
T
hujun 542c107317 feat: 优化AI助手及报告对比界面,完成金额精度专项验证
- 调整环境配置切换至测试服
- 实现AI助手上下文折叠展开功能,优化界面布局
- 新增报告对比首屏短参数展示,简化信息呈现
- 调整报告页布局,优化导出组件为紧凑模式
- 完成原型08金额精度三轮真实专项验证,新增金额守卫逻辑
- 更新文档记录部署、数据库切换及验收进展
2026-09-29 17:27:25 +08:00

239 lines
20 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)"><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">{{ hasDemo ? '含演示产品,仅供功能验证。' : '' }}展示顺序不代表优劣。</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 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>
</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 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
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 hasDemo = computed((): boolean => columns.value.some((c: CompareColumn): boolean => usable(c) && c.data!.item.product.demo))
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 == '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>
.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>