Files
airz-uniapp/packages/account/consents/consents.uvue
T

135 lines
12 KiB
Plaintext
Raw Normal View History

<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>