Files
lexgo/learner/src/stores/session.ts
T
ila a55708cd37 feat: 粘贴英语文本、章节处理与本人阅读 (#5)
schema v3 新增 lexgo_books、lexgo_chapters、lexgo_ingest_jobs;一次粘贴生成一个章节,
原文按收到的字符串逐字保存;处理任务具备持久状态机、启动恢复与 requestId 幂等。
新增书籍/章节/任务 API 一律按认证身份过滤归属,他人编号返回 404,后台任务不使用
客户端用户编号;学习端补齐粘贴导入、书库、章节状态与失败重试、原文阅读与章节切换。

测试:MySQL 集成测试覆盖导入→处理中→就绪→阅读完整路径、失败重试、幂等、越权与
Unicode 原文保真;学习端 Vitest 31 项、Playwright 3 项与构建通过;lexgo_dev 已显式
迁移到 v3,迁移前后既有数据指纹不变。

文档:Wiki 先写后回读(架构、业务规则、本地验证、需求总览、Home),导出核心镜像。
2026-09-11 00:36:23 +08:00

107 lines
3.6 KiB
TypeScript

import { defineStore } from 'pinia'
import { ref } from 'vue'
export const TOKEN_KEY = 'lexgo-learner-token'
// Carries the real HTTP status alongside the server message so callers can tell
// "this id does not exist for me" (404) from a transient failure without
// re-parsing the envelope. The message itself is unchanged.
export class ApiError extends Error {
readonly status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
interface User { id: number; username: string; role: 'admin' | 'learner' }
interface Space { ownerId: number; language: 'en' }
interface Login { token: string; expiresAt: string; user: User }
export const useSessionStore = defineStore('session', () => {
const user = ref<User | null>(null)
const space = ref<Space | null>(null)
const notice = ref('')
let token = sessionStorage.getItem(TOKEN_KEY) ?? ''
// Invalidate all in-flight work on any session boundary, including failed switches.
let generation = 0
let initialized = false
let restoring: Promise<void> | undefined
function clear() {
generation++
token = ''
user.value = null
space.value = null
sessionStorage.removeItem(TOKEN_KEY)
}
async function request<T>(path: string, method = 'GET', body?: unknown, auth = token, version = generation): Promise<T> {
const response = await fetch(`/api/v1/${path}`, {
method,
headers: { ...(auth ? { Authorization: `Bearer ${auth}` } : {}), ...(body ? { 'Content-Type': 'application/json' } : {}) },
body: body ? JSON.stringify(body) : undefined,
cache: 'no-store',
})
if (response.status === 401 && version === generation) {
clear()
notice.value = '登录已失效,请重新登录。'
}
const result = await response.json()
if (!response.ok || result.code !== 200) throw new ApiError(result.msg || '请求失败,请稍后重试。', response.status)
return result.data as T
}
async function login(username: string, password: string) {
clear()
initialized = true
notice.value = ''
const version = generation
const result = await request<Login>('login', 'POST', { username, password }, '', version)
if (version !== generation) return
sessionStorage.setItem(TOKEN_KEY, result.token)
token = result.token
user.value = result.user
}
async function restore() {
if (initialized) return
if (restoring) return restoring
const version = generation
restoring = (async () => {
try {
if (!token) return
const account = await request<User>('me')
if (version === generation) user.value = account
} catch {
if (version === generation) {
clear()
notice.value = '暂时无法恢复登录,请重新登录。'
}
} finally {
initialized = true
restoring = undefined
}
})()
return restoring
}
async function loadSpace() {
if (!user.value) throw new Error('请先登录。')
const version = generation
const ownerId = user.value.id
const result = await request<Space>('space')
if (version !== generation) return
if (result.ownerId !== ownerId || result.language !== 'en') throw new Error('学习空间暂时无法加载,请稍后重试。')
space.value = result
}
async function logout() {
const previousToken = token
clear()
notice.value = ''
if (previousToken) await request<null>('logout', 'POST', undefined, previousToken, -1)
}
return { user, space, notice, login, restore, logout, loadSpace, request }
})