Files
airz-uniapp/packages/finance/matches/matches.uvue
T
hujun 71b3c25695 feat: 优化账户切换时的页面状态一致性,增加登录回跳校验和课程阅读新逻辑
- 增加账户状态监听定时器,页面切换时自动校验账户归属,避免跨账户操作
- 新增登录回跳路径校验函数,限制仅允许指定页面回跳,防止非法跳转
- 课程页改为整篇连续阅读,底部仅一次标记已读,兼容历史记录
- 完善助手、对比页、资料填写页等多处的账户状态校验逻辑,防止错版数据提交
- 优化账户资料请求逻辑,增加版本校验,避免旧请求覆盖新数据
2026-10-07 10:01:05 +08:00

182 lines
18 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="pageError.length > 0" class="state-panel"><text class="muted">{{ pageError }}</text><button class="secondary" :disabled="loading" @click="loadMore">重试加载更多</button></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 && pageError.length == 0" 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, MatchPage, fetchMatchPage, validRecordId } from '@/packages/finance/services/matches.uts'
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery, matchReturnPath } 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 pageError = 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 = ''
let firstPage: MatchPage | null = null; let accountTimer = 0
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 = ''; pageError.value = ''; hasMore.value = false; cursor = ''; firstPage = null; eligible.value = '—'; ineligible.value = '—'; unknown.value = '—' }
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeMatch=1&matchPage=matches' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value).replace('?', '&') + '&group=' + group.value }) }
const current = (request: number, token: string): boolean => {
if (!visible || request != generation) return false
if (getSession()?.getString('token') == token) return true
generation++; clear(); loading.value = false; stopAccountTimer()
if (getSession() == null) login(); else error.value = '账户已切换,请重新加载本人匹配。'
selectedIds.value = []
return false
}
const updateUrl = () => {
// #ifdef WEB
window.history.replaceState(window.history.state, '', location.pathname + location.search + '#' + matchReturnPath('matches', assessmentId.value, selectedIds.value.join(','), revisionId.value, '', group.value))
// #endif
}
const load = async (append: boolean) => {
const request = ++generation
if (!append) { stopAccountTimer(); clear() }
error.value = ''; pageError.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) { loading.value = false; login(); return }
const token = session.getString('token') ?? ''; if (owner.length > 0 && owner != token) selectedIds.value = []; owner = token; loading.value = true
if (accountTimer == 0) accountTimer = setInterval(() => { current(generation, owner) }, 1000)
try {
const result = await fetchMatchPage(assessmentId.value, group.value, append ? cursor : '', revisionId.value)
if (!current(request, token)) return
// 分页只追加同一冻结结果,失败时保留已读行与原游标供局部重试。
if (append && (firstPage == null || firstPage!.assessmentId != result.assessmentId || firstPage!.revisionId != result.revisionId || firstPage!.resultVersion != result.resultVersion || firstPage!.snapshotId != result.snapshotId || firstPage!.ruleVersion != result.ruleVersion || firstPage!.orderingVersion != result.orderingVersion || firstPage!.status != result.status || firstPage!.eligible != result.eligible || firstPage!.ineligible != result.ineligible || firstPage!.unknown != result.unknown || result.items.some((item: MatchView): boolean => matches.value.some((old: MatchView): boolean => old.id == item.id)) || (result.hasMore && result.cursor == cursor))) throw new Error('分页与已读取的冻结结果不一致,请重试或重新加载。')
revisionId.value = result.revisionId
if (!append) firstPage = result
saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value); updateUrl()
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 (current(request, token)) { const message = err instanceof Error ? err.message : '匹配结果读取失败'; if (append) pageError.value = message; else error.value = message } }
finally { if (request == generation) loading.value = false }
}
const selectGroup = (id: string) => { if (!current(generation, owner) || id == group.value) return; group.value = id; load(false) }
const retry = () => { load(false) }
const loadMore = () => { if (current(generation, owner) && !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 (owner.length > 0 && !current(generation, owner)) return; 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 (!current(generation, owner) || !matches.value.some((item: MatchView): boolean => item.id == id)) return
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); updateUrl()
}
const openCompare = () => { if (current(generation, owner) && selectedIds.value.length >= 2) uni.navigateTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }) }
const openProduct = (id: string) => { if (current(generation, owner) && matches.value.some((item: MatchView): boolean => item.id == id)) 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; stopAccountTimer(); generation++; clear(); loading.value = false; error.value = '' })
onUnload(() => { visible = false; pageActive.value = false; stopAccountTimer(); 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>