Merge branch 'develop' into yongpxu-dev

This commit is contained in:
乘风
2025-12-10 18:05:11 +08:00
65 changed files with 7981 additions and 1288 deletions

View File

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

View File

@@ -26,4 +26,5 @@ export interface DeviceSelectionProps {
showSelectedList?: boolean; // 新增
readonly?: boolean; // 新增
deviceGroups?: any[]; // 传递设备组数据
singleSelect?: boolean; // 新增,是否单选模式
}

View File

@@ -18,6 +18,7 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
showInput = true,
showSelectedList = true,
readonly = false,
singleSelect = false,
}) => {
// 弹窗控制
const [popupVisible, setPopupVisible] = useState(false);
@@ -37,6 +38,9 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
// 获取显示文本
const getDisplayText = () => {
if (selectedOptions.length === 0) return "";
if (singleSelect && selectedOptions.length > 0) {
return selectedOptions[0].memo || selectedOptions[0].wechatId || "已选择设备";
}
return `已选择 ${selectedOptions.length} 个设备`;
};
@@ -179,6 +183,7 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
onClose={() => setRealVisible(false)}
selectedOptions={selectedOptions}
onSelect={onSelect}
singleSelect={singleSelect}
/>
</>
);

View File

@@ -12,6 +12,7 @@ interface SelectionPopupProps {
onClose: () => void;
selectedOptions: DeviceSelectionItem[];
onSelect: (devices: DeviceSelectionItem[]) => void;
singleSelect?: boolean;
}
const PAGE_SIZE = 20;
@@ -21,6 +22,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
onClose,
selectedOptions,
onSelect,
singleSelect = false,
}) => {
// 设备数据
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
@@ -110,6 +112,15 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
// 处理设备选择
const handleDeviceToggle = (device: DeviceSelectionItem) => {
if (singleSelect) {
// 单选模式:如果已选中,则取消选择;否则替换为当前设备
if (tempSelectedOptions.some(v => v.id === device.id)) {
setTempSelectedOptions([]);
} else {
setTempSelectedOptions([device]);
}
} else {
// 多选模式:原有的逻辑
if (tempSelectedOptions.some(v => v.id === device.id)) {
setTempSelectedOptions(
tempSelectedOptions.filter(v => v.id !== device.id),
@@ -117,6 +128,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
} else {
const newSelectedOptions = [...tempSelectedOptions, device];
setTempSelectedOptions(newSelectedOptions);
}
}
};
@@ -179,6 +191,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
totalPages={totalPages}
loading={loading}
selectedCount={tempSelectedOptions.length}
singleSelect={singleSelect}
onPageChange={setCurrentPage}
onCancel={onClose}
onConfirm={() => {
@@ -187,7 +200,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
onClose();
}}
isAllSelected={isCurrentPageAllSelected}
onSelectAll={handleSelectAllCurrentPage}
onSelectAll={singleSelect ? undefined : handleSelectAllCurrentPage}
/>
}
>

View File

@@ -95,9 +95,9 @@ export default function FriendSelection({
{(selectedOptions || []).map(friend => (
<div key={friend.id} className={style.selectedListRow}>
<div className={style.selectedListRowContent}>
<Avatar src={friend.avatar} />
<Avatar src={friend.avatar || friend.friendAvatar} />
<div className={style.selectedListRowContentText}>
<div>{friend.nickname}</div>
<div>{friend.nickname || friend.friendName}</div>
<div>{friend.wechatId}</div>
</div>
{!readonly && (

View File

@@ -14,6 +14,7 @@ interface PopupFooterProps {
// 全选功能相关
isAllSelected?: boolean;
onSelectAll?: (checked: boolean) => void;
singleSelect?: boolean;
}
const PopupFooter: React.FC<PopupFooterProps> = ({
@@ -26,11 +27,13 @@ const PopupFooter: React.FC<PopupFooterProps> = ({
onConfirm,
isAllSelected = false,
onSelectAll,
singleSelect = false,
}) => {
return (
<>
{/* 分页栏 */}
<div className={style.paginationRow}>
{onSelectAll && (
<div className={style.totalCount}>
<Checkbox
checked={isAllSelected}
@@ -40,6 +43,7 @@ const PopupFooter: React.FC<PopupFooterProps> = ({
</Checkbox>
</div>
)}
<div className={style.paginationControls}>
<Button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
@@ -61,7 +65,13 @@ const PopupFooter: React.FC<PopupFooterProps> = ({
</div>
</div>
<div className={style.popupFooter}>
<div className={style.selectedCount}> {selectedCount} </div>
<div className={style.selectedCount}>
{singleSelect
? selectedCount > 0
? "已选择设备"
: "未选择设备"
: `已选择 ${selectedCount} 条记录`}
</div>
<div className={style.footerBtnGroup}>
<Button color="primary" variant="filled" onClick={onCancel}>

View File

@@ -149,14 +149,14 @@ const Home: React.FC = () => {
<div className={style["stat-card"]} onClick={handleDevicesClick}>
<div className={style["stat-label"]}></div>
<div className={style["stat-value"]}>
<span>{dashboard.deviceNum || 42}</span>
<span>{dashboard.deviceNum || 0}</span>
<MobileOutlined style={{ fontSize: 20, color: "#3b82f6" }} />
</div>
</div>
<div className={style["stat-card"]} onClick={handleWechatClick}>
<div className={style["stat-label"]}></div>
<div className={style["stat-value"]}>
<span>{dashboard.wechatNum || 42}</span>
<span>{dashboard.wechatNum || 0}</span>
<TeamOutlined style={{ fontSize: 20, color: "#3b82f6" }} />
</div>
</div>
@@ -166,7 +166,7 @@ const Home: React.FC = () => {
>
<div className={style["stat-label"]}>线</div>
<div className={style["stat-value"]}>
<span>{dashboard.aliveWechatNum || 35}</span>
<span>{dashboard.aliveWechatNum || 0}</span>
<LineChartOutlined style={{ fontSize: 20, color: "#3b82f6" }} />
</div>
<div className={style["progress-bar"]}>

View File

@@ -14,7 +14,7 @@ export function getContentLibraryDetail(id: string): Promise<any> {
export function createContentLibrary(
params: CreateContentLibraryParams,
): Promise<any> {
return request("/v1/content/library/create", params, "POST");
return request("/v1/content/library/create", { ...params, formType: 0 }, "POST");
}
// 更新内容库

View File

@@ -1,23 +1,37 @@
.form-page {
background: #f7f8fa;
padding: 16px;
padding-bottom: 100px;
}
.form-main {
max-width: 420px;
margin: 0 auto;
padding: 16px 0 0 0;
padding: 0;
}
.form-section {
margin-bottom: 18px;
&:last-child {
margin-bottom: 0;
}
}
.form-card {
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
padding: 24px 18px 18px 18px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
padding: 16px;
background: #fff;
margin-bottom: 12px;
}
.source-tag {
font-size: 14px;
padding: 4px 16px;
border-radius: 16px;
margin-top: 8px;
display: inline-block;
}
.form-label {
@@ -32,9 +46,23 @@
font-size: 16px;
font-weight: 700;
color: #222;
margin-top: 28px;
margin-top: 20px;
margin-bottom: 12px;
letter-spacing: 0.5px;
padding-left: 8px;
position: relative;
&::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
background: #1890ff;
border-radius: 2px;
}
}
.section-block {
@@ -47,40 +75,78 @@
.adm-tabs-header {
background: #f7f8fa;
border-radius: 8px;
margin-bottom: 8px;
margin-bottom: 12px;
padding: 4px;
}
.adm-tabs-tab {
font-size: 15px;
font-size: 14px;
font-weight: 500;
padding: 8px 0;
padding: 8px 20px;
border-radius: 6px;
transition: all 0.2s;
&.adm-tabs-tab-active {
background: #1890ff;
color: #fff;
font-weight: 600;
}
}
.adm-tabs-content {
padding: 0;
}
}
.collapse {
margin-top: 12px;
.keyword-collapse {
.adm-collapse-panel-header {
padding: 0;
background: transparent;
border: none;
}
.adm-collapse-panel-content {
padding-bottom: 8px;
background: #f8fafc;
border-radius: 10px;
padding: 18px 14px 10px 14px;
margin-top: 2px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
padding: 16px 0 0 0;
background: transparent;
}
.form-section {
margin-bottom: 22px;
margin-bottom: 18px;
&:last-child {
margin-bottom: 0;
}
}
.form-label {
font-size: 15px;
font-weight: 500;
margin-bottom: 4px;
margin-bottom: 8px;
color: #333;
display: block;
}
.adm-input {
min-height: 42px;
font-size: 15px;
border-radius: 7px;
margin-bottom: 2px;
}
}
.keyword-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0;
}
.keyword-title {
font-size: 16px;
font-weight: 600;
color: #222;
}
.keyword-arrow {
font-size: 12px;
color: #999;
transition: transform 0.3s;
}
.keyword-collapse .adm-collapse-panel-active .keyword-arrow {
transform: rotate(180deg);
}
.ai-row,
@@ -91,9 +157,53 @@
}
.ai-desc {
color: #888;
font-size: 13px;
color: #666;
font-size: 14px;
flex: 1;
line-height: 1.5;
}
.content-type-header {
margin-bottom: 12px;
}
.content-type-title {
font-size: 16px;
font-weight: 600;
color: #222;
}
.content-type-buttons {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.content-type-btn {
padding: 10px 24px;
border: none;
border-radius: 8px;
background: #1890ff;
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
outline: none;
&:not(.active) {
background: #f5f5f5;
color: #666;
}
&:hover {
opacity: 0.9;
}
&.active {
background: #1890ff;
color: #fff;
}
}
.date-row,
@@ -109,6 +219,179 @@
border-radius: 8px;
}
.time-limit-header {
margin-bottom: 12px;
}
.time-limit-title {
font-size: 16px;
font-weight: 600;
color: #222;
}
.date-inputs {
display: flex;
gap: 16px;
align-items: flex-start;
}
.date-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.date-label {
font-size: 14px;
color: #666;
white-space: nowrap;
}
.date-input {
width: 100%;
.ant-input {
background: #f5f5f5;
border: 1px solid #e5e6eb;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
color: #222;
cursor: pointer;
&:hover {
border-color: #1890ff;
}
&:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}
}
}
.enable-section {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0;
}
.enable-label {
font-size: 15px;
font-weight: 500;
color: #222;
}
.device-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #e5e6eb;
border-radius: 8px;
background: #fff;
cursor: pointer;
margin-top: 8px;
transition: all 0.2s;
&:hover {
border-color: #1890ff;
}
}
.device-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-text {
font-size: 16px;
color: #fff;
font-weight: 700;
}
}
.device-info {
flex: 1;
min-width: 0;
}
.device-name-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.device-name {
font-size: 15px;
font-weight: 500;
color: #222;
}
.device-tag {
font-size: 12px;
padding: 2px 8px;
background: #f5f5f5;
color: #666;
border-radius: 4px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.online {
background: #52c41a;
}
&.offline {
background: #ff4d4f;
}
}
.device-wechat-id {
font-size: 13px;
color: #888;
}
.device-arrow {
color: #999;
font-size: 14px;
}
.device-input-wrapper {
margin-top: 8px;
}
.device-input {
width: 100%;
}
.keyword-select {
width: 100%;
.ant-select-selector {
min-height: 44px;
border-radius: 8px;
}
}
.submit-btn {
margin-top: 32px;
height: 48px !important;

View File

@@ -1,16 +1,19 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Input as AntdInput, Switch } from "antd";
import { Input as AntdInput, Switch, Tag } from "antd";
import { Button, Collapse, Toast, DatePicker, Tabs } from "antd-mobile";
import { DownOutlined } from "@ant-design/icons";
import NavCommon from "@/components/NavCommon";
import FriendSelection from "@/components/FriendSelection";
import GroupSelection from "@/components/GroupSelection";
import DeviceSelection from "@/components/DeviceSelection";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import request from "@/api/request";
import { getContentLibraryDetail, updateContentLibrary } from "./api";
import { GroupSelectionItem } from "@/components/GroupSelection/data";
import { FriendSelectionItem } from "@/components/FriendSelection/data";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
const { TextArea } = AntdInput;
@@ -29,6 +32,15 @@ export default function ContentForm() {
const isEdit = !!id;
const [sourceType, setSourceType] = useState<"friends" | "groups">("friends");
const [name, setName] = useState("");
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>([]);
const [deviceSelectionVisible, setDeviceSelectionVisible] = useState(false);
// 处理设备选择(单选模式)
const handleDeviceSelect = (devices: DeviceSelectionItem[]) => {
// 单选模式:只保留最后一个选中的设备
setSelectedDevices(devices.length > 0 ? [devices[devices.length - 1]] : []);
setDeviceSelectionVisible(false);
};
const [friendsGroups, setSelectedFriends] = useState<string[]>([]);
const [friendsGroupsOptions, setSelectedFriendsOptions] = useState<
FriendSelectionItem[]
@@ -64,6 +76,23 @@ export default function ContentForm() {
.then(data => {
setName(data.name || "");
setSourceType(data.sourceType === 1 ? "friends" : "groups");
// 详情接口中的采集设备数据可能在 devices / selectedDevices / deviceGroupsOptions 中
const deviceOptions: DeviceSelectionItem[] =
data.devices ||
data.selectedDevices ||
(data.deviceGroupsOptions
? (data.deviceGroupsOptions as any[]).map(item => ({
id: item.id,
memo: item.memo,
imei: item.imei,
wechatId: item.wechatId,
status: item.alive === 1 ? "online" : "offline",
nickname: item.nickname,
avatar: item.avatar,
totalFriend: item.totalFriend,
}))
: []);
setSelectedDevices(deviceOptions || []);
setSelectedFriends(data.sourceFriends || []);
setSelectedGroups(data.selectedGroups || []);
setSelectedGroupsOptions(data.selectedGroupsOptions || []);
@@ -72,7 +101,13 @@ export default function ContentForm() {
setKeywordsExclude((data.keywordExclude || []).join(","));
setCatchType(data.catchType || ["text", "image", "video"]);
setAIPrompt(data.aiPrompt || "");
// aiEnabled 为 AI 提示词开关1 开启 0 关闭
if (typeof data.aiEnabled !== "undefined") {
setUseAI(data.aiEnabled === 1);
} else {
// 兼容旧数据,默认根据是否有 aiPrompt 判断
setUseAI(!!data.aiPrompt);
}
setEnabled(data.status === 1);
// 时间范围
const start = data.timeStart || data.startTime;
@@ -103,6 +138,7 @@ export default function ContentForm() {
const payload = {
name,
sourceType: sourceType === "friends" ? 1 : 2,
devices: selectedDevices.map(d => d.id),
friendsGroups: friendsGroups,
wechatGroups: selectedGroups,
groupMembers: {},
@@ -116,6 +152,7 @@ export default function ContentForm() {
.filter(Boolean),
catchType,
aiPrompt,
aiEnabled: useAI ? 1 : 0,
timeEnabled: dateRange[0] || dateRange[1] ? 1 : 0,
startTime: dateRange[0] ? formatDate(dateRange[0]) : "",
endTime: dateRange[1] ? formatDate(dateRange[1]) : "",
@@ -125,7 +162,7 @@ export default function ContentForm() {
await updateContentLibrary({ id, ...payload });
Toast.show({ content: "保存成功", position: "top" });
} else {
await request("/v1/content/library/create", payload, "POST");
await request("/v1/content/library/create", { ...payload, formType: 0 }, "POST");
Toast.show({ content: "创建成功", position: "top" });
}
navigate("/mine/content");
@@ -178,7 +215,7 @@ export default function ContentForm() {
onSubmit={e => e.preventDefault()}
autoComplete="off"
>
<div className={style["form-section"]}>
<div className={style["form-card"]}>
<label className={style["form-label"]}>
<span style={{ color: "#ff4d4f", marginRight: 4 }}>*</span>
@@ -191,8 +228,100 @@ export default function ContentForm() {
/>
</div>
<div className={style["section-title"]}></div>
<div className={style["form-section"]}>
<div className={style["form-card"]}>
<label className={style["form-label"]}>
<span style={{ color: "#ff4d4f", marginRight: 4 }}>*</span>
</label>
<Tag color="blue" className={style["source-tag"]}>
</Tag>
</div>
<div className={style["form-card"]}>
<label className={style["form-label"]}>
<span style={{ color: "#ff4d4f", marginRight: 4 }}>*</span>
</label>
{selectedDevices.length > 0 ? (
<div
className={style["device-card"]}
onClick={() => setDeviceSelectionVisible(true)}
>
<div className={style["device-avatar"]}>
{selectedDevices[0].avatar ? (
<img
src={selectedDevices[0].avatar}
alt="头像"
className={style["avatar-img"]}
/>
) : (
<span className={style["avatar-text"]}>
{(selectedDevices[0].memo ||
selectedDevices[0].wechatId ||
"设")[0]}
</span>
)}
</div>
<div className={style["device-info"]}>
<div className={style["device-name-row"]}>
<span className={style["device-name"]}>
{selectedDevices[0].nickname || selectedDevices[0].memo}
</span>
{selectedDevices[0].memo &&
selectedDevices[0].nickname &&
selectedDevices[0].memo !== selectedDevices[0].nickname && (
<span className={style["device-tag"]}>
{selectedDevices[0].memo}
</span>
)}
<span
className={`${style["status-dot"]} ${
selectedDevices[0].status === "online"
? style["online"]
: style["offline"]
}`}
/>
</div>
<div className={style["device-wechat-id"]}>
ID: {selectedDevices[0].wechatId}
</div>
</div>
<div className={style["device-arrow"]}>
<DownOutlined />
</div>
</div>
) : (
<div className={style["device-input-wrapper"]}>
<DeviceSelection
selectedOptions={selectedDevices}
onSelect={handleDeviceSelect}
placeholder="选择采集设备"
showInput={true}
showSelectedList={false}
singleSelect={true}
className={style["device-input"]}
/>
</div>
)}
{/* 隐藏的设备选择组件,用于打开弹窗 */}
<div style={{ display: "none" }}>
<DeviceSelection
selectedOptions={selectedDevices}
onSelect={handleDeviceSelect}
placeholder="选择采集设备"
showInput={false}
showSelectedList={false}
singleSelect={true}
mode="dialog"
open={deviceSelectionVisible}
onOpenChange={setDeviceSelectionVisible}
/>
</div>
</div>
<div className={style["section-title"]}></div>
<div className={style["form-card"]}>
<Tabs
activeKey={sourceType}
onChange={key => setSourceType(key as "friends" | "groups")}
@@ -203,6 +332,7 @@ export default function ContentForm() {
selectedOptions={friendsGroupsOptions}
onSelect={handleFriendsChange}
placeholder="选择微信好友"
deviceIds={selectedDevices.map(d => Number(d.id))}
/>
</Tabs.Tab>
<Tabs.Tab title="选择聊天群" key="groups">
@@ -215,10 +345,16 @@ export default function ContentForm() {
</Tabs>
</div>
<Collapse className={style["collapse"]}>
<div className={style["form-card"]}>
<Collapse className={style["keyword-collapse"]}>
<Collapse.Panel
key="keywords"
title={<span className={style["form-label"]}></span>}
title={
<div className={style["keyword-header"]}>
<span className={style["keyword-title"]}></span>
<DownOutlined className={style["keyword-arrow"]} />
</div>
}
>
<div className={style["form-section"]}>
<label className={style["form-label"]}></label>
@@ -242,27 +378,20 @@ export default function ContentForm() {
</div>
</Collapse.Panel>
</Collapse>
</div>
{/* 采集内容类型 */}
<div className={style["section-title"]}></div>
<div className={style["form-section"]}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 12 }}>
<div className={style["form-card"]}>
<div className={style["content-type-header"]}>
<span className={style["content-type-title"]}></span>
</div>
<div className={style["content-type-buttons"]}>
{["text", "image", "video"].map(type => (
<div
<button
key={type}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
border: "1px solid #d9d9d9",
borderRadius: "6px",
backgroundColor: catchType.includes(type)
? "#1890ff"
: "#fff",
color: catchType.includes(type) ? "#fff" : "#333",
cursor: "pointer",
}}
className={`${style["content-type-btn"]} ${
catchType.includes(type) ? style["active"] : ""
}`}
onClick={() => {
setCatchType(prev =>
prev.includes(type)
@@ -271,26 +400,24 @@ export default function ContentForm() {
);
}}
>
<span>
{type === "text"
? "文本"
: type === "image"
? "图片"
: "视频"}
</span>
</div>
</button>
))}
</div>
</div>
<div className={style["section-title"]}>AI</div>
<div className={style["form-card"]}>
<div
className={style["form-section"]}
style={{ display: "flex", alignItems: "center", gap: 12 }}
>
<Switch checked={useAI} onChange={setUseAI} />
<span className={style["ai-desc"]}>
AI后AI生成
,AI生成
</span>
</div>
{useAI && (
@@ -305,19 +432,24 @@ export default function ContentForm() {
/>
</div>
)}
</div>
<div className={style["section-title"]}></div>
<div
className={style["form-section"]}
style={{ display: "flex", gap: 12 }}
>
<label></label>
<div style={{ flex: 1 }}>
<div className={style["form-card"]}>
<div className={style["time-limit-header"]}>
<span className={style["time-limit-title"]}></span>
</div>
<div className={style["date-inputs"]}>
<div className={style["date-item"]}>
<label className={style["date-label"]}></label>
<AntdInput
readOnly
value={dateRange[0] ? dateRange[0].toLocaleDateString() : ""}
value={
dateRange[0]
? `${dateRange[0].getFullYear()}/${String(dateRange[0].getMonth() + 1).padStart(2, "0")}/${String(dateRange[0].getDate()).padStart(2, "0")}`
: ""
}
placeholder="年/月/日"
className={style["input"]}
className={style["date-input"]}
onClick={() => setShowStartPicker(true)}
/>
<DatePicker
@@ -331,13 +463,17 @@ export default function ContentForm() {
}}
/>
</div>
<label></label>
<div style={{ flex: 1 }}>
<div className={style["date-item"]}>
<label className={style["date-label"]}></label>
<AntdInput
readOnly
value={dateRange[1] ? dateRange[1].toLocaleDateString() : ""}
value={
dateRange[1]
? `${dateRange[1].getFullYear()}/${String(dateRange[1].getMonth() + 1).padStart(2, "0")}/${String(dateRange[1].getDate()).padStart(2, "0")}`
: ""
}
placeholder="年/月/日"
className={style["input"]}
className={style["date-input"]}
onClick={() => setShowEndPicker(true)}
/>
<DatePicker
@@ -350,21 +486,15 @@ export default function ContentForm() {
setShowEndPicker(false);
}}
/>
</div>
</div>
</div>
<div
className={style["section-title"]}
style={{
marginTop: 24,
marginBottom: 8,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span></span>
<div className={style["form-card"]}>
<div className={style["enable-section"]}>
<span className={style["enable-label"]}></span>
<Switch checked={enabled} onChange={setEnabled} />
</div>
</div>
</form>
</div>

View File

@@ -12,7 +12,7 @@ export function getContentLibraryList(params: {
keyword?: string;
sourceType?: number;
}): Promise<any> {
return request("/v1/content/library/list", params, "GET");
return request("/v1/content/library/list", { ...params, formType: 0 }, "GET");
}
// 获取内容库详情
@@ -24,7 +24,7 @@ export function getContentLibraryDetail(id: string): Promise<any> {
export function createContentLibrary(
params: CreateContentLibraryParams,
): Promise<any> {
return request("/v1/content/library/create", params, "POST");
return request("/v1/content/library/create", { ...params, formType: 0 }, "POST");
}
// 更新内容库

View File

@@ -47,3 +47,11 @@ export function aiRewriteContent(params: AIRewriteParams) {
export function replaceContent(params: ReplaceContentParams) {
return request("/v1/content/library/aiEditContent", params, "POST");
}
// 导入Excel素材
export function importMaterialsFromExcel(params: {
id: string;
fileUrl: string;
}) {
return request("/v1/content/library/import-excel", params, "POST");
}

View File

@@ -776,3 +776,87 @@
}
}
}
// 导入弹窗样式
.import-popup-content {
padding: 20px;
max-height: 90vh;
overflow-y: auto;
background: #ffffff;
.import-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #e8f4ff;
h3 {
font-size: 18px;
font-weight: 600;
color: #1677ff;
margin: 0;
display: flex;
align-items: center;
&::before {
content: '';
display: inline-block;
width: 4px;
height: 18px;
background: #1677ff;
margin-right: 8px;
border-radius: 2px;
}
}
}
.import-form {
.import-form-item {
margin-bottom: 20px;
.import-form-label {
font-size: 15px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
display: flex;
align-items: center;
&::before {
content: '';
display: inline-block;
width: 3px;
height: 14px;
background: #1677ff;
margin-right: 6px;
border-radius: 2px;
}
}
.import-form-control {
.import-tip {
font-size: 12px;
color: #999;
margin-top: 8px;
line-height: 1.5;
}
}
}
.import-actions {
margin-top: 24px;
button {
height: 44px;
font-size: 16px;
border-radius: 8px;
&:first-child {
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.2);
}
}
}
}
}

View File

@@ -15,10 +15,12 @@ import {
VideoCameraOutlined,
FileTextOutlined,
AppstoreOutlined,
UploadOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import { getContentItemList, deleteContentItem, aiRewriteContent, replaceContent } from "./api";
import { getContentItemList, deleteContentItem, aiRewriteContent, replaceContent, importMaterialsFromExcel } from "./api";
import FileUpload from "@/components/Upload/FileUpload";
import { ContentItem } from "./data";
import style from "./index.module.scss";
@@ -50,6 +52,11 @@ const MaterialsList: React.FC = () => {
const [aiLoading, setAiLoading] = useState(false);
const [replaceLoading, setReplaceLoading] = useState(false);
// 导入相关状态
const [showImportPopup, setShowImportPopup] = useState(false);
const [importFileUrl, setImportFileUrl] = useState<string>("");
const [importLoading, setImportLoading] = useState(false);
// 获取素材列表
const fetchMaterials = useCallback(async () => {
if (!id) return;
@@ -187,6 +194,64 @@ const MaterialsList: React.FC = () => {
fetchMaterials();
};
// 处理导入文件上传
const handleImportFileChange = (fileInfo: { fileName: string; fileUrl: string }) => {
setImportFileUrl(fileInfo.fileUrl);
};
// 执行导入
const handleImport = async () => {
if (!id) {
Toast.show({
content: "内容库ID不存在",
position: "top",
});
return;
}
if (!importFileUrl) {
Toast.show({
content: "请先上传Excel文件",
position: "top",
});
return;
}
try {
setImportLoading(true);
await importMaterialsFromExcel({
id: id,
fileUrl: importFileUrl,
});
Toast.show({
content: "导入成功",
position: "top",
});
// 关闭弹窗并重置状态
setShowImportPopup(false);
setImportFileUrl("");
// 刷新素材列表
fetchMaterials();
} catch (error: unknown) {
console.error("导入失败:", error);
Toast.show({
content: error instanceof Error ? error.message : "导入失败,请重试",
position: "top",
});
} finally {
setImportLoading(false);
}
};
// 关闭导入弹窗
const closeImportPopup = () => {
setShowImportPopup(false);
setImportFileUrl("");
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
@@ -354,9 +419,18 @@ const MaterialsList: React.FC = () => {
title="素材管理"
backFn={() => navigate("/mine/content")}
right={
<Button type="primary" onClick={handleCreateNew}>
<PlusOutlined />
</Button>
<>
<Button
type="default"
onClick={() => setShowImportPopup(true)}
style={{ marginRight: 8 }}
>
<UploadOutlined />
</Button>
<Button type="primary" onClick={handleCreateNew}>
<PlusOutlined />
</Button>
</>
}
/>
{/* 搜索栏 */}
@@ -586,6 +660,71 @@ const MaterialsList: React.FC = () => {
</div>
</div>
</Popup>
{/* 导入弹窗 */}
<Popup
visible={showImportPopup}
onMaskClick={closeImportPopup}
bodyStyle={{
borderRadius: "16px 16px 0 0",
maxHeight: "90vh",
}}
>
<div className={style["import-popup-content"]}>
<div className={style["import-popup-header"]}>
<h3></h3>
<Button
size="small"
onClick={closeImportPopup}
>
</Button>
</div>
<div className={style["import-form"]}>
<div className={style["import-form-item"]}>
<div className={style["import-form-label"]}>Excel文件</div>
<div className={style["import-form-control"]}>
<FileUpload
value={importFileUrl}
onChange={(url) => {
const fileUrl = Array.isArray(url) ? url[0] : url;
setImportFileUrl(fileUrl || "");
}}
acceptTypes={["excel"]}
maxSize={50}
maxCount={1}
showPreview={false}
/>
<div className={style["import-tip"]}>
Excel格式的文件50MB
</div>
</div>
</div>
<div className={style["import-actions"]}>
<Button
block
color="primary"
onClick={handleImport}
loading={importLoading}
disabled={importLoading || !importFileUrl}
>
{importLoading ? "导入中..." : "确认导入"}
</Button>
<Button
block
color="danger"
fill="outline"
onClick={closeImportPopup}
style={{ marginTop: 12 }}
>
</Button>
</div>
</div>
</div>
</Popup>
</Layout>
);
};

View File

@@ -42,3 +42,7 @@ export const fetchDeviceQRCode = (accountId: string) =>
// 通过IMEI添加设备
export const addDeviceByImei = (imei: string, name: string) =>
request("/v1/api/device/add-by-imei", { imei, name }, "POST");
// 获取设备添加结果(用于轮询检查)
export const fetchAddResults = (params: { accountId?: string }) =>
request("/v1/devices/add-results", params, "GET");

View File

@@ -15,6 +15,7 @@ import {
fetchDeviceQRCode,
addDeviceByImei,
deleteDevice,
fetchAddResults,
} from "./api";
import type { Device } from "@/types/device";
import { comfirm } from "@/utils/common";
@@ -44,12 +45,18 @@ const Devices: React.FC = () => {
const [name, setName] = useState("");
const [addLoading, setAddLoading] = useState(false);
// 轮询监听相关
const [isPolling, setIsPolling] = useState(false);
const pollingRef = useRef<NodeJS.Timeout | null>(null);
const loadDevicesRef = useRef<((reset?: boolean) => Promise<void>) | null>(null);
// 删除弹窗
const [delVisible, setDelVisible] = useState(false);
const [delLoading, setDelLoading] = useState(false);
const navigate = useNavigate();
const { user } = useUserStore();
// 加载设备列表
const loadDevices = useCallback(
async (reset = false) => {
@@ -74,6 +81,11 @@ const Devices: React.FC = () => {
[loading, search, page],
);
// 更新 loadDevices 的 ref
useEffect(() => {
loadDevicesRef.current = loadDevices;
}, [loadDevices]);
// 首次加载和搜索
useEffect(() => {
loadDevices(true);
@@ -110,6 +122,56 @@ const Devices: React.FC = () => {
return true;
});
// 开始轮询监听设备状态
const startPolling = useCallback(() => {
if (isPolling) return;
setIsPolling(true);
const pollDeviceStatus = async () => {
try {
const res = await fetchAddResults({ accountId: user?.s2_accountId });
if (res.added) {
Toast.show({ content: "设备添加成功!", position: "top" });
setAddVisible(false);
setIsPolling(false);
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
// 刷新设备列表
if (loadDevicesRef.current) {
await loadDevicesRef.current(true);
}
return;
}
} catch (error) {
console.error("轮询检查设备状态失败:", error);
}
};
// 每3秒检查一次设备状态
pollingRef.current = setInterval(pollDeviceStatus, 3000);
}, [isPolling, user?.s2_accountId]);
// 停止轮询
const stopPolling = useCallback(() => {
setIsPolling(false);
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}, []);
// 组件卸载时清理轮询
useEffect(() => {
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
}
};
}, []);
// 获取二维码
const handleGetQr = async () => {
setQrLoading(true);
@@ -119,6 +181,8 @@ const Devices: React.FC = () => {
if (!accountId) throw new Error("未获取到用户信息");
const res = await fetchDeviceQRCode(accountId);
setQrCode(res.qrCode);
// 获取二维码后开始轮询监听
startPolling();
} catch (e: any) {
Toast.show({ content: e.message || "获取二维码失败", position: "top" });
} finally {
@@ -362,7 +426,11 @@ const Devices: React.FC = () => {
{/* 添加设备弹窗 */}
<Popup
visible={addVisible}
onMaskClick={() => setAddVisible(false)}
onMaskClick={() => {
setAddVisible(false);
stopPolling();
setQrCode(null);
}}
bodyStyle={{
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
@@ -403,6 +471,13 @@ const Devices: React.FC = () => {
<div style={{ color: "#888", fontSize: 12, marginTop: 8 }}>
</div>
{isPolling && (
<div
style={{ color: "#1890ff", fontSize: 12, marginTop: 8 }}
>
...
</div>
)}
</div>
)}
</div>

View File

@@ -4,7 +4,7 @@ import request from "@/api/request";
export interface PowerPackage {
id: number;
name: string;
tokens: number; // 算力点数
tokens: number | string; // 算力点数(可能是字符串,如"2,800"
price: number; // 价格(分)
originalPrice: number; // 原价(分)
unitPrice: number; // 单价
@@ -13,7 +13,7 @@ export interface PowerPackage {
isRecommend: number; // 是否推荐
isHot: number; // 是否热门
isVip: number; // 是否VIP
features: string[]; // 功能特性
features?: string[]; // 功能特性(可选)
description: string[]; // 描述关键词
status: number;
createTime: string;

View File

@@ -6,6 +6,9 @@ export interface Statistics {
monthUsed: number; // 本月使用
remainingTokens: number; // 剩余算力
totalConsumed: number; // 总消耗
yesterdayUsed?: number; // 昨日消耗
historyConsumed?: number; // 历史消耗
estimatedDays?: number; // 预计可用天数
}
// 算力统计接口
export function getStatistics(): Promise<Statistics> {
@@ -143,3 +146,56 @@ export function buyPackage(params: { id: number; price: number }) {
export function buyCustomPower(params: { amount: number }) {
return request("/v1/power/buy-custom", params, "POST");
}
// 查询订单状态
export interface QueryOrderResponse {
id: number;
mchId: number;
companyId: number;
userId: number;
orderType: number;
status: number; // 0: 待支付, 1: 已支付
goodsId: number;
goodsName: string;
goodsSpecs: string;
money: number;
orderNo: string;
payType: number | null;
payTime: number | null;
payInfo: any;
createTime: number;
}
export function queryOrder(orderNo: string): Promise<QueryOrderResponse> {
return request("/v1/tokens/queryOrder", { orderNo }, "GET");
}
// 账号信息
export interface Account {
id: number;
uid?: number; // 用户ID用于分配算力
userId?: number; // 用户ID别名
userName: string;
realName: string;
nickname: string;
departmentId: number;
departmentName: string;
avatar: string;
}
// 获取账号列表
export function getAccountList(): Promise<{ list: Account[]; total: number }> {
return request("/v1/kefu/accounts/list", undefined, "GET");
}
// 分配算力接口参数
export interface AllocateTokensParams {
targetUserId: number; // 目标用户ID
tokens: number; // 分配的算力数量
remarks?: string; // 备注
}
// 分配算力
export function allocateTokens(params: AllocateTokensParams): Promise<any> {
return request("/v1/tokens/allocate", params, "POST");
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,7 @@ export interface TokensUseRecordItem {
export interface TokensUseRecordList {
list: TokensUseRecordItem[];
total?: number;
}
//算力使用明细

View File

@@ -49,6 +49,8 @@ export function transferWechatFriends(params: {
wechatId: string;
devices: number[];
inherit: boolean;
greeting?: string;
firstMessage?: string;
}) {
return request("/v1/wechats/transfer-friends", params, "POST");
}
@@ -92,6 +94,20 @@ export async function exportWechatMoments(params: {
}
);
// 检查响应类型如果是JSON错误响应需要解析错误信息
const contentType = response.headers["content-type"] || "";
if (contentType.includes("application/json")) {
// 如果是JSON响应说明可能是错误信息
const text = await response.data.text();
const errorData = JSON.parse(text);
throw new Error(errorData.message || errorData.msg || "导出失败");
}
// 检查响应状态
if (response.status !== 200) {
throw new Error(`导出失败,状态码: ${response.status}`);
}
// 创建下载链接
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
@@ -114,6 +130,32 @@ export async function exportWechatMoments(params: {
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error: any) {
throw new Error(error.response?.data?.message || error.message || "导出失败");
// 如果是我们抛出的错误,直接抛出
if (error.message && error.message !== "导出失败") {
throw error;
}
// 处理axios错误响应
if (error.response) {
// 如果响应是blob类型尝试读取为文本
if (error.response.data instanceof Blob) {
try {
const text = await error.response.data.text();
const errorData = JSON.parse(text);
throw new Error(errorData.message || errorData.msg || "导出失败");
} catch (parseError) {
throw new Error("导出失败,请重试");
}
} else {
throw new Error(
error.response.data?.message ||
error.response.data?.msg ||
error.message ||
"导出失败"
);
}
} else {
throw new Error(error.message || "导出失败");
}
}
}

View File

@@ -339,8 +339,8 @@
height: 5px;
border-radius: 10px 10px 0 0;
background: #1677ff;
}
}
}
.stat-icon-chat {
width: 20px;
@@ -373,6 +373,12 @@
font-weight: 600;
color: #52c41a;
}
.stat-value-negative {
font-size: 20px;
font-weight: 600;
color: #ff4d4f;
}
}
}
@@ -700,7 +706,7 @@
.adm-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
border-radius: 50%;
}
}
@@ -710,13 +716,13 @@
}
.friend-name-row {
display: flex;
align-items: center;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
margin-bottom: 4px;
}
.friend-name {
.friend-name {
font-size: 15px;
font-weight: 600;
color: #111;
@@ -727,7 +733,7 @@
display: flex;
flex-wrap: wrap;
gap: 4px;
}
}
.friend-tag {
font-size: 11px;
@@ -735,17 +741,17 @@
border-radius: 999px;
background: #f5f5f5;
color: #666;
}
}
.friend-id-row {
font-size: 12px;
font-size: 12px;
color: #999;
margin-bottom: 6px;
}
}
.friend-status-row {
display: flex;
flex-wrap: wrap;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
@@ -764,7 +770,7 @@
font-size: 11px;
color: #999;
margin-bottom: 4px;
}
}
.value-amount {
font-size: 14px;
@@ -868,9 +874,10 @@
.type-selector {
display: flex;
gap: 8px;
flex-wrap: wrap;
width: 100%;
.type-option {
flex: 1;
padding: 8px 16px;
border: 1px solid #e0e0e0;
border-radius: 8px;
@@ -879,6 +886,7 @@
cursor: pointer;
transition: all 0.2s;
background: white;
text-align: center;
&:hover {
border-color: #1677ff;
@@ -1331,82 +1339,54 @@
}
}
.moments-action-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: white;
border-bottom: 1px solid #f0f0f0;
.action-button, .action-button-dark {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 8px;
background: #1677ff;
border: none;
cursor: pointer;
transition: all 0.2s;
color: white;
&:active {
background: #0958d9;
transform: scale(0.95);
}
svg {
font-size: 20px;
color: white;
}
}
.action-button-dark {
background: #1677ff;
color: white;
&:active {
background: #0958d9;
}
}
}
.moments-content {
padding: 16px 0;
height: 500px;
overflow-y: auto;
background: #f5f5f5;
.moments-action-bar {
display: flex;
justify-content: space-between;
padding: 0 16px 16px;
.action-button, .action-button-dark {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 70px;
height: 40px;
border-radius: 8px;
background: #1677ff;
.action-icon-text, .action-icon-image, .action-icon-video, .action-icon-export {
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
margin-bottom: 2px;
position: relative;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 2px;
background: white;
}
}
.action-icon-image::after {
content: '';
position: absolute;
top: 6px;
left: 6px;
width: 8px;
height: 8px;
border-radius: 2px;
background: white;
}
.action-icon-video::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 0;
height: 0;
border-style: solid;
border-width: 5px 0 5px 8px;
border-color: transparent transparent transparent white;
}
.action-text, .action-text-light {
font-size: 12px;
color: white;
}
}
.action-button-dark {
background: #333;
}
}
.moments-list {
padding: 0 16px;
@@ -1459,7 +1439,7 @@
.image-grid {
display: grid;
gap: 8px;
gap: 4px;
width: 100%;
// 1张图片宽度拉伸高度自适应
@@ -1469,56 +1449,57 @@
img {
width: 100%;
height: auto;
max-height: 400px;
object-fit: cover;
border-radius: 8px;
border-radius: 4px;
}
}
// 2张图片左右并列
// 2张图片左右并列1:1比例
&.double {
grid-template-columns: 1fr 1fr;
img {
width: 100%;
height: 120px;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 8px;
border-radius: 4px;
}
}
// 3张图片三张并列
// 3张图片三张并列1:1比例
&.triple {
grid-template-columns: 1fr 1fr 1fr;
img {
width: 100%;
height: 100px;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 8px;
border-radius: 4px;
}
}
// 4张图片2x2网格布局
// 4张图片2x2网格布局1:1比例
&.quad {
grid-template-columns: repeat(2, 1fr);
img {
width: 100%;
height: 140px;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 8px;
border-radius: 4px;
}
}
// 5张及以上网格布局9宫格
// 5张及以上网格布局9宫格1:1比例
&.grid {
grid-template-columns: repeat(3, 1fr);
img {
width: 100%;
height: 100px;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 8px;
border-radius: 4px;
}
.image-more {
@@ -1526,11 +1507,11 @@
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
border-radius: 4px;
color: white;
font-size: 12px;
font-weight: 500;
height: 100px;
aspect-ratio: 1 / 1;
}
}
}
@@ -1547,6 +1528,34 @@
}
}
}
.moments-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 16px 0;
}
.moments-no-more {
display: flex;
align-items: center;
justify-content: center;
padding: 16px 0;
}
}
.friends-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 16px 0;
}
.friends-no-more {
display: flex;
align-items: center;
justify-content: center;
padding: 16px 0;
}
}

View File

@@ -12,13 +12,18 @@ import {
Tag,
Switch,
DatePicker,
InfiniteScroll,
} from "antd-mobile";
import { Input, Pagination } from "antd";
import { Input } from "antd";
import NavCommon from "@/components/NavCommon";
import {
SearchOutlined,
ReloadOutlined,
UserOutlined,
FileTextOutlined,
PictureOutlined,
VideoCameraOutlined,
DownloadOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import style from "./detail.module.scss";
@@ -32,6 +37,7 @@ import {
} from "./api";
import DeviceSelection from "@/components/DeviceSelection";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
import dayjs from "dayjs";
import { WechatAccountSummary, Friend, MomentItem } from "./data";
@@ -47,6 +53,8 @@ const WechatAccountDetail: React.FC = () => {
const [showTransferConfirm, setShowTransferConfirm] = useState(false);
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>([]);
const [inheritInfo, setInheritInfo] = useState(true);
const [greeting, setGreeting] = useState("");
const [firstMessage, setFirstMessage] = useState("");
const [transferLoading, setTransferLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState("overview");
@@ -105,6 +113,84 @@ const WechatAccountDetail: React.FC = () => {
}
}, [id]);
// 计算账号价值
// 规则:
// 1. 1个好友3块
// 2. 1个群1块
// 3. 修改过微信号10块
const calculateAccountValue = useCallback(() => {
// 获取好友数量(优先使用概览数据,其次使用好友列表总数,最后使用账号信息)
const friendsCount = overviewData?.totalFriends || friendsTotal || accountInfo?.friendShip?.totalFriend || 0;
// 获取群数量(优先使用概览数据,其次使用账号信息)
const groupsCount = overviewData?.highValueChatrooms || accountInfo?.friendShip?.groupNumber || 0;
// 判断是否修改过微信号
// 注意需要根据实际API返回的字段来判断可能的字段名
// - isWechatIdModified (布尔值)
// - wechatIdModified (布尔值)
// - hasModifiedWechatId (布尔值)
// - wechatIdChangeCount (数字大于0表示修改过)
// 如果API没有返回该字段需要后端添加或根据其他逻辑判断
const isWechatIdModified =
accountInfo?.isWechatIdModified ||
accountInfo?.wechatIdModified ||
accountInfo?.hasModifiedWechatId ||
(accountInfo?.wechatIdChangeCount && accountInfo.wechatIdChangeCount > 0) ||
false;
// 计算各部分价值
const friendsValue = friendsCount * 3; // 好友数 * 3
const groupsValue = groupsCount * 1; // 群数 * 1
const wechatIdModifiedValue = isWechatIdModified ? 10 : 0; // 修改过微信号 ? 10 : 0
// 计算总价值
const totalValue = friendsValue + groupsValue + wechatIdModifiedValue;
return {
value: totalValue,
formatted: `¥${totalValue.toLocaleString()}`,
breakdown: {
friends: friendsValue,
groups: groupsValue,
wechatIdModified: wechatIdModifiedValue,
friendsCount,
groupsCount,
isWechatIdModified,
},
};
}, [overviewData, friendsTotal, accountInfo]);
// 计算今日价值变化
// 规则:
// 1. 今日新增好友 * 3块
// 2. 今日新增群 * 1块
const calculateTodayValueChange = useCallback(() => {
// 获取今日新增好友数
const todayNewFriends = overviewData?.todayNewFriends || accountSummary?.statistics?.todayAdded || 0;
// 获取今日新增群数
const todayNewChatrooms = overviewData?.todayNewChatrooms || 0;
// 计算今日价值变化
const friendsValueChange = todayNewFriends * 3; // 今日新增好友数 * 3
const groupsValueChange = todayNewChatrooms * 1; // 今日新增群数 * 1
const totalChange = friendsValueChange + groupsValueChange;
return {
change: totalChange,
formatted: totalChange >= 0 ? `+${totalChange.toLocaleString()}` : `${totalChange.toLocaleString()}`,
isPositive: totalChange >= 0,
breakdown: {
friends: friendsValueChange,
groups: groupsValueChange,
todayNewFriends,
todayNewChatrooms,
},
};
}, [overviewData, accountSummary]);
// 获取概览数据
const fetchOverviewData = useCallback(async () => {
if (!id) return;
@@ -120,7 +206,7 @@ const WechatAccountDetail: React.FC = () => {
// 获取好友列表 - 封装为独立函数
const fetchFriendsList = useCallback(
async (page: number = 1, keyword: string = "") => {
async (page: number = 1, keyword: string = "", append: boolean = false) => {
if (!id) return;
setIsFetchingFriends(true);
@@ -130,7 +216,7 @@ const WechatAccountDetail: React.FC = () => {
const response = await getWechatFriends({
wechatAccount: id,
page: page,
limit: 5,
limit: 20,
keyword: keyword,
});
@@ -173,15 +259,17 @@ const WechatAccountDetail: React.FC = () => {
};
});
setFriends(newFriends);
setFriends(prev => (append ? [...prev, ...newFriends] : newFriends));
setFriendsTotal(response.total);
setFriendsPage(page);
setIsFriendsEmpty(newFriends.length === 0);
setIsFriendsEmpty(newFriends.length === 0 && !append);
} catch (error) {
console.error("获取好友列表失败:", error);
setHasFriendLoadError(true);
setFriends([]);
setIsFriendsEmpty(true);
if (!append) {
setFriends([]);
setIsFriendsEmpty(true);
}
Toast.show({
content: "获取好友列表失败,请检查网络连接",
position: "top",
@@ -236,22 +324,20 @@ const WechatAccountDetail: React.FC = () => {
// 搜索好友
const handleSearch = useCallback(() => {
setFriendsPage(1);
fetchFriendsList(1, searchQuery);
fetchFriendsList(1, searchQuery, false);
}, [searchQuery, fetchFriendsList]);
// 刷新好友列表
const handleRefreshFriends = useCallback(() => {
fetchFriendsList(friendsPage, searchQuery);
fetchFriendsList(friendsPage, searchQuery, false);
}, [friendsPage, searchQuery, fetchFriendsList]);
// 分页切换
const handlePageChange = useCallback(
(page: number) => {
setFriendsPage(page);
fetchFriendsList(page, searchQuery);
},
[searchQuery, fetchFriendsList],
);
// 加载更多好友
const handleLoadMoreFriends = async () => {
if (isFetchingFriends) return;
if (friends.length >= friendsTotal) return;
await fetchFriendsList(friendsPage + 1, searchQuery, true);
};
// 初始化数据
useEffect(() => {
@@ -266,7 +352,7 @@ const WechatAccountDetail: React.FC = () => {
if (activeTab === "friends" && id) {
setIsFriendsEmpty(false);
setHasFriendLoadError(false);
fetchFriendsList(1, searchQuery);
fetchFriendsList(1, searchQuery, false);
}
}, [activeTab, id, fetchFriendsList, searchQuery]);
@@ -294,6 +380,10 @@ const WechatAccountDetail: React.FC = () => {
const handleTransferFriends = () => {
setSelectedDevices([]);
setInheritInfo(true);
// 设置默认打招呼内容,使用当前微信账号昵称
const nickname = accountInfo?.nickname || "未知";
setGreeting(`我是${nickname}的新号,请通过`);
setFirstMessage("这个是我的新号,重新加你一下,以后业务就用这个号!");
setShowTransferConfirm(true);
};
@@ -321,7 +411,9 @@ const WechatAccountDetail: React.FC = () => {
await transferWechatFriends({
wechatId: id,
devices: selectedDevices.map(device => device.id),
inherit: inheritInfo
inherit: inheritInfo,
greeting: greeting.trim(),
firstMessage: firstMessage.trim()
});
Toast.show({
@@ -330,6 +422,7 @@ const WechatAccountDetail: React.FC = () => {
});
setShowTransferConfirm(false);
setSelectedDevices([]);
setFirstMessage("");
navigate("/scenarios");
} catch (error) {
console.error("好友转移失败:", error);
@@ -376,10 +469,10 @@ const WechatAccountDetail: React.FC = () => {
navigate(`/mine/traffic-pool/detail/${friend.wechatId}/${friend.id}`);
};
const handleLoadMoreMoments = () => {
const handleLoadMoreMoments = async () => {
if (isFetchingMoments) return;
if (moments.length >= momentsTotal) return;
fetchMomentsList(momentsPage + 1, true);
await fetchMomentsList(momentsPage + 1, true);
};
// 处理朋友圈导出
@@ -389,6 +482,18 @@ const WechatAccountDetail: React.FC = () => {
return;
}
// 验证时间范围不超过1个月
if (exportStartTime && exportEndTime) {
const maxDate = dayjs(exportStartTime).add(1, "month").toDate();
if (exportEndTime > maxDate) {
Toast.show({
content: "日期范围不能超过1个月",
position: "top",
});
return;
}
}
setExportLoading(true);
try {
// 格式化时间
@@ -409,18 +514,27 @@ const WechatAccountDetail: React.FC = () => {
});
Toast.show({ content: "导出成功", position: "top" });
setShowExportPopup(false);
// 重置筛选条件
// 重置筛选条件(先重置,再关闭弹窗)
setExportKeyword("");
setExportType(undefined);
setExportStartTime(null);
setExportEndTime(null);
setShowStartTimePicker(false);
setShowEndTimePicker(false);
// 延迟关闭弹窗确保Toast显示
setTimeout(() => {
setShowExportPopup(false);
}, 500);
} catch (error: any) {
console.error("导出失败:", error);
const errorMessage = error?.message || "导出失败,请重试";
Toast.show({
content: error.message || "导出失败,请重试",
content: errorMessage,
position: "top",
duration: 2000,
});
// 确保loading状态被重置
setExportLoading(false);
} finally {
setExportLoading(false);
}
@@ -539,7 +653,7 @@ const WechatAccountDetail: React.FC = () => {
<div className={style["stat-icon-up"]}></div>
</div>
<div className={style["stat-value"]}>
{overviewData?.accountValue?.formatted || `¥${overviewData?.accountValue?.value || "29,800"}`}
{calculateAccountValue().formatted}
</div>
</div>
@@ -549,8 +663,8 @@ const WechatAccountDetail: React.FC = () => {
<div className={style["stat-title"]}></div>
<div className={style["stat-icon-plus"]}></div>
</div>
<div className={style["stat-value-positive"]}>
{overviewData?.todayValueChange?.formatted || `+${overviewData?.todayValueChange?.change || "500"}`}
<div className={calculateTodayValueChange().isPositive ? style["stat-value-positive"] : style["stat-value-negative"]}>
{calculateTodayValueChange().formatted}
</div>
</div>
</div>
@@ -752,7 +866,7 @@ const WechatAccountDetail: React.FC = () => {
<div className={style["summary-item"]}>
<div className={style["summary-label"]}></div>
<div className={style["summary-value-highlight"]}>
{overviewData?.accountValue?.formatted || "¥1,500,000"}
{calculateAccountValue().formatted}
</div>
</div>
</div>
@@ -771,7 +885,7 @@ const WechatAccountDetail: React.FC = () => {
<Button
size="small"
onClick={() =>
fetchFriendsList(friendsPage, searchQuery)
fetchFriendsList(friendsPage, searchQuery, false)
}
>
@@ -828,47 +942,58 @@ const WechatAccountDetail: React.FC = () => {
)}
</div>
{/* 分页组件 */}
{friendsTotal > 20 &&
!isFriendsEmpty &&
!hasFriendLoadError && (
<div className={style["pagination-wrapper"]}>
<Pagination
total={Math.ceil(friendsTotal / 20)}
current={friendsPage}
onChange={handlePageChange}
/>
{/* 无限滚动加载 */}
<InfiniteScroll
loadMore={handleLoadMoreFriends}
hasMore={friends.length < friendsTotal}
threshold={100}
>
{isFetchingFriends && friends.length > 0 && (
<div className={style["friends-loading"]}>
<SpinLoading color="primary" style={{ fontSize: 16 }} />
<span style={{ marginLeft: 8, color: "#999", fontSize: 12 }}>
...
</span>
</div>
)}
{friends.length >= friendsTotal && friends.length > 0 && (
<div className={style["friends-no-more"]}>
<span style={{ color: "#999", fontSize: 12 }}></span>
</div>
)}
</InfiniteScroll>
</div>
</Tabs.Tab>
<Tabs.Tab title="朋友圈" key="moments">
<div className={style["moments-content"]}>
{/* 功能按钮栏 */}
<div className={style["moments-action-bar"]}>
<div className={style["action-button"]}>
<span className={style["action-icon-text"]}></span>
<span className={style["action-text"]}></span>
</div>
<div className={style["action-button"]}>
<span className={style["action-icon-image"]}></span>
<span className={style["action-text"]}></span>
</div>
<div className={style["action-button"]}>
<span className={style["action-icon-video"]}></span>
<span className={style["action-text"]}></span>
</div>
<div
className={style["action-button-dark"]}
onClick={() => setShowExportPopup(true)}
>
<span className={style["action-icon-export"]}></span>
<span className={style["action-text-light"]}></span>
</div>
{/* 功能按钮栏 - 移到白色背景上 */}
<div className={style["moments-action-bar"]}>
<div className={style["action-button"]}>
<FileTextOutlined />
</div>
<div className={style["action-button"]}>
<PictureOutlined />
</div>
<div className={style["action-button"]}>
<VideoCameraOutlined />
</div>
<div
className={style["action-button-dark"]}
onClick={() => {
// 默认设置近7天
const today = new Date();
const sevenDaysAgo = dayjs(today).subtract(7, "day").toDate();
setExportStartTime(sevenDaysAgo);
setExportEndTime(today);
setShowExportPopup(true);
}}
>
<DownloadOutlined />
</div>
</div>
<div className={style["moments-content"]}>
{/* 朋友圈列表 */}
<div className={style["moments-list"]}>
{isFetchingMoments && moments.length === 0 ? (
@@ -940,18 +1065,25 @@ const WechatAccountDetail: React.FC = () => {
)}
</div>
{moments.length < momentsTotal && (
<div className={style["moments-load-more"]}>
<Button
size="small"
onClick={handleLoadMoreMoments}
loading={isFetchingMoments}
disabled={isFetchingMoments}
>
</Button>
</div>
)}
<InfiniteScroll
loadMore={handleLoadMoreMoments}
hasMore={moments.length < momentsTotal}
threshold={100}
>
{isFetchingMoments && moments.length > 0 && (
<div className={style["moments-loading"]}>
<SpinLoading color="primary" style={{ fontSize: 16 }} />
<span style={{ marginLeft: 8, color: "#999", fontSize: 12 }}>
...
</span>
</div>
)}
{moments.length >= momentsTotal && moments.length > 0 && (
<div className={style["moments-no-more"]}>
<span style={{ color: "#999", fontSize: 12 }}></span>
</div>
)}
</InfiniteScroll>
</div>
</Tabs.Tab>
@@ -1049,6 +1181,38 @@ const WechatAccountDetail: React.FC = () => {
</span>
</div>
</div>
{/* 打招呼 */}
<div className={style["form-item"]}>
<div className={style["form-label"]}></div>
<div className={style["form-control"]}>
<Input.TextArea
placeholder="请输入打招呼内容(可选)"
value={greeting}
onChange={(e) => setGreeting(e.target.value)}
rows={3}
maxLength={200}
showCount
style={{ resize: "none" }}
/>
</div>
</div>
{/* 通过后首次消息 */}
<div className={style["form-item"]}>
<div className={style["form-label"]}></div>
<div className={style["form-control"]}>
<Input.TextArea
placeholder="请输入好友通过验证后发送的首条消息(可选)"
value={firstMessage}
onChange={(e) => setFirstMessage(e.target.value)}
rows={3}
maxLength={200}
showCount
style={{ resize: "none" }}
/>
</div>
</div>
</div>
<div className={style["popup-actions"]}>
@@ -1068,6 +1232,10 @@ const WechatAccountDetail: React.FC = () => {
onClick={() => {
setShowTransferConfirm(false);
setSelectedDevices([]);
// 重置为默认打招呼内容
const nickname = accountInfo?.nickname || "未知";
setGreeting(`这个是${nickname}的新号,之前那个号没用了,重新加一下您`);
setFirstMessage("");
}}
>
@@ -1079,7 +1247,11 @@ const WechatAccountDetail: React.FC = () => {
{/* 朋友圈导出弹窗 */}
<Popup
visible={showExportPopup}
onMaskClick={() => setShowExportPopup(false)}
onMaskClick={() => {
setShowExportPopup(false);
setShowStartTimePicker(false);
setShowEndTimePicker(false);
}}
bodyStyle={{ borderRadius: "16px 16px 0 0" }}
>
<div className={style["popup-content"]}>
@@ -1088,7 +1260,11 @@ const WechatAccountDetail: React.FC = () => {
<Button
size="small"
fill="outline"
onClick={() => setShowExportPopup(false)}
onClick={() => {
setShowExportPopup(false);
setShowStartTimePicker(false);
setShowEndTimePicker(false);
}}
>
</Button>
@@ -1162,11 +1338,13 @@ const WechatAccountDetail: React.FC = () => {
visible={showStartTimePicker}
title="开始时间"
value={exportStartTime}
max={exportEndTime || new Date()}
onClose={() => setShowStartTimePicker(false)}
onConfirm={val => {
setExportStartTime(val);
setShowStartTimePicker(false);
}}
onCancel={() => setShowStartTimePicker(false)}
/>
</div>
@@ -1185,6 +1363,8 @@ const WechatAccountDetail: React.FC = () => {
visible={showEndTimePicker}
title="结束时间"
value={exportEndTime}
min={exportStartTime || undefined}
max={new Date()}
onClose={() => setShowEndTimePicker(false)}
onConfirm={val => {
setExportEndTime(val);
@@ -1214,6 +1394,8 @@ const WechatAccountDetail: React.FC = () => {
setExportType(undefined);
setExportStartTime(null);
setExportEndTime(null);
setShowStartTimePicker(false);
setShowEndTimePicker(false);
}}
style={{ marginTop: 12 }}
>

View File

@@ -370,13 +370,13 @@ const ScenarioList: React.FC = () => {
title={scenarioName || ""}
right={
scenarioId !== "10" ? (
<Button
size="small"
color="primary"
onClick={handleCreateNewPlan}
>
<PlusOutlined />
</Button>
<Button
size="small"
color="primary"
onClick={handleCreateNewPlan}
>
<PlusOutlined />
</Button>
) : null
}
/>
@@ -427,13 +427,13 @@ const ScenarioList: React.FC = () => {
{searchTerm ? "没有找到匹配的计划" : "暂无计划"}
</div>
{scenarioId !== "10" && (
<Button
color="primary"
onClick={handleCreateNewPlan}
className={style["create-first-btn"]}
>
<PlusOutlined />
</Button>
<Button
color="primary"
onClick={handleCreateNewPlan}
className={style["create-first-btn"]}
>
<PlusOutlined />
</Button>
)}
</div>
) : (

View File

@@ -70,24 +70,72 @@ const PlanApi: React.FC<PlanApiProps> = ({
// 处理webhook URL确保包含完整的API地址
const fullWebhookUrl = useMemo(() => {
return buildApiUrl(webhookUrl);
return buildApiUrl('');
}, [webhookUrl]);
// 生成测试URL
// 快速测试使用的 GET 地址(携带示例查询参数,方便在浏览器中直接访问)
const testUrl = useMemo(() => {
if (!fullWebhookUrl) return "";
return `${fullWebhookUrl}?name=测试客户&phone=13800138000&source=API测试`;
}, [fullWebhookUrl]);
return buildApiUrl(webhookUrl);
}, [webhookUrl]);
// 检测是否为移动端
const isMobile = window.innerWidth <= 768;
const handleCopy = (text: string, type: string) => {
navigator.clipboard.writeText(text);
Toast.show({
content: `${type}已复制到剪贴板`,
position: "top",
});
// 先尝试使用 Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(text)
.then(() => {
Toast.show({
content: `${type}已复制到剪贴板`,
position: "top",
});
})
.catch(() => {
// 回退到传统的 textarea 复制方式
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand("copy");
Toast.show({
content: `${type}已复制到剪贴板`,
position: "top",
});
} catch {
Toast.show({
content: `${type}复制失败,请手动复制`,
position: "top",
});
}
document.body.removeChild(textarea);
});
} else {
// 不支持 Clipboard API 时直接使用回退方案
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand("copy");
Toast.show({
content: `${type}已复制到剪贴板`,
position: "top",
});
} catch {
Toast.show({
content: `${type}复制失败,请手动复制`,
position: "top",
});
}
document.body.removeChild(textarea);
}
};
const handleTestInBrowser = () => {
@@ -96,7 +144,7 @@ const PlanApi: React.FC<PlanApiProps> = ({
const renderConfigTab = () => (
<div className={style["config-content"]}>
{/* API密钥配置 */}
{/* 鉴权参数配置 */}
<div className={style["config-section"]}>
<div className={style["section-header"]}>
<div className={style["section-title"]}>
@@ -122,7 +170,7 @@ const PlanApi: React.FC<PlanApiProps> = ({
</div>
</div>
{/* 接口地址配置 */}
{/* 接口地址与参数说明 */}
<div className={style["config-section"]}>
<div className={style["section-header"]}>
<div className={style["section-title"]}>
@@ -150,27 +198,42 @@ const PlanApi: React.FC<PlanApiProps> = ({
{/* 参数说明 */}
<div className={style["params-grid"]}>
<div className={style["param-section"]}>
<h4></h4>
<h4></h4>
<div className={style["param-list"]}>
<div>
<code>name</code> -
<code>apiKey</code> -
</div>
<div>
<code>phone</code> -
<code>sign</code> -
</div>
<div>
<code>timestamp</code> - 5
</div>
</div>
</div>
<div className={style["param-section"]}>
<h4></h4>
<h4></h4>
<div className={style["param-list"]}>
<div>
<code>source</code> -
<code>wechatId</code> -
</div>
<div>
<code>phone</code> - <code>wechatId</code>
</div>
<div>
<code>name</code> -
</div>
<div>
<code>source</code> - 线广
</div>
<div>
<code>remark</code> -
</div>
<div>
<code>tags</code> -
<code>tags</code> - <code>"高意向,电商,女装"</code>
</div>
<div>
<code>siteTags</code> -
</div>
</div>
</div>
@@ -179,93 +242,131 @@ const PlanApi: React.FC<PlanApiProps> = ({
</div>
);
const renderQuickTestTab = () => (
<div className={style["test-content"]}>
<div className={style["test-section"]}>
<h3>URL</h3>
<div className={style["input-group"]}>
<Input value={testUrl} disabled className={style["test-input"]} />
</div>
<div className={style["test-buttons"]}>
<Button
onClick={() => handleCopy(testUrl, "测试URL")}
className={style["test-btn"]}
>
<CopyOutlined />
URL
</Button>
<Button
type="primary"
onClick={handleTestInBrowser}
className={style["test-btn"]}
>
</Button>
const renderQuickTestTab = () => {
return (
<div className={style["test-content"]}>
<div className={style["test-section"]}>
<h3> URLGET </h3>
<div className={style["input-group"]}>
<Input value={testUrl} disabled className={style["test-input"]} />
</div>
<div className={style["test-buttons"]}>
<Button
onClick={() => handleCopy(testUrl, "测试URL")}
className={style["test-btn"]}
>
<CopyOutlined />
URL
</Button>
<Button
color="primary"
onClick={handleTestInBrowser}
className={style["test-btn"]}
>
</Button>
</div>
</div>
</div>
</div>
);
);
};
const renderDocsTab = () => (
<div className={style["docs-content"]}>
<div className={style["docs-grid"]}>
<Card className={style["doc-card"]}>
<div className={style["doc-icon"]}>
<BookOutlined />
</div>
<h4>API文档</h4>
<p></p>
</Card>
<Card className={style["doc-card"]}>
<div className={style["doc-icon"]}>
<LinkOutlined />
</div>
<h4></h4>
<p></p>
</Card>
const renderDocsTab = () => {
const docUrl = `${import.meta.env.VITE_API_BASE_URL}/doc/api_v1.md`;
return (
<div className={style["docs-content"]}>
<div className={style["docs-grid"]}>
<Card className={style["doc-card"]}>
<div className={style["doc-icon"]}>
<BookOutlined />
</div>
<h4>线V1</h4>
<p></p>
<div className={style["doc-actions"]}>
<Button
size="small"
onClick={() => {
window.open(docUrl, "_blank");
}}
className={style["doc-open-btn"]}
>
</Button>
<Button
size="small"
onClick={() => handleCopy(docUrl, "文档链接")}
className={style["doc-copy-btn"]}
>
<CopyOutlined />
</Button>
</div>
</Card>
</div>
</div>
</div>
);
);
};
const renderCodeTab = () => {
const codeExamples = {
javascript: `fetch('${fullWebhookUrl}', {
javascript: `// 参考 api_v1 文档示例,使用 JSON 方式 POST
fetch('${fullWebhookUrl}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ${apiKey}'
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: '张三',
apiKey: '${apiKey}',
timestamp: 1710000000, // 秒级时间戳
phone: '13800138000',
name: '张三',
source: '官网表单',
remark: '通过H5表单提交',
tags: '高意向,电商',
siteTags: '新客,女装',
// sign 需要根据签名规则生成
sign: '根据签名规则生成的MD5字符串'
})
})`,
python: `import requests
url = '${fullWebhookUrl}'
headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ${apiKey}'
'Content-Type': 'application/json'
}
data = {
'name': '张三',
'phone': '13800138000',
'source': '官网表单'
"apiKey": "${apiKey}",
"timestamp": 1710000000,
"phone": "13800138000",
"name": "张三",
"source": "官网表单",
"remark": "通过H5表单提交",
"tags": "高意向,电商",
"siteTags": "新客,女装",
# sign 需要根据签名规则生成
"sign": "根据签名规则生成的MD5字符串"
}
response = requests.post(url, json=data, headers=headers)`,
php: `<?php
$url = '${fullWebhookUrl}';
$data = array(
'name' => '张三',
'apiKey' => '${apiKey}',
'timestamp' => 1710000000,
'phone' => '13800138000',
'source' => '官网表单'
'name' => '张三',
'source' => '官网表单',
'remark' => '通过H5表单提交',
'tags' => '高意向,电商',
'siteTags' => '新客,女装',
// sign 需要根据签名规则生成
'sign' => '根据签名规则生成的MD5字符串'
);
$options = array(
'http' => array(
'header' => "Content-type: application/json\\r\\nAuthorization: Bearer ${apiKey}\\r\\n",
'header' => "Content-type: application/json\\r\\n",
'method' => 'POST',
'content' => json_encode($data)
)
@@ -279,12 +380,11 @@ import java.net.http.HttpResponse;
import java.net.URI;
HttpClient client = HttpClient.newHttpClient();
String json = "{\\"name\\":\\"张三\\",\\"phone\\":\\"13800138000\\",\\"source\\":\\"官网表单\\"}";
String json = "{\\"apiKey\\":\\"${apiKey}\\",\\"timestamp\\":1710000000,\\"phone\\":\\"13800138000\\",\\"name\\":\\"张三\\",\\"source\\":\\"官网表单\\",\\"remark\\":\\"通过H5表单提交\\",\\"tags\\":\\"高意向,电商\\",\\"siteTags\\":\\"新客,女装\\",\\"sign\\":\\"根据签名规则生成的MD5字符串\\"}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("${fullWebhookUrl}"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer ${apiKey}")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
@@ -394,11 +494,7 @@ HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.o
<SafetyOutlined />
HTTPS加密
</div>
<Button
type="primary"
onClick={onClose}
className={style["complete-btn"]}
>
<Button color="primary" onClick={onClose} className={style["complete-btn"]}>
</Button>
</div>

View File

@@ -0,0 +1,235 @@
import React, { useImperativeHandle, forwardRef } from "react";
import { Form, Card, Tabs } from "antd";
import DeviceSelection from "@/components/DeviceSelection";
import FriendSelection from "@/components/FriendSelection";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
import { FriendSelectionItem } from "@/components/FriendSelection/data";
interface OwnerAdminSelectorProps {
selectedOwners: DeviceSelectionItem[];
selectedAdmins: FriendSelectionItem[];
onNext: (data: {
devices: string[];
devicesOptions: DeviceSelectionItem[];
admins: string[];
adminsOptions: FriendSelectionItem[];
}) => void;
}
export interface OwnerAdminSelectorRef {
validate: () => Promise<boolean>;
getValues: () => any;
}
const OwnerAdminSelector = forwardRef<
OwnerAdminSelectorRef,
OwnerAdminSelectorProps
>(({ selectedOwners, selectedAdmins, onNext }, ref) => {
const [form] = Form.useForm();
const [owners, setOwners] = React.useState<DeviceSelectionItem[]>(
selectedOwners || []
);
const [admins, setAdmins] = React.useState<FriendSelectionItem[]>(
selectedAdmins || []
);
// 当外部传入的 selectedOwners 或 selectedAdmins 变化时,同步内部状态
React.useEffect(() => {
setOwners(selectedOwners || []);
}, [selectedOwners]);
React.useEffect(() => {
setAdmins(selectedAdmins || []);
}, [selectedAdmins]);
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
validate: async () => {
// 验证群主和管理员
if (owners.length === 0) {
form.setFields([
{
name: "devices",
errors: ["请选择一个群主"],
},
]);
return false;
}
if (owners.length > 1) {
form.setFields([
{
name: "devices",
errors: ["群主只能选择一个设备"],
},
]);
return false;
}
if (admins.length === 0) {
form.setFields([
{
name: "admins",
errors: ["请至少选择一个管理员"],
},
]);
return false;
}
// 清除错误
form.setFields([
{
name: "devices",
errors: [],
},
{
name: "admins",
errors: [],
},
]);
return true;
},
getValues: () => {
return {
devices: owners.map(o => o.id.toString()),
admins: admins.map(a => a.id.toString()),
devicesOptions: owners,
adminsOptions: admins,
};
},
}));
// 群主选择(设备选择)
const handleOwnersSelect = (selectedDevices: DeviceSelectionItem[]) => {
const previousOwnerId = owners.length > 0 ? owners[0]?.id : null;
const newOwnerId = selectedDevices.length > 0 ? selectedDevices[0]?.id : null;
// 当群主改变时,清空已选的管理员(因为筛选条件变了)
const shouldClearAdmins = previousOwnerId !== newOwnerId;
setOwners(selectedDevices);
const ownerIds = selectedDevices.map(d => d.id.toString());
form.setFieldValue("devices", ownerIds);
if (shouldClearAdmins) {
setAdmins([]);
form.setFieldValue("admins", []);
}
// 通知父组件数据变化
onNext({
devices: ownerIds,
devicesOptions: selectedDevices,
admins: shouldClearAdmins ? [] : admins.map(a => a.id.toString()),
adminsOptions: shouldClearAdmins ? [] : admins,
});
};
// 管理员选择
const handleAdminsSelect = (selectedFriends: FriendSelectionItem[]) => {
setAdmins(selectedFriends);
const adminIds = selectedFriends.map(f => f.id.toString());
form.setFieldValue("admins", adminIds);
// 通知父组件数据变化
onNext({
devices: owners.map(o => o.id.toString()),
devicesOptions: owners,
admins: adminIds,
adminsOptions: selectedFriends,
});
};
const tabItems = [
{
key: "devices",
label: `群主 (${owners.length})`,
children: (
<div>
<div style={{ marginBottom: 16 }}>
<p style={{ margin: "0 0 8px 0", color: "#666", fontSize: 14 }}>
</p>
</div>
<Form.Item
name="devices"
validateStatus={owners.length === 0 || owners.length > 1 ? "error" : ""}
help={
owners.length === 0
? "请选择一个群主(设备)"
: owners.length > 1
? "群主只能选择一个设备"
: ""
}
>
<DeviceSelection
selectedOptions={owners}
onSelect={handleOwnersSelect}
placeholder="选择群主(设备)"
singleSelect={true}
/>
</Form.Item>
</div>
),
},
{
key: "admins",
label: `管理员 (${admins.length})`,
children: (
<div>
<div style={{ marginBottom: 16 }}>
<p style={{ margin: "0 0 8px 0", color: "#666", fontSize: 14 }}>
{owners.length === 0
? "请先选择群主(设备),然后选择该设备下的好友作为管理员"
: "请选择管理员,管理员将协助管理新建的群聊(仅显示所选设备下的好友)"}
</p>
</div>
<Form.Item
name="admins"
validateStatus={admins.length === 0 ? "error" : ""}
help={
owners.length === 0
? "请先选择群主(设备)"
: admins.length === 0
? "请至少选择一个管理员"
: ""
}
>
<FriendSelection
selectedOptions={admins}
onSelect={handleAdminsSelect}
placeholder={owners.length === 0 ? "请先选择群主" : "选择管理员"}
deviceIds={owners.length > 0 ? owners.map(d => d.id) : []}
enableDeviceFilter={true}
readonly={owners.length === 0}
/>
</Form.Item>
</div>
),
},
];
return (
<Card>
<Form
form={form}
layout="vertical"
initialValues={{
devices: (selectedOwners || []).map(item => item.id.toString()),
admins: (selectedAdmins || []).map(item => item.id.toString()),
}}
>
<div style={{ marginBottom: 20 }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
</h2>
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
</p>
</div>
<Tabs items={tabItems} />
</Form>
</Card>
);
});
OwnerAdminSelector.displayName = "OwnerAdminSelector";
export default OwnerAdminSelector;

View File

@@ -7,24 +7,29 @@ import { createAutoGroup, updateAutoGroup, getAutoGroupDetail } from "./api";
import { AutoGroupFormData, StepItem } from "./types";
import StepIndicator from "@/components/StepIndicator";
import BasicSettings, { BasicSettingsRef } from "./components/BasicSettings";
import DeviceSelector, { DeviceSelectorRef } from "./components/DeviceSelector";
import OwnerAdminSelector, {
OwnerAdminSelectorRef,
} from "./components/OwnerAdminSelector";
import PoolSelector, { PoolSelectorRef } from "./components/PoolSelector";
import NavCommon from "@/components/NavCommon/index";
import dayjs from "dayjs";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
import { FriendSelectionItem } from "@/components/FriendSelection/data";
import { PoolSelectionItem } from "@/components/PoolSelection/data";
const steps: StepItem[] = [
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
{ id: 2, title: "步骤 2", subtitle: "选择设备" },
{ id: 2, title: "步骤 2", subtitle: "选择群主和管理员" },
{ id: 3, title: "步骤 3", subtitle: "选择流量池包" },
];
const defaultForm: AutoGroupFormData = {
name: "",
type: 4,
deviceGroups: [], // 设备组
deviceGroupsOptions: [], // 设备组选项
devices: [], // 群主ID列表
devicesOptions: [], // 群主选项
admins: [], // 管理员ID列表
adminsOptions: [], // 管理员选项
poolGroups: [], // 内容库
poolGroupsOptions: [], // 内容库选项
startTime: dayjs().format("HH:mm"), // 开始时间 (HH:mm)
@@ -45,16 +50,15 @@ const AutoGroupForm: React.FC = () => {
const [loading, setLoading] = useState(false);
const [dataLoaded, setDataLoaded] = useState(!isEdit); // 非编辑模式直接标记为已加载
const [formData, setFormData] = useState<AutoGroupFormData>(defaultForm);
const [deviceGroupsOptions, setDeviceGroupsOptions] = useState<
DeviceSelectionItem[]
>([]);
const [devicesOptions, setDevicesOptions] = useState<DeviceSelectionItem[]>([]);
const [adminsOptions, setAdminsOptions] = useState<FriendSelectionItem[]>([]);
const [poolGroupsOptions, setpoolGroupsOptions] = useState<
PoolSelectionItem[]
>([]);
// 创建子组件的ref
const basicSettingsRef = useRef<BasicSettingsRef>(null);
const deviceSelectorRef = useRef<DeviceSelectorRef>(null);
const ownerAdminSelectorRef = useRef<OwnerAdminSelectorRef>(null);
const poolSelectorRef = useRef<PoolSelectorRef>(null);
useEffect(() => {
@@ -64,8 +68,10 @@ const AutoGroupForm: React.FC = () => {
const updatedForm = {
...defaultForm,
name: res.name,
deviceGroups: res.config.deviceGroups || [],
deviceGroupsOptions: res.config.deviceGroupsOptions || [],
devices: res.config.deviceGroups || res.config.devices || [], // 兼容deviceGroups和devices
devicesOptions: res.config.deviceGroupsOptions || res.config.devicesOptions || [], // 兼容deviceGroupsOptions和devicesOptions
admins: res.config.admins || [],
adminsOptions: res.config.adminsOptions || [],
poolGroups: res.config.poolGroups || [],
poolGroupsOptions: res.config.poolGroupsOptions || [],
startTime: res.config.startTime,
@@ -80,7 +86,8 @@ const AutoGroupForm: React.FC = () => {
id: res.id,
};
setFormData(updatedForm);
setDeviceGroupsOptions(res.config.deviceGroupsOptions || []);
setDevicesOptions(res.config.deviceGroupsOptions || res.config.devicesOptions || []); // 兼容deviceGroupsOptions和devicesOptions
setAdminsOptions(res.config.adminsOptions || []);
setpoolGroupsOptions(res.config.poolGroupsOptions || []);
setDataLoaded(true); // 标记数据已加载
});
@@ -90,16 +97,20 @@ const AutoGroupForm: React.FC = () => {
setFormData(prev => ({ ...prev, ...values }));
};
// 设备组选择
const handleDevicesChange = (data: {
deviceGroups: string[];
deviceGroupsOptions: DeviceSelectionItem[];
// 群主和管理员选择
const handleOwnerAdminChange = (data: {
devices: string[];
devicesOptions: DeviceSelectionItem[];
admins: string[];
adminsOptions: FriendSelectionItem[];
}) => {
setFormData(prev => ({
...prev,
deviceGroups: data.deviceGroups,
devices: data.devices,
admins: data.admins,
}));
setDeviceGroupsOptions(data.deviceGroupsOptions);
setDevicesOptions(data.devicesOptions);
setAdminsOptions(data.adminsOptions);
};
// 流量池包选择
@@ -116,8 +127,16 @@ const AutoGroupForm: React.FC = () => {
Toast.show({ content: "请输入任务名称" });
return;
}
if (formData.deviceGroups.length === 0) {
Toast.show({ content: "请选择至少一个设备组" });
if (formData.devices.length === 0) {
Toast.show({ content: "请选择一个群主" });
return;
}
if (formData.devices.length > 1) {
Toast.show({ content: "群主只能选择一个设备" });
return;
}
if (formData.admins.length === 0) {
Toast.show({ content: "请至少选择一个管理员" });
return;
}
if (formData.poolGroups.length === 0) {
@@ -127,9 +146,13 @@ const AutoGroupForm: React.FC = () => {
setLoading(true);
try {
// 构建提交数据将devices映射为deviceGroups
const { devices, devicesOptions, ...restFormData } = formData;
const submitData = {
...formData,
deviceGroupsOptions: deviceGroupsOptions,
...restFormData,
deviceGroups: devices, // 设备ID数组传输字段名为deviceGroups
deviceGroupsOptions: devicesOptions, // 设备完整信息传输字段名为deviceGroupsOptions
adminsOptions: adminsOptions,
poolGroupsOptions: poolGroupsOptions,
};
@@ -173,8 +196,9 @@ const AutoGroupForm: React.FC = () => {
break;
case 2:
// 调用 DeviceSelector 的表单校验
isValid = (await deviceSelectorRef.current?.validate()) || false;
// 调用 OwnerAdminSelector 的表单校验
isValid =
(await ownerAdminSelectorRef.current?.validate()) || false;
if (isValid) {
setCurrentStep(3);
}
@@ -217,10 +241,11 @@ const AutoGroupForm: React.FC = () => {
);
case 2:
return (
<DeviceSelector
ref={deviceSelectorRef}
selectedDevices={deviceGroupsOptions}
onNext={handleDevicesChange}
<OwnerAdminSelector
ref={ownerAdminSelectorRef}
selectedOwners={devicesOptions}
selectedAdmins={adminsOptions}
onNext={handleOwnerAdminChange}
/>
);
case 3:

View File

@@ -1,13 +1,16 @@
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
import { PoolSelectionItem } from "@/components/PoolSelection/data";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
import { FriendSelectionItem } from "@/components/FriendSelection/data";
// 自动建群表单数据类型定义
export interface AutoGroupFormData {
id?: string; // 任务ID
type: number; // 任务类型
name: string; // 任务名称
deviceGroups: string[]; // 设备组
deviceGroupsOptions: DeviceSelectionItem[]; // 设备组选项
devices: string[]; // 群主ID列表设备ID
devicesOptions: DeviceSelectionItem[]; // 群主选项(设备)
admins: string[]; // 管理员ID列表好友ID
adminsOptions: FriendSelectionItem[]; // 管理员选项(好友)
poolGroups: string[]; // 流量池
poolGroupsOptions: PoolSelectionItem[]; // 流量池选项
startTime: string; // 开始时间 (YYYY-MM-DD HH:mm:ss)
@@ -34,9 +37,13 @@ export const formValidationRules = {
{ required: true, message: "请输入任务名称" },
{ min: 2, max: 50, message: "任务名称长度应在2-50个字符之间" },
],
deviceGroups: [
{ required: true, message: "请选择设备组" },
{ type: "array", min: 1, message: "至少选择一个设备" },
devices: [
{ required: true, message: "请选择群主" },
{ type: "array", min: 1, max: 1, message: "群主只能选择一个设备" },
],
admins: [
{ required: true, message: "请选择管理员" },
{ type: "array", min: 1, message: "至少选择一个管理员" },
],
poolGroups: [
{ required: true, message: "请选择内容库" },

View File

@@ -116,20 +116,38 @@ const AutoLike: React.FC = () => {
setLoading(true);
try {
const Res: any = await fetchAutoLikeTasks();
// 直接就是任务数组,无需再解包
const mappedTasks = Res?.list?.map((task: any) => ({
...task,
status: task.status || 2, // 默认为关闭状态
deviceCount: task.deviceCount || 0,
targetGroup: task.targetGroup || "全部好友",
likeInterval: task.likeInterval || 60,
maxLikesPerDay: task.maxLikesPerDay || 100,
lastLikeTime: task.lastLikeTime || "暂无",
createTime: task.createTime || "",
updateTime: task.updateTime || "",
todayLikeCount: task.todayLikeCount || 0,
totalLikeCount: task.totalLikeCount || 0,
}));
// 数据在 data.list 中
const taskList = Res?.data?.list || Res?.list || [];
const mappedTasks = taskList.map((task: any) => {
const config = task.config || {};
const friends = config.friends || [];
const devices = config.devices || [];
// 判断目标人群:如果 friends 为空或未设置,表示选择全部好友
let targetGroup = "全部好友";
if (friends.length > 0) {
targetGroup = `${friends.length} 个好友`;
}
return {
id: task.id?.toString() || "",
name: task.name || "",
status: task.status === 1 ? 1 : 2, // 1: 开启, 2: 关闭
deviceCount: devices.length,
targetGroup: targetGroup,
likeInterval: config.interval || 60,
maxLikesPerDay: config.maxLikes || 100,
lastLikeTime: task.lastLikeTime || "暂无",
createTime: task.createTime || "",
updateTime: task.updateTime || "",
todayLikeCount: config.todayLikeCount || 0,
totalLikeCount: config.totalLikeCount || 0,
// 保留原始数据
config: config,
devices: devices,
friends: friends,
};
});
setTasks(mappedTasks);
} catch (error) {
console.error("获取自动点赞任务失败:", error);
@@ -355,7 +373,7 @@ const AutoLike: React.FC = () => {
/>
<span className={style["stats-label"]}></span>
<span className={style["stats-value"]}>
{task.lastLikeTime}
{task.todayLikeCount || 0}
</span>
</div>
<div className={style["stats-item"]}>

View File

@@ -36,6 +36,7 @@ const NewAutoLike: React.FC = () => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoading, setIsLoading] = useState(isEditMode);
const [autoEnabled, setAutoEnabled] = useState(false);
const [selectAllFriends, setSelectAllFriends] = useState(false);
const [formData, setFormData] = useState<CreateLikeTaskData>({
name: "",
interval: 5,
@@ -45,8 +46,8 @@ const NewAutoLike: React.FC = () => {
contentTypes: ["text", "image", "video"],
deviceGroups: [],
deviceGroupsOptions: [],
friendsGroups: [],
friendsGroupsOptions: [],
wechatFriends: [],
wechatFriendsOptions: [],
targetTags: [],
friendMaxLikes: 10,
enableFriendTags: false,
@@ -74,8 +75,8 @@ const NewAutoLike: React.FC = () => {
contentTypes: config.contentTypes || ["text", "image", "video"],
deviceGroups: config.deviceGroups || [],
deviceGroupsOptions: config.deviceGroupsOptions || [],
friendsGroups: config.friendsgroups || [],
friendsGroupsOptions: config.friendsGroupsOptions || [],
wechatFriends: config.wechatFriends || [],
wechatFriendsOptions: config.wechatFriendsOptions || [],
targetTags: config.targetTags || [],
friendMaxLikes: config.friendMaxLikes || 10,
enableFriendTags: config.enableFriendTags || false,
@@ -85,6 +86,10 @@ const NewAutoLike: React.FC = () => {
(taskDetail as any).status === 1 ||
(taskDetail as any).status === "running",
);
// 如果 wechatFriends 为空或未设置,可能表示选择了全部好友
setSelectAllFriends(
!config.wechatFriends || config.wechatFriends.length === 0
);
}
} catch (error) {
message.error("获取任务详情失败");
@@ -127,11 +132,19 @@ const NewAutoLike: React.FC = () => {
}
setIsSubmitting(true);
try {
// 如果选择了全部好友,提交时传空数组或特殊标识
const submitData = {
...formData,
wechatFriends: selectAllFriends ? [] : formData.wechatFriends,
wechatFriendsOptions: selectAllFriends ? [] : formData.wechatFriendsOptions,
selectAllFriends: selectAllFriends, // 添加标识字段
};
if (isEditMode) {
await updateAutoLikeTask({ ...formData, id });
await updateAutoLikeTask({ ...submitData, id });
message.success("更新成功");
} else {
await createAutoLikeTask(formData);
await createAutoLikeTask(submitData);
message.success("创建成功");
}
navigate("/workspace/auto-like");
@@ -142,6 +155,28 @@ const NewAutoLike: React.FC = () => {
}
};
// 选择全部好友(仅设置标识)
const handleSelectAllFriends = () => {
if (!formData.deviceGroups || formData.deviceGroups.length === 0) {
message.warning("请先选择执行设备");
return;
}
if (selectAllFriends) {
// 取消全选标识
setSelectAllFriends(false);
// 清空已选好友
handleUpdateFormData({
wechatFriends: [],
wechatFriendsOptions: [],
});
} else {
// 设置全选标识
setSelectAllFriends(true);
message.success("已标记为选择全部好友");
}
};
// 步骤器
const renderStepIndicator = () => (
<StepIndicator steps={steps} currentStep={currentStep} />
@@ -364,16 +399,39 @@ const NewAutoLike: React.FC = () => {
const renderFriendSettings = () => (
<div className={style.basicSection}>
<div className={style.formItem}>
<FriendSelection
selectedOptions={formData.friendsGroupsOptions || []}
onSelect={friends =>
handleUpdateFormData({
friendsGroups: friends.map(f => f.id),
friendsGroupsOptions: friends,
})
}
deviceIds={formData.deviceGroups}
/>
<div className={style.friendSelectionHeader}>
<div className={style.formLabel}></div>
<Button
type={selectAllFriends ? "primary" : "default"}
size="small"
onClick={handleSelectAllFriends}
disabled={!formData.deviceGroups || formData.deviceGroups.length === 0}
className={style.selectAllBtn}
>
{selectAllFriends ? "已选择全部" : "选择全部好友"}
</Button>
</div>
{selectAllFriends ? (
<div className={style.selectAllTip}>
<span className={style.selectAllIcon}></span>
</div>
) : (
<FriendSelection
selectedOptions={formData.wechatFriendsOptions || []}
onSelect={friends => {
handleUpdateFormData({
wechatFriends: friends.map(f => f.id),
wechatFriendsOptions: friends,
});
// 如果手动选择了好友,取消全选标识
if (selectAllFriends) {
setSelectAllFriends(false);
}
}}
deviceIds={formData.deviceGroups}
/>
)}
</div>
<Button
onClick={handlePrev}
@@ -390,7 +448,8 @@ const NewAutoLike: React.FC = () => {
size="large"
loading={isSubmitting}
disabled={
!formData.friendsGroups || formData.friendsGroups.length === 0
!selectAllFriends &&
(!formData.wechatFriends || formData.wechatFriends.length === 0)
}
>
{isEditMode ? "更新任务" : "创建任务"}

View File

@@ -126,6 +126,13 @@
gap: 8px;
}
.contentTypeBtn {
flex: 1;
height: 40px;
border-radius: 8px;
font-size: 14px;
}
.contentTypeTag {
padding: 8px 16px;
border-radius: 6px;
@@ -230,3 +237,50 @@
font-size: 15px;
min-width: 120px;
}
.friendSelectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.selectAllBtn {
height: 32px;
border-radius: 6px;
font-size: 14px;
padding: 0 16px;
}
.selectAllTip {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #f0f9ff;
border: 1px solid #91d5ff;
border-radius: 8px;
color: #1890ff;
font-size: 14px;
margin-top: 8px;
}
.selectAllIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: #1890ff;
color: #fff;
border-radius: 50%;
font-size: 12px;
font-weight: bold;
}
.mainBtn {
height: 44px;
border-radius: 8px;
font-size: 15px;
margin-top: 24px;
}

View File

@@ -63,8 +63,8 @@ const NewMomentsSync: React.FC = () => {
if (res) {
setFormData({
taskName: res.name,
startTime: res.timeRange?.start || "06:00",
endTime: res.timeRange?.end || "23:59",
startTime: res.config?.startTime || "06:00",
endTime: res.config?.endTime || "23:59",
syncCount: res.config?.syncCount || res.syncCount || 5,
syncInterval: res.config?.syncInterval || res.syncInterval || 30,
syncType: res.config?.syncType,

View File

@@ -33,14 +33,15 @@ export const getFullApiPath = (): string => {
* - buildApiUrl('https://api.example.com/webhook/123') → 'https://api.example.com/webhook/123'
*/
export const buildApiUrl = (path: string): string => {
if (!path) return "";
const fullApiPath = getFullApiPath();
if (!path) return `${fullApiPath}`;
// 如果已经是完整的URL包含http或https直接返回
if (path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
const fullApiPath = getFullApiPath();
// 如果是相对路径拼接完整API路径
if (path.startsWith("/")) {

11
Moncter/MCP/mcp.json Normal file
View File

@@ -0,0 +1,11 @@
{
"mcpServers": {
"MongoDB": {
"command": "npx",
"args": ["-y", "mongodb-mcp-server@latest", "--readOnly"],
"env": {
"MDB_MCP_CONNECTION_STRING": "mongodb://ckb:123456@192.168.1.106:27017/ckb"
}
}
}
}

View File

@@ -0,0 +1,15 @@
# 账户密码
```
HOST 192.168.1.106
PORT 27017
ACCOUNTckb
DBNAMEckb
PASSWROD123456
```
==================
# 环境准备
需要安装一个全局环境否则无法运行
node版本为22.12.0
npm i mongodb-mcp-server -g

View File

@@ -397,9 +397,9 @@ class MessageController extends BaseController
if (!empty($accountData)){
$account = new AccountController();
$account->getlist(['pageIndex' => 0,'pageSize' => 100,'departmentId' => $accountData['departmentId']]);
$accountIds = Db::table('s2_company_account')->where(['id' => $accountId,'alive' => 1])->column('id');
$accountIds = Db::table('s2_company_account')->where(['departmentId' => $accountData['departmentId'],'alive' => 1])->column('id');
if (!empty($accountIds)){
if (!in_array($friend['accountId'],$accountData)){
if (!in_array($friend['accountId'],$accountIds)){
// 执行切换好友命令
$randomKey = array_rand($accountIds, 1);
$toAccountId = $accountIds[$randomKey];

View File

@@ -17,7 +17,7 @@ class MomentsController extends BaseController
// 获取授权token
$authorization = $this->authorization;
if (empty($authorization)) {
return errorJson('缺少授权信息');
return json_encode(['msg' => '缺少授权信息','code' => 400]);
}
try {
@@ -42,18 +42,18 @@ class MomentsController extends BaseController
// 必填参数验证
if (empty($jobPublishWechatMomentsItems) || !is_array($jobPublishWechatMomentsItems)) {
return errorJson('至少需要选择一个发布账号');
return json_encode(['msg' => '至少需要选择一个发布账号','code' => 400]);
}
// 根据朋友圈类型验证必填字段
if ($momentContentType == 1 && empty($text)) { // 纯文本
return errorJson('朋友圈内容不能为空');
return json_encode(['msg' => '朋友圈内容不能为空','code' => 400]);
} else if ($momentContentType == 2 && (empty($picUrlList) || empty($text))) { // 图片+文字
return errorJson('朋友圈内容和图片不能为空');
return json_encode(['msg' => '朋友圈内容和图片不能为空','code' => 400]);
} else if ($momentContentType == 3 && (empty($videoUrl) || empty($text))) { // 视频+文字
return errorJson('朋友圈内容和视频不能为空');
return json_encode(['msg' => '朋友圈内容和视频不能为空','code' => 400]);
} else if ($momentContentType == 4 && (empty($link) || empty($text))) { // 链接+文字
return errorJson('朋友圈内容和链接不能为空');
return json_encode(['msg' => '朋友圈内容和链接不能为空','code' => 400]);
}
// 构建请求参数
@@ -84,13 +84,13 @@ class MomentsController extends BaseController
$result = requestCurl($this->baseUrl . 'api/JobPublishWechatMoments/addJob', $params, 'POST', $header, 'json');
// 处理响应
if (empty($result)) {
return successJson([], '朋友圈任务创建成功');
return json_encode(['msg' => '朋友圈任务创建成功','code' => 200]);
} else {
// 如果返回的是错误信息
return errorJson($result);
return json_encode(['msg' => $result,'code' => 400]);
}
} catch (\Exception $e) {
return errorJson('发布朋友圈失败' . $e->getMessage());
return json_encode(['msg' => '发布朋友圈失败','code' => 400]);
}
}

View File

@@ -34,15 +34,22 @@ Route::group('v1/', function () {
Route::get('list', 'app\chukebao\controller\AccountsController@getList'); // 获取账号列表
});
//客服相关
//消息相关
Route::group('message/', function () {
Route::get('list', 'app\chukebao\controller\MessageController@getList'); // 获取好友列表
Route::get('readMessage', 'app\chukebao\controller\MessageController@readMessage'); // 读取消息
Route::get('details', 'app\chukebao\controller\MessageController@details'); // 消息详情
Route::get('getMessageStatus', 'app\chukebao\controller\MessageController@getMessageStatus'); // 获取单条消息发送状态
});
//微信分组
Route::get('wechatGroup/list', 'app\chukebao\controller\WechatGroupController@getList'); // 微信分组
Route::group('wechatGroup/', function () {
Route::get('list', 'app\chukebao\controller\WechatGroupController@getList'); // 获取分组列表
Route::post('add', 'app\chukebao\controller\WechatGroupController@create'); // 新增分组
Route::post('update', 'app\chukebao\controller\WechatGroupController@update'); // 更新分组
Route::delete('delete', 'app\chukebao\controller\WechatGroupController@delete'); // 删除分组(假删除)
Route::post('move', 'app\chukebao\controller\WechatGroupController@move'); // 移动分组(好友/群移动到指定分组)
});

View File

@@ -29,6 +29,7 @@ class AccountsController extends BaseController
$query = Db::table('s2_company_account')
->alias('a')
->join('users u', 'a.id = u.s2_accountId')
->where([
['a.departmentId', '=', $companyId],
['a.status', '=', 0],
@@ -48,6 +49,7 @@ class AccountsController extends BaseController
$total = (clone $query)->count();
$list = $query->field([
'a.id',
'u.id as uid',
'a.userName',
'a.realName',
'a.nickname',

View File

@@ -562,7 +562,7 @@ class AiChatController extends BaseController
$data = [
'tokens' => $tokenCount * 20,
'type' => 0,
'form' => 1,
'form' => 13,
'wechatAccountId' => $params['wechatAccountId'],
'friendIdOrGroupId' => $params['friendId'],
'remarks' => $remarks,
@@ -816,7 +816,7 @@ class AiChatController extends BaseController
$data = [
'tokens' => $res['data']['token'],
'type' => 0,
'form' => 1,
'form' => 13,
'wechatAccountId' => $wechatAccountId,
'friendIdOrGroupId' => $friendId,
'remarks' => $remarks,

View File

@@ -36,6 +36,7 @@ class DataProcessing extends BaseController
'CmdChatroomOperate', //修改群信息 {chatroomName群名、announce公告、extra公告、wechatAccountId、wechatChatroomId}
'CmdNewMessage', //接收消息
'CmdSendMessageResult', //更新消息状态
'CmdPinToTop', //置顶
];
if (empty($type) || empty($wechatAccountId)) {
@@ -164,6 +165,41 @@ class DataProcessing extends BaseController
$msg = '更新消息状态成功';
break;
case 'CmdPinToTop': //置顶
$wechatFriendId = $this->request->param('wechatFriendId', 0);
$wechatChatroomId = $this->request->param('wechatChatroomId', 0);
$isTop = $this->request->param('isTop', null);
if ($isTop === null) {
return ResponseHelper::error('isTop不能为空');
}
if (empty($wechatFriendId) && empty($wechatChatroomId)) {
return ResponseHelper::error('wechatFriendId或chatroomId至少提供一个');
}
if (!empty($wechatFriendId)){
$data = WechatFriendModel::where(['id' => $wechatFriendId,'wechatAccountId' => $wechatAccountId])->find();
$msg = $isTop == 1 ? '已置顶' : '取消置顶';
if(empty($data)){
return ResponseHelper::error('好友不存在');
}
}
if (!empty($wechatChatroomId)){
$data = WechatChatroomModel::where(['id' => $wechatChatroomId,'wechatAccountId' => $wechatAccountId])->find();
$msg = $isTop == 1 ? '已置顶' : '取消置顶';
if(empty($data)){
return ResponseHelper::error('群聊不存在');
}
}
$data->updateTime = time();
$data->isTop = $isTop;
$data->save();
break;
}
return ResponseHelper::success('',$msg,$codee);
}

View File

@@ -2,12 +2,24 @@
namespace app\chukebao\controller;
use app\api\model\WechatMessageModel;
use app\chukebao\model\FriendSettings;
use library\ResponseHelper;
use think\Db;
use think\facade\Env;
use app\common\service\AuthService;
class MessageController extends BaseController
{
protected $baseUrl;
protected $authorization;
public function __construct()
{
parent::__construct();
$this->baseUrl = Env::get('api.wechat_url');
$this->authorization = AuthService::getSystemAuthorization();
}
public function getList()
{
@@ -20,7 +32,7 @@ class MessageController extends BaseController
$friends = Db::table('s2_wechat_friend')
->where(['accountId' => $accountId, 'isDeleted' => 0])
->column('id,nickname,avatar,conRemark,labels,groupId,wechatAccountId,wechatId,extendFields,phone,region');
->column('id,nickname,avatar,conRemark,labels,groupId,wechatAccountId,wechatId,extendFields,phone,region,isTop');
// 构建好友子查询
@@ -31,7 +43,7 @@ class MessageController extends BaseController
// 优化后的查询使用MySQL兼容的查询方式
$unionQuery = "
(SELECT m.id, m.content, m.wechatFriendId, m.wechatChatroomId, m.createTime, m.wechatTime,m.wechatAccountId, 2 as msgType, wc.nickname, wc.chatroomAvatar as avatar, wc.chatroomId
(SELECT m.id, m.content, m.wechatFriendId, m.wechatChatroomId, m.createTime, m.wechatTime,m.wechatAccountId, 2 as msgType, wc.nickname, wc.chatroomAvatar as avatar, wc.chatroomId, wc.isTop
FROM s2_wechat_chatroom wc
INNER JOIN s2_wechat_message m ON wc.id = m.wechatChatroomId AND m.type = 2
INNER JOIN (
@@ -43,7 +55,7 @@ class MessageController extends BaseController
WHERE wc.accountId = {$accountId} AND wc.isDeleted = 0
)
UNION ALL
(SELECT m.id, m.content, m.wechatFriendId, m.wechatChatroomId, m.createTime, m.wechatTime, 1 as msgType, 1 as nickname, 1 as avatar, 1 as chatroomId, 1 as wechatAccountId
(SELECT m.id, m.content, m.wechatFriendId, m.wechatChatroomId, m.createTime, m.wechatTime, 1 as msgType, 1 as nickname, 1 as avatar, 1 as chatroomId, 1 as wechatAccountId, 0 as isTop
FROM s2_wechat_message m
INNER JOIN (
SELECT wechatFriendId, MAX(wechatTime) as maxTime, MAX(id) as maxId
@@ -64,7 +76,6 @@ class MessageController extends BaseController
return $b['wechatTime'] <=> $a['wechatTime'];
});
// 批量统计未读数量isRead=0按好友/群聊分别聚合
$friendIds = [];
$chatroomIds = [];
@@ -122,6 +133,7 @@ class MessageController extends BaseController
$v['extendFields'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['extendFields'] : [];
$v['region'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['region'] : '';
$v['phone'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['phone'] : '';
$v['isTop'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['isTop'] : 0;
$v['labels'] = !empty($friends[$v['wechatFriendId']]) ? json_decode($friends[$v['wechatFriendId']]['labels'], true) : [];
$unreadCount = isset($friendUnreadMap[$v['wechatFriendId']]) ? (int)$friendUnreadMap[$v['wechatFriendId']] : 0;
@@ -136,7 +148,7 @@ class MessageController extends BaseController
$v['id'] = !empty($v['wechatFriendId']) ? $v['wechatFriendId'] : $v['wechatChatroomId'];
$v['config'] = [
'top' => false,
'top' => !empty($v['isTop']) ? true : false,
'unreadCount' => $unreadCount,
'chat' => true,
'msgTime' => $v['wechatTime'],
@@ -150,7 +162,7 @@ class MessageController extends BaseController
'wechatTime' => $wechatTime
];
unset($v['wechatFriendId'], $v['wechatChatroomId']);
unset($v['wechatFriendId'], $v['wechatChatroomId'],$v['isTop']);
}
unset($v);
@@ -184,6 +196,110 @@ class MessageController extends BaseController
}
/**
* 获取单条消息发送状态(带轮询功能)
* @return \think\response\Json
*/
public function getMessageStatus()
{
$messageId = $this->request->param('messageId', 0);
$wechatAccountId = $this->request->param('wechatAccountId', '');
$accountId = $this->getUserInfo('s2_accountId');
$wechatFriendId = $this->request->param('wechatFriendId', '');
$wechatChatroomId = $this->request->param('wechatChatroomId', '');
if (empty($accountId)) {
return ResponseHelper::error('请先登录');
}
if (empty($messageId)) {
return ResponseHelper::error('消息ID不能为空');
}
if(empty($wechatFriendId) && empty($wechatChatroomId)) {
return ResponseHelper::error('消息类型不能为空');
}
// 查询单条消息的基本信息(只需要发送状态相关字段)
$message = Db::table('s2_wechat_message')
->where('id', $messageId)
->field('id,wechatAccountId,wechatFriendId,wechatChatroomId,sendStatus')
->find();
if (empty($message)) {
$message = [
'id' => $messageId,
'wechatAccountId' => $wechatAccountId,
'wechatFriendId' => $wechatFriendId,
'wechatChatroomId' => $wechatChatroomId,
'sendStatus' => 0,
];
}
$sendStatus = isset($message['sendStatus']) ? (int)$message['sendStatus'] : 0;
$isUpdated = false;
$pollCount = 0;
$maxPollCount = 10; // 最多轮询10次
// 如果sendStatus不为0开始轮询
if ($sendStatus != 0) {
$messageRequest = [
'id' => $message['id'],
'wechatAccountId' => !empty($wechatAccountId) ? $wechatAccountId : $message['wechatAccountId'],
'wechatFriendId' => !empty($message['wechatFriendId']) ? $message['wechatFriendId'] : '',
'wechatChatroomId' => !empty($message['wechatChatroomId']) ? $message['wechatChatroomId'] : '',
'from' => '',
'to' => '',
];
// 轮询逻辑最多10次
while ($pollCount < $maxPollCount && $sendStatus != 0) {
$pollCount++;
// 请求线上接口获取最新状态
$newData = $this->fetchLatestMessageFromApi($messageRequest);
if (!empty($newData)) {
// 重新查询消息状态(可能已更新)
$updatedMessage = Db::table('s2_wechat_message')
->where('id', $messageId)
->field('sendStatus')
->find();
if (!empty($updatedMessage)) {
$newSendStatus = isset($updatedMessage['sendStatus']) ? (int)$updatedMessage['sendStatus'] : 0;
// 如果状态已更新为0已发送停止轮询
if ($newSendStatus == 0) {
$sendStatus = 0;
$isUpdated = true;
break;
}
// 如果状态仍然是1继续轮询但需要等待一下避免请求过快
if ($newSendStatus != 0 && $pollCount < $maxPollCount) {
// 每次轮询间隔500毫秒0.5秒)
usleep(500000);
}
}
} else {
// 如果请求失败,等待后继续尝试
if ($pollCount < $maxPollCount) {
usleep(500000);
}
}
}
}
// 返回发送状态信息
return ResponseHelper::success([
'messageId' => $messageId,
'sendStatus' => $sendStatus,
'statusText' => $sendStatus == 0 ? '已发送' : '发送中'
]);
}
public function details()
{
$wechatFriendId = $this->request->param('wechatFriendId', '');
@@ -218,14 +334,143 @@ class MessageController extends BaseController
$total = Db::table('s2_wechat_message')->where($where)->count();
$list = Db::table('s2_wechat_message')->where($where)->page($page, $limit)->order('id DESC')->select();
foreach ($list as $k => &$v) {
$v['wechatTime'] = !empty($v['wechatTime']) ? date('Y-m-d H:i:s', $v['wechatTime']) : '';
// 检查消息是否有sendStatus字段如果有且不为0则请求线上最新接口
foreach ($list as $k => &$item) {
// 检查是否存在sendStatus字段且不为00表示已发送成功
if (isset($item['sendStatus']) && $item['sendStatus'] != 0) {
// 需要请求新的数据
$messageRequest = [
'id' => $item['id'],
'wechatAccountId' => $wechatAccountId,
'wechatFriendId' => $wechatFriendId,
'wechatChatroomId' => $wechatChatroomId,
'from' => '',
'to' => '',
];
$newData = $this->fetchLatestMessageFromApi($messageRequest);
if (!empty($newData)){
$item['sendStatus'] = 0;
}
}
// 格式化时间
$item['wechatTime'] = !empty($item['wechatTime']) ? date('Y-m-d H:i:s', $item['wechatTime']) : '';
}
unset($item);
return ResponseHelper::success(['total' => $total, 'list' => $list]);
}
/**
* 从线上接口获取最新消息
* @param array $messageRequest 消息项包含wechatAccountId、wechatFriendId或wechatChatroomId、id等
* @return array|null 最新消息数据失败返回null
*/
private function fetchLatestMessageFromApi($messageRequest)
{
if (empty($this->baseUrl) || empty($this->authorization)) {
return null;
}
try {
// 设置请求头
$headerData = ['client:system'];
$header = setHeader($headerData, $this->authorization, 'json');
// 判断是好友消息还是群聊消息
if (!empty($messageRequest['wechatFriendId'])) {
// 好友消息接口
$params = [
'keyword' => '',
'msgType' => '',
'accountId' => '',
'count' => 20, // 获取多条消息以便找到对应的消息
'messageId' => isset($messageRequest['id']) ? $messageRequest['id'] : '',
'olderData' => true,
'wechatAccountId' => $messageRequest['wechatAccountId'],
'wechatFriendId' => $messageRequest['wechatFriendId'],
'from' => $messageRequest['from'],
'to' => $messageRequest['to'],
'searchFrom' => 'admin'
];
$result = requestCurl($this->baseUrl . 'api/FriendMessage/searchMessage', $params, 'GET', $header, 'json');
$response = handleApiResponse($result);
// 查找对应的消息
if (!empty($response) && is_array($response)) {
$data = $response[0];
if ($data['sendStatus'] == 0){
WechatMessageModel::where(['id' => $data['id']])->update(['sendStatus' => 0]);
return true;
}
}
return false;
} elseif (!empty($messageRequest['wechatChatroomId'])) {
// 群聊消息接口
$params = [
'keyword' => '',
'msgType' => '',
'accountId' => '',
'count' => 20, // 获取多条消息以便找到对应的消息
'messageId' => isset($messageRequest['id']) ? $messageRequest['id'] : '',
'olderData' => true,
'wechatId' => '',
'wechatAccountId' => $messageRequest['wechatAccountId'],
'wechatChatroomId' => $messageRequest['wechatChatroomId'],
'from' => $messageRequest['from'],
'to' => $messageRequest['to'],
'searchFrom' => 'admin'
];
$result = requestCurl($this->baseUrl . 'api/ChatroomMessage/searchMessage', $params, 'GET', $header, 'json');
$response = handleApiResponse($result);
// 查找对应的消息
if (!empty($response) && is_array($response)) {
$data = $response[0];
if ($data['sendStatus'] == 0){
WechatMessageModel::where(['id' => $data['id']])->update(['sendStatus' => 0]);
return true;
}
}
return false;
}
} catch (\Exception $e) {
// 记录错误日志,但不影响主流程
\think\facade\Log::error('获取线上最新消息失败:' . $e->getMessage());
}
return null;
}
/**
* 更新数据库中的消息
* @param array $latestMessage 线上获取的最新消息
* @param array $oldMessage 旧消息数据
*/
private function updateMessageInDatabase($latestMessage, $oldMessage)
{
try {
// 使用API模块的MessageController来保存消息
$apiMessageController = new \app\api\controller\MessageController();
// 判断是好友消息还是群聊消息
if (!empty($oldMessage['wechatFriendId'])) {
// 保存好友消息
$apiMessageController->saveMessage($latestMessage);
} elseif (!empty($oldMessage['wechatChatroomId'])) {
// 保存群聊消息
$apiMessageController->saveChatroomMessage($latestMessage);
}
} catch (\Exception $e) {
// 记录错误日志,但不影响主流程
\think\facade\Log::error('更新数据库消息失败:' . $e->getMessage());
}
}
}

View File

@@ -16,6 +16,8 @@ class TokensRecordController extends BaseController
$limit = $this->request->param('limit', 10);
$type = $this->request->param('type', '');
$form = $this->request->param('form', '');
$startTime = $this->request->param('startTime', '');
$endTime = $this->request->param('endTime', '');
$userId = $this->getUserInfo('id');
$companyId = $this->getUserInfo('companyId');
@@ -32,6 +34,26 @@ class TokensRecordController extends BaseController
$where[] = ['form','=',$form];
}
// 时间筛选
if (!empty($startTime)) {
// 支持时间戳或日期字符串格式
$startTimestamp = is_numeric($startTime) ? intval($startTime) : strtotime($startTime);
if ($startTimestamp !== false) {
$where[] = ['createTime', '>=', $startTimestamp];
}
}
if (!empty($endTime)) {
// 支持时间戳或日期字符串格式
$endTimestamp = is_numeric($endTime) ? intval($endTime) : strtotime($endTime);
if ($endTimestamp !== false) {
// 如果是日期字符串自动设置为当天的23:59:59
if (!is_numeric($endTime)) {
$endTimestamp = strtotime(date('Y-m-d 23:59:59', $endTimestamp));
}
$where[] = ['createTime', '<=', $endTimestamp];
}
}
$query = TokensRecord::where($where);
$total = $query->count();
@@ -78,9 +100,6 @@ class TokensRecordController extends BaseController
return ResponseHelper::error('类型参数错误0为减少1为增加');
}
if (!in_array($form, [0, 1, 2, 3, 4, 5])) {
return ResponseHelper::error('来源参数错误');
}
// 重试机制最多重试3次
$maxRetries = 3;
@@ -108,7 +127,7 @@ class TokensRecordController extends BaseController
Db::startTrans();
try {
// 使用悲观锁获取用户当前tokens余额确保并发安全
$userInfo = TokensCompany::where('companyId', $companyId)->lock(true)->find();
$userInfo = TokensCompany::where(['companyId'=> $companyId,'userId' => $userId])->lock(true)->find();
if (!$userInfo) {
throw new \Exception('用户不存在');
}

View File

@@ -14,13 +14,30 @@ class WechatChatroomController extends BaseController
public function getList(){
$page = $this->request->param('page', 1);
$limit = $this->request->param('limit', 10);
$keyword = $this->request->param('keyword', '');
$groupIds = $this->request->param('groupIds', '');
$accountId = $this->getUserInfo('s2_accountId');
if (empty($accountId)){
return ResponseHelper::error('请先登录');
}
$query = Db::table('s2_wechat_chatroom')
->where(['accountId' => $accountId,'isDeleted' => 0])
->order('id desc');
->where(['accountId' => $accountId,'isDeleted' => 0]);
// 关键字搜索群昵称、微信号这里使用chatroomId作为群标识
if ($keyword !== '' && $keyword !== null) {
$query->where(function ($q) use ($keyword) {
$like = '%' . $keyword . '%';
$q->whereLike('nickname', $like)
->whereOr('conRemark', 'like', $like);
});
}
// 分组筛选groupIds单个分组ID
if ($groupIds !== '' && $groupIds !== null) {
$query->where('groupIds', $groupIds);
}
$query->order('id desc');
$total = $query->count();
$list = $query->page($page, $limit)->select();
@@ -168,7 +185,7 @@ class WechatChatroomController extends BaseController
$data = [
'tokens' => $res['data']['token'],
'type' => 0,
'form' => 3,
'form' => 14,
'wechatAccountId' => $wechatAccountId,
'friendIdOrGroupId' => $groupId,
'remarks' => $remarks,

View File

@@ -13,13 +13,32 @@ class WechatFriendController extends BaseController
{
$page = $this->request->param('page', 1);
$limit = $this->request->param('limit', 10);
$keyword = $this->request->param('keyword', '');
$groupIds = $this->request->param('groupIds', '');
$accountId = $this->getUserInfo('s2_accountId');
if (empty($accountId)) {
return ResponseHelper::error('请先登录');
}
$query = Db::table('s2_wechat_friend')
->where(['accountId' => $accountId, 'isDeleted' => 0])
->order('id desc');
->where(['accountId' => $accountId, 'isDeleted' => 0]);
// 关键字搜索:昵称、备注、微信号
if ($keyword !== '' && $keyword !== null) {
$query->where(function ($q) use ($keyword) {
$like = '%' . $keyword . '%';
$q->whereLike('nickname', $like)
->whereOr('conRemark', 'like', $like)
->whereOr('alias', 'like', $like)
->whereOr('wechatId', 'like', $like);
});
}
// 分组筛选groupIds单个分组ID
if ($groupIds !== '' && $groupIds !== null) {
$query->where('groupIds', $groupIds);
}
$query->order('id desc');
$total = $query->count();
$list = $query->page($page, $limit)->select();
@@ -114,6 +133,7 @@ class WechatFriendController extends BaseController
$requestData = $this->request->param();
$updatableColumns = [
'phone',
'conRemark',
];
$columnUpdates = [];

View File

@@ -4,27 +4,32 @@ namespace app\chukebao\controller;
use library\ResponseHelper;
use think\Db;
use app\chukebao\model\ChatGroups;
class WechatGroupController extends BaseController
{
public function getList(){
$accountId = $this->getUserInfo('s2_accountId');
$userId = $this->getUserInfo('id');
/**
* 获取分组列表
* @return \think\response\Json
* @throws \Exception
*/
public function getList()
{
// 公司维度分组,不强制校验 userId
$companyId = $this->getUserInfo('companyId');
$query = Db::table('s2_wechat_group')
->where(function ($query) use ($accountId,$companyId) {
$query->where('accountId', $accountId)->whereOr('departmentId', $companyId);
})
->whereIn('groupType',[1,2])
->order('groupType desc,sortIndex desc,id desc');
$list = $query->select();
$query = ChatGroups::where([
'companyId' => $companyId,
'isDel' => 0,
])
->order('groupType desc,sort desc,id desc');
$total = $query->count();
$list = $query->select();
// 处理每个好友的数据
// 处理每个分组的数据
$list = is_array($list) ? $list : $list->toArray();
foreach ($list as $k => &$v) {
$v['createTime'] = !empty($v['createTime']) ? date('Y-m-d H:i:s', $v['createTime']) : '';
}
@@ -32,4 +37,237 @@ class WechatGroupController extends BaseController
return ResponseHelper::success(['list'=>$list,'total'=>$total]);
}
/**
* 新增分组
* @return \think\response\Json
* @throws \Exception
*/
public function create()
{
$groupName = $this->request->param('groupName', '');
$groupMemo = $this->request->param('groupMemo', '');
$groupType = $this->request->param('groupType', 1);
$sort = $this->request->param('sort', 0);
$companyId = $this->getUserInfo('companyId');
// 只校验公司维度
if (empty($companyId)) {
return ResponseHelper::error('请先登录');
}
if (empty($groupName)) {
return ResponseHelper::error('分组名称不能为空');
}
// 验证分组类型
if (!in_array($groupType, [1, 2])) {
return ResponseHelper::error('无效的分组类型');
}
Db::startTrans();
try {
$chatGroup = new ChatGroups();
$chatGroup->groupName = $groupName;
$chatGroup->groupMemo = $groupMemo;
$chatGroup->groupType = $groupType;
$chatGroup->sort = $sort;
$chatGroup->userId = $this->getUserInfo('id');
$chatGroup->companyId = $companyId;
$chatGroup->createTime = time();
$chatGroup->isDel = 0;
$chatGroup->save();
Db::commit();
return ResponseHelper::success(['id' => $chatGroup->id], '创建成功');
} catch (\Exception $e) {
Db::rollback();
return ResponseHelper::error('创建失败:' . $e->getMessage());
}
}
/**
* 更新分组
* @return \think\response\Json
* @throws \Exception
*/
public function update()
{
$id = $this->request->param('id', 0);
$groupName = $this->request->param('groupName', '');
$groupMemo = $this->request->param('groupMemo', '');
$groupType = $this->request->param('groupType', 1);
$sort = $this->request->param('sort', 0);
$companyId = $this->getUserInfo('companyId');
if (empty($companyId)) {
return ResponseHelper::error('请先登录');
}
if (empty($id)) {
return ResponseHelper::error('参数缺失');
}
if (empty($groupName)) {
return ResponseHelper::error('分组名称不能为空');
}
// 验证分组类型
if (!in_array($groupType, [1, 2])) {
return ResponseHelper::error('无效的分组类型');
}
// 检查分组是否存在
$chatGroup = ChatGroups::where([
'id' => $id,
'companyId' => $companyId,
'isDel' => 0,
])->find();
if (empty($chatGroup)) {
return ResponseHelper::error('该分组不存在或已删除');
}
Db::startTrans();
try {
$chatGroup->groupName = $groupName;
$chatGroup->groupMemo = $groupMemo;
$chatGroup->groupType = $groupType;
$chatGroup->sort = $sort;
$chatGroup->save();
Db::commit();
return ResponseHelper::success('', '更新成功');
} catch (\Exception $e) {
Db::rollback();
return ResponseHelper::error('更新失败:' . $e->getMessage());
}
}
/**
* 删除分组(假删除)
* @return \think\response\Json
* @throws \Exception
*/
public function delete()
{
$id = $this->request->param('id', 0);
$companyId = $this->getUserInfo('companyId');
if (empty($companyId)) {
return ResponseHelper::error('请先登录');
}
if (empty($id)) {
return ResponseHelper::error('参数缺失');
}
// 检查分组是否存在
$chatGroup = ChatGroups::where([
'id' => $id,
'companyId' => $companyId,
'isDel' => 0,
])->find();
if (empty($chatGroup)) {
return ResponseHelper::error('该分组不存在或已删除');
}
Db::startTrans();
try {
// 1. 假删除当前分组
$chatGroup->isDel = 1;
$chatGroup->deleteTime = time();
$chatGroup->save();
// 2. 重置该分组下所有好友的分组IDs2_wechat_friend.groupIds -> 0
Db::table('s2_wechat_friend')
->where('groupIds', $id)
->update(['groupIds' => 0]);
// 3. 重置该分组下所有微信群的分组IDs2_wechat_chatroom.groupIds -> 0
Db::table('s2_wechat_chatroom')
->where('groupIds', $id)
->update(['groupIds' => 0]);
Db::commit();
return ResponseHelper::success('', '删除成功');
} catch (\Exception $e) {
Db::rollback();
return ResponseHelper::error('删除失败:' . $e->getMessage());
}
}
/**
* 移动分组(将好友或群移动到指定分组)
* @return \think\response\Json
* @throws \Exception
*/
public function move()
{
// type: friend 好友, chatroom 群
$type = $this->request->param('type', 'friend');
$targetId = (int)$this->request->param('groupId', 0);
// 仅支持单个ID移动
$idParam = $this->request->param('id', 0);
$companyId = $this->getUserInfo('companyId');
if (empty($companyId)) {
return ResponseHelper::error('请先登录');
}
if (empty($targetId)) {
return ResponseHelper::error('目标分组ID不能为空');
}
// 仅允许单个 ID禁止批量
$moveId = (int)$idParam;
if (empty($moveId)) {
return ResponseHelper::error('需要移动的ID不能为空');
}
// 校验目标分组是否存在且属于当前公司
$targetGroup = ChatGroups::where([
'id' => $targetId,
'companyId' => $companyId,
'isDel' => 0,
])->find();
if (empty($targetGroup)) {
return ResponseHelper::error('目标分组不存在或已删除');
}
// 校验分组类型与移动对象类型是否匹配
// groupType: 1=好友分组, 2=群分组
if ($type === 'friend' && (int)$targetGroup->groupType !== 1) {
return ResponseHelper::error('目标分组类型错误(需要好友分组)');
}
if ($type === 'chatroom' && (int)$targetGroup->groupType !== 2) {
return ResponseHelper::error('目标分组类型错误(需要群分组)');
}
Db::startTrans();
try {
if ($type === 'friend') {
// 移动单个好友到指定分组:更新 s2_wechat_friend.groupIds
Db::table('s2_wechat_friend')
->where('id', $moveId)
->update(['groupIds' => $targetId]);
} elseif ($type === 'chatroom') {
// 移动单个群到指定分组:更新 s2_wechat_chatroom.groupIds
Db::table('s2_wechat_chatroom')
->where('id', $moveId)
->update(['groupIds' => $targetId]);
} else {
Db::rollback();
return ResponseHelper::error('无效的类型参数');
}
Db::commit();
return ResponseHelper::success('', '移动成功');
} catch (\Exception $e) {
Db::rollback();
return ResponseHelper::error('移动失败:' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace app\chukebao\model;
use think\Model;
class ChatGroups extends Model
{
protected $pk = 'id';
protected $name = 'chat_groups';
// 不开启自动时间戳,手动维护 createTime / deleteTime
protected $autoWriteTimestamp = false;
}

View File

@@ -34,6 +34,7 @@ class WorkbenchGroupCreateCommand extends Command
// 检查队列是否已经在运行
$queueLockKey = "queue_lock:{$this->queueName}";
Cache::rm($queueLockKey);
if (Cache::get($queueLockKey)) {
$output->writeln("队列 {$this->queueName} 已经在运行中,跳过执行");
Log::warning("队列 {$this->queueName} 已经在运行中,跳过执行");

View File

@@ -51,7 +51,8 @@ class Attachment extends Controller
'data' => [
'id' => $existFile['id'],
'name' => $existFile['name'],
'url' => $existFile['source']
'url' => $existFile['source'],
'size' => isset($existFile['size']) ? $existFile['size'] : 0
]
]);
}
@@ -97,7 +98,8 @@ class Attachment extends Controller
'data' => [
'id' => $attachment->id,
'name' => $attachmentData['name'],
'url' => $attachmentData['source']
'url' => $attachmentData['source'],
'size' => $attachmentData['size']
]
]);

View File

@@ -9,6 +9,7 @@ use app\common\util\PaymentUtil;
use think\facade\Env;
use think\facade\Request;
use app\common\model\Order;
use app\common\model\User;
/**
* 支付服务(内部调用)
@@ -495,7 +496,14 @@ class PaymentService
switch ($order['orderType']) {
case 1:
// 处理购买算力
$token = TokensCompany::where(['companyId' => $order->companyId])->find();
// 查询用户信息判断是否为管理员需要同时匹配userId和companyId
$user = User::where([
'id' => $order->userId,
'companyId' => $order->companyId
])->find();
$isAdmin = (!empty($user) && isset($user->isAdmin) && $user->isAdmin == 1) ? 1 : 0;
$token = TokensCompany::where(['companyId' => $order->companyId,'userId' => $order->userId])->find();
$goodsSpecs = json_decode($order->goodsSpecs, true);
if (!empty($token)) {
$token->tokens = $token->tokens + $goodsSpecs['tokens'];
@@ -504,8 +512,10 @@ class PaymentService
$newTokens = $token->tokens;
} else {
$tokensCompany = new TokensCompany();
$tokensCompany->userId = $order->userId;
$tokensCompany->companyId = $order->companyId;
$tokensCompany->tokens = $goodsSpecs['tokens'];
$tokensCompany->isAdmin = $isAdmin;
$tokensCompany->createTime = time();
$tokensCompany->updateTime = time();
$tokensCompany->save();

View File

@@ -130,6 +130,7 @@ Route::group('v1/', function () {
Route::get('get-item-detail', 'app\cunkebao\controller\ContentLibraryController@getItemDetail'); // 获取内容库素材详情
Route::post('update-item', 'app\cunkebao\controller\ContentLibraryController@updateItem'); // 更新内容库素材
Route::any('aiEditContent', 'app\cunkebao\controller\ContentLibraryController@aiEditContent');
Route::post('import-excel', 'app\cunkebao\controller\ContentLibraryController@importExcel'); // 导入Excel表格支持图片
});
// 好友相关
@@ -162,6 +163,7 @@ Route::group('v1/', function () {
Route::get('queryOrder', 'app\cunkebao\controller\TokensController@queryOrder'); // 查询订单(扫码付款)
Route::get('orderList', 'app\cunkebao\controller\TokensController@getOrderList'); // 获取订单列表
Route::get('statistics', 'app\cunkebao\controller\TokensController@getTokensStatistics'); // 获取算力统计
Route::post('allocate', 'app\cunkebao\controller\TokensController@allocateTokens'); // 分配token仅管理员
});

View File

@@ -2,6 +2,9 @@
namespace app\cunkebao\controller;
use app\common\model\Device as DeviceModel;
use app\common\model\DeviceWechatLogin as DeviceWechatLoginModel;
use app\common\model\WechatCustomer as WechatCustomerModel;
use app\cunkebao\model\ContentLibrary;
use app\cunkebao\model\ContentItem;
use library\s2\titleFavicon;
@@ -12,6 +15,8 @@ use think\facade\Cache;
use think\facade\Env;
use app\api\controller\AutomaticAssign;
use think\facade\Request;
use PHPExcel_IOFactory;
use app\common\util\AliyunOSS;
/**
* 内容库控制器
@@ -64,6 +69,7 @@ class ContentLibraryController extends Controller
$keywordInclude = isset($param['keywordInclude']) ? json_encode($param['keywordInclude'], 256) : json_encode([]);
$keywordExclude = isset($param['keywordExclude']) ? json_encode($param['keywordExclude'], 256) : json_encode([]);
$devices = isset($param['devices']) ? json_encode($param['devices'], 256) : json_encode([]);
$sourceType = isset($param['sourceType']) ? $param['sourceType'] : 1;
@@ -75,6 +81,7 @@ class ContentLibraryController extends Controller
'sourceGroups' => $sourceType == 2 && isset($param['wechatGroups']) ? json_encode($param['wechatGroups']) : json_encode([]), // 选择的微信群
'groupMembers' => $sourceType == 2 && isset($param['groupMembers']) ? json_encode($param['groupMembers']) : json_encode([]), // 群组成员
'catchType' => isset($param['catchType']) ? json_encode($param['catchType']) : json_encode([]), // 采集类型
'devices' => $devices,
// 关键词配置
'keywordInclude' => $keywordInclude, // 包含的关键词
'keywordExclude' => $keywordExclude, // 排除的关键词
@@ -87,6 +94,8 @@ class ContentLibraryController extends Controller
'timeEnd' => isset($param['endTime']) ? strtotime($param['endTime']) : 0, // 结束时间(转换为时间戳)
// 来源类型
'sourceType' => $sourceType, // 1=好友2=群3=好友和群
// 表单类型
'formType' => isset($param['formType']) ? intval($param['formType']) : 0, // 表单类型默认为0
// 基础信息
'status' => isset($param['status']) ? $param['status'] : 0, // 状态0=禁用1=启用
'userId' => $this->request->userInfo['id'],
@@ -122,6 +131,7 @@ class ContentLibraryController extends Controller
$limit = $this->request->param('limit', 10);
$keyword = $this->request->param('keyword', '');
$sourceType = $this->request->param('sourceType', ''); // 来源类型1=好友2=群
$formType = $this->request->param('formType', ''); // 表单类型筛选
$companyId = $this->request->userInfo['companyId'];
$userId = $this->request->userInfo['id'];
$isAdmin = !empty($this->request->userInfo['isAdmin']);
@@ -147,12 +157,17 @@ class ContentLibraryController extends Controller
$where[] = ['sourceType', '=', $sourceType];
}
// 添加表单类型筛选
if ($formType !== '') {
$where[] = ['formType', '=', $formType];
}
// 获取总记录数
$total = ContentLibrary::where($where)->count();
// 获取分页数据
$list = ContentLibrary::where($where)
->field('id,name,sourceFriends,sourceGroups,keywordInclude,keywordExclude,aiEnabled,aiPrompt,timeEnabled,timeStart,timeEnd,status,sourceType,userId,createTime,updateTime')
->field('id,name,sourceFriends,sourceGroups,keywordInclude,keywordExclude,aiEnabled,aiPrompt,timeEnabled,timeStart,timeEnd,status,sourceType,formType,userId,createTime,updateTime')
->with(['user' => function ($query) {
$query->field('id,username');
}])
@@ -314,7 +329,7 @@ class ContentLibraryController extends Controller
// 查询内容库信息
$library = ContentLibrary::where($where)
->field('id,name,sourceType,sourceFriends,sourceGroups,keywordInclude,keywordExclude,aiEnabled,aiPrompt,timeEnabled,timeStart,timeEnd,status,userId,companyId,createTime,updateTime,groupMembers,catchType')
->field('id,name,sourceType,formType,devices ,sourceFriends,sourceGroups,keywordInclude,keywordExclude,aiEnabled,aiPrompt,timeEnabled,timeStart,timeEnd,status,userId,companyId,createTime,updateTime,groupMembers,catchType')
->find();
if (empty($library)) {
@@ -322,13 +337,14 @@ class ContentLibraryController extends Controller
}
// 处理JSON字段转数组
$library['friendsGroups'] = json_decode($library['sourceFriends'] ?: '[]', true);
$library['wechatGroups'] = json_decode($library['sourceGroups'] ?: '[]', true);
$library['keywordInclude'] = json_decode($library['keywordInclude'] ?: '[]', true);
$library['keywordExclude'] = json_decode($library['keywordExclude'] ?: '[]', true);
$library['groupMembers'] = json_decode($library['groupMembers'] ?: '[]', true);
$library['catchType'] = json_decode($library['catchType'] ?: '[]', true);
unset($library['sourceFriends'], $library['sourceGroups']);
$library['friendsGroups'] = json_decode($library['sourceFriends'] ?: [], true);
$library['wechatGroups'] = json_decode($library['sourceGroups'] ?: [], true);
$library['keywordInclude'] = json_decode($library['keywordInclude'] ?: [], true);
$library['keywordExclude'] = json_decode($library['keywordExclude'] ?: [], true);
$library['groupMembers'] = json_decode($library['groupMembers'] ?: [], true);
$library['catchType'] = json_decode($library['catchType'] ?: [], true);
$library['deviceGroups'] = json_decode($library['devices'] ?: [], true);
unset($library['sourceFriends'], $library['sourceGroups'],$library['devices']);
// 将时间戳转换为日期格式(精确到日)
if (!empty($library['timeStart'])) {
@@ -369,6 +385,32 @@ class ContentLibraryController extends Controller
}
}
//获取设备信息
if (!empty($library['deviceGroups'])) {
$deviceList = DeviceModel::alias('d')
->field([
'd.id', 'd.imei', 'd.memo', 'd.alive',
'l.wechatId',
'a.nickname', 'a.alias', 'a.avatar', 'a.alias', '0 totalFriend'
])
->leftJoin('device_wechat_login l', 'd.id = l.deviceId and l.alive =' . DeviceWechatLoginModel::ALIVE_WECHAT_ACTIVE . ' and l.companyId = d.companyId')
->leftJoin('wechat_account a', 'l.wechatId = a.wechatId')
->whereIn('d.id', $library['deviceGroups'])
->order('d.id desc')
->select();
foreach ($deviceList as &$device) {
$curstomer = WechatCustomerModel::field('friendShip')->where(['wechatId' => $device['wechatId']])->find();
$device['totalFriend'] = $curstomer->friendShip->totalFriend ?? 0;
}
unset($device);
$library['deviceGroupsOptions'] = $deviceList;
} else {
$library['deviceGroupsOptions'] = [];
}
return json([
'code' => 200,
'msg' => '获取成功',
@@ -401,7 +443,8 @@ class ContentLibraryController extends Controller
$where = [
['companyId', '=', $this->request->userInfo['companyId']],
['isDel', '=', 0] // 只查询未删除的记录
['isDel', '=', 0], // 只查询未删除的记录
['id', '=', $param['id']]
];
if (empty($this->request->userInfo['isAdmin'])) {
@@ -420,6 +463,7 @@ class ContentLibraryController extends Controller
$keywordInclude = isset($param['keywordInclude']) ? json_encode($param['keywordInclude'], 256) : json_encode([]);
$keywordExclude = isset($param['keywordExclude']) ? json_encode($param['keywordExclude'], 256) : json_encode([]);
$devices = isset($param['devices']) ? json_encode($param['devices'], 256) : json_encode([]);
// 更新内容库基本信息
$library->name = $param['name'];
@@ -428,6 +472,7 @@ class ContentLibraryController extends Controller
$library->sourceGroups = $param['sourceType'] == 2 && isset($param['wechatGroups']) ? json_encode($param['wechatGroups']) : json_encode([]);
$library->groupMembers = $param['sourceType'] == 2 && isset($param['groupMembers']) ? json_encode($param['groupMembers']) : json_encode([]);
$library->catchType = isset($param['catchType']) ? json_encode($param['catchType']) : json_encode([]);// 采集类型
$library->devices = $devices;
$library->keywordInclude = $keywordInclude;
$library->keywordExclude = $keywordExclude;
$library->aiEnabled = isset($param['aiEnabled']) ? $param['aiEnabled'] : 0;
@@ -435,10 +480,9 @@ class ContentLibraryController extends Controller
$library->timeEnabled = isset($param['timeEnabled']) ? $param['timeEnabled'] : 0;
$library->timeStart = isset($param['startTime']) ? strtotime($param['startTime']) : 0;
$library->timeEnd = isset($param['endTime']) ? strtotime($param['endTime']) : 0;
$library->formType = isset($param['formType']) ? intval($param['formType']) : $library->formType; // 表单类型,如果未传则保持原值
$library->status = isset($param['status']) ? $param['status'] : 0;
$library->updateTime = time();
$library->save();
Db::commit();
@@ -597,8 +641,8 @@ class ContentLibraryController extends Controller
$item['content'] = !empty($item['contentAi']) ? $item['contentAi'] : $item['content'];
// 处理JSON字段
$item['resUrls'] = json_decode($item['resUrls'] ?: '[]', true);
$item['urls'] = json_decode($item['urls'] ?: '[]', true);
$item['resUrls'] = json_decode($item['resUrls'] ?: [], true);
$item['urls'] = json_decode($item['urls'] ?: [], true);
// 格式化时间
if (!empty($item['createMomentTime']) && is_numeric($item['createMomentTime'])) {
@@ -798,8 +842,8 @@ class ContentLibraryController extends Controller
}
// 处理JSON字段
$item['resUrls'] = json_decode($item['resUrls'] ?: '[]', true);
$item['urls'] = json_decode($item['urls'] ?: '[]', true);
$item['resUrls'] = json_decode($item['resUrls'] ?: [], true);
$item['urls'] = json_decode($item['urls'] ?: [], true);
// 添加内容类型的文字描述
$contentTypeMap = [
@@ -1083,12 +1127,12 @@ class ContentLibraryController extends Controller
// 预处理内容库数据
foreach ($libraries as &$library) {
// 解析JSON字段
$library['sourceFriends'] = json_decode($library['sourceFriends'] ?: '[]', true);
$library['sourceGroups'] = json_decode($library['sourceGroups'] ?: '[]', true);
$library['keywordInclude'] = json_decode($library['keywordInclude'] ?: '[]', true);
$library['keywordExclude'] = json_decode($library['keywordExclude'] ?: '[]', true);
$library['groupMembers'] = json_decode($library['groupMembers'] ?: '[]', true);
$library['catchType'] = json_decode($library['catchType'] ?: '[]', true);
$library['sourceFriends'] = json_decode($library['sourceFriends'] ?: [], true);
$library['sourceGroups'] = json_decode($library['sourceGroups'] ?: [], true);
$library['keywordInclude'] = json_decode($library['keywordInclude'] ?: [], true);
$library['keywordExclude'] = json_decode($library['keywordExclude'] ?: [], true);
$library['groupMembers'] = json_decode($library['groupMembers'] ?: [], true);
$library['catchType'] = json_decode($library['catchType'] ?: [], true);
}
unset($library); // 解除引用
@@ -2351,4 +2395,352 @@ class ContentLibraryController extends Controller
return false;
}
}
/**
* 导入Excel表格支持图片导入
* @return \think\response\Json
*/
public function importExcel()
{
try {
$libraryId = $this->request->param('id', 0);
$companyId = $this->request->userInfo['companyId'];
$userId = $this->request->userInfo['id'];
$isAdmin = !empty($this->request->userInfo['isAdmin']);
if (empty($libraryId)) {
return json(['code' => 400, 'msg' => '内容库ID不能为空']);
}
// 验证内容库权限
$libraryWhere = [
['id', '=', $libraryId],
['companyId', '=', $companyId],
['isDel', '=', 0]
];
if (!$isAdmin) {
$libraryWhere[] = ['userId', '=', $userId];
}
$library = ContentLibrary::where($libraryWhere)->find();
if (empty($library)) {
return json(['code' => 500, 'msg' => '内容库不存在或无权限访问']);
}
// 获取文件可能是上传的文件或远程URL
$fileUrl = $this->request->param('fileUrl', '');
$file = Request::file('file');
$tmpFile = '';
if (!empty($fileUrl)) {
// 处理远程URL
if (!preg_match('/^https?:\/\//i', $fileUrl)) {
return json(['code' => 400, 'msg' => '无效的文件URL']);
}
// 验证文件扩展名
$urlExt = strtolower(pathinfo(parse_url($fileUrl, PHP_URL_PATH), PATHINFO_EXTENSION));
if (!in_array($urlExt, ['xls', 'xlsx'])) {
return json(['code' => 400, 'msg' => '只支持Excel文件.xls, .xlsx']);
}
// 下载远程文件到临时目录
$tmpFile = tempnam(sys_get_temp_dir(), 'excel_import_') . '.' . $urlExt;
$fileContent = @file_get_contents($fileUrl);
if ($fileContent === false) {
return json(['code' => 400, 'msg' => '下载远程文件失败请检查URL是否可访问']);
}
file_put_contents($tmpFile, $fileContent);
} elseif ($file) {
// 处理上传的文件
$ext = strtolower($file->getExtension());
if (!in_array($ext, ['xls', 'xlsx'])) {
return json(['code' => 400, 'msg' => '只支持Excel文件.xls, .xlsx']);
}
// 保存临时文件
$tmpFile = $file->getRealPath();
if (empty($tmpFile)) {
$savePath = $file->move(sys_get_temp_dir());
$tmpFile = $savePath->getRealPath();
}
} else {
return json(['code' => 400, 'msg' => '请上传Excel文件或提供文件URL']);
}
if (empty($tmpFile) || !file_exists($tmpFile)) {
return json(['code' => 400, 'msg' => '文件不存在或无法访问']);
}
// 加载Excel文件
$excel = PHPExcel_IOFactory::load($tmpFile);
$sheet = $excel->getActiveSheet();
// 获取所有图片
$images = [];
try {
$drawings = $sheet->getDrawingCollection();
foreach ($drawings as $drawing) {
if ($drawing instanceof \PHPExcel_Worksheet_Drawing) {
$coordinates = $drawing->getCoordinates();
$imagePath = $drawing->getPath();
// 如果是嵌入的图片zip://格式),提取到临时文件
if (strpos($imagePath, 'zip://') === 0) {
$zipEntry = str_replace('zip://', '', $imagePath);
$zipEntry = explode('#', $zipEntry);
$zipFile = $zipEntry[0];
$imageEntry = isset($zipEntry[1]) ? $zipEntry[1] : '';
if (!empty($imageEntry)) {
$zip = new \ZipArchive();
if ($zip->open($zipFile) === true) {
$imageContent = $zip->getFromName($imageEntry);
if ($imageContent !== false) {
$tempImageFile = tempnam(sys_get_temp_dir(), 'excel_img_');
file_put_contents($tempImageFile, $imageContent);
$images[$coordinates] = $tempImageFile;
}
$zip->close();
}
}
} elseif (file_exists($imagePath)) {
// 如果是外部文件路径
$images[$coordinates] = $imagePath;
}
} elseif ($drawing instanceof \PHPExcel_Worksheet_MemoryDrawing) {
// 处理内存中的图片
$coordinates = $drawing->getCoordinates();
$imageResource = $drawing->getImageResource();
if ($imageResource) {
$tempImageFile = tempnam(sys_get_temp_dir(), 'excel_img_') . '.png';
$imageType = $drawing->getMimeType();
switch ($imageType) {
case 'image/png':
imagepng($imageResource, $tempImageFile);
break;
case 'image/jpeg':
case 'image/jpg':
imagejpeg($imageResource, $tempImageFile);
break;
case 'image/gif':
imagegif($imageResource, $tempImageFile);
break;
default:
imagepng($imageResource, $tempImageFile);
}
$images[$coordinates] = $tempImageFile;
}
}
}
} catch (\Exception $e) {
\think\facade\Log::error('提取Excel图片失败' . $e->getMessage());
}
// 读取数据(实际内容从第三行开始,前两行是标题和说明)
$data = $sheet->toArray();
if (count($data) < 3) {
return json(['code' => 400, 'msg' => 'Excel文件数据为空']);
}
// 移除前两行(标题行和说明行)
array_shift($data); // 移除第1行
array_shift($data); // 移除第2行
$successCount = 0;
$failCount = 0;
$errors = [];
Db::startTrans();
try {
foreach ($data as $rowIndex => $row) {
$rowNum = $rowIndex + 3; // Excel行号从3开始因为前两行是标题和说明
// 跳过空行
if (empty(array_filter($row))) {
continue;
}
try {
// 解析数据(根据图片中的表格结构)
// A:日期, B:投放时间, C:作用分类, D:朋友圈文案, E:自回评内容, F:朋友圈展示形式, G-O:配图1-9
$date = isset($row[0]) ? trim($row[0]) : '';
$placementTime = isset($row[1]) ? trim($row[1]) : '';
$functionCategory = isset($row[2]) ? trim($row[2]) : '';
$content = isset($row[3]) ? trim($row[3]) : '';
$selfReply = isset($row[4]) ? trim($row[4]) : '';
$displayForm = isset($row[5]) ? trim($row[5]) : '';
// 如果没有朋友圈文案,跳过
if (empty($content)) {
continue;
}
// 提取配图G-O列索引6-14
$imageUrls = [];
for ($colIndex = 6; $colIndex <= 14; $colIndex++) {
$columnLetter = $this->columnLetter($colIndex);
$cellCoordinate = $columnLetter . $rowNum;
// 检查是否有图片
if (isset($images[$cellCoordinate])) {
$imagePath = $images[$cellCoordinate];
// 上传图片到OSS
$imageExt = 'jpg';
if (file_exists($imagePath)) {
$imageInfo = @getimagesize($imagePath);
if ($imageInfo) {
$imageExt = image_type_to_extension($imageInfo[2], false);
if ($imageExt === 'jpeg') {
$imageExt = 'jpg';
}
}
}
$objectName = AliyunOSS::generateObjectName('excel_img_' . $rowNum . '_' . ($colIndex - 5) . '.' . $imageExt);
$uploadResult = AliyunOSS::uploadFile($imagePath, $objectName);
if ($uploadResult['success']) {
$imageUrls[] = $uploadResult['url'];
}
}
}
// 解析日期和时间
$createMomentTime = 0;
if (!empty($date)) {
// 尝试解析日期格式2025年11月25日 或 2025-11-25
$dateStr = $date;
if (preg_match('/(\d{4})[年\-](\d{1,2})[月\-](\d{1,2})/', $dateStr, $matches)) {
$year = $matches[1];
$month = str_pad($matches[2], 2, '0', STR_PAD_LEFT);
$day = str_pad($matches[3], 2, '0', STR_PAD_LEFT);
// 解析时间
$hour = 0;
$minute = 0;
if (!empty($placementTime) && preg_match('/(\d{1,2}):(\d{2})/', $placementTime, $timeMatches)) {
$hour = intval($timeMatches[1]);
$minute = intval($timeMatches[2]);
}
$createMomentTime = strtotime("{$year}-{$month}-{$day} {$hour}:{$minute}:00");
}
}
if ($createMomentTime == 0) {
$createMomentTime = time();
}
// 判断内容类型
$contentType = 4; // 默认文本
if (!empty($imageUrls)) {
$contentType = 1; // 图文
}
// 创建内容项
$item = new ContentItem();
$item->libraryId = $libraryId;
$item->type = 'diy'; // 自定义类型
$item->title = !empty($date) ? $date . ' ' . $placementTime : '导入的内容';
$item->content = $content;
$item->comment = $selfReply; // 自回评内容
$item->contentType = $contentType;
$item->resUrls = json_encode($imageUrls, JSON_UNESCAPED_UNICODE);
$item->urls = json_encode([], JSON_UNESCAPED_UNICODE);
$item->createMomentTime = $createMomentTime;
$item->createTime = time();
// 设置封面图片
if (!empty($imageUrls[0])) {
$item->coverImage = $imageUrls[0];
}
// 保存其他信息到contentData
$contentData = [
'date' => $date,
'placementTime' => $placementTime,
'functionCategory' => $functionCategory,
'displayForm' => $displayForm,
'selfReply' => $selfReply
];
$item->contentData = json_encode($contentData, JSON_UNESCAPED_UNICODE);
$item->save();
$successCount++;
} catch (\Exception $e) {
$failCount++;
$errors[] = "{$rowNum}行处理失败:" . $e->getMessage();
\think\facade\Log::error('导入Excel第' . $rowNum . '行失败:' . $e->getMessage());
}
}
Db::commit();
// 清理临时图片文件
foreach ($images as $imagePath) {
if (file_exists($imagePath) && strpos($imagePath, sys_get_temp_dir()) === 0) {
@unlink($imagePath);
}
}
// 清理临时Excel文件
if (file_exists($tmpFile) && strpos($tmpFile, sys_get_temp_dir()) === 0) {
@unlink($tmpFile);
}
return json([
'code' => 200,
'msg' => '导入完成',
'data' => [
'success' => $successCount,
'fail' => $failCount,
'errors' => $errors
]
]);
} catch (\Exception $e) {
Db::rollback();
// 清理临时文件
foreach ($images as $imagePath) {
if (file_exists($imagePath) && strpos($imagePath, sys_get_temp_dir()) === 0) {
@unlink($imagePath);
}
}
if (file_exists($tmpFile) && strpos($tmpFile, sys_get_temp_dir()) === 0) {
@unlink($tmpFile);
}
return json(['code' => 500, 'msg' => '导入失败:' . $e->getMessage()]);
}
} catch (\Exception $e) {
return json(['code' => 500, 'msg' => '导入失败:' . $e->getMessage()]);
}
}
/**
* 根据列序号生成Excel列字母
* @param int $index 列索引从0开始
* @return string 列字母如A, B, C, ..., Z, AA, AB等
*/
private function columnLetter($index)
{
$letters = '';
do {
$letters = chr($index % 26 + 65) . $letters;
$index = intval($index / 26) - 1;
} while ($index >= 0);
return $letters;
}
}

View File

@@ -29,7 +29,7 @@ class StatsController extends Controller
$where = [
['departmentId','=',$this->request->userInfo['companyId']]
];
if (!empty($this->request->userInfo['isAdmin'])){
if (empty($this->request->userInfo['isAdmin'])){
$where[] = ['id','=',$this->request->userInfo['s2_accountId']];
}
$accounts = Db::table('s2_company_account')->where($where)->column('id');
@@ -407,7 +407,7 @@ class StatsController extends Controller
$where = [
['departmentId','=',$companyId]
];
if (!empty($this->request->userInfo['isAdmin'])){
if (empty($this->request->userInfo['isAdmin'])){
$where[] = ['id','=',$this->request->userInfo['s2_accountId']];
}
$accounts = Db::table('s2_company_account')->where($where)->column('id');

View File

@@ -4,10 +4,12 @@ namespace app\cunkebao\controller;
use app\common\controller\PaymentService;
use app\common\model\Order;
use app\common\model\User;
use app\cunkebao\model\TokensPackage;
use app\chukebao\model\TokensCompany;
use app\chukebao\model\TokensRecord;
use library\ResponseHelper;
use think\Db;
use think\facade\Env;
class TokensController extends BaseController
@@ -72,7 +74,7 @@ class TokensController extends BaseController
} else {
//获取配置的tokens比例
$tokens_multiple = Env::get('payment.tokens_multiple', 28);
$tokens_multiple = Env::get('payment.tokens_multiple', 20);
$specs = [
'id' => 0,
'name' => '自定义购买算力',
@@ -119,7 +121,7 @@ class TokensController extends BaseController
return ResponseHelper::success($order, '订单已支付');
} else {
$errorMsg = !empty($order['payInfo']) ? $order['payInfo'] : '订单未支付';
return ResponseHelper::success($order,$errorMsg,400);
return ResponseHelper::success($order,$errorMsg);
}
} else {
return ResponseHelper::success($order, '订单已支付');
@@ -140,6 +142,7 @@ class TokensController extends BaseController
$status = $this->request->param('status', ''); // 订单状态筛选
$keyword = $this->request->param('keyword', ''); // 关键词搜索(订单号)
$orderType = $this->request->param('orderType', ''); // 订单类型筛选
$payType = $this->request->param('payType', ''); // 支付类型筛选
$startTime = $this->request->param('startTime', ''); // 开始时间
$endTime = $this->request->param('endTime', ''); // 结束时间
@@ -148,6 +151,7 @@ class TokensController extends BaseController
// 构建查询条件
$where = [
['userId', '=', $userId],
['companyId', '=', $companyId]
];
@@ -166,6 +170,11 @@ class TokensController extends BaseController
$where[] = ['orderType', '=', $orderType];
}
// 支付类型筛选
if($payType !== '') {
$where[] = ['payType', '=', $payType];
}
// 时间范围筛选
if (!empty($startTime)) {
$where[] = ['createTime', '>=', strtotime($startTime)];
@@ -251,13 +260,14 @@ class TokensController extends BaseController
public function getTokensStatistics()
{
try {
$userId = $this->getUserInfo('id');
$companyId = $this->getUserInfo('companyId');
if (empty($companyId)) {
return ResponseHelper::error('公司信息获取失败');
}
// 获取公司算力余额
$tokensCompany = TokensCompany::where('companyId', $companyId)->find();
$tokensCompany = TokensCompany::where(['companyId' => $companyId,'userId' => $userId])->find();
$remainingTokens = $tokensCompany ? intval($tokensCompany->tokens) : 0;
// 获取今日开始和结束时间戳
@@ -270,6 +280,7 @@ class TokensController extends BaseController
// 统计今日消费type=0表示消费
$todayUsed = TokensRecord::where([
['userId', '=', $userId],
['companyId', '=', $companyId],
['type', '=', 0], // 0为减少消费
['createTime', '>=', $todayStart],
@@ -279,6 +290,7 @@ class TokensController extends BaseController
// 统计本月消费
$monthUsed = TokensRecord::where([
['userId', '=', $userId],
['companyId', '=', $companyId],
['type', '=', 0], // 0为减少消费
['createTime', '>=', $monthStart],
@@ -288,6 +300,7 @@ class TokensController extends BaseController
// 计算总算力(当前剩余 + 历史总消费)
$totalConsumed = TokensRecord::where([
['userId', '=', $userId],
['companyId', '=', $companyId],
['type', '=', 0]
])->sum('tokens');
@@ -295,21 +308,228 @@ class TokensController extends BaseController
// 总充值算力
$totalRecharged = TokensRecord::where([
['userId', '=', $userId],
['companyId', '=', $companyId],
['type', '=', 1] // 1为增加充值
])->sum('tokens');
$totalRecharged = intval($totalRecharged);
// 计算预计可用天数(基于过去一个月的平均消耗)
$estimatedDays = $this->calculateEstimatedDays($userId,$companyId, $remainingTokens);
return ResponseHelper::success([
'totalTokens' => $totalRecharged, // 总算力(累计充值)
'todayUsed' => $todayUsed, // 今日使用
'monthUsed' => $monthUsed, // 本月使用
'remainingTokens' => $remainingTokens, // 剩余算力
'totalConsumed' => $totalConsumed, // 累计消费
'estimatedDays' => $estimatedDays, // 预计可用天数
], '获取成功');
} catch (\Exception $e) {
return ResponseHelper::error('获取算力统计失败:' . $e->getMessage());
}
}
/**
* 计算预计可用天数(基于过去一个月的平均消耗)
* @param int $userId 用户ID
* @param int $companyId 公司ID
* @param int $remainingTokens 当前剩余算力
* @return int 预计可用天数,-1表示无法计算无消耗记录或余额为0
*/
private function calculateEstimatedDays($userId,$companyId, $remainingTokens)
{
// 如果余额为0或负数无法计算
if ($remainingTokens <= 0) {
return -1;
}
// 计算过去30天的消耗总量只统计减少的记录type=0
$oneMonthAgo = time() - (30 * 24 * 60 * 60); // 30天前的时间戳
$totalConsumed = TokensRecord::where([
['userId', '=', $userId],
['companyId', '=', $companyId],
['type', '=', 0], // 只统计减少的记录
['createTime', '>=', $oneMonthAgo]
])->sum('tokens');
$totalConsumed = intval($totalConsumed);
// 如果过去30天没有消耗记录无法计算
if ($totalConsumed <= 0) {
return -1;
}
// 计算平均每天消耗量
$avgDailyConsumption = $totalConsumed / 30;
// 如果平均每天消耗为0无法计算
if ($avgDailyConsumption <= 0) {
return -1;
}
// 计算预计可用天数 = 当前余额 / 平均每天消耗量
$estimatedDays = floor($remainingTokens / $avgDailyConsumption);
return $estimatedDays;
}
/**
* 分配token仅管理员可用
* @return \think\response\Json
*/
public function allocateTokens()
{
try {
$userId = $this->getUserInfo('id');
$companyId = $this->getUserInfo('companyId');
$targetUserId = (int)$this->request->param('targetUserId', 0);
$tokens = (int)$this->request->param('tokens', 0);
$remarks = $this->request->param('remarks', '');
// 验证参数
if (empty($targetUserId)) {
return ResponseHelper::error('目标用户ID不能为空');
}
if ($tokens <= 0) {
return ResponseHelper::error('分配的token数量必须大于0');
}
if (empty($companyId)) {
return ResponseHelper::error('公司信息获取失败');
}
// 验证当前用户是否为管理员
$currentUser = User::where([
'id' => $userId,
'companyId' => $companyId
])->find();
if (empty($currentUser)) {
return ResponseHelper::error('用户信息不存在');
}
if (empty($currentUser->isAdmin) || $currentUser->isAdmin != 1) {
return ResponseHelper::error('只有管理员才能分配token');
}
// 验证目标用户是否存在且属于同一公司
$targetUser = User::where([
'id' => $targetUserId,
'companyId' => $companyId
])->find();
if (empty($targetUser)) {
return ResponseHelper::error('目标用户不存在或不属于同一公司');
}
// 检查分配者的token余额
$allocatorTokens = TokensCompany::where([
'companyId' => $companyId,
'userId' => $userId
])->find();
$allocatorBalance = $allocatorTokens ? intval($allocatorTokens->tokens) : 0;
if ($allocatorBalance < $tokens) {
return ResponseHelper::error('token余额不足当前余额' . $allocatorBalance);
}
// 开始事务
Db::startTrans();
try {
// 1. 减少分配者的token
if (!empty($allocatorTokens)) {
$allocatorTokens->tokens = $allocatorBalance - $tokens;
$allocatorTokens->updateTime = time();
$allocatorTokens->save();
$allocatorNewBalance = $allocatorTokens->tokens;
} else {
// 如果分配者没有记录创建一条余额为0
$allocatorTokens = new TokensCompany();
$allocatorTokens->userId = $userId;
$allocatorTokens->companyId = $companyId;
$allocatorTokens->tokens = 0;
$allocatorTokens->isAdmin = 1;
$allocatorTokens->createTime = time();
$allocatorTokens->updateTime = time();
$allocatorTokens->save();
$allocatorNewBalance = 0;
}
// 2. 记录分配者的减少记录
$targetUserAccount = $targetUser->account ?? $targetUser->phone ?? '用户ID[' . $targetUserId . ']';
$allocatorRecord = new TokensRecord();
$allocatorRecord->companyId = $companyId;
$allocatorRecord->userId = $userId;
$allocatorRecord->type = 0; // 0为减少
$allocatorRecord->form = 1001; // 1001表示分配
$allocatorRecord->wechatAccountId = 0;
$allocatorRecord->friendIdOrGroupId = $targetUserId;
$allocatorRecord->remarks = !empty($remarks) ? $remarks : '分配给' . $targetUserAccount;
$allocatorRecord->tokens = $tokens;
$allocatorRecord->balanceTokens = $allocatorNewBalance;
$allocatorRecord->createTime = time();
$allocatorRecord->save();
// 3. 增加接收者的token
$receiverTokens = TokensCompany::where([
'companyId' => $companyId,
'userId' => $targetUserId
])->find();
if (!empty($receiverTokens)) {
$receiverTokens->tokens = intval($receiverTokens->tokens) + $tokens;
$receiverTokens->updateTime = time();
$receiverTokens->save();
$receiverNewBalance = $receiverTokens->tokens;
} else {
// 如果接收者没有记录,创建一条
$receiverTokens = new TokensCompany();
$receiverTokens->userId = $targetUserId;
$receiverTokens->companyId = $companyId;
$receiverTokens->tokens = $tokens;
$receiverTokens->isAdmin = (!empty($targetUser->isAdmin) && $targetUser->isAdmin == 1) ? 1 : 0;
$receiverTokens->createTime = time();
$receiverTokens->updateTime = time();
$receiverTokens->save();
$receiverNewBalance = $tokens;
}
// 4. 记录接收者的增加记录
$adminAccount = $currentUser->account ?? $currentUser->phone ?? '管理员';
$receiverRecord = new TokensRecord();
$receiverRecord->companyId = $companyId;
$receiverRecord->userId = $targetUserId;
$receiverRecord->type = 1; // 1为增加
$receiverRecord->form = 1001; // 1001表示分配
$receiverRecord->wechatAccountId = 0;
$receiverRecord->friendIdOrGroupId = $userId;
$receiverRecord->remarks = !empty($remarks) ? '管理员分配:' . $remarks : '管理员分配';
$receiverRecord->tokens = $tokens;
$receiverRecord->balanceTokens = $receiverNewBalance;
$receiverRecord->createTime = time();
$receiverRecord->save();
Db::commit();
return ResponseHelper::success([
'allocatorBalance' => $allocatorNewBalance,
'receiverBalance' => $receiverNewBalance,
'allocatedTokens' => $tokens
], '分配成功');
} catch (\Exception $e) {
Db::rollback();
return ResponseHelper::error('分配失败:' . $e->getMessage());
}
} catch (\Exception $e) {
return ResponseHelper::error('分配失败:' . $e->getMessage());
}
}
}

View File

@@ -93,7 +93,7 @@ class WorkbenchController extends Controller
$config->endTime = $param['endTime'];
$config->contentTypes = json_encode($param['contentTypes']);
$config->devices = json_encode($param['deviceGroups']);
$config->friends = json_encode($param['friendsGroups']);
$config->friends = json_encode($param['wechatFriends']);
// $config->targetGroups = json_encode($param['targetGroups']);
// $config->tagOperator = $param['tagOperator'];
$config->friendMaxLikes = $param['friendMaxLikes'];
@@ -140,6 +140,7 @@ class WorkbenchController extends Controller
$config->groupDescription = $param['groupDescription'];
$config->poolGroups = json_encode($param['poolGroups'] ?? []);
$config->wechatGroups = json_encode($param['wechatGroups'] ?? []);
$config->admins = json_encode($param['admins'] ?? [], JSON_UNESCAPED_UNICODE);
$config->createTime = time();
$config->updateTime = time();
$config->save();
@@ -229,7 +230,7 @@ class WorkbenchController extends Controller
$query->field('workbenchId,pushType,targetType,groupPushSubType,startTime,endTime,maxPerDay,pushOrder,isLoop,status,groups,friends,ownerWechatIds,trafficPools,contentLibraries,friendIntervalMin,friendIntervalMax,messageIntervalMin,messageIntervalMax,isRandomTemplate,postPushTags,announcementContent,enableAiRewrite,aiRewritePrompt');
},
'groupCreate' => function ($query) {
$query->field('workbenchId,devices,startTime,endTime,groupSizeMin,groupSizeMax,maxGroupsPerDay,groupNameTemplate,groupDescription,poolGroups,wechatGroups');
$query->field('workbenchId,devices,startTime,endTime,groupSizeMin,groupSizeMax,maxGroupsPerDay,groupNameTemplate,groupDescription,poolGroups,wechatGroups,admins');
},
'importContact' => function ($query) {
$query->field('workbenchId,devices,pools,num,remarkType,remark,clearContact,startTime,endTime');
@@ -348,6 +349,18 @@ class WorkbenchController extends Controller
$item->config->devices = json_decode($item->config->devices, true);
$item->config->poolGroups = json_decode($item->config->poolGroups, true);
$item->config->wechatGroups = json_decode($item->config->wechatGroups, true);
$item->config->admins = json_decode($item->config->admins ?? '[]', true) ?: [];
if (!empty($item->config->admins)) {
$adminOptions = Db::table('s2_wechat_friend')->alias('wf')
->join(['s2_wechat_account' => 'wa'], 'wa.id = wf.wechatAccountId', 'left')
->where('wf.id', 'in', $item->config->admins)
->order('wf.id', 'desc')
->field('wf.id,wf.wechatId,wf.nickname as friendName,wf.avatar as friendAvatar,wf.conRemark,wf.ownerWechatId,wa.nickName as accountName,wa.avatar as accountAvatar')
->select();
$item->config->adminsOptions = $adminOptions;
} else {
$item->config->adminsOptions = [];
}
}
unset($item->groupCreate, $item->group_create);
break;
@@ -457,7 +470,7 @@ class WorkbenchController extends Controller
$query->field('workbenchId,pushType,targetType,groupPushSubType,startTime,endTime,maxPerDay,pushOrder,isLoop,status,groups,friends,ownerWechatIds,trafficPools,contentLibraries,friendIntervalMin,friendIntervalMax,messageIntervalMin,messageIntervalMax,isRandomTemplate,postPushTags,announcementContent,enableAiRewrite,aiRewritePrompt');
},
'groupCreate' => function ($query) {
$query->field('workbenchId,devices,startTime,endTime,groupSizeMin,groupSizeMax,maxGroupsPerDay,groupNameTemplate,groupDescription,poolGroups,wechatGroups');
$query->field('workbenchId,devices,startTime,endTime,groupSizeMin,groupSizeMax,maxGroupsPerDay,groupNameTemplate,groupDescription,poolGroups,wechatGroups,admins');
},
'importContact' => function ($query) {
$query->field('workbenchId,devices,pools,num,remarkType,remark,clearContact,startTime,endTime');
@@ -490,7 +503,8 @@ class WorkbenchController extends Controller
if (!empty($workbench->autoLike)) {
$workbench->config = $workbench->autoLike;
$workbench->config->deviceGroups = json_decode($workbench->config->devices, true);
$workbench->config->friendsGroups = json_decode($workbench->config->friends, true);
$workbench->config->wechatFriends = json_decode($workbench->config->friends, true);
$workbench->config->targetType = 2;
//$workbench->config->targetGroups = json_decode($workbench->config->targetGroups, true);
$workbench->config->contentTypes = json_decode($workbench->config->contentTypes, true);
@@ -566,6 +580,7 @@ class WorkbenchController extends Controller
$workbench->config->deviceGroups = json_decode($workbench->config->devices, true);
$workbench->config->poolGroups = json_decode($workbench->config->poolGroups, true);
$workbench->config->wechatGroups = json_decode($workbench->config->wechatGroups, true);
$workbench->config->admins = json_decode($workbench->config->admins ?? '[]', true) ?: [];
unset($workbench->groupCreate, $workbench->group_create);
}
break;
@@ -689,7 +704,7 @@ class WorkbenchController extends Controller
// 获取好友当targetType=2时
if (!empty($workbench->config->wechatFriends) && isset($workbench->config->targetType) && $workbench->config->targetType == 2) {
$friendList = Db::table('s2_wechat_friend')->alias('wf')
->join('s2_wechat_account wa', 'wa.id = wf.wechatAccountId', 'left')
->join(['s2_wechat_account' => 'wa'], 'wa.id = wf.wechatAccountId', 'left')
->where('wf.id', 'in', $workbench->config->wechatFriends)
->order('wf.id', 'desc')
->field('wf.id,wf.wechatId,wf.nickname as friendName,wf.avatar as friendAvatar,wf.conRemark,wf.ownerWechatId,wa.nickName as accountName,wa.avatar as accountAvatar')
@@ -760,6 +775,18 @@ class WorkbenchController extends Controller
$workbench->config->ownerWechatOptions = [];
}
// 获取管理员选项(自动建群)
if ($workbench->type == self::TYPE_GROUP_CREATE && !empty($workbench->config->admins)) {
$adminOptions = Db::table('s2_wechat_friend')->alias('wf')
->join(['s2_wechat_account' => 'wa'], 'wa.id = wf.wechatAccountId', 'left')
->where('wf.id', 'in', $workbench->config->admins)
->order('wf.id', 'desc')
->field('wf.id,wf.wechatId,wf.nickname as friendName,wf.avatar as friendAvatar,wf.conRemark,wf.ownerWechatId,wa.nickName as accountName,wa.avatar as accountAvatar')
->select();
$workbench->config->adminsOptions = $adminOptions;
} else {
$workbench->config->adminsOptions = [];
}
return json(['code' => 200, 'msg' => '获取成功', 'data' => $workbench]);
}
@@ -819,7 +846,7 @@ class WorkbenchController extends Controller
$config->endTime = $param['endTime'];
$config->contentTypes = json_encode($param['contentTypes']);
$config->devices = json_encode($param['deviceGroups']);
$config->friends = json_encode($param['friendsGroups']);
$config->friends = json_encode($param['wechatFriends']);
// $config->targetGroups = json_encode($param['targetGroups']);
// $config->tagOperator = $param['tagOperator'];
$config->friendMaxLikes = $param['friendMaxLikes'];
@@ -881,6 +908,7 @@ class WorkbenchController extends Controller
$config->groupDescription = $param['groupDescription'];
$config->poolGroups = json_encode($param['poolGroups'] ?? []);
$config->wechatGroups = json_encode($param['wechatGroups'] ?? []);
$config->admins = json_encode($param['admins'] ?? [], JSON_UNESCAPED_UNICODE);
$config->updateTime = time();
$config->save();
}
@@ -1108,6 +1136,7 @@ class WorkbenchController extends Controller
$newConfig->groupDescription = $config->groupDescription;
$newConfig->poolGroups = $config->poolGroups;
$newConfig->wechatGroups = $config->wechatGroups;
$newConfig->admins = $config->admins ?? json_encode([], JSON_UNESCAPED_UNICODE);
$newConfig->createTime = time();
$newConfig->updateTime = time();
$newConfig->save();

View File

@@ -13,9 +13,12 @@ class PostTransferFriends extends BaseController
{
$wechatId = $this->request->param('wechatId', '');
$inherit = $this->request->param('inherit', '');
$greeting = $this->request->param('greeting', '');
$firstMessage = $this->request->param('firstMessage', '');
$devices = $this->request->param('devices', []);
$companyId = $this->getUserInfo('companyId');
if (empty($wechatId)){
return ResponseHelper::error('迁移的微信不能为空');
}
@@ -23,13 +26,16 @@ class PostTransferFriends extends BaseController
if (empty($devices)){
return ResponseHelper::error('迁移的设备不能为空');
}
if (empty($greeting)){
return ResponseHelper::error('打招呼不能为空');
}
if (!is_array($devices)){
return ResponseHelper::error('迁移的设备必须为数组');
}
$wechat = Db::name('wechat_customer')->alias('wc')
->join('wechat_account wa', 'wc.wechatId = wa.wechatId')
->where(['wc.wechatId' => $wechatId, 'wc.companyId' => $companyId])
->where(['wc.wechatId' => $wechatId])
->field('wa.*')
->find();
@@ -53,11 +59,6 @@ class PostTransferFriends extends BaseController
'id' => 'poster-3',
'name' => '点击咨询',
'src' => 'https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%92%A8%E8%AF%A2-FTiyAMAPop2g9LvjLOLDz0VwPg3KVu.gif'
],
'$posters' => [
'id' => 'poster-3',
'name' => '点击咨询',
'src' => 'https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%92%A8%E8%AF%A2-FTiyAMAPop2g9LvjLOLDz0VwPg3KVu.gif'
]
];
$reqConf = [
@@ -66,18 +67,38 @@ class PostTransferFriends extends BaseController
'endTime' => '18:00',
'remarkType' => 'phone',
'addFriendInterval' => 60,
'greeting' => '您好,我是'. $wechat['nickname'] .'的辅助客服,请通过'
'greeting' => !empty($greeting) ? $greeting :'我是'. $wechat['nickname'] .'的新号,请通过'
];
if (!empty($firstMessage)){
$msgConf = [
[
'day' => 0,
'messages' => [
[
'id' => 1,
'type' => 'text',
'content' => $firstMessage,
'intervalUnit' => 'seconds',
'sendInterval' => 5,
]
]
]
];
}else{
$msgConf = [];
}
// 使用容器获取控制器实例,而不是直接实例化
$createAddFriendPlan = app('app\cunkebao\controller\plan\PostCreateAddFriendPlanV1Controller');
$taskId = Db::name('customer_acquisition_task')->insertGetId([
'name' => '迁移好友('. $wechat['nickname'] .'',
'sceneId' => 10,
'sceneConf' => json_encode($sceneConf),
'reqConf' => json_encode($reqConf),
'sceneConf' => json_encode($sceneConf,256),
'reqConf' => json_encode($reqConf,256),
'tagConf' => json_encode([]),
'msgConf' => json_encode($msgConf,256),
'userId' => $this->getUserInfo('id'),
'companyId' => $companyId,
'status' => 0,

View File

@@ -0,0 +1,155 @@
<?php
namespace app\job;
use app\api\controller\WebSocketController;
use think\facade\Log;
use think\facade\Env;
use think\Db;
use think\queue\Job;
use think\facade\Cache;
use think\facade\Config;
/**
* 工作台群创建-拉管理员好友任务
* Class WorkbenchGroupCreateAdminFriendJob
* @package app\job
*/
class WorkbenchGroupCreateAdminFriendJob
{
/**
* 最大重试次数
*/
const MAX_RETRY_ATTEMPTS = 3;
/**
* 成员类型常量
*/
const MEMBER_TYPE_ADMIN_FRIEND = 4;
/**
* 状态常量
*/
const STATUS_SUCCESS = 2;
const STATUS_ADMIN_FRIEND_ADDED = 4;
/**
* 队列任务处理
* @param Job $job 队列任务
* @param array $data 任务数据
* @return bool
*/
public function fire(Job $job, $data)
{
$workbenchId = $data['workbenchId'] ?? 0;
$wechatAccountId = $data['wechatAccountId'] ?? 0;
$groupId = $data['groupId'] ?? 0;
$chatroomId = $data['chatroomId'] ?? '';
$adminFriendIds = $data['adminFriendIds'] ?? [];
$poolUsers = $data['poolUsers'] ?? [];
try {
if (empty($adminFriendIds) || empty($poolUsers)) {
Log::info("管理员好友或流量池用户为空跳过。工作台ID: {$workbenchId}");
$job->delete();
return true;
}
// 获取管理员信息
$adminFriends = Db::table('s2_wechat_friend')
->where('id', 'in', $adminFriendIds)
->column('id,wechatId,ownerWechatId');
if (empty($adminFriends)) {
Log::warning("未找到管理员好友信息。工作台ID: {$workbenchId}");
$job->delete();
return true;
}
// 获取微信账号信息
$wechatAccount = Db::table('s2_wechat_account')->where('id', $wechatAccountId)->find();
if (empty($wechatAccount)) {
Log::error("未找到微信账号。微信账号ID: {$wechatAccountId}");
$job->delete();
return false;
}
// 从流量池用户中查找每个管理员的好友
// 管理员的好友从s2_wechat_friend表中查找ownerWechatId=管理员的wechatId且wechatId在流量池用户中
$allAdminFriendIds = [];
foreach ($adminFriends as $adminFriend) {
$adminWechatId = $adminFriend['wechatId'];
// 从好友表中查找该管理员的好友(在流量池用户中)
$adminFriendsList = Db::table('s2_wechat_friend')
->where('ownerWechatId', $adminWechatId)
->whereIn('wechatId', $poolUsers)
->column('id,wechatId');
if (!empty($adminFriendsList)) {
$allAdminFriendIds = array_merge($allAdminFriendIds, array_keys($adminFriendsList));
}
}
$allAdminFriendIds = array_unique($allAdminFriendIds);
if (empty($allAdminFriendIds)) {
Log::info("未找到管理员的好友跳过拉人。工作台ID: {$workbenchId}");
$job->delete();
return true;
}
// 初始化WebSocket
$toAccountId = '';
$username = Env::get('api.username2', '');
$password = Env::get('api.password2', '');
if (!empty($username) || !empty($password)) {
$toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId');
}
$webSocket = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]);
// 拉管理员好友进群
$inviteResult = $webSocket->CmdChatroomInvite([
'wechatChatroomId' => $groupId,
'wechatFriendIds' => $allAdminFriendIds
]);
// 记录管理员好友进群
$installData = [];
foreach ($allAdminFriendIds as $friendId) {
$friendInfo = Db::table('s2_wechat_friend')->where('id', $friendId)->find();
$installData[] = [
'workbenchId' => $workbenchId,
'friendId' => $friendId,
'wechatId' => $friendInfo['wechatId'] ?? '',
'groupId' => $groupId,
'wechatAccountId' => $wechatAccountId,
'status' => self::STATUS_ADMIN_FRIEND_ADDED,
'memberType' => self::MEMBER_TYPE_ADMIN_FRIEND,
'retryCount' => 0,
'chatroomId' => $chatroomId,
'createTime' => time(),
];
}
if (!empty($installData)) {
Db::name('workbench_group_create_item')->insertAll($installData);
Log::info("管理员好友已拉入群。工作台ID: {$workbenchId}, 群ID: {$groupId}, 好友数: " . count($installData));
}
$job->delete();
return true;
} catch (\Exception $e) {
Log::error("拉管理员好友任务异常:{$e->getMessage()}");
if ($job->attempts() > self::MAX_RETRY_ATTEMPTS) {
$job->delete();
} else {
$job->release(Config::get('queue.failed_delay', 10));
}
return false;
}
}
}

View File

@@ -7,6 +7,7 @@ use app\cunkebao\model\Workbench;
use app\cunkebao\model\WorkbenchGroupCreate;
use app\api\model\WechatFriendModel as WechatFriend;
use app\api\model\WechatMomentsModel as WechatMoments;
use app\common\model\DeviceWechatLogin as DeviceWechatLoginModel;
use think\facade\Log;
use think\facade\Env;
use think\Db;
@@ -16,6 +17,7 @@ use think\facade\Config;
use app\api\controller\MomentsController as Moments;
use Workerman\Lib\Timer;
use app\api\controller\WechatController;
use think\Queue;
/**
* 工作台群创建任务
@@ -50,6 +52,23 @@ class WorkbenchGroupCreateJob
}
}
/**
* 成员类型常量
*/
const MEMBER_TYPE_OWNER = 1; // 群主成员
const MEMBER_TYPE_ADMIN = 2; // 管理员
const MEMBER_TYPE_OWNER_FRIEND = 3; // 群主好友
const MEMBER_TYPE_ADMIN_FRIEND = 4; // 管理员好友
/**
* 状态常量
*/
const STATUS_PENDING = 0; // 待创建
const STATUS_CREATING = 1; // 创建中
const STATUS_SUCCESS = 2; // 创建成功
const STATUS_FAILED = 3; // 创建失败
const STATUS_ADMIN_FRIEND_ADDED = 4; // 管理员好友已拉入
/**
* 执行任务
* @throws \Exception
@@ -57,7 +76,7 @@ class WorkbenchGroupCreateJob
public function execute()
{
try {
// 获取所有工作台
// 1. 查询启用了建群功能的数据
$workbenches = Workbench::where(['status' => 1, 'type' => 4, 'isDel' => 0])->order('id desc')->select();
foreach ($workbenches as $workbench) {
// 获取工作台配置
@@ -65,158 +84,356 @@ class WorkbenchGroupCreateJob
if (!$config) {
continue;
}
// 解析配置
$config['poolGroups'] = json_decode($config['poolGroups'], true);
$config['devices'] = json_decode($config['devices'], true);
$config['admins'] = json_decode($config['admins'] ?? '[]', true) ?: [];
if (empty($config['poolGroups']) || empty($config['devices'])) {
continue;
}
//群主及内部成员
$groupMember = Db::name('device_wechat_login')->alias('dwl')
->join(['s2_wechat_account' => 'a'], 'dwl.wechatId = a.wechatId')
->whereIn('dwl.deviceId', $config['devices'])
->group('a.id')
->column('a.wechatId');
if (empty($groupMember)) {
$groupMember = [];
$wechatId = Db::name('device_wechat_login')
->whereIn('deviceId',$config['devices'])
->order('id desc')
->value('wechatId');
if (empty($wechatId)) {
continue;
}
$groupMemberWechatId = Db::table('s2_wechat_friend')
->where('ownerWechatId', $groupMember[0])
->whereIn('wechatId', $groupMember)
->column('id,wechatId');
$groupMember[] = $wechatId;
// 获取群主好友ID映射所有群主的好友
$groupMemberWechatId = [];
$groupMemberId = [];
foreach ($groupMember as $ownerWechatId) {
$friends = Db::table('s2_wechat_friend')
->where('ownerWechatId', $ownerWechatId)
->whereIn('wechatId', $groupMember)
->field('id,wechatId')
->select();
foreach ($friends as $friend) {
if (!isset($groupMemberWechatId[$friend['id']])) {
$groupMemberWechatId[$friend['id']] = $friend['wechatId'];
$groupMemberId[] = $friend['id'];
}
}
}
if (empty($groupMemberWechatId)) {
continue;
}
$groupMemberId = array_keys($groupMemberWechatId);
//流量池用户
// 获取流量池用户
$poolItem = Db::name('traffic_source_package_item')
->whereIn('packageId', $config['poolGroups'])
->group('identifier')
->column('identifier');
if (empty($poolItem)) {
continue;
}
//群用户
// 获取已入群的用户(排除已成功入群的)
$groupUser = Db::name('workbench_group_create_item')
->where('workbenchId', $workbench->id)
->where('status', 'in', [self::STATUS_SUCCESS, self::STATUS_ADMIN_FRIEND_ADDED])
->whereIn('wechatId', $poolItem)
->group('wechatId')
->column('wechatId');
//待入群的用户
// 待入群的用户
$joinUser = array_diff($poolItem, $groupUser);
if (empty($joinUser)) {
continue;
}
//随机群人数
// 计算随机群人数(不包含管理员,只减去群主成员数)
$groupRandNum = mt_rand($config['groupSizeMin'], $config['groupSizeMax']) - count($groupMember);
//待加入用户
// 分批处理待入群用户
$addGroupUser = [];
$totalRows = count($joinUser);
for ($i = 0; $i < $totalRows; $i += $groupRandNum) {
$batchRows = array_slice($joinUser, $i, $groupRandNum);
if (!empty($batchRows)) {
$user = [];
foreach ($batchRows as $row) {
$user[] = $row;
}
$addGroupUser[] = $user;
$addGroupUser[] = $batchRows;
}
}
foreach ($addGroupUser as $key => $val) {
//判断第一组用户是否满足创建群的条件
$friendIds = Db::name('wechat_friendship')->alias('f')
->join(['s2_wechat_account' => 'a'], 'f.ownerWechatId=a.wechatId')
->where('f.companyId', $workbench->companyId)
->whereIn('f.wechatId', $val)
->group('f.wechatId')
->column('f.id,f.wechatId,a.id as wechatAccountId');
// 初始化WebSocket
$toAccountId = '';
$username = Env::get('api.username2', '');
$password = Env::get('api.password2', '');
if (!empty($username) || !empty($password)) {
$toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId');
}
$webSocket = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]);
// 整理数组按wechatAccountId分组值为对应的id数组
$groupedFriends = [];
$wechatAccountIds = [];
$wechatIds = [];
foreach ($friendIds as $friend) {
$wechatAccountId = $friend['wechatAccountId'];
if (!in_array($wechatAccountId, $wechatAccountIds)) {
$wechatAccountIds[] = $wechatAccountId;
}
$friendId = $friend['id'];
if (!isset($groupedFriends[$wechatAccountId])) {
$groupedFriends[$wechatAccountId] = [];
}
$groupedFriends[$wechatAccountId][] = $friendId;
$wechatIds[$friendId] = $friend['wechatId'];
}
//==================== 群相关功能开始 ===========================
$toAccountId = '';
$username = Env::get('api.username2', '');
$password = Env::get('api.password2', '');
if (!empty($username) || !empty($password)) {
$toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId');
}
$webSocket = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]);
//$webSocket = new WebSocketController(['userName' => 'wz_03', 'password' => 'key123456', 'accountId' => 5015]);
//拉人进群 $webSocket->CmdChatroomInvite(['wechatChatroomId' => 830794, 'wechatFriendIds' => [21168549]]);
//修改群名称 $webSocket->CmdChatroomModifyInfo(['wechatChatroomId' => 830794, 'wechatAccountId' => 300745,'chatroomName' => 'test111']);
//修改群公告 $webSocket->CmdChatroomModifyInfo(['wechatChatroomId' => 830794, 'wechatAccountId' => 300745,'announce' => 'test111']);
//建群 $webSocket->CmdChatroomCreate(['chatroomName' => '聊天测试群', 'wechatFriendIds' => [17453051,17453058],'wechatAccountId' => 300745]);
foreach ($groupedFriends as $wechatAccountId => $friendId) {
//列出所有群
$group = '';
$groupMemberNum = 0;
$groupIds = Db::name('workbench_group_create_item')->where(['workbenchId' => $workbench->id])->group('groupId')->column('groupId');
if (!empty($groupIds)) {
//最新创建的群
$group = Db::name('wechat_group')->where(['wechatAccountId' => $wechatAccountId])->whereIn('id', $groupIds)->order('createTime DESC')->find();
//群用户数量
if (!empty($group)) {
$groupMemberNum = Db::name('wechat_group_member')->where('groupId', $group['id'])->count();
}
}
//拉群或者建群
$wechatFriendIds = array_merge($friendId, $groupMemberId);
if ($groupMemberNum == 0 || (count($wechatFriendIds) + $groupMemberNum) >= $groupRandNum) {
if (count($groupIds) > 0) {
$chatroomName = $config['groupNameTemplate'] . count($groupIds) + 1 . '群';
} else {
$chatroomName = $config['groupNameTemplate'];
}
$webSocket->CmdChatroomCreate(['chatroomName' => $chatroomName, 'wechatFriendIds' => $wechatFriendIds,'wechatAccountId' => $wechatAccountId]);
} else {
$webSocket->CmdChatroomInvite(['wechatChatroomId' => $group['id'], 'wechatFriendIds' => $wechatFriendIds]);
}
$installData = [];
//记录进群人员
foreach ($wechatFriendIds as $v) {
$installData[] = [
'workbenchId' => $workbench->id,
'friendId' => $v,
'wechatId' => !empty($wechatIds[$v]) ? $wechatIds[$v] : $groupMemberWechatId[$v],
'groupId' => 0,
'wechatAccountId' => $wechatAccountId,
'createTime' => time(),
];
}
Db::name('workbench_group_create_item')->insertAll($installData);
}
// 遍历每批用户
foreach ($addGroupUser as $batchUsers) {
$this->processBatchUsers($workbench, $config, $batchUsers, $groupMemberId, $groupMemberWechatId, $groupRandNum, $webSocket);
}
}
} catch (\Exception $e) {
Log::error("消息群发任务异常: " . $e->getMessage());
Log::error("工作台建群任务异常: " . $e->getMessage());
throw $e;
}
}
/**
* 处理一批用户
* @param Workbench $workbench 工作台
* @param array $config 配置
* @param array $batchUsers 批次用户微信ID数组来自流量池
* @param array $groupMemberId 群主成员ID数组
* @param array $groupMemberWechatId 群主成员微信ID映射
* @param int $groupRandNum 随机群人数(不包含管理员)
* @param WebSocketController $webSocket WebSocket实例
*/
protected function processBatchUsers($workbench, $config, $batchUsers, $groupMemberId, $groupMemberWechatId, $groupRandNum, $webSocket)
{
// 1. 获取群主微信ID列表用于验证管理员
// 从群主成员的好友记录中提取所有群主的微信IDownerWechatId
$groupOwnerWechatIds = [];
foreach ($groupMemberId as $memberId) {
$member = Db::table('s2_wechat_friend')->where('id', $memberId)->find();
if ($member && !in_array($member['ownerWechatId'], $groupOwnerWechatIds)) {
$groupOwnerWechatIds[] = $member['ownerWechatId'];
}
}
// 如果从好友表获取不到使用群主成员微信ID列表作为备用
if (empty($groupOwnerWechatIds)) {
$groupOwnerWechatIds = array_values(array_unique($groupMemberWechatId));
}
// 2. 验证并获取管理员好友ID管理员必须是群主的好友
$adminFriendIds = [];
$adminWechatIds = [];
if (!empty($config['admins'])) {
$adminFriends = Db::table('s2_wechat_friend')
->where('id', 'in', $config['admins'])
->field('id,wechatId,ownerWechatId')
->select();
foreach ($adminFriends as $adminFriend) {
// 验证:管理员必须是群主的好友
if (in_array($adminFriend['ownerWechatId'], $groupOwnerWechatIds)) {
$adminFriendIds[] = $adminFriend['id'];
$adminWechatIds[$adminFriend['id']] = $adminFriend['wechatId'];
}
}
}
exit_data($adminWechatIds);
// 3. 从流量池用户中筛选出是群主好友的用户(按微信账号分组)
$ownerFriendIdsByAccount = [];
$wechatIds = [];
// 获取群主的好友关系(从流量池中筛选)
$ownerFriends = Db::name('wechat_friendship')->alias('f')
->join(['s2_wechat_account' => 'a'], 'f.ownerWechatId=a.wechatId')
->where('f.companyId', $workbench->companyId)
->whereIn('f.wechatId', $batchUsers)
->whereIn('f.ownerWechatId', $groupOwnerWechatIds)
->field('f.id,f.wechatId,a.id as wechatAccountId')
->select();
if (empty($ownerFriends)) {
Log::warning("未找到群主的好友跳过。工作台ID: {$workbench->id}");
return;
}
// 按微信账号分组群主好友
foreach ($ownerFriends as $friend) {
$wechatAccountId = $friend['wechatAccountId'];
if (!isset($ownerFriendIdsByAccount[$wechatAccountId])) {
$ownerFriendIdsByAccount[$wechatAccountId] = [];
}
$ownerFriendIdsByAccount[$wechatAccountId][] = $friend['id'];
$wechatIds[$friend['id']] = $friend['wechatId'];
}
// 4. 遍历每个微信账号,创建群
foreach ($ownerFriendIdsByAccount as $wechatAccountId => $ownerFriendIds) {
// 4.1 获取当前账号的管理员好友ID
$currentAdminFriendIds = [];
$accountWechatId = Db::table('s2_wechat_account')->where('id', $wechatAccountId)->value('wechatId');
foreach ($adminFriendIds as $adminFriendId) {
$adminFriend = Db::table('s2_wechat_friend')->where('id', $adminFriendId)->find();
if ($adminFriend && $adminFriend['ownerWechatId'] == $accountWechatId) {
$currentAdminFriendIds[] = $adminFriendId;
$wechatIds[$adminFriendId] = $adminWechatIds[$adminFriendId];
}
}
// 4.2 获取当前账号的群主成员ID
$currentGroupMemberIds = [];
foreach ($groupMemberId as $memberId) {
$member = Db::table('s2_wechat_friend')->where('id', $memberId)->find();
if ($member && $member['ownerWechatId'] == $accountWechatId) {
$currentGroupMemberIds[] = $memberId;
if (!isset($wechatIds[$memberId])) {
$wechatIds[$memberId] = $groupMemberWechatId[$memberId] ?? '';
}
}
}
// 4.3 限制群主好友数量(按随机群人数)
$limitedOwnerFriendIds = array_slice($ownerFriendIds, 0, $groupRandNum);
// 4.4 创建群:管理员 + 群主成员 + 群主好友(从流量池筛选)
$createFriendIds = array_merge($currentAdminFriendIds, $currentGroupMemberIds, $limitedOwnerFriendIds);
if (count($createFriendIds) < 2) {
Log::warning("建群好友数量不足跳过。工作台ID: {$workbench->id}, 微信账号ID: {$wechatAccountId}");
continue;
}
// 4.5 生成群名称
$existingGroupCount = Db::name('workbench_group_create_item')
->where('workbenchId', $workbench->id)
->where('wechatAccountId', $wechatAccountId)
->where('status', self::STATUS_SUCCESS)
->group('groupId')
->count();
$chatroomName = $existingGroupCount > 0
? $config['groupNameTemplate'] . ($existingGroupCount + 1) . '群'
: $config['groupNameTemplate'];
// 4.6 调用建群接口
$createTime = time();
$createResult = $webSocket->CmdChatroomCreate([
'chatroomName' => $chatroomName,
'wechatFriendIds' => $createFriendIds,
'wechatAccountId' => $wechatAccountId
]);
$createResultData = json_decode($createResult, true);
// 4.7 解析建群结果获取群ID
$chatroomId = 0;
if (!empty($createResultData) && isset($createResultData['code']) && $createResultData['code'] == 200) {
// 尝试从返回数据中获取群ID根据实际API返回格式调整
if (isset($createResultData['data']['chatroomId'])) {
$chatroomId = $createResultData['data']['chatroomId'];
} elseif (isset($createResultData['data']['id'])) {
$chatroomId = $createResultData['data']['id'];
}
}
// 4.8 记录创建请求
$installData = [];
foreach ($createFriendIds as $friendId) {
$memberType = in_array($friendId, $currentAdminFriendIds)
? self::MEMBER_TYPE_ADMIN
: (in_array($friendId, $currentGroupMemberIds) ? self::MEMBER_TYPE_OWNER : self::MEMBER_TYPE_OWNER_FRIEND);
$installData[] = [
'workbenchId' => $workbench->id,
'friendId' => $friendId,
'wechatId' => $wechatIds[$friendId] ?? ($groupMemberWechatId[$friendId] ?? ''),
'groupId' => $chatroomId,
'wechatAccountId' => $wechatAccountId,
'status' => $chatroomId > 0 ? self::STATUS_SUCCESS : self::STATUS_CREATING,
'memberType' => $memberType,
'retryCount' => 0,
'chatroomId' => $chatroomId > 0 ? $chatroomId : null,
'createTime' => $createTime,
];
}
Db::name('workbench_group_create_item')->insertAll($installData);
// 5. 如果群创建成功,拉管理员的好友进群
if ($chatroomId > 0 && !empty($currentAdminFriendIds)) {
$this->inviteAdminFriends($workbench, $config, $batchUsers, $currentAdminFriendIds, $chatroomId, $wechatAccountId, $wechatIds, $createTime, $webSocket);
}
}
}
/**
* 拉管理员的好友进群
* @param Workbench $workbench 工作台
* @param array $config 配置
* @param array $batchUsers 批次用户流量池微信ID数组
* @param array $adminFriendIds 管理员好友ID数组
* @param int $chatroomId 群ID
* @param int $wechatAccountId 微信账号ID
* @param array $wechatIds 好友ID到微信ID的映射
* @param int $createTime 创建时间
* @param WebSocketController $webSocket WebSocket实例
*/
protected function inviteAdminFriends($workbench, $config, $batchUsers, $adminFriendIds, $chatroomId, $wechatAccountId, $wechatIds, $createTime, $webSocket)
{
// 获取管理员的微信ID列表
$adminWechatIds = [];
foreach ($adminFriendIds as $adminFriendId) {
if (isset($wechatIds[$adminFriendId])) {
$adminWechatIds[] = $wechatIds[$adminFriendId];
}
}
if (empty($adminWechatIds)) {
return;
}
// 从流量池用户中筛选出是管理员好友的用户
$adminFriendsFromPool = Db::name('wechat_friendship')->alias('f')
->join(['s2_wechat_account' => 'a'], 'f.ownerWechatId=a.wechatId')
->where('f.companyId', $workbench->companyId)
->whereIn('f.wechatId', $batchUsers)
->whereIn('f.ownerWechatId', $adminWechatIds)
->where('a.id', $wechatAccountId)
->field('f.id,f.wechatId')
->select();
if (empty($adminFriendsFromPool)) {
Log::info("未找到管理员的好友跳过拉人。工作台ID: {$workbench->id}, 群ID: {$chatroomId}");
return;
}
// 提取好友ID列表
$adminFriendIdsToInvite = [];
foreach ($adminFriendsFromPool as $friend) {
$adminFriendIdsToInvite[] = $friend['id'];
$wechatIds[$friend['id']] = $friend['wechatId'];
}
// 调用拉人接口
$inviteResult = $webSocket->CmdChatroomInvite([
'wechatChatroomId' => $chatroomId,
'wechatFriendIds' => $adminFriendIdsToInvite
]);
$inviteResultData = json_decode($inviteResult, true);
$inviteSuccess = !empty($inviteResultData) && isset($inviteResultData['code']) && $inviteResultData['code'] == 200;
// 记录管理员好友拉入状态
$adminFriendData = [];
foreach ($adminFriendIdsToInvite as $friendId) {
$adminFriendData[] = [
'workbenchId' => $workbench->id,
'friendId' => $friendId,
'wechatId' => $wechatIds[$friendId] ?? '',
'groupId' => $chatroomId,
'wechatAccountId' => $wechatAccountId,
'status' => $inviteSuccess ? self::STATUS_ADMIN_FRIEND_ADDED : self::STATUS_FAILED,
'memberType' => self::MEMBER_TYPE_ADMIN_FRIEND,
'retryCount' => 0,
'chatroomId' => $chatroomId,
'createTime' => $createTime,
];
}
Db::name('workbench_group_create_item')->insertAll($adminFriendData);
if ($inviteSuccess) {
Log::info("管理员好友拉入成功。工作台ID: {$workbench->id}, 群ID: {$chatroomId}, 拉入数量: " . count($adminFriendIdsToInvite));
} else {
Log::warning("管理员好友拉入失败。工作台ID: {$workbench->id}, 群ID: {$chatroomId}");
}
}
/**
* 获取设备列表

View File

@@ -0,0 +1,109 @@
<?php
namespace app\job;
use app\api\controller\WebSocketController;
use think\facade\Log;
use think\facade\Env;
use think\Db;
use think\queue\Job;
use think\facade\Cache;
use think\facade\Config;
/**
* 工作台群创建-拉群主好友任务
* Class WorkbenchGroupCreateOwnerFriendJob
* @package app\job
*/
class WorkbenchGroupCreateOwnerFriendJob
{
/**
* 最大重试次数
*/
const MAX_RETRY_ATTEMPTS = 3;
/**
* 成员类型常量
*/
const MEMBER_TYPE_OWNER_FRIEND = 3;
/**
* 状态常量
*/
const STATUS_SUCCESS = 2;
/**
* 队列任务处理
* @param Job $job 队列任务
* @param array $data 任务数据
* @return bool
*/
public function fire(Job $job, $data)
{
$workbenchId = $data['workbenchId'] ?? 0;
$wechatAccountId = $data['wechatAccountId'] ?? 0;
$groupId = $data['groupId'] ?? 0;
$chatroomId = $data['chatroomId'] ?? '';
$ownerFriendIds = $data['ownerFriendIds'] ?? [];
$createTime = $data['createTime'] ?? 0;
try {
if (empty($ownerFriendIds) || empty($groupId)) {
Log::info("群主好友或群ID为空跳过。工作台ID: {$workbenchId}");
$job->delete();
return true;
}
// 初始化WebSocket
$toAccountId = '';
$username = Env::get('api.username2', '');
$password = Env::get('api.password2', '');
if (!empty($username) || !empty($password)) {
$toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId');
}
$webSocket = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]);
// 拉群主好友进群
$inviteResult = $webSocket->CmdChatroomInvite([
'wechatChatroomId' => $groupId,
'wechatFriendIds' => $ownerFriendIds
]);
// 获取好友微信ID映射
$friendWechatIds = Db::table('s2_wechat_friend')
->where('id', 'in', $ownerFriendIds)
->column('id,wechatId');
// 更新群主好友记录状态
Db::name('workbench_group_create_item')
->where('workbenchId', $workbenchId)
->where('wechatAccountId', $wechatAccountId)
->where('status', 1) // 创建中
->where('memberType', self::MEMBER_TYPE_OWNER_FRIEND)
->where('createTime', '>=', $createTime - 10)
->where('createTime', '<=', $createTime + 10)
->update([
'status' => self::STATUS_SUCCESS,
'groupId' => $groupId,
'chatroomId' => $chatroomId,
'verifyTime' => time()
]);
Log::info("群主好友已拉入群。工作台ID: {$workbenchId}, 群ID: {$groupId}, 好友数: " . count($ownerFriendIds));
$job->delete();
return true;
} catch (\Exception $e) {
Log::error("拉群主好友任务异常:{$e->getMessage()}");
if ($job->attempts() > self::MAX_RETRY_ATTEMPTS) {
$job->delete();
} else {
$job->release(Config::get('queue.failed_delay', 10));
}
return false;
}
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace app\job;
use app\api\controller\WebSocketController;
use app\cunkebao\model\Workbench;
use app\cunkebao\model\WorkbenchGroupCreate;
use think\facade\Log;
use think\facade\Env;
use think\Db;
use think\queue\Job;
use think\facade\Cache;
use think\facade\Config;
use think\Queue;
/**
* 工作台群创建重试任务
* Class WorkbenchGroupCreateRetryJob
* @package app\job
*/
class WorkbenchGroupCreateRetryJob
{
/**
* 最大重试次数
*/
const MAX_RETRY_ATTEMPTS = 3;
/**
* 队列任务处理
* @param Job $job 队列任务
* @param array $data 任务数据
* @return bool
*/
public function fire(Job $job, $data)
{
$workbenchId = $data['workbenchId'] ?? 0;
$wechatAccountId = $data['wechatAccountId'] ?? 0;
$createTime = $data['createTime'] ?? 0;
try {
// 获取工作台和配置
$workbench = Workbench::where('id', $workbenchId)->find();
if (!$workbench) {
Log::error("未找到工作台。工作台ID: {$workbenchId}");
$job->delete();
return false;
}
$config = WorkbenchGroupCreate::where('workbenchId', $workbench->id)->find();
if (!$config) {
Log::error("未找到工作台配置。工作台ID: {$workbenchId}");
$job->delete();
return false;
}
// 获取失败记录
$failedItems = Db::name('workbench_group_create_item')
->where('workbenchId', $workbenchId)
->where('wechatAccountId', $wechatAccountId)
->where('createTime', '>=', $createTime - 10)
->where('createTime', '<=', $createTime + 10)
->where('status', 'in', [1, 3]) // 创建中或失败
->select();
if (empty($failedItems)) {
Log::info("未找到需要重试的记录。工作台ID: {$workbenchId}");
$job->delete();
return true;
}
// 解析配置
$config['poolGroups'] = json_decode($config['poolGroups'], true);
$config['devices'] = json_decode($config['devices'], true);
$config['admins'] = json_decode($config['admins'] ?? '[]', true) ?: [];
// 获取群主成员
$groupMember = Db::name('device_wechat_login')->alias('dwl')
->join(['s2_wechat_account' => 'a'], 'dwl.wechatId = a.wechatId')
->whereIn('dwl.deviceId', $config['devices'])
->group('a.id')
->column('a.wechatId');
$groupMemberWechatId = Db::table('s2_wechat_friend')
->where('ownerWechatId', $groupMember[0])
->whereIn('wechatId', $groupMember)
->column('id,wechatId');
$groupMemberId = array_keys($groupMemberWechatId);
// 获取管理员好友ID
$adminFriendIds = [];
if (!empty($config['admins'])) {
$adminFriends = Db::table('s2_wechat_friend')
->where('id', 'in', $config['admins'])
->column('id,wechatId,ownerWechatId');
$accountWechatId = Db::table('s2_wechat_account')->where('id', $wechatAccountId)->value('wechatId');
foreach ($adminFriends as $adminFriend) {
if ($adminFriend['ownerWechatId'] == $accountWechatId) {
$adminFriendIds[] = $adminFriend['id'];
}
}
}
// 初始化WebSocket
$toAccountId = '';
$username = Env::get('api.username2', '');
$password = Env::get('api.password2', '');
if (!empty($username) || !empty($password)) {
$toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId');
}
$webSocket = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]);
// 重新创建群
$createFriendIds = array_merge($adminFriendIds, $groupMemberId);
if (count($createFriendIds) < 2) {
Log::error("重试建群好友数量不足。工作台ID: {$workbenchId}");
$job->delete();
return false;
}
// 生成群名称
$existingGroupCount = Db::name('workbench_group_create_item')
->where('workbenchId', $workbenchId)
->where('wechatAccountId', $wechatAccountId)
->where('status', 2) // 成功
->group('groupId')
->count();
$chatroomName = $existingGroupCount > 0
? $config['groupNameTemplate'] . ($existingGroupCount + 1) . '群'
: $config['groupNameTemplate'];
// 调用建群接口
$createResult = $webSocket->CmdChatroomCreate([
'chatroomName' => $chatroomName,
'wechatFriendIds' => $createFriendIds,
'wechatAccountId' => $wechatAccountId
]);
// 更新记录状态为创建中
Db::name('workbench_group_create_item')
->where('workbenchId', $workbenchId)
->where('wechatAccountId', $wechatAccountId)
->where('createTime', '>=', $createTime - 10)
->where('createTime', '<=', $createTime + 10)
->update([
'status' => 1, // 创建中
'createTime' => time() // 更新创建时间
]);
// 创建新的轮询验证任务
Queue::later(5, 'app\job\WorkbenchGroupCreateVerifyJob', [
'workbenchId' => $workbenchId,
'wechatAccountId' => $wechatAccountId,
'createTime' => time(),
'adminFriendIds' => $adminFriendIds,
'poolUsers' => [], // 重试时暂时不传poolUsers后续可以优化
], 'default');
Log::info("重试建群任务已创建。工作台ID: {$workbenchId}, 微信账号ID: {$wechatAccountId}");
$job->delete();
return true;
} catch (\Exception $e) {
Log::error("重试建群任务异常:{$e->getMessage()}");
if ($job->attempts() > self::MAX_RETRY_ATTEMPTS) {
$job->delete();
} else {
$job->release(Config::get('queue.failed_delay', 10));
}
return false;
}
}
}

View File

@@ -0,0 +1,248 @@
<?php
namespace app\job;
use app\api\controller\WechatChatroomController;
use think\facade\Log;
use think\Db;
use think\queue\Job;
use think\facade\Cache;
use think\facade\Config;
use think\Queue;
/**
* 工作台群创建验证任务(轮询群创建状态)
* Class WorkbenchGroupCreateVerifyJob
* @package app\job
*/
class WorkbenchGroupCreateVerifyJob
{
/**
* 最大重试次数
*/
const MAX_RETRY_ATTEMPTS = 15; // 最多轮询15次
/**
* 轮询间隔(秒)
*/
const POLL_INTERVAL = 5;
/**
* 状态常量
*/
const STATUS_CREATING = 1;
const STATUS_SUCCESS = 2;
const STATUS_FAILED = 3;
/**
* 队列任务处理
* @param Job $job 队列任务
* @param array $data 任务数据
* @return bool
*/
public function fire(Job $job, $data)
{
$workbenchId = $data['workbenchId'] ?? 0;
$wechatAccountId = $data['wechatAccountId'] ?? 0;
$createTime = $data['createTime'] ?? 0;
$adminFriendIds = $data['adminFriendIds'] ?? [];
$poolUsers = $data['poolUsers'] ?? [];
try {
$attempts = $job->attempts();
// 查询待验证的群记录
$groupItems = Db::name('workbench_group_create_item')
->where('workbenchId', $workbenchId)
->where('wechatAccountId', $wechatAccountId)
->where('status', self::STATUS_CREATING)
->where('createTime', '>=', $createTime - 10) // 允许10秒误差
->where('createTime', '<=', $createTime + 10)
->group('wechatAccountId')
->select();
if (empty($groupItems)) {
Log::info("未找到待验证的群记录任务完成。工作台ID: {$workbenchId}, 微信账号ID: {$wechatAccountId}");
$job->delete();
return true;
}
// 获取微信账号信息
$wechatAccount = Db::table('s2_wechat_account')->where('id', $wechatAccountId)->find();
if (empty($wechatAccount)) {
Log::error("未找到微信账号任务失败。微信账号ID: {$wechatAccountId}");
$job->delete();
return false;
}
// 调用接口查询群聊列表
$chatroomController = new WechatChatroomController();
$chatroomList = $chatroomController->getlist([
'wechatAccountKeyword' => $wechatAccount['wechatId'],
'pageIndex' => 0,
'pageSize' => 100
], true);
$chatroomListData = json_decode($chatroomList, true);
if (empty($chatroomListData['data']['results'])) {
// 如果超过最大重试次数,标记为失败并重试创建
if ($attempts >= self::MAX_RETRY_ATTEMPTS) {
$this->handleCreateFailed($workbenchId, $wechatAccountId, $createTime, $job);
return false;
}
// 继续轮询
$job->release(self::POLL_INTERVAL);
return false;
}
// 查找符合条件的群chatroomOwnerAvatar和chatroomOwnerNickname不为空
$successGroup = null;
foreach ($chatroomListData['data']['results'] as $chatroom) {
if (!empty($chatroom['chatroomOwnerAvatar']) && !empty($chatroom['chatroomOwnerNickname'])) {
// 检查创建时间是否匹配允许30秒误差
$chatroomCreateTime = isset($chatroom['createTime']) ? strtotime($chatroom['createTime']) : 0;
if (abs($chatroomCreateTime - $createTime) <= 30) {
$successGroup = $chatroom;
break;
}
}
}
if ($successGroup) {
// 群创建成功,更新记录状态
$groupId = $successGroup['id'] ?? 0;
$chatroomId = $successGroup['chatroomId'] ?? '';
// 更新管理员和群主成员的记录状态
Db::name('workbench_group_create_item')
->where('workbenchId', $workbenchId)
->where('wechatAccountId', $wechatAccountId)
->where('status', self::STATUS_CREATING)
->where('memberType', 'in', [1, 2]) // 群主成员和管理员
->where('createTime', '>=', $createTime - 10)
->where('createTime', '<=', $createTime + 10)
->update([
'status' => self::STATUS_SUCCESS,
'groupId' => $groupId,
'chatroomId' => $chatroomId,
'verifyTime' => time()
]);
Log::info("群创建成功工作台ID: {$workbenchId}, 微信账号ID: {$wechatAccountId}, 群ID: {$groupId}");
// 3. 拉群主好友进群(在验证成功后执行)
$ownerFriendIds = $data['ownerFriendIds'] ?? [];
if (!empty($ownerFriendIds)) {
Queue::push('app\job\WorkbenchGroupCreateOwnerFriendJob', [
'workbenchId' => $workbenchId,
'wechatAccountId' => $wechatAccountId,
'groupId' => $groupId,
'chatroomId' => $chatroomId,
'ownerFriendIds' => $ownerFriendIds,
'createTime' => $createTime
], 'default');
}
// 5. 创建拉管理员好友的任务(在群主好友拉入后执行)
if (!empty($adminFriendIds) && !empty($poolUsers)) {
Queue::push('app\job\WorkbenchGroupCreateAdminFriendJob', [
'workbenchId' => $workbenchId,
'wechatAccountId' => $wechatAccountId,
'groupId' => $groupId,
'chatroomId' => $chatroomId,
'adminFriendIds' => $adminFriendIds,
'poolUsers' => $poolUsers
], 'default');
}
$job->delete();
return true;
} else {
// 如果超过最大重试次数,标记为失败并重试创建
if ($attempts >= self::MAX_RETRY_ATTEMPTS) {
$this->handleCreateFailed($workbenchId, $wechatAccountId, $createTime, $job);
return false;
}
// 继续轮询
$job->release(self::POLL_INTERVAL);
return false;
}
} catch (\Exception $e) {
Log::error("群创建验证任务异常:{$e->getMessage()}");
if ($job->attempts() >= self::MAX_RETRY_ATTEMPTS) {
$job->delete();
} else {
$job->release(self::POLL_INTERVAL);
}
return false;
}
}
/**
* 处理创建失败的情况(重试创建)
* @param int $workbenchId 工作台ID
* @param int $wechatAccountId 微信账号ID
* @param int $createTime 创建时间
* @param Job $job 队列任务
*/
protected function handleCreateFailed($workbenchId, $wechatAccountId, $createTime, $job)
{
// 更新状态为失败
Db::name('workbench_group_create_item')
->where('workbenchId', $workbenchId)
->where('wechatAccountId', $wechatAccountId)
->where('status', self::STATUS_CREATING)
->where('createTime', '>=', $createTime - 10)
->where('createTime', '<=', $createTime + 10)
->update([
'status' => self::STATUS_FAILED,
'verifyTime' => time()
]);
Log::warning("群创建失败准备重试。工作台ID: {$workbenchId}, 微信账号ID: {$wechatAccountId}");
// 检查重试次数
$failedItems = Db::name('workbench_group_create_item')
->where('workbenchId', $workbenchId)
->where('wechatAccountId', $wechatAccountId)
->where('createTime', '>=', $createTime - 10)
->where('createTime', '<=', $createTime + 10)
->select();
$maxRetryCount = 0;
foreach ($failedItems as $item) {
if ($item['retryCount'] >= 3) {
Log::error("群创建重试次数已达上限放弃重试。工作台ID: {$workbenchId}, 微信账号ID: {$wechatAccountId}");
$job->delete();
return;
}
$maxRetryCount = max($maxRetryCount, $item['retryCount']);
}
// 增加重试次数并重置状态
Db::name('workbench_group_create_item')
->where('workbenchId', $workbenchId)
->where('wechatAccountId', $wechatAccountId)
->where('createTime', '>=', $createTime - 10)
->where('createTime', '<=', $createTime + 10)
->update([
'status' => self::STATUS_CREATING,
'retryCount' => Db::raw('retryCount + 1')
]);
// 重新创建建群任务延迟10秒
Queue::later(10, 'app\job\WorkbenchGroupCreateRetryJob', [
'workbenchId' => $workbenchId,
'wechatAccountId' => $wechatAccountId,
'createTime' => $createTime
], 'default');
$job->delete();
}
}

View File

@@ -130,11 +130,14 @@ class WorkbenchMomentsJob
// 3) 下发
$moments = new Moments();
$moments->addJob($sendData);
KfMoments::where(['id' => $val['id']])->update(['isSend' => 1]);
$res = $moments->addJob($sendData);
$res = json_decode($res, true);
if ($res['code'] == 200){
KfMoments::where(['id' => $val['id']])->update(['isSend' => 1]);
// 4) 统计
$this->incrementSendStats($companyId, $userId, $allowed);
// 4) 统计
$this->incrementSendStats($companyId, $userId, $allowed);
}
}
} catch (\Exception $e) {
Log::error("朋友圈同步任务异常: " . $e->getMessage());
@@ -334,9 +337,12 @@ class WorkbenchMomentsJob
];
// 发送朋友圈
$moments = new Moments();
$moments->addJob($data);
// 记录发送记录
$this->recordSendHistory($workbench, $devices, $contentLibrary);
$res = $moments->addJob($data);
$res = json_decode($res,true);
if ($res['code'] == 200){
// 记录发送记录
$this->recordSendHistory($workbench, $devices, $contentLibrary);
}
}

View File

@@ -115,4 +115,64 @@ crontab -l
- 本地: php think worker:server
- 线上: php think worker:server -d (自带守护进程无需搭配Supervisor 之类的工具)
- php think worker:server stop php think worker:server status
```
```
# 设备列表 - 未删除设备(每半小时执行)
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think device:list --isDel=0 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_device_active.log 2>&1
# 设备列表 - 已删除设备每天1点执行
0 1 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think device:list --isDel=1 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_device_deleted.log 2>&1
# 设备列表 - 已停用设备每天1:10执行
10 1 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think device:list --isDel=2 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_device_stopped.log 2>&1
# 微信好友列表 - 未删除好友每1分钟执行
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatFriends:list --isDel=0 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_wechatFriends_active.log 2>&1
# 微信好友列表 - 已删除好友每天1:30分执行
30 1 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatFriends:list --isDel=1 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_wechatFriends_deleted.log 2>&1
# 微信群列表 - 未删除群每5分钟执行
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatChatroom:list --isDel=0 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_wechatChatroom_active.log 2>&1
# 微信群列表 - 已删除群每天1:30分执行
30 1 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatChatroom:list --isDel=1 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_wechatChatroom_deleted.log 2>&1
# 微信群好友列表没5分钟执行
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think groupFriends:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_groupFriends.log 2>&1
# 添加好友任务列表(每1分钟执行)
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think friendTask:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_friendTask.log 2>&1
# 微信客服列表每5分钟执行
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatList:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_wechatList.log 2>&1
# 公司账号列表每5分钟执行
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think account:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_account.log 2>&1
# 微信好友消息列表每30分钟执行
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think message:friendsList >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_messageFriends.log 2>&1
# 微信群聊消息列表每30分钟执行
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think message:chatroomList >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_messageChatroom.log 2>&1
# 获取通话记录
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think call-recording:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/call_recording.log 2>&1
# 清洗微信数据
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think sync:wechatData >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/sync_wechat_data.log 2>&1
# 内容采集任务每5分钟执行
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think content:collect >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_contentCollect.log 2>&1
# 工作台任务_自动点赞每10分钟执行
*/6 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:autoLike >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_workbench_autoLike.log 2>&1
# 每3天的3点同步所有好友
0 3 */3 * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think sync:allFriends >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/all_friends.log 2>&1
# 工作台流量分发
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:trafficDistribute >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/traffic_distribute.log 2>&1
# 工作台朋友圈同步任务
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:moments >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_moments.log 2>&1
# 工作台群发消息
#*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:groupPush >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_groupPush.log 2>&1
# 预防性切换好友
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think switch:friends >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/switch_friends.log 2>&1
# 工作台建群
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:groupCreate >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_groupCreate.log 2>&1
# 工作台通讯录导入
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:import-contact >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/import_contact.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
# 每分钟执行一次调度器(调度器内部会自动判断哪些任务需要执行)
* * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think scheduler:run >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/scheduler.log 2>&1

413
Server/public/doc/api_v1.md Normal file
View File

@@ -0,0 +1,413 @@
# 对外获客线索上报接口文档V1
## 一、接口概述
- **接口名称**:对外获客线索上报接口
- **接口用途**:供第三方系统向【存客宝】上报客户线索(手机号 / 微信号等),用于后续的跟进、标签管理和画像分析。
- **接口协议**HTTP
- **请求方式**`POST`
- **请求地址** `http://ckbapi.quwanzhi.com/v1/api/scenarios`
> 具体 URL 以实际环境配置为准。
- **数据格式**
- 推荐:`application/json`
- 兼容:`application/x-www-form-urlencoded`
- **字符编码**`UTF-8`
---
## 二、鉴权与签名
### 2.1 必填鉴权字段
| 字段名 | 类型 | 必填 | 说明 |
|-------------|--------|------|---------------------------------------|
| `apiKey` | string | 是 | 分配给第三方的接口密钥(每个任务唯一)|
| `sign` | string | 是 | 签名值 |
| `timestamp` | int | 是 | 秒级时间戳(与服务器时间差不超过 5 分钟) |
### 2.2 时间戳校验
服务器会校验 `timestamp` 是否在当前时间前后 **5 分钟** 内:
- 通过条件:`|server_time - timestamp| <= 300`
- 超出范围则返回:`请求已过期`
### 2.3 签名生成规则
接口采用自定义签名机制。**签名字段为 `sign`,生成步骤如下:**
假设本次请求的所有参数为 `params`,其中包括业务参数 + `apiKey` + `timestamp` + `sign` + 可能存在的 `portrait` 对象。
#### 第一步:移除特定字段
`params` 中移除以下字段:
- `sign` —— 自身不参与签名
- `apiKey` —— 不参与参数拼接,仅在最后一步参与二次 MD5
- `portrait` —— 整个画像对象不参与签名(即使内部还有子字段)
> 说明:`portrait` 通常是一个 JSON 对象,字段较多,为避免签名实现复杂且双方难以对齐,统一不参与签名。
#### 第二步:移除空值字段
从剩余参数中,移除值为:
- `null`
- 空字符串 `''`
的字段,这些字段不参与签名。
#### 第三步:按参数名升序排序
对剩余参数按**参数名(键名)升序排序**,排序规则为标准的 ASCII 升序:
```text
例如: name, phone, source, timestamp
```
#### 第四步:拼接参数值
将排序后的参数 **只取“值”**,按顺序直接拼接为一个字符串,中间不加任何分隔符:
- 示例:
排序后参数为:
```text
name = 张三
phone = 13800000000
source = 微信广告
timestamp = 1710000000
```
则拼接:
```text
stringToSign = "张三13800000000微信广告1710000000"
```
#### 第五步:第一次 MD5
对上一步拼接得到的字符串做一次 MD5
\[
\text{firstMd5} = \text{MD5}(\text{stringToSign})
\]
#### 第六步:拼接 apiKey 再次 MD5
将第一步的结果与 `apiKey` 直接拼接,再做一次 MD5得到最终签名值
\[
\text{sign} = \text{MD5}(\text{firstMd5} + \text{apiKey})
\]
#### 第七步:放入请求
将第六步得到的 `sign` 填入请求参数中的 `sign` 字段即可。
> 建议:
> - 使用小写 MD5 字符串(双方约定统一即可)。
> - 请确保参与签名的参数与最终请求发送的参数一致(包括是否传空值)。
### 2.4 签名示例PHP 伪代码)
```php
$params = [
'apiKey' => 'YOUR_API_KEY',
'timestamp' => '1710000000',
'phone' => '13800000000',
'name' => '张三',
'source' => '微信广告',
'remark' => '通过H5落地页留资',
// 'portrait' => [...], // 如有画像,这里会存在,但不参与签名
// 'sign' => '待生成',
];
// 1. 去掉 sign、apiKey、portrait
unset($params['sign'], $params['apiKey'], $params['portrait']);
// 2. 去掉空值
$params = array_filter($params, function($value) {
return !is_null($value) && $value !== '';
});
// 3. 按键名升序排序
ksort($params);
// 4. 拼接参数值
$stringToSign = implode('', array_values($params));
// 5. 第一次 MD5
$firstMd5 = md5($stringToSign);
// 6. 第二次 MD5拼接 apiKey
$apiKey = 'YOUR_API_KEY';
$sign = md5($firstMd5 . $apiKey);
// 将 $sign 作为字段发送
$params['sign'] = $sign;
```
---
## 三、请求参数说明
### 3.1 主标识字段(至少传一个)
| 字段名 | 类型 | 必填 | 说明 |
|-----------|--------|------|-------------------------------------------|
| `wechatId`| string | 否 | 微信号,存在时优先作为主标识 |
| `phone` | string | 否 | 手机号,当 `wechatId` 为空时用作主标识 |
### 3.2 基础信息字段
| 字段名 | 类型 | 必填 | 说明 |
|------------|--------|------|-------------------------|
| `name` | string | 否 | 客户姓名 |
| `source` | string | 否 | 线索来源描述,如“百度推广”、“抖音直播间” |
| `remark` | string | 否 | 备注信息 |
| `tags` | string | 否 | 逗号分隔的“微信标签”,如:`"高意向,电商,女装"` |
| `siteTags` | string | 否 | 逗号分隔的“站内标签”,用于站内进一步细分 |
### 3.3 用户画像字段 `portrait`(可选)
`portrait` 为一个对象JSON用于记录用户的行为画像数据。
#### 3.3.1 基本示例
```json
"portrait": {
"type": 1,
"source": 1,
"sourceData": {
"age": 28,
"gender": "female",
"city": "上海",
"productId": "P12345",
"pageUrl": "https://example.com/product/123"
},
"remark": "画像-基础属性",
"uniqueId": "user_13800000000_20250301_001"
}
```
#### 3.3.2 字段详细说明
| 字段名 | 类型 | 必填 | 说明 |
|-----------------------|--------|------|----------------------------------------|
| `portrait.type` | int | 否 | 画像类型,枚举值:<br>0-浏览<br>1-点击<br>2-下单/购买<br>3-注册<br>4-互动<br>默认值0 |
| `portrait.source` | int | 否 | 画像来源,枚举值:<br>0-本站<br>1-老油条<br>2-老坑爹<br>默认值0 |
| `portrait.sourceData` | object | 否 | 画像明细数据(键值对,会存储为 JSON 格式)<br>可包含任意业务相关的键值对年龄、性别、城市、商品ID、页面URL等 |
| `portrait.remark` | string | 否 | 画像备注信息最大长度100字符 |
| `portrait.uniqueId` | string | 否 | 画像去重用唯一 ID<br>用于防止重复记录,相同 `uniqueId` 的画像数据在半小时内会被合并统计count字段累加<br>建议格式:`{来源标识}_{用户标识}_{时间戳}_{序号}` |
#### 3.3.3 画像类型type说明
| 值 | 类型 | 说明 | 适用场景 |
|---|------|------|---------|
| 0 | 浏览 | 用户浏览了页面或内容 | 页面访问、商品浏览、文章阅读等 |
| 1 | 点击 | 用户点击了某个元素 | 按钮点击、链接点击、广告点击等 |
| 2 | 下单/购买 | 用户完成了购买行为 | 订单提交、支付完成等 |
| 3 | 注册 | 用户完成了注册 | 账号注册、会员注册等 |
| 4 | 互动 | 用户进行了互动行为 | 点赞、评论、分享、咨询等 |
#### 3.3.4 画像来源source说明
| 值 | 来源 | 说明 |
|---|------|------|
| 0 | 本站 | 来自本站的数据 |
| 1 | 老油条 | 来自"老油条"系统的数据 |
| 2 | 老坑爹 | 来自"老坑爹"系统的数据 |
#### 3.3.5 sourceData 数据格式说明
`sourceData` 是一个 JSON 对象,可以包含任意业务相关的键值对。常见字段示例:
```json
{
"age": 28,
"gender": "female",
"city": "上海",
"province": "上海市",
"productId": "P12345",
"productName": "商品名称",
"category": "女装",
"price": 299.00,
"pageUrl": "https://example.com/product/123",
"referrer": "https://www.baidu.com",
"device": "mobile",
"browser": "WeChat"
}
```
> **注意**
> - `sourceData` 中的数据类型可以是字符串、数字、布尔值等
> - 嵌套对象会被序列化为 JSON 字符串存储
> - 建议根据实际业务需求定义字段结构
#### 3.3.6 uniqueId 去重机制说明
- **作用**:防止重复记录相同的画像数据
- **规则**:相同 `uniqueId` 的画像数据在 **半小时内** 会被合并统计,`count` 字段会自动累加
- **建议格式**`{来源标识}_{用户标识}_{时间戳}_{序号}`
- 示例:`site_13800000000_1710000000_001`
- 示例:`wechat_wxid_abc123_1710000000_001`
- **注意事项**
- 如果不传 `uniqueId`,系统会为每条画像数据创建新记录
- 如果需要在半小时内多次统计同一行为,应使用相同的 `uniqueId`
- 如果需要在半小时后重新统计,应使用不同的 `uniqueId`(建议修改时间戳部分)
> **重要提示**`portrait` **整体不参与签名计算**,但会参与业务处理。系统会根据 `uniqueId` 自动处理去重和统计。
---
## 四、请求示例
### 4.1 JSON 请求示例(无画像)
```json
{
"apiKey": "YOUR_API_KEY",
"timestamp": 1710000000,
"phone": "13800000000",
"name": "张三",
"source": "微信广告",
"remark": "通过H5落地页留资",
"tags": "高意向,电商",
"siteTags": "新客,女装",
"sign": "根据签名规则生成的MD5字符串"
}
```
### 4.2 JSON 请求示例(带微信号与画像)
```json
{
"apiKey": "YOUR_API_KEY",
"timestamp": 1710000000,
"wechatId": "wxid_abcdefg123",
"phone": "13800000001",
"name": "李四",
"source": "小程序落地页",
"remark": "点击【立即咨询】按钮",
"tags": "中意向,直播",
"siteTags": "复购,高客单",
"portrait": {
"type": 1,
"source": 0,
"sourceData": {
"age": 28,
"gender": "female",
"city": "上海",
"pageUrl": "https://example.com/product/123",
"productId": "P12345"
},
"remark": "画像-点击行为",
"uniqueId": "site_13800000001_1710000000_001"
},
"sign": "根据签名规则生成的MD5字符串"
}
```
### 4.3 JSON 请求示例(多种画像类型)
#### 4.3.1 浏览行为画像
```json
{
"apiKey": "YOUR_API_KEY",
"timestamp": 1710000000,
"phone": "13800000002",
"name": "王五",
"source": "百度推广",
"portrait": {
"type": 0,
"source": 0,
"sourceData": {
"pageUrl": "https://example.com/product/456",
"productName": "商品名称",
"category": "女装",
"stayTime": 120,
"device": "mobile"
},
"remark": "商品浏览",
"uniqueId": "site_13800000002_1710000000_001"
},
"sign": "根据签名规则生成的MD5字符串"
}
```
```
---
## 五、响应说明
### 5.1 成功响应
**1新增线索成功**
```json
{
"code": 200,
"message": "新增成功",
"data": "13800000000"
}
```
**2线索已存在**
```json
{
"code": 200,
"message": "已存在",
"data": "13800000000"
}
```
> `data` 字段返回本次线索的主标识 `wechatId` 或 `phone`。
### 5.2 常见错误响应
```json
{ "code": 400, "message": "apiKey不能为空", "data": null }
{ "code": 400, "message": "sign不能为空", "data": null }
{ "code": 400, "message": "timestamp不能为空", "data": null }
{ "code": 400, "message": "请求已过期", "data": null }
{ "code": 401, "message": "无效的apiKey", "data": null }
{ "code": 401, "message": "签名验证失败", "data": null }
{ "code": 500, "message": "系统错误: 具体错误信息", "data": null }
```
---
## 六、常见问题FAQ
### Q1: 如果同一个用户多次上报相同的行为,会如何处理?
**A**: 如果使用相同的 `uniqueId`,系统会在半小时内合并统计,`count` 字段会累加。如果使用不同的 `uniqueId`,会创建多条记录。
### Q2: portrait 字段是否必须传递?
**A**: 不是必须的。`portrait` 字段是可选的,只有在需要记录用户画像数据时才传递。
### Q3: sourceData 中可以存储哪些类型的数据?
**A**: `sourceData` 是一个 JSON 对象,可以存储任意键值对。支持字符串、数字、布尔值等基本类型,嵌套对象会被序列化为 JSON 字符串。
### Q4: uniqueId 的作用是什么?
**A**: `uniqueId` 用于防止重复记录。相同 `uniqueId` 的画像数据在半小时内会被合并统计,避免重复数据。
### Q5: 画像数据如何与用户关联?
**A**: 系统会根据请求中的 `wechatId` 或 `phone` 自动匹配 `traffic_pool` 表中的用户,并将画像数据关联到对应的 `trafficPoolId`。
---