Initial project setup with Next.js accounts manager
- Set up Next.js project structure - Added UI components and styling - Configured package dependencies - Added feature documentation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
73
lib/api.ts
Normal file
73
lib/api.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
ApiResponse,
|
||||
ListAccountsBody,
|
||||
ListAccountsResponse,
|
||||
BatchDeleteBody,
|
||||
BatchUpdateBody,
|
||||
StatsOverview,
|
||||
ScriptUploadItem
|
||||
} from './types';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:3006';
|
||||
|
||||
class ApiClient {
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 获取账户列表
|
||||
async getAccountsList(body: ListAccountsBody): Promise<ApiResponse<ListAccountsResponse>> {
|
||||
return this.request<ListAccountsResponse>('/web/v1/accounts/list', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// 批量删除账户
|
||||
async batchDeleteAccounts(body: BatchDeleteBody): Promise<ApiResponse<{ deletedCount: number }>> {
|
||||
return this.request<{ deletedCount: number }>('/web/v1/accounts/delete-batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// 批量更新账户
|
||||
async batchUpdateAccounts(body: BatchUpdateBody): Promise<ApiResponse<{ updatedCount: number }>> {
|
||||
return this.request<{ updatedCount: number }>('/web/v1/accounts/update-batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// 获取统计概览
|
||||
async getStatsOverview(): Promise<ApiResponse<StatsOverview>> {
|
||||
return this.request<StatsOverview>('/web/v1/stats/overview');
|
||||
}
|
||||
|
||||
// 上传账户
|
||||
async uploadAccounts(accounts: ScriptUploadItem[], ownerId: string): Promise<ApiResponse<{ processedCount: number; createdCount: number; updatedCount: number }>> {
|
||||
return this.request<{ processedCount: number; createdCount: number; updatedCount: number }>(`/s/v1/${ownerId}/upload`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(accounts),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
265
lib/hooks/use-accounts.ts
Normal file
265
lib/hooks/use-accounts.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Account,
|
||||
ListAccountsBody,
|
||||
ListAccountsResponse,
|
||||
StatsOverview,
|
||||
BusinessCode,
|
||||
BatchUpdateBody,
|
||||
BatchDeleteBody,
|
||||
ScriptUploadItem
|
||||
} from '@/lib/types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface UseAccountsReturn {
|
||||
// 数据状态
|
||||
accounts: Account[];
|
||||
stats: StatsOverview | null;
|
||||
loading: boolean;
|
||||
selectedIds: number[];
|
||||
|
||||
// 分页和筛选
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
filters: {
|
||||
platform: string;
|
||||
status: string[];
|
||||
ownerId: string;
|
||||
search: string;
|
||||
};
|
||||
sort: {
|
||||
field: keyof Account;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
// 操作方法
|
||||
fetchAccounts: () => Promise<void>;
|
||||
fetchStats: () => Promise<void>;
|
||||
handleFilterChange: (key: string, value: any) => void;
|
||||
handlePageChange: (page: number) => void;
|
||||
handlePageSizeChange: (pageSize: number) => void;
|
||||
handleSortChange: (field: keyof Account, order: 'asc' | 'desc') => void;
|
||||
handleSelectAll: (checked: boolean) => void;
|
||||
handleSelectOne: (id: number, checked: boolean) => void;
|
||||
handleBatchDelete: () => Promise<void>;
|
||||
handleBatchUpdate: (payload: Partial<Pick<Account, 'status' | 'ownerId' | 'notes' | 'platform'>>) => Promise<void>;
|
||||
handleUploadAccounts: (accounts: ScriptUploadItem[], ownerId: string) => Promise<void>;
|
||||
setSelectedIds: (ids: number[]) => void;
|
||||
}
|
||||
|
||||
export function useAccounts(): UseAccountsReturn {
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [stats, setStats] = useState<StatsOverview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
platform: '',
|
||||
status: [] as string[],
|
||||
ownerId: '',
|
||||
search: ''
|
||||
});
|
||||
|
||||
const [sort, setSort] = useState({
|
||||
field: 'id' as keyof Account,
|
||||
order: 'desc' as 'asc' | 'desc'
|
||||
});
|
||||
|
||||
// 获取统计数据
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await apiClient.getStatsOverview();
|
||||
if (response.code === BusinessCode.Success && response.data) {
|
||||
setStats(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
toast.error('获取统计数据失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取账户列表
|
||||
const fetchAccounts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const body: ListAccountsBody = {
|
||||
filters,
|
||||
pagination: {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize
|
||||
},
|
||||
sort
|
||||
};
|
||||
|
||||
const response = await apiClient.getAccountsList(body);
|
||||
if (response.code === BusinessCode.Success && response.data) {
|
||||
setAccounts(response.data.list);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: response.data.pagination.total,
|
||||
totalPages: response.data.pagination.totalPages
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch accounts:', error);
|
||||
toast.error('获取账户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理筛选变化
|
||||
const handleFilterChange = (key: string, value: any) => {
|
||||
setFilters({ ...filters, [key]: value });
|
||||
setPagination({ ...pagination, page: 1 });
|
||||
};
|
||||
|
||||
// 处理分页
|
||||
const handlePageChange = (page: number) => {
|
||||
setPagination({ ...pagination, page });
|
||||
};
|
||||
|
||||
// 处理每页大小变化
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
setPagination({ ...pagination, pageSize, page: 1 });
|
||||
};
|
||||
|
||||
// 处理排序
|
||||
const handleSortChange = (field: keyof Account, order: 'asc' | 'desc') => {
|
||||
setSort({ field, order });
|
||||
};
|
||||
|
||||
// 处理全选
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(accounts.map(account => account.id));
|
||||
} else {
|
||||
setSelectedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理单选
|
||||
const handleSelectOne = (id: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds([...selectedIds, id]);
|
||||
} else {
|
||||
setSelectedIds(selectedIds.filter(selectedId => selectedId !== id));
|
||||
}
|
||||
};
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
|
||||
if (!confirm(`确认删除 ${selectedIds.length} 个账户?`)) return;
|
||||
|
||||
try {
|
||||
const response = await apiClient.batchDeleteAccounts({ ids: selectedIds });
|
||||
if (response.code === BusinessCode.Success) {
|
||||
setSelectedIds([]);
|
||||
await fetchAccounts();
|
||||
await fetchStats();
|
||||
toast.success(`成功删除 ${response.data?.deletedCount || 0} 个账户`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete accounts:', error);
|
||||
toast.error('删除失败,请检查网络连接后重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 批量更新
|
||||
const handleBatchUpdate = async (payload: Partial<Pick<Account, 'status' | 'ownerId' | 'notes' | 'platform'>>, targetIds?: number[]) => {
|
||||
const idsToUpdate = targetIds || selectedIds;
|
||||
console.log('handleBatchUpdate 被调用, idsToUpdate:', idsToUpdate);
|
||||
console.log('payload:', payload);
|
||||
|
||||
if (idsToUpdate.length === 0) {
|
||||
console.log('idsToUpdate为空,直接返回');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
toast.warning('请至少选择一个字段进行更新');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('发送批量更新请求...');
|
||||
const response = await apiClient.batchUpdateAccounts({ ids: idsToUpdate, payload });
|
||||
console.log('批量更新响应:', response);
|
||||
|
||||
if (response.code === BusinessCode.Success) {
|
||||
if (!targetIds) {
|
||||
// 只有在没有指定目标ID时才清空选中状态(批量操作时)
|
||||
setSelectedIds([]);
|
||||
}
|
||||
await fetchAccounts();
|
||||
await fetchStats();
|
||||
toast.success(`成功更新 ${response.data?.updatedCount || 0} 个账户`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update accounts:', error);
|
||||
toast.error('更新失败,请检查网络连接后重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 上传账户
|
||||
const handleUploadAccounts = async (uploadAccounts: ScriptUploadItem[], ownerId: string) => {
|
||||
try {
|
||||
const response = await apiClient.uploadAccounts(uploadAccounts, ownerId);
|
||||
if (response.code === BusinessCode.Success) {
|
||||
await fetchAccounts();
|
||||
await fetchStats();
|
||||
toast.success(`成功处理 ${response.data?.processedCount || 0} 个账户 (${response.data?.createdCount || 0} 个新建, ${response.data?.updatedCount || 0} 个更新)`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to upload accounts:', error);
|
||||
toast.error('上传失败,请检查网络连接后重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化数据
|
||||
useEffect(() => {
|
||||
fetchAccounts();
|
||||
}, [filters, pagination.page, pagination.pageSize, sort]);
|
||||
|
||||
// 只在开始时获取统计数据
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
accounts,
|
||||
stats,
|
||||
loading,
|
||||
selectedIds,
|
||||
pagination,
|
||||
filters,
|
||||
sort,
|
||||
fetchAccounts,
|
||||
fetchStats,
|
||||
handleFilterChange,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
handleSortChange,
|
||||
handleSelectAll,
|
||||
handleSelectOne,
|
||||
handleBatchDelete,
|
||||
handleBatchUpdate,
|
||||
handleUploadAccounts,
|
||||
setSelectedIds
|
||||
};
|
||||
}
|
||||
59
lib/hooks/use-stats-data.ts
Normal file
59
lib/hooks/use-stats-data.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { StatsOverview } from '@/lib/types';
|
||||
|
||||
export interface StatsDataItem {
|
||||
value: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function useStatsData(stats: StatsOverview | null) {
|
||||
const platforms = useMemo(() => {
|
||||
if (!stats?.platformSummary) return [];
|
||||
return Object.entries(stats.platformSummary).map(([platform, count]) => ({
|
||||
value: platform,
|
||||
label: platform,
|
||||
count
|
||||
}));
|
||||
}, [stats?.platformSummary]);
|
||||
|
||||
const owners = useMemo(() => {
|
||||
if (!stats?.ownerSummary) return [];
|
||||
return Object.entries(stats.ownerSummary).map(([owner, count]) => ({
|
||||
value: owner,
|
||||
label: owner,
|
||||
count
|
||||
}));
|
||||
}, [stats?.ownerSummary]);
|
||||
|
||||
const statuses = useMemo(() => {
|
||||
if (!stats?.statusSummary) return [];
|
||||
return Object.entries(stats.statusSummary).map(([status, count]) => ({
|
||||
value: status,
|
||||
label: getStatusLabel(status),
|
||||
count
|
||||
}));
|
||||
}, [stats?.statusSummary]);
|
||||
|
||||
return {
|
||||
platforms,
|
||||
owners,
|
||||
statuses,
|
||||
hasData: Boolean(stats)
|
||||
};
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return '可用';
|
||||
case 'locked':
|
||||
return '已锁定';
|
||||
case 'banned':
|
||||
return '已封禁';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
101
lib/types.ts
Normal file
101
lib/types.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// 业务状态码
|
||||
export enum BusinessCode {
|
||||
Success = 0,
|
||||
NoResource = 1001,
|
||||
InvalidParams = 2001,
|
||||
ResourceConflict = 3001,
|
||||
PermissionDenied = 4001,
|
||||
BusinessError = 5001,
|
||||
}
|
||||
|
||||
// 通用响应格式
|
||||
export interface ApiResponse<T> {
|
||||
code: BusinessCode;
|
||||
message: string;
|
||||
data: T | null;
|
||||
}
|
||||
|
||||
// 账户类型
|
||||
export interface Account {
|
||||
id: number;
|
||||
ownerId: string;
|
||||
platform: string;
|
||||
customId: string;
|
||||
data: string;
|
||||
status: string;
|
||||
notes?: string;
|
||||
lockedAt?: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// 分页结果
|
||||
export interface PaginationResult {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// 脚本获取响应
|
||||
export type ScriptAcquireResponse = Pick<Account, 'id' | 'customId' | 'data'>[];
|
||||
|
||||
// 脚本上传项
|
||||
export interface ScriptUploadItem {
|
||||
platform: string;
|
||||
customId: string;
|
||||
data: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// 账户列表筛选条件
|
||||
export interface ListAccountsFilters {
|
||||
platform?: string;
|
||||
status?: string[];
|
||||
ownerId?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// 账户列表请求体
|
||||
export interface ListAccountsBody {
|
||||
filters: ListAccountsFilters;
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
sort: {
|
||||
field: keyof Account;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
// 账户列表响应
|
||||
export interface ListAccountsResponse {
|
||||
list: Account[];
|
||||
pagination: PaginationResult;
|
||||
}
|
||||
|
||||
// 批量删除请求体
|
||||
export interface BatchDeleteBody {
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
// 批量更新请求体
|
||||
export interface BatchUpdateBody {
|
||||
ids: number[];
|
||||
payload: Partial<Pick<Account, 'status' | 'ownerId' | 'notes' | 'platform'>>;
|
||||
}
|
||||
|
||||
// 统计概览
|
||||
export interface StatsOverview {
|
||||
totalAccounts: number;
|
||||
platformSummary: Record<string, number>;
|
||||
ownerSummary: Record<string, number>;
|
||||
statusSummary: Record<string, number>;
|
||||
detailedBreakdown: {
|
||||
platform: string;
|
||||
ownerId: string;
|
||||
status: string;
|
||||
count: number;
|
||||
}[];
|
||||
}
|
||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user