Files
lexgo/learner/src/stores/progress.ts
T
ila a9952b3516 feat: 完成章节并查看个人基础进度 (#13)
- schema v7 新增 lexgo_chapter_progress(chapter_id 主键、read_sha256 内容版本门控、
  外键级联到章节/书籍/账号),仍然只用可重放的 CREATE TABLE IF NOT EXISTS
- POST /api/v1/chapters/:id/complete:只记已读、不改词语状态;只有 ready 章节可标记;
  重复调用返回同一行并带 duplicate,不移动已读时间也不重复计数;正文新版本后该章
  回到未读但保留记录,重新标记更新同一行
- GET /api/v1/progress:已读/可阅读章节、已知/学习中/新词/忽略、已保存词条、待复习
  与每本书的已读进度;到期数量与复习队列共用 dueTermsQuery 与同一时钟
- ChapterSummary 增加 readAt,书籍列表、阅读器与编辑响应都会解析
- 学习端新增 /progress 页面与导航、stores/progress.ts,阅读器新增显式「标记本章已读」
  区块,书籍页显示已读标记
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-15 12:31:53 +08:00

103 lines
3.0 KiB
TypeScript

import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { ApiError, useSessionStore } from './session'
/** One book's share of the reading total. */
export interface BookProgress {
id: number
title: string
readChapters: number
totalChapters: number
}
/**
* The learner's own basic progress for the current language. Reading counts only chapters that
* can be read, and the four word statuses are reported apart, so "known" and "ignored" are never
* folded into one number.
*/
export interface LearnerProgress {
readChapters: number
totalChapters: number
knownTerms: number
learningTerms: number
newTerms: number
ignoredTerms: number
savedTerms: number
dueNow: number
books: BookProgress[]
}
function emptyProgress(): LearnerProgress {
return {
readChapters: 0, totalChapters: 0, knownTerms: 0, learningTerms: 0,
newTerms: 0, ignoredTerms: 0, savedTerms: 0, dueNow: 0, books: [],
}
}
/**
* The progress page reads one snapshot from the server. Every number is counted server-side under
* the caller's own identity, so the page never adds up rows it happens to have loaded.
*/
export const useProgressStore = defineStore('progress', () => {
const session = useSessionStore()
const data = ref<LearnerProgress>(emptyProgress())
const loading = ref(false)
const error = ref('')
const loaded = ref(false)
let sequence = 0
let generation = 0
const hasBooks = computed(() => data.value.books.length > 0)
const readLabel = computed(() => `${data.value.readChapters} / ${data.value.totalChapters} 章(可阅读)`)
watch(() => session.user?.id ?? null, (next, previous) => {
if (next !== previous) reset()
}, { flush: 'sync' })
function ownerId(): number | null {
return session.user?.id ?? null
}
function isStale(version: number, owner: number | null): boolean {
return version !== generation || owner !== ownerId()
}
async function load(): Promise<void> {
if (!session.user) return
const version = generation
const owner = ownerId()
const seq = ++sequence
loading.value = true
error.value = ''
try {
const result = await session.request<LearnerProgress>('progress')
if (seq !== sequence || isStale(version, owner)) return
data.value = {
...emptyProgress(),
...result,
books: Array.isArray(result?.books) ? result.books : [],
}
loaded.value = true
} catch (reason) {
if (seq !== sequence || isStale(version, owner)) return
// Keep whatever is already on screen; a failed refresh is not a claim about the records.
error.value = reason instanceof ApiError
? reason.message
: '进度暂时无法加载,请稍后重试。'
} finally {
if (seq === sequence && !isStale(version, owner)) loading.value = false
}
}
function reset(): void {
generation++
sequence++
data.value = emptyProgress()
loading.value = false
error.value = ''
loaded.value = false
}
return { data, loading, error, loaded, hasBooks, readLabel, load, reset }
})