From 66fc078b3c86576a5ab7d07ee16ccf87f16779c8 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Sat, 11 Apr 2026 17:46:00 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=AF=B9=E6=8E=A5=20duoqi-api=20?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E7=99=BB=E5=BD=95=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ApiResponse 改为标准 { success, data, error } 格式 - 登录响应使用 accessToken/refreshToken 字段 - AdminRole 新增 super_admin 角色 - auth-store 支持 refreshToken 存储 - 所有 API 调用处理 data 可能为 null 的情况 --- .../question/ImportQuestionsDialog.tsx | 8 ++- src/components/settings/EventConfigTab.tsx | 2 +- .../settings/GeneralSettingsTab.tsx | 2 +- src/components/settings/PushTemplateTab.tsx | 2 +- src/lib/api/admin-api.ts | 68 +++++++++++++++++-- src/lib/auth.ts | 14 ++++ src/lib/constants.ts | 1 + src/routes/admins/index.tsx | 4 +- src/routes/knowledge-cards/index.tsx | 4 +- src/routes/login.tsx | 41 ++++++----- src/routes/users/$id.tsx | 9 ++- src/stores/auth-store.ts | 21 ++++-- src/types/admin.ts | 16 +++-- src/types/api.ts | 38 +++++++++-- 14 files changed, 175 insertions(+), 55 deletions(-) diff --git a/src/components/question/ImportQuestionsDialog.tsx b/src/components/question/ImportQuestionsDialog.tsx index 1c3617f..99061b4 100644 --- a/src/components/question/ImportQuestionsDialog.tsx +++ b/src/components/question/ImportQuestionsDialog.tsx @@ -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 { diff --git a/src/components/settings/EventConfigTab.tsx b/src/components/settings/EventConfigTab.tsx index 909175c..10b19eb 100644 --- a/src/components/settings/EventConfigTab.tsx +++ b/src/components/settings/EventConfigTab.tsx @@ -70,7 +70,7 @@ export function EventConfigTab() { setLoading(true) try { const res = await fetchEvents() - setEvents(res.data) + setEvents(res.data ?? []) } catch { setEvents([]) } finally { diff --git a/src/components/settings/GeneralSettingsTab.tsx b/src/components/settings/GeneralSettingsTab.tsx index 9385e65..304a1b4 100644 --- a/src/components/settings/GeneralSettingsTab.tsx +++ b/src/components/settings/GeneralSettingsTab.tsx @@ -79,7 +79,7 @@ export function GeneralSettingsTab() { try { const res = await fetchSettings("general") const settingsMap: Record = {} - res.data.forEach((s) => { + res.data?.forEach((s) => { settingsMap[s.key] = s.value }) setSettings(settingsMap) diff --git a/src/components/settings/PushTemplateTab.tsx b/src/components/settings/PushTemplateTab.tsx index 6b1488c..9884e61 100644 --- a/src/components/settings/PushTemplateTab.tsx +++ b/src/components/settings/PushTemplateTab.tsx @@ -77,7 +77,7 @@ export function PushTemplateTab() { setLoading(true) try { const res = await fetchPushTemplates() - setTemplates(res.data) + setTemplates(res.data ?? []) } catch { setTemplates([]) } finally { diff --git a/src/lib/api/admin-api.ts b/src/lib/api/admin-api.ts index 911ad38..7c109e0 100644 --- a/src/lib/api/admin-api.ts +++ b/src/lib/api/admin-api.ts @@ -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> { - return apiClient.post("auth/login", { json: credentials }).json>() +): Promise> { + return apiClient.post("auth/login", { json: credentials }).json>() } +/** + * Token 登录(向后兼容) + * POST /admin/auth + */ +export async function loginWithToken( + token: string +): Promise> { + return apiClient.post("auth", { json: { token } }).json>() +} + +/** + * 刷新访问令牌 + * POST /admin/auth/refresh + */ +export async function refreshAccessToken( + refreshToken: string +): Promise> { + return apiClient + .post("auth/refresh", { json: { refreshToken } }) + .json>() +} + +/** + * 获取当前管理员信息 + * GET /admin/auth/me + */ export async function fetchMe(): Promise> { return apiClient.get("auth/me").json>() } -// 管理员管理 +// ==================== 管理员管理 ==================== + +/** + * 获取管理员列表 + * GET /admin/admins + */ export async function fetchAdmins(): Promise> { return apiClient.get("admins").json>() } +/** + * 获取单个管理员详情 + * GET /admin/admins/:id + */ export async function fetchAdmin(id: string): Promise> { return apiClient.get(`admins/${id}`).json>() } +/** + * 创建管理员 + * POST /admin/admins + */ export async function createAdmin( data: CreateAdminForm ): Promise> { return apiClient.post("admins", { json: data }).json>() } +/** + * 更新管理员信息 + * PUT /admin/admins/:id + */ export async function updateAdmin( id: string, data: Partial @@ -35,10 +83,18 @@ export async function updateAdmin( return apiClient.put(`admins/${id}`, { json: data }).json>() } +/** + * 删除管理员 + * DELETE /admin/admins/:id + */ export async function deleteAdmin(id: string): Promise> { return apiClient.delete(`admins/${id}`).json>() } +/** + * 重置管理员密码 + * POST /admin/admins/:id/reset-password + */ export async function resetAdminPassword( id: string, newPassword: string diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 6f19ee0..11e745e 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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) } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1c00fcb..dc6fdaa 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -105,6 +105,7 @@ export const SETTING_CATEGORY_LABELS: Record = { } export const ADMIN_ROLE_LABELS: Record = { + super_admin: "超级管理员", admin: "管理员", moderator: "审核员", } diff --git a/src/routes/admins/index.tsx b/src/routes/admins/index.tsx index b28b6eb..77fafb3 100644 --- a/src/routes/admins/index.tsx +++ b/src/routes/admins/index.tsx @@ -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 = { + 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 { diff --git a/src/routes/knowledge-cards/index.tsx b/src/routes/knowledge-cards/index.tsx index dd59d8c..38bf431 100644 --- a/src/routes/knowledge-cards/index.tsx +++ b/src/routes/knowledge-cards/index.tsx @@ -66,7 +66,7 @@ export default function KnowledgeCardsPage() { const [detailCard, setDetailCard] = useState(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 { diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 8974c97..79ab60b 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -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 // 用户名密码登录表单 const passwordLoginSchema = z.object({ username: z.string().min(1, "请输入用户名"), - password: z.string().min(1, "请输入密码"), + password: z.string().min(8, "密码至少8个字符"), }) type PasswordLoginForm = z.infer @@ -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() + const response: ApiResponse = 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 = 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() { diff --git a/src/routes/users/$id.tsx b/src/routes/users/$id.tsx index a5bd7a9..df425d2 100644 --- a/src/routes/users/$id.tsx +++ b/src/routes/users/$id.tsx @@ -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 ( diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index a17f602..ee217fb 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -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((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 }) }, })) diff --git a/src/types/admin.ts b/src/types/admin.ts index efbcc6a..4dffe50 100644 --- a/src/types/admin.ts +++ b/src/types/admin.ts @@ -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 diff --git a/src/types/api.ts b/src/types/api.ts index 29ee1bb..23519d5 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,30 +1,58 @@ -export interface ApiResponse { - data: T - message?: string +// API 响应错误结构 +export interface ApiError { + code: string + message: string } +// 统一 API 响应格式(匹配 duoqi-api 规范) +export interface ApiResponse { + success: boolean + data: T | null + error: ApiError | null +} + +// 分页元数据 export interface PaginationMeta { total: number page: number limit: number } +// 分页响应(额外包含 pagination) export interface PaginatedResponse { + 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 +}