feat(融资): 新增产品对比页面及相关接口实现

新增产品对比页面 compare/compare,支持同一评估下最多3个产品的横向对比,展示额度、期限、利率、适用条件等核心指标及匹配详情。
新增 matches.uts 服务层,封装匹配列表、详情、公共产品查询接口。
新增 account 分包的 latest-report.uts 服务,用于查找用户最新成功报告。
调整个人中心页面,将“评估记录”改为“开始评估”,新增最近报告直达功能。
更新文档,标记匹配相关接口及原型07、09页面已实现,调整页面数量至26页。
移除 finance 分包中旧的 fetchMatches、fetchProduct 模拟数据接口,统一使用正式API。
This commit is contained in:
hujun
2026-09-28 14:56:46 +08:00
parent 26da91b337
commit 7c252fc730
20 changed files with 1605 additions and 403 deletions
+44 -3
View File
@@ -4,7 +4,7 @@
<scroll-view scroll-y :style="contentStyle(226, false)">
<view class="profile-content">
<view class="profile-card" @click="openAccount"><view class="avatar"><image class="profile-avatar" :src="avatar.length > 0 ? avatar : DEFAULT_AVATAR" mode="aspectFill" @error="avatar = ''" /></view><view class="profile-main"><text class="profile-name">{{ displayName }}</text><text class="profile-phone">{{ loggedIn ? maskedPhone : '登录后管理您的融资信息' }}</text></view><text v-if="!loggedIn" class="account-state">未登录</text><AppIcon v-if="loggedIn" name="chevron-right" :size="28" /></view>
<view class="stat-row"><view class="stat-item" @click="go('/packages/finance/archive/archive')"><text class="stat-value">查看</text><text class="stat-label">评估记录</text></view><view class="stat-item" @click="openReport"><text class="stat-value">查看</text><text class="stat-label">评估报告</text></view><view class="stat-item" @click="go('/packages/account/points/points')"><AppIcon name="points" :size="42" /><text class="stat-label">积分中心</text></view></view>
<view class="stat-row"><view class="stat-item" @click="startAssessment"><AppIcon name="edit-file" :size="42" /><text class="stat-label">开始评估</text></view><view class="stat-item" @click="openReport"><AppIcon name="file-text" :size="42" /><text class="stat-label">{{ reportLoading ? '正在查找…' : '最近报告' }}</text></view><view class="stat-item" @click="go('/packages/account/points/points')"><AppIcon name="points" :size="42" /><text class="stat-label">积分中心</text></view></view>
<view class="menu-list"><view class="menu-row" v-for="item in menus" :key="item.name" @click="open(item.key)"><AppIcon :name="item.icon" :size="46" /><text class="menu-name">{{ item.name }}</text><text v-if="item.note.length > 0" class="menu-note">{{ item.note }}</text><AppIcon name="chevron-right" :size="28" /></view></view>
<button class="logout" @click="logout">{{ loggedIn ? '退出登录' : '登录 / 注册' }}</button>
<text class="version-note">AI融资 · 让融资更简单</text>
@@ -23,6 +23,7 @@
import AppIcon from '@/components/AppIcon.uvue'
import { getSession } from '@/services/api.uts'
import { logoutAccount } from '@/packages/account/services/auth.uts'
import { findLatestReportEntry } from '@/packages/account/services/latest-report.uts'
import { showAgreementNotice } from '@/services/assessment.uts'
import { DEFAULT_AVATAR, getAccountProfile, fetchAccountProfile, maskPhone } from '@/packages/account/services/account.uts'
const { contentStyle, pageStyle } = usePageLayout()
@@ -32,7 +33,10 @@
const displayName = ref('登录 / 注册')
const maskedPhone = ref('')
const avatar = ref('')
const reportLoading = ref(false)
let profileVisible = false; let reportRequest = 0
onShow(() => {
profileVisible = true
const session = getSession()
loggedIn.value = session != null
const profile = getAccountProfile()
@@ -43,7 +47,45 @@
}).catch(() => { /* 读取失败保留已归属当前账户的缓存,不显示其他账户资料。 */ })
})
const go = (url: string) => { uni.navigateTo({ url }) }
const openReport = () => { go('/packages/finance/archive/archive') }
const startAssessment = () => {
const session = getSession()
if (session == null) { go('/packages/account/login/login'); return }
const token = session.getString('token') ?? ''
uni.showActionSheet({ itemList: ['个人评估', '企业评估'], success: (result) => {
if (profileVisible && getSession()?.getString('token') == token) go('/packages/finance/entry/entry?type=' + (result.tapIndex == 0 ? 'personal' : 'enterprise'))
} })
}
const openReport = async () => {
if (reportLoading.value) return
const session = getSession()
if (session == null) { go('/packages/account/login/login'); return }
const token = session.getString('token') ?? ''; const request = ++reportRequest
const current = (): boolean => profileVisible && request == reportRequest && getSession()?.getString('token') == token
reportLoading.value = true
try {
const entry = await findLatestReportEntry()
if (!current()) return
if (entry != null && entry.status == 'succeeded') {
go('/packages/finance/report/report?assessmentId=' + entry.id)
} else if (entry != null) {
const id = entry.id
uni.showModal({ title: '报告生成中', content: '您有一份评估正在排队或分析,可查看当前进度。', confirmText: '查看进度', cancelText: '暂不查看', success: (result) => {
if (result.confirm && current()) go('/packages/finance/analyzing/analyzing?assessmentId=' + id)
} })
} else {
uni.showModal({ title: '暂无报告', content: '暂无已完成的评估报告。您可以开始评估,已提交的记录可在“我的融资档案”查看。', confirmText: '开始评估', cancelText: '暂不评估', success: (result) => {
if (result.confirm && current()) startAssessment()
} })
}
} catch (err) {
if (!profileVisible || request != reportRequest) return
if (getSession() == null) { go('/packages/account/login/login'); return }
if (current()) uni.showToast({ title: err instanceof Error ? err.message : '报告查找失败,请重试', icon: 'none' })
} finally { if (request == reportRequest) reportLoading.value = false }
}
// 离页或退出后,旧查找结果及弹窗回调不得把用户带到前一个账户的评估。
onHide(() => { profileVisible = false; reportRequest++; reportLoading.value = false })
onUnload(() => { profileVisible = false; reportRequest++; reportLoading.value = false })
const open = (key: string) => {
if (key == 'archive') go('/packages/finance/archive/archive')
else if (key == 'help') go('/packages/content/help/help')
@@ -72,7 +114,6 @@
.account-state { padding: 8rpx 12rpx; border-radius: 8rpx; background-color: #eef4fd; color: #6c84a5; font-size: 22rpx; flex-shrink: 0; margin-left: 10rpx; }
.stat-row { min-height: 136rpx; background-color: #fff; border-top: 1rpx solid #edf1f6; border-radius: 0 0 18rpx 18rpx; flex-direction: row; }
.stat-item { flex: 1; align-items: center; justify-content: center; }
.stat-value { font-size: 31rpx; font-weight: 700; color: #173864; line-height: 44rpx; }
.stat-label { color: #7d8ca1; font-size: 24rpx; margin-top: 10rpx; }
.menu-list { margin-top: 24rpx; padding: 0 24rpx; border-radius: 18rpx; background-color: #fff; }
.menu-row { min-height: 106rpx; flex-direction: row; align-items: center; border-bottom: 1rpx solid #edf1f6; }
@@ -0,0 +1,20 @@
import { requestJson } from '@/services/api.uts'
export type LatestReportEntry = { id: string, status: string }
async function latestAssessment(status: string): Promise<LatestReportEntry | null> {
const result = await requestJson('/assessments', 'GET', { status, limit: '1' })
const items = result['items'] as UTSJSONObject[] | null
if (items == null) throw new Error('评估记录响应不完整')
if (items.length == 0) return null
const id = items[0].getString('assessmentId') ?? ''
if (!/^[1-9][0-9]{0,9}$/.test(id) || items[0].getString('status') != status) throw new Error('评估记录格式错误,请稍后重试')
return { id, status }
}
// 服务端先按成功状态筛选再取最新提交,避免最近一页中的处理中任务遮住更早的报告。
// 账户分包只读取共享API,不导入融资分包代码,也不缓存报告或个人资料。
export async function findLatestReportEntry(): Promise<LatestReportEntry | null> {
const report = await latestAssessment('succeeded')
return report != null ? report : await latestAssessment('processing')
}
+213 -89
View File
@@ -1,117 +1,241 @@
<template>
<view class="page" :style="pageStyle()">
<AppHeader :title="selectedId.length > 0 ? '评估记录详情' : '我的融资档案'" :customBack="selectedId.length > 0" @back="backToList" />
<scroll-view scroll-y :style="contentStyle(0, false)">
<AppHeader title="我的融资档案" />
<scroll-view scroll-y :style="contentStyle(96, false)">
<view class="archive-content">
<text class="archive-title">{{ selectedId.length > 0 ? '已提交的评估资料' : '已提交的评估记录' }}</text>
<text class="archive-note">提交后的资料不可修改;再次评估请新建记录。未提交的填写内容不会保存。</text>
<view v-if="selectedId.length == 0" class="start-actions">
<button class="primary-button start-button" @click="start('personal')">个人评估</button>
<button class="primary-button start-button" @click="start('enterprise')">企业评估</button>
<view class="archive-switch">
<text :class="type == 'personal' ? 'switch-item switch-active' : 'switch-item'" @click="switchType('personal')">个人档案</text>
<text :class="type == 'enterprise' ? 'switch-item switch-active' : 'switch-item'" @click="switchType('enterprise')">企业档案</text>
</view>
<view v-if="error.length > 0" class="notice"><text>{{ error }}</text><text class="action" @click="refresh">重试加载</text></view>
<text v-if="loading" class="archive-note">正在加载…</text>
<template v-if="selectedId.length > 0 && detail != null">
<text class="record-title">{{ detail!.record.type == 'personal' ? '个人' : '企业' }}评估 · #{{ detail!.record.id }}</text>
<text class="record-status">{{ detail!.record.statusLabel }}</text>
<text class="archive-note">{{ detail!.record.message }}</text>
<text class="record-time">提交时间:{{ detail!.record.submittedAt }}</text>
<text class="action" @click="openAnalysis">{{ detail!.record.status == 'succeeded' ? '查看分析报告' : '查看分析状态 / 重试' }}</text>
<view class="detail-row" v-for="field in detail!.fields" :key="field.label"><text class="field-label">{{ field.label }}</text><text class="field-value">{{ field.value }}</text></view>
<text class="action" @click="backToList">返回记录列表</text>
</template>
<template v-if="selectedId.length == 0">
<text v-if="!loading && error.length == 0 && records.length == 0" class="empty-state">暂无已提交的评估记录</text>
<view class="record-card" v-for="item in records" :key="item.id" @click="openRecord(item.id)">
<text class="record-title">{{ item.type == 'personal' ? '个人' : '企业' }}评估 · #{{ item.id }}</text>
<text class="record-status">{{ item.statusLabel }}</text>
<text class="record-time">{{ item.city }} · {{ item.submittedAt }}</text>
<text class="action">查看已提交资料</text>
<view v-if="detail != null || records.length > 0" class="record-picker">
<view class="record-picker-head" @click="historyOpen = !historyOpen">
<view class="record-caption"><text class="record-title">{{ detail == null ? '选择已提交的评估' : '评估记录 #' + detail!.record.id }}</text><text class="record-meta">{{ detail == null ? '按提交时间由近到远排列' : detail!.record.submittedAt + ' · ' + detail!.record.statusLabel }}</text></view>
<text class="history-link">{{ historyOpen ? '收起记录' : '切换记录' }}</text>
</view>
<text v-if="hasMore && !loading" class="action" @click="loadMore">加载更多</text>
<text v-if="records.length > 0 && !hasMore && !loading" class="archive-note">已显示全部记录</text>
</template>
<view v-if="historyOpen" class="history-list">
<text class="history-note">每次提交独立保存,请选择要查看的记录。</text>
<view v-for="item in records" :key="item.id" :class="item.id == selectedId ? 'history-row history-selected' : 'history-row'" @click="chooseRecord(item.id)">
<view class="record-caption"><text class="history-title">{{ item.submittedAt }} · #{{ item.id }}</text><text class="record-meta">{{ item.city }} · {{ item.statusLabel }}</text></view><text class="history-state">{{ item.id == selectedId ? '当前' : '查看' }}</text>
</view>
<text v-if="listError.length > 0" class="error-text">{{ listError }}</text>
<text v-if="listError.length > 0" class="text-action" @click="retryList">重新加载记录</text>
<text v-if="listLoading" class="history-note">正在读取历史记录…</text>
<text v-else-if="hasMore" class="text-action" @click="loadMore">加载更早记录</text>
<text v-else-if="listError.length == 0" class="history-note">已显示本类全部记录</text>
</view>
</view>
<view v-if="error.length > 0" class="notice"><text class="error-text">{{ error }}</text><text class="text-action" @click="refresh">重新加载</text><text v-if="selectedId.length > 0" class="text-action" @click="showLatest">选择其他历史记录</text></view>
<text v-if="loading" class="loading-text">正在读取已提交的档案…</text>
<view class="completion">
<view class="completion-head"><text class="completion-title">档案完整度</text><view class="score-wrap"><text class="completion-score">{{ completeness == null ? '—' : completeness!.toString() }}</text><text v-if="completeness != null" class="score-unit">%</text></view></view>
<view class="progress-track"><view class="progress-bar" :style="'width:' + (completeness ?? 0).toString() + '%;'"></view></view>
<text class="completion-desc">{{ completenessMessage }}</text>
<text v-if="completenessFailed" class="text-action completion-retry" @click="retryCompleteness">重新读取完整度</text>
</view>
<view v-if="!loading && error.length == 0 && detail == null" class="empty-panel"><text class="empty-title">暂无{{ type == 'personal' ? '个人' : '企业' }}评估档案</text><text class="empty-note">完成一次评估提交后,即可在这里查看资料与报告。</text></view>
<view class="archive-list">
<view v-for="section in sections" :key="section.id" class="section">
<view class="archive-row" @click="toggleSection(section.id)">
<AppIcon :name="section.icon" :size="48" />
<view class="archive-labels"><text class="archive-name">{{ section.title }}</text><text class="archive-note">{{ section.note }}</text></view>
<text :class="detail != null && section.id != 'authorization' ? 'state state-done' : 'state'">{{ sectionState(section) }}</text>
<AppIcon v-if="detail != null" name="chevron-right" :size="28" :class="expanded == section.id ? 'arrow-open' : ''" />
</view>
<view v-if="expanded == section.id && detail != null" class="section-detail">
<template v-if="section.id == 'authorization'"><text class="section-hint">私有资料上传与外部查询授权尚未开放,本次未通过这些服务上传资料或发起查询。</text></template>
<template v-else><text class="section-hint">以下为本次提交时的资料,仅供查看。</text><view v-for="field in section.fields" :key="field.label" class="detail-row"><text class="field-label">{{ field.label }}</text><text class="field-value">{{ field.value }}</text></view></template>
</view>
</view>
</view>
<text v-if="detail != null && detail!.record.message.length > 0" class="task-note">{{ detail!.record.message }}</text>
<button v-if="detail != null" class="primary-button main-action" @click="openAnalysis">{{ detail!.record.status == 'succeeded' ? '查看分析报告' : '查看分析状态 / 重试' }}</button>
<button :class="detail == null ? 'primary-button main-action' : 'new-action'" :disabled="loading" @click="start">发起新的{{ type == 'personal' ? '个人' : '企业' }}评估</button>
<text class="local-note">已提交资料只读保留;再次评估会新建记录。
未提交的填写内容不会保存。</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { clearAssessmentFlow } from '@/services/assessment-flow.uts'
import { AssessmentRecord, AssessmentDetail, fetchAssessments, fetchAssessmentDetail } from '@/packages/finance/services/records.uts'
import { AssessmentRecord, AssessmentDetail, ArchiveSection, fetchAssessments, fetchAssessmentDetail, fetchArchiveCompleteness } from '@/packages/finance/services/records.uts'
const { pageStyle, contentStyle } = usePageLayout()
const records = ref<AssessmentRecord[]>([])
const detail = ref<AssessmentDetail | null>(null)
const selectedId = ref(''); const loading = ref(false); const error = ref(''); const hasMore = ref(false)
let cursor = ''; let generation = 0; let visible = false
const sameOwner = (token: string): boolean => { const session = getSession(); return session != null && session['token'] == token }
const refresh = async () => {
const type = ref('personal'); const selectedId = ref(''); const records = ref<AssessmentRecord[]>([])
const detail = ref<AssessmentDetail | null>(null); const expanded = ref(''); const historyOpen = ref(false)
const loading = ref(false); const error = ref(''); const listError = ref(''); const listLoading = ref(false); const hasMore = ref(false)
const completeness = ref<number | null>(null); const completenessFailed = ref(false)
const completenessMessage = ref('提交评估后,查看对应报告的资料完整度。')
let cursor = ''; let generation = 0; let reportRequest = 0; let visible = false; let routeError = ''; let routeId = ''; let owner = ''
const sections = computed((): ArchiveSection[] => detail.value != null ? detail.value!.sections : [
{ id: 'basic', icon: 'file-text', title: '基本信息', note: type.value == 'enterprise' ? '企业、联系人与融资需求' : '身份、联系方式与融资需求', fields: [], provided: 0 },
{ id: 'income', icon: 'income', title: type.value == 'enterprise' ? '经营信息' : '职业与收入', note: type.value == 'enterprise' ? '经营行业、月数与营收' : '职业、收入与缴纳情况', fields: [], provided: 0 },
{ id: 'assets', icon: 'asset', title: '资产与负债', note: '贷款、信用卡额度与房车情况', fields: [], provided: 0 },
{ id: 'authorization', icon: 'authorization', title: '资料与授权', note: '私有资料与外部查询授权', fields: [], provided: 0 }
])
const sectionState = (section: ArchiveSection): string => {
if (section.id == 'authorization') return '未开放'
if (detail.value == null) return '暂无记录'
return section.provided > 0 ? '已记录' : '未填写'
}
const clearView = () => {
detail.value = null; records.value = []; cursor = ''; hasMore.value = false; expanded.value = ''
completeness.value = null; completenessFailed.value = false; reportRequest++
completenessMessage.value = '提交评估后,查看对应报告的资料完整度。'
error.value = ''; listError.value = ''; listLoading.value = false
}
const active = (request: number, token: string): boolean => {
if (!visible || request != generation) return false
const session = getSession()
if (session == null) { records.value = []; detail.value = null; uni.redirectTo({ url: '/packages/account/login/login' }); return }
const owner = session['token'] as string
const request = ++generation
loading.value = true; error.value = ''
if (session?.getString('token') == token) return true
// 包括报告/历史分页返回401的情况:立即移除已显示资料,不只忽略迟到响应。
generation++; clearView(); loading.value = false; selectedId.value = routeId
if (session == null) uni.redirectTo({ url: '/packages/account/login/login' })
else error.value = '登录状态已变化,请重新加载档案。'
return false
}
const readCompleteness = async (id: string, request: number, token: string) => {
const reportSequence = ++reportRequest
completeness.value = null; completenessFailed.value = false; completenessMessage.value = '正在读取本次报告的资料完整度…'
try {
const result = await fetchArchiveCompleteness(id)
if (active(request, token) && reportSequence == reportRequest) { completeness.value = result.percent; completenessMessage.value = result.description }
} catch (err) {
if (active(request, token) && reportSequence == reportRequest) {
completenessFailed.value = true; completenessMessage.value = '完整度暂时读取失败,已提交资料仍可查看。'
}
}
}
const readList = async (append: boolean, request: number, token: string) => {
listLoading.value = true; listError.value = ''
try {
const result = await fetchAssessments(append ? cursor : '', type.value)
if (!active(request, token)) return
const incoming = result.items.filter((item: AssessmentRecord): boolean => !append || !records.value.some((old: AssessmentRecord): boolean => old.id == item.id))
records.value = append ? records.value.concat(incoming) : incoming; hasMore.value = result.hasMore; cursor = result.nextCursor
} catch (err) {
if (active(request, token)) listError.value = err instanceof Error ? err.message : '历史记录读取失败,请重试'
} finally { if (active(request, token)) listLoading.value = false }
}
const refresh = async () => {
const request = ++generation; clearView(); loading.value = true
const session = getSession()
if (session == null) { loading.value = false; uni.redirectTo({ url: '/packages/account/login/login' }); return }
const token = session.getString('token') ?? ''; owner = token
if (routeError.length > 0) { error.value = routeError; loading.value = false; completenessMessage.value = '请先选择有效的评估记录。'; return }
try {
// 显式ID先校验本人详情;失败不能自动切到最新记录,掩盖越权或已删除状态。
if (selectedId.value.length > 0) {
const result = await fetchAssessmentDetail(selectedId.value)
if (visible && request == generation && sameOwner(owner)) detail.value = result
if (!active(request, token)) return
detail.value = result; type.value = result.record.type
await readList(false, request, token)
} else {
const result = await fetchAssessments('')
if (visible && request == generation && sameOwner(owner)) { records.value = result.items; hasMore.value = result.hasMore; cursor = result.nextCursor }
await readList(false, request, token)
if (!active(request, token)) return
if (listError.value.length > 0) throw new Error(listError.value)
if (records.value.length > 0) {
selectedId.value = records.value[0].id
const result = await fetchAssessmentDetail(selectedId.value)
if (!active(request, token)) return
detail.value = result
}
}
} catch (err) { if (visible && request == generation) error.value = err instanceof Error ? err.message : '加载失败,请重试' }
finally { if (request == generation) loading.value = false }
}
const loadMore = async () => {
if (loading.value || !hasMore.value) return
const session = getSession()
if (session == null) { refresh(); return }
const owner = session['token'] as string
const request = ++generation
loading.value = true; error.value = ''
try {
const result = await fetchAssessments(cursor)
if (visible && request == generation && sameOwner(owner)) {
const incoming = result.items.filter((item: AssessmentRecord): boolean => !records.value.some((old: AssessmentRecord): boolean => old.id == item.id))
records.value = records.value.concat(incoming); hasMore.value = result.hasMore; cursor = result.nextCursor
if (!active(request, token)) return
if (listError.value.length > 0) historyOpen.value = true
loading.value = false
if (detail.value != null) {
if (detail.value!.record.status == 'succeeded') await readCompleteness(detail.value!.record.id, request, token)
else completenessMessage.value = detail.value!.record.status == 'failed' ? '本次分析未完成,暂没有报告完整度;已提交资料已保留。' : '本次分析尚未完成,报告生成后可查看完整度。'
}
} catch (err) { if (visible && request == generation) error.value = err instanceof Error ? err.message : '加载失败,请重试' }
finally { if (request == generation) loading.value = false }
} catch (err) {
if (active(request, token)) { error.value = err instanceof Error ? err.message : '档案读取失败,请重试'; completenessMessage.value = '档案暂不可读取,请重新加载或选择其他记录。' }
} finally { if (active(request, token)) loading.value = false }
}
const start = (type: string) => { clearAssessmentFlow(); uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + type }) }
const openRecord = (id: string) => { uni.navigateTo({ url: '/packages/finance/archive/archive?assessmentId=' + encodeURIComponent(id) }) }
const retryList = () => { if (!listLoading.value) readList(false, generation, owner) }
const loadMore = () => { if (!listLoading.value && hasMore.value) readList(true, generation, owner) }
const retryCompleteness = () => { if (detail.value != null && detail.value!.record.status == 'succeeded') readCompleteness(detail.value!.record.id, generation, owner) }
const toggleSection = (id: string) => { if (detail.value != null) expanded.value = expanded.value == id ? '' : id }
// 类型与显式历史选择留在路由里,刷新重新鉴权后仍查看同一记录;不保存草稿或资料缓存。
const switchType = (next: string) => { if (next != type.value) uni.redirectTo({ url: '/packages/finance/archive/archive?type=' + next }) }
const chooseRecord = (id: string) => { if (id == selectedId.value) { historyOpen.value = false; return }; uni.redirectTo({ url: '/packages/finance/archive/archive?type=' + type.value + '&assessmentId=' + encodeURIComponent(id) }) }
const showLatest = () => { uni.redirectTo({ url: '/packages/finance/archive/archive?type=' + type.value }) }
const start = () => { clearAssessmentFlow(); uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + type.value }) }
const openAnalysis = () => {
const page = detail.value?.record.status == 'succeeded' ? 'report/report' : 'analyzing/analyzing'
uni.navigateTo({ url: '/packages/finance/' + page + '?assessmentId=' + encodeURIComponent(selectedId.value) })
if (detail.value == null) return
const page = detail.value!.record.status == 'succeeded' ? 'report/report' : 'analyzing/analyzing'
uni.navigateTo({ url: '/packages/finance/' + page + '?assessmentId=' + encodeURIComponent(detail.value!.record.id) })
}
const backToList = () => {
const pages = getCurrentPages()
if (pages.length > 1 && pages[pages.length - 2].route == 'packages/finance/archive/archive') uni.navigateBack()
else uni.redirectTo({ url: '/packages/finance/archive/archive' })
}
onBackPress((event): boolean => { if (event.from == 'navigateBack' || selectedId.value.length == 0) return false; backToList(); return true })
onLoad((options) => { if (options != null) selectedId.value = options['assessmentId'] ?? '' })
onShow(() => { visible = true; refresh() })
// 离开时清空敏感详情;迟到的请求不能把其他账户的数据写回页面。
onHide(() => { visible = false; generation++; records.value = []; detail.value = null })
onUnload(() => { visible = false; generation++ })
onLoad((options) => {
type.value = options?.['type'] ?? 'personal'; routeId = options?.['assessmentId'] ?? ''; selectedId.value = routeId
if (!['personal', 'enterprise'].includes(type.value)) { type.value = 'personal'; routeError = '档案类型无效,请重新选择。' }
if (options?.['assessmentId'] != null && !/^[1-9][0-9]{0,9}$/.test(routeId)) routeError = '评估编号无效,请重新选择历史记录。'
})
onShow(() => {
visible = true
if (getSession()?.getString('token') != owner) { selectedId.value = routeId; historyOpen.value = false }
refresh()
})
// 离开即清空敏感内容;账户切换、快速换档和迟到响应均不能恢复旧账户资料。
onHide(() => { visible = false; generation++; clearView(); loading.value = false })
onUnload(() => { visible = false; generation++; clearView() })
</script>
<style>
.archive-content { margin: 28rpx; padding: 30rpx; border-radius: 18rpx; background-color: #fff; }
.archive-title { font-size: 32rpx; font-weight: 600; color: #23344e; }
.archive-note { margin: 24rpx 0 30rpx; color: #738198; font-size: 26rpx; line-height: 42rpx; }
.start-actions { flex-direction: row; margin-bottom: 20rpx; }
.start-button { flex: 1; margin: 6rpx; font-size: 27rpx; }
.record-card { padding: 28rpx 0; border-bottom: 1rpx solid #edf1f6; }
.record-title { font-size: 30rpx; font-weight: 600; color: #23344e; }
.record-status { margin-top: 16rpx; color: #0868f7; font-size: 27rpx; }
.record-time { margin-top: 14rpx; color: #738198; font-size: 23rpx; line-height: 36rpx; }
.action { color: #0868f7; font-size: 27rpx; padding: 24rpx 0; }
.notice { color: #ad432d; font-size: 27rpx; line-height: 40rpx; }
.empty-state { padding: 50rpx 0; color: #738198; font-size: 28rpx; }
.detail-row { flex-direction: row; padding: 24rpx 0; border-bottom: 1rpx solid #edf1f6; }
.field-label { width: 210rpx; font-size: 26rpx; color: #738198; }
.field-value { flex: 1; text-align: right; font-size: 27rpx; color: #23344e; }
.archive-content { padding: 26rpx 30rpx 70rpx; }
.archive-switch { flex-direction: row; background-color: #eaf0f8; border-radius: 16rpx; padding: 6rpx; margin-bottom: 24rpx; }
.switch-item { flex: 1; text-align: center; font-size: 27rpx; color: #708099; padding: 18rpx 0; border-radius: 12rpx; }
.switch-active { background-color: #fff; color: #0868f7; font-weight: 600; }
.record-picker { background-color: #fff; border: 1rpx solid #e7edf6; border-radius: 18rpx; margin-bottom: 24rpx; }
.record-picker-head { flex-direction: row; align-items: center; padding: 24rpx; }
.record-caption { flex: 1; }
.record-title { font-size: 26rpx; font-weight: 600; color: #23344e; }
.record-meta { margin-top: 9rpx; font-size: 22rpx; line-height: 32rpx; color: #7b899c; }
.history-link { color: #0868f7; font-size: 24rpx; margin-left: 16rpx; padding: 10rpx 0; }
.history-list { padding: 0 24rpx 16rpx; border-top: 1rpx solid #edf1f6; }
.history-note { color: #8a96a6; font-size: 22rpx; line-height: 34rpx; padding: 16rpx 0; }
.history-row { flex-direction: row; align-items: center; padding: 20rpx 12rpx; border-bottom: 1rpx solid #edf1f6; }
.history-selected { background-color: #f0f6ff; border-radius: 10rpx; }
.history-title { color: #354963; font-size: 23rpx; }
.history-state { color: #0868f7; font-size: 22rpx; margin-left: 12rpx; }
.completion { padding: 30rpx; border-radius: 20rpx; background-color: #edf5ff; }
.completion-head { flex-direction: row; justify-content: space-between; align-items: center; }
.completion-title { font-size: 30rpx; font-weight: 600; color: #23344e; }
.score-wrap { flex-direction: row; align-items: flex-end; }
.completion-score { font-size: 52rpx; font-weight: 700; color: #087af5; }
.score-unit { font-size: 26rpx; font-weight: 600; color: #087af5; }
.progress-track { height: 12rpx; border-radius: 6rpx; background-color: #dbe6f8; margin-top: 26rpx; overflow: hidden; }
.progress-bar { height: 12rpx; border-radius: 6rpx; background-color: #087af5; }
.completion-desc { font-size: 24rpx; line-height: 38rpx; color: #7d8b9d; margin-top: 20rpx; }
.completion-retry { padding-bottom: 0; }
.archive-list { background-color: #fff; border: 1rpx solid #e7edf6; border-radius: 20rpx; margin-top: 24rpx; padding: 0 24rpx; }
.section { border-bottom: 1rpx solid #f0f3f8; }
.archive-row { flex-direction: row; align-items: center; padding: 28rpx 0; }
.archive-labels { flex: 1; margin-left: 22rpx; margin-right: 12rpx; }
.archive-name { font-size: 29rpx; font-weight: 500; color: #23344e; }
.archive-note { font-size: 23rpx; line-height: 34rpx; color: #9099a5; margin-top: 7rpx; }
.state { font-size: 23rpx; color: #9aA2ac; margin-right: 8rpx; }
.state-done { color: #65839f; }
.arrow-open { transform: rotate(90deg); }
.section-detail { padding: 0 2rpx 24rpx; }
.section-hint { font-size: 23rpx; line-height: 38rpx; color: #7b899c; }
.detail-row { flex-direction: row; padding: 20rpx 0; border-bottom: 1rpx solid #f2f5f9; }
.field-label { width: 210rpx; font-size: 24rpx; line-height: 38rpx; color: #7b899c; }
.field-value { flex: 1; text-align: right; font-size: 25rpx; line-height: 38rpx; color: #23344e; }
.main-action { margin-top: 30rpx; font-size: 29rpx; }
.new-action { margin-top: 20rpx; padding: 24rpx; border-radius: 16rpx; background-color: #eaf3ff; color: #0868f7; font-size: 27rpx; text-align: center; }
.local-note { font-size: 22rpx; line-height: 36rpx; color: #99a2af; margin-top: 22rpx; text-align: center; }
.empty-panel { padding: 24rpx 8rpx 0; }
.empty-title { font-size: 27rpx; color: #435874; }
.empty-note { font-size: 23rpx; line-height: 36rpx; color: #8592a4; margin-top: 10rpx; }
.notice { padding: 24rpx; margin-bottom: 24rpx; background-color: #fff; border-radius: 16rpx; }
.error-text { color: #a7533e; font-size: 25rpx; line-height: 38rpx; }
.text-action { color: #0868f7; font-size: 24rpx; padding: 18rpx 0; }
.loading-text { color: #7b899c; font-size: 24rpx; padding: 0 0 24rpx; }
.task-note { font-size: 24rpx; line-height: 38rpx; color: #7b899c; margin-top: 22rpx; }
</style>
+189
View File
@@ -0,0 +1,189 @@
<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="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 { 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 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>
+149 -84
View File
@@ -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>
+246 -97
View File
@@ -1,103 +1,252 @@
<template>
<view class="page" :style="pageStyle()">
<AppHeader title="产品详情">
<!-- #ifndef MP-WEIXIN -->
<text v-if="available" class="share" @click="shareProduct">分享</text>
<!-- #endif -->
</AppHeader>
<scroll-view scroll-y :style="contentStyle(224, false)">
<view class="product-content">
<view v-if="loading" class="state"><text class="state-text">正在加载产品…</text></view>
<view v-else-if="!available" class="state"><text class="state-title">{{ loadFailed ? '产品加载失败' : '暂未找到该产品' }}</text><text class="state-text">请重试或返回方案列表选择其他产品</text><button class="retry-button" @click="loadProduct">重新加载</button></view>
<view v-else>
<!-- #ifdef MP-WEIXIN -->
<view class="product-actions"><text class="share" @click="shareProduct">分享产品</text></view>
<!-- #endif -->
<view class="bank-head"><view class="bank-mark" :style="'background-color:' + product.color"><text class="bank-initial">{{ product.initials }}</text></view><view class="bank-copy"><text class="product-name">{{ product.bank }}</text><text class="product-tags">个人信用融资 · 产品信息</text></view></view>
<view class="product-metrics"><view class="metric"><text class="metric-label">最高额度</text><text class="amount">{{ product.amount }}万元</text></view><view class="metric"><text class="metric-label">年化利率(起)</text><text class="rate">{{ product.rate.toFixed(2) }}%</text></view><view class="metric last-metric"><text class="metric-label">贷款期限</text><text class="term">{{ product.term }}</text></view></view>
<view class="detail-section"><text class="section-title">产品优势</text><view class="advantage-grid"><view class="advantage" v-for="item in product.advantages" :key="item"><AppIcon name="check-circle" :size="30" /><text class="advantage-label">{{ item }}</text></view></view></view>
<view class="detail-section"><text class="section-title">申请条件</text><view class="condition"><text class="condition-label">年龄要求</text><text class="condition-value">{{ product.age }}</text></view><view class="condition"><text class="condition-label">工作要求</text><text class="condition-value">{{ product.work }}</text></view><view class="condition"><text class="condition-label">信用要求</text><text class="condition-value">{{ product.credit }}</text></view></view>
<view class="detail-section"><text class="section-title">所需材料</text><text class="material">{{ product.materials }}</text></view>
<text class="product-note">额度、利率及申请条件仅供参考,以机构实际审批为准。{{ isDemo ? '当前可准备申请资料,尚未向机构提交。' : '' }}</text>
</view>
</view>
</scroll-view>
<view v-if="available" class="apply-bar" :style="bottomStyle(18)"><button class="primary-button apply-button" @click="apply">准备申请</button></view>
</view>
<view class="page product-page" :style="pageStyle()">
<AppHeader :title="assessmentId.length > 0 ? '银行匹配详情' : '产品详情'" :customBack="true" @back="back" />
<scroll-view scroll-y :style="contentStyle(96, false)">
<view v-if="pageActive" class="product-content">
<text v-if="loading" class="muted loading">正在读取产品资料…</text>
<view v-if="error.length > 0" class="error-panel"><text class="heading">暂时无法读取</text><text class="body">{{ error }}</text><button class="secondary" @click="load">重新加载</button><text class="link" @click="back">返回选择产品</text></view>
<template v-if="product != null">
<view class="bank-header">
<view class="bank-mark"><image class="bank-icon" src="/static/mobile/icons-generated/building.png" mode="aspectFit" /></view>
<view class="bank-info">
<view class="bank-title-row"><text class="bank-name">{{ product!.bank }}</text><text v-if="match != null" :class="match!.state == 'eligible' ? 'badge good' : (match!.state == 'ineligible' ? 'badge blocked' : 'badge pending')">{{ match!.state == 'eligible' ? '可供参考' : (match!.state == 'ineligible' ? '暂不推荐' : '待核实') }}</text></view>
<text v-if="match != null" :class="match!.state == 'eligible' ? 'state good-text' : 'state attention-text'">{{ match!.stateLabel }}</text>
<text class="product-name">{{ product!.name }}</text>
</view>
</view>
<text v-if="product!.demo" class="demo">演示数据 · 非真实金融产品</text>
<view class="tabs">
<view v-if="assessmentId.length > 0" :class="tab == 'explanation' ? 'tab active' : 'tab'" @click="switchTab('explanation')"><text :class="tab == 'explanation' ? 'tab-label selected' : 'tab-label'">AI解释</text></view>
<view :class="tab == 'details' ? 'tab active' : 'tab'" @click="switchTab('details')"><text :class="tab == 'details' ? 'tab-label selected' : 'tab-label'">产品详情</text></view>
<view :class="tab == 'conditions' ? 'tab active' : 'tab'" @click="switchTab('conditions')"><text :class="tab == 'conditions' ? 'tab-label selected' : 'tab-label'">申请条件</text></view>
</view>
<template v-if="tab == 'explanation' && match != null">
<view class="reason-section">
<text class="heading good-text">推荐依据({{ match!.positive.length }}条已满足条件)</text>
<text v-if="match!.positive.length == 0" class="empty-note">暂无已确认满足的条件,不补充未经验证的推荐理由。</text>
<view v-for="(reason, index) in positiveVisible" :key="reason.id" class="reason-row">
<text class="number green-number">{{ index + 1 }}</text>
<view class="reason-content"><text class="reason-title">{{ reason.text }}</text><text class="reason-detail">{{ reason.explanation.length > 0 ? reason.explanation : reason.evidence.join(';') }}</text><text class="evidence-toggle" @click="openReason = openReason == reason.id ? '' : reason.id">{{ openReason == reason.id ? '收起依据' : '查看依据' }}</text><view v-if="openReason == reason.id" class="evidence-box"><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view></view>
</view>
<text v-if="match!.positive.length > 3" class="expand" @click="expanded = !expanded">{{ expanded ? '收起' : '展开其余' + (match!.positive.length - 3) + '条依据' }}</text>
</view>
<view class="reason-section attention-section">
<text class="heading attention-text">不推荐/需注意的点({{ attention.length }}条)</text>
<text v-if="attention.length == 0" class="empty-note">已知规则中暂无不符或待核实项,仍需核验资料,不代表审批结果。</text>
<view v-for="(reason, index) in attentionVisible" :key="reason.id" class="reason-row">
<text class="number orange-number">{{ index + 1 }}</text>
<view class="reason-content"><text class="reason-title">{{ reason.text }}</text><text class="reason-detail">{{ reason.explanation.length > 0 ? reason.explanation : reason.evidence.join(';') }}</text><text class="evidence-toggle" @click="openReason = openReason == reason.id ? '' : reason.id">{{ openReason == reason.id ? '收起依据' : '查看依据' }}</text><view v-if="openReason == reason.id" class="evidence-box"><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view></view>
</view>
<text v-if="attention.length > 3" class="expand" @click="attentionExpanded = !attentionExpanded">{{ attentionExpanded ? '收起' : '展开其余' + (attention.length - 3) + '条注意事项' }}</text>
</view>
<view class="ai-panel">
<text class="ai-title">AI详细解释</text>
<text v-if="match!.aiStatus == 'completed'" class="ai-note">已将逐条解释补充在上方,生成结果已保存,重新打开无需再次生成。</text>
<text v-else-if="aiPending" class="ai-note">{{ pollStopped ? '生成仍在进行,可稍后查看状态。' : '正在解释本次匹配依据,请稍候。离开页面后任务仍会继续。' }}</text>
<text v-else-if="match!.aiStatus == 'failed'" class="ai-note">解释生成未完成,规则依据不受影响。{{ match!.aiCanRetry ? '可以重新尝试。' : '本次已达到重试上限。' }}</text>
<text v-else class="ai-note">当前为规则依据。可让AI逐条说明条件含义和资料核对建议。</text>
<text v-if="aiError.length > 0" class="attention-text ai-note">{{ aiError }}</text>
<button v-if="!aiPending && match!.aiStatus != 'completed' && (match!.aiStatus != 'failed' || match!.aiCanRetry)" class="ai-button" :disabled="aiBusy" @click="generateExplanation">{{ aiBusy ? '正在提交…' : (match!.aiStatus == 'failed' ? '重试AI解释' : '生成AI详细解释') }}</button>
<button v-if="aiPending && (aiError.length > 0 || pollStopped)" class="ai-button" :disabled="aiBusy" @click="checkExplanation">重新查询状态</button>
</view>
</template>
<template v-if="tab == 'details'">
<view class="details-section"><text class="heading">产品信息</text>
<view class="row"><text class="label">额度范围</text><text class="value emphasis">{{ product!.amount }}</text></view>
<view class="row"><text class="label">年化利率</text><text class="value">{{ product!.rate }}</text></view>
<view class="row"><text class="label">贷款期限</text><text class="value">{{ product!.term }}</text></view>
<view class="row"><text class="label">适用主体</text><text class="value">{{ product!.objects }}</text></view>
<view class="row"><text class="label">适用地区</text><text class="value">{{ product!.regions }}</text></view>
<view class="row"><text class="label">有效期</text><text class="value">{{ product!.valid }}</text></view>
<view class="row"><text class="label">还款方式</text><text class="value">未提供</text></view>
<view class="row"><text class="label">办理渠道</text><text class="value">未提供</text></view>
<view class="row"><text class="label">审批时长</text><text class="value">未提供</text></view>
</view>
<view class="details-section"><text class="heading">资料来源</text><text class="body">{{ product!.source }}</text><text class="evidence">引用:{{ product!.sourceRef }}</text><text class="evidence">核对日期:{{ product!.checkedOn }}</text></view>
</template>
<template v-if="tab == 'conditions'">
<view class="details-section"><text class="heading">产品准入条件</text><text class="muted">公开条件不代表个人已符合,缺少核验依据时仍需核实。</text><text v-for="(condition, index) in product!.conditions" :key="index" class="condition">{{ condition }}</text></view>
<view class="details-section"><text class="heading">资料准备说明</text><text class="body">{{ product!.materials }}</text><text class="muted">仅提供材料引导,不向机构提交申请。实际条件和材料须向机构核实。</text></view>
</template>
<view class="footnotes"><text class="record-note">{{ assessmentId.length > 0 ? '本次评估 #' + assessmentId : '公共产品资料' }} · 产品版本 v{{ product!.version }}</text><text v-if="match != null" class="record-note">{{ match!.availabilityLabel }},历史参数与结论保留。</text><text v-if="match != null && !canPrepare" class="record-note">{{ match!.state != 'eligible' ? '请先核对不符条件或待核实项。' : '当前产品不可用或状态未知,暂不提供准备入口。' }}</text><text class="record-note">自填信息未经核验;本页面不承诺额度、放款或获批。</text><text v-if="assessmentId.length > 0" class="link" @click="toMatches">返回本次匹配</text><text v-else class="link" @click="history">选择评估查看匹配结果</text></view>
</template>
</view>
</scroll-view>
<view v-if="pageActive && product != null" class="action-bar" :style="bottomStyle(22)">
<template v-if="match != null && canPrepare"><button class="footer-button secondary" @click="openCompare">查看同类产品对比</button><button class="footer-button primary" @click="prepare">{{ tab == 'conditions' ? '返回本次匹配' : '查看资料准备' }}</button></template>
<template v-else-if="match != null"><button class="footer-button secondary" @click="switchTab(tab == 'details' ? 'explanation' : 'details')">{{ tab == 'details' ? '查看匹配解释' : '查看产品详情' }}</button><button class="footer-button primary" @click="openCompare">{{ selectedIds.includes(productId) ? '查看产品对比' : '加入产品对比' }}</button></template>
<button v-else class="footer-button primary" @click="switchTab(tab == 'conditions' ? 'details' : 'conditions')">{{ tab == 'conditions' ? '查看产品详情' : '查看申请条件' }}</button>
</view>
</view>
</template>
<script setup lang="uts">
import { usePageLayout } from '@/services/layout.uts'
import { FinanceProduct, demoProducts } from '@/packages/finance/services/finance.uts'
import { fetchProduct, API_BASE_URL } from '@/packages/finance/services/api.uts'
import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue'
const { bottomStyle, contentStyle, pageStyle } = usePageLayout()
const product = ref<FinanceProduct>(demoProducts[0])
const available = ref(false)
const productId = ref('')
const loading = ref(true)
const loadFailed = ref(false)
const isDemo = API_BASE_URL.length == 0
const loadProduct = async () => {
loading.value = true
loadFailed.value = false
available.value = false
try {
const selected = await fetchProduct(productId.value)
if (selected != null) { product.value = selected; available.value = true }
} catch (error) { loadFailed.value = true }
loading.value = false
}
onLoad((options) => {
productId.value = options?.['id'] ?? ''
loadProduct()
})
const shareProduct = () => {
const item = product.value
if (!available.value) return
uni.setClipboardData({ data: item.bank + '|最高' + item.amount + '万元|年化利率' + item.rate.toFixed(2) + '%起|' + item.term + '。展示信息仅供参考,以机构审批为准。' })
}
const apply = () => {
uni.showModal({
title: '准备申请资料',
content: '先完善融资档案,核对申请所需材料。当前尚未开通机构申请,资料不会提交至银行。',
confirmText: '完善档案',
success: (result) => { if (result.confirm) uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
})
}
import AppHeader from '@/components/AppHeader.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { MatchView, MatchReason, ProductView, fetchMatchDetail, fetchPublicProduct, validRecordId, requestProductExplanation } from '@/packages/finance/services/matches.uts'
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery } from '@/packages/finance/services/compare.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
const assessmentId = ref(''); const productId = ref(''); const tab = ref('details')
const selectedIds = ref<string[]>([]); let from = ''
const product = ref<ProductView | null>(null); const match = ref<MatchView | null>(null)
const loading = ref(false); const error = ref(''); const expanded = ref(false)
const pageActive = ref(false); const attentionExpanded = ref(false); const openReason = ref('')
const aiBusy = ref(false); const aiError = ref(''); const pollStopped = ref(false)
let timer: number = 0; let pollCount = 0; let requestKey = ''
let routeError = ''; let visible = false; let generation = 0
const positiveVisible = computed((): MatchReason[] => match.value == null ? [] : (expanded.value ? match.value!.positive : match.value!.positive.slice(0, 3)))
const attention = computed((): MatchReason[] => match.value == null ? [] : match.value!.blocking.concat(match.value!.cautions))
const attentionVisible = computed((): MatchReason[] => attentionExpanded.value ? attention.value : attention.value.slice(0, 3))
const aiPending = computed((): boolean => match.value != null && ['queued','running'].includes(match.value!.aiStatus))
const canPrepare = computed((): boolean => match.value != null && match.value!.state == 'eligible' && match.value!.availability == 'available')
const load = async () => {
stopPoll(); pollCount = 0; pollStopped.value = false; aiError.value = ''
const request = ++generation; product.value = null; match.value = null; error.value = ''; expanded.value = false
if (routeError.length > 0) { error.value = routeError; return }
const session = getSession()
if (session == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
const token = session.getString('token') ?? ''; loading.value = true
try {
// 带评估编号始终读取冻结结果;404/越权/失败不能降级为公共资料。
if (assessmentId.value.length > 0) {
const result = await fetchMatchDetail(assessmentId.value, productId.value)
if (visible && generation == request && getSession()?.getString('token') == token) { match.value = result; product.value = result.product; schedulePoll() }
} else {
const result = await fetchPublicProduct(productId.value)
if (visible && generation == request && getSession()?.getString('token') == token) product.value = result
}
} catch (err) { if (visible && generation == request) error.value = err instanceof Error ? err.message : '产品读取失败' }
finally { if (generation == request) loading.value = false }
}
// 替换当前路由保留栏目、来源与对比选择,刷新后仍停留在同一栏。
const switchTab = (value: string) => {
if (value == tab.value) return
const query = assessmentId.value.length > 0 ? compareQuery(assessmentId.value, selectedIds.value) + '&' : '?'
uni.redirectTo({ url: '/packages/finance/product/product' + query + 'productId=' + productId.value + '&tab=' + value + '&from=' + from })
}
const prepare = () => { if (canPrepare.value) { if (tab.value == 'conditions') toMatches(); else switchTab('conditions') } }
const stopPoll = () => { if (timer != 0) clearTimeout(timer); timer = 0 }
const current = (request: number, token: string): boolean => visible && generation == request && getSession()?.getString('token') == token
const refreshExplanation = async () => {
if (aiBusy.value || match.value == null) return
const request = generation; const token = getSession()?.getString('token') ?? ''
aiError.value = ''; aiBusy.value = true
try {
const result = await fetchMatchDetail(assessmentId.value, productId.value)
if (current(request, token)) { match.value = result; schedulePoll() }
} catch (err) { if (current(request, token)) aiError.value = err instanceof Error ? err.message : '解释状态读取失败,请重试'
} finally { if (current(request, token)) aiBusy.value = false }
}
const schedulePoll = () => {
stopPoll()
if (!visible || !aiPending.value) return
if (++pollCount > 60) { pollStopped.value = true; return }
timer = setTimeout(() => { timer = 0; refreshExplanation() }, 3000)
}
const checkExplanation = () => { pollCount = 0; pollStopped.value = false; refreshExplanation() }
const generateExplanation = async () => {
if (aiBusy.value || match.value == null || aiPending.value || match.value!.aiStatus == 'completed') return
const request = generation; const token = getSession()?.getString('token') ?? ''
// 网络结果不确定时保留同一个幂等键,只有收到明确失败状态后才开始新的尝试。
if (requestKey.length == 0) requestKey = 'explain_' + Date.now().toString() + '_' + Math.random().toString(36).slice(2, 12)
aiBusy.value = true; aiError.value = ''; pollCount = 0; pollStopped.value = false
try {
const result = await requestProductExplanation(assessmentId.value, productId.value, requestKey)
if (current(request, token)) { match.value = result; requestKey = ''; schedulePoll() }
} catch (err) { if (current(request, token)) aiError.value = err instanceof Error ? err.message : 'AI解释请求失败,请重试'
} finally { if (current(request, token)) aiBusy.value = false }
}
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
const toMatches = () => { if (assessmentId.value.length > 0) uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, selectedIds.value) }); else history() }
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else if (from == 'compare') uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value) }); else toMatches() }
const openCompare = () => {
if (match.value == null) return
if (!selectedIds.value.includes(productId.value)) {
if (selectedIds.value.length >= 3) { uni.showToast({ title: '已选满3项,请在对比页调整选择', icon: 'none' }); return }
selectedIds.value.push(productId.value)
}
saveCompareSelection(assessmentId.value, selectedIds.value)
uni.navigateTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value) })
}
onLoad((options) => {
assessmentId.value = options?.['assessmentId'] ?? ''; const oldId = options?.['id'] ?? ''
productId.value = options?.['productId'] ?? oldId
if (options?.['assessmentId'] != null && !validRecordId(assessmentId.value)) routeError = '评估编号无效,请从报告重新进入。'
if (!validRecordId(productId.value) || (oldId.length > 0 && oldId != productId.value)) routeError = '产品编号无效或冲突,请重新选择。'
tab.value = options?.['tab'] ?? (assessmentId.value.length > 0 ? 'explanation' : 'details')
if (!['explanation','details','conditions'].includes(tab.value) || (assessmentId.value.length == 0 && tab.value == 'explanation')) routeError = '页面栏目参数无效,请重新选择。'
from = options?.['from'] ?? ''
if (!['','matches','product','compare','report','next-step','archive'].includes(from)) 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); load() })
onHide(() => { pageActive.value = false; visible = false; stopPoll(); generation++; product.value = null; match.value = null; error.value = ''; aiError.value = ''; aiBusy.value = false; loading.value = false; openReason.value = '' })
onUnload(() => { pageActive.value = false; visible = false; stopPoll(); generation++; product.value = null; match.value = null; requestKey = '' })
</script>
<style>
.product-actions { flex-direction: row; justify-content: flex-end; margin-bottom: 12rpx; }
.share { color: #0868f7; font-size: 25rpx; padding: 16rpx 0 16rpx 16rpx; }
.product-content { padding: 24rpx 28rpx 60rpx; }
.bank-head { padding: 26rpx; border-radius: 18rpx; background-color: #fff; border: 1rpx solid #e6edf6; flex-direction: row; align-items: center; }
.bank-mark { width: 80rpx; height: 80rpx; border-radius: 24rpx; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; }
.bank-initial { color: #fff; font-size: 36rpx; font-weight: 700; }
.bank-copy { flex: 1; }
.product-name { font-size: 30rpx; font-weight: 700; color: #20324d; }
.product-tags { color: #75859b; font-size: 22rpx; margin-top: 10rpx; }
.product-metrics { margin-top: 20rpx; padding: 30rpx 12rpx; border-radius: 18rpx; background-color: #fff; border: 1rpx solid #e6edf6; flex-direction: row; }
.metric { flex: 1; align-items: center; border-right: 1rpx solid #edf1f6; }
.last-metric { border-right: 0; }
.metric-label { color: #75859b; font-size: 21rpx; }
.amount, .rate, .term { margin-top: 14rpx; font-size: 36rpx; line-height: 46rpx; font-weight: 800; color: #20324d; }
.rate { color: #0868f7; }
.term { font-size: 27rpx; }
.detail-section { margin-top: 20rpx; padding: 26rpx; background-color: #fff; border-radius: 18rpx; border: 1rpx solid #e6edf6; }
.advantage-grid { margin-top: 24rpx; flex-direction: row; flex-wrap: wrap; }
.advantage { width: 50%; flex-direction: row; align-items: center; margin-bottom: 16rpx; }
.advantage-label { margin-left: 12rpx; font-size: 25rpx; color: #435168; }
.condition { min-height: 78rpx; padding: 14rpx 0; flex-direction: row; justify-content: space-between; align-items: center; border-bottom: 1rpx solid #edf1f6; }
.condition-label { color: #748399; font-size: 24rpx; flex-shrink: 0; margin-right: 20rpx; }
.condition-value { flex: 1; text-align: right; color: #273b54; font-size: 24rpx; line-height: 36rpx; }
.material { margin-top: 18rpx; font-size: 24rpx; line-height: 38rpx; color: #53667f; }
.product-note { margin: 22rpx 6rpx 0; color: #7a899e; font-size: 21rpx; line-height: 34rpx; }
.apply-bar { position: fixed; left: 0; right: 0; bottom: 0; padding: 18rpx 28rpx; background-color: #fff; border-top: 1rpx solid #e7edf6; }
.apply-button { margin: 0; }
.state { padding: 80rpx 24rpx; align-items: center; }
.state-title { font-size: 30rpx; color: #20324d; font-weight: 600; margin-bottom: 14rpx; }
.state-text { font-size: 25rpx; line-height: 40rpx; text-align: center; color: #75859b; }
.retry-button { margin-top: 24rpx; color: #0868f7; background-color: #e8f2ff; font-size: 25rpx; }
.product-page { background-color: #fff; }
.product-content { padding: 18rpx 32rpx 200rpx; }
.loading { padding: 36rpx 0; }
.bank-header { flex-direction: row; align-items: center; padding: 20rpx 0 10rpx; }
.bank-mark { width: 108rpx; height: 108rpx; border-radius: 54rpx; background-color: #edf5ff; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; }
.bank-icon { width: 86rpx; height: 86rpx; }
.bank-info { flex: 1; }
.bank-title-row { flex-direction: row; flex-wrap: wrap; align-items: center; }
.bank-name { color: #16253c; font-size: 33rpx; font-weight: 700; line-height: 48rpx; margin-right: 14rpx; }
.badge { font-size: 21rpx; line-height: 32rpx; padding: 3rpx 12rpx; border-radius: 6rpx; }
.good { color: #249a76; background-color: #e9f8f1; }
.blocked { color: #697387; background-color: #f0f2f6; }
.pending { color: #ac802d; background-color: #fff7e5; }
.state { font-size: 24rpx; line-height: 36rpx; margin-top: 5rpx; }
.product-name { color: #788294; font-size: 24rpx; line-height: 36rpx; margin-top: 3rpx; }
.demo { color: #8d96a4; font-size: 21rpx; line-height: 32rpx; margin-top: 10rpx; }
.tabs { flex-direction: row; border-bottom: 1rpx solid #e8edf4; margin-top: 18rpx; }
.tab { flex: 1; align-items: center; padding: 22rpx 0; }
.active { border-bottom: 4rpx solid #0868f7; }
.tab-label { font-size: 26rpx; color: #687588; }
.selected { color: #0868f7; font-weight: 700; }
.reason-section { margin-top: 28rpx; }
.attention-section { margin-top: 30rpx; }
.heading { font-size: 28rpx; color: #243752; font-weight: 700; line-height: 42rpx; }
.good-text { color: #259d79; }
.attention-text { color: #e37b55; }
.reason-row { flex-direction: row; align-items: flex-start; margin-top: 22rpx; }
.number { width: 42rpx; height: 42rpx; border-radius: 21rpx; color: #fff; font-size: 25rpx; line-height: 42rpx; text-align: center; flex-shrink: 0; margin-right: 16rpx; margin-top: 2rpx; }
.green-number { background-color: #35b38c; }
.orange-number { background-color: #f49162; }
.reason-content { flex: 1; }
.reason-title { color: #27364c; font-size: 26rpx; line-height: 40rpx; font-weight: 600; }
.reason-detail { color: #7b8594; font-size: 23rpx; line-height: 36rpx; margin-top: 4rpx; }
.evidence-toggle { color: #8596ad; font-size: 21rpx; padding: 8rpx 0; }
.evidence-box { background-color: #f5f8fc; border-radius: 8rpx; padding: 8rpx 14rpx 14rpx; margin-top: 4rpx; }
.evidence { color: #7f8b9b; font-size: 22rpx; line-height: 36rpx; margin-top: 8rpx; }
.expand { color: #4784c9; font-size: 23rpx; padding: 12rpx 0 0 58rpx; }
.empty-note { color: #8c97a6; font-size: 23rpx; line-height: 38rpx; margin-top: 16rpx; }
.ai-panel { background-color: #f0f6ff; border-radius: 12rpx; padding: 24rpx; margin-top: 34rpx; }
.ai-title { color: #316aac; font-size: 25rpx; font-weight: 600; }
.ai-note { color: #7890ab; font-size: 23rpx; line-height: 38rpx; margin-top: 10rpx; }
.ai-button { font-size: 24rpx; color: #0868f7; background-color: #fff; border: 1rpx solid #d6e6fb; border-radius: 10rpx; margin-top: 20rpx; }
.details-section { padding-top: 30rpx; }
.row { flex-direction: row; border-bottom: 1rpx solid #f0f3f7; padding: 23rpx 0; }
.label { color: #8190a3; width: 146rpx; font-size: 24rpx; line-height: 38rpx; }
.value { flex: 1; text-align: right; color: #35465f; font-size: 25rpx; line-height: 38rpx; }
.emphasis { color: #24578c; font-weight: 700; }
.body { color: #53657e; font-size: 25rpx; line-height: 42rpx; margin-top: 18rpx; }
.muted { color: #8694a7; font-size: 23rpx; line-height: 38rpx; margin-top: 12rpx; }
.condition { padding: 20rpx 0; border-bottom: 1rpx solid #edf1f7; font-size: 25rpx; color: #435b77; line-height: 42rpx; }
.footnotes { padding: 26rpx 0 0; margin-top: 28rpx; border-top: 1rpx solid #edf1f6; }
.record-note { color: #939dac; font-size: 21rpx; line-height: 36rpx; }
.link { font-size: 24rpx; color: #0868f7; padding: 22rpx 0; }
.error-panel { padding: 20rpx 0; }
.action-bar { position: fixed; bottom: 0; left: 0; right: 0; padding: 18rpx 32rpx 22rpx; flex-direction: row; background-color: #fff; border-top: 1rpx solid #edf1f7; }
.footer-button { flex: 1; height: 80rpx; line-height: 80rpx; padding: 0 10rpx; font-size: 25rpx; border-radius: 12rpx; margin: 0; }
.primary { background-color: #0868f7; color: #fff; }
.secondary { background-color: #edf4ff; color: #1769ca; font-size: 25rpx; border-radius: 12rpx; }
.action-bar .secondary { margin-right: 20rpx; }
/* #ifndef APP */
@media (min-width: 768px) { .product-content { max-width: 760px; margin: 0 auto; } .action-bar { max-width: 760px; margin: 0 auto; } }
/* #endif */
</style>
+176 -38
View File
@@ -1,25 +1,55 @@
<template>
<view class="page" :style="pageStyle()">
<AppHeader title="融资资料分析报告" />
<scroll-view scroll-y :style="contentStyle(0, false)">
<AppHeader title="我的融资报告" :customBack="true" @back="back" />
<view class="tabs"><view v-for="item in tabs" :key="item.id" :class="tab == item.id ? 'tab active' : 'tab'" @click="switchTab(item.id)"><text :class="tab == item.id ? 'tab-label selected' : 'tab-label'">{{ item.label }}</text></view></view>
<scroll-view scroll-y :style="contentStyle(186, false)">
<view class="report-content">
<text class="report-note">评估记录 · {{ assessmentId }}</text>
<text v-if="loading" class="body-text">正在加载报告…</text>
<view v-if="error.length > 0" class="panel"><text class="body-text">{{ error }}</text><text class="action" @click="loadReport">重新加载</text><text class="action" @click="history">前往融资档案</text></view>
<text class="eyebrow">评估记录 #{{ assessmentId.length > 0 ? assessmentId : '待选择' }}</text>
<text v-if="loading" class="body-text">正在读取已保存报告…</text>
<view v-if="error.length > 0" class="panel"><text class="section-title">报告暂不可读取</text><text class="body-text">{{ error }}</text><button v-if="routeError.length == 0" class="secondary" @click="loadReport">重新加载报告</button><text class="action" @click="history">前往融资档案</text></view>
<template v-if="report != null">
<text class="report-note">分析模型:{{ report!.modelCode }}</text>
<template v-if="report!.version == 2">
<view class="panel"><text class="section-title">需求概况</text><view class="fact-row" v-for="fact in report!.demand" :key="fact.label"><text class="fact-label">{{ fact.label }}</text><text class="fact-value">{{ fact.value }}</text></view></view>
<view class="panel"><text class="section-title">资料完整度</text><text class="readiness-value">{{ report!.completenessText }}</text><text class="report-note">仅统计当前适用的自填字段,不代表信用评分或真实性。</text><view class="fact-row" v-for="item in report!.completenessItems" :key="item.label"><text class="fact-label">{{ item.label }}</text><text class="fact-value">{{ item.value }}</text></view></view>
<view class="panel"><text class="section-title">融资准备状态</text><text class="readiness-value">{{ report!.readinessLabel }}</text><text class="body-text" v-for="(item, index) in report!.readinessBasis" :key="index">{{ item }}</text><text class="report-note">资料未经核验;即使完整度达到100%,仍需核对机构要求的材料。</text></view>
<text class="report-note">生成于 {{ report!.generatedAt }} · 用户自填资料分析</text>
<view v-if="report!.version == 1" class="legacy"><text class="report-note">这是一份旧版自填报告,当时未采集融资需求、未计算完整度与准备状态,也未执行产品匹配。历史内容保持原样。</text></view>
<template v-if="tab == 'overview'">
<view class="hero">
<text class="hero-label">本次融资准备状态</text><text class="hero-title">{{ report!.readinessLabel }}</text>
<text class="hero-note">{{ report!.version == 1 ? '旧版未评定,可重新填写并发起新评估' : '按本次提交资料及评估时规则生成' }}</text>
<view class="hero-divider"></view><view class="panel-heading"><text class="hero-label">资料完整度</text><text class="percent">{{ report!.completenessPercent == null ? '—' : report!.completenessPercent!.toString() + '%' }}</text></view>
<view v-if="report!.completenessPercent != null" class="progress-track"><view class="progress-fill" :style="'width:' + report!.completenessPercent!.toString() + '%;'"></view></view>
<text class="hero-note">{{ report!.completenessText }}</text><text class="hero-note">完整度不代表信用评分、资料真实性或获批概率。</text>
</view>
<view v-if="report!.version == 2" class="panel"><text class="section-title">本次资金需求</text><view class="fact-row" v-for="fact in report!.demand" :key="fact.label"><text class="fact-label">{{ fact.label }}</text><text class="fact-value">{{ fact.value }}</text></view></view>
<view class="panel"><text class="section-title">分析摘要</text><text class="body-text">{{ report!.summary }}</text><text class="source-note">分析来源:已保存的AI文本 · {{ report!.modelCode }}</text></view>
<view v-if="report!.version == 2" class="panel"><text class="section-title">准备状态依据</text><text v-if="report!.readinessBasis.length == 0" class="report-note">本次没有可展示的准备状态依据。</text><text class="body-text" v-for="(item, index) in report!.readinessBasis" :key="index">{{ item }}</text><text class="report-note">资料未经核验;完整度达到100%仍需核对机构要求。</text></view>
<view class="panel"><text class="section-title">{{ report!.version == 2 ? '主要优势' : '资料解读' }}</text><text v-if="report!.observations.length == 0" class="report-note">暂无有充分依据的优势说明。</text><text class="body-text" v-for="(item, index) in report!.observations" :key="index">{{ item }}</text></view>
<view class="panel"><view class="panel-heading" @click="factsOpen = !factsOpen"><text class="section-title">本次提交的事实</text><text class="toggle">{{ factsOpen ? '收起' : '展开' }}</text></view><text class="report-note">来源:用户自填,未经外部核验;未填写不等于零。</text><template v-if="factsOpen"><view class="fact-row" v-for="fact in report!.facts" :key="fact.label"><text class="fact-label">{{ fact.label }}</text><text class="fact-value">{{ fact.value }}</text></view><text v-if="report!.facts.length == 0" class="report-note">暂无可展示的事实。</text></template></view>
<view v-if="report!.version == 2" class="panel"><view class="panel-heading" @click="completenessOpen = !completenessOpen"><text class="section-title">逐项填写情况</text><text class="toggle">{{ completenessOpen ? '收起' : '展开' }}</text></view><template v-if="completenessOpen"><view class="fact-row" v-for="item in report!.completenessItems" :key="item.label"><text class="fact-label">{{ item.label }}</text><text class="fact-value">{{ item.value }}</text></view><text v-if="report!.completenessItems.length == 0" class="report-note">本次未评定逐项完整度。</text></template></view>
<view class="panel"><text class="section-title">{{ report!.version == 2 ? '风险与核验边界' : '待补充信息' }}</text><text class="body-text" v-for="(item, index) in report!.warnings" :key="'system-' + index">{{ item }}</text><text class="body-text" v-for="(item, index) in report!.dataGaps" :key="index">{{ item }}</text><text v-if="report!.warnings.length == 0 && report!.dataGaps.length == 0" class="report-note">本次暂无额外提示,不代表资料已核验或无风险。</text></view>
<button class="primary" @click="switchTab('matches')">查看本次匹配与依据</button>
</template>
<view class="panel"><text class="section-title">自填资料分析</text><text class="body-text">{{ report!.summary }}</text><text class="report-note">{{ report!.disclaimer }}</text></view>
<view class="panel"><text class="section-title">本次提交的事实</text><text class="report-note">来源:用户自填,未经外部核验;未填写不等于零。</text><view class="fact-row" v-for="fact in report!.facts" :key="fact.label"><text class="fact-label">{{ fact.label }}</text><text class="fact-value">{{ fact.value }}</text></view></view>
<view class="panel"><text class="section-title">{{ report!.version == 2 ? '主要优势' : '资料解读' }}</text><text v-if="report!.observations.length == 0" class="report-note">暂无有充分依据的优势说明。</text><text class="body-text" v-for="(item, index) in report!.observations" :key="index">{{ item }}</text></view>
<view v-if="report!.suggestions.length > 0" class="panel"><text class="section-title">完善建议</text><text class="body-text" v-for="(item, index) in report!.suggestions" :key="index">{{ item }}</text></view>
<view class="panel"><text class="section-title">{{ report!.version == 2 ? '风险提示' : '待补充信息' }}</text><text class="body-text" v-for="(item, index) in report!.warnings" :key="'system-' + index">{{ item }}</text><text class="body-text" v-for="(item, index) in report!.dataGaps" :key="index">{{ item }}</text></view>
<view class="panel"><text class="section-title">产品匹配</text><text class="body-text">{{ report!.matchingMessage }}</text></view>
<text class="action" @click="history">查看历史评估</text>
<template v-else-if="tab == 'matches'">
<text class="headline">本次需求的可选方向</text><text class="intro">以下为评估时的条件判断。展示顺序不代表优劣,也不代表机构审批。</text>
<view v-if="report!.matching.status == 'completed'" class="stats"><view class="stat" @click="allMatches('eligible')"><text class="stat-number">{{ report!.matching.eligible }}</text><text class="stat-label">符合已知条件</text></view><view class="stat" @click="allMatches('ineligible')"><text class="stat-number">{{ report!.matching.ineligible }}</text><text class="stat-label">条件不符</text></view><view class="stat" @click="allMatches('unknown')"><text class="stat-number">{{ report!.matching.unknown }}</text><text class="stat-label">待核实</text></view></view>
<text class="report-note">{{ report!.matchingMessage }}</text>
<view v-if="report!.matching.status != 'completed'" class="panel"><text class="section-title">本次未执行产品匹配</text><text class="body-text">这与“已匹配但没有候选”不同。历史报告不会自动补算;需要采用新资料与当前产品条件时,请重新评估。</text></view>
<template v-else>
<text v-if="candidateLoading" class="body-text">正在读取历史候选…</text>
<view v-if="candidateError.length > 0" class="panel"><text class="section-title">候选读取失败</text><text class="body-text">{{ candidateError }}</text><button class="secondary" @click="loadCandidates">重试候选</button></view>
<view v-if="!candidateLoading && candidateError.length == 0 && candidates.length == 0" class="panel"><text class="section-title">暂无符合已知条件的产品</text><text class="body-text">本次已执行匹配,可查看条件不符或待核实原因。没有候选不代表永久无法融资。</text></view>
<view v-for="item in candidates" :key="item.id" class="product-card" @click="openProduct(item.id)"><view class="panel-heading"><text class="product-name">{{ item.product.name }}</text><text class="version">v{{ item.product.version }}</text></view><text class="report-note">{{ item.product.bank }} · {{ item.stateLabel }}</text><text v-if="item.product.demo" class="demo">演示产品 · 非真实金融产品</text><text class="amount">{{ item.product.amount }}</text><view class="metrics"><text class="metric">年化 {{ item.product.rate }}</text><text class="metric">{{ item.product.term }}</text></view><text class="reason" v-for="reason in item.positive.slice(0, 2)" :key="reason.id">{{ reason.text }}</text><text v-if="item.availability != 'available'" class="availability">{{ item.availabilityLabel }};仍保留历史参数。</text><text class="action">查看匹配依据与详情 ›</text></view>
<text v-if="candidates.length > 0" class="report-note">摘要最多展示3项符合已知条件的产品,全部分组见完整匹配列表。</text>
<button class="primary" @click="allMatches('eligible')">查看全部匹配结果</button><view class="links"><text class="action" @click="allMatches('ineligible')">查看不符原因</text><text class="action" @click="allMatches('unknown')">查看待核实项</text></view>
</template>
</template>
<template v-else>
<text class="headline">让下一次准备更充分</text><text class="intro">依据本次报告和产品条件整理。补充资料后需重新提交新评估,改善不保证获批。</text>
<view v-if="report!.version == 2" class="panel"><text class="section-title">优先补充与核实</text><text v-if="missingItems.length == 0" class="report-note">{{ report!.completenessPercent == null ? '本次未评定缺失字段,不能据此认为资料齐全。' : '本次字段清单没有待补充或格式待核实项,仍需准备核验材料。' }}</text><view class="fact-row" v-for="item in missingItems" :key="item.label"><text class="fact-label">{{ item.label }}</text><text class="fact-value">{{ item.value }}</text></view><text class="body-text" v-for="(item, index) in report!.readinessBasis" :key="index">{{ item }}</text></view>
<view class="panel"><text class="section-title">已保存的AI改善建议</text><text v-if="report!.suggestions.length == 0" class="report-note">暂无有依据的额外建议,不代表已经完成核验。</text><text class="body-text" v-for="(item, index) in report!.suggestions" :key="index">{{ item }}</text><text class="source-note">保留报告生成时的文本与依据,不重新调用模型。</text></view>
<view v-if="report!.matching.status != 'completed'" class="panel"><text class="section-title">产品条件提示</text><text class="report-note">本次未执行产品匹配,没有可关联的产品改善依据。</text></view>
<template v-else><view v-for="group in adviceGroups" :key="group.id" class="panel"><text class="section-title">{{ group.label }}</text><text class="report-note">仅展示本组前3项产品的已有依据,可进入完整列表查看更多。</text><text v-if="group.loading" class="body-text">正在读取产品依据…</text><template v-if="group.error.length > 0"><text class="body-text">{{ group.error }}</text><button class="secondary" @click="loadAdvice(group.id)">重试{{ group.label }}</button></template><text v-if="!group.loading && group.error.length == 0 && group.items.length == 0" class="report-note">本组暂无产品依据。</text><view v-for="item in group.items" :key="item.id" class="advice-product"><text class="product-name" @click="openProduct(item.id)">{{ item.product.name }} ›</text><text class="source-note">{{ item.stateLabel }} · 评估时版本 v{{ item.product.version }}</text><view v-for="reason in adviceReasons(item)" :key="reason.id"><text class="body-text">{{ reason.text }}</text><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view><text v-if="item.availability != 'available'" class="availability">{{ item.availabilityLabel }}</text></view><text class="action" @click="allMatches(group.id)">查看全部{{ group.label }} ›</text></view></template>
</template>
<text class="disclaimer">{{ report!.disclaimer }}</text>
<view class="bottom-actions"><button class="secondary" @click="start">重新评估</button><button class="secondary" @click="history">查看历史评估</button></view>
</template>
</view>
</scroll-view>
@@ -29,40 +59,148 @@
import AppHeader from '@/components/AppHeader.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { AnalysisReport, fetchAnalysisReport } from '@/packages/finance/services/records.uts'
import { clearAssessmentFlow } from '@/services/assessment-flow.uts'
import { AnalysisReport, AssessmentField, fetchAnalysisReport } from '@/packages/finance/services/records.uts'
import { MatchPage, MatchView, MatchReason, fetchMatchPage, validRecordId } from '@/packages/finance/services/matches.uts'
type ReportTab = { id: string, label: string }
type AdviceGroup = { id: string, label: string, loading: boolean, error: string, items: MatchView[] }
const { contentStyle, pageStyle } = usePageLayout()
const assessmentId = ref(''); const loading = ref(false); const error = ref('')
const report = ref<AnalysisReport | null>(null)
const tabs: ReportTab[] = [{ id: 'overview', label: '评估概况' }, { id: 'matches', label: '匹配结果' }, { id: 'improvements', label: '改善建议' }]
const assessmentId = ref(''); const tab = ref('overview'); const loading = ref(false); const error = ref(''); const routeError = ref('')
const report = ref<AnalysisReport | null>(null); const factsOpen = ref(false); const completenessOpen = ref(false)
const candidates = ref<MatchView[]>([]); const candidateLoading = ref(false); const candidateError = ref('')
const adviceGroups = ref<AdviceGroup[]>([])
let generation = 0; let visible = false
const missingItems = computed((): AssessmentField[] => report.value == null ? [] : report.value!.completenessItems.filter((item: AssessmentField): boolean => ['待补充','需核实'].includes(item.value)))
const adviceReasons = (item: MatchView): MatchReason[] => item.state == 'ineligible' ? item.blocking.concat(item.cautions) : item.cautions
const reset = () => { report.value = null; candidates.value = []; adviceGroups.value = []; candidateLoading.value = false; candidateError.value = ''; factsOpen.value = false; completenessOpen.value = false }
const current = (request: number, token: string): boolean => visible && request == generation && getSession()?.getString('token') == token
const sessionChanged = (request: number, token: string): boolean => {
if (!visible || request != generation) return true
if (getSession()?.getString('token') == token) return false
generation++; reset(); loading.value = false
if (getSession() == null) uni.redirectTo({ url: '/packages/account/login/login' })
else error.value = '账户已切换,请重新加载本人报告。'
return true
}
const verifyMatches = (page: MatchPage) => {
const value = report.value
if (value == null || page.assessmentId != assessmentId.value || page.resultVersion != value.version || page.status != 'completed' || page.snapshotId != value.matching.snapshotId || page.ruleVersion != value.matching.ruleVersion || page.orderingVersion != value.matching.orderingVersion || page.eligible != value.matching.eligible || page.ineligible != value.matching.ineligible || page.unknown != value.matching.unknown) throw new Error('产品结果与本报告的冻结版本不一致,请重新加载报告。')
}
const loadCandidates = async () => {
if (candidateLoading.value || report.value?.matching.status != 'completed') return
const request = generation; const token = getSession()?.getString('token') ?? ''
candidateLoading.value = true; candidateError.value = ''; candidates.value = []
try {
const result = await fetchMatchPage(assessmentId.value, 'eligible', '')
if (!current(request, token)) { sessionChanged(request, token); return }
verifyMatches(result); candidates.value = result.items
} catch (err) { if (!sessionChanged(request, token)) candidateError.value = err instanceof Error ? err.message : '候选读取失败' }
finally { if (request == generation) candidateLoading.value = false }
}
const loadAdvice = async (id: string) => {
const group = adviceGroups.value.find((item: AdviceGroup): boolean => item.id == id)
if (group == null || group.loading || report.value?.matching.status != 'completed') return
const request = generation; const token = getSession()?.getString('token') ?? ''
group.loading = true; group.error = ''; group.items = []
try {
const result = await fetchMatchPage(assessmentId.value, id, '')
if (!current(request, token)) { sessionChanged(request, token); return }
verifyMatches(result); group.items = result.items
} catch (err) { if (!sessionChanged(request, token)) group.error = err instanceof Error ? err.message : '产品依据读取失败' }
finally { if (request == generation) group.loading = false }
}
const loadReport = async () => {
const request = ++generation
report.value = null; error.value = ''
if (assessmentId.value.length == 0) { error.value = '请从融资档案选择已完成的评估记录。'; return }
const request = ++generation; reset(); error.value = ''; loading.value = false
if (routeError.value.length > 0) { error.value = routeError.value; return }
const session = getSession()
if (session == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
const owner = session['token'] as string
loading.value = true
const token = session.getString('token') ?? ''; loading.value = true
try {
const result = await fetchAnalysisReport(assessmentId.value)
if (visible && request == generation && getSession()?.getString('token') == owner) report.value = result
} catch (err) { if (visible && request == generation) error.value = err instanceof Error ? err.message : '报告加载失败,请重试' }
if (!current(request, token)) { sessionChanged(request, token); return }
report.value = result
if (tab.value == 'matches') loadCandidates()
if (tab.value == 'improvements') {
adviceGroups.value = [{ id: 'ineligible', label: '不符条件', loading: false, error: '', items: [] }, { id: 'unknown', label: '待核实项', loading: false, error: '', items: [] }]
if (result.matching.status == 'completed') { loadAdvice('ineligible'); loadAdvice('unknown') }
}
} catch (err) { if (!sessionChanged(request, token)) error.value = err instanceof Error ? err.message : '报告加载失败,请重试' }
finally { if (request == generation) loading.value = false }
}
// 切栏替换当前路由,刷新恢复所选栏目;只读取已保存快照,不提交评估或触发模型。
const switchTab = (value: string) => { if (value != tab.value && routeError.value.length == 0) uni.redirectTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value + '&tab=' + value }) }
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
onLoad((options) => { if (options != null) assessmentId.value = options['assessmentId'] ?? '' })
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else uni.redirectTo({ url: '/packages/finance/archive/archive' }) }
const allMatches = (group: string) => { uni.navigateTo({ url: '/packages/finance/matches/matches?assessmentId=' + assessmentId.value + '&group=' + group }) }
const openProduct = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product?assessmentId=' + assessmentId.value + '&productId=' + id + '&tab=explanation&from=report' }) }
const start = () => {
const token = getSession()?.getString('token') ?? ''; const request = generation
uni.showActionSheet({ itemList: ['个人评估','企业评估'], success: (result) => {
if (!current(request, token)) return
clearAssessmentFlow()
uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + (result.tapIndex == 1 ? 'enterprise' : 'personal') })
} })
}
onLoad((options) => {
assessmentId.value = options?.['assessmentId'] ?? ''; tab.value = options?.['tab'] ?? 'overview'
if (!validRecordId(assessmentId.value)) routeError.value = '请从融资档案选择有效的评估记录。'
if (!['overview','matches','improvements'].includes(tab.value)) routeError.value = '报告栏目参数无效,请从融资档案重新进入。'
})
onShow(() => { visible = true; loadReport() })
// 报告只留当前可见页面,账户切换或离开后不能由迟到请求恢复旧资料。
onHide(() => { visible = false; generation++; report.value = null; error.value = '' })
onUnload(() => { visible = false; generation++ })
// 离页丢弃个人数据;旧请求不得覆盖新页面或新登录账户。
onHide(() => { visible = false; generation++; reset(); loading.value = false; error.value = '' })
onUnload(() => { visible = false; generation++; reset() })
</script>
<style>
.report-content { padding: 28rpx 30rpx 80rpx; }
.tabs { height: 90rpx; flex-direction: row; background-color: #fff; border-bottom: 1rpx solid #e5ecf5; }
.tab { flex: 1; align-items: center; justify-content: center; }
.active { border-bottom: 4rpx solid #0868f7; }
.tab-label { font-size: 27rpx; color: #7b8ba0; }
.selected { color: #0868f7; font-weight: 700; }
.report-content { padding: 28rpx 28rpx 65rpx; }
.eyebrow { color: #778ba3; font-size: 22rpx; }
.headline { color: #1a3859; font-size: 36rpx; font-weight: 700; margin-top: 28rpx; }
.intro { color: #73869d; font-size: 24rpx; line-height: 40rpx; margin-top: 14rpx; }
.hero { background-color: #155dd1; border-radius: 22rpx; padding: 32rpx; margin-top: 24rpx; }
.hero-label { color: #d8e8ff; font-size: 24rpx; }
.hero-title { color: #fff; font-size: 42rpx; font-weight: 700; line-height: 60rpx; margin-top: 14rpx; }
.hero-note { color: #d8e8ff; font-size: 22rpx; line-height: 36rpx; margin-top: 12rpx; }
.hero-divider { height: 1rpx; background-color: #4983dc; margin-top: 26rpx; margin-bottom: 24rpx; }
.percent { color: #fff; font-size: 38rpx; font-weight: 700; }
.progress-track { height: 12rpx; border-radius: 6rpx; background-color: #4983dc; margin-top: 18rpx; overflow: hidden; }
.progress-fill { height: 12rpx; background-color: #bde6ff; border-radius: 6rpx; }
.panel { margin-top: 24rpx; padding: 28rpx; border-radius: 18rpx; background-color: #fff; border: 1rpx solid #e7edf6; }
.body-text { margin-top: 20rpx; font-size: 27rpx; color: #42536b; line-height: 44rpx; }
.report-note { font-size: 23rpx; line-height: 38rpx; color: #78889e; margin-top: 18rpx; }
.panel-heading { flex-direction: row; justify-content: space-between; align-items: center; }
.section-title { color: #253f5e; font-size: 29rpx; font-weight: 700; line-height: 42rpx; }
.toggle { font-size: 24rpx; color: #0868f7; padding: 10rpx; }
.body-text { margin-top: 20rpx; font-size: 26rpx; color: #425b78; line-height: 44rpx; }
.report-note { font-size: 23rpx; line-height: 38rpx; color: #78889e; margin-top: 12rpx; }
.source-note { color: #8796a8; font-size: 22rpx; line-height: 36rpx; margin-top: 16rpx; }
.fact-row { flex-direction: row; padding: 22rpx 0; border-bottom: 1rpx solid #edf1f6; }
.fact-label { flex: 1; font-size: 25rpx; color: #738198; }
.fact-value { flex: 1; text-align: right; font-size: 26rpx; color: #23344e; }
.action { padding: 26rpx 0; color: #0868f7; font-size: 27rpx; }
.readiness-value { margin-top: 20rpx; font-size: 32rpx; line-height: 48rpx; font-weight: bold; color: #23344e; }
.fact-label { flex: 1; font-size: 25rpx; line-height: 39rpx; color: #738198; }
.fact-value { flex: 1; text-align: right; font-size: 25rpx; line-height: 39rpx; color: #233e5e; }
.legacy { background-color: #fff5e7; padding: 16rpx 22rpx 24rpx; border-radius: 14rpx; margin-top: 20rpx; }
.stats { flex-direction: row; background-color: #e9f2ff; padding: 26rpx 8rpx; border-radius: 18rpx; margin-top: 26rpx; }
.stat { flex: 1; align-items: center; }
.stat-number { font-size: 39rpx; color: #1b5ebd; font-weight: 700; }
.stat-label { font-size: 22rpx; color: #5c7799; margin-top: 12rpx; }
.product-card { padding: 28rpx; border-radius: 18rpx; background-color: #fff; border: 1rpx solid #e0eaf7; margin-top: 24rpx; }
.product-name { flex: 1; color: #284566; font-size: 28rpx; line-height: 42rpx; font-weight: 700; }
.version { color: #8b9aab; font-size: 22rpx; margin-left: 14rpx; }
.demo { color: #9b762d; font-size: 22rpx; margin-top: 12rpx; }
.amount { color: #164b86; font-size: 38rpx; font-weight: 700; margin-top: 22rpx; }
.metrics { flex-direction: row; justify-content: space-between; margin-top: 16rpx; }
.metric { color: #5c7491; font-size: 23rpx; line-height: 37rpx; }
.reason { color: #6e8097; font-size: 23rpx; line-height: 38rpx; margin-top: 14rpx; }
.availability { color: #a07436; font-size: 23rpx; line-height: 38rpx; margin-top: 16rpx; }
.action { padding: 24rpx 0; color: #0868f7; font-size: 25rpx; }
.links { flex-direction: row; justify-content: space-between; }
.advice-product { border-top: 1rpx solid #e8eef6; padding-top: 24rpx; margin-top: 24rpx; }
.evidence { color: #8494a8; font-size: 22rpx; line-height: 36rpx; margin-top: 12rpx; }
.primary { background-color: #0868f7; color: #fff; font-size: 27rpx; border-radius: 12rpx; margin-top: 24rpx; }
.secondary { background-color: #eaf3ff; color: #0868f7; font-size: 25rpx; border-radius: 12rpx; margin-top: 22rpx; }
.disclaimer { color: #8897aa; font-size: 22rpx; line-height: 36rpx; margin-top: 28rpx; }
.bottom-actions { flex-direction: row; justify-content: space-between; }
.bottom-actions .secondary { width: 48%; }
</style>
-21
View File
@@ -1,5 +1,4 @@
import { API_DEMO_ENABLED, API_BASE_URL, wait, requestJson } from '@/services/api.uts'
import { demoProducts, parseProduct, FinanceProduct } from './finance.uts'
import { createAssessment } from './records.uts'
export { API_BASE_URL } from '@/services/api.uts'
@@ -7,26 +6,6 @@ export async function submitAssessment(payload: UTSJSONObject): Promise<string>
return await createAssessment(payload)
}
export async function fetchMatches(): Promise<FinanceProduct[]> {
if (API_DEMO_ENABLED) {
await wait(320)
return demoProducts.slice()
}
const result = await requestJson('/matches', 'GET', new UTSJSONObject())
const items = result['items'] as UTSJSONObject[] | null
return items == null ? [] : items.map((item: UTSJSONObject): FinanceProduct => parseProduct(item))
}
export async function fetchProduct(id: string): Promise<FinanceProduct | null> {
if (API_DEMO_ENABLED) {
await wait(180)
const products = demoProducts.filter((item: FinanceProduct): boolean => item.id == id)
return products.length > 0 ? products[0] : null
}
const result = await requestJson('/products/' + encodeURIComponent(id), 'GET', new UTSJSONObject())
return parseProduct(result)
}
export async function sendAssistantMessage(message: string): Promise<string> {
if (API_DEMO_ENABLED) {
await wait(600)
+38
View File
@@ -0,0 +1,38 @@
import { getSession } from '@/services/api.uts'
import { validRecordId, MatchDetail, MatchReason } from './matches.uts'
let scope = ''
let selected: string[] = []
export function parseCompareIds(raw: string): string[] {
if (raw.length == 0) return []
const ids = raw.split(',')
if (ids.length > 3) throw new Error('每次最多对比3个产品,请返回重新选择。')
const seen: string[] = []
ids.forEach((id: string) => {
if (!validRecordId(id) || seen.includes(id)) throw new Error('对比产品编号无效或重复,请返回重新选择。')
seen.push(id)
})
return ids
}
// 只缓存少量选择ID,不持久化产品或个人事实;换账号、会话或评估后立即隔离。
export function compareSelection(assessmentId: string): string[] {
const session = getSession()
const key = session == null ? '' : (session.getString('userId') ?? '') + ':' + (session.getString('token') ?? '') + ':' + assessmentId
if (key != scope || key.length == 0) { scope = key; selected = [] }
return selected.slice()
}
export function saveCompareSelection(assessmentId: string, ids: string[]): void {
compareSelection(assessmentId)
selected = parseCompareIds(ids.join(','))
}
export function compareQuery(assessmentId: string, ids: string[]): string {
return '?assessmentId=' + encodeURIComponent(assessmentId) + '&productIds=' + ids.join(',')
}
export function sameMatchSnapshot(a: MatchDetail, b: MatchDetail): boolean {
return a.assessmentId == b.assessmentId && a.resultVersion == b.resultVersion && a.snapshotId == b.snapshotId && a.ruleVersion == b.ruleVersion && a.orderingVersion == b.orderingVersion
}
export function compareReasonText(reasons: MatchReason[], empty: string): string {
return reasons.length == 0 ? empty : reasons.map((r: MatchReason): string => r.text + '\n' + r.evidence.join('\n')).join('\n\n')
}
+107
View File
@@ -0,0 +1,107 @@
import { requestJson } from '@/services/api.uts'
import { yuanToWan } from '@/services/money.uts'
export type ProductView = { id: string, version: number, name: string, bank: string, type: string, demo: boolean, amount: string, rate: string, term: string, objects: string, regions: string, valid: string, materials: string, source: string, sourceRef: string, checkedOn: string, conditions: string[] }
export type MatchReason = { id: string, text: string, evidence: string[], fields: string[], explanation: string }
export type MatchDetail = { assessmentId: string, resultVersion: number, snapshotId: string, ruleVersion: string, orderingVersion: string, item: MatchView }
export type MatchView = { id: string, state: string, stateLabel: string, availability: string, availabilityLabel: string, product: ProductView, positive: MatchReason[], blocking: MatchReason[], cautions: MatchReason[], aiText: string, aiStatus: string, aiCanRetry: boolean, aiGeneratedAt: string }
export type MatchPage = { assessmentId: string, resultVersion: number, snapshotId: string, ruleVersion: string, orderingVersion: string, status: string, message: string, eligible: string, ineligible: string, unknown: string, items: MatchView[], hasMore: boolean, cursor: string }
function text(data: UTSJSONObject, key: string): string { return data.getString(key) ?? '' }
export function validRecordId(value: string): boolean { return /^[1-9][0-9]{0,9}$/.test(value) }
export function eligibilityLabel(value: string): string { return value == 'eligible' ? '符合已知条件' : (value == 'ineligible' ? '条件不符' : '待核实') }
const fieldNames: UTSJSONObject = { requestedAmountYuan: '融资金额', requestedTermMonths: '融资期限', applicantType: '申请主体', purposeCode: '资金用途', cityCode: '所在城市', monthlyIncome: '月收入', annualRevenue: '年营收', operatingMonths: '经营月数', totalDebtBalance: '贷款余额', monthlyLoanPayment: '每月还款额', creditLoanCount: '贷款笔数', creditCardCount: '信用卡数量', creditCardTotalLimit: '信用卡总额度', creditCardUsedLimit: '信用卡已用额度', socialSecurity: '社保情况', housingFund: '公积金情况', house: '房产情况', car: '车辆情况' }
const labels: UTSJSONObject = { personal: '个人', enterprise: '企业', daily_consumption: '日常消费', education: '教育进修', home_improvement: '家居装修', personal_business: '个体经营周转', working_capital: '经营周转', inventory_purchase: '采购备货', equipment_purchase: '设备购置', business_expansion: '经营扩展', other: '其他用途', none: '无', under_six_months: '不足六个月', six_months_plus: '六个月及以上', yes: '有', no: '无', '440300': '深圳市', '440100': '广州市', '110100': '北京市', '310100': '上海市', '330100': '杭州市' }
const moneyFields = ['requestedAmountYuan','monthlyIncome','annualRevenue','totalDebtBalance','monthlyLoanPayment','creditCardTotalLimit','creditCardUsedLimit']
function valueText(value: any | null, field: string): string {
if (value == null) return '未提供'
const raw = value.toString()
if (moneyFields.includes(field)) return yuanToWan(raw) + ' 万元'
if (field == 'requestedTermMonths' || field == 'operatingMonths') return raw + ' 个月'
return labels.getString(raw) ?? raw
}
function ruleText(rule: UTSJSONObject): string {
const field = text(rule, 'field'); const op = text(rule, 'operator')
const prefix = fieldNames.getString(field) ?? '条件'
let expected = ''
if (op == 'in' || op == 'between') {
const values = rule['value'] as any[]
expected = values.map((value: any): string => valueText(value, field)).join(op == 'between' ? ' 至 ' : '、')
} else expected = valueText(rule['value'], field)
const operation = op == 'gte' ? '不少于 ' : (op == 'lte' ? '不超过 ' : (op == 'in' ? '属于 ' : ''))
return prefix + ':' + operation + expected + (text(rule, 'requiredSource') == 'verified' ? '(需核验依据)' : '')
}
function productView(data: UTSJSONObject, publicMode: boolean): ProductView {
const source = data['source'] as UTSJSONObject
const objects = data['applicantTypes'] as string[]; const regions = data['regions'] as string[]
const conditions: string[] = []
if (publicMode) {
const groups = data['rulesByApplicantType'] as UTSJSONObject
objects.forEach((type: string) => {
(groups[type] as UTSJSONObject[]).forEach((r: UTSJSONObject) => { conditions.push((labels.getString(type) ?? type) + ' · ' + ruleText(r)) })
})
} else (data['rules'] as UTSJSONObject[]).forEach((r: UTSJSONObject) => { conditions.push(ruleText(r)) })
return { id: text(data, 'productId'), version: data.getNumber('productVersion') ?? 0, name: text(data, 'productName'), bank: text(data, 'bankName'), type: text(data, 'productType'), demo: data.getBoolean('isDemo') ?? false,
amount: text(data, 'amountMinYuan').length == 0 || text(data, 'amountMaxYuan').length == 0 ? '未提供' : yuanToWan(text(data, 'amountMinYuan')) + '–' + yuanToWan(text(data, 'amountMaxYuan')) + ' 万元',
rate: text(data, 'annualRateMinPercent').length == 0 || text(data, 'annualRateMaxPercent').length == 0 ? '未提供' : text(data, 'annualRateMinPercent') + '%–' + text(data, 'annualRateMaxPercent') + '%',
term: data.getNumber('termMinMonths') == null || data.getNumber('termMaxMonths') == null ? '未提供' : data.getNumber('termMinMonths')!.toString() + '–' + data.getNumber('termMaxMonths')!.toString() + ' 个月',
objects: objects.map((v: string): string => labels.getString(v) ?? v).join(' / '), regions: regions.length == 0 ? '全国' : regions.map((v: string): string => labels.getString(v) ?? v).join('、'),
valid: text(data, 'validFrom') + ' 至 ' + text(data, 'validUntil'), materials: text(data, 'information').length > 0 ? text(data, 'information') : '未提供',
source: text(source, 'publisher') + ' · ' + text(source, 'title'), sourceRef: text(source, 'reference'), checkedOn: text(source, 'checkedOn'), conditions }
}
function matchView(data: UTSJSONObject): MatchView {
const evidence = data['evidence'] as UTSJSONObject[]
const ai = data['aiExplanation'] as UTSJSONObject
const explanations = (ai['reasonExplanations'] as UTSJSONObject[] | null) ?? []
const reasons = (key: string): MatchReason[] => (data[key] as UTSJSONObject[]).map((r: UTSJSONObject): MatchReason => {
const reasonFields: string[] = []
const lines = (r['evidenceRefs'] as string[]).map((ref: string): string => {
const e = evidence.find((item: UTSJSONObject): boolean => text(item, 'evidenceId') == ref)
if (e == null) return '证据暂不可读取'
const fields = e['fieldRefs'] as string[]; const field = fields.length > 0 ? fields[0] : ''
fields.forEach((name: string) => { if (!reasonFields.includes(name)) reasonFields.push(name) })
const source = text(e, 'source') == 'verified' ? '外部核验' : (text(e, 'source') == 'product_snapshot' ? '产品快照' : (text(e, 'source') == 'self_reported' ? '用户自填,未经核验' : '来源未知'))
return (fieldNames.getString(field) ?? field) + ':' + valueText(e['actual'], field) + ' · ' + source
})
const explanation = explanations.find((item: UTSJSONObject): boolean => text(item, 'reasonId') == text(r, 'reasonId'))
return { id: text(r, 'reasonId'), text: text(r, 'text'), evidence: lines, fields: reasonFields, explanation: text(ai, 'status') == 'completed' && explanation != null ? text(explanation, 'text') : '' }
})
const availability = text(data, 'currentAvailability')
return { id: text(data, 'productId'), state: text(data, 'eligibility'), stateLabel: eligibilityLabel(text(data, 'eligibility')), availability,
availabilityLabel: availability == 'available' ? '当前产品可查看' : (availability == 'unavailable' ? '当前产品已下架或不在有效期' : '当前产品状态暂无法确认'),
product: productView(data['productSnapshot'] as UTSJSONObject, false), positive: reasons('positiveReasons'), blocking: reasons('blockingReasons'), cautions: reasons('cautions'),
aiText: text(ai, 'status') == 'completed' ? text(ai, 'text') : (text(ai, 'status') == 'failed' ? 'AI解释暂不可用,已保留全部规则依据。' : '当前展示规则依据,尚未生成单产品AI解释。'),
aiStatus: text(ai, 'status'), aiCanRetry: ai.getBoolean('canRetry') ?? false, aiGeneratedAt: text(ai, 'generatedAt') }
}
export async function requestProductExplanation(id: string, productId: string, requestId: string): Promise<MatchView> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId) + '/explanation', 'POST', { requestId })
const item = matchView(data['item'] as UTSJSONObject)
if (text(data, 'assessmentId') != id || item.id != productId) throw new Error('AI解释与本次产品不一致,请重新加载。')
return item
}
export async function fetchMatchPage(id: string, group: string, cursor: string): Promise<MatchPage> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches', 'GET', { eligibility: group, limit: '3', cursor })
const matching = data['matching'] as UTSJSONObject; const status = text(matching, 'status')
if (text(data, 'assessmentId') != id) throw new Error('匹配结果与本次评估不一致,请重新加载。')
const reason = text(matching, 'reasonCode')
const count = (key: string): string => { const v = matching.getNumber(key); return v == null ? '—' : v.toString() }
const message = status == 'completed' ? '按本次评估时的产品条件判断,展示顺序不代表优劣。' : (reason == 'LEGACY_INPUT' ? '旧版报告未执行产品匹配,可重新评估获取新的结果。' : (reason == 'MATCH_RULES_UNPUBLISHED' ? '本次评估尚未启用产品匹配规则。' : '本次评估没有可用产品目录,未执行产品匹配。'))
return { assessmentId: id, resultVersion: data.getNumber('resultVersion') ?? 0, snapshotId: text(matching, 'catalogSnapshotId'), ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), status, message, eligible: count('candidateCount'), ineligible: count('ineligibleCount'), unknown: count('unknownCount'), items: (data['items'] as UTSJSONObject[]).map((v: UTSJSONObject): MatchView => matchView(v)), hasMore: data.getBoolean('hasMore') ?? false, cursor: text(data, 'nextCursor') }
}
export async function fetchMatchDetail(id: string, productId: string): Promise<MatchView> {
return (await fetchMatchDetailRecord(id, productId)).item
}
export async function fetchMatchDetailRecord(id: string, productId: string): Promise<MatchDetail> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId), 'GET', new UTSJSONObject())
const matching = data['matching'] as UTSJSONObject
const item = matchView(data['item'] as UTSJSONObject)
const version = data.getNumber('resultVersion') ?? 0
const snapshotId = text(matching, 'catalogSnapshotId')
// URL只表达选择意图;读取后再次检查归属和冻结版本,不能用公共资料补齐失败列。
if (text(data, 'assessmentId') != id || item.id != productId || item.product.id != productId || version < 1 || snapshotId.length == 0 || text(matching, 'status') != 'completed') throw new Error('产品不属于本次冻结匹配结果,请返回重新选择。')
return { assessmentId: id, resultVersion: version, snapshotId, ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), item }
}
export async function fetchPublicProduct(id: string): Promise<ProductView> {
return productView(await requestJson('/products/' + encodeURIComponent(id), 'GET', new UTSJSONObject()), true)
}
+63 -12
View File
@@ -7,7 +7,22 @@ import { demandValidationMessage } from '@/services/assessment-demand.uts'
export type AssessmentRecord = { id: string, type: string, status: string, statusLabel: string, message: string, city: string, submittedAt: string, taskId: string, stage: string, canRetry: boolean }
export type AssessmentPage = { items: AssessmentRecord[], hasMore: boolean, nextCursor: string }
export type AssessmentField = { label: string, value: string }
export type AssessmentDetail = { record: AssessmentRecord, fields: AssessmentField[] }
export type ArchiveSection = { id: string, icon: string, title: string, note: string, fields: AssessmentField[], provided: number }
export type AssessmentDetail = { record: AssessmentRecord, fields: AssessmentField[], sections: ArchiveSection[] }
export type ArchiveCompleteness = { percent: number | null, description: string }
// 档案只消费报告中已保存的完整度,不用当前表单规则重算旧评估,也不把缺失当作0%。
export async function fetchArchiveCompleteness(id: string): Promise<ArchiveCompleteness> {
const report = await requestJson('/assessments/' + encodeURIComponent(id) + '/report', 'GET', {})
if (report.getString('assessmentId') != id) throw new Error('报告与当前档案不一致')
if (report.getNumber('resultVersion') == 1) return { percent: null, description: '这份旧版报告未计算完整度,已提交资料仍可查看。' }
if (report.getNumber('resultVersion') != 2) throw new Error('报告格式暂不支持')
const completeness = report['completeness'] as UTSJSONObject | null
if (completeness == null || completeness.getString('status') != 'evaluated') return { percent: null, description: '这份报告尚未评定资料完整度。' }
const percent = completeness.getNumber('percent')
if (percent == null || !Number.isInteger(percent) || percent < 0 || percent > 100) throw new Error('完整度数据暂不可读取')
return { percent, description: '反映本次评估时的资料填写情况,不代表信用评分或审批结果。' }
}
function recordFrom(item: UTSJSONObject): AssessmentRecord {
const submitted = draftText(item, 'submittedAt')
@@ -21,11 +36,27 @@ export async function retryAssessment(id: string, requestId: string): Promise<vo
await requestJson('/assessments/' + encodeURIComponent(id) + '/retry', 'POST', { requestId })
}
export type AnalysisReport = { version: number, summary: string, facts: AssessmentField[], observations: string[], suggestions: string[], dataGaps: string[], disclaimer: string, matchingMessage: string, modelCode: string, demand: AssessmentField[], completenessText: string, completenessItems: AssessmentField[], readinessLabel: string, readinessBasis: string[], warnings: string[] }
export type ReportMatching = { status: string, snapshotId: string, ruleVersion: string, orderingVersion: string, eligible: string, ineligible: string, unknown: string }
export type AnalysisReport = { version: number, generatedAt: string, completenessPercent: number | null, matching: ReportMatching, summary: string, facts: AssessmentField[], observations: string[], suggestions: string[], dataGaps: string[], disclaimer: string, matchingMessage: string, modelCode: string, demand: AssessmentField[], completenessText: string, completenessItems: AssessmentField[], readinessLabel: string, readinessBasis: string[], warnings: string[] }
export async function fetchAnalysisReport(id: string): Promise<AnalysisReport> {
const result = await requestJson('/assessments/' + encodeURIComponent(id) + '/report', 'GET', {})
const version = result['resultVersion'] as number
if (result['assessmentId'] != id || (version != 1 && version != 2) || (version == 1 && result['reportType'] != 'self_reported_analysis') || (version == 2 && result['reportType'] != 'financing_needs_analysis')) throw new Error('报告格式不支持')
const date = new Date(draftText(result, 'generatedAt'))
const pad = (value: number): string => value < 10 ? '0' + value.toString() : value.toString()
const generatedAt = isNaN(date.getTime()) ? '未记录' : date.getFullYear().toString() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes())
const rawMatching = result['matching'] as UTSJSONObject | null
const matching: ReportMatching = { status: 'unavailable', snapshotId: '', ruleVersion: '', orderingVersion: '', eligible: '—', ineligible: '—', unknown: '—' }
if (version == 2 && rawMatching != null) {
matching.status = draftText(rawMatching, 'status')
if (!['completed','unavailable'].includes(matching.status)) throw new Error('报告匹配状态暂不可读取')
if (matching.status == 'completed') {
matching.snapshotId = draftText(rawMatching, 'catalogSnapshotId'); matching.ruleVersion = draftText(rawMatching, 'ruleVersion'); matching.orderingVersion = draftText(rawMatching, 'orderingPolicyVersion')
const count = (key: string): string => { const value = rawMatching!.getNumber(key); if (value == null || !Number.isInteger(value) || value < 0) throw new Error('报告匹配统计暂不可读取'); return value.toString() }
matching.eligible = count('candidateCount'); matching.ineligible = count('ineligibleCount'); matching.unknown = count('unknownCount')
if (matching.snapshotId.length == 0 || matching.ruleVersion.length == 0 || matching.orderingVersion.length == 0) throw new Error('报告匹配版本暂不可读取')
}
}
const facts: AssessmentField[] = []
const rawFacts = result['facts'] as UTSJSONObject[]
rawFacts.forEach((fact: UTSJSONObject) => {
@@ -54,6 +85,8 @@ export async function fetchAnalysisReport(id: string): Promise<AnalysisReport> {
{ label: '资金用途', value: draftText(demandData, 'purposeLabel') }
]
const evaluated = completeness['status'] == 'evaluated'
const completenessPercent = evaluated ? completeness.getNumber('percent') : null
if (evaluated && (completenessPercent == null || !Number.isInteger(completenessPercent) || completenessPercent < 0 || completenessPercent > 100)) throw new Error('完整度数据暂不可读取')
const completenessText = evaluated ? (completeness['percent'] as number).toString() + '% · 已填写 ' + (completeness['providedCount'] as number).toString() + ' / ' + (completeness['applicableCount'] as number).toString() + ' 项' : '资料完整度规则待配置'
// 模型文本仅显示已通过后端验证的证据引用;准备状态不由模型推导。
const explain = (key: string): string[] => (result[key] as UTSJSONObject[]).map((item: UTSJSONObject): string => {
@@ -67,7 +100,7 @@ export async function fetchAnalysisReport(id: string): Promise<AnalysisReport> {
}).filter((label: string): boolean => label.length > 0)
return draftText(item, 'text') + (labels.length > 0 ? '(依据:' + labels.join(';') + ')' : '')
})
return { version, summary: draftText(result, 'summary'), facts, observations: explain('strengths'), suggestions: explain('improvements'), dataGaps: explain('riskWarnings'),
return { version, generatedAt, completenessPercent, matching, summary: draftText(result, 'summary'), facts, observations: explain('strengths'), suggestions: explain('improvements'), dataGaps: explain('riskWarnings'),
// 新报告展示服务端冻结结果的统计;旧报告无此字段时保留原提示,不重算历史。
disclaimer: draftText(result, 'disclaimer'), matchingMessage: draftText(result, 'matchingMessage').length > 0 ? draftText(result, 'matchingMessage') : '产品资料及匹配规则尚未就绪,本报告未执行产品匹配。', modelCode: draftText(result, 'modelCode'),
demand, completenessText, completenessItems, readinessLabel: readiness['status'] == 'evaluated' ? draftText(readiness, 'label') : '未评定',
@@ -77,7 +110,7 @@ export async function fetchAnalysisReport(id: string): Promise<AnalysisReport> {
const fact = rawFacts.find((entry: UTSJSONObject): boolean => entry['id'] == item['factId'])
return (fact == null ? '' : draftText(fact, 'label') + ':') + draftText(item, 'text')
})
return { version, summary: draftText(result, 'summary'), facts, observations, suggestions: result['suggestions'] as string[], dataGaps: result['dataGaps'] as string[], disclaimer: draftText(result, 'disclaimer'), matchingMessage: draftText(result, 'matchingMessage'), modelCode: draftText(result, 'modelCode'), demand: [], completenessText: '旧版报告未计算资料完整度', completenessItems: [], readinessLabel: '未评定', readinessBasis: [], warnings: [] }
return { version, generatedAt, completenessPercent: null, matching, summary: draftText(result, 'summary'), facts, observations, suggestions: result['suggestions'] as string[], dataGaps: result['dataGaps'] as string[], disclaimer: draftText(result, 'disclaimer'), matchingMessage: draftText(result, 'matchingMessage'), modelCode: draftText(result, 'modelCode'), demand: [], completenessText: '旧版报告未计算资料完整度', completenessItems: [], readinessLabel: '未评定', readinessBasis: [], warnings: [] }
}
function enumValue(value: string, labels: string[], codes: string[]): string | null {
@@ -130,8 +163,11 @@ export async function createAssessment(payload: UTSJSONObject): Promise<string>
return id
}
export async function fetchAssessments(cursor: string): Promise<AssessmentPage> {
const result = await requestJson('/assessments', 'GET', { limit: '20', cursor })
export async function fetchAssessments(cursor: string, type: string = ''): Promise<AssessmentPage> {
const params: UTSJSONObject = { limit: '20', cursor }
// 按主体在服务端过滤后分页,不能只过滤当前页而漏掉更早的企业/个人档案。
if (type.length > 0) params['type'] = type
const result = await requestJson('/assessments', 'GET', params)
const items = result['items'] as UTSJSONObject[] | null
return { items: items == null ? [] : items.map((item: UTSJSONObject): AssessmentRecord => recordFrom(item)), hasMore: result['hasMore'] == true, nextCursor: draftText(result, 'nextCursor') }
}
@@ -145,14 +181,26 @@ export async function fetchAssessmentDetail(id: string): Promise<AssessmentDetai
const form = result['form'] as UTSJSONObject | null
if (form == null) throw new Error('评估详情不完整')
const fields: AssessmentField[] = []
const enterprise = result['type'] == 'enterprise'
const sections: ArchiveSection[] = [
{ id: 'basic', icon: 'file-text', title: '基本信息', note: enterprise ? '企业、联系人与融资需求' : '身份、联系方式与融资需求', fields: [], provided: 0 },
{ id: 'income', icon: 'income', title: enterprise ? '经营信息' : '职业与收入', note: enterprise ? '经营行业、月数与营收' : '职业、收入与缴纳情况', fields: [], provided: 0 },
{ id: 'assets', icon: 'asset', title: '资产与负债', note: '贷款、信用卡额度与房车情况', fields: [], provided: 0 },
{ id: 'authorization', icon: 'authorization', title: '资料与授权', note: '私有资料与外部查询授权', fields: [], provided: 0 }
]
const addField = (section: number, label: string, value: string, provided: boolean) => {
const field: AssessmentField = { label, value }
fields.push(field); sections[section].fields.push(field)
if (provided) sections[section].provided++
}
const keys = ['name', 'phone', 'city', 'idCard', 'enterpriseName', 'businessCode', 'occupation', 'company', 'operatingMonths', 'monthlyIncome', 'annualRevenue', 'creditLoanCount', 'totalDebtBalance', 'monthlyLoanPayment', 'creditCardCount', 'creditCardTotalLimit', 'creditCardUsedLimit', 'maritalStatus', 'socialSecurity', 'housingFund', 'house', 'car']
const legacy = result['inputVersion'] == null || result['inputVersion'] == 1
// 历史记录使用提交时冻结的用途标签,不拿当前字典重新解释旧需求。
fields.push({ label: '融资金额', value: legacy ? '当时未采集' : yuanToWan(draftText(form, 'requestedAmountYuan')) + ' 万元' } as AssessmentField)
addField(0, '融资金额', legacy ? '当时未采集' : yuanToWan(draftText(form, 'requestedAmountYuan')) + ' 万元', !legacy && form['requestedAmountYuan'] != null)
const term = form['requestedTermMonths'] as number | null
fields.push({ label: '融资期限', value: legacy || term == null ? '当时未采集' : term.toString() + ' 个月' } as AssessmentField)
fields.push({ label: '资金用途', value: legacy ? '当时未采集' : draftText(form, 'purposeLabel') } as AssessmentField)
if (form['purposeCode'] == 'other') fields.push({ label: '用途说明', value: draftText(form, 'purposeDetail') } as AssessmentField)
addField(0, '融资期限', legacy || term == null ? '当时未采集' : term.toString() + ' 个月', !legacy && term != null)
addField(0, '资金用途', legacy ? '当时未采集' : draftText(form, 'purposeLabel'), !legacy && form['purposeCode'] != null)
if (form['purposeCode'] == 'other') addField(0, '用途说明', draftText(form, 'purposeDetail'), draftText(form, 'purposeDetail').length > 0)
const labels = ['姓名 / 联系人', '手机号', '城市', '身份证号', '企业名称', '信用代码', '职业 / 行业', '工作单位', '经营月数', '月收入', '年营收', '信用贷款笔数', '欠款总余额', '贷款月还款金额', '信用卡张数', '信用卡总额度', '信用卡已用额度', '婚姻状况', '社保缴纳', '公积金缴纳', '是否有房', '是否有车']
const moneyKeys = ['monthlyIncome', 'annualRevenue', 'totalDebtBalance', 'monthlyLoanPayment', 'creditCardTotalLimit', 'creditCardUsedLimit']
const enumLabels: UTSJSONObject = { married: '已婚', single: '未婚', divorced: '离异', widowed: '丧偶', yes: '有', no: '无', six_months_plus: '连续缴纳6个月以上', under_six_months: '缴纳不足6个月', none: '未缴纳' }
@@ -165,7 +213,10 @@ export async function fetchAssessmentDetail(id: string): Promise<AssessmentDetai
const personalOnly = ['idCard', 'company', 'monthlyIncome', 'maritalStatus', 'socialSecurity', 'housingFund']
const enterpriseOnly = ['enterpriseName', 'businessCode', 'operatingMonths', 'annualRevenue']
if ((result['type'] == 'enterprise' && personalOnly.includes(key)) || (result['type'] == 'personal' && enterpriseOnly.includes(key))) return
const field: AssessmentField = { label: labels[index], value }; fields.push(field)
const incomeKeys = ['occupation', 'company', 'operatingMonths', 'monthlyIncome', 'annualRevenue', 'socialSecurity', 'housingFund']
const assetKeys = ['creditLoanCount', 'totalDebtBalance', 'monthlyLoanPayment', 'creditCardCount', 'creditCardTotalLimit', 'creditCardUsedLimit', 'house', 'car']
// “无”及数值0都是已填写,不能用真值判断遗漏零负债、零经营月数。
addField(incomeKeys.includes(key) ? 1 : (assetKeys.includes(key) ? 2 : 0), labels[index], value, raw != null && value.length > 0)
})
return { record: recordFrom(result), fields }
return { record: recordFrom(result), fields, sections }
}