Files
lexgo/learner/src/stores/session.ts
T
ila ab23ff60e5 feat: 书籍封面与音频附件、播放器与播放位置 (#21)
- schema v8:lexgo_book_attachments(按 book_id+kind 唯一,bytes MEDIUMBLOB)与
  lexgo_playback_positions(owner_id+book_id 主键),只用可重放 DDL,外键级联删书
- 后端:按文件头 magic bytes 判定类型(MP3 / JPG / PNG / WebP,含 WebP 维度解析),
  音频 ≤20 MiB、封面 ≤2 MiB 且 ≤4096²;上传/替换/移除;binaryResponse 交给
  http.ServeContent 提供 Range 206、416、ETag 与 304;播放位置 upsert,替换音频时重置
- 学习端:带凭据的 fetch + 对象 URL(令牌不进 URL),书库封面、书籍页「音频与封面」
  区块、阅读页常驻播放器(播放/暂停、进度、0.75–1.5 倍速、错误重试、位置上报)
- 测试:Go 86 项(新增附件单测/集成与 v7→v8 迁移)、学习端 157 单测与 26 项 E2E
- 顺带修掉 e2e/phrase.spec.ts 的偶发失败:重试条件改为断言期望词数、重读坐标、关面板后重试
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home /
  Deployment-and-Operations(备份范围含附件与体积提示)
2026-09-15 18:39:56 +08:00

132 lines
4.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> {
// A multipart body carries its own content type with the boundary, so it is sent as is.
const multipart = body instanceof FormData
const response = await fetch(`/api/v1/${path}`, {
method,
headers: { ...(auth ? { Authorization: `Bearer ${auth}` } : {}), ...(body && !multipart ? { 'Content-Type': 'application/json' } : {}) },
body: body ? (multipart ? 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
}
/**
* Fetches binary content with the same session as every other request. A media element cannot
* carry an Authorization header, so the bytes are fetched here and handed to the page as a blob
* URL instead of putting the token into a URL where it could be logged or shared.
*/
async function requestBlob(path: string): Promise<Blob> {
const response = await fetch(`/api/v1/${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
cache: 'no-store',
})
if (!response.ok) {
let message = '附件暂时无法加载。'
try {
const result = await response.json()
if (result?.msg) message = result.msg
} catch {
// A non-JSON error body keeps the default message.
}
throw new ApiError(message, response.status)
}
return response.blob()
}
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, requestBlob }
})