Files
hujun c95482a0f3 feat: 实现协议全文与同意记录功能
新增协议全文后台管理、版本化发布及本人同意记录功能,支持可撤销授权;完善资料与授权相关边界提示,调整评估提交时的资料与授权校验逻辑,同步更新相关页面文案与路由。
2026-10-14 14:53:13 +08:00

135 lines
12 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>