Files
airz-uniapp/packages/finance/matches/matches.uvue
T
hujun 3b2a0139e4 feat: 支持评估版本隔离的报告、匹配及PDF导出功能
新增revisionId参数到路由、服务及导出接口,实现不同评估版本的隔离访问。删除已废弃的需求相关服务文件,移除取消采集的融资金额、期限和用途字段,更新相关页面提示文本。同时更新文档,说明评估版本、最新资料及产品展示规则的调整,支持前台正常业务名称显示,去除环境标签。
2026-09-30 19:11:44 +08:00

159 lines
15 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 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></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 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 revisionId = ref(''); 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 : '', revisionId.value)
if (!visible || request != generation || getSession()?.getString('token') != token) return
revisionId.value = result.revisionId
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 + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.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, revisionId.value)
}
const openCompare = () => { if (selectedIds.value.length >= 2) uni.navigateTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }) }
const openProduct = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) + '&productId=' + id + '&tab=explanation&from=matches' }) }
onLoad((options) => {
revisionId.value = options?.['revisionId'] ?? '';
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError = '版本编号无效,请返回重新选择。'
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']!), revisionId.value) }
catch (err) { routeError = err instanceof Error ? err.message : '对比参数无效' }
}
})
onShow(() => { visible = true; pageActive.value = true; selectedIds.value = compareSelection(assessmentId.value, revisionId.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>
.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>