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';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
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 */}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
onClick={toggleSidebar}
|
||||
style={{
|
||||
padding: 12,
|
||||
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';
|
||||
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -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 store(src/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 无需 Provider,Zustand 自管理状态。保留仅为向下兼容。 */
|
||||
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}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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