feat(融资): 新增产品对比页面及相关接口实现
新增产品对比页面 compare/compare,支持同一评估下最多3个产品的横向对比,展示额度、期限、利率、适用条件等核心指标及匹配详情。 新增 matches.uts 服务层,封装匹配列表、详情、公共产品查询接口。 新增 account 分包的 latest-report.uts 服务,用于查找用户最新成功报告。 调整个人中心页面,将“评估记录”改为“开始评估”,新增最近报告直达功能。 更新文档,标记匹配相关接口及原型07、09页面已实现,调整页面数量至26页。 移除 finance 分包中旧的 fetchMatches、fetchProduct 模拟数据接口,统一使用正式API。
This commit is contained in:
@@ -1,90 +1,155 @@
|
||||
<template>
|
||||
<view class="page" :style="pageStyle()">
|
||||
<AppHeader title="匹配融资方案" />
|
||||
<view class="filter-bar"><view v-for="item in filters" :key="item.id" :class="filter == item.id ? 'filter active' : 'filter'" @click="filter = item.id"><text :class="filter == item.id ? 'filter-label selected' : 'filter-label'">{{ item.label }}</text></view></view>
|
||||
<scroll-view scroll-y :style="contentStyle(314, false)">
|
||||
<view class="match-content">
|
||||
<view v-if="loading" class="state-panel"><text class="state-text">正在匹配融资方案…</text></view>
|
||||
<view v-else-if="errorMessage.length > 0" class="state-panel"><text class="state-text">{{ errorMessage }}</text><button class="retry-button" @click="loadMatches">重新加载</button></view>
|
||||
<view v-else-if="matches.length == 0" class="state-panel"><text class="state-text">暂无可展示方案,请完善评估资料后重试</text></view>
|
||||
<view v-else>
|
||||
<text class="result-summary">共 {{ matches.length }} 个方案 · 了解额度与申请条件</text>
|
||||
<view class="match-card" v-for="item in sortedMatches" :key="item.id" @click="openProduct(item.id)">
|
||||
<view class="bank-row"><view class="bank-logo" :style="'background-color:' + item.color"><text class="bank-initial">{{ item.initials }}</text></view><view class="bank-copy"><text class="bank-name">{{ item.bank }}</text><text class="match-tag">个人信用融资</text></view><text class="match-score">{{ item.score }}%匹配</text></view>
|
||||
<view class="metrics"><view class="metric"><text class="metric-value">{{ item.amount }}<text class="metric-unit"> 万元</text></text><text class="metric-label">最高额度</text></view><view class="metric"><text class="metric-value rate-value">{{ item.rate.toFixed(2) }}%</text><text class="metric-label">年化利率(起)</text></view><view class="metric term-metric"><text class="term-value">{{ item.term }}</text><text class="metric-label">贷款期限</text></view></view>
|
||||
<view class="card-footer"><text class="footer-hint">{{ item.advantages.join(' · ') }}</text><view class="detail-link"><text class="detail-label">查看详情</text><AppIcon name="chevron-right" :size="26" /></view></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="list-tip">展示参数仅供参考,实际额度、利率及条件以机构审批为准。银行以文字简称标识。</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<BottomNav active="" />
|
||||
</view>
|
||||
<view class="page" :style="pageStyle()">
|
||||
<AppHeader title="本次融资匹配" :customBack="true" @back="back" />
|
||||
<scroll-view scroll-y :style="contentStyle(96, false)">
|
||||
<view v-if="pageActive" class="content">
|
||||
<text class="eyebrow">评估记录 {{ assessmentId.length > 0 ? '#' + assessmentId : '待选择' }}</text>
|
||||
<text class="headline">找到适合本次需求的方向</text>
|
||||
<text class="intro">每项结论都有依据。自填资料尚未核验,匹配结果不代表审批。</text>
|
||||
<view v-if="assessmentId.length == 0 && error.length == 0" class="state-panel"><text class="state-title">先选择一份评估</text><text class="muted">从融资档案查看已完成的评估,再获取对应匹配结果。</text><button class="primary" @click="history">选择历史评估</button><button class="secondary" @click="start">开始新评估</button></view>
|
||||
<template v-else>
|
||||
<view class="tabs">
|
||||
<view v-for="item in groups" :key="item.id" :class="group == item.id ? 'tab chosen' : 'tab'" @click="selectGroup(item.id)"><text :class="group == item.id ? 'tab-label selected' : 'tab-label'">{{ item.label }}</text><text class="count">{{ count(item.id) }}</text></view>
|
||||
</view>
|
||||
<text v-if="message.length > 0" class="muted notice">{{ message }}</text>
|
||||
<view v-if="error.length == 0 && assessmentId.length > 0" class="compare-bar"><text class="compare-caption">已选 {{ selectedIds.length }} / 3 项,可跨分组选择</text><button class="compare-button" :disabled="selectedIds.length < 2" @click="openCompare">对比所选产品</button></view>
|
||||
<text v-if="loading && matches.length == 0" class="state-copy">正在读取本次匹配结果…</text>
|
||||
<view v-if="error.length > 0" class="state-panel"><text class="state-title">暂时无法读取</text><text class="muted">{{ error }}</text><button class="secondary" @click="retry">重新加载</button><text class="link" @click="history">返回融资档案</text></view>
|
||||
<view v-if="!loading && error.length == 0 && matches.length == 0" class="state-panel"><text class="state-title">{{ status == 'unavailable' ? '本次未执行匹配' : (group == 'eligible' ? '暂无符合已知条件的产品' : (group == 'ineligible' ? '暂无条件不符的产品' : '暂无待核实的产品')) }}</text><text class="muted">{{ status == 'unavailable' ? '历史结果不会自动补算。可从融资档案发起新的评估。' : '可切换分组查看其他结果,或返回报告了解资料准备情况。' }}</text><text class="link" @click="report">查看本次报告</text></view>
|
||||
<view v-for="item in matches" :key="item.id" class="card" @click="openProduct(item.id)">
|
||||
<view class="card-head"><view class="mark"><text class="mark-text">融</text></view><view class="identity"><text class="name">{{ item.product.name }}</text><text class="bank">{{ item.product.bank }} · {{ item.product.objects }}</text></view></view>
|
||||
<view class="badge-row"><text :class="item.state == 'eligible' ? 'badge good' : (item.state == 'ineligible' ? 'badge blocked' : 'badge pending')">{{ item.stateLabel }}</text><text v-if="item.product.demo" class="demo">演示产品</text></view>
|
||||
<view class="amount-row"><text class="amount">{{ item.product.amount }}</text><text class="caption">产品额度范围</text></view>
|
||||
<view class="metrics"><view class="metric"><text class="value">{{ item.product.rate }}</text><text class="caption">年化利率范围</text></view><view class="metric"><text class="value">{{ item.product.term }}</text><text class="caption">期限范围</text></view></view>
|
||||
<view class="reasons"><text class="reason" v-for="reason in preview(item)" :key="reason.id">{{ reason.text }}</text></view>
|
||||
<text v-if="item.availability != 'available'" class="availability">{{ item.availabilityLabel }};历史结果仍可查看。</text>
|
||||
<view class="card-foot"><text class="version">评估时版本 v{{ item.product.version }}</text><text class="detail-link">查看依据与详情 ›</text></view>
|
||||
<button class="choose-button" @click.stop="toggleCompare(item.id)">{{ selectedIds.includes(item.id) ? '已加入对比 · 点击移除' : '加入对比' }}</button>
|
||||
</view>
|
||||
<button v-if="hasMore" class="secondary" :disabled="loading" @click="loadMore">{{ loading ? '正在加载…' : '查看更多' }}</button>
|
||||
<text v-if="matches.length > 0 && !hasMore" class="end-note">已显示本组全部结果 · 顺序不代表产品优劣</text>
|
||||
<text class="link" @click="report">返回本次报告</text>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { FinanceProduct } from '@/packages/finance/services/finance.uts'
|
||||
import { fetchMatches } from '@/packages/finance/services/api.uts'
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import BottomNav from '@/components/BottomNav.uvue'
|
||||
import AppIcon from '@/components/AppIcon.uvue'
|
||||
const { contentStyle, pageStyle } = usePageLayout()
|
||||
type MatchFilter = { id: string, label: string }
|
||||
const filters: MatchFilter[] = [{ id: 'all', label: '综合排序' }, { id: 'amount', label: '额度优先' }, { id: 'rate', label: '低息优先' }]
|
||||
const filter = ref('all')
|
||||
const matches = ref<FinanceProduct[]>([])
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref('')
|
||||
const sortedMatches = computed((): FinanceProduct[] => {
|
||||
const items = matches.value.slice()
|
||||
items.sort((a: FinanceProduct, b: FinanceProduct): number => {
|
||||
if (filter.value == 'amount') return b.amount - a.amount
|
||||
if (filter.value == 'rate') return a.rate - b.rate
|
||||
return b.score - a.score
|
||||
})
|
||||
return items
|
||||
})
|
||||
const loadMatches = async () => {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try { matches.value = await fetchMatches() } catch (error) { errorMessage.value = '方案加载失败,请重试' }
|
||||
loading.value = false
|
||||
}
|
||||
onMounted(() => { loadMatches() })
|
||||
const openProduct = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product?id=' + id }) }
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { MatchView, MatchReason, fetchMatchPage, validRecordId } from '@/packages/finance/services/matches.uts'
|
||||
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery } from '@/packages/finance/services/compare.uts'
|
||||
const { pageStyle, contentStyle } = usePageLayout()
|
||||
type MatchGroup = { id: string, label: string }
|
||||
const groups: MatchGroup[] = [{ id: 'eligible', label: '符合条件' }, { id: 'ineligible', label: '条件不符' }, { id: 'unknown', label: '待核实' }]
|
||||
const assessmentId = ref(''); const group = ref('eligible'); const matches = ref<MatchView[]>([])
|
||||
const selectedIds = ref<string[]>([])
|
||||
const pageActive = ref(false)
|
||||
const loading = ref(false); const error = ref(''); const message = ref(''); const status = ref('')
|
||||
const eligible = ref('—'); const ineligible = ref('—'); const unknown = ref('—'); const hasMore = ref(false)
|
||||
let cursor = ''; let generation = 0; let visible = false; let routeError = ''; let owner = ''
|
||||
const count = (id: string): string => id == 'eligible' ? eligible.value : (id == 'ineligible' ? ineligible.value : unknown.value)
|
||||
const preview = (item: MatchView): MatchReason[] => item.state == 'ineligible' ? item.blocking.slice(0, 3) : (item.state == 'unknown' ? item.cautions.slice(0, 3) : item.positive.slice(0, 3))
|
||||
const clear = () => { matches.value = []; message.value = ''; status.value = ''; hasMore.value = false; cursor = ''; eligible.value = '—'; ineligible.value = '—'; unknown.value = '—' }
|
||||
const load = async (append: boolean) => {
|
||||
const request = ++generation
|
||||
if (!append) clear()
|
||||
error.value = ''
|
||||
if (routeError.length > 0) { error.value = routeError; loading.value = false; return }
|
||||
if (assessmentId.value.length == 0) { loading.value = false; return }
|
||||
const session = getSession()
|
||||
if (session == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
|
||||
const token = session.getString('token') ?? ''; owner = token; loading.value = true
|
||||
try {
|
||||
const result = await fetchMatchPage(assessmentId.value, group.value, append ? cursor : '')
|
||||
if (!visible || request != generation || getSession()?.getString('token') != token) return
|
||||
matches.value = append ? matches.value.concat(result.items) : result.items
|
||||
message.value = result.message; status.value = result.status; eligible.value = result.eligible; ineligible.value = result.ineligible; unknown.value = result.unknown
|
||||
cursor = result.cursor; hasMore.value = result.hasMore
|
||||
} catch (err) { if (visible && request == generation) error.value = err instanceof Error ? err.message : '匹配结果读取失败' }
|
||||
finally { if (request == generation) loading.value = false }
|
||||
}
|
||||
const selectGroup = (id: string) => { if (id == group.value) return; group.value = id; load(false) }
|
||||
const retry = () => { load(false) }
|
||||
const loadMore = () => { if (!loading.value && hasMore.value) load(true) }
|
||||
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
|
||||
const start = () => { uni.navigateTo({ url: '/packages/finance/entry/entry' }) }
|
||||
const report = () => { if (validRecordId(assessmentId.value)) uni.redirectTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value }); else history() }
|
||||
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else if (assessmentId.value.length > 0) report(); else history() }
|
||||
const toggleCompare = (id: string) => {
|
||||
if (selectedIds.value.includes(id)) selectedIds.value = selectedIds.value.filter((value: string): boolean => value != id)
|
||||
else {
|
||||
if (selectedIds.value.length >= 3) { uni.showToast({ title: '最多选择3个产品,请先移除一项', icon: 'none' }); return }
|
||||
selectedIds.value.push(id)
|
||||
}
|
||||
saveCompareSelection(assessmentId.value, selectedIds.value)
|
||||
}
|
||||
const openCompare = () => { if (selectedIds.value.length >= 2) uni.navigateTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value) }) }
|
||||
const openProduct = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product' + compareQuery(assessmentId.value, selectedIds.value) + '&productId=' + id + '&tab=explanation&from=matches' }) }
|
||||
onLoad((options) => {
|
||||
assessmentId.value = options?.['assessmentId'] ?? ''
|
||||
const selected = options?.['group'] ?? 'eligible'
|
||||
if (['eligible','ineligible','unknown'].includes(selected)) group.value = selected
|
||||
else routeError = '匹配分组参数无效,请从报告重新进入。'
|
||||
if (assessmentId.value.length > 0 && !validRecordId(assessmentId.value)) routeError = '评估编号无效,请从融资档案重新选择。'
|
||||
if (options?.['productIds'] != null) {
|
||||
try { saveCompareSelection(assessmentId.value, parseCompareIds(options!['productIds']!)) }
|
||||
catch (err) { routeError = err instanceof Error ? err.message : '对比参数无效' }
|
||||
}
|
||||
})
|
||||
onShow(() => { visible = true; pageActive.value = true; selectedIds.value = compareSelection(assessmentId.value); if (getSession()?.getString('token') != owner) clear(); load(false) })
|
||||
// 先关闭内容渲染再清空资料,避免卸载途中因空数组重新挂载状态节点。
|
||||
onHide(() => { visible = false; pageActive.value = false; generation++; clear(); loading.value = false; error.value = '' })
|
||||
onUnload(() => { visible = false; pageActive.value = false; generation++; clear() })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.filter-bar { height: 88rpx; background-color: #fff; flex-direction: row; padding: 0 28rpx; border-bottom: 1rpx solid #e9eef6; }
|
||||
.filter { flex: 1; align-items: center; justify-content: center; }
|
||||
.active { border-bottom: 4rpx solid #0868f7; }
|
||||
.filter-label { color: #718098; font-size: 25rpx; }
|
||||
.selected { color: #0868f7; font-weight: 700; }
|
||||
.match-content { padding: 24rpx 28rpx 90rpx; }
|
||||
.result-summary { color: #6f7f96; font-size: 22rpx; margin-bottom: 20rpx; }
|
||||
.match-card { border-radius: 18rpx; margin-bottom: 20rpx; background-color: #fff; border: 1rpx solid #e4ebf5; padding: 24rpx; }
|
||||
.bank-row { flex-direction: row; align-items: center; }
|
||||
.bank-logo { width: 64rpx; height: 64rpx; border-radius: 20rpx; align-items: center; justify-content: center; margin-right: 16rpx; flex-shrink: 0; }
|
||||
.bank-initial { color: #fff; font-size: 30rpx; font-weight: 700; }
|
||||
.bank-copy { flex: 1; }
|
||||
.bank-name { font-size: 27rpx; font-weight: 700; color: #20324d; }
|
||||
.match-tag { font-size: 21rpx; color: #7a899f; margin-top: 6rpx; }
|
||||
.match-score { color: #178461; font-size: 20rpx; }
|
||||
.metrics { flex-direction: row; margin-top: 26rpx; margin-bottom: 22rpx; }
|
||||
.metric { flex: 1; }
|
||||
.term-metric { align-items: flex-end; }
|
||||
.metric-value { color: #20324d; font-size: 35rpx; font-weight: 800; line-height: 44rpx; }
|
||||
.metric-unit { font-size: 23rpx; }
|
||||
.rate-value { color: #0868f7; }
|
||||
.term-value { color: #20324d; font-size: 25rpx; font-weight: 600; line-height: 44rpx; }
|
||||
.metric-label { margin-top: 7rpx; font-size: 21rpx; color: #7c8aa0; }
|
||||
.card-footer { border-top: 1rpx solid #edf1f6; padding-top: 18rpx; flex-direction: row; align-items: center; justify-content: space-between; }
|
||||
.footer-hint { flex: 1; margin-right: 16rpx; font-size: 20rpx; line-height: 32rpx; color: #78889f; }
|
||||
.detail-link { flex-direction: row; align-items: center; flex-shrink: 0; }
|
||||
.detail-label { font-size: 24rpx; color: #0868f7; font-weight: 600; margin-right: 6rpx; }
|
||||
.list-tip { color: #7a899e; font-size: 21rpx; line-height: 34rpx; margin: 8rpx 8rpx 0; }
|
||||
.state-panel { padding: 70rpx 20rpx; align-items: center; }
|
||||
.state-text { font-size: 26rpx; line-height: 42rpx; color: #728197; text-align: center; }
|
||||
.retry-button { margin-top: 20rpx; color: #0868f7; background-color: #e8f2ff; font-size: 24rpx; }
|
||||
.compare-bar { background-color: #fff; border-radius: 16rpx; padding: 20rpx; margin-bottom: 22rpx; }
|
||||
.compare-caption { color: #506c8b; font-size: 24rpx; }
|
||||
.compare-button { background-color: #0868f7; color: #fff; font-size: 25rpx; margin-top: 14rpx; border-radius: 12rpx; }
|
||||
.choose-button { color: #0868f7; background-color: #eef5ff; font-size: 24rpx; margin-top: 22rpx; border-radius: 12rpx; }
|
||||
.content { padding: 30rpx 28rpx 60rpx; }
|
||||
.eyebrow { font-size: 22rpx; color: #61758f; }
|
||||
.headline { font-size: 37rpx; font-weight: 700; color: #142d4e; margin-top: 14rpx; }
|
||||
.intro { font-size: 24rpx; line-height: 38rpx; color: #718198; margin-top: 14rpx; }
|
||||
.tabs { flex-direction: row; background-color: #fff; border-radius: 16rpx; padding: 8rpx; margin-top: 28rpx; }
|
||||
.tab { flex: 1; align-items: center; padding: 17rpx 2rpx; border-radius: 12rpx; }
|
||||
.chosen { background-color: #eaf3ff; }
|
||||
.tab-label { font-size: 24rpx; color: #6e7e93; }
|
||||
.selected { color: #0868f7; font-weight: 700; }
|
||||
.count { font-size: 31rpx; color: #223d60; margin-top: 8rpx; font-weight: 700; }
|
||||
.muted { font-size: 24rpx; line-height: 39rpx; color: #758399; }
|
||||
.notice { margin: 20rpx 4rpx; }
|
||||
.card { padding: 28rpx; background-color: #fff; border: 1rpx solid #e5edf7; border-radius: 20rpx; margin-bottom: 22rpx; }
|
||||
.card-head { flex-direction: row; align-items: center; }
|
||||
.mark { width: 70rpx; height: 70rpx; background-color: #eaf3ff; border-radius: 16rpx; align-items: center; justify-content: center; margin-right: 18rpx; flex-shrink: 0; }
|
||||
.mark-text { font-size: 32rpx; color: #0868f7; font-weight: 700; }
|
||||
.identity { flex: 1; }
|
||||
.name { font-size: 29rpx; line-height: 42rpx; color: #203652; font-weight: 700; }
|
||||
.bank { font-size: 22rpx; color: #7a899d; line-height: 34rpx; margin-top: 6rpx; }
|
||||
.badge-row { flex-direction: row; align-items: center; margin-top: 20rpx; }
|
||||
.badge { padding: 8rpx 16rpx; border-radius: 8rpx; font-size: 22rpx; }
|
||||
.good { color: #187857; background-color: #eaf7f1; }
|
||||
.blocked { color: #ae593d; background-color: #fff0e9; }
|
||||
.pending { color: #9a7320; background-color: #fff8e5; }
|
||||
.demo { font-size: 21rpx; margin-left: 14rpx; color: #78879b; }
|
||||
.amount-row { margin-top: 26rpx; }
|
||||
.amount { font-size: 38rpx; font-weight: 700; color: #123a6c; line-height: 54rpx; }
|
||||
.caption { font-size: 22rpx; color: #8390a2; margin-top: 6rpx; }
|
||||
.metrics { flex-direction: row; margin-top: 20rpx; }
|
||||
.metric { flex: 1; }
|
||||
.value { font-size: 25rpx; color: #344c6a; line-height: 38rpx; }
|
||||
.reasons { margin-top: 22rpx; border-top: 1rpx solid #edf2f8; padding-top: 12rpx; }
|
||||
.reason { font-size: 23rpx; line-height: 37rpx; color: #60758e; margin-top: 8rpx; }
|
||||
.availability { font-size: 23rpx; line-height: 37rpx; color: #a87534; margin-top: 12rpx; }
|
||||
.card-foot { flex-direction: row; justify-content: space-between; margin-top: 24rpx; }
|
||||
.version { font-size: 21rpx; color: #98a3b2; }
|
||||
.detail-link { font-size: 23rpx; color: #0868f7; }
|
||||
.state-panel { padding: 42rpx 28rpx; background-color: #fff; border-radius: 18rpx; margin-top: 24rpx; }
|
||||
.state-title { font-size: 30rpx; font-weight: 700; color: #273f5f; margin-bottom: 16rpx; }
|
||||
.state-copy { padding: 40rpx 12rpx; font-size: 26rpx; color: #73839a; }
|
||||
.primary { background-color: #0868f7; color: #fff; font-size: 26rpx; margin-top: 28rpx; border-radius: 12rpx; }
|
||||
.secondary { background-color: #eaf3ff; color: #0868f7; font-size: 26rpx; margin-top: 22rpx; border-radius: 12rpx; }
|
||||
.link { color: #0868f7; font-size: 25rpx; padding: 24rpx 0; }
|
||||
.end-note { text-align: center; font-size: 22rpx; color: #8795a9; padding: 18rpx 0; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user