feat: API层与认证流(client拦截/守卫/登录改密实装/域API骨架/vitest)

- 自建轻量 axios client(src/api/client.ts):Bearer 注入、401 清会话跳登录
  (login 请求除外)、409 精确匹配"必须先修改初始密码"跳强制改密(区别于并发
  冲突 409)、429/网络错误统一 ElMessage、ApiError 标准化、showErrorMessage
  可opt-out——模板 utils/http 的 {code,msg,data} 信封与后端裸 HTTP 语义不符故弃用
- Pinia auth store(src/store/modules/auth.ts):token/role/tenant_slug/
  must_change 落地并持久化(all-kefu-auth key),login/logout/changePassword/
  会话过期/强制改密跳转(动态 import 断循环)
- 路由守卫:token 事实源判定、must_change 全站门(除改密页)、UserInfo 由登录
  快照派生(后端无用户信息端点);user store 残留自愈
- 登录页实装(去除角色调试开关)、强制改密页实装;用户菜单/锁屏对接真实 logout
- 域 API 骨架:tenants/config/conversations/stats/settings(类型+端点常量)
- mask 哨兵助手:__SET__ ↔ 已设置 表单语义
- vitest 25 例(client 11/store 8/mask 6);登录页拖拽验证 reset 空引用修复

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 16:28:11 +08:00
parent 6afa31ec85
commit 3973abea8b
23 changed files with 1809 additions and 132 deletions
+188
View File
@@ -0,0 +1,188 @@
/**
* 认证状态管理(Phase 3 Task 3)
*
* 会话事实源:token / role / tenantSlug / username / mustChangePassword。
* 持久化 localStorage(键 all-kefu-auth,all-kefu 前缀与模板 sys-v* 键区隔),
* 刷新页面恢复会话;路由守卫与 HTTP client(401/409 全局处理)消费。
*
* 模板侧同步:登录成功后把 token / 登录标记 / 用户信息(roles 供菜单过滤)
* 同步进模板的 user store(isLogin/accessToken/info 是模板路由初始化与
* 顶栏展示的既有依赖);登出委托 userStore.logOut() 完成模板全套清理
* (动态路由注销、工作台标签、跳登录页)。
*
* 与 @/api/client 的模块环(client → 本模块 → @/api/auth → client):
* 双方均只在函数体内访问对方导出(ESM 活绑定),模块求值期无交叉读取。
*/
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { fetchChangePassword, fetchLogin, fetchLogout } from '@/api/auth'
import { useUserStore } from '@/store/modules/user'
import { RoutesAlias } from '@/router/routesAlias'
/** 平台角色(与后端 token 快照一致) */
export type AdminRole = 'super' | 'tenant'
/** 登录参数(POST /api/platform/auth/login) */
export interface LoginParams {
username: string
password: string
}
/** 改密参数(POST /api/platform/auth/change-password) */
export interface ChangePasswordParams {
old_password: string
new_password: string
}
/** 登录响应(后端 auth_routes.login) */
export interface LoginResponse {
token: string
role: AdminRole
tenant_slug: string | null
must_change_password: boolean
}
/** 持久化键(all-kefu 前缀;user store 等模板键为 sys-v* 体系) */
const AUTH_STORAGE_KEY = 'all-kefu-auth'
/**
* 用户名 → 稳定伪 ID(djb2 哈希)
*
* 后端不下发 user_id;模板 user store 的工作台跨用户清理
* (checkAndClearWorktabs)按 userId 比对,用确定性哈希保持该语义。
*/
function stableUserId(username: string): number {
let hash = 5381
for (let i = 0; i < username.length; i++) {
hash = ((hash << 5) + hash + username.charCodeAt(i)) | 0
}
return Math.abs(hash) || 1
}
export const useAuthStore = defineStore(
'authStore',
() => {
// ---- 状态 ------------------------------------------------------------------
const token = ref('')
const role = ref<AdminRole | ''>('')
const tenantSlug = ref<string | null>(null)
const username = ref('')
const mustChangePassword = ref(false)
// ---- 计算属性 ----------------------------------------------------------------
const isAuthenticated = computed(() => token.value !== '')
/** 模板 Api.Auth.UserInfo 映射(守卫初始化动态路由/菜单角色过滤用) */
const userInfo = computed<Api.Auth.UserInfo>(() => ({
buttons: [],
roles: role.value ? [role.value] : [],
userId: stableUserId(username.value),
userName: username.value,
email: ''
}))
// ---- 内部工具 ----------------------------------------------------------------
/** 登录成功后的状态落地(不调 API) */
function applyLogin(loginUsername: string, res: LoginResponse): void {
token.value = res.token
role.value = res.role
tenantSlug.value = res.tenant_slug
mustChangePassword.value = res.must_change_password
username.value = loginUsername
syncUserStore()
}
/** 同步模板 user store(模板路由初始化/顶栏依赖其状态) */
function syncUserStore(): void {
const userStore = useUserStore()
userStore.setToken(token.value, '')
userStore.setLoginStatus(true)
userStore.setUserInfo(userInfo.value)
userStore.checkAndClearWorktabs()
}
/** 清空本 store 全部会话状态 */
function clearSession(): void {
token.value = ''
role.value = ''
tenantSlug.value = null
username.value = ''
mustChangePassword.value = false
}
/**
* 页面跳转(动态 import 断开与 router 模块的静态环;
* 已在目标页则不重复跳——防 401/409 拦截死循环)
*/
async function goTo(path: string): Promise<void> {
const { router } = await import('@/router')
if (router.currentRoute.value.path === path) return
await router.replace(path)
}
// ---- Actions -----------------------------------------------------------------
/** 登录:调 API 成功后落地会话状态(失败向上抛,由调用方/全局拦截提示) */
async function login(params: LoginParams): Promise<LoginResponse> {
const res = await fetchLogin(params)
applyLogin(params.username, res)
return res
}
/**
* 登出:调后端撤销 token(best effort,失败也照常本地清理),
* 清空会话后委托模板 userStore.logOut()(动态路由注销 + 跳登录页)
*/
async function logout(): Promise<void> {
try {
await fetchLogout()
} catch {
// token 已失效/网络不可达:本地清理照常进行
}
clearSession()
useUserStore().logOut()
}
/**
* 改密:成功后清 must_change 标志(后端已就地清当前 token 的标志,
* 会话保持,改完无需重登)
*/
async function changePassword(params: ChangePasswordParams): Promise<void> {
await fetchChangePassword(params)
mustChangePassword.value = false
}
/** 401 全局处理:清会话 + 模板侧全套清理(userStore.logOut 含跳登录) */
function handleSessionExpired(): void {
clearSession()
useUserStore().logOut()
}
/** 409 强制改密全局处理:置标志 + 跳改密页(已在改密页不重复跳) */
async function handleMustChangePassword(): Promise<void> {
mustChangePassword.value = true
await goTo(RoutesAlias.ForceChangePassword)
}
return {
token,
role,
tenantSlug,
username,
mustChangePassword,
isAuthenticated,
userInfo,
login,
logout,
changePassword,
clearSession,
handleSessionExpired,
handleMustChangePassword
}
},
{
persist: {
key: AUTH_STORAGE_KEY,
storage: localStorage
}
}
)
+3 -1
View File
@@ -41,6 +41,7 @@ import { AppRouteRecord } from '@/types/router'
import { setPageTitle } from '@/utils/router'
import { resetRouterState } from '@/router/guards/beforeEach'
import { useMenuStore } from './menu'
import { RoutesAlias } from '@/router/routesAlias'
import { StorageConfig } from '@/utils/storage/storage-config'
/**
@@ -167,8 +168,9 @@ export const useUserStore = defineStore(
// 重置路由状态
resetRouterState(500)
// 跳转到登录页,携带当前路由作为 redirect 参数
// (已在登录页则不带——否则登录成功后会跳回登录页自身)
const currentRoute = router.currentRoute.value
const redirect = currentRoute.path !== '/login' ? currentRoute.fullPath : undefined
const redirect = currentRoute.path !== RoutesAlias.Login ? currentRoute.fullPath : undefined
router.push({
name: 'Login',
query: redirect ? { redirect } : undefined