Merge branch 'develop' into yongpxu-dev

# Conflicts:
#	nkebao/postcss.config.js   resolved by develop version
#	nkebao/技术栈.md   resolved by develop version
This commit is contained in:
超级老白兔
2025-08-12 09:33:39 +08:00
304 changed files with 865 additions and 57082 deletions

View File

@@ -97,6 +97,11 @@ Route::group('v1', function () {
Route::get('autoCreate', 'app\api\controller\AllotRuleController@autoCreateAllotRules');// 自动创建分配规则 √
});
// CallRecording控制器路由
Route::group('call-recording', function () {
Route::get('list', 'app\api\controller\CallRecordingController@getlist'); // 获取通话记录列表 √
});
});
});

View File

@@ -0,0 +1,142 @@
<?php
namespace app\api\controller;
use app\api\model\CompanyAccountModel;
use app\api\model\CompanyModel;
use app\api\model\CallRecordingModel;
use Library\S2\Logics\AccountLogic;
use think\Db;
use think\facade\Request;
/**
* 通话记录控制器
* 包含通话记录管理的相关功能
*/
class CallRecordingController extends BaseController
{
/**
* 获取通话记录列表
* @param array $data 请求参数
* @param bool $isInner 是否为定时任务调用
* @return \think\response\Json
*/
public function getlist($data = [], $isInner = false)
{
// 获取请求参数
$keyword = !empty($data['keyword']) ? $data['keyword'] : '';
$isCallOut = !empty($data['isCallOut']) ? $data['isCallOut'] : '';
$secondMin = !empty($data['secondMin']) ? $data['secondMin'] : 0;
$secondMax = !empty($data['secondMax']) ? $data['secondMax'] : 99999;
$departmentIds = !empty($data['departmentIds']) ? $data['departmentIds'] : '';
$pageIndex = !empty($data['pageIndex']) ? $data['pageIndex'] : 0;
$pageSize = !empty($data['pageSize']) ? $data['pageSize'] : 100;
$from = !empty($data['from']) ? $data['from'] : '2016-01-01 00:00:00';
$to = !empty($data['to']) ? $data['to'] : '2025-08-31 00:00:00';
$departmentId = !empty($data['departmentId']) ? $data['departmentId'] : '';
// 获取授权token
$authorization = trim($this->request->header('authorization', $this->authorization));
if (empty($authorization)) {
if ($isInner) {
return json_encode(['code' => 500, 'msg' => '缺少授权信息']);
} else {
return errorJson('缺少授权信息');
}
}
try {
// 构建请求参数
$params = [
'keyword' => $keyword,
'isCallOut' => $isCallOut,
'secondMin' => $secondMin,
'secondMax' => $secondMax,
'departmentIds' => $departmentIds,
'pageIndex' => $pageIndex,
'pageSize' => $pageSize,
'from' => $from,
'to' => $to,
'departmentId' => $departmentId
];
// 设置请求头
$headerData = ['client:system'];
$header = setHeader($headerData, $authorization, 'plain');
// 发送请求获取通话记录列表
$result = requestCurl($this->baseUrl . 'api/CallRecording/list', $params, 'GET', $header);
$response = handleApiResponse($result);
// 保存数据到数据库
if (!empty($response['results'])) {
foreach ($response['results'] as $item) {
$this->saveCallRecording($item);
}
}
if ($isInner) {
return json_encode(['code' => 200, 'msg' => '获取通话记录列表成功', 'data' => $response]);
} else {
return successJson($response, '获取通话记录列表成功');
}
} catch (\Exception $e) {
if ($isInner) {
return json_encode(['code' => 500, 'msg' => '获取通话记录列表失败:' . $e->getMessage()]);
} else {
return errorJson('获取通话记录列表失败:' . $e->getMessage());
}
}
}
/**
* 保存通话记录数据到数据库
* @param array $item 通话记录数据
*/
private function saveCallRecording($item)
{
// 将时间戳转换为秒级时间戳API返回的是毫秒级
$beginTime = isset($item['beginTime']) ? intval($item['beginTime'] / 1000) : 0;
$endTime = isset($item['endTime']) ? intval($item['endTime'] / 1000) : 0;
$callBeginTime = isset($item['callBeginTime']) ? intval($item['callBeginTime'] / 1000) : 0;
// 将日期时间字符串转换为时间戳
$createTime = isset($item['createTime']) ? strtotime($item['createTime']) : 0;
$lastUpdateTime = isset($item['lastUpdateTime']) ? strtotime($item['lastUpdateTime']) : 0;
$data = [
'id' => isset($item['id']) ? $item['id'] : 0,
'tenantId' => isset($item['tenantId']) ? $item['tenantId'] : 0,
'deviceOwnerId' => isset($item['deviceOwnerId']) ? $item['deviceOwnerId'] : 0,
'userName' => isset($item['userName']) ? $item['userName'] : '',
'nickname' => isset($item['nickname']) ? $item['nickname'] : '',
'realName' => isset($item['realName']) ? $item['realName'] : '',
'deviceMemo' => isset($item['deviceMemo']) ? $item['deviceMemo'] : '',
'fileName' => isset($item['fileName']) ? $item['fileName'] : '',
'imei' => isset($item['imei']) ? $item['imei'] : '',
'phone' => isset($item['phone']) ? $item['phone'] : '',
'isCallOut' => isset($item['isCallOut']) ? $item['isCallOut'] : false,
'beginTime' => $beginTime,
'endTime' => $endTime,
'audioUrl' => isset($item['audioUrl']) ? $item['audioUrl'] : '',
'mp3AudioUrl' => isset($item['mp3AudioUrl']) ? $item['mp3AudioUrl'] : '',
'callBeginTime' => $callBeginTime,
'callLogId' => isset($item['callLogId']) ? $item['callLogId'] : 0,
'callType' => isset($item['callType']) ? $item['callType'] : 0,
'duration' => isset($item['duration']) ? $item['duration'] : 0,
'skipReason' => isset($item['skipReason']) ? $item['skipReason'] : '',
'skipUpload' => isset($item['skipUpload']) ? $item['skipUpload'] : false,
'isDeleted' => isset($item['isDeleted']) ? $item['isDeleted'] : false,
'createTime' => $createTime,
'lastUpdateTime' => $lastUpdateTime
];
// 使用id作为唯一性判断
$callRecording = CallRecordingModel::where('id', $item['id'])->find();
if ($callRecording) {
$callRecording->save($data);
} else {
CallRecordingModel::create($data);
}
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace app\api\model;
use think\Model;
class CallRecordingModel extends Model
{
// 设置表名
protected $table = 's2_call_recording';
}

View File

@@ -34,4 +34,5 @@ return [
'workbench:trafficDistribute' => 'app\command\WorkbenchTrafficDistributeCommand', // 工作台流量分发任务
'workbench:groupPush' => 'app\command\WorkbenchGroupPushCommand', // 工作台群组同步任务
'switch:friends' => 'app\command\SwitchFriendsCommand',
'call-recording:list' => 'app\command\CallRecordingListCommand', // 通话记录列表 √
];

View File

@@ -0,0 +1,57 @@
<?php
namespace app\command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\facade\Log;
use think\Queue;
use app\job\CallRecordingListJob;
class CallRecordingListCommand extends Command
{
protected function configure()
{
$this->setName('call-recording:list')
->setDescription('获取通话记录列表,并根据分页自动处理下一页');
}
protected function execute(Input $input, Output $output)
{
$output->writeln('开始处理通话记录列表任务...');
try {
// 初始页码
$pageIndex = 0;
$pageSize = 100; // 每页获取100条记录
// 将第一页任务添加到队列
$this->addToQueue($pageIndex, $pageSize);
$output->writeln('通话记录列表任务已添加到队列');
} catch (\Exception $e) {
Log::error('通话记录列表任务添加失败:' . $e->getMessage());
$output->writeln('通话记录列表任务添加失败:' . $e->getMessage());
return false;
}
return true;
}
/**
* 添加任务到队列
* @param int $pageIndex 页码
* @param int $pageSize 每页大小
*/
protected function addToQueue($pageIndex, $pageSize)
{
$data = [
'pageIndex' => $pageIndex,
'pageSize' => $pageSize
];
// 添加到队列,设置任务名为 call_recording_list
Queue::push(CallRecordingListJob::class, $data, 'call_recording_list');
}
}

View File

@@ -58,8 +58,8 @@ class ContentLibraryController extends Controller
$data = [
'name' => $param['name'],
// 数据来源配置
'sourceFriends' => $sourceType == 1 ? json_encode($param['friends']) : json_encode([]), // 选择的微信好友
'sourceGroups' => $sourceType == 2 ? json_encode($param['groups']) : json_encode([]), // 选择的微信群
'sourceFriends' => $sourceType == 1 ? json_encode($param['friendsGroups']) : json_encode([]), // 选择的微信好友
'sourceGroups' => $sourceType == 2 ? json_encode($param['wechatGroups']) : json_encode([]), // 选择的微信群
'groupMembers' => $sourceType == 2 ? json_encode($param['groupMembers']) : json_encode([]), // 群组成员
// 关键词配置
'keywordInclude' => $keywordInclude, // 包含的关键词
@@ -220,11 +220,12 @@ class ContentLibraryController extends Controller
}
// 处理JSON字段转数组
$library['sourceFriends'] = json_decode($library['sourceFriends'] ?: '[]', true);
$library['sourceGroups'] = json_decode($library['sourceGroups'] ?: '[]', true);
$library['friendsGroups'] = json_decode($library['sourceFriends'] ?: '[]', true);
$library['wechatGroups'] = json_decode($library['sourceGroups'] ?: '[]', true);
$library['keywordInclude'] = json_decode($library['keywordInclude'] ?: '[]', true);
$library['keywordExclude'] = json_decode($library['keywordExclude'] ?: '[]', true);
$library['groupMembers'] = json_decode($library['groupMembers'] ?: '[]', true);
unset($library['sourceFriends'],$library['sourceGroups']);
// 将时间戳转换为日期格式(精确到日)
if (!empty($library['timeStart'])) {
@@ -235,8 +236,8 @@ class ContentLibraryController extends Controller
}
// 获取好友详细信息
if (!empty($library['sourceFriends'])) {
$friendIds = $library['sourceFriends'];
if (!empty($library['friendsGroups'])) {
$friendIds = $library['friendsGroups'];
$friendsInfo = [];
if (!empty($friendIds)) {
@@ -249,12 +250,14 @@ class ContentLibraryController extends Controller
}
// 将好友信息添加到返回数据中
$library['selectedFriends'] = $friendsInfo;
$library['friendsGroupsOptions'] = $friendsInfo;
}else{
$library['friendsGroupsOptions'] = [];
}
// 获取群组详细信息
if (!empty($library['sourceGroups'])) {
$groupIds = $library['sourceGroups'];
if (!empty($library['wechatGroups'])) {
$groupIds = $library['wechatGroups'];
$groupsInfo = [];
if (!empty($groupIds)) {
@@ -267,7 +270,9 @@ class ContentLibraryController extends Controller
}
// 将群组信息添加到返回数据中
$library['selectedGroups'] = $groupsInfo;
$library['wechatGroupsOptions'] = $groupsInfo;
}else{
$library['wechatGroupsOptions'] = [];
}
return json([
@@ -319,8 +324,8 @@ class ContentLibraryController extends Controller
// 更新内容库基本信息
$library->name = $param['name'];
$library->sourceType = isset($param['sourceType']) ? $param['sourceType'] : 1;
$library->sourceFriends = $param['sourceType'] == 1 ? json_encode($param['friends']) : json_encode([]);
$library->sourceGroups = $param['sourceType'] == 2 ? json_encode($param['groups']) : json_encode([]);
$library->sourceFriends = $param['sourceType'] == 1 ? json_encode($param['friendsGroups']) : json_encode([]);
$library->sourceGroups = $param['sourceType'] == 2 ? json_encode($param['wechatGroups']) : json_encode([]);
$library->groupMembers = $param['sourceType'] == 2 ? json_encode($param['groupMembers']) : json_encode([]);
$library->keywordInclude = $keywordInclude;
$library->keywordExclude = $keywordExclude;

View File

@@ -75,8 +75,8 @@ class WorkbenchController extends Controller
$config->startTime = $param['startTime'];
$config->endTime = $param['endTime'];
$config->contentTypes = json_encode($param['contentTypes']);
$config->devices = json_encode($param['devices']);
$config->friends = json_encode($param['friends']);
$config->devices = json_encode($param['deveiceGroups']);
$config->friends = json_encode($param['friendsGroups']);
// $config->targetGroups = json_encode($param['targetGroups']);
// $config->tagOperator = $param['tagOperator'];
$config->friendMaxLikes = $param['friendMaxLikes'];
@@ -95,8 +95,8 @@ class WorkbenchController extends Controller
$config->startTime = $param['startTime'];
$config->endTime = $param['endTime'];
$config->accountType = $param['accountType'];
$config->devices = json_encode($param['devices']);
$config->contentLibraries = json_encode($param['contentLibraries'] ?? []);
$config->devices = json_encode($param['deveiceGroups']);
$config->contentLibraries = json_encode($param['contentGroups'] ?? []);
$config->createTime = time();
$config->updateTime = time();
$config->save();
@@ -111,8 +111,10 @@ class WorkbenchController extends Controller
$config->pushOrder = $param['pushOrder']; // 推送顺序
$config->isLoop = !empty($param['isLoop']) ? 1 : 0; // 是否循环
$config->status = !empty($param['status']) ? 1 : 0; // 是否启用
$config->groups = json_encode($param['groups'], JSON_UNESCAPED_UNICODE); // 群组信息
$config->contentLibraries = json_encode($param['contentLibraries'], JSON_UNESCAPED_UNICODE); // 内容库信息
$config->groups = json_encode($param['wechatGroups'], JSON_UNESCAPED_UNICODE); // 群组信息
$config->contentLibraries = json_encode($param['contentGroups'], JSON_UNESCAPED_UNICODE); // 内容库信息
$config->socialMediaId = !empty($param['socialMediaId']) ? $param['socialMediaId'] : '';
$config->promotionSiteId = !empty($param['promotionSiteId']) ? $param['promotionSiteId'] : '';
$config->createTime = time();
$config->updateTime = time();
$config->save();
@@ -123,7 +125,7 @@ class WorkbenchController extends Controller
$config->groupNamePrefix = $param['groupNamePrefix'];
$config->maxGroups = $param['maxGroups'];
$config->membersPerGroup = $param['membersPerGroup'];
$config->devices = json_encode($param['devices']);
$config->devices = json_encode($param['deveiceGroups']);
$config->targetGroups = json_encode($param['targetGroups']);
$config->createTime = time();
$config->updateTime = time();
@@ -137,9 +139,9 @@ class WorkbenchController extends Controller
$config->timeType = $param['timeType'];
$config->startTime = $param['startTime'];
$config->endTime = $param['endTime'];
$config->devices = json_encode($param['devices'], JSON_UNESCAPED_UNICODE);
$config->devices = json_encode($param['deveiceGroups'], JSON_UNESCAPED_UNICODE);
$config->pools = json_encode($param['pools'], JSON_UNESCAPED_UNICODE);
$config->account = json_encode($param['account'], JSON_UNESCAPED_UNICODE);
$config->account = json_encode($param['accountGroups'], JSON_UNESCAPED_UNICODE);
$config->createTime = time();
$config->updateTime = time();
$config->save();
@@ -169,12 +171,12 @@ class WorkbenchController extends Controller
['userId', '=', $this->request->userInfo['id']],
['isDel', '=', 0]
];
// 添加类型筛选
if ($type !== '') {
$where[] = ['type', '=', $type];
}
// 添加名称模糊搜索
if ($keyword !== '') {
$where[] = ['name', 'like', '%' . $keyword . '%'];
@@ -182,19 +184,19 @@ class WorkbenchController extends Controller
// 定义关联关系
$with = [
'autoLike' => function($query) {
'autoLike' => function ($query) {
$query->field('workbenchId,interval,maxLikes,startTime,endTime,contentTypes,devices,friends');
},
'momentsSync' => function($query) {
'momentsSync' => function ($query) {
$query->field('workbenchId,syncInterval,syncCount,syncType,startTime,endTime,accountType,devices,contentLibraries');
},
'trafficConfig' => function($query) {
'trafficConfig' => function ($query) {
$query->field('workbenchId,distributeType,maxPerDay,timeType,startTime,endTime,devices,pools,account');
},
'groupPush' => function($query) {
'groupPush' => function ($query) {
$query->field('workbenchId,pushType,startTime,endTime,maxPerDay,pushOrder,isLoop,status,groups,contentLibraries');
},
'user' => function($query) {
'user' => function ($query) {
$query->field('id,username');
}
];
@@ -212,9 +214,9 @@ class WorkbenchController extends Controller
if (!empty($item->autoLike)) {
$item->config = $item->autoLike;
$item->config->devices = json_decode($item->config->devices, true);
$item->config->contentTypes = json_decode($item->config->contentTypes, true);
$item->config->contentTypes = json_decode($item->config->contentTypes, true);
$item->config->friends = json_decode($item->config->friends, true);
// 添加今日点赞数
$startTime = strtotime(date('Y-m-d') . ' 00:00:00');
$endTime = strtotime(date('Y-m-d') . ' 23:59:59');
@@ -222,16 +224,16 @@ class WorkbenchController extends Controller
->where('workbenchId', $item->id)
->whereTime('createTime', 'between', [$startTime, $endTime])
->count();
// 添加总点赞数
$totalLikeCount = Db::name('workbench_auto_like_item')
->where('workbenchId', $item->id)
->count();
$item->config->todayLikeCount = $todayLikeCount;
$item->config->totalLikeCount = $totalLikeCount;
}
unset($item->autoLike,$item->auto_like);
unset($item->autoLike, $item->auto_like);
break;
case self::TYPE_MOMENTS_SYNC:
if (!empty($item->momentsSync)) {
@@ -242,7 +244,7 @@ class WorkbenchController extends Controller
$sendNum = Db::name('workbench_moments_sync_item')->where(['workbenchId' => $item->id])->count();
$item->syncCount = $sendNum;
$lastTime = Db::name('workbench_moments_sync_item')->where(['workbenchId' => $item->id])->order('id DESC')->value('createTime');
$item->lastSyncTime = !empty($lastTime) ? date('Y-m-d H:i',$lastTime) : '--';
$item->lastSyncTime = !empty($lastTime) ? date('Y-m-d H:i', $lastTime) : '--';
// 获取内容库名称
@@ -254,7 +256,7 @@ class WorkbenchController extends Controller
$item->config->contentLibraryNames = [];
}
}
unset($item->momentsSync,$item->moments_sync);
unset($item->momentsSync, $item->moments_sync);
break;
case self::TYPE_GROUP_PUSH:
if (!empty($item->groupPush)) {
@@ -270,7 +272,7 @@ class WorkbenchController extends Controller
$item->config->contentLibraries = json_decode($item->config->contentLibraries, true);
$item->config->lastPushTime = '22222';
}
unset($item->groupPush,$item->group_push);
unset($item->groupPush, $item->group_push);
break;
case self::TYPE_GROUP_CREATE:
if (!empty($item->groupCreate)) {
@@ -278,7 +280,7 @@ class WorkbenchController extends Controller
$item->config->devices = json_decode($item->config->devices, true);
$item->config->targetGroups = json_decode($item->config->targetGroups, true);
}
unset($item->groupCreate,$item->group_create);
unset($item->groupCreate, $item->group_create);
break;
case self::TYPE_TRAFFIC_DISTRIBUTION:
if (!empty($item->trafficConfig)) {
@@ -287,10 +289,10 @@ class WorkbenchController extends Controller
$item->config->pools = json_decode($item->config->pools, true);
$item->config->account = json_decode($item->config->account, true);
$config_item = Db::name('workbench_traffic_config_item')->where(['workbenchId' => $item->id])->order('id DESC')->find();
$item->config->lastUpdated = !empty($config_item) ? date('Y-m-d H:i',$config_item['createTime']) : '--';
$item->config->lastUpdated = !empty($config_item) ? date('Y-m-d H:i', $config_item['createTime']) : '--';
//统计
$labels = $item->config->pools;
$labels = $item->config->pools;
$totalUsers = Db::table('s2_wechat_friend')->alias('wf')
->join(['s2_company_account' => 'sa'], 'sa.id = wf.accountId', 'left')
->join(['s2_wechat_account' => 'wa'], 'wa.id = wf.wechatAccountId', 'left')
@@ -299,8 +301,8 @@ class WorkbenchController extends Controller
['sa.departmentId', '=', $item->companyId]
])
->whereIn('wa.currentDeviceId', $item->config->devices);
if(!empty($labels) && count($labels) > 0){
if (!empty($labels) && count($labels) > 0) {
$totalUsers = $totalUsers->where(function ($q) use ($labels) {
foreach ($labels as $label) {
$q->whereOrRaw("JSON_CONTAINS(wf.labels, '\"{$label}\"')");
@@ -314,13 +316,13 @@ class WorkbenchController extends Controller
$totalAccounts = count($item->config->account);
$dailyAverage = Db::name('workbench_traffic_config_item')
->where('workbenchId', $item->id)
->count();
->where('workbenchId', $item->id)
->count();
$day = (time() - strtotime($item->createTime)) / 86400;
$day = intval($day);
if($dailyAverage > 0 && $totalAccounts > 0 && $day > 0){
if ($dailyAverage > 0 && $totalAccounts > 0 && $day > 0) {
$dailyAverage = $dailyAverage / $totalAccounts / $day;
}
@@ -332,7 +334,7 @@ class WorkbenchController extends Controller
'totalUsers' => $totalUsers >> 0
];
}
unset($item->trafficConfig,$item->traffic_config);
unset($item->trafficConfig, $item->traffic_config);
break;
}
// 添加创建人名称
@@ -370,16 +372,16 @@ class WorkbenchController extends Controller
// 定义关联关系
$with = [
'autoLike' => function($query) {
'autoLike' => function ($query) {
$query->field('workbenchId,interval,maxLikes,startTime,endTime,contentTypes,devices,friends,friendMaxLikes,friendTags,enableFriendTags');
},
'momentsSync' => function($query) {
'momentsSync' => function ($query) {
$query->field('workbenchId,syncInterval,syncCount,syncType,startTime,endTime,accountType,devices,contentLibraries');
},
'trafficConfig' => function($query) {
'trafficConfig' => function ($query) {
$query->field('workbenchId,distributeType,maxPerDay,timeType,startTime,endTime,devices,pools,account');
},
'groupPush' => function($query) {
'groupPush' => function ($query) {
$query->field('workbenchId,pushType,startTime,endTime,maxPerDay,pushOrder,isLoop,status,groups,contentLibraries');
},
// 'groupCreate' => function($query) {
@@ -392,9 +394,9 @@ class WorkbenchController extends Controller
['userId', '=', $this->request->userInfo['id']],
['isDel', '=', 0]
])
->field('id,name,type,status,autoStart,createTime,updateTime,companyId')
->with($with)
->find();
->field('id,name,type,status,autoStart,createTime,updateTime,companyId')
->with($with)
->find();
if (empty($workbench)) {
return json(['code' => 404, 'msg' => '工作台不存在']);
@@ -402,14 +404,15 @@ class WorkbenchController extends Controller
// 处理配置信息
switch ($workbench->type) {
//自动点赞
case self::TYPE_AUTO_LIKE:
if (!empty($workbench->autoLike)) {
$workbench->config = $workbench->autoLike;
$workbench->config->devices = json_decode($workbench->config->devices, true);
$workbench->config->friends = json_decode($workbench->config->friends, true);
$workbench->config->deveiceGroups = json_decode($workbench->config->devices, true);
$workbench->config->friendsGroups = json_decode($workbench->config->friends, true);
//$workbench->config->targetGroups = json_decode($workbench->config->targetGroups, true);
$workbench->config->contentTypes = json_decode($workbench->config->contentTypes, true);
// 添加今日点赞数
$startTime = strtotime(date('Y-m-d') . ' 00:00:00');
$endTime = strtotime(date('Y-m-d') . ' 23:59:59');
@@ -417,156 +420,122 @@ class WorkbenchController extends Controller
->where('workbenchId', $workbench->id)
->whereTime('createTime', 'between', [$startTime, $endTime])
->count();
// 添加总点赞数
$totalLikeCount = Db::name('workbench_auto_like_item')
->where('workbenchId', $workbench->id)
->count();
$workbench->config->todayLikeCount = $todayLikeCount;
$workbench->config->totalLikeCount = $totalLikeCount;
unset($workbench->autoLike,$workbench->auto_like);
unset($workbench->autoLike, $workbench->auto_like);
}
break;
//自动同步朋友圈
case self::TYPE_MOMENTS_SYNC:
if (!empty($workbench->momentsSync)) {
$workbench->config = $workbench->momentsSync;
$workbench->config->devices = json_decode($workbench->config->devices, true);
$workbench->config->contentLibraries = json_decode($workbench->config->contentLibraries, true);
$workbench->config->deveiceGroups = json_decode($workbench->config->devices, true);
$workbench->config->contentGroups = json_decode($workbench->config->contentLibraries, true);
//同步记录
$sendNum = Db::name('workbench_moments_sync_item')->where(['workbenchId' => $workbench->id])->count();
$workbench->syncCount = $sendNum;
$lastTime = Db::name('workbench_moments_sync_item')->where(['workbenchId' => $workbench->id])->order('id DESC')->value('createTime');
$workbench->lastSyncTime = !empty($lastTime) ? date('Y-m-d H:i',$lastTime) : '--';
// 获取内容库名称
if (!empty($workbench->config->contentLibraries)) {
$libraryNames = ContentLibrary::where('id', 'in', $workbench->config->contentLibraries)
->select();
$workbench->config->contentLibraries = $libraryNames;
} else {
$workbench->config->contentLibraryNames = [];
}
if(!empty($workbench->config->devices)){
$deviceList = DeviceModel::alias('d')
->field([
'd.id', 'd.imei', 'd.memo', 'd.alive',
'l.wechatId',
'a.nickname', 'a.alias', 'a.avatar','a.alias'
])
->leftJoin('device_wechat_login l', 'd.id = l.deviceId and l.alive =' . DeviceWechatLoginModel::ALIVE_WECHAT_ACTIVE . ' and l.companyId = d.companyId')
->leftJoin('wechat_account a', 'l.wechatId = a.wechatId')
->whereIn('d.id',$workbench->config->devices)
->order('d.id desc')
->select();
$workbench->config->deviceList = $deviceList;
}else{
$workbench->config->deviceList = [];
}
unset($workbench->momentsSync,$workbench->moments_sync);
$workbench->lastSyncTime = !empty($lastTime) ? date('Y-m-d H:i', $lastTime) : '--';
unset($workbench->momentsSync, $workbench->moments_sync);
}
break;
//群推送
case self::TYPE_GROUP_PUSH:
if (!empty($workbench->groupPush)) {
$workbench->config = $workbench->groupPush;
$workbench->config->groups = json_decode($workbench->config->groups, true);
$workbench->config->wechatGroups = json_decode($workbench->config->groups, true);
$workbench->config->contentLibraries = json_decode($workbench->config->contentLibraries, true);
// 获取群
$groupList = Db::name('wechat_group')->alias('wg')
->join('wechat_account wa', 'wa.wechatId = wg.ownerWechatId')
->where('wg.id', 'in', $workbench->config->groups)
->order('wg.id', 'desc')
->field('wg.id,wg.name as groupName,wg.ownerWechatId,wa.nickName,wa.avatar,wa.alias,wg.avatar as groupAvatar')
->select();
$workbench->config->groupList = $groupList;
// 获取群组内容库
/* // 获取群组内容库
$contentLibraryList = ContentLibrary::where('id', 'in', $workbench->config->contentLibraries)
->field('id,name,sourceFriends,sourceGroups,keywordInclude,keywordExclude,aiEnabled,aiPrompt,timeEnabled,timeStart,timeEnd,status,sourceType,userId,createTime,updateTime')
->with(['user' => function($query) {
$query->field('id,username');
}])
->order('id', 'desc')
->select();
->field('id,name,sourceFriends,sourceGroups,keywordInclude,keywordExclude,aiEnabled,aiPrompt,timeEnabled,timeStart,timeEnd,status,sourceType,userId,createTime,updateTime')
->with(['user' => function ($query) {
$query->field('id,username');
}])
->order('id', 'desc')
->select();
// 处理JSON字段
foreach ($contentLibraryList as &$item) {
$item['sourceFriends'] = json_decode($item['sourceFriends'] ?: '[]', true);
$item['sourceGroups'] = json_decode($item['sourceGroups'] ?: '[]', true);
$item['keywordInclude'] = json_decode($item['keywordInclude'] ?: '[]', true);
$item['keywordExclude'] = json_decode($item['keywordExclude'] ?: '[]', true);
// 添加创建人名称
$item['creatorName'] = $item['user']['username'] ?? '';
$item['itemCount'] = Db::name('content_item')->where('libraryId', $item['id'])->count();
// 处理JSON字段
foreach ($contentLibraryList as &$item) {
$item['sourceFriends'] = json_decode($item['sourceFriends'] ?: '[]', true);
$item['sourceGroups'] = json_decode($item['sourceGroups'] ?: '[]', true);
$item['keywordInclude'] = json_decode($item['keywordInclude'] ?: '[]', true);
$item['keywordExclude'] = json_decode($item['keywordExclude'] ?: '[]', true);
// 添加创建人名称
$item['creatorName'] = $item['user']['username'] ?? '';
$item['itemCount'] = Db::name('content_item')->where('libraryId', $item['id'])->count();
// 获取好友详细信息
if (!empty($item['sourceFriends'] && $item['sourceType'] == 1)) {
$friendIds = $item['sourceFriends'];
$friendsInfo = [];
// 获取好友详细信息
if (!empty($item['sourceFriends'] && $item['sourceType'] == 1)) {
$friendIds = $item['sourceFriends'];
$friendsInfo = [];
if (!empty($friendIds)) {
// 查询好友信息使用wechat_friendship表
$friendsInfo = Db::name('wechat_friendship')->alias('wf')
->field('wf.id,wf.wechatId, wa.nickname, wa.avatar')
->join('wechat_account wa', 'wf.wechatId = wa.wechatId')
->whereIn('wf.id', $friendIds)
->select();
}
// 将好友信息添加到返回数据中
$item['selectedFriends'] = $friendsInfo;
if (!empty($friendIds)) {
// 查询好友信息使用wechat_friendship表
$friendsInfo = Db::name('wechat_friendship')->alias('wf')
->field('wf.id,wf.wechatId, wa.nickname, wa.avatar')
->join('wechat_account wa', 'wf.wechatId = wa.wechatId')
->whereIn('wf.id', $friendIds)
->select();
}
if (!empty($item['sourceGroups']) && $item['sourceType'] == 2) {
$groupIds = $item['sourceGroups'];
$groupsInfo = [];
if (!empty($groupIds)) {
// 查询群组信息
$groupsInfo = Db::name('wechat_group')->alias('g')
->field('g.id, g.chatroomId, g.name, g.avatar, g.ownerWechatId')
->whereIn('g.id', $groupIds)
->select();
}
// 将群组信息添加到返回数据中
$item['selectedGroups'] = $groupsInfo;
}
unset($item['user']); // 移除关联数据
// 将好友信息添加到返回数据中
$item['selectedFriends'] = $friendsInfo;
}
$workbench->config->contentLibraryList = $contentLibraryList;
if (!empty($item['sourceGroups']) && $item['sourceType'] == 2) {
$groupIds = $item['sourceGroups'];
$groupsInfo = [];
if (!empty($groupIds)) {
// 查询群组信息
$groupsInfo = Db::name('wechat_group')->alias('g')
->field('g.id, g.chatroomId, g.name, g.avatar, g.ownerWechatId')
->whereIn('g.id', $groupIds)
->select();
}
// 将群组信息添加到返回数据中
$item['selectedGroups'] = $groupsInfo;
}
unset($item['user']); // 移除关联数据
}
$workbench->config->contentLibraryList = $contentLibraryList;*/
unset($workbench->groupPush, $workbench->group_push);
}
break;
//建群助手
case self::TYPE_GROUP_CREATE:
if (!empty($workbench->groupCreate)) {
$workbench->config = $workbench->groupCreate;
$workbench->config->devices = json_decode($workbench->config->devices, true);
$workbench->config->deveiceGroups = json_decode($workbench->config->devices, true);
$workbench->config->targetGroups = json_decode($workbench->config->targetGroups, true);
}
break;
//流量分发
case self::TYPE_TRAFFIC_DISTRIBUTION:
if (!empty($workbench->trafficConfig)) {
$workbench->config = $workbench->trafficConfig;
$workbench->config->devices = json_decode($workbench->config->devices, true);
$workbench->config->deveiceGroups = json_decode($workbench->config->devices, true);
$workbench->config->accountGroups = json_decode($workbench->config->account, true);
$workbench->config->pools = json_decode($workbench->config->pools, true);
$workbench->config->account = json_decode($workbench->config->account, true);
$config_item = Db::name('workbench_traffic_config_item')->where(['workbenchId' => $workbench->id])->order('id DESC')->find();
$workbench->config->lastUpdated = !empty($config_item) ? date('Y-m-d H:i',$config_item['createTime']) : '--';
$workbench->config->lastUpdated = !empty($config_item) ? date('Y-m-d H:i', $config_item['createTime']) : '--';
//统计
$labels = $workbench->config->pools;
$labels = $workbench->config->pools;
$totalUsers = Db::table('s2_wechat_friend')->alias('wf')
->join(['s2_company_account' => 'sa'], 'sa.id = wf.accountId', 'left')
->join(['s2_wechat_account' => 'wa'], 'wa.id = wf.wechatAccountId', 'left')
@@ -574,45 +543,110 @@ class WorkbenchController extends Controller
['wf.isDeleted', '=', 0],
['sa.departmentId', '=', $workbench->companyId]
])
->whereIn('wa.currentDeviceId', $workbench->config->devices)
->whereIn('wa.currentDeviceId', $workbench->config->deveiceGroups)
->field('wf.id,wf.wechatAccountId,wf.wechatId,wf.labels,sa.userName,wa.currentDeviceId as deviceId')
->where(function ($q) use ($labels) {
foreach ($labels as $label) {
$q->whereOrRaw("JSON_CONTAINS(wf.labels, '\"{$label}\"')");
}
})->count();
foreach ($labels as $label) {
$q->whereOrRaw("JSON_CONTAINS(wf.labels, '\"{$label}\"')");
}
})->count();
$totalAccounts = Db::table('s2_company_account')
->alias('a')
->where(['a.departmentId' => $workbench->companyId, 'a.status' => 0])
->whereNotLike('a.userName', '%_offline%')
->whereNotLike('a.userName', '%_delete%')
->group('a.id')
->count();
->alias('a')
->where(['a.departmentId' => $workbench->companyId, 'a.status' => 0])
->whereNotLike('a.userName', '%_offline%')
->whereNotLike('a.userName', '%_delete%')
->group('a.id')
->count();
$dailyAverage = Db::name('workbench_traffic_config_item')
->where('workbenchId', $workbench->id)
->count();
->where('workbenchId', $workbench->id)
->count();
$day = (time() - strtotime($workbench->createTime)) / 86400;
$day = intval($day);
if($dailyAverage > 0){
if ($dailyAverage > 0) {
$dailyAverage = $dailyAverage / $totalAccounts / $day;
}
$workbench->config->total = [
'dailyAverage' => intval($dailyAverage),
'totalAccounts' => $totalAccounts,
'deviceCount' => count($workbench->config->devices),
'deviceCount' => count($workbench->config->deveiceGroups),
'poolCount' => count($workbench->config->pools),
'totalUsers' => $totalUsers >> 0
];
unset($workbench->trafficConfig,$workbench->traffic_config);
unset($workbench->trafficConfig, $workbench->traffic_config);
}
break;
}
unset($workbench->autoLike, $workbench->momentsSync, $workbench->groupPush, $workbench->groupCreate);
unset(
$workbench->autoLike,
$workbench->momentsSync,
$workbench->groupPush,
$workbench->groupCreate,
$workbench->config->devices,
$workbench->config->friends,
$workbench->config->groups,
$workbench->config->contentLibraries,
$workbench->config->account,
);
//获取设备信息
if (!empty($workbench->config->deveiceGroups)) {
$deviceList = DeviceModel::alias('d')
->field([
'd.id', 'd.imei', 'd.memo', 'd.alive',
'l.wechatId',
'a.nickname', 'a.alias', 'a.avatar', 'a.alias'
])
->leftJoin('device_wechat_login l', 'd.id = l.deviceId and l.alive =' . DeviceWechatLoginModel::ALIVE_WECHAT_ACTIVE . ' and l.companyId = d.companyId')
->leftJoin('wechat_account a', 'l.wechatId = a.wechatId')
->whereIn('d.id', $workbench->config->deveiceGroups)
->order('d.id desc')
->select();
$workbench->config->deveiceGroupsOptions = $deviceList;
} else {
$workbench->config->deveiceGroupsOptions = [];
}
// 获取群
if (!empty($workbench->config->wechatGroups)){
$groupList = Db::name('wechat_group')->alias('wg')
->join('wechat_account wa', 'wa.wechatId = wg.ownerWechatId')
->where('wg.id', 'in', $workbench->config->groups)
->order('wg.id', 'desc')
->field('wg.id,wg.name as groupName,wg.ownerWechatId,wa.nickName,wa.avatar,wa.alias,wg.avatar as groupAvatar')
->select();
$workbench->config->wechatGroupsOptions = $groupList;
}else{
$workbench->config->wechatGroupsOptions = [];
}
// 获取内容库名称
if (!empty($workbench->config->contentGroups)) {
$libraryNames = ContentLibrary::where('id', 'in', $workbench->config->contentGroups)->select();
$workbench->config->contentGroupsOptions = $libraryNames;
} else {
$workbench->config->contentGroupsOptions = [];
}
//账号
if (!empty($workbench->config->accountGroups)){
$account = Db::table('s2_company_account')->alias('a')
->where(['a.departmentId' => $this->request->userInfo['companyId'], 'a.status' => 0])
->whereIn('a.id', $workbench->config->accountGroups)
->whereNotLike('a.userName', '%_offline%')
->whereNotLike('a.userName', '%_delete%')
->field('a.id,a.userName,a.realName,a.nickname,a.memo')
->select();
$workbench->config->accountGroupsOptions = $account;
}else{
$workbench->config->accountGroupsOptions = [];
}
return json(['code' => 200, 'msg' => '获取成功', 'data' => $workbench]);
}
@@ -664,8 +698,8 @@ class WorkbenchController extends Controller
$config->startTime = $param['startTime'];
$config->endTime = $param['endTime'];
$config->contentTypes = json_encode($param['contentTypes']);
$config->devices = json_encode($param['devices']);
$config->friends = json_encode($param['friends']);
$config->devices = json_encode($param['deveiceGroups']);
$config->friends = json_encode($param['friendsGroups']);
// $config->targetGroups = json_encode($param['targetGroups']);
// $config->tagOperator = $param['tagOperator'];
$config->friendMaxLikes = $param['friendMaxLikes'];
@@ -679,25 +713,25 @@ class WorkbenchController extends Controller
case self::TYPE_MOMENTS_SYNC:
$config = WorkbenchMomentsSync::where('workbenchId', $param['id'])->find();
if ($config) {
if (!empty($param['contentLibraries'])){
foreach ($param['contentLibraries'] as $library){
if(isset($library['id']) && !empty($library['id'])){
if (!empty($param['contentGroups'])) {
foreach ($param['contentGroups'] as $library) {
if (isset($library['id']) && !empty($library['id'])) {
$contentLibraries[] = $library['id'];
}else{
} else {
$contentLibraries[] = $library;
}
}
}else{
} else {
$contentLibraries = [];
}
$config->syncInterval = $param['syncInterval'];
$config->syncCount = $param['syncCount'];
$config->syncType = $param['syncType'];
$config->startTime = $param['startTime'];
$config->endTime = $param['endTime'];
$config->accountType = $param['accountType'];
$config->devices = json_encode($param['devices']);
$config->devices = json_encode($param['deveiceGroups']);
$config->contentLibraries = json_encode($contentLibraries);
$config->updateTime = time();
$config->save();
@@ -714,8 +748,10 @@ class WorkbenchController extends Controller
$config->pushOrder = $param['pushOrder']; // 推送顺序
$config->isLoop = !empty($param['isLoop']) ? 1 : 0; // 是否循环
$config->status = !empty($param['status']) ? 1 : 0; // 是否启用
$config->groups = json_encode($param['groups'], JSON_UNESCAPED_UNICODE); // 群组信息
$config->contentLibraries = json_encode($param['contentLibraries'], JSON_UNESCAPED_UNICODE); // 内容库信息
$config->groups = json_encode($param['wechatGroups'], JSON_UNESCAPED_UNICODE); // 群组信息
$config->contentLibraries = json_encode($param['contentGroups'], JSON_UNESCAPED_UNICODE); // 内容库信息
$config->socialMediaId = !empty($param['socialMediaId']) ? $param['socialMediaId'] : '';
$config->promotionSiteId = !empty($param['promotionSiteId']) ? $param['promotionSiteId'] : '';
$config->updateTime = time();
$config->save();
}
@@ -727,7 +763,7 @@ class WorkbenchController extends Controller
$config->groupNamePrefix = $param['groupNamePrefix'];
$config->maxGroups = $param['maxGroups'];
$config->membersPerGroup = $param['membersPerGroup'];
$config->devices = json_encode($param['devices']);
$config->devices = json_encode($param['deveiceGroups']);
$config->targetGroups = json_encode($param['targetGroups']);
$config->updateTime = time();
$config->save();
@@ -741,9 +777,9 @@ class WorkbenchController extends Controller
$config->timeType = $param['timeType'];
$config->startTime = $param['startTime'];
$config->endTime = $param['endTime'];
$config->devices = json_encode($param['devices']);
$config->devices = json_encode($param['deveiceGroups']);
$config->pools = json_encode($param['pools']);
$config->account = json_encode($param['account']);
$config->account = json_encode($param['accountGroups']);
$config->updateTime = time();
$config->save();
}
@@ -768,9 +804,9 @@ class WorkbenchController extends Controller
return json(['code' => 400, 'msg' => '请求方式错误']);
}
$id = $this->request->param('id','');
$id = $this->request->param('id', '');
if(empty($id)){
if (empty($id)) {
return json(['code' => 400, 'msg' => '参数错误']);
}
@@ -976,7 +1012,7 @@ class WorkbenchController extends Controller
// 处理时间格式
$item['likeTime'] = date('Y-m-d H:i:s', $item['likeTime']);
$item['momentTime'] = !empty($item['momentTime']) ? date('Y-m-d H:i:s', $item['momentTime']) : '';
// 处理资源链接
if (!empty($item['resUrls'])) {
$item['resUrls'] = json_decode($item['resUrls'], true);
@@ -1036,11 +1072,10 @@ class WorkbenchController extends Controller
->page($page, $limit)
->select();
foreach ($list as &$item) {
$item['resUrls'] = json_decode($item['resUrls'], true);
$item['urls'] = json_decode($item['urls'], true);
}
foreach ($list as &$item) {
$item['resUrls'] = json_decode($item['resUrls'], true);
$item['urls'] = json_decode($item['urls'], true);
}
// 获取总记录数
@@ -1074,7 +1109,7 @@ class WorkbenchController extends Controller
// 获取今日数据
$todayStart = strtotime(date('Y-m-d') . ' 00:00:00');
$todayEnd = strtotime(date('Y-m-d') . ' 23:59:59');
$todayStats = Db::name('workbench_moments_sync_item')
->where([
['workbenchId', '=', $workbenchId],
@@ -1158,7 +1193,7 @@ class WorkbenchController extends Controller
foreach ($list as &$item) {
// 处理时间格式
$item['distributeTime'] = date('Y-m-d H:i:s', $item['distributeTime']);
// 处理性别
$genderMap = [
0 => '未知',
@@ -1207,7 +1242,7 @@ class WorkbenchController extends Controller
// 获取今日数据
$todayStart = strtotime(date('Y-m-d') . ' 00:00:00');
$todayEnd = strtotime(date('Y-m-d') . ' 23:59:59');
$todayStats = Db::name('workbench_traffic_distribution_item')
->where([
['workbenchId', '=', $workbenchId],
@@ -1289,7 +1324,7 @@ class WorkbenchController extends Controller
// 处理数据
$detail['distributeTime'] = date('Y-m-d H:i:s', $detail['distributeTime']);
// 处理性别
$genderMap = [
0 => '未知',
@@ -1347,10 +1382,10 @@ class WorkbenchController extends Controller
'updateTime' => time()
]);
Db::commit();
return json(['code'=>200, 'msg'=>'创建成功']);
return json(['code' => 200, 'msg' => '创建成功']);
} catch (\Exception $e) {
Db::rollback();
return json(['code'=>500, 'msg'=>'创建失败:'.$e->getMessage()]);
return json(['code' => 500, 'msg' => '创建失败:' . $e->getMessage()]);
}
}
@@ -1400,14 +1435,13 @@ class WorkbenchController extends Controller
// 搜索过滤
if (!empty($keyword)) {
$labels = array_filter($labels, function($label) use ($keyword) {
$labels = array_filter($labels, function ($label) use ($keyword) {
return mb_stripos($label, $keyword) !== false;
});
$labels = array_values($labels); // 重新索引数组
}
// 分页处理
$labels2 = array_slice($labels, ($page - 1) * $limit, $limit);
@@ -1415,9 +1449,9 @@ class WorkbenchController extends Controller
$newLabel = [];
foreach ($labels2 as $label) {
$friendCount = Db::table('s2_wechat_friend')
->whereIn('ownerWechatId',$wechatIds)
->where('labels', 'like', '%"'.$label.'"%')
->count();
->whereIn('ownerWechatId', $wechatIds)
->where('labels', 'like', '%"' . $label . '"%')
->count();
$newLabel[] = [
'label' => $label,
'count' => $friendCount
@@ -1426,8 +1460,8 @@ class WorkbenchController extends Controller
// 返回结果
return json([
'code' => 200,
'msg' => '获取成功',
'code' => 200,
'msg' => '获取成功',
'data' => [
'list' => $newLabel,
'total' => count($labels),
@@ -1461,9 +1495,9 @@ class WorkbenchController extends Controller
$total = $query->count();
$list = $query->order('wg.id', 'desc')
->field('wg.id,wg.name as groupName,wg.ownerWechatId,wa.nickName,wg.createTime,wa.avatar,wa.alias,wg.avatar as groupAvatar')
->page($page, $limit)
->select();
->field('wg.id,wg.name as groupName,wg.ownerWechatId,wa.nickName,wg.createTime,wa.avatar,wa.alias,wg.avatar as groupAvatar')
->page($page, $limit)
->select();
// 优化:格式化时间,头像兜底
$defaultGroupAvatar = '';
@@ -1474,7 +1508,7 @@ class WorkbenchController extends Controller
$item['avatar'] = $item['avatar'] ?: $defaultAvatar;
}
return json(['code' => 200, 'msg' => '获取成功', 'data' => ['total' => $total,'list' => $list]]);
return json(['code' => 200, 'msg' => '获取成功', 'data' => ['total' => $total, 'list' => $list]]);
}
@@ -1484,19 +1518,18 @@ class WorkbenchController extends Controller
$page = $this->request->param('page', 1);
$limit = $this->request->param('limit', 10);
$query = Db::table('s2_company_account')
->alias('a')
->where(['a.departmentId' => $companyId, 'a.status' => 0])
->whereNotLike('a.userName', '%_offline%')
->whereNotLike('a.userName', '%_delete%');
->alias('a')
->where(['a.departmentId' => $companyId, 'a.status' => 0])
->whereNotLike('a.userName', '%_offline%')
->whereNotLike('a.userName', '%_delete%');
$total = $query->count();
$list = $query->field('a.id,a.userName,a.realName,a.nickname,a.memo')
->page($page, $limit)
->select();
->page($page, $limit)
->select();
return json(['code' => 200, 'msg' => '获取成功', 'data' => ['total' => $total,'list' => $list]]);
return json(['code' => 200, 'msg' => '获取成功', 'data' => ['total' => $total, 'list' => $list]]);
}
@@ -1527,11 +1560,9 @@ class WorkbenchController extends Controller
return json(['code' => 500, 'msg' => '参数缺失']);
}
$data = Db::name('jd_promotion_site')->where('jdSocialMediaId',$id)->order('id DESC')->select();
$data = Db::name('jd_promotion_site')->where('jdSocialMediaId', $id)->order('id DESC')->select();
return json(['code' => 200, 'msg' => '获取成功', 'data' => $data]);
}
}
}

View File

@@ -2,6 +2,8 @@
namespace app\cunkebao\controller\plan;
use app\common\model\Device as DeviceModel;
use app\common\model\DeviceWechatLogin as DeviceWechatLoginModel;
use library\ResponseHelper;
use think\Controller;
use think\Db;
@@ -116,10 +118,53 @@ class GetAddFriendPlanDetailV1Controller extends Controller
// 解析JSON字段
$sceneConf = json_decode($plan['sceneConf'], true) ?: [];
$sceneConf['wechatGroups'] = $sceneConf['groupSelected'];
$reqConf = json_decode($plan['reqConf'], true) ?: [];
$reqConf['deveiceGroups'] = $reqConf['device'];
$msgConf = json_decode($plan['msgConf'], true) ?: [];
$tagConf = json_decode($plan['tagConf'], true) ?: [];
if(!empty($sceneConf['wechatGroups'])){
$groupList = Db::name('wechat_group')->alias('wg')
->join('wechat_account wa', 'wa.wechatId = wg.ownerWechatId')
->where('wg.id', 'in', $sceneConf['wechatGroups'])
->order('wg.id', 'desc')
->field('wg.id,wg.name as groupName,wg.ownerWechatId,wa.nickName,wa.avatar,wa.alias,wg.avatar as groupAvatar')
->select();
$sceneConf['wechatGroupsOptions'] = $groupList;
}else{
$sceneConf['wechatGroupsOptions'] = [];
}
if (!empty($reqConf['deveiceGroups'])){
$deveiceGroupsOptions = DeviceModel::alias('d')
->field([
'd.id', 'd.imei', 'd.memo', 'd.alive',
'l.wechatId',
'a.nickname', 'a.alias', '0 totalFriend'
])
->leftJoin('device_wechat_login l', 'd.id = l.deviceId and l.alive =' . DeviceWechatLoginModel::ALIVE_WECHAT_ACTIVE . ' and l.companyId = d.companyId')
->leftJoin('wechat_account a', 'l.wechatId = a.wechatId')
->order('d.id desc')
->whereIn('d.id',$reqConf['deveiceGroups'])
->select();
$reqConf['deveiceGroupsOptions'] = $deveiceGroupsOptions;
}else{
$reqConf['deveiceGroupsOptions'] = [];
}
unset(
$reqConf['device'],
$sceneConf['groupSelected'],
);
// 合并数据
$newData['messagePlans'] = $msgConf;
$newData = array_merge($newData, $sceneConf, $reqConf, $tagConf, $plan);

View File

@@ -20,21 +20,21 @@ class PostUpdateAddFriendPlanV1Controller extends Controller
{
try {
$params = $this->request->param();
// 验证必填字段
if (empty($params['planId'])) {
return ResponseHelper::error('计划ID不能为空', 400);
}
if (empty($params['name'])) {
return ResponseHelper::error('计划名称不能为空', 400);
}
if (empty($params['sceneId'])) {
return ResponseHelper::error('场景ID不能为空', 400);
}
if (empty($params['device'])) {
if (empty($params['deveiceGroups'])) {
return ResponseHelper::error('请选择设备', 400);
}
@@ -51,17 +51,23 @@ class PostUpdateAddFriendPlanV1Controller extends Controller
$msgConf = isset($params['messagePlans']) ? $params['messagePlans'] : [];
$tagConf = [
'scenarioTags' => $params['scenarioTags'] ?? [],
'customTags' => $params['customTags'] ?? [],
'customTags' => $params['customTags'] ?? [],
];
$reqConf = [
'device' => $params['device'] ?? [],
'remarkType' => $params['remarkType'] ?? '',
'greeting' => $params['greeting'] ?? '',
'device' => $params['deveiceGroups'] ?? [],
'remarkType' => $params['remarkType'] ?? '',
'greeting' => $params['greeting'] ?? '',
'addFriendInterval' => $params['addFriendInterval'] ?? '',
'startTime' => $params['startTime'] ?? '',
'endTime' => $params['endTime'] ?? '',
'startTime' => $params['startTime'] ?? '',
'endTime' => $params['endTime'] ?? '',
];
if (isset($params['wechatGroups'])){
$params['wechatGroups'] = $params['groupSelected'];
}
// 其余参数归为sceneConf
$sceneConf = $params;
unset(
@@ -75,7 +81,7 @@ class PostUpdateAddFriendPlanV1Controller extends Controller
$sceneConf['messagePlans'],
$sceneConf['scenarioTags'],
$sceneConf['customTags'],
$sceneConf['device'],
$sceneConf['deveiceGroups'],
$sceneConf['orderTableFileName'],
$sceneConf['userInfo'],
$sceneConf['textUrl'],
@@ -89,162 +95,236 @@ class PostUpdateAddFriendPlanV1Controller extends Controller
// 构建更新数据
$data = [
'name' => $params['name'],
'sceneId' => $params['sceneId'],
'name' => $params['name'],
'sceneId' => $params['sceneId'],
'sceneConf' => json_encode($sceneConf, JSON_UNESCAPED_UNICODE),
'reqConf' => json_encode($reqConf, JSON_UNESCAPED_UNICODE),
'msgConf' => json_encode($msgConf, JSON_UNESCAPED_UNICODE),
'tagConf' => json_encode($tagConf, JSON_UNESCAPED_UNICODE),
'updateTime'=> time(),
'reqConf' => json_encode($reqConf, JSON_UNESCAPED_UNICODE),
'msgConf' => json_encode($msgConf, JSON_UNESCAPED_UNICODE),
'tagConf' => json_encode($tagConf, JSON_UNESCAPED_UNICODE),
'updateTime' => time(),
];
try {
// 更新数据
$result = Db::name('customer_acquisition_task')
->where('id', $params['planId'])
->update($data);
if ($result === false) {
throw new \Exception('更新计划失败');
}
//订单
if($params['sceneId'] == 2){
if(!empty($params['orderTableFile'])){
// 先下载到本地临时文件,再分析,最后删除
$originPath = $params['orderTableFile'];
$tmpFile = tempnam(sys_get_temp_dir(), 'order_');
// 判断是否为远程文件
if (preg_match('/^https?:\/\//i', $originPath)) {
// 远程URL下载到本地
$fileContent = file_get_contents($originPath);
if ($fileContent === false) {
exit('远程文件下载失败: ' . $originPath);
}
file_put_contents($tmpFile, $fileContent);
} else {
// 本地文件直接copy
if (!file_exists($originPath)) {
exit('文件不存在: ' . $originPath);
}
copy($originPath, $tmpFile);
//订单
if ($params['sceneId'] == 2) {
if (!empty($params['orderTableFile'])) {
// 先下载到本地临时文件,再分析,最后删除
$originPath = $params['orderTableFile'];
$tmpFile = tempnam(sys_get_temp_dir(), 'order_');
// 判断是否为远程文件
if (preg_match('/^https?:\/\//i', $originPath)) {
// 远程URL下载到本地
$fileContent = file_get_contents($originPath);
if ($fileContent === false) {
exit('远程文件下载失败: ' . $originPath);
}
file_put_contents($tmpFile, $fileContent);
} else {
// 本地文件直接copy
if (!file_exists($originPath)) {
exit('文件不存在: ' . $originPath);
}
copy($originPath, $tmpFile);
}
// 解析临时文件
$ext = strtolower(pathinfo($originPath, PATHINFO_EXTENSION));
$rows = [];
if (in_array($ext, ['xls', 'xlsx'])) {
// 直接用composer自动加载的PHPExcel
$excel = \PHPExcel_IOFactory::load($tmpFile);
$sheet = $excel->getActiveSheet();
$data = $sheet->toArray();
if (count($data) > 1) {
array_shift($data); // 去掉表头
}
// 解析临时文件
$ext = strtolower(pathinfo($originPath, PATHINFO_EXTENSION));
$rows = [];
if (in_array($ext, ['xls', 'xlsx'])) {
// 直接用composer自动加载的PHPExcel
$excel = \PHPExcel_IOFactory::load($tmpFile);
$sheet = $excel->getActiveSheet();
$data = $sheet->toArray();
if (count($data) > 1) {
array_shift($data); // 去掉表头
}
foreach ($data as $cols) {
$rows[] = [
'name' => isset($cols[0]) ? trim($cols[0]) : '',
'phone' => isset($cols[1]) ? trim($cols[1]) : '',
'wechat' => isset($cols[2]) ? trim($cols[2]) : '',
'source' => isset($cols[3]) ? trim($cols[3]) : '',
'orderAmount' => isset($cols[4]) ? trim($cols[4]) : '',
'orderDate' => isset($cols[5]) ? trim($cols[5]) : '',
];
}
} elseif ($ext === 'csv') {
$content = file_get_contents($tmpFile);
$lines = preg_split('/\r\n|\r|\n/', $content);
if (count($lines) > 1) {
array_shift($lines); // 去掉表头
foreach ($lines as $line) {
if (trim($line) === '') continue;
$cols = str_getcsv($line);
if (count($cols) >= 6) {
$rows[] = [
'name' => isset($cols[0]) ? trim($cols[0]) : '',
'phone' => isset($cols[1]) ? trim($cols[1]) : '',
'wechat' => isset($cols[2]) ? trim($cols[2]) : '',
'source' => isset($cols[3]) ? trim($cols[3]) : '',
'orderAmount' => isset($cols[4]) ? trim($cols[4]) : '',
'orderDate' => isset($cols[5]) ? trim($cols[5]) : '',
];
}
foreach ($data as $cols) {
$rows[] = [
'name' => isset($cols[0]) ? trim($cols[0]) : '',
'phone' => isset($cols[1]) ? trim($cols[1]) : '',
'wechat' => isset($cols[2]) ? trim($cols[2]) : '',
'source' => isset($cols[3]) ? trim($cols[3]) : '',
'orderAmount' => isset($cols[4]) ? trim($cols[4]) : '',
'orderDate' => isset($cols[5]) ? trim($cols[5]) : '',
];
}
} elseif ($ext === 'csv') {
$content = file_get_contents($tmpFile);
$lines = preg_split('/\r\n|\r|\n/', $content);
if (count($lines) > 1) {
array_shift($lines); // 去掉表头
foreach ($lines as $line) {
if (trim($line) === '') continue;
$cols = str_getcsv($line);
if (count($cols) >= 6) {
$rows[] = [
'name' => isset($cols[0]) ? trim($cols[0]) : '',
'phone' => isset($cols[1]) ? trim($cols[1]) : '',
'wechat' => isset($cols[2]) ? trim($cols[2]) : '',
'source' => isset($cols[3]) ? trim($cols[3]) : '',
'orderAmount' => isset($cols[4]) ? trim($cols[4]) : '',
'orderDate' => isset($cols[5]) ? trim($cols[5]) : '',
];
}
}
} else {
unlink($tmpFile);
exit('暂不支持的文件类型: ' . $ext);
}
// 删除临时文件
} else {
unlink($tmpFile);
exit('暂不支持的文件类型: ' . $ext);
}
// 删除临时文件
unlink($tmpFile);
// 1000条为一组进行批量处理
$batchSize = 1000;
$totalRows = count($rows);
for ($i = 0; $i < $totalRows; $i += $batchSize) {
$batchRows = array_slice($rows, $i, $batchSize);
if (!empty($batchRows)) {
// 1. 提取当前批次的phone
$phones = [];
foreach ($batchRows as $row) {
$phone = !empty($row['phone']) ? $row['phone'] : $row['wechat'];
if (!empty($phone)) {
$phones[] = $phone;
}
}
// 2. 批量查询已存在的phone
$existingPhones = [];
if (!empty($phones)) {
$existing = Db::name('task_customer')
->where('task_id', $params['planId'])
->where('phone', 'in', $phones)
->field('phone')
->select();
$existingPhones = array_column($existing, 'phone');
}
// 3. 过滤出新数据,批量插入
$newData = [];
foreach ($batchRows as $row) {
$phone = !empty($row['phone']) ? $row['phone'] : $row['wechat'];
if (!empty($phone) && !in_array($phone, $existingPhones)) {
$newData[] = [
'task_id' => $params['planId'],
'name' => $row['name'] ?? '',
'source' => $row['source'] ?? '',
'phone' => $phone,
'tags' => json_encode([], JSON_UNESCAPED_UNICODE),
'siteTags' => json_encode([], JSON_UNESCAPED_UNICODE),
'createTime' => time(),
];
}
}
// 4. 批量插入新数据
if (!empty($newData)) {
Db::name('task_customer')->insertAll($newData);
// 1000条为一组进行批量处理
$batchSize = 1000;
$totalRows = count($rows);
for ($i = 0; $i < $totalRows; $i += $batchSize) {
$batchRows = array_slice($rows, $i, $batchSize);
if (!empty($batchRows)) {
// 1. 提取当前批次的phone
$phones = [];
foreach ($batchRows as $row) {
$phone = !empty($row['phone']) ? $row['phone'] : $row['wechat'];
if (!empty($phone)) {
$phones[] = $phone;
}
}
// 2. 批量查询已存在的phone
$existingPhones = [];
if (!empty($phones)) {
$existing = Db::name('task_customer')
->where('task_id', $params['planId'])
->where('phone', 'in', $phones)
->field('phone')
->select();
$existingPhones = array_column($existing, 'phone');
}
// 3. 过滤出新数据,批量插入
$newData = [];
foreach ($batchRows as $row) {
$phone = !empty($row['phone']) ? $row['phone'] : $row['wechat'];
if (!empty($phone) && !in_array($phone, $existingPhones)) {
$newData[] = [
'task_id' => $params['planId'],
'name' => $row['name'] ?? '',
'source' => $row['source'] ?? '',
'phone' => $phone,
'tags' => json_encode([], JSON_UNESCAPED_UNICODE),
'siteTags' => json_encode([], JSON_UNESCAPED_UNICODE),
'createTime' => time(),
];
}
}
// 4. 批量插入新数据
if (!empty($newData)) {
Db::name('task_customer')->insertAll($newData);
}
}
}
}
}
//群获客
if ($params['sceneId'] == 7) {
if (!empty($params['groupSelected']) && is_array($params['groupSelected'])) {
$rows = Db::name('wechat_group_member')->alias('gm')
->join('wechat_account wa', 'gm.identifier = wa.wechatId')
->where('gm.companyId', $this->getUserInfo('companyId'))
->whereIn('gm.groupId', $params['groupSelected'])
->group('gm.identifier')
->column('wa.id,wa.wechatId,wa.alias,wa.phone');
// 1000条为一组进行批量处理
$batchSize = 1000;
$totalRows = count($rows);
for ($i = 0; $i < $totalRows; $i += $batchSize) {
$batchRows = array_slice($rows, $i, $batchSize);
if (!empty($batchRows)) {
// 1. 提取当前批次的phone
$phones = [];
foreach ($batchRows as $row) {
if (!empty($row['phone'])) {
$phone = !empty($row['phone']);
} elseif (!empty($row['alias'])) {
$phone = $row['alias'];
} else {
$phone = $row['wechatId'];
}
if (!empty($phone)) {
$phones[] = $phone;
}
}
// 2. 批量查询已存在的phone
$existingPhones = [];
if (!empty($phones)) {
$existing = Db::name('task_customer')
->where('task_id', $params['planId'])
->where('phone', 'in', $phones)
->field('phone')
->select();
$existingPhones = array_column($existing, 'phone');
}
// 3. 过滤出新数据,批量插入
$newData = [];
foreach ($batchRows as $row) {
if (!empty($row['phone'])) {
$phone = !empty($row['phone']);
} elseif (!empty($row['alias'])) {
$phone = $row['alias'];
} else {
$phone = $row['wechatId'];
}
if (!empty($phone) && !in_array($phone, $existingPhones)) {
$newData[] = [
'task_id' => $params['planId'],
'name' => '',
'source' => '场景获客_' . $params['name'] ?? '',
'phone' => $phone,
'tags' => json_encode([], JSON_UNESCAPED_UNICODE),
'siteTags' => json_encode([], JSON_UNESCAPED_UNICODE),
'createTime' => time(),
];
}
}
// 4. 批量插入新数据
if (!empty($newData)) {
Db::name('task_customer')->insertAll($newData);
}
}
}
}
}
return ResponseHelper::success(['planId' => $params['planId']], '更新计划任务成功');
} catch (\Exception $e) {
// 回滚事务
Db::rollback();
throw $e;
}
} catch (\Exception $e) {
return ResponseHelper::error('系统错误: ' . $e->getMessage(), 500);
}

View File

@@ -0,0 +1,123 @@
<?php
namespace app\job;
use think\queue\Job;
use think\facade\Log;
use think\Queue;
use think\facade\Config;
use app\api\controller\CallRecordingController;
class CallRecordingListJob
{
/**
* 队列任务处理
* @param Job $job 队列任务
* @param array $data 任务数据
* @return void
*/
public function fire(Job $job, $data)
{
try {
// 如果任务执行成功后删除任务
if ($this->processCallRecordingList($data, $job->attempts())) {
$job->delete();
Log::info('通话记录列表任务执行成功,页码:' . $data['pageIndex']);
} else {
if ($job->attempts() > 3) {
// 超过重试次数,删除任务
Log::error('通话记录列表任务执行失败,已超过重试次数,页码:' . $data['pageIndex']);
$job->delete();
} else {
// 任务失败,重新放回队列
Log::warning('通话记录列表任务执行失败,重试次数:' . $job->attempts() . ',页码:' . $data['pageIndex']);
$job->release(Config::get('queue.failed_delay', 10));
}
}
} catch (\Exception $e) {
// 出现异常,记录日志
Log::error('通话记录列表任务异常:' . $e->getMessage());
if ($job->attempts() > 3) {
$job->delete();
} else {
$job->release(Config::get('queue.failed_delay', 10));
}
}
}
/**
* 处理通话记录列表获取
* @param array $data 任务数据
* @param int $attempts 重试次数
* @return bool
*/
protected function processCallRecordingList($data, $attempts)
{
// 获取参数
$pageIndex = isset($data['pageIndex']) ? $data['pageIndex'] : 0;
$pageSize = isset($data['pageSize']) ? $data['pageSize'] : 100;
Log::info('开始获取通话记录列表,页码:' . $pageIndex . ',页大小:' . $pageSize);
// 实例化控制器
$callRecordingController = new CallRecordingController();
// 构建请求参数
$params = [
'pageIndex' => $pageIndex,
'pageSize' => $pageSize,
'keyword' => '',
'isCallOut' => '',
'secondMin' => 0,
'secondMax' => 99999,
'departmentIds' => '',
'from' => '2016-01-01 00:00:00',
'to' => '2025-08-31 00:00:00',
'departmentId' => ''
];
// 设置请求信息
$request = request();
$request->withGet($params);
// 调用通话记录列表获取方法
$result = $callRecordingController->getlist($params, true);
$response = json_decode($result, true);
// 判断是否成功
if ($response['code'] == 200) {
$data = $response['data'];
// 判断是否有下一页
if (!empty($data) && isset($data['results']) && count($data['results']) > 0) {
// 有下一页,将下一页任务添加到队列
$nextPageIndex = $pageIndex + 1;
$this->addNextPageToQueue($nextPageIndex, $pageSize);
Log::info('添加下一页任务到队列,页码:' . $nextPageIndex);
}
return true;
} else {
$errorMsg = isset($response['msg']) ? $response['msg'] : '未知错误';
Log::error('获取通话记录列表失败:' . $errorMsg);
return false;
}
}
/**
* 添加下一页任务到队列
* @param int $pageIndex 页码
* @param int $pageSize 每页大小
*/
protected function addNextPageToQueue($pageIndex, $pageSize)
{
$data = [
'pageIndex' => $pageIndex,
'pageSize' => $pageSize
];
// 添加到队列,设置任务名为 call_recording_list
Queue::push(self::class, $data, 'call_recording_list');
}
}

View File

@@ -1,4 +0,0 @@
# 基础环境变量示例
# VITE_API_BASE_URL=http://www.yishi.com
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
VITE_APP_TITLE=存客宝

View File

@@ -1,3 +0,0 @@
# 基础环境变量示例
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
VITE_APP_TITLE=存客宝

View File

@@ -1,64 +0,0 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended", // 这个配置会自动处理大部分冲突
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 12,
sourceType: "module",
},
plugins: ["react", "react-hooks", "@typescript-eslint", "prettier"],
rules: {
"prettier/prettier": "error",
"react/react-in-jsx-scope": "off",
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unnecessary-type-constraint": "warn",
"react/prop-types": "off",
"linebreak-style": "off",
"eol-last": "off",
"no-empty": "warn",
"prefer-const": "warn",
// 确保与 Prettier 完全兼容
"comma-dangle": "off",
"comma-spacing": "off",
"comma-style": "off",
"object-curly-spacing": "off",
"array-bracket-spacing": "off",
indent: "off",
quotes: "off",
semi: "off",
"arrow-parens": "off",
"no-multiple-empty-lines": "off",
"max-len": "off",
"space-before-function-paren": "off",
"space-before-blocks": "off",
"keyword-spacing": "off",
"space-infix-ops": "off",
"space-in-parens": "off",
"space-in-brackets": "off",
"object-property-newline": "off",
"array-element-newline": "off",
"function-paren-newline": "off",
"object-curly-newline": "off",
"array-bracket-newline": "off",
},
settings: {
react: {
version: "detect",
},
},
};

27
nkebao/.gitattributes vendored
View File

@@ -1,27 +0,0 @@
# 设置默认行为如果core.autocrlf没有设置Git会自动处理行尾符
* text=auto
# 明确指定文本文件使用LF
*.js text eol=lf
*.jsx text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.json text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.html text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
# 二进制文件
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.svg binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary

6
nkebao/.gitignore vendored
View File

@@ -1,6 +0,0 @@
node_modules/
dist/
build/
yarn.lock
.env
.DS_Store

View File

@@ -1,13 +0,0 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "lf",
"bracketSpacing": true,
"arrowParens": "avoid",
"jsxSingleQuote": false,
"quoteProps": "as-needed"
}

View File

@@ -1,8 +0,0 @@
{
"hash": "efe0acf4",
"configHash": "2bed34b3",
"lockfileHash": "ef01d341",
"browserHash": "91bd3b2c",
"optimized": {},
"chunks": {}
}

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

View File

@@ -1,11 +0,0 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-typescript-next",
"formulahendry.auto-rename-tag",
"christian-kohler.path-intellisense",
"ms-vscode.vscode-json"
]
}

View File

@@ -1,45 +0,0 @@
{
"files.eol": "\n",
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true,
"files.trimTrailingWhitespace": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"eslint.format.enable": false,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.suggest.autoImports": true,
"editor.tabSize": 2,
"editor.insertSpaces": true,
"editor.detectIndentation": false
}

View File

@@ -1,95 +0,0 @@
import os
import zipfile
import paramiko
# 配置
local_dir = './dist' # 本地要打包的目录
zip_name = 'dist.zip'
# 上传到服务器的 zip 路径
remote_path = '/www/wwwroot/auto-devlop/ckb-operation/dist.zip' # 服务器上的临时zip路径
server_ip = '42.194.245.239'
server_port = 6523
server_user = 'yongpxu'
server_pwd = 'Aa123456789.'
# 服务器 dist 相关目录
remote_base_dir = '/www/wwwroot/auto-devlop/ckb-operation'
dist_dir = f'{remote_base_dir}/dist'
dist1_dir = f'{remote_base_dir}/dist1'
dist2_dir = f'{remote_base_dir}/dist2'
# 美化输出用的函数
from datetime import datetime
def info(msg):
print(f"\033[36m[INFO {datetime.now().strftime('%H:%M:%S')}] {msg}\033[0m")
def success(msg):
print(f"\033[32m[SUCCESS] {msg}\033[0m")
def error(msg):
print(f"\033[31m[ERROR] {msg}\033[0m")
def step(msg):
print(f"\n\033[35m==== {msg} ====" + "\033[0m")
# 1. 先运行 yarn build
step('Step 1: 构建项目 (yarn build)')
info('开始执行 yarn build...')
ret = os.system('yarn build')
if ret != 0:
error('yarn build 失败,终止部署!')
exit(1)
success('yarn build 完成')
# 2. 打包
step('Step 2: 打包 dist 目录为 zip')
info('开始打包 dist 目录...')
with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(local_dir):
for file in files:
filepath = os.path.join(root, file)
arcname = os.path.relpath(filepath, local_dir)
zipf.write(filepath, arcname)
success('本地打包完成')
# 3. 上传
step('Step 3: 上传 zip 包到服务器')
info('开始上传 zip 包...')
transport = paramiko.Transport((server_ip, server_port))
transport.connect(username=server_user, password=server_pwd)
sftp = paramiko.SFTPClient.from_transport(transport)
sftp.put(zip_name, remote_path)
sftp.close()
transport.close()
success('上传到服务器完成')
# 删除本地 dist.zip
try:
os.remove(zip_name)
success('本地 dist.zip 已删除')
except Exception as e:
error(f'本地 dist.zip 删除失败: {e}')
# 4. 远程解压并覆盖
step('Step 4: 服务器端解压、切换目录')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(server_ip, server_port, server_user, server_pwd)
commands = [
f'unzip -oq {remote_path} -d {dist2_dir}', # 静默解压
f'rm {remote_path}',
f'if [ -d {dist_dir} ]; then mv {dist_dir} {dist1_dir}; fi',
f'mv {dist2_dir} {dist_dir}',
f'rm -rf {dist1_dir}'
]
for i, cmd in enumerate(commands, 1):
info(f'执行第{i}步: {cmd}')
stdin, stdout, stderr = ssh.exec_command(cmd)
out, err = stdout.read().decode(), stderr.read().decode()
# 只打印非 unzip 命令的输出
if i != 1 and out.strip():
print(out.strip())
if err.strip():
error(err.strip())
ssh.close()
success('服务器解压并覆盖完成,部署成功!')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,19 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>存客宝</title>
<style>
html {
font-size: 16px;
}
</style>
<!-- 引入 uni-app web-view SDK必须 -->
<script type="text/javascript" src="./websdk.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6479
nkebao/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,49 +0,0 @@
{
"name": "cunkebao",
"version": "3.0.0",
"license": "MIT",
"private": true,
"dependencies": {
"@ant-design/icons": "^5.6.1",
"antd": "^5.13.1",
"antd-mobile": "^5.39.1",
"axios": "^1.6.7",
"dayjs": "^1.11.13",
"echarts": "^5.6.0",
"echarts-for-react": "^3.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"vconsole": "^3.15.1",
"zustand": "^5.0.6"
},
"devDependencies": {
"@types/node": "^24.0.14",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "^7.7.0",
"@typescript-eslint/parser": "^7.7.0",
"@vitejs/plugin-react": "^4.6.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^5.2.0",
"postcss": "^8.4.38",
"postcss-pxtorem": "^6.0.0",
"prettier": "^3.2.5",
"sass": "^1.75.0",
"typescript": "^5.4.5",
"vite": "^7.0.5"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"build:check": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .js,.jsx,.ts,.tsx --fix",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,scss,css}\"",
"lint:check": "eslint src --ext .js,.jsx,.ts,.tsx",
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,json,scss,css}\""
}
}

View File

@@ -1,8 +0,0 @@
module.exports = {
plugins: {
'postcss-pxtorem': {
rootValue: 16,
propList: ['*'],
},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

View File

@@ -1,30 +0,0 @@
{
"name": "Cunkebao",
"short_name": "Cunkebao",
"description": "Cunkebao Mobile App",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone",
"orientation": "portrait",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "logo.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@@ -1,308 +0,0 @@
!(function (e, n) {
"object" == typeof exports && "undefined" != typeof module
? (module.exports = n())
: "function" == typeof define && define.amd
? define(n)
: ((e = e || self).uni = n());
})(this, function () {
"use strict";
try {
var e = {};
(Object.defineProperty(e, "passive", {
get: function () {
!0;
},
}),
window.addEventListener("test-passive", null, e));
} catch (e) {}
var n = Object.prototype.hasOwnProperty;
function i(e, i) {
return n.call(e, i);
}
var t = [];
function o() {
return window.__dcloud_weex_postMessage || window.__dcloud_weex_;
}
function a() {
return window.__uniapp_x_postMessage || window.__uniapp_x_;
}
var r = function (e, n) {
var i = { options: { timestamp: +new Date() }, name: e, arg: n };
if (a()) {
if ("postMessage" === e) {
var r = { data: n };
return window.__uniapp_x_postMessage
? window.__uniapp_x_postMessage(r)
: window.__uniapp_x_.postMessage(JSON.stringify(r));
}
var d = {
type: "WEB_INVOKE_APPSERVICE",
args: { data: i, webviewIds: t },
};
window.__uniapp_x_postMessage
? window.__uniapp_x_postMessageToService(d)
: window.__uniapp_x_.postMessageToService(JSON.stringify(d));
} else if (o()) {
if ("postMessage" === e) {
var s = { data: [n] };
return window.__dcloud_weex_postMessage
? window.__dcloud_weex_postMessage(s)
: window.__dcloud_weex_.postMessage(JSON.stringify(s));
}
var w = {
type: "WEB_INVOKE_APPSERVICE",
args: { data: i, webviewIds: t },
};
window.__dcloud_weex_postMessage
? window.__dcloud_weex_postMessageToService(w)
: window.__dcloud_weex_.postMessageToService(JSON.stringify(w));
} else {
if (!window.plus)
return window.parent.postMessage(
{ type: "WEB_INVOKE_APPSERVICE", data: i, pageId: "" },
"*",
);
if (0 === t.length) {
var u = plus.webview.currentWebview();
if (!u) throw new Error("plus.webview.currentWebview() is undefined");
var g = u.parent(),
v = "";
((v = g ? g.id : u.id), t.push(v));
}
if (plus.webview.getWebviewById("__uniapp__service"))
plus.webview.postMessageToUniNView(
{ type: "WEB_INVOKE_APPSERVICE", args: { data: i, webviewIds: t } },
"__uniapp__service",
);
else {
var c = JSON.stringify(i);
plus.webview
.getLaunchWebview()
.evalJS(
'UniPlusBridge.subscribeHandler("'
.concat("WEB_INVOKE_APPSERVICE", '",')
.concat(c, ",")
.concat(JSON.stringify(t), ");"),
);
}
}
},
d = {
navigateTo: function () {
var e =
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
n = e.url;
r("navigateTo", { url: encodeURI(n) });
},
navigateBack: function () {
var e =
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
n = e.delta;
r("navigateBack", { delta: parseInt(n) || 1 });
},
switchTab: function () {
var e =
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
n = e.url;
r("switchTab", { url: encodeURI(n) });
},
reLaunch: function () {
var e =
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
n = e.url;
r("reLaunch", { url: encodeURI(n) });
},
redirectTo: function () {
var e =
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
n = e.url;
r("redirectTo", { url: encodeURI(n) });
},
getEnv: function (e) {
a()
? e({ uvue: !0 })
: o()
? e({ nvue: !0 })
: window.plus
? e({ plus: !0 })
: e({ h5: !0 });
},
postMessage: function () {
var e =
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {};
r("postMessage", e.data || {});
},
},
s = /uni-app/i.test(navigator.userAgent),
w = /Html5Plus/i.test(navigator.userAgent),
u = /complete|loaded|interactive/;
var g =
window.my &&
navigator.userAgent.indexOf(
["t", "n", "e", "i", "l", "C", "y", "a", "p", "i", "l", "A"]
.reverse()
.join(""),
) > -1;
var v =
window.swan && window.swan.webView && /swan/i.test(navigator.userAgent);
var c =
window.qq &&
window.qq.miniProgram &&
/QQ/i.test(navigator.userAgent) &&
/miniProgram/i.test(navigator.userAgent);
var p =
window.tt &&
window.tt.miniProgram &&
/toutiaomicroapp/i.test(navigator.userAgent);
var _ =
window.wx &&
window.wx.miniProgram &&
/micromessenger/i.test(navigator.userAgent) &&
/miniProgram/i.test(navigator.userAgent);
var m = window.qa && /quickapp/i.test(navigator.userAgent);
var f =
window.ks &&
window.ks.miniProgram &&
/micromessenger/i.test(navigator.userAgent) &&
/miniProgram/i.test(navigator.userAgent);
var l =
window.tt &&
window.tt.miniProgram &&
/Lark|Feishu/i.test(navigator.userAgent);
var E =
window.jd && window.jd.miniProgram && /jdmp/i.test(navigator.userAgent);
var x =
window.xhs &&
window.xhs.miniProgram &&
/xhsminiapp/i.test(navigator.userAgent);
for (
var S,
h = function () {
((window.UniAppJSBridge = !0),
document.dispatchEvent(
new CustomEvent("UniAppJSBridgeReady", {
bubbles: !0,
cancelable: !0,
}),
));
},
y = [
function (e) {
if (s || w)
return (
window.__uniapp_x_postMessage ||
window.__uniapp_x_ ||
window.__dcloud_weex_postMessage ||
window.__dcloud_weex_
? document.addEventListener("DOMContentLoaded", e)
: window.plus && u.test(document.readyState)
? setTimeout(e, 0)
: document.addEventListener("plusready", e),
d
);
},
function (e) {
if (_)
return (
window.WeixinJSBridge && window.WeixinJSBridge.invoke
? setTimeout(e, 0)
: document.addEventListener("WeixinJSBridgeReady", e),
window.wx.miniProgram
);
},
function (e) {
if (c)
return (
window.QQJSBridge && window.QQJSBridge.invoke
? setTimeout(e, 0)
: document.addEventListener("QQJSBridgeReady", e),
window.qq.miniProgram
);
},
function (e) {
if (g) {
document.addEventListener("DOMContentLoaded", e);
var n = window.my;
return {
navigateTo: n.navigateTo,
navigateBack: n.navigateBack,
switchTab: n.switchTab,
reLaunch: n.reLaunch,
redirectTo: n.redirectTo,
postMessage: n.postMessage,
getEnv: n.getEnv,
};
}
},
function (e) {
if (v)
return (
document.addEventListener("DOMContentLoaded", e),
window.swan.webView
);
},
function (e) {
if (p)
return (
document.addEventListener("DOMContentLoaded", e),
window.tt.miniProgram
);
},
function (e) {
if (m) {
window.QaJSBridge && window.QaJSBridge.invoke
? setTimeout(e, 0)
: document.addEventListener("QaJSBridgeReady", e);
var n = window.qa;
return {
navigateTo: n.navigateTo,
navigateBack: n.navigateBack,
switchTab: n.switchTab,
reLaunch: n.reLaunch,
redirectTo: n.redirectTo,
postMessage: n.postMessage,
getEnv: n.getEnv,
};
}
},
function (e) {
if (f)
return (
window.WeixinJSBridge && window.WeixinJSBridge.invoke
? setTimeout(e, 0)
: document.addEventListener("WeixinJSBridgeReady", e),
window.ks.miniProgram
);
},
function (e) {
if (l)
return (
document.addEventListener("DOMContentLoaded", e),
window.tt.miniProgram
);
},
function (e) {
if (E)
return (
window.JDJSBridgeReady && window.JDJSBridgeReady.invoke
? setTimeout(e, 0)
: document.addEventListener("JDJSBridgeReady", e),
window.jd.miniProgram
);
},
function (e) {
if (x) return window.xhs.miniProgram;
},
function (e) {
return (document.addEventListener("DOMContentLoaded", e), d);
},
],
M = 0;
M < y.length && !(S = y[M](h));
M++
);
S || (S = {});
var P = "undefined" != typeof uni ? uni : {};
if (!P.navigateTo) for (var b in S) i(S, b) && (P[b] = S[b]);
return ((P.webView = S), P);
});

View File

@@ -1,14 +0,0 @@
import React from "react";
import AppRouter from "@/router";
import UpdateNotification from "@/components/UpdateNotification";
function App() {
return (
<>
<AppRouter />
<UpdateNotification position="top" autoReload={false} showToast={true} />
</>
);
}
export default App;

View File

@@ -1,27 +0,0 @@
import request from "./request";
/**
* 通用文件上传方法(支持图片、文件)
* @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);
// 使用 request 方法上传文件,设置正确的 Content-Type
const res = await request(uploadUrl, formData, "POST", {
headers: {
"Content-Type": "multipart/form-data",
},
});
return res.url;
} catch (e: any) {
throw new Error(e?.message || "文件上传失败");
}
}

View File

@@ -1,90 +0,0 @@
import axios, {
AxiosInstance,
AxiosRequestConfig,
Method,
AxiosResponse,
} from "axios";
import { Toast } from "antd-mobile";
import { useUserStore } from "@/store/module/user";
const { token } = useUserStore.getState();
const DEFAULT_DEBOUNCE_GAP = 1000;
const debounceMap = new Map<string, number>();
const instance: AxiosInstance = axios.create({
baseURL: (import.meta as any).env?.VITE_API_BASE_URL || "/api",
timeout: 20000,
headers: {
"Content-Type": "application/json",
},
});
instance.interceptors.request.use((config: any) => {
if (token) {
config.headers = config.headers || {};
config.headers["Authorization"] = `Bearer ${token}`;
}
return config;
});
instance.interceptors.response.use(
(res: AxiosResponse) => {
const { code, success, msg } = res.data || {};
if (code === 200 || success) {
return res.data.data ?? res.data;
}
Toast.show({ content: msg || "接口错误", position: "top" });
if (code === 401) {
localStorage.removeItem("token");
const currentPath = window.location.pathname + window.location.search;
if (currentPath === "/login") {
window.location.href = "/login";
} else {
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
}
}
return Promise.reject(msg || "接口错误");
},
err => {
Toast.show({ content: err.message || "网络异常", position: "top" });
return Promise.reject(err);
},
);
export function request(
url: string,
data?: any,
method: Method = "GET",
config?: AxiosRequestConfig,
debounceGap?: number,
): Promise<any> {
const gap =
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;
const key = `${method}_${url}_${JSON.stringify(data)}`;
const now = Date.now();
const last = debounceMap.get(key) || 0;
if (gap > 0 && now - last < gap) {
// Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
return Promise.reject("请求过于频繁,请稍后再试");
}
debounceMap.set(key, now);
const axiosConfig: AxiosRequestConfig = {
url,
method,
...config,
};
// 如果是FormData不设置Content-Type让浏览器自动设置
if (data instanceof FormData) {
delete axiosConfig.headers?.["Content-Type"];
}
if (method.toUpperCase() === "GET") {
axiosConfig.params = data;
} else {
axiosConfig.data = data;
}
return instance(axiosConfig);
}
export default request;

View File

@@ -1,10 +0,0 @@
import request from "@/api/request";
// 获取好友列表
export function getAccountList(params: {
page: number;
limit: number;
keyword?: string;
}) {
return request("/v1/workbench/account-list", params, "GET");
}

View File

@@ -1,34 +0,0 @@
// 账号对象类型
export interface AccountItem {
id: number;
userName: string;
realName: string;
departmentName: string;
avatar?: string;
[key: string]: any;
}
//弹窗的
export interface SelectionPopupProps {
visible: boolean;
onVisibleChange: (visible: boolean) => void;
selectedOptions: AccountItem[];
onSelect: (options: AccountItem[]) => void;
readonly?: boolean;
onConfirm?: (selectedOptions: AccountItem[]) => void;
}
// 组件属性接口
export interface AccountSelectionProps {
selectedOptions: AccountItem[];
onSelect: (options: AccountItem[]) => void;
accounts?: AccountItem[]; // 可选:用于在外层显示已选账号详情
placeholder?: string;
className?: string;
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (selectedOptions: AccountItem[]) => void;
}

View File

@@ -1,231 +0,0 @@
.inputWrapper {
position: relative;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 20px;
}
.input {
padding-left: 38px !important;
height: 48px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.popupContainer {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
.popupHeader {
padding: 24px;
}
.popupTitle {
text-align: center;
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
}
.searchWrapper {
position: relative;
margin-bottom: 16px;
}
.searchInput {
padding-left: 40px !important;
padding-top: 8px !important;
padding-bottom: 8px !important;
border-radius: 24px !important;
border: 1px solid #e5e6eb !important;
font-size: 15px;
background: #f8f9fa;
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 16px;
}
.clearBtn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
height: 24px;
width: 24px;
border-radius: 50%;
min-width: 24px;
}
.friendList {
flex: 1;
overflow-y: auto;
}
.friendListInner {
border-top: 1px solid #f0f0f0;
}
.friendItem {
display: flex;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.radioWrapper {
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.radioSelected {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #1890ff;
display: flex;
align-items: center;
justify-content: center;
}
.radioUnselected {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #e5e6eb;
display: flex;
align-items: center;
justify-content: center;
}
.radioDot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #1890ff;
}
.friendInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.friendAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 500;
overflow: hidden;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.friendDetail {
flex: 1;
}
.friendName {
font-weight: 500;
font-size: 16px;
color: #222;
margin-bottom: 2px;
}
.friendId {
font-size: 13px;
color: #888;
margin-bottom: 2px;
}
.friendCustomer {
font-size: 13px;
color: #bdbdbd;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loadingText {
color: #888;
font-size: 15px;
}
.emptyBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.emptyText {
color: #888;
font-size: 15px;
}
.paginationRow {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.totalCount {
font-size: 14px;
color: #888;
}
.paginationControls {
display: flex;
align-items: center;
gap: 8px;
}
.pageBtn {
padding: 0 8px;
height: 32px;
min-width: 32px;
}
.pageInfo {
font-size: 14px;
color: #222;
}
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}
.cancelBtn {
padding: 0 24px;
border-radius: 24px;
border: 1px solid #e5e6eb;
}
.confirmBtn {
padding: 0 24px;
border-radius: 24px;
}

View File

@@ -1,139 +0,0 @@
import React, { useState } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Input } from "antd";
import style from "./index.module.scss";
import SelectionPopup from "./selectionPopup";
import { AccountItem, AccountSelectionProps } from "./data";
export default function AccountSelection({
selectedOptions,
onSelect,
accounts: propAccounts = [],
placeholder = "选择账号",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: AccountSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setRealVisible(true);
};
// 获取显示文本
const getDisplayText = () => {
if (selectedOptions.length === 0) return "";
return `已选择 ${selectedOptions.length} 个账号`;
};
// 删除已选账号
const handleRemoveAccount = (id: number) => {
if (readonly) return;
onSelect(selectedOptions.filter(d => d.id !== id));
};
return (
<>
{/* 输入框 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选账号列表窗口 */}
{showSelectedList && selectedOptions.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedOptions.map(acc => (
<div
key={acc.id}
className={style.selectedListRow}
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid #f0f0f0",
fontSize: 14,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{acc.realName} {acc.userName}
</div>
{!readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveAccount(acc.id)}
/>
)}
</div>
))}
</div>
)}
{/* 弹窗 */}
<SelectionPopup
visible={realVisible}
onVisibleChange={setRealVisible}
selectedOptions={selectedOptions}
onSelect={onSelect}
readonly={readonly}
onConfirm={onConfirm}
/>
</>
);
}

View File

@@ -1,202 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Popup } from "antd-mobile";
import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer";
import style from "./index.module.scss";
import { getAccountList } from "./api";
import { AccountItem, SelectionPopupProps } from "./data";
export default function SelectionPopup({
visible,
onVisibleChange,
selectedOptions,
onSelect,
readonly = false,
onConfirm,
}: SelectionPopupProps) {
const [accounts, setAccounts] = useState<AccountItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalAccounts, setTotalAccounts] = useState(0);
const [loading, setLoading] = useState(false);
// 累积已加载过的账号,确保确认时能返回更完整的对象
const loadedAccountMapRef = useRef<Map<number, AccountItem>>(new Map());
const pageSize = 20;
const fetchAccounts = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = { page, limit: pageSize };
if (keyword.trim()) params.keyword = keyword.trim();
const response = await getAccountList(params);
if (response && response.list) {
setAccounts(response.list);
const total: number = response.total || response.list.length || 0;
setTotalAccounts(total);
setTotalPages(Math.max(1, Math.ceil(total / pageSize)));
// 累积到映射表
response.list.forEach((acc: AccountItem) => {
loadedAccountMapRef.current.set(acc.id, acc);
});
} else {
setAccounts([]);
setTotalAccounts(0);
setTotalPages(1);
}
} catch (error) {
console.error("获取账号列表失败:", error);
} finally {
setLoading(false);
}
};
const handleAccountToggle = (account: AccountItem) => {
if (readonly || !onSelect) return;
const isSelected = selectedOptions.some(opt => opt.id === account.id);
const next = isSelected
? selectedOptions.filter(opt => opt.id !== account.id)
: selectedOptions.concat(account);
onSelect(next);
};
const handleConfirm = () => {
if (onConfirm) {
onConfirm(selectedOptions);
}
onVisibleChange(false);
};
// 弹窗打开时初始化数据
useEffect(() => {
if (visible) {
setCurrentPage(1);
setSearchQuery("");
loadedAccountMapRef.current.clear();
fetchAccounts(1, "");
}
}, [visible]);
// 搜索防抖
useEffect(() => {
if (!visible) return;
if (searchQuery === "") return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchAccounts(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible]);
// 页码变化
useEffect(() => {
if (!visible || currentPage === 1) return;
fetchAccounts(currentPage, searchQuery);
}, [currentPage, visible, searchQuery]);
const selectedIdSet = useMemo(
() => new Set(selectedOptions.map(opt => opt.id)),
[selectedOptions],
);
return (
<Popup
visible={visible && !readonly}
onMaskClick={() => onVisibleChange(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<Layout
header={
<PopupHeader
title="选择账号"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索账号"
loading={loading}
onRefresh={() => fetchAccounts(currentPage, searchQuery)}
/>
}
footer={
<PopupFooter
total={totalAccounts}
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedOptions.length}
onPageChange={setCurrentPage}
onCancel={() => onVisibleChange(false)}
onConfirm={handleConfirm}
/>
}
>
<div className={style.friendList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : accounts.length > 0 ? (
<div className={style.friendListInner}>
{accounts.map(acc => (
<label
key={acc.id}
className={style.friendItem}
onClick={() => !readonly && handleAccountToggle(acc)}
>
<div className={style.radioWrapper}>
<div
className={
selectedIdSet.has(acc.id)
? style.radioSelected
: style.radioUnselected
}
>
{selectedIdSet.has(acc.id) && (
<div className={style.radioDot}></div>
)}
</div>
</div>
<div className={style.friendInfo}>
<div className={style.friendAvatar}>
{acc.avatar ? (
<img
src={acc.avatar}
alt={acc.userName}
className={style.avatarImg}
/>
) : (
(acc.userName?.charAt(0) ?? "?")
)}
</div>
<div className={style.friendDetail}>
<div className={style.friendName}>{acc.userName}</div>
<div className={style.friendId}>
: {acc.realName}
</div>
<div className={style.friendId}>
: {acc.departmentName}
</div>
</div>
</div>
</label>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{searchQuery
? `没有找到包含"${searchQuery}"的账号`
: "没有找到账号"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
);
}

View File

@@ -1,5 +0,0 @@
import request from "@/api/request";
export function getContentLibraryList(params: any) {
return request("/v1/content/library/list", params, "GET");
}

View File

@@ -1,21 +0,0 @@
// 内容库接口类型
export interface ContentItem {
id: number;
name: string;
[key: string]: any;
}
// 组件属性接口
export interface ContentSelectionProps {
selectedOptions: ContentItem[];
onSelect: (selectedItems: ContentItem[]) => void;
placeholder?: string;
className?: string;
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (selectedItems: ContentItem[]) => void;
}

View File

@@ -1,117 +0,0 @@
.inputWrapper {
position: relative;
}
.selectedListWindow {
margin-top: 8px;
border: 1px solid #e5e6eb;
border-radius: 8px;
background: #fff;
}
.selectedListRow {
display: flex;
align-items: center;
padding: 4px 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.libraryList {
flex: 1;
overflow-y: auto;
}
.libraryListInner {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.libraryItem {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 1px solid #f0f0f0;
background: #fff;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.checkboxWrapper {
margin-top: 4px;
}
.checkboxSelected {
width: 20px;
height: 20px;
border-radius: 4px;
background: #1677ff;
display: flex;
align-items: center;
justify-content: center;
}
.checkboxUnselected {
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid #e5e6eb;
background: #fff;
}
.checkboxDot {
width: 12px;
height: 12px;
border-radius: 2px;
background: #fff;
}
.libraryInfo {
flex: 1;
}
.libraryHeader {
display: flex;
align-items: center;
justify-content: space-between;
}
.libraryName {
font-weight: 500;
font-size: 16px;
color: #222;
}
.typeTag {
font-size: 12px;
color: #1677ff;
border: 1px solid #1677ff;
border-radius: 12px;
padding: 2px 10px;
margin-left: 8px;
background: #f4f8ff;
font-weight: 500;
}
.libraryMeta {
font-size: 12px;
color: #888;
}
.libraryDesc {
font-size: 13px;
color: #888;
margin-top: 4px;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loadingText {
color: #888;
font-size: 15px;
}
.emptyBox {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
}
.emptyText {
color: #888;
font-size: 15px;
}

View File

@@ -1,302 +0,0 @@
import React, { useState, useEffect } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Input } from "antd";
import { Popup, Checkbox } from "antd-mobile";
import style from "./index.module.scss";
import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer";
import { getContentLibraryList } from "./api";
import { ContentItem, ContentSelectionProps } from "./data";
// 类型标签文本
const getTypeText = (type?: number) => {
if (type === 1) return "文本";
if (type === 2) return "图片";
if (type === 3) return "视频";
return "未知";
};
// 时间格式化
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-";
const d = new Date(dateStr);
if (isNaN(d.getTime())) return "-";
return `${d.getFullYear()}/${(d.getMonth() + 1)
.toString()
.padStart(2, "0")}/${d.getDate().toString().padStart(2, "0")} ${d
.getHours()
.toString()
.padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d
.getSeconds()
.toString()
.padStart(2, "0")}`;
};
export default function ContentSelection({
selectedOptions,
onSelect,
placeholder = "选择内容库",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: ContentSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
const [libraries, setLibraries] = useState<ContentItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalLibraries, setTotalLibraries] = useState(0);
const [loading, setLoading] = useState(false);
// 删除已选内容库
const handleRemoveLibrary = (id: number) => {
if (readonly) return;
onSelect(selectedOptions.filter(c => c.id !== id));
};
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setCurrentPage(1);
setSearchQuery("");
setRealVisible(true);
fetchLibraries(1, "");
};
// 当页码变化时,拉取对应页数据(弹窗已打开时)
useEffect(() => {
if (realVisible && currentPage !== 1) {
fetchLibraries(currentPage, searchQuery);
}
}, [currentPage, realVisible, searchQuery]);
// 搜索防抖
useEffect(() => {
if (!realVisible) return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchLibraries(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, realVisible]);
// 获取内容库列表API
const fetchLibraries = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = {
page,
limit: 20,
};
if (keyword.trim()) {
params.keyword = keyword.trim();
}
const response = await getContentLibraryList(params);
if (response && response.list) {
setLibraries(response.list);
setTotalLibraries(response.total || 0);
setTotalPages(Math.ceil((response.total || 0) / 20));
}
} catch (error) {
console.error("获取内容库列表失败:", error);
} finally {
setLoading(false);
}
};
// 处理内容库选择
const handleLibraryToggle = (library: ContentItem) => {
if (readonly) return;
const newSelected = selectedOptions.some(c => c.id === library.id)
? selectedOptions.filter(c => c.id !== library.id)
: [...selectedOptions, library];
onSelect(newSelected);
};
// 获取显示文本
const getDisplayText = () => {
if (selectedOptions.length === 0) return "";
return `已选择 ${selectedOptions.length} 个内容库`;
};
// 确认选择
const handleConfirm = () => {
if (onConfirm) {
onConfirm(selectedOptions);
}
setRealVisible(false);
};
return (
<>
{/* 输入框 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选内容库列表窗口 */}
{showSelectedList && selectedOptions.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedOptions.map(item => (
<div
key={item.id}
className={style.selectedListRow}
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid #f0f0f0",
fontSize: 14,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{item.name || item.id}
</div>
{!readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveLibrary(item.id)}
/>
)}
</div>
))}
</div>
)}
{/* 弹窗 */}
<Popup
visible={realVisible && !readonly}
onMaskClick={() => setRealVisible(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<Layout
header={
<PopupHeader
title="选择内容库"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索内容库"
loading={loading}
onRefresh={() => fetchLibraries(currentPage, searchQuery)}
/>
}
footer={
<PopupFooter
total={totalLibraries}
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedOptions.length}
onPageChange={setCurrentPage}
onCancel={() => setRealVisible(false)}
onConfirm={handleConfirm}
/>
}
>
<div className={style.libraryList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : libraries.length > 0 ? (
<div className={style.libraryListInner}>
{libraries.map(item => (
<label key={item.id} className={style.libraryItem}>
<Checkbox
checked={selectedOptions.map(c => c.id).includes(item.id)}
onChange={() => !readonly && handleLibraryToggle(item)}
disabled={readonly}
className={style.checkboxWrapper}
/>
<div className={style.libraryInfo}>
<div className={style.libraryHeader}>
<span className={style.libraryName}>{item.name}</span>
<span className={style.typeTag}>
{getTypeText(item.sourceType)}
</span>
</div>
<div className={style.libraryMeta}>
<div>: {item.creatorName || "-"}</div>
<div>: {formatDate(item.updateTime)}</div>
</div>
{item.description && (
<div className={style.libraryDesc}>
{item.description}
</div>
)}
</div>
</label>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{searchQuery
? `没有找到包含"${searchQuery}"的内容库`
: "没有找到内容库"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
</>
);
}

View File

@@ -1,10 +0,0 @@
import request from "@/api/request";
// 获取设备列表
export function getDeviceList(params: {
page: number;
limit: number;
keyword?: string;
}) {
return request("/v1/devices", params, "GET");
}

View File

@@ -1,26 +0,0 @@
// 设备选择项接口
export interface DeviceSelectionItem {
id: number;
memo: string;
imei: string;
wechatId: string;
status: "online" | "offline";
wxid?: string;
nickname?: string;
usedInPlans?: number;
}
// 组件属性接口
export interface DeviceSelectionProps {
selectedOptions: DeviceSelectionItem[];
onSelect: (devices: DeviceSelectionItem[]) => void;
placeholder?: string;
className?: string;
mode?: "input" | "dialog"; // 新增默认input
open?: boolean; // 仅mode=dialog时生效
onOpenChange?: (open: boolean) => void; // 仅mode=dialog时生效
selectedListMaxHeight?: number; // 新增已选列表最大高度默认500
showInput?: boolean; // 新增
showSelectedList?: boolean; // 新增
readonly?: boolean; // 新增
}

View File

@@ -1,182 +0,0 @@
.inputWrapper {
position: relative;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
z-index: 10;
font-size: 18px;
}
.input {
padding-left: 38px !important;
height: 56px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.popupHeader {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.popupTitle {
font-size: 20px;
font-weight: 600;
text-align: center;
}
.popupSearchRow {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
}
.popupSearchInputWrap {
position: relative;
flex: 1;
}
.popupSearchInput {
padding-left: 36px !important;
border-radius: 12px !important;
height: 44px;
font-size: 15px;
border: 1px solid #e5e6eb !important;
background: #f8f9fa;
}
.statusSelect {
width: 120px;
height: 40px;
border-radius: 8px;
border: 1px solid #e5e6eb;
font-size: 15px;
padding: 0 10px;
background: #fff;
}
.deviceList {
flex: 1;
overflow-y: auto;
}
.deviceListInner {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.deviceItem {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 1px solid #f0f0f0;
background: #fff;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.deviceCheckbox {
margin-top: 4px;
}
.deviceInfo {
flex: 1;
}
.deviceInfoRow {
display: flex;
align-items: center;
justify-content: space-between;
}
.deviceName {
font-weight: 500;
font-size: 16px;
color: #222;
}
.statusOnline {
width: 56px;
height: 24px;
border-radius: 12px;
background: #52c41a;
color: #fff;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
}
.statusOffline {
width: 56px;
height: 24px;
border-radius: 12px;
background: #e5e6eb;
color: #888;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
}
.deviceInfoDetail {
font-size: 13px;
color: #888;
margin-top: 4px;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loadingText {
color: #888;
font-size: 15px;
}
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}
.refreshBtn {
width: 36px;
height: 36px;
}
.paginationRow {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.totalCount {
font-size: 14px;
color: #888;
}
.paginationControls {
display: flex;
align-items: center;
gap: 8px;
}
.pageBtn {
padding: 0 8px;
height: 32px;
min-width: 32px;
border-radius: 16px;
}
.pageInfo {
font-size: 14px;
color: #222;
margin: 0 8px;
}

View File

@@ -1,139 +0,0 @@
import React, { useState } from "react";
import { SearchOutlined } from "@ant-design/icons";
import { Input, Button } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import { DeviceSelectionProps } from "./data";
import SelectionPopup from "./selectionPopup";
import style from "./index.module.scss";
const DeviceSelection: React.FC<DeviceSelectionProps> = ({
selectedOptions,
onSelect,
placeholder = "选择设备",
className = "",
mode = "input",
open,
onOpenChange,
selectedListMaxHeight = 300, // 默认300
showInput = true,
showSelectedList = true,
readonly = false,
}) => {
// 弹窗控制
const [popupVisible, setPopupVisible] = useState(false);
const isDialog = mode === "dialog";
const realVisible = isDialog ? !!open : popupVisible;
const setRealVisible = (v: boolean) => {
if (isDialog && onOpenChange) onOpenChange(v);
if (!isDialog) setPopupVisible(v);
};
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setRealVisible(true);
};
// 获取显示文本
const getDisplayText = () => {
if (selectedOptions.length === 0) return "";
return `已选择 ${selectedOptions.length} 个设备`;
};
// 删除已选设备
const handleRemoveDevice = (id: number) => {
if (readonly) return;
onSelect(selectedOptions.filter(v => v.id !== id));
};
return (
<>
{/* mode=input 显示输入框mode=dialog不显示 */}
{mode === "input" && showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选设备列表窗口 */}
{mode === "input" && showSelectedList && selectedOptions.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedOptions.map(device => (
<div
key={device.id}
className={style.selectedListRow}
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid #f0f0f0",
fontSize: 14,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{device.memo} - {device.wechatId}
</div>
{!readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveDevice(device.id)}
/>
)}
</div>
))}
</div>
)}
{/* 弹窗 */}
<SelectionPopup
visible={realVisible && !readonly}
onClose={() => setRealVisible(false)}
selectedOptions={selectedOptions}
onSelect={onSelect}
/>
</>
);
};
export default DeviceSelection;

View File

@@ -1,198 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import { Checkbox, Popup } from "antd-mobile";
import { getDeviceList } from "./api";
import style from "./index.module.scss";
import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer";
import { DeviceSelectionItem } from "./data";
interface SelectionPopupProps {
visible: boolean;
onClose: () => void;
selectedOptions: DeviceSelectionItem[];
onSelect: (devices: DeviceSelectionItem[]) => void;
}
const PAGE_SIZE = 20;
const SelectionPopup: React.FC<SelectionPopupProps> = ({
visible,
onClose,
selectedOptions,
onSelect,
}) => {
// 设备数据
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
// 获取设备列表支持keyword和分页
const fetchDevices = useCallback(
async (keyword: string = "", page: number = 1) => {
setLoading(true);
try {
const res = await getDeviceList({
page,
limit: PAGE_SIZE,
keyword: keyword.trim() || undefined,
});
if (res && Array.isArray(res.list)) {
setDevices(
res.list.map((d: any) => ({
id: d.id?.toString() || "",
memo: d.memo || d.imei || "",
imei: d.imei || "",
wechatId: d.wechatId || "",
status: d.alive === 1 ? "online" : "offline",
wxid: d.wechatId || "",
nickname: d.nickname || "",
usedInPlans: d.usedInPlans || 0,
})),
);
setTotal(res.total || 0);
}
} catch (error) {
console.error("获取设备列表失败:", error);
} finally {
setLoading(false);
}
},
[],
);
// 打开弹窗时获取第一页
useEffect(() => {
if (visible) {
setSearchQuery("");
setCurrentPage(1);
fetchDevices("", 1);
}
}, [visible, fetchDevices]);
// 搜索防抖
useEffect(() => {
if (!visible) return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchDevices(searchQuery, 1);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible, fetchDevices]);
// 翻页时重新请求
useEffect(() => {
if (!visible) return;
fetchDevices(searchQuery, currentPage);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]);
// 过滤设备(只保留状态过滤)
const filteredDevices = devices.filter(device => {
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "online" && device.status === "online") ||
(statusFilter === "offline" && device.status === "offline");
return matchesStatus;
});
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// 处理设备选择
const handleDeviceToggle = (device: DeviceSelectionItem) => {
if (selectedOptions.some(v => v.id === device.id)) {
onSelect(selectedOptions.filter(v => v.id !== device.id));
} else {
const newSelectedOptions = [...selectedOptions, device];
onSelect(newSelectedOptions);
}
};
return (
<Popup
visible={visible}
onMaskClick={onClose}
position="bottom"
bodyStyle={{ height: "100vh" }}
closeOnMaskClick={false}
>
<Layout
header={
<PopupHeader
title="选择设备"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索设备IMEI/备注/微信号"
loading={loading}
onRefresh={() => fetchDevices(searchQuery, currentPage)}
showTabs={true}
tabsConfig={{
activeKey: statusFilter,
onChange: setStatusFilter,
tabs: [
{ title: "全部", key: "all" },
{ title: "在线", key: "online" },
{ title: "离线", key: "offline" },
],
}}
/>
}
footer={
<PopupFooter
total={total}
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedOptions.length}
onPageChange={setCurrentPage}
onCancel={onClose}
onConfirm={onClose}
/>
}
>
<div className={style.deviceList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : (
<div className={style.deviceListInner}>
{filteredDevices.map(device => (
<label key={device.id} className={style.deviceItem}>
<Checkbox
checked={selectedOptions.some(v => v.id === device.id)}
onChange={() => handleDeviceToggle(device)}
className={style.deviceCheckbox}
/>
<div className={style.deviceInfo}>
<div className={style.deviceInfoRow}>
<span className={style.deviceName}>{device.memo}</span>
<div
className={
device.status === "online"
? style.statusOnline
: style.statusOffline
}
>
{device.status === "online" ? "在线" : "离线"}
</div>
</div>
<div className={style.deviceInfoDetail}>
<div>IMEI: {device.imei}</div>
<div>: {device.wechatId}</div>
</div>
</div>
</label>
))}
</div>
)}
</div>
</Layout>
</Popup>
);
};
export default SelectionPopup;

View File

@@ -1,11 +0,0 @@
import request from "@/api/request";
// 获取好友列表
export function getFriendList(params: {
page: number;
limit: number;
deviceIds?: string; // 逗号分隔
keyword?: string;
}) {
return request("/v1/friend", params, "GET");
}

View File

@@ -1,27 +0,0 @@
export interface FriendSelectionItem {
id: number;
wechatId: string;
nickname: string;
avatar: string;
[key: string]: any;
}
// 组件属性接口
export interface FriendSelectionProps {
selectedOptions?: FriendSelectionItem[];
onSelect: (friends: FriendSelectionItem[]) => void;
deviceIds?: string[];
enableDeviceFilter?: boolean;
placeholder?: string;
className?: string;
visible?: boolean; // 新增
onVisibleChange?: (visible: boolean) => void; // 新增
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (
selectedIds: number[],
selectedItems: FriendSelectionItem[],
) => void; // 新增
}

View File

@@ -1,246 +0,0 @@
.inputWrapper {
position: relative;
}
.selectedListRow {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.selectedListRowContent {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.selectedListRowContentText {
flex: 1;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 20px;
}
.input {
padding-left: 38px !important;
height: 48px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.popupContainer {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
.popupHeader {
padding: 24px;
}
.popupTitle {
text-align: center;
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
}
.searchWrapper {
position: relative;
margin-bottom: 16px;
}
.searchInput {
padding-left: 40px !important;
padding-top: 8px !important;
padding-bottom: 8px !important;
border-radius: 24px !important;
border: 1px solid #e5e6eb !important;
font-size: 15px;
background: #f8f9fa;
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 16px;
}
.clearBtn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
height: 24px;
width: 24px;
border-radius: 50%;
min-width: 24px;
}
.friendList {
flex: 1;
overflow-y: auto;
}
.friendListInner {
border-top: 1px solid #f0f0f0;
}
.friendItem {
display: flex;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.radioWrapper {
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.radioSelected {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #1890ff;
display: flex;
align-items: center;
justify-content: center;
}
.radioUnselected {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #e5e6eb;
display: flex;
align-items: center;
justify-content: center;
}
.radioDot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #1890ff;
}
.friendInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.friendAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 500;
overflow: hidden;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.friendDetail {
flex: 1;
}
.friendName {
font-weight: 500;
font-size: 16px;
color: #222;
margin-bottom: 2px;
}
.friendId {
font-size: 13px;
color: #888;
margin-bottom: 2px;
}
.friendCustomer {
font-size: 13px;
color: #bdbdbd;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loadingText {
color: #888;
font-size: 15px;
}
.emptyBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.emptyText {
color: #888;
font-size: 15px;
}
.paginationRow {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.totalCount {
font-size: 14px;
color: #888;
}
.paginationControls {
display: flex;
align-items: center;
gap: 8px;
}
.pageBtn {
padding: 0 8px;
height: 32px;
min-width: 32px;
}
.pageInfo {
font-size: 14px;
color: #222;
}
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}
.cancelBtn {
padding: 0 24px;
border-radius: 24px;
border: 1px solid #e5e6eb;
}
.confirmBtn {
padding: 0 24px;
border-radius: 24px;
}

View File

@@ -1,140 +0,0 @@
import React, { useState } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Input } from "antd";
import { Avatar } from "antd-mobile";
import style from "./index.module.scss";
import { FriendSelectionProps } from "./data";
import SelectionPopup from "./selectionPopup";
export default function FriendSelection({
selectedOptions = [],
onSelect,
deviceIds = [],
enableDeviceFilter = true,
placeholder = "选择微信好友",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: FriendSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
// 内部弹窗交给 selectionPopup 处理
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setRealVisible(true);
};
// 获取显示文本
const getDisplayText = () => {
if (!selectedOptions || selectedOptions.length === 0) return "";
return `已选择 ${selectedOptions.length} 个好友`;
};
// 删除已选好友
const handleRemoveFriend = (id: number) => {
if (readonly) return;
onSelect((selectedOptions || []).filter(v => v.id !== id));
};
// 弹窗确认回调
const handleConfirm = (
selectedIds: number[],
selectedItems: typeof selectedOptions,
) => {
onSelect(selectedItems);
if (onConfirm) onConfirm(selectedIds, selectedItems);
setRealVisible(false);
};
return (
<>
{/* 输入框 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选好友列表窗口 */}
{showSelectedList && (selectedOptions || []).length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{(selectedOptions || []).map(friend => (
<div key={friend.id} className={style.selectedListRow}>
<div className={style.selectedListRowContent}>
<Avatar src={friend.avatar} />
<div className={style.selectedListRowContentText}>
<div>{friend.nickname}</div>
<div>{friend.wechatId}</div>
</div>
{!readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveFriend(friend.id)}
/>
)}
</div>
</div>
))}
</div>
)}
{/* 弹窗 */}
<SelectionPopup
visible={realVisible && !readonly}
onVisibleChange={setRealVisible}
selectedOptions={selectedOptions || []}
onSelect={onSelect}
deviceIds={deviceIds}
enableDeviceFilter={enableDeviceFilter}
readonly={readonly}
onConfirm={handleConfirm}
/>
</>
);
}

View File

@@ -1,213 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import { Popup, Checkbox } from "antd-mobile";
import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer";
import { getFriendList } from "./api";
import style from "./index.module.scss";
import type { FriendSelectionItem } from "./data";
interface SelectionPopupProps {
visible: boolean;
onVisibleChange: (visible: boolean) => void;
selectedOptions: FriendSelectionItem[];
onSelect: (friends: FriendSelectionItem[]) => void;
deviceIds?: string[];
enableDeviceFilter?: boolean;
readonly?: boolean;
onConfirm?: (
selectedIds: number[],
selectedItems: FriendSelectionItem[],
) => void;
}
const SelectionPopup: React.FC<SelectionPopupProps> = ({
visible,
onVisibleChange,
selectedOptions,
onSelect,
deviceIds = [],
enableDeviceFilter = true,
readonly = false,
onConfirm,
}) => {
const [friends, setFriends] = useState<FriendSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalFriends, setTotalFriends] = useState(0);
const [loading, setLoading] = useState(false);
// 获取好友列表API
const fetchFriends = useCallback(
async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = {
page,
limit: 20,
};
if (keyword.trim()) {
params.keyword = keyword.trim();
}
if (enableDeviceFilter && deviceIds.length > 0) {
params.deviceIds = deviceIds.join(",");
}
const response = await getFriendList(params);
if (response && response.list) {
setFriends(response.list);
setTotalFriends(response.total || 0);
setTotalPages(Math.ceil((response.total || 0) / 20));
}
} catch (error) {
console.error("获取好友列表失败:", error);
} finally {
setLoading(false);
}
},
[deviceIds, enableDeviceFilter],
);
// 处理好友选择
const handleFriendToggle = (friend: FriendSelectionItem) => {
if (readonly) return;
const newSelectedFriends = selectedOptions.some(f => f.id === friend.id)
? selectedOptions.filter(f => f.id !== friend.id)
: selectedOptions.concat(friend);
onSelect(newSelectedFriends);
};
// 确认选择
const handleConfirm = () => {
if (onConfirm) {
onConfirm(
selectedOptions.map(v => v.id),
selectedOptions,
);
}
onVisibleChange(false);
};
// 弹窗打开时初始化
useEffect(() => {
if (visible) {
setCurrentPage(1);
setSearchQuery("");
fetchFriends(1, "");
}
}, [visible]); // 只在弹窗开启时请求
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
useEffect(() => {
if (!visible || searchQuery === "") return; // 弹窗关闭或搜索词为空时不请求
const timer = setTimeout(() => {
setCurrentPage(1);
fetchFriends(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible]);
// 页码变化时请求数据只在弹窗打开且页码不是1时执行
useEffect(() => {
if (!visible || currentPage === 1) return; // 弹窗关闭或第一页时不请求
fetchFriends(currentPage, searchQuery);
}, [currentPage, visible, searchQuery]);
return (
<Popup
visible={visible && !readonly}
onMaskClick={() => onVisibleChange(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<Layout
header={
<PopupHeader
title="选择微信好友"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索好友"
loading={loading}
onRefresh={() => fetchFriends(currentPage, searchQuery)}
/>
}
footer={
<PopupFooter
total={totalFriends}
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedOptions.length}
onPageChange={setCurrentPage}
onCancel={() => onVisibleChange(false)}
onConfirm={handleConfirm}
/>
}
>
<div className={style.friendList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : friends.length > 0 ? (
<div className={style.friendListInner}>
{friends.map(friend => (
<div key={friend.id} className={style.friendItem}>
<Checkbox
checked={selectedOptions.some(f => f.id === friend.id)}
onChange={() => !readonly && handleFriendToggle(friend)}
disabled={readonly}
style={{ marginRight: 12 }}
/>
<div className={style.friendInfo}>
<div className={style.friendAvatar}>
{friend.avatar ? (
<img
src={friend.avatar}
alt={friend.nickname}
className={style.avatarImg}
/>
) : (
friend.nickname.charAt(0)
)}
</div>
<div className={style.friendDetail}>
<div className={style.friendName}>{friend.nickname}</div>
<div className={style.friendId}>
ID: {friend.wechatId}
</div>
{friend.customer && (
<div className={style.friendCustomer}>
: {friend.customer}
</div>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{deviceIds.length === 0
? "请先选择设备"
: searchQuery
? `没有找到包含"${searchQuery}"的好友`
: "没有找到好友"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
);
};
export default SelectionPopup;

View File

@@ -1,10 +0,0 @@
import request from "@/api/request";
// 获取群组列表
export function getGroupList(params: {
page: number;
limit: number;
keyword?: string;
}) {
return request("/v1/chatroom", params, "GET");
}

View File

@@ -1,43 +0,0 @@
// 群组接口类型
export interface WechatGroup {
id: string;
chatroomId: string;
name: string;
avatar: string;
ownerWechatId: string;
ownerNickname: string;
ownerAvatar: string;
}
export interface GroupSelectionItem {
id: string;
avatar: string;
chatroomId?: string;
createTime?: number;
identifier?: string;
name: string;
ownerAlias?: string;
ownerAvatar?: string;
ownerNickname?: string;
ownerWechatId?: string;
[key: string]: any;
}
// 组件属性接口
export interface GroupSelectionProps {
selectedOptions: GroupSelectionItem[];
onSelect: (groups: GroupSelectionItem[]) => void;
onSelectDetail?: (groups: WechatGroup[]) => void;
placeholder?: string;
className?: string;
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (
selectedIds: string[],
selectedItems: GroupSelectionItem[],
) => void; // 新增
}

View File

@@ -1,206 +0,0 @@
.inputWrapper {
position: relative;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 20px;
}
.input {
padding-left: 38px !important;
height: 48px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.selectedListRow {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.selectedListRowContent {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.selectedListRowContentText {
flex: 1;
}
.popupContainer {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
.popupHeader {
padding: 24px;
}
.popupTitle {
text-align: center;
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
}
.searchWrapper {
position: relative;
margin-bottom: 16px;
}
.searchInput {
padding-left: 40px !important;
padding-top: 8px !important;
padding-bottom: 8px !important;
border-radius: 24px !important;
border: 1px solid #e5e6eb !important;
font-size: 15px;
background: #f8f9fa;
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 16px;
}
.clearBtn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
height: 24px;
width: 24px;
border-radius: 50%;
min-width: 24px;
}
.groupList {
flex: 1;
overflow-y: auto;
}
.groupListInner {
border-top: 1px solid #f0f0f0;
}
.groupItem {
display: flex;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.groupInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.groupAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 500;
overflow: hidden;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.groupDetail {
flex: 1;
}
.groupName {
font-weight: 500;
font-size: 16px;
color: #222;
margin-bottom: 2px;
}
.groupId {
font-size: 13px;
color: #888;
margin-bottom: 2px;
}
.groupOwner {
font-size: 13px;
color: #bdbdbd;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loadingText {
color: #888;
font-size: 15px;
}
.emptyBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.emptyText {
color: #888;
font-size: 15px;
}
.paginationRow {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.totalCount {
font-size: 14px;
color: #888;
}
.paginationControls {
display: flex;
align-items: center;
gap: 8px;
}
.pageBtn {
padding: 0 8px;
height: 32px;
min-width: 32px;
}
.pageInfo {
font-size: 14px;
color: #222;
}
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}

View File

@@ -1,126 +0,0 @@
import React, { useState } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Input } from "antd";
import { Avatar } from "antd-mobile";
import style from "./index.module.scss";
import SelectionPopup from "./selectionPopup";
import { GroupSelectionProps } from "./data";
export default function GroupSelection({
selectedOptions,
onSelect,
onSelectDetail,
placeholder = "选择群聊",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: GroupSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
// 删除已选群聊
const handleRemoveGroup = (id: string) => {
if (readonly) return;
onSelect(selectedOptions.filter(g => g.id !== id));
};
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setRealVisible(true);
};
// 获取显示文本
const getDisplayText = () => {
if (selectedOptions.length === 0) return "";
return `已选择 ${selectedOptions.length} 个群聊`;
};
return (
<>
{/* 输入框 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选群聊列表窗口 */}
{showSelectedList && selectedOptions.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedOptions.map(group => (
<div key={group.id} className={style.selectedListRow}>
<div className={style.selectedListRowContent}>
<Avatar src={group.avatar} />
<div className={style.selectedListRowContentText}>
<div>{group.name}</div>
<div>{group.chatroomId}</div>
</div>
{!readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveGroup(group.id)}
/>
)}
</div>
</div>
))}
</div>
)}
{/* 弹窗 */}
<SelectionPopup
visible={realVisible}
onVisibleChange={setRealVisible}
selectedOptions={selectedOptions}
onSelect={onSelect}
onSelectDetail={onSelectDetail}
readonly={readonly}
onConfirm={onConfirm}
/>
</>
);
}

View File

@@ -1,220 +0,0 @@
import React, { useState, useEffect } from "react";
import { Popup, Checkbox } from "antd-mobile";
import { getGroupList } from "./api";
import style from "./index.module.scss";
import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer";
import { GroupSelectionItem } from "./data";
// 群组接口类型
interface WechatGroup {
id: string;
name: string;
avatar: string;
chatroomId?: string;
ownerWechatId?: string;
ownerNickname?: string;
ownerAvatar?: string;
}
// 弹窗属性接口
interface SelectionPopupProps {
visible: boolean;
onVisibleChange: (visible: boolean) => void;
selectedOptions: GroupSelectionItem[];
onSelect: (groups: GroupSelectionItem[]) => void;
onSelectDetail?: (groups: WechatGroup[]) => void;
readonly?: boolean;
onConfirm?: (
selectedIds: string[],
selectedItems: GroupSelectionItem[],
) => void;
}
export default function SelectionPopup({
visible,
onVisibleChange,
selectedOptions,
onSelect,
onSelectDetail,
readonly = false,
onConfirm,
}: SelectionPopupProps) {
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);
// 获取群聊列表API
const fetchGroups = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = {
page,
limit: 20,
};
if (keyword.trim()) {
params.keyword = keyword.trim();
}
const response = await getGroupList(params);
if (response && response.list) {
setGroups(response.list);
setTotalGroups(response.total || 0);
setTotalPages(Math.ceil((response.total || 0) / 20));
}
} catch (error) {
console.error("获取群聊列表失败:", error);
} finally {
setLoading(false);
}
};
// 处理群聊选择
const handleGroupToggle = (group: GroupSelectionItem) => {
if (readonly) return;
const newSelectedGroups = selectedOptions.some(g => g.id === group.id)
? selectedOptions.filter(g => g.id !== group.id)
: selectedOptions.concat(group);
onSelect(newSelectedGroups);
// 如果有 onSelectDetail 回调,传递完整的群聊对象
if (onSelectDetail) {
const selectedGroupObjs = groups.filter(group =>
newSelectedGroups.some(g => g.id === group.id),
);
onSelectDetail(selectedGroupObjs);
}
};
// 确认选择
const handleConfirm = () => {
if (onConfirm) {
onConfirm(
selectedOptions.map(g => g.id),
selectedOptions,
);
}
onVisibleChange(false);
};
// 弹窗打开时初始化数据(只执行一次)
useEffect(() => {
if (visible) {
setCurrentPage(1);
setSearchQuery("");
fetchGroups(1, "");
}
}, [visible]);
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
useEffect(() => {
if (!visible || searchQuery === "") return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchGroups(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible]);
// 页码变化时请求数据只在弹窗打开且页码不是1时执行
useEffect(() => {
if (!visible || currentPage === 1) return;
fetchGroups(currentPage, searchQuery);
}, [currentPage, visible, searchQuery]);
return (
<Popup
visible={visible && !readonly}
onMaskClick={() => onVisibleChange(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<Layout
header={
<PopupHeader
title="选择群聊"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索群聊"
loading={loading}
onRefresh={() => fetchGroups(currentPage, searchQuery)}
/>
}
footer={
<PopupFooter
total={totalGroups}
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedOptions.length}
onPageChange={setCurrentPage}
onCancel={() => onVisibleChange(false)}
onConfirm={handleConfirm}
/>
}
>
<div className={style.groupList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : groups.length > 0 ? (
<div className={style.groupListInner}>
{groups.map(group => (
<div key={group.id} className={style.groupItem}>
<Checkbox
checked={selectedOptions.some(g => g.id === group.id)}
onChange={() => !readonly && handleGroupToggle(group)}
disabled={readonly}
style={{ marginRight: 12 }}
/>
<div className={style.groupInfo}>
<div className={style.groupAvatar}>
{group.avatar ? (
<img
src={group.avatar}
alt={group.name}
className={style.avatarImg}
/>
) : (
group.name.charAt(0)
)}
</div>
<div className={style.groupDetail}>
<div className={style.groupName}>{group.name}</div>
<div className={style.groupId}>
ID: {group.chatroomId}
</div>
{group.ownerNickname && (
<div className={style.groupOwner}>
: {group.ownerNickname}
</div>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{searchQuery
? `没有找到包含"${searchQuery}"的群聊`
: "没有找到群聊"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
);
}

View File

@@ -1,87 +0,0 @@
.listContainer {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.listItem {
flex-shrink: 0;
width: 100%;
}
.loadMoreButtonContainer {
display: flex;
justify-content: center;
align-items: center;
padding: 16px;
flex-shrink: 0;
}
.noMoreText {
text-align: center;
color: #999;
font-size: 14px;
padding: 16px;
flex-shrink: 0;
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #999;
flex: 1;
min-height: 200px;
}
.emptyIcon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.emptyText {
font-size: 14px;
color: #999;
}
.pullToRefresh {
height: 100%;
overflow: auto;
}
// 自定义滚动条样式
.listContainer::-webkit-scrollbar {
width: 4px;
}
.listContainer::-webkit-scrollbar-track {
background: transparent;
}
.listContainer::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
.listContainer::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
// 响应式设计
@media (max-width: 768px) {
.listContainer {
padding: 0 8px;
}
.loadMoreButtonContainer {
padding: 12px;
}
.noMoreText {
padding: 12px;
}
}

View File

@@ -1,195 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import {
PullToRefresh,
InfiniteScroll,
Button,
SpinLoading,
} from "antd-mobile";
import styles from "./InfiniteList.module.scss";
interface InfiniteListProps<T> {
// 数据相关
data: T[];
loading?: boolean;
hasMore?: boolean;
loadingText?: string;
noMoreText?: string;
// 渲染相关
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor?: (item: T, index: number) => string | number;
// 事件回调
onLoadMore?: () => Promise<void> | void;
onRefresh?: () => Promise<void> | void;
// 样式相关
className?: string;
itemClassName?: string;
containerStyle?: React.CSSProperties;
// 功能开关
enablePullToRefresh?: boolean;
enableInfiniteScroll?: boolean;
enableLoadMoreButton?: boolean;
// 自定义高度
height?: string | number;
minHeight?: string | number;
}
const InfiniteList = <T extends any>({
data,
loading = false,
hasMore = true,
loadingText = "加载中...",
noMoreText = "没有更多了",
renderItem,
keyExtractor = (_, index) => index,
onLoadMore,
onRefresh,
className = "",
itemClassName = "",
containerStyle = {},
enablePullToRefresh = true,
enableInfiniteScroll = true,
enableLoadMoreButton = false,
height = "100%",
minHeight = "200px",
}: InfiniteListProps<T>) => {
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// 处理下拉刷新
const handleRefresh = useCallback(async () => {
if (!onRefresh) return;
setRefreshing(true);
try {
await onRefresh();
} catch (error) {
console.error("Refresh failed:", error);
} finally {
setRefreshing(false);
}
}, [onRefresh]);
// 处理加载更多
const handleLoadMore = useCallback(async () => {
if (!onLoadMore || loadingMore || !hasMore) return;
setLoadingMore(true);
try {
await onLoadMore();
} catch (error) {
console.error("Load more failed:", error);
} finally {
setLoadingMore(false);
}
}, [onLoadMore, loadingMore, hasMore]);
// 点击加载更多按钮
const handleLoadMoreClick = useCallback(() => {
handleLoadMore();
}, [handleLoadMore]);
// 容器样式
const containerStyles: React.CSSProperties = {
height,
minHeight,
...containerStyle,
};
// 渲染列表项
const renderListItems = () => {
return data.map((item, index) => (
<div
key={keyExtractor(item, index)}
className={`${styles.listItem} ${itemClassName}`}
>
{renderItem(item, index)}
</div>
));
};
// 渲染加载更多按钮
const renderLoadMoreButton = () => {
if (!enableLoadMoreButton || !hasMore) return null;
return (
<div className={styles.loadMoreButtonContainer}>
<Button
size="small"
loading={loadingMore}
onClick={handleLoadMoreClick}
disabled={loading || !hasMore}
>
{loadingMore ? loadingText : "点击加载更多"}
</Button>
</div>
);
};
// 渲染无更多数据提示
const renderNoMoreText = () => {
if (hasMore || data.length === 0) return null;
return <div className={styles.noMoreText}>{noMoreText}</div>;
};
// 渲染空状态
const renderEmptyState = () => {
if (data.length > 0 || loading) return null;
return (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>📝</div>
<div className={styles.emptyText}></div>
</div>
);
};
const content = (
<div
className={`${styles.listContainer} ${className}`}
style={containerStyles}
>
{renderListItems()}
{renderLoadMoreButton()}
{renderNoMoreText()}
{renderEmptyState()}
{/* 无限滚动组件 */}
{enableInfiniteScroll && (
<InfiniteScroll
loadMore={handleLoadMore}
hasMore={hasMore}
threshold={100}
/>
)}
</div>
);
// 如果启用下拉刷新包装PullToRefresh
if (enablePullToRefresh && onRefresh) {
return (
<PullToRefresh
onRefresh={handleRefresh}
refreshing={refreshing}
className={styles.pullToRefresh}
>
{content}
</PullToRefresh>
);
}
return content;
};
export default InfiniteList;

View File

@@ -1,52 +0,0 @@
import React, { useEffect } from "react";
import { SpinLoading } from "antd-mobile";
import styles from "./layout.module.scss";
interface LayoutProps {
loading?: boolean;
children?: React.ReactNode;
header?: React.ReactNode;
footer?: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = ({
children,
header,
footer,
loading = false,
}) => {
// 移动端100vh兼容
useEffect(() => {
const setRealHeight = () => {
document.documentElement.style.setProperty(
"--real-vh",
`${window.innerHeight * 0.01}px`,
);
};
setRealHeight();
window.addEventListener("resize", setRealHeight);
return () => window.removeEventListener("resize", setRealHeight);
}, []);
return (
<div
className={styles.container}
style={{ height: "calc(var(--real-vh, 1vh) * 100)" }}
>
{header && <header>{header}</header>}
<main>
{loading ? (
<div className={styles.loadingContainer}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
<div className={styles.loadingText}>...</div>
</div>
) : (
children
)}
</main>
{footer && <footer>{footer}</footer>}
</div>
);
};
export default Layout;

View File

@@ -1,28 +0,0 @@
.container {
display: flex;
height: 100vh;
flex-direction: column;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
}
.container main {
flex: 1;
overflow: auto;
}
.loadingContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 300px;
background: rgba(255, 255, 255, 0.8);
}
.loadingText {
margin-top: 16px;
color: #666;
font-size: 14px;
text-align: center;
}

View File

@@ -1,53 +0,0 @@
import React from "react";
import ReactECharts from "echarts-for-react";
interface LineChartProps {
title?: string;
xData: string[];
yData: number[];
height?: number | string;
}
const LineChart: React.FC<LineChartProps> = ({
title = "",
xData,
yData,
height = 200,
}) => {
const option = {
title: {
text: title,
left: "center",
textStyle: { fontSize: 16 },
},
tooltip: { trigger: "axis" },
xAxis: {
type: "category",
data: xData,
boundaryGap: false,
},
yAxis: {
type: "value",
boundaryGap: ["10%", "10%"], // 上下留白
min: (value: any) => value.min - 10, // 下方多留一点空间
max: (value: any) => value.max + 10, // 上方多留一点空间
minInterval: 1,
axisLabel: { margin: 12 },
},
series: [
{
data: yData,
type: "line",
smooth: true,
symbol: "circle",
lineStyle: { color: "#1677ff" },
itemStyle: { color: "#1677ff" },
},
],
grid: { left: 40, right: 24, top: 40, bottom: 32 },
};
return <ReactECharts option={option} style={{ height, width: "100%" }} />;
};
export default LineChart;

View File

@@ -1,57 +0,0 @@
import React from "react";
import { TabBar } from "antd-mobile";
import { PieOutline, UserOutline } from "antd-mobile-icons";
import { HomeOutlined, TeamOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
const tabs = [
{
key: "home",
title: "首页",
icon: <HomeOutlined />,
path: "/",
},
{
key: "scenarios",
title: "场景获客",
icon: <TeamOutlined />,
path: "/scenarios",
},
{
key: "workspace",
title: "工作台",
icon: <PieOutline />,
path: "/workspace",
},
{
key: "mine",
title: "我的",
icon: <UserOutline />,
path: "/mine",
},
];
interface MeauMobileProps {
activeKey: string;
}
const MeauMobile: React.FC<MeauMobileProps> = ({ activeKey }) => {
const navigate = useNavigate();
return (
<TabBar
style={{ background: "#fff" }}
activeKey={activeKey}
onChange={key => {
const tab = tabs.find(t => t.key === key);
if (tab && tab.path) navigate(tab.path);
}}
>
{tabs.map(item => (
<TabBar.Item key={item.key} icon={item.icon} title={item.title} />
))}
</TabBar>
);
};
export default MeauMobile;

View File

@@ -1,62 +0,0 @@
import React, { useEffect, useState } from "react";
import { NavBar } from "antd-mobile";
import { ArrowLeftOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { getSafeAreaHeight } from "@/utils/common";
interface NavCommonProps {
title: string;
backFn?: () => void;
right?: React.ReactNode;
left?: React.ReactNode;
}
const NavCommon: React.FC<NavCommonProps> = ({
title,
backFn,
right,
left,
}) => {
const navigate = useNavigate();
const [paddingTop, setPaddingTop] = useState("0px");
useEffect(() => {
setPaddingTop(getSafeAreaHeight() + "px");
}, []);
return (
<div
style={{
paddingTop: paddingTop,
background: "#fff",
}}
>
<NavBar
back={null}
left={
left ? (
left
) : (
<div className="nav-title">
<ArrowLeftOutlined
twoToneColor="#1677ff"
onClick={() => {
if (backFn) {
backFn();
} else {
navigate(-1);
}
}}
/>
</div>
)
}
right={right}
>
<span style={{ color: "var(--primary-color)", fontWeight: 600 }}>
{title}
</span>
</NavBar>
</div>
);
};
export default NavCommon;

View File

@@ -1,56 +0,0 @@
import React from "react";
import { NavBar, Button } from "antd-mobile";
import { PlusOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
interface PlaceholderPageProps {
title: string;
showBack?: boolean;
showAddButton?: boolean;
addButtonText?: string;
showFooter?: boolean;
}
const PlaceholderPage: React.FC<PlaceholderPageProps> = ({
title,
showBack = true,
showAddButton = false,
addButtonText = "新建",
showFooter = true,
}) => {
return (
<Layout
header={
<NavBar
backArrow={showBack}
style={{ background: "#fff" }}
onBack={showBack ? () => window.history.back() : undefined}
left={
<div style={{ color: "var(--primary-color)", fontWeight: 600 }}>
{title}
</div>
}
right={
showAddButton ? (
<Button size="small" color="primary">
<PlusOutlined />
<span style={{ marginLeft: 4, fontSize: 12 }}>
{addButtonText}
</span>
</Button>
) : undefined
}
/>
}
footer={showFooter ? <MeauMobile /> : undefined}
>
<div style={{ padding: 20, textAlign: "center", color: "#666" }}>
<h3>{title}</h3>
<p>...</p>
</div>
</Layout>
);
};
export default PlaceholderPage;

View File

@@ -1,71 +0,0 @@
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}
.paginationRow {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.totalCount {
font-size: 14px;
color: #888;
}
.paginationControls {
display: flex;
align-items: center;
gap: 8px;
}
.pageBtn {
padding: 0 8px;
height: 32px;
min-width: 32px;
border-radius: 16px;
border: 1px solid #d9d9d9;
color: #333;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
border-color: #1677ff;
color: #1677ff;
}
&:disabled {
background: #f5f5f5;
color: #ccc;
cursor: not-allowed;
}
}
.pageInfo {
font-size: 14px;
color: #222;
margin: 0 8px;
min-width: 60px;
text-align: center;
}

View File

@@ -1,67 +0,0 @@
import React from "react";
import { Button } from "antd";
import style from "./footer.module.scss";
import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
interface PopupFooterProps {
total: number;
currentPage: number;
totalPages: number;
loading: boolean;
selectedCount: number;
onPageChange: (page: number) => void;
onCancel: () => void;
onConfirm: () => void;
}
const PopupFooter: React.FC<PopupFooterProps> = ({
total,
currentPage,
totalPages,
loading,
selectedCount,
onPageChange,
onCancel,
onConfirm,
}) => {
return (
<>
{/* 分页栏 */}
<div className={style.paginationRow}>
<div className={style.totalCount}> {total} </div>
<div className={style.paginationControls}>
<Button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className={style.pageBtn}
>
<ArrowLeftOutlined />
</Button>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<Button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages || loading}
className={style.pageBtn}
>
<ArrowRightOutlined />
</Button>
</div>
</div>
<div className={style.popupFooter}>
<div className={style.selectedCount}> {selectedCount} </div>
<div className={style.footerBtnGroup}>
<Button color="primary" variant="filled" onClick={onCancel}>
</Button>
<Button type="primary" onClick={onConfirm}>
</Button>
</div>
</div>
</>
);
};
export default PopupFooter;

View File

@@ -1,51 +0,0 @@
.popupHeader {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.popupTitle {
font-size: 20px;
font-weight: 600;
text-align: center;
}
.popupSearchRow {
display: flex;
align-items: center;
gap: 5px;
padding: 16px;
}
.popupSearchInputWrap {
position: relative;
flex: 1;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
z-index: 10;
font-size: 18px;
}
.refreshBtn {
width: 36px;
height: 36px;
}
.loadingIcon {
animation: spin 1s linear infinite;
font-size: 16px;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -1,86 +0,0 @@
import React from "react";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { Input, Button } from "antd";
import { Tabs } from "antd-mobile";
import style from "./header.module.scss";
interface PopupHeaderProps {
title: string;
searchQuery: string;
setSearchQuery: (value: string) => void;
searchPlaceholder?: string;
loading?: boolean;
onRefresh?: () => void;
showRefresh?: boolean;
showSearch?: boolean;
showTabs?: boolean;
tabsConfig?: {
activeKey: string;
onChange: (key: string) => void;
tabs: Array<{ title: string; key: string }>;
};
}
const PopupHeader: React.FC<PopupHeaderProps> = ({
title,
searchQuery,
setSearchQuery,
searchPlaceholder = "搜索...",
loading = false,
onRefresh,
showRefresh = true,
showSearch = true,
showTabs = false,
tabsConfig,
}) => {
return (
<>
<div className={style.popupHeader}>
<div className={style.popupTitle}>{title}</div>
</div>
{showSearch && (
<div className={style.popupSearchRow}>
<div className={style.popupSearchInputWrap}>
<Input
placeholder={searchPlaceholder}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
prefix={<SearchOutlined />}
size="large"
/>
</div>
{showRefresh && onRefresh && (
<Button
type="text"
onClick={onRefresh}
disabled={loading}
className={style.refreshBtn}
>
{loading ? (
<div className={style.loadingIcon}></div>
) : (
<ReloadOutlined />
)}
</Button>
)}
</div>
)}
{showTabs && tabsConfig && (
<Tabs
activeKey={tabsConfig.activeKey}
onChange={tabsConfig.onChange}
style={{ marginTop: 8 }}
>
{tabsConfig.tabs.map(tab => (
<Tabs.Tab key={tab.key} title={tab.title} />
))}
</Tabs>
)}
</>
);
};
export default PopupHeader;

View File

@@ -1,43 +0,0 @@
import React from "react";
import { Steps } from "antd-mobile";
interface StepIndicatorProps {
currentStep: number;
steps: { id: number; title: string; subtitle: string }[];
}
const StepIndicator: React.FC<StepIndicatorProps> = ({
currentStep,
steps,
}) => {
return (
<div style={{ overflowX: "auto", padding: "30px 0px", background: "#fff" }}>
<Steps current={currentStep - 1}>
{steps.map((step, idx) => (
<Steps.Step
key={step.id}
title={step.subtitle}
icon={
<div
style={{
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: idx < currentStep ? "#1677ff" : "#cccccc",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{step.id}
</div>
}
/>
))}
</Steps>
</div>
);
};
export default StepIndicator;

View File

@@ -1,180 +0,0 @@
import React, { useState, useEffect } from "react";
import { Button } from "antd-mobile";
import { updateChecker } from "@/utils/updateChecker";
import {
ReloadOutlined,
CloudDownloadOutlined,
RocketOutlined,
} from "@ant-design/icons";
interface UpdateNotificationProps {
position?: "top" | "bottom";
autoReload?: boolean;
showToast?: boolean;
}
const UpdateNotification: React.FC<UpdateNotificationProps> = ({
position = "top",
autoReload = false,
showToast = true,
}) => {
const [hasUpdate, setHasUpdate] = useState(false);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// 注册更新检测回调
const handleUpdate = (info: { hasUpdate: boolean }) => {
if (info.hasUpdate) {
setHasUpdate(true);
setIsVisible(true);
if (autoReload) {
// 自动刷新
setTimeout(() => {
updateChecker.forceReload();
}, 3000);
}
}
};
updateChecker.onUpdate(handleUpdate);
// 启动更新检测
updateChecker.start();
return () => {
updateChecker.offUpdate(handleUpdate);
updateChecker.stop();
};
}, [autoReload, showToast]);
const handleReload = () => {
updateChecker.forceReload();
};
if (!isVisible || !hasUpdate) {
return null;
}
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 99999,
background: "linear-gradient(135deg, #1890ff 0%, #096dd9 100%)",
color: "white",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "20px",
textAlign: "center",
}}
>
{/* 背景装饰 */}
<div
style={{
position: "absolute",
top: "10%",
left: "50%",
transform: "translateX(-50%)",
fontSize: "120px",
opacity: 0.1,
animation: "float 3s ease-in-out infinite",
}}
>
<RocketOutlined />
</div>
{/* 主要内容 */}
<div style={{ position: "relative", zIndex: 1 }}>
{/* 图标 */}
<div
style={{
fontSize: "80px",
marginBottom: "20px",
animation: "pulse 2s ease-in-out infinite",
}}
>
<CloudDownloadOutlined />
</div>
{/* 标题 */}
<div
style={{
fontSize: "28px",
fontWeight: "bold",
marginBottom: "12px",
textShadow: "0 2px 4px rgba(0,0,0,0.3)",
}}
>
</div>
{/* 描述 */}
<div
style={{
fontSize: "16px",
opacity: 0.9,
marginBottom: "40px",
lineHeight: "1.5",
maxWidth: "300px",
}}
>
</div>
{/* 更新按钮 */}
<Button
size="large"
style={{
background: "rgba(255,255,255,0.9)",
border: "2px solid rgba(255,255,255,0.5)",
color: "#1890ff",
fontSize: "18px",
fontWeight: "bold",
padding: "12px 40px",
borderRadius: "50px",
backdropFilter: "blur(10px)",
boxShadow: "0 8px 32px rgba(24,144,255,0.3)",
transition: "all 0.3s ease",
}}
onClick={handleReload}
>
<ReloadOutlined style={{ marginRight: "8px" }} />
</Button>
{/* 提示文字 */}
<div
style={{
fontSize: "12px",
opacity: 0.7,
marginTop: "20px",
}}
>
</div>
</div>
{/* 动画样式 */}
<style>
{`
@keyframes float {
0%, 100% { transform: translateX(-50%) translateY(0px); }
50% { transform: translateX(-50%) translateY(-20px); }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
`}
</style>
</div>
);
};
export default UpdateNotification;

View File

@@ -1,484 +0,0 @@
.uploadContainer {
width: 100%;
// 自定义上传组件样式
:global {
.adm-image-uploader {
.adm-image-uploader-upload-button {
width: 100px;
height: 100px;
border: 1px dashed #d9d9d9;
border-radius: 8px;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #1677ff;
background: #f0f8ff;
}
.adm-image-uploader-upload-button-icon {
font-size: 32px;
color: #999;
}
}
.adm-image-uploader-item {
width: 100px;
height: 100px;
border-radius: 8px;
overflow: hidden;
position: relative;
.adm-image-uploader-item-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.adm-image-uploader-item-delete {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
cursor: pointer;
}
.adm-image-uploader-item-loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
}
// 禁用状态
.uploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.uploadContainer.error {
:global {
.adm-image-uploader-upload-button {
border-color: #ff4d4f;
background: #fff2f0;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.uploadContainer {
:global {
.adm-image-uploader {
.adm-image-uploader-upload-button,
.adm-image-uploader-item {
width: 80px;
height: 80px;
}
.adm-image-uploader-upload-button-icon {
font-size: 28px;
}
}
}
}
}
// 头像上传组件样式
.avatarUploadContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
.avatarWrapper {
position: relative;
border-radius: 50%;
overflow: hidden;
background: #f0f0f0;
border: 2px solid #e0e0e0;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: var(--primary-color);
box-shadow: 0 4px 12px rgba(24, 142, 238, 0.3);
}
.avatarImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarPlaceholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 40px;
}
.avatarUploadOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
opacity: 0;
transition: opacity 0.3s ease;
&:hover {
opacity: 1;
}
.uploadLoading {
font-size: 12px;
text-align: center;
line-height: 1.4;
}
}
.avatarDeleteBtn {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
background: #ff4d4f;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
z-index: 10;
&:hover {
background: #ff7875;
transform: scale(1.1);
}
}
&:hover .avatarUploadOverlay {
opacity: 1;
}
}
.avatarTip {
font-size: 12px;
color: #999;
text-align: center;
line-height: 1.4;
max-width: 200px;
}
}
// 视频上传组件样式
.videoUploadContainer {
width: 100%;
.videoUploadButton {
width: 100%;
min-height: 120px;
border: 2px dashed #d9d9d9;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #1890ff;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
}
&:active {
transform: translateY(0);
}
.uploadingContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
padding: 20px;
.uploadingIcon {
font-size: 32px;
color: #1890ff;
animation: pulse 2s infinite;
}
.uploadingText {
font-size: 14px;
color: #666;
font-weight: 500;
}
.uploadProgress {
width: 100%;
max-width: 200px;
}
}
.uploadContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
text-align: center;
.uploadIcon {
font-size: 48px;
color: #1890ff;
transition: all 0.3s ease;
}
.uploadText {
.uploadTitle {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.uploadSubtitle {
font-size: 12px;
color: #666;
line-height: 1.4;
}
}
&:hover .uploadIcon {
transform: scale(1.1);
color: #40a9ff;
}
}
}
.videoItem {
width: 100%;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
}
.videoItemContent {
display: flex;
align-items: center;
gap: 12px;
.videoIcon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
flex-shrink: 0;
}
.videoInfo {
flex: 1;
min-width: 0;
.videoName {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.videoSize {
font-size: 12px;
color: #666;
}
}
.videoActions {
display: flex;
gap: 4px;
flex-shrink: 0;
.previewBtn,
.deleteBtn {
padding: 4px 8px;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background: #f5f5f5;
}
}
.previewBtn {
color: #1890ff;
&:hover {
color: #40a9ff;
background: #e6f7ff;
}
}
.deleteBtn {
color: #ff4d4f;
&:hover {
color: #ff7875;
background: #fff2f0;
}
}
}
}
.itemProgress {
margin-top: 8px;
}
}
.videoPreview {
display: flex;
justify-content: center;
align-items: center;
background: #000;
border-radius: 8px;
overflow: hidden;
video {
border-radius: 8px;
}
}
}
// 动画效果
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
// 暗色主题支持
@media (prefers-color-scheme: dark) {
.videoUploadContainer {
.videoUploadButton {
background: linear-gradient(135deg, #2a2a2a 0%, #1f1f1f 100%);
border-color: #434343;
&:hover {
background: linear-gradient(135deg, #1a365d 0%, #2d3748 100%);
border-color: #40a9ff;
}
.uploadingContainer {
.uploadingText {
color: #ccc;
}
}
.uploadContent {
.uploadText {
.uploadTitle {
color: #fff;
}
.uploadSubtitle {
color: #ccc;
}
}
}
}
.videoItem {
background: #2a2a2a;
border-color: #434343;
&:hover {
border-color: #40a9ff;
}
.videoItemContent {
.videoInfo {
.videoName {
color: #fff;
}
.videoSize {
color: #ccc;
}
}
.videoActions {
.previewBtn,
.deleteBtn {
&:hover {
background: #434343;
}
}
}
}
}
}
}

View File

@@ -1,188 +0,0 @@
import React, { useState, useEffect } from "react";
import { Toast, Dialog } from "antd-mobile";
import { UserOutlined, CameraOutlined } from "@ant-design/icons";
import style from "./index.module.scss";
interface AvatarUploadProps {
value?: string;
onChange?: (url: string) => void;
disabled?: boolean;
className?: string;
size?: number; // 头像尺寸
}
const AvatarUpload: React.FC<AvatarUploadProps> = ({
value = "",
onChange,
disabled = false,
className,
size = 100,
}) => {
const [uploading, setUploading] = useState(false);
const [avatarUrl, setAvatarUrl] = useState(value);
useEffect(() => {
setAvatarUrl(value);
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
// 检查文件类型
const isValidType = file.type.startsWith("image/");
if (!isValidType) {
Toast.show("只能上传图片文件!");
return null;
}
// 检查文件大小 (5MB)
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
Toast.show("图片大小不能超过5MB");
return null;
}
return file;
};
// 上传函数
const upload = async (file: File): Promise<{ url: string }> => {
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/v1/attachment/upload`,
{
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: formData,
},
);
if (!response.ok) {
throw new Error("上传失败");
}
const result = await response.json();
if (result.code === 200) {
Toast.show("头像上传成功");
// 确保返回的是字符串URL
let url = "";
if (typeof result.data === "string") {
url = result.data;
} else if (result.data && typeof result.data === "object") {
url = result.data.url || "";
}
return { url };
} else {
throw new Error(result.msg || "上传失败");
}
} catch (error) {
Toast.show("头像上传失败,请重试");
throw error;
}
};
// 处理头像上传
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || disabled || uploading) return;
const validatedFile = beforeUpload(file);
if (!validatedFile) return;
setUploading(true);
try {
const result = await upload(validatedFile);
setAvatarUrl(result.url);
onChange?.(result.url);
} catch (error) {
console.error("头像上传失败:", error);
} finally {
setUploading(false);
}
};
// 删除头像
const handleDelete = () => {
return Dialog.confirm({
content: "确定要删除头像吗?",
onConfirm: () => {
setAvatarUrl("");
onChange?.("");
Toast.show("头像已删除");
},
});
};
return (
<div className={`${style.avatarUploadContainer} ${className || ""}`}>
<div
className={style.avatarWrapper}
style={{ width: size, height: size }}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt="头像"
className={style.avatarImage}
style={{ width: size, height: size }}
/>
) : (
<div
className={style.avatarPlaceholder}
style={{ width: size, height: size }}
>
<UserOutlined />
</div>
)}
{/* 上传覆盖层 */}
<div
className={style.avatarUploadOverlay}
onClick={() =>
!disabled && !uploading && fileInputRef.current?.click()
}
>
{uploading ? (
<div className={style.uploadLoading}>...</div>
) : (
<CameraOutlined />
)}
</div>
{/* 删除按钮 */}
{avatarUrl && !disabled && (
<div className={style.avatarDeleteBtn} onClick={handleDelete}>
×
</div>
)}
</div>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleAvatarChange}
disabled={disabled || uploading}
/>
{/* 提示文字 */}
<div className={style.avatarTip}>
{uploading
? "正在上传头像..."
: "点击头像可更换支持JPG、PNG格式大小不超过5MB"}
</div>
</div>
);
};
// 创建 ref
const fileInputRef = React.createRef<HTMLInputElement>();
export default AvatarUpload;

View File

@@ -1,254 +0,0 @@
import React, { useState } from "react";
import { Input, Button, Card, Space, Typography, Divider } from "antd";
import { SendOutlined } from "@ant-design/icons";
import ChatFileUpload from "./index";
const { TextArea } = Input;
const { Text } = Typography;
interface ChatMessage {
id: string;
type: "text" | "file";
content: string;
timestamp: Date;
fileInfo?: {
url: string;
name: string;
type: string;
size: number;
};
}
const ChatFileUploadExample: React.FC = () => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState("");
// 处理文件上传
const handleFileUploaded = (fileInfo: {
url: string;
name: string;
type: string;
size: number;
}) => {
const newMessage: ChatMessage = {
id: Date.now().toString(),
type: "file",
content: `文件: ${fileInfo.name}`,
timestamp: new Date(),
fileInfo,
};
setMessages(prev => [...prev, newMessage]);
};
// 处理文本发送
const handleSendText = () => {
if (!inputValue.trim()) return;
const newMessage: ChatMessage = {
id: Date.now().toString(),
type: "text",
content: inputValue,
timestamp: new Date(),
};
setMessages(prev => [...prev, newMessage]);
setInputValue("");
};
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 获取文件类型图标
const getFileTypeIcon = (type: string, name: string) => {
const lowerType = type.toLowerCase();
const lowerName = name.toLowerCase();
if (lowerType.startsWith("image/")) {
return "🖼️";
} else if (lowerType.startsWith("video/")) {
return "🎥";
} else if (lowerType.startsWith("audio/")) {
return "🎵";
} else if (lowerType === "application/pdf") {
return "📄";
} else if (lowerName.endsWith(".doc") || lowerName.endsWith(".docx")) {
return "📝";
} else if (lowerName.endsWith(".xls") || lowerName.endsWith(".xlsx")) {
return "📊";
} else if (lowerName.endsWith(".ppt") || lowerName.endsWith(".pptx")) {
return "📈";
} else {
return "📎";
}
};
return (
<div style={{ maxWidth: 600, margin: "0 auto", padding: 20 }}>
<Card title="聊天文件上传示例" style={{ marginBottom: 20 }}>
<Space direction="vertical" style={{ width: "100%" }}>
<Text></Text>
<ul>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</Space>
</Card>
{/* 聊天消息区域 */}
<Card
title="聊天记录"
style={{
height: 400,
marginBottom: 20,
overflowY: "auto",
}}
bodyStyle={{ height: 320, overflowY: "auto" }}
>
{messages.length === 0 ? (
<div style={{ textAlign: "center", color: "#999", marginTop: 100 }}>
</div>
) : (
<div>
{messages.map(message => (
<div key={message.id} style={{ marginBottom: 16 }}>
<div
style={{
background: "#f0f0f0",
padding: 12,
borderRadius: 8,
maxWidth: "80%",
wordBreak: "break-word",
}}
>
{message.type === "text" ? (
<div>{message.content}</div>
) : (
<div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 4,
}}
>
<span>
{getFileTypeIcon(
message.fileInfo!.type,
message.fileInfo!.name,
)}
</span>
<Text strong>{message.fileInfo!.name}</Text>
</div>
<div style={{ fontSize: 12, color: "#666" }}>
: {formatFileSize(message.fileInfo!.size)}
</div>
<div style={{ fontSize: 12, color: "#666" }}>
: {message.fileInfo!.type}
</div>
<a
href={message.fileInfo!.url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 12, color: "#1890ff" }}
>
</a>
</div>
)}
<div
style={{
fontSize: 11,
color: "#999",
marginTop: 4,
textAlign: "right",
}}
>
{message.timestamp.toLocaleTimeString()}
</div>
</div>
</div>
))}
</div>
)}
</Card>
{/* 输入区域 */}
<Card title="发送消息">
<Space direction="vertical" style={{ width: "100%" }}>
<TextArea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
placeholder="输入消息内容..."
autoSize={{ minRows: 2, maxRows: 4 }}
onPressEnter={e => {
if (!e.shiftKey) {
e.preventDefault();
handleSendText();
}
}}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Space>
{/* 文件上传组件 */}
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={50} // 最大50MB
accept="*/*" // 接受所有文件类型
buttonText="文件"
buttonIcon={<span>📎</span>}
/>
{/* 图片上传组件 */}
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={10} // 最大10MB
accept="image/*" // 只接受图片
buttonText="图片"
buttonIcon={<span>🖼</span>}
/>
{/* 文档上传组件 */}
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={20} // 最大20MB
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx" // 只接受文档
buttonText="文档"
buttonIcon={<span>📄</span>}
/>
</Space>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSendText}
disabled={!inputValue.trim()}
>
</Button>
</div>
</Space>
</Card>
</div>
);
};
export default ChatFileUploadExample;

View File

@@ -1,48 +0,0 @@
.chatFileUpload {
display: inline-block;
.uploadButton {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border: none;
background: transparent;
color: #666;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: #1890ff;
background-color: rgba(24, 144, 255, 0.1);
}
&:disabled {
color: #ccc;
cursor: not-allowed;
&:hover {
background-color: transparent;
}
}
.anticon {
font-size: 16px;
}
}
}
// 移动端适配
@media (max-width: 768px) {
.chatFileUpload {
.uploadButton {
padding: 6px 8px;
font-size: 12px;
.anticon {
font-size: 14px;
}
}
}
}

View File

@@ -1,189 +0,0 @@
import React, { useRef, useState } from "react";
import { Button, message } from "antd";
import {
PaperClipOutlined,
LoadingOutlined,
FileOutlined,
FileImageOutlined,
FileVideoOutlined,
FileAudioOutlined,
FilePdfOutlined,
FileWordOutlined,
FileExcelOutlined,
FilePptOutlined,
} from "@ant-design/icons";
import { uploadFile } from "@/api/common";
import style from "./index.module.scss";
interface ChatFileUploadProps {
onFileUploaded?: (fileInfo: {
url: string;
name: string;
type: string;
size: number;
}) => void;
disabled?: boolean;
className?: string;
maxSize?: number; // 最大文件大小(MB)
accept?: string; // 接受的文件类型
buttonText?: string;
buttonIcon?: React.ReactNode;
}
const ChatFileUpload: React.FC<ChatFileUploadProps> = ({
onFileUploaded,
disabled = false,
className,
maxSize = 50, // 默认50MB
accept = "*/*", // 默认接受所有文件类型
buttonText = "发送文件",
buttonIcon = <PaperClipOutlined />,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
// 获取文件图标
const getFileIcon = (file: File) => {
const type = file.type.toLowerCase();
const name = file.name.toLowerCase();
if (type.startsWith("image/")) {
return <FileImageOutlined />;
} else if (type.startsWith("video/")) {
return <FileVideoOutlined />;
} else if (type.startsWith("audio/")) {
return <FileAudioOutlined />;
} else if (type === "application/pdf") {
return <FilePdfOutlined />;
} else if (name.endsWith(".doc") || name.endsWith(".docx")) {
return <FileWordOutlined />;
} else if (name.endsWith(".xls") || name.endsWith(".xlsx")) {
return <FileExcelOutlined />;
} else if (name.endsWith(".ppt") || name.endsWith(".pptx")) {
return <FilePptOutlined />;
} else {
return <FileOutlined />;
}
};
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 验证文件
const validateFile = (file: File): boolean => {
// 检查文件大小
if (file.size > maxSize * 1024 * 1024) {
message.error(`文件大小不能超过 ${maxSize}MB`);
return false;
}
// 检查文件类型如果指定了accept
if (accept !== "*/*") {
const acceptTypes = accept.split(",").map(type => type.trim());
const fileType = file.type;
const fileName = file.name.toLowerCase();
const isValidType = acceptTypes.some(type => {
if (type.startsWith(".")) {
// 扩展名匹配
return fileName.endsWith(type);
} else if (type.includes("*")) {
// MIME类型通配符匹配
const baseType = type.replace("*", "");
return fileType.startsWith(baseType);
} else {
// 精确MIME类型匹配
return fileType === type;
}
});
if (!isValidType) {
message.error(`不支持的文件类型: ${file.type}`);
return false;
}
}
return true;
};
// 处理文件选择
const handleFileSelect = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const files = event.target.files;
if (!files || files.length === 0) return;
const file = files[0];
// 验证文件
if (!validateFile(file)) {
// 清空input值允许重新选择同一文件
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
setUploading(true);
try {
// 上传文件
const fileUrl = await uploadFile(file);
// 调用回调函数,传递文件信息
onFileUploaded?.({
url: fileUrl,
name: file.name,
type: file.type,
size: file.size,
});
message.success("文件上传成功");
} catch (error: any) {
message.error(error.message || "文件上传失败");
} finally {
setUploading(false);
// 清空input值允许重新选择同一文件
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
// 触发文件选择
const handleClick = () => {
if (disabled || uploading) return;
fileInputRef.current?.click();
};
return (
<div className={`${style.chatFileUpload} ${className || ""}`}>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleFileSelect}
style={{ display: "none" }}
/>
<Button
type="text"
icon={uploading ? <LoadingOutlined /> : buttonIcon}
onClick={handleClick}
disabled={disabled || uploading}
className={style.uploadButton}
title={buttonText}
>
{buttonText}
</Button>
</div>
);
};
export default ChatFileUpload;

View File

@@ -1,265 +0,0 @@
.fileUploadContainer {
width: 100%;
// 覆盖 antd Upload 组件的默认样式
:global {
.ant-upload {
width: 100%;
}
.ant-upload-list {
width: 100%;
}
.ant-upload-list-text {
width: 100%;
}
.ant-upload-list-text .ant-upload-list-item {
width: 100%;
}
}
.fileUploadButton {
width: 100%;
aspect-ratio: 16 / 9;
min-height: clamp(90px, 20vw, 180px);
border: 2px dashed #d9d9d9;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #1890ff;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
}
&:active {
transform: translateY(0);
}
.uploadingContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
padding: 20px;
.uploadingIcon {
font-size: clamp(24px, 4vw, 32px);
color: #1890ff;
animation: pulse 2s infinite;
}
.uploadingText {
font-size: clamp(11px, 2vw, 14px);
color: #666;
font-weight: 500;
}
.uploadProgress {
width: 100%;
max-width: 200px;
}
}
.uploadContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
text-align: center;
.uploadIcon {
font-size: clamp(50px, 6vw, 48px);
color: #1890ff;
transition: all 0.3s ease;
}
.uploadText {
.uploadTitle {
font-size: clamp(14px, 2.5vw, 16px);
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.uploadSubtitle {
font-size: clamp(10px, 1.5vw, 14px);
color: #666;
line-height: 1.4;
}
}
&:hover .uploadIcon {
transform: scale(1.1);
color: #40a9ff;
}
}
}
.fileItem {
width: 100%;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
}
.fileItemContent {
display: flex;
align-items: center;
gap: 12px;
.fileIcon {
width: clamp(28px, 5vw, 40px);
height: clamp(28px, 5vw, 40px);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(14px, 2.5vw, 18px);
flex-shrink: 0;
// Excel文件图标样式
:global(.anticon-file-excel) {
color: #217346;
background: rgba(33, 115, 70, 0.1);
}
// Word文件图标样式
:global(.anticon-file-word) {
color: #2b579a;
background: rgba(43, 87, 154, 0.1);
}
// PPT文件图标样式
:global(.anticon-file-ppt) {
color: #d24726;
background: rgba(210, 71, 38, 0.1);
}
// 默认文件图标样式
:global(.anticon-file) {
color: #666;
background: rgba(102, 102, 102, 0.1);
}
}
.fileInfo {
flex: 1;
min-width: 0;
.fileName {
font-size: clamp(11px, 2vw, 14px);
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fileSize {
font-size: clamp(10px, 1.5vw, 12px);
color: #666;
}
}
.fileActions {
display: flex;
gap: 4px;
flex-shrink: 0;
.previewBtn,
.deleteBtn {
padding: 4px 8px;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background: #f5f5f5;
}
}
.previewBtn {
color: #1890ff;
&:hover {
color: #40a9ff;
background: #e6f7ff;
}
}
.deleteBtn {
color: #ff4d4f;
&:hover {
color: #ff7875;
background: #fff2f0;
}
}
}
}
.itemProgress {
margin-top: 8px;
}
}
.filePreview {
display: flex;
justify-content: center;
align-items: center;
background: #f5f5f5;
border-radius: 8px;
overflow: hidden;
iframe {
border-radius: 8px;
}
}
}
// 禁用状态
.fileUploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.fileUploadContainer.error {
.fileUploadButton {
border-color: #ff4d4f;
background: #fff2f0;
}
}
// 动画效果
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}

View File

@@ -1,459 +0,0 @@
import React, { useState } from "react";
import { Upload, message, Progress, Button, Modal } from "antd";
import {
LoadingOutlined,
FileOutlined,
DeleteOutlined,
EyeOutlined,
CloudUploadOutlined,
FileExcelOutlined,
FileWordOutlined,
FilePptOutlined,
} from "@ant-design/icons";
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
import style from "./index.module.scss";
interface FileUploadProps {
value?: string | string[]; // 支持单个字符串或字符串数组
onChange?: (url: string | string[]) => void; // 支持单个字符串或字符串数组
disabled?: boolean;
className?: string;
maxSize?: number; // 最大文件大小(MB)
showPreview?: boolean; // 是否显示预览
maxCount?: number; // 最大上传数量默认为1
acceptTypes?: string[]; // 接受的文件类型
}
const FileUpload: React.FC<FileUploadProps> = ({
value = "",
onChange,
disabled = false,
className,
maxSize = 10,
showPreview = true,
maxCount = 1,
acceptTypes = ["excel", "word", "ppt"],
}) => {
const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [previewVisible, setPreviewVisible] = useState(false);
const [previewUrl, setPreviewUrl] = useState("");
// 文件类型配置
const fileTypeConfig = {
excel: {
accept: ".xlsx,.xls",
mimeTypes: [
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
],
icon: FileExcelOutlined,
name: "Excel文件",
extensions: ["xlsx", "xls"],
},
word: {
accept: ".docx,.doc",
mimeTypes: [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
],
icon: FileWordOutlined,
name: "Word文件",
extensions: ["docx", "doc"],
},
ppt: {
accept: ".pptx,.ppt",
mimeTypes: [
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.ms-powerpoint",
],
icon: FilePptOutlined,
name: "PPT文件",
extensions: ["pptx", "ppt"],
},
};
// 生成accept字符串
const generateAcceptString = () => {
return acceptTypes
.map(type => fileTypeConfig[type as keyof typeof fileTypeConfig]?.accept)
.filter(Boolean)
.join(",");
};
// 获取文件类型信息
const getFileTypeInfo = (file: File) => {
const extension = file.name.split(".").pop()?.toLowerCase();
for (const type of acceptTypes) {
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
if (config && config.extensions.includes(extension || "")) {
return config;
}
}
return null;
};
// 获取文件图标
const getFileIcon = (file: File) => {
const typeInfo = getFileTypeInfo(file);
return typeInfo ? typeInfo.icon : FileOutlined;
};
React.useEffect(() => {
if (value) {
// 处理单个字符串或字符串数组
const urls = Array.isArray(value) ? value : [value];
const files: UploadFile[] = urls.map((url, index) => ({
uid: `file-${index}`,
name: `document-${index + 1}`,
status: "done",
url: url || "",
}));
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
const typeInfo = getFileTypeInfo(file);
if (!typeInfo) {
const allowedTypes = acceptTypes
.map(type => fileTypeConfig[type as keyof typeof fileTypeConfig]?.name)
.filter(Boolean)
.join("、");
message.error(`只能上传${allowedTypes}`);
return false;
}
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
if (!isLtMaxSize) {
message.error(`文件大小不能超过${maxSize}MB`);
return false;
}
return true;
};
// 处理文件变化
const handleChange: UploadProps["onChange"] = info => {
// 更新 fileList确保所有 URL 都是字符串
const updatedFileList = info.fileList.map(file => {
let url = "";
if (file.url) {
url = file.url;
} else if (file.response) {
// 处理响应对象
if (typeof file.response === "string") {
url = file.response;
} else if (file.response.data) {
url =
typeof file.response.data === "string"
? file.response.data
: file.response.data.url || "";
} else if (file.response.url) {
url = file.response.url;
}
}
return {
...file,
url: url,
};
});
setFileList(updatedFileList);
// 处理上传状态
if (info.file.status === "uploading") {
setLoading(true);
// 模拟上传进度
const progress = Math.min(99, Math.random() * 100);
setUploadProgress(progress);
} else if (info.file.status === "done") {
setLoading(false);
setUploadProgress(100);
message.success("文件上传成功!");
// 从响应中获取上传后的URL
let uploadedUrl = "";
if (info.file.response) {
if (typeof info.file.response === "string") {
uploadedUrl = info.file.response;
} else if (info.file.response.data) {
uploadedUrl =
typeof info.file.response.data === "string"
? info.file.response.data
: info.file.response.data.url || "";
} else if (info.file.response.url) {
uploadedUrl = info.file.response.url;
}
}
if (uploadedUrl) {
if (maxCount === 1) {
// 单个文件模式
onChange?.(uploadedUrl);
} else {
// 多个文件模式
const currentUrls = Array.isArray(value)
? value
: value
? [value]
: [];
const newUrls = [...currentUrls, uploadedUrl];
onChange?.(newUrls);
}
}
} else if (info.file.status === "error") {
setLoading(false);
setUploadProgress(0);
message.error("上传失败,请重试");
} else if (info.file.status === "removed") {
if (maxCount === 1) {
onChange?.("");
} else {
// 多个文件模式,移除对应的文件
const currentUrls = Array.isArray(value) ? value : value ? [value] : [];
const removedIndex = info.fileList.findIndex(
f => f.uid === info.file.uid,
);
if (removedIndex !== -1) {
const newUrls = currentUrls.filter(
(_, index) => index !== removedIndex,
);
onChange?.(newUrls);
}
}
}
};
// 删除文件
const handleRemove = (file?: UploadFile) => {
Modal.confirm({
title: "确认删除",
content: "确定要删除这个文件吗?",
okText: "确定",
cancelText: "取消",
onOk: () => {
if (maxCount === 1) {
setFileList([]);
onChange?.("");
} else if (file) {
// 多个文件模式,删除指定文件
const currentUrls = Array.isArray(value)
? value
: value
? [value]
: [];
const fileIndex = fileList.findIndex(f => f.uid === file.uid);
if (fileIndex !== -1) {
const newUrls = currentUrls.filter(
(_, index) => index !== fileIndex,
);
onChange?.(newUrls);
}
}
message.success("文件已删除");
},
});
return true;
};
// 预览文件
const handlePreview = (url: string) => {
setPreviewUrl(url);
setPreviewVisible(true);
};
// 获取文件大小显示
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 自定义上传按钮
const uploadButton = (
<div className={style.fileUploadButton}>
{loading ? (
<div className={style.uploadingContainer}>
<div className={style.uploadingIcon}>
<LoadingOutlined spin />
</div>
<div className={style.uploadingText}>...</div>
<Progress
percent={uploadProgress}
size="small"
showInfo={false}
strokeColor="#1890ff"
className={style.uploadProgress}
/>
</div>
) : (
<div className={style.uploadContent}>
<div className={style.uploadIcon}>
<CloudUploadOutlined />
</div>
<div className={style.uploadText}>
<div className={style.uploadTitle}>
{maxCount === 1
? "上传文档"
: `上传文档 (${fileList.length}/${maxCount})`}
</div>
<div className={style.uploadSubtitle}>
{" "}
{acceptTypes
.map(
type =>
fileTypeConfig[type as keyof typeof fileTypeConfig]?.name,
)
.filter(Boolean)
.join("、")}
{maxSize}MB
{maxCount > 1 && `,最多上传 ${maxCount} 个文件`}
</div>
</div>
</div>
)}
</div>
);
// 自定义文件列表项
const customItemRender = (
originNode: React.ReactElement,
file: UploadFile,
) => {
const FileIcon = file.originFileObj
? getFileIcon(file.originFileObj)
: FileOutlined;
if (file.status === "uploading") {
return (
<div className={style.fileItem}>
<div className={style.fileItemContent}>
<div className={style.fileIcon}>
<FileIcon />
</div>
<div className={style.fileInfo}>
<div className={style.fileName}>{file.name}</div>
<div className={style.fileSize}>
{file.size ? formatFileSize(file.size) : "计算中..."}
</div>
</div>
<div className={style.fileActions}>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove(file)}
className={style.deleteBtn}
/>
</div>
</div>
<Progress
percent={uploadProgress}
size="small"
strokeColor="#1890ff"
className={style.itemProgress}
/>
</div>
);
}
if (file.status === "done") {
return (
<div className={style.fileItem}>
<div className={style.fileItemContent}>
<div className={style.fileIcon}>
<FileIcon />
</div>
<div className={style.fileInfo}>
<div className={style.fileName}>{file.name}</div>
<div className={style.fileSize}>
{file.size ? formatFileSize(file.size) : "未知大小"}
</div>
</div>
<div className={style.fileActions}>
{showPreview && (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handlePreview(file.url || "")}
className={style.previewBtn}
/>
)}
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove(file)}
className={style.deleteBtn}
/>
</div>
</div>
</div>
);
}
return originNode;
};
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
return (
<div className={`${style.fileUploadContainer} ${className || ""}`}>
<Upload
name="file"
headers={{
Authorization: `Bearer ${localStorage.getItem("token")}`,
}}
action={action}
multiple={maxCount > 1}
fileList={fileList}
accept={generateAcceptString()}
listType="text"
showUploadList={{
showPreviewIcon: false,
showRemoveIcon: false,
showDownloadIcon: false,
}}
disabled={disabled || loading}
beforeUpload={beforeUpload}
onChange={handleChange}
onRemove={handleRemove}
maxCount={maxCount}
itemRender={customItemRender}
>
{fileList.length >= maxCount ? null : uploadButton}
</Upload>
{/* 文件预览模态框 */}
<Modal
title="文件预览"
open={previewVisible}
onCancel={() => setPreviewVisible(false)}
footer={null}
width={800}
centered
>
<div className={style.filePreview}>
<iframe
src={previewUrl}
style={{ width: "100%", height: "500px", border: "none" }}
title="文件预览"
/>
</div>
</Modal>
</div>
);
};
export default FileUpload;

View File

@@ -1,141 +0,0 @@
import React, { useState, useEffect } from "react";
import { ImageUploader, Toast, Dialog } from "antd-mobile";
import type { ImageUploadItem } from "antd-mobile/es/components/image-uploader";
import style from "./index.module.scss";
interface UploadComponentProps {
value?: string[];
onChange?: (urls: string[]) => void;
count?: number; // 最大上传数量
accept?: string; // 文件类型
disabled?: boolean;
className?: string;
}
const UploadComponent: React.FC<UploadComponentProps> = ({
value = [],
onChange,
count = 9,
accept = "image/*",
disabled = false,
className,
}) => {
const [fileList, setFileList] = useState<ImageUploadItem[]>([]);
// 将value转换为fileList格式
useEffect(() => {
if (value && value.length > 0) {
const files = value.map((url, index) => ({
url: url || "",
uid: `file-${index}`,
}));
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
// 检查文件类型
const isValidType = file.type.startsWith(accept.replace("*", ""));
if (!isValidType) {
Toast.show(`只能上传${accept}格式的文件!`);
return null;
}
// 检查文件大小 (5MB)
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
Toast.show("文件大小不能超过5MB");
return null;
}
return file;
};
// 上传函数
const upload = async (file: File): Promise<{ url: string }> => {
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/v1/attachment/upload`,
{
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: formData,
},
);
if (!response.ok) {
throw new Error("上传失败");
}
const result = await response.json();
if (result.code === 200) {
Toast.show("上传成功");
// 确保返回的是字符串URL
let url = "";
if (typeof result.data === "string") {
url = result.data;
} else if (result.data && typeof result.data === "object") {
url = result.data.url || "";
}
return { url };
} else {
throw new Error(result.msg || "上传失败");
}
} catch (error) {
Toast.show("上传失败,请重试");
throw error;
}
};
// 处理文件变化
const handleChange = (files: ImageUploadItem[]) => {
setFileList(files);
// 提取URL数组并传递给父组件
const urls = files
.map(file => file.url)
.filter(url => Boolean(url)) as string[];
onChange?.(urls);
};
// 删除确认
const handleDelete = () => {
return Dialog.confirm({
content: "确定要删除这张图片吗?",
});
};
// 数量超出限制
const handleCountExceed = (exceed: number) => {
Toast.show(`最多选择 ${count} 张图片,你多选了 ${exceed}`);
};
return (
<div className={`${style.uploadContainer} ${className || ""}`}>
<ImageUploader
value={fileList}
onChange={handleChange}
upload={upload}
beforeUpload={beforeUpload}
onDelete={handleDelete}
onCountExceed={handleCountExceed}
multiple={count > 1}
maxCount={count}
showUpload={fileList.length < count && !disabled}
accept={accept}
/>
</div>
);
};
export default UploadComponent;

View File

@@ -1,484 +0,0 @@
.uploadContainer {
width: 100%;
// 自定义上传组件样式
:global {
.adm-image-uploader {
.adm-image-uploader-upload-button {
width: 100px;
height: 100px;
border: 1px dashed #d9d9d9;
border-radius: 8px;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #1677ff;
background: #f0f8ff;
}
.adm-image-uploader-upload-button-icon {
font-size: 32px;
color: #999;
}
}
.adm-image-uploader-item {
width: 100px;
height: 100px;
border-radius: 8px;
overflow: hidden;
position: relative;
.adm-image-uploader-item-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.adm-image-uploader-item-delete {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
cursor: pointer;
}
.adm-image-uploader-item-loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
}
// 禁用状态
.uploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.uploadContainer.error {
:global {
.adm-image-uploader-upload-button {
border-color: #ff4d4f;
background: #fff2f0;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.uploadContainer {
:global {
.adm-image-uploader {
.adm-image-uploader-upload-button,
.adm-image-uploader-item {
width: 80px;
height: 80px;
}
.adm-image-uploader-upload-button-icon {
font-size: 28px;
}
}
}
}
}
// 头像上传组件样式
.avatarUploadContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
.avatarWrapper {
position: relative;
border-radius: 50%;
overflow: hidden;
background: #f0f0f0;
border: 2px solid #e0e0e0;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: var(--primary-color);
box-shadow: 0 4px 12px rgba(24, 142, 238, 0.3);
}
.avatarImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarPlaceholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 40px;
}
.avatarUploadOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
opacity: 0;
transition: opacity 0.3s ease;
&:hover {
opacity: 1;
}
.uploadLoading {
font-size: 12px;
text-align: center;
line-height: 1.4;
}
}
.avatarDeleteBtn {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
background: #ff4d4f;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
z-index: 10;
&:hover {
background: #ff7875;
transform: scale(1.1);
}
}
&:hover .avatarUploadOverlay {
opacity: 1;
}
}
.avatarTip {
font-size: 12px;
color: #999;
text-align: center;
line-height: 1.4;
max-width: 200px;
}
}
// 视频上传组件样式
.videoUploadContainer {
width: 100%;
.videoUploadButton {
width: 100%;
min-height: 120px;
border: 2px dashed #d9d9d9;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #1890ff;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
}
&:active {
transform: translateY(0);
}
.uploadingContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
padding: 20px;
.uploadingIcon {
font-size: 32px;
color: #1890ff;
animation: pulse 2s infinite;
}
.uploadingText {
font-size: 14px;
color: #666;
font-weight: 500;
}
.uploadProgress {
width: 100%;
max-width: 200px;
}
}
.uploadContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
text-align: center;
.uploadIcon {
font-size: 48px;
color: #1890ff;
transition: all 0.3s ease;
}
.uploadText {
.uploadTitle {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.uploadSubtitle {
font-size: 12px;
color: #666;
line-height: 1.4;
}
}
&:hover .uploadIcon {
transform: scale(1.1);
color: #40a9ff;
}
}
}
.videoItem {
width: 100%;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
}
.videoItemContent {
display: flex;
align-items: center;
gap: 12px;
.videoIcon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
flex-shrink: 0;
}
.videoInfo {
flex: 1;
min-width: 0;
.videoName {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.videoSize {
font-size: 12px;
color: #666;
}
}
.videoActions {
display: flex;
gap: 4px;
flex-shrink: 0;
.previewBtn,
.deleteBtn {
padding: 4px 8px;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background: #f5f5f5;
}
}
.previewBtn {
color: #1890ff;
&:hover {
color: #40a9ff;
background: #e6f7ff;
}
}
.deleteBtn {
color: #ff4d4f;
&:hover {
color: #ff7875;
background: #fff2f0;
}
}
}
}
.itemProgress {
margin-top: 8px;
}
}
.videoPreview {
display: flex;
justify-content: center;
align-items: center;
background: #000;
border-radius: 8px;
overflow: hidden;
video {
border-radius: 8px;
}
}
}
// 动画效果
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
// 暗色主题支持
@media (prefers-color-scheme: dark) {
.videoUploadContainer {
.videoUploadButton {
background: linear-gradient(135deg, #2a2a2a 0%, #1f1f1f 100%);
border-color: #434343;
&:hover {
background: linear-gradient(135deg, #1a365d 0%, #2d3748 100%);
border-color: #40a9ff;
}
.uploadingContainer {
.uploadingText {
color: #ccc;
}
}
.uploadContent {
.uploadText {
.uploadTitle {
color: #fff;
}
.uploadSubtitle {
color: #ccc;
}
}
}
}
.videoItem {
background: #2a2a2a;
border-color: #434343;
&:hover {
border-color: #40a9ff;
}
.videoItemContent {
.videoInfo {
.videoName {
color: #fff;
}
.videoSize {
color: #ccc;
}
}
.videoActions {
.previewBtn,
.deleteBtn {
&:hover {
background: #434343;
}
}
}
}
}
}
}

View File

@@ -1,291 +0,0 @@
.mainImgUploadContainer {
width: 100%;
// 覆盖 antd Upload 组件的默认样式
:global {
.ant-upload {
width: 100%;
}
.ant-upload-list {
width: 100%;
}
.ant-upload-list-text {
width: 100%;
}
.ant-upload-list-text .ant-upload-list-item {
width: 100%;
}
}
.mainImgUploadButton {
width: 100%;
aspect-ratio: 16 / 9;
min-height: clamp(90px, 20vw, 180px);
border: 2px dashed #d9d9d9;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #1890ff;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
}
&:active {
transform: translateY(0);
}
.uploadingContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
padding: 20px;
.uploadingIcon {
font-size: clamp(24px, 4vw, 32px);
color: #1890ff;
animation: pulse 2s infinite;
}
.uploadingText {
font-size: clamp(11px, 2vw, 14px);
color: #666;
font-weight: 500;
}
}
.uploadContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
text-align: center;
.uploadIcon {
font-size: clamp(50px, 6vw, 48px);
color: #1890ff;
transition: all 0.3s ease;
}
.uploadText {
.uploadTitle {
font-size: clamp(14px, 2.5vw, 16px);
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.uploadSubtitle {
font-size: clamp(10px, 1.5vw, 14px);
color: #666;
line-height: 1.4;
}
}
&:hover .uploadIcon {
transform: scale(1.1);
color: #40a9ff;
}
}
}
.mainImgItem {
width: 100%;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
}
.mainImgItemContent {
display: flex;
align-items: center;
gap: 12px;
.mainImgIcon {
width: clamp(28px, 5vw, 40px);
height: clamp(28px, 5vw, 40px);
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: clamp(14px, 2.5vw, 18px);
flex-shrink: 0;
}
.mainImgInfo {
flex: 1;
min-width: 0;
.mainImgName {
font-size: clamp(11px, 2vw, 14px);
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mainImgSize {
font-size: clamp(10px, 1.5vw, 12px);
color: #666;
}
}
.mainImgActions {
display: flex;
gap: 4px;
flex-shrink: 0;
.previewBtn,
.deleteBtn {
padding: 4px 8px;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background: #f5f5f5;
}
}
.previewBtn {
color: #1890ff;
&:hover {
color: #40a9ff;
background: #e6f7ff;
}
}
.deleteBtn {
color: #ff4d4f;
&:hover {
color: #ff7875;
background: #fff2f0;
}
}
}
}
.mainImgPreview {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
min-height: clamp(90px, 20vw, 180px);
border-radius: 8px;
overflow: hidden;
background: #f5f5f5;
.mainImgImage {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.mainImgOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
border-radius: 8px;
.mainImgActions {
display: flex;
gap: 8px;
.previewBtn,
.deleteBtn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.9);
color: #666;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: white;
color: #1890ff;
transform: scale(1.1);
}
.anticon {
font-size: 14px;
}
}
.deleteBtn:hover {
color: #ff4d4f;
}
}
}
&:hover .mainImgOverlay {
opacity: 1;
}
}
}
}
// 禁用状态
.mainImgUploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.mainImgUploadContainer.error {
.mainImgUploadButton {
border-color: #ff4d4f;
background: #fff2f0;
}
}
// 动画效果
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}

View File

@@ -1,313 +0,0 @@
import React, { useState, useEffect } from "react";
import { Upload, message, Button } from "antd";
import {
LoadingOutlined,
PictureOutlined,
DeleteOutlined,
EyeOutlined,
CloudUploadOutlined,
} from "@ant-design/icons";
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
import style from "./index.module.scss";
interface MainImgUploadProps {
value?: string;
onChange?: (url: string) => void;
disabled?: boolean;
className?: string;
maxSize?: number; // 最大文件大小(MB)
showPreview?: boolean; // 是否显示预览
}
const MainImgUpload: React.FC<MainImgUploadProps> = ({
value = "",
onChange,
disabled = false,
className,
maxSize = 5,
showPreview = true,
}) => {
const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
useEffect(() => {
if (value) {
const files: UploadFile[] = [
{
uid: "main-img",
name: "main-image",
status: "done",
url: value,
},
];
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
const isImage = file.type.startsWith("image/");
if (!isImage) {
message.error("只能上传图片文件!");
return false;
}
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
if (!isLtMaxSize) {
message.error(`图片大小不能超过${maxSize}MB`);
return false;
}
return true;
};
// 处理文件变化
const handleChange: UploadProps["onChange"] = info => {
// 更新 fileList确保所有 URL 都是字符串
const updatedFileList = info.fileList.map(file => {
let url = "";
if (file.url) {
url = file.url;
} else if (file.response) {
// 处理响应对象
if (typeof file.response === "string") {
url = file.response;
} else if (file.response.data) {
url =
typeof file.response.data === "string"
? file.response.data
: file.response.data.url || "";
} else if (file.response.url) {
url = file.response.url;
}
}
return {
...file,
url: url,
};
});
setFileList(updatedFileList);
// 处理上传状态
if (info.file.status === "uploading") {
setLoading(true);
} else if (info.file.status === "done") {
setLoading(false);
message.success("图片上传成功!");
// 从响应中获取上传后的URL
let uploadedUrl = "";
if (info.file.response) {
if (typeof info.file.response === "string") {
uploadedUrl = info.file.response;
} else if (info.file.response.data) {
uploadedUrl =
typeof info.file.response.data === "string"
? info.file.response.data
: info.file.response.data.url || "";
} else if (info.file.response.url) {
uploadedUrl = info.file.response.url;
}
}
if (uploadedUrl) {
onChange?.(uploadedUrl);
}
} else if (info.file.status === "error") {
setLoading(false);
message.error("上传失败,请重试");
} else if (info.file.status === "removed") {
onChange?.("");
}
};
// 删除文件
const handleRemove = () => {
setFileList([]);
onChange?.("");
message.success("图片已删除");
return true;
};
// 预览图片
const handlePreview = (url: string) => {
const img = new Image();
img.src = url;
const newWindow = window.open();
if (newWindow) {
newWindow.document.write(img.outerHTML);
}
};
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 自定义上传按钮
const uploadButton = (
<div className={style.mainImgUploadButton}>
{loading ? (
<div className={style.uploadingContainer}>
<div className={style.uploadingIcon}>
<LoadingOutlined spin />
</div>
<div className={style.uploadingText}>...</div>
</div>
) : (
<div className={style.uploadContent}>
<div className={style.uploadIcon}>
<CloudUploadOutlined />
</div>
<div className={style.uploadText}>
<div className={style.uploadTitle}></div>
<div className={style.uploadSubtitle}>
JPGPNGGIF {maxSize}MB
</div>
</div>
</div>
)}
</div>
);
// 自定义文件列表项
const customItemRender = (
originNode: React.ReactElement,
file: UploadFile,
) => {
if (file.status === "uploading") {
return (
<div className={style.mainImgItem}>
<div className={style.mainImgItemContent}>
<div className={style.mainImgIcon}>
<PictureOutlined />
</div>
<div className={style.mainImgInfo}>
<div className={style.mainImgName}>{file.name}</div>
<div className={style.mainImgSize}>
{file.size ? formatFileSize(file.size) : "计算中..."}
</div>
</div>
<div className={style.mainImgActions}>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove()}
className={style.deleteBtn}
/>
</div>
</div>
</div>
);
}
if (file.status === "done") {
return (
<div className={style.mainImgItem}>
<div className={style.mainImgItemContent}>
<div className={style.mainImgIcon}>
<PictureOutlined />
</div>
<div className={style.mainImgInfo}>
<div className={style.mainImgName}>{file.name}</div>
<div className={style.mainImgSize}>
{file.size ? formatFileSize(file.size) : "未知大小"}
</div>
</div>
<div className={style.mainImgActions}>
{showPreview && (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handlePreview(file.url || "")}
className={style.previewBtn}
/>
)}
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove()}
className={style.deleteBtn}
/>
</div>
</div>
<div className={style.mainImgPreview}>
<img
src={file.url}
alt={file.name}
className={style.mainImgImage}
/>
<div className={style.mainImgOverlay}>
<div className={style.mainImgActions}>
{showPreview && (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handlePreview(file.url || "")}
className={style.previewBtn}
/>
)}
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove()}
className={style.deleteBtn}
/>
</div>
</div>
</div>
</div>
);
}
return originNode;
};
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
return (
<div className={`${style.mainImgUploadContainer} ${className || ""}`}>
<Upload
name="file"
headers={{
Authorization: `Bearer ${localStorage.getItem("token")}`,
}}
action={action}
multiple={false}
fileList={fileList}
accept="image/*"
listType="text"
showUploadList={{
showPreviewIcon: false,
showRemoveIcon: false,
showDownloadIcon: false,
}}
disabled={disabled || loading}
beforeUpload={beforeUpload}
onChange={handleChange}
onRemove={handleRemove}
maxCount={1}
itemRender={customItemRender}
>
{fileList.length >= 1 ? null : uploadButton}
</Upload>
</div>
);
};
export default MainImgUpload;

View File

@@ -1,451 +0,0 @@
# Upload 组件使用说明
## 组件概述
本项目提供了多个专门的上传组件,所有组件都支持编辑时的数据回显功能,确保在编辑模式下能够正确显示已上传的文件。
## 组件列表
### 1. MainImgUpload 主图封面上传组件
#### 功能特点
- 只支持上传一张图片作为主图封面
- 上传后右上角显示删除按钮
- 支持图片预览功能
- 响应式设计,适配移动端
- 16:9宽高比宽度高度自适应
- **支持数据回显**:编辑时自动显示已上传的图片
#### 使用方法
```tsx
import MainImgUpload from "@/components/Upload/MainImgUpload";
const MyComponent = () => {
const [mainImage, setMainImage] = useState<string>("");
return (
<MainImgUpload
value={mainImage}
onChange={setMainImage}
maxSize={5} // 最大5MB
showPreview={true} // 显示预览按钮
disabled={false}
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的图片URL
const [mainImage, setMainImage] = useState<string>(
"https://example.com/image.jpg",
);
<MainImgUpload
value={mainImage} // 会自动显示已上传的图片
onChange={setMainImage}
/>;
```
### 2. ImageUpload 多图上传组件
#### 功能特点
- 支持多张图片上传
- 可设置最大上传数量
- 支持图片预览和删除
- **支持数据回显**:编辑时自动显示已上传的图片数组
#### 使用方法
```tsx
import ImageUpload from "@/components/Upload/ImageUpload/ImageUpload";
const MyComponent = () => {
const [images, setImages] = useState<string[]>([]);
return (
<ImageUpload
value={images}
onChange={setImages}
count={9} // 最大9张
accept="image/*"
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的图片URL数组
const [images, setImages] = useState<string[]>([
"https://example.com/image1.jpg",
"https://example.com/image2.jpg",
]);
<ImageUpload
value={images} // 会自动显示已上传的图片
onChange={setImages}
/>;
```
### 3. VideoUpload 视频上传组件
#### 功能特点
- 支持视频文件上传
- 支持单个或多个视频
- 视频预览功能
- 文件大小验证
- **支持数据回显**:编辑时自动显示已上传的视频
#### 使用方法
```tsx
import VideoUpload from "@/components/Upload/VideoUpload";
const MyComponent = () => {
const [videoUrl, setVideoUrl] = useState<string>("");
return (
<VideoUpload
value={videoUrl}
onChange={setVideoUrl}
maxSize={50} // 最大50MB
showPreview={true}
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的视频URL
const [videoUrl, setVideoUrl] = useState<string>(
"https://example.com/video.mp4",
);
<VideoUpload
value={videoUrl} // 会自动显示已上传的视频
onChange={setVideoUrl}
/>;
```
### 4. FileUpload 文件上传组件
#### 功能特点
- 支持Excel、Word、PPT等文档文件
- 可配置接受的文件类型
- 文件预览和下载
- **支持数据回显**:编辑时自动显示已上传的文件
#### 使用方法
```tsx
import FileUpload from "@/components/Upload/FileUpload";
const MyComponent = () => {
const [fileUrl, setFileUrl] = useState<string>("");
return (
<FileUpload
value={fileUrl}
onChange={setFileUrl}
maxSize={10} // 最大10MB
acceptTypes={["excel", "word", "ppt"]}
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的文件URL
const [fileUrl, setFileUrl] = useState<string>(
"https://example.com/document.xlsx",
);
<FileUpload
value={fileUrl} // 会自动显示已上传的文件
onChange={setFileUrl}
/>;
```
### 5. AvatarUpload 头像上传组件
#### 功能特点
- 专门的头像上传组件
- 圆形头像显示
- 支持删除和重新上传
- **支持数据回显**:编辑时自动显示已上传的头像
#### 使用方法
```tsx
import AvatarUpload from "@/components/Upload/AvatarUpload";
const MyComponent = () => {
const [avatarUrl, setAvatarUrl] = useState<string>("");
return (
<AvatarUpload
value={avatarUrl}
onChange={setAvatarUrl}
size={100} // 头像尺寸
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的头像URL
const [avatarUrl, setAvatarUrl] = useState<string>(
"https://example.com/avatar.jpg",
);
<AvatarUpload
value={avatarUrl} // 会自动显示已上传的头像
onChange={setAvatarUrl}
/>;
```
### 6. ChatFileUpload 聊天文件上传组件
#### 功能特点
- 专门为聊天场景设计的文件上传组件
- 点击按钮直接唤醒文件选择框
- 选择文件后自动上传
- 上传成功后自动发送到聊天框
- 支持各种文件类型和大小限制
- 显示文件图标和大小信息
- 支持自定义按钮文本和图标
#### 使用方法
```tsx
import ChatFileUpload from "@/components/Upload/ChatFileUpload";
const ChatComponent = () => {
const handleFileUploaded = (fileInfo: {
url: string;
name: string;
type: string;
size: number;
}) => {
// 处理上传成功的文件
console.log("文件上传成功:", fileInfo);
// 发送到聊天框
sendMessage({
type: "file",
content: fileInfo,
});
};
return (
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={50} // 最大50MB
accept="*/*" // 接受所有文件类型
buttonText="发送文件"
buttonIcon={<span>📎</span>}
/>
);
};
```
#### 不同文件类型的配置示例
```tsx
// 图片上传
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={10}
accept="image/*"
buttonText="图片"
buttonIcon={<span>🖼️</span>}
/>
// 文档上传
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={20}
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"
buttonText="文档"
buttonIcon={<span>📄</span>}
/>
// 视频上传
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={100}
accept="video/*"
buttonText="视频"
buttonIcon={<span>🎥</span>}
/>
```
#### 在聊天界面中的完整使用示例
```tsx
import React, { useState } from "react";
import { Input, Button } from "antd";
import ChatFileUpload from "@/components/Upload/ChatFileUpload";
const ChatInterface = () => {
const [messages, setMessages] = useState([]);
const [inputValue, setInputValue] = useState("");
const handleFileUploaded = fileInfo => {
const newMessage = {
id: Date.now(),
type: "file",
content: fileInfo,
timestamp: new Date(),
};
setMessages(prev => [...prev, newMessage]);
};
const handleSendText = () => {
if (!inputValue.trim()) return;
const newMessage = {
id: Date.now(),
type: "text",
content: inputValue,
timestamp: new Date(),
};
setMessages(prev => [...prev, newMessage]);
setInputValue("");
};
return (
<div>
{/* 聊天消息区域 */}
<div className="chat-messages">
{messages.map(msg => (
<div key={msg.id} className="message">
{msg.type === "file" ? (
<div>
<div>📎 {msg.content.name}</div>
<div>大小: {formatFileSize(msg.content.size)}</div>
<a href={msg.content.url} target="_blank">
查看文件
</a>
</div>
) : (
<div>{msg.content}</div>
)}
</div>
))}
</div>
{/* 输入区域 */}
<div className="chat-input">
<Input.TextArea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
placeholder="输入消息..."
/>
<div className="input-actions">
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={50}
accept="*/*"
buttonText="文件"
/>
<Button onClick={handleSendText}>发送</Button>
</div>
</div>
</div>
);
};
```
## 数据回显机制
### 工作原理
所有Upload组件都通过以下机制实现数据回显
1. **useEffect监听value变化**当传入的value发生变化时自动更新内部状态
2. **文件列表同步**将URL转换为文件列表格式显示已上传的文件
3. **状态管理**:维护上传状态、文件列表等内部状态
4. **UI更新**:根据文件列表自动更新界面显示
### 使用场景
- **新增模式**value为空或未定义显示上传按钮
- **编辑模式**value包含已上传文件的URL自动显示文件
- **混合模式**:支持部分文件已上传,部分文件待上传
### 注意事项
1. **URL格式**确保传入的URL是有效的文件访问地址
2. **权限验证**确保文件URL在编辑时仍然可访问
3. **状态同步**value和onChange需要正确配合使用
4. **错误处理**组件会自动处理无效URL的显示
## 技术实现
### 核心特性
- 基于 antd Upload 组件
- 使用 antd-mobile 的 Toast 提示
- 支持 FormData 上传
- 自动处理文件验证和错误提示
- 集成项目统一的API请求封装
- **完整的数据回显支持**
### 文件结构
```
src/components/Upload/
├── MainImgUpload/ # 主图上传组件
├── ImageUpload/ # 多图上传组件
├── VideoUpload/ # 视频上传组件
├── FileUpload/ # 文件上传组件
├── AvatarUpload/ # 头像上传组件
├── ChatFileUpload/ # 聊天文件上传组件
│ ├── index.tsx # 主组件文件
│ ├── index.module.scss # 样式文件
│ └── example.tsx # 使用示例
└── README.md # 使用说明文档
```
### 统一的数据回显模式
所有组件都遵循相同的数据回显模式:
```tsx
// 1. 接收value属性
interface Props {
value?: string | string[];
onChange?: (url: string | string[]) => void;
}
// 2. 使用useEffect监听value变化
useEffect(() => {
if (value) {
// 将URL转换为文件列表格式
const files = convertUrlToFileList(value);
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 3. 在UI中显示文件列表
// 4. 支持编辑、删除、预览等操作
```

View File

@@ -1,243 +0,0 @@
.videoUploadContainer {
width: 100%;
// 覆盖 antd Upload 组件的默认样式
:global {
.ant-upload {
width: 100%;
}
.ant-upload-list {
width: 100%;
}
.ant-upload-list-text {
width: 100%;
}
.ant-upload-list-text .ant-upload-list-item {
width: 100%;
}
}
.videoUploadButton {
width: 100%;
aspect-ratio: 16 / 9;
min-height: clamp(90px, 20vw, 180px);
border: 2px dashed #d9d9d9;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #1890ff;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
}
&:active {
transform: translateY(0);
}
.uploadingContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
padding: 20px;
.uploadingIcon {
font-size: clamp(24px, 4vw, 32px);
color: #1890ff;
animation: pulse 2s infinite;
}
.uploadingText {
font-size: clamp(11px, 2vw, 14px);
color: #666;
font-weight: 500;
}
.uploadProgress {
width: 100%;
max-width: 200px;
}
}
.uploadContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
text-align: center;
.uploadIcon {
font-size: clamp(50px, 6vw, 48px);
color: #1890ff;
transition: all 0.3s ease;
}
.uploadText {
.uploadTitle {
font-size: clamp(14px, 2.5vw, 16px);
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.uploadSubtitle {
font-size: clamp(10px, 1.5vw, 14px);
color: #666;
line-height: 1.4;
}
}
&:hover .uploadIcon {
transform: scale(1.1);
color: #40a9ff;
}
}
}
.videoItem {
width: 100%;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
}
.videoItemContent {
display: flex;
align-items: center;
gap: 12px;
.videoIcon {
width: clamp(28px, 5vw, 40px);
height: clamp(28px, 5vw, 40px);
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: clamp(14px, 2.5vw, 18px);
flex-shrink: 0;
}
.videoInfo {
flex: 1;
min-width: 0;
.videoName {
font-size: clamp(11px, 2vw, 14px);
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.videoSize {
font-size: clamp(10px, 1.5vw, 12px);
color: #666;
}
}
.videoActions {
display: flex;
gap: 4px;
flex-shrink: 0;
.previewBtn,
.deleteBtn {
padding: 4px 8px;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background: #f5f5f5;
}
}
.previewBtn {
color: #1890ff;
&:hover {
color: #40a9ff;
background: #e6f7ff;
}
}
.deleteBtn {
color: #ff4d4f;
&:hover {
color: #ff7875;
background: #fff2f0;
}
}
}
}
.itemProgress {
margin-top: 8px;
}
}
.videoPreview {
display: flex;
justify-content: center;
align-items: center;
background: #000;
border-radius: 8px;
overflow: hidden;
video {
border-radius: 8px;
}
}
}
// 禁用状态
.videoUploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.videoUploadContainer.error {
.videoUploadButton {
border-color: #ff4d4f;
background: #fff2f0;
}
}
// 动画效果
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}

View File

@@ -1,381 +0,0 @@
import React, { useState } from "react";
import { Upload, message, Progress, Button, Modal } from "antd";
import {
LoadingOutlined,
PlayCircleOutlined,
DeleteOutlined,
EyeOutlined,
FileOutlined,
CloudUploadOutlined,
} from "@ant-design/icons";
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
import style from "./index.module.scss";
interface VideoUploadProps {
value?: string | string[]; // 支持单个字符串或字符串数组
onChange?: (url: string | string[]) => void; // 支持单个字符串或字符串数组
disabled?: boolean;
className?: string;
maxSize?: number; // 最大文件大小(MB)
showPreview?: boolean; // 是否显示预览
maxCount?: number; // 最大上传数量默认为1
}
const VideoUpload: React.FC<VideoUploadProps> = ({
value = "",
onChange,
disabled = false,
className,
maxSize = 50,
showPreview = true,
maxCount = 1,
}) => {
const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [previewVisible, setPreviewVisible] = useState(false);
const [previewUrl, setPreviewUrl] = useState("");
React.useEffect(() => {
if (value) {
// 处理单个字符串或字符串数组
const urls = Array.isArray(value) ? value : [value];
const files: UploadFile[] = urls.map((url, index) => ({
uid: `file-${index}`,
name: `video-${index + 1}`,
status: "done",
url: url || "",
}));
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
const isVideo = file.type.startsWith("video/");
if (!isVideo) {
message.error("只能上传视频文件!");
return false;
}
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
if (!isLtMaxSize) {
message.error(`视频大小不能超过${maxSize}MB`);
return false;
}
return true;
};
// 处理文件变化
const handleChange: UploadProps["onChange"] = info => {
// 更新 fileList确保所有 URL 都是字符串
const updatedFileList = info.fileList.map(file => {
let url = "";
if (file.url) {
url = file.url;
} else if (file.response) {
// 处理响应对象
if (typeof file.response === "string") {
url = file.response;
} else if (file.response.data) {
url =
typeof file.response.data === "string"
? file.response.data
: file.response.data.url || "";
} else if (file.response.url) {
url = file.response.url;
}
}
return {
...file,
url: url,
};
});
setFileList(updatedFileList);
// 处理上传状态
if (info.file.status === "uploading") {
setLoading(true);
// 模拟上传进度
const progress = Math.min(99, Math.random() * 100);
setUploadProgress(progress);
} else if (info.file.status === "done") {
setLoading(false);
setUploadProgress(100);
message.success("视频上传成功!");
// 从响应中获取上传后的URL
let uploadedUrl = "";
if (info.file.response) {
if (typeof info.file.response === "string") {
uploadedUrl = info.file.response;
} else if (info.file.response.data) {
uploadedUrl =
typeof info.file.response.data === "string"
? info.file.response.data
: info.file.response.data.url || "";
} else if (info.file.response.url) {
uploadedUrl = info.file.response.url;
}
}
if (uploadedUrl) {
if (maxCount === 1) {
// 单个视频模式
onChange?.(uploadedUrl);
} else {
// 多个视频模式
const currentUrls = Array.isArray(value)
? value
: value
? [value]
: [];
const newUrls = [...currentUrls, uploadedUrl];
onChange?.(newUrls);
}
}
} else if (info.file.status === "error") {
setLoading(false);
setUploadProgress(0);
message.error("上传失败,请重试");
} else if (info.file.status === "removed") {
if (maxCount === 1) {
onChange?.("");
} else {
// 多个视频模式,移除对应的视频
const currentUrls = Array.isArray(value) ? value : value ? [value] : [];
const removedIndex = info.fileList.findIndex(
f => f.uid === info.file.uid,
);
if (removedIndex !== -1) {
const newUrls = currentUrls.filter(
(_, index) => index !== removedIndex,
);
onChange?.(newUrls);
}
}
}
};
// 删除文件
const handleRemove = (file?: UploadFile) => {
Modal.confirm({
title: "确认删除",
content: "确定要删除这个视频文件吗?",
okText: "确定",
cancelText: "取消",
onOk: () => {
if (maxCount === 1) {
setFileList([]);
onChange?.("");
} else if (file) {
// 多个视频模式,删除指定视频
const currentUrls = Array.isArray(value)
? value
: value
? [value]
: [];
const fileIndex = fileList.findIndex(f => f.uid === file.uid);
if (fileIndex !== -1) {
const newUrls = currentUrls.filter(
(_, index) => index !== fileIndex,
);
onChange?.(newUrls);
}
}
message.success("视频已删除");
},
});
return true;
};
// 预览视频
const handlePreview = (url: string) => {
setPreviewUrl(url);
setPreviewVisible(true);
};
// 获取文件大小显示
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 自定义上传按钮
const uploadButton = (
<div className={style.videoUploadButton}>
{loading ? (
<div className={style.uploadingContainer}>
<div className={style.uploadingIcon}>
<LoadingOutlined spin />
</div>
<div className={style.uploadingText}>...</div>
<Progress
percent={uploadProgress}
size="small"
showInfo={false}
strokeColor="#1890ff"
className={style.uploadProgress}
/>
</div>
) : (
<div className={style.uploadContent}>
<div className={style.uploadIcon}>
<CloudUploadOutlined />
</div>
<div className={style.uploadText}>
<div className={style.uploadTitle}>
{maxCount === 1
? "上传视频"
: `上传视频 (${fileList.length}/${maxCount})`}
</div>
<div className={style.uploadSubtitle}>
MP4AVIMOV {maxSize}MB
{maxCount > 1 && `,最多上传 ${maxCount} 个视频`}
</div>
</div>
</div>
)}
</div>
);
// 自定义文件列表项
const customItemRender = (
originNode: React.ReactElement,
file: UploadFile,
) => {
if (file.status === "uploading") {
return (
<div className={style.videoItem}>
<div className={style.videoItemContent}>
<div className={style.videoIcon}>
<FileOutlined />
</div>
<div className={style.videoInfo}>
<div className={style.videoName}>{file.name}</div>
<div className={style.videoSize}>
{file.size ? formatFileSize(file.size) : "计算中..."}
</div>
</div>
<div className={style.videoActions}>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove(file)}
className={style.deleteBtn}
/>
</div>
</div>
<Progress
percent={uploadProgress}
size="small"
strokeColor="#1890ff"
className={style.itemProgress}
/>
</div>
);
}
if (file.status === "done") {
return (
<div className={style.videoItem}>
<div className={style.videoItemContent}>
<div className={style.videoIcon}>
<PlayCircleOutlined />
</div>
<div className={style.videoInfo}>
<div className={style.videoName}>{file.name}</div>
<div className={style.videoSize}>
{file.size ? formatFileSize(file.size) : "未知大小"}
</div>
</div>
<div className={style.videoActions}>
{showPreview && (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handlePreview(file.url || "")}
className={style.previewBtn}
/>
)}
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove(file)}
className={style.deleteBtn}
/>
</div>
</div>
</div>
);
}
return originNode;
};
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
return (
<div className={`${style.videoUploadContainer} ${className || ""}`}>
<Upload
name="file"
headers={{
Authorization: `Bearer ${localStorage.getItem("token")}`,
}}
action={action}
multiple={maxCount > 1}
fileList={fileList}
accept="video/*"
listType="text"
showUploadList={{
showPreviewIcon: false,
showRemoveIcon: false,
showDownloadIcon: false,
}}
disabled={disabled || loading}
beforeUpload={beforeUpload}
onChange={handleChange}
onRemove={handleRemove}
maxCount={maxCount}
itemRender={customItemRender}
>
{fileList.length >= maxCount ? null : uploadButton}
</Upload>
{/* 视频预览模态框 */}
<Modal
title="视频预览"
open={previewVisible}
onCancel={() => setPreviewVisible(false)}
footer={null}
width={800}
centered
>
<div className={style.videoPreview}>
<video
controls
style={{ width: "100%", maxHeight: "400px" }}
src={previewUrl}
>
</video>
</div>
</Modal>
</div>
);
};
export default VideoUpload;

View File

@@ -1,8 +0,0 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/global.scss";
// import VConsole from "vconsole";
// new VConsole();
const root = createRoot(document.getElementById("root")!);
root.render(<App />);

View File

@@ -1,13 +0,0 @@
import request from "@/api/request";
// 获取设备二维码
export const fetchDeviceQRCode = (accountId: string) =>
request("/v1/api/device/add", { accountId }, "POST");
// 通过IMEI添加设备
export const addDeviceByImei = (imei: string, name: string) =>
request("/v1/api/device/add-by-imei", { imei, name }, "POST");
// 获取设备列表
export const fetchDeviceList = (params: { accountId?: string }) =>
request("/v1/devices/add-results", params, "GET");

View File

@@ -1,341 +0,0 @@
.guideContainer {
height: 100vh;
background: var(--primary-color);
padding: 16px;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="25" cy="25" r="1" fill="rgba(255,255,255,0.1)"/><circle cx="75" cy="75" r="1" fill="rgba(255,255,255,0.1)"/><circle cx="50" cy="10" r="0.5" fill="rgba(255,255,255,0.1)"/><circle cx="10" cy="60" r="0.5" fill="rgba(255,255,255,0.1)"/><circle cx="90" cy="40" r="0.5" fill="rgba(255,255,255,0.1)"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
opacity: 0.3;
pointer-events: none;
}
}
.loadingContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: var(--primary-color);
}
.loadingText {
color: white;
font-size: 16px;
margin-top: 16px;
font-weight: 500;
}
.header {
text-align: center;
margin-bottom: 20px;
position: relative;
z-index: 1;
}
.iconContainer {
width: 60px;
height: 60px;
background: #fff;
border-radius: 50%;
margin: 0 auto 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
overflow: hidden;
}
.logo {
width: 100%;
height: 100%;
object-fit: contain;
}
.title {
color: white;
font-size: 22px;
font-weight: 700;
margin-bottom: 8px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.subtitle {
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
line-height: 1.4;
max-width: 280px;
margin: 0 auto;
}
.content {
flex: 1;
position: relative;
z-index: 1;
overflow-y: auto;
padding-right: 4px;
}
.deviceStatus {
margin-bottom: 16px;
}
.statusCard {
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
padding: 12px;
display: flex;
align-items: center;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.statusIcon {
width: 40px;
height: 40px;
background: var(--primary-color);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
color: white;
font-size: 20px;
}
.statusInfo {
flex: 1;
}
.statusTitle {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 2px;
}
.statusValue {
font-size: 12px;
color: #666;
}
.deviceCount {
color: var(--primary-color);
font-weight: 700;
font-size: 16px;
}
.guideSteps {
margin-bottom: 16px;
}
.stepsTitle {
color: white;
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.stepList {
display: flex;
flex-direction: column;
gap: 8px;
}
.stepItem {
background: rgba(255, 255, 255, 0.95);
border-radius: 8px;
padding: 10px;
display: flex;
align-items: flex-start;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
transition: transform 0.2s ease;
&:hover {
transform: translateY(-1px);
}
}
.stepNumber {
width: 24px;
height: 24px;
background: var(--primary-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 12px;
margin-right: 10px;
flex-shrink: 0;
}
.stepContent {
flex: 1;
}
.stepTitle {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 2px;
}
.stepDesc {
font-size: 12px;
color: #666;
line-height: 1.3;
}
.tips {
background: rgba(255, 255, 255, 0.95);
border-radius: 8px;
padding: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.tipsTitle {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.tipsIcon {
color: #ff6b6b;
margin-right: 6px;
font-size: 16px;
}
.tipsContent {
p {
font-size: 12px;
color: #666;
line-height: 1.4;
margin-bottom: 4px;
&:last-child {
margin-bottom: 0;
}
}
}
.footer {
margin-top: 16px;
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.primaryButton {
background: white;
border: none;
border-radius: 8px;
height: 44px;
font-size: 15px;
font-weight: 600;
color: var(--primary-color);
box-shadow: 0 2px 12px rgba(255, 255, 255, 0.4);
transition: all 0.3s ease;
&:active {
transform: translateY(1px);
box-shadow: 0 1px 6px rgba(255, 255, 255, 0.4);
}
}
.buttonIcon {
margin-left: 6px;
font-size: 12px;
}
.secondaryButton {
border: 2px solid rgba(255, 255, 255, 0.8);
border-radius: 8px;
height: 44px;
font-size: 15px;
font-weight: 600;
color: white;
background: transparent;
backdrop-filter: blur(10px);
&:active {
background: rgba(255, 255, 255, 0.1);
}
}
// 响应式设计
@media (max-width: 480px) {
.guideContainer {
padding: 12px;
}
.title {
font-size: 20px;
}
.subtitle {
font-size: 13px;
}
.statusCard {
padding: 10px;
}
.stepItem {
padding: 8px;
}
.tips {
padding: 10px;
}
}
// 动画效果
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.header,
.deviceStatus,
.guideSteps,
.tips {
animation: fadeInUp 0.5s ease-out;
}
.guideSteps {
animation-delay: 0.1s;
}
.tips {
animation-delay: 0.2s;
}
.footer {
animation: fadeInUp 0.5s ease-out 0.3s both;
}

View File

@@ -1,349 +0,0 @@
import React, { useEffect, useState, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Toast, Popup, Tabs, Input } from "antd-mobile";
import {
MobileOutlined,
ExclamationCircleOutlined,
ArrowRightOutlined,
QrcodeOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import { fetchDeviceQRCode, addDeviceByImei, fetchDeviceList } from "./api";
import { useUserStore } from "@/store/module/user";
import styles from "./index.module.scss";
const Guide: React.FC = () => {
const navigate = useNavigate();
const { user } = useUserStore();
const [loading, setLoading] = useState(false);
const [deviceCount, setDeviceCount] = useState(user?.deviceTotal || 0);
// 添加设备弹窗状态
const [addVisible, setAddVisible] = useState(false);
const [addTab, setAddTab] = useState("scan");
const [qrLoading, setQrLoading] = useState(false);
const [qrCode, setQrCode] = useState<string | null>(null);
const [imei, setImei] = useState("");
const [name, setName] = useState("");
const [addLoading, setAddLoading] = useState(false);
// 轮询监听相关
const [isPolling, setIsPolling] = useState(false);
const pollingRef = useRef<NodeJS.Timeout | null>(null);
const initialDeviceCountRef = useRef(deviceCount);
// 检查设备绑定状态
const checkDeviceStatus = useCallback(async () => {
try {
setLoading(true);
// 使用store中的设备数量
const deviceNum = user?.deviceTotal || 0;
setDeviceCount(deviceNum);
// 如果已有设备,直接跳转到首页
if (deviceNum > 0) {
navigate("/");
return;
}
} catch (error) {
console.error("检查设备状态失败:", error);
Toast.show({
content: "检查设备状态失败,请重试",
position: "top",
});
} finally {
setLoading(false);
}
}, [user?.deviceTotal, navigate]);
useEffect(() => {
checkDeviceStatus();
}, [checkDeviceStatus]);
// 开始轮询监听设备状态
const startPolling = useCallback(() => {
if (isPolling) return;
setIsPolling(true);
initialDeviceCountRef.current = deviceCount;
const pollDeviceStatus = async () => {
try {
// 这里可以调用一个简单的设备数量接口来检查是否有新设备
// 或者使用其他方式检测设备状态变化
// 暂时使用store中的数量实际项目中可能需要调用专门的接口
let currentDeviceCount = user?.deviceTotal || 0;
const res = await fetchDeviceList({ accountId: user?.s2_accountId });
if (res.added) {
currentDeviceCount = 1;
Toast.show({ content: "设备添加成功!", position: "top" });
setAddVisible(false);
setDeviceCount(currentDeviceCount);
setIsPolling(false);
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
// 可以选择跳转到首页或继续留在当前页面
navigate("/");
return;
}
} catch (error) {
console.error("轮询检查设备状态失败:", error);
}
};
// 每3秒检查一次设备状态
pollingRef.current = setInterval(pollDeviceStatus, 3000);
}, [isPolling, user?.s2_accountId]);
// 停止轮询
const stopPolling = useCallback(() => {
setIsPolling(false);
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}, []);
// 组件卸载时清理轮询
useEffect(() => {
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
}
};
}, []);
// 获取二维码
const handleGetQr = async () => {
setQrLoading(true);
setQrCode(null);
try {
const accountId = user?.s2_accountId;
if (!accountId) throw new Error("未获取到用户信息");
const res = await fetchDeviceQRCode(accountId);
setQrCode(res.qrCode);
// 获取二维码后开始轮询监听
startPolling();
} catch (e: any) {
Toast.show({ content: e.message || "获取二维码失败", position: "top" });
} finally {
setQrLoading(false);
}
};
// 跳转到设备管理页面
const handleGoToDevices = () => {
handleGetQr();
setAddVisible(true);
};
// 手动添加设备
const handleAddDevice = async () => {
if (!imei.trim() || !name.trim()) {
Toast.show({ content: "请填写完整信息", position: "top" });
return;
}
setAddLoading(true);
try {
await addDeviceByImei(imei, name);
Toast.show({ content: "添加成功", position: "top" });
setAddVisible(false);
setImei("");
setName("");
// 重新检查设备状态
await checkDeviceStatus();
} catch (e: any) {
Toast.show({ content: e.message || "添加失败", position: "top" });
} finally {
setAddLoading(false);
}
};
// 关闭弹窗时停止轮询
const handleClosePopup = () => {
setAddVisible(false);
stopPolling();
setQrCode(null);
};
if (loading) {
return (
<Layout loading={true}>
<div className={styles.loadingContainer}>
<div className={styles.loadingText}>...</div>
</div>
</Layout>
);
}
return (
<Layout>
<div className={styles.guideContainer}>
{/* 头部区域 */}
<div className={styles.header}>
<div className={styles.iconContainer}>
<img src="/logo.png" alt="存客宝" className={styles.logo} />
</div>
<h1 className={styles.title}>使</h1>
<p className={styles.subtitle}></p>
</div>
{/* 内容区域 */}
<div className={styles.content}>
<div className={styles.deviceStatus}>
<div className={styles.statusCard}>
<div className={styles.statusIcon}>
<MobileOutlined />
</div>
<div className={styles.statusInfo}>
<div className={styles.statusTitle}></div>
<div className={styles.statusValue}>
<span className={styles.deviceCount}>{deviceCount}</span>
</div>
</div>
</div>
</div>
<div className={styles.guideSteps}>
<h2 className={styles.stepsTitle}></h2>
<div className={styles.stepList}>
<div className={styles.stepItem}>
<div className={styles.stepNumber}>1</div>
<div className={styles.stepContent}>
<div className={styles.stepTitle}></div>
<div className={styles.stepDesc}>
</div>
</div>
</div>
<div className={styles.stepItem}>
<div className={styles.stepNumber}>2</div>
<div className={styles.stepContent}>
<div className={styles.stepTitle}></div>
<div className={styles.stepDesc}></div>
</div>
</div>
<div className={styles.stepItem}>
<div className={styles.stepNumber}>3</div>
<div className={styles.stepContent}>
<div className={styles.stepTitle}>使</div>
<div className={styles.stepDesc}>
使
</div>
</div>
</div>
</div>
</div>
<div className={styles.tips}>
<div className={styles.tipsTitle}>
<ExclamationCircleOutlined className={styles.tipsIcon} />
</div>
<div className={styles.tipsContent}>
<p> </p>
<p> 10</p>
<p> </p>
</div>
</div>
</div>
{/* 底部按钮区域 */}
<div className={styles.footer}>
<Button
block
color="primary"
size="large"
className={styles.primaryButton}
onClick={handleGoToDevices}
>
<ArrowRightOutlined className={styles.buttonIcon} />
</Button>
</div>
</div>
{/* 添加设备弹窗 */}
<Popup
visible={addVisible}
onMaskClick={handleClosePopup}
bodyStyle={{
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
minHeight: 320,
}}
>
<div style={{ padding: 20 }}>
<Tabs
activeKey={addTab}
onChange={setAddTab}
style={{ marginBottom: 16 }}
>
<Tabs.Tab title="扫码添加" key="scan" />
<Tabs.Tab title="手动添加" key="manual" />
</Tabs>
{addTab === "scan" && (
<div style={{ textAlign: "center", minHeight: 200 }}>
<Button color="primary" onClick={handleGetQr} loading={qrLoading}>
<QrcodeOutlined />
&nbsp;
</Button>
{qrCode && (
<div style={{ marginTop: 16 }}>
<img
src={qrCode}
alt="二维码"
style={{
width: 180,
height: 180,
background: "#f5f5f5",
borderRadius: 8,
margin: "0 auto",
}}
/>
<div style={{ color: "#888", fontSize: 12, marginTop: 8 }}>
</div>
{isPolling && (
<div
style={{ color: "#1890ff", fontSize: 12, marginTop: 8 }}
>
...
</div>
)}
</div>
)}
</div>
)}
{addTab === "manual" && (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<Input
placeholder="设备名称"
value={name}
onChange={val => setName(val)}
clearable
/>
<Input
placeholder="设备IMEI"
value={imei}
onChange={val => setImei(val)}
clearable
/>
<Button
color="primary"
onClick={handleAddDevice}
loading={addLoading}
>
</Button>
</div>
)}
</div>
</Popup>
</Layout>
);
};
export default Guide;

View File

@@ -1,323 +0,0 @@
.iframe-debug-page {
min-height: 100vh;
background: var(--primary-gradient);
padding: 20px;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.header {
text-align: center;
color: white;
margin-bottom: 30px;
h1 {
font-size: 2.5rem;
margin: 0 0 10px 0;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
p {
font-size: 1.1rem;
margin: 0;
opacity: 0.9;
}
}
.content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
margin-bottom: 30px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 20px;
}
}
.control-panel,
.message-panel {
background: white;
border-radius: 15px;
padding: 25px;
box-shadow: 0 10px 30px var(--primary-shadow);
h3 {
margin: 0 0 20px 0;
color: #333;
font-size: 1.3rem;
border-bottom: 2px solid var(--primary-color);
padding-bottom: 10px;
}
}
.input-group {
display: flex;
gap: 10px;
margin-bottom: 20px;
@media (max-width: 480px) {
flex-direction: column;
}
}
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
padding: 12px 20px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
&:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
&:active {
transform: translateY(0);
}
&.btn-primary {
background: var(--primary-gradient);
color: white;
&:hover {
background: linear-gradient(
135deg,
var(--primary-color-dark) 0%,
var(--primary-color) 100%
);
}
}
&.btn-secondary {
background: linear-gradient(
135deg,
var(--primary-color-light) 0%,
var(--primary-color) 100%
);
color: white;
&:hover {
background: linear-gradient(
135deg,
var(--primary-color) 0%,
var(--primary-color-dark) 100%
);
}
}
&.btn-danger {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
&:hover {
background: linear-gradient(135deg, #ee5a52 0%, #d63031 100%);
}
}
}
.message-list {
max-height: 400px;
overflow-y: auto;
border: 1px solid #e1e5e9;
border-radius: 8px;
padding: 10px;
background: #f8f9fa;
font-size: 12px;
}
.no-messages {
text-align: center;
color: #6c757d;
font-style: italic;
padding: 20px;
}
.message-item {
background: white;
padding: 12px 15px;
margin-bottom: 8px;
border-radius: 6px;
border-left: 4px solid var(--primary-color);
box-shadow: 0 2px 4px var(--primary-shadow-light);
font-size: 12px;
&:last-child {
margin-bottom: 0;
}
}
.message-text {
font-family: "Courier New", monospace;
font-size: 12px;
color: #333;
word-break: break-all;
line-height: 1.4;
}
.info-panel {
background: white;
border-radius: 15px;
padding: 25px;
box-shadow: 0 10px 30px var(--primary-shadow);
h3 {
margin: 0 0 20px 0;
color: #333;
font-size: 1.3rem;
border-bottom: 2px solid var(--primary-color);
padding-bottom: 10px;
}
}
.info-item {
margin-bottom: 12px;
padding: 10px;
background: #f8f9fa;
border-radius: 6px;
border-left: 3px solid var(--primary-color);
strong {
color: #495057;
margin-right: 8px;
}
&:last-child {
margin-bottom: 0;
}
}
// 滚动条样式
.message-list::-webkit-scrollbar {
width: 8px;
}
.message-list::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.message-list::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
&:hover {
background: #a8a8a8;
}
}
// 响应式设计
@media (max-width: 768px) {
.iframe-debug-page {
padding: 15px;
}
.header h1 {
font-size: 2rem;
}
.control-panel,
.message-panel,
.info-panel {
padding: 20px;
}
.btn {
padding: 10px 16px;
font-size: 0.9rem;
}
}
@media (max-width: 480px) {
.header h1 {
font-size: 1.8rem;
}
.content {
gap: 15px;
}
.control-panel,
.message-panel,
.info-panel {
padding: 15px;
}
.button-group {
flex-direction: column;
}
.btn {
width: 100%;
}
}
// URL 参数区域样式
.url-params-section {
margin-top: 20px;
padding-top: 20px;
border-top: 2px solid #e1e5e9;
h4 {
margin: 0 0 15px 0;
color: #333;
font-size: 1.1rem;
font-weight: 600;
}
}
.no-params {
color: #666;
font-style: italic;
text-align: center;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
border: 2px dashed #dee2e6;
}
.params-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.param-item {
display: flex;
align-items: center;
padding: 10px 15px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid var(--primary-color);
transition: all 0.3s ease;
&:hover {
background: #e9ecef;
transform: translateX(5px);
}
}
.param-key {
font-weight: 600;
color: #333;
min-width: 80px;
margin-right: 10px;
}
.param-value {
color: #666;
font-family: "Courier New", monospace;
word-break: break-all;
flex: 1;
}

View File

@@ -1,242 +0,0 @@
import React, { useState, useEffect } from "react";
import style from "./index.module.scss";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import { LoadingOutlined, CheckCircleOutlined } from "@ant-design/icons";
import { Input } from "antd";
// 声明全局的 uni 对象
declare global {
interface Window {
uni: any;
}
}
interface Message {
type: number; // 数据类型0数据交互 1App功能调用
data: any;
}
const TYPE_EMUE = {
CONNECT: 0,
DATA: 1,
FUNCTION: 2,
CONFIG: 3,
};
const IframeDebugPage: React.FC = () => {
const [receivedMessages, setReceivedMessages] = useState<string[]>([]);
const [messageId, setMessageId] = useState(0);
const [inputMessage, setInputMessage] = useState("");
const [connectStatus, setConnectStatus] = useState(false);
// 解析 URL 参数中的消息
const parseUrlMessage = () => {
const search = window.location.search.substring(1);
let messageParam = null;
if (search) {
const pairs = search.split("&");
for (const pair of pairs) {
const [key, value] = pair.split("=");
if (key === "message" && value) {
messageParam = decodeURIComponent(value);
break;
}
}
}
if (messageParam) {
try {
const message = JSON.parse(decodeURIComponent(messageParam));
console.log("[存客宝]ReceiveMessage=>\n" + JSON.stringify(message));
handleReceivedMessage(message);
// 清除URL中的message参数
const newUrl =
window.location.pathname +
window.location.search
.replace(/[?&]message=[^&]*/, "")
.replace(/^&/, "?");
window.history.replaceState({}, "", newUrl);
} catch (e) {
console.error("解析URL消息失败:", e);
}
}
};
useEffect(() => {
parseUrlMessage();
// 监听 SDK 初始化完成事件
}, []);
// 处理接收到的消息
const handleReceivedMessage = (message: Message) => {
const messageText = `[${new Date().toLocaleTimeString()}] 收到: ${JSON.stringify(message)}`;
setReceivedMessages(prev => [...prev, messageText]);
console.log("message.type", message.type);
if ([TYPE_EMUE.CONNECT].includes(message.type)) {
setConnectStatus(true);
}
};
// 向 App 发送消息
const sendMessageToParent = (message: Message) => {
if (window.uni && window.uni.postMessage) {
try {
window.uni.postMessage({
data: message,
});
console.log("[存客宝]SendMessage=>\n" + JSON.stringify(message));
} catch (e) {
console.error(
"[存客宝]SendMessage=>\n" + JSON.stringify(message) + "发送失败:",
e,
);
}
} else {
console.error(
"[存客宝]SendMessage=>\n" + JSON.stringify(message) + "无法发送消息",
);
}
};
// 发送自定义消息到 App
const sendCustomMessage = () => {
if (!inputMessage.trim()) return;
const newMessageId = messageId + 1;
setMessageId(newMessageId);
const message: Message = {
type: TYPE_EMUE.DATA, // 数据交互
data: {
id: newMessageId,
content: inputMessage,
source: "存客宝消息源",
timestamp: Date.now(),
},
};
sendMessageToParent(message);
setInputMessage("");
};
// 发送测试消息到 App
const sendTestMessage = () => {
const newMessageId = messageId + 1;
setMessageId(newMessageId);
const message: Message = {
type: TYPE_EMUE.DATA, // 数据交互
data: {
id: newMessageId,
action: "ping",
content: `存客宝测试消息 ${newMessageId}`,
random: Math.random(),
},
};
sendMessageToParent(message);
};
// 发送App功能调用消息
const sendAppFunctionCall = () => {
const message: Message = {
type: 1, // App功能调用
data: {
action: "showToast",
params: {
title: "来自H5的功能调用",
icon: "success",
},
},
};
sendMessageToParent(message);
};
// 清空消息列表
const clearMessages = () => {
setInputMessage("");
setReceivedMessages([]);
};
return (
<Layout
header={
<NavCommon
title="iframe调试"
right={
connectStatus ? (
<CheckCircleOutlined style={{ color: "green" }} />
) : (
<span>
<span style={{ marginRight: 4, fontSize: 12 }}>...</span>
<LoadingOutlined />
</span>
)
}
/>
}
>
<div className={style["iframe-debug-page"]}>
<div className={style.content}>
<div className={style["message-panel"]}>
<h4></h4>
<div className={style["message-list"]}>
{receivedMessages.length === 0 ? (
<div className={style["no-messages"]}></div>
) : (
receivedMessages.map((msg, index) => (
<div key={index} className={style["message-item"]}>
<span className={style["message-text"]}>{msg}</span>
</div>
))
)}
</div>
</div>
<div className={style["control-panel"]}>
<h4></h4>
<div className={style["input-group"]}>
<Input
type="text"
value={inputMessage}
onChange={e => setInputMessage(e.target.value)}
placeholder="输入要发送的消息"
/>
<button
onClick={sendCustomMessage}
className={`${style.btn} ${style["btn-primary"]}`}
>
</button>
</div>
<div className={style["button-group"]}>
<button
onClick={sendTestMessage}
className={`${style.btn} ${style["btn-secondary"]}`}
>
</button>
<button
onClick={sendAppFunctionCall}
className={`${style.btn} ${style["btn-warning"]}`}
>
</button>
<button
onClick={clearMessages}
className={`${style.btn} ${style["btn-danger"]}`}
>
</button>
</div>
</div>
</div>
</div>
</Layout>
);
};
export default IframeDebugPage;

View File

@@ -1,172 +0,0 @@
import React, { useState, useEffect } from "react";
import style from "./index.module.scss";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import { Input } from "antd";
import { useNavigate } from "react-router-dom";
import { useSettingsStore } from "@/store/module/settings";
import {
sendMessageToParent,
parseUrlMessage,
Message,
TYPE_EMUE,
} from "@/utils/postApp";
// 声明全局的 uni 对象
declare global {
interface Window {
uni: any;
}
}
const IframeDebugPage: React.FC = () => {
const { setSettings } = useSettingsStore();
const [receivedMessages, setReceivedMessages] = useState<string[]>([]);
const [messageId, setMessageId] = useState(0);
const [inputMessage, setInputMessage] = useState("");
const navigate = useNavigate();
// 解析 URL 参数中的消息
parseUrlMessage().then(message => {
if (message) {
handleReceivedMessage(message);
}
});
useEffect(() => {
parseUrlMessage();
// 监听 SDK 初始化完成事件
}, []);
// 处理接收到的消息
const handleReceivedMessage = (message: Message) => {
const messageText = `[${new Date().toLocaleTimeString()}] 收到: ${JSON.stringify(message)}`;
setReceivedMessages(prev => [...prev, messageText]);
if ([TYPE_EMUE.CONFIG].includes(message.type)) {
const { paddingTop, appId, appName, appVersion } = message.data;
setSettings({
paddingTop,
appId,
appName,
appVersion,
isAppMode: true,
});
navigate("/");
}
};
// 发送自定义消息到 App
const sendCustomMessage = () => {
if (!inputMessage.trim()) return;
const newMessageId = messageId + 1;
setMessageId(newMessageId);
const message = {
id: newMessageId,
content: inputMessage,
source: "存客宝消息源",
timestamp: Date.now(),
};
sendMessageToParent(message, TYPE_EMUE.DATA);
setInputMessage("");
};
// 发送测试消息到 App
const sendTestMessage = () => {
const newMessageId = messageId + 1;
setMessageId(newMessageId);
const message = {
id: newMessageId,
action: "ping",
content: `存客宝测试消息 ${newMessageId}`,
random: Math.random(),
};
sendMessageToParent(message, TYPE_EMUE.DATA);
};
// 发送App功能调用消息
const sendAppFunctionCall = () => {
const message = {
action: "showToast",
params: {
title: "来自H5的功能调用",
icon: "success",
},
};
sendMessageToParent(message, TYPE_EMUE.FUNCTION);
};
// 清空消息列表
const clearMessages = () => {
setInputMessage("");
setReceivedMessages([]);
};
return (
<Layout header={<NavCommon title="iframe调试" />}>
<div className={style["iframe-debug-page"]}>
<div className={style.content}>
<div className={style["message-panel"]}>
<h4></h4>
<div className={style["message-list"]}>
{receivedMessages.length === 0 ? (
<div className={style["no-messages"]}></div>
) : (
receivedMessages.map((msg, index) => (
<div key={index} className={style["message-item"]}>
<span className={style["message-text"]}>{msg}</span>
</div>
))
)}
</div>
</div>
<div className={style["control-panel"]}>
<h4></h4>
<div className={style["input-group"]}>
<Input
type="text"
value={inputMessage}
onChange={e => setInputMessage(e.target.value)}
placeholder="输入要发送的消息"
/>
<button
onClick={sendCustomMessage}
className={`${style.btn} ${style["btn-primary"]}`}
>
</button>
</div>
<div className={style["button-group"]}>
<button
onClick={sendTestMessage}
className={`${style.btn} ${style["btn-secondary"]}`}
>
</button>
<button
onClick={sendAppFunctionCall}
className={`${style.btn} ${style["btn-warning"]}`}
>
</button>
<button
onClick={clearMessages}
className={`${style.btn} ${style["btn-danger"]}`}
>
</button>
</div>
</div>
</div>
</div>
</Layout>
);
};
export default IframeDebugPage;

View File

@@ -1,54 +0,0 @@
import request from "@/api/request";
export interface LoginParams {
phone: string;
password?: string;
verificationCode?: string;
}
export interface LoginResponse {
code: number;
msg: string;
data: {
token: string;
token_expired: string;
deviceTotal: number; // 设备总数
member: {
id: string;
name: string;
phone: string;
s2_accountId: string;
avatar?: string;
email?: string;
};
};
}
export interface SendCodeResponse {
code: number;
msg: string;
}
// 密码登录
export function loginWithPassword(params: any) {
return request("/v1/auth/login", params, "POST");
}
// 验证码登录
export function loginWithCode(params: any) {
return request("/v1/auth/login-code", params, "POST");
}
// 发送验证码
export function sendVerificationCode(params: any) {
return request("/v1/auth/code", params, "POST");
}
// 退出登录
export function logout() {
return request("/v1/auth/logout", {}, "POST");
}
// 获取用户信息
export function getUserInfo() {
return request("/v1/auth/user-info", {}, "GET");
}

View File

@@ -1,436 +0,0 @@
.login-page {
min-height: 100vh;
background: var(--primary-gradient);
display: flex;
align-items: center;
justify-content: center;
padding: 15px;
position: relative;
overflow: hidden;
}
// 背景装饰
.bg-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 0;
}
.bg-circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite;
&:nth-child(1) {
width: 200px;
height: 200px;
top: -100px;
right: -100px;
animation-delay: 0s;
}
&:nth-child(2) {
width: 150px;
height: 150px;
bottom: -75px;
left: -75px;
animation-delay: 2s;
}
&:nth-child(3) {
width: 100px;
height: 100px;
top: 50%;
right: 10%;
animation-delay: 4s;
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(180deg);
}
}
.login-container {
width: 100%;
max-width: 420px;
background: #ffffff;
backdrop-filter: blur(20px);
border-radius: 24px;
padding: 24px 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
position: relative;
z-index: 1;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.login-header {
text-align: center;
margin-bottom: 24px;
}
.logo-section {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 16px;
}
.logo-icon {
width: 40px;
height: 40px;
background: var(--primary-gradient);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
box-shadow: 0 6px 12px var(--primary-shadow);
}
.app-name {
font-size: 24px;
font-weight: 800;
background: var(--primary-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
}
.subtitle {
font-size: 13px;
color: #666;
margin: 0;
}
// 标签页样式
.tab-container {
display: flex;
background: #f8f9fa;
border-radius: 10px;
padding: 3px;
margin-bottom: 24px;
position: relative;
}
.tab-item {
flex: 1;
text-align: center;
padding: 10px 12px;
font-size: 13px;
font-weight: 500;
color: #666;
cursor: pointer;
border-radius: 7px;
transition: all 0.3s ease;
position: relative;
z-index: 2;
&.active {
color: var(--primary-color);
font-weight: 600;
background: white;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
}
.tab-indicator {
display: none; // 隐藏分割线指示器
}
// 表单样式
.login-form {
:global(.adm-form) {
--adm-font-size-main: 14px;
}
}
.input-group {
margin-bottom: 18px;
}
.input-label {
display: block;
font-size: 13px;
font-weight: 600;
color: #333;
margin-bottom: 6px;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
background: #f8f9fa;
border: 2px solid transparent;
border-radius: 10px;
transition: all 0.3s ease;
&:focus-within {
border-color: var(--primary-color);
background: white;
box-shadow: 0 0 0 3px var(--primary-shadow-light);
}
}
.input-prefix {
padding: 0 12px;
color: #666;
font-size: 13px;
font-weight: 500;
border-right: 1px solid #e5e5e5;
}
.phone-input,
.password-input,
.code-input {
flex: 1;
border: none !important;
background: transparent !important;
padding: 12px 14px !important;
font-size: 15px !important;
color: #333 !important;
&::placeholder {
color: #999;
}
&:focus {
box-shadow: none !important;
}
}
.eye-icon {
padding: 0 12px;
color: #666;
cursor: pointer;
transition: color 0.3s ease;
&:hover {
color: var(--primary-color);
}
}
.send-code-btn {
padding: 6px 12px;
margin-right: 6px;
background: var(--primary-gradient);
color: white;
border: none;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover:not(.disabled) {
transform: translateY(-1px);
box-shadow: 0 3px 8px var(--primary-shadow);
}
&.disabled {
background: #e5e5e5;
color: #999;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
}
.agreement-section {
margin-bottom: 24px;
}
.agreement-checkbox {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #666;
line-height: 1.3;
white-space: nowrap;
:global(.adm-checkbox) {
margin-top: 0;
flex-shrink: 0;
transform: scale(0.8);
}
}
.agreement-text {
flex: 1;
display: flex;
align-items: center;
flex-wrap: nowrap;
white-space: nowrap;
overflow: visible;
text-overflow: clip;
font-size: 13px;
}
.agreement-link {
color: var(--primary-color);
cursor: pointer;
text-decoration: none;
white-space: nowrap;
font-size: 11px;
&:hover {
text-decoration: underline;
}
}
.login-btn {
height: 46px;
font-size: 15px;
font-weight: 600;
border-radius: 10px;
background: var(--primary-gradient);
border: none;
box-shadow: 0 6px 12px var(--primary-shadow);
transition: all 0.3s ease;
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 8px 16px var(--primary-shadow-dark);
}
&:disabled {
background: #e5e5e5;
color: #999;
transform: none;
box-shadow: none;
}
}
.divider {
position: relative;
text-align: center;
margin: 24px 0;
&::before {
content: "";
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: #e5e5e5;
}
span {
background: rgba(255, 255, 255, 0.95);
padding: 0 12px;
color: #999;
font-size: 11px;
font-weight: 500;
}
}
.third-party-login {
display: flex;
justify-content: center;
gap: 20px;
}
.third-party-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 12px;
border-radius: 10px;
transition: all 0.3s ease;
&:hover {
background: #f8f9fa;
transform: translateY(-1px);
}
span {
font-size: 11px;
color: #666;
font-weight: 500;
}
}
.wechat-icon,
.apple-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
transition: all 0.3s ease;
}
.wechat-icon {
background: #07c160;
box-shadow: 0 3px 8px rgba(7, 193, 96, 0.3);
&:hover {
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.4);
}
svg {
width: 20px;
height: 20px;
}
}
.apple-icon {
background: #000;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
svg {
width: 20px;
height: 20px;
}
}
// 响应式设计
@media (max-width: 480px) {
.login-container {
padding: 24px 20px;
margin: 0 12px;
}
.app-name {
font-size: 22px;
}
.third-party-login {
gap: 16px;
}
.third-party-item {
padding: 10px;
}
.wechat-icon,
.apple-icon {
width: 32px;
height: 32px;
}
}

View File

@@ -1,309 +0,0 @@
import React, { useState, useEffect } from "react";
import { Form, Input, Button, Toast, Checkbox } from "antd-mobile";
import {
EyeInvisibleOutline,
EyeOutline,
UserOutline,
} from "antd-mobile-icons";
import { useUserStore } from "@/store/module/user";
import { loginWithPassword, loginWithCode, sendVerificationCode } from "./api";
import style from "./login.module.scss";
const Login: React.FC = () => {
const [form] = Form.useForm();
const [activeTab, setActiveTab] = useState(1); // 1: 密码登录, 2: 验证码登录
const [loading, setLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
const [showPassword, setShowPassword] = useState(false);
const [agreeToTerms, setAgreeToTerms] = useState(false);
const { login } = useUserStore();
// 倒计时效果
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
}
}, [countdown]);
// 发送验证码
const handleSendVerificationCode = async () => {
const account = form.getFieldValue("account");
if (!account) {
Toast.show({ content: "请输入手机号", position: "top" });
return;
}
// 手机号格式验证
const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneRegex.test(account)) {
Toast.show({ content: "请输入正确的11位手机号", position: "top" });
return;
}
try {
setLoading(true);
await sendVerificationCode({
mobile: account,
type: "login",
});
Toast.show({ content: "验证码已发送", position: "top" });
setCountdown(60);
} catch (error) {
// 错误已在request中处理这里不需要额外处理
} finally {
setLoading(false);
}
};
// 登录处理
const handleLogin = async (values: any) => {
if (!agreeToTerms) {
Toast.show({ content: "请同意用户协议和隐私政策", position: "top" });
return;
}
setLoading(true);
try {
// 添加typeId参数
const loginParams = {
...values,
typeId: activeTab as number,
};
let response;
if (activeTab === 1) {
response = await loginWithPassword(loginParams);
} else {
response = await loginWithCode(loginParams);
}
// 获取设备总数
const deviceTotal = response.deviceTotal || 0;
// 更新状态管理token会自动存储到localStorage用户信息存储在状态管理中
login(response.token, response.member, deviceTotal);
} catch (error: any) {
// 错误已在request中处理这里不需要额外处理
} finally {
setLoading(false);
}
};
// 第三方登录处理
const handleWechatLogin = () => {
Toast.show({ content: "微信登录功能开发中", position: "top" });
};
const handleAppleLogin = () => {
Toast.show({ content: "Apple登录功能开发中", position: "top" });
};
const paddingTop = localStorage.getItem("paddingTop") || "44px";
return (
<div className={style["login-page"]}>
<div style={{ height: paddingTop }}></div>
<div style={{ height: "80px" }}></div>
{/* 背景装饰 */}
<div className={style["bg-decoration"]}>
<div className={style["bg-circle"]}></div>
<div className={style["bg-circle"]}></div>
<div className={style["bg-circle"]}></div>
</div>
<div className={style["login-container"]}>
{/* Logo和标题区域 */}
<div className={style["login-header"]}>
<div className={style["logo-section"]}>
<div className={style["logo-icon"]}>
<UserOutline />
</div>
<h1 className={style["app-name"]}></h1>
</div>
<p className={style["subtitle"]}>使</p>
</div>
{/* 登录表单 */}
<div className={style["form-container"]}>
{/* 标签页切换 */}
<div className={style["tab-container"]}>
<div
className={`${style["tab-item"]} ${
activeTab === 1 ? style["active"] : ""
}`}
onClick={() => setActiveTab(1)}
>
</div>
<div
className={`${style["tab-item"]} ${
activeTab === 2 ? style["active"] : ""
}`}
onClick={() => setActiveTab(2)}
>
</div>
<div
className={`${style["tab-indicator"]} ${
activeTab === 2 ? style["slide"] : ""
}`}
></div>
</div>
<Form
form={form}
layout="vertical"
className={style["login-form"]}
onFinish={handleLogin}
>
{/* 手机号输入 */}
<Form.Item
name="account"
label="手机号"
rules={[
{ required: true, message: "请输入手机号" },
{
pattern: /^1[3-9]\d{9}$/,
message: "请输入正确的11位手机号",
},
]}
>
<div className={style["input-wrapper"]}>
<span className={style["input-prefix"]}>+86</span>
<Input
placeholder="请输入手机号"
clearable
className={style["phone-input"]}
/>
</div>
</Form.Item>
{/* 密码输入 */}
{activeTab === 1 && (
<Form.Item
name="password"
label="密码"
rules={[{ required: true, message: "请输入密码" }]}
>
<div className={style["input-wrapper"]}>
<Input
placeholder="请输入密码"
clearable
type={showPassword ? "text" : "password"}
className={style["password-input"]}
/>
<div
className={style["eye-icon"]}
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOutline /> : <EyeInvisibleOutline />}
</div>
</div>
</Form.Item>
)}
{/* 验证码输入 */}
{activeTab === 2 && (
<Form.Item
name="verificationCode"
label="验证码"
rules={[{ required: true, message: "请输入验证码" }]}
>
<div className={style["input-wrapper"]}>
<Input
placeholder="请输入验证码"
clearable
className={style["code-input"]}
/>
<button
type="button"
className={`${style["send-code-btn"]} ${
countdown > 0 ? style["disabled"] : ""
}`}
onClick={handleSendVerificationCode}
disabled={loading || countdown > 0}
>
{countdown > 0 ? `${countdown}s` : "获取验证码"}
</button>
</div>
</Form.Item>
)}
{/* 用户协议 */}
<div className={style["agreement-section"]}>
<Checkbox
checked={agreeToTerms}
onChange={setAgreeToTerms}
className={style["agreement-checkbox"]}
>
<span className={style["agreement-text"]}>
<span className={style["agreement-link"]}>
</span>
<span className={style["agreement-link"]}></span>
</span>
</Checkbox>
</div>
{/* 登录按钮 */}
<Button
block
type="submit"
color="primary"
loading={loading}
size="large"
className={style["login-btn"]}
>
{loading ? "登录中..." : "登录"}
</Button>
</Form>
{/* 分割线 */}
<div className={style["divider"]}>
<span></span>
</div>
{/* 第三方登录 */}
<div className={style["third-party-login"]}>
<div
className={style["third-party-item"]}
onClick={handleWechatLogin}
>
<div className={style["wechat-icon"]}>
<svg
viewBox="0 0 24 24"
fill="currentColor"
height="24"
width="24"
className={style["wechat-icon"]}
>
<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>
<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"></path>
</svg>
</div>
<span></span>
</div>
<div
className={style["third-party-item"]}
onClick={handleAppleLogin}
>
<div className={style["apple-icon"]}>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
</div>
<span>Apple</span>
</div>
</div>
</div>
</div>
</div>
);
};
export default Login;

View File

@@ -1,26 +0,0 @@
import request from "@/api/request";
import {
ContentLibrary,
CreateContentLibraryParams,
UpdateContentLibraryParams,
} from "./data";
// 获取内容库详情
export function getContentLibraryDetail(id: string): Promise<any> {
return request("/v1/content/library/detail", { id }, "GET");
}
// 创建内容库
export function createContentLibrary(
params: CreateContentLibraryParams,
): Promise<any> {
return request("/v1/content/library/create", params, "POST");
}
// 更新内容库
export function updateContentLibrary(
params: UpdateContentLibraryParams,
): Promise<any> {
const { id, ...data } = params;
return request(`/v1/content/library/update`, { id, ...data }, "POST");
}

View File

@@ -1,61 +0,0 @@
// 内容库表单数据类型定义
export interface ContentLibrary {
id: string;
name: string;
sourceType: number; // 1=微信好友, 2=聊天群
creatorName?: string;
updateTime: string;
status: number; // 0=未启用, 1=已启用
itemCount?: number;
createTime: string;
sourceFriends?: string[];
sourceGroups?: string[];
keywordInclude?: string[];
keywordExclude?: string[];
aiPrompt?: string;
timeEnabled?: number;
timeStart?: string;
timeEnd?: string;
selectedFriends?: any[];
selectedGroups?: any[];
selectedGroupMembers?: WechatGroupMember[];
}
// 微信群成员
export interface WechatGroupMember {
id: string;
nickname: string;
wechatId: string;
avatar: string;
gender?: "male" | "female";
role?: "owner" | "admin" | "member";
joinTime?: string;
}
// API 响应类型
export interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
// 创建内容库参数
export interface CreateContentLibraryParams {
name: string;
sourceType: number;
sourceFriends?: string[];
sourceGroups?: string[];
keywordInclude?: string[];
keywordExclude?: string[];
aiPrompt?: string;
timeEnabled?: number;
timeStart?: string;
timeEnd?: string;
}
// 更新内容库参数
export interface UpdateContentLibraryParams
extends Partial<CreateContentLibraryParams> {
id: string;
status?: number;
}

View File

@@ -1,140 +0,0 @@
.form-page {
background: #f7f8fa;
padding: 16px;
}
.form-main {
max-width: 420px;
margin: 0 auto;
padding: 16px 0 0 0;
}
.form-section {
margin-bottom: 18px;
}
.form-card {
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
padding: 24px 18px 18px 18px;
background: #fff;
}
.form-label {
font-weight: 600;
font-size: 16px;
color: #222;
display: block;
margin-bottom: 6px;
}
.section-title {
font-size: 16px;
font-weight: 700;
color: #222;
margin-top: 28px;
margin-bottom: 12px;
letter-spacing: 0.5px;
}
.section-block {
padding: 12px 0 8px 0;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 8px;
}
.tabs-bar {
.adm-tabs-header {
background: #f7f8fa;
border-radius: 8px;
margin-bottom: 8px;
}
.adm-tabs-tab {
font-size: 15px;
font-weight: 500;
padding: 8px 0;
}
}
.collapse {
margin-top: 12px;
.adm-collapse-panel-content {
padding-bottom: 8px;
background: #f8fafc;
border-radius: 10px;
padding: 18px 14px 10px 14px;
margin-top: 2px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
}
.form-section {
margin-bottom: 22px;
}
.form-label {
font-size: 15px;
font-weight: 500;
margin-bottom: 4px;
color: #333;
}
.adm-input {
min-height: 42px;
font-size: 15px;
border-radius: 7px;
margin-bottom: 2px;
}
}
.ai-row,
.section-block {
display: flex;
align-items: center;
gap: 12px;
}
.ai-desc {
color: #888;
font-size: 13px;
flex: 1;
}
.date-row,
.section-block {
display: flex;
gap: 12px;
align-items: center;
}
.adm-input {
min-height: 44px;
font-size: 15px;
border-radius: 8px;
}
.submit-btn {
margin-top: 32px;
height: 48px !important;
border-radius: 10px !important;
font-size: 17px;
font-weight: 600;
letter-spacing: 1px;
}
@media (max-width: 600px) {
.form-main {
max-width: 100vw;
padding: 0;
}
.form-card {
border-radius: 0;
box-shadow: none;
padding: 16px 6px 12px 6px;
}
.section-title {
font-size: 15px;
margin-top: 22px;
margin-bottom: 8px;
}
.submit-btn {
height: 44px !important;
font-size: 15px;
}
}

View File

@@ -1,330 +0,0 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Input as AntdInput, Switch } from "antd";
import { Button, Collapse, Toast, DatePicker, Tabs } from "antd-mobile";
import NavCommon from "@/components/NavCommon";
import FriendSelection from "@/components/FriendSelection";
import GroupSelection from "@/components/GroupSelection";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import request from "@/api/request";
import { getContentLibraryDetail, updateContentLibrary } from "./api";
import { GroupSelectionItem } from "@/components/GroupSelection/data";
import { FriendSelectionItem } from "@/components/FriendSelection/data";
const { TextArea } = AntdInput;
function formatDate(date: Date | null) {
if (!date) return "";
// 格式化为 YYYY-MM-DD
const y = date.getFullYear();
const m = (date.getMonth() + 1).toString().padStart(2, "0");
const d = date.getDate().toString().padStart(2, "0");
return `${y}-${m}-${d}`;
}
export default function ContentForm() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const isEdit = !!id;
const [sourceType, setSourceType] = useState<"friends" | "groups">("friends");
const [name, setName] = useState("");
const [friendsGroups, setSelectedFriends] = useState<string[]>([]);
const [friendsGroupsOptions, setSelectedFriendsOptions] = useState<
FriendSelectionItem[]
>([]);
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
const [selectedGroupsOptions, setSelectedGroupsOptions] = useState<
GroupSelectionItem[]
>([]);
const [useAI, setUseAI] = useState(false);
const [aiPrompt, setAIPrompt] = useState("");
const [enabled, setEnabled] = useState(true);
const [dateRange, setDateRange] = useState<[Date | null, Date | null]>([
null,
null,
]);
const [showStartPicker, setShowStartPicker] = useState(false);
const [showEndPicker, setShowEndPicker] = useState(false);
const [keywordsInclude, setKeywordsInclude] = useState("");
const [keywordsExclude, setKeywordsExclude] = useState("");
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(false);
// 编辑模式下拉详情并回填
useEffect(() => {
if (isEdit && id) {
setLoading(true);
getContentLibraryDetail(id)
.then(data => {
setName(data.name || "");
setSourceType(data.sourceType === 1 ? "friends" : "groups");
setSelectedFriends(data.sourceFriends || []);
setSelectedGroups(data.selectedGroups || []);
setSelectedGroupsOptions(data.selectedGroupsOptions || []);
setSelectedFriendsOptions(data.sourceFriendsOptions || []);
setKeywordsInclude((data.keywordInclude || []).join(","));
setKeywordsExclude((data.keywordExclude || []).join(","));
setAIPrompt(data.aiPrompt || "");
setUseAI(!!data.aiPrompt);
setEnabled(data.status === 1);
// 时间范围
const start = data.timeStart || data.startTime;
const end = data.timeEnd || data.endTime;
setDateRange([
start ? new Date(start) : null,
end ? new Date(end) : null,
]);
})
.catch(e => {
Toast.show({
content: e?.message || "获取详情失败",
position: "top",
});
})
.finally(() => setLoading(false));
}
}, [isEdit, id]);
const handleSubmit = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!name.trim()) {
Toast.show({ content: "请输入内容库名称", position: "top" });
return;
}
setSubmitting(true);
try {
const payload = {
name,
sourceType: sourceType === "friends" ? 1 : 2,
friends: friendsGroups,
groups: selectedGroups,
groupMembers: {},
keywordInclude: keywordsInclude
.split(/,||\n|\s+/)
.map(s => s.trim())
.filter(Boolean),
keywordExclude: keywordsExclude
.split(/,||\n|\s+/)
.map(s => s.trim())
.filter(Boolean),
aiPrompt,
timeEnabled: dateRange[0] || dateRange[1] ? 1 : 0,
startTime: dateRange[0] ? formatDate(dateRange[0]) : "",
endTime: dateRange[1] ? formatDate(dateRange[1]) : "",
status: enabled ? 1 : 0,
};
if (isEdit && id) {
await updateContentLibrary({ id, ...payload });
Toast.show({ content: "保存成功", position: "top" });
} else {
await request("/v1/content/library/create", payload, "POST");
Toast.show({ content: "创建成功", position: "top" });
}
navigate("/mine/content");
} catch (e: any) {
Toast.show({
content: e?.message || (isEdit ? "保存失败" : "创建失败"),
position: "top",
});
} finally {
setSubmitting(false);
}
};
const handleGroupsChange = (groups: GroupSelectionItem[]) => {
setSelectedGroups(groups.map(g => g.id.toString()));
setSelectedGroupsOptions(groups);
};
const handleFriendsChange = (friends: FriendSelectionItem[]) => {
setSelectedFriends(friends.map(f => f.id.toString()));
setSelectedFriendsOptions(friends);
};
return (
<Layout
header={<NavCommon title={isEdit ? "编辑内容库" : "新建内容库"} />}
footer={
<div style={{ padding: "16px", backgroundColor: "#fff" }}>
<Button
block
color="primary"
loading={submitting || loading}
disabled={submitting || loading}
onClick={handleSubmit}
>
{isEdit
? submitting
? "保存中..."
: "保存内容库"
: submitting
? "创建中..."
: "创建内容库"}
</Button>
</div>
}
>
<div className={style["form-page"]}>
<form
className={style["form-main"]}
onSubmit={e => e.preventDefault()}
autoComplete="off"
>
<div className={style["form-section"]}>
<label className={style["form-label"]}>
<span style={{ color: "#ff4d4f", marginRight: 4 }}>*</span>
</label>
<AntdInput
placeholder="请输入内容库名称"
value={name}
onChange={e => setName(e.target.value)}
className={style["input"]}
/>
</div>
<div className={style["section-title"]}></div>
<div className={style["form-section"]}>
<Tabs
activeKey={sourceType}
onChange={key => setSourceType(key as "friends" | "groups")}
className={style["tabs-bar"]}
>
<Tabs.Tab title="选择微信好友" key="friends">
<FriendSelection
selectedFriends={friendsGroupsOptions}
onSelect={handleFriendsChange}
placeholder="选择微信好友"
/>
</Tabs.Tab>
<Tabs.Tab title="选择聊天群" key="groups">
<GroupSelection
selectedOptions={selectedGroupsOptions}
onSelect={handleGroupsChange}
placeholder="选择聊天群"
/>
</Tabs.Tab>
</Tabs>
</div>
<Collapse
defaultActiveKey={["keywords"]}
className={style["collapse"]}
>
<Collapse.Panel
key="keywords"
title={<span className={style["form-label"]}></span>}
>
<div className={style["form-section"]}>
<label className={style["form-label"]}></label>
<TextArea
placeholder="多个关键词用逗号分隔"
value={keywordsInclude}
onChange={e => setKeywordsInclude(e.target.value)}
className={style["input"]}
autoSize={{ minRows: 2, maxRows: 4 }}
/>
</div>
<div className={style["form-section"]}>
<label className={style["form-label"]}></label>
<TextArea
placeholder="多个关键词用逗号分隔"
value={keywordsExclude}
onChange={e => setKeywordsExclude(e.target.value)}
className={style["input"]}
autoSize={{ minRows: 2, maxRows: 4 }}
/>
</div>
</Collapse.Panel>
</Collapse>
<div className={style["section-title"]}>AI</div>
<div
className={style["form-section"]}
style={{ display: "flex", alignItems: "center", gap: 12 }}
>
<Switch checked={useAI} onChange={setUseAI} />
<span className={style["ai-desc"]}>
AI后AI生成
</span>
</div>
{useAI && (
<div className={style["form-section"]}>
<label className={style["form-label"]}>AI提示词</label>
<AntdInput
placeholder="请输入AI提示词"
value={aiPrompt}
onChange={e => setAIPrompt(e.target.value)}
className={style["input"]}
/>
</div>
)}
<div className={style["section-title"]}></div>
<div
className={style["form-section"]}
style={{ display: "flex", gap: 12 }}
>
<label></label>
<div style={{ flex: 1 }}>
<AntdInput
readOnly
value={dateRange[0] ? dateRange[0].toLocaleDateString() : ""}
placeholder="年/月/日"
className={style["input"]}
onClick={() => setShowStartPicker(true)}
/>
<DatePicker
visible={showStartPicker}
title="开始时间"
value={dateRange[0]}
onClose={() => setShowStartPicker(false)}
onConfirm={val => {
setDateRange([val, dateRange[1]]);
setShowStartPicker(false);
}}
/>
</div>
<label></label>
<div style={{ flex: 1 }}>
<AntdInput
readOnly
value={dateRange[1] ? dateRange[1].toLocaleDateString() : ""}
placeholder="年/月/日"
className={style["input"]}
onClick={() => setShowEndPicker(true)}
/>
<DatePicker
visible={showEndPicker}
title="结束时间"
value={dateRange[1]}
onClose={() => setShowEndPicker(false)}
onConfirm={val => {
setDateRange([dateRange[0], val]);
setShowEndPicker(false);
}}
/>
</div>
</div>
<div
className={style["section-title"]}
style={{
marginTop: 24,
marginBottom: 8,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span></span>
<Switch checked={enabled} onChange={setEnabled} />
</div>
</form>
</div>
</Layout>
);
}

View File

@@ -1,49 +0,0 @@
import request from "@/api/request";
import {
ContentLibrary,
CreateContentLibraryParams,
UpdateContentLibraryParams,
} from "./data";
// 获取内容库列表
export function getContentLibraryList(params: {
page?: number;
limit?: number;
keyword?: string;
sourceType?: number;
}): Promise<any> {
return request("/v1/content/library/list", params, "GET");
}
// 获取内容库详情
export function getContentLibraryDetail(id: string): Promise<any> {
return request("/v1/content/library/detail", { id }, "GET");
}
// 创建内容库
export function createContentLibrary(
params: CreateContentLibraryParams,
): Promise<any> {
return request("/v1/content/library/create", params, "POST");
}
// 更新内容库
export function updateContentLibrary(
params: UpdateContentLibraryParams,
): Promise<any> {
const { id, ...data } = params;
return request(`/v1/content/library/update`, { id, ...data }, "POST");
}
// 删除内容库
export function deleteContentLibrary(id: string): Promise<any> {
return request("/v1/content/library/delete", { id }, "DELETE");
}
// 切换内容库状态
export function toggleContentLibraryStatus(
id: string,
status: number,
): Promise<any> {
return request("/v1/content/library/update-status", { id, status }, "POST");
}

View File

@@ -1,66 +0,0 @@
// 内容库接口类型定义
export interface ContentLibrary {
id: string;
name: string;
sourceType: number; // 1=微信好友, 2=聊天群
creatorName?: string;
updateTime: string;
status: number; // 0=未启用, 1=已启用
itemCount?: number;
createTime: string;
sourceFriends?: string[];
sourceGroups?: string[];
keywordInclude?: string[];
keywordExclude?: string[];
aiPrompt?: string;
timeEnabled?: number;
timeStart?: string;
timeEnd?: string;
selectedFriends?: any[];
selectedGroups?: any[];
selectedGroupMembers?: WechatGroupMember[];
}
// 微信群成员
export interface WechatGroupMember {
id: string;
nickname: string;
wechatId: string;
avatar: string;
gender?: "male" | "female";
role?: "owner" | "admin" | "member";
joinTime?: string;
}
// API 响应类型
export interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
export interface LibraryListResponse {
list: ContentLibrary[];
total: number;
}
// 创建内容库参数
export interface CreateContentLibraryParams {
name: string;
sourceType: number;
sourceFriends?: string[];
sourceGroups?: string[];
keywordInclude?: string[];
keywordExclude?: string[];
aiPrompt?: string;
timeEnabled?: number;
timeStart?: string;
timeEnd?: string;
}
// 更新内容库参数
export interface UpdateContentLibraryParams
extends Partial<CreateContentLibraryParams> {
id: string;
status?: number;
}

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