From 4feea2667c1e809a9b49befa0660611c56421664 Mon Sep 17 00:00:00 2001 From: hailin Date: Wed, 4 Mar 2026 19:18:20 -0800 Subject: [PATCH] =?UTF-8?q?refactor(admin-web):=20=E5=AE=9E=E7=8E=B0=20Cle?= =?UTF-8?q?an=20Architecture=20+=20Zustand=20+=20Redux=20Toolkit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按要求重构架构,从扁平的 React Context + useState 升级为大厂标准模式: Clean Architecture 分层: domain/entities/ — 业务实体 (AdminUser/User/Issuer/AppVersion) domain/repositories/ — Repository 接口(契约层) infrastructure/http/ — HttpClient(替代旧 api-client.ts) infrastructure/repositories/ — Repository 实现(AuthRepository/UserRepository) 状态管理(大厂混合模式): Zustand useAuthStore — 轻量客户端状态:登录会话 + localStorage 持久化 Zustand useUIStore — UI 偏好:sidebar 折叠状态持久化 Redux uiSlice — 全局通知队列、globalLoading Redux usersSlice — 用户列表筛选/分页 client state React Query — 服务端数据 fetching/缓存(保留) 更新: providers.tsx — 加入 Redux Provider,移除旧 AuthProvider auth-context.tsx — 向下兼容层,re-export Zustand store api-client.ts — 向下兼容层,re-export httpClient AdminLayout.tsx — 使用 Zustand auth/ui store Co-Authored-By: Claude Sonnet 4.6 --- frontend/admin-web/src/app/providers.tsx | 11 ++- .../admin-web/src/domain/entities/index.ts | 77 +++++++++++++++ .../repositories/auth.repository.interface.ts | 6 ++ .../repositories/user.repository.interface.ts | 15 +++ .../src/infrastructure/http/http.client.ts | 80 ++++++++++++++++ .../repositories/auth.repository.ts | 18 ++++ .../repositories/user.repository.ts | 23 +++++ .../admin-web/src/layouts/AdminLayout.tsx | 10 +- frontend/admin-web/src/lib/api-client.ts | 72 ++------------ frontend/admin-web/src/lib/auth-context.tsx | 94 +++---------------- frontend/admin-web/src/store/index.ts | 26 +++++ .../admin-web/src/store/slices/ui.slice.ts | 50 ++++++++++ .../admin-web/src/store/slices/users.slice.ts | 65 +++++++++++++ .../admin-web/src/store/zustand/auth.store.ts | 82 ++++++++++++++++ .../admin-web/src/store/zustand/ui.store.ts | 46 +++++++++ 15 files changed, 521 insertions(+), 154 deletions(-) create mode 100644 frontend/admin-web/src/domain/entities/index.ts create mode 100644 frontend/admin-web/src/domain/repositories/auth.repository.interface.ts create mode 100644 frontend/admin-web/src/domain/repositories/user.repository.interface.ts create mode 100644 frontend/admin-web/src/infrastructure/http/http.client.ts create mode 100644 frontend/admin-web/src/infrastructure/repositories/auth.repository.ts create mode 100644 frontend/admin-web/src/infrastructure/repositories/user.repository.ts create mode 100644 frontend/admin-web/src/store/index.ts create mode 100644 frontend/admin-web/src/store/slices/ui.slice.ts create mode 100644 frontend/admin-web/src/store/slices/users.slice.ts create mode 100644 frontend/admin-web/src/store/zustand/auth.store.ts create mode 100644 frontend/admin-web/src/store/zustand/ui.store.ts diff --git a/frontend/admin-web/src/app/providers.tsx b/frontend/admin-web/src/app/providers.tsx index 9e1d7b2..4319cef 100644 --- a/frontend/admin-web/src/app/providers.tsx +++ b/frontend/admin-web/src/app/providers.tsx @@ -1,8 +1,9 @@ 'use client'; import React, { useState } from 'react'; +import { Provider as ReduxProvider } from 'react-redux'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { AuthProvider } from '@/lib/auth-context'; +import { store } from '@/store'; import { DeployGuard } from '@/lib/deploy-guard'; export function Providers({ children }: { children: React.ReactNode }) { @@ -20,11 +21,11 @@ export function Providers({ children }: { children: React.ReactNode }) { ); return ( - - + + {children} - - + + ); } diff --git a/frontend/admin-web/src/domain/entities/index.ts b/frontend/admin-web/src/domain/entities/index.ts new file mode 100644 index 0000000..a0592fe --- /dev/null +++ b/frontend/admin-web/src/domain/entities/index.ts @@ -0,0 +1,77 @@ +// ============================================================ +// Domain Entities — 业务实体定义 +// Clean Architecture: Domain 层不依赖任何外部框架 +// ============================================================ + +// ── Auth ──────────────────────────────────────────────────── +export interface AdminUser { + id: string; + email: string; + name: string; + role: string; +} + +export interface AuthTokens { + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +// ── User ──────────────────────────────────────────────────── +export type KycStatus = 'UNSUBMITTED' | 'PENDING' | 'APPROVED' | 'REJECTED'; + +export interface User { + id: string; + phone?: string; + email?: string; + name?: string; + kycStatus: KycStatus; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +// ── Issuer ────────────────────────────────────────────────── +export type IssuerStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'SUSPENDED'; + +export interface Issuer { + id: string; + companyName: string; + contactEmail: string; + status: IssuerStatus; + createdAt: string; +} + +// ── AppVersion ────────────────────────────────────────────── +export type AppPlatform = 'ANDROID' | 'IOS'; +export type AppType = 'GENEX_MOBILE' | 'ADMIN_APP'; + +export interface AppVersion { + id: string; + appType: AppType; + platform: AppPlatform; + versionName: string; + versionCode: number; + minOsVersion?: string; + fileSize?: number; + downloadUrl?: string; + changelog?: string; + isForceUpdate: boolean; + isEnabled: boolean; + releasedAt?: string; + createdAt: string; +} + +// ── Common ────────────────────────────────────────────────── +export interface PaginatedResult { + items: T[]; + total: number; + page: number; + pageSize: number; +} + +export interface ApiResponse { + code: number; + data: T; + message: string; +} diff --git a/frontend/admin-web/src/domain/repositories/auth.repository.interface.ts b/frontend/admin-web/src/domain/repositories/auth.repository.interface.ts new file mode 100644 index 0000000..babc84c --- /dev/null +++ b/frontend/admin-web/src/domain/repositories/auth.repository.interface.ts @@ -0,0 +1,6 @@ +import type { AdminUser, AuthTokens } from '../entities'; + +export interface IAuthRepository { + login(identifier: string, password: string): Promise<{ user: AdminUser; tokens: AuthTokens }>; + logout(): Promise; +} diff --git a/frontend/admin-web/src/domain/repositories/user.repository.interface.ts b/frontend/admin-web/src/domain/repositories/user.repository.interface.ts new file mode 100644 index 0000000..f62f249 --- /dev/null +++ b/frontend/admin-web/src/domain/repositories/user.repository.interface.ts @@ -0,0 +1,15 @@ +import type { User, PaginatedResult } from '../entities'; + +export interface UserListFilters { + search?: string; + kycStatus?: string; + page?: number; + pageSize?: number; +} + +export interface IUserRepository { + findMany(filters: UserListFilters): Promise>; + findById(id: string): Promise; + updateKycStatus(id: string, status: string): Promise; + toggleActive(id: string, isActive: boolean): Promise; +} diff --git a/frontend/admin-web/src/infrastructure/http/http.client.ts b/frontend/admin-web/src/infrastructure/http/http.client.ts new file mode 100644 index 0000000..0674315 --- /dev/null +++ b/frontend/admin-web/src/infrastructure/http/http.client.ts @@ -0,0 +1,80 @@ +// ============================================================ +// Infrastructure — HTTP Client +// 替代旧版 src/lib/api-client.ts,作为所有 Repository 的底层实现 +// 从 Zustand auth store 读取 token(而非直接操作 localStorage) +// ============================================================ + +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://api.gogenex.com'; + +class HttpClient { + private client: AxiosInstance; + + constructor() { + this.client = axios.create({ + baseURL: API_BASE_URL, + timeout: 30000, + headers: { 'Content-Type': 'application/json' }, + }); + + // Request 拦截器:注入 Bearer token + this.client.interceptors.request.use((config) => { + if (typeof window !== 'undefined') { + // 从 Zustand persisted storage 读取 token(避免直接耦合 store 模块导致循环依赖) + try { + const raw = localStorage.getItem('gcx-admin-auth'); + if (raw) { + const { state } = JSON.parse(raw); + if (state?.token) { + config.headers.Authorization = `Bearer ${state.token}`; + } + } + } catch { + // ignore parse errors + } + } + return config; + }); + + // Response 拦截器:401 → 清空 auth store 并跳转登录 + this.client.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401 && typeof window !== 'undefined') { + // 清空 Zustand persisted auth + localStorage.removeItem('gcx-admin-auth'); + window.location.href = '/login'; + } + return Promise.reject(error); + }, + ); + } + + async get(url: string, config?: AxiosRequestConfig): Promise { + const response: AxiosResponse = await this.client.get(url, config); + return response.data?.data ?? response.data; + } + + async post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + const response: AxiosResponse = await this.client.post(url, data, config); + return response.data?.data ?? response.data; + } + + async put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + const response: AxiosResponse = await this.client.put(url, data, config); + return response.data?.data ?? response.data; + } + + async patch(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + const response: AxiosResponse = await this.client.patch(url, data, config); + return response.data?.data ?? response.data; + } + + async delete(url: string, config?: AxiosRequestConfig): Promise { + const response: AxiosResponse = await this.client.delete(url, config); + return response.data?.data ?? response.data; + } +} + +export const httpClient = new HttpClient(); diff --git a/frontend/admin-web/src/infrastructure/repositories/auth.repository.ts b/frontend/admin-web/src/infrastructure/repositories/auth.repository.ts new file mode 100644 index 0000000..dd4c9ad --- /dev/null +++ b/frontend/admin-web/src/infrastructure/repositories/auth.repository.ts @@ -0,0 +1,18 @@ +import type { IAuthRepository } from '@/domain/repositories/auth.repository.interface'; +import type { AdminUser, AuthTokens } from '@/domain/entities'; +import { httpClient } from '../http/http.client'; + +class AuthRepository implements IAuthRepository { + async login( + identifier: string, + password: string, + ): Promise<{ user: AdminUser; tokens: AuthTokens }> { + return httpClient.post('/api/v1/auth/login', { identifier, password }); + } + + async logout(): Promise { + await httpClient.post('/api/v1/auth/logout').catch(() => {}); + } +} + +export const authRepository = new AuthRepository(); diff --git a/frontend/admin-web/src/infrastructure/repositories/user.repository.ts b/frontend/admin-web/src/infrastructure/repositories/user.repository.ts new file mode 100644 index 0000000..270c718 --- /dev/null +++ b/frontend/admin-web/src/infrastructure/repositories/user.repository.ts @@ -0,0 +1,23 @@ +import type { IUserRepository, UserListFilters } from '@/domain/repositories/user.repository.interface'; +import type { User, PaginatedResult } from '@/domain/entities'; +import { httpClient } from '../http/http.client'; + +class UserRepository implements IUserRepository { + async findMany(filters: UserListFilters): Promise> { + return httpClient.get('/api/v1/admin/users', { params: filters }); + } + + async findById(id: string): Promise { + return httpClient.get(`/api/v1/admin/users/${id}`); + } + + async updateKycStatus(id: string, status: string): Promise { + return httpClient.patch(`/api/v1/admin/users/${id}/kyc`, { status }); + } + + async toggleActive(id: string, isActive: boolean): Promise { + return httpClient.patch(`/api/v1/admin/users/${id}/status`, { isActive }); + } +} + +export const userRepository = new UserRepository(); diff --git a/frontend/admin-web/src/layouts/AdminLayout.tsx b/frontend/admin-web/src/layouts/AdminLayout.tsx index bd7148e..5c4962d 100644 --- a/frontend/admin-web/src/layouts/AdminLayout.tsx +++ b/frontend/admin-web/src/layouts/AdminLayout.tsx @@ -1,10 +1,11 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import Link from 'next/link'; import { t } from '@/i18n/locales'; -import { useAuth } from '@/lib/auth-context'; +import { useAuth } from '@/store/zustand/auth.store'; +import { useUIStore } from '@/store/zustand/ui.store'; /** * D. Web管理前端 - 主布局 @@ -98,7 +99,8 @@ export const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children const pathname = usePathname(); const router = useRouter(); const { isAuthenticated, isLoading, user, logout } = useAuth(); - const [collapsed, setCollapsed] = useState(false); + // Zustand UI store:sidebar 折叠状态持久化(替代本地 useState) + const { sidebarCollapsed: collapsed, toggleSidebar } = useUIStore(); // Derive activeKey from current pathname // ⚠️ 必须在所有 useState/useEffect 之前计算,因为 expandedKeys 的初始值依赖它 @@ -265,7 +267,7 @@ export const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children {/* Collapse toggle */}