- 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 与需求更新
103 lines
3.0 KiB
TypeScript
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 }
|
|
})
|