feat: 接入内容服务端真实接口并优化页面交互

- 迁移内容数据到服务端接口,移除本地模拟数据
- 首页新增推荐课程加载与未读消息检查
- 资讯、课程、搜索页面支持分页加载与错误重试
- 登录页新增恢复课程、消息、帮助页的路由逻辑
- 评估分析页新增处理中状态动效与阶段提示
- API 环境切换到正式域名 airz.hqling.cloud
This commit is contained in:
hujun
2026-10-01 20:26:41 +08:00
parent 3b2a0139e4
commit 2a9ff403c4
41 changed files with 21742 additions and 427 deletions
+74 -48
View File
@@ -2,16 +2,19 @@
<view class="page" :style="pageStyle()">
<AppHeader title="课程详情" />
<scroll-view scroll-y :scroll-into-view="scrollTarget" :style="contentStyle(96, false)">
<view v-if="available" class="lesson-detail-content">
<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>
<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.title" :id="'chapter-' + index">
<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>
@@ -19,7 +22,7 @@
</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]" @click="completeChapter(index)">{{ completed[index] ? '本节已完成' : '我已读完,完成本节' }}</button>
<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>
</view>
@@ -29,54 +32,77 @@
</template>
<script setup lang="uts">
import { getUserStorage, setUserStorage } from '@/services/storage.uts'
import { usePageLayout } from '@/services/layout.uts'
import { financeLessons, findFinanceLesson, FinanceLesson } from '@/packages/content/services/content.uts'
import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue'
const { contentStyle, pageStyle } = usePageLayout()
const lesson = ref<FinanceLesson>(financeLessons[0])
const available = ref(true)
const completed = ref<boolean[]>([])
const activeIndex = ref(-1)
const scrollTarget = ref('')
const storageKey = (index: number): string => 'finance.lesson.' + lesson.value.id + '.chapter.' + index
const restoreProgress = (): void => {
completed.value = lesson.value.chapters.map((_item, index): boolean => getUserStorage(storageKey(index)) == true)
}
const completedCount = computed((): number => completed.value.filter((value: boolean): boolean => value).length)
const progress = computed((): number => Math.round(completedCount.value * 100 / lesson.value.chapters.length))
onLoad((options) => {
if (options != null) {
const id = options['id'] == null ? '' : options['id'] as string
const legacyTitle = options['title'] == null ? '' : options['title'] as string
if (id != '' || legacyTitle != '') {
const selected = findFinanceLesson(id, legacyTitle)
available.value = selected != null
if (selected != null) lesson.value = selected
}
}
restoreProgress()
})
const toggleChapter = (index: number): void => { activeIndex.value = activeIndex.value == index ? -1 : index }
const completeChapter = (index: number): void => {
setUserStorage(storageKey(index), true)
const next = completed.value.slice()
next[index] = true
completed.value = next
}
const start = (): void => {
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 = (): void => { uni.redirectTo({ url: '/packages/content/classroom/classroom' }) }
import { getSession } from '@/services/api.uts'
import { usePageLayout } from '@/services/layout.uts'
import { fetchLesson, fetchLessonProgress, completeLessonChapter, emptyLesson, FinanceLesson } 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
let reloadAfterAccountChange: () => void = () => {}
const tokenNow = (): string => getSession()?.getString('token') ?? ''
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()
return false
}
async function reload() {
const request = ++generation; const token = tokenNow()
loading.value = true; available.value = false; error.value = ''; progressError.value = ''; completedIds.value = []; saving.value = false
loggedIn.value = token.length > 0
try {
const result = await fetchLesson(id)
if (!current(request, token)) return
lesson.value = result; activeIndex.value = -1; available.value = true
if (token.length > 0) {
try {
const state = await fetchLessonProgress(id)
if (!current(request, token)) return
if (state.lessonVersion != result.lessonVersion) { progressError.value = '课程已更新,请刷新后继续学习'; return }
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 }
}
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 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 }
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 : '进度保存失败,请重试' }
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 = [] })
</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-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; }