Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c33c62aa6d | ||
|
|
581e09d45b | ||
|
|
688a200999 | ||
|
|
cc89df7957 | ||
|
|
198e0434b3 | ||
|
|
55fe2b46df | ||
|
|
c7e934f23d | ||
|
|
7752dd136f | ||
|
|
af2f32da4a | ||
|
|
81859dfacb | ||
|
|
3e145ca123 | ||
|
|
c9a5d3091f | ||
|
|
4374ad5036 | ||
|
|
1ca2ca7058 | ||
|
|
9fe15b1bec | ||
|
|
a557319b3e | ||
|
|
f06eb0e9d7 | ||
|
|
1be091522e | ||
|
|
63df35b79c | ||
|
|
2697da15aa | ||
|
|
ced0f993de | ||
|
|
55629a8ce4 | ||
|
|
923c6a5904 | ||
|
|
b63e87d0cf | ||
|
|
96caa71f03 | ||
|
|
b9b1877e04 | ||
|
|
81daa8cfdc | ||
|
|
903ea005e3 | ||
|
|
580e9f4f73 | ||
|
|
51e08d1b2a | ||
|
|
3708f5a61e | ||
|
|
58377b4670 | ||
|
|
5bb5aa3d5a | ||
|
|
71def21369 | ||
|
|
1ea565fbdf | ||
|
|
56a9a015f1 | ||
|
|
0708b5a134 | ||
|
|
242065d298 | ||
|
|
b5d6214521 | ||
|
|
9c5bccb23c | ||
|
|
5f5b567749 | ||
|
|
354d6c4f62 | ||
|
|
2c37314c4f | ||
|
|
1dbdee155e | ||
|
|
11a4ec8c67 | ||
|
|
75aab08a72 | ||
|
|
2cc2184002 | ||
|
|
57c398f537 | ||
|
|
7d9f94984c | ||
|
|
8659049ae9 | ||
|
|
1ca5b30c15 | ||
|
|
b9bc0a71a9 | ||
|
|
13ae8bd76f |
@@ -1,5 +1,5 @@
|
|||||||
import request from "@/api/request";
|
import request from "@/api/request";
|
||||||
|
|
||||||
export function getContentLibraryList(params: any) {
|
export function getContentLibraryList(params: any) {
|
||||||
return request("/v1/content/library/list", params, "GET");
|
return request("/v1/content/library/list", { ...params, formType: 0 }, "GET");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ export interface DeviceSelectionProps {
|
|||||||
showSelectedList?: boolean; // 新增
|
showSelectedList?: boolean; // 新增
|
||||||
readonly?: boolean; // 新增
|
readonly?: boolean; // 新增
|
||||||
deviceGroups?: any[]; // 传递设备组数据
|
deviceGroups?: any[]; // 传递设备组数据
|
||||||
|
singleSelect?: boolean; // 新增,是否单选模式
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
|
|||||||
showInput = true,
|
showInput = true,
|
||||||
showSelectedList = true,
|
showSelectedList = true,
|
||||||
readonly = false,
|
readonly = false,
|
||||||
|
singleSelect = false,
|
||||||
}) => {
|
}) => {
|
||||||
// 弹窗控制
|
// 弹窗控制
|
||||||
const [popupVisible, setPopupVisible] = useState(false);
|
const [popupVisible, setPopupVisible] = useState(false);
|
||||||
@@ -37,6 +38,9 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
|
|||||||
// 获取显示文本
|
// 获取显示文本
|
||||||
const getDisplayText = () => {
|
const getDisplayText = () => {
|
||||||
if (selectedOptions.length === 0) return "";
|
if (selectedOptions.length === 0) return "";
|
||||||
|
if (singleSelect && selectedOptions.length > 0) {
|
||||||
|
return selectedOptions[0].memo || selectedOptions[0].wechatId || "已选择设备";
|
||||||
|
}
|
||||||
return `已选择 ${selectedOptions.length} 个设备`;
|
return `已选择 ${selectedOptions.length} 个设备`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,6 +183,7 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
|
|||||||
onClose={() => setRealVisible(false)}
|
onClose={() => setRealVisible(false)}
|
||||||
selectedOptions={selectedOptions}
|
selectedOptions={selectedOptions}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
|
singleSelect={singleSelect}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface SelectionPopupProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
selectedOptions: DeviceSelectionItem[];
|
selectedOptions: DeviceSelectionItem[];
|
||||||
onSelect: (devices: DeviceSelectionItem[]) => void;
|
onSelect: (devices: DeviceSelectionItem[]) => void;
|
||||||
|
singleSelect?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -21,6 +22,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
singleSelect = false,
|
||||||
}) => {
|
}) => {
|
||||||
// 设备数据
|
// 设备数据
|
||||||
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
|
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
|
||||||
@@ -110,6 +112,15 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
|||||||
|
|
||||||
// 处理设备选择
|
// 处理设备选择
|
||||||
const handleDeviceToggle = (device: DeviceSelectionItem) => {
|
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)) {
|
if (tempSelectedOptions.some(v => v.id === device.id)) {
|
||||||
setTempSelectedOptions(
|
setTempSelectedOptions(
|
||||||
tempSelectedOptions.filter(v => v.id !== device.id),
|
tempSelectedOptions.filter(v => v.id !== device.id),
|
||||||
@@ -117,6 +128,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
const newSelectedOptions = [...tempSelectedOptions, device];
|
const newSelectedOptions = [...tempSelectedOptions, device];
|
||||||
setTempSelectedOptions(newSelectedOptions);
|
setTempSelectedOptions(newSelectedOptions);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,6 +191,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
|||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
selectedCount={tempSelectedOptions.length}
|
selectedCount={tempSelectedOptions.length}
|
||||||
|
singleSelect={singleSelect}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentPage}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
@@ -187,7 +200,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
isAllSelected={isCurrentPageAllSelected}
|
isAllSelected={isCurrentPageAllSelected}
|
||||||
onSelectAll={handleSelectAllCurrentPage}
|
onSelectAll={singleSelect ? undefined : handleSelectAllCurrentPage}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ export default function FriendSelection({
|
|||||||
{(selectedOptions || []).map(friend => (
|
{(selectedOptions || []).map(friend => (
|
||||||
<div key={friend.id} className={style.selectedListRow}>
|
<div key={friend.id} className={style.selectedListRow}>
|
||||||
<div className={style.selectedListRowContent}>
|
<div className={style.selectedListRowContent}>
|
||||||
<Avatar src={friend.avatar} />
|
<Avatar src={friend.avatar || friend.friendAvatar} />
|
||||||
<div className={style.selectedListRowContentText}>
|
<div className={style.selectedListRowContentText}>
|
||||||
<div>{friend.nickname}</div>
|
<div>{friend.nickname || friend.friendName}</div>
|
||||||
<div>{friend.wechatId}</div>
|
<div>{friend.wechatId}</div>
|
||||||
</div>
|
</div>
|
||||||
{!readonly && (
|
{!readonly && (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface PopupFooterProps {
|
|||||||
// 全选功能相关
|
// 全选功能相关
|
||||||
isAllSelected?: boolean;
|
isAllSelected?: boolean;
|
||||||
onSelectAll?: (checked: boolean) => void;
|
onSelectAll?: (checked: boolean) => void;
|
||||||
|
singleSelect?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PopupFooter: React.FC<PopupFooterProps> = ({
|
const PopupFooter: React.FC<PopupFooterProps> = ({
|
||||||
@@ -26,11 +27,13 @@ const PopupFooter: React.FC<PopupFooterProps> = ({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
isAllSelected = false,
|
isAllSelected = false,
|
||||||
onSelectAll,
|
onSelectAll,
|
||||||
|
singleSelect = false,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 分页栏 */}
|
{/* 分页栏 */}
|
||||||
<div className={style.paginationRow}>
|
<div className={style.paginationRow}>
|
||||||
|
{onSelectAll && (
|
||||||
<div className={style.totalCount}>
|
<div className={style.totalCount}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isAllSelected}
|
checked={isAllSelected}
|
||||||
@@ -40,6 +43,7 @@ const PopupFooter: React.FC<PopupFooterProps> = ({
|
|||||||
全选当前页
|
全选当前页
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className={style.paginationControls}>
|
<div className={style.paginationControls}>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||||
@@ -61,7 +65,13 @@ const PopupFooter: React.FC<PopupFooterProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.popupFooter}>
|
<div className={style.popupFooter}>
|
||||||
<div className={style.selectedCount}>已选择 {selectedCount} 条记录</div>
|
<div className={style.selectedCount}>
|
||||||
|
{singleSelect
|
||||||
|
? selectedCount > 0
|
||||||
|
? "已选择设备"
|
||||||
|
: "未选择设备"
|
||||||
|
: `已选择 ${selectedCount} 条记录`}
|
||||||
|
</div>
|
||||||
<div className={style.footerBtnGroup}>
|
<div className={style.footerBtnGroup}>
|
||||||
<Button color="primary" variant="filled" onClick={onCancel}>
|
<Button color="primary" variant="filled" onClick={onCancel}>
|
||||||
取消
|
取消
|
||||||
|
|||||||
@@ -149,14 +149,14 @@ const Home: React.FC = () => {
|
|||||||
<div className={style["stat-card"]} onClick={handleDevicesClick}>
|
<div className={style["stat-card"]} onClick={handleDevicesClick}>
|
||||||
<div className={style["stat-label"]}>设备数量</div>
|
<div className={style["stat-label"]}>设备数量</div>
|
||||||
<div className={style["stat-value"]}>
|
<div className={style["stat-value"]}>
|
||||||
<span>{dashboard.deviceNum || 42}</span>
|
<span>{dashboard.deviceNum || 0}</span>
|
||||||
<MobileOutlined style={{ fontSize: 20, color: "#3b82f6" }} />
|
<MobileOutlined style={{ fontSize: 20, color: "#3b82f6" }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style["stat-card"]} onClick={handleWechatClick}>
|
<div className={style["stat-card"]} onClick={handleWechatClick}>
|
||||||
<div className={style["stat-label"]}>微信号数量</div>
|
<div className={style["stat-label"]}>微信号数量</div>
|
||||||
<div className={style["stat-value"]}>
|
<div className={style["stat-value"]}>
|
||||||
<span>{dashboard.wechatNum || 42}</span>
|
<span>{dashboard.wechatNum || 0}</span>
|
||||||
<TeamOutlined style={{ fontSize: 20, color: "#3b82f6" }} />
|
<TeamOutlined style={{ fontSize: 20, color: "#3b82f6" }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,7 +166,7 @@ const Home: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className={style["stat-label"]}>在线微信号</div>
|
<div className={style["stat-label"]}>在线微信号</div>
|
||||||
<div className={style["stat-value"]}>
|
<div className={style["stat-value"]}>
|
||||||
<span>{dashboard.aliveWechatNum || 35}</span>
|
<span>{dashboard.aliveWechatNum || 0}</span>
|
||||||
<LineChartOutlined style={{ fontSize: 20, color: "#3b82f6" }} />
|
<LineChartOutlined style={{ fontSize: 20, color: "#3b82f6" }} />
|
||||||
</div>
|
</div>
|
||||||
<div className={style["progress-bar"]}>
|
<div className={style["progress-bar"]}>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function getContentLibraryDetail(id: string): Promise<any> {
|
|||||||
export function createContentLibrary(
|
export function createContentLibrary(
|
||||||
params: CreateContentLibraryParams,
|
params: CreateContentLibraryParams,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
return request("/v1/content/library/create", params, "POST");
|
return request("/v1/content/library/create", { ...params, formType: 0 }, "POST");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新内容库
|
// 更新内容库
|
||||||
|
|||||||
@@ -1,23 +1,37 @@
|
|||||||
.form-page {
|
.form-page {
|
||||||
background: #f7f8fa;
|
background: #f7f8fa;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
padding-bottom: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-main {
|
.form-main {
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 16px 0 0 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-section {
|
.form-section {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-card {
|
.form-card {
|
||||||
border-radius: 16px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
padding: 24px 18px 18px 18px;
|
padding: 16px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-tag {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
@@ -32,9 +46,23 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #222;
|
color: #222;
|
||||||
margin-top: 28px;
|
margin-top: 20px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
letter-spacing: 0.5px;
|
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 {
|
.section-block {
|
||||||
@@ -47,40 +75,78 @@
|
|||||||
.adm-tabs-header {
|
.adm-tabs-header {
|
||||||
background: #f7f8fa;
|
background: #f7f8fa;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 12px;
|
||||||
|
padding: 4px;
|
||||||
}
|
}
|
||||||
.adm-tabs-tab {
|
.adm-tabs-tab {
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
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 {
|
.keyword-collapse {
|
||||||
margin-top: 12px;
|
.adm-collapse-panel-header {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
.adm-collapse-panel-content {
|
.adm-collapse-panel-content {
|
||||||
padding-bottom: 8px;
|
padding: 16px 0 0 0;
|
||||||
background: #f8fafc;
|
background: transparent;
|
||||||
border-radius: 10px;
|
|
||||||
padding: 18px 14px 10px 14px;
|
|
||||||
margin-top: 2px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-section {
|
.form-section {
|
||||||
margin-bottom: 22px;
|
margin-bottom: 18px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 8px;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
.adm-input {
|
}
|
||||||
min-height: 42px;
|
|
||||||
font-size: 15px;
|
.keyword-header {
|
||||||
border-radius: 7px;
|
display: flex;
|
||||||
margin-bottom: 2px;
|
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,
|
.ai-row,
|
||||||
@@ -91,9 +157,53 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-desc {
|
.ai-desc {
|
||||||
color: #888;
|
color: #666;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
flex: 1;
|
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,
|
.date-row,
|
||||||
@@ -109,6 +219,179 @@
|
|||||||
border-radius: 8px;
|
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 {
|
.submit-btn {
|
||||||
margin-top: 32px;
|
margin-top: 32px;
|
||||||
height: 48px !important;
|
height: 48px !important;
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
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 { Button, Collapse, Toast, DatePicker, Tabs } from "antd-mobile";
|
||||||
|
import { DownOutlined } from "@ant-design/icons";
|
||||||
import NavCommon from "@/components/NavCommon";
|
import NavCommon from "@/components/NavCommon";
|
||||||
import FriendSelection from "@/components/FriendSelection";
|
import FriendSelection from "@/components/FriendSelection";
|
||||||
import GroupSelection from "@/components/GroupSelection";
|
import GroupSelection from "@/components/GroupSelection";
|
||||||
|
import DeviceSelection from "@/components/DeviceSelection";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import style from "./index.module.scss";
|
import style from "./index.module.scss";
|
||||||
import request from "@/api/request";
|
import request from "@/api/request";
|
||||||
import { getContentLibraryDetail, updateContentLibrary } from "./api";
|
import { getContentLibraryDetail, updateContentLibrary } from "./api";
|
||||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||||
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||||
|
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||||
|
|
||||||
const { TextArea } = AntdInput;
|
const { TextArea } = AntdInput;
|
||||||
|
|
||||||
@@ -29,6 +32,15 @@ export default function ContentForm() {
|
|||||||
const isEdit = !!id;
|
const isEdit = !!id;
|
||||||
const [sourceType, setSourceType] = useState<"friends" | "groups">("friends");
|
const [sourceType, setSourceType] = useState<"friends" | "groups">("friends");
|
||||||
const [name, setName] = useState("");
|
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 [friendsGroups, setSelectedFriends] = useState<string[]>([]);
|
||||||
const [friendsGroupsOptions, setSelectedFriendsOptions] = useState<
|
const [friendsGroupsOptions, setSelectedFriendsOptions] = useState<
|
||||||
FriendSelectionItem[]
|
FriendSelectionItem[]
|
||||||
@@ -64,6 +76,23 @@ export default function ContentForm() {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
setName(data.name || "");
|
setName(data.name || "");
|
||||||
setSourceType(data.sourceType === 1 ? "friends" : "groups");
|
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 || []);
|
setSelectedFriends(data.sourceFriends || []);
|
||||||
setSelectedGroups(data.selectedGroups || []);
|
setSelectedGroups(data.selectedGroups || []);
|
||||||
setSelectedGroupsOptions(data.selectedGroupsOptions || []);
|
setSelectedGroupsOptions(data.selectedGroupsOptions || []);
|
||||||
@@ -72,7 +101,13 @@ export default function ContentForm() {
|
|||||||
setKeywordsExclude((data.keywordExclude || []).join(","));
|
setKeywordsExclude((data.keywordExclude || []).join(","));
|
||||||
setCatchType(data.catchType || ["text", "image", "video"]);
|
setCatchType(data.catchType || ["text", "image", "video"]);
|
||||||
setAIPrompt(data.aiPrompt || "");
|
setAIPrompt(data.aiPrompt || "");
|
||||||
|
// aiEnabled 为 AI 提示词开关,1 开启 0 关闭
|
||||||
|
if (typeof data.aiEnabled !== "undefined") {
|
||||||
|
setUseAI(data.aiEnabled === 1);
|
||||||
|
} else {
|
||||||
|
// 兼容旧数据,默认根据是否有 aiPrompt 判断
|
||||||
setUseAI(!!data.aiPrompt);
|
setUseAI(!!data.aiPrompt);
|
||||||
|
}
|
||||||
setEnabled(data.status === 1);
|
setEnabled(data.status === 1);
|
||||||
// 时间范围
|
// 时间范围
|
||||||
const start = data.timeStart || data.startTime;
|
const start = data.timeStart || data.startTime;
|
||||||
@@ -103,6 +138,7 @@ export default function ContentForm() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
name,
|
name,
|
||||||
sourceType: sourceType === "friends" ? 1 : 2,
|
sourceType: sourceType === "friends" ? 1 : 2,
|
||||||
|
devices: selectedDevices.map(d => d.id),
|
||||||
friendsGroups: friendsGroups,
|
friendsGroups: friendsGroups,
|
||||||
wechatGroups: selectedGroups,
|
wechatGroups: selectedGroups,
|
||||||
groupMembers: {},
|
groupMembers: {},
|
||||||
@@ -116,6 +152,7 @@ export default function ContentForm() {
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
catchType,
|
catchType,
|
||||||
aiPrompt,
|
aiPrompt,
|
||||||
|
aiEnabled: useAI ? 1 : 0,
|
||||||
timeEnabled: dateRange[0] || dateRange[1] ? 1 : 0,
|
timeEnabled: dateRange[0] || dateRange[1] ? 1 : 0,
|
||||||
startTime: dateRange[0] ? formatDate(dateRange[0]) : "",
|
startTime: dateRange[0] ? formatDate(dateRange[0]) : "",
|
||||||
endTime: dateRange[1] ? formatDate(dateRange[1]) : "",
|
endTime: dateRange[1] ? formatDate(dateRange[1]) : "",
|
||||||
@@ -125,7 +162,7 @@ export default function ContentForm() {
|
|||||||
await updateContentLibrary({ id, ...payload });
|
await updateContentLibrary({ id, ...payload });
|
||||||
Toast.show({ content: "保存成功", position: "top" });
|
Toast.show({ content: "保存成功", position: "top" });
|
||||||
} else {
|
} else {
|
||||||
await request("/v1/content/library/create", payload, "POST");
|
await request("/v1/content/library/create", { ...payload, formType: 0 }, "POST");
|
||||||
Toast.show({ content: "创建成功", position: "top" });
|
Toast.show({ content: "创建成功", position: "top" });
|
||||||
}
|
}
|
||||||
navigate("/mine/content");
|
navigate("/mine/content");
|
||||||
@@ -178,7 +215,7 @@ export default function ContentForm() {
|
|||||||
onSubmit={e => e.preventDefault()}
|
onSubmit={e => e.preventDefault()}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
>
|
>
|
||||||
<div className={style["form-section"]}>
|
<div className={style["form-card"]}>
|
||||||
<label className={style["form-label"]}>
|
<label className={style["form-label"]}>
|
||||||
<span style={{ color: "#ff4d4f", marginRight: 4 }}>*</span>
|
<span style={{ color: "#ff4d4f", marginRight: 4 }}>*</span>
|
||||||
内容库名称
|
内容库名称
|
||||||
@@ -191,8 +228,100 @@ export default function ContentForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={style["section-title"]}>数据来源配置</div>
|
<div className={style["form-card"]}>
|
||||||
<div className={style["form-section"]}>
|
<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
|
<Tabs
|
||||||
activeKey={sourceType}
|
activeKey={sourceType}
|
||||||
onChange={key => setSourceType(key as "friends" | "groups")}
|
onChange={key => setSourceType(key as "friends" | "groups")}
|
||||||
@@ -203,6 +332,7 @@ export default function ContentForm() {
|
|||||||
selectedOptions={friendsGroupsOptions}
|
selectedOptions={friendsGroupsOptions}
|
||||||
onSelect={handleFriendsChange}
|
onSelect={handleFriendsChange}
|
||||||
placeholder="选择微信好友"
|
placeholder="选择微信好友"
|
||||||
|
deviceIds={selectedDevices.map(d => Number(d.id))}
|
||||||
/>
|
/>
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
<Tabs.Tab title="选择聊天群" key="groups">
|
<Tabs.Tab title="选择聊天群" key="groups">
|
||||||
@@ -215,10 +345,16 @@ export default function ContentForm() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Collapse className={style["collapse"]}>
|
<div className={style["form-card"]}>
|
||||||
|
<Collapse className={style["keyword-collapse"]}>
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
key="keywords"
|
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"]}>
|
<div className={style["form-section"]}>
|
||||||
<label className={style["form-label"]}>包含关键词</label>
|
<label className={style["form-label"]}>包含关键词</label>
|
||||||
@@ -242,27 +378,20 @@ export default function ContentForm() {
|
|||||||
</div>
|
</div>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 采集内容类型 */}
|
{/* 采集内容类型 */}
|
||||||
<div className={style["section-title"]}>采集内容类型</div>
|
<div className={style["form-card"]}>
|
||||||
<div className={style["form-section"]}>
|
<div className={style["content-type-header"]}>
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 12 }}>
|
<span className={style["content-type-title"]}>采集内容类型</span>
|
||||||
|
</div>
|
||||||
|
<div className={style["content-type-buttons"]}>
|
||||||
{["text", "image", "video"].map(type => (
|
{["text", "image", "video"].map(type => (
|
||||||
<div
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
style={{
|
className={`${style["content-type-btn"]} ${
|
||||||
display: "flex",
|
catchType.includes(type) ? style["active"] : ""
|
||||||
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",
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCatchType(prev =>
|
setCatchType(prev =>
|
||||||
prev.includes(type)
|
prev.includes(type)
|
||||||
@@ -271,26 +400,24 @@ export default function ContentForm() {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
|
||||||
{type === "text"
|
{type === "text"
|
||||||
? "文本"
|
? "文本"
|
||||||
: type === "image"
|
: type === "image"
|
||||||
? "图片"
|
? "图片"
|
||||||
: "视频"}
|
: "视频"}
|
||||||
</span>
|
</button>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={style["section-title"]}>是否启用AI</div>
|
<div className={style["form-card"]}>
|
||||||
<div
|
<div
|
||||||
className={style["form-section"]}
|
className={style["form-section"]}
|
||||||
style={{ display: "flex", alignItems: "center", gap: 12 }}
|
style={{ display: "flex", alignItems: "center", gap: 12 }}
|
||||||
>
|
>
|
||||||
<Switch checked={useAI} onChange={setUseAI} />
|
<Switch checked={useAI} onChange={setUseAI} />
|
||||||
<span className={style["ai-desc"]}>
|
<span className={style["ai-desc"]}>
|
||||||
启用AI后,该内容库下的所有内容都会通过AI生成
|
启用后,该内容库下的内容会通过AI生成
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{useAI && (
|
{useAI && (
|
||||||
@@ -305,19 +432,24 @@ export default function ContentForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={style["section-title"]}>时间限制</div>
|
<div className={style["form-card"]}>
|
||||||
<div
|
<div className={style["time-limit-header"]}>
|
||||||
className={style["form-section"]}
|
<span className={style["time-limit-title"]}>时间限制</span>
|
||||||
style={{ display: "flex", gap: 12 }}
|
</div>
|
||||||
>
|
<div className={style["date-inputs"]}>
|
||||||
<label>开始时间</label>
|
<div className={style["date-item"]}>
|
||||||
<div style={{ flex: 1 }}>
|
<label className={style["date-label"]}>开始时间</label>
|
||||||
<AntdInput
|
<AntdInput
|
||||||
readOnly
|
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="年/月/日"
|
placeholder="年/月/日"
|
||||||
className={style["input"]}
|
className={style["date-input"]}
|
||||||
onClick={() => setShowStartPicker(true)}
|
onClick={() => setShowStartPicker(true)}
|
||||||
/>
|
/>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
@@ -331,13 +463,17 @@ export default function ContentForm() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label>结束时间</label>
|
<div className={style["date-item"]}>
|
||||||
<div style={{ flex: 1 }}>
|
<label className={style["date-label"]}>结束时间</label>
|
||||||
<AntdInput
|
<AntdInput
|
||||||
readOnly
|
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="年/月/日"
|
placeholder="年/月/日"
|
||||||
className={style["input"]}
|
className={style["date-input"]}
|
||||||
onClick={() => setShowEndPicker(true)}
|
onClick={() => setShowEndPicker(true)}
|
||||||
/>
|
/>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
@@ -350,21 +486,15 @@ export default function ContentForm() {
|
|||||||
setShowEndPicker(false);
|
setShowEndPicker(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className={style["form-card"]}>
|
||||||
className={style["section-title"]}
|
<div className={style["enable-section"]}>
|
||||||
style={{
|
<span className={style["enable-label"]}>是否启用</span>
|
||||||
marginTop: 24,
|
|
||||||
marginBottom: 8,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>是否启用</span>
|
|
||||||
<Switch checked={enabled} onChange={setEnabled} />
|
<Switch checked={enabled} onChange={setEnabled} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function getContentLibraryList(params: {
|
|||||||
keyword?: string;
|
keyword?: string;
|
||||||
sourceType?: number;
|
sourceType?: number;
|
||||||
}): Promise<any> {
|
}): 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(
|
export function createContentLibrary(
|
||||||
params: CreateContentLibraryParams,
|
params: CreateContentLibraryParams,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
return request("/v1/content/library/create", params, "POST");
|
return request("/v1/content/library/create", { ...params, formType: 0 }, "POST");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新内容库
|
// 更新内容库
|
||||||
|
|||||||
@@ -47,3 +47,11 @@ export function aiRewriteContent(params: AIRewriteParams) {
|
|||||||
export function replaceContent(params: ReplaceContentParams) {
|
export function replaceContent(params: ReplaceContentParams) {
|
||||||
return request("/v1/content/library/aiEditContent", params, "POST");
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ import {
|
|||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
|
UploadOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import NavCommon from "@/components/NavCommon";
|
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 { ContentItem } from "./data";
|
||||||
import style from "./index.module.scss";
|
import style from "./index.module.scss";
|
||||||
|
|
||||||
@@ -50,6 +52,11 @@ const MaterialsList: React.FC = () => {
|
|||||||
const [aiLoading, setAiLoading] = useState(false);
|
const [aiLoading, setAiLoading] = useState(false);
|
||||||
const [replaceLoading, setReplaceLoading] = 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 () => {
|
const fetchMaterials = useCallback(async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -187,6 +194,64 @@ const MaterialsList: React.FC = () => {
|
|||||||
fetchMaterials();
|
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) => {
|
const handlePageChange = (page: number) => {
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
};
|
};
|
||||||
@@ -354,9 +419,18 @@ const MaterialsList: React.FC = () => {
|
|||||||
title="素材管理"
|
title="素材管理"
|
||||||
backFn={() => navigate("/mine/content")}
|
backFn={() => navigate("/mine/content")}
|
||||||
right={
|
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>
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</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>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,3 +42,7 @@ export const fetchDeviceQRCode = (accountId: string) =>
|
|||||||
// 通过IMEI添加设备
|
// 通过IMEI添加设备
|
||||||
export const addDeviceByImei = (imei: string, name: string) =>
|
export const addDeviceByImei = (imei: string, name: string) =>
|
||||||
request("/v1/api/device/add-by-imei", { imei, name }, "POST");
|
request("/v1/api/device/add-by-imei", { imei, name }, "POST");
|
||||||
|
|
||||||
|
// 获取设备添加结果(用于轮询检查)
|
||||||
|
export const fetchAddResults = (params: { accountId?: string }) =>
|
||||||
|
request("/v1/devices/add-results", params, "GET");
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
fetchDeviceQRCode,
|
fetchDeviceQRCode,
|
||||||
addDeviceByImei,
|
addDeviceByImei,
|
||||||
deleteDevice,
|
deleteDevice,
|
||||||
|
fetchAddResults,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import type { Device } from "@/types/device";
|
import type { Device } from "@/types/device";
|
||||||
import { comfirm } from "@/utils/common";
|
import { comfirm } from "@/utils/common";
|
||||||
@@ -44,12 +45,18 @@ const Devices: React.FC = () => {
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [addLoading, setAddLoading] = useState(false);
|
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 [delVisible, setDelVisible] = useState(false);
|
||||||
const [delLoading, setDelLoading] = useState(false);
|
const [delLoading, setDelLoading] = useState(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useUserStore();
|
const { user } = useUserStore();
|
||||||
|
|
||||||
// 加载设备列表
|
// 加载设备列表
|
||||||
const loadDevices = useCallback(
|
const loadDevices = useCallback(
|
||||||
async (reset = false) => {
|
async (reset = false) => {
|
||||||
@@ -74,6 +81,11 @@ const Devices: React.FC = () => {
|
|||||||
[loading, search, page],
|
[loading, search, page],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 更新 loadDevices 的 ref
|
||||||
|
useEffect(() => {
|
||||||
|
loadDevicesRef.current = loadDevices;
|
||||||
|
}, [loadDevices]);
|
||||||
|
|
||||||
// 首次加载和搜索
|
// 首次加载和搜索
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDevices(true);
|
loadDevices(true);
|
||||||
@@ -110,6 +122,56 @@ const Devices: React.FC = () => {
|
|||||||
return true;
|
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 () => {
|
const handleGetQr = async () => {
|
||||||
setQrLoading(true);
|
setQrLoading(true);
|
||||||
@@ -119,6 +181,8 @@ const Devices: React.FC = () => {
|
|||||||
if (!accountId) throw new Error("未获取到用户信息");
|
if (!accountId) throw new Error("未获取到用户信息");
|
||||||
const res = await fetchDeviceQRCode(accountId);
|
const res = await fetchDeviceQRCode(accountId);
|
||||||
setQrCode(res.qrCode);
|
setQrCode(res.qrCode);
|
||||||
|
// 获取二维码后开始轮询监听
|
||||||
|
startPolling();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Toast.show({ content: e.message || "获取二维码失败", position: "top" });
|
Toast.show({ content: e.message || "获取二维码失败", position: "top" });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -362,7 +426,11 @@ const Devices: React.FC = () => {
|
|||||||
{/* 添加设备弹窗 */}
|
{/* 添加设备弹窗 */}
|
||||||
<Popup
|
<Popup
|
||||||
visible={addVisible}
|
visible={addVisible}
|
||||||
onMaskClick={() => setAddVisible(false)}
|
onMaskClick={() => {
|
||||||
|
setAddVisible(false);
|
||||||
|
stopPolling();
|
||||||
|
setQrCode(null);
|
||||||
|
}}
|
||||||
bodyStyle={{
|
bodyStyle={{
|
||||||
borderTopLeftRadius: 16,
|
borderTopLeftRadius: 16,
|
||||||
borderTopRightRadius: 16,
|
borderTopRightRadius: 16,
|
||||||
@@ -403,6 +471,13 @@ const Devices: React.FC = () => {
|
|||||||
<div style={{ color: "#888", fontSize: 12, marginTop: 8 }}>
|
<div style={{ color: "#888", fontSize: 12, marginTop: 8 }}>
|
||||||
请用手机扫码添加设备
|
请用手机扫码添加设备
|
||||||
</div>
|
</div>
|
||||||
|
{isPolling && (
|
||||||
|
<div
|
||||||
|
style={{ color: "#1890ff", fontSize: 12, marginTop: 8 }}
|
||||||
|
>
|
||||||
|
正在监听设备添加状态...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import request from "@/api/request";
|
|||||||
export interface PowerPackage {
|
export interface PowerPackage {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
tokens: number; // 算力点数
|
tokens: number | string; // 算力点数(可能是字符串,如"2,800")
|
||||||
price: number; // 价格(分)
|
price: number; // 价格(分)
|
||||||
originalPrice: number; // 原价(分)
|
originalPrice: number; // 原价(分)
|
||||||
unitPrice: number; // 单价
|
unitPrice: number; // 单价
|
||||||
@@ -13,7 +13,7 @@ export interface PowerPackage {
|
|||||||
isRecommend: number; // 是否推荐
|
isRecommend: number; // 是否推荐
|
||||||
isHot: number; // 是否热门
|
isHot: number; // 是否热门
|
||||||
isVip: number; // 是否VIP
|
isVip: number; // 是否VIP
|
||||||
features: string[]; // 功能特性
|
features?: string[]; // 功能特性(可选)
|
||||||
description: string[]; // 描述关键词
|
description: string[]; // 描述关键词
|
||||||
status: number;
|
status: number;
|
||||||
createTime: string;
|
createTime: string;
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export interface Statistics {
|
|||||||
monthUsed: number; // 本月使用
|
monthUsed: number; // 本月使用
|
||||||
remainingTokens: number; // 剩余算力
|
remainingTokens: number; // 剩余算力
|
||||||
totalConsumed: number; // 总消耗
|
totalConsumed: number; // 总消耗
|
||||||
|
yesterdayUsed?: number; // 昨日消耗
|
||||||
|
historyConsumed?: number; // 历史消耗
|
||||||
|
estimatedDays?: number; // 预计可用天数
|
||||||
}
|
}
|
||||||
// 算力统计接口
|
// 算力统计接口
|
||||||
export function getStatistics(): Promise<Statistics> {
|
export function getStatistics(): Promise<Statistics> {
|
||||||
@@ -143,3 +146,56 @@ export function buyPackage(params: { id: number; price: number }) {
|
|||||||
export function buyCustomPower(params: { amount: number }) {
|
export function buyCustomPower(params: { amount: number }) {
|
||||||
return request("/v1/power/buy-custom", params, "POST");
|
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
@@ -36,6 +36,7 @@ export interface TokensUseRecordItem {
|
|||||||
|
|
||||||
export interface TokensUseRecordList {
|
export interface TokensUseRecordList {
|
||||||
list: TokensUseRecordItem[];
|
list: TokensUseRecordItem[];
|
||||||
|
total?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
//算力使用明细
|
//算力使用明细
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export function transferWechatFriends(params: {
|
|||||||
wechatId: string;
|
wechatId: string;
|
||||||
devices: number[];
|
devices: number[];
|
||||||
inherit: boolean;
|
inherit: boolean;
|
||||||
|
greeting?: string;
|
||||||
|
firstMessage?: string;
|
||||||
}) {
|
}) {
|
||||||
return request("/v1/wechats/transfer-friends", params, "POST");
|
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 blob = new Blob([response.data]);
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
@@ -114,6 +130,32 @@ export async function exportWechatMoments(params: {
|
|||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
} catch (error: any) {
|
} 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 || "导出失败");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,8 +339,8 @@
|
|||||||
height: 5px;
|
height: 5px;
|
||||||
border-radius: 10px 10px 0 0;
|
border-radius: 10px 10px 0 0;
|
||||||
background: #1677ff;
|
background: #1677ff;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.stat-icon-chat {
|
.stat-icon-chat {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
@@ -373,6 +373,12 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #52c41a;
|
color: #52c41a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-value-negative {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -700,7 +706,7 @@
|
|||||||
.adm-avatar {
|
.adm-avatar {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,13 +716,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.friend-name-row {
|
.friend-name-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.friend-name {
|
.friend-name {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #111;
|
color: #111;
|
||||||
@@ -727,7 +733,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.friend-tag {
|
.friend-tag {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -735,17 +741,17 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.friend-id-row {
|
.friend-id-row {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.friend-status-row {
|
.friend-status-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,7 +770,7 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #999;
|
color: #999;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.value-amount {
|
.value-amount {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -868,9 +874,10 @@
|
|||||||
.type-selector {
|
.type-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
width: 100%;
|
||||||
|
|
||||||
.type-option {
|
.type-option {
|
||||||
|
flex: 1;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid #e0e0e0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -879,6 +886,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
background: white;
|
background: white;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #1677ff;
|
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 {
|
.moments-content {
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
height: 500px;
|
height: 500px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #f5f5f5;
|
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 {
|
.moments-list {
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
|
|
||||||
@@ -1459,7 +1439,7 @@
|
|||||||
|
|
||||||
.image-grid {
|
.image-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 4px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
// 1张图片:宽度拉伸,高度自适应
|
// 1张图片:宽度拉伸,高度自适应
|
||||||
@@ -1469,56 +1449,57 @@
|
|||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
max-height: 400px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2张图片:左右并列
|
// 2张图片:左右并列,1:1比例
|
||||||
&.double {
|
&.double {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 120px;
|
aspect-ratio: 1 / 1;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3张图片:三张并列
|
// 3张图片:三张并列,1:1比例
|
||||||
&.triple {
|
&.triple {
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100px;
|
aspect-ratio: 1 / 1;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4张图片:2x2网格布局
|
// 4张图片:2x2网格布局,1:1比例
|
||||||
&.quad {
|
&.quad {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 140px;
|
aspect-ratio: 1 / 1;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5张及以上:网格布局(9宫格)
|
// 5张及以上:网格布局(9宫格),1:1比例
|
||||||
&.grid {
|
&.grid {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100px;
|
aspect-ratio: 1 / 1;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-more {
|
.image-more {
|
||||||
@@ -1526,11 +1507,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,18 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Switch,
|
Switch,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
|
InfiniteScroll,
|
||||||
} from "antd-mobile";
|
} from "antd-mobile";
|
||||||
import { Input, Pagination } from "antd";
|
import { Input } from "antd";
|
||||||
import NavCommon from "@/components/NavCommon";
|
import NavCommon from "@/components/NavCommon";
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import style from "./detail.module.scss";
|
import style from "./detail.module.scss";
|
||||||
@@ -32,6 +37,7 @@ import {
|
|||||||
} from "./api";
|
} from "./api";
|
||||||
import DeviceSelection from "@/components/DeviceSelection";
|
import DeviceSelection from "@/components/DeviceSelection";
|
||||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import { WechatAccountSummary, Friend, MomentItem } from "./data";
|
import { WechatAccountSummary, Friend, MomentItem } from "./data";
|
||||||
|
|
||||||
@@ -47,6 +53,8 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
const [showTransferConfirm, setShowTransferConfirm] = useState(false);
|
const [showTransferConfirm, setShowTransferConfirm] = useState(false);
|
||||||
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>([]);
|
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>([]);
|
||||||
const [inheritInfo, setInheritInfo] = useState(true);
|
const [inheritInfo, setInheritInfo] = useState(true);
|
||||||
|
const [greeting, setGreeting] = useState("");
|
||||||
|
const [firstMessage, setFirstMessage] = useState("");
|
||||||
const [transferLoading, setTransferLoading] = useState(false);
|
const [transferLoading, setTransferLoading] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState("overview");
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
@@ -105,6 +113,84 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [id]);
|
}, [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 () => {
|
const fetchOverviewData = useCallback(async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -120,7 +206,7 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
|
|
||||||
// 获取好友列表 - 封装为独立函数
|
// 获取好友列表 - 封装为独立函数
|
||||||
const fetchFriendsList = useCallback(
|
const fetchFriendsList = useCallback(
|
||||||
async (page: number = 1, keyword: string = "") => {
|
async (page: number = 1, keyword: string = "", append: boolean = false) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
setIsFetchingFriends(true);
|
setIsFetchingFriends(true);
|
||||||
@@ -130,7 +216,7 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
const response = await getWechatFriends({
|
const response = await getWechatFriends({
|
||||||
wechatAccount: id,
|
wechatAccount: id,
|
||||||
page: page,
|
page: page,
|
||||||
limit: 5,
|
limit: 20,
|
||||||
keyword: keyword,
|
keyword: keyword,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,15 +259,17 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
setFriends(newFriends);
|
setFriends(prev => (append ? [...prev, ...newFriends] : newFriends));
|
||||||
setFriendsTotal(response.total);
|
setFriendsTotal(response.total);
|
||||||
setFriendsPage(page);
|
setFriendsPage(page);
|
||||||
setIsFriendsEmpty(newFriends.length === 0);
|
setIsFriendsEmpty(newFriends.length === 0 && !append);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取好友列表失败:", error);
|
console.error("获取好友列表失败:", error);
|
||||||
setHasFriendLoadError(true);
|
setHasFriendLoadError(true);
|
||||||
setFriends([]);
|
if (!append) {
|
||||||
setIsFriendsEmpty(true);
|
setFriends([]);
|
||||||
|
setIsFriendsEmpty(true);
|
||||||
|
}
|
||||||
Toast.show({
|
Toast.show({
|
||||||
content: "获取好友列表失败,请检查网络连接",
|
content: "获取好友列表失败,请检查网络连接",
|
||||||
position: "top",
|
position: "top",
|
||||||
@@ -236,22 +324,20 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
// 搜索好友
|
// 搜索好友
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = useCallback(() => {
|
||||||
setFriendsPage(1);
|
setFriendsPage(1);
|
||||||
fetchFriendsList(1, searchQuery);
|
fetchFriendsList(1, searchQuery, false);
|
||||||
}, [searchQuery, fetchFriendsList]);
|
}, [searchQuery, fetchFriendsList]);
|
||||||
|
|
||||||
// 刷新好友列表
|
// 刷新好友列表
|
||||||
const handleRefreshFriends = useCallback(() => {
|
const handleRefreshFriends = useCallback(() => {
|
||||||
fetchFriendsList(friendsPage, searchQuery);
|
fetchFriendsList(friendsPage, searchQuery, false);
|
||||||
}, [friendsPage, searchQuery, fetchFriendsList]);
|
}, [friendsPage, searchQuery, fetchFriendsList]);
|
||||||
|
|
||||||
// 分页切换
|
// 加载更多好友
|
||||||
const handlePageChange = useCallback(
|
const handleLoadMoreFriends = async () => {
|
||||||
(page: number) => {
|
if (isFetchingFriends) return;
|
||||||
setFriendsPage(page);
|
if (friends.length >= friendsTotal) return;
|
||||||
fetchFriendsList(page, searchQuery);
|
await fetchFriendsList(friendsPage + 1, searchQuery, true);
|
||||||
},
|
};
|
||||||
[searchQuery, fetchFriendsList],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 初始化数据
|
// 初始化数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -266,7 +352,7 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
if (activeTab === "friends" && id) {
|
if (activeTab === "friends" && id) {
|
||||||
setIsFriendsEmpty(false);
|
setIsFriendsEmpty(false);
|
||||||
setHasFriendLoadError(false);
|
setHasFriendLoadError(false);
|
||||||
fetchFriendsList(1, searchQuery);
|
fetchFriendsList(1, searchQuery, false);
|
||||||
}
|
}
|
||||||
}, [activeTab, id, fetchFriendsList, searchQuery]);
|
}, [activeTab, id, fetchFriendsList, searchQuery]);
|
||||||
|
|
||||||
@@ -294,6 +380,10 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
const handleTransferFriends = () => {
|
const handleTransferFriends = () => {
|
||||||
setSelectedDevices([]);
|
setSelectedDevices([]);
|
||||||
setInheritInfo(true);
|
setInheritInfo(true);
|
||||||
|
// 设置默认打招呼内容,使用当前微信账号昵称
|
||||||
|
const nickname = accountInfo?.nickname || "未知";
|
||||||
|
setGreeting(`我是${nickname}的新号,请通过`);
|
||||||
|
setFirstMessage("这个是我的新号,重新加你一下,以后业务就用这个号!");
|
||||||
setShowTransferConfirm(true);
|
setShowTransferConfirm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -321,7 +411,9 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
await transferWechatFriends({
|
await transferWechatFriends({
|
||||||
wechatId: id,
|
wechatId: id,
|
||||||
devices: selectedDevices.map(device => device.id),
|
devices: selectedDevices.map(device => device.id),
|
||||||
inherit: inheritInfo
|
inherit: inheritInfo,
|
||||||
|
greeting: greeting.trim(),
|
||||||
|
firstMessage: firstMessage.trim()
|
||||||
});
|
});
|
||||||
|
|
||||||
Toast.show({
|
Toast.show({
|
||||||
@@ -330,6 +422,7 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
});
|
});
|
||||||
setShowTransferConfirm(false);
|
setShowTransferConfirm(false);
|
||||||
setSelectedDevices([]);
|
setSelectedDevices([]);
|
||||||
|
setFirstMessage("");
|
||||||
navigate("/scenarios");
|
navigate("/scenarios");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("好友转移失败:", error);
|
console.error("好友转移失败:", error);
|
||||||
@@ -376,10 +469,10 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
navigate(`/mine/traffic-pool/detail/${friend.wechatId}/${friend.id}`);
|
navigate(`/mine/traffic-pool/detail/${friend.wechatId}/${friend.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadMoreMoments = () => {
|
const handleLoadMoreMoments = async () => {
|
||||||
if (isFetchingMoments) return;
|
if (isFetchingMoments) return;
|
||||||
if (moments.length >= momentsTotal) return;
|
if (moments.length >= momentsTotal) return;
|
||||||
fetchMomentsList(momentsPage + 1, true);
|
await fetchMomentsList(momentsPage + 1, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理朋友圈导出
|
// 处理朋友圈导出
|
||||||
@@ -389,6 +482,18 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
return;
|
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);
|
setExportLoading(true);
|
||||||
try {
|
try {
|
||||||
// 格式化时间
|
// 格式化时间
|
||||||
@@ -409,18 +514,27 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Toast.show({ content: "导出成功", position: "top" });
|
Toast.show({ content: "导出成功", position: "top" });
|
||||||
setShowExportPopup(false);
|
// 重置筛选条件(先重置,再关闭弹窗)
|
||||||
// 重置筛选条件
|
|
||||||
setExportKeyword("");
|
setExportKeyword("");
|
||||||
setExportType(undefined);
|
setExportType(undefined);
|
||||||
setExportStartTime(null);
|
setExportStartTime(null);
|
||||||
setExportEndTime(null);
|
setExportEndTime(null);
|
||||||
|
setShowStartTimePicker(false);
|
||||||
|
setShowEndTimePicker(false);
|
||||||
|
// 延迟关闭弹窗,确保Toast显示
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowExportPopup(false);
|
||||||
|
}, 500);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("导出失败:", error);
|
console.error("导出失败:", error);
|
||||||
|
const errorMessage = error?.message || "导出失败,请重试";
|
||||||
Toast.show({
|
Toast.show({
|
||||||
content: error.message || "导出失败,请重试",
|
content: errorMessage,
|
||||||
position: "top",
|
position: "top",
|
||||||
|
duration: 2000,
|
||||||
});
|
});
|
||||||
|
// 确保loading状态被重置
|
||||||
|
setExportLoading(false);
|
||||||
} finally {
|
} finally {
|
||||||
setExportLoading(false);
|
setExportLoading(false);
|
||||||
}
|
}
|
||||||
@@ -539,7 +653,7 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
<div className={style["stat-icon-up"]}></div>
|
<div className={style["stat-icon-up"]}></div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style["stat-value"]}>
|
<div className={style["stat-value"]}>
|
||||||
{overviewData?.accountValue?.formatted || `¥${overviewData?.accountValue?.value || "29,800"}`}
|
{calculateAccountValue().formatted}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -549,8 +663,8 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
<div className={style["stat-title"]}>今日价值变化</div>
|
<div className={style["stat-title"]}>今日价值变化</div>
|
||||||
<div className={style["stat-icon-plus"]}></div>
|
<div className={style["stat-icon-plus"]}></div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style["stat-value-positive"]}>
|
<div className={calculateTodayValueChange().isPositive ? style["stat-value-positive"] : style["stat-value-negative"]}>
|
||||||
{overviewData?.todayValueChange?.formatted || `+${overviewData?.todayValueChange?.change || "500"}`}
|
{calculateTodayValueChange().formatted}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -752,7 +866,7 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
<div className={style["summary-item"]}>
|
<div className={style["summary-item"]}>
|
||||||
<div className={style["summary-label"]}>好友总估值</div>
|
<div className={style["summary-label"]}>好友总估值</div>
|
||||||
<div className={style["summary-value-highlight"]}>
|
<div className={style["summary-value-highlight"]}>
|
||||||
{overviewData?.accountValue?.formatted || "¥1,500,000"}
|
{calculateAccountValue().formatted}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -771,7 +885,7 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
fetchFriendsList(friendsPage, searchQuery)
|
fetchFriendsList(friendsPage, searchQuery, false)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
重试
|
重试
|
||||||
@@ -828,47 +942,58 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 分页组件 */}
|
{/* 无限滚动加载 */}
|
||||||
{friendsTotal > 20 &&
|
<InfiniteScroll
|
||||||
!isFriendsEmpty &&
|
loadMore={handleLoadMoreFriends}
|
||||||
!hasFriendLoadError && (
|
hasMore={friends.length < friendsTotal}
|
||||||
<div className={style["pagination-wrapper"]}>
|
threshold={100}
|
||||||
<Pagination
|
>
|
||||||
total={Math.ceil(friendsTotal / 20)}
|
{isFetchingFriends && friends.length > 0 && (
|
||||||
current={friendsPage}
|
<div className={style["friends-loading"]}>
|
||||||
onChange={handlePageChange}
|
<SpinLoading color="primary" style={{ fontSize: 16 }} />
|
||||||
/>
|
<span style={{ marginLeft: 8, color: "#999", fontSize: 12 }}>
|
||||||
|
加载中...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{friends.length >= friendsTotal && friends.length > 0 && (
|
||||||
|
<div className={style["friends-no-more"]}>
|
||||||
|
<span style={{ color: "#999", fontSize: 12 }}>没有更多了</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</InfiniteScroll>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
|
||||||
|
|
||||||
<Tabs.Tab title="朋友圈" key="moments">
|
<Tabs.Tab title="朋友圈" key="moments">
|
||||||
<div className={style["moments-content"]}>
|
{/* 功能按钮栏 - 移到白色背景上 */}
|
||||||
{/* 功能按钮栏 */}
|
<div className={style["moments-action-bar"]}>
|
||||||
<div className={style["moments-action-bar"]}>
|
<div className={style["action-button"]}>
|
||||||
<div className={style["action-button"]}>
|
<FileTextOutlined />
|
||||||
<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>
|
</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"]}>
|
<div className={style["moments-list"]}>
|
||||||
{isFetchingMoments && moments.length === 0 ? (
|
{isFetchingMoments && moments.length === 0 ? (
|
||||||
@@ -940,18 +1065,25 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{moments.length < momentsTotal && (
|
<InfiniteScroll
|
||||||
<div className={style["moments-load-more"]}>
|
loadMore={handleLoadMoreMoments}
|
||||||
<Button
|
hasMore={moments.length < momentsTotal}
|
||||||
size="small"
|
threshold={100}
|
||||||
onClick={handleLoadMoreMoments}
|
>
|
||||||
loading={isFetchingMoments}
|
{isFetchingMoments && moments.length > 0 && (
|
||||||
disabled={isFetchingMoments}
|
<div className={style["moments-loading"]}>
|
||||||
>
|
<SpinLoading color="primary" style={{ fontSize: 16 }} />
|
||||||
加载更多
|
<span style={{ marginLeft: 8, color: "#999", fontSize: 12 }}>
|
||||||
</Button>
|
加载中...
|
||||||
</div>
|
</span>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
{moments.length >= momentsTotal && moments.length > 0 && (
|
||||||
|
<div className={style["moments-no-more"]}>
|
||||||
|
<span style={{ color: "#999", fontSize: 12 }}>没有更多了</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</InfiniteScroll>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
|
||||||
@@ -1049,6 +1181,38 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className={style["popup-actions"]}>
|
<div className={style["popup-actions"]}>
|
||||||
@@ -1068,6 +1232,10 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowTransferConfirm(false);
|
setShowTransferConfirm(false);
|
||||||
setSelectedDevices([]);
|
setSelectedDevices([]);
|
||||||
|
// 重置为默认打招呼内容
|
||||||
|
const nickname = accountInfo?.nickname || "未知";
|
||||||
|
setGreeting(`这个是${nickname}的新号,之前那个号没用了,重新加一下您`);
|
||||||
|
setFirstMessage("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -1079,7 +1247,11 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
{/* 朋友圈导出弹窗 */}
|
{/* 朋友圈导出弹窗 */}
|
||||||
<Popup
|
<Popup
|
||||||
visible={showExportPopup}
|
visible={showExportPopup}
|
||||||
onMaskClick={() => setShowExportPopup(false)}
|
onMaskClick={() => {
|
||||||
|
setShowExportPopup(false);
|
||||||
|
setShowStartTimePicker(false);
|
||||||
|
setShowEndTimePicker(false);
|
||||||
|
}}
|
||||||
bodyStyle={{ borderRadius: "16px 16px 0 0" }}
|
bodyStyle={{ borderRadius: "16px 16px 0 0" }}
|
||||||
>
|
>
|
||||||
<div className={style["popup-content"]}>
|
<div className={style["popup-content"]}>
|
||||||
@@ -1088,7 +1260,11 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
fill="outline"
|
fill="outline"
|
||||||
onClick={() => setShowExportPopup(false)}
|
onClick={() => {
|
||||||
|
setShowExportPopup(false);
|
||||||
|
setShowStartTimePicker(false);
|
||||||
|
setShowEndTimePicker(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1162,11 +1338,13 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
visible={showStartTimePicker}
|
visible={showStartTimePicker}
|
||||||
title="开始时间"
|
title="开始时间"
|
||||||
value={exportStartTime}
|
value={exportStartTime}
|
||||||
|
max={exportEndTime || new Date()}
|
||||||
onClose={() => setShowStartTimePicker(false)}
|
onClose={() => setShowStartTimePicker(false)}
|
||||||
onConfirm={val => {
|
onConfirm={val => {
|
||||||
setExportStartTime(val);
|
setExportStartTime(val);
|
||||||
setShowStartTimePicker(false);
|
setShowStartTimePicker(false);
|
||||||
}}
|
}}
|
||||||
|
onCancel={() => setShowStartTimePicker(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1185,6 +1363,8 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
visible={showEndTimePicker}
|
visible={showEndTimePicker}
|
||||||
title="结束时间"
|
title="结束时间"
|
||||||
value={exportEndTime}
|
value={exportEndTime}
|
||||||
|
min={exportStartTime || undefined}
|
||||||
|
max={new Date()}
|
||||||
onClose={() => setShowEndTimePicker(false)}
|
onClose={() => setShowEndTimePicker(false)}
|
||||||
onConfirm={val => {
|
onConfirm={val => {
|
||||||
setExportEndTime(val);
|
setExportEndTime(val);
|
||||||
@@ -1214,6 +1394,8 @@ const WechatAccountDetail: React.FC = () => {
|
|||||||
setExportType(undefined);
|
setExportType(undefined);
|
||||||
setExportStartTime(null);
|
setExportStartTime(null);
|
||||||
setExportEndTime(null);
|
setExportEndTime(null);
|
||||||
|
setShowStartTimePicker(false);
|
||||||
|
setShowEndTimePicker(false);
|
||||||
}}
|
}}
|
||||||
style={{ marginTop: 12 }}
|
style={{ marginTop: 12 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -370,13 +370,13 @@ const ScenarioList: React.FC = () => {
|
|||||||
title={scenarioName || ""}
|
title={scenarioName || ""}
|
||||||
right={
|
right={
|
||||||
scenarioId !== "10" ? (
|
scenarioId !== "10" ? (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleCreateNewPlan}
|
onClick={handleCreateNewPlan}
|
||||||
>
|
>
|
||||||
<PlusOutlined /> 新建计划
|
<PlusOutlined /> 新建计划
|
||||||
</Button>
|
</Button>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -427,13 +427,13 @@ const ScenarioList: React.FC = () => {
|
|||||||
{searchTerm ? "没有找到匹配的计划" : "暂无计划"}
|
{searchTerm ? "没有找到匹配的计划" : "暂无计划"}
|
||||||
</div>
|
</div>
|
||||||
{scenarioId !== "10" && (
|
{scenarioId !== "10" && (
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleCreateNewPlan}
|
onClick={handleCreateNewPlan}
|
||||||
className={style["create-first-btn"]}
|
className={style["create-first-btn"]}
|
||||||
>
|
>
|
||||||
<PlusOutlined /> 创建第一个计划
|
<PlusOutlined /> 创建第一个计划
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -70,24 +70,72 @@ const PlanApi: React.FC<PlanApiProps> = ({
|
|||||||
|
|
||||||
// 处理webhook URL,确保包含完整的API地址
|
// 处理webhook URL,确保包含完整的API地址
|
||||||
const fullWebhookUrl = useMemo(() => {
|
const fullWebhookUrl = useMemo(() => {
|
||||||
return buildApiUrl(webhookUrl);
|
return buildApiUrl('');
|
||||||
}, [webhookUrl]);
|
}, [webhookUrl]);
|
||||||
|
|
||||||
// 生成测试URL
|
// 快速测试使用的 GET 地址(携带示例查询参数,方便在浏览器中直接访问)
|
||||||
const testUrl = useMemo(() => {
|
const testUrl = useMemo(() => {
|
||||||
if (!fullWebhookUrl) return "";
|
return buildApiUrl(webhookUrl);
|
||||||
return `${fullWebhookUrl}?name=测试客户&phone=13800138000&source=API测试`;
|
}, [webhookUrl]);
|
||||||
}, [fullWebhookUrl]);
|
|
||||||
|
|
||||||
// 检测是否为移动端
|
// 检测是否为移动端
|
||||||
const isMobile = window.innerWidth <= 768;
|
const isMobile = window.innerWidth <= 768;
|
||||||
|
|
||||||
const handleCopy = (text: string, type: string) => {
|
const handleCopy = (text: string, type: string) => {
|
||||||
navigator.clipboard.writeText(text);
|
// 先尝试使用 Clipboard API
|
||||||
Toast.show({
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
content: `${type}已复制到剪贴板`,
|
navigator.clipboard
|
||||||
position: "top",
|
.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 = () => {
|
const handleTestInBrowser = () => {
|
||||||
@@ -96,7 +144,7 @@ const PlanApi: React.FC<PlanApiProps> = ({
|
|||||||
|
|
||||||
const renderConfigTab = () => (
|
const renderConfigTab = () => (
|
||||||
<div className={style["config-content"]}>
|
<div className={style["config-content"]}>
|
||||||
{/* API密钥配置 */}
|
{/* 鉴权参数配置 */}
|
||||||
<div className={style["config-section"]}>
|
<div className={style["config-section"]}>
|
||||||
<div className={style["section-header"]}>
|
<div className={style["section-header"]}>
|
||||||
<div className={style["section-title"]}>
|
<div className={style["section-title"]}>
|
||||||
@@ -122,7 +170,7 @@ const PlanApi: React.FC<PlanApiProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 接口地址配置 */}
|
{/* 接口地址与参数说明 */}
|
||||||
<div className={style["config-section"]}>
|
<div className={style["config-section"]}>
|
||||||
<div className={style["section-header"]}>
|
<div className={style["section-header"]}>
|
||||||
<div className={style["section-title"]}>
|
<div className={style["section-title"]}>
|
||||||
@@ -150,27 +198,42 @@ const PlanApi: React.FC<PlanApiProps> = ({
|
|||||||
{/* 参数说明 */}
|
{/* 参数说明 */}
|
||||||
<div className={style["params-grid"]}>
|
<div className={style["params-grid"]}>
|
||||||
<div className={style["param-section"]}>
|
<div className={style["param-section"]}>
|
||||||
<h4>必要参数</h4>
|
<h4>鉴权参数(必填)</h4>
|
||||||
<div className={style["param-list"]}>
|
<div className={style["param-list"]}>
|
||||||
<div>
|
<div>
|
||||||
<code>name</code> - 客户姓名
|
<code>apiKey</code> - 分配给第三方的接口密钥(每个任务唯一)
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<code>phone</code> - 手机号码
|
<code>sign</code> - 签名值,按文档的签名规则生成
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<code>timestamp</code> - 秒级时间戳(与服务器时间差不超过 5 分钟)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style["param-section"]}>
|
<div className={style["param-section"]}>
|
||||||
<h4>可选参数</h4>
|
<h4>业务参数</h4>
|
||||||
<div className={style["param-list"]}>
|
<div className={style["param-list"]}>
|
||||||
<div>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<code>remark</code> - 备注信息
|
<code>remark</code> - 备注信息
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<code>tags</code> - 客户标签
|
<code>tags</code> - 微信标签,逗号分隔,如 <code>"高意向,电商,女装"</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<code>siteTags</code> - 站内标签,逗号分隔,用于站内进一步细分
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,93 +242,131 @@ const PlanApi: React.FC<PlanApiProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderQuickTestTab = () => (
|
const renderQuickTestTab = () => {
|
||||||
<div className={style["test-content"]}>
|
return (
|
||||||
<div className={style["test-section"]}>
|
<div className={style["test-content"]}>
|
||||||
<h3>快速测试URL</h3>
|
<div className={style["test-section"]}>
|
||||||
<div className={style["input-group"]}>
|
<h3>快速测试 URL(GET 示例)</h3>
|
||||||
<Input value={testUrl} disabled className={style["test-input"]} />
|
<div className={style["input-group"]}>
|
||||||
</div>
|
<Input value={testUrl} disabled className={style["test-input"]} />
|
||||||
<div className={style["test-buttons"]}>
|
</div>
|
||||||
<Button
|
<div className={style["test-buttons"]}>
|
||||||
onClick={() => handleCopy(testUrl, "测试URL")}
|
<Button
|
||||||
className={style["test-btn"]}
|
onClick={() => handleCopy(testUrl, "测试URL")}
|
||||||
>
|
className={style["test-btn"]}
|
||||||
<CopyOutlined />
|
>
|
||||||
复制测试URL
|
<CopyOutlined />
|
||||||
</Button>
|
复制测试URL
|
||||||
<Button
|
</Button>
|
||||||
type="primary"
|
<Button
|
||||||
onClick={handleTestInBrowser}
|
color="primary"
|
||||||
className={style["test-btn"]}
|
onClick={handleTestInBrowser}
|
||||||
>
|
className={style["test-btn"]}
|
||||||
在浏览器中测试
|
>
|
||||||
</Button>
|
在浏览器中测试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
const renderDocsTab = () => (
|
const renderDocsTab = () => {
|
||||||
<div className={style["docs-content"]}>
|
const docUrl = `${import.meta.env.VITE_API_BASE_URL}/doc/api_v1.md`;
|
||||||
<div className={style["docs-grid"]}>
|
|
||||||
<Card className={style["doc-card"]}>
|
return (
|
||||||
<div className={style["doc-icon"]}>
|
<div className={style["docs-content"]}>
|
||||||
<BookOutlined />
|
<div className={style["docs-grid"]}>
|
||||||
</div>
|
<Card className={style["doc-card"]}>
|
||||||
<h4>完整API文档</h4>
|
<div className={style["doc-icon"]}>
|
||||||
<p>详细的接口说明和参数文档</p>
|
<BookOutlined />
|
||||||
</Card>
|
</div>
|
||||||
<Card className={style["doc-card"]}>
|
<h4>对外获客线索上报接口文档(V1)</h4>
|
||||||
<div className={style["doc-icon"]}>
|
<p>点击下方按钮可直接在浏览器中打开最新的远程文档。</p>
|
||||||
<LinkOutlined />
|
<div className={style["doc-actions"]}>
|
||||||
</div>
|
<Button
|
||||||
<h4>集成指南</h4>
|
size="small"
|
||||||
<p>第三方平台集成教程</p>
|
onClick={() => {
|
||||||
</Card>
|
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>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
const renderCodeTab = () => {
|
const renderCodeTab = () => {
|
||||||
const codeExamples = {
|
const codeExamples = {
|
||||||
javascript: `fetch('${fullWebhookUrl}', {
|
javascript: `// 参考 api_v1 文档示例,使用 JSON 方式 POST
|
||||||
|
fetch('${fullWebhookUrl}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json'
|
||||||
'Authorization': 'Bearer ${apiKey}'
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: '张三',
|
apiKey: '${apiKey}',
|
||||||
|
timestamp: 1710000000, // 秒级时间戳
|
||||||
phone: '13800138000',
|
phone: '13800138000',
|
||||||
|
name: '张三',
|
||||||
source: '官网表单',
|
source: '官网表单',
|
||||||
|
remark: '通过H5表单提交',
|
||||||
|
tags: '高意向,电商',
|
||||||
|
siteTags: '新客,女装',
|
||||||
|
// sign 需要根据签名规则生成
|
||||||
|
sign: '根据签名规则生成的MD5字符串'
|
||||||
})
|
})
|
||||||
})`,
|
})`,
|
||||||
python: `import requests
|
python: `import requests
|
||||||
|
|
||||||
url = '${fullWebhookUrl}'
|
url = '${fullWebhookUrl}'
|
||||||
headers = {
|
headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json'
|
||||||
'Authorization': 'Bearer ${apiKey}'
|
|
||||||
}
|
}
|
||||||
data = {
|
data = {
|
||||||
'name': '张三',
|
"apiKey": "${apiKey}",
|
||||||
'phone': '13800138000',
|
"timestamp": 1710000000,
|
||||||
'source': '官网表单'
|
"phone": "13800138000",
|
||||||
|
"name": "张三",
|
||||||
|
"source": "官网表单",
|
||||||
|
"remark": "通过H5表单提交",
|
||||||
|
"tags": "高意向,电商",
|
||||||
|
"siteTags": "新客,女装",
|
||||||
|
# sign 需要根据签名规则生成
|
||||||
|
"sign": "根据签名规则生成的MD5字符串"
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(url, json=data, headers=headers)`,
|
response = requests.post(url, json=data, headers=headers)`,
|
||||||
php: `<?php
|
php: `<?php
|
||||||
$url = '${fullWebhookUrl}';
|
$url = '${fullWebhookUrl}';
|
||||||
$data = array(
|
$data = array(
|
||||||
'name' => '张三',
|
'apiKey' => '${apiKey}',
|
||||||
|
'timestamp' => 1710000000,
|
||||||
'phone' => '13800138000',
|
'phone' => '13800138000',
|
||||||
'source' => '官网表单'
|
'name' => '张三',
|
||||||
|
'source' => '官网表单',
|
||||||
|
'remark' => '通过H5表单提交',
|
||||||
|
'tags' => '高意向,电商',
|
||||||
|
'siteTags' => '新客,女装',
|
||||||
|
// sign 需要根据签名规则生成
|
||||||
|
'sign' => '根据签名规则生成的MD5字符串'
|
||||||
);
|
);
|
||||||
|
|
||||||
$options = array(
|
$options = array(
|
||||||
'http' => array(
|
'http' => array(
|
||||||
'header' => "Content-type: application/json\\r\\nAuthorization: Bearer ${apiKey}\\r\\n",
|
'header' => "Content-type: application/json\\r\\n",
|
||||||
'method' => 'POST',
|
'method' => 'POST',
|
||||||
'content' => json_encode($data)
|
'content' => json_encode($data)
|
||||||
)
|
)
|
||||||
@@ -279,12 +380,11 @@ import java.net.http.HttpResponse;
|
|||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
|
||||||
HttpClient client = HttpClient.newHttpClient();
|
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()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create("${fullWebhookUrl}"))
|
.uri(URI.create("${fullWebhookUrl}"))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Authorization", "Bearer ${apiKey}")
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(json))
|
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -394,11 +494,7 @@ HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.o
|
|||||||
<SafetyOutlined />
|
<SafetyOutlined />
|
||||||
所有数据传输均采用HTTPS加密
|
所有数据传输均采用HTTPS加密
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button color="primary" onClick={onClose} className={style["complete-btn"]}>
|
||||||
type="primary"
|
|
||||||
onClick={onClose}
|
|
||||||
className={style["complete-btn"]}
|
|
||||||
>
|
|
||||||
完成配置
|
完成配置
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -7,24 +7,29 @@ import { createAutoGroup, updateAutoGroup, getAutoGroupDetail } from "./api";
|
|||||||
import { AutoGroupFormData, StepItem } from "./types";
|
import { AutoGroupFormData, StepItem } from "./types";
|
||||||
import StepIndicator from "@/components/StepIndicator";
|
import StepIndicator from "@/components/StepIndicator";
|
||||||
import BasicSettings, { BasicSettingsRef } from "./components/BasicSettings";
|
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 PoolSelector, { PoolSelectorRef } from "./components/PoolSelector";
|
||||||
import NavCommon from "@/components/NavCommon/index";
|
import NavCommon from "@/components/NavCommon/index";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||||
|
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||||
import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
||||||
|
|
||||||
const steps: StepItem[] = [
|
const steps: StepItem[] = [
|
||||||
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
|
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
|
||||||
{ id: 2, title: "步骤 2", subtitle: "选择设备" },
|
{ id: 2, title: "步骤 2", subtitle: "选择群主和管理员" },
|
||||||
{ id: 3, title: "步骤 3", subtitle: "选择流量池包" },
|
{ id: 3, title: "步骤 3", subtitle: "选择流量池包" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const defaultForm: AutoGroupFormData = {
|
const defaultForm: AutoGroupFormData = {
|
||||||
name: "",
|
name: "",
|
||||||
type: 4,
|
type: 4,
|
||||||
deviceGroups: [], // 设备组
|
devices: [], // 群主ID列表
|
||||||
deviceGroupsOptions: [], // 设备组选项
|
devicesOptions: [], // 群主选项
|
||||||
|
admins: [], // 管理员ID列表
|
||||||
|
adminsOptions: [], // 管理员选项
|
||||||
poolGroups: [], // 内容库
|
poolGroups: [], // 内容库
|
||||||
poolGroupsOptions: [], // 内容库选项
|
poolGroupsOptions: [], // 内容库选项
|
||||||
startTime: dayjs().format("HH:mm"), // 开始时间 (HH:mm)
|
startTime: dayjs().format("HH:mm"), // 开始时间 (HH:mm)
|
||||||
@@ -45,16 +50,15 @@ const AutoGroupForm: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [dataLoaded, setDataLoaded] = useState(!isEdit); // 非编辑模式直接标记为已加载
|
const [dataLoaded, setDataLoaded] = useState(!isEdit); // 非编辑模式直接标记为已加载
|
||||||
const [formData, setFormData] = useState<AutoGroupFormData>(defaultForm);
|
const [formData, setFormData] = useState<AutoGroupFormData>(defaultForm);
|
||||||
const [deviceGroupsOptions, setDeviceGroupsOptions] = useState<
|
const [devicesOptions, setDevicesOptions] = useState<DeviceSelectionItem[]>([]);
|
||||||
DeviceSelectionItem[]
|
const [adminsOptions, setAdminsOptions] = useState<FriendSelectionItem[]>([]);
|
||||||
>([]);
|
|
||||||
const [poolGroupsOptions, setpoolGroupsOptions] = useState<
|
const [poolGroupsOptions, setpoolGroupsOptions] = useState<
|
||||||
PoolSelectionItem[]
|
PoolSelectionItem[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
// 创建子组件的ref
|
// 创建子组件的ref
|
||||||
const basicSettingsRef = useRef<BasicSettingsRef>(null);
|
const basicSettingsRef = useRef<BasicSettingsRef>(null);
|
||||||
const deviceSelectorRef = useRef<DeviceSelectorRef>(null);
|
const ownerAdminSelectorRef = useRef<OwnerAdminSelectorRef>(null);
|
||||||
const poolSelectorRef = useRef<PoolSelectorRef>(null);
|
const poolSelectorRef = useRef<PoolSelectorRef>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,8 +68,10 @@ const AutoGroupForm: React.FC = () => {
|
|||||||
const updatedForm = {
|
const updatedForm = {
|
||||||
...defaultForm,
|
...defaultForm,
|
||||||
name: res.name,
|
name: res.name,
|
||||||
deviceGroups: res.config.deviceGroups || [],
|
devices: res.config.deviceGroups || res.config.devices || [], // 兼容deviceGroups和devices
|
||||||
deviceGroupsOptions: res.config.deviceGroupsOptions || [],
|
devicesOptions: res.config.deviceGroupsOptions || res.config.devicesOptions || [], // 兼容deviceGroupsOptions和devicesOptions
|
||||||
|
admins: res.config.admins || [],
|
||||||
|
adminsOptions: res.config.adminsOptions || [],
|
||||||
poolGroups: res.config.poolGroups || [],
|
poolGroups: res.config.poolGroups || [],
|
||||||
poolGroupsOptions: res.config.poolGroupsOptions || [],
|
poolGroupsOptions: res.config.poolGroupsOptions || [],
|
||||||
startTime: res.config.startTime,
|
startTime: res.config.startTime,
|
||||||
@@ -80,7 +86,8 @@ const AutoGroupForm: React.FC = () => {
|
|||||||
id: res.id,
|
id: res.id,
|
||||||
};
|
};
|
||||||
setFormData(updatedForm);
|
setFormData(updatedForm);
|
||||||
setDeviceGroupsOptions(res.config.deviceGroupsOptions || []);
|
setDevicesOptions(res.config.deviceGroupsOptions || res.config.devicesOptions || []); // 兼容deviceGroupsOptions和devicesOptions
|
||||||
|
setAdminsOptions(res.config.adminsOptions || []);
|
||||||
setpoolGroupsOptions(res.config.poolGroupsOptions || []);
|
setpoolGroupsOptions(res.config.poolGroupsOptions || []);
|
||||||
setDataLoaded(true); // 标记数据已加载
|
setDataLoaded(true); // 标记数据已加载
|
||||||
});
|
});
|
||||||
@@ -90,16 +97,20 @@ const AutoGroupForm: React.FC = () => {
|
|||||||
setFormData(prev => ({ ...prev, ...values }));
|
setFormData(prev => ({ ...prev, ...values }));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 设备组选择
|
// 群主和管理员选择
|
||||||
const handleDevicesChange = (data: {
|
const handleOwnerAdminChange = (data: {
|
||||||
deviceGroups: string[];
|
devices: string[];
|
||||||
deviceGroupsOptions: DeviceSelectionItem[];
|
devicesOptions: DeviceSelectionItem[];
|
||||||
|
admins: string[];
|
||||||
|
adminsOptions: FriendSelectionItem[];
|
||||||
}) => {
|
}) => {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...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: "请输入任务名称" });
|
Toast.show({ content: "请输入任务名称" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (formData.deviceGroups.length === 0) {
|
if (formData.devices.length === 0) {
|
||||||
Toast.show({ content: "请选择至少一个设备组" });
|
Toast.show({ content: "请选择一个群主" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formData.devices.length > 1) {
|
||||||
|
Toast.show({ content: "群主只能选择一个设备" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formData.admins.length === 0) {
|
||||||
|
Toast.show({ content: "请至少选择一个管理员" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (formData.poolGroups.length === 0) {
|
if (formData.poolGroups.length === 0) {
|
||||||
@@ -127,9 +146,13 @@ const AutoGroupForm: React.FC = () => {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
// 构建提交数据,将devices映射为deviceGroups
|
||||||
|
const { devices, devicesOptions, ...restFormData } = formData;
|
||||||
const submitData = {
|
const submitData = {
|
||||||
...formData,
|
...restFormData,
|
||||||
deviceGroupsOptions: deviceGroupsOptions,
|
deviceGroups: devices, // 设备ID数组,传输字段名为deviceGroups
|
||||||
|
deviceGroupsOptions: devicesOptions, // 设备完整信息,传输字段名为deviceGroupsOptions
|
||||||
|
adminsOptions: adminsOptions,
|
||||||
poolGroupsOptions: poolGroupsOptions,
|
poolGroupsOptions: poolGroupsOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,8 +196,9 @@ const AutoGroupForm: React.FC = () => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 2:
|
case 2:
|
||||||
// 调用 DeviceSelector 的表单校验
|
// 调用 OwnerAdminSelector 的表单校验
|
||||||
isValid = (await deviceSelectorRef.current?.validate()) || false;
|
isValid =
|
||||||
|
(await ownerAdminSelectorRef.current?.validate()) || false;
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
setCurrentStep(3);
|
setCurrentStep(3);
|
||||||
}
|
}
|
||||||
@@ -217,10 +241,11 @@ const AutoGroupForm: React.FC = () => {
|
|||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
return (
|
return (
|
||||||
<DeviceSelector
|
<OwnerAdminSelector
|
||||||
ref={deviceSelectorRef}
|
ref={ownerAdminSelectorRef}
|
||||||
selectedDevices={deviceGroupsOptions}
|
selectedOwners={devicesOptions}
|
||||||
onNext={handleDevicesChange}
|
selectedAdmins={adminsOptions}
|
||||||
|
onNext={handleOwnerAdminChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 3:
|
case 3:
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
|
||||||
import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
||||||
|
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||||
|
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||||
|
|
||||||
// 自动建群表单数据类型定义
|
// 自动建群表单数据类型定义
|
||||||
export interface AutoGroupFormData {
|
export interface AutoGroupFormData {
|
||||||
id?: string; // 任务ID
|
id?: string; // 任务ID
|
||||||
type: number; // 任务类型
|
type: number; // 任务类型
|
||||||
name: string; // 任务名称
|
name: string; // 任务名称
|
||||||
deviceGroups: string[]; // 设备组
|
devices: string[]; // 群主ID列表(设备ID)
|
||||||
deviceGroupsOptions: DeviceSelectionItem[]; // 设备组选项
|
devicesOptions: DeviceSelectionItem[]; // 群主选项(设备)
|
||||||
|
admins: string[]; // 管理员ID列表(好友ID)
|
||||||
|
adminsOptions: FriendSelectionItem[]; // 管理员选项(好友)
|
||||||
poolGroups: string[]; // 流量池
|
poolGroups: string[]; // 流量池
|
||||||
poolGroupsOptions: PoolSelectionItem[]; // 流量池选项
|
poolGroupsOptions: PoolSelectionItem[]; // 流量池选项
|
||||||
startTime: string; // 开始时间 (YYYY-MM-DD HH:mm:ss)
|
startTime: string; // 开始时间 (YYYY-MM-DD HH:mm:ss)
|
||||||
@@ -34,9 +37,13 @@ export const formValidationRules = {
|
|||||||
{ required: true, message: "请输入任务名称" },
|
{ required: true, message: "请输入任务名称" },
|
||||||
{ min: 2, max: 50, message: "任务名称长度应在2-50个字符之间" },
|
{ min: 2, max: 50, message: "任务名称长度应在2-50个字符之间" },
|
||||||
],
|
],
|
||||||
deviceGroups: [
|
devices: [
|
||||||
{ required: true, message: "请选择设备组" },
|
{ required: true, message: "请选择群主" },
|
||||||
{ type: "array", min: 1, message: "至少选择一个设备组" },
|
{ type: "array", min: 1, max: 1, message: "群主只能选择一个设备" },
|
||||||
|
],
|
||||||
|
admins: [
|
||||||
|
{ required: true, message: "请选择管理员" },
|
||||||
|
{ type: "array", min: 1, message: "至少选择一个管理员" },
|
||||||
],
|
],
|
||||||
poolGroups: [
|
poolGroups: [
|
||||||
{ required: true, message: "请选择内容库" },
|
{ required: true, message: "请选择内容库" },
|
||||||
|
|||||||
@@ -116,20 +116,38 @@ const AutoLike: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const Res: any = await fetchAutoLikeTasks();
|
const Res: any = await fetchAutoLikeTasks();
|
||||||
// 直接就是任务数组,无需再解包
|
// 数据在 data.list 中
|
||||||
const mappedTasks = Res?.list?.map((task: any) => ({
|
const taskList = Res?.data?.list || Res?.list || [];
|
||||||
...task,
|
const mappedTasks = taskList.map((task: any) => {
|
||||||
status: task.status || 2, // 默认为关闭状态
|
const config = task.config || {};
|
||||||
deviceCount: task.deviceCount || 0,
|
const friends = config.friends || [];
|
||||||
targetGroup: task.targetGroup || "全部好友",
|
const devices = config.devices || [];
|
||||||
likeInterval: task.likeInterval || 60,
|
|
||||||
maxLikesPerDay: task.maxLikesPerDay || 100,
|
// 判断目标人群:如果 friends 为空或未设置,表示选择全部好友
|
||||||
lastLikeTime: task.lastLikeTime || "暂无",
|
let targetGroup = "全部好友";
|
||||||
createTime: task.createTime || "",
|
if (friends.length > 0) {
|
||||||
updateTime: task.updateTime || "",
|
targetGroup = `${friends.length} 个好友`;
|
||||||
todayLikeCount: task.todayLikeCount || 0,
|
}
|
||||||
totalLikeCount: task.totalLikeCount || 0,
|
|
||||||
}));
|
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);
|
setTasks(mappedTasks);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取自动点赞任务失败:", error);
|
console.error("获取自动点赞任务失败:", error);
|
||||||
@@ -355,7 +373,7 @@ const AutoLike: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<span className={style["stats-label"]}>今日点赞:</span>
|
<span className={style["stats-label"]}>今日点赞:</span>
|
||||||
<span className={style["stats-value"]}>
|
<span className={style["stats-value"]}>
|
||||||
{task.lastLikeTime}
|
{task.todayLikeCount || 0}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={style["stats-item"]}>
|
<div className={style["stats-item"]}>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const NewAutoLike: React.FC = () => {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(isEditMode);
|
const [isLoading, setIsLoading] = useState(isEditMode);
|
||||||
const [autoEnabled, setAutoEnabled] = useState(false);
|
const [autoEnabled, setAutoEnabled] = useState(false);
|
||||||
|
const [selectAllFriends, setSelectAllFriends] = useState(false);
|
||||||
const [formData, setFormData] = useState<CreateLikeTaskData>({
|
const [formData, setFormData] = useState<CreateLikeTaskData>({
|
||||||
name: "",
|
name: "",
|
||||||
interval: 5,
|
interval: 5,
|
||||||
@@ -45,8 +46,8 @@ const NewAutoLike: React.FC = () => {
|
|||||||
contentTypes: ["text", "image", "video"],
|
contentTypes: ["text", "image", "video"],
|
||||||
deviceGroups: [],
|
deviceGroups: [],
|
||||||
deviceGroupsOptions: [],
|
deviceGroupsOptions: [],
|
||||||
friendsGroups: [],
|
wechatFriends: [],
|
||||||
friendsGroupsOptions: [],
|
wechatFriendsOptions: [],
|
||||||
targetTags: [],
|
targetTags: [],
|
||||||
friendMaxLikes: 10,
|
friendMaxLikes: 10,
|
||||||
enableFriendTags: false,
|
enableFriendTags: false,
|
||||||
@@ -74,8 +75,8 @@ const NewAutoLike: React.FC = () => {
|
|||||||
contentTypes: config.contentTypes || ["text", "image", "video"],
|
contentTypes: config.contentTypes || ["text", "image", "video"],
|
||||||
deviceGroups: config.deviceGroups || [],
|
deviceGroups: config.deviceGroups || [],
|
||||||
deviceGroupsOptions: config.deviceGroupsOptions || [],
|
deviceGroupsOptions: config.deviceGroupsOptions || [],
|
||||||
friendsGroups: config.friendsgroups || [],
|
wechatFriends: config.wechatFriends || [],
|
||||||
friendsGroupsOptions: config.friendsGroupsOptions || [],
|
wechatFriendsOptions: config.wechatFriendsOptions || [],
|
||||||
targetTags: config.targetTags || [],
|
targetTags: config.targetTags || [],
|
||||||
friendMaxLikes: config.friendMaxLikes || 10,
|
friendMaxLikes: config.friendMaxLikes || 10,
|
||||||
enableFriendTags: config.enableFriendTags || false,
|
enableFriendTags: config.enableFriendTags || false,
|
||||||
@@ -85,6 +86,10 @@ const NewAutoLike: React.FC = () => {
|
|||||||
(taskDetail as any).status === 1 ||
|
(taskDetail as any).status === 1 ||
|
||||||
(taskDetail as any).status === "running",
|
(taskDetail as any).status === "running",
|
||||||
);
|
);
|
||||||
|
// 如果 wechatFriends 为空或未设置,可能表示选择了全部好友
|
||||||
|
setSelectAllFriends(
|
||||||
|
!config.wechatFriends || config.wechatFriends.length === 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error("获取任务详情失败");
|
message.error("获取任务详情失败");
|
||||||
@@ -127,11 +132,19 @@ const NewAutoLike: React.FC = () => {
|
|||||||
}
|
}
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
// 如果选择了全部好友,提交时传空数组或特殊标识
|
||||||
|
const submitData = {
|
||||||
|
...formData,
|
||||||
|
wechatFriends: selectAllFriends ? [] : formData.wechatFriends,
|
||||||
|
wechatFriendsOptions: selectAllFriends ? [] : formData.wechatFriendsOptions,
|
||||||
|
selectAllFriends: selectAllFriends, // 添加标识字段
|
||||||
|
};
|
||||||
|
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
await updateAutoLikeTask({ ...formData, id });
|
await updateAutoLikeTask({ ...submitData, id });
|
||||||
message.success("更新成功");
|
message.success("更新成功");
|
||||||
} else {
|
} else {
|
||||||
await createAutoLikeTask(formData);
|
await createAutoLikeTask(submitData);
|
||||||
message.success("创建成功");
|
message.success("创建成功");
|
||||||
}
|
}
|
||||||
navigate("/workspace/auto-like");
|
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 = () => (
|
const renderStepIndicator = () => (
|
||||||
<StepIndicator steps={steps} currentStep={currentStep} />
|
<StepIndicator steps={steps} currentStep={currentStep} />
|
||||||
@@ -364,16 +399,39 @@ const NewAutoLike: React.FC = () => {
|
|||||||
const renderFriendSettings = () => (
|
const renderFriendSettings = () => (
|
||||||
<div className={style.basicSection}>
|
<div className={style.basicSection}>
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<FriendSelection
|
<div className={style.friendSelectionHeader}>
|
||||||
selectedOptions={formData.friendsGroupsOptions || []}
|
<div className={style.formLabel}>选择好友</div>
|
||||||
onSelect={friends =>
|
<Button
|
||||||
handleUpdateFormData({
|
type={selectAllFriends ? "primary" : "default"}
|
||||||
friendsGroups: friends.map(f => f.id),
|
size="small"
|
||||||
friendsGroupsOptions: friends,
|
onClick={handleSelectAllFriends}
|
||||||
})
|
disabled={!formData.deviceGroups || formData.deviceGroups.length === 0}
|
||||||
}
|
className={style.selectAllBtn}
|
||||||
deviceIds={formData.deviceGroups}
|
>
|
||||||
/>
|
{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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handlePrev}
|
onClick={handlePrev}
|
||||||
@@ -390,7 +448,8 @@ const NewAutoLike: React.FC = () => {
|
|||||||
size="large"
|
size="large"
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
disabled={
|
disabled={
|
||||||
!formData.friendsGroups || formData.friendsGroups.length === 0
|
!selectAllFriends &&
|
||||||
|
(!formData.wechatFriends || formData.wechatFriends.length === 0)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isEditMode ? "更新任务" : "创建任务"}
|
{isEditMode ? "更新任务" : "创建任务"}
|
||||||
|
|||||||
@@ -126,6 +126,13 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contentTypeBtn {
|
||||||
|
flex: 1;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.contentTypeTag {
|
.contentTypeTag {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -230,3 +237,50 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
min-width: 120px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
if (res) {
|
if (res) {
|
||||||
setFormData({
|
setFormData({
|
||||||
taskName: res.name,
|
taskName: res.name,
|
||||||
startTime: res.timeRange?.start || "06:00",
|
startTime: res.config?.startTime || "06:00",
|
||||||
endTime: res.timeRange?.end || "23:59",
|
endTime: res.config?.endTime || "23:59",
|
||||||
syncCount: res.config?.syncCount || res.syncCount || 5,
|
syncCount: res.config?.syncCount || res.syncCount || 5,
|
||||||
syncInterval: res.config?.syncInterval || res.syncInterval || 30,
|
syncInterval: res.config?.syncInterval || res.syncInterval || 30,
|
||||||
syncType: res.config?.syncType,
|
syncType: res.config?.syncType,
|
||||||
|
|||||||
@@ -33,14 +33,15 @@ export const getFullApiPath = (): string => {
|
|||||||
* - buildApiUrl('https://api.example.com/webhook/123') → 'https://api.example.com/webhook/123'
|
* - buildApiUrl('https://api.example.com/webhook/123') → 'https://api.example.com/webhook/123'
|
||||||
*/
|
*/
|
||||||
export const buildApiUrl = (path: string): string => {
|
export const buildApiUrl = (path: string): string => {
|
||||||
if (!path) return "";
|
const fullApiPath = getFullApiPath();
|
||||||
|
|
||||||
|
if (!path) return `${fullApiPath}`;
|
||||||
|
|
||||||
// 如果已经是完整的URL(包含http或https),直接返回
|
// 如果已经是完整的URL(包含http或https),直接返回
|
||||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullApiPath = getFullApiPath();
|
|
||||||
|
|
||||||
// 如果是相对路径,拼接完整API路径
|
// 如果是相对路径,拼接完整API路径
|
||||||
if (path.startsWith("/")) {
|
if (path.startsWith("/")) {
|
||||||
|
|||||||
11
Moncter/MCP/mcp.json
Normal file
11
Moncter/MCP/mcp.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
Moncter/MCP/数据库账户密码.md
Normal file
15
Moncter/MCP/数据库账户密码.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 账户密码
|
||||||
|
```
|
||||||
|
HOST 192.168.1.106
|
||||||
|
PORT 27017
|
||||||
|
ACCOUNT:ckb
|
||||||
|
DBNAME:ckb
|
||||||
|
PASSWROD:123456
|
||||||
|
```
|
||||||
|
|
||||||
|
==================
|
||||||
|
# 环境准备
|
||||||
|
需要安装一个全局环境否则无法运行
|
||||||
|
node版本为22.12.0
|
||||||
|
|
||||||
|
npm i mongodb-mcp-server -g
|
||||||
@@ -75,7 +75,7 @@ class MessageController extends BaseController
|
|||||||
'keyword' => '',
|
'keyword' => '',
|
||||||
'msgType' => '',
|
'msgType' => '',
|
||||||
'accountId' => '',
|
'accountId' => '',
|
||||||
'count' => 100,
|
'count' => 20,
|
||||||
'messageId' => '',
|
'messageId' => '',
|
||||||
'olderData' => true,
|
'olderData' => true,
|
||||||
'wechatAccountId' => $friend['wechatAccountId'],
|
'wechatAccountId' => $friend['wechatAccountId'],
|
||||||
@@ -238,7 +238,7 @@ class MessageController extends BaseController
|
|||||||
'keyword' => '',
|
'keyword' => '',
|
||||||
'msgType' =>'',
|
'msgType' =>'',
|
||||||
'accountId' => '',
|
'accountId' => '',
|
||||||
'count' => 100,
|
'count' => 20,
|
||||||
'messageId' => '',
|
'messageId' => '',
|
||||||
'olderData' => true,
|
'olderData' => true,
|
||||||
'wechatId' => '',
|
'wechatId' => '',
|
||||||
@@ -350,6 +350,12 @@ class MessageController extends BaseController
|
|||||||
{
|
{
|
||||||
// 检查消息是否已存在
|
// 检查消息是否已存在
|
||||||
$exists = WechatMessageModel::where('id', $item['id']) ->find();
|
$exists = WechatMessageModel::where('id', $item['id']) ->find();
|
||||||
|
|
||||||
|
if (!empty($exists) && $exists['sendStatus'] == 0){
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 将毫秒时间戳转换为秒级时间戳
|
// 将毫秒时间戳转换为秒级时间戳
|
||||||
$createTime = isset($item['createTime']) ? strtotime($item['createTime']) : null;
|
$createTime = isset($item['createTime']) ? strtotime($item['createTime']) : null;
|
||||||
$deleteTime = !empty($item['isDeleted']) ? strtotime($item['deleteTime']) : null;
|
$deleteTime = !empty($item['isDeleted']) ? strtotime($item['deleteTime']) : null;
|
||||||
@@ -391,9 +397,9 @@ class MessageController extends BaseController
|
|||||||
if (!empty($accountData)){
|
if (!empty($accountData)){
|
||||||
$account = new AccountController();
|
$account = new AccountController();
|
||||||
$account->getlist(['pageIndex' => 0,'pageSize' => 100,'departmentId' => $accountData['departmentId']]);
|
$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 (!empty($accountIds)){
|
||||||
if (!in_array($friend['accountId'],$accountData)){
|
if (!in_array($friend['accountId'],$accountIds)){
|
||||||
// 执行切换好友命令
|
// 执行切换好友命令
|
||||||
$randomKey = array_rand($accountIds, 1);
|
$randomKey = array_rand($accountIds, 1);
|
||||||
$toAccountId = $accountIds[$randomKey];
|
$toAccountId = $accountIds[$randomKey];
|
||||||
@@ -466,6 +472,9 @@ class MessageController extends BaseController
|
|||||||
// 检查消息是否已存在
|
// 检查消息是否已存在
|
||||||
$exists = WechatMessageModel::where('id', $item['id'])->find();
|
$exists = WechatMessageModel::where('id', $item['id'])->find();
|
||||||
|
|
||||||
|
if (!empty($exists) && $exists['sendStatus'] == 0){
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// 处理发送者信息
|
// 处理发送者信息
|
||||||
$sender = $item['sender'] ?? [];
|
$sender = $item['sender'] ?? [];
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class MomentsController extends BaseController
|
|||||||
// 获取授权token
|
// 获取授权token
|
||||||
$authorization = $this->authorization;
|
$authorization = $this->authorization;
|
||||||
if (empty($authorization)) {
|
if (empty($authorization)) {
|
||||||
return errorJson('缺少授权信息');
|
return json_encode(['msg' => '缺少授权信息','code' => 400]);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -42,18 +42,18 @@ class MomentsController extends BaseController
|
|||||||
|
|
||||||
// 必填参数验证
|
// 必填参数验证
|
||||||
if (empty($jobPublishWechatMomentsItems) || !is_array($jobPublishWechatMomentsItems)) {
|
if (empty($jobPublishWechatMomentsItems) || !is_array($jobPublishWechatMomentsItems)) {
|
||||||
return errorJson('至少需要选择一个发布账号');
|
return json_encode(['msg' => '至少需要选择一个发布账号','code' => 400]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据朋友圈类型验证必填字段
|
// 根据朋友圈类型验证必填字段
|
||||||
if ($momentContentType == 1 && empty($text)) { // 纯文本
|
if ($momentContentType == 1 && empty($text)) { // 纯文本
|
||||||
return errorJson('朋友圈内容不能为空');
|
return json_encode(['msg' => '朋友圈内容不能为空','code' => 400]);
|
||||||
} else if ($momentContentType == 2 && (empty($picUrlList) || empty($text))) { // 图片+文字
|
} else if ($momentContentType == 2 && (empty($picUrlList) || empty($text))) { // 图片+文字
|
||||||
return errorJson('朋友圈内容和图片不能为空');
|
return json_encode(['msg' => '朋友圈内容和图片不能为空','code' => 400]);
|
||||||
} else if ($momentContentType == 3 && (empty($videoUrl) || empty($text))) { // 视频+文字
|
} else if ($momentContentType == 3 && (empty($videoUrl) || empty($text))) { // 视频+文字
|
||||||
return errorJson('朋友圈内容和视频不能为空');
|
return json_encode(['msg' => '朋友圈内容和视频不能为空','code' => 400]);
|
||||||
} else if ($momentContentType == 4 && (empty($link) || empty($text))) { // 链接+文字
|
} 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');
|
$result = requestCurl($this->baseUrl . 'api/JobPublishWechatMoments/addJob', $params, 'POST', $header, 'json');
|
||||||
// 处理响应
|
// 处理响应
|
||||||
if (empty($result)) {
|
if (empty($result)) {
|
||||||
return successJson([], '朋友圈任务创建成功');
|
return json_encode(['msg' => '朋友圈任务创建成功','code' => 200]);
|
||||||
} else {
|
} else {
|
||||||
// 如果返回的是错误信息
|
// 如果返回的是错误信息
|
||||||
return errorJson($result);
|
return json_encode(['msg' => $result,'code' => 400]);
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return errorJson('发布朋友圈失败:' . $e->getMessage());
|
return json_encode(['msg' => '发布朋友圈失败','code' => 400]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,15 +34,22 @@ Route::group('v1/', function () {
|
|||||||
Route::get('list', 'app\chukebao\controller\AccountsController@getList'); // 获取账号列表
|
Route::get('list', 'app\chukebao\controller\AccountsController@getList'); // 获取账号列表
|
||||||
});
|
});
|
||||||
|
|
||||||
//客服相关
|
//消息相关
|
||||||
Route::group('message/', function () {
|
Route::group('message/', function () {
|
||||||
Route::get('list', 'app\chukebao\controller\MessageController@getList'); // 获取好友列表
|
Route::get('list', 'app\chukebao\controller\MessageController@getList'); // 获取好友列表
|
||||||
Route::get('readMessage', 'app\chukebao\controller\MessageController@readMessage'); // 读取消息
|
Route::get('readMessage', 'app\chukebao\controller\MessageController@readMessage'); // 读取消息
|
||||||
Route::get('details', 'app\chukebao\controller\MessageController@details'); // 消息详情
|
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'); // 移动分组(好友/群移动到指定分组)
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class AccountsController extends BaseController
|
|||||||
|
|
||||||
$query = Db::table('s2_company_account')
|
$query = Db::table('s2_company_account')
|
||||||
->alias('a')
|
->alias('a')
|
||||||
|
->join('users u', 'a.id = u.s2_accountId')
|
||||||
->where([
|
->where([
|
||||||
['a.departmentId', '=', $companyId],
|
['a.departmentId', '=', $companyId],
|
||||||
['a.status', '=', 0],
|
['a.status', '=', 0],
|
||||||
@@ -48,6 +49,7 @@ class AccountsController extends BaseController
|
|||||||
$total = (clone $query)->count();
|
$total = (clone $query)->count();
|
||||||
$list = $query->field([
|
$list = $query->field([
|
||||||
'a.id',
|
'a.id',
|
||||||
|
'u.id as uid',
|
||||||
'a.userName',
|
'a.userName',
|
||||||
'a.realName',
|
'a.realName',
|
||||||
'a.nickname',
|
'a.nickname',
|
||||||
|
|||||||
@@ -562,7 +562,7 @@ class AiChatController extends BaseController
|
|||||||
$data = [
|
$data = [
|
||||||
'tokens' => $tokenCount * 20,
|
'tokens' => $tokenCount * 20,
|
||||||
'type' => 0,
|
'type' => 0,
|
||||||
'form' => 1,
|
'form' => 13,
|
||||||
'wechatAccountId' => $params['wechatAccountId'],
|
'wechatAccountId' => $params['wechatAccountId'],
|
||||||
'friendIdOrGroupId' => $params['friendId'],
|
'friendIdOrGroupId' => $params['friendId'],
|
||||||
'remarks' => $remarks,
|
'remarks' => $remarks,
|
||||||
@@ -816,7 +816,7 @@ class AiChatController extends BaseController
|
|||||||
$data = [
|
$data = [
|
||||||
'tokens' => $res['data']['token'],
|
'tokens' => $res['data']['token'],
|
||||||
'type' => 0,
|
'type' => 0,
|
||||||
'form' => 1,
|
'form' => 13,
|
||||||
'wechatAccountId' => $wechatAccountId,
|
'wechatAccountId' => $wechatAccountId,
|
||||||
'friendIdOrGroupId' => $friendId,
|
'friendIdOrGroupId' => $friendId,
|
||||||
'remarks' => $remarks,
|
'remarks' => $remarks,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class DataProcessing extends BaseController
|
|||||||
'CmdChatroomOperate', //修改群信息 {chatroomName(群名)、announce(公告)、extra(公告)、wechatAccountId、wechatChatroomId}
|
'CmdChatroomOperate', //修改群信息 {chatroomName(群名)、announce(公告)、extra(公告)、wechatAccountId、wechatChatroomId}
|
||||||
'CmdNewMessage', //接收消息
|
'CmdNewMessage', //接收消息
|
||||||
'CmdSendMessageResult', //更新消息状态
|
'CmdSendMessageResult', //更新消息状态
|
||||||
|
'CmdPinToTop', //置顶
|
||||||
];
|
];
|
||||||
|
|
||||||
if (empty($type) || empty($wechatAccountId)) {
|
if (empty($type) || empty($wechatAccountId)) {
|
||||||
@@ -164,6 +165,41 @@ class DataProcessing extends BaseController
|
|||||||
|
|
||||||
$msg = '更新消息状态成功';
|
$msg = '更新消息状态成功';
|
||||||
break;
|
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);
|
return ResponseHelper::success('',$msg,$codee);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,24 @@
|
|||||||
|
|
||||||
namespace app\chukebao\controller;
|
namespace app\chukebao\controller;
|
||||||
|
|
||||||
|
use app\api\model\WechatMessageModel;
|
||||||
use app\chukebao\model\FriendSettings;
|
use app\chukebao\model\FriendSettings;
|
||||||
use library\ResponseHelper;
|
use library\ResponseHelper;
|
||||||
use think\Db;
|
use think\Db;
|
||||||
|
use think\facade\Env;
|
||||||
|
use app\common\service\AuthService;
|
||||||
|
|
||||||
class MessageController extends BaseController
|
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()
|
public function getList()
|
||||||
{
|
{
|
||||||
@@ -20,7 +32,7 @@ class MessageController extends BaseController
|
|||||||
|
|
||||||
$friends = Db::table('s2_wechat_friend')
|
$friends = Db::table('s2_wechat_friend')
|
||||||
->where(['accountId' => $accountId, 'isDeleted' => 0])
|
->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兼容的查询方式
|
// 优化后的查询:使用MySQL兼容的查询方式
|
||||||
$unionQuery = "
|
$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
|
FROM s2_wechat_chatroom wc
|
||||||
INNER JOIN s2_wechat_message m ON wc.id = m.wechatChatroomId AND m.type = 2
|
INNER JOIN s2_wechat_message m ON wc.id = m.wechatChatroomId AND m.type = 2
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
@@ -43,7 +55,7 @@ class MessageController extends BaseController
|
|||||||
WHERE wc.accountId = {$accountId} AND wc.isDeleted = 0
|
WHERE wc.accountId = {$accountId} AND wc.isDeleted = 0
|
||||||
)
|
)
|
||||||
UNION ALL
|
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
|
FROM s2_wechat_message m
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT wechatFriendId, MAX(wechatTime) as maxTime, MAX(id) as maxId
|
SELECT wechatFriendId, MAX(wechatTime) as maxTime, MAX(id) as maxId
|
||||||
@@ -64,7 +76,6 @@ class MessageController extends BaseController
|
|||||||
return $b['wechatTime'] <=> $a['wechatTime'];
|
return $b['wechatTime'] <=> $a['wechatTime'];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// 批量统计未读数量(isRead=0),按好友/群聊分别聚合
|
// 批量统计未读数量(isRead=0),按好友/群聊分别聚合
|
||||||
$friendIds = [];
|
$friendIds = [];
|
||||||
$chatroomIds = [];
|
$chatroomIds = [];
|
||||||
@@ -122,6 +133,7 @@ class MessageController extends BaseController
|
|||||||
$v['extendFields'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['extendFields'] : [];
|
$v['extendFields'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['extendFields'] : [];
|
||||||
$v['region'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['region'] : '';
|
$v['region'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['region'] : '';
|
||||||
$v['phone'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['phone'] : '';
|
$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) : [];
|
$v['labels'] = !empty($friends[$v['wechatFriendId']]) ? json_decode($friends[$v['wechatFriendId']]['labels'], true) : [];
|
||||||
|
|
||||||
$unreadCount = isset($friendUnreadMap[$v['wechatFriendId']]) ? (int)$friendUnreadMap[$v['wechatFriendId']] : 0;
|
$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['id'] = !empty($v['wechatFriendId']) ? $v['wechatFriendId'] : $v['wechatChatroomId'];
|
||||||
$v['config'] = [
|
$v['config'] = [
|
||||||
'top' => false,
|
'top' => !empty($v['isTop']) ? true : false,
|
||||||
'unreadCount' => $unreadCount,
|
'unreadCount' => $unreadCount,
|
||||||
'chat' => true,
|
'chat' => true,
|
||||||
'msgTime' => $v['wechatTime'],
|
'msgTime' => $v['wechatTime'],
|
||||||
@@ -150,7 +162,7 @@ class MessageController extends BaseController
|
|||||||
'wechatTime' => $wechatTime
|
'wechatTime' => $wechatTime
|
||||||
];
|
];
|
||||||
|
|
||||||
unset($v['wechatFriendId'], $v['wechatChatroomId']);
|
unset($v['wechatFriendId'], $v['wechatChatroomId'],$v['isTop']);
|
||||||
|
|
||||||
}
|
}
|
||||||
unset($v);
|
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()
|
public function details()
|
||||||
{
|
{
|
||||||
$wechatFriendId = $this->request->param('wechatFriendId', '');
|
$wechatFriendId = $this->request->param('wechatFriendId', '');
|
||||||
@@ -218,14 +334,143 @@ class MessageController extends BaseController
|
|||||||
$total = Db::table('s2_wechat_message')->where($where)->count();
|
$total = Db::table('s2_wechat_message')->where($where)->count();
|
||||||
$list = Db::table('s2_wechat_message')->where($where)->page($page, $limit)->order('id DESC')->select();
|
$list = Db::table('s2_wechat_message')->where($where)->page($page, $limit)->order('id DESC')->select();
|
||||||
|
|
||||||
|
// 检查消息是否有sendStatus字段,如果有且不为0,则请求线上最新接口
|
||||||
foreach ($list as $k => &$v) {
|
foreach ($list as $k => &$item) {
|
||||||
$v['wechatTime'] = !empty($v['wechatTime']) ? date('Y-m-d H:i:s', $v['wechatTime']) : '';
|
// 检查是否存在sendStatus字段且不为0(0表示已发送成功)
|
||||||
|
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]);
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,8 @@ class TokensRecordController extends BaseController
|
|||||||
$limit = $this->request->param('limit', 10);
|
$limit = $this->request->param('limit', 10);
|
||||||
$type = $this->request->param('type', '');
|
$type = $this->request->param('type', '');
|
||||||
$form = $this->request->param('form', '');
|
$form = $this->request->param('form', '');
|
||||||
|
$startTime = $this->request->param('startTime', '');
|
||||||
|
$endTime = $this->request->param('endTime', '');
|
||||||
$userId = $this->getUserInfo('id');
|
$userId = $this->getUserInfo('id');
|
||||||
$companyId = $this->getUserInfo('companyId');
|
$companyId = $this->getUserInfo('companyId');
|
||||||
|
|
||||||
@@ -32,6 +34,26 @@ class TokensRecordController extends BaseController
|
|||||||
$where[] = ['form','=',$form];
|
$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);
|
$query = TokensRecord::where($where);
|
||||||
$total = $query->count();
|
$total = $query->count();
|
||||||
@@ -78,9 +100,6 @@ class TokensRecordController extends BaseController
|
|||||||
return ResponseHelper::error('类型参数错误,0为减少,1为增加');
|
return ResponseHelper::error('类型参数错误,0为减少,1为增加');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!in_array($form, [0, 1, 2, 3, 4, 5])) {
|
|
||||||
return ResponseHelper::error('来源参数错误');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重试机制,最多重试3次
|
// 重试机制,最多重试3次
|
||||||
$maxRetries = 3;
|
$maxRetries = 3;
|
||||||
@@ -108,7 +127,7 @@ class TokensRecordController extends BaseController
|
|||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
try {
|
try {
|
||||||
// 使用悲观锁获取用户当前tokens余额,确保并发安全
|
// 使用悲观锁获取用户当前tokens余额,确保并发安全
|
||||||
$userInfo = TokensCompany::where('companyId', $companyId)->lock(true)->find();
|
$userInfo = TokensCompany::where(['companyId'=> $companyId,'userId' => $userId])->lock(true)->find();
|
||||||
if (!$userInfo) {
|
if (!$userInfo) {
|
||||||
throw new \Exception('用户不存在');
|
throw new \Exception('用户不存在');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,30 @@ class WechatChatroomController extends BaseController
|
|||||||
public function getList(){
|
public function getList(){
|
||||||
$page = $this->request->param('page', 1);
|
$page = $this->request->param('page', 1);
|
||||||
$limit = $this->request->param('limit', 10);
|
$limit = $this->request->param('limit', 10);
|
||||||
|
$keyword = $this->request->param('keyword', '');
|
||||||
|
$groupIds = $this->request->param('groupIds', '');
|
||||||
$accountId = $this->getUserInfo('s2_accountId');
|
$accountId = $this->getUserInfo('s2_accountId');
|
||||||
if (empty($accountId)){
|
if (empty($accountId)){
|
||||||
return ResponseHelper::error('请先登录');
|
return ResponseHelper::error('请先登录');
|
||||||
}
|
}
|
||||||
$query = Db::table('s2_wechat_chatroom')
|
$query = Db::table('s2_wechat_chatroom')
|
||||||
->where(['accountId' => $accountId,'isDeleted' => 0])
|
->where(['accountId' => $accountId,'isDeleted' => 0]);
|
||||||
->order('id desc');
|
|
||||||
|
// 关键字搜索:群昵称、微信号(这里使用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();
|
$total = $query->count();
|
||||||
$list = $query->page($page, $limit)->select();
|
$list = $query->page($page, $limit)->select();
|
||||||
|
|
||||||
@@ -168,7 +185,7 @@ class WechatChatroomController extends BaseController
|
|||||||
$data = [
|
$data = [
|
||||||
'tokens' => $res['data']['token'],
|
'tokens' => $res['data']['token'],
|
||||||
'type' => 0,
|
'type' => 0,
|
||||||
'form' => 3,
|
'form' => 14,
|
||||||
'wechatAccountId' => $wechatAccountId,
|
'wechatAccountId' => $wechatAccountId,
|
||||||
'friendIdOrGroupId' => $groupId,
|
'friendIdOrGroupId' => $groupId,
|
||||||
'remarks' => $remarks,
|
'remarks' => $remarks,
|
||||||
|
|||||||
@@ -13,13 +13,32 @@ class WechatFriendController extends BaseController
|
|||||||
{
|
{
|
||||||
$page = $this->request->param('page', 1);
|
$page = $this->request->param('page', 1);
|
||||||
$limit = $this->request->param('limit', 10);
|
$limit = $this->request->param('limit', 10);
|
||||||
|
$keyword = $this->request->param('keyword', '');
|
||||||
|
$groupIds = $this->request->param('groupIds', '');
|
||||||
$accountId = $this->getUserInfo('s2_accountId');
|
$accountId = $this->getUserInfo('s2_accountId');
|
||||||
if (empty($accountId)) {
|
if (empty($accountId)) {
|
||||||
return ResponseHelper::error('请先登录');
|
return ResponseHelper::error('请先登录');
|
||||||
}
|
}
|
||||||
$query = Db::table('s2_wechat_friend')
|
$query = Db::table('s2_wechat_friend')
|
||||||
->where(['accountId' => $accountId, 'isDeleted' => 0])
|
->where(['accountId' => $accountId, 'isDeleted' => 0]);
|
||||||
->order('id desc');
|
|
||||||
|
// 关键字搜索:昵称、备注、微信号
|
||||||
|
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();
|
$total = $query->count();
|
||||||
$list = $query->page($page, $limit)->select();
|
$list = $query->page($page, $limit)->select();
|
||||||
|
|
||||||
@@ -114,6 +133,7 @@ class WechatFriendController extends BaseController
|
|||||||
$requestData = $this->request->param();
|
$requestData = $this->request->param();
|
||||||
$updatableColumns = [
|
$updatableColumns = [
|
||||||
'phone',
|
'phone',
|
||||||
|
'conRemark',
|
||||||
];
|
];
|
||||||
$columnUpdates = [];
|
$columnUpdates = [];
|
||||||
|
|
||||||
|
|||||||
@@ -4,27 +4,32 @@ namespace app\chukebao\controller;
|
|||||||
|
|
||||||
use library\ResponseHelper;
|
use library\ResponseHelper;
|
||||||
use think\Db;
|
use think\Db;
|
||||||
|
use app\chukebao\model\ChatGroups;
|
||||||
|
|
||||||
class WechatGroupController extends BaseController
|
class WechatGroupController extends BaseController
|
||||||
{
|
{
|
||||||
|
|
||||||
public function getList(){
|
/**
|
||||||
|
* 获取分组列表
|
||||||
$accountId = $this->getUserInfo('s2_accountId');
|
* @return \think\response\Json
|
||||||
$userId = $this->getUserInfo('id');
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function getList()
|
||||||
|
{
|
||||||
|
// 公司维度分组,不强制校验 userId
|
||||||
$companyId = $this->getUserInfo('companyId');
|
$companyId = $this->getUserInfo('companyId');
|
||||||
|
|
||||||
$query = Db::table('s2_wechat_group')
|
$query = ChatGroups::where([
|
||||||
->where(function ($query) use ($accountId,$companyId) {
|
'companyId' => $companyId,
|
||||||
$query->where('accountId', $accountId)->whereOr('departmentId', $companyId);
|
'isDel' => 0,
|
||||||
})
|
])
|
||||||
->whereIn('groupType',[1,2])
|
->order('groupType desc,sort desc,id desc');
|
||||||
->order('groupType desc,sortIndex desc,id desc');
|
|
||||||
$list = $query->select();
|
|
||||||
$total = $query->count();
|
$total = $query->count();
|
||||||
|
$list = $query->select();
|
||||||
|
|
||||||
|
// 处理每个分组的数据
|
||||||
// 处理每个好友的数据
|
$list = is_array($list) ? $list : $list->toArray();
|
||||||
foreach ($list as $k => &$v) {
|
foreach ($list as $k => &$v) {
|
||||||
$v['createTime'] = !empty($v['createTime']) ? date('Y-m-d H:i:s', $v['createTime']) : '';
|
$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 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. 重置该分组下所有好友的分组ID(s2_wechat_friend.groupIds -> 0)
|
||||||
|
Db::table('s2_wechat_friend')
|
||||||
|
->where('groupIds', $id)
|
||||||
|
->update(['groupIds' => 0]);
|
||||||
|
|
||||||
|
// 3. 重置该分组下所有微信群的分组ID(s2_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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
16
Server/application/chukebao/model/ChatGroups.php
Normal file
16
Server/application/chukebao/model/ChatGroups.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ class WorkbenchGroupCreateCommand extends Command
|
|||||||
|
|
||||||
// 检查队列是否已经在运行
|
// 检查队列是否已经在运行
|
||||||
$queueLockKey = "queue_lock:{$this->queueName}";
|
$queueLockKey = "queue_lock:{$this->queueName}";
|
||||||
|
Cache::rm($queueLockKey);
|
||||||
if (Cache::get($queueLockKey)) {
|
if (Cache::get($queueLockKey)) {
|
||||||
$output->writeln("队列 {$this->queueName} 已经在运行中,跳过执行");
|
$output->writeln("队列 {$this->queueName} 已经在运行中,跳过执行");
|
||||||
Log::warning("队列 {$this->queueName} 已经在运行中,跳过执行");
|
Log::warning("队列 {$this->queueName} 已经在运行中,跳过执行");
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ class Attachment extends Controller
|
|||||||
'data' => [
|
'data' => [
|
||||||
'id' => $existFile['id'],
|
'id' => $existFile['id'],
|
||||||
'name' => $existFile['name'],
|
'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' => [
|
'data' => [
|
||||||
'id' => $attachment->id,
|
'id' => $attachment->id,
|
||||||
'name' => $attachmentData['name'],
|
'name' => $attachmentData['name'],
|
||||||
'url' => $attachmentData['source']
|
'url' => $attachmentData['source'],
|
||||||
|
'size' => $attachmentData['size']
|
||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use app\common\util\PaymentUtil;
|
|||||||
use think\facade\Env;
|
use think\facade\Env;
|
||||||
use think\facade\Request;
|
use think\facade\Request;
|
||||||
use app\common\model\Order;
|
use app\common\model\Order;
|
||||||
|
use app\common\model\User;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 支付服务(内部调用)
|
* 支付服务(内部调用)
|
||||||
@@ -495,7 +496,14 @@ class PaymentService
|
|||||||
switch ($order['orderType']) {
|
switch ($order['orderType']) {
|
||||||
case 1:
|
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);
|
$goodsSpecs = json_decode($order->goodsSpecs, true);
|
||||||
if (!empty($token)) {
|
if (!empty($token)) {
|
||||||
$token->tokens = $token->tokens + $goodsSpecs['tokens'];
|
$token->tokens = $token->tokens + $goodsSpecs['tokens'];
|
||||||
@@ -504,8 +512,10 @@ class PaymentService
|
|||||||
$newTokens = $token->tokens;
|
$newTokens = $token->tokens;
|
||||||
} else {
|
} else {
|
||||||
$tokensCompany = new TokensCompany();
|
$tokensCompany = new TokensCompany();
|
||||||
|
$tokensCompany->userId = $order->userId;
|
||||||
$tokensCompany->companyId = $order->companyId;
|
$tokensCompany->companyId = $order->companyId;
|
||||||
$tokensCompany->tokens = $goodsSpecs['tokens'];
|
$tokensCompany->tokens = $goodsSpecs['tokens'];
|
||||||
|
$tokensCompany->isAdmin = $isAdmin;
|
||||||
$tokensCompany->createTime = time();
|
$tokensCompany->createTime = time();
|
||||||
$tokensCompany->updateTime = time();
|
$tokensCompany->updateTime = time();
|
||||||
$tokensCompany->save();
|
$tokensCompany->save();
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ Route::group('v1/', function () {
|
|||||||
Route::get('get-item-detail', 'app\cunkebao\controller\ContentLibraryController@getItemDetail'); // 获取内容库素材详情
|
Route::get('get-item-detail', 'app\cunkebao\controller\ContentLibraryController@getItemDetail'); // 获取内容库素材详情
|
||||||
Route::post('update-item', 'app\cunkebao\controller\ContentLibraryController@updateItem'); // 更新内容库素材
|
Route::post('update-item', 'app\cunkebao\controller\ContentLibraryController@updateItem'); // 更新内容库素材
|
||||||
Route::any('aiEditContent', 'app\cunkebao\controller\ContentLibraryController@aiEditContent');
|
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('queryOrder', 'app\cunkebao\controller\TokensController@queryOrder'); // 查询订单(扫码付款)
|
||||||
Route::get('orderList', 'app\cunkebao\controller\TokensController@getOrderList'); // 获取订单列表
|
Route::get('orderList', 'app\cunkebao\controller\TokensController@getOrderList'); // 获取订单列表
|
||||||
Route::get('statistics', 'app\cunkebao\controller\TokensController@getTokensStatistics'); // 获取算力统计
|
Route::get('statistics', 'app\cunkebao\controller\TokensController@getTokensStatistics'); // 获取算力统计
|
||||||
|
Route::post('allocate', 'app\cunkebao\controller\TokensController@allocateTokens'); // 分配token(仅管理员)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
namespace app\cunkebao\controller;
|
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\ContentLibrary;
|
||||||
use app\cunkebao\model\ContentItem;
|
use app\cunkebao\model\ContentItem;
|
||||||
use library\s2\titleFavicon;
|
use library\s2\titleFavicon;
|
||||||
@@ -12,6 +15,8 @@ use think\facade\Cache;
|
|||||||
use think\facade\Env;
|
use think\facade\Env;
|
||||||
use app\api\controller\AutomaticAssign;
|
use app\api\controller\AutomaticAssign;
|
||||||
use think\facade\Request;
|
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([]);
|
$keywordInclude = isset($param['keywordInclude']) ? json_encode($param['keywordInclude'], 256) : json_encode([]);
|
||||||
$keywordExclude = isset($param['keywordExclude']) ? json_encode($param['keywordExclude'], 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;
|
$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([]), // 选择的微信群
|
'sourceGroups' => $sourceType == 2 && isset($param['wechatGroups']) ? json_encode($param['wechatGroups']) : json_encode([]), // 选择的微信群
|
||||||
'groupMembers' => $sourceType == 2 && isset($param['groupMembers']) ? json_encode($param['groupMembers']) : json_encode([]), // 群组成员
|
'groupMembers' => $sourceType == 2 && isset($param['groupMembers']) ? json_encode($param['groupMembers']) : json_encode([]), // 群组成员
|
||||||
'catchType' => isset($param['catchType']) ? json_encode($param['catchType']) : json_encode([]), // 采集类型
|
'catchType' => isset($param['catchType']) ? json_encode($param['catchType']) : json_encode([]), // 采集类型
|
||||||
|
'devices' => $devices,
|
||||||
// 关键词配置
|
// 关键词配置
|
||||||
'keywordInclude' => $keywordInclude, // 包含的关键词
|
'keywordInclude' => $keywordInclude, // 包含的关键词
|
||||||
'keywordExclude' => $keywordExclude, // 排除的关键词
|
'keywordExclude' => $keywordExclude, // 排除的关键词
|
||||||
@@ -87,6 +94,8 @@ class ContentLibraryController extends Controller
|
|||||||
'timeEnd' => isset($param['endTime']) ? strtotime($param['endTime']) : 0, // 结束时间(转换为时间戳)
|
'timeEnd' => isset($param['endTime']) ? strtotime($param['endTime']) : 0, // 结束时间(转换为时间戳)
|
||||||
// 来源类型
|
// 来源类型
|
||||||
'sourceType' => $sourceType, // 1=好友,2=群,3=好友和群
|
'sourceType' => $sourceType, // 1=好友,2=群,3=好友和群
|
||||||
|
// 表单类型
|
||||||
|
'formType' => isset($param['formType']) ? intval($param['formType']) : 0, // 表单类型,默认为0
|
||||||
// 基础信息
|
// 基础信息
|
||||||
'status' => isset($param['status']) ? $param['status'] : 0, // 状态:0=禁用,1=启用
|
'status' => isset($param['status']) ? $param['status'] : 0, // 状态:0=禁用,1=启用
|
||||||
'userId' => $this->request->userInfo['id'],
|
'userId' => $this->request->userInfo['id'],
|
||||||
@@ -122,6 +131,7 @@ class ContentLibraryController extends Controller
|
|||||||
$limit = $this->request->param('limit', 10);
|
$limit = $this->request->param('limit', 10);
|
||||||
$keyword = $this->request->param('keyword', '');
|
$keyword = $this->request->param('keyword', '');
|
||||||
$sourceType = $this->request->param('sourceType', ''); // 来源类型,1=好友,2=群
|
$sourceType = $this->request->param('sourceType', ''); // 来源类型,1=好友,2=群
|
||||||
|
$formType = $this->request->param('formType', ''); // 表单类型筛选
|
||||||
$companyId = $this->request->userInfo['companyId'];
|
$companyId = $this->request->userInfo['companyId'];
|
||||||
$userId = $this->request->userInfo['id'];
|
$userId = $this->request->userInfo['id'];
|
||||||
$isAdmin = !empty($this->request->userInfo['isAdmin']);
|
$isAdmin = !empty($this->request->userInfo['isAdmin']);
|
||||||
@@ -147,12 +157,17 @@ class ContentLibraryController extends Controller
|
|||||||
$where[] = ['sourceType', '=', $sourceType];
|
$where[] = ['sourceType', '=', $sourceType];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加表单类型筛选
|
||||||
|
if ($formType !== '') {
|
||||||
|
$where[] = ['formType', '=', $formType];
|
||||||
|
}
|
||||||
|
|
||||||
// 获取总记录数
|
// 获取总记录数
|
||||||
$total = ContentLibrary::where($where)->count();
|
$total = ContentLibrary::where($where)->count();
|
||||||
|
|
||||||
// 获取分页数据
|
// 获取分页数据
|
||||||
$list = ContentLibrary::where($where)
|
$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) {
|
->with(['user' => function ($query) {
|
||||||
$query->field('id,username');
|
$query->field('id,username');
|
||||||
}])
|
}])
|
||||||
@@ -314,7 +329,7 @@ class ContentLibraryController extends Controller
|
|||||||
|
|
||||||
// 查询内容库信息
|
// 查询内容库信息
|
||||||
$library = ContentLibrary::where($where)
|
$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();
|
->find();
|
||||||
|
|
||||||
if (empty($library)) {
|
if (empty($library)) {
|
||||||
@@ -322,13 +337,14 @@ class ContentLibraryController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理JSON字段转数组
|
// 处理JSON字段转数组
|
||||||
$library['friendsGroups'] = json_decode($library['sourceFriends'] ?: '[]', true);
|
$library['friendsGroups'] = json_decode($library['sourceFriends'] ?: [], true);
|
||||||
$library['wechatGroups'] = json_decode($library['sourceGroups'] ?: '[]', true);
|
$library['wechatGroups'] = json_decode($library['sourceGroups'] ?: [], true);
|
||||||
$library['keywordInclude'] = json_decode($library['keywordInclude'] ?: '[]', true);
|
$library['keywordInclude'] = json_decode($library['keywordInclude'] ?: [], true);
|
||||||
$library['keywordExclude'] = json_decode($library['keywordExclude'] ?: '[]', true);
|
$library['keywordExclude'] = json_decode($library['keywordExclude'] ?: [], true);
|
||||||
$library['groupMembers'] = json_decode($library['groupMembers'] ?: '[]', true);
|
$library['groupMembers'] = json_decode($library['groupMembers'] ?: [], true);
|
||||||
$library['catchType'] = json_decode($library['catchType'] ?: '[]', true);
|
$library['catchType'] = json_decode($library['catchType'] ?: [], true);
|
||||||
unset($library['sourceFriends'], $library['sourceGroups']);
|
$library['deviceGroups'] = json_decode($library['devices'] ?: [], true);
|
||||||
|
unset($library['sourceFriends'], $library['sourceGroups'],$library['devices']);
|
||||||
|
|
||||||
// 将时间戳转换为日期格式(精确到日)
|
// 将时间戳转换为日期格式(精确到日)
|
||||||
if (!empty($library['timeStart'])) {
|
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([
|
return json([
|
||||||
'code' => 200,
|
'code' => 200,
|
||||||
'msg' => '获取成功',
|
'msg' => '获取成功',
|
||||||
@@ -401,7 +443,8 @@ class ContentLibraryController extends Controller
|
|||||||
|
|
||||||
$where = [
|
$where = [
|
||||||
['companyId', '=', $this->request->userInfo['companyId']],
|
['companyId', '=', $this->request->userInfo['companyId']],
|
||||||
['isDel', '=', 0] // 只查询未删除的记录
|
['isDel', '=', 0], // 只查询未删除的记录
|
||||||
|
['id', '=', $param['id']]
|
||||||
];
|
];
|
||||||
|
|
||||||
if (empty($this->request->userInfo['isAdmin'])) {
|
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([]);
|
$keywordInclude = isset($param['keywordInclude']) ? json_encode($param['keywordInclude'], 256) : json_encode([]);
|
||||||
$keywordExclude = isset($param['keywordExclude']) ? json_encode($param['keywordExclude'], 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'];
|
$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->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->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->catchType = isset($param['catchType']) ? json_encode($param['catchType']) : json_encode([]);// 采集类型
|
||||||
|
$library->devices = $devices;
|
||||||
$library->keywordInclude = $keywordInclude;
|
$library->keywordInclude = $keywordInclude;
|
||||||
$library->keywordExclude = $keywordExclude;
|
$library->keywordExclude = $keywordExclude;
|
||||||
$library->aiEnabled = isset($param['aiEnabled']) ? $param['aiEnabled'] : 0;
|
$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->timeEnabled = isset($param['timeEnabled']) ? $param['timeEnabled'] : 0;
|
||||||
$library->timeStart = isset($param['startTime']) ? strtotime($param['startTime']) : 0;
|
$library->timeStart = isset($param['startTime']) ? strtotime($param['startTime']) : 0;
|
||||||
$library->timeEnd = isset($param['endTime']) ? strtotime($param['endTime']) : 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->status = isset($param['status']) ? $param['status'] : 0;
|
||||||
$library->updateTime = time();
|
$library->updateTime = time();
|
||||||
|
|
||||||
|
|
||||||
$library->save();
|
$library->save();
|
||||||
|
|
||||||
Db::commit();
|
Db::commit();
|
||||||
@@ -597,8 +641,8 @@ class ContentLibraryController extends Controller
|
|||||||
$item['content'] = !empty($item['contentAi']) ? $item['contentAi'] : $item['content'];
|
$item['content'] = !empty($item['contentAi']) ? $item['contentAi'] : $item['content'];
|
||||||
|
|
||||||
// 处理JSON字段
|
// 处理JSON字段
|
||||||
$item['resUrls'] = json_decode($item['resUrls'] ?: '[]', true);
|
$item['resUrls'] = json_decode($item['resUrls'] ?: [], true);
|
||||||
$item['urls'] = json_decode($item['urls'] ?: '[]', true);
|
$item['urls'] = json_decode($item['urls'] ?: [], true);
|
||||||
|
|
||||||
// 格式化时间
|
// 格式化时间
|
||||||
if (!empty($item['createMomentTime']) && is_numeric($item['createMomentTime'])) {
|
if (!empty($item['createMomentTime']) && is_numeric($item['createMomentTime'])) {
|
||||||
@@ -798,8 +842,8 @@ class ContentLibraryController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理JSON字段
|
// 处理JSON字段
|
||||||
$item['resUrls'] = json_decode($item['resUrls'] ?: '[]', true);
|
$item['resUrls'] = json_decode($item['resUrls'] ?: [], true);
|
||||||
$item['urls'] = json_decode($item['urls'] ?: '[]', true);
|
$item['urls'] = json_decode($item['urls'] ?: [], true);
|
||||||
|
|
||||||
// 添加内容类型的文字描述
|
// 添加内容类型的文字描述
|
||||||
$contentTypeMap = [
|
$contentTypeMap = [
|
||||||
@@ -1083,12 +1127,12 @@ class ContentLibraryController extends Controller
|
|||||||
// 预处理内容库数据
|
// 预处理内容库数据
|
||||||
foreach ($libraries as &$library) {
|
foreach ($libraries as &$library) {
|
||||||
// 解析JSON字段
|
// 解析JSON字段
|
||||||
$library['sourceFriends'] = json_decode($library['sourceFriends'] ?: '[]', true);
|
$library['sourceFriends'] = json_decode($library['sourceFriends'] ?: [], true);
|
||||||
$library['sourceGroups'] = json_decode($library['sourceGroups'] ?: '[]', true);
|
$library['sourceGroups'] = json_decode($library['sourceGroups'] ?: [], true);
|
||||||
$library['keywordInclude'] = json_decode($library['keywordInclude'] ?: '[]', true);
|
$library['keywordInclude'] = json_decode($library['keywordInclude'] ?: [], true);
|
||||||
$library['keywordExclude'] = json_decode($library['keywordExclude'] ?: '[]', true);
|
$library['keywordExclude'] = json_decode($library['keywordExclude'] ?: [], true);
|
||||||
$library['groupMembers'] = json_decode($library['groupMembers'] ?: '[]', true);
|
$library['groupMembers'] = json_decode($library['groupMembers'] ?: [], true);
|
||||||
$library['catchType'] = json_decode($library['catchType'] ?: '[]', true);
|
$library['catchType'] = json_decode($library['catchType'] ?: [], true);
|
||||||
}
|
}
|
||||||
unset($library); // 解除引用
|
unset($library); // 解除引用
|
||||||
|
|
||||||
@@ -2351,4 +2395,352 @@ class ContentLibraryController extends Controller
|
|||||||
return false;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ class StatsController extends Controller
|
|||||||
$where = [
|
$where = [
|
||||||
['departmentId','=',$this->request->userInfo['companyId']]
|
['departmentId','=',$this->request->userInfo['companyId']]
|
||||||
];
|
];
|
||||||
if (!empty($this->request->userInfo['isAdmin'])){
|
if (empty($this->request->userInfo['isAdmin'])){
|
||||||
$where[] = ['id','=',$this->request->userInfo['s2_accountId']];
|
$where[] = ['id','=',$this->request->userInfo['s2_accountId']];
|
||||||
}
|
}
|
||||||
$accounts = Db::table('s2_company_account')->where($where)->column('id');
|
$accounts = Db::table('s2_company_account')->where($where)->column('id');
|
||||||
@@ -407,7 +407,7 @@ class StatsController extends Controller
|
|||||||
$where = [
|
$where = [
|
||||||
['departmentId','=',$companyId]
|
['departmentId','=',$companyId]
|
||||||
];
|
];
|
||||||
if (!empty($this->request->userInfo['isAdmin'])){
|
if (empty($this->request->userInfo['isAdmin'])){
|
||||||
$where[] = ['id','=',$this->request->userInfo['s2_accountId']];
|
$where[] = ['id','=',$this->request->userInfo['s2_accountId']];
|
||||||
}
|
}
|
||||||
$accounts = Db::table('s2_company_account')->where($where)->column('id');
|
$accounts = Db::table('s2_company_account')->where($where)->column('id');
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ namespace app\cunkebao\controller;
|
|||||||
|
|
||||||
use app\common\controller\PaymentService;
|
use app\common\controller\PaymentService;
|
||||||
use app\common\model\Order;
|
use app\common\model\Order;
|
||||||
|
use app\common\model\User;
|
||||||
use app\cunkebao\model\TokensPackage;
|
use app\cunkebao\model\TokensPackage;
|
||||||
use app\chukebao\model\TokensCompany;
|
use app\chukebao\model\TokensCompany;
|
||||||
use app\chukebao\model\TokensRecord;
|
use app\chukebao\model\TokensRecord;
|
||||||
use library\ResponseHelper;
|
use library\ResponseHelper;
|
||||||
|
use think\Db;
|
||||||
use think\facade\Env;
|
use think\facade\Env;
|
||||||
|
|
||||||
class TokensController extends BaseController
|
class TokensController extends BaseController
|
||||||
@@ -72,7 +74,7 @@ class TokensController extends BaseController
|
|||||||
|
|
||||||
} else {
|
} else {
|
||||||
//获取配置的tokens比例
|
//获取配置的tokens比例
|
||||||
$tokens_multiple = Env::get('payment.tokens_multiple', 28);
|
$tokens_multiple = Env::get('payment.tokens_multiple', 20);
|
||||||
$specs = [
|
$specs = [
|
||||||
'id' => 0,
|
'id' => 0,
|
||||||
'name' => '自定义购买算力',
|
'name' => '自定义购买算力',
|
||||||
@@ -119,7 +121,7 @@ class TokensController extends BaseController
|
|||||||
return ResponseHelper::success($order, '订单已支付');
|
return ResponseHelper::success($order, '订单已支付');
|
||||||
} else {
|
} else {
|
||||||
$errorMsg = !empty($order['payInfo']) ? $order['payInfo'] : '订单未支付';
|
$errorMsg = !empty($order['payInfo']) ? $order['payInfo'] : '订单未支付';
|
||||||
return ResponseHelper::success($order,$errorMsg,400);
|
return ResponseHelper::success($order,$errorMsg);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return ResponseHelper::success($order, '订单已支付');
|
return ResponseHelper::success($order, '订单已支付');
|
||||||
@@ -140,6 +142,7 @@ class TokensController extends BaseController
|
|||||||
$status = $this->request->param('status', ''); // 订单状态筛选
|
$status = $this->request->param('status', ''); // 订单状态筛选
|
||||||
$keyword = $this->request->param('keyword', ''); // 关键词搜索(订单号)
|
$keyword = $this->request->param('keyword', ''); // 关键词搜索(订单号)
|
||||||
$orderType = $this->request->param('orderType', ''); // 订单类型筛选
|
$orderType = $this->request->param('orderType', ''); // 订单类型筛选
|
||||||
|
$payType = $this->request->param('payType', ''); // 支付类型筛选
|
||||||
$startTime = $this->request->param('startTime', ''); // 开始时间
|
$startTime = $this->request->param('startTime', ''); // 开始时间
|
||||||
$endTime = $this->request->param('endTime', ''); // 结束时间
|
$endTime = $this->request->param('endTime', ''); // 结束时间
|
||||||
|
|
||||||
@@ -148,6 +151,7 @@ class TokensController extends BaseController
|
|||||||
|
|
||||||
// 构建查询条件
|
// 构建查询条件
|
||||||
$where = [
|
$where = [
|
||||||
|
['userId', '=', $userId],
|
||||||
['companyId', '=', $companyId]
|
['companyId', '=', $companyId]
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -166,6 +170,11 @@ class TokensController extends BaseController
|
|||||||
$where[] = ['orderType', '=', $orderType];
|
$where[] = ['orderType', '=', $orderType];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 支付类型筛选
|
||||||
|
if($payType !== '') {
|
||||||
|
$where[] = ['payType', '=', $payType];
|
||||||
|
}
|
||||||
|
|
||||||
// 时间范围筛选
|
// 时间范围筛选
|
||||||
if (!empty($startTime)) {
|
if (!empty($startTime)) {
|
||||||
$where[] = ['createTime', '>=', strtotime($startTime)];
|
$where[] = ['createTime', '>=', strtotime($startTime)];
|
||||||
@@ -251,13 +260,14 @@ class TokensController extends BaseController
|
|||||||
public function getTokensStatistics()
|
public function getTokensStatistics()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$userId = $this->getUserInfo('id');
|
||||||
$companyId = $this->getUserInfo('companyId');
|
$companyId = $this->getUserInfo('companyId');
|
||||||
if (empty($companyId)) {
|
if (empty($companyId)) {
|
||||||
return ResponseHelper::error('公司信息获取失败');
|
return ResponseHelper::error('公司信息获取失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取公司算力余额
|
// 获取公司算力余额
|
||||||
$tokensCompany = TokensCompany::where('companyId', $companyId)->find();
|
$tokensCompany = TokensCompany::where(['companyId' => $companyId,'userId' => $userId])->find();
|
||||||
$remainingTokens = $tokensCompany ? intval($tokensCompany->tokens) : 0;
|
$remainingTokens = $tokensCompany ? intval($tokensCompany->tokens) : 0;
|
||||||
|
|
||||||
// 获取今日开始和结束时间戳
|
// 获取今日开始和结束时间戳
|
||||||
@@ -270,6 +280,7 @@ class TokensController extends BaseController
|
|||||||
|
|
||||||
// 统计今日消费(type=0表示消费)
|
// 统计今日消费(type=0表示消费)
|
||||||
$todayUsed = TokensRecord::where([
|
$todayUsed = TokensRecord::where([
|
||||||
|
['userId', '=', $userId],
|
||||||
['companyId', '=', $companyId],
|
['companyId', '=', $companyId],
|
||||||
['type', '=', 0], // 0为减少(消费)
|
['type', '=', 0], // 0为减少(消费)
|
||||||
['createTime', '>=', $todayStart],
|
['createTime', '>=', $todayStart],
|
||||||
@@ -279,6 +290,7 @@ class TokensController extends BaseController
|
|||||||
|
|
||||||
// 统计本月消费
|
// 统计本月消费
|
||||||
$monthUsed = TokensRecord::where([
|
$monthUsed = TokensRecord::where([
|
||||||
|
['userId', '=', $userId],
|
||||||
['companyId', '=', $companyId],
|
['companyId', '=', $companyId],
|
||||||
['type', '=', 0], // 0为减少(消费)
|
['type', '=', 0], // 0为减少(消费)
|
||||||
['createTime', '>=', $monthStart],
|
['createTime', '>=', $monthStart],
|
||||||
@@ -288,6 +300,7 @@ class TokensController extends BaseController
|
|||||||
|
|
||||||
// 计算总算力(当前剩余 + 历史总消费)
|
// 计算总算力(当前剩余 + 历史总消费)
|
||||||
$totalConsumed = TokensRecord::where([
|
$totalConsumed = TokensRecord::where([
|
||||||
|
['userId', '=', $userId],
|
||||||
['companyId', '=', $companyId],
|
['companyId', '=', $companyId],
|
||||||
['type', '=', 0]
|
['type', '=', 0]
|
||||||
])->sum('tokens');
|
])->sum('tokens');
|
||||||
@@ -295,21 +308,228 @@ class TokensController extends BaseController
|
|||||||
|
|
||||||
// 总充值算力
|
// 总充值算力
|
||||||
$totalRecharged = TokensRecord::where([
|
$totalRecharged = TokensRecord::where([
|
||||||
|
['userId', '=', $userId],
|
||||||
['companyId', '=', $companyId],
|
['companyId', '=', $companyId],
|
||||||
['type', '=', 1] // 1为增加(充值)
|
['type', '=', 1] // 1为增加(充值)
|
||||||
])->sum('tokens');
|
])->sum('tokens');
|
||||||
$totalRecharged = intval($totalRecharged);
|
$totalRecharged = intval($totalRecharged);
|
||||||
|
|
||||||
|
// 计算预计可用天数(基于过去一个月的平均消耗)
|
||||||
|
$estimatedDays = $this->calculateEstimatedDays($userId,$companyId, $remainingTokens);
|
||||||
|
|
||||||
return ResponseHelper::success([
|
return ResponseHelper::success([
|
||||||
'totalTokens' => $totalRecharged, // 总算力(累计充值)
|
'totalTokens' => $totalRecharged, // 总算力(累计充值)
|
||||||
'todayUsed' => $todayUsed, // 今日使用
|
'todayUsed' => $todayUsed, // 今日使用
|
||||||
'monthUsed' => $monthUsed, // 本月使用
|
'monthUsed' => $monthUsed, // 本月使用
|
||||||
'remainingTokens' => $remainingTokens, // 剩余算力
|
'remainingTokens' => $remainingTokens, // 剩余算力
|
||||||
'totalConsumed' => $totalConsumed, // 累计消费
|
'totalConsumed' => $totalConsumed, // 累计消费
|
||||||
|
'estimatedDays' => $estimatedDays, // 预计可用天数
|
||||||
], '获取成功');
|
], '获取成功');
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return ResponseHelper::error('获取算力统计失败:' . $e->getMessage());
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ class WorkbenchController extends Controller
|
|||||||
$config->endTime = $param['endTime'];
|
$config->endTime = $param['endTime'];
|
||||||
$config->contentTypes = json_encode($param['contentTypes']);
|
$config->contentTypes = json_encode($param['contentTypes']);
|
||||||
$config->devices = json_encode($param['deviceGroups']);
|
$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->targetGroups = json_encode($param['targetGroups']);
|
||||||
// $config->tagOperator = $param['tagOperator'];
|
// $config->tagOperator = $param['tagOperator'];
|
||||||
$config->friendMaxLikes = $param['friendMaxLikes'];
|
$config->friendMaxLikes = $param['friendMaxLikes'];
|
||||||
@@ -140,6 +140,7 @@ class WorkbenchController extends Controller
|
|||||||
$config->groupDescription = $param['groupDescription'];
|
$config->groupDescription = $param['groupDescription'];
|
||||||
$config->poolGroups = json_encode($param['poolGroups'] ?? []);
|
$config->poolGroups = json_encode($param['poolGroups'] ?? []);
|
||||||
$config->wechatGroups = json_encode($param['wechatGroups'] ?? []);
|
$config->wechatGroups = json_encode($param['wechatGroups'] ?? []);
|
||||||
|
$config->admins = json_encode($param['admins'] ?? [], JSON_UNESCAPED_UNICODE);
|
||||||
$config->createTime = time();
|
$config->createTime = time();
|
||||||
$config->updateTime = time();
|
$config->updateTime = time();
|
||||||
$config->save();
|
$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');
|
$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) {
|
'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) {
|
'importContact' => function ($query) {
|
||||||
$query->field('workbenchId,devices,pools,num,remarkType,remark,clearContact,startTime,endTime');
|
$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->devices = json_decode($item->config->devices, true);
|
||||||
$item->config->poolGroups = json_decode($item->config->poolGroups, true);
|
$item->config->poolGroups = json_decode($item->config->poolGroups, true);
|
||||||
$item->config->wechatGroups = json_decode($item->config->wechatGroups, 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);
|
unset($item->groupCreate, $item->group_create);
|
||||||
break;
|
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');
|
$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) {
|
'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) {
|
'importContact' => function ($query) {
|
||||||
$query->field('workbenchId,devices,pools,num,remarkType,remark,clearContact,startTime,endTime');
|
$query->field('workbenchId,devices,pools,num,remarkType,remark,clearContact,startTime,endTime');
|
||||||
@@ -490,7 +503,8 @@ class WorkbenchController extends Controller
|
|||||||
if (!empty($workbench->autoLike)) {
|
if (!empty($workbench->autoLike)) {
|
||||||
$workbench->config = $workbench->autoLike;
|
$workbench->config = $workbench->autoLike;
|
||||||
$workbench->config->deviceGroups = json_decode($workbench->config->devices, true);
|
$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->targetGroups = json_decode($workbench->config->targetGroups, true);
|
||||||
$workbench->config->contentTypes = json_decode($workbench->config->contentTypes, 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->deviceGroups = json_decode($workbench->config->devices, true);
|
||||||
$workbench->config->poolGroups = json_decode($workbench->config->poolGroups, true);
|
$workbench->config->poolGroups = json_decode($workbench->config->poolGroups, true);
|
||||||
$workbench->config->wechatGroups = json_decode($workbench->config->wechatGroups, 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);
|
unset($workbench->groupCreate, $workbench->group_create);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -689,7 +704,7 @@ class WorkbenchController extends Controller
|
|||||||
// 获取好友(当targetType=2时)
|
// 获取好友(当targetType=2时)
|
||||||
if (!empty($workbench->config->wechatFriends) && isset($workbench->config->targetType) && $workbench->config->targetType == 2) {
|
if (!empty($workbench->config->wechatFriends) && isset($workbench->config->targetType) && $workbench->config->targetType == 2) {
|
||||||
$friendList = Db::table('s2_wechat_friend')->alias('wf')
|
$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)
|
->where('wf.id', 'in', $workbench->config->wechatFriends)
|
||||||
->order('wf.id', 'desc')
|
->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')
|
->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 = [];
|
$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]);
|
return json(['code' => 200, 'msg' => '获取成功', 'data' => $workbench]);
|
||||||
}
|
}
|
||||||
@@ -819,7 +846,7 @@ class WorkbenchController extends Controller
|
|||||||
$config->endTime = $param['endTime'];
|
$config->endTime = $param['endTime'];
|
||||||
$config->contentTypes = json_encode($param['contentTypes']);
|
$config->contentTypes = json_encode($param['contentTypes']);
|
||||||
$config->devices = json_encode($param['deviceGroups']);
|
$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->targetGroups = json_encode($param['targetGroups']);
|
||||||
// $config->tagOperator = $param['tagOperator'];
|
// $config->tagOperator = $param['tagOperator'];
|
||||||
$config->friendMaxLikes = $param['friendMaxLikes'];
|
$config->friendMaxLikes = $param['friendMaxLikes'];
|
||||||
@@ -881,6 +908,7 @@ class WorkbenchController extends Controller
|
|||||||
$config->groupDescription = $param['groupDescription'];
|
$config->groupDescription = $param['groupDescription'];
|
||||||
$config->poolGroups = json_encode($param['poolGroups'] ?? []);
|
$config->poolGroups = json_encode($param['poolGroups'] ?? []);
|
||||||
$config->wechatGroups = json_encode($param['wechatGroups'] ?? []);
|
$config->wechatGroups = json_encode($param['wechatGroups'] ?? []);
|
||||||
|
$config->admins = json_encode($param['admins'] ?? [], JSON_UNESCAPED_UNICODE);
|
||||||
$config->updateTime = time();
|
$config->updateTime = time();
|
||||||
$config->save();
|
$config->save();
|
||||||
}
|
}
|
||||||
@@ -1108,6 +1136,7 @@ class WorkbenchController extends Controller
|
|||||||
$newConfig->groupDescription = $config->groupDescription;
|
$newConfig->groupDescription = $config->groupDescription;
|
||||||
$newConfig->poolGroups = $config->poolGroups;
|
$newConfig->poolGroups = $config->poolGroups;
|
||||||
$newConfig->wechatGroups = $config->wechatGroups;
|
$newConfig->wechatGroups = $config->wechatGroups;
|
||||||
|
$newConfig->admins = $config->admins ?? json_encode([], JSON_UNESCAPED_UNICODE);
|
||||||
$newConfig->createTime = time();
|
$newConfig->createTime = time();
|
||||||
$newConfig->updateTime = time();
|
$newConfig->updateTime = time();
|
||||||
$newConfig->save();
|
$newConfig->save();
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ class PostTransferFriends extends BaseController
|
|||||||
{
|
{
|
||||||
$wechatId = $this->request->param('wechatId', '');
|
$wechatId = $this->request->param('wechatId', '');
|
||||||
$inherit = $this->request->param('inherit', '');
|
$inherit = $this->request->param('inherit', '');
|
||||||
|
$greeting = $this->request->param('greeting', '');
|
||||||
|
$firstMessage = $this->request->param('firstMessage', '');
|
||||||
$devices = $this->request->param('devices', []);
|
$devices = $this->request->param('devices', []);
|
||||||
$companyId = $this->getUserInfo('companyId');
|
$companyId = $this->getUserInfo('companyId');
|
||||||
|
|
||||||
|
|
||||||
if (empty($wechatId)){
|
if (empty($wechatId)){
|
||||||
return ResponseHelper::error('迁移的微信不能为空');
|
return ResponseHelper::error('迁移的微信不能为空');
|
||||||
}
|
}
|
||||||
@@ -23,13 +26,16 @@ class PostTransferFriends extends BaseController
|
|||||||
if (empty($devices)){
|
if (empty($devices)){
|
||||||
return ResponseHelper::error('迁移的设备不能为空');
|
return ResponseHelper::error('迁移的设备不能为空');
|
||||||
}
|
}
|
||||||
|
if (empty($greeting)){
|
||||||
|
return ResponseHelper::error('打招呼不能为空');
|
||||||
|
}
|
||||||
if (!is_array($devices)){
|
if (!is_array($devices)){
|
||||||
return ResponseHelper::error('迁移的设备必须为数组');
|
return ResponseHelper::error('迁移的设备必须为数组');
|
||||||
}
|
}
|
||||||
|
|
||||||
$wechat = Db::name('wechat_customer')->alias('wc')
|
$wechat = Db::name('wechat_customer')->alias('wc')
|
||||||
->join('wechat_account wa', 'wc.wechatId = wa.wechatId')
|
->join('wechat_account wa', 'wc.wechatId = wa.wechatId')
|
||||||
->where(['wc.wechatId' => $wechatId, 'wc.companyId' => $companyId])
|
->where(['wc.wechatId' => $wechatId])
|
||||||
->field('wa.*')
|
->field('wa.*')
|
||||||
->find();
|
->find();
|
||||||
|
|
||||||
@@ -53,11 +59,6 @@ class PostTransferFriends extends BaseController
|
|||||||
'id' => 'poster-3',
|
'id' => 'poster-3',
|
||||||
'name' => '点击咨询',
|
'name' => '点击咨询',
|
||||||
'src' => 'https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%92%A8%E8%AF%A2-FTiyAMAPop2g9LvjLOLDz0VwPg3KVu.gif'
|
'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 = [
|
$reqConf = [
|
||||||
@@ -66,18 +67,38 @@ class PostTransferFriends extends BaseController
|
|||||||
'endTime' => '18:00',
|
'endTime' => '18:00',
|
||||||
'remarkType' => 'phone',
|
'remarkType' => 'phone',
|
||||||
'addFriendInterval' => 60,
|
'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');
|
$createAddFriendPlan = app('app\cunkebao\controller\plan\PostCreateAddFriendPlanV1Controller');
|
||||||
|
|
||||||
$taskId = Db::name('customer_acquisition_task')->insertGetId([
|
$taskId = Db::name('customer_acquisition_task')->insertGetId([
|
||||||
'name' => '迁移好友('. $wechat['nickname'] .')',
|
'name' => '迁移好友('. $wechat['nickname'] .')',
|
||||||
'sceneId' => 10,
|
'sceneId' => 10,
|
||||||
'sceneConf' => json_encode($sceneConf),
|
'sceneConf' => json_encode($sceneConf,256),
|
||||||
'reqConf' => json_encode($reqConf),
|
'reqConf' => json_encode($reqConf,256),
|
||||||
'tagConf' => json_encode([]),
|
'tagConf' => json_encode([]),
|
||||||
|
'msgConf' => json_encode($msgConf,256),
|
||||||
'userId' => $this->getUserInfo('id'),
|
'userId' => $this->getUserInfo('id'),
|
||||||
'companyId' => $companyId,
|
'companyId' => $companyId,
|
||||||
'status' => 0,
|
'status' => 0,
|
||||||
|
|||||||
155
Server/application/job/WorkbenchGroupCreateAdminFriendJob.php
Normal file
155
Server/application/job/WorkbenchGroupCreateAdminFriendJob.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ use app\cunkebao\model\Workbench;
|
|||||||
use app\cunkebao\model\WorkbenchGroupCreate;
|
use app\cunkebao\model\WorkbenchGroupCreate;
|
||||||
use app\api\model\WechatFriendModel as WechatFriend;
|
use app\api\model\WechatFriendModel as WechatFriend;
|
||||||
use app\api\model\WechatMomentsModel as WechatMoments;
|
use app\api\model\WechatMomentsModel as WechatMoments;
|
||||||
|
use app\common\model\DeviceWechatLogin as DeviceWechatLoginModel;
|
||||||
use think\facade\Log;
|
use think\facade\Log;
|
||||||
use think\facade\Env;
|
use think\facade\Env;
|
||||||
use think\Db;
|
use think\Db;
|
||||||
@@ -16,6 +17,7 @@ use think\facade\Config;
|
|||||||
use app\api\controller\MomentsController as Moments;
|
use app\api\controller\MomentsController as Moments;
|
||||||
use Workerman\Lib\Timer;
|
use Workerman\Lib\Timer;
|
||||||
use app\api\controller\WechatController;
|
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
|
* @throws \Exception
|
||||||
@@ -57,7 +76,7 @@ class WorkbenchGroupCreateJob
|
|||||||
public function execute()
|
public function execute()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
// 获取所有工作台
|
// 1. 查询启用了建群功能的数据
|
||||||
$workbenches = Workbench::where(['status' => 1, 'type' => 4, 'isDel' => 0])->order('id desc')->select();
|
$workbenches = Workbench::where(['status' => 1, 'type' => 4, 'isDel' => 0])->order('id desc')->select();
|
||||||
foreach ($workbenches as $workbench) {
|
foreach ($workbenches as $workbench) {
|
||||||
// 获取工作台配置
|
// 获取工作台配置
|
||||||
@@ -65,158 +84,356 @@ class WorkbenchGroupCreateJob
|
|||||||
if (!$config) {
|
if (!$config) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解析配置
|
||||||
$config['poolGroups'] = json_decode($config['poolGroups'], true);
|
$config['poolGroups'] = json_decode($config['poolGroups'], true);
|
||||||
$config['devices'] = json_decode($config['devices'], true);
|
$config['devices'] = json_decode($config['devices'], true);
|
||||||
|
$config['admins'] = json_decode($config['admins'] ?? '[]', true) ?: [];
|
||||||
|
|
||||||
if (empty($config['poolGroups']) || empty($config['devices'])) {
|
if (empty($config['poolGroups']) || empty($config['devices'])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
//群主及内部成员
|
$groupMember = [];
|
||||||
$groupMember = Db::name('device_wechat_login')->alias('dwl')
|
$wechatId = Db::name('device_wechat_login')
|
||||||
->join(['s2_wechat_account' => 'a'], 'dwl.wechatId = a.wechatId')
|
->whereIn('deviceId',$config['devices'])
|
||||||
->whereIn('dwl.deviceId', $config['devices'])
|
->order('id desc')
|
||||||
->group('a.id')
|
->value('wechatId');
|
||||||
->column('a.wechatId');
|
if (empty($wechatId)) {
|
||||||
if (empty($groupMember)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$groupMemberWechatId = Db::table('s2_wechat_friend')
|
$groupMember[] = $wechatId;
|
||||||
->where('ownerWechatId', $groupMember[0])
|
// 获取群主好友ID映射(所有群主的好友)
|
||||||
->whereIn('wechatId', $groupMember)
|
$groupMemberWechatId = [];
|
||||||
->column('id,wechatId');
|
$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)) {
|
if (empty($groupMemberWechatId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$groupMemberId = array_keys($groupMemberWechatId);
|
|
||||||
|
|
||||||
|
// 获取流量池用户
|
||||||
//流量池用户
|
|
||||||
$poolItem = Db::name('traffic_source_package_item')
|
$poolItem = Db::name('traffic_source_package_item')
|
||||||
->whereIn('packageId', $config['poolGroups'])
|
->whereIn('packageId', $config['poolGroups'])
|
||||||
->group('identifier')
|
->group('identifier')
|
||||||
->column('identifier');
|
->column('identifier');
|
||||||
|
|
||||||
|
|
||||||
if (empty($poolItem)) {
|
if (empty($poolItem)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
//群用户
|
// 获取已入群的用户(排除已成功入群的)
|
||||||
$groupUser = Db::name('workbench_group_create_item')
|
$groupUser = Db::name('workbench_group_create_item')
|
||||||
->where('workbenchId', $workbench->id)
|
->where('workbenchId', $workbench->id)
|
||||||
|
->where('status', 'in', [self::STATUS_SUCCESS, self::STATUS_ADMIN_FRIEND_ADDED])
|
||||||
->whereIn('wechatId', $poolItem)
|
->whereIn('wechatId', $poolItem)
|
||||||
->group('wechatId')
|
->group('wechatId')
|
||||||
->column('wechatId');
|
->column('wechatId');
|
||||||
|
// 待入群的用户
|
||||||
//待入群的用户
|
|
||||||
$joinUser = array_diff($poolItem, $groupUser);
|
$joinUser = array_diff($poolItem, $groupUser);
|
||||||
if (empty($joinUser)) {
|
if (empty($joinUser)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// 计算随机群人数(不包含管理员,只减去群主成员数)
|
||||||
//随机群人数
|
|
||||||
$groupRandNum = mt_rand($config['groupSizeMin'], $config['groupSizeMax']) - count($groupMember);
|
$groupRandNum = mt_rand($config['groupSizeMin'], $config['groupSizeMax']) - count($groupMember);
|
||||||
|
|
||||||
//待加入用户
|
// 分批处理待入群用户
|
||||||
$addGroupUser = [];
|
$addGroupUser = [];
|
||||||
$totalRows = count($joinUser);
|
$totalRows = count($joinUser);
|
||||||
for ($i = 0; $i < $totalRows; $i += $groupRandNum) {
|
for ($i = 0; $i < $totalRows; $i += $groupRandNum) {
|
||||||
$batchRows = array_slice($joinUser, $i, $groupRandNum);
|
$batchRows = array_slice($joinUser, $i, $groupRandNum);
|
||||||
if (!empty($batchRows)) {
|
if (!empty($batchRows)) {
|
||||||
$user = [];
|
$addGroupUser[] = $batchRows;
|
||||||
foreach ($batchRows as $row) {
|
|
||||||
$user[] = $row;
|
|
||||||
}
|
|
||||||
$addGroupUser[] = $user;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach ($addGroupUser as $key => $val) {
|
// 初始化WebSocket
|
||||||
//判断第一组用户是否满足创建群的条件
|
$toAccountId = '';
|
||||||
$friendIds = Db::name('wechat_friendship')->alias('f')
|
$username = Env::get('api.username2', '');
|
||||||
->join(['s2_wechat_account' => 'a'], 'f.ownerWechatId=a.wechatId')
|
$password = Env::get('api.password2', '');
|
||||||
->where('f.companyId', $workbench->companyId)
|
if (!empty($username) || !empty($password)) {
|
||||||
->whereIn('f.wechatId', $val)
|
$toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId');
|
||||||
->group('f.wechatId')
|
}
|
||||||
->column('f.id,f.wechatId,a.id as wechatAccountId');
|
$webSocket = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]);
|
||||||
|
|
||||||
// 整理数组:按wechatAccountId分组,值为对应的id数组
|
// 遍历每批用户
|
||||||
$groupedFriends = [];
|
foreach ($addGroupUser as $batchUsers) {
|
||||||
$wechatAccountIds = [];
|
$this->processBatchUsers($workbench, $config, $batchUsers, $groupMemberId, $groupMemberWechatId, $groupRandNum, $webSocket);
|
||||||
$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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error("消息群发任务异常: " . $e->getMessage());
|
Log::error("工作台建群任务异常: " . $e->getMessage());
|
||||||
throw $e;
|
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列表(用于验证管理员)
|
||||||
|
// 从群主成员的好友记录中提取所有群主的微信ID(ownerWechatId)
|
||||||
|
$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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取设备列表
|
* 获取设备列表
|
||||||
|
|||||||
109
Server/application/job/WorkbenchGroupCreateOwnerFriendJob.php
Normal file
109
Server/application/job/WorkbenchGroupCreateOwnerFriendJob.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
179
Server/application/job/WorkbenchGroupCreateRetryJob.php
Normal file
179
Server/application/job/WorkbenchGroupCreateRetryJob.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
248
Server/application/job/WorkbenchGroupCreateVerifyJob.php
Normal file
248
Server/application/job/WorkbenchGroupCreateVerifyJob.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -130,11 +130,14 @@ class WorkbenchMomentsJob
|
|||||||
|
|
||||||
// 3) 下发
|
// 3) 下发
|
||||||
$moments = new Moments();
|
$moments = new Moments();
|
||||||
$moments->addJob($sendData);
|
$res = $moments->addJob($sendData);
|
||||||
KfMoments::where(['id' => $val['id']])->update(['isSend' => 1]);
|
$res = json_decode($res, true);
|
||||||
|
if ($res['code'] == 200){
|
||||||
|
KfMoments::where(['id' => $val['id']])->update(['isSend' => 1]);
|
||||||
|
|
||||||
// 4) 统计
|
// 4) 统计
|
||||||
$this->incrementSendStats($companyId, $userId, $allowed);
|
$this->incrementSendStats($companyId, $userId, $allowed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error("朋友圈同步任务异常: " . $e->getMessage());
|
Log::error("朋友圈同步任务异常: " . $e->getMessage());
|
||||||
@@ -334,9 +337,12 @@ class WorkbenchMomentsJob
|
|||||||
];
|
];
|
||||||
// 发送朋友圈
|
// 发送朋友圈
|
||||||
$moments = new Moments();
|
$moments = new Moments();
|
||||||
$moments->addJob($data);
|
$res = $moments->addJob($data);
|
||||||
// 记录发送记录
|
$res = json_decode($res,true);
|
||||||
$this->recordSendHistory($workbench, $devices, $contentLibrary);
|
if ($res['code'] == 200){
|
||||||
|
// 记录发送记录
|
||||||
|
$this->recordSendHistory($workbench, $devices, $contentLibrary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -115,4 +115,64 @@ crontab -l
|
|||||||
- 本地: php think worker:server
|
- 本地: php think worker:server
|
||||||
- 线上: php think worker:server -d (自带守护进程,无需搭配Supervisor 之类的工具)
|
- 线上: php think worker:server -d (自带守护进程,无需搭配Supervisor 之类的工具)
|
||||||
- php think worker:server stop php think worker:server status
|
- 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
413
Server/public/doc/api_v1.md
Normal 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`。
|
||||||
|
|
||||||
|
---
|
||||||
135
Touchkebao/package-lock.json
generated
135
Touchkebao/package-lock.json
generated
@@ -10,6 +10,8 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.6.1",
|
"@ant-design/icons": "^5.6.1",
|
||||||
|
"@sentry/react": "^10.29.0",
|
||||||
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"antd": "^5.13.1",
|
"antd": "^5.13.1",
|
||||||
"antd-mobile": "^5.39.1",
|
"antd-mobile": "^5.39.1",
|
||||||
"antd-mobile-icons": "^0.3.0",
|
"antd-mobile-icons": "^0.3.0",
|
||||||
@@ -2025,6 +2027,124 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@sentry-internal/browser-utils": {
|
||||||
|
"version": "10.29.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@sentry-internal/browser-utils/-/browser-utils-10.29.0.tgz",
|
||||||
|
"integrity": "sha512-M3kycMY6f3KY9a8jDYac+yG0E3ZgWVWSxlOEC5MhYyX+g7mqxkwrb3LFQyuxSm/m+CCgMTCaPOOaB2twXP6EQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/core": "10.29.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry-internal/feedback": {
|
||||||
|
"version": "10.29.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@sentry-internal/feedback/-/feedback-10.29.0.tgz",
|
||||||
|
"integrity": "sha512-Y7IRsNeS99cEONu1mZWZc3HvbjNnu59Hgymm0swFFKbdgbCgdT6l85kn2oLsuq4Ew8Dw/pL/Sgpwsl9UgYFpUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/core": "10.29.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry-internal/replay": {
|
||||||
|
"version": "10.29.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@sentry-internal/replay/-/replay-10.29.0.tgz",
|
||||||
|
"integrity": "sha512-45NVw9PwB9TQ8z+xJ6G6Za+wmQ1RTA35heBSzR6U4bknj8LmA04k2iwnobvxCBEQXeLfcJEO1vFgagMoqMZMBw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry-internal/browser-utils": "10.29.0",
|
||||||
|
"@sentry/core": "10.29.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry-internal/replay-canvas": {
|
||||||
|
"version": "10.29.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@sentry-internal/replay-canvas/-/replay-canvas-10.29.0.tgz",
|
||||||
|
"integrity": "sha512-typY4JrpAQQGPuSyd/BD8+nNCbvTV2UVvKzr+iKgI0m1qc4Dz8tHZ4Nfais2Z8eYn/pL1kqVQN5ERTmJoYFdIw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry-internal/replay": "10.29.0",
|
||||||
|
"@sentry/core": "10.29.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/browser": {
|
||||||
|
"version": "10.29.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@sentry/browser/-/browser-10.29.0.tgz",
|
||||||
|
"integrity": "sha512-XdbyIR6F4qoR9Z1JCWTgunVcTJjS9p2Th+v4wYs4ME+ZdLC4tuKKmRgYg3YdSIWCn1CBfIgdI6wqETSf7H6Njw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry-internal/browser-utils": "10.29.0",
|
||||||
|
"@sentry-internal/feedback": "10.29.0",
|
||||||
|
"@sentry-internal/replay": "10.29.0",
|
||||||
|
"@sentry-internal/replay-canvas": "10.29.0",
|
||||||
|
"@sentry/core": "10.29.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/core": {
|
||||||
|
"version": "10.29.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@sentry/core/-/core-10.29.0.tgz",
|
||||||
|
"integrity": "sha512-olQ2DU9dA/Bwsz3PtA9KNXRMqBWRQSkPw+MxwWEoU1K1qtiM9L0j6lbEFb5iSY3d7WYD5MB+1d5COugjSBrHtw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/react": {
|
||||||
|
"version": "10.29.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@sentry/react/-/react-10.29.0.tgz",
|
||||||
|
"integrity": "sha512-YGaEUXubzil7qssD1koh1fyt0aS8tHB61/6+oNShJ6xZPg03AB42bNMr2/y8fIFx36kb3MiCA5sFoH/ubF0LnQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/browser": "10.29.0",
|
||||||
|
"@sentry/core": "10.29.0",
|
||||||
|
"hoist-non-react-statics": "^3.3.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/query-core": {
|
||||||
|
"version": "5.90.12",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.90.12.tgz",
|
||||||
|
"integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.90.12",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.90.12.tgz",
|
||||||
|
"integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.90.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -4397,6 +4517,21 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hoist-non-react-statics": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"react-is": "^16.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hoist-non-react-statics/node_modules/react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz",
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.6.1",
|
"@ant-design/icons": "^5.6.1",
|
||||||
|
"@sentry/react": "^10.29.0",
|
||||||
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"antd": "^5.13.1",
|
"antd": "^5.13.1",
|
||||||
"antd-mobile": "^5.39.1",
|
"antd-mobile": "^5.39.1",
|
||||||
"antd-mobile-icons": "^0.3.0",
|
"antd-mobile-icons": "^0.3.0",
|
||||||
|
|||||||
1300
Touchkebao/pnpm-lock.yaml
generated
1300
Touchkebao/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
'postcss-pxtorem': {
|
"postcss-pxtorem": {
|
||||||
rootValue: 16,
|
rootValue: 16,
|
||||||
propList: ['*'],
|
propList: ["*"],
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import * as Sentry from "@sentry/react";
|
||||||
import AppRouter from "@/router";
|
import AppRouter from "@/router";
|
||||||
import UpdateNotification from "@/components/UpdateNotification";
|
import UpdateNotification from "@/components/UpdateNotification";
|
||||||
|
|
||||||
|
const ErrorFallback = () => (
|
||||||
|
<div style={{ padding: "20px", textAlign: "center" }}>
|
||||||
|
<h2>出现了一些问题</h2>
|
||||||
|
<p>我们已经记录了这个问题,正在修复中...</p>
|
||||||
|
<button onClick={() => window.location.reload()}>刷新页面</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
<Sentry.ErrorBoundary fallback={ErrorFallback}>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
<UpdateNotification position="top" autoReload={false} showToast={true} />
|
<UpdateNotification position="top" autoReload={false} showToast={true} />
|
||||||
</>
|
</Sentry.ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import axios from "axios";
|
|||||||
import { useUserStore } from "@/store/module/user";
|
import { useUserStore } from "@/store/module/user";
|
||||||
import { request } from "@/api/request";
|
import { request } from "@/api/request";
|
||||||
|
|
||||||
|
export function asyncMessageStatus(params: {
|
||||||
|
messageId: number;
|
||||||
|
wechatFriendId?: number;
|
||||||
|
wechatChatroomId?: number;
|
||||||
|
wechatAccountId: number;
|
||||||
|
}) {
|
||||||
|
return request("/v1/kefu/message/getMessageStatus", params, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
//ai对话接口
|
//ai对话接口
|
||||||
export interface AiChatParams {
|
export interface AiChatParams {
|
||||||
friendId: number;
|
friendId: number;
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ const { token } = useUserStore.getState();
|
|||||||
const DEFAULT_DEBOUNCE_GAP = 1000;
|
const DEFAULT_DEBOUNCE_GAP = 1000;
|
||||||
const debounceMap = new Map<string, number>();
|
const debounceMap = new Map<string, number>();
|
||||||
|
|
||||||
|
// 需要高频轮询、不走截流的接口白名单(按实际接口路径调整)
|
||||||
|
const NO_DEBOUNCE_URLS = [
|
||||||
|
"/wechat/friend/list", // 好友列表
|
||||||
|
"/wechat/group/list", // 群组列表
|
||||||
|
"/wechat/message/list", // 消息列表
|
||||||
|
];
|
||||||
|
|
||||||
|
// 接口错误白名单:这些接口失败时不显示错误提示
|
||||||
|
const ERROR_SILENT_URLS = [
|
||||||
|
"/v1/kefu/wechatFriend/list", // 微信好友列表
|
||||||
|
];
|
||||||
|
|
||||||
const instance: AxiosInstance = axios.create({
|
const instance: AxiosInstance = axios.create({
|
||||||
baseURL: (import.meta as any).env?.VITE_API_BASE_URL || "/api",
|
baseURL: (import.meta as any).env?.VITE_API_BASE_URL || "/api",
|
||||||
timeout: 20000,
|
timeout: 20000,
|
||||||
@@ -42,7 +54,13 @@ instance.interceptors.response.use(
|
|||||||
return payload.data ?? payload;
|
return payload.data ?? payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
Toast.show({ content: msg || "接口错误", position: "top" });
|
// 检查是否在错误白名单中
|
||||||
|
const url = res.config?.url || "";
|
||||||
|
const isInErrorSilentList = ERROR_SILENT_URLS.some(pattern =>
|
||||||
|
url.includes(pattern),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 401 错误始终需要处理
|
||||||
if (code === 401) {
|
if (code === 401) {
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
const currentPath = window.location.pathname + window.location.search;
|
const currentPath = window.location.pathname + window.location.search;
|
||||||
@@ -51,11 +69,38 @@ instance.interceptors.response.use(
|
|||||||
} else {
|
} else {
|
||||||
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
|
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
|
||||||
}
|
}
|
||||||
|
return Promise.reject(msg || "接口错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不在白名单中,显示错误提示
|
||||||
|
if (!isInErrorSilentList) {
|
||||||
|
Toast.show({ content: msg || "接口错误", position: "top" });
|
||||||
}
|
}
|
||||||
return Promise.reject(msg || "接口错误");
|
return Promise.reject(msg || "接口错误");
|
||||||
},
|
},
|
||||||
err => {
|
err => {
|
||||||
Toast.show({ content: err.message || "网络异常", position: "top" });
|
// 检查是否在错误白名单中
|
||||||
|
const url = err.config?.url || "";
|
||||||
|
const isInErrorSilentList = ERROR_SILENT_URLS.some(pattern =>
|
||||||
|
url.includes(pattern),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 401 错误始终需要处理
|
||||||
|
if (err.response && err.response.status === 401) {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
const currentPath = window.location.pathname + window.location.search;
|
||||||
|
if (currentPath === "/login") {
|
||||||
|
window.location.href = "/login";
|
||||||
|
} else {
|
||||||
|
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
|
||||||
|
}
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不在白名单中,显示错误提示
|
||||||
|
if (!isInErrorSilentList) {
|
||||||
|
Toast.show({ content: err.message || "网络异常", position: "top" });
|
||||||
|
}
|
||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -64,21 +109,31 @@ export function request(
|
|||||||
url: string,
|
url: string,
|
||||||
data?: any,
|
data?: any,
|
||||||
method: Method = "GET",
|
method: Method = "GET",
|
||||||
config?: AxiosRequestConfig,
|
// 允许通过 config.debounce 控制是否开启截流,默认开启
|
||||||
|
config?: AxiosRequestConfig & { debounce?: boolean },
|
||||||
debounceGap?: number,
|
debounceGap?: number,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const gap =
|
const gap =
|
||||||
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;
|
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;
|
||||||
const key = `${method}_${url}_${JSON.stringify(data)}`;
|
|
||||||
const now = Date.now();
|
|
||||||
const last = debounceMap.get(key) || 0;
|
|
||||||
if (gap > 0 && now - last < gap) {
|
|
||||||
// Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
|
|
||||||
return Promise.reject("请求过于频繁,请稍后再试");
|
|
||||||
}
|
|
||||||
debounceMap.set(key, now);
|
|
||||||
|
|
||||||
const axiosConfig: AxiosRequestConfig = {
|
const enableDebounce = config?.debounce !== false;
|
||||||
|
const isInNoDebounceList = NO_DEBOUNCE_URLS.some(pattern =>
|
||||||
|
url.includes(pattern),
|
||||||
|
);
|
||||||
|
const shouldDebounce = enableDebounce && !isInNoDebounceList;
|
||||||
|
|
||||||
|
if (shouldDebounce) {
|
||||||
|
const key = `${method}_${url}_${JSON.stringify(data)}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const last = debounceMap.get(key) || 0;
|
||||||
|
if (gap > 0 && now - last < gap) {
|
||||||
|
// Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
|
||||||
|
return Promise.reject("请求过于频繁,请稍后再试");
|
||||||
|
}
|
||||||
|
debounceMap.set(key, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const axiosConfig: AxiosRequestConfig & { debounce?: boolean } = {
|
||||||
url,
|
url,
|
||||||
method,
|
method,
|
||||||
...config,
|
...config,
|
||||||
|
|||||||
@@ -9,11 +9,25 @@ import { useUserStore } from "@/store/module/user";
|
|||||||
const DEFAULT_DEBOUNCE_GAP = 1000;
|
const DEFAULT_DEBOUNCE_GAP = 1000;
|
||||||
const debounceMap = new Map<string, number>();
|
const debounceMap = new Map<string, number>();
|
||||||
|
|
||||||
|
// 需要高频轮询、不走截流的接口白名单(按实际接口路径调整)
|
||||||
|
const NO_DEBOUNCE_URLS = [
|
||||||
|
"/wechat/friend/list",
|
||||||
|
"/wechat/group/list",
|
||||||
|
"/wechat/message/list",
|
||||||
|
];
|
||||||
|
|
||||||
|
// 接口错误白名单:这些接口失败时不显示错误提示
|
||||||
|
const ERROR_SILENT_URLS = [
|
||||||
|
"/v1/kefu/wechatFriend/list", // 微信好友列表
|
||||||
|
];
|
||||||
|
|
||||||
interface RequestConfig extends AxiosRequestConfig {
|
interface RequestConfig extends AxiosRequestConfig {
|
||||||
headers: {
|
headers?: {
|
||||||
Client?: string;
|
Client?: string;
|
||||||
"Content-Type"?: string;
|
"Content-Type"?: string;
|
||||||
};
|
};
|
||||||
|
// 是否开启截流,默认开启
|
||||||
|
debounce?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const instance: AxiosInstance = axios.create({
|
const instance: AxiosInstance = axios.create({
|
||||||
@@ -40,6 +54,12 @@ instance.interceptors.response.use(
|
|||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
err => {
|
err => {
|
||||||
|
// 检查是否在错误白名单中
|
||||||
|
const url = err.config?.url || "";
|
||||||
|
const isInErrorSilentList = ERROR_SILENT_URLS.some(pattern =>
|
||||||
|
url.includes(pattern),
|
||||||
|
);
|
||||||
|
|
||||||
// 处理401错误,跳转到登录页面
|
// 处理401错误,跳转到登录页面
|
||||||
if (err.response && err.response.status === 401) {
|
if (err.response && err.response.status === 401) {
|
||||||
Toast.show({ content: "登录已过期,请重新登录", position: "top" });
|
Toast.show({ content: "登录已过期,请重新登录", position: "top" });
|
||||||
@@ -49,7 +69,10 @@ instance.interceptors.response.use(
|
|||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
Toast.show({ content: err.message || "网络异常", position: "top" });
|
// 如果不在白名单中,显示错误提示
|
||||||
|
if (!isInErrorSilentList) {
|
||||||
|
Toast.show({ content: err.message || "网络异常", position: "top" });
|
||||||
|
}
|
||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -63,14 +86,23 @@ export function request(
|
|||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const gap =
|
const gap =
|
||||||
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;
|
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;
|
||||||
const key = `${method}_${url}_${JSON.stringify(data)}`;
|
|
||||||
const now = Date.now();
|
const enableDebounce = config?.debounce !== false;
|
||||||
const last = debounceMap.get(key) || 0;
|
const isInNoDebounceList = NO_DEBOUNCE_URLS.some(pattern =>
|
||||||
if (gap > 0 && now - last < gap) {
|
url.includes(pattern),
|
||||||
// Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
|
);
|
||||||
return Promise.reject("请求过于频繁,请稍后再试");
|
const shouldDebounce = enableDebounce && !isInNoDebounceList;
|
||||||
|
|
||||||
|
if (shouldDebounce) {
|
||||||
|
const key = `${method}_${url}_${JSON.stringify(data)}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const last = debounceMap.get(key) || 0;
|
||||||
|
if (gap > 0 && now - last < gap) {
|
||||||
|
// Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
|
||||||
|
return Promise.reject("请求过于频繁,请稍后再试");
|
||||||
|
}
|
||||||
|
debounceMap.set(key, now);
|
||||||
}
|
}
|
||||||
debounceMap.set(key, now);
|
|
||||||
|
|
||||||
const axiosConfig: RequestConfig = {
|
const axiosConfig: RequestConfig = {
|
||||||
url,
|
url,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||||
import { Input, Button } from "antd";
|
import { Input, Button, Space } from "antd";
|
||||||
import { Tabs } from "antd-mobile";
|
import { Tabs } from "antd-mobile";
|
||||||
import style from "./header.module.scss";
|
import style from "./header.module.scss";
|
||||||
|
|
||||||
@@ -44,14 +44,22 @@ const PopupHeader: React.FC<PopupHeaderProps> = ({
|
|||||||
{showSearch && (
|
{showSearch && (
|
||||||
<div className={style.popupSearchRow}>
|
<div className={style.popupSearchRow}>
|
||||||
<div className={style.popupSearchInputWrap}>
|
<div className={style.popupSearchInputWrap}>
|
||||||
<Input.Search
|
<Space.Compact style={{ width: "100%" }}>
|
||||||
placeholder={searchPlaceholder}
|
<Input
|
||||||
value={searchQuery}
|
placeholder={searchPlaceholder}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
value={searchQuery}
|
||||||
onSearch={() => onSearch && onSearch(searchQuery)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
prefix={<SearchOutlined />}
|
onPressEnter={() => onSearch && onSearch(searchQuery)}
|
||||||
size="large"
|
prefix={<SearchOutlined />}
|
||||||
/>
|
size="large"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
onClick={() => onSearch && onSearch(searchQuery)}
|
||||||
|
/>
|
||||||
|
</Space.Compact>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showRefresh && onRefresh && (
|
{showRefresh && onRefresh && (
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import React, { useMemo, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { VariableSizeList as List } from "react-window";
|
||||||
|
import { ChatRecord } from "@/pages/pc/ckbox/data";
|
||||||
|
import { MessageGroup } from "@/hooks/weChat/useMessageGrouping";
|
||||||
|
|
||||||
|
export interface VirtualMessageItem {
|
||||||
|
type: "time" | "system" | "message";
|
||||||
|
id: string | number;
|
||||||
|
data: ChatRecord | string;
|
||||||
|
groupIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VirtualMessageListProps {
|
||||||
|
groupedMessages: MessageGroup[];
|
||||||
|
containerHeight: number;
|
||||||
|
containerRef?: React.RefObject<HTMLDivElement>;
|
||||||
|
renderItem: (item: VirtualMessageItem, index: number) => React.ReactNode;
|
||||||
|
onScroll?: (scrollTop: number) => void;
|
||||||
|
onScrollToTop?: () => void;
|
||||||
|
estimatedItemSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 虚拟滚动消息列表组件
|
||||||
|
* 使用 react-window 的 VariableSizeList 实现高性能消息列表
|
||||||
|
*/
|
||||||
|
export const VirtualMessageList: React.FC<VirtualMessageListProps> = ({
|
||||||
|
groupedMessages,
|
||||||
|
containerHeight,
|
||||||
|
containerRef,
|
||||||
|
renderItem,
|
||||||
|
onScroll,
|
||||||
|
onScrollToTop,
|
||||||
|
estimatedItemSize = 80,
|
||||||
|
}) => {
|
||||||
|
const listRef = useRef<List>(null);
|
||||||
|
const itemSizeCache = useRef<Map<number, number>>(new Map());
|
||||||
|
const itemRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
|
|
||||||
|
// 将分组消息展平为扁平列表
|
||||||
|
const flatItems = useMemo<VirtualMessageItem[]>(() => {
|
||||||
|
const items: VirtualMessageItem[] = [];
|
||||||
|
|
||||||
|
groupedMessages.forEach((group, groupIndex) => {
|
||||||
|
// 添加时间戳
|
||||||
|
items.push({
|
||||||
|
type: "time",
|
||||||
|
id: `time-${groupIndex}`,
|
||||||
|
data: group.time,
|
||||||
|
groupIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加系统消息(时间分隔符)
|
||||||
|
const systemMessages = group.messages.filter(v =>
|
||||||
|
[10000, -10001].includes(v.msgType),
|
||||||
|
);
|
||||||
|
systemMessages.forEach(msg => {
|
||||||
|
items.push({
|
||||||
|
type: "system",
|
||||||
|
id: `system-${msg.id}`,
|
||||||
|
data: msg,
|
||||||
|
groupIndex,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加其他系统消息
|
||||||
|
const otherSystemMessages = group.messages.filter(v =>
|
||||||
|
[570425393, 90000].includes(v.msgType),
|
||||||
|
);
|
||||||
|
otherSystemMessages.forEach(msg => {
|
||||||
|
items.push({
|
||||||
|
type: "system",
|
||||||
|
id: `system-${msg.id}`,
|
||||||
|
data: msg,
|
||||||
|
groupIndex,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加普通消息
|
||||||
|
const normalMessages = group.messages.filter(
|
||||||
|
v => ![10000, 570425393, 90000, -10001].includes(v.msgType),
|
||||||
|
);
|
||||||
|
normalMessages.forEach(msg => {
|
||||||
|
items.push({
|
||||||
|
type: "message",
|
||||||
|
id: msg.id,
|
||||||
|
data: msg,
|
||||||
|
groupIndex,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [groupedMessages]);
|
||||||
|
|
||||||
|
// 估算项目高度
|
||||||
|
const getItemSize = useCallback(
|
||||||
|
(index: number): number => {
|
||||||
|
// 如果缓存中有,直接返回
|
||||||
|
if (itemSizeCache.current.has(index)) {
|
||||||
|
return itemSizeCache.current.get(index)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = flatItems[index];
|
||||||
|
if (!item) return estimatedItemSize;
|
||||||
|
|
||||||
|
// 根据类型返回估算高度
|
||||||
|
switch (item.type) {
|
||||||
|
case "time":
|
||||||
|
return 40; // 时间戳高度
|
||||||
|
case "system":
|
||||||
|
return 30; // 系统消息高度
|
||||||
|
case "message":
|
||||||
|
// 根据消息类型估算
|
||||||
|
const msg = item.data as ChatRecord;
|
||||||
|
if (msg.msgType === 3) {
|
||||||
|
// 图片消息
|
||||||
|
return 250;
|
||||||
|
} else if (msg.msgType === 43) {
|
||||||
|
// 视频消息
|
||||||
|
return 250;
|
||||||
|
} else if (msg.msgType === 49) {
|
||||||
|
// 小程序消息
|
||||||
|
return 120;
|
||||||
|
} else {
|
||||||
|
// 文本消息,根据内容长度估算
|
||||||
|
const content = String(msg.content || "");
|
||||||
|
const lines = Math.ceil(content.length / 30);
|
||||||
|
return Math.max(60, lines * 24 + 40);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return estimatedItemSize;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[flatItems, estimatedItemSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 测量实际项目高度并更新缓存
|
||||||
|
const measureItem = useCallback(
|
||||||
|
(index: number, element: HTMLDivElement | null) => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
itemRefs.current.set(index, element);
|
||||||
|
|
||||||
|
// 使用 ResizeObserver 或直接测量
|
||||||
|
const height = element.getBoundingClientRect().height;
|
||||||
|
if (height > 0 && height !== itemSizeCache.current.get(index)) {
|
||||||
|
itemSizeCache.current.set(index, height);
|
||||||
|
// 通知列表更新该索引的高度
|
||||||
|
listRef.current?.resetAfterIndex(index, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理滚动事件
|
||||||
|
const handleScroll = useCallback(
|
||||||
|
({ scrollOffset }: { scrollOffset: number }) => {
|
||||||
|
onScroll?.(scrollOffset);
|
||||||
|
// 检测是否滚动到顶部
|
||||||
|
if (scrollOffset < 50 && onScrollToTop) {
|
||||||
|
onScrollToTop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onScroll, onScrollToTop],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
if (listRef.current && flatItems.length > 0) {
|
||||||
|
listRef.current.scrollToItem(flatItems.length - 1, "end");
|
||||||
|
}
|
||||||
|
}, [flatItems.length]);
|
||||||
|
|
||||||
|
// 暴露滚动到底部方法
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef?.current) {
|
||||||
|
(containerRef.current as any).scrollToBottom = scrollToBottom;
|
||||||
|
}
|
||||||
|
}, [containerRef, scrollToBottom]);
|
||||||
|
|
||||||
|
// 渲染列表项
|
||||||
|
const Row = useCallback(
|
||||||
|
({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||||
|
const item = flatItems[index];
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={style}
|
||||||
|
ref={el => measureItem(index, el)}
|
||||||
|
data-index={index}
|
||||||
|
>
|
||||||
|
{renderItem(item, index)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[flatItems, renderItem, measureItem],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (flatItems.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
ref={listRef}
|
||||||
|
height={containerHeight}
|
||||||
|
itemCount={flatItems.length}
|
||||||
|
itemSize={getItemSize}
|
||||||
|
width="100%"
|
||||||
|
onScroll={handleScroll}
|
||||||
|
overscanCount={5} // 预渲染5个项目
|
||||||
|
style={{ overflowX: "hidden" }}
|
||||||
|
>
|
||||||
|
{Row}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VirtualMessageList;
|
||||||
95
Touchkebao/src/hooks/weChat/useChatMessages.ts
Normal file
95
Touchkebao/src/hooks/weChat/useChatMessages.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { getChatMessages, getChatroomMessages } from "@/pages/pc/ckbox/api";
|
||||||
|
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||||
|
import { captureError, addPerformanceBreadcrumb } from "@/utils/sentry";
|
||||||
|
|
||||||
|
const DEFAULT_MESSAGE_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息列表 Hook
|
||||||
|
* 使用 TanStack Query 管理消息数据,自动缓存和分页
|
||||||
|
* 使用 Sentry 监控请求性能和错误
|
||||||
|
*/
|
||||||
|
export const useChatMessages = (contact: ContractData | weChatGroup | null) => {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: ["chatMessages", contact?.id, contact?.wechatAccountId],
|
||||||
|
initialPageParam: 1, // TanStack Query v5 必需
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
if (!contact) {
|
||||||
|
throw new Error("联系人信息缺失");
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
const params: any = {
|
||||||
|
wechatAccountId: contact.wechatAccountId,
|
||||||
|
page: pageParam,
|
||||||
|
limit: DEFAULT_MESSAGE_PAGE_SIZE,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isGroup = "chatroomId" in contact && Boolean(contact.chatroomId);
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
params.wechatChatroomId = contact.id;
|
||||||
|
} else {
|
||||||
|
params.wechatFriendId = contact.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = isGroup
|
||||||
|
? await getChatroomMessages(params)
|
||||||
|
: await getChatMessages(params);
|
||||||
|
|
||||||
|
const duration = performance.now() - startTime;
|
||||||
|
|
||||||
|
// ✅ 使用 Sentry 记录请求性能
|
||||||
|
addPerformanceBreadcrumb("获取消息列表", {
|
||||||
|
duration,
|
||||||
|
contactId: contact.id,
|
||||||
|
page: pageParam,
|
||||||
|
messageCount: (response as any)?.list?.length || 0,
|
||||||
|
isGroup,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果请求时间超过 1 秒,记录警告
|
||||||
|
if (duration > 1000) {
|
||||||
|
addPerformanceBreadcrumb("慢请求警告", {
|
||||||
|
duration,
|
||||||
|
contactId: contact.id,
|
||||||
|
threshold: 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
// ✅ 使用 Sentry 捕获错误
|
||||||
|
captureError(error as Error, {
|
||||||
|
tags: {
|
||||||
|
action: "getChatMessages",
|
||||||
|
isGroup: String(isGroup),
|
||||||
|
},
|
||||||
|
extra: {
|
||||||
|
contactId: contact.id,
|
||||||
|
page: pageParam,
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
// 判断是否还有更多数据
|
||||||
|
const lastPageData = lastPage as any;
|
||||||
|
const hasMore =
|
||||||
|
lastPageData?.hasNext ||
|
||||||
|
lastPageData?.hasNextPage ||
|
||||||
|
(lastPageData?.list?.length || 0) >= DEFAULT_MESSAGE_PAGE_SIZE;
|
||||||
|
return hasMore ? allPages.length + 1 : undefined;
|
||||||
|
},
|
||||||
|
enabled: !!contact, // 只有联系人存在时才请求
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 分钟缓存
|
||||||
|
gcTime: 10 * 60 * 1000, // 10 分钟缓存(v5 使用 gcTime)
|
||||||
|
// ✅ 使用 Sentry 监控查询状态变化
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
31
Touchkebao/src/hooks/weChat/useMessageGrouping.ts
Normal file
31
Touchkebao/src/hooks/weChat/useMessageGrouping.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { ChatRecord } from "@/pages/pc/ckbox/data";
|
||||||
|
import { formatWechatTime } from "@/utils/common";
|
||||||
|
|
||||||
|
export interface MessageGroup {
|
||||||
|
time: string;
|
||||||
|
messages: ChatRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息分组 Hook
|
||||||
|
* 使用 useMemo 缓存分组结果,减少重复计算
|
||||||
|
*/
|
||||||
|
export const useMessageGrouping = (
|
||||||
|
messages: ChatRecord[] | null | undefined,
|
||||||
|
): MessageGroup[] => {
|
||||||
|
return useMemo(() => {
|
||||||
|
const safeMessages = Array.isArray(messages)
|
||||||
|
? messages
|
||||||
|
: Array.isArray((messages as any)?.list)
|
||||||
|
? ((messages as any).list as ChatRecord[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return safeMessages
|
||||||
|
.filter(msg => msg !== null && msg !== undefined) // 过滤掉null和undefined的消息
|
||||||
|
.map(msg => ({
|
||||||
|
time: formatWechatTime(String(msg?.wechatTime)),
|
||||||
|
messages: [msg],
|
||||||
|
}));
|
||||||
|
}, [messages]);
|
||||||
|
};
|
||||||
447
Touchkebao/src/hooks/weChat/useMessageParser.tsx
Normal file
447
Touchkebao/src/hooks/weChat/useMessageParser.tsx
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||||
|
import { getEmojiPath } from "@/components/EmojiSeclection/wechatEmoji";
|
||||||
|
import AudioMessage from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/AudioMessage/AudioMessage";
|
||||||
|
import SmallProgramMessage from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage";
|
||||||
|
import VideoMessage from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/VideoMessage";
|
||||||
|
import LocationMessage from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/LocationMessage";
|
||||||
|
import SystemRecommendRemarkMessage from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SystemRecommendRemarkMessage/index";
|
||||||
|
import RedPacketMessage from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/RedPacketMessage";
|
||||||
|
import TransferMessage from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/TransferMessage";
|
||||||
|
import styles from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/com.module.scss";
|
||||||
|
|
||||||
|
const IMAGE_EXT_REGEX = /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i;
|
||||||
|
const FILE_EXT_REGEX = /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i;
|
||||||
|
|
||||||
|
const openInNewTab = (url: string) => window.open(url, "_blank");
|
||||||
|
|
||||||
|
const handleImageError = (
|
||||||
|
event: React.SyntheticEvent<HTMLImageElement>,
|
||||||
|
fallbackText: string,
|
||||||
|
) => {
|
||||||
|
const target = event.target as HTMLImageElement;
|
||||||
|
const parent = target.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.innerHTML = `<div class="${styles.messageText}">${fallbackText}</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ImageContentOptions {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
fallbackText: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
wrapperClassName?: string;
|
||||||
|
withBubble?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderImageContent = ({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
fallbackText,
|
||||||
|
style = {
|
||||||
|
maxWidth: "200px",
|
||||||
|
maxHeight: "200px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
},
|
||||||
|
wrapperClassName = styles.imageMessage,
|
||||||
|
withBubble = false,
|
||||||
|
onClick,
|
||||||
|
}: ImageContentOptions) => {
|
||||||
|
const imageNode = (
|
||||||
|
<div className={wrapperClassName}>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
style={style}
|
||||||
|
onClick={onClick ?? (() => openInNewTab(src))}
|
||||||
|
onError={event => handleImageError(event, fallbackText)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (withBubble) {
|
||||||
|
return <div className={styles.messageBubble}>{imageNode}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEmojiContent = (src: string) =>
|
||||||
|
renderImageContent({
|
||||||
|
src,
|
||||||
|
alt: "表情包",
|
||||||
|
fallbackText: "[表情包加载失败]",
|
||||||
|
style: {
|
||||||
|
maxWidth: "120px",
|
||||||
|
maxHeight: "120px",
|
||||||
|
},
|
||||||
|
wrapperClassName: styles.emojiMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderFileContent = (url: string) => {
|
||||||
|
const fileName = url.split("/").pop()?.split("?")[0] || "文件";
|
||||||
|
const displayName =
|
||||||
|
fileName.length > 20 ? `${fileName.substring(0, 20)}...` : fileName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.fileMessage}>
|
||||||
|
<div className={styles.fileCard}>
|
||||||
|
<div className={styles.fileIcon}>📄</div>
|
||||||
|
<div className={styles.fileInfo}>
|
||||||
|
<div className={styles.fileName}>{displayName}</div>
|
||||||
|
<div className={styles.fileAction} onClick={() => openInNewTab(url)}>
|
||||||
|
点击查看
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value);
|
||||||
|
const isHttpImageUrl = (value: string) =>
|
||||||
|
isHttpUrl(value) && IMAGE_EXT_REGEX.test(value);
|
||||||
|
const isFileUrl = (value: string) =>
|
||||||
|
isHttpUrl(value) && FILE_EXT_REGEX.test(value);
|
||||||
|
|
||||||
|
const isLegacyEmojiContent = (content: string) =>
|
||||||
|
IMAGE_EXT_REGEX.test(content) ||
|
||||||
|
content.includes("emoji") ||
|
||||||
|
content.includes("sticker");
|
||||||
|
|
||||||
|
const tryParseContentJson = (content: string): Record<string, any> | null => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息解析 Hook
|
||||||
|
* 提取消息解析逻辑,使用 useCallback 优化性能
|
||||||
|
*/
|
||||||
|
export const useMessageParser = (contract: ContractData | weChatGroup) => {
|
||||||
|
// 判断是否为表情包URL的工具函数
|
||||||
|
const isEmojiUrl = useCallback((content: string): boolean => {
|
||||||
|
return (
|
||||||
|
content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") ||
|
||||||
|
/\.(gif|webp|png|jpg|jpeg)$/i.test(content) ||
|
||||||
|
content.includes("emoji") ||
|
||||||
|
content.includes("sticker") ||
|
||||||
|
content.includes("expression")
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 解析表情包文字格式[表情名称]并替换为img标签
|
||||||
|
const parseEmojiText = useCallback((text: string): React.ReactNode[] => {
|
||||||
|
const emojiRegex = /\[([^\]]+)\]/g;
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = emojiRegex.exec(text)) !== null) {
|
||||||
|
// 添加表情前的文字
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(text.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取表情名称并查找对应路径
|
||||||
|
const emojiName = match[1];
|
||||||
|
const emojiPath = getEmojiPath(emojiName as any);
|
||||||
|
|
||||||
|
if (emojiPath) {
|
||||||
|
// 如果找到表情,添加img标签
|
||||||
|
parts.push(
|
||||||
|
<img
|
||||||
|
key={`emoji-${match.index}`}
|
||||||
|
src={emojiPath}
|
||||||
|
alt={emojiName}
|
||||||
|
className={styles.emojiImage}
|
||||||
|
style={{
|
||||||
|
width: "20px",
|
||||||
|
height: "20px",
|
||||||
|
margin: "0 2px",
|
||||||
|
display: "inline",
|
||||||
|
lineHeight: "20px",
|
||||||
|
float: "left",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 如果没找到表情,保持原文字
|
||||||
|
parts.push(match[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = emojiRegex.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加剩余的文字
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 渲染未知内容
|
||||||
|
const renderUnknownContent = useCallback(
|
||||||
|
(
|
||||||
|
rawContent: string,
|
||||||
|
trimmedContent: string,
|
||||||
|
msg?: ChatRecord,
|
||||||
|
contractParam?: ContractData | weChatGroup,
|
||||||
|
) => {
|
||||||
|
if (isLegacyEmojiContent(trimmedContent)) {
|
||||||
|
return renderEmojiContent(rawContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonData = tryParseContentJson(trimmedContent);
|
||||||
|
|
||||||
|
if (jsonData && typeof jsonData === "object") {
|
||||||
|
// 判断是否为红包消息
|
||||||
|
if (
|
||||||
|
jsonData.nativeurl &&
|
||||||
|
typeof jsonData.nativeurl === "string" &&
|
||||||
|
jsonData.nativeurl.includes(
|
||||||
|
"wxpay://c2cbizmessagehandler/hongbao/receivehongbao",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<RedPacketMessage
|
||||||
|
content={rawContent}
|
||||||
|
msg={msg}
|
||||||
|
contract={contractParam}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否为转账消息
|
||||||
|
if (
|
||||||
|
jsonData.title === "微信转账" ||
|
||||||
|
(jsonData.transferid && jsonData.feedesc)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<TransferMessage
|
||||||
|
content={rawContent}
|
||||||
|
msg={msg}
|
||||||
|
contract={contractParam}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonData.type === "file" && msg && contractParam) {
|
||||||
|
return (
|
||||||
|
<SmallProgramMessage
|
||||||
|
content={rawContent}
|
||||||
|
msg={msg}
|
||||||
|
contract={contractParam}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonData.type === "link" && jsonData.title && jsonData.url) {
|
||||||
|
const { title, desc, thumbPath, url } = jsonData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.miniProgramCard} ${styles.linkCard}`}
|
||||||
|
onClick={() => openInNewTab(url)}
|
||||||
|
>
|
||||||
|
{thumbPath && (
|
||||||
|
<img
|
||||||
|
src={thumbPath}
|
||||||
|
alt="链接缩略图"
|
||||||
|
className={styles.miniProgramThumb}
|
||||||
|
onError={event => {
|
||||||
|
const target = event.target as HTMLImageElement;
|
||||||
|
target.style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={styles.miniProgramInfo}>
|
||||||
|
<div className={styles.miniProgramTitle}>{title}</div>
|
||||||
|
{desc && <div className={styles.linkDescription}>{desc}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.miniProgramApp}>链接</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
jsonData.previewImage &&
|
||||||
|
(jsonData.tencentUrl || jsonData.videoUrl)
|
||||||
|
) {
|
||||||
|
const previewImageUrl = String(jsonData.previewImage).replace(
|
||||||
|
/[`"']/g,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className={styles.videoMessage}>
|
||||||
|
<div className={styles.videoContainer}>
|
||||||
|
<img
|
||||||
|
src={previewImageUrl}
|
||||||
|
alt="视频预览"
|
||||||
|
className={styles.videoPreview}
|
||||||
|
onClick={() => {
|
||||||
|
const videoUrl = jsonData.videoUrl || jsonData.tencentUrl;
|
||||||
|
if (videoUrl) {
|
||||||
|
openInNewTab(videoUrl);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onError={event => {
|
||||||
|
const target = event.target as HTMLImageElement;
|
||||||
|
const parent = target.parentElement?.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.innerHTML = `<div class="${styles.messageText}">[视频预览加载失败]</div>`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={styles.playButton}>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHttpImageUrl(trimmedContent)) {
|
||||||
|
return renderImageContent({
|
||||||
|
src: rawContent,
|
||||||
|
alt: "图片消息",
|
||||||
|
fallbackText: "[图片加载失败]",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFileUrl(trimmedContent)) {
|
||||||
|
return renderFileContent(trimmedContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.messageText}>{parseEmojiText(rawContent)}</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[parseEmojiText],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 解析消息内容,根据msgType判断消息类型并返回对应的渲染内容
|
||||||
|
const parseMessageContent = useCallback(
|
||||||
|
(
|
||||||
|
content: string | null | undefined,
|
||||||
|
msg: ChatRecord,
|
||||||
|
msgType?: number,
|
||||||
|
): React.ReactNode => {
|
||||||
|
// 处理null或undefined的内容
|
||||||
|
if (content === null || content === undefined) {
|
||||||
|
return <div className={styles.messageText}>消息内容不可用</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一的错误消息渲染函数
|
||||||
|
const renderErrorMessage = (fallbackText: string) => (
|
||||||
|
<div className={styles.messageText}>{fallbackText}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const isStringValue = typeof content === "string";
|
||||||
|
const rawContent = isStringValue ? content : "";
|
||||||
|
const trimmedContent = rawContent.trim();
|
||||||
|
|
||||||
|
switch (msgType) {
|
||||||
|
case 1: // 文本消息
|
||||||
|
return (
|
||||||
|
<div className={styles.messageBubble}>
|
||||||
|
<div className={styles.messageText}>
|
||||||
|
{parseEmojiText(rawContent)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 3: // 图片消息
|
||||||
|
if (!isStringValue || !trimmedContent) {
|
||||||
|
return renderErrorMessage("[图片消息 - 无效链接]");
|
||||||
|
}
|
||||||
|
return renderImageContent({
|
||||||
|
src: rawContent,
|
||||||
|
alt: "图片消息",
|
||||||
|
fallbackText: "[图片加载失败]",
|
||||||
|
withBubble: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
case 34: // 语音消息
|
||||||
|
if (!isStringValue || !trimmedContent) {
|
||||||
|
return renderErrorMessage("[语音消息 - 无效内容]");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AudioMessage audioUrl={rawContent} msgId={String(msg.id)} />;
|
||||||
|
|
||||||
|
case 43: // 视频消息
|
||||||
|
return (
|
||||||
|
<VideoMessage
|
||||||
|
content={isStringValue ? rawContent : ""}
|
||||||
|
msg={msg}
|
||||||
|
contract={contract}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 47: // 动图表情包(gif、其他表情包)
|
||||||
|
if (!isStringValue || !trimmedContent) {
|
||||||
|
return renderErrorMessage("[表情包 - 无效链接]");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmojiUrl(trimmedContent)) {
|
||||||
|
return renderEmojiContent(rawContent);
|
||||||
|
}
|
||||||
|
return renderErrorMessage("[表情包]");
|
||||||
|
|
||||||
|
case 48: // 定位消息
|
||||||
|
return <LocationMessage content={isStringValue ? rawContent : ""} />;
|
||||||
|
|
||||||
|
case 49: // 小程序/文章/其他:图文、文件
|
||||||
|
return (
|
||||||
|
<SmallProgramMessage
|
||||||
|
content={isStringValue ? rawContent : ""}
|
||||||
|
msg={msg}
|
||||||
|
contract={contract}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 10002: // 系统推荐备注消息
|
||||||
|
return (
|
||||||
|
<SystemRecommendRemarkMessage
|
||||||
|
content={isStringValue ? rawContent : ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default: {
|
||||||
|
if (!isStringValue || !trimmedContent) {
|
||||||
|
return renderErrorMessage(
|
||||||
|
`[未知消息类型${msgType ? ` - ${msgType}` : ""}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderUnknownContent(
|
||||||
|
rawContent,
|
||||||
|
trimmedContent,
|
||||||
|
msg,
|
||||||
|
contract,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[contract, parseEmojiText, isEmojiUrl, renderUnknownContent],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
parseMessageContent,
|
||||||
|
parseEmojiText,
|
||||||
|
isEmojiUrl,
|
||||||
|
};
|
||||||
|
};
|
||||||
129
Touchkebao/src/hooks/weChat/useWeChatSelectors.ts
Normal file
129
Touchkebao/src/hooks/weChat/useWeChatSelectors.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import type { WeChatState } from "@/store/module/weChat/weChat.data";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并多个 selector,减少重渲染
|
||||||
|
* 使用 useShallow 进行 shallow 比较,只有对象属性变化时才触发更新
|
||||||
|
* 使用 useMemo 稳定 selector 函数引用
|
||||||
|
*/
|
||||||
|
export const useWeChatSelectors = () => {
|
||||||
|
const selector = useMemo(
|
||||||
|
() => (state: WeChatState) => ({
|
||||||
|
// 消息相关
|
||||||
|
currentMessages: state.currentMessages,
|
||||||
|
currentMessagesHasMore: state.currentMessagesHasMore,
|
||||||
|
messagesLoading: state.messagesLoading,
|
||||||
|
isLoadingData: state.isLoadingData,
|
||||||
|
|
||||||
|
// 联系人相关
|
||||||
|
currentContract: state.currentContract,
|
||||||
|
|
||||||
|
// UI 状态
|
||||||
|
showCheckbox: state.showCheckbox,
|
||||||
|
EnterModule: state.EnterModule,
|
||||||
|
showChatRecordModel: state.showChatRecordModel,
|
||||||
|
|
||||||
|
// AI 相关
|
||||||
|
isLoadingAiChat: state.isLoadingAiChat,
|
||||||
|
quoteMessageContent: state.quoteMessageContent,
|
||||||
|
aiQuoteMessageContent: state.aiQuoteMessageContent,
|
||||||
|
|
||||||
|
// 选中记录
|
||||||
|
selectedChatRecords: state.selectedChatRecords,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return useWeChatStore(useShallow(selector));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息相关的 selector
|
||||||
|
* 使用 useShallow 进行 shallow 比较,避免 getSnapshot 警告
|
||||||
|
* 使用 useMemo 稳定 selector 函数引用
|
||||||
|
*/
|
||||||
|
export const useMessageSelectors = (): Pick<
|
||||||
|
WeChatState,
|
||||||
|
| "currentMessages"
|
||||||
|
| "currentMessagesHasMore"
|
||||||
|
| "messagesLoading"
|
||||||
|
| "isLoadingData"
|
||||||
|
> => {
|
||||||
|
const selector = useMemo(
|
||||||
|
() => (state: WeChatState) => ({
|
||||||
|
currentMessages: state.currentMessages,
|
||||||
|
currentMessagesHasMore: state.currentMessagesHasMore,
|
||||||
|
messagesLoading: state.messagesLoading,
|
||||||
|
isLoadingData: state.isLoadingData,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return useWeChatStore(useShallow(selector));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI 状态相关的 selector
|
||||||
|
* 使用 useShallow 进行 shallow 比较,避免 getSnapshot 警告
|
||||||
|
* 使用 useMemo 稳定 selector 函数引用
|
||||||
|
*/
|
||||||
|
export const useUIStateSelectors = (): Pick<
|
||||||
|
WeChatState,
|
||||||
|
"showCheckbox" | "EnterModule" | "showChatRecordModel"
|
||||||
|
> => {
|
||||||
|
const selector = useMemo(
|
||||||
|
() => (state: WeChatState) => ({
|
||||||
|
showCheckbox: state.showCheckbox,
|
||||||
|
EnterModule: state.EnterModule,
|
||||||
|
showChatRecordModel: state.showChatRecordModel,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return useWeChatStore(useShallow(selector));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 相关的 selector
|
||||||
|
* 使用 useShallow 进行 shallow 比较,避免 getSnapshot 警告
|
||||||
|
* 使用 useMemo 稳定 selector 函数引用
|
||||||
|
*/
|
||||||
|
export const useAISelectors = (): Pick<
|
||||||
|
WeChatState,
|
||||||
|
"isLoadingAiChat" | "quoteMessageContent" | "aiQuoteMessageContent"
|
||||||
|
> => {
|
||||||
|
const selector = useMemo(
|
||||||
|
() => (state: WeChatState) => ({
|
||||||
|
isLoadingAiChat: state.isLoadingAiChat,
|
||||||
|
quoteMessageContent: state.quoteMessageContent,
|
||||||
|
aiQuoteMessageContent: state.aiQuoteMessageContent,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return useWeChatStore(useShallow(selector));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作方法 selector
|
||||||
|
* 使用 useShallow 进行 shallow 比较,避免 getSnapshot 警告
|
||||||
|
* 虽然方法引用稳定,但返回的对象需要 shallow 比较
|
||||||
|
*/
|
||||||
|
export const useWeChatActions = () => {
|
||||||
|
const selector = useMemo(
|
||||||
|
() => (state: WeChatState) => ({
|
||||||
|
addMessage: state.addMessage,
|
||||||
|
updateMessage: state.updateMessage,
|
||||||
|
recallMessage: state.recallMessage,
|
||||||
|
loadChatMessages: state.loadChatMessages,
|
||||||
|
updateShowCheckbox: state.updateShowCheckbox,
|
||||||
|
updateEnterModule: state.updateEnterModule,
|
||||||
|
updateQuoteMessageContent: state.updateQuoteMessageContent,
|
||||||
|
updateIsLoadingAiChat: state.updateIsLoadingAiChat,
|
||||||
|
updateSelectedChatRecords: state.updateSelectedChatRecords,
|
||||||
|
updateShowChatRecordModel: state.updateShowChatRecordModel,
|
||||||
|
setCurrentContact: state.setCurrentContact,
|
||||||
|
updateAiQuoteMessageContent: state.updateAiQuoteMessageContent,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return useWeChatStore(useShallow(selector));
|
||||||
|
};
|
||||||
@@ -8,6 +8,11 @@ import "dayjs/locale/zh-cn";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./styles/global.scss";
|
import "./styles/global.scss";
|
||||||
import { initializeDatabaseFromPersistedUser } from "./utils/db";
|
import { initializeDatabaseFromPersistedUser } from "./utils/db";
|
||||||
|
import { initSentry } from "./utils/sentry";
|
||||||
|
import { QueryProvider } from "./providers/QueryProvider";
|
||||||
|
|
||||||
|
// 最先初始化 Sentry(必须在其他代码之前)
|
||||||
|
initSentry();
|
||||||
|
|
||||||
// 设置dayjs为中文
|
// 设置dayjs为中文
|
||||||
dayjs.locale("zh-cn");
|
dayjs.locale("zh-cn");
|
||||||
@@ -22,7 +27,9 @@ async function bootstrap() {
|
|||||||
const root = createRoot(document.getElementById("root")!);
|
const root = createRoot(document.getElementById("root")!);
|
||||||
root.render(
|
root.render(
|
||||||
<ConfigProvider locale={zhCN}>
|
<ConfigProvider locale={zhCN}>
|
||||||
<App />
|
<QueryProvider>
|
||||||
|
<App />
|
||||||
|
</QueryProvider>
|
||||||
</ConfigProvider>,
|
</ConfigProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,16 +255,6 @@ export const deleteChatSession = (chatId: string): Promise<void> => {
|
|||||||
return request2(`/v1/chats/${chatId}`, {}, "DELETE");
|
return request2(`/v1/chats/${chatId}`, {}, "DELETE");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 置顶聊天会话
|
|
||||||
export const pinChatSession = (chatId: string): Promise<void> => {
|
|
||||||
return request2(`/v1/chats/${chatId}/pin`, {}, "PUT");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 取消置顶聊天会话
|
|
||||||
export const unpinChatSession = (chatId: string): Promise<void> => {
|
|
||||||
return request2(`/v1/chats/${chatId}/unpin`, {}, "PUT");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 静音聊天会话
|
// 静音聊天会话
|
||||||
export const muteChatSession = (chatId: string): Promise<void> => {
|
export const muteChatSession = (chatId: string): Promise<void> => {
|
||||||
return request2(`/v1/chats/${chatId}/mute`, {}, "PUT");
|
return request2(`/v1/chats/${chatId}/mute`, {}, "PUT");
|
||||||
|
|||||||
@@ -305,16 +305,6 @@ export const deleteChatSession = (chatId: string): Promise<void> => {
|
|||||||
return request2(`/v1/chats/${chatId}`, {}, "DELETE");
|
return request2(`/v1/chats/${chatId}`, {}, "DELETE");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 置顶聊天会话
|
|
||||||
export const pinChatSession = (chatId: string): Promise<void> => {
|
|
||||||
return request2(`/v1/chats/${chatId}/pin`, {}, "PUT");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 取消置顶聊天会话
|
|
||||||
export const unpinChatSession = (chatId: string): Promise<void> => {
|
|
||||||
return request2(`/v1/chats/${chatId}/unpin`, {}, "PUT");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 静音聊天会话
|
// 静音聊天会话
|
||||||
export const muteChatSession = (chatId: string): Promise<void> => {
|
export const muteChatSession = (chatId: string): Promise<void> => {
|
||||||
return request2(`/v1/chats/${chatId}/mute`, {}, "PUT");
|
return request2(`/v1/chats/${chatId}/mute`, {}, "PUT");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Modal, Input, Button, List, message, Spin } from "antd";
|
import { Modal, Input, Button, List, message, Spin, Space } from "antd";
|
||||||
import { SearchOutlined, EnvironmentOutlined } from "@ant-design/icons";
|
import { SearchOutlined, EnvironmentOutlined } from "@ant-design/icons";
|
||||||
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||||
import styles from "./selectMap.module.scss";
|
import styles from "./selectMap.module.scss";
|
||||||
@@ -943,24 +943,24 @@ const SelectMap: React.FC<SelectMapProps> = ({
|
|||||||
<div className={styles.selectMapContainer}>
|
<div className={styles.selectMapContainer}>
|
||||||
{/* 搜索区域 */}
|
{/* 搜索区域 */}
|
||||||
<div className={styles.searchArea}>
|
<div className={styles.searchArea}>
|
||||||
<Input
|
{/* ✅ 使用 Space.Compact 替代 Input 的 suffix(addonAfter 已废弃) */}
|
||||||
placeholder="搜索地址"
|
<Space.Compact style={{ width: "100%" }}>
|
||||||
value={searchValue}
|
<Input
|
||||||
onChange={e => setSearchValue(e.target.value)}
|
placeholder="搜索地址"
|
||||||
onPressEnter={handleSearch}
|
value={searchValue}
|
||||||
prefix={<SearchOutlined />}
|
onChange={e => setSearchValue(e.target.value)}
|
||||||
suffix={
|
onPressEnter={handleSearch}
|
||||||
<Button
|
prefix={<SearchOutlined />}
|
||||||
type="link"
|
className={styles.searchInput}
|
||||||
size="small"
|
/>
|
||||||
onClick={handleSearch}
|
<Button
|
||||||
loading={isSearching}
|
type="primary"
|
||||||
>
|
onClick={handleSearch}
|
||||||
搜索
|
loading={isSearching}
|
||||||
</Button>
|
>
|
||||||
}
|
搜索
|
||||||
className={styles.searchInput}
|
</Button>
|
||||||
/>
|
</Space.Compact>
|
||||||
|
|
||||||
{/* 搜索结果列表 */}
|
{/* 搜索结果列表 */}
|
||||||
{searchResults.length > 0 && (
|
{searchResults.length > 0 && (
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import React, { useEffect, useState, useRef } from "react";
|
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import {
|
import { Layout, Button, message, Tooltip } from "antd";
|
||||||
Layout,
|
|
||||||
Input,
|
|
||||||
Button,
|
|
||||||
Modal,
|
|
||||||
message,
|
|
||||||
Tooltip,
|
|
||||||
AutoComplete,
|
|
||||||
Input as AntInput,
|
|
||||||
Spin,
|
|
||||||
} from "antd";
|
|
||||||
import {
|
import {
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
FolderOutlined,
|
FolderOutlined,
|
||||||
@@ -29,14 +19,17 @@ import AudioRecorder from "@/components/Upload/AudioRecorder";
|
|||||||
import ToContract from "./components/toContract";
|
import ToContract from "./components/toContract";
|
||||||
import styles from "./MessageEnter.module.scss";
|
import styles from "./MessageEnter.module.scss";
|
||||||
import {
|
import {
|
||||||
useWeChatStore,
|
|
||||||
clearAiRequestQueue,
|
clearAiRequestQueue,
|
||||||
manualTriggerAi,
|
manualTriggerAi,
|
||||||
} from "@/store/module/weChat/weChat";
|
} from "@/store/module/weChat/weChat";
|
||||||
|
import {
|
||||||
|
useUIStateSelectors,
|
||||||
|
useAISelectors,
|
||||||
|
} from "@/hooks/weChat/useWeChatSelectors";
|
||||||
|
import { useWeChatActions } from "@/hooks/weChat/useWeChatSelectors";
|
||||||
import { useContactStore } from "@/store/module/weChat/contacts";
|
import { useContactStore } from "@/store/module/weChat/contacts";
|
||||||
import SelectMap from "./components/selectMap";
|
import SelectMap from "./components/selectMap";
|
||||||
const { Footer } = Layout;
|
const { Footer } = Layout;
|
||||||
const { TextArea } = Input;
|
|
||||||
|
|
||||||
interface MessageEnterProps {
|
interface MessageEnterProps {
|
||||||
contract: ContractData | weChatGroup;
|
contract: ContractData | weChatGroup;
|
||||||
@@ -44,304 +37,455 @@ interface MessageEnterProps {
|
|||||||
|
|
||||||
const { sendCommand } = useWebSocketStore.getState();
|
const { sendCommand } = useWebSocketStore.getState();
|
||||||
|
|
||||||
const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
const FileType = {
|
||||||
const [inputValue, setInputValue] = useState("");
|
TEXT: 1,
|
||||||
const EnterModule = useWeChatStore(state => state.EnterModule);
|
IMAGE: 2,
|
||||||
const updateShowCheckbox = useWeChatStore(state => state.updateShowCheckbox);
|
VIDEO: 3,
|
||||||
const updateEnterModule = useWeChatStore(state => state.updateEnterModule);
|
AUDIO: 4,
|
||||||
const setTransmitModal = useContactStore(state => state.setTransmitModal);
|
FILE: 5,
|
||||||
const addMessage = useWeChatStore(state => state.addMessage);
|
};
|
||||||
const showChatRecordModel = useWeChatStore(
|
|
||||||
state => state.showChatRecordModel,
|
|
||||||
);
|
|
||||||
const updateShowChatRecordModel = useWeChatStore(
|
|
||||||
state => state.updateShowChatRecordModel,
|
|
||||||
);
|
|
||||||
|
|
||||||
const quoteMessageContent = useWeChatStore(
|
const IMAGE_FORMATS = [
|
||||||
state => state.quoteMessageContent,
|
"jpg",
|
||||||
);
|
"jpeg",
|
||||||
const isLoadingAiChat = useWeChatStore(state => state.isLoadingAiChat);
|
"png",
|
||||||
const updateIsLoadingAiChat = useWeChatStore(
|
"gif",
|
||||||
state => state.updateIsLoadingAiChat,
|
"bmp",
|
||||||
);
|
"webp",
|
||||||
const updateQuoteMessageContent = useWeChatStore(
|
"svg",
|
||||||
state => state.updateQuoteMessageContent,
|
"ico",
|
||||||
);
|
];
|
||||||
// 获取接待类型:0=人工接待, 1=AI辅助, 2=AI接管
|
|
||||||
const aiQuoteMessageContent = useWeChatStore(
|
const VIDEO_FORMATS = [
|
||||||
state => state.aiQuoteMessageContent,
|
"mp4",
|
||||||
);
|
"avi",
|
||||||
|
"mov",
|
||||||
|
"wmv",
|
||||||
|
"flv",
|
||||||
|
"mkv",
|
||||||
|
"webm",
|
||||||
|
"3gp",
|
||||||
|
"rmvb",
|
||||||
|
];
|
||||||
|
|
||||||
|
// 根据文件格式判断消息类型(纯函数,放在组件外避免重复创建)
|
||||||
|
const getMsgTypeByFileFormat = (filePath: string): number => {
|
||||||
|
const extension = filePath.toLowerCase().split(".").pop() || "";
|
||||||
|
|
||||||
|
if (IMAGE_FORMATS.includes(extension)) {
|
||||||
|
return 3; // 图片
|
||||||
|
}
|
||||||
|
|
||||||
|
if (VIDEO_FORMATS.includes(extension)) {
|
||||||
|
return 43; // 视频
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他格式默认为文件
|
||||||
|
return 49; // 文件
|
||||||
|
};
|
||||||
|
|
||||||
|
const InputToolbar = React.memo(
|
||||||
|
({
|
||||||
|
isAiAssist,
|
||||||
|
isAiTakeover,
|
||||||
|
isLoadingAiChat,
|
||||||
|
onEmojiSelect,
|
||||||
|
onFileUploaded,
|
||||||
|
onAudioUploaded,
|
||||||
|
onOpenMap,
|
||||||
|
onManualTriggerAi,
|
||||||
|
onOpenChatRecord,
|
||||||
|
}: {
|
||||||
|
isAiAssist: boolean;
|
||||||
|
isAiTakeover: boolean;
|
||||||
|
isLoadingAiChat: boolean;
|
||||||
|
onEmojiSelect: (emoji: EmojiInfo) => void;
|
||||||
|
onFileUploaded: (
|
||||||
|
filePath: { url: string; name: string; durationMs?: number },
|
||||||
|
fileType: number,
|
||||||
|
) => void;
|
||||||
|
onAudioUploaded: (audioData: {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
durationMs?: number;
|
||||||
|
}) => void;
|
||||||
|
onOpenMap: () => void;
|
||||||
|
onManualTriggerAi: () => void;
|
||||||
|
onOpenChatRecord: () => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.inputToolbar}>
|
||||||
|
<div className={styles.leftTool}>
|
||||||
|
<EmojiPicker onEmojiSelect={onEmojiSelect} />
|
||||||
|
<SimpleFileUpload
|
||||||
|
onFileUploaded={fileInfo => onFileUploaded(fileInfo, FileType.FILE)}
|
||||||
|
maxSize={10}
|
||||||
|
type={4}
|
||||||
|
slot={
|
||||||
|
<Button
|
||||||
|
className={styles.toolbarButton}
|
||||||
|
type="text"
|
||||||
|
icon={<FolderOutlined />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SimpleFileUpload
|
||||||
|
onFileUploaded={fileInfo =>
|
||||||
|
onFileUploaded(fileInfo, FileType.IMAGE)
|
||||||
|
}
|
||||||
|
maxSize={10}
|
||||||
|
type={1}
|
||||||
|
slot={
|
||||||
|
<Button
|
||||||
|
className={styles.toolbarButton}
|
||||||
|
type="text"
|
||||||
|
icon={<PictureOutlined />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AudioRecorder
|
||||||
|
onAudioUploaded={onAudioUploaded}
|
||||||
|
className={styles.toolbarButton}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className={styles.toolbarButton}
|
||||||
|
type="text"
|
||||||
|
icon={<EnvironmentOutlined />}
|
||||||
|
onClick={onOpenMap}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* AI模式下显示重新生成按钮 */}
|
||||||
|
{(isAiAssist || isAiTakeover) && (
|
||||||
|
<Tooltip title="重新生成AI回复">
|
||||||
|
<Button
|
||||||
|
className={styles.toolbarButton}
|
||||||
|
type="text"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={onManualTriggerAi}
|
||||||
|
disabled={isLoadingAiChat}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.rightTool}>
|
||||||
|
<ToContract className={styles.rightToolItem} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#666",
|
||||||
|
}}
|
||||||
|
onClick={onOpenChatRecord}
|
||||||
|
>
|
||||||
|
<MessageOutlined />
|
||||||
|
聊天记录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
InputToolbar.displayName = "InputToolbar";
|
||||||
|
|
||||||
|
const MemoSelectMap: React.FC<React.ComponentProps<typeof SelectMap>> =
|
||||||
|
React.memo(props => <SelectMap {...props} />);
|
||||||
|
|
||||||
|
MemoSelectMap.displayName = "MemoSelectMap";
|
||||||
|
|
||||||
|
const MessageEnterComponent: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
// ✅ 使用 useRef 存储 inputValue,避免 handleSend 依赖变化
|
||||||
|
const inputValueRef = useRef(inputValue);
|
||||||
|
|
||||||
|
// 同步 inputValue 到 ref
|
||||||
|
useEffect(() => {
|
||||||
|
inputValueRef.current = inputValue;
|
||||||
|
}, [inputValue]);
|
||||||
|
|
||||||
|
// ✅ 使用优化的 selector(合并多个 selector,减少重渲染)
|
||||||
|
const { EnterModule, showChatRecordModel } = useUIStateSelectors();
|
||||||
|
const { isLoadingAiChat, quoteMessageContent, aiQuoteMessageContent } =
|
||||||
|
useAISelectors();
|
||||||
|
|
||||||
|
const {
|
||||||
|
updateShowCheckbox,
|
||||||
|
updateEnterModule,
|
||||||
|
addMessage,
|
||||||
|
updateShowChatRecordModel,
|
||||||
|
updateIsLoadingAiChat,
|
||||||
|
updateQuoteMessageContent,
|
||||||
|
} = useWeChatActions();
|
||||||
|
|
||||||
|
const setTransmitModal = useContactStore(state => state.setTransmitModal);
|
||||||
|
|
||||||
// 判断接待类型
|
// 判断接待类型
|
||||||
const isAiAssist = aiQuoteMessageContent === 1; // AI辅助
|
const isAiAssist = aiQuoteMessageContent === 1; // AI辅助
|
||||||
const isAiTakeover = aiQuoteMessageContent === 2; // AI接管
|
const isAiTakeover = aiQuoteMessageContent === 2; // AI接管
|
||||||
|
|
||||||
// 取消AI生成
|
// 取消AI生成
|
||||||
const handleCancelAi = () => {
|
const handleCancelAi = useCallback(() => {
|
||||||
// 清除AI请求定时器和队列
|
|
||||||
clearAiRequestQueue("用户手动取消");
|
clearAiRequestQueue("用户手动取消");
|
||||||
// 停止AI加载状态
|
|
||||||
updateIsLoadingAiChat(false);
|
updateIsLoadingAiChat(false);
|
||||||
// 清空AI回复内容
|
|
||||||
updateQuoteMessageContent("");
|
updateQuoteMessageContent("");
|
||||||
message.info("已取消AI生成");
|
message.info("已取消AI生成");
|
||||||
};
|
}, [updateIsLoadingAiChat, updateQuoteMessageContent]);
|
||||||
|
|
||||||
// 监听输入框变化 - 用户开始输入时取消AI
|
// 监听输入框变化 - 用户开始输入时取消AI
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInputChange = useCallback(
|
||||||
const newValue = e.target.value;
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setInputValue(newValue);
|
const newValue = e.target.value;
|
||||||
|
setInputValue(newValue);
|
||||||
|
|
||||||
// 如果用户开始输入(且不是AI填充的内容),取消AI请求
|
// 如果用户开始输入(且不是AI填充的内容),取消AI请求
|
||||||
if (newValue && newValue !== quoteMessageContent) {
|
if (newValue && newValue !== quoteMessageContent && isLoadingAiChat) {
|
||||||
if (isLoadingAiChat) {
|
|
||||||
console.log("👤 用户开始输入,取消AI生成");
|
console.log("👤 用户开始输入,取消AI生成");
|
||||||
clearAiRequestQueue("用户开始输入");
|
clearAiRequestQueue("用户开始输入");
|
||||||
updateIsLoadingAiChat(false);
|
updateIsLoadingAiChat(false);
|
||||||
updateQuoteMessageContent("");
|
updateQuoteMessageContent("");
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
[
|
||||||
|
isLoadingAiChat,
|
||||||
|
quoteMessageContent,
|
||||||
|
updateIsLoadingAiChat,
|
||||||
|
updateQuoteMessageContent,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// 手动触发AI生成
|
// 手动触发AI生成
|
||||||
const handleManualTriggerAi = async () => {
|
const handleManualTriggerAi = useCallback(async () => {
|
||||||
const success = await manualTriggerAi();
|
const success = await manualTriggerAi();
|
||||||
if (success) {
|
if (success) {
|
||||||
message.success("AI正在生成回复...");
|
message.success("AI正在生成回复...");
|
||||||
} else {
|
} else {
|
||||||
message.warning("无法生成AI回复,请检查消息记录");
|
message.warning("无法生成AI回复,请检查消息记录");
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// 发送消息(支持传入内容参数,避免闭包问题)
|
// ✅ 发送消息(使用 useRef 避免依赖 inputValue,减少函数重新创建)
|
||||||
const handleSend = async (content?: string) => {
|
const handleSend = useCallback(
|
||||||
const messageContent = content || inputValue; // 优先使用传入的内容
|
async (content?: string) => {
|
||||||
|
const messageContent = content || inputValueRef.current; // 优先使用传入的内容,否则使用 ref
|
||||||
|
|
||||||
if (!messageContent || !messageContent.trim()) {
|
if (!messageContent || !messageContent.trim()) {
|
||||||
console.warn("消息内容为空,取消发送");
|
console.warn("消息内容为空,取消发送");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户主动发送消息时,取消AI请求
|
// 用户主动发送消息时,取消AI请求
|
||||||
if (!content && isLoadingAiChat) {
|
if (!content && isLoadingAiChat) {
|
||||||
console.log("👤 用户主动发送消息,取消AI生成");
|
console.log("👤 用户主动发送消息,取消AI生成");
|
||||||
clearAiRequestQueue("用户主动发送");
|
clearAiRequestQueue("用户主动发送");
|
||||||
updateIsLoadingAiChat(false);
|
updateIsLoadingAiChat(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("handleSend", messageContent);
|
console.log("handleSend", messageContent);
|
||||||
const messageId = +Date.now();
|
const messageId = +Date.now();
|
||||||
// 构造本地消息对象
|
// 构造本地消息对象
|
||||||
const localMessage: ChatRecord = {
|
const localMessage: ChatRecord = {
|
||||||
id: messageId, // 使用时间戳作为临时ID
|
id: messageId, // 使用时间戳作为临时ID
|
||||||
wechatAccountId: contract.wechatAccountId,
|
wechatAccountId: contract.wechatAccountId,
|
||||||
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
|
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
|
||||||
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
|
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
|
||||||
tenantId: 0,
|
tenantId: 0,
|
||||||
accountId: 0,
|
accountId: 0,
|
||||||
synergyAccountId: 0,
|
synergyAccountId: 0,
|
||||||
content: messageContent,
|
content: messageContent,
|
||||||
msgType: 1,
|
msgType: 1,
|
||||||
msgSubType: 0,
|
msgSubType: 0,
|
||||||
msgSvrId: "",
|
msgSvrId: "",
|
||||||
isSend: true, // 标记为发送中
|
isSend: true, // 标记为发送中
|
||||||
createTime: new Date().toISOString(),
|
createTime: new Date().toISOString(),
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
deleteTime: "",
|
deleteTime: "",
|
||||||
sendStatus: 1,
|
sendStatus: 1,
|
||||||
wechatTime: Date.now(),
|
wechatTime: Date.now(),
|
||||||
origin: 0,
|
origin: 0,
|
||||||
msgId: 0,
|
msgId: 0,
|
||||||
recalled: false,
|
recalled: false,
|
||||||
seq: messageId,
|
seq: messageId,
|
||||||
};
|
};
|
||||||
// 先插入本地数据
|
// 先插入本地数据
|
||||||
addMessage(localMessage);
|
addMessage(localMessage);
|
||||||
|
|
||||||
// 再发送消息到服务器
|
// 再发送消息到服务器
|
||||||
const params = {
|
const params = {
|
||||||
wechatAccountId: contract.wechatAccountId,
|
wechatAccountId: contract.wechatAccountId,
|
||||||
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
|
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
|
||||||
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
|
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
|
||||||
msgSubType: 0,
|
msgSubType: 0,
|
||||||
msgType: 1,
|
msgType: 1,
|
||||||
content: messageContent,
|
content: messageContent,
|
||||||
seq: messageId,
|
seq: messageId,
|
||||||
};
|
};
|
||||||
sendCommand("CmdSendMessage", params);
|
sendCommand("CmdSendMessage", params);
|
||||||
|
|
||||||
// 清空输入框和AI回复内容
|
// 清空输入框和AI回复内容
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
updateQuoteMessageContent("");
|
updateQuoteMessageContent("");
|
||||||
};
|
},
|
||||||
|
[
|
||||||
|
addMessage,
|
||||||
|
contract.id,
|
||||||
|
contract.wechatAccountId,
|
||||||
|
contract?.chatroomId,
|
||||||
|
// ✅ 移除 inputValue 依赖,使用 ref 代替
|
||||||
|
isLoadingAiChat,
|
||||||
|
updateIsLoadingAiChat,
|
||||||
|
updateQuoteMessageContent,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// AI 消息处理 - 只处理AI辅助模式
|
// AI 消息处理 - 只处理AI辅助模式
|
||||||
// AI接管模式已经在weChat.ts中直接发送,不经过此组件
|
// AI接管模式已经在weChat.ts中直接发送,不经过此组件
|
||||||
// 快捷语填充:当 quoteMessageContent 更新时,填充到输入框
|
// 快捷语填充:当 quoteMessageContent 更新时,填充到输入框
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (quoteMessageContent) {
|
if (quoteMessageContent) {
|
||||||
if (isAiAssist) {
|
// AI辅助模式 & 快捷语模式:都直接填充输入框
|
||||||
// AI辅助模式:直接填充输入框
|
setInputValue(quoteMessageContent);
|
||||||
setInputValue(quoteMessageContent);
|
|
||||||
} else {
|
|
||||||
// 快捷语模式:直接填充输入框(用户主动点击快捷语,应该替换当前内容)
|
|
||||||
setInputValue(quoteMessageContent);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [quoteMessageContent, aiQuoteMessageContent, isAiAssist]);
|
}, [quoteMessageContent, aiQuoteMessageContent, isAiAssist]);
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
const handleKeyPress = useCallback(
|
||||||
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey) {
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
e.preventDefault();
|
// 中文等输入法候选阶段,忽略 Enter,避免误触发送和卡顿感
|
||||||
handleSend();
|
if ((e.nativeEvent as any).isComposing) {
|
||||||
}
|
return;
|
||||||
// Ctrl+Enter 换行由 TextArea 自动处理,不需要阻止默认行为
|
}
|
||||||
};
|
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
// Ctrl+Enter 换行由浏览器原生 textarea 处理,不需要阻止默认行为
|
||||||
|
},
|
||||||
|
[handleSend],
|
||||||
|
);
|
||||||
|
|
||||||
// 处理表情选择
|
// 处理表情选择
|
||||||
const handleEmojiSelect = (emoji: EmojiInfo) => {
|
const handleEmojiSelect = useCallback((emoji: EmojiInfo) => {
|
||||||
setInputValue(prevValue => prevValue + `[${emoji.name}]`);
|
setInputValue(prevValue => prevValue + `[${emoji.name}]`);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// 根据文件格式判断消息类型
|
const handleFileUploaded = useCallback(
|
||||||
const getMsgTypeByFileFormat = (filePath: string): number => {
|
(
|
||||||
const extension = filePath.toLowerCase().split(".").pop() || "";
|
filePath: { url: string; name: string; durationMs?: number },
|
||||||
|
fileType: number,
|
||||||
|
) => {
|
||||||
|
console.log("handleFileUploaded: ", fileType, filePath);
|
||||||
|
|
||||||
// 图片格式
|
// msgType(1:文本 3:图片 43:视频 47:动图表情包(gif、其他表情包) 49:小程序/其他:图文、文件)
|
||||||
const imageFormats = [
|
let msgType = 1;
|
||||||
"jpg",
|
let content: any = "";
|
||||||
"jpeg",
|
if ([FileType.TEXT].includes(fileType)) {
|
||||||
"png",
|
msgType = getMsgTypeByFileFormat(filePath.url);
|
||||||
"gif",
|
} else if ([FileType.IMAGE].includes(fileType)) {
|
||||||
"bmp",
|
msgType = 3;
|
||||||
"webp",
|
|
||||||
"svg",
|
|
||||||
"ico",
|
|
||||||
];
|
|
||||||
if (imageFormats.includes(extension)) {
|
|
||||||
return 3; // 图片
|
|
||||||
}
|
|
||||||
|
|
||||||
// 视频格式
|
|
||||||
const videoFormats = [
|
|
||||||
"mp4",
|
|
||||||
"avi",
|
|
||||||
"mov",
|
|
||||||
"wmv",
|
|
||||||
"flv",
|
|
||||||
"mkv",
|
|
||||||
"webm",
|
|
||||||
"3gp",
|
|
||||||
"rmvb",
|
|
||||||
];
|
|
||||||
if (videoFormats.includes(extension)) {
|
|
||||||
return 43; // 视频
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他格式默认为文件
|
|
||||||
return 49; // 文件
|
|
||||||
};
|
|
||||||
const FileType = {
|
|
||||||
TEXT: 1,
|
|
||||||
IMAGE: 2,
|
|
||||||
VIDEO: 3,
|
|
||||||
AUDIO: 4,
|
|
||||||
FILE: 5,
|
|
||||||
};
|
|
||||||
const handleFileUploaded = (
|
|
||||||
filePath: { url: string; name: string; durationMs?: number },
|
|
||||||
fileType: number,
|
|
||||||
) => {
|
|
||||||
console.log("handleFileUploaded: ", fileType, filePath);
|
|
||||||
|
|
||||||
// msgType(1:文本 3:图片 43:视频 47:动图表情包(gif、其他表情包) 49:小程序/其他:图文、文件)
|
|
||||||
let msgType = 1;
|
|
||||||
let content: any = "";
|
|
||||||
if ([FileType.TEXT].includes(fileType)) {
|
|
||||||
msgType = getMsgTypeByFileFormat(filePath.url);
|
|
||||||
} else if ([FileType.IMAGE].includes(fileType)) {
|
|
||||||
msgType = 3;
|
|
||||||
content = filePath.url;
|
|
||||||
} else if ([FileType.AUDIO].includes(fileType)) {
|
|
||||||
msgType = 34;
|
|
||||||
content = JSON.stringify({
|
|
||||||
url: filePath.url,
|
|
||||||
durationMs: filePath.durationMs,
|
|
||||||
});
|
|
||||||
} else if ([FileType.FILE].includes(fileType)) {
|
|
||||||
msgType = getMsgTypeByFileFormat(filePath.url);
|
|
||||||
if (msgType === 3) {
|
|
||||||
content = filePath.url;
|
content = filePath.url;
|
||||||
}
|
} else if ([FileType.AUDIO].includes(fileType)) {
|
||||||
if (msgType === 43) {
|
msgType = 34;
|
||||||
content = filePath.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msgType === 49) {
|
|
||||||
content = JSON.stringify({
|
content = JSON.stringify({
|
||||||
type: "file",
|
|
||||||
title: filePath.name,
|
|
||||||
url: filePath.url,
|
url: filePath.url,
|
||||||
|
durationMs: filePath.durationMs,
|
||||||
});
|
});
|
||||||
|
} else if ([FileType.FILE].includes(fileType)) {
|
||||||
|
msgType = getMsgTypeByFileFormat(filePath.url);
|
||||||
|
if (msgType === 3) {
|
||||||
|
content = filePath.url;
|
||||||
|
}
|
||||||
|
if (msgType === 43) {
|
||||||
|
content = filePath.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msgType === 49) {
|
||||||
|
content = JSON.stringify({
|
||||||
|
type: "file",
|
||||||
|
title: filePath.name,
|
||||||
|
url: filePath.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
const messageId = +Date.now();
|
||||||
const messageId = +Date.now();
|
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
wechatAccountId: contract.wechatAccountId,
|
wechatAccountId: contract.wechatAccountId,
|
||||||
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
|
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
|
||||||
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
|
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
|
||||||
msgSubType: 0,
|
msgSubType: 0,
|
||||||
msgType,
|
msgType,
|
||||||
content: content,
|
content: content,
|
||||||
seq: messageId,
|
seq: messageId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 构造本地消息对象
|
// 构造本地消息对象
|
||||||
const localMessage: ChatRecord = {
|
const localMessage: ChatRecord = {
|
||||||
id: messageId, // 使用时间戳作为临时ID
|
id: messageId, // 使用时间戳作为临时ID
|
||||||
wechatAccountId: contract.wechatAccountId,
|
wechatAccountId: contract.wechatAccountId,
|
||||||
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
|
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
|
||||||
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
|
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
|
||||||
tenantId: 0,
|
tenantId: 0,
|
||||||
accountId: 0,
|
accountId: 0,
|
||||||
synergyAccountId: 0,
|
synergyAccountId: 0,
|
||||||
content: params.content,
|
content: params.content,
|
||||||
msgType: msgType,
|
msgType: msgType,
|
||||||
msgSubType: 0,
|
msgSubType: 0,
|
||||||
msgSvrId: "",
|
msgSvrId: "",
|
||||||
isSend: true, // 标记为发送中
|
isSend: true, // 标记为发送中
|
||||||
createTime: new Date().toISOString(),
|
createTime: new Date().toISOString(),
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
deleteTime: "",
|
deleteTime: "",
|
||||||
sendStatus: 1,
|
sendStatus: 1,
|
||||||
wechatTime: Date.now(),
|
wechatTime: Date.now(),
|
||||||
origin: 0,
|
origin: 0,
|
||||||
msgId: 0,
|
msgId: 0,
|
||||||
recalled: false,
|
recalled: false,
|
||||||
seq: messageId,
|
seq: messageId,
|
||||||
};
|
};
|
||||||
// 先插入本地数据
|
// 先插入本地数据
|
||||||
addMessage(localMessage);
|
addMessage(localMessage);
|
||||||
|
|
||||||
sendCommand("CmdSendMessage", params);
|
sendCommand("CmdSendMessage", params);
|
||||||
};
|
},
|
||||||
|
[addMessage, contract.wechatAccountId, contract.chatroomId, contract.id],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCancelAction = () => {
|
const handleCancelAction = useCallback(() => {
|
||||||
|
if (!EnterModule) return;
|
||||||
updateShowCheckbox(false);
|
updateShowCheckbox(false);
|
||||||
updateEnterModule("common");
|
updateEnterModule("common");
|
||||||
};
|
}, [EnterModule, updateShowCheckbox, updateEnterModule]);
|
||||||
const handTurnRignt = () => {
|
|
||||||
|
const handTurnRignt = useCallback(() => {
|
||||||
setTransmitModal(true);
|
setTransmitModal(true);
|
||||||
};
|
}, [setTransmitModal]);
|
||||||
const openChatRecordModel = () => {
|
|
||||||
|
const openChatRecordModel = useCallback(() => {
|
||||||
updateShowChatRecordModel(!showChatRecordModel);
|
updateShowChatRecordModel(!showChatRecordModel);
|
||||||
};
|
}, [showChatRecordModel, updateShowChatRecordModel]);
|
||||||
|
|
||||||
const [mapVisible, setMapVisible] = useState(false);
|
const [mapVisible, setMapVisible] = useState(false);
|
||||||
|
|
||||||
|
const handleOpenMap = useCallback(() => {
|
||||||
|
setMapVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAudioUploaded = useCallback(
|
||||||
|
(audioData: { name: string; url: string; durationMs?: number }) => {
|
||||||
|
handleFileUploaded(
|
||||||
|
{
|
||||||
|
name: audioData.name,
|
||||||
|
url: audioData.url,
|
||||||
|
durationMs: audioData.durationMs,
|
||||||
|
},
|
||||||
|
FileType.AUDIO,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[handleFileUploaded],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 聊天输入 */}
|
{/* 聊天输入 */}
|
||||||
@@ -394,96 +538,27 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
|||||||
<>
|
<>
|
||||||
{["common"].includes(EnterModule) && (
|
{["common"].includes(EnterModule) && (
|
||||||
<div className={styles.inputContainer}>
|
<div className={styles.inputContainer}>
|
||||||
<div className={styles.inputToolbar}>
|
<InputToolbar
|
||||||
<div className={styles.leftTool}>
|
isAiAssist={isAiAssist}
|
||||||
<EmojiPicker onEmojiSelect={handleEmojiSelect} />
|
isAiTakeover={isAiTakeover}
|
||||||
<SimpleFileUpload
|
isLoadingAiChat={isLoadingAiChat}
|
||||||
onFileUploaded={fileInfo =>
|
onEmojiSelect={handleEmojiSelect}
|
||||||
handleFileUploaded(fileInfo, FileType.FILE)
|
onFileUploaded={handleFileUploaded}
|
||||||
}
|
onAudioUploaded={handleAudioUploaded}
|
||||||
maxSize={10}
|
onOpenMap={handleOpenMap}
|
||||||
type={4}
|
onManualTriggerAi={handleManualTriggerAi}
|
||||||
slot={
|
onOpenChatRecord={openChatRecordModel}
|
||||||
<Button
|
/>
|
||||||
className={styles.toolbarButton}
|
|
||||||
type="text"
|
|
||||||
icon={<FolderOutlined />}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SimpleFileUpload
|
|
||||||
onFileUploaded={fileInfo =>
|
|
||||||
handleFileUploaded(fileInfo, FileType.IMAGE)
|
|
||||||
}
|
|
||||||
maxSize={10}
|
|
||||||
type={1}
|
|
||||||
slot={
|
|
||||||
<Button
|
|
||||||
className={styles.toolbarButton}
|
|
||||||
type="text"
|
|
||||||
icon={<PictureOutlined />}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AudioRecorder
|
|
||||||
onAudioUploaded={audioData =>
|
|
||||||
handleFileUploaded(
|
|
||||||
{
|
|
||||||
name: audioData.name,
|
|
||||||
url: audioData.url,
|
|
||||||
durationMs: audioData.durationMs,
|
|
||||||
},
|
|
||||||
FileType.AUDIO,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={styles.toolbarButton}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
className={styles.toolbarButton}
|
|
||||||
type="text"
|
|
||||||
icon={<EnvironmentOutlined />}
|
|
||||||
onClick={() => setMapVisible(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* AI模式下显示重新生成按钮 */}
|
|
||||||
{(isAiAssist || isAiTakeover) && (
|
|
||||||
<Tooltip title="重新生成AI回复">
|
|
||||||
<Button
|
|
||||||
className={styles.toolbarButton}
|
|
||||||
type="text"
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
onClick={handleManualTriggerAi}
|
|
||||||
disabled={isLoadingAiChat}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.rightTool}>
|
|
||||||
<ToContract className={styles.rightToolItem} />
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "12px",
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "#666",
|
|
||||||
}}
|
|
||||||
onClick={openChatRecordModel}
|
|
||||||
>
|
|
||||||
<MessageOutlined />
|
|
||||||
聊天记录
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.inputArea}>
|
<div className={styles.inputArea}>
|
||||||
<div className={styles.inputWrapper}>
|
<div className={styles.inputWrapper}>
|
||||||
<TextArea
|
<textarea
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleKeyPress}
|
onKeyDown={handleKeyPress}
|
||||||
placeholder="输入消息..."
|
placeholder="输入消息..."
|
||||||
className={styles.messageInput}
|
className={styles.messageInput}
|
||||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={styles.sendButtonArea}>
|
<div className={styles.sendButtonArea}>
|
||||||
@@ -524,7 +599,7 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Footer>
|
</Footer>
|
||||||
<SelectMap
|
<MemoSelectMap
|
||||||
visible={mapVisible}
|
visible={mapVisible}
|
||||||
onClose={() => setMapVisible(false)}
|
onClose={() => setMapVisible(false)}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
@@ -534,4 +609,15 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ✅ 使用 React.memo 优化 MessageEnter 组件,避免不必要的重渲染
|
||||||
|
const MessageEnter = React.memo(
|
||||||
|
MessageEnterComponent,
|
||||||
|
(prev, next) => {
|
||||||
|
// 只有当联系人 ID 变化时才重新渲染
|
||||||
|
return prev.contract.id === next.contract.id;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
MessageEnter.displayName = "MessageEnter";
|
||||||
|
|
||||||
export default MessageEnter;
|
export default MessageEnter;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
.redPacketMessage {
|
.redPacketMessage {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
max-width: 300px;
|
width: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.redPacketCard {
|
.redPacketCard {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||||
|
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||||
import styles from "./RedPacketMessage.module.scss";
|
import styles from "./RedPacketMessage.module.scss";
|
||||||
|
|
||||||
interface RedPacketData {
|
interface RedPacketData {
|
||||||
@@ -10,9 +12,15 @@ interface RedPacketData {
|
|||||||
|
|
||||||
interface RedPacketMessageProps {
|
interface RedPacketMessageProps {
|
||||||
content: string;
|
content: string;
|
||||||
|
msg?: ChatRecord;
|
||||||
|
contract?: ContractData | weChatGroup;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RedPacketMessage: React.FC<RedPacketMessageProps> = ({ content }) => {
|
const RedPacketMessage: React.FC<RedPacketMessageProps> = ({
|
||||||
|
content,
|
||||||
|
msg,
|
||||||
|
contract,
|
||||||
|
}) => {
|
||||||
const renderErrorMessage = (fallbackText: string) => (
|
const renderErrorMessage = (fallbackText: string) => (
|
||||||
<div className={styles.messageText}>{fallbackText}</div>
|
<div className={styles.messageText}>{fallbackText}</div>
|
||||||
);
|
);
|
||||||
@@ -39,10 +47,42 @@ const RedPacketMessage: React.FC<RedPacketMessageProps> = ({ content }) => {
|
|||||||
|
|
||||||
const title = jsonData.sendertitle || "恭喜发财,大吉大利";
|
const title = jsonData.sendertitle || "恭喜发财,大吉大利";
|
||||||
const paymsgid = jsonData.paymsgid || "";
|
const paymsgid = jsonData.paymsgid || "";
|
||||||
|
const nativeurl = jsonData.nativeurl || "";
|
||||||
|
|
||||||
|
// 处理红包点击事件,发送 socket 请求
|
||||||
|
const handleRedPacketClick = () => {
|
||||||
|
if (!contract || !paymsgid || !nativeurl) {
|
||||||
|
console.warn("红包点击失败:缺少必要参数", {
|
||||||
|
contract,
|
||||||
|
paymsgid,
|
||||||
|
nativeurl,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGroup = !!contract.chatroomId;
|
||||||
|
const wechatFriendId = isGroup ? 0 : contract.id;
|
||||||
|
|
||||||
|
// 发送 socket 请求
|
||||||
|
useWebSocketStore.getState().sendCommand("CmdOpenLuckyMoney", {
|
||||||
|
wechatAccountId: contract.wechatAccountId,
|
||||||
|
wechatFriendId: wechatFriendId,
|
||||||
|
paymsgid: paymsgid,
|
||||||
|
nativeurl: nativeurl,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("发送红包打开请求:", {
|
||||||
|
cmdType: "CmdOpenLuckyMoney",
|
||||||
|
wechatAccountId: contract.wechatAccountId,
|
||||||
|
wechatFriendId: wechatFriendId,
|
||||||
|
paymsgid: paymsgid,
|
||||||
|
nativeurl: nativeurl,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.redPacketMessage}>
|
<div className={styles.redPacketMessage}>
|
||||||
<div className={styles.redPacketCard}>
|
<div className={styles.redPacketCard} onClick={handleRedPacketClick}>
|
||||||
<div className={styles.redPacketHeader}>
|
<div className={styles.redPacketHeader}>
|
||||||
<div className={styles.redPacketIcon}>🧧</div>
|
<div className={styles.redPacketIcon}>🧧</div>
|
||||||
<div className={styles.redPacketTitle}>{title}</div>
|
<div className={styles.redPacketTitle}>{title}</div>
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// 转账消息样式
|
||||||
|
.transferMessage {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
width: 260px;
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transferCard {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 12px;
|
||||||
|
background: #ff9500; // 橙色背景
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 149, 0, 0.2);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 149, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.transferDisabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 149, 0, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.transferHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transferIcon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transferAmount {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #ffffff;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transferStatus {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transferDivider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transferFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transferLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息文本样式(用于错误提示)
|
||||||
|
.messageText {
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式设计
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.transferMessage {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transferCard {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transferIcon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.transferAmount {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transferStatus {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transferLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { SwapOutlined } from "@ant-design/icons";
|
||||||
|
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||||
|
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||||
|
import styles from "./TransferMessage.module.scss";
|
||||||
|
|
||||||
|
interface TransferData {
|
||||||
|
title?: string;
|
||||||
|
feedesc?: string;
|
||||||
|
payMemo?: string;
|
||||||
|
transferid?: string;
|
||||||
|
transcationid?: string;
|
||||||
|
invalidtime?: string;
|
||||||
|
paysubtype?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransferMessageProps {
|
||||||
|
content: string;
|
||||||
|
msg?: ChatRecord;
|
||||||
|
contract?: ContractData | weChatGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TransferMessage: React.FC<TransferMessageProps> = ({
|
||||||
|
content,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
msg, // 保留参数以保持与 RedPacketMessage 组件的一致性,未来可能会用到
|
||||||
|
contract,
|
||||||
|
}) => {
|
||||||
|
const renderErrorMessage = (fallbackText: string) => (
|
||||||
|
<div className={styles.messageText}>{fallbackText}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof content !== "string" || !content.trim()) {
|
||||||
|
return renderErrorMessage("[转账消息 - 无效内容]");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const trimmedContent = content.trim();
|
||||||
|
const jsonData: TransferData = JSON.parse(trimmedContent);
|
||||||
|
|
||||||
|
// 验证是否为转账消息
|
||||||
|
const isTransfer =
|
||||||
|
jsonData.title === "微信转账" ||
|
||||||
|
(jsonData.transferid && jsonData.feedesc);
|
||||||
|
|
||||||
|
if (!isTransfer) {
|
||||||
|
return renderErrorMessage("[转账消息 - 格式错误]");
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = jsonData.feedesc || "¥0.00";
|
||||||
|
|
||||||
|
// 判断转账状态
|
||||||
|
const getTransferStatus = (
|
||||||
|
data: TransferData,
|
||||||
|
): { text: string; canClick: boolean } => {
|
||||||
|
const paySubType = data.paysubtype || "";
|
||||||
|
|
||||||
|
switch (paySubType) {
|
||||||
|
case "1":
|
||||||
|
return { text: "待朋友确认收钱", canClick: true };
|
||||||
|
case "2":
|
||||||
|
return { text: "已过期", canClick: false };
|
||||||
|
case "3":
|
||||||
|
return { text: "已领取", canClick: false };
|
||||||
|
case "4":
|
||||||
|
return { text: "已退回", canClick: false };
|
||||||
|
default:
|
||||||
|
// 默认情况:可能是待领取
|
||||||
|
return { text: "待朋友确认收钱", canClick: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { text: statusText, canClick } = getTransferStatus(jsonData);
|
||||||
|
|
||||||
|
// 处理转账点击事件,发送 socket 请求
|
||||||
|
const handleTransferClick = () => {
|
||||||
|
// 如果状态不允许点击,直接返回
|
||||||
|
if (!canClick) {
|
||||||
|
console.log("转账状态不允许点击:", statusText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!contract ||
|
||||||
|
!jsonData.transferid ||
|
||||||
|
!jsonData.transcationid ||
|
||||||
|
!jsonData.invalidtime ||
|
||||||
|
!jsonData.paysubtype
|
||||||
|
) {
|
||||||
|
console.warn("转账点击失败:缺少必要参数", {
|
||||||
|
contract,
|
||||||
|
transferid: jsonData.transferid,
|
||||||
|
transcationid: jsonData.transcationid,
|
||||||
|
invalidtime: jsonData.invalidtime,
|
||||||
|
paysubtype: jsonData.paysubtype,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGroup = !!contract.chatroomId;
|
||||||
|
const wechatFriendId = isGroup ? 0 : contract.id;
|
||||||
|
|
||||||
|
// 发送 socket 请求
|
||||||
|
useWebSocketStore.getState().sendCommand("CmdReceiveTransMoney", {
|
||||||
|
wechatAccountId: contract.wechatAccountId,
|
||||||
|
wechatFriendId: wechatFriendId,
|
||||||
|
transcationid: jsonData.transcationid,
|
||||||
|
transferid: jsonData.transferid,
|
||||||
|
invalidtime: jsonData.invalidtime,
|
||||||
|
paysubtype: jsonData.paysubtype,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("发送转账接收请求:", {
|
||||||
|
cmdType: "CmdReceiveTransMoney",
|
||||||
|
wechatAccountId: contract.wechatAccountId,
|
||||||
|
wechatFriendId: wechatFriendId,
|
||||||
|
transcationid: jsonData.transcationid,
|
||||||
|
transferid: jsonData.transferid,
|
||||||
|
invalidtime: jsonData.invalidtime,
|
||||||
|
paysubtype: jsonData.paysubtype,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.transferMessage}>
|
||||||
|
<div
|
||||||
|
className={`${styles.transferCard} ${!canClick ? styles.transferDisabled : ""}`}
|
||||||
|
onClick={handleTransferClick}
|
||||||
|
>
|
||||||
|
<div className={styles.transferHeader}>
|
||||||
|
<div className={styles.transferIcon}>
|
||||||
|
<SwapOutlined style={{ fontSize: 20 }} />
|
||||||
|
</div>
|
||||||
|
<div className="destion">
|
||||||
|
<div className={styles.transferAmount}>{amount}</div>
|
||||||
|
<div className={styles.transferStatus}>{statusText}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.transferDivider}></div>
|
||||||
|
<div className={styles.transferFooter}>
|
||||||
|
<span className={styles.transferLabel}>微信转账</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("转账消息解析失败:", e);
|
||||||
|
return renderErrorMessage("[转账消息 - 解析失败]");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TransferMessage;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
Input,
|
Input,
|
||||||
@@ -50,7 +50,7 @@ const TransmitModal: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 加载联系人数据
|
// 加载联系人数据
|
||||||
const loadContacts = async () => {
|
const loadContacts = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// 从统一联系人表加载所有联系人
|
// 从统一联系人表加载所有联系人
|
||||||
@@ -63,9 +63,9 @@ const TransmitModal: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [currentUserId]);
|
||||||
|
|
||||||
// 重置状态
|
// 重置状态 - 只在 openTransmitModal 变为 true 时执行
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (openTransmitModal) {
|
if (openTransmitModal) {
|
||||||
setSearchValue("");
|
setSearchValue("");
|
||||||
@@ -73,6 +73,8 @@ const TransmitModal: React.FC = () => {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
loadContacts();
|
loadContacts();
|
||||||
}
|
}
|
||||||
|
// 注意:loadContacts 已经在 useCallback 中稳定,但为了安全,我们只在 openTransmitModal 变化时执行
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [openTransmitModal]);
|
}, [openTransmitModal]);
|
||||||
|
|
||||||
// 过滤联系人 - 支持名称和拼音搜索
|
// 过滤联系人 - 支持名称和拼音搜索
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import React, { useRef, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { VariableSizeList, ListChildComponentProps } from "react-window";
|
||||||
|
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||||
|
import { MessageGroup } from "@/hooks/weChat/useMessageGrouping";
|
||||||
|
import { MessageItem } from "../index";
|
||||||
|
import { parseSystemMessage } from "@/utils/filter";
|
||||||
|
import styles from "../com.module.scss";
|
||||||
|
import { addPerformanceBreadcrumb } from "@/utils/sentry";
|
||||||
|
|
||||||
|
interface VirtualizedMessageListProps {
|
||||||
|
groupedMessages: MessageGroup[];
|
||||||
|
contract: ContractData | weChatGroup;
|
||||||
|
isGroupChat: boolean;
|
||||||
|
showCheckbox: boolean;
|
||||||
|
currentCustomerAvatar?: string;
|
||||||
|
renderGroupUser: (msg: ChatRecord) => { avatar: string; nickname: string };
|
||||||
|
clearWechatidInContent: (sender: any, content: string) => string;
|
||||||
|
parseMessageContent: (
|
||||||
|
content: string | null | undefined,
|
||||||
|
msg: ChatRecord,
|
||||||
|
msgType?: number,
|
||||||
|
) => React.ReactNode;
|
||||||
|
isMessageSelected: (msg: ChatRecord) => boolean;
|
||||||
|
onCheckboxChange: (checked: boolean, msg: ChatRecord) => void;
|
||||||
|
onContextMenu: (e: React.MouseEvent, msg: ChatRecord, isOwn: boolean) => void;
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>;
|
||||||
|
messagesEndRef: React.RefObject<HTMLDivElement>;
|
||||||
|
onScroll?: (scrollTop: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemData {
|
||||||
|
groups: MessageGroup[];
|
||||||
|
props: Omit<
|
||||||
|
VirtualizedMessageListProps,
|
||||||
|
"groupedMessages" | "containerRef" | "messagesEndRef" | "onScroll"
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 估算每个消息组的高度(像素)
|
||||||
|
* 根据消息数量和类型估算
|
||||||
|
*/
|
||||||
|
const estimateGroupHeight = (group: MessageGroup): number => {
|
||||||
|
let height = 40; // 时间分隔符高度
|
||||||
|
const messageCount = group.messages.filter(
|
||||||
|
v => ![10000, 570425393, 90000, -10001].includes(v.msgType),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// 基础消息项高度(包含间距)
|
||||||
|
const baseMessageHeight = 80;
|
||||||
|
// 系统消息高度
|
||||||
|
const systemMessageHeight = 30;
|
||||||
|
|
||||||
|
// 计算系统消息数量
|
||||||
|
const systemMessageCount = group.messages.filter(v =>
|
||||||
|
[10000, 570425393, 90000, -10001].includes(v.msgType),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
height += systemMessageCount * systemMessageHeight;
|
||||||
|
height += messageCount * baseMessageHeight;
|
||||||
|
|
||||||
|
return height;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 虚拟滚动消息列表项
|
||||||
|
*/
|
||||||
|
const VirtualizedMessageItem: React.FC<ListChildComponentProps<ItemData>> = ({
|
||||||
|
index,
|
||||||
|
style,
|
||||||
|
data,
|
||||||
|
}) => {
|
||||||
|
const { groups, props } = data;
|
||||||
|
const group = groups[index];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={style}>
|
||||||
|
{/* 时间分隔符 */}
|
||||||
|
{group.messages
|
||||||
|
.filter(v => [10000, -10001].includes(v.msgType))
|
||||||
|
.map(msg => {
|
||||||
|
const parsedText = parseSystemMessage(msg.content);
|
||||||
|
return (
|
||||||
|
<div key={`divider-${msg.id}`} className={styles.messageTime}>
|
||||||
|
{parsedText}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 其他系统消息 */}
|
||||||
|
{group.messages
|
||||||
|
.filter(v => [570425393, 90000].includes(v.msgType))
|
||||||
|
.map(msg => {
|
||||||
|
let displayContent = msg.content;
|
||||||
|
try {
|
||||||
|
const parsedContent = JSON.parse(msg.content);
|
||||||
|
if (
|
||||||
|
parsedContent &&
|
||||||
|
typeof parsedContent === "object" &&
|
||||||
|
parsedContent.content
|
||||||
|
) {
|
||||||
|
displayContent = parsedContent.content;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
displayContent = msg.content;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={`divider-${msg.id}`} className={styles.messageTime}>
|
||||||
|
{displayContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 时间标签 */}
|
||||||
|
<div className={styles.messageTime}>{group.time}</div>
|
||||||
|
|
||||||
|
{/* 消息项 */}
|
||||||
|
{group.messages
|
||||||
|
.filter(v => ![10000, 570425393, 90000, -10001].includes(v.msgType))
|
||||||
|
.map(msg => {
|
||||||
|
if (!msg) return null;
|
||||||
|
const isOwn = !!msg.isSend;
|
||||||
|
return (
|
||||||
|
<MessageItem
|
||||||
|
key={msg.id}
|
||||||
|
msg={msg}
|
||||||
|
contract={props.contract}
|
||||||
|
isGroup={props.isGroupChat}
|
||||||
|
showCheckbox={props.showCheckbox}
|
||||||
|
isSelected={props.isMessageSelected(msg)}
|
||||||
|
currentCustomerAvatar={props.currentCustomerAvatar || ""}
|
||||||
|
renderGroupUser={props.renderGroupUser}
|
||||||
|
clearWechatidInContent={props.clearWechatidInContent}
|
||||||
|
parseMessageContent={props.parseMessageContent}
|
||||||
|
onCheckboxChange={props.onCheckboxChange}
|
||||||
|
onContextMenu={e => props.onContextMenu(e, msg, isOwn)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 虚拟滚动消息列表
|
||||||
|
* 使用 react-window 实现虚拟滚动,提升长列表性能
|
||||||
|
*/
|
||||||
|
export const VirtualizedMessageList: React.FC<VirtualizedMessageListProps> = ({
|
||||||
|
groupedMessages,
|
||||||
|
containerRef,
|
||||||
|
messagesEndRef,
|
||||||
|
onScroll,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const listRef = useRef<VariableSizeList>(null);
|
||||||
|
const [listHeight, setListHeight] = React.useState(600);
|
||||||
|
const heightCacheRef = useRef<Map<number, number>>(new Map());
|
||||||
|
|
||||||
|
// 监听容器高度变化
|
||||||
|
useEffect(() => {
|
||||||
|
const updateHeight = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const height = containerRef.current.clientHeight;
|
||||||
|
setListHeight(height);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateHeight();
|
||||||
|
const resizeObserver = new ResizeObserver(updateHeight);
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [containerRef]);
|
||||||
|
|
||||||
|
// 获取每个项目的高度
|
||||||
|
const getItemSize = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
// 如果缓存中有,直接返回
|
||||||
|
if (heightCacheRef.current.has(index)) {
|
||||||
|
return heightCacheRef.current.get(index)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 估算高度
|
||||||
|
const group = groupedMessages[index];
|
||||||
|
const estimatedHeight = estimateGroupHeight(group);
|
||||||
|
heightCacheRef.current.set(index, estimatedHeight);
|
||||||
|
return estimatedHeight;
|
||||||
|
},
|
||||||
|
[groupedMessages],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当消息列表变化时,清除高度缓存
|
||||||
|
useEffect(() => {
|
||||||
|
heightCacheRef.current.clear();
|
||||||
|
if (listRef.current) {
|
||||||
|
listRef.current.resetAfterIndex(0);
|
||||||
|
}
|
||||||
|
}, [groupedMessages.length]);
|
||||||
|
|
||||||
|
// 当新消息到达时,滚动到底部
|
||||||
|
useEffect(() => {
|
||||||
|
if (listRef.current && groupedMessages.length > 0) {
|
||||||
|
// 延迟滚动,确保 DOM 已更新
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (listRef.current) {
|
||||||
|
// 滚动到最后一个项目
|
||||||
|
listRef.current.scrollToItem(groupedMessages.length - 1, "end");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [groupedMessages.length]);
|
||||||
|
|
||||||
|
// 处理滚动事件
|
||||||
|
const handleScroll = useCallback(
|
||||||
|
(event: { scrollOffset: number }) => {
|
||||||
|
if (onScroll) {
|
||||||
|
onScroll(event.scrollOffset);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onScroll],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 性能监控
|
||||||
|
useEffect(() => {
|
||||||
|
if (groupedMessages.length > 100) {
|
||||||
|
addPerformanceBreadcrumb("虚拟滚动启用", {
|
||||||
|
messageCount: groupedMessages.length,
|
||||||
|
groupCount: groupedMessages.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [groupedMessages.length]);
|
||||||
|
|
||||||
|
// 准备传递给列表项的数据
|
||||||
|
const itemData = useMemo<ItemData>(
|
||||||
|
() => ({
|
||||||
|
groups: groupedMessages,
|
||||||
|
props,
|
||||||
|
}),
|
||||||
|
[groupedMessages, props],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (groupedMessages.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<VariableSizeList
|
||||||
|
ref={listRef}
|
||||||
|
height={listHeight}
|
||||||
|
itemCount={groupedMessages.length}
|
||||||
|
itemSize={getItemSize}
|
||||||
|
width="100%"
|
||||||
|
itemData={itemData}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
overscanCount={5} // 预渲染 5 个项目,提升滚动流畅度
|
||||||
|
>
|
||||||
|
{VirtualizedMessageItem}
|
||||||
|
</VariableSizeList>
|
||||||
|
{/* 用于滚动到底部的锚点 */}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ import {
|
|||||||
import { comfirm } from "@/utils/common";
|
import { comfirm } from "@/utils/common";
|
||||||
|
|
||||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
// 单个朋友圈项目组件
|
// 单个朋友圈项目组件
|
||||||
export const FriendCard: React.FC<FriendCardProps> = ({
|
export const FriendCard: React.FC<FriendCardProps> = ({
|
||||||
monent,
|
monent,
|
||||||
@@ -37,7 +38,13 @@ export const FriendCard: React.FC<FriendCardProps> = ({
|
|||||||
const time = formatTime(monent.createTime);
|
const time = formatTime(monent.createTime);
|
||||||
const likesCount = monent?.likeList?.length || 0;
|
const likesCount = monent?.likeList?.length || 0;
|
||||||
const commentsCount = monent?.commentList?.length || 0;
|
const commentsCount = monent?.commentList?.length || 0;
|
||||||
const { updateLikeMoment, updateComment } = useWeChatStore();
|
// ✅ 使用 useShallow 避免 getSnapshot 警告
|
||||||
|
const { updateLikeMoment, updateComment } = useWeChatStore(
|
||||||
|
useShallow(state => ({
|
||||||
|
updateLikeMoment: state.updateLikeMoment,
|
||||||
|
updateComment: state.updateComment,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
// 评论相关状态
|
// 评论相关状态
|
||||||
const [showCommentInput, setShowCommentInput] = useState(false);
|
const [showCommentInput, setShowCommentInput] = useState(false);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import styles from "./index.module.scss";
|
|||||||
import { fetchFriendsCircleData } from "./api";
|
import { fetchFriendsCircleData } from "./api";
|
||||||
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
|
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
|
||||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
|
||||||
interface FriendsCircleProps {
|
interface FriendsCircleProps {
|
||||||
wechatFriendId?: number;
|
wechatFriendId?: number;
|
||||||
@@ -17,7 +18,13 @@ const FriendsCircle: React.FC<FriendsCircleProps> = ({ wechatFriendId }) => {
|
|||||||
const currentKf = useCkChatStore(state =>
|
const currentKf = useCkChatStore(state =>
|
||||||
state.kfUserList.find(kf => kf.id === state.kfSelected),
|
state.kfUserList.find(kf => kf.id === state.kfSelected),
|
||||||
);
|
);
|
||||||
const { clearMomentCommon, updateMomentCommonLoading } = useWeChatStore();
|
// ✅ 使用 useShallow 避免 getSnapshot 警告
|
||||||
|
const { clearMomentCommon, updateMomentCommonLoading } = useWeChatStore(
|
||||||
|
useShallow(state => ({
|
||||||
|
clearMomentCommon: state.clearMomentCommon,
|
||||||
|
updateMomentCommonLoading: state.updateMomentCommonLoading,
|
||||||
|
})),
|
||||||
|
);
|
||||||
const MomentCommon = useWeChatStore(state => state.MomentCommon);
|
const MomentCommon = useWeChatStore(state => state.MomentCommon);
|
||||||
const MomentCommonLoading = useWeChatStore(
|
const MomentCommonLoading = useWeChatStore(
|
||||||
state => state.MomentCommonLoading,
|
state => state.MomentCommonLoading,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import request from "@/api/request";
|
|||||||
// 更新好友信息
|
// 更新好友信息
|
||||||
export interface UpdateFriendInfoParams {
|
export interface UpdateFriendInfoParams {
|
||||||
id: number;
|
id: number;
|
||||||
|
conRemark: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
company: string;
|
company: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -15,18 +16,6 @@ export function updateFriendInfo(params: UpdateFriendInfoParams): Promise<any> {
|
|||||||
return request("/v1/kefu/wechatFriend/updateInfo", params, "POST");
|
return request("/v1/kefu/wechatFriend/updateInfo", params, "POST");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新本地数据库中的好友信息
|
|
||||||
export interface UpdateLocalDBParams {
|
|
||||||
wechatFriendId: number;
|
|
||||||
extendFields: string;
|
|
||||||
updateConversation?: boolean; // 是否同时更新会话列表
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateLocalDBFriendInfo(
|
|
||||||
params: UpdateLocalDBParams,
|
|
||||||
): Promise<any> {
|
|
||||||
return request("/v1/kefu/wechatFriend/updateLocalDB", params, "POST");
|
|
||||||
}
|
|
||||||
// 获取好友信息
|
// 获取好友信息
|
||||||
export interface GetFriendInfoParams {
|
export interface GetFriendInfoParams {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React, { useCallback, useState, useEffect, useRef } from "react";
|
|||||||
import { Input, message } from "antd";
|
import { Input, message } from "antd";
|
||||||
import { Button } from "antd-mobile";
|
import { Button } from "antd-mobile";
|
||||||
import { EditOutlined } from "@ant-design/icons";
|
import { EditOutlined } from "@ant-design/icons";
|
||||||
import { updateFriendInfo, UpdateFriendInfoParams } from "../api";
|
|
||||||
|
|
||||||
import styles from "../Person.module.scss";
|
import styles from "../Person.module.scss";
|
||||||
|
|
||||||
@@ -31,7 +30,6 @@ export interface DetailValueProps {
|
|||||||
values: Record<string, string>,
|
values: Record<string, string>,
|
||||||
changedKeys: string[],
|
changedKeys: string[],
|
||||||
) => void;
|
) => void;
|
||||||
isGroup?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DetailValue: React.FC<DetailValueProps> = ({
|
const DetailValue: React.FC<DetailValueProps> = ({
|
||||||
@@ -44,7 +42,6 @@ const DetailValue: React.FC<DetailValueProps> = ({
|
|||||||
renderFooter,
|
renderFooter,
|
||||||
saveHandler,
|
saveHandler,
|
||||||
onSaveSuccess,
|
onSaveSuccess,
|
||||||
isGroup = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
const [editingFields, setEditingFields] = useState<Record<string, boolean>>(
|
const [editingFields, setEditingFields] = useState<Record<string, boolean>>(
|
||||||
@@ -158,37 +155,12 @@ const DetailValue: React.FC<DetailValueProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isGroup) {
|
// 统一由外部传入的 saveHandler 或 onSubmit 处理保存逻辑,
|
||||||
// 群组信息使用传入的saveHandler
|
// DetailValue 只负责收集数据和触发保存,避免与业务层重复。
|
||||||
if (saveHandler) {
|
if (saveHandler) {
|
||||||
await saveHandler(fieldValues, changedKeys);
|
await saveHandler(fieldValues, changedKeys);
|
||||||
} else {
|
|
||||||
onSubmit?.(fieldValues, changedKeys);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 个人资料信息处理
|
onSubmit?.(fieldValues, changedKeys);
|
||||||
if (changedKeys.includes("conRemark")) {
|
|
||||||
// 微信备注是特例,使用WebSocket更新
|
|
||||||
if (saveHandler) {
|
|
||||||
await saveHandler(fieldValues, changedKeys);
|
|
||||||
} else {
|
|
||||||
onSubmit?.(fieldValues, changedKeys);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 其他个人资料信息使用updateFriendInfo API
|
|
||||||
const params: UpdateFriendInfoParams = {
|
|
||||||
id: Number(value.id) || 0,
|
|
||||||
phone: fieldValues.phone || "",
|
|
||||||
company: fieldValues.company || "",
|
|
||||||
name: fieldValues.name || "",
|
|
||||||
position: fieldValues.position || "",
|
|
||||||
email: fieldValues.email || "",
|
|
||||||
address: fieldValues.address || "",
|
|
||||||
qq: fieldValues.qq || "",
|
|
||||||
remark: fieldValues.remark || "",
|
|
||||||
};
|
|
||||||
await updateFriendInfo(params);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新原始值
|
// 更新原始值
|
||||||
@@ -216,8 +188,6 @@ const DetailValue: React.FC<DetailValueProps> = ({
|
|||||||
changedKeys,
|
changedKeys,
|
||||||
fields,
|
fields,
|
||||||
messageApi,
|
messageApi,
|
||||||
isGroup,
|
|
||||||
value.id,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isEditing = Object.values(editingFields).some(Boolean);
|
const isEditing = Object.values(editingFields).some(Boolean);
|
||||||
|
|||||||
@@ -21,19 +21,18 @@ import {
|
|||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||||
import { useCustomerStore } from "@/store/module/weChat/customer";
|
import { useCustomerStore } from "@/store/module/weChat/customer";
|
||||||
|
import { useMessageStore } from "@weChatStore/message";
|
||||||
|
import { useUserStore } from "@storeModule/user";
|
||||||
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||||
import { contactUnifiedService } from "@/utils/db";
|
import { contactUnifiedService } from "@/utils/db";
|
||||||
|
import { MessageManager } from "@/utils/dbAction/message";
|
||||||
import { generateAiText } from "@/api/ai";
|
import { generateAiText } from "@/api/ai";
|
||||||
import TwoColumnSelection from "@/components/TwoColumnSelection/TwoColumnSelection";
|
import TwoColumnSelection from "@/components/TwoColumnSelection/TwoColumnSelection";
|
||||||
import TwoColumnMemberSelection from "@/components/MemberSelection/TwoColumnMemberSelection";
|
import TwoColumnMemberSelection from "@/components/MemberSelection/TwoColumnMemberSelection";
|
||||||
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||||
import DetailValue from "./components/detailValue";
|
import DetailValue from "./components/detailValue";
|
||||||
import {
|
import { getFriendInfo, FriendDetailResponse, updateFriendInfo } from "./api";
|
||||||
getFriendInfo,
|
|
||||||
FriendDetailResponse,
|
|
||||||
updateLocalDBFriendInfo,
|
|
||||||
} from "./api";
|
|
||||||
import styles from "./Person.module.scss";
|
import styles from "./Person.module.scss";
|
||||||
interface PersonProps {
|
interface PersonProps {
|
||||||
contract: ContractData | weChatGroup;
|
contract: ContractData | weChatGroup;
|
||||||
@@ -52,6 +51,11 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 会话列表 & 当前用户,用于在备注更新后同步会话列表显示
|
||||||
|
const setSessions = useMessageStore(state => state.setSessions);
|
||||||
|
// ✅ 使用 selector 避免 getSnapshot 警告
|
||||||
|
const currentUserId = useUserStore(state => state.user?.id) || 0;
|
||||||
|
|
||||||
// 判断是否为群聊
|
// 判断是否为群聊
|
||||||
const isGroup = "chatroomId" in contract;
|
const isGroup = "chatroomId" in contract;
|
||||||
|
|
||||||
@@ -212,36 +216,19 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
|
|||||||
|
|
||||||
// 优化:使用选择器函数直接订阅匹配的客服对象,避免订阅整个 customerList
|
// 优化:使用选择器函数直接订阅匹配的客服对象,避免订阅整个 customerList
|
||||||
// 添加相等性比较,只有当匹配的客服对象或其 labels 真正变化时才触发重新渲染
|
// 添加相等性比较,只有当匹配的客服对象或其 labels 真正变化时才触发重新渲染
|
||||||
const kfSelectedUser = useCustomerStore(
|
const kfSelectedUser = useCustomerStore(state => {
|
||||||
state => {
|
if (!contract.wechatAccountId) return null;
|
||||||
if (!contract.wechatAccountId) return null;
|
return (
|
||||||
return (
|
state.customerList.find(
|
||||||
state.customerList.find(
|
(customer: any) => customer.id === contract.wechatAccountId,
|
||||||
customer => customer.id === contract.wechatAccountId,
|
) || null
|
||||||
) || null
|
);
|
||||||
);
|
});
|
||||||
},
|
|
||||||
(prev, next) => {
|
|
||||||
// 如果都是 null,认为相等
|
|
||||||
if (!prev && !next) return true;
|
|
||||||
// 如果一个是 null 另一个不是,认为不相等
|
|
||||||
if (!prev || !next) return false;
|
|
||||||
// 比较关键字段:id 和 labels(因为 useEffect 中使用了 labels)
|
|
||||||
if (prev.id !== next.id) return false;
|
|
||||||
// 比较 labels 数组是否真的变化了
|
|
||||||
const prevLabels = prev.labels || [];
|
|
||||||
const nextLabels = next.labels || [];
|
|
||||||
if (prevLabels.length !== nextLabels.length) return false;
|
|
||||||
// 深度比较 labels 数组内容(先复制再排序,避免修改原数组)
|
|
||||||
const prevLabelsStr = JSON.stringify([...prevLabels].sort());
|
|
||||||
const nextLabelsStr = JSON.stringify([...nextLabels].sort());
|
|
||||||
return prevLabelsStr === nextLabelsStr;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 不再需要从useContactStore获取getContactsByCustomer
|
// 不再需要从useContactStore获取getContactsByCustomer
|
||||||
|
|
||||||
const { sendCommand } = useWebSocketStore();
|
// ✅ 使用 selector 避免 getSnapshot 警告
|
||||||
|
const sendCommand = useWebSocketStore(state => state.sendCommand);
|
||||||
|
|
||||||
// 权限控制:检查当前客服是否有群管理权限
|
// 权限控制:检查当前客服是否有群管理权限
|
||||||
const hasGroupManagePermission = () => {
|
const hasGroupManagePermission = () => {
|
||||||
@@ -253,7 +240,7 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAvailableTags = async () => {
|
const fetchAvailableTags = async () => {
|
||||||
try {
|
try {
|
||||||
const kfTags = kfSelectedUser?.labels || [];
|
const kfTags = (kfSelectedUser as any)?.labels || [];
|
||||||
const contractTags = contract.labels || [];
|
const contractTags = contract.labels || [];
|
||||||
const allTags = [...new Set([...kfTags, ...contractTags])];
|
const allTags = [...new Set([...kfTags, ...contractTags])];
|
||||||
setAllAvailableTags(allTags);
|
setAllAvailableTags(allTags);
|
||||||
@@ -403,14 +390,47 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
|
|||||||
extendFields: extendFieldsStr,
|
extendFields: extendFieldsStr,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 同时更新会话列表和好友本地数据库中的extendFields字段
|
// 同步好友信息到后端
|
||||||
updateLocalDBFriendInfo({
|
updateFriendInfo({
|
||||||
wechatFriendId: contract.id,
|
id: Number(contract.id) || 0,
|
||||||
extendFields: extendFieldsStr,
|
conRemark: values.conRemark || "",
|
||||||
updateConversation: true, // 同时更新会话列表
|
phone: values.phone || "",
|
||||||
|
company: values.company || "",
|
||||||
|
name: values.name || "",
|
||||||
|
position: values.position || "",
|
||||||
|
email: values.email || "",
|
||||||
|
address: values.address || "",
|
||||||
|
qq: values.qq || "",
|
||||||
|
remark: values.remark || "",
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error("更新本地数据库失败:", err);
|
console.error("更新好友信息失败:", err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 备注变更时,同步更新会话列表 UI 和本地会话数据库
|
||||||
|
if (changedKeys.includes("conRemark")) {
|
||||||
|
const newRemark = values.conRemark || "";
|
||||||
|
|
||||||
|
// 1. 立即更新会话列表 UI(乐观更新)
|
||||||
|
setSessions(prev =>
|
||||||
|
prev.map(session =>
|
||||||
|
session.type === "friend" && session.id === contract.id
|
||||||
|
? { ...session, conRemark: newRemark }
|
||||||
|
: session,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. 同步到会话本地数据库
|
||||||
|
if (currentUserId) {
|
||||||
|
MessageManager.updateRemark(
|
||||||
|
currentUserId,
|
||||||
|
Number(contract.id) || 0,
|
||||||
|
"friend",
|
||||||
|
newRemark,
|
||||||
|
).catch(err => {
|
||||||
|
console.error("更新会话备注失败:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,7 +760,7 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前用户ID
|
// 获取当前用户ID
|
||||||
const userId = kfSelectedUser?.userId || 0;
|
const userId = (kfSelectedUser as any)?.userId || 0;
|
||||||
const storeUserId = databaseManager.getCurrentUserId();
|
const storeUserId = databaseManager.getCurrentUserId();
|
||||||
const effectiveUserId = storeUserId || userId;
|
const effectiveUserId = storeUserId || userId;
|
||||||
|
|
||||||
@@ -924,7 +944,6 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
|
|||||||
setSelfDisplayNameValue(values.selfDisplayName);
|
setSelfDisplayNameValue(values.selfDisplayName);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
isGroup={true}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// 好友信息
|
// 好友信息
|
||||||
@@ -1010,7 +1029,6 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
|
|||||||
setRemarkValue(values.conRemark);
|
setRemarkValue(values.conRemark);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
isGroup={false}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
|
SearchOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import {
|
import {
|
||||||
QuickWordsItem,
|
QuickWordsItem,
|
||||||
@@ -510,13 +511,16 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
<Input.Search
|
<Space.Compact style={{ flex: 1 }}>
|
||||||
placeholder="输入关键字过滤"
|
<Input
|
||||||
allowClear
|
placeholder="输入关键字过滤"
|
||||||
value={keyword}
|
allowClear
|
||||||
onChange={e => setKeyword(e.target.value)}
|
value={keyword}
|
||||||
style={{ flex: 1 }}
|
onChange={e => setKeyword(e.target.value)}
|
||||||
/>
|
prefix={<SearchOutlined />}
|
||||||
|
onPressEnter={() => {}}
|
||||||
|
/>
|
||||||
|
</Space.Compact>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
menu={{
|
menu={{
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface PersonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Person: React.FC<PersonProps> = ({ contract }) => {
|
const Person: React.FC<PersonProps> = ({ contract }) => {
|
||||||
const [activeKey, setActiveKey] = useState("profile");
|
const [activeKey, setActiveKey] = useState("quickwords");
|
||||||
const isGroup = "chatroomId" in contract;
|
const isGroup = "chatroomId" in contract;
|
||||||
// 使用state保存当前contract的副本,确保在切换tab时不会丢失修改
|
// 使用state保存当前contract的副本,确保在切换tab时不会丢失修改
|
||||||
const [currentContract, setCurrentContract] = useState<
|
const [currentContract, setCurrentContract] = useState<
|
||||||
@@ -52,8 +52,8 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
|
|||||||
}, [currentContract, isGroup]);
|
}, [currentContract, isGroup]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveKey("profile");
|
setActiveKey("quickwords");
|
||||||
setRenderedKeys(["profile"]);
|
setRenderedKeys(["quickwords"]);
|
||||||
}, [contract]);
|
}, [contract]);
|
||||||
|
|
||||||
const tabHeaderItems = useMemo(
|
const tabHeaderItems = useMemo(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useCallback, useMemo, lazy, Suspense } from "react";
|
||||||
import {
|
import {
|
||||||
Layout,
|
Layout,
|
||||||
Button,
|
Button,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
message,
|
message,
|
||||||
|
Spin,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import {
|
import {
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
@@ -19,14 +20,25 @@ import {
|
|||||||
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||||
import styles from "./ChatWindow.module.scss";
|
import styles from "./ChatWindow.module.scss";
|
||||||
|
|
||||||
import ProfileCard from "./components/ProfileCard";
|
// ✅ 核心组件保持同步加载(需要立即渲染)
|
||||||
import MessageEnter from "./components/MessageEnter";
|
import MessageEnter from "./components/MessageEnter";
|
||||||
import MessageRecord from "./components/MessageRecord";
|
import MessageRecord from "./components/MessageRecord";
|
||||||
import FollowupReminderModal from "./components/FollowupReminderModal";
|
|
||||||
import TodoListModal from "./components/TodoListModal";
|
|
||||||
import ChatRecordSearch from "./components/ChatRecordSearch";
|
import ChatRecordSearch from "./components/ChatRecordSearch";
|
||||||
|
|
||||||
|
// ✅ 非关键组件使用懒加载(减少初始包大小)
|
||||||
|
const ProfileCard = lazy(() => import("./components/ProfileCard"));
|
||||||
|
const FollowupReminderModal = lazy(() => import("./components/FollowupReminderModal"));
|
||||||
|
const TodoListModal = lazy(() => import("./components/TodoListModal"));
|
||||||
|
|
||||||
|
// 加载中的占位组件
|
||||||
|
const LoadingFallback = () => (
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", padding: "20px" }}>
|
||||||
|
<Spin size="small" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
import { setFriendInjectConfig } from "@/pages/pc/ckbox/weChat/api";
|
import { setFriendInjectConfig } from "@/pages/pc/ckbox/weChat/api";
|
||||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
import { useAISelectors, useUIStateSelectors } from "@/hooks/weChat/useWeChatSelectors";
|
||||||
|
import { useWeChatActions } from "@/hooks/weChat/useWeChatSelectors";
|
||||||
import { MessageManager } from "@/utils/dbAction/message";
|
import { MessageManager } from "@/utils/dbAction/message";
|
||||||
import { ContactManager } from "@/utils/dbAction/contact";
|
import { ContactManager } from "@/utils/dbAction/contact";
|
||||||
import { useUserStore } from "@storeModule/user";
|
import { useUserStore } from "@storeModule/user";
|
||||||
@@ -42,122 +54,132 @@ const typeOptions = [
|
|||||||
{ value: 2, label: "AI接管" },
|
{ value: 2, label: "AI接管" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
|
const ChatWindowComponent: React.FC<ChatWindowProps> = ({ contract }) => {
|
||||||
const updateAiQuoteMessageContent = useWeChatStore(
|
// ✅ 使用优化的 selector(合并多个 selector,减少重渲染)
|
||||||
state => state.updateAiQuoteMessageContent,
|
const { aiQuoteMessageContent } = useAISelectors();
|
||||||
);
|
const { showChatRecordModel } = useUIStateSelectors();
|
||||||
const aiQuoteMessageContent = useWeChatStore(
|
const { updateAiQuoteMessageContent, setCurrentContact } = useWeChatActions();
|
||||||
state => state.aiQuoteMessageContent,
|
|
||||||
);
|
// ✅ 使用 selector 避免 getSnapshot 警告
|
||||||
const showChatRecordModel = useWeChatStore(
|
const currentUserId = useUserStore(state => state.user?.id) || 0;
|
||||||
state => state.showChatRecordModel,
|
|
||||||
);
|
|
||||||
const setCurrentContact = useWeChatStore(state => state.setCurrentContact);
|
|
||||||
const { user } = useUserStore();
|
|
||||||
const currentUserId = user?.id || 0;
|
|
||||||
|
|
||||||
const [showProfile, setShowProfile] = useState(true);
|
const [showProfile, setShowProfile] = useState(true);
|
||||||
const [followupModalVisible, setFollowupModalVisible] = useState(false);
|
const [followupModalVisible, setFollowupModalVisible] = useState(false);
|
||||||
const [todoModalVisible, setTodoModalVisible] = useState(false);
|
const [todoModalVisible, setTodoModalVisible] = useState(false);
|
||||||
|
|
||||||
const onToggleProfile = () => {
|
const onToggleProfile = useCallback(() => {
|
||||||
setShowProfile(!showProfile);
|
setShowProfile(prev => !prev);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleFollowupClick = () => {
|
const handleFollowupClick = useCallback(() => {
|
||||||
setFollowupModalVisible(true);
|
setFollowupModalVisible(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleFollowupModalClose = () => {
|
const handleFollowupModalClose = useCallback(() => {
|
||||||
setFollowupModalVisible(false);
|
setFollowupModalVisible(false);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleTodoClick = () => {
|
const handleTodoClick = useCallback(() => {
|
||||||
setTodoModalVisible(true);
|
setTodoModalVisible(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleTodoModalClose = () => {
|
const handleTodoModalClose = useCallback(() => {
|
||||||
setTodoModalVisible(false);
|
setTodoModalVisible(false);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const [currentConfig, setCurrentConfig] = useState(
|
const [currentConfig, setCurrentConfig] = useState(
|
||||||
typeOptions.find(option => option.value === aiQuoteMessageContent),
|
typeOptions.find(option => option.value === aiQuoteMessageContent) ||
|
||||||
|
typeOptions[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentConfig(
|
const found =
|
||||||
typeOptions.find(option => option.value === aiQuoteMessageContent),
|
typeOptions.find(option => option.value === aiQuoteMessageContent) ||
|
||||||
);
|
typeOptions[0];
|
||||||
|
setCurrentConfig(found);
|
||||||
}, [aiQuoteMessageContent]);
|
}, [aiQuoteMessageContent]);
|
||||||
|
|
||||||
// 处理配置选择
|
// 处理配置选择
|
||||||
const handleConfigChange = async option => {
|
const handleConfigChange = useCallback(
|
||||||
setCurrentConfig({
|
async (option: { value: number; label: string }) => {
|
||||||
value: option.value,
|
setCurrentConfig({
|
||||||
label: option.label,
|
value: option.value,
|
||||||
});
|
label: option.label,
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 保存配置到后端
|
|
||||||
await setFriendInjectConfig({
|
|
||||||
type: option.value,
|
|
||||||
wechatAccountId: contract.wechatAccountId,
|
|
||||||
friendId: contract.id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. 更新 Store 中的 AI 配置状态
|
try {
|
||||||
updateAiQuoteMessageContent(option.value);
|
// 1. 保存配置到后端
|
||||||
|
await setFriendInjectConfig({
|
||||||
|
type: option.value,
|
||||||
|
wechatAccountId: contract.wechatAccountId,
|
||||||
|
friendId: contract.id,
|
||||||
|
});
|
||||||
|
|
||||||
// 3. 确定联系人类型
|
// 2. 更新 Store 中的 AI 配置状态
|
||||||
const contactType: "friend" | "group" = contract.chatroomId
|
updateAiQuoteMessageContent(option.value);
|
||||||
? "group"
|
|
||||||
: "friend";
|
|
||||||
const aiType = option.value;
|
|
||||||
|
|
||||||
console.log(
|
// 3. 确定联系人类型
|
||||||
`开始更新 aiType: contactId=${contract.id}, type=${contactType}, aiType=${aiType}`,
|
const contactType: "friend" | "group" = contract.chatroomId
|
||||||
);
|
? "group"
|
||||||
|
: "friend";
|
||||||
|
const aiType = option.value;
|
||||||
|
|
||||||
// 4. 更新会话列表数据库的 aiType
|
console.log(
|
||||||
await MessageManager.updateSession({
|
`开始更新 aiType: contactId=${contract.id}, type=${contactType}, aiType=${aiType}`,
|
||||||
userId: currentUserId,
|
);
|
||||||
id: contract.id!,
|
|
||||||
type: contactType,
|
|
||||||
aiType: aiType,
|
|
||||||
});
|
|
||||||
console.log("✅ 会话列表数据库 aiType 更新成功");
|
|
||||||
|
|
||||||
// 5. 更新联系人数据库的 aiType
|
// 4. 更新会话列表数据库的 aiType
|
||||||
const contactInDb = await ContactManager.getContactByIdAndType(
|
await MessageManager.updateSession({
|
||||||
currentUserId,
|
userId: currentUserId,
|
||||||
contract.id!,
|
id: contract.id!,
|
||||||
contactType,
|
type: contactType,
|
||||||
);
|
|
||||||
|
|
||||||
if (contactInDb) {
|
|
||||||
await ContactManager.updateContact({
|
|
||||||
...contactInDb,
|
|
||||||
aiType: aiType,
|
aiType: aiType,
|
||||||
});
|
});
|
||||||
console.log("✅ 联系人数据库 aiType 更新成功");
|
console.log("✅ 会话列表数据库 aiType 更新成功");
|
||||||
} else {
|
|
||||||
console.warn("⚠️ 联系人数据库中未找到该联系人");
|
// 5. 更新联系人数据库的 aiType
|
||||||
|
const contactInDb = await ContactManager.getContactByIdAndType(
|
||||||
|
currentUserId,
|
||||||
|
contract.id!,
|
||||||
|
contactType,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (contactInDb) {
|
||||||
|
await ContactManager.updateContact({
|
||||||
|
...contactInDb,
|
||||||
|
aiType: aiType,
|
||||||
|
});
|
||||||
|
console.log("✅ 联系人数据库 aiType 更新成功");
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ 联系人数据库中未找到该联系人");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 更新 Store 中的 currentContract(通过重新设置)
|
||||||
|
const updatedContract = {
|
||||||
|
...contract,
|
||||||
|
aiType: aiType,
|
||||||
|
};
|
||||||
|
setCurrentContact(updatedContract);
|
||||||
|
console.log("✅ Store currentContract aiType 更新成功");
|
||||||
|
|
||||||
|
message.success(`已切换为${option.label}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("更新 AI 配置失败:", error);
|
||||||
|
message.error("配置更新失败,请重试");
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[contract, currentUserId, setCurrentContact, updateAiQuoteMessageContent],
|
||||||
|
);
|
||||||
|
|
||||||
// 6. 更新 Store 中的 currentContract(通过重新设置)
|
const aiTypeMenuItems = useMemo(
|
||||||
const updatedContract = {
|
() =>
|
||||||
...contract,
|
typeOptions.map(option => ({
|
||||||
aiType: aiType,
|
key: option.value,
|
||||||
};
|
label: option.label,
|
||||||
setCurrentContact(updatedContract);
|
onClick: () => handleConfigChange(option),
|
||||||
console.log("✅ Store currentContract aiType 更新成功");
|
})),
|
||||||
|
[handleConfigChange],
|
||||||
message.success(`已切换为${option.label}`);
|
);
|
||||||
} catch (error) {
|
|
||||||
console.error("更新 AI 配置失败:", error);
|
|
||||||
message.error("配置更新失败,请重试");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className={styles.chatWindow}>
|
<Layout className={styles.chatWindow}>
|
||||||
@@ -183,11 +205,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
|
|||||||
{!contract.chatroomId && (
|
{!contract.chatroomId && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
menu={{
|
menu={{
|
||||||
items: typeOptions.map(option => ({
|
items: aiTypeMenuItems,
|
||||||
key: option.value,
|
|
||||||
label: option.label,
|
|
||||||
onClick: () => handleConfigChange(option),
|
|
||||||
})),
|
|
||||||
}}
|
}}
|
||||||
trigger={["click"]}
|
trigger={["click"]}
|
||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
@@ -230,26 +248,49 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
|
|||||||
<MessageEnter contract={contract} />
|
<MessageEnter contract={contract} />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
{/* 右侧个人资料卡片 */}
|
{/* 右侧个人资料卡片 - 懒加载 */}
|
||||||
{showProfile && <ProfileCard contract={contract} />}
|
{showProfile && (
|
||||||
|
<Suspense fallback={<div style={{ padding: "20px", textAlign: "center" }}>加载中...</div>}>
|
||||||
|
<ProfileCard contract={contract} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 跟进提醒模态框 */}
|
{/* 跟进提醒模态框 - 懒加载 */}
|
||||||
<FollowupReminderModal
|
{followupModalVisible && (
|
||||||
visible={followupModalVisible}
|
<Suspense fallback={null}>
|
||||||
onClose={handleFollowupModalClose}
|
<FollowupReminderModal
|
||||||
recipientName={contract.nickname || contract.name}
|
visible={followupModalVisible}
|
||||||
friendId={contract.id?.toString()}
|
onClose={handleFollowupModalClose}
|
||||||
/>
|
recipientName={contract.nickname || contract.name}
|
||||||
|
friendId={contract.id?.toString()}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 待办事项模态框 */}
|
{/* 待办事项模态框 - 懒加载 */}
|
||||||
<TodoListModal
|
{todoModalVisible && (
|
||||||
visible={todoModalVisible}
|
<Suspense fallback={null}>
|
||||||
onClose={handleTodoModalClose}
|
<TodoListModal
|
||||||
clientName={contract.nickname || contract.name}
|
visible={todoModalVisible}
|
||||||
friendId={contract.id?.toString()}
|
onClose={handleTodoModalClose}
|
||||||
/>
|
clientName={contract.nickname || contract.name}
|
||||||
|
friendId={contract.id?.toString()}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ✅ 使用 React.memo 优化 ChatWindow 组件,避免不必要的重渲染
|
||||||
|
const ChatWindow = React.memo(
|
||||||
|
ChatWindowComponent,
|
||||||
|
(prev, next) => {
|
||||||
|
// 只有当联系人 ID 变化时才重新渲染
|
||||||
|
return prev.contract.id === next.contract.id;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ChatWindow.displayName = "ChatWindow";
|
||||||
|
|
||||||
export default ChatWindow;
|
export default ChatWindow;
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ const FriendsCircle: React.FC<FriendsCircleProps> = ({ wechatFriendId }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 默认头像路径
|
||||||
|
const defaultAvatar = "/assets/face/smile.png";
|
||||||
|
|
||||||
const collapseItems = [
|
const collapseItems = [
|
||||||
{
|
{
|
||||||
key: "1",
|
key: "1",
|
||||||
@@ -79,8 +82,15 @@ const FriendsCircle: React.FC<FriendsCircleProps> = ({ wechatFriendId }) => {
|
|||||||
<div className={styles.collapseHeader}>
|
<div className={styles.collapseHeader}>
|
||||||
<img
|
<img
|
||||||
className={styles.avatar}
|
className={styles.avatar}
|
||||||
src={currentKf?.avatar}
|
src={currentKf?.avatar || defaultAvatar}
|
||||||
alt="客服头像"
|
alt="客服头像"
|
||||||
|
onError={(e) => {
|
||||||
|
// 如果图片加载失败,使用默认头像
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
if (target.src !== defaultAvatar) {
|
||||||
|
target.src = defaultAvatar;
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span className={styles.specialText}>我的朋友圈</span>
|
<span className={styles.specialText}>我的朋友圈</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user