feat: 完善登录流程、UI适配及协议文档更新

本次提交包含以下修改:
1. 新增微信登录相关服务及页面逻辑,实现首次微信绑定手机号流程
2. 优化产品匹配卡片 UI,移除不必要状态显示
3. 调整首页 scroll-view 属性及 banner 背景样式
4. 完善积分规则校验逻辑,增加时区及策略格式验证
5. 更新协议草案文档,补充资料清单及功能说明
6. 修复分析结果路由路径,适配新的匹配结果页面
7. 调整品牌 Logo 安卓端字体对齐样式
This commit is contained in:
hujun
2026-10-22 11:24:15 +08:00
parent e5406c23a6
commit 2c9d3feab5
36 changed files with 1246 additions and 212 deletions
+106 -18
View File
@@ -6,15 +6,16 @@
<BrandLogo />
<text class="welcome">欢迎登录 AI融资</text>
<text class="login-subtitle">智能评估,让融资更简单</text>
<text class="form-heading">{{ mode == 'phone' ? '手机号登录 / 注册' : '密码登录' }}</text>
<text class="form-heading">{{ wechatBindingTicket.length > 0 ? '微信绑定手机号' : (mode == 'phone' ? '手机号登录 / 注册' : '密码登录') }}</text>
<text v-if="wechatBindingTicket.length > 0" class="binding-note">首次微信登录需验证手机号。已有账户将直接关联,保留原有资料和积分。</text>
<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 ? '处理中…' : (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>
<button class="login-button" :disabled="loading" @click="login">{{ loading ? '处理中…' : (authenticated ? '确认同意记录并继续' : (mode == 'phone' ? (smsSent ? (wechatBindingTicket.length > 0 ? '验证并登录' : '登录') : '获取验证码') : '登录')) }}</button>
<view class="mode-row"><text v-if="wechatBindingTicket.length == 0" class="mode-link" @click="switchMode">{{ mode == 'phone' ? '密码登录' : '使用验证码登录' }}</text><text v-else class="mode-link" @click="cancelWechatBinding">取消绑定,使用手机号登录</text></view>
<view v-if="wechatBindingTicket.length == 0 && !authenticated" class="other-login"><view class="line"></view><text class="other-text">其他登录方式</text><view class="line"></view></view>
<view v-if="wechatBindingTicket.length == 0 && !authenticated" class="social-row"><view v-if="wechatSupported" class="social-button" @click="beginWechatLogin"><image class="social-icon" src="/packages/account/static/wechat.svg" mode="aspectFit" /><text class="social-label">微信登录</text></view><view class="social-button" @click="quickLogin"><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="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>
@@ -28,8 +29,9 @@
<script setup lang="uts">
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 { getSession } from '@/services/api.uts'
import { API_BASE_URL, requestSmsCode, requestSmsLogin, requestPasswordLogin, acceptSession } from '@/packages/account/services/auth.uts'
import { wechatLoginSupported, requestWechatAuthorization, requestWechatLogin, requestWechatBindingCode, bindWechatPhone, acceptWechatSession } from '@/packages/account/services/wechat-login.uts'
import { getSession, ApiRequestError } 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'
@@ -79,6 +81,9 @@
const errorMessage = ref('')
const agreed = ref(false)
const authenticated = ref(false)
const wechatSupported = wechatLoginSupported()
const wechatBindingTicket = ref('')
let wechatBindingExpiresAt = 0; let wechatOriginSession = ''
const terms = ref<Agreement>(emptyAgreement()); const privacy = ref<Agreement>(emptyAgreement())
const agreementState = ref('loading'); const agreementNote = ref('正在加载用户协议与隐私政策…')
const isDemo = API_BASE_URL.length == 0
@@ -86,6 +91,10 @@
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 quickLogin = () => {
if (!pageVisible || loading.value || authenticated.value || wechatBindingTicket.value.length > 0) return
uni.showModal({ title: '一键登录暂未开放', content: '请先使用手机号登录。该登录方式将在正式服务接入后开放。', showCancel: false, confirmText: '我知道了' })
}
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 = () => {
@@ -121,7 +130,8 @@
clearInterval(countdownTimer)
})
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 sessionIdentity = (): string => { const session = getSession(); return (session?.getString('token') ?? '') + ':' + (session?.getString('userId') ?? '') }
const currentLoginRequest = (request: number, origin: string): boolean => pageVisible && request == generation && origin == sessionIdentity()
const validPhone = (): boolean => {
if (!/^1[3-9][0-9]{9}$/.test(phone.value)) { errorMessage.value = '请输入正确的 11 位手机号'; return false }
return true
@@ -133,6 +143,68 @@
if (countdown.value <= 1) { countdown.value = 0; clearInterval(countdownTimer) } else countdown.value -= 1
}, 1000)
}
const resetWechatBinding = () => {
wechatBindingTicket.value = ''; wechatBindingExpiresAt = 0; wechatOriginSession = ''
smsSent.value = false; smsCode.value = ''; countdown.value = 0; clearInterval(countdownTimer)
}
const cancelWechatBinding = () => {
if (loading.value || authenticated.value) return
generation++; resetWechatBinding(); mode.value = 'phone'; errorMessage.value = ''
}
const bindingReady = (): boolean => {
if (wechatBindingTicket.value.length == 0) return true
if (wechatOriginSession != sessionIdentity()) {
resetWechatBinding(); errorMessage.value = '登录状态已变化,请重新微信登录'; return false
}
if (Date.now() >= wechatBindingExpiresAt) {
resetWechatBinding(); errorMessage.value = '绑定已过期,请重新微信登录'; return false
}
return true
}
async function handleWechatError(error: any | null, request: number) {
if (!pageVisible || request != generation) return
if (agreementChanged(error)) {
resetWechatBinding(); agreed.value = false; selectedTerms = null; selectedPrivacy = null
loading.value = false; await loadAgreements(); errorMessage.value = '协议版本已更新,请重新阅读并勾选后微信登录。'
} else {
if (error instanceof ApiRequestError && error.errorCode == 'WECHAT_BINDING_EXPIRED') resetWechatBinding()
errorMessage.value = error instanceof Error ? error.message : '微信登录失败,请重新操作'
}
}
const acceptWechatResult = (result: UTSJSONObject, request: number, origin: string) => {
if (!currentLoginRequest(request, origin)) return
const token = acceptWechatSession(result)
const session = getSession()
if (session?.getString('token') != token) throw new Error('登录状态已变化,请重新登录')
authToken = token; authUserId = session?.getString('userId') ?? ''; authenticated.value = true
pendingConsents = []; resetWechatBinding()
uni.redirectTo({ url: afterLoginUrl })
}
const beginWechatLogin = async () => {
if (!pageVisible || loading.value || authenticated.value || !wechatSupported) return
// 密码登录保留的协议未发布例外,不适用于新微信入口。
if (agreementState.value != 'ready' || !agreed.value || selectedTerms == null || selectedPrivacy == null) {
errorMessage.value = '请先阅读并确认已正式发布的用户协议和隐私政策'; return
}
const commands: UTSJSONObject[] = [
{ agreementCode: 'terms', agreementVersion: selectedTerms!.version, contentHash: selectedTerms!.contentHash, requestId: consentRequestId() },
{ agreementCode: 'privacy', agreementVersion: selectedPrivacy!.version, contentHash: selectedPrivacy!.contentHash, requestId: consentRequestId() }
]
const request = ++generation; const origin = sessionIdentity()
loading.value = true; errorMessage.value = ''
try {
const code = await requestWechatAuthorization()
if (!currentLoginRequest(request, origin)) return
const result = await requestWechatLogin(code, commands)
if (!currentLoginRequest(request, origin)) return
if (result.getString('status') == 'bind_required') {
resetWechatBinding(); mode.value = 'phone'; phone.value = ''; password.value = ''
wechatBindingTicket.value = result.getString('bindingTicket') ?? ''
wechatBindingExpiresAt = Date.now() + (result.getNumber('expiresIn') ?? 0) * 1000; wechatOriginSession = origin
} else acceptWechatResult(result, request, origin)
} catch (error) { if (currentLoginRequest(request, origin)) await handleWechatError(error, request) }
finally { if (request == generation) loading.value = false }
}
async function confirmLoginConsents(request: number) {
if (!pageVisible || request != generation || !sameAuthentication()) throw new Error('登录状态已变化,请重新登录')
if (pendingConsents.length == 0 && agreementState.value == 'ready') {
@@ -149,41 +221,53 @@
}
const resendSms = async () => {
if (!pageVisible || authenticated.value || loading.value || countdown.value > 0 || !validPhone()) return
if (!bindingReady()) return
if (!agreementReadyForLogin()) return
const request = generation
const request = generation; const origin = sessionIdentity()
loading.value = true; errorMessage.value = ''
try {
if (!(await requestSmsCode(phone.value))) throw new Error('验证码发送失败,请稍后重试')
if (!pageVisible || request != generation) return
const sent = wechatBindingTicket.value.length > 0 ? await requestWechatBindingCode(wechatBindingTicket.value, phone.value) : await requestSmsCode(phone.value)
if (!sent) throw new Error('验证码发送失败,请稍后重试')
if (!currentLoginRequest(request, origin)) return
startCountdown()
uni.showToast({ title: isDemo ? '请输入 6 位验证码' : '验证码已重新发送', icon: 'none' })
} catch (error) { if (pageVisible && request == generation) errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试' } finally { if (request == generation) loading.value = false }
} catch (error) { if (currentLoginRequest(request, origin)) await handleWechatError(error, request) } finally { if (request == generation) loading.value = false }
}
const login = async () => {
if (!pageVisible || loading.value) return
errorMessage.value = ''
if (!bindingReady()) return
if (authenticated.value && !sameAuthentication()) { clearAuthentication(); errorMessage.value = '登录状态已变化,请重新登录'; return }
if (pendingConsents.length == 0 && !agreementReadyForLogin()) return
if (!authenticated.value && !validPhone()) return
const request = ++generation
const request = ++generation; const origin = sessionIdentity()
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
const sent = wechatBindingTicket.value.length > 0 ? await requestWechatBindingCode(wechatBindingTicket.value, phone.value) : await requestSmsCode(phone.value)
if (!sent) throw new Error('验证码发送失败,请稍后重试')
if (!currentLoginRequest(request, origin)) 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 位数字验证码')
token = await loginWithSms(phone.value, smsCode.value)
if (wechatBindingTicket.value.length > 0) {
const result = await bindWechatPhone(wechatBindingTicket.value, phone.value, smsCode.value)
acceptWechatResult(result, request, origin); return
}
const result = await requestSmsLogin(phone.value, smsCode.value)
if (!currentLoginRequest(request, origin)) return
token = acceptSession(result, phone.value)
}
} else {
if (password.value.length < 6) throw new Error('密码至少需要 6 位')
token = await loginWithPassword(phone.value, password.value)
const result = await requestPasswordLogin(phone.value, password.value)
if (!currentLoginRequest(request, origin)) return
token = acceptSession(result, phone.value)
}
if (!pageVisible || request != generation) return
const session = getSession()
@@ -192,19 +276,22 @@
await confirmLoginConsents(request)
} catch (err) {
if (!pageVisible || request != generation) return
if (!authenticated.value && origin != sessionIdentity()) return
if (wechatBindingTicket.value.length > 0) { await handleWechatError(err, request); 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 }
}
onShow(async () => {
pageVisible = true
if (wechatBindingTicket.value.length > 0) bindingReady()
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() })
onUnload(() => { hidePage(); clearInterval(countdownTimer); resetWechatBinding(); clearAuthentication() })
onUnmounted(() => { clearInterval(countdownTimer); clearInterval(accountTimer) })
</script>
@@ -213,6 +300,7 @@
.welcome { font-size: 42rpx; line-height: 60rpx; font-weight: 800; margin-top: 38rpx; color: #152443; }
.login-subtitle { font-size: 26rpx; color: #748198; line-height: 40rpx; margin-top: 8rpx; }
.form-heading { margin-top: 54rpx; font-size: 28rpx; font-weight: 600; color: #22324a; }
.binding-note { margin-top: 16rpx; color: #748198; font-size: 24rpx; line-height: 38rpx; }
.input-box { height: 96rpx; margin-top: 22rpx; border: 1rpx solid #dce4ef; border-radius: 16rpx; padding: 0 24rpx; flex-direction: row; align-items: center; background-color: #fafcff; }
.input-prefix { font-size: 28rpx; color: #31415a; margin-right: 22rpx; flex-shrink: 0; }
.field-input { flex: 1; min-width: 0; font-size: 28rpx; color: #17243c; }
+24 -8
View File
@@ -13,14 +13,14 @@
</view>
<view v-if="errorMessage.length > 0" class="inline-error"><text class="inline-error-copy">{{ errorMessage }}</text><text class="inline-error-action" @click="load">重新加载</text></view>
<view class="task-grid">
<view class="task" v-for="item in taskViews" :key="item.id" @click="doTask(item)">
<button class="task" v-for="item in taskViews" :key="item.id" :open-type="item.id == 'share' ? 'share' : ''" @click="doTask(item)">
<AppIcon :name="item.icon" :size="52" />
<text class="task-name">{{ item.name }}</text>
<text :class="item.status == 'completed' ? 'task-caption completed-caption' : 'task-caption'">{{ taskCaption(item) }}</text>
</view>
</button>
</view>
<view class="invite-panel">
<text class="panel-title">邀请与资料奖励</text><text class="invite-copy">当前只展示已发布的积分规则,邀请和资料奖励尚未开放。</text>
<text class="panel-title">分享与邀请</text><text class="invite-copy">将小程序分享给好友,分享和邀请奖励尚未开放。</text>
</view>
<view class="records">
<view class="record-head" @click="toggleRecords"><text class="panel-title">积分明细</text><view class="record-toggle"><text class="more">{{ recordsExpanded ? '收起' : '展开' }}</text><AppIcon name="chevron-right" :size="26" :class="recordsExpanded ? 'record-arrow expanded-arrow' : 'record-arrow'" muted /></view></view>
@@ -48,7 +48,7 @@
type TaskView = { id: string, icon: string, name: string, status: string, reward: number | null, ruleVersion: string | null }
const taskDefinitions: UTSJSONObject[] = [
{ id: 'checkin', icon: 'calendar-check', name: '签到' },
{ id: 'profile', icon: 'edit-file', name: '完善资料' },
{ id: 'share', icon: 'share', name: '分享' },
{ id: 'invitation', icon: 'gift', name: '邀请好友' }
]
const pendingKey = 'finance.points.checkin.pending'
@@ -75,6 +75,7 @@
const pointsNote = computed((): string => {
if (points.value == null) return '正在同步账户数据'
if (points.value.balanceState == 'anomaly') return '账户积分状态异常,请稍后重试'
if (rules.value?.status == 'paused') return '签到已暂停,已有积分和明细仍可查看'
if (rules.value == null || rules.value.status != 'published') return '积分服务尚未发布,签到暂不可用'
return '服务器同步 · ' + points.value.serverDate
})
@@ -125,6 +126,7 @@
const checkInTask = taskPage.items.find((item: PointsTask): boolean => item.task == 'checkin')
if (summary.serverDate != taskPage.serverDate || summary.timezone != taskPage.timezone || summary.timezone != currentRules.timezone
|| summary.ruleVersion != currentRules.ruleVersion || summary.checkInReward != currentRules.checkInReward || checkInTask == null
|| (summary.checkInEnabled && currentRules.status != 'published')
|| checkInTask.ruleVersion != summary.ruleVersion || checkInTask.reward != summary.checkInReward
|| (checkInTask.status == 'completed') != (summary.ruleVersion != null && summary.signedToday)
|| (checkInTask.status == 'available') != (summary.checkInEnabled && !summary.signedToday)) throw new Error('积分状态已变化,请重新加载')
@@ -164,12 +166,14 @@
if (!sameAccount()) return
const currentRules = rules.value
if (currentRules == null) { uni.showModal({ title: '积分规则', content: '积分规则暂时无法读取,请稍后重试。', showCancel: false, confirmText: '知道了' }); return }
if (currentRules.status != 'published' || currentRules.checkInReward == null || currentRules.ruleVersion == null) {
if (currentRules.status == 'draft' || currentRules.publishedRules == null || currentRules.checkInReward == null || currentRules.ruleVersion == null) {
uni.showModal({ title: '积分规则', content: '积分服务尚未发布,签到和奖励暂未开放。', showCancel: false, confirmText: '知道了' }); return
}
uni.showModal({ title: '积分规则', content: '当前规则版本:' + currentRules.ruleVersion + '\n每日签到奖励:' + currentRules.checkInReward.toString() + ' 分\n每日限签到 1 次,不支持补签。', showCancel: false, confirmText: '知道了' })
const statusNote = currentRules.status == 'paused' ? '签到已暂停,已有积分和明细仍可查看。\n\n' : ''
uni.showModal({ title: '积分规则', content: statusNote + '每日签到奖励:' + currentRules.checkInReward.toString() + ' 分\n按北京时间每日0点划分,每日限签到1次,不支持补签。\n连续签到无额外加成,分享和邀请奖励暂未开放。\n积分不过期,不影响会员等级。\n暂不支持兑换、提现或抵扣AI调用额度。', showCancel: false, confirmText: '知道了' })
}
const taskCaption = (item: TaskView): string => {
if (item.id == 'share') return '分享小程序'
if (item.id == 'checkin' && checkingIn.value) return '正在确认…'
if (item.id == 'checkin' && pendingRequest.value != null) return '确认上次状态'
if (item.status == 'completed') return '今日已完成'
@@ -209,9 +213,17 @@
const doTask = (item: TaskView): void => {
if (!sameAccount()) return
if (item.id == 'checkin') { checkIn(); return }
if (item.id == 'profile') { uni.navigateTo({ url: '/packages/finance/archive/archive' }); return }
if (item.id == 'share') {
// #ifndef MP-WEIXIN
uni.showModal({ title: '分享', content: '请使用浏览器或客户端的分享功能,将页面分享给好友。', showCancel: false, confirmText: '知道了' })
// #endif
return
}
uni.showToast({ title: '邀请奖励暂未开放', icon: 'none' })
}
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({ title: 'AI融资 · 智能融资分析与产品匹配', path: '/pages/home/home' }))
// #endif
const recordName = (item: PointsLedgerItem): string => item.businessType == 'checkin' ? '每日签到' : '历史积分记录'
const recordDate = (item: PointsLedgerItem): string => item.businessDate ?? item.createdAt ?? '日期待核实'
const deltaLabel = (item: PointsLedgerItem): string => item.delta >= 0 ? '+' + item.delta.toString() : item.delta.toString()
@@ -236,7 +248,11 @@
.inline-error-copy { flex: 1; color: #b0473e; font-size: 23rpx; line-height: 34rpx; }
.inline-error-action { color: #0868f7; font-size: 23rpx; margin-left: 18rpx; }
.task-grid { min-height: 186rpx; flex-direction: row; background-color: #ffffff; border-radius: 18rpx; margin-top: 20rpx; }
.task { flex: 1; align-items: center; justify-content: center; padding: 24rpx 0; }
.task { flex: 1; flex-direction: column; align-items: center; justify-content: center; padding: 24rpx 0; margin: 0; border-radius: 0; background-color: transparent; line-height: 1.5;
/* #ifndef APP */
display: flex;
/* #endif */
}
.task-name { margin-top: 14rpx; font-size: 24rpx; color: #172640; font-weight: 600; }
.task-caption { margin-top: 7rpx; font-size: 22rpx; color: #66758a; }
.completed-caption { color: #168454; }
+12 -3
View File
@@ -7,7 +7,7 @@ export async function requestSmsCode(phone: string): Promise<boolean> {
return true
}
function acceptSession(result: UTSJSONObject, phone: string): string {
export function acceptSession(result: UTSJSONObject, phone: string): string {
const token = result['token'] as string | null
const userId = result['userId'] as string | null
const expiresAt = result['expiresAt'] as string | null
@@ -19,11 +19,20 @@ function acceptSession(result: UTSJSONObject, phone: string): string {
}
export async function loginWithSms(phone: string, code: string): Promise<string> {
return acceptSession(await requestJson('/auth/sms-login', 'POST', { phone, code }), phone)
return acceptSession(await requestSmsLogin(phone, code), phone)
}
export async function loginWithPassword(phone: string, password: string): Promise<string> {
return acceptSession(await requestJson('/auth/password-login', 'POST', { phone, password }), phone)
return acceptSession(await requestPasswordLogin(phone, password), phone)
}
// 页面先校验自身代次与原会话,再接受认证结果;迟到响应不能切换当前账户。
export async function requestSmsLogin(phone: string, code: string): Promise<UTSJSONObject> {
return await requestJson('/auth/sms-login', 'POST', { phone, code })
}
export async function requestPasswordLogin(phone: string, password: string): Promise<UTSJSONObject> {
return await requestJson('/auth/password-login', 'POST', { phone, password })
}
export async function logoutAccount(): Promise<void> {
@@ -0,0 +1,90 @@
import { requestJson } from '@/services/api.uts'
import { acceptSession } from './auth.uts'
export function wechatLoginSupported(): boolean {
// App微信SDK及各平台应用凭据接入后再开放原生入口。
// #ifdef MP-WEIXIN
return true
// #endif
// #ifndef MP-WEIXIN
return false
// #endif
}
export function requestWechatAuthorization(): Promise<string> {
// #ifdef MP-WEIXIN
return new Promise<string>((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (result) => {
const code = result.code
if (code.length == 0) { reject(new Error('未能取得微信登录凭证,请重新操作')); return }
resolve(code)
},
fail: (error) => {
const cancelled = error.errMsg.toLowerCase().includes('cancel') || error.errCode == 1310509
reject(new Error(cancelled ? '已取消微信登录' : '微信授权失败,请重新操作'))
}
})
})
// #endif
// #ifndef MP-WEIXIN
return new Promise<string>((resolve, reject) => {
reject(new Error('当前平台暂未开放微信登录,请使用手机号登录'))
})
// #endif
}
function validTicket(value: string): boolean { return /^[A-Za-z0-9_-]{32,128}$/.test(value) }
function validPhone(value: string): boolean { return /^1[3-9][0-9]{9}$/.test(value) }
function validateResult(result: UTSJSONObject): UTSJSONObject {
const status = result.getString('status') ?? ''
if (status == 'bind_required') {
const expires = result.getNumber('expiresIn') ?? 0
if (!validTicket(result.getString('bindingTicket') ?? '') || expires < 1 || expires > 600 || Math.floor(expires) != expires || result['token'] != null)
throw new Error('微信绑定响应格式不完整,请重新微信登录')
} else if (status == 'authenticated') {
const expiry = Date.parse(result.getString('expiresAt') ?? '')
if (result.getBoolean('consentsConfirmed') != true || !validPhone(result.getString('phone') ?? '')
|| !/^[1-9][0-9]{0,18}$/.test(result.getString('userId') ?? '') || (result.getString('token') ?? '').length == 0
|| isNaN(expiry) || expiry <= Date.now()) throw new Error('微信登录响应缺少有效账户或协议确认')
} else throw new Error('微信登录响应状态无效')
return result
}
export async function requestWechatLogin(code: string, agreements: UTSJSONObject[]): Promise<UTSJSONObject> {
if (code.length == 0 || code.length > 256 || agreements.length != 2) throw new Error('请重新确认协议并微信登录')
let terms = false; let privacy = false; let previousRequestId = ''
for (const item of agreements) {
const agreementCode = item['agreementCode'] as string | null ?? ''
const requestId = item['requestId'] as string | null ?? ''
if ((agreementCode == 'terms' && !terms)) terms = true
else if ((agreementCode == 'privacy' && !privacy)) privacy = true
else throw new Error('用户协议和隐私政策必须分别确认')
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(item['agreementVersion'] as string | null ?? '')
|| !/^[a-f0-9]{64}$/.test(item['contentHash'] as string | null ?? '') || !/^[A-Za-z0-9_-]{16,64}$/.test(requestId)
|| requestId == previousRequestId) throw new Error('协议确认信息不完整,请重新阅读并确认')
previousRequestId = requestId
}
return validateResult(await requestJson('/auth/wechat-login', 'POST', { platform: 'mp-weixin', code, agreements }))
}
export async function requestWechatBindingCode(bindingTicket: string, phone: string): Promise<boolean> {
if (!validTicket(bindingTicket) || !validPhone(phone)) throw new Error('请重新微信登录并输入正确手机号')
await requestJson('/auth/wechat/sms-code', 'POST', { bindingTicket, phone })
return true
}
export async function bindWechatPhone(bindingTicket: string, phone: string, code: string): Promise<UTSJSONObject> {
if (!validTicket(bindingTicket) || !validPhone(phone) || !/^[0-9]{6}$/.test(code)) throw new Error('请核对手机号和6位验证码')
const result = validateResult(await requestJson('/auth/wechat/bind-phone', 'POST', { bindingTicket, phone, code }))
if (result.getString('status') != 'authenticated') throw new Error('手机号绑定尚未完成,请重新微信登录')
return result
}
export function acceptWechatSession(result: UTSJSONObject): string {
validateResult(result)
if (result.getString('status') != 'authenticated') throw new Error('请先完成手机号绑定')
return acceptSession(result, result.getString('phone') ?? '')
}
+3 -3
View File
@@ -18,7 +18,7 @@
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { fetchAssessmentStatus, retryAssessment } from '@/packages/finance/services/records.uts'
import { reportRoute } from '@/services/report-route.uts'
import { matchReturnPath } from '@/services/match-route.uts'
const { statusStyle, pageStyle } = usePageLayout()
const title = ref('正在读取评估状态')
const message = ref(''); const error = ref(''); const assessmentId = ref('')
@@ -62,7 +62,7 @@
message.value = result.message.length > 0 ? result.message : (result.status == 'processing' ? (result.stage == 'queued' ? '资料已保存,等待分析。您可以稍后从档案查看。' : '正在分析自填资料,您可以稍后从档案查看。') : (result.status == 'failed' ? '分析未完成,可重新分析或刷新状态。已提交的资料无需重新填写。' : '分析已完成,可查看本次报告。'))
if (result.status == 'succeeded') {
// 只打开本次已发布版本;重评处理中或旧报告回包不会触发跳转。
const target = result.hasReport && result.publishedRevisionId == result.revisionId ? reportRoute(assessmentId.value, 'matches', result.publishedRevisionId) : ''
const target = result.hasReport && result.publishedRevisionId == result.revisionId ? matchReturnPath('matches', assessmentId.value, '', result.publishedRevisionId, '', 'all') : ''
succeeded.value = target.length > 0
if (target.length > 0) { publishedRevisionId.value = result.publishedRevisionId; uni.redirectTo({ url: target }); return }
message.value = '本次报告尚未就绪,请刷新状态。'
@@ -95,7 +95,7 @@
} catch (err) { if (visible && request == generation && getSession()?.getString('token') == owner) error.value = err instanceof Error ? err.message : '重新分析失败,请重试' }
finally { retrying.value = false }
}
const report = () => { const target = reportRoute(assessmentId.value, 'matches', publishedRevisionId.value); if (succeeded.value && target.length > 0) uni.redirectTo({ url: target }) }
const report = () => { const target = matchReturnPath('matches', assessmentId.value, '', publishedRevisionId.value, '', 'all'); if (succeeded.value && target.length > 0) uni.redirectTo({ url: target }) }
const archive = () => { uni.reLaunch({ url: '/packages/finance/archive/archive?assessmentId=' + encodeURIComponent(assessmentId.value) }) }
onLoad((options) => { if (options != null) assessmentId.value = options['assessmentId'] ?? '' })
onShow(() => { visible = true; loadStatus() })
+1 -1
View File
@@ -19,7 +19,7 @@
<view class="context-heading" @click="contextOpen = !contextOpen"><text class="context-summary">{{ context!.title }} · {{ context!.products.length > 0 ? context!.products.length.toString() + '项产品' : '报告解读' }}</text><text class="context-toggle">{{ contextOpen ? '收起' : '展开' }}</text></view>
<template v-if="contextOpen">
<text v-if="context!.products.length == 0" class="context-note">围绕这份已保存报告解答,不重新评估</text>
<view v-for="item in context!.products" :key="item.id" class="context-product"><text class="context-product-name">{{ item.name }}</text><text :class="item.state == 'eligible' ? 'product-state good-state' : 'product-state'">{{ item.stateLabel }}</text></view>
<view v-for="item in context!.products" :key="item.id" class="context-product"><text class="context-product-name">{{ item.name }}</text></view>
<text class="context-link context-source" @click="source">{{ sourceLabel }}</text>
</template>
</view>
+43 -61
View File
@@ -29,29 +29,25 @@
</view>
<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">
<view class="rail"><view class="rail-head"><text class="rail-title">对比指标</text><text class="hint">同次评估</text></view><view v-for="row in rows" :key="row.id" class="label-cell" :style="rowStyle(row)"><text class="row-label">{{ row.label }}</text></view></view>
<scroll-view scroll-x class="horizontal">
<view class="columns" :style="columnsStyle()">
<view v-for="column in columns" :key="column.id" class="column">
<scroll-view scroll-y class="product-head"><view class="head-content">
<template v-if="usable(column)">
<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>
<text class="detail" @click="detail(column.id)">查看依据与详情 ›</text>
</template>
<template v-else><text class="product-name">产品 #{{ column.id }}</text><text class="hint">{{ column.loading ? '正在读取历史资料…' : problem(column) }}</text><button v-if="!column.loading" class="retry" @click="loadColumn(column.id)">重试此列</button></template>
<text class="remove" @click="remove(column.id)">移除</text>
</view></scroll-view>
<view v-for="row in rows" :key="row.id" class="cell" :style="rowStyle(row)">
<scroll-view scroll-y class="cell-scroll"><text :class="row.id == 'amount' ? 'cell-text amount' : 'cell-text'">{{ cell(column, row.id) }}</text></scroll-view>
</view>
</view>
<text v-if="detailsOpen" class="scroll-hint">各产品并排展示 · 向下查看完整条件与依据</text>
<view v-if="detailsOpen" class="overview-table detail-table">
<view class="overview-row">
<view class="overview-label"><text class="row-label">对比指标</text><text class="hint">同次评估</text></view>
<view v-for="column in columns" :key="column.id" class="overview-value detail-value">
<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 class="overview-bank">{{ column.data!.item.product.bank }} · v{{ column.data!.item.product.version }}</text>
<text class="overview-link" @click="detail(column.id)">查看依据与详情 ›</text>
</template>
<template v-else><text class="overview-name">产品 #{{ column.id }}</text><text class="hint">{{ 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>
</scroll-view>
</view>
<view v-for="row in rows" :key="row.id" class="overview-row">
<view class="overview-label"><text class="row-label">{{ row.label }}</text></view>
<view v-for="column in columns" :key="column.id" class="overview-value detail-value"><text :class="row.id == 'amount' ? 'cell-text cell-amount' : 'cell-text'">{{ cell(column, row.id) }}</text></view>
</view>
</view>
</template>
<text class="footer-note">历史参数保留评估时版本;当前可用状态单独展示。缺失信息不会被推断为零。</text>
@@ -70,12 +66,12 @@
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 }
type CompareRow = { id: string, label: string }
const { pageStyle, contentStyle } = usePageLayout()
const revisionId = ref(''); const assessmentId = ref(''); const ids = ref<string[]>([]); const columns = ref<CompareColumn[]>([]); const routeError = ref('')
const detailsOpen = ref(false)
// 首屏并排展示短参数;长证据保留原宽表,展开后读取完整内容,不截断判断依据。
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 }]
// 按指标组织整行,产品列均分可用宽度,长证据自然撑高行而不截断。
const overviewDefinitions: CompareRow[] = [{ id: 'score', label: '匹配度' }, { id: 'amount', label: '额度范围' }, { id: 'term', label: '期限范围' }, { id: 'rate', label: '年化利率' }, { id: 'availability', label: '当前状态' }]
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 }
@@ -93,16 +89,14 @@
// #endif
}
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: 'conditions', label: '准入条件', height: 300 }, { id: 'repayment', label: '还款方式', height: 120 },
{ id: 'materials', label: '准备材料', height: 260 }, { id: 'channel', label: '办理渠道', height: 120 }, { id: 'source', label: '资料来源', height: 260 }
{ id: 'availability', label: '当前状态' },
{ id: 'score', label: '匹配度' },
{ id: 'amount', label: '额度范围' }, { id: 'term', label: '期限范围' }, { id: 'rate', label: '年化利率' },
{ id: 'objects', label: '适用对象' }, { id: 'regions', label: '适用地区' },
{ id: 'positive', label: '推荐与参考理由' }, { id: 'blocking', label: '不推荐原因' }, { id: 'cautions', label: '注意事项' },
{ id: 'conditions', label: '准入条件' }, { id: 'repayment', label: '还款方式' },
{ id: 'materials', label: '准备材料' }, { id: 'channel', label: '办理渠道' }, { id: 'source', label: '资料来源' }
]
const rowStyle = (row: CompareRow): string => 'height:' + row.height + 'rpx;'
const columnsStyle = (): string => 'width:' + (columns.value.length * 350) + 'rpx;'
const problem = (column: CompareColumn): string => {
if (column.error.length > 0) return column.error
if (column.data == null) return '资料尚未读取'
@@ -114,15 +108,15 @@
const complete = computed((): boolean => ids.value.length >= 2 && columns.value.length == ids.value.length && columns.value.every((c: CompareColumn): boolean => usable(c)))
const overviewClass = (column: CompareColumn, key: string): string => {
if (key == 'amount') return 'overview-text overview-amount'
if (key != 'state' || !usable(column)) return 'overview-text'
return 'overview-text ' + (column.data!.item.state == 'eligible' ? 'good' : (column.data!.item.state == 'ineligible' ? 'blocked' : 'pending'))
return 'overview-text'
}
const cell = (column: CompareColumn, key: string): string => {
if (!usable(column)) return column.loading ? '读取中…' : '本列未就绪'
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 == 'score') {
const percentage = matchPercentage(m.state, m.score)
return percentage.length > 0 ? percentage : '—'
}
if (key == 'availability') return m.availabilityLabel + ';不改变历史结论'
if (key == 'positive') return compareReasonText(m.positive, '暂无已确认满足的条件')
if (key == 'blocking') return compareReasonText(m.blocking, '未发现已知不符条件;结果仅供参考')
@@ -136,7 +130,10 @@
if (key == 'materials') return p.materials
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') || '—'
if (key == 'source') {
const sourceText = [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 sourceText.length > 0 ? sourceText : '—'
}
return '未提供'
}
const rowHasValue = (row: CompareRow): boolean => columns.value.some((column: CompareColumn): boolean => !usable(column) || hasProductValue(cell(column, row.id)))
@@ -229,33 +226,18 @@
.hint { color: #788ba2; font-size: 22rpx; line-height: 34rpx; margin-top: 8rpx; }
.add { color: #0868f7; background-color: #e6f0ff; font-size: 24rpx; border-radius: 12rpx; margin-left: 14rpx; }
.scroll-hint { font-size: 21rpx; color: #8494a7; margin-bottom: 16rpx; }
.table { flex-direction: row; border: 1rpx solid #dfe8f3; border-radius: 14rpx; overflow: hidden; background-color: #fff; }
.rail { width: 152rpx; flex-shrink: 0; background-color: #eef4fb; }
.rail-head { height: 350rpx; padding: 26rpx 16rpx; border-bottom: 1rpx solid #dfe8f3; }
.rail-title { color: #244566; font-size: 24rpx; font-weight: 700; }
.label-cell { padding: 24rpx 16rpx; border-bottom: 1rpx solid #dfe8f3; }
.row-label { font-size: 23rpx; line-height: 36rpx; color: #48627e; }
.horizontal { flex: 1; width: 0; }
.columns { flex-direction: row; }
/* #ifndef APP */
.columns { min-width: 100%; }
/* #endif */
.column { width: 350rpx; flex-grow: 1; flex-shrink: 0; border-left: 1rpx solid #e7edf5; }
.product-head { height: 350rpx; border-bottom: 1rpx solid #dfe8f3; background-color: #fafdff; }
.head-content { padding: 22rpx 20rpx; }
.product-name { font-size: 26rpx; line-height: 37rpx; color: #1d3d60; font-weight: 700; }
.state { font-size: 22rpx; margin-top: 12rpx; }
.good { color: #18805d; }
.blocked { color: #b26341; }
.pending { color: #9b751f; }
.demo { color: #8b97a8; font-size: 20rpx; margin-top: 6rpx; }
.detail { color: #0868f7; font-size: 23rpx; padding-top: 16rpx; }
.remove { color: #77889e; font-size: 22rpx; padding-top: 16rpx; }
.retry { font-size: 23rpx; color: #0868f7; background-color: #eaf3ff; margin-top: 12rpx; }
.cell { padding: 20rpx; border-bottom: 1rpx solid #e7edf5; }
.cell-scroll { flex: 1; height: 0; }
.cell-text { font-size: 24rpx; line-height: 39rpx; color: #455f7c; }
.amount { color: #154779; font-weight: 700; font-size: 27rpx; }
.detail-value { justify-content: flex-start; }
.cell-text { font-size: 23rpx; line-height: 36rpx; color: #455f7c; }
.cell-amount { color: #154779; font-weight: 700; }
/* #ifndef APP */
.detail-table .cell-text, .detail-table .overview-name, .detail-table .overview-bank { white-space: pre-wrap; word-break: break-all; }
/* #endif */
.footer-note { color: #8997a8; font-size: 22rpx; line-height: 36rpx; margin-top: 24rpx; }
.secondary { color: #0868f7; background-color: #eaf3ff; font-size: 26rpx; border-radius: 12rpx; margin-top: 20rpx; }
</style>
@@ -3,7 +3,7 @@
<view class="match-head">
<view :class="markClass(item.product.bank)"><image v-if="item.product.logo.length > 0 && !logoFailed" class="institution-logo" :src="item.product.logo" mode="aspectFit" @error="logoFailed = true" /><text v-else class="institution-letter">{{ 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="institution-name">{{ item.product.bank }}</text><text :class="item.product.bank.length > 0 ? 'product-name' : 'institution-name'">{{ item.product.name }}</text></view>
<view class="match-status"><text v-if="matchPercentage(item.state, item.score).length > 0" :class="item.state == 'eligible' ? 'percentage' : 'percentage reference-percentage'">匹配度 {{ matchPercentage(item.state, item.score) }}</text><text :class="item.state == 'eligible' ? 'state eligible' : (item.state == 'ineligible' ? 'state ineligible' : 'state unknown')">{{ item.stateLabel }}</text></view><text class="arrow">›</text>
<view v-if="matchPercentage(item.state, item.score).length > 0" class="match-status"><text :class="item.state == 'eligible' ? 'percentage' : 'percentage reference-percentage'">匹配度 {{ matchPercentage(item.state, item.score) }}</text></view><text class="arrow">›</text>
</view>
<view class="product-tags"><text v-if="hasProductValue(item.product.rate)" class="product-tag">年化 {{ item.product.rate }}</text><text v-if="hasProductValue(item.product.term)" class="product-tag">{{ item.product.term }}</text><text v-if="hasProductValue(item.product.channel)" class="product-tag">{{ item.product.channel }}</text><text v-if="!hasProductValue(item.product.rate) && !hasProductValue(item.product.term) && !hasProductValue(item.product.channel)" class="product-tag">{{ item.product.objects }}</text></view>
<text v-if="item.availability != 'available'" class="availability">{{ item.availabilityLabel }}</text>
@@ -11,7 +11,9 @@
</view>
</template>
<script setup lang="uts">
import { MatchView, hasProductValue, matchPercentage } from '@/packages/finance/services/matches.uts'
import { MatchView, hasProductValue as productHasValue, matchPercentage as productMatchPercentage } from '@/packages/finance/services/matches.uts'
const hasProductValue = (value: string): boolean => productHasValue(value)
const matchPercentage = (state: string, score: number | null): string => productMatchPercentage(state, score)
const props = defineProps({ item: { type: Object as PropType<MatchView>, required: true }, selectable: { type: Boolean, default: false }, selected: { type: Boolean, default: false } })
const emit = defineEmits(['open', 'select'])
const logoFailed = ref(false)
+15 -12
View File
@@ -12,7 +12,7 @@
<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 == 'all' ? '本次暂无可展示的产品' : (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 == 'all' ? '本次暂无可展示的产品' : '本组暂无可展示的产品') }}</text><text class="muted">{{ status == 'unavailable' ? '历史结果不会自动补算。可从融资档案发起新的评估。' : '可切换分组查看其他结果,或返回报告了解资料准备情况。' }}</text><text class="link" @click="report">查看本次报告</text></view>
<MatchProductCard v-for="item in matches" :key="item.id" :item="item" :selectable="true" :selected="selectedIds.includes(item.id)" @open="openProduct" @select="toggleCompare" />
<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>
@@ -41,7 +41,7 @@
const eligible = ref('—'); const ineligible = ref('—'); const unknown = ref('—'); const hasMore = ref(false)
let cursor = ''; let generation = 0; let visible = false; let routeError = ''; let owner = ''
let firstPage: MatchPage | null = null; let accountTimer = 0
const count = (id: string): string => id == 'all' ? (eligible.value == '—' ? '—' : (Number(eligible.value) + Number(unknown.value) + Number(ineligible.value)).toString()) : (id == 'eligible' ? eligible.value : (id == 'ineligible' ? ineligible.value : unknown.value))
const count = (id: string): string => id == 'all' ? (eligible.value == '—' ? '—' : (parseInt(eligible.value) + parseInt(unknown.value) + parseInt(ineligible.value)).toString()) : (id == 'eligible' ? eligible.value : (id == 'ineligible' ? ineligible.value : unknown.value))
const summaryTitle = computed((): string => {
if (assessmentId.value.length == 0) return '选择评估,查看融资匹配'
if (error.value.length > 0) return '匹配结果暂不可读取'
@@ -77,16 +77,19 @@
const token = session.getString('token') ?? ''; if (owner.length > 0 && owner != token) selectedIds.value = []; owner = token; loading.value = true
if (accountTimer == 0) accountTimer = setInterval(() => { current(generation, owner) }, 1000)
try {
const result = await fetchMatchPage(assessmentId.value, group.value, append ? cursor : '', revisionId.value)
if (!current(request, token)) return
// 分页只追加同一冻结结果,失败时保留已读行与原游标供局部重试。
if (append && (firstPage == null || firstPage!.assessmentId != result.assessmentId || firstPage!.revisionId != result.revisionId || firstPage!.resultVersion != result.resultVersion || firstPage!.snapshotId != result.snapshotId || firstPage!.ruleVersion != result.ruleVersion || firstPage!.orderingVersion != result.orderingVersion || firstPage!.status != result.status || firstPage!.eligible != result.eligible || firstPage!.ineligible != result.ineligible || firstPage!.unknown != result.unknown || result.items.some((item: MatchView): boolean => matches.value.some((old: MatchView): boolean => old.id == item.id)) || (result.hasMore && result.cursor == cursor))) throw new Error('分页与已读取的冻结结果不一致,请重试或重新加载。')
revisionId.value = result.revisionId
if (!append) firstPage = result
saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value); updateUrl()
matches.value = append ? matches.value.concat(result.items) : result.items
message.value = result.message; status.value = result.status; eligible.value = result.eligible; ineligible.value = result.ineligible; unknown.value = result.unknown
cursor = result.cursor; hasMore.value = result.hasMore
// 首屏保留摘要;点击查看更多后连续读取全部剩余页,失败可从当前游标重试。
do {
const result = await fetchMatchPage(assessmentId.value, group.value, append ? cursor : '', revisionId.value)
if (!current(request, token)) return
// 分页只追加同一冻结结果,失败时保留已读行与原游标供局部重试。
if (append && (firstPage == null || firstPage!.assessmentId != result.assessmentId || firstPage!.revisionId != result.revisionId || firstPage!.resultVersion != result.resultVersion || firstPage!.snapshotId != result.snapshotId || firstPage!.ruleVersion != result.ruleVersion || firstPage!.orderingVersion != result.orderingVersion || firstPage!.status != result.status || firstPage!.eligible != result.eligible || firstPage!.ineligible != result.ineligible || firstPage!.unknown != result.unknown || result.items.some((item: MatchView): boolean => matches.value.some((old: MatchView): boolean => old.id == item.id)) || (result.hasMore && result.cursor == cursor))) throw new Error('分页与已读取的冻结结果不一致,请重试或重新加载。')
revisionId.value = result.revisionId
if (!append) firstPage = result
saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value); updateUrl()
matches.value = append ? matches.value.concat(result.items) : result.items
message.value = result.message; status.value = result.status; eligible.value = result.eligible; ineligible.value = result.ineligible; unknown.value = result.unknown
cursor = result.cursor; hasMore.value = result.hasMore
} while (append && hasMore.value)
} catch (err) { if (current(request, token)) { const message = err instanceof Error ? err.message : '匹配结果读取失败'; if (append) pageError.value = message; else error.value = message } }
finally { if (request == generation) loading.value = false }
}
+1 -1
View File
@@ -21,7 +21,7 @@
<template v-else-if="matchError.length > 0"><text class="body">{{ matchError }}</text><button class="secondary" @click="load">重新核对产品</button></template>
<template v-else-if="match != null">
<text class="product-title">{{ match!.product.name }}</text><text class="note">{{ match!.product.bank }} · 评估时版本 v{{ match!.product.version }}</text>
<text class="status">{{ match!.stateLabel }} · {{ match!.availabilityLabel }}</text>
<text class="status">{{ match!.availabilityLabel }}</text>
<template v-if="canPrepare">
<text class="label">所需材料与办理说明</text><text class="body">{{ match!.product.materials }}</text>
<text class="label">申请条件(评估时)</text><text v-for="item in match!.product.conditions" :key="item" class="body">{{ item }}</text><text v-if="match!.product.conditions.length == 0" class="body">未提供</text>
+6 -6
View File
@@ -9,8 +9,7 @@
<view class="bank-header">
<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 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')">{{ matchPercentage(match!.state, match!.score).length > 0 ? '匹配度 ' + matchPercentage(match!.state, match!.score) : (match!.state == 'eligible' ? '可供参考' : (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>
<view class="bank-title-row"><text v-if="product!.bank.length > 0" class="bank-name">{{ product!.bank }}</text><text v-if="match != null && matchPercentage(match!.state, match!.score).length > 0" :class="match!.state == 'eligible' ? 'badge good' : (match!.state == 'ineligible' ? 'badge blocked' : 'badge pending')">{{ '匹配度 ' + matchPercentage(match!.state, match!.score) }}</text></view>
<text class="product-name">{{ product!.name }}</text>
</view>
</view>
@@ -63,7 +62,6 @@
<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 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>
@@ -84,7 +82,9 @@
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, hasProductValue, matchPercentage } from '@/packages/finance/services/matches.uts'
import { MatchView, MatchReason, MatchDetail, ProductView, fetchMatchDetailRecord, fetchPublicProduct, validRecordId, requestProductExplanation, hasProductValue as productHasValue, matchPercentage as productMatchPercentage } from '@/packages/finance/services/matches.uts'
const hasProductValue = (value: string): boolean => productHasValue(value)
const matchPercentage = (state: string, score: number | null): string => productMatchPercentage(state, score)
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()
@@ -102,8 +102,8 @@
const attentionVisible = computed((): MatchReason[] => attentionExpanded.value ? attention.value : attention.value.slice(0, 3))
const aiPending = computed((): boolean => match.value != null && ['queued','running'].includes(match.value!.aiStatus))
const canPrepare = computed((): boolean => match.value != null && match.value!.state == 'eligible' && match.value!.availability == 'available')
// 缩短规则模板标题,不改判定含义。
const reasonTitle = (reason: MatchReason): string => reason.text.replace('本产品已配置条件', '产品条件')
// 加减分附注仅在本页隐藏,保留原理由、评分与冻结数据。
const reasonTitle = (reason: MatchReason): string => reason.text.replace('本产品已配置条件', '产品条件').replace(/[((]\s*匹配度\s*(?:[+\-−]|加|减)\s*\d+(?:\.\d+)?\s*分\s*[))]/g, '').trim()
function stopPoll(): void { if (timer != 0) clearTimeout(timer); timer = 0 }
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeMatch=1&matchPage=product&assessmentId=' + assessmentId.value + '&revisionId=' + revisionId.value + '&productIds=' + selectedIds.value.join(',') + '&productId=' + productId.value + '&tab=' + tab.value + '&from=' + from }) }
+2 -2
View File
@@ -56,7 +56,7 @@
<view v-if="report!.suggestions.length > 0" class="panel"><view class="panel-heading" @click="detailsOpen = !detailsOpen"><text class="section-title">建议依据</text><text class="toggle">{{ detailsOpen ? '收起' : '展开' }}</text></view><template v-if="detailsOpen"><text v-for="item in report!.suggestions" :key="item" class="body-text">{{ item }}</text></template></view>
<view v-if="report!.version == 2" class="panel"><text class="section-title">优先补充与核实</text><text v-if="missingItems.length == 0" class="report-note">{{ report!.completenessPercent == null ? '本次未评定缺失字段,不能据此认为资料齐全。' : '本次字段清单没有待补充或格式待核实项,仍需准备核验材料。' }}</text><view class="fact-row" v-for="item in missingItems" :key="item.label"><text class="fact-label">{{ item.label }}</text><text class="fact-value">{{ item.value }}</text></view><text class="body-text" v-for="(item, index) in report!.readinessBasis" :key="index">{{ item }}</text></view>
<view v-if="report!.matching.status != 'completed'" class="panel"><text class="section-title">产品条件提示</text><text class="report-note">本次未执行产品匹配,没有可关联的产品改善依据。</text></view>
<template v-else><view v-for="group in adviceGroups" :key="group.id" class="panel"><text class="section-title">{{ group.label }}</text><text class="report-note">仅展示本组前3项产品的已有依据,可进入完整列表查看更多。</text><text v-if="group.loading" class="body-text">正在读取产品依据…</text><template v-if="group.error.length > 0"><text class="body-text">{{ group.error }}</text><button class="secondary" @click="loadAdvice(group.id)">重试{{ group.label }}</button></template><text v-if="!group.loading && group.error.length == 0 && group.items.length == 0" class="report-note">本组暂无产品依据。</text><view v-for="item in group.items" :key="item.id" class="advice-product"><text class="product-name" @click="openProduct(item.id)">{{ item.product.name }} ›</text><text class="source-note">{{ item.stateLabel }} · 评估时版本 v{{ item.product.version }}</text><view v-for="reason in adviceReasons(item)" :key="reason.id"><text class="body-text">{{ reason.text }}</text><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view><text v-if="item.availability != 'available'" class="availability">{{ item.availabilityLabel }}</text></view><text class="action" @click="allMatches(group.id)">查看全部{{ group.label }} ›</text></view></template>
<template v-else><view v-for="group in adviceGroups" :key="group.id" class="panel"><text class="section-title">{{ group.label }}</text><text class="report-note">仅展示本组前3项产品的已有依据,可进入完整列表查看更多。</text><text v-if="group.loading" class="body-text">正在读取产品依据…</text><template v-if="group.error.length > 0"><text class="body-text">{{ group.error }}</text><button class="secondary" @click="loadAdvice(group.id)">重试{{ group.label }}</button></template><text v-if="!group.loading && group.error.length == 0 && group.items.length == 0" class="report-note">本组暂无产品依据。</text><view v-for="item in group.items" :key="item.id" class="advice-product"><text class="product-name" @click="openProduct(item.id)">{{ item.product.name }} ›</text><text class="source-note">评估时版本 v{{ item.product.version }}</text><view v-for="reason in adviceReasons(item)" :key="reason.id"><text class="body-text">{{ reason.text }}</text><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view><text v-if="item.availability != 'available'" class="availability">{{ item.availabilityLabel }}</text></view><text class="action" @click="allMatches(group.id)">查看全部{{ group.label }} ›</text></view></template>
</template>
<view v-if="tab == 'matches'" class="matching-footer"><text class="report-link" @click="editAssessment">修改资料,重新评估 ›</text><text class="matching-disclaimer">匹配度仅供参考,不代表获批概率。</text></view>
<template v-else>
@@ -101,7 +101,7 @@
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeReport=1&assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&tab=' + tab.value }) }
const missingItems = computed((): AssessmentField[] => report.value == null ? [] : report.value!.completenessItems.filter((item: AssessmentField): boolean => ['待补充','需核实'].includes(item.value)))
const priorityItems = computed((): string[] => report.value == null ? [] : report.value!.priorityItems)
const matchingTotal = computed((): string => report.value == null || report.value!.matching.status != 'completed' ? '0' : (Number(report.value!.matching.eligible) + Number(report.value!.matching.unknown) + Number(report.value!.matching.ineligible)).toString())
const matchingTotal = computed((): string => report.value == null || report.value!.matching.status != 'completed' ? '0' : (parseInt(report.value!.matching.eligible) + parseInt(report.value!.matching.unknown) + parseInt(report.value!.matching.ineligible)).toString())
const adviceReasons = (item: MatchView): MatchReason[] => item.state == 'ineligible' ? item.blocking.concat(item.cautions) : item.cautions
const reset = () => { report.value = null; candidates.value = []; adviceGroups.value = []; candidateLoading.value = false; candidateError.value = ''; factsOpen.value = false; completenessOpen.value = false; metadataOpen.value = false; detailsOpen.value = false; downloadStatus.value = '' }
const current = (request: number, token: string): boolean => visible && request == generation && getSession()?.getString('token') == token
+9 -5
View File
@@ -11,7 +11,10 @@ export type MatchDetail = { assessmentId: string, revisionId: string, resultVers
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) ?? '' }
function text(data: UTSJSONObject, key: string, fallback: string = ''): string {
const value = data.getString(key) ?? ''
return value.length > 0 ? value : fallback
}
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' ? '条件不符' : '待核实') }
export function hasProductValue(value: string): boolean { return value.trim().length > 0 && !['未提供', '待核实', '—'].includes(value.trim()) }
@@ -73,14 +76,15 @@ function productView(data: UTSJSONObject, publicMode: boolean): ProductView {
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')), ' 万元'),
const sourceText = [text(source, 'publisher'), text(source, 'title')].filter((value: string): boolean => value.length > 0).join(' · ')
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').length > 0 ? 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() ?? '', ' 个月'),
term: text(data, 'quota').length > 0 ? 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')].filter((value: string): boolean => value.length > 0).join(' · ') || '待核实', sourceRef: text(source, 'reference').startsWith('demo:airz-') ? '产品规则档案' : (text(source, 'reference') || '未提供'), checkedOn: text(source, 'checkedOn') || '未提供', conditions }
source: sourceText.length > 0 ? sourceText : '待核实', 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