Files
airz-uniapp/packages/finance/report/report.uvue
T

215 lines
26 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page" :style="pageStyle()">
<AppHeader title="我的融资报告" :customBack="true" @back="back" />
<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="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">
<ReportExport :assessmentId="assessmentId" @authrequired="login" @sessionchanged="loadReport" />
<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>
<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>
<button class="primary" @click="nextStep">下一步行动</button>
<button class="secondary" @click="consult">对报告有疑问,咨询AI</button>
<view class="bottom-actions"><button class="secondary" @click="start">重新评估</button><button class="secondary" @click="history">查看历史评估</button></view>
</template>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import AppHeader from '@/components/AppHeader.uvue'
import ReportExport from '@/packages/finance/components/ReportExport.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { assistantRoute } from '@/packages/finance/services/assistant.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 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 login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeReport=1&assessmentId=' + assessmentId.value + '&tab=' + tab.value }) }
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) 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; reset(); error.value = ''; loading.value = false
if (routeError.value.length > 0) { error.value = routeError.value; return }
const session = getSession()
if (session == null) { login(); return }
const token = session.getString('token') ?? ''; loading.value = true
try {
const result = await fetchAnalysisReport(assessmentId.value)
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' }) }
const consult = () => { uni.navigateTo({ url: assistantRoute(assessmentId.value, [], 'report') }) }
const nextStep = () => { if (report.value != null) uni.navigateTo({ url: '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + '&section=actions' }) }
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++; reset(); loading.value = false; error.value = '' })
onUnload(() => { visible = false; generation++; reset() })
</script>
<style>
.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; }
.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; 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>