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:
parent
96dad278ea
commit
4feea2667c
|
|
@ -1,8 +1,9 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { Provider as ReduxProvider } from 'react-redux';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { AuthProvider } from '@/lib/auth-context';
|
import { store } from '@/store';
|
||||||
import { DeployGuard } from '@/lib/deploy-guard';
|
import { DeployGuard } from '@/lib/deploy-guard';
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
|
@ -20,11 +21,11 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<ReduxProvider store={store}>
|
||||||
<AuthProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<DeployGuard />
|
<DeployGuard />
|
||||||
{children}
|
{children}
|
||||||
</AuthProvider>
|
</QueryClientProvider>
|
||||||
</QueryClientProvider>
|
</ReduxProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { t } from '@/i18n/locales';
|
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管理前端 - 主布局
|
* D. Web管理前端 - 主布局
|
||||||
|
|
@ -98,7 +99,8 @@ export const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isAuthenticated, isLoading, user, logout } = useAuth();
|
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
|
// Derive activeKey from current pathname
|
||||||
// ⚠️ 必须在所有 useState/useEffect 之前计算,因为 expandedKeys 的初始值依赖它
|
// ⚠️ 必须在所有 useState/useEffect 之前计算,因为 expandedKeys 的初始值依赖它
|
||||||
|
|
@ -265,7 +267,7 @@ export const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children
|
||||||
|
|
||||||
{/* Collapse toggle */}
|
{/* Collapse toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={toggleSidebar}
|
||||||
style={{
|
style={{
|
||||||
padding: 12,
|
padding: 12,
|
||||||
border: 'none',
|
border: 'none',
|
||||||
|
|
|
||||||
|
|
@ -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';
|
export { httpClient as apiClient } from '@/infrastructure/http/http.client';
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
|
||||||
|
|
@ -1,88 +1,18 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
/**
|
||||||
import { apiClient } from './api-client';
|
* auth-context.tsx — 向下兼容层
|
||||||
|
*
|
||||||
|
* Auth 状态已迁移至 Zustand store(src/store/zustand/auth.store.ts)。
|
||||||
|
* 此文件保留 AuthProvider 和 useAuth 导出,确保现有页面无需修改。
|
||||||
|
*
|
||||||
|
* 新代码应直接从 @/store/zustand/auth.store 引入 useAuth / useAuthStore。
|
||||||
|
*/
|
||||||
|
|
||||||
interface AdminUser {
|
import React from 'react';
|
||||||
id: string;
|
export { useAuth, useAuthStore } from '@/store/zustand/auth.store';
|
||||||
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);
|
|
||||||
|
|
||||||
|
/** @deprecated 无需 Provider,Zustand 自管理状态。保留仅为向下兼容。 */
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<AdminUser | null>(null);
|
return <>{children}</>;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Zustand Auth Store
|
||||||
|
// 大厂模式:Zustand 管理轻量客户端状态(登录会话)
|
||||||
|
// 替代旧版 React Context(auth-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 };
|
||||||
|
}
|
||||||
|
|
@ -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 }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue