feat: 本次提交更新内容如下

定版本转移2025年7月17日
This commit is contained in:
2025-07-17 10:22:38 +08:00
parent 0f860d01e4
commit 92a3d407a7
645 changed files with 30755 additions and 118800 deletions

View File

@@ -1,38 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -1,9 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@@ -1,191 +0,0 @@
import React, { useEffect } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider } from "./contexts/AuthContext";
import { WechatAccountProvider } from "./contexts/WechatAccountContext";
import ProtectedRoute from "./components/ProtectedRoute";
import LayoutWrapper from "./components/LayoutWrapper";
import { initInterceptors } from "./api";
import Home from "./pages/Home";
import Login from "./pages/login/Login";
import Devices from "./pages/devices/Devices";
import DeviceDetail from "./pages/devices/DeviceDetail";
import WechatAccounts from "./pages/wechat-accounts/WechatAccounts";
import WechatAccountDetail from "./pages/wechat-accounts/WechatAccountDetail";
import Workspace from "./pages/workspace/Workspace";
import AutoLike from "./pages/workspace/auto-like/AutoLike";
import NewAutoLike from "./pages/workspace/auto-like/NewAutoLike";
import AutoLikeDetail from "./pages/workspace/auto-like/AutoLikeDetail";
import NewDistribution from "./pages/workspace/traffic-distribution/NewDistribution";
import AutoGroup from "./pages/workspace/auto-group/AutoGroup";
import AutoGroupDetail from "./pages/workspace/auto-group/Detail";
import GroupPush from "./pages/workspace/group-push/GroupPush";
import MomentsSync from "./pages/workspace/moments-sync/MomentsSync";
import MomentsSyncDetail from "./pages/workspace/moments-sync/Detail";
import NewMomentsSync from "./pages/workspace/moments-sync/new";
import AIAssistant from "./pages/workspace/ai-assistant/AIAssistant";
import TrafficDistribution from "./pages/workspace/traffic-distribution/TrafficDistribution";
import TrafficDistributionDetail from "./pages/workspace/traffic-distribution/Detail";
import Scenarios from "./pages/scenarios/Scenarios";
import NewPlan from "./pages/scenarios/new/page";
import ScenarioList from "./pages/scenarios/ScenarioList";
import Profile from "./pages/profile/Profile";
import Plans from "./pages/plans/Plans";
import PlanDetail from "./pages/plans/PlanDetail";
import Orders from "./pages/orders/Orders";
import TrafficPool from "./pages/traffic-pool/TrafficPool";
import ContactImport from "./pages/contact-import/ContactImport";
import Content from "./pages/content/Content";
import TrafficPoolDetail from "./pages/traffic-pool/TrafficPoolDetail";
import NewContent from "./pages/content/NewContent";
import Materials from "./pages/content/materials/List";
import MaterialsNew from "./pages/content/materials/New";
import NewGroupPush from './pages/workspace/group-push/new';
// 占位导入(如未实现可后续补充)
// import GroupPushDetail from './pages/workspace/group-push/GroupPushDetail';
// import EditGroupPush from './pages/workspace/group-push/EditGroupPush';
// import NewAutoGroup from './pages/workspace/auto-group/NewAutoGroup';
// import EditAutoGroup from './pages/workspace/auto-group/EditAutoGroup';
function App() {
// 初始化HTTP拦截器
useEffect(() => {
const cleanup = initInterceptors();
return cleanup;
}, []);
return (
<BrowserRouter
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
>
<AuthProvider>
<WechatAccountProvider>
<ProtectedRoute>
<LayoutWrapper>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/devices" element={<Devices />} />
<Route path="/devices/:id" element={<DeviceDetail />} />
<Route path="/wechat-accounts" element={<WechatAccounts />} />
<Route
path="/wechat-accounts/:id"
element={<WechatAccountDetail />}
/>
<Route path="/workspace" element={<Workspace />} />
<Route path="/workspace/auto-like" element={<AutoLike />} />
<Route
path="/workspace/auto-like/new"
element={<NewAutoLike />}
/>
<Route
path="/workspace/auto-like/:id"
element={<AutoLikeDetail />}
/>
<Route
path="/workspace/auto-like/:id/edit"
element={<NewAutoLike />}
/>
<Route
path="/workspace/traffic-distribution"
element={<TrafficDistribution />}
/>
<Route
path="/workspace/traffic-distribution/new"
element={<NewDistribution />}
/>
<Route
path="/workspace/traffic-distribution/edit/:id"
element={<NewDistribution />}
/>
<Route path="/workspace/auto-group" element={<AutoGroup />} />
<Route
path="/workspace/auto-group/:id"
element={<AutoGroupDetail />}
/>
<Route path="/workspace/group-push" element={<GroupPush />} />
<Route
path="/workspace/group-push/new"
element={<NewGroupPush />}
/>
<Route
path="/workspace/group-push/:id"
element={<div>GroupPushDetail组件</div>}
/>
<Route
path="/workspace/group-push/:id/edit"
element={<div>EditGroupPush组件</div>}
/>
<Route
path="/workspace/moments-sync"
element={<MomentsSync />}
/>
<Route
path="/workspace/moments-sync/new"
element={<NewMomentsSync />}
/>
<Route
path="/workspace/moments-sync/:id"
element={<MomentsSyncDetail />}
/>
<Route
path="/workspace/moments-sync/edit/:id"
element={<NewMomentsSync />}
/>
<Route
path="/workspace/ai-assistant"
element={<AIAssistant />}
/>
<Route
path="/workspace/traffic-distribution"
element={<TrafficDistribution />}
/>
<Route
path="/workspace/traffic-distribution/:id"
element={<TrafficDistributionDetail />}
/>
{/* 场景计划开始 */}
<Route path="/scenarios" element={<Scenarios />} />
<Route path="/scenarios/new" element={<NewPlan />} />
<Route
path="/scenarios/new/:scenarioId"
element={<NewPlan />}
/>
<Route path="/scenarios/edit/:planId" element={<NewPlan />} />
<Route
path="/scenarios/list/:scenarioId/:scenarioName"
element={<ScenarioList />}
/>
{/* 场景计划结束 */}
<Route path="/profile" element={<Profile />} />
<Route path="/plans" element={<Plans />} />
<Route path="/plans/:planId" element={<PlanDetail />} />
<Route path="/orders" element={<Orders />} />
<Route path="/traffic-pool" element={<TrafficPool />} />
<Route
path="/traffic-pool/:id"
element={<TrafficPoolDetail />}
/>
<Route path="/contact-import" element={<ContactImport />} />
<Route path="/content" element={<Content />} />
<Route path="/content/new" element={<NewContent />} />
<Route path="/content/edit/:id" element={<NewContent />} />
<Route path="/content/materials/:id" element={<Materials />} />
<Route
path="/content/materials/new/:id"
element={<MaterialsNew />}
/>
<Route
path="/content/materials/edit/:id/:materialId"
element={<MaterialsNew />}
/>
{/* 你可以继续添加更多路由 */}
</Routes>
</LayoutWrapper>
</ProtectedRoute>
</WechatAccountProvider>
</AuthProvider>
</BrowserRouter>
);
}
export default App;

View File

@@ -1,82 +0,0 @@
import { request } from './request';
import type { ApiResponse } from '@/types/common';
// 登录响应数据类型
export interface LoginResponse {
token: string;
token_expired: string;
member: {
id: number;
username: string;
account: string;
avatar?: string;
s2_accountId: string;
};
}
// 验证码响应类型
export interface VerificationCodeResponse {
code: string;
expire_time: string;
}
// 认证相关API
export const authApi = {
// 账号密码登录
login: async (account: string, password: string) => {
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/login', {
account,
password,
typeId: 1 // 默认使用用户类型1
});
return response as unknown as ApiResponse<LoginResponse>;
},
// 验证码登录
loginWithCode: async (account: string, code: string) => {
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/login/code', {
account,
code,
typeId: 1
});
return response as unknown as ApiResponse<LoginResponse>;
},
// 发送验证码
sendVerificationCode: async (account: string) => {
const response = await request.post<ApiResponse<VerificationCodeResponse>>('/v1/auth/send-code', {
account,
type: 'login' // 登录验证码
});
return response as unknown as ApiResponse<VerificationCodeResponse>;
},
// 获取用户信息
getUserInfo: async () => {
const response = await request.get<ApiResponse<any>>('/v1/auth/info');
return response as unknown as ApiResponse<any>;
},
// 刷新Token
refreshToken: async () => {
const response = await request.post<ApiResponse<{ token: string; token_expired: string }>>('/v1/auth/refresh', {});
return response as unknown as ApiResponse<{ token: string; token_expired: string }>;
},
// 微信登录
wechatLogin: async (code: string) => {
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/wechat', {
code
});
return response as unknown as ApiResponse<LoginResponse>;
},
// Apple登录
appleLogin: async (identityToken: string, authorizationCode: string) => {
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/apple', {
identity_token: identityToken,
authorization_code: authorizationCode
});
return response as unknown as ApiResponse<LoginResponse>;
},
};

View File

@@ -1,119 +0,0 @@
import { get, post, del } from './request';
import {
LikeTask,
CreateLikeTaskData,
UpdateLikeTaskData,
LikeRecord,
ApiResponse,
PaginatedResponse
} from '@/types/auto-like';
// 获取自动点赞任务列表
export async function fetchAutoLikeTasks(): Promise<LikeTask[]> {
try {
const res = await get<ApiResponse<PaginatedResponse<LikeTask>>>('/v1/workbench/list?type=1&page=1&limit=100');
if (res.code === 200 && res.data) {
return res.data.list || [];
}
return [];
} catch (error) {
console.error('获取自动点赞任务失败:', error);
return [];
}
}
// 获取单个任务详情
export async function fetchAutoLikeTaskDetail(id: string): Promise<LikeTask | null> {
try {
console.log(`Fetching task detail for id: ${id}`);
// 使用any类型来处理可能的不同响应结构
const res = await get<any>(`/v1/workbench/detail?id=${id}`);
console.log('Task detail API response:', res);
if (res.code === 200) {
// 检查响应中的data字段
if (res.data) {
// 如果data是对象直接返回
if (typeof res.data === 'object') {
return res.data;
} else {
console.error('Task detail API response data is not an object:', res.data);
return null;
}
} else {
console.error('Task detail API response missing data field:', res);
return null;
}
}
console.error('Task detail API error:', res.msg || 'Unknown error');
return null;
} catch (error) {
console.error('获取任务详情失败:', error);
return null;
}
}
// 创建自动点赞任务
export async function createAutoLikeTask(data: CreateLikeTaskData): Promise<ApiResponse> {
return post('/v1/workbench/create', {
...data,
type: 1 // 自动点赞类型
});
}
// 更新自动点赞任务
export async function updateAutoLikeTask(data: UpdateLikeTaskData): Promise<ApiResponse> {
return post('/v1/workbench/update', {
...data,
type: 1 // 自动点赞类型
});
}
// 删除自动点赞任务
export async function deleteAutoLikeTask(id: string): Promise<ApiResponse> {
return del('/v1/workbench/delete', { params: { id } });
}
// 切换任务状态
export async function toggleAutoLikeTask(id: string, status: string): Promise<ApiResponse> {
return post('/v1/workbench/update-status', { id, status });
}
// 复制自动点赞任务
export async function copyAutoLikeTask(id: string): Promise<ApiResponse> {
return post('/v1/workbench/copy', { id });
}
// 获取点赞记录
export async function fetchLikeRecords(
workbenchId: string,
page: number = 1,
limit: number = 20,
keyword?: string
): Promise<PaginatedResponse<LikeRecord>> {
try {
const params = new URLSearchParams({
workbenchId,
page: page.toString(),
limit: limit.toString()
});
if (keyword) {
params.append('keyword', keyword);
}
const res = await get<ApiResponse<PaginatedResponse<LikeRecord>>>(`/v1/workbench/like-records?${params.toString()}`);
if (res.code === 200 && res.data) {
return res.data;
}
return { list: [], total: 0, page, limit };
} catch (error) {
console.error('获取点赞记录失败:', error);
return { list: [], total: 0, page, limit };
}
}
export type { LikeTask, LikeRecord, CreateLikeTaskData };

View File

@@ -1,69 +0,0 @@
import { get, post, put, del } from './request';
import type { ApiResponse, PaginatedResponse } from '@/types/common';
// 内容库类型定义
export interface ContentLibrary {
id: string;
name: string;
sourceType: number;
creatorName: string;
updateTime: string;
status: number;
}
// 内容库列表响应
export interface ContentLibraryListResponse {
code: number;
msg: string;
data: {
list: ContentLibrary[];
total: number;
page: number;
limit: number;
};
}
// 获取内容库列表
export const fetchContentLibraryList = async (
page: number = 1,
limit: number = 100,
keyword?: string
): Promise<ContentLibraryListResponse> => {
const params = new URLSearchParams();
params.append('page', page.toString());
params.append('limit', limit.toString());
if (keyword) {
params.append('keyword', keyword);
}
return get<ContentLibraryListResponse>(`/v1/content/library/list?${params.toString()}`);
};
// 内容库API对象
export const contentLibraryApi = {
// 获取内容库列表
async getList(page: number = 1, limit: number = 100, keyword?: string): Promise<ContentLibraryListResponse> {
return fetchContentLibraryList(page, limit, keyword);
},
// 创建内容库
async create(params: { name: string; sourceType: number }): Promise<ApiResponse<ContentLibrary>> {
return post<ApiResponse<ContentLibrary>>('/v1/content/library', params);
},
// 更新内容库
async update(id: string, params: Partial<ContentLibrary>): Promise<ApiResponse<ContentLibrary>> {
return put<ApiResponse<ContentLibrary>>(`/v1/content/library/${id}`, params);
},
// 删除内容库
async delete(id: string): Promise<ApiResponse<void>> {
return del<ApiResponse<void>>(`/v1/content/library/${id}`);
},
// 获取内容库详情
async getById(id: string): Promise<ApiResponse<ContentLibrary>> {
return get<ApiResponse<ContentLibrary>>(`/v1/content/library/${id}`);
},
};

View File

@@ -1,200 +0,0 @@
import { get, post, put, del } from './request';
import type { ApiResponse, PaginatedResponse } from '@/types/common';
import type {
Device,
DeviceStats,
DeviceTaskRecord,
QueryDeviceParams,
CreateDeviceParams,
UpdateDeviceParams,
DeviceStatus,
ServerDevicesResponse
} from '@/types/device';
const API_BASE = "/devices";
// 获取设备列表 - 连接到服务器/v1/devices接口
export const fetchDeviceList = async (page: number = 1, limit: number = 20, keyword?: string): Promise<ServerDevicesResponse> => {
const params = new URLSearchParams();
params.append('page', page.toString());
params.append('limit', limit.toString());
if (keyword) {
params.append('keyword', keyword);
}
return get<ServerDevicesResponse>(`/v1/devices?${params.toString()}`);
};
// 获取设备详情 - 连接到服务器/v1/devices/:id接口
export const fetchDeviceDetail = async (id: string | number): Promise<ApiResponse<any>> => {
return get<ApiResponse<any>>(`/v1/devices/${id}`);
};
// 获取设备关联的微信账号
export const fetchDeviceRelatedAccounts = async (id: string | number): Promise<ApiResponse<any>> => {
return get<ApiResponse<any>>(`/v1/wechats/related-device/${id}`);
};
// 获取设备操作记录
export const fetchDeviceHandleLogs = async (id: string | number, page: number = 1, limit: number = 10): Promise<ApiResponse<any>> => {
return get<ApiResponse<any>>(`/v1/devices/${id}/handle-logs?page=${page}&limit=${limit}`);
};
// 更新设备任务配置
export const updateDeviceTaskConfig = async (
config: {
deviceId: string | number;
autoAddFriend?: boolean;
autoReply?: boolean;
momentsSync?: boolean;
aiChat?: boolean;
}
): Promise<ApiResponse<any>> => {
return post<ApiResponse<any>>(`/v1/devices/task-config`, config);
};
// 删除设备
export const deleteDevice = async (id: number): Promise<ApiResponse<any>> => {
return del<ApiResponse<any>>(`/v1/devices/${id}`);
};
// 设备管理API
export const devicesApi = {
// 获取设备列表
async getList(page: number = 1, limit: number = 20, keyword?: string): Promise<ServerDevicesResponse> {
const params = new URLSearchParams();
params.append('page', page.toString());
params.append('limit', limit.toString());
if (keyword) {
params.append('keyword', keyword);
}
return get<ServerDevicesResponse>(`/v1/devices?${params.toString()}`);
},
// 获取设备二维码
async getQRCode(accountId: string): Promise<ApiResponse<{ qrCode: string }>> {
return post<ApiResponse<{ qrCode: string }>>('/v1/api/device/add', { accountId });
},
// 通过IMEI添加设备
async addByImei(imei: string, name: string): Promise<ApiResponse<any>> {
return post<ApiResponse<any>>('/v1/api/device/add-by-imei', { imei, name });
},
// 创建设备
async create(params: CreateDeviceParams): Promise<ApiResponse<Device>> {
return post<ApiResponse<Device>>(`${API_BASE}`, params);
},
// 更新设备
async update(params: UpdateDeviceParams): Promise<ApiResponse<Device>> {
return put<ApiResponse<Device>>(`${API_BASE}/${params.id}`, params);
},
// 获取设备详情
async getById(id: string): Promise<ApiResponse<Device>> {
return get<ApiResponse<Device>>(`${API_BASE}/${id}`);
},
// 查询设备列表
async query(params: QueryDeviceParams): Promise<ApiResponse<PaginatedResponse<Device>>> {
// 创建一个新对象用于构建URLSearchParams
const queryParams: Record<string, string> = {};
// 按需将params中的属性添加到queryParams
if (params.keyword) queryParams.keyword = params.keyword;
if (params.status) queryParams.status = params.status;
if (params.type) queryParams.type = params.type;
if (params.page) queryParams.page = params.page.toString();
if (params.pageSize) queryParams.pageSize = params.pageSize.toString();
// 特殊处理需要JSON序列化的属性
if (params.tags) queryParams.tags = JSON.stringify(params.tags);
if (params.dateRange) queryParams.dateRange = JSON.stringify(params.dateRange);
// 构建查询字符串
const queryString = new URLSearchParams(queryParams).toString();
return get<ApiResponse<PaginatedResponse<Device>>>(`${API_BASE}?${queryString}`);
},
// 删除设备(旧版本)
async deleteById(id: string): Promise<ApiResponse<void>> {
return del<ApiResponse<void>>(`${API_BASE}/${id}`);
},
// 删除设备(新版本)
async delete(id: number): Promise<ApiResponse<any>> {
return del<ApiResponse<any>>(`/v1/devices/${id}`);
},
// 重启设备
async restart(id: string): Promise<ApiResponse<void>> {
return post<ApiResponse<void>>(`${API_BASE}/${id}/restart`);
},
// 解绑设备
async unbind(id: string): Promise<ApiResponse<void>> {
return post<ApiResponse<void>>(`${API_BASE}/${id}/unbind`);
},
// 获取设备统计数据
async getStats(id: string): Promise<ApiResponse<DeviceStats>> {
return get<ApiResponse<DeviceStats>>(`${API_BASE}/${id}/stats`);
},
// 获取设备任务记录
async getTaskRecords(id: string, page = 1, pageSize = 20): Promise<ApiResponse<PaginatedResponse<DeviceTaskRecord>>> {
return get<ApiResponse<PaginatedResponse<DeviceTaskRecord>>>(`${API_BASE}/${id}/tasks?page=${page}&pageSize=${pageSize}`);
},
// 批量更新设备标签
async updateTags(ids: string[], tags: string[]): Promise<ApiResponse<void>> {
return post<ApiResponse<void>>(`${API_BASE}/tags`, { deviceIds: ids, tags });
},
// 批量导出设备数据
async exportDevices(ids: string[]): Promise<Blob> {
const response = await fetch(`${process.env.REACT_APP_API_BASE || 'http://localhost:3000/api'}${API_BASE}/export`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ deviceIds: ids }),
});
return response.blob();
},
// 检查设备在线状态
async checkStatus(ids: string[]): Promise<ApiResponse<Record<string, DeviceStatus>>> {
return post<ApiResponse<Record<string, DeviceStatus>>>(`${API_BASE}/status`, { deviceIds: ids });
},
// 获取设备关联的微信账号
async getRelatedAccounts(id: string | number): Promise<ApiResponse<any>> {
return get<ApiResponse<any>>(`/v1/wechats/related-device/${id}`);
},
// 获取设备操作记录
async getHandleLogs(id: string | number, page: number = 1, limit: number = 10): Promise<ApiResponse<any>> {
return get<ApiResponse<any>>(`/v1/devices/${id}/handle-logs?page=${page}&limit=${limit}`);
},
// 更新设备任务配置
async updateTaskConfig(config: {
deviceId: string | number;
autoAddFriend?: boolean;
autoReply?: boolean;
momentsSync?: boolean;
aiChat?: boolean;
}): Promise<ApiResponse<any>> {
return post<ApiResponse<any>>(`/v1/devices/task-config`, config);
},
// 获取设备任务配置
async getTaskConfig(id: string | number): Promise<ApiResponse<any>> {
return get<ApiResponse<any>>(`/v1/devices/${id}/task-config`);
},
};

View File

@@ -1,201 +0,0 @@
import { get, post, put, del } from './request';
// 群发推送任务类型定义
export interface GroupPushTask {
id: string;
name: string;
status: number; // 1: 运行中, 2: 已暂停
deviceCount: number;
targetGroups: string[];
pushCount: number;
successCount: number;
lastPushTime: string;
createTime: string;
creator: string;
pushInterval: number;
maxPushPerDay: number;
timeRange: { start: string; end: string };
messageType: 'text' | 'image' | 'video' | 'link';
messageContent: string;
targetTags: string[];
pushMode: 'immediate' | 'scheduled';
scheduledTime?: string;
}
// API响应类型
interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
/**
* 获取群发推送任务列表
*/
export async function fetchGroupPushTasks(): Promise<GroupPushTask[]> {
try {
const response = await get<ApiResponse<GroupPushTask[]>>('/v1/workspace/group-push/tasks');
if (response.code === 200 && Array.isArray(response.data)) {
return response.data;
}
// 如果API不可用返回模拟数据
return getMockGroupPushTasks();
} catch (error) {
console.error('获取群发推送任务失败:', error);
// 返回模拟数据作为降级方案
return getMockGroupPushTasks();
}
}
/**
* 删除群发推送任务
*/
export async function deleteGroupPushTask(id: string): Promise<ApiResponse> {
try {
const response = await del<ApiResponse>(`/v1/workspace/group-push/tasks/${id}`);
return response;
} catch (error) {
console.error('删除群发推送任务失败:', error);
throw error;
}
}
/**
* 切换群发推送任务状态
*/
export async function toggleGroupPushTask(id: string, status: string): Promise<ApiResponse> {
try {
const response = await post<ApiResponse>(`/v1/workspace/group-push/tasks/${id}/toggle`, {
status
});
return response;
} catch (error) {
console.error('切换群发推送任务状态失败:', error);
throw error;
}
}
/**
* 复制群发推送任务
*/
export async function copyGroupPushTask(id: string): Promise<ApiResponse> {
try {
const response = await post<ApiResponse>(`/v1/workspace/group-push/tasks/${id}/copy`);
return response;
} catch (error) {
console.error('复制群发推送任务失败:', error);
throw error;
}
}
/**
* 创建群发推送任务
*/
export async function createGroupPushTask(taskData: Partial<GroupPushTask>): Promise<ApiResponse> {
try {
const response = await post<ApiResponse>('/v1/workspace/group-push/tasks', taskData);
return response;
} catch (error) {
console.error('创建群发推送任务失败:', error);
throw error;
}
}
/**
* 更新群发推送任务
*/
export async function updateGroupPushTask(id: string, taskData: Partial<GroupPushTask>): Promise<ApiResponse> {
try {
const response = await put<ApiResponse>(`/v1/workspace/group-push/tasks/${id}`, taskData);
return response;
} catch (error) {
console.error('更新群发推送任务失败:', error);
throw error;
}
}
/**
* 获取群发推送任务详情
*/
export async function getGroupPushTaskDetail(id: string): Promise<GroupPushTask> {
try {
const response = await get<ApiResponse<GroupPushTask>>(`/v1/workspace/group-push/tasks/${id}`);
if (response.code === 200 && response.data) {
return response.data;
}
throw new Error(response.message || '获取任务详情失败');
} catch (error) {
console.error('获取群发推送任务详情失败:', error);
throw error;
}
}
/**
* 模拟数据 - 当API不可用时使用
*/
function getMockGroupPushTasks(): GroupPushTask[] {
return [
{
id: '1',
name: '产品推广群发',
deviceCount: 2,
targetGroups: ['VIP客户群', '潜在客户群'],
pushCount: 156,
successCount: 142,
lastPushTime: '2025-02-06 13:12:35',
createTime: '2024-11-20 19:04:14',
creator: 'admin',
status: 1, // 运行中
pushInterval: 60,
maxPushPerDay: 200,
timeRange: { start: '09:00', end: '21:00' },
messageType: 'text',
messageContent: '新品上市,限时优惠!点击查看详情...',
targetTags: ['VIP客户', '高意向'],
pushMode: 'immediate',
},
{
id: '2',
name: '活动通知推送',
deviceCount: 1,
targetGroups: ['活动群', '推广群'],
pushCount: 89,
successCount: 78,
lastPushTime: '2024-03-04 14:09:35',
createTime: '2024-03-04 14:29:04',
creator: 'manager',
status: 2, // 已暂停
pushInterval: 120,
maxPushPerDay: 100,
timeRange: { start: '10:00', end: '20:00' },
messageType: 'image',
messageContent: '活动海报.jpg',
targetTags: ['活跃用户', '中意向'],
pushMode: 'scheduled',
scheduledTime: '2024-03-05 10:00:00',
},
{
id: '3',
name: '新客户欢迎消息',
deviceCount: 3,
targetGroups: ['新客户群', '体验群'],
pushCount: 234,
successCount: 218,
lastPushTime: '2025-02-06 15:30:22',
createTime: '2024-12-01 09:15:30',
creator: 'admin',
status: 1, // 运行中
pushInterval: 30,
maxPushPerDay: 300,
timeRange: { start: '08:00', end: '22:00' },
messageType: 'text',
messageContent: '欢迎加入我们的大家庭!这里有最新的产品信息和优惠活动...',
targetTags: ['新客户', '欢迎'],
pushMode: 'immediate',
},
];
}

View File

@@ -1,14 +0,0 @@
// 导出所有API相关的内容
export * from './auth';
export * from './utils';
export * from './interceptors';
export * from './request';
// 导出现有的API模块
export * from './devices';
export * from './scenarios';
export * from './wechat-accounts';
export * from './trafficDistribution';
// 默认导出request实例
export { default as request } from './request';

View File

@@ -1,152 +0,0 @@
import { refreshAuthToken, isTokenExpiringSoon, clearToken } from './utils';
// Token过期处理
export const handleTokenExpired = () => {
if (typeof window !== 'undefined') {
// 清除本地存储
clearToken();
// 跳转到登录页面
setTimeout(() => {
window.location.href = '/login';
}, 0);
}
};
// 显示API错误但不会重定向
export const showApiError = (error: any, defaultMessage: string = '请求失败') => {
if (typeof window === 'undefined') return; // 服务端不处理
let errorMessage = defaultMessage;
// 尝试从各种可能的错误格式中获取消息
if (error) {
if (typeof error === 'string') {
errorMessage = error;
} else if (error instanceof Error) {
errorMessage = error.message || defaultMessage;
} else if (typeof error === 'object') {
// 尝试从API响应中获取错误消息
errorMessage = error.msg || error.message || error.error || defaultMessage;
}
}
// 显示错误消息
console.error('API错误:', errorMessage);
// 这里可以集成toast系统
// 由于toast context在组件层级这里暂时用console
// 实际项目中可以通过事件系统或其他方式集成
};
// 请求拦截器 - 检查token是否需要刷新
export const requestInterceptor = async (): Promise<boolean> => {
if (typeof window === 'undefined') {
return true;
}
// 检查token是否即将过期
if (isTokenExpiringSoon()) {
try {
console.log('Token即将过期尝试刷新...');
const success = await refreshAuthToken();
if (!success) {
console.log('Token刷新失败需要重新登录');
handleTokenExpired();
return false;
}
console.log('Token刷新成功');
} catch (error) {
console.error('Token刷新过程中出错:', error);
handleTokenExpired();
return false;
}
}
return true;
};
// 响应拦截器 - 处理常见错误
export const responseInterceptor = (response: any, result: any) => {
// 处理401未授权
if (response?.status === 401 || (result && result.code === 401)) {
handleTokenExpired();
throw new Error('登录已过期,请重新登录');
}
// 处理403禁止访问
if (response?.status === 403 || (result && result.code === 403)) {
throw new Error('没有权限访问此资源');
}
// 处理404未找到
if (response?.status === 404 || (result && result.code === 404)) {
throw new Error('请求的资源不存在');
}
// 处理500服务器错误
if (response?.status >= 500 || (result && result.code >= 500)) {
throw new Error('服务器内部错误,请稍后重试');
}
return result;
};
// 错误拦截器 - 统一错误处理
export const errorInterceptor = (error: any) => {
console.error('API请求错误:', error);
let errorMessage = '网络请求失败,请稍后重试';
if (error) {
if (typeof error === 'string') {
errorMessage = error;
} else if (error instanceof Error) {
errorMessage = error.message;
} else if (error.name === 'TypeError' && error.message.includes('fetch')) {
errorMessage = '网络连接失败,请检查网络设置';
} else if (error.name === 'AbortError') {
errorMessage = '请求已取消';
}
}
showApiError(error, errorMessage);
throw new Error(errorMessage);
};
// 网络状态监听
export const setupNetworkListener = () => {
if (typeof window === 'undefined') return;
const handleOnline = () => {
console.log('网络已连接');
// 可以在这里添加网络恢复后的处理逻辑
};
const handleOffline = () => {
console.log('网络已断开');
showApiError(null, '网络连接已断开,请检查网络设置');
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// 返回清理函数
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
};
// 初始化拦截器
export const initInterceptors = () => {
// 设置网络监听
const cleanupNetwork = setupNetworkListener();
// 返回清理函数
return () => {
if (cleanupNetwork) {
cleanupNetwork();
}
};
};

View File

@@ -1,111 +0,0 @@
import { get, post, del } from './request';
import {
MomentsSyncTask,
CreateMomentsSyncData,
UpdateMomentsSyncData,
SyncRecord,
ApiResponse,
PaginatedResponse
} from '@/types/moments-sync';
// 获取朋友圈同步任务列表
export async function fetchMomentsSyncTasks(): Promise<MomentsSyncTask[]> {
try {
const res = await get<ApiResponse<PaginatedResponse<MomentsSyncTask>>>('/v1/workbench/list?type=2&page=1&limit=100');
if (res.code === 200 && res.data) {
return res.data.list || [];
}
return [];
} catch (error) {
console.error('获取朋友圈同步任务失败:', error);
return [];
}
}
// 获取单个任务详情
export async function fetchMomentsSyncTaskDetail(id: string): Promise<MomentsSyncTask | null> {
try {
const res = await get<ApiResponse<MomentsSyncTask>>(`/v1/workbench/detail?id=${id}`);
if (res.code === 200 && res.data) {
return res.data;
}
return null;
} catch (error) {
console.error('获取任务详情失败:', error);
return null;
}
}
// 创建朋友圈同步任务
export async function createMomentsSyncTask(data: CreateMomentsSyncData): Promise<ApiResponse> {
return post('/v1/workbench/create', {
...data,
type: 2 // 朋友圈同步类型
});
}
// 更新朋友圈同步任务
export async function updateMomentsSyncTask(data: UpdateMomentsSyncData): Promise<ApiResponse> {
return post('/v1/workbench/update', {
...data,
type: 2 // 朋友圈同步类型
});
}
// 删除朋友圈同步任务
export async function deleteMomentsSyncTask(id: string): Promise<ApiResponse> {
return del('/v1/workbench/delete', { params: { id } });
}
// 切换任务状态
export async function toggleMomentsSyncTask(id: string, status: string): Promise<ApiResponse> {
return post('/v1/workbench/update-status', { id, status });
}
// 复制朋友圈同步任务
export async function copyMomentsSyncTask(id: string): Promise<ApiResponse> {
return post('/v1/workbench/copy', { id });
}
// 获取同步记录
export async function fetchSyncRecords(
workbenchId: string,
page: number = 1,
limit: number = 20,
keyword?: string
): Promise<PaginatedResponse<SyncRecord>> {
try {
const params = new URLSearchParams({
workbenchId,
page: page.toString(),
limit: limit.toString()
});
if (keyword) {
params.append('keyword', keyword);
}
const res = await get<ApiResponse<PaginatedResponse<SyncRecord>>>(`/v1/workbench/sync-records?${params.toString()}`);
if (res.code === 200 && res.data) {
return res.data;
}
return { list: [], total: 0, page, limit };
} catch (error) {
console.error('获取同步记录失败:', error);
return { list: [], total: 0, page, limit };
}
}
// 手动同步
export async function syncMoments(id: string): Promise<ApiResponse> {
return post('/v1/workbench/sync', { id });
}
// 同步所有任务
export async function syncAllMoments(): Promise<ApiResponse> {
return post('/v1/workbench/sync-all', { type: 2 });
}
export type { MomentsSyncTask, SyncRecord, CreateMomentsSyncData };

View File

@@ -1,73 +0,0 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { requestInterceptor, responseInterceptor, errorInterceptor } from './interceptors';
// 创建axios实例
const request: AxiosInstance = axios.create({
baseURL: process.env.REACT_APP_API_BASE_URL || 'https://ckbapi.quwanzhi.com',
timeout: 20000,
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器
request.interceptors.request.use(
async (config) => {
// 检查token是否需要刷新
if (config.headers.Authorization) {
const shouldContinue = await requestInterceptor();
if (!shouldContinue) {
throw new Error('请求被拦截,需要重新登录');
}
}
// 添加token到请求头
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
request.interceptors.response.use(
(response: AxiosResponse) => {
// 处理响应数据
const result = response.data;
const processedResult = responseInterceptor(response, result);
return processedResult;
},
(error) => {
// 统一错误处理
return errorInterceptor(error);
}
);
// 封装GET请求
export const get = <T = any>(url: string, config?: AxiosRequestConfig): Promise<T> => {
return request.get(url, config);
};
// 封装POST请求
export const post = <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> => {
return request.post(url, data, config);
};
// 封装PUT请求
export const put = <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> => {
return request.put(url, data, config);
};
// 封装DELETE请求
export const del = <T = any>(url: string, config?: AxiosRequestConfig): Promise<T> => {
return request.delete(url, config);
};
// 导出request实例
export { request };
export default request;

View File

@@ -1,311 +0,0 @@
import { get, del, post,put } from './request';
import type { ApiResponse } from '@/types/common';
// 服务器返回的场景数据类型
export interface SceneItem {
id: number;
name: string;
image: string;
status: number;
createTime: number;
updateTime: number | null;
deleteTime: number | null;
}
// 前端使用的场景数据类型
export interface Channel {
id: string;
name: string;
icon: string;
stats: {
daily: number;
growth: number;
};
link?: string;
plans?: Plan[];
}
// 计划类型
export interface Plan {
id: string;
name: string;
isNew?: boolean;
status: "active" | "paused" | "completed";
acquisitionCount: number;
}
// 任务类型
export interface Task {
id: string;
name: string;
status: number;
stats: {
devices: number;
acquired: number;
added: number;
};
lastUpdated: string;
executionTime: string;
nextExecutionTime: string;
trend: { date: string; customers: number }[];
}
// 消息内容类型
export interface MessageContent {
id: string;
type: string; // "text" | "image" | "video" | "file" | "miniprogram" | "link" | "group" 等
content?: string;
intervalUnit?: "seconds" | "minutes";
sendInterval?: number;
// 其他可选字段
[key: string]: any;
}
// 每天的消息计划
export interface MessagePlan {
day: number;
messages: MessageContent[];
}
// 海报类型
export interface Poster {
id: string;
name: string;
type: string;
preview: string;
}
// 标签类型
export interface Tag {
id: string;
name: string;
[key: string]: any;
}
// textUrl类型
export interface TextUrl {
apiKey: string;
originalString?: string;
sign?: string;
fullUrl: string;
}
// 计划详情类型
export interface PlanDetail {
id: number;
name: string;
scenario: number;
scenarioTags: Tag[];
customTags: Tag[];
posters: Poster[];
device: string[];
enabled: boolean;
addInterval: number;
remarkFormat: string;
endTime: string;
greeting: string;
startTime: string;
remarkType: string;
addFriendInterval: number;
messagePlans: MessagePlan[];
sceneId: number | string;
userId: number;
companyId: number;
status: number;
apiKey: string;
wxMinAppSrc?: any;
textUrl: TextUrl;
[key: string]: any;
}
/**
* 获取获客场景列表
*
* @param params 查询参数
* @returns 获客场景列表
*/
export const fetchScenes = async (params: {
page?: number;
limit?: number;
keyword?: string;
} = {}): Promise<ApiResponse<SceneItem[]>> => {
const { page = 1, limit = 10, keyword = "" } = params;
const queryParams = new URLSearchParams();
queryParams.append("page", String(page));
queryParams.append("limit", String(limit));
if (keyword) {
queryParams.append("keyword", keyword);
}
try {
return await get<ApiResponse<SceneItem[]>>(`/v1/plan/scenes?${queryParams.toString()}`);
} catch (error) {
console.error("Error fetching scenes:", error);
// 返回一个错误响应
return {
code: 500,
msg: "获取场景列表失败",
data: []
};
}
};
/**
* 获取场景详情
*
* @param id 场景ID
* @returns 场景详情
*/
export const fetchSceneDetail = async (id: string | number): Promise<ApiResponse<SceneItem>> => {
try {
return await get<ApiResponse<SceneItem>>(`/v1/plan/scenes/${id}`);
} catch (error) {
console.error("Error fetching scene detail:", error);
return {
code: 500,
msg: "获取场景详情失败",
data: null
};
}
};
/**
* 获取场景名称
*
* @param channel 场景标识
* @returns 场景名称
*/
export const fetchSceneName = async (channel: string): Promise<ApiResponse<{ name: string }>> => {
try {
return await get<ApiResponse<{ name: string }>>(`/v1/plan/scenes-detail?id=${channel}`);
} catch (error) {
console.error("Error fetching scene name:", error);
return {
code: 500,
msg: "获取场景名称失败",
data: { name: channel }
};
}
};
/**
* 获取计划列表
*
* @param channel 场景标识
* @param page 页码
* @param pageSize 每页数量
* @returns 计划列表
*/
export const fetchPlanList = async (
channel: string,
page: number = 1,
pageSize: number = 10
): Promise<ApiResponse<{ list: Task[]; total: number }>> => {
try {
return await get<ApiResponse<{ list: Task[]; total: number }>>(
`/v1/plan/list?sceneId=${channel}&page=${page}&pageSize=${pageSize}`
);
} catch (error) {
console.error("Error fetching plan list:", error);
return {
code: 500,
msg: "获取计划列表失败",
data: { list: [], total: 0 }
};
}
};
/**
* 复制计划
*
* @param planId 计划ID
* @returns 复制结果
*/
export const copyPlan = async (planId: string): Promise<ApiResponse<any>> => {
try {
return await get<ApiResponse<any>>(`/v1/plan/copy?planId=${planId}`);
} catch (error) {
console.error("Error copying plan:", error);
return {
code: 500,
msg: "复制计划失败",
data: null
};
}
};
/**
* 删除计划
*
* @param planId 计划ID
* @returns 删除结果
*/
export const deletePlan = async (planId: string): Promise<ApiResponse<any>> => {
try {
return await del<ApiResponse<any>>(`/v1/plan/delete?planId=${planId}`);
} catch (error) {
console.error("Error deleting plan:", error);
return {
code: 500,
msg: "删除计划失败",
data: null
};
}
};
/**
* 获取计划详情
*
* @param planId 计划ID
* @returns 计划详情
*/
export const fetchPlanDetail = async (planId: string): Promise<ApiResponse<PlanDetail>> => {
try {
return await get<ApiResponse<PlanDetail>>(`/v1/plan/detail?planId=${planId}`);
} catch (error) {
console.error("Error fetching plan detail:", error);
return {
code: 500,
msg: "获取计划详情失败",
data: null
};
}
};
/**
* 将服务器返回的场景数据转换为前端展示需要的格式
*
* @param item 服务器返回的场景数据
* @returns 前端展示的场景数据
*/
export const transformSceneItem = (item: SceneItem): Channel => {
// 为每个场景生成随机的"今日"数据和"增长百分比"
const dailyCount = Math.floor(Math.random() * 100);
const growthPercent = Math.floor(Math.random() * 40) - 10; // -10% 到 30% 的随机值
// 默认图标(如果服务器没有返回)
const defaultIcon = "/assets/icons/poster-icon.svg";
return {
id: String(item.id),
name: item.name,
icon: item.image || defaultIcon,
stats: {
daily: dailyCount,
growth: growthPercent
}
};
};
export const getPlanScenes = () => get<any>('/v1/plan/scenes');
export async function createScenarioPlan(data: any) {
return post('/v1/plan/create', data);
}
// 编辑计划
export async function updateScenarioPlan(planId: number | string, data: any) {
return await put(`/v1/plan/update?planId=${planId}`, data);
}

View File

@@ -1,227 +0,0 @@
import { get, post, put, del } from './request';
import type { ApiResponse } from '@/types/common';
// 工作台任务类型
export enum WorkbenchTaskType {
MOMENTS_SYNC = 1, // 朋友圈同步
GROUP_PUSH = 2, // 社群推送
AUTO_LIKE = 3, // 自动点赞
AUTO_GROUP = 4, // 自动建群
TRAFFIC_DISTRIBUTION = 5, // 流量分发
}
// 工作台任务状态
export enum WorkbenchTaskStatus {
PENDING = 0, // 待处理
RUNNING = 1, // 运行中
PAUSED = 2, // 已暂停
COMPLETED = 3, // 已完成
FAILED = 4, // 失败
}
// 账号类型
export interface Account {
id: string;
userName: string;
realName: string;
nickname: string;
memo: string;
}
// 账号列表响应类型
export interface AccountListResponse {
list: Account[];
total: number;
page: number;
limit: number;
}
// 流量池类型
export interface TrafficPool {
id: string;
name: string;
count: number;
description?: string;
deviceIds: string[];
createTime?: string;
updateTime?: string;
}
// 流量池列表响应类型
export interface TrafficPoolListResponse {
list: TrafficPool[];
total: number;
page: number;
pageSize: number;
}
// 流量分发规则类型
export interface DistributionRule {
id: number;
name: string;
type: number;
status: number;
autoStart: number;
createTime: string;
updateTime: string;
companyId: number;
config?: {
id: number;
workbenchId: number;
distributeType: number; // 1-均分配, 2-优先级分配, 3-比例分配
maxPerDay: number; // 每日最大分配量
timeType: number; // 1-全天, 2-自定义时间段
startTime: string; // 开始时间
endTime: string; // 结束时间
account: string[]; // 账号列表
devices: string[]; // 设备列表
pools: string[]; // 流量池列表
createTime: string;
updateTime: string;
lastUpdated: string;
total: {
dailyAverage: number; // 日均分发量
totalAccounts: number; // 分发账户总数
deviceCount: number; // 分发设备数量
poolCount: number; // 流量池数量
totalUsers: number; // 总用户数
};
};
auto_like?: any;
moments_sync?: any;
group_push?: any;
}
// 流量分发列表响应类型
export interface TrafficDistributionListResponse {
list: DistributionRule[];
total: number;
page: number;
limit: number;
}
/**
* 获取账号列表
* @param params 查询参数
* @returns 账号列表
*/
export const fetchAccountList = async (params: {
page?: number; // 页码
limit?: number; // 每页数量
keyword?: string; // 搜索关键词
} = {}): Promise<ApiResponse<AccountListResponse>> => {
const { page = 1, limit = 10, keyword = "" } = params;
const queryParams = new URLSearchParams();
queryParams.append('page', page.toString());
queryParams.append('limit', limit.toString());
if (keyword) {
queryParams.append('keyword', keyword);
}
return get<ApiResponse<AccountListResponse>>(`/v1/workbench/account-list?${queryParams.toString()}`);
};
/**
* 获取设备标签(流量池)列表
* @param params 查询参数
* @returns 流量池列表
*/
export const fetchDeviceLabels = async (params: {
deviceIds: string[]; // 设备ID列表
page?: number; // 页码
pageSize?: number; // 每页数量
keyword?: string; // 搜索关键词
}): Promise<ApiResponse<TrafficPoolListResponse>> => {
const { deviceIds, page = 1, pageSize = 10, keyword = "" } = params;
const queryParams = new URLSearchParams();
queryParams.append('deviceIds', deviceIds.join(','));
queryParams.append('page', page.toString());
queryParams.append('pageSize', pageSize.toString());
if (keyword) {
queryParams.append('keyword', keyword);
}
return get<ApiResponse<TrafficPoolListResponse>>(`/v1/workbench/device-labels?${queryParams.toString()}`);
};
/**
* 获取流量分发规则列表
* @param params 查询参数
* @returns 流量分发规则列表
*/
export const fetchDistributionRules = async (params: {
page?: number;
limit?: number;
keyword?: string;
} = {}): Promise<ApiResponse<TrafficDistributionListResponse>> => {
const { page = 1, limit = 10, keyword = "" } = params;
const queryParams = new URLSearchParams();
queryParams.append('type', WorkbenchTaskType.TRAFFIC_DISTRIBUTION.toString());
queryParams.append('page', page.toString());
queryParams.append('limit', limit.toString());
if (keyword) {
queryParams.append('keyword', keyword);
}
return get<ApiResponse<TrafficDistributionListResponse>>(`/v1/workbench/list?${queryParams.toString()}`);
};
/**
* 获取流量分发规则详情
* @param id 规则ID
* @returns 流量分发规则详情
*/
export const fetchDistributionRuleDetail = async (id: string): Promise<ApiResponse<DistributionRule>> => {
return get<ApiResponse<DistributionRule>>(`/v1/workbench/detail?id=${id}`);
};
/**
* 创建流量分发规则
* @param params 创建参数
* @returns 创建结果
*/
export const createDistributionRule = async (params: any): Promise<ApiResponse<{ id: string }>> => {
return post<ApiResponse<{ id: string }>>('/v1/workbench/create', {
...params,
type: WorkbenchTaskType.TRAFFIC_DISTRIBUTION
});
};
/**
* 更新流量分发规则
* @param id 规则ID
* @param params 更新参数
* @returns 更新结果
*/
export const updateDistributionRule = async (id : string, params: any): Promise<ApiResponse<any>> => {
return post<ApiResponse<any>>(`/v1/workbench/update`, {
id: id,
...params,
type: WorkbenchTaskType.TRAFFIC_DISTRIBUTION
});
};
/**
* 删除流量分发规则
* @param id 规则ID
* @returns 删除结果
*/
export const deleteDistributionRule = async (id: string): Promise<ApiResponse<any>> => {
return del<ApiResponse<any>>(`/v1/workbench/delete?id=${id}`);
};
/**
* 启动/暂停流量分发规则
* @param id 规则ID
* @param status 状态1-启动0-暂停
* @returns 操作结果
*/
export const toggleDistributionRuleStatus = async (id: string, status: 0 | 1): Promise<ApiResponse<any>> => {
return post<ApiResponse<any>>('/v1/workbench/update-status', { id, status });
};

View File

@@ -1,18 +0,0 @@
import { request } from './request';
import type { AxiosResponse } from 'axios';
// 上传图片,返回图片地址
export async function uploadImage(file: File): Promise<string> {
const formData = new FormData();
formData.append('file', file);
const response: AxiosResponse<any> = await request.post('/v1/attachment/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
const res = response.data || response;
if (res?.url) {
return res.url;
}
throw new Error(res?.msg || '图片上传失败');
}

View File

@@ -1,195 +0,0 @@
import { authApi } from './auth';
import { get, post, put, del } from './request';
import type { ApiResponse, PaginatedResponse } from '@/types/common';
// 设置token到localStorage
export const setToken = (token: string) => {
if (typeof window !== 'undefined') {
localStorage.setItem('token', token);
}
};
// 获取token
export const getToken = (): string | null => {
if (typeof window !== 'undefined') {
return localStorage.getItem('token');
}
return null;
};
// 清除token
export const clearToken = () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
localStorage.removeItem('token_expired');
localStorage.removeItem('s2_accountId');
}
};
// 验证token是否有效
export const validateToken = async (): Promise<boolean> => {
try {
const response = await authApi.getUserInfo();
return response.code === 200;
} catch (error) {
console.error('Token验证失败:', error);
return false;
}
};
// 刷新令牌
export const refreshAuthToken = async (): Promise<boolean> => {
if (typeof window === 'undefined') {
return false;
}
try {
const response = await authApi.refreshToken();
if (response.code === 200 && response.data?.token) {
setToken(response.data.token);
// 更新过期时间
if (response.data.token_expired) {
localStorage.setItem('token_expired', response.data.token_expired);
}
return true;
}
return false;
} catch (error) {
console.error('刷新Token失败:', error);
return false;
}
};
// 检查token是否即将过期
export const isTokenExpiringSoon = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
const tokenExpired = localStorage.getItem('token_expired');
if (!tokenExpired) return true;
try {
const expiredTime = new Date(tokenExpired).getTime();
const currentTime = new Date().getTime();
// 提前10分钟认为即将过期
return currentTime >= (expiredTime - 10 * 60 * 1000);
} catch (error) {
console.error('解析token过期时间失败:', error);
return true;
}
};
// 检查token是否已过期
export const isTokenExpired = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
const tokenExpired = localStorage.getItem('token_expired');
if (!tokenExpired) return true;
try {
const expiredTime = new Date(tokenExpired).getTime();
const currentTime = new Date().getTime();
// 提前5分钟认为过期给刷新留出时间
return currentTime >= (expiredTime - 5 * 60 * 1000);
} catch (error) {
console.error('解析token过期时间失败:', error);
return true;
}
};
// 请求去重器
class RequestDeduplicator {
private pendingRequests = new Map<string, Promise<any>>();
async deduplicate<T>(key: string, requestFn: () => Promise<T>): Promise<T> {
if (this.pendingRequests.has(key)) {
return this.pendingRequests.get(key)!;
}
const promise = requestFn();
this.pendingRequests.set(key, promise);
try {
const result = await promise;
return result;
} finally {
this.pendingRequests.delete(key);
}
}
getPendingCount(): number {
return this.pendingRequests.size;
}
clear(): void {
this.pendingRequests.clear();
}
}
// 请求取消管理器
class RequestCancelManager {
private abortControllers = new Map<string, AbortController>();
createController(key: string): AbortController {
// 取消之前的请求
this.cancelRequest(key);
const controller = new AbortController();
this.abortControllers.set(key, controller);
return controller;
}
cancelRequest(key: string): void {
const controller = this.abortControllers.get(key);
if (controller) {
controller.abort();
this.abortControllers.delete(key);
}
}
cancelAllRequests(): void {
this.abortControllers.forEach(controller => controller.abort());
this.abortControllers.clear();
}
getController(key: string): AbortController | undefined {
return this.abortControllers.get(key);
}
}
// 导出单例实例
export const requestDeduplicator = new RequestDeduplicator();
export const requestCancelManager = new RequestCancelManager();
/**
* 通用文件上传方法(支持图片、文件)
* @param {File} file - 要上传的文件对象
* @param {string} [uploadUrl='/v1/attachment/upload'] - 上传接口地址
* @returns {Promise<string>} - 上传成功后返回文件url
*/
export async function uploadFile(file: File, uploadUrl: string = '/v1/attachment/upload'): Promise<string> {
try {
// 创建 FormData 对象用于文件上传
const formData = new FormData();
formData.append('file', file);
// 使用 post 方法上传文件,设置正确的 Content-Type
const res = await post(uploadUrl, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
// 检查响应结果
if (res?.code === 200 && res?.data?.url) {
return res.data.url;
} else {
throw new Error(res?.msg || '文件上传失败');
}
} catch (e: any) {
throw new Error(e?.message || '文件上传失败');
}
}

View File

@@ -1,207 +0,0 @@
import { get, post, put } from './request';
import type { ApiResponse } from '@/types/common';
// 添加接口返回数据类型定义
interface WechatAccountSummary {
accountAge: string;
activityLevel: {
allTimes: number;
dayTimes: number;
};
accountWeight: {
scope: number;
ageWeight: number;
activityWeigth: number;
restrictWeight: number;
realNameWeight: number;
};
statistics: {
todayAdded: number;
addLimit: number;
};
restrictions: {
id: number;
level: string;
reason: string;
date: string;
}[];
}
interface QueryWechatAccountParams {
page?: number;
limit?: number;
keyword?: string;
sort?: string;
order?: string;
}
/**
* 获取微信账号列表
* @param params 查询参数
* @returns 微信账号列表响应
*/
export const fetchWechatAccountList = async (params: QueryWechatAccountParams = {}): Promise<ApiResponse<{
list: any[];
total: number;
page: number;
limit: number;
}>> => {
const queryParams = new URLSearchParams();
// 添加查询参数
if (params.page) queryParams.append('page', params.page.toString());
if (params.limit) queryParams.append('limit', params.limit.toString());
if (params.keyword) queryParams.append('nickname', params.keyword); // 使用nickname作为关键词搜索参数
if (params.sort) queryParams.append('sort', params.sort);
if (params.order) queryParams.append('order', params.order);
// 发起API请求
return get<ApiResponse<{
list: any[];
total: number;
page: number;
limit: number;
}>>(`/v1/wechats?${queryParams.toString()}`);
};
/**
* 刷新微信账号状态
* @returns 刷新结果
*/
export const refreshWechatAccounts = async (): Promise<ApiResponse<any>> => {
return put<ApiResponse<any>>('/v1/wechats/refresh', {});
};
/**
* 执行微信好友转移
* @param sourceId 源微信账号ID
* @param targetId 目标微信账号ID
* @returns 转移结果
*/
export const transferWechatFriends = async (sourceId: string | number, targetId: string | number): Promise<ApiResponse<any>> => {
return post<ApiResponse<any>>('/v1/wechats/transfer-friends', {
source_id: sourceId,
target_id: targetId
});
};
/**
* 将服务器返回的微信账号数据转换为前端使用的格式
* @param serverAccount 服务器返回的微信账号数据
* @returns 前端使用的微信账号数据
*/
export const transformWechatAccount = (serverAccount: any): any => {
// 从deviceInfo中提取设备信息
let deviceName = '';
if (serverAccount.deviceInfo) {
// 尝试解析设备信息字符串
const deviceInfo = serverAccount.deviceInfo.split(' ');
if (deviceInfo.length > 0) {
// 提取设备名称
if (deviceInfo.length > 1) {
deviceName = deviceInfo[1] ? deviceInfo[1].replace(/[()]/g, '').trim() : '';
}
}
}
// 如果没有设备名称,使用备用名称
if (!deviceName) {
deviceName = serverAccount.deviceMemo || '未命名设备';
}
// 假设每天最多可添加20个好友
const maxDailyAdds = 20;
const todayAdded = serverAccount.todayNewFriendCount || 0;
return {
id: serverAccount.id.toString(),
avatar: serverAccount.avatar || '',
nickname: serverAccount.nickname || serverAccount.accountNickname || '未命名',
wechatId: serverAccount.wechatId || '',
deviceId: serverAccount.deviceId || '',
deviceName,
friendCount: serverAccount.totalFriend || 0,
todayAdded,
remainingAdds: serverAccount.canAddFriendCount || (maxDailyAdds - todayAdded),
maxDailyAdds,
status: serverAccount.wechatStatus === 1 ? "normal" : "abnormal" as "normal" | "abnormal",
lastActive: new Date().toLocaleString() // 服务端未提供,使用当前时间
};
};
/**
* 获取微信好友列表
* @param wechatId 微信账号ID
* @param page 页码
* @param pageSize 每页数量
* @param searchQuery 搜索关键词
* @returns 好友列表数据
*/
export const fetchWechatFriends = async (wechatId: string, page: number = 1, pageSize: number = 20, searchQuery: string = ''): Promise<ApiResponse<{
list: any[];
total: number;
page: number;
limit: number;
}>> => {
try {
const queryParams = new URLSearchParams();
queryParams.append('page', page.toString());
queryParams.append('limit', pageSize.toString());
if (searchQuery) {
queryParams.append('search', searchQuery);
}
return get<ApiResponse<{
list: any[];
total: number;
page: number;
limit: number;
}>>(`/v1/wechats/${wechatId}/friends?${queryParams.toString()}`);
} catch (error) {
console.error("获取好友列表失败:", error);
throw error;
}
};
/**
* 获取微信账号概览信息
* @param id 微信账号ID
* @returns 微信账号概览信息
*/
export const fetchWechatAccountSummary = async (wechatId: string): Promise<ApiResponse<WechatAccountSummary>> => {
try {
return get<ApiResponse<WechatAccountSummary>>(`/v1/wechats/${wechatId}/summary`);
} catch (error) {
console.error("获取账号概览失败:", error);
throw error;
}
};
/**
* 获取好友详情信息
* @param wechatId 微信账号ID
* @param friendId 好友ID
* @returns 好友详情信息
*/
export interface WechatFriendDetail {
id: number;
avatar: string;
nickname: string;
region: string;
wechatId: string;
addDate: string;
tags: string[];
memo: string;
source: string;
}
export const fetchWechatFriendDetail = async (wechatId: string): Promise<ApiResponse<WechatFriendDetail>> => {
try {
return get<ApiResponse<WechatFriendDetail>>(`/v1/wechats/${wechatId}/friend-detail`);
} catch (error) {
console.error("获取好友详情失败:", error);
throw error;
}
};

View File

@@ -1,92 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronLeft, ArrowLeft } from 'lucide-react';
interface BackButtonProps {
/** 返回按钮的样式变体 */
variant?: 'icon' | 'button' | 'text';
/** 自定义返回逻辑如果不提供则使用navigate(-1) */
onBack?: () => void;
/** 按钮文本仅在button和text变体时使用 */
text?: string;
/** 自定义CSS类名 */
className?: string;
/** 图标大小 */
iconSize?: number;
/** 是否显示图标 */
showIcon?: boolean;
/** 自定义图标 */
icon?: React.ReactNode;
}
/**
* 通用返回上一页按钮组件
* 使用React Router的navigate方法实现返回功能
*/
export const BackButton: React.FC<BackButtonProps> = ({
variant = 'icon',
onBack,
text = '返回',
className = '',
iconSize = 6,
showIcon = true,
icon
}) => {
const navigate = useNavigate();
const handleBack = () => {
if (onBack) {
onBack();
} else {
navigate(-1);
}
};
const defaultIcon = variant === 'icon' ? (
<ChevronLeft className={`h-${iconSize} w-${iconSize}`} />
) : (
<ArrowLeft className={`h-${iconSize} w-${iconSize}`} />
);
const buttonIcon = icon || (showIcon ? defaultIcon : null);
switch (variant) {
case 'icon':
return (
<button
onClick={handleBack}
className={`p-2 hover:bg-gray-100 rounded-lg transition-colors ${className}`}
title="返回上一页"
>
{buttonIcon}
</button>
);
case 'button':
return (
<button
onClick={handleBack}
className={`flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors ${className}`}
>
{buttonIcon}
{text}
</button>
);
case 'text':
return (
<button
onClick={handleBack}
className={`flex items-center gap-2 text-blue-600 hover:text-blue-700 transition-colors ${className}`}
>
{buttonIcon}
{text}
</button>
);
default:
return null;
}
};
export default BackButton;

View File

@@ -1,66 +0,0 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Home, Users, LayoutGrid, User } from 'lucide-react';
const navItems = [
{
id: "home",
name: "首页",
href: "/",
icon: Home,
active: (pathname: string) => pathname === "/",
},
{
id: "scenarios",
name: "场景获客",
href: "/scenarios",
icon: Users,
active: (pathname: string) => pathname.startsWith("/scenarios"),
},
{
id: "workspace",
name: "工作台",
href: "/workspace",
icon: LayoutGrid,
active: (pathname: string) => pathname.startsWith("/workspace"),
},
{
id: "profile",
name: "我的",
href: "/profile",
icon: User,
active: (pathname: string) => pathname.startsWith("/profile"),
},
];
interface BottomNavProps {
activeTab?: string;
}
export default function BottomNav({ activeTab }: BottomNavProps) {
const location = useLocation();
return (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 safe-area-pb">
<div className="flex justify-around items-center h-16 max-w-md mx-auto">
{navItems.map((item) => {
const IconComponent = item.icon;
const isActive = activeTab ? activeTab === item.id : item.active(location.pathname);
return (
<Link
key={item.href}
to={item.href}
className={`flex flex-col items-center justify-center flex-1 h-full transition-colors ${
isActive ? "text-blue-500" : "text-gray-500 hover:text-gray-900"
}`}
>
<IconComponent className="w-5 h-5" />
<span className="text-xs mt-1">{item.name}</span>
</Link>
);
})}
</div>
</div>
);
}

View File

@@ -1,210 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Search, RefreshCw, Loader2 } from "lucide-react";
import { fetchContentLibraryList } from "@/api/content";
import { ContentLibrary } from "@/api/content";
import { useToast } from "@/components/ui/toast";
interface ContentLibrarySelectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedLibraries: string[];
onSelect: (libraries: string[]) => void;
}
export function ContentLibrarySelectionDialog({
open,
onOpenChange,
selectedLibraries,
onSelect,
}: ContentLibrarySelectionDialogProps) {
const { toast } = useToast();
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(false);
const [libraries, setLibraries] = useState<ContentLibrary[]>([]);
const [tempSelected, setTempSelected] = useState<string[]>([]);
// 获取内容库列表
const fetchLibraries = useCallback(async () => {
setLoading(true);
try {
const response = await fetchContentLibraryList(1, 100, searchQuery);
if (response.code === 200 && response.data) {
setLibraries(response.data.list);
} else {
toast({
title: "获取内容库列表失败",
description: response.msg,
variant: "destructive",
});
}
} catch (error) {
console.error("获取内容库列表失败:", error);
toast({
title: "获取内容库列表失败",
description: "请检查网络连接",
variant: "destructive",
});
} finally {
setLoading(false);
}
}, [searchQuery, toast]);
useEffect(() => {
if (open) {
fetchLibraries();
setTempSelected(selectedLibraries);
}
}, [open, selectedLibraries, fetchLibraries]);
const handleRefresh = () => {
fetchLibraries();
};
const handleSelectAll = () => {
if (tempSelected.length === libraries.length) {
setTempSelected([]);
} else {
setTempSelected(libraries.map((lib) => lib.id));
}
};
const handleLibraryToggle = (libraryId: string) => {
setTempSelected((prev) =>
prev.includes(libraryId)
? prev.filter((id) => id !== libraryId)
: [...prev, libraryId]
);
};
const handleDialogOpenChange = (open: boolean) => {
if (!open) {
setTempSelected(selectedLibraries);
}
onOpenChange(open);
};
const handleConfirm = () => {
onSelect(tempSelected);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent className="flex flex-col bg-white">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex items-center space-x-2 my-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索内容库"
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={loading}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
<div className="flex justify-between items-center mb-2">
<div className="text-sm text-gray-500">
{tempSelected.length}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleSelectAll}
disabled={loading || libraries.length === 0}
>
{tempSelected.length === libraries.length ? "取消全选" : "全选"}
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto -mx-6 px-6 max-h-[400px]">
<div className="space-y-2">
{loading ? (
<div className="flex items-center justify-center h-full text-gray-500">
...
</div>
) : libraries.length === 0 ? (
<div className="flex items-center justify-center h-full text-gray-500">
</div>
) : (
libraries.map((library) => (
<label
key={library.id}
className="flex items-start space-x-3 p-4 rounded-lg hover:bg-gray-50 cursor-pointer border"
>
<input
type="checkbox"
checked={tempSelected.includes(library.id)}
onChange={() => handleLibraryToggle(library.id)}
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="font-medium">{library.name}</span>
<Badge variant="outline">
{library.sourceType === 1
? "文本"
: library.sourceType === 2
? "图片"
: "视频"}
</Badge>
</div>
<div className="text-sm text-gray-500 mt-1">
<div>: {library.creatorName || "-"}</div>
<div>
:{" "}
{new Date(library.updateTime).toLocaleString()}
</div>
</div>
</div>
</label>
))
)}
</div>
</div>
<div className="flex justify-between items-center mt-4 pt-4 border-t">
<div className="text-sm text-gray-500">
{tempSelected.length}
</div>
<div className="flex space-x-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleConfirm}>
{tempSelected.length > 0 ? ` (${tempSelected.length})` : ""}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,205 +0,0 @@
import React, { useState, useEffect } from "react";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { fetchDeviceList } from "@/api/devices";
// 设备选择项接口
interface DeviceSelectionItem {
id: string;
name: string;
imei: string;
wechatId: string;
status: "online" | "offline";
}
// 组件属性接口
interface DeviceSelectionProps {
selectedDevices: string[];
onSelect: (devices: string[]) => void;
placeholder?: string;
className?: string;
}
export default function DeviceSelection({
selectedDevices,
onSelect,
placeholder = "选择设备",
className = "",
}: DeviceSelectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
// 获取设备列表支持keyword
const fetchDevices = async (keyword: string = "") => {
setLoading(true);
try {
const res = await fetchDeviceList(1, 100, keyword.trim() || undefined);
if (res && res.data && Array.isArray(res.data.list)) {
setDevices(
res.data.list.map((d) => ({
id: d.id?.toString() || "",
name: d.memo || d.imei || "",
imei: d.imei || "",
wechatId: d.wechatId || "",
status: d.alive === 1 ? "online" : "offline",
}))
);
}
} catch (error) {
console.error("获取设备列表失败:", error);
} finally {
setLoading(false);
}
};
// 打开弹窗时获取设备列表
const openDialog = () => {
setSearchQuery("");
setDialogOpen(true);
fetchDevices("");
};
// 搜索防抖
useEffect(() => {
if (!dialogOpen) return;
const timer = setTimeout(() => {
fetchDevices(searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, dialogOpen]);
// 过滤设备(只保留状态过滤)
const filteredDevices = devices.filter((device) => {
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "online" && device.status === "online") ||
(statusFilter === "offline" && device.status === "offline");
return matchesStatus;
});
// 处理设备选择
const handleDeviceToggle = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) {
onSelect(selectedDevices.filter((id) => id !== deviceId));
} else {
onSelect([...selectedDevices, deviceId]);
}
};
// 获取显示文本
const getDisplayText = () => {
if (selectedDevices.length === 0) return "";
return `已选择 ${selectedDevices.length} 个设备`;
};
return (
<>
{/* 输入框 */}
<div className={`relative ${className}`}>
<Search className="absolute left-3 top-4 h-5 w-5 text-gray-400" />
<Input
placeholder={placeholder}
className="pl-10 h-14 rounded-xl border-gray-200 text-base"
readOnly
onClick={openDialog}
value={getDisplayText()}
/>
</div>
{/* 设备选择弹窗 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col bg-white">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex items-center space-x-4 my-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索设备IMEI/备注/微信号"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-32 h-10 rounded border border-gray-300 px-2 text-base"
>
<option value="all"></option>
<option value="online">线</option>
<option value="offline">线</option>
</select>
</div>
<ScrollArea className="flex-1 -mx-6 px-6 h-80 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">...</div>
</div>
) : (
<div className="space-y-2">
{filteredDevices.map((device) => (
<label
key={device.id}
className="flex items-start space-x-3 p-4 rounded-lg hover:bg-gray-50 cursor-pointer"
>
<Checkbox
checked={selectedDevices.includes(device.id)}
onCheckedChange={() => handleDeviceToggle(device.id)}
className="mt-1"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="font-medium">{device.name}</span>
<div
className={`w-16 h-6 flex items-center justify-center text-xs ${
device.status === "online"
? "bg-green-500 text-white"
: "bg-gray-200 text-gray-600"
}`}
>
{device.status === "online" ? "在线" : "离线"}
</div>
</div>
<div className="text-sm text-gray-500 mt-1">
<div>IMEI: {device.imei}</div>
<div>: {device.wechatId}</div>
</div>
</div>
</label>
))}
</div>
)}
</ScrollArea>
<div className="flex items-center justify-between pt-4 border-t">
<div className="text-sm text-gray-500">
{selectedDevices.length}
</div>
<div className="flex space-x-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>
</Button>
<Button onClick={() => setDialogOpen(false)}></Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,230 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Search, RefreshCw, Loader2 } from "lucide-react";
import { fetchDeviceList } from "@/api/devices";
import { ServerDevice } from "@/types/device";
import { useToast } from "@/components/ui/toast";
interface Device {
id: string;
name: string;
imei: string;
wxid: string;
status: "online" | "offline";
usedInPlans: number;
nickname: string;
}
interface DeviceSelectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedDevices: string[];
onSelect: (devices: string[]) => void;
}
export function DeviceSelectionDialog({
open,
onOpenChange,
selectedDevices,
onSelect,
}: DeviceSelectionDialogProps) {
const { toast } = useToast();
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
const [devices, setDevices] = useState<Device[]>([]);
// 获取设备列表支持keyword
const fetchDevices = useCallback(
async (keyword: string = "") => {
setLoading(true);
try {
const response = await fetchDeviceList(
1,
100,
keyword.trim() || undefined
);
if (response.code === 200 && response.data) {
// 转换服务端数据格式为组件需要的格式
const convertedDevices: Device[] = response.data.list.map(
(serverDevice: ServerDevice) => ({
id: serverDevice.id.toString(),
name: serverDevice.memo || `设备 ${serverDevice.id}`,
imei: serverDevice.imei,
wxid: serverDevice.wechatId || "",
status: serverDevice.alive === 1 ? "online" : "offline",
usedInPlans: 0, // 这个字段需要从其他API获取
nickname: serverDevice.nickname || "",
})
);
setDevices(convertedDevices);
} else {
toast({
title: "获取设备列表失败",
description: response.msg,
variant: "destructive",
});
}
} catch (error) {
console.error("获取设备列表失败:", error);
toast({
title: "获取设备列表失败",
description: "请检查网络连接",
variant: "destructive",
});
} finally {
setLoading(false);
}
},
[toast]
);
// 打开弹窗时获取设备列表
useEffect(() => {
if (open) {
fetchDevices("");
}
}, [open, fetchDevices]);
// 搜索防抖
useEffect(() => {
if (!open) return;
const timer = setTimeout(() => {
fetchDevices(searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, open, fetchDevices]);
// 过滤设备(只保留状态过滤)
const filteredDevices = devices.filter((device) => {
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "online" && device.status === "online") ||
(statusFilter === "offline" && device.status === "offline");
return matchesStatus;
});
const handleDeviceSelect = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) {
onSelect(selectedDevices.filter((id) => id !== deviceId));
} else {
onSelect([...selectedDevices, deviceId]);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex flex-col bg-white">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex items-center space-x-4 my-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索设备IMEI/备注"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-32 px-3 py-2 border border-gray-300 rounded-md text-sm"
>
<option value="all"></option>
<option value="online">线</option>
<option value="offline">线</option>
</select>
<Button
variant="outline"
size="icon"
onClick={() => fetchDevices(searchQuery)}
disabled={loading}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
<div className="flex-1 overflow-y-auto -mx-6 px-6 max-h-[400px]">
<div className="space-y-2">
{loading ? (
<div className="flex items-center justify-center h-full text-gray-500">
...
</div>
) : filteredDevices.length === 0 ? (
<div className="flex items-center justify-center h-full text-gray-500">
</div>
) : (
filteredDevices.map((device) => (
<label
key={device.id}
className="flex items-start space-x-3 p-4 rounded-lg hover:bg-gray-50 cursor-pointer border"
>
<input
type="checkbox"
checked={selectedDevices.includes(device.id)}
onChange={() => handleDeviceSelect(device.id)}
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="font-medium">{device.name}</span>
<Badge
variant={
device.status === "online" ? "default" : "secondary"
}
>
{device.status === "online" ? "在线" : "离线"}
</Badge>
</div>
<div className="text-sm text-gray-500 mt-1">
<div>IMEI: {device.imei}</div>
<div>: {device.wxid || "-"}</div>
<div>: {device.nickname || "-"}</div>
</div>
{device.usedInPlans > 0 && (
<div className="text-sm text-orange-500 mt-1">
{device.usedInPlans}
</div>
)}
</div>
</label>
))
)}
</div>
</div>
<div className="flex justify-between items-center mt-4 pt-4 border-t">
<div className="text-sm text-gray-500">
{selectedDevices.length}
</div>
<div className="flex space-x-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={() => onOpenChange(false)}>
{selectedDevices.length > 0 ? ` (${selectedDevices.length})` : ""}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,375 +0,0 @@
import React, { useState, useEffect } from "react";
import { Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { get } from "@/api/request";
// 微信好友接口类型
interface WechatFriend {
id: string;
nickname: string;
wechatId: string;
avatar: string;
customer: string;
}
// 好友列表API响应类型
interface FriendsResponse {
code: number;
msg: string;
data: {
list: Array<{
id: number;
nickname: string;
wechatId: string;
avatar?: string;
customer?: string;
}>;
total: number;
page: number;
limit: number;
};
}
// 获取好友列表API函数 - 添加 keyword 参数
const fetchFriendsList = async (params: {
page: number;
limit: number;
deviceIds?: string[];
keyword?: string;
}): Promise<FriendsResponse> => {
if (params.deviceIds && params.deviceIds.length === 0) {
return {
code: 200,
msg: "success",
data: {
list: [],
total: 0,
page: params.page,
limit: params.limit,
},
};
}
const deviceIdsParam = params?.deviceIds?.join(",") || "";
const keywordParam = params?.keyword
? `&keyword=${encodeURIComponent(params.keyword)}`
: "";
return get<FriendsResponse>(
`/v1/friend?page=${params.page}&limit=${params.limit}&deviceIds=${deviceIdsParam}${keywordParam}`
);
};
// 组件属性接口
interface FriendSelectionProps {
selectedFriends: string[];
onSelect: (friends: string[]) => void;
onSelectDetail?: (friends: WechatFriend[]) => void; // 新增
deviceIds?: string[];
enableDeviceFilter?: boolean; // 新增开关默认true
placeholder?: string;
className?: string;
}
export default function FriendSelection({
selectedFriends,
onSelect,
onSelectDetail,
deviceIds = [],
enableDeviceFilter = true,
placeholder = "选择微信好友",
className = "",
}: FriendSelectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [friends, setFriends] = useState<WechatFriend[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalFriends, setTotalFriends] = useState(0);
const [loading, setLoading] = useState(false);
// 打开弹窗并请求第一页好友
const openDialog = () => {
setCurrentPage(1);
setSearchQuery(""); // 重置搜索关键词
setDialogOpen(true);
fetchFriends(1, "");
};
// 当页码变化时,拉取对应页数据(弹窗已打开时)
useEffect(() => {
if (dialogOpen && currentPage !== 1) {
fetchFriends(currentPage, searchQuery);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]);
// 搜索防抖
useEffect(() => {
if (!dialogOpen) return;
const timer = setTimeout(() => {
setCurrentPage(1); // 重置到第一页
fetchFriends(1, searchQuery);
}, 500); // 500 防抖
return () => clearTimeout(timer);
}, [searchQuery, dialogOpen]);
// 获取好友列表API - 添加 keyword 参数
const fetchFriends = async (page: number, keyword: string = "") => {
setLoading(true);
try {
let res;
if (enableDeviceFilter) {
if (deviceIds.length === 0) {
setFriends([]);
setTotalFriends(0);
setTotalPages(1);
setLoading(false);
return;
}
res = await fetchFriendsList({
page,
limit: 20,
deviceIds: deviceIds,
keyword: keyword.trim() || undefined,
});
} else {
res = await fetchFriendsList({
page,
limit: 20,
keyword: keyword.trim() || undefined,
});
}
if (res && res.code === 200 && res.data) {
setFriends(
res.data.list.map((friend) => ({
id: friend.id?.toString() || "",
nickname: friend.nickname || "",
wechatId: friend.wechatId || "",
avatar: friend.avatar || "",
customer: friend.customer || "",
}))
);
setTotalFriends(res.data.total || 0);
setTotalPages(Math.ceil((res.data.total || 0) / 20));
}
} catch (error) {
console.error("获取好友列表失败:", error);
} finally {
setLoading(false);
}
};
// 处理好友选择
const handleFriendToggle = (friendId: string) => {
let newIds: string[];
if (selectedFriends.includes(friendId)) {
newIds = selectedFriends.filter((id) => id !== friendId);
} else {
newIds = [...selectedFriends, friendId];
}
onSelect(newIds);
if (onSelectDetail) {
const selectedObjs = friends.filter((f) => newIds.includes(f.id));
onSelectDetail(selectedObjs);
}
};
// 获取显示文本
const getDisplayText = () => {
if (selectedFriends.length === 0) return "";
return `已选择 ${selectedFriends.length} 个好友`;
};
const handleConfirm = () => {
setDialogOpen(false);
};
// 清空搜索
const handleClearSearch = () => {
setSearchQuery("");
setCurrentPage(1);
fetchFriends(1, "");
};
return (
<>
{/* 输入框 */}
<div className={`relative ${className}`}>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<svg
width="20"
height="20"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</span>
<Input
placeholder={placeholder}
className="pl-10 h-12 rounded-xl border-gray-200 text-base"
readOnly
onClick={openDialog}
value={getDisplayText()}
/>
</div>
{/* 微信好友选择弹窗 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden bg-white">
<div className="p-6">
<DialogTitle className="text-center text-xl font-medium mb-6">
</DialogTitle>
<div className="relative mb-4">
<Input
placeholder="搜索好友"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 py-2 rounded-full border-gray-200"
/>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 h-6 w-6 rounded-full"
onClick={handleClearSearch}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto h-[50vh]">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">...</div>
</div>
) : friends.length > 0 ? (
<div className="divide-y">
{friends.map((friend) => (
<label
key={friend.id}
className="flex items-center px-6 py-4 hover:bg-gray-50 cursor-pointer"
onClick={() => handleFriendToggle(friend.id)}
>
<div className="mr-3 flex items-center justify-center">
<div
className={`w-5 h-5 rounded-full border ${
selectedFriends.includes(friend.id)
? "border-blue-600"
: "border-gray-300"
} flex items-center justify-center`}
>
{selectedFriends.includes(friend.id) && (
<div className="w-3 h-3 rounded-full bg-blue-600"></div>
)}
</div>
</div>
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-medium overflow-hidden">
{friend.avatar ? (
<img
src={friend.avatar}
alt={friend.nickname}
className="w-full h-full object-cover"
/>
) : (
friend.nickname.charAt(0)
)}
</div>
<div className="flex-1">
<div className="font-medium">{friend.nickname}</div>
<div className="text-sm text-gray-500">
ID: {friend.wechatId}
</div>
{friend.customer && (
<div className="text-sm text-gray-400">
: {friend.customer}
</div>
)}
</div>
</div>
</label>
))}
</div>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">
{deviceIds.length === 0
? "请先选择设备"
: searchQuery
? `没有找到包含"${searchQuery}"的好友`
: "没有找到好友"}
</div>
</div>
)}
</div>
<div className="border-t p-4 flex items-center justify-between bg-white">
<div className="text-sm text-gray-500">
{totalFriends}
</div>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className="px-2 py-0 h-8 min-w-0"
>
&lt;
</Button>
<span className="text-sm">
{currentPage} / {totalPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages || loading}
className="px-2 py-0 h-8 min-w-0"
>
&gt;
</Button>
</div>
</div>
<div className="border-t p-4 flex items-center justify-between bg-white">
<Button
variant="outline"
onClick={() => setDialogOpen(false)}
className="px-6 rounded-full border-gray-300"
>
</Button>
<Button
onClick={handleConfirm}
className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full"
>
({selectedFriends.length})
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,337 +0,0 @@
import React, { useState, useEffect } from "react";
import { Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { get } from "@/api/request";
// 群组接口类型
interface WechatGroup {
id: string;
chatroomId: string;
name: string;
avatar: string;
ownerWechatId: string;
ownerNickname: string;
ownerAvatar: string;
}
interface GroupsResponse {
code: number;
msg: string;
data: {
list: Array<{
id: number;
chatroomId: string;
name: string;
avatar?: string;
ownerWechatId?: string;
ownerNickname?: string;
ownerAvatar?: string;
}>;
total: number;
page: number;
limit: number;
};
}
// 修改支持keyword参数
const fetchGroupsList = async (params: {
page: number;
limit: number;
keyword?: string;
}): Promise<GroupsResponse> => {
const keywordParam = params.keyword
? `&keyword=${encodeURIComponent(params.keyword)}`
: "";
return get<GroupsResponse>(
`/v1/chatroom?page=${params.page}&limit=${params.limit}${keywordParam}`
);
};
interface GroupSelectionProps {
selectedGroups: string[];
onSelect: (groups: string[]) => void;
onSelectDetail?: (groups: WechatGroup[]) => void; // 新增
placeholder?: string;
className?: string;
}
export default function GroupSelection({
selectedGroups,
onSelect,
onSelectDetail,
placeholder = "选择群聊",
className = "",
}: GroupSelectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [groups, setGroups] = useState<WechatGroup[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalGroups, setTotalGroups] = useState(0);
const [loading, setLoading] = useState(false);
// 打开弹窗并请求第一页群组
const openDialog = () => {
setCurrentPage(1);
setSearchQuery(""); // 重置搜索关键词
setDialogOpen(true);
fetchGroups(1, "");
};
// 当页码变化时,拉取对应页数据(弹窗已打开时)
useEffect(() => {
if (dialogOpen && currentPage !== 1) {
fetchGroups(currentPage, searchQuery);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]);
// 搜索防抖
useEffect(() => {
if (!dialogOpen) return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchGroups(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, dialogOpen]);
// 获取群组列表API - 支持keyword
const fetchGroups = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const res = await fetchGroupsList({
page,
limit: 20,
keyword: keyword.trim() || undefined,
});
if (res && res.code === 200 && res.data) {
setGroups(
res.data.list.map((group) => ({
id: group.id?.toString() || "",
chatroomId: group.chatroomId || "",
name: group.name || "",
avatar: group.avatar || "",
ownerWechatId: group.ownerWechatId || "",
ownerNickname: group.ownerNickname || "",
ownerAvatar: group.ownerAvatar || "",
}))
);
setTotalGroups(res.data.total || 0);
setTotalPages(Math.ceil((res.data.total || 0) / 20));
}
} catch (error) {
console.error("获取群组列表失败:", error);
} finally {
setLoading(false);
}
};
// 处理群组选择
const handleGroupToggle = (groupId: string) => {
let newIds: string[];
if (selectedGroups.includes(groupId)) {
newIds = selectedGroups.filter((id) => id !== groupId);
} else {
newIds = [...selectedGroups, groupId];
}
onSelect(newIds);
if (onSelectDetail) {
const selectedObjs = groups.filter((g) => newIds.includes(g.id));
onSelectDetail(selectedObjs);
}
};
// 获取显示文本
const getDisplayText = () => {
if (selectedGroups.length === 0) return "";
return `已选择 ${selectedGroups.length} 个群聊`;
};
const handleConfirm = () => {
setDialogOpen(false);
};
// 清空搜索
const handleClearSearch = () => {
setSearchQuery("");
setCurrentPage(1);
fetchGroups(1, "");
};
return (
<>
{/* 输入框 */}
<div className={`relative ${className}`}>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<svg
width="20"
height="20"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</span>
<Input
placeholder={placeholder}
className="pl-10 h-12 rounded-xl border-gray-200 text-base"
readOnly
onClick={openDialog}
value={getDisplayText()}
/>
</div>
{/* 群组选择弹窗 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden bg-white">
<div className="p-6">
<DialogTitle className="text-center text-xl font-medium mb-6">
</DialogTitle>
<div className="relative mb-4">
<Input
placeholder="搜索群聊"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 py-2 rounded-full border-gray-200"
/>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 h-6 w-6 rounded-full"
onClick={handleClearSearch}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto h-[50vh]">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">...</div>
</div>
) : groups.length > 0 ? (
<div className="divide-y">
{groups.map((group) => (
<label
key={group.id}
className="flex items-center px-6 py-4 hover:bg-gray-50 cursor-pointer"
onClick={() => handleGroupToggle(group.id)}
>
<div className="mr-3 flex items-center justify-center">
<div
className={`w-5 h-5 rounded-full border ${
selectedGroups.includes(group.id)
? "border-blue-600"
: "border-gray-300"
} flex items-center justify-center`}
>
{selectedGroups.includes(group.id) && (
<div className="w-3 h-3 rounded-full bg-blue-600"></div>
)}
</div>
</div>
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-medium overflow-hidden">
{group.avatar ? (
<img
src={group.avatar}
alt={group.name}
className="w-full h-full object-cover"
/>
) : (
group.name.charAt(0)
)}
</div>
<div className="flex-1">
<div className="font-medium">{group.name}</div>
<div className="text-sm text-gray-500">
ID: {group.chatroomId}
</div>
{group.ownerNickname && (
<div className="text-sm text-gray-400">
: {group.ownerNickname}
</div>
)}
</div>
</div>
</label>
))}
</div>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">
{searchQuery
? `没有找到包含"${searchQuery}"的群聊`
: "没有找到群聊"}
</div>
</div>
)}
</div>
<div className="border-t p-4 flex items-center justify-between bg-white">
<div className="text-sm text-gray-500">
{totalGroups}
{searchQuery && ` (搜索: "${searchQuery}")`}
</div>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className="px-2 py-0 h-8 min-w-0"
>
&lt;
</Button>
<span className="text-sm">
{currentPage} / {totalPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages || loading}
className="px-2 py-0 h-8 min-w-0"
>
&gt;
</Button>
</div>
</div>
<div className="border-t p-4 flex items-center justify-between bg-white">
<Button
variant="outline"
onClick={() => setDialogOpen(false)}
className="px-6 rounded-full border-gray-300"
>
</Button>
<Button
onClick={handleConfirm}
className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full"
>
({selectedGroups.length})
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,10 +0,0 @@
.container {
display: flex;
height: 100vh;
flex-direction: column;
}
.container main {
flex: 1;
overflow: auto;
}

View File

@@ -1,25 +0,0 @@
import React from "react";
interface LayoutProps {
loading?: boolean;
children?: React.ReactNode;
header?: React.ReactNode;
footer?: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = ({
loading,
children,
header,
footer,
}) => {
return (
<div className="container">
{header && <header>{header}</header>}
<main className="bg-gray-50">{children}</main>
{footer && <footer>{footer}</footer>}
</div>
);
};
export default Layout;

View File

@@ -1,43 +0,0 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import BottomNav from './BottomNav';
// 配置需要底部导航的页面路径(白名单)
const BOTTOM_NAV_CONFIG = [
'/', // 首页
'/scenarios', // 场景获客
'/workspace', // 工作台
'/profile', // 我的
];
interface LayoutWrapperProps {
children: React.ReactNode;
}
export default function LayoutWrapper({ children }: LayoutWrapperProps) {
const location = useLocation();
// 检查当前路径是否需要底部导航
const shouldShowBottomNav = BOTTOM_NAV_CONFIG.some(path => {
// 特殊处理首页路由 '/'
if (path === '/') {
return location.pathname === '/';
}
return location.pathname === path;
});
// 如果是登录页面,直接渲染内容(不显示底部导航)
if (location.pathname === '/login') {
return <>{children}</>;
}
// 只有在配置列表中的页面才显示底部导航
return (
<div className="flex flex-col h-screen">
<div className="flex-1 overflow-y-auto">
{children}
</div>
{shouldShowBottomNav && <BottomNav />}
</div>
);
}

View File

@@ -1,83 +0,0 @@
import React from 'react';
import BackButton from './BackButton';
import { useSimpleBack } from '@/hooks/useBackNavigation';
interface PageHeaderProps {
/** 页面标题 */
title: string;
/** 返回按钮文本 */
backText?: string;
/** 自定义返回逻辑 */
onBack?: () => void;
/** 默认返回路径 */
defaultBackPath?: string;
/** 是否显示返回按钮 */
showBack?: boolean;
/** 右侧扩展内容 */
rightContent?: React.ReactNode;
/** 自定义CSS类名 */
className?: string;
/** 标题样式类名 */
titleClassName?: string;
/** 返回按钮样式变体 */
backButtonVariant?: 'icon' | 'button' | 'text';
/** 返回按钮自定义样式类名 */
backButtonClassName?: string;
/** 是否显示底部边框 */
showBorder?: boolean;
}
/**
* 通用页面Header组件
* 支持返回按钮、标题和右侧扩展插槽
*/
export const PageHeader: React.FC<PageHeaderProps> = ({
title,
backText = '返回',
onBack,
defaultBackPath = '/',
showBack = true,
rightContent,
className = '',
titleClassName = '',
backButtonVariant = 'icon',
backButtonClassName = '',
showBorder = true
}) => {
const { goBack } = useSimpleBack(defaultBackPath);
const handleBack = onBack || goBack;
const baseClasses = `bg-white ${showBorder ? 'border-b border-gray-200' : ''}`;
const headerClasses = `${baseClasses} ${className}`;
// 默认小号按钮样式
const defaultBackBtnClass = 'text-sm px-2 py-1 h-8 min-h-0';
return (
<header className={headerClasses}>
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center ">
{showBack && (
<BackButton
variant={backButtonVariant}
text={backText}
onBack={handleBack}
className={`${defaultBackBtnClass} ${backButtonClassName}`.trim()}
/>
)}
<h1 className={`text-lg font-semibold ${titleClassName}`}>
{title}
</h1>
</div>
{rightContent && (
<div className="flex items-center gap-2">
{rightContent}
</div>
)}
</div>
</header>
);
};
export default PageHeader;

View File

@@ -1,71 +0,0 @@
import React, { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
// 不需要登录的公共页面路径
const PUBLIC_PATHS = [
'/login',
'/register',
'/forgot-password',
'/reset-password',
'/404',
'/500'
];
interface ProtectedRouteProps {
children: React.ReactNode;
}
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuth();
const navigate = useNavigate();
const location = useLocation();
// 检查当前路径是否是公共页面
const isPublicPath = PUBLIC_PATHS.some(path =>
location.pathname.startsWith(path)
);
useEffect(() => {
// 如果正在加载,不进行任何跳转
if (isLoading) {
return;
}
// 如果未登录且不是公共页面,重定向到登录页面
if (!isAuthenticated && !isPublicPath) {
// 保存当前URL登录后可以重定向回来
const returnUrl = encodeURIComponent(window.location.href);
navigate(`/login?returnUrl=${returnUrl}`, { replace: true });
return;
}
// 如果已登录且在登录页面,重定向到首页
if (isAuthenticated && location.pathname === '/login') {
navigate('/', { replace: true });
return;
}
}, [isAuthenticated, isLoading, location.pathname, navigate, isPublicPath]);
// 如果正在加载,显示加载状态
if (isLoading) {
return (
<div className="flex h-screen w-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
);
}
// 如果未登录且不是公共页面,不渲染内容(等待重定向)
if (!isAuthenticated && !isPublicPath) {
return null;
}
// 如果已登录且在登录页面,不渲染内容(等待重定向)
if (isAuthenticated && location.pathname === '/login') {
return null;
}
// 其他情况正常渲染
return <>{children}</>;
}

View File

@@ -1,206 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { MoreHorizontal, Copy, Pencil, Trash2, Clock, Link } from 'lucide-react';
interface Task {
id: string;
name: string;
status: "running" | "paused" | "completed";
stats: {
devices: number;
acquired: number;
added: number;
};
lastUpdated: string;
executionTime: string;
nextExecutionTime: string;
trend: { date: string; customers: number }[];
reqConf?: {
device?: string[];
selectedDevices?: string[];
};
acquiredCount?: number;
addedCount?: number;
passRate?: number;
}
interface ScenarioAcquisitionCardProps {
task: Task;
channel: string;
onEdit: (taskId: string) => void;
onCopy: (taskId: string) => void;
onDelete: (taskId: string) => void;
onOpenSettings?: (taskId: string) => void;
onStatusChange?: (taskId: string, newStatus: "running" | "paused") => void;
}
export function ScenarioAcquisitionCard({
task,
channel,
onEdit,
onCopy,
onDelete,
onOpenSettings,
onStatusChange,
}: ScenarioAcquisitionCardProps) {
// 兼容后端真实数据结构
const deviceCount = Array.isArray(task.reqConf?.device)
? task.reqConf!.device.length
: Array.isArray(task.reqConf?.selectedDevices)
? task.reqConf!.selectedDevices.length
: 0;
// 获客数和已添加数可根据 msgConf 或其它字段自定义
const acquiredCount = task.acquiredCount ?? 0;
const addedCount = task.addedCount ?? 0;
const passRate = task.passRate ?? 0;
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const isActive = task.status === "running";
const handleStatusChange = (e: React.MouseEvent) => {
e.stopPropagation();
if (onStatusChange) {
onStatusChange(task.id, task.status === "running" ? "paused" : "running");
}
};
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation();
setMenuOpen(false);
onEdit(task.id);
};
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
setMenuOpen(false);
onCopy(task.id);
};
const handleOpenSettings = (e: React.MouseEvent) => {
e.stopPropagation();
setMenuOpen(false);
if (onOpenSettings) {
onOpenSettings(task.id);
}
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
setMenuOpen(false);
onDelete(task.id);
};
const toggleMenu = (e?: React.MouseEvent) => {
if (e) e.stopPropagation();
setMenuOpen(!menuOpen);
};
// 点击外部关闭菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
return (
<Card className="p-6 hover:shadow-lg transition-all mb-4 bg-white/80">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<h3 className="font-medium text-lg">{task.name}</h3>
<Badge
variant={isActive ? "success" : "secondary"}
className="cursor-pointer hover:opacity-80"
onClick={handleStatusChange}
>
{isActive ? "进行中" : "已暂停"}
</Badge>
</div>
<div className="relative z-20" ref={menuRef}>
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100 rounded-full" onClick={toggleMenu}>
<MoreHorizontal className="h-4 w-4" />
</Button>
{menuOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-50 py-1 border">
<button
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={handleEdit}
>
<Pencil className="w-4 h-4 mr-2" />
</button>
<button
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={handleCopy}
>
<Copy className="w-4 h-4 mr-2" />
</button>
{onOpenSettings && (
<button
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={handleOpenSettings}
>
<Link className="w-4 h-4 mr-2" />
</button>
)}
<button
className="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
onClick={handleDelete}
>
<Trash2 className="w-4 h-4 mr-2" />
</button>
</div>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-2 mb-4">
<div className="block">
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{deviceCount}</div>
</Card>
</div>
<div className="block">
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{acquiredCount}</div>
</Card>
</div>
<div className="block">
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{addedCount}</div>
</Card>
</div>
<Card className="p-2">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{passRate}%</div>
</Card>
</div>
<div className="flex items-center justify-between text-sm border-t pt-4 text-gray-500">
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4" />
<span>{task.lastUpdated}</span>
</div>
</div>
</Card>
);
}

View File

@@ -1,23 +0,0 @@
import React from 'react';
import { cn } from '@/utils';
interface TestComponentProps {
title: string;
className?: string;
}
const TestComponent: React.FC<TestComponentProps> = ({ title, className }) => {
return (
<div className={cn('p-4 border rounded-lg', className)}>
<h3 className="text-lg font-semibold mb-2">{title}</h3>
<p className="text-gray-600">
@/
</p>
<div className="mt-2 text-sm text-blue-600">
使 @/utils
</div>
</div>
);
};
export default TestComponent;

View File

@@ -1,291 +0,0 @@
import React from 'react';
import {
useThrottledRequestWithLoading,
useThrottledRequestWithError,
useRequestWithRetry,
useCancellableRequest
} from '../hooks/useThrottledRequest';
interface ThrottledButtonProps {
onClick: () => Promise<any>;
children: React.ReactNode;
delay?: number;
disabled?: boolean;
className?: string;
variant?: 'throttle' | 'debounce' | 'retry' | 'cancellable';
maxRetries?: number;
retryDelay?: number;
showLoadingText?: boolean;
loadingText?: string;
errorText?: string;
onSuccess?: (result: any) => void;
onError?: (error: any) => void;
}
export const ThrottledButton: React.FC<ThrottledButtonProps> = ({
onClick,
children,
delay = 1000,
disabled = false,
className = '',
variant = 'throttle',
maxRetries = 3,
retryDelay = 1000,
showLoadingText = true,
loadingText = '处理中...',
errorText,
onSuccess,
onError
}) => {
// 处理请求结果
const handleRequest = async () => {
try {
const result = await onClick();
onSuccess?.(result);
} catch (error) {
onError?.(error);
}
};
// 根据variant渲染不同的按钮
const renderButton = () => {
switch (variant) {
case 'retry':
return <RetryButtonContent
onClick={handleRequest}
maxRetries={maxRetries}
retryDelay={retryDelay}
loadingText={loadingText}
showLoadingText={showLoadingText}
disabled={disabled}
className={className}
>
{children}
</RetryButtonContent>;
case 'cancellable':
return <CancellableButtonContent
onClick={handleRequest}
loadingText={loadingText}
showLoadingText={showLoadingText}
disabled={disabled}
className={className}
>
{children}
</CancellableButtonContent>;
case 'debounce':
return <DebounceButtonContent
onClick={handleRequest}
delay={delay}
loadingText={loadingText}
showLoadingText={showLoadingText}
disabled={disabled}
className={className}
errorText={errorText}
>
{children}
</DebounceButtonContent>;
default:
return <ThrottleButtonContent
onClick={handleRequest}
delay={delay}
loadingText={loadingText}
showLoadingText={showLoadingText}
disabled={disabled}
className={className}
>
{children}
</ThrottleButtonContent>;
}
};
return renderButton();
};
// 节流按钮内容组件
const ThrottleButtonContent: React.FC<{
onClick: () => Promise<any>;
delay: number;
loadingText: string;
showLoadingText: boolean;
disabled: boolean;
className: string;
children: React.ReactNode;
}> = ({ onClick, delay, loadingText, showLoadingText, disabled, className, children }) => {
const { throttledRequest, loading } = useThrottledRequestWithLoading(onClick, delay);
const getButtonText = () => {
return loading && showLoadingText ? loadingText : children;
};
const getButtonClassName = () => {
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
const variantClasses = loading
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
return `${baseClasses} ${variantClasses} ${className}`;
};
return (
<button
onClick={throttledRequest}
disabled={disabled || loading}
className={getButtonClassName()}
>
{getButtonText()}
</button>
);
};
// 防抖按钮内容组件
const DebounceButtonContent: React.FC<{
onClick: () => Promise<any>;
delay: number;
loadingText: string;
showLoadingText: boolean;
disabled: boolean;
className: string;
errorText?: string;
children: React.ReactNode;
}> = ({ onClick, delay, loadingText, showLoadingText, disabled, className, errorText, children }) => {
const { throttledRequest, loading, error } = useThrottledRequestWithError(onClick, delay);
const getButtonText = () => {
return loading && showLoadingText ? loadingText : children;
};
const getButtonClassName = () => {
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
let variantClasses = '';
if (loading) {
variantClasses = 'bg-gray-400 text-white cursor-not-allowed';
} else if (error) {
variantClasses = 'bg-red-500 text-white hover:bg-red-600';
} else {
variantClasses = 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
}
return `${baseClasses} ${variantClasses} ${className}`;
};
return (
<div className="flex items-center gap-2">
<button
onClick={throttledRequest}
disabled={disabled || loading}
className={getButtonClassName()}
>
{getButtonText()}
</button>
{error && errorText && (
<span className="text-red-500 text-sm">{errorText}</span>
)}
</div>
);
};
// 重试按钮内容组件
const RetryButtonContent: React.FC<{
onClick: () => Promise<any>;
maxRetries: number;
retryDelay: number;
loadingText: string;
showLoadingText: boolean;
disabled: boolean;
className: string;
children: React.ReactNode;
}> = ({ onClick, maxRetries, retryDelay, loadingText, showLoadingText, disabled, className, children }) => {
const { requestWithRetry, loading, retryCount } = useRequestWithRetry(onClick, maxRetries, retryDelay);
const getButtonText = () => {
if (loading) {
if (retryCount > 0) {
return `${loadingText} (重试 ${retryCount}/${maxRetries})`;
}
return showLoadingText ? loadingText : children;
}
return children;
};
const getButtonClassName = () => {
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
const variantClasses = loading
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
return `${baseClasses} ${variantClasses} ${className}`;
};
return (
<button
onClick={requestWithRetry}
disabled={disabled || loading}
className={getButtonClassName()}
>
{getButtonText()}
</button>
);
};
// 可取消按钮内容组件
const CancellableButtonContent: React.FC<{
onClick: () => Promise<any>;
loadingText: string;
showLoadingText: boolean;
disabled: boolean;
className: string;
children: React.ReactNode;
}> = ({ onClick, loadingText, showLoadingText, disabled, className, children }) => {
const { cancellableRequest, loading, cancelRequest } = useCancellableRequest(onClick);
const getButtonText = () => {
return loading && showLoadingText ? loadingText : children;
};
const getButtonClassName = () => {
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
const variantClasses = loading
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
return `${baseClasses} ${variantClasses} ${className}`;
};
return (
<div className="flex items-center gap-2">
<button
onClick={cancellableRequest}
disabled={disabled || loading}
className={getButtonClassName()}
>
{getButtonText()}
</button>
{loading && cancelRequest && (
<button
onClick={cancelRequest}
className="px-3 py-2 rounded bg-red-500 text-white hover:bg-red-600 text-sm"
>
</button>
)}
</div>
);
};
// 导出其他类型的按钮组件
export const DebouncedButton: React.FC<Omit<ThrottledButtonProps, 'variant'> & { delay?: number }> = (props) => (
<ThrottledButton {...props} variant="debounce" delay={props.delay || 300} />
);
export const RetryButton: React.FC<Omit<ThrottledButtonProps, 'variant'> & { maxRetries?: number; retryDelay?: number }> = (props) => (
<ThrottledButton {...props} variant="retry" maxRetries={props.maxRetries || 3} retryDelay={props.retryDelay || 1000} />
);
export const CancellableButton: React.FC<Omit<ThrottledButtonProps, 'variant'>> = (props) => (
<ThrottledButton {...props} variant="cancellable" />
);

View File

@@ -1,297 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Search, Database } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useToast } from '@/components/ui/toast';
import { fetchDeviceLabels, type TrafficPool } from '@/api/trafficDistribution';
// 组件属性接口
interface TrafficPoolSelectionProps {
selectedPools: string[];
onSelect: (pools: string[]) => void;
deviceIds: string[];
placeholder?: string;
className?: string;
}
export default function TrafficPoolSelection({
selectedPools,
onSelect,
deviceIds,
placeholder = "选择流量池",
className = ""
}: TrafficPoolSelectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [pools, setPools] = useState<TrafficPool[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalPools, setTotalPools] = useState(0);
const [loading, setLoading] = useState(false);
const { toast } = useToast();
// 获取流量池列表API
const fetchPools = useCallback(async (page: number, keyword: string = '') => {
if (deviceIds.length === 0) return;
setLoading(true);
try {
const res = await fetchDeviceLabels({
deviceIds,
page,
pageSize: 10,
keyword
});
if (res && res.code === 200 && res.data) {
setPools(res.data.list || []);
setTotalPools(res.data.total || 0);
setTotalPages(Math.ceil((res.data.total || 0) / 10));
} else {
toast({
title: "获取流量池列表失败",
description: res?.msg || "请稍后重试",
variant: "destructive"
});
// 使用模拟数据作为降级处理
const mockData: TrafficPool[] = [
{ id: "1", name: "新客流量池", count: 1250, description: "新获取的客户流量", deviceIds },
{ id: "2", name: "高意向流量池", count: 850, description: "有购买意向的客户", deviceIds },
{ id: "3", name: "复购流量池", count: 620, description: "已购买过产品的客户", deviceIds },
{ id: "4", name: "活跃流量池", count: 1580, description: "近期活跃的客户", deviceIds },
{ id: "5", name: "沉睡流量池", count: 2300, description: "长期未活跃的客户", deviceIds },
{ id: "6", name: "VIP客户池", count: 156, description: "VIP等级客户", deviceIds },
{ id: "7", name: "潜在客户池", count: 3200, description: "有潜在购买可能的客户", deviceIds },
{ id: "8", name: "游戏玩家池", count: 890, description: "游戏类产品感兴趣客户", deviceIds },
];
// 根据关键词过滤模拟数据
const filteredData = keyword
? mockData.filter(pool =>
pool.name.toLowerCase().includes(keyword.toLowerCase()) ||
(pool.description && pool.description.toLowerCase().includes(keyword.toLowerCase()))
)
: mockData;
// 分页处理模拟数据
const startIndex = (page - 1) * 10;
const endIndex = startIndex + 10;
const paginatedData = filteredData.slice(startIndex, endIndex);
setPools(paginatedData);
setTotalPools(filteredData.length);
setTotalPages(Math.ceil(filteredData.length / 10));
}
} catch (error) {
console.error('获取流量池列表失败:', error);
toast({
title: "网络错误",
description: "请检查网络连接后重试",
variant: "destructive"
});
// 网络错误时使用模拟数据
const mockData: TrafficPool[] = [
{ id: "1", name: "新客流量池", count: 1250, description: "新获取的客户流量", deviceIds },
{ id: "2", name: "高意向流量池", count: 850, description: "有购买意向的客户", deviceIds },
{ id: "3", name: "复购流量池", count: 620, description: "已购买过产品的客户", deviceIds },
{ id: "4", name: "活跃流量池", count: 1580, description: "近期活跃的客户", deviceIds },
{ id: "5", name: "沉睡流量池", count: 2300, description: "长期未活跃的客户", deviceIds },
];
setPools(mockData);
setTotalPools(mockData.length);
setTotalPages(1);
} finally {
setLoading(false);
}
}, [deviceIds, toast]);
// 当弹窗打开时获取流量池列表
useEffect(() => {
if (dialogOpen && deviceIds.length > 0) {
// 弹窗打开时重置搜索和页码,然后立即请求第一页数据
setSearchQuery('');
setCurrentPage(1);
fetchPools(1, '');
}
}, [dialogOpen, deviceIds, fetchPools]);
// 监听页码变化,重新请求数据
useEffect(() => {
if (dialogOpen && deviceIds.length > 0 && currentPage > 1) {
fetchPools(currentPage, searchQuery);
}
}, [currentPage, dialogOpen, deviceIds.length, fetchPools, searchQuery]);
// 当设备ID变化时清空已选择的流量池如果需要的话
useEffect(() => {
if (deviceIds.length === 0) {
setPools([]);
setTotalPools(0);
setTotalPages(1);
}
}, [deviceIds]);
// 处理搜索
const handleSearch = (keyword: string) => {
setSearchQuery(keyword);
setCurrentPage(1);
// 立即搜索,不管弹窗是否打开(因为这个函数只在弹窗内调用)
if (deviceIds.length > 0) {
fetchPools(1, keyword);
}
};
// 处理流量池选择
const handlePoolToggle = (poolId: string) => {
if (selectedPools.includes(poolId)) {
onSelect(selectedPools.filter(id => id !== poolId));
} else {
onSelect([...selectedPools, poolId]);
}
};
// 获取显示文本
const getDisplayText = () => {
if (selectedPools.length === 0) return '';
return `已选择 ${selectedPools.length} 个流量池`;
};
const handleConfirm = () => {
setDialogOpen(false);
};
// 处理输入框点击
const handleInputClick = () => {
if (deviceIds.length === 0) {
toast({
title: "请先选择设备",
description: "需要先选择设备才能选择流量池",
variant: "destructive"
});
return;
}
setDialogOpen(true);
};
return (
<>
{/* 输入框 */}
<div className={`relative ${className}`}>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<Database className="w-5 h-5" />
</span>
<Input
placeholder={placeholder}
className="pl-10 h-12 rounded-xl border-gray-200 text-base"
readOnly
onClick={handleInputClick}
value={getDisplayText()}
/>
</div>
{/* 流量池选择弹窗 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-sm w-[90vw] max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
<div>
<DialogTitle className="text-center text-xl font-medium mb-6"></DialogTitle>
<div className="relative mb-4">
<Input
placeholder="搜索流量池"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="pl-10 py-2 rounded-full border-gray-200"
/>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
</div>
</div>
<ScrollArea className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">...</div>
</div>
) : pools.length > 0 ? (
<div className="divide-y">
{pools.map((pool) => (
<label
key={pool.id}
className="flex items-center px-6 py-4 hover:bg-gray-50 cursor-pointer"
onClick={() => handlePoolToggle(pool.id)}
>
<div className="mr-3 flex items-center justify-center">
<div className={`w-5 h-5 rounded-full border ${selectedPools.includes(pool.id) ? 'border-blue-600' : 'border-gray-300'} flex items-center justify-center`}>
{selectedPools.includes(pool.id) && (
<div className="w-3 h-3 rounded-full bg-blue-600"></div>
)}
</div>
</div>
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<Database className="h-5 w-5 text-blue-600" />
</div>
<div className="flex-1">
<div className="font-medium">{pool.name}</div>
{pool.description && (
<div className="text-sm text-gray-500">{pool.description}</div>
)}
<div className="text-sm text-gray-400">{pool.count} </div>
</div>
</div>
</label>
))}
</div>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">
{deviceIds.length === 0 ? '请先选择设备' : '没有找到流量池'}
</div>
</div>
)}
</ScrollArea>
<div className="border-t p-4 flex items-center justify-between bg-white">
<div className="text-sm text-gray-500">
{totalPools}
</div>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className="px-2 py-0 h-8 min-w-0"
>
&lt;
</Button>
<span className="text-sm">{currentPage} / {totalPages}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages || loading}
className="px-2 py-0 h-8 min-w-0"
>
&gt;
</Button>
</div>
</div>
<div className="border-t p-4 flex items-center justify-between bg-white">
<Button variant="outline" onClick={() => setDialogOpen(false)} className="px-6 rounded-full border-gray-300">
</Button>
<Button onClick={handleConfirm} className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full">
({selectedPools.length})
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,296 +0,0 @@
import React from 'react';
import { ChevronLeft, Settings, Bell, Search, RefreshCw, Filter, Plus, MoreVertical } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useNavigate, useLocation } from 'react-router-dom';
interface HeaderAction {
type: 'button' | 'icon' | 'search' | 'custom';
icon?: React.ComponentType<any>;
label?: string;
onClick?: () => void;
variant?: 'default' | 'ghost' | 'outline' | 'destructive' | 'secondary';
size?: 'default' | 'sm' | 'lg' | 'icon';
className?: string;
content?: React.ReactNode;
}
interface UnifiedHeaderProps {
/** 页面标题 */
title: string;
/** 是否显示返回按钮 */
showBack?: boolean;
/** 返回按钮文本 */
backText?: string;
/** 自定义返回逻辑 */
onBack?: () => void;
/** 默认返回路径 */
defaultBackPath?: string;
/** 右侧操作按钮 */
actions?: HeaderAction[];
/** 自定义右侧内容 */
rightContent?: React.ReactNode;
/** 是否显示搜索框 */
showSearch?: boolean;
/** 搜索框占位符 */
searchPlaceholder?: string;
/** 搜索值 */
searchValue?: string;
/** 搜索回调 */
onSearchChange?: (value: string) => void;
/** 是否显示底部边框 */
showBorder?: boolean;
/** 背景样式 */
background?: 'white' | 'transparent' | 'blur';
/** 自定义CSS类名 */
className?: string;
/** 标题样式类名 */
titleClassName?: string;
/** 标题颜色 */
titleColor?: 'default' | 'blue' | 'gray';
/** 是否居中标题 */
centerTitle?: boolean;
/** 头部高度 */
height?: 'default' | 'compact' | 'tall';
}
const UnifiedHeader: React.FC<UnifiedHeaderProps> = ({
title,
showBack = true,
backText = '返回',
onBack,
defaultBackPath = '/',
actions = [],
rightContent,
showSearch = false,
searchPlaceholder = '搜索...',
searchValue = '',
onSearchChange,
showBorder = true,
background = 'white',
className = '',
titleClassName = '',
titleColor = 'default',
centerTitle = false,
height = 'default',
}) => {
const navigate = useNavigate();
const location = useLocation();
const handleBack = () => {
if (onBack) {
onBack();
} else if (defaultBackPath) {
navigate(defaultBackPath);
} else {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/');
}
}
};
// 背景样式
const backgroundClasses = {
white: 'bg-white',
transparent: 'bg-transparent',
blur: 'bg-white/80 backdrop-blur-sm',
};
// 高度样式
const heightClasses = {
default: 'h-14',
compact: 'h-12',
tall: 'h-16',
};
// 标题颜色样式
const titleColorClasses = {
default: 'text-gray-900',
blue: 'text-blue-600',
gray: 'text-gray-600',
};
const headerClasses = [
backgroundClasses[background],
heightClasses[height],
showBorder ? 'border-b border-gray-200' : '',
'sticky top-0 z-50',
className,
].filter(Boolean).join(' ');
const titleClasses = [
'text-lg font-semibold',
titleColorClasses[titleColor],
centerTitle ? 'text-center' : '',
titleClassName,
].filter(Boolean).join(' ');
// 渲染操作按钮
const renderAction = (action: HeaderAction, index: number) => {
if (action.type === 'custom' && action.content) {
return <div key={index}>{action.content}</div>;
}
if (action.type === 'search') {
return (
<div key={index} className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange?.(e.target.value)}
className="pl-9 w-48"
/>
</div>
);
}
const IconComponent = action.icon || MoreVertical;
return (
<Button
key={index}
variant={action.variant || 'ghost'}
size={action.size || 'icon'}
onClick={action.onClick}
className={action.className}
>
<IconComponent className="h-5 w-5" />
{action.label && action.size !== 'icon' && (
<span className="ml-2">{action.label}</span>
)}
</Button>
);
};
return (
<header className={headerClasses}>
<div className="flex items-center justify-between px-4 h-full">
{/* 左侧:返回按钮和标题 */}
<div className="flex items-center space-x-3 flex-1">
{showBack && (
<Button
variant="ghost"
size="icon"
onClick={handleBack}
className="h-8 w-8 hover:bg-gray-100"
>
<ChevronLeft className="h-5 w-5" />
</Button>
)}
{!centerTitle && (
<h1 className={titleClasses}>
{title}
</h1>
)}
</div>
{/* 中间:居中标题 */}
{centerTitle && (
<div className="flex-1 flex justify-center">
<h1 className={titleClasses}>
{title}
</h1>
</div>
)}
{/* 右侧:搜索框、操作按钮、自定义内容 */}
<div className="flex items-center space-x-2 flex-1 justify-end">
{showSearch && !actions.some(a => a.type === 'search') && (
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange?.(e.target.value)}
className="pl-9 w-48"
/>
</div>
)}
{actions.map((action, index) => renderAction(action, index))}
{rightContent && (
<div className="flex items-center space-x-2">
{rightContent}
</div>
)}
</div>
</div>
</header>
);
};
// 预设的常用Header配置
export const HeaderPresets = {
// 基础页面Header有返回按钮
basic: (title: string, onBack?: () => void): UnifiedHeaderProps => ({
title,
showBack: true,
onBack,
titleColor: 'blue',
}),
// 主页Header无返回按钮
main: (title: string, actions?: HeaderAction[]): UnifiedHeaderProps => ({
title,
showBack: false,
titleColor: 'blue',
actions: actions || [
{
type: 'icon',
icon: Bell,
onClick: () => console.log('Notifications'),
},
],
}),
// 搜索页面Header
search: (title: string, searchValue: string, onSearchChange: (value: string) => void): UnifiedHeaderProps => ({
title,
showBack: true,
showSearch: true,
searchValue,
onSearchChange,
titleColor: 'blue',
}),
// 列表页面Header带刷新和添加
list: (title: string, onRefresh?: () => void, onAdd?: () => void): UnifiedHeaderProps => ({
title,
showBack: true,
titleColor: 'blue',
actions: [
...(onRefresh ? [{
type: 'icon' as const,
icon: RefreshCw,
onClick: onRefresh,
}] : []),
...(onAdd ? [{
type: 'button' as const,
icon: Plus,
label: '新建',
size: 'sm' as const,
onClick: onAdd,
}] : []),
],
}),
// 设置页面Header
settings: (title: string): UnifiedHeaderProps => ({
title,
showBack: true,
titleColor: 'blue',
actions: [
{
type: 'icon',
icon: Settings,
onClick: () => console.log('Settings'),
},
],
}),
};
export default UnifiedHeader;

View File

@@ -1,54 +0,0 @@
import React from 'react';
import { Upload } from 'tdesign-mobile-react';
import type { UploadFile as TDesignUploadFile } from 'tdesign-mobile-react/es/upload/type';
import { uploadImage } from '@/api/upload';
interface UploadImageProps {
value?: string[];
onChange?: (urls: string[]) => void;
max?: number;
accept?: string;
disabled?: boolean;
}
const UploadImage: React.FC<UploadImageProps> = ({ value = [], onChange, ...props }) => {
// 处理上传
const requestMethod = async (file: TDesignUploadFile) => {
try {
const url = await uploadImage(file.raw as File);
return {
status: 'success' as const,
response: {
url
},
url,
};
} catch (e: any) {
return {
status: 'fail' as const,
error: e.message || '上传失败',
response: {},
};
}
};
// 处理文件变更
const handleChange = (newFiles: TDesignUploadFile[]) => {
const urls = newFiles.map(f => f.url).filter((url): url is string => Boolean(url));
onChange?.(urls);
};
return (
<Upload
files={value.map(url => ({ url }))}
requestMethod={requestMethod}
onChange={handleChange}
multiple
accept={props.accept}
max={props.max}
disabled={props.disabled}
/>
);
};
export default UploadImage;

View File

@@ -1,94 +0,0 @@
import React, { useRef } from 'react';
import { Button } from 'tdesign-mobile-react';
import { X } from 'lucide-react';
import { uploadImage } from '@/api/upload';
interface UploadVideoProps {
value?: string;
onChange?: (url: string) => void;
accept?: string;
disabled?: boolean;
}
const VIDEO_BOX_CLASS =
'relative flex items-center justify-center w-full aspect-[16/9] rounded-2xl border-2 border-dashed border-blue-300 bg-gray-50 overflow-hidden';
const UploadVideo: React.FC<UploadVideoProps> = ({
value,
onChange,
accept = 'video/mp4,video/webm,video/ogg,video/quicktime,video/x-msvideo,video/x-ms-wmv,video/x-flv,video/x-matroska',
disabled,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
// 选择文件并上传
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const url = await uploadImage(file);
onChange?.(url);
} catch (err: any) {
alert(err?.message || '上传失败');
} finally {
if (inputRef.current) inputRef.current.value = '';
}
};
// 触发文件选择
const handleClick = () => {
if (!disabled) inputRef.current?.click();
};
// 删除视频
const handleDelete = () => {
onChange?.('');
};
return (
<div className="flex flex-col items-center w-full">
{!value ? (
<div className={VIDEO_BOX_CLASS}>
<input
ref={inputRef}
type="file"
accept={accept}
style={{ display: 'none' }}
onChange={handleFileChange}
disabled={disabled}
/>
<button
type="button"
className="flex flex-col items-center justify-center w-full h-full bg-transparent border-none outline-none cursor-pointer"
onClick={handleClick}
disabled={disabled}
>
<span className="text-3xl mb-2">🎬</span>
<span className="text-base text-gray-500 font-medium"></span>
<span className="text-xs text-gray-400 mt-1">MP4WebMMOV等格式</span>
</button>
</div>
) : (
<div className={VIDEO_BOX_CLASS}>
<video
src={value}
controls
className="w-full h-full object-cover rounded-2xl bg-black"
style={{ background: '#000' }}
/>
<button
type="button"
className="absolute top-2 right-2 z-10 bg-white/80 hover:bg-white rounded-full p-1 shadow"
onClick={handleDelete}
disabled={disabled}
aria-label="删除视频"
>
<X className="w-5 h-5 text-gray-600" />
</button>
</div>
)}
</div>
);
};
export default UploadVideo;

View File

@@ -1,11 +0,0 @@
import React from "react";
export function AppleIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" height="24" width="24" {...props}>
<path d="M14.94 5.19A4.38 4.38 0 0016 2a4.44 4.44 0 00-3 1.52 4.17 4.17 0 00-1 3.09 3.69 3.69 0 002.94-1.42zm2.52 7.44a4.51 4.51 0 012.16-3.81 4.66 4.66 0 00-3.66-2c-1.56-.16-3 .91-3.83.91s-2-.89-3.3-.87a4.92 4.92 0 00-4.14 2.53C2.93 12.45 4.24 17 6 19.47c.8 1.21 1.8 2.58 3.12 2.53s1.75-.82 3.28-.82 2 .82 3.3.79 2.22-1.24 3.06-2.45a11 11 0 001.38-2.85 4.41 4.41 0 01-2.68-4.04z" />
</svg>
);
}
export default AppleIcon;

View File

@@ -1,22 +0,0 @@
import React from "react";
export function EyeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
height="24"
width="24"
{...props}
>
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
export default EyeIcon;

View File

@@ -1,12 +0,0 @@
import React from "react";
export function WeChatIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" height="24" width="24" {...props}>
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.81-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.595-6.348zM5.959 5.48c.609 0 1.104.498 1.104 1.112 0 .612-.495 1.11-1.104 1.11-.612 0-1.108-.498-1.108-1.11 0-.614.496-1.112 1.108-1.112zm5.315 0c.61 0 1.107.498 1.107 1.112 0 .612-.497 1.11-1.107 1.11-.611 0-1.105-.498-1.105-1.11 0-.614.494-1.112 1.105-1.112z" />
<path d="M23.002 15.816c0-3.309-3.136-6-7-6-3.863 0-7 2.691-7 6 0 3.31 3.137 6 7 6 .814 0 1.601-.099 2.338-.285a.7.7 0 0 1 .579.08l1.5.87a.267.267 0 0 0 .135.044c.13 0 .236-.108.236-.241 0-.06-.023-.118-.038-.17l-.309-1.167a.476.476 0 0 1 .172-.534c1.645-1.17 2.387-2.835 2.387-4.597zm-9.498-1.19c-.497 0-.9-.407-.9-.908a.905.905 0 0 1 .9-.91c.498 0 .9.408.9.91 0 .5-.402.908-.9.908zm4.998 0c-.497 0-.9-.407-.9-.908a.905.905 0 0 1 .9-.91c.498 0 .9.408.9.91 0 .5-.402.908-.9.908z" />
</svg>
);
}
export default WeChatIcon;

View File

@@ -1,62 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, children, ...props }, ref) => (
children ? (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
>
{children}
</h5>
) : null
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -1,77 +0,0 @@
import React from 'react';
import { UserCircle } from 'lucide-react';
interface AvatarProps {
children: React.ReactNode;
className?: string;
}
export function Avatar({ children, className = '' }: AvatarProps) {
return (
<div className={`relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full ${className}`}>
{children}
</div>
);
}
interface AvatarImageProps {
src?: string;
alt?: string;
className?: string;
}
export function AvatarImage({ src, alt, className = '' }: AvatarImageProps) {
if (!src) return null;
return (
<img
src={src}
alt={alt || '头像'}
className={`aspect-square h-full w-full object-cover ${className}`}
onError={(e) => {
// 图片加载失败时隐藏图片显示fallback
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
);
}
interface AvatarFallbackProps {
children?: React.ReactNode;
className?: string;
variant?: 'default' | 'gradient' | 'solid' | 'outline';
showUserIcon?: boolean;
}
export function AvatarFallback({
children,
className = '',
variant = 'default',
showUserIcon = true
}: AvatarFallbackProps) {
const getVariantClasses = () => {
switch (variant) {
case 'gradient':
return 'bg-gradient-to-br from-blue-500 to-purple-600 text-white shadow-lg';
case 'solid':
return 'bg-blue-500 text-white';
case 'outline':
return 'bg-white border-2 border-blue-500 text-blue-500';
default:
return 'bg-gradient-to-br from-blue-100 to-blue-200 text-blue-600';
}
};
return (
<div className={`flex h-full w-full items-center justify-center rounded-full ${getVariantClasses()} ${className}`}>
{children ? (
<span className="text-sm font-medium">{children}</span>
) : showUserIcon ? (
<UserCircle className="h-1/2 w-1/2" />
) : (
<span className="text-sm font-medium"></span>
)}
</div>
);
}

View File

@@ -1,45 +0,0 @@
import React from 'react';
interface BadgeProps {
children: React.ReactNode;
variant?: 'default' | 'secondary' | 'success' | 'destructive' | 'outline';
className?: string;
onClick?: (e: React.MouseEvent) => void;
}
export function Badge({
children,
variant = 'default',
className = '',
onClick
}: BadgeProps) {
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
const variantClasses = {
default: 'bg-blue-100 text-blue-800',
secondary: 'bg-gray-100 text-gray-800',
success: 'bg-green-100 text-green-800',
destructive: 'bg-red-100 text-red-800',
outline: 'border border-gray-300 bg-white text-gray-700'
};
const classes = `${baseClasses} ${variantClasses[variant]} ${className}`;
if (onClick) {
return (
<button
className={classes}
onClick={onClick}
type="button"
>
{children}
</button>
);
}
return (
<span className={classes}>
{children}
</span>
);
}

View File

@@ -1,60 +0,0 @@
import React from 'react';
interface ButtonProps {
children: React.ReactNode;
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon';
className?: string;
onClick?: (e?: React.MouseEvent) => void;
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
loading?: boolean;
}
export function Button({
children,
variant = 'default',
size = 'default',
className = '',
onClick,
disabled = false,
type = 'button',
loading = false
}: ButtonProps) {
const baseClasses = 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none';
const variantClasses = {
default: 'bg-blue-600 text-white hover:bg-blue-700',
destructive: 'bg-red-600 text-white hover:bg-red-700',
outline: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
ghost: 'hover:bg-gray-100 text-gray-700',
link: 'text-blue-600 underline-offset-4 hover:underline'
};
const sizeClasses = {
default: 'h-10 px-4 py-2',
sm: 'h-9 px-3',
lg: 'h-11 px-8',
icon: 'h-10 w-10'
};
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
return (
<button
className={classes}
onClick={onClick}
disabled={disabled || loading}
type={type}
>
{loading && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
{children}
</button>
);
}

View File

@@ -1,66 +0,0 @@
import React from 'react';
interface CardProps {
children: React.ReactNode;
className?: string;
}
export function Card({ children, className = '' }: CardProps) {
return (
<div className={`bg-white rounded-lg border border-gray-200 shadow-sm ${className}`}>
{children}
</div>
);
}
interface CardHeaderProps {
children: React.ReactNode;
className?: string;
}
export function CardHeader({ children, className = '' }: CardHeaderProps) {
return (
<div className={`px-6 py-4 border-b border-gray-200 ${className}`}>
{children}
</div>
);
}
interface CardTitleProps {
children: React.ReactNode;
className?: string;
}
export function CardTitle({ children, className = '' }: CardTitleProps) {
return (
<h3 className={`text-lg font-semibold text-gray-900 ${className}`}>
{children}
</h3>
);
}
interface CardContentProps {
children: React.ReactNode;
className?: string;
}
export function CardContent({ children, className = '' }: CardContentProps) {
return (
<div className={`px-6 py-4 ${className}`}>
{children}
</div>
);
}
interface CardFooterProps {
children: React.ReactNode;
className?: string;
}
export function CardFooter({ children, className = '' }: CardFooterProps) {
return (
<div className={`px-6 py-4 border-t border-gray-200 ${className}`}>
{children}
</div>
);
}

View File

@@ -1,39 +0,0 @@
import React from 'react';
interface CheckboxProps {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
onChange?: (checked: boolean) => void;
disabled?: boolean;
className?: string;
id?: string;
onClick?: (e: React.MouseEvent) => void;
}
export function Checkbox({
checked = false,
onCheckedChange,
onChange,
disabled = false,
className = '',
id,
onClick
}: CheckboxProps) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newChecked = e.target.checked;
onCheckedChange?.(newChecked);
onChange?.(newChecked);
};
return (
<input
type="checkbox"
id={id}
checked={checked}
onChange={handleChange}
onClick={onClick}
disabled={disabled}
className={`w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2 ${className}`}
/>
);
}

View File

@@ -1,122 +0,0 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"bg-white fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -1,109 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
interface DropdownMenuProps {
children: React.ReactNode;
}
export function DropdownMenu({ children }: DropdownMenuProps) {
return <>{children}</>;
}
interface DropdownMenuTriggerProps {
children: React.ReactNode;
asChild?: boolean;
}
export function DropdownMenuTrigger({ children }: DropdownMenuTriggerProps) {
return <>{children}</>;
}
interface DropdownMenuContentProps {
children: React.ReactNode;
align?: 'start' | 'center' | 'end';
}
export function DropdownMenuContent({ children, align = 'end' }: DropdownMenuContentProps) {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const handleClick = () => setIsOpen(!isOpen);
trigger.addEventListener('click', handleClick);
return () => {
trigger.removeEventListener('click', handleClick);
};
}, [isOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
contentRef.current &&
!contentRef.current.contains(event.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return (
<div className="relative">
<div ref={triggerRef}>
{React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
return React.cloneElement(child, {
...child.props,
children: (
<>
{child.props.children}
{isOpen && (
<div
ref={contentRef}
className={`absolute z-50 mt-2 min-w-[8rem] overflow-hidden rounded-md border bg-white p-1 shadow-md ${
align === 'start' ? 'left-0' : align === 'center' ? 'left-1/2 transform -translate-x-1/2' : 'right-0'
}`}
>
{children}
</div>
)}
</>
)
});
}
return child;
})}
</div>
</div>
);
}
interface DropdownMenuItemProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
}
export function DropdownMenuItem({ children, onClick, disabled = false }: DropdownMenuItemProps) {
return (
<button
className={`relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-gray-100 focus:bg-gray-100 disabled:pointer-events-none disabled:opacity-50 ${
disabled ? 'cursor-not-allowed opacity-50' : ''
}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}

View File

@@ -1,64 +0,0 @@
import React from 'react';
interface InputProps {
value?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
placeholder?: string;
className?: string;
readOnly?: boolean;
readonly?: boolean;
id?: string;
type?: string;
min?: number;
max?: number;
name?: string;
required?: boolean;
disabled?: boolean;
autoComplete?: string;
step?: number;
}
export function Input({
value,
onChange,
onKeyDown,
onClick,
placeholder,
className = '',
readOnly = false,
readonly = false,
id,
type = 'text',
min,
max,
name,
required = false,
disabled = false,
autoComplete,
step
}: InputProps) {
const isReadOnly = readOnly || readonly;
return (
<input
id={id}
type={type}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onClick={onClick}
placeholder={placeholder}
readOnly={isReadOnly}
min={min}
max={max}
name={name}
required={required}
disabled={disabled}
autoComplete={autoComplete}
step={step}
className={`flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
/>
);
}

View File

@@ -1,18 +0,0 @@
import React from 'react';
interface LabelProps {
children: React.ReactNode;
htmlFor?: string;
className?: string;
}
export function Label({ children, htmlFor, className = '' }: LabelProps) {
return (
<label
htmlFor={htmlFor}
className={`text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${className}`}
>
{children}
</label>
);
}

View File

@@ -1,17 +0,0 @@
import React from 'react';
interface ProgressProps {
value: number;
className?: string;
}
export function Progress({ value, className = '' }: ProgressProps) {
return (
<div className={`w-full bg-gray-200 rounded-full h-2 ${className}`}>
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
);
}

View File

@@ -1,44 +0,0 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -1,48 +0,0 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -1,184 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
interface SelectProps {
value?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
className?: string;
placeholder?: string;
children: React.ReactNode;
}
interface SelectTriggerProps {
children: React.ReactNode;
className?: string;
}
interface SelectContentProps {
children: React.ReactNode;
className?: string;
}
interface SelectItemProps {
value: string;
children: React.ReactNode;
className?: string;
}
interface SelectValueProps {
placeholder?: string;
className?: string;
}
export function Select({
value,
onValueChange,
disabled = false,
className = '',
placeholder,
children
}: SelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState(value || '');
const [selectedLabel, setSelectedLabel] = useState('');
const selectRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSelectedValue(value || '');
}, [value]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (value: string, label: string) => {
setSelectedValue(value);
setSelectedLabel(label);
onValueChange?.(value);
setIsOpen(false);
};
return (
<div ref={selectRef} className={`relative ${className}`}>
{React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
if (child.type === SelectTrigger) {
return React.cloneElement(child as any, {
onClick: () => !disabled && setIsOpen(!isOpen),
disabled,
selectedValue: selectedValue,
selectedLabel: selectedLabel,
placeholder,
isOpen
});
}
if (child.type === SelectContent && isOpen) {
return React.cloneElement(child as any, {
onSelect: handleSelect
});
}
}
return child;
})}
</div>
);
}
export function SelectTrigger({
children,
className = '',
onClick,
disabled,
selectedValue,
selectedLabel,
placeholder,
isOpen
}: SelectTriggerProps & {
onClick?: () => void;
disabled?: boolean;
selectedValue?: string;
selectedLabel?: string;
placeholder?: string;
isOpen?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`w-full px-3 py-2 text-left border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${className}`}
>
<span className="flex items-center justify-between">
<span className={selectedValue ? 'text-gray-900' : 'text-gray-500'}>
{selectedLabel || placeholder || '请选择...'}
</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</span>
</button>
);
}
export function SelectContent({
children,
className = '',
onSelect
}: SelectContentProps & {
onSelect?: (value: string, label: string) => void;
}) {
return (
<div className={`absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto ${className}`}>
{React.Children.map(children, (child) => {
if (React.isValidElement(child) && child.type === SelectItem) {
return React.cloneElement(child as any, {
onSelect
});
}
return child;
})}
</div>
);
}
export function SelectItem({
value,
children,
className = '',
onSelect
}: SelectItemProps & {
onSelect?: (value: string, label: string) => void;
}) {
return (
<button
type="button"
onClick={() => onSelect?.(value, children as string)}
className={`w-full px-3 py-2 text-left hover:bg-gray-100 focus:bg-gray-100 focus:outline-none ${className}`}
>
{children}
</button>
);
}
export function SelectValue({
placeholder,
className = ''
}: SelectValueProps) {
return (
<span className={className}>
{placeholder}
</span>
);
}

View File

@@ -1,28 +0,0 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "../../utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -1,15 +0,0 @@
import { cn } from "../../utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -1,34 +0,0 @@
import React from 'react';
interface SwitchProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
className?: string;
id?: string;
}
export function Switch({ checked, onCheckedChange, disabled = false, className = '', id }: SwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
id={id}
onClick={() => !disabled && onCheckedChange(!checked)}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50
${checked ? 'bg-blue-600' : 'bg-gray-200'}
${className}
`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
${checked ? 'translate-x-6' : 'translate-x-1'}
`}
/>
</button>
);
}

View File

@@ -1,69 +0,0 @@
import * as React from "react"
import { cn } from "@/utils"
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
),
)
Table.displayName = "Table"
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />,
)
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
),
)
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
),
)
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className)}
{...props}
/>
),
)
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
),
)
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className)}
{...props}
/>
),
)
TableCell.displayName = "TableCell"
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell }

View File

@@ -1,52 +0,0 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -1,33 +0,0 @@
import React from 'react';
interface TextareaProps {
value?: string;
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
rows?: number;
}
export function Textarea({
value,
onChange,
onKeyDown,
placeholder,
className = '',
disabled = false,
rows = 3
}: TextareaProps) {
return (
<textarea
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={rows}
className={`w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${className}`}
/>
);
}

View File

@@ -1,223 +0,0 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/utils"
export type { ToastActionElement, ToastProps };
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive: "destructive border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className,
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
// === 以下为 use-toast.ts 的内容迁移 ===
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_VALUE;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| { type: ActionType["ADD_TOAST"]; toast: ToasterToast }
| { type: ActionType["UPDATE_TOAST"]; toast: Partial<ToasterToast> }
| { type: ActionType["DISMISS_TOAST"]; toastId?: ToasterToast["id"] }
| { type: ActionType["REMOVE_TOAST"]; toastId?: ToasterToast["id"] };
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) return;
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({ type: "REMOVE_TOAST", toastId });
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return { ...state, toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT) };
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case "DISMISS_TOAST": {
const { toastId } = action;
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => addToRemoveQueue(toast.id));
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined ? { ...t, open: false } : t
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return { ...state, toasts: [] };
}
return { ...state, toasts: state.toasts.filter((t) => t.id !== action.toastId) };
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => listener(memoryState));
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) => dispatch({ type: "UPDATE_TOAST", toast: { ...props, id } });
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open: boolean) => {
if (!open) dismiss();
},
},
});
return { id, dismiss, update };
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) listeners.splice(index, 1);
};
}, []);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

View File

@@ -1,76 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
interface TooltipProviderProps {
children: React.ReactNode;
}
export function TooltipProvider({ children }: TooltipProviderProps) {
return <>{children}</>;
}
interface TooltipProps {
children: React.ReactNode;
}
export function Tooltip({ children }: TooltipProps) {
return <>{children}</>;
}
interface TooltipTriggerProps {
children: React.ReactNode;
asChild?: boolean;
}
export function TooltipTrigger({ children, asChild }: TooltipTriggerProps) {
return <>{children}</>;
}
interface TooltipContentProps {
children: React.ReactNode;
className?: string;
}
export function TooltipContent({ children, className = '' }: TooltipContentProps) {
const [isVisible, setIsVisible] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const showTooltip = () => setIsVisible(true);
const hideTooltip = () => setIsVisible(false);
trigger.addEventListener('mouseenter', showTooltip);
trigger.addEventListener('mouseleave', hideTooltip);
return () => {
trigger.removeEventListener('mouseenter', showTooltip);
trigger.removeEventListener('mouseleave', hideTooltip);
};
}, []);
return (
<div ref={triggerRef} className="relative inline-block">
{React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
return React.cloneElement(child, {
...child.props,
children: (
<>
{child.props.children}
{isVisible && (
<div className={`absolute z-50 px-2 py-1 text-xs text-white bg-gray-900 rounded shadow-lg whitespace-nowrap ${className}`} style={{ top: '-30px', left: '50%', transform: 'translateX(-50%)' }}>
{children}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900"></div>
</div>
)}
</>
)
});
}
return child;
})}
</div>
);
}

View File

@@ -1,188 +0,0 @@
"use client"
import * as React from "react"
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@@ -1,244 +0,0 @@
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { validateToken, refreshAuthToken } from '@/api';
// 安全的localStorage访问方法
const safeLocalStorage = {
getItem: (key: string): string | null => {
if (typeof window !== 'undefined') {
return localStorage.getItem(key);
}
return null;
},
setItem: (key: string, value: string): void => {
if (typeof window !== 'undefined') {
localStorage.setItem(key, value);
}
},
removeItem: (key: string): void => {
if (typeof window !== 'undefined') {
localStorage.removeItem(key);
}
}
};
interface User {
id: number;
username: string;
account?: string;
avatar?: string;
s2_accountId?: string;
}
interface AuthContextType {
isAuthenticated: boolean;
token: string | null;
user: User | null;
login: (token: string, userData: User) => void;
logout: () => void;
updateToken: (newToken: string) => void;
isLoading: boolean;
}
// 创建默认上下文
const AuthContext = createContext<AuthContextType>({
isAuthenticated: false,
token: null,
user: null,
login: () => {},
logout: () => {},
updateToken: () => {},
isLoading: true
});
export const useAuth = () => useContext(AuthContext);
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<User | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const navigate = useNavigate();
// 先声明handleLogout避免useEffect依赖提前引用
const handleLogout = useCallback(() => {
// 先清除所有认证相关的状态
safeLocalStorage.removeItem("token");
safeLocalStorage.removeItem("token_expired");
safeLocalStorage.removeItem("s2_accountId");
safeLocalStorage.removeItem("userInfo");
safeLocalStorage.removeItem("user");
setToken(null);
setUser(null);
setIsAuthenticated(false);
// 跳转到登录页面
navigate('/login');
}, [navigate]);
// 检查token是否过期
const isTokenExpired = (): boolean => {
const tokenExpired = safeLocalStorage.getItem("token_expired");
if (!tokenExpired) return true;
try {
const expiredTime = new Date(tokenExpired).getTime();
const currentTime = new Date().getTime();
// 提前5分钟认为过期给刷新留出时间
return currentTime >= (expiredTime - 5 * 60 * 1000);
} catch (error) {
console.error('解析token过期时间失败:', error);
return true;
}
};
// 验证token有效性
const verifyToken = async (): Promise<boolean> => {
try {
const isValid = await validateToken();
return isValid;
} catch (error) {
console.error('Token验证失败:', error);
return false;
}
};
// 尝试刷新token
const tryRefreshToken = async (): Promise<boolean> => {
try {
const success = await refreshAuthToken();
if (success) {
const newToken = safeLocalStorage.getItem("token");
if (newToken) {
setToken(newToken);
return true;
}
}
return false;
} catch (error) {
console.error('刷新token失败:', error);
return false;
}
};
// 初始化认证状态
useEffect(() => {
setIsLoading(true);
const initAuth = async () => {
try {
const storedToken = safeLocalStorage.getItem("token");
if (storedToken) {
// 检查token是否过期
if (isTokenExpired()) {
console.log('Token已过期尝试刷新...');
const refreshSuccess = await tryRefreshToken();
if (!refreshSuccess) {
console.log('Token刷新失败需要重新登录');
handleLogout();
return;
}
}
// 验证token有效性
const isValid = await verifyToken();
if (!isValid) {
console.log('Token无效需要重新登录');
handleLogout();
return;
}
// 获取用户信息
const userDataStr = safeLocalStorage.getItem("userInfo");
if (userDataStr) {
try {
const userData = JSON.parse(userDataStr) as User;
setToken(storedToken);
setUser(userData);
setIsAuthenticated(true);
} catch (parseError) {
console.error('解析用户数据失败:', parseError);
handleLogout();
}
} else {
console.warn('找到token但没有用户信息尝试保持登录状态');
setToken(storedToken);
setIsAuthenticated(true);
}
}
} catch (error) {
console.error("初始化认证状态时出错:", error);
handleLogout();
} finally {
setIsLoading(false);
setIsInitialized(true);
}
};
initAuth();
}, []);
// 定期检查token状态
useEffect(() => {
if (!isAuthenticated) return;
const checkTokenInterval = setInterval(async () => {
if (isTokenExpired()) {
console.log('检测到token即将过期尝试刷新...');
const refreshSuccess = await tryRefreshToken();
if (!refreshSuccess) {
console.log('Token刷新失败登出用户');
handleLogout();
}
}
}, 60000); // 每分钟检查一次
return () => clearInterval(checkTokenInterval);
}, [isAuthenticated, handleLogout]);
const login = (newToken: string, userData: User) => {
safeLocalStorage.setItem("token", newToken);
safeLocalStorage.setItem("userInfo", JSON.stringify(userData));
if (userData.s2_accountId) {
safeLocalStorage.setItem("s2_accountId", userData.s2_accountId);
}
setToken(newToken);
setUser(userData);
setIsAuthenticated(true);
};
const logout = () => {
handleLogout();
};
// 用于刷新 token 的方法
const updateToken = (newToken: string) => {
safeLocalStorage.setItem("token", newToken);
setToken(newToken);
};
return (
<AuthContext.Provider value={{
isAuthenticated,
token,
user,
login,
logout,
updateToken,
isLoading
}}>
{isLoading && isInitialized ? (
<div className="flex h-screen w-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
) : (
children
)}
</AuthContext.Provider>
);
}

View File

@@ -1,54 +0,0 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
export interface WechatAccountData {
id: string;
avatar: string;
nickname: string;
status: "normal" | "abnormal";
wechatId: string;
wechatAccount: string;
deviceName: string;
deviceId: string;
}
interface WechatAccountContextType {
currentAccount: WechatAccountData | null;
setCurrentAccount: (account: WechatAccountData) => void;
clearCurrentAccount: () => void;
}
const WechatAccountContext = createContext<WechatAccountContextType>({
currentAccount: null,
setCurrentAccount: () => {},
clearCurrentAccount: () => {},
});
export const useWechatAccount = () => useContext(WechatAccountContext);
interface WechatAccountProviderProps {
children: ReactNode;
}
export function WechatAccountProvider({ children }: WechatAccountProviderProps) {
const [currentAccount, setCurrentAccountState] = useState<WechatAccountData | null>(null);
const setCurrentAccount = (account: WechatAccountData) => {
setCurrentAccountState(account);
};
const clearCurrentAccount = () => {
setCurrentAccountState(null);
};
return (
<WechatAccountContext.Provider
value={{
currentAccount,
setCurrentAccount,
clearCurrentAccount,
}}
>
{children}
</WechatAccountContext.Provider>
);
}

View File

@@ -1,17 +0,0 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -1,80 +0,0 @@
import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
// 不需要登录的公共页面路径
const PUBLIC_PATHS = [
'/login',
'/register',
'/forgot-password',
'/reset-password',
'/404',
'/500'
];
/**
* 认证守卫Hook
* 用于在组件中检查用户是否已登录
* @param requireAuth 是否需要认证默认为true
* @param redirectTo 未认证时重定向的路径,默认为'/login'
*/
export function useAuthGuard(requireAuth: boolean = true, redirectTo: string = '/login') {
const { isAuthenticated, isLoading } = useAuth();
const navigate = useNavigate();
const location = useLocation();
// 检查当前路径是否是公共页面
const isPublicPath = PUBLIC_PATHS.some(path =>
location.pathname.startsWith(path)
);
useEffect(() => {
// 如果正在加载,不进行任何跳转
if (isLoading) {
return;
}
// 如果需要认证但未登录且不是公共页面
if (requireAuth && !isAuthenticated && !isPublicPath) {
// 保存当前URL登录后可以重定向回来
const returnUrl = encodeURIComponent(window.location.href);
navigate(`${redirectTo}?returnUrl=${returnUrl}`, { replace: true });
return;
}
// 如果已登录但在登录页面,重定向到首页
if (isAuthenticated && location.pathname === '/login') {
navigate('/', { replace: true });
return;
}
}, [isAuthenticated, isLoading, location.pathname, navigate, requireAuth, redirectTo, isPublicPath]);
return {
isAuthenticated,
isLoading,
isPublicPath,
// 是否应该显示内容
shouldRender: !isLoading && (isAuthenticated || isPublicPath || !requireAuth)
};
}
/**
* 简单的认证检查Hook
* 只返回认证状态,不进行自动重定向
*/
export function useAuthCheck() {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
const isPublicPath = PUBLIC_PATHS.some(path =>
location.pathname.startsWith(path)
);
return {
isAuthenticated,
isLoading,
isPublicPath,
// 是否需要认证
requiresAuth: !isPublicPath
};
}

View File

@@ -1,182 +0,0 @@
import { useCallback, useRef, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
interface BackNavigationOptions {
/** 默认返回路径,当没有历史记录时使用 */
defaultPath?: string;
/** 是否在组件卸载时保存当前路径到历史记录 */
saveOnUnmount?: boolean;
/** 最大历史记录数量 */
maxHistoryLength?: number;
/** 自定义返回逻辑 */
customBackLogic?: (history: string[], currentPath: string) => string | null;
}
interface BackNavigationReturn {
/** 返回上一页 */
goBack: () => void;
/** 返回到指定路径 */
goTo: (path: string) => void;
/** 返回到首页 */
goHome: () => void;
/** 检查是否可以返回 */
canGoBack: () => boolean;
/** 获取历史记录 */
getHistory: () => string[];
/** 清除历史记录 */
clearHistory: () => void;
/** 当前路径 */
currentPath: string;
}
/**
* 高级返回导航Hook
* 提供更智能的返回逻辑和历史记录管理
*/
export const useBackNavigation = (options: BackNavigationOptions = {}): BackNavigationReturn => {
const navigate = useNavigate();
const location = useLocation();
const historyRef = useRef<string[]>([]);
const {
defaultPath = '/',
saveOnUnmount = true,
maxHistoryLength = 10,
customBackLogic
} = options;
// 保存路径到历史记录
const saveToHistory = useCallback((path: string) => {
const history = historyRef.current;
// 如果路径已经存在,移除它
const filteredHistory = history.filter(p => p !== path);
// 添加到开头
filteredHistory.unshift(path);
// 限制历史记录长度
if (filteredHistory.length > maxHistoryLength) {
filteredHistory.splice(maxHistoryLength);
}
historyRef.current = filteredHistory;
}, [maxHistoryLength]);
// 获取历史记录
const getHistory = useCallback(() => {
return [...historyRef.current];
}, []);
// 清除历史记录
const clearHistory = useCallback(() => {
historyRef.current = [];
}, []);
// 检查是否可以返回
const canGoBack = useCallback(() => {
return historyRef.current.length > 1 || window.history.length > 1;
}, []);
// 返回上一页
const goBack = useCallback(() => {
const history = getHistory();
// 如果有自定义返回逻辑,使用它
if (customBackLogic) {
const targetPath = customBackLogic(history, location.pathname);
if (targetPath) {
navigate(targetPath);
return;
}
}
// 如果有历史记录,返回到上一个路径
if (history.length > 1) {
const previousPath = history[1]; // 当前路径在索引0上一个在索引1
navigate(previousPath);
return;
}
// 如果浏览器历史记录有上一页,使用浏览器返回
if (window.history.length > 1) {
navigate(-1);
return;
}
// 最后回退到默认路径
navigate(defaultPath);
}, [navigate, location.pathname, getHistory, customBackLogic, defaultPath]);
// 返回到指定路径
const goTo = useCallback((path: string) => {
navigate(path);
}, [navigate]);
// 返回到首页
const goHome = useCallback(() => {
navigate('/');
}, [navigate]);
// 组件挂载时保存当前路径
useEffect(() => {
saveToHistory(location.pathname);
}, [location.pathname, saveToHistory]);
// 组件卸载时保存路径(可选)
useEffect(() => {
if (!saveOnUnmount) return;
return () => {
saveToHistory(location.pathname);
};
}, [location.pathname, saveToHistory, saveOnUnmount]);
return {
goBack,
goTo,
goHome,
canGoBack,
getHistory,
clearHistory,
currentPath: location.pathname
};
};
/**
* 简化的返回Hook只提供基本的返回功能
*/
export const useSimpleBack = (defaultPath: string = '/') => {
const navigate = useNavigate();
const goBack = useCallback(() => {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate(defaultPath);
}
}, [navigate, defaultPath]);
return { goBack };
};
/**
* 带确认的返回Hook
*/
export const useConfirmBack = (
message: string = '确定要离开当前页面吗?',
defaultPath: string = '/'
) => {
const navigate = useNavigate();
const goBack = useCallback(() => {
if (window.confirm(message)) {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate(defaultPath);
}
}
}, [navigate, message, defaultPath]);
return { goBack };
};

View File

@@ -1,265 +0,0 @@
import { useCallback, useRef, useState, useEffect } from 'react';
import { requestDeduplicator, requestCancelManager } from '../api/utils';
// 节流请求Hook
export const useThrottledRequest = <T extends (...args: any[]) => any>(
requestFn: T,
delay: number = 1000
) => {
const lastCallRef = useRef(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const throttledRequest = useCallback(
((...args: any[]) => {
const now = Date.now();
if (now - lastCallRef.current < delay) {
// 如果在节流时间内,取消之前的定时器并设置新的
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
lastCallRef.current = now;
requestFn(...args);
}, delay - (now - lastCallRef.current));
} else {
// 如果超过节流时间,直接执行
lastCallRef.current = now;
requestFn(...args);
}
}) as T,
[requestFn, delay]
);
return throttledRequest;
};
// 防抖请求Hook
export const useDebouncedRequest = <T extends (...args: any[]) => any>(
requestFn: T,
delay: number = 300
) => {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const debouncedRequest = useCallback(
((...args: any[]) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
requestFn(...args);
}, delay);
}) as T,
[requestFn, delay]
);
return debouncedRequest;
};
// 带加载状态的请求Hook
export const useRequestWithLoading = <T extends (...args: any[]) => Promise<any>>(
requestFn: T
) => {
const [loading, setLoading] = useState(false);
const requestWithLoading = useCallback(
(async (...args: any[]) => {
if (loading) {
console.log('请求正在进行中,跳过重复请求');
return;
}
setLoading(true);
try {
const result = await requestFn(...args);
return result;
} finally {
setLoading(false);
}
}) as T,
[requestFn, loading]
);
return { requestWithLoading, loading };
};
// 组合Hook节流 + 加载状态
export const useThrottledRequestWithLoading = <T extends (...args: any[]) => Promise<any>>(
requestFn: T,
delay: number = 1000
) => {
const { requestWithLoading, loading } = useRequestWithLoading(requestFn);
const throttledRequest = useThrottledRequest(requestWithLoading, delay);
return { throttledRequest, loading };
};
// 带错误处理的请求Hook
export const useRequestWithError = <T extends (...args: any[]) => Promise<any>>(
requestFn: T
) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestWithError = useCallback(
(async (...args: any[]) => {
setError(null);
setLoading(true);
try {
const result = await requestFn(...args);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '请求失败';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}) as T,
[requestFn]
);
return { requestWithError, loading, error, clearError: () => setError(null) };
};
// 带重试的请求Hook
export const useRequestWithRetry = <T extends (...args: any[]) => Promise<any>>(
requestFn: T,
maxRetries: number = 3,
retryDelay: number = 1000
) => {
const [loading, setLoading] = useState(false);
const [retryCount, setRetryCount] = useState(0);
const requestWithRetry = useCallback(
(async (...args: any[]) => {
setLoading(true);
setRetryCount(0);
let lastError: any;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
setRetryCount(attempt);
const result = await requestFn(...args);
return result;
} catch (error) {
lastError = error;
if (attempt === maxRetries) {
throw error;
}
// 等待后重试
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
throw lastError;
}) as T,
[requestFn, maxRetries, retryDelay]
);
return { requestWithRetry, loading, retryCount };
};
// 可取消的请求Hook
export const useCancellableRequest = <T extends (...args: any[]) => Promise<any>>(
requestFn: T
) => {
const [loading, setLoading] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const cancellableRequest = useCallback(
(async (...args: any[]) => {
// 取消之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// 创建新的AbortController
abortControllerRef.current = new AbortController();
setLoading(true);
try {
const result = await requestFn(...args);
return result;
} finally {
setLoading(false);
abortControllerRef.current = null;
}
}) as T,
[requestFn]
);
const cancelRequest = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
setLoading(false);
abortControllerRef.current = null;
}
}, []);
// 组件卸载时取消请求
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return { cancellableRequest, loading, cancelRequest };
};
// 组合Hook节流 + 加载状态 + 错误处理
export const useThrottledRequestWithError = <T extends (...args: any[]) => Promise<any>>(
requestFn: T,
delay: number = 1000
) => {
const { requestWithError, loading, error, clearError } = useRequestWithError(requestFn);
const throttledRequest = useThrottledRequest(requestWithError, delay);
return { throttledRequest, loading, error, clearError };
};
// 组合Hook防抖 + 加载状态 + 错误处理
export const useDebouncedRequestWithError = <T extends (...args: any[]) => Promise<any>>(
requestFn: T,
delay: number = 300
) => {
const { requestWithError, loading, error, clearError } = useRequestWithError(requestFn);
const debouncedRequest = useDebouncedRequest(requestWithError, delay);
return { debouncedRequest, loading, error, clearError };
};
// 请求状态监控Hook
export const useRequestMonitor = () => {
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
const updatePendingCount = () => {
setPendingCount(requestDeduplicator.getPendingCount());
};
// 初始更新
updatePendingCount();
// 定期检查待处理请求数量
const interval = setInterval(updatePendingCount, 100);
return () => clearInterval(interval);
}, []);
const cancelAllRequests = useCallback(() => {
requestCancelManager.cancelAllRequests();
requestDeduplicator.clear();
}, []);
return { pendingCount, cancelAllRequests };
};

View File

@@ -1,37 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
font-size: 16px; /* 基础字体大小1rem = 16px */
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 1rem; /* 16px */
line-height: 1.5;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* 移动端适配 */
@media screen and (max-width: 768px) {
html {
font-size: 16px; /* 移动端保持16px基础字体大小 */
}
}
/* 小屏幕设备适配 */
@media screen and (max-width: 375px) {
html {
font-size: 14px; /* 小屏幕设备稍微减小字体 */
}
}

View File

@@ -1,46 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
// 全局错误处理 - 过滤浏览器扩展错误
window.addEventListener('error', (event) => {
// 过滤掉扩展相关的错误
if (event.filename && (
event.filename.includes('content_scripts') ||
event.filename.includes('extension') ||
event.filename.includes('chrome-extension') ||
event.filename.includes('moz-extension')
)) {
event.preventDefault();
console.warn('浏览器扩展错误已忽略:', event.message);
return false;
}
});
// 处理未捕获的 Promise 错误
window.addEventListener('unhandledrejection', (event) => {
const errorMessage = event.reason?.message || event.reason?.toString() || '';
if (errorMessage.includes('shadowRoot') ||
errorMessage.includes('content_scripts') ||
errorMessage.includes('extension')) {
event.preventDefault();
console.warn('浏览器扩展 Promise 错误已忽略:', event.reason);
return false;
}
});
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,490 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Bell, Smartphone, Users, Activity, MessageSquare, TrendingUp } from 'lucide-react';
import Chart from 'chart.js/auto';
import Layout from '@/components/Layout';
import BottomNav from '@/components/BottomNav';
import UnifiedHeader, { HeaderPresets } from '@/components/UnifiedHeader';
import { Card } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import '@/components/Layout.css';
// API接口定义
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || "https://ckbapi.quwanzhi.com";
// 统一的API请求客户端
async function apiRequest<T>(url: string): Promise<T> {
try {
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
console.log("发送API请求:", url);
const response = await fetch(url, {
method: "GET",
headers,
mode: "cors",
});
console.log("API响应状态:", response.status, response.statusText);
// 检查响应头的Content-Type
const contentType = response.headers.get("content-type");
console.log("响应Content-Type:", contentType);
if (!response.ok) {
// 如果是401未授权清除本地存储
if (response.status === 401) {
if (typeof window !== "undefined") {
localStorage.removeItem("token");
localStorage.removeItem("userInfo");
}
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// 检查是否是JSON响应
if (!contentType || !contentType.includes("application/json")) {
const text = await response.text();
console.log("非JSON响应内容:", text.substring(0, 200));
throw new Error("服务器返回了非JSON格式的数据可能是HTML错误页面");
}
const data = await response.json();
console.log("API响应数据:", data);
// 检查业务状态码
if (data.code && data.code !== 200 && data.code !== 0) {
throw new Error(data.message || "请求失败");
}
return data.data || data;
} catch (error) {
console.error("API请求失败:", error);
throw error;
}
}
export default function Home() {
const navigate = useNavigate();
const chartRef = useRef<HTMLCanvasElement>(null);
const chartInstance = useRef<any>(null);
// 统一设备数据
const [stats, setStats] = useState({
totalDevices: 0,
onlineDevices: 0,
totalWechatAccounts: 0,
onlineWechatAccounts: 0,
});
const [isLoading, setIsLoading] = useState(true);
const [apiError, setApiError] = useState("");
// 场景获客数据
const scenarioFeatures = [
{
id: "douyin",
name: "抖音获客",
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-QR8ManuDplYTySUJsY4mymiZkDYnQ9.png",
color: "bg-blue-100 text-blue-600",
value: 156,
growth: 12,
},
{
id: "xiaohongshu",
name: "小红书获客",
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-yvnMxpoBUzcvEkr8DfvHgPHEo1kmQ3.png",
color: "bg-red-100 text-red-600",
value: 89,
growth: 8,
},
{
id: "gongzhonghao",
name: "公众号获客",
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-Gsg0CMf5tsZb41mioszdjqU1WmsRxW.png",
color: "bg-green-100 text-green-600",
value: 234,
growth: 15,
},
{
id: "haibao",
name: "海报获客",
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-x92XJgXy4MI7moNYlA1EAes2FqDxMH.png",
color: "bg-orange-100 text-orange-600",
value: 167,
growth: 10,
},
];
// 今日数据统计
const todayStats = [
{
title: "朋友圈同步",
value: "12",
icon: <MessageSquare className="h-4 w-4" />,
color: "text-purple-600",
path: "/workspace/moments-sync",
},
{
title: "群发任务",
value: "8",
icon: <Users className="h-4 w-4" />,
color: "text-orange-600",
path: "/workspace/group-push",
},
{
title: "获客转化",
value: "85%",
icon: <TrendingUp className="h-4 w-4" />,
color: "text-green-600",
path: "/scenarios",
},
{
title: "系统活跃度",
value: "98%",
icon: <Activity className="h-4 w-4" />,
color: "text-blue-600",
path: "/workspace",
},
];
useEffect(() => {
// 获取统计数据
const fetchStats = async () => {
try {
setIsLoading(true);
setApiError("");
// 检查是否有token
const token = localStorage.getItem("token");
if (!token) {
console.log("未找到登录token使用默认数据");
setStats({
totalDevices: 42,
onlineDevices: 35,
totalWechatAccounts: 42,
onlineWechatAccounts: 35,
});
setIsLoading(false);
return;
}
// 尝试请求API数据
try {
// 并行请求多个接口
const [deviceStatsResult, wechatStatsResult] = await Promise.allSettled([
apiRequest(`${API_BASE_URL}/v1/dashboard/device-stats`),
apiRequest(`${API_BASE_URL}/v1/dashboard/wechat-stats`),
]);
const newStats = {
totalDevices: 0,
onlineDevices: 0,
totalWechatAccounts: 0,
onlineWechatAccounts: 0,
};
// 处理设备统计数据
if (deviceStatsResult.status === "fulfilled") {
const deviceData = deviceStatsResult.value as any;
newStats.totalDevices = deviceData.total || 0;
newStats.onlineDevices = deviceData.online || 0;
} else {
console.warn("设备统计API失败:", deviceStatsResult.reason);
}
// 处理微信号统计数据
if (wechatStatsResult.status === "fulfilled") {
const wechatData = wechatStatsResult.value as any;
newStats.totalWechatAccounts = wechatData.total || 0;
newStats.onlineWechatAccounts = wechatData.active || 0;
} else {
console.warn("微信号统计API失败:", wechatStatsResult.reason);
}
setStats(newStats);
} catch (apiError) {
console.warn("API请求失败使用默认数据:", apiError);
setApiError(apiError instanceof Error ? apiError.message : "API连接失败");
// 使用默认数据
setStats({
totalDevices: 42,
onlineDevices: 35,
totalWechatAccounts: 42,
onlineWechatAccounts: 35,
});
}
} catch (error) {
console.error("获取统计数据失败:", error);
setApiError(error instanceof Error ? error.message : "数据加载失败");
// 使用默认数据
setStats({
totalDevices: 42,
onlineDevices: 35,
totalWechatAccounts: 42,
onlineWechatAccounts: 35,
});
} finally {
setIsLoading(false);
}
};
fetchStats();
// 定时刷新数据每30秒
const interval = setInterval(fetchStats, 30000);
return () => clearInterval(interval);
}, []); // 移除stats依赖
const handleDevicesClick = () => {
navigate('/profile/devices');
};
const handleWechatClick = () => {
navigate('/wechat-accounts');
};
// 使用Chart.js创建图表
useEffect(() => {
if (chartRef.current && !isLoading) {
// 如果已经有图表实例,先销毁它
if (chartInstance.current) {
chartInstance.current.destroy();
}
const ctx = chartRef.current.getContext("2d");
// 添加null检查
if (!ctx) return;
// 创建新的图表实例
chartInstance.current = new Chart(ctx, {
type: "line",
data: {
labels: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
datasets: [
{
label: "获客数量",
data: [120, 150, 180, 200, 230, 210, 190],
backgroundColor: "rgba(59, 130, 246, 0.2)",
borderColor: "rgba(59, 130, 246, 1)",
borderWidth: 2,
tension: 0.3,
pointRadius: 4,
pointBackgroundColor: "rgba(59, 130, 246, 1)",
pointHoverRadius: 6,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: "rgba(255, 255, 255, 0.9)",
titleColor: "#333",
bodyColor: "#666",
borderColor: "#ddd",
borderWidth: 1,
padding: 10,
displayColors: false,
callbacks: {
label: (context) => `获客数量: ${context.parsed.y}`,
},
},
},
scales: {
x: {
grid: {
display: false,
},
},
y: {
beginAtZero: true,
grid: {
color: "rgba(0, 0, 0, 0.05)",
},
},
},
},
});
}
// 组件卸载时清理图表实例
return () => {
if (chartInstance.current) {
chartInstance.current.destroy();
}
};
}, [isLoading]);
if (isLoading) {
return (
<Layout
header={
<div className="bg-white border-b">
<div className="flex justify-between items-center p-4">
<h1 className="text-xl font-semibold text-blue-600"></h1>
</div>
</div>
}
footer={<BottomNav />}
>
<div className="bg-gray-50">
<div className="p-4 space-y-4">
<div className="grid grid-cols-3 gap-3">
{[...Array(3)].map((_, i) => (
<Card key={i} className="p-3 bg-white animate-pulse">
<div className="h-4 bg-gray-200 rounded mb-2"></div>
<div className="h-6 bg-gray-200 rounded"></div>
</Card>
))}
</div>
</div>
</div>
</Layout>
);
}
return (
<Layout
header={
<UnifiedHeader
title="存客宝"
showBack={false}
titleColor="blue"
rightContent={
<>
{apiError && (
<div className="text-xs text-orange-600 bg-orange-50 px-2 py-1 rounded mr-2">
API连接异常
</div>
)}
<button className="p-2 hover:bg-gray-100 rounded-full">
<Bell className="h-5 w-5 text-gray-600" />
</button>
</>
}
/>
}
footer={<BottomNav />}
>
<div className="bg-gray-50">
<div className="p-4 space-y-4">
{/* 统计卡片 */}
<div className="grid grid-cols-3 gap-3">
<div className="cursor-pointer" onClick={handleDevicesClick}>
<Card className="p-3 bg-white hover:shadow-md transition-all">
<div className="flex flex-col">
<span className="text-xs text-gray-500 mb-1"></span>
<div className="flex items-center justify-between">
<span className="text-lg font-bold text-blue-600">{stats.totalDevices}</span>
<Smartphone className="w-5 h-5 text-blue-600" />
</div>
</div>
</Card>
</div>
<div className="cursor-pointer" onClick={handleWechatClick}>
<Card className="p-3 bg-white hover:shadow-md transition-all">
<div className="flex flex-col">
<span className="text-xs text-gray-500 mb-1"></span>
<div className="flex items-center justify-between">
<span className="text-lg font-bold text-blue-600">{stats.totalWechatAccounts}</span>
<Users className="w-5 h-5 text-blue-600" />
</div>
</div>
</Card>
</div>
<Card className="p-3 bg-white">
<div className="flex flex-col">
<span className="text-xs text-gray-500 mb-1">线</span>
<div className="flex items-center justify-between mb-1">
<span className="text-lg font-bold text-blue-600">{stats.onlineWechatAccounts}</span>
<Activity className="w-5 h-5 text-blue-600" />
</div>
<Progress
value={
stats.totalWechatAccounts > 0 ? (stats.onlineWechatAccounts / stats.totalWechatAccounts) * 100 : 0
}
className="h-1"
/>
</div>
</Card>
</div>
{/* 场景获客统计 */}
<Card className="p-4 bg-white">
<div className="flex justify-between items-center mb-3">
<h2 className="text-base font-semibold"></h2>
</div>
<div className="flex justify-between">
{scenarioFeatures
.sort((a, b) => b.value - a.value)
.slice(0, 4) // 只显示前4个
.map((scenario) => (
<div
key={scenario.id}
className="block flex-1 cursor-pointer"
onClick={() => navigate(`/scenarios/${scenario.id}?name=${encodeURIComponent(scenario.name)}`)}
>
<div className="flex flex-col items-center text-center space-y-1">
<div className={`w-10 h-10 rounded-full ${scenario.color} flex items-center justify-center`}>
<img src={scenario.icon || "/placeholder.svg"} alt={scenario.name} className="w-5 h-5" />
</div>
<div className="text-sm font-medium">{scenario.value}</div>
<div className="text-xs text-gray-500 whitespace-nowrap overflow-hidden text-ellipsis w-full">
{scenario.name}
</div>
</div>
</div>
))}
</div>
</Card>
{/* 今日数据统计 */}
<Card className="p-4 bg-white">
<div className="flex justify-between items-center mb-3">
<h2 className="text-base font-semibold"></h2>
</div>
<div className="grid grid-cols-2 gap-4">
{todayStats.map((stat, index) => (
<div
key={index}
className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors"
onClick={() => stat.path && navigate(stat.path)}
>
<div className={`p-2 rounded-full bg-white ${stat.color}`}>{stat.icon}</div>
<div>
<div className="text-lg font-semibold">{stat.value}</div>
<div className="text-xs text-gray-500">{stat.title}</div>
</div>
</div>
))}
</div>
</Card>
{/* 每日获客趋势 */}
<Card className="p-4 bg-white">
<h2 className="text-base font-semibold mb-3"></h2>
<div className="w-full h-48 relative">
<canvas ref={chartRef} />
</div>
</Card>
</div>
</div>
</Layout>
);
}

View File

@@ -1,5 +0,0 @@
import React from 'react';
export default function ContactImport() {
return <div></div>;
}

View File

@@ -1,382 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronLeft, Filter, Search, RefreshCw, Plus, Edit, Trash2, Eye, MoreVertical, Copy } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { useToast } from '@/components/ui/toast';
import { get, del } from '@/api/request';
import Layout from '@/components/Layout';
import UnifiedHeader from '@/components/UnifiedHeader';
import BottomNav from '@/components/BottomNav';
interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
interface LibraryListResponse {
list: ContentLibrary[];
total: number;
}
interface WechatGroupMember {
id: string;
nickname: string;
wechatId: string;
avatar: string;
gender?: 'male' | 'female';
role?: 'owner' | 'admin' | 'member';
joinTime?: string;
}
interface ContentLibrary {
id: string;
name: string;
source: 'friends' | 'groups';
targetAudience: {
id: string;
nickname: string;
avatar: string;
}[];
creator: string;
creatorName?: string;
itemCount: number;
lastUpdated: string;
enabled: boolean;
sourceFriends: string[];
sourceGroups: string[];
friendsData?: any[];
groupsData?: any[];
keywordInclude: string[];
keywordExclude: string[];
isEnabled: number;
aiPrompt: string;
timeEnabled: number;
timeStart: string;
timeEnd: string;
status: number;
createTime: string;
updateTime: string;
sourceType: number;
selectedGroupMembers?: WechatGroupMember[];
}
function CardMenu({ onView, onEdit, onDelete, onViewMaterials }: {
onView: () => void;
onEdit: () => void;
onDelete: () => void;
onViewMaterials: () => void;
}) {
const [open, setOpen] = useState(false);
const menuRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
if (open) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
return (
<div style={{ position: "relative" }}>
<button onClick={() => setOpen((v) => !v)} style={{ background: "none", border: "none", padding: 0, margin: 0, cursor: "pointer" }}>
<MoreVertical className="h-4 w-4" />
</button>
{open && (
<div
ref={menuRef}
style={{
position: "absolute",
right: 0,
top: 28,
background: "#fff",
borderRadius: 8,
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
zIndex: 100,
minWidth: 120,
padding: 4,
}}
>
<div onClick={() => { onEdit(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
<Edit className="h-4 w-4 mr-2" />
</div>
<div onClick={() => { onDelete(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, color: "#e53e3e", transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
<Trash2 className="h-4 w-4 mr-2" />
</div>
<div onClick={() => { onViewMaterials(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
<Eye className="h-4 w-4 mr-2" />
</div>
</div>
)}
</div>
);
}
export default function Content() {
const navigate = useNavigate();
const [libraries, setLibraries] = useState<ContentLibrary[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState('all');
const [loading, setLoading] = useState(false);
const { toast } = useToast();
// 获取内容库列表
const fetchLibraries = useCallback(async () => {
setLoading(true);
try {
const queryParams = new URLSearchParams({
page: '1',
limit: '100',
...(searchQuery ? { keyword: searchQuery } : {}),
...(activeTab !== 'all' ? { sourceType: activeTab === 'friends' ? '1' : '2' } : {})
});
const response = await get<ApiResponse<LibraryListResponse>>(`/v1/content/library/list?${queryParams.toString()}`);
if (response.code === 200 && response.data) {
// 转换数据格式以匹配原有UI
const transformedLibraries = response.data.list.map((item: any) => {
const friendsData = Array.isArray(item.selectedFriends) ? item.selectedFriends : [];
const groupsData = Array.isArray(item.selectedGroups) ? item.selectedGroups : [];
const transformedItem: ContentLibrary = {
id: item.id,
name: item.name,
source: item.sourceType === 1 ? 'friends' : 'groups',
targetAudience: [
...friendsData.map((friend: any) => ({
id: friend.id,
nickname: friend.nickname || `好友${friend.id}`,
avatar: friend.avatar || '/placeholder.svg'
})),
...groupsData.map((group: any) => ({
id: group.id,
nickname: group.name || `群组${group.id}`,
avatar: group.avatar || '/placeholder.svg'
}))
],
creator: item.creatorName || '系统',
creatorName: item.creatorName,
itemCount: item.itemCount,
lastUpdated: item.updateTime,
enabled: item.isEnabled === 1,
sourceFriends: item.sourceFriends || [],
sourceGroups: item.sourceGroups || [],
friendsData: friendsData,
groupsData: groupsData,
keywordInclude: item.keywordInclude || [],
keywordExclude: item.keywordExclude || [],
isEnabled: item.isEnabled,
aiPrompt: item.aiPrompt || '',
timeEnabled: item.timeEnabled,
timeStart: item.timeStart || '',
timeEnd: item.timeEnd || '',
status: item.status,
createTime: item.createTime,
updateTime: item.updateTime,
sourceType: item.sourceType,
selectedGroupMembers: item.selectedGroupMembers || []
};
return transformedItem;
});
setLibraries(transformedLibraries);
} else {
toast({ title: '获取失败', description: response.msg || '获取内容库列表失败' });
}
} catch (error: any) {
console.error('获取内容库列表失败:', error);
toast({ title: '网络错误', description: error?.message || '请检查网络连接' });
} finally {
setLoading(false);
}
}, [searchQuery, activeTab, toast]);
useEffect(() => {
fetchLibraries();
}, [fetchLibraries]);
const handleCreateNew = () => {
navigate('/content/new');
};
const handleEdit = (id: string) => {
navigate(`/content/edit/${id}`);
};
const handleDelete = async (id: string) => {
try {
const response = await del<ApiResponse>(`/v1/content/library/delete?id=${id}`);
if (response.code === 200) {
toast({ title: '删除成功', description: '内容库已删除' });
fetchLibraries();
} else {
toast({ title: '删除失败', description: response.msg || '删除失败' });
}
} catch (error: any) {
console.error('删除内容库失败:', error);
toast({ title: '网络错误', description: error?.message || '请检查网络连接' });
}
};
const handleViewMaterials = (id: string) => {
navigate(`/content/materials/${id}`);
};
const handleSearch = () => {
fetchLibraries();
};
const handleRefresh = () => {
fetchLibraries();
};
const filteredLibraries = libraries.filter(
(library) =>
library.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
library.targetAudience.some((target) => target.nickname.toLowerCase().includes(searchQuery.toLowerCase()))
);
return (
<Layout
header={
<>
<UnifiedHeader title="内容库" showBack />
<div className="bg-white shadow-sm rounded-b-xl px-4 pt-4 pb-2">
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索内容库..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="pl-9 rounded-full bg-gray-50 border-none focus:ring-2 focus:ring-blue-100"
/>
</div>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={loading}
className="rounded-full border-gray-200"
>
<RefreshCw className={`h-5 w-5 ${loading ? 'animate-spin' : ''}`} />
</Button>
<Button onClick={handleCreateNew} className="rounded-full px-4 py-2" size="sm">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="mt-3">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3 rounded-full bg-gray-100">
<TabsTrigger value="all" className="rounded-full"></TabsTrigger>
<TabsTrigger value="friends" className="rounded-full"></TabsTrigger>
<TabsTrigger value="groups" className="rounded-full"></TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
</>
}
footer={<BottomNav />}
>
<div className="space-y-4 p-4">
<div className="space-y-3">
{loading ? (
<div className="flex justify-center items-center py-12">
<RefreshCw className="h-8 w-8 text-blue-500 animate-spin" />
</div>
) : filteredLibraries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<img src="/empty-state-content.svg" alt="暂无内容库" className="w-32 h-32 mb-4 opacity-80" />
<div className="mb-2"></div>
<Button onClick={handleCreateNew} size="sm" className="rounded-full px-6"></Button>
</div>
) : (
filteredLibraries.map((library, idx) => (
<Card
key={library.id}
className={`p-4 rounded-xl shadow-sm border border-gray-100 transition hover:shadow-md bg-white ${idx !== filteredLibraries.length - 1 ? 'mb-2' : ''}`}
>
<div className="flex items-start justify-between">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<h3 className="font-medium text-base text-gray-900">{library.name}</h3>
<Badge variant={library.isEnabled === 1 ? 'default' : 'secondary'} className="text-xs rounded-full px-2">
{library.isEnabled === 1 ? '已启用' : '未启用'}
</Badge>
</div>
<div className="text-xs text-gray-500 space-y-1">
<div className="flex items-center space-x-1">
<span></span>
{library.sourceType === 1 && library.sourceFriends?.length > 0 ? (
<div className="flex -space-x-1 overflow-hidden">
{(library.friendsData || []).slice(0, 3).map((friend) => (
<img
key={friend.id}
src={friend.avatar || '/placeholder.svg'}
alt={friend.nickname || `好友${friend.id}`}
className="inline-block h-6 w-6 rounded-full ring-2 ring-white"
/>
))}
{library.sourceFriends.length > 3 && (
<span className="flex items-center justify-center h-6 w-6 rounded-full bg-gray-200 text-xs font-medium text-gray-800">
+{library.sourceFriends.length - 3}
</span>
)}
</div>
) : library.sourceType === 2 && library.sourceGroups?.length > 0 ? (
<div className="flex items-center space-x-2">
<div className="flex -space-x-1 overflow-hidden">
{(library.groupsData || []).slice(0, 3).map((group) => (
<img
key={group.id}
src={group.avatar || '/placeholder.svg'}
alt={group.name || `群组${group.id}`}
className="inline-block h-6 w-6 rounded-full ring-2 ring-white"
/>
))}
{library.sourceGroups.length > 3 && (
<span className="flex items-center justify-center h-6 w-6 rounded-full bg-gray-200 text-xs font-medium text-gray-800">
+{library.sourceGroups.length - 3}
</span>
)}
</div>
</div>
) : (
<div className="w-6 h-6 bg-gray-200 rounded-full"></div>
)}
</div>
<div>{library.creator}</div>
<div>{library.itemCount}</div>
<div>{new Date(library.updateTime).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}</div>
</div>
</div>
<CardMenu
onView={() => navigate(`/content/${library.id}`)}
onEdit={() => handleEdit(library.id)}
onDelete={() => handleDelete(library.id)}
onViewMaterials={() => handleViewMaterials(library.id)}
/>
</div>
</Card>
))
)}
</div>
</div>
</Layout>
);
}

View File

@@ -1,482 +0,0 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import Layout from "@/components/Layout";
import UnifiedHeader from "@/components/UnifiedHeader";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Card } from "@/components/ui/card";
import { Collapse, CollapsePanel, Button } from "tdesign-mobile-react";
import { toast } from "@/components/ui/toast";
import FriendSelection from "@/components/FriendSelection";
import GroupSelection from "@/components/GroupSelection";
import { get, post } from "@/api/request";
// TODO: 引入微信好友/群组选择器、日期选择器等组件
interface WechatFriend {
id: string;
nickname: string;
avatar: string;
}
interface WechatGroup {
id: string;
name: string;
avatar: string;
}
interface ContentLibraryForm {
name: string;
sourceType: "friends" | "groups";
keywordsInclude: string;
keywordsExclude: string;
startDate: string;
endDate: string;
selectedFriends: WechatFriend[];
selectedGroups: WechatGroup[];
useAI: boolean;
aiPrompt: string;
enabled: boolean;
}
export default function NewContentLibraryPage() {
const navigate = useNavigate();
const { id } = useParams();
const isEdit = !!id;
const [form, setForm] = useState<ContentLibraryForm>({
name: "",
sourceType: "friends",
keywordsInclude: "",
keywordsExclude: "",
startDate: "",
endDate: "",
selectedFriends: [],
selectedGroups: [],
useAI: false,
aiPrompt: "",
enabled: true,
});
const [selectedFriendObjs, setSelectedFriendObjs] = useState<WechatFriend[]>(
[]
);
const [selectedGroupObjs, setSelectedGroupObjs] = useState<WechatGroup[]>([]);
const [isFriendSelectorOpen, setIsFriendSelectorOpen] = useState(false);
const [isGroupSelectorOpen, setIsGroupSelectorOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (isEdit) {
(async () => {
const res = await get(`/v1/content/library/detail?id=${id}`);
if (res && res.code === 200 && res.data) {
const data = res.data;
// 时间戳转YYYY-MM-DD
const formatDate = (val: number) => {
if (
!val ||
val === 0 ||
typeof val !== "number" ||
isNaN(val) ||
val < 1000000000
)
return "";
try {
const d = new Date(val * 1000);
if (isNaN(d.getTime())) return "";
return d.toISOString().slice(0, 10);
} catch {
return "";
}
};
setForm((f) => ({
...f,
name: data.name || "",
sourceType: data.sourceType === 1 ? "friends" : "groups",
keywordsInclude: (data.keywordInclude || []).join(","),
keywordsExclude: (data.keywordExclude || []).join(","),
startDate: formatDate(data.timeStart),
endDate: formatDate(data.timeEnd),
selectedFriends: (
data.selectedFriends ||
data.sourceFriends ||
[]
).map((fid: number | string) => ({
id: String(fid),
nickname: String(fid),
avatar: "",
})),
selectedGroups: (data.sourceGroups || []).map(
(gid: number | string) => ({
id: String(gid),
name: String(gid),
avatar: "",
})
),
useAI: data.aiEnabled === 1,
aiPrompt: data.aiPrompt || "",
enabled: data.status === 1,
}));
setSelectedFriendObjs(
(data.selectedFriends || data.sourceFriends || []).map(
(fid: number | string) => ({
id: String(fid),
nickname: String(fid),
avatar: "",
})
)
);
setSelectedGroupObjs(
(data.sourceGroups || []).map((gid: number | string) => ({
id: String(gid),
name: String(gid),
avatar: "",
}))
);
}
})();
}
}, [isEdit, id]);
// TODO: 选择器、日期选择器等逻辑
const handleSave = async () => {
setIsSubmitting(true);
try {
const payload = {
id: isEdit ? id : undefined,
name: form.name,
sourceType: form.sourceType === "friends" ? 1 : 2,
friends: form.selectedFriends.map((f) => Number(f.id)),
groups: form.selectedGroups.map((g) => Number(g.id)),
groupMembers: {},
keywordInclude: form.keywordsInclude
? form.keywordsInclude
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [],
keywordExclude: form.keywordsExclude
? form.keywordsExclude
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [],
aiPrompt: form.aiPrompt,
timeEnabled: form.startDate || form.endDate ? 1 : 0,
startTime: form.startDate || "",
endTime: form.endDate || "",
status: form.enabled ? 1 : 0,
};
if (isEdit) {
await post("/v1/content/library/update", payload);
} else {
await post("/v1/content/library/create", payload);
}
toast({
title: isEdit ? "保存成功" : "创建成功",
description: "内容库已保存",
});
navigate("/content");
} catch (error) {
toast({
title: isEdit ? "保存失败" : "创建失败",
description: "保存内容库失败",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Layout
header={
<UnifiedHeader
title={isEdit ? "编辑内容库" : "新建内容库"}
showBack
onBack={() => navigate(-1)}
/>
}
footer={
<div className="p-4">
<Button
theme="primary"
block
onClick={handleSave}
disabled={isSubmitting || !form.name}
>
{isSubmitting
? isEdit
? "保存中..."
: "创建中..."
: isEdit
? "保存"
: "创建内容库"}
</Button>
</div>
}
>
<div className="flex-1 bg-gray-50 ">
<div className="p-4 space-y-4 max-w-lg mx-auto">
<Card className="p-4">
<div className="space-y-4">
<div>
<label className="block font-medium mb-1">
<span className="text-red-500">*</span>
</label>
<Input
value={form.name}
onChange={(e) =>
setForm((f) => ({ ...f, name: e.target.value }))
}
placeholder="请输入内容库名称"
required
/>
</div>
<div>
<label className="block font-medium mb-1"></label>
<Tabs
value={form.sourceType}
onValueChange={(val) =>
setForm((f) => ({
...f,
sourceType: val as "friends" | "groups",
}))
}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="friends"></TabsTrigger>
<TabsTrigger value="groups"></TabsTrigger>
</TabsList>
<TabsContent value="friends">
<FriendSelection
selectedFriends={form.selectedFriends.map((f) => f.id)}
onSelect={(ids) =>
setForm((f) => ({
...f,
selectedFriends: ids.map((id) => ({
id,
nickname: id,
avatar: "",
})),
}))
}
onSelectDetail={setSelectedFriendObjs}
enableDeviceFilter={false}
placeholder="选择微信好友"
/>
{selectedFriendObjs.length > 0 && (
<div className="mt-2 space-y-2">
{selectedFriendObjs.map((friend) => (
<div
key={friend.id}
className="flex items-center justify-between bg-gray-100 p-2 rounded-md"
>
<div className="flex items-center gap-2">
{friend.avatar ? (
<img
src={friend.avatar}
alt={friend.nickname}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-white text-sm">
{friend.nickname?.charAt(0) || "友"}
</div>
)}
<span>{friend.nickname}</span>
</div>
<button
className="text-gray-400 hover:text-red-500 ml-2"
onClick={() => {
setForm((f) => ({
...f,
selectedFriends: f.selectedFriends.filter(
(frd) => frd.id !== friend.id
),
}));
setSelectedFriendObjs((objs) =>
objs.filter((frd) => frd.id !== friend.id)
);
}}
title="移除"
>
×
</button>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="groups">
<GroupSelection
selectedGroups={form.selectedGroups.map((g) => g.id)}
onSelect={(ids) =>
setForm((f) => ({
...f,
selectedGroups: ids.map((id) => {
const old = f.selectedGroups.find(
(g) => g.id === id
);
return old || { id, name: id, avatar: "" };
}),
}))
}
onSelectDetail={setSelectedGroupObjs}
placeholder="选择群聊"
/>
{selectedGroupObjs.length > 0 && (
<div className="mt-2 space-y-2">
{selectedGroupObjs.map((group) => (
<div
key={group.id}
className="flex items-center justify-between bg-gray-100 p-2 rounded-md"
>
<div className="flex items-center gap-2">
{group.avatar ? (
<img
src={group.avatar}
alt={group.name}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-white text-sm">
{group.name?.charAt(0) || "群"}
</div>
)}
<span>{group.name}</span>
</div>
<button
className="text-gray-400 hover:text-red-500 ml-2"
onClick={() => {
setForm((f) => ({
...f,
selectedGroups: f.selectedGroups.filter(
(grp) => grp.id !== group.id
),
}));
setSelectedGroupObjs((objs) =>
objs.filter((grp) => grp.id !== group.id)
);
}}
title="移除"
>
×
</button>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
<Collapse>
<CollapsePanel header="关键字设置" value="keywords">
<div className="space-y-4">
<div>
<label className="block font-medium mb-1">
</label>
<Textarea
value={form.keywordsInclude}
onChange={(e) =>
setForm((f) => ({
...f,
keywordsInclude: e.target.value,
}))
}
placeholder="如果设置了关键字,系统只会采集含有关键字的内容。多个关键字,用半角的','隔开。"
/>
</div>
<div>
<label className="block font-medium mb-1">
</label>
<Textarea
value={form.keywordsExclude}
onChange={(e) =>
setForm((f) => ({
...f,
keywordsExclude: e.target.value,
}))
}
placeholder="排除含有这些关键字的内容。多个关键字,用半角的','隔开。"
/>
</div>
</div>
</CollapsePanel>
</Collapse>
<div className="flex items-center justify-between">
<div>
<label className="block font-medium">AI</label>
</div>
<div className="w-10">
<Switch
checked={form.useAI}
onCheckedChange={(checked) =>
setForm((f) => ({ ...f, useAI: checked }))
}
/>
</div>
</div>
<p className="text-sm text-gray-500 mt-1 ">
AI之后AI重新生成内容
</p>
{form.useAI && (
<div>
<label className="block font-medium mb-1">AI </label>
<Textarea
value={form.aiPrompt}
onChange={(e) =>
setForm((f) => ({ ...f, aiPrompt: e.target.value }))
}
placeholder="请输入 AI 提示词"
/>
</div>
)}
<div>
<label className="block font-medium mb-2"></label>
{/* TODO: 替换为TDesign日期范围选择器 */}
<div
className="flex mb-2"
style={{ justifyContent: "space-between" }}
>
<label className="text-sm w-20 "></label>
<Input
type="date"
value={form.startDate}
onChange={(e) =>
setForm((f) => ({ ...f, startDate: e.target.value }))
}
className="inline-block w-1/2 "
/>
</div>
<div className="flex ">
<label className="text-sm w-20"></label>
<Input
type="date"
value={form.endDate}
onChange={(e) =>
setForm((f) => ({ ...f, endDate: e.target.value }))
}
className="inline-block w-1/2"
/>
</div>
</div>
<div className="flex items-center justify-between">
<label className="block font-medium mb-1"></label>
<Switch
checked={form.enabled}
onCheckedChange={(checked) =>
setForm((f) => ({ ...f, enabled: checked }))
}
/>
</div>
</div>
</Card>
</div>
{/* TODO: 微信好友/群组选择器弹窗、日期选择器弹窗 */}
</div>
</Layout>
);
}

View File

@@ -1,206 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Layout from '@/components/Layout';
import UnifiedHeader from '@/components/UnifiedHeader';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/toast';
import { get, del } from '@/api/request';
import { Plus, Search, Edit, Trash2, UserCircle2, Tag, BarChart } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
interface MaterialItem {
id: string;
content: string;
tags: string[];
type?: string; // 可选: text/image/video/link
images?: string[];
video?: string;
createTime?: string;
status?: string;
title?: string; // Added for new card structure
creatorName?: string; // Added for new card structure
aiAnalysis?: string; // Added for AI analysis result
resUrls?: string[]; // Added for image URLs
}
export default function Materials() {
const navigate = useNavigate();
const { id } = useParams();
const [materials, setMaterials] = useState<MaterialItem[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false);
const [aiDialogOpen, setAiDialogOpen] = useState(false);
const [selectedMaterial, setSelectedMaterial] = useState<MaterialItem | null>(null);
// 拉取素材列表
const fetchMaterials = async () => {
setLoading(true);
try {
const res = await get(`/v1/content/library/item-list?page=1&limit=100&libraryId=${id}${searchQuery ? `&keyword=${encodeURIComponent(searchQuery)}` : ''}`);
if (res && res.code === 200 && Array.isArray(res.data?.list)) {
setMaterials(res.data.list);
} else {
setMaterials([]);
toast({ title: '获取失败', description: res?.msg || '获取素材列表失败' });
}
} catch (error: any) {
setMaterials([]);
toast({ title: '网络错误', description: error?.message || '请检查网络连接' });
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchMaterials();
// eslint-disable-next-line
}, [id]);
const handleSearch = () => {
fetchMaterials();
};
const handleDelete = async (materialId: string) => {
if (!window.confirm('确定要删除该素材吗?')) return;
try {
const res = await del(`/v1/content/library/material/delete?id=${materialId}`);
if (res && res.code === 200) {
toast({ title: '删除成功', description: '素材已删除' });
fetchMaterials();
} else {
toast({ title: '删除失败', description: res?.msg || '删除素材失败' });
}
} catch (error: any) {
toast({ title: '网络错误', description: error?.message || '请检查网络连接' });
}
};
const handleNewMaterial = () => {
navigate(`/content/materials/new/${id}`);
};
const handleEdit = (materialId: string) => {
navigate(`/content/materials/edit/${id}/${materialId}`);
};
return (
<Layout
header={
<>
<UnifiedHeader title="素材列表" showBack onBack={() => navigate(-1)}
rightContent={
<>
<Button onClick={handleNewMaterial} variant="default">
<Plus className="h-4 w-4 mr-1" />
</Button>
</>
}/>
<div className="flex items-center gap-2 m-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索素材内容或标签..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSearch(); }}
className="pl-9"
/>
</div>
<Button onClick={handleSearch} variant="outline"></Button>
</div>
</>
}
>
<div className="flex-1 bg-gray-50 min-h-screen pb-16">
<div className="p-4 space-y-4 max-w-2xl mx-auto">
<div className="space-y-2">
{loading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : materials.length === 0 ? (
<div className="text-center py-8 text-gray-400"></div>
) : (
materials.map((item) => (
<div
key={item.id}
className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5 mb-4 flex flex-col"
style={{ boxShadow: '0 2px 8px 0 rgba(0,0,0,0.04)' }}
>
{/* 顶部头像+系统创建+ID */}
<div className="flex items-center mb-2">
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center text-2xl mr-3">
<UserCircle2 className="w-10 h-10 text-blue-400" />
</div>
<div className="flex flex-col">
<span className="font-semibold text-base text-gray-800 leading-tight"></span>
<span className="mt-1">
<span className="bg-blue-50 text-blue-700 text-xs font-bold rounded-full px-3 py-0.5 align-middle">ID: {item.id}</span>
</span>
</div>
</div>
{/* 标题 */}
<div className="font-bold text-lg text-gray-900 mb-2 mt-1">{item.title ? `${item.title}` : (item.content.length > 20 ? `${item.content.slice(0, 20)}...】` : `${item.content}`)}</div>
{/* 内容 */}
<div className="text-base text-gray-800 whitespace-pre-line mb-3" style={{ lineHeight: '1.8' }}>{item.content}</div>
{/* 图片展示 */}
{item.resUrls && item.resUrls.length > 0 && (
<div className="flex flex-col gap-2 mb-3">
{item.resUrls.map((url: string, idx: number) => (
<img
key={idx}
src={url}
alt="素材图片"
className="w-full max-w-full rounded-lg border"
style={{ height: 'auto', boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}
/>
))}
</div>
)}
{/* 标签 */}
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{item.tags.map((tag, index) => (
<Badge key={index} variant="secondary">
<Tag className="h-3 w-3 mr-1" />
{tag}
</Badge>
))}
</div>
)}
{/* 操作按钮区 */}
<div className="flex items-center justify-between mt-2">
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => handleEdit(item.id)}>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={() => { setSelectedMaterial(item); setAiDialogOpen(true); }}>
<BarChart className="h-4 w-4 mr-1" />AI分析
</Button>
<Dialog open={aiDialogOpen && selectedMaterial?.id === item.id} onOpenChange={setAiDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>AI </DialogTitle>
</DialogHeader>
<div className="mt-4">
<p>{selectedMaterial?.aiAnalysis || '正在分析中...'}</p>
</div>
</DialogContent>
</Dialog>
</div>
<Button size="sm" variant="destructive" onClick={() => handleDelete(item.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
</div>
</Layout>
);
}

View File

@@ -1,271 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Card } from '@/components/ui/card';
import { Button } from 'tdesign-mobile-react';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { toast } from '@/components/ui/toast';
import Layout from '@/components/Layout';
import UnifiedHeader from '@/components/UnifiedHeader';
import { get, post } from '@/api/request';
import UploadImage from '@/components/UploadImage';
import UploadVideo from '@/components/UploadVideo';
export default function NewMaterial() {
const navigate = useNavigate();
const { id, materialId } = useParams(); // materialId 作为编辑标识
const [content, setContent] = useState('');
const [comment, setComment] = useState('');
const [contentType, setContentType] = useState<number>(1);
const [desc, setDesc] = useState('');
const [coverImage, setCoverImage] = useState('');
const [url, setUrl] = useState('');
const [videoUrl, setVideoUrl] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isEdit, setIsEdit] = useState(false);
const [sendTime, setSendTime] = useState('');
const [images, setImages] = useState<string[]>([]);
const [isFirstLoad, setIsFirstLoad] = useState(true);
// 优化图片上传逻辑,确保每次选择图片后立即上传并回显
// 判断模式并拉取详情
useEffect(() => {
if (materialId) {
setIsEdit(true);
get(`/v1/content/library/get-item-detail?id=${materialId}`)
.then(res => {
if (res && res.code === 200 && res.data) {
setContent(res.data.content || '');
setComment(res.data.comment || '');
setSendTime(res.data.sendTime || '');
if (isFirstLoad && res.data.contentType) {
setContentType(Number(res.data.contentType));
setIsFirstLoad(false);
}
setDesc(res.data.desc || '');
setCoverImage(res.data.coverImage || '');
setUrl(res.data.url || '');
setVideoUrl(res.data.videoUrl || '');
setImages(res.data.resUrls || []); // 图片回显
} else {
toast({ title: '获取失败', description: res?.msg || '获取素材详情失败', variant: 'destructive' });
}
})
.catch(error => {
toast({ title: '网络错误', description: error?.message || '请检查网络连接', variant: 'destructive' });
});
} else {
setIsEdit(false);
setContent('');
setComment('');
setSendTime('');
setContentType(1);
setImages([]);
setIsFirstLoad(true);
}
}, [materialId]);
const handleSave = async () => {
if (!content) {
toast({
title: '错误',
description: '请输入素材内容',
variant: 'destructive',
});
return;
}
setIsSubmitting(true);
try {
let res;
if (isEdit) {
// 编辑模式,调用新接口,所有字段取表单值
const payload = {
id: materialId,
contentType,
content,
comment,
sendTime,
resUrls: images,
};
res = await post('/v1/content/library/update-item', payload);
} else {
// 新建模式,所有字段取表单值
const payload = {
libraryId: id,
type: contentType,
content,
comment,
sendTime,
resUrls: images,
};
res = await post('/v1/content/library/create-item', payload);
}
if (res && res.code === 200) {
toast({ title: '成功', description: isEdit ? '素材已更新' : '新素材已创建' });
navigate(-1);
} else {
toast({ title: isEdit ? '保存失败' : '创建失败', description: res?.msg || (isEdit ? '保存素材失败' : '创建新素材失败'), variant: 'destructive' });
}
} catch (error: any) {
toast({ title: '网络错误', description: error?.message || '请检查网络连接', variant: 'destructive' });
} finally {
setIsSubmitting(false);
}
};
// 移除未用的 handleUploadImage 及 uploadImage 相关代码
return (
<Layout
header={<UnifiedHeader title={isEdit ? '编辑素材' : '新建素材'} showBack onBack={() => navigate(-1)} />}
footer={
<div className='m-2'>
{/* 2. 按钮onClick绑定handleSave */}
<Button theme="primary" block onClick={handleSave} disabled={isSubmitting}>
{isSubmitting ? (isEdit ? '保存中...' : '创建中...') : (isEdit ? '保存修改' : '保存素材')}
</Button>
</div>
}
>
<div className="flex-1 bg-gray-50 min-h-screen">
<div className="p-4 max-w-lg mx-auto">
<Card className="p-8 rounded-3xl shadow-xl bg-white">
<form className="space-y-8">
{/* 基础信息分组 */}
<div className="mb-6">
<div className="text-xs text-gray-400 mb-2 tracking-widest"></div>
<Label className="font-bold flex items-center mb-2"></Label>
<Input
type="datetime-local"
value={sendTime}
onChange={e => setSendTime(e.target.value)}
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
placeholder="请选择发布时间"
/>
<Label className="font-bold flex items-center mb-2 mt-4"><span className="text-red-500 mr-1">*</span></Label>
<select
value={contentType}
onChange={e => setContentType(Number(e.target.value))}
className="w-full h-12 border border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base bg-white appearance-none"
>
<option value="" disabled></option>
<option value={1}></option>
<option value={2}></option>
<option value={3}></option>
<option value={4}></option>
<option value={5}></option>
</select>
</div>
{/* 内容信息分组 */}
<div className="mb-6">
<div className="text-xs text-gray-400 mb-2 tracking-widest"></div>
<Label htmlFor="content" className="font-bold flex items-center mb-2"><span className="text-red-500 mr-1">*</span></Label>
<Textarea
value={content}
onChange={e => setContent(e.target.value)}
placeholder="请输入内容"
className="w-full rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base min-h-[120px] bg-gray-50 placeholder:text-gray-300"
rows={8}
/>
{(contentType === 2 || contentType === 6) && (
<>
<Label htmlFor="desc" className="font-bold flex items-center mb-2"><span className="text-red-500 mr-1">*</span></Label>
<Input
id="desc"
value={desc}
onChange={e => setDesc(e.target.value)}
placeholder="请输入描述"
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
/>
<Label className="font-bold mb-2 mt-4"></Label>
<div className="flex items-center gap-4">
<UploadImage
value={images}
onChange={urls => {
setCoverImage(urls[0]);
}}
max={1}
accept="image/*"
/>
</div>
<Label htmlFor="url" className="font-bold flex items-center mb-2 mt-4"><span className="text-red-500 mr-1">*</span></Label>
<Input
id="url"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="请输入链接地址"
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
/>
</>
)}
{contentType === 3 && (
<>
<Label className="font-bold mb-2"></Label>
<div className="pt-4">
<UploadVideo
value={videoUrl}
onChange={setVideoUrl}
/>
</div>
</>
)}
</div>
{/* 素材上传分组(仅图片类型和小程序类型) */}
{([1,5].includes(contentType)) && (
<div className="mb-6">
<div className="text-xs text-gray-400 mb-2 tracking-widest">9</div>
{contentType === 1 && (
<div className="mb-6">
<UploadImage
value={images}
onChange={urls => {
setImages(urls);
}}
max={9}
accept="image/*"
/>
</div>
)}
{contentType === 5 && (
<div className="space-y-6">
<Label htmlFor="appTitle" className="font-bold mb-2"></Label>
<Input id="appTitle" placeholder="请输入小程序名称" className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300" />
<Label htmlFor="appId" className="font-bold mb-2">AppID</Label>
<Input id="appId" placeholder="请输入AppID" className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300" />
<Label className="font-bold mb-2"></Label>
<UploadImage
value={images}
onChange={urls => {
setImages(urls);
}}
max={9}
accept="image/*"
/>
</div>
)}
</div>
)}
{/* 评论/备注分组 */}
<div className="mb-6">
<div className="text-xs text-gray-400 mb-2 tracking-widest">/</div>
<Textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="请输入评论或备注"
className="w-full rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base min-h-[80px] bg-gray-50 placeholder:text-gray-300"
rows={4}
/>
</div>
</form>
</Card>
</div>
</div>
</Layout>
);
}

View File

@@ -1,869 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import PageHeader from '@/components/PageHeader';
import BackButton from '@/components/BackButton';
import { useSimpleBack } from '@/hooks/useBackNavigation';
import { Smartphone, Battery, Wifi, MessageCircle, Users, Settings, History, RefreshCw, Loader2 } from 'lucide-react';
import { devicesApi, fetchDeviceDetail, fetchDeviceRelatedAccounts, fetchDeviceHandleLogs, updateDeviceTaskConfig } from '@/api/devices';
import { useToast } from '@/components/ui/toast';
import Layout from '@/components/Layout';
import BottomNav from '@/components/BottomNav';
interface WechatAccount {
id: string;
avatar: string;
nickname: string;
wechatId: string;
gender: number;
status: number;
statusText: string;
wechatAlive: number;
wechatAliveText: string;
addFriendStatus: number;
totalFriend: number;
lastActive: string;
}
interface Device {
id: string;
imei: string;
name: string;
status: "online" | "offline";
battery: number;
lastActive: string;
historicalIds: string[];
wechatAccounts: WechatAccount[];
features: {
autoAddFriend: boolean;
autoReply: boolean;
momentsSync: boolean;
aiChat: boolean;
};
history: {
time: string;
action: string;
operator: string;
}[];
totalFriend: number;
thirtyDayMsgCount: number;
}
interface HandleLog {
id: string | number;
content: string;
username: string;
createTime: string;
}
export default function DeviceDetail() {
const { id } = useParams<{ id: string }>();
const { goBack } = useSimpleBack('/devices');
const { toast } = useToast();
const [device, setDevice] = useState<Device | null>(null);
const [activeTab, setActiveTab] = useState("info");
const [loading, setLoading] = useState(true);
const [accountsLoading, setAccountsLoading] = useState(false);
const [logsLoading, setLogsLoading] = useState(false);
const [handleLogs, setHandleLogs] = useState<HandleLog[]>([]);
const [logPage, setLogPage] = useState(1);
const [hasMoreLogs, setHasMoreLogs] = useState(true);
const logsPerPage = 10;
const logsEndRef = useRef<HTMLDivElement>(null);
const [savingFeatures, setSavingFeatures] = useState({
autoAddFriend: false,
autoReply: false,
momentsSync: false,
aiChat: false
});
const [accountPage, setAccountPage] = useState(1);
const [hasMoreAccounts, setHasMoreAccounts] = useState(true);
const accountsPerPage = 10;
const accountsEndRef = useRef<HTMLDivElement>(null);
// 获取设备详情
useEffect(() => {
if (!id) return;
const fetchDevice = async () => {
try {
setLoading(true);
const response = await fetchDeviceDetail(id);
if (response && response.code === 200 && response.data) {
const serverData = response.data;
// 构建符合前端期望格式的设备对象
const formattedDevice: Device = {
id: serverData.id?.toString() || "",
imei: serverData.imei || "",
name: serverData.memo || "未命名设备",
status: serverData.alive === 1 ? "online" : "offline",
battery: serverData.battery || 0,
lastActive: serverData.lastUpdateTime || new Date().toISOString(),
historicalIds: [],
wechatAccounts: [],
history: [],
features: {
autoAddFriend: false,
autoReply: false,
momentsSync: false,
aiChat: false
},
totalFriend: serverData.totalFriend || 0,
thirtyDayMsgCount: serverData.thirtyDayMsgCount || 0
};
// 解析features
if (serverData.features) {
formattedDevice.features = {
autoAddFriend: Boolean(serverData.features.autoAddFriend),
autoReply: Boolean(serverData.features.autoReply),
momentsSync: Boolean(serverData.features.momentsSync || serverData.features.contentSync),
aiChat: Boolean(serverData.features.aiChat)
};
} else if (serverData.taskConfig) {
try {
const taskConfig = JSON.parse(serverData.taskConfig || '{}');
if (taskConfig) {
formattedDevice.features = {
autoAddFriend: Boolean(taskConfig.autoAddFriend),
autoReply: Boolean(taskConfig.autoReply),
momentsSync: Boolean(taskConfig.momentsSync),
aiChat: Boolean(taskConfig.aiChat)
};
}
} catch (err) {
console.error('解析taskConfig失败:', err);
}
}
setDevice(formattedDevice);
// 获取设备任务配置
await fetchTaskConfig();
// 如果当前激活标签是"accounts",则立即加载关联微信账号
if (activeTab === "accounts") {
fetchRelatedAccounts();
}
} else {
toast({
title: "获取设备信息失败",
description: response.msg || "未知错误",
variant: "destructive",
});
}
} catch (error) {
console.error("获取设备信息失败:", error);
toast({
title: "获取设备信息失败",
description: "请稍后重试",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
fetchDevice();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
// 获取设备关联微信账号
const fetchRelatedAccounts = useCallback(async (page = 1) => {
if (!id || accountsLoading) return;
try {
setAccountsLoading(true);
const response = await fetchDeviceRelatedAccounts(id);
if (response && response.code === 200 && response.data) {
const accounts = response.data.accounts || [];
if (page === 1) {
setDevice(prev => prev ? {
...prev,
wechatAccounts: accounts
} : null);
} else {
setDevice(prev => prev ? {
...prev,
wechatAccounts: [...prev.wechatAccounts, ...accounts]
} : null);
}
setHasMoreAccounts(accounts.length === accountsPerPage);
setAccountPage(page);
} else {
toast({
title: "获取关联账号失败",
description: response.msg || "请稍后重试",
variant: "destructive",
});
}
} catch (error) {
console.error("获取关联账号失败:", error);
toast({
title: "获取关联账号失败",
description: "请稍后重试",
variant: "destructive",
});
} finally {
setAccountsLoading(false);
}
}, [id, accountsLoading, accountsPerPage, toast]);
// 获取操作记录
const fetchHandleLogs = useCallback(async () => {
if (!id || logsLoading) return;
try {
setLogsLoading(true);
const response = await fetchDeviceHandleLogs(id, logPage, logsPerPage);
if (response && response.code === 200 && response.data) {
const logs = response.data.list || [];
if (logPage === 1) {
setHandleLogs(logs);
} else {
setHandleLogs(prev => [...prev, ...logs]);
}
setHasMoreLogs(logs.length === logsPerPage);
} else {
toast({
title: "获取操作记录失败",
description: response.msg || "请稍后重试",
variant: "destructive",
});
}
} catch (error) {
console.error("获取操作记录失败:", error);
toast({
title: "获取操作记录失败",
description: "请稍后重试",
variant: "destructive",
});
} finally {
setLogsLoading(false);
}
}, [id, logsLoading, logPage, logsPerPage, toast]);
// 加载更多操作记录
const loadMoreLogs = useCallback(() => {
if (logsLoading || !hasMoreLogs) return;
setLogPage(prev => prev + 1);
fetchHandleLogs();
}, [logsLoading, hasMoreLogs, fetchHandleLogs]);
// 无限滚动加载操作记录
useEffect(() => {
if (!hasMoreLogs || logsLoading) return;
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasMoreLogs && !logsLoading) {
loadMoreLogs();
}
},
{ threshold: 0.5 }
);
if (logsEndRef.current) {
observer.observe(logsEndRef.current);
}
return () => {
observer.disconnect();
};
}, [hasMoreLogs, logsLoading, loadMoreLogs]);
// 获取任务配置
const fetchTaskConfig = async () => {
if (!id) return;
try {
const response = await devicesApi.getTaskConfig(id);
if (response && response.code === 200 && response.data) {
const config = response.data;
setDevice(prev => prev ? {
...prev,
features: {
autoAddFriend: Boolean(config.autoAddFriend),
autoReply: Boolean(config.autoReply),
momentsSync: Boolean(config.momentsSync),
aiChat: Boolean(config.aiChat)
}
} : null);
}
} catch (error) {
console.error("获取任务配置失败:", error);
}
};
// 标签页切换处理
const handleTabChange = (value: string) => {
setActiveTab(value);
setTimeout(() => {
if (value === "accounts" && device && (!device.wechatAccounts || device.wechatAccounts.length === 0)) {
fetchRelatedAccounts(1);
} else if (value === "history" && handleLogs.length === 0) {
setLogPage(1);
setHasMoreLogs(true);
fetchHandleLogs();
}
}, 100);
};
// 功能开关处理 - 只更新开关状态,不重新加载页面
const handleFeatureChange = async (feature: keyof Device['features'], checked: boolean) => {
if (!id) return;
// 立即更新UI状态提供即时反馈
setDevice(prev => prev ? {
...prev,
features: {
...prev.features,
[feature]: checked
}
} : null);
setSavingFeatures(prev => ({ ...prev, [feature]: true }));
try {
const response = await updateDeviceTaskConfig({
deviceId: id,
[feature]: checked
});
if (response && response.code === 200) {
// 请求成功,显示成功提示
toast({
title: "设置成功",
description: `${getFeatureName(feature)}${checked ? '启用' : '禁用'}`,
});
} else {
// 请求失败回滚UI状态
setDevice(prev => prev ? {
...prev,
features: {
...prev.features,
[feature]: !checked
}
} : null);
toast({
title: "设置失败",
description: response.msg || "请稍后重试",
variant: "destructive",
});
}
} catch (error) {
console.error("设置功能失败:", error);
// 网络错误回滚UI状态
setDevice(prev => prev ? {
...prev,
features: {
...prev.features,
[feature]: !checked
}
} : null);
toast({
title: "设置失败",
description: "请稍后重试",
variant: "destructive",
});
} finally {
setSavingFeatures(prev => ({ ...prev, [feature]: false }));
}
};
// 获取功能名称
const getFeatureName = (feature: string): string => {
const names: Record<string, string> = {
autoAddFriend: "自动加好友",
autoReply: "自动回复",
momentsSync: "朋友圈同步",
aiChat: "AI会话"
};
return names[feature] || feature;
};
// 加载更多账号
const loadMoreAccounts = useCallback(() => {
if (accountsLoading || !hasMoreAccounts) return;
fetchRelatedAccounts(accountPage + 1);
}, [accountsLoading, hasMoreAccounts, accountPage, fetchRelatedAccounts]);
// 无限滚动加载账号
useEffect(() => {
if (!hasMoreAccounts || accountsLoading) return;
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasMoreAccounts && !accountsLoading) {
loadMoreAccounts();
}
},
{ threshold: 0.5 }
);
if (accountsEndRef.current) {
observer.observe(accountsEndRef.current);
}
return () => {
observer.disconnect();
};
}, [hasMoreAccounts, accountsLoading, loadMoreAccounts]);
if (loading) {
return (
<Layout
header={<PageHeader title="设备详情" defaultBackPath="/devices" rightContent={<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors"><Settings className="h-5 w-5" /></button>} />}
footer={<BottomNav />}
>
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="flex flex-col items-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
<p className="text-gray-500">...</p>
</div>
</div>
</Layout>
);
}
if (!device) {
return (
<Layout
header={<PageHeader title="设备详情" defaultBackPath="/devices" rightContent={<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors"><Settings className="h-5 w-5" /></button>} />}
footer={<BottomNav />}
>
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="flex flex-col items-center space-y-4 p-6 bg-white rounded-xl shadow-sm max-w-md">
<div className="w-12 h-12 flex items-center justify-center rounded-full bg-red-100">
<Smartphone className="h-6 w-6 text-red-500" />
</div>
<div className="text-xl font-medium text-center"></div>
<div className="text-sm text-gray-500 text-center">
ID为 "{id}"
</div>
<BackButton
variant="button"
text="返回上一页"
onBack={goBack}
/>
</div>
</div>
</Layout>
);
}
return (
<Layout
header={<PageHeader title="设备详情" defaultBackPath="/devices" rightContent={<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors"><Settings className="h-5 w-5" /></button>} />}
footer={<BottomNav />}
>
<div className="pb-20">
<div className="p-4 space-y-4">
{/* 设备基本信息卡片 */}
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<div className="flex items-center gap-4">
<div className="p-3 bg-blue-50 rounded-lg">
<Smartphone className="h-6 w-6 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h2 className="font-semibold truncate">{device.name}</h2>
<span className={`px-2.5 py-1 text-xs rounded-full font-medium ${
device.status === "online"
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-600"
}`}>
{device.status === "online" ? "在线" : "离线"}
</span>
</div>
<div className="text-xs text-gray-500 mt-1">
<span className="mr-1">IMEI:</span>
{device.imei}
</div>
{device.historicalIds && device.historicalIds.length > 0 && (
<div className="text-sm text-gray-500">ID: {device.historicalIds.join(", ")}</div>
)}
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<Battery className={`w-4 h-4 ${device.battery < 20 ? "text-red-500" : "text-green-500"}`} />
<span className="text-sm">{device.battery}%</span>
</div>
<div className="flex items-center gap-2">
<Wifi className="w-4 h-4 text-blue-500" />
<span className="text-sm">{device.status === "online" ? "已连接" : "未连接"}</span>
</div>
</div>
<div className="mt-2 text-sm text-gray-500">{device.lastActive}</div>
</div>
{/* 标签页 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="flex border-b border-gray-200">
<button
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
activeTab === "info"
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
onClick={() => handleTabChange("info")}
>
</button>
<button
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
activeTab === "accounts"
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
onClick={() => handleTabChange("accounts")}
>
</button>
<button
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
activeTab === "history"
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
onClick={() => handleTabChange("history")}
>
</button>
</div>
{/* 基本信息标签页 */}
{activeTab === "info" && (
<div className="p-4 space-y-4">
{/* 功能配置 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="text-sm font-medium"></div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="flex items-center">
{savingFeatures.autoAddFriend && (
<Loader2 className="w-4 h-4 mr-2 animate-spin text-blue-500" />
)}
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={Boolean(device.features.autoAddFriend)}
onChange={(e) => handleFeatureChange('autoAddFriend', e.target.checked)}
disabled={savingFeatures.autoAddFriend}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="text-sm font-medium"></div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="flex items-center">
{savingFeatures.autoReply && (
<Loader2 className="w-4 h-4 mr-2 animate-spin text-blue-500" />
)}
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={Boolean(device.features.autoReply)}
onChange={(e) => handleFeatureChange('autoReply', e.target.checked)}
disabled={savingFeatures.autoReply}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="text-sm font-medium"></div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="flex items-center">
{savingFeatures.momentsSync && (
<Loader2 className="w-4 h-4 mr-2 animate-spin text-blue-500" />
)}
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={Boolean(device.features.momentsSync)}
onChange={(e) => handleFeatureChange('momentsSync', e.target.checked)}
disabled={savingFeatures.momentsSync}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">AI会话</div>
<div className="text-xs text-gray-500">AI智能对话</div>
</div>
<div className="flex items-center">
{savingFeatures.aiChat && (
<Loader2 className="w-4 h-4 mr-2 animate-spin text-blue-500" />
)}
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={Boolean(device.features.aiChat)}
onChange={(e) => handleFeatureChange('aiChat', e.target.checked)}
disabled={savingFeatures.aiChat}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="bg-gray-50 p-4 rounded-xl">
<div className="flex items-center gap-2 text-gray-500">
<Users className="w-4 h-4" />
<span className="text-sm"></span>
</div>
<div className="text-2xl font-bold text-blue-600 mt-2">
{(device.totalFriend || 0).toLocaleString()}
</div>
</div>
<div className="bg-gray-50 p-4 rounded-xl">
<div className="flex items-center gap-2 text-gray-500">
<MessageCircle className="w-4 h-4" />
<span className="text-sm"></span>
</div>
<div className="text-2xl font-bold text-blue-600 mt-2">
{(device.thirtyDayMsgCount || 0).toLocaleString()}
</div>
</div>
</div>
</div>
)}
{/* 关联账号标签页 */}
{activeTab === "accounts" && (
<div className="p-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-md font-medium"></h3>
<button
className="px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center gap-2"
onClick={() => {
setAccountPage(1);
setHasMoreAccounts(true);
fetchRelatedAccounts(1);
}}
disabled={accountsLoading}
>
{accountsLoading ? (
<React.Fragment key="loading">
<Loader2 className="h-4 w-4 animate-spin" />
</React.Fragment>
) : (
<React.Fragment key="refresh">
<RefreshCw className="h-4 w-4" />
</React.Fragment>
)}
</button>
</div>
<div className="min-h-[120px] max-h-[calc(100vh-300px)] overflow-y-auto">
{accountsLoading && !device?.wechatAccounts?.length ? (
<div className="flex justify-center items-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-blue-500 mr-2" />
<span className="text-gray-500">...</span>
</div>
) : device?.wechatAccounts && device.wechatAccounts.length > 0 ? (
<div className="space-y-4">
{device.wechatAccounts.map((account) => (
<div key={account.id} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
<img
src={account.avatar || "/placeholder.svg"}
alt={account.nickname}
className="w-12 h-12 rounded-full"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="font-medium truncate">{account.nickname}</div>
<span className={`px-2 py-1 text-xs rounded-full ${
account.wechatAlive === 1
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-700"
}`}>
{account.wechatAliveText}
</span>
</div>
<div className="text-sm text-gray-500 mt-1">: {account.wechatId}</div>
<div className="text-sm text-gray-500">: {account.gender === 1 ? "男" : "女"}</div>
<div className="flex items-center justify-between mt-2">
<span className="text-sm text-gray-500">: {account.totalFriend}</span>
<span className={`px-2 py-1 text-xs rounded-full ${
account.status === 1
? "bg-blue-100 text-blue-700"
: "bg-gray-100 text-gray-600"
}`}>
{account.statusText}
</span>
</div>
<div className="text-xs text-gray-400 mt-1">: {account.lastActive}</div>
</div>
</div>
))}
{/* 加载更多区域 */}
<div
ref={accountsEndRef}
className="py-2 flex justify-center items-center"
>
{accountsLoading && hasMoreAccounts ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
<span className="text-sm text-gray-500">...</span>
</div>
) : hasMoreAccounts ? (
<button
className="text-sm text-blue-500 hover:text-blue-600"
onClick={loadMoreAccounts}
>
</button>
) : device.wechatAccounts.length > 0 && (
<span className="text-xs text-gray-400">- -</span>
)}
</div>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<p></p>
<button
className="mt-2 px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center gap-2 mx-auto"
onClick={() => fetchRelatedAccounts(1)}
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
)}
</div>
</div>
)}
{/* 操作记录标签页 */}
{activeTab === "history" && (
<div className="p-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-md font-medium"></h3>
<button
className="px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center gap-2"
onClick={() => {
setLogPage(1);
setHasMoreLogs(true);
fetchHandleLogs();
}}
disabled={logsLoading}
>
{logsLoading ? (
<React.Fragment key="logs-loading">
<Loader2 className="h-4 w-4 animate-spin" />
</React.Fragment>
) : (
<React.Fragment key="logs-refresh">
<RefreshCw className="h-4 w-4" />
</React.Fragment>
)}
</button>
</div>
<div className="h-[calc(min(80vh, 500px))] overflow-y-auto">
{logsLoading && handleLogs.length === 0 ? (
<div className="flex justify-center items-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-blue-500 mr-2" />
<span className="text-gray-500">...</span>
</div>
) : handleLogs.length > 0 ? (
<div className="space-y-4">
{handleLogs.map((log) => (
<div key={log.id} className="flex items-start gap-3">
<div className="p-2 bg-blue-50 rounded-full">
<History className="w-4 h-4 text-blue-600" />
</div>
<div className="flex-1">
<div className="text-sm font-medium">{log.content}</div>
<div className="text-xs text-gray-500 mt-1">
: {log.username} · {log.createTime}
</div>
</div>
</div>
))}
{/* 加载更多区域 */}
<div
ref={logsEndRef}
className="py-2 flex justify-center items-center"
>
{logsLoading && hasMoreLogs ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
<span className="text-sm text-gray-500">...</span>
</div>
) : hasMoreLogs ? (
<button
className="text-sm text-blue-500 hover:text-blue-600"
onClick={loadMoreLogs}
>
</button>
) : (
<span className="text-xs text-gray-400">- -</span>
)}
</div>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<p></p>
<button
className="mt-2 px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center gap-2 mx-auto"
onClick={() => {
setLogPage(1);
setHasMoreLogs(true);
fetchHandleLogs();
}}
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
</Layout>
);
}

View File

@@ -1,719 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Search, RefreshCw, QrCode, Loader2, AlertTriangle, X } from 'lucide-react';
import { devicesApi } from '@/api';
import { useToast } from '@/components/ui/toast';
import PageHeader from '@/components/PageHeader';
import Layout from '@/components/Layout';
import '@/components/Layout.css';
// 设备接口
interface Device {
id: number;
imei: string;
memo: string;
wechatId: string;
totalFriend: number;
alive: number;
status: "online" | "offline";
}
export default function Devices() {
const navigate = useNavigate();
const { toast } = useToast();
const [devices, setDevices] = useState<Device[]>([]);
const [isAddDeviceOpen, setIsAddDeviceOpen] = useState(false);
const [stats, setStats] = useState({
totalDevices: 0,
onlineDevices: 0,
});
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
// 恢复分页功能
const [, setCurrentPage] = useState(1);
const [selectedDeviceId, setSelectedDeviceId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [totalCount, setTotalCount] = useState(0);
const observerTarget = useRef<HTMLDivElement>(null);
const pageRef = useRef(1);
const [deviceImei, setDeviceImei] = useState("");
const [deviceName, setDeviceName] = useState("");
const [qrCodeImage, setQrCodeImage] = useState("");
const [isLoadingQRCode, setIsLoadingQRCode] = useState(false);
const [isSubmittingImei, setIsSubmittingImei] = useState(false);
const [activeTab, setActiveTab] = useState("scan");
const [pollingStatus, setPollingStatus] = useState<{
isPolling: boolean;
message: string;
messageType: 'default' | 'success' | 'error';
showAnimation: boolean;
}>({
isPolling: false,
message: '',
messageType: 'default',
showAnimation: false
});
const pollingTimerRef = useRef<NodeJS.Timeout | null>(null);
const devicesPerPage = 20;
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deviceToDelete, setDeviceToDelete] = useState<number | null>(null);
const loadDevices = useCallback(async (page: number, refresh: boolean = false) => {
if (isLoading) return;
try {
setIsLoading(true);
const response = await devicesApi.getList(page, devicesPerPage, searchQuery);
if (response.code === 200 && response.data) {
const serverDevices = response.data.list.map((device: any) => ({
...device,
status: device.alive === 1 ? "online" as const : "offline" as const
}));
if (refresh) {
setDevices(serverDevices);
} else {
setDevices(prev => [...prev, ...serverDevices]);
}
const total = response.data.total;
const online = response.data.list.filter((d: any) => d.alive === 1).length;
setStats({
totalDevices: total,
onlineDevices: online
});
setTotalCount(response.data.total);
const hasMoreData = serverDevices.length > 0 &&
serverDevices.length === devicesPerPage &&
(page * devicesPerPage) < response.data.total;
setHasMore(hasMoreData);
pageRef.current = page;
} else {
toast({
title: "获取设备列表失败",
description: response.msg || "请稍后重试",
variant: "destructive",
});
}
} catch (error) {
console.error("获取设备列表失败", error);
toast({
title: "获取设备列表失败",
description: "请检查网络连接后重试",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
}, [searchQuery, isLoading, toast]);
const loadNextPage = useCallback(() => {
if (isLoading || !hasMore) return;
const nextPage = pageRef.current + 1;
setCurrentPage(nextPage);
loadDevices(nextPage, false);
}, [hasMore, isLoading, loadDevices]);
const isMounted = useRef(true);
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
useEffect(() => {
if (!isMounted.current) return;
setCurrentPage(1);
pageRef.current = 1;
loadDevices(1, true);
}, [searchQuery, loadDevices]);
useEffect(() => {
if (!hasMore || isLoading) return;
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasMore && !isLoading && isMounted.current) {
loadNextPage();
}
},
{ threshold: 0.5 }
);
if (typeof window !== 'undefined' && observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => {
observer.disconnect();
};
}, [hasMore, isLoading, loadNextPage]);
const fetchDeviceQRCode = async () => {
try {
setIsLoadingQRCode(true);
setQrCodeImage("");
const accountId = localStorage.getItem('s2_accountId');
if (!accountId) {
toast({
title: "获取二维码失败",
description: "未获取到用户信息,请重新登录",
variant: "destructive",
});
return;
}
const response = await devicesApi.getQRCode(accountId);
if (response.code === 200 && response.data) {
setQrCodeImage(response.data.qrCode);
// 开始轮询检测设备添加结果
setTimeout(() => {
startPolling();
}, 5000);
} else {
toast({
title: "获取二维码失败",
description: response.msg || "请稍后重试",
variant: "destructive",
});
}
} catch (error) {
console.error("获取二维码失败:", error);
toast({
title: "获取二维码失败",
description: "请稍后重试",
variant: "destructive",
});
} finally {
setIsLoadingQRCode(false);
}
};
const startPolling = () => {
setPollingStatus({
isPolling: true,
message: "正在检测添加结果...",
messageType: 'default',
showAnimation: true
});
const poll = async () => {
try {
const response = await devicesApi.getList(1, 1);
if (response.code === 200 && response.data) {
const currentCount = response.data.total;
if (currentCount > totalCount) {
setPollingStatus({
isPolling: false,
message: "设备添加成功!",
messageType: 'success',
showAnimation: false
});
setIsAddDeviceOpen(false);
loadDevices(1, true);
if (pollingTimerRef.current) {
clearTimeout(pollingTimerRef.current);
}
return;
}
}
} catch (error) {
console.error("轮询检测失败:", error);
}
// 继续轮询
pollingTimerRef.current = setTimeout(poll, 2000);
};
poll();
};
const handleOpenAddDeviceModal = () => {
setIsAddDeviceOpen(true);
setActiveTab("scan");
setQrCodeImage("");
setDeviceImei("");
setDeviceName("");
setPollingStatus({
isPolling: false,
message: '',
messageType: 'default',
showAnimation: false
});
// 自动获取二维码
setTimeout(() => {
fetchDeviceQRCode();
}, 100);
};
const handleCloseAddDeviceModal = () => {
setIsAddDeviceOpen(false);
if (pollingTimerRef.current) {
clearTimeout(pollingTimerRef.current);
}
};
const handleAddDeviceByImei = async () => {
if (!deviceImei.trim() || !deviceName.trim()) {
toast({
title: "请填写完整信息",
description: "设备名称和IMEI不能为空",
variant: "destructive",
});
return;
}
try {
setIsSubmittingImei(true);
const response = await devicesApi.addByImei(deviceImei, deviceName);
if (response.code === 200) {
toast({
title: "添加成功",
description: "设备已成功添加",
});
setIsAddDeviceOpen(false);
loadDevices(1, true);
} else {
toast({
title: "添加失败",
description: response.msg || "请稍后重试",
variant: "destructive",
});
}
} catch (error) {
console.error('添加设备失败:', error);
toast({
title: '添加设备失败,请稍后重试',
variant: "destructive",
});
} finally {
setIsSubmittingImei(false);
}
};
const handleRefresh = () => {
setCurrentPage(1);
pageRef.current = 1;
loadDevices(1, true);
};
const handleDeleteClick = () => {
if (!selectedDeviceId) {
toast({
title: "请选择要删除的设备",
variant: "destructive",
});
return;
}
setDeviceToDelete(selectedDeviceId);
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
if (!deviceToDelete) return;
try {
const response = await devicesApi.delete(deviceToDelete);
if (response.code === 200) {
toast({
title: "删除成功",
description: "设备已成功删除",
});
setSelectedDeviceId(null);
loadDevices(1, true);
} else {
toast({
title: "删除失败",
description: response.msg || "请稍后重试",
variant: "destructive",
});
}
} catch (error) {
console.error('删除设备失败:', error);
toast({
title: '删除设备失败,请稍后重试',
variant: "destructive",
});
} finally {
setIsDeleteDialogOpen(false);
setDeviceToDelete(null);
}
};
const handleCancelDelete = () => {
setIsDeleteDialogOpen(false);
setDeviceToDelete(null);
};
const handleDeviceClick = (deviceId: number, event: React.MouseEvent) => {
event.stopPropagation();
navigate(`/devices/${deviceId}`);
};
const handleAddDevice = async () => {
if (activeTab === "manual") {
await handleAddDeviceByImei();
}
};
// 过滤设备列表
const filteredDevices = devices.filter(device => {
if (statusFilter === "online") return device.status === "online";
if (statusFilter === "offline") return device.status === "offline";
return true;
});
return (
<Layout
header={
<PageHeader
title="设备管理"
defaultBackPath="/"
rightContent={
<button
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
onClick={handleOpenAddDeviceModal}
>
<Plus className="h-4 w-4" />
</button>
}
/>
}
>
<div className="bg-gray-50">
<div className="p-4 space-y-4">
{/* 统计卡片 */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-bold text-blue-600">{stats.totalDevices}</div>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<div className="text-sm text-gray-500 mb-1">线</div>
<div className="text-2xl font-bold text-green-600">{stats.onlineDevices}</div>
</div>
</div>
{/* 搜索和过滤 */}
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 space-y-4">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="搜索设备IMEI/备注"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
<button
className="p-2.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
onClick={handleRefresh}
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
>
<option value="all"></option>
<option value="online">线</option>
<option value="offline">线</option>
</select>
<button
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
onClick={handleDeleteClick}
disabled={!selectedDeviceId}
>
</button>
</div>
</div>
{/* 设备列表 */}
<div className="space-y-3">
{filteredDevices.map((device) => (
<div
key={device.id}
className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 hover:shadow-md transition-all cursor-pointer"
onClick={(e) => handleDeviceClick(device.id, e)}
>
<div className="flex items-start gap-3">
<input
type="checkbox"
checked={selectedDeviceId === device.id}
onChange={(e) => {
if (e.target.checked) {
setSelectedDeviceId(device.id);
} else {
setSelectedDeviceId(null);
}
}}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<div className="font-semibold text-gray-900 truncate">{device.memo || "未命名设备"}</div>
<span className={`px-2.5 py-1 text-xs rounded-full font-medium ${
device.status === "online"
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-600"
}`}>
{device.status === "online" ? "在线" : "离线"}
</span>
</div>
<div className="space-y-1 text-sm text-gray-600">
<div>IMEI: {device.imei}</div>
<div>: {device.wechatId || "未绑定或微信离线"}</div>
<div>: {device.totalFriend}</div>
</div>
</div>
</div>
</div>
))}
<div ref={observerTarget} className="py-4 flex items-center justify-center">
{isLoading && <div className="text-sm text-gray-500">...</div>}
{!hasMore && devices.length > 0 && <div className="text-sm text-gray-500"></div>}
{!hasMore && devices.length === 0 && <div className="text-sm text-gray-500"></div>}
</div>
</div>
</div>
</div>
{/* 添加设备弹窗 */}
{isAddDeviceOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900"></h2>
<button
onClick={handleCloseAddDeviceModal}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="h-5 w-5 text-gray-400" />
</button>
</div>
<div className="space-y-4">
<div className="flex border-b border-gray-200">
<button
className={`flex-1 py-2.5 px-4 text-sm font-medium transition-colors ${
activeTab === "scan"
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
onClick={() => {
setActiveTab("scan");
// 切换到扫码添加时自动获取二维码
setTimeout(() => {
fetchDeviceQRCode();
}, 100);
}}
>
</button>
<button
className={`flex-1 py-2.5 px-4 text-sm font-medium transition-colors ${
activeTab === "manual"
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
onClick={() => setActiveTab("manual")}
>
</button>
</div>
{activeTab === "scan" && (
<div className="py-3">
<div className="flex flex-col items-center space-y-4">
{/* 状态提示 */}
<div className="text-center">
{pollingStatus.isPolling || pollingStatus.showAnimation ? (
<div className="space-y-1">
<span className="text-sm text-gray-700"></span>
<div className="flex justify-center space-x-1">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
</div>
</div>
) : (
<span className="text-sm text-gray-600">5</span>
)}
</div>
{/* 二维码区域 */}
<div className="bg-gray-50 p-3 rounded-xl w-full max-w-[220px] min-h-[220px] flex flex-col items-center justify-center">
{isLoadingQRCode ? (
<div className="flex flex-col items-center space-y-2">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
<p className="text-sm text-gray-500">...</p>
</div>
) : qrCodeImage ? (
<div id="qrcode-container" className="flex flex-col items-center space-y-2">
<div className="relative w-44 h-44 flex items-center justify-center">
<img
src={qrCodeImage}
alt="设备添加二维码"
className="w-full h-full object-contain"
onError={(e) => {
console.error("二维码图片加载失败");
e.currentTarget.style.display = 'none';
const container = document.getElementById('qrcode-container');
if (container) {
const errorEl = container.querySelector('.qrcode-error');
if (errorEl) {
errorEl.classList.remove('hidden');
}
}
}}
/>
<div className="qrcode-error hidden absolute inset-0 flex flex-col items-center justify-center text-center text-red-500 bg-white rounded-lg">
<AlertTriangle className="h-6 w-6 mb-1" />
<p className="text-xs"></p>
</div>
</div>
<p className="text-sm text-center text-gray-600">
使
</p>
</div>
) : (
<div className="text-center text-gray-500">
<QrCode className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm"></p>
</div>
)}
</div>
{/* 操作按钮 */}
<button
type="button"
onClick={fetchDeviceQRCode}
disabled={isLoadingQRCode}
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2.5 rounded-xl disabled:bg-gray-300 transition-colors flex items-center justify-center gap-2"
>
{isLoadingQRCode ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<RefreshCw className="h-4 w-4" />
</>
)}
</button>
</div>
</div>
)}
{activeTab === "manual" && (
<div className="py-3 space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700"></label>
<input
type="text"
placeholder="请输入设备名称"
value={deviceName}
onChange={(e) => setDeviceName(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
<p className="text-xs text-gray-500">
便
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">IMEI</label>
<input
type="text"
placeholder="请输入设备IMEI"
value={deviceImei}
onChange={(e) => setDeviceImei(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
<p className="text-xs text-gray-500">
IMEI码
</p>
</div>
</div>
<div className="flex gap-3">
<button
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors"
onClick={() => setIsAddDeviceOpen(false)}
>
</button>
<button
className="flex-1 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl disabled:bg-gray-300 transition-colors"
onClick={handleAddDevice}
disabled={!deviceImei.trim() || !deviceName.trim() || isSubmittingImei}
>
{isSubmittingImei ? "添加中..." : "添加"}
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* 删除确认弹窗 */}
{isDeleteDialogOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl max-w-md w-full p-6">
<div className="text-center mb-6">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-900 mb-2"></h3>
<p className="text-gray-600">
</p>
</div>
<div className="flex gap-3">
<button
className="flex-1 px-4 py-3 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors"
onClick={handleCancelDelete}
>
</button>
<button
className="flex-1 px-4 py-3 bg-red-600 hover:bg-red-700 text-white rounded-xl transition-colors"
onClick={handleConfirmDelete}
>
</button>
</div>
</div>
</div>
)}
</Layout>
);
}

View File

@@ -1,455 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Eye, EyeOff, Phone } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
import { useToast } from '@/components/ui/toast';
import { authApi } from '@/api';
import WeChatIcon from '@/components/icons/WeChatIcon';
import AppleIcon from '@/components/icons/AppleIcon';
// 定义登录表单类型
interface LoginForm {
phone: string;
password: string;
verificationCode: string;
agreeToTerms: boolean;
}
export default function Login() {
const [showPassword, setShowPassword] = useState(false);
const [activeTab, setActiveTab] = useState<'password' | 'verification'>('password');
const [isLoading, setIsLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
const [form, setForm] = useState<LoginForm>({
phone: '',
password: '',
verificationCode: '',
agreeToTerms: false,
});
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { toast } = useToast();
const { login } = useAuth();
// 检查URL是否为登录页面
const isLoginPage = (url: string) => {
try {
const urlObj = new URL(url, window.location.origin);
return urlObj.pathname === '/login' || urlObj.pathname.endsWith('/login');
} catch {
// 如果URL格式不正确返回false
return false;
}
};
// 倒计时效果
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
}
}, [countdown]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
};
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, agreeToTerms: e.target.checked }));
};
const validateForm = () => {
if (!form.phone) {
toast({
variant: 'destructive',
title: '请输入手机号',
description: '手机号不能为空',
});
return false;
}
// 手机号格式验证
const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneRegex.test(form.phone)) {
toast({
variant: 'destructive',
title: '手机号格式错误',
description: '请输入正确的11位手机号',
});
return false;
}
if (!form.agreeToTerms) {
toast({
variant: 'destructive',
title: '请同意用户协议',
description: '需要同意用户协议和隐私政策才能继续',
});
return false;
}
if (activeTab === 'password' && !form.password) {
toast({
variant: 'destructive',
title: '请输入密码',
description: '密码不能为空',
});
return false;
}
if (activeTab === 'verification' && !form.verificationCode) {
toast({
variant: 'destructive',
title: '请输入验证码',
description: '验证码不能为空',
});
return false;
}
return true;
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
setIsLoading(true);
try {
if (activeTab === 'password') {
// 发送账号密码登录请求
const response = await authApi.login(form.phone, form.password);
if (response.code === 200 && response.data) {
// 保存登录信息
localStorage.setItem('token', response.data.token);
localStorage.setItem('token_expired', response.data.token_expired);
localStorage.setItem('s2_accountId', response.data.member.s2_accountId);
// 保存用户信息
localStorage.setItem('userInfo', JSON.stringify(response.data.member));
// 调用认证上下文的登录方法
login(response.data.token, response.data.member);
// 显示成功提示
toast({
title: '登录成功',
description: '欢迎回来!',
});
// 跳转到首页或重定向URL
const returnUrl = searchParams.get('returnUrl');
if (returnUrl) {
const decodedUrl = decodeURIComponent(returnUrl);
// 检查重定向URL是否为登录页面避免无限重定向
if (isLoginPage(decodedUrl)) {
navigate('/');
} else {
window.location.href = decodedUrl;
}
} else {
navigate('/');
}
} else {
throw new Error(response.msg || '登录失败');
}
} else {
// 验证码登录
const response = await authApi.loginWithCode(form.phone, form.verificationCode);
if (response.code === 200 && response.data) {
// 保存登录信息
localStorage.setItem('token', response.data.token);
localStorage.setItem('token_expired', response.data.token_expired);
localStorage.setItem('s2_accountId', response.data.member.s2_accountId);
// 保存用户信息
localStorage.setItem('userInfo', JSON.stringify(response.data.member));
// 调用认证上下文的登录方法
login(response.data.token, response.data.member);
// 显示成功提示
toast({
title: '登录成功',
description: '欢迎回来!',
});
// 跳转到首页或重定向URL
const returnUrl = searchParams.get('returnUrl');
if (returnUrl) {
const decodedUrl = decodeURIComponent(returnUrl);
// 检查重定向URL是否为登录页面避免无限重定向
if (isLoginPage(decodedUrl)) {
navigate('/');
} else {
window.location.href = decodedUrl;
}
} else {
navigate('/');
}
} else {
throw new Error(response.msg || '登录失败');
}
}
} catch (error) {
toast({
variant: 'destructive',
title: '登录失败',
description: error instanceof Error ? error.message : '请稍后重试',
});
} finally {
setIsLoading(false);
}
};
const handleSendVerificationCode = async () => {
if (!form.phone) {
toast({
variant: 'destructive',
title: '请输入手机号',
description: '发送验证码需要手机号',
});
return;
}
// 手机号格式验证
const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneRegex.test(form.phone)) {
toast({
variant: 'destructive',
title: '手机号格式错误',
description: '请输入正确的11位手机号',
});
return;
}
try {
setIsLoading(true);
const response = await authApi.sendVerificationCode(form.phone);
if (response.code === 200) {
toast({
title: '验证码已发送',
description: '请查收短信验证码',
});
setCountdown(60); // 开始60秒倒计时
} else {
throw new Error(response.msg || '发送失败');
}
} catch (error) {
toast({
variant: 'destructive',
title: '发送失败',
description: error instanceof Error ? error.message : '请稍后重试',
});
} finally {
setIsLoading(false);
}
};
const handleWechatLogin = () => {
// 微信登录逻辑
toast({
title: '功能开发中',
description: '微信登录功能正在开发中,请使用其他方式登录',
});
};
const handleAppleLogin = () => {
// Apple登录逻辑
toast({
title: '功能开发中',
description: 'Apple登录功能正在开发中请使用其他方式登录',
});
};
return (
<div className="min-h-screen bg-white flex items-center justify-center px-4 py-8">
<div className="max-w-md w-full">
{/* 标题 */}
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900 mb-2"></h1>
<p className="text-gray-600 text-sm"> / / Apple </p>
</div>
{/* 标签页切换 */}
<div className="flex border-b border-gray-200 mb-6">
<button
onClick={() => setActiveTab('password')}
className={`flex-1 py-3 text-center border-b-2 transition-colors font-medium ${
activeTab === 'password'
? 'border-blue-500 text-blue-500'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
</button>
<button
onClick={() => setActiveTab('verification')}
className={`flex-1 py-3 text-center border-b-2 transition-colors font-medium ${
activeTab === 'verification'
? 'border-blue-500 text-blue-500'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
</button>
</div>
<form onSubmit={handleLogin} className="space-y-6">
{/* 手机号输入 */}
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<input
type="tel"
name="phone"
value={form.phone}
onChange={handleInputChange}
placeholder="请输入手机号"
className="w-full pl-16 pr-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 transition-colors"
disabled={isLoading}
/>
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 flex items-center gap-1 text-sm">
<Phone className="h-4 w-4" />
+86
</span>
</div>
</div>
{/* 密码输入 */}
{activeTab === 'password' && (
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
name="password"
value={form.password}
onChange={handleInputChange}
placeholder="请输入密码"
className="w-full pl-4 pr-12 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 transition-colors"
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 transition-colors"
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
</div>
)}
{/* 验证码输入 */}
{activeTab === 'verification' && (
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<input
type="text"
name="verificationCode"
value={form.verificationCode}
onChange={handleInputChange}
placeholder="请输入验证码"
className="w-full pl-4 pr-32 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 transition-colors"
disabled={isLoading}
/>
<button
type="button"
onClick={handleSendVerificationCode}
disabled={isLoading || countdown > 0}
className={`absolute right-2 top-1/2 -translate-y-1/2 px-4 h-10 rounded-md text-sm font-medium transition-colors ${
countdown > 0
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-blue-50 text-blue-500 hover:bg-blue-100'
}`}
>
{countdown > 0 ? `${countdown}s` : '获取验证码'}
</button>
</div>
</div>
)}
{/* 用户协议 */}
<div className="flex items-start space-x-3">
<input
type="checkbox"
id="terms"
checked={form.agreeToTerms}
onChange={handleCheckboxChange}
disabled={isLoading}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mt-0.5"
/>
<label
htmlFor="terms"
className="text-sm text-gray-600 leading-relaxed cursor-pointer"
>
<button type="button" className="text-blue-500 hover:text-blue-600 mx-1"></button>
<button type="button" className="text-blue-500 hover:text-blue-600 mx-1"></button>
</label>
</div>
{/* 登录按钮 */}
<button
type="submit"
className="w-full py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
...
</div>
) : (
'登录'
)}
</button>
{/* 分割线 */}
<div className="relative my-8">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-white text-gray-500"></span>
</div>
</div>
{/* 第三方登录 */}
<div className="flex justify-center space-x-8">
<button
type="button"
onClick={handleWechatLogin}
className="flex flex-col items-center space-y-2 p-4 text-gray-500 hover:text-gray-700 transition-colors rounded-lg hover:bg-gray-50"
>
<WeChatIcon className="h-8 w-8" />
<span className="text-xs"></span>
</button>
<button
type="button"
onClick={handleAppleLogin}
className="flex flex-col items-center space-y-2 p-4 text-gray-500 hover:text-gray-700 transition-colors rounded-lg hover:bg-gray-50"
>
<AppleIcon className="h-8 w-8" />
<span className="text-xs">Apple</span>
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,5 +0,0 @@
import React from 'react';
export default function Orders() {
return <div></div>;
}

View File

@@ -1,239 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Users, TrendingUp, Calendar, Settings, Play, Pause, Edit } from 'lucide-react';
interface PlanData {
id: string;
name: string;
status: 'active' | 'paused' | 'completed';
createdAt: string;
totalCustomers: number;
todayCustomers: number;
growth: string;
description?: string;
scenario: string;
}
export default function PlanDetail() {
const { planId } = useParams<{ planId: string }>();
const navigate = useNavigate();
const [plan, setPlan] = useState<PlanData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const fetchPlanData = async () => {
setLoading(true);
try {
// 模拟API调用
const mockPlan: PlanData = {
id: planId || '',
name: '春季营销计划',
status: 'active',
createdAt: '2024-03-15',
totalCustomers: 456,
todayCustomers: 23,
growth: '+8.2%',
description: '针对春季市场的营销推广计划,通过多种渠道获取潜在客户',
scenario: 'douyin',
};
setPlan(mockPlan);
} catch (error) {
setError('获取计划数据失败');
console.error('获取计划数据失败:', error);
} finally {
setLoading(false);
}
};
fetchPlanData();
}, [planId]);
const handleStatusChange = async (newStatus: 'active' | 'paused') => {
if (!plan) return;
try {
// 这里可以调用实际的API
// await fetch(`/api/plans/${plan.id}/status`, {
// method: 'PATCH',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ status: newStatus }),
// });
setPlan({ ...plan, status: newStatus });
} catch (error) {
console.error('更新计划状态失败:', error);
alert('更新失败,请重试');
}
};
if (loading) {
return (
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
<div className="flex justify-center items-center h-40">
<div className="text-gray-500">...</div>
</div>
</div>
);
}
if (error || !plan) {
return (
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
<div className="text-red-500 text-center py-8">{error || '计划不存在'}</div>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between p-4">
<div className="flex items-center">
<button
onClick={() => navigate(-1)}
className="mr-3 p-1 hover:bg-gray-100 rounded"
>
<ArrowLeft className="h-5 w-5" />
</button>
<h1 className="text-xl font-semibold">{plan.name}</h1>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => navigate(`/plans/${plan.id}/edit`)}
className="p-2 hover:bg-gray-100 rounded"
>
<Edit className="h-5 w-5" />
</button>
<button
onClick={() => navigate(`/plans/${plan.id}/settings`)}
className="p-2 hover:bg-gray-100 rounded"
>
<Settings className="h-5 w-5" />
</button>
</div>
</div>
</header>
<div className="p-4">
{/* 计划描述 */}
{plan.description && (
<div className="bg-white rounded-lg p-4 mb-6">
<p className="text-gray-600 text-sm">{plan.description}</p>
</div>
)}
{/* 数据统计 */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="bg-white rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm"></p>
<p className="text-2xl font-bold text-gray-900">{plan.totalCustomers}</p>
</div>
<Users className="h-8 w-8 text-blue-500" />
</div>
<div className="flex items-center mt-2 text-green-500 text-sm">
<TrendingUp className="h-4 w-4 mr-1" />
<span>{plan.growth}</span>
</div>
</div>
<div className="bg-white rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm"></p>
<p className="text-2xl font-bold text-gray-900">{plan.todayCustomers}</p>
</div>
<Calendar className="h-8 w-8 text-green-500" />
</div>
<div className="flex items-center mt-2 text-gray-500 text-sm">
<span> {plan.createdAt}</span>
</div>
</div>
</div>
{/* 状态控制 */}
<div className="bg-white rounded-lg p-4 mb-6">
<h3 className="text-lg font-medium mb-4"></h3>
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className={`px-3 py-1 rounded-full text-sm ${
plan.status === 'active'
? 'text-green-600 bg-green-50'
: plan.status === 'paused'
? 'text-yellow-600 bg-yellow-50'
: 'text-gray-600 bg-gray-50'
}`}>
{plan.status === 'active' ? '进行中' : plan.status === 'paused' ? '已暂停' : '已完成'}
</span>
</div>
<div className="flex space-x-2">
{plan.status === 'active' ? (
<button
onClick={() => handleStatusChange('paused')}
className="flex items-center px-3 py-2 bg-yellow-500 text-white rounded-md hover:bg-yellow-600 transition-colors text-sm"
>
<Pause className="h-4 w-4 mr-1" />
</button>
) : plan.status === 'paused' ? (
<button
onClick={() => handleStatusChange('active')}
className="flex items-center px-3 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors text-sm"
>
<Play className="h-4 w-4 mr-1" />
</button>
) : null}
</div>
</div>
</div>
{/* 功能区域 */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-white rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate(`/plans/${plan.id}/customers`)}>
<div className="flex items-center">
<Users className="h-8 w-8 text-blue-500 mr-3" />
<div>
<h3 className="font-medium text-gray-900"></h3>
<p className="text-sm text-gray-500"></p>
</div>
</div>
</div>
<div className="bg-white rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate(`/plans/${plan.id}/analytics`)}>
<div className="flex items-center">
<TrendingUp className="h-8 w-8 text-green-500 mr-3" />
<div>
<h3 className="font-medium text-gray-900"></h3>
<p className="text-sm text-gray-500"></p>
</div>
</div>
</div>
<div className="bg-white rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate(`/plans/${plan.id}/content`)}>
<div className="flex items-center">
<Calendar className="h-8 w-8 text-purple-500 mr-3" />
<div>
<h3 className="font-medium text-gray-900"></h3>
<p className="text-sm text-gray-500"></p>
</div>
</div>
</div>
<div className="bg-white rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate(`/plans/${plan.id}/settings`)}>
<div className="flex items-center">
<Settings className="h-8 w-8 text-gray-500 mr-3" />
<div>
<h3 className="font-medium text-gray-900"></h3>
<p className="text-sm text-gray-500"></p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,193 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Calendar } from 'lucide-react';
import PageHeader from '@/components/PageHeader';
interface Plan {
id: string;
name: string;
status: 'active' | 'paused' | 'completed';
createdAt: string;
totalCustomers: number;
todayCustomers: number;
growth: string;
scenario: string;
}
export default function Plans() {
const navigate = useNavigate();
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const fetchPlans = async () => {
setLoading(true);
try {
// 模拟API调用
const mockPlans: Plan[] = [
{
id: '1',
name: '春季营销计划',
status: 'active',
createdAt: '2024-03-15',
totalCustomers: 456,
todayCustomers: 23,
growth: '+8.2%',
scenario: 'douyin',
},
{
id: '2',
name: '新品推广计划',
status: 'active',
createdAt: '2024-03-10',
totalCustomers: 234,
todayCustomers: 15,
growth: '+5.1%',
scenario: 'xiaohongshu',
},
{
id: '3',
name: '节日活动计划',
status: 'paused',
createdAt: '2024-02-28',
totalCustomers: 789,
todayCustomers: 0,
growth: '+0%',
scenario: 'gongzhonghao',
},
];
setPlans(mockPlans);
} catch (error) {
setError('获取计划数据失败');
console.error('获取计划数据失败:', error);
} finally {
setLoading(false);
}
};
fetchPlans();
}, []);
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'text-green-600 bg-green-50';
case 'paused':
return 'text-yellow-600 bg-yellow-50';
case 'completed':
return 'text-gray-600 bg-gray-50';
default:
return 'text-gray-600 bg-gray-50';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'active':
return '进行中';
case 'paused':
return '已暂停';
case 'completed':
return '已完成';
default:
return '未知';
}
};
if (loading) {
return (
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
<PageHeader
title="获客计划"
showBack={false}
/>
<div className="flex justify-center items-center h-40">
<div className="text-gray-500">...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
<PageHeader
title="获客计划"
showBack={false}
/>
<div className="text-red-500 text-center py-8">{error}</div>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
<PageHeader
title="获客计划"
showBack={false}
rightContent={
<button
onClick={() => navigate('/scenarios/new')}
className="flex items-center px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm"
>
<Plus className="h-4 w-4 mr-1" />
</button>
}
/>
<div className="p-4">
{plans.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<p></p>
<button
onClick={() => navigate('/scenarios/new')}
className="mt-2 text-blue-600 hover:text-blue-700"
>
</button>
</div>
) : (
<div className="space-y-4">
{plans.map((plan) => (
<div
key={plan.id}
className="bg-white rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => navigate(`/plans/${plan.id}`)}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center mb-2">
<h3 className="font-medium text-gray-900">{plan.name}</h3>
<span className={`ml-2 px-2 py-1 text-xs rounded-full ${getStatusColor(plan.status)}`}>
{getStatusText(plan.status)}
</span>
</div>
<div className="flex items-center text-sm text-gray-500">
<Calendar className="h-4 w-4 mr-1" />
<span> {plan.createdAt}</span>
</div>
</div>
<div className="text-right">
<div className="flex items-center text-sm">
<span className="text-gray-500">:</span>
<span className="font-medium ml-1">{plan.totalCustomers}</span>
</div>
<div className="flex items-center text-sm mt-1">
<span className="text-gray-500">:</span>
<span className="font-medium ml-1">{plan.todayCustomers}</span>
<span className="text-green-500 ml-1">({plan.growth})</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,258 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronRight, Settings, Bell, LogOut, Smartphone, MessageCircle, Database, FolderOpen } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useAuth } from '@/contexts/AuthContext';
import { useToast } from '@/components/ui/toast';
import Layout from '@/components/Layout';
import BottomNav from '@/components/BottomNav';
import UnifiedHeader from '@/components/UnifiedHeader';
import '@/components/Layout.css';
export default function Profile() {
const navigate = useNavigate();
const { user, logout, isAuthenticated } = useAuth();
const { toast } = useToast();
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const [userInfo, setUserInfo] = useState<any>(null);
const [stats, setStats] = useState({
devices: 12,
wechat: 25,
traffic: 8,
content: 156,
});
// 从localStorage获取用户信息
useEffect(() => {
const userInfoStr = localStorage.getItem('userInfo');
if (userInfoStr) {
setUserInfo(JSON.parse(userInfoStr));
}
}, []);
// 用户信息
const currentUserInfo = {
name: userInfo?.username || user?.username || "卡若",
email: userInfo?.email || "zhangsan@example.com",
role: "管理员",
joinDate: "2023-01-15",
lastLogin: "2024-01-20 14:30",
};
// 功能模块数据
const functionModules = [
{
id: "devices",
title: "设备管理",
description: "管理您的设备和微信账号",
icon: <Smartphone className="h-5 w-5 text-blue-500" />,
count: stats.devices,
path: "/devices",
bgColor: "bg-blue-50",
},
{
id: "wechat",
title: "微信号管理",
description: "管理微信账号和好友",
icon: <MessageCircle className="h-5 w-5 text-green-500" />,
count: stats.wechat,
path: "/wechat-accounts",
bgColor: "bg-green-50",
},
{
id: "traffic",
title: "流量池",
description: "管理用户流量池和分组",
icon: <Database className="h-5 w-5 text-purple-500" />,
count: stats.traffic,
path: "/traffic-pool",
bgColor: "bg-purple-50",
},
{
id: "content",
title: "内容库",
description: "管理营销内容和素材",
icon: <FolderOpen className="h-5 w-5 text-orange-500" />,
count: stats.content,
path: "/content",
bgColor: "bg-orange-50",
},
];
// 加载统计数据
const loadStats = async () => {
try {
// 这里可以调用实际的API
// const [deviceStats, wechatStats, trafficStats, contentStats] = await Promise.allSettled([
// getDeviceStats(),
// getWechatStats(),
// getTrafficStats(),
// getContentStats(),
// ]);
// 暂时使用模拟数据
setStats({
devices: 12,
wechat: 25,
traffic: 8,
content: 156,
});
} catch (error) {
console.error("加载统计数据失败:", error);
}
};
useEffect(() => {
loadStats();
}, []);
const handleLogout = () => {
// 清除本地存储的用户信息
localStorage.removeItem('token');
localStorage.removeItem('token_expired');
localStorage.removeItem('s2_accountId');
localStorage.removeItem('userInfo');
setShowLogoutDialog(false);
logout();
navigate('/login');
toast({
title: '退出成功',
description: '您已安全退出系统',
});
};
const handleFunctionClick = (path: string) => {
navigate(path);
};
if (!isAuthenticated) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-gray-500"></div>
</div>
);
}
return (
<Layout
header={
<UnifiedHeader
title="我的"
showBack={false}
titleColor="blue"
actions={[
{
type: 'icon',
icon: Bell,
onClick: () => console.log('Notifications'),
},
{
type: 'icon',
icon: Settings,
onClick: () => console.log('Settings'),
},
]}
/>
}
footer={<BottomNav />}
>
<div className="bg-gray-50 pb-16">
<div className="p-4 space-y-4">
{/* 用户信息卡片 */}
<Card>
<CardContent className="p-4">
<div className="flex items-center space-x-4">
<Avatar className="h-16 w-16">
<AvatarImage src={userInfo?.avatar || user?.avatar || ''} />
<AvatarFallback className="bg-gray-200 text-gray-600 text-lg font-medium">
{currentUserInfo.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center space-x-2 mb-1">
<h2 className="text-lg font-medium">{currentUserInfo.name}</h2>
<span className="px-2 py-1 text-xs bg-gradient-to-r from-orange-400 to-orange-500 text-white rounded-full font-medium shadow-sm">
{currentUserInfo.role}
</span>
</div>
<p className="text-sm text-gray-600 mb-2">{currentUserInfo.email}</p>
<div className="text-xs text-gray-500">
<div>: {currentUserInfo.lastLogin}</div>
</div>
</div>
<div className="flex flex-col space-y-2">
<Button variant="ghost" size="icon">
<Bell className="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon">
<Settings className="h-5 w-5" />
</Button>
</div>
</div>
</CardContent>
</Card>
{/* 我的功能 */}
<Card>
<CardContent className="p-4">
<div className="space-y-2">
{functionModules.map((module) => (
<div
key={module.id}
className="flex items-center p-4 rounded-lg border hover:bg-gray-50 cursor-pointer transition-colors w-full"
onClick={() => handleFunctionClick(module.path)}
>
<div className={`p-2 rounded-lg ${module.bgColor} mr-3`}>{module.icon}</div>
<div className="flex-1">
<div className="font-medium text-sm">{module.title}</div>
<div className="text-xs text-gray-500">{module.description}</div>
</div>
<div className="flex items-center space-x-2">
<span className="px-2 py-1 text-xs bg-gray-50 text-gray-700 rounded-full border border-gray-200 font-medium shadow-sm">
{module.count}
</span>
<ChevronRight className="h-4 w-4 text-gray-400" />
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* 退出登录 */}
<Button
variant="outline"
className="w-full text-red-600 border-red-200 hover:bg-red-50 bg-transparent"
onClick={() => setShowLogoutDialog(true)}
>
<LogOut className="h-4 w-4 mr-2" />
退
</Button>
</div>
</div>
{/* 退出登录确认对话框 */}
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>退</DialogTitle>
<DialogDescription>
退退使
</DialogDescription>
</DialogHeader>
<div className="flex justify-end space-x-2 mt-4">
<Button variant="outline" onClick={() => setShowLogoutDialog(false)}>
</Button>
<Button variant="destructive" onClick={handleLogout}>
退
</Button>
</div>
</DialogContent>
</Dialog>
</Layout>
);
}

View File

@@ -1,663 +0,0 @@
import React, { useEffect, useState, useCallback } from "react";
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
import PageHeader from "@/components/PageHeader";
import Layout from "@/components/Layout";
import BottomNav from "@/components/BottomNav";
import {
Plus,
Users,
Calendar,
Copy,
Trash2,
Edit,
Settings,
Loader2,
Code,
Search,
RefreshCw,
} from "lucide-react";
import {
fetchPlanList,
fetchPlanDetail,
copyPlan,
deletePlan,
type Task,
} from "@/api/scenarios";
import { useToast } from "@/components/ui/toast";
import "@/components/Layout.css";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
interface ScenarioData {
id: string;
name: string;
image: string;
description: string;
totalPlans: number;
totalCustomers: number;
todayCustomers: number;
growth: string;
}
interface ApiSettings {
apiKey: string;
webhookUrl: string;
taskId: string;
}
export default function ScenarioDetail() {
const { scenarioId, scenarioName } = useParams<{
scenarioId: string;
scenarioName: string;
}>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { toast } = useToast();
const [scenario, setScenario] = useState<ScenarioData | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showApiDialog, setShowApiDialog] = useState(false);
const [currentApiSettings, setCurrentApiSettings] = useState<ApiSettings>({
apiKey: "",
webhookUrl: "",
taskId: "",
});
const [searchTerm, setSearchTerm] = useState("");
const [loadingTasks, setLoadingTasks] = useState(false);
// 获取渠道中文名称
const getChannelName = (channel: string) => {
const channelMap: Record<string, string> = {
douyin: "抖音直播获客",
kuaishou: "快手直播获客",
xiaohongshu: "小红书种草获客",
weibo: "微博话题获客",
haibao: "海报扫码获客",
phone: "电话号码获客",
gongzhonghao: "公众号引流获客",
weixinqun: "微信群裂变获客",
payment: "付款码获客",
api: "API接口获客",
};
return channelMap[channel] || `${channel}获客`;
};
// 获取场景描述
const getScenarioDescription = (channel: string) => {
const descriptions: Record<string, string> = {
douyin: "通过抖音平台进行精准获客,利用短视频内容吸引目标用户",
xiaohongshu: "利用小红书平台进行内容营销获客,通过优质内容建立品牌形象",
gongzhonghao: "通过微信公众号进行获客,建立私域流量池",
haibao: "通过海报分享进行获客,快速传播品牌信息",
phone: "通过电话营销进行获客,直接与客户沟通",
weixinqun: "通过微信群进行获客,利用社交裂变效应",
payment: "通过付款码进行获客,便捷的支付方式",
api: "通过API接口进行获客支持第三方系统集成",
};
return descriptions[channel] || "通过该平台进行获客";
};
useEffect(() => {
const fetchScenarioData = async () => {
if (!scenarioId) return;
setLoading(true);
setError("");
try {
// 获取计划列表
const response = await fetchPlanList(scenarioId, 1, 20);
// 设置计划列表(可能为空)
if (response && response.data && response.data.list) {
setTasks(response.data.list);
} else {
setTasks([]);
}
// 构建场景数据(无论是否有计划都要创建)
const scenarioData: ScenarioData = {
id: scenarioId,
name: scenarioName || "",
image: "", // 可以根据需要设置图片
description: getScenarioDescription(scenarioId),
totalPlans: response?.data?.list?.length || 0,
totalCustomers: 0, // 移除统计
todayCustomers: 0, // 移除统计
growth: "", // 移除增长
};
setScenario(scenarioData);
} catch (error) {
console.error("获取场景数据失败:", error);
// 即使API失败也要创建基本的场景数据
const scenarioData: ScenarioData = {
id: scenarioId,
name: getScenarioName(),
image: "",
description: getScenarioDescription(scenarioId),
totalPlans: 0,
totalCustomers: 0,
todayCustomers: 0,
growth: "",
};
setScenario(scenarioData);
setTasks([]);
} finally {
setLoading(false);
}
};
fetchScenarioData();
}, [scenarioId]);
// 获取场景名称 - 优先使用URL查询参数其次使用映射
const getScenarioName = useCallback(() => {
// 优先使用URL查询参数中的name
const urlName = searchParams.get("name");
if (urlName) {
return urlName;
}
// 如果没有URL参数使用映射
return getChannelName(scenarioId || "");
}, [searchParams, scenarioId]);
// 更新场景数据中的名称
useEffect(() => {
setScenario((prev) =>
prev
? {
...prev,
name: (() => {
const urlName = searchParams.get("name");
if (urlName) return urlName;
return getChannelName(scenarioId || "");
})(),
}
: null
);
}, [searchParams, scenarioId]);
const handleCopyPlan = async (taskId: string) => {
const taskToCopy = tasks.find((task) => task.id === taskId);
if (!taskToCopy) return;
try {
const response = await copyPlan(taskId);
if (response && response.code === 200) {
toast({
title: "计划已复制",
description: `已成功复制"${taskToCopy.name}"`,
});
// 重新加载数据
const refreshResponse = await fetchPlanList(scenarioId!, 1, 20);
if (
refreshResponse &&
refreshResponse.code === 200 &&
refreshResponse.data
) {
setTasks(refreshResponse.data.list);
}
} else {
throw new Error(response?.msg || "复制失败");
}
} catch (error) {
console.error("复制计划失败:", error);
toast({
title: "复制失败",
description: error instanceof Error ? error.message : "复制计划失败",
variant: "destructive",
});
}
};
const handleDeletePlan = async (taskId: string) => {
const taskToDelete = tasks.find((task) => task.id === taskId);
if (!taskToDelete) return;
if (!window.confirm(`确定要删除"${taskToDelete.name}"吗?`)) return;
try {
const response = await deletePlan(taskId);
if (response && response.code === 200) {
toast({
title: "计划已删除",
description: `已成功删除"${taskToDelete.name}"`,
});
// 重新加载数据
const refreshResponse = await fetchPlanList(scenarioId!, 1, 20);
if (
refreshResponse &&
refreshResponse.code === 200 &&
refreshResponse.data
) {
setTasks(refreshResponse.data.list);
}
} else {
throw new Error(response?.msg || "删除失败");
}
} catch (error) {
console.error("删除计划失败:", error);
toast({
title: "删除失败",
description: error instanceof Error ? error.message : "删除计划失败",
variant: "destructive",
});
}
};
const handleStatusChange = async (taskId: string, newStatus: 1 | 0) => {
try {
// 这里应该调用状态切换API暂时模拟
setTasks((prev) =>
prev.map((task) =>
task.id === taskId ? { ...task, status: newStatus } : task
)
);
toast({
title: "状态已更新",
description: `计划已${newStatus === 1 ? "启动" : "暂停"}`,
});
} catch (error) {
console.error("状态切换失败:", error);
toast({
title: "状态切换失败",
description: "请稍后重试",
variant: "destructive",
});
}
};
const handleOpenApiSettings = async (taskId: string) => {
try {
const response = await fetchPlanDetail(taskId);
if (response && response.code === 200 && response.data) {
setCurrentApiSettings({
apiKey: response.data.apiKey || "demo-api-key-123456",
webhookUrl:
response.data.textUrl?.fullUrl ||
`https://api.example.com/webhook/${taskId}`,
taskId,
});
setShowApiDialog(true);
} else {
throw new Error(response?.msg || "获取API设置失败");
}
} catch (error) {
console.error("获取API设置失败:", error);
toast({
title: "获取API设置失败",
description: "请稍后重试",
variant: "destructive",
});
}
};
const handleCopyApiUrl = (url: string) => {
navigator.clipboard.writeText(url);
toast({
title: "已复制",
description: "接口地址已复制到剪贴板",
});
};
const handleCreateNewPlan = () => {
navigate(`/scenarios/new/${scenarioId}`);
};
const getStatusColor = (status: number) => {
switch (status) {
case 1:
return "text-green-600 bg-green-50";
case 0:
return "text-yellow-600 bg-yellow-50";
default:
return "text-gray-600 bg-gray-50";
}
};
const getStatusText = (status: number) => {
switch (status) {
case 1:
return "进行中";
case 0:
return "已暂停";
default:
return "未知";
}
};
if (loading) {
return (
<Layout
header={
<PageHeader
title={scenario?.name || "场景详情"}
defaultBackPath="/scenarios"
/>
}
>
<div className="bg-gray-50 min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
<p className="text-gray-500">...</p>
</div>
</div>
</Layout>
);
}
if (error) {
return (
<Layout
header={<PageHeader title="场景详情" defaultBackPath="/scenarios" />}
footer={<BottomNav />}
>
<div className="bg-gray-50 min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-red-500 mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
</button>
</div>
</div>
</Layout>
);
}
if (!scenario) {
return (
<Layout
header={<PageHeader title="场景详情" defaultBackPath="/scenarios" />}
footer={<BottomNav />}
>
<div className="bg-gray-50 min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
<p className="text-gray-500">...</p>
</div>
</div>
</Layout>
);
}
const handleRefresh = async () => {
setLoadingTasks(true);
await fetchPlanList(scenarioId!, 1, 20);
setLoadingTasks(false);
};
const filteredTasks = tasks.filter((task) => task.name.includes(searchTerm));
return (
<Layout
header={
<>
<PageHeader
title={scenario.name}
defaultBackPath="/scenarios"
rightContent={
<button
onClick={handleCreateNewPlan}
className="flex items-center px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm"
>
<Plus className="h-4 w-4 mr-1" />
</button>
}
/>
<div className="flex items-center space-x-2 m-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索计划名称"
className="pl-9"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={loadingTasks}
>
{loadingTasks ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
</>
}
>
<div className="p-4">
{/* 计划列表 */}
<div className="rounded-lg">
{filteredTasks.length === 0 ? (
<div className="p-8 text-center">
<div className="mb-4">
<Users className="h-12 w-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 text-lg font-medium mb-2">
</p>
<p className="text-gray-400 text-sm">
</p>
</div>
<button
onClick={handleCreateNewPlan}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<Plus className="h-4 w-4 mr-2" />
</button>
</div>
) : (
<div className="divide-y">
{filteredTasks.map((task) => (
<div key={task.id} className="p-4 bg-white">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center mb-2">
<h3 className="font-medium text-gray-900">
{task.name}
</h3>
<span
className={`ml-2 px-2 py-1 text-xs rounded-full ${getStatusColor(
task.status
)}`}
>
{getStatusText(task.status)}
</span>
</div>
<div className="flex items-center text-sm text-gray-500">
<Calendar className="h-4 w-4 mr-1" />
<span>: {task.lastUpdated}</span>
</div>
<div className="flex items-center mt-2 text-sm text-gray-500">
<span>
: {task.stats?.devices || 0} | :{" "}
{task.stats?.acquired || 0} | :{" "}
{task.stats?.added || 0}
</span>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => navigate(`/scenarios/edit/${task.id}`)}
className={`p-2 rounded-md ${
task.status === 1
? "text-yellow-600 hover:bg-yellow-50"
: "text-green-600 hover:bg-green-50"
}`}
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleOpenApiSettings(task.id)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-md"
>
<Settings className="h-4 w-4" />
</button>
<button
onClick={() => handleCopyPlan(task.id)}
className="p-2 text-gray-600 hover:bg-gray-50 rounded-md"
>
<Copy className="h-4 w-4" />
</button>
<button
onClick={() => handleDeletePlan(task.id)}
className="p-2 text-red-600 hover:bg-red-50 rounded-md"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* API接口设置对话框 */}
{showApiDialog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Code className="h-6 w-6 text-blue-600" />
</div>
<div>
<h3 className="text-xl font-semibold"></h3>
<p className="text-gray-500 text-sm">
API接口直接导入客资到该获客计划
</p>
</div>
</div>
<button
onClick={() => setShowApiDialog(false)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<span className="text-2xl">&times;</span>
</button>
</div>
<div className="space-y-6">
{/* API密钥配置 */}
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium">API密钥</h4>
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded">
</span>
</div>
<div className="flex items-center space-x-3">
<input
value={currentApiSettings.apiKey}
readOnly
className="flex-1 p-2 bg-white border rounded font-mono text-sm"
/>
<button
onClick={() => {
navigator.clipboard.writeText(currentApiSettings.apiKey);
toast({
title: "已复制",
description: "API密钥已复制到剪贴板",
});
}}
className="px-3 py-2 border border-gray-300 rounded hover:bg-gray-50 transition-colors"
>
<Copy className="h-4 w-4 mr-1" />
</button>
</div>
<div className="mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-sm text-amber-800">
<strong></strong>
API密钥使
</p>
</div>
</div>
{/* 接口地址配置 */}
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium"></h4>
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded">
POST请求
</span>
</div>
<div className="flex items-center space-x-3">
<input
value={currentApiSettings.webhookUrl}
readOnly
className="flex-1 p-2 bg-white border rounded font-mono text-sm"
/>
<button
onClick={() =>
handleCopyApiUrl(currentApiSettings.webhookUrl)
}
className="px-3 py-2 border border-gray-300 rounded hover:bg-gray-50 transition-colors"
>
<Copy className="h-4 w-4 mr-1" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<h5 className="font-medium text-green-800 mb-2">
</h5>
<div className="space-y-1 text-sm text-green-700">
<div>
<code className="bg-green-100 px-1 rounded">name</code>{" "}
-
</div>
<div>
<code className="bg-green-100 px-1 rounded">phone</code>{" "}
-
</div>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<h5 className="font-medium text-blue-800 mb-2"></h5>
<div className="space-y-1 text-sm text-blue-700">
<div>
<code className="bg-blue-100 px-1 rounded">source</code>{" "}
-
</div>
<div>
<code className="bg-blue-100 px-1 rounded">remark</code>{" "}
-
</div>
<div>
<code className="bg-blue-100 px-1 rounded">tags</code> -
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</Layout>
);
}

View File

@@ -1,199 +0,0 @@
import React, { useEffect, useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Plus, TrendingUp, Loader2 } from "lucide-react";
import UnifiedHeader from "@/components/UnifiedHeader";
import Layout from "@/components/Layout";
import BottomNav from "@/components/BottomNav";
import { fetchScenes, type SceneItem } from "@/api/scenarios";
import "@/components/Layout.css";
interface Scenario {
id: string;
name: string;
image: string;
description?: string;
count: number;
growth: string;
status: string;
}
export default function Scenarios() {
const navigate = useNavigate();
const [scenarios, setScenarios] = useState<Scenario[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// 场景描述映射
const scenarioDescriptions: Record<string, string> = useMemo(
() => ({
douyin: "通过抖音平台进行精准获客",
xiaohongshu: "利用小红书平台进行内容营销获客",
gongzhonghao: "通过微信公众号进行获客",
haibao: "通过海报分享进行获客",
phone: "通过电话营销进行获客",
weixinqun: "通过微信群进行获客",
payment: "通过付款码进行获客",
api: "通过API接口进行获客",
}),
[]
);
useEffect(() => {
const fetchScenarios = async () => {
setLoading(true);
setError("");
try {
const response = await fetchScenes({ page: 1, limit: 20 });
if (response && response.code === 200 && response.data) {
// 转换API数据为前端需要的格式
const transformedScenarios: Scenario[] = response.data.map(
(item: SceneItem) => ({
id: item.id.toString(),
name: item.name,
image:
item.image ||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-api.png",
description:
scenarioDescriptions[item.name.toLowerCase()] ||
"通过该平台进行获客",
count: Math.floor(Math.random() * 200) + 50, // 模拟今日数据
growth: `+${Math.floor(Math.random() * 20) + 5}%`, // 模拟增长率
status: item.status === 1 ? "active" : "inactive",
})
);
setScenarios(transformedScenarios);
}
} catch (error) {
console.error("获取场景数据失败:", error);
setError("获取场景数据失败,请稍后重试");
} finally {
setLoading(false);
}
};
fetchScenarios();
}, [scenarioDescriptions]);
const handleScenarioClick = (scenarioId: string, scenarioName: string) => {
navigate(
`/scenarios/list/${scenarioId}/${encodeURIComponent(scenarioName)}`
);
};
const handleNewPlan = () => {
navigate("/scenarios/new");
};
if (loading) {
return (
<Layout
header={<UnifiedHeader title="场景获客" showBack={false} />}
footer={<BottomNav />}
>
<div className="bg-gray-50 min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
<p className="text-gray-500">...</p>
</div>
</div>
</Layout>
);
}
if (error && scenarios.length === 0) {
return (
<Layout
header={<UnifiedHeader title="场景获客" showBack={false} />}
footer={<BottomNav />}
>
<div className="bg-gray-50 min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-red-500 mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
</button>
</div>
</div>
</Layout>
);
}
return (
<Layout
header={
<UnifiedHeader
title="场景获客"
showBack={false}
titleColor="blue"
actions={[
{
type: "button",
icon: Plus,
label: "新建计划",
size: "sm",
onClick: handleNewPlan,
},
]}
/>
}
footer={<BottomNav />}
>
<div className="bg-gray-50 min-h-screen pb-20">
<div className="p-4">
{error && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<p className="text-yellow-800 text-sm">{error}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
{scenarios.map((scenario) => (
<div
key={scenario.id}
className="bg-white rounded-lg shadow overflow-hidden hover:shadow-md transition-shadow cursor-pointer"
onClick={() => handleScenarioClick(scenario.id, scenario.name)}
>
<div className="p-4 flex flex-col items-center">
<div className="w-12 h-12 bg-gray-200 rounded-full flex items-center justify-center mb-2">
<img
src={scenario.image}
alt={scenario.name}
className="w-8 h-8"
onError={(e) => {
// 图片加载失败时使用默认图标
e.currentTarget.src =
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-api.png";
}}
/>
</div>
<h3 className="text-blue-600 font-medium text-center">
{scenario.name}
</h3>
{scenario.description && (
<p className="text-xs text-gray-500 text-center mt-1 line-clamp-2">
{scenario.description}
</p>
)}
<div className="flex items-center mt-2 text-gray-500 text-sm">
<span>: </span>
<span className="font-medium ml-1">{scenario.count}</span>
</div>
<div className="flex items-center mt-1 text-green-500 text-xs">
<TrendingUp className="h-3 w-3 mr-1" />
<span>{scenario.growth}</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</Layout>
);
}

View File

@@ -1,242 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ChevronLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Toast } from "tdesign-mobile-react";
import { Steps, StepItem } from "tdesign-mobile-react";
import { BasicSettings } from "./steps/BasicSettings";
import { FriendRequestSettings } from "./steps/FriendRequestSettings";
import { MessageSettings } from "./steps/MessageSettings";
import Layout from "@/components/Layout";
import {
getPlanScenes,
createScenarioPlan,
fetchPlanDetail,
PlanDetail,
updateScenarioPlan,
} from "@/api/scenarios";
// 步骤定义 - 只保留三个步骤
const steps = [
{ id: 1, title: "步骤一", subtitle: "基础设置" },
{ id: 2, title: "步骤二", subtitle: "好友申请设置" },
{ id: 3, title: "步骤三", subtitle: "消息设置" },
];
// 类型定义
interface FormData {
name: string;
scenario: number;
posters: any[]; // 后续可替换为具体Poster类型
device: string[];
remarkType: string;
greeting: string;
addInterval: number;
startTime: string;
endTime: string;
enabled: boolean;
sceneId: string | number;
remarkFormat: string;
addFriendInterval: number;
}
export default function NewPlan() {
const router = useNavigate();
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState<FormData>({
name: "",
scenario: 1,
posters: [],
device: [],
remarkType: "phone",
greeting: "你好,请通过",
addInterval: 1,
startTime: "09:00",
endTime: "18:00",
enabled: true,
sceneId: "",
remarkFormat: "",
addFriendInterval: 1,
});
const [sceneList, setSceneList] = useState<any[]>([]);
const [sceneLoading, setSceneLoading] = useState(true);
const { scenarioId, planId } = useParams<{
scenarioId: string;
planId: string;
}>();
const [isEdit, setIsEdit] = useState(false);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setSceneLoading(true);
//获取场景类型
getPlanScenes()
.then((res) => {
setSceneList(res?.data || []);
})
.finally(() => setSceneLoading(false));
if (planId) {
setIsEdit(true);
//获取计划详情
const res = await fetchPlanDetail(planId);
if (res.code === 200 && res.data) {
const detail = res.data as PlanDetail;
setFormData((prev) => ({
...prev,
name: detail.name ?? "",
scenario: Number(detail.scenario) || 1,
posters: detail.posters ?? [],
device: detail.device ?? [],
remarkType: detail.remarkType ?? "phone",
greeting: detail.greeting ?? "",
addInterval: detail.addInterval ?? 1,
startTime: detail.startTime ?? "09:00",
endTime: detail.endTime ?? "18:00",
enabled: detail.enabled ?? true,
sceneId: Number(detail.scenario) || 1,
remarkFormat: detail.remarkFormat ?? "",
addFriendInterval: detail.addFriendInterval ?? 1,
}));
}
} else {
if (scenarioId) {
setFormData((prev) => ({
...prev,
...{ scenario: Number(scenarioId) || 1 },
}));
}
}
};
// 更新表单数据
const onChange = (data: any) => {
setFormData((prev) => ({ ...prev, ...data }));
};
// 处理保存
const handleSave = async () => {
try {
let result;
if (isEdit && planId) {
// 编辑:拼接后端需要的完整参数
const editData = {
...formData,
id: Number(planId),
planId: Number(planId),
// 兼容后端需要的字段
// 你可以根据实际需要补充其它字段
};
result = await updateScenarioPlan(planId, editData);
} else {
// 新建
result = await createScenarioPlan(formData);
}
if (result.code === 200) {
Toast({
message: isEdit ? "计划已更新" : "获客计划已创建",
theme: "success",
});
const sceneItem = sceneList.find((v) => formData.scenario === v.id);
router(`/scenarios/list/${formData.sceneId}/${sceneItem.name}`);
} else {
Toast({ message: result.msg, theme: "error" });
}
} catch (error) {
Toast({
message:
error instanceof Error
? error.message
: typeof error === "string"
? error
: isEdit
? "更新计划失败,请重试"
: "创建计划失败,请重试",
theme: "error",
});
}
};
// 下一步
const handleNext = () => {
if (currentStep === steps.length) {
handleSave();
} else {
setCurrentStep((prev) => prev + 1);
}
};
// 上一步
const handlePrev = () => {
setCurrentStep((prev) => Math.max(prev - 1, 1));
};
// 渲染当前步骤内容
const renderStepContent = () => {
switch (currentStep) {
case 1:
return (
<BasicSettings
formData={formData}
onChange={onChange}
onNext={handleNext}
sceneList={sceneList}
sceneLoading={sceneLoading}
/>
);
case 2:
return (
<FriendRequestSettings
formData={formData}
onChange={onChange}
onNext={handleNext}
onPrev={handlePrev}
/>
);
case 3:
return (
<MessageSettings
formData={formData}
onChange={onChange}
onNext={handleSave}
onPrev={handlePrev}
/>
);
default:
return null;
}
};
return (
<Layout
header={
<>
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between h-14 px-4">
<div className="flex items-center">
<Button variant="ghost" size="icon" onClick={() => router(-1)}>
<ChevronLeft className="h-5 w-5" />
</Button>
</div>
</div>
</header>
<div className="px-4 py-6">
<Steps current={currentStep - 1}>
{steps.map((step) => (
<StepItem
key={step.id}
title={step.title}
content={step.subtitle}
/>
))}
</Steps>
</div>
</>
}
>
<div className="p-4">{renderStepContent()}</div>
</Layout>
);
}

View File

@@ -1,829 +0,0 @@
import type React from "react";
import { useState, useEffect, useRef } from "react";
import {
Button,
Input,
Tag,
Grid,
ImageViewer,
Switch,
} from "tdesign-mobile-react";
import EyeIcon from "@/components/icons/EyeIcon";
import { uploadFile } from "@/api/utils";
interface BasicSettingsProps {
formData: any;
onChange: (data: any) => void;
onNext?: () => void;
sceneList: any[];
sceneLoading: boolean;
}
interface Account {
id: string;
nickname: string;
avatar: string;
}
interface Material {
id: string;
name: string;
type: string;
preview: string;
}
const posterTemplates = [
{
id: "poster-1",
name: "点击领取",
preview:
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E9%A2%86%E5%8F%961-tipd1HI7da6qooY5NkhxQnXBnT5LGU.gif",
},
{
id: "poster-2",
name: "点击合作",
preview:
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%90%88%E4%BD%9C-LPlMdgxtvhqCSr4IM1bZFEFDBF3ztI.gif",
},
{
id: "poster-3",
name: "点击咨询",
preview:
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%92%A8%E8%AF%A2-FTiyAMAPop2g9LvjLOLDz0VwPg3KVu.gif",
},
{
id: "poster-4",
name: "点击签到",
preview:
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E7%AD%BE%E5%88%B0-94TZIkjLldb4P2jTVlI6MkSDg0NbXi.gif",
},
{
id: "poster-5",
name: "点击了解",
preview:
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E4%BA%86%E8%A7%A3-6GCl7mQVdO4WIiykJyweSubLsTwj71.gif",
},
{
id: "poster-6",
name: "点击报名",
preview:
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E6%8A%A5%E5%90%8D-Mj0nnva0BiASeDAIhNNaRRAbjPgjEj.gif",
},
];
const generateRandomAccounts = (count: number): Account[] => {
return Array.from({ length: count }, (_, index) => ({
id: `account-${index + 1}`,
nickname: `账号-${Math.random().toString(36).substring(2, 7)}`,
avatar: `/placeholder.svg?height=40&width=40&text=${index + 1}`,
}));
};
const generatePosterMaterials = (): Material[] => {
return posterTemplates.map((template) => ({
id: template.id,
name: template.name,
type: "poster",
preview: template.preview,
}));
};
export function BasicSettings({
formData,
onChange,
onNext,
sceneList,
sceneLoading,
}: BasicSettingsProps) {
const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false);
const [isMaterialDialogOpen, setIsMaterialDialogOpen] = useState(false);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isPhoneSettingsOpen, setIsPhoneSettingsOpen] = useState(false);
const [accounts] = useState<Account[]>(generateRandomAccounts(50));
const [materials] = useState<Material[]>(generatePosterMaterials());
const [selectedAccounts, setSelectedAccounts] = useState<Account[]>(
formData.accounts?.length > 0 ? formData.accounts : []
);
const [selectedMaterials, setSelectedMaterials] = useState<Material[]>(
formData.materials?.length > 0 ? formData.materials : []
);
// showAllScenarios 默认为 true
const [showAllScenarios, setShowAllScenarios] = useState(true);
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
const [importedTags, setImportedTags] = useState<
Array<{
phone: string;
wechat: string;
source?: string;
orderAmount?: number;
orderDate?: string;
}>
>(formData.importedTags || []);
// 自定义标签相关状态
const [customTagInput, setCustomTagInput] = useState("");
const [customTags, setCustomTags] = useState(formData.customTags || []);
const [selectedScenarioTags, setSelectedScenarioTags] = useState(
formData.scenarioTags || []
);
// 电话获客相关状态
const [phoneSettings, setPhoneSettings] = useState({
autoAdd: formData.phoneSettings?.autoAdd ?? true,
speechToText: formData.phoneSettings?.speechToText ?? true,
questionExtraction: formData.phoneSettings?.questionExtraction ?? true,
});
// 群设置相关状态
const [weixinqunName, setWeixinqunName] = useState(
formData.weixinqunName || ""
);
const [weixinqunNotice, setWeixinqunNotice] = useState(
formData.weixinqunNotice || ""
);
// 新增:自定义海报相关状态
const [customPosters, setCustomPosters] = useState<Material[]>([]);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
// 新增用于文件选择的ref
const uploadInputRef = useRef<HTMLInputElement>(null);
const uploadOrderInputRef = useRef<HTMLInputElement>(null);
// 更新电话获客设置
const handlePhoneSettingsUpdate = () => {
onChange({ ...formData, phoneSettings });
setIsPhoneSettingsOpen(false);
};
// 处理标签选择
const handleTagToggle = (tagId: string) => {
const newTags = selectedScenarioTags.includes(tagId)
? selectedScenarioTags.filter((id: string) => id !== tagId)
: [...selectedScenarioTags, tagId];
setSelectedScenarioTags(newTags);
onChange({ ...formData, scenarioTags: newTags });
};
// 处理通话类型选择
const handleCallTypeChange = (type: string) => {
// setPhoneCallType(type) // This line was removed as per the edit hint.
onChange({ ...formData, phoneCallType: type });
};
// 初始化时,如果没有选择场景,默认选择海报获客
useEffect(() => {
if (!formData.scenario) {
onChange({ ...formData, scenario: "haibao" });
}
// 检查是否已经有上传的订单文件
if (formData.orderFileUploaded) {
setOrderUploaded(true);
}
}, [formData, onChange]);
useEffect(() => {
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "");
const sceneItem = sceneList.find((v) => formData.scenario === v.id);
onChange({ ...formData, name: `${sceneItem?.name || "海报"}${today}` });
}, [sceneList]);
// 选中场景
const handleScenarioSelect = (sceneId: number) => {
onChange({ ...formData, scenario: sceneId });
};
// 选中/取消标签
const handleScenarioTagToggle = (tag: string) => {
const newTags = selectedScenarioTags.includes(tag)
? selectedScenarioTags.filter((t: string) => t !== tag)
: [...selectedScenarioTags, tag];
setSelectedScenarioTags(newTags);
onChange({ ...formData, scenarioTags: newTags });
};
// 添加自定义标签
const handleAddCustomTag = () => {
if (!customTagInput.trim()) return;
const newTag = {
id: `custom-${Date.now()}`,
name: customTagInput.trim(),
};
const updatedCustomTags = [...customTags, newTag];
setCustomTags(updatedCustomTags);
setCustomTagInput("");
onChange({ ...formData, customTags: updatedCustomTags });
};
// 删除自定义标签
const handleRemoveCustomTag = (tagId: string) => {
const updatedCustomTags = customTags.filter((tag: any) => tag.id !== tagId);
setCustomTags(updatedCustomTags);
onChange({ ...formData, customTags: updatedCustomTags });
// 同时从选中标签中移除
const updatedSelectedTags = selectedScenarioTags.filter(
(t: string) => t !== tagId
);
setSelectedScenarioTags(updatedSelectedTags);
onChange({
...formData,
scenarioTags: updatedSelectedTags,
customTags: updatedCustomTags,
});
};
// 新增:自定义上传图片
const handleCustomPosterUpload = (urls: string[]) => {
if (urls && urls.length > 0) {
const newPoster: Material = {
id: `custom-${Date.now()}`,
name: "自定义海报",
type: "poster",
preview: urls[0],
};
setCustomPosters((prev) => [...prev, newPoster]);
}
};
// 新增:删除自定义海报
const handleRemoveCustomPoster = (id: string) => {
setCustomPosters((prev) => prev.filter((p) => p.id !== id));
// 如果选中则取消选中
if (selectedMaterials.some((m) => m.id === id)) {
setSelectedMaterials([]);
onChange({ ...formData, materials: [] });
}
};
// 修改:选中/取消选中海报
const handleMaterialSelect = (material: Material) => {
const isSelected = selectedMaterials.some((m) => m.id === material.id);
if (isSelected) {
setSelectedMaterials([]);
onChange({ ...formData, materials: [] });
} else {
setSelectedMaterials([material]);
onChange({ ...formData, materials: [material] });
}
};
// 移除已选素材
const handleRemoveMaterial = (id: string) => {
setSelectedMaterials([]);
onChange({ ...formData, materials: [] });
};
// 新增:全屏预览
const handlePreviewImage = (url: string) => {
setPreviewUrl(url);
setIsPreviewOpen(true);
};
// 账号多选切换
const handleAccountToggle = (account: Account) => {
const isSelected = selectedAccounts.some(
(a: Account) => a.id === account.id
);
let newSelected;
if (isSelected) {
newSelected = selectedAccounts.filter(
(a: Account) => a.id !== account.id
);
} else {
newSelected = [...selectedAccounts, account];
}
setSelectedAccounts(newSelected);
onChange({ ...formData, accounts: newSelected });
};
// 移除已选账号
const handleRemoveAccount = (id: string) => {
const newSelected = selectedAccounts.filter((a: Account) => a.id !== id);
setSelectedAccounts(newSelected);
onChange({ ...formData, accounts: newSelected });
};
// 处理文件导入
const handleFileImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target?.result as string;
const rows = content.split("\n").filter((row) => row.trim());
const tags = rows.slice(1).map((row) => {
const [phone, wechat, source, orderAmount, orderDate] =
row.split(",");
return {
phone: phone?.trim(),
wechat: wechat?.trim(),
source: source?.trim(),
orderAmount: orderAmount ? Number(orderAmount) : undefined,
orderDate: orderDate?.trim(),
};
});
setImportedTags(tags);
onChange({ ...formData, importedTags: tags });
} catch (error) {
// 可用 toast 提示
}
};
reader.readAsText(file);
}
};
// 下载模板
const handleDownloadTemplate = () => {
const template =
"电话号码,微信号,来源,订单金额,下单日期\n13800138000,wxid_123,抖音,99.00,2024-03-03";
const blob = new Blob([template], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "订单导入模板.csv";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
};
// 修改订单表格上传逻辑,使用 uploadFile 公共方法
const [orderUploaded, setOrderUploaded] = useState(false);
const handleOrderFileUpload = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0];
if (file) {
try {
await uploadFile(file); // 默认接口即可
setOrderUploaded(true);
onChange({ ...formData, orderFileUploaded: true });
// 可用 toast 或其它方式提示成功
// alert('上传成功');
} catch (err) {
// 可用 toast 或其它方式提示失败
// alert('上传失败');
}
event.target.value = "";
}
};
// 账号弹窗关闭时清理搜索等状态
const handleAccountDialogClose = () => {
setIsAccountDialogOpen(false);
// 可在此清理账号搜索等临时状态
};
// 素材弹窗关闭时清理搜索等状态
const handleMaterialDialogClose = () => {
setIsMaterialDialogOpen(false);
// 可在此清理素材搜索等临时状态
};
// 订单导入弹窗关闭时清理文件输入等状态
const handleImportDialogClose = () => {
setIsImportDialogOpen(false);
// 可在此清理文件输入等临时状态
};
// 电话获客弹窗关闭
const handlePhoneSettingsDialogClose = () => {
setIsPhoneSettingsOpen(false);
};
// 图片预览关闭
const handleImagePreviewClose = () => {
setIsPreviewOpen(false);
};
// 当前选中的场景对象
const currentScene = sceneList.find((s) => s.id === formData.scenario);
//打开订单
const openOrder =
formData.scenario !== 2 ? { display: "none" } : { display: "block" };
const openPoster =
formData.scenario !== 1 ? { display: "none" } : { display: "block" };
return (
<div>
{/* 场景选择区块 */}
{sceneLoading ? (
<div style={{ padding: 16, textAlign: "center" }}>...</div>
) : (
<Grid gutter={20} column={3}>
{sceneList.map((scene) => (
<Button
key={scene.id}
theme={formData.scenario === scene.id ? "primary" : "light"}
onClick={() => handleScenarioSelect(scene.id)}
size="small"
>
{scene.name.replace("获客", "")}
</Button>
))}
</Grid>
)}
{/* 计划名称输入区 */}
<div className="mb-4"></div>
<div className="border p-2 mb-4">
<input
className="w-full"
type="text"
value={formData.name}
onChange={(e) =>
onChange({ ...formData, name: String(e.target.value) })
}
placeholder="请输入计划名称"
/>
</div>
<div className="mb-4"></div>
{/* 标签选择区块 */}
{formData.scenario && (
<div className="flex pb-4" style={{ flexWrap: "wrap", gap: 8 }}>
{(currentScene?.scenarioTags || []).map((tag: string) => (
<Tag
key={tag}
shape="round"
theme={selectedScenarioTags.includes(tag) ? "primary" : "default"}
onClick={() => handleScenarioTagToggle(tag)}
style={{ marginBottom: 4 }}
size="large"
variant="light"
>
{tag}
</Tag>
))}
{/* 自定义标签 */}
{customTags.map((tag: any) => (
<Tag
key={tag.id}
shape="round"
theme={
selectedScenarioTags.includes(tag.id) ? "primary" : "default"
}
onClick={() => handleScenarioTagToggle(tag.id)}
style={{ marginBottom: 4 }}
closable
size="large"
variant="light"
onClose={() => handleRemoveCustomTag(tag.id)}
>
{tag.name}
</Tag>
))}
</div>
)}
{/* 自定义标签输入区 */}
<div className="flex p" style={{ gap: 8 }}>
<div className="border p-2 flex-1">
<input
type="text"
value={customTagInput}
onChange={(e) => setCustomTagInput(e.target.value)}
placeholder="添加自定义标签"
className="w-full"
/>
</div>
<div className="pt-1">
<Button theme="primary" size="small" onClick={handleAddCustomTag}>
</Button>
</div>
</div>
{/* 选素材 */}
<div className="my-4" style={openPoster}>
<div className="mb-4"></div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 12,
}}
>
{[...materials, ...customPosters].map((material) => {
const isSelected = selectedMaterials.some(
(m) => m.id === material.id
);
const isCustom = material.id.startsWith("custom-");
return (
<div
key={material.id}
style={{
border: isSelected ? "2px solid #1890ff" : "2px solid #eee",
borderRadius: 8,
padding: 6,
cursor: "pointer",
background: isSelected ? "#e6f7ff" : "#fff",
transition: "border 0.2s",
textAlign: "center",
position: "relative",
height: 180 + 12, // 图片高度180+上下padding
overflow: "hidden",
minHeight: 192,
}}
onClick={() => handleMaterialSelect(material)}
>
{/* 预览按钮:自定义海报在左上,内置海报在右上 */}
<button
type="button"
style={{
position: "absolute",
top: 8,
left: isCustom ? 8 : "auto",
right: isCustom ? "auto" : 8,
background: "rgba(0,0,0,0.5)",
border: "none",
borderRadius: "50%",
padding: 2,
zIndex: 2,
cursor: "pointer",
}}
onClick={(e) => {
e.stopPropagation();
handlePreviewImage(material.preview);
}}
>
<EyeIcon style={{ color: "#fff", width: 18, height: 18 }} />
</button>
{/* 删除自定义海报按钮 */}
{isCustom && (
<div
style={{
position: "absolute",
top: 8,
right: 8,
width: 28,
height: 28,
background: "rgba(0,0,0,0.5)",
border: "none",
borderRadius: "50%",
zIndex: 2,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
lineHeight: 20,
color: "#ffffff",
}}
onClick={(e) => {
e.stopPropagation();
handleRemoveCustomPoster(material.id);
}}
>
×
</div>
)}
<img
src={material.preview}
alt={material.name}
style={{
width: 100,
height: 180,
objectFit: "cover",
borderRadius: 4,
marginBottom: 0,
display: "block",
}}
/>
<div
style={{
position: "absolute",
left: 0,
bottom: 0,
width: "100%",
background: "rgba(0,0,0,0.5)",
color: "#fff",
fontSize: 14,
padding: "4px 0",
borderBottomLeftRadius: 4,
borderBottomRightRadius: 4,
textAlign: "center",
zIndex: 3,
}}
>
{material.name}
</div>
</div>
);
})}
{/* 添加海报卡片 */}
<div
style={{
border: "2px dashed #bbb",
borderRadius: 8,
padding: 6,
cursor: "pointer",
background: "#fafbfc",
textAlign: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: 190,
}}
onClick={() => uploadInputRef.current?.click()}
>
<span style={{ fontSize: 36, color: "#bbb", marginBottom: 8 }}>
+
</span>
<span style={{ color: "#888" }}></span>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
// 直接上传
try {
const url = await (
await import("@/api/upload")
).uploadImage(file);
const newPoster = {
id: `custom-${Date.now()}`,
name: "自定义海报",
type: "poster",
preview: url,
};
setCustomPosters((prev) => [...prev, newPoster]);
} catch (err) {
// 可加toast提示
}
e.target.value = "";
}
}}
/>
</div>
</div>
{/* 全屏图片预览 */}
<ImageViewer
images={previewUrl ? [previewUrl] : []}
visible={isPreviewOpen}
onClose={() => {
setIsPreviewOpen(false);
setPreviewUrl(null);
}}
index={0}
/>
</div>
{/* 订单导入区块优化 */}
<div style={openOrder} className="my-4">
<div style={{ fontWeight: 500, marginBottom: 8 }}></div>
<div style={{ display: "flex", gap: 12, marginBottom: 4 }}>
<Button
type="button"
style={{ display: "flex", alignItems: "center", gap: 4 }}
theme="default"
onClick={handleDownloadTemplate}
>
<span className="iconfont" style={{ fontSize: 18 }}>
</span>{" "}
</Button>
<Button
type="button"
style={{
display: "flex",
alignItems: "center",
gap: 4,
...(orderUploaded && {
backgroundColor: "#52c41a",
color: "#fff",
borderColor: "#52c41a",
}),
}}
theme="default"
onClick={() => uploadOrderInputRef.current?.click()}
>
<span className="iconfont" style={{ fontSize: 18 }}>
{orderUploaded ? "✓" : "↑"}
</span>{" "}
{orderUploaded ? "已上传" : "上传订单表格"}
<input
ref={uploadOrderInputRef}
type="file"
accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
style={{ display: "none" }}
onChange={handleOrderFileUpload}
/>
</Button>
</div>
<div style={{ color: "#888", fontSize: 13, marginBottom: 8 }}>
CSVExcel
</div>
</div>
{/* 电话获客设置区块,仅在选择电话获客场景时显示 */}
{formData.scenario === 5 && (
<div style={{ margin: "16px 0" }}>
<div
style={{
background: "#f7f8fa",
borderRadius: 10,
padding: 20,
boxShadow: "0 2px 8px rgba(0,0,0,0.03)",
marginBottom: 12,
}}
>
<div style={{ fontWeight: 600, fontSize: 16, marginBottom: 16 }}>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span></span>
<Switch
value={phoneSettings.autoAdd}
onChange={(v) =>
setPhoneSettings((s) => ({ ...s, autoAdd: v }))
}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span></span>
<Switch
value={phoneSettings.speechToText}
onChange={(v) =>
setPhoneSettings((s) => ({ ...s, speechToText: v }))
}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span></span>
<Switch
value={phoneSettings.questionExtraction}
onChange={(v) =>
setPhoneSettings((s) => ({ ...s, questionExtraction: v }))
}
/>
</div>
</div>
</div>
</div>
)}
{/* 微信群设置区块,仅在选择微信群场景时显示 */}
{formData.scenario === 7 && (
<div style={{ margin: "16px 0" }}>
<div style={{ marginBottom: 8 }}>
<Input
value={weixinqunName}
onChange={setWeixinqunName}
placeholder="微信群名称"
maxlength={20}
onBlur={() => onChange({ ...formData, weixinqunName })}
/>
</div>
<div>
<Input
value={weixinqunNotice}
onChange={setWeixinqunNotice}
placeholder="群公告/欢迎语"
maxlength={50}
onBlur={() => onChange({ ...formData, weixinqunNotice })}
/>
</div>
</div>
)}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
margin: "16px 0",
}}
>
<span></span>
<Switch
value={formData.enabled}
onChange={(value) => onChange({ ...formData, enabled: value })}
/>
</div>
<Button className="mt-4" block theme="primary" onClick={onNext}>
</Button>
</div>
);
}

View File

@@ -1,269 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { HelpCircle, MessageSquare, AlertCircle } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ChevronsUpDown } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import DeviceSelection from "@/components/DeviceSelection";
interface FriendRequestSettingsProps {
formData: any;
onChange: (data: any) => void;
onNext: () => void;
onPrev: () => void;
}
// 招呼语模板
const greetingTemplates = [
"你好,请通过",
"你好,了解XX,请通过",
"你好我是XX产品的客服请通过",
"你好,感谢关注我们的产品",
"你好,很高兴为您服务",
];
// 备注类型选项
const remarkTypes = [
{ value: "phone", label: "手机号" },
{ value: "nickname", label: "昵称" },
{ value: "source", label: "来源" },
];
export function FriendRequestSettings({
formData,
onChange,
onNext,
onPrev,
}: FriendRequestSettingsProps) {
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false);
const [hasWarnings, setHasWarnings] = useState(false);
const [selectedDevices, setSelectedDevices] = useState<any[]>(
formData.selectedDevices || []
);
const [showRemarkTip, setShowRemarkTip] = useState(false);
// 获取场景标题
const getScenarioTitle = () => {
switch (formData.scenario) {
case "douyin":
return "抖音直播";
case "xiaohongshu":
return "小红书";
case "weixinqun":
return "微信群";
case "gongzhonghao":
return "公众号";
default:
return formData.name || "获客计划";
}
};
// 使用useEffect设置默认值
useEffect(() => {
if (!formData.greeting) {
onChange({
...formData,
greeting: "你好,请通过",
remarkType: "phone", // 默认选择手机号
remarkFormat: `手机号+${getScenarioTitle()}`, // 默认备注格式
addFriendInterval: 1,
});
}
}, [formData, formData.greeting, onChange]);
// 检查是否有未完成的必填项
useEffect(() => {
const hasIncompleteFields = !formData.greeting?.trim();
setHasWarnings(hasIncompleteFields);
}, [formData]);
const handleTemplateSelect = (template: string) => {
onChange({ ...formData, greeting: template });
setIsTemplateDialogOpen(false);
};
const handleNext = () => {
// 即使有警告也允许进入下一步,但会显示提示
onNext();
};
return (
<>
<div className="space-y-6">
<div>
<span className="font-medium text-base"></span>
<div className="mt-2">
<DeviceSelection
selectedDevices={selectedDevices.map((d) => d.id)}
onSelect={(deviceIds) => {
const newSelectedDevices = deviceIds.map((id) => ({
id,
name: `设备 ${id}`,
status: "online",
}));
setSelectedDevices(newSelectedDevices);
onChange({ ...formData, device: deviceIds });
}}
placeholder="选择设备"
/>
</div>
</div>
<div className="mb-4">
<div className="flex items-center space-x-2 mb-1 relative">
<span className="font-medium text-base"></span>
<span
className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-gray-200 text-gray-500 text-xs cursor-pointer hover:bg-gray-300 transition-colors"
onMouseEnter={() => setShowRemarkTip(true)}
onMouseLeave={() => setShowRemarkTip(false)}
onClick={() => setShowRemarkTip((v) => !v)}
>
?
</span>
{showRemarkTip && (
<div className="absolute left-24 top-0 z-20 w-64 p-3 bg-white border border-gray-200 rounded shadow-lg text-sm text-gray-700">
<div></div>
<div className="mt-2 text-xs text-gray-500"></div>
<div className="mt-1 text-blue-600">
{formData.remarkType === "phone" &&
`138****1234+${getScenarioTitle()}`}
{formData.remarkType === "nickname" &&
`小红书用户2851+${getScenarioTitle()}`}
{formData.remarkType === "source" &&
`抖音直播+${getScenarioTitle()}`}
</div>
</div>
)}
</div>
<select
value={formData.remarkType || "phone"}
onChange={(e) =>
onChange({ ...formData, remarkType: e.target.value })
}
className="w-full mt-2 p-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{remarkTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center justify-between">
<span className="font-medium text-base"></span>
<Button
variant="ghost"
size="sm"
onClick={() => setIsTemplateDialogOpen(true)}
className="text-blue-500"
>
<MessageSquare className="h-4 w-4 mr-2" />
</Button>
</div>
<Input
value={formData.greeting}
onChange={(e) =>
onChange({ ...formData, greeting: e.target.value })
}
placeholder="请输入招呼语"
className="mt-2"
/>
</div>
<div>
<span className="font-medium text-base"></span>
<div className="flex items-center space-x-2 mt-2">
<Input
type="number"
value={formData.addFriendInterval || 1}
onChange={(e) =>
onChange({
...formData,
addFriendInterval: Number(e.target.value),
})
}
/>
<div className="w-10"></div>
</div>
</div>
<div>
<span className="font-medium text-base"></span>
<div className="flex items-center space-x-2 mt-2">
<Input
type="time"
value={formData.addFriendTimeStart || "09:00"}
onChange={(e) =>
onChange({ ...formData, addFriendTimeStart: e.target.value })
}
className="w-32"
/>
<span></span>
<Input
type="time"
value={formData.addFriendTimeEnd || "18:00"}
onChange={(e) =>
onChange({ ...formData, addFriendTimeEnd: e.target.value })
}
className="w-32"
/>
</div>
</div>
{hasWarnings && (
<Alert className="bg-amber-50 border-amber-200">
<AlertCircle className="h-4 w-4 text-amber-500" />
<AlertDescription>
</AlertDescription>
</Alert>
)}
<div className="flex justify-between pt-4">
<Button variant="outline" onClick={onPrev}>
</Button>
<Button onClick={handleNext}></Button>
</div>
</div>
<Dialog
open={isTemplateDialogOpen}
onOpenChange={setIsTemplateDialogOpen}
>
<DialogContent className="bg-white">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-2">
{greetingTemplates.map((template, index) => (
<Button
key={index}
variant="outline"
className="w-full justify-start h-auto py-3 px-4"
onClick={() => handleTemplateSelect(template)}
>
{template}
</Button>
))}
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,698 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
MessageSquare,
ImageIcon,
Video,
FileText,
Link2,
Users,
AppWindowIcon as Window,
Plus,
X,
Upload,
Clock,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toast } from "@/components/ui/use-toast";
interface MessageContent {
id: string;
type: "text" | "image" | "video" | "file" | "miniprogram" | "link" | "group";
content: string;
sendInterval?: number;
intervalUnit?: "seconds" | "minutes";
scheduledTime?: {
hour: number;
minute: number;
second: number;
};
title?: string;
description?: string;
address?: string;
coverImage?: string;
groupId?: string;
linkUrl?: string;
}
interface DayPlan {
day: number;
messages: MessageContent[];
}
interface MessageSettingsProps {
formData: any;
onChange: (data: any) => void;
onNext: () => void;
onPrev: () => void;
}
// 消息类型配置
const messageTypes = [
{ id: "text", icon: MessageSquare, label: "文本" },
{ id: "image", icon: ImageIcon, label: "图片" },
{ id: "video", icon: Video, label: "视频" },
{ id: "file", icon: FileText, label: "文件" },
{ id: "miniprogram", icon: Window, label: "小程序" },
{ id: "link", icon: Link2, label: "链接" },
{ id: "group", icon: Users, label: "邀请入群" },
];
// 模拟群组数据
const mockGroups = [
{ id: "1", name: "产品交流群1", memberCount: 156 },
{ id: "2", name: "产品交流群2", memberCount: 234 },
{ id: "3", name: "产品交流群3", memberCount: 89 },
];
export function MessageSettings({
formData,
onChange,
onNext,
onPrev,
}: MessageSettingsProps) {
const [dayPlans, setDayPlans] = useState<DayPlan[]>([
{
day: 0,
messages: [
{
id: "1",
type: "text",
content: "",
sendInterval: 5,
intervalUnit: "seconds", // 默认改为秒
},
],
},
]);
const [isAddDayPlanOpen, setIsAddDayPlanOpen] = useState(false);
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false);
const [selectedGroupId, setSelectedGroupId] = useState("");
// 添加新消息
const handleAddMessage = (dayIndex: number, type = "text") => {
const updatedPlans = [...dayPlans];
const newMessage: MessageContent = {
id: Date.now().toString(),
type: type as MessageContent["type"],
content: "",
};
if (dayPlans[dayIndex].day === 0) {
// 即时消息使用间隔设置
newMessage.sendInterval = 5;
newMessage.intervalUnit = "seconds"; // 默认改为秒
} else {
// 非即时消息使用具体时间设置
newMessage.scheduledTime = {
hour: 9,
minute: 0,
second: 0,
};
}
updatedPlans[dayIndex].messages.push(newMessage);
setDayPlans(updatedPlans);
onChange({ ...formData, messagePlans: updatedPlans });
};
// 更新消息内容
const handleUpdateMessage = (
dayIndex: number,
messageIndex: number,
updates: Partial<MessageContent>
) => {
const updatedPlans = [...dayPlans];
updatedPlans[dayIndex].messages[messageIndex] = {
...updatedPlans[dayIndex].messages[messageIndex],
...updates,
};
setDayPlans(updatedPlans);
onChange({ ...formData, messagePlans: updatedPlans });
};
// 删除消息
const handleRemoveMessage = (dayIndex: number, messageIndex: number) => {
const updatedPlans = [...dayPlans];
updatedPlans[dayIndex].messages.splice(messageIndex, 1);
setDayPlans(updatedPlans);
onChange({ ...formData, messagePlans: updatedPlans });
};
// 切换时间单位
const toggleIntervalUnit = (dayIndex: number, messageIndex: number) => {
const message = dayPlans[dayIndex].messages[messageIndex];
const newUnit = message.intervalUnit === "minutes" ? "seconds" : "minutes";
handleUpdateMessage(dayIndex, messageIndex, { intervalUnit: newUnit });
};
// 添加新的天数计划
const handleAddDayPlan = () => {
const newDay = dayPlans.length;
setDayPlans([
...dayPlans,
{
day: newDay,
messages: [
{
id: Date.now().toString(),
type: "text",
content: "",
scheduledTime: {
hour: 9,
minute: 0,
second: 0,
},
},
],
},
]);
setIsAddDayPlanOpen(false);
toast({
title: "添加成功",
description: `已添加第${newDay}天的消息计划`,
});
};
// 选择群组
const handleSelectGroup = (groupId: string) => {
setSelectedGroupId(groupId);
setIsGroupSelectOpen(false);
toast({
title: "选择成功",
description: `已选择群组:${
mockGroups.find((g) => g.id === groupId)?.name
}`,
});
};
// 处理文件上传
const handleFileUpload = (
dayIndex: number,
messageIndex: number,
type: "image" | "video" | "file"
) => {
// 模拟文件上传
toast({
title: "上传成功",
description: `${
type === "image" ? "图片" : type === "video" ? "视频" : "文件"
}上传成功`,
});
};
return (
<>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold"></h2>
<Button
variant="outline"
size="icon"
onClick={() => setIsAddDayPlanOpen(true)}
>
<Plus className="h-4 w-4" />
</Button>
</div>
<Tabs defaultValue="0" className="w-full">
<TabsList className="w-full bg-gray-50">
{dayPlans.map((plan) => (
<TabsTrigger
key={plan.day}
value={plan.day.toString()}
className="flex-1"
>
{plan.day === 0 ? "即时消息" : `${plan.day}`}
</TabsTrigger>
))}
</TabsList>
{dayPlans.map((plan, dayIndex) => (
<TabsContent key={plan.day} value={plan.day.toString()}>
<div className="space-y-4">
{plan.messages.map((message, messageIndex) => (
<div
key={message.id}
className="space-y-4 p-4 bg-gray-50 rounded-lg"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{plan.day === 0 ? (
<>
<div className="w-10"></div>
<div className="w-40">
<Input
type="number"
value={String(message.sendInterval)}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
sendInterval: Number(e.target.value),
})
}
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() =>
toggleIntervalUnit(dayIndex, messageIndex)
}
className="flex items-center space-x-1"
>
<Clock className="h-3 w-3" />
<span>
{message.intervalUnit === "minutes"
? "分钟"
: "秒"}
</span>
</Button>
</>
) : (
<>
<div className="font-medium"></div>
<div className="flex items-center space-x-1">
<Input
type="number"
min={0}
max={23}
value={String(message.scheduledTime?.hour || 0)}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
scheduledTime: {
...(message.scheduledTime || {
hour: 0,
minute: 0,
second: 0,
}),
hour: Number(e.target.value),
},
})
}
className="w-16"
/>
<span>:</span>
<Input
type="number"
min={0}
max={59}
value={String(
message.scheduledTime?.minute || 0
)}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
scheduledTime: {
...(message.scheduledTime || {
hour: 0,
minute: 0,
second: 0,
}),
minute: Number(e.target.value),
},
})
}
className="w-16"
/>
<span>:</span>
<Input
type="number"
min={0}
max={59}
value={String(
message.scheduledTime?.second || 0
)}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
scheduledTime: {
...(message.scheduledTime || {
hour: 0,
minute: 0,
second: 0,
}),
second: Number(e.target.value),
},
})
}
className="w-16"
/>
</div>
</>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRemoveMessage(dayIndex, messageIndex)
}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center space-x-2 bg-white p-2 rounded-lg">
{messageTypes.map((type) => (
<Button
key={type.id}
variant={
message.type === type.id ? "default" : "outline"
}
size="sm"
onClick={() =>
handleUpdateMessage(dayIndex, messageIndex, {
type: type.id as any,
})
}
className="flex flex-col items-center p-2 h-auto"
>
<type.icon className="h-4 w-4" />
</Button>
))}
</div>
{message.type === "text" && (
<Textarea
value={message.content}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
content: e.target.value,
})
}
placeholder="请输入消息内容"
className="min-h-[100px]"
/>
)}
{message.type === "miniprogram" && (
<div className="space-y-4">
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<Input
value={message.title}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
title: e.target.value,
})
}
placeholder="请输入小程序标题"
/>
</div>
<div className="space-y-2">
<div className="font-medium"></div>
<Input
value={message.description}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
description: e.target.value,
})
}
placeholder="请输入小程序描述"
/>
</div>
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<Input
value={message.address}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
address: e.target.value,
})
}
placeholder="请输入小程序路径"
/>
</div>
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<div className="border-2 border-dashed rounded-lg p-4 text-center">
{message.coverImage ? (
<div className="relative">
<img
src={message.coverImage || "/placeholder.svg"}
alt="封面"
className="max-w-[200px] mx-auto rounded-lg"
/>
<Button
variant="secondary"
size="sm"
className="absolute top-2 right-2"
onClick={() =>
handleUpdateMessage(
dayIndex,
messageIndex,
{ coverImage: undefined }
)
}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<Button
variant="outline"
className="w-full h-[120px]"
onClick={() =>
handleFileUpload(
dayIndex,
messageIndex,
"image"
)
}
>
<Upload className="h-4 w-4 mr-2" />
</Button>
)}
</div>
</div>
</div>
)}
{message.type === "link" && (
<div className="space-y-4">
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<Input
value={message.title}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
title: e.target.value,
})
}
placeholder="请输入链接标题"
/>
</div>
<div className="space-y-2">
<div className="font-medium"></div>
<Input
value={message.description}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
description: e.target.value,
})
}
placeholder="请输入链接描述"
/>
</div>
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<Input
value={message.linkUrl}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
linkUrl: e.target.value,
})
}
placeholder="请输入链接地址"
/>
</div>
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<div className="border-2 border-dashed rounded-lg p-4 text-center">
{message.coverImage ? (
<div className="relative">
<img
src={message.coverImage || "/placeholder.svg"}
alt="封面"
className="max-w-[200px] mx-auto rounded-lg"
/>
<Button
variant="secondary"
size="sm"
className="absolute top-2 right-2"
onClick={() =>
handleUpdateMessage(
dayIndex,
messageIndex,
{ coverImage: undefined }
)
}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<Button
variant="outline"
className="w-full h-[120px]"
onClick={() =>
handleFileUpload(
dayIndex,
messageIndex,
"image"
)
}
>
<Upload className="h-4 w-4 mr-2" />
</Button>
)}
</div>
</div>
</div>
)}
{message.type === "group" && (
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => setIsGroupSelectOpen(true)}
>
{selectedGroupId
? mockGroups.find((g) => g.id === selectedGroupId)
?.name
: "选择邀请入的群"}
</Button>
</div>
)}
{(message.type === "image" ||
message.type === "video" ||
message.type === "file") && (
<div className="border-2 border-dashed rounded-lg p-4 text-center">
<Button
variant="outline"
className="w-full h-[120px]"
onClick={() =>
handleFileUpload(
dayIndex,
messageIndex,
message.type as any
)
}
>
<Upload className="h-4 w-4 mr-2" />
{message.type === "image"
? "图片"
: message.type === "video"
? "视频"
: "文件"}
</Button>
</div>
)}
</div>
))}
<Button
variant="outline"
onClick={() => handleAddMessage(dayIndex)}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</TabsContent>
))}
</Tabs>
<div className="flex justify-between pt-4">
<Button variant="outline" onClick={onPrev}>
</Button>
<Button onClick={onNext}></Button>
</div>
</div>
{/* 添加天数计划弹窗 */}
<Dialog open={isAddDayPlanOpen} onOpenChange={setIsAddDayPlanOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-gray-500 mb-4">
</p>
<Button onClick={handleAddDayPlan} className="w-full">
{dayPlans.length}
</Button>
</div>
</DialogContent>
</Dialog>
{/* 选择群聊弹窗 */}
<Dialog open={isGroupSelectOpen} onOpenChange={setIsGroupSelectOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="py-4">
<div className="space-y-2">
{mockGroups.map((group) => (
<div
key={group.id}
className={`p-4 rounded-lg cursor-pointer hover:bg-gray-100 ${
selectedGroupId === group.id
? "bg-blue-50 border border-blue-200"
: ""
}`}
onClick={() => handleSelectGroup(group.id)}
>
<div className="font-medium">{group.name}</div>
<div className="text-sm text-gray-500">
{group.memberCount}
</div>
</div>
))}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsGroupSelectOpen(false)}
>
</Button>
<Button onClick={() => setIsGroupSelectOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,205 +0,0 @@
"use client"
import { useState, useEffect } from "react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Plus, X, Edit2, AlertCircle } from "lucide-react"
import { Alert, AlertDescription } from "@/components/ui/alert"
interface TagSettingsProps {
formData: any
onChange: (data: any) => void
onNext?: () => void
onPrev?: () => void
}
interface Tag {
id: string
name: string
keywords: string[]
}
export function TagSettings({ formData, onChange, onNext, onPrev }: TagSettingsProps) {
const [tags, setTags] = useState<Tag[]>(formData.tags || [])
const [isAddTagDialogOpen, setIsAddTagDialogOpen] = useState(false)
const [editingTag, setEditingTag] = useState<Tag | null>(null)
const [newTagName, setNewTagName] = useState("")
const [newTagKeywords, setNewTagKeywords] = useState("")
const [hasWarnings, setHasWarnings] = useState(false)
// 当标签更新时更新formData
useEffect(() => {
onChange({ ...formData, tags })
}, [tags, onChange])
// 检查是否有标签
useEffect(() => {
setHasWarnings(tags.length === 0)
}, [tags])
const handleAddTag = () => {
if (!newTagName.trim()) return
const keywordsArray = newTagKeywords
.split("\n")
.map((k) => k.trim())
.filter((k) => k !== "")
if (editingTag) {
// 编辑现有标签
setTags(
tags.map((tag) => (tag.id === editingTag.id ? { ...tag, name: newTagName, keywords: keywordsArray } : tag)),
)
} else {
// 添加新标签
setTags([
...tags,
{
id: Date.now().toString(),
name: newTagName,
keywords: keywordsArray,
},
])
}
// 重置表单
setNewTagName("")
setNewTagKeywords("")
setEditingTag(null)
setIsAddTagDialogOpen(false)
}
const handleEditTag = (tag: Tag) => {
setEditingTag(tag)
setNewTagName(tag.name)
setNewTagKeywords(tag.keywords.join("\n"))
setIsAddTagDialogOpen(true)
}
const handleDeleteTag = (tagId: string) => {
setTags(tags.filter((tag) => tag.id !== tagId))
}
const handleNext = () => {
// 确保onNext是一个函数
if (typeof onNext === "function") {
onNext()
}
}
const handlePrev = () => {
// 确保onPrev是一个函数
if (typeof onPrev === "function") {
onPrev()
}
}
const handleCancel = () => {
setNewTagName("")
setNewTagKeywords("")
setEditingTag(null)
setIsAddTagDialogOpen(false)
}
return (
<div className="w-full p-4 bg-gray-50">
<div className="space-y-6">
<div>
<div className="flex items-center justify-between mb-4">
<Label className="text-base font-medium"></Label>
<Button onClick={() => setIsAddTagDialogOpen(true)} size="sm">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
{tags.length === 0 ? (
<div className="border rounded-md p-8 text-center text-gray-500">
"添加标签"
</div>
) : (
<div className="space-y-3">
{tags.map((tag) => (
<div key={tag.id} className="border rounded-md p-3 flex justify-between items-center">
<div>
<Badge className="mb-2">{tag.name}</Badge>
<div className="text-sm text-gray-500">
{tag.keywords.length > 0 ? `关键词: ${tag.keywords.join(", ")}` : "无关键词"}
</div>
</div>
<div className="flex space-x-2">
<Button variant="ghost" size="sm" onClick={() => handleEditTag(tag)}>
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteTag(tag.id)}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{hasWarnings && (
<Alert variant="destructive" className="mt-4 bg-amber-50 border-amber-200">
<AlertCircle className="h-4 w-4 text-amber-500" />
<AlertDescription>便</AlertDescription>
</Alert>
)}
</div>
<div className="flex justify-between pt-4">
<Button variant="outline" onClick={handlePrev}>
</Button>
<Button onClick={handleNext}></Button>
</div>
</div>
<Dialog open={isAddTagDialogOpen} onOpenChange={setIsAddTagDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{editingTag ? "编辑标签" : "添加标签"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="bg-green-50 p-3 rounded-md text-sm text-green-700">
/
</div>
<div className="space-y-2">
<Input
placeholder="请输入标签名称(最长6位字符)"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value.slice(0, 6))}
/>
</div>
<div className="space-y-2">
<Textarea
placeholder="(非必填项) 请输入关键词,一行代表一个关键词"
rows={5}
value={newTagKeywords}
onChange={(e) => setNewTagKeywords(e.target.value)}
/>
</div>
</div>
<DialogFooter className="sm:justify-end">
<Button type="button" variant="outline" onClick={handleCancel}>
</Button>
<Button type="button" onClick={handleAddTag}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,225 +0,0 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import Layout from '@/components/Layout';
import UnifiedHeader from '@/components/UnifiedHeader';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { useState } from 'react';
// 复用mock数据生成
import {
generateMockDevices,
generateMockWechatAccounts,
generateMockCustomerServices,
generateMockTrafficPools,
generateMockUsers,
RFM_SEGMENTS,
TrafficUser,
} from './TrafficPool';
const devices = generateMockDevices();
const wechatAccounts = generateMockWechatAccounts(devices);
const customerServices = generateMockCustomerServices();
const trafficPools = generateMockTrafficPools();
const users = generateMockUsers(devices, wechatAccounts, customerServices, trafficPools);
function getUserById(id: string): TrafficUser | undefined {
return users.find((u: TrafficUser) => u.id === id);
}
function getWechatAccount(accountId: string) {
return wechatAccounts.find((acc) => acc.id === accountId);
}
function getCustomerService(csId: string) {
return customerServices.find((cs) => cs.id === csId);
}
function getDevice(deviceId: string) {
return devices.find((device) => device.id === deviceId);
}
function getPoolNames(poolIds: string[]) {
return poolIds.map(id => trafficPools.find((pool) => pool.id === id)?.name).filter(Boolean).join(', ');
}
function formatDate(dateString: string) {
if (!dateString) return '--';
try {
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN');
} catch (error) {
return dateString;
}
}
export default function TrafficPoolDetail() {
const { id } = useParams();
const [activeTab, setActiveTab] = useState<'base' | 'journey' | 'tags'>('base');
const user = getUserById(id as string);
if (!user) {
return <div className="p-8 text-center text-gray-400"></div>;
}
const wechatAccount = getWechatAccount(user.wechatAccountId);
const customerService = getCustomerService(user.customerServiceId);
const device = getDevice(user.deviceId);
// RFM分段
const rfmSegment = Object.values(RFM_SEGMENTS).find((seg: any) => seg.name === user.rfmScore.segment) as { name: string; color: string } | undefined;
return (
<Layout
header={
<UnifiedHeader title="用户详情" showBack />
}
>
<div className="p-4 space-y-4">
{/* 头像与基本信息 */}
<div className="flex items-center space-x-4">
<Avatar className="h-16 w-16">
<AvatarImage src={user.avatar} />
<AvatarFallback>{user.nickname?.slice(0, 2) || '用户'}</AvatarFallback>
</Avatar>
<div>
<div className="flex items-center space-x-2">
<span className="text-lg font-bold">{user.nickname}</span>
{user.poolIds.length > 0 && (
<Badge className="bg-purple-100 text-purple-700 border-0">{getPoolNames(user.poolIds)}</Badge>
)}
{user.status === 'added' && <Badge className="bg-green-100 text-green-700 border-0"></Badge>}
</div>
<div className="text-blue-600 text-sm font-medium">{user.wechatId}</div>
</div>
</div>
{/* 重要保持客户/优先添加等 */}
<div className="flex items-center gap-2">
{rfmSegment && (
<Badge className={rfmSegment.color + ' border-0'}>{rfmSegment.name}</Badge>
)}
{user.status === 'added' && <Badge className="bg-pink-100 text-pink-700 border-0"></Badge>}
</div>
{/* Tab栏 */}
<div className="flex border-b mb-2">
<div
className={`px-4 py-2 font-medium cursor-pointer ${activeTab === 'base' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-400'}`}
onClick={() => setActiveTab('base')}
></div>
<div
className={`px-4 py-2 font-medium cursor-pointer ${activeTab === 'journey' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-400'}`}
onClick={() => setActiveTab('journey')}
></div>
<div
className={`px-4 py-2 font-medium cursor-pointer ${activeTab === 'tags' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-400'}`}
onClick={() => setActiveTab('tags')}
></div>
</div>
{/* Tab内容区 */}
{activeTab === 'base' && (
<>
{/* 关键信息卡片 */}
<Card className="p-4 space-y-2">
<div className="text-sm text-gray-500 font-medium mb-1"></div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>{device?.name || '--'}</div>
<div>{wechatAccount?.nickname || '--'}</div>
<div>{customerService?.name || '--'}</div>
<div>{formatDate(user.addTime)}</div>
<div>{formatDate(user.lastInteraction)}</div>
</div>
</Card>
{/* RFM评分卡片 */}
<Card className="p-4 space-y-2">
<div className="text-sm text-gray-500 font-medium mb-1">RFM评分</div>
<div className="flex gap-4 text-center">
<div>
<div className="text-lg font-bold text-blue-600">{user.rfmScore.recency}</div>
<div className="text-xs text-gray-500">(R)</div>
</div>
<div>
<div className="text-lg font-bold text-green-600">{user.rfmScore.frequency}</div>
<div className="text-xs text-gray-500">(F)</div>
</div>
<div>
<div className="text-lg font-bold text-purple-600">{user.rfmScore.monetary}</div>
<div className="text-xs text-gray-500">(M)</div>
</div>
</div>
</Card>
{/* 流量池按钮 */}
<div className="flex gap-2">
<Button size="sm" variant="outline"></Button>
<Button size="sm" variant="outline"></Button>
</div>
{/* 统计数据卡片 */}
<Card className="p-4 grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-lg font-bold text-green-600">¥{user.totalSpent}</div>
<div className="text-xs text-gray-500"></div>
</div>
<div>
<div className="text-lg font-bold text-blue-600">{user.interactionCount}</div>
<div className="text-xs text-gray-500"></div>
</div>
<div>
<div className="text-lg font-bold text-orange-600">{user.conversionRate}%</div>
<div className="text-xs text-gray-500"></div>
</div>
<div>
<div className="text-lg font-bold text-red-600">{user.status === 'failed' ? '添加失败' : user.status === 'added' ? '添加成功' : '未添加'}</div>
<div className="text-xs text-gray-500"></div>
</div>
</Card>
</>
)}
{activeTab === 'journey' && (
<Card className="p-4 space-y-4">
<div className="text-sm font-medium mb-2"></div>
{user.interactions && user.interactions.length > 0 ? (
user.interactions.slice(0, 4).map((it, idx) => (
<div key={it.id} className="flex items-start gap-3 border-b last:border-b-0 pb-3 last:pb-0">
<div className="mt-1">
{it.type === 'click' && <span className="inline-block w-6 h-6 rounded-full bg-orange-50 text-orange-400 text-center">📱</span>}
{it.type === 'message' && <span className="inline-block w-6 h-6 rounded-full bg-blue-50 text-blue-400 text-center">💬</span>}
{it.type === 'purchase' && <span className="inline-block w-6 h-6 rounded-full bg-green-50 text-green-400 text-center">💲</span>}
{it.type === 'view' && <span className="inline-block w-6 h-6 rounded-full bg-purple-50 text-purple-400 text-center">👁</span>}
</div>
<div className="flex-1">
<div className="font-medium text-gray-700">
{it.type === 'click' && '点击行为'}
{it.type === 'message' && '消息互动'}
{it.type === 'purchase' && '购买行为'}
{it.type === 'view' && '页面浏览'}
</div>
<div className="text-gray-500 text-sm mb-1">{it.content}{it.type === 'purchase' && it.value && <span className="text-green-600 font-bold ml-1">¥{it.value}</span>}</div>
</div>
<div className="text-xs text-gray-400 mt-1 whitespace-nowrap">{formatDate(it.timestamp)} {new Date(it.timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</div>
</div>
))
) : (
<div className="text-gray-400 text-center"></div>
)}
</Card>
)}
{activeTab === 'tags' && (
<div className="space-y-4">
<Card className="p-4">
<div className="text-sm font-medium mb-2"></div>
<div className="flex flex-wrap gap-2 mb-2">
{user.tags.map(tag => (
<Badge key={tag.id} className="px-3 py-1 text-sm">{tag.name}</Badge>
))}
</div>
<div className="text-sm font-medium mb-2"></div>
<div className="flex items-center gap-2 mb-2">
<Badge className="bg-purple-100 text-purple-700 border-0"></Badge>
<span className="text-gray-400 text-xs">RFM总分{user.rfmScore.recency + user.rfmScore.frequency + user.rfmScore.monetary}/15</span>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-500 text-sm"></span>
<Badge className="bg-red-100 text-red-600 border-0"></Badge>
</div>
</Card>
<Button className="w-full mt-2" size="lg" variant="outline"> </Button>
</div>
)}
</div>
</Layout>
);
}

View File

@@ -1,971 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import PageHeader from '@/components/PageHeader';
import {
ChevronLeft,
Smartphone,
Users,
Star,
Clock,
MessageSquare,
Shield,
Info,
UserPlus,
Search,
ChevronRight,
Loader2
} from 'lucide-react';
import { useWechatAccount } from '@/contexts/WechatAccountContext';
import { fetchWechatAccountSummary, fetchWechatFriends, fetchWechatFriendDetail } from '@/api/wechat-accounts';
import { useToast } from '@/components/ui/toast';
import Layout from '@/components/Layout';
import '@/components/Layout.css';
interface WechatAccountSummary {
accountAge: string;
activityLevel: {
allTimes: number;
dayTimes: number;
};
accountWeight: {
scope: number;
ageWeight: number;
activityWeigth: number;
restrictWeight: number;
realNameWeight: number;
};
statistics: {
todayAdded: number;
addLimit: number;
};
restrictions: {
id: number;
level: string;
reason: string;
date: string;
}[];
}
interface Friend {
id: string;
avatar: string;
nickname: string;
wechatId: string;
remark: string;
addTime: string;
lastInteraction: string;
tags: Array<{
id: string;
name: string;
color: string;
}>;
region: string;
source: string;
notes: string;
}
interface WechatFriendDetail {
id: number;
avatar: string;
nickname: string;
region: string;
wechatId: string;
addDate: string;
tags: string[];
memo: string;
source: string;
}
export default function WechatAccountDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const { currentAccount } = useWechatAccount();
const [accountSummary, setAccountSummary] = useState<WechatAccountSummary | null>(null);
const [showRestrictions, setShowRestrictions] = useState(false);
const [showTransferConfirm, setShowTransferConfirm] = useState(false);
const [showFriendDetail, setShowFriendDetail] = useState(false);
const [selectedFriend, setSelectedFriend] = useState<Friend | null>(null);
const [friendDetail, setFriendDetail] = useState<WechatFriendDetail | null>(null);
const [isLoadingFriendDetail, setIsLoadingFriendDetail] = useState(false);
const [friendDetailError, setFriendDetailError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState("overview");
const [, setIsLoading] = useState(false);
// 好友列表相关状态
const [friends, setFriends] = useState<Friend[]>([]);
const [friendsPage, setFriendsPage] = useState(1);
const [friendsTotal, setFriendsTotal] = useState(0);
const [hasMoreFriends, setHasMoreFriends] = useState(true);
const [isFetchingFriends, setIsFetchingFriends] = useState(false);
const [hasFriendLoadError, setHasFriendLoadError] = useState(false);
const [isFriendsEmpty, setIsFriendsEmpty] = useState(false);
const friendsObserver = useRef<IntersectionObserver | null>(null);
const friendsLoadingRef = useRef<HTMLDivElement | null>(null);
// 如果没有账号数据,返回上一页
useEffect(() => {
if (!currentAccount) {
toast({
title: "数据错误",
description: "未找到账号信息,请重新选择",
variant: "destructive"
});
navigate('/wechat-accounts');
return;
}
}, [currentAccount, navigate, toast]);
// 获取账号概览信息
const fetchAccountSummary = useCallback(async () => {
if (!id) return;
try {
setIsLoading(true);
const response = await fetchWechatAccountSummary(id);
if (response && response.code === 200 && response.data) {
setAccountSummary(response.data);
} else {
toast({
title: "获取账号概览失败",
description: response?.msg || "请稍后重试",
variant: "destructive"
});
}
} catch (error) {
console.error("获取账号概览失败:", error);
toast({
title: "获取账号概览失败",
description: "请检查网络连接后重试",
variant: "destructive"
});
} finally {
setIsLoading(false);
}
}, [id, toast]);
// 获取好友列表
const fetchFriends = useCallback(async (page: number = 1, isNewSearch: boolean = false) => {
console.log('fetchFriends called:', { page, isNewSearch, isFetchingFriends, id, searchQuery });
if (!id || isFetchingFriends) {
console.log('fetchFriends early return:', { id, isFetchingFriends });
return;
}
try {
setIsFetchingFriends(true);
setHasFriendLoadError(false);
console.log('Making API request for friends:', { id, page, searchQuery });
const response = await fetchWechatFriends(id, page, 20, searchQuery);
console.log('API response:', response);
if (response && response.code === 200 && response.data) {
const newFriends = response.data.list.map((friend: any) => ({
id: friend.id.toString(),
avatar: friend.avatar || "/placeholder.svg",
nickname: friend.nickname || "未知用户",
wechatId: friend.wechatId || "",
remark: friend.memo || "",
addTime: friend.createTime || new Date().toISOString().split('T')[0],
lastInteraction: friend.lastInteraction || new Date().toISOString().split('T')[0],
tags: friend.tags ? friend.tags.map((tag: string, index: number) => ({
id: `tag-${index}`,
name: tag,
color: getRandomTagColor()
})) : [],
region: friend.region || "未知",
source: friend.source || "未知",
notes: friend.notes || ""
}));
console.log('Processed friends:', { newFriendsCount: newFriends.length, isNewSearch });
if (isNewSearch) {
setFriends(newFriends);
// 如果是新搜索且数据为空,设置空状态
if (newFriends.length === 0) {
console.log('Setting empty state for new search');
setIsFriendsEmpty(true);
setHasMoreFriends(false);
} else {
console.log('Setting normal state for new search');
setIsFriendsEmpty(false);
setHasMoreFriends(newFriends.length === 20);
}
} else {
setFriends(prev => [...prev, ...newFriends]);
setHasMoreFriends(newFriends.length === 20);
}
setFriendsTotal(response.data.total);
setFriendsPage(page);
} else {
console.log('API response error:', response);
setHasFriendLoadError(true);
if (isNewSearch) {
setFriends([]);
setIsFriendsEmpty(true);
setHasMoreFriends(false);
}
toast({
title: "获取好友列表失败",
description: response?.msg || "请稍后重试",
variant: "destructive"
});
}
} catch (error) {
console.error("获取好友列表失败:", error);
setHasFriendLoadError(true);
if (isNewSearch) {
setFriends([]);
setIsFriendsEmpty(true);
setHasMoreFriends(false);
}
toast({
title: "获取好友列表失败",
description: "请检查网络连接后重试",
variant: "destructive"
});
} finally {
console.log('Setting isFetchingFriends to false');
setIsFetchingFriends(false);
}
}, [id, searchQuery, toast, isFetchingFriends]);
// 初始化数据
useEffect(() => {
if (id) {
fetchAccountSummary();
if (activeTab === "friends") {
fetchFriends(1, true);
}
}
}, [id, fetchAccountSummary]);
// 监听标签切换
useEffect(() => {
if (activeTab === "friends" && id) {
// 重置空状态,允许重新加载
setIsFriendsEmpty(false);
setHasFriendLoadError(false);
fetchFriends(1, true);
}
}, [activeTab, id, fetchFriends]);
// 无限滚动加载好友
useEffect(() => {
if (!friendsLoadingRef.current || !hasMoreFriends || isFetchingFriends || isFriendsEmpty) return;
friendsObserver.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMoreFriends && !isFetchingFriends && !isFriendsEmpty) {
fetchFriends(friendsPage + 1, false);
}
},
{ threshold: 0.1 }
);
friendsObserver.current.observe(friendsLoadingRef.current);
return () => {
if (friendsObserver.current) {
friendsObserver.current.disconnect();
}
};
}, [hasMoreFriends, isFetchingFriends, friendsPage, fetchFriends, isFriendsEmpty]);
// 工具函数
const getRandomTagColor = (): string => {
const colors = [
"bg-blue-100 text-blue-800",
"bg-green-100 text-green-800",
"bg-red-100 text-red-800",
"bg-pink-100 text-pink-800",
"bg-emerald-100 text-emerald-800",
"bg-amber-100 text-amber-800",
];
return colors[Math.floor(Math.random() * colors.length)];
};
const calculateAccountAge = (registerTime: string) => {
const registerDate = new Date(registerTime);
const now = new Date();
const diffTime = Math.abs(now.getTime() - registerDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
const years = Math.floor(diffDays / 365);
const months = Math.floor((diffDays % 365) / 30);
return { years, months };
};
const formatAccountAge = (age: { years: number; months: number }) => {
if (age.years > 0) {
return `${age.years}${age.months}个月`;
}
return `${age.months}个月`;
};
const getWeightColor = (weight: number) => {
if (weight >= 80) return "text-green-600";
if (weight >= 60) return "text-yellow-600";
return "text-red-600";
};
const getWeightDescription = (weight: number) => {
if (weight >= 80) return "账号质量优秀,可以正常使用";
if (weight >= 60) return "账号质量良好,需要注意使用频率";
return "账号质量较差,建议谨慎使用";
};
const handleTransferFriends = () => {
setShowTransferConfirm(true);
};
const confirmTransferFriends = () => {
toast({
title: "好友转移计划已创建",
description: "请在场景获客中查看详情",
});
setShowTransferConfirm(false);
navigate("/scenarios");
};
// const handleBack = () => {
// clearCurrentAccount();
// navigate('/wechat-accounts');
// };
const handleFriendClick = async (friend: Friend) => {
setSelectedFriend(friend);
setShowFriendDetail(true);
setIsLoadingFriendDetail(true);
setFriendDetailError(null);
try {
const response = await fetchWechatFriendDetail(friend.id);
if (response && response.code === 200 && response.data) {
setFriendDetail(response.data);
} else {
setFriendDetailError(response?.msg || "获取好友详情失败");
}
} catch (error) {
console.error("获取好友详情失败:", error);
setFriendDetailError("网络错误,请稍后重试");
} finally {
setIsLoadingFriendDetail(false);
}
};
const getRestrictionLevelColor = (level: string) => {
switch (level) {
case "high":
return "text-red-600";
case "medium":
return "text-yellow-600";
default:
return "text-gray-600";
}
};
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}).replace(/\//g, '-');
};
const handleSearch = () => {
// 搜索时重置空状态
setIsFriendsEmpty(false);
setHasFriendLoadError(false);
fetchFriends(1, true);
};
const handleTabChange = (value: string) => {
setActiveTab(value);
};
if (!currentAccount) {
return (
<div className="flex justify-center items-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
);
}
return (
<Layout
header={
<PageHeader
title="账号详情"
defaultBackPath="/wechat-accounts"
/>
}
>
<div className="bg-gradient-to-b from-blue-50 to-white">
<div className="p-4 space-y-4">
{/* 账号基本信息卡片 */}
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<div className="flex items-center space-x-4">
<div className="relative">
<img
src={currentAccount.avatar || "/placeholder.svg"}
alt={currentAccount.nickname}
className="w-16 h-16 rounded-full ring-4 ring-offset-2 ring-blue-500/20"
/>
<div className={`absolute -bottom-1 -right-1 w-4 h-4 rounded-full border-2 border-white ${
currentAccount.status === "normal" ? "bg-green-500" : "bg-red-500"
}`}></div>
</div>
<div className="flex-1">
<div className="flex items-center space-x-2">
<h2 className="text-xl font-semibold truncate max-w-[200px]">{currentAccount.nickname}</h2>
<span className={`px-2 py-1 text-xs rounded-full ${
currentAccount.status === "normal"
? "bg-green-500 text-white"
: "bg-red-500 text-white"
}`}>
{currentAccount.status === "normal" ? "正常" : "异常"}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">{currentAccount.wechatAccount}</p>
<div className="flex gap-2 mt-2">
<button
className="px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center"
onClick={() => navigate(`/devices/${currentAccount.deviceId}`)}
>
<Smartphone className="w-4 h-4 mr-2" />
{currentAccount.deviceName || '未命名设备'}
</button>
<button
className="px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center"
onClick={handleTransferFriends}
>
<UserPlus className="w-4 h-4 mr-2" />
</button>
</div>
</div>
</div>
</div>
{/* 标签页 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
<div className="flex border-b border-gray-200">
<button
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
activeTab === "overview"
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
onClick={() => handleTabChange("overview")}
>
</button>
<button
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
activeTab === "friends"
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
onClick={() => handleTabChange("friends")}
>
{activeTab === "friends" && friendsTotal > 0 ? ` (${friendsTotal.toLocaleString()})` : ''}
</button>
</div>
<div className="p-4">
{activeTab === "overview" ? (
<div className="space-y-4">
{/* 账号基础信息 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-3 rounded-xl border border-blue-100 shadow-sm hover:shadow-md transition-all duration-300">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="p-1.5 bg-blue-100 rounded-lg">
<Clock className="w-4 h-4 text-blue-600" />
</div>
<div>
<div className="text-xs font-medium text-blue-700"></div>
{accountSummary && (
<div className="text-xs text-blue-600">
{new Date(accountSummary.accountAge).toLocaleDateString()}
</div>
)}
</div>
</div>
{accountSummary && (
<div className="text-right">
<div className="text-lg font-bold text-blue-800">
{formatAccountAge(calculateAccountAge(accountSummary.accountAge))}
</div>
</div>
)}
</div>
</div>
<div className="bg-gradient-to-br from-green-50 to-emerald-50 p-3 rounded-xl border border-green-100 shadow-sm hover:shadow-md transition-all duration-300">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="p-1.5 bg-green-100 rounded-lg">
<MessageSquare className="w-4 h-4 text-green-600" />
</div>
<div>
<div className="text-xs font-medium text-green-700"></div>
{accountSummary && (
<div className="text-xs text-green-600">
{accountSummary.activityLevel.allTimes.toLocaleString()}
</div>
)}
</div>
</div>
{accountSummary && (
<div className="text-right">
<div className="text-lg font-bold text-green-800">
{accountSummary.activityLevel.dayTimes.toLocaleString()}
<span className="text-sm text-green-600 ml-1">/</span>
</div>
</div>
)}
</div>
</div>
</div>
{/* 账号权重评估 */}
{accountSummary && (
<div className="bg-gradient-to-br from-amber-50 to-yellow-50 p-4 rounded-xl border border-amber-100 shadow-sm hover:shadow-md transition-all duration-300">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<div className="p-1.5 bg-amber-100 rounded-lg">
<Star className="w-4 h-4 text-amber-600" />
</div>
<span className="font-semibold text-amber-800 text-base"></span>
</div>
<div className={`flex items-center space-x-2 px-3 py-1.5 rounded-full ${getWeightColor(accountSummary.accountWeight.scope).includes('green') ? 'bg-green-100 text-green-700' : getWeightColor(accountSummary.accountWeight.scope).includes('yellow') ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'}`}>
<span className="text-xl font-bold">{accountSummary.accountWeight.scope}</span>
<span className="text-xs font-medium"></span>
</div>
</div>
<p className="text-xs text-amber-700 mb-4 bg-amber-100 px-3 py-2 rounded-lg border border-amber-200">
{getWeightDescription(accountSummary.accountWeight.scope)}
</p>
<div className="space-y-3">
<div className="flex items-center">
<span className="flex-shrink-0 w-16 text-xs font-medium text-amber-700"></span>
<div className="flex-1 mx-3 bg-amber-200 rounded-full h-2 overflow-hidden">
<div
className="bg-gradient-to-r from-amber-400 to-amber-600 h-2 rounded-full transition-all duration-500"
style={{ width: `${accountSummary.accountWeight.ageWeight}%` }}
></div>
</div>
<span className="flex-shrink-0 w-10 text-xs font-medium text-amber-700 text-right">{accountSummary.accountWeight.ageWeight}%</span>
</div>
<div className="flex items-center">
<span className="flex-shrink-0 w-16 text-xs font-medium text-amber-700"></span>
<div className="flex-1 mx-3 bg-amber-200 rounded-full h-2 overflow-hidden">
<div
className="bg-gradient-to-r from-amber-400 to-amber-600 h-2 rounded-full transition-all duration-500"
style={{ width: `${accountSummary.accountWeight.activityWeigth}%` }}
></div>
</div>
<span className="flex-shrink-0 w-10 text-xs font-medium text-amber-700 text-right">{accountSummary.accountWeight.activityWeigth}%</span>
</div>
<div className="flex items-center">
<span className="flex-shrink-0 w-16 text-xs font-medium text-amber-700"></span>
<div className="flex-1 mx-3 bg-amber-200 rounded-full h-2 overflow-hidden">
<div
className="bg-gradient-to-r from-amber-400 to-amber-600 h-2 rounded-full transition-all duration-500"
style={{ width: `${accountSummary.accountWeight.restrictWeight}%` }}
></div>
</div>
<span className="flex-shrink-0 w-10 text-xs font-medium text-amber-700 text-right">{accountSummary.accountWeight.restrictWeight}%</span>
</div>
<div className="flex items-center">
<span className="flex-shrink-0 w-16 text-xs font-medium text-amber-700"></span>
<div className="flex-1 mx-3 bg-amber-200 rounded-full h-2 overflow-hidden">
<div
className="bg-gradient-to-r from-amber-400 to-amber-600 h-2 rounded-full transition-all duration-500"
style={{ width: `${accountSummary.accountWeight.realNameWeight}%` }}
></div>
</div>
<span className="flex-shrink-0 w-10 text-xs font-medium text-amber-700 text-right">{accountSummary.accountWeight.realNameWeight}%</span>
</div>
</div>
</div>
)}
{/* 添加好友统计 */}
{accountSummary && (
<div className="bg-gradient-to-br from-purple-50 to-indigo-50 p-4 rounded-xl border border-purple-100 shadow-sm hover:shadow-md transition-all duration-300">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<div className="p-1.5 bg-purple-100 rounded-lg">
<Users className="w-4 h-4 text-purple-600" />
</div>
<span className="font-semibold text-purple-800 text-base"></span>
</div>
<div className="relative group">
<div className="p-1.5 bg-purple-100 rounded-lg cursor-help">
<Info className="w-3 h-3 text-purple-600" />
</div>
<div className="absolute bottom-full right-0 mb-2 px-2 py-1.5 text-xs bg-purple-800 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap shadow-lg z-10">
<div className="absolute top-full right-4 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-purple-800"></div>
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between bg-white p-3 rounded-lg border border-purple-200">
<span className="text-xs font-medium text-purple-700"></span>
<span className="text-xl font-bold text-purple-800">{accountSummary.statistics.todayAdded}</span>
</div>
<div className="space-y-2">
<div className="flex justify-between text-xs">
<span className="text-purple-700 font-medium"></span>
<span className="text-purple-800 font-semibold">
{accountSummary.statistics.todayAdded}/{accountSummary.statistics.addLimit}
</span>
</div>
<div className="w-full bg-purple-200 rounded-full h-2 overflow-hidden">
<div
className="bg-gradient-to-r from-purple-400 to-purple-600 h-2 rounded-full transition-all duration-500"
style={{ width: `${Math.min((accountSummary.statistics.todayAdded / accountSummary.statistics.addLimit) * 100, 100)}%` }}
></div>
</div>
</div>
<div className="text-xs text-purple-700 bg-purple-100 px-3 py-2 rounded-lg border border-purple-200">
<span className="font-semibold text-purple-800">({accountSummary.accountWeight.scope})</span>{" "}
<span className="font-bold text-purple-800">{accountSummary.statistics.addLimit.toLocaleString()}</span>{" "}
</div>
</div>
</div>
)}
{/* 限制记录 */}
{accountSummary && (
<div className="bg-gradient-to-br from-red-50 to-pink-50 p-4 rounded-xl border border-red-100 shadow-sm hover:shadow-md transition-all duration-300">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<div className="p-1.5 bg-red-100 rounded-lg">
<Shield className="w-4 h-4 text-red-600" />
</div>
<span className="font-semibold text-red-800 text-base"></span>
</div>
{accountSummary.restrictions.length > 0 && (
<button
className="px-3 py-1.5 bg-red-100 hover:bg-red-200 text-red-700 rounded-full text-xs font-medium transition-colors duration-200 border border-red-200"
onClick={() => setShowRestrictions(true)}
>
{accountSummary.restrictions.length}
</button>
)}
</div>
{accountSummary.restrictions.length > 0 ? (
<div className="space-y-2">
{accountSummary.restrictions.slice(0, 2).map((record) => (
<div key={record.id} className="bg-white p-3 rounded-lg border border-red-200 hover:border-red-300 transition-colors">
<div className="flex items-center justify-between mb-1">
<span className={`text-xs font-medium ${getRestrictionLevelColor(record.level)}`}>
{record.reason}
</span>
<span className="text-xs text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
{formatDateTime(record.date)}
</span>
</div>
<div className="text-xs text-red-500">
{formatDateTime(record.date)}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-6">
<div className="flex flex-col items-center space-y-2">
<div className="p-2 bg-green-100 rounded-full">
<Shield className="w-5 h-5 text-green-600" />
</div>
<div className="text-green-700 font-medium text-sm"></div>
<div className="text-xs text-green-600">使</div>
</div>
</div>
)}
</div>
)}
</div>
) : (
<div className="space-y-4">
{/* 搜索栏 */}
<div className="flex items-center space-x-2 bg-white rounded-lg">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<input
placeholder="搜索好友昵称/微信号/备注/标签"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
/>
</div>
</div>
{/* 好友列表 */}
<div className="space-y-2 min-h-[200px]">
{isFetchingFriends && friends.length === 0 ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
) : hasFriendLoadError ? (
<div className="text-center py-8 text-red-500">
<p></p>
<button
className="mt-4 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm"
onClick={() => {
setHasFriendLoadError(false);
fetchFriends(1, true);
}}
>
</button>
</div>
) : isFriendsEmpty ? (
<div className="text-center py-8 text-gray-500">
<p></p>
{searchQuery && (
<button
className="mt-4 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm"
onClick={() => {
setSearchQuery("");
setIsFriendsEmpty(false);
fetchFriends(1, true);
}}
>
</button>
)}
</div>
) : friends.length === 0 ? (
<div className="text-center py-8 text-gray-500"></div>
) : (
<>
{friends.map((friend) => (
<div
key={friend.id}
className="flex items-center p-3 bg-white border rounded-lg hover:bg-gray-50 cursor-pointer transition-colors duration-200"
onClick={() => handleFriendClick(friend)}
>
<img
src={friend.avatar}
alt={friend.nickname}
className="w-10 h-10 rounded-full mr-3"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="font-medium truncate max-w-[180px]">
{friend.nickname}
{friend.remark && <span className="text-gray-500 ml-1 truncate">({friend.remark})</span>}
</div>
<ChevronRight className="h-4 w-4 text-gray-400" />
</div>
<div className="text-sm text-gray-500 truncate">{friend.wechatId}</div>
<div className="flex flex-wrap gap-1 mt-1">
{friend.tags?.map((tag, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-800"
>
{typeof tag === 'string' ? tag : tag.name}
</span>
))}
</div>
</div>
</div>
))}
{hasMoreFriends && !isFriendsEmpty && (
<div ref={friendsLoadingRef} className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
</div>
)}
</>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div> {/* 这里补上闭合415行的<div className='p-4 space-y-4'> */}
{/* 限制记录详情弹窗 */}
{showRestrictions && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-md w-full max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold"></h3>
<button
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
onClick={() => setShowRestrictions(false)}
>
<ChevronLeft className="h-5 w-5" />
</button>
</div>
<p className="text-sm text-gray-500 mb-4">24</p>
<div className="space-y-4">
{(accountSummary?.restrictions && accountSummary.restrictions.length > 0) ? (
accountSummary.restrictions.map((record) => (
<div key={record.id} className="border-b pb-4 last:border-0">
<div className="flex justify-between items-start">
<div className={`text-sm ${getRestrictionLevelColor(record.level)}`}>
{record.reason}
</div>
<span className="px-2 py-1 border border-gray-200 rounded text-xs">{formatDateTime(record.date)}</span>
</div>
<div className="text-sm text-gray-500 mt-1">{formatDateTime(record.date)}</div>
</div>
))
) : (
<div className="text-center py-8 text-green-500">
</div>
)}
</div>
</div>
</div>
)}
{/* 好友转移确认对话框 */}
{showTransferConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-md w-full">
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-sm text-gray-500 mb-4"></p>
<div className="py-4">
<div className="flex items-center space-x-3 p-3 bg-blue-50 rounded-lg">
<img
src={currentAccount.avatar}
alt={currentAccount.nickname}
className="w-10 h-10 rounded-full"
/>
<div>
<div className="font-medium">{currentAccount.nickname}</div>
<div className="text-sm text-gray-500">{currentAccount.wechatId}</div>
</div>
</div>
<div className="mt-4 text-sm text-gray-500">
<p> </p>
<p> </p>
<p> </p>
</div>
</div>
<div className="flex space-x-3">
<button
className="flex-1 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
onClick={() => setShowTransferConfirm(false)}
>
</button>
<button
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
onClick={confirmTransferFriends}
>
</button>
</div>
</div>
</div>
)}
{/* 好友详情对话框 */}
{showFriendDetail && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-md w-full max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold"></h3>
<button
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
onClick={() => setShowFriendDetail(false)}
>
<ChevronLeft className="h-5 w-5" />
</button>
</div>
{isLoadingFriendDetail ? (
<div className="flex justify-center items-center py-10">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
) : friendDetailError ? (
<div className="text-center py-8 text-red-500">
<p>{friendDetailError}</p>
<button
className="mt-4 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm"
onClick={() => handleFriendClick(selectedFriend!)}
>
</button>
</div>
) : friendDetail && selectedFriend ? (
<div className="space-y-4">
<div className="flex items-center space-x-3">
<img
src={selectedFriend.avatar}
alt={selectedFriend.nickname}
className="w-12 h-12 rounded-full"
/>
<div>
<h4 className="font-medium">{selectedFriend.nickname}</h4>
<p className="text-sm text-gray-500">{selectedFriend.wechatId}</p>
</div>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">{friendDetail.region || "未知"}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">{friendDetail.addDate}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">{friendDetail.source || "未知"}</span>
</div>
{friendDetail.memo && (
<div className="flex justify-between">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">{friendDetail.memo}</span>
</div>
)}
{friendDetail.tags && friendDetail.tags.length > 0 && (
<div>
<span className="text-sm text-gray-500 block mb-2"></span>
<div className="flex flex-wrap gap-1">
{friendDetail.tags.map((tag, index) => (
<span
key={index}
className="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800"
>
{tag}
</span>
))}
</div>
</div>
)}
</div>
</div>
) : null}
</div>
</div>
)}
</Layout>
);
}

View File

@@ -1,383 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, RefreshCw, Loader2 } from 'lucide-react';
import { fetchWechatAccountList, transformWechatAccount } from '@/api/wechat-accounts';
import { useToast } from '@/components/ui/toast';
import { useWechatAccount } from '@/contexts/WechatAccountContext';
import PageHeader from '@/components/PageHeader';
import Layout from '@/components/Layout';
import '@/components/Layout.css';
interface WechatAccount {
id: string;
wechatId: string;
wechatAccount: string;
nickname: string;
avatar: string;
remainingAdds: number;
todayAdded: number;
status: "normal" | "abnormal";
friendCount: number;
deviceName: string;
lastActive: string;
maxDailyAdds: number;
deviceId: string;
}
export default function WechatAccounts() {
const navigate = useNavigate();
const { toast } = useToast();
const { setCurrentAccount } = useWechatAccount();
const [accounts, setAccounts] = useState<WechatAccount[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [isTransferDialogOpen, setIsTransferDialogOpen] = useState(false);
const [selectedAccount, setSelectedAccount] = useState<WechatAccount | null>(null);
const [totalAccounts, setTotalAccounts] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const accountsPerPage = 10;
const mounted = useRef(false);
// 获取微信账号列表
const fetchAccounts = useCallback(async (page: number = 1, keyword: string = "") => {
try {
setIsLoading(true);
const response = await fetchWechatAccountList({
page,
limit: accountsPerPage,
keyword,
sort: 'id',
order: 'desc'
});
if (response && response.code === 200 && response.data) {
// 转换数据格式
const wechatAccounts = response.data.list.map((item: any) => transformWechatAccount(item));
setAccounts(wechatAccounts);
setTotalAccounts(response.data.total);
} else {
toast({
title: "获取微信账号失败",
description: response?.msg || "请稍后再试",
variant: "destructive"
});
setAccounts([]);
setTotalAccounts(0);
}
} catch (error) {
console.error("获取微信账号列表失败:", error);
toast({
title: "获取微信账号失败",
description: "请检查网络连接或稍后再试",
variant: "destructive"
});
setAccounts([]);
setTotalAccounts(0);
} finally {
setIsLoading(false);
}
}, [accountsPerPage, toast]);
// 初始化数据加载
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
fetchAccounts(currentPage, searchQuery);
}
}, []);
// 处理页码和搜索变化
useEffect(() => {
if (mounted.current) {
fetchAccounts(currentPage, searchQuery);
}
}, [currentPage, searchQuery, fetchAccounts]);
// 搜索处理
const handleSearch = () => {
setCurrentPage(1);
};
// 刷新处理
const handleRefresh = async () => {
try {
setIsRefreshing(true);
// 重新获取微信账号列表数据
await fetchAccounts(currentPage, searchQuery);
toast({
title: "刷新成功",
description: "微信账号列表已更新"
});
} catch (error) {
console.error("刷新微信账号状态失败:", error);
toast({
title: "刷新失败",
description: "请检查网络连接或稍后再试",
variant: "destructive"
});
} finally {
setIsRefreshing(false);
}
};
const totalPages = Math.ceil(totalAccounts / accountsPerPage);
const handleTransferFriends = (account: WechatAccount) => {
setSelectedAccount(account);
setIsTransferDialogOpen(true);
};
const handleConfirmTransfer = async () => {
if (!selectedAccount) return;
try {
// 实际实现好友转移功能,这里需要另一个账号作为目标
// 现在只是模拟效果
toast({
title: "好友转移计划已创建",
description: "请在场景获客中查看详情",
});
setIsTransferDialogOpen(false);
navigate("/scenarios");
} catch (error) {
console.error("好友转移失败:", error);
toast({
title: "好友转移失败",
description: "请稍后再试",
variant: "destructive"
});
}
};
return (
<Layout
header={
<PageHeader
title="微信号"
defaultBackPath="/"
/>
}
>
<div className="bg-gray-50">
<div className="p-4">
{/* 搜索和操作栏 */}
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 mb-4 ">
<div className="flex items-center space-x-3">
<div className="relative flex-1">
<Search className="w-4 h-4 absolute left-3 top-3 text-gray-400" />
<input
className="w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="搜索微信号/昵称"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<button
className="p-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
onClick={handleRefresh}
disabled={isRefreshing}
>
{isRefreshing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
</div>
</div>
{/* 账号列表 */}
{isLoading ? (
<div className="flex justify-center items-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
) : accounts.length === 0 ? (
<div className="text-center py-20 text-gray-500">
<p></p>
<button
className="mt-4 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
onClick={handleRefresh}
disabled={isRefreshing}
>
{isRefreshing ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
</button>
</div>
) : (
<div className="space-y-3">
{accounts.map((account) => (
<div
key={account.id}
className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 hover:shadow-lg transition-all cursor-pointer"
onClick={() => {
// 使用Context存储数据而不是URL参数
setCurrentAccount({
id: account.id,
avatar: account.avatar,
nickname: account.nickname,
status: account.status,
wechatId: account.wechatId,
wechatAccount: account.wechatAccount,
deviceName: account.deviceName,
deviceId: account.deviceId,
});
navigate(`/wechat-accounts/${account.id}`);
}}
>
<div className="flex items-start space-x-4">
<div className="relative">
<img
src={account.avatar || "/placeholder.svg"}
alt={account.nickname}
className="w-12 h-12 rounded-full ring-2 ring-offset-2 ring-blue-500/20"
/>
<div className={`absolute -bottom-1 -right-1 w-4 h-4 rounded-full border-2 border-white ${
account.status === "normal" ? "bg-green-500" : "bg-red-500"
}`}></div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<h3 className="font-medium truncate max-w-[180px]">{account.nickname}</h3>
<span className={`px-2 py-1 text-xs rounded-full ${
account.status === "normal"
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-700"
}`}>
{account.status === "normal" ? "正常" : "异常"}
</span>
</div>
<button
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
onClick={(e) => {
e.stopPropagation();
handleTransferFriends(account);
}}
>
{/* ArrowRightLeft className="h-4 w-4" */}
</button>
</div>
<div className="mt-2 text-sm text-gray-500 space-y-1">
<div className="truncate">{account.wechatAccount}</div>
<div className="flex items-center justify-between flex-wrap gap-1">
<div>{account.friendCount}</div>
<div className="text-green-600">+{account.todayAdded}</div>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center space-x-1">
<span></span>
<span className="font-medium">{account.remainingAdds}</span>
<div className="relative group">
{/* AlertCircle className="h-4 w-4 text-gray-400" */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 text-xs bg-gray-800 text-white rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{account.maxDailyAdds}
</div>
</div>
</div>
<span className="text-sm text-gray-500">
{account.todayAdded}/{account.maxDailyAdds}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${(account.todayAdded / account.maxDailyAdds) * 100}%` }}
></div>
</div>
</div>
<div className="flex items-center justify-between text-xs text-gray-500 pt-2 flex-wrap gap-1">
<div className="truncate max-w-[150px]">{account.deviceName || '未知设备'}</div>
<div className="whitespace-nowrap">{account.lastActive}</div>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* 分页 */}
{!isLoading && accounts.length > 0 && totalPages > 1 && (
<div className="mt-6 flex justify-center">
<div className="flex items-center space-x-2">
<button
className="px-3 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
>
</button>
<div className="flex items-center space-x-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageToShow = i + 1;
if (currentPage > 3 && totalPages > 5) {
pageToShow = Math.min(currentPage - 2 + i, totalPages);
if (pageToShow > totalPages - 4) {
pageToShow = totalPages - 4 + i;
}
}
return (
<button
key={pageToShow}
className={`px-3 py-2 rounded-lg transition-colors ${
currentPage === pageToShow
? "bg-blue-600 text-white"
: "border border-gray-200 hover:bg-gray-50"
}`}
onClick={() => setCurrentPage(pageToShow)}
>
{pageToShow}
</button>
);
})}
</div>
<button
className="px-3 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
>
</button>
</div>
</div>
)}
</div>
</div>
{/* 好友转移确认对话框 */}
{isTransferDialogOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-md w-full">
<h3 className="text-lg font-semibold mb-4"></h3>
<p className="text-sm text-gray-500 mb-6">
{selectedAccount?.nickname} {selectedAccount?.friendCount}{" "}
</p>
<div className="flex space-x-3">
<button
className="flex-1 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
onClick={() => setIsTransferDialogOpen(false)}
>
</button>
<button
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
onClick={handleConfirmTransfer}
>
</button>
</div>
</div>
</div>
)}
</Layout>
);
}

View File

@@ -1,212 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ThumbsUp, MessageSquare, Send, Users, Share2, Brain, BarChart2, LineChart, Clock } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import Layout from '@/components/Layout';
import UnifiedHeader from '@/components/UnifiedHeader';
import BottomNav from '@/components/BottomNav';
import '@/components/Layout.css';
export default function Workspace() {
// 模拟任务数据
const taskStats = {
total: 42,
inProgress: 12,
completed: 30,
todayTasks: 12,
activityRate: 98,
};
// 常用功能 - 保持原有排列
const commonFeatures = [
{
id: "auto-like",
name: "自动点赞",
description: "智能自动点赞互动",
icon: <ThumbsUp className="h-5 w-5 text-red-500" />,
path: "/workspace/auto-like",
bgColor: "bg-red-100",
isNew: true,
},
{
id: "moments-sync",
name: "朋友圈同步",
description: "自动同步朋友圈内容",
icon: <Clock className="h-5 w-5 text-purple-500" />,
path: "/workspace/moments-sync",
bgColor: "bg-purple-100",
},
{
id: "group-push",
name: "群消息推送",
description: "智能群发助手",
icon: <Send className="h-5 w-5 text-orange-500" />,
path: "/workspace/group-push",
bgColor: "bg-orange-100",
},
{
id: "auto-group",
name: "自动建群",
description: "智能拉好友建群",
icon: <Users className="h-5 w-5 text-green-500" />,
path: "/workspace/auto-group",
bgColor: "bg-green-100",
},
{
id: "traffic-distribution",
name: "流量分发",
description: "管理流量分发和分配",
icon: <Share2 className="h-5 w-5 text-blue-500" />,
path: "/workspace/traffic-distribution",
bgColor: "bg-blue-100",
},
{
id: "ai-assistant",
name: "AI对话助手",
description: "智能回复,提高互动质量",
icon: <MessageSquare className="h-5 w-5 text-blue-500" />,
path: "/workspace/ai-assistant",
bgColor: "bg-blue-100",
isNew: true,
},
];
// AI智能助手
const aiFeatures = [
{
id: "ai-analyzer",
name: "AI数据分析",
description: "智能分析客户行为特征",
icon: <BarChart2 className="h-5 w-5 text-indigo-500" />,
path: "/workspace/ai-analyzer",
bgColor: "bg-indigo-100",
isNew: true,
},
{
id: "ai-strategy",
name: "AI策略优化",
description: "智能优化获客策略",
icon: <Brain className="h-5 w-5 text-cyan-500" />,
path: "/workspace/ai-strategy",
bgColor: "bg-cyan-100",
isNew: true,
},
{
id: "ai-forecast",
name: "AI销售预测",
description: "智能预测销售趋势",
icon: <LineChart className="h-5 w-5 text-amber-500" />,
path: "/workspace/ai-forecast",
bgColor: "bg-amber-100",
},
];
return (
<Layout
header={
<UnifiedHeader
title="工作台"
titleColor="blue"
defaultBackPath="/"
/>
}
footer={<BottomNav />}
>
<div className="bg-gray-50 min-h-screen pb-20">
<div className="p-4">
<div className="max-w-md mx-auto">
{/* 任务统计卡片 */}
<div className="grid grid-cols-2 gap-3 mb-6">
<Card className="overflow-hidden">
<CardContent className="p-4">
<div className="text-sm text-gray-500"></div>
<div className="text-3xl font-bold text-blue-500 mt-1">{taskStats.total}</div>
<Progress value={(taskStats.inProgress / taskStats.total) * 100} className="h-2 mt-2 bg-blue-100" />
<div className="text-xs text-gray-500 mt-1">
: {taskStats.inProgress} / : {taskStats.completed}
</div>
</CardContent>
</Card>
<Card className="overflow-hidden">
<CardContent className="p-4">
<div className="text-sm text-gray-500"></div>
<div className="text-3xl font-bold text-green-500 mt-1">{taskStats.todayTasks}</div>
<div className="flex items-center mt-2">
<svg
className="w-4 h-4 text-green-500 mr-1"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12H7L10 19L14 5L17 12H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className="text-sm"> {taskStats.activityRate}%</span>
</div>
</CardContent>
</Card>
</div>
{/* 常用功能 */}
<div className="mb-6">
<h2 className="text-lg font-medium mb-3"></h2>
<div className="grid grid-cols-2 gap-3">
{commonFeatures.map((feature) => (
<Link to={feature.path} key={feature.id}>
<Card className="overflow-hidden hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className={`w-10 h-10 rounded-lg ${feature.bgColor} flex items-center justify-center mb-3`}>
{feature.icon}
</div>
<div className="flex items-center">
<div className="font-medium">{feature.name}</div>
{feature.isNew && (
<Badge className="ml-2 bg-blue-100 text-blue-600 hover:bg-blue-100 border-0">New</Badge>
)}
</div>
<div className="text-xs text-gray-500 mt-1">{feature.description}</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
{/* AI智能助手 */}
<div>
<h2 className="text-lg font-medium mb-3">AI </h2>
<div className="grid grid-cols-2 gap-3">
{aiFeatures.map((feature) => (
<Link to={feature.path} key={feature.id}>
<Card className="overflow-hidden hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className={`w-10 h-10 rounded-lg ${feature.bgColor} flex items-center justify-center mb-3`}>
{feature.icon}
</div>
<div className="flex items-center">
<div className="font-medium">{feature.name}</div>
{feature.isNew && (
<Badge className="ml-2 bg-blue-100 text-blue-600 hover:bg-blue-100 border-0">New</Badge>
)}
</div>
<div className="text-xs text-gray-500 mt-1">{feature.description}</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
</div>
</div>
</div>
</Layout>
);
}

View File

@@ -1,386 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import {
Send,
Settings,
Trash2,
Copy,
MoreVertical,
Bot,
User,
Sparkles,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
// import { Input } from '@/components/ui/input';
// import { Badge } from '@/components/ui/badge';
// import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import Layout from '@/components/Layout';
import PageHeader from '@/components/PageHeader';
import BottomNav from '@/components/BottomNav';
import { useToast } from '@/components/ui/toast';
import '@/components/Layout.css';
interface Message {
id: string;
type: 'user' | 'assistant';
content: string;
timestamp: Date;
isTyping?: boolean;
}
interface Conversation {
id: string;
title: string;
messages: Message[];
createdAt: Date;
updatedAt: Date;
}
export default function AIAssistant() {
const { toast } = useToast();
const [conversations, setConversations] = useState<Conversation[]>([]);
const [currentConversation, setCurrentConversation] = useState<Conversation | null>(null);
const [inputMessage, setInputMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
// 模拟初始对话
useEffect(() => {
const initialConversation: Conversation = {
id: '1',
title: '新对话',
messages: [
{
id: '1',
type: 'assistant',
content: '您好我是AI助手可以帮助您处理各种问题。请问有什么可以帮您的吗',
timestamp: new Date(),
},
],
createdAt: new Date(),
updatedAt: new Date(),
};
setConversations([initialConversation]);
setCurrentConversation(initialConversation);
}, []);
// 自动滚动到底部
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [currentConversation?.messages]);
const handleSendMessage = async () => {
if (!inputMessage.trim() || !currentConversation) return;
const userMessage: Message = {
id: Date.now().toString(),
type: 'user',
content: inputMessage,
timestamp: new Date(),
};
// 添加用户消息
const updatedConversation = {
...currentConversation,
messages: [...currentConversation.messages, userMessage],
updatedAt: new Date(),
};
setCurrentConversation(updatedConversation);
setConversations(prev =>
prev.map(conv =>
conv.id === currentConversation.id ? updatedConversation : conv
)
);
setInputMessage('');
setIsLoading(true);
// 模拟AI回复
setTimeout(() => {
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
type: 'assistant',
content: generateAIResponse(inputMessage),
timestamp: new Date(),
};
const finalConversation = {
...updatedConversation,
messages: [...updatedConversation.messages, assistantMessage],
updatedAt: new Date(),
};
setCurrentConversation(finalConversation);
setConversations(prev =>
prev.map(conv =>
conv.id === currentConversation.id ? finalConversation : conv
)
);
setIsLoading(false);
}, 1000 + Math.random() * 2000);
};
const generateAIResponse = (userMessage: string): string => {
const responses = [
'我理解您的问题,让我为您详细解答...',
'这是一个很好的问题!根据我的分析...',
'我可以帮您处理这个问题,建议您...',
'基于您提供的信息,我认为...',
'让我为您提供一些建议和解决方案...',
];
return responses[Math.floor(Math.random() * responses.length)];
};
const handleNewConversation = () => {
const newConversation: Conversation = {
id: Date.now().toString(),
title: '新对话',
messages: [
{
id: '1',
type: 'assistant',
content: '您好我是AI助手可以帮助您处理各种问题。请问有什么可以帮您的吗',
timestamp: new Date(),
},
],
createdAt: new Date(),
updatedAt: new Date(),
};
setConversations(prev => [newConversation, ...prev]);
setCurrentConversation(newConversation);
};
const handleDeleteConversation = (conversationId: string) => {
if (!window.confirm('确定要删除这个对话吗?')) return;
setConversations(prev => prev.filter(conv => conv.id !== conversationId));
if (currentConversation?.id === conversationId) {
const remainingConversations = conversations.filter(conv => conv.id !== conversationId);
setCurrentConversation(remainingConversations[0] || null);
}
};
const handleCopyMessage = (content: string) => {
navigator.clipboard.writeText(content);
toast({
title: '已复制',
description: '消息内容已复制到剪贴板',
});
};
const formatTime = (date: Date) => {
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
};
return (
<Layout
header={
<PageHeader
title="AI对话助手"
defaultBackPath="/workspace"
rightContent={
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="icon"
onClick={() => setShowSettings(!showSettings)}
>
<Settings className="h-4 w-4" />
</Button>
<Button onClick={handleNewConversation}>
<Sparkles className="h-4 w-4 mr-2" />
</Button>
</div>
}
/>
}
footer={<BottomNav />}
>
<div className="bg-gray-50 min-h-screen pb-20">
<div className="flex h-full">
{/* 侧边栏 - 对话列表 */}
<div className="w-80 bg-white border-r border-gray-200 flex flex-col">
<div className="p-4 border-b">
<h2 className="text-lg font-medium"></h2>
</div>
<div className="flex-1 overflow-y-auto">
{conversations.map((conversation) => (
<div
key={conversation.id}
className={`p-4 border-b cursor-pointer hover:bg-gray-50 ${
currentConversation?.id === conversation.id ? 'bg-blue-50 border-blue-200' : ''
}`}
onClick={() => setCurrentConversation(conversation)}
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm truncate">{conversation.title}</h3>
<p className="text-xs text-gray-500 mt-1">
{conversation.messages.length}
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleDeleteConversation(conversation.id)}>
<Trash2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
</div>
{/* 主聊天区域 */}
<div className="flex-1 flex flex-col">
{currentConversation ? (
<>
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{currentConversation.messages.map((message) => (
<div
key={message.id}
className={`flex ${message.type === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-xs lg:max-w-md ${message.type === 'user' ? 'order-2' : 'order-1'}`}>
<div className={`flex items-start space-x-2 ${message.type === 'user' ? 'flex-row-reverse space-x-reverse' : ''}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
message.type === 'user'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-600'
}`}>
{message.type === 'user' ? (
<User className="h-4 w-4" />
) : (
<Bot className="h-4 w-4" />
)}
</div>
<div className={`flex-1 ${message.type === 'user' ? 'text-right' : ''}`}>
<Card className={`inline-block ${message.type === 'user' ? 'bg-blue-500 text-white' : 'bg-white'}`}>
<CardContent className="p-3">
<div className="flex items-start justify-between">
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 ml-2 opacity-0 group-hover:opacity-100"
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleCopyMessage(message.content)}>
<Copy className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className={`text-xs mt-2 ${message.type === 'user' ? 'text-blue-100' : 'text-gray-500'}`}>
{formatTime(message.timestamp)}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="max-w-xs lg:max-w-md">
<div className="flex items-start space-x-2">
<div className="w-8 h-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center">
<Bot className="h-4 w-4" />
</div>
<Card className="bg-white">
<CardContent className="p-3">
<div className="flex items-center space-x-2">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
<span className="text-sm text-gray-500">AI正在思考...</span>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div className="border-t bg-white p-4">
<div className="flex items-end space-x-2">
<div className="flex-1">
<Textarea
placeholder="输入您的问题..."
value={inputMessage}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setInputMessage(e.target.value)}
onKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
className="min-h-[60px] max-h-[120px] resize-none"
disabled={isLoading}
/>
</div>
<Button
onClick={handleSendMessage}
disabled={!inputMessage.trim() || isLoading}
className="h-[60px] px-4"
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<Bot className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-gray-500 mb-4">AI助手将帮助您解决各种问题</p>
<Button onClick={handleNewConversation}>
<Sparkles className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
)}
</div>
</div>
</div>
</Layout>
);
}

View File

@@ -1,454 +0,0 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Plus,
Filter,
Search,
RefreshCw,
MoreVertical,
Clock,
Edit,
Trash2,
Eye,
Copy,
ChevronDown,
ChevronUp,
Settings,
Calendar,
Users,
UserPlus,
// CheckCircle,
// XCircle,
} from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Progress } from '@/components/ui/progress';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import Layout from '@/components/Layout';
import PageHeader from '@/components/PageHeader';
import BottomNav from '@/components/BottomNav';
import { useToast } from '@/components/ui/toast';
import '@/components/Layout.css';
interface GroupTask {
id: string;
name: string;
status: 'running' | 'paused' | 'completed';
deviceCount: number;
targetFriends: number;
createdGroups: number;
lastCreateTime: string;
createTime: string;
creator: string;
createInterval: number;
maxGroupsPerDay: number;
timeRange: { start: string; end: string };
groupSize: { min: number; max: number };
targetTags: string[];
groupNameTemplate: string;
groupDescription: string;
}
// CardMenu组件参考AutoLike实现
function CardMenu({ onView, onEdit, onCopy, onDelete }: { onView: () => void; onEdit: () => void; onCopy: () => void; onDelete: () => void; }) {
const [open, setOpen] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
if (open) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
return (
<div style={{ position: "relative" }}>
<button onClick={() => setOpen((v) => !v)} style={{ background: "none", border: "none", padding: 0, margin: 0, cursor: "pointer" }}>
<MoreVertical className="h-4 w-4" />
</button>
{open && (
<div
ref={menuRef}
style={{
position: "absolute",
right: 0,
top: 28,
background: "#fff",
borderRadius: 8,
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
zIndex: 100,
minWidth: 120,
padding: 4,
}}
>
<div onClick={() => { onView(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
<Eye className="h-4 w-4 mr-2" />
</div>
<div onClick={() => { onEdit(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
<Edit className="h-4 w-4 mr-2" />
</div>
<div onClick={() => { onCopy(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
<Copy className="h-4 w-4 mr-2" />
</div>
<div onClick={() => { onDelete(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, color: "#e53e3e", transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
<Trash2 className="h-4 w-4 mr-2" />
</div>
</div>
)}
</div>
);
}
export default function AutoGroup() {
const navigate = useNavigate();
const { toast } = useToast();
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [tasks, setTasks] = useState<GroupTask[]>([
{
id: '1',
name: 'VIP客户建群',
deviceCount: 2,
targetFriends: 156,
createdGroups: 12,
lastCreateTime: '2025-02-06 13:12:35',
createTime: '2024-11-20 19:04:14',
creator: 'admin',
status: 'running',
createInterval: 300,
maxGroupsPerDay: 20,
timeRange: { start: '09:00', end: '21:00' },
groupSize: { min: 20, max: 50 },
targetTags: ['VIP客户', '高价值'],
groupNameTemplate: 'VIP客户交流群{序号}',
groupDescription: 'VIP客户专属交流群提供优质服务',
},
{
id: '2',
name: '产品推广建群',
deviceCount: 1,
targetFriends: 89,
createdGroups: 8,
lastCreateTime: '2024-03-04 14:09:35',
createTime: '2024-03-04 14:29:04',
creator: 'manager',
status: 'paused',
createInterval: 600,
maxGroupsPerDay: 10,
timeRange: { start: '10:00', end: '20:00' },
groupSize: { min: 15, max: 30 },
targetTags: ['潜在客户', '中意向'],
groupNameTemplate: '产品推广群{序号}',
groupDescription: '产品推广交流群,了解最新产品信息',
},
]);
const toggleExpand = (taskId: string) => {
setExpandedTaskId(expandedTaskId === taskId ? null : taskId);
};
const handleDelete = (taskId: string) => {
const taskToDelete = tasks.find((task) => task.id === taskId);
if (!taskToDelete) return;
if (!window.confirm(`确定要删除"${taskToDelete.name}"吗?`)) return;
setTasks(tasks.filter((task) => task.id !== taskId));
toast({
title: '删除成功',
description: '已成功删除建群任务',
});
};
const handleEdit = (taskId: string) => {
navigate(`/workspace/auto-group/${taskId}/edit`);
};
const handleView = (taskId: string) => {
navigate(`/workspace/auto-group/${taskId}`);
};
const handleCopy = (taskId: string) => {
const taskToCopy = tasks.find((task) => task.id === taskId);
if (taskToCopy) {
const newTask = {
...taskToCopy,
id: `${Date.now()}`,
name: `${taskToCopy.name} (复制)`,
createTime: new Date().toISOString().replace('T', ' ').substring(0, 19),
};
setTasks([...tasks, newTask]);
toast({
title: '复制成功',
description: '已成功复制建群任务',
});
}
};
const toggleTaskStatus = (taskId: string) => {
const task = tasks.find((t) => t.id === taskId);
if (!task) return;
setTasks(
tasks.map((task) =>
task.id === taskId ? { ...task, status: task.status === 'running' ? 'paused' : 'running' } : task,
),
);
toast({
title: task.status === 'running' ? '已暂停' : '已启动',
description: `${task.name}任务${task.status === 'running' ? '已暂停' : '已启动'}`,
});
};
const handleCreateNew = () => {
navigate('/workspace/auto-group/new');
};
const filteredTasks = tasks.filter((task) =>
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
const getStatusColor = (status: string) => {
switch (status) {
case 'running':
return 'bg-green-100 text-green-800';
case 'paused':
return 'bg-gray-100 text-gray-800';
case 'completed':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'running':
return '进行中';
case 'paused':
return '已暂停';
case 'completed':
return '已完成';
default:
return '未知';
}
};
return (
<Layout
header={
<PageHeader
title="自动建群"
defaultBackPath="/workspace"
rightContent={
<Button onClick={handleCreateNew}>
<Plus className="h-4 w-4 mr-2" />
</Button>
}
/>
}
footer={<BottomNav />}
>
<div className="bg-gray-50 min-h-screen pb-20">
<div className="p-4">
{/* 搜索和筛选 */}
<Card className="p-4 mb-4">
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索任务名称"
className="pl-9"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</Card>
{/* 任务列表 */}
<div className="space-y-4">
{filteredTasks.length === 0 ? (
<Card className="p-8 text-center">
<UserPlus className="h-12 w-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 text-lg font-medium mb-2"></p>
<p className="text-gray-400 text-sm mb-4"></p>
<Button onClick={handleCreateNew}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</Card>
) : (
filteredTasks.map((task) => (
<Card key={task.id} className="p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<h3 className="font-medium">{task.name}</h3>
<Badge className={getStatusColor(task.status)}>
{getStatusText(task.status)}
</Badge>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={task.status === 'running'}
onCheckedChange={() => toggleTaskStatus(task.id)}
disabled={task.status === 'completed'}
/>
<CardMenu
onView={() => handleView(task.id)}
onEdit={() => handleEdit(task.id)}
onCopy={() => handleCopy(task.id)}
onDelete={() => handleDelete(task.id)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="text-sm text-gray-500">
<div>{task.deviceCount} </div>
<div>{task.targetFriends} </div>
</div>
<div className="text-sm text-gray-500">
<div>{task.createdGroups} </div>
<div>{task.creator}</div>
</div>
</div>
<div className="flex items-center justify-between text-xs text-gray-500 border-t pt-4">
<div className="flex items-center">
<Clock className="w-4 h-4 mr-1" />
{task.lastCreateTime}
</div>
<div className="flex items-center">
<span>{task.createTime}</span>
<Button
variant="ghost"
size="sm"
className="ml-2 p-0 h-6 w-6"
onClick={() => toggleExpand(task.id)}
>
{expandedTaskId === task.id ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</div>
</div>
{expandedTaskId === task.id && (
<div className="mt-4 pt-4 border-t border-dashed">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="flex items-center">
<Settings className="h-5 w-5 mr-2 text-gray-500" />
<h4 className="font-medium"></h4>
</div>
<div className="space-y-2 pl-7">
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span>{task.createInterval} </span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span>{task.maxGroupsPerDay} </span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span>
{task.timeRange.start} - {task.timeRange.end}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span>{task.groupSize.min}-{task.groupSize.max} </span>
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center">
<Users className="h-5 w-5 mr-2 text-gray-500" />
<h4 className="font-medium"></h4>
</div>
<div className="space-y-2 pl-7">
<div className="flex flex-wrap gap-2">
{task.targetTags.map((tag) => (
<Badge key={tag} variant="outline" className="bg-gray-50">
{tag}
</Badge>
))}
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center">
<UserPlus className="h-5 w-5 mr-2 text-gray-500" />
<h4 className="font-medium"></h4>
</div>
<div className="space-y-2 pl-7">
<div className="text-sm">
<div className="text-gray-500 mb-1"></div>
<div className="bg-gray-50 p-2 rounded text-xs">
{task.groupNameTemplate}
</div>
</div>
<div className="text-sm">
<div className="text-gray-500 mb-1"></div>
<div className="bg-gray-50 p-2 rounded text-xs">
{task.groupDescription}
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center">
<Calendar className="h-5 w-5 mr-2 text-gray-500" />
<h4 className="font-medium"></h4>
</div>
<div className="space-y-2 pl-7">
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-500"></span>
<span>
{task.createdGroups} / {task.maxGroupsPerDay}
</span>
</div>
<Progress
value={(task.createdGroups / task.maxGroupsPerDay) * 100}
className="h-2"
/>
</div>
</div>
</div>
</div>
)}
</Card>
))
)}
</div>
</div>
</div>
</Layout>
);
}

View File

@@ -1,414 +0,0 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
ChevronLeft,
Search,
Filter,
RefreshCw,
AlertCircle,
CheckCircle2,
Users,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area';
import Layout from '@/components/Layout';
import PageHeader from '@/components/PageHeader';
import { useToast } from '@/components/ui/toast';
import '@/components/Layout.css';
// 群组成员接口
interface GroupMember {
id: string;
nickname: string;
wechatId: string;
tags: string[];
}
// 群组接口
interface Group {
id: string;
members: GroupMember[];
}
// 建群任务详情接口
interface GroupTaskDetail {
id: string;
name: string;
status: 'preparing' | 'creating' | 'completed' | 'paused';
totalGroups: number;
currentGroupIndex: number;
groups: Group[];
createTime: string;
lastUpdateTime: string;
creator: string;
deviceCount: number;
targetFriends: number;
groupSize: { min: number; max: number };
timeRange: { start: string; end: string };
targetTags: string[];
groupNameTemplate: string;
groupDescription: string;
}
// 群组预览组件
function GroupPreview({
groupIndex,
members,
isCreating,
isCompleted,
onRetry
}: {
groupIndex: number;
members: GroupMember[];
isCreating: boolean;
isCompleted: boolean;
onRetry?: () => void;
}) {
const [expanded, setExpanded] = useState(false);
const targetSize = 38; // 微信群人数固定为38人
return (
<Card className="w-full">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-medium">
{groupIndex + 1}
<Badge
variant={isCompleted ? "default" : isCreating ? "secondary" : "outline"}
className="ml-2"
>
{isCompleted ? "已完成" : isCreating ? "创建中" : "等待中"}
</Badge>
</CardTitle>
<div className="flex items-center space-x-2">
<Users className="w-4 h-4 text-gray-500" />
<span className="text-sm text-gray-500">{members.length}/{targetSize}</span>
</div>
</div>
</CardHeader>
<CardContent>
{isCreating && !isCompleted && (
<div className="mb-4">
<Progress value={Math.round((members.length / targetSize) * 100)} />
</div>
)}
{expanded ? (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
{members.map((member) => (
<div key={member.id} className="text-sm flex items-center space-x-2 bg-gray-50 p-2 rounded">
<span className="truncate">{member.nickname}</span>
{member.tags.length > 0 && (
<Badge variant="outline" className="text-xs">
{member.tags[0]}
</Badge>
)}
</div>
))}
</div>
<Button variant="ghost" size="sm" className="w-full mt-2" onClick={() => setExpanded(false)}>
</Button>
</div>
) : (
<Button variant="ghost" size="sm" className="w-full" onClick={() => setExpanded(true)}>
({members.length})
</Button>
)}
{!isCompleted && members.length < targetSize && (
<div className="mt-4 flex items-center text-amber-500 text-sm">
<AlertCircle className="w-4 h-4 mr-2" />
{targetSize}
{onRetry && (
<Button variant="ghost" size="sm" className="ml-2 text-blue-500" onClick={onRetry}>
</Button>
)}
</div>
)}
{isCompleted && (
<div className="mt-4 flex items-center text-green-500 text-sm">
<CheckCircle2 className="w-4 h-4 mr-2" />
</div>
)}
</CardContent>
</Card>
);
}
// 建群进度组件
function GroupCreationProgress({
taskDetail,
onComplete
}: {
taskDetail: GroupTaskDetail;
onComplete: () => void;
}) {
const [groups, setGroups] = useState<Group[]>(taskDetail.groups);
const [currentGroupIndex, setCurrentGroupIndex] = useState(taskDetail.currentGroupIndex);
const [status, setStatus] = useState<'preparing' | 'creating' | 'completed'>(taskDetail.status as any);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// 模拟建群进度更新
if (status === 'creating' && currentGroupIndex < groups.length) {
const timer = setTimeout(() => {
if (currentGroupIndex === groups.length - 1) {
setStatus('completed');
onComplete();
} else {
setCurrentGroupIndex((prev) => prev + 1);
}
}, 3000);
return () => clearTimeout(timer);
}
}, [status, currentGroupIndex, groups.length, onComplete]);
const handleRetryGroup = (groupIndex: number) => {
// 模拟重试逻辑
setGroups((prev) =>
prev.map((group, index) => {
if (index === groupIndex) {
return {
...group,
members: [
...group.members,
{
id: `retry-member-${Date.now()}`,
nickname: `补充用户${group.members.length + 1}`,
wechatId: `wx_retry_${Date.now()}`,
tags: ['新加入'],
},
],
};
}
return group;
}),
);
};
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-medium">
<Badge className="ml-2">
{status === "preparing" ? "准备中" : status === "creating" ? "创建中" : "已完成"}
</Badge>
</CardTitle>
<div className="text-sm text-gray-500">
{currentGroupIndex + 1}/{groups.length}
</div>
</div>
</CardHeader>
<CardContent>
<Progress value={Math.round(((currentGroupIndex + 1) / groups.length) * 100)} className="h-2" />
</CardContent>
</Card>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<ScrollArea className="h-[calc(100vh-400px)]">
<div className="space-y-4">
{groups.map((group, index) => (
<GroupPreview
key={group.id}
groupIndex={index}
members={group.members}
isCreating={status === "creating" && index === currentGroupIndex}
isCompleted={status === "completed" || index < currentGroupIndex}
onRetry={() => handleRetryGroup(index)}
/>
))}
</div>
</ScrollArea>
{status === "completed" && (
<Alert>
<CheckCircle2 className="h-4 w-4" />
<AlertDescription></AlertDescription>
</Alert>
)}
</div>
);
}
export default function AutoGroupDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [taskDetail, setTaskDetail] = useState<GroupTaskDetail | null>(null);
// 模拟获取任务详情
useEffect(() => {
const fetchTaskDetail = async () => {
setLoading(true);
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000));
// 模拟数据
const mockTaskDetail: GroupTaskDetail = {
id: id || '1',
name: 'VIP客户建群',
status: 'creating',
totalGroups: 5,
currentGroupIndex: 2,
groups: Array.from({ length: 5 }).map((_, index) => ({
id: `group-${index}`,
members: Array.from({ length: Math.floor(Math.random() * 10) + 30 }).map((_, mIndex) => ({
id: `member-${index}-${mIndex}`,
nickname: `用户${mIndex + 1}`,
wechatId: `wx_${mIndex}`,
tags: [`标签${(mIndex % 3) + 1}`],
})),
})),
createTime: '2024-11-20 19:04:14',
lastUpdateTime: '2025-02-06 13:12:35',
creator: 'admin',
deviceCount: 2,
targetFriends: 156,
groupSize: { min: 20, max: 50 },
timeRange: { start: '09:00', end: '21:00' },
targetTags: ['VIP客户', '高价值'],
groupNameTemplate: 'VIP客户交流群{序号}',
groupDescription: 'VIP客户专属交流群提供优质服务',
};
setTaskDetail(mockTaskDetail);
} catch (error) {
console.error('获取任务详情失败:', error);
toast({
title: '获取任务详情失败',
description: '请稍后重试',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
if (id) {
fetchTaskDetail();
}
}, [id, toast]);
const handleComplete = () => {
toast({
title: '建群完成',
description: '所有群组已创建完成',
});
};
if (loading) {
return (
<Layout
header={
<PageHeader
title="建群详情"
defaultBackPath="/workspace/auto-group"
/>
}
>
<div className="bg-gray-50 min-h-screen pb-20">
<div className="p-4">
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
<div className="text-gray-500">...</div>
</div>
</div>
</div>
</div>
</Layout>
);
}
if (!taskDetail) {
return (
<Layout
header={
<PageHeader
title="建群详情"
defaultBackPath="/workspace/auto-group"
/>
}
>
<div className="bg-gray-50 min-h-screen pb-20">
<div className="p-4">
<Card className="p-8 text-center">
<AlertCircle className="h-12 w-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 text-lg font-medium mb-2"></p>
<p className="text-gray-400 text-sm mb-4">ID是否正确</p>
<Button onClick={() => navigate('/workspace/auto-group')}>
</Button>
</Card>
</div>
</div>
</Layout>
);
}
return (
<Layout
header={
<PageHeader
title={`${taskDetail.name} - 建群详情`}
defaultBackPath="/workspace/auto-group"
/>
}
>
<div className="bg-gray-50 min-h-screen pb-20">
<div className="p-4">
{/* 任务基本信息 */}
<Card className="p-4 mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h3 className="font-medium mb-2"></h3>
<div className="space-y-1 text-sm text-gray-600">
<div>{taskDetail.name}</div>
<div>{taskDetail.createTime}</div>
<div>{taskDetail.creator}</div>
<div>{taskDetail.deviceCount} </div>
</div>
</div>
<div>
<h3 className="font-medium mb-2"></h3>
<div className="space-y-1 text-sm text-gray-600">
<div>{taskDetail.groupSize.min}-{taskDetail.groupSize.max} </div>
<div>{taskDetail.timeRange.start} - {taskDetail.timeRange.end}</div>
<div>{taskDetail.targetTags.join(', ')}</div>
<div>{taskDetail.groupNameTemplate}</div>
</div>
</div>
</div>
</Card>
{/* 建群进度 */}
<GroupCreationProgress
taskDetail={taskDetail}
onComplete={handleComplete}
/>
</div>
</div>
</Layout>
);
}

View File

@@ -1,305 +0,0 @@
import React, { useState } from "react";
import {
MoreVertical,
Eye,
Edit,
Copy,
Trash2,
Plus,
Search,
RefreshCw,
ThumbsUp,
ChevronLeft,
} from "lucide-react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { useNavigate } from "react-router-dom";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/components/ui/toast";
import { fetchAutoLikeTasks, deleteAutoLikeTask, toggleAutoLikeTask, copyAutoLikeTask, LikeTask } from '@/api/autoLike';
type CardMenuProps = {
onView: () => void;
onEdit: () => void;
onCopy: () => void;
onDelete: () => void;
};
function CardMenu({ onView, onEdit, onCopy, onDelete }: CardMenuProps) {
const [open, setOpen] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
if (open) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
return (
<div style={{ position: "relative" }}>
<button onClick={() => setOpen((v) => !v)} style={{ background: "none", border: "none", padding: 0, margin: 0, cursor: "pointer" }}>
<MoreVertical className="h-4 w-4" />
</button>
{open && (
<div
ref={menuRef}
style={{
position: "absolute",
right: 0,
top: 28,
background: "#fff",
borderRadius: 8,
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
zIndex: 100,
minWidth: 120,
padding: 4,
}}
>
<div onClick={() => { onView(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
<Eye className="h-4 w-4 mr-2" />
</div>
<div onClick={() => { onEdit(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
<Edit className="h-4 w-4 mr-2" />
</div>
<div onClick={() => { onCopy(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
<Copy className="h-4 w-4 mr-2" />
</div>
<div onClick={() => { onDelete(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, color: "#e53e3e", transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
<Trash2 className="h-4 w-4 mr-2" />
</div>
</div>
)}
</div>
);
}
export default function AutoLike() {
const navigate = useNavigate();
const { toast } = useToast();
const [tasks, setTasks] = React.useState<LikeTask[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [loading, setLoading] = useState(false);
// 1. fetchTasks 不用 useCallback直接定义
async function fetchTasks() {
setLoading(true);
try {
const list = await fetchAutoLikeTasks();
// 确保数据字段与旧项目一致
const mappedTasks = list.map(task => ({
...task,
// 确保字段名称和格式与旧项目一致
status: task.status || 2, // 默认为关闭状态
deviceCount: task.deviceCount || 0,
targetGroup: task.targetGroup || '默认人群',
likeCount: task.todayLikeCount || task.likeCount || 0,
creator: task.creator || '未知',
lastLikeTime: task.lastLikeTime || '暂无',
createTime: task.createTime || '未知',
likeInterval: task.likeInterval || 5,
maxLikesPerDay: task.maxLikesPerDay || 200,
timeRange: task.timeRange || { start: '08:00', end: '22:00' },
contentTypes: task.contentTypes || ['text', 'image', 'video'],
targetTags: task.targetTags || []
}));
setTasks(mappedTasks);
} catch (error) {
toast({ title: "获取任务失败", variant: "destructive" });
} finally {
setLoading(false);
}
}
// 2. useEffect 里直接调用
React.useEffect(() => {
fetchTasks();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleDelete = async (id: string) => {
if (!window.confirm("确定要删除该任务吗?")) return;
try {
const response = await deleteAutoLikeTask(id);
if (response.code === 200) {
toast({ title: "删除成功" });
fetchTasks();
} else {
toast({ title: "删除失败", description: response.msg || "请稍后重试", variant: "destructive" });
}
} catch (error) {
toast({ title: "删除失败", description: "请稍后重试", variant: "destructive" });
}
};
const handleEdit = (taskId: string) => {
navigate(`/workspace/auto-like/${taskId}/edit`);
};
const handleView = (taskId: string) => {
navigate(`/workspace/auto-like/${taskId}`);
};
const handleCopy = async (id: string) => {
try {
const response = await copyAutoLikeTask(id);
if (response.code === 200) {
toast({ title: "复制成功" });
fetchTasks();
} else {
toast({ title: "复制失败", description: response.msg || "请稍后重试", variant: "destructive" });
}
} catch (error) {
toast({ title: "复制失败", description: "请稍后重试", variant: "destructive" });
}
};
const toggleTaskStatus = async (id: string, status: number) => {
// 先更新本地状态
const newStatus = (status === 1 ? 2 : 1) as 1 | 2;
setTasks(prevTasks =>
prevTasks.map(task =>
task.id === id ? { ...task, status: newStatus } : task
)
);
try {
const response = await toggleAutoLikeTask(id, String(newStatus));
if (response.code === 200) {
toast({ title: "操作成功" });
// 成功时不刷新列表,保持本地状态
} else {
// 请求失败,回退本地状态
setTasks(prevTasks =>
prevTasks.map(task =>
task.id === id ? { ...task, status: status as 1 | 2 } : task
)
);
toast({ title: "操作失败", description: response.msg || "请稍后重试", variant: "destructive" });
}
} catch (error) {
// 请求异常,回退本地状态
setTasks(prevTasks =>
prevTasks.map(task =>
task.id === id ? { ...task, status: status as 1 | 2 } : task
)
);
toast({ title: "操作失败", description: "请稍后重试", variant: "destructive" });
}
};
const handleCreateNew = () => {
navigate("/workspace/auto-like/new");
};
const filteredTasks = tasks.filter((task) =>
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<div className="flex-1 bg-gray-50 min-h-screen pb-20">
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between p-4">
<div className="flex items-center space-x-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="text-lg font-medium"></h1>
</div>
<Button onClick={handleCreateNew}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</header>
<div className="p-4">
<Card className="p-4 mb-4">
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input placeholder="搜索任务名称" className="pl-9" value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
</div>
<Button variant="outline" size="icon" onClick={fetchTasks} disabled={loading}>
{loading ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
</Card>
<div className="space-y-4">
{filteredTasks.map((task) => (
<Card key={task.id} className="p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<h3 className="font-medium">{task.name}</h3>
<Badge variant={Number(task.status) === 1 ? "success" : "secondary"}>
{Number(task.status) === 1 ? "进行中" : "已暂停"}
</Badge>
</div>
<div className="flex items-center space-x-2">
<Switch checked={Number(task.status) === 1} onCheckedChange={() => toggleTaskStatus(task.id, Number(task.status))} />
<CardMenu
onView={() => handleView(task.id)}
onEdit={() => handleEdit(task.id)}
onCopy={() => handleCopy(task.id)}
onDelete={() => handleDelete(task.id)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-6 mb-6">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500 font-medium"></span>
<span className="text-sm text-gray-900 font-semibold">{task.deviceCount} </span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500 font-medium"></span>
<span className="text-sm text-gray-900 font-semibold">{task.targetGroup}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500 font-medium"></span>
<span className="text-sm text-gray-900 font-semibold">{task.updateTime}</span>
</div>
</div>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500 font-medium"></span>
<span className="text-sm text-gray-900 font-semibold">{task.likeInterval} </span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500 font-medium"></span>
<span className="text-sm text-gray-900 font-semibold">{task.maxLikesPerDay} </span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500 font-medium"></span>
<span className="text-sm text-gray-900 font-semibold">{task.createTime}</span>
</div>
</div>
</div>
<div className="flex items-center justify-between text-sm text-gray-500 border-t pt-5">
<div className="flex items-center space-x-2">
<ThumbsUp className="w-4 h-4 text-blue-500" />
<span className="font-medium"></span>
<span className="text-gray-900 font-semibold">{task.lastLikeTime}</span>
</div>
<div className="flex items-center space-x-2">
<ThumbsUp className="w-4 h-4 text-green-500" />
<span className="font-medium"></span>
<span className="text-gray-900 font-semibold">{task.totalLikeCount || 0}</span>
</div>
</div>
</Card>
))}
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More