feat(admin): art-design-pro integration with local session auth

P1b Task 9: apps/admin based on art-design-pro v3.0.2 (tag, commit
d224dd4; source recorded in apps/admin/SOURCE.md), trimmed to
layout/login/i18n/request/table-form core. Login wired to real
POST /v1/auth/password (single-token session), Bearer header on all
requests, 401 interceptor logout+redirect, request-driven sliding
renewal (refresh at ~80% TTL, single-flight), best-effort
POST /v1/auth/logout on user-menu logout. One protected stub page
(相册管理). Dev port 5176 strictPort + /v1 proxy to 8080. Admin keeps
its own eslint9 flat config; root eslint ignores apps/admin/**.
vitest unit tests for renewal pacing + Playwright e2e (guard redirect,
localized wrong-password error, login lands on stub, token persists
across reload, logout clears session).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 11:47:32 +08:00
parent 90fe64f637
commit a6cc190751
273 changed files with 32565 additions and 48 deletions
+21
View File
@@ -0,0 +1,21 @@
/**
* 主动登出(用户点击退出菜单时使用)
*
* 区别于 userStore.logOut()(仅清本地态,供 401 拦截器/守卫同步调用):
* 这里先尽力调用 POST /v1/auth/logout 让服务端吊销会话,再清本地态。
* 网络失败不阻断本地登出——本地令牌丢弃后服务端会话也会自然过期。
*/
import { fetchLogout } from '@/api/auth'
import { useUserStore } from '@/store/modules/user'
export async function logoutWithApi(): Promise<void> {
const userStore = useUserStore()
if (userStore.isLogin && userStore.sessionToken) {
try {
await fetchLogout()
} catch {
// 尽力而为:失败静默,继续本地登出
}
}
userStore.logOut()
}
+38
View File
@@ -0,0 +1,38 @@
// 滑动续期节律的单测:80% TTL 触发点、过期会话不再续期、边界舍入。
import { describe, expect, it } from 'vitest'
import { REFRESH_TTL_RATIO, refreshDueAt, shouldRefreshSession } from './session'
describe('refreshDueAt', () => {
it('在会话起点 + 80% TTL 处触发', () => {
const startedAt = Date.parse('2026-09-18T00:00:00Z')
const expiresAt = Date.parse('2026-10-18T00:00:00Z') // 30 天 TTL
// 30d = 2_592_000_000ms,80% = 2_073_600_000ms
expect(refreshDueAt(startedAt, expiresAt)).toBe(startedAt + 2_073_600_000)
expect(REFRESH_TTL_RATIO).toBe(0.8)
})
it('TTL 向下取整,避免亚毫秒提前触发', () => {
const startedAt = 1_000
const expiresAt = 1_999 // TTL=999ms,80%=799.2 → 799
expect(refreshDueAt(startedAt, expiresAt)).toBe(1_799)
})
})
describe('shouldRefreshSession', () => {
const startedAt = 0
const expiresAt = 1_000_000 // TTL=1000s(ms),due at 800_000
it('未到 80% TTL 不触发', () => {
expect(shouldRefreshSession(startedAt, expiresAt, 799_999)).toBe(false)
})
it('到达 80% TTL 触发', () => {
expect(shouldRefreshSession(startedAt, expiresAt, 800_000)).toBe(true)
expect(shouldRefreshSession(startedAt, expiresAt, 950_000)).toBe(true)
})
it('会话已过期不再续期(交给 401 拦截器登出)', () => {
expect(shouldRefreshSession(startedAt, expiresAt, 1_000_000)).toBe(false)
expect(shouldRefreshSession(startedAt, expiresAt, 1_200_000)).toBe(false)
})
})
+71
View File
@@ -0,0 +1,71 @@
/**
* 会话滑动续期(对齐后端单令牌会话模型)
*
* 后端语义:登录发放 sessionToken + expiresAt(默认 30 天);
* POST /v1/auth/refresh 在有效期内每次把 expiresAt 顺延一个完整 TTL,
* 令牌本身不变。因此客户端只需在「已消耗 ~80% TTL」时补一次 refresh。
*
* 实现约定(计划 Task 9):不设常驻定时器,靠请求驱动——每次 API 成功
* 响应后调用 maybeRefreshSession() 检查,到期才发 refresh(单飞,合并并发)。
* 纯函数 refreshDueAt/shouldRefreshSession 单测覆盖(session.spec.ts)。
*/
import { fetchRefreshSession } from '@/api/auth'
import { useUserStore } from '@/store/modules/user'
/** 续期阈值:消耗 80% TTL 后触发(剩余 20% 缓冲) */
export const REFRESH_TTL_RATIO = 0.8
/**
* 计算应触发续期的绝对时间点(ms)。
* @param startedAtMs 本段会话起点(登录/上次续期完成的时刻)
* @param expiresAtMs 服务端给的绝对过期时间
*/
export function refreshDueAt(startedAtMs: number, expiresAtMs: number): number {
const ttl = expiresAtMs - startedAtMs
return startedAtMs + Math.floor(ttl * REFRESH_TTL_RATIO)
}
/**
* 当前是否应触发续期:仅在会话仍有效且已过 80% TTL 时为真。
* 会话已过期交由 401 拦截器处理,这里不再补 refresh。
*/
export function shouldRefreshSession(
startedAtMs: number,
expiresAtMs: number,
nowMs: number
): boolean {
if (expiresAtMs <= nowMs) return false
return nowMs >= refreshDueAt(startedAtMs, expiresAtMs)
}
/** 进行中的 refresh(单飞:并发请求只触发一次) */
let refreshInFlight: Promise<void> | null = null
/**
* 请求驱动的滑动续期入口:HTTP 层每次成功响应后调用。
* 未登录/未到期直接返回;到期则发一次 refresh 并更新过期时间。
*/
export function maybeRefreshSession(): Promise<void> | null {
if (refreshInFlight) return refreshInFlight
const userStore = useUserStore()
const { isLogin, sessionToken, sessionStartedAt, sessionExpiresAt } = userStore
if (!isLogin || !sessionToken) return null
const startedAt = Number(sessionStartedAt)
const expiresAtMs = Date.parse(sessionExpiresAt)
if (!Number.isFinite(startedAt) || !Number.isFinite(expiresAtMs)) return null
if (!shouldRefreshSession(startedAt, expiresAtMs, Date.now())) return null
refreshInFlight = (async () => {
try {
const { expiresAt } = await fetchRefreshSession()
userStore.renewSession(expiresAt)
} catch {
// 续期失败:会话真正过期时 401 拦截器会登出;瞬时失败等下次请求再试
} finally {
refreshInFlight = null
}
})()
return refreshInFlight
}