feat: 支持评估版本隔离的报告、匹配及PDF导出功能

新增revisionId参数到路由、服务及导出接口,实现不同评估版本的隔离访问。删除已废弃的需求相关服务文件,移除取消采集的融资金额、期限和用途字段,更新相关页面提示文本。同时更新文档,说明评估版本、最新资料及产品展示规则的调整,支持前台正常业务名称显示,去除环境标签。
This commit is contained in:
hujun
2026-09-30 19:11:44 +08:00
parent 542c107317
commit 3b2a0139e4
34 changed files with 1389 additions and 338 deletions
+33 -9
View File
@@ -14,7 +14,7 @@
<text class="history-link">{{ historyOpen ? '收起记录' : '切换记录' }}</text>
</view>
<view v-if="historyOpen" class="history-list">
<text class="history-note">每次提交独立保存,请选择要查看的记录。</text>
<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>
@@ -50,10 +50,17 @@
</view>
</view>
</view>
<view v-if="detail != null" class="record-picker">
<view class="record-picker-head"><text class="record-title">本评估版本历史</text></view>
<view v-for="item in versions" :key="item.revisionId" class="history-row" @click="chooseVersion(item.revisionId)"><text class="history-title">第 {{ item.revisionNo }} 版 · {{ item.submittedAt }} · {{ item.statusLabel }}</text><text class="history-state">{{ detail!.record.revisionId == item.revisionId ? '当前查看' : '查看' }}</text></view>
<text v-if="versionsError.length > 0" class="error-text">{{ versionsError }}</text><text v-if="versionsLoading" class="history-note">正在读取版本…</text><text v-else-if="versionsMore || versionsError.length > 0" class="text-action" @click="moreVersions">继续读取版本</text>
</view>
<button v-if="detail != null" class="new-action" @click="editAssessment">修改资料并重新评估</button>
<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 v-if="detail != null && detail!.record.status != 'succeeded' && detail!.record.hasReport" class="new-action" @click="openPreviousReport">查看上次成功结果</button>
<button :class="detail == null ? 'primary-button main-action' : 'new-action'" :disabled="loading" @click="start">发起新的{{ type == 'personal' ? '个人' : '企业' }}评估</button>
<text class="local-note">已提交资料只读保留;再次评估会新建记录。
<text class="local-note">修改会在本评估下保存新版本;新建评估另建记录。
未提交的填写内容不会保存。</text>
</view>
</scroll-view>
@@ -65,16 +72,17 @@
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { clearAssessmentFlow } from '@/services/assessment-flow.uts'
import { AssessmentRecord, AssessmentDetail, ArchiveSection, fetchAssessments, fetchAssessmentDetail, fetchArchiveCompleteness } from '@/packages/finance/services/records.uts'
import { AssessmentRecord, AssessmentDetail, ArchiveSection, fetchAssessments, fetchAssessmentDetail, fetchArchiveCompleteness, fetchAssessmentRevisions } from '@/packages/finance/services/records.uts'
const { pageStyle, contentStyle } = usePageLayout()
const type = ref('personal'); const selectedId = ref(''); const records = ref<AssessmentRecord[]>([])
const revisionId = ref(''); const versions = ref<AssessmentRecord[]>([]); const versionsMore = ref(false); const versionsError = ref(''); const versionsLoading = ref(false); let versionsCursor = ''
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: '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 }
@@ -85,7 +93,7 @@
return section.provided > 0 ? '已记录' : '未填写'
}
const clearView = () => {
detail.value = null; records.value = []; cursor = ''; hasMore.value = false; expanded.value = ''
detail.value = null; records.value = []; versions.value = []; versionsMore.value = false; versionsError.value = ''; versionsCursor = ''; versionsLoading.value = false; cursor = ''; hasMore.value = false; expanded.value = ''
completeness.value = null; completenessFailed.value = false; reportRequest++
completenessMessage.value = '提交评估后,查看对应报告的资料完整度。'
error.value = ''; listError.value = ''; listLoading.value = false
@@ -104,7 +112,7 @@
const reportSequence = ++reportRequest
completeness.value = null; completenessFailed.value = false; completenessMessage.value = '正在读取本次报告的资料完整度…'
try {
const result = await fetchArchiveCompleteness(id)
const result = await fetchArchiveCompleteness(id, detail.value?.record.revisionId ?? revisionId.value)
if (active(request, token) && reportSequence == reportRequest) { completeness.value = result.percent; completenessMessage.value = result.description }
} catch (err) {
if (active(request, token) && reportSequence == reportRequest) {
@@ -123,6 +131,18 @@
if (active(request, token)) listError.value = err instanceof Error ? err.message : '历史记录读取失败,请重试'
} finally { if (active(request, token)) listLoading.value = false }
}
const readVersions = async (append: boolean, request: number, token: string) => {
versionsLoading.value = true; versionsError.value = ''
try {
const page = await fetchAssessmentRevisions(selectedId.value, append ? versionsCursor : '')
if (!active(request, token)) return
versions.value = append ? versions.value.concat(page.items) : page.items; versionsMore.value = page.hasMore; versionsCursor = page.nextCursor
} catch (err) { if (active(request, token)) versionsError.value = err instanceof Error ? err.message : '版本历史读取失败' }
finally { if (active(request, token)) versionsLoading.value = false }
}
const moreVersions = () => { if (!versionsLoading.value) readVersions(versions.value.length > 0, generation, owner) }
const chooseVersion = (id: string) => { uni.redirectTo({ url: '/packages/finance/archive/archive?assessmentId=' + selectedId.value + '&revisionId=' + id }) }
const editAssessment = () => { if (detail.value != null) uni.navigateTo({ url: '/packages/finance/assessment/assessment?editId=' + selectedId.value }) }
const refresh = async () => {
const request = ++generation; clearView(); loading.value = true
const session = getSession()
@@ -132,7 +152,7 @@
try {
// 显式ID先校验本人详情;失败不能自动切到最新记录,掩盖越权或已删除状态。
if (selectedId.value.length > 0) {
const result = await fetchAssessmentDetail(selectedId.value)
const result = await fetchAssessmentDetail(selectedId.value, revisionId.value)
if (!active(request, token)) return
detail.value = result; type.value = result.record.type
await readList(false, request, token)
@@ -142,7 +162,7 @@
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)
const result = await fetchAssessmentDetail(selectedId.value, revisionId.value)
if (!active(request, token)) return
detail.value = result
}
@@ -151,6 +171,7 @@
if (listError.value.length > 0) historyOpen.value = true
loading.value = false
if (detail.value != null) {
await readVersions(false, request, token)
if (detail.value!.record.status == 'succeeded') await readCompleteness(detail.value!.record.id, request, token)
else completenessMessage.value = detail.value!.record.status == 'failed' ? '本次分析未完成,暂没有报告完整度;已提交资料已保留。' : '本次分析尚未完成,报告生成后可查看完整度。'
}
@@ -167,12 +188,15 @@
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 openPreviousReport = () => { if (detail.value != null) uni.navigateTo({ url: '/packages/finance/report/report?assessmentId=' + selectedId.value + '&revisionId=' + detail.value!.record.publishedRevisionId }) }
const openAnalysis = () => {
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) })
uni.navigateTo({ url: '/packages/finance/' + page + '?assessmentId=' + encodeURIComponent(detail.value!.record.id) + (page == 'report/report' ? '&revisionId=' + detail.value!.record.revisionId : '') })
}
onLoad((options) => {
revisionId.value = options?.['revisionId'] ?? ''
if (revisionId.value.length > 0 && !/^[1-9][0-9]{0,9}$/.test(revisionId.value)) routeError = '版本编号无效,请重新选择。'
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 = '评估编号无效,请重新选择历史记录。'
+31 -47
View File
@@ -3,7 +3,8 @@
<AppHeader :title="titles[step]" :customBack="true" @back="back" />
<scroll-view scroll-y :style="contentStyle(96, false)">
<view class="assessment-content">
<text class="local-note">未提交内容不会保存,离开或刷新后需重新填写。</text><view class="progress-row"><view class="step-line"><view v-for="i in 5" :key="i" :class="i <= step + 1 ? 'step-segment done' : 'step-segment'"></view></view><text class="step-count">{{ step + 1 }}/5</text></view>
<!-- 离页清空表单时仅切换提示可见性,避免卸载期间创建原生文本节点。 -->
<text v-if="editId.length > 0" class="section-hint">修改本次评估 · 提交后保留此前版本</text><text v-show="editLoading" class="local-note">正在读取已提交资料…</text><text v-show="editError.length > 0" class="local-note">{{ editError }}</text><text class="local-note">未提交内容不会保存,离开或刷新后需重新填写。</text><view class="progress-row"><view class="step-line"><view v-for="i in 5" :key="i" :class="i <= step + 1 ? 'step-segment done' : 'step-segment'"></view></view><text class="step-count">{{ step + 1 }}/5</text></view>
<view v-if="step == 0" class="type-row"><text class="type-label">融资类型</text><view class="type-switch"><text :class="assessmentType == 'personal' ? 'type-item type-active' : 'type-item'" @click="switchType('personal')">个人</text><text :class="assessmentType == 'enterprise' ? 'type-item type-active' : 'type-item'" @click="switchType('enterprise')">企业</text></view></view>
<text v-else class="section-hint">{{ assessmentType == 'enterprise' ? '企业信用融资' : '个人信用融资' }} · {{ step == 3 ? '资料与授权均为选填' : '请按实际情况填写' }}</text>
<view v-if="step == 0" class="form-list">
@@ -29,14 +30,7 @@
<AssessmentSelect label="社保缴纳" v-model="form.socialSecurity" :options="['连续缴纳 6 个月以上', '缴纳不足 6 个月', '未缴纳']" />
<AssessmentSelect label="公积金缴纳" v-model="form.housingFund" :options="['连续缴纳 6 个月以上', '缴纳不足 6 个月', '未缴纳']" />
</template>
<text class="asset-section-title">资金需求(必填)</text>
<text class="local-note">填写您期望的金额、期限和用途,不代表机构审批结果。</text>
<view class="form-row"><text class="field-label">融资金额</text><input v-model="form.requestedAmountWan" class="field-input" type="digit" maxlength="15" placeholder="请输入期望金额" /><text class="field-unit">万元</text></view>
<view class="form-row"><text class="field-label">融资期限</text><input v-model="form.requestedTermMonths" class="field-input" type="number" maxlength="4" placeholder="请输入整数月" /><text class="field-unit">个月</text></view>
<text v-if="purposesLoading" class="local-note">正在加载资金用途…</text>
<view v-else-if="purposeError.length > 0" class="purpose-error"><text class="local-note">{{ purposeError }}</text><button class="purpose-retry" @click="loadPurposes">重新加载用途</button></view>
<AssessmentSelect v-else label="资金用途" :modelValue="form.purposeLabel" @update:modelValue="purposeChanged" :options="purposeLabels" />
<view v-if="form.purposeCode == 'other'" class="purpose-detail"><text class="field-label">用途说明</text><textarea v-model="form.purposeDetail" class="purpose-textarea" maxlength="200" placeholder="请简述资金用途(2至200字)" /></view>
</view>
<view v-else-if="step == 2" class="form-list">
<text class="asset-section-title">贷款信息</text>
@@ -61,15 +55,16 @@
<view class="scroll-spacer" :style="bottomStyle(24)"></view>
</view>
</scroll-view>
<view class="bottom-action" :style="bottomStyle(20)"><button v-if="step > 0" class="back-button" @click="previous">上一步</button><button class="primary-button next-button" @click="next">{{ step == 3 ? '确认信息' : '下一步' }}</button></view>
<view class="bottom-action" :style="bottomStyle(20)"><button v-if="step > 0" class="back-button" @click="previous">上一步</button><button class="primary-button next-button" :disabled="editLoading || editError.length > 0" @click="next">{{ step == 3 ? '确认信息' : '下一步' }}</button></view>
</view>
</template>
<script setup lang="uts">
import { getSession } from '@/services/api.uts'
import { fetchAssessmentEdit } from '@/packages/finance/services/records.uts'
import { writeAssessmentFlow } from '@/services/assessment-flow.uts'
import { wanAmountValid } from '@/services/money.uts'
import { FinancingPurpose, fetchFinancingPurposes, demandValidationMessage } from '@/services/assessment-demand.uts'
import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue'
@@ -77,47 +72,28 @@
import { AssessmentForm, SelectedDocument, DataAuthorization, emptyAssessmentForm, activeAssessmentType, getAssessmentInput, formFromInput, documentsFromInput, setAssessmentInput, showAgreementNotice, assetValidationMessage, clearAssessmentFlow } from '@/services/assessment.uts'
const { bottomStyle, contentStyle, pageStyle } = usePageLayout()
const showAgreement = (title: string) => { showAgreementNotice(title) }
const editId = ref(''); const editError = ref(''); const editLoading = ref(false)
let baseRevisionId = ''; let editGeneration = 0
const step = ref(0)
const assessmentType = ref('personal')
const form = ref<AssessmentForm>(emptyAssessmentForm())
let ownerToken = ''; let goingToConfirm = false
const sameOwner = (): boolean => { const session = getSession(); return session != null && session['token'] == ownerToken }
const purposes = ref<FinancingPurpose[]>([])
const purposesLoading = ref(false)
const purposeError = ref('')
const purposeLabels = computed((): string[] => purposes.value.filter((item) => item.type == assessmentType.value).map((item) => item.label))
const purposeChanged = (label: string) => {
const item = purposes.value.find((entry) => entry.type == assessmentType.value && entry.label == label)
if (item == null) return
form.value.purposeCode = item.code; form.value.purposeLabel = item.label
// 从其他用途切走时清空说明,避免提交隐藏的过期内容。
if (!item.requiresDetail) form.value.purposeDetail = ''
}
const loadPurposes = async () => {
if (purposesLoading.value) return
purposesLoading.value = true; purposeError.value = ''
try {
const items = await fetchFinancingPurposes()
if (sameOwner()) purposes.value = items
} catch (error) {
if (sameOwner()) purposeError.value = error instanceof Error ? error.message : '资金用途加载失败,请重试'
} finally { purposesLoading.value = false }
}
const customCity = ref('')
const cityChanged = (city: string) => { form.value.city = city; if (city != '其他城市') customCity.value = '' }
const documentConsent = ref(false)
const dataConsent = ref(false)
const documents = ref<SelectedDocument[]>([])
const authorizations = ref<DataAuthorization[]>([{ key: 'credit', name: '征信查询', selected: false }, { key: 'business', name: '经营数据', selected: false }, { key: 'fund', name: '公积金数据', selected: false }])
const titles = ['基本信息', '收入与资金需求', '资产 / 负债信息', '资料上传 / 授权']
const titles = ['基本信息', '经营 / 收入信息', '资产 / 负债信息', '资料上传 / 授权']
const uploadNames = computed((): string[] => assessmentType.value == 'enterprise' ? ['营业执照', '银行流水', '经营证明'] : ['身份证正面', '身份证反面', '银行卡', '收入证明'])
let initialized = false
const rememberInput = () => {
if (!initialized || !sameOwner()) return
const source = form.value
const currentForm: AssessmentForm = { name: source.name, idCard: source.idCard, phone: source.phone, city: source.city, maritalStatus: source.maritalStatus, occupation: source.occupation, company: source.company, monthlyIncome: source.monthlyIncome, socialSecurity: source.socialSecurity, housingFund: source.housingFund, house: source.house, car: source.car, savings: source.savings, creditCard: source.creditCard, otherAssets: source.otherAssets, creditLoanCount: source.creditLoanCount, totalDebtBalance: source.totalDebtBalance, monthlyLoanPayment: source.monthlyLoanPayment, creditCardCount: source.creditCardCount, creditCardTotalLimit: source.creditCardTotalLimit, creditCardUsedLimit: source.creditCardUsedLimit, enterpriseName: source.enterpriseName, businessCode: source.businessCode, annualRevenue: source.annualRevenue, operatingYears: source.operatingYears, operatingMonths: source.operatingMonths, requestedAmountWan: source.requestedAmountWan, requestedTermMonths: source.requestedTermMonths, purposeCode: source.purposeCode, purposeLabel: source.purposeLabel, purposeDetail: source.purposeDetail }
const currentForm: AssessmentForm = { name: source.name, idCard: source.idCard, phone: source.phone, city: source.city, maritalStatus: source.maritalStatus, occupation: source.occupation, company: source.company, monthlyIncome: source.monthlyIncome, socialSecurity: source.socialSecurity, housingFund: source.housingFund, house: source.house, car: source.car, savings: source.savings, creditCard: source.creditCard, otherAssets: source.otherAssets, creditLoanCount: source.creditLoanCount, totalDebtBalance: source.totalDebtBalance, monthlyLoanPayment: source.monthlyLoanPayment, creditCardCount: source.creditCardCount, creditCardTotalLimit: source.creditCardTotalLimit, creditCardUsedLimit: source.creditCardUsedLimit, enterpriseName: source.enterpriseName, businessCode: source.businessCode, annualRevenue: source.annualRevenue, operatingYears: source.operatingYears, operatingMonths: source.operatingMonths }
if (currentForm.city == '其他城市' && customCity.value.trim().length > 0) currentForm.city = customCity.value.trim()
setAssessmentInput(assessmentType.value, step.value, currentForm, documents.value, authorizations.value, documentConsent.value, dataConsent.value)
setAssessmentInput(assessmentType.value, step.value, currentForm, documents.value, authorizations.value, documentConsent.value, dataConsent.value, editId.value, baseRevisionId)
}
const restore = (type: string) => {
assessmentType.value = type; step.value = 0; form.value = emptyAssessmentForm(); customCity.value = ''; documents.value = []; documentConsent.value = false; dataConsent.value = false
@@ -134,7 +110,17 @@
initialized = true
rememberInput()
}
const switchType = (type: string) => { if (assessmentType.value == type) return; rememberInput(); restore(type); step.value = 0; rememberInput() }
const switchType = (type: string) => { if (editId.value.length > 0 || assessmentType.value == type) return; rememberInput(); restore(type); step.value = 0; rememberInput() }
const loadEdit = async (type: string) => {
const ticket = ++editGeneration; editLoading.value = true
try {
const saved = await fetchAssessmentEdit(editId.value)
if (ticket != editGeneration || !sameOwner()) return
baseRevisionId = saved.getString('baseRevisionId') ?? ''
writeAssessmentFlow(ownerToken, saved); restore(saved.getString('type') ?? type)
} catch (err) { if (ticket == editGeneration && sameOwner()) editError.value = err instanceof Error ? err.message : '资料读取失败,请返回重试。' }
finally { if (ticket == editGeneration) editLoading.value = false }
}
onLoad((options) => {
const session = getSession()
if (session == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
@@ -142,8 +128,10 @@
clearAssessmentFlow()
let type = activeAssessmentType()
if (options != null && (options['type'] == 'enterprise' || options['type'] == 'personal')) type = options['type'] as string
restore(type)
loadPurposes()
editId.value = options?.['editId'] ?? ''
if (editId.value.length == 0) { restore(type); return }
if (!/^[1-9][0-9]{0,9}$/.test(editId.value)) { editError.value = '评估编号无效,请返回档案重新选择。'; return }
loadEdit(type)
})
onShow(() => {
goingToConfirm = false
@@ -154,12 +142,14 @@
// 离开填写流程即丢弃未提交内容;进入确认页时只在内存传递。
onHide(() => {
if (!goingToConfirm) {
clearAssessmentFlow(); form.value = emptyAssessmentForm(); step.value = 0; documents.value = []
editGeneration++; if (editId.value.length > 0) editError.value = '已离开修改流程,请返回档案重新进入。'
// 离页清空资料但不切换步骤,避免卸载时又挂载另一组原生输入组件。
clearAssessmentFlow(); form.value = emptyAssessmentForm(); documents.value = []
customCity.value = ''; documentConsent.value = false; dataConsent.value = false
authorizations.value.forEach((item) => { item.selected = false })
}
})
onUnload(() => { initialized = false; clearAssessmentFlow() })
onUnload(() => { editGeneration++; initialized = false; clearAssessmentFlow() })
// 页内前后步骤只维护当前内存状态,返回入口后不保留填写内容。
onBackPress((): boolean => { rememberInput(); if (step.value > 0) { step.value--; rememberInput(); return true }; return false })
const previous = () => { rememberInput(); if (step.value > 0) step.value--; rememberInput() }
@@ -199,9 +189,6 @@
if (step.value == 1) {
const amount = assessmentType.value == 'enterprise' ? form.value.annualRevenue : form.value.monthlyIncome
if (!wanAmountValid(amount) || parseFloat(amount) <= 0) message = assessmentType.value == 'enterprise' ? '请输入有效年营收(万元,最多六位小数)' : '请输入有效月收入(万元,最多六位小数)'
else if (purposesLoading.value || purposeError.value.length > 0) message = '请先加载资金用途后继续'
else if (!purposes.value.some((item) => item.type == assessmentType.value && item.code == form.value.purposeCode)) message = '请选择资金用途'
else message = demandValidationMessage(form.value.requestedAmountWan, form.value.requestedTermMonths, form.value.purposeCode, form.value.purposeDetail)
}
if (step.value == 2) message = assetValidationMessage(form.value)
if (step.value == 3 && documents.value.length > 0 && !documentConsent.value) message = '请阅读并同意资料使用协议,或移除资料'
@@ -209,7 +196,7 @@
if (message.length == 0) return true
uni.showToast({ title: message, icon: 'none' }); return false
}
const next = () => { if (!validateStep()) return; if (step.value < 3) { step.value++; rememberInput() } else { rememberInput(); goingToConfirm = true; uni.navigateTo({ url: '/packages/finance/confirm/confirm', fail: () => { goingToConfirm = false } }) } }
const next = () => { if (editLoading.value || editError.value.length > 0 || !sameOwner()) return; if (!validateStep()) return; if (step.value < 3) { step.value++; rememberInput() } else { rememberInput(); goingToConfirm = true; uni.navigateTo({ url: '/packages/finance/confirm/confirm', fail: () => { goingToConfirm = false } }) } }
</script>
<style>
@@ -231,9 +218,6 @@
.form-row { min-height: 106rpx; flex-direction: row; align-items: center; border-bottom: 1rpx solid #edf1f6; }
.field-label { font-size: 28rpx; color: #29374e; margin-right: 18rpx; flex-shrink: 0; }
.field-input { flex: 1; min-width: 0; text-align: right; font-size: 27rpx; color: #22324a; }
.purpose-detail { margin-top: 26rpx; }
.purpose-textarea { width: 100%; height: 150rpx; margin-top: 16rpx; padding: 18rpx; border-radius: 12rpx; background-color: #f5f7fb; font-size: 26rpx; color: #22324a; }
.purpose-retry { margin: 14rpx 0; font-size: 24rpx; color: #0868f7; background-color: #eff6ff; }
.upload-title { font-size: 30rpx; font-weight: 600; color: #20304a; margin-top: 28rpx; }
.optional { font-size: 24rpx; color: #748198; font-weight: 400; }
.upload-desc { font-size: 24rpx; line-height: 36rpx; color: #748198; margin-top: 10rpx; }
+14 -14
View File
@@ -20,7 +20,6 @@
<template v-if="contextOpen">
<text v-if="context!.products.length == 0" class="context-note">围绕这份已保存报告解答,不重新评估</text>
<view v-for="item in context!.products" :key="item.id" class="context-product"><text class="context-product-name">{{ item.name }}</text><text :class="item.state == 'eligible' ? 'product-state good-state' : 'product-state'">{{ item.stateLabel }}</text></view>
<text v-if="hasDemo" class="context-note">演示产品 · 仅供功能验证</text>
<text class="context-link context-source" @click="source">{{ sourceLabel }}</text>
</template>
</view>
@@ -50,7 +49,7 @@
import { parseCompareIds } from '@/packages/finance/services/compare.uts'
import { AssistantProduct, AssistantContext, AssistantTurn, AssistantHistory, assistantKey, assistantRoute, previewAssistant, loadAssistant, createAssistant, assistantMessages, submitAssistant, assistantHistory } from '@/packages/finance/services/assistant.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
const assessmentId = ref(''); const productIds = ref<string[]>([]); const chatId = ref(''); const from = ref('')
const revisionId = ref(''); const assessmentId = ref(''); const productIds = ref<string[]>([]); const chatId = ref(''); const from = ref('')
const context = ref<AssistantContext | null>(null); const messages = ref<AssistantTurn[]>([])
// 展开只影响展示;切换会话或账户时恢复折叠,不改变已绑定的报告和产品。
const contextOpen = ref(false)
@@ -62,21 +61,20 @@
let historyCursor = ''; let createKey = assistantKey(); let bindingSpecified = false; let generation = 0; let scope = ''; let timer = 0; let polling = false; let pollCount = 0
let sendKey = ''; let sendText = ''; let sendAttempt = 0
const pending = computed((): boolean => messages.value.some((t: AssistantTurn): boolean => t.status == 'queued' || t.status == 'running'))
const hasDemo = computed((): boolean => context.value?.products.some((p: AssistantProduct): boolean => p.demo) ?? false)
const lastRequestId = computed((): string => messages.value.length > 0 ? messages.value[messages.value.length - 1].requestId : '')
const canSend = computed((): boolean => context.value != null && !sending.value && !pending.value && !uncertain.value && draft.value.trim().length > 0)
const sourceLabel = computed((): string => from.value == 'next-step' ? '返回下一步行动 ›' : (from.value == 'product' && productIds.value.length == 1 ? '返回产品 ›' : (from.value == 'compare' ? '返回对比 ›' : '查看报告 ›')))
const statusLabel = (item: AssistantTurn): string => item.status == 'queued' ? '问题已收到,等待AI回复…' : (item.status == 'running' ? '正在整理建议…' : (item.status == 'local' ? (sending.value ? '正在确认发送结果…' : '尚未确认发送结果,请查看下方提示。') : '回复暂未完成。' + (item.canRetry ? '您可以重新尝试。' : '已达到本条消息重试上限。')))
const source = () => {
if (assessmentId.value.length == 0) { uni.reLaunch({ url: '/pages/home/home' }); return }
let url = '/packages/finance/report/report?assessmentId=' + assessmentId.value
if (from.value == 'product' && productIds.value.length == 1) url = '/packages/finance/product/product?assessmentId=' + assessmentId.value + '&productId=' + productIds.value[0] + '&tab=explanation'
if (from.value == 'compare') url = '/packages/finance/compare/compare?assessmentId=' + assessmentId.value + '&productIds=' + productIds.value.join(',')
if (from.value == 'next-step') url = '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + (productIds.value.length == 1 ? '&productId=' + productIds.value[0] + '&section=prepare' : '&section=actions')
let url = '/packages/finance/report/report?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '')
if (from.value == 'product' && productIds.value.length == 1) url = '/packages/finance/product/product?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&productId=' + productIds.value[0] + '&tab=explanation'
if (from.value == 'compare') url = '/packages/finance/compare/compare?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&productIds=' + productIds.value.join(',')
if (from.value == 'next-step') url = '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + (productIds.value.length == 1 ? '&productId=' + productIds.value[0] + '&section=prepare' : '&section=actions')
uni.redirectTo({ url })
}
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else source() }
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeAssistant=1&assessmentId=' + assessmentId.value + '&productIds=' + productIds.value.join(',') + '&chatId=' + chatId.value + '&from=' + from.value }) }
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeAssistant=1&assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&productIds=' + productIds.value.join(',') + '&chatId=' + chatId.value + '&from=' + from.value }) }
const stopTimer = () => { if (timer != 0) { clearInterval(timer); timer = 0 } }
const clearContent = () => { context.value = null; contextOpen.value = false; messages.value = []; draft.value = ''; sendText = ''; sendKey = ''; uncertain.value = false; histories.value = []; historyCursor = ''; historyOpen.value = false; sendError.value = ''; queryError.value = '' }
// 请求代次与登录Token双重校验;离页、退出、换账号后旧响应不得回填个人对话。
@@ -112,13 +110,13 @@
scope = session.getString('token') ?? ''; const token = scope; loading.value = true
try {
if (chatId.value.length > 0) {
const chat = await loadAssistant(chatId.value, assessmentId.value, productIds.value, bindingSpecified)
const chat = await loadAssistant(chatId.value, assessmentId.value, productIds.value, bindingSpecified, revisionId.value)
if (!current(ticket, token)) return
const page = await assistantMessages(chatId.value, '')
if (!current(ticket, token)) return
context.value = chat.context; assessmentId.value = chat.context.assessmentId; productIds.value = chat.context.productIds
context.value = chat.context; revisionId.value = chat.context.revisionId; assessmentId.value = chat.context.assessmentId; productIds.value = chat.context.productIds
messages.value = page.items; more.value = page.more; cursor.value = page.cursor; scrollEnd()
} else { const data = await previewAssistant(assessmentId.value, productIds.value); if (!current(ticket, token)) return; context.value = data }
} else { const data = await previewAssistant(assessmentId.value, productIds.value, revisionId.value); if (!current(ticket, token)) return; context.value = data; revisionId.value = data.revisionId }
pollCount = 0; pollStopped.value = false; beginTimer()
} catch (error) { if (current(ticket, token)) { context.value = null; messages.value = []; pageError.value = error instanceof Error ? error.message : '对话读取失败' } }
finally { if (ticket == generation) loading.value = false }
@@ -133,14 +131,14 @@
const updateUrl = () => {
// H5仅更新地址中的ID,刷新后重新鉴权;不把消息、Token或报告写入URL/本地缓存。
// #ifdef WEB
history.replaceState(history.state, '', location.pathname + location.search + '#' + assistantRoute(assessmentId.value, productIds.value, from.value, chatId.value))
history.replaceState(history.state, '', location.pathname + location.search + '#' + assistantRoute(assessmentId.value, productIds.value, from.value, chatId.value, revisionId.value))
// #endif
}
const transmit = async () => {
if (sending.value || context.value == null) return
const ticket = generation; const token = scope; sending.value = true; sendError.value = ''; uncertain.value = true
try {
if (chatId.value.length == 0) { const chat = await createAssistant(assessmentId.value, productIds.value, createKey); if (!current(ticket, token)) return; chatId.value = chat.id; updateUrl() }
if (chatId.value.length == 0) { const chat = await createAssistant(assessmentId.value, productIds.value, createKey, revisionId.value); if (!current(ticket, token)) return; chatId.value = chat.id; updateUrl() }
const item = await submitAssistant(chatId.value, sendKey, sendText, sendAttempt)
if (!current(ticket, token)) return
merge([item]); uncertain.value = false; draft.value = ''; pollCount = 0; pollStopped.value = false; scrollEnd()
@@ -160,8 +158,10 @@
}
const showHistory = () => { historyOpen.value = !historyOpen.value; if (historyOpen.value && histories.value.length == 0) loadHistory() }
const openHistory = (id: string) => { uni.redirectTo({ url: assistantRoute('', [], 'archive', id) }) }
const newChat = () => { uni.redirectTo({ url: assistantRoute(assessmentId.value, productIds.value, from.value) }) }
const newChat = () => { uni.redirectTo({ url: assistantRoute(assessmentId.value, productIds.value, from.value, '', revisionId.value) }) }
onLoad((options) => {
revisionId.value = options?.['revisionId'] ?? '';
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError.value = '版本编号无效,请返回重新选择。'
assessmentId.value = options?.['assessmentId'] ?? ''; chatId.value = options?.['chatId'] ?? ''; from.value = options?.['from'] ?? ''; bindingSpecified = options?.['assessmentId'] != null || options?.['productIds'] != null
try {
productIds.value = parseCompareIds(options?.['productIds'] ?? '')
+14 -18
View File
@@ -27,7 +27,7 @@
<view v-for="column in columns" :key="column.id" class="overview-value"><text :class="overviewClass(column, row.id)">{{ cell(column, row.id) }}</text></view>
</view>
</view>
<text class="overview-note">{{ hasDemo ? '含演示产品,仅供功能验证。' : '' }}展示顺序不代表优劣。</text>
<text class="overview-note">展示顺序不代表优劣,具体条件以机构核验为准。</text>
<view class="details-toggle" @click="detailsOpen = !detailsOpen"><text class="summary-title">详细条件与匹配依据</text><text class="detail-toggle-label">{{ detailsOpen ? '收起' : '展开' }}</text></view>
<text v-if="detailsOpen" class="scroll-hint">左右滑动查看各产品 · 长内容可在单元格内上下滑动</text>
<view v-if="detailsOpen" class="table">
@@ -40,7 +40,6 @@
<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>
@@ -54,7 +53,7 @@
</scroll-view>
</view>
</template>
<text class="footer-note">历史参数保留评估时版本;当前可用状态单独展示。缺失信息不会被推断为零,演示产品仅供功能验证。</text>
<text class="footer-note">历史参数保留评估时版本;当前可用状态单独展示。缺失信息不会被推断为零。</text>
<button class="secondary" @click="consult">产品差异,咨询AI</button>
<button class="secondary" @click="toMatches">返回本次匹配</button>
</template>
@@ -72,13 +71,13 @@
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('')
const revisionId = ref(''); const assessmentId = ref(''); const ids = ref<string[]>([]); const columns = ref<CompareColumn[]>([]); const routeError = ref('')
const detailsOpen = ref(false)
// 首屏并排展示短参数;长证据保留原宽表,展开后读取完整内容,不截断判断依据。
const overviewRows: CompareRow[] = [{ id: 'state', label: '匹配状态', height: 0 }, { id: 'amount', label: '额度范围', height: 0 }, { id: 'term', label: '期限范围', height: 0 }, { id: 'rate', label: '年化利率', height: 0 }]
let visible = false; let generation = 0
const rows: CompareRow[] = [
{ id: 'availability', label: '当前状态', height: 160 }, { id: 'coverage', label: '需求覆盖依据', height: 360 },
{ id: 'availability', label: '当前状态', height: 160 },
{ 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 },
@@ -96,7 +95,6 @@
}
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 hasDemo = computed((): boolean => columns.value.some((c: CompareColumn): boolean => usable(c) && c.data!.item.product.demo))
const overviewClass = (column: CompareColumn, key: string): string => {
if (key == 'amount') return 'overview-text overview-amount'
if (key != 'state' || !usable(column)) return 'overview-text'
@@ -108,10 +106,6 @@
// 核心表也保留当前不可用提示,不能让历史符合条件被误读为现在仍可办理。
if (key == 'state') return m.stateLabel + (m.availability != 'available' ? ' · ' + m.availabilityLabel : '')
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, '暂无待核实提示;不代表已获审批')
@@ -134,8 +128,8 @@
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
const result = await fetchMatchDetailRecord(assessmentId.value, id, revisionId.value)
if (visible && request == generation && getSession()?.getString('token') == token) { if (revisionId.value.length == 0) revisionId.value = result.revisionId; if (revisionId.value != result.revisionId) throw new Error('评估版本已变化,请重新加载对比。'); column.data = result }
} catch (err) {
if (!visible || request != generation) return
if (getSession()?.getString('token') != token) {
@@ -150,21 +144,23 @@
}
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) })
uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, ids.value, revisionId.value) })
}
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else toMatches() }
const consult = () => { uni.navigateTo({ url: assistantRoute(assessmentId.value, ids.value, 'compare') }) }
const consult = () => { uni.navigateTo({ url: assistantRoute(assessmentId.value, ids.value, 'compare', '', revisionId.value) }) }
const remove = (id: string) => {
const next = ids.value.filter((value: string): boolean => value != id)
saveCompareSelection(assessmentId.value, next)
saveCompareSelection(assessmentId.value, next, revisionId.value)
// 用新路由替换当前页,使刷新/分享恢复实际选择,而不是已移除的旧ID。
uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, next) })
uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, next, revisionId.value) })
}
const detail = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product' + compareQuery(assessmentId.value, ids.value) + '&productId=' + id + '&tab=explanation&from=compare' }) }
const detail = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product' + compareQuery(assessmentId.value, ids.value, revisionId.value) + '&productId=' + id + '&tab=explanation&from=compare' }) }
onLoad((options) => {
revisionId.value = options?.['revisionId'] ?? '';
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError.value = '版本编号无效,请返回重新选择。'
assessmentId.value = options?.['assessmentId'] ?? ''
if (!validRecordId(assessmentId.value)) { routeError.value = '评估编号无效,请从融资档案重新选择。'; return }
try { ids.value = parseCompareIds(options?.['productIds'] ?? ''); saveCompareSelection(assessmentId.value, ids.value) }
try { ids.value = parseCompareIds(options?.['productIds'] ?? ''); saveCompareSelection(assessmentId.value, ids.value, revisionId.value) }
catch (err) { routeError.value = err instanceof Error ? err.message : '对比参数无效' }
})
onShow(() => {
+21 -9
View File
@@ -1,16 +1,22 @@
<template>
<view :class="compact ? 'export-area export-compact' : 'export-area'">
<view class="export-row"><text class="export-label">{{ compact ? '评估 #' + assessmentId + ' · 已保存' : '报告已保存至融资档案' }}</text><button class="export-button" :disabled="busy" @click="download">{{ busy ? '正在生成…' : '下载PDF' }}</button></view>
<text v-if="message.length > 0" :class="failed ? 'export-message export-error' : 'export-message'">{{ message }}</text>
<text v-if="message.length == 0 && !compact" class="export-message">导出不含身份信息,请妥善保管下载文件。</text>
<view :class="toolbar ? 'export-area export-toolbar' : (compact ? 'export-area export-compact' : 'export-area')">
<view class="export-row"><text v-if="!toolbar" class="export-label">{{ compact ? '评估 #' + assessmentId + ' · 已保存' : '报告已保存至融资档案' }}</text><button :class="toolbar ? 'export-button toolbar-button' : 'export-button'" :disabled="busy" @click="download">{{ toolbar ? (busy ? '…' : toolbarLabel) : (busy ? '正在生成…' : '下载PDF') }}</button></view>
<text v-if="message.length > 0 && !toolbar" :class="failed ? 'export-message export-error' : 'export-message'">{{ message }}</text>
<text v-if="message.length == 0 && !compact && !toolbar" class="export-message">导出不含身份信息,请妥善保管下载文件。</text>
</view>
</template>
<script setup lang="uts">
import { getSession } from '@/services/api.uts'
import { ReportDownload, downloadReportPdf } from '@/packages/finance/services/report-export.uts'
// 报告页使用紧凑入口,下载进度和错误始终展示;其他页面保留原布局。
const props = defineProps({ assessmentId: { type: String, required: true }, compact: { type: Boolean, default: false } })
const emit = defineEmits(['authrequired', 'sessionchanged'])
// 顶栏模式由报告页显示完整导出回执,避免窄导航栏中的错误消息被裁切。
const props = defineProps({ revisionId: { type: String, default: '' }, assessmentId: { type: String, required: true }, compact: { type: Boolean, default: false }, toolbar: { type: Boolean, default: false } })
const emit = defineEmits(['authrequired', 'sessionchanged', 'statuschange'])
// #ifdef MP-WEIXIN
const toolbarLabel = 'PDF'
// #endif
// #ifndef MP-WEIXIN
const toolbarLabel = '下载PDF'
// #endif
const busy = ref(false); const message = ref(''); const failed = ref(false)
let operation: ReportDownload | null = null; let generation = 0
const ownerToken = getSession()?.getString('token') ?? ''
@@ -20,15 +26,16 @@
if (getSession() == null) { emit('authrequired'); return }
if (getSession()?.getString('token') != ownerToken) { emit('sessionchanged'); return }
const request = ++generation; busy.value = true; failed.value = false; message.value = ''
if (props.toolbar) emit('statuschange', '正在生成PDF…')
const token = getSession()?.getString('token') ?? ''
operation = downloadReportPdf(props.assessmentId)
operation = downloadReportPdf(props.assessmentId, props.revisionId)
try { const result = await operation!.done; if (request == generation) message.value = result }
catch (err) {
if (request != generation) return
failed.value = true; message.value = err instanceof Error ? err.message : '下载失败,请检查网络后重试。'
if (getSession() == null) emit('authrequired')
else if (getSession()?.getString('token') != token) emit('sessionchanged')
} finally { if (request == generation) { busy.value = false; operation = null } }
} finally { if (request == generation) { busy.value = false; operation = null; if (props.toolbar) emit('statuschange', message.value) } }
}
// 父页面离开即移除报告,组件销毁时中止下载,避免隐藏页面导出旧账户资料。
onUnmounted(() => dispose())
@@ -41,4 +48,9 @@
.export-message { color: #8393a6; font-size: 22rpx; line-height: 36rpx; margin-top: 12rpx; }
.export-error { color: #a05d37; }
.export-compact { margin-top: 0; margin-bottom: 0; }
.export-toolbar { margin: 0; }
.toolbar-button { background-color: #0868f7; color: #fff; margin: 0; padding: 0 16rpx; height: 50rpx; line-height: 50rpx; font-size: 21rpx; border-radius: 12rpx; }
/* #ifdef MP-WEIXIN */
.toolbar-button { width: 32px; height: 32px; line-height: 32px; padding: 0; font-size: 11px; }
/* #endif */
</style>
+2 -12
View File
@@ -7,7 +7,7 @@
<view class="confirm-tip"><view class="tip-icon"><text class="tip-mark">✓</text></view><view class="tip-copy"><text class="tip-title">{{ assessmentType == 'enterprise' ? '企业融资信息' : '个人融资信息' }}</text><text class="tip-desc">请确认后再开始评估,可随时返回修改。</text></view></view>
<text v-if="!hasInput" class="empty-hint">还没有评估信息,请先完成信息填写。</text>
<view class="info-section" v-for="section in sections" :key="section.title"><view class="section-bar"><text class="section-name">{{ section.title }}</text><text class="edit" @click="edit(section.step)">编辑</text></view><template v-for="(row, index) in section.rows" :key="row.label"><view v-if="index < 3 || section.expanded" class="info-row"><text class="info-label">{{ row.label }}</text><text class="info-value">{{ row.value }}</text></view></template><text v-if="section.rows.length > 3" class="expand-link" @click="section.expanded = !section.expanded">{{ section.expanded ? '收起详情' : '查看其余信息' }}</text></view>
<text class="assessment-note">提交后保存评估资料并进行自填资料分析。融资需求不代表授信承诺。资料上传及外部查询暂未开放;如已选资料或授权,请先移除。不会向金融机构提交贷款申请。</text>
<text class="assessment-note">提交后保存评估资料并进行自填资料分析。评估结果不代表授信承诺。资料上传及外部查询暂未开放;如已选资料或授权,请先移除。不会向金融机构提交贷款申请。</text>
<view class="scroll-spacer" :style="bottomStyle(24)"></view>
</view>
</scroll-view>
@@ -18,7 +18,6 @@
<script setup lang="uts">
import { wanAmountValid } from '@/services/money.uts'
import { demandValidationMessage } from '@/services/assessment-demand.uts'
import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue'
import { submitAssessment } from '@/packages/finance/services/api.uts'
@@ -59,12 +58,6 @@
{ label: '职业类型', value: displayValue(saved, 'occupation') }, { label: '月收入', value: draftText(saved, 'monthlyIncome').length > 0 ? draftText(saved, 'monthlyIncome') + ' 万元' : '未填写' }, { label: '工作单位', value: displayValue(saved, 'company') }, { label: '社保缴纳', value: displayValue(saved, 'socialSecurity') }, { label: '公积金缴纳', value: displayValue(saved, 'housingFund') }
]
const files = documentsFromInput(saved)
const demandRows: InfoRow[] = [
{ label: '融资金额', value: valueWithUnit(saved, 'requestedAmountWan', '万元') },
{ label: '融资期限', value: valueWithUnit(saved, 'requestedTermMonths', '个月') },
{ label: '资金用途', value: displayValue(saved, 'purposeLabel') }
]
if (draftText(saved, 'purposeCode') == 'other') appendRow(demandRows, '用途说明', displayValue(saved, 'purposeDetail'))
const fileNames = files.map((file) => file.name).join('、')
const grants = saved['authorizations'] as string[] | null
const grantNames: string[] = []
@@ -72,7 +65,6 @@
sections.value = [
{ title: '基本信息', step: 0, rows: basicRows, expanded: false },
{ title: enterprise ? '经营信息' : '职业与收入', step: 1, rows: incomeRows, expanded: false },
{ title: '资金需求', step: 1, rows: demandRows, expanded: true },
{ title: '资产与负债', step: 2, rows: [
{ label: '信用贷款笔数', value: valueWithUnit(saved, 'creditLoanCount', '笔') },
{ label: '欠款总余额', value: valueWithUnit(saved, 'totalDebtBalance', '万元') },
@@ -108,8 +100,6 @@
if (assessmentType.value == 'personal' && !/(^[0-9]{15}$)|(^[0-9]{17}[0-9Xx]$)/.test(draftText(saved, 'idCard'))) { uni.showToast({ title: '请补全身份证信息', icon: 'none' }); edit(0); return }
if (assessmentType.value == 'enterprise' && (draftText(saved, 'enterpriseName').length < 2 || !/^[0-9A-Z]{18}$/.test(draftText(saved, 'businessCode').toUpperCase()))) { uni.showToast({ title: '请补全企业信息', icon: 'none' }); edit(0); return }
if (!wanAmountValid(amount) || parseFloat(amount) <= 0) { uni.showToast({ title: '请先完善收入信息', icon: 'none' }); edit(1); return }
const demandError = demandValidationMessage(draftText(saved, 'requestedAmountWan'), draftText(saved, 'requestedTermMonths'), draftText(saved, 'purposeCode'), draftText(saved, 'purposeDetail'))
if (demandError.length > 0) { uni.showToast({ title: demandError, icon: 'none' }); edit(1); return }
const grants = saved['authorizations'] as string[] | null
if ((documentsFromInput(saved).length > 0 && saved['documentConsent'] != true) || (grants != null && grants.length > 0 && saved['dataConsent'] != true)) { uni.showToast({ title: '请确认资料与授权选择', icon: 'none' }); edit(3); return }
const assetError = assetValidationMessage(formFromInput(saved))
@@ -118,7 +108,7 @@
try {
const id = await submitAssessment(payload.value)
clearAssessmentFlow()
// 已提交记录只读;清空页面栈,防止返回旧表单再次提交。
// 清空本次填写流程和页面栈;修改须重新读取最新版本,防止返回旧表单重复提交。
uni.reLaunch({ url: '/packages/finance/analyzing/analyzing?assessmentId=' + encodeURIComponent(id) })
}
catch (error) { uni.showToast({ title: error instanceof Error ? error.message : '提交失败,请重试', icon: 'none' }) }
+12 -9
View File
@@ -18,7 +18,7 @@
<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="badge-row"><text :class="item.state == 'eligible' ? 'badge good' : (item.state == 'ineligible' ? 'badge blocked' : 'badge pending')">{{ item.stateLabel }}</text></view>
<view class="amount-row"><text class="amount">{{ item.product.amount }}</text><text class="caption">产品额度范围</text></view>
<view class="metrics"><view class="metric"><text class="value">{{ item.product.rate }}</text><text class="caption">年化利率范围</text></view><view class="metric"><text class="value">{{ item.product.term }}</text><text class="caption">期限范围</text></view></view>
<view class="reasons"><text class="reason" v-for="reason in preview(item)" :key="reason.id">{{ reason.text }}</text></view>
@@ -43,7 +43,7 @@
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 revisionId = ref(''); const assessmentId = ref(''); const group = ref('eligible'); const matches = ref<MatchView[]>([])
const selectedIds = ref<string[]>([])
const pageActive = ref(false)
const loading = ref(false); const error = ref(''); const message = ref(''); const status = ref('')
@@ -62,8 +62,9 @@
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 : '')
const result = await fetchMatchPage(assessmentId.value, group.value, append ? cursor : '', revisionId.value)
if (!visible || request != generation || getSession()?.getString('token') != token) return
revisionId.value = result.revisionId
matches.value = append ? matches.value.concat(result.items) : result.items
message.value = result.message; status.value = result.status; eligible.value = result.eligible; ineligible.value = result.ineligible; unknown.value = result.unknown
cursor = result.cursor; hasMore.value = result.hasMore
@@ -75,7 +76,7 @@
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 report = () => { if (validRecordId(assessmentId.value)) uni.redirectTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') }); else history() }
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else if (assessmentId.value.length > 0) report(); else history() }
const toggleCompare = (id: string) => {
if (selectedIds.value.includes(id)) selectedIds.value = selectedIds.value.filter((value: string): boolean => value != id)
@@ -83,22 +84,24 @@
if (selectedIds.value.length >= 3) { uni.showToast({ title: '最多选择3个产品,请先移除一项', icon: 'none' }); return }
selectedIds.value.push(id)
}
saveCompareSelection(assessmentId.value, selectedIds.value)
saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value)
}
const openCompare = () => { if (selectedIds.value.length >= 2) uni.navigateTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value) }) }
const openProduct = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product' + compareQuery(assessmentId.value, selectedIds.value) + '&productId=' + id + '&tab=explanation&from=matches' }) }
const openCompare = () => { if (selectedIds.value.length >= 2) uni.navigateTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }) }
const openProduct = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) + '&productId=' + id + '&tab=explanation&from=matches' }) }
onLoad((options) => {
revisionId.value = options?.['revisionId'] ?? '';
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError = '版本编号无效,请返回重新选择。'
assessmentId.value = options?.['assessmentId'] ?? ''
const selected = options?.['group'] ?? 'eligible'
if (['eligible','ineligible','unknown'].includes(selected)) group.value = selected
else routeError = '匹配分组参数无效,请从报告重新进入。'
if (assessmentId.value.length > 0 && !validRecordId(assessmentId.value)) routeError = '评估编号无效,请从融资档案重新选择。'
if (options?.['productIds'] != null) {
try { saveCompareSelection(assessmentId.value, parseCompareIds(options!['productIds']!)) }
try { saveCompareSelection(assessmentId.value, parseCompareIds(options!['productIds']!), revisionId.value) }
catch (err) { routeError = err instanceof Error ? err.message : '对比参数无效' }
}
})
onShow(() => { visible = true; pageActive.value = true; selectedIds.value = compareSelection(assessmentId.value); if (getSession()?.getString('token') != owner) clear(); load(false) })
onShow(() => { visible = true; pageActive.value = true; selectedIds.value = compareSelection(assessmentId.value, revisionId.value); if (getSession()?.getString('token') != owner) clear(); load(false) })
// 先关闭内容渲染再清空资料,避免卸载途中因空数组重新挂载状态节点。
onHide(() => { visible = false; pageActive.value = false; generation++; clear(); loading.value = false; error.value = '' })
onUnload(() => { visible = false; pageActive.value = false; generation++; clear() })
+19 -18
View File
@@ -11,7 +11,7 @@
<view class="actions">
<view class="action-row" @click="prepare"><image class="action-icon" src="/packages/finance/static/action-prepare.svg" /><view class="action-copy"><text class="action-title">{{ productId.length > 0 && !canPrepare ? '核对产品条件' : '准备申请' }}</text><text class="action-note">{{ productId.length == 0 ? '选择本次匹配产品,查看材料准备说明' : '核对产品条件,了解所需材料' }}</text></view><text class="chevron">›</text></view>
<view class="action-row" @click="openReport"><image class="action-icon" src="/packages/finance/static/action-report.svg" /><view class="action-copy"><text class="action-title">查看已保存报告</text><text class="action-note">报告已保存至融资档案,可随时查看</text></view><text class="chevron">›</text></view>
<view class="action-row" @click="optimize"><image class="action-icon" src="/packages/finance/static/action-improve.svg" /><view class="action-copy"><text class="action-title">优化资料</text><text class="action-note">核对待补充与待核实项,再发起新评估</text></view><text class="chevron">›</text></view>
<view class="action-row" @click="optimize"><image class="action-icon" src="/packages/finance/static/action-improve.svg" /><view class="action-copy"><text class="action-title">优化资料</text><text class="action-note">核对待补充与待核实项,修改后重新评估</text></view><text class="chevron">›</text></view>
<view class="action-row last" @click="consult"><image class="action-icon" src="/packages/finance/static/action-chat.svg" /><view class="action-copy"><text class="action-title">咨询AI</text><text class="action-note">结合本次报告,为您解释准备方向</text></view><text class="chevron">›</text></view>
</view>
<view v-if="section == 'prepare'" id="prepare-section" class="panel">
@@ -20,7 +20,6 @@
<template v-else-if="matchError.length > 0"><text class="body">{{ matchError }}</text><button class="secondary" @click="load">重新核对产品</button></template>
<template v-else-if="match != null">
<text class="product-title">{{ match!.product.name }}</text><text class="note">{{ match!.product.bank }} · 评估时版本 v{{ match!.product.version }}</text>
<text v-if="match!.product.demo" class="demo">演示产品,仅用于功能验证,不代表真实在售产品。</text>
<text class="status">{{ match!.stateLabel }} · {{ match!.availabilityLabel }}</text>
<template v-if="canPrepare">
<text class="label">所需材料与办理说明</text><text class="body">{{ match!.product.materials }}</text>
@@ -38,10 +37,10 @@
<view v-for="item in missingItems" :key="item.label" class="field-row"><text class="field-label">{{ item.label }}</text><text class="field-state">{{ item.value }}</text></view>
<text v-if="missingItems.length == 0" class="body">{{ report!.version == 1 ? '旧版报告未记录逐项完整度,请参考以下报告提示。' : '本次报告没有列出待补充或格式需核实字段,自填资料仍需核验。' }}</text>
<text v-for="item in report!.dataGaps" :key="item" class="body">{{ item }}</text><text v-for="item in report!.suggestions" :key="item" class="body">{{ item }}</text>
<text class="note">将进入同类型空白表单,不带入旧资料。正式提交后生成新记录,原报告保持不变。</text><button class="primary" @click="restart">重新进行{{ assessmentType == 'enterprise' ? '企业' : '个人' }}评估</button>
<text class="note">将回填本评估最新提交的资料。修改后保存为新的提交版本,历史资料和报告仍可查看。</text><button class="primary" @click="restart">修改资料并重新评估</button>
</view>
<view class="notice"><text class="notice-title">重要提示</text><text class="notice-text">本页仅提供材料准备与办理引导,不向银行或其他机构提交申请。匹配结果基于自填资料,不代表授信或获批承诺;实际产品条件、材料及办理方式以机构核实为准。</text></view>
<ReportExport :assessmentId="assessmentId" @authrequired="login" @sessionchanged="load" />
<ReportExport :assessmentId="assessmentId" :revisionId="revisionId" @authrequired="login" @sessionchanged="load" />
</template>
<button class="home-button" @click="home">返回首页</button>
</view>
@@ -59,7 +58,7 @@
import { AnalysisReport, AssessmentField, fetchAnalysisReport, fetchAssessmentStatus } from '@/packages/finance/services/records.uts'
import { MatchView, fetchMatchDetailRecord, validRecordId } from '@/packages/finance/services/matches.uts'
const { pageStyle, contentStyle } = usePageLayout()
const assessmentId = ref(''); const productId = ref(''); const section = ref('actions'); const assessmentType = ref('')
const revisionId = ref(''); const assessmentId = ref(''); const productId = ref(''); const section = ref('actions'); const assessmentType = ref('')
const report = ref<AnalysisReport | null>(null); const match = ref<MatchView | null>(null)
const loading = ref(false); const error = ref(''); const matchError = ref(''); const routeError = ref(''); const improveOpen = ref(false)
const scrollTarget = ref('')
@@ -69,7 +68,7 @@
const reset = () => { report.value = null; match.value = null; assessmentType.value = ''; matchError.value = ''; loadedToken = ''; improveOpen.value = false; scrollTarget.value = '' }
const home = () => { uni.reLaunch({ url: '/pages/home/home' }) }
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeNextStep=1&assessmentId=' + assessmentId.value + '&productId=' + productId.value + '&section=' + section.value }) }
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeNextStep=1&assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&productId=' + productId.value + '&section=' + section.value }) }
// 离页或账户变更后,丢弃旧请求;重新进入必须重查归属和当前可用性。
const stale = (request: number, token: string): boolean => {
if (!visible || request != generation) return true
@@ -87,17 +86,17 @@
try {
const status = await fetchAssessmentStatus(assessmentId.value)
if (stale(request, token)) return
if (status.id != assessmentId.value || status.status != 'succeeded' || !['personal', 'enterprise'].includes(status.type)) throw new Error('请从本人已成功生成的报告进入下一步行动。')
const result = await fetchAnalysisReport(assessmentId.value)
if (status.id != assessmentId.value || !status.hasReport || !['personal', 'enterprise'].includes(status.type)) throw new Error('请从本人已成功生成的报告进入下一步行动。')
const result = await fetchAnalysisReport(assessmentId.value, revisionId.value)
if (stale(request, token)) return
report.value = result; assessmentType.value = status.type; loadedToken = token
report.value = result; revisionId.value = result.revisionId; assessmentType.value = status.type; loadedToken = token
if (productId.value.length > 0) {
try {
if (result.matching.status != 'completed') throw new Error('本报告没有可读取的产品匹配结果。')
const detail = await fetchMatchDetailRecord(assessmentId.value, productId.value)
const detail = await fetchMatchDetailRecord(assessmentId.value, productId.value, revisionId.value)
if (stale(request, token)) return
// 材料必须来自同次评估冻结产品,不允许用公共或其他报告产品补齐。
if (detail.assessmentId != assessmentId.value || detail.resultVersion != result.version || detail.snapshotId != result.matching.snapshotId || detail.ruleVersion != result.matching.ruleVersion || detail.orderingVersion != result.matching.orderingVersion) throw new Error('产品与本报告的冻结版本不一致,请重新选择产品。')
if (detail.assessmentId != assessmentId.value || detail.revisionId != result.revisionId || detail.resultVersion != result.version || detail.snapshotId != result.matching.snapshotId || detail.ruleVersion != result.matching.ruleVersion || detail.orderingVersion != result.matching.orderingVersion) throw new Error('产品与本报告的冻结版本不一致,请重新选择产品。')
match.value = detail.item
} catch (err) { if (!stale(request, token)) matchError.value = err instanceof Error ? err.message : '产品读取失败,请重试' }
}
@@ -110,12 +109,12 @@
}
}
const ready = (): boolean => !stale(generation, loadedToken) && report.value != null && !loading.value
const openReport = () => { if (ready()) uni.navigateTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value }) }
const chooseProduct = () => { if (ready() && report.value!.matching.status == 'completed') uni.navigateTo({ url: '/packages/finance/matches/matches?assessmentId=' + assessmentId.value + '&group=eligible' }) }
const openReport = () => { if (ready()) uni.navigateTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') }) }
const chooseProduct = () => { if (ready() && report.value!.matching.status == 'completed') uni.navigateTo({ url: '/packages/finance/matches/matches?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&group=eligible' }) }
const prepare = () => {
if (!ready()) return
if (section.value == 'prepare') { load(); return }
uni.redirectTo({ url: nextStepRoute(assessmentId.value, productId.value, 'prepare') })
uni.redirectTo({ url: nextStepRoute(assessmentId.value, productId.value, 'prepare', revisionId.value) })
}
const optimize = () => {
if (!ready()) return
@@ -126,21 +125,23 @@
const consult = () => {
if (!ready()) return
if (productId.value.length > 0 && match.value == null) { uni.showToast({ title: '请先重新核对产品或从报告咨询AI', icon: 'none' }); return }
uni.navigateTo({ url: assistantRoute(assessmentId.value, productId.value.length > 0 ? [productId.value] : [], 'next-step') })
uni.navigateTo({ url: assistantRoute(assessmentId.value, productId.value.length > 0 ? [productId.value] : [], 'next-step', '', revisionId.value) })
}
const restart = () => {
if (!ready() || !improveOpen.value) return
clearAssessmentFlow()
uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + assessmentType.value })
uni.navigateTo({ url: '/packages/finance/assessment/assessment?editId=' + assessmentId.value })
}
const back = () => {
if (getCurrentPages().length > 1) uni.navigateBack()
else if (validRecordId(assessmentId.value)) uni.redirectTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value })
else if (validRecordId(assessmentId.value)) uni.redirectTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') })
else history()
}
onLoad((options) => {
revisionId.value = options?.['revisionId'] ?? '';
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError.value = '版本编号无效,请返回重新选择。'
assessmentId.value = options?.['assessmentId'] ?? ''; productId.value = options?.['productId'] ?? ''; section.value = options?.['section'] ?? 'actions'
if (nextStepRoute(assessmentId.value, productId.value, section.value).length == 0) routeError.value = '页面参数无效,请从本次报告或匹配产品重新进入。'
if (nextStepRoute(assessmentId.value, productId.value, section.value, revisionId.value).length == 0) routeError.value = '页面参数无效,请从本次报告或匹配产品重新进入。'
})
onShow(() => { visible = true; load() })
onHide(() => { visible = false; generation++; reset(); loading.value = false; error.value = '' })
+23 -19
View File
@@ -14,7 +14,6 @@
<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>
@@ -47,7 +46,8 @@
<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>
<text v-if="!match!.aiCanRequest" class="ai-note">历史版本保留已保存的规则与解释。</text>
<button v-if="match!.aiCanRequest && !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>
@@ -84,11 +84,11 @@
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 { MatchView, MatchReason, ProductView, fetchMatchDetailRecord, fetchPublicProduct, validRecordId, requestProductExplanation } from '@/packages/finance/services/matches.uts'
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery } from '@/packages/finance/services/compare.uts'
import { assistantRoute } from '@/packages/finance/services/assistant.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
const assessmentId = ref(''); const productId = ref(''); const tab = ref('details')
const revisionId = ref(''); 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)
@@ -122,8 +122,9 @@
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() }
const detail = await fetchMatchDetailRecord(assessmentId.value, productId.value, revisionId.value)
const result = detail.item
if (current(request, token)) { revisionId.value = detail.revisionId; match.value = result; schedulePoll() }
} catch (err) { explanationError(request, token, err instanceof Error ? err.message : '解释状态读取失败,请重试')
} finally { if (current(request, token)) aiBusy.value = false }
}
@@ -136,7 +137,7 @@
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)
const result = await requestProductExplanation(assessmentId.value, productId.value, requestKey, revisionId.value)
if (current(request, token)) { match.value = result; requestKey = ''; schedulePoll() }
} catch (err) { explanationError(request, token, err instanceof Error ? err.message : 'AI解释请求失败,请重试')
} finally { if (current(request, token)) aiBusy.value = false }
@@ -151,38 +152,41 @@
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() }
const detail = await fetchMatchDetailRecord(assessmentId.value, productId.value, revisionId.value)
const result = detail.item
if (current(request, token)) { revisionId.value = detail.revisionId; 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
if (current(request, 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 history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
// 仅携带选择ID;助手会重新校验归属并读取冻结事实,进入页面不会调用模型。
const consult = () => { uni.navigateTo({ url: assistantRoute(assessmentId.value, [productId.value], 'product') }) }
function toMatches(): void { if (assessmentId.value.length > 0) uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, selectedIds.value) }); else history() }
const consult = () => { uni.navigateTo({ url: assistantRoute(assessmentId.value, [productId.value], 'product', '', revisionId.value) }) }
function toMatches(): void { if (assessmentId.value.length > 0) uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }); else history() }
// 替换当前路由保留栏目、来源与对比选择,刷新后仍停留在同一栏。
const switchTab = (value: string) => {
if (value == tab.value) return
const query = assessmentId.value.length > 0 ? compareQuery(assessmentId.value, selectedIds.value) + '&' : '?'
const query = assessmentId.value.length > 0 ? compareQuery(assessmentId.value, selectedIds.value, revisionId.value) + '&' : '?'
uni.redirectTo({ url: '/packages/finance/product/product' + query + 'productId=' + productId.value + '&tab=' + value + '&from=' + from })
}
// 下一步页重新核对当前可用性,避免沿用产品详情进入时的旧状态。
const prepare = () => { if (canPrepare.value) uni.navigateTo({ url: '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + '&productId=' + productId.value + '&section=prepare' }) }
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 prepare = () => { if (canPrepare.value) uni.navigateTo({ url: '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&productId=' + productId.value + '&section=prepare' }) }
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else if (from == 'compare') uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.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) })
saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value)
uni.navigateTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) })
}
onLoad((options) => {
revisionId.value = options?.['revisionId'] ?? '';
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError = '版本编号无效,请返回重新选择。'
assessmentId.value = options?.['assessmentId'] ?? ''; const oldId = options?.['id'] ?? ''
productId.value = options?.['productId'] ?? oldId
if (options?.['assessmentId'] != null && !validRecordId(assessmentId.value)) routeError = '评估编号无效,请从报告重新进入。'
@@ -192,11 +196,11 @@
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']!)) }
try { saveCompareSelection(assessmentId.value, parseCompareIds(options!['productIds']!), revisionId.value) }
catch (err) { routeError = err instanceof Error ? err.message : '对比参数无效' }
}
})
onShow(() => { visible = true; pageActive.value = true; selectedIds.value = compareSelection(assessmentId.value); load() })
onShow(() => { visible = true; pageActive.value = true; selectedIds.value = compareSelection(assessmentId.value, revisionId.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>
+115 -54
View File
@@ -1,34 +1,38 @@
<template>
<view class="page" :style="pageStyle()">
<AppHeader title="我的融资报告" :customBack="true" @back="back" />
<view class="status-bar" :style="statusStyle(0)"></view>
<view class="report-header">
<view class="header-back" @click="back"><AppIcon name="chevron-right" :size="40" class="back-icon" /></view>
<view class="header-title-area" :style="reportTitleStyle()"><text class="header-title">我的融资报告</text></view>
<view v-if="report != null" class="header-export" :style="navigationActionStyle(96, 1)"><ReportExport :assessmentId="assessmentId" :revisionId="revisionId" :toolbar="true" @authrequired="login" @sessionchanged="loadReport" @statuschange="exportStatus" /></view>
</view>
<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">
<scroll-view scroll-y :style="contentStyle(report == null ? 178 : 314, false)">
<view class="report-content" :style="bottomStyle(24)">
<!-- 离页清空报告时只切换可见性,避免在页面销毁过程中重新挂载文本节点。 -->
<text v-show="report == null" 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" :compact="true" @authrequired="login" @sessionchanged="loadReport" />
<text class="metadata-toggle" @click="metadataOpen = !metadataOpen">用户自填资料分析 · 报告信息{{ metadataOpen ? '收起' : '展开' }}</text>
<view v-if="metadataOpen" class="metadata"><text class="report-note">生成于 {{ report!.generatedAt }}</text><text class="report-note">报告已保存至融资档案。导出不含身份信息,请妥善保管下载文件。</text></view>
<text v-if="report!.isPreviousResult" class="revision-notice">当前查看第 {{ report!.revisionNo }} 版结果。最新提交{{ report!.currentStatus == 'processing' ? '正在评估,成功后更新结果。' : (report!.currentStatus == 'failed' ? '评估失败,已保留上次成功结果。' : '已有新版本,可从档案查看。') }}</text>
<text v-show="downloadStatus.length > 0" class="download-status">{{ downloadStatus }}</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 :class="report!.readinessLabel == '可进入资料核对' ? 'readiness-band ready' : 'readiness-band attention'"><text class="readiness-label">融资准备状态</text><text class="readiness-title">{{ report!.readinessLabel }}</text></view>
<text class="summary">{{ report!.summary }}</text>
<view class="priority-section"><text class="section-title">{{ priorityItems.length > 0 ? '需重点完善的 ' + priorityItems.slice(0, 3).length.toString() + ' 项' : '资料核验与准备' }}</text><view v-for="(item, index) in priorityItems.slice(0, 3)" :key="item" class="priority-row"><text class="priority-number">{{ index + 1 }}</text><text class="priority-text">{{ item }}</text></view><text v-if="priorityItems.length == 0" class="report-note">本次暂无额外改善项,请按机构要求核实资料。</text><text v-if="priorityItems.length > 3" class="more-advice" @click="switchTab('improvements')">查看全部 {{ priorityItems.length }} 项改善建议 ›</text></view>
<!-- 首屏保持原型的状态、摘要和编号改善项;完整度与所有冻结依据按需展开。 -->
<view class="details-toggle" @click="detailsOpen = !detailsOpen"><text class="details-label">查看完整报告</text><text class="details-action">{{ detailsOpen ? '收起' : '展开' }}</text></view>
<template v-if="detailsOpen">
<view v-if="report!.suggestions.length > 0" class="panel"><text class="section-title">改善建议与依据</text><text v-for="item in report!.suggestions" :key="item" class="body-text">{{ item }}</text></view>
<view class="panel"><view class="panel-heading"><text class="section-title">资料完整度</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="report-note">{{ report!.completenessText }}</text><text class="report-note">完整度不代表信用评分、资料真实性或获批概率。</text></view>
<view v-if="report!.demand.length > 0" 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 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>
<template v-else-if="tab == 'matches'">
<text class="headline">本次需求的可选方向</text><text class="intro">以下为评估时的条件判断。展示顺序不代表优劣,也不代表机构审批。</text>
@@ -39,29 +43,31 @@
<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>
<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 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>
<text class="headline">改善建议</text><text class="intro">依据本次报告整理 · 改善不保证获批</text>
<view class="priority-section"><text class="section-title">{{ priorityItems.length > 0 ? '需重点完善的 ' + priorityItems.length.toString() + ' 项' : '资料核验与准备' }}</text><view v-for="(item, index) in priorityItems" :key="item" class="priority-row"><text class="priority-number">{{ index + 1 }}</text><text class="priority-text">{{ item }}</text></view><text v-if="priorityItems.length == 0" class="report-note">本次暂无额外改善项,请按机构要求核实资料。</text></view>
<view v-if="report!.suggestions.length > 0" class="panel"><view class="panel-heading" @click="detailsOpen = !detailsOpen"><text class="section-title">建议依据</text><text class="toggle">{{ detailsOpen ? '收起' : '展开' }}</text></view><template v-if="detailsOpen"><text v-for="item in report!.suggestions" :key="item" class="body-text">{{ item }}</text></template></view>
<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>
<view class="report-links"><text class="report-link" @click="nextStep">下一步行动 ›</text><text class="report-link" @click="consult">咨询AI ›</text></view>
<view class="details-toggle" @click="metadataOpen = !metadataOpen"><text class="metadata-toggle">报告信息</text><text class="details-action">{{ metadataOpen ? '收起' : '展开' }}</text></view>
<view v-if="metadataOpen" class="metadata"><text class="report-note">评估 #{{ assessmentId }} · 第 {{ report!.revisionNo }} 版 · {{ report!.generatedAt }}</text><text class="report-note">分析来源:用户自填资料 · {{ report!.modelCode }}</text><text class="report-note">报告已保存至融资档案。导出不含身份信息,请妥善保管下载文件。</text><text class="disclaimer">{{ report!.disclaimer }}</text><view class="report-links"><text class="report-link" @click="start">发起新评估</text><text class="report-link" @click="history">历史评估</text></view></view>
<text v-show="!metadataOpen" class="disclaimer">自填资料未经核验,结果仅供参考,不代表授信或获批。</text>
</template>
</view>
</scroll-view>
<view v-if="report != null" class="report-footer" :style="bottomStyle(24)"><button class="reevaluate-button" @click="editAssessment">重新评估</button></view>
</view>
</template>
<script setup lang="uts">
import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue'
import ReportExport from '@/packages/finance/components/ReportExport.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
@@ -71,19 +77,40 @@
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 { contentStyle, pageStyle, statusStyle, bottomStyle, navigationTitleStyle, navigationActionStyle } = usePageLayout()
const reportTitleStyle = (): string => {
// 小程序标题须同时避让PDF按钮和右侧胶囊,窄屏也不能覆盖操作入口。
// #ifdef MP-WEIXIN
const windowWidth = uni.getWindowInfo().windowWidth
const capsule = uni.getMenuButtonBoundingClientRect()
const reserved = capsule.left > 0 && capsule.left < windowWidth ? windowWidth - capsule.left + 8 : 104
return 'left:50px;right:' + (reserved + 40) + 'px;'
// #endif
// #ifndef MP-WEIXIN
return navigationTitleStyle()
// #endif
}
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 revisionId = ref(''); 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 metadataOpen = ref(false)
const detailsOpen = ref(false); const downloadStatus = ref('')
const exportStatus = (message: string) => { downloadStatus.value = message }
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 login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeReport=1&assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&tab=' + tab.value }) }
const missingItems = computed((): AssessmentField[] => report.value == null ? [] : report.value!.completenessItems.filter((item: AssessmentField): boolean => ['待补充','需核实'].includes(item.value)))
const priorityItems = computed((): string[] => {
const value = report.value
if (value == null) return []
// 使用已保存建议及真实缺失项,不补造原型中的固定三条或融资评分。
const items = value.suggestionSummaries.concat(missingItems.value.map((item: AssessmentField): string => (item.value == '待补充' ? '补充' : '核实') + item.label))
return items.filter((item: string, index: number): boolean => item.trim().length > 0 && items.indexOf(item) == index)
})
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; metadataOpen.value = false }
const reset = () => { report.value = null; candidates.value = []; adviceGroups.value = []; candidateLoading.value = false; candidateError.value = ''; factsOpen.value = false; completenessOpen.value = false; metadataOpen.value = false; detailsOpen.value = false; downloadStatus.value = '' }
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
@@ -95,14 +122,14 @@
}
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('产品结果与本报告的冻结版本不一致,请重新加载报告。')
if (value == null || page.assessmentId != assessmentId.value || page.revisionId != value.revisionId || 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', '')
const result = await fetchMatchPage(assessmentId.value, 'eligible', '', revisionId.value)
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 : '候选读取失败' }
@@ -114,7 +141,7 @@
const request = generation; const token = getSession()?.getString('token') ?? ''
group.loading = true; group.error = ''; group.items = []
try {
const result = await fetchMatchPage(assessmentId.value, id, '')
const result = await fetchMatchPage(assessmentId.value, id, '', revisionId.value)
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 : '产品依据读取失败' }
@@ -127,9 +154,9 @@
if (session == null) { login(); return }
const token = session.getString('token') ?? ''; loading.value = true
try {
const result = await fetchAnalysisReport(assessmentId.value)
const result = await fetchAnalysisReport(assessmentId.value, revisionId.value)
if (!current(request, token)) { sessionChanged(request, token); return }
report.value = result
report.value = result; revisionId.value = result.revisionId
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: [] }]
@@ -139,13 +166,14 @@
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 switchTab = (value: string) => { if (value != tab.value && routeError.value.length == 0) uni.redirectTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&tab=' + value }) }
const editAssessment = () => { uni.navigateTo({ url: '/packages/finance/assessment/assessment?editId=' + assessmentId.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 consult = () => { uni.navigateTo({ url: assistantRoute(assessmentId.value, [], 'report', '', revisionId.value) }) }
const nextStep = () => { if (report.value != null) uni.navigateTo({ url: '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.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 allMatches = (group: string) => { uni.navigateTo({ url: '/packages/finance/matches/matches?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&group=' + group }) }
const openProduct = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&productId=' + id + '&tab=explanation&from=report' }) }
const start = () => {
const token = getSession()?.getString('token') ?? ''; const request = generation
uni.showActionSheet({ itemList: ['个人评估','企业评估'], success: (result) => {
@@ -155,6 +183,8 @@
} })
}
onLoad((options) => {
revisionId.value = options?.['revisionId'] ?? '';
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError.value = '版本编号无效,请返回重新选择。'
assessmentId.value = options?.['assessmentId'] ?? ''; tab.value = options?.['tab'] ?? 'overview'
if (!validRecordId(assessmentId.value)) routeError.value = '请从融资档案选择有效的评估记录。'
if (!['overview','matches','improvements'].includes(tab.value)) routeError.value = '报告栏目参数无效,请从融资档案重新进入。'
@@ -165,28 +195,54 @@
onUnload(() => { visible = false; generation++; reset() })
</script>
<style>
.tabs { height: 90rpx; flex-direction: row; background-color: #fff; border-bottom: 1rpx solid #e5ecf5; }
.page { background-color: #fff; }
.status-bar { background-color: #fff; }
.report-header { height: 96rpx; padding: 0 28rpx; flex-direction: row; align-items: center; justify-content: space-between; position: relative; background-color: #fff; }
.header-back { width: 64rpx; height: 96rpx; justify-content: center; align-items: flex-start; }
.back-icon { transform: rotate(180deg); }
.header-title-area { position: absolute; left: 140rpx; right: 140rpx; top: 0; bottom: 0; align-items: center; justify-content: center; }
.header-title { color: #17243b; font-size: 32rpx; font-weight: 700; }
.header-export { align-items: center; justify-content: center; }
/* #ifdef MP-WEIXIN */
.header-title { font-size: 27rpx; }
/* #endif */
.tabs { height: 82rpx; flex-direction: row; background-color: #fff; padding: 0 28rpx; border-bottom: 1rpx solid #eff2f6; }
.tab { flex: 1; align-items: center; justify-content: center; }
.active { border-bottom: 4rpx solid #0868f7; }
.tab-label { font-size: 27rpx; color: #7b8ba0; }
.tab-label { font-size: 26rpx; color: #737e90; }
.selected { color: #0868f7; font-weight: 700; }
.report-content { padding: 28rpx 28rpx 65rpx; }
.revision-notice { font-size: 24rpx; line-height: 38rpx; color: #886934; padding: 18rpx 22rpx; background-color: #fff8eb; border-radius: 12rpx; margin-bottom: 12rpx; }
.download-status { font-size: 23rpx; line-height: 36rpx; color: #547292; padding-bottom: 18rpx; }
.report-content { padding: 28rpx 30rpx 24rpx; }
.eyebrow { color: #778ba3; font-size: 22rpx; }
.headline { color: #1a3859; font-size: 34rpx; font-weight: 700; margin-top: 22rpx; }
.intro { color: #73869d; font-size: 24rpx; line-height: 40rpx; margin-top: 14rpx; }
.metadata-toggle { color: #78889e; font-size: 21rpx; line-height: 36rpx; padding-top: 8rpx; padding-bottom: 4rpx; }
.metadata-toggle { color: #8893a2; font-size: 22rpx; line-height: 36rpx; }
.metadata { padding: 0 0 12rpx; }
.hero { background-color: #eef5ff; border: 1rpx solid #dfeafb; border-radius: 18rpx; padding: 26rpx; margin-top: 18rpx; }
.hero-label { color: #627c9c; font-size: 24rpx; }
.hero-title { color: #165cb9; font-size: 42rpx; font-weight: 700; line-height: 56rpx; margin-top: 8rpx; }
.hero-note { color: #7086a1; font-size: 22rpx; line-height: 34rpx; margin-top: 8rpx; }
.hero-divider { height: 1rpx; background-color: #d9e5f6; margin-top: 18rpx; margin-bottom: 16rpx; }
.readiness-band { padding: 28rpx 30rpx; min-height: 154rpx; background-color: #eef9f3; }
.attention { background-color: #fff8ed; }
.readiness-label { color: #526272; font-size: 25rpx; line-height: 38rpx; }
.readiness-title { color: #17a46b; font-size: 46rpx; line-height: 64rpx; font-weight: 700; margin-top: 9rpx; }
.attention .readiness-title { color: #a57925; }
.summary { font-size: 26rpx; line-height: 44rpx; color: #4d5869; margin-top: 24rpx; }
.priority-section { margin-top: 30rpx; }
.priority-row { flex-direction: row; align-items: flex-start; padding: 24rpx 0; border-bottom: 1rpx solid #edf0f4; min-height: 88rpx; }
.priority-number { width: 36rpx; height: 36rpx; border-radius: 18rpx; background-color: #f66f4e; text-align: center; color: #fff; font-size: 22rpx; line-height: 36rpx; margin-right: 18rpx; margin-top: 2rpx; flex-shrink: 0; }
.priority-text { flex: 1; font-size: 26rpx; line-height: 40rpx; color: #485568; }
.more-advice { font-size: 23rpx; line-height: 38rpx; color: #0868f7; margin-top: 18rpx; }
.details-toggle { flex-direction: row; align-items: center; justify-content: space-between; padding: 22rpx 0; border-bottom: 1rpx solid #edf0f4; }
.details-label { font-size: 24rpx; color: #738094; }
.details-action { font-size: 23rpx; color: #8794a5; }
.report-links { flex-direction: row; align-items: center; justify-content: space-between; margin-top: 22rpx; }
.report-link { font-size: 24rpx; line-height: 38rpx; color: #4e77aa; padding: 8rpx 0; }
.report-footer { position: fixed; left: 0; right: 0; bottom: 0; padding: 20rpx 30rpx 24rpx; background-color: #fff; }
.reevaluate-button { width: 100%; height: 92rpx; line-height: 92rpx; font-size: 28rpx; font-weight: 600; border-radius: 14rpx; background-color: #0868f7; color: #fff; margin: 0; }
.percent { color: #285c9d; font-size: 30rpx; font-weight: 700; }
.progress-track { height: 8rpx; border-radius: 4rpx; background-color: #dbe7f8; margin-top: 12rpx; overflow: hidden; }
.progress-fill { height: 8rpx; background-color: #4c8beb; border-radius: 4rpx; }
.panel { margin-top: 24rpx; padding: 28rpx; border-radius: 18rpx; background-color: #fff; border: 1rpx solid #e7edf6; }
.panel { margin-top: 28rpx; padding: 0 0 24rpx; border-bottom: 1rpx solid #edf0f4; }
.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; }
.section-title { color: #233750; font-size: 28rpx; 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; }
@@ -195,11 +251,11 @@
.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; }
.stats { flex-direction: row; padding: 26rpx 8rpx; border-top: 1rpx solid #edf0f4; border-bottom: 1rpx solid #edf0f4; 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-card { padding: 28rpx; border-radius: 14rpx; 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; }
@@ -214,7 +270,12 @@
.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%; }
.disclaimer { color: #939caa; font-size: 21rpx; line-height: 34rpx; margin-top: 18rpx; }
/* #ifndef APP */
@media (min-width: 768px) {
.report-content { max-width: 720px; margin: 0 auto; }
.report-footer { align-items: center; }
.reevaluate-button { max-width: 690px; }
}
/* #endif */
</style>
+10 -9
View File
@@ -1,6 +1,7 @@
import { revisionParams } from './records.uts'
import { requestJson } from '@/services/api.uts'
export type AssistantProduct = { id: string, name: string, state: string, stateLabel: string, demo: boolean }
export type AssistantContext = { assessmentId: string, productIds: string[], title: string, products: AssistantProduct[], questions: string[], notice: string }
export type AssistantContext = { revisionId: string, assessmentId: string, productIds: string[], title: string, products: AssistantProduct[], questions: string[], notice: string }
export type AssistantChat = { id: string, title: string, context: AssistantContext }
export type AssistantTurn = { id: string, requestId: string, message: string, reply: string, status: string, attempt: number, canRetry: boolean }
export type AssistantPage = { items: AssistantTurn[], more: boolean, cursor: string }
@@ -8,24 +9,24 @@ export type AssistantHistory = { id: string, title: string, createdAt: string }
export type AssistantHistoryPage = { items: AssistantHistory[], more: boolean, cursor: string }
function text(data: UTSJSONObject, key: string): string { return data.getString(key) ?? '' }
function contextView(data: UTSJSONObject): AssistantContext {
return { assessmentId: text(data, 'assessmentId'), productIds: data['productIds'] as string[], title: text(data, 'title'),
return { revisionId: text(data, 'revisionId'), assessmentId: text(data, 'assessmentId'), productIds: data['productIds'] as string[], title: text(data, 'title'),
products: (data['products'] as UTSJSONObject[]).map((p: UTSJSONObject): AssistantProduct => ({ id: text(p, 'id'), name: text(p, 'name'), state: text(p, 'state'), stateLabel: text(p, 'stateLabel'), demo: p.getBoolean('isDemo') ?? false })),
questions: data['questions'] as string[], notice: text(data, 'notice') }
}
function chatView(data: UTSJSONObject): AssistantChat { return { id: text(data, 'chatId'), title: text(data, 'title'), context: contextView(data['context'] as UTSJSONObject) } }
function turnView(data: UTSJSONObject): AssistantTurn { return { id: text(data, 'id'), requestId: text(data, 'requestId'), message: text(data, 'message'), reply: text(data, 'reply'), status: text(data, 'status'), attempt: data.getNumber('attempt') ?? 0, canRetry: data.getBoolean('canRetry') ?? false } }
function contextQuery(assessmentId: string, ids: string[]): UTSJSONObject { return { contextAssessmentId: assessmentId, contextProductIds: ids.join(',') } }
function contextQuery(assessmentId: string, ids: string[], revisionId: string): UTSJSONObject { return { ...revisionParams(revisionId), contextAssessmentId: assessmentId, contextProductIds: ids.join(',') } }
export function assistantKey(): string { return 'chat_' + Date.now().toString() + '_' + Math.floor(Math.random() * 1000000000).toString() }
export function assistantRoute(assessmentId: string, ids: string[], from: string, chatId: string = ''): string {
return '/packages/finance/assistant/assistant?from=' + from + (assessmentId.length > 0 ? '&assessmentId=' + assessmentId + '&productIds=' + ids.join(',') : '') + (chatId.length > 0 ? '&chatId=' + chatId : '')
export function assistantRoute(assessmentId: string, ids: string[], from: string, chatId: string = '', revisionId: string = ''): string {
return '/packages/finance/assistant/assistant?from=' + from + (assessmentId.length > 0 ? '&assessmentId=' + assessmentId + '&productIds=' + ids.join(',') : '') + (chatId.length > 0 ? '&chatId=' + chatId : '') + (revisionId.length > 0 ? '&revisionId=' + revisionId : '')
}
export async function previewAssistant(assessmentId: string, ids: string[]): Promise<AssistantContext> { return contextView(await requestJson('/assistant/context', 'GET', contextQuery(assessmentId, ids))) }
export async function loadAssistant(chatId: string, assessmentId: string, ids: string[], checkBinding: boolean): Promise<AssistantChat> {
const result = chatView(await requestJson('/assistant/chats/' + chatId, 'GET', checkBinding ? contextQuery(assessmentId, ids) : new UTSJSONObject()))
export async function previewAssistant(assessmentId: string, ids: string[], revisionId: string = ''): Promise<AssistantContext> { return contextView(await requestJson('/assistant/context', 'GET', contextQuery(assessmentId, ids, revisionId))) }
export async function loadAssistant(chatId: string, assessmentId: string, ids: string[], checkBinding: boolean, revisionId: string = ''): Promise<AssistantChat> {
const result = chatView(await requestJson('/assistant/chats/' + chatId, 'GET', checkBinding ? contextQuery(assessmentId, ids, revisionId) : new UTSJSONObject()))
if (result.id != chatId) throw new Error('返回的会话不一致,请重新加载。')
return result
}
export async function createAssistant(assessmentId: string, ids: string[], requestId: string): Promise<AssistantChat> { return chatView(await requestJson('/assistant/chats', 'POST', { contextAssessmentId: assessmentId.length > 0 ? assessmentId : null, contextProductIds: ids, requestId })) }
export async function createAssistant(assessmentId: string, ids: string[], requestId: string, revisionId: string = ''): Promise<AssistantChat> { return chatView(await requestJson('/assistant/chats', 'POST', { ...revisionParams(revisionId), contextAssessmentId: assessmentId.length > 0 ? assessmentId : null, contextProductIds: ids, requestId })) }
export async function assistantMessages(chatId: string, cursor: string): Promise<AssistantPage> {
const data = await requestJson('/assistant/chats/' + chatId + '/messages', 'GET', { limit: '20', cursor })
if (text(data, 'chatId') != chatId) throw new Error('消息不属于本次会话。')
+7 -7
View File
@@ -17,21 +17,21 @@ export function parseCompareIds(raw: string): string[] {
}
// 只缓存少量选择ID,不持久化产品或个人事实;换账号、会话或评估后立即隔离。
export function compareSelection(assessmentId: string): string[] {
export function compareSelection(assessmentId: string, revisionId: string = ''): string[] {
const session = getSession()
const key = session == null ? '' : (session.getString('userId') ?? '') + ':' + (session.getString('token') ?? '') + ':' + assessmentId
const key = session == null ? '' : (session.getString('userId') ?? '') + ':' + (session.getString('token') ?? '') + ':' + assessmentId + ':' + revisionId
if (key != scope || key.length == 0) { scope = key; selected = [] }
return selected.slice()
}
export function saveCompareSelection(assessmentId: string, ids: string[]): void {
compareSelection(assessmentId)
export function saveCompareSelection(assessmentId: string, ids: string[], revisionId: string = ''): void {
compareSelection(assessmentId, revisionId)
selected = parseCompareIds(ids.join(','))
}
export function compareQuery(assessmentId: string, ids: string[]): string {
return '?assessmentId=' + encodeURIComponent(assessmentId) + '&productIds=' + ids.join(',')
export function compareQuery(assessmentId: string, ids: string[], revisionId: string = ''): string {
return '?assessmentId=' + encodeURIComponent(assessmentId) + '&productIds=' + ids.join(',') + (revisionId.length > 0 ? '&revisionId=' + revisionId : '')
}
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
return a.assessmentId == b.assessmentId && a.revisionId == b.revisionId && 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')
+20 -18
View File
@@ -1,11 +1,12 @@
import { revisionParams } from './records.uts'
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 }
export type MatchDetail = { assessmentId: string, revisionId: 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, aiCanRequest: boolean, aiGeneratedAt: string }
export type MatchPage = { assessmentId: string, revisionId: 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) }
@@ -48,7 +49,8 @@ function productView(data: UTSJSONObject, publicMode: boolean): ProductView {
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 }
// 内部引用用于定位资料来源,不把测试环境标识当作面向用户的产品说明。
source: text(source, 'publisher') + ' · ' + text(source, 'title'), sourceRef: text(source, 'reference').startsWith('demo:airz-') ? '产品规则档案' : text(source, 'reference'), checkedOn: text(source, 'checkedOn'), conditions }
}
function matchView(data: UTSJSONObject): MatchView {
const evidence = data['evidence'] as UTSJSONObject[]
@@ -72,35 +74,35 @@ function matchView(data: UTSJSONObject): MatchView {
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') }
aiStatus: text(ai, 'status'), aiCanRetry: ai.getBoolean('canRetry') ?? false, aiCanRequest: data.getBoolean('canRequestExplanation') ?? 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 })
export async function requestProductExplanation(id: string, productId: string, requestId: string, revisionId: string = ''): Promise<MatchView> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId) + '/explanation', 'POST', { ...revisionParams(revisionId), requestId })
const item = matchView(data['item'] as UTSJSONObject)
if (text(data, 'assessmentId') != id || item.id != productId) throw new Error('AI解释与本次产品不一致,请重新加载。')
if ((revisionId.length > 0 && text(data, 'revisionId') != revisionId) || 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 })
export async function fetchMatchPage(id: string, group: string, cursor: string, revisionId: string = ''): Promise<MatchPage> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches', 'GET', { ...revisionParams(revisionId), eligibility: group, limit: '3', cursor })
const matching = data['matching'] as UTSJSONObject; const status = text(matching, 'status')
if (text(data, 'assessmentId') != id) throw new Error('匹配结果与本次评估不一致,请重新加载。')
if ((revisionId.length > 0 && text(data, 'revisionId') != revisionId) || 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') }
return { assessmentId: id, revisionId: text(data, 'revisionId'), 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 fetchMatchDetail(id: string, productId: string, revisionId: string = ''): Promise<MatchView> {
return (await fetchMatchDetailRecord(id, productId, revisionId)).item
}
export async function fetchMatchDetailRecord(id: string, productId: string): Promise<MatchDetail> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId), 'GET', new UTSJSONObject())
export async function fetchMatchDetailRecord(id: string, productId: string, revisionId: string = ''): Promise<MatchDetail> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId), 'GET', revisionParams(revisionId))
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 }
if ((revisionId.length > 0 && text(data, 'revisionId') != revisionId) || 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, revisionId: text(data, 'revisionId'), 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)
+48 -32
View File
@@ -1,10 +1,9 @@
import { getSession, requestJson } from '@/services/api.uts'
import { assessmentApiPayload, draftText } from '@/services/assessment.uts'
import { assessmentRequestKey } from '@/services/assessment-flow.uts'
import { yuanToWan, wanToYuan } from '@/services/money.uts'
import { demandValidationMessage } from '@/services/assessment-demand.uts'
import { yuanToWan } from '@/services/money.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 AssessmentRecord = { id: string, type: string, status: string, statusLabel: string, message: string, city: string, submittedAt: string, taskId: string, stage: string, canRetry: boolean, revisionId: string, revisionNo: number, hasReport: boolean, publishedRevisionId: string }
export type AssessmentPage = { items: AssessmentRecord[], hasMore: boolean, nextCursor: string }
export type AssessmentField = { label: string, value: string }
export type ArchiveSection = { id: string, icon: string, title: string, note: string, fields: AssessmentField[], provided: number }
@@ -12,8 +11,8 @@ export type AssessmentDetail = { record: AssessmentRecord, fields: AssessmentFie
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', {})
export async function fetchArchiveCompleteness(id: string, revisionId: string = ''): Promise<ArchiveCompleteness> {
const report = await requestJson('/assessments/' + encodeURIComponent(id) + '/report', 'GET', revisionParams(revisionId))
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('报告格式暂不支持')
@@ -29,7 +28,7 @@ function recordFrom(item: UTSJSONObject): AssessmentRecord {
const date = new Date(submitted)
const pad = (value: number): string => value < 10 ? '0' + value.toString() : value.toString()
const dateLabel = submitted.length == 0 || isNaN(date.getTime()) ? '时间未知' : date.getFullYear().toString() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes())
return { id: draftText(item, 'assessmentId'), type: draftText(item, 'type'), status: draftText(item, 'status'), statusLabel: draftText(item, 'statusLabel'), message: draftText(item, 'message'), city: draftText(item, 'city'), submittedAt: dateLabel, taskId: draftText(item, 'taskId'), stage: draftText(item, 'stage'), canRetry: item['canRetry'] == true }
return { id: draftText(item, 'assessmentId'), type: draftText(item, 'type'), status: draftText(item, 'status'), statusLabel: draftText(item, 'statusLabel'), message: draftText(item, 'message'), city: draftText(item, 'city'), submittedAt: dateLabel, taskId: draftText(item, 'taskId'), stage: draftText(item, 'stage'), canRetry: item['canRetry'] == true, revisionId: draftText(item, 'revisionId'), revisionNo: item.getNumber('revisionNo') ?? 1, hasReport: item.getBoolean('hasReport') ?? false, publishedRevisionId: draftText(item, 'publishedRevisionId') }
}
export async function retryAssessment(id: string, requestId: string): Promise<void> {
@@ -37,10 +36,11 @@ export async function retryAssessment(id: string, requestId: string): Promise<vo
}
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', {})
export type AnalysisReport = { revisionId: string, revisionNo: number, isPreviousResult: boolean, currentStatus: string, version: number, generatedAt: string, completenessPercent: number | null, matching: ReportMatching, summary: string, facts: AssessmentField[], observations: string[], suggestions: string[], suggestionSummaries: 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, revisionId: string = ''): Promise<AnalysisReport> {
const result = await requestJson('/assessments/' + encodeURIComponent(id) + '/report', 'GET', revisionParams(revisionId))
const version = result['resultVersion'] as number
if (revisionId.length > 0 && result.getString('revisionId') != revisionId) throw new Error('报告版本不一致,请重新选择。')
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()
@@ -79,7 +79,7 @@ export async function fetchAnalysisReport(id: string): Promise<AnalysisReport> {
const basis = readiness['basis'] as UTSJSONObject[]
const stateLabels: UTSJSONObject = { provided: '已填写', missing: '待补充', invalid: '需核实', not_applicable: '不适用' }
const completenessItems = items.map((item: UTSJSONObject): AssessmentField => ({ label: draftText(item, 'label'), value: stateLabels[draftText(item, 'state')] as string }))
const demand: AssessmentField[] = [
const demand: AssessmentField[] = result['inputVersion'] == 3 ? [] : [
{ label: '融资金额', value: yuanToWan(draftText(demandData, 'requestedAmountYuan')) + ' 万元' },
{ label: '融资期限', value: (demandData['requestedTermMonths'] as number).toString() + ' 个月' },
{ label: '资金用途', value: draftText(demandData, 'purposeLabel') }
@@ -100,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, generatedAt, completenessPercent, matching, summary: draftText(result, 'summary'), facts, observations: explain('strengths'), suggestions: explain('improvements'), dataGaps: explain('riskWarnings'),
return { revisionId: draftText(result, 'revisionId'), revisionNo: result.getNumber('revisionNo') ?? 1, isPreviousResult: result.getBoolean('isPreviousResult') ?? false, currentStatus: draftText(result, 'currentStatus'), version, generatedAt, completenessPercent, matching, summary: draftText(result, 'summary'), facts, observations: explain('strengths'), suggestions: explain('improvements'), suggestionSummaries: (result['improvements'] as UTSJSONObject[]).map((item: UTSJSONObject): string => draftText(item, 'text')), 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') : '未评定',
@@ -110,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, 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: [] }
return { revisionId: draftText(result, 'revisionId'), revisionNo: result.getNumber('revisionNo') ?? 1, isPreviousResult: result.getBoolean('isPreviousResult') ?? false, currentStatus: draftText(result, 'currentStatus'), version, generatedAt, completenessPercent: null, matching, summary: draftText(result, 'summary'), facts, observations, suggestions: result['suggestions'] as string[], suggestionSummaries: 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 {
@@ -123,8 +123,6 @@ function enumValue(value: string, labels: string[], codes: string[]): string | n
export async function createAssessment(payload: UTSJSONObject): Promise<string> {
const session = getSession()
if (session == null) throw new Error('请先登录')
const demandError = demandValidationMessage(draftText(payload, 'requestedAmountWan'), draftText(payload, 'requestedTermMonths'), draftText(payload, 'purposeCode'), draftText(payload, 'purposeDetail'))
if (demandError.length > 0) throw new Error(demandError)
const documents = payload['documents'] as UTSJSONObject[] | null
const grants = payload['authorizations'] as string[] | null
if ((documents != null && documents.length > 0) || (grants != null && grants.length > 0)) throw new Error('资料上传与外部查询授权尚未开放,请移除资料及授权选择后提交')
@@ -150,14 +148,14 @@ export async function createAssessment(payload: UTSJSONObject): Promise<string>
const cityCodes = ['440300', '440100', '110100', '310100', '330100']
const cityIndex = cityNames.indexOf(draftText(values, 'city'))
form['cityCode'] = cityIndex < 0 ? null : cityCodes[cityIndex]
// 需求金额单独命名单位,仅在提交边界换算,避免与收入及负债混用。
form['requestedAmountYuan'] = wanToYuan(draftText(payload, 'requestedAmountWan'))
form['requestedTermMonths'] = parseInt(draftText(payload, 'requestedTermMonths'))
form['purposeCode'] = draftText(payload, 'purposeCode')
form['purposeDetail'] = form['purposeCode'] == 'other' ? draftText(payload, 'purposeDetail').trim() : null
const body: UTSJSONObject = { inputVersion: 2, type: values['type'], moneyUnit: 'yuan', form, documents: [] as UTSJSONObject[], consentIds: [] as string[] }
body['requestId'] = assessmentRequestKey(session['token'] as string, JSON.stringify(body))
const result = await requestJson('/assessments', 'POST', body)
// 输入v3恢复原资料表单,不提交已取消的资金需求字段;报告仍使用v2展示结构。
const body: UTSJSONObject = { inputVersion: 3, type: values['type'], moneyUnit: 'yuan', form, documents: [] as UTSJSONObject[], consentIds: [] as string[] }
const editId = draftText(payload, 'editAssessmentId')
if (editId.length > 0) body['baseRevisionId'] = draftText(payload, 'baseRevisionId')
// 新建和修改使用不同幂等作用域,回执丢失时安全重放原提交。
body['requestId'] = assessmentRequestKey(session['token'] as string, editId + ':' + JSON.stringify(body))
const path = editId.length > 0 ? '/assessments/' + encodeURIComponent(editId) + '/revisions' : '/assessments'
const result = await requestJson(path, 'POST', body)
const id = draftText(result, 'assessmentId')
if (id.length == 0) throw new Error('提交回执不完整,请重试')
return id
@@ -176,14 +174,14 @@ export async function fetchAssessmentStatus(id: string): Promise<AssessmentRecor
return recordFrom(await requestJson('/assessments/' + encodeURIComponent(id) + '/status', 'GET', {}))
}
export async function fetchAssessmentDetail(id: string): Promise<AssessmentDetail> {
const result = await requestJson('/assessments/' + encodeURIComponent(id), 'GET', {})
export async function fetchAssessmentDetail(id: string, revisionId: string = ''): Promise<AssessmentDetail> {
const result = await requestJson('/assessments/' + encodeURIComponent(id), 'GET', revisionParams(revisionId))
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: '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 }
@@ -194,13 +192,6 @@ export async function fetchAssessmentDetail(id: string): Promise<AssessmentDetai
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
// 历史记录使用提交时冻结的用途标签,不拿当前字典重新解释旧需求。
addField(0, '融资金额', legacy ? '当时未采集' : yuanToWan(draftText(form, 'requestedAmountYuan')) + ' 万元', !legacy && form['requestedAmountYuan'] != null)
const term = form['requestedTermMonths'] as number | null
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: '未缴纳' }
@@ -220,3 +211,28 @@ export async function fetchAssessmentDetail(id: string): Promise<AssessmentDetai
})
return { record: recordFrom(result), fields, sections }
}
// 版本编号显式随请求传递,避免不同页面共享“当前版本”造成报告串版。
export function revisionParams(revisionId: string): UTSJSONObject { return revisionId.length > 0 ? { revisionId } : new UTSJSONObject() }
export async function fetchAssessmentRevisions(id: string, cursor: string): Promise<AssessmentPage> {
const result = await requestJson('/assessments/' + encodeURIComponent(id) + '/revisions', 'GET', { cursor })
return { items: (result['items'] as UTSJSONObject[]).map((item: UTSJSONObject): AssessmentRecord => recordFrom(item)), hasMore: result.getBoolean('hasMore') ?? false, nextCursor: draftText(result, 'nextCursor') }
}
export async function fetchAssessmentEdit(id: string): Promise<UTSJSONObject> {
const result = await requestJson('/assessments/' + encodeURIComponent(id) + '/edit', 'GET', {})
if (draftText(result, 'assessmentId') != id || result.getString('status') == 'processing') throw new Error('评估正在处理中,请完成后再修改。')
const form = result['form'] as UTSJSONObject
const payload: UTSJSONObject = { type: result['type'], editAssessmentId: id, baseRevisionId: result['revisionId'], step: 0 }
const money = ['monthlyIncome', 'annualRevenue', 'totalDebtBalance', 'monthlyLoanPayment', 'creditCardTotalLimit', 'creditCardUsedLimit']
const labels: UTSJSONObject = { married: '已婚', single: '未婚', divorced: '离异', widowed: '丧偶', yes: '有', no: '无', six_months_plus: '连续缴纳 6 个月以上', under_six_months: '缴纳不足 6 个月', none: '未缴纳' }
const enums = ['maritalStatus', 'house', 'car', 'socialSecurity', 'housingFund']
const keys = ['name', 'idCard', 'phone', 'city', 'occupation', 'company', 'enterpriseName', 'businessCode', 'monthlyIncome', 'annualRevenue', 'totalDebtBalance', 'monthlyLoanPayment', 'creditCardTotalLimit', 'creditCardUsedLimit', 'creditLoanCount', 'creditCardCount', 'operatingMonths', 'maritalStatus', 'house', 'car', 'socialSecurity', 'housingFund']
keys.forEach((key: string) => {
const raw = form[key]
let value = raw == null ? '' : (typeof raw == 'number' ? (raw as number).toString() : raw as string)
if (value.length > 0 && money.includes(key)) value = yuanToWan(value)
if (enums.includes(key) && labels[value] != null) value = labels[value] as string
payload[key] = value
})
return payload
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { APP_ENVIRONMENT } from '@/services/config.uts'
export type ReportDownload = { cancel: () => void, done: Promise<string> }
export function downloadReportPdf(id: string): ReportDownload {
export function downloadReportPdf(id: string, revisionId: string = ''): ReportDownload {
// 首版仅开放普通浏览器H5;其他端明确提示,不生成假文件或成功回执。
// #ifndef H5
return { cancel: () => {}, done: new Promise<string>((_resolve, reject) => { reject(new Error('当前客户端暂未开放PDF下载,请在普通浏览器打开H5并登录后下载。')) }) }
@@ -23,7 +23,7 @@ export function downloadReportPdf(id: string): ReportDownload {
}
const timeout = setTimeout(() => controller.abort(), 45000)
try {
const response = await fetch(API_BASE_URL + '/assessments/' + id + '/report.pdf', {
const response = await fetch(API_BASE_URL + '/assessments/' + id + '/report.pdf' + (revisionId.length > 0 ? '?revisionId=' + encodeURIComponent(revisionId) : ''), {
method: 'GET', headers: { Authorization: 'Bearer ' + token, 'X-Requested-With': 'XMLHttpRequest' },
cache: 'no-store', credentials: 'omit', redirect: 'error', signal: controller.signal
})