Merge branch 'yongpxu-dev' into yongpxu-dev2
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,9 +5,11 @@ Store_vue/node_modules/
|
|||||||
Cunkebao/.specstory/
|
Cunkebao/.specstory/
|
||||||
*.cursorindexingignore
|
*.cursorindexingignore
|
||||||
Server/.specstory/
|
Server/.specstory/
|
||||||
|
Server/thinkphp/
|
||||||
Store_vue/.specstory/
|
Store_vue/.specstory/
|
||||||
Store_vue/unpackage/
|
Store_vue/unpackage/
|
||||||
Store_vue/.vscode/
|
Store_vue/.vscode/
|
||||||
SuperAdmin/.specstory/
|
SuperAdmin/.specstory/
|
||||||
Cunkebao/dist
|
Cunkebao/dist
|
||||||
Touchkebao/.specstory/
|
Touchkebao/.specstory/
|
||||||
|
Serverruntime/
|
||||||
|
|||||||
8
Cunkebao/pnpm-lock.yaml
generated
8
Cunkebao/pnpm-lock.yaml
generated
@@ -26,9 +26,6 @@ importers:
|
|||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.13
|
specifier: ^1.11.13
|
||||||
version: 1.11.13
|
version: 1.11.13
|
||||||
dexie:
|
|
||||||
specifier: ^4.2.0
|
|
||||||
version: 4.2.0
|
|
||||||
echarts:
|
echarts:
|
||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.6.0
|
version: 5.6.0
|
||||||
@@ -1070,9 +1067,6 @@ packages:
|
|||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
dexie@4.2.0:
|
|
||||||
resolution: {integrity: sha512-OSeyyWOUetDy9oFWeddJgi83OnRA3hSFh3RrbltmPgqHszE9f24eUCVLI4mPg0ifsWk0lQTdnS+jyGNrPMvhDA==}
|
|
||||||
|
|
||||||
dir-glob@3.0.1:
|
dir-glob@3.0.1:
|
||||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -3410,8 +3404,6 @@ snapshots:
|
|||||||
detect-libc@1.0.3:
|
detect-libc@1.0.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
dexie@4.2.0: {}
|
|
||||||
|
|
||||||
dir-glob@3.0.1:
|
dir-glob@3.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-type: 4.0.0
|
path-type: 4.0.0
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
.chatFooter {
|
|
||||||
background: #f7f7f7;
|
|
||||||
border-top: 1px solid #e1e1e1;
|
|
||||||
padding: 0;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inputContainer {
|
|
||||||
padding: 8px 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inputToolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leftTool {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbarButton {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #666;
|
|
||||||
font-size: 16px;
|
|
||||||
transition: all 0.15s;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #e6e6e6;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background: #d9d9d9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.inputArea {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inputWrapper {
|
|
||||||
border: 1px solid #d1d1d1;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #fff;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&:focus-within {
|
|
||||||
border-color: #07c160;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageInput {
|
|
||||||
width: 100%;
|
|
||||||
border: none;
|
|
||||||
resize: none;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.4;
|
|
||||||
padding: 8px 10px;
|
|
||||||
background: transparent;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
box-shadow: none;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: #b3b3b3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sendButtonArea {
|
|
||||||
padding: 8px 10px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sendButton {
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: normal;
|
|
||||||
min-width: 60px;
|
|
||||||
font-size: 13px;
|
|
||||||
background: #07c160;
|
|
||||||
border-color: #07c160;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #06ad56;
|
|
||||||
border-color: #06ad56;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background: #059748;
|
|
||||||
border-color: #059748;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
background: #b3b3b3;
|
|
||||||
border-color: #b3b3b3;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hintButton {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #666;
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.inputHint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #999;
|
|
||||||
text-align: right;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.inputToolbar {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sendButtonArea {
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
.stepContent {
|
|
||||||
.stepHeader {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.step3Content {
|
|
||||||
display: flex;
|
|
||||||
gap: 24px;
|
|
||||||
align-items: flex-start;
|
|
||||||
|
|
||||||
.leftColumn {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rightColumn {
|
|
||||||
width: 400px;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messagePreview {
|
|
||||||
border: 2px dashed #52c41a;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f6ffed;
|
|
||||||
|
|
||||||
.previewTitle {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #52c41a;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageBubble {
|
|
||||||
min-height: 60px;
|
|
||||||
padding: 12px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
|
|
||||||
.currentEditingLabel {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageText {
|
|
||||||
color: #333;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.savedScriptGroups {
|
|
||||||
.scriptGroupTitle {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scriptGroupItem {
|
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
background: #fff;
|
|
||||||
|
|
||||||
.scriptGroupHeader {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.scriptGroupLeft {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
:global(.ant-radio) {
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scriptGroupName {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageCount {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scriptGroupActions {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
|
|
||||||
.actionButton {
|
|
||||||
padding: 4px;
|
|
||||||
color: #666;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scriptGroupContent {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageInputArea {
|
|
||||||
.messageInput {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachmentButtons {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aiRewriteSection {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageHint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingsPanel {
|
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
background: #fafafa;
|
|
||||||
|
|
||||||
.settingsTitle {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingItem {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingLabel {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingControl {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagSection {
|
|
||||||
.settingLabel {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pushPreview {
|
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f0f7ff;
|
|
||||||
|
|
||||||
.previewTitle {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
li {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.step3Content {
|
|
||||||
.rightColumn {
|
|
||||||
width: 350px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.step3Content {
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.leftColumn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rightColumn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import ContentSelection from "@/components/ContentSelection";
|
|
||||||
import { ContentItem } from "@/components/ContentSelection/data";
|
|
||||||
import InputMessage from "./InputMessage/InputMessage";
|
|
||||||
import styles from "./index.module.scss";
|
|
||||||
|
|
||||||
interface StepSendMessageProps {
|
|
||||||
@@ -457,22 +457,16 @@ class WebSocketController extends BaseController
|
|||||||
|
|
||||||
// 构建请求参数
|
// 构建请求参数
|
||||||
$params = [
|
$params = [
|
||||||
"cmdType" => 'CmdDownloadMomentImagesResult',
|
"cmdType" => 'CmdDownloadMomentImages',
|
||||||
"snsId" => $data['snsId'],
|
"snsId" => $data['snsId'],
|
||||||
"urls" => $data['snsUrls'],
|
"urls" => $data['snsUrls'],
|
||||||
"wechatAccountId" => $data['wechatAccountId'],
|
"wechatAccountId" => $data['wechatAccountId'],
|
||||||
"seq" => time(),
|
"seq" => time(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// 记录请求日志
|
// 记录请求日志
|
||||||
Log::info('获取朋友圈资源链接请求:' . json_encode($params, 256));
|
Log::info('获取朋友圈资源链接请求:' . json_encode($params, 256));
|
||||||
|
|
||||||
// 发送请求
|
$message = $this->sendMessage($params);
|
||||||
$this->client->send(json_encode($params));
|
|
||||||
|
|
||||||
// 接收响应
|
|
||||||
$response = $this->client->receive();
|
|
||||||
$message = json_decode($response, true);
|
|
||||||
|
|
||||||
if (empty($message)) {
|
if (empty($message)) {
|
||||||
return json_encode(['code' => 500, 'msg' => '获取朋友圈资源链接失败']);
|
return json_encode(['code' => 500, 'msg' => '获取朋友圈资源链接失败']);
|
||||||
@@ -558,15 +552,17 @@ class WebSocketController extends BaseController
|
|||||||
$dataToSave['create_time'] = time();
|
$dataToSave['create_time'] = time();
|
||||||
$res = WechatMoments::create($dataToSave);
|
$res = WechatMoments::create($dataToSave);
|
||||||
}
|
}
|
||||||
// // 获取资源链接
|
|
||||||
// if(empty($momentEntity['resUrls']) && !empty($momentEntity['urls'])){
|
|
||||||
// $snsData = [
|
// 获取资源链接
|
||||||
// 'snsId' => $moment['snsId'],
|
if(empty($momentEntity['resUrls']) && !empty($momentEntity['urls']) && $moment['type'] == 1) {
|
||||||
// 'snsUrls' => $momentEntity['urls'],
|
$snsData = [
|
||||||
// 'wechatAccountId' => $wechatAccountId,
|
'snsId' => $moment['snsId'],
|
||||||
// ];
|
'snsUrls' => $momentEntity['urls'],
|
||||||
// $this->getMomentSourceRealUrl($snsData);
|
'wechatAccountId' => $wechatAccountId,
|
||||||
// }
|
];
|
||||||
|
$this->getMomentSourceRealUrl($snsData);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
//Log::write('朋友圈数据已存入数据库,共' . count($momentList) . '条');
|
//Log::write('朋友圈数据已存入数据库,共' . count($momentList) . '条');
|
||||||
|
|||||||
@@ -7,5 +7,27 @@ use think\Model;
|
|||||||
class WechatAccountModel extends Model
|
class WechatAccountModel extends Model
|
||||||
{
|
{
|
||||||
// 设置表名
|
// 设置表名
|
||||||
protected $table = 's2_wechat_account';
|
protected $table = 's2_wechat_account';
|
||||||
|
|
||||||
|
// 定义字段类型
|
||||||
|
protected $type = [
|
||||||
|
'healthScore' => 'integer',
|
||||||
|
'baseScore' => 'integer',
|
||||||
|
'dynamicScore' => 'integer',
|
||||||
|
'isModifiedAlias' => 'integer',
|
||||||
|
'frequentCount' => 'integer',
|
||||||
|
'consecutiveNoFrequentDays' => 'integer',
|
||||||
|
'lastFrequentTime' => 'integer',
|
||||||
|
'lastNoFrequentTime' => 'integer',
|
||||||
|
'scoreUpdateTime' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 允许批量赋值的字段
|
||||||
|
protected $field = [
|
||||||
|
'id', 'wechatId', 'alias', 'nickname', 'avatar', 'gender', 'region', 'signature',
|
||||||
|
'healthScore', 'baseScore', 'dynamicScore', 'isModifiedAlias',
|
||||||
|
'lastFrequentTime', 'frequentCount', 'lastNoFrequentTime',
|
||||||
|
'consecutiveNoFrequentDays', 'scoreUpdateTime',
|
||||||
|
'createTime', 'updateTime', 'status', 'isDeleted'
|
||||||
|
];
|
||||||
}
|
}
|
||||||
@@ -39,4 +39,7 @@ return [
|
|||||||
'workbench:groupCreate' => 'app\command\WorkbenchGroupCreateCommand', // 工作台群创建任务
|
'workbench:groupCreate' => 'app\command\WorkbenchGroupCreateCommand', // 工作台群创建任务
|
||||||
'workbench:import-contact' => 'app\command\WorkbenchImportContactCommand', // 工作台通讯录导入任务
|
'workbench:import-contact' => 'app\command\WorkbenchImportContactCommand', // 工作台通讯录导入任务
|
||||||
'kf:notice' => 'app\command\KfNoticeCommand', // 客服端消息通知
|
'kf:notice' => 'app\command\KfNoticeCommand', // 客服端消息通知
|
||||||
|
|
||||||
|
'wechat:calculate-score' => 'app\command\CalculateWechatAccountScoreCommand', // 统一计算微信账号健康分
|
||||||
|
'wechat:update-score' => 'app\command\UpdateWechatAccountScoreCommand', // 更新微信账号评分记录
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,558 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\command;
|
||||||
|
|
||||||
|
use think\console\Command;
|
||||||
|
use think\console\Input;
|
||||||
|
use think\console\Output;
|
||||||
|
use think\Db;
|
||||||
|
use think\facade\Log;
|
||||||
|
use app\common\service\WechatAccountHealthScoreService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一计算微信账号健康分命令
|
||||||
|
* 一个命令完成所有评分工作:
|
||||||
|
* 1. 初始化未计算的账号(基础分只计算一次)
|
||||||
|
* 2. 更新评分记录(根据wechatId和alias不一致情况)
|
||||||
|
* 3. 批量更新健康分(只更新动态分)
|
||||||
|
*/
|
||||||
|
class CalculateWechatAccountScoreCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 数据库表名
|
||||||
|
*/
|
||||||
|
const TABLE_WECHAT_ACCOUNT = 's2_wechat_account';
|
||||||
|
const TABLE_WECHAT_ACCOUNT_SCORE = 's2_wechat_account_score';
|
||||||
|
protected function configure()
|
||||||
|
{
|
||||||
|
$this->setName('wechat:calculate-score')
|
||||||
|
->setDescription('统一计算微信账号健康分(包含初始化、更新评分记录、批量计算)')
|
||||||
|
->addOption('only-init', null, \think\console\input\Option::VALUE_NONE, '仅执行初始化步骤')
|
||||||
|
->addOption('only-update', null, \think\console\input\Option::VALUE_NONE, '仅执行更新评分记录步骤')
|
||||||
|
->addOption('only-batch', null, \think\console\input\Option::VALUE_NONE, '仅执行批量更新健康分步骤')
|
||||||
|
->addOption('account-id', 'a', \think\console\input\Option::VALUE_OPTIONAL, '指定账号ID,仅处理该账号')
|
||||||
|
->addOption('batch-size', 'b', \think\console\input\Option::VALUE_OPTIONAL, '批处理大小', 50)
|
||||||
|
->addOption('force-recalculate', 'f', \think\console\input\Option::VALUE_NONE, '强制重新计算基础分');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行命令
|
||||||
|
*
|
||||||
|
* @param Input $input 输入对象
|
||||||
|
* @param Output $output 输出对象
|
||||||
|
* @return int 命令执行状态码(0表示成功)
|
||||||
|
*/
|
||||||
|
protected function execute(Input $input, Output $output)
|
||||||
|
{
|
||||||
|
// 解析命令行参数
|
||||||
|
$onlyInit = $input->getOption('only-init');
|
||||||
|
$onlyUpdate = $input->getOption('only-update');
|
||||||
|
$onlyBatch = $input->getOption('only-batch');
|
||||||
|
$accountId = $input->getOption('account-id');
|
||||||
|
$batchSize = (int)$input->getOption('batch-size');
|
||||||
|
$forceRecalculate = $input->getOption('force-recalculate');
|
||||||
|
|
||||||
|
// 参数验证
|
||||||
|
if ($batchSize <= 0) {
|
||||||
|
$batchSize = 50; // 默认批处理大小
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示执行参数
|
||||||
|
$output->writeln("==========================================");
|
||||||
|
$output->writeln("开始统一计算微信账号健康分...");
|
||||||
|
$output->writeln("==========================================");
|
||||||
|
|
||||||
|
if ($accountId) {
|
||||||
|
$output->writeln("指定账号ID: {$accountId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($onlyInit) {
|
||||||
|
$output->writeln("仅执行初始化步骤");
|
||||||
|
} elseif ($onlyUpdate) {
|
||||||
|
$output->writeln("仅执行更新评分记录步骤");
|
||||||
|
} elseif ($onlyBatch) {
|
||||||
|
$output->writeln("仅执行批量更新健康分步骤");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($forceRecalculate) {
|
||||||
|
$output->writeln("强制重新计算基础分");
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln("批处理大小: {$batchSize}");
|
||||||
|
|
||||||
|
// 记录命令开始执行的日志
|
||||||
|
Log::info('开始执行微信账号健康分计算命令', [
|
||||||
|
'accountId' => $accountId,
|
||||||
|
'onlyInit' => $onlyInit ? 'true' : 'false',
|
||||||
|
'onlyUpdate' => $onlyUpdate ? 'true' : 'false',
|
||||||
|
'onlyBatch' => $onlyBatch ? 'true' : 'false',
|
||||||
|
'batchSize' => $batchSize,
|
||||||
|
'forceRecalculate' => $forceRecalculate ? 'true' : 'false'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$startTime = time();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 实例化服务
|
||||||
|
$service = new WechatAccountHealthScoreService();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$errorMsg = "实例化WechatAccountHealthScoreService失败: " . $e->getMessage();
|
||||||
|
$output->writeln("<error>{$errorMsg}</error>");
|
||||||
|
Log::error($errorMsg);
|
||||||
|
return 1; // 返回非零状态码表示失败
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化统计数据
|
||||||
|
$initStats = ['success' => 0, 'failed' => 0, 'errors' => []];
|
||||||
|
$updateStats = ['total' => 0];
|
||||||
|
$batchStats = ['success' => 0, 'failed' => 0, 'errors' => []];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 步骤1: 初始化未计算基础分的账号
|
||||||
|
if (!$onlyUpdate && !$onlyBatch) {
|
||||||
|
$output->writeln("\n[步骤1] 初始化未计算基础分的账号...");
|
||||||
|
Log::info('[步骤1] 开始初始化未计算基础分的账号');
|
||||||
|
$initStats = $this->initUncalculatedAccounts($service, $output, $accountId, $batchSize);
|
||||||
|
$output->writeln("初始化完成:成功 {$initStats['success']} 条,失败 {$initStats['failed']} 条");
|
||||||
|
Log::info("初始化完成:成功 {$initStats['success']} 条,失败 {$initStats['failed']} 条");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤2: 更新评分记录(根据wechatId和alias不一致情况)
|
||||||
|
if (!$onlyInit && !$onlyBatch) {
|
||||||
|
$output->writeln("\n[步骤2] 更新评分记录(根据wechatId和alias不一致情况)...");
|
||||||
|
Log::info('[步骤2] 开始更新评分记录(根据wechatId和alias不一致情况)');
|
||||||
|
$updateStats = $this->updateScoreRecords($service, $output, $accountId, $batchSize);
|
||||||
|
$output->writeln("更新完成:处理了 {$updateStats['total']} 条记录");
|
||||||
|
Log::info("更新评分记录完成:处理了 {$updateStats['total']} 条记录");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤3: 批量更新健康分(只更新动态分,不重新计算基础分)
|
||||||
|
if (!$onlyInit && !$onlyUpdate) {
|
||||||
|
$output->writeln("\n[步骤3] 批量更新健康分(只更新动态分)...");
|
||||||
|
Log::info('[步骤3] 开始批量更新健康分(只更新动态分)');
|
||||||
|
$batchStats = $this->batchUpdateHealthScore($service, $output, $accountId, $batchSize, $forceRecalculate);
|
||||||
|
$output->writeln("批量更新完成:成功 {$batchStats['success']} 条,失败 {$batchStats['failed']} 条");
|
||||||
|
Log::info("批量更新健康分完成:成功 {$batchStats['success']} 条,失败 {$batchStats['failed']} 条");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计信息
|
||||||
|
$endTime = time();
|
||||||
|
$duration = $endTime - $startTime;
|
||||||
|
|
||||||
|
$output->writeln("\n==========================================");
|
||||||
|
$output->writeln("任务完成!");
|
||||||
|
$output->writeln("==========================================");
|
||||||
|
$output->writeln("总耗时: {$duration} 秒");
|
||||||
|
$output->writeln("初始化: 成功 {$initStats['success']} 条,失败 {$initStats['failed']} 条");
|
||||||
|
$output->writeln("更新评分记录: {$updateStats['total']} 条");
|
||||||
|
$output->writeln("批量更新: 成功 {$batchStats['success']} 条,失败 {$batchStats['failed']} 条");
|
||||||
|
|
||||||
|
// 记录命令执行完成的日志
|
||||||
|
Log::info("微信账号健康分计算命令执行完成,总耗时: {$duration} 秒," .
|
||||||
|
"初始化: 成功 {$initStats['success']} 条,失败 {$initStats['failed']} 条," .
|
||||||
|
"更新评分记录: {$updateStats['total']} 条," .
|
||||||
|
"批量更新: 成功 {$batchStats['success']} 条,失败 {$batchStats['failed']} 条");
|
||||||
|
|
||||||
|
if (!empty($initStats['errors'])) {
|
||||||
|
$output->writeln("\n初始化错误详情:");
|
||||||
|
Log::warning("初始化阶段出现 " . count($initStats['errors']) . " 个错误");
|
||||||
|
|
||||||
|
foreach (array_slice($initStats['errors'], 0, 10) as $error) {
|
||||||
|
$output->writeln(" 账号ID {$error['accountId']}: {$error['error']}");
|
||||||
|
Log::error("初始化错误 - 账号ID {$error['accountId']}: {$error['error']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($initStats['errors']) > 10) {
|
||||||
|
$output->writeln(" ... 还有 " . (count($initStats['errors']) - 10) . " 个错误");
|
||||||
|
Log::warning("初始化错误过多,只记录前10个,还有 " . (count($initStats['errors']) - 10) . " 个错误未显示");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($batchStats['errors'])) {
|
||||||
|
$output->writeln("\n批量更新错误详情:");
|
||||||
|
Log::warning("批量更新阶段出现 " . count($batchStats['errors']) . " 个错误");
|
||||||
|
|
||||||
|
foreach (array_slice($batchStats['errors'], 0, 10) as $error) {
|
||||||
|
$output->writeln(" 账号ID {$error['accountId']}: {$error['error']}");
|
||||||
|
Log::error("批量更新错误 - 账号ID {$error['accountId']}: {$error['error']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($batchStats['errors']) > 10) {
|
||||||
|
$output->writeln(" ... 还有 " . (count($batchStats['errors']) - 10) . " 个错误");
|
||||||
|
Log::warning("批量更新错误过多,只记录前10个,还有 " . (count($batchStats['errors']) - 10) . " 个错误未显示");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
// 数据库异常
|
||||||
|
$errorMsg = "数据库操作失败: " . $e->getMessage();
|
||||||
|
$output->writeln("\n<error>数据库错误: " . $errorMsg . "</error>");
|
||||||
|
$output->writeln($e->getTraceAsString());
|
||||||
|
|
||||||
|
// 记录数据库错误
|
||||||
|
Log::error("数据库错误: " . $errorMsg);
|
||||||
|
Log::error("错误堆栈: " . $e->getTraceAsString());
|
||||||
|
|
||||||
|
return 2; // 数据库错误状态码
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// 一般异常
|
||||||
|
$errorMsg = "命令执行失败: " . $e->getMessage();
|
||||||
|
$output->writeln("\n<error>错误: " . $errorMsg . "</error>");
|
||||||
|
$output->writeln($e->getTraceAsString());
|
||||||
|
|
||||||
|
// 记录严重错误
|
||||||
|
Log::error($errorMsg);
|
||||||
|
Log::error("错误堆栈: " . $e->getTraceAsString());
|
||||||
|
|
||||||
|
return 1; // 一般错误状态码
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// 其他所有错误
|
||||||
|
$errorMsg = "严重错误: " . $e->getMessage();
|
||||||
|
$output->writeln("\n<error>严重错误: " . $errorMsg . "</error>");
|
||||||
|
$output->writeln($e->getTraceAsString());
|
||||||
|
|
||||||
|
// 记录严重错误
|
||||||
|
Log::critical($errorMsg);
|
||||||
|
Log::critical("错误堆栈: " . $e->getTraceAsString());
|
||||||
|
|
||||||
|
return 3; // 严重错误状态码
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0; // 成功执行
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化未计算基础分的账号
|
||||||
|
*
|
||||||
|
* @param WechatAccountHealthScoreService $service 健康分服务实例
|
||||||
|
* @param Output $output 输出对象
|
||||||
|
* @return array 处理结果统计
|
||||||
|
* @throws \Exception 如果查询或处理过程中出现错误
|
||||||
|
*/
|
||||||
|
private function initUncalculatedAccounts($service, $output, $accountId = null, $batchSize = 50)
|
||||||
|
{
|
||||||
|
$stats = [
|
||||||
|
'total' => 0,
|
||||||
|
'success' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'errors' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取所有未计算基础分的账号
|
||||||
|
// 优化查询:使用索引字段,只查询必要的字段
|
||||||
|
$query = Db::table(self::TABLE_WECHAT_ACCOUNT)
|
||||||
|
->alias('a')
|
||||||
|
->leftJoin([self::TABLE_WECHAT_ACCOUNT_SCORE => 's'], 's.accountId = a.id')
|
||||||
|
->where('a.isDeleted', 0)
|
||||||
|
->where(function($query) {
|
||||||
|
$query->whereNull('s.id')
|
||||||
|
->whereOr('s.baseScoreCalculated', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果指定了账号ID,则只处理该账号
|
||||||
|
if ($accountId) {
|
||||||
|
$query->where('a.id', $accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$accounts = $query->field('a.id, a.wechatId') // 只查询必要的字段
|
||||||
|
->select();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error("查询未计算基础分的账号失败: " . $e->getMessage());
|
||||||
|
throw new \Exception("查询未计算基础分的账号失败: " . $e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats['total'] = count($accounts);
|
||||||
|
|
||||||
|
if ($stats['total'] == 0) {
|
||||||
|
$output->writeln("没有需要初始化的账号");
|
||||||
|
Log::info("没有需要初始化的账号");
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln("找到 {$stats['total']} 个需要初始化的账号");
|
||||||
|
Log::info("找到 {$stats['total']} 个需要初始化的账号");
|
||||||
|
|
||||||
|
// 优化批处理:使用传入的批处理大小
|
||||||
|
$batches = array_chunk($accounts, $batchSize);
|
||||||
|
$batchCount = count($batches);
|
||||||
|
|
||||||
|
Log::info("将分 {$batchCount} 批处理,每批 {$batchSize} 个账号");
|
||||||
|
|
||||||
|
foreach ($batches as $batchIndex => $batch) {
|
||||||
|
$batchStartTime = microtime(true);
|
||||||
|
$batchSuccessCount = 0;
|
||||||
|
$batchFailedCount = 0;
|
||||||
|
|
||||||
|
foreach ($batch as $account) {
|
||||||
|
try {
|
||||||
|
$service->calculateAndUpdate($account['id']);
|
||||||
|
$stats['success']++;
|
||||||
|
$batchSuccessCount++;
|
||||||
|
|
||||||
|
if ($stats['success'] % 20 == 0) { // 更频繁地显示进度
|
||||||
|
$output->write(".");
|
||||||
|
Log::debug("已成功初始化 {$stats['success']} 个账号");
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$stats['failed']++;
|
||||||
|
$batchFailedCount++;
|
||||||
|
$errorMsg = "初始化账号 {$account['id']} 失败: " . $e->getMessage();
|
||||||
|
Log::error($errorMsg);
|
||||||
|
$stats['errors'][] = [
|
||||||
|
'accountId' => $account['id'],
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$batchEndTime = microtime(true);
|
||||||
|
$batchDuration = round($batchEndTime - $batchStartTime, 2);
|
||||||
|
|
||||||
|
// 每批次完成后输出进度信息
|
||||||
|
$output->writeln(" 批次 " . ($batchIndex + 1) . "/{$batchCount} 完成,耗时 {$batchDuration} 秒,成功 {$batchSuccessCount},失败 {$batchFailedCount}");
|
||||||
|
Log::info("初始化批次 " . ($batchIndex + 1) . "/{$batchCount} 完成,耗时 {$batchDuration} 秒,成功 {$batchSuccessCount},失败 {$batchFailedCount}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新评分记录(根据wechatId和alias不一致情况)
|
||||||
|
*
|
||||||
|
* @param WechatAccountHealthScoreService $service 健康分服务实例
|
||||||
|
* @param Output $output 输出对象
|
||||||
|
* @return array 处理结果统计
|
||||||
|
* @throws \Exception 如果查询或处理过程中出现错误
|
||||||
|
*/
|
||||||
|
private function updateScoreRecords($service, $output, $accountId = null, $batchSize = 50)
|
||||||
|
{
|
||||||
|
$stats = ['total' => 0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 优化查询:合并两次查询为一次,减少数据库访问次数
|
||||||
|
$query = Db::table(self::TABLE_WECHAT_ACCOUNT)
|
||||||
|
->where('isDeleted', 0)
|
||||||
|
->where('wechatId', '<>', '')
|
||||||
|
->where('alias', '<>', '');
|
||||||
|
|
||||||
|
// 如果指定了账号ID,则只处理该账号
|
||||||
|
if ($accountId) {
|
||||||
|
$query->where('id', $accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$accounts = $query->field('id, wechatId, alias, IF(wechatId = alias, 0, 1) as isModifiedAlias')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
// 分类处理查询结果
|
||||||
|
$inconsistentAccounts = [];
|
||||||
|
$consistentAccounts = [];
|
||||||
|
|
||||||
|
foreach ($accounts as $account) {
|
||||||
|
if ($account['isModifiedAlias'] == 1) {
|
||||||
|
$inconsistentAccounts[] = $account;
|
||||||
|
} else {
|
||||||
|
$consistentAccounts[] = $account;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error("查询需要更新评分记录的账号失败: " . $e->getMessage());
|
||||||
|
throw new \Exception("查询需要更新评分记录的账号失败: " . $e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
$allAccounts = array_merge($inconsistentAccounts, $consistentAccounts);
|
||||||
|
$stats['total'] = count($allAccounts);
|
||||||
|
|
||||||
|
if ($stats['total'] == 0) {
|
||||||
|
$output->writeln("没有需要更新的账号");
|
||||||
|
Log::info("没有需要更新的评分记录");
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln("找到 {$stats['total']} 个需要更新的账号(不一致: " . count($inconsistentAccounts) . ",一致: " . count($consistentAccounts) . ")");
|
||||||
|
Log::info("找到 {$stats['total']} 个需要更新的账号(不一致: " . count($inconsistentAccounts) . ",一致: " . count($consistentAccounts) . ")");
|
||||||
|
|
||||||
|
$updatedCount = 0;
|
||||||
|
|
||||||
|
// 优化批处理:使用传入的批处理大小
|
||||||
|
$batches = array_chunk($allAccounts, $batchSize);
|
||||||
|
$batchCount = count($batches);
|
||||||
|
|
||||||
|
Log::info("将分 {$batchCount} 批更新评分记录,每批 {$batchSize} 个账号");
|
||||||
|
|
||||||
|
foreach ($batches as $batchIndex => $batch) {
|
||||||
|
$batchStartTime = microtime(true);
|
||||||
|
$batchUpdatedCount = 0;
|
||||||
|
|
||||||
|
foreach ($batch as $account) {
|
||||||
|
$isModifiedAlias = isset($account['isModifiedAlias']) ?
|
||||||
|
($account['isModifiedAlias'] == 1) :
|
||||||
|
in_array($account['id'], array_column($inconsistentAccounts, 'id'));
|
||||||
|
|
||||||
|
$this->updateScoreRecord($account['id'], $isModifiedAlias, $service);
|
||||||
|
$updatedCount++;
|
||||||
|
$batchUpdatedCount++;
|
||||||
|
|
||||||
|
if ($batchUpdatedCount % 20 == 0) {
|
||||||
|
$output->write(".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$batchEndTime = microtime(true);
|
||||||
|
$batchDuration = round($batchEndTime - $batchStartTime, 2);
|
||||||
|
|
||||||
|
// 每批次完成后输出进度信息
|
||||||
|
$output->writeln(" 批次 " . ($batchIndex + 1) . "/{$batchCount} 完成,耗时 {$batchDuration} 秒,更新 {$batchUpdatedCount} 条记录");
|
||||||
|
Log::info("更新评分记录批次 " . ($batchIndex + 1) . "/{$batchCount} 完成,耗时 {$batchDuration} 秒,更新 {$batchUpdatedCount} 条记录");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($updatedCount > 0 && $updatedCount % 100 == 0) {
|
||||||
|
$output->writeln("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更新健康分(只更新动态分)
|
||||||
|
*
|
||||||
|
* @param WechatAccountHealthScoreService $service 健康分服务实例
|
||||||
|
* @param Output $output 输出对象
|
||||||
|
* @return array 处理结果统计
|
||||||
|
* @throws \Exception 如果查询或处理过程中出现错误
|
||||||
|
*/
|
||||||
|
private function batchUpdateHealthScore($service, $output, $accountId = null, $batchSize = 50, $forceRecalculate = false)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// 获取所有已计算基础分的账号
|
||||||
|
// 优化查询:只查询必要的字段,使用索引字段
|
||||||
|
$query = Db::table(self::TABLE_WECHAT_ACCOUNT_SCORE)
|
||||||
|
->where('baseScoreCalculated', 1);
|
||||||
|
|
||||||
|
// 如果指定了账号ID,则只处理该账号
|
||||||
|
if ($accountId) {
|
||||||
|
$query->where('accountId', $accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$accountIds = $query->column('accountId');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error("查询需要批量更新健康分的账号失败: " . $e->getMessage());
|
||||||
|
throw new \Exception("查询需要批量更新健康分的账号失败: " . $e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = count($accountIds);
|
||||||
|
|
||||||
|
if ($total == 0) {
|
||||||
|
$output->writeln("没有需要更新的账号");
|
||||||
|
Log::info("没有需要批量更新健康分的账号");
|
||||||
|
return ['success' => 0, 'failed' => 0, 'errors' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln("找到 {$total} 个需要更新动态分的账号");
|
||||||
|
Log::info("找到 {$total} 个需要更新动态分的账号");
|
||||||
|
|
||||||
|
// 使用传入的批处理大小和强制重新计算标志
|
||||||
|
Log::info("使用批量大小 {$batchSize} 进行批量更新健康分,强制重新计算基础分: " . ($forceRecalculate ? 'true' : 'false'));
|
||||||
|
$stats = $service->batchCalculateAndUpdate($accountIds, $batchSize, $forceRecalculate);
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新评分记录
|
||||||
|
*
|
||||||
|
* @param int $accountId 账号ID
|
||||||
|
* @param bool $isModifiedAlias 是否已修改微信号
|
||||||
|
* @param WechatAccountHealthScoreService $service 评分服务
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 更新评分记录
|
||||||
|
*
|
||||||
|
* @param int $accountId 账号ID
|
||||||
|
* @param bool $isModifiedAlias 是否已修改微信号
|
||||||
|
* @param WechatAccountHealthScoreService $service 评分服务
|
||||||
|
* @return bool 是否成功更新
|
||||||
|
*/
|
||||||
|
private function updateScoreRecord($accountId, $isModifiedAlias, $service)
|
||||||
|
{
|
||||||
|
Log::debug("开始更新账号 {$accountId} 的评分记录,isModifiedAlias: " . ($isModifiedAlias ? 'true' : 'false'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账号数据 - 只查询必要的字段
|
||||||
|
$accountData = Db::table(self::TABLE_WECHAT_ACCOUNT)
|
||||||
|
->where('id', $accountId)
|
||||||
|
->field('id, wechatId, alias') // 只查询必要的字段
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if (empty($accountData)) {
|
||||||
|
Log::warning("账号 {$accountId} 不存在,跳过更新评分记录");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保评分记录存在 - 只查询必要的字段
|
||||||
|
$scoreRecord = Db::table(self::TABLE_WECHAT_ACCOUNT_SCORE)
|
||||||
|
->where('accountId', $accountId)
|
||||||
|
->field('accountId, baseScore, baseScoreCalculated, baseInfoScore, dynamicScore') // 只查询必要的字段
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if (empty($scoreRecord)) {
|
||||||
|
// 如果记录不存在,创建并计算基础分
|
||||||
|
Log::info("账号 {$accountId} 的评分记录不存在,创建并计算基础分");
|
||||||
|
$service->calculateAndUpdate($accountId);
|
||||||
|
$scoreRecord = Db::table(self::TABLE_WECHAT_ACCOUNT_SCORE)
|
||||||
|
->where('accountId', $accountId)
|
||||||
|
->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($scoreRecord)) {
|
||||||
|
Log::warning("账号 {$accountId} 的评分记录创建失败,跳过更新");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新isModifiedAlias字段
|
||||||
|
$updateData = [
|
||||||
|
'isModifiedAlias' => $isModifiedAlias ? 1 : 0,
|
||||||
|
'updateTime' => time()
|
||||||
|
];
|
||||||
|
|
||||||
|
// 如果基础分已计算,需要更新基础信息分和基础分
|
||||||
|
if ($scoreRecord['baseScoreCalculated']) {
|
||||||
|
$oldBaseInfoScore = $scoreRecord['baseInfoScore'] ?? 0;
|
||||||
|
$newBaseInfoScore = $isModifiedAlias ? 10 : 0; // 已修改微信号得10分
|
||||||
|
|
||||||
|
if ($oldBaseInfoScore != $newBaseInfoScore) {
|
||||||
|
$oldBaseScore = $scoreRecord['baseScore'] ?? 60;
|
||||||
|
$newBaseScore = $oldBaseScore - $oldBaseInfoScore + $newBaseInfoScore;
|
||||||
|
|
||||||
|
$updateData['baseInfoScore'] = $newBaseInfoScore;
|
||||||
|
$updateData['baseScore'] = $newBaseScore;
|
||||||
|
|
||||||
|
// 重新计算健康分
|
||||||
|
$dynamicScore = $scoreRecord['dynamicScore'] ?? 0;
|
||||||
|
$healthScore = $newBaseScore + $dynamicScore;
|
||||||
|
$healthScore = max(0, min(100, $healthScore));
|
||||||
|
$updateData['healthScore'] = $healthScore;
|
||||||
|
$updateData['maxAddFriendPerDay'] = (int)floor($healthScore * 0.2);
|
||||||
|
|
||||||
|
Log::info("账号 {$accountId} 的基础信息分从 {$oldBaseInfoScore} 更新为 {$newBaseInfoScore}," .
|
||||||
|
"基础分从 {$oldBaseScore} 更新为 {$newBaseScore},健康分更新为 {$healthScore}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 基础分未计算,只更新标记和基础信息分
|
||||||
|
$updateData['baseInfoScore'] = $isModifiedAlias ? 10 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = Db::table(self::TABLE_WECHAT_ACCOUNT_SCORE)
|
||||||
|
->where('accountId', $accountId)
|
||||||
|
->update($updateData);
|
||||||
|
|
||||||
|
Log::debug("账号 {$accountId} 的评分记录更新" . ($result !== false ? "成功" : "失败"));
|
||||||
|
|
||||||
|
return $result !== false;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error("更新账号 {$accountId} 的评分记录失败: " . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
168
Server/application/command/UpdateWechatAccountScoreCommand.php
Normal file
168
Server/application/command/UpdateWechatAccountScoreCommand.php
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\command;
|
||||||
|
|
||||||
|
use think\console\Command;
|
||||||
|
use think\console\Input;
|
||||||
|
use think\console\Output;
|
||||||
|
use think\Db;
|
||||||
|
use app\common\service\WechatAccountHealthScoreService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新微信账号评分记录
|
||||||
|
* 根据wechatId和alias是否不一致来更新isModifiedAlias字段(仅用于评分,不修复数据)
|
||||||
|
*/
|
||||||
|
class UpdateWechatAccountScoreCommand extends Command
|
||||||
|
{
|
||||||
|
protected function configure()
|
||||||
|
{
|
||||||
|
$this->setName('wechat:update-score')
|
||||||
|
->setDescription('更新微信账号评分记录,根据wechatId和alias不一致情况更新isModifiedAlias字段(仅用于评分)');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(Input $input, Output $output)
|
||||||
|
{
|
||||||
|
$output->writeln("开始更新微信账号评分记录...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 查找所有需要更新的账号
|
||||||
|
$output->writeln("步骤1: 查找需要更新的账号...");
|
||||||
|
|
||||||
|
// 查找wechatId和alias不一致的账号
|
||||||
|
$inconsistentAccounts = Db::table('s2_wechat_account')
|
||||||
|
->where('isDeleted', 0)
|
||||||
|
->where('wechatId', '<>', '')
|
||||||
|
->where('alias', '<>', '')
|
||||||
|
->whereRaw('wechatId != alias')
|
||||||
|
->field('id, wechatId, alias')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
// 查找wechatId和alias一致的账号
|
||||||
|
$consistentAccounts = Db::table('s2_wechat_account')
|
||||||
|
->where('isDeleted', 0)
|
||||||
|
->where('wechatId', '<>', '')
|
||||||
|
->where('alias', '<>', '')
|
||||||
|
->whereRaw('wechatId = alias')
|
||||||
|
->field('id, wechatId, alias')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$output->writeln("发现 " . count($inconsistentAccounts) . " 条不一致记录(已修改微信号)");
|
||||||
|
$output->writeln("发现 " . count($consistentAccounts) . " 条一致记录(未修改微信号)");
|
||||||
|
|
||||||
|
// 2. 更新评分记录表中的isModifiedAlias字段
|
||||||
|
$output->writeln("步骤2: 更新评分记录表...");
|
||||||
|
$updatedCount = 0;
|
||||||
|
$healthScoreService = new WechatAccountHealthScoreService();
|
||||||
|
|
||||||
|
// 更新不一致的记录
|
||||||
|
foreach ($inconsistentAccounts as $account) {
|
||||||
|
$this->updateScoreRecord($account['id'], true, $healthScoreService);
|
||||||
|
$updatedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新一致的记录
|
||||||
|
foreach ($consistentAccounts as $account) {
|
||||||
|
$this->updateScoreRecord($account['id'], false, $healthScoreService);
|
||||||
|
$updatedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln("已更新 " . $updatedCount . " 条评分记录");
|
||||||
|
|
||||||
|
// 3. 重新计算健康分(只更新基础信息分,不重新计算基础分)
|
||||||
|
$output->writeln("步骤3: 重新计算健康分...");
|
||||||
|
$allAccountIds = array_merge(
|
||||||
|
array_column($inconsistentAccounts, 'id'),
|
||||||
|
array_column($consistentAccounts, 'id')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!empty($allAccountIds)) {
|
||||||
|
$stats = $healthScoreService->batchCalculateAndUpdate($allAccountIds, 100, false);
|
||||||
|
$output->writeln("健康分计算完成:成功 " . $stats['success'] . " 条,失败 " . $stats['failed'] . " 条");
|
||||||
|
|
||||||
|
if (!empty($stats['errors'])) {
|
||||||
|
$output->writeln("错误详情:");
|
||||||
|
foreach ($stats['errors'] as $error) {
|
||||||
|
$output->writeln(" 账号ID {$error['accountId']}: {$error['error']}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln("任务完成!");
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$output->writeln("错误: " . $e->getMessage());
|
||||||
|
$output->writeln($e->getTraceAsString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新评分记录
|
||||||
|
*
|
||||||
|
* @param int $accountId 账号ID
|
||||||
|
* @param bool $isModifiedAlias 是否已修改微信号
|
||||||
|
* @param WechatAccountHealthScoreService $service 评分服务
|
||||||
|
*/
|
||||||
|
private function updateScoreRecord($accountId, $isModifiedAlias, $service)
|
||||||
|
{
|
||||||
|
// 获取或创建评分记录
|
||||||
|
$accountData = Db::table('s2_wechat_account')
|
||||||
|
->where('id', $accountId)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if (empty($accountData)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保评分记录存在
|
||||||
|
$scoreRecord = Db::table('s2_wechat_account_score')
|
||||||
|
->where('accountId', $accountId)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if (empty($scoreRecord)) {
|
||||||
|
// 如果记录不存在,创建并计算基础分
|
||||||
|
$service->calculateAndUpdate($accountId);
|
||||||
|
$scoreRecord = Db::table('s2_wechat_account_score')
|
||||||
|
->where('accountId', $accountId)
|
||||||
|
->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($scoreRecord)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新isModifiedAlias字段
|
||||||
|
$updateData = [
|
||||||
|
'isModifiedAlias' => $isModifiedAlias ? 1 : 0,
|
||||||
|
'updateTime' => time()
|
||||||
|
];
|
||||||
|
|
||||||
|
// 如果基础分已计算,需要更新基础信息分和基础分
|
||||||
|
if ($scoreRecord['baseScoreCalculated']) {
|
||||||
|
$oldBaseInfoScore = $scoreRecord['baseInfoScore'] ?? 0;
|
||||||
|
$newBaseInfoScore = $isModifiedAlias ? 10 : 0; // 已修改微信号得10分
|
||||||
|
|
||||||
|
if ($oldBaseInfoScore != $newBaseInfoScore) {
|
||||||
|
$oldBaseScore = $scoreRecord['baseScore'] ?? 60;
|
||||||
|
$newBaseScore = $oldBaseScore - $oldBaseInfoScore + $newBaseInfoScore;
|
||||||
|
|
||||||
|
$updateData['baseInfoScore'] = $newBaseInfoScore;
|
||||||
|
$updateData['baseScore'] = $newBaseScore;
|
||||||
|
|
||||||
|
// 重新计算健康分
|
||||||
|
$dynamicScore = $scoreRecord['dynamicScore'] ?? 0;
|
||||||
|
$healthScore = $newBaseScore + $dynamicScore;
|
||||||
|
$healthScore = max(0, min(100, $healthScore));
|
||||||
|
$updateData['healthScore'] = $healthScore;
|
||||||
|
$updateData['maxAddFriendPerDay'] = (int)floor($healthScore * 0.2);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 基础分未计算,只更新标记和基础信息分
|
||||||
|
$updateData['baseInfoScore'] = $isModifiedAlias ? 10 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::table('s2_wechat_account_score')
|
||||||
|
->where('accountId', $accountId)
|
||||||
|
->update($updateData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -75,7 +75,18 @@ if (!function_exists('requestCurl')) {
|
|||||||
if (!function_exists('dataBuild')) {
|
if (!function_exists('dataBuild')) {
|
||||||
function dataBuild($array)
|
function dataBuild($array)
|
||||||
{
|
{
|
||||||
return is_array($array) ? http_build_query($array) : $array;
|
if (!is_array($array)) {
|
||||||
|
return $array;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理嵌套数组
|
||||||
|
foreach ($array as $key => $value) {
|
||||||
|
if (is_array($value)) {
|
||||||
|
$array[$key] = json_encode($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return http_build_query($array);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,14 +561,15 @@ if (!function_exists('exit_data')) {
|
|||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!function_exists('dump')) {
|
||||||
/**
|
/**
|
||||||
* 调试打印变量但不终止程序
|
* 调试打印变量但不终止程序
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
function dump()
|
function dump()
|
||||||
{
|
{
|
||||||
call_user_func_array(['app\\common\\helper\\Debug', 'dump'], func_get_args());
|
call_user_func_array(['app\\common\\helper\\Debug', 'dump'], func_get_args());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!function_exists('artificialAllotWechatFriend')) {
|
if (!function_exists('artificialAllotWechatFriend')) {
|
||||||
|
|||||||
44
Server/application/common/model/WechatAccountScore.php
Normal file
44
Server/application/common/model/WechatAccountScore.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\common\model;
|
||||||
|
|
||||||
|
use think\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信账号评分记录模型类
|
||||||
|
*/
|
||||||
|
class WechatAccountScore extends Model
|
||||||
|
{
|
||||||
|
// 设置表名
|
||||||
|
protected $name = 'wechat_account_score';
|
||||||
|
protected $table = 's2_wechat_account_score';
|
||||||
|
|
||||||
|
// 自动写入时间戳
|
||||||
|
protected $autoWriteTimestamp = false;
|
||||||
|
|
||||||
|
// 定义字段类型
|
||||||
|
protected $type = [
|
||||||
|
'accountId' => 'integer',
|
||||||
|
'baseScore' => 'integer',
|
||||||
|
'baseScoreCalculated' => 'integer',
|
||||||
|
'baseInfoScore' => 'integer',
|
||||||
|
'friendCountScore' => 'integer',
|
||||||
|
'friendCount' => 'integer',
|
||||||
|
'dynamicScore' => 'integer',
|
||||||
|
'frequentCount' => 'integer',
|
||||||
|
'frequentPenalty' => 'integer',
|
||||||
|
'consecutiveNoFrequentDays' => 'integer',
|
||||||
|
'noFrequentBonus' => 'integer',
|
||||||
|
'banPenalty' => 'integer',
|
||||||
|
'healthScore' => 'integer',
|
||||||
|
'maxAddFriendPerDay' => 'integer',
|
||||||
|
'isModifiedAlias' => 'integer',
|
||||||
|
'isBanned' => 'integer',
|
||||||
|
'lastFrequentTime' => 'integer',
|
||||||
|
'lastNoFrequentTime' => 'integer',
|
||||||
|
'baseScoreCalcTime' => 'integer',
|
||||||
|
'createTime' => 'integer',
|
||||||
|
'updateTime' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -601,12 +601,14 @@ class ContentLibraryController extends Controller
|
|||||||
$item['urls'] = json_decode($item['urls'] ?: '[]', true);
|
$item['urls'] = json_decode($item['urls'] ?: '[]', true);
|
||||||
|
|
||||||
// 格式化时间
|
// 格式化时间
|
||||||
if ($item['createMomentTime']) {
|
if (!empty($item['createMomentTime']) && is_numeric($item['createMomentTime'])) {
|
||||||
$item['time'] = date('Y-m-d H:i:s', $item['createMomentTime']);
|
$item['time'] = date('Y-m-d H:i:s', (int)$item['createMomentTime']);
|
||||||
} elseif ($item['createMessageTime']) {
|
} elseif (!empty($item['createMessageTime']) && is_numeric($item['createMessageTime'])) {
|
||||||
$item['time'] = date('Y-m-d H:i:s', $item['createMessageTime']);
|
$item['time'] = date('Y-m-d H:i:s', (int)$item['createMessageTime']);
|
||||||
|
} elseif (!empty($item['createTime']) && is_numeric($item['createTime'])) {
|
||||||
|
$item['time'] = date('Y-m-d H:i:s', (int)$item['createTime']);
|
||||||
} else {
|
} else {
|
||||||
$item['time'] = date('Y-m-d H:i:s', $item['createTime']);
|
$item['time'] = date('Y-m-d H:i:s'); // 如果没有有效的时间戳,使用当前时间
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置发送者信息
|
// 设置发送者信息
|
||||||
@@ -1068,7 +1070,7 @@ class ContentLibraryController extends Controller
|
|||||||
->select()->toArray();
|
->select()->toArray();
|
||||||
|
|
||||||
if (empty($libraries)) {
|
if (empty($libraries)) {
|
||||||
return json(['code' => 200, 'msg' => '没有可用的内容库配置']);
|
return json_encode(['code' => 200, 'msg' => '没有可用的内容库配置'],256);
|
||||||
}
|
}
|
||||||
|
|
||||||
$successCount = 0;
|
$successCount = 0;
|
||||||
@@ -1157,7 +1159,7 @@ class ContentLibraryController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 返回采集结果
|
// 返回采集结果
|
||||||
return json([
|
return json_encode([
|
||||||
'code' => 200,
|
'code' => 200,
|
||||||
'msg' => '采集任务执行完成',
|
'msg' => '采集任务执行完成',
|
||||||
'data' => [
|
'data' => [
|
||||||
@@ -1167,7 +1169,7 @@ class ContentLibraryController extends Controller
|
|||||||
'skipped' => $totalLibraries - $successCount - $failCount,
|
'skipped' => $totalLibraries - $successCount - $failCount,
|
||||||
'results' => $results
|
'results' => $results
|
||||||
]
|
]
|
||||||
]);
|
],256);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1204,7 +1206,7 @@ class ContentLibraryController extends Controller
|
|||||||
->whereIn('id', $friendIds)
|
->whereIn('id', $friendIds)
|
||||||
->where('isDeleted', 0)
|
->where('isDeleted', 0)
|
||||||
->select();
|
->select();
|
||||||
|
|
||||||
if (empty($friends)) {
|
if (empty($friends)) {
|
||||||
return [
|
return [
|
||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
|
|||||||
@@ -2816,6 +2816,8 @@ class WorkbenchController extends Controller
|
|||||||
$limit = $this->request->param('limit', 10);
|
$limit = $this->request->param('limit', 10);
|
||||||
$workbenchId = $this->request->param('workbenchId', 0);
|
$workbenchId = $this->request->param('workbenchId', 0);
|
||||||
$keyword = $this->request->param('keyword', '');
|
$keyword = $this->request->param('keyword', '');
|
||||||
|
$pushType = $this->request->param('pushType', ''); // 推送类型筛选:''=全部, 'friend'=好友消息, 'group'=群消息, 'announcement'=群公告
|
||||||
|
$status = $this->request->param('status', ''); // 状态筛选:''=全部, 'success'=已完成, 'progress'=进行中, 'failed'=失败
|
||||||
$userId = $this->request->userInfo['id'];
|
$userId = $this->request->userInfo['id'];
|
||||||
|
|
||||||
// 构建工作台查询条件
|
// 构建工作台查询条件
|
||||||
@@ -2840,10 +2842,11 @@ class WorkbenchController extends Controller
|
|||||||
$workbenchWhere[] = ['w.id', '=', $workbenchId];
|
$workbenchWhere[] = ['w.id', '=', $workbenchId];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按内容ID、工作台ID和时间分组,统计每次推送
|
// 1. 先查询所有已执行的推送记录(按推送时间分组)
|
||||||
$query = Db::name('workbench_group_push_item')
|
$pushHistoryQuery = Db::name('workbench_group_push_item')
|
||||||
->alias('wgpi')
|
->alias('wgpi')
|
||||||
->join('workbench w', 'w.id = wgpi.workbenchId', 'left')
|
->join('workbench w', 'w.id = wgpi.workbenchId', 'left')
|
||||||
|
->join('workbench_group_push wgp', 'wgp.workbenchId = wgpi.workbenchId', 'left')
|
||||||
->join('content_item ci', 'ci.id = wgpi.contentId', 'left')
|
->join('content_item ci', 'ci.id = wgpi.contentId', 'left')
|
||||||
->join('content_library cl', 'cl.id = ci.libraryId', 'left')
|
->join('content_library cl', 'cl.id = ci.libraryId', 'left')
|
||||||
->where($workbenchWhere)
|
->where($workbenchWhere)
|
||||||
@@ -2853,52 +2856,57 @@ class WorkbenchController extends Controller
|
|||||||
'wgpi.contentId',
|
'wgpi.contentId',
|
||||||
'FROM_UNIXTIME(wgpi.createTime, "%Y-%m-%d %H:00:00") as pushTime',
|
'FROM_UNIXTIME(wgpi.createTime, "%Y-%m-%d %H:00:00") as pushTime',
|
||||||
'wgpi.targetType',
|
'wgpi.targetType',
|
||||||
|
'wgp.groupPushSubType',
|
||||||
'MIN(wgpi.createTime) as createTime',
|
'MIN(wgpi.createTime) as createTime',
|
||||||
'COUNT(DISTINCT wgpi.id) as totalCount',
|
'COUNT(DISTINCT wgpi.id) as totalCount',
|
||||||
'cl.name as contentLibraryName'
|
'cl.name as contentLibraryName'
|
||||||
])
|
])
|
||||||
->group('wgpi.workbenchId, wgpi.contentId, pushTime, wgpi.targetType');
|
->group('wgpi.workbenchId, wgpi.contentId, pushTime, wgpi.targetType, wgp.groupPushSubType');
|
||||||
|
|
||||||
if (!empty($keyword)) {
|
if (!empty($keyword)) {
|
||||||
$query->where('w.name|cl.name|ci.content', 'like', '%' . $keyword . '%');
|
$pushHistoryQuery->where('w.name|cl.name|ci.content', 'like', '%' . $keyword . '%');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取分页数据
|
$pushHistoryList = $pushHistoryQuery->order('createTime', 'desc')->select();
|
||||||
$list = $query->order('createTime', 'desc')
|
|
||||||
->page($page, $limit)
|
// 2. 查询所有任务(包括未执行的)
|
||||||
->select();
|
$allTasksQuery = Db::name('workbench')
|
||||||
|
->alias('w')
|
||||||
// 对于有 group by 的查询,统计总数需要重新查询
|
->join('workbench_group_push wgp', 'wgp.workbenchId = w.id', 'left')
|
||||||
$totalQuery = Db::name('workbench_group_push_item')
|
->where($workbenchWhere)
|
||||||
->alias('wgpi')
|
|
||||||
->join('workbench w', 'w.id = wgpi.workbenchId', 'left')
|
|
||||||
->join('content_item ci', 'ci.id = wgpi.contentId', 'left')
|
|
||||||
->join('content_library cl', 'cl.id = ci.libraryId', 'left')
|
|
||||||
->where($workbenchWhere);
|
|
||||||
|
|
||||||
if (!empty($keyword)) {
|
|
||||||
$totalQuery->where('w.name|cl.name|ci.content', 'like', '%' . $keyword . '%');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统计分组后的记录数(使用子查询)
|
|
||||||
$subQuery = $totalQuery
|
|
||||||
->field([
|
->field([
|
||||||
'wgpi.workbenchId',
|
'w.id as workbenchId',
|
||||||
'wgpi.contentId',
|
'w.name as workbenchName',
|
||||||
'FROM_UNIXTIME(wgpi.createTime, "%Y-%m-%d %H:00:00") as pushTime',
|
'w.createTime',
|
||||||
'wgpi.targetType'
|
'wgp.targetType',
|
||||||
])
|
'wgp.groupPushSubType',
|
||||||
->group('wgpi.workbenchId, wgpi.contentId, pushTime, wgpi.targetType')
|
'wgp.groups',
|
||||||
->buildSql();
|
'wgp.friends',
|
||||||
|
'wgp.trafficPools'
|
||||||
$total = Db::table('(' . $subQuery . ') as temp')->count();
|
]);
|
||||||
|
|
||||||
// 处理每条记录
|
if (!empty($keyword)) {
|
||||||
foreach ($list as &$item) {
|
$allTasksQuery->where('w.name', 'like', '%' . $keyword . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$allTasks = $allTasksQuery->select();
|
||||||
|
|
||||||
|
// 3. 合并数据:已执行的推送记录 + 未执行的任务
|
||||||
|
$resultList = [];
|
||||||
|
$executedWorkbenchIds = [];
|
||||||
|
|
||||||
|
// 处理已执行的推送记录
|
||||||
|
foreach ($pushHistoryList as $item) {
|
||||||
$itemWorkbenchId = $item['workbenchId'];
|
$itemWorkbenchId = $item['workbenchId'];
|
||||||
$contentId = $item['contentId'];
|
$contentId = $item['contentId'];
|
||||||
$pushTime = $item['pushTime'];
|
$pushTime = $item['pushTime'];
|
||||||
$targetType = intval($item['targetType']);
|
$targetType = intval($item['targetType']);
|
||||||
|
$groupPushSubType = isset($item['groupPushSubType']) ? intval($item['groupPushSubType']) : 1;
|
||||||
|
|
||||||
|
// 标记该工作台已有执行记录
|
||||||
|
if (!in_array($itemWorkbenchId, $executedWorkbenchIds)) {
|
||||||
|
$executedWorkbenchIds[] = $itemWorkbenchId;
|
||||||
|
}
|
||||||
|
|
||||||
// 将时间字符串转换为时间戳范围(小时级别)
|
// 将时间字符串转换为时间戳范围(小时级别)
|
||||||
$pushTimeStart = strtotime($pushTime);
|
$pushTimeStart = strtotime($pushTime);
|
||||||
@@ -2937,23 +2945,149 @@ class WorkbenchController extends Controller
|
|||||||
$failCount = 0; // 简化处理,实际需要从发送状态获取
|
$failCount = 0; // 简化处理,实际需要从发送状态获取
|
||||||
|
|
||||||
// 状态判断
|
// 状态判断
|
||||||
$status = $successCount > 0 ? 'success' : 'failed';
|
$itemStatus = $successCount > 0 ? 'success' : 'failed';
|
||||||
if ($failCount > 0 && $successCount > 0) {
|
if ($failCount > 0 && $successCount > 0) {
|
||||||
$status = 'partial';
|
$itemStatus = 'partial';
|
||||||
}
|
}
|
||||||
|
|
||||||
$item['pushType'] = $targetType == 1 ? '群推送' : '好友推送';
|
// 推送类型判断
|
||||||
$item['pushTypeCode'] = $targetType;
|
$pushTypeText = '';
|
||||||
$item['targetCount'] = $targetCount;
|
$pushTypeCode = '';
|
||||||
$item['successCount'] = $successCount;
|
if ($targetType == 1) {
|
||||||
$item['failCount'] = $failCount;
|
// 群推送
|
||||||
$item['status'] = $status;
|
if ($groupPushSubType == 2) {
|
||||||
$item['statusText'] = $status == 'success' ? '成功' : ($status == 'partial' ? '部分成功' : '失败');
|
$pushTypeText = '群公告';
|
||||||
$item['createTime'] = date('Y-m-d H:i:s', $item['createTime']);
|
$pushTypeCode = 'announcement';
|
||||||
// 任务名称(工作台名称)
|
} else {
|
||||||
$item['taskName'] = $item['workbenchName'] ?? '';
|
$pushTypeText = '群消息';
|
||||||
|
$pushTypeCode = 'group';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 好友推送
|
||||||
|
$pushTypeText = '好友消息';
|
||||||
|
$pushTypeCode = 'friend';
|
||||||
|
}
|
||||||
|
|
||||||
|
$resultList[] = [
|
||||||
|
'workbenchId' => $itemWorkbenchId,
|
||||||
|
'taskName' => $item['workbenchName'] ?? '',
|
||||||
|
'pushType' => $pushTypeText,
|
||||||
|
'pushTypeCode' => $pushTypeCode,
|
||||||
|
'targetCount' => $targetCount,
|
||||||
|
'successCount' => $successCount,
|
||||||
|
'failCount' => $failCount,
|
||||||
|
'status' => $itemStatus,
|
||||||
|
'statusText' => $this->getStatusText($itemStatus),
|
||||||
|
'createTime' => date('Y-m-d H:i:s', $item['createTime']),
|
||||||
|
'contentLibraryName' => $item['contentLibraryName'] ?? ''
|
||||||
|
];
|
||||||
}
|
}
|
||||||
unset($item);
|
|
||||||
|
// 处理未执行的任务
|
||||||
|
foreach ($allTasks as $task) {
|
||||||
|
$taskWorkbenchId = $task['workbenchId'];
|
||||||
|
|
||||||
|
// 如果该任务已有执行记录,跳过(避免重复)
|
||||||
|
if (in_array($taskWorkbenchId, $executedWorkbenchIds)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetType = isset($task['targetType']) ? intval($task['targetType']) : 1;
|
||||||
|
$groupPushSubType = isset($task['groupPushSubType']) ? intval($task['groupPushSubType']) : 1;
|
||||||
|
|
||||||
|
// 计算目标数量(从配置中获取)
|
||||||
|
$targetCount = 0;
|
||||||
|
if ($targetType == 1) {
|
||||||
|
// 群推送:统计配置的群数量
|
||||||
|
$groups = json_decode($task['groups'] ?? '[]', true);
|
||||||
|
$targetCount = is_array($groups) ? count($groups) : 0;
|
||||||
|
} else {
|
||||||
|
// 好友推送:统计配置的好友数量或流量池数量
|
||||||
|
$friends = json_decode($task['friends'] ?? '[]', true);
|
||||||
|
$trafficPools = json_decode($task['trafficPools'] ?? '[]', true);
|
||||||
|
$friendCount = is_array($friends) ? count($friends) : 0;
|
||||||
|
$poolCount = is_array($trafficPools) ? count($trafficPools) : 0;
|
||||||
|
// 如果配置了流量池,目标数量暂时显示为流量池数量(实际数量需要从流量池中统计)
|
||||||
|
$targetCount = $friendCount > 0 ? $friendCount : $poolCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送类型判断
|
||||||
|
$pushTypeText = '';
|
||||||
|
$pushTypeCode = '';
|
||||||
|
if ($targetType == 1) {
|
||||||
|
// 群推送
|
||||||
|
if ($groupPushSubType == 2) {
|
||||||
|
$pushTypeText = '群公告';
|
||||||
|
$pushTypeCode = 'announcement';
|
||||||
|
} else {
|
||||||
|
$pushTypeText = '群消息';
|
||||||
|
$pushTypeCode = 'group';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 好友推送
|
||||||
|
$pushTypeText = '好友消息';
|
||||||
|
$pushTypeCode = 'friend';
|
||||||
|
}
|
||||||
|
|
||||||
|
$resultList[] = [
|
||||||
|
'workbenchId' => $taskWorkbenchId,
|
||||||
|
'taskName' => $task['workbenchName'] ?? '',
|
||||||
|
'pushType' => $pushTypeText,
|
||||||
|
'pushTypeCode' => $pushTypeCode,
|
||||||
|
'targetCount' => $targetCount,
|
||||||
|
'successCount' => 0,
|
||||||
|
'failCount' => 0,
|
||||||
|
'status' => 'pending',
|
||||||
|
'statusText' => '进行中',
|
||||||
|
'createTime' => date('Y-m-d H:i:s', $task['createTime']),
|
||||||
|
'contentLibraryName' => ''
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用筛选条件
|
||||||
|
$filteredList = [];
|
||||||
|
foreach ($resultList as $item) {
|
||||||
|
// 推送类型筛选
|
||||||
|
if (!empty($pushType)) {
|
||||||
|
if ($pushType === 'friend' && $item['pushTypeCode'] !== 'friend') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($pushType === 'group' && $item['pushTypeCode'] !== 'group') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($pushType === 'announcement' && $item['pushTypeCode'] !== 'announcement') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态筛选
|
||||||
|
if (!empty($status)) {
|
||||||
|
if ($status === 'success' && $item['status'] !== 'success') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($status === 'progress') {
|
||||||
|
// 进行中:包括 partial 和 pending
|
||||||
|
if ($item['status'] !== 'partial' && $item['status'] !== 'pending') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($status === 'failed' && $item['status'] !== 'failed') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$filteredList[] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按创建时间倒序排序
|
||||||
|
usort($filteredList, function($a, $b) {
|
||||||
|
return strtotime($b['createTime']) - strtotime($a['createTime']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分页处理
|
||||||
|
$total = count($filteredList);
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
$list = array_slice($filteredList, $offset, $limit);
|
||||||
|
|
||||||
return json([
|
return json([
|
||||||
'code' => 200,
|
'code' => 200,
|
||||||
@@ -2967,5 +3101,21 @@ class WorkbenchController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取状态文本
|
||||||
|
* @param string $status 状态码
|
||||||
|
* @return string 状态文本
|
||||||
|
*/
|
||||||
|
private function getStatusText($status)
|
||||||
|
{
|
||||||
|
$statusMap = [
|
||||||
|
'success' => '已完成',
|
||||||
|
'partial' => '进行中',
|
||||||
|
'pending' => '进行中',
|
||||||
|
'failed' => '失败'
|
||||||
|
];
|
||||||
|
return $statusMap[$status] ?? '未知';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
49
Server/application/http/middleware/Jwt.php
Normal file
49
Server/application/http/middleware/Jwt.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
namespace app\http\middleware;
|
||||||
|
|
||||||
|
use app\common\util\JwtUtil;
|
||||||
|
use think\facade\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT认证中间件
|
||||||
|
*/
|
||||||
|
class Jwt
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 处理请求
|
||||||
|
* @param \think\Request $request
|
||||||
|
* @param \Closure $next
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function handle($request, \Closure $next)
|
||||||
|
{
|
||||||
|
// 获取Token
|
||||||
|
$token = JwtUtil::getRequestToken();
|
||||||
|
|
||||||
|
// 验证Token
|
||||||
|
if (!$token) {
|
||||||
|
return json([
|
||||||
|
'code' => 401,
|
||||||
|
'msg' => '未授权访问,缺少有效的身份凭证',
|
||||||
|
'data' => null
|
||||||
|
])->header(['Content-Type' => 'application/json; charset=utf-8']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = JwtUtil::verifyToken($token);
|
||||||
|
if (!$payload) {
|
||||||
|
return json([
|
||||||
|
'code' => 401,
|
||||||
|
'msg' => '授权已过期或无效',
|
||||||
|
'data' => null
|
||||||
|
])->header(['Content-Type' => 'application/json; charset=utf-8']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将用户信息附加到请求中
|
||||||
|
$request->userInfo = $payload;
|
||||||
|
|
||||||
|
// 写入日志
|
||||||
|
Log::info('JWT认证通过', ['user_id' => $payload['id'] ?? 0, 'username' => $payload['username'] ?? '']);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +1,72 @@
|
|||||||
{
|
{
|
||||||
"name": "topthink/think",
|
"name": "topthink/think",
|
||||||
"description": "the new thinkphp framework",
|
"description": "the new thinkphp framework",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"framework",
|
"framework",
|
||||||
"thinkphp",
|
"thinkphp",
|
||||||
"ORM"
|
"ORM"
|
||||||
],
|
],
|
||||||
"homepage": "http://thinkphp.cn/",
|
"homepage": "http://thinkphp.cn/",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"authors": [
|
"authors": [
|
||||||
{
|
{
|
||||||
"name": "liu21st",
|
"name": "liu21st",
|
||||||
"email": "liu21st@gmail.com"
|
"email": "liu21st@gmail.com"
|
||||||
}
|
|
||||||
],
|
|
||||||
"require": {
|
|
||||||
"php": ">=5.4.0",
|
|
||||||
"topthink/framework": "5.0.*"
|
|
||||||
},
|
},
|
||||||
"autoload": {
|
{
|
||||||
"psr-4": {
|
"name": "yunwuxin",
|
||||||
"app\\": "application"
|
"email": "448901948@qq.com"
|
||||||
}
|
|
||||||
},
|
|
||||||
"extra": {
|
|
||||||
"think-path": "thinkphp"
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"preferred-install": "dist"
|
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"require": {
|
||||||
|
"php": ">=5.6.0",
|
||||||
|
"topthink/framework": "5.1.41",
|
||||||
|
"topthink/think-installer": "2.*",
|
||||||
|
"topthink/think-captcha": "^2.0",
|
||||||
|
"topthink/think-helper": "^3.0",
|
||||||
|
"topthink/think-image": "^1.0",
|
||||||
|
"topthink/think-queue": "^2.0",
|
||||||
|
"topthink/think-worker": "^2.0",
|
||||||
|
"textalk/websocket": "^1.5",
|
||||||
|
"aliyuncs/oss-sdk-php": "^2.6",
|
||||||
|
"monolog/monolog": "^1.27",
|
||||||
|
"guzzlehttp/guzzle": "^6.5",
|
||||||
|
"overtrue/wechat": "~4.6",
|
||||||
|
"endroid/qr-code": "^3.9",
|
||||||
|
"phpoffice/phpspreadsheet": "^1.29",
|
||||||
|
"workerman/workerman": "^3.5",
|
||||||
|
"workerman/gateway-worker": "^3.0",
|
||||||
|
"hashids/hashids": "^2.0",
|
||||||
|
"khanamiryan/qrcode-detector-decoder": "^1.0",
|
||||||
|
"lizhichao/word": "^2.0",
|
||||||
|
"adbario/php-dot-notation": "^2.2"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"symfony/var-dumper": "^3.4|^4.4",
|
||||||
|
"topthink/think-migration": "^2.0",
|
||||||
|
"phpunit/phpunit": "^5.0|^6.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"app\\": "application",
|
||||||
|
"Eison\\": "extend/Eison"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"application/common.php"
|
||||||
|
],
|
||||||
|
"classmap": []
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"think-path": "thinkphp"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"preferred-install": "dist",
|
||||||
|
"allow-plugins": {
|
||||||
|
"topthink/think-installer": true,
|
||||||
|
"easywechat-composer/easywechat-composer": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimum-stability": "dev",
|
||||||
|
"prefer-stable": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ return [
|
|||||||
|
|
||||||
// 全局中间件
|
// 全局中间件
|
||||||
'alias' => [
|
'alias' => [
|
||||||
'cors' => 'app\\common\\middleware\\AllowCrossDomain'
|
'cors' => 'app\\common\\middleware\\AllowCrossDomain',
|
||||||
|
'jwt' => 'app\\http\\middleware\\Jwt'
|
||||||
],
|
],
|
||||||
|
|
||||||
// 应用中间件
|
// 应用中间件
|
||||||
|
|||||||
@@ -81,6 +81,8 @@
|
|||||||
# 消息提醒
|
# 消息提醒
|
||||||
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think kf:notice >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/kf_notice.log 2>&1
|
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think kf:notice >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/kf_notice.log 2>&1
|
||||||
|
|
||||||
|
# 客服评分
|
||||||
|
0 2 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechat:calculate-score >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/calculate_score.log 2>&1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -107,4 +109,10 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
crontab -l
|
crontab -l
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
- 本地: php think worker:server
|
||||||
|
- 线上: php think worker:server -d (自带守护进程,无需搭配Supervisor 之类的工具)
|
||||||
|
- php think worker:server stop php think worker:server status
|
||||||
```
|
```
|
||||||
125
Server/extend/Eison/Utils/Helper/ArrHelper.php
Normal file
125
Server/extend/Eison/Utils/Helper/ArrHelper.php
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Eison\Utils\Helper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数组辅助类
|
||||||
|
*/
|
||||||
|
class ArrHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 从数组中提取指定的键值
|
||||||
|
*
|
||||||
|
* @param string $keys 键名,多个用逗号分隔,支持键名映射(如:account=userName)
|
||||||
|
* @param array $array 源数组
|
||||||
|
* @param mixed $default 默认值,如果键不存在时返回此值
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function getValue(string $keys, array $array, $default = null): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
$keyList = explode(',', $keys);
|
||||||
|
|
||||||
|
foreach ($keyList as $key) {
|
||||||
|
$key = trim($key);
|
||||||
|
|
||||||
|
// 支持键名映射:account=userName
|
||||||
|
if (strpos($key, '=') !== false) {
|
||||||
|
list($sourceKey, $targetKey) = explode('=', $key, 2);
|
||||||
|
$sourceKey = trim($sourceKey);
|
||||||
|
$targetKey = trim($targetKey);
|
||||||
|
|
||||||
|
// 如果源键存在,使用源键的值;否则使用目标键的值;都不存在则使用默认值
|
||||||
|
if (isset($array[$sourceKey])) {
|
||||||
|
$result[$targetKey] = $array[$sourceKey];
|
||||||
|
} elseif (isset($array[$targetKey])) {
|
||||||
|
$result[$targetKey] = $array[$targetKey];
|
||||||
|
} else {
|
||||||
|
// 如果提供了默认值,使用默认值;否则不添加该键
|
||||||
|
if ($default !== null) {
|
||||||
|
$result[$targetKey] = $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 普通键名
|
||||||
|
if (isset($array[$key])) {
|
||||||
|
$result[$key] = $array[$key];
|
||||||
|
} else {
|
||||||
|
// 如果提供了默认值,使用默认值;否则不添加该键
|
||||||
|
if ($default !== null) {
|
||||||
|
$result[$key] = $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除数组中的空值(null、空字符串、空数组)
|
||||||
|
*
|
||||||
|
* @param array $array 源数组
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function rmValue(array $array): array
|
||||||
|
{
|
||||||
|
return array_filter($array, function($value) {
|
||||||
|
if (is_array($value)) {
|
||||||
|
return !empty($value);
|
||||||
|
}
|
||||||
|
return $value !== null && $value !== '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 左连接两个数组
|
||||||
|
*
|
||||||
|
* @param array $leftArray 左数组
|
||||||
|
* @param array $rightArray 右数组
|
||||||
|
* @param string $key 关联键名
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function leftJoin(array $leftArray, array $rightArray, string $key): array
|
||||||
|
{
|
||||||
|
// 将右数组按关联键索引
|
||||||
|
$rightIndexed = [];
|
||||||
|
foreach ($rightArray as $item) {
|
||||||
|
if (isset($item[$key])) {
|
||||||
|
$rightIndexed[$item[$key]] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左连接
|
||||||
|
$result = [];
|
||||||
|
foreach ($leftArray as $leftItem) {
|
||||||
|
$leftKeyValue = $leftItem[$key] ?? null;
|
||||||
|
if ($leftKeyValue !== null && isset($rightIndexed[$leftKeyValue])) {
|
||||||
|
$result[] = array_merge($leftItem, $rightIndexed[$leftKeyValue]);
|
||||||
|
} else {
|
||||||
|
$result[] = $leftItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将数组的某一列作为键,重新组织数组
|
||||||
|
*
|
||||||
|
* @param string $key 作为键的列名
|
||||||
|
* @param array $array 源数组
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function columnTokey(string $key, array $array): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($array as $item) {
|
||||||
|
if (isset($item[$key])) {
|
||||||
|
$result[$item[$key]] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ use think\facade\Config;
|
|||||||
use think\facade\Log;
|
use think\facade\Log;
|
||||||
use app\api\controller\FriendTaskController;
|
use app\api\controller\FriendTaskController;
|
||||||
use app\common\service\AuthService;
|
use app\common\service\AuthService;
|
||||||
|
use app\common\service\WechatAccountHealthScoreService;
|
||||||
use app\api\controller\WebSocketController;
|
use app\api\controller\WebSocketController;
|
||||||
use Workerman\Lib\Timer;
|
use Workerman\Lib\Timer;
|
||||||
|
|
||||||
@@ -180,13 +181,11 @@ class Adapter implements WeChatServiceInterface
|
|||||||
->select();
|
->select();
|
||||||
$taskData = array_merge($taskData, $tasks);
|
$taskData = array_merge($taskData, $tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($taskData) {
|
if ($taskData) {
|
||||||
|
|
||||||
foreach ($taskData as $task) {
|
foreach ($taskData as $task) {
|
||||||
$task_id = $task['task_id'];
|
$task_id = $task['task_id'];
|
||||||
$task_info = $this->getCustomerAcquisitionTask($task_id);
|
$task_info = $this->getCustomerAcquisitionTask($task_id);
|
||||||
|
|
||||||
if (empty($task_info['status']) || empty($task_info['reqConf']) || empty($task_info['reqConf']['device'])) {
|
if (empty($task_info['status']) || empty($task_info['reqConf']) || empty($task_info['reqConf']['device'])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -213,9 +212,86 @@ class Adapter implements WeChatServiceInterface
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断24h内加的好友数量,friend_task 先固定10个人 getLast24hAddedFriendsCount
|
// 根据健康分判断24h内加的好友数量限制
|
||||||
|
$healthScoreService = new WechatAccountHealthScoreService();
|
||||||
|
$healthScoreInfo = $healthScoreService->getHealthScore($accountId);
|
||||||
|
|
||||||
|
// 如果健康分记录不存在,先计算一次
|
||||||
|
if (empty($healthScoreInfo)) {
|
||||||
|
try {
|
||||||
|
$healthScoreService->calculateAndUpdate($accountId);
|
||||||
|
$healthScoreInfo = $healthScoreService->getHealthScore($accountId);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error("计算健康分失败 (accountId: {$accountId}): " . $e->getMessage());
|
||||||
|
// 如果计算失败,使用默认值5作为兜底
|
||||||
|
$maxAddFriendPerDay = 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取每日最大加人次数(基于健康分)
|
||||||
|
$maxAddFriendPerDay = $healthScoreInfo['maxAddFriendPerDay'] ?? 5;
|
||||||
|
|
||||||
|
// 如果健康分为0或很低,不允许添加好友
|
||||||
|
if ($maxAddFriendPerDay <= 0) {
|
||||||
|
Log::info("账号健康分过低,不允许添加好友 (accountId: {$accountId}, wechatId: {$wechatId}, healthScore: " . ($healthScoreInfo['healthScore'] ?? 0) . ")");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查频繁暂停限制:首次频繁或再次频繁,暂停24小时
|
||||||
|
$lastFrequentTime = $healthScoreInfo['lastFrequentTime'] ?? null;
|
||||||
|
$frequentCount = $healthScoreInfo['frequentCount'] ?? 0;
|
||||||
|
if (!empty($lastFrequentTime) && $frequentCount > 0) {
|
||||||
|
$frequentPauseHours = 24; // 频繁暂停24小时
|
||||||
|
$frequentPauseTime = $lastFrequentTime + ($frequentPauseHours * 3600);
|
||||||
|
$currentTime = time();
|
||||||
|
|
||||||
|
if ($currentTime < $frequentPauseTime) {
|
||||||
|
$remainingHours = ceil(($frequentPauseTime - $currentTime) / 3600);
|
||||||
|
Log::info("账号频繁,暂停添加好友 (accountId: {$accountId}, wechatId: {$wechatId}, frequentCount: {$frequentCount}, 剩余暂停时间: {$remainingHours}小时)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查封号暂停限制:封号暂停72小时
|
||||||
|
$isBanned = $healthScoreInfo['isBanned'] ?? 0;
|
||||||
|
if ($isBanned == 1) {
|
||||||
|
// 查询封号时间(从s2_wechat_message表查询最近一次封号消息)
|
||||||
|
$banMessage = Db::table('s2_wechat_message')
|
||||||
|
->where('wechatAccountId', $accountId)
|
||||||
|
->where('msgType', 10000)
|
||||||
|
->where('content', 'like', '%你的账号被限制%')
|
||||||
|
->where('isDeleted', 0)
|
||||||
|
->order('createTime', 'desc')
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if (!empty($banMessage)) {
|
||||||
|
$banTime = $banMessage['createTime'] ?? 0;
|
||||||
|
$banPauseHours = 72; // 封号暂停72小时
|
||||||
|
$banPauseTime = $banTime + ($banPauseHours * 3600);
|
||||||
|
$currentTime = time();
|
||||||
|
|
||||||
|
if ($currentTime < $banPauseTime) {
|
||||||
|
$remainingHours = ceil(($banPauseTime - $currentTime) / 3600);
|
||||||
|
Log::info("账号封号,暂停添加好友 (accountId: {$accountId}, wechatId: {$wechatId}, 剩余暂停时间: {$remainingHours}小时)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断今天添加的好友数量,使用健康分计算的每日最大加人次数
|
||||||
|
// 优先使用今天添加的好友数量(更符合"每日"限制)
|
||||||
|
$todayAddedFriendsCount = $this->getTodayAddedFriendsCount($wechatId);
|
||||||
|
if ($todayAddedFriendsCount >= $maxAddFriendPerDay) {
|
||||||
|
Log::info("今天添加好友数量已达上限 (accountId: {$accountId}, wechatId: {$wechatId}, count: {$todayAddedFriendsCount}, max: {$maxAddFriendPerDay}, healthScore: " . ($healthScoreInfo['healthScore'] ?? 0) . ")");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果今天添加数量未达上限,再检查24小时内的数量(作为额外保护)
|
||||||
$last24hAddedFriendsCount = $this->getLast24hAddedFriendsCount($wechatId);
|
$last24hAddedFriendsCount = $this->getLast24hAddedFriendsCount($wechatId);
|
||||||
if ($last24hAddedFriendsCount >= 20) {
|
// 24小时内的限制可以稍微宽松一些,设置为每日限制的1.2倍(防止跨天累积)
|
||||||
|
$max24hLimit = (int)ceil($maxAddFriendPerDay * 1.2);
|
||||||
|
if ($last24hAddedFriendsCount >= $max24hLimit) {
|
||||||
|
Log::info("24小时内添加好友数量已达上限 (accountId: {$accountId}, wechatId: {$wechatId}, count: {$last24hAddedFriendsCount}, max24h: {$max24hLimit}, maxDaily: {$maxAddFriendPerDay})");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -828,6 +904,7 @@ class Adapter implements WeChatServiceInterface
|
|||||||
if (empty($deviceIds)) {
|
if (empty($deviceIds)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$records = Db::table('s2_wechat_account')
|
$records = Db::table('s2_wechat_account')
|
||||||
->where('deviceAlive', 1)
|
->where('deviceAlive', 1)
|
||||||
->where('wechatAlive', 1)
|
->where('wechatAlive', 1)
|
||||||
|
|||||||
2301
Server/sql.sql
Normal file
2301
Server/sql.sql
Normal file
File diff suppressed because it is too large
Load Diff
58
Server/微信健康分规则v2.md
Normal file
58
Server/微信健康分规则v2.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# 微信健康分规则 v2
|
||||||
|
|
||||||
|
## 一、定义
|
||||||
|
|
||||||
|
当客户收到手机设备后,登录了微信号,我们将对其微信号进行健康分的评估。
|
||||||
|
|
||||||
|
**健康分 = 基础分 + 动态分**
|
||||||
|
|
||||||
|
健康分只与系统中的"每日自动添加好友次数"这个功能相关联。\
|
||||||
|
通过健康分体系来定义一个微信号每日**最佳、最稳定的添加次数**。\
|
||||||
|
后期还可将健康分作为标签属性,用于快速筛选微信号。
|
||||||
|
|
||||||
|
**公式:每日最大加人次数 = 健康分 × 0.2**
|
||||||
|
|
||||||
|
## 二、基础分
|
||||||
|
|
||||||
|
基础分为 **60--100 分**。
|
||||||
|
|
||||||
|
由 `60 + 40(基础加成分)` 四个维度参数组成,每个参数具有不同权重。
|
||||||
|
|
||||||
|
### 基础分组成
|
||||||
|
|
||||||
|
类型 权重 分数
|
||||||
|
------------ ------ ------
|
||||||
|
基础信息 0.2 10
|
||||||
|
好友数量 0.3 30
|
||||||
|
默认基础分 --- 60
|
||||||
|
|
||||||
|
### 1. 基础信息(权重 0.2,满分 10)
|
||||||
|
|
||||||
|
类型 权重 分数
|
||||||
|
-------------- ------ ------
|
||||||
|
已修改微信号 1 10
|
||||||
|
|
||||||
|
### 2. 好友数量(权重 0.3,满分 30)
|
||||||
|
|
||||||
|
好友数量范围 权重 分数
|
||||||
|
-------------- ------ ------
|
||||||
|
0--50 0.1 3
|
||||||
|
51--500 0.2 6
|
||||||
|
501--3000 0.3 8
|
||||||
|
3001 以上 0.4 12
|
||||||
|
|
||||||
|
## 三、动态分规则
|
||||||
|
|
||||||
|
### 扣分规则
|
||||||
|
|
||||||
|
场景 扣分 处罚
|
||||||
|
---------- ------ --------------
|
||||||
|
首次频繁 15 暂停 24 小时
|
||||||
|
再次频繁 25 暂停 24 小时
|
||||||
|
封号 60 暂停 72 小时
|
||||||
|
|
||||||
|
### 加分规则
|
||||||
|
|
||||||
|
场景 加分
|
||||||
|
--------------------- ------
|
||||||
|
连续 3 天不触发频繁 5/日
|
||||||
@@ -11,6 +11,10 @@
|
|||||||
</style>
|
</style>
|
||||||
<!-- 引入 uni-app web-view SDK(必须) -->
|
<!-- 引入 uni-app web-view SDK(必须) -->
|
||||||
<script type="text/javascript" src="/websdk.js"></script>
|
<script type="text/javascript" src="/websdk.js"></script>
|
||||||
|
<script
|
||||||
|
charset="utf-8"
|
||||||
|
src="https://map.qq.com/api/gljs?v=1.exp&libraries=service&key=7DZBZ-ZSRK3-QJN3W-O5VTV-4E2P6-7GFYX"
|
||||||
|
></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
431
Touchkebao/src/pages/pc/ckbox/components/NavCommon/Notice.tsx
Normal file
431
Touchkebao/src/pages/pc/ckbox/components/NavCommon/Notice.tsx
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Drawer, Avatar, Space, Button, Badge, Empty, Tabs, Tag } from "antd";
|
||||||
|
import { BellOutlined } from "@ant-design/icons";
|
||||||
|
import {
|
||||||
|
noticeList,
|
||||||
|
readMessage,
|
||||||
|
readAll,
|
||||||
|
friendRequestList as fetchFriendRequestListApi,
|
||||||
|
} from "./api";
|
||||||
|
import styles from "./index.module.scss";
|
||||||
|
|
||||||
|
interface MessageItem {
|
||||||
|
id: number;
|
||||||
|
type: number;
|
||||||
|
companyId: number;
|
||||||
|
userId: number;
|
||||||
|
bindId: number;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
isRead: number;
|
||||||
|
createTime: string;
|
||||||
|
readTime: string;
|
||||||
|
friendData: {
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FriendRequestItem {
|
||||||
|
taskId: number;
|
||||||
|
phone: string;
|
||||||
|
wechatId: string;
|
||||||
|
adder?: {
|
||||||
|
avatar?: string;
|
||||||
|
nickname?: string;
|
||||||
|
username?: string;
|
||||||
|
accountNickname?: string;
|
||||||
|
accountRealName?: string;
|
||||||
|
};
|
||||||
|
status?: {
|
||||||
|
code?: number;
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
time?: {
|
||||||
|
addTime?: string;
|
||||||
|
addTimeStamp?: number;
|
||||||
|
updateTime?: string;
|
||||||
|
updateTimeStamp?: number;
|
||||||
|
passTime?: string;
|
||||||
|
passTimeStamp?: number;
|
||||||
|
};
|
||||||
|
friend?: {
|
||||||
|
nickname?: string;
|
||||||
|
isPassed?: boolean;
|
||||||
|
};
|
||||||
|
other?: {
|
||||||
|
msgContent?: string;
|
||||||
|
remark?: string;
|
||||||
|
from?: string;
|
||||||
|
labels?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_QUERY = { page: 1, limit: 20 };
|
||||||
|
|
||||||
|
const Notice: React.FC = () => {
|
||||||
|
const [messageDrawerVisible, setMessageDrawerVisible] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState("messages");
|
||||||
|
const [messageList, setMessageList] = useState<MessageItem[]>([]);
|
||||||
|
const [messageCount, setMessageCount] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [friendRequestList, setFriendRequestList] = useState<
|
||||||
|
FriendRequestItem[]
|
||||||
|
>([]);
|
||||||
|
const [friendRequestLoading, setFriendRequestLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchMessageList = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await noticeList(DEFAULT_QUERY);
|
||||||
|
if (response?.list) {
|
||||||
|
setMessageList(response.list);
|
||||||
|
const unreadCount = response.list.filter(
|
||||||
|
(item: MessageItem) => item.isRead === 0,
|
||||||
|
).length;
|
||||||
|
setMessageCount(unreadCount);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取消息列表失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshUnreadCount = async () => {
|
||||||
|
try {
|
||||||
|
const response = await noticeList(DEFAULT_QUERY);
|
||||||
|
if (response && typeof response.noRead === "number") {
|
||||||
|
setMessageCount(response.noRead);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取未读消息数失败:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMessageList();
|
||||||
|
const timer = window.setInterval(refreshUnreadCount, 30 * 1000);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMessageClick = () => {
|
||||||
|
setMessageDrawerVisible(true);
|
||||||
|
fetchMessageList();
|
||||||
|
fetchFriendRequestList();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTabChange = (key: string) => {
|
||||||
|
setActiveTab(key);
|
||||||
|
if (key === "friendRequests") {
|
||||||
|
fetchFriendRequestList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMessageDrawerClose = () => {
|
||||||
|
setMessageDrawerVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReadMessage = async (messageId: number) => {
|
||||||
|
try {
|
||||||
|
await readMessage({ id: messageId });
|
||||||
|
setMessageList(prev => {
|
||||||
|
const updated = prev.map(item =>
|
||||||
|
item.id === messageId ? { ...item, isRead: 1 } : item,
|
||||||
|
);
|
||||||
|
const unreadCount = updated.filter(item => item.isRead === 0).length;
|
||||||
|
setMessageCount(unreadCount);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("标记消息已读失败:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReadAll = async () => {
|
||||||
|
try {
|
||||||
|
await readAll();
|
||||||
|
setMessageList(prev => prev.map(item => ({ ...item, isRead: 1 })));
|
||||||
|
setMessageCount(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("全部已读失败:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFriendRequestList = async () => {
|
||||||
|
try {
|
||||||
|
setFriendRequestLoading(true);
|
||||||
|
const response = await fetchFriendRequestListApi(DEFAULT_QUERY);
|
||||||
|
if (response?.list) {
|
||||||
|
setFriendRequestList(response.list);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取好友添加记录失败:", error);
|
||||||
|
} finally {
|
||||||
|
setFriendRequestLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timeStr?: string) => {
|
||||||
|
if (!timeStr) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const date = new Date(timeStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (days === 0) {
|
||||||
|
return date.toLocaleTimeString("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} else if (days === 1) {
|
||||||
|
return "昨天";
|
||||||
|
} else if (days < 7) {
|
||||||
|
return `${days}天前`;
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (statusCode?: number, statusText?: string) => {
|
||||||
|
if (statusText) {
|
||||||
|
return statusText;
|
||||||
|
}
|
||||||
|
switch (statusCode) {
|
||||||
|
case 0:
|
||||||
|
return "待处理";
|
||||||
|
case 1:
|
||||||
|
return "已同意";
|
||||||
|
case 2:
|
||||||
|
return "已拒绝";
|
||||||
|
default:
|
||||||
|
return "未知";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (statusCode?: number) => {
|
||||||
|
switch (statusCode) {
|
||||||
|
case 0:
|
||||||
|
return "#1890ff";
|
||||||
|
case 1:
|
||||||
|
return "#52c41a";
|
||||||
|
case 2:
|
||||||
|
return "#ff4d4f";
|
||||||
|
default:
|
||||||
|
return "#999";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFriendRequestKey = (item: FriendRequestItem) => {
|
||||||
|
return (
|
||||||
|
item.taskId?.toString() ||
|
||||||
|
item.wechatId ||
|
||||||
|
item.phone ||
|
||||||
|
`${item.adder?.username || "unknown"}-${item.time?.addTime || "time"}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAddedUserName = (item: FriendRequestItem) => {
|
||||||
|
return (
|
||||||
|
item.friend?.nickname ||
|
||||||
|
item.phone ||
|
||||||
|
item.wechatId ||
|
||||||
|
item.adder?.nickname ||
|
||||||
|
"未知好友"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAdderName = (item: FriendRequestItem) => {
|
||||||
|
return (
|
||||||
|
item.adder?.nickname ||
|
||||||
|
item.adder?.username ||
|
||||||
|
item.adder?.accountNickname ||
|
||||||
|
item.adder?.accountRealName ||
|
||||||
|
"未知添加人"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.messageButton} onClick={handleMessageClick}>
|
||||||
|
<Badge count={messageCount} size="small">
|
||||||
|
<BellOutlined style={{ fontSize: 20 }} />
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title="通知中心"
|
||||||
|
placement="right"
|
||||||
|
onClose={handleMessageDrawerClose}
|
||||||
|
open={messageDrawerVisible}
|
||||||
|
width={400}
|
||||||
|
className={styles.messageDrawer}
|
||||||
|
extra={
|
||||||
|
activeTab === "messages" && (
|
||||||
|
<Space>
|
||||||
|
<Button type="text" size="small" onClick={handleReadAll}>
|
||||||
|
全部已读
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ padding: "0 20px" }}>
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: "messages",
|
||||||
|
label: "消息列表",
|
||||||
|
children: (
|
||||||
|
<div className={styles.messageContent}>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: "center", padding: "20px" }}>
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
) : messageList.length === 0 ? (
|
||||||
|
<Empty description="暂无消息" />
|
||||||
|
) : (
|
||||||
|
messageList.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`${styles.messageItem} ${
|
||||||
|
item.isRead === 0 ? styles.unread : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => handleReadMessage(item.id)}
|
||||||
|
>
|
||||||
|
<div className={styles.messageAvatar}>
|
||||||
|
<Avatar
|
||||||
|
size={40}
|
||||||
|
src={item.friendData?.avatar}
|
||||||
|
style={{ backgroundColor: "#87d068" }}
|
||||||
|
>
|
||||||
|
{item.friendData?.nickname?.charAt(0) || "U"}
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
<div className={styles.messageInfo}>
|
||||||
|
<div className={styles.messageTitle}>
|
||||||
|
<span className={styles.messageType}>
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
{item.isRead === 0 && (
|
||||||
|
<div className={styles.messageStatus}></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.messageText}>
|
||||||
|
{item.message}
|
||||||
|
</div>
|
||||||
|
{item.isRead === 0 && (
|
||||||
|
<div className={styles.messageTime}>
|
||||||
|
{formatTime(item.createTime)}
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleReadMessage(item.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
标记已读
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "friendRequests",
|
||||||
|
label: "好友添加记录",
|
||||||
|
children: (
|
||||||
|
<div className={styles.messageContent}>
|
||||||
|
{friendRequestLoading ? (
|
||||||
|
<div style={{ textAlign: "center", padding: "20px" }}>
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
) : friendRequestList.length === 0 ? (
|
||||||
|
<Empty description="暂无好友添加记录" />
|
||||||
|
) : (
|
||||||
|
friendRequestList.map(item => (
|
||||||
|
<div
|
||||||
|
key={getFriendRequestKey(item)}
|
||||||
|
className={styles.messageItem}
|
||||||
|
>
|
||||||
|
<div className={styles.messageAvatar}>
|
||||||
|
<Avatar
|
||||||
|
size={40}
|
||||||
|
src={item.adder?.avatar}
|
||||||
|
style={{ backgroundColor: "#87d068" }}
|
||||||
|
>
|
||||||
|
{item.adder?.nickname?.charAt(0) || "U"}
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
<div className={styles.messageInfo}>
|
||||||
|
<div className={styles.messageTitle}>
|
||||||
|
<span className={styles.messageType}>
|
||||||
|
添加好友:
|
||||||
|
<Tag color="blue">{getAddedUserName(item)}</Tag>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: getStatusColor(item.status?.code),
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getStatusText(
|
||||||
|
item.status?.code,
|
||||||
|
item.status?.text,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={styles.messageText}
|
||||||
|
style={{ color: "#595959" }}
|
||||||
|
>
|
||||||
|
申请人:{getAdderName(item)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.messageText}>
|
||||||
|
验证信息:{item.other?.msgContent || "无"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={styles.messageText}
|
||||||
|
style={{ color: "#999", fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{item.other?.remark && (
|
||||||
|
<Tag color="orange" style={{ marginTop: 4 }}>
|
||||||
|
备注:{item.other.remark}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.messageTime}>
|
||||||
|
{formatTime(item.time?.addTime)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Notice;
|
||||||
@@ -14,3 +14,8 @@ export const readMessage = (params: { id: number }) => {
|
|||||||
export const readAll = () => {
|
export const readAll = () => {
|
||||||
return request(`/v1/kefu/notice/readAll`, undefined, "PUT");
|
return request(`/v1/kefu/notice/readAll`, undefined, "PUT");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 好友添加任务列表
|
||||||
|
export const friendRequestList = (params: { page: number; limit: number }) => {
|
||||||
|
return request(`/v1/kefu/wechatFriend/addTaskList`, params, "GET");
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,28 +1,18 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState } from "react";
|
||||||
import {
|
import { Layout, Avatar, Space, Button, Dropdown, message } from "antd";
|
||||||
Layout,
|
|
||||||
Drawer,
|
|
||||||
Avatar,
|
|
||||||
Space,
|
|
||||||
Button,
|
|
||||||
Badge,
|
|
||||||
Dropdown,
|
|
||||||
Empty,
|
|
||||||
} from "antd";
|
|
||||||
import {
|
import {
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
BellOutlined,
|
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
CalendarOutlined,
|
SendOutlined,
|
||||||
RetweetOutlined,
|
ClearOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { noticeList, readMessage, readAll } from "./api";
|
|
||||||
import { useUserStore } from "@/store/module/user";
|
import { useUserStore } from "@/store/module/user";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import styles from "./index.module.scss";
|
import styles from "./index.module.scss";
|
||||||
|
import Notice from "./Notice";
|
||||||
|
|
||||||
const { Header } = Layout;
|
const { Header } = Layout;
|
||||||
|
|
||||||
@@ -31,39 +21,12 @@ interface NavCommonProps {
|
|||||||
onMenuClick?: () => void;
|
onMenuClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息数据类型
|
|
||||||
interface MessageItem {
|
|
||||||
id: number;
|
|
||||||
type: number;
|
|
||||||
companyId: number;
|
|
||||||
userId: number;
|
|
||||||
bindId: number;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
isRead: number;
|
|
||||||
createTime: string;
|
|
||||||
readTime: string;
|
|
||||||
friendData: {
|
|
||||||
nickname: string;
|
|
||||||
avatar: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
|
const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
|
||||||
const [messageDrawerVisible, setMessageDrawerVisible] = useState(false);
|
const [clearingCache, setClearingCache] = useState(false);
|
||||||
const [messageList, setMessageList] = useState<MessageItem[]>([]);
|
|
||||||
const [messageCount, setMessageCount] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, logout } = useUserStore();
|
const { user, logout } = useUserStore();
|
||||||
|
|
||||||
// 初始化时获取消息列表
|
|
||||||
useEffect(() => {
|
|
||||||
fetchMessageList();
|
|
||||||
setInterval(IntervalMessageCount, 30 * 1000);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 处理菜单图标点击:在两个路由之间切换
|
// 处理菜单图标点击:在两个路由之间切换
|
||||||
const handleMenuClick = () => {
|
const handleMenuClick = () => {
|
||||||
if (!location.pathname.startsWith("/pc/powerCenter")) {
|
if (!location.pathname.startsWith("/pc/powerCenter")) {
|
||||||
@@ -72,111 +35,102 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
|
|||||||
navigate("/pc/weChat");
|
navigate("/pc/weChat");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isWeChat = () => {
|
|
||||||
return location.pathname.startsWith("/pc/weChat");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 定时器获取消息条数
|
|
||||||
const IntervalMessageCount = async () => {
|
|
||||||
try {
|
|
||||||
const response = await noticeList({ page: 1, limit: 20 });
|
|
||||||
if (response && response.noRead) {
|
|
||||||
setMessageCount(response.noRead);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取消息列表失败:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// 获取消息列表
|
|
||||||
const fetchMessageList = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await noticeList({ page: 1, limit: 20 });
|
|
||||||
if (response && response.list) {
|
|
||||||
setMessageList(response.list);
|
|
||||||
// 计算未读消息数量
|
|
||||||
const unreadCount = response.list.filter(
|
|
||||||
(item: MessageItem) => item.isRead === 0,
|
|
||||||
).length;
|
|
||||||
setMessageCount(unreadCount);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取消息列表失败:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理消息中心点击
|
|
||||||
const handleMessageClick = () => {
|
|
||||||
setMessageDrawerVisible(true);
|
|
||||||
fetchMessageList();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理消息抽屉关闭
|
|
||||||
const handleMessageDrawerClose = () => {
|
|
||||||
setMessageDrawerVisible(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理退出登录
|
// 处理退出登录
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout(); // 清除localStorage中的token和用户状态
|
logout(); // 清除localStorage中的token和用户状态
|
||||||
navigate("/login"); // 跳转到登录页面
|
navigate("/login"); // 跳转到登录页面
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理消息已读
|
// 清除所有 IndexedDB 数据库
|
||||||
const handleReadMessage = async (messageId: number) => {
|
const clearAllIndexedDB = async (): Promise<void> => {
|
||||||
try {
|
return new Promise((resolve, reject) => {
|
||||||
await readMessage({ id: messageId }); // 这里需要根据实际API调整参数
|
if (!window.indexedDB) {
|
||||||
// 更新本地状态
|
resolve();
|
||||||
setMessageList(prev =>
|
return;
|
||||||
prev.map(item =>
|
}
|
||||||
item.id === messageId ? { ...item, isRead: 1 } : item,
|
|
||||||
),
|
// 获取所有数据库名称
|
||||||
);
|
const databases: string[] = [];
|
||||||
// 重新计算未读数量
|
const request = indexedDB.databases();
|
||||||
const unreadCount =
|
|
||||||
messageList.filter(item => item.isRead === 0).length - 1;
|
request
|
||||||
setMessageCount(Math.max(0, unreadCount));
|
.then(dbs => {
|
||||||
} catch (error) {
|
dbs.forEach(db => {
|
||||||
console.error("标记消息已读失败:", error);
|
if (db.name) {
|
||||||
}
|
databases.push(db.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除所有数据库
|
||||||
|
const deletePromises = databases.map(dbName => {
|
||||||
|
return new Promise<void>((resolveDelete, rejectDelete) => {
|
||||||
|
const deleteRequest = indexedDB.deleteDatabase(dbName);
|
||||||
|
deleteRequest.onsuccess = () => {
|
||||||
|
resolveDelete();
|
||||||
|
};
|
||||||
|
deleteRequest.onerror = () => {
|
||||||
|
rejectDelete(new Error(`删除数据库 ${dbName} 失败`));
|
||||||
|
};
|
||||||
|
deleteRequest.onblocked = () => {
|
||||||
|
// 如果数据库被阻塞,等待一下再重试
|
||||||
|
setTimeout(() => {
|
||||||
|
const retryRequest = indexedDB.deleteDatabase(dbName);
|
||||||
|
retryRequest.onsuccess = () => resolveDelete();
|
||||||
|
retryRequest.onerror = () =>
|
||||||
|
rejectDelete(new Error(`删除数据库 ${dbName} 失败`));
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(deletePromises)
|
||||||
|
.then(() => resolve())
|
||||||
|
.catch(reject);
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理全部已读
|
// 处理清除缓存
|
||||||
const handleReadAll = async () => {
|
const handleClearCache = async () => {
|
||||||
try {
|
try {
|
||||||
await readAll(); // 这里需要根据实际API调整参数
|
setClearingCache(true);
|
||||||
// 更新本地状态
|
const hideLoading = message.loading("正在清除缓存...", 0);
|
||||||
setMessageList(prev => prev.map(item => ({ ...item, isRead: 1 })));
|
|
||||||
setMessageCount(0);
|
// 1. 清除所有 localStorage
|
||||||
|
try {
|
||||||
|
localStorage.clear();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("清除 localStorage 失败:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 清除所有 sessionStorage
|
||||||
|
try {
|
||||||
|
sessionStorage.clear();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("清除 sessionStorage 失败:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 清除所有 IndexedDB 数据库
|
||||||
|
try {
|
||||||
|
await clearAllIndexedDB();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("清除 IndexedDB 失败:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
hideLoading();
|
||||||
|
message.success("缓存清除成功");
|
||||||
|
|
||||||
|
// 清除成功后跳转到登录页面
|
||||||
|
setTimeout(() => {
|
||||||
|
logout();
|
||||||
|
navigate("/login");
|
||||||
|
}, 500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("全部已读失败:", error);
|
console.error("清除缓存失败:", error);
|
||||||
}
|
message.error("清除缓存失败,请稍后重试");
|
||||||
};
|
} finally {
|
||||||
|
setClearingCache(false);
|
||||||
// 格式化时间
|
|
||||||
const formatTime = (timeStr: string) => {
|
|
||||||
const date = new Date(timeStr);
|
|
||||||
const now = new Date();
|
|
||||||
const diff = now.getTime() - date.getTime();
|
|
||||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (days === 0) {
|
|
||||||
return date.toLocaleTimeString("zh-CN", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
} else if (days === 1) {
|
|
||||||
return "昨天";
|
|
||||||
} else if (days < 7) {
|
|
||||||
return `${days}天前`;
|
|
||||||
} else {
|
|
||||||
return date.toLocaleDateString("zh-CN", {
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -199,6 +153,13 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
|
|||||||
navigate("/pc/commonConfig");
|
navigate("/pc/commonConfig");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "clearCache",
|
||||||
|
icon: <ClearOutlined style={{ fontSize: 16 }} />,
|
||||||
|
label: clearingCache ? "清除缓存中..." : "清除缓存",
|
||||||
|
onClick: handleClearCache,
|
||||||
|
disabled: clearingCache,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "logout",
|
key: "logout",
|
||||||
icon: <LogoutOutlined style={{ fontSize: 14 }} />,
|
icon: <LogoutOutlined style={{ fontSize: 14 }} />,
|
||||||
@@ -220,10 +181,10 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
|
|||||||
></Button>
|
></Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
icon={<CalendarOutlined />}
|
icon={<SendOutlined />}
|
||||||
onClick={handleContentManagementClick}
|
onClick={handleContentManagementClick}
|
||||||
>
|
>
|
||||||
内容管理
|
发朋友圈
|
||||||
</Button>
|
</Button>
|
||||||
<span className={styles.title}>{title}</span>
|
<span className={styles.title}>{title}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,11 +197,7 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
|
|||||||
</span>
|
</span>
|
||||||
{user?.tokens}
|
{user?.tokens}
|
||||||
</span>
|
</span>
|
||||||
<div className={styles.messageButton} onClick={handleMessageClick}>
|
<Notice />
|
||||||
<Badge count={messageCount} size="small">
|
|
||||||
<BellOutlined style={{ fontSize: 20 }} />
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
menu={{ items: userMenuItems }}
|
menu={{ items: userMenuItems }}
|
||||||
@@ -264,69 +221,6 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
|
|||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<Drawer
|
|
||||||
title="通知中心"
|
|
||||||
placement="right"
|
|
||||||
onClose={handleMessageDrawerClose}
|
|
||||||
open={messageDrawerVisible}
|
|
||||||
width={400}
|
|
||||||
className={styles.messageDrawer}
|
|
||||||
extra={
|
|
||||||
<Space>
|
|
||||||
<Button type="text" size="small" onClick={handleReadAll}>
|
|
||||||
全部已读
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={styles.messageContent}>
|
|
||||||
{loading ? (
|
|
||||||
<div style={{ textAlign: "center", padding: "20px" }}>
|
|
||||||
加载中...
|
|
||||||
</div>
|
|
||||||
) : messageList.length === 0 ? (
|
|
||||||
<Empty description="暂无消息" />
|
|
||||||
) : (
|
|
||||||
messageList.map(item => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className={`${styles.messageItem} ${
|
|
||||||
item.isRead === 0 ? styles.unread : ""
|
|
||||||
}`}
|
|
||||||
onClick={() => handleReadMessage(item.id)}
|
|
||||||
>
|
|
||||||
<div className={styles.messageAvatar}>
|
|
||||||
<Avatar
|
|
||||||
size={40}
|
|
||||||
src={item.friendData?.avatar}
|
|
||||||
style={{ backgroundColor: "#87d068" }}
|
|
||||||
>
|
|
||||||
{item.friendData?.nickname?.charAt(0) || "U"}
|
|
||||||
</Avatar>
|
|
||||||
</div>
|
|
||||||
<div className={styles.messageInfo}>
|
|
||||||
<div className={styles.messageTitle}>
|
|
||||||
<span className={styles.messageType}>{item.title}</span>
|
|
||||||
{item.isRead === 0 && (
|
|
||||||
<div className={styles.messageStatus}></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.messageText}>{item.message}</div>
|
|
||||||
{item.isRead === 0 && (
|
|
||||||
<div className={styles.messageTime}>
|
|
||||||
{formatTime(item.createTime)}
|
|
||||||
<Button type="link" size="small">
|
|
||||||
标记已读
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Drawer>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const ContentManagement: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<PowerNavigation
|
<PowerNavigation
|
||||||
title="内容管理"
|
title="发朋友圈"
|
||||||
subtitle="可以讲聊天过程的信息收录到素材库中,也调用。"
|
subtitle="可以讲聊天过程的信息收录到素材库中,也调用。"
|
||||||
showBackButton={true}
|
showBackButton={true}
|
||||||
backButtonText="返回功能中心"
|
backButtonText="返回功能中心"
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { TeamOutlined, CommentOutlined, BookOutlined, SendOutlined } from "@ant-design/icons";
|
import {
|
||||||
|
TeamOutlined,
|
||||||
|
CommentOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
|
||||||
// 数据类型定义
|
// 数据类型定义
|
||||||
export interface FeatureCard {
|
export interface FeatureCard {
|
||||||
@@ -56,21 +61,21 @@ export const featureCategories: FeatureCard[] = [
|
|||||||
],
|
],
|
||||||
path: "/pc/powerCenter/ai-reception",
|
path: "/pc/powerCenter/ai-reception",
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
id: "content-library",
|
// id: "content-library",
|
||||||
title: "AI内容库配置",
|
// title: "AI内容库配置",
|
||||||
description: "管理AI内容库,配置调用权限,优化AI推送效果和内容质量",
|
// description: "管理AI内容库,配置调用权限,优化AI推送效果和内容质量",
|
||||||
icon: <BookOutlined style={{ fontSize: "32px", color: "#52c41a" }} />,
|
// icon: <BookOutlined style={{ fontSize: "32px", color: "#52c41a" }} />,
|
||||||
color: "#52c41a",
|
// color: "#52c41a",
|
||||||
tag: "内容管理",
|
// tag: "内容管理",
|
||||||
features: [
|
// features: [
|
||||||
"多库管理与分类",
|
// "多库管理与分类",
|
||||||
"AI调用权限配置",
|
// "AI调用权限配置",
|
||||||
"内容检索规则设置",
|
// "内容检索规则设置",
|
||||||
"手动内容上传",
|
// "手动内容上传",
|
||||||
],
|
// ],
|
||||||
path: "/pc/powerCenter/content-library",
|
// path: "/pc/powerCenter/content-library",
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
id: "message-push-assistant",
|
id: "message-push-assistant",
|
||||||
title: "消息推送助手",
|
title: "消息推送助手",
|
||||||
|
|||||||
@@ -39,18 +39,6 @@ const PowerCenter: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.powerCenter}>
|
<div className={styles.powerCenter}>
|
||||||
{/* 页面标题区域 */}
|
{/* 页面标题区域 */}
|
||||||
<div className={styles.pageHeader}>
|
|
||||||
<div className={styles.titleSection}>
|
|
||||||
<div className={styles.mainTitle}>
|
|
||||||
<div className={styles.titleIcon}>⭐</div>
|
|
||||||
<h1>功能中心</h1>
|
|
||||||
</div>
|
|
||||||
<p className={styles.subtitle}>
|
|
||||||
AI智能营销·一站式客户管理·高效业务增长
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* KPI统计区域(置顶,按图展示) */}
|
{/* KPI统计区域(置顶,按图展示) */}
|
||||||
<div className={styles.kpiSection}>
|
<div className={styles.kpiSection}>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
@@ -157,9 +145,11 @@ const PowerCenter: React.FC = () => {
|
|||||||
{card.features.map((feature, index) => (
|
{card.features.map((feature, index) => (
|
||||||
<li
|
<li
|
||||||
key={index}
|
key={index}
|
||||||
style={{
|
style={
|
||||||
"--dot-color": card.color,
|
{
|
||||||
} as React.CSSProperties}
|
"--dot-color": card.color,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{feature}
|
{feature}
|
||||||
</li>
|
</li>
|
||||||
@@ -186,7 +176,7 @@ const PowerCenter: React.FC = () => {
|
|||||||
className={styles.cardIcon}
|
className={styles.cardIcon}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: getIconBgColor(
|
backgroundColor: getIconBgColor(
|
||||||
featureCategories[3].color
|
featureCategories[3].color,
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -212,9 +202,11 @@ const PowerCenter: React.FC = () => {
|
|||||||
{featureCategories[3].features.map((feature, index) => (
|
{featureCategories[3].features.map((feature, index) => (
|
||||||
<li
|
<li
|
||||||
key={index}
|
key={index}
|
||||||
style={{
|
style={
|
||||||
"--dot-color": featureCategories[3].color,
|
{
|
||||||
} as React.CSSProperties}
|
"--dot-color": featureCategories[3].color,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{feature}
|
{feature}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import request from "@/api/request";
|
import request from "@/api/request";
|
||||||
|
import type { CreatePushTaskPayload } from "./types";
|
||||||
|
|
||||||
// 获取客服列表
|
// 创建推送任务
|
||||||
export function queryWorkbenchCreate(params) {
|
export function queryWorkbenchCreate(
|
||||||
|
params: CreatePushTaskPayload,
|
||||||
|
): Promise<any> {
|
||||||
return request("/v1/workbench/create", params, "POST");
|
return request("/v1/workbench/create", params, "POST");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
.stepContent {
|
||||||
|
.step4Content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsPanel {
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #fafafa;
|
||||||
|
|
||||||
|
.settingsTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingItem {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingControl {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagSection {
|
||||||
|
.settingLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pushPreview {
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f0f7ff;
|
||||||
|
|
||||||
|
.previewTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stepContent {
|
||||||
|
.step4Content {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Select, Slider } from "antd";
|
||||||
|
import styles from "./index.module.scss";
|
||||||
|
|
||||||
|
interface StepPushParamsProps {
|
||||||
|
selectedAccounts: any[];
|
||||||
|
selectedContacts: any[];
|
||||||
|
targetLabel: string;
|
||||||
|
friendInterval: [number, number];
|
||||||
|
onFriendIntervalChange: (value: [number, number]) => void;
|
||||||
|
messageInterval: [number, number];
|
||||||
|
onMessageIntervalChange: (value: [number, number]) => void;
|
||||||
|
selectedTag: string;
|
||||||
|
onSelectedTagChange: (value: string) => void;
|
||||||
|
savedScriptGroups: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepPushParams: React.FC<StepPushParamsProps> = ({
|
||||||
|
selectedAccounts,
|
||||||
|
selectedContacts,
|
||||||
|
targetLabel,
|
||||||
|
friendInterval,
|
||||||
|
onFriendIntervalChange,
|
||||||
|
messageInterval,
|
||||||
|
onMessageIntervalChange,
|
||||||
|
selectedTag,
|
||||||
|
onSelectedTagChange,
|
||||||
|
savedScriptGroups,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.stepContent}>
|
||||||
|
<div className={styles.step4Content}>
|
||||||
|
<div className={styles.settingsPanel}>
|
||||||
|
<div className={styles.settingsTitle}>相关设置</div>
|
||||||
|
<div className={styles.settingItem}>
|
||||||
|
<div className={styles.settingLabel}>好友间间隔</div>
|
||||||
|
<div className={styles.settingControl}>
|
||||||
|
<span>间隔时间(秒)</span>
|
||||||
|
<Slider
|
||||||
|
range
|
||||||
|
min={1}
|
||||||
|
max={60}
|
||||||
|
value={friendInterval}
|
||||||
|
onChange={value =>
|
||||||
|
onFriendIntervalChange(value as [number, number])
|
||||||
|
}
|
||||||
|
style={{ flex: 1, margin: "0 16px" }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{friendInterval[0]} - {friendInterval[1]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingItem}>
|
||||||
|
<div className={styles.settingLabel}>消息间间隔</div>
|
||||||
|
<div className={styles.settingControl}>
|
||||||
|
<span>间隔时间(秒)</span>
|
||||||
|
<Slider
|
||||||
|
range
|
||||||
|
min={1}
|
||||||
|
max={60}
|
||||||
|
value={messageInterval}
|
||||||
|
onChange={value =>
|
||||||
|
onMessageIntervalChange(value as [number, number])
|
||||||
|
}
|
||||||
|
style={{ flex: 1, margin: "0 16px" }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{messageInterval[0]} - {messageInterval[1]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tagSection}>
|
||||||
|
<div className={styles.settingLabel}>完成打标签</div>
|
||||||
|
<Select
|
||||||
|
value={selectedTag}
|
||||||
|
onChange={onSelectedTagChange}
|
||||||
|
placeholder="选择标签"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<Select.Option value="potential">潜在客户</Select.Option>
|
||||||
|
<Select.Option value="customer">客户</Select.Option>
|
||||||
|
<Select.Option value="partner">合作伙伴</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.pushPreview}>
|
||||||
|
<div className={styles.previewTitle}>推送预览</div>
|
||||||
|
<ul>
|
||||||
|
<li>推送账号: {selectedAccounts.length}个</li>
|
||||||
|
<li>
|
||||||
|
推送{targetLabel}: {selectedContacts.length}个
|
||||||
|
</li>
|
||||||
|
<li>话术组数: {savedScriptGroups.length}个</li>
|
||||||
|
<li>随机推送: 否</li>
|
||||||
|
<li>预计耗时: ~1分钟</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StepPushParams;
|
||||||
@@ -6,6 +6,7 @@ import { EmojiPicker } from "@/components/EmojiSeclection";
|
|||||||
import { EmojiInfo } from "@/components/EmojiSeclection/wechatEmoji";
|
import { EmojiInfo } from "@/components/EmojiSeclection/wechatEmoji";
|
||||||
import SimpleFileUpload from "@/components/Upload/SimpleFileUpload";
|
import SimpleFileUpload from "@/components/Upload/SimpleFileUpload";
|
||||||
import AudioRecorder from "@/components/Upload/AudioRecorder";
|
import AudioRecorder from "@/components/Upload/AudioRecorder";
|
||||||
|
import type { MessageItem } from "../../../types";
|
||||||
|
|
||||||
import styles from "./index.module.scss";
|
import styles from "./index.module.scss";
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ interface InputMessageProps {
|
|||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
onContentChange?: (value: string) => void;
|
onContentChange?: (value: string) => void;
|
||||||
onSend?: (value: string) => void;
|
onSend?: (value: string) => void;
|
||||||
|
onAddMessage?: (message: MessageItem) => void; // 新增:支持添加非文本消息
|
||||||
clearOnSend?: boolean;
|
clearOnSend?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
hint?: React.ReactNode;
|
hint?: React.ReactNode;
|
||||||
@@ -68,6 +70,7 @@ const InputMessage: React.FC<InputMessageProps> = ({
|
|||||||
defaultValue = "",
|
defaultValue = "",
|
||||||
onContentChange,
|
onContentChange,
|
||||||
onSend,
|
onSend,
|
||||||
|
onAddMessage,
|
||||||
clearOnSend = false,
|
clearOnSend = false,
|
||||||
placeholder = "输入消息...",
|
placeholder = "输入消息...",
|
||||||
hint,
|
hint,
|
||||||
@@ -169,9 +172,44 @@ const InputMessage: React.FC<InputMessageProps> = ({
|
|||||||
msgType,
|
msgType,
|
||||||
content,
|
content,
|
||||||
});
|
});
|
||||||
antdMessage.success("附件上传成功,可在推送时使用");
|
|
||||||
|
// 如果提供了 onAddMessage 回调,则添加到消息列表
|
||||||
|
if (onAddMessage) {
|
||||||
|
let messageItem: MessageItem;
|
||||||
|
if ([FileType.IMAGE].includes(fileType)) {
|
||||||
|
messageItem = {
|
||||||
|
type: "image",
|
||||||
|
content: filePath.url,
|
||||||
|
fileName: filePath.name,
|
||||||
|
};
|
||||||
|
} else if ([FileType.AUDIO].includes(fileType)) {
|
||||||
|
messageItem = {
|
||||||
|
type: "audio",
|
||||||
|
content: filePath.url,
|
||||||
|
fileName: filePath.name,
|
||||||
|
durationMs: filePath.durationMs,
|
||||||
|
};
|
||||||
|
} else if ([FileType.FILE].includes(fileType)) {
|
||||||
|
messageItem = {
|
||||||
|
type: "file",
|
||||||
|
content: filePath.url,
|
||||||
|
fileName: filePath.name,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 默认作为文本处理
|
||||||
|
messageItem = {
|
||||||
|
type: "text",
|
||||||
|
content: filePath.url,
|
||||||
|
fileName: filePath.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
onAddMessage(messageItem);
|
||||||
|
antdMessage.success("已添加消息内容");
|
||||||
|
} else {
|
||||||
|
antdMessage.success("附件上传成功,可在推送时使用");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[],
|
[onAddMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAudioUploaded = useCallback(
|
const handleAudioUploaded = useCallback(
|
||||||
|
|||||||
@@ -19,28 +19,27 @@
|
|||||||
|
|
||||||
.step3Content {
|
.step3Content {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
align-items: flex-start;
|
|
||||||
|
|
||||||
.leftColumn {
|
.leftColumn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rightColumn {
|
.rightColumn {
|
||||||
width: 400px;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
max-width: 500px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewHeader {
|
.previewHeader {
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
.previewHeaderTitle {
|
.previewHeaderTitle {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -49,78 +48,178 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.messagePreview {
|
.messagePreview {
|
||||||
border: 2px dashed #52c41a;
|
border: 1px solid #e8e8e8;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 15px;
|
padding: 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
.messageBubble {
|
.messagePlaceholder {
|
||||||
min-height: 100px;
|
color: #999;
|
||||||
background: #fff;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
.currentEditingLabel {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #52c41a;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageText {
|
|
||||||
color: #1a1a1a;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messagePlaceholder {
|
|
||||||
color: #999;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageList {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageItem {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageText {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageAction {
|
|
||||||
color: #ff4d4f;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scriptNameInput {
|
.messageList {
|
||||||
margin-top: 12px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageBubbleWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageBubble {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.messageAvatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.messageBubbleInner {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -6px;
|
||||||
|
top: 12px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-top: 6px solid transparent;
|
||||||
|
border-bottom: 6px solid transparent;
|
||||||
|
border-right: 6px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageText {
|
||||||
|
color: #1a1a1a;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageMedia {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.messageMediaIcon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #1890ff;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageImage {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageFileInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageFileName {
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageFileSize {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-left: 4px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiRewriteButton {
|
||||||
|
color: #1890ff;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
height: auto;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #40a9ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAction {
|
||||||
|
color: #ff4d4f;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
height: auto;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #ff7875;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.savedScriptGroups {
|
.pushContentHeader {
|
||||||
.contentLibrarySelector {
|
.pushContentTitle {
|
||||||
margin-bottom: 20px;
|
font-size: 16px;
|
||||||
padding: 16px;
|
font-weight: 600;
|
||||||
background: #fff;
|
color: #1a1a1a;
|
||||||
border: 1px solid #e8e8e8;
|
margin-bottom: 16px;
|
||||||
border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentLibrarySelector {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
.contentLibraryHeader {
|
.contentLibraryHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -139,7 +238,9 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.savedScriptGroups {
|
||||||
.scriptGroupHeaderRow {
|
.scriptGroupHeaderRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -160,7 +261,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scriptGroupList {
|
.scriptGroupList {
|
||||||
max-height: 260px;
|
max-height: calc(100vh - 400px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,132 +342,358 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.messageInputArea {
|
.scriptNameInput {
|
||||||
.messageInput {
|
margin-top: 0;
|
||||||
margin-bottom: 12px;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.attachmentButtons {
|
.createScriptGroupButton {
|
||||||
display: flex;
|
margin-top: 0;
|
||||||
gap: 8px;
|
}
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aiRewriteSection {
|
// AI改写弹窗样式
|
||||||
display: flex;
|
.aiRewriteModalWrap {
|
||||||
justify-content: space-between;
|
:global {
|
||||||
align-items: center;
|
.ant-modal {
|
||||||
margin-bottom: 8px;
|
border-radius: 12px;
|
||||||
gap: 12px;
|
overflow: hidden;
|
||||||
|
|
||||||
.aiRewriteToggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.aiRewriteLabel {
|
.ant-modal-content {
|
||||||
font-size: 14px;
|
border-radius: 12px;
|
||||||
color: #1a1a1a;
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||||
}
|
|
||||||
|
|
||||||
.aiRewriteInput {
|
|
||||||
max-width: 240px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aiRewriteActions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aiRewriteButton {
|
|
||||||
min-width: 96px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.settingsPanel {
|
.aiRewriteModal {
|
||||||
border: 1px solid #e8e8e8;
|
:global {
|
||||||
border-radius: 8px;
|
.ant-modal-header {
|
||||||
padding: 20px;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
background: #fafafa;
|
padding: 20px 24px;
|
||||||
|
background: linear-gradient(135deg, #fff 0%, #fafafa 100%);
|
||||||
.settingsTitle {
|
border-radius: 12px 12px 0 0;
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingItem {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settingLabel {
|
.ant-modal-body {
|
||||||
font-size: 14px;
|
padding: 24px;
|
||||||
font-weight: 500;
|
background: #ffffff;
|
||||||
color: #1a1a1a;
|
|
||||||
margin-bottom: 12px;
|
// 确保内容区域的样式能够正确应用
|
||||||
|
// 原文消息内容区域
|
||||||
|
[class*="aiRewriteModalOriginalText"] {
|
||||||
|
padding: 20px !important;
|
||||||
|
background: #f5f5f5 !important;
|
||||||
|
min-height: 80px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改写提示词输入框
|
||||||
|
[class*="aiRewriteModalTextArea"] {
|
||||||
|
textarea.ant-input {
|
||||||
|
background: #f5f5f5 !important;
|
||||||
|
min-height: 80px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.settingControl {
|
.ant-modal-footer {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: #fafafa;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
span {
|
.ant-modal-close {
|
||||||
font-size: 14px;
|
color: #8c8c8c;
|
||||||
color: #666;
|
transition: color 0.3s;
|
||||||
min-width: 80px;
|
top: 20px;
|
||||||
|
right: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1a1a1a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tagSection {
|
.aiRewriteModalTitle {
|
||||||
.settingLabel {
|
display: flex;
|
||||||
font-size: 14px;
|
align-items: center;
|
||||||
font-weight: 500;
|
gap: 8px;
|
||||||
color: #1a1a1a;
|
font-size: 18px;
|
||||||
margin-bottom: 12px;
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
|
||||||
|
.aiRewriteModalTitleIcon {
|
||||||
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pushPreview {
|
.aiRewriteModalContent {
|
||||||
border: 1px solid #e8e8e8;
|
display: flex;
|
||||||
border-radius: 8px;
|
flex-direction: column;
|
||||||
padding: 20px;
|
gap: 24px;
|
||||||
background: #f0f7ff;
|
}
|
||||||
|
|
||||||
.previewTitle {
|
.aiRewriteModalCompareSection {
|
||||||
font-size: 14px;
|
display: flex;
|
||||||
font-weight: 500;
|
flex-direction: column;
|
||||||
color: #1a1a1a;
|
gap: 24px;
|
||||||
margin-bottom: 12px;
|
padding: 24px;
|
||||||
|
background: linear-gradient(135deg, #fafafa 0%, #f5f5f5 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiRewriteModalDivider {
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
#d9d9d9 20%,
|
||||||
|
#d9d9d9 80%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
margin: 12px 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 40px;
|
||||||
|
height: 1px;
|
||||||
|
background: #1890ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
&::after {
|
||||||
list-style: none;
|
content: "→";
|
||||||
padding: 0;
|
position: absolute;
|
||||||
margin: 0;
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: #fafafa;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #1890ff;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
li {
|
.aiRewriteModalSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiRewriteModalSectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
.aiRewriteModalSectionIcon {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiRewriteModalLabel {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiRewriteModalTextArea {
|
||||||
|
:global {
|
||||||
|
textarea.ant-input {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
transition: all 0.3s;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #666;
|
padding: 12px;
|
||||||
line-height: 1.8;
|
background: #f5f5f5 !important;
|
||||||
|
min-height: 80px !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #40a9ff;
|
||||||
|
background: #fafafa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #1890ff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||||
|
background: #ffffff !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
.aiRewriteModalOriginalText {
|
||||||
.step3Content {
|
padding: 20px !important;
|
||||||
.rightColumn {
|
background: #f5f5f5 !important;
|
||||||
width: 350px;
|
border: 1px solid #d9d9d9;
|
||||||
|
border-left: 4px solid #8c8c8c;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||||
|
margin-top: 4px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
min-height: 80px !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
border-color: #bfbfbf;
|
||||||
|
background: #fafafa !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiRewriteModalLoading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 20px;
|
||||||
|
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
||||||
|
border: 1px solid #bae6fd;
|
||||||
|
border-radius: 8px;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.aiRewriteModalLoadingIcon {
|
||||||
|
font-size: 32px;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiRewriteModalLoadingText {
|
||||||
|
color: #1890ff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiRewriteModalResultText {
|
||||||
|
padding: 20px;
|
||||||
|
background: #f0f9ff;
|
||||||
|
border: 1px solid #91d5ff;
|
||||||
|
border-left: 4px solid #1890ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15);
|
||||||
|
position: relative;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #bfbfbf;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiRewriteExecuteButton {
|
||||||
|
background-color: #ff6b35 !important;
|
||||||
|
border-color: #ff6b35 !important;
|
||||||
|
font-weight: 500;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 4px rgba(255, 107, 53, 0.2);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: #ff5722 !important;
|
||||||
|
border-color: #ff5722 !important;
|
||||||
|
box-shadow: 0 4px 8px rgba(255, 107, 53, 0.3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background-color: #e64a19 !important;
|
||||||
|
border-color: #e64a19 !important;
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 2px 4px rgba(255, 107, 53, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background-color: #d9d9d9 !important;
|
||||||
|
border-color: #d9d9d9 !important;
|
||||||
|
color: #bfbfbf !important;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmButton {
|
||||||
|
background-color: #07c160 !important;
|
||||||
|
border-color: #07c160 !important;
|
||||||
|
font-weight: 500;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 4px rgba(7, 193, 96, 0.2);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: #06ad56 !important;
|
||||||
|
border-color: #06ad56 !important;
|
||||||
|
box-shadow: 0 4px 8px rgba(7, 193, 96, 0.3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background-color: #059c4d !important;
|
||||||
|
border-color: #059c4d !important;
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 2px 4px rgba(7, 193, 96, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background-color: #d9d9d9 !important;
|
||||||
|
border-color: #d9d9d9 !important;
|
||||||
|
color: #bfbfbf !important;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Modal,
|
||||||
Slider,
|
|
||||||
Switch,
|
Switch,
|
||||||
message as antdMessage,
|
message as antdMessage,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
@@ -15,11 +14,15 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
SoundOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import type { CheckboxChangeEvent } from "antd/es/checkbox";
|
import type { CheckboxChangeEvent } from "antd/es/checkbox";
|
||||||
|
|
||||||
import styles from "./index.module.scss";
|
import styles from "./index.module.scss";
|
||||||
import { ContactItem, ScriptGroup } from "../../types";
|
import { ContactItem, ScriptGroup, MessageItem } from "../../types";
|
||||||
import InputMessage from "./InputMessage/InputMessage";
|
import InputMessage from "./InputMessage/InputMessage";
|
||||||
import ContentLibrarySelector from "./ContentLibrarySelector";
|
import ContentLibrarySelector from "./ContentLibrarySelector";
|
||||||
import type { ContentItem } from "@/components/ContentSelection/data";
|
import type { ContentItem } from "@/components/ContentSelection/data";
|
||||||
@@ -36,12 +39,6 @@ interface StepSendMessageProps {
|
|||||||
targetLabel: string;
|
targetLabel: string;
|
||||||
messageContent: string;
|
messageContent: string;
|
||||||
onMessageContentChange: (value: string) => void;
|
onMessageContentChange: (value: string) => void;
|
||||||
friendInterval: [number, number];
|
|
||||||
onFriendIntervalChange: (value: [number, number]) => void;
|
|
||||||
messageInterval: [number, number];
|
|
||||||
onMessageIntervalChange: (value: [number, number]) => void;
|
|
||||||
selectedTag: string;
|
|
||||||
onSelectedTagChange: (value: string) => void;
|
|
||||||
aiRewriteEnabled: boolean;
|
aiRewriteEnabled: boolean;
|
||||||
onAiRewriteToggle: (value: boolean) => void;
|
onAiRewriteToggle: (value: boolean) => void;
|
||||||
aiPrompt: string;
|
aiPrompt: string;
|
||||||
@@ -64,12 +61,6 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({
|
|||||||
targetLabel,
|
targetLabel,
|
||||||
messageContent,
|
messageContent,
|
||||||
onMessageContentChange,
|
onMessageContentChange,
|
||||||
friendInterval,
|
|
||||||
onFriendIntervalChange,
|
|
||||||
messageInterval,
|
|
||||||
onMessageIntervalChange,
|
|
||||||
selectedTag,
|
|
||||||
onSelectedTagChange,
|
|
||||||
aiRewriteEnabled,
|
aiRewriteEnabled,
|
||||||
onAiRewriteToggle,
|
onAiRewriteToggle,
|
||||||
aiPrompt,
|
aiPrompt,
|
||||||
@@ -88,47 +79,111 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({
|
|||||||
const [savingScriptGroup, setSavingScriptGroup] = useState(false);
|
const [savingScriptGroup, setSavingScriptGroup] = useState(false);
|
||||||
const [aiRewriting, setAiRewriting] = useState(false);
|
const [aiRewriting, setAiRewriting] = useState(false);
|
||||||
const [deletingGroupIds, setDeletingGroupIds] = useState<string[]>([]);
|
const [deletingGroupIds, setDeletingGroupIds] = useState<string[]>([]);
|
||||||
|
const [aiRewriteModalVisible, setAiRewriteModalVisible] = useState(false);
|
||||||
|
const [aiRewriteModalIndex, setAiRewriteModalIndex] = useState<number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [aiRewriteModalPrompt, setAiRewriteModalPrompt] = useState("");
|
||||||
|
const [aiRewritingMessage, setAiRewritingMessage] = useState(false);
|
||||||
|
const [aiRewriteResult, setAiRewriteResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 将 string[] 转换为 MessageItem[]
|
||||||
|
const messagesToItems = useCallback((messages: string[]): MessageItem[] => {
|
||||||
|
return messages.map(msg => {
|
||||||
|
try {
|
||||||
|
// 尝试解析为 JSON(新格式)
|
||||||
|
const parsed = JSON.parse(msg);
|
||||||
|
if (parsed && typeof parsed === "object" && "type" in parsed) {
|
||||||
|
return parsed as MessageItem;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 解析失败,作为文本消息处理
|
||||||
|
}
|
||||||
|
// 旧格式:纯文本
|
||||||
|
return { type: "text", content: msg };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 将 MessageItem[] 转换为 string[]
|
||||||
|
const itemsToMessages = useCallback((items: MessageItem[]): string[] => {
|
||||||
|
return items.map(item => {
|
||||||
|
// 如果是纯文本消息,直接返回内容(保持向后兼容)
|
||||||
|
if (item.type === "text" && !item.fileName) {
|
||||||
|
return item.content;
|
||||||
|
}
|
||||||
|
// 其他类型序列化为 JSON
|
||||||
|
return JSON.stringify(item);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 内部维护的 MessageItem[] 状态
|
||||||
|
const [messageItems, setMessageItems] = useState<MessageItem[]>(() =>
|
||||||
|
messagesToItems(currentScriptMessages),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当 currentScriptMessages 变化时,同步更新 messageItems
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMessageItems(messagesToItems(currentScriptMessages));
|
||||||
|
}, [currentScriptMessages, messagesToItems]);
|
||||||
|
|
||||||
const handleAddMessage = useCallback(
|
const handleAddMessage = useCallback(
|
||||||
(content?: string, showSuccess?: boolean) => {
|
(content?: string | MessageItem, showSuccess?: boolean) => {
|
||||||
const finalContent = (content ?? messageContent).trim();
|
let newItem: MessageItem;
|
||||||
if (!finalContent) {
|
if (typeof content === "string") {
|
||||||
antdMessage.warning("请输入消息内容");
|
const finalContent = (content || messageContent).trim();
|
||||||
return;
|
if (!finalContent) {
|
||||||
|
antdMessage.warning("请输入消息内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newItem = { type: "text", content: finalContent };
|
||||||
|
} else if (content && typeof content === "object") {
|
||||||
|
newItem = content;
|
||||||
|
} else {
|
||||||
|
const finalContent = messageContent.trim();
|
||||||
|
if (!finalContent) {
|
||||||
|
antdMessage.warning("请输入消息内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newItem = { type: "text", content: finalContent };
|
||||||
}
|
}
|
||||||
onCurrentScriptMessagesChange([...currentScriptMessages, finalContent]);
|
|
||||||
|
const newItems = [...messageItems, newItem];
|
||||||
|
setMessageItems(newItems);
|
||||||
|
onCurrentScriptMessagesChange(itemsToMessages(newItems));
|
||||||
onMessageContentChange("");
|
onMessageContentChange("");
|
||||||
if (showSuccess) {
|
if (showSuccess) {
|
||||||
antdMessage.success("已添加消息内容");
|
antdMessage.success("已添加消息内容");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
currentScriptMessages,
|
|
||||||
messageContent,
|
messageContent,
|
||||||
|
messageItems,
|
||||||
onCurrentScriptMessagesChange,
|
onCurrentScriptMessagesChange,
|
||||||
onMessageContentChange,
|
onMessageContentChange,
|
||||||
|
itemsToMessages,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemoveMessage = useCallback(
|
const handleRemoveMessage = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
const next = currentScriptMessages.filter((_, idx) => idx !== index);
|
const next = messageItems.filter((_, idx) => idx !== index);
|
||||||
onCurrentScriptMessagesChange(next);
|
setMessageItems(next);
|
||||||
|
onCurrentScriptMessagesChange(itemsToMessages(next));
|
||||||
},
|
},
|
||||||
[currentScriptMessages, onCurrentScriptMessagesChange],
|
[messageItems, onCurrentScriptMessagesChange, itemsToMessages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSaveScriptGroup = useCallback(async () => {
|
const handleSaveScriptGroup = useCallback(async () => {
|
||||||
if (savingScriptGroup) {
|
if (savingScriptGroup) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentScriptMessages.length === 0) {
|
if (messageItems.length === 0) {
|
||||||
antdMessage.warning("请先添加消息内容");
|
antdMessage.warning("请先添加消息内容");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const groupName =
|
const groupName =
|
||||||
currentScriptName.trim() || `话术组${savedScriptGroups.length + 1}`;
|
currentScriptName.trim() || `话术组${savedScriptGroups.length + 1}`;
|
||||||
const messages = [...currentScriptMessages];
|
const messages = itemsToMessages(messageItems);
|
||||||
const params: CreateContentLibraryParams = {
|
const params: CreateContentLibraryParams = {
|
||||||
name: groupName,
|
name: groupName,
|
||||||
sourceType: 1,
|
sourceType: 1,
|
||||||
@@ -155,6 +210,7 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({
|
|||||||
messages,
|
messages,
|
||||||
};
|
};
|
||||||
onSavedScriptGroupsChange([...savedScriptGroups, newGroup]);
|
onSavedScriptGroupsChange([...savedScriptGroups, newGroup]);
|
||||||
|
setMessageItems([]);
|
||||||
onCurrentScriptMessagesChange([]);
|
onCurrentScriptMessagesChange([]);
|
||||||
onCurrentScriptNameChange("");
|
onCurrentScriptNameChange("");
|
||||||
onMessageContentChange("");
|
onMessageContentChange("");
|
||||||
@@ -169,7 +225,7 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({
|
|||||||
}, [
|
}, [
|
||||||
aiPrompt,
|
aiPrompt,
|
||||||
aiRewriteEnabled,
|
aiRewriteEnabled,
|
||||||
currentScriptMessages,
|
messageItems,
|
||||||
currentScriptName,
|
currentScriptName,
|
||||||
onCurrentScriptMessagesChange,
|
onCurrentScriptMessagesChange,
|
||||||
onCurrentScriptNameChange,
|
onCurrentScriptNameChange,
|
||||||
@@ -177,6 +233,7 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({
|
|||||||
onSavedScriptGroupsChange,
|
onSavedScriptGroupsChange,
|
||||||
savedScriptGroups,
|
savedScriptGroups,
|
||||||
savingScriptGroup,
|
savingScriptGroup,
|
||||||
|
itemsToMessages,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleAiRewrite = useCallback(async () => {
|
const handleAiRewrite = useCallback(async () => {
|
||||||
@@ -272,6 +329,138 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({
|
|||||||
onMessageContentChange,
|
onMessageContentChange,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleOpenAiRewriteModal = useCallback((index: number) => {
|
||||||
|
setAiRewriteModalIndex(index);
|
||||||
|
setAiRewriteModalPrompt("");
|
||||||
|
setAiRewriteModalVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseAiRewriteModal = useCallback(() => {
|
||||||
|
setAiRewriteModalVisible(false);
|
||||||
|
setAiRewriteModalIndex(null);
|
||||||
|
setAiRewriteModalPrompt("");
|
||||||
|
setAiRewriteResult(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 执行 AI 改写,获取结果但不立即应用
|
||||||
|
const handleAiRewriteExecute = useCallback(async () => {
|
||||||
|
if (aiRewriteModalIndex === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmedPrompt = aiRewriteModalPrompt.trim();
|
||||||
|
if (!trimmedPrompt) {
|
||||||
|
antdMessage.warning("请输入改写提示词");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const messageToRewrite = messageItems[aiRewriteModalIndex];
|
||||||
|
if (!messageToRewrite) {
|
||||||
|
antdMessage.error("消息不存在");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// AI改写只支持文本消息
|
||||||
|
if (messageToRewrite.type !== "text") {
|
||||||
|
antdMessage.warning("AI改写仅支持文本消息");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (aiRewritingMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setAiRewritingMessage(true);
|
||||||
|
const response = await aiEditContent({
|
||||||
|
aiPrompt: trimmedPrompt,
|
||||||
|
content: messageToRewrite.content,
|
||||||
|
});
|
||||||
|
const normalizedResponse = response as {
|
||||||
|
content?: string;
|
||||||
|
contentAfter?: string;
|
||||||
|
contentFront?: string;
|
||||||
|
data?:
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
content?: string;
|
||||||
|
contentAfter?: string;
|
||||||
|
contentFront?: string;
|
||||||
|
};
|
||||||
|
result?: string;
|
||||||
|
};
|
||||||
|
const dataField = normalizedResponse?.data;
|
||||||
|
const dataContent =
|
||||||
|
typeof dataField === "string"
|
||||||
|
? dataField
|
||||||
|
: (dataField?.content ?? undefined);
|
||||||
|
const dataContentAfter =
|
||||||
|
typeof dataField === "string" ? undefined : dataField?.contentAfter;
|
||||||
|
const dataContentFront =
|
||||||
|
typeof dataField === "string" ? undefined : dataField?.contentFront;
|
||||||
|
|
||||||
|
const primaryAfter =
|
||||||
|
normalizedResponse?.contentAfter ?? dataContentAfter ?? undefined;
|
||||||
|
const primaryFront =
|
||||||
|
normalizedResponse?.contentFront ?? dataContentFront ?? undefined;
|
||||||
|
|
||||||
|
let rewrittenContent = "";
|
||||||
|
if (typeof response === "string") {
|
||||||
|
rewrittenContent = response;
|
||||||
|
} else if (primaryAfter) {
|
||||||
|
rewrittenContent = primaryFront
|
||||||
|
? `${primaryFront}\n${primaryAfter}`
|
||||||
|
: primaryAfter;
|
||||||
|
} else if (typeof normalizedResponse?.content === "string") {
|
||||||
|
rewrittenContent = normalizedResponse.content;
|
||||||
|
} else if (typeof dataContent === "string") {
|
||||||
|
rewrittenContent = dataContent;
|
||||||
|
} else if (typeof normalizedResponse?.result === "string") {
|
||||||
|
rewrittenContent = normalizedResponse.result;
|
||||||
|
} else if (primaryFront) {
|
||||||
|
rewrittenContent = primaryFront;
|
||||||
|
}
|
||||||
|
if (!rewrittenContent || typeof rewrittenContent !== "string") {
|
||||||
|
antdMessage.error("AI改写失败,请稍后重试");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAiRewriteResult(rewrittenContent.trim());
|
||||||
|
} catch (error) {
|
||||||
|
console.error("AI改写失败:", error);
|
||||||
|
antdMessage.error("AI改写失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
setAiRewritingMessage(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
aiRewriteModalIndex,
|
||||||
|
aiRewriteModalPrompt,
|
||||||
|
messageItems,
|
||||||
|
aiRewritingMessage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 确认并应用 AI 改写结果
|
||||||
|
const handleConfirmAiRewrite = useCallback(() => {
|
||||||
|
if (aiRewriteModalIndex === null || !aiRewriteResult) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const messageToRewrite = messageItems[aiRewriteModalIndex];
|
||||||
|
if (!messageToRewrite) {
|
||||||
|
antdMessage.error("消息不存在");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newItems = [...messageItems];
|
||||||
|
newItems[aiRewriteModalIndex] = {
|
||||||
|
...messageToRewrite,
|
||||||
|
content: aiRewriteResult,
|
||||||
|
};
|
||||||
|
setMessageItems(newItems);
|
||||||
|
onCurrentScriptMessagesChange(itemsToMessages(newItems));
|
||||||
|
handleCloseAiRewriteModal();
|
||||||
|
antdMessage.success("AI改写完成");
|
||||||
|
}, [
|
||||||
|
aiRewriteModalIndex,
|
||||||
|
aiRewriteResult,
|
||||||
|
messageItems,
|
||||||
|
onCurrentScriptMessagesChange,
|
||||||
|
itemsToMessages,
|
||||||
|
handleCloseAiRewriteModal,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleApplyGroup = useCallback(
|
const handleApplyGroup = useCallback(
|
||||||
(group: ScriptGroup) => {
|
(group: ScriptGroup) => {
|
||||||
onCurrentScriptMessagesChange(group.messages);
|
onCurrentScriptMessagesChange(group.messages);
|
||||||
@@ -352,61 +541,167 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({
|
|||||||
<div className={styles.stepContent}>
|
<div className={styles.stepContent}>
|
||||||
<div className={styles.step3Content}>
|
<div className={styles.step3Content}>
|
||||||
<div className={styles.leftColumn}>
|
<div className={styles.leftColumn}>
|
||||||
|
{/* 1. 模拟推送内容 */}
|
||||||
<div className={styles.previewHeader}>
|
<div className={styles.previewHeader}>
|
||||||
<div className={styles.previewHeaderTitle}>模拟推送内容</div>
|
<div className={styles.previewHeaderTitle}>模拟推送内容</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. 消息列表 */}
|
||||||
|
<div className={styles.messagePreview}>
|
||||||
|
{messageItems.length === 0 ? (
|
||||||
|
<div className={styles.messagePlaceholder}>
|
||||||
|
开始添加消息内容...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.messageList}>
|
||||||
|
{messageItems.map((msgItem, index) => (
|
||||||
|
<div className={styles.messageBubbleWrapper} key={index}>
|
||||||
|
<div className={styles.messageBubble}>
|
||||||
|
<div className={styles.messageAvatar}>
|
||||||
|
<UserOutlined />
|
||||||
|
</div>
|
||||||
|
<div className={styles.messageContent}>
|
||||||
|
<div className={styles.messageBubbleInner}>
|
||||||
|
{msgItem.type === "text" && (
|
||||||
|
<div className={styles.messageText}>
|
||||||
|
{msgItem.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msgItem.type === "image" && (
|
||||||
|
<div className={styles.messageMedia}>
|
||||||
|
<div className={styles.messageMediaIcon}>
|
||||||
|
<PictureOutlined />
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src={msgItem.content}
|
||||||
|
alt={msgItem.fileName || "图片"}
|
||||||
|
className={styles.messageImage}
|
||||||
|
onError={e => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{msgItem.fileName && (
|
||||||
|
<div className={styles.messageFileName}>
|
||||||
|
{msgItem.fileName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msgItem.type === "file" && (
|
||||||
|
<div className={styles.messageMedia}>
|
||||||
|
<div className={styles.messageMediaIcon}>
|
||||||
|
<FileOutlined />
|
||||||
|
</div>
|
||||||
|
<div className={styles.messageFileInfo}>
|
||||||
|
<div className={styles.messageFileName}>
|
||||||
|
{msgItem.fileName || "文件"}
|
||||||
|
</div>
|
||||||
|
{msgItem.fileSize && (
|
||||||
|
<div className={styles.messageFileSize}>
|
||||||
|
{msgItem.fileSize >= 1024 * 1024
|
||||||
|
? `${(msgItem.fileSize / 1024 / 1024).toFixed(2)} MB`
|
||||||
|
: `${(msgItem.fileSize / 1024).toFixed(2)} KB`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msgItem.type === "audio" && (
|
||||||
|
<div className={styles.messageMedia}>
|
||||||
|
<div className={styles.messageMediaIcon}>
|
||||||
|
<SoundOutlined />
|
||||||
|
</div>
|
||||||
|
<div className={styles.messageFileInfo}>
|
||||||
|
<div className={styles.messageFileName}>
|
||||||
|
{msgItem.fileName || "语音消息"}
|
||||||
|
</div>
|
||||||
|
{msgItem.durationMs && (
|
||||||
|
<div className={styles.messageFileSize}>
|
||||||
|
{Math.floor(msgItem.durationMs / 1000)}秒
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.messageActions}>
|
||||||
|
{msgItem.type === "text" && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => handleOpenAiRewriteModal(index)}
|
||||||
|
className={styles.aiRewriteButton}
|
||||||
|
>
|
||||||
|
AI改写
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => handleRemoveMessage(index)}
|
||||||
|
className={styles.messageAction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. 消息输入组件 */}
|
||||||
|
<div className={styles.messageInputArea}>
|
||||||
|
<InputMessage
|
||||||
|
defaultValue={messageContent}
|
||||||
|
onContentChange={onMessageContentChange}
|
||||||
|
onSend={value => handleAddMessage(value)}
|
||||||
|
onAddMessage={message => handleAddMessage(message)}
|
||||||
|
clearOnSend
|
||||||
|
placeholder="请输入内容"
|
||||||
|
hint={`按ENTER发送,按住CTRL+ENTER换行,已配置${savedScriptGroups.length}个话术组,已选择${selectedScriptGroupIds.length}个进行推送,已选${selectedContentLibraries.length}个内容库`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4. 话术组标题 */}
|
||||||
|
<div className={styles.scriptNameInput}>
|
||||||
|
<Input
|
||||||
|
placeholder="话术组名称(可选)"
|
||||||
|
value={currentScriptName}
|
||||||
|
onChange={event => onCurrentScriptNameChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5. 创建话术组按钮 */}
|
||||||
|
<div className={styles.createScriptGroupButton}>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={handleSaveScriptGroup}
|
onClick={handleSaveScriptGroup}
|
||||||
disabled={currentScriptMessages.length === 0 || savingScriptGroup}
|
disabled={currentScriptMessages.length === 0 || savingScriptGroup}
|
||||||
loading={savingScriptGroup}
|
loading={savingScriptGroup}
|
||||||
|
block
|
||||||
>
|
>
|
||||||
保存为话术组
|
创建为话术组
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.messagePreview}>
|
</div>
|
||||||
<div className={styles.messageBubble}>
|
|
||||||
<div className={styles.currentEditingLabel}>当前编辑话术</div>
|
|
||||||
{currentScriptMessages.length === 0 ? (
|
|
||||||
<div className={styles.messagePlaceholder}>
|
|
||||||
开始添加消息内容...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.messageList}>
|
|
||||||
{currentScriptMessages.map((msg, index) => (
|
|
||||||
<div className={styles.messageItem} key={index}>
|
|
||||||
<div className={styles.messageText}>{msg}</div>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
onClick={() => handleRemoveMessage(index)}
|
|
||||||
className={styles.messageAction}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.scriptNameInput}>
|
|
||||||
<Input
|
|
||||||
placeholder="话术组名称(可选)"
|
|
||||||
value={currentScriptName}
|
|
||||||
onChange={event =>
|
|
||||||
onCurrentScriptNameChange(event.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.savedScriptGroups}>
|
<div className={styles.rightColumn}>
|
||||||
{/* 内容库选择组件 */}
|
<div className={styles.pushContentHeader}>
|
||||||
|
<div className={styles.pushContentTitle}>推送内容</div>
|
||||||
<ContentLibrarySelector
|
<ContentLibrarySelector
|
||||||
selectedContentLibraries={selectedContentLibraries}
|
selectedContentLibraries={selectedContentLibraries}
|
||||||
onSelectedContentLibrariesChange={
|
onSelectedContentLibrariesChange={
|
||||||
onSelectedContentLibrariesChange
|
onSelectedContentLibrariesChange
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.savedScriptGroups}>
|
||||||
<div className={styles.scriptGroupHeaderRow}>
|
<div className={styles.scriptGroupHeaderRow}>
|
||||||
<div className={styles.scriptGroupTitle}>
|
<div className={styles.scriptGroupTitle}>
|
||||||
已保存话术组 ({savedScriptGroups.length})
|
已保存话术组 ({savedScriptGroups.length})
|
||||||
@@ -451,137 +746,119 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.scriptGroupContent}>
|
|
||||||
{group.messages[0]}
|
|
||||||
{group.messages.length > 1 && " ..."}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.messageInputArea}>
|
|
||||||
<InputMessage
|
|
||||||
defaultValue={messageContent}
|
|
||||||
onContentChange={onMessageContentChange}
|
|
||||||
onSend={value => handleAddMessage(value)}
|
|
||||||
clearOnSend
|
|
||||||
placeholder="请输入内容"
|
|
||||||
hint={`按住CTRL+ENTER换行,已配置${savedScriptGroups.length}个话术组,已选择${selectedScriptGroupIds.length}个进行推送,已选${selectedContentLibraries.length}个内容库`}
|
|
||||||
/>
|
|
||||||
<div className={styles.aiRewriteSection}>
|
|
||||||
<div className={styles.aiRewriteToggle}>
|
|
||||||
<Switch
|
|
||||||
checked={aiRewriteEnabled}
|
|
||||||
onChange={onAiRewriteToggle}
|
|
||||||
/>
|
|
||||||
<div className={styles.aiRewriteLabel}>AI智能话术改写</div>
|
|
||||||
<div>
|
|
||||||
{aiRewriteEnabled && (
|
|
||||||
<Input
|
|
||||||
placeholder="输入改写提示词"
|
|
||||||
value={aiPrompt}
|
|
||||||
onChange={event => onAiPromptChange(event.target.value)}
|
|
||||||
className={styles.aiRewriteInput}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.aiRewriteActions}>
|
|
||||||
<Button
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
onClick={handleAiRewrite}
|
|
||||||
disabled={!aiRewriteEnabled}
|
|
||||||
loading={aiRewriting}
|
|
||||||
className={styles.aiRewriteButton}
|
|
||||||
>
|
|
||||||
AI改写
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => handleAddMessage(undefined, true)}
|
|
||||||
>
|
|
||||||
添加
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.rightColumn}>
|
|
||||||
<div className={styles.settingsPanel}>
|
|
||||||
<div className={styles.settingsTitle}>相关设置</div>
|
|
||||||
<div className={styles.settingItem}>
|
|
||||||
<div className={styles.settingLabel}>好友间间隔</div>
|
|
||||||
<div className={styles.settingControl}>
|
|
||||||
<span>间隔时间(秒)</span>
|
|
||||||
<Slider
|
|
||||||
range
|
|
||||||
min={1}
|
|
||||||
max={60}
|
|
||||||
value={friendInterval}
|
|
||||||
onChange={value =>
|
|
||||||
onFriendIntervalChange(value as [number, number])
|
|
||||||
}
|
|
||||||
style={{ flex: 1, margin: "0 16px" }}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{friendInterval[0]} - {friendInterval[1]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.settingItem}>
|
|
||||||
<div className={styles.settingLabel}>消息间间隔</div>
|
|
||||||
<div className={styles.settingControl}>
|
|
||||||
<span>间隔时间(秒)</span>
|
|
||||||
<Slider
|
|
||||||
range
|
|
||||||
min={1}
|
|
||||||
max={60}
|
|
||||||
value={messageInterval}
|
|
||||||
onChange={value =>
|
|
||||||
onMessageIntervalChange(value as [number, number])
|
|
||||||
}
|
|
||||||
style={{ flex: 1, margin: "0 16px" }}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{messageInterval[0]} - {messageInterval[1]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.tagSection}>
|
|
||||||
<div className={styles.settingLabel}>完成打标签</div>
|
|
||||||
<Select
|
|
||||||
value={selectedTag}
|
|
||||||
onChange={onSelectedTagChange}
|
|
||||||
placeholder="选择标签"
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
|
||||||
<Select.Option value="potential">潜在客户</Select.Option>
|
|
||||||
<Select.Option value="customer">客户</Select.Option>
|
|
||||||
<Select.Option value="partner">合作伙伴</Select.Option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.pushPreview}>
|
|
||||||
<div className={styles.previewTitle}>推送预览</div>
|
|
||||||
<ul>
|
|
||||||
<li>推送账号: {selectedAccounts.length}个</li>
|
|
||||||
<li>
|
|
||||||
推送{targetLabel}: {selectedContacts.length}个
|
|
||||||
</li>
|
|
||||||
<li>话术组数: {savedScriptGroups.length}个</li>
|
|
||||||
<li>随机推送: 否</li>
|
|
||||||
<li>预计耗时: ~1分钟</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AI改写弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div className={styles.aiRewriteModalTitle}>
|
||||||
|
<span className={styles.aiRewriteModalTitleIcon}>✨</span>
|
||||||
|
<span>AI智能改写</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
open={aiRewriteModalVisible}
|
||||||
|
onCancel={handleCloseAiRewriteModal}
|
||||||
|
width={680}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={handleCloseAiRewriteModal}>
|
||||||
|
取消
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="execute"
|
||||||
|
type="primary"
|
||||||
|
className={styles.aiRewriteExecuteButton}
|
||||||
|
loading={aiRewritingMessage}
|
||||||
|
onClick={handleAiRewriteExecute}
|
||||||
|
disabled={!aiRewriteModalPrompt.trim()}
|
||||||
|
>
|
||||||
|
AI改写
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="confirm"
|
||||||
|
type="primary"
|
||||||
|
className={styles.confirmButton}
|
||||||
|
onClick={handleConfirmAiRewrite}
|
||||||
|
disabled={!aiRewriteResult || aiRewritingMessage}
|
||||||
|
>
|
||||||
|
确认改写
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
className={styles.aiRewriteModal}
|
||||||
|
wrapClassName={styles.aiRewriteModalWrap}
|
||||||
|
>
|
||||||
|
<div className={styles.aiRewriteModalContent}>
|
||||||
|
{/* 原文和结果对比区域 */}
|
||||||
|
<div className={styles.aiRewriteModalCompareSection}>
|
||||||
|
{/* 原消息内容区域 */}
|
||||||
|
{aiRewriteModalIndex !== null && (
|
||||||
|
<div className={styles.aiRewriteModalSection}>
|
||||||
|
<div className={styles.aiRewriteModalSectionHeader}>
|
||||||
|
<span className={styles.aiRewriteModalSectionIcon}>📝</span>
|
||||||
|
<span className={styles.aiRewriteModalLabel}>
|
||||||
|
1原消息内容
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.aiRewriteModalOriginalText}>
|
||||||
|
{messageItems[aiRewriteModalIndex]?.type === "text"
|
||||||
|
? messageItems[aiRewriteModalIndex].content
|
||||||
|
: "非文本消息不支持AI改写"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading 状态 */}
|
||||||
|
{aiRewritingMessage && (
|
||||||
|
<div className={styles.aiRewriteModalLoading}>
|
||||||
|
<div className={styles.aiRewriteModalLoadingIcon}>⏳</div>
|
||||||
|
<div className={styles.aiRewriteModalLoadingText}>
|
||||||
|
AI正在改写中,请稍候...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 分隔线 */}
|
||||||
|
{aiRewriteModalIndex !== null && aiRewriteResult && (
|
||||||
|
<div className={styles.aiRewriteModalDivider} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 改写结果区域 */}
|
||||||
|
{aiRewriteResult && (
|
||||||
|
<div className={styles.aiRewriteModalSection}>
|
||||||
|
<div className={styles.aiRewriteModalSectionHeader}>
|
||||||
|
<span className={styles.aiRewriteModalSectionIcon}>✨</span>
|
||||||
|
<span className={styles.aiRewriteModalLabel}>改写结果</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.aiRewriteModalResultText}>
|
||||||
|
{aiRewriteResult}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提示词输入区域 - 放在最下面 */}
|
||||||
|
<div className={styles.aiRewriteModalSection}>
|
||||||
|
<div className={styles.aiRewriteModalSectionHeader}>
|
||||||
|
<span className={styles.aiRewriteModalSectionIcon}>💡</span>
|
||||||
|
<span className={styles.aiRewriteModalLabel}>改写提示词</span>
|
||||||
|
</div>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder="默认提示词为: 1、原本的字数和意思不要修改超过10% 2、出现品牌名或个人名字就去除。"
|
||||||
|
value={aiRewriteModalPrompt}
|
||||||
|
onChange={event => setAiRewriteModalPrompt(event.target.value)}
|
||||||
|
rows={3}
|
||||||
|
autoFocus
|
||||||
|
disabled={aiRewritingMessage}
|
||||||
|
className={styles.aiRewriteModalTextArea}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import { getCustomerList } from "@/pages/pc/ckbox/weChat/api";
|
|||||||
import StepSelectAccount from "./components/StepSelectAccount";
|
import StepSelectAccount from "./components/StepSelectAccount";
|
||||||
import StepSelectContacts from "./components/StepSelectContacts";
|
import StepSelectContacts from "./components/StepSelectContacts";
|
||||||
import StepSendMessage from "./components/StepSendMessage";
|
import StepSendMessage from "./components/StepSendMessage";
|
||||||
import { ContactItem, PushType, ScriptGroup } from "./types";
|
import StepPushParams from "./components/StepPushParams";
|
||||||
|
import {
|
||||||
|
ContactItem,
|
||||||
|
PushType,
|
||||||
|
ScriptGroup,
|
||||||
|
CreatePushTaskPayload,
|
||||||
|
} from "./types";
|
||||||
import StepIndicator from "@/components/StepIndicator";
|
import StepIndicator from "@/components/StepIndicator";
|
||||||
import type { ContentItem } from "@/components/ContentSelection/data";
|
import type { ContentItem } from "@/components/ContentSelection/data";
|
||||||
import type { PoolSelectionItem } from "@/components/PoolSelection/data";
|
import type { PoolSelectionItem } from "@/components/PoolSelection/data";
|
||||||
@@ -163,6 +169,20 @@ const CreatePushTask: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCurrentStep(3);
|
setCurrentStep(3);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 3) {
|
||||||
|
// 验证推送内容
|
||||||
|
if (
|
||||||
|
currentScriptMessages.length === 0 &&
|
||||||
|
selectedScriptGroupIds.length === 0 &&
|
||||||
|
selectedContentLibraries.length === 0
|
||||||
|
) {
|
||||||
|
message.warning("请至少添加一条消息、选择一个话术组或内容库");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentStep(4);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,9 +204,12 @@ const CreatePushTask: React.FC = () => {
|
|||||||
if (creatingTask) {
|
if (creatingTask) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 1. 数据验证和准备 ==========
|
||||||
const selectedGroups = savedScriptGroups.filter(group =>
|
const selectedGroups = savedScriptGroups.filter(group =>
|
||||||
selectedScriptGroupIds.includes(group.id),
|
selectedScriptGroupIds.includes(group.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
currentScriptMessages.length === 0 &&
|
currentScriptMessages.length === 0 &&
|
||||||
selectedGroups.length === 0 &&
|
selectedGroups.length === 0 &&
|
||||||
@@ -195,19 +218,27 @@ const CreatePushTask: React.FC = () => {
|
|||||||
message.warning("请添加话术内容、选择话术组或内容库");
|
message.warning("请添加话术内容、选择话术组或内容库");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 手动消息处理
|
||||||
const manualMessages = currentScriptMessages
|
const manualMessages = currentScriptMessages
|
||||||
.map(item => item.trim())
|
.map(item => item.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (validPushType === "group-announcement" && manualMessages.length === 0) {
|
if (validPushType === "group-announcement" && manualMessages.length === 0) {
|
||||||
message.warning("请先填写公告内容");
|
message.warning("请先填写公告内容");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const toNumberId = (value: unknown) => {
|
|
||||||
|
// ID 转换工具函数
|
||||||
|
const toNumberId = (value: unknown): number | null => {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
return Number.isFinite(numeric) && !Number.isNaN(numeric)
|
return Number.isFinite(numeric) && !Number.isNaN(numeric)
|
||||||
? numeric
|
? numeric
|
||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ========== 2. 内容库ID处理 ==========
|
||||||
const contentGroupIds = Array.from(
|
const contentGroupIds = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
[
|
[
|
||||||
@@ -220,6 +251,7 @@ const CreatePushTask: React.FC = () => {
|
|||||||
].filter((id): id is number => id !== null),
|
].filter((id): id is number => id !== null),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
manualMessages.length === 0 &&
|
manualMessages.length === 0 &&
|
||||||
selectedGroups.length === 0 &&
|
selectedGroups.length === 0 &&
|
||||||
@@ -228,6 +260,8 @@ const CreatePushTask: React.FC = () => {
|
|||||||
message.warning("缺少有效的话术内容,请重新检查");
|
message.warning("缺少有效的话术内容,请重新检查");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 3. 账号ID处理 ==========
|
||||||
const ownerWechatIds = Array.from(
|
const ownerWechatIds = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
selectedAccounts
|
selectedAccounts
|
||||||
@@ -235,47 +269,25 @@ const CreatePushTask: React.FC = () => {
|
|||||||
.filter((id): id is number => id !== null),
|
.filter((id): id is number => id !== null),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (ownerWechatIds.length === 0) {
|
if (ownerWechatIds.length === 0) {
|
||||||
message.error("缺少有效的推送账号信息");
|
message.error("缺少有效的推送账号信息");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 4. 联系人ID处理 ==========
|
||||||
const selectedContactIds = Array.from(
|
const selectedContactIds = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
selectedContacts.map(contact => contact?.id).filter(isValidNumber),
|
selectedContacts.map(contact => contact?.id).filter(isValidNumber),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selectedContactIds.length === 0) {
|
if (selectedContactIds.length === 0) {
|
||||||
message.error("缺少有效的推送对象");
|
message.error("缺少有效的推送对象");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const friendIntervalMin = friendInterval[0];
|
|
||||||
const friendIntervalMax = friendInterval[1];
|
// ========== 5. 设备分组ID处理(好友推送必填) ==========
|
||||||
const messageIntervalMin = messageInterval[0];
|
|
||||||
const messageIntervalMax = messageInterval[1];
|
|
||||||
const trafficPoolIds = selectedTrafficPools
|
|
||||||
.map(pool => pool.id)
|
|
||||||
.filter(
|
|
||||||
id => id !== undefined && id !== null && String(id).trim() !== "",
|
|
||||||
);
|
|
||||||
const { startTime, endTime } = DEFAULT_TIME_RANGE[validPushType];
|
|
||||||
const maxPerDay =
|
|
||||||
selectedContacts.length > 0
|
|
||||||
? selectedContacts.length
|
|
||||||
: DEFAULT_MAX_PER_DAY[validPushType];
|
|
||||||
const pushOrder = DEFAULT_PUSH_ORDER[validPushType];
|
|
||||||
const normalizedPostPushTags =
|
|
||||||
selectedTag.trim().length > 0
|
|
||||||
? [
|
|
||||||
toNumberId(selectedTag) !== null
|
|
||||||
? (toNumberId(selectedTag) as number)
|
|
||||||
: selectedTag,
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
const taskName =
|
|
||||||
currentScriptName.trim() ||
|
|
||||||
selectedGroups[0]?.name ||
|
|
||||||
(manualMessages[0] ? manualMessages[0].slice(0, 20) : "") ||
|
|
||||||
`推送任务-${Date.now()}`;
|
|
||||||
const deviceGroupIds = Array.from(
|
const deviceGroupIds = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
selectedAccounts
|
selectedAccounts
|
||||||
@@ -283,84 +295,191 @@ const CreatePushTask: React.FC = () => {
|
|||||||
.filter((id): id is number => id !== null),
|
.filter((id): id is number => id !== null),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (validPushType === "friend-message" && deviceGroupIds.length === 0) {
|
if (validPushType === "friend-message" && deviceGroupIds.length === 0) {
|
||||||
message.error("缺少有效的推送设备分组");
|
message.error("缺少有效的推送设备分组");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const basePayload: Record<string, any> = {
|
// ========== 6. 流量池ID处理 ==========
|
||||||
name: taskName,
|
const trafficPoolIds = selectedTrafficPools
|
||||||
type: 3,
|
.map(pool => {
|
||||||
autoStart: DEFAULT_AUTO_START[validPushType],
|
const id = pool.id;
|
||||||
status: 1,
|
if (id === undefined || id === null) return null;
|
||||||
pushType: DEFAULT_PUSH_TYPE[validPushType],
|
const strId = String(id).trim();
|
||||||
|
return strId !== "" ? strId : null;
|
||||||
|
})
|
||||||
|
.filter((id): id is string => id !== null);
|
||||||
|
|
||||||
|
// ========== 7. 时间范围 ==========
|
||||||
|
const { startTime, endTime } = DEFAULT_TIME_RANGE[validPushType];
|
||||||
|
|
||||||
|
// ========== 8. 每日最大推送数 ==========
|
||||||
|
const maxPerDay =
|
||||||
|
selectedContacts.length > 0
|
||||||
|
? selectedContacts.length
|
||||||
|
: DEFAULT_MAX_PER_DAY[validPushType];
|
||||||
|
|
||||||
|
// ========== 9. 推送顺序 ==========
|
||||||
|
const pushOrder = DEFAULT_PUSH_ORDER[validPushType];
|
||||||
|
|
||||||
|
// ========== 10. 推送后标签处理 ==========
|
||||||
|
const postPushTags =
|
||||||
|
selectedTag.trim().length > 0
|
||||||
|
? (() => {
|
||||||
|
const tagId = toNumberId(selectedTag);
|
||||||
|
return tagId !== null ? [tagId] : [];
|
||||||
|
})()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// ========== 11. 任务名称 ==========
|
||||||
|
const taskName =
|
||||||
|
currentScriptName.trim() ||
|
||||||
|
selectedGroups[0]?.name ||
|
||||||
|
(manualMessages[0] ? manualMessages[0].slice(0, 20) : "") ||
|
||||||
|
`推送任务-${Date.now()}`;
|
||||||
|
|
||||||
|
// ========== 12. 构建基础载荷 ==========
|
||||||
|
const basePayload: CreatePushTaskPayload = {
|
||||||
|
name: String(taskName).trim(),
|
||||||
|
type: 3, // 固定值:工作台类型
|
||||||
|
autoStart: DEFAULT_AUTO_START[validPushType] ? 1 : 0,
|
||||||
|
status: 1, // 固定值:启用
|
||||||
|
pushType: DEFAULT_PUSH_TYPE[validPushType] ? 1 : 0,
|
||||||
targetType: validPushType === "friend-message" ? 2 : 1,
|
targetType: validPushType === "friend-message" ? 2 : 1,
|
||||||
groupPushSubType: validPushType === "group-announcement" ? 2 : 1,
|
groupPushSubType: validPushType === "group-announcement" ? 2 : 1,
|
||||||
startTime,
|
startTime: String(startTime),
|
||||||
endTime,
|
endTime: String(endTime),
|
||||||
maxPerDay,
|
maxPerDay: Number(maxPerDay),
|
||||||
pushOrder,
|
pushOrder: Number(pushOrder),
|
||||||
friendIntervalMin,
|
friendIntervalMin: Number(friendInterval[0]),
|
||||||
friendIntervalMax,
|
friendIntervalMax: Number(friendInterval[1]),
|
||||||
messageIntervalMin,
|
messageIntervalMin: Number(messageInterval[0]),
|
||||||
messageIntervalMax,
|
messageIntervalMax: Number(messageInterval[1]),
|
||||||
isRandomTemplate: selectedScriptGroupIds.length > 1 ? 1 : 0,
|
isRandomTemplate: selectedScriptGroupIds.length > 1 ? 1 : 0,
|
||||||
contentGroups: contentGroupIds,
|
contentGroups: contentGroupIds.length > 0 ? contentGroupIds : [],
|
||||||
postPushTags: normalizedPostPushTags,
|
postPushTags: postPushTags,
|
||||||
ownerWechatIds,
|
ownerWechatIds: ownerWechatIds,
|
||||||
enableAiRewrite: aiRewriteEnabled ? 1 : 0,
|
|
||||||
};
|
};
|
||||||
if (trafficPoolIds.length > 0) {
|
|
||||||
basePayload.trafficPools = trafficPoolIds;
|
// ========== 13. 根据推送类型添加特定字段 ==========
|
||||||
}
|
|
||||||
if (validPushType === "friend-message") {
|
|
||||||
basePayload.isLoop = 0;
|
|
||||||
basePayload.deviceGroups = deviceGroupIds;
|
|
||||||
}
|
|
||||||
if (manualMessages.length > 0) {
|
|
||||||
basePayload.manualMessages = manualMessages;
|
|
||||||
if (currentScriptName.trim()) {
|
|
||||||
basePayload.manualScriptName = currentScriptName.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (selectedScriptGroupIds.length > 0) {
|
|
||||||
basePayload.selectedScriptGroupIds = selectedScriptGroupIds;
|
|
||||||
}
|
|
||||||
if (aiRewriteEnabled && aiPrompt.trim()) {
|
|
||||||
basePayload.aiRewritePrompt = aiPrompt.trim();
|
|
||||||
}
|
|
||||||
if (selectedGroups.length > 0) {
|
|
||||||
basePayload.scriptGroups = selectedGroups.map(group => ({
|
|
||||||
id: group.id,
|
|
||||||
name: group.name,
|
|
||||||
messages: group.messages,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (validPushType === "friend-message") {
|
if (validPushType === "friend-message") {
|
||||||
|
// 好友推送特有字段
|
||||||
|
// 注意:wechatFriends 必须是字符串数组,不是数字数组
|
||||||
basePayload.wechatFriends = Array.from(
|
basePayload.wechatFriends = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
selectedContacts
|
selectedContacts
|
||||||
.map(contact => toNumberId(contact?.id))
|
.map(contact => {
|
||||||
.filter((id): id is number => id !== null),
|
const id = toNumberId(contact?.id);
|
||||||
|
return id !== null ? String(id) : null;
|
||||||
|
})
|
||||||
|
.filter((id): id is string => id !== null),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
basePayload.targetType = 2;
|
basePayload.deviceGroups = deviceGroupIds; // 必填,数字数组
|
||||||
|
basePayload.isLoop = 0; // 固定值
|
||||||
|
basePayload.targetType = 2; // 确保是好友类型
|
||||||
|
basePayload.groupPushSubType = 1; // 固定为群群发
|
||||||
} else {
|
} else {
|
||||||
|
// 群推送特有字段
|
||||||
const groupIds = Array.from(
|
const groupIds = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
selectedContacts
|
selectedContacts
|
||||||
.map(contact => toNumberId(contact.groupId ?? contact.id))
|
.map(contact => {
|
||||||
|
// 优先使用 groupId,如果没有则使用 id
|
||||||
|
const id = contact.groupId ?? contact.id;
|
||||||
|
return toNumberId(id);
|
||||||
|
})
|
||||||
.filter((id): id is number => id !== null),
|
.filter((id): id is number => id !== null),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
basePayload.wechatGroups = groupIds;
|
|
||||||
|
basePayload.wechatGroups = groupIds; // 数字数组
|
||||||
|
basePayload.targetType = 1; // 群类型
|
||||||
basePayload.groupPushSubType =
|
basePayload.groupPushSubType =
|
||||||
validPushType === "group-announcement" ? 2 : 1;
|
validPushType === "group-announcement" ? 2 : 1;
|
||||||
basePayload.targetType = 1;
|
|
||||||
|
// 群公告特有字段
|
||||||
if (validPushType === "group-announcement") {
|
if (validPushType === "group-announcement") {
|
||||||
basePayload.announcementContent = manualMessages.join("\n");
|
basePayload.announcementContent = manualMessages.join("\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 14. 可选字段处理 ==========
|
||||||
|
// 流量池(如果存在)
|
||||||
|
if (trafficPoolIds.length > 0) {
|
||||||
|
basePayload.trafficPools = trafficPoolIds; // 字符串数组
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动消息(如果存在)
|
||||||
|
if (manualMessages.length > 0) {
|
||||||
|
basePayload.manualMessages = manualMessages;
|
||||||
|
if (currentScriptName.trim()) {
|
||||||
|
basePayload.manualScriptName = String(currentScriptName.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中的话术组ID(如果存在)
|
||||||
|
if (selectedScriptGroupIds.length > 0) {
|
||||||
|
basePayload.selectedScriptGroupIds = selectedScriptGroupIds.map(id =>
|
||||||
|
String(id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI改写相关(如果启用)
|
||||||
|
if (aiRewriteEnabled) {
|
||||||
|
basePayload.enableAiRewrite = 1;
|
||||||
|
if (aiPrompt.trim()) {
|
||||||
|
basePayload.aiRewritePrompt = String(aiPrompt.trim());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
basePayload.enableAiRewrite = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 话术组对象(如果存在)
|
||||||
|
if (selectedGroups.length > 0) {
|
||||||
|
basePayload.scriptGroups = selectedGroups.map(group => ({
|
||||||
|
id: String(group.id),
|
||||||
|
name: String(group.name || ""),
|
||||||
|
messages: Array.isArray(group.messages)
|
||||||
|
? group.messages.map(msg => String(msg))
|
||||||
|
: [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 15. 数据验证和提交 ==========
|
||||||
|
// 最终验证:确保必填字段存在
|
||||||
|
if (validPushType === "friend-message") {
|
||||||
|
if (
|
||||||
|
!Array.isArray(basePayload.deviceGroups) ||
|
||||||
|
basePayload.deviceGroups.length === 0
|
||||||
|
) {
|
||||||
|
message.error("好友推送必须选择设备分组");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Array.isArray(basePayload.wechatFriends) ||
|
||||||
|
basePayload.wechatFriends.length === 0
|
||||||
|
) {
|
||||||
|
message.error("好友推送必须选择好友");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
!Array.isArray(basePayload.wechatGroups) ||
|
||||||
|
basePayload.wechatGroups.length === 0
|
||||||
|
) {
|
||||||
|
message.error("群推送必须选择群");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交前打印日志(开发环境)
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.log("提交数据:", JSON.stringify(basePayload, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 16. 提交请求 ==========
|
||||||
let hideLoading: ReturnType<typeof message.loading> | undefined;
|
let hideLoading: ReturnType<typeof message.loading> | undefined;
|
||||||
try {
|
try {
|
||||||
setCreatingTask(true);
|
setCreatingTask(true);
|
||||||
@@ -386,7 +505,7 @@ const CreatePushTask: React.FC = () => {
|
|||||||
<Layout
|
<Layout
|
||||||
header={
|
header={
|
||||||
<>
|
<>
|
||||||
<div style={{ padding: "20px" }}>
|
<div style={{ padding: "0 20px" }}>
|
||||||
<PowerNavigation
|
<PowerNavigation
|
||||||
title={title}
|
title={title}
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
@@ -395,26 +514,33 @@ const CreatePushTask: React.FC = () => {
|
|||||||
onBackClick={handleClose}
|
onBackClick={handleClose}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<StepIndicator
|
<div style={{ margin: "0 20px" }}>
|
||||||
currentStep={currentStep}
|
<StepIndicator
|
||||||
steps={[
|
currentStep={currentStep}
|
||||||
{
|
steps={[
|
||||||
id: 1,
|
{
|
||||||
title: "选择微信",
|
id: 1,
|
||||||
subtitle: "选择微信",
|
title: "选择微信",
|
||||||
},
|
subtitle: "选择微信",
|
||||||
{
|
},
|
||||||
id: 2,
|
{
|
||||||
title: `选择${step2Title}`,
|
id: 2,
|
||||||
subtitle: `选择${step2Title}`,
|
title: `选择${step2Title}`,
|
||||||
},
|
subtitle: `选择${step2Title}`,
|
||||||
{
|
},
|
||||||
id: 3,
|
{
|
||||||
title: "一键群发",
|
id: 3,
|
||||||
subtitle: "一键群发",
|
title: "推送内容",
|
||||||
},
|
subtitle: "推送内容",
|
||||||
]}
|
},
|
||||||
/>
|
{
|
||||||
|
id: 4,
|
||||||
|
title: "推送参数",
|
||||||
|
subtitle: "推送参数",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
@@ -434,6 +560,12 @@ const CreatePushTask: React.FC = () => {
|
|||||||
{selectedContacts.length}个
|
{selectedContacts.length}个
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{currentStep === 4 && (
|
||||||
|
<span>
|
||||||
|
推送账号: {selectedAccounts.length}个, 推送{step2Title}:{" "}
|
||||||
|
{selectedContacts.length}个
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.footerRight}>
|
<div className={styles.footerRight}>
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
@@ -458,6 +590,14 @@ const CreatePushTask: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
|
<>
|
||||||
|
<Button onClick={handlePrev}>上一步</Button>
|
||||||
|
<Button type="primary" onClick={handleNext}>
|
||||||
|
下一步 >
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{currentStep === 4 && (
|
||||||
<>
|
<>
|
||||||
<Button onClick={handlePrev}>上一步</Button>
|
<Button onClick={handlePrev}>上一步</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -511,16 +651,24 @@ const CreatePushTask: React.FC = () => {
|
|||||||
onSelectedScriptGroupIdsChange={setSelectedScriptGroupIds}
|
onSelectedScriptGroupIdsChange={setSelectedScriptGroupIds}
|
||||||
selectedContentLibraries={selectedContentLibraries}
|
selectedContentLibraries={selectedContentLibraries}
|
||||||
onSelectedContentLibrariesChange={setSelectedContentLibraries}
|
onSelectedContentLibrariesChange={setSelectedContentLibraries}
|
||||||
|
aiRewriteEnabled={aiRewriteEnabled}
|
||||||
|
onAiRewriteToggle={setAiRewriteEnabled}
|
||||||
|
aiPrompt={aiPrompt}
|
||||||
|
onAiPromptChange={setAiPrompt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 4 && (
|
||||||
|
<StepPushParams
|
||||||
|
selectedAccounts={selectedAccounts}
|
||||||
|
selectedContacts={selectedContacts}
|
||||||
|
targetLabel={step2Title}
|
||||||
friendInterval={friendInterval}
|
friendInterval={friendInterval}
|
||||||
onFriendIntervalChange={setFriendInterval}
|
onFriendIntervalChange={setFriendInterval}
|
||||||
messageInterval={messageInterval}
|
messageInterval={messageInterval}
|
||||||
onMessageIntervalChange={setMessageInterval}
|
onMessageIntervalChange={setMessageInterval}
|
||||||
selectedTag={selectedTag}
|
selectedTag={selectedTag}
|
||||||
onSelectedTagChange={setSelectedTag}
|
onSelectedTagChange={setSelectedTag}
|
||||||
aiRewriteEnabled={aiRewriteEnabled}
|
savedScriptGroups={savedScriptGroups}
|
||||||
onAiRewriteToggle={setAiRewriteEnabled}
|
|
||||||
aiPrompt={aiPrompt}
|
|
||||||
onAiPromptChange={setAiPrompt}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,8 +20,67 @@ export interface ContactItem {
|
|||||||
extendFields?: Record<string, any>;
|
extendFields?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 消息类型定义
|
||||||
|
export type MessageType = "text" | "image" | "file" | "audio";
|
||||||
|
|
||||||
|
export interface MessageItem {
|
||||||
|
type: MessageType;
|
||||||
|
content: string; // 文本内容或文件URL
|
||||||
|
// 文件相关字段
|
||||||
|
fileName?: string; // 文件名
|
||||||
|
fileSize?: number; // 文件大小(字节)
|
||||||
|
// 语音相关字段
|
||||||
|
durationMs?: number; // 语音时长(毫秒)
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScriptGroup {
|
export interface ScriptGroup {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
messages: string[];
|
messages: string[]; // 保持向后兼容,但实际应该使用 MessageItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接口请求载荷类型定义
|
||||||
|
export interface CreatePushTaskPayload {
|
||||||
|
// 基础字段
|
||||||
|
name: string;
|
||||||
|
type: 3; // 固定值:工作台类型
|
||||||
|
autoStart: 0 | 1;
|
||||||
|
status: 1; // 固定值:启用
|
||||||
|
pushType: 0 | 1; // 0=定时,1=立即
|
||||||
|
targetType: 1 | 2; // 1=群推送,2=好友推送
|
||||||
|
groupPushSubType: 1 | 2; // 1=群群发,2=群公告
|
||||||
|
startTime: string; // "HH:mm" 格式
|
||||||
|
endTime: string; // "HH:mm" 格式
|
||||||
|
maxPerDay: number;
|
||||||
|
pushOrder: 1 | 2; // 1=最早优先,2=最新优先
|
||||||
|
friendIntervalMin: number;
|
||||||
|
friendIntervalMax: number;
|
||||||
|
messageIntervalMin: number;
|
||||||
|
messageIntervalMax: number;
|
||||||
|
isRandomTemplate: 0 | 1;
|
||||||
|
contentGroups: number[]; // 内容库ID数组
|
||||||
|
postPushTags: number[]; // 推送后标签ID数组
|
||||||
|
ownerWechatIds: number[]; // 客服ID数组
|
||||||
|
|
||||||
|
// 好友推送特有字段
|
||||||
|
wechatFriends?: string[]; // 好友ID列表(字符串数组)
|
||||||
|
deviceGroups?: number[]; // 设备分组ID数组(好友推送时必填)
|
||||||
|
isLoop?: 0; // 固定值(好友推送时)
|
||||||
|
|
||||||
|
// 群推送特有字段
|
||||||
|
wechatGroups?: number[]; // 微信群ID数组
|
||||||
|
announcementContent?: string; // 群公告内容(群公告时必填)
|
||||||
|
|
||||||
|
// 可选字段
|
||||||
|
trafficPools?: string[]; // 流量池ID数组(字符串数组)
|
||||||
|
manualMessages?: string[]; // 手动消息数组
|
||||||
|
manualScriptName?: string; // 手动话术名称
|
||||||
|
selectedScriptGroupIds?: string[]; // 选中的话术组ID数组
|
||||||
|
enableAiRewrite?: 0 | 1; // 是否启用AI改写
|
||||||
|
aiRewritePrompt?: string; // AI改写提示词
|
||||||
|
scriptGroups?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
messages: string[];
|
||||||
|
}>; // 话术组对象数组
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
帮我对接数据,以下是传参实例,三种模式都是同一界面的。
|
|
||||||
|
|
||||||
群发助手传参实例
|
|
||||||
{
|
|
||||||
"name": "群群发-新品宣传", // 任务名称
|
|
||||||
"type": 3, // 工作台类型:3=群消息推送
|
|
||||||
"autoStart": 1, // 保存后自动启动
|
|
||||||
"status": 1, // 是否启用
|
|
||||||
"pushType": 0, // 推送方式:0=定时,1=立即
|
|
||||||
"targetType": 1, // 目标类型:1=群推送
|
|
||||||
"groupPushSubType": 1, // 群推送子类型:1=群群发,2=群公告
|
|
||||||
"startTime": "09:00", // 推送起始时间
|
|
||||||
"endTime": "20:00", // 推送结束时间
|
|
||||||
"maxPerDay": 200, // 每日最大推送群数
|
|
||||||
"pushOrder": 1, // 推送顺序:1=最早优先,2=最新优先
|
|
||||||
"wechatGroups": [102, 205, 318], // 选择的微信群 ID 列表
|
|
||||||
"contentGroups": [11, 12], // 关联内容库 ID 列表
|
|
||||||
"friendIntervalMin": 10, // 群间最小间隔(秒)
|
|
||||||
"friendIntervalMax": 25, // 群间最大间隔(秒)
|
|
||||||
"messageIntervalMin": 2, // 同一群消息间最小间隔(秒)
|
|
||||||
"messageIntervalMax": 6, // 同一群消息间最大间隔(秒)
|
|
||||||
"isRandomTemplate": 1, // 是否随机选择话术模板
|
|
||||||
"postPushTags": [301, 302], // 推送完成后打的标签
|
|
||||||
ownerWechatIds:[123123,1231231] //客服id
|
|
||||||
}
|
|
||||||
|
|
||||||
//群公告传参实例
|
|
||||||
{
|
|
||||||
"name": "群公告-双11活动", // 任务名称
|
|
||||||
"type": 3, // 群消息推送
|
|
||||||
"autoStart": 0, // 不自动启动
|
|
||||||
"status": 1, // 启用
|
|
||||||
"pushType": 1, // 立即推送
|
|
||||||
"targetType": 1, // 群推送
|
|
||||||
"groupPushSubType": 2, // 群公告
|
|
||||||
"startTime": "08:30", // 开始时间
|
|
||||||
"endTime": "18:30", // 结束时间
|
|
||||||
"maxPerDay": 80, // 每日最大公告数
|
|
||||||
"pushOrder": 2, // 最新优先
|
|
||||||
"wechatGroups": [5021, 5026], // 公告目标群
|
|
||||||
"announcementContent": "…", // 公告正文
|
|
||||||
"enableAiRewrite": 1, // 启用 AI 改写
|
|
||||||
"aiRewritePrompt": "保持活泼口吻…", // AI 改写提示词
|
|
||||||
"contentGroups": [21], // 关联内容库
|
|
||||||
"friendIntervalMin": 15, // 群间最小间隔
|
|
||||||
"friendIntervalMax": 30, // 群间最大间隔
|
|
||||||
"messageIntervalMin": 3, // 消息间最小间隔
|
|
||||||
"messageIntervalMax": 9, // 消息间最大间隔
|
|
||||||
"isRandomTemplate": 0, // 不随机模板
|
|
||||||
"postPushTags": [], // 推送后标签
|
|
||||||
ownerWechatIds:[123123,1231231] //客服id
|
|
||||||
}
|
|
||||||
|
|
||||||
//好友传参实例
|
|
||||||
{
|
|
||||||
"name": "好友私聊-新客转化", // 任务名称
|
|
||||||
"type": 3, // 群消息推送
|
|
||||||
"autoStart": 1, // 自动启动
|
|
||||||
"status": 1, // 启用
|
|
||||||
"pushType": 0, // 定时推送
|
|
||||||
"targetType": 2, // 目标类型:2=好友推送
|
|
||||||
"groupPushSubType": 1, // 固定为群群发(好友推送不支持公告)
|
|
||||||
"startTime": "10:00", // 开始时间
|
|
||||||
"endTime": "22:00", // 结束时间
|
|
||||||
"maxPerDay": 150, // 每日最大推送好友数
|
|
||||||
"pushOrder": 1, // 最早优先
|
|
||||||
"wechatFriends": ["12312"], // 指定好友列表(可为空数组)
|
|
||||||
"deviceGroups": [9001, 9002], // 必选:推送设备分组 ID
|
|
||||||
"contentGroups": [41, 42], // 话术内容库
|
|
||||||
"friendIntervalMin": 12, // 好友间最小间隔
|
|
||||||
"friendIntervalMax": 28, // 好友间最大间隔
|
|
||||||
"messageIntervalMin": 4, // 消息间最小间隔
|
|
||||||
"messageIntervalMax": 10, // 消息间最大间隔
|
|
||||||
"isRandomTemplate": 1, // 随机话术
|
|
||||||
"postPushTags": [501], // 推送后标签
|
|
||||||
ownerWechatIds:[123123,1231231] //客服id
|
|
||||||
}
|
|
||||||
|
|
||||||
请求接口是 queryWorkbenchCreate
|
|
||||||
@@ -6,8 +6,10 @@ export interface GetPushHistoryParams {
|
|||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
pushType?: string;
|
pushTypeCode?: string; // 推送类型代码:friend, group, announcement
|
||||||
status?: string;
|
status?: string; // 状态:pending, completed, failed
|
||||||
|
workbenchId?: string;
|
||||||
|
[property: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取推送历史接口响应
|
// 获取推送历史接口响应
|
||||||
@@ -27,11 +29,30 @@ export interface GetPushHistoryResponse {
|
|||||||
*/
|
*/
|
||||||
export interface GetGroupPushHistoryParams {
|
export interface GetGroupPushHistoryParams {
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
limit: string;
|
limit?: string | number;
|
||||||
page: string;
|
page?: string | number;
|
||||||
|
pageSize?: string | number;
|
||||||
|
pushTypeCode?: string;
|
||||||
|
status?: string;
|
||||||
workbenchId?: string;
|
workbenchId?: string;
|
||||||
[property: string]: any;
|
[property: string]: any;
|
||||||
}
|
}
|
||||||
export const getPushHistory = async (params: GetGroupPushHistoryParams) => {
|
|
||||||
return request("/v1/workbench/group-push-history", params, "GET");
|
export const getPushHistory = async (
|
||||||
|
params: GetGroupPushHistoryParams,
|
||||||
|
): Promise<GetPushHistoryResponse> => {
|
||||||
|
// 转换参数格式,确保 limit 和 page 是字符串
|
||||||
|
const requestParams: Record<string, any> = {
|
||||||
|
...params,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.page !== undefined) {
|
||||||
|
requestParams.page = String(params.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.pageSize !== undefined) {
|
||||||
|
requestParams.limit = String(params.pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
return request("/v1/workbench/group-push-history", requestParams, "GET");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,30 +15,33 @@ import styles from "./index.module.scss";
|
|||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
// 推送类型枚举
|
// 推送类型代码枚举
|
||||||
export enum PushType {
|
export enum PushTypeCode {
|
||||||
FRIEND_MESSAGE = "friend-message", // 好友消息
|
FRIEND = "friend", // 好友消息
|
||||||
GROUP_MESSAGE = "group-message", // 群消息
|
GROUP = "group", // 群消息
|
||||||
GROUP_ANNOUNCEMENT = "group-announcement", // 群公告
|
ANNOUNCEMENT = "announcement", // 群公告
|
||||||
}
|
}
|
||||||
|
|
||||||
// 推送状态枚举
|
// 推送状态枚举
|
||||||
export enum PushStatus {
|
export enum PushStatus {
|
||||||
|
PENDING = "pending", // 进行中
|
||||||
COMPLETED = "completed", // 已完成
|
COMPLETED = "completed", // 已完成
|
||||||
IN_PROGRESS = "in-progress", // 进行中
|
|
||||||
FAILED = "failed", // 失败
|
FAILED = "failed", // 失败
|
||||||
}
|
}
|
||||||
|
|
||||||
// 推送历史记录接口
|
// 推送历史记录接口
|
||||||
export interface PushHistoryRecord {
|
export interface PushHistoryRecord {
|
||||||
id: string;
|
workbenchId: number;
|
||||||
pushType: PushType;
|
taskName: string;
|
||||||
pushContent: string;
|
pushType: string; // 推送类型中文名称,如 "好友消息"
|
||||||
|
pushTypeCode: string; // 推送类型代码,如 "friend"
|
||||||
targetCount: number;
|
targetCount: number;
|
||||||
successCount: number;
|
successCount: number;
|
||||||
failureCount: number;
|
failCount: number;
|
||||||
status: PushStatus;
|
status: string; // 状态代码,如 "pending"
|
||||||
|
statusText: string; // 状态中文名称,如 "进行中"
|
||||||
createTime: string;
|
createTime: string;
|
||||||
|
contentLibraryName: string; // 内容库名称
|
||||||
}
|
}
|
||||||
|
|
||||||
const PushHistory: React.FC = () => {
|
const PushHistory: React.FC = () => {
|
||||||
@@ -59,8 +62,8 @@ const PushHistory: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const params: any = {
|
const params: any = {
|
||||||
page,
|
page: String(page),
|
||||||
pageSize: pagination.pageSize,
|
limit: String(pagination.pageSize),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (searchValue.trim()) {
|
if (searchValue.trim()) {
|
||||||
@@ -68,7 +71,7 @@ const PushHistory: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeFilter !== "all") {
|
if (typeFilter !== "all") {
|
||||||
params.pushType = typeFilter;
|
params.pushTypeCode = typeFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusFilter !== "all") {
|
if (statusFilter !== "all") {
|
||||||
@@ -157,13 +160,33 @@ const PushHistory: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 获取推送类型标签
|
// 获取推送类型标签
|
||||||
const getPushTypeTag = (type: PushType) => {
|
const getPushTypeTag = (pushType: string, pushTypeCode?: string) => {
|
||||||
const typeMap = {
|
// 优先使用中文名称,如果没有则根据代码映射
|
||||||
[PushType.FRIEND_MESSAGE]: { text: "好友消息", color: "#666" },
|
if (pushType) {
|
||||||
[PushType.GROUP_MESSAGE]: { text: "群消息", color: "#666" },
|
const colorMap: Record<string, string> = {
|
||||||
[PushType.GROUP_ANNOUNCEMENT]: { text: "群公告", color: "#666" },
|
好友消息: "#1890ff",
|
||||||
|
群消息: "#52c41a",
|
||||||
|
群公告: "#722ed1",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
color={colorMap[pushType] || "#666"}
|
||||||
|
style={{ borderRadius: "12px" }}
|
||||||
|
>
|
||||||
|
{pushType}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 如果没有中文名称,根据代码映射
|
||||||
|
const codeMap: Record<string, { text: string; color: string }> = {
|
||||||
|
[PushTypeCode.FRIEND]: { text: "好友消息", color: "#1890ff" },
|
||||||
|
[PushTypeCode.GROUP]: { text: "群消息", color: "#52c41a" },
|
||||||
|
[PushTypeCode.ANNOUNCEMENT]: { text: "群公告", color: "#722ed1" },
|
||||||
};
|
};
|
||||||
const config = typeMap[type] || { text: "未知", color: "#666" };
|
const config =
|
||||||
|
pushTypeCode && codeMap[pushTypeCode]
|
||||||
|
? codeMap[pushTypeCode]
|
||||||
|
: { text: pushType || "未知", color: "#666" };
|
||||||
return (
|
return (
|
||||||
<Tag color={config.color} style={{ borderRadius: "12px" }}>
|
<Tag color={config.color} style={{ borderRadius: "12px" }}>
|
||||||
{config.text}
|
{config.text}
|
||||||
@@ -172,14 +195,31 @@ const PushHistory: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 获取状态标签
|
// 获取状态标签
|
||||||
const getStatusTag = (status: PushStatus) => {
|
const getStatusTag = (status: string, statusText?: string) => {
|
||||||
const statusMap = {
|
// 优先使用中文状态文本
|
||||||
|
const displayText = statusText || status;
|
||||||
|
|
||||||
|
// 根据状态代码或文本匹配
|
||||||
|
const statusMap: Record<
|
||||||
|
string,
|
||||||
|
{ text: string; color: string; icon: React.ReactNode }
|
||||||
|
> = {
|
||||||
[PushStatus.COMPLETED]: {
|
[PushStatus.COMPLETED]: {
|
||||||
text: "已完成",
|
text: "已完成",
|
||||||
color: "#52c41a",
|
color: "#52c41a",
|
||||||
icon: <CheckCircleOutlined />,
|
icon: <CheckCircleOutlined />,
|
||||||
},
|
},
|
||||||
[PushStatus.IN_PROGRESS]: {
|
completed: {
|
||||||
|
text: "已完成",
|
||||||
|
color: "#52c41a",
|
||||||
|
icon: <CheckCircleOutlined />,
|
||||||
|
},
|
||||||
|
[PushStatus.PENDING]: {
|
||||||
|
text: "进行中",
|
||||||
|
color: "#1890ff",
|
||||||
|
icon: <ClockCircleOutlined />,
|
||||||
|
},
|
||||||
|
pending: {
|
||||||
text: "进行中",
|
text: "进行中",
|
||||||
color: "#1890ff",
|
color: "#1890ff",
|
||||||
icon: <ClockCircleOutlined />,
|
icon: <ClockCircleOutlined />,
|
||||||
@@ -189,12 +229,43 @@ const PushHistory: React.FC = () => {
|
|||||||
color: "#ff4d4f",
|
color: "#ff4d4f",
|
||||||
icon: <CloseCircleOutlined />,
|
icon: <CloseCircleOutlined />,
|
||||||
},
|
},
|
||||||
|
failed: {
|
||||||
|
text: "失败",
|
||||||
|
color: "#ff4d4f",
|
||||||
|
icon: <CloseCircleOutlined />,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const config = statusMap[status] || {
|
|
||||||
text: "未知",
|
// 根据状态文本匹配
|
||||||
color: "#666",
|
const textMap: Record<
|
||||||
icon: null,
|
string,
|
||||||
|
{ text: string; color: string; icon: React.ReactNode }
|
||||||
|
> = {
|
||||||
|
已完成: {
|
||||||
|
text: "已完成",
|
||||||
|
color: "#52c41a",
|
||||||
|
icon: <CheckCircleOutlined />,
|
||||||
|
},
|
||||||
|
进行中: {
|
||||||
|
text: "进行中",
|
||||||
|
color: "#1890ff",
|
||||||
|
icon: <ClockCircleOutlined />,
|
||||||
|
},
|
||||||
|
失败: {
|
||||||
|
text: "失败",
|
||||||
|
color: "#ff4d4f",
|
||||||
|
icon: <CloseCircleOutlined />,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const config = textMap[displayText] ||
|
||||||
|
statusMap[status] ||
|
||||||
|
statusMap[status.toLowerCase()] || {
|
||||||
|
text: displayText,
|
||||||
|
color: "#666",
|
||||||
|
icon: null,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tag
|
<Tag
|
||||||
color={config.color}
|
color={config.color}
|
||||||
@@ -217,15 +288,26 @@ const PushHistory: React.FC = () => {
|
|||||||
dataIndex: "pushType",
|
dataIndex: "pushType",
|
||||||
key: "pushType",
|
key: "pushType",
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (type: PushType) => getPushTypeTag(type),
|
render: (pushType: string, record: PushHistoryRecord) =>
|
||||||
|
getPushTypeTag(pushType, record.pushTypeCode),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "推送内容",
|
title: "任务名称",
|
||||||
dataIndex: "pushContent",
|
dataIndex: "taskName",
|
||||||
key: "pushContent",
|
key: "taskName",
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (text: string) => <span style={{ color: "#333" }}>{text}</span>,
|
render: (text: string) => <span style={{ color: "#333" }}>{text}</span>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "内容库",
|
||||||
|
dataIndex: "contentLibraryName",
|
||||||
|
key: "contentLibraryName",
|
||||||
|
width: 150,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (text: string) => (
|
||||||
|
<span style={{ color: "#666", fontSize: "13px" }}>{text || "-"}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "目标数量",
|
title: "目标数量",
|
||||||
dataIndex: "targetCount",
|
dataIndex: "targetCount",
|
||||||
@@ -246,8 +328,8 @@ const PushHistory: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "失败数",
|
title: "失败数",
|
||||||
dataIndex: "failureCount",
|
dataIndex: "failCount",
|
||||||
key: "failureCount",
|
key: "failCount",
|
||||||
width: 100,
|
width: 100,
|
||||||
align: "center" as const,
|
align: "center" as const,
|
||||||
render: (count: number) => (
|
render: (count: number) => (
|
||||||
@@ -260,7 +342,8 @@ const PushHistory: React.FC = () => {
|
|||||||
key: "status",
|
key: "status",
|
||||||
width: 120,
|
width: 120,
|
||||||
align: "center" as const,
|
align: "center" as const,
|
||||||
render: (status: PushStatus) => getStatusTag(status),
|
render: (status: string, record: PushHistoryRecord) =>
|
||||||
|
getStatusTag(status, record.statusText),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "创建时间",
|
title: "创建时间",
|
||||||
@@ -329,9 +412,9 @@ const PushHistory: React.FC = () => {
|
|||||||
suffixIcon={<span>▼</span>}
|
suffixIcon={<span>▼</span>}
|
||||||
>
|
>
|
||||||
<Option value="all">全部类型</Option>
|
<Option value="all">全部类型</Option>
|
||||||
<Option value={PushType.FRIEND_MESSAGE}>好友消息</Option>
|
<Option value={PushTypeCode.FRIEND}>好友消息</Option>
|
||||||
<Option value={PushType.GROUP_MESSAGE}>群消息</Option>
|
<Option value={PushTypeCode.GROUP}>群消息</Option>
|
||||||
<Option value={PushType.GROUP_ANNOUNCEMENT}>群公告</Option>
|
<Option value={PushTypeCode.ANNOUNCEMENT}>群公告</Option>
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
@@ -340,8 +423,8 @@ const PushHistory: React.FC = () => {
|
|||||||
suffixIcon={<span>▼</span>}
|
suffixIcon={<span>▼</span>}
|
||||||
>
|
>
|
||||||
<Option value="all">全部状态</Option>
|
<Option value="all">全部状态</Option>
|
||||||
|
<Option value={PushStatus.PENDING}>进行中</Option>
|
||||||
<Option value={PushStatus.COMPLETED}>已完成</Option>
|
<Option value={PushStatus.COMPLETED}>已完成</Option>
|
||||||
<Option value={PushStatus.IN_PROGRESS}>进行中</Option>
|
|
||||||
<Option value={PushStatus.FAILED}>失败</Option>
|
<Option value={PushStatus.FAILED}>失败</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -353,7 +436,7 @@ const PushHistory: React.FC = () => {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
rowKey="id"
|
rowKey="workbenchId"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
className={styles.dataTable}
|
className={styles.dataTable}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
.selectMapContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 600px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchArea {
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchResults {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10001;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
margin-top: 4px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
:global(.ant-list-item) {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 12px 16px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapArea {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapContainer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingOverlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locationInfo {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locationLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1890ff;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locationText {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locationCoords {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultItem {
|
||||||
|
:global(.ant-list-item-meta-title) {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ant-list-item-meta-description) {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,15 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
import { Layout, Input, Button, Modal, message, Tooltip } from "antd";
|
import {
|
||||||
|
Layout,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
message,
|
||||||
|
Tooltip,
|
||||||
|
AutoComplete,
|
||||||
|
Input as AntInput,
|
||||||
|
Spin,
|
||||||
|
} from "antd";
|
||||||
import {
|
import {
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
FolderOutlined,
|
FolderOutlined,
|
||||||
@@ -8,6 +18,7 @@ import {
|
|||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { ContractData, weChatGroup, ChatRecord } from "@/pages/pc/ckbox/data";
|
import { ContractData, weChatGroup, ChatRecord } from "@/pages/pc/ckbox/data";
|
||||||
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||||
@@ -23,6 +34,7 @@ import {
|
|||||||
manualTriggerAi,
|
manualTriggerAi,
|
||||||
} from "@/store/module/weChat/weChat";
|
} from "@/store/module/weChat/weChat";
|
||||||
import { useContactStore } from "@/store/module/weChat/contacts";
|
import { useContactStore } from "@/store/module/weChat/contacts";
|
||||||
|
import SelectMap from "./components/selectMap";
|
||||||
const { Footer } = Layout;
|
const { Footer } = Layout;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
@@ -326,6 +338,8 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
|||||||
updateShowChatRecordModel(!showChatRecordModel);
|
updateShowChatRecordModel(!showChatRecordModel);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [mapVisible, setMapVisible] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 聊天输入 */}
|
{/* 聊天输入 */}
|
||||||
@@ -423,6 +437,12 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
|||||||
}
|
}
|
||||||
className={styles.toolbarButton}
|
className={styles.toolbarButton}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
className={styles.toolbarButton}
|
||||||
|
type="text"
|
||||||
|
icon={<EnvironmentOutlined />}
|
||||||
|
onClick={() => setMapVisible(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* AI模式下显示重新生成按钮 */}
|
{/* AI模式下显示重新生成按钮 */}
|
||||||
{(isAiAssist || isAiTakeover) && (
|
{(isAiAssist || isAiTakeover) && (
|
||||||
@@ -502,7 +522,12 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Footer>
|
</Footer>
|
||||||
、
|
<SelectMap
|
||||||
|
visible={mapVisible}
|
||||||
|
onClose={() => setMapVisible(false)}
|
||||||
|
contract={contract}
|
||||||
|
addMessage={addMessage}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
// 位置消息基础样式
|
// 位置消息基础样式
|
||||||
.locationMessage {
|
.locationMessage {
|
||||||
max-width: 420px;
|
width: 420px;
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
@@ -33,6 +35,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 地图预览区域
|
||||||
|
.mapPreview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapImage {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapPlaceholder {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
span:first-child {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 位置消息头部
|
// 位置消息头部
|
||||||
.locationHeader {
|
.locationHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -70,6 +111,21 @@
|
|||||||
// 位置消息内容
|
// 位置消息内容
|
||||||
.locationContent {
|
.locationContent {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadName {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poiName {
|
.poiName {
|
||||||
@@ -89,9 +145,8 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #666;
|
color: #666;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin-bottom: 12px;
|
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -175,13 +230,17 @@
|
|||||||
// 响应式设计
|
// 响应式设计
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.locationMessage {
|
.locationMessage {
|
||||||
max-width: 280px;
|
width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locationCard {
|
.locationCard {
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mapPreview {
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
.locationHeader {
|
.locationHeader {
|
||||||
padding: 10px 14px 6px;
|
padding: 10px 14px 6px;
|
||||||
}
|
}
|
||||||
@@ -253,6 +312,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mapPreview {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapPlaceholder {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
.locationHeader {
|
.locationHeader {
|
||||||
background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%);
|
background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%);
|
||||||
border-bottom-color: #333;
|
border-bottom-color: #333;
|
||||||
|
|||||||
@@ -85,10 +85,36 @@ const LocationMessage: React.FC<LocationMessageProps> = ({ content }) => {
|
|||||||
return renderErrorMessage("[位置消息 - 解析失败]");
|
return renderErrorMessage("[位置消息 - 解析失败]");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成地图链接
|
// 格式化经纬度为6位小数
|
||||||
|
const formatCoordinate = (coord: string): string => {
|
||||||
|
const num = parseFloat(coord);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return coord; // 如果无法解析,返回原值
|
||||||
|
}
|
||||||
|
return num.toFixed(6);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成地图链接(用于点击跳转)
|
||||||
const generateMapUrl = (lat: string, lng: string, label: string) => {
|
const generateMapUrl = (lat: string, lng: string, label: string) => {
|
||||||
|
const formattedLat = formatCoordinate(lat);
|
||||||
|
const formattedLng = formatCoordinate(lng);
|
||||||
// 使用腾讯地图链接
|
// 使用腾讯地图链接
|
||||||
return `https://apis.map.qq.com/uri/v1/marker?marker=coord:${lat},${lng};title:${encodeURIComponent(label)}&referer=wechat`;
|
return `https://apis.map.qq.com/uri/v1/marker?marker=coord:${formattedLng},${formattedLat};title:${encodeURIComponent(label)}&referer=wechat`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成静态地图预览图URL
|
||||||
|
const generateStaticMapUrl = (
|
||||||
|
lat: string,
|
||||||
|
lng: string,
|
||||||
|
width: number = 420,
|
||||||
|
height: number = 200,
|
||||||
|
) => {
|
||||||
|
const formattedLat = formatCoordinate(lat);
|
||||||
|
const formattedLng = formatCoordinate(lng);
|
||||||
|
const key = "7DZBZ-ZSRK3-QJN3W-O5VTV-4E2P6-7GFYX";
|
||||||
|
const zoom = locationData.scale || "15";
|
||||||
|
// 腾讯地图静态地图API
|
||||||
|
return `https://apis.map.qq.com/ws/staticmap/v2/?center=${formattedLng},${formattedLat}&zoom=${zoom}&size=${width}x${height}&markers=${formattedLng},${formattedLat}&key=${key}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapUrl = generateMapUrl(
|
const mapUrl = generateMapUrl(
|
||||||
@@ -97,12 +123,18 @@ const LocationMessage: React.FC<LocationMessageProps> = ({ content }) => {
|
|||||||
locationData.label,
|
locationData.label,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const staticMapUrl = generateStaticMapUrl(
|
||||||
|
locationData.y,
|
||||||
|
locationData.x,
|
||||||
|
420,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
|
||||||
// 处理POI信息
|
// 处理POI信息
|
||||||
const poiName = locationData.poiname || locationData.label;
|
// 提取道路名称(如果有的话,从label中提取)
|
||||||
const poiCategory = locationData.poiCategoryTips
|
const roadName =
|
||||||
? locationData.poiCategoryTips.split(":")[0]
|
locationData.poiname.split(/[((]/)[0] || locationData.label;
|
||||||
: "";
|
const detailAddress = locationData.label;
|
||||||
const poiPhone = locationData.poiPhone || "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.locationMessage}>
|
<div className={styles.locationMessage}>
|
||||||
@@ -110,29 +142,35 @@ const LocationMessage: React.FC<LocationMessageProps> = ({ content }) => {
|
|||||||
className={styles.locationCard}
|
className={styles.locationCard}
|
||||||
onClick={() => window.open(mapUrl, "_blank")}
|
onClick={() => window.open(mapUrl, "_blank")}
|
||||||
>
|
>
|
||||||
{/* 位置详情 */}
|
{/* 地图预览图 */}
|
||||||
|
<div className={styles.mapPreview}>
|
||||||
|
<img
|
||||||
|
src={staticMapUrl}
|
||||||
|
alt={locationData.label}
|
||||||
|
className={styles.mapImage}
|
||||||
|
onError={e => {
|
||||||
|
// 如果图片加载失败,显示占位符
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.style.display = "none";
|
||||||
|
const placeholder = target.nextElementSibling as HTMLElement;
|
||||||
|
if (placeholder) {
|
||||||
|
placeholder.style.display = "flex";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={styles.mapPlaceholder}>
|
||||||
|
<span>📍</span>
|
||||||
|
<span>地图加载中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 位置信息 */}
|
||||||
<div className={styles.locationContent}>
|
<div className={styles.locationContent}>
|
||||||
{/* POI名称 */}
|
{/* 道路名称 */}
|
||||||
{poiName && <div className={styles.poiName}>{poiName}</div>}
|
{roadName && <div className={styles.roadName}>{roadName}</div>}
|
||||||
|
|
||||||
{/* 详细地址 */}
|
{/* 详细地址 */}
|
||||||
<div className={styles.locationAddress}>{locationData.label}</div>
|
<div className={styles.locationAddress}>{detailAddress}</div>
|
||||||
|
|
||||||
{/* POI分类和电话 */}
|
|
||||||
<div className={styles.locationDetails}>
|
|
||||||
{poiCategory && (
|
|
||||||
<div className={styles.poiCategory}>
|
|
||||||
<span className={styles.categoryIcon}>🏷️</span>
|
|
||||||
{poiCategory}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{poiPhone && (
|
|
||||||
<div className={styles.poiPhone}>
|
|
||||||
<span className={styles.phoneIcon}>📞</span>
|
|
||||||
{poiPhone}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevMessages = prevMessagesRef.current;
|
const prevMessages = prevMessagesRef.current;
|
||||||
|
const prevLength = prevMessages.length;
|
||||||
|
|
||||||
const hasVideoStateChange = currentMessages.some((msg, index) => {
|
const hasVideoStateChange = currentMessages.some((msg, index) => {
|
||||||
// 首先检查消息对象本身是否为null或undefined
|
// 首先检查消息对象本身是否为null或undefined
|
||||||
@@ -384,8 +385,9 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 只有在没有视频状态变化时才自动滚动到底部
|
if (currentMessages.length > prevLength && !hasVideoStateChange) {
|
||||||
if (!hasVideoStateChange && isLoadingData) {
|
scrollToBottom();
|
||||||
|
} else if (isLoadingData && !hasVideoStateChange) {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useState, useEffect } from "react";
|
import React, { useCallback, useState, useEffect, useRef } from "react";
|
||||||
import { Input, message } from "antd";
|
import { Input, message } from "antd";
|
||||||
import { Button } from "antd-mobile";
|
import { Button } from "antd-mobile";
|
||||||
import { EditOutlined } from "@ant-design/icons";
|
import { EditOutlined } from "@ant-design/icons";
|
||||||
@@ -56,8 +56,32 @@ const DetailValue: React.FC<DetailValueProps> = ({
|
|||||||
useState<Record<string, string>>(value);
|
useState<Record<string, string>>(value);
|
||||||
const [changedKeys, setChangedKeys] = useState<string[]>([]);
|
const [changedKeys, setChangedKeys] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 使用 useRef 存储上一次的 value,用于深度比较
|
||||||
|
const prevValueRef = useRef<Record<string, string>>(value);
|
||||||
|
|
||||||
|
// 深度比较函数:比较两个对象的值是否真的变化了
|
||||||
|
const isValueChanged = useCallback(
|
||||||
|
(prev: Record<string, string>, next: Record<string, string>) => {
|
||||||
|
const allKeys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
||||||
|
for (const key of allKeys) {
|
||||||
|
if (prev[key] !== next[key]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// 当外部value变化时,更新内部状态
|
// 当外部value变化时,更新内部状态
|
||||||
|
// 优化:只有当值真正变化时才重置编辑状态,避免因对象引用变化导致编辑状态丢失
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 深度比较,只有当值真正变化时才更新
|
||||||
|
if (!isValueChanged(prevValueRef.current, value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有在值真正变化时才更新状态
|
||||||
setFieldValues(value);
|
setFieldValues(value);
|
||||||
setOriginalValues(value);
|
setOriginalValues(value);
|
||||||
setChangedKeys([]);
|
setChangedKeys([]);
|
||||||
@@ -67,7 +91,10 @@ const DetailValue: React.FC<DetailValueProps> = ({
|
|||||||
newEditingFields[field.key] = false;
|
newEditingFields[field.key] = false;
|
||||||
});
|
});
|
||||||
setEditingFields(newEditingFields);
|
setEditingFields(newEditingFields);
|
||||||
}, [value, fields]);
|
|
||||||
|
// 更新 ref
|
||||||
|
prevValueRef.current = value;
|
||||||
|
}, [value, fields, isValueChanged]);
|
||||||
|
|
||||||
const handleFieldChange = useCallback(
|
const handleFieldChange = useCallback(
|
||||||
(fieldKey: string, nextVal: string) => {
|
(fieldKey: string, nextVal: string) => {
|
||||||
|
|||||||
@@ -210,14 +210,34 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
|
|||||||
|
|
||||||
// 构建联系人或群聊详细信息
|
// 构建联系人或群聊详细信息
|
||||||
|
|
||||||
const customerList = useCustomerStore(state => state.customerList);
|
// 优化:使用选择器函数直接订阅匹配的客服对象,避免订阅整个 customerList
|
||||||
const kfSelectedUser = useMemo(() => {
|
// 添加相等性比较,只有当匹配的客服对象或其 labels 真正变化时才触发重新渲染
|
||||||
if (!contract.wechatAccountId) return null;
|
const kfSelectedUser = useCustomerStore(
|
||||||
const matchedCustomer = customerList.find(
|
state => {
|
||||||
customer => customer.id === contract.wechatAccountId,
|
if (!contract.wechatAccountId) return null;
|
||||||
);
|
return (
|
||||||
return matchedCustomer || null;
|
state.customerList.find(
|
||||||
}, [customerList, contract.wechatAccountId]);
|
customer => customer.id === contract.wechatAccountId,
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(prev, next) => {
|
||||||
|
// 如果都是 null,认为相等
|
||||||
|
if (!prev && !next) return true;
|
||||||
|
// 如果一个是 null 另一个不是,认为不相等
|
||||||
|
if (!prev || !next) return false;
|
||||||
|
// 比较关键字段:id 和 labels(因为 useEffect 中使用了 labels)
|
||||||
|
if (prev.id !== next.id) return false;
|
||||||
|
// 比较 labels 数组是否真的变化了
|
||||||
|
const prevLabels = prev.labels || [];
|
||||||
|
const nextLabels = next.labels || [];
|
||||||
|
if (prevLabels.length !== nextLabels.length) return false;
|
||||||
|
// 深度比较 labels 数组内容(先复制再排序,避免修改原数组)
|
||||||
|
const prevLabelsStr = JSON.stringify([...prevLabels].sort());
|
||||||
|
const nextLabelsStr = JSON.stringify([...nextLabels].sort());
|
||||||
|
return prevLabelsStr === nextLabelsStr;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// 不再需要从useContactStore获取getContactsByCustomer
|
// 不再需要从useContactStore获取getContactsByCustomer
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,11 @@
|
|||||||
.active & {
|
.active & {
|
||||||
border-color: #1890ff;
|
border-color: #1890ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.offline {
|
||||||
|
filter: grayscale(100%);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.allUser {
|
.allUser {
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ const CustomerList: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className={styles.allUser}>全部</div>
|
<div className={styles.allUser}>全部</div>
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className={`${styles.onlineIndicator} ${styles.online}`} />
|
|
||||||
</div>
|
</div>
|
||||||
{customerList.map(customer => (
|
{customerList.map(customer => (
|
||||||
<div
|
<div
|
||||||
@@ -105,7 +104,7 @@ const CustomerList: React.FC = () => {
|
|||||||
<Avatar
|
<Avatar
|
||||||
src={customer.avatar}
|
src={customer.avatar}
|
||||||
size={50}
|
size={50}
|
||||||
className={styles.userAvatar}
|
className={`${styles.userAvatar} ${!customer.isOnline ? styles.offline : ""}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: !customer.avatar ? "#1890ff" : undefined,
|
backgroundColor: !customer.avatar ? "#1890ff" : undefined,
|
||||||
}}
|
}}
|
||||||
@@ -113,9 +112,6 @@ const CustomerList: React.FC = () => {
|
|||||||
{!customer.avatar && customer.name.charAt(0)}
|
{!customer.avatar && customer.name.charAt(0)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Badge>
|
</Badge>
|
||||||
<div
|
|
||||||
className={`${styles.onlineIndicator} ${customer.isOnline ? styles.online : styles.offline}`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
const requestId = ++loadRequestRef.current;
|
const requestId = ++loadRequestRef.current;
|
||||||
|
|
||||||
const initializeSessions = async () => {
|
const initializeSessions = async () => {
|
||||||
setLoading(true);
|
// setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cachedSessions =
|
const cachedSessions =
|
||||||
@@ -416,7 +416,7 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!isCancelled && loadRequestRef.current === requestId) {
|
if (!isCancelled && loadRequestRef.current === requestId) {
|
||||||
setLoading(false);
|
// setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -353,13 +353,13 @@ export class ContactManager {
|
|||||||
exclude: boolean = false,
|
exclude: boolean = false,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
try {
|
try {
|
||||||
console.log("getContactCount 调用参数:", {
|
// console.log("getContactCount 调用参数:", {
|
||||||
userId,
|
// userId,
|
||||||
type,
|
// type,
|
||||||
customerId,
|
// customerId,
|
||||||
groupIds,
|
// groupIds,
|
||||||
exclude,
|
// exclude,
|
||||||
});
|
// });
|
||||||
|
|
||||||
const conditions: any[] = [
|
const conditions: any[] = [
|
||||||
{ field: "userId", operator: "equals", value: userId },
|
{ field: "userId", operator: "equals", value: userId },
|
||||||
@@ -394,14 +394,14 @@ export class ContactManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("查询条件:", conditions);
|
// console.log("查询条件:", conditions);
|
||||||
|
|
||||||
const contacts =
|
const contacts =
|
||||||
await contactUnifiedService.findWhereMultiple(conditions);
|
await contactUnifiedService.findWhereMultiple(conditions);
|
||||||
|
|
||||||
console.log(
|
// console.log(
|
||||||
`查询结果数量: ${contacts.length}, type: ${type}, groupIds: ${groupIds}`,
|
// `查询结果数量: ${contacts.length}, type: ${type}, groupIds: ${groupIds}`,
|
||||||
);
|
// );
|
||||||
|
|
||||||
return contacts.length;
|
return contacts.length;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ export const messageFilter = (message: string) => {
|
|||||||
return "[图片]";
|
return "[图片]";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// XML 格式的位置消息:包含 <location 标签
|
||||||
|
if (/<location[\s>]/i.test(message)) {
|
||||||
|
return "[位置]";
|
||||||
|
}
|
||||||
|
|
||||||
// 其他情况直接返回原始消息
|
// 其他情况直接返回原始消息
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user