feat: 优化账户切换时的页面状态一致性,增加登录回跳校验和课程阅读新逻辑

- 增加账户状态监听定时器,页面切换时自动校验账户归属,避免跨账户操作
- 新增登录回跳路径校验函数,限制仅允许指定页面回跳,防止非法跳转
- 课程页改为整篇连续阅读,底部仅一次标记已读,兼容历史记录
- 完善助手、对比页、资料填写页等多处的账户状态校验逻辑,防止错版数据提交
- 优化账户资料请求逻辑,增加版本校验,避免旧请求覆盖新数据
This commit is contained in:
hujun
2026-10-07 10:01:05 +08:00
parent 1723363b7e
commit 71b3c25695
37 changed files with 1389 additions and 323 deletions
+3 -2
View File
@@ -33,7 +33,7 @@
<script setup lang="uts">
import { usePageLayout } from '@/services/layout.uts'
import { fetchLessons, FinanceLesson } from '@/services/content.uts'
import { fetchLessons, FinanceLesson, validateContentAppend } from '@/services/content.uts'
import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue'
const { contentStyle, pageStyle } = usePageLayout()
@@ -51,10 +51,11 @@ async function load(append: boolean) {
if (!visible || loading.value || loadingMore.value || (append && !hasMore.value)) return
const request = append ? generation : ++generation
if (append) { loadingMore.value = true; moreError.value = '' }
else { loading.value = true; error.value = ''; lessons.value = []; total.value = 0; hasMore.value = false; page = 1 }
else { loading.value = true; error.value = ''; moreError.value = ''; lessons.value = []; total.value = 0; hasMore.value = false; page = 1 }
try {
const result = await fetchLessons(activeTopic.value, append ? page + 1 : 1)
if (!visible || request != generation) return
if (append) validateContentAppend(lessons.value.map((item: FinanceLesson): string => item.id), result.items.map((item: FinanceLesson): string => item.id))
lessons.value = append ? lessons.value.concat(result.items) : result.items
total.value = result.total; hasMore.value = result.hasMore
if (append) page++
+56 -71
View File
@@ -1,29 +1,22 @@
<template>
<view class="page" :style="pageStyle()">
<view class="page page-white" :style="pageStyle()">
<AppHeader title="课程详情" />
<scroll-view scroll-y :scroll-into-view="scrollTarget" :style="contentStyle(96, false)">
<scroll-view scroll-y :style="contentStyle(96, false)">
<view v-if="loading" class="empty-content"><text class="empty-copy">正在加载课程…</text></view>
<view v-else-if="error.length > 0" class="empty-content"><text class="empty-title">{{ error }}</text><button class="primary-button empty-button" @click="reload">重新加载</button></view>
<view v-else-if="available" class="lesson-detail-content">
<image class="lesson-cover" :src="lesson.image" mode="aspectFill"></image>
<text class="lesson-detail-title">{{ lesson.title }}</text>
<view class="lesson-detail-meta"><text class="lesson-tag">{{ lesson.tag }}</text><text class="lesson-duration">{{ lesson.duration }} · {{ lesson.chapters.length }} 节图文</text></view>
<view class="lesson-detail-meta"><text class="lesson-tag">{{ lesson.tag }}</text><text class="lesson-duration">{{ lesson.duration }}阅读</text></view>
<text class="lesson-intro">{{ lesson.intro }}</text>
<view class="progress-head"><text class="progress-label">学习进度</text><text class="progress-value">已完成 {{ completedCount }}/{{ lesson.chapters.length }} 节</text></view>
<view class="lesson-progress"><view class="progress-fill" :style="'width:' + progress + '%;'"></view></view>
<view v-if="progressError.length > 0" class="progress-error"><text>{{ progressError }}</text><text class="progress-retry" @click="reload">刷新课程</text></view>
<button class="primary-button start-learning" @click="start">{{ completedCount == 0 ? '开始学习' : (completedCount == lesson.chapters.length ? '回顾课程' : '继续学习') }}</button>
<text class="chapter-heading">课程目录</text>
<view class="chapter-card" v-for="(item, index) in lesson.chapters" :key="item.id" :id="'chapter-' + index">
<view class="chapter-row" @click="toggleChapter(index)">
<view :class="completed[index] ? 'chapter-index completed-index' : 'chapter-index'"><text :class="completed[index] ? 'index-text completed-text' : 'index-text'">{{ index + 1 }}</text></view>
<view class="chapter-copy"><text class="chapter-title">{{ item.title }}</text><text :class="completed[index] ? 'chapter-state completed-state' : 'chapter-state'">{{ completed[index] ? '已完成 · 可再次阅读' : '点击展开阅读' }}</text></view>
<AppIcon name="chevron-right" :size="28" :class="activeIndex == index ? 'chapter-arrow open-arrow' : 'chapter-arrow'" muted />
</view>
<view v-if="activeIndex == index" class="chapter-content">
<text class="chapter-body">{{ item.body }}</text>
<button :class="completed[index] ? 'chapter-done chapter-already-done' : 'chapter-done'" :disabled="completed[index] || saving" @click="completeChapter(index)">{{ completed[index] ? '本节已完成' : (saving ? '正在保存…' : (loggedIn ? '我已读完,完成本节' : '登录后记录学习进度')) }}</button>
</view>
<view v-for="item in lesson.chapters" :key="item.id" class="reading-section">
<text class="reading-heading">{{ item.title }}</text>
<text class="reading-body">{{ item.body }}</text>
</view>
<view class="reading-footer">
<text class="reading-note">内容仅供信息参考,具体申请条件以相关机构官方说明为准。</text>
<view v-if="progressError.length > 0" class="progress-error"><text>{{ progressError }}</text><text class="progress-retry" @click="reload">重新加载</text></view>
<button :class="isRead ? 'read-button already-read' : 'read-button'" :disabled="isRead || saving || progressLoading" @click="markRead">{{ isRead ? '已读完' : (saving ? '正在保存…' : (progressLoading ? '正在读取状态…' : (loggedIn ? '我已读完' : '登录后标记已读'))) }}</button>
</view>
</view>
<view v-else class="empty-content"><text class="empty-title">暂未找到这门课程</text><text class="empty-copy">返回融资课堂,选择其他课程。</text><button class="primary-button empty-button" @click="goClassroom">返回课堂</button></view>
@@ -34,105 +27,97 @@
<script setup lang="uts">
import { getSession } from '@/services/api.uts'
import { usePageLayout } from '@/services/layout.uts'
import { fetchLesson, fetchLessonProgress, completeLessonChapter, emptyLesson, FinanceLesson } from '@/services/content.uts'
import { fetchLesson, fetchLessonProgress, completeLessonChapter, emptyLesson, FinanceLesson, validateLessonState } from '@/services/content.uts'
import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue'
const { contentStyle, pageStyle } = usePageLayout()
const lesson = ref<FinanceLesson>(emptyLesson()); const available = ref(false); const loading = ref(false); const error = ref('')
const completedIds = ref<string[]>([]); const activeIndex = ref(-1); const scrollTarget = ref('')
const progressError = ref(''); const loggedIn = ref(false); const saving = ref(false)
let id = ''; let visible = false; let generation = 0
const completedIds = ref<string[]>([])
const progressError = ref(''); const loggedIn = ref(false); const saving = ref(false); const progressLoading = ref(false)
let id = ''; let visible = false; let generation = 0; let owner = ''; let accountTimer = 0
let reloadAfterAccountChange: () => void = () => {}
const tokenNow = (): string => getSession()?.getString('token') ?? ''
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
function ensureAccount(): boolean {
if (!visible) return false
if (tokenNow() == owner) return true
// 旧课程页不能用新账户提交完成记录;可见期也及时移除旧账户进度。
completedIds.value = []; saving.value = false; reloadAfterAccountChange()
return false
}
function current(request: number, token: string): boolean {
if (!visible || request != generation) return false
if (tokenNow() == token) return true
// 学习内容公开,但完成记录只属于当前账户;切换后立即清除旧进度。
generation++; completedIds.value = []; saving.value = false; reloadAfterAccountChange()
ensureAccount()
return false
}
async function reload() {
if (!visible) return
const request = ++generation; const token = tokenNow()
owner = token; stopAccountTimer(); accountTimer = setInterval(() => { ensureAccount() }, 1000)
loading.value = true; available.value = false; error.value = ''; progressError.value = ''; completedIds.value = []; saving.value = false
loggedIn.value = token.length > 0
loggedIn.value = token.length > 0; progressLoading.value = false
try {
const result = await fetchLesson(id)
if (!current(request, token)) return
lesson.value = result; activeIndex.value = -1; available.value = true
lesson.value = result; available.value = true; loading.value = false
if (token.length > 0) {
progressLoading.value = true
try {
const state = await fetchLessonProgress(id)
if (!current(request, token)) return
if (state.lessonVersion != result.lessonVersion) { progressError.value = '课程已更新,请刷新后继续学习'; return }
validateLessonState(result, state)
completedIds.value = state.completedChapterIds
} catch (err) { if (current(request, token)) progressError.value = err instanceof Error ? err.message : '进度加载失败,请重试' }
}
} catch (err) { if (current(request, token)) error.value = err instanceof Error ? err.message : '课程加载失败,请重试' }
finally { if (request == generation) loading.value = false }
finally { if (request == generation) { loading.value = false; progressLoading.value = false } }
}
reloadAfterAccountChange = () => { reload() }
const completed = computed((): boolean[] => lesson.value.chapters.map((item): boolean => completedIds.value.includes(item.id)))
const completedCount = computed((): number => completedIds.value.length)
const progress = computed((): number => lesson.value.chapters.length > 0 ? Math.round(completedCount.value * 100 / lesson.value.chapters.length) : 0)
const toggleChapter = (index: number) => { activeIndex.value = activeIndex.value == index ? -1 : index }
const completeChapter = async (index: number) => {
if (!available.value || saving.value || index < 0 || index >= lesson.value.chapters.length) return
const isRead = computed((): boolean => lesson.value.chapters.length > 0 && lesson.value.chapters.every((item): boolean => completedIds.value.includes(item.id)))
const markRead = async () => {
if (!ensureAccount() || !available.value || loading.value || progressLoading.value || saving.value || isRead.value) return
const token = tokenNow()
if (token.length == 0) { uni.navigateTo({ url: '/packages/account/login/login?resumeLesson=1&lessonId=' + id }); return }
if (progressError.value.length > 0) { uni.showToast({ title: '请先刷新课程和进度', icon: 'none' }); return }
if (progressError.value.length > 0) { uni.showToast({ title: '请先重新加载已读状态', icon: 'none' }); return }
const request = generation; saving.value = true; progressError.value = ''
try {
const state = await completeLessonChapter(id, lesson.value.chapters[index].id, lesson.value.lessonVersion)
if (current(request, token)) completedIds.value = state.completedChapterIds
} catch (err) { if (current(request, token)) progressError.value = err instanceof Error ? err.message : '进度保存失败,请重试' }
// 沿用已上线的记录接口;一次操作补齐未完成段落,失败后可从已有记录继续。
const content = lesson.value
for (let index = 0; index < content.chapters.length; index++) {
if (!current(request, token)) return
if (completedIds.value.includes(content.chapters[index].id)) continue
const state = await completeLessonChapter(id, content.chapters[index].id, content.lessonVersion)
if (!current(request, token)) return
validateLessonState(content, state); completedIds.value = state.completedChapterIds
}
if (!isRead.value) throw new Error('已读状态保存不完整,请重新加载后重试')
} catch (err) { if (current(request, token)) progressError.value = err instanceof Error ? err.message : '已读状态保存失败,请重试' }
finally { if (request == generation) saving.value = false }
}
const start = () => {
let nextIndex = completed.value.findIndex((value: boolean): boolean => !value)
if (nextIndex < 0) nextIndex = 0
activeIndex.value = nextIndex; scrollTarget.value = ''
nextTick(() => { scrollTarget.value = 'chapter-' + nextIndex })
}
const goClassroom = () => { uni.redirectTo({ url: '/packages/content/classroom/classroom' }) }
onLoad((options) => { id = options?.['id'] ?? '' })
onShow(() => { visible = true; reload() })
onHide(() => { visible = false; generation++; completedIds.value = []; saving.value = false })
onUnload(() => { visible = false; generation++; completedIds.value = [] })
onHide(() => { visible = false; stopAccountTimer(); generation++; completedIds.value = []; saving.value = false; progressLoading.value = false })
onUnload(() => { visible = false; stopAccountTimer(); generation++; completedIds.value = []; saving.value = false; progressLoading.value = false })
</script>
<style>
.progress-error { color: #b64d35; font-size: 24rpx; line-height: 40rpx; margin-top: 16rpx; }
.progress-retry { color: #0868f7; padding: 14rpx 0; }
.lesson-detail-content { padding: 24rpx 28rpx 48rpx; }
.lesson-detail-content { padding: 24rpx 30rpx 64rpx; }
.lesson-cover { width: 100%; height: 260rpx; border-radius: 18rpx; }
.lesson-detail-title { color: #172640; font-size: 36rpx; line-height: 50rpx; font-weight: 700; margin-top: 24rpx; }
.lesson-detail-meta { flex-direction: row; justify-content: space-between; align-items: center; margin-top: 14rpx; }
.lesson-tag { color: #0868f7; font-size: 24rpx; }
.lesson-duration { color: #66758a; font-size: 23rpx; }
.lesson-intro { color: #66758a; font-size: 26rpx; line-height: 44rpx; margin-top: 22rpx; }
.progress-head { flex-direction: row; align-items: center; justify-content: space-between; margin-top: 26rpx; }
.progress-label { color: #172640; font-size: 24rpx; font-weight: 600; }
.progress-value { color: #0868f7; font-size: 23rpx; }
.lesson-progress { height: 10rpx; margin-top: 12rpx; border-radius: 6rpx; background-color: #e1eafa; overflow: hidden; }
.progress-fill { height: 100%; border-radius: 6rpx; background-color: #0868f7; }
.start-learning { margin-top: 24rpx; }
.chapter-heading { color: #172640; font-size: 30rpx; font-weight: 700; margin-top: 32rpx; margin-bottom: 14rpx; }
.chapter-card { border: 1rpx solid #e5ecf5; background-color: #ffffff; border-radius: 16rpx; margin-bottom: 14rpx; overflow: hidden; }
.chapter-row { min-height: 116rpx; padding: 20rpx; flex-direction: row; align-items: center; }
.chapter-index { width: 48rpx; height: 48rpx; border-radius: 14rpx; background-color: #eaf2ff; align-items: center; justify-content: center; margin-right: 16rpx; flex-shrink: 0; }
.index-text { color: #0868f7; font-size: 24rpx; font-weight: 700; }
.completed-index { background-color: #eaf7ef; }
.completed-text { color: #168454; }
.chapter-copy { flex: 1; padding-right: 8rpx; }
.chapter-title { color: #172640; font-size: 28rpx; line-height: 40rpx; font-weight: 600; }
.chapter-state { color: #66758a; font-size: 22rpx; margin-top: 6rpx; }
.completed-state { color: #168454; }
.chapter-arrow { transform: rotate(90deg); }
.open-arrow { transform: rotate(-90deg); }
.chapter-content { padding: 0 22rpx 24rpx; }
.chapter-body { color: #4d5e75; font-size: 27rpx; line-height: 48rpx; border-top: 1rpx solid #e8eef6; padding-top: 22rpx; }
.chapter-done { margin-top: 24rpx; width: 100%; height: 80rpx; line-height: 80rpx; border-radius: 12rpx; background-color: #eaf2ff; color: #0868f7; font-size: 26rpx; font-weight: 600; }
.chapter-already-done { color: #168454; background-color: #edf8f1; }
.reading-section { margin-top: 32rpx; }
.reading-heading { color: #172640; font-size: 30rpx; line-height: 46rpx; font-weight: 600; }
.reading-body { color: #4d5e75; font-size: 28rpx; line-height: 52rpx; margin-top: 12rpx; }
.reading-footer { border-top: 1rpx solid #e8eef6; padding-top: 24rpx; margin-top: 40rpx; }
.reading-note { color: #66758a; font-size: 22rpx; line-height: 36rpx; }
.read-button { margin-top: 28rpx; width: 100%; height: 88rpx; line-height: 88rpx; border-radius: 12rpx; background-color: #eaf2ff; color: #0868f7; font-size: 28rpx; font-weight: 600; }
.already-read { color: #168454; background-color: #edf8f1; }
.empty-content { padding: 100rpx 40rpx; align-items: center; }
.empty-title { color: #172640; font-size: 32rpx; font-weight: 700; }
.empty-copy { color: #66758a; font-size: 26rpx; margin-top: 20rpx; }
+3 -2
View File
@@ -30,7 +30,7 @@
<script setup lang="uts">
import { usePageLayout } from '@/services/layout.uts'
import { fetchArticles, emptyArticle, NewsArticle } from '@/services/content.uts'
import { fetchArticles, emptyArticle, NewsArticle, validateContentAppend } from '@/services/content.uts'
import AppHeader from '@/components/AppHeader.uvue'
import BottomNav from '@/components/BottomNav.uvue'
const { contentStyle, pageStyle } = usePageLayout()
@@ -44,11 +44,12 @@ async function load(append: boolean) {
if (!visible || loading.value || loadingMore.value || (append && !hasMore.value)) return
const request = append ? generation : ++generation
if (append) { loadingMore.value = true; moreError.value = '' }
else { loading.value = true; error.value = ''; articles.value = []; total.value = 0; hasMore.value = false; page = 1 }
else { loading.value = true; error.value = ''; moreError.value = ''; articles.value = []; total.value = 0; hasMore.value = false; page = 1 }
try {
const result = await fetchArticles(tab.value == '推荐' ? '' : tab.value, append ? page + 1 : 1)
// 换分类或离页后,旧列表响应不能覆盖新选择。
if (!visible || request != generation) return
if (append) validateContentAppend(articles.value.map((item: NewsArticle): string => item.id), result.items.map((item: NewsArticle): string => item.id))
articles.value = append ? articles.value.concat(result.items) : result.items
total.value = result.total; hasMore.value = result.hasMore
if (append) page++
+9 -5
View File
@@ -39,6 +39,7 @@
import AppHeader from '@/components/AppHeader.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { searchContent, ContentSearchResult } from '@/packages/content/services/search.uts'
import { validateContentAppend } from '@/services/content.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
type SearchTab = { id: string, label: string }
const tabs: SearchTab[] = [{ id: 'all', label: '全部' }, { id: 'news', label: '新闻' }, { id: 'lesson', label: '融资课堂' }]
@@ -53,9 +54,11 @@
const total = ref(0); const hasMore = ref(false); const loadingMore = ref(false); const moreError = ref('')
let page = 1
let requestVersion = 0
let visible = false
const resetResults = () => { requestVersion++; results.value = []; total.value = 0; hasMore.value = false; loadingMore.value = false; moreError.value = ''; searched.value = false; loading.value = false; errorMessage.value = ''; listVersion.value++ }
const clearSearch = () => { keyword.value = ''; resetResults() }
async function load(append: boolean) {
if (!visible) return
if (append && (loading.value || loadingMore.value || !hasMore.value)) return
keyword.value = keyword.value.trim()
if (keyword.value.length == 0) { clearSearch(); return }
@@ -66,12 +69,13 @@
try {
const result = await searchContent(keyword.value, activeType.value, append ? page + 1 : 1)
// 输入、切换分类或退出页面后,忽略旧请求,避免结果被覆盖。
if (currentRequest != requestVersion) return
if (!visible || currentRequest != requestVersion) return
if (append) validateContentAppend(results.value.map((item: ContentSearchResult): string => item.type + ':' + item.id), result.items.map((item: ContentSearchResult): string => item.type + ':' + item.id))
results.value = append ? results.value.concat(result.items) : result.items
total.value = result.total; hasMore.value = result.hasMore
if (append) page++
} catch (err) {
if (currentRequest == requestVersion) {
if (visible && currentRequest == requestVersion) {
const message = err instanceof Error ? err.message : '请检查网络连接后重试'
if (append) moreError.value = message; else errorMessage.value = message
}
@@ -85,9 +89,9 @@
const route = item.type == 'news' ? '/packages/content/article/article' : '/packages/content/lesson/lesson'
uni.navigateTo({ url: route + '?id=' + encodeURIComponent(item.id) })
}
onShow(() => { if (searched.value) search() })
onHide(() => { requestVersion++; loading.value = false; loadingMore.value = false })
onUnload(() => { requestVersion++ })
onShow(() => { visible = true; if (searched.value) search() })
onHide(() => { visible = false; requestVersion++; loading.value = false; loadingMore.value = false })
onUnload(() => { visible = false; requestVersion++ })
</script>
<style>