feat: 重构评估流程为内存状态管理并接入真实提交与报告接口

本次重构将评估草稿改为内存状态管理,移除本地存储草稿,新增评估流程内存状态模块;接入真实的账户资料获取、头像上传接口,新增账户缓存清理逻辑;重构评估提交逻辑,接入真实提交接口并返回评估ID;新增分析页和报告页的真实状态查询、报告获取功能;更新README文档,记录开发进度和接口状态。
This commit is contained in:
hujun
2026-09-24 11:50:07 +08:00
parent dcf12a4471
commit dbfd637388
22 changed files with 1468 additions and 434 deletions
@@ -16,16 +16,23 @@
import AppIcon from '@/components/AppIcon.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { DEFAULT_AVATAR, maskPhone, getAccountProfile, saveAccountProfile, readAvatar } from '@/packages/account/services/account.uts'
import { DEFAULT_AVATAR, maskPhone, getAccountProfile, fetchAccountProfile, saveAccountProfile, uploadAccountAvatar } from '@/packages/account/services/account.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
const nickname = ref(''); const avatar = ref(''); const phone = ref(''); const saving = ref(false); const choosing = ref(false); const errorMessage = ref('')
const avatarId = ref('')
let originalName = ''; let originalAvatar = ''; let alive = true
const dirty = (): boolean => nickname.value.trim() != originalName || avatar.value != originalAvatar
onLoad(() => {
if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
const profile = getAccountProfile(); nickname.value = profile.nickname; avatar.value = profile.avatar; phone.value = profile.phone
originalName = profile.nickname; originalAvatar = profile.avatar
// 服务端资料优先;请求返回时已开始编辑则不覆盖用户输入。
fetchAccountProfile().then((fresh) => {
if (!alive || dirty()) return
nickname.value = fresh.nickname; avatar.value = fresh.avatar; phone.value = fresh.phone
originalName = fresh.nickname; originalAvatar = fresh.avatar
}).catch((error) => { if (alive) errorMessage.value = error instanceof Error ? error.message : '资料加载失败,请重试' })
})
const dirty = (): boolean => nickname.value.trim() != originalName || avatar.value != originalAvatar
const leave = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else uni.redirectTo({ url: '/packages/account/profile/profile' }) }
const back = () => {
if (saving.value) return
@@ -35,14 +42,17 @@
onBackPress((event): boolean => { if (event.from == 'navigateBack') return false; if (saving.value || dirty()) { back(); return true }; return false })
const loadAvatar = async (path: string) => {
choosing.value = true; errorMessage.value = ''
try { const image = await readAvatar(path); if (alive) avatar.value = image }
try {
const result = await uploadAccountAvatar(path)
if (alive) { avatar.value = result['avatarUrl'] as string; avatarId.value = result['attachmentId'] as string }
}
catch (error) { if (alive) errorMessage.value = error instanceof Error ? error.message : '图片读取失败' }
finally { if (alive) choosing.value = false }
}
const chooseAvatar = () => {
if (saving.value || choosing.value) return
uni.showActionSheet({ itemList: ['从相册选择', '恢复默认头像'], success: (action) => {
if (action.tapIndex == 1) { avatar.value = ''; return }
if (action.tapIndex == 1) { avatar.value = ''; avatarId.value = 'default'; return }
uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album'], success: (result) => {
if (result.tempFilePaths.length == 0) return
if (result.tempFiles[0].size > 512000) { errorMessage.value = '请选择 500KB 以内的图片'; return }
@@ -56,7 +66,7 @@
errorMessage.value = ''; saving.value = true
try {
if (getAccountProfile().phone != phone.value) throw new Error('登录状态已变化,请重新进入页面')
await saveAccountProfile(nickname.value, avatar.value)
await saveAccountProfile(nickname.value, avatarId.value)
originalName = nickname.value.trim(); originalAvatar = avatar.value
uni.showToast({ title: '资料已保存', icon: 'success' }); leave()
} catch (error) { errorMessage.value = error instanceof Error ? error.message : '保存失败,请稍后重试' }
+10 -13
View File
@@ -4,7 +4,7 @@
<scroll-view scroll-y :style="contentStyle(226, false)">
<view class="profile-content">
<view class="profile-card" @click="openAccount"><view class="avatar"><image class="profile-avatar" :src="avatar.length > 0 ? avatar : DEFAULT_AVATAR" mode="aspectFill" @error="avatar = ''" /></view><view class="profile-main"><text class="profile-name">{{ displayName }}</text><text class="profile-phone">{{ loggedIn ? maskedPhone : '登录后管理您的融资信息' }}</text></view><text v-if="!loggedIn" class="account-state">未登录</text><AppIcon v-if="loggedIn" name="chevron-right" :size="28" /></view>
<view class="stat-row"><view class="stat-item" @click="go('/packages/finance/archive/archive')"><text class="stat-value">{{ completion }}%</text><text class="stat-label">档案完整度</text></view><view class="stat-item" @click="openReport"><text class="stat-value">{{ hasReport ? '已生成' : '暂无' }}</text><text class="stat-label">评估报告</text></view><view class="stat-item" @click="go('/packages/account/points/points')"><AppIcon name="points" :size="42" /><text class="stat-label">积分中心</text></view></view>
<view class="stat-row"><view class="stat-item" @click="go('/packages/finance/archive/archive')"><text class="stat-value">查看</text><text class="stat-label">评估记录</text></view><view class="stat-item" @click="openReport"><text class="stat-value">查看</text><text class="stat-label">评估报告</text></view><view class="stat-item" @click="go('/packages/account/points/points')"><AppIcon name="points" :size="42" /><text class="stat-label">积分中心</text></view></view>
<view class="menu-list"><view class="menu-row" v-for="item in menus" :key="item.name" @click="open(item.key)"><AppIcon :name="item.icon" :size="46" /><text class="menu-name">{{ item.name }}</text><text v-if="item.note.length > 0" class="menu-note">{{ item.note }}</text><AppIcon name="chevron-right" :size="28" /></view></view>
<button class="logout" @click="logout">{{ loggedIn ? '退出登录' : '登录 / 注册' }}</button>
<text class="version-note">AI融资 · 让融资更简单</text>
@@ -15,7 +15,7 @@
</template>
<script setup lang="uts">
import { getUserStorage, setUserStorage } from '@/services/storage.uts'
import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue'
@@ -23,8 +23,8 @@
import AppIcon from '@/components/AppIcon.uvue'
import { getSession } from '@/services/api.uts'
import { logoutAccount } from '@/packages/account/services/auth.uts'
import { activeAssessmentType, assessmentProgress, showAgreementNotice } from '@/services/assessment.uts'
import { DEFAULT_AVATAR, getAccountProfile, maskPhone } from '@/packages/account/services/account.uts'
import { showAgreementNotice } from '@/services/assessment.uts'
import { DEFAULT_AVATAR, getAccountProfile, fetchAccountProfile, maskPhone } from '@/packages/account/services/account.uts'
const { contentStyle, pageStyle } = usePageLayout()
type ProfileMenu = { icon: string, name: string, note: string, key: string }
const menus: ProfileMenu[] = [{ icon: 'archive', name: '我的融资档案', note: '', key: 'archive' }, { icon: 'shield', name: '账户安全', note: '', key: 'security' }, { icon: 'privacy', name: '隐私与授权', note: '', key: 'privacy' }, { icon: 'help', name: '帮助与反馈', note: '', key: 'help' }, { icon: 'info', name: '关于我们', note: 'V1.0.0', key: 'about' }]
@@ -32,32 +32,29 @@
const displayName = ref('登录 / 注册')
const maskedPhone = ref('')
const avatar = ref('')
const completion = ref(0)
const hasReport = ref(false)
onShow(() => {
const session = getSession()
loggedIn.value = session != null
const profile = getAccountProfile()
maskedPhone.value = maskPhone(profile.phone); avatar.value = loggedIn.value ? profile.avatar : ''
const type = activeAssessmentType()
displayName.value = loggedIn.value ? profile.nickname : '登录 / 注册'
completion.value = assessmentProgress(type).percent
const latest = getUserStorage('latestAssessment')
hasReport.value = latest != null && typeof latest == 'object'
if (loggedIn.value) fetchAccountProfile().then((fresh) => {
displayName.value = fresh.nickname; avatar.value = fresh.avatar; maskedPhone.value = maskPhone(fresh.phone)
}).catch(() => { /* 读取失败保留已归属当前账户的缓存,不显示其他账户资料。 */ })
})
const go = (url: string) => { uni.navigateTo({ url }) }
const openReport = () => { if (hasReport.value) go('/packages/finance/report/report'); else uni.showModal({ title: '暂无评估报告', content: '完善融资信息后,即可体验融资评估与方案匹配。', confirmText: '去完善', success: (result) => { if (result.confirm) go('/packages/finance/archive/archive') } }) }
const openReport = () => { go('/packages/finance/archive/archive') }
const open = (key: string) => {
if (key == 'archive') go('/packages/finance/archive/archive')
else if (key == 'help') go('/packages/content/help/help')
else if (key == 'security') go(loggedIn.value ? '/packages/account/security/security' : '/packages/account/login/login')
else if (key == 'privacy') uni.showActionSheet({ itemList: ['查看隐私说明', '管理资料与授权'], success: (result) => { if (result.tapIndex == 0) showAgreementNotice('隐私说明'); else if (result.tapIndex == 1) { setUserStorage('assessmentEditStep', 3); go('/packages/finance/assessment/assessment?type=' + activeAssessmentType()) } } })
else if (key == 'privacy') uni.showActionSheet({ itemList: ['查看隐私说明', '管理资料与授权'], success: (result) => { if (result.tapIndex == 0) showAgreementNotice('隐私说明'); else if (result.tapIndex == 1) { go('/packages/finance/archive/archive') } } })
else if (key == 'about') uni.showModal({ title: '关于 AI融资', content: '版本 V1.0.0\n帮助您整理融资信息、了解融资知识与融资方案。评估结果仅供参考,不构成授信承诺。', showCancel: false, confirmText: '我知道了' })
}
const openAccount = () => { go(loggedIn.value ? '/packages/account/edit-profile/edit-profile' : '/packages/account/login/login') }
const logout = () => {
if (!loggedIn.value) { go('/packages/account/login/login'); return }
uni.showModal({ title: '退出登录', content: '退出后,本机保存的评估草稿仍会保留。', confirmText: '退出登录', success: (result) => { if (result.confirm) {
uni.showModal({ title: '退出登录', content: '将清除本机账户缓存和未提交的填写内容,已提交的评估记录不受影响。', confirmText: '退出登录', success: (result) => { if (result.confirm) {
logoutAccount().then(() => { uni.reLaunch({ url: '/packages/account/login/login' }) })
.catch(() => { uni.showToast({ title: '退出失败,请检查网络后重试', icon: 'none' }) })
} }, fail: (error) => { console.error('logout confirmation failed', error); uni.showToast({ title: '暂时无法打开确认框,请重试', icon: 'none' }) } })
+24 -32
View File
@@ -1,6 +1,6 @@
import { getUserStorage, setUserStorage } from '@/services/storage.uts'
import { API_BASE_URL, getSession, requestJson } from '@/services/api.uts'
import { activeAssessmentType, loadAssessmentDraft, draftText } from '@/services/assessment.uts'
import { getSession, requestJson, uploadJson } from '@/services/api.uts'
import { draftText } from '@/services/assessment.uts'
export type AccountProfile = { nickname: string, avatar: string, phone: string }
export const DEFAULT_AVATAR = '/packages/account/static/default-avatar.png'
@@ -27,9 +27,6 @@ export function getAccountProfile(): AccountProfile {
const saved = getUserStorage(profileKey(phone))
if (saved != null && typeof saved == 'object') {
nickname = draftText(saved as UTSJSONObject, 'nickname'); avatar = draftText(saved as UTSJSONObject, 'avatar')
} else {
const draft = loadAssessmentDraft(activeAssessmentType())
if (draft != null && draftText(draft, 'phone') == phone && draftText(draft, 'name').length > 0) nickname = draftText(draft, 'name')
}
}
return { phone, nickname: nickname.length > 0 ? nickname : '融资用户', avatar }
@@ -40,13 +37,32 @@ async function accountRequest(path: string, method: RequestMethod, payload: UTSJ
requireAccount()
return await requestJson(path, method, payload)
}
export async function saveAccountProfile(nickname: string, avatar: string): Promise<void> {
function cacheProfile(result: UTSJSONObject, phone: string): AccountProfile {
const profile: AccountProfile = { phone, nickname: draftText(result, 'nickname'), avatar: draftText(result, 'avatarUrl') }
setUserStorage(profileKey(phone), { nickname: profile.nickname, avatar: profile.avatar })
return profile
}
export async function fetchAccountProfile(): Promise<AccountProfile> {
const session = requireAccount()
const result = await accountRequest('/account/profile', 'GET', {})
if (!sameAccount(session)) throw new Error('登录状态已变化,请重新进入页面')
return cacheProfile(result, draftText(session, 'phone'))
}
export async function saveAccountProfile(nickname: string, avatarId: string): Promise<void> {
const session = requireAccount(); const phone = draftText(session, 'phone')
const name = nickname.trim()
if (name.length < 2 || name.length > 20) throw new Error('昵称请输入 2–20 个字符')
if (API_BASE_URL.length > 0) await accountRequest('/account/profile', 'PUT', { nickname: name, avatar })
const payload: UTSJSONObject = { nickname: name }
if (avatarId == 'default') payload['avatarId'] = null
else if (avatarId.length > 0) payload['avatarId'] = avatarId
const result = await accountRequest('/account/profile', 'PUT', payload)
if (!sameAccount(session)) throw new Error('登录状态已变化,请重新进入页面')
setUserStorage(profileKey(phone), { nickname: name, avatar })
cacheProfile(result, phone)
}
export async function uploadAccountAvatar(path: string): Promise<UTSJSONObject> {
requireAccount()
return await uploadJson('/account/avatar', path)
}
export async function requestSecurityCode(purpose: string, newPhone: string): Promise<void> {
if (!['password', 'phone-current', 'phone-new'].includes(purpose)) throw new Error('验证码用途无效')
@@ -68,27 +84,3 @@ export async function changeAccountPhone(newPhone: string, currentCode: string,
if (!validCode(currentCode) || !validCode(newCode)) throw new Error('请输入两组 6 位数字验证码')
await accountRequest('/account/phone', 'PUT', { newPhone, currentCode, newCode })
}
// 限制头像体积,避免把临时路径或过大的图片写入账户缓存。
export function readAvatar(path: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
// #ifdef WEB
fetch(path).then((response) => response.blob()).then((blob) => {
if (blob.size > 512000 || !['image/jpeg', 'image/png', 'image/webp'].includes(blob.type)) { reject(new Error('请选择 500KB 以内的 JPG、PNG 或 WebP 图片')); return }
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(new Error('图片读取失败,请重新选择'))
reader.readAsDataURL(blob)
}).catch(() => reject(new Error('图片读取失败,请重新选择')))
// #endif
// #ifndef WEB
uni.getFileSystemManager().readFile({ filePath: path, encoding: 'base64', success: (result) => {
const data = result.data as string
if (data.length > 683000) { reject(new Error('请选择 500KB 以内的图片')); return }
const mime = data.startsWith('/9j/') ? 'image/jpeg' : (data.startsWith('iVBOR') ? 'image/png' : (data.startsWith('UklGR') ? 'image/webp' : ''))
if (mime.length == 0) { reject(new Error('请选择 JPG、PNG 或 WebP 图片')); return }
resolve('data:' + mime + ';base64,' + data)
}, fail: () => reject(new Error('图片读取失败,请重新选择')) })
// #endif
})
}
+59 -26
View File
@@ -1,37 +1,69 @@
<template>
<view class="analyzing-page" :style="pageStyle()">
<view class="safe" :style="statusStyle(80)"></view>
<text class="analyzing-title">AI分析中</text>
<text class="analyzing-sub">正在整理融资评估信息…</text>
<text class="analyzing-title">{{ title }}</text>
<text class="analyzing-sub">正式提交记录 · {{ assessmentId }}</text>
<view class="orbital"><view class="orbit orbit-one"></view><view class="orbit orbit-two"></view><view class="ai-core"><image src="/static/mobile/images/robot-cutout.png" mode="aspectFit" class="ai-core-image"></image></view></view>
<view class="analysis-list"><view class="analysis-item" v-for="(item,index) in items" :key="item"><view :class="progress > index ? 'analysis-check done' : 'analysis-check'"><text class="analysis-mark">{{ progress > index ? '✓' : '·' }}</text></view><text class="analysis-label">{{ item }}</text></view></view>
<text class="estimate">正在生成评估报告,请稍候…</text>
<text class="view-now" @click="finish">查看评估结果</text>
<text class="status-message">{{ message }}</text>
<text v-if="error.length > 0" class="status-message">{{ error }}</text>
<text v-if="canRetry" class="view-now" @click="retry">{{ retrying ? '正在提交…' : '重新分析' }}</text>
<text v-if="succeeded" class="view-now" @click="report">查看分析报告</text>
<text class="view-now" @click="loadStatus">刷新状态</text>
<text class="view-now" @click="archive">查看已提交资料</text>
</view>
</template>
<script setup lang="uts">
import { usePageLayout } from '@/services/layout.uts'
const { statusStyle, pageStyle } = usePageLayout()
const progress = ref(0)
const items = ['数据解析中', '信用评估中', '匹配方案中', '风险检测中']
let timer = 0
let finishTimer = 0
let finished = false
const finish = () => {
if (finished) return
finished = true
clearInterval(timer)
clearTimeout(finishTimer)
uni.redirectTo({ url: '/packages/finance/report/report' })
}
onShow(() => {
if (finished) return
timer = setInterval(() => { if (progress.value < 4) progress.value++; else clearInterval(timer) }, 550)
finishTimer = setTimeout(() => finish(), 3200)
})
onHide(() => { clearInterval(timer); clearTimeout(finishTimer) })
onUnmounted(() => { clearInterval(timer); clearTimeout(finishTimer) })
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { fetchAssessmentStatus, retryAssessment } from '@/packages/finance/services/records.uts'
const { statusStyle, pageStyle } = usePageLayout()
const title = ref('正在读取评估状态')
const message = ref(''); const error = ref(''); const assessmentId = ref('')
const canRetry = ref(false); const succeeded = ref(false); const retrying = ref(false)
let retryKey = ''; let retryTask = ''; let retryOwner = ''
let timer = 0; let visible = false; let generation = 0
async function loadStatus(): Promise<void> {
clearTimeout(timer)
if (assessmentId.value.length == 0) { title.value = '缺少评估记录'; message.value = '请从融资档案选择已提交的记录。'; return }
const session = getSession()
if (session == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
const owner = session['token'] as string
const request = ++generation
error.value = ''
try {
const result = await fetchAssessmentStatus(assessmentId.value)
if (!visible || request != generation || getSession()?.getString('token') != owner) return
canRetry.value = result.canRetry; succeeded.value = result.status == 'succeeded'
if (retryTask != result.taskId || retryOwner != owner) { retryKey = ''; retryTask = result.taskId; retryOwner = owner }
title.value = result.statusLabel
message.value = result.message.length > 0 ? result.message : (result.status == 'processing' ? (result.stage == 'queued' ? '资料已保存,等待分析。您可以稍后从档案查看。' : '正在分析自填资料,您可以稍后从档案查看。') : '分析已完成,可查看本次报告。')
// 只轮询服务端仍在处理的任务,不用计时器模拟完成。
if (result.status == 'processing') timer = setTimeout(() => { loadStatus() }, 5000)
} catch (err) { if (visible && request == generation) { title.value = '状态加载失败'; error.value = err instanceof Error ? err.message : '请稍后重试' } }
}
const retry = async () => {
if (retrying.value || !canRetry.value) return
const session = getSession()
if (session == null) { loadStatus(); return }
const owner = session['token'] as string
if (retryOwner != owner) { retryKey = ''; retryOwner = owner }
// 网络失败保留当前键;状态中的任务ID变化后才为下次尝试生成新键。
if (retryKey.length == 0) retryKey = 'retry_' + Date.now().toString() + '_' + Math.random().toString(36).substring(2, 14)
retrying.value = true; error.value = ''; clearTimeout(timer)
const request = ++generation
try {
await retryAssessment(assessmentId.value, retryKey)
if (visible && request == generation && getSession()?.getString('token') == owner) { canRetry.value = false; await loadStatus() }
} catch (err) { if (visible && request == generation) error.value = err instanceof Error ? err.message : '重新分析失败,请重试' }
finally { retrying.value = false }
}
const report = () => { uni.navigateTo({ url: '/packages/finance/report/report?assessmentId=' + encodeURIComponent(assessmentId.value) }) }
const archive = () => { uni.reLaunch({ url: '/packages/finance/archive/archive?assessmentId=' + encodeURIComponent(assessmentId.value) }) }
onLoad((options) => { if (options != null) assessmentId.value = options['assessmentId'] ?? '' })
onShow(() => { visible = true; loadStatus() })
onHide(() => { visible = false; generation++; clearTimeout(timer); canRetry.value = false; succeeded.value = false; message.value = ''; error.value = '' })
onUnload(() => { visible = false; generation++; clearTimeout(timer) })
</script>
<style>
@@ -51,6 +83,7 @@
.analysis-mark { font-size: 24rpx; color: #ffffff; }
.analysis-check { width: 38rpx; height: 38rpx; border-radius: 50%; border: 2rpx solid #4c73a7; align-items: center; justify-content: center; margin-right: 17rpx; }
.analysis-check.done { background-color: #158aff; border-color: #41d7ff; color: white; }
.status-message { margin: 32rpx; color: #d8e6ff; font-size: 28rpx; line-height: 44rpx; text-align: center; }
.estimate { margin-top: 64rpx; color: #8faeda; font-size: 22rpx; }
.view-now { margin-top: 28rpx; color: #62c8ff; font-size: 23rpx; }
</style>
+111 -59
View File
@@ -1,65 +1,117 @@
<template>
<view class="page" :style="pageStyle()">
<AppHeader title="我的融资档案" />
<scroll-view scroll-y :style="contentStyle(96, false)"><view class="archive-content">
<view class="archive-switch"><text :class="type == 'personal' ? 'switch-item switch-active' : 'switch-item'" @click="switchType('personal')">个人档案</text><text :class="type == 'enterprise' ? 'switch-item switch-active' : 'switch-item'" @click="switchType('enterprise')">企业档案</text></view>
<view class="completion"><view class="completion-head"><text class="completion-title">档案完整度</text><text class="completion-score">{{ progress.percent }}<text class="score-unit">%</text></text></view><view class="progress-track"><view class="progress-bar" :style="'width:' + progress.percent + '%;'"></view></view><text class="completion-desc">{{ progress.percent == 100 ? '基础信息已完善,可继续查看评估方案。' : '完善基础信息,让融资评估更贴近您的情况。' }}</text></view>
<view class="archive-list"><view class="archive-row" v-for="item in archiveItems" :key="item.step" @click="edit(item.step)"><AppIcon :name="item.icon" :size="48" /><view class="archive-labels"><text class="archive-name">{{ item.name }}</text><text class="archive-note">{{ item.note }}</text></view><text :class="item.done ? 'state state-done' : 'state'">{{ item.done ? (item.step == 3 ? '已选择' : '已完善') : (item.step == 3 ? '选填' : '待完善') }}</text><AppIcon name="chevron-right" :size="28" /></view></view>
<button class="primary-button complete-button" @click="complete">{{ progress.percent == 100 ? '查看并确认信息' : '继续完善档案' }}</button>
<text class="local-note">草稿保存在当前设备,资料与授权可自主选择。</text>
</view></scroll-view>
</view>
<view class="page" :style="pageStyle()">
<AppHeader :title="selectedId.length > 0 ? '评估记录详情' : '我的融资档案'" :customBack="selectedId.length > 0" @back="backToList" />
<scroll-view scroll-y :style="contentStyle(0, false)">
<view class="archive-content">
<text class="archive-title">{{ selectedId.length > 0 ? '已提交的评估资料' : '已提交的评估记录' }}</text>
<text class="archive-note">提交后的资料不可修改;再次评估请新建记录。未提交的填写内容不会保存。</text>
<view v-if="selectedId.length == 0" class="start-actions">
<button class="primary-button start-button" @click="start('personal')">个人评估</button>
<button class="primary-button start-button" @click="start('enterprise')">企业评估</button>
</view>
<view v-if="error.length > 0" class="notice"><text>{{ error }}</text><text class="action" @click="refresh">重试加载</text></view>
<text v-if="loading" class="archive-note">正在加载…</text>
<template v-if="selectedId.length > 0 && detail != null">
<text class="record-title">{{ detail!.record.type == 'personal' ? '个人' : '企业' }}评估 · #{{ detail!.record.id }}</text>
<text class="record-status">{{ detail!.record.statusLabel }}</text>
<text class="archive-note">{{ detail!.record.message }}</text>
<text class="record-time">提交时间:{{ detail!.record.submittedAt }}</text>
<text class="action" @click="openAnalysis">{{ detail!.record.status == 'succeeded' ? '查看分析报告' : '查看分析状态 / 重试' }}</text>
<view class="detail-row" v-for="field in detail!.fields" :key="field.label"><text class="field-label">{{ field.label }}</text><text class="field-value">{{ field.value }}</text></view>
<text class="action" @click="backToList">返回记录列表</text>
</template>
<template v-if="selectedId.length == 0">
<text v-if="!loading && error.length == 0 && records.length == 0" class="empty-state">暂无已提交的评估记录</text>
<view class="record-card" v-for="item in records" :key="item.id" @click="openRecord(item.id)">
<text class="record-title">{{ item.type == 'personal' ? '个人' : '企业' }}评估 · #{{ item.id }}</text>
<text class="record-status">{{ item.statusLabel }}</text>
<text class="record-time">{{ item.city }} · {{ item.submittedAt }}</text>
<text class="action">查看已提交资料</text>
</view>
<text v-if="hasMore && !loading" class="action" @click="loadMore">加载更多</text>
<text v-if="records.length > 0 && !hasMore && !loading" class="archive-note">已显示全部记录</text>
</template>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { setUserStorage } from '@/services/storage.uts'
import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue'
import { AssessmentProgress, activeAssessmentType, assessmentProgress } from '@/services/assessment.uts'
const { pageStyle, contentStyle } = usePageLayout()
type ArchiveItem = { icon: string, name: string, note: string, done: boolean, step: number }
const type = ref('personal')
const progress = ref<AssessmentProgress>({ percent: 0, basicDone: false, incomeDone: false, assetsDone: false, authorizationSelected: false })
const archiveItems = computed((): ArchiveItem[] => [
{ icon: 'file-text', name: '基本信息', note: type.value == 'enterprise' ? '企业与联系人信息' : '身份与联系方式', done: progress.value.basicDone, step: 0 },
{ icon: 'income', name: type.value == 'enterprise' ? '经营信息' : '职业与收入', note: type.value == 'enterprise' ? '经营行业、年限与营收' : '职业、收入与缴纳情况', done: progress.value.incomeDone, step: 1 },
{ icon: 'asset', name: '资产与负债', note: '贷款、信用卡额度与房车情况', done: progress.value.assetsDone, step: 2 },
{ icon: 'authorization', name: '资料与授权', note: '本地资料与数据授权意向', done: progress.value.authorizationSelected, step: 3 }
])
const refresh = () => { progress.value = assessmentProgress(type.value) }
onShow(() => { type.value = activeAssessmentType(); refresh() })
const switchType = (next: string) => { type.value = next; setUserStorage('assessmentActiveType', next); refresh() }
const edit = (step: number) => { setUserStorage('assessmentEditStep', step); uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + type.value }) }
const complete = () => {
if (progress.value.percent == 100) uni.navigateTo({ url: '/packages/finance/confirm/confirm' })
else if (!progress.value.basicDone) edit(0)
else if (!progress.value.incomeDone) edit(1)
else edit(2)
}
import AppHeader from '@/components/AppHeader.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { clearAssessmentFlow } from '@/services/assessment-flow.uts'
import { AssessmentRecord, AssessmentDetail, fetchAssessments, fetchAssessmentDetail } from '@/packages/finance/services/records.uts'
const { pageStyle, contentStyle } = usePageLayout()
const records = ref<AssessmentRecord[]>([])
const detail = ref<AssessmentDetail | null>(null)
const selectedId = ref(''); const loading = ref(false); const error = ref(''); const hasMore = ref(false)
let cursor = ''; let generation = 0; let visible = false
const sameOwner = (token: string): boolean => { const session = getSession(); return session != null && session['token'] == token }
const refresh = async () => {
const session = getSession()
if (session == null) { records.value = []; detail.value = null; uni.redirectTo({ url: '/packages/account/login/login' }); return }
const owner = session['token'] as string
const request = ++generation
loading.value = true; error.value = ''
try {
if (selectedId.value.length > 0) {
const result = await fetchAssessmentDetail(selectedId.value)
if (visible && request == generation && sameOwner(owner)) detail.value = result
} else {
const result = await fetchAssessments('')
if (visible && request == generation && sameOwner(owner)) { records.value = result.items; hasMore.value = result.hasMore; cursor = result.nextCursor }
}
} catch (err) { if (visible && request == generation) error.value = err instanceof Error ? err.message : '加载失败,请重试' }
finally { if (request == generation) loading.value = false }
}
const loadMore = async () => {
if (loading.value || !hasMore.value) return
const session = getSession()
if (session == null) { refresh(); return }
const owner = session['token'] as string
const request = ++generation
loading.value = true; error.value = ''
try {
const result = await fetchAssessments(cursor)
if (visible && request == generation && sameOwner(owner)) {
const incoming = result.items.filter((item: AssessmentRecord): boolean => !records.value.some((old: AssessmentRecord): boolean => old.id == item.id))
records.value = records.value.concat(incoming); hasMore.value = result.hasMore; cursor = result.nextCursor
}
} catch (err) { if (visible && request == generation) error.value = err instanceof Error ? err.message : '加载失败,请重试' }
finally { if (request == generation) loading.value = false }
}
const start = (type: string) => { clearAssessmentFlow(); uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + type }) }
const openRecord = (id: string) => { uni.navigateTo({ url: '/packages/finance/archive/archive?assessmentId=' + encodeURIComponent(id) }) }
const openAnalysis = () => {
const page = detail.value?.record.status == 'succeeded' ? 'report/report' : 'analyzing/analyzing'
uni.navigateTo({ url: '/packages/finance/' + page + '?assessmentId=' + encodeURIComponent(selectedId.value) })
}
const backToList = () => {
const pages = getCurrentPages()
if (pages.length > 1 && pages[pages.length - 2].route == 'packages/finance/archive/archive') uni.navigateBack()
else uni.redirectTo({ url: '/packages/finance/archive/archive' })
}
onBackPress((event): boolean => { if (event.from == 'navigateBack' || selectedId.value.length == 0) return false; backToList(); return true })
onLoad((options) => { if (options != null) selectedId.value = options['assessmentId'] ?? '' })
onShow(() => { visible = true; refresh() })
// 离开时清空敏感详情;迟到的请求不能把其他账户的数据写回页面。
onHide(() => { visible = false; generation++; records.value = []; detail.value = null })
onUnload(() => { visible = false; generation++ })
</script>
<style>
.archive-content { padding: 26rpx 30rpx 64rpx; }
.archive-switch { flex-direction: row; background-color: #eaf0f8; border-radius: 16rpx; padding: 6rpx; margin-bottom: 24rpx; }
.switch-item { flex: 1; text-align: center; font-size: 27rpx; color: #708099; padding: 18rpx 0; border-radius: 12rpx; }
.switch-active { background-color: #fff; color: #0868f7; font-weight: 600; }
.completion { padding: 30rpx; border-radius: 20rpx; background-color: #edf5ff; }
.completion-head { flex-direction: row; justify-content: space-between; align-items: center; }
.completion-title { font-size: 30rpx; font-weight: 600; color: #233d64; }
.completion-score { color: #0868f7; font-size: 52rpx; line-height: 64rpx; font-weight: 800; }
.score-unit { color: #0868f7; font-size: 28rpx; font-weight: 600; }
.completion-desc { color: #6c819d; font-size: 25rpx; line-height: 38rpx; margin-top: 20rpx; }
.progress-track { height: 12rpx; border-radius: 6rpx; background-color: #d9e6f8; margin-top: 20rpx; overflow: hidden; }
.progress-bar { height: 12rpx; border-radius: 6rpx; background-color: #0868f7; }
.archive-list { margin-top: 24rpx; background-color: #fff; border: 1rpx solid #e5ecf5; border-radius: 18rpx; padding: 0 22rpx; }
.archive-row { min-height: 126rpx; flex-direction: row; align-items: center; border-bottom: 1rpx solid #edf1f6; }
.archive-labels { flex: 1; min-width: 0; margin: 0 18rpx; }
.archive-name { font-size: 28rpx; color: #26374e; font-weight: 500; }
.archive-note { font-size: 23rpx; color: #8190a4; margin-top: 8rpx; line-height: 34rpx; }
.state { font-size: 23rpx; color: #8c99ab; margin-right: 10rpx; flex-shrink: 0; }
.state-done { color: #16a478; }
.complete-button { margin-top: 32rpx; }
.local-note { font-size: 23rpx; color: #8795a8; line-height: 36rpx; margin-top: 22rpx; text-align: center; }
.archive-content { margin: 28rpx; padding: 30rpx; border-radius: 18rpx; background-color: #fff; }
.archive-title { font-size: 32rpx; font-weight: 600; color: #23344e; }
.archive-note { margin: 24rpx 0 30rpx; color: #738198; font-size: 26rpx; line-height: 42rpx; }
.start-actions { flex-direction: row; margin-bottom: 20rpx; }
.start-button { flex: 1; margin: 6rpx; font-size: 27rpx; }
.record-card { padding: 28rpx 0; border-bottom: 1rpx solid #edf1f6; }
.record-title { font-size: 30rpx; font-weight: 600; color: #23344e; }
.record-status { margin-top: 16rpx; color: #0868f7; font-size: 27rpx; }
.record-time { margin-top: 14rpx; color: #738198; font-size: 23rpx; line-height: 36rpx; }
.action { color: #0868f7; font-size: 27rpx; padding: 24rpx 0; }
.notice { color: #ad432d; font-size: 27rpx; line-height: 40rpx; }
.empty-state { padding: 50rpx 0; color: #738198; font-size: 28rpx; }
.detail-row { flex-direction: row; padding: 24rpx 0; border-bottom: 1rpx solid #edf1f6; }
.field-label { width: 210rpx; font-size: 26rpx; color: #738198; }
.field-value { flex: 1; text-align: right; font-size: 27rpx; color: #23344e; }
</style>
+80 -28
View File
@@ -3,7 +3,7 @@
<AppHeader :title="titles[step]" :customBack="true" @back="back" />
<scroll-view scroll-y :style="contentStyle(96, false)">
<view class="assessment-content">
<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 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">
@@ -19,7 +19,7 @@
<view v-else-if="step == 1" class="form-list">
<template v-if="assessmentType == 'enterprise'">
<AssessmentSelect label="经营行业" v-model="form.occupation" :options="['商贸零售', '制造业', '科技服务', '餐饮住宿', '专业服务', '其他行业']" />
<AssessmentSelect label="经营年限" v-model="form.operatingYears" :options="['不足 1 年', '1–3 年', '3–5 年', '5 年以上']" />
<view class="form-row"><text class="field-label">经营月数</text><input v-model="form.operatingMonths" class="field-input" type="number" maxlength="4" placeholder="请输入实际经营月数" /></view>
<view class="form-row"><text class="field-label">年营收(万元)</text><input v-model="form.annualRevenue" class="field-input" type="digit" placeholder="请输入年营业收入" /></view>
</template>
<template v-else>
@@ -29,6 +29,14 @@
<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>
@@ -58,41 +66,65 @@
</template>
<script setup lang="uts">
import { getUserStorage, removeUserStorage } from '@/services/storage.uts'
import { getSession } from '@/services/api.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'
import AssessmentSelect from '@/packages/finance/components/AssessmentSelect.uvue'
import { AssessmentForm, SelectedDocument, DataAuthorization, emptyAssessmentForm, activeAssessmentType, loadAssessmentDraft, formFromDraft, documentsFromDraft, saveAssessmentDraft, showAgreementNotice, assetValidationMessage } from '@/services/assessment.uts'
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 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 saveDraft = () => {
if (!initialized) return
const rememberInput = () => {
if (!initialized || !sameOwner()) return
const source = form.value
const draftForm: 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 }
if (draftForm.city == '其他城市' && customCity.value.trim().length > 0) draftForm.city = customCity.value.trim()
saveAssessmentDraft(assessmentType.value, step.value, draftForm, documents.value, authorizations.value, documentConsent.value, dataConsent.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 }
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)
}
const restore = (type: string) => {
assessmentType.value = type; step.value = 0; form.value = emptyAssessmentForm(); customCity.value = ''; documents.value = []; documentConsent.value = false; dataConsent.value = false
authorizations.value.forEach((item) => { item.selected = false })
const saved = loadAssessmentDraft(type)
const saved = getAssessmentInput(type)
if (saved != null) {
form.value = formFromDraft(saved); documents.value = documentsFromDraft(saved)
form.value = formFromInput(saved); documents.value = documentsFromInput(saved)
const savedStep = saved['step']; if (typeof savedStep == 'number') step.value = Math.max(0, Math.min(3, savedStep as number))
documentConsent.value = saved['documentConsent'] == true; dataConsent.value = saved['dataConsent'] == true
const grants = saved['authorizations'] as string[] | null
@@ -100,27 +132,41 @@
if (form.value.city.length > 0 && !['深圳市', '广州市', '北京市', '上海市', '杭州市', '其他城市'].includes(form.value.city)) { customCity.value = form.value.city; form.value.city = '其他城市' }
}
initialized = true
saveDraft()
rememberInput()
}
const switchType = (type: string) => { if (assessmentType.value == type) return; saveDraft(); restore(type); step.value = 0; saveDraft() }
const switchType = (type: string) => { if (assessmentType.value == type) return; rememberInput(); restore(type); step.value = 0; rememberInput() }
onLoad((options) => {
const session = getSession()
if (session == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
ownerToken = session['token'] as string
clearAssessmentFlow()
let type = activeAssessmentType()
if (options != null && (options['type'] == 'enterprise' || options['type'] == 'personal')) type = options['type'] as string
restore(type)
loadPurposes()
})
onShow(() => {
const requestedStep = getUserStorage('assessmentEditStep')
if (typeof requestedStep == 'number') { step.value = Math.max(0, Math.min(3, requestedStep as number)); removeUserStorage('assessmentEditStep'); saveDraft() }
})
onHide(() => saveDraft())
onUnload(() => saveDraft())
// 系统返回与顶部返回均先保存;页内返回优先回到上一步。
onBackPress((): boolean => { saveDraft(); if (step.value > 0) { step.value--; saveDraft(); return true }; return false })
const previous = () => { saveDraft(); if (step.value > 0) step.value--; saveDraft() }
const back = () => { if (step.value > 0) { previous(); return }; saveDraft(); if (getCurrentPages().length > 1) uni.navigateBack(); else uni.redirectTo({ url: '/packages/finance/entry/entry?type=' + assessmentType.value }) }
onShow(() => {
goingToConfirm = false
if (initialized && !sameOwner()) { initialized = false; form.value = emptyAssessmentForm(); clearAssessmentFlow(); uni.redirectTo({ url: '/packages/account/login/login' }) }
const input = getAssessmentInput(assessmentType.value)
if (initialized && input != null && typeof input['step'] == 'number') step.value = Math.max(0, Math.min(3, input['step'] as number))
})
// 离开填写流程即丢弃未提交内容;进入确认页时只在内存传递。
onHide(() => {
if (!goingToConfirm) {
clearAssessmentFlow(); form.value = emptyAssessmentForm(); step.value = 0; documents.value = []
customCity.value = ''; documentConsent.value = false; dataConsent.value = false
authorizations.value.forEach((item) => { item.selected = false })
}
})
onUnload(() => { 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() }
const back = () => { if (step.value > 0) { previous(); return }; rememberInput(); if (getCurrentPages().length > 1) uni.navigateBack(); else uni.redirectTo({ url: '/packages/finance/entry/entry?type=' + assessmentType.value }) }
const documentPath = (name: string): string => { const item = documents.value.find((file) => file.name == name); return item == null ? '' : item.path }
const documentExpired = (name: string) => {
documents.value = documents.value.filter((file) => file.name != name); saveDraft()
documents.value = documents.value.filter((file) => file.name != name); rememberInput()
uni.showToast({ title: name + '已失效,请重新选择', icon: 'none' })
}
const chooseDocument = (name: string) => {
@@ -128,7 +174,7 @@
if (result.tempFilePaths.length == 0) return
documents.value = documents.value.filter((file) => file.name != name)
const document: SelectedDocument = { name, path: result.tempFilePaths[0] }
documents.value.push(document); saveDraft()
documents.value.push(document); rememberInput()
}, fail: (error) => { if (!error.errMsg.includes('cancel')) uni.showToast({ title: '选择失败,请重试或检查相册权限', icon: 'none' }) } })
}
const documentAction = (name: string) => {
@@ -137,7 +183,7 @@
uni.showActionSheet({ itemList: ['预览图片', '重新选择', '移除资料'], success: (result) => {
if (result.tapIndex == 0) uni.previewImage({ urls: [path], fail: () => { uni.showToast({ title: '本地图片已失效,请重新选择', icon: 'none' }) } })
else if (result.tapIndex == 1) chooseDocument(name)
else if (result.tapIndex == 2) { documents.value = documents.value.filter((file) => file.name != name); saveDraft() }
else if (result.tapIndex == 2) { documents.value = documents.value.filter((file) => file.name != name); rememberInput() }
} })
}
const validateStep = (): boolean => {
@@ -153,6 +199,9 @@
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 = '请阅读并同意资料使用协议,或移除资料'
@@ -160,7 +209,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++; saveDraft() } else { saveDraft(); uni.navigateTo({ url: '/packages/finance/confirm/confirm' }) } }
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 } }) } }
</script>
<style>
@@ -182,6 +231,9 @@
.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; }
+39 -20
View File
@@ -5,29 +5,29 @@
<view class="confirm-content">
<view class="confirm-progress"><view class="progress-line"><view class="progress-segment" v-for="i in 5" :key="i"></view></view><text class="progress-count">5/5</text></view>
<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="!hasDraft" class="empty-hint">还没有评估信息,请先完成信息填写。</text>
<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>
<view class="submit-bar" :style="bottomStyle(20)"><button class="primary-button submit-button" :disabled="submitting" @click="submit">{{ !hasDraft ? '去填写信息' : (submitting ? '提交中…' : '确认并开始评估') }}</button></view>
<view class="submit-bar" :style="bottomStyle(20)"><button class="primary-button submit-button" :disabled="submitting" @click="submit">{{ !hasInput ? '去填写信息' : (submitting ? '提交中…' : '确认提交资料') }}</button></view>
</view>
</template>
<script setup lang="uts">
import { setUserStorage } from '@/services/storage.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'
import { activeAssessmentType, loadAssessmentDraft, draftText, documentsFromDraft, ownershipFromDraft, formFromDraft, assetValidationMessage } from '@/services/assessment.uts'
import { activeAssessmentType, getAssessmentInput, draftText, documentsFromInput, ownershipFromInput, formFromInput, assetValidationMessage, clearAssessmentFlow } from '@/services/assessment.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
type InfoRow = { label: string, value: string }
type InfoSection = { title: string, step: number, rows: InfoRow[], expanded: boolean }
const submitting = ref(false)
const hasDraft = ref(false)
const hasInput = ref(false)
const assessmentType = ref('personal')
const sections = ref<InfoSection[]>([])
const payload = ref<UTSJSONObject>({ source: 'mobile', confirmed: false })
@@ -35,11 +35,11 @@
const displayValue = (saved: UTSJSONObject, key: string): string => { const value = draftText(saved, key); return value.length > 0 ? value : '未填写' }
const appendRow = (rows: InfoRow[], label: string, value: string) => { const row: InfoRow = { label, value }; rows.push(row) }
const valueWithUnit = (saved: UTSJSONObject, key: string, unit: string): string => { const value = draftText(saved, key); return value.length > 0 ? value + ' ' + unit : '未填写' }
const loadDraft = () => {
const loadInput = () => {
assessmentType.value = activeAssessmentType()
const saved = loadAssessmentDraft(assessmentType.value)
if (saved == null) { hasDraft.value = false; sections.value = []; return }
hasDraft.value = true
const saved = getAssessmentInput(assessmentType.value)
if (saved == null) { hasInput.value = false; sections.value = []; return }
hasInput.value = true
const enterprise = assessmentType.value == 'enterprise'
const basicRows: InfoRow[] = []
if (enterprise) {
@@ -54,11 +54,17 @@
appendRow(basicRows, '婚姻状况', displayValue(saved, 'maritalStatus'))
}
const incomeRows: InfoRow[] = enterprise ? [
{ label: '经营行业', value: displayValue(saved, 'occupation') }, { label: '经营年限', value: displayValue(saved, 'operatingYears') }, { label: '年营业收入', value: draftText(saved, 'annualRevenue').length > 0 ? draftText(saved, 'annualRevenue') + ' 万元' : '未填写' }
{ label: '经营行业', value: displayValue(saved, 'occupation') }, { label: '经营月数', value: valueWithUnit(saved, 'operatingMonths', '个月') }, { label: '年营业收入', value: draftText(saved, 'annualRevenue').length > 0 ? draftText(saved, 'annualRevenue') + ' 万元' : '未填写' }
] : [
{ 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 = documentsFromDraft(saved)
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[] = []
@@ -66,6 +72,7 @@
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', '万元') },
@@ -73,22 +80,27 @@
{ label: '银行信用卡张数', value: valueWithUnit(saved, 'creditCardCount', '张') },
{ label: '总额度', value: valueWithUnit(saved, 'creditCardTotalLimit', '万元') },
{ label: '已使用总额度', value: valueWithUnit(saved, 'creditCardUsedLimit', '万元') },
{ label: '是否有房', value: ownershipFromDraft(saved, 'house').length > 0 ? ownershipFromDraft(saved, 'house') : '未填写' },
{ label: '是否有车', value: ownershipFromDraft(saved, 'car').length > 0 ? ownershipFromDraft(saved, 'car') : '未填写' }
{ label: '是否有房', value: ownershipFromInput(saved, 'house').length > 0 ? ownershipFromInput(saved, 'house') : '未填写' },
{ label: '是否有车', value: ownershipFromInput(saved, 'car').length > 0 ? ownershipFromInput(saved, 'car') : '未填写' }
], expanded: false },
{ title: '资料与授权', step: 3, rows: [{ label: '本地资料', value: fileNames.length > 0 ? fileNames : '未选择' }, { label: '授权意向', value: grantNames.length > 0 ? grantNames.join('、') : '未选择' }, { label: '当前状态', value: '资料未上传 · 数据未查询' }], expanded: false }
]
payload.value = { ...saved, source: 'mobile', confirmed: true }
}
onShow(() => loadDraft())
onShow(() => loadInput())
onUnload(() => {
const pages = getCurrentPages()
if (!pages.some((page): boolean => page.route == 'packages/finance/assessment/assessment')) clearAssessmentFlow()
})
const edit = (step: number) => {
setUserStorage('assessmentEditStep', step)
const input = getAssessmentInput(assessmentType.value)
if (input != null) input['step'] = step
const pages = getCurrentPages()
if (pages.length > 1 && pages[pages.length - 2].route == 'packages/finance/assessment/assessment') uni.navigateBack({ delta: 1 })
else uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + assessmentType.value })
}
const submit = async () => {
if (!hasDraft.value) { edit(0); return }
if (!hasInput.value) { edit(0); return }
if (submitting.value) return
const saved = payload.value
const amount = draftText(saved, assessmentType.value == 'enterprise' ? 'annualRevenue' : 'monthlyIncome')
@@ -96,12 +108,19 @@
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 ((documentsFromDraft(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(formFromDraft(saved))
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))
if (assetError.length > 0) { uni.showToast({ title: assetError, icon: 'none' }); edit(2); return }
submitting.value = true
try { await submitAssessment(payload.value); uni.redirectTo({ url: '/packages/finance/analyzing/analyzing' }) }
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' }) }
finally { submitting.value = false }
}
+62 -41
View File
@@ -1,47 +1,68 @@
<template>
<view class="page" :style="pageStyle()">
<AppHeader title="融资需求报告" />
<scroll-view scroll-y class="report-scroll" :style="contentStyle(96, false)">
<view class="report-content">
<view class="score-panel"><text class="score-label">综合评分</text><view class="score-row"><text class="big-score">85</text><text class="score-unit">分</text><text class="score-badge">良好</text></view><text class="score-caption">从五个维度了解融资资质</text></view>
<view class="radar-panel"><image class="radar-image" src="/packages/finance/static/finance-radar.png" mode="aspectFit"></image><text class="radar-label top">还款能力</text><text class="radar-label left">收入稳定</text><text class="radar-label right">信用状况</text><text class="radar-label bottom-left">资产条件</text><text class="radar-label bottom-right">负债情况</text></view>
<view class="conclusion"><text class="section-title">评估结论</text><text class="conclusion-text">可进一步比较信用融资产品的额度、期限与还款安排,结合自身需求选择合适方案。</text><view class="suggest-row"><text class="suggest-label">建议期限</text><text class="suggest-value">6–36个月</text></view><text class="report-note">报告内容仅供参考,实际融资结果以授权资料和机构审核为准。</text></view>
<button class="primary-button" @click="viewMatches">查看匹配方案</button>
</view>
</scroll-view>
</view>
<view class="page" :style="pageStyle()">
<AppHeader title="融资资料分析报告" />
<scroll-view scroll-y :style="contentStyle(0, false)">
<view class="report-content">
<text class="report-note">评估记录 · {{ assessmentId }}</text>
<text v-if="loading" class="body-text">正在加载报告…</text>
<view v-if="error.length > 0" class="panel"><text class="body-text">{{ error }}</text><text class="action" @click="loadReport">重新加载</text><text class="action" @click="history">前往融资档案</text></view>
<template v-if="report != null">
<text class="report-note">分析模型:{{ report!.modelCode }}</text>
<template v-if="report!.version == 2">
<view class="panel"><text class="section-title">需求概况</text><view class="fact-row" v-for="fact in report!.demand" :key="fact.label"><text class="fact-label">{{ fact.label }}</text><text class="fact-value">{{ fact.value }}</text></view></view>
<view class="panel"><text class="section-title">资料完整度</text><text class="readiness-value">{{ report!.completenessText }}</text><text class="report-note">仅统计当前适用的自填字段,不代表信用评分或真实性。</text><view class="fact-row" v-for="item in report!.completenessItems" :key="item.label"><text class="fact-label">{{ item.label }}</text><text class="fact-value">{{ item.value }}</text></view></view>
<view class="panel"><text class="section-title">融资准备状态</text><text class="readiness-value">{{ report!.readinessLabel }}</text><text class="body-text" v-for="(item, index) in report!.readinessBasis" :key="index">{{ item }}</text><text class="report-note">资料未经核验;即使完整度达到100%,仍需核对机构要求的材料。</text></view>
</template>
<view class="panel"><text class="section-title">自填资料分析</text><text class="body-text">{{ report!.summary }}</text><text class="report-note">{{ report!.disclaimer }}</text></view>
<view class="panel"><text class="section-title">本次提交的事实</text><text class="report-note">来源:用户自填,未经外部核验;未填写不等于零。</text><view class="fact-row" v-for="fact in report!.facts" :key="fact.label"><text class="fact-label">{{ fact.label }}</text><text class="fact-value">{{ fact.value }}</text></view></view>
<view class="panel"><text class="section-title">{{ report!.version == 2 ? '主要优势' : '资料解读' }}</text><text v-if="report!.observations.length == 0" class="report-note">暂无有充分依据的优势说明。</text><text class="body-text" v-for="(item, index) in report!.observations" :key="index">{{ item }}</text></view>
<view v-if="report!.suggestions.length > 0" class="panel"><text class="section-title">完善建议</text><text class="body-text" v-for="(item, index) in report!.suggestions" :key="index">{{ item }}</text></view>
<view class="panel"><text class="section-title">{{ report!.version == 2 ? '风险提示' : '待补充信息' }}</text><text class="body-text" v-for="(item, index) in report!.warnings" :key="'system-' + index">{{ item }}</text><text class="body-text" v-for="(item, index) in report!.dataGaps" :key="index">{{ item }}</text></view>
<view class="panel"><text class="section-title">产品匹配</text><text class="body-text">{{ report!.matchingMessage }}</text></view>
<text class="action" @click="history">查看历史评估</text>
</template>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { usePageLayout } from '@/services/layout.uts'
const { contentStyle, pageStyle } = usePageLayout()
import AppHeader from '@/components/AppHeader.uvue'
const viewMatches = () => { uni.navigateTo({ url: '/packages/finance/matches/matches' }) }
import AppHeader from '@/components/AppHeader.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { AnalysisReport, fetchAnalysisReport } from '@/packages/finance/services/records.uts'
const { contentStyle, pageStyle } = usePageLayout()
const assessmentId = ref(''); const loading = ref(false); const error = ref('')
const report = ref<AnalysisReport | null>(null)
let generation = 0; let visible = false
const loadReport = async () => {
const request = ++generation
report.value = null; error.value = ''
if (assessmentId.value.length == 0) { error.value = '请从融资档案选择已完成的评估记录。'; return }
const session = getSession()
if (session == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
const owner = session['token'] as string
loading.value = true
try {
const result = await fetchAnalysisReport(assessmentId.value)
if (visible && request == generation && getSession()?.getString('token') == owner) report.value = result
} catch (err) { if (visible && request == generation) error.value = err instanceof Error ? err.message : '报告加载失败,请重试' }
finally { if (request == generation) loading.value = false }
}
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
onLoad((options) => { if (options != null) assessmentId.value = options['assessmentId'] ?? '' })
onShow(() => { visible = true; loadReport() })
// 报告只留当前可见页面,账户切换或离开后不能由迟到请求恢复旧资料。
onHide(() => { visible = false; generation++; report.value = null; error.value = '' })
onUnload(() => { visible = false; generation++ })
</script>
<style>
.report-scroll { }
.report-content { padding: 28rpx 30rpx 80rpx; }
.score-panel { background-color: #fff; border-radius: 18rpx; padding: 30rpx; border: 1rpx solid #e7edf6; }
.score-label { font-size: 24rpx; color: #6f7d91; }
.score-row { flex-direction: row; align-items: flex-end; margin-top: 8rpx; }
.big-score { color: #102557; font-size: 82rpx; line-height: 90rpx; font-weight: 900; }
.score-unit { font-size: 28rpx; margin: 0 16rpx 14rpx 5rpx; }
.score-badge { padding: 8rpx 17rpx; border-radius: 10rpx; background-color: #e5f8ee; color: #0ba868; font-size: 22rpx; margin-bottom: 15rpx; }
.score-caption { color: #8793a6; font-size: 22rpx; margin-top: 6rpx; }
.radar-panel { height: 370rpx; margin-top: 20rpx; border-radius: 18rpx; background-color: #fff; border: 1rpx solid #e7edf6; align-items: center; justify-content: center; position: relative; overflow: hidden; }
.radar-image { width: 340rpx; height: 310rpx; }
.radar-core { position: absolute; width: 92rpx; height: 92rpx; left: 50%; top: 50%; margin-left: -46rpx; margin-top: -46rpx; border-radius: 50%; background-color: #0868f7; align-items: center; justify-content: center; color: white; font-size: 34rpx; font-weight: 800; box-shadow: 0 0 32rpx rgba(8,104,247,.28); }
.radar-label { position: absolute; color: #58667b; font-size: 21rpx; }
.top { top: 28rpx; }
.left { left: 27rpx; top: 162rpx; }
.right { right: 27rpx; top: 162rpx; }
.bottom-left { left: 95rpx; bottom: 32rpx; }
.bottom-right { right: 86rpx; bottom: 32rpx; }
.conclusion { margin: 24rpx 0 32rpx; background-color: #fff; border-radius: 18rpx; padding: 28rpx; border: 1rpx solid #e7edf6; }
.conclusion-text { margin-top: 18rpx; font-size: 24rpx; color: #59677b; line-height: 42rpx; }
.suggest-row { margin-top: 24rpx; padding-top: 22rpx; border-top: 1rpx solid #edf1f6; flex-direction: row; justify-content: space-between; font-size: 25rpx; }
.suggest-value { color: #0868f7; font-size: 27rpx; font-weight: 700; }
.suggest-label { color: #53667f; font-size: 25rpx; }
.report-note { font-size: 21rpx; line-height: 34rpx; color: #78889e; margin-top: 20rpx; }
.report-content { padding: 28rpx 30rpx 80rpx; }
.panel { margin-top: 24rpx; padding: 28rpx; border-radius: 18rpx; background-color: #fff; border: 1rpx solid #e7edf6; }
.body-text { margin-top: 20rpx; font-size: 27rpx; color: #42536b; line-height: 44rpx; }
.report-note { font-size: 23rpx; line-height: 38rpx; color: #78889e; margin-top: 18rpx; }
.fact-row { flex-direction: row; padding: 22rpx 0; border-bottom: 1rpx solid #edf1f6; }
.fact-label { flex: 1; font-size: 25rpx; color: #738198; }
.fact-value { flex: 1; text-align: right; font-size: 26rpx; color: #23344e; }
.action { padding: 26rpx 0; color: #0868f7; font-size: 27rpx; }
.readiness-value { margin-top: 20rpx; font-size: 32rpx; line-height: 48rpx; font-weight: bold; color: #23344e; }
</style>
+2 -12
View File
@@ -1,20 +1,10 @@
import { setUserStorage } from '@/services/storage.uts'
import { API_DEMO_ENABLED, API_BASE_URL, wait, requestJson } from '@/services/api.uts'
import { demoProducts, parseProduct, FinanceProduct } from './finance.uts'
import { assessmentApiPayload } from '@/services/assessment.uts'
import { createAssessment } from './records.uts'
export { API_BASE_URL } from '@/services/api.uts'
export async function submitAssessment(payload: UTSJSONObject): Promise<string> {
const apiPayload = assessmentApiPayload(payload)
if (API_DEMO_ENABLED) {
await wait(650)
setUserStorage('latestAssessment', apiPayload)
return 'ASSESSMENT-DEMO-001'
}
const result = await requestJson('/assessments', 'POST', apiPayload)
const id = result['id'] as string | null
if (id == null || id.length == 0) throw new Error('评估提交成功但未返回编号')
return id
return await createAssessment(payload)
}
export async function fetchMatches(): Promise<FinanceProduct[]> {
+170
View File
@@ -0,0 +1,170 @@
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'
export type AssessmentRecord = { id: string, type: string, status: string, statusLabel: string, message: string, city: string, submittedAt: string, taskId: string, stage: string, canRetry: boolean }
export type AssessmentPage = { items: AssessmentRecord[], hasMore: boolean, nextCursor: string }
export type AssessmentField = { label: string, value: string }
export type AssessmentDetail = { record: AssessmentRecord, fields: AssessmentField[] }
function recordFrom(item: UTSJSONObject): AssessmentRecord {
const submitted = draftText(item, 'submittedAt')
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 }
}
export async function retryAssessment(id: string, requestId: string): Promise<void> {
await requestJson('/assessments/' + encodeURIComponent(id) + '/retry', 'POST', { requestId })
}
export type AnalysisReport = { version: number, summary: string, facts: AssessmentField[], observations: string[], suggestions: string[], dataGaps: string[], disclaimer: string, matchingMessage: string, modelCode: string, demand: AssessmentField[], completenessText: string, completenessItems: AssessmentField[], readinessLabel: string, readinessBasis: string[], warnings: string[] }
export async function fetchAnalysisReport(id: string): Promise<AnalysisReport> {
const result = await requestJson('/assessments/' + encodeURIComponent(id) + '/report', 'GET', {})
const version = result['resultVersion'] as number
if (result['assessmentId'] != id || (version != 1 && version != 2) || (version == 1 && result['reportType'] != 'self_reported_analysis') || (version == 2 && result['reportType'] != 'financing_needs_analysis')) throw new Error('报告格式不支持')
const facts: AssessmentField[] = []
const rawFacts = result['facts'] as UTSJSONObject[]
rawFacts.forEach((fact: UTSJSONObject) => {
const raw = fact['value']
let value = raw == null ? '未填写' : (typeof raw == 'number' ? (raw as number).toString() : draftText(fact, 'value'))
if (fact['value'] != null && fact['unit'] == 'yuan') value = yuanToWan(value) + ' 万元'
if (fact['value'] != null && fact['unit'] == 'month') value += ' 个月'
if (fact['state'] == 'invalid') value = '格式需核实'
const enumLabels: UTSJSONObject = { six_months_plus: '连续缴纳6个月以上', under_six_months: '缴纳不足6个月', none: '无' }
if (enumLabels[value] != null) value = enumLabels[value] as string
// v2的需求在独立区块展示,避免用途代码和同一金额重复出现。
if (version == 2 && ['requestedAmountYuan', 'requestedTermMonths', 'purposeCode'].includes(draftText(fact, 'fieldKey'))) return
facts.push({ label: draftText(fact, 'label'), value } as AssessmentField)
})
if (version == 2) {
const demandData = result['demand'] as UTSJSONObject
const completeness = result['completeness'] as UTSJSONObject
const readiness = result['readiness'] as UTSJSONObject
const items = completeness['items'] as UTSJSONObject[]
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[] = [
{ label: '融资金额', value: yuanToWan(draftText(demandData, 'requestedAmountYuan')) + ' 万元' },
{ label: '融资期限', value: (demandData['requestedTermMonths'] as number).toString() + ' 个月' },
{ label: '资金用途', value: draftText(demandData, 'purposeLabel') }
]
const evaluated = completeness['status'] == 'evaluated'
const completenessText = evaluated ? (completeness['percent'] as number).toString() + '% · 已填写 ' + (completeness['providedCount'] as number).toString() + ' / ' + (completeness['applicableCount'] as number).toString() + ' 项' : '资料完整度规则待配置'
// 模型文本仅显示已通过后端验证的证据引用;准备状态不由模型推导。
const explain = (key: string): string[] => (result[key] as UTSJSONObject[]).map((item: UTSJSONObject): string => {
const labels = (item['evidenceRefs'] as string[]).map((ref: string): string => {
if (ref.startsWith('fact:')) {
const fact = rawFacts.find((entry: UTSJSONObject): boolean => draftText(entry, 'fieldKey') == ref.slice(5))
return fact == null ? '' : draftText(fact, 'label')
}
const entry = basis.find((entry: UTSJSONObject): boolean => draftText(entry, 'basisId') == ref.slice(10))
return entry == null ? '' : draftText(entry, 'message')
}).filter((label: string): boolean => label.length > 0)
return draftText(item, 'text') + (labels.length > 0 ? '(依据:' + labels.join(';') + ')' : '')
})
return { version, summary: draftText(result, 'summary'), facts, observations: explain('strengths'), suggestions: explain('improvements'), dataGaps: explain('riskWarnings'),
disclaimer: draftText(result, 'disclaimer'), matchingMessage: '产品资料及匹配规则尚未就绪,本报告未执行产品匹配。', modelCode: draftText(result, 'modelCode'),
demand, completenessText, completenessItems, readinessLabel: readiness['status'] == 'evaluated' ? draftText(readiness, 'label') : '未评定',
readinessBasis: basis.map((item: UTSJSONObject): string => draftText(item, 'message')), warnings: result['systemWarnings'] as string[] }
}
const observations = (result['observations'] as UTSJSONObject[]).map((item: UTSJSONObject): string => {
const fact = rawFacts.find((entry: UTSJSONObject): boolean => entry['id'] == item['factId'])
return (fact == null ? '' : draftText(fact, 'label') + ':') + draftText(item, 'text')
})
return { version, summary: draftText(result, 'summary'), facts, observations, suggestions: result['suggestions'] as string[], dataGaps: result['dataGaps'] as string[], disclaimer: draftText(result, 'disclaimer'), matchingMessage: draftText(result, 'matchingMessage'), modelCode: draftText(result, 'modelCode'), demand: [], completenessText: '旧版报告未计算资料完整度', completenessItems: [], readinessLabel: '未评定', readinessBasis: [], warnings: [] }
}
function enumValue(value: string, labels: string[], codes: string[]): string | null {
if (value.length == 0) return null
const index = labels.indexOf(value)
if (index < 0) throw new Error('选项已变化,请重新选择')
return codes[index]
}
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('资料上传与外部查询授权尚未开放,请移除资料及授权选择后提交')
const values = assessmentApiPayload(payload)
const form = new UTSJSONObject()
const textKeys = ['name', 'idCard', 'phone', 'city', 'occupation', 'company', 'enterpriseName', 'businessCode']
const moneyKeys = ['monthlyIncome', 'annualRevenue', 'totalDebtBalance', 'monthlyLoanPayment', 'creditCardTotalLimit', 'creditCardUsedLimit']
textKeys.concat(moneyKeys).forEach((key: string) => { const value = draftText(values, key).trim(); form[key] = value.length == 0 ? null : value })
if (form['idCard'] != null) form['idCard'] = (form['idCard'] as string).toUpperCase()
if (form['businessCode'] != null) form['businessCode'] = (form['businessCode'] as string).toUpperCase()
const countKeys = ['creditLoanCount', 'creditCardCount', 'operatingMonths']
countKeys.forEach((key: string) => {
const value = draftText(values, key)
if (value.length > 0 && !/^[0-9]{1,4}$/.test(value)) throw new Error('数量与经营月数须为0至9999的整数')
form[key] = value.length == 0 ? null : parseInt(value)
})
form['maritalStatus'] = enumValue(draftText(values, 'maritalStatus'), ['已婚', '未婚', '离异', '丧偶'], ['married', 'single', 'divorced', 'widowed'])
const socialKeys = ['socialSecurity', 'housingFund']
socialKeys.forEach((key: string) => { form[key] = enumValue(draftText(values, key), ['连续缴纳 6 个月以上', '缴纳不足 6 个月', '未缴纳'], ['six_months_plus', 'under_six_months', 'none']) })
const ownershipKeys = ['house', 'car']
ownershipKeys.forEach((key: string) => { form[key] = enumValue(draftText(values, key), ['有', '无'], ['yes', 'no']) })
const cityNames = ['深圳市', '广州市', '北京市', '上海市', '杭州市']
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)
const id = draftText(result, 'assessmentId')
if (id.length == 0) throw new Error('提交回执不完整,请重试')
return id
}
export async function fetchAssessments(cursor: string): Promise<AssessmentPage> {
const result = await requestJson('/assessments', 'GET', { limit: '20', cursor })
const items = result['items'] as UTSJSONObject[] | null
return { items: items == null ? [] : items.map((item: UTSJSONObject): AssessmentRecord => recordFrom(item)), hasMore: result['hasMore'] == true, nextCursor: draftText(result, 'nextCursor') }
}
export async function fetchAssessmentStatus(id: string): Promise<AssessmentRecord> {
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', {})
const form = result['form'] as UTSJSONObject | null
if (form == null) throw new Error('评估详情不完整')
const fields: AssessmentField[] = []
const keys = ['name', 'phone', 'city', 'idCard', 'enterpriseName', 'businessCode', 'occupation', 'company', 'operatingMonths', 'monthlyIncome', 'annualRevenue', 'creditLoanCount', 'totalDebtBalance', 'monthlyLoanPayment', 'creditCardCount', 'creditCardTotalLimit', 'creditCardUsedLimit', 'maritalStatus', 'socialSecurity', 'housingFund', 'house', 'car']
const legacy = result['inputVersion'] == null || result['inputVersion'] == 1
// 历史记录使用提交时冻结的用途标签,不拿当前字典重新解释旧需求。
fields.push({ label: '融资金额', value: legacy ? '当时未采集' : yuanToWan(draftText(form, 'requestedAmountYuan')) + ' 万元' } as AssessmentField)
const term = form['requestedTermMonths'] as number | null
fields.push({ label: '融资期限', value: legacy || term == null ? '当时未采集' : term.toString() + ' 个月' } as AssessmentField)
fields.push({ label: '资金用途', value: legacy ? '当时未采集' : draftText(form, 'purposeLabel') } as AssessmentField)
if (form['purposeCode'] == 'other') fields.push({ label: '用途说明', value: draftText(form, 'purposeDetail') } as AssessmentField)
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: '未缴纳' }
keys.forEach((key: string, index: number) => {
const raw = form[key]
let value = raw == null ? '未填写' : (typeof raw == 'number' ? (raw as number).toString() : raw as string)
if (raw != null && moneyKeys.includes(key)) value = yuanToWan(value) + ' 万元'
if (raw != null && key == 'operatingMonths') value += ' 个月'
if (['maritalStatus', 'socialSecurity', 'housingFund', 'house', 'car'].includes(key) && enumLabels[value] != null) value = enumLabels[value] as string
const personalOnly = ['idCard', 'company', 'monthlyIncome', 'maritalStatus', 'socialSecurity', 'housingFund']
const enterpriseOnly = ['enterpriseName', 'businessCode', 'operatingMonths', 'annualRevenue']
if ((result['type'] == 'enterprise' && personalOnly.includes(key)) || (result['type'] == 'personal' && enterpriseOnly.includes(key))) return
const field: AssessmentField = { label: labels[index], value }; fields.push(field)
})
return { record: recordFrom(result), fields }
}