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:
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user