feat: 实现协议全文与同意记录功能
新增协议全文后台管理、版本化发布及本人同意记录功能,支持可撤销授权;完善资料与授权相关边界提示,调整评估提交时的资料与授权校验逻辑,同步更新相关页面文案与路由。
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<view class="page" :style="pageStyle()"><AppHeader title="隐私与授权" />
|
||||
<scroll-view scroll-y :style="contentStyle(96, false)"><view class="consents-content" :style="bottomStyle(40)">
|
||||
<view class="intro-card"><text class="intro-title">我的同意记录</text><text class="intro-copy">记录所见协议版本与确认时间。可选授权可撤销;同意记录不代表已上传资料、查询征信或发起外部数据查询。</text><view class="policy-links"><text class="policy-link" @click="openAgreement('terms')">用户协议</text><text class="policy-link" @click="openAgreement('privacy')">隐私政策</text><text class="policy-link" @click="openAgreement('document-use')">资料使用</text><text class="policy-link" @click="openAgreement('data-authorization')">数据授权</text></view></view>
|
||||
<view class="filter-row"><view v-for="item in filters" :key="item.scope" :class="scope == item.scope ? 'filter active' : 'filter'" @click="chooseScope(item.scope)"><text :class="scope == item.scope ? 'filter-copy active-copy' : 'filter-copy'">{{ item.title }}</text></view></view>
|
||||
<view v-if="pending != null" class="pending-card"><text class="pending-copy">{{ consentTitle(pending!.agreementCode) }}({{ pending!.agreementVersion }})撤销结果待确认。再次确认会沿用原请求。</text><button class="secondary-button retry-button" :disabled="revoking" @click="retryRevoke">{{ revoking ? '正在确认…' : '再次确认撤销' }}</button></view>
|
||||
<text v-if="actionError.length > 0" class="error-copy">{{ actionError }}</text>
|
||||
<view class="list-heading"><text class="count-copy">{{ total }} 条记录</text><text class="refresh-link" @click="reload">刷新</text></view>
|
||||
<view v-if="loading && records.length == 0" class="empty-content"><text class="empty-copy">正在加载同意记录…</text></view>
|
||||
<view v-else-if="error.length > 0 && records.length == 0" class="empty-content"><text class="empty-title">记录加载失败</text><text class="empty-copy">{{ error }}</text><button class="primary-button empty-button" @click="reload">重新加载</button></view>
|
||||
<view v-else-if="records.length == 0" class="empty-content"><text class="empty-title">暂无同意记录</text><text class="empty-copy">正式协议发布并确认后,记录会显示在这里。</text></view>
|
||||
<view v-for="item in records" :key="item.consentId" class="record-card">
|
||||
<view class="record-heading"><text class="record-title">{{ consentTitle(item.agreementCode) }}</text><text :class="item.revokedAt.length > 0 ? 'record-status revoked' : 'record-status'">{{ item.revokedAt.length > 0 ? '已撤销' : (optionalConsentScope(item.scope) ? '已同意' : '已确认') }}</text></view>
|
||||
<text class="record-meta">协议版本:{{ item.agreementVersion }}</text><text class="record-meta">同意时间:{{ consentTime(item.grantedAt) }}</text><text v-if="item.revokedAt.length > 0" class="record-meta">撤销时间:{{ consentTime(item.revokedAt) }}</text>
|
||||
<text v-if="!optionalConsentScope(item.scope)" class="record-note">账户使用协议确认记录;可查看当时版本。</text>
|
||||
<view class="record-actions"><text class="action-link" @click="openAgreement(item.agreementCode, item.agreementVersion)">查看当时版本</text><text v-if="item.canRevoke && optionalConsentScope(item.scope) && item.revokedAt.length == 0" class="revoke-link" @click="confirmRevoke(item)">撤销可选授权</text></view>
|
||||
</view>
|
||||
<text v-if="error.length > 0 && records.length > 0" class="error-copy">{{ error }}</text>
|
||||
<button v-if="records.length > 0 && hasMore" class="secondary-button more-button" :disabled="loading" @click="loadMore">{{ loading ? '正在加载…' : '加载更多' }}</button>
|
||||
<text v-if="records.length > 0 && !hasMore" class="end-copy">已显示全部记录</text>
|
||||
</view></scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="uts">
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { Consent, fetchConsents, agreementRoute, consentTitle, consentTime, optionalConsentScope, revokeConsent, consentRequestId, sameConsentSession } from '@/services/agreements.uts'
|
||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||
type ScopeFilter = { scope: string, title: string }
|
||||
const filters: ScopeFilter[] = [{ scope: '', title: '全部' }, { scope: 'account-terms', title: '用户协议' }, { scope: 'account-privacy', title: '隐私政策' }, { scope: 'documents', title: '资料' }, { scope: 'data', title: '数据' }]
|
||||
const scope = ref(''); const records = ref<Consent[]>([]); const total = ref(0); const hasMore = ref(false); const loading = ref(false); const error = ref('')
|
||||
const pending = ref<Consent | null>(null); const revoking = ref(false); const actionError = ref('')
|
||||
let pendingId = ''; let page = 1; let visible = false; let generation = 0; let actionGeneration = 0; let ownerToken = ''; let ownerId = ''; let accountTimer = 0
|
||||
const clearView = () => { records.value = []; total.value = 0; hasMore.value = false; loading.value = false; error.value = ''; generation++; actionGeneration++; revoking.value = false }
|
||||
const sameOwner = (): boolean => {
|
||||
if (!visible) return false
|
||||
const current = getSession()
|
||||
if (sameConsentSession(ownerToken, ownerId, current?.getString('token') ?? '', current?.getString('userId') ?? '')) return true
|
||||
clearView(); pending.value = null; pendingId = ''; actionError.value = ''; clearInterval(accountTimer)
|
||||
uni.redirectTo({ url: current == null ? '/packages/account/login/login?resumeConsents=1' : '/packages/account/profile/profile' })
|
||||
return false
|
||||
}
|
||||
async function loadPage(nextPage: number, reset: boolean) {
|
||||
if (!sameOwner() || loading.value) return
|
||||
const request = ++generation; const selectedScope = scope.value; loading.value = true; error.value = ''
|
||||
if (reset) { records.value = []; total.value = 0; hasMore.value = false }
|
||||
try {
|
||||
const result = await fetchConsents(selectedScope, nextPage)
|
||||
if (request != generation || !sameOwner()) return
|
||||
const merged = reset ? result.items : records.value.concat(result.items)
|
||||
const ids: string[] = []; const unique: Consent[] = []
|
||||
merged.forEach((item: Consent) => { if (!ids.includes(item.consentId)) { ids.push(item.consentId); unique.push(item) } })
|
||||
records.value = unique; total.value = result.total; hasMore.value = result.hasMore; page = nextPage
|
||||
} catch (err) { if (request == generation && sameOwner()) error.value = err instanceof Error ? err.message : '记录加载失败,请重试' }
|
||||
finally { if (request == generation) loading.value = false }
|
||||
}
|
||||
const reload = () => { if (loading.value) return; loadPage(1, true) }
|
||||
const loadMore = () => { if (hasMore.value && !loading.value) loadPage(page + 1, false) }
|
||||
const chooseScope = (value: string) => { if (!sameOwner() || value == scope.value) return; generation++; loading.value = false; scope.value = value; reload() }
|
||||
function openAgreement(code: string, version: string = '') { const url = agreementRoute(code, version); if (sameOwner() && url.length > 0) uni.navigateTo({ url }) }
|
||||
async function retryRevoke() {
|
||||
if (!sameOwner() || revoking.value || pending.value == null) return
|
||||
const item = pending.value!; const request = ++actionGeneration
|
||||
revoking.value = true; actionError.value = ''
|
||||
try {
|
||||
const result = await revokeConsent(item, pendingId)
|
||||
if (request != actionGeneration || !sameOwner()) return
|
||||
// 撤销前读取的列表快照不得覆盖回执;筛选/刷新/分页仍在途时重读当前范围第一页。
|
||||
const staleList = loading.value
|
||||
generation++; loading.value = false
|
||||
records.value = records.value.map((row: Consent): Consent => row.consentId == result.consentId ? result : row)
|
||||
pending.value = null; pendingId = ''; uni.showToast({ title: '可选授权已撤销', icon: 'none' })
|
||||
if (staleList) loadPage(1, true)
|
||||
} catch (err) { if (request == actionGeneration && sameOwner()) actionError.value = (err instanceof Error ? err.message : '撤销未确认') + ';请再次确认撤销。' }
|
||||
finally { if (request == actionGeneration) revoking.value = false }
|
||||
}
|
||||
const confirmRevoke = (item: Consent) => {
|
||||
if (!sameOwner() || revoking.value || !item.canRevoke || !optionalConsentScope(item.scope) || item.revokedAt.length > 0) return
|
||||
if (pending.value != null) { actionError.value = '请先确认上一项撤销结果。'; return }
|
||||
const request = actionGeneration; const view = generation
|
||||
uni.showModal({ title: '撤销可选授权', content: '撤销此条可选授权同意记录。历史确认记录仍可查看,当前未开放外部查询。', confirmText: '确认撤销', success: (result) => {
|
||||
if (!result.confirm || request != actionGeneration || view != generation || !sameOwner() || pending.value != null) return
|
||||
pending.value = item; pendingId = consentRequestId(); retryRevoke()
|
||||
} })
|
||||
}
|
||||
onShow(() => {
|
||||
visible = true; const session = getSession(); const token = session?.getString('token') ?? ''; const userId = session?.getString('userId') ?? ''
|
||||
if (token != ownerToken || userId != ownerId) { pending.value = null; pendingId = ''; actionError.value = '' }
|
||||
ownerToken = token; ownerId = userId; clearView(); clearInterval(accountTimer)
|
||||
if (!sameOwner()) return
|
||||
accountTimer = setInterval(() => { sameOwner() }, 1000); reload()
|
||||
})
|
||||
// 隐藏时只失效请求;离场过渡仍使用已挂载节点,返回后再按当前账户清空并重读。
|
||||
const hidePage = () => { visible = false; clearInterval(accountTimer); generation++; actionGeneration++; loading.value = false; revoking.value = false }
|
||||
onHide(() => hidePage())
|
||||
onUnload(() => { hidePage(); pending.value = null; pendingId = '' })
|
||||
</script>
|
||||
<style>
|
||||
.consents-content { padding: 30rpx; }
|
||||
.intro-card { padding: 26rpx; background-color: #edf5ff; border-radius: 18rpx; }
|
||||
.intro-title { color: #23344e; font-size: 32rpx; font-weight: 700; }
|
||||
.intro-copy { color: #66758a; font-size: 24rpx; line-height: 40rpx; margin-top: 14rpx; }
|
||||
.policy-links { flex-direction: row; flex-wrap: wrap; margin-top: 10rpx; }
|
||||
.policy-link { color: #0868f7; font-size: 24rpx; padding: 14rpx 18rpx 8rpx 0; }
|
||||
.filter-row { flex-direction: row; flex-wrap: wrap; margin-top: 24rpx; }
|
||||
.filter { padding: 16rpx 18rpx; margin-right: 10rpx; margin-bottom: 10rpx; border-radius: 12rpx; background-color: #ffffff; }
|
||||
.active { background-color: #e3efff; }
|
||||
.filter-copy { font-size: 24rpx; color: #66758a; }
|
||||
.active-copy { color: #0868f7; font-weight: 600; }
|
||||
.list-heading { flex-direction: row; justify-content: space-between; padding: 16rpx 0; }
|
||||
.count-copy { color: #748198; font-size: 24rpx; }
|
||||
.refresh-link { color: #0868f7; font-size: 24rpx; }
|
||||
.record-card { padding: 26rpx; background-color: #ffffff; border-radius: 18rpx; margin-top: 18rpx; }
|
||||
.record-heading { flex-direction: row; align-items: center; justify-content: space-between; }
|
||||
.record-title { flex: 1; min-width: 0; color: #29394f; font-size: 29rpx; font-weight: 600; }
|
||||
.record-status { color: #0868f7; font-size: 23rpx; margin-left: 12rpx; }
|
||||
.revoked { color: #8490a3; }
|
||||
.record-meta { color: #66758a; font-size: 23rpx; line-height: 36rpx; margin-top: 12rpx; }
|
||||
.record-note { color: #8793a6; font-size: 23rpx; line-height: 36rpx; margin-top: 12rpx; }
|
||||
.record-actions { flex-direction: row; justify-content: space-between; margin-top: 20rpx; }
|
||||
.action-link { color: #0868f7; font-size: 24rpx; padding: 10rpx 0; }
|
||||
.revoke-link { color: #bd594d; font-size: 24rpx; padding: 10rpx 0; }
|
||||
.pending-card { padding: 24rpx; background-color: #fff8ec; border-radius: 16rpx; margin-top: 18rpx; }
|
||||
.pending-copy { color: #7b643e; font-size: 24rpx; line-height: 38rpx; }
|
||||
.retry-button { margin-top: 18rpx; font-size: 26rpx; }
|
||||
.error-copy { color: #bd594d; font-size: 24rpx; line-height: 38rpx; margin-top: 18rpx; }
|
||||
.empty-content { padding: 72rpx 10rpx; align-items: center; }
|
||||
.empty-title { color: #172640; font-size: 30rpx; font-weight: 600; }
|
||||
.empty-copy { color: #66758a; font-size: 25rpx; line-height: 40rpx; margin-top: 20rpx; }
|
||||
.empty-button { width: 100%; margin-top: 28rpx; }
|
||||
.more-button { margin-top: 26rpx; }
|
||||
.end-copy { color: #8793a6; font-size: 23rpx; text-align: center; padding: 30rpx 0; }
|
||||
</style>
|
||||
@@ -7,15 +7,19 @@
|
||||
<text class="welcome">欢迎登录 AI融资</text>
|
||||
<text class="login-subtitle">智能评估,让融资更简单</text>
|
||||
<text class="form-heading">{{ mode == 'phone' ? '手机号登录 / 注册' : '密码登录' }}</text>
|
||||
<view class="input-box"><text class="input-prefix">+86</text><input v-model="phone" :disabled="loading" type="number" maxlength="11" placeholder="请输入手机号" class="field-input" /></view>
|
||||
<view v-if="mode == 'password'" class="input-box"><input v-model="password" :disabled="loading" password placeholder="请输入登录密码" class="field-input" /></view>
|
||||
<view v-if="mode == 'phone' && smsSent" class="input-box"><input v-model="smsCode" type="number" maxlength="6" placeholder="请输入 6 位验证码" class="field-input" /><text class="countdown" @click="resendSms">{{ countdown > 0 ? countdown.toString() + 's 后重试' : '重新获取' }}</text></view>
|
||||
<view class="input-box"><text class="input-prefix">+86</text><input v-model="phone" :disabled="loading || authenticated" type="number" maxlength="11" placeholder="请输入手机号" class="field-input" /></view>
|
||||
<view v-if="mode == 'password'" class="input-box"><input v-model="password" :disabled="loading || authenticated" password placeholder="请输入登录密码" class="field-input" /></view>
|
||||
<view v-if="mode == 'phone' && smsSent" class="input-box"><input v-model="smsCode" :disabled="loading || authenticated" type="number" maxlength="6" placeholder="请输入 6 位验证码" class="field-input" /><text class="countdown" @click="resendSms">{{ countdown > 0 ? countdown.toString() + 's 后重试' : '重新获取' }}</text></view>
|
||||
<text v-if="errorMessage.length > 0" class="error-message">{{ errorMessage }}</text>
|
||||
<button class="login-button" :disabled="loading" @click="login">{{ loading ? '处理中…' : (mode == 'phone' ? (smsSent ? '登录' : '获取验证码') : '登录') }}</button>
|
||||
<button class="login-button" :disabled="loading" @click="login">{{ loading ? '处理中…' : (authenticated ? '确认同意记录并继续' : (mode == 'phone' ? (smsSent ? '登录' : '获取验证码') : '登录')) }}</button>
|
||||
<view class="mode-row"><text class="mode-link" @click="switchMode">{{ mode == 'phone' ? '密码登录' : '使用验证码登录' }}</text></view>
|
||||
<view class="other-login"><view class="line"></view><text class="other-text">其他登录方式</text><view class="line"></view></view>
|
||||
<view class="social-row"><view class="social-button" @click="socialLogin('微信')"><image class="social-icon" src="/packages/account/static/wechat.svg" mode="aspectFit" /><text class="social-label">微信</text></view><view class="social-button" @click="socialLogin('一键登录')"><image class="social-icon" src="/packages/account/static/quick-login.svg" mode="aspectFit" /><text class="social-label">一键登录</text></view></view>
|
||||
<view class="agreement"><view class="check-hit" @click="agreed = !agreed"><view :class="agreed ? 'check checked' : 'check'"><text v-if="agreed" class="check-mark">✓</text></view></view><text class="agreement-copy">我已阅读并同意<text class="agreement-link" @click="showAgreement('用户协议')">《用户协议》</text>和<text class="agreement-link" @click="showAgreement('隐私政策')">《隐私政策》</text></text></view>
|
||||
<view class="agreement"><view class="check-hit" @click="toggleAgreement"><view :class="agreed ? 'check checked' : 'check'"><text v-if="agreed" class="check-mark">✓</text></view></view><text class="agreement-copy">我已阅读并同意<text class="agreement-link" @click="showAgreement('terms')">《用户协议》</text>和<text class="agreement-link" @click="showAgreement('privacy')">《隐私政策》</text></text></view>
|
||||
<text v-if="agreementState == 'ready'" class="agreement-note">确认版本:用户协议 {{ terms.version }} / 隐私政策 {{ privacy.version }}</text>
|
||||
<text v-else class="agreement-note">{{ agreementNote }}</text>
|
||||
<text v-if="agreementState == 'error' || agreementState == 'unavailable'" class="agreement-retry" @click="loadAgreements">重新加载协议</text>
|
||||
<text v-if="authenticated" class="agreement-note">登录已成功。确认同意记录失败时,点击上方按钮以原请求重试,无需再次验证密码或短信。</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
@@ -25,7 +29,8 @@
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import BrandLogo from '@/components/BrandLogo.uvue'
|
||||
import { API_BASE_URL, requestSmsCode, loginWithSms, loginWithPassword } from '@/packages/account/services/auth.uts'
|
||||
import { showAgreementNotice } from '@/services/assessment.uts'
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { Agreement, emptyAgreement, fetchAgreement, agreementRoute, agreementUnavailable, agreementChanged, grantConsent, consentRequestId, sameConsentSession } from '@/services/agreements.uts'
|
||||
import { consumeInitialLaunch } from '@/services/launch.uts'
|
||||
import { assistantReturnPath } from '@/services/assistant-return.uts'
|
||||
import { nextStepRoute } from '@/services/next-step-route.uts'
|
||||
@@ -56,6 +61,11 @@
|
||||
if (target.length > 0) afterLoginUrl = target
|
||||
} else if (options?.['resumePoints'] == '1') {
|
||||
afterLoginUrl = '/packages/account/points/points'
|
||||
} else if (options?.['resumeConsents'] == '1') {
|
||||
afterLoginUrl = '/packages/account/consents/consents'
|
||||
} else if (options?.['resumeAgreement'] == '1') {
|
||||
const target = agreementRoute(options?.['agreementCode'] ?? '')
|
||||
if (target.length > 0) afterLoginUrl = target
|
||||
}
|
||||
})
|
||||
const { statusStyle, contentStyle, pageStyle } = usePageLayout()
|
||||
@@ -68,14 +78,49 @@
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const agreed = ref(false)
|
||||
const authenticated = ref(false)
|
||||
const terms = ref<Agreement>(emptyAgreement()); const privacy = ref<Agreement>(emptyAgreement())
|
||||
const agreementState = ref('loading'); const agreementNote = ref('正在加载用户协议与隐私政策…')
|
||||
const isDemo = API_BASE_URL.length == 0
|
||||
const showAgreement = (title: string) => { showAgreementNotice(title) }
|
||||
let pageVisible = false; let generation = 0; let agreementGeneration = 0; let accountTimer = 0; let authToken = ''; let authUserId = ''
|
||||
type LoginConsentCommand = { agreement: Agreement, requestId: string }
|
||||
let pendingConsents: LoginConsentCommand[] = []; let confirmedCount = 0; let selectedTerms: Agreement | null = null; let selectedPrivacy: Agreement | null = null
|
||||
const showAgreement = (code: string) => { const item = code == 'terms' ? terms.value : privacy.value; const url = agreementRoute(code, item.version); if (pageVisible && url.length > 0) uni.navigateTo({ url }) }
|
||||
const sameAuthentication = (): boolean => { const current = getSession(); return sameConsentSession(authToken, authUserId, current?.getString('token') ?? '', current?.getString('userId') ?? '') }
|
||||
const clearAuthentication = () => { authToken = ''; authUserId = ''; authenticated.value = false; pendingConsents = []; confirmedCount = 0; agreed.value = false; selectedTerms = null; selectedPrivacy = null; generation++ }
|
||||
const toggleAgreement = () => {
|
||||
if (loading.value || agreementState.value != 'ready' || pendingConsents.length > 0) return
|
||||
agreed.value = !agreed.value
|
||||
selectedTerms = agreed.value ? terms.value : null; selectedPrivacy = agreed.value ? privacy.value : null
|
||||
}
|
||||
async function loadAgreements() {
|
||||
if (!pageVisible || loading.value || pendingConsents.length > 0) return
|
||||
const request = ++agreementGeneration; agreementState.value = 'loading'; agreementNote.value = '正在加载用户协议与隐私政策…'
|
||||
let nextTerms: Agreement | null = null; let nextPrivacy: Agreement | null = null; let missing = false; let failure = ''
|
||||
try { nextTerms = await fetchAgreement('terms') } catch (err) { if (agreementUnavailable(err)) missing = true; else failure = err instanceof Error ? err.message : '协议加载失败' }
|
||||
try { nextPrivacy = await fetchAgreement('privacy') } catch (err) { if (agreementUnavailable(err)) missing = true; else failure = err instanceof Error ? err.message : '协议加载失败' }
|
||||
if (!pageVisible || request != agreementGeneration) return
|
||||
if (failure.length > 0 || missing || nextTerms == null || nextPrivacy == null) {
|
||||
agreed.value = false; selectedTerms = null; selectedPrivacy = null; terms.value = emptyAgreement(); privacy.value = emptyAgreement()
|
||||
agreementState.value = failure.length > 0 ? 'error' : 'unavailable'
|
||||
agreementNote.value = failure.length > 0 ? failure + ',请重新加载协议。' : '用户协议或隐私政策尚未正式发布,暂不记录同意;现有账户可使用密码登录。'
|
||||
return
|
||||
}
|
||||
if (selectedTerms?.version != nextTerms!.version || selectedTerms?.contentHash != nextTerms!.contentHash || selectedPrivacy?.version != nextPrivacy!.version || selectedPrivacy?.contentHash != nextPrivacy!.contentHash) { agreed.value = false; selectedTerms = null; selectedPrivacy = null }
|
||||
terms.value = nextTerms!; privacy.value = nextPrivacy!; agreementState.value = 'ready'; agreementNote.value = ''
|
||||
}
|
||||
const agreementReadyForLogin = (): boolean => {
|
||||
if (agreementState.value == 'unavailable' && mode.value == 'password') return true
|
||||
if (agreementState.value != 'ready') { errorMessage.value = agreementState.value == 'unavailable' ? '协议未正式发布,请使用现有账户密码登录' : '请先重新加载用户协议与隐私政策'; return false }
|
||||
if (!agreed.value || selectedTerms == null || selectedPrivacy == null) { errorMessage.value = '请先阅读并勾选用户协议与隐私政策'; return false }
|
||||
return true
|
||||
}
|
||||
let countdownTimer = 0
|
||||
watch(phone, () => {
|
||||
smsSent.value = false; smsCode.value = ''; countdown.value = 0; errorMessage.value = ''
|
||||
clearInterval(countdownTimer)
|
||||
})
|
||||
const switchMode = () => { if (loading.value) return; mode.value = mode.value == 'phone' ? 'password' : 'phone'; errorMessage.value = '' }
|
||||
const switchMode = () => { if (loading.value || authenticated.value) return; mode.value = mode.value == 'phone' ? 'password' : 'phone'; errorMessage.value = '' }
|
||||
const socialLogin = (provider: string) => { uni.showModal({ title: provider + '暂未开放', content: '请先使用手机号登录。该登录方式将在正式服务接入后开放。', showCancel: false, confirmText: '我知道了' }) }
|
||||
const validPhone = (): boolean => {
|
||||
if (!/^1[3-9][0-9]{9}$/.test(phone.value)) { errorMessage.value = '请输入正确的 11 位手机号'; return false }
|
||||
@@ -88,39 +133,79 @@
|
||||
if (countdown.value <= 1) { countdown.value = 0; clearInterval(countdownTimer) } else countdown.value -= 1
|
||||
}, 1000)
|
||||
}
|
||||
const finishLogin = (_token: string) => { uni.redirectTo({ url: afterLoginUrl }) }
|
||||
async function confirmLoginConsents(request: number) {
|
||||
if (!pageVisible || request != generation || !sameAuthentication()) throw new Error('登录状态已变化,请重新登录')
|
||||
if (pendingConsents.length == 0 && agreementState.value == 'ready') {
|
||||
if (!agreed.value || selectedTerms == null || selectedPrivacy == null) throw new Error('请重新阅读并确认当前协议')
|
||||
pendingConsents = [{ agreement: selectedTerms!, requestId: consentRequestId() }, { agreement: selectedPrivacy!, requestId: consentRequestId() }]; confirmedCount = 0
|
||||
}
|
||||
while (confirmedCount < pendingConsents.length) {
|
||||
const command = pendingConsents[confirmedCount]
|
||||
await grantConsent(command.agreement, command.requestId)
|
||||
if (!pageVisible || request != generation || !sameAuthentication()) return
|
||||
confirmedCount++
|
||||
}
|
||||
if (pageVisible && request == generation && sameAuthentication()) { pendingConsents = []; uni.redirectTo({ url: afterLoginUrl }) }
|
||||
}
|
||||
const resendSms = async () => {
|
||||
if (loading.value || countdown.value > 0 || !validPhone()) return
|
||||
if (!agreed.value) { errorMessage.value = '请先阅读并勾选用户协议与隐私政策'; return }
|
||||
if (!pageVisible || authenticated.value || loading.value || countdown.value > 0 || !validPhone()) return
|
||||
if (!agreementReadyForLogin()) return
|
||||
const request = generation
|
||||
loading.value = true; errorMessage.value = ''
|
||||
try {
|
||||
if (!(await requestSmsCode(phone.value))) throw new Error('验证码发送失败,请稍后重试')
|
||||
if (!pageVisible || request != generation) return
|
||||
startCountdown()
|
||||
uni.showToast({ title: isDemo ? '请输入 6 位验证码' : '验证码已重新发送', icon: 'none' })
|
||||
} catch (error) { errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试' } finally { loading.value = false }
|
||||
} catch (error) { if (pageVisible && request == generation) errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试' } finally { if (request == generation) loading.value = false }
|
||||
}
|
||||
const login = async () => {
|
||||
if (!pageVisible || loading.value) return
|
||||
errorMessage.value = ''
|
||||
if (!agreed.value) { errorMessage.value = '请先阅读并勾选用户协议与隐私政策'; return }
|
||||
if (!validPhone() || loading.value) return
|
||||
if (authenticated.value && !sameAuthentication()) { clearAuthentication(); errorMessage.value = '登录状态已变化,请重新登录'; return }
|
||||
if (pendingConsents.length == 0 && !agreementReadyForLogin()) return
|
||||
if (!authenticated.value && !validPhone()) return
|
||||
const request = ++generation
|
||||
loading.value = true
|
||||
try {
|
||||
if (authenticated.value) { await confirmLoginConsents(request); return }
|
||||
let token = ''
|
||||
if (mode.value == 'phone') {
|
||||
if (!smsSent.value) {
|
||||
if (!(await requestSmsCode(phone.value))) throw new Error('验证码发送失败,请稍后重试')
|
||||
if (!pageVisible || request != generation) return
|
||||
smsSent.value = true; startCountdown()
|
||||
uni.showToast({ title: isDemo ? '请输入 6 位验证码' : '验证码已发送', icon: 'none' })
|
||||
return
|
||||
} else {
|
||||
if (!/^[0-9]{6}$/.test(smsCode.value)) throw new Error('请输入 6 位数字验证码')
|
||||
finishLogin(await loginWithSms(phone.value, smsCode.value))
|
||||
token = await loginWithSms(phone.value, smsCode.value)
|
||||
}
|
||||
} else {
|
||||
if (password.value.length < 6) throw new Error('密码至少需要 6 位')
|
||||
finishLogin(await loginWithPassword(phone.value, password.value))
|
||||
token = await loginWithPassword(phone.value, password.value)
|
||||
}
|
||||
} catch (error) { errorMessage.value = error instanceof Error ? error.message : '登录失败,请稍后重试' } finally { loading.value = false }
|
||||
if (!pageVisible || request != generation) return
|
||||
const session = getSession()
|
||||
if (session?.getString('token') != token) throw new Error('登录状态已变化,请重新登录')
|
||||
authToken = token; authUserId = session?.getString('userId') ?? ''; authenticated.value = true
|
||||
await confirmLoginConsents(request)
|
||||
} catch (err) {
|
||||
if (!pageVisible || request != generation) return
|
||||
if (agreementChanged(err)) { pendingConsents = []; confirmedCount = 0; agreed.value = false; selectedTerms = null; selectedPrivacy = null; loading.value = false; await loadAgreements(); errorMessage.value = '协议版本已更新,请重新阅读并勾选后继续。' }
|
||||
else errorMessage.value = (err instanceof Error ? err.message : '登录失败,请稍后重试') + (authenticated.value ? ';同意记录未确认,请点击按钮以原请求重试。' : '')
|
||||
} finally { if (request == generation) loading.value = false }
|
||||
}
|
||||
onUnmounted(() => { clearInterval(countdownTimer) })
|
||||
onShow(async () => {
|
||||
pageVisible = true
|
||||
if (authenticated.value && !sameAuthentication()) clearAuthentication()
|
||||
clearInterval(accountTimer); accountTimer = setInterval(() => { if (pageVisible && authenticated.value && !sameAuthentication()) { clearAuthentication(); loading.value = false; errorMessage.value = '登录状态已变化,请重新登录'; loadAgreements() } }, 1000)
|
||||
if (pendingConsents.length == 0) await loadAgreements()
|
||||
})
|
||||
const hidePage = () => { pageVisible = false; generation++; agreementGeneration++; loading.value = false; clearInterval(accountTimer) }
|
||||
onHide(() => hidePage())
|
||||
onUnload(() => { hidePage(); clearInterval(countdownTimer); clearAuthentication() })
|
||||
onUnmounted(() => { clearInterval(countdownTimer); clearInterval(accountTimer) })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -150,4 +235,6 @@
|
||||
.check-mark { font-size: 23rpx; color: #fff; }
|
||||
.agreement-copy { flex: 1; min-width: 0; color: #748198; font-size: 24rpx; line-height: 42rpx; }
|
||||
.agreement-link { color: #0868f7; font-size: 24rpx; line-height: 42rpx; }
|
||||
.agreement-note { color: #748198; font-size: 23rpx; line-height: 38rpx; margin-top: 16rpx; }
|
||||
.agreement-retry { color: #0868f7; font-size: 24rpx; padding: 18rpx 0; }
|
||||
</style>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { logoutAccount } from '@/packages/account/services/auth.uts'
|
||||
import { findLatestReportEntry } from '@/packages/account/services/latest-report.uts'
|
||||
import { showAgreementNotice } from '@/services/assessment.uts'
|
||||
import { showAgreementNotice } from '@/services/agreements.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 }
|
||||
@@ -104,7 +104,7 @@
|
||||
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') { const view = generation; uni.showActionSheet({ itemList: ['查看隐私说明', '管理资料与授权'], success: (result) => { if (view != generation || !sameOwner()) return; if (result.tapIndex == 0) showAgreementNotice('隐私说明'); else if (result.tapIndex == 1) { go('/packages/finance/archive/archive') } } }) }
|
||||
else if (key == 'privacy') { const view = generation; uni.showActionSheet({ itemList: ['查看隐私政策', '管理同意记录与可选授权'], success: (result) => { if (view != generation || !sameOwner()) return; if (result.tapIndex == 0) showAgreementNotice('隐私说明'); else if (result.tapIndex == 1) { go(loggedIn.value ? '/packages/account/consents/consents' : '/packages/account/login/login?resumeConsents=1') } } }) }
|
||||
else if (key == 'about') uni.showModal({ title: '关于 AI融资', content: '版本 V1.0.0\n帮助您整理融资信息、了解融资知识与融资方案。评估结果仅供参考,不构成授信承诺。', showCancel: false, confirmText: '我知道了' })
|
||||
}
|
||||
const openAccount = () => { if (sameOwner()) go(loggedIn.value ? '/packages/account/edit-profile/edit-profile' : '/packages/account/login/login') }
|
||||
|
||||
@@ -50,9 +50,11 @@
|
||||
</view>
|
||||
<view v-else class="upload-area">
|
||||
<text class="upload-title">资料选择 <text class="optional">(选填)</text></text>
|
||||
<text class="upload-desc">支持照片,仅在本机预览,尚未上传。</text>
|
||||
<view class="upload-grid"><view class="upload-card" v-for="(item, index) in uploadNames" :key="item" :class="index == 2 ? 'upload-last' : ''" @click="documentAction(item)"><image v-if="documentPath(item).length > 0" class="document-thumb" :src="documentPath(item)" mode="aspectFill" @error="documentExpired(item)" /><AppIcon v-else name="upload" :size="52" /><text class="upload-name">{{ item }}</text><text :class="documentPath(item).length > 0 ? 'upload-state selected-state' : 'upload-state'">{{ documentPath(item).length > 0 ? '已选择 · 管理' : '点击选择' }}</text></view></view>
|
||||
<view class="consent"><view class="check-hit" @click="documentConsent = !documentConsent"><view :class="documentConsent ? 'checkbox checked' : 'checkbox'"><text v-if="documentConsent" class="check-mark">✓</text></view></view><text class="consent-copy">我已阅读并同意<text class="consent-link" @click="showAgreement('资料使用协议')">《资料使用协议》</text></text></view>
|
||||
<text class="upload-desc">JPG / PNG,单张最多5MB。仅本人可查看和删除;照片未核验,暂不参与评估。</text>
|
||||
<view class="upload-grid"><view class="upload-card" v-for="(item, index) in uploadNames" :key="item" :class="index == uploadNames.length - 1 ? 'upload-last' : ''" @click="documentAction(item)"><image v-if="documentPath(item).length > 0" class="document-thumb" :src="documentPath(item)" mode="aspectFill" @error="documentExpired(item, '')" /><AppIcon v-else name="upload" :size="52" /><text class="upload-name">{{ item }}</text><text class="upload-state">{{ documentState(item) }}</text></view></view>
|
||||
<text v-if="documentMessage.length > 0" class="upload-desc">{{ documentMessage }}</text>
|
||||
<text v-if="documentRecoveryFailed" class="consent-link" @click="restoreDocuments">照片读取失败 · 点击重试</text>
|
||||
<view class="consent"><view class="check-hit" @click="toggleDocumentConsent"><view :class="documentConsent ? 'checkbox checked' : 'checkbox'"><text v-if="documentConsent" class="check-mark">✓</text></view></view><text class="consent-copy">我已阅读并同意<text class="consent-link" @click="showAgreement('资料使用协议')">《资料使用协议》</text></text></view>
|
||||
<view class="data-section"><text class="upload-title">数据授权 <text class="optional">(可选)</text></text><text class="upload-desc">自主选择授权项目,可随时取消选择。</text><view class="grant-list"><view v-for="grant in authorizations" :key="grant.key" :class="grant.selected ? 'grant-card grant-selected' : 'grant-card'" @click="grant.selected = !grant.selected"><text :class="grant.selected ? 'grant-name grant-name-active' : 'grant-name'">{{ grant.name }}</text><view :class="grant.selected ? 'checkbox checked' : 'checkbox'"><text v-if="grant.selected" class="check-mark">✓</text></view></view></view><view class="consent"><view class="check-hit" @click="dataConsent = !dataConsent"><view :class="dataConsent ? 'checkbox checked' : 'checkbox'"><text v-if="dataConsent" class="check-mark">✓</text></view></view><text class="consent-copy">我已阅读并同意<text class="consent-link" @click="showAgreement('数据授权协议')">《数据授权协议》</text></text></view><text class="local-note">当前仅保存选择意向,不查询外部数据。</text></view>
|
||||
</view>
|
||||
<view class="scroll-spacer" :style="bottomStyle(24)"></view>
|
||||
@@ -74,22 +76,29 @@
|
||||
import AssessmentSelect from '@/packages/finance/components/AssessmentSelect.uvue'
|
||||
import { AssessmentForm, SelectedDocument, DataAuthorization, emptyAssessmentForm, activeAssessmentType, getAssessmentInput, formFromInput, documentsFromInput, setAssessmentInput, showAgreementNotice, assetValidationMessage, contributionValidationMessage, clearAssessmentFlow } from '@/services/assessment.uts'
|
||||
import { contributionOptions as contributionChoices, contributionBaseVisible as baseVisible } from '@/services/contribution.uts'
|
||||
import { uploadDocument, downloadDocument, deleteDocument, releaseDocumentTemp, documentCategory, fetchOwnedDocuments } from '@/services/documents.uts'
|
||||
import { Agreement, emptyAgreement, fetchAgreement, grantConsent, consentRequestId } from '@/services/agreements.uts'
|
||||
const { bottomStyle, contentStyle, pageStyle } = usePageLayout()
|
||||
const contributionOptions = (current: string): string[] => contributionChoices(current)
|
||||
const contributionBaseVisible = (duration: string): boolean => baseVisible(duration)
|
||||
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; let visible = false; let accountTimer = 0
|
||||
let ownerToken = ''; let goingToConfirm = false; let goingToAgreement = false; let visible = false; let accountTimer = 0
|
||||
const sameOwner = (): boolean => { const session = getSession(); return session != null && session['token'] == ownerToken }
|
||||
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 documentBusy = ref(''); const documentMessage = ref('')
|
||||
const documentRecoveryFailed = ref(false); let thumbnailIds: string[] = []
|
||||
let documentGeneration = 0; let documentConsentId = ''; let consentKey = ''; let pendingAgreement: Agreement = emptyAgreement()
|
||||
let choosingImage = false; let previewingImage = false
|
||||
const clearDocumentTemps = () => { documents.value.forEach((file) => { if (file.documentId.length > 0) releaseDocumentTemp(file.path); file.path = '' }) }
|
||||
const invalidateDocuments = () => { documentGeneration++; documentBusy.value = ''; thumbnailIds = []; documentRecoveryFailed.value = false; clearDocumentTemps() }
|
||||
const authorizations = ref<DataAuthorization[]>([{ key: 'credit', name: '征信查询', selected: false }, { key: 'business', name: '经营数据', selected: false }, { key: 'fund', name: '公积金数据', selected: false }])
|
||||
const titles = ['基本信息', '经营 / 收入信息', '资产 / 负债信息', '资料上传 / 授权']
|
||||
const uploadNames = computed((): string[] => assessmentType.value == 'enterprise' ? ['营业执照', '银行流水', '经营证明'] : ['身份证正面', '身份证反面', '银行卡', '收入证明'])
|
||||
@@ -100,7 +109,7 @@
|
||||
if (sameOwner()) return true
|
||||
const session = getSession()
|
||||
ownerToken = session?.getString('token') ?? ''
|
||||
editGeneration++; initialized = false; stopAccountTimer(); clearAssessmentFlow()
|
||||
editGeneration++; invalidateDocuments(); documentConsentId = ''; consentKey = ''; pendingAgreement = emptyAgreement(); documentMessage.value = ''; initialized = false; stopAccountTimer(); clearAssessmentFlow()
|
||||
form.value = emptyAssessmentForm(); documents.value = []; customCity.value = ''; step.value = 0
|
||||
documentConsent.value = false; dataConsent.value = false; authorizations.value.forEach((item) => { item.selected = false })
|
||||
baseRevisionId = ''; editLoading.value = false; editError.value = '账户已变化,请返回重新填写。'
|
||||
@@ -121,6 +130,7 @@
|
||||
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, editId.value, baseRevisionId)
|
||||
}
|
||||
const showAgreement = (title: string) => { if (!ensureOwner()) return; rememberInput(); goingToAgreement = true; showAgreementNotice(title) }
|
||||
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 })
|
||||
@@ -136,7 +146,39 @@
|
||||
initialized = true
|
||||
rememberInput()
|
||||
}
|
||||
const switchType = (type: string) => { if (!ensureOwner() || editId.value.length > 0 || assessmentType.value == type) return; rememberInput(); restore(type); step.value = 0; rememberInput() }
|
||||
const hydrateDocumentCards = () => {
|
||||
if (!visible || !sameOwner() || step.value != 3) return
|
||||
const ticket = documentGeneration
|
||||
uploadNames.value.forEach((name) => {
|
||||
const item = documents.value.find((file) => file.name == name)
|
||||
if (item == null || item.documentId.length == 0 || item.deletePending || item.path.length > 0 || thumbnailIds.includes(item.documentId)) return
|
||||
const id = item.documentId; thumbnailIds.push(id)
|
||||
downloadDocument(id).then((path) => {
|
||||
const current = documents.value.find((file) => file.documentId == id && !file.deletePending)
|
||||
if (ticket != documentGeneration || !visible || !sameOwner() || current == null) { releaseDocumentTemp(path); return }
|
||||
if (current.path.length > 0) { releaseDocumentTemp(path); return }
|
||||
current.path = path
|
||||
}).catch(() => {
|
||||
if (ticket == documentGeneration && visible && sameOwner()) documentMessage.value = '缩略图读取失败,点击对应卡片可重试查看'
|
||||
}).finally(() => { if (ticket == documentGeneration) thumbnailIds = thumbnailIds.filter((value) => value != id) })
|
||||
})
|
||||
}
|
||||
const restoreDocuments = async () => {
|
||||
if (!ensureOwner() || !initialized || documentBusy.value.length > 0) return
|
||||
const ticket = documentGeneration; documentBusy.value = 'restore'
|
||||
try {
|
||||
const saved = await fetchOwnedDocuments()
|
||||
if (ticket != documentGeneration || !ensureOwner()) return
|
||||
const pending = documents.value.filter((file) => file.documentId.length == 0)
|
||||
documents.value.forEach((file) => { if (file.documentId.length > 0) releaseDocumentTemp(file.path) })
|
||||
// 卡片先处理待上传项;同类已保存照片通过卡片逐份管理。
|
||||
documents.value = pending.concat(saved.map((file): SelectedDocument => ({ name: file.name, path: '', documentId: file.documentId, consentId: file.consentId, deletePending: file.deletePending })))
|
||||
documentRecoveryFailed.value = false; hydrateDocumentCards()
|
||||
rememberInput()
|
||||
} catch (error) { if (ticket == documentGeneration && ensureOwner()) { documentRecoveryFailed.value = true; documentMessage.value = error instanceof Error ? error.message : '已保存照片读取失败,请重新读取' } }
|
||||
finally { if (ticket == documentGeneration) documentBusy.value = '' }
|
||||
}
|
||||
const switchType = (type: string) => { if (!ensureOwner() || editId.value.length > 0 || assessmentType.value == type) return; rememberInput(); invalidateDocuments(); restore(type); step.value = 0; rememberInput(); restoreDocuments() }
|
||||
const loadEdit = async (type: string) => {
|
||||
const ticket = ++editGeneration; editLoading.value = true
|
||||
try {
|
||||
@@ -144,6 +186,7 @@
|
||||
if (ticket != editGeneration || !ensureOwner()) return
|
||||
baseRevisionId = saved.getString('baseRevisionId') ?? ''
|
||||
writeAssessmentFlow(ownerToken, saved); restore(saved.getString('type') ?? type)
|
||||
restoreDocuments()
|
||||
} catch (err) { if (ticket == editGeneration && ensureOwner()) editError.value = err instanceof Error ? err.message : '资料读取失败,请返回重试。' }
|
||||
finally { if (ticket == editGeneration) editLoading.value = false }
|
||||
}
|
||||
@@ -161,16 +204,20 @@
|
||||
})
|
||||
onShow(() => {
|
||||
visible = true; stopAccountTimer()
|
||||
goingToConfirm = false
|
||||
const returningFromImage = choosingImage || previewingImage
|
||||
goingToConfirm = false; goingToAgreement = false; previewingImage = false
|
||||
if (initialized && !sameOwner()) ensureOwner()
|
||||
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))
|
||||
startAccountTimer()
|
||||
if (!returningFromImage) restoreDocuments()
|
||||
})
|
||||
// 离开填写流程即丢弃未提交内容;进入确认页时只在内存传递。
|
||||
onHide(() => {
|
||||
visible = false; stopAccountTimer()
|
||||
if (!goingToConfirm) {
|
||||
if (choosingImage || previewingImage) return
|
||||
invalidateDocuments()
|
||||
if (!goingToConfirm && !goingToAgreement) {
|
||||
editGeneration++; if (editId.value.length > 0) editError.value = '已离开修改流程,请返回档案重新进入。'
|
||||
// 离页清空资料但不切换步骤,避免卸载时又挂载另一组原生输入组件。
|
||||
clearAssessmentFlow(); form.value = emptyAssessmentForm(); documents.value = []
|
||||
@@ -178,37 +225,130 @@
|
||||
authorizations.value.forEach((item) => { item.selected = false })
|
||||
}
|
||||
})
|
||||
onUnload(() => { visible = false; stopAccountTimer(); editGeneration++; initialized = false; clearAssessmentFlow() })
|
||||
onUnload(() => { visible = false; stopAccountTimer(); editGeneration++; invalidateDocuments(); 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); rememberInput()
|
||||
uni.showToast({ title: name + '已失效,请重新选择', icon: 'none' })
|
||||
const cardDocument = (name: string): SelectedDocument | null => documents.value.find((file) => file.name == name && file.documentId.length == 0) ?? documents.value.find((file) => file.name == name) ?? null
|
||||
const documentPath = (name: string): string => { const item = cardDocument(name); return item == null ? '' : item.path }
|
||||
const documentState = (name: string): string => {
|
||||
if (documentBusy.value == name) return '处理中…'
|
||||
const item = cardDocument(name)
|
||||
if (item == null) return '点击上传'
|
||||
const count = documents.value.filter((file) => file.name == name && file.documentId.length > 0).length
|
||||
if (item.documentId.length > 0 && count > 1) return count.toString() + ' 张照片 · 管理'
|
||||
if (item.deletePending) return '删除未完成 · 重试'
|
||||
return item.documentId.length > 0 ? '已上传 · 管理' : '未上传 · 重试'
|
||||
}
|
||||
const documentExpired = (name: string, id: string) => {
|
||||
const item = id.length == 0 ? cardDocument(name) : documents.value.find((file) => file.name == name && file.documentId == id)
|
||||
if (item != null) { if (item.documentId.length > 0) releaseDocumentTemp(item.path); item.path = '' }
|
||||
rememberInput(); documentMessage.value = '预览已失效,已上传资料仍可重新查看或删除'
|
||||
}
|
||||
const toggleDocumentConsent = async () => {
|
||||
if (!ensureOwner() || documentBusy.value.length > 0) return
|
||||
if (documentConsent.value) { documentConsent.value = false; rememberInput(); return }
|
||||
const ticket = documentGeneration; documentBusy.value = 'consent'; documentMessage.value = ''
|
||||
try {
|
||||
if (pendingAgreement.version.length == 0) {
|
||||
const agreement = await fetchAgreement('document-use')
|
||||
if (ticket != documentGeneration || !ensureOwner()) return
|
||||
pendingAgreement = agreement; consentKey = consentRequestId()
|
||||
}
|
||||
const record = await grantConsent(pendingAgreement, consentKey)
|
||||
if (ticket != documentGeneration || !ensureOwner()) return
|
||||
if (record.revokedAt.length > 0) { pendingAgreement = emptyAgreement(); consentKey = ''; throw new Error('该同意已撤销,请重新阅读并确认') }
|
||||
documentConsentId = record.consentId; documentConsent.value = true; pendingAgreement = emptyAgreement(); consentKey = ''; rememberInput()
|
||||
} catch (error) { if (ticket == documentGeneration && ensureOwner()) documentMessage.value = error instanceof Error ? error.message : '同意记录未确认,请再次点击重试' }
|
||||
finally { if (ticket == documentGeneration) documentBusy.value = '' }
|
||||
}
|
||||
const uploadSelectedDocument = async (name: string) => {
|
||||
if (!ensureOwner() || documentBusy.value.length > 0) return
|
||||
const item = documents.value.find((file) => file.name == name && file.documentId.length == 0)
|
||||
if (item == null || item.path.length == 0 || item.documentId.length > 0) return
|
||||
if (!documentConsent.value || documentConsentId.length == 0) { documentMessage.value = '请阅读并确认资料使用协议后上传'; return }
|
||||
const ticket = documentGeneration; documentBusy.value = name; documentMessage.value = ''
|
||||
try {
|
||||
const id = await uploadDocument(item.path, documentCategory(name), documentConsentId)
|
||||
if (ticket != documentGeneration || !ensureOwner()) return
|
||||
item.documentId = id; item.consentId = documentConsentId; rememberInput()
|
||||
documentMessage.value = '照片已单独保存,未核验、暂不参与评估'
|
||||
} catch (error) { if (ticket == documentGeneration && ensureOwner()) documentMessage.value = error instanceof Error ? error.message : '保存结果未确认,请手动重试;可能另存一份照片' }
|
||||
finally { if (ticket == documentGeneration) documentBusy.value = '' }
|
||||
}
|
||||
const chooseDocument = (name: string) => {
|
||||
if (!ensureOwner()) return
|
||||
const ticket = editGeneration
|
||||
if (!ensureOwner() || documentBusy.value.length > 0) return
|
||||
if (!documentConsent.value || documentConsentId.length == 0) { documentMessage.value = '请阅读并确认资料使用协议后上传'; return }
|
||||
const ticket = documentGeneration; choosingImage = true
|
||||
uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: (result) => {
|
||||
if (ticket != editGeneration || !ensureOwner() || 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); rememberInput()
|
||||
}, fail: (error) => { if (ticket == editGeneration && ensureOwner() && !error.errMsg.includes('cancel')) uni.showToast({ title: '选择失败,请重试或检查相册权限', icon: 'none' }) } })
|
||||
choosingImage = false
|
||||
if (ticket != documentGeneration || !sameOwner() || result.tempFilePaths.length == 0) return
|
||||
documents.value = documents.value.filter((file) => file.name != name || file.documentId.length > 0)
|
||||
const document: SelectedDocument = { name, path: result.tempFilePaths[0], documentId: '', consentId: '', deletePending: false }
|
||||
documents.value.unshift(document); rememberInput(); uploadSelectedDocument(name)
|
||||
}, fail: (error) => { choosingImage = false; if (ticket == documentGeneration && sameOwner() && !error.errMsg.includes('cancel')) documentMessage.value = '选择失败,请重试或检查相册权限' } })
|
||||
}
|
||||
const previewDocument = async (name: string, id: string) => {
|
||||
if (!ensureOwner() || documentBusy.value.length > 0) return
|
||||
const item = documents.value.find((file) => file.name == name && (id.length == 0 || file.documentId == id))
|
||||
if (item == null || item.deletePending || item.documentId.length == 0) return
|
||||
const ticket = documentGeneration; documentBusy.value = name
|
||||
try {
|
||||
const path = await downloadDocument(item.documentId)
|
||||
if (ticket != documentGeneration || !ensureOwner()) { releaseDocumentTemp(path); return }
|
||||
releaseDocumentTemp(item.path); item.path = path; previewingImage = true
|
||||
uni.previewImage({ urls: [path], fail: () => { previewingImage = false; documentExpired(name, item.documentId) } })
|
||||
} catch (error) { if (ticket == documentGeneration && ensureOwner()) documentMessage.value = error instanceof Error ? error.message : '资料读取失败,请重试' }
|
||||
finally { if (ticket == documentGeneration) documentBusy.value = '' }
|
||||
}
|
||||
const removeDocument = async (name: string, id: string) => {
|
||||
if (!ensureOwner() || documentBusy.value.length > 0) return
|
||||
const item = documents.value.find((file) => file.name == name && (id.length == 0 || file.documentId == id))
|
||||
if (item == null) return
|
||||
if (item.documentId.length == 0) { documents.value = documents.value.filter((file) => file.name != name || file.documentId.length > 0); rememberInput(); return }
|
||||
const ticket = documentGeneration; documentBusy.value = name; item.deletePending = true; releaseDocumentTemp(item.path); item.path = ''; rememberInput()
|
||||
try {
|
||||
await deleteDocument(item.documentId)
|
||||
if (ticket != documentGeneration || !ensureOwner()) return
|
||||
documents.value = documents.value.filter((file) => file.documentId != item.documentId); rememberInput(); documentMessage.value = '照片已删除'
|
||||
hydrateDocumentCards()
|
||||
} catch (error) { if (ticket == documentGeneration && ensureOwner()) documentMessage.value = error instanceof Error ? error.message : '删除未完成,请对同一资料重试' }
|
||||
finally { if (ticket == documentGeneration) documentBusy.value = '' }
|
||||
}
|
||||
const documentItemAction = (name: string, id: string) => {
|
||||
if (!ensureOwner() || documentBusy.value.length > 0) return
|
||||
const ticket = documentGeneration; const item = documents.value.find((file) => file.name == name && file.documentId == id)
|
||||
if (item == null) { chooseDocument(name); return }
|
||||
const list = item.deletePending ? ['重试删除'] : (item.documentId.length > 0 ? ['查看照片', '删除照片'] : ['重试上传', '重新选择', '移除未上传照片'])
|
||||
uni.showActionSheet({ itemList: list, success: (result) => {
|
||||
if (ticket != documentGeneration || !ensureOwner()) return
|
||||
if (item.deletePending) removeDocument(name, item.documentId)
|
||||
else if (item.documentId.length > 0) { if (result.tapIndex == 0) previewDocument(name, item.documentId); else removeDocument(name, item.documentId) }
|
||||
else { if (result.tapIndex == 0) uploadSelectedDocument(name); else if (result.tapIndex == 1) chooseDocument(name); else removeDocument(name, item.documentId) }
|
||||
} })
|
||||
}
|
||||
function documentPhotoAction(name: string, offset: number): void {
|
||||
const saved = documents.value.filter((file) => file.name == name && file.documentId.length > 0)
|
||||
const group = saved.slice(offset, offset + 5); const ticket = documentGeneration
|
||||
const list = group.map((file, index): string => '照片 ' + (offset + index + 1).toString() + (file.deletePending ? '(删除未完成)' : ''))
|
||||
const more = offset + group.length < saved.length
|
||||
// 微信操作菜单最多六项,每次保留五张照片和一个翻页入口。
|
||||
if (more || offset > 0) list.push(more ? '更多照片' : '返回第一组')
|
||||
uni.showActionSheet({ itemList: list, success: (result) => {
|
||||
if (ticket != documentGeneration || !ensureOwner() || result.tapIndex < 0 || result.tapIndex >= list.length) return
|
||||
if (result.tapIndex == group.length) { documentPhotoAction(name, more ? offset + group.length : 0); return }
|
||||
documentItemAction(name, group[result.tapIndex].documentId)
|
||||
} })
|
||||
}
|
||||
const documentAction = (name: string) => {
|
||||
if (!ensureOwner()) return
|
||||
const ticket = editGeneration
|
||||
const path = documentPath(name)
|
||||
if (path.length == 0) { chooseDocument(name); return }
|
||||
uni.showActionSheet({ itemList: ['预览图片', '重新选择', '移除资料'], success: (result) => {
|
||||
if (ticket != editGeneration || !ensureOwner()) return
|
||||
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); rememberInput() }
|
||||
} })
|
||||
if (!ensureOwner() || documentBusy.value.length > 0) return
|
||||
const item = cardDocument(name)
|
||||
if (item == null) { chooseDocument(name); return }
|
||||
if (item.documentId.length == 0) { documentItemAction(name, ''); return }
|
||||
const saved = documents.value.filter((file) => file.name == name && file.documentId.length > 0)
|
||||
if (saved.length == 1) { documentItemAction(name, item.documentId); return }
|
||||
documentPhotoAction(name, 0)
|
||||
}
|
||||
const validateStep = (): boolean => {
|
||||
let message = ''
|
||||
@@ -226,12 +366,12 @@
|
||||
}
|
||||
if (step.value == 1 && message.length == 0 && assessmentType.value == 'personal') message = contributionValidationMessage(form.value)
|
||||
if (step.value == 2) message = assetValidationMessage(form.value)
|
||||
if (step.value == 3 && documents.value.length > 0 && !documentConsent.value) message = '请阅读并同意资料使用协议,或移除资料'
|
||||
if (step.value == 3 && (documentBusy.value.length > 0 || documents.value.some((item) => item.documentId.length == 0 || item.deletePending))) message = '请先完成上传、删除重试,或移除未上传照片'
|
||||
if (step.value == 3 && authorizations.value.some((item) => item.selected) && !dataConsent.value) message = '请阅读并同意数据授权协议,或取消授权选择'
|
||||
if (message.length == 0) return true
|
||||
uni.showToast({ title: message, icon: 'none' }); return false
|
||||
}
|
||||
const next = () => { if (!ensureOwner() || editLoading.value || editError.value.length > 0) 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 } }) } }
|
||||
const next = () => { if (!ensureOwner() || editLoading.value || editError.value.length > 0) return; if (!validateStep()) return; if (step.value < 3) { step.value++; rememberInput(); hydrateDocumentCards() } else { rememberInput(); goingToConfirm = true; uni.navigateTo({ url: '/packages/finance/confirm/confirm', fail: () => { goingToConfirm = false } }) } }
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -259,7 +399,7 @@
|
||||
.upload-grid { flex-direction: row; margin-top: 22rpx; }
|
||||
.upload-card { flex: 1; min-width: 0; height: 200rpx; margin-right: 14rpx; border-radius: 16rpx; background-color: #f1f6fd; align-items: center; justify-content: center; }
|
||||
.upload-last { margin-right: 0; }
|
||||
.document-thumb { width: 74rpx; height: 62rpx; border-radius: 8rpx; }
|
||||
.document-thumb { width: 100%; height: 100rpx; border-radius: 10rpx; }
|
||||
.upload-name { font-size: 25rpx; color: #304260; margin-top: 16rpx; }
|
||||
.upload-state { color: #7a889e; font-size: 22rpx; margin-top: 9rpx; }
|
||||
.selected-state { color: #0868f7; }
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<view class="overview-label overview-head"><text class="row-label">核心参数</text></view>
|
||||
<scroll-view v-for="column in columns" :key="column.id" scroll-y class="overview-value overview-head">
|
||||
<view class="overview-product">
|
||||
<template v-if="usable(column)"><text class="overview-name">{{ column.data!.item.product.name }}</text><text class="overview-bank">{{ column.data!.item.product.bank }}</text><text class="overview-link" @click="detail(column.id)">产品详情 ›</text></template>
|
||||
<template v-if="usable(column)"><image class="overview-logo" :src="column.data!.item.product.logo.length > 0 ? column.data!.item.product.logo : '/static/mobile/icons-generated/building.png'" mode="aspectFit" @error="column.data!.item.product.logo = ''" /><text class="overview-name">{{ column.data!.item.product.name }}</text><text v-if="column.data!.item.product.bank.length > 0" class="overview-bank">{{ column.data!.item.product.bank }}</text><text class="overview-link" @click="detail(column.id)">产品详情 ›</text></template>
|
||||
<template v-else><text class="overview-name">产品 #{{ column.id }}</text><text class="overview-bank">{{ column.loading ? '读取中…' : problem(column) }}</text><text v-if="!column.loading" class="overview-link" @click="loadColumn(column.id)">重试此列</text></template>
|
||||
<text class="overview-remove" @click="remove(column.id)">移除</text>
|
||||
</view>
|
||||
@@ -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">展示顺序不代表优劣,具体条件以机构核验为准。</text>
|
||||
<text class="overview-note">{{ orderingNote }}</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">
|
||||
@@ -37,6 +37,7 @@
|
||||
<view v-for="column in columns" :key="column.id" class="column">
|
||||
<scroll-view scroll-y class="product-head"><view class="head-content">
|
||||
<template v-if="usable(column)">
|
||||
<image class="overview-logo" :src="column.data!.item.product.logo.length > 0 ? column.data!.item.product.logo : '/static/mobile/icons-generated/building.png'" mode="aspectFit" @error="column.data!.item.product.logo = ''" />
|
||||
<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>
|
||||
@@ -66,7 +67,7 @@
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { assistantRoute } from '@/packages/finance/services/assistant.uts'
|
||||
import { MatchDetail, MatchReason, validRecordId, fetchMatchDetailRecord } from '@/packages/finance/services/matches.uts'
|
||||
import { MatchDetail, MatchReason, validRecordId, fetchMatchDetailRecord, hasProductValue, matchPercentage } from '@/packages/finance/services/matches.uts'
|
||||
import { parseCompareIds, saveCompareSelection, compareQuery, sameMatchSnapshot, compareReasonText } from '@/packages/finance/services/compare.uts'
|
||||
type CompareColumn = { id: string, loading: boolean, error: string, data: MatchDetail | null }
|
||||
type CompareRow = { id: string, label: string, height: number }
|
||||
@@ -74,7 +75,7 @@
|
||||
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 }]
|
||||
const overviewDefinitions: CompareRow[] = [{ id: 'state', label: '匹配状态', height: 0 }, { id: 'score', label: '匹配度', height: 0 }, { id: 'amount', label: '额度范围', height: 0 }, { id: 'term', label: '期限范围', height: 0 }, { id: 'rate', label: '年化利率', height: 0 }]
|
||||
let visible = false; let generation = 0
|
||||
let owner = ''; let accountTimer = 0; let frozenDetail: MatchDetail | null = null
|
||||
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
|
||||
@@ -91,13 +92,14 @@
|
||||
history.replaceState(history.state, '', location.pathname + location.search + '#/packages/finance/compare/compare' + compareQuery(assessmentId.value, ids.value, revisionId.value))
|
||||
// #endif
|
||||
}
|
||||
const rows: CompareRow[] = [
|
||||
const detailRows: CompareRow[] = [
|
||||
{ id: 'availability', label: '当前状态', height: 160 },
|
||||
{ id: 'score', label: '匹配度', height: 120 },
|
||||
{ id: 'amount', label: '额度范围', height: 120 }, { id: 'term', label: '期限范围', height: 120 }, { id: 'rate', label: '年化利率', height: 120 },
|
||||
{ id: 'objects', label: '适用对象', height: 120 }, { id: 'regions', label: '适用地区', height: 140 },
|
||||
{ id: 'positive', label: '已满足条件', height: 360 }, { id: 'blocking', label: '不符条件', height: 300 }, { id: 'cautions', label: '待核实与提示', height: 300 },
|
||||
{ id: 'positive', label: '推荐与参考理由', height: 360 }, { id: 'blocking', label: '不推荐原因', height: 300 }, { id: 'cautions', label: '注意与补充资料', height: 300 },
|
||||
{ id: 'conditions', label: '准入条件', height: 300 }, { id: 'repayment', label: '还款方式', height: 120 },
|
||||
{ id: 'materials', label: '准备材料', height: 260 }, { id: 'channel', label: '办理渠道', height: 120 }, { id: 'valid', label: '产品有效期', height: 160 }, { id: 'source', label: '资料来源', height: 260 }
|
||||
{ id: 'materials', label: '准备材料', height: 260 }, { id: 'channel', label: '办理渠道', height: 120 }, { id: 'source', label: '资料来源', height: 260 }
|
||||
]
|
||||
const rowStyle = (row: CompareRow): string => 'height:' + row.height + 'rpx;'
|
||||
const columnsStyle = (): string => 'width:' + (columns.value.length * 350) + 'rpx;'
|
||||
@@ -120,21 +122,27 @@
|
||||
const m = column.data!.item; const p = m.product
|
||||
// 核心表也保留当前不可用提示,不能让历史符合条件被误读为现在仍可办理。
|
||||
if (key == 'state') return m.stateLabel + (m.availability != 'available' ? ' · ' + m.availabilityLabel : '')
|
||||
if (key == 'score') return matchPercentage(m.state, m.score) || '—'
|
||||
if (key == 'availability') return m.availabilityLabel + ';不改变历史结论'
|
||||
if (key == 'positive') return compareReasonText(m.positive, '暂无已确认满足的条件')
|
||||
if (key == 'blocking') return compareReasonText(m.blocking, '未发现已知不符条件;仍以核验为准')
|
||||
if (key == 'cautions') return compareReasonText(m.cautions, '暂无待核实提示;不代表已获审批')
|
||||
if (key == 'conditions') return p.conditions.length > 0 ? p.conditions.join('\n\n') : '未提供'
|
||||
if (key == 'amount') return p.amount
|
||||
if (key == 'term') return p.term
|
||||
if (key == 'rate') return p.rate
|
||||
if (key == 'amount') return hasProductValue(p.amount) ? p.amount : '—'
|
||||
if (key == 'term') return hasProductValue(p.term) ? p.term : '—'
|
||||
if (key == 'rate') return hasProductValue(p.rate) ? p.rate : '—'
|
||||
if (key == 'objects') return p.objects
|
||||
if (key == 'regions') return p.regions
|
||||
if (key == 'materials') return p.materials
|
||||
if (key == 'valid') return p.valid
|
||||
if (key == 'source') return p.source + '\n引用:' + p.sourceRef + '\n核对日期:' + p.checkedOn
|
||||
if (key == 'repayment') return hasProductValue(p.repayment) ? p.repayment : '—'
|
||||
if (key == 'channel') return hasProductValue(p.channel) ? p.channel : '—'
|
||||
if (key == 'source') return [hasProductValue(p.source) ? p.source : '', hasProductValue(p.sourceRef) ? '引用:' + p.sourceRef : '', hasProductValue(p.checkedOn) ? '核对日期:' + p.checkedOn : ''].filter((value: string): boolean => value.length > 0).join('\n') || '—'
|
||||
return '未提供'
|
||||
}
|
||||
const rowHasValue = (row: CompareRow): boolean => columns.value.some((column: CompareColumn): boolean => !usable(column) || hasProductValue(cell(column, row.id)))
|
||||
const overviewRows = computed((): CompareRow[] => overviewDefinitions.filter((row: CompareRow): boolean => !['score', 'amount', 'term', 'rate'].includes(row.id) || rowHasValue(row)))
|
||||
const rows = computed((): CompareRow[] => detailRows.filter((row: CompareRow): boolean => !['score', 'amount', 'term', 'rate', 'repayment', 'channel', 'source'].includes(row.id) || rowHasValue(row)))
|
||||
const orderingNote = computed((): string => columns.value.some((column: CompareColumn): boolean => usable(column) && column.data!.orderingVersion == 'product-score-descending-v1') ? '匹配列表按匹配度排序,此处按所选顺序对比。匹配度不代表获批概率。' : '按所选顺序对比,具体条件以机构核验为准。')
|
||||
const loadColumn = async (id: string) => {
|
||||
if (!current(generation, owner)) return
|
||||
const column = columns.value.find((c: CompareColumn): boolean => c.id == id)
|
||||
@@ -204,6 +212,7 @@
|
||||
.overview-link, .overview-remove { font-size: 22rpx; line-height: 34rpx; color: #0868f7; padding-top: 12rpx; }
|
||||
.overview-remove { color: #788ba2; }
|
||||
.overview-text { font-size: 23rpx; line-height: 36rpx; color: #455f7c; }
|
||||
.overview-logo { width: 56rpx; height: 56rpx; margin-bottom: 10rpx; flex-shrink: 0; }
|
||||
.overview-amount { font-weight: 700; color: #154779; }
|
||||
.overview-note { color: #8494a7; font-size: 21rpx; line-height: 34rpx; margin-top: 14rpx; }
|
||||
.details-toggle { flex-direction: row; align-items: center; justify-content: space-between; padding: 28rpx 0; }
|
||||
|
||||
@@ -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>
|
||||
@@ -87,7 +87,7 @@
|
||||
{ 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 }
|
||||
{ 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 }
|
||||
}
|
||||
@@ -121,7 +121,7 @@
|
||||
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 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 }
|
||||
if (grants != null && grants.length > 0 && saved['dataConsent'] != true) { uni.showToast({ title: '请确认授权选择', icon: 'none' }); edit(3); return }
|
||||
const contributionError = assessmentType.value == 'personal' ? contributionValidationMessage(formFromInput(payload.value)) : ''
|
||||
if (contributionError.length > 0) { uni.showToast({ title: contributionError, icon: 'none' }); edit(1); return }
|
||||
const assetError = assetValidationMessage(formFromInput(saved))
|
||||
|
||||
@@ -12,16 +12,16 @@
|
||||
<text v-if="loading && matches.length == 0" class="state-copy">正在读取本次匹配结果…</text>
|
||||
<view v-if="error.length > 0" class="state-panel"><text class="state-title">暂时无法读取</text><text class="muted">{{ error }}</text><button class="secondary" @click="retry">重新加载</button><text class="link" @click="history">返回融资档案</text></view>
|
||||
<view v-if="pageError.length > 0" class="state-panel"><text class="muted">{{ pageError }}</text><button class="secondary" :disabled="loading" @click="loadMore">重试加载更多</button></view>
|
||||
<view v-if="!loading && error.length == 0 && matches.length == 0" class="state-panel"><text class="state-title">{{ status == 'unavailable' ? '本次未执行匹配' : (group == 'eligible' ? '暂无符合已知条件的产品' : (group == 'ineligible' ? '暂无条件不符的产品' : '暂无待核实的产品')) }}</text><text class="muted">{{ status == 'unavailable' ? '历史结果不会自动补算。可从融资档案发起新的评估。' : '可切换分组查看其他结果,或返回报告了解资料准备情况。' }}</text><text class="link" @click="report">查看本次报告</text></view>
|
||||
<view v-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="bankMarkClass(item.product.bank)"><text class="mark-text">{{ item.product.bank.slice(0, 1) }}</text></view><view class="identity"><text class="bank-name">{{ item.product.bank }}</text><text class="product-name">{{ item.product.name }}</text></view><text :class="item.state == 'eligible' ? 'match-state good' : (item.state == 'ineligible' ? 'match-state blocked' : 'match-state pending')">{{ item.stateLabel }}</text><text class="card-arrow">›</text></view>
|
||||
<view class="tags"><text class="tag">年化 {{ item.product.rate }}</text><text class="tag">{{ item.product.term }}</text><text class="tag">{{ item.product.objects }}</text></view>
|
||||
<view class="card-head"><view :class="bankMarkClass(item.product.bank)"><image v-if="item.product.logo.length > 0" class="mark-logo" :src="item.product.logo" mode="aspectFit" @error="item.product.logo = ''" /><text v-else class="mark-text">{{ item.product.bank.length > 0 ? item.product.bank.slice(0, 1) : item.product.name.slice(0, 1) }}</text></view><view class="identity"><text v-if="item.product.bank.length > 0" class="bank-name">{{ item.product.bank }}</text><text class="product-name">{{ item.product.name }}</text></view><view class="card-status"><text :class="item.state == 'eligible' ? 'match-state good' : (item.state == 'ineligible' ? 'match-state blocked' : 'match-state pending')">{{ item.stateLabel }}</text><text v-if="matchPercentage(item.state, item.score).length > 0" class="match-score">匹配度 {{ matchPercentage(item.state, item.score) }}</text></view><text class="card-arrow">›</text></view>
|
||||
<view class="tags"><text v-if="hasProductValue(item.product.rate)" class="tag">年化 {{ item.product.rate }}</text><text v-if="hasProductValue(item.product.term)" class="tag">{{ item.product.term }}</text><text class="tag">{{ item.product.objects }}</text></view>
|
||||
<text v-if="item.availability != 'available'" class="availability">{{ item.availabilityLabel }};历史结果仍可查看。</text>
|
||||
<view class="card-foot"><text class="detail-link">查看匹配依据</text><view class="choose-control" @click.stop="toggleCompare(item.id)"><text :class="selectedIds.includes(item.id) ? 'selection-box selected-box' : 'selection-box'">{{ selectedIds.includes(item.id) ? '✓' : '+' }}</text><text class="choose-label">{{ selectedIds.includes(item.id) ? '已选对比' : '加入对比' }}</text></view></view>
|
||||
</view>
|
||||
<button v-if="hasMore && pageError.length == 0" class="more-button" :disabled="loading" @click="loadMore">{{ loading ? '正在加载…' : '查看更多产品(共' + count(group) + '项)' }}</button>
|
||||
<view v-if="error.length == 0 && matches.length > 0" class="compare-bar"><text class="compare-caption">已选 {{ selectedIds.length }} / 3 项</text><button class="compare-button" :disabled="selectedIds.length < 2" @click="openCompare">对比所选产品</button></view>
|
||||
<text v-if="matches.length > 0 && !hasMore" class="end-note">已显示本组全部结果 · 顺序不代表产品优劣</text>
|
||||
<text v-if="matches.length > 0 && !hasMore" class="end-note">已显示本组全部结果</text>
|
||||
<text v-if="message.length > 0" class="muted notice">{{ message }}</text>
|
||||
<text class="link" @click="report">返回本次报告</text>
|
||||
</template>
|
||||
@@ -33,11 +33,11 @@
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { MatchView, MatchPage, fetchMatchPage, validRecordId } from '@/packages/finance/services/matches.uts'
|
||||
import { MatchView, MatchPage, fetchMatchPage, validRecordId, hasProductValue, matchPercentage } from '@/packages/finance/services/matches.uts'
|
||||
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery, matchReturnPath } from '@/packages/finance/services/compare.uts'
|
||||
const { pageStyle, contentStyle } = usePageLayout()
|
||||
type MatchGroup = { id: string, label: string }
|
||||
const groups: MatchGroup[] = [{ id: 'eligible', label: '符合条件' }, { id: 'ineligible', label: '条件不符' }, { id: 'unknown', label: '待核实' }]
|
||||
const groups: MatchGroup[] = [{ id: 'eligible', label: '符合条件' }, { id: 'ineligible', label: '条件不符' }, { id: 'unknown', label: '待确认' }]
|
||||
const revisionId = ref(''); const assessmentId = ref(''); const group = ref('eligible'); const matches = ref<MatchView[]>([])
|
||||
const selectedIds = ref<string[]>([])
|
||||
const pageActive = ref(false)
|
||||
@@ -153,6 +153,7 @@
|
||||
.card { padding: 24rpx 22rpx 18rpx; background-color: #fff; border: 1rpx solid #e2e8ef; border-radius: 10rpx; margin-bottom: 20rpx; }
|
||||
.card-head { flex-direction: row; align-items: center; }
|
||||
.mark { width: 66rpx; height: 66rpx; border-radius: 33rpx; align-items: center; justify-content: center; margin-right: 16rpx; flex-shrink: 0; }
|
||||
.mark-logo { width: 52rpx; height: 52rpx; }
|
||||
.mark-blue { background-color: #edf4fd; }
|
||||
.mark-red { background-color: #fcedef; }
|
||||
.mark-green { background-color: #eaf6f1; }
|
||||
@@ -163,6 +164,8 @@
|
||||
.bank-name { font-size: 28rpx; line-height: 40rpx; color: #24354b; font-weight: 600; }
|
||||
.product-name { font-size: 22rpx; color: #7a899d; line-height: 34rpx; margin-top: 3rpx; }
|
||||
.match-state { font-size: 20rpx; line-height: 30rpx; max-width: 140rpx; flex-shrink: 0; text-align: right; margin-left: 10rpx; }
|
||||
.card-status { align-items: flex-end; margin-left: 8rpx; }
|
||||
.match-score { font-size: 21rpx; line-height: 32rpx; color: #0868f7; margin-top: 4rpx; }
|
||||
.card-arrow { font-size: 38rpx; color: #a3aebe; margin-left: 12rpx; }
|
||||
.tags { flex-direction: row; flex-wrap: wrap; margin-top: 18rpx; }
|
||||
.tag { background-color: #f2f5f9; color: #718197; font-size: 20rpx; line-height: 30rpx; padding: 4rpx 10rpx; border-radius: 4rpx; margin-right: 8rpx; margin-bottom: 6rpx; }
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
<view v-if="error.length > 0" class="error-panel"><text class="heading">暂时无法读取</text><text class="body">{{ error }}</text><button class="secondary" @click="load">重新加载</button><text class="link" @click="back">返回选择产品</text></view>
|
||||
<template v-if="product != null">
|
||||
<view class="bank-header">
|
||||
<view class="bank-mark"><image class="bank-icon" src="/static/mobile/icons-generated/building.png" mode="aspectFit" /></view>
|
||||
<view class="bank-mark"><image class="bank-icon" :src="product!.logo.length > 0 ? product!.logo : '/static/mobile/icons-generated/building.png'" @error="product!.logo = ''" mode="aspectFit" /></view>
|
||||
<view class="bank-info">
|
||||
<view class="bank-title-row"><text class="bank-name">{{ product!.bank }}</text><text v-if="match != null" :class="match!.state == 'eligible' ? 'badge good' : (match!.state == 'ineligible' ? 'badge blocked' : 'badge pending')">{{ match!.state == 'eligible' ? '可供参考' : (match!.state == 'ineligible' ? '暂不推荐' : '待核实') }}</text></view>
|
||||
<view class="bank-title-row"><text v-if="product!.bank.length > 0" class="bank-name">{{ product!.bank }}</text><text v-if="match != null" :class="match!.state == 'eligible' ? 'badge good' : (match!.state == 'ineligible' ? 'badge blocked' : 'badge pending')">{{ match!.state == 'eligible' ? (match!.score != null ? '匹配度 ' + match!.score!.toString() + '%' : '可供参考') : (match!.state == 'ineligible' ? '暂不推荐' : match!.stateLabel) }}</text></view>
|
||||
<text v-if="match != null" :class="match!.state == 'eligible' ? 'state good-text' : 'state attention-text'">{{ match!.stateLabel }}</text>
|
||||
<text class="product-name">{{ product!.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="hasProductValue(product!.amount) || hasProductValue(product!.rate)" class="product-summary"><text v-if="hasProductValue(product!.amount)" class="summary-tag">额度范围 {{ product!.amount }}</text><text v-if="hasProductValue(product!.rate)" class="summary-tag">年化利率 {{ product!.rate }}</text></view>
|
||||
<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>
|
||||
@@ -21,7 +22,7 @@
|
||||
</view>
|
||||
<template v-if="tab == 'explanation' && match != null">
|
||||
<view class="reason-section">
|
||||
<text class="heading good-text">{{ match!.state == 'eligible' ? '推荐依据(' + match!.positive.length + '条已满足条件)' : '已满足的条件(' + match!.positive.length + '条)' }}</text>
|
||||
<text class="heading good-text">{{ match!.state == 'eligible' ? '推荐理由(' + match!.positive.length + '条)' : '参考理由(' + match!.positive.length + '条)' }}</text>
|
||||
<text v-if="match!.positive.length == 0" class="empty-note">暂无已确认满足的条件,不补充未经验证的推荐理由。</text>
|
||||
<view v-for="(reason, index) in positiveVisible" :key="reason.id" class="reason-row">
|
||||
<text class="number green-number">{{ index + 1 }}</text>
|
||||
@@ -53,28 +54,27 @@
|
||||
</template>
|
||||
<template v-if="tab == 'details'">
|
||||
<view class="details-section"><text class="heading">产品信息</text>
|
||||
<view class="row"><text class="label">额度范围</text><text class="value emphasis">{{ product!.amount }}</text></view>
|
||||
<view class="row"><text class="label">年化利率</text><text class="value">{{ product!.rate }}</text></view>
|
||||
<view class="row"><text class="label">贷款期限</text><text class="value">{{ product!.term }}</text></view>
|
||||
<view v-if="hasProductValue(product!.amount)" class="row"><text class="label">额度范围</text><text class="value emphasis">{{ product!.amount }}</text></view>
|
||||
<view v-if="hasProductValue(product!.rate)" class="row"><text class="label">年化利率</text><text class="value">{{ product!.rate }}</text></view>
|
||||
<view v-if="hasProductValue(product!.term)" class="row"><text class="label">贷款期限</text><text class="value">{{ product!.term }}</text></view>
|
||||
<view class="row"><text class="label">适用主体</text><text class="value">{{ product!.objects }}</text></view>
|
||||
<view class="row"><text class="label">适用地区</text><text class="value">{{ product!.regions }}</text></view>
|
||||
<view class="row"><text class="label">有效期</text><text class="value">{{ product!.valid }}</text></view>
|
||||
<view class="row"><text class="label">还款方式</text><text class="value">未提供</text></view>
|
||||
<view class="row"><text class="label">办理渠道</text><text class="value">未提供</text></view>
|
||||
<view class="row"><text class="label">审批时长</text><text class="value">未提供</text></view>
|
||||
<view v-if="hasProductValue(product!.repayment)" class="row"><text class="label">还款方式</text><text class="value">{{ product!.repayment }}</text></view>
|
||||
<view v-if="hasProductValue(product!.channel)" class="row"><text class="label">办理渠道</text><text class="value">{{ product!.channel }}</text></view>
|
||||
<view v-if="hasProductValue(product!.approval)" class="row"><text class="label">审批时长</text><text class="value">{{ product!.approval }}</text></view>
|
||||
</view>
|
||||
<view class="details-section"><text class="heading">资料来源</text><text class="body">{{ product!.source }}</text><text class="evidence">引用:{{ product!.sourceRef }}</text><text class="evidence">核对日期:{{ product!.checkedOn }}</text></view>
|
||||
<view v-if="hasProductValue(product!.source) || hasProductValue(product!.sourceRef) || hasProductValue(product!.checkedOn)" class="details-section"><text class="heading">资料来源</text><text v-if="hasProductValue(product!.source)" class="body">{{ product!.source }}</text><text v-if="hasProductValue(product!.sourceRef)" class="evidence">引用:{{ product!.sourceRef }}</text><text v-if="hasProductValue(product!.checkedOn)" class="evidence">核对日期:{{ product!.checkedOn }}</text></view>
|
||||
</template>
|
||||
<template v-if="tab == 'conditions'">
|
||||
<view class="details-section"><text class="heading">产品准入条件</text><text class="muted">公开条件不代表个人已符合,缺少核验依据时仍需核实。</text><text v-for="(condition, index) in product!.conditions" :key="index" class="condition">{{ condition }}</text></view>
|
||||
<view class="details-section"><text class="heading">资料准备说明</text><text class="body">{{ product!.materials }}</text><text class="muted">仅提供材料引导,不向机构提交申请。实际条件和材料须向机构核实。</text></view>
|
||||
<view class="details-section"><text class="heading">资料准备说明</text><view v-for="(material, index) in product!.materialList" :key="index" class="material-item"><text class="condition">{{ material.name }} · {{ material.required ? '必需' : '补充' }} · {{ material.objects }}</text><text v-if="material.note.length > 0" class="evidence">{{ material.note }}</text></view><text v-if="product!.materialList.length == 0 || product!.materials != '未提供'" class="body">{{ product!.materials }}</text><text class="muted">仅提供材料引导,不向机构提交申请。实际条件和材料须向机构核实。</text></view>
|
||||
</template>
|
||||
<view class="footnotes"><text class="record-note">{{ assessmentId.length > 0 ? '本次评估 #' + assessmentId : '公共产品资料' }} · 产品版本 v{{ product!.version }}</text><text v-if="match != null" class="record-note">{{ match!.availabilityLabel }},历史参数与结论保留。</text><text v-if="match != null && !canPrepare" class="record-note">{{ match!.state != 'eligible' ? '请先核对不符条件或待核实项。' : '当前产品不可用或状态未知,暂不提供准备入口。' }}</text><text class="record-note">自填信息未经核验;本页面不承诺额度、放款或获批。</text><text v-if="assessmentId.length > 0" class="link" @click="openCompare">查看同类产品对比</text><text v-if="assessmentId.length > 0" class="link" @click="toMatches">返回本次匹配</text><text v-else class="link" @click="history">选择评估查看匹配结果</text></view>
|
||||
<view class="footnotes"><text class="record-note">{{ assessmentId.length > 0 ? '本次评估 #' + assessmentId : '公共产品资料' }} · 产品版本 v{{ product!.version }}</text><text v-if="match != null" class="record-note">{{ match!.availabilityLabel }},历史参数与结论保留。</text><text v-if="match != null && !canPrepare" class="record-note">{{ match!.state != 'eligible' ? '请先核对不符条件或待核实项。' : '当前产品不可用或状态未知,暂不提供准备入口。' }}</text><text v-if="match != null && match!.score != null" class="record-note">匹配度表示条件匹配程度,不代表获批概率。</text><text class="record-note">自填信息未经核验;本页面不承诺额度、放款或获批。</text><text v-if="assessmentId.length > 0" class="link" @click="openCompare">查看同类产品对比</text><text v-if="assessmentId.length > 0" class="link" @click="toMatches">返回本次匹配</text><text v-else class="link" @click="history">选择评估查看匹配结果</text></view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view v-if="pageActive && product != null" class="action-bar" :style="bottomStyle(22)">
|
||||
<template v-if="match != null && canPrepare"><button class="footer-button secondary" @click="openCompare">查看同类产品对比</button><button class="footer-button primary" @click="prepare">准备申请</button></template>
|
||||
<template v-if="match != null && canPrepare"><button class="footer-button secondary" @click="consultAdvisor">咨询顾问</button><button class="footer-button primary" @click="prepare">申请办理</button></template>
|
||||
<template v-else-if="match != null"><button class="footer-button secondary" @click="switchTab(tab == 'details' ? 'explanation' : 'details')">{{ tab == 'details' ? '查看匹配解释' : '查看产品详情' }}</button><button class="footer-button primary" @click="consult">我有疑问,咨询AI</button></template>
|
||||
<button v-else class="footer-button primary" @click="switchTab(tab == 'conditions' ? 'details' : 'conditions')">{{ tab == 'conditions' ? '查看产品详情' : '查看申请条件' }}</button>
|
||||
</view>
|
||||
@@ -84,7 +84,7 @@
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { MatchView, MatchReason, MatchDetail, ProductView, fetchMatchDetailRecord, fetchPublicProduct, validRecordId, requestProductExplanation } from '@/packages/finance/services/matches.uts'
|
||||
import { MatchView, MatchReason, MatchDetail, ProductView, fetchMatchDetailRecord, fetchPublicProduct, validRecordId, requestProductExplanation, hasProductValue } from '@/packages/finance/services/matches.uts'
|
||||
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery, sameMatchSnapshot, matchReturnPath } from '@/packages/finance/services/compare.uts'
|
||||
import { assistantRoute } from '@/packages/finance/services/assistant.uts'
|
||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||
@@ -189,6 +189,13 @@
|
||||
uni.redirectTo({ url: '/packages/finance/product/product' + query + 'productId=' + productId.value + '&tab=' + value + '&from=' + from })
|
||||
}
|
||||
// 下一步页重新核对当前可用性,避免沿用产品详情进入时的旧状态。
|
||||
const consultAdvisor = () => {
|
||||
if (!ready() || product.value == null) return
|
||||
if (match.value != null && match.value!.availability != 'available') { uni.showToast({ title: '产品当前不可用,请联系平台核对。', icon: 'none' }); return }
|
||||
const phone = product.value!.consultPhone
|
||||
if (phone.length == 0) { uni.showToast({ title: '暂未配置顾问电话,请联系平台。', icon: 'none' }); return }
|
||||
uni.makePhoneCall({ phoneNumber: phone })
|
||||
}
|
||||
const prepare = () => { if (ready() && canPrepare.value) uni.navigateTo({ url: '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&productId=' + productId.value + '§ion=prepare' }) }
|
||||
const back = () => { if (owner.length > 0 && !current(generation, owner)) return; 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 = () => {
|
||||
@@ -238,6 +245,9 @@
|
||||
.state { font-size: 24rpx; line-height: 36rpx; margin-top: 5rpx; }
|
||||
.product-name { color: #788294; font-size: 24rpx; line-height: 36rpx; margin-top: 3rpx; }
|
||||
.demo { color: #8d96a4; font-size: 21rpx; line-height: 32rpx; margin-top: 10rpx; }
|
||||
.product-summary { flex-direction: row; flex-wrap: wrap; margin-top: 12rpx; }
|
||||
.summary-tag { color: #1670ed; background-color: #edf4ff; font-size: 23rpx; line-height: 38rpx; padding: 4rpx 12rpx; border-radius: 6rpx; margin-right: 10rpx; margin-top: 6rpx; }
|
||||
.material-item { margin-top: 12rpx; }
|
||||
.tabs { flex-direction: row; border-bottom: 1rpx solid #e8edf4; margin-top: 18rpx; }
|
||||
.tab { flex: 1; align-items: center; padding: 22rpx 0; }
|
||||
.active { border-bottom: 4rpx solid #0868f7; }
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { revisionParams } from './records.uts'
|
||||
import { requestJson } from '@/services/api.uts'
|
||||
import { API_BASE_URL } from '@/services/config.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 ProductMaterial = { name: string, note: string, required: boolean, objects: string }
|
||||
export type ProductView = { logo: string, materialList: ProductMaterial[], consultPhone: string, repayment: string, channel: string, approval: string, 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, 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 MatchView = { score: number | null, 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) }
|
||||
export function eligibilityLabel(value: string): string { return value == 'eligible' ? '符合已知条件' : (value == 'ineligible' ? '条件不符' : '待核实') }
|
||||
const fieldNames: UTSJSONObject = { requestedAmountYuan: '融资金额', requestedTermMonths: '融资期限', applicantType: '申请主体', purposeCode: '资金用途', cityCode: '所在城市', monthlyIncome: '月收入', annualRevenue: '年营收', operatingMonths: '经营月数', totalDebtBalance: '贷款余额', monthlyLoanPayment: '每月还款额', creditLoanCount: '贷款笔数', creditCardCount: '信用卡数量', creditCardTotalLimit: '信用卡总额度', creditCardUsedLimit: '信用卡已用额度', socialSecurity: '社保情况', housingFund: '公积金情况', house: '房产情况', car: '车辆情况' }
|
||||
const labels: UTSJSONObject = { personal: '个人', enterprise: '企业', daily_consumption: '日常消费', education: '教育进修', home_improvement: '家居装修', personal_business: '个体经营周转', working_capital: '经营周转', inventory_purchase: '采购备货', equipment_purchase: '设备购置', business_expansion: '经营扩展', other: '其他用途', none: '无', under_six_months: '不足六个月', six_months_plus: '六个月及以上', yes: '有', no: '无', '440300': '深圳市', '440100': '广州市', '110100': '北京市', '310100': '上海市', '330100': '杭州市' }
|
||||
const moneyFields = ['requestedAmountYuan','monthlyIncome','annualRevenue','totalDebtBalance','monthlyLoanPayment','creditCardTotalLimit','creditCardUsedLimit']
|
||||
export function eligibilityLabel(value: string): string { return value == 'eligible' ? '符合已知条件' : (value == 'ineligible' ? '条件不符' : '待确认') }
|
||||
export function hasProductValue(value: string): boolean { return value.trim().length > 0 && !['未提供', '待核实', '—'].includes(value.trim()) }
|
||||
export function matchPercentage(state: string, score: number | null): string { return state == 'eligible' && score != null ? score.toString() + '%' : '' }
|
||||
const fieldNames: UTSJSONObject = { requestedAmountYuan: '融资金额', requestedTermMonths: '融资期限', applicantType: '申请主体', purposeCode: '资金用途', cityCode: '所在城市', monthlyIncome: '月收入', annualRevenue: '年营收', operatingMonths: '经营月数', totalDebtBalance: '贷款余额', monthlyLoanPayment: '每月还款额', creditLoanCount: '贷款笔数', creditCardCount: '信用卡数量', creditCardTotalLimit: '信用卡总额度', creditCardUsedLimit: '信用卡已用额度', socialSecurity: '社保情况', housingFund: '公积金情况', house: '房产情况', car: '车辆情况', establishedMonths: '企业成立月数', annualInvoiceAmount: '年开票金额', taxPaymentMonths: '连续纳税月数', taxpayerGrade: '纳税等级', enterpriseCreditStatus: '企业信用状态', annualRevenueGrowthPercent: '年营收增长率', debtRatioPercent: '负债率', cashFlowStability: '经营流水稳定性', industryCategory: '行业类别' }
|
||||
const labels: UTSJSONObject = { personal: '个人', enterprise: '企业', daily_consumption: '日常消费', education: '教育进修', home_improvement: '家居装修', personal_business: '个体经营周转', working_capital: '经营周转', inventory_purchase: '采购备货', equipment_purchase: '设备购置', business_expansion: '经营扩展', other: '其他用途', none: '无', under_six_months: '不足六个月', six_months_plus: '六个月及以上', yes: '有', no: '无', normal: '正常', abnormal: '异常', stable: '稳定', unstable: '不稳定', manufacturing: '制造业', trade: '贸易', services: '服务业', construction: '建筑业', agriculture: '农业', finance: '金融业', real_estate: '房地产业', '440300': '深圳市', '440100': '广州市', '110100': '北京市', '310100': '上海市', '330100': '杭州市' }
|
||||
const moneyFields = ['requestedAmountYuan','monthlyIncome','annualRevenue','totalDebtBalance','monthlyLoanPayment','creditCardTotalLimit','creditCardUsedLimit','annualInvoiceAmount']
|
||||
|
||||
function valueText(value: any | null, field: string): string {
|
||||
if (value == null) return '未提供'
|
||||
const raw = value.toString()
|
||||
if (moneyFields.includes(field)) return yuanToWan(raw) + ' 万元'
|
||||
if (field == 'requestedTermMonths' || field == 'operatingMonths') return raw + ' 个月'
|
||||
if (field == 'requestedTermMonths' || field == 'operatingMonths' || field == 'establishedMonths' || field == 'taxPaymentMonths') return raw + ' 个月'
|
||||
if (field == 'debtRatioPercent' || field == 'annualRevenueGrowthPercent') return raw + '%'
|
||||
return labels.getString(raw) ?? raw
|
||||
}
|
||||
function ruleText(rule: UTSJSONObject): string {
|
||||
@@ -33,6 +38,17 @@ function ruleText(rule: UTSJSONObject): string {
|
||||
const operation = op == 'gte' ? '不少于 ' : (op == 'lte' ? '不超过 ' : (op == 'in' ? '属于 ' : ''))
|
||||
return prefix + ':' + operation + expected + (text(rule, 'requiredSource') == 'verified' ? '(需核验依据)' : '')
|
||||
}
|
||||
function rateText(value: string): string { return value.indexOf('.') >= 0 ? value.replace(/0+$/, '').replace(/\.$/, '') : value }
|
||||
function validityText(data: UTSJSONObject): string {
|
||||
const from = text(data, 'validFrom'); const until = text(data, 'validUntil')
|
||||
if (from.length == 0) return until.length == 0 ? '未设置有效期' : '有效至 ' + until
|
||||
return until.length == 0 ? from + ' 起生效' : from + ' 至 ' + until
|
||||
}
|
||||
function rangeText(min: string, max: string, unit: string): string {
|
||||
if (min.length == 0) return max.length == 0 ? '未提供' : '最高 ' + max + unit
|
||||
if (max.length == 0) return '最低 ' + min + unit
|
||||
return (min == max ? min : min + '–' + max) + unit
|
||||
}
|
||||
function productView(data: UTSJSONObject, publicMode: boolean): ProductView {
|
||||
const source = data['source'] as UTSJSONObject
|
||||
const objects = data['applicantTypes'] as string[]; const regions = data['regions'] as string[]
|
||||
@@ -43,14 +59,20 @@ function productView(data: UTSJSONObject, publicMode: boolean): ProductView {
|
||||
(groups[type] as UTSJSONObject[]).forEach((r: UTSJSONObject) => { conditions.push((labels.getString(type) ?? type) + ' · ' + ruleText(r)) })
|
||||
})
|
||||
} else (data['rules'] as UTSJSONObject[]).forEach((r: UTSJSONObject) => { conditions.push(ruleText(r)) })
|
||||
return { id: text(data, 'productId'), version: data.getNumber('productVersion') ?? 0, name: text(data, 'productName'), bank: text(data, 'bankName'), type: text(data, 'productType'), demo: data.getBoolean('isDemo') ?? false,
|
||||
amount: text(data, 'amountMinYuan').length == 0 || text(data, 'amountMaxYuan').length == 0 ? '未提供' : yuanToWan(text(data, 'amountMinYuan')) + '–' + yuanToWan(text(data, 'amountMaxYuan')) + ' 万元',
|
||||
rate: text(data, 'annualRateMinPercent').length == 0 || text(data, 'annualRateMaxPercent').length == 0 ? '未提供' : text(data, 'annualRateMinPercent') + '%–' + text(data, 'annualRateMaxPercent') + '%',
|
||||
term: data.getNumber('termMinMonths') == null || data.getNumber('termMaxMonths') == null ? '未提供' : data.getNumber('termMinMonths')!.toString() + '–' + data.getNumber('termMaxMonths')!.toString() + ' 个月',
|
||||
objects: objects.map((v: string): string => labels.getString(v) ?? v).join(' / '), regions: regions.length == 0 ? '全国' : regions.map((v: string): string => labels.getString(v) ?? v).join('、'),
|
||||
valid: text(data, 'validFrom') + ' 至 ' + text(data, 'validUntil'), materials: text(data, 'information').length > 0 ? text(data, 'information') : '未提供',
|
||||
const rawLogo = text(data, 'bankLogo')
|
||||
const logo = /^https:\/\/[^\s<>"']+$/.test(rawLogo) ? rawLogo : (/^\/uploads\/[a-zA-Z0-9/_.-]+$/.test(rawLogo) ? API_BASE_URL.replace(/\/api\/v1\/?$/, '') + rawLogo : '')
|
||||
const materialList = ((data['materials'] as UTSJSONObject[] | null) ?? []).map((item: UTSJSONObject): ProductMaterial => {
|
||||
return { name: text(item, 'name'), note: text(item, 'note'), required: item.getBoolean('required') ?? false, objects: ((item['applicantTypes'] as string[] | null) ?? []).map((v: string): string => labels.getString(v) ?? v).join(' / ') }
|
||||
})
|
||||
const rawPhone = text(data, 'consultPhone')
|
||||
return { logo, materialList, consultPhone: /^[+0-9][0-9 -]{4,29}$/.test(rawPhone) ? rawPhone : '', repayment: text(data, 'repaymentMethod') || '未提供', channel: text(data, 'channel') || '未提供', approval: text(data, 'approvalDuration') || '未提供', id: text(data, 'productId'), version: data.getNumber('productVersion') ?? 0, name: text(data, 'productName'), bank: text(data, 'bankName'), type: text(data, 'productType'), demo: data.getBoolean('isDemo') ?? false,
|
||||
amount: text(data, 'expectation') || rangeText(text(data, 'amountMinYuan').length == 0 ? '' : yuanToWan(text(data, 'amountMinYuan')), text(data, 'amountMaxYuan').length == 0 ? '' : yuanToWan(text(data, 'amountMaxYuan')), ' 万元'),
|
||||
rate: rangeText(rateText(text(data, 'annualRateMinPercent')), rateText(text(data, 'annualRateMaxPercent')), '%'),
|
||||
term: text(data, 'quota') || rangeText(data.getNumber('termMinMonths')?.toString() ?? '', data.getNumber('termMaxMonths')?.toString() ?? '', ' 个月'),
|
||||
objects: objects.map((v: string): string => labels.getString(v) ?? v).join(' / '), regions: text(data, 'regionMode') == 'unverified' ? '待核实' : (regions.length == 0 ? '全国' : regions.map((v: string): string => labels.getString(v) ?? v).join('、')),
|
||||
valid: validityText(data), materials: text(data, 'information').length > 0 ? text(data, 'information') : '未提供',
|
||||
// 内部引用用于定位资料来源,不把测试环境标识当作面向用户的产品说明。
|
||||
source: text(source, 'publisher') + ' · ' + text(source, 'title'), sourceRef: text(source, 'reference').startsWith('demo:airz-') ? '产品规则档案' : text(source, 'reference'), checkedOn: text(source, 'checkedOn'), conditions }
|
||||
source: [text(source, 'publisher'), text(source, 'title')].filter((value: string): boolean => value.length > 0).join(' · ') || '待核实', sourceRef: text(source, 'reference').startsWith('demo:airz-') ? '产品规则档案' : (text(source, 'reference') || '未提供'), checkedOn: text(source, 'checkedOn') || '未提供', conditions }
|
||||
}
|
||||
function matchView(data: UTSJSONObject): MatchView {
|
||||
const snapshot = data['productSnapshot'] as UTSJSONObject
|
||||
@@ -61,20 +83,30 @@ function matchView(data: UTSJSONObject): MatchView {
|
||||
const explanations = (ai['reasonExplanations'] as UTSJSONObject[] | null) ?? []
|
||||
const reasons = (key: string): MatchReason[] => (data[key] as UTSJSONObject[]).map((r: UTSJSONObject): MatchReason => {
|
||||
const reasonFields: string[] = []
|
||||
const reasonText = ['AI_CONDITION_PASS', 'AI_CONDITION_FAIL'].includes(text(r, 'code')) ? text(r, 'text').replace('(根据自填资料判断)', '') : text(r, 'text')
|
||||
const lines = (r['evidenceRefs'] as string[]).map((ref: string): string => {
|
||||
const e = evidence.find((item: UTSJSONObject): boolean => text(item, 'evidenceId') == ref)
|
||||
if (e == null) return '证据暂不可读取'
|
||||
const fields = e['fieldRefs'] as string[]; const field = fields.length > 0 ? fields[0] : ''
|
||||
fields.forEach((name: string) => { if (!reasonFields.includes(name)) reasonFields.push(name) })
|
||||
if (text(e, 'source') == 'ai_self_reported') {
|
||||
if (e['actual'] != null && fieldNames.getString(field) != null) return fieldNames.getString(field)! + ':' + valueText(e['actual'], field)
|
||||
const names = fields.map((name: string): string => fieldNames.getString(name) ?? '').filter((name: string): boolean => name.length > 0)
|
||||
return names.length > 0 ? '关联资料:' + names.join('、') : reasonText
|
||||
}
|
||||
const source = text(e, 'source') == 'verified' ? '外部核验' : (text(e, 'source') == 'product_snapshot' ? '产品快照' : (text(e, 'source') == 'self_reported' ? '用户自填,未经核验' : '来源未知'))
|
||||
return (fieldNames.getString(field) ?? field) + ':' + valueText(e['actual'], field) + ' · ' + source
|
||||
})
|
||||
const explanation = explanations.find((item: UTSJSONObject): boolean => text(item, 'reasonId') == text(r, 'reasonId'))
|
||||
return { id: text(r, 'reasonId'), text: text(r, 'text'), evidence: lines, fields: reasonFields, explanation: text(ai, 'status') == 'completed' && explanation != null ? text(explanation, 'text') : '' }
|
||||
return { id: text(r, 'reasonId'), text: reasonText, evidence: lines, fields: reasonFields, explanation: text(ai, 'status') == 'completed' && explanation != null ? text(explanation, 'text') : '' }
|
||||
})
|
||||
const availability = text(data, 'currentAvailability')
|
||||
return { id: text(data, 'productId'), state: text(data, 'eligibility'), stateLabel: eligibilityLabel(text(data, 'eligibility')), availability,
|
||||
availabilityLabel: availability == 'available' ? '当前产品可查看' : (availability == 'unavailable' ? '当前产品已下架或不在有效期' : '当前产品状态暂无法确认'),
|
||||
const score = data.getNumber('score')
|
||||
if (score != null && (Math.floor(score) != score || score < 0 || score > 100 || text(data, 'scorePolicyVersion') != 'product-soft-score-v1')) throw new Error('匹配度资料无效,请重新加载。')
|
||||
const pending = evidence.filter((item: UTSJSONObject): boolean => text(item, 'kind') == 'hard' && text(item, 'outcome') == 'unknown')
|
||||
const missingData = text(data, 'eligibility') == 'unknown' && pending.length > 0 && pending.every((item: UTSJSONObject): boolean => ['FACT_MISSING', 'AI_CONDITION_UNKNOWN'].includes(text(item, 'reasonCode')))
|
||||
return { score, id: text(data, 'productId'), state: text(data, 'eligibility'), stateLabel: missingData ? '资料不足' : eligibilityLabel(text(data, 'eligibility')), availability,
|
||||
availabilityLabel: availability == 'available' ? '当前产品可查看' : (availability == 'unavailable' ? '当前产品已下架' : '当前产品状态暂无法确认'),
|
||||
product: productView(data['productSnapshot'] as UTSJSONObject, false), positive: reasons('positiveReasons'), blocking: reasons('blockingReasons'), cautions: reasons('cautions'),
|
||||
aiText: text(ai, 'status') == 'completed' ? text(ai, 'text') : (text(ai, 'status') == 'failed' ? 'AI解释暂不可用,已保留全部规则依据。' : '当前展示规则依据,尚未生成单产品AI解释。'),
|
||||
aiStatus: text(ai, 'status'), aiCanRetry: ai.getBoolean('canRetry') ?? false, aiCanRequest: data.getBoolean('canRequestExplanation') ?? false, aiGeneratedAt: text(ai, 'generatedAt') }
|
||||
@@ -100,7 +132,7 @@ export async function fetchMatchPage(id: string, group: string, cursor: string,
|
||||
}
|
||||
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' ? '本次评估尚未启用产品匹配规则。' : '本次评估没有可用产品目录,未执行产品匹配。'))
|
||||
const message = status == 'completed' ? (text(matching, 'orderingPolicyVersion') == 'product-score-descending-v1' ? '按本次条件匹配度排序,匹配度不代表获批概率。' : '按本次评估时的产品条件判断,展示顺序不代表优劣。') : (reason == 'LEGACY_INPUT' ? '旧版报告未执行产品匹配,可重新评估获取新的结果。' : (reason == 'MATCH_RULES_UNPUBLISHED' ? '本次评估尚未启用产品匹配规则。' : '本次评估没有可用产品目录,未执行产品匹配。'))
|
||||
return { assessmentId: id, revisionId: resolvedRevision, resultVersion: version, snapshotId: text(matching, 'catalogSnapshotId'), ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), status, message, eligible: count('candidateCount'), ineligible: count('ineligibleCount'), unknown: count('unknownCount'), items, hasMore, cursor: nextCursor }
|
||||
}
|
||||
export async function fetchMatchDetail(id: string, productId: string, revisionId: string = ''): Promise<MatchView> {
|
||||
|
||||
@@ -175,7 +175,9 @@ export async function createAssessment(payload: UTSJSONObject): Promise<string>
|
||||
if (session == null) throw new Error('请先登录')
|
||||
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('资料上传与外部查询授权尚未开放,请移除资料及授权选择后提交')
|
||||
if (documents != null && documents.some((item) => !/^[1-9][0-9]{0,18}$/.test(item.getString('documentId') ?? '') || !/^[1-9][0-9]{0,18}$/.test(item.getString('consentId') ?? '') || item.getBoolean('deletePending') == true))
|
||||
throw new Error('请先完成照片上传或移除未完成资料')
|
||||
if (grants != null && grants.length > 0) throw new Error('外部查询尚未开放,请取消授权选择后提交')
|
||||
const editId = draftText(payload, 'editAssessmentId'); const baseRevision = draftText(payload, 'baseRevisionId')
|
||||
if (editId.length > 0 && (!validAssessmentId(editId) || !validAssessmentId(baseRevision))) throw new Error('修改版本无效,请返回档案重新读取。')
|
||||
if (!['personal', 'enterprise'].includes(draftText(payload, 'type'))) throw new Error('评估类型无效,请重新填写。')
|
||||
@@ -201,7 +203,7 @@ 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]
|
||||
// 输入v3恢复原资料表单,不提交已取消的资金需求字段;报告仍使用v2展示结构。
|
||||
// 卡片资料单独保存,确认页明示暂不绑定评估;只发送自填信息,绝不把本机路径或附件送入分析。
|
||||
const body: UTSJSONObject = { inputVersion: 3, type: values['type'], moneyUnit: 'yuan', form, documents: [] as UTSJSONObject[], consentIds: [] as string[] }
|
||||
if (editId.length > 0) body['baseRevisionId'] = baseRevision
|
||||
// 新建和修改使用不同幂等作用域,回执丢失时安全重放原提交。
|
||||
|
||||
Reference in New Issue
Block a user