refactor: 对接 duoqi-api 管理员登录规范
- ApiResponse 改为标准 { success, data, error } 格式
- 登录响应使用 accessToken/refreshToken 字段
- AdminRole 新增 super_admin 角色
- auth-store 支持 refreshToken 存储
- 所有 API 调用处理 data 可能为 null 的情况
This commit is contained in:
parent
2c2fc952f9
commit
66fc078b3c
@ -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 {
|
||||
|
||||
@ -70,7 +70,7 @@ export function EventConfigTab() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetchEvents()
|
||||
setEvents(res.data)
|
||||
setEvents(res.data ?? [])
|
||||
} catch {
|
||||
setEvents([])
|
||||
} finally {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -77,7 +77,7 @@ export function PushTemplateTab() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetchPushTemplates()
|
||||
setTemplates(res.data)
|
||||
setTemplates(res.data ?? [])
|
||||
} catch {
|
||||
setTemplates([])
|
||||
} finally {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -105,6 +105,7 @@ export const SETTING_CATEGORY_LABELS: Record<SettingCategory, string> = {
|
||||
}
|
||||
|
||||
export const ADMIN_ROLE_LABELS: Record<AdminRole, string> = {
|
||||
super_admin: "超级管理员",
|
||||
admin: "管理员",
|
||||
moderator: "审核员",
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")}
|
||||
/>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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 })
|
||||
},
|
||||
}))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user