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) setImporting(true)
try { try {
const res = await importQuestions(parsedQuestions) const res = await importQuestions(parsedQuestions)
setResult(res.data) if (res.data) {
setStep("result") setResult(res.data)
if (res.data.imported > 0) onSuccess() setStep("result")
if (res.data.imported > 0) onSuccess()
}
} catch { } catch {
setParseError("导入失败,请检查网络或联系管理员") setParseError("导入失败,请检查网络或联系管理员")
} finally { } finally {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,9 @@
import { AUTH_STORAGE_KEY } from "./constants" import { AUTH_STORAGE_KEY } from "./constants"
const CURRENT_ADMIN_ID_KEY = "duoqi_admin_current_id" const CURRENT_ADMIN_ID_KEY = "duoqi_admin_current_id"
const REFRESH_TOKEN_KEY = "duoqi_admin_refresh_token"
// Access Token 操作
export function getStoredToken(): string | null { export function getStoredToken(): string | null {
return localStorage.getItem(AUTH_STORAGE_KEY) return localStorage.getItem(AUTH_STORAGE_KEY)
} }
@ -10,11 +12,23 @@ export function setStoredToken(token: string): void {
localStorage.setItem(AUTH_STORAGE_KEY, token) 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 { export function removeStoredToken(): void {
localStorage.removeItem(AUTH_STORAGE_KEY) localStorage.removeItem(AUTH_STORAGE_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(CURRENT_ADMIN_ID_KEY) localStorage.removeItem(CURRENT_ADMIN_ID_KEY)
} }
// 当前管理员 ID
export function setCurrentAdminId(id: string): void { export function setCurrentAdminId(id: string): void {
localStorage.setItem(CURRENT_ADMIN_ID_KEY, id) 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> = { export const ADMIN_ROLE_LABELS: Record<AdminRole, string> = {
super_admin: "超级管理员",
admin: "管理员", admin: "管理员",
moderator: "审核员", moderator: "审核员",
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,28 +1,37 @@
import { create } from "zustand" 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" import type { AdminUser } from "@/types/api"
interface AuthState { interface AuthState {
token: string | null token: string | null
refreshToken: string | null
admin: AdminUser | null admin: AdminUser | null
isAuthenticated: boolean isAuthenticated: boolean
login: (token: string, admin: AdminUser) => void login: (accessToken: string, refreshToken: string, admin: AdminUser) => void
logout: () => void logout: () => void
} }
export const useAuthStore = create<AuthState>((set) => ({ export const useAuthStore = create<AuthState>((set) => ({
token: getStoredToken(), token: getStoredToken(),
refreshToken: null,
admin: null, admin: null,
isAuthenticated: !!getStoredToken(), isAuthenticated: !!getStoredToken(),
login: (token, admin) => { login: (accessToken, refreshToken, admin) => {
setStoredToken(token) setStoredToken(accessToken)
setRefreshToken(refreshToken)
setCurrentAdminId(admin.id) setCurrentAdminId(admin.id)
set({ token, admin, isAuthenticated: true }) set({ token: accessToken, refreshToken, admin, isAuthenticated: true })
}, },
logout: () => { logout: () => {
removeStoredToken() 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 { export interface Admin {
id: string id: string
username: string username: string
@ -6,18 +12,20 @@ export interface Admin {
lastLoginAt?: string lastLoginAt?: string
} }
export type AdminRole = "admin" | "moderator" // 登录表单(用户名密码)
export interface AdminLoginForm { export interface AdminLoginForm {
username: string username: string
password: string password: string
} }
// 管理员会话(包含 access token 和 refresh token
export interface AdminSession { export interface AdminSession {
admin: Admin admin: AdminUser
token: string accessToken: string
refreshToken: string
} }
// 创建管理员表单
export interface CreateAdminForm { export interface CreateAdminForm {
username: string username: string
password: string password: string

View File

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