Merge branch 'develop' of https://gitee.com/cunkebao/cunkebao_v3 into release/friend

This commit is contained in:
wong
2025-11-20 16:12:09 +08:00
17 changed files with 1861 additions and 490 deletions

2
.gitignore vendored
View File

@@ -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/

View File

@@ -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

View File

@@ -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')) {

View 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);
}
}

View File

@@ -13,21 +13,59 @@
{ {
"name": "liu21st", "name": "liu21st",
"email": "liu21st@gmail.com" "email": "liu21st@gmail.com"
},
{
"name": "yunwuxin",
"email": "448901948@qq.com"
} }
], ],
"require": { "require": {
"php": ">=5.4.0", "php": ">=5.6.0",
"topthink/framework": "5.0.*" "topthink/framework": "5.1.*",
"topthink/think-installer": "~1.0",
"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.2",
"aliyuncs/oss-sdk-php": "^2.3",
"monolog/monolog": "^1.24",
"guzzlehttp/guzzle": "^6.3",
"overtrue/wechat": "~4.0",
"endroid/qr-code": "^3.5",
"phpoffice/phpspreadsheet": "^1.8",
"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",
"topthink/think-migration": "^2.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"app\\": "application" "app\\": "application",
} "Eison\\": "extend/Eison"
},
"files": [
"application/common.php"
],
"classmap": []
}, },
"extra": { "extra": {
"think-path": "thinkphp" "think-path": "thinkphp"
}, },
"config": { "config": {
"preferred-install": "dist" "preferred-install": "dist",
} "allow-plugins": {
"topthink/think-installer": true,
"easywechat-composer/easywechat-composer": true
}
},
"minimum-stability": "dev",
"prefer-stable": true
} }

View File

@@ -23,7 +23,8 @@ return [
// 全局中间件 // 全局中间件
'alias' => [ 'alias' => [
'cors' => 'app\\common\\middleware\\AllowCrossDomain' 'cors' => 'app\\common\\middleware\\AllowCrossDomain',
'jwt' => 'app\\http\\middleware\\Jwt'
], ],
// 应用中间件 // 应用中间件

View 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;
}
}

View File

@@ -8,6 +8,7 @@ import {
Badge, Badge,
Dropdown, Dropdown,
Empty, Empty,
message,
} from "antd"; } from "antd";
import { import {
BarChartOutlined, BarChartOutlined,
@@ -17,7 +18,7 @@ import {
ThunderboltOutlined, ThunderboltOutlined,
SettingOutlined, SettingOutlined,
CalendarOutlined, CalendarOutlined,
RetweetOutlined, ClearOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { noticeList, readMessage, readAll } from "./api"; import { noticeList, readMessage, readAll } from "./api";
import { useUserStore } from "@/store/module/user"; import { useUserStore } from "@/store/module/user";
@@ -54,6 +55,7 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
const [messageList, setMessageList] = useState<MessageItem[]>([]); const [messageList, setMessageList] = useState<MessageItem[]>([]);
const [messageCount, setMessageCount] = useState(0); const [messageCount, setMessageCount] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [clearingCache, setClearingCache] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { user, logout } = useUserStore(); const { user, logout } = useUserStore();
@@ -72,11 +74,6 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
navigate("/pc/weChat"); navigate("/pc/weChat");
} }
}; };
const isWeChat = () => {
return location.pathname.startsWith("/pc/weChat");
};
// 定时器获取消息条数 // 定时器获取消息条数
const IntervalMessageCount = async () => { const IntervalMessageCount = async () => {
try { try {
@@ -125,6 +122,99 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
navigate("/login"); // 跳转到登录页面 navigate("/login"); // 跳转到登录页面
}; };
// 清除所有 IndexedDB 数据库
const clearAllIndexedDB = async (): Promise<void> => {
return new Promise((resolve, reject) => {
if (!window.indexedDB) {
resolve();
return;
}
// 获取所有数据库名称
const databases: string[] = [];
const request = indexedDB.databases();
request
.then(dbs => {
dbs.forEach(db => {
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 handleClearCache = async () => {
try {
setClearingCache(true);
const hideLoading = message.loading("正在清除缓存...", 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) {
console.error("清除缓存失败:", error);
message.error("清除缓存失败,请稍后重试");
} finally {
setClearingCache(false);
}
};
// 处理消息已读 // 处理消息已读
const handleReadMessage = async (messageId: number) => { const handleReadMessage = async (messageId: number) => {
try { try {
@@ -199,6 +289,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 }} />,

View File

@@ -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");
} }

View File

@@ -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%;
}
}
}

View File

@@ -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;

View File

@@ -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(

View File

@@ -19,28 +19,26 @@
.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;
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 +47,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 +237,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 +260,7 @@
} }
.scriptGroupList { .scriptGroupList {
max-height: 260px; max-height: calc(100vh - 400px);
overflow-y: auto; overflow-y: auto;
} }
@@ -241,132 +341,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;
} }
} }
} }

View File

@@ -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})
@@ -460,128 +755,114 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({
)} )}
</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>
); );
}; };

View File

@@ -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}>
&gt;
</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>

View File

@@ -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[];
}>; // 话术组对象数组
} }

View File

@@ -220,7 +220,7 @@ const PushHistory: React.FC = () => {
render: (type: PushType) => getPushTypeTag(type), render: (type: PushType) => getPushTypeTag(type),
}, },
{ {
title: "推送内容", title: "任务名称",
dataIndex: "pushContent", dataIndex: "pushContent",
key: "pushContent", key: "pushContent",
ellipsis: true, ellipsis: true,