feat: 接入两端用户名登录和独立学习空间 (#2)

This commit is contained in:
ila
2026-09-10 17:39:57 +08:00
parent 596543b0e2
commit 2c8e9b3ca0
82 changed files with 14148 additions and 30 deletions
+109
View File
@@ -0,0 +1,109 @@
export function normalizeUsername(value) { return value.trim().toLowerCase() }
export function validPassword(value) {
const bytes = new TextEncoder().encode(value).length
return bytes >= 10 && bytes <= 72
}
export function validUsername(value) { return /^[a-z][a-z0-9_.-]{2,31}$/.test(normalizeUsername(value)) }
export function createSession({ fetch, storage, changed = () => {} }) {
const key = 'lexgo-admin-token'
const state = { token: storage.getItem(key) || '', user: null, accounts: [], generation: 0 }
function clear() {
state.generation++
state.token = ''
state.user = null
state.accounts = []
storage.removeItem(key)
changed(state)
}
function assertCurrent(generation) {
if (generation !== state.generation) throw new Error('会话已变化,请重新操作')
}
async function request(path, method = 'GET', body, token = state.token, generation = state.generation) {
const result = await fetch('/api/v1' + path, {
method,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
cache: 'no-store'
})
const payload = await result.json()
assertCurrent(generation)
if (!result.ok) {
if (result.status === 401 || result.status === 403) clear()
throw new Error(result.status === 401 ? '登录已失效,请重新登录' : payload.msg || '请求失败')
}
return payload.data
}
async function revoke(token) {
// Cleanup must never restore a previous identity or expose its response.
if (!token) return
let result
try {
result = await fetch('/api/v1/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + token } })
} catch {
throw new Error('服务器尚未确认退出,原会话可能仍有效')
}
if (!result.ok && result.status !== 401) throw new Error('服务器尚未确认退出,原会话可能仍有效')
}
async function requireAdmin(user, token) {
if (user.role !== 'admin') {
clear()
try { await revoke(token) } catch (error) { throw new Error('此账号没有管理权限;' + error.message) }
throw new Error('此账号没有管理权限')
}
}
function authorized() { if (!state.user || state.user.role !== 'admin') throw new Error('请先登录管理员账号') }
return {
state, clear,
async login(username, password) {
const oldToken = state.token
clear()
const generation = state.generation
if (oldToken) await revoke(oldToken)
assertCurrent(generation)
const data = await request('/login', 'POST', { username: normalizeUsername(username), password }, '', generation)
assertCurrent(generation)
await requireAdmin(data.user, data.token)
assertCurrent(generation)
state.token = data.token
state.user = data.user
storage.setItem(key, data.token)
changed(state)
},
async restore() {
if (!state.token) return false
const generation = state.generation
try {
const user = await request('/me')
assertCurrent(generation)
await requireAdmin(user, state.token)
assertCurrent(generation)
state.user = user
changed(state)
return true
} catch (error) { if (generation === state.generation) clear(); throw error }
},
async logout() { const token = state.token; clear(); await revoke(token) },
async loadAccounts() {
authorized()
const generation = state.generation
const data = await request('/accounts')
assertCurrent(generation)
state.accounts = data.items
changed(state)
},
async createAccount(username, password) {
authorized()
if (!validUsername(username)) throw new Error('账号须为 3–32 位,以字母开头,仅含字母、数字、点、下划线或短横线')
if (!validPassword(password)) throw new Error('密码须为 10–72 字节')
return request('/accounts', 'POST', { username: normalizeUsername(username), password })
},
async updateAccount(id, patch) {
authorized()
if (patch.password !== undefined && !validPassword(patch.password)) throw new Error('密码须为 10–72 字节')
if (id === state.user.id && patch.disabled) throw new Error('不能停用当前管理员')
const account = state.accounts.find(item => item.id === id)
if (id === state.user.id || (account && account.role !== 'learner')) throw new Error('管理员账号不支持此操作')
return request('/accounts/' + id, 'PATCH', patch)
}
}
}