feat: 接入积分服务端真实接口并完成隔离联调

新增积分服务端接口封装、积分中心页面,支持登录回跳、签到恢复、积分明细等功能。清理缓存时保留未确认签到键,新增ApiRequestError错误类型区分业务状态,更新开发文档记录积分页面联调完成情况。
This commit is contained in:
hujun
2026-10-04 08:29:53 +08:00
parent 2a9ff403c4
commit 6b16d4fd55
9 changed files with 555 additions and 150 deletions
+2
View File
@@ -48,6 +48,8 @@
} else if (options?.['resumeReport'] == '1') {
const target = reportRoute(options?.['assessmentId'] ?? '', options?.['tab'] ?? 'overview', options?.['revisionId'] ?? '')
if (target.length > 0) afterLoginUrl = target
} else if (options?.['resumePoints'] == '1') {
afterLoginUrl = '/packages/account/points/points'
}
})
const { statusStyle, contentStyle, pageStyle } = usePageLayout()
+253 -125
View File
@@ -1,134 +1,262 @@
<template>
<view class="page" :style="pageStyle()">
<AppHeader title="积分中心" />
<scroll-view scroll-y :style="contentStyle(96, false)">
<view class="points-content">
<view class="points-hero">
<view class="points-hero-top"><text class="points-label">我的体验积分</text><button @click="showRules" class="points-hero-button">积分规则</button></view>
<text class="points-number">{{ totalPoints }}</text><text class="points-note">本地体验 · 仅在本机记录</text>
</view>
<view class="task-grid">
<view class="task" v-for="item in tasks" :key="item.id" @click="doTask(item.id)">
<AppIcon :name="item.icon" :size="52" />
<text class="task-name">{{ item.id == 'checkin' && signedToday ? '已签到' : item.name }}</text>
<text :class="item.id == 'checkin' && signedToday ? 'task-caption completed-caption' : 'task-caption'">{{ item.id == 'checkin' && signedToday ? '明天再来' : item.caption }}</text>
</view>
</view>
<view class="invite-panel">
<text class="panel-title">与好友一起了解融资</text><text class="invite-copy">分享实用知识,提前做好申请准备。</text>
<view class="invite-code"><text class="code-label">体验邀请码</text><text class="code">AI2680</text><view class="copy-action" @click="copyCode"><text class="copy-text">复制</text></view></view>
<button class="primary-button invite-button" @click="invite">复制邀请文案</button><text class="invite-note">当前为本地体验,复制或分享不会发放积分。</text>
</view>
<view class="records">
<view class="record-head" @click="recordsExpanded = !recordsExpanded"><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>
<view v-if="recordsExpanded">
<view v-if="records.length == 0" class="empty-records"><text class="empty-record-title">还没有积分记录</text><text class="empty-record-copy">完成今日签到,获得 10 体验积分。</text></view>
<view class="record" v-for="(item, index) in records" :key="item.date" :class="index == records.length - 1 ? 'last-record' : ''"><view class="record-copy"><text class="record-name">每日签到</text><text class="record-date">{{ item.date }} · 本地体验</text></view><text class="record-score">+{{ item.score }}</text></view>
</view>
</view>
</view>
</scroll-view>
</view>
<view class="page" :style="pageStyle()">
<AppHeader title="积分中心" />
<scroll-view scroll-y :style="contentStyle(96, false)">
<view class="points-content">
<view v-if="loading" class="state-panel"><AppIcon name="points" :size="54" /><text class="state-title">正在读取积分</text><text class="state-copy">请稍候,正在同步账户数据。</text></view>
<view v-else-if="!loggedIn" class="state-panel"><AppIcon name="lock" :size="54" /><text class="state-title">登录后查看积分</text><button class="state-button" @click="login">登录 / 注册</button></view>
<view v-else-if="points == null" class="state-panel"><AppIcon name="points" :size="54" /><text class="state-title">积分暂不可用</text><text class="state-copy">{{ errorMessage }}</text><button v-if="pendingRequest != null" class="state-button" :disabled="checkingIn" @click="checkIn">{{ checkingIn ? '正在确认…' : '确认上次签到' }}</button><button class="state-button" :disabled="checkingIn" @click="load">重新加载</button></view>
<template v-else>
<view class="points-hero">
<view class="points-hero-top"><text class="points-label">我的积分</text><button @click="showRules" class="points-hero-button">积分规则</button></view>
<text class="points-number">{{ balanceLabel }}</text><text class="points-note">{{ pointsNote }}</text>
</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)">
<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>
</view>
<view class="invite-panel">
<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>
<view v-if="recordsExpanded">
<view v-if="records.length == 0" class="empty-records"><text class="empty-record-title">暂无积分记录</text><text class="empty-record-copy">已发布的积分事件会显示在这里。</text></view>
<view class="record" v-for="(item, index) in records" :key="item.ledgerId" :class="index == records.length - 1 ? 'last-record' : ''"><view class="record-copy"><text class="record-name">{{ recordName(item) }}</text><text class="record-date">{{ recordDate(item) }}</text></view><text :class="item.delta >= 0 ? 'record-score' : 'record-score negative-score'">{{ deltaLabel(item) }}</text></view>
<button v-if="hasMore" class="load-more" :disabled="loadingMore" @click="loadMore">{{ loadingMore ? '正在加载…' : '加载更多' }}</button>
</view>
</view>
</template>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { getUserStorage, setUserStorage } from '@/services/storage.uts'
import { ApiRequestError, getSession } from '@/services/api.uts'
import { getUserStorage, setUserStorage, removeUserStorage } from '@/services/storage.uts'
import { usePageLayout } from '@/services/layout.uts'
import { CheckInRequest, PointsLedgerItem, PointsRules, PointsSummary, PointsTask, createCheckInRequest, fetchPointsLedger, fetchPointsRules, fetchPointsSummary, fetchPointsTasks, submitPointsCheckIn } from '@/services/points.uts'
import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue'
import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue'
const { contentStyle, pageStyle } = usePageLayout()
type PointsTask = { id: string, icon: string, name: string, caption: string }
type PointsRecord = { date: string, score: number }
const tasks: PointsTask[] = [
{ id: 'checkin', icon: 'calendar-check', name: '签到', caption: '每日 +10' },
{ id: 'share', icon: 'share', name: '分享', caption: '复制文案' },
{ id: 'profile', icon: 'edit-file', name: '完善资料', caption: '查看档案' },
{ id: 'invite', icon: 'gift', name: '邀请好友', caption: '复制邀请' }
]
const signDays = ref<string[]>([])
const today = ref('')
const recordsExpanded = ref(true)
const totalPoints = computed((): number => signDays.value.length * 10)
const signedToday = computed((): boolean => signDays.value.includes(today.value))
const records = computed((): PointsRecord[] => signDays.value.map((date: string): PointsRecord => ({ date, score: 10 })))
const localDate = (): string => {
const date = new Date()
const month = date.getMonth() + 1
const day = date.getDate()
return date.getFullYear() + '-' + (month < 10 ? '0' : '') + month + '-' + (day < 10 ? '0' : '') + day
}
const restore = (): void => {
today.value = localDate()
const stored = getUserStorage('finance.points.checkin.days')
if (typeof stored != 'string' || stored == '') { signDays.value = []; return }
const restored = (stored as string).split(',')
const unique: string[] = []
restored.forEach((day: string): void => { if (/^\d{4}-\d{2}-\d{2}$/.test(day) && !unique.includes(day)) unique.push(day) })
signDays.value = unique
}
onShow(() => { restore() })
const checkIn = (): void => {
restore()
if (signedToday.value) { uni.showToast({ title: '今天已签到,明天再来', icon: 'none' }); return }
const next = signDays.value.slice()
next.unshift(today.value)
setUserStorage('finance.points.checkin.days', next.join(','))
signDays.value = next
recordsExpanded.value = true
uni.showToast({ title: '签到完成,体验积分 +10', icon: 'none' })
}
const showRules = (): void => {
uni.showModal({ title: '体验积分规则', content: '当前积分仅供本地体验,不代表真实账户权益。每天可签到一次,每次增加 10 体验积分。分享、邀请和完善资料暂不发放积分;清除应用数据后,本地记录也会清除。', showCancel: false, confirmText: '知道了' })
}
const copyText = (value: string, message: string): void => {
uni.setClipboardData({ data: value, success: () => { uni.showToast({ title: message, icon: 'none' }) }, fail: () => { uni.showToast({ title: '复制失败,请稍后重试', icon: 'none' }) } })
}
const copyCode = (): void => { copyText('AI2680', '体验邀请码已复制') }
const invite = (): void => { copyText('邀请你一起了解 AI融资:学习融资知识,梳理申请资料,提前做好融资准备。体验邀请码:AI2680。', '邀请文案已复制,可自行分享') }
const doTask = (id: string): void => {
if (id == 'checkin') { checkIn(); return }
if (id == 'profile') { uni.navigateTo({ url: '/packages/finance/archive/archive' }); return }
if (id == 'invite') { invite(); return }
copyText('分享一个融资准备方法:先梳理信用、收入与负债,再比较适合自己的产品。可以在 AI融资的融资课堂中学习相关知识。', '分享文案已复制,可自行分享')
}
const { contentStyle, pageStyle } = usePageLayout()
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: 'invitation', icon: 'gift', name: '邀请好友' }
]
const pendingKey = 'finance.points.checkin.pending'
const loading = ref(false)
const loadingMore = ref(false)
const checkingIn = ref(false)
const loggedIn = ref(false)
const errorMessage = ref('')
const recordsExpanded = ref(true)
const points = ref<PointsSummary | null>(null)
const rules = ref<PointsRules | null>(null)
const serverTasks = ref<PointsTask[]>([])
const records = ref<PointsLedgerItem[]>([])
const snapshotId = ref('')
const nextBeforeId = ref<string | null>(null)
const pendingRequest = ref<CheckInRequest | null>(null)
const accountRefresh = ref(0)
let visible = false
let generation = 0
let loginPending = false
let viewToken = ''
const balanceLabel = computed((): string => points.value == null ? '--' : points.value.balance.toString())
const pointsNote = computed((): string => {
if (points.value == null) return '正在同步账户数据'
if (points.value.balanceState == 'anomaly') return '账户积分状态异常,请稍后重试'
if (rules.value == null || rules.value.status != 'published') return '积分服务尚未发布,签到暂不可用'
return '服务器同步 · ' + points.value.serverDate
})
const taskViews = computed((): TaskView[] => taskDefinitions.map((definition: UTSJSONObject): TaskView => {
const id = definition.getString('id') ?? ''
const task = serverTasks.value.find((item: PointsTask): boolean => item.task == id)
return { id, icon: definition.getString('icon') ?? 'points', name: definition.getString('name') ?? '', status: task?.status ?? 'unavailable', reward: task?.reward ?? null, ruleVersion: task?.ruleVersion ?? null }
}))
const hasMore = computed((): boolean => { const cursor = nextBeforeId.value; return cursor != null && cursor.length > 0 })
const login = (): void => {
if (!visible || loginPending) return
loginPending = true
uni.navigateTo({ url: '/packages/account/login/login?resumePoints=1', fail: () => { loginPending = false } })
}
const resetData = (): void => { points.value = null; rules.value = null; serverTasks.value = []; records.value = []; snapshotId.value = ''; nextBeforeId.value = null; pendingRequest.value = null; viewToken = '' }
const current = (ticket: number, token: string): boolean => {
if (!visible || generation != ticket) return false
const session = getSession()
if (session?.getString('token') == token) return true
resetData(); loading.value = false; loadingMore.value = false; checkingIn.value = false
generation++
if (session == null) { loggedIn.value = false; login() }
else { accountRefresh.value++ }
return false
}
const validPending = (value: any | null): CheckInRequest | null => {
if (value == null || typeof value != 'object' || Array.isArray(value)) return null
const data = value as UTSJSONObject
const requestId = data.getString('requestId') ?? ''; const expectedRuleVersion = data.getString('expectedRuleVersion') ?? ''; const expectedDate = data.getString('expectedDate') ?? ''
if (!/^pts_[0-9a-f]{32}$/.test(requestId) || !/^[a-z][a-z0-9-]{1,63}$/.test(expectedRuleVersion) || !/^\d{4}-\d{2}-\d{2}$/.test(expectedDate)) return null
return { requestId, expectedRuleVersion, expectedDate }
}
const restorePending = (): void => { pendingRequest.value = validPending(getUserStorage(pendingKey)) }
async function load(): Promise<void> {
if (!visible) return
const session = getSession()
if (session == null) { loggedIn.value = false; resetData(); login(); return }
const token = session.getString('token') ?? ''; const ticket = ++generation
loggedIn.value = true; loading.value = true; loadingMore.value = false; checkingIn.value = false; errorMessage.value = ''; resetData()
viewToken = token; restorePending()
try {
const summary = await fetchPointsSummary(); if (!current(ticket, token)) return
const currentRules = await fetchPointsRules(); if (!current(ticket, token)) return
const taskPage = await fetchPointsTasks(); if (!current(ticket, token)) return
const ledgerPage = await fetchPointsLedger(20); if (!current(ticket, token)) return
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
|| 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('积分状态已变化,请重新加载')
points.value = summary; rules.value = currentRules; serverTasks.value = taskPage.items
records.value = ledgerPage.items; snapshotId.value = ledgerPage.snapshotId; nextBeforeId.value = ledgerPage.nextBeforeId
} catch (error) {
if (!current(ticket, token)) return
errorMessage.value = error instanceof Error ? error.message : '积分读取失败,请重试'
} finally { if (ticket == generation && visible) loading.value = false }
}
watch(accountRefresh, () => { if (visible) load() })
const sameAccount = (): boolean => {
if (!visible) return false
const token = getSession()?.getString('token') ?? ''
if (token.length > 0 && token == viewToken) return true
load(); return false
}
const toggleRecords = (): void => { if (sameAccount()) recordsExpanded.value = !recordsExpanded.value }
const loadMore = async (): Promise<void> => {
if (loading.value || loadingMore.value || checkingIn.value || !hasMore.value || snapshotId.value.length == 0 || nextBeforeId.value == null) return
if (!sameAccount()) return
const session = getSession(); if (session == null) { login(); return }
const token = session.getString('token') ?? ''; const ticket = generation
const beforeCursor = nextBeforeId.value
if (beforeCursor == null) return
loadingMore.value = true
try {
const page = await fetchPointsLedger(20, snapshotId.value, beforeCursor)
if (!current(ticket, token)) return
records.value = records.value.concat(page.items); nextBeforeId.value = page.nextBeforeId
} catch (error) {
if (current(ticket, token)) errorMessage.value = error instanceof Error ? error.message : '积分明细读取失败,请重试'
}
finally { if (ticket == generation) loadingMore.value = false }
}
const showRules = (): void => {
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) {
uni.showModal({ title: '积分规则', content: '积分服务尚未发布,签到和奖励暂未开放。', showCancel: false, confirmText: '知道了' }); return
}
uni.showModal({ title: '积分规则', content: '当前规则版本:' + currentRules.ruleVersion + '\n每日签到奖励:' + currentRules.checkInReward.toString() + ' 分\n每日限签到 1 次,不支持补签。', showCancel: false, confirmText: '知道了' })
}
const taskCaption = (item: TaskView): string => {
if (item.id == 'checkin' && checkingIn.value) return '正在确认…'
if (item.id == 'checkin' && pendingRequest.value != null) return '确认上次状态'
if (item.status == 'completed') return '今日已完成'
if (item.status == 'available' && item.reward != null) return '每日 +' + item.reward.toString()
return '暂未开放'
}
const checkIn = async (): Promise<void> => {
if (loading.value || loadingMore.value || checkingIn.value) return
if (!sameAccount()) return
const session = getSession(); if (session == null) { login(); return }
const summary = points.value
let request = pendingRequest.value
try {
if (request == null) {
if (summary == null) { await load(); return }
if (summary.signedToday) { uni.showToast({ title: '今天已签到', icon: 'none' }); return }
request = createCheckInRequest(summary); pendingRequest.value = request; setUserStorage(pendingKey, request)
}
} catch (error) { uni.showToast({ title: error instanceof Error ? error.message : '签到暂不可用', icon: 'none' }); return }
const token = session.getString('token') ?? ''; const ticket = generation; checkingIn.value = true; errorMessage.value = ''
try {
const receipt = await submitPointsCheckIn(request)
if (!current(ticket, token)) return
removeUserStorage(pendingKey); pendingRequest.value = null; uni.showToast({ title: receipt.replayed ? '已确认签到状态' : '签到成功 +' + receipt.delta.toString() + ' 分', icon: 'none' }); await load()
} catch (error) {
if (!current(ticket, token)) return
// 只有服务器明确拒绝且未入账的结果才能弃旧键;未知提交仍用原键确认。
if (error instanceof ApiRequestError && ((error.status == 409 && ['POINT_DATE_CHANGED', 'POINT_RULE_CHANGED', 'POINT_ALREADY_SIGNED', 'POINT_REQUEST_CONFLICT', 'POINT_BALANCE_INVALID'].includes(error.errorCode))
|| (error.status == 422 && error.errorCode == 'POINT_INPUT_INVALID') || (error.status == 503 && error.errorCode == 'POINT_RULE_UNAVAILABLE'))) {
removeUserStorage(pendingKey); pendingRequest.value = null
const message = error.message
await load()
if (visible && getSession()?.getString('token') == token) uni.showToast({ title: message, icon: 'none' })
} else errorMessage.value = error instanceof Error ? error.message : '签到请求未确认,可稍后重试'
} finally { if (ticket == generation) checkingIn.value = false }
}
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 }
uni.showToast({ title: '邀请奖励暂未开放', icon: 'none' })
}
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()
onShow(() => { visible = true; loginPending = false; load() })
onHide(() => { visible = false; generation++; loading.value = false; loadingMore.value = false; checkingIn.value = false; resetData() })
onUnload(() => { visible = false; generation++; loading.value = false; loadingMore.value = false; checkingIn.value = false; resetData() })
</script>
<style>
.points-content { padding: 24rpx 28rpx 48rpx; }
.points-hero { padding: 28rpx; border-radius: 20rpx; background-color: #5148d8; }
.points-hero-top { flex-direction: row; align-items: center; justify-content: space-between; }
.points-label { font-size: 26rpx; color: #f0efff; }
.points-number { font-size: 74rpx; line-height: 94rpx; font-weight: 700; color: #ffffff; margin-top: 12rpx; }
.points-note { color: #e5e1ff; font-size: 22rpx; line-height: 34rpx; margin-top: 4rpx; }
.points-hero-button { width: 144rpx; height: 56rpx; line-height: 56rpx; padding: 0; margin: 0; border-radius: 28rpx; color: #5148d8; background-color: #ffffff; font-size: 23rpx; }
.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-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; }
.invite-panel, .records { margin-top: 22rpx; padding: 24rpx; border-radius: 18rpx; background-color: #ffffff; }
.panel-title { color: #172640; font-size: 30rpx; line-height: 42rpx; font-weight: 700; }
.invite-copy { margin-top: 12rpx; color: #66758a; font-size: 25rpx; line-height: 38rpx; }
.invite-code { min-height: 92rpx; padding: 12rpx 18rpx; margin-top: 22rpx; background-color: #f4f7fb; border-radius: 12rpx; flex-direction: row; align-items: center; }
.code-label { color: #66758a; font-size: 23rpx; }
.code { flex: 1; margin-left: 16rpx; color: #172640; font-size: 29rpx; font-weight: 700; }
.copy-action { padding: 12rpx 0 12rpx 16rpx; }
.copy-text { color: #0868f7; font-size: 24rpx; }
.invite-button { margin-top: 22rpx; }
.invite-note { color: #66758a; font-size: 22rpx; line-height: 34rpx; margin-top: 14rpx; }
.record-head { flex-direction: row; justify-content: space-between; align-items: center; min-height: 52rpx; }
.record-toggle { flex-direction: row; align-items: center; padding-left: 20rpx; }
.more { color: #0868f7; font-size: 23rpx; margin-right: 6rpx; }
.record-arrow { transform: rotate(90deg); }
.expanded-arrow { transform: rotate(-90deg); }
.record { min-height: 116rpx; padding: 18rpx 0; flex-direction: row; justify-content: space-between; align-items: center; border-bottom: 1rpx solid #edf1f6; }
.last-record { border-bottom-width: 0; }
.record-copy { flex: 1; }
.record-name { font-size: 27rpx; color: #172640; }
.record-date { color: #66758a; font-size: 22rpx; line-height: 34rpx; margin-top: 8rpx; }
.record-score { color: #168454; font-size: 30rpx; font-weight: 700; margin-left: 16rpx; }
.empty-records { padding: 28rpx 0 12rpx; }
.empty-record-title { color: #66758a; font-size: 26rpx; }
.empty-record-copy { color: #66758a; font-size: 23rpx; line-height: 36rpx; margin-top: 10rpx; }
.points-content { padding: 24rpx 28rpx 48rpx; }
.points-hero { padding: 28rpx; border-radius: 20rpx; background-color: #5148d8; }
.points-hero-top { flex-direction: row; align-items: center; justify-content: space-between; }
.points-label { font-size: 26rpx; color: #f0efff; }
.points-number { font-size: 74rpx; line-height: 94rpx; font-weight: 700; color: #ffffff; margin-top: 12rpx; }
.points-note { color: #e5e1ff; font-size: 22rpx; line-height: 34rpx; margin-top: 4rpx; }
.points-hero-button { width: 144rpx; height: 56rpx; line-height: 56rpx; padding: 0; margin: 0; border-radius: 28rpx; color: #5148d8; background-color: #ffffff; font-size: 23rpx; }
.state-panel { padding: 100rpx 28rpx; align-items: center; background-color: #ffffff; border-radius: 18rpx; }
.state-title { margin-top: 24rpx; color: #172640; font-size: 30rpx; font-weight: 700; }
.state-copy { margin-top: 12rpx; color: #66758a; font-size: 24rpx; line-height: 36rpx; text-align: center; }
.state-button { min-width: 250rpx; height: 76rpx; line-height: 76rpx; margin-top: 28rpx; border-radius: 14rpx; color: #ffffff; background-color: #0868f7; font-size: 26rpx; }
.inline-error { min-height: 76rpx; padding: 14rpx 20rpx; margin-top: 18rpx; border-radius: 12rpx; background-color: #fff4f2; flex-direction: row; align-items: center; }
.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-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; }
.invite-panel, .records { margin-top: 22rpx; padding: 24rpx; border-radius: 18rpx; background-color: #ffffff; }
.panel-title { color: #172640; font-size: 30rpx; line-height: 42rpx; font-weight: 700; }
.invite-copy { margin-top: 12rpx; color: #66758a; font-size: 25rpx; line-height: 38rpx; }
.record-head { flex-direction: row; justify-content: space-between; align-items: center; min-height: 52rpx; }
.record-toggle { flex-direction: row; align-items: center; padding-left: 20rpx; }
.more { color: #0868f7; font-size: 23rpx; margin-right: 6rpx; }
.record-arrow { transform: rotate(90deg); }
.expanded-arrow { transform: rotate(-90deg); }
.record { min-height: 116rpx; padding: 18rpx 0; flex-direction: row; justify-content: space-between; align-items: center; border-bottom: 1rpx solid #edf1f6; }
.last-record { border-bottom-width: 0; }
.record-copy { flex: 1; }
.record-name { font-size: 27rpx; color: #172640; }
.record-date { color: #66758a; font-size: 22rpx; line-height: 34rpx; margin-top: 8rpx; }
.record-score { color: #168454; font-size: 30rpx; font-weight: 700; margin-left: 16rpx; }
.negative-score { color: #b0473e; }
.empty-records { padding: 28rpx 0 12rpx; }
.empty-record-title { color: #66758a; font-size: 26rpx; }
.empty-record-copy { color: #66758a; font-size: 23rpx; line-height: 36rpx; margin-top: 10rpx; }
.load-more { height: 72rpx; line-height: 72rpx; margin-top: 18rpx; border-radius: 12rpx; color: #0868f7; background-color: #f2f6fc; font-size: 24rpx; }
</style>