schema v3 新增 lexgo_books、lexgo_chapters、lexgo_ingest_jobs;一次粘贴生成一个章节, 原文按收到的字符串逐字保存;处理任务具备持久状态机、启动恢复与 requestId 幂等。 新增书籍/章节/任务 API 一律按认证身份过滤归属,他人编号返回 404,后台任务不使用 客户端用户编号;学习端补齐粘贴导入、书库、章节状态与失败重试、原文阅读与章节切换。 测试:MySQL 集成测试覆盖导入→处理中→就绪→阅读完整路径、失败重试、幂等、越权与 Unicode 原文保真;学习端 Vitest 31 项、Playwright 3 项与构建通过;lexgo_dev 已显式 迁移到 v3,迁移前后既有数据指纹不变。 文档:Wiki 先写后回读(架构、业务规则、本地验证、需求总览、Home),导出核心镜像。
107 lines
3.6 KiB
TypeScript
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 }
|
|
})
|