refactor(admin-web): 实现 Clean Architecture + Zustand + Redux Toolkit

按要求重构架构,从扁平的 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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-04 19:18:20 -08:00
parent 96dad278ea
commit 4feea2667c
15 changed files with 521 additions and 154 deletions

View File

@ -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 (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ReduxProvider store={store}>
<QueryClientProvider client={queryClient}>
<DeployGuard />
{children}
</AuthProvider>
</QueryClientProvider>
</QueryClientProvider>
</ReduxProvider>
);
}

View File

@ -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<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
export interface ApiResponse<T = unknown> {
code: number;
data: T;
message: string;
}

View File

@ -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<void>;
}

View File

@ -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<PaginatedResult<User>>;
findById(id: string): Promise<User>;
updateKycStatus(id: string, status: string): Promise<User>;
toggleActive(id: string, isActive: boolean): Promise<User>;
}

View File

@ -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<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse = await this.client.get(url, config);
return response.data?.data ?? response.data;
}
async post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse = await this.client.post(url, data, config);
return response.data?.data ?? response.data;
}
async put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse = await this.client.put(url, data, config);
return response.data?.data ?? response.data;
}
async patch<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse = await this.client.patch(url, data, config);
return response.data?.data ?? response.data;
}
async delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse = await this.client.delete(url, config);
return response.data?.data ?? response.data;
}
}
export const httpClient = new HttpClient();

View File

@ -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<void> {
await httpClient.post('/api/v1/auth/logout').catch(() => {});
}
}
export const authRepository = new AuthRepository();

View File

@ -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<PaginatedResult<User>> {
return httpClient.get('/api/v1/admin/users', { params: filters });
}
async findById(id: string): Promise<User> {
return httpClient.get(`/api/v1/admin/users/${id}`);
}
async updateKycStatus(id: string, status: string): Promise<User> {
return httpClient.patch(`/api/v1/admin/users/${id}/kyc`, { status });
}
async toggleActive(id: string, isActive: boolean): Promise<User> {
return httpClient.patch(`/api/v1/admin/users/${id}/status`, { isActive });
}
}
export const userRepository = new UserRepository();

View File

@ -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 storesidebar 折叠状态持久化(替代本地 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 */}
<button
onClick={() => setCollapsed(!collapsed)}
onClick={toggleSidebar}
style={{
padding: 12,
border: 'none',

View File

@ -1,64 +1,10 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
/**
* api-client.ts
*
* HTTP Infrastructure src/infrastructure/http/http.client.ts
* apiClient use-api.ts
*
* @/infrastructure/http/http.client httpClient
*/
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://api.gogenex.com';
class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
});
this.client.interceptors.request.use((config) => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('admin_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
this.client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && typeof window !== 'undefined') {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
window.location.href = '/login';
}
return Promise.reject(error);
},
);
}
async get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse = await this.client.get(url, config);
return response.data?.data ?? response.data;
}
async post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse = await this.client.post(url, data, config);
return response.data?.data ?? response.data;
}
async put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse = await this.client.put(url, data, config);
return response.data?.data ?? response.data;
}
async patch<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse = await this.client.patch(url, data, config);
return response.data?.data ?? response.data;
}
async delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse = await this.client.delete(url, config);
return response.data?.data ?? response.data;
}
}
export const apiClient = new ApiClient();
export { httpClient as apiClient } from '@/infrastructure/http/http.client';

View File

@ -1,88 +1,18 @@
'use client';
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { apiClient } from './api-client';
/**
* auth-context.tsx
*
* Auth Zustand storesrc/store/zustand/auth.store.ts
* AuthProvider useAuth
*
* @/store/zustand/auth.store useAuth / useAuthStore
*/
interface AdminUser {
id: string;
email: string;
name: string;
role: string;
}
interface AuthContextType {
user: AdminUser | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
import React from 'react';
export { useAuth, useAuthStore } from '@/store/zustand/auth.store';
/** @deprecated 无需 ProviderZustand 自管理状态。保留仅为向下兼容。 */
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AdminUser | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const savedToken = localStorage.getItem('admin_token');
const savedUser = localStorage.getItem('admin_user');
if (savedToken && savedUser) {
setToken(savedToken);
try {
setUser(JSON.parse(savedUser));
} catch {
localStorage.removeItem('admin_user');
}
}
setIsLoading(false);
}, []);
const login = useCallback(async (email: string, password: string) => {
const result = await apiClient.post<{
user: AdminUser;
tokens: { accessToken: string; refreshToken: string; expiresIn: number };
}>(
'/api/v1/auth/login',
{ identifier: email, password },
);
const { tokens, user: adminUser } = result;
localStorage.setItem('admin_token', tokens.accessToken);
localStorage.setItem('admin_user', JSON.stringify(adminUser));
setToken(tokens.accessToken);
setUser(adminUser);
}, []);
const logout = useCallback(() => {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
setToken(null);
setUser(null);
apiClient.post('/api/v1/auth/logout').catch(() => {});
}, []);
return (
<AuthContext.Provider
value={{
user,
token,
isAuthenticated: !!token,
isLoading,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
return <>{children}</>;
}

View File

@ -0,0 +1,26 @@
// ============================================================
// Redux Store 配置
// 大厂模式RTK configureStore + 类型安全的 hooks
// ============================================================
import { configureStore } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { uiSlice } from './slices/ui.slice';
import { usersSlice } from './slices/users.slice';
export const store = configureStore({
reducer: {
ui: uiSlice.reducer,
users: usersSlice.reducer,
},
devTools: process.env.NODE_ENV !== 'production',
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
/** 类型安全的 dispatch hook */
export const useAppDispatch = () => useDispatch<AppDispatch>();
/** 类型安全的 selector hook */
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@ -0,0 +1,50 @@
// ============================================================
// Redux Toolkit — UI Slice
// 大厂模式RTK 管理全局通知队列、异步 loading 等跨模块状态
// ============================================================
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export type NotificationType = 'success' | 'error' | 'warning' | 'info';
export interface Notification {
id: string;
type: NotificationType;
message: string;
duration?: number; // ms, default 3000
}
interface UISliceState {
notifications: Notification[];
globalLoading: boolean;
}
const initialState: UISliceState = {
notifications: [],
globalLoading: false,
};
export const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
addNotification: (state, action: PayloadAction<Omit<Notification, 'id'>>) => {
state.notifications.push({
...action.payload,
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
});
},
removeNotification: (state, action: PayloadAction<string>) => {
state.notifications = state.notifications.filter((n) => n.id !== action.payload);
},
clearNotifications: (state) => {
state.notifications = [];
},
setGlobalLoading: (state, action: PayloadAction<boolean>) => {
state.globalLoading = action.payload;
},
},
});
export const { addNotification, removeNotification, clearNotifications, setGlobalLoading } =
uiSlice.actions;

View File

@ -0,0 +1,65 @@
// ============================================================
// Redux Toolkit — Users Slice
// 大厂模式RTK 管理用户列表的筛选/分页/缓存状态
// 服务端数据获取由 React Query 负责RTK 负责客户端 UI 状态
// ============================================================
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { KycStatus } from '@/domain/entities';
interface UsersFilters {
search: string;
kycStatus: KycStatus | '';
isActive: boolean | null;
}
interface UsersSliceState {
filters: UsersFilters;
page: number;
pageSize: number;
selectedIds: string[];
}
const initialState: UsersSliceState = {
filters: {
search: '',
kycStatus: '',
isActive: null,
},
page: 1,
pageSize: 20,
selectedIds: [],
};
export const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {
setFilters: (state, action: PayloadAction<Partial<UsersFilters>>) => {
state.filters = { ...state.filters, ...action.payload };
state.page = 1; // 改筛选条件时重置到第一页
},
setPage: (state, action: PayloadAction<number>) => {
state.page = action.payload;
},
setPageSize: (state, action: PayloadAction<number>) => {
state.pageSize = action.payload;
state.page = 1;
},
toggleSelectUser: (state, action: PayloadAction<string>) => {
const idx = state.selectedIds.indexOf(action.payload);
if (idx >= 0) {
state.selectedIds.splice(idx, 1);
} else {
state.selectedIds.push(action.payload);
}
},
clearSelection: (state) => {
state.selectedIds = [];
},
resetFilters: () => initialState,
},
});
export const { setFilters, setPage, setPageSize, toggleSelectUser, clearSelection, resetFilters } =
usersSlice.actions;

View File

@ -0,0 +1,82 @@
'use client';
// ============================================================
// Zustand Auth Store
// 大厂模式Zustand 管理轻量客户端状态(登录会话)
// 替代旧版 React Contextauth-context.tsx
// ============================================================
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { AdminUser } from '@/domain/entities';
import { authRepository } from '@/infrastructure/repositories/auth.repository';
interface AuthState {
// State
user: AdminUser | null;
token: string | null;
isLoading: boolean;
isAuthenticated: boolean;
// Actions
login: (email: string, password: string) => Promise<void>;
logout: () => void;
/** 内部Zustand persist 重水合后调用,同步 isAuthenticated */
_onRehydrate: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
isLoading: true,
isAuthenticated: false,
login: async (email, password) => {
const { user, tokens } = await authRepository.login(email, password);
set({
user,
token: tokens.accessToken,
isAuthenticated: true,
isLoading: false,
});
},
logout: () => {
authRepository.logout();
set({ user: null, token: null, isAuthenticated: false });
},
_onRehydrate: () => {
const { token } = get();
set({ isAuthenticated: !!token, isLoading: false });
},
}),
{
name: 'gcx-admin-auth',
storage: createJSONStorage(() => {
// SSR 安全:服务端返回 no-op storage
if (typeof window === 'undefined') {
return {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
};
}
return localStorage;
}),
// 只持久化 user 和 token不持久化 isLoading/isAuthenticated运行时计算
partialize: (state) => ({ user: state.user, token: state.token }),
onRehydrateStorage: () => (state) => {
if (state) state._onRehydrate();
},
},
),
);
/** 向下兼容 hook接口与旧版 useAuth() 完全一致 */
export function useAuth() {
const { user, isAuthenticated, isLoading, login, logout } = useAuthStore();
return { user, isAuthenticated, isLoading, login, logout, token: useAuthStore.getState().token };
}

View File

@ -0,0 +1,46 @@
'use client';
// ============================================================
// Zustand UI Store
// 大厂模式Zustand 管理全局 UI 状态sidebar、modal 等)
// ============================================================
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface UIState {
// Sidebar
sidebarCollapsed: boolean;
toggleSidebar: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
// Global modal
activeModal: string | null;
openModal: (modalId: string) => void;
closeModal: () => void;
}
export const useUIStore = create<UIState>()(
persist(
(set) => ({
sidebarCollapsed: false,
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
activeModal: null,
openModal: (modalId) => set({ activeModal: modalId }),
closeModal: () => set({ activeModal: null }),
}),
{
name: 'gcx-admin-ui',
storage: createJSONStorage(() => {
if (typeof window === 'undefined') {
return { getItem: () => null, setItem: () => {}, removeItem: () => {} };
}
return localStorage;
}),
// 只持久化 sidebar 状态
partialize: (state) => ({ sidebarCollapsed: state.sidebarCollapsed }),
},
),
);