feat: 实现协议全文与同意记录功能

新增协议全文后台管理、版本化发布及本人同意记录功能,支持可撤销授权;完善资料与授权相关边界提示,调整评估提交时的资料与授权校验逻辑,同步更新相关页面文案与路由。
This commit is contained in:
hujun
2026-10-14 14:53:13 +08:00
parent b418969e92
commit c95482a0f3
58 changed files with 3269 additions and 146 deletions
+134
View File
@@ -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>
+105 -18
View File
@@ -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>
+2 -2
View File
@@ -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') }