Merge branch 'develop' of https://gitee.com/cunkebao/cunkebao_v3 into wong-dev
This commit is contained in:
@@ -0,0 +1,128 @@
|
|||||||
|
.modalMask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoContainer {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: slideUp 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoWrapper {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
padding-top: 56.25%; // 16:9 比例
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动画
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modalMask {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoContainer {
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.tsx
Normal file
101
Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import React, { useRef, useEffect } from "react";
|
||||||
|
import { CloseOutlined } from "@ant-design/icons";
|
||||||
|
import styles from "./VideoPlayer.module.scss";
|
||||||
|
|
||||||
|
interface VideoPlayerProps {
|
||||||
|
/** 视频URL */
|
||||||
|
videoUrl: string;
|
||||||
|
/** 是否显示播放器 */
|
||||||
|
visible: boolean;
|
||||||
|
/** 关闭回调 */
|
||||||
|
onClose: () => void;
|
||||||
|
/** 视频标题 */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
|
videoUrl,
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
title = "操作视频",
|
||||||
|
}) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && videoRef.current) {
|
||||||
|
// 播放器打开时播放视频
|
||||||
|
videoRef.current.play().catch(err => {
|
||||||
|
console.error("视频播放失败:", err);
|
||||||
|
});
|
||||||
|
// 阻止背景滚动
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
} else if (videoRef.current) {
|
||||||
|
// 播放器关闭时暂停视频
|
||||||
|
videoRef.current.pause();
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// 点击遮罩层关闭
|
||||||
|
const handleMaskClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
// 如果点击的是遮罩层本身(不是视频容器),则关闭
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 阻止事件冒泡
|
||||||
|
const handleContentClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={styles.modalMask}
|
||||||
|
onClick={handleMaskClick}
|
||||||
|
>
|
||||||
|
<div className={styles.videoContainer} onClick={handleContentClick}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.title}>{title}</span>
|
||||||
|
<button className={styles.closeButton} onClick={handleClose}>
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.videoWrapper}>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={videoUrl}
|
||||||
|
controls
|
||||||
|
className={styles.video}
|
||||||
|
playsInline
|
||||||
|
webkit-playsinline="true"
|
||||||
|
x5-playsinline="true"
|
||||||
|
x5-video-player-type="h5"
|
||||||
|
x5-video-player-fullscreen="true"
|
||||||
|
>
|
||||||
|
您的浏览器不支持视频播放
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoPlayer;
|
||||||
56
Cunkebao/src/components/FloatingVideoHelp/index.module.scss
Normal file
56
Cunkebao/src/components/FloatingVideoHelp/index.module.scss
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
.floatingButton {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 80px;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 9998;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
right: 16px;
|
||||||
|
bottom: 70px;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
Cunkebao/src/components/FloatingVideoHelp/index.tsx
Normal file
68
Cunkebao/src/components/FloatingVideoHelp/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { PlayCircleOutlined } from "@ant-design/icons";
|
||||||
|
import VideoPlayer from "./VideoPlayer";
|
||||||
|
import { getVideoUrlByRoute } from "./videoConfig";
|
||||||
|
import styles from "./index.module.scss";
|
||||||
|
|
||||||
|
interface FloatingVideoHelpProps {
|
||||||
|
/** 是否显示悬浮窗,默认为 true */
|
||||||
|
visible?: boolean;
|
||||||
|
/** 自定义样式类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FloatingVideoHelp: React.FC<FloatingVideoHelpProps> = ({
|
||||||
|
visible = true,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const [showPlayer, setShowPlayer] = useState(false);
|
||||||
|
const [currentVideoUrl, setCurrentVideoUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 根据当前路由获取视频URL
|
||||||
|
useEffect(() => {
|
||||||
|
const videoUrl = getVideoUrlByRoute(location.pathname);
|
||||||
|
setCurrentVideoUrl(videoUrl);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (currentVideoUrl) {
|
||||||
|
setShowPlayer(true);
|
||||||
|
} else {
|
||||||
|
// 如果没有对应的视频,可以显示提示
|
||||||
|
console.warn("当前路由没有对应的操作视频");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setShowPlayer(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果没有视频URL,不显示悬浮窗
|
||||||
|
if (!visible || !currentVideoUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`${styles.floatingButton} ${className || ""}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
title="查看操作视频"
|
||||||
|
>
|
||||||
|
<PlayCircleOutlined className={styles.icon} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPlayer && currentVideoUrl && (
|
||||||
|
<VideoPlayer
|
||||||
|
videoUrl={currentVideoUrl}
|
||||||
|
visible={showPlayer}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FloatingVideoHelp;
|
||||||
110
Cunkebao/src/components/FloatingVideoHelp/videoConfig.ts
Normal file
110
Cunkebao/src/components/FloatingVideoHelp/videoConfig.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* 路由到视频URL的映射配置
|
||||||
|
* key: 路由路径(支持正则表达式)
|
||||||
|
* value: 视频URL
|
||||||
|
*/
|
||||||
|
interface VideoConfig {
|
||||||
|
[route: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频URL配置
|
||||||
|
const videoConfig: VideoConfig = {
|
||||||
|
// 首页
|
||||||
|
"/": "/videos/home.mp4",
|
||||||
|
"/mobile/home": "/videos/home.mp4",
|
||||||
|
|
||||||
|
// 工作台
|
||||||
|
"/workspace": "/videos/workspace.mp4",
|
||||||
|
"/workspace/auto-like": "/videos/auto-like-list.mp4",
|
||||||
|
"/workspace/auto-like/new": "/videos/auto-like-new.mp4",
|
||||||
|
"/workspace/auto-like/record": "/videos/auto-like-record.mp4",
|
||||||
|
"/workspace/auto-group": "/videos/auto-group-list.mp4",
|
||||||
|
"/workspace/auto-group/new": "/videos/auto-group-new.mp4",
|
||||||
|
"/workspace/group-push": "/videos/group-push-list.mp4",
|
||||||
|
"/workspace/group-push/new": "/videos/group-push-new.mp4",
|
||||||
|
"/workspace/moments-sync": "/videos/moments-sync-list.mp4",
|
||||||
|
"/workspace/moments-sync/new": "/videos/moments-sync-new.mp4",
|
||||||
|
"/workspace/ai-assistant": "/videos/ai-assistant.mp4",
|
||||||
|
"/workspace/ai-analyzer": "/videos/ai-analyzer.mp4",
|
||||||
|
"/workspace/traffic-distribution": "/videos/traffic-distribution-list.mp4",
|
||||||
|
"/workspace/traffic-distribution/new": "/videos/traffic-distribution-new.mp4",
|
||||||
|
"/workspace/contact-import": "/videos/contact-import-list.mp4",
|
||||||
|
"/workspace/contact-import/form": "/videos/contact-import-form.mp4",
|
||||||
|
"/workspace/ai-knowledge": "/videos/ai-knowledge-list.mp4",
|
||||||
|
"/workspace/ai-knowledge/new": "/videos/ai-knowledge-new.mp4",
|
||||||
|
|
||||||
|
// 我的
|
||||||
|
"/mobile/mine": "/videos/mine.mp4",
|
||||||
|
"/mobile/mine/devices": "/videos/devices.mp4",
|
||||||
|
"/mobile/mine/wechat-accounts": "/videos/wechat-accounts.mp4",
|
||||||
|
"/mobile/mine/content": "/videos/content.mp4",
|
||||||
|
"/mobile/mine/traffic-pool": "/videos/traffic-pool.mp4",
|
||||||
|
"/mobile/mine/recharge": "/videos/recharge.mp4",
|
||||||
|
"/mobile/mine/setting": "/videos/setting.mp4",
|
||||||
|
|
||||||
|
// 场景
|
||||||
|
"/mobile/scenarios": "/videos/scenarios.mp4",
|
||||||
|
"/mobile/scenarios/plan": "/videos/scenarios-plan.mp4",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据路由路径获取对应的视频URL
|
||||||
|
* @param routePath 当前路由路径
|
||||||
|
* @returns 视频URL,如果没有匹配则返回 null
|
||||||
|
*/
|
||||||
|
export function getVideoUrlByRoute(routePath: string): string | null {
|
||||||
|
// 精确匹配
|
||||||
|
if (videoConfig[routePath]) {
|
||||||
|
return videoConfig[routePath];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模糊匹配(支持动态路由参数)
|
||||||
|
// 例如:/workspace/auto-like/edit/123 会匹配 /workspace/auto-like/edit/:id
|
||||||
|
const routeKeys = Object.keys(videoConfig);
|
||||||
|
for (const key of routeKeys) {
|
||||||
|
// 将配置中的 :id 等参数转换为正则表达式
|
||||||
|
const regexPattern = key.replace(/:\w+/g, "[^/]+");
|
||||||
|
const regex = new RegExp(`^${regexPattern}$`);
|
||||||
|
if (regex.test(routePath)) {
|
||||||
|
return videoConfig[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前缀匹配(作为兜底方案)
|
||||||
|
// 例如:/workspace/auto-like/edit/123 会匹配 /workspace/auto-like
|
||||||
|
const sortedKeys = routeKeys.sort((a, b) => b.length - a.length); // 按长度降序排列
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
if (routePath.startsWith(key)) {
|
||||||
|
return videoConfig[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加或更新视频配置
|
||||||
|
* @param route 路由路径
|
||||||
|
* @param videoUrl 视频URL
|
||||||
|
*/
|
||||||
|
export function setVideoConfig(route: string, videoUrl: string): void {
|
||||||
|
videoConfig[route] = videoUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量添加视频配置
|
||||||
|
* @param config 视频配置对象
|
||||||
|
*/
|
||||||
|
export function setVideoConfigs(config: VideoConfig): void {
|
||||||
|
Object.assign(videoConfig, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有视频配置
|
||||||
|
* @returns 视频配置对象
|
||||||
|
*/
|
||||||
|
export function getAllVideoConfigs(): VideoConfig {
|
||||||
|
return { ...videoConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default videoConfig;
|
||||||
@@ -75,7 +75,7 @@ export interface OrderListParams {
|
|||||||
[property: string]: any;
|
[property: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrderList {
|
export interface OrderList {
|
||||||
id?: number;
|
id?: number;
|
||||||
mchId?: number;
|
mchId?: number;
|
||||||
companyId?: number;
|
companyId?: number;
|
||||||
@@ -84,22 +84,24 @@ interface OrderList {
|
|||||||
status?: number;
|
status?: number;
|
||||||
goodsId?: number;
|
goodsId?: number;
|
||||||
goodsName?: string;
|
goodsName?: string;
|
||||||
goodsSpecs?: {
|
goodsSpecs?:
|
||||||
id: number;
|
| {
|
||||||
name: string;
|
id: number;
|
||||||
price: number;
|
name: string;
|
||||||
tokens: number;
|
price: number;
|
||||||
};
|
tokens: number;
|
||||||
|
}
|
||||||
|
| string;
|
||||||
money?: number;
|
money?: number;
|
||||||
orderNo?: string;
|
orderNo?: string;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
nonceStr?: string;
|
nonceStr?: string;
|
||||||
createTime?: string;
|
createTime?: string | number;
|
||||||
payType?: number;
|
payType?: number;
|
||||||
payTime?: string;
|
payTime?: string | number;
|
||||||
payInfo?: any;
|
payInfo?: any;
|
||||||
deleteTime?: string;
|
deleteTime?: string | number;
|
||||||
tokens?: string;
|
tokens?: string | number;
|
||||||
statusText?: string;
|
statusText?: string;
|
||||||
orderTypeText?: string;
|
orderTypeText?: string;
|
||||||
payTypeText?: string;
|
payTypeText?: string;
|
||||||
|
|||||||
@@ -258,6 +258,18 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
border: 1px solid #f0f0f0;
|
border: 1px solid #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordItem:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordItem:hover {
|
||||||
|
box-shadow: 0 6px 18px rgba(22, 119, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recordHeader {
|
.recordHeader {
|
||||||
|
|||||||
@@ -12,17 +12,74 @@ import {
|
|||||||
import NavCommon from "@/components/NavCommon";
|
import NavCommon from "@/components/NavCommon";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import { getStatistics, getOrderList } from "./api";
|
import { getStatistics, getOrderList } from "./api";
|
||||||
import type { Statistics } from "./api";
|
import type { Statistics, OrderList } from "./api";
|
||||||
import { Pagination } from "antd";
|
import { Pagination } from "antd";
|
||||||
|
|
||||||
type OrderRecordView = {
|
type TagColor = NonNullable<React.ComponentProps<typeof Tag>["color"]>;
|
||||||
id: number;
|
|
||||||
type: string;
|
type GoodsSpecs =
|
||||||
status: string;
|
| {
|
||||||
amount: number; // 元
|
id: number;
|
||||||
power: number;
|
name: string;
|
||||||
description: string;
|
price: number;
|
||||||
createTime: string;
|
tokens: number;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const parseGoodsSpecs = (value: OrderList["goodsSpecs"]): GoodsSpecs => {
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("解析 goodsSpecs 失败:", error, value);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (value?: number | string | null) => {
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
if (typeof value === "string" && value.trim() === "") return "";
|
||||||
|
|
||||||
|
const numericValue =
|
||||||
|
typeof value === "number" ? value : Number.parseFloat(value);
|
||||||
|
|
||||||
|
if (Number.isNaN(numericValue)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp =
|
||||||
|
numericValue > 1e12
|
||||||
|
? numericValue
|
||||||
|
: numericValue > 1e10
|
||||||
|
? numericValue
|
||||||
|
: numericValue * 1000;
|
||||||
|
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return date.toLocaleString("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const centsToYuan = (value?: number | string | null) => {
|
||||||
|
if (value === undefined || value === null) return 0;
|
||||||
|
if (typeof value === "string" && value.trim() === "") return 0;
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return 0;
|
||||||
|
if (Number.isInteger(num)) {
|
||||||
|
return num / 100;
|
||||||
|
}
|
||||||
|
return num;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PowerManagement: React.FC = () => {
|
const PowerManagement: React.FC = () => {
|
||||||
@@ -30,7 +87,7 @@ const PowerManagement: React.FC = () => {
|
|||||||
const [activeTab, setActiveTab] = useState("overview");
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [stats, setStats] = useState<Statistics | null>(null);
|
const [stats, setStats] = useState<Statistics | null>(null);
|
||||||
const [records, setRecords] = useState<OrderRecordView[]>([]);
|
const [records, setRecords] = useState<OrderList[]>([]);
|
||||||
const [filterType, setFilterType] = useState<string>("all");
|
const [filterType, setFilterType] = useState<string>("all");
|
||||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||||
const [filterTypeVisible, setFilterTypeVisible] = useState(false);
|
const [filterTypeVisible, setFilterTypeVisible] = useState(false);
|
||||||
@@ -50,11 +107,19 @@ const PowerManagement: React.FC = () => {
|
|||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ label: "全部状态", value: "all" },
|
{ label: "全部状态", value: "all" },
|
||||||
{ label: "已完成", value: "completed" },
|
{ label: "待支付", value: "pending", requestValue: "0" },
|
||||||
{ label: "进行中", value: "processing" },
|
{ label: "已支付", value: "paid", requestValue: "1" },
|
||||||
{ label: "已取消", value: "cancelled" },
|
{ label: "已取消", value: "cancelled", requestValue: "2" },
|
||||||
|
{ label: "已退款", value: "refunded", requestValue: "3" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const statusMeta: Record<number, { label: string; color: TagColor }> = {
|
||||||
|
0: { label: "待支付", color: "warning" },
|
||||||
|
1: { label: "已支付", color: "success" },
|
||||||
|
2: { label: "已取消", color: "default" },
|
||||||
|
3: { label: "已退款", color: "primary" },
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStats();
|
fetchStats();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -81,35 +146,20 @@ const PowerManagement: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const reqPage = customPage !== undefined ? customPage : page;
|
const reqPage = customPage !== undefined ? customPage : page;
|
||||||
// 映射状态到订单状态:0待支付 1已支付 2已取消 3已退款
|
const statusRequestValue = statusOptions.find(
|
||||||
const statusMap: Record<string, string | undefined> = {
|
opt => opt.value === filterStatus,
|
||||||
all: undefined,
|
)?.requestValue;
|
||||||
completed: "1",
|
|
||||||
processing: "0",
|
|
||||||
cancelled: "2",
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await getOrderList({
|
const res = await getOrderList({
|
||||||
page: String(reqPage),
|
page: String(reqPage),
|
||||||
limit: String(pageSize),
|
limit: String(pageSize),
|
||||||
orderType: "1",
|
orderType: "1",
|
||||||
status: statusMap[filterStatus],
|
status: statusRequestValue,
|
||||||
});
|
});
|
||||||
|
setRecords(res.list || []);
|
||||||
const list = (res.list || []).map((o: any) => ({
|
|
||||||
id: o.id,
|
|
||||||
type: o.orderTypeText || o.goodsName || "充值订单",
|
|
||||||
status: o.statusText || "",
|
|
||||||
amount: typeof o.money === "number" ? o.money / 100 : 0,
|
|
||||||
power: Number(o.goodsSpecs?.tokens ?? o.tokens ?? 0),
|
|
||||||
description: o.goodsName || "",
|
|
||||||
createTime: o.createTime || "",
|
|
||||||
}));
|
|
||||||
setRecords(list);
|
|
||||||
setTotal(Number(res.total || 0));
|
setTotal(Number(res.total || 0));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取消费记录失败:", error);
|
console.error("获取订单记录失败:", error);
|
||||||
Toast.show({ content: "获取消费记录失败", position: "top" });
|
Toast.show({ content: "获取订单记录失败", position: "top" });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -225,7 +275,7 @@ const PowerManagement: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 渲染消费记录Tab
|
// 渲染订单记录Tab
|
||||||
const renderRecords = () => (
|
const renderRecords = () => (
|
||||||
<div className={style.recordsContent}>
|
<div className={style.recordsContent}>
|
||||||
{/* 筛选器 */}
|
{/* 筛选器 */}
|
||||||
@@ -273,42 +323,72 @@ const PowerManagement: React.FC = () => {
|
|||||||
</Picker>
|
</Picker>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 消费记录列表 */}
|
{/* 订单记录列表 */}
|
||||||
<div className={style.recordList}>
|
<div className={style.recordList}>
|
||||||
{loading && records.length === 0 ? (
|
{loading && records.length === 0 ? (
|
||||||
<div className={style.loadingContainer}>
|
<div className={style.loadingContainer}>
|
||||||
<div className={style.loadingText}>加载中...</div>
|
<div className={style.loadingText}>加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
) : records.length > 0 ? (
|
) : records.length > 0 ? (
|
||||||
records.map(record => (
|
records.map(record => {
|
||||||
<Card key={record.id} className={style.recordItem}>
|
const statusCode =
|
||||||
<div className={style.recordHeader}>
|
record.status !== undefined ? Number(record.status) : undefined;
|
||||||
<div className={style.recordLeft}>
|
const tagColor =
|
||||||
<div className={style.recordType}>{record.type}</div>
|
statusCode !== undefined
|
||||||
<Tag
|
? statusMeta[statusCode]?.color || "default"
|
||||||
color={record.status === "已完成" ? "success" : "primary"}
|
: "default";
|
||||||
className={style.recordStatus}
|
const tagLabel =
|
||||||
>
|
record.statusText ||
|
||||||
{record.status}
|
(statusCode !== undefined
|
||||||
</Tag>
|
? statusMeta[statusCode]?.label || "未知状态"
|
||||||
</div>
|
: "未知状态");
|
||||||
<div className={style.recordRight}>
|
const goodsSpecs = parseGoodsSpecs(record.goodsSpecs);
|
||||||
<div className={style.recordAmount}>
|
const amount = centsToYuan(record.money);
|
||||||
-¥{record.amount.toFixed(1)}
|
const powerValue = Number(goodsSpecs?.tokens ?? record.tokens ?? 0);
|
||||||
|
const power = Number.isNaN(powerValue) ? 0 : powerValue;
|
||||||
|
const description =
|
||||||
|
record.orderTypeText ||
|
||||||
|
goodsSpecs?.name ||
|
||||||
|
record.goodsName ||
|
||||||
|
"";
|
||||||
|
const createTime = formatTimestamp(record.createTime);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={record.id ?? record.orderNo}
|
||||||
|
className={style.recordItem}
|
||||||
|
onClick={() =>
|
||||||
|
record.orderNo &&
|
||||||
|
navigate(`/recharge/order/${record.orderNo}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.recordHeader}>
|
||||||
|
<div className={style.recordLeft}>
|
||||||
|
<div className={style.recordType}>
|
||||||
|
{record.goodsName || "算力充值"}
|
||||||
|
</div>
|
||||||
|
<Tag color={tagColor} className={style.recordStatus}>
|
||||||
|
{tagLabel}
|
||||||
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.recordPower}>
|
<div className={style.recordRight}>
|
||||||
{formatNumber(record.power)} 算力
|
<div className={style.recordAmount}>
|
||||||
|
-¥{amount.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
<div className={style.recordPower}>
|
||||||
|
{formatNumber(power)} 算力
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className={style.recordDesc}>{description}</div>
|
||||||
<div className={style.recordDesc}>{record.description}</div>
|
<div className={style.recordTime}>{createTime}</div>
|
||||||
<div className={style.recordTime}>{record.createTime}</div>
|
</Card>
|
||||||
</Card>
|
);
|
||||||
))
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className={style.emptyRecords}>
|
<div className={style.emptyRecords}>
|
||||||
<div className={style.emptyIcon}>📋</div>
|
<div className={style.emptyIcon}>📋</div>
|
||||||
<div className={style.emptyText}>暂无消费记录</div>
|
<div className={style.emptyText}>暂无订单记录</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -334,7 +414,7 @@ const PowerManagement: React.FC = () => {
|
|||||||
className={style.powerTabs}
|
className={style.powerTabs}
|
||||||
>
|
>
|
||||||
<Tabs.Tab title="概览" key="overview" />
|
<Tabs.Tab title="概览" key="overview" />
|
||||||
<Tabs.Tab title="消费记录" key="records" />
|
<Tabs.Tab title="订单记录" key="records" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,197 +1,37 @@
|
|||||||
import {
|
import {
|
||||||
RechargeOrdersResponse,
|
|
||||||
RechargeOrderDetail,
|
RechargeOrderDetail,
|
||||||
RechargeOrderParams,
|
RechargeOrderParams,
|
||||||
|
GetRechargeOrderDetailParams,
|
||||||
} from "./data";
|
} from "./data";
|
||||||
|
import request from "@/api/request";
|
||||||
// 模拟数据
|
|
||||||
const mockOrders = [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
orderNo: "RC20241201001",
|
|
||||||
amount: 100.0,
|
|
||||||
paymentMethod: "wechat",
|
|
||||||
status: "success" as const,
|
|
||||||
createTime: "2024-12-01T10:30:00Z",
|
|
||||||
payTime: "2024-12-01T10:32:15Z",
|
|
||||||
description: "账户充值",
|
|
||||||
balance: 150.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
orderNo: "RC20241201002",
|
|
||||||
amount: 200.0,
|
|
||||||
paymentMethod: "alipay",
|
|
||||||
status: "pending" as const,
|
|
||||||
createTime: "2024-12-01T14:20:00Z",
|
|
||||||
description: "账户充值",
|
|
||||||
balance: 350.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
orderNo: "RC20241130001",
|
|
||||||
amount: 50.0,
|
|
||||||
paymentMethod: "bank",
|
|
||||||
status: "success" as const,
|
|
||||||
createTime: "2024-11-30T09:15:00Z",
|
|
||||||
payTime: "2024-11-30T09:18:30Z",
|
|
||||||
description: "账户充值",
|
|
||||||
balance: 50.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4",
|
|
||||||
orderNo: "RC20241129001",
|
|
||||||
amount: 300.0,
|
|
||||||
paymentMethod: "wechat",
|
|
||||||
status: "failed" as const,
|
|
||||||
createTime: "2024-11-29T16:45:00Z",
|
|
||||||
description: "账户充值",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "5",
|
|
||||||
orderNo: "RC20241128001",
|
|
||||||
amount: 150.0,
|
|
||||||
paymentMethod: "alipay",
|
|
||||||
status: "cancelled" as const,
|
|
||||||
createTime: "2024-11-28T11:20:00Z",
|
|
||||||
description: "账户充值",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "6",
|
|
||||||
orderNo: "RC20241127001",
|
|
||||||
amount: 80.0,
|
|
||||||
paymentMethod: "wechat",
|
|
||||||
status: "success" as const,
|
|
||||||
createTime: "2024-11-27T13:10:00Z",
|
|
||||||
payTime: "2024-11-27T13:12:45Z",
|
|
||||||
description: "账户充值",
|
|
||||||
balance: 80.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "7",
|
|
||||||
orderNo: "RC20241126001",
|
|
||||||
amount: 120.0,
|
|
||||||
paymentMethod: "bank",
|
|
||||||
status: "success" as const,
|
|
||||||
createTime: "2024-11-26T08:30:00Z",
|
|
||||||
payTime: "2024-11-26T08:33:20Z",
|
|
||||||
description: "账户充值",
|
|
||||||
balance: 120.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "8",
|
|
||||||
orderNo: "RC20241125001",
|
|
||||||
amount: 250.0,
|
|
||||||
paymentMethod: "alipay",
|
|
||||||
status: "pending" as const,
|
|
||||||
createTime: "2024-11-25T15:45:00Z",
|
|
||||||
description: "账户充值",
|
|
||||||
balance: 370.0,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 模拟延迟
|
|
||||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
// 获取充值记录列表
|
// 获取充值记录列表
|
||||||
export async function getRechargeOrders(
|
export async function getRechargeOrders(params: RechargeOrderParams) {
|
||||||
params: RechargeOrderParams,
|
return request("/v1/tokens/orderList", params, "GET");
|
||||||
): Promise<RechargeOrdersResponse> {
|
|
||||||
await delay(800); // 模拟网络延迟
|
|
||||||
|
|
||||||
let filteredOrders = [...mockOrders];
|
|
||||||
|
|
||||||
// 状态筛选
|
|
||||||
if (params.status && params.status !== "all") {
|
|
||||||
filteredOrders = filteredOrders.filter(
|
|
||||||
order => order.status === params.status,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 时间筛选
|
|
||||||
if (params.startTime) {
|
|
||||||
filteredOrders = filteredOrders.filter(
|
|
||||||
order => new Date(order.createTime) >= new Date(params.startTime!),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (params.endTime) {
|
|
||||||
filteredOrders = filteredOrders.filter(
|
|
||||||
order => new Date(order.createTime) <= new Date(params.endTime!),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页
|
|
||||||
const startIndex = (params.page - 1) * params.limit;
|
|
||||||
const endIndex = startIndex + params.limit;
|
|
||||||
const paginatedOrders = filteredOrders.slice(startIndex, endIndex);
|
|
||||||
|
|
||||||
return {
|
|
||||||
list: paginatedOrders,
|
|
||||||
total: filteredOrders.length,
|
|
||||||
page: params.page,
|
|
||||||
limit: params.limit,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取充值记录详情
|
// 获取充值记录详情
|
||||||
export async function getRechargeOrderDetail(
|
export async function getRechargeOrderDetail(
|
||||||
id: string,
|
params: GetRechargeOrderDetailParams,
|
||||||
): Promise<RechargeOrderDetail> {
|
): Promise<RechargeOrderDetail> {
|
||||||
await delay(500);
|
return request("/v1/tokens/queryOrder", params, "GET");
|
||||||
|
|
||||||
const order = mockOrders.find(o => o.id === id);
|
|
||||||
if (!order) {
|
|
||||||
throw new Error("订单不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...order,
|
|
||||||
paymentChannel:
|
|
||||||
order.paymentMethod === "wechat"
|
|
||||||
? "微信支付"
|
|
||||||
: order.paymentMethod === "alipay"
|
|
||||||
? "支付宝"
|
|
||||||
: "银行转账",
|
|
||||||
transactionId: `TX${order.orderNo}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消充值订单
|
export interface ContinuePayParams {
|
||||||
export async function cancelRechargeOrder(id: string): Promise<void> {
|
orderNo: string;
|
||||||
await delay(1000);
|
[property: string]: any;
|
||||||
|
|
||||||
const orderIndex = mockOrders.findIndex(o => o.id === id);
|
|
||||||
if (orderIndex === -1) {
|
|
||||||
throw new Error("订单不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mockOrders[orderIndex].status !== "pending") {
|
|
||||||
throw new Error("只能取消处理中的订单");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟更新订单状态
|
|
||||||
(mockOrders[orderIndex] as any).status = "cancelled";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 申请退款
|
export interface ContinuePayResponse {
|
||||||
export async function refundRechargeOrder(
|
code_url?: string;
|
||||||
id: string,
|
codeUrl?: string;
|
||||||
reason: string,
|
payUrl?: string;
|
||||||
): Promise<void> {
|
[property: string]: any;
|
||||||
await delay(1200);
|
}
|
||||||
|
|
||||||
const orderIndex = mockOrders.findIndex(o => o.id === id);
|
// 继续支付
|
||||||
if (orderIndex === -1) {
|
export function continuePay(
|
||||||
throw new Error("订单不存在");
|
params: ContinuePayParams,
|
||||||
}
|
): Promise<ContinuePayResponse> {
|
||||||
|
return request("/v1/tokens/pay", params, "POST");
|
||||||
if (mockOrders[orderIndex].status !== "success") {
|
|
||||||
throw new Error("只能对成功的订单申请退款");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟添加退款信息
|
|
||||||
const order = mockOrders[orderIndex];
|
|
||||||
(order as any).refundAmount = order.amount;
|
|
||||||
(order as any).refundTime = new Date().toISOString();
|
|
||||||
(order as any).refundReason = reason;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,62 @@
|
|||||||
// 充值记录类型定义
|
// 充值记录类型定义
|
||||||
export interface RechargeOrder {
|
export interface RechargeOrder {
|
||||||
id: string;
|
id?: number | string;
|
||||||
orderNo: string;
|
orderNo?: string;
|
||||||
amount: number;
|
money?: number;
|
||||||
paymentMethod: string;
|
amount?: number;
|
||||||
status: "success" | "pending" | "failed" | "cancelled";
|
paymentMethod?: string;
|
||||||
createTime: string;
|
paymentChannel?: string;
|
||||||
payTime?: string;
|
status?: number | string;
|
||||||
|
statusText?: string;
|
||||||
|
orderType?: number;
|
||||||
|
orderTypeText?: string;
|
||||||
|
createTime?: string | number;
|
||||||
|
payTime?: string | number;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
goodsName?: string;
|
||||||
|
goodsSpecs?:
|
||||||
|
| {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
tokens: number;
|
||||||
|
}
|
||||||
|
| string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
operator?: string;
|
operator?: string;
|
||||||
balance?: number;
|
balance?: number;
|
||||||
|
tokens?: number | string;
|
||||||
|
payType?: number;
|
||||||
|
payTypeText?: string;
|
||||||
|
transactionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API响应类型
|
// API响应类型
|
||||||
export interface RechargeOrdersResponse {
|
export interface RechargeOrdersResponse {
|
||||||
list: RechargeOrder[];
|
list: RechargeOrder[];
|
||||||
total: number;
|
total?: number;
|
||||||
page: number;
|
page?: number;
|
||||||
limit: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 充值记录详情
|
// 充值记录详情
|
||||||
export interface RechargeOrderDetail extends RechargeOrder {
|
export interface RechargeOrderDetail extends RechargeOrder {
|
||||||
paymentChannel?: string;
|
|
||||||
transactionId?: string;
|
|
||||||
refundAmount?: number;
|
refundAmount?: number;
|
||||||
refundTime?: string;
|
refundTime?: string | number;
|
||||||
refundReason?: string;
|
refundReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询参数
|
// 查询参数
|
||||||
export interface RechargeOrderParams {
|
export interface RechargeOrderParams {
|
||||||
page: number;
|
page?: number | string;
|
||||||
limit: number;
|
limit?: number | string;
|
||||||
status?: string;
|
status?: number | string;
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
|
[property: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetRechargeOrderDetailParams {
|
||||||
|
orderNo: string;
|
||||||
|
[property: string]: any;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
.detailPage {
|
||||||
|
padding: 16px 16px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 95, 204, 0.06);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusIcon {
|
||||||
|
font-size: 56px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusTitle {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1d2129;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDesc {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #86909c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amountHighlight {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #00b578;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px 16px;
|
||||||
|
box-shadow: 0 6px 24px rgba(15, 54, 108, 0.05);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1d2129;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:not(:last-child) {
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px dashed #e5e6eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #86909c;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1d2129;
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyBtn {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1677ff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagGroup {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 12px 16px 24px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, #ffffff 70%);
|
||||||
|
box-shadow: 0 -4px 20px rgba(20, 66, 125, 0.06);
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoiceBtn {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid #1677ff;
|
||||||
|
color: #1677ff;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backBtn {
|
||||||
|
flex: 1;
|
||||||
|
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingWrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyWrapper {
|
||||||
|
text-align: center;
|
||||||
|
color: #86909c;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refundBlock {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f5f7ff;
|
||||||
|
color: #1d2129;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refundTitle {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
328
Cunkebao/src/pages/mobile/mine/recharge/order/detail/index.tsx
Normal file
328
Cunkebao/src/pages/mobile/mine/recharge/order/detail/index.tsx
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { Button, SpinLoading, Tag, Toast } from "antd-mobile";
|
||||||
|
import {
|
||||||
|
CheckCircleOutline,
|
||||||
|
CloseCircleOutline,
|
||||||
|
ClockCircleOutline,
|
||||||
|
ExclamationCircleOutline,
|
||||||
|
} from "antd-mobile-icons";
|
||||||
|
import { CopyOutlined } from "@ant-design/icons";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import NavCommon from "@/components/NavCommon";
|
||||||
|
import { getRechargeOrderDetail } from "../api";
|
||||||
|
import type { RechargeOrderDetail } from "../data";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
|
||||||
|
type StatusMeta = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
amountPrefix: string;
|
||||||
|
color: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatusCode = 0 | 1 | 2 | 3 | 4;
|
||||||
|
|
||||||
|
const statusMetaMap: Record<StatusCode, StatusMeta> = {
|
||||||
|
1: {
|
||||||
|
title: "支付成功",
|
||||||
|
description: "订单已完成支付",
|
||||||
|
amountPrefix: "已支付",
|
||||||
|
color: "#00b578",
|
||||||
|
icon: <CheckCircleOutline className={style.statusIcon} color="#00b578" />,
|
||||||
|
},
|
||||||
|
0: {
|
||||||
|
title: "待支付",
|
||||||
|
description: "请尽快完成支付,以免订单失效",
|
||||||
|
amountPrefix: "待支付",
|
||||||
|
color: "#faad14",
|
||||||
|
icon: <ClockCircleOutline className={style.statusIcon} color="#faad14" />,
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
title: "支付失败",
|
||||||
|
description: "支付未成功,可重新发起支付",
|
||||||
|
amountPrefix: "需支付",
|
||||||
|
color: "#ff4d4f",
|
||||||
|
icon: <CloseCircleOutline className={style.statusIcon} color="#ff4d4f" />,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
title: "订单已取消",
|
||||||
|
description: "该订单已取消,如需继续请重新创建订单",
|
||||||
|
amountPrefix: "订单金额",
|
||||||
|
color: "#86909c",
|
||||||
|
icon: (
|
||||||
|
<ExclamationCircleOutline className={style.statusIcon} color="#86909c" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
title: "订单已退款",
|
||||||
|
description: "订单款项已退回,请注意查收",
|
||||||
|
amountPrefix: "退款金额",
|
||||||
|
color: "#1677ff",
|
||||||
|
icon: (
|
||||||
|
<ExclamationCircleOutline className={style.statusIcon} color="#1677ff" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseStatusCode = (status?: RechargeOrderDetail["status"]) => {
|
||||||
|
if (status === undefined || status === null) return undefined;
|
||||||
|
if (typeof status === "number")
|
||||||
|
return statusMetaMap[status] ? status : undefined;
|
||||||
|
const numeric = Number(status);
|
||||||
|
if (!Number.isNaN(numeric) && statusMetaMap[numeric as StatusCode]) {
|
||||||
|
return numeric as StatusCode;
|
||||||
|
}
|
||||||
|
const map: Record<string, StatusCode> = {
|
||||||
|
success: 1,
|
||||||
|
pending: 0,
|
||||||
|
failed: 4,
|
||||||
|
cancelled: 2,
|
||||||
|
refunded: 3,
|
||||||
|
};
|
||||||
|
return map[status] ?? undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (value?: string | number | null) => {
|
||||||
|
if (value === undefined || value === null) return "-";
|
||||||
|
if (typeof value === "string" && value.trim() === "") return "-";
|
||||||
|
|
||||||
|
const numericValue =
|
||||||
|
typeof value === "number" ? value : Number.parseFloat(value);
|
||||||
|
if (!Number.isFinite(numericValue)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp =
|
||||||
|
numericValue > 1e12
|
||||||
|
? numericValue
|
||||||
|
: numericValue > 1e10
|
||||||
|
? numericValue
|
||||||
|
: numericValue * 1000;
|
||||||
|
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value);
|
||||||
|
return date.toLocaleString("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const centsToYuan = (value?: number | string | null) => {
|
||||||
|
if (value === undefined || value === null) return 0;
|
||||||
|
if (typeof value === "string" && value.trim() === "") return 0;
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return 0;
|
||||||
|
if (Number.isInteger(num)) return num / 100;
|
||||||
|
return num;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RechargeOrderDetailPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [detail, setDetail] = useState<RechargeOrderDetail | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) {
|
||||||
|
Toast.show({ content: "缺少订单ID", position: "top" });
|
||||||
|
navigate(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDetail = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await getRechargeOrderDetail({ orderNo: id });
|
||||||
|
setDetail(res);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取订单详情失败:", error);
|
||||||
|
Toast.show({ content: "获取订单详情失败", position: "top" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDetail();
|
||||||
|
}, [id, navigate]);
|
||||||
|
|
||||||
|
const meta = useMemo<StatusMeta>(() => {
|
||||||
|
if (!detail) {
|
||||||
|
return statusMetaMap[0];
|
||||||
|
}
|
||||||
|
const code = parseStatusCode(detail.status);
|
||||||
|
if (code !== undefined && statusMetaMap[code]) {
|
||||||
|
return statusMetaMap[code];
|
||||||
|
}
|
||||||
|
return statusMetaMap[0];
|
||||||
|
}, [detail]);
|
||||||
|
|
||||||
|
const handleCopy = async (text?: string) => {
|
||||||
|
if (!text) return;
|
||||||
|
if (!navigator.clipboard) {
|
||||||
|
Toast.show({ content: "当前环境不支持复制", position: "top" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
Toast.show({ content: "复制成功", position: "top" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("复制失败:", error);
|
||||||
|
Toast.show({ content: "复制失败,请手动复制", position: "top" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyInvoice = () => {
|
||||||
|
Toast.show({ content: "发票功能即将上线,敬请期待", position: "top" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
navigate("/recharge");
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRefundInfo = () => {
|
||||||
|
if (!detail?.refundAmount) return null;
|
||||||
|
return (
|
||||||
|
<div className={style.refundBlock}>
|
||||||
|
<div className={style.refundTitle}>退款信息</div>
|
||||||
|
<div>退款金额:¥{centsToYuan(detail.refundAmount).toFixed(2)}</div>
|
||||||
|
{detail.refundTime ? (
|
||||||
|
<div>退款时间:{formatDateTime(detail.refundTime)}</div>
|
||||||
|
) : null}
|
||||||
|
{detail.refundReason ? (
|
||||||
|
<div>退款原因:{detail.refundReason}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
header={<NavCommon title="订单详情" />}
|
||||||
|
loading={loading && !detail}
|
||||||
|
footer={
|
||||||
|
<div className={style.actions}>
|
||||||
|
<Button className={style.invoiceBtn} onClick={handleApplyInvoice}>
|
||||||
|
申请发票
|
||||||
|
</Button>
|
||||||
|
<Button className={style.backBtn} onClick={handleBack}>
|
||||||
|
返回算力中心
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.detailPage}>
|
||||||
|
{loading && !detail ? (
|
||||||
|
<div className={style.loadingWrapper}>
|
||||||
|
<SpinLoading color="primary" />
|
||||||
|
</div>
|
||||||
|
) : !detail ? (
|
||||||
|
<div className={style.emptyWrapper}>未找到订单详情</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={style.statusCard}>
|
||||||
|
{meta.icon}
|
||||||
|
<div className={style.statusTitle}>{meta.title}</div>
|
||||||
|
<div className={style.statusDesc}>{meta.description}</div>
|
||||||
|
<div
|
||||||
|
className={style.amountHighlight}
|
||||||
|
style={{ color: meta.color }}
|
||||||
|
>
|
||||||
|
{meta.amountPrefix} ¥
|
||||||
|
{centsToYuan(detail.money ?? detail.amount ?? 0).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.section}>
|
||||||
|
<div className={style.sectionTitle}>订单信息</div>
|
||||||
|
<div className={style.sectionList}>
|
||||||
|
<div className={style.row}>
|
||||||
|
<span className={style.label}>订单号</span>
|
||||||
|
<span className={style.value}>
|
||||||
|
{detail.orderNo || "-"}
|
||||||
|
<span
|
||||||
|
className={style.copyBtn}
|
||||||
|
onClick={() => handleCopy(detail.orderNo)}
|
||||||
|
>
|
||||||
|
<CopyOutlined />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={style.row}>
|
||||||
|
<span className={style.label}>套餐名称</span>
|
||||||
|
<span className={style.value}>
|
||||||
|
{detail.description || detail.goodsName || "算力充值"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={style.row}>
|
||||||
|
<span className={style.label}>订单金额</span>
|
||||||
|
<span className={style.value}>
|
||||||
|
¥
|
||||||
|
{centsToYuan(detail.money ?? detail.amount ?? 0).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={style.row}>
|
||||||
|
<span className={style.label}>创建时间</span>
|
||||||
|
<span className={style.value}>
|
||||||
|
{formatDateTime(detail.createTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={style.row}>
|
||||||
|
<span className={style.label}>支付时间</span>
|
||||||
|
<span className={style.value}>
|
||||||
|
{formatDateTime(detail.payTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{detail.balance !== undefined ? (
|
||||||
|
<div className={style.row}>
|
||||||
|
<span className={style.label}>充值后余额</span>
|
||||||
|
<span className={style.value}>
|
||||||
|
¥{centsToYuan(detail.balance).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{renderRefundInfo()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.section}>
|
||||||
|
<div className={style.sectionTitle}>支付信息</div>
|
||||||
|
<div className={style.sectionList}>
|
||||||
|
<div className={style.row}>
|
||||||
|
<span className={style.label}>支付方式</span>
|
||||||
|
<span className={style.value}>
|
||||||
|
<span className={style.tagGroup}>
|
||||||
|
<Tag color="primary" fill="outline">
|
||||||
|
{detail.payTypeText ||
|
||||||
|
detail.paymentChannel ||
|
||||||
|
detail.paymentMethod ||
|
||||||
|
"其他支付"}
|
||||||
|
</Tag>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={style.row}>
|
||||||
|
<span className={style.label}>交易流水号</span>
|
||||||
|
<span className={style.value}>{detail.id || "-"}</span>
|
||||||
|
</div>
|
||||||
|
{detail.remark ? (
|
||||||
|
<div className={style.row}>
|
||||||
|
<span className={style.label}>备注信息</span>
|
||||||
|
<span className={style.value}>{detail.remark}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RechargeOrderDetailPage;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Card, SpinLoading, Empty, Toast, Dialog } from "antd-mobile";
|
import { Card, SpinLoading, Empty, Toast, Dialog } from "antd-mobile";
|
||||||
import {
|
import {
|
||||||
@@ -10,14 +10,110 @@ import {
|
|||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import NavCommon from "@/components/NavCommon";
|
import NavCommon from "@/components/NavCommon";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import {
|
import { getRechargeOrders, continuePay } from "./api";
|
||||||
getRechargeOrders,
|
|
||||||
cancelRechargeOrder,
|
|
||||||
refundRechargeOrder,
|
|
||||||
} from "./api";
|
|
||||||
import { RechargeOrder } from "./data";
|
import { RechargeOrder } from "./data";
|
||||||
import style from "./index.module.scss";
|
import style from "./index.module.scss";
|
||||||
|
|
||||||
|
type StatusCode = 0 | 1 | 2 | 3 | 4;
|
||||||
|
|
||||||
|
const STATUS_META: Record<
|
||||||
|
StatusCode,
|
||||||
|
{ label: string; color: string; tagBgOpacity?: number }
|
||||||
|
> = {
|
||||||
|
0: { label: "待支付", color: "#faad14" },
|
||||||
|
1: { label: "充值成功", color: "#52c41a" },
|
||||||
|
2: { label: "已取消", color: "#999999" },
|
||||||
|
3: { label: "已退款", color: "#1890ff" },
|
||||||
|
4: { label: "充值失败", color: "#ff4d4f" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseStatusCode = (
|
||||||
|
status?: RechargeOrder["status"],
|
||||||
|
): StatusCode | undefined => {
|
||||||
|
if (status === undefined || status === null) return undefined;
|
||||||
|
|
||||||
|
if (typeof status === "number") {
|
||||||
|
return STATUS_META[status as StatusCode]
|
||||||
|
? (status as StatusCode)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number(status);
|
||||||
|
if (!Number.isNaN(numeric) && STATUS_META[numeric as StatusCode]) {
|
||||||
|
return numeric as StatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringMap: Record<string, StatusCode> = {
|
||||||
|
success: 1,
|
||||||
|
pending: 0,
|
||||||
|
cancelled: 2,
|
||||||
|
refunded: 3,
|
||||||
|
failed: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
return stringMap[status] ?? undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (value?: string | number | null) => {
|
||||||
|
if (value === undefined || value === null) return "-";
|
||||||
|
if (typeof value === "string" && value.trim() === "") return "-";
|
||||||
|
|
||||||
|
const numericValue =
|
||||||
|
typeof value === "number" ? value : Number.parseFloat(value);
|
||||||
|
if (!Number.isFinite(numericValue)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp =
|
||||||
|
numericValue > 1e12
|
||||||
|
? numericValue
|
||||||
|
: numericValue > 1e10
|
||||||
|
? numericValue
|
||||||
|
: numericValue * 1000;
|
||||||
|
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (days === 0) {
|
||||||
|
return date.toLocaleTimeString("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (days === 1) {
|
||||||
|
return `昨天 ${date.toLocaleTimeString("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
if (days < 7) {
|
||||||
|
return `${days}天前`;
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString("zh-CN");
|
||||||
|
};
|
||||||
|
|
||||||
|
const centsToYuan = (value?: number | string | null) => {
|
||||||
|
if (value === undefined || value === null) return 0;
|
||||||
|
if (typeof value === "string" && value.trim() === "") return 0;
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return 0;
|
||||||
|
if (Number.isInteger(num)) return num / 100;
|
||||||
|
return num;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPaymentMethodText = (order: RechargeOrder) => {
|
||||||
|
if (order.payTypeText) return order.payTypeText;
|
||||||
|
if (order.paymentChannel) return order.paymentChannel;
|
||||||
|
if (order.paymentMethod) return order.paymentMethod;
|
||||||
|
return "其他支付";
|
||||||
|
};
|
||||||
|
|
||||||
const RechargeOrders: React.FC = () => {
|
const RechargeOrders: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [orders, setOrders] = useState<RechargeOrder[]>([]);
|
const [orders, setOrders] = useState<RechargeOrder[]>([]);
|
||||||
@@ -25,6 +121,7 @@ const RechargeOrders: React.FC = () => {
|
|||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const [payingOrderNo, setPayingOrderNo] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadOrders = async (reset = false) => {
|
const loadOrders = async (reset = false) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -33,6 +130,7 @@ const RechargeOrders: React.FC = () => {
|
|||||||
const params = {
|
const params = {
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
|
orderType: 1,
|
||||||
...(statusFilter !== "all" && { status: statusFilter }),
|
...(statusFilter !== "all" && { status: statusFilter }),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,6 +151,7 @@ const RechargeOrders: React.FC = () => {
|
|||||||
// 初始化加载
|
// 初始化加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadOrders(true);
|
loadOrders(true);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 筛选条件变化时重新加载
|
// 筛选条件变化时重新加载
|
||||||
@@ -63,36 +162,6 @@ const RechargeOrders: React.FC = () => {
|
|||||||
loadOrders(true);
|
loadOrders(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusText = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "success":
|
|
||||||
return "充值成功";
|
|
||||||
case "pending":
|
|
||||||
return "处理中";
|
|
||||||
case "failed":
|
|
||||||
return "充值失败";
|
|
||||||
case "cancelled":
|
|
||||||
return "已取消";
|
|
||||||
default:
|
|
||||||
return "未知状态";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "success":
|
|
||||||
return "#52c41a";
|
|
||||||
case "pending":
|
|
||||||
return "#faad14";
|
|
||||||
case "failed":
|
|
||||||
return "#ff4d4f";
|
|
||||||
case "cancelled":
|
|
||||||
return "#999";
|
|
||||||
default:
|
|
||||||
return "#666";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPaymentMethodIcon = (method: string) => {
|
const getPaymentMethodIcon = (method: string) => {
|
||||||
switch (method.toLowerCase()) {
|
switch (method.toLowerCase()) {
|
||||||
case "wechat":
|
case "wechat":
|
||||||
@@ -119,131 +188,231 @@ const RechargeOrders: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (timeStr: string) => {
|
const handleViewDetail = (order: RechargeOrder) => {
|
||||||
const date = new Date(timeStr);
|
const identifier = order.orderNo || order.id;
|
||||||
const now = new Date();
|
if (!identifier) {
|
||||||
const diff = now.getTime() - date.getTime();
|
Toast.show({
|
||||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
content: "无法打开订单详情",
|
||||||
|
position: "top",
|
||||||
if (days === 0) {
|
|
||||||
return date.toLocaleTimeString("zh-CN", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
});
|
||||||
} else if (days === 1) {
|
return;
|
||||||
return (
|
}
|
||||||
"昨天 " +
|
navigate(`/recharge/order/${identifier}`);
|
||||||
date.toLocaleTimeString("zh-CN", {
|
};
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
const openPayDialog = (
|
||||||
})
|
order: RechargeOrder,
|
||||||
|
options: { codeUrl?: string; payUrl?: string },
|
||||||
|
) => {
|
||||||
|
const { codeUrl, payUrl } = options;
|
||||||
|
if (codeUrl) {
|
||||||
|
Dialog.show({
|
||||||
|
content: (
|
||||||
|
<div style={{ textAlign: "center", padding: "20px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "16px",
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
请使用微信扫码完成支付
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src={codeUrl}
|
||||||
|
alt="支付二维码"
|
||||||
|
style={{ width: "220px", height: "220px", margin: "0 auto" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "16px",
|
||||||
|
color: "#666",
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
支付金额:¥
|
||||||
|
{centsToYuan(order.money ?? order.amount ?? 0).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
closeOnMaskClick: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payUrl) {
|
||||||
|
window.location.href = payUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.show({
|
||||||
|
content: "暂未获取到支付信息,请稍后重试",
|
||||||
|
position: "top",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContinuePay = async (order: RechargeOrder) => {
|
||||||
|
if (!order.orderNo) {
|
||||||
|
Toast.show({
|
||||||
|
content: "订单号缺失,无法继续支付",
|
||||||
|
position: "top",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderNo = String(order.orderNo);
|
||||||
|
setPayingOrderNo(orderNo);
|
||||||
|
try {
|
||||||
|
const res = await continuePay({ orderNo });
|
||||||
|
const codeUrl = res?.code_url || res?.codeUrl;
|
||||||
|
const payUrl = res?.payUrl;
|
||||||
|
if (!codeUrl && !payUrl) {
|
||||||
|
Toast.show({
|
||||||
|
content: "未获取到支付链接,请稍后重试",
|
||||||
|
position: "top",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openPayDialog(order, { codeUrl, payUrl });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("继续支付失败:", error);
|
||||||
|
Toast.show({
|
||||||
|
content: "继续支付失败,请重试",
|
||||||
|
position: "top",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPayingOrderNo(prev => (prev === orderNo ? null : prev));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderOrderItem = (order: RechargeOrder) => {
|
||||||
|
const statusCode = parseStatusCode(order.status);
|
||||||
|
const statusMeta =
|
||||||
|
statusCode !== undefined ? STATUS_META[statusCode] : undefined;
|
||||||
|
const paymentMethod = getPaymentMethodText(order);
|
||||||
|
const paymentMethodKey = paymentMethod.toLowerCase();
|
||||||
|
const statusBgOpacity = statusMeta?.tagBgOpacity ?? 0.15;
|
||||||
|
const statusBgColor = statusMeta
|
||||||
|
? `${statusMeta.color}${Math.round(statusBgOpacity * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0")}`
|
||||||
|
: "#66666626";
|
||||||
|
const amount = centsToYuan(order.money ?? order.amount ?? 0) || 0;
|
||||||
|
const isPaying = payingOrderNo === order.orderNo;
|
||||||
|
const actions: React.ReactNode[] = [];
|
||||||
|
|
||||||
|
if (statusCode === 0) {
|
||||||
|
actions.push(
|
||||||
|
<button
|
||||||
|
key="continue"
|
||||||
|
className={`${style["action-btn"]} ${style["primary"]}`}
|
||||||
|
onClick={() => handleContinuePay(order)}
|
||||||
|
disabled={isPaying}
|
||||||
|
>
|
||||||
|
{isPaying ? "处理中..." : "继续支付"}
|
||||||
|
</button>,
|
||||||
);
|
);
|
||||||
} else if (days < 7) {
|
|
||||||
return `${days}天前`;
|
|
||||||
} else {
|
|
||||||
return date.toLocaleDateString("zh-CN");
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelOrder = async (orderId: string) => {
|
if (statusCode === 4) {
|
||||||
const result = await Dialog.confirm({
|
actions.push(
|
||||||
content: "确定要取消这个充值订单吗?",
|
<button
|
||||||
confirmText: "确定取消",
|
key="retry"
|
||||||
cancelText: "再想想",
|
className={`${style["action-btn"]} ${style["primary"]}`}
|
||||||
});
|
onClick={() => navigate("/recharge")}
|
||||||
|
>
|
||||||
if (result) {
|
重新充值
|
||||||
try {
|
</button>,
|
||||||
await cancelRechargeOrder(orderId);
|
);
|
||||||
Toast.show({ content: "订单已取消", position: "top" });
|
|
||||||
loadOrders(true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("取消订单失败:", error);
|
|
||||||
Toast.show({ content: "取消失败,请重试", position: "top" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefundOrder = async (orderId: string) => {
|
if (statusCode === 1 || statusCode === 3 || statusCode === 2) {
|
||||||
const result = await Dialog.confirm({
|
actions.push(
|
||||||
content: "确定要申请退款吗?退款将在1-3个工作日内处理。",
|
<button
|
||||||
confirmText: "申请退款",
|
key="purchase-again"
|
||||||
cancelText: "取消",
|
className={`${style["action-btn"]} ${style["secondary"]}`}
|
||||||
});
|
onClick={() => navigate("/recharge")}
|
||||||
|
>
|
||||||
if (result) {
|
再次购买
|
||||||
try {
|
</button>,
|
||||||
await refundRechargeOrder(orderId, "用户主动申请退款");
|
);
|
||||||
Toast.show({ content: "退款申请已提交", position: "top" });
|
|
||||||
loadOrders(true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("申请退款失败:", error);
|
|
||||||
Toast.show({ content: "申请失败,请重试", position: "top" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const renderOrderItem = (order: RechargeOrder) => (
|
actions.push(
|
||||||
<Card key={order.id} className={style["order-card"]}>
|
<button
|
||||||
<div className={style["order-header"]}>
|
key="detail"
|
||||||
<div className={style["order-info"]}>
|
className={`${style["action-btn"]} ${style["secondary"]}`}
|
||||||
<div className={style["order-no"]}>订单号:{order.orderNo}</div>
|
onClick={() => handleViewDetail(order)}
|
||||||
<div className={style["order-time"]}>
|
>
|
||||||
<ClockCircleOutlined style={{ fontSize: 12 }} />
|
查看详情
|
||||||
{formatTime(order.createTime)}
|
</button>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={order.id} className={style["order-card"]}>
|
||||||
|
<div className={style["order-header"]}>
|
||||||
|
<div className={style["order-info"]}>
|
||||||
|
<div className={style["order-no"]}>
|
||||||
|
订单号:{order.orderNo || "-"}
|
||||||
|
</div>
|
||||||
|
<div className={style["order-time"]}>
|
||||||
|
<ClockCircleOutlined style={{ fontSize: 12 }} />
|
||||||
|
{formatTimestamp(order.createTime)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style["order-amount"]}>
|
||||||
|
<div className={style["amount-text"]}>¥{amount.toFixed(2)}</div>
|
||||||
|
<div
|
||||||
|
className={style["status-tag"]}
|
||||||
|
style={{
|
||||||
|
backgroundColor: statusBgColor,
|
||||||
|
color: statusMeta?.color || "#666",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusMeta?.label || "未知状态"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style["order-amount"]}>
|
|
||||||
<div className={style["amount-text"]}>
|
|
||||||
¥{order.amount.toFixed(2)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={style["status-tag"]}
|
|
||||||
style={{
|
|
||||||
backgroundColor: `${getStatusColor(order.status)}20`,
|
|
||||||
color: getStatusColor(order.status),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getStatusText(order.status)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={style["order-details"]}>
|
<div className={style["order-details"]}>
|
||||||
<div className={style["payment-method"]}>
|
<div className={style["payment-method"]}>
|
||||||
<div
|
<div
|
||||||
className={style["method-icon"]}
|
className={style["method-icon"]}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: getPaymentMethodColor(order.paymentMethod),
|
backgroundColor: getPaymentMethodColor(paymentMethodKey),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getPaymentMethodIcon(order.paymentMethod)}
|
{getPaymentMethodIcon(paymentMethod)}
|
||||||
|
</div>
|
||||||
|
<div className={style["method-text"]}>{paymentMethod}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style["method-text"]}>{order.paymentMethod}</div>
|
|
||||||
|
{(order.description || order.remark) && (
|
||||||
|
<div className={style["detail-row"]}>
|
||||||
|
<span className={style["label"]}>备注</span>
|
||||||
|
<span className={style["value"]}>
|
||||||
|
{order.description || order.remark}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{order.payTime && (
|
||||||
|
<div className={style["detail-row"]}>
|
||||||
|
<span className={style["label"]}>支付时间</span>
|
||||||
|
<span className={style["value"]}>
|
||||||
|
{formatTimestamp(order.payTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{order.balance !== undefined && (
|
||||||
|
<div className={style["balance-info"]}>
|
||||||
|
充值后余额: ¥{order.balance.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{order.description && (
|
{/* {order.status === "pending" && (
|
||||||
<div className={style["detail-row"]}>
|
|
||||||
<span className={style["label"]}>备注</span>
|
|
||||||
<span className={style["value"]}>{order.description}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{order.payTime && (
|
|
||||||
<div className={style["detail-row"]}>
|
|
||||||
<span className={style["label"]}>支付时间</span>
|
|
||||||
<span className={style["value"]}>{formatTime(order.payTime)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{order.balance !== undefined && (
|
|
||||||
<div className={style["balance-info"]}>
|
|
||||||
充值后余额: ¥{order.balance.toFixed(2)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{order.status === "pending" && (
|
|
||||||
<div className={style["order-actions"]}>
|
<div className={style["order-actions"]}>
|
||||||
<button
|
<button
|
||||||
className={`${style["action-btn"]} ${style["danger"]}`}
|
className={`${style["action-btn"]} ${style["danger"]}`}
|
||||||
@@ -252,44 +421,21 @@ const RechargeOrders: React.FC = () => {
|
|||||||
取消订单
|
取消订单
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
|
|
||||||
{order.status === "success" && (
|
{actions.length > 0 && (
|
||||||
<div className={style["order-actions"]}>
|
<div className={style["order-actions"]}>{actions}</div>
|
||||||
<button
|
)}
|
||||||
className={`${style["action-btn"]} ${style["secondary"]}`}
|
</Card>
|
||||||
onClick={() => navigate(`/recharge/order/${order.id}`)}
|
);
|
||||||
>
|
};
|
||||||
查看详情
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`${style["action-btn"]} ${style["primary"]}`}
|
|
||||||
onClick={() => handleRefundOrder(order.id)}
|
|
||||||
>
|
|
||||||
申请退款
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{order.status === "failed" && (
|
|
||||||
<div className={style["order-actions"]}>
|
|
||||||
<button
|
|
||||||
className={`${style["action-btn"]} ${style["primary"]}`}
|
|
||||||
onClick={() => navigate("/recharge")}
|
|
||||||
>
|
|
||||||
重新充值
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
const filterTabs = [
|
const filterTabs = [
|
||||||
{ key: "all", label: "全部" },
|
{ key: "all", label: "全部" },
|
||||||
{ key: "success", label: "成功" },
|
{ key: "1", label: "成功" },
|
||||||
{ key: "pending", label: "处理中" },
|
{ key: "0", label: "待支付" },
|
||||||
{ key: "failed", label: "失败" },
|
{ key: "2", label: "已取消" },
|
||||||
{ key: "cancelled", label: "已取消" },
|
{ key: "3", label: "已退款" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
|||||||
// 下载模板
|
// 下载模板
|
||||||
const handleDownloadTemplate = () => {
|
const handleDownloadTemplate = () => {
|
||||||
const template =
|
const template =
|
||||||
"电话号码,微信号,来源,订单金额,下单日期\n13800138000,wxid_123,抖音,99.00,2024-03-03";
|
"姓名/备注,电话号码,微信号,来源,订单金额,下单日期\n张三,13800138000,wxid_123,抖音,99.00,2024-03-03";
|
||||||
const blob = new Blob([template], { type: "text/csv" });
|
const blob = new Blob([template], { type: "text/csv" });
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { BrowserRouter, useRoutes, RouteObject } from "react-router-dom";
|
import { BrowserRouter, useRoutes, RouteObject } from "react-router-dom";
|
||||||
import PermissionRoute from "./permissionRoute";
|
import PermissionRoute from "./permissionRoute";
|
||||||
|
import FloatingVideoHelp from "@/components/FloatingVideoHelp";
|
||||||
|
|
||||||
// 动态导入所有 module 下的 ts/tsx 路由模块
|
// 动态导入所有 module 下的 ts/tsx 路由模块
|
||||||
const modules = import.meta.glob("./module/*.{ts,tsx}", { eager: true });
|
const modules = import.meta.glob("./module/*.{ts,tsx}", { eager: true });
|
||||||
@@ -43,6 +44,7 @@ const AppRouter: React.FC = () => (
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
|
{/* <FloatingVideoHelp /> */}
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import WechatAccounts from "@/pages/mobile/mine/wechat-accounts/list/index";
|
|||||||
import WechatAccountDetail from "@/pages/mobile/mine/wechat-accounts/detail/index";
|
import WechatAccountDetail from "@/pages/mobile/mine/wechat-accounts/detail/index";
|
||||||
import Recharge from "@/pages/mobile/mine/recharge/index";
|
import Recharge from "@/pages/mobile/mine/recharge/index";
|
||||||
import RechargeOrder from "@/pages/mobile/mine/recharge/order/index";
|
import RechargeOrder from "@/pages/mobile/mine/recharge/order/index";
|
||||||
|
import RechargeOrderDetail from "@/pages/mobile/mine/recharge/order/detail";
|
||||||
import BuyPower from "@/pages/mobile/mine/recharge/buy-power";
|
import BuyPower from "@/pages/mobile/mine/recharge/buy-power";
|
||||||
import UsageRecords from "@/pages/mobile/mine/recharge/usage-records";
|
import UsageRecords from "@/pages/mobile/mine/recharge/usage-records";
|
||||||
import Setting from "@/pages/mobile/mine/setting/index";
|
import Setting from "@/pages/mobile/mine/setting/index";
|
||||||
@@ -76,6 +77,11 @@ const routes = [
|
|||||||
element: <RechargeOrder />,
|
element: <RechargeOrder />,
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/recharge/order/:id",
|
||||||
|
element: <RechargeOrderDetail />,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/recharge/buy-power",
|
path: "/recharge/buy-power",
|
||||||
element: <BuyPower />,
|
element: <BuyPower />,
|
||||||
|
|||||||
5
Moncter/runtime/logs/workerman.log
Normal file
5
Moncter/runtime/logs/workerman.log
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
2025-11-07 15:58:25 pid:842 Workerman[start.php] start in DEBUG mode
|
||||||
|
2025-11-07 16:03:24 pid:842 Workerman[start.php] reloading
|
||||||
|
2025-11-07 18:26:50 pid:842 Workerman[start.php] received signal SIGHUP
|
||||||
|
2025-11-07 18:26:50 pid:842 Workerman[start.php] stopping
|
||||||
|
2025-11-07 18:26:50 pid:842 Workerman[start.php] has been stopped
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
.chatFooter {
|
||||||
|
background: #f7f7f7;
|
||||||
|
border-top: 1px solid #e1e1e1;
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputContainer {
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputToolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leftTool {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarButton {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e6e6e6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #d9d9d9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputArea {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrapper {
|
||||||
|
border: 1px solid #d1d1d1;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #07c160;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageInput {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
resize: none;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #b3b3b3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendButtonArea {
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendButton {
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: normal;
|
||||||
|
min-width: 60px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #07c160;
|
||||||
|
border-color: #07c160;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #06ad56;
|
||||||
|
border-color: #06ad56;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #059748;
|
||||||
|
border-color: #059748;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #b3b3b3;
|
||||||
|
border-color: #b3b3b3;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hintButton {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputHint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.inputToolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendButtonArea {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
.stepContent {
|
||||||
|
.stepHeader {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.step3Content {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.leftColumn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightColumn {
|
||||||
|
width: 400px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagePreview {
|
||||||
|
border: 2px dashed #52c41a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f6ffed;
|
||||||
|
|
||||||
|
.previewTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #52c41a;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageBubble {
|
||||||
|
min-height: 60px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
|
||||||
|
.currentEditingLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageText {
|
||||||
|
color: #333;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.savedScriptGroups {
|
||||||
|
.scriptGroupTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scriptGroupItem {
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
.scriptGroupHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.scriptGroupLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
:global(.ant-radio) {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scriptGroupName {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageCount {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scriptGroupActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.actionButton {
|
||||||
|
padding: 4px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scriptGroupContent {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageInputArea {
|
||||||
|
.messageInput {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachmentButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiRewriteSection {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageHint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsPanel {
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #fafafa;
|
||||||
|
|
||||||
|
.settingsTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingItem {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingControl {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagSection {
|
||||||
|
.settingLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pushPreview {
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f0f7ff;
|
||||||
|
|
||||||
|
.previewTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.step3Content {
|
||||||
|
.rightColumn {
|
||||||
|
width: 350px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.step3Content {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.leftColumn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightColumn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import ContentSelection from "@/components/ContentSelection";
|
||||||
|
import { ContentItem } from "@/components/ContentSelection/data";
|
||||||
|
import InputMessage from "./InputMessage/InputMessage";
|
||||||
|
import styles from "./index.module.scss";
|
||||||
|
|
||||||
|
interface StepSendMessageProps {
|
||||||
0
Moncter/src/pages/pc/ckbox/weChat/api.ts
Normal file
0
Moncter/src/pages/pc/ckbox/weChat/api.ts
Normal file
0
Moncter/src/store/module/websocket/websocket.ts
Normal file
0
Moncter/src/store/module/websocket/websocket.ts
Normal file
@@ -2,7 +2,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container main {
|
.container main {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const FriendListItem = memo<{
|
|||||||
onClick={() => onSelect(friend)}
|
onClick={() => onSelect(friend)}
|
||||||
>
|
>
|
||||||
<Checkbox checked={isSelected} />
|
<Checkbox checked={isSelected} />
|
||||||
|
|
||||||
<Avatar src={friend.avatar} size={40}>
|
<Avatar src={friend.avatar} size={40}>
|
||||||
{friend.nickname?.charAt(0)}
|
{friend.nickname?.charAt(0)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@@ -41,6 +42,9 @@ interface TwoColumnSelectionProps {
|
|||||||
deviceIds?: number[];
|
deviceIds?: number[];
|
||||||
enableDeviceFilter?: boolean;
|
enableDeviceFilter?: boolean;
|
||||||
dataSource?: FriendSelectionItem[];
|
dataSource?: FriendSelectionItem[];
|
||||||
|
onLoadMore?: () => void; // 加载更多回调
|
||||||
|
hasMore?: boolean; // 是否有更多数据
|
||||||
|
loading?: boolean; // 是否正在加载
|
||||||
}
|
}
|
||||||
|
|
||||||
const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
|
const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
|
||||||
@@ -51,15 +55,16 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
|
|||||||
deviceIds = [],
|
deviceIds = [],
|
||||||
enableDeviceFilter = true,
|
enableDeviceFilter = true,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
onLoadMore,
|
||||||
|
hasMore = false,
|
||||||
|
loading = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [rawFriends, setRawFriends] = useState<FriendSelectionItem[]>([]);
|
const [rawFriends, setRawFriends] = useState<FriendSelectionItem[]>([]);
|
||||||
const [selectedFriends, setSelectedFriends] = useState<FriendSelectionItem[]>(
|
const [selectedFriends, setSelectedFriends] = useState<FriendSelectionItem[]>(
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
|
||||||
|
|
||||||
// 使用 useMemo 缓存过滤结果,避免每次渲染都重新计算
|
// 使用 useMemo 缓存过滤结果,避免每次渲染都重新计算
|
||||||
const filteredFriends = useMemo(() => {
|
const filteredFriends = useMemo(() => {
|
||||||
@@ -76,17 +81,8 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
|
|||||||
);
|
);
|
||||||
}, [dataSource, rawFriends, searchQuery]);
|
}, [dataSource, rawFriends, searchQuery]);
|
||||||
|
|
||||||
// 分页显示好友列表,避免一次性渲染太多项目
|
// 好友列表直接使用过滤后的结果
|
||||||
const ITEMS_PER_PAGE = 50;
|
const friends = filteredFriends;
|
||||||
const [displayPage, setDisplayPage] = useState(1);
|
|
||||||
|
|
||||||
const friends = useMemo(() => {
|
|
||||||
const startIndex = 0;
|
|
||||||
const endIndex = displayPage * ITEMS_PER_PAGE;
|
|
||||||
return filteredFriends.slice(startIndex, endIndex);
|
|
||||||
}, [filteredFriends, displayPage]);
|
|
||||||
|
|
||||||
const hasMoreFriends = filteredFriends.length > friends.length;
|
|
||||||
|
|
||||||
// 使用 useMemo 缓存选中状态映射,避免每次渲染都重新计算
|
// 使用 useMemo 缓存选中状态映射,避免每次渲染都重新计算
|
||||||
const selectedFriendsMap = useMemo(() => {
|
const selectedFriendsMap = useMemo(() => {
|
||||||
@@ -100,7 +96,7 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
|
|||||||
// 获取好友列表
|
// 获取好友列表
|
||||||
const fetchFriends = useCallback(
|
const fetchFriends = useCallback(
|
||||||
async (page: number, keyword: string = "") => {
|
async (page: number, keyword: string = "") => {
|
||||||
setLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: any = {
|
const params: any = {
|
||||||
page,
|
page,
|
||||||
@@ -119,7 +115,6 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
|
|||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setRawFriends(response.data.list || []);
|
setRawFriends(response.data.list || []);
|
||||||
setTotalPages(Math.ceil((response.data.total || 0) / 20));
|
|
||||||
} else {
|
} else {
|
||||||
setRawFriends([]);
|
setRawFriends([]);
|
||||||
message.error(response.message || "获取好友列表失败");
|
message.error(response.message || "获取好友列表失败");
|
||||||
@@ -128,7 +123,7 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
|
|||||||
console.error("获取好友列表失败:", error);
|
console.error("获取好友列表失败:", error);
|
||||||
message.error("获取好友列表失败");
|
message.error("获取好友列表失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[deviceIds, enableDeviceFilter],
|
[deviceIds, enableDeviceFilter],
|
||||||
@@ -139,7 +134,6 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
|
|||||||
if (visible && !dataSource) {
|
if (visible && !dataSource) {
|
||||||
// 只有在没有外部数据源时才调用 API
|
// 只有在没有外部数据源时才调用 API
|
||||||
fetchFriends(1);
|
fetchFriends(1);
|
||||||
setCurrentPage(1);
|
|
||||||
}
|
}
|
||||||
}, [visible, dataSource, fetchFriends]);
|
}, [visible, dataSource, fetchFriends]);
|
||||||
|
|
||||||
@@ -148,49 +142,23 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
|
|||||||
if (visible) {
|
if (visible) {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setSelectedFriends([]);
|
setSelectedFriends([]);
|
||||||
setLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
// 防抖搜索处理
|
// 防抖搜索处理
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = useCallback(
|
||||||
let timeoutId: NodeJS.Timeout;
|
(value: string) => {
|
||||||
return (value: string) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
setDisplayPage(1); // 重置分页
|
|
||||||
if (!dataSource) {
|
|
||||||
fetchFriends(1, value);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
}, [dataSource, fetchFriends])();
|
|
||||||
|
|
||||||
// API搜索处理(当没有外部数据源时)
|
|
||||||
const handleApiSearch = useCallback(
|
|
||||||
async (keyword: string) => {
|
|
||||||
if (!dataSource) {
|
if (!dataSource) {
|
||||||
await fetchFriends(1, keyword);
|
const timer = setTimeout(() => {
|
||||||
|
fetchFriends(1, value);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dataSource, fetchFriends],
|
[dataSource, fetchFriends],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 加载更多好友
|
|
||||||
const handleLoadMore = useCallback(() => {
|
|
||||||
setDisplayPage(prev => prev + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 防抖搜索
|
|
||||||
useEffect(() => {
|
|
||||||
if (!dataSource && searchQuery.trim()) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
handleApiSearch(searchQuery);
|
|
||||||
}, 300);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [searchQuery, dataSource, handleApiSearch]);
|
|
||||||
|
|
||||||
// 选择好友 - 使用 useCallback 优化性能
|
// 选择好友 - 使用 useCallback 优化性能
|
||||||
const handleSelectFriend = useCallback((friend: FriendSelectionItem) => {
|
const handleSelectFriend = useCallback((friend: FriendSelectionItem) => {
|
||||||
setSelectedFriends(prev => {
|
setSelectedFriends(prev => {
|
||||||
@@ -216,10 +184,8 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
|
|||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
}, [selectedFriends, onConfirm]);
|
}, [selectedFriends, onConfirm]);
|
||||||
|
|
||||||
// 取消选择 - 使用 useCallback 优化性能
|
// 取消选择
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
setSelectedFriends([]);
|
|
||||||
setSearchQuery("");
|
|
||||||
onCancel();
|
onCancel();
|
||||||
}, [onCancel]);
|
}, [onCancel]);
|
||||||
|
|
||||||
@@ -248,8 +214,8 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setSearchQuery(value); // 立即更新显示
|
setSearchQuery(value);
|
||||||
handleSearch(value); // 防抖处理搜索
|
handleSearch(value);
|
||||||
}}
|
}}
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
allowClear
|
allowClear
|
||||||
@@ -257,7 +223,7 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.friendList}>
|
<div className={styles.friendList}>
|
||||||
{loading ? (
|
{isLoading && !loading ? (
|
||||||
<div className={styles.loading}>加载中...</div>
|
<div className={styles.loading}>加载中...</div>
|
||||||
) : friends.length > 0 ? (
|
) : friends.length > 0 ? (
|
||||||
// 使用 React.memo 优化列表项渲染
|
// 使用 React.memo 优化列表项渲染
|
||||||
@@ -280,9 +246,10 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasMoreFriends && (
|
{/* 使用外部传入的加载更多 */}
|
||||||
|
{hasMore && (
|
||||||
<div className={styles.loadMoreWrapper}>
|
<div className={styles.loadMoreWrapper}>
|
||||||
<Button type="link" onClick={handleLoadMore} loading={loading}>
|
<Button type="link" onClick={onLoadMore} loading={loading}>
|
||||||
加载更多
|
加载更多
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,97 +7,18 @@ import dayjs from "dayjs";
|
|||||||
import "dayjs/locale/zh-cn";
|
import "dayjs/locale/zh-cn";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./styles/global.scss";
|
import "./styles/global.scss";
|
||||||
import { db } from "./utils/db"; // 引入数据库实例
|
import { initializeDatabaseFromPersistedUser } from "./utils/db";
|
||||||
|
|
||||||
// 设置dayjs为中文
|
// 设置dayjs为中文
|
||||||
dayjs.locale("zh-cn");
|
dayjs.locale("zh-cn");
|
||||||
|
|
||||||
// 清理旧数据库
|
async function bootstrap() {
|
||||||
async function cleanupOldDatabase() {
|
|
||||||
try {
|
try {
|
||||||
// 获取所有数据库
|
await initializeDatabaseFromPersistedUser();
|
||||||
const databases = await indexedDB.databases();
|
|
||||||
|
|
||||||
for (const dbInfo of databases) {
|
|
||||||
if (dbInfo.name === "CunkebaoDatabase") {
|
|
||||||
console.log("检测到旧版数据库,开始清理...");
|
|
||||||
|
|
||||||
// 打开数据库检查版本
|
|
||||||
const openRequest = indexedDB.open(dbInfo.name);
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
openRequest.onsuccess = async event => {
|
|
||||||
const database = (event.target as IDBOpenDBRequest).result;
|
|
||||||
const objectStoreNames = Array.from(database.objectStoreNames);
|
|
||||||
|
|
||||||
// 检查是否存在旧表
|
|
||||||
const hasOldTables = objectStoreNames.some(name =>
|
|
||||||
[
|
|
||||||
"kfUsers",
|
|
||||||
"weChatGroup",
|
|
||||||
"contracts",
|
|
||||||
"newContactList",
|
|
||||||
"messageList",
|
|
||||||
].includes(name),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasOldTables) {
|
|
||||||
console.log("发现旧表,删除整个数据库:", objectStoreNames);
|
|
||||||
database.close();
|
|
||||||
|
|
||||||
// 删除整个数据库
|
|
||||||
const deleteRequest = indexedDB.deleteDatabase(dbInfo.name);
|
|
||||||
deleteRequest.onsuccess = () => {
|
|
||||||
console.log("旧数据库已删除");
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
deleteRequest.onerror = () => {
|
|
||||||
console.error("删除旧数据库失败");
|
|
||||||
reject();
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
console.log("数据库结构正确,无需清理");
|
|
||||||
database.close();
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
openRequest.onerror = () => {
|
|
||||||
console.error("无法打开数据库进行检查");
|
|
||||||
reject();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("清理旧数据库时出错(可忽略):", error);
|
console.warn("Failed to prepare database before app bootstrap:", error);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 数据库初始化
|
|
||||||
async function initializeApp() {
|
|
||||||
try {
|
|
||||||
// 1. 清理旧数据库
|
|
||||||
await cleanupOldDatabase();
|
|
||||||
|
|
||||||
// 2. 打开新数据库
|
|
||||||
await db.open();
|
|
||||||
console.log("数据库初始化成功");
|
|
||||||
|
|
||||||
// 3. 开发环境清空数据(可选)
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
console.log("开发环境:跳过数据清理");
|
|
||||||
// 如需清空数据,取消下面的注释
|
|
||||||
// await db.chatSessions.clear();
|
|
||||||
// await db.contactsUnified.clear();
|
|
||||||
// await db.contactLabelMap.clear();
|
|
||||||
// await db.userLoginRecords.clear();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("数据库初始化失败:", error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染应用
|
|
||||||
const root = createRoot(document.getElementById("root")!);
|
const root = createRoot(document.getElementById("root")!);
|
||||||
root.render(
|
root.render(
|
||||||
<ConfigProvider locale={zhCN}>
|
<ConfigProvider locale={zhCN}>
|
||||||
@@ -106,5 +27,4 @@ async function initializeApp() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动应用
|
void bootstrap();
|
||||||
initializeApp();
|
|
||||||
|
|||||||
@@ -35,28 +35,64 @@ export function updateConfig(params) {
|
|||||||
return request2("/api/WechatFriend/updateConfig", params, "PUT");
|
return request2("/api/WechatFriend/updateConfig", params, "PUT");
|
||||||
}
|
}
|
||||||
//获取聊天记录-2 获取列表
|
//获取聊天记录-2 获取列表
|
||||||
export function getChatMessages(params: {
|
export interface messreocrParams {
|
||||||
wechatAccountId: number;
|
From?: number | string;
|
||||||
wechatFriendId?: number;
|
To?: number | string;
|
||||||
wechatChatroomId?: number;
|
/**
|
||||||
From: number;
|
* 当前页码,从 1 开始
|
||||||
To: number;
|
*/
|
||||||
Count: number;
|
page?: number;
|
||||||
olderData: boolean;
|
/**
|
||||||
}) {
|
* 每页条数
|
||||||
return request2("/api/FriendMessage/SearchMessage", params, "GET");
|
*/
|
||||||
|
limit?: number;
|
||||||
|
/**
|
||||||
|
* 群id
|
||||||
|
*/
|
||||||
|
wechatChatroomId?: number | string;
|
||||||
|
/**
|
||||||
|
* 好友id
|
||||||
|
*/
|
||||||
|
wechatFriendId?: number | string;
|
||||||
|
/**
|
||||||
|
* 微信账号ID
|
||||||
|
*/
|
||||||
|
wechatAccountId?: number | string;
|
||||||
|
/**
|
||||||
|
* 关键词、类型等扩展参数
|
||||||
|
*/
|
||||||
|
[property: string]: any;
|
||||||
}
|
}
|
||||||
export function getChatroomMessages(params: {
|
export function getChatMessages(params: messreocrParams) {
|
||||||
wechatAccountId: number;
|
return request("/v1/kefu/message/details", params, "GET");
|
||||||
wechatFriendId?: number;
|
|
||||||
wechatChatroomId?: number;
|
|
||||||
From: number;
|
|
||||||
To: number;
|
|
||||||
Count: number;
|
|
||||||
olderData: boolean;
|
|
||||||
}) {
|
|
||||||
return request2("/api/ChatroomMessage/SearchMessage", params, "GET");
|
|
||||||
}
|
}
|
||||||
|
export function getChatroomMessages(params: messreocrParams) {
|
||||||
|
return request("/v1/kefu/message/details", params, "GET");
|
||||||
|
}
|
||||||
|
//=====================旧==============================
|
||||||
|
|
||||||
|
// export function getChatMessages(params: {
|
||||||
|
// wechatAccountId: number;
|
||||||
|
// wechatFriendId?: number;
|
||||||
|
// wechatChatroomId?: number;
|
||||||
|
// From: number;
|
||||||
|
// To: number;
|
||||||
|
// Count: number;
|
||||||
|
// olderData: boolean;
|
||||||
|
// }) {
|
||||||
|
// return request2("/api/FriendMessage/SearchMessage", params, "GET");
|
||||||
|
// }
|
||||||
|
// export function getChatroomMessages(params: {
|
||||||
|
// wechatAccountId: number;
|
||||||
|
// wechatFriendId?: number;
|
||||||
|
// wechatChatroomId?: number;
|
||||||
|
// From: number;
|
||||||
|
// To: number;
|
||||||
|
// Count: number;
|
||||||
|
// olderData: boolean;
|
||||||
|
// }) {
|
||||||
|
// return request2("/api/ChatroomMessage/SearchMessage", params, "GET");
|
||||||
|
// }
|
||||||
|
|
||||||
//获取群列表
|
//获取群列表
|
||||||
export function getGroupList(params: { prevId: number; count: number }) {
|
export function getGroupList(params: { prevId: number; count: number }) {
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export interface ContractData {
|
|||||||
labels: string[];
|
labels: string[];
|
||||||
signature: string;
|
signature: string;
|
||||||
accountId: number;
|
accountId: number;
|
||||||
extendFields: null;
|
extendFields?: Record<string, any> | null;
|
||||||
city?: string;
|
city?: string;
|
||||||
lastUpdateTime: string;
|
lastUpdateTime: string;
|
||||||
isPassed: boolean;
|
isPassed: boolean;
|
||||||
|
|||||||
@@ -23,12 +23,16 @@ import {
|
|||||||
SendOutlined,
|
SendOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import styles from "./PushTaskModal.module.scss";
|
import styles from "./PushTaskModal.module.scss";
|
||||||
import {
|
import { useCustomerStore } from "@/store/module/weChat/customer";
|
||||||
useCustomerStore,
|
|
||||||
} from "@/store/module/weChat/customer";
|
|
||||||
import { getContactList, getGroupList } from "@/pages/pc/ckbox/weChat/api";
|
import { getContactList, getGroupList } from "@/pages/pc/ckbox/weChat/api";
|
||||||
|
|
||||||
export type PushType = "friend-message" | "group-message" | "group-announcement";
|
const DEFAULT_FRIEND_INTERVAL: [number, number] = [3, 10];
|
||||||
|
const DEFAULT_MESSAGE_INTERVAL: [number, number] = [1, 3];
|
||||||
|
|
||||||
|
export type PushType =
|
||||||
|
| "friend-message"
|
||||||
|
| "group-message"
|
||||||
|
| "group-announcement";
|
||||||
|
|
||||||
interface PushTaskModalProps {
|
interface PushTaskModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -67,8 +71,12 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({
|
|||||||
const [selectedAccounts, setSelectedAccounts] = useState<any[]>([]);
|
const [selectedAccounts, setSelectedAccounts] = useState<any[]>([]);
|
||||||
const [selectedContacts, setSelectedContacts] = useState<ContactItem[]>([]);
|
const [selectedContacts, setSelectedContacts] = useState<ContactItem[]>([]);
|
||||||
const [messageContent, setMessageContent] = useState("");
|
const [messageContent, setMessageContent] = useState("");
|
||||||
const [friendInterval, setFriendInterval] = useState(10);
|
const [friendInterval, setFriendInterval] = useState<[number, number]>([
|
||||||
const [messageInterval, setMessageInterval] = useState(1);
|
...DEFAULT_FRIEND_INTERVAL,
|
||||||
|
]);
|
||||||
|
const [messageInterval, setMessageInterval] = useState<[number, number]>([
|
||||||
|
...DEFAULT_MESSAGE_INTERVAL,
|
||||||
|
]);
|
||||||
const [selectedTag, setSelectedTag] = useState<string>("");
|
const [selectedTag, setSelectedTag] = useState<string>("");
|
||||||
const [aiRewriteEnabled, setAiRewriteEnabled] = useState(false);
|
const [aiRewriteEnabled, setAiRewriteEnabled] = useState(false);
|
||||||
const [aiPrompt, setAiPrompt] = useState("");
|
const [aiPrompt, setAiPrompt] = useState("");
|
||||||
@@ -97,7 +105,7 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getSubtitle = () => {
|
const getSubtitle = () => {
|
||||||
return "智能批量推送,AI智能话术改写";
|
return "智能批量推送,AI智能话术改写";
|
||||||
};
|
};
|
||||||
|
|
||||||
// 步骤2的标题
|
// 步骤2的标题
|
||||||
@@ -120,8 +128,8 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({
|
|||||||
setSelectedAccounts([]);
|
setSelectedAccounts([]);
|
||||||
setSelectedContacts([]);
|
setSelectedContacts([]);
|
||||||
setMessageContent("");
|
setMessageContent("");
|
||||||
setFriendInterval(10);
|
setFriendInterval([...DEFAULT_FRIEND_INTERVAL]);
|
||||||
setMessageInterval(1);
|
setMessageInterval([...DEFAULT_MESSAGE_INTERVAL]);
|
||||||
setSelectedTag("");
|
setSelectedTag("");
|
||||||
setAiRewriteEnabled(false);
|
setAiRewriteEnabled(false);
|
||||||
setAiPrompt("");
|
setAiPrompt("");
|
||||||
@@ -270,7 +278,9 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({
|
|||||||
setCurrentStep(2);
|
setCurrentStep(2);
|
||||||
} else if (currentStep === 2) {
|
} else if (currentStep === 2) {
|
||||||
if (selectedContacts.length === 0) {
|
if (selectedContacts.length === 0) {
|
||||||
message.warning(`请至少选择一个${pushType === "friend-message" ? "好友" : "群"}`);
|
message.warning(
|
||||||
|
`请至少选择一个${pushType === "friend-message" ? "好友" : "群"}`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCurrentStep(3);
|
setCurrentStep(3);
|
||||||
@@ -343,7 +353,9 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({
|
|||||||
<div className={styles.accountCards}>
|
<div className={styles.accountCards}>
|
||||||
{filteredAccounts.length > 0 ? (
|
{filteredAccounts.length > 0 ? (
|
||||||
filteredAccounts.map(account => {
|
filteredAccounts.map(account => {
|
||||||
const isSelected = selectedAccounts.some(a => a.id === account.id);
|
const isSelected = selectedAccounts.some(
|
||||||
|
a => a.id === account.id,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={account.id}
|
key={account.id}
|
||||||
@@ -355,7 +367,8 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({
|
|||||||
size={48}
|
size={48}
|
||||||
style={{ backgroundColor: "#1890ff" }}
|
style={{ backgroundColor: "#1890ff" }}
|
||||||
>
|
>
|
||||||
{!account.avatar && (account.nickname || account.name || "").charAt(0)}
|
{!account.avatar &&
|
||||||
|
(account.nickname || account.name || "").charAt(0)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className={styles.cardName}>
|
<div className={styles.cardName}>
|
||||||
{account.nickname || account.name || "未知"}
|
{account.nickname || account.name || "未知"}
|
||||||
@@ -570,10 +583,7 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({
|
|||||||
<Button type="text" icon="⭐" />
|
<Button type="text" icon="⭐" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.aiRewriteSection}>
|
<div className={styles.aiRewriteSection}>
|
||||||
<Switch
|
<Switch checked={aiRewriteEnabled} onChange={setAiRewriteEnabled} />
|
||||||
checked={aiRewriteEnabled}
|
|
||||||
onChange={setAiRewriteEnabled}
|
|
||||||
/>
|
|
||||||
<span style={{ marginLeft: 8 }}>AI智能话术改写</span>
|
<span style={{ marginLeft: 8 }}>AI智能话术改写</span>
|
||||||
{aiRewriteEnabled && (
|
{aiRewriteEnabled && (
|
||||||
<Input
|
<Input
|
||||||
@@ -598,13 +608,16 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({
|
|||||||
<div className={styles.settingControl}>
|
<div className={styles.settingControl}>
|
||||||
<span>间隔时间(秒)</span>
|
<span>间隔时间(秒)</span>
|
||||||
<Slider
|
<Slider
|
||||||
min={10}
|
range
|
||||||
max={20}
|
min={1}
|
||||||
|
max={60}
|
||||||
value={friendInterval}
|
value={friendInterval}
|
||||||
onChange={setFriendInterval}
|
onChange={value => setFriendInterval(value as [number, number])}
|
||||||
style={{ flex: 1, margin: "0 16px" }}
|
style={{ flex: 1, margin: "0 16px" }}
|
||||||
/>
|
/>
|
||||||
<span>{friendInterval} - 20</span>
|
<span>
|
||||||
|
{friendInterval[0]} - {friendInterval[1]}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.settingItem}>
|
<div className={styles.settingItem}>
|
||||||
@@ -612,13 +625,18 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({
|
|||||||
<div className={styles.settingControl}>
|
<div className={styles.settingControl}>
|
||||||
<span>间隔时间(秒)</span>
|
<span>间隔时间(秒)</span>
|
||||||
<Slider
|
<Slider
|
||||||
|
range
|
||||||
min={1}
|
min={1}
|
||||||
max={12}
|
max={60}
|
||||||
value={messageInterval}
|
value={messageInterval}
|
||||||
onChange={setMessageInterval}
|
onChange={value =>
|
||||||
|
setMessageInterval(value as [number, number])
|
||||||
|
}
|
||||||
style={{ flex: 1, margin: "0 16px" }}
|
style={{ flex: 1, margin: "0 16px" }}
|
||||||
/>
|
/>
|
||||||
<span>{messageInterval} - 12</span>
|
<span>
|
||||||
|
{messageInterval[0]} - {messageInterval[1]}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.settingItem}>
|
<div className={styles.settingItem}>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// 获取客服列表
|
||||||
|
export function queryWorkbenchCreate(params) {
|
||||||
|
return request("/v1/workbench/create", params, "POST");
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { Avatar, Empty, Input } from "antd";
|
||||||
|
import { CheckCircleOutlined, SearchOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
import styles from "../../index.module.scss";
|
||||||
|
|
||||||
|
interface StepSelectAccountProps {
|
||||||
|
customerList: any[];
|
||||||
|
selectedAccounts: any[];
|
||||||
|
onChange: (accounts: any[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepSelectAccount: React.FC<StepSelectAccountProps> = ({
|
||||||
|
customerList,
|
||||||
|
selectedAccounts,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
|
|
||||||
|
const filteredAccounts = useMemo(() => {
|
||||||
|
if (!searchKeyword.trim()) return customerList;
|
||||||
|
const keyword = searchKeyword.toLowerCase();
|
||||||
|
return customerList.filter(
|
||||||
|
account =>
|
||||||
|
(account.nickname || "").toLowerCase().includes(keyword) ||
|
||||||
|
(account.wechatId || "").toLowerCase().includes(keyword),
|
||||||
|
);
|
||||||
|
}, [customerList, searchKeyword]);
|
||||||
|
|
||||||
|
const handleAccountToggle = (account: any) => {
|
||||||
|
const isSelected = selectedAccounts.some(a => a.id === account.id);
|
||||||
|
if (isSelected) {
|
||||||
|
onChange(selectedAccounts.filter(a => a.id !== account.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange([...selectedAccounts, account]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.step1Content}>
|
||||||
|
<div className={styles.stepHeader}>
|
||||||
|
<h3>选择微信账号</h3>
|
||||||
|
<p>可选择多个微信账号进行推送</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.searchBar}>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入昵称/微信号进行搜索"
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={e => setSearchKeyword(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredAccounts.length > 0 ? (
|
||||||
|
<div className={styles.accountCards}>
|
||||||
|
{filteredAccounts.map(account => {
|
||||||
|
const isSelected = selectedAccounts.some(s => s.id === account.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={account.id}
|
||||||
|
className={`${styles.accountCard} ${isSelected ? styles.selected : ""}`}
|
||||||
|
onClick={() => handleAccountToggle(account)}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={account.avatar}
|
||||||
|
size={40}
|
||||||
|
style={{ backgroundColor: "#1890ff" }}
|
||||||
|
shape="square"
|
||||||
|
>
|
||||||
|
{!account.avatar &&
|
||||||
|
(account.nickname || account.name || "").charAt(0)}
|
||||||
|
</Avatar>
|
||||||
|
<div className={styles.accountInfo}>
|
||||||
|
<div className={styles.accountName}>
|
||||||
|
{account.nickname || account.name || "未知"}
|
||||||
|
</div>
|
||||||
|
<div className={styles.accountStatus}>
|
||||||
|
<span
|
||||||
|
className={`${styles.statusDot} ${account.isOnline ? styles.online : styles.offline}`}
|
||||||
|
/>
|
||||||
|
<span className={styles.statusText}>
|
||||||
|
{account.isOnline ? "在线" : "离线"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<CheckCircleOutlined className={styles.checkmark} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
description={searchKeyword ? "未找到匹配的账号" : "暂无微信账号"}
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StepSelectAccount;
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Empty,
|
||||||
|
Input,
|
||||||
|
Pagination,
|
||||||
|
Spin,
|
||||||
|
message,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
CloseOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
|
||||||
|
import { getContactList, getGroupList } from "@/pages/pc/ckbox/weChat/api";
|
||||||
|
|
||||||
|
import styles from "../../index.module.scss";
|
||||||
|
import { ContactItem, PushType } from "../../types";
|
||||||
|
import PoolSelection from "@/components/PoolSelection";
|
||||||
|
import type { PoolSelectionItem } from "@/components/PoolSelection/data";
|
||||||
|
|
||||||
|
interface ContactFilterValues {
|
||||||
|
includeTags: string[];
|
||||||
|
excludeTags: string[];
|
||||||
|
includeCities: string[];
|
||||||
|
excludeCities: string[];
|
||||||
|
nicknameRemark: string;
|
||||||
|
groupIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDefaultFilterValues = (): ContactFilterValues => ({
|
||||||
|
includeTags: [],
|
||||||
|
excludeTags: [],
|
||||||
|
includeCities: [],
|
||||||
|
excludeCities: [],
|
||||||
|
nicknameRemark: "",
|
||||||
|
groupIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cloneFilterValues = (
|
||||||
|
values: ContactFilterValues,
|
||||||
|
): ContactFilterValues => ({
|
||||||
|
includeTags: [...values.includeTags],
|
||||||
|
excludeTags: [...values.excludeTags],
|
||||||
|
includeCities: [...values.includeCities],
|
||||||
|
excludeCities: [...values.excludeCities],
|
||||||
|
nicknameRemark: values.nicknameRemark,
|
||||||
|
groupIds: [...values.groupIds],
|
||||||
|
});
|
||||||
|
|
||||||
|
const DISABLED_TAG_LABELS = new Set(["请选择标签"]);
|
||||||
|
|
||||||
|
interface StepSelectContactsProps {
|
||||||
|
pushType: PushType;
|
||||||
|
selectedAccounts: any[];
|
||||||
|
selectedContacts: ContactItem[];
|
||||||
|
onChange: (contacts: ContactItem[]) => void;
|
||||||
|
selectedTrafficPools: PoolSelectionItem[];
|
||||||
|
onTrafficPoolsChange: (pools: PoolSelectionItem[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepSelectContacts: React.FC<StepSelectContactsProps> = ({
|
||||||
|
pushType,
|
||||||
|
selectedAccounts,
|
||||||
|
selectedContacts,
|
||||||
|
onChange,
|
||||||
|
selectedTrafficPools,
|
||||||
|
onTrafficPoolsChange,
|
||||||
|
}) => {
|
||||||
|
const [contactsData, setContactsData] = useState<ContactItem[]>([]);
|
||||||
|
const [loadingContacts, setLoadingContacts] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [searchValue, setSearchValue] = useState("");
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [filterModalVisible, setFilterModalVisible] = useState(false);
|
||||||
|
const [filterValues, setFilterValues] = useState<ContactFilterValues>(
|
||||||
|
createDefaultFilterValues,
|
||||||
|
);
|
||||||
|
const [draftFilterValues, setDraftFilterValues] =
|
||||||
|
useState<ContactFilterValues>(createDefaultFilterValues);
|
||||||
|
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
const stepTitle = useMemo(() => {
|
||||||
|
switch (pushType) {
|
||||||
|
case "friend-message":
|
||||||
|
return "好友";
|
||||||
|
case "group-message":
|
||||||
|
case "group-announcement":
|
||||||
|
return "群";
|
||||||
|
default:
|
||||||
|
return "选择";
|
||||||
|
}
|
||||||
|
}, [pushType]);
|
||||||
|
|
||||||
|
const loadContacts = useCallback(async () => {
|
||||||
|
if (selectedAccounts.length === 0) {
|
||||||
|
setContactsData([]);
|
||||||
|
setTotal(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingContacts(true);
|
||||||
|
try {
|
||||||
|
const accountIds = selectedAccounts.map(a => a.id);
|
||||||
|
const allData: ContactItem[] = [];
|
||||||
|
let totalCount = 0;
|
||||||
|
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
const params: any = {
|
||||||
|
page,
|
||||||
|
limit: pageSize,
|
||||||
|
wechatAccountId: accountId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (searchValue.trim()) {
|
||||||
|
params.keyword = searchValue.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response =
|
||||||
|
pushType === "friend-message"
|
||||||
|
? await getContactList(params)
|
||||||
|
: await getGroupList(params);
|
||||||
|
|
||||||
|
const data =
|
||||||
|
response.data?.list || response.data || response.list || [];
|
||||||
|
const totalValue = response.data?.total || response.total || 0;
|
||||||
|
|
||||||
|
const filteredData = data.filter((item: any) => {
|
||||||
|
const itemAccountId = item.wechatAccountId || item.accountId;
|
||||||
|
return itemAccountId === accountId;
|
||||||
|
});
|
||||||
|
|
||||||
|
filteredData.forEach((item: ContactItem) => {
|
||||||
|
if (!allData.some(d => d.id === item.id)) {
|
||||||
|
allData.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
totalCount += totalValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContactsData(allData);
|
||||||
|
setTotal(totalCount > 0 ? totalCount : allData.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("加载数据失败:", error);
|
||||||
|
message.error("加载数据失败");
|
||||||
|
setContactsData([]);
|
||||||
|
setTotal(0);
|
||||||
|
} finally {
|
||||||
|
setLoadingContacts(false);
|
||||||
|
}
|
||||||
|
}, [page, pushType, searchValue, selectedAccounts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadContacts();
|
||||||
|
}, [loadContacts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchValue.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPage(1);
|
||||||
|
}, [searchValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
if (selectedAccounts.length === 0 && selectedContacts.length > 0) {
|
||||||
|
onChange([]);
|
||||||
|
}
|
||||||
|
}, [onChange, selectedAccounts, selectedContacts.length]);
|
||||||
|
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
|
setSearchValue(value);
|
||||||
|
if (!value.trim()) {
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tagOptions = useMemo(() => {
|
||||||
|
const tagSet = new Set<string>();
|
||||||
|
contactsData.forEach(contact => {
|
||||||
|
(contact.labels || []).forEach(tag => {
|
||||||
|
const normalizedTag = (tag || "").trim();
|
||||||
|
if (normalizedTag && !DISABLED_TAG_LABELS.has(normalizedTag)) {
|
||||||
|
tagSet.add(normalizedTag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Array.from(tagSet).map(tag => ({ label: tag, value: tag }));
|
||||||
|
}, [contactsData]);
|
||||||
|
|
||||||
|
const cityOptions = useMemo(() => {
|
||||||
|
const citySet = new Set<string>();
|
||||||
|
contactsData.forEach(contact => {
|
||||||
|
const city = (contact.city || contact.region || "").trim();
|
||||||
|
if (city) {
|
||||||
|
citySet.add(city);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(citySet).map(city => ({ label: city, value: city }));
|
||||||
|
}, [contactsData]);
|
||||||
|
|
||||||
|
const groupOptions = useMemo(() => {
|
||||||
|
const groupMap = new Map<string, string>();
|
||||||
|
contactsData.forEach(contact => {
|
||||||
|
const key =
|
||||||
|
contact.groupName ||
|
||||||
|
contact.groupLabel ||
|
||||||
|
(contact.groupId !== undefined ? contact.groupId.toString() : "");
|
||||||
|
if (key) {
|
||||||
|
const display =
|
||||||
|
contact.groupName || contact.groupLabel || `分组 ${key}`;
|
||||||
|
groupMap.set(key, display);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(groupMap.entries()).map(([value, label]) => ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
}, [contactsData]);
|
||||||
|
|
||||||
|
const hasActiveFilter = useMemo(() => {
|
||||||
|
const {
|
||||||
|
includeTags,
|
||||||
|
excludeTags,
|
||||||
|
includeCities,
|
||||||
|
excludeCities,
|
||||||
|
nicknameRemark,
|
||||||
|
groupIds,
|
||||||
|
} = filterValues;
|
||||||
|
|
||||||
|
if (
|
||||||
|
includeTags.length ||
|
||||||
|
excludeTags.length ||
|
||||||
|
includeCities.length ||
|
||||||
|
excludeCities.length ||
|
||||||
|
groupIds.length ||
|
||||||
|
nicknameRemark.trim()
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [filterValues]);
|
||||||
|
|
||||||
|
const filteredContacts = useMemo(() => {
|
||||||
|
const keyword = searchValue.trim().toLowerCase();
|
||||||
|
const nicknameKeyword = filterValues.nicknameRemark.trim().toLowerCase();
|
||||||
|
|
||||||
|
return contactsData.filter(contact => {
|
||||||
|
const labels = contact.labels || [];
|
||||||
|
const city = (contact.city || contact.region || "").toLowerCase();
|
||||||
|
const groupValue =
|
||||||
|
contact.groupName ||
|
||||||
|
contact.groupLabel ||
|
||||||
|
(contact.groupId !== undefined ? contact.groupId.toString() : "");
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
const combined = `${contact.nickname || ""} ${
|
||||||
|
contact.conRemark || ""
|
||||||
|
}`.toLowerCase();
|
||||||
|
if (!combined.includes(keyword)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterValues.includeTags.length > 0) {
|
||||||
|
const hasAllIncludes = filterValues.includeTags.every(tag =>
|
||||||
|
labels.includes(tag),
|
||||||
|
);
|
||||||
|
if (!hasAllIncludes) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterValues.excludeTags.length > 0) {
|
||||||
|
const hasExcluded = filterValues.excludeTags.some(tag =>
|
||||||
|
labels.includes(tag),
|
||||||
|
);
|
||||||
|
if (hasExcluded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterValues.includeCities.length > 0) {
|
||||||
|
const matchCity = filterValues.includeCities.some(value =>
|
||||||
|
city.includes(value.toLowerCase()),
|
||||||
|
);
|
||||||
|
if (!matchCity) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterValues.excludeCities.length > 0) {
|
||||||
|
const matchExcludedCity = filterValues.excludeCities.some(value =>
|
||||||
|
city.includes(value.toLowerCase()),
|
||||||
|
);
|
||||||
|
if (matchExcludedCity) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nicknameKeyword) {
|
||||||
|
const combined = `${contact.nickname || ""} ${
|
||||||
|
contact.conRemark || ""
|
||||||
|
}`.toLowerCase();
|
||||||
|
if (!combined.includes(nicknameKeyword)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterValues.groupIds.length > 0) {
|
||||||
|
if (!groupValue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!filterValues.groupIds.some(value => value === groupValue.toString())
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [contactsData, filterValues, searchValue]);
|
||||||
|
|
||||||
|
const displayTotal = useMemo(() => {
|
||||||
|
if (hasActiveFilter) {
|
||||||
|
return filteredContacts.length;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}, [filteredContacts, hasActiveFilter, total]);
|
||||||
|
|
||||||
|
const handleContactToggle = (contact: ContactItem) => {
|
||||||
|
const isSelected = selectedContacts.some(c => c.id === contact.id);
|
||||||
|
if (isSelected) {
|
||||||
|
onChange(selectedContacts.filter(c => c.id !== contact.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange([...selectedContacts, contact]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveContact = (contactId: number) => {
|
||||||
|
onChange(selectedContacts.filter(c => c.id !== contactId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAllContacts = () => {
|
||||||
|
if (filteredContacts.length === 0) return;
|
||||||
|
const allSelected = filteredContacts.every(contact =>
|
||||||
|
selectedContacts.some(c => c.id === contact.id),
|
||||||
|
);
|
||||||
|
if (allSelected) {
|
||||||
|
const currentIds = filteredContacts.map(c => c.id);
|
||||||
|
onChange(selectedContacts.filter(c => !currentIds.includes(c.id)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toAdd = filteredContacts.filter(
|
||||||
|
contact => !selectedContacts.some(c => c.id === contact.id),
|
||||||
|
);
|
||||||
|
onChange([...selectedContacts, ...toAdd]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFilterModal = () => {
|
||||||
|
setDraftFilterValues(cloneFilterValues(filterValues));
|
||||||
|
setFilterModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeFilterModal = () => {
|
||||||
|
setFilterModalVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterConfirm = () => {
|
||||||
|
setFilterValues(cloneFilterValues(draftFilterValues));
|
||||||
|
setPage(1);
|
||||||
|
setFilterModalVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterReset = () => {
|
||||||
|
const nextValues = createDefaultFilterValues();
|
||||||
|
setDraftFilterValues(nextValues);
|
||||||
|
setFilterValues(nextValues);
|
||||||
|
setPage(1);
|
||||||
|
setFilterModalVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDraftFilter = <K extends keyof ContactFilterValues>(
|
||||||
|
key: K,
|
||||||
|
value: ContactFilterValues[K],
|
||||||
|
) => {
|
||||||
|
setDraftFilterValues(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (p: number) => {
|
||||||
|
setPage(p);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.stepContent}>
|
||||||
|
<div className={styles.step2Content}>
|
||||||
|
<div className={styles.stepHeader}>
|
||||||
|
<h3>选择{stepTitle}</h3>
|
||||||
|
<p>从{stepTitle}列表中选择推送对象</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.searchContainer}>
|
||||||
|
<Input
|
||||||
|
placeholder={`筛选${stepTitle}`}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={searchValue}
|
||||||
|
onChange={e => handleSearchChange(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<PoolSelection
|
||||||
|
selectedOptions={selectedTrafficPools}
|
||||||
|
onSelect={onTrafficPoolsChange}
|
||||||
|
placeholder="选择流量池包"
|
||||||
|
showSelectedList
|
||||||
|
selectedListMaxHeight={200}
|
||||||
|
/>
|
||||||
|
<div className={styles.contentBody}>
|
||||||
|
<div className={styles.contactList}>
|
||||||
|
<div className={styles.listHeader}>
|
||||||
|
<span>
|
||||||
|
{stepTitle}列表(共{displayTotal}个)
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", gap: 10 }}>
|
||||||
|
<Button onClick={handleSelectAllContacts}>全选</Button>
|
||||||
|
<Button
|
||||||
|
type={hasActiveFilter ? "primary" : "default"}
|
||||||
|
onClick={openFilterModal}
|
||||||
|
>
|
||||||
|
筛选
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.listContent}>
|
||||||
|
{loadingContacts ? (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<Spin size="large" />
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredContacts.length > 0 ? (
|
||||||
|
filteredContacts.map(contact => {
|
||||||
|
const isSelected = selectedContacts.some(
|
||||||
|
c => c.id === contact.id,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={contact.id}
|
||||||
|
className={`${styles.contactItem} ${isSelected ? styles.selected : ""}`}
|
||||||
|
onClick={() => handleContactToggle(contact)}
|
||||||
|
>
|
||||||
|
<Checkbox checked={isSelected} />
|
||||||
|
<Avatar
|
||||||
|
src={contact.avatar}
|
||||||
|
size={40}
|
||||||
|
icon={
|
||||||
|
contact.type === "group" ? (
|
||||||
|
<TeamOutlined />
|
||||||
|
) : (
|
||||||
|
<UserOutlined />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className={styles.contactInfo}>
|
||||||
|
<div className={styles.contactName}>
|
||||||
|
{contact.nickname}
|
||||||
|
</div>
|
||||||
|
{contact.conRemark && (
|
||||||
|
<div className={styles.conRemark}>
|
||||||
|
{contact.conRemark}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{contact.type === "group" && (
|
||||||
|
<TeamOutlined className={styles.groupIcon} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
description={
|
||||||
|
searchValue
|
||||||
|
? `未找到匹配的${stepTitle}`
|
||||||
|
: `暂无${stepTitle}`
|
||||||
|
}
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{displayTotal > 0 && (
|
||||||
|
<div className={styles.paginationContainer}>
|
||||||
|
<Pagination
|
||||||
|
size="small"
|
||||||
|
current={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={displayTotal}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
showSizeChanger={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.selectedList}>
|
||||||
|
<div className={styles.listHeader}>
|
||||||
|
<span>
|
||||||
|
已选{stepTitle}列表(共{selectedContacts.length}个)
|
||||||
|
</span>
|
||||||
|
{selectedContacts.length > 0 && (
|
||||||
|
<Button type="link" size="small" onClick={() => onChange([])}>
|
||||||
|
全取消
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.listContent}>
|
||||||
|
{selectedContacts.length > 0 ? (
|
||||||
|
selectedContacts.map(contact => (
|
||||||
|
<div key={contact.id} className={styles.selectedItem}>
|
||||||
|
<div className={styles.contactInfo}>
|
||||||
|
<Avatar
|
||||||
|
src={contact.avatar}
|
||||||
|
size={40}
|
||||||
|
icon={
|
||||||
|
contact.type === "group" ? (
|
||||||
|
<TeamOutlined />
|
||||||
|
) : (
|
||||||
|
<UserOutlined />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className={styles.contactName}>
|
||||||
|
<div>{contact.nickname}</div>
|
||||||
|
{contact.conRemark && (
|
||||||
|
<div className={styles.conRemark}>
|
||||||
|
{contact.conRemark}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{contact.type === "group" && (
|
||||||
|
<TeamOutlined className={styles.groupIcon} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CloseOutlined
|
||||||
|
className={styles.removeIcon}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveContact(contact.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
description={`请选择${stepTitle}`}
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`筛选${stepTitle}`}
|
||||||
|
open={filterModalVisible}
|
||||||
|
onCancel={closeFilterModal}
|
||||||
|
width={720}
|
||||||
|
className={styles.filterModal}
|
||||||
|
footer={[
|
||||||
|
<Button key="reset" onClick={handleFilterReset}>
|
||||||
|
重置
|
||||||
|
</Button>,
|
||||||
|
<Button key="cancel" onClick={closeFilterModal}>
|
||||||
|
取消
|
||||||
|
</Button>,
|
||||||
|
<Button key="ok" type="primary" onClick={handleFilterConfirm}>
|
||||||
|
确定
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className={styles.filterRow}>
|
||||||
|
<div className={styles.filterLabel}>标签</div>
|
||||||
|
<div className={styles.filterControls}>
|
||||||
|
<div className={styles.filterControl}>
|
||||||
|
<Button type="primary">包含</Button>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="请选择"
|
||||||
|
options={tagOptions}
|
||||||
|
value={draftFilterValues.includeTags}
|
||||||
|
onChange={(value: string[]) =>
|
||||||
|
updateDraftFilter("includeTags", value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.filterControl}>
|
||||||
|
<Button className={styles.excludeButton}>不包含</Button>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="请选择"
|
||||||
|
options={tagOptions}
|
||||||
|
value={draftFilterValues.excludeTags}
|
||||||
|
onChange={(value: string[]) =>
|
||||||
|
updateDraftFilter("excludeTags", value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.filterRow}>
|
||||||
|
<div className={styles.filterLabel}>城市</div>
|
||||||
|
<div className={styles.filterControls}>
|
||||||
|
<div className={styles.filterControl}>
|
||||||
|
<Button type="primary">包含</Button>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="请选择"
|
||||||
|
options={cityOptions}
|
||||||
|
value={draftFilterValues.includeCities}
|
||||||
|
onChange={(value: string[]) =>
|
||||||
|
updateDraftFilter("includeCities", value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.filterControl}>
|
||||||
|
<Button className={styles.excludeButton}>不包含</Button>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="请选择"
|
||||||
|
options={cityOptions}
|
||||||
|
value={draftFilterValues.excludeCities}
|
||||||
|
onChange={(value: string[]) =>
|
||||||
|
updateDraftFilter("excludeCities", value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.filterRow}>
|
||||||
|
<div className={styles.filterLabel}>昵称/备注</div>
|
||||||
|
<div className={styles.filterSingleControl}>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入内容"
|
||||||
|
value={draftFilterValues.nicknameRemark}
|
||||||
|
onChange={e =>
|
||||||
|
updateDraftFilter("nicknameRemark", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.filterRow}>
|
||||||
|
<div className={styles.filterLabel}>分组</div>
|
||||||
|
<div className={styles.filterSingleControl}>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="请选择"
|
||||||
|
options={groupOptions}
|
||||||
|
value={draftFilterValues.groupIds}
|
||||||
|
onChange={(value: string[]) =>
|
||||||
|
updateDraftFilter("groupIds", value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StepSelectContacts;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ContentSelection from "@/components/ContentSelection";
|
||||||
|
import type { ContentItem } from "@/components/ContentSelection/data";
|
||||||
|
import styles from "./index.module.scss";
|
||||||
|
|
||||||
|
interface ContentLibrarySelectorProps {
|
||||||
|
selectedContentLibraries: ContentItem[];
|
||||||
|
onSelectedContentLibrariesChange: (selectedItems: ContentItem[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContentLibrarySelector: React.FC<ContentLibrarySelectorProps> = ({
|
||||||
|
selectedContentLibraries,
|
||||||
|
onSelectedContentLibrariesChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.contentLibrarySelector}>
|
||||||
|
<div className={styles.contentLibraryHeader}>
|
||||||
|
<div className={styles.contentLibraryTitle}>内容库选择</div>
|
||||||
|
<div className={styles.contentLibraryHint}>
|
||||||
|
选择内容库可快速引用现有话术
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ContentSelection
|
||||||
|
selectedOptions={selectedContentLibraries}
|
||||||
|
onSelect={onSelectedContentLibrariesChange}
|
||||||
|
onConfirm={onSelectedContentLibrariesChange}
|
||||||
|
placeholder="请选择内容库"
|
||||||
|
selectedListMaxHeight={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContentLibrarySelector;
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Button, Input, message as antdMessage } from "antd";
|
||||||
|
import { FolderOutlined, PictureOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
import { EmojiPicker } from "@/components/EmojiSeclection";
|
||||||
|
import { EmojiInfo } from "@/components/EmojiSeclection/wechatEmoji";
|
||||||
|
import SimpleFileUpload from "@/components/Upload/SimpleFileUpload";
|
||||||
|
import AudioRecorder from "@/components/Upload/AudioRecorder";
|
||||||
|
|
||||||
|
import styles from "./index.module.scss";
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
type FileTypeValue = 1 | 2 | 3 | 4 | 5;
|
||||||
|
|
||||||
|
interface InputMessageProps {
|
||||||
|
defaultValue?: string;
|
||||||
|
onContentChange?: (value: string) => void;
|
||||||
|
onSend?: (value: string) => void;
|
||||||
|
clearOnSend?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
hint?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileType: Record<string, FileTypeValue> = {
|
||||||
|
TEXT: 1,
|
||||||
|
IMAGE: 2,
|
||||||
|
VIDEO: 3,
|
||||||
|
AUDIO: 4,
|
||||||
|
FILE: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMsgTypeByFileFormat = (filePath: string): number => {
|
||||||
|
const extension = filePath.toLowerCase().split(".").pop() || "";
|
||||||
|
const imageFormats = [
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"png",
|
||||||
|
"gif",
|
||||||
|
"bmp",
|
||||||
|
"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 InputMessage: React.FC<InputMessageProps> = ({
|
||||||
|
defaultValue = "",
|
||||||
|
onContentChange,
|
||||||
|
onSend,
|
||||||
|
clearOnSend = false,
|
||||||
|
placeholder = "输入消息...",
|
||||||
|
hint,
|
||||||
|
}) => {
|
||||||
|
const [inputValue, setInputValue] = useState(defaultValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultValue !== inputValue) {
|
||||||
|
setInputValue(defaultValue);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [defaultValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onContentChange?.(inputValue);
|
||||||
|
}, [inputValue, onContentChange]);
|
||||||
|
|
||||||
|
const handleInputChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setInputValue(e.target.value);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSend = useCallback(() => {
|
||||||
|
const content = inputValue.trim();
|
||||||
|
if (!content) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSend?.(content);
|
||||||
|
if (clearOnSend) {
|
||||||
|
setInputValue("");
|
||||||
|
}
|
||||||
|
antdMessage.success("已添加消息内容");
|
||||||
|
}, [clearOnSend, inputValue, onSend]);
|
||||||
|
|
||||||
|
const handleKeyPress = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key !== "Enter") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = e.currentTarget;
|
||||||
|
const { selectionStart, selectionEnd, value } = target;
|
||||||
|
const nextValue =
|
||||||
|
value.slice(0, selectionStart) + "\n" + value.slice(selectionEnd);
|
||||||
|
setInputValue(nextValue);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const cursorPosition = selectionStart + 1;
|
||||||
|
target.selectionStart = cursorPosition;
|
||||||
|
target.selectionEnd = cursorPosition;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSend],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEmojiSelect = useCallback((emoji: EmojiInfo) => {
|
||||||
|
setInputValue(prev => prev + `[${emoji.name}]`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileUploaded = useCallback(
|
||||||
|
(
|
||||||
|
filePath: { url: string; name: string; durationMs?: number },
|
||||||
|
fileType: FileTypeValue,
|
||||||
|
) => {
|
||||||
|
let msgType = 1;
|
||||||
|
let content: string | Record<string, unknown> = filePath.url;
|
||||||
|
if ([FileType.TEXT].includes(fileType)) {
|
||||||
|
msgType = getMsgTypeByFileFormat(filePath.url);
|
||||||
|
} else if ([FileType.IMAGE].includes(fileType)) {
|
||||||
|
msgType = 3;
|
||||||
|
} 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 === 49) {
|
||||||
|
content = JSON.stringify({
|
||||||
|
type: "file",
|
||||||
|
title: filePath.name,
|
||||||
|
url: filePath.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("模拟上传内容: ", {
|
||||||
|
msgType,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
antdMessage.success("附件上传成功,可在推送时使用");
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAudioUploaded = useCallback(
|
||||||
|
(audioData: { name: string; url: string; durationMs?: number }) => {
|
||||||
|
handleFileUploaded(
|
||||||
|
{
|
||||||
|
name: audioData.name,
|
||||||
|
url: audioData.url,
|
||||||
|
durationMs: audioData.durationMs,
|
||||||
|
},
|
||||||
|
FileType.AUDIO,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[handleFileUploaded],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.chatFooter}>
|
||||||
|
<div className={styles.inputContainer}>
|
||||||
|
<div className={styles.inputToolbar}>
|
||||||
|
<div className={styles.leftTool}>
|
||||||
|
<EmojiPicker onEmojiSelect={handleEmojiSelect} />
|
||||||
|
|
||||||
|
<SimpleFileUpload
|
||||||
|
onFileUploaded={fileInfo =>
|
||||||
|
handleFileUploaded(fileInfo, FileType.IMAGE)
|
||||||
|
}
|
||||||
|
maxSize={10}
|
||||||
|
type={1}
|
||||||
|
slot={
|
||||||
|
<Button
|
||||||
|
className={styles.toolbarButton}
|
||||||
|
type="text"
|
||||||
|
icon={<PictureOutlined />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SimpleFileUpload
|
||||||
|
onFileUploaded={fileInfo =>
|
||||||
|
handleFileUploaded(fileInfo, FileType.FILE)
|
||||||
|
}
|
||||||
|
maxSize={20}
|
||||||
|
type={4}
|
||||||
|
slot={
|
||||||
|
<Button
|
||||||
|
className={styles.toolbarButton}
|
||||||
|
type="text"
|
||||||
|
icon={<FolderOutlined />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AudioRecorder
|
||||||
|
onAudioUploaded={handleAudioUploaded}
|
||||||
|
className={styles.toolbarButton}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.inputArea}>
|
||||||
|
<div className={styles.inputWrapper}>
|
||||||
|
<TextArea
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyPress}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={styles.messageInput}
|
||||||
|
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{hint && <div className={styles.inputHint}>{hint}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InputMessage;
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
.chatFooter {
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputToolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leftTool {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarButton {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e6e6e6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #d9d9d9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputArea {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrapper {
|
||||||
|
border: 1px solid #d1d1d1;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #07c160;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageInput {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
resize: none;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #b3b3b3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendButtonArea {
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendButton {
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: normal;
|
||||||
|
min-width: 60px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #07c160;
|
||||||
|
border-color: #07c160;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #06ad56;
|
||||||
|
border-color: #06ad56;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #059748;
|
||||||
|
border-color: #059748;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #b3b3b3;
|
||||||
|
border-color: #b3b3b3;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hintButton {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputHint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.inputToolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendButtonArea {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
// 创建内容库参数
|
||||||
|
export interface CreateContentLibraryParams {
|
||||||
|
name: string;
|
||||||
|
sourceType: number;
|
||||||
|
sourceFriends?: string[];
|
||||||
|
sourceGroups?: string[];
|
||||||
|
keywordInclude?: string[];
|
||||||
|
keywordExclude?: string[];
|
||||||
|
aiPrompt?: string;
|
||||||
|
timeEnabled?: number;
|
||||||
|
timeStart?: string;
|
||||||
|
timeEnd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建内容库
|
||||||
|
export function createContentLibrary(
|
||||||
|
params: CreateContentLibraryParams,
|
||||||
|
): Promise<any> {
|
||||||
|
return request("/v1/content/library/create", params, "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除内容库
|
||||||
|
export function deleteContentLibrary(params: { id: number }) {
|
||||||
|
return request(`/v1/content/library/update`, params, "DELETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 智能话术改写
|
||||||
|
export function aiEditContent(params: { aiPrompt: string; content: string }) {
|
||||||
|
return request(`/v1/content/library/aiEditContent`, params, "GET");
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
.stepContent {
|
||||||
|
.stepHeader {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.step3Content {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.leftColumn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightColumn {
|
||||||
|
width: 400px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.previewHeaderTitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagePreview {
|
||||||
|
border: 2px dashed #52c41a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
|
||||||
|
.messageBubble {
|
||||||
|
min-height: 100px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
|
||||||
|
.currentEditingLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #52c41a;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageText {
|
||||||
|
color: #1a1a1a;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagePlaceholder {
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageItem {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageText {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAction {
|
||||||
|
color: #ff4d4f;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scriptNameInput {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.savedScriptGroups {
|
||||||
|
.contentLibrarySelector {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentLibraryHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentLibraryTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentLibraryHint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scriptGroupHeaderRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scriptGroupTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scriptGroupHint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scriptGroupList {
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyGroup {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px dashed #d9d9d9;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scriptGroupItem {
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
.scriptGroupHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.scriptGroupLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
:global(.ant-checkbox) {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scriptGroupInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scriptGroupName {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageCount {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scriptGroupActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.actionButton {
|
||||||
|
padding: 4px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scriptGroupContent {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageInputArea {
|
||||||
|
.messageInput {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachmentButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiRewriteSection {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.aiRewriteToggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiRewriteLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiRewriteInput {
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiRewriteActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiRewriteButton {
|
||||||
|
min-width: 96px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsPanel {
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #fafafa;
|
||||||
|
|
||||||
|
.settingsTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingItem {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingControl {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagSection {
|
||||||
|
.settingLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pushPreview {
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f0f7ff;
|
||||||
|
|
||||||
|
.previewTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.step3Content {
|
||||||
|
.rightColumn {
|
||||||
|
width: 350px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.step3Content {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.leftColumn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightColumn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Slider,
|
||||||
|
Switch,
|
||||||
|
message as antdMessage,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
CopyOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import type { CheckboxChangeEvent } from "antd/es/checkbox";
|
||||||
|
|
||||||
|
import styles from "./index.module.scss";
|
||||||
|
import { ContactItem, ScriptGroup } from "../../types";
|
||||||
|
import InputMessage from "./InputMessage/InputMessage";
|
||||||
|
import ContentLibrarySelector from "./ContentLibrarySelector";
|
||||||
|
import type { ContentItem } from "@/components/ContentSelection/data";
|
||||||
|
import {
|
||||||
|
createContentLibrary,
|
||||||
|
deleteContentLibrary,
|
||||||
|
aiEditContent,
|
||||||
|
type CreateContentLibraryParams,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
|
interface StepSendMessageProps {
|
||||||
|
selectedAccounts: any[];
|
||||||
|
selectedContacts: ContactItem[];
|
||||||
|
targetLabel: string;
|
||||||
|
messageContent: string;
|
||||||
|
onMessageContentChange: (value: string) => void;
|
||||||
|
friendInterval: [number, number];
|
||||||
|
onFriendIntervalChange: (value: [number, number]) => void;
|
||||||
|
messageInterval: [number, number];
|
||||||
|
onMessageIntervalChange: (value: [number, number]) => void;
|
||||||
|
selectedTag: string;
|
||||||
|
onSelectedTagChange: (value: string) => void;
|
||||||
|
aiRewriteEnabled: boolean;
|
||||||
|
onAiRewriteToggle: (value: boolean) => void;
|
||||||
|
aiPrompt: string;
|
||||||
|
onAiPromptChange: (value: string) => void;
|
||||||
|
currentScriptMessages: string[];
|
||||||
|
onCurrentScriptMessagesChange: (messages: string[]) => void;
|
||||||
|
currentScriptName: string;
|
||||||
|
onCurrentScriptNameChange: (value: string) => void;
|
||||||
|
savedScriptGroups: ScriptGroup[];
|
||||||
|
onSavedScriptGroupsChange: (groups: ScriptGroup[]) => void;
|
||||||
|
selectedScriptGroupIds: string[];
|
||||||
|
onSelectedScriptGroupIdsChange: (ids: string[]) => void;
|
||||||
|
selectedContentLibraries: ContentItem[];
|
||||||
|
onSelectedContentLibrariesChange: (items: ContentItem[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepSendMessage: React.FC<StepSendMessageProps> = ({
|
||||||
|
selectedAccounts,
|
||||||
|
selectedContacts,
|
||||||
|
targetLabel,
|
||||||
|
messageContent,
|
||||||
|
onMessageContentChange,
|
||||||
|
friendInterval,
|
||||||
|
onFriendIntervalChange,
|
||||||
|
messageInterval,
|
||||||
|
onMessageIntervalChange,
|
||||||
|
selectedTag,
|
||||||
|
onSelectedTagChange,
|
||||||
|
aiRewriteEnabled,
|
||||||
|
onAiRewriteToggle,
|
||||||
|
aiPrompt,
|
||||||
|
onAiPromptChange,
|
||||||
|
currentScriptMessages,
|
||||||
|
onCurrentScriptMessagesChange,
|
||||||
|
currentScriptName,
|
||||||
|
onCurrentScriptNameChange,
|
||||||
|
savedScriptGroups,
|
||||||
|
onSavedScriptGroupsChange,
|
||||||
|
selectedScriptGroupIds,
|
||||||
|
onSelectedScriptGroupIdsChange,
|
||||||
|
selectedContentLibraries,
|
||||||
|
onSelectedContentLibrariesChange,
|
||||||
|
}) => {
|
||||||
|
const [savingScriptGroup, setSavingScriptGroup] = useState(false);
|
||||||
|
const [aiRewriting, setAiRewriting] = useState(false);
|
||||||
|
const [deletingGroupIds, setDeletingGroupIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const handleAddMessage = useCallback(
|
||||||
|
(content?: string, showSuccess?: boolean) => {
|
||||||
|
const finalContent = (content ?? messageContent).trim();
|
||||||
|
if (!finalContent) {
|
||||||
|
antdMessage.warning("请输入消息内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onCurrentScriptMessagesChange([...currentScriptMessages, finalContent]);
|
||||||
|
onMessageContentChange("");
|
||||||
|
if (showSuccess) {
|
||||||
|
antdMessage.success("已添加消息内容");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
currentScriptMessages,
|
||||||
|
messageContent,
|
||||||
|
onCurrentScriptMessagesChange,
|
||||||
|
onMessageContentChange,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveMessage = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const next = currentScriptMessages.filter((_, idx) => idx !== index);
|
||||||
|
onCurrentScriptMessagesChange(next);
|
||||||
|
},
|
||||||
|
[currentScriptMessages, onCurrentScriptMessagesChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSaveScriptGroup = useCallback(async () => {
|
||||||
|
if (savingScriptGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentScriptMessages.length === 0) {
|
||||||
|
antdMessage.warning("请先添加消息内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const groupName =
|
||||||
|
currentScriptName.trim() || `话术组${savedScriptGroups.length + 1}`;
|
||||||
|
const messages = [...currentScriptMessages];
|
||||||
|
const params: CreateContentLibraryParams = {
|
||||||
|
name: groupName,
|
||||||
|
sourceType: 1,
|
||||||
|
keywordInclude: messages,
|
||||||
|
};
|
||||||
|
const trimmedPrompt = aiPrompt.trim();
|
||||||
|
if (aiRewriteEnabled && trimmedPrompt) {
|
||||||
|
params.aiPrompt = trimmedPrompt;
|
||||||
|
}
|
||||||
|
let hideLoading: ReturnType<typeof antdMessage.loading> | undefined;
|
||||||
|
try {
|
||||||
|
setSavingScriptGroup(true);
|
||||||
|
hideLoading = antdMessage.loading("正在保存话术组...", 0);
|
||||||
|
const response = await createContentLibrary(params);
|
||||||
|
hideLoading?.();
|
||||||
|
const responseId =
|
||||||
|
response?.id ?? response?.data?.id ?? response?.libraryId;
|
||||||
|
const newGroup: ScriptGroup = {
|
||||||
|
id:
|
||||||
|
responseId !== undefined
|
||||||
|
? String(responseId)
|
||||||
|
: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
name: groupName,
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
onSavedScriptGroupsChange([...savedScriptGroups, newGroup]);
|
||||||
|
onCurrentScriptMessagesChange([]);
|
||||||
|
onCurrentScriptNameChange("");
|
||||||
|
onMessageContentChange("");
|
||||||
|
antdMessage.success("已保存为话术组");
|
||||||
|
} catch (error) {
|
||||||
|
hideLoading?.();
|
||||||
|
console.error("保存话术组失败:", error);
|
||||||
|
antdMessage.error("保存失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
setSavingScriptGroup(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
aiPrompt,
|
||||||
|
aiRewriteEnabled,
|
||||||
|
currentScriptMessages,
|
||||||
|
currentScriptName,
|
||||||
|
onCurrentScriptMessagesChange,
|
||||||
|
onCurrentScriptNameChange,
|
||||||
|
onMessageContentChange,
|
||||||
|
onSavedScriptGroupsChange,
|
||||||
|
savedScriptGroups,
|
||||||
|
savingScriptGroup,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleAiRewrite = useCallback(async () => {
|
||||||
|
if (!aiRewriteEnabled) {
|
||||||
|
antdMessage.warning("请先开启AI智能话术改写");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmedPrompt = aiPrompt.trim();
|
||||||
|
const originalContent = messageContent;
|
||||||
|
const trimmedContent = originalContent.trim();
|
||||||
|
if (!trimmedPrompt) {
|
||||||
|
antdMessage.warning("请输入改写提示词");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!trimmedContent) {
|
||||||
|
antdMessage.warning("请输入需要改写的内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (aiRewriting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let hideLoading: ReturnType<typeof antdMessage.loading> | undefined;
|
||||||
|
try {
|
||||||
|
setAiRewriting(true);
|
||||||
|
hideLoading = antdMessage.loading("AI正在改写话术...", 0);
|
||||||
|
const response = await aiEditContent({
|
||||||
|
aiPrompt: trimmedPrompt,
|
||||||
|
content: originalContent,
|
||||||
|
});
|
||||||
|
hideLoading?.();
|
||||||
|
const normalizedResponse = response as {
|
||||||
|
content?: string;
|
||||||
|
contentAfter?: string;
|
||||||
|
contentFront?: string;
|
||||||
|
data?:
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
content?: string;
|
||||||
|
contentAfter?: string;
|
||||||
|
contentFront?: string;
|
||||||
|
};
|
||||||
|
result?: string;
|
||||||
|
};
|
||||||
|
const dataField = normalizedResponse?.data;
|
||||||
|
const dataContent =
|
||||||
|
typeof dataField === "string"
|
||||||
|
? dataField
|
||||||
|
: (dataField?.content ?? undefined);
|
||||||
|
const dataContentAfter =
|
||||||
|
typeof dataField === "string" ? undefined : dataField?.contentAfter;
|
||||||
|
const dataContentFront =
|
||||||
|
typeof dataField === "string" ? undefined : dataField?.contentFront;
|
||||||
|
|
||||||
|
const primaryAfter =
|
||||||
|
normalizedResponse?.contentAfter ?? dataContentAfter ?? undefined;
|
||||||
|
const primaryFront =
|
||||||
|
normalizedResponse?.contentFront ?? dataContentFront ?? undefined;
|
||||||
|
|
||||||
|
let rewrittenContent = "";
|
||||||
|
if (typeof response === "string") {
|
||||||
|
rewrittenContent = response;
|
||||||
|
} else if (primaryAfter) {
|
||||||
|
rewrittenContent = primaryFront
|
||||||
|
? `${primaryFront}\n${primaryAfter}`
|
||||||
|
: primaryAfter;
|
||||||
|
} else if (typeof normalizedResponse?.content === "string") {
|
||||||
|
rewrittenContent = normalizedResponse.content;
|
||||||
|
} else if (typeof dataContent === "string") {
|
||||||
|
rewrittenContent = dataContent;
|
||||||
|
} else if (typeof normalizedResponse?.result === "string") {
|
||||||
|
rewrittenContent = normalizedResponse.result;
|
||||||
|
} else if (primaryFront) {
|
||||||
|
rewrittenContent = primaryFront;
|
||||||
|
}
|
||||||
|
if (!rewrittenContent || typeof rewrittenContent !== "string") {
|
||||||
|
antdMessage.error("AI改写失败,请稍后重试");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onMessageContentChange(rewrittenContent.trim());
|
||||||
|
antdMessage.success("AI改写完成,请确认内容");
|
||||||
|
} catch (error) {
|
||||||
|
hideLoading?.();
|
||||||
|
console.error("AI改写失败:", error);
|
||||||
|
antdMessage.error("AI改写失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
setAiRewriting(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
aiPrompt,
|
||||||
|
aiRewriting,
|
||||||
|
aiRewriteEnabled,
|
||||||
|
messageContent,
|
||||||
|
onMessageContentChange,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleApplyGroup = useCallback(
|
||||||
|
(group: ScriptGroup) => {
|
||||||
|
onCurrentScriptMessagesChange(group.messages);
|
||||||
|
onCurrentScriptNameChange(group.name);
|
||||||
|
onMessageContentChange("");
|
||||||
|
antdMessage.success("已加载话术组");
|
||||||
|
},
|
||||||
|
[
|
||||||
|
onCurrentScriptMessagesChange,
|
||||||
|
onCurrentScriptNameChange,
|
||||||
|
onMessageContentChange,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteGroup = useCallback(
|
||||||
|
async (groupId: string) => {
|
||||||
|
if (deletingGroupIds.includes(groupId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const numericGroupId = Number(groupId);
|
||||||
|
if (Number.isNaN(numericGroupId)) {
|
||||||
|
antdMessage.error("无法删除:缺少有效的内容库ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let hideLoading: ReturnType<typeof antdMessage.loading> | undefined;
|
||||||
|
try {
|
||||||
|
setDeletingGroupIds(prev => [...prev, groupId]);
|
||||||
|
hideLoading = antdMessage.loading("正在删除话术组...", 0);
|
||||||
|
await deleteContentLibrary({ id: numericGroupId });
|
||||||
|
hideLoading?.();
|
||||||
|
const nextGroups = savedScriptGroups.filter(
|
||||||
|
group => group.id !== groupId,
|
||||||
|
);
|
||||||
|
onSavedScriptGroupsChange(nextGroups);
|
||||||
|
if (selectedScriptGroupIds.includes(groupId)) {
|
||||||
|
const nextSelected = selectedScriptGroupIds.filter(
|
||||||
|
id => id !== groupId,
|
||||||
|
);
|
||||||
|
onSelectedScriptGroupIdsChange(nextSelected);
|
||||||
|
}
|
||||||
|
antdMessage.success("已删除话术组");
|
||||||
|
} catch (error) {
|
||||||
|
hideLoading?.();
|
||||||
|
console.error("删除话术组失败:", error);
|
||||||
|
antdMessage.error("删除失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
setDeletingGroupIds(prev =>
|
||||||
|
prev.filter(deletingId => deletingId !== groupId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
deletingGroupIds,
|
||||||
|
onSavedScriptGroupsChange,
|
||||||
|
onSelectedScriptGroupIdsChange,
|
||||||
|
savedScriptGroups,
|
||||||
|
selectedScriptGroupIds,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectChange = useCallback(
|
||||||
|
(groupId: string) => (event: CheckboxChangeEvent) => {
|
||||||
|
const checked = event.target.checked;
|
||||||
|
if (checked) {
|
||||||
|
if (!selectedScriptGroupIds.includes(groupId)) {
|
||||||
|
onSelectedScriptGroupIdsChange([...selectedScriptGroupIds, groupId]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onSelectedScriptGroupIdsChange(
|
||||||
|
selectedScriptGroupIds.filter(id => id !== groupId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSelectedScriptGroupIdsChange, selectedScriptGroupIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.stepContent}>
|
||||||
|
<div className={styles.step3Content}>
|
||||||
|
<div className={styles.leftColumn}>
|
||||||
|
<div className={styles.previewHeader}>
|
||||||
|
<div className={styles.previewHeaderTitle}>模拟推送内容</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleSaveScriptGroup}
|
||||||
|
disabled={currentScriptMessages.length === 0 || savingScriptGroup}
|
||||||
|
loading={savingScriptGroup}
|
||||||
|
>
|
||||||
|
保存为话术组
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.messagePreview}>
|
||||||
|
<div className={styles.messageBubble}>
|
||||||
|
<div className={styles.currentEditingLabel}>当前编辑话术</div>
|
||||||
|
{currentScriptMessages.length === 0 ? (
|
||||||
|
<div className={styles.messagePlaceholder}>
|
||||||
|
开始添加消息内容...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.messageList}>
|
||||||
|
{currentScriptMessages.map((msg, index) => (
|
||||||
|
<div className={styles.messageItem} key={index}>
|
||||||
|
<div className={styles.messageText}>{msg}</div>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => handleRemoveMessage(index)}
|
||||||
|
className={styles.messageAction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.scriptNameInput}>
|
||||||
|
<Input
|
||||||
|
placeholder="话术组名称(可选)"
|
||||||
|
value={currentScriptName}
|
||||||
|
onChange={event =>
|
||||||
|
onCurrentScriptNameChange(event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.savedScriptGroups}>
|
||||||
|
{/* 内容库选择组件 */}
|
||||||
|
<ContentLibrarySelector
|
||||||
|
selectedContentLibraries={selectedContentLibraries}
|
||||||
|
onSelectedContentLibrariesChange={
|
||||||
|
onSelectedContentLibrariesChange
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className={styles.scriptGroupHeaderRow}>
|
||||||
|
<div className={styles.scriptGroupTitle}>
|
||||||
|
已保存话术组 ({savedScriptGroups.length})
|
||||||
|
</div>
|
||||||
|
<div className={styles.scriptGroupHint}>勾选后将随机均分推送</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.scriptGroupList}>
|
||||||
|
{savedScriptGroups.length === 0 ? (
|
||||||
|
<div className={styles.emptyGroup}>暂无已保存话术组</div>
|
||||||
|
) : (
|
||||||
|
savedScriptGroups.map((group, index) => (
|
||||||
|
<div className={styles.scriptGroupItem} key={group.id}>
|
||||||
|
<div className={styles.scriptGroupHeader}>
|
||||||
|
<div className={styles.scriptGroupLeft}>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedScriptGroupIds.includes(group.id)}
|
||||||
|
onChange={handleSelectChange(group.id)}
|
||||||
|
/>
|
||||||
|
<div className={styles.scriptGroupInfo}>
|
||||||
|
<div className={styles.scriptGroupName}>
|
||||||
|
{group.name || `话术组${index + 1}`}
|
||||||
|
</div>
|
||||||
|
<div className={styles.messageCount}>
|
||||||
|
{group.messages.length}条消息
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.scriptGroupActions}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
className={styles.actionButton}
|
||||||
|
onClick={() => handleApplyGroup(group)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
className={styles.actionButton}
|
||||||
|
onClick={() => handleDeleteGroup(group.id)}
|
||||||
|
loading={deletingGroupIds.includes(group.id)}
|
||||||
|
disabled={deletingGroupIds.includes(group.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.scriptGroupContent}>
|
||||||
|
{group.messages[0]}
|
||||||
|
{group.messages.length > 1 && " ..."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.messageInputArea}>
|
||||||
|
<InputMessage
|
||||||
|
defaultValue={messageContent}
|
||||||
|
onContentChange={onMessageContentChange}
|
||||||
|
onSend={value => handleAddMessage(value)}
|
||||||
|
clearOnSend
|
||||||
|
placeholder="请输入内容"
|
||||||
|
hint={`按住CTRL+ENTER换行,已配置${savedScriptGroups.length}个话术组,已选择${selectedScriptGroupIds.length}个进行推送,已选${selectedContentLibraries.length}个内容库`}
|
||||||
|
/>
|
||||||
|
<div className={styles.aiRewriteSection}>
|
||||||
|
<div className={styles.aiRewriteToggle}>
|
||||||
|
<Switch
|
||||||
|
checked={aiRewriteEnabled}
|
||||||
|
onChange={onAiRewriteToggle}
|
||||||
|
/>
|
||||||
|
<div className={styles.aiRewriteLabel}>AI智能话术改写</div>
|
||||||
|
<div>
|
||||||
|
{aiRewriteEnabled && (
|
||||||
|
<Input
|
||||||
|
placeholder="输入改写提示词"
|
||||||
|
value={aiPrompt}
|
||||||
|
onChange={event => onAiPromptChange(event.target.value)}
|
||||||
|
className={styles.aiRewriteInput}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.aiRewriteActions}>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={handleAiRewrite}
|
||||||
|
disabled={!aiRewriteEnabled}
|
||||||
|
loading={aiRewriting}
|
||||||
|
className={styles.aiRewriteButton}
|
||||||
|
>
|
||||||
|
AI改写
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => handleAddMessage(undefined, true)}
|
||||||
|
>
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.rightColumn}>
|
||||||
|
<div className={styles.settingsPanel}>
|
||||||
|
<div className={styles.settingsTitle}>相关设置</div>
|
||||||
|
<div className={styles.settingItem}>
|
||||||
|
<div className={styles.settingLabel}>好友间间隔</div>
|
||||||
|
<div className={styles.settingControl}>
|
||||||
|
<span>间隔时间(秒)</span>
|
||||||
|
<Slider
|
||||||
|
range
|
||||||
|
min={1}
|
||||||
|
max={60}
|
||||||
|
value={friendInterval}
|
||||||
|
onChange={value =>
|
||||||
|
onFriendIntervalChange(value as [number, number])
|
||||||
|
}
|
||||||
|
style={{ flex: 1, margin: "0 16px" }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{friendInterval[0]} - {friendInterval[1]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingItem}>
|
||||||
|
<div className={styles.settingLabel}>消息间间隔</div>
|
||||||
|
<div className={styles.settingControl}>
|
||||||
|
<span>间隔时间(秒)</span>
|
||||||
|
<Slider
|
||||||
|
range
|
||||||
|
min={1}
|
||||||
|
max={60}
|
||||||
|
value={messageInterval}
|
||||||
|
onChange={value =>
|
||||||
|
onMessageIntervalChange(value as [number, number])
|
||||||
|
}
|
||||||
|
style={{ flex: 1, margin: "0 16px" }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{messageInterval[0]} - {messageInterval[1]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tagSection}>
|
||||||
|
<div className={styles.settingLabel}>完成打标签</div>
|
||||||
|
<Select
|
||||||
|
value={selectedTag}
|
||||||
|
onChange={onSelectedTagChange}
|
||||||
|
placeholder="选择标签"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<Select.Option value="potential">潜在客户</Select.Option>
|
||||||
|
<Select.Option value="customer">客户</Select.Option>
|
||||||
|
<Select.Option value="partner">合作伙伴</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.pushPreview}>
|
||||||
|
<div className={styles.previewTitle}>推送预览</div>
|
||||||
|
<ul>
|
||||||
|
<li>推送账号: {selectedAccounts.length}个</li>
|
||||||
|
<li>
|
||||||
|
推送{targetLabel}: {selectedContacts.length}个
|
||||||
|
</li>
|
||||||
|
<li>话术组数: {savedScriptGroups.length}个</li>
|
||||||
|
<li>随机推送: 否</li>
|
||||||
|
<li>预计耗时: ~1分钟</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StepSendMessage;
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
.container {
|
.container {
|
||||||
padding: 24px;
|
padding: 15px;
|
||||||
background: #f5f5f5;
|
|
||||||
min-height: calc(100vh - 64px);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -351,228 +349,85 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.step3Content {
|
.filterModal {
|
||||||
display: flex;
|
:global(.ant-modal-body) {
|
||||||
gap: 24px;
|
padding-bottom: 12px;
|
||||||
align-items: flex-start;
|
}
|
||||||
|
|
||||||
// 左侧栏
|
.filterRow {
|
||||||
.leftColumn {
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterLabel {
|
||||||
|
width: 64px;
|
||||||
|
text-align: right;
|
||||||
|
line-height: 32px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f1f1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterControls {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 16px;
|
||||||
gap: 20px;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 右侧栏
|
.filterControl {
|
||||||
.rightColumn {
|
display: flex;
|
||||||
width: 400px;
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
:global(.ant-select) {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterSingleControl {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
:global(.ant-input),
|
||||||
|
:global(.ant-select) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.extendFields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messagePreview {
|
.extendFieldItem {
|
||||||
border: 2px dashed #52c41a;
|
display: flex;
|
||||||
border-radius: 8px;
|
align-items: center;
|
||||||
padding: 20px;
|
gap: 12px;
|
||||||
background: #f6ffed;
|
|
||||||
|
|
||||||
.previewTitle {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #52c41a;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageBubble {
|
|
||||||
min-height: 60px;
|
|
||||||
padding: 12px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
|
|
||||||
.currentEditingLabel {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageText {
|
|
||||||
color: #333;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已保存话术组
|
.extendFieldLabel {
|
||||||
.savedScriptGroups {
|
width: 80px;
|
||||||
.scriptGroupTitle {
|
text-align: right;
|
||||||
font-size: 14px;
|
color: #595959;
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scriptGroupItem {
|
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
background: #fff;
|
|
||||||
|
|
||||||
.scriptGroupHeader {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.scriptGroupLeft {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
:global(.ant-radio) {
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scriptGroupName {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageCount {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scriptGroupActions {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
|
|
||||||
.actionButton {
|
|
||||||
padding: 4px;
|
|
||||||
color: #666;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scriptGroupContent {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.messageInputArea {
|
.extendFieldItem :global(.ant-input) {
|
||||||
.messageInput {
|
flex: 1;
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachmentButtons {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aiRewriteSection {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageHint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settingsPanel {
|
.excludeButton {
|
||||||
border: 1px solid #e8e8e8;
|
background-color: #faad14;
|
||||||
border-radius: 8px;
|
border-color: #faad14;
|
||||||
padding: 20px;
|
color: #fff;
|
||||||
background: #fafafa;
|
|
||||||
|
|
||||||
.settingsTitle {
|
&:hover,
|
||||||
font-size: 14px;
|
&:focus {
|
||||||
font-weight: 500;
|
background-color: #d48806;
|
||||||
color: #1a1a1a;
|
border-color: #d48806;
|
||||||
margin-bottom: 16px;
|
color: #fff;
|
||||||
}
|
|
||||||
|
|
||||||
.settingItem {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingLabel {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingControl {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagSection {
|
|
||||||
.settingLabel {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pushPreview {
|
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f0f7ff;
|
|
||||||
|
|
||||||
.previewTitle {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
li {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -585,6 +440,7 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
margin: 20px;
|
||||||
|
|
||||||
.footerLeft {
|
.footerLeft {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -612,12 +468,6 @@
|
|||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.step3Content {
|
|
||||||
.rightColumn {
|
|
||||||
width: 350px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,18 +502,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.step3Content {
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.leftColumn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rightColumn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -697,23 +535,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchBar {
|
// 账号卡片列表
|
||||||
margin-bottom: 24px;
|
.accountCards {
|
||||||
|
|
||||||
:global(.ant-input-affix-wrapper) {
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 未选择的账号列表
|
|
||||||
.accountList {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
@@ -724,7 +553,8 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accountItem {
|
.accountCard {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -737,82 +567,61 @@
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #52c41a;
|
border-color: #52c41a;
|
||||||
background: #fafafa;
|
box-shadow: 0 2px 8px rgba(82, 196, 26, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border: 2px solid #52c41a;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accountInfo {
|
.accountInfo {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
.accountName {
|
.accountName {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accountId {
|
.accountStatus {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 已选择区域
|
.statusDot {
|
||||||
.selectedSection {
|
width: 6px;
|
||||||
.selectedHeader {
|
height: 6px;
|
||||||
display: flex;
|
border-radius: 50%;
|
||||||
justify-content: space-between;
|
display: inline-block;
|
||||||
align-items: center;
|
|
||||||
padding: 12px 0;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
|
|
||||||
span {
|
&.online {
|
||||||
font-size: 14px;
|
background: #52c41a;
|
||||||
color: #666;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.clearButton {
|
&.offline {
|
||||||
padding: 0;
|
background: #999;
|
||||||
font-size: 14px;
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.selectedCards {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
.selectedCard {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: 2px solid #52c41a;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f6ffed;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.accountInfo {
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
.accountName {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.accountId {
|
.statusText {
|
||||||
font-size: 12px;
|
color: #666;
|
||||||
color: #999;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.checkIcon {
|
.checkmark {
|
||||||
font-size: 20px;
|
position: absolute;
|
||||||
color: #52c41a;
|
top: 8px;
|
||||||
}
|
right: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #52c41a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
export type PushType =
|
||||||
|
| "friend-message"
|
||||||
|
| "group-message"
|
||||||
|
| "group-announcement";
|
||||||
|
|
||||||
|
export interface ContactItem {
|
||||||
|
id: number;
|
||||||
|
nickname: string;
|
||||||
|
avatar?: string;
|
||||||
|
conRemark?: string;
|
||||||
|
wechatId?: string;
|
||||||
|
gender?: number;
|
||||||
|
region?: string;
|
||||||
|
type?: "friend" | "group";
|
||||||
|
labels?: string[];
|
||||||
|
groupId?: number | string;
|
||||||
|
groupName?: string;
|
||||||
|
groupLabel?: string;
|
||||||
|
city?: string;
|
||||||
|
extendFields?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
messages: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
帮我对接数据,以下是传参实例,三种模式都是同一界面的。
|
||||||
|
|
||||||
|
群发助手传参实例
|
||||||
|
{
|
||||||
|
"name": "群群发-新品宣传", // 任务名称
|
||||||
|
"type": 3, // 工作台类型:3=群消息推送
|
||||||
|
"autoStart": 1, // 保存后自动启动
|
||||||
|
"status": 1, // 是否启用
|
||||||
|
"pushType": 0, // 推送方式:0=定时,1=立即
|
||||||
|
"targetType": 1, // 目标类型:1=群推送
|
||||||
|
"groupPushSubType": 1, // 群推送子类型:1=群群发,2=群公告
|
||||||
|
"startTime": "09:00", // 推送起始时间
|
||||||
|
"endTime": "20:00", // 推送结束时间
|
||||||
|
"maxPerDay": 200, // 每日最大推送群数
|
||||||
|
"pushOrder": 1, // 推送顺序:1=最早优先,2=最新优先
|
||||||
|
"wechatGroups": [102, 205, 318], // 选择的微信群 ID 列表
|
||||||
|
"contentGroups": [11, 12], // 关联内容库 ID 列表
|
||||||
|
"friendIntervalMin": 10, // 群间最小间隔(秒)
|
||||||
|
"friendIntervalMax": 25, // 群间最大间隔(秒)
|
||||||
|
"messageIntervalMin": 2, // 同一群消息间最小间隔(秒)
|
||||||
|
"messageIntervalMax": 6, // 同一群消息间最大间隔(秒)
|
||||||
|
"isRandomTemplate": 1, // 是否随机选择话术模板
|
||||||
|
"postPushTags": [301, 302], // 推送完成后打的标签
|
||||||
|
ownerWechatIds:[123123,1231231] //客服id
|
||||||
|
}
|
||||||
|
|
||||||
|
//群公告传参实例
|
||||||
|
{
|
||||||
|
"name": "群公告-双11活动", // 任务名称
|
||||||
|
"type": 3, // 群消息推送
|
||||||
|
"autoStart": 0, // 不自动启动
|
||||||
|
"status": 1, // 启用
|
||||||
|
"pushType": 1, // 立即推送
|
||||||
|
"targetType": 1, // 群推送
|
||||||
|
"groupPushSubType": 2, // 群公告
|
||||||
|
"startTime": "08:30", // 开始时间
|
||||||
|
"endTime": "18:30", // 结束时间
|
||||||
|
"maxPerDay": 80, // 每日最大公告数
|
||||||
|
"pushOrder": 2, // 最新优先
|
||||||
|
"wechatGroups": [5021, 5026], // 公告目标群
|
||||||
|
"announcementContent": "…", // 公告正文
|
||||||
|
"enableAiRewrite": 1, // 启用 AI 改写
|
||||||
|
"aiRewritePrompt": "保持活泼口吻…", // AI 改写提示词
|
||||||
|
"contentGroups": [21], // 关联内容库
|
||||||
|
"friendIntervalMin": 15, // 群间最小间隔
|
||||||
|
"friendIntervalMax": 30, // 群间最大间隔
|
||||||
|
"messageIntervalMin": 3, // 消息间最小间隔
|
||||||
|
"messageIntervalMax": 9, // 消息间最大间隔
|
||||||
|
"isRandomTemplate": 0, // 不随机模板
|
||||||
|
"postPushTags": [], // 推送后标签
|
||||||
|
ownerWechatIds:[123123,1231231] //客服id
|
||||||
|
}
|
||||||
|
|
||||||
|
//好友传参实例
|
||||||
|
{
|
||||||
|
"name": "好友私聊-新客转化", // 任务名称
|
||||||
|
"type": 3, // 群消息推送
|
||||||
|
"autoStart": 1, // 自动启动
|
||||||
|
"status": 1, // 启用
|
||||||
|
"pushType": 0, // 定时推送
|
||||||
|
"targetType": 2, // 目标类型:2=好友推送
|
||||||
|
"groupPushSubType": 1, // 固定为群群发(好友推送不支持公告)
|
||||||
|
"startTime": "10:00", // 开始时间
|
||||||
|
"endTime": "22:00", // 结束时间
|
||||||
|
"maxPerDay": 150, // 每日最大推送好友数
|
||||||
|
"pushOrder": 1, // 最早优先
|
||||||
|
"wechatFriends": ["12312"], // 指定好友列表(可为空数组)
|
||||||
|
"deviceGroups": [9001, 9002], // 必选:推送设备分组 ID
|
||||||
|
"contentGroups": [41, 42], // 话术内容库
|
||||||
|
"friendIntervalMin": 12, // 好友间最小间隔
|
||||||
|
"friendIntervalMax": 28, // 好友间最大间隔
|
||||||
|
"messageIntervalMin": 4, // 消息间最小间隔
|
||||||
|
"messageIntervalMax": 10, // 消息间最大间隔
|
||||||
|
"isRandomTemplate": 1, // 随机话术
|
||||||
|
"postPushTags": [501], // 推送后标签
|
||||||
|
ownerWechatIds:[123123,1231231] //客服id
|
||||||
|
}
|
||||||
|
|
||||||
|
请求接口是 queryWorkbenchCreate
|
||||||
@@ -12,7 +12,10 @@ import {
|
|||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import styles from "./index.module.scss";
|
import styles from "./index.module.scss";
|
||||||
|
|
||||||
export type PushType = "friend-message" | "group-message" | "group-announcement";
|
export type PushType =
|
||||||
|
| "friend-message"
|
||||||
|
| "group-message"
|
||||||
|
| "group-announcement";
|
||||||
|
|
||||||
const MessagePushAssistant: React.FC = () => {
|
const MessagePushAssistant: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -26,7 +29,9 @@ const MessagePushAssistant: React.FC = () => {
|
|||||||
icon: <UserOutlined />,
|
icon: <UserOutlined />,
|
||||||
color: "#1890ff",
|
color: "#1890ff",
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
navigate("/pc/powerCenter/message-push-assistant/create-push-task/friend-message");
|
navigate(
|
||||||
|
"/pc/powerCenter/message-push-assistant/create-push-task/friend-message",
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -36,17 +41,21 @@ const MessagePushAssistant: React.FC = () => {
|
|||||||
icon: <MessageOutlined />,
|
icon: <MessageOutlined />,
|
||||||
color: "#52c41a",
|
color: "#52c41a",
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
navigate("/pc/powerCenter/message-push-assistant/create-push-task/group-message");
|
navigate(
|
||||||
|
"/pc/powerCenter/message-push-assistant/create-push-task/group-message",
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "group-announcement",
|
id: "group-announcement",
|
||||||
title: "群公告推送",
|
title: "群公告推送",
|
||||||
description: "向选定的微信群发布群公告",
|
description: "向选定的微信群批量发布群公告",
|
||||||
icon: <SoundOutlined />,
|
icon: <SoundOutlined />,
|
||||||
color: "#722ed1",
|
color: "#722ed1",
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
navigate("/pc/powerCenter/message-push-assistant/create-push-task/group-announcement");
|
navigate(
|
||||||
|
"/pc/powerCenter/message-push-assistant/create-push-task/group-announcement",
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -81,7 +90,7 @@ const MessagePushAssistant: React.FC = () => {
|
|||||||
<div style={{ padding: "20px" }}>
|
<div style={{ padding: "20px" }}>
|
||||||
<PowerNavigation
|
<PowerNavigation
|
||||||
title="消息推送助手"
|
title="消息推送助手"
|
||||||
subtitle="智能批量推送,AI智能话术改写"
|
subtitle="智能批量推送,AI智能话术改写"
|
||||||
showBackButton={true}
|
showBackButton={true}
|
||||||
backButtonText="返回"
|
backButtonText="返回"
|
||||||
onBackClick={() => navigate("/pc/powerCenter")}
|
onBackClick={() => navigate("/pc/powerCenter")}
|
||||||
|
|||||||
@@ -25,41 +25,13 @@ export interface GetPushHistoryResponse {
|
|||||||
/**
|
/**
|
||||||
* 获取推送历史列表
|
* 获取推送历史列表
|
||||||
*/
|
*/
|
||||||
export const getPushHistory = async (
|
export interface GetGroupPushHistoryParams {
|
||||||
params: GetPushHistoryParams
|
keyword?: string;
|
||||||
): Promise<GetPushHistoryResponse> => {
|
limit: string;
|
||||||
try {
|
page: string;
|
||||||
// TODO: 替换为实际的API接口地址
|
workbenchId?: string;
|
||||||
const response = await request.get("/api/push-history", { params });
|
[property: string]: any;
|
||||||
|
}
|
||||||
// 如果接口返回的数据格式不同,需要在这里进行转换
|
export const getPushHistory = async (params: GetGroupPushHistoryParams) => {
|
||||||
if (response.data && response.data.success !== undefined) {
|
return request("/v1/workbench/group-push-history", params, "GET");
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 兼容不同的响应格式
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
list: response.data?.list || response.data?.data || [],
|
|
||||||
total: response.data?.total || 0,
|
|
||||||
page: response.data?.page || params.page || 1,
|
|
||||||
pageSize: response.data?.pageSize || params.pageSize || 10,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("获取推送历史失败:", error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: error?.message || "获取推送历史失败",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,18 +76,31 @@ const PushHistory: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await getPushHistory(params);
|
const response = await getPushHistory(params);
|
||||||
|
const result = response?.data ?? response ?? {};
|
||||||
|
|
||||||
if (response.success) {
|
if (!result || typeof result !== "object") {
|
||||||
setDataSource(response.data?.list || []);
|
message.error("获取推送历史失败");
|
||||||
setPagination(prev => ({
|
|
||||||
...prev,
|
|
||||||
current: response.data?.page || page,
|
|
||||||
total: response.data?.total || 0,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
message.error(response.message || "获取推送历史失败");
|
|
||||||
setDataSource([]);
|
setDataSource([]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toNumber = (value: unknown, fallback: number) => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const list = Array.isArray(result.list) ? result.list : [];
|
||||||
|
const total = toNumber(result.total, pagination.total);
|
||||||
|
const currentPage = toNumber(result.page, page);
|
||||||
|
const pageSize = toNumber(result.pageSize, pagination.pageSize);
|
||||||
|
|
||||||
|
setDataSource(list);
|
||||||
|
setPagination(prev => ({
|
||||||
|
...prev,
|
||||||
|
current: currentPage,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取推送历史失败:", error);
|
console.error("获取推送历史失败:", error);
|
||||||
message.error("获取推送历史失败,请稍后重试");
|
message.error("获取推送历史失败,请稍后重试");
|
||||||
@@ -211,9 +224,7 @@ const PushHistory: React.FC = () => {
|
|||||||
dataIndex: "pushContent",
|
dataIndex: "pushContent",
|
||||||
key: "pushContent",
|
key: "pushContent",
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (text: string) => (
|
render: (text: string) => <span style={{ color: "#333" }}>{text}</span>,
|
||||||
<span style={{ color: "#333" }}>{text}</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "目标数量",
|
title: "目标数量",
|
||||||
@@ -287,7 +298,9 @@ const PushHistory: React.FC = () => {
|
|||||||
subtitle="查看所有推送任务的历史记录"
|
subtitle="查看所有推送任务的历史记录"
|
||||||
showBackButton={true}
|
showBackButton={true}
|
||||||
backButtonText="返回"
|
backButtonText="返回"
|
||||||
onBackClick={() => navigate("/pc/powerCenter/message-push-assistant")}
|
onBackClick={() =>
|
||||||
|
navigate("/pc/powerCenter/message-push-assistant")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -369,11 +382,3 @@ const PushHistory: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default PushHistory;
|
export default PushHistory;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function WechatFriendAllot(params: {
|
|||||||
|
|
||||||
//获取可转移客服列表
|
//获取可转移客服列表
|
||||||
export function getTransferableAgentList() {
|
export function getTransferableAgentList() {
|
||||||
return request2("/api/account/myDepartmentAccountsForTransfer", {}, "GET");
|
return request("/v1/kefu/accounts/list", {}, "GET");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 微信好友列表
|
// 微信好友列表
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
import { useCurrentContact } from "@/store/module/weChat/weChat";
|
import { useCurrentContact } from "@/store/module/weChat/weChat";
|
||||||
import { ContactManager } from "@/utils/dbAction/contact";
|
import { ContactManager } from "@/utils/dbAction/contact";
|
||||||
import { MessageManager } from "@/utils/dbAction/message";
|
import { MessageManager } from "@/utils/dbAction/message";
|
||||||
import { triggerRefresh } from "@/store/module/weChat/message";
|
|
||||||
import { useUserStore } from "@/store/module/user";
|
import { useUserStore } from "@/store/module/user";
|
||||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@@ -49,7 +48,7 @@ const ToContract: React.FC<ToContractProps> = ({
|
|||||||
const openModal = () => {
|
const openModal = () => {
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
getTransferableAgentList().then(data => {
|
getTransferableAgentList().then(data => {
|
||||||
setCustomerServiceList(data);
|
setCustomerServiceList(data.list);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -110,10 +109,7 @@ const ToContract: React.FC<ToContractProps> = ({
|
|||||||
await ContactManager.deleteContact(currentContact.id);
|
await ContactManager.deleteContact(currentContact.id);
|
||||||
console.log("✅ 已从联系人数据库删除");
|
console.log("✅ 已从联系人数据库删除");
|
||||||
|
|
||||||
// 3. 触发会话列表刷新
|
// 3. 清空当前选中的联系人(关闭聊天窗口)
|
||||||
triggerRefresh();
|
|
||||||
|
|
||||||
// 4. 清空当前选中的联系人(关闭聊天窗口)
|
|
||||||
clearCurrentContact();
|
clearCurrentContact();
|
||||||
|
|
||||||
message.success("转接成功,已清理本地数据");
|
message.success("转接成功,已清理本地数据");
|
||||||
@@ -167,10 +163,7 @@ const ToContract: React.FC<ToContractProps> = ({
|
|||||||
await ContactManager.deleteContact(currentContact.id);
|
await ContactManager.deleteContact(currentContact.id);
|
||||||
console.log("✅ 已从联系人数据库删除");
|
console.log("✅ 已从联系人数据库删除");
|
||||||
|
|
||||||
// 3. 触发会话列表刷新
|
// 3. 清空当前选中的联系人(关闭聊天窗口)
|
||||||
triggerRefresh();
|
|
||||||
|
|
||||||
// 4. 清空当前选中的联系人(关闭聊天窗口)
|
|
||||||
clearCurrentContact();
|
clearCurrentContact();
|
||||||
|
|
||||||
message.success("转回成功,已清理本地数据");
|
message.success("转回成功,已清理本地数据");
|
||||||
|
|||||||
@@ -167,27 +167,11 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
|||||||
// AI 消息处理
|
// AI 消息处理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (quoteMessageContent) {
|
if (quoteMessageContent) {
|
||||||
console.log(
|
|
||||||
"🤖 AI消息到达 - aiQuoteMessageContent:",
|
|
||||||
aiQuoteMessageContent,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 检查:如果用户输入框已有内容(且不是之前的AI内容),不覆盖
|
|
||||||
if (inputValue && inputValue !== quoteMessageContent) {
|
|
||||||
console.log("⚠️ 用户正在输入,不覆盖输入内容");
|
|
||||||
updateQuoteMessageContent(""); // 清空AI回复
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAiAssist) {
|
if (isAiAssist) {
|
||||||
// AI辅助模式:填充到输入框,等待人工确认
|
|
||||||
console.log("✨ AI辅助模式:填充消息到输入框");
|
|
||||||
setInputValue(quoteMessageContent);
|
setInputValue(quoteMessageContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAiTakeover) {
|
if (isAiTakeover) {
|
||||||
// AI接管模式:直接发送消息(传入内容,避免 state 闭包问题)
|
|
||||||
console.log("🚀 AI接管模式:自动发送消息");
|
|
||||||
handleSend(quoteMessageContent);
|
handleSend(quoteMessageContent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,6 +273,12 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fileActionDisabled {
|
||||||
|
color: #999;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 响应式设计
|
// 响应式设计
|
||||||
@@ -312,4 +318,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,169 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { parseWeappMsgStr } from "@/utils/common";
|
import { parseWeappMsgStr } from "@/utils/common";
|
||||||
|
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||||
|
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||||
|
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||||
import styles from "./SmallProgramMessage.module.scss";
|
import styles from "./SmallProgramMessage.module.scss";
|
||||||
|
|
||||||
|
const FILE_MESSAGE_TYPE = "file";
|
||||||
|
|
||||||
|
interface FileMessageData {
|
||||||
|
type: string;
|
||||||
|
title?: string;
|
||||||
|
fileName?: string;
|
||||||
|
filename?: string;
|
||||||
|
url?: string;
|
||||||
|
isDownloading?: boolean;
|
||||||
|
fileext?: string;
|
||||||
|
size?: number | string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isJsonLike = (value: string) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.startsWith("{") && trimmed.endsWith("}");
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractFileInfoFromXml = (source: string): FileMessageData | null => {
|
||||||
|
if (typeof source !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = source.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof DOMParser !== "undefined") {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(trimmed, "text/xml");
|
||||||
|
if (doc.getElementsByTagName("parsererror").length === 0) {
|
||||||
|
const titleNode = doc.getElementsByTagName("title")[0];
|
||||||
|
const fileExtNode = doc.getElementsByTagName("fileext")[0];
|
||||||
|
const sizeNode =
|
||||||
|
doc.getElementsByTagName("totallen")[0] ||
|
||||||
|
doc.getElementsByTagName("filesize")[0];
|
||||||
|
|
||||||
|
const result: FileMessageData = { type: FILE_MESSAGE_TYPE };
|
||||||
|
const titleText = titleNode?.textContent?.trim();
|
||||||
|
if (titleText) {
|
||||||
|
result.title = titleText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileExtText = fileExtNode?.textContent?.trim();
|
||||||
|
if (fileExtText) {
|
||||||
|
result.fileext = fileExtText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeText = sizeNode?.textContent?.trim();
|
||||||
|
if (sizeText) {
|
||||||
|
const sizeNumber = Number(sizeText);
|
||||||
|
result.size = Number.isNaN(sizeNumber) ? sizeText : sizeNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("extractFileInfoFromXml parse failed:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const regexTitle =
|
||||||
|
trimmed.match(/<title><!\[CDATA\[(.*?)\]\]><\/title>/i) ||
|
||||||
|
trimmed.match(/<title>([^<]+)<\/title>/i);
|
||||||
|
const regexExt =
|
||||||
|
trimmed.match(/<fileext><!\[CDATA\[(.*?)\]\]><\/fileext>/i) ||
|
||||||
|
trimmed.match(/<fileext>([^<]+)<\/fileext>/i);
|
||||||
|
const regexSize =
|
||||||
|
trimmed.match(/<totallen>([^<]+)<\/totallen>/i) ||
|
||||||
|
trimmed.match(/<filesize>([^<]+)<\/filesize>/i);
|
||||||
|
|
||||||
|
if (!regexTitle && !regexExt && !regexSize) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback: FileMessageData = { type: FILE_MESSAGE_TYPE };
|
||||||
|
if (regexTitle?.[1]) {
|
||||||
|
fallback.title = regexTitle[1].trim();
|
||||||
|
}
|
||||||
|
if (regexExt?.[1]) {
|
||||||
|
fallback.fileext = regexExt[1].trim();
|
||||||
|
}
|
||||||
|
if (regexSize?.[1]) {
|
||||||
|
const sizeNumber = Number(regexSize[1]);
|
||||||
|
fallback.size = Number.isNaN(sizeNumber) ? regexSize[1].trim() : sizeNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveFileMessageData = (
|
||||||
|
messageData: any,
|
||||||
|
msg: ChatRecord,
|
||||||
|
rawContent: string,
|
||||||
|
): FileMessageData | null => {
|
||||||
|
const meta =
|
||||||
|
msg?.fileDownloadMeta && typeof msg.fileDownloadMeta === "object"
|
||||||
|
? { ...(msg.fileDownloadMeta as Record<string, any>) }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (messageData && typeof messageData === "object") {
|
||||||
|
if (messageData.type === FILE_MESSAGE_TYPE) {
|
||||||
|
return {
|
||||||
|
type: FILE_MESSAGE_TYPE,
|
||||||
|
...messageData,
|
||||||
|
...(meta || {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof messageData.contentXml === "string") {
|
||||||
|
const xmlData = extractFileInfoFromXml(messageData.contentXml);
|
||||||
|
if (xmlData || meta) {
|
||||||
|
return {
|
||||||
|
...(xmlData || {}),
|
||||||
|
...(meta || {}),
|
||||||
|
type: FILE_MESSAGE_TYPE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawContent === "string") {
|
||||||
|
const xmlData = extractFileInfoFromXml(rawContent);
|
||||||
|
if (xmlData || meta) {
|
||||||
|
return {
|
||||||
|
...(xmlData || {}),
|
||||||
|
...(meta || {}),
|
||||||
|
type: FILE_MESSAGE_TYPE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
return {
|
||||||
|
type: FILE_MESSAGE_TYPE,
|
||||||
|
...meta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
interface SmallProgramMessageProps {
|
interface SmallProgramMessageProps {
|
||||||
content: string;
|
content: string;
|
||||||
|
msg: ChatRecord;
|
||||||
|
contract: ContractData | weChatGroup;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
|
const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
|
||||||
content,
|
content,
|
||||||
|
msg,
|
||||||
|
contract,
|
||||||
}) => {
|
}) => {
|
||||||
|
const sendCommand = useWebSocketStore(state => state.sendCommand);
|
||||||
|
const setFileDownloading = useWeChatStore(state => state.setFileDownloading);
|
||||||
|
|
||||||
// 统一的错误消息渲染函数
|
// 统一的错误消息渲染函数
|
||||||
const renderErrorMessage = (fallbackText: string) => (
|
const renderErrorMessage = (fallbackText: string) => (
|
||||||
<div className={styles.messageText}>{fallbackText}</div>
|
<div className={styles.messageText}>{fallbackText}</div>
|
||||||
@@ -20,12 +175,10 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const trimmedContent = content.trim();
|
const trimmedContent = content.trim();
|
||||||
|
const isJsonContent = isJsonLike(trimmedContent);
|
||||||
|
const messageData = isJsonContent ? JSON.parse(trimmedContent) : null;
|
||||||
|
|
||||||
// 尝试解析JSON格式的消息
|
if (messageData && typeof messageData === "object") {
|
||||||
if (trimmedContent.startsWith("{") && trimmedContent.endsWith("}")) {
|
|
||||||
const messageData = JSON.parse(trimmedContent);
|
|
||||||
|
|
||||||
// 处理文章类型消息
|
|
||||||
if (messageData.type === "link") {
|
if (messageData.type === "link") {
|
||||||
const { title, desc, thumbPath, url } = messageData;
|
const { title, desc, thumbPath, url } = messageData;
|
||||||
|
|
||||||
@@ -37,10 +190,7 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
|
|||||||
className={`${styles.miniProgramCard} ${styles.articleCard}`}
|
className={`${styles.miniProgramCard} ${styles.articleCard}`}
|
||||||
onClick={() => window.open(url, "_blank")}
|
onClick={() => window.open(url, "_blank")}
|
||||||
>
|
>
|
||||||
{/* 标题在第一行 */}
|
|
||||||
<div className={styles.articleTitle}>{title}</div>
|
<div className={styles.articleTitle}>{title}</div>
|
||||||
|
|
||||||
{/* 下方:文字在左,图片在右 */}
|
|
||||||
<div className={styles.articleContent}>
|
<div className={styles.articleContent}>
|
||||||
<div className={styles.articleTextArea}>
|
<div className={styles.articleTextArea}>
|
||||||
{desc && (
|
{desc && (
|
||||||
@@ -67,7 +217,6 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理小程序消息 - 统一使用parseWeappMsgStr解析
|
|
||||||
if (messageData.type === "miniprogram") {
|
if (messageData.type === "miniprogram") {
|
||||||
try {
|
try {
|
||||||
const parsedData = parseWeappMsgStr(trimmedContent);
|
const parsedData = parseWeappMsgStr(trimmedContent);
|
||||||
@@ -77,16 +226,12 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
|
|||||||
const title = appmsg.title || "小程序消息";
|
const title = appmsg.title || "小程序消息";
|
||||||
const appName =
|
const appName =
|
||||||
appmsg.sourcedisplayname || appmsg.appname || "小程序";
|
appmsg.sourcedisplayname || appmsg.appname || "小程序";
|
||||||
|
|
||||||
// 获取小程序类型
|
|
||||||
const miniProgramType =
|
const miniProgramType =
|
||||||
appmsg.weappinfo && appmsg.weappinfo.type
|
appmsg.weappinfo && appmsg.weappinfo.type
|
||||||
? parseInt(appmsg.weappinfo.type)
|
? parseInt(appmsg.weappinfo.type)
|
||||||
: 1;
|
: 1;
|
||||||
|
|
||||||
// 根据type类型渲染不同布局
|
|
||||||
if (miniProgramType === 2) {
|
if (miniProgramType === 2) {
|
||||||
// 类型2:图片区域布局
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`}
|
className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`}
|
||||||
@@ -113,146 +258,158 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
// 默认类型:横向布局
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
|
|
||||||
>
|
|
||||||
<div className={styles.miniProgramCard}>
|
|
||||||
<img
|
|
||||||
src={parsedData.previewImage}
|
|
||||||
alt="小程序缩略图"
|
|
||||||
className={styles.miniProgramThumb}
|
|
||||||
onError={e => {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
target.style.display = "none";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className={styles.miniProgramInfo}>
|
|
||||||
<div className={styles.miniProgramTitle}>{title}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.miniProgramApp}>{appName}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
|
||||||
|
>
|
||||||
|
<div className={styles.miniProgramCard}>
|
||||||
|
<img
|
||||||
|
src={parsedData.previewImage}
|
||||||
|
alt="小程序缩略图"
|
||||||
|
className={styles.miniProgramThumb}
|
||||||
|
onError={e => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={styles.miniProgramInfo}>
|
||||||
|
<div className={styles.miniProgramTitle}>{title}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.miniProgramApp}>{appName}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.error("parseWeappMsgStr解析失败:", parseError);
|
console.error("parseWeappMsgStr解析失败:", parseError);
|
||||||
return renderErrorMessage("[小程序消息 - 解析失败]");
|
return renderErrorMessage("[小程序消息 - 解析失败]");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//处理文档类型消息
|
const rawContentForResolve =
|
||||||
|
messageData && typeof messageData.contentXml === "string"
|
||||||
|
? messageData.contentXml
|
||||||
|
: trimmedContent;
|
||||||
|
const fileMessageData = resolveFileMessageData(
|
||||||
|
messageData,
|
||||||
|
msg,
|
||||||
|
rawContentForResolve,
|
||||||
|
);
|
||||||
|
|
||||||
if (messageData.type === "file") {
|
if (fileMessageData && fileMessageData.type === FILE_MESSAGE_TYPE) {
|
||||||
const { url, title } = messageData;
|
const {
|
||||||
// 增强的文件消息处理
|
url = "",
|
||||||
const isFileUrl =
|
title,
|
||||||
url.startsWith("http") ||
|
fileName,
|
||||||
url.startsWith("https") ||
|
filename,
|
||||||
url.startsWith("file://") ||
|
fileext,
|
||||||
/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i.test(url);
|
isDownloading = false,
|
||||||
|
} = fileMessageData;
|
||||||
|
const resolvedFileName =
|
||||||
|
title ||
|
||||||
|
fileName ||
|
||||||
|
filename ||
|
||||||
|
(typeof url === "string" && url
|
||||||
|
? url.split("/").pop()?.split("?")[0]
|
||||||
|
: "") ||
|
||||||
|
"文件";
|
||||||
|
const resolvedExtension = (
|
||||||
|
fileext ||
|
||||||
|
resolvedFileName.split(".").pop() ||
|
||||||
|
""
|
||||||
|
).toLowerCase();
|
||||||
|
|
||||||
if (isFileUrl) {
|
const iconMap: Record<string, string> = {
|
||||||
// 尝试从URL中提取文件名
|
pdf: "📕",
|
||||||
const fileName =
|
doc: "📘",
|
||||||
title || url.split("/").pop()?.split("?")[0] || "文件";
|
docx: "📘",
|
||||||
const fileExtension = fileName.split(".").pop()?.toLowerCase();
|
xls: "📗",
|
||||||
|
xlsx: "📗",
|
||||||
|
ppt: "📙",
|
||||||
|
pptx: "📙",
|
||||||
|
txt: "📝",
|
||||||
|
zip: "🗜️",
|
||||||
|
rar: "🗜️",
|
||||||
|
"7z": "🗜️",
|
||||||
|
jpg: "🖼️",
|
||||||
|
jpeg: "🖼️",
|
||||||
|
png: "🖼️",
|
||||||
|
gif: "🖼️",
|
||||||
|
mp4: "🎬",
|
||||||
|
avi: "🎬",
|
||||||
|
mov: "🎬",
|
||||||
|
mp3: "🎵",
|
||||||
|
wav: "🎵",
|
||||||
|
flac: "🎵",
|
||||||
|
};
|
||||||
|
const fileIcon = iconMap[resolvedExtension] || "📄";
|
||||||
|
const isUrlAvailable = typeof url === "string" && url.trim().length > 0;
|
||||||
|
|
||||||
// 根据文件类型选择图标
|
const handleFileDownload = () => {
|
||||||
let fileIcon = "📄";
|
if (isDownloading || !contract || !msg?.id) return;
|
||||||
if (fileExtension) {
|
|
||||||
const iconMap: { [key: string]: string } = {
|
setFileDownloading(msg.id, true);
|
||||||
pdf: "📕",
|
sendCommand("CmdDownloadFile", {
|
||||||
doc: "📘",
|
wechatAccountId: contract.wechatAccountId,
|
||||||
docx: "📘",
|
friendMessageId: contract.chatroomId ? 0 : msg.id,
|
||||||
xls: "📗",
|
chatroomMessageId: contract.chatroomId ? msg.id : 0,
|
||||||
xlsx: "📗",
|
});
|
||||||
ppt: "📙",
|
};
|
||||||
pptx: "📙",
|
|
||||||
txt: "📝",
|
const actionText = isUrlAvailable
|
||||||
zip: "🗜️",
|
? "点击查看"
|
||||||
rar: "🗜️",
|
: isDownloading
|
||||||
"7z": "🗜️",
|
? "下载中..."
|
||||||
jpg: "🖼️",
|
: "下载";
|
||||||
jpeg: "🖼️",
|
const actionDisabled = !isUrlAvailable && isDownloading;
|
||||||
png: "🖼️",
|
|
||||||
gif: "🖼️",
|
const handleActionClick = (event: React.MouseEvent) => {
|
||||||
mp4: "🎬",
|
event.stopPropagation();
|
||||||
avi: "🎬",
|
if (isUrlAvailable) {
|
||||||
mov: "🎬",
|
try {
|
||||||
mp3: "🎵",
|
window.open(url, "_blank");
|
||||||
wav: "🎵",
|
} catch (e) {
|
||||||
flac: "🎵",
|
console.error("文件打开失败:", e);
|
||||||
};
|
|
||||||
fileIcon = iconMap[fileExtension] || "📄";
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleFileDownload();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.fileMessage}>
|
<div className={styles.fileMessage}>
|
||||||
<div className={styles.fileCard}>
|
<div
|
||||||
<div className={styles.fileIcon}>{fileIcon}</div>
|
className={styles.fileCard}
|
||||||
<div className={styles.fileInfo}>
|
onClick={() => {
|
||||||
<div className={styles.fileName}>
|
if (isUrlAvailable) {
|
||||||
{fileName.length > 20
|
window.open(url, "_blank");
|
||||||
? fileName.substring(0, 20) + "..."
|
} else if (!isDownloading) {
|
||||||
: fileName}
|
handleFileDownload();
|
||||||
</div>
|
}
|
||||||
<div
|
}}
|
||||||
className={styles.fileAction}
|
>
|
||||||
onClick={() => {
|
<div className={styles.fileIcon}>{fileIcon}</div>
|
||||||
try {
|
<div className={styles.fileInfo}>
|
||||||
window.open(messageData.url, "_blank");
|
<div className={styles.fileName}>
|
||||||
} catch (e) {
|
{resolvedFileName.length > 20
|
||||||
console.error("文件打开失败:", e);
|
? resolvedFileName.substring(0, 20) + "..."
|
||||||
}
|
: resolvedFileName}
|
||||||
}}
|
</div>
|
||||||
>
|
<div
|
||||||
点击查看
|
className={`${styles.fileAction} ${
|
||||||
</div>
|
actionDisabled ? styles.fileActionDisabled : ""
|
||||||
</div>
|
}`}
|
||||||
|
onClick={handleActionClick}
|
||||||
|
>
|
||||||
|
{actionText}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
</div>
|
||||||
}
|
);
|
||||||
|
|
||||||
// 验证传统JSON格式的小程序数据结构
|
|
||||||
// if (
|
|
||||||
// messageData &&
|
|
||||||
// typeof messageData === "object" &&
|
|
||||||
// (messageData.title || messageData.appName)
|
|
||||||
// ) {
|
|
||||||
// return (
|
|
||||||
// <div className={styles.miniProgramMessage}>
|
|
||||||
// <div className={styles.miniProgramCard}>
|
|
||||||
// {messageData.thumb && (
|
|
||||||
// <img
|
|
||||||
// src={messageData.thumb}
|
|
||||||
// alt="小程序缩略图"
|
|
||||||
// className={styles.miniProgramThumb}
|
|
||||||
// onError={e => {
|
|
||||||
// const target = e.target as HTMLImageElement;
|
|
||||||
// target.style.display = "none";
|
|
||||||
// }}
|
|
||||||
// />
|
|
||||||
// )}
|
|
||||||
// <div className={styles.miniProgramInfo}>
|
|
||||||
// <div className={styles.miniProgramTitle}>
|
|
||||||
// {messageData.title || "小程序消息"}
|
|
||||||
// </div>
|
|
||||||
// {messageData.appName && (
|
|
||||||
// <div className={styles.miniProgramApp}>
|
|
||||||
// {messageData.appName}
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderErrorMessage("[小程序/文件消息]");
|
return renderErrorMessage("[小程序/文件消息]");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
import { Avatar, Checkbox } from "antd";
|
import { Avatar, Checkbox } from "antd";
|
||||||
import { UserOutlined, LoadingOutlined } from "@ant-design/icons";
|
import { UserOutlined, LoadingOutlined } from "@ant-design/icons";
|
||||||
import AudioMessage from "./components/AudioMessage/AudioMessage";
|
import AudioMessage from "./components/AudioMessage/AudioMessage";
|
||||||
@@ -17,6 +17,117 @@ import { useCustomerStore } from "@weChatStore/customer";
|
|||||||
import { fetchReCallApi, fetchVoiceToTextApi } from "./api";
|
import { fetchReCallApi, fetchVoiceToTextApi } from "./api";
|
||||||
import TransmitModal from "./components/TransmitModal";
|
import TransmitModal from "./components/TransmitModal";
|
||||||
|
|
||||||
|
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 DEFAULT_IMAGE_STYLE: CSSProperties = {
|
||||||
|
maxWidth: "200px",
|
||||||
|
maxHeight: "200px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
};
|
||||||
|
const EMOJI_IMAGE_STYLE: CSSProperties = {
|
||||||
|
maxWidth: "120px",
|
||||||
|
maxHeight: "120px",
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImageContentOptions = {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
fallbackText: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
wrapperClassName?: string;
|
||||||
|
withBubble?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderImageContent = ({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
fallbackText,
|
||||||
|
style = DEFAULT_IMAGE_STYLE,
|
||||||
|
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: EMOJI_IMAGE_STYLE,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
interface MessageRecordProps {
|
interface MessageRecordProps {
|
||||||
contract: ContractData | weChatGroup;
|
contract: ContractData | weChatGroup;
|
||||||
}
|
}
|
||||||
@@ -34,6 +145,9 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
|||||||
const [selectedRecords, setSelectedRecords] = useState<ChatRecord[]>([]);
|
const [selectedRecords, setSelectedRecords] = useState<ChatRecord[]>([]);
|
||||||
|
|
||||||
const currentMessages = useWeChatStore(state => state.currentMessages);
|
const currentMessages = useWeChatStore(state => state.currentMessages);
|
||||||
|
const currentMessagesHasMore = useWeChatStore(
|
||||||
|
state => state.currentMessagesHasMore,
|
||||||
|
);
|
||||||
|
|
||||||
const loadChatMessages = useWeChatStore(state => state.loadChatMessages);
|
const loadChatMessages = useWeChatStore(state => state.loadChatMessages);
|
||||||
const messagesLoading = useWeChatStore(state => state.messagesLoading);
|
const messagesLoading = useWeChatStore(state => state.messagesLoading);
|
||||||
@@ -122,6 +236,115 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
|||||||
return parts;
|
return parts;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderUnknownContent = (
|
||||||
|
rawContent: string,
|
||||||
|
trimmedContent: string,
|
||||||
|
msg?: ChatRecord,
|
||||||
|
contract?: ContractData | weChatGroup,
|
||||||
|
) => {
|
||||||
|
if (isLegacyEmojiContent(trimmedContent)) {
|
||||||
|
return renderEmojiContent(rawContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonData = tryParseContentJson(trimmedContent);
|
||||||
|
|
||||||
|
if (jsonData && typeof jsonData === "object") {
|
||||||
|
if (jsonData.type === "file" && msg && contract) {
|
||||||
|
return (
|
||||||
|
<SmallProgramMessage
|
||||||
|
content={rawContent}
|
||||||
|
msg={msg}
|
||||||
|
contract={contract}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevMessages = prevMessagesRef.current;
|
const prevMessages = prevMessagesRef.current;
|
||||||
|
|
||||||
@@ -190,290 +413,84 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
|||||||
<div className={styles.messageText}>{fallbackText}</div>
|
<div className={styles.messageText}>{fallbackText}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 添加调试信息
|
const isStringValue = typeof content === "string";
|
||||||
// console.log("MessageRecord - msgType:", msgType, "content:", content);
|
const rawContent = isStringValue ? content : "";
|
||||||
|
const trimmedContent = rawContent.trim();
|
||||||
|
|
||||||
// 根据msgType进行消息类型判断
|
|
||||||
switch (msgType) {
|
switch (msgType) {
|
||||||
case 1: // 文本消息
|
case 1: // 文本消息
|
||||||
return (
|
return (
|
||||||
<div className={styles.messageBubble}>
|
<div className={styles.messageBubble}>
|
||||||
<div className={styles.messageText}>{parseEmojiText(content)}</div>
|
<div className={styles.messageText}>
|
||||||
|
{parseEmojiText(rawContent)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 3: // 图片消息
|
case 3: // 图片消息
|
||||||
// 验证是否为有效的图片URL
|
if (!isStringValue || !trimmedContent) {
|
||||||
if (typeof content !== "string" || !content.trim()) {
|
|
||||||
return renderErrorMessage("[图片消息 - 无效链接]");
|
return renderErrorMessage("[图片消息 - 无效链接]");
|
||||||
}
|
}
|
||||||
return (
|
return renderImageContent({
|
||||||
<div className={styles.messageBubble}>
|
src: rawContent,
|
||||||
<div className={styles.imageMessage}>
|
alt: "图片消息",
|
||||||
<img
|
fallbackText: "[图片加载失败]",
|
||||||
src={content}
|
withBubble: true,
|
||||||
alt="图片消息"
|
});
|
||||||
style={{
|
|
||||||
maxWidth: "200px",
|
|
||||||
maxHeight: "200px",
|
|
||||||
borderRadius: "8px",
|
|
||||||
}}
|
|
||||||
onClick={() => window.open(content, "_blank")}
|
|
||||||
onError={e => {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
const parent = target.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
parent.innerHTML = `<div class="${styles.messageText}">[图片加载失败]</div>`;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 34: // 语音消息
|
case 34: // 语音消息
|
||||||
if (typeof content !== "string" || !content.trim()) {
|
if (!isStringValue || !trimmedContent) {
|
||||||
return renderErrorMessage("[语音消息 - 无效内容]");
|
return renderErrorMessage("[语音消息 - 无效内容]");
|
||||||
}
|
}
|
||||||
|
|
||||||
// content直接是音频URL字符串
|
return <AudioMessage audioUrl={rawContent} msgId={String(msg.id)} />;
|
||||||
return <AudioMessage audioUrl={content} msgId={String(msg.id)} />;
|
|
||||||
|
|
||||||
case 43: // 视频消息
|
case 43: // 视频消息
|
||||||
return (
|
return (
|
||||||
<VideoMessage content={content || ""} msg={msg} contract={contract} />
|
<VideoMessage
|
||||||
|
content={isStringValue ? rawContent : ""}
|
||||||
|
msg={msg}
|
||||||
|
contract={contract}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 47: // 动图表情包(gif、其他表情包)
|
case 47: // 动图表情包(gif、其他表情包)
|
||||||
if (typeof content !== "string" || !content.trim()) {
|
if (!isStringValue || !trimmedContent) {
|
||||||
return renderErrorMessage("[表情包 - 无效链接]");
|
return renderErrorMessage("[表情包 - 无效链接]");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用工具函数判断表情包URL
|
if (isEmojiUrl(trimmedContent)) {
|
||||||
if (isEmojiUrl(content)) {
|
return renderEmojiContent(rawContent);
|
||||||
return (
|
|
||||||
<div className={styles.emojiMessage}>
|
|
||||||
<img
|
|
||||||
src={content}
|
|
||||||
alt="表情包"
|
|
||||||
style={{ maxWidth: "120px", maxHeight: "120px" }}
|
|
||||||
onClick={() => window.open(content, "_blank")}
|
|
||||||
onError={e => {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
const parent = target.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
parent.innerHTML = `<div class="${styles.messageText}">[表情包加载失败]</div>`;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return renderErrorMessage("[表情包]");
|
return renderErrorMessage("[表情包]");
|
||||||
|
|
||||||
case 48: // 定位消息
|
case 48: // 定位消息
|
||||||
return <LocationMessage content={content || ""} />;
|
return <LocationMessage content={isStringValue ? rawContent : ""} />;
|
||||||
|
|
||||||
case 49: // 小程序/文章/其他:图文、文件
|
case 49: // 小程序/文章/其他:图文、文件
|
||||||
return <SmallProgramMessage content={content || ""} />;
|
return (
|
||||||
|
<SmallProgramMessage
|
||||||
|
content={isStringValue ? rawContent : ""}
|
||||||
|
msg={msg}
|
||||||
|
contract={contract}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case 10002: // 系统推荐备注消息
|
case 10002: // 系统推荐备注消息
|
||||||
return <SystemRecommendRemarkMessage content={content || ""} />;
|
return (
|
||||||
|
<SystemRecommendRemarkMessage
|
||||||
|
content={isStringValue ? rawContent : ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
// 兼容旧版本和未知消息类型的处理逻辑
|
if (!isStringValue || !trimmedContent) {
|
||||||
if (typeof content !== "string" || !content.trim()) {
|
|
||||||
return renderErrorMessage(
|
return renderErrorMessage(
|
||||||
`[未知消息类型${msgType ? ` - ${msgType}` : ""}]`,
|
`[未知消息类型${msgType ? ` - ${msgType}` : ""}]`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 智能识别消息类型(兼容旧版本数据)
|
return renderUnknownContent(rawContent, trimmedContent, msg, contract);
|
||||||
const contentStr = content.trim();
|
|
||||||
|
|
||||||
// 1. 检查是否为表情包(兼容旧逻辑)
|
|
||||||
const isLegacyEmoji =
|
|
||||||
contentStr.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") ||
|
|
||||||
/\.(gif|webp|png|jpg|jpeg)$/i.test(contentStr) ||
|
|
||||||
contentStr.includes("emoji") ||
|
|
||||||
contentStr.includes("sticker");
|
|
||||||
|
|
||||||
if (isLegacyEmoji) {
|
|
||||||
return (
|
|
||||||
<div className={styles.emojiMessage}>
|
|
||||||
<img
|
|
||||||
src={contentStr}
|
|
||||||
alt="表情包"
|
|
||||||
style={{ maxWidth: "120px", maxHeight: "120px" }}
|
|
||||||
onClick={() => window.open(contentStr, "_blank")}
|
|
||||||
onError={e => {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
const parent = target.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
parent.innerHTML = `<div class="${styles.messageText}">[表情包加载失败]</div>`;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 检查是否为JSON格式消息(包括视频、链接等)
|
|
||||||
if (contentStr.startsWith("{") && contentStr.endsWith("}")) {
|
|
||||||
try {
|
|
||||||
const jsonData = JSON.parse(contentStr);
|
|
||||||
|
|
||||||
// 检查是否为链接类型消息
|
|
||||||
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={() => window.open(url, "_blank")}
|
|
||||||
>
|
|
||||||
{thumbPath && (
|
|
||||||
<img
|
|
||||||
src={thumbPath}
|
|
||||||
alt="链接缩略图"
|
|
||||||
className={styles.miniProgramThumb}
|
|
||||||
onError={e => {
|
|
||||||
const target = e.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 &&
|
|
||||||
typeof jsonData === "object" &&
|
|
||||||
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) {
|
|
||||||
window.open(videoUrl, "_blank");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onError={e => {
|
|
||||||
const target = e.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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("兼容模式JSON解析失败:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 检查是否为图片链接
|
|
||||||
const isImageUrl =
|
|
||||||
contentStr.startsWith("http") &&
|
|
||||||
/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(contentStr);
|
|
||||||
|
|
||||||
if (isImageUrl) {
|
|
||||||
return (
|
|
||||||
<div className={styles.imageMessage}>
|
|
||||||
<img
|
|
||||||
src={contentStr}
|
|
||||||
alt="图片消息"
|
|
||||||
style={{
|
|
||||||
maxWidth: "200px",
|
|
||||||
maxHeight: "200px",
|
|
||||||
borderRadius: "8px",
|
|
||||||
}}
|
|
||||||
onClick={() => window.open(contentStr, "_blank")}
|
|
||||||
onError={e => {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
const parent = target.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
parent.innerHTML = `<div class="${styles.messageText}">[图片加载失败]</div>`;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 检查是否为文件链接
|
|
||||||
const isFileLink =
|
|
||||||
contentStr.startsWith("http") &&
|
|
||||||
/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i.test(
|
|
||||||
contentStr,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isFileLink) {
|
|
||||||
const fileName = contentStr.split("/").pop()?.split("?")[0] || "文件";
|
|
||||||
return (
|
|
||||||
<div className={styles.fileMessage}>
|
|
||||||
<div className={styles.fileCard}>
|
|
||||||
<div className={styles.fileIcon}>📄</div>
|
|
||||||
<div className={styles.fileInfo}>
|
|
||||||
<div className={styles.fileName}>
|
|
||||||
{fileName.length > 20
|
|
||||||
? fileName.substring(0, 20) + "..."
|
|
||||||
: fileName}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={styles.fileAction}
|
|
||||||
onClick={() => window.open(contentStr, "_blank")}
|
|
||||||
>
|
|
||||||
点击查看
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 默认按文本消息处理
|
|
||||||
return (
|
|
||||||
<div className={styles.messageText}>{parseEmojiText(content)}</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -538,8 +555,14 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 用于分组消息并添加时间戳的辅助函数
|
// 用于分组消息并添加时间戳的辅助函数
|
||||||
const groupMessagesByTime = (messages: ChatRecord[]) => {
|
const groupMessagesByTime = (messages: ChatRecord[] | null | undefined) => {
|
||||||
return messages
|
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的消息
|
.filter(msg => msg !== null && msg !== undefined) // 过滤掉null和undefined的消息
|
||||||
.map(msg => ({
|
.map(msg => ({
|
||||||
time: formatWechatTime(String(msg?.wechatTime)),
|
time: formatWechatTime(String(msg?.wechatTime)),
|
||||||
@@ -625,7 +648,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isOwn && (
|
{!!isOwn && (
|
||||||
<>
|
<>
|
||||||
{/* Checkbox 显示控制 */}
|
{/* Checkbox 显示控制 */}
|
||||||
{showCheckbox && (
|
{showCheckbox && (
|
||||||
@@ -636,7 +659,6 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Avatar
|
<Avatar
|
||||||
size={32}
|
size={32}
|
||||||
src={currentCustomer?.avatar || ""}
|
src={currentCustomer?.avatar || ""}
|
||||||
@@ -666,33 +688,10 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
const loadMoreMessages = () => {
|
const loadMoreMessages = () => {
|
||||||
// 兼容性处理:检查消息数组和时间戳
|
if (messagesLoading || !currentMessagesHasMore) {
|
||||||
if (!currentMessages || currentMessages.length === 0) {
|
|
||||||
console.warn("No messages available for loading more");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
loadChatMessages(false);
|
||||||
const firstMessage = currentMessages[0];
|
|
||||||
if (!firstMessage || !firstMessage.createTime) {
|
|
||||||
console.warn("Invalid message or createTime");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 兼容性处理:确保时间戳格式正确
|
|
||||||
let timestamp;
|
|
||||||
try {
|
|
||||||
const date = new Date(firstMessage.createTime);
|
|
||||||
if (isNaN(date.getTime())) {
|
|
||||||
console.warn("Invalid createTime format:", firstMessage.createTime);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
timestamp = date.getTime() - 24 * 36000 * 1000;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error parsing createTime:", error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadChatMessages(false, timestamp);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleForwardMessage = (messageData: ChatRecord) => {
|
const handleForwardMessage = (messageData: ChatRecord) => {
|
||||||
@@ -771,8 +770,17 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.messagesContainer}>
|
<div className={styles.messagesContainer}>
|
||||||
<div className={styles.loadMore} onClick={() => loadMoreMessages()}>
|
<div
|
||||||
点击加载更早的信息 {messagesLoading ? <LoadingOutlined /> : ""}
|
className={styles.loadMore}
|
||||||
|
onClick={() => loadMoreMessages()}
|
||||||
|
style={{
|
||||||
|
cursor:
|
||||||
|
currentMessagesHasMore && !messagesLoading ? "pointer" : "default",
|
||||||
|
opacity: currentMessagesHasMore ? 1 : 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentMessagesHasMore ? "点击加载更早的信息" : "已经没有更早的消息了"}
|
||||||
|
{messagesLoading ? <LoadingOutlined /> : ""}
|
||||||
</div>
|
</div>
|
||||||
{groupMessagesByTime(currentMessages).map((group, groupIndex) => (
|
{groupMessagesByTime(currentMessages).map((group, groupIndex) => (
|
||||||
<React.Fragment key={`group-${groupIndex}`}>
|
<React.Fragment key={`group-${groupIndex}`}>
|
||||||
|
|||||||
@@ -4,3 +4,51 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tabHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 30px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabItem {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 24px;
|
||||||
|
padding: 12px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabItem:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabItem:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabItemActive {
|
||||||
|
color: #1677ff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabUnderline {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabItemActive .tabUnderline {
|
||||||
|
background: #1677ff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -197,6 +197,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footerActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
// 响应式设计
|
// 响应式设计
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.profileSider {
|
.profileSider {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
// 更新好友信息
|
||||||
|
export interface UpdateFriendInfoParams {
|
||||||
|
id: number;
|
||||||
|
phone: string;
|
||||||
|
company: string;
|
||||||
|
name: string;
|
||||||
|
position: string;
|
||||||
|
email: string;
|
||||||
|
address: string;
|
||||||
|
qq: string;
|
||||||
|
remark: string;
|
||||||
|
}
|
||||||
|
export function updateFriendInfo(params: UpdateFriendInfoParams): Promise<any> {
|
||||||
|
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 {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FriendDetailResponse {
|
||||||
|
detail: {
|
||||||
|
id: number;
|
||||||
|
wechatAccountId: number;
|
||||||
|
alias: string;
|
||||||
|
wechatId: string;
|
||||||
|
conRemark: string;
|
||||||
|
nickname: string;
|
||||||
|
pyInitial: string;
|
||||||
|
quanPin: string;
|
||||||
|
avatar: string;
|
||||||
|
gender: number;
|
||||||
|
region: string;
|
||||||
|
addFrom: number;
|
||||||
|
labels: any[];
|
||||||
|
siteLabels: string[];
|
||||||
|
signature: string;
|
||||||
|
isDeleted: number;
|
||||||
|
isPassed: number;
|
||||||
|
deleteTime: number;
|
||||||
|
accountId: number;
|
||||||
|
extendFields: string;
|
||||||
|
accountUserName: string;
|
||||||
|
accountRealName: string;
|
||||||
|
accountNickname: string;
|
||||||
|
ownerAlias: string;
|
||||||
|
ownerWechatId: string;
|
||||||
|
ownerNickname: string;
|
||||||
|
ownerAvatar: string;
|
||||||
|
phone: string;
|
||||||
|
thirdParty: string;
|
||||||
|
groupId: number;
|
||||||
|
passTime: string;
|
||||||
|
additionalPicture: string;
|
||||||
|
desc: string;
|
||||||
|
country: string;
|
||||||
|
privince: string;
|
||||||
|
city: string;
|
||||||
|
createTime: string;
|
||||||
|
updateTime: string;
|
||||||
|
R: string;
|
||||||
|
F: string;
|
||||||
|
M: string;
|
||||||
|
realName: null | string;
|
||||||
|
company: null | string;
|
||||||
|
position: null | string;
|
||||||
|
aiType: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function getFriendInfo(
|
||||||
|
params: GetFriendInfoParams,
|
||||||
|
): Promise<FriendDetailResponse> {
|
||||||
|
return request("/v1/kefu/wechatFriend/detail", params, "GET");
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import React, { useCallback, useState, useEffect } from "react";
|
||||||
|
import { Input, message } from "antd";
|
||||||
|
import { Button } from "antd-mobile";
|
||||||
|
import { EditOutlined } from "@ant-design/icons";
|
||||||
|
import { updateFriendInfo, UpdateFriendInfoParams } from "../api";
|
||||||
|
|
||||||
|
import styles from "../Person.module.scss";
|
||||||
|
|
||||||
|
export interface DetailValueField {
|
||||||
|
label: string;
|
||||||
|
key: string;
|
||||||
|
ifEdit?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
type?: "text" | "textarea";
|
||||||
|
editable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailValueProps {
|
||||||
|
fields: DetailValueField[];
|
||||||
|
value?: Record<string, string>;
|
||||||
|
onChange?: (next: Record<string, string>) => void;
|
||||||
|
onSubmit?: (next: Record<string, string>, changedKeys: string[]) => void;
|
||||||
|
submitText?: string;
|
||||||
|
submitting?: boolean;
|
||||||
|
renderFooter?: React.ReactNode;
|
||||||
|
saveHandler?: (
|
||||||
|
values: Record<string, string>,
|
||||||
|
changedKeys: string[],
|
||||||
|
) => Promise<void>;
|
||||||
|
onSaveSuccess?: (
|
||||||
|
values: Record<string, string>,
|
||||||
|
changedKeys: string[],
|
||||||
|
) => void;
|
||||||
|
isGroup?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DetailValue: React.FC<DetailValueProps> = ({
|
||||||
|
fields,
|
||||||
|
value = {},
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
submitText = "保存",
|
||||||
|
submitting = false,
|
||||||
|
renderFooter,
|
||||||
|
saveHandler,
|
||||||
|
onSaveSuccess,
|
||||||
|
isGroup = false,
|
||||||
|
}) => {
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
const [editingFields, setEditingFields] = useState<Record<string, boolean>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [fieldValues, setFieldValues] = useState<Record<string, string>>(value);
|
||||||
|
|
||||||
|
const [originalValues, setOriginalValues] =
|
||||||
|
useState<Record<string, string>>(value);
|
||||||
|
const [changedKeys, setChangedKeys] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 当外部value变化时,更新内部状态
|
||||||
|
useEffect(() => {
|
||||||
|
setFieldValues(value);
|
||||||
|
setOriginalValues(value);
|
||||||
|
setChangedKeys([]);
|
||||||
|
// 重置所有编辑状态
|
||||||
|
const newEditingFields: Record<string, boolean> = {};
|
||||||
|
fields.forEach(field => {
|
||||||
|
newEditingFields[field.key] = false;
|
||||||
|
});
|
||||||
|
setEditingFields(newEditingFields);
|
||||||
|
}, [value, fields]);
|
||||||
|
|
||||||
|
const handleFieldChange = useCallback(
|
||||||
|
(fieldKey: string, nextVal: string) => {
|
||||||
|
setFieldValues(prev => ({
|
||||||
|
...prev,
|
||||||
|
[fieldKey]: nextVal,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 检查值是否发生变化,更新changedKeys
|
||||||
|
if (nextVal !== originalValues[fieldKey]) {
|
||||||
|
if (!changedKeys.includes(fieldKey)) {
|
||||||
|
setChangedKeys(prev => [...prev, fieldKey]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果值恢复到原始值,从changedKeys中移除
|
||||||
|
setChangedKeys(prev => prev.filter(key => key !== fieldKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用外部onChange,但不触发自动保存
|
||||||
|
if (onChange) {
|
||||||
|
onChange({
|
||||||
|
...fieldValues,
|
||||||
|
[fieldKey]: nextVal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange, fieldValues, originalValues, changedKeys],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditField = useCallback((fieldKey: string) => {
|
||||||
|
setEditingFields(prev => ({
|
||||||
|
...prev,
|
||||||
|
[fieldKey]: true,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancelEdit = useCallback(
|
||||||
|
(fieldKey: string) => {
|
||||||
|
// 恢复原始值
|
||||||
|
setFieldValues(prev => ({
|
||||||
|
...prev,
|
||||||
|
[fieldKey]: originalValues[fieldKey] || "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 从changedKeys中移除
|
||||||
|
setChangedKeys(prev => prev.filter(key => key !== fieldKey));
|
||||||
|
|
||||||
|
// 关闭编辑状态
|
||||||
|
setEditingFields(prev => ({
|
||||||
|
...prev,
|
||||||
|
[fieldKey]: false,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[originalValues],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (changedKeys.length === 0) {
|
||||||
|
messageApi.info("没有需要保存的更改");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isGroup) {
|
||||||
|
// 群组信息使用传入的saveHandler
|
||||||
|
if (saveHandler) {
|
||||||
|
await saveHandler(fieldValues, changedKeys);
|
||||||
|
} else {
|
||||||
|
onSubmit?.(fieldValues, changedKeys);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 个人资料信息处理
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新原始值
|
||||||
|
setOriginalValues(fieldValues);
|
||||||
|
// 清空changedKeys
|
||||||
|
setChangedKeys([]);
|
||||||
|
// 关闭所有编辑状态
|
||||||
|
const newEditingFields: Record<string, boolean> = {};
|
||||||
|
fields.forEach(field => {
|
||||||
|
newEditingFields[field.key] = false;
|
||||||
|
});
|
||||||
|
setEditingFields(newEditingFields);
|
||||||
|
// 调用保存成功回调
|
||||||
|
onSaveSuccess?.(fieldValues, changedKeys);
|
||||||
|
messageApi.success("保存成功");
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error("保存失败");
|
||||||
|
console.error("保存失败:", error);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
onSubmit,
|
||||||
|
saveHandler,
|
||||||
|
onSaveSuccess,
|
||||||
|
fieldValues,
|
||||||
|
changedKeys,
|
||||||
|
fields,
|
||||||
|
messageApi,
|
||||||
|
isGroup,
|
||||||
|
value.id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isEditing = Object.values(editingFields).some(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{contextHolder}
|
||||||
|
{fields.map(field => {
|
||||||
|
const disabled = field.ifEdit === false;
|
||||||
|
const fieldValue = fieldValues[field.key] ?? "";
|
||||||
|
const isFieldEditing = editingFields[field.key];
|
||||||
|
const InputComponent =
|
||||||
|
field.type === "textarea" ? Input.TextArea : Input;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={field.key} className={styles.infoItem}>
|
||||||
|
<span className={styles.infoLabel}>{field.label}:</span>
|
||||||
|
<div className={styles.infoValue}>
|
||||||
|
{disabled ? (
|
||||||
|
<span>{fieldValue || field.placeholder || ""}</span>
|
||||||
|
) : isFieldEditing ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InputComponent
|
||||||
|
value={fieldValue}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
onChange={event =>
|
||||||
|
handleFieldChange(field.key, event.target.value)
|
||||||
|
}
|
||||||
|
onPressEnter={undefined}
|
||||||
|
autoFocus
|
||||||
|
rows={field.type === "textarea" ? 4 : undefined}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleCancelEdit(field.key)}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button size="small" color="primary" onClick={handleSubmit}>
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: 4,
|
||||||
|
border: "1px solid transparent",
|
||||||
|
transition: "all 0.3s",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
onClick={() => handleEditField(field.key)}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#f5f5f5";
|
||||||
|
e.currentTarget.style.borderColor = "#d9d9d9";
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.borderColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace:
|
||||||
|
field.type === "textarea" ? "pre-wrap" : "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fieldValue || field.placeholder || ""}
|
||||||
|
</span>
|
||||||
|
<EditOutlined style={{ color: "#1890ff", marginLeft: 8 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{(onSubmit || renderFooter) && !isEditing && changedKeys.length > 0 && (
|
||||||
|
<div className={styles.footerActions}>
|
||||||
|
{renderFooter}
|
||||||
|
<Button
|
||||||
|
loading={submitting}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
style={{ marginLeft: renderFooter ? 8 : 0 }}
|
||||||
|
>
|
||||||
|
{submitText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DetailValue;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,7 @@ const GroupModal: React.FC<GroupModalProps> = ({
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
}}
|
}}
|
||||||
footer={null}
|
footer={null}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
}}
|
}}
|
||||||
footer={null}
|
footer={null}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import QuickReplyModal from "./components/QuickReplyModal";
|
|||||||
import GroupModal from "./components/GroupModal";
|
import GroupModal from "./components/GroupModal";
|
||||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||||
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||||
|
import { ChatRecord } from "@/pages/pc/ckbox/data";
|
||||||
|
|
||||||
// 消息类型枚举
|
// 消息类型枚举
|
||||||
export enum MessageType {
|
export enum MessageType {
|
||||||
@@ -82,10 +83,12 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
|
|||||||
state => state.updateQuoteMessageContent,
|
state => state.updateQuoteMessageContent,
|
||||||
);
|
);
|
||||||
const currentContract = useWeChatStore(state => state.currentContract);
|
const currentContract = useWeChatStore(state => state.currentContract);
|
||||||
|
const addMessage = useWeChatStore(state => state.addMessage);
|
||||||
const { sendCommand } = useWebSocketStore.getState();
|
const { sendCommand } = useWebSocketStore.getState();
|
||||||
|
|
||||||
const sendQuickReplyNow = (reply: QuickWordsReply) => {
|
const sendQuickReplyNow = (reply: QuickWordsReply) => {
|
||||||
if (!currentContract) return;
|
if (!currentContract) return;
|
||||||
|
const messageId = Date.now();
|
||||||
const params = {
|
const params = {
|
||||||
wechatAccountId: currentContract.wechatAccountId,
|
wechatAccountId: currentContract.wechatAccountId,
|
||||||
wechatChatroomId: currentContract?.chatroomId ? currentContract.id : 0,
|
wechatChatroomId: currentContract?.chatroomId ? currentContract.id : 0,
|
||||||
@@ -93,7 +96,35 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
|
|||||||
msgSubType: 0,
|
msgSubType: 0,
|
||||||
msgType: reply.msgType,
|
msgType: reply.msgType,
|
||||||
content: reply.content,
|
content: reply.content,
|
||||||
|
seq: messageId,
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
|
if (reply.msgType !== MessageType.TEXT) {
|
||||||
|
const localMessage: ChatRecord = {
|
||||||
|
id: messageId,
|
||||||
|
wechatAccountId: params.wechatAccountId,
|
||||||
|
wechatFriendId: params.wechatFriendId,
|
||||||
|
wechatChatroomId: params.wechatChatroomId,
|
||||||
|
tenantId: 0,
|
||||||
|
accountId: 0,
|
||||||
|
synergyAccountId: 0,
|
||||||
|
content: params.content,
|
||||||
|
msgType: reply.msgType,
|
||||||
|
msgSubType: params.msgSubType,
|
||||||
|
msgSvrId: "",
|
||||||
|
isSend: true,
|
||||||
|
createTime: new Date().toISOString(),
|
||||||
|
isDeleted: false,
|
||||||
|
deleteTime: "",
|
||||||
|
sendStatus: 1,
|
||||||
|
wechatTime: Date.now(),
|
||||||
|
origin: 0,
|
||||||
|
msgId: 0,
|
||||||
|
recalled: false,
|
||||||
|
seq: messageId,
|
||||||
|
};
|
||||||
|
addMessage(localMessage);
|
||||||
|
}
|
||||||
sendCommand("CmdSendMessage", params);
|
sendCommand("CmdSendMessage", params);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { Layout, Tabs } from "antd";
|
import { Layout } from "antd";
|
||||||
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||||
import styles from "./Person.module.scss";
|
import styles from "./Person.module.scss";
|
||||||
import ProfileModules from "./components/ProfileModules";
|
import ProfileModules from "./components/ProfileModules";
|
||||||
@@ -9,6 +9,8 @@ import LayoutFiexd from "@/components/Layout/LayoutFiexd";
|
|||||||
|
|
||||||
const { Sider } = Layout;
|
const { Sider } = Layout;
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
interface PersonProps {
|
interface PersonProps {
|
||||||
contract: ContractData | weChatGroup;
|
contract: ContractData | weChatGroup;
|
||||||
}
|
}
|
||||||
@@ -16,50 +18,113 @@ interface PersonProps {
|
|||||||
const Person: React.FC<PersonProps> = ({ contract }) => {
|
const Person: React.FC<PersonProps> = ({ contract }) => {
|
||||||
const [activeKey, setActiveKey] = useState("profile");
|
const [activeKey, setActiveKey] = useState("profile");
|
||||||
const isGroup = "chatroomId" in contract;
|
const isGroup = "chatroomId" in contract;
|
||||||
|
// 使用state保存当前contract的副本,确保在切换tab时不会丢失修改
|
||||||
|
const [currentContract, setCurrentContract] = useState<
|
||||||
|
ContractData | weChatGroup
|
||||||
|
>(contract);
|
||||||
|
|
||||||
|
// 当外部contract变化时,更新内部状态
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentContract(contract);
|
||||||
|
}, [contract]);
|
||||||
|
|
||||||
|
const tabItems = useMemo(() => {
|
||||||
|
const baseItems = [
|
||||||
|
{
|
||||||
|
key: "quickwords",
|
||||||
|
label: "快捷语录",
|
||||||
|
children: <QuickWords onInsert={noop} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "profile",
|
||||||
|
label: isGroup ? "群资料" : "个人资料",
|
||||||
|
children: <ProfileModules contract={currentContract} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (!isGroup) {
|
||||||
|
baseItems.push({
|
||||||
|
key: "moments",
|
||||||
|
label: "朋友圈",
|
||||||
|
children: <FriendsCircle wechatFriendId={currentContract.id} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return baseItems;
|
||||||
|
}, [currentContract, isGroup]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveKey("profile");
|
||||||
|
setRenderedKeys(["profile"]);
|
||||||
|
}, [contract]);
|
||||||
|
|
||||||
|
const tabHeaderItems = useMemo(
|
||||||
|
() => tabItems.map(({ key, label }) => ({ key, label })),
|
||||||
|
[tabItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableKeys = useMemo(
|
||||||
|
() => tabItems.map(item => item.key),
|
||||||
|
[tabItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [renderedKeys, setRenderedKeys] = useState<string[]>(() => ["profile"]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!availableKeys.includes(activeKey) && availableKeys.length > 0) {
|
||||||
|
setActiveKey(availableKeys[0]);
|
||||||
|
}
|
||||||
|
}, [activeKey, availableKeys]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRenderedKeys(keys => {
|
||||||
|
const filtered = keys.filter(key => availableKeys.includes(key));
|
||||||
|
if (!filtered.includes(activeKey)) {
|
||||||
|
filtered.push(activeKey);
|
||||||
|
}
|
||||||
|
const isSameLength = filtered.length === keys.length;
|
||||||
|
const isSameOrder =
|
||||||
|
isSameLength && filtered.every((key, index) => key === keys[index]);
|
||||||
|
return isSameOrder ? keys : filtered;
|
||||||
|
});
|
||||||
|
}, [activeKey, availableKeys]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sider width={330} className={styles.profileSider}>
|
<Sider width={330} className={styles.profileSider}>
|
||||||
<LayoutFiexd
|
<LayoutFiexd
|
||||||
header={
|
header={
|
||||||
<Tabs
|
<div className={styles.tabHeader}>
|
||||||
activeKey={activeKey}
|
{tabHeaderItems.map(({ key, label }) => {
|
||||||
onChange={key => setActiveKey(key)}
|
const isActive = key === activeKey;
|
||||||
tabBarStyle={{
|
return (
|
||||||
padding: "0 30px",
|
<div
|
||||||
}}
|
key={key}
|
||||||
items={[
|
className={`${styles.tabItem}${
|
||||||
{
|
isActive ? ` ${styles.tabItemActive}` : ""
|
||||||
key: "quickwords",
|
}`}
|
||||||
label: "快捷语录",
|
onClick={() => {
|
||||||
},
|
setActiveKey(key);
|
||||||
{
|
}}
|
||||||
key: "profile",
|
>
|
||||||
label: isGroup ? "群资料" : "个人资料",
|
<span>{label}</span>
|
||||||
},
|
<div className={styles.tabUnderline} />
|
||||||
|
</div>
|
||||||
...(!isGroup
|
);
|
||||||
? [
|
})}
|
||||||
{
|
</div>
|
||||||
key: "moments",
|
|
||||||
label: "朋友圈",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{activeKey === "profile" && <ProfileModules contract={contract} />}
|
{renderedKeys.map(key => {
|
||||||
{activeKey === "quickwords" && (
|
const item = tabItems.find(tab => tab.key === key);
|
||||||
<QuickWords
|
if (!item) return null;
|
||||||
words={[]}
|
const isActive = key === activeKey;
|
||||||
onInsert={() => {}}
|
return (
|
||||||
onAdd={() => {}}
|
<div
|
||||||
onRemove={() => {}}
|
key={key}
|
||||||
/>
|
style={{ display: isActive ? "block" : "none", height: "100%" }}
|
||||||
)}
|
>
|
||||||
{activeKey === "moments" && !isGroup && (
|
{item.children}
|
||||||
<FriendsCircle wechatFriendId={contract.id} />
|
</div>
|
||||||
)}
|
);
|
||||||
|
})}
|
||||||
</LayoutFiexd>
|
</LayoutFiexd>
|
||||||
</Sider>
|
</Sider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export interface ContractData {
|
|||||||
labels: string[];
|
labels: string[];
|
||||||
signature: string;
|
signature: string;
|
||||||
accountId: number;
|
accountId: number;
|
||||||
extendFields: null;
|
extendFields?: Record<string, any> | null;
|
||||||
city?: string;
|
city?: string;
|
||||||
lastUpdateTime: string;
|
lastUpdateTime: string;
|
||||||
isPassed: boolean;
|
isPassed: boolean;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
getWechatFriendDetail,
|
getWechatFriendDetail,
|
||||||
getWechatChatroomDetail,
|
getWechatChatroomDetail,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import { useMessageStore, triggerRefresh } from "@weChatStore/message";
|
import { useMessageStore } from "@weChatStore/message";
|
||||||
import { useWebSocketStore } from "@storeModule/websocket/websocket";
|
import { useWebSocketStore } from "@storeModule/websocket/websocket";
|
||||||
import { useCustomerStore } from "@weChatStore/customer";
|
import { useCustomerStore } from "@weChatStore/customer";
|
||||||
import { useContactStore } from "@weChatStore/contacts";
|
import { useContactStore } from "@weChatStore/contacts";
|
||||||
@@ -39,14 +39,12 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
// Store状态
|
// Store状态
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
refreshTrigger,
|
|
||||||
hasLoadedOnce,
|
hasLoadedOnce,
|
||||||
setLoading,
|
setLoading,
|
||||||
setHasLoadedOnce,
|
setHasLoadedOnce,
|
||||||
|
sessions,
|
||||||
|
setSessions: setSessionState,
|
||||||
} = useMessageStore();
|
} = useMessageStore();
|
||||||
|
|
||||||
// 组件内部状态:会话列表数据
|
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
|
||||||
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([]);
|
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([]);
|
||||||
|
|
||||||
// 右键菜单相关状态
|
// 右键菜单相关状态
|
||||||
@@ -74,6 +72,8 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const previousUserIdRef = useRef<number | null>(null);
|
||||||
|
const loadRequestRef = useRef(0);
|
||||||
|
|
||||||
// 右键菜单事件处理
|
// 右键菜单事件处理
|
||||||
const handleContextMenu = (e: React.MouseEvent, session: ChatSession) => {
|
const handleContextMenu = (e: React.MouseEvent, session: ChatSession) => {
|
||||||
@@ -105,7 +105,7 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 立即更新UI并重新排序(乐观更新)
|
// 1. 立即更新UI并重新排序(乐观更新)
|
||||||
setSessions(prev => {
|
setSessionState(prev => {
|
||||||
const updatedSessions = prev.map(s =>
|
const updatedSessions = prev.map(s =>
|
||||||
s.id === session.id
|
s.id === session.id
|
||||||
? {
|
? {
|
||||||
@@ -141,7 +141,7 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
message.success(`${newPinned === 1 ? "置顶" : "取消置顶"}成功`);
|
message.success(`${newPinned === 1 ? "置顶" : "取消置顶"}成功`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 4. 失败时回滚UI
|
// 4. 失败时回滚UI
|
||||||
setSessions(prev =>
|
setSessionState(prev =>
|
||||||
prev.map(s =>
|
prev.map(s =>
|
||||||
s.id === session.id
|
s.id === session.id
|
||||||
? { ...s, config: { ...s.config, top: currentPinned } }
|
? { ...s, config: { ...s.config, top: currentPinned } }
|
||||||
@@ -162,7 +162,7 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
// 1. 立即从UI移除
|
// 1. 立即从UI移除
|
||||||
setSessions(prev => prev.filter(s => s.id !== session.id));
|
setSessionState(prev => prev.filter(s => s.id !== session.id));
|
||||||
|
|
||||||
// 2. 后台调用API
|
// 2. 后台调用API
|
||||||
await updateConfig({
|
await updateConfig({
|
||||||
@@ -180,7 +180,7 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
message.success("删除成功");
|
message.success("删除成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 4. 失败时恢复UI
|
// 4. 失败时恢复UI
|
||||||
setSessions(prev => [...prev, session]);
|
setSessionState(prev => [...prev, session]);
|
||||||
message.error("删除失败");
|
message.error("删除失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 立即更新UI
|
// 1. 立即更新UI
|
||||||
setSessions(prev =>
|
setSessionState(prev =>
|
||||||
prev.map(s =>
|
prev.map(s =>
|
||||||
s.id === session.id ? { ...s, conRemark: editRemarkModal.remark } : s,
|
s.id === session.id ? { ...s, conRemark: editRemarkModal.remark } : s,
|
||||||
),
|
),
|
||||||
@@ -258,7 +258,7 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
message.success("备注更新成功");
|
message.success("备注更新成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 4. 失败时回滚UI
|
// 4. 失败时回滚UI
|
||||||
setSessions(prev =>
|
setSessionState(prev =>
|
||||||
prev.map(s =>
|
prev.map(s =>
|
||||||
s.id === session.id ? { ...s, conRemark: oldRemark } : s,
|
s.id === session.id ? { ...s, conRemark: oldRemark } : s,
|
||||||
),
|
),
|
||||||
@@ -343,9 +343,6 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("群聊数据示例:", groups[0]); // 调试:查看第一个群聊数据
|
|
||||||
console.log("好友数据示例:", friends[0]); // 调试:查看第一个好友数据
|
|
||||||
|
|
||||||
// 执行增量同步
|
// 执行增量同步
|
||||||
const syncResult = await MessageManager.syncSessions(currentUserId, {
|
const syncResult = await MessageManager.syncSessions(currentUserId, {
|
||||||
friends,
|
friends,
|
||||||
@@ -360,112 +357,93 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
`会话同步完成: 新增${syncResult.added}, 更新${syncResult.updated}, 删除${syncResult.deleted}`,
|
`会话同步完成: 新增${syncResult.added}, 更新${syncResult.updated}, 删除${syncResult.deleted}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 如果有数据变更,触发UI刷新
|
// 会话管理器会在有变更时触发订阅回调
|
||||||
if (
|
|
||||||
syncResult.added > 0 ||
|
|
||||||
syncResult.updated > 0 ||
|
|
||||||
syncResult.deleted > 0
|
|
||||||
) {
|
|
||||||
triggerRefresh();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("同步服务器数据失败:", error);
|
console.error("同步服务器数据失败:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 切换账号时重置加载状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUserId) return;
|
||||||
|
if (previousUserIdRef.current === currentUserId) return;
|
||||||
|
previousUserIdRef.current = currentUserId;
|
||||||
|
setHasLoadedOnce(false);
|
||||||
|
setSessionState([]);
|
||||||
|
}, [currentUserId, setHasLoadedOnce, setSessionState]);
|
||||||
|
|
||||||
// 初始化加载会话列表
|
// 初始化加载会话列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!currentUserId || currentUserId === 0) {
|
||||||
|
console.warn("currentUserId 无效,跳过加载:", currentUserId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
const requestId = ++loadRequestRef.current;
|
||||||
|
|
||||||
const initializeSessions = async () => {
|
const initializeSessions = async () => {
|
||||||
if (!currentUserId || currentUserId === 0) {
|
|
||||||
console.warn("currentUserId 无效,跳过加载:", currentUserId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果已经加载过一次,只从本地数据库读取,不请求接口
|
|
||||||
if (hasLoadedOnce) {
|
|
||||||
console.log("已加载过,只从本地数据库读取");
|
|
||||||
setLoading(true); // 显示骨架屏
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cachedSessions =
|
|
||||||
await MessageManager.getUserSessions(currentUserId);
|
|
||||||
console.log("从本地加载会话数:", cachedSessions.length);
|
|
||||||
|
|
||||||
// 如果本地数据为空,重置 hasLoadedOnce 并重新加载
|
|
||||||
if (cachedSessions.length === 0) {
|
|
||||||
console.warn("本地数据为空,重置加载状态并重新加载");
|
|
||||||
setHasLoadedOnce(false);
|
|
||||||
// 不 return,继续执行下面的首次加载逻辑
|
|
||||||
} else {
|
|
||||||
setSessions(cachedSessions);
|
|
||||||
setLoading(false); // 数据加载完成,关闭骨架屏
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("从本地加载会话列表失败:", error);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("首次加载,开始初始化...");
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 优先从本地数据库加载
|
|
||||||
const cachedSessions =
|
const cachedSessions =
|
||||||
await MessageManager.getUserSessions(currentUserId);
|
await MessageManager.getUserSessions(currentUserId);
|
||||||
|
|
||||||
console.log("本地缓存会话数:", cachedSessions.length);
|
if (isCancelled || loadRequestRef.current !== requestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (cachedSessions.length > 0) {
|
if (cachedSessions.length > 0) {
|
||||||
// 有缓存数据,立即显示
|
setSessionState(cachedSessions);
|
||||||
console.log("有缓存数据,立即显示");
|
}
|
||||||
setSessions(cachedSessions);
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
// 2. 后台静默同步(不显示同步提示)
|
const needsFullSync = cachedSessions.length === 0 || !hasLoadedOnce;
|
||||||
console.log("后台静默同步中...");
|
|
||||||
|
if (needsFullSync) {
|
||||||
await syncWithServer();
|
await syncWithServer();
|
||||||
setHasLoadedOnce(true); // 标记已加载过
|
if (isCancelled || loadRequestRef.current !== requestId) {
|
||||||
console.log("同步完成");
|
return;
|
||||||
|
}
|
||||||
|
setHasLoadedOnce(true);
|
||||||
} else {
|
} else {
|
||||||
// 无缓存,直接API加载
|
syncWithServer().catch(error => {
|
||||||
console.log("无缓存,从服务器加载...");
|
console.error("后台同步失败:", error);
|
||||||
await syncWithServer();
|
});
|
||||||
const newSessions =
|
|
||||||
await MessageManager.getUserSessions(currentUserId);
|
|
||||||
console.log("从服务器加载会话数:", newSessions.length);
|
|
||||||
setSessions(newSessions);
|
|
||||||
setLoading(false);
|
|
||||||
setHasLoadedOnce(true); // 标记已加载过
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("初始化会话列表失败:", error);
|
if (!isCancelled) {
|
||||||
setLoading(false);
|
console.error("初始化会话列表失败:", error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled && loadRequestRef.current === requestId) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeSessions();
|
initializeSessions();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [currentUserId]);
|
}, [currentUserId]);
|
||||||
|
|
||||||
// 监听refreshTrigger,重新查询数据库
|
// 订阅数据库变更,自动更新Store
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refreshSessions = async () => {
|
if (!currentUserId) {
|
||||||
if (!currentUserId || refreshTrigger === 0) return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const unsubscribe = MessageManager.onSessionsUpdate(
|
||||||
const updatedSessions =
|
({ userId: ownerId, sessions: updatedSessions }) => {
|
||||||
await MessageManager.getUserSessions(currentUserId);
|
if (ownerId !== currentUserId) return;
|
||||||
setSessions(updatedSessions);
|
setSessionState(updatedSessions);
|
||||||
} catch (error) {
|
},
|
||||||
console.error("刷新会话列表失败:", error);
|
);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
refreshSessions();
|
return unsubscribe;
|
||||||
}, [refreshTrigger, currentUserId]);
|
}, [currentUserId, setSessionState]);
|
||||||
|
|
||||||
// 根据客服和搜索关键词筛选会话
|
// 根据客服和搜索关键词筛选会话
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -655,6 +633,8 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
top: 0,
|
top: 0,
|
||||||
},
|
},
|
||||||
sortKey: "",
|
sortKey: "",
|
||||||
|
phone: msgData.phone || "",
|
||||||
|
region: msgData.region || "",
|
||||||
};
|
};
|
||||||
|
|
||||||
await MessageManager.addSession(newSession);
|
await MessageManager.addSession(newSession);
|
||||||
@@ -681,6 +661,8 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
top: 0,
|
top: 0,
|
||||||
},
|
},
|
||||||
sortKey: "",
|
sortKey: "",
|
||||||
|
phone: msgData.phone || "",
|
||||||
|
region: msgData.region || "",
|
||||||
};
|
};
|
||||||
|
|
||||||
await MessageManager.addSession(newSession);
|
await MessageManager.addSession(newSession);
|
||||||
@@ -688,8 +670,7 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 触发静默刷新:通知组件从数据库重新查询
|
// MessageManager 的回调会自动把最新数据发给 Store
|
||||||
triggerRefresh();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
@@ -710,7 +691,6 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
// 点击会话
|
// 点击会话
|
||||||
const onContactClick = async (session: ChatSession) => {
|
const onContactClick = async (session: ChatSession) => {
|
||||||
console.log("onContactClick", session);
|
console.log("onContactClick", session);
|
||||||
console.log("session.aiType:", session.aiType); // 调试:查看 aiType 字段
|
|
||||||
|
|
||||||
// 设置当前会话
|
// 设置当前会话
|
||||||
setCurrentContact(session as any);
|
setCurrentContact(session as any);
|
||||||
@@ -718,7 +698,7 @@ const MessageList: React.FC<MessageListProps> = () => {
|
|||||||
// 标记为已读(不更新时间和排序)
|
// 标记为已读(不更新时间和排序)
|
||||||
if (session.config.unreadCount > 0) {
|
if (session.config.unreadCount > 0) {
|
||||||
// 立即更新UI(只更新未读数量)
|
// 立即更新UI(只更新未读数量)
|
||||||
setSessions(prev =>
|
setSessionState(prev =>
|
||||||
prev.map(s =>
|
prev.map(s =>
|
||||||
s.id === session.id
|
s.id === session.id
|
||||||
? { ...s, config: { ...s.config, unreadCount: 0 } }
|
? { ...s, config: { ...s.config, unreadCount: 0 } }
|
||||||
|
|||||||
@@ -82,6 +82,20 @@ export const getAllGroups = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const serializeExtendFields = (value: any) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.trim() ? value : "{}";
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("序列化 extendFields 失败:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "{}";
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将好友数据转换为统一的 Contact 格式
|
* 将好友数据转换为统一的 Contact 格式
|
||||||
*/
|
*/
|
||||||
@@ -95,11 +109,21 @@ export const convertFriendsToContacts = (
|
|||||||
id: friend.id,
|
id: friend.id,
|
||||||
type: "friend" as const,
|
type: "friend" as const,
|
||||||
wechatAccountId: friend.wechatAccountId,
|
wechatAccountId: friend.wechatAccountId,
|
||||||
|
wechatFriendId: friend.id,
|
||||||
wechatId: friend.wechatId,
|
wechatId: friend.wechatId,
|
||||||
nickname: friend.nickname || "",
|
nickname: friend.nickname || "",
|
||||||
conRemark: friend.conRemark || "",
|
conRemark: friend.conRemark || "",
|
||||||
avatar: friend.avatar || "",
|
avatar: friend.avatar || "",
|
||||||
|
alias: friend.alias || "",
|
||||||
|
gender: friend.gender,
|
||||||
|
aiType: friend.aiType ?? 0,
|
||||||
|
phone: friend.phone ?? "",
|
||||||
|
region: friend.region ?? "",
|
||||||
|
quanPin: friend.quanPin || "",
|
||||||
|
signature: friend.signature || "",
|
||||||
|
config: friend.config || {},
|
||||||
groupId: friend.groupId, // 保留标签ID
|
groupId: friend.groupId, // 保留标签ID
|
||||||
|
extendFields: serializeExtendFields(friend.extendFields),
|
||||||
lastUpdateTime: new Date().toISOString(),
|
lastUpdateTime: new Date().toISOString(),
|
||||||
sortKey: "",
|
sortKey: "",
|
||||||
searchKey: "",
|
searchKey: "",
|
||||||
@@ -120,10 +144,19 @@ export const convertGroupsToContacts = (
|
|||||||
type: "group" as const,
|
type: "group" as const,
|
||||||
wechatAccountId: group.wechatAccountId,
|
wechatAccountId: group.wechatAccountId,
|
||||||
wechatId: group.chatroomId || "",
|
wechatId: group.chatroomId || "",
|
||||||
|
chatroomId: group.chatroomId || "",
|
||||||
|
chatroomOwner: group.chatroomOwner || "",
|
||||||
nickname: group.nickname || "",
|
nickname: group.nickname || "",
|
||||||
conRemark: group.conRemark || "",
|
conRemark: group.conRemark || "",
|
||||||
avatar: group.chatroomAvatar || group.avatar || "",
|
avatar: group.chatroomAvatar || group.avatar || "",
|
||||||
|
selfDisplayName: group.selfDisplyName || "",
|
||||||
|
notice: group.notice || "",
|
||||||
|
aiType: group.aiType ?? 0,
|
||||||
|
phone: group.phone ?? "",
|
||||||
|
region: group.region ?? "",
|
||||||
|
config: group.config || {},
|
||||||
groupId: group.groupId, // 保留标签ID
|
groupId: group.groupId, // 保留标签ID
|
||||||
|
extendFields: serializeExtendFields(group.extendFields),
|
||||||
lastUpdateTime: new Date().toISOString(),
|
lastUpdateTime: new Date().toISOString(),
|
||||||
sortKey: "",
|
sortKey: "",
|
||||||
searchKey: "",
|
searchKey: "",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Input, Skeleton, Button, Dropdown, MenuProps } from "antd";
|
import { Input, Skeleton, Button, Dropdown, MenuProps } from "antd";
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
@@ -193,6 +193,27 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const tabContentCacheRef = useRef<Record<string, React.ReactNode>>({});
|
||||||
|
|
||||||
|
const getTabContent = (tabKey: string) => {
|
||||||
|
if (!tabContentCacheRef.current[tabKey]) {
|
||||||
|
switch (tabKey) {
|
||||||
|
case "chats":
|
||||||
|
tabContentCacheRef.current[tabKey] = <MessageList />;
|
||||||
|
break;
|
||||||
|
case "contracts":
|
||||||
|
tabContentCacheRef.current[tabKey] = <WechatFriends />;
|
||||||
|
break;
|
||||||
|
case "friendsCicle":
|
||||||
|
tabContentCacheRef.current[tabKey] = <FriendsCircle />;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
tabContentCacheRef.current[tabKey] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tabContentCacheRef.current[tabKey];
|
||||||
|
};
|
||||||
|
|
||||||
// 渲染内容部分
|
// 渲染内容部分
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
// 如果正在切换tab到聊天,显示骨架屏
|
// 如果正在切换tab到聊天,显示骨架屏
|
||||||
@@ -200,16 +221,27 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
|
|||||||
return renderSkeleton();
|
return renderSkeleton();
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (activeTab) {
|
const availableTabs = ["chats", "contracts"];
|
||||||
case "chats":
|
if (currentCustomer && currentCustomer.id !== 0) {
|
||||||
return <MessageList />;
|
availableTabs.push("friendsCicle");
|
||||||
case "contracts":
|
|
||||||
return <WechatFriends />;
|
|
||||||
case "friendsCicle":
|
|
||||||
return <FriendsCircle />;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{availableTabs.map(tabKey => (
|
||||||
|
<div
|
||||||
|
key={tabKey}
|
||||||
|
style={{
|
||||||
|
display: activeTab === tabKey ? "block" : "none",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
aria-hidden={activeTab !== tabKey}
|
||||||
|
>
|
||||||
|
{getTabContent(tabKey)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export interface ContractData {
|
|||||||
labels: string[];
|
labels: string[];
|
||||||
signature: string;
|
signature: string;
|
||||||
accountId: number;
|
accountId: number;
|
||||||
extendFields: null;
|
extendFields?: Record<string, any> | null;
|
||||||
city?: string;
|
city?: string;
|
||||||
lastUpdateTime: string;
|
lastUpdateTime: string;
|
||||||
isPassed: boolean;
|
isPassed: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,52 @@
|
|||||||
import { createPersistStore } from "@/store/createPersistStore";
|
import { createPersistStore } from "@/store/createPersistStore";
|
||||||
import { Toast } from "antd-mobile";
|
import { Toast } from "antd-mobile";
|
||||||
|
import { databaseManager } from "@/utils/db";
|
||||||
|
|
||||||
|
const STORE_CACHE_KEYS = [
|
||||||
|
"user-store",
|
||||||
|
"app-store",
|
||||||
|
"settings-store",
|
||||||
|
"websocket-store",
|
||||||
|
"ckchat-store",
|
||||||
|
"wechat-storage",
|
||||||
|
"contacts-storage",
|
||||||
|
"message-storage",
|
||||||
|
"customer-storage",
|
||||||
|
];
|
||||||
|
|
||||||
|
const allStorages = (): Storage[] => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const storages: Storage[] = [];
|
||||||
|
try {
|
||||||
|
storages.push(window.localStorage);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("无法访问 localStorage:", error);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
storages.push(window.sessionStorage);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("无法访问 sessionStorage:", error);
|
||||||
|
}
|
||||||
|
return storages;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearStoreCaches = () => {
|
||||||
|
const storages = allStorages();
|
||||||
|
if (!storages.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
STORE_CACHE_KEYS.forEach(key => {
|
||||||
|
storages.forEach(storage => {
|
||||||
|
try {
|
||||||
|
storage.removeItem(key);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`清理持久化数据失败: ${key}`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -28,7 +75,7 @@ interface UserState {
|
|||||||
setToken: (token: string) => void;
|
setToken: (token: string) => void;
|
||||||
setToken2: (token2: string) => void;
|
setToken2: (token2: string) => void;
|
||||||
clearUser: () => void;
|
clearUser: () => void;
|
||||||
login: (token: string, userInfo: User) => void;
|
login: (token: string, userInfo: User) => Promise<void>;
|
||||||
login2: (token2: string) => void;
|
login2: (token2: string) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
@@ -39,12 +86,27 @@ export const useUserStore = createPersistStore<UserState>(
|
|||||||
token: null,
|
token: null,
|
||||||
token2: null,
|
token2: null,
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
setUser: user => set({ user, isLoggedIn: true }),
|
setUser: user => {
|
||||||
|
set({ user, isLoggedIn: true });
|
||||||
|
databaseManager.ensureDatabase(user.id).catch(error => {
|
||||||
|
console.warn("Failed to initialize database for user:", error);
|
||||||
|
});
|
||||||
|
},
|
||||||
setToken: token => set({ token }),
|
setToken: token => set({ token }),
|
||||||
setToken2: token2 => set({ token2 }),
|
setToken2: token2 => set({ token2 }),
|
||||||
clearUser: () =>
|
clearUser: () => {
|
||||||
set({ user: null, token: null, token2: null, isLoggedIn: false }),
|
databaseManager.closeCurrentDatabase().catch(error => {
|
||||||
login: (token, userInfo) => {
|
console.warn("Failed to close database on clearUser:", error);
|
||||||
|
});
|
||||||
|
clearStoreCaches();
|
||||||
|
set({ user: null, token: null, token2: null, isLoggedIn: false });
|
||||||
|
},
|
||||||
|
login: async (token, userInfo) => {
|
||||||
|
clearStoreCaches();
|
||||||
|
|
||||||
|
// 清除旧的双token缓存
|
||||||
|
localStorage.removeItem("token2");
|
||||||
|
|
||||||
// 只将token存储到localStorage
|
// 只将token存储到localStorage
|
||||||
localStorage.setItem("token", token);
|
localStorage.setItem("token", token);
|
||||||
|
|
||||||
@@ -66,6 +128,11 @@ export const useUserStore = createPersistStore<UserState>(
|
|||||||
lastLoginIp: userInfo.lastLoginIp,
|
lastLoginIp: userInfo.lastLoginIp,
|
||||||
lastLoginTime: userInfo.lastLoginTime,
|
lastLoginTime: userInfo.lastLoginTime,
|
||||||
};
|
};
|
||||||
|
try {
|
||||||
|
await databaseManager.ensureDatabase(user.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to initialize user database:", error);
|
||||||
|
}
|
||||||
set({ user, token, isLoggedIn: true });
|
set({ user, token, isLoggedIn: true });
|
||||||
|
|
||||||
Toast.show({ content: "登录成功", position: "top" });
|
Toast.show({ content: "登录成功", position: "top" });
|
||||||
@@ -80,6 +147,10 @@ export const useUserStore = createPersistStore<UserState>(
|
|||||||
// 清除localStorage中的token
|
// 清除localStorage中的token
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
localStorage.removeItem("token2");
|
localStorage.removeItem("token2");
|
||||||
|
databaseManager.closeCurrentDatabase().catch(error => {
|
||||||
|
console.warn("Failed to close user database on logout:", error);
|
||||||
|
});
|
||||||
|
clearStoreCaches();
|
||||||
set({ user: null, token: null, token2: null, isLoggedIn: false });
|
set({ user: null, token: null, token2: null, isLoggedIn: false });
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -92,7 +163,11 @@ export const useUserStore = createPersistStore<UserState>(
|
|||||||
isLoggedIn: state.isLoggedIn,
|
isLoggedIn: state.isLoggedIn,
|
||||||
}),
|
}),
|
||||||
onRehydrateStorage: () => state => {
|
onRehydrateStorage: () => state => {
|
||||||
// console.log("User store hydrated:", state);
|
if (state?.user?.id) {
|
||||||
|
databaseManager.ensureDatabase(state.user!.id).catch(error => {
|
||||||
|
console.warn("Failed to restore user database:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export interface ContractData {
|
|||||||
labels: string[];
|
labels: string[];
|
||||||
signature: string;
|
signature: string;
|
||||||
accountId: number;
|
accountId: number;
|
||||||
extendFields: null;
|
extendFields?: Record<string, any> | null;
|
||||||
city?: string;
|
city?: string;
|
||||||
lastUpdateTime: string;
|
lastUpdateTime: string;
|
||||||
isPassed: boolean;
|
isPassed: boolean;
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { persist } from "zustand/middleware";
|
|||||||
import { ContactGroupByLabel } from "@/pages/pc/ckbox/data";
|
import { ContactGroupByLabel } from "@/pages/pc/ckbox/data";
|
||||||
import { Contact } from "@/utils/db";
|
import { Contact } from "@/utils/db";
|
||||||
import { ContactManager } from "@/utils/dbAction";
|
import { ContactManager } from "@/utils/dbAction";
|
||||||
|
import { useUserStore } from "@/store/module/user";
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_DELAY = 300;
|
||||||
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 联系人状态管理接口
|
* 联系人状态管理接口
|
||||||
@@ -171,8 +175,16 @@ export const useContactStore = create<ContactState>()(
|
|||||||
|
|
||||||
setSearchKeyword: (keyword: string) => {
|
setSearchKeyword: (keyword: string) => {
|
||||||
set({ searchKeyword: keyword });
|
set({ searchKeyword: keyword });
|
||||||
|
|
||||||
|
if (searchDebounceTimer) {
|
||||||
|
clearTimeout(searchDebounceTimer);
|
||||||
|
searchDebounceTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (keyword.trim()) {
|
if (keyword.trim()) {
|
||||||
get().searchContacts(keyword);
|
searchDebounceTimer = setTimeout(() => {
|
||||||
|
get().searchContacts(keyword);
|
||||||
|
}, SEARCH_DEBOUNCE_DELAY);
|
||||||
} else {
|
} else {
|
||||||
set({ isSearchMode: false, searchResults: [] });
|
set({ isSearchMode: false, searchResults: [] });
|
||||||
}
|
}
|
||||||
@@ -204,8 +216,15 @@ export const useContactStore = create<ContactState>()(
|
|||||||
set({ loading: true, isSearchMode: true });
|
set({ loading: true, isSearchMode: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const currentUserId = useUserStore.getState().user?.id;
|
||||||
|
|
||||||
|
if (!currentUserId) {
|
||||||
|
set({ searchResults: [], isSearchMode: false, loading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const results = await ContactManager.searchContacts(
|
const results = await ContactManager.searchContacts(
|
||||||
get().currentContact?.userId || 0,
|
currentUserId,
|
||||||
keyword,
|
keyword,
|
||||||
);
|
);
|
||||||
set({ searchResults: results });
|
set({ searchResults: results });
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ChatSession } from "@/utils/db";
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: number;
|
id: number;
|
||||||
wechatId: string;
|
wechatId: string;
|
||||||
@@ -26,13 +28,15 @@ export interface Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//Store State - 会话列表状态管理(不存储数据,只管理状态)
|
//Store State - 会话列表状态管理(不存储数据,只管理状态)
|
||||||
|
export type SessionsUpdater =
|
||||||
|
| ChatSession[]
|
||||||
|
| ((previous: ChatSession[]) => ChatSession[]);
|
||||||
|
|
||||||
export interface MessageState {
|
export interface MessageState {
|
||||||
//加载状态
|
//加载状态
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
//后台同步状态
|
//后台同步状态
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
//刷新触发器(用于通知组件重新查询数据库)
|
|
||||||
refreshTrigger: number;
|
|
||||||
//最后刷新时间
|
//最后刷新时间
|
||||||
lastRefreshTime: string | null;
|
lastRefreshTime: string | null;
|
||||||
//是否已经加载过一次(避免重复请求)
|
//是否已经加载过一次(避免重复请求)
|
||||||
@@ -42,8 +46,6 @@ export interface MessageState {
|
|||||||
setLoading: (loading: boolean) => void;
|
setLoading: (loading: boolean) => void;
|
||||||
//设置同步状态
|
//设置同步状态
|
||||||
setRefreshing: (refreshing: boolean) => void;
|
setRefreshing: (refreshing: boolean) => void;
|
||||||
//触发刷新(通知组件重新查询)
|
|
||||||
triggerRefresh: () => void;
|
|
||||||
//设置已加载标识
|
//设置已加载标识
|
||||||
setHasLoadedOnce: (loaded: boolean) => void;
|
setHasLoadedOnce: (loaded: boolean) => void;
|
||||||
//重置加载状态(用于登出或切换用户)
|
//重置加载状态(用于登出或切换用户)
|
||||||
@@ -60,4 +62,16 @@ export interface MessageState {
|
|||||||
updateMessageStatus: (messageId: number, status: string) => void;
|
updateMessageStatus: (messageId: number, status: string) => void;
|
||||||
//更新当前选中的消息(废弃,保留兼容)
|
//更新当前选中的消息(废弃,保留兼容)
|
||||||
updateCurrentMessage: (message: Message) => void;
|
updateCurrentMessage: (message: Message) => void;
|
||||||
|
|
||||||
|
// ==================== 新的会话数据接口 ====================
|
||||||
|
// 当前会话列表
|
||||||
|
sessions: ChatSession[];
|
||||||
|
// 设置或更新会话列表(支持回调写法)
|
||||||
|
setSessions: (updater: SessionsUpdater) => void;
|
||||||
|
// 新增或替换某个会话
|
||||||
|
upsertSession: (session: ChatSession) => void;
|
||||||
|
// 按 ID 和类型移除会话
|
||||||
|
removeSessionById: (sessionId: number, type: ChatSession["type"]) => void;
|
||||||
|
// 清空所有会话(登出/切账号使用)
|
||||||
|
clearSessions: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,43 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import { Message, MessageState } from "./message.data";
|
import { ChatSession } from "@/utils/db";
|
||||||
|
import { Message, MessageState, SessionsUpdater } from "./message.data";
|
||||||
|
|
||||||
|
const computeSortKey = (session: ChatSession) => {
|
||||||
|
const isTop = session.config?.top ? 1 : 0;
|
||||||
|
const timestamp = new Date(session.lastUpdateTime || new Date()).getTime();
|
||||||
|
const displayName = (
|
||||||
|
session.conRemark ||
|
||||||
|
session.nickname ||
|
||||||
|
(session as any).wechatId ||
|
||||||
|
""
|
||||||
|
).toLowerCase();
|
||||||
|
|
||||||
|
return `${isTop}|${timestamp}|${displayName}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSessions = (sessions: ChatSession[]) => {
|
||||||
|
if (!Array.isArray(sessions)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...sessions]
|
||||||
|
.map(session => ({
|
||||||
|
...session,
|
||||||
|
sortKey: computeSortKey(session),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.sortKey.localeCompare(a.sortKey));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveUpdater = (
|
||||||
|
updater: SessionsUpdater,
|
||||||
|
previous: ChatSession[],
|
||||||
|
): ChatSession[] => {
|
||||||
|
if (typeof updater === "function") {
|
||||||
|
return updater(previous);
|
||||||
|
}
|
||||||
|
return updater;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 会话列表状态管理Store
|
* 会话列表状态管理Store
|
||||||
@@ -13,24 +50,18 @@ export const useMessageStore = create<MessageState>()(
|
|||||||
// ==================== 新增状态管理 ====================
|
// ==================== 新增状态管理 ====================
|
||||||
loading: false,
|
loading: false,
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
refreshTrigger: 0,
|
|
||||||
lastRefreshTime: null,
|
lastRefreshTime: null,
|
||||||
hasLoadedOnce: false,
|
hasLoadedOnce: false,
|
||||||
|
|
||||||
setLoading: (loading: boolean) => set({ loading }),
|
setLoading: (loading: boolean) => set({ loading }),
|
||||||
setRefreshing: (refreshing: boolean) => set({ refreshing }),
|
setRefreshing: (refreshing: boolean) => set({ refreshing }),
|
||||||
triggerRefresh: () =>
|
|
||||||
set({
|
|
||||||
refreshTrigger: get().refreshTrigger + 1,
|
|
||||||
lastRefreshTime: new Date().toISOString(),
|
|
||||||
}),
|
|
||||||
setHasLoadedOnce: (loaded: boolean) => set({ hasLoadedOnce: loaded }),
|
setHasLoadedOnce: (loaded: boolean) => set({ hasLoadedOnce: loaded }),
|
||||||
resetLoadState: () =>
|
resetLoadState: () =>
|
||||||
set({
|
set({
|
||||||
hasLoadedOnce: false,
|
hasLoadedOnce: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
refreshTrigger: 0,
|
sessions: [],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ==================== 保留原有接口(向后兼容) ====================
|
// ==================== 保留原有接口(向后兼容) ====================
|
||||||
@@ -45,6 +76,45 @@ export const useMessageStore = create<MessageState>()(
|
|||||||
message.id === messageId ? { ...message, status } : message,
|
message.id === messageId ? { ...message, status } : message,
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ==================== 会话数据接口 ====================
|
||||||
|
sessions: [],
|
||||||
|
setSessions: (updater: SessionsUpdater) =>
|
||||||
|
set(state => ({
|
||||||
|
sessions: normalizeSessions(resolveUpdater(updater, state.sessions)),
|
||||||
|
lastRefreshTime: new Date().toISOString(),
|
||||||
|
})),
|
||||||
|
upsertSession: (session: ChatSession) =>
|
||||||
|
set(state => {
|
||||||
|
const next = [...state.sessions];
|
||||||
|
const index = next.findIndex(
|
||||||
|
s => s.id === session.id && s.type === session.type,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (index > -1) {
|
||||||
|
next[index] = session;
|
||||||
|
} else {
|
||||||
|
next.push(session);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sessions: normalizeSessions(next),
|
||||||
|
lastRefreshTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
removeSessionById: (sessionId: number, type: ChatSession["type"]) =>
|
||||||
|
set(state => ({
|
||||||
|
sessions: normalizeSessions(
|
||||||
|
state.sessions.filter(
|
||||||
|
s => !(s.id === sessionId && s.type === type),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
lastRefreshTime: new Date().toISOString(),
|
||||||
|
})),
|
||||||
|
clearSessions: () =>
|
||||||
|
set({
|
||||||
|
sessions: [],
|
||||||
|
lastRefreshTime: new Date().toISOString(),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "message-storage",
|
name: "message-storage",
|
||||||
@@ -105,11 +175,6 @@ export const setLoading = (loading: boolean) =>
|
|||||||
export const setRefreshing = (refreshing: boolean) =>
|
export const setRefreshing = (refreshing: boolean) =>
|
||||||
useMessageStore.getState().setRefreshing(refreshing);
|
useMessageStore.getState().setRefreshing(refreshing);
|
||||||
|
|
||||||
/**
|
|
||||||
* 触发刷新(通知组件重新查询数据库)
|
|
||||||
*/
|
|
||||||
export const triggerRefresh = () => useMessageStore.getState().triggerRefresh();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置已加载标识
|
* 设置已加载标识
|
||||||
* @param loaded 是否已加载
|
* @param loaded 是否已加载
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export interface WeChatState {
|
|||||||
// ==================== 聊天消息管理 ====================
|
// ==================== 聊天消息管理 ====================
|
||||||
/** 当前聊天的消息列表 */
|
/** 当前聊天的消息列表 */
|
||||||
currentMessages: ChatRecord[];
|
currentMessages: ChatRecord[];
|
||||||
|
/** 当前聊天记录分页页码 */
|
||||||
|
currentMessagesPage: number;
|
||||||
|
/** 单页消息条数 */
|
||||||
|
currentMessagesPageSize: number;
|
||||||
|
/** 是否还有更多历史消息 */
|
||||||
|
currentMessagesHasMore: boolean;
|
||||||
/** 添加新消息 */
|
/** 添加新消息 */
|
||||||
addMessage: (message: ChatRecord) => void;
|
addMessage: (message: ChatRecord) => void;
|
||||||
/** 更新指定消息 */
|
/** 更新指定消息 */
|
||||||
@@ -83,7 +89,7 @@ export interface WeChatState {
|
|||||||
|
|
||||||
// ==================== 消息加载方法 ====================
|
// ==================== 消息加载方法 ====================
|
||||||
/** 加载聊天消息 */
|
/** 加载聊天消息 */
|
||||||
loadChatMessages: (Init: boolean, To?: number) => Promise<void>;
|
loadChatMessages: (Init: boolean, pageOverride?: number) => Promise<void>;
|
||||||
/** 搜索消息 */
|
/** 搜索消息 */
|
||||||
SearchMessage: (params: {
|
SearchMessage: (params: {
|
||||||
From: number;
|
From: number;
|
||||||
@@ -97,6 +103,11 @@ export interface WeChatState {
|
|||||||
setVideoLoading: (messageId: number, isLoading: boolean) => void;
|
setVideoLoading: (messageId: number, isLoading: boolean) => void;
|
||||||
/** 设置视频消息URL */
|
/** 设置视频消息URL */
|
||||||
setVideoUrl: (messageId: number, videoUrl: string) => void;
|
setVideoUrl: (messageId: number, videoUrl: string) => void;
|
||||||
|
// ==================== 文件消息处理 ====================
|
||||||
|
/** 设置文件消息下载状态 */
|
||||||
|
setFileDownloading: (messageId: number, isDownloading: boolean) => void;
|
||||||
|
/** 设置文件消息URL */
|
||||||
|
setFileDownloadUrl: (messageId: number, fileUrl: string) => void;
|
||||||
|
|
||||||
// ==================== 消息接收处理 ====================
|
// ==================== 消息接收处理 ====================
|
||||||
/** 接收新消息处理 */
|
/** 接收新消息处理 */
|
||||||
|
|||||||
@@ -27,6 +27,272 @@ let aiRequestTimer: NodeJS.Timeout | null = null;
|
|||||||
let pendingMessages: ChatRecord[] = []; // 待处理的消息队列
|
let pendingMessages: ChatRecord[] = []; // 待处理的消息队列
|
||||||
let currentAiGenerationId: string | null = null; // 当前AI生成的唯一ID
|
let currentAiGenerationId: string | null = null; // 当前AI生成的唯一ID
|
||||||
const AI_REQUEST_DELAY = 3000; // 3秒延迟
|
const AI_REQUEST_DELAY = 3000; // 3秒延迟
|
||||||
|
const FILE_MESSAGE_TYPE = "file";
|
||||||
|
const DEFAULT_MESSAGE_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
type FileMessagePayload = {
|
||||||
|
type?: string;
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
isDownloading?: boolean;
|
||||||
|
fileext?: string;
|
||||||
|
size?: number | string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isJsonLike = (value: string) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.startsWith("{") && trimmed.endsWith("}");
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseFileJsonContent = (
|
||||||
|
rawContent: unknown,
|
||||||
|
): FileMessagePayload | null => {
|
||||||
|
if (typeof rawContent !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = rawContent.trim();
|
||||||
|
if (!trimmed || !isJsonLike(trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed === "object" &&
|
||||||
|
parsed.type === FILE_MESSAGE_TYPE
|
||||||
|
) {
|
||||||
|
return parsed as FileMessagePayload;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("parseFileJsonContent failed:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractFileTitleFromContent = (rawContent: unknown): string => {
|
||||||
|
if (typeof rawContent !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = rawContent.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const cdataMatch =
|
||||||
|
trimmed.match(/<title><!\[CDATA\[(.*?)\]\]><\/title>/i) ||
|
||||||
|
trimmed.match(/"title"\s*:\s*"([^"]+)"/i);
|
||||||
|
if (cdataMatch?.[1]) {
|
||||||
|
return cdataMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const simpleMatch = trimmed.match(/<title>([^<]+)<\/title>/i);
|
||||||
|
if (simpleMatch?.[1]) {
|
||||||
|
return simpleMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFileLikeMessage = (msg: ChatRecord): boolean => {
|
||||||
|
if ((msg as any).fileDownloadMeta) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof msg.content === "string") {
|
||||||
|
const trimmed = msg.content.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
/"type"\s*:\s*"file"/i.test(trimmed) ||
|
||||||
|
/<appattach/i.test(trimmed) ||
|
||||||
|
/<fileext/i.test(trimmed)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeMessages = (source: any): ChatRecord[] => {
|
||||||
|
if (Array.isArray(source)) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
if (Array.isArray(source?.list)) {
|
||||||
|
return source.list;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseTimeValue = (value: unknown): number => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isNaN(numeric)) {
|
||||||
|
return numeric;
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.getTime();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageTimestamp = (msg: ChatRecord): number => {
|
||||||
|
const candidates = [
|
||||||
|
(msg as any)?.wechatTime,
|
||||||
|
(msg as any)?.createTime,
|
||||||
|
(msg as any)?.msgTime,
|
||||||
|
(msg as any)?.timestamp,
|
||||||
|
(msg as any)?.time,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const parsed = parseTimeValue(candidate);
|
||||||
|
if (parsed) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof msg.id === "number" ? msg.id : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortMessagesByTime = (messages: ChatRecord[]): ChatRecord[] => {
|
||||||
|
return [...messages].sort(
|
||||||
|
(a, b) => getMessageTimestamp(a) - getMessageTimestamp(b),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvePaginationState = (
|
||||||
|
source: any,
|
||||||
|
requestedPage: number,
|
||||||
|
requestedLimit: number,
|
||||||
|
listLength: number,
|
||||||
|
) => {
|
||||||
|
const page =
|
||||||
|
typeof source?.page === "number"
|
||||||
|
? source.page
|
||||||
|
: typeof source?.current === "number"
|
||||||
|
? source.current
|
||||||
|
: requestedPage;
|
||||||
|
|
||||||
|
const limit =
|
||||||
|
typeof source?.limit === "number"
|
||||||
|
? source.limit
|
||||||
|
: typeof source?.pageSize === "number"
|
||||||
|
? source.pageSize
|
||||||
|
: requestedLimit;
|
||||||
|
|
||||||
|
let hasMore: boolean;
|
||||||
|
if (typeof source?.hasNext === "boolean") {
|
||||||
|
hasMore = source.hasNext;
|
||||||
|
} else if (typeof source?.hasNextPage === "boolean") {
|
||||||
|
hasMore = source.hasNextPage;
|
||||||
|
} else if (typeof source?.pages === "number") {
|
||||||
|
hasMore = page < source.pages;
|
||||||
|
} else if (typeof source?.total === "number" && limit > 0) {
|
||||||
|
hasMore = page * limit < source.total;
|
||||||
|
} else {
|
||||||
|
hasMore = listLength >= limit && listLength > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listLength === 0) {
|
||||||
|
hasMore = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
limit: limit || requestedLimit || DEFAULT_MESSAGE_PAGE_SIZE,
|
||||||
|
hasMore,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeFilePayload = (
|
||||||
|
payload: FileMessagePayload | null | undefined,
|
||||||
|
msg: ChatRecord,
|
||||||
|
): FileMessagePayload => {
|
||||||
|
const fallbackTitle =
|
||||||
|
payload?.title ||
|
||||||
|
((msg as any).fileDownloadMeta &&
|
||||||
|
typeof (msg as any).fileDownloadMeta === "object"
|
||||||
|
? ((msg as any).fileDownloadMeta as FileMessagePayload).title
|
||||||
|
: undefined) ||
|
||||||
|
extractFileTitleFromContent(msg.content) ||
|
||||||
|
(msg as any).fileName ||
|
||||||
|
(msg as any).title ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: FILE_MESSAGE_TYPE,
|
||||||
|
...payload,
|
||||||
|
title: payload?.title ?? fallbackTitle ?? "",
|
||||||
|
isDownloading: payload?.isDownloading ?? false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFileMessageState = (
|
||||||
|
msg: ChatRecord,
|
||||||
|
updater: (payload: FileMessagePayload) => FileMessagePayload,
|
||||||
|
): ChatRecord => {
|
||||||
|
const parsedPayload = parseFileJsonContent(msg.content);
|
||||||
|
|
||||||
|
if (!parsedPayload && !isFileLikeMessage(msg)) {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePayload = parsedPayload
|
||||||
|
? normalizeFilePayload(parsedPayload, msg)
|
||||||
|
: normalizeFilePayload(
|
||||||
|
(msg as any).fileDownloadMeta as FileMessagePayload | undefined,
|
||||||
|
msg,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedPayload = updater(basePayload);
|
||||||
|
const sanitizedPayload: FileMessagePayload = {
|
||||||
|
...basePayload,
|
||||||
|
...updatedPayload,
|
||||||
|
type: FILE_MESSAGE_TYPE,
|
||||||
|
title:
|
||||||
|
updatedPayload.title ??
|
||||||
|
basePayload.title ??
|
||||||
|
extractFileTitleFromContent(msg.content) ??
|
||||||
|
"",
|
||||||
|
isDownloading:
|
||||||
|
updatedPayload.isDownloading ?? basePayload.isDownloading ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parsedPayload) {
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
content: JSON.stringify({
|
||||||
|
...parsedPayload,
|
||||||
|
...sanitizedPayload,
|
||||||
|
}),
|
||||||
|
fileDownloadMeta: sanitizedPayload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
fileDownloadMeta: sanitizedPayload,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清除AI请求定时器和队列
|
* 清除AI请求定时器和队列
|
||||||
@@ -185,6 +451,10 @@ export const useWeChatStore = create<WeChatState>()(
|
|||||||
currentContract: null,
|
currentContract: null,
|
||||||
/** 当前聊天的消息列表 */
|
/** 当前聊天的消息列表 */
|
||||||
currentMessages: [],
|
currentMessages: [],
|
||||||
|
/** 当前消息分页信息 */
|
||||||
|
currentMessagesPage: 1,
|
||||||
|
currentMessagesPageSize: DEFAULT_MESSAGE_PAGE_SIZE,
|
||||||
|
currentMessagesHasMore: true,
|
||||||
|
|
||||||
// ==================== 聊天消息管理方法 ====================
|
// ==================== 聊天消息管理方法 ====================
|
||||||
/** 添加新消息到当前聊天 */
|
/** 添加新消息到当前聊天 */
|
||||||
@@ -266,7 +536,13 @@ export const useWeChatStore = create<WeChatState>()(
|
|||||||
aiRequestTimer = null;
|
aiRequestTimer = null;
|
||||||
}
|
}
|
||||||
pendingMessages = [];
|
pendingMessages = [];
|
||||||
set({ currentContract: null, currentMessages: [] });
|
set({
|
||||||
|
currentContract: null,
|
||||||
|
currentMessages: [],
|
||||||
|
currentMessagesPage: 1,
|
||||||
|
currentMessagesHasMore: true,
|
||||||
|
currentMessagesPageSize: DEFAULT_MESSAGE_PAGE_SIZE,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
/** 设置当前联系人并加载相关数据 */
|
/** 设置当前联系人并加载相关数据 */
|
||||||
setCurrentContact: (contract: ContractData | weChatGroup) => {
|
setCurrentContact: (contract: ContractData | weChatGroup) => {
|
||||||
@@ -280,7 +556,13 @@ export const useWeChatStore = create<WeChatState>()(
|
|||||||
|
|
||||||
const state = useWeChatStore.getState();
|
const state = useWeChatStore.getState();
|
||||||
// 切换联系人时清空当前消息,等待重新加载
|
// 切换联系人时清空当前消息,等待重新加载
|
||||||
set({ currentMessages: [], isLoadingAiChat: false });
|
set({
|
||||||
|
currentMessages: [],
|
||||||
|
currentMessagesPage: 1,
|
||||||
|
currentMessagesHasMore: true,
|
||||||
|
currentMessagesPageSize: DEFAULT_MESSAGE_PAGE_SIZE,
|
||||||
|
isLoadingAiChat: false,
|
||||||
|
});
|
||||||
|
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
|
|
||||||
@@ -305,62 +587,91 @@ export const useWeChatStore = create<WeChatState>()(
|
|||||||
id: contract.id,
|
id: contract.id,
|
||||||
config: { chat: true },
|
config: { chat: true },
|
||||||
});
|
});
|
||||||
state.loadChatMessages(true, 4704624000000);
|
state.loadChatMessages(true);
|
||||||
},
|
},
|
||||||
|
|
||||||
// ==================== 消息加载方法 ====================
|
// ==================== 消息加载方法 ====================
|
||||||
/** 加载聊天消息 */
|
/** 加载聊天消息 */
|
||||||
loadChatMessages: async (Init: boolean, To?: number) => {
|
loadChatMessages: async (Init: boolean, pageOverride?: number) => {
|
||||||
const state = useWeChatStore.getState();
|
const state = useWeChatStore.getState();
|
||||||
const contact = state.currentContract;
|
const contact = state.currentContract;
|
||||||
set({ messagesLoading: true });
|
|
||||||
set({ isLoadingData: Init });
|
if (!contact) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Init && !state.currentMessagesHasMore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPage = Init
|
||||||
|
? 1
|
||||||
|
: (pageOverride ?? state.currentMessagesPage + 1);
|
||||||
|
const limit =
|
||||||
|
state.currentMessagesPageSize || DEFAULT_MESSAGE_PAGE_SIZE;
|
||||||
|
|
||||||
|
if (state.messagesLoading && !Init) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
messagesLoading: true,
|
||||||
|
isLoadingData: Init,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params: any = {
|
const params: any = {
|
||||||
wechatAccountId: contact.wechatAccountId,
|
wechatAccountId: contact.wechatAccountId,
|
||||||
From: 1,
|
page: nextPage,
|
||||||
To: To || +new Date(),
|
limit,
|
||||||
Count: 20,
|
|
||||||
olderData: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if ("chatroomId" in contact && contact.chatroomId) {
|
const isGroup =
|
||||||
// 群聊消息加载
|
"chatroomId" in contact && Boolean(contact.chatroomId);
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
params.wechatChatroomId = contact.id;
|
params.wechatChatroomId = contact.id;
|
||||||
const messages = await getChatroomMessages(params);
|
} else {
|
||||||
const currentGroupMembers = await getGroupMembers({
|
params.wechatFriendId = contact.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = isGroup
|
||||||
|
? await getChatroomMessages(params)
|
||||||
|
: await getChatMessages(params);
|
||||||
|
|
||||||
|
const normalizedMessages = normalizeMessages(response);
|
||||||
|
const sortedMessages = sortMessagesByTime(normalizedMessages);
|
||||||
|
const paginationMeta = resolvePaginationState(
|
||||||
|
response,
|
||||||
|
nextPage,
|
||||||
|
limit,
|
||||||
|
sortedMessages.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
let nextGroupMembers = state.currentGroupMembers;
|
||||||
|
if (Init && isGroup) {
|
||||||
|
nextGroupMembers = await getGroupMembers({
|
||||||
id: contact.id,
|
id: contact.id,
|
||||||
});
|
});
|
||||||
if (Init) {
|
|
||||||
set({ currentMessages: messages || [], currentGroupMembers });
|
|
||||||
} else {
|
|
||||||
set({
|
|
||||||
currentMessages: [
|
|
||||||
...(messages || []),
|
|
||||||
...state.currentMessages,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 私聊消息加载
|
|
||||||
params.wechatFriendId = contact.id;
|
|
||||||
const messages = await getChatMessages(params);
|
|
||||||
if (Init) {
|
|
||||||
set({ currentMessages: messages || [] });
|
|
||||||
} else {
|
|
||||||
set({
|
|
||||||
currentMessages: [
|
|
||||||
...(messages || []),
|
|
||||||
...state.currentMessages,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
set({ messagesLoading: false });
|
|
||||||
|
set(current => ({
|
||||||
|
currentMessages: Init
|
||||||
|
? sortedMessages
|
||||||
|
: [...sortedMessages, ...current.currentMessages],
|
||||||
|
currentGroupMembers:
|
||||||
|
Init && isGroup ? nextGroupMembers : current.currentGroupMembers,
|
||||||
|
currentMessagesPage: paginationMeta.page,
|
||||||
|
currentMessagesPageSize: paginationMeta.limit,
|
||||||
|
currentMessagesHasMore: paginationMeta.hasMore,
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取聊天消息失败:", error);
|
console.error("获取聊天消息失败:", error);
|
||||||
} finally {
|
} finally {
|
||||||
set({ messagesLoading: false });
|
set({
|
||||||
|
messagesLoading: false,
|
||||||
|
isLoadingData: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -383,11 +694,11 @@ export const useWeChatStore = create<WeChatState>()(
|
|||||||
try {
|
try {
|
||||||
const params: any = {
|
const params: any = {
|
||||||
wechatAccountId: contact.wechatAccountId,
|
wechatAccountId: contact.wechatAccountId,
|
||||||
|
keyword,
|
||||||
From,
|
From,
|
||||||
To,
|
To,
|
||||||
keyword,
|
page: 1,
|
||||||
Count,
|
limit: Count,
|
||||||
olderData: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if ("chatroomId" in contact && contact.chatroomId) {
|
if ("chatroomId" in contact && contact.chatroomId) {
|
||||||
@@ -397,12 +708,23 @@ export const useWeChatStore = create<WeChatState>()(
|
|||||||
const currentGroupMembers = await getGroupMembers({
|
const currentGroupMembers = await getGroupMembers({
|
||||||
id: contact.id,
|
id: contact.id,
|
||||||
});
|
});
|
||||||
set({ currentMessages: messages || [], currentGroupMembers });
|
set({
|
||||||
|
currentMessages: sortMessagesByTime(normalizeMessages(messages)),
|
||||||
|
currentGroupMembers,
|
||||||
|
currentMessagesPage: 1,
|
||||||
|
currentMessagesHasMore: false,
|
||||||
|
currentMessagesPageSize: Count || state.currentMessagesPageSize,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// 私聊消息搜索
|
// 私聊消息搜索
|
||||||
params.wechatFriendId = contact.id;
|
params.wechatFriendId = contact.id;
|
||||||
const messages = await getChatMessages(params);
|
const messages = await getChatMessages(params);
|
||||||
set({ currentMessages: messages || [] });
|
set({
|
||||||
|
currentMessages: sortMessagesByTime(normalizeMessages(messages)),
|
||||||
|
currentMessagesPage: 1,
|
||||||
|
currentMessagesHasMore: false,
|
||||||
|
currentMessagesPageSize: Count || state.currentMessagesPageSize,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
set({ messagesLoading: false });
|
set({ messagesLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -618,12 +940,59 @@ export const useWeChatStore = create<WeChatState>()(
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ==================== 文件消息处理方法 ====================
|
||||||
|
/** 更新文件消息下载状态 */
|
||||||
|
setFileDownloading: (messageId: number, isDownloading: boolean) => {
|
||||||
|
set(state => ({
|
||||||
|
currentMessages: state.currentMessages.map(msg => {
|
||||||
|
if (msg.id !== messageId) {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return updateFileMessageState(msg, payload => ({
|
||||||
|
...payload,
|
||||||
|
isDownloading,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("更新文件下载状态失败:", error);
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 更新文件消息URL */
|
||||||
|
setFileDownloadUrl: (messageId: number, fileUrl: string) => {
|
||||||
|
set(state => ({
|
||||||
|
currentMessages: state.currentMessages.map(msg => {
|
||||||
|
if (msg.id !== messageId) {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return updateFileMessageState(msg, payload => ({
|
||||||
|
...payload,
|
||||||
|
url: fileUrl,
|
||||||
|
isDownloading: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("更新文件URL失败:", error);
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
// ==================== 数据清理方法 ====================
|
// ==================== 数据清理方法 ====================
|
||||||
/** 清空所有数据 */
|
/** 清空所有数据 */
|
||||||
clearAllData: () => {
|
clearAllData: () => {
|
||||||
set({
|
set({
|
||||||
currentContract: null,
|
currentContract: null,
|
||||||
currentMessages: [],
|
currentMessages: [],
|
||||||
|
currentMessagesPage: 1,
|
||||||
|
currentMessagesHasMore: true,
|
||||||
|
currentMessagesPageSize: DEFAULT_MESSAGE_PAGE_SIZE,
|
||||||
messagesLoading: false,
|
messagesLoading: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const updateMessage = useWeChatStore.getState().updateMessage;
|
|||||||
const updateMomentCommonLoading =
|
const updateMomentCommonLoading =
|
||||||
useWeChatStore.getState().updateMomentCommonLoading;
|
useWeChatStore.getState().updateMomentCommonLoading;
|
||||||
const addMomentCommon = useWeChatStore.getState().addMomentCommon;
|
const addMomentCommon = useWeChatStore.getState().addMomentCommon;
|
||||||
|
const setFileDownloadUrl = useWeChatStore.getState().setFileDownloadUrl;
|
||||||
|
const setFileDownloading = useWeChatStore.getState().setFileDownloading;
|
||||||
// 消息处理器映射
|
// 消息处理器映射
|
||||||
const messageHandlers: Record<string, MessageHandler> = {
|
const messageHandlers: Record<string, MessageHandler> = {
|
||||||
// 微信账号存活状态响应
|
// 微信账号存活状态响应
|
||||||
@@ -104,6 +106,22 @@ const messageHandlers: Record<string, MessageHandler> = {
|
|||||||
console.log("视频下载结果:", message);
|
console.log("视频下载结果:", message);
|
||||||
// setVideoUrl(message.friendMessageId, message.url);
|
// setVideoUrl(message.friendMessageId, message.url);
|
||||||
},
|
},
|
||||||
|
CmdDownloadFileResult: message => {
|
||||||
|
const messageId = message.friendMessageId || message.chatroomMessageId;
|
||||||
|
|
||||||
|
if (!messageId) {
|
||||||
|
console.warn("文件下载结果缺少消息ID:", message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.url) {
|
||||||
|
console.warn("文件下载结果缺少URL:", message);
|
||||||
|
setFileDownloading(messageId, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFileDownloadUrl(messageId, message.url);
|
||||||
|
},
|
||||||
|
|
||||||
CmdFetchMomentResult: message => {
|
CmdFetchMomentResult: message => {
|
||||||
addMomentCommon(message.result);
|
addMomentCommon(message.result);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createPersistStore } from "@/store/createPersistStore";
|
import { createPersistStore } from "@/store/createPersistStore";
|
||||||
import { Toast } from "antd-mobile";
|
|
||||||
import { useUserStore } from "../user";
|
import { useUserStore } from "../user";
|
||||||
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
|
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
|
||||||
|
import { useCustomerStore } from "@/store/module/weChat/customer";
|
||||||
const { getAccountId } = useCkChatStore.getState();
|
const { getAccountId } = useCkChatStore.getState();
|
||||||
import { msgManageCore } from "./msgManage";
|
import { msgManageCore } from "./msgManage";
|
||||||
// WebSocket消息类型
|
// WebSocket消息类型
|
||||||
@@ -52,6 +52,8 @@ interface WebSocketState {
|
|||||||
reconnectAttempts: number;
|
reconnectAttempts: number;
|
||||||
reconnectTimer: NodeJS.Timeout | null;
|
reconnectTimer: NodeJS.Timeout | null;
|
||||||
aliveStatusTimer: NodeJS.Timeout | null; // 客服用户状态查询定时器
|
aliveStatusTimer: NodeJS.Timeout | null; // 客服用户状态查询定时器
|
||||||
|
aliveStatusUnsubscribe: (() => void) | null;
|
||||||
|
aliveStatusLastRequest: number | null;
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
connect: (config: Partial<WebSocketConfig>) => void;
|
connect: (config: Partial<WebSocketConfig>) => void;
|
||||||
@@ -87,6 +89,8 @@ const DEFAULT_CONFIG: WebSocketConfig = {
|
|||||||
maxReconnectAttempts: 5,
|
maxReconnectAttempts: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ALIVE_STATUS_MIN_INTERVAL = 5 * 1000; // ms
|
||||||
|
|
||||||
export const useWebSocketStore = createPersistStore<WebSocketState>(
|
export const useWebSocketStore = createPersistStore<WebSocketState>(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
status: WebSocketStatus.DISCONNECTED,
|
status: WebSocketStatus.DISCONNECTED,
|
||||||
@@ -97,6 +101,8 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
|
|||||||
reconnectAttempts: 0,
|
reconnectAttempts: 0,
|
||||||
reconnectTimer: null,
|
reconnectTimer: null,
|
||||||
aliveStatusTimer: null,
|
aliveStatusTimer: null,
|
||||||
|
aliveStatusUnsubscribe: null,
|
||||||
|
aliveStatusLastRequest: null,
|
||||||
|
|
||||||
// 连接WebSocket
|
// 连接WebSocket
|
||||||
connect: (config: Partial<WebSocketConfig>) => {
|
connect: (config: Partial<WebSocketConfig>) => {
|
||||||
@@ -232,11 +238,6 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
|
|||||||
currentState.status !== WebSocketStatus.CONNECTED ||
|
currentState.status !== WebSocketStatus.CONNECTED ||
|
||||||
!currentState.ws
|
!currentState.ws
|
||||||
) {
|
) {
|
||||||
// Toast.show({
|
|
||||||
// content: "WebSocket未连接,正在重新连接...",
|
|
||||||
// position: "top",
|
|
||||||
// });
|
|
||||||
|
|
||||||
// 重置连接状态并发起重新连接
|
// 重置连接状态并发起重新连接
|
||||||
set({ status: WebSocketStatus.DISCONNECTED });
|
set({ status: WebSocketStatus.DISCONNECTED });
|
||||||
if (currentState.config) {
|
if (currentState.config) {
|
||||||
@@ -392,7 +393,7 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
|
|||||||
|
|
||||||
set({
|
set({
|
||||||
messages: [...currentState.messages, newMessage],
|
messages: [...currentState.messages, newMessage],
|
||||||
unreadCount: currentState.config.unreadCount + 1,
|
unreadCount: (currentState.unreadCount ?? 0) + 1,
|
||||||
});
|
});
|
||||||
//消息处理器
|
//消息处理器
|
||||||
msgManageCore(data);
|
msgManageCore(data);
|
||||||
@@ -405,7 +406,7 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 内部方法:处理连接关闭
|
// 内部方法:处理连接关闭
|
||||||
_handleClose: (event: CloseEvent) => {
|
_handleClose: () => {
|
||||||
const currentState = get();
|
const currentState = get();
|
||||||
|
|
||||||
// console.log("WebSocket连接关闭:", event.code, event.reason);
|
// console.log("WebSocket连接关闭:", event.code, event.reason);
|
||||||
@@ -431,7 +432,7 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 内部方法:处理连接错误
|
// 内部方法:处理连接错误
|
||||||
_handleError: (event: Event) => {
|
_handleError: () => {
|
||||||
// console.error("WebSocket连接错误:", event);
|
// console.error("WebSocket连接错误:", event);
|
||||||
|
|
||||||
set({ status: WebSocketStatus.ERROR });
|
set({ status: WebSocketStatus.ERROR });
|
||||||
@@ -477,42 +478,97 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
|
|||||||
// 先停止现有定时器
|
// 先停止现有定时器
|
||||||
currentState._stopAliveStatusTimer();
|
currentState._stopAliveStatusTimer();
|
||||||
|
|
||||||
// 获取客服用户列表
|
const requestAliveStatus = () => {
|
||||||
const { kfUserList } = useCkChatStore.getState();
|
const state = get();
|
||||||
|
if (state.status !== WebSocketStatus.CONNECTED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 如果没有客服用户,不启动定时器
|
const now = Date.now();
|
||||||
if (!kfUserList || kfUserList.length === 0) {
|
if (
|
||||||
return;
|
state.aliveStatusLastRequest &&
|
||||||
}
|
now - state.aliveStatusLastRequest < ALIVE_STATUS_MIN_INTERVAL
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { customerList } = useCustomerStore.getState();
|
||||||
|
const { kfUserList } = useCkChatStore.getState();
|
||||||
|
const targets =
|
||||||
|
customerList && customerList.length > 0
|
||||||
|
? customerList
|
||||||
|
: kfUserList && kfUserList.length > 0
|
||||||
|
? kfUserList
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (targets.length > 0) {
|
||||||
|
state.sendCommand("CmdRequestWechatAccountsAliveStatus", {
|
||||||
|
wechatAccountIds: targets.map(v => v.id),
|
||||||
|
});
|
||||||
|
set({ aliveStatusLastRequest: now });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 尝试立即请求一次,如果客服列表尚未加载,后续定时器会继续检查
|
||||||
|
requestAliveStatus();
|
||||||
|
|
||||||
|
const unsubscribeCustomer = useCustomerStore.subscribe(state => {
|
||||||
|
if (
|
||||||
|
get().status === WebSocketStatus.CONNECTED &&
|
||||||
|
state.customerList &&
|
||||||
|
state.customerList.length > 0
|
||||||
|
) {
|
||||||
|
requestAliveStatus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubscribeKf = useCkChatStore.subscribe(state => {
|
||||||
|
if (
|
||||||
|
get().status === WebSocketStatus.CONNECTED &&
|
||||||
|
state.kfUserList &&
|
||||||
|
state.kfUserList.length > 0
|
||||||
|
) {
|
||||||
|
requestAliveStatus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 启动定时器,每5秒查询一次
|
// 启动定时器,每5秒查询一次
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
const state = get();
|
const state = get();
|
||||||
// 检查连接状态
|
// 检查连接状态
|
||||||
if (state.status === WebSocketStatus.CONNECTED) {
|
if (state.status === WebSocketStatus.CONNECTED) {
|
||||||
const { kfUserList: currentKfUserList } = useCkChatStore.getState();
|
requestAliveStatus();
|
||||||
if (currentKfUserList && currentKfUserList.length > 0) {
|
|
||||||
state.sendCommand("CmdRequestWechatAccountsAliveStatus", {
|
|
||||||
wechatAccountIds: currentKfUserList.map(v => v.id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 如果连接断开,停止定时器
|
// 如果连接断开,停止定时器
|
||||||
state._stopAliveStatusTimer();
|
state._stopAliveStatusTimer();
|
||||||
}
|
}
|
||||||
}, 5 * 1000);
|
}, 5 * 1000);
|
||||||
|
|
||||||
set({ aliveStatusTimer: timer });
|
set({
|
||||||
|
aliveStatusTimer: timer,
|
||||||
|
aliveStatusUnsubscribe: () => {
|
||||||
|
unsubscribeCustomer();
|
||||||
|
unsubscribeKf();
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// 内部方法:停止客服状态查询定时器
|
// 内部方法:停止客服状态查询定时器
|
||||||
_stopAliveStatusTimer: () => {
|
_stopAliveStatusTimer: () => {
|
||||||
const currentState = get();
|
const currentState = get();
|
||||||
|
|
||||||
|
if (currentState.aliveStatusUnsubscribe) {
|
||||||
|
currentState.aliveStatusUnsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
if (currentState.aliveStatusTimer) {
|
if (currentState.aliveStatusTimer) {
|
||||||
clearInterval(currentState.aliveStatusTimer);
|
clearInterval(currentState.aliveStatusTimer);
|
||||||
set({ aliveStatusTimer: null });
|
|
||||||
}
|
}
|
||||||
|
set({
|
||||||
|
aliveStatusTimer: null,
|
||||||
|
aliveStatusUnsubscribe: null,
|
||||||
|
aliveStatusLastRequest: null,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -524,6 +580,7 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
|
|||||||
messages: state.messages.slice(-100), // 只保留最近100条消息
|
messages: state.messages.slice(-100), // 只保留最近100条消息
|
||||||
unreadCount: state.unreadCount,
|
unreadCount: state.unreadCount,
|
||||||
reconnectAttempts: state.reconnectAttempts,
|
reconnectAttempts: state.reconnectAttempts,
|
||||||
|
aliveStatusLastRequest: state.aliveStatusLastRequest,
|
||||||
// 注意:定时器不需要持久化,重新连接时会重新创建
|
// 注意:定时器不需要持久化,重新连接时会重新创建
|
||||||
}),
|
}),
|
||||||
onRehydrateStorage: () => state => {
|
onRehydrateStorage: () => state => {
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ export const PERSIST_KEYS = {
|
|||||||
USER_STORE: "user-store",
|
USER_STORE: "user-store",
|
||||||
APP_STORE: "app-store",
|
APP_STORE: "app-store",
|
||||||
SETTINGS_STORE: "settings-store",
|
SETTINGS_STORE: "settings-store",
|
||||||
|
CKCHAT_STORE: "ckchat-store",
|
||||||
|
WEBSOCKET_STORE: "websocket-store",
|
||||||
|
WECHAT_STORAGE: "wechat-storage",
|
||||||
|
CONTACTS_STORAGE: "contacts-storage",
|
||||||
|
MESSAGE_STORAGE: "message-storage",
|
||||||
|
CUSTOMER_STORAGE: "customer-storage",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 存储类型
|
// 存储类型
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import Dexie, { Table } from "dexie";
|
import Dexie, { Table } from "dexie";
|
||||||
|
import { getPersistedData, PERSIST_KEYS } from "@/store/persistUtils";
|
||||||
|
const DB_NAME_PREFIX = "CunkebaoDatabase";
|
||||||
|
|
||||||
// ==================== 用户登录记录 ====================
|
// ==================== 用户登录记录 ====================
|
||||||
export interface UserLoginRecord {
|
export interface UserLoginRecord {
|
||||||
@@ -58,6 +60,9 @@ export interface ChatSession {
|
|||||||
chatroomOwner?: string; // 群主
|
chatroomOwner?: string; // 群主
|
||||||
selfDisplayName?: string; // 群内昵称
|
selfDisplayName?: string; // 群内昵称
|
||||||
notice?: string; // 群公告
|
notice?: string; // 群公告
|
||||||
|
phone?: string; // 联系人电话
|
||||||
|
region?: string; // 联系人地区
|
||||||
|
extendFields?: string; // 扩展字段(JSON 字符串)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 统一联系人表(兼容好友和群聊) ====================
|
// ==================== 统一联系人表(兼容好友和群聊) ====================
|
||||||
@@ -88,6 +93,7 @@ export interface Contact {
|
|||||||
signature?: string; // 个性签名
|
signature?: string; // 个性签名
|
||||||
phone?: string; // 手机号
|
phone?: string; // 手机号
|
||||||
quanPin?: string; // 全拼
|
quanPin?: string; // 全拼
|
||||||
|
extendFields?: string; // 扩展字段(JSON 字符串)
|
||||||
|
|
||||||
// 群聊特有字段(type='group'时有效)
|
// 群聊特有字段(type='group'时有效)
|
||||||
chatroomId?: string; // 群聊ID
|
chatroomId?: string; // 群聊ID
|
||||||
@@ -123,18 +129,17 @@ class CunkebaoDatabase extends Dexie {
|
|||||||
contactLabelMap!: Table<ContactLabelMap>; // 联系人标签映射表
|
contactLabelMap!: Table<ContactLabelMap>; // 联系人标签映射表
|
||||||
userLoginRecords!: Table<UserLoginRecord>; // 用户登录记录表
|
userLoginRecords!: Table<UserLoginRecord>; // 用户登录记录表
|
||||||
|
|
||||||
constructor() {
|
constructor(dbName: string) {
|
||||||
super("CunkebaoDatabase");
|
super(dbName);
|
||||||
|
|
||||||
// 版本1:统一表结构
|
|
||||||
this.version(1).stores({
|
this.version(1).stores({
|
||||||
// 会话表索引:支持按用户、类型、时间、置顶等查询
|
// 会话表索引:支持按用户、类型、时间、置顶等查询
|
||||||
chatSessions:
|
chatSessions:
|
||||||
"serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+lastUpdateTime], sortKey, nickname, conRemark, avatar, content, lastUpdateTime",
|
"serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+lastUpdateTime], [userId+aiType], sortKey, nickname, conRemark, avatar, content, lastUpdateTime, aiType, phone, region",
|
||||||
|
|
||||||
// 联系人表索引:支持按用户、类型、标签、搜索等查询
|
// 联系人表索引:支持按用户、类型、标签、搜索等查询
|
||||||
contactsUnified:
|
contactsUnified:
|
||||||
"serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], sortKey, searchKey, nickname, conRemark, avatar, lastUpdateTime, groupId",
|
"serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+aiType], sortKey, searchKey, nickname, conRemark, avatar, lastUpdateTime, groupId, aiType, phone, region",
|
||||||
|
|
||||||
// 联系人标签映射表索引:支持按用户、标签、联系人、类型查询
|
// 联系人标签映射表索引:支持按用户、标签、联系人、类型查询
|
||||||
contactLabelMap:
|
contactLabelMap:
|
||||||
@@ -145,68 +150,200 @@ class CunkebaoDatabase extends Dexie {
|
|||||||
"serverId, userId, lastLoginTime, loginCount, createTime, lastActiveTime",
|
"serverId, userId, lastLoginTime, loginCount, createTime, lastActiveTime",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 版本2:添加 aiType 字段
|
|
||||||
this.version(2)
|
this.version(2)
|
||||||
.stores({
|
.stores({
|
||||||
// 会话表索引:添加 aiType 索引
|
|
||||||
chatSessions:
|
chatSessions:
|
||||||
"serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+lastUpdateTime], [userId+aiType], sortKey, nickname, conRemark, avatar, content, lastUpdateTime, aiType",
|
"serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+lastUpdateTime], [userId+aiType], sortKey, nickname, conRemark, avatar, content, lastUpdateTime, aiType, phone, region, extendFields",
|
||||||
|
|
||||||
// 联系人表索引:添加 aiType 索引
|
|
||||||
contactsUnified:
|
contactsUnified:
|
||||||
"serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+aiType], sortKey, searchKey, nickname, conRemark, avatar, lastUpdateTime, groupId, aiType",
|
"serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+aiType], sortKey, searchKey, nickname, conRemark, avatar, lastUpdateTime, groupId, aiType, phone, region, extendFields",
|
||||||
|
|
||||||
// 联系人标签映射表索引:保持不变
|
|
||||||
contactLabelMap:
|
contactLabelMap:
|
||||||
"serverId, userId, labelId, contactId, contactType, [userId+labelId], [userId+contactId], [userId+labelId+sortKey], sortKey, searchKey, avatar, nickname, conRemark, unreadCount, lastUpdateTime",
|
"serverId, userId, labelId, contactId, contactType, [userId+labelId], [userId+contactId], [userId+labelId+sortKey], sortKey, searchKey, avatar, nickname, conRemark, unreadCount, lastUpdateTime",
|
||||||
|
|
||||||
// 用户登录记录表索引:保持不变
|
|
||||||
userLoginRecords:
|
userLoginRecords:
|
||||||
"serverId, userId, lastLoginTime, loginCount, createTime, lastActiveTime",
|
"serverId, userId, lastLoginTime, loginCount, createTime, lastActiveTime",
|
||||||
})
|
})
|
||||||
.upgrade(tx => {
|
.upgrade(async tx => {
|
||||||
// 数据迁移:为现有数据添加 aiType 默认值
|
await tx
|
||||||
return tx
|
|
||||||
.table("chatSessions")
|
.table("chatSessions")
|
||||||
.toCollection()
|
.toCollection()
|
||||||
.modify(session => {
|
.modify(session => {
|
||||||
if (session.aiType === undefined) {
|
if (!("extendFields" in session) || session.extendFields == null) {
|
||||||
session.aiType = 0; // 默认为普通类型
|
session.extendFields = "{}";
|
||||||
|
} else if (typeof session.extendFields !== "string") {
|
||||||
|
session.extendFields = JSON.stringify(session.extendFields);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.table("contactsUnified")
|
||||||
|
.toCollection()
|
||||||
|
.modify(contact => {
|
||||||
|
if (!("extendFields" in contact) || contact.extendFields == null) {
|
||||||
|
contact.extendFields = "{}";
|
||||||
|
} else if (typeof contact.extendFields !== "string") {
|
||||||
|
contact.extendFields = JSON.stringify(contact.extendFields);
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
return tx
|
|
||||||
.table("contactsUnified")
|
|
||||||
.toCollection()
|
|
||||||
.modify(contact => {
|
|
||||||
if (contact.aiType === undefined) {
|
|
||||||
contact.aiType = 0; // 默认为普通类型
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建数据库实例
|
class DatabaseManager {
|
||||||
export const db = new CunkebaoDatabase();
|
private currentDb: CunkebaoDatabase | null = null;
|
||||||
|
private currentUserId: number | null = null;
|
||||||
|
|
||||||
|
private getDatabaseName(userId: number) {
|
||||||
|
return `${DB_NAME_PREFIX}_${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openDatabase(dbName: string) {
|
||||||
|
const instance = new CunkebaoDatabase(dbName);
|
||||||
|
await instance.open();
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureDatabase(userId: number) {
|
||||||
|
if (userId === undefined || userId === null) {
|
||||||
|
throw new Error("Invalid userId provided for database initialization");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.currentDb &&
|
||||||
|
this.currentUserId === userId &&
|
||||||
|
this.currentDb.isOpen()
|
||||||
|
) {
|
||||||
|
return this.currentDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.closeCurrentDatabase();
|
||||||
|
|
||||||
|
const dbName = this.getDatabaseName(userId);
|
||||||
|
this.currentDb = await this.openDatabase(dbName);
|
||||||
|
this.currentUserId = userId;
|
||||||
|
|
||||||
|
return this.currentDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentDatabase(): CunkebaoDatabase {
|
||||||
|
if (!this.currentDb) {
|
||||||
|
throw new Error("Database has not been initialized for the current user");
|
||||||
|
}
|
||||||
|
return this.currentDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentUserId() {
|
||||||
|
return this.currentUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
isInitialized(): boolean {
|
||||||
|
return !!this.currentDb && this.currentDb.isOpen();
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeCurrentDatabase() {
|
||||||
|
if (this.currentDb) {
|
||||||
|
try {
|
||||||
|
this.currentDb.close();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to close current database:", error);
|
||||||
|
}
|
||||||
|
this.currentDb = null;
|
||||||
|
}
|
||||||
|
this.currentUserId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const databaseManager = new DatabaseManager();
|
||||||
|
|
||||||
|
let pendingDatabaseRestore: Promise<CunkebaoDatabase | null> | null = null;
|
||||||
|
|
||||||
|
async function restoreDatabaseFromPersistedState() {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistedData = getPersistedData<string | Record<string, any>>(
|
||||||
|
PERSIST_KEYS.USER_STORE,
|
||||||
|
"localStorage",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!persistedData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: any = persistedData;
|
||||||
|
|
||||||
|
if (typeof persistedData === "string") {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(persistedData);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to parse persisted user-store value:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = parsed?.state ?? parsed;
|
||||||
|
const userId = state?.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await databaseManager.ensureDatabase(userId);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to initialize database from persisted user:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeDatabaseFromPersistedUser() {
|
||||||
|
if (databaseManager.isInitialized()) {
|
||||||
|
return databaseManager.getCurrentDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingDatabaseRestore) {
|
||||||
|
pendingDatabaseRestore = restoreDatabaseFromPersistedState().finally(() => {
|
||||||
|
pendingDatabaseRestore = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return pendingDatabaseRestore;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbProxy = new Proxy({} as CunkebaoDatabase, {
|
||||||
|
get(_target, prop: string | symbol) {
|
||||||
|
const currentDb = databaseManager.getCurrentDatabase();
|
||||||
|
const value = (currentDb as any)[prop];
|
||||||
|
if (typeof value === "function") {
|
||||||
|
return value.bind(currentDb);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const db = dbProxy;
|
||||||
|
|
||||||
// 简单的数据库操作类
|
// 简单的数据库操作类
|
||||||
export class DatabaseService<T> {
|
export class DatabaseService<T> {
|
||||||
constructor(private table: Table<T>) {}
|
constructor(private readonly tableAccessor: () => Table<T>) {}
|
||||||
|
|
||||||
|
private get table(): Table<T> {
|
||||||
|
return this.tableAccessor();
|
||||||
|
}
|
||||||
|
|
||||||
// 基础 CRUD 操作 - 使用serverId作为主键
|
// 基础 CRUD 操作 - 使用serverId作为主键
|
||||||
async create(data: Omit<T, "serverId">): Promise<string | number> {
|
async create(data: Omit<T, "serverId">): Promise<string | number> {
|
||||||
return await this.table.add(data as T);
|
return await this.table.add(this.prepareDataForWrite(data) as T);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建数据(直接使用接口数据)
|
// 创建数据(直接使用接口数据)
|
||||||
// 接口数据的id字段直接作为serverId主键,原id字段保留
|
// 接口数据的id字段直接作为serverId主键,原id字段保留
|
||||||
async createWithServerId(data: any): Promise<string | number> {
|
async createWithServerId(data: any): Promise<string | number> {
|
||||||
const dataToInsert = {
|
const dataToInsert = this.prepareDataForWrite({
|
||||||
...data,
|
...data,
|
||||||
serverId: data.id, // 使用接口的id作为serverId主键
|
serverId: data.id, // 使用接口的id作为serverId主键
|
||||||
};
|
phone: data.phone ?? "",
|
||||||
|
region: data.region ?? "",
|
||||||
|
});
|
||||||
return await this.table.add(dataToInsert as T);
|
return await this.table.add(dataToInsert as T);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +362,10 @@ export class DatabaseService<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(serverId: string | number, data: Partial<T>): Promise<number> {
|
async update(serverId: string | number, data: Partial<T>): Promise<number> {
|
||||||
return await this.table.update(serverId, data as any);
|
return await this.table.update(
|
||||||
|
serverId,
|
||||||
|
this.prepareDataForWrite(data) as any,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMany(
|
async updateMany(
|
||||||
@@ -234,7 +374,7 @@ export class DatabaseService<T> {
|
|||||||
return await this.table.bulkUpdate(
|
return await this.table.bulkUpdate(
|
||||||
dataList.map(item => ({
|
dataList.map(item => ({
|
||||||
key: item.serverId,
|
key: item.serverId,
|
||||||
changes: item.data as any,
|
changes: this.prepareDataForWrite(item.data) as any,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -242,7 +382,8 @@ export class DatabaseService<T> {
|
|||||||
async createMany(
|
async createMany(
|
||||||
dataList: Omit<T, "serverId">[],
|
dataList: Omit<T, "serverId">[],
|
||||||
): Promise<(string | number)[]> {
|
): Promise<(string | number)[]> {
|
||||||
return await this.table.bulkAdd(dataList as T[], { allKeys: true });
|
const processed = dataList.map(item => this.prepareDataForWrite(item));
|
||||||
|
return await this.table.bulkAdd(processed as T[], { allKeys: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量创建数据(直接使用接口数据)
|
// 批量创建数据(直接使用接口数据)
|
||||||
@@ -266,10 +407,14 @@ export class DatabaseService<T> {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const processedData = newData.map(item => ({
|
const processedData = newData.map(item =>
|
||||||
...item,
|
this.prepareDataForWrite({
|
||||||
serverId: item.id, // 使用接口的id作为serverId主键
|
...item,
|
||||||
}));
|
serverId: item.id, // 使用接口的id作为serverId主键
|
||||||
|
phone: item.phone ?? "",
|
||||||
|
region: item.region ?? "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return await this.table.bulkAdd(processedData as T[], { allKeys: true });
|
return await this.table.bulkAdd(processedData as T[], { allKeys: true });
|
||||||
}
|
}
|
||||||
@@ -443,13 +588,42 @@ export class DatabaseService<T> {
|
|||||||
.equals(value)
|
.equals(value)
|
||||||
.count();
|
.count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private prepareDataForWrite(data: any) {
|
||||||
|
if (!data || typeof data !== "object") {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepared = { ...data };
|
||||||
|
|
||||||
|
if ("extendFields" in prepared) {
|
||||||
|
const value = prepared.extendFields;
|
||||||
|
if (typeof value === "string" && value.trim() !== "") {
|
||||||
|
prepared.extendFields = value;
|
||||||
|
} else if (value && typeof value === "object") {
|
||||||
|
prepared.extendFields = JSON.stringify(value);
|
||||||
|
} else {
|
||||||
|
prepared.extendFields = "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prepared;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建统一表的服务实例
|
// 创建统一表的服务实例
|
||||||
export const chatSessionService = new DatabaseService(db.chatSessions);
|
export const chatSessionService = new DatabaseService<ChatSession>(
|
||||||
export const contactUnifiedService = new DatabaseService(db.contactsUnified);
|
() => databaseManager.getCurrentDatabase().chatSessions,
|
||||||
export const contactLabelMapService = new DatabaseService(db.contactLabelMap);
|
);
|
||||||
export const userLoginRecordService = new DatabaseService(db.userLoginRecords);
|
export const contactUnifiedService = new DatabaseService<Contact>(
|
||||||
|
() => databaseManager.getCurrentDatabase().contactsUnified,
|
||||||
|
);
|
||||||
|
export const contactLabelMapService = new DatabaseService<ContactLabelMap>(
|
||||||
|
() => databaseManager.getCurrentDatabase().contactLabelMap,
|
||||||
|
);
|
||||||
|
export const userLoginRecordService = new DatabaseService<UserLoginRecord>(
|
||||||
|
() => databaseManager.getCurrentDatabase().userLoginRecords,
|
||||||
|
);
|
||||||
|
|
||||||
// 默认导出数据库实例
|
// 默认导出数据库实例
|
||||||
export default db;
|
export default db;
|
||||||
|
|||||||
@@ -184,7 +184,10 @@ export class ContactManager {
|
|||||||
local.conRemark !== server.conRemark ||
|
local.conRemark !== server.conRemark ||
|
||||||
local.avatar !== server.avatar ||
|
local.avatar !== server.avatar ||
|
||||||
local.wechatAccountId !== server.wechatAccountId ||
|
local.wechatAccountId !== server.wechatAccountId ||
|
||||||
(local.aiType ?? 0) !== (server.aiType ?? 0) // 添加 aiType 比较
|
(local.aiType ?? 0) !== (server.aiType ?? 0) || // 添加 aiType 比较
|
||||||
|
(local.phone ?? "") !== (server.phone ?? "") ||
|
||||||
|
(local.region ?? "") !== (server.region ?? "") ||
|
||||||
|
(local.extendFields ?? "{}") !== (server.extendFields ?? "{}")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,10 +195,12 @@ export class ContactManager {
|
|||||||
* 获取联系人分组列表
|
* 获取联系人分组列表
|
||||||
*/
|
*/
|
||||||
static async getContactGroups(
|
static async getContactGroups(
|
||||||
userId: number,
|
_userId: number,
|
||||||
customerId?: number,
|
_customerId?: number,
|
||||||
): Promise<ContactGroupByLabel[]> {
|
): Promise<ContactGroupByLabel[]> {
|
||||||
try {
|
try {
|
||||||
|
void _userId;
|
||||||
|
void _customerId;
|
||||||
// 这里应该根据实际的标签系统来实现
|
// 这里应该根据实际的标签系统来实现
|
||||||
// 暂时返回空数组,实际实现需要根据标签表来查询
|
// 暂时返回空数组,实际实现需要根据标签表来查询
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -7,11 +7,33 @@
|
|||||||
* 4. 提供回调机制通知组件更新
|
* 4. 提供回调机制通知组件更新
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import Dexie from "dexie";
|
||||||
import { db, chatSessionService, ChatSession } from "../db";
|
import { db, chatSessionService, ChatSession } from "../db";
|
||||||
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||||
|
|
||||||
|
const serializeExtendFields = (value: any) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.trim() ? value : "{}";
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("序列化 extendFields 失败:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "{}";
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SessionUpdatePayload {
|
||||||
|
userId: number;
|
||||||
|
sessions: ChatSession[];
|
||||||
|
}
|
||||||
|
|
||||||
export class MessageManager {
|
export class MessageManager {
|
||||||
private static updateCallbacks = new Set<(sessions: ChatSession[]) => void>();
|
private static updateCallbacks = new Set<
|
||||||
|
(payload: SessionUpdatePayload) => void
|
||||||
|
>();
|
||||||
|
|
||||||
// ==================== 回调管理 ====================
|
// ==================== 回调管理 ====================
|
||||||
|
|
||||||
@@ -20,9 +42,11 @@ export class MessageManager {
|
|||||||
* @param callback 回调函数
|
* @param callback 回调函数
|
||||||
* @returns 取消注册的函数
|
* @returns 取消注册的函数
|
||||||
*/
|
*/
|
||||||
static onSessionsUpdate(callback: (sessions: ChatSession[]) => void) {
|
static onSessionsUpdate(callback: (payload: SessionUpdatePayload) => void) {
|
||||||
this.updateCallbacks.add(callback);
|
this.updateCallbacks.add(callback);
|
||||||
return () => this.updateCallbacks.delete(callback);
|
return () => {
|
||||||
|
this.updateCallbacks.delete(callback);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,7 +58,7 @@ export class MessageManager {
|
|||||||
const sessions = await this.getUserSessions(userId);
|
const sessions = await this.getUserSessions(userId);
|
||||||
this.updateCallbacks.forEach(callback => {
|
this.updateCallbacks.forEach(callback => {
|
||||||
try {
|
try {
|
||||||
callback(sessions);
|
callback({ userId, sessions });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("会话更新回调执行失败:", error);
|
console.error("会话更新回调执行失败:", error);
|
||||||
}
|
}
|
||||||
@@ -92,6 +116,8 @@ export class MessageManager {
|
|||||||
content: (friend as any).content || "",
|
content: (friend as any).content || "",
|
||||||
lastUpdateTime: friend.lastUpdateTime || new Date().toISOString(),
|
lastUpdateTime: friend.lastUpdateTime || new Date().toISOString(),
|
||||||
aiType: (friend as any).aiType ?? 0, // AI类型,默认为0(普通)
|
aiType: (friend as any).aiType ?? 0, // AI类型,默认为0(普通)
|
||||||
|
phone: (friend as any).phone ?? "",
|
||||||
|
region: (friend as any).region ?? "",
|
||||||
config: {
|
config: {
|
||||||
unreadCount: friend.config?.unreadCount || 0,
|
unreadCount: friend.config?.unreadCount || 0,
|
||||||
top: (friend.config as any)?.top || false,
|
top: (friend.config as any)?.top || false,
|
||||||
@@ -100,6 +126,7 @@ export class MessageManager {
|
|||||||
wechatFriendId: friend.id,
|
wechatFriendId: friend.id,
|
||||||
wechatId: friend.wechatId,
|
wechatId: friend.wechatId,
|
||||||
alias: friend.alias,
|
alias: friend.alias,
|
||||||
|
extendFields: serializeExtendFields((friend as any).extendFields),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +152,8 @@ export class MessageManager {
|
|||||||
content: (group as any).content || "",
|
content: (group as any).content || "",
|
||||||
lastUpdateTime: (group as any).lastUpdateTime || new Date().toISOString(),
|
lastUpdateTime: (group as any).lastUpdateTime || new Date().toISOString(),
|
||||||
aiType: (group as any).aiType ?? 0, // AI类型,默认为0(普通)
|
aiType: (group as any).aiType ?? 0, // AI类型,默认为0(普通)
|
||||||
|
phone: (group as any).phone ?? "",
|
||||||
|
region: (group as any).region ?? "",
|
||||||
config: {
|
config: {
|
||||||
unreadCount: (group.config as any)?.unreadCount || 0,
|
unreadCount: (group.config as any)?.unreadCount || 0,
|
||||||
top: (group.config as any)?.top || false,
|
top: (group.config as any)?.top || false,
|
||||||
@@ -134,6 +163,7 @@ export class MessageManager {
|
|||||||
chatroomOwner: group.chatroomOwner,
|
chatroomOwner: group.chatroomOwner,
|
||||||
selfDisplayName: group.selfDisplyName,
|
selfDisplayName: group.selfDisplyName,
|
||||||
notice: group.notice,
|
notice: group.notice,
|
||||||
|
extendFields: serializeExtendFields((group as any).extendFields),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +228,9 @@ export class MessageManager {
|
|||||||
"avatar",
|
"avatar",
|
||||||
"wechatAccountId", // 添加wechatAccountId比较
|
"wechatAccountId", // 添加wechatAccountId比较
|
||||||
"aiType", // 添加aiType比较
|
"aiType", // 添加aiType比较
|
||||||
|
"phone",
|
||||||
|
"region",
|
||||||
|
"extendFields",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const field of fieldsToCompare) {
|
for (const field of fieldsToCompare) {
|
||||||
@@ -243,7 +276,9 @@ export class MessageManager {
|
|||||||
"userId",
|
"userId",
|
||||||
userId,
|
userId,
|
||||||
)) as ChatSession[];
|
)) as ChatSession[];
|
||||||
const localSessionMap = new Map(localSessions.map(s => [s.id, s]));
|
const localSessionMap = new Map(
|
||||||
|
localSessions.map(session => [session.serverId, session]),
|
||||||
|
);
|
||||||
|
|
||||||
// 2. 转换服务器数据为统一格式
|
// 2. 转换服务器数据为统一格式
|
||||||
const serverSessions: ChatSession[] = [];
|
const serverSessions: ChatSession[] = [];
|
||||||
@@ -264,16 +299,18 @@ export class MessageManager {
|
|||||||
serverSessions.push(...groups);
|
serverSessions.push(...groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverSessionMap = new Map(serverSessions.map(s => [s.id, s]));
|
const serverSessionMap = new Map(
|
||||||
|
serverSessions.map(session => [session.serverId, session]),
|
||||||
|
);
|
||||||
|
|
||||||
// 3. 计算差异
|
// 3. 计算差异
|
||||||
const toAdd: ChatSession[] = [];
|
const toAdd: ChatSession[] = [];
|
||||||
const toUpdate: ChatSession[] = [];
|
const toUpdate: ChatSession[] = [];
|
||||||
const toDelete: number[] = [];
|
const toDelete: string[] = [];
|
||||||
|
|
||||||
// 检查新增和更新
|
// 检查新增和更新
|
||||||
for (const serverSession of serverSessions) {
|
for (const serverSession of serverSessions) {
|
||||||
const localSession = localSessionMap.get(serverSession.id);
|
const localSession = localSessionMap.get(serverSession.serverId);
|
||||||
|
|
||||||
if (!localSession) {
|
if (!localSession) {
|
||||||
toAdd.push(serverSession);
|
toAdd.push(serverSession);
|
||||||
@@ -286,8 +323,8 @@ export class MessageManager {
|
|||||||
|
|
||||||
// 检查删除
|
// 检查删除
|
||||||
for (const localSession of localSessions) {
|
for (const localSession of localSessions) {
|
||||||
if (!serverSessionMap.has(localSession.id)) {
|
if (!serverSessionMap.has(localSession.serverId)) {
|
||||||
toDelete.push(localSession.id);
|
toDelete.push(localSession.serverId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +371,19 @@ export class MessageManager {
|
|||||||
serverId: `${session.type}_${session.id}`,
|
serverId: `${session.type}_${session.id}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await db.chatSessions.bulkAdd(dataToInsert);
|
try {
|
||||||
|
await db.chatSessions.bulkAdd(dataToInsert);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Dexie.BulkError) {
|
||||||
|
console.warn(
|
||||||
|
`批量新增会话时检测到重复主键,切换为 bulkPut 以覆盖更新。错误详情:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
await db.chatSessions.bulkPut(dataToInsert);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -357,14 +406,16 @@ export class MessageManager {
|
|||||||
*/
|
*/
|
||||||
private static async batchDeleteSessions(
|
private static async batchDeleteSessions(
|
||||||
userId: number,
|
userId: number,
|
||||||
sessionIds: number[],
|
serverIds: string[],
|
||||||
) {
|
) {
|
||||||
if (sessionIds.length === 0) return;
|
if (serverIds.length === 0) return;
|
||||||
|
|
||||||
|
const serverIdSet = new Set(serverIds);
|
||||||
|
|
||||||
await db.chatSessions
|
await db.chatSessions
|
||||||
.where("userId")
|
.where("userId")
|
||||||
.equals(userId)
|
.equals(userId)
|
||||||
.and(session => sessionIds.includes(session.id))
|
.and(session => serverIdSet.has(session.serverId))
|
||||||
.delete();
|
.delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user