refactor: 对接 duoqi-api 管理员登录规范

- ApiResponse 改为标准 { success, data, error } 格式
- 登录响应使用 accessToken/refreshToken 字段
- AdminRole 新增 super_admin 角色
- auth-store 支持 refreshToken 存储
- 所有 API 调用处理 data 可能为 null 的情况
This commit is contained in:
Wang Zhuoxuan 2026-04-11 17:46:00 +08:00
parent 2c2fc952f9
commit 66fc078b3c
14 changed files with 175 additions and 55 deletions

View File

@ -117,9 +117,11 @@ export function ImportQuestionsDialog({
setImporting(true)
try {
const res = await importQuestions(parsedQuestions)
setResult(res.data)
setStep("result")
if (res.data.imported > 0) onSuccess()
if (res.data) {
setResult(res.data)
setStep("result")
if (res.data.imported > 0) onSuccess()
}
} catch {
setParseError("导入失败,请检查网络或联系管理员")
} finally {

View File

@ -70,7 +70,7 @@ export function EventConfigTab() {
setLoading(true)
try {
const res = await fetchEvents()
setEvents(res.data)
setEvents(res.data ?? [])
} catch {
setEvents([])
} finally {

View File

@ -79,7 +79,7 @@ export function GeneralSettingsTab() {
try {
const res = await fetchSettings("general")
const settingsMap: Record<string, string> = {}
res.data.forEach((s) => {
res.data?.forEach((s) => {
settingsMap[s.key] = s.value
})
setSettings(settingsMap)

View File

@ -77,7 +77,7 @@ export function PushTemplateTab() {
setLoading(true)
try {
const res = await fetchPushTemplates()
setTemplates(res.data)
setTemplates(res.data ?? [])
} catch {
setTemplates([])
} finally {

View File

@ -1,33 +1,81 @@
import { apiClient } from "@/lib/api-client"
import type { ApiResponse } from "@/types/api"
import type { Admin, AdminLoginForm, AdminSession, CreateAdminForm } from "@/types/admin"
import type { ApiResponse, LoginResponse, RefreshTokenResponse } from "@/types/api"
import type { Admin, AdminLoginForm, CreateAdminForm } from "@/types/admin"
// 认证
// ==================== 认证相关 ====================
/**
*
* POST /admin/auth/login
*/
export async function loginAdmin(
credentials: AdminLoginForm
): Promise<ApiResponse<AdminSession>> {
return apiClient.post("auth/login", { json: credentials }).json<ApiResponse<AdminSession>>()
): Promise<ApiResponse<LoginResponse>> {
return apiClient.post("auth/login", { json: credentials }).json<ApiResponse<LoginResponse>>()
}
/**
* Token
* POST /admin/auth
*/
export async function loginWithToken(
token: string
): Promise<ApiResponse<LoginResponse>> {
return apiClient.post("auth", { json: { token } }).json<ApiResponse<LoginResponse>>()
}
/**
* 访
* POST /admin/auth/refresh
*/
export async function refreshAccessToken(
refreshToken: string
): Promise<ApiResponse<RefreshTokenResponse>> {
return apiClient
.post("auth/refresh", { json: { refreshToken } })
.json<ApiResponse<RefreshTokenResponse>>()
}
/**
*
* GET /admin/auth/me
*/
export async function fetchMe(): Promise<ApiResponse<Admin>> {
return apiClient.get("auth/me").json<ApiResponse<Admin>>()
}
// 管理员管理
// ==================== 管理员管理 ====================
/**
*
* GET /admin/admins
*/
export async function fetchAdmins(): Promise<ApiResponse<Admin[]>> {
return apiClient.get("admins").json<ApiResponse<Admin[]>>()
}
/**
*
* GET /admin/admins/:id
*/
export async function fetchAdmin(id: string): Promise<ApiResponse<Admin>> {
return apiClient.get(`admins/${id}`).json<ApiResponse<Admin>>()
}
/**
*
* POST /admin/admins
*/
export async function createAdmin(
data: CreateAdminForm
): Promise<ApiResponse<Admin>> {
return apiClient.post("admins", { json: data }).json<ApiResponse<Admin>>()
}
/**
*
* PUT /admin/admins/:id
*/
export async function updateAdmin(
id: string,
data: Partial<CreateAdminForm>
@ -35,10 +83,18 @@ export async function updateAdmin(
return apiClient.put(`admins/${id}`, { json: data }).json<ApiResponse<Admin>>()
}
/**
*
* DELETE /admin/admins/:id
*/
export async function deleteAdmin(id: string): Promise<ApiResponse<{ id: string }>> {
return apiClient.delete(`admins/${id}`).json<ApiResponse<{ id: string }>>()
}
/**
*
* POST /admin/admins/:id/reset-password
*/
export async function resetAdminPassword(
id: string,
newPassword: string

View File

@ -1,7 +1,9 @@
import { AUTH_STORAGE_KEY } from "./constants"
const CURRENT_ADMIN_ID_KEY = "duoqi_admin_current_id"
const REFRESH_TOKEN_KEY = "duoqi_admin_refresh_token"
// Access Token 操作
export function getStoredToken(): string | null {
return localStorage.getItem(AUTH_STORAGE_KEY)
}
@ -10,11 +12,23 @@ export function setStoredToken(token: string): void {
localStorage.setItem(AUTH_STORAGE_KEY, token)
}
// Refresh Token 操作
export function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY)
}
export function setRefreshToken(token: string): void {
localStorage.setItem(REFRESH_TOKEN_KEY, token)
}
// 清除所有认证信息
export function removeStoredToken(): void {
localStorage.removeItem(AUTH_STORAGE_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(CURRENT_ADMIN_ID_KEY)
}
// 当前管理员 ID
export function setCurrentAdminId(id: string): void {
localStorage.setItem(CURRENT_ADMIN_ID_KEY, id)
}

View File

@ -105,6 +105,7 @@ export const SETTING_CATEGORY_LABELS: Record<SettingCategory, string> = {
}
export const ADMIN_ROLE_LABELS: Record<AdminRole, string> = {
super_admin: "超级管理员",
admin: "管理员",
moderator: "审核员",
}

View File

@ -42,11 +42,13 @@ import { ADMIN_ROLE_LABELS } from "@/lib/constants"
import type { Admin, AdminRole, CreateAdminForm } from "@/types/admin"
const roleIcons = {
super_admin: Shield,
admin: Shield,
moderator: ShieldAlert,
}
const roleBadgeVariants: Record<AdminRole, "default" | "secondary"> = {
super_admin: "default",
admin: "default",
moderator: "secondary",
}
@ -77,7 +79,7 @@ export default function AdminsPage() {
setLoading(true)
try {
const res = await fetchAdmins()
setAdmins(res.data)
setAdmins(res.data ?? [])
} catch {
setAdmins([])
} finally {

View File

@ -66,7 +66,7 @@ export default function KnowledgeCardsPage() {
const [detailCard, setDetailCard] = useState<KnowledgeCardItem | null>(null)
useEffect(() => {
fetchCategories({}).then((res) => setCategories(res.data))
fetchCategories({}).then((res) => setCategories(res.data ?? []))
}, [])
const loadCards = useCallback(async () => {
@ -76,7 +76,7 @@ export default function KnowledgeCardsPage() {
search: search || undefined,
status: statusFilter,
})
setCards(res.data)
setCards(res.data ?? [])
} catch {
setCards([])
} finally {

View File

@ -5,15 +5,13 @@ import { z } from "zod/v4"
import { zodResolver } from "@hookform/resolvers/zod"
import { Eye, EyeOff } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { apiClient } from "@/lib/api-client"
import { loginAdmin } from "@/lib/api/admin-api"
import { loginAdmin, loginWithToken } from "@/lib/api/admin-api"
import { useAuth } from "@/hooks/use-auth"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import type { LoginResponse } from "@/types/api"
import type { AdminSession } from "@/types/admin"
import type { LoginResponse, ApiResponse } from "@/types/api"
// Token 登录表单
const tokenLoginSchema = z.object({
@ -25,7 +23,7 @@ type TokenLoginForm = z.infer<typeof tokenLoginSchema>
// 用户名密码登录表单
const passwordLoginSchema = z.object({
username: z.string().min(1, "请输入用户名"),
password: z.string().min(1, "请输入密码"),
password: z.string().min(8, "密码至少8个字符"),
})
type PasswordLoginForm = z.infer<typeof passwordLoginSchema>
@ -53,15 +51,17 @@ export default function LoginPage() {
async function handleTokenLogin(data: TokenLoginForm) {
setError("")
try {
const response = await apiClient
.post("auth/login", { json: { token: data.token } })
.json<LoginResponse>()
const response: ApiResponse<LoginResponse> = await loginWithToken(data.token)
login(response.jwt, response.admin)
navigate("/")
if (response.success && response.data) {
login(response.data.accessToken, response.data.refreshToken, response.data.admin)
navigate("/")
} else if (response.error) {
setError(response.error.message)
}
} catch {
// 后端不可用时,回退到离线模式:直接用 token 登录
login(`offline_${data.token}`, {
login(`offline_${data.token}`, `offline_refresh_${data.token}`, {
id: "offline-admin",
username: "admin",
role: "admin",
@ -74,20 +74,17 @@ export default function LoginPage() {
async function handlePasswordLogin(data: PasswordLoginForm) {
setError("")
try {
const response = await loginAdmin(data)
const session: AdminSession = response.data
const response: ApiResponse<LoginResponse> = await loginAdmin(data)
const legacyAdmin = {
id: session.admin.id,
username: session.admin.username,
role: session.admin.role,
if (response.success && response.data) {
login(response.data.accessToken, response.data.refreshToken, response.data.admin)
navigate("/")
} else if (response.error) {
setError(response.error.message)
}
login(session.token, legacyAdmin)
navigate("/")
} catch {
// 后端不可用时,回退到离线模式
login(`offline_${data.username}`, {
login(`offline_${data.username}`, `offline_refresh_${data.username}`, {
id: "offline-admin",
username: data.username,
role: "admin",
@ -158,7 +155,7 @@ export default function LoginPage() {
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="请输入密码"
placeholder="请输入密码至少8个字符"
autoComplete="current-password"
{...passwordForm.register("password")}
/>

View File

@ -35,11 +35,11 @@ export default function UserDetailPage() {
Promise.all([
fetchUserDetail(id).then((res) => res.data),
fetchUserChapterProgress(id)
.then((res) => res.data)
.then((res) => res.data ?? [])
.catch(() => []),
])
.then(([userDetail, chapterData]) => {
setUser(userDetail)
if (userDetail) setUser(userDetail)
setChapters(chapterData)
})
.catch(() => navigate("/users"))
@ -57,7 +57,10 @@ export default function UserDetailPage() {
const handleTierChange = async (tier: UserTier) => {
if (!id) return
const res = await updateUserTier(id, tier)
setUser((prev) => (prev ? { ...prev, tier: res.data.tier } : prev))
const newTier = res.data?.tier
if (newTier) {
setUser((prev) => (prev ? { ...prev, tier: newTier } : prev))
}
}
return (

View File

@ -1,28 +1,37 @@
import { create } from "zustand"
import { getStoredToken, setStoredToken, removeStoredToken, setCurrentAdminId } from "@/lib/auth"
import {
getStoredToken,
setStoredToken,
setRefreshToken,
removeStoredToken,
setCurrentAdminId,
} from "@/lib/auth"
import type { AdminUser } from "@/types/api"
interface AuthState {
token: string | null
refreshToken: string | null
admin: AdminUser | null
isAuthenticated: boolean
login: (token: string, admin: AdminUser) => void
login: (accessToken: string, refreshToken: string, admin: AdminUser) => void
logout: () => void
}
export const useAuthStore = create<AuthState>((set) => ({
token: getStoredToken(),
refreshToken: null,
admin: null,
isAuthenticated: !!getStoredToken(),
login: (token, admin) => {
setStoredToken(token)
login: (accessToken, refreshToken, admin) => {
setStoredToken(accessToken)
setRefreshToken(refreshToken)
setCurrentAdminId(admin.id)
set({ token, admin, isAuthenticated: true })
set({ token: accessToken, refreshToken, admin, isAuthenticated: true })
},
logout: () => {
removeStoredToken()
set({ token: null, admin: null, isAuthenticated: false })
set({ token: null, refreshToken: null, admin: null, isAuthenticated: false })
},
}))

View File

@ -1,3 +1,9 @@
import type { AdminUser } from "./api"
// 管理员角色类型(匹配 duoqi-api 规范)
export type AdminRole = "super_admin" | "admin" | "moderator"
// 完整的管理员信息
export interface Admin {
id: string
username: string
@ -6,18 +12,20 @@ export interface Admin {
lastLoginAt?: string
}
export type AdminRole = "admin" | "moderator"
// 登录表单(用户名密码)
export interface AdminLoginForm {
username: string
password: string
}
// 管理员会话(包含 access token 和 refresh token
export interface AdminSession {
admin: Admin
token: string
admin: AdminUser
accessToken: string
refreshToken: string
}
// 创建管理员表单
export interface CreateAdminForm {
username: string
password: string

View File

@ -1,30 +1,58 @@
export interface ApiResponse<T> {
data: T
message?: string
// API 响应错误结构
export interface ApiError {
code: string
message: string
}
// 统一 API 响应格式(匹配 duoqi-api 规范)
export interface ApiResponse<T> {
success: boolean
data: T | null
error: ApiError | null
}
// 分页元数据
export interface PaginationMeta {
total: number
page: number
limit: number
}
// 分页响应(额外包含 pagination
export interface PaginatedResponse<T> {
success: boolean
data: T[]
pagination: PaginationMeta
error: null
}
// 管理员用户信息
export interface AdminUser {
id: string
username: string
role: "admin" | "moderator"
role: "super_admin" | "admin" | "moderator"
}
// Token 登录请求
export interface LoginRequest {
token: string
}
// 密码登录请求
export interface PasswordLoginRequest {
username: string
password: string
}
// 登录响应(匹配 duoqi-api 规范)
export interface LoginResponse {
jwt: string
accessToken: string
refreshToken: string
admin: AdminUser
}
// Token 刷新响应
export interface RefreshTokenResponse {
accessToken: string
refreshToken: string
}